Skip to content

Docstrings: the migrations API, PassReport and the CLI commands #721

Closed

Description

@0x054

Parent

#698

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 D417 reports 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: and Args: 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

Test scenarios

  • Happy path: tests/test_docstring_examples.py runs 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 D417 over the owned modules exits 0.
  • Error path: removing one parameter from an owned docstring's Args: makes ruff check --select D417 fail.

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:cacheprovider

Blocked by

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 ExecutedStatement documents each field properly. Elsewhere, field meanings sit in prose and the role values 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.slug backfill (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/0012 slug backfill with Author (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_options leads with the env.py snippet 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 (check 0/3, status 0/3/4, drift 0/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

  1. 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).
  2. Parameters described in prose, not Args: (≈60 undocumented parameters across 25 functions). This affects the whole migrations API, the raw functions (sql, *args, using, session), the settings methods and Field (db_type, db_check, reverse_index). The API page then has no parameter table, and defaults are buried mid-paragraph.
  3. Undocumented dataclass/pydantic fields (≈45 of the 54 missing docstrings). Affected: PoolConfig, DatabaseSettings, Session, Report, Subject, PassReport, Check, RowSecurity and the exception attributes. ExecutedStatement, RunReport and RoundTripResult already show the fix, a """…""" under each field.
  4. 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.
  5. 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.
  6. 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 follow ADR-0056 or #443, and "pushes the SchemaIR to the Rust runtime" answers no user question (I-11).
  7. Explanation essays in reference entries (PoolConfig ~90 lines, Session.set_config 68 lines, connect 96 lines). The content is good, but it belongs in the RLS/connections guides with a link (rule 1). set_config even sends readers to the source.
  8. 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.

Activity

  1. added
    documentationImprovements or additions to documentation
    ready-for-agentUnblocked and fully specified; an agent may claim it
    size:LCross-cutting or multi-worker; local waves only
    on Oct 19, 2026
  2. added theissue type on Oct 19, 2026
  3. self-assigned this
    on Oct 19, 2026
  4. 0x054 commented on Oct 19, 2026

    @0x054
    ContributorAuthor

    Status: verify-passed
    Worker: worker-721 (reviewed by reviewer-721)
    Branch: sdd/721-docstrings-migrations
    PR: #745
    Verify: passed 456544e (trial merge into feat/docs-rewrite at 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 status no longer accepts --no-steps / --no-json, which were no-op flags used nowhere; ferro.migrations.chunked the module is gone (from ferro.migrations.chunked import encode_cursor now raises ImportError); 20 examples are compile-only instead of {test=run}.
    The Verify correction (the chunked.py path became _chunk_loop.py) is valid.

  5. 0x054 commented on Oct 19, 2026

    @0x054
    ContributorAuthor

    Merged in #745 into feat/docs-rewrite

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

documentationImprovements or additions to documentationready-for-agentUnblocked and fully specified; an agent may claim itsize:LCross-cutting or multi-worker; local waves only

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions