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 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
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 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
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 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 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 response.json() can raise; it raises requests.JSONDecodeError, a subclass of 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
call, as above. The example test intests/test_client.pypasses once it exists.describe_error(response), as above.run(args, *, session)carries out one checked command. Pick the method forargs.command, build{"title": args.title, "author": args.author}foraddandupdate, send it withcall(..., session=session, token=args.token, data=data), and check the status first. On success,listprints onebook_lineper book inresponse.json()["books"],showprints the book’s line,addandupdateprintaddedorupdatedbefore it, anddeleteprintsdeleted ID; a 204 has no body, sodeletereads none. Return 0.main(argv=None, *, session=None)parses withbuild_parser(), callscheck_usage(parser, args), and runs the command insidetry. 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
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 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 SERVICE_CONTRACT.md describes it under “The client”.
Write four build_parser, check_usage, book_url, book_line, and your make_session:
call(method, url, *, session=None, token=None, data=None, timeout=TIMEOUT)sends one request withclient = requests if session is None else sessionand returns the response. It sendsauthorization: Bearer TOKENonly whentokenis given,json=dataonly whendatais given, and alwaystimeout=timeout. It never passesallow_redirects.describe_error(response)returns one line: the status, a space, then the server’serrortext, or the reason phrase when the body has none.run(args, *, session)sends the command’s request throughcallwithargs.token, sends{"title": ..., "author": ...}foraddandupdate, and checks the status first: for any 4xx or 5xx it printsdescribe_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.main(argv=None, *, session=None)parsesargvwithbuild_parser(), callscheck_usage(parser, args), and runs the command, onsessionwhen one is given and otherwise insidewith make_session() as session:. It catchesrequests.Timeoutfirst, thenrequests.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 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 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.