Python
Guidance for writing and editing Python in this ecosystem. Formatting, import
order, lint, and typing are enforced by the deterministic gates (ruff format,
ruff check, pyright) in the pre-commit hook — never restate a linter's rules
here or send an agent to do a formatter's job. This skill carries the judgment
those gates cannot.
Style
- Type-hint public functions, methods, and dataclass fields; let inference cover obvious locals. A public signature is a contract — annotate it.
- Prefer
pathlib.Pathoveros.pathstring munging for filesystem work. - Do not hand-format to match the formatter. Run
ruff format/ruff check(they run in pre-commit) and let them own whitespace, quotes, and import order.
except A, B: is valid here — leave it alone
This repo's floor is Python 3.14 (requires-python = ">=3.14"), where PEP 758
makes an unparenthesized multi-exception clause legal:
except ValueError, OSError: catches either type. It is not the Python 2
except Error, name: capture form and not a syntax error — src/basicly/decompose.py
uses it and imports fine. Do not add parentheses to "fix" it; that is a no-op
diff, and stopping to re-verify that the module parses costs a whole detour.
Parentheses are still required when the clause binds the exception —
except (ValueError, OSError) as err:. Dropping them there raises
SyntaxError: multiple exception types must be parenthesized when using 'as'.
Type test doubles and helpers precisely
The pre-commit gate is much more than ruff: it runs every check declared for
mode fast in basicly.toml — pyright, bandit, lint-imports, vulture,
docs-claims and the projection gates among them. So a worktree with ruff and
pytest both green can still be rejected at commit time, and a mistyped test
helper blocks the commit itself rather than CI. Commit early and let the hook
name the mismatch instead of inferring it from a clean ruff check.
Be precise about the type a helper produces, and structural about the slot a double fills:
- Return the concrete type, not
object—def _fake_args() -> argparse.Namespace:, not-> object. Anobjectreturned where a concrete type is expected tripsreportArgumentTypeat the first call site that passes it on. - Type a captured container to its real value type —
captured: dict[str, list[str]], notdict[str, object]. Adict[str, object]value tripsreportOperatorIssuethe moment the test indexes, iterates, or compares it. - Annotate a parameter that receives a double with a structural type —
Callable[..., _Proc]or aProtocol— never a sibling fake class. A helper typeddef _install(monkeypatch, fake: _FakeBr)checks clean until a second fake or alambdareaches it, and then pyright rejects those call sites with"_FakeBrShow" is not assignable to "_FakeBr" (reportArgumentType). The same helper typedCallable[..., _Proc]accepts every one of them —_installintests/test_decompose.py, which is fed_FakeBr,_FakeBrShow, and a barelambda.
Measure headroom before you place code
Two ratchets gate every module and pull opposite ways: module-size refuses growth on
a module at its frozen token baseline, and comment-density refuses a prose share
over its cap, so trimming a docstring to pay one raises the other. Paying them after
the code is written is the slowest order (four successive trims on one file, measured
2026-08-16). Before writing a line, measure every file the change will touch:
uv run python .scripts/headroom.py <file> [<file> ...] # tokens left, prose points left
A file with under ~200 tokens left wants the extraction decided now, not after the
gate refuses. A new module is never free: it needs tests/test_<module>.py for the
naming gate, a layer in .importlinter, and a git add before pytest can import it.
Cross-platform shell-out (fails only on Windows CI)
Two subprocess mistakes pass every local POSIX run and surface only on Windows CI, so they read as "flaky" when they are deterministic. Both, with reproductions and fixes, are in references/cross-platform.md:
- Building the child env — inherit
os.environ(keepPATH). A bare env dict dropsPATH, which still findsgitand friends on POSIX but raises[WinError 2]on Windows. - Embedding an OS path in a shell string — POSIX
shlex.splittreats\as an escape, so a Windows path likesys.executableis mangled. Pass an argv list, or normalize withPath(...).as_posix(); never concatenate a path into a shell string.
Read the reference before touching any code that spawns a process or builds a command line.