HTTP Is Text on a Socket · capstone
Capstone project: Project: A Raw fetch()
Over four lessons you built fetch(method, target, *, connect) from four pieces: make_request writes the request, read_head reads up to the empty line, parse_head turns the head into a status and headers, and read_body reads exactly the body, or none after HEAD. Each piece was checked on its own, and each live run showed one reply from practice-api. This project proves the whole
What fetch promises
When you are done, wire.py holds a fetch that does all of this:
returns a
with "version","status","reason","headers"and"body"for a reply with any status, 404 as readily as 200;raises
for a malformed head or length, and for a body it cannot measure yet: a reply with no Content-Length, or withTransfer-Encoding, which Chapter 9 handles;raises
ConnectionErrorwhen the server closes before the head or the body is complete;never reads past the end of the body, and reads no body after
HEAD;leaves the connection closed on every path, which the
withblock does for you, also when an exception is raised inside it.
If you followed Lessons 3 to 6, your fetch already does all of this. If Check my work finds a gap, it names it, and you fix it in wire.py. The new work in this lesson is the tests.
The tools in fakes.py
Open fakes.py. It is ordinary Python, written for this course, and it holds two functions your tests use.
scripted_socket(chunks) returns a stand-in recv(size) hands out the next chunk of the script, never more than size bytes at a time, keeping the rest of a long chunk for the next call. After the last chunk, recv returns b"", as a real socket does once the server has closed. A None in the script stands for a server that has gone silent but kept the connection open: recv raises TimeoutError there, which is what a real socket with a timeout does. Every sendall call is recorded in the sock.sent.
fake_connect(sock, calls) returns a function you pass to fetch as connect. When fetch calls it, it appends the address and timeout to your calls list and hands back sock. It wraps sock in contextlib.nullcontext(sock) first, because fetch writes with connect(...) as sock: and the stand-in socket cannot start a with block on its own: nullcontext hands sock to the block and does nothing when the block ends. Your notice board tests in Networks with Python used the same trick.
tests/test_wire.py already starts with import wire, imports both functions, defines EXAMPLE_REPLY, a made-up reply in practice-api’s shape with a 13-byte
Milestones
Write the tests in this order. Run python -m pytest -q after each one.
The whole reply in one chunk. Script
EXAMPLE_REPLYas one chunk, callwire.fetch("GET", "/v1/items/1", connect=fake_connect(sock, calls)), and assert what came back: the connect call (("practice-api", 8080)and2.0), the exact bytes insock.sent, the status, thecontent-lengthheader, and the body.Every split position. A
over every position ifrom 1 to the length of the reply builds a two-chunk script,[EXAMPLE_REPLY[:i], EXAMPLE_REPLY[i:]], and checks the body each time. That covers a cut inside the status line, inside every, inside the empty line, and inside the body. HEAD. Script only the head of the reply, up to and including the empty line, followed by
None, and callfetchwith"HEAD". Assert that the body isb""and thatcontent-lengthis still there. TheNonematters: it is a server that keeps the connection open, so afetchthat waits for a body, or for the server to close, raisesTimeoutErrorand the test fails.Any casing. Script a reply whose length header is written
cOnTeNt-LeNgTh, and assert that its body comes back.
Here is the shape of milestone 2, without its assertions:
def test_fetch_reads_a_reply_split_anywhere():
"""The reply may arrive in two pieces cut at any byte."""
for i in range(1, len(EXAMPLE_REPLY)):
sock = scripted_socket([EXAMPLE_REPLY[:i], EXAMPLE_REPLY[i:]])
response = wire.fetch("GET", "/v1/items/1", connect=fake_connect(sock, []))
Three rules keep your tests checkable. Write each test as a plain test_ function with no wire.fetch(...) through the from wire import fetch or fetch = wire.fetch: Check my work swaps in other versions of fetch through the module to see whether your tests notice, and a name copied out of the module keeps the original. And never let a test reach the network: every fetch call in your tests passes connect=.
Run the tests
From the Shell tab:
python -m pytest -q
Every test should pass in well under a second, because no test waits for a network. If one hangs, a loop in your test or in wire.py is not reaching recv: a script cannot wait, because it ends in b"" or raises at None.
If you like, run python wire.py once more against practice-api. It is the same reply as in Lesson 5, now from a function you have tested against every way the reply could be cut. That live run is for looking at; Check my work does not use it, and your tests are stronger evidence than any one run, because they cover cuts a network may never happen to make while you watch.
Then press Check my work. fetch can send exactly one kind of request: a Host line and no other header. Chapter 2 builds requests for any
Task
Finish fetch in wire.py if anything in the lesson’s tests/test_wire.py, keeping the one already there:
a reply in one chunk: check the connect call, the bytes sent, the status, the
content-lengthheader, and the body;a reply cut in two at every position, checking the body each time;
a
HEADrequest whose script holds only the head followed byNone, checking that the body isb""andcontent-lengthis still there;a reply whose length header is written in unusual casing, checking the body.
Write each test as a plain test_ scripted_socket and fake_connect from fakes.py, and call wire.fetch(...) through the connect=, inside each test. Do not write from wire import or store wire.fetch in a name of its own.
In the python -m pytest -q.
Then press Check my work. The check first calls your fetch with a stand-in connect on replies you have not seen: replies with any status, header names in unusual casing, a repeated header, a body with characters outside ASCII, a reply followed by the start of another, and a HEAD reply, each delivered whole, one byte at a time, cut in two at every position, and in pieces of 7 bytes, with a server that closes and with one that keeps the connection open; then malformed status lines, Content-Length, one with Transfer-Encoding, a head that never ends, a length far over the limit, and servers that close early. Each time it checks that the connection was closed. It also checks the bytes make_request writes. Then it runs your tests with pytest, first against your own wire.py, where they must all pass without any network, and then against four broken versions of fetch: one that calls recv only once, one that reads until the server closes, one that reads a body after HEAD, and one that looks up Content-Length only in that exact casing. Your tests must fail against each broken version. It does not read your terminal or contact practice-api.