Code style & conventions¶
uv run ruff check --fix . # lint (line length 100, E/F/I/UP/B rules)
uv run mypy src alembic tests # strict type checking
uv run alembic check # verify SQLModel models match the latest migration
All three are part of what "done" means for a change here, alongside
the live-verification discipline in Setup & testing —
ruff/mypy catch what they catch, but neither proves a feature
actually works against real infrastructure.
Typing, and the type: ignores that stay¶
mypy runs in strict mode with the pydantic.mypy plugin enabled (see
[tool.mypy] in pyproject.toml), and the tree is clean. A handful of
# type: ignore comments survive that, deliberately — if you are tempted
to clear the last ones out, read this first.
They fall into two groups, and both are about someone else's types, not ours:
- Untyped third-party decorators. The MCP SDK's
MCPServer.custom_routecarries no annotations, sostrictmode flags any function it wraps (untyped-decorator). It is down to a single occurrence — the SDK 2.x port removed the other one, since the low-level resource registration that used to need a decorator is now an ordinary, fully typedadd_request_handler()call. Silencing the last one needs either upstream changes or a local stub package for the SDK — a lot of surface area to maintain for no checking we'd actually gain. - Test scaffolding that takes
**overrides. The formatter tests build fixture objects from a defaults dict splatted into a dataclass. Spelling out typed keyword parameters for every field of every builder trades a one-line ignore for a few dozen lines of boilerplate in code that exists to make other tests readable. (dataclasses.replacelooks like a way out; it isn't —mypychecks that too.)
The rule when you hit a new one: prefer a narrowing cast over an
ignore whenever the type is knowable. An ignore[attr-defined] doesn't
just silence the error, it makes the whole expression Any and stops
checking everything downstream of it. db/result.py's affected_rows()
is the worked example — AsyncSession.execute() is typed as returning
Result[Any], which has no rowcount, but DML really returns a
CursorResult, which does. One documented cast in one place restores
int typing at every call site that needs a row count.
If an ignore genuinely has to stay, keep it error-code-specific
(# type: ignore[arg-type], never a bare # type: ignore) so it can't
quietly absorb an unrelated error later, and say why in a comment or
docstring.
Docstring convention¶
Google style (docstring_style = "google" in zensical.toml), with
the narrative kept in front. src/qmd_py/store/collection.py is the
worked reference — copy its shape.
The primary content is still decision-log prose: what a typical docstring explains is why a design choice was made, what alternative was rejected and why, or what real bug shaped the current code. Google sections are additive, not a replacement — they exist for the few things prose states badly.
For example (src/qmd_py/config.py's actual docstring on
sqlalchemy_url):
Deliberately just our own schema, NOT
qmd_py,public. TSVECTOR is a built-in Postgres type (doesn't needpublicon the path at all), and a multi-entry search_path breaks Alembic autogenerate in a subtle way: Postgres'spg_table_is_visible()... considers a table visible if ANY search_path entry resolves to it — so withqmd_py,publicon the path, [it] proposed dropping [unrelated tables it shouldn't have touched] ...
That preamble is the part that carries weight: a contributor reading a
signature can already see the parameter types (this project is mypy
--strict end to end); what they can't see is why it's shaped that way,
or what it would break to "simplify" it. When you make a non-obvious
choice, or fix a bug that wasn't obvious from the code alone, that's what
the docstring is for.
Which sections to use¶
Three rules, in order of how much they matter:
- Never put types in the docstring. Write
name: Unique per owner, notname (str): .... Everything is annotated andshow_signature_annotationsrenders the real signature, so a type in prose is a second copy that mypy can't check and that drifts on the first refactor. Raises:almost always earns its place. Which exception a caller gets — and when — is the least guessable thing about these functions.CollectionNotFoundErrorversusPermissionDeniedErrordepends on whether the lookup prefiltered on ownership in SQL; anIntegrityErroron flush versus an up-front check is a real difference to the caller.Args:is optional;Returns:is worth it for the dataclasses.session/userrepeat on nearly every service function and documenting them each time is noise — skip them and describe only the arguments with something to say.Returns:earns its place where the type name doesn't tell the whole story (ReindexResult,RemoveCollectionResult), andAttributes:on the dataclass itself is usually the better home for that detail.
Note: is useful for the caveat that doesn't belong in the summary — a
known N+1, or a behaviour that was once wrong in an interesting way.
Two things that will bite¶
Section names are exact, and indentation matters. Args: and
Arguments: both parse; Params: doesn't. A mis-indented section isn't
an error — griffe just stops recognising it and renders it as ordinary
prose, so it looks almost right. After writing your first few, build the
docs and look at the page.
zensical build --strict is where malformed docstrings surface. It
passes today. Keep it that way — a sloppy section can break the docs build
rather than degrading quietly.
The Code reference pages are generated from these
same docstrings, and show the annotated source alongside them
(show_source = true is intentional — see zensical.toml).
Adding a new CLI command¶
- One file per command (or command group) under
src/qmd_py/cli/commands/, following the existing pattern (aclick.command/click.group, an_implasync function it delegates to viacli/runtime.py's session-opening helper). - Register it in
src/qmd_py/cli/main.py. - If it returns multiple results in a list, consider reusing
cli/formatter.py's existing--formatmachinery rather than adding a new one-off output path. - User-facing strings (help text, error messages) should say
marq, notqmd— see Architecture › Naming for why that distinction is intentional and maintained deliberately, not just cosmetic.