0%

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 the caller passed in. That dict lasted as long as the program. Each command of 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 needs one place that does, and 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 now is less than its expires. A fresh entry is shown without a request.

  • A stale entry is asked for again with If-None-Match and its etag. A 304 keeps the book and sets expires to now plus the 304’s own max-age. A 200 replaces the entry.

  • Only a 200 with an etag is stored, with expires set to now plus its max-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 JSON.

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:

SituationRequest"status""book""response""source"
a fresh entrynone200the stored bookNone"cache"
a stale entry, and a 304GET with If-None-Match200the stored bookthe 304"revalidated"
anything elseGET, with If-None-Match if storedthe reply’s statusits JSON for a 200, else Nonethe 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

  1. Write max_age and cached_show.

  2. Send show through cached_show in run.

  3. Print at and the absolute URL after added.

  4. 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 after add.

  1. max_age(response): the number after max-age= in the reply’s cache-control, as an int, or 0 when there is none.

  2. cached_show(base_url, book_id, *, cache_path, now, session=None, timeout=TIMEOUT), by “The cache” in the contract: a fresh entry (now less than its expires) is returned without a request; a stale one is asked for with If-None-Match and its etag; a 304 keeps the book and sets expires to now plus the 304’s own max-age; a 200 with an etag is stored under the requested URL as {"etag", "expires", "book"}; any other reply removes that URL’s entry. Use load_cache and save_cache, the session you are given, and timeout. Return {"status", "book", "response", "source"} as the lesson’s table shows.

  3. In run, show goes through cached_show with now and cache_path; an error status prints describe_error on standard error and returns 2.

  4. add prints added ID: TITLE by AUTHOR at URL, where URL is urljoin(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 , URL, token, and body of each write; max_age on five cache-control values; load_cache on a missing file, a folder, broken JSON, a , and bytes that are not ; 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 , and nothing from your terminal is graded.