作者在報價及發票應用程式中禁止算術運算,並以 AST 測試強制執行
I banned arithmetic in my invoicing app and enforced it with an AST test
作者將報價及發票應用程式的金額計算集中在 quote_to_cash,並以 AST 測試限制其他程式碼中的算術運算和金額建構。分期開票的最後一期按每項金額的餘數結算;測試涵蓋 7 組百分比分配和 3 張報價,以檢查金額與原報價一致。作者亦以每張報價的鎖處理併發請求,但指出鎖只在單一 API 程序內有效。
The quoting and invoicing app I built has a rule that looks odd at first: the app package is not allowed to do arithmetic. No +, no -, no *, no /, no float, and no Decimal(...) outside a short list of named files.
Every money figure comes from a separate engine, quote_to_cash: one function prices a set of lines (compute_totals), a few more work out what's paid and outstanding. The app above it, a Next.js front end over a thin FastAPI layer with a SQLite file per tenant, can store figures, show them and send them on. It can't make new ones.
The reason is practical. A quote total that the API worked out one way and the PDF another is a bug a customer finds before I do. If only one module can produce a figure, the two can't disagree. And a rule like that doesn't survive long in a README, so it lives in a test.
How do you test that code doesn't do maths?
You read the code as data. Python ships its own parser, so the test walks the syntax tree of every .py file under the package and fails on anything that looks like arithmetic:
_ARITH_OPS = (ast.Add, ast.Sub, ast.Mult, ast.Div, ast.FloorDiv, ast.Mod, ast.Pow)
for node in ast.walk(tree):
if isinstance(node, (ast.BinOp, ast.AugAssign)):
if isinstance(node.op, _ARITH_OPS) and not exempt(path, node):
violations.append(f"{path}:{node.lineno}: arithmetic operator "
f"{type(node.op).__name__} not allowed here")
On top of operators it reports any float literal, any float(...) call, any .quantize(...) call, any sum(...) outside the one aggregates module, and any Decimal(...) outside money_io.py, the boundary where a request string becomes money and money becomes a response string.
It also refuses to pass if it scanned fewer than ten files, because a gate pointed at an empty directory passes forever.
One consequence is easy to miss. pathlib overloads / for joining paths, and in the syntax tree base / "data" is a BinOp with ast.Div, the same node as a / b. The checker doesn't try to tell them apart. Instead the spec says paths are joined with Path.joinpath(), never /. It reads oddly, and it's the cheapest fix available.
The exceptions are by path, and narrow
Some files genuinely need an operator: date ageing subtracts dates, invoice numbering increments a sequence, the demo data offsets dates from today. Each gets exactly one operator, in exactly one file. Later work added a few more, and the interesting ones are narrower than "this file may use +":
- The files that keep integer counters (push attempts, failed sign-ins) may use
+only in the shape<expr> + 1. The check looks at the right operand and requires the literal integer1. It also rejectsTrue, becauseboolis a subclass ofintin Python andn + Truewould otherwise pass. - The approvals store may use
+only as<anything> + timedelta(...), for a 30-minute expiry. A static check can't know a name holds adatetime, so it pins the half it can see: the right operand must be a literaltimedelta(...)call.now + LIFETIMEis refused even though it's correct, so the code saysnow + timedelta(minutes=LIFETIME_MINUTES).
Each narrowing has a planted-violation test. n + 2, 1 + n and a + b in a counter file must all be reported, and n + 1 in a file with no exemption must be reported too.
What the first version let through
The first version of the gate went through a cold-read review, which found three holes.
Spellings. Version one looked for a bare name Decimal being called. These all construct a Decimal and all passed:
import decimal
from decimal import Decimal as D
decimal.Decimal("5")
D("5")
Decimal.from_float(x)
builtins.float("1")
The fix collects every alias bound by from decimal import Decimal as X in the file, and treats any call ending in .Decimal, .from_float or .float as a construction, whatever comes before the dot. A test plants all four and asserts exactly four violations.
Basenames. Exemptions were matched on the file name alone. money_io.py may construct Decimal, so a future api/money_io.py would have inherited that right just by sharing a name. Root-level exemptions now require path.parent == package_root, and nested ones are keyed by the path relative to the package root. The test builds a fake package with the same file at both levels and checks only one is exempt.
Unchecked calls inside the exempt file. The aggregates module is allowed sum, but only as sum(values, Decimal("0.00")). Version one never looked at the arguments, so sum([a, b]) with an integer start passed. It now requires exactly two positional arguments, the second being that literal.
The common thread: the gate was checking one spelling of the rule rather than the rule.
The one file that's allowed to divide
Then I added staged invoicing: bill 60% of a quote now and the rest on completion. That needs a percentage of a figure, which is multiplication and division. It went into one new file, staged/split.py, with its own short list of operators and three allowed literals: Decimal("0.01"), Decimal("0.00") and Decimal("100").
The rule in that file is that the stage that closes a quote is a remainder, never a fresh percentage. Here's the demo quote from the spec:
| Labour | Materials | Net | VAT 20% | Gross | |
|---|---|---|---|---|---|
| Whole quote | 338.00 | 272.00 | 610.00 | 122.00 | 732.00 |
| 60% stage | 202.80 | 163.20 | 366.00 | 73.20 | 439.20 |
| Closing stage (remainder) | 135.20 | 108.80 | 244.00 | 48.80 | 292.80 |
338.00 - 202.80 = 135.20. Taking 338.00 × 40 / 100 gives the same 135.20 here, which is exactly the problem. The fixed oracles in the spec couldn't distinguish the two rules, because for these figures no whole-number percentage does.
Try a split that doesn't divide cleanly: 33.33%, 33.33%, 33.34%. Price the third stage as a fresh 33.34% and the three nets come to 610.01. A penny more than the quote.
Why the remainder still came out a penny short
The first implementation took the remainder on the line amounts only, then priced each stage as an ordinary invoice. The nets summed to 610.00. VAT didn't.
The engine rounds VAT once per invoice: it rounds the net, then rounds net × 20 / 100 half-up. Each of the first two stages has a net of 203.32 and VAT of 40.664, rounded to 40.66. The closing stage's lines come to 203.36, VAT 40.672, rounded to 40.67. Total VAT: 121.99 against the quote's 122.00. Gross: 731.99 against 732.00.
With two stages this never shows up. Whatever the first stage's rounding drops, the second stage's rounding picks back up. Every oracle in the spec had two stages. The review that caught it tried three.
The fix takes the remainder of every figure on the closing stage, not just the lines:
def _closing_totals(whole: Totals, earlier: Sequence[Totals]) -> Totals:
return Totals(
net_gbp=split.remainder(whole.net_gbp, [t.net_gbp for t in earlier]),
vat_gbp=split.remainder(whole.vat_gbp, [t.vat_gbp for t in earlier]),
gross_gbp=split.remainder(whole.gross_gbp, [t.gross_gbp for t in earlier]),
# ...CIS deduction, retention and payable the same way
)
The closing stage now carries VAT of 40.68 (122.00 - 40.66 - 40.66), a penny off what quantize(203.36 × 0.20) gives on its own. That penny is the one the earlier stages rounded away, and the last invoice is where it belongs, as a contractor's final invoice balances the account. Gross is still net plus VAT, because a difference of differences is the difference of the sums. And the subtraction goes through split.remainder, so the gate needed no new exemption.
The proof is a test that runs 7 percentage sets across 3 quotes, including a reverse-charge quote with a CIS deduction, checks all six figures against the quote's own, and goes red against a planted mutation that re-prices the closing stage from its lines.
The same review found a race
One more defect from that review, the kind a demo never shows. Two stage requests arriving together were both admitted. Both read "60% remaining", both wrote, one record overwrote the other, and 120% of the quote was pushed to the accounting system while 40% was still marked as invoiceable.
The fix is a per-quote lock held across check, write and push, plus a create-only write that raises if the record already exists. The test races two threads through two API clients with a gate clock that holds both inside the check-then-write window.
That lock is in-process only, and the commit says so. The storage layer has no transactions or compare-and-set, so two API processes against one tenant file could still race. The app runs a single local API process, so the lock covers what exists today; anything more belongs in the storage layer.
Would I do it again?
Yes. The gate is about 580 lines including its own tests. What it buys is less "no arithmetic" than "every exception is a reviewed line in one file, with a test proving it can't spread". When staged invoicing needed to divide, the change was one new path and three literals, not a search for stray multiplications.
If you want the same thing:
- Put every money calculation behind one module, and make the rest of the code unable to do maths by test, not by convention.
- Make the checker catch constructions, not names:
decimal.Decimal, aliases,from_float,builtins.float. - Match exemptions by full path.
- For splits, close with a remainder of every figure, and test with at least three parts. Two parts hide the rounding.
The user's side of this app, rather than its internals, is described on the quoting automation page: quotes priced on the business's own rate card and checked by a person before they go out.
This article was drafted with AI assistance, working from the project's own code, specs, tests and commit history.
來源:DEV Community · dev.to