0%

requests and httpx, Now That You Know · capstone

Capstone project: Project: The Reading List Client

Since Chapter 1 your client has been wire.py, built one at a time so that each mechanism stayed visible. This project builds the client a person would actually use, on requests, in reading_client.py:

python reading_client.py add --base-url URL --token TOKEN --title Piranesi --author "Susanna Clarke"
python reading_client.py show 3 --base-url URL

Here pl_http runs the commands for you against your server. In Chapter 12 you run both on your own computer.

What the client must do

SERVICE_CONTRACT.md has a new section, “The client”. Read it first; it is the requirement. In short: five commands, list, show ID, add, update ID, and delete ID, each one request to the service; --token for writes; a timeout of TIMEOUT, (1.0, 2.0), on every request; results on standard output; and an exit status that says what happened: 0 when the request succeeded, 1 when no answer came, 2 when the server answered with an error or the breaks the rules.

reading_client.py already holds the parts that are not about . build_parser() and check_usage(parser, args) read and check the command line, book_url(base_url, book_id) builds /books or /books/ID onto the base , and book_line(book) formats ID: TITLE by AUTHOR. Your make_session() from Lesson 1 is there too. Four functions are left.

One function sends every request

Every command is one request with a few parts, so one function sends them all:

def call(method, url, *, session=None, token=None, data=None, timeout=TIMEOUT):

Use client = requests if session is None else session, as in Lesson 2, and send with client.request(method, url, ...), which takes the as text. Then add only what the request needs. With a token, a header authorization: Bearer TOKEN, built from the token you were given, and no such header without one. With data, json=data, which makes requests encode the and add content-type: application/json; reads send no body. Always timeout=timeout.

Redirects need nothing from you. requests follows them, as Lesson 2 showed, and the service’s old /v0 paths answer every method with 308, which keeps the method and the body. A PUT through --base-url http://books.test/v0 arrives at /books/ID as a PUT, with its body and its authorization header, because requests keeps that header when the redirect stays on the same host.

Status first, then output

A reply is a success or an error by its class, the rule from Chapter 3: 2xx is a result to print, and any 4xx or 5xx is an error. Decide that before you print anything. For an error, print one line on standard error, print(describe_error(response), file=sys.stderr), print nothing on standard output, and return 2. Standard output carries only results, so a program that reads it never mistakes an error message for a book.

describe_error(response) returns that one line: the status, a space, then the server’s error text when the body is a with one, or the reason phrase when it is not. A 503 from a proxy may have no JSON body at all, so response.json() can raise; it raises requests.JSONDecodeError, a subclass of , which you catch with except ValueError:.

delete 7 gets 403. What does the client do?

When no answer comes, there is no response to look at: requests raises instead. Two classes matter, and their order matters. A requests.ConnectTimeout is both a requests.Timeout and a requests.ConnectionError, and Python runs the first except clause that matches. So catch requests.Timeout first and print no answer from BASE_URL in time, then requests.ConnectionError and print could not connect to BASE_URL, each on standard error, each returning 1. Catch nothing wider. An except Exception: would report a bug in your own code as a network problem.

Why catch requests.Timeout before requests.ConnectionError?

Milestones

  1. call, as above. The example test in tests/test_client.py passes once it exists.

  2. describe_error(response), as above.

  3. run(args, *, session) carries out one checked command. Pick the method for args.command, build {"title": args.title, "author": args.author} for add and update, send it with call(..., session=session, token=args.token, data=data), and check the status first. On success, list prints one book_line per book in response.json()["books"], show prints the book’s line, add and update print added or updated before it, and delete prints deleted ID; a 204 has no body, so delete reads none. Return 0.

  4. main(argv=None, *, session=None) parses with build_parser(), calls check_usage(parser, args), and runs the command inside try. With no session, it makes one, with make_session() as session:, so the session is closed when the command ends. About forty lines in all.

