Repository navigation
Docstrings: the migrations API, PassReport and the CLI commands #721
Copy link
Copy link
Closed
Labels
documentationImprovements or additions to documentationImprovements or additions to documentationready-for-agentUnblocked and fully specified; an agent may claim itUnblocked and fully specified; an agent may claim itsize:LCross-cutting or multi-worker; local waves onlyCross-cutting or multi-worker; local waves only
Description
Parent
What to build
ferro.migrate's and create_tables' Returns: sections render as five and two broken rows; about 60 parameters across the migrations API are described only in prose; RunReport, StatusReport and CheckReport, the return types of up(), status() and check(), have no docstrings worth rendering; ferro.migrations.chunked resolves to a private module. CLI help text is user-facing prose under the same rules.
Every public docstring and every CLI command docstring in these modules follows I-14; tests/test_cli_help.py stays green.
Acceptance criteria
- Every public function, method, class, property and dataclass/Pydantic field in the owned modules has a docstring in Google style as griffe parses it (no griffe warning at default level with
docstring_parser="google"). - Every summary is one line, in the infinitive ("Fetch one record by primary key."), ending with a period; class summaries are noun phrases ending with a period (docs conventions from AGENTS.md, written by the AGENTS.md ticket (AGENTS.md: rewrite I-8 for the lambda-only query API, add a docstring invariant, record the docs conventions #703)).
- Every parameter appears in
Args:with its default stated inline;ruff check --select D417reports nothing for the owned files. - No Sphinx roles (
:class:,:meth:,:func:,:attr:,:data:) and no RST::blocks remain; cross-references are Markdown autorefs and resolve in the strict build. -
Returns:,Raises:,Attributes:andArgs:sections render as tables with one row per item (continuation lines indented, no blank line under a section header). - No ADR numbers, issue numbers, codenames, Rust internals or test paths in docstrings; explanations longer than a paragraph move to a guide or concepts page and are linked.
- Examples sections use the lambda style named after the model, run against the shared reference prelude inside a session, and are marked to run (
{test=run prelude=reference}).
Files owned
- Modify:
src/ferro/pass_report.py,src/ferro/migrations/__init__.py,src/ferro/migrations/runner.py,src/ferro/migrations/report.py,src/ferro/migrations/steps.py,src/ferro/migrations/chunked.py,src/ferro/migrations/context.py,src/ferro/migrations/testing.py,src/ferro/migrations/alembic.py,src/ferro/migrations/errors.py,src/ferro/migrations/generate.py,src/ferro/migrations/historical.py,src/ferro/migrations/rerecord.py,src/ferro/migrations/_baseline.py,src/ferro/migrations/_drift.py,src/ferro/migrations/layout.py,src/ferro/migrations/scaffold.py,src/ferro/migrations/target.py,src/ferro/migrations/translate.py,src/ferro/cli/__init__.py,src/ferro/cli/migrate.py,src/ferro/cli/output.py - Test:
tests/test_cli_help.py
Interfaces
- Consumes: I-14 (AGENTS.md ticket (AGENTS.md: rewrite I-8 for the lambda-only query API, add a docstring invariant, record the docs conventions #703))
- Consumes: harness (harness ticket (Docs harness: run inline blocks with a prelude, run docstring examples, and check lambda names and error checks #704))
- Consumes: plain-text CLI help and
tests/test_cli_help.py(CLI help ticket (CLI help keeps its placeholders:ferro migrate --helpprints<name>,<migration>:<step>and the rest #702)) - Produces: Docstrings for
RunReport,StatusReport,CheckReportand everyPassReportfield, rendered by the API completeness ticket (API reference completeness: every public object has an entry, pages named after their objects #722).
Test scenarios
- Happy path:
tests/test_docstring_examples.pyruns every marked example in the owned modules and they pass. - Happy path: the strict build succeeds and the rendered API pages for the owned objects contain no literal
:class:-style roles. - Edge case:
ruff check --select D417over the owned modules exits 0. - Error path: removing one parameter from an owned docstring's
Args:makesruff check --select D417fail.
Verify
uv run --no-sync zensical build --clean --strict
uv run --no-sync pytest tests/test_docstring_examples.py -q --no-cov -p no:cacheprovider
uv run --no-sync python -m tests._docs_checks src/ferro/pass_report.py src/ferro/migrations/__init__.py src/ferro/migrations/runner.py src/ferro/migrations/report.py src/ferro/migrations/steps.py src/ferro/migrations/chunked.py src/ferro/migrations/context.py src/ferro/migrations/testing.py src/ferro/migrations/alembic.py src/ferro/migrations/errors.py src/ferro/migrations/generate.py src/ferro/migrations/historical.py src/ferro/migrations/rerecord.py src/ferro/migrations/_baseline.py src/ferro/migrations/_drift.py src/ferro/migrations/layout.py src/ferro/migrations/scaffold.py src/ferro/migrations/target.py src/ferro/migrations/translate.py src/ferro/cli/__init__.py src/ferro/cli/migrate.py src/ferro/cli/output.py
uv run --no-sync ruff check --select D417 src/ferro/pass_report.py src/ferro/migrations/__init__.py src/ferro/migrations/runner.py src/ferro/migrations/report.py src/ferro/migrations/steps.py src/ferro/migrations/chunked.py src/ferro/migrations/context.py src/ferro/migrations/testing.py src/ferro/migrations/alembic.py src/ferro/migrations/errors.py src/ferro/migrations/generate.py src/ferro/migrations/historical.py src/ferro/migrations/rerecord.py src/ferro/migrations/_baseline.py src/ferro/migrations/_drift.py src/ferro/migrations/layout.py src/ferro/migrations/scaffold.py src/ferro/migrations/target.py src/ferro/migrations/translate.py src/ferro/cli/__init__.py src/ferro/cli/migrate.py src/ferro/cli/output.py
uv run --no-sync pytest tests/test_docs_site.py -q --no-cov -p no:cacheprovider
uv run --no-sync pytest tests/test_cli_help.py -q --no-cov -p no:cacheproviderBlocked by
- Docs site config: Zensical features, Markdown extensions, API reference options and a strict build #701, CLI help keeps its placeholders:
ferro migrate --helpprints<name>,<migration>:<step>and the rest #702, AGENTS.md: rewrite I-8 for the lambda-only query API, add a docstring invariant, record the docs conventions #703, Docs harness: run inline blocks with a prelude, run docstring examples, and check lambda names and error checks #704
Audit findings
Rows from the docs audit this ticket covers (file, line, rule, fix)
src/ferro/pass_report.py — PassReport, ExecutedStatement, Report, Subject
- Kind: reference
- Verdict: compact, and
ExecutedStatementdocuments each field properly. Elsewhere, field meanings sit in prose and therolevalues are never listed. B
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| M | 52 | 21 | role: "What it was for (see Role)". Role is not on the API page, so a reader cannot learn the values (the guide filters on "schema"). |
List the literal values inline. |
| M | 76–93, 56–64, 110–120 | 21 | The fields of Report (kind, subject, text, recurs), Subject (scope, table, column, type_name) and PassReport (statements, warnings) have no attribute docstrings. They render bare beside the prose. |
Use per-field """…""" as ExecutedStatement does. |
| L | 76, 110, 56 | 21 | Three multi-line summaries. "ADR-0052" in a summary line. "as before" (line 115) is change history. | One line each. Drop the ADR number and "as before". |
src/ferro/migrations/ core — up, require_applied, status, check, baseline, remove_baseline, drift, render_op, step declarations, errors, BaselineReport, DriftReport, RunReport, HistoricalModels
- Kind: reference, written as dense specification prose
- Running example:
author.slugbackfill (chunked,todo) — consistent with the guides - Verdict: precise and refusal-aware, but these docstrings are hard to scan. No function has an
Args:table. Summaries run over two to three lines. 28 Sphinx roles render as literal text.up()'s return type is never documented on the page. C
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| H | runner.py 207, 1121; generate.py 1191 | 21 | up() returns RunReport, status() StatusReport, check() CheckReport, and MigrationRefused.report holds one of them. None of the three is rendered on api/migrations, so a reader of up() cannot see .applied, .inherited or .refused. |
Add ::: ferro.migrations.runner.RunReport, …report.StatusReport and …generate.CheckReport, or export them. |
| H | runner.py 415ff (and every module here) | 22 | 42 roles in runner.py and 20 in generate.py. On the rendered page: :class:<code>MigrationRefused</code> ×4, :data:<code>AllowAhead</code> ×2, :class:<code>StatusReport</code> ×2, :func:<code>ferro_options</code> ×2, 28 in total. Several targets (InheritedEvent, StepEvent, RunNote, AllowAhead, Target.resolve) are on no page at all. |
Replace with [up][ferro.migrations.up] autorefs, or plain code when there is no target page. |
| M | runner.py 415, _baseline.py 185/219, _drift.py 429, runner.py 1121/1142, steps.py 158/193/204 | 21 | 34 parameters across 12 functions are described only inside paragraphs (up packs 8 parameters into one 17-line paragraph). The API page has no parameter table. The page's intro paragraph covers the shared settings/database/using/url once. |
Add Args: for the parameters specific to each function (allow_ahead, progress, on_event, lock_timeout, target, query, batch_size, reason). The shared four can say "See Running migrations". |
| M | 31 of 65 functions | 21 | The summary line does not end the sentence (e.g. up: "Apply every pending step of every pending migration, in order, under" / "the run lock (ferro migrate up)."). |
One line ending with a period. Details go in the next paragraph. |
| M | 18 refs | 1, I-11 | ADR and issue numbers in user-facing text: "(ADR-0038)", "(ADR-0054)", "(ADR-0056)", "There is no override (ADR-0031)", "#443", "#446", "(ADR-0052)". Readers cannot follow them. | Remove them, or link the ADR on GitHub when the decision matters. |
| M | steps.py 158–172 | 21 | chunked raises TypeError/ValueError at decoration and StepRefused at run. None is in Raises:. Its example uses ::. (The lambda style is fine: models, author, I-8 ✓.) |
Add Raises: and fence the example. |
| M | migrations/init.py 37 vs chunked.py |
22 | Statically, ferro.migrations.chunked resolves to the private module migrations/chunked.py ("The chunk loop…"), not the decorator. At runtime it is the decorator. The API page works around this with ::: ferro.migrations.steps.chunked, but any autoref to ferro.migrations.chunked lands on the module. |
Rename the module (_chunk_loop.py). |
| M | 9 places | 21 | Noun-phrase summaries on functions: status "Where the database stands…", render_op "One planner op as one plain-language line…", *.render "The text … prints", HistoricalModels.names/tables/table/unreachable. |
Infinitive: "Report where the database stands…", "Render one planner op as…". |
| L | errors.py 63–107, report.py 105–145 | 21 | Exception attributes (pending, refusals, ahead, contract_steps, applied, above, report) are described in prose only. |
Attributes: sections. |
| L | errors.py 92 | 15 | "allow_ahead="expand-only" lets the database through when it is empty": "it" is ambiguous (contract_steps). |
"…when contract_steps is empty". |
| L | steps.py 150–152 | 21 | atomic's summary ends with the fragment "async def up(ctx)." and has no example. irreversible/nothing_to_reverse have no example and no Args: reason. |
Add a three-line example each. |
src/ferro/migrations/testing.py — harness, Harness, RoundTripResult
- Kind: reference
- Running example:
0011/0012slug backfill withAuthor(I-8 ✓) - Verdict: the best in scope. Each method states its precondition and what is refused, with small checkable values. The rich example lives in the module docstring, which the page does not render. B
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| M | 1–49 | 2, 8 | The test_slug_backfill walk-through (the one example showing seed → apply → read back) is in the module docstring, and api/migrations.md renders only harness, Harness and RoundTripResult. |
Move the example into harness()'s docstring, or render the module. |
| L | 121, 438, 237 | 22 | :class:/:meth:/:func: roles (11 in the file). harness's summary is a noun phrase over three lines. |
"Return a Harness over database…" on one line, then the defaults as Args:. |
| L | 136, 187, 213 | 21 | migration is described in prose only ("0007" or "0007_split_name"). |
Args: migration. |
src/ferro/migrations/alembic.py — get_metadata, ferro_options, render_item
- Kind: reference
- Verdict: accurate, and
ferro_optionsleads with theenv.pysnippet the reader needs. It reads more like explanation than reference in places. B
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| M | 106–131 | 22 | get_metadata links :class:~ferro.base.FerroField`` /\~ferro.base.ForeignKey\ (internal paths, rendered literally). The bold "Column nullability:" is a pseudo-heading. |
[FerroField][ferro.FerroField], and a plain paragraph. |
| L | 370–389 | 1, I-11 | render_item explains SQLAlchemy repr internals and cites #443/#446. Users only pass it through ferro_options. |
Cut to two sentences plus "You do not call this directly." |
| L | 446, 370 | 21 | Noun-phrase summaries ("The options env.py passes…", "Alembic render_item hook…"). include_object/render_item have no Args:. |
"Return the options…" plus Args:. |
src/ferro/cli/ — main, render_refusal, Global, and ferro migrate <verb> --help
- Kind: reference (help text)
- Verdict: the verb summaries are the most consistent prose in scope: all infinitive, one line, and exit codes stated (
check0/3,status0/3/4,drift0/4). One rendering defect wipes out the placeholder in seven help strings. C (B once the H is fixed)
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| H | migrate.py 170, 179, 198, 210, 222, 270, 280, 289, 308, 690 | 8, 22 | cyclopts renders help as Markdown, which drops <name>, <model>, <table>.<column>, <migration>:<step> as HTML tags. The real output reads "writes NNNN_/", "SQL step NN_.up.sql", "data step NN_backfill_.py", "this column (., repeatable)", and for rerecord: "The step whose edit to accept: :, as 0007:01." |
Set help_format="plaintext" (or "rich") on both Apps, or wrap placeholders in backticks. Add a test that asserts <name> survives in --help. |
| M | migrate.py 210–213 | 15 | --replace help ends "…carrying its data steps and hand-written SQL steps across: two branches that each generated the same number." The use case comes after the colon as a fragment. |
Put the condition first: "When two branches generated the same number, regenerate this migration…". |
| L | status --help |
22 | --steps --no-steps and --json --no-json show negative flags, while every other boolean sets negative="" (--verbose, --data-only, --all). |
negative="" on both. |
| L | new/squash --help |
— | An extra blank line after the summary, and a blank line after --no-backfill STR... and rerecord's TARGET. Both are side effects of the same Markdown parsing. |
Fixed by the plaintext format. |
| L | cli/init.py 1–20, 42, 53 | 22 | Module/Global/render_refusal docstrings use :func:/:data:/:class: roles and "(ADR-0036: …)". No API page renders them, so this is low impact. |
Convert if they are ever rendered. |
| L | ferro --help |
22 | "ferro's command line: schema migrations for ferro models." starts lowercase. Docstrings elsewhere say "Ferro" in prose (33×) and "ferro" (≈400× incl. code). | Pick one prose capitalisation for the product name. |
Cross-cutting patterns
- Sphinx/RST markup inside Google docstrings (193 roles in in-scope modules; 43 render literally on the API pages in scope, plus
::markers in 8 places).:class:X``,:func:,`:meth:`,`:data:`show as literal text, and the targets are often unrendered (`InheritedEvent`,`StepEvent`,`AllowAhead`,`RouteHandle`). One mechanical pass to mkdocstrings autorefs (`[X][ferro.X]`) and fenced code fixes every case. Add a lint (`ruff` cannot catch this; a grep in CI can). - Parameters described in prose, not
Args:(≈60 undocumented parameters across 25 functions). This affects the whole migrations API, therawfunctions (sql,*args,using,session), the settings methods andField(db_type,db_check,reverse_index). The API page then has no parameter table, and defaults are buried mid-paragraph. - Undocumented dataclass/pydantic fields (≈45 of the 54 missing docstrings). Affected:
PoolConfig,DatabaseSettings,Session,Report,Subject,PassReport,Check,RowSecurityand the exception attributes.ExecutedStatement,RunReportandRoundTripResultalready show the fix, a"""…"""under each field. - Multi-line summaries (31 of 65 functions, 9 classes). The summary line ends mid-clause ("…, under" / "the run lock"). Rule 21 wants one line ending with a period.
- Verb form: infinitive (46) vs noun phrase (18), never third-person. The project has in effect chosen infinitives. The 18 value-returning methods written as noun phrases are the outliers to convert. Exceptions use three different opening forms.
- Internals and project history in public docstrings (18 ADR/issue refs, plus registry/Rust/ContextVar mechanics). Affected:
clear_registry,ensure_resolved_modelset,create_tables,raw.execute,FerroSettings,render_item. Readers cannot followADR-0056or#443, and "pushes the SchemaIR to the Rust runtime" answers no user question (I-11). - Explanation essays in reference entries (
PoolConfig~90 lines,Session.set_config68 lines,connect96 lines). The content is good, but it belongs in the RLS/connections guides with a link (rule 1).set_configeven sends readers to the source. - Silent rendering breakage griffe never warns about (3 places + 1 CLI-wide). Unindented
Returns:continuations split into 2 and 5 table rows (create_tables,migrate). Types sit inside the Returns description (current_session). Markdown help rendering erases<placeholders>from seven CLI help strings. Running griffe for warnings alone is not a sufficient gate; a test that renders the pages and greps for:class:, repeated return rows and empty placeholders would catch all three.
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
documentationImprovements or additions to documentationImprovements or additions to documentationready-for-agentUnblocked and fully specified; an agent may claim itUnblocked and fully specified; an agent may claim itsize:LCross-cutting or multi-worker; local waves onlyCross-cutting or multi-worker; local waves only
Activity
ferro migrate --helpprints<name>,<migration>:<step>and the rest #7020x054 commentedon Oct 19, 2026
Status: verify-passed
Worker: worker-721 (reviewed by reviewer-721)
Branch:
sdd/721-docstrings-migrationsPR: #745
Verify: passed 456544e (trial merge into
feat/docs-rewriteat a7cbca6 passed too)Review: Spec PASS, Quality PASS, with no Critical or Important findings. The full suite fails only the 3 known RLS step-file tests in
tests/test_docs_migrations.py, which #744 fixes.Three Minor ticket-mandated notes for the human:
ferro migrate statusno longer accepts--no-steps/--no-json, which were no-op flags used nowhere;ferro.migrations.chunkedthe module is gone (from ferro.migrations.chunked import encode_cursornow raises ImportError); 20 examples are compile-only instead of{test=run}.The Verify correction (the
chunked.pypath became_chunk_loop.py) is valid.docs(api): migrations, pass report and CLI docstrings as Google-style…
0x054 commentedon Oct 19, 2026
Merged in #745 into
feat/docs-rewritedocs(api): every export has one API entry under its public path (#722) (