Skip to content

← Capstone Projects step 2 of 6

Hard End-to-End

Capstone: Give Each Upload State Its Own Type

An upload starts as a draft. You add chunks of bytes to it, then seal it to get a checksum. Build this API so that mypy rejects a call made on the wrong state, such as asking a draft for its checksum, before the code runs.

import hashlib

empty = begin("report")
first = add_chunk(empty, b"he")
complete = add_chunk(first, b"llo")
sealed = seal(complete)

assert checksum_of(sealed) == hashlib.sha256(b"hello").hexdigest()
assert checksum_of(seal(first)) == hashlib.sha256(b"he").hexdigest()
assert describe(empty) == "draft(report)"
assert describe(sealed) == "sealed(report)"

Give each state its own type

Typestate means giving each state of an object its own class. Each function then accepts only the state it can handle, and mypy rejects a call on the wrong one. Here is the idea with a door:

from dataclasses import dataclass


@dataclass(frozen=True)
class OpenDoor:
    room: str


@dataclass(frozen=True)
class ClosedDoor:
    room: str


def walk_through(door: OpenDoor) -> str:
    return f"You are in the {door.room}."


walk_through(ClosedDoor("kitchen"))
error: Argument 1 to "walk_through" has incompatible type "ClosedDoor"; expected "OpenDoor"  [arg-type]

Write these types and functions, and keep the starter's __all__:

type Upload = Draft | Sealed

def begin(name: str) -> Draft: ...
def add_chunk(draft: Draft, data: bytes) -> Draft: ...
def seal(draft: Draft) -> Sealed: ...
def checksum_of(sealed: Sealed) -> str: ...
def describe(upload: Upload) -> str: ...
  • Make Draft and Sealed two different classes. Upload is the type for code that accepts either one.
  • Keep each parameter type as shown. add_chunk and seal take a Draft, and checksum_of takes a Sealed.
  • Let the signatures do the checking. Don't add isinstance checks, assert or raise TypeError to add_chunk or checksum_of.

Never change a draft

  • begin(name) returns an empty draft. Keep the name exactly as given, spaces and punctuation included.
  • add_chunk(draft, data) returns a new draft that holds the earlier bytes followed by data. The draft you passed in stays as it was.
  • Assigning to a draft's name raises an exception. A frozen dataclass does this for you. Store the chunks in a tuple, because frozen=True does not stop someone appending to a list field.
  • Sealing a draft does not use it up. You can seal the same draft again, or add more chunks to it, and each call returns a separate value.

Seal and describe

  • seal(draft) returns a Sealed whose checksum is the SHA-256 hex digest of all the chunk bytes joined in order. The name is not part of the digest.
  • An empty draft is valid, and its checksum is the digest of b"". An empty chunk leaves the checksum unchanged.
  • checksum_of(sealed) returns that checksum.
  • describe(upload) returns exactly "draft(<name>)" or "sealed(<name>)". Handle the two classes in a match that ends with case _: assert_never(upload). If someone later adds a third class to Upload, mypy then reports the case you have not handled.

Keep the starter's type checks

The starter's _negative_fixtures function never runs, but mypy checks it.

  • Its four assert_type calls check what each function returns. assert_type does nothing at runtime. mypy reports an error when the type differs.
  • Its three calls marked # type: ignore[arg-type] are wrong-state calls, such as checksum_of(begin("a")). In strict mode, mypy reports any ignore comment that hides no error. So if checksum_of accepted an Upload, its wrong-state call would stop being an error, and mypy would fail the file with [unused-ignore].
  • Keep the fixtures as they are, and pass mypy --strict without adding a type: ignore of your own.

What the driver checks

Keep solve as it is. It starts a draft named name, adds each string in chunks encoded as UTF-8, and keeps every draft along the way.

  • checksum is the checksum of the final draft.
  • prefixes lists the checksum of every draft, from the empty one to the final one. The driver seals the newest draft first, so an add_chunk that changes its input shows up as wrong checksums for the earlier drafts.
  • immutable is True when assigning to a draft's name raises.
  • distinct is True when Draft and Sealed are different classes.
  • describe holds describe() of the final draft and of its sealed upload.

Going further

Python does not check type hints when the code runs. A caller that skips mypy can still pass a draft to checksum_of. In a library, add runtime checks at the public functions too. The typing guide covers exhaustiveness checking and assert_type.