0%

Capstone: A Live Scoreboard · capstone

Capstone project: Finish the Client CLI

Until now you have stood on the server’s side of the scoreboard, deciding what to send, when to refuse and how to close. This lesson stands you on the other side of your own protocol. Everything the server decides reaches a client as a message, a or an , and a client has to turn each of them into an exit status that a person, or a script, can act on.

scoreboard_client.py is half written. client_parser() declares the two commands, watch and score, and their options. The supplied main(argv=None, *, connect=connect) parses them, refuses a bad command line with parser.error (exit status 2: --points outside 1 to 10, a blank --team, a --wait out of ), reads the token file for score and returns 1 with one line on standard error if it cannot, and then returns whatever watch(...) or score(...) returns. format_snapshot(snapshot) turns a snapshot into one line, teams in the order the message them.

What does this print?

snapshot = {"version": 3, "scores": {"Owls": 4, "Foxes": 0}}
parts = []
for team in snapshot["scores"]:
    parts.append(f"{team} {snapshot['scores'][team]}")
line = f"version {snapshot['version']}: " + ", ".join(parts)
print(line)

Never go backwards

watch(url, max_messages, wait, *, connect=connect) connects, prints one line per snapshot with format_snapshot, and stops after max_messages lines. It applies the rule from Chapter 8’s lesson on reconnecting: a client never shows a board older than one it has already shown. Keep the version of the last snapshot printed, start below any real version (-1 works), and skip a snapshot whose version is not greater. Only printed lines count toward --max-messages.

The server already sends versions in order, but the client does not rely on the server alone: it keeps the rule even if a server, a proxy or a later version of your own code does not. Not every message is a snapshot. A valid error envelope is meant for a scorekeeper, so watch skips it. A score envelope breaks the protocol, because only the scorekeeper sends scores, and so does anything parse_message refuses.

watch has printed version 7 and then receives a snapshot with version 6. What does it do?

How it ended decides the exit status

Every way the conversation can end has one exit status in the contract. For watch:

How the conversation endedExit status
max_messages lines printed0
a close with 1000 or 10010
a close with any other code1
the connection ended without a close (exc.rcvd is None, the 1006 of Chapter 6)1
the was refused (InvalidStatus, with the status in exc.response.status_code)1
no snapshot within wait seconds, or no handshake answer in time (TimeoutError)1
nothing listens there (ConnectionRefusedError, or another OSError)1
a message that breaks the protocol: close with 1008 first1
a valid error envelopeskipped

Every exit with 1 writes exactly one line to standard error that says why, with print(..., file=sys.stderr); standard output holds only snapshot lines. A refused handshake names its status, as in HTTP 503.

Why 1008 for a bad message? RFC 6455 section 7.4.1 makes 1008, policy violation, the generic code when no more specific one fits. 1003 is for a kind of data the endpoint cannot accept, such as a binary frame, and invalid is already failed by the library itself with 1007. Valid that breaks the scoreboard’s rules is a matter of this protocol’s policy, as the server’s own no such path close is.

Two facts decide the order of your except clauses. TimeoutError is a kind of OSError, so its clause must come before an except OSError: that would otherwise catch it with the wrong message. And InvalidStatus is not an OSError at all: it is a WebSocketException, so it needs a clause of its own. Pass open_timeout, close_timeout and proxy=None to connect, as Chapter 6 did, and pass the wait to every recv, so no silent server can keep the client for ever.

Why must except TimeoutError: come before except OSError: in watch?

The token goes in a header

score(url, token, team, points, *, connect=connect) sends one score and reports the answer. The token travels in the handshake, as an extra header:

headers = {"Authorization": f"Bearer {token}"}
with connect(url, additional_headers=headers, open_timeout=WAIT, close_timeout=WAIT, proxy=None) as connection:
    connection.send(text)

On a local computer, a server’s process_request hook read exactly one , Bearer and the token, from a handshake sent that way. Chapter 9 also accepted ?token= in the , but only for a browser page, which cannot add the header. This client is not a browser, and a URL is exactly the kind of text that servers and proxies write into their logs.