A command line the contract refuses never reaches run. Try one in the , then ask the shell for the exit status of the last command:

python reading_client.py show --base-url http://books.test
echo $?

argparse prints the usage and reading_client.py: error: show needs a book id on standard error, and echo $? prints 2. No request is sent, so no server is needed.

Then run the scenario:

python pl_http.py client

It runs eight commands against your server, on one session pl_http mounts there. The update with a reader’s token goes through the old path, is redirected, and is refused:

request 1  PUT /v0/books/3  ->  308 Permanent Redirect  (connection 1)
request 2  PUT /books/3  ->  403 Forbidden  (connection 1)
--- your reading_client.py update 3 --base-url http://books.test/v0 --token tok-ben-reads-K9pz --title "Piranesi (paperback)" --author "Susanna Clarke" ---
exit status: 2
standard output: []
standard error: ['403 this token may read the list but not change it']

The last command uses a second session whose handler points at a where nothing listens. Recorded on a Mac, the refusal came at once and gave standard error: ['could not connect to http://books.test'] with exit status 1; if a system made the connection wait instead, your timeout line would appear.

Run your tests too, now that call exists:

python -m pytest -q

Adding tests of your own to tests/test_client.py is worth it. fakes.fake_session answers with an error as easily as with a book, and pytest’s capsys shows what main printed:

def test_a_404_exits_2(capsys):
    calls = []
    session = fakes.fake_session([fakes.fake_response(404, {"error": "no book has that id"})], calls)
    status = reading_client.main(["show", "9", "--base-url", "http://books.test"], session=session)
    assert status == 2
    assert capsys.readouterr().out == ""

Press Check my work. Your client now does the client’s half of everything the server does by hand: redirects, credentials, framing, and the connection. Chapter 11 turns to the other side and asks what a web framework would do for your server.

Task

Build reading_client.py, a client for the reading service, as SERVICE_CONTRACT.md describes it under “The client”.

Write four ; keep the supplied build_parser, check_usage, book_url, book_line, and your make_session:

  1. call(method, url, *, session=None, token=None, data=None, timeout=TIMEOUT) sends one request with client = requests if session is None else session and returns the response. It sends authorization: Bearer TOKEN only when token is given, json=data only when data is given, and always timeout=timeout. It never passes allow_redirects.

  2. describe_error(response) returns one line: the status, a space, then the server’s error text, or the reason phrase when the body has none.

  3. run(args, *, session) sends the command’s request through call with args.token, sends {"title": ..., "author": ...} for add and update, and checks the status first: for any 4xx or 5xx it prints describe_error(response) on standard error, prints nothing on standard output, and returns 2. Otherwise it prints the contract’s result line or lines on standard output and returns 0.

  4. main(argv=None, *, session=None) parses argv with build_parser(), calls check_usage(parser, args), and runs the command, on session when one is given and otherwise inside with make_session() as session:. It catches requests.Timeout first, then requests.ConnectionError, prints one line on standard error for either, and returns 1. It catches nothing wider.

In the , python pl_http.py client runs eight commands against your server, through its old /v0 paths for two of them, and shows each command’s exit status, standard output, and standard error. python -m pytest -q runs your tests.

Then press Check my work. With a base , books, titles, and tokens you have not seen, it runs every command through a session that writes down each call and answers from a list: the , the URL, the body, the authorization header only with a token, and the timeout of every request; the exact output lines; one standard-error line naming the status and exit status 2 for 404, 409, 500, and 503 replies; exit status 1 for a connect timeout, a read timeout, and a refused connection, with a timeout reported differently from a refused connection; an ordinary left uncaught; and argparse’s status 2 for a command line the contract refuses. Then it runs add, update with a reader’s token and with a writer’s, and delete through the old /v0 paths of a small server inside the check, in memory, and show of the deleted book, and checks that main without a session makes one and closes it. Last, it runs your tests once; they must all pass. Nothing from your terminal is graded.