We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Modelling Data with Dataclasses step 5 of 28
Compute Cents from an Amount with __post_init__
A payment API takes amounts in minor units, the smallest unit of a currency, such as cents for US dollars. You keep the amount as the caller wrote it, and you work out the minor units once, when you build the record. The rounding mode goes into that calculation, but the record does not store it.
solve("1.005", "USD", "half_up")
# {
# "error": "",
# "field_names": ["amount", "currency", "minor_units"],
# "as_dict": {"amount": "1.005", "currency": "USD", "minor_units": "101"},
# "eq_vs_half_even": False,
# }
Two tools for a computed field
__post_init__ is a method that the generated __init__ calls after it
sets the fields. Checks and computed fields go there. InitVar marks a
constructor argument that __init__ passes to __post_init__ and does
not store. So with rounding: InitVar[str] = "half_even", callers can
write Money(amount, "USD", "half_up"), and rounding never becomes a
field.
Complete Money
Keep Money frozen, and declare these entries in this order:
| Entry | Declaration |
|---|---|
amount |
a Decimal field that the caller passes in |
currency |
a str field that the caller passes in |
minor_units |
an int field, field(init=False, default=0) |
rounding |
InitVar[str], with the default "half_even" |
Write __post_init__(self, rounding: str) -> None, and keep the generated
__init__. Check in this order:
- If
currencyis not a key ofMINOR_UNIT_EXPONENT, raiseValueError(f"unknown ISO-4217 currency: {self.currency!r}"). - If
roundingis not a key ofROUNDING_MODES, raiseValueError(f"unknown rounding mode: {rounding!r}"). - Otherwise, compute
minor_unitsand set it.
So when both names are wrong, raise the currency error. Names are case-sensitive, so do not strip them or change their case.
Compute minor_units
Use Decimal for every step, and never convert to float. For an
exponent of 2:
Decimal(1).scaleb(-2)isDecimal("0.01"), the size of one cent.amount.quantize(Decimal("0.01"), rounding=ROUNDING_MODES[rounding])rounds the amount to two decimal places.quantized.scaleb(2)moves the decimal point two places to the right, andint()turns the result into a whole number of cents.
Set the result with object.__setattr__(self, "minor_units", result).
frozen=True blocks self.minor_units = ..., even inside
__post_init__, and object.__setattr__ skips the dataclass's check.
Leave amount as the caller passed it. Do not replace it with the rounded
value.
| amount | currency | rounding | minor_units |
|---|---|---|---|
| "10.00" | USD | half_even | 1000 |
| "1.005" | USD | half_even | 100 |
| "1.005" | USD | half_up | 101 |
| "-1.005" | USD | half_up | -101 |
| "1234" | JPY | half_even | 1234 |
| "1.2345" | KWD | half_up | 1235 |
Both modes round to the nearest value. They differ only at a tie, such as
1.005 dollars, which is halfway between 100 and 101 cents. half_even
picks the even last digit. half_up rounds away from zero, so -1.005
becomes -101.
Implement solve
Parse amount with Decimal(amount), and build a Money with the given
currency and rounding. If that raises ValueError, return:
{"error": str(exc), "field_names": [], "as_dict": {}, "eq_vs_half_even": False}
Otherwise, return:
"error": an empty string."field_names": the names fromfields(money), in order."as_dict":asdict(money)withstrapplied to every value. Sominor_unitsbecomes"101", andamountis the text ofstr(Decimal(amount)), such as"10.00"."eq_vs_half_even": whethermoneyequals a secondMoneybuilt from the same amount and currency with"half_even".
rounding is not a field, so fields(), asdict(), the repr and ==
all leave it out. It still affects == through minor_units, so two
amounts rounded differently are unequal.
Your code must pass mypy --strict. Annotate the rounding parameter of
__post_init__ and the return type of solve.
Going further
money.rounding does not tell you which mode built the object. Python
finds the class attribute, "half_even". The exercise Update a Frozen
Record with replace() shows how that default changes a copy.
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.