We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Capstone Projects step 4 of 6
Capstone: Build an Immutable Order Model from Untrusted Data
Build an order type that nobody can change once it exists. You parse it from untrusted data such as decoded JSON, change it only by making new orders, and turn it back into a plain dictionary.
order = parse_order({
"id": "o-1",
"customer_email": "ada@example.com",
"currency": "GBP",
"lines": [{"sku": "widget", "quantity": 2, "unit_price": "9.99"}],
})
bolts = OrderLine(
sku="bolt",
quantity=3,
unit_price=Money(amount=Decimal("0.50"), currency=Currency.GBP),
)
bigger = order.with_line(bolts)
placed = bigger.transition(Status.PLACED)
print(order.total.amount, bigger.total.amount) # 19.98 21.48
print(bigger.status, placed.status) # draft placed
print(Codec.dump(placed)["lines"][1])
# {'sku': 'bolt', 'quantity': 3, 'unit_price': '0.50'}
with_line and transition return new orders and leave the old ones as
they were. Bad input raises InvalidOrder with the path of the field that
failed, such as "lines[0].quantity".
What to write
Fill in entity, Money, OrderLine, Order, parse_order, Codec and
_parse_line. The starter supplies the Currency and Status enums,
OrderId, the TRANSITIONS table, both exceptions and the driver. Make the
file pass mypy --strict on Python 3.13 or newer.
Write one decorator for all three classes
The dataclass_transform exercise builds a decorator like this one.
entityappliesdataclass(frozen=True, kw_only=True, slots=True)to the class. Frozen means that assigning to a field raises an error. Keyword-only means that callers pass every field by name. Slots means that instances have no__dict__.- It registers the class in
REGISTRYunder the class's name, soREGISTRY["Order"]isOrder. - With
slots=True,dataclassreturns a new class. Register and return that new class, not the one passed in. - Decorate
entitywith@dataclass_transform(frozen_default=True, kw_only_default=True, field_specifiers=(field,)). It tells mypy that classes made withentityare frozen, keyword-only dataclasses, and thatfield(...)sets their field options.
Define the three classes
Decorate each class with @entity and give it these fields:
| Class | Fields |
|---|---|
Money |
amount: Decimal, currency: Currency |
OrderLine |
sku: str, quantity: int, unit_price: Money |
Order |
id: OrderId, customer_email: str, currency: Currency, status: Status = Status.DRAFT, lines: tuple[OrderLine, ...] = (), total: Money |
- Store
linesas a tuple ofOrderLineobjects. Then nobody can append to it, and the whole order is hashable, so it can be a dictionary key. - Declare
customer_emailwithfield(repr=False), so the email stays out ofrepr(order)and out of log lines that print the order.
Compute the total
A derived field is one that the class computes from its other fields. The
caller never passes it in. The __post_init__ exercise computes one the
same way.
- Adding two
Moneyvalues with+adds their amounts. If the currencies differ, raiseInvalidOrder("currency"). Money.times(count)multiplies the amount bycount.OrderLine.subtotalis a property that returns the unit price times the quantity.Order.totalis a derived field. Declare it withfield(init=False).- In
__post_init__, add up the line subtotals, starting from zero in the order's currency. The class is frozen, so set the field withobject.__setattr__(self, "total", ...). - If any of this arithmetic raises
decimal.DecimalException, or gives an amount that is not finite, raiseInvalidOrder("total").
Change an order by making a new one
The exercise on updating a frozen record with replace() covers this.
with_line(line) -> Selfreturns a new order withlineadded at the end. Build it withdataclasses.replace, which calls__init__again, so__post_init__computes the new total.transition(to) -> Selfreturns a new order with statusto, ifTRANSITIONSallows the change from the current status. Otherwise it raisesIllegalTransition. For example, a draft can become placed or cancelled, but not shipped.- Both methods leave the original order unchanged.
Check untrusted data at the boundary
The boundary is the place where outside data enters your code. Check
everything there, and the rest of your code can trust any Order it gets.
The exercise on parsing untrusted JSON into a dataclass covers this.
parse_order(raw: object) -> Orderaccepts a mapping with string keys. Ifrawis not a mapping, or one of its keys is not a string, raiseInvalidOrder("$")."$"stands for the whole document.- Every error is an
InvalidOrderwhosefield_nameis the path of the bad field, such as"customer_email"or"lines[0].quantity". Never let aKeyError, aTypeErroror a decimal error escape. - Check for unknown keys before you check any values. The order allows
only
id,customer_email,currency,statusandlines. Report any other key by its name, as inInvalidOrder("discount"). - A
totalkey is unknown too, because the order computes its own total. idandcustomer_emailmust be strings. You don't need to check anything else about them.currencymust be"GBP","USD"or"EUR".statusdefaults to"draft". If present, it must be the value of aStatusmember.linesdefaults to an empty list. If present, it must be alist.
Check each line the same way. Every path inside a line starts with the
line's own path, such as "lines[0]".
- A line must be a mapping with string keys. Otherwise report the line's own path.
- A line allows only
sku,quantityandunit_price. Report any other key with its full path, such as"lines[0].colour". skumust be a string.quantitymust be anintof at least 1. RejectTrueandFalse, which Python counts asints.unit_pricemust be anintor a string that holds a number, such as"9.99". Reject abool, afloatand a string that is not a number.- Also reject a price that is not finite, such as
"NaN","sNaN"or"Infinity". Accept negative prices. - Each line's prices use the order's currency.
_parse_line(raw, currency, name)parses one line, andnameis the start of its paths. The driver passesname="added"for the line it adds, so its errors have paths such as"added.quantity".
Write the dictionary out yourself
Codec.dump(order)returns a dictionary with exactly the keysid,customer_email,currency,statusandlines. Leave outtotal.- Write
currencyandstatusas their string values. Writelinesas a list of dictionaries, with eachunit_priceasstr(amount), such as"9.99". Codec.load(raw)returnsparse_order(raw). Loading a dump gives back an order equal to the one you dumped.
Don't use dataclasses.asdict here. It would include total, keep the
Decimal and enum objects, and turn each price into a nested dictionary.
Keep the starter's type checks
- Leave
_negative_fixturesas it is. Nothing calls it. Each of its three lines must stay a type error. They call a constructor with positional arguments, assign to a frozen field, and callappendon a tuple. mypy --strictreports a# type: ignorecomment that hides no error. So these lines fail the type check unless your classes are keyword-only and frozen, andlinesis a tuple.- The driver has one more
# type: ignore, for its ownappendtest. Add none to your code.
What the driver checks
Keep solve, _negative_fixtures and _EMPTY_LINE as they are. The driver
parses raw. When a test supplies add_line, the driver parses that line
with _parse_line and adds it with with_line. When a test supplies
transition, the driver applies that change.
okisFalsewhen a step fails.errorthen holds theInvalidOrderpath,"transition"for anIllegalTransition, or"status"for a transition name that is not a status.totalisstr(order.total.amount), such as"19.98".statusis the final status, andline_countis the number of lines.hashableisTruewhen the order works as a dictionary key.frozenisTruewhen settingstatusraises an error.lines_immutableisTruewhenorder.lineshas noappendmethod.slottedisTruewhen the order has no__dict__.email_in_repris thereproption of thecustomer_emailfield. It must beFalse.email_in_dumpisTruewhen the dump holds the email.roundtripisTruewhenCodec.load(Codec.dump(order)) == order.registry_okisTruewhenREGISTRYholds the decoratedOrder,OrderLineandMoneyclasses.
Going further
frozen=Truestops assignment to the order's own fields only. A list inside a frozen object can still change, solinesis a tuple of frozen lines.repr=Falsehides the email fromrepr()only. The dump still holds it.- Decimal arithmetic follows the current decimal context, and this
exercise adds no rounding. Real prices often need rounding to the
currency's smallest unit, with
Decimal.quantize. - A round trip can pass even when
dumpandloadagree on the wrong key names. In your own tests, check the dump's keys and strings too. - The Python documentation covers dataclasses and decimal.
Your code, saved here
Sign in to access your accepted solutions and saved draft history. You can keep reading the lesson without an account.
Sign in to your accountStuck?
Python reference solution
Sign in to track your attempts toward unlocking the reference solution.