Build the score envelope with scoreboard_messages.encode_message, send it once, and read one answer with recv(timeout=WAIT). A snapshot answer is printed with format_snapshot, exit 0. An error envelope, a refused handshake, a close before the answer, a timeout or no connection is one line on standard error, exit 1. The line for a refused handshake names the status, such as HTTP 401, and never the token: the token appears on no stream.

watch does not reconnect when its connection drops. Reconnecting with backoff, from Chapter 8, stays something you can recognize rather than something this client does: it ends, and its exit status says how.

Why does score send the token in an Authorization header and not as ?token= in the URL?

Your task

Write watch and score. Most of each is except clauses. Then run seven commands of your client against a scoreboard written for this course, so that a mistake you see is the client’s and not your server’s:

python pl_ws.py client-main

A finished version, run on the site, printed, after the list of commands:

1 watch | version 0: Owls 0, Foxes 0, Herons 0
1 watch | version 1: Owls 3, Foxes 0, Herons 0
1 watch | version 2: Owls 3, Foxes 2, Herons 0
1 watch exited with 0
2 score | version 1: Owls 3, Foxes 0, Herons 0
2 score exited with 0
3 score | version 2: Owls 3, Foxes 2, Herons 0
3 score exited with 0
4 wrong token ! the scoreboard refused the connection: HTTP 401
4 wrong token exited with 1
5 unknown team ! the scoreboard refused the score: unknown team
5 unknown team exited with 1

Lines with | went to standard output and lines with ! to standard error. Command 6, --points 11, is refused by the supplied main with its usage and exit status 2, and command 7 watches a where nothing listens and exits with 1. Your own lines on standard error may say it differently. Then run the tests and ask for their exit status:

python -m pytest -q
echo $?

The six assert False tests still fail, so echo $? prints 1: you have not written a test of your own for either program yet. That is Lesson 4.

Task

In scoreboard_client.py, write two . The supplied main already refuses a bad with exit status 2 and an unreadable token file with 1; keep it, client_parser, read_token and format_snapshot as they are.

  1. watch(url, max_messages, wait, *, connect=connect): connect with connect(url, open_timeout=WAIT, close_timeout=WAIT, proxy=None) in a with block; call connection.recv(timeout=wait) for each message; print a snapshot with format_snapshot only when its version is greater than the last one printed, and return 0 after max_messages lines. Skip a valid error envelope. For anything scoreboard_messages.parse_message refuses, and for a score envelope, call connection.close(1008, ...), then return 1. Return 0 for a close with 1000 or 1001, and 1 for any other close, for exc.rcvd of None, for InvalidStatus, TimeoutError, and any other OSError.

  2. score(url, token, team, points, *, connect=connect): send one score envelope, built with scoreboard_messages.encode_message, on a connection opened with additional_headers={"Authorization": f"Bearer {token}"}, open_timeout=WAIT, close_timeout=WAIT and proxy=None; read one answer with recv(timeout=WAIT). Print a snapshot answer with format_snapshot and return 0. Return 1 for an error envelope, InvalidStatus, ConnectionClosed, TimeoutError and any other OSError.

Every return of 1 writes exactly one line to standard error, and a refused ’s line names exc.response.status_code. Standard output holds only snapshot lines. Neither function ever prints the token or puts it in the .

Run python pl_ws.py client-main and python -m pytest -q. Then press Check my work. It runs Lessons 1 and 2’s checks again, then calls the supplied main with a stand-in connect whose connection hands out scripted messages, records each recv timeout and records send and close; refused handshakes are the library’s own InvalidStatus. watch --max-messages 3 --wait 1.5 given versions 5, 4, 5, 6, 7, 8 must print versions 5, 6 and 7 and return 0, with proxy=None and the wait on every recv. Each ending in the table of this lesson must give its exit status, with exactly one line on standard error for each 1 and a close with 1008 for a message that breaks the protocol. score must connect to the URL given, send exactly one Authorization , Bearer and a token you have not seen, send one score whose equals the team and points given, print the snapshot answer, and return 1 for the failures above, naming 401 for a refused handshake. The token must appear on no stream and in no URL. Any wording on standard error is fine, the header may be a , a of pairs or a Headers, its name in any case, and the timeout may be given by position.