Skip to content

← Precise Type Hints with Literals and Generics step 24 of 26

Hard End-to-End

Capstone: Modernize a Typed Inventory Module

The starter is an inventory module written in 2019. It has Dict[str, Any] records, a # type: comment, an Item class with no annotations, and a parser that catches every error and returns None. Turn it into a typed module that checks each record and says what is wrong with it.

This capstone uses most of the track. It needs a Literal status, a NewType SKU, narrowing on values read from a record, a frozen dataclass, a generic helper, assert_never and @final. The parsing behavior changes on purpose. Match the rules below, not the starter.

records = [
    {"sku": "B", "name": "bolt", "status": "active", "quantity": 2},
    {"sku": "A", "name": "", "status": "active", "quantity": 0},
    {"sku": "B", "name": "spare", "status": "active", "quantity": 3},
    {"sku": "", "name": 7},
]
assert solve(records, "active", 0) == (["A", "B", "B"], 5, ["bad sku"], "in catalogue:A")
assert solve(records, "active", 3) == (["B"], 3, ["bad sku"], "in catalogue:B")
assert solve([], "discontinued", 0) == ([], 0, [], "retired:none")

The types

  • Status, an alias for Literal["active", "discontinued"], and a STATUSES tuple of both values.
  • Sku = NewType("Sku", str).
  • Item, a dataclass with frozen=True and slots=True, and the fields sku: Sku, name: str, status: Status and quantity: int, in that order.
  • ParseError, a subclass of ValueError.

Parse each record

parse_item(record: Mapping[str, object]) -> Item checks the fields in this order and raises ParseError for the first one that fails:

Check Message
sku is missing, not a string, or empty bad sku
name is missing or not a string bad name
quantity is missing, a bool, not an int, or negative bad quantity
status is missing or not one of the two statuses bad status: <repr>
  • Use repr in the status message, so "activ" gives bad status: 'activ' and a missing status gives bad status: None.
  • Do not trim or change case. A SKU of spaces and an empty name are valid.
  • A quantity of 0 is valid. True and False are not.
  • Ignore extra fields, and do not change the record. Use only Mapping reads, such as record.get(...), so a read-only mapping works too.

parse_status(raw: object) -> Status returns the matching Status value, or raises ParseError with the status message.

Query the items

  • Inventory is a class marked @final. Inventory(items: Iterable[Item]) stores the items as a tuple.
  • matching(status: Status, minimum: int) -> list[Item] returns a new list of the items with that status and quantity >= minimum, in their original order and with duplicates. minimum can be negative.
  • total(items: Sequence[Item]) -> int adds up the quantities of the items you pass it, not the whole inventory. An empty sequence gives 0.
  • first_or[T](items: Iterable[T], default: T) -> T returns the first item, even if it is falsy, and default only for an empty input. Stop after the first item, so an iterator is not used up. Delete the starter's unused TypeVar.
  • describe(status: Status) -> str returns "in catalogue" for "active" and "retired" for "discontinued". End its match with case _: assert_never(status).

What solve returns

solve(raw: list[dict[str, object]], status: str, minimum: int) returns tuple[list[str], int, list[str], str].

  1. Parse every record in order. Catch ParseError around each parse_item call only, and keep its message.
  2. Parse the status argument with parse_status. If it is invalid, let the ParseError propagate.
  3. Query the inventory, then return four values:
    • the matching SKUs as strings, sorted, with duplicates;
    • the total quantity of the matching items;
    • the record error messages, in input order;
    • the description, a colon and the first sorted SKU, or none when nothing matches.

Modernize the code

  • Use list, dict and X | None, not typing.List or Optional.
  • Annotate every function. Do not use Any, cast, # type: ignore, a bare except, or Generic.
  • Delete the unused load helper and the json import, because solve receives records that are already decoded.