The canonical Python stack has been pinned in ~/dev/.context/AGENT.md since local-dev v0.3.1, and three skills still had Go examples with no Python counterpart. Following the structure #18 settled on, and the existing solid/references/go-adaptation.md precedent, each gets a reference file rather than inline dilution of the primary doc. The stack table itself is NOT copied into any of them — each cites AGENT.md as its home. This repo spent today learning what a second copy of a fact costs. Content is the part a linter cannot give you: - clean-code: `raise X from e` as the analogue of `%w` error wrapping, dataclass over `dict[str, Any]`, and the two traps where the broken version reads tidier — mutable default arguments, and `if not amount:` for a value where zero is legitimate. - solid: `Protocol` over `ABC`, defined at the consumer, which is what makes DIP real in Python; plus what does NOT carry over from the Go rewrite (return structs/accept interfaces, embedding, package-as-deployment-unit). - code-review: a Python anti-pattern table parallel to the Go one, opening with the rule that a finding ruff or mypy already catches is not a review comment, it is a question about why the gate is not running. Refs #20 Bump-Type: minor
4.1 KiB
Clean Code: Python Adaptation
The principles in SKILL.md are language-agnostic. This file covers the places where
Python's idioms differ from the Go examples, and where following the Go shape produces
bad Python.
The canonical stack (ruff, mypy --strict, pytest, uv, pydantic-settings, src-layout)
lives in ~/dev/.context/AGENT.md and is not restated here — one source of truth, and
it is not this file.
Reveal intent with types, not comments
mypy --strict is the Python analogue of Go's compiler, and it is non-negotiable for
the same reason: a type is a claim the machine checks, and a comment is a claim nobody
checks. Two consequences for clean code specifically:
Anyis a hole in the argument. Each one says "the reader is on their own here". If a value is genuinely polymorphic, say so precisely —Protocol, a union, aTypeVar— rather than surrendering.- A
# type: ignoreneeds the error code and a reason:# type: ignore[arg-type] # boto3 stubs lag the API. A bare ignore is an unexplained exception to a rule the rest of the file follows.
Errors: chain them, never swallow them
Go's fmt.Errorf("operation: %w", err) is raise with from:
# Bad — the original cause is gone, and so is the stack that produced it
try:
row = fetch(invoice_id)
except KeyError:
raise InvoiceNotFound(invoice_id)
# Good — the chain survives; the traceback shows both
try:
row = fetch(invoice_id)
except KeyError as e:
raise InvoiceNotFound(invoice_id) from e
Two rules with no exceptions:
- Never bare
except:. It catchesKeyboardInterruptandSystemExittoo, so it turns Ctrl-C into a swallowed error.except Exceptionif you truly mean everything. - Never a silent
pass. An exception handler that does nothing is a decision to ignore a failure; if that is genuinely right, the handler needs a comment saying why, and usually a log line. Most of the time it is a bug being stored for later.
Data classes over dicts
A dict[str, Any] passed between functions is Go's map[string]interface{} — every
reader has to reconstruct the shape from the code that writes it.
# Bad: shape known only by reading every producer
def total(invoice: dict[str, Any]) -> int: ...
# Good: shape declared once, checked everywhere
@dataclass(frozen=True, slots=True)
class Invoice:
id: InvoiceId
lines: tuple[Line, ...]
currency: str # ISO 4217
def total(invoice: Invoice) -> Money: ...
frozen=True where the value is conceptually immutable — it makes accidental mutation
an error rather than a surprise at a distance. Use pydantic models at the boundary
(env config, HTTP payloads) where validation is the point, and dataclasses inside,
where validation has already happened.
Two Python-specific traps that read as clean code
Mutable default arguments are evaluated once, at definition:
def add_line(line: Line, lines: list[Line] = []) -> list[Line]: # shared across calls
Use None and build inside. Ruff catches this (B006); the point of naming it here is
that the broken version looks tidier than the correct one.
Truthiness is not presence. if not amount: is true for 0, 0.0, "", [] and
None alike. For anything where zero is a legitimate value — money, counts, offsets —
write if amount is None:. This is the single most common way a Python function
silently mishandles a valid input.
Structure
src/layout,pyproject.tomlonly. A flat layout lets tests import the working directory instead of the installed package, so the suite passes on a package that cannot actually be installed.- Module names are the unit of cohesion, as Go packages are.
utils.pyis the Python spelling of a god package: it has no reason to change because it has no subject. - Prefer functions to classes with one method. A class whose only job is to hold
constructor arguments and expose
run()is a closure with ceremony.
See also
references/clean-code.md— the language-agnostic practicesreferences/code-smells.md— the smell catalogue, all of which apply heretdd/references/python.md— the test-side conventions