0%

Chapter 7 · practice

One Server, Many Clients

Give Every Message a Shape

The echo service of Chapter 6 did not care what a message meant. Whatever text a client sent came straight back, and each client talked to the server alone. This chapter builds something different: a scoreboard for a quiz night. One scorekeeper sends scores, and every viewer watching on a screen must see the same board. Before any of that works, the server and its clients need to agree on what a message looks like.

One envelope per frame

Each message travels as one text , and the text inside is , the format you used in Python in Practice. The JSON is always an with a "type" field, which tells the reader how to read the rest. This kind of agreed shape is often called an envelope: the type is written on the outside, and the fields inside depend on it.

The scoreboard has three :

{"type": "snapshot", "version": 3, "scores": {"Owls": 4, "Foxes": 2}}
{"type": "score", "team": "Owls", "points": 3}
{"type": "error", "version": 3, "reason": "that team is not playing tonight"}

A snapshot is the whole board: every team’s points. A score is the scorekeeper asking the server to add points to one team. An error says that something went wrong; this chapter defines it, and Chapter 9 is where the server starts sending it.

The server tells a new viewer every team's points. Which type does it send?

Versions number the board

A snapshot and an error carry a version, a whole number that starts at 0 for the empty board and goes up by one with every change. A snapshot’s version says which state of the board it shows, so a client that receives version 6 after version 5 knows it has the newer board. Versions only go up.

A score has no version. Only the server changes the board, so only the server can say which state a message describes. The scorekeeper asks for a change; the server makes it and numbers the result. That is this course’s rule for its protocol, not a rule of RFC 6455, which says nothing about what your messages contain.

Why does a score message carry no version?

The full contract, field by field:

  • snapshot: version, a whole number of 0 or more, and scores, an object whose every is a whole number;

  • score: team, a that is not blank, and points, a whole number from 1 to 10 (MIN_POINTS and MAX_POINTS at the top of scoreboard_messages.py);

  • error: version, as for a snapshot, and reason, a string.

Any other type is refused, and so is anything that is not an object, such as a JSON . Extra keys are ignored on the way in. That is a choice this course makes so the protocol can grow: a later version can add a field without breaking a client that does not know it yet. Whether a team is actually playing is not part of the envelope check; only the board knows its teams, and Lesson 4 checks that.

True is an int

“A whole number” sounds like isinstance(points, int). It is not quite. In Python, bool is a kind of int, so True passes that check, and JSON’s true arrives in Python as True. A scorekeeper that sent "points": true would score one point by accident.

What does this print?

print(isinstance(True, int), type(True) is int)

So is_whole_number(value) returns True only for an int that is not a bool. Two isinstance checks joined with and not say exactly that. Use it for points, versions and every score in a snapshot. A float such as 2.0 and a string such as "3" are not whole numbers either, and is_whole_number already says so. Check the type before you compare: "3" < 1 raises , not ValueError.

Check on the way out, check on the way in

check_message(message) holds the whole contract. It returns nothing when the envelope is valid and raises ValueError with a short reason when it is not. Read the fields with message.get(...), which gives None for a missing key, so a missing team fails the same check as a blank one. For a blank team, team.strip() == "" is the test.

Two use it. encode_message(message) checks before it encodes, so the server can never send a broken envelope, and returns json.dumps(message, ensure_ascii=False). Without ensure_ascii=False, json.dumps writes a team called Café as Caf\u00e9; both are valid JSON, and the readable form is easier to check in a transcript.

parse_message(data) works the other way. data is what the library’s recv returned. A text frame gives a str, a binary frame gives bytes, and the scoreboard only speaks text. Refuse anything that is not a str first, with ValueError. That check is yours alone: json.loads also accepts bytes and would happily read a binary frame. Then call json.loads. Text that is not JSON makes it raise json.JSONDecodeError, which is a kind of ValueError, so you can let it escape as it is. Finally check the envelope and return it.

Every refusal is a ValueError, whatever the reason. The scorekeeper’s handler in Lesson 6 will catch that one exception and close the connection.

Run

Press Run. scoreboard_demo.py prints one section per lesson; until a lesson’s functions are written, its section says not written yet. A finished version, run on the site, printed this for Lesson 1:

Lesson 1: two envelopes and one refusal
    encoded: {"type": "snapshot", "version": 3, "scores": {"Owls": 4, "Café Quiz": 2}}
    parsed:  {'type': 'score', 'team': 'Owls', 'points': 3}
    ValueError: points must be a whole number from 1 to 10

The first line is the text one frame would carry. The second is the parse_message returned. The third is the refusal of "points": true; its wording is your own, so yours may differ. Then press Submit.

The envelopes exist, and nothing sends them yet. In the next lesson a viewer connects and receives its first snapshot.

Task

In scoreboard_messages.py, write the four , in this order. Submit names the first one that still raises NotImplementedError.

  1. is_whole_number(value): True for an int that is not a bool, and False for anything else.

  2. check_message(message): return nothing for a valid envelope and raise for anything else. A snapshot needs a whole-number version of 0 or more and a scores whose values are all whole numbers. An error needs the same version and a reason that is a . A score needs a team string that is not blank and whole-number points from MIN_POINTS to MAX_POINTS. Refuse any other type and anything that is not a dict. Ignore extra keys.

  3. encode_message(message): call check_message, then return json.dumps(message, ensure_ascii=False).

  4. parse_message(data): raise ValueError unless data is a str; then json.loads it, call check_message on the result, and return it.

Press Run and read the Lesson 1 section, then press Submit. The check calls your functions with envelopes the Run output does not show, such as a snapshot with a team called Café Quiz at versions 0 and 41, and scores of 1 and 10 points for a team called Bärchen. It expects each to be encoded as text that json.loads turns back into the same envelope, and parsed back unchanged, extra keys included. It sends fifteen broken envelopes through both encode_message and parse_message, among them points of 0, 11, true, 2.0 and "3", a blank team, a version of -1 or true, a snapshot score of false, an error with no reason, an unknown type and a , and expects ValueError from each. parse_message must also refuse a binary ’s bytes, "not json" and the empty string. The text of each ValueError is yours to choose, and type(value) is int is as good as two isinstance checks.