Capstone: The Reading List Service and Client · capstone
Capstone project: Finish the Client
Your server now answers a matching If-None-Match with a bodiless 304 and gives every new book a location. The client uses neither: every show downloads the whole book, and add never says where the new book lives. This lesson builds both client halves.
The cache rules are the ones you wrote in Chapter 8, Lesson 5, for cached_get in wire.py, which kept its entries in a reading_client.py is a new run of Python, so a cache that should help the second show has to outlive the first: it lives in a file, reading_cache.json, in the folder the client runs in.
One clock reading
Freshness needs the current time. main now reads the clock, once:
def main(argv=None, *, session=None, now=None, cache_path=CACHE_FILE):
...
if now is None:
now = time.time()
and passes now inward to run, which passes it to cached_show. That is the injected time boundary from Python in Practice’s capstone, Chapter 13, Lesson 2. Nothing below main calls time.time(), so a test passes now=0 and then now=59 and gets the same answer on every run. In Chapters 7 to 9 no code of yours read a clock; a program with a main is that place.
cache_path works the same way: it defaults to reading_cache.json, and every test that came with the project passes a path inside tmp_path instead.
Why does main read the clock and pass now in, instead of cached_show calling time.time() itself?
The rules you already wrote
SERVICE_CONTRACT.md, under “The client” and then “The cache”, states them for this file. They are Chapter 8’s:
The file holds a
. Its keys are the requested , and each is {"etag": ..., "expires": ..., "book": ...}.An entry is fresh while
nowis less than itsexpires. A fresh entry is shown without a request.A stale entry is asked for again with
If-None-Matchand itsetag. A 304 keeps the book and setsexpirestonowplus the 304’s ownmax-age. A 200 replaces the entry.Only a 200 with an
etagis stored, withexpiresset tonowplus itsmax-age, or 0 when there is none. Any other reply removes the entry for that URL.
Python in Practice taught reading and writing such a file, so both come with the project: load_cache(cache_path) returns the stored dict, or {} when the file is missing, cannot be read, is not JSON, or is not a JSON object, and save_cache(cache_path, cache) writes the dict back as
The cache is keyed by the URL the client asked for, so a show through the old /v0 base keeps entries of its own, even though the server redirects it.
A location is not a URL yet
Your server’s 201 carries location: /books/3. That is a path, relative to the URL the client asked, and a client that prints it as it came prints half an address. Chapter 6, Lesson 2 resolved such values with urljoin from urllib.parse, and reading_client.py already imports it:
urljoin("http://127.0.0.1:8080/books", "/books/3") # 'http://127.0.0.1:8080/books/3'
Resolve it against response.url, where requests ended up, not against --base-url: after a redirect the two differ, and the reply that carried the location is the right base.
Milestones
First, max_age(response). Split response.headers.get("cache-control", "") on commas. For the member that, stripped and lowercased, starts with max-age=, return the ASCII digits after it as an int. Return 0 when there is none. requests’ headers are case-insensitive, so "cache-control" finds Cache-Control too.
Then cached_show(base_url, book_id, *, cache_path, now, session=None, timeout=TIMEOUT). Build the URL with book_url, load the cache, and look up the entry. Return a dict with exactly four keys:
| Situation | Request | "status" | "book" | "response" | "source" |
|---|---|---|---|---|---|
| a fresh entry | none | 200 | the stored book | None | "cache" |
| a stale entry, and a 304 | GET with If-None-Match | 200 | the stored book | the 304 | "revalidated" |
| anything else | GET, with If-None-Match if stored | the reply’s status | its JSON for a 200, else None | the reply | "network" |
Send the request through the session as call does, client = requests if session is None else session, with timeout=timeout, and save the file whenever you change an entry. About twenty lines.
Then run. Its show branch calls cached_show(args.base_url, args.book_id, cache_path=cache_path, now=now, session=session) instead of call. A status of 400 or above prints describe_error(result["response"]) on standard error and returns 2; otherwise it prints book_line(result["book"]) and returns 0.
Last, the add line. After added, the book line, then at and urljoin(response.url, response.headers["location"]).
python -m pytest -q
The tests that came with the project should still pass.
The exit statuses still hold, and the shell keeps the last one in $?. A show with no book id stops before any request, so it needs no server:
python reading_client.py show --base-url http://books.test
echo $?
argparse prints a usage block on standard error that ends with reading_client.py: error: show needs a book id, and echo $? prints 2. A 404 also ends with 2; no answer ends with 1.
Your task
Write
max_ageandcached_show.Send
showthroughcached_showinrun.Print
atand the absolute URL afteradded.Run
python -m pytest -q, then press Check my work.
A failing check names the command, its now, and the rule. Ask Monty to compare the message with “The cache” in SERVICE_CONTRACT.md, without writing the fix.
The client now remembers what it fetched and says where a new book lives. Redirects are still followed silently, which the next lesson changes before running both halves together.
Task
Finish two of the client rows of SERVICE_CONTRACT.md: the cache that show keeps, and the absolute add.
max_age(response): the number aftermax-age=in the reply’scache-control, as an int, or 0 when there is none.cached_show(base_url, book_id, *, cache_path, now, session=None, timeout=TIMEOUT), by “The cache” in the contract: a fresh entry (nowless than itsexpires) is returned without a request; a stale one is asked for withIf-None-Matchand itsetag; a 304 keeps the book and setsexpirestonowplus the 304’s ownmax-age; a 200 with anetagis stored under the requested URL as{"etag", "expires", "book"}; any other reply removes that URL’s entry. Useload_cacheandsave_cache, the session you are given, andtimeout. Return{"status", "book", "response", "source"}as the lesson’s table shows.In
run,showgoes throughcached_showwithnowandcache_path; an error status printsdescribe_erroron standard error and returns 2.addprintsadded ID: TITLE by AUTHOR at URL, where URL isurljoin(response.url, response.headers["location"]).
Keep every other client row as it is: a timeout on every request, authorization: Bearer TOKEN only with --token, exit statuses 0, 1, and 2 with one standard-error line. Run python -m pytest -q in the
Then press Check my work. It runs reading_client.main with a session that records each request and answers from a script, with a cache file in a folder it makes, and checks: the timeout on every command and that call and cached_show pass on a timeout they are given; exit statuses and streams for 404, 409, 500, 503, and 401 replies, a connect timeout, a read timeout, a refused connection, and three usage errors; the max_age on five cache-control values; load_cache on a missing file, a folder, broken JSON, a show at times 1000 to 1099 with max-age values of 45 and 7, through a first fetch, a fresh entry, a 304, a 200 that replaces, a 404, and a 200 with no etag; and the absolute URL after add for four base URLs and location values. While your client runs, reading_client.requests cannot send anything, so a request that skips the session fails with a message. Nothing opens a