From e8c49ccac259433d961f6088d049ac522be7679e Mon Sep 17 00:00:00 2001 From: 0x54 Date: Mon, 19 Oct 2026 13:11:18 -0400 Subject: [PATCH 01/33] chore(docs): AGENTS.md I-8 for the lambda-only API, I-14 docstrings, docs conventions notes (#703) (#727) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #703 ## What AGENTS.md I-8 used to tell agents to order predicate styles "lambda > `col()` > operator" and call operator style "compatible today". Both were removed in 0.14, and `where()` raises `TypeError` on anything that isn't a callable. I-8 now describes the API that exists: ```python adults = await User.where(lambda user: user.age >= 18).all() recent = await Post.order_by(lambda post: post.published_at, "desc").all() ``` It covers lambda predicates, the parameter named after the model in lowercase singular, `order_by`'s string or lambda selector, and the `python -m tests._docs_checks` lint from #704. A new **I-14** sets the docstring rules: Google style as griffe parses it, a one-line infinitive summary (a noun phrase for classes), every parameter in `Args:`, a one-line docstring on every public field, Markdown autorefs, no ADR, issue, codename or Rust references, lambda-style examples against the `reference` prelude, and longer explanations moved into guides. Two new notes in `docs/solutions/patterns/`: - `docs-page-conventions.md` covers one kind per page, one running example, snippet `end:` markers below the result, the session wrapper, generated SQL (once #700 lands), the glossary file, and the one Annotated idiom for twins. - `docstring-conventions.md` shows each I-14 rule as a before/after pair. The Annotated idiom keeps the same `Field(...)` call as the Assignment tab and assigns the default outside `Annotated`: ```python # before (two idioms in today's twins) id: Annotated[int | None, Field(default=None, primary_key=True)] id: Annotated[int | None, FerroField(primary_key=True)] = None # after id: Annotated[int | None, Field(primary_key=True)] = None ``` I checked the reason the note gives. With the default inside `Annotated`, `ty` reports `C(name="a")` as `missing-argument: id`. With the default assigned outside, it passes. ## Verify Ran the ticket's Verify block on `337bcbf`: passed. Full suite: 3296 passed, 339 skipped, 2 xfailed. TDD: RED, the Verify greps against `origin/feat/docs-rewrite`'s AGENTS.md: `grep -q '^## I-14'` exited 1, and `grep -nE 'slated for deprecation|compatible today'` matched lines 433โ€“434. GREEN: the Verify block passed on `337bcbf` (4/4). Verify block corrected: no ## Interfaces Interfaces changed: none ## Notes for the reviewer - `docs/solutions/README.md` has no index of notes, only layout and frontmatter rules, so it is unchanged. - I-8 and I-14 name artifacts that #704 creates: `tests._docs_checks`, the `reference` prelude, the `{test=run prelude=โ€ฆ}` fence marker and `tests/test_docstring_examples.py`. They are attributed to #704 in the text. - The I-13 example in AGENTS.md used `lambda m:`. I renamed it to `lambda message:` so the file follows its own I-8. - [ ] Exit steps: issue status updates (#703 progress comment, closure after merge into the integration branch) are complete before merge. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- AGENTS.md | 125 ++++++++-- .../patterns/docs-page-conventions.md | 148 ++++++++++++ .../patterns/docstring-conventions.md | 226 ++++++++++++++++++ 3 files changed, 473 insertions(+), 26 deletions(-) create mode 100644 docs/solutions/patterns/docs-page-conventions.md create mode 100644 docs/solutions/patterns/docstring-conventions.md diff --git a/AGENTS.md b/AGENTS.md index 83ee9d1d..2dc4caff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -413,38 +413,45 @@ preferred declaration style. --- -## I-8: Lambda predicates are the official query style +## I-8: Queries are lambda predicates named after the model -Documentation and examples use the lambda predicate style for all queries: +A query filters with a lambda whose parameter is named after the model, in +lowercase singular: ```python adults = await User.where(lambda user: user.age >= 18).all() +recent = await Post.order_by(lambda post: post.published_at, "desc").all() ``` +Lambdas are the only predicate form. `where()` raises `TypeError` on anything +that is not a callable; the `col()` and operator-style predicates were removed +in 0.14. + Rules: -- **Every query example** in docs, docstring `Examples:` sections, and - `docs/examples/` scripts uses lambda predicates. -- Name the lambda parameter after the model in **lowercase singular** - (`user` for `User`, `post` for `Post`) so filters read naturally. -- When the predicate styles themselves are documented, present them in - order **lambda > `col()` > operator**, with lambda labeled the officially - recommended style. -- **Operator style** (`User.where(User.age >= 18)`) is compatible today but - is slated for deprecation in a future release and fails static type - checking (`User.age >= 18` types as `bool`; `where()` expects - `QueryNode | Predicate`). Docs say so explicitly wherever the style is - shown. -- **`order_by` is not a predicate**, but its lambda selector - (`order_by(lambda u: u.age, "desc")`) is validated and is the documented - style โ€” and it is the ONLY way to order by a related column - (`order_by(lambda t: t.account.label)`), which relation traversal requires. - Attribute style is not accepted โ€” `order_by` takes a column-name string or - a lambda selector (anything else raises `TypeError`). - -The canonical comparisons live in `docs/pages/guide/queries.md` -("Predicate Styles") and `docs/pages/concepts/query-typing.md`; everywhere -else uses lambda without restating the trade-offs. +- **Every query example** in docs pages, docstring `Examples:` sections and + `docs/examples/` scripts passes lambdas to query methods (`where`, + `select`, `order_by`, `include`, `exists`, `count`, `aggregate`, + `group_by`, `after`, `before` and every other method that takes a + callable). +- **Name the parameter after the model in lowercase singular**: `user` for + `User`, `post` for `Post`, `line_item` for `LineItem`, so the predicate + reads as the sentence it means. A traversal keeps the root model's name + (`lambda transaction: transaction.account.label`). +- **`order_by`** takes a column-name string (`order_by("created_at", + "desc")`) or a lambda selector (`order_by(lambda post: post.created_at, + "desc")`); anything else raises `TypeError`. The lambda is the documented + form, and the only one that orders by a related column + (`order_by(lambda transaction: transaction.account.label)`). +- **The lint** `uv run --no-sync python -m tests._docs_checks PATH...` lists + every lambda passed to a query method whose parameter is one character, + as `path:line: message`, and exits 1. It reads Python files, Python fences + in Markdown and docstrings (added by #704; #724 makes it a test gate). + Run it over every docs page, example and docstring you touch. + +`docs/pages/guide/queries.md` and `docs/pages/concepts/query-typing.md` +explain how lambda predicates are evaluated and typed; every other page uses +them without restating that. --- @@ -519,8 +526,8 @@ key: pinned_at = datetime(2026, 3, 1, 15, 0, tzinfo=UTC) await Message(id=2, pinned_at=pinned_at, ...).save() page = await ( - Message.order_by(lambda m: m.pinned_at) - .order_by(lambda m: m.id) + Message.order_by(lambda message: message.pinned_at) + .order_by(lambda message: message.id) .after((pinned_at, 2)) .all() ) @@ -547,6 +554,72 @@ Architecture review: I-22 in #428. See PRD #430 and --- +## I-14: Docstrings are short Google-style reference entries + +The API reference is rendered from docstrings by mkdocstrings and griffe, so +a docstring is a reference entry, written once in one form: + +```python +async def get(cls, pk: Any, *, session: Session | None = None) -> Self: + """Fetch one record by primary key. + + Args: + pk: The primary key value to look up. + session: The session to run in. Defaults to the one opened by the + enclosing `async with engines.session():`. + + Returns: + The record whose primary key is `pk`. + + Raises: + ModelDoesNotExist: No row has that key. Use + [`get_or_none`][ferro.Model.get_or_none] to get `None` instead. + + Examples: + ```python {test=run prelude=reference} + author = await Author.get(1) + print(author) + ``` + """ +``` + +Rules: + +- **Google style as griffe parses it**: `Args:`, `Returns:`, `Yields:`, + `Raises:`, `Attributes:`, `Examples:`, each a section griffe recognises, + with entries indented under it. A section griffe drops or splits into + table rows is a bug in the docstring. +- **The summary is one line ending with a period.** A function or method + summary is an infinitive ("Fetch one record by primary key."); a class + summary is a noun phrase ("A lazy query over one model's rows."). The + body starts after a blank line. +- **Every parameter is in `Args:`**, keyword-only ones included + (`session`, `using`, `nulls`), with the default stated when it is not + obvious from the signature. +- **Every public dataclass or Pydantic field** has a one-line docstring: a + string literal on the line below the field. +- **Cross-references are Markdown autorefs**: + `` [`Model.where`][ferro.Model.where] `` or `[Model][ferro.Model]`. + Sphinx roles (`:class:`, `:func:`, `:meth:`, `:data:`) render as literal + text. +- **Every reference is one the reader can follow.** Docstrings carry no ADR + numbers, issue numbers, internal codenames or Rust internals (crate, + function and FFI names); a link to a docs page carries the reasoning. +- **Examples use lambda predicates (I-8) against the shared reference + prelude** (`docs/examples/_preludes/reference.py`: `Author` and `Post`, + connected, inside a session), in a fence marked + `{test=run prelude=reference}` so the docs harness + (`tests/test_docstring_examples.py`, from #704) runs it. +- **An explanation longer than one paragraph lives in a guide or concepts + page**, and the docstring links to it. + +`docs/solutions/patterns/docstring-conventions.md` shows each rule as a +before/after pair. Page-level conventions (running examples, snippet +markers, the session wrapper, the Annotated idiom for I-7 twins) are in +`docs/solutions/patterns/docs-page-conventions.md`. + +--- + ## Agent skills ### Issue tracker diff --git a/docs/solutions/patterns/docs-page-conventions.md b/docs/solutions/patterns/docs-page-conventions.md new file mode 100644 index 00000000..14f49b32 --- /dev/null +++ b/docs/solutions/patterns/docs-page-conventions.md @@ -0,0 +1,148 @@ +--- +title: Docs page conventions (kind, running example, results, session, SQL, glossary, Annotated twins) +type: pattern +tags: [docs, convention, zensical, pydantic] +related_files: + - AGENTS.md + - docs/examples/ + - docs/includes/abbreviations.md + - tests/test_docs_examples.py + - zensical.toml +related_issues: [698, 700, 701, 703, 704, 725] +related_prs: [] +captured: 2026-10-19 +--- + +## Problem + +The 2026 docs audit found pages a reader could not copy from: snippets that +stop just above the answer, three to fourteen unrelated models per page, no +session wrapper anywhere, no SQL, and Annotated tabs that differ from their +Assignment twins in more than declaration style. + +## Takeaway + +A page is one kind, uses one running example, shows every result, shows the +session it runs in, and shows the SQL Ferro sends. Annotated twins use one +idiom: `name: Annotated[T, Field(...)] = default`. + +## The conventions + +### One kind per page + +Each page is a tutorial, a how-to, a reference or an explanation, and opens +with one sentence saying what it covers, then an example on the first screen. +Material of another kind moves to its own page and is linked. Option tables, +exit codes and other reference tables live once under Reference; guides link +to them. + +### One running example per page + +A page declares one model set near the top and reuses it to the end; each +later section shows only what changed. Example data is about four rows that +read like real records, with no tied values, so the reader can check a +result by eye. Docstrings and the API reference share one running example, +the `reference` prelude (`docs/examples/_preludes/reference.py`, `Author` and +`Post`). + +### Snippet `end:` markers go below the result + +The embedded range ends after the `assert` or `print` that holds the answer, +so the page shows what the call returns. + +Before (the page shows a query and never its result): + +```python +# --8<-- [start:filtering] +adults = await User.where(lambda user: user.age >= 18).all() +# --8<-- [end:filtering] +assert [user.name for user in adults] == ["Ada", "Grace"] +``` + +After: + +```python +# --8<-- [start:filtering] +adults = await User.where(lambda user: user.age >= 18).all() +assert [user.name for user in adults] == ["Ada", "Grace"] +# --8<-- [end:filtering] +``` + +### The session wrapper + +Every ORM call runs inside `async with engines.session():` and raises +`No database route` outside it. The first code block on a page shows the +wrapper. Every later block either shows it too or sits under a sentence +saying it runs inside one, linking to Connections โ€บ Sessions. An inline block +that does real work runs under the docs harness with a prelude that opens the +session (`{test=run prelude=}`, from #704). + +### Generated SQL beside each query + +Each query and each schema feature shows the SQL Ferro sends, for each +dialect where it differs. The SQL is captured by the example's own test run, +never typed by hand; a hand-simplified block drifts (unquoted reserved +words, invented aliases). This lands once #700 ships a public way to read a +query's SQL (#725 applies it to the guides). + +### The glossary file + +Acronyms in prose (DDL, RLS, FK, PK, GUC, FFI, IR, ...) get a hover +definition from one glossary, `docs/includes/abbreviations.md`, appended to +every page by `pymdownx.snippets.auto_append` (#701). Its wording follows +`CONTEXT.md` where `CONTEXT.md` defines the term. A new term goes into that +file, never into a page's own abbreviation list. + +### One Annotated idiom for I-7 twins + +AGENTS.md I-7 shows every field-declaring example as an Assignment tab and an +Annotated tab. The two tabs differ only in where the field options sit: the +Annotated tab moves the same `Field(...)` call inside `Annotated[...]` and +keeps the default as the assigned value. A field with no default has no +assignment in either tab. + +```python +name: Annotated[T, Field(...)] = default +``` + +Before (two idioms in today's twins, neither matching the Assignment tab +line for line): + +```python +id: Annotated[int | None, Field(default=None, primary_key=True)] +id: Annotated[int | None, FerroField(primary_key=True)] = None +``` + +After (the Assignment tab, then its Annotated twin): + +```python +id: int | None = Field(default=None, primary_key=True) +email: str = Field(unique=True) +nickname: str | None = Field(default=None, index=True) +``` + +```python +id: Annotated[int | None, Field(primary_key=True)] = None +email: Annotated[str, Field(unique=True)] +nickname: Annotated[str | None, Field(index=True)] = None +``` + +Pydantic accepts `Field(default=...)` inside `Annotated`, but type checkers +do not see a default there (`ty` reports `User(email="ada@example.com")` as +missing `id`); the +assigned default is the one both read, and it keeps the two tabs line for +line. Use `Field`, the name the Assignment tab imports; +`FerroField` stays out of twins. Forward foreign keys +(`Annotated[Target, ForeignKey(...)]`) and `BackRef()` / `ManyToMany()` +assignments have one form and appear the same in both tabs. + +Link the tabs with the labels `Assignment` and `Annotated`, exactly; the +`content.tabs.link` feature only syncs tabs whose labels match. + +## When to apply + +Every new or edited page under `docs/pages/`, every script under +`docs/examples/`, and every twin pair. Prose rules (lead-in and result +sentences, sentence-case headings, no minimising words) come from the +writing-for-humans skill and are checked in review; lambda naming (I-8) and +error checks that can fail are checked by `python -m tests._docs_checks`. diff --git a/docs/solutions/patterns/docstring-conventions.md b/docs/solutions/patterns/docstring-conventions.md new file mode 100644 index 00000000..f9181173 --- /dev/null +++ b/docs/solutions/patterns/docstring-conventions.md @@ -0,0 +1,226 @@ +--- +title: Docstring conventions for the rendered API reference (I-14) +type: pattern +tags: [docs, python, convention, invariant, mkdocstrings, griffe] +related_files: + - AGENTS.md + - src/ferro/ + - docs/examples/_preludes/reference.py + - tests/test_docstring_examples.py + - zensical.toml +related_issues: [698, 703, 704, 719, 720, 721] +related_prs: [] +captured: 2026-10-19 +--- + +## Problem + +The API reference is rendered from docstrings, and the 2026 audit found it +showing about 280 literal `:class:` roles, a `Returns:` section split into +five broken table rows, about 180 members with no docstring, a `#294` line +rendered as a heading, and 90-line essays opening `connect` and `PoolConfig`. + +## Takeaway + +A docstring is a short Google-style reference entry: a one-line summary, +every parameter in `Args:`, Markdown autorefs, references the reader can +follow, a runnable lambda-style example, and a link for anything longer than +a paragraph. AGENTS.md I-14 holds the rules; this note shows each as a +before/after pair. + +## The rules, before and after + +### Google style as griffe parses it + +griffe recognises a section by its title and a colon, with entries indented +under it. A `Returns:` written as prose lines becomes table rows, one per +line. + +Before: + +```python +"""... + +Returns: + PassReport with: + - statements: what ran + - warnings: what was skipped +""" +``` + +After: + +```python +"""... + +Returns: + The statements the pass ran and the warnings it raised, as a + [`PassReport`][ferro.PassReport]. +""" +``` + +### A one-line summary in the infinitive, ending with a period + +A function or method summary starts with a verb in the infinitive. A class +summary is a noun phrase. + +Before: + +```python +"""Returns the first matching record or None if the query has no rows""" +"""This class represents a lazily-evaluated query that ...""" +``` + +After: + +```python +"""Return the first matching record, or `None` when there is none.""" +"""A lazy query over one model's rows.""" +``` + +### Every parameter in `Args:` + +Keyword-only parameters such as `session`, `using` and `nulls` belong in the +table too, with the default when the signature does not make it obvious. + +Before: + +```python +async def get(cls, pk: Any, *, session: Session | None = None) -> Self: + """Fetch one record by primary key. + + Args: + pk: Primary key value. + """ +``` + +After: + +```python +async def get(cls, pk: Any, *, session: Session | None = None) -> Self: + """Fetch one record by primary key. + + Args: + pk: The primary key value to look up. + session: The session to run in. Defaults to the one opened by the + enclosing `async with engines.session():`. + """ +``` + +### A one-line docstring on every public dataclass or Pydantic field + +griffe reads a string literal on the line below a field as its docstring. +Without one, the field renders as a bare name. + +Before: + +```python +@dataclass(frozen=True) +class PassReport: + statements: tuple[ExecutedStatement, ...] + warnings: tuple[Report, ...] +``` + +After: + +```python +@dataclass(frozen=True) +class PassReport: + """What one reconciliation pass ran and reported.""" + + statements: tuple[ExecutedStatement, ...] + """Every statement the pass sent, in execution order.""" + warnings: tuple[Report, ...] + """Every warning the pass raised, in the order raised.""" +``` + +### Markdown autorefs, never Sphinx roles + +Sphinx roles render as literal text in mkdocstrings. + +Before: + +```python +"""Use :meth:`get_or_none` to get ``None`` instead. See :class:`~ferro.Model`.""" +``` + +After: + +```python +"""Use [`get_or_none`][ferro.Model.get_or_none] to get `None` instead. +See [Model][ferro.Model].""" +``` + +### No ADR numbers, issue numbers, codenames or Rust internals + +The reader cannot follow `ADR-0007`, `#412` or a Rust function name. +Say what the behaviour is, and link the docs page that explains why. + +Before: + +```python +"""Test membership with ``t.posts.exists()``; negate it with +``~t.posts.exists()`` (ADR-0007, #412), lowered by ``render_exists_subquery``.""" +``` + +After: + +```python +"""Test membership with `author.posts.exists()`; negate it with +`~author.posts.exists()`.""" +``` + +### Examples in the lambda style, against the reference prelude + +Examples use lambda predicates named after the model (I-8) and the shared +`reference` prelude (`Author` and `Post`, connected, inside a session). The +fence marker `{test=run prelude=reference}` makes the docs harness run it. + +Before: + +```python +"""Examples: + >>> users = await User.where(lambda u: u.age >= 18).all() +""" +``` + +After: + +````python +"""Examples: + ```python {test=run prelude=reference} + authors = await Author.where(lambda author: author.id <= 2).all() + print([author.id for author in authors]) + ``` +""" +```` + +### Explanations longer than a paragraph live in a guide + +A docstring states what the object does and how to call it. The reasoning, +trade-offs and walk-throughs go on a guide or concepts page, and the +docstring links there. + +Before: `connect()` opening with 90 lines on pools, routing and migration +modes. + +After: + +```python +"""Open a connection pool and register it as a database route. + +See [Connections](../guide/connections.md) for routing and pool sizing. + +Args: + ... +""" +``` + +## How to recognize + +- Literal `:class:` or `:meth:` text on a rendered API page. +- A `Returns:` or `Attributes:` table whose rows are sentence fragments. +- A field listed with no description, or a parameter in the signature missing + from the table. +- A summary that wraps, starts with "Returns" or "This", or has no period. +- `lambda t:` or a doctest `>>>` in an `Examples:` section. From 20d9f9769f95dc26f4fc569b9d5e824c53cb5901 Mon Sep 17 00:00:00 2001 From: 0x54 Date: Mon, 19 Oct 2026 13:13:01 -0400 Subject: [PATCH 02/33] fix(cli): keep in --help (#702) (#726) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #702 ## What `ferro migrate rerecord --help` used to read "The step whose edit to accept: :, as 0007:01." because help was rendered as Markdown and `:` was swallowed as an HTML tag. Both cyclopts apps now use `help_format="plaintext"`, so it prints `:`, and `new` and `squash` keep `NN_.up.sql`. ## Verify Ran the ticket's Verify block on `57ea0c6`: passed. TDD: RED `pytest tests/test_cli_help.py` failed for `migrate new`, `squash`, `rerecord` (3 failed, 10 passed); GREEN same command passed (13/13). Full suite: 3309 passed. Verify block corrected: no ## Interfaces Interfaces changed: none ## Notes for the reviewer - `tests/test_cli_help.py` walks the command tree from `app._meta_parent`, collects each command's summary, docstring and parameter help, and asserts every `` is in that command's `--help` (whitespace folded, since the table wraps). - Exit-steps: issue status updates are complete (#702 closes with this PR). ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- src/ferro/cli/__init__.py | 1 + src/ferro/cli/migrate.py | 1 + tests/test_cli_help.py | 69 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 tests/test_cli_help.py diff --git a/src/ferro/cli/__init__.py b/src/ferro/cli/__init__.py index bc4a7024..0350f768 100644 --- a/src/ferro/cli/__init__.py +++ b/src/ferro/cli/__init__.py @@ -114,6 +114,7 @@ def _build() -> "App": ferro_version = "unknown" root = App( + help_format="plaintext", name="ferro", help="ferro's command line: schema migrations for ferro models.", version=ferro_version, diff --git a/src/ferro/cli/migrate.py b/src/ferro/cli/migrate.py index 7e6ab222..6cbcb94c 100644 --- a/src/ferro/cli/migrate.py +++ b/src/ferro/cli/migrate.py @@ -48,6 +48,7 @@ __all__ = ["migrate"] migrate = App( + help_format="plaintext", name="migrate", help="Write, apply and check schema migrations.", ) diff --git a/tests/test_cli_help.py b/tests/test_cli_help.py new file mode 100644 index 00000000..0535097f --- /dev/null +++ b/tests/test_cli_help.py @@ -0,0 +1,69 @@ +"""``--help`` prints every ```` written in a command's help source. + +Help rendered as Markdown swallows ``:`` as an HTML tag, so +``ferro migrate rerecord --help`` used to read "The step whose edit to +accept: :, as 0007:01." Help is plain text; this pins it for every command. +""" + +import inspect +import re +from collections.abc import Iterator + +import pytest + +from ferro.cli import app + +PLACEHOLDER = re.compile(r"<[A-Za-z_][^<>\s]*>") + + +def _commands( + root, path: tuple[str, ...] = () +) -> Iterator[tuple[tuple[str, ...], object]]: + yield path, root + for name, sub in root._commands.items(): + if name.startswith("-"): + continue + yield from _commands(sub, (*path, name)) + + +def _help_source(command) -> str: + """Every help string written for the command: summary, docstring, parameters.""" + parts = [] + if command.help: + parts.append(command.help) + func = command.default_command + if func is not None: + parts.append(inspect.getdoc(func) or "") + for argument in command.assemble_argument_collection(): + parts.append(argument.parameter.help or "") + return "\n".join(parts) + + +CASES = [ + pytest.param(path, command, id=" ".join(("ferro", *path))) + for path, command in _commands(app._meta_parent) +] + + +@pytest.mark.parametrize(("path", "command"), CASES) +def test_help_keeps_every_placeholder(path, command, capsys) -> None: + placeholders = set(PLACEHOLDER.findall(_help_source(command))) + try: + app([*path, "--help"]) + except SystemExit: + pass + out = capsys.readouterr().out + # The help table wraps long lines; compare with whitespace folded away. + folded = re.sub(r"\s+", "", out) + missing = sorted(p for p in placeholders if re.sub(r"\s+", "", p) not in folded) + assert not missing, f"--help dropped {missing}:\n{out}" + + +def test_the_scan_sees_the_placeholders_the_ticket_names() -> None: + found = { + " ".join(path): set(PLACEHOLDER.findall(_help_source(command))) + for path, command in _commands(app._meta_parent) + } + assert {"", ""} <= found["migrate rerecord"] + assert found["migrate new"] + assert found["migrate squash"] From a6fa52285dbbcec4487cf4745ce1a7157ecd4115 Mon Sep 17 00:00:00 2001 From: 0x54 Date: Mon, 19 Oct 2026 13:20:05 -0400 Subject: [PATCH 03/33] =?UTF-8?q?feat(docs):=20docs=20examples=20harness?= =?UTF-8?q?=20=E2=80=94=20prelude=20fences,=20docstring=20examples,=20lamb?= =?UTF-8?q?da=20and=20error=20checks=20(#704)=20(#728)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #704 ## What A docs fence can now run against a small seeded database. Mark it like this on a page or in a docstring: ````markdown ```python {test=run prelude=reference} print(await Author.where(lambda author: author.id == 1).first()) ``` ```` The block runs in a subprocess after `docs/examples/_preludes/reference.py`, which declares `Author` and `Post`, connects to in-memory SQLite, adds two authors and two posts, and holds a session open. Top-level `await` works. If the block fails, the traceback names the page and its line (`File "docs/pages/guide/queries.md", line 42`). `tests/test_docstring_examples.py` finds every Python fence in `src/ferro` docstrings through griffe. It compiles each one and runs the ones marked with a prelude. `python -m tests._docs_checks PATH...` lists two mistakes and exits 1 if it finds any: ```text docs/examples/aggregations.py:217: lambda passed to select() takes one-letter parameter 't'; name it after the model in lowercase singular docs/examples/aggregations.py:216: try/except asserts on the error but has no else that raises, so it passes when the error stops happening; add else: raise AssertionError("expected TypeError") ``` It is not wired into the suite yet. #724 makes it a gate. ## Verify Ran the ticket's Verify block on `4c85007`: passed (26 passed; `test_docs_examples.py` 234 passed). TDD: RED `uv run --no-sync pytest tests/test_docs_checks.py tests/test_docstring_examples.py -q --no-cov -p no:cacheprovider` failed as expected (`ModuleNotFoundError: No module named 'tests._docs_harness'`, b593745); GREEN, the same command passed (26/26). Full suite: 3327 passed, 339 skipped, 2 xfailed. Verify block corrected: no ## Interfaces Interfaces changed: none ## Test scenarios - Happy path: a `{test=run prelude=reference}` fence that queries `Author` passes and prints `Ada Lovelace`. - Error path: a fence that raises `RuntimeError` fails, and the message names the page and line 5. - Edge case: `lambda author:` passes, `lambda a:` fails with its line, `sorted(key=lambda r: ...)` and `rows.sort(...)` are ignored. - Edge case: `try/except` that asserts without `else` is reported; with `else: raise AssertionError` it is not. - Integration: a scratch module's docstring example marked `{test=run prelude=reference}` is collected and runs. - Added: an unknown prelude fails and names the available ones; `prelude=` without `test=run` is refused; bare and quoted settings both parse; a fence inside a content tab and a fence in a function docstring are checked at their file lines; a default-captured lambda parameter (`f=field`) is not a query parameter. ## Notes for the reviewer - **Today's counts.** `python -m tests._docs_checks docs/examples` reports 74 one-letter lambdas: 68 in the four scripts (`aggregations` 24, `partial_selects` 17, `populated_relations` 16, `existence_tests` 11) plus 6 in their twins. The audit counted 66. It reports **10** unfailing error checks, not 12: `mutations.py:56` and `:113` catch the error without asserting anything, so the ticket's rule ("an `except` that asserts") does not cover them. Widening the rule to any `except` that swallows a named error would flag legitimate fallbacks such as `except ImportError`, so I kept the ticket's definition. The mutations page ticket should give both blocks an `else: raise`. - **The opt-ins never worked.** pytest-examples' `prefix_settings()` only reads quoted values, so `{test=run}` and `{lint=check}` were silently ignored. `fence_settings()` reads both spellings. No page used them, so results are unchanged. Recorded in `docs/solutions/issues/pytest-examples-prefix-settings-need-quotes.md` (I-5). - **Only supplied parameters are checked.** `lambda related, f=fk_field, v=pk_val: ...` captures `f` and `v` by default, and the query only passes the first, so only positional parameters without a default count. - **The method list** comes from the `Query`, `ProjectedQuery`, `Relation`, `Model` and `ModelConnection` signatures (including overloads) whose parameters mention `Callable`, `Predicate` or `RowSelector`, plus the names the ticket lists. - **Markdown inline code spans** (`` `order_by(lambda t: ...)` `` in prose) are not checked. The ticket scopes the checks to fences, and the audit's 57 page lines include spans. ### Outside Files owned - `tests/_docs_harness.py`: fence settings, fence extraction, docstring collection and the prelude runner call. Both test modules and the checks need them, and putting them in `_docs_checks.py` would mix the runner into the checks module. - `src/ferro/relations/descriptors.py`: renamed one internal lambda parameter `t` to `related`, so `python -m tests._docs_checks src/ferro` exits 0 and the docstring tickets start from a clean run. - `docs/solutions/issues/pytest-examples-prefix-settings-need-quotes.md`: the I-5 note above. ## Exit steps - [ ] #704 status updated (progress comment at `pr-open`; the orchestrator closes it after the merge into `feat/docs-rewrite`) ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- docs/examples/_preludes/__init__.py | 75 ++++++ docs/examples/_preludes/reference.py | 49 ++++ ...st-examples-prefix-settings-need-quotes.md | 35 +++ src/ferro/relations/descriptors.py | 4 +- tests/_docs_checks.py | 244 ++++++++++++++++++ tests/_docs_harness.py | 143 ++++++++++ tests/test_docs_checks.py | 238 +++++++++++++++++ tests/test_docs_examples.py | 70 ++++- tests/test_docstring_examples.py | 93 +++++++ 9 files changed, 944 insertions(+), 7 deletions(-) create mode 100644 docs/examples/_preludes/__init__.py create mode 100644 docs/examples/_preludes/reference.py create mode 100644 docs/solutions/issues/pytest-examples-prefix-settings-need-quotes.md create mode 100644 tests/_docs_checks.py create mode 100644 tests/_docs_harness.py create mode 100644 tests/test_docs_checks.py create mode 100644 tests/test_docstring_examples.py diff --git a/docs/examples/_preludes/__init__.py b/docs/examples/_preludes/__init__.py new file mode 100644 index 00000000..3b89997e --- /dev/null +++ b/docs/examples/_preludes/__init__.py @@ -0,0 +1,75 @@ +"""Preludes for runnable docs blocks, and the runner that uses them. + +A prelude is a module in this directory. It declares the models a docs block +uses and defines ``opened()``, an async context manager that connects to an +in-memory SQLite database, adds a few rows and opens a session. A block marked +``{test=run prelude=}`` sees every name the prelude defines and runs +inside ``opened()``, with top-level ``await``. + +Run a block by piping its source to this file: + + python docs/examples/_preludes/__init__.py + +``path`` and ``first line`` are where the block's source sits, so a traceback +points at the docs page or docstring, not at this runner. +""" + +from __future__ import annotations + +import ast +import asyncio +import importlib.util +import inspect +import sys +from pathlib import Path +from types import ModuleType + +PRELUDES = Path(__file__).resolve().parent + + +def load_prelude(name: str) -> ModuleType: + """Import ``.py`` from this directory, refusing a name that has none.""" + path = PRELUDES / f"{name}.py" + if name.startswith("_") or not path.is_file(): + available = sorted( + p.stem for p in PRELUDES.glob("*.py") if not p.stem.startswith("_") + ) + raise SystemExit( + f"no prelude named {name!r}; add docs/examples/_preludes/{name}.py " + f"or use one of: {', '.join(available)}" + ) + spec = importlib.util.spec_from_file_location(f"_ferro_prelude_{name}", path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +async def run_block(prelude: str, source: str, path: str, first_line: int) -> None: + """Run ``source`` inside the prelude's ``opened()``, with its names in scope.""" + module = load_prelude(prelude) + namespace = { + name: value for name, value in vars(module).items() if not name.startswith("__") + } + namespace["__name__"] = "__main__" + # Pad the source so line numbers in a traceback are the page's own. + code = compile( + "\n" * (first_line - 1) + source, + path, + "exec", + flags=ast.PyCF_ALLOW_TOP_LEVEL_AWAIT, + ) + async with module.opened(): + result = eval(code, namespace) # noqa: S307 - the docs block is ours + if inspect.iscoroutine(result): + await result + + +def main(argv: list[str]) -> None: + prelude, path, first_line = argv + asyncio.run(run_block(prelude, sys.stdin.read(), path, int(first_line))) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/docs/examples/_preludes/reference.py b/docs/examples/_preludes/reference.py new file mode 100644 index 00000000..8d56de96 --- /dev/null +++ b/docs/examples/_preludes/reference.py @@ -0,0 +1,49 @@ +"""The running example for docstrings and the API reference. + +Two authors and their posts, in an in-memory SQLite database. A block marked +``{test=run prelude=reference}`` runs inside ``opened()`` and can use +``Author`` and ``Post`` directly. +""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Annotated + +from ferro import BackRef, Field, ForeignKey, Model, Relation, connect, engines + + +class Author(Model): + id: int | None = Field(default=None, primary_key=True) + name: str + email: str = Field(unique=True) + posts: Relation[list["Post"]] = BackRef() + + +class Post(Model): + id: int | None = Field(default=None, primary_key=True) + title: str + published: bool = False + views: int = 0 + author: Annotated[Author, ForeignKey(related_name="posts")] + + +@asynccontextmanager +async def opened() -> AsyncIterator[None]: + """Connect, add the rows and hold a session open around the block.""" + await connect("sqlite::memory:", auto_migrate=True) + async with engines.session(): + ada = await Author.create(name="Ada Lovelace", email="ada@example.com") + grace = await Author.create(name="Grace Hopper", email="grace@example.com") + await Post.create( + title="Notes on the Analytical Engine", + published=True, + views=1843, + author=ada, + ) + await Post.create( + title="The Education of a Computer", + published=True, + views=952, + author=grace, + ) + yield diff --git a/docs/solutions/issues/pytest-examples-prefix-settings-need-quotes.md b/docs/solutions/issues/pytest-examples-prefix-settings-need-quotes.md new file mode 100644 index 00000000..77e74a00 --- /dev/null +++ b/docs/solutions/issues/pytest-examples-prefix-settings-need-quotes.md @@ -0,0 +1,35 @@ +--- +title: pytest-examples ignores bare fence settings like {test=run} +type: issue +tags: [pytest, docs, gotcha] +related_files: + - tests/_docs_harness.py + - tests/test_docs_examples.py + - docs/examples/_preludes/__init__.py +related_issues: [704] +captured: 2026-10-19 +--- + +A docs fence marked ` ```python {test=run} ` never ran, because +`CodeExample.prefix_settings()` from pytest-examples only reads quoted values +(`test="run"`). + +Read fence settings with `fence_settings()` in `tests/_docs_harness.py` +instead. It accepts both forms: + +```python +fence_settings(example) # 'python {test=run prelude=reference}' +# {'test': 'run', 'prelude': 'reference'} +``` + +The docs harness used `prefix_settings()` from the start, so the +`{test=run}` and `{lint=check}` opt-ins it documented did nothing for the bare +spelling. No page used them, so nothing failed. Every other check in the +harness reads settings through `fence_settings()`, which also refuses a +`prelude=` without `test=run`. + +## How to recognize + +A fence carries a setting and its test passes, but deliberately breaking the +block also passes. Print `example.prefix_settings()`: an empty dict for a +prefix that clearly has `key=value` pairs is this bug. diff --git a/src/ferro/relations/descriptors.py b/src/ferro/relations/descriptors.py index 80784724..3657dcca 100644 --- a/src/ferro/relations/descriptors.py +++ b/src/ferro/relations/descriptors.py @@ -60,7 +60,7 @@ def __get__(self, instance, owner): fk_field = f"{self.field_name}_id" if self.is_one_to_one: return self._target_model.where( - lambda t, f=fk_field, v=pk_val: getattr(t, f) == v + lambda related, f=fk_field, v=pk_val: getattr(related, f) == v ).first() from ..query.builder import Relation @@ -68,7 +68,7 @@ def __get__(self, instance, owner): return Relation( self._target_model, using=_instance_origin_outside_transaction(instance), - ).where(lambda t, f=fk_field, v=pk_val: getattr(t, f) == v) + ).where(lambda related, f=fk_field, v=pk_val: getattr(related, f) == v) class ForwardDescriptor(BaseModel): diff --git a/tests/_docs_checks.py b/tests/_docs_checks.py new file mode 100644 index 00000000..5e7234bd --- /dev/null +++ b/tests/_docs_checks.py @@ -0,0 +1,244 @@ +"""Checks over docs examples: query lambdas and error checks. + +Two mistakes compile and run but teach the wrong thing: + +- ``Post.where(lambda p: p.published)``: a query lambda with a one-letter + parameter. The docs name it after the model, ``lambda post: post.published``. +- A ``try`` whose ``except`` asserts on the error but has no ``else`` that + raises. When the error stops happening, the block still passes. + +Both checks read Python files (with the fences in their docstrings) and the +Python fences of Markdown pages. Run them from the command line: + + python -m tests._docs_checks docs/examples docs/pages src/ferro + +Each violation prints as ``path:line: message``; any violation exits 1. +""" + +from __future__ import annotations + +import ast +import inspect +import sys +import typing +from collections.abc import Iterator +from dataclasses import dataclass +from functools import cache +from pathlib import Path + +from tests._docs_harness import fenced_examples + +# Query methods the docs call with a lambda whose signature does not say so +# (aggregate projections, keyset paging). The signatures add the rest. +_NAMED_QUERY_METHODS = frozenset( + { + "where", + "select", + "order_by", + "include", + "exists", + "count", + "aggregate", + "group_by", + "after", + "before", + } +) +_CALLABLE_ANNOTATIONS = ("Callable", "Predicate", "RowSelector") + + +@dataclass(frozen=True) +class Violation: + """One finding: where it is and what to change.""" + + path: Path + line: int + message: str + + def __str__(self) -> str: + return f"{self.path}:{self.line}: {self.message}" + + +@cache +def query_methods_taking_callables() -> frozenset[str]: + """Name every public ``Query`` or ``Model`` method that takes a callable. + + Read from the signatures (overloads included), plus the names the docs + use on aggregate projections and keyset paging. + """ + from ferro.models import Model, ModelConnection + from ferro.query import ProjectedQuery, Query, Relation + + names = set(_NAMED_QUERY_METHODS) + for cls in (Query, ProjectedQuery, Relation, Model, ModelConnection): + for name, member in vars(cls).items(): + if name.startswith("_"): + continue + function = getattr(member, "__func__", member) + if not callable(function): + continue + for signature_of in (function, *typing.get_overloads(function)): + try: + signature = inspect.signature(signature_of) + except (TypeError, ValueError): + continue + if any( + marker in str(parameter.annotation) + for parameter in signature.parameters.values() + for marker in _CALLABLE_ANNOTATIONS + ): + names.add(name) + return frozenset(names) + + +def _sources(path: Path) -> Iterator[tuple[str, int]]: + """Yield each Python source in ``path`` with the file line it starts on.""" + text = path.read_text(encoding="utf-8") + if path.suffix == ".md": + for example in fenced_examples(path, text): + # Snippet directives are expanded by the docs build; the file + # they include is checked on its own. + if "--8<--" not in example.source: + yield example.source, example.start_line + 1 + return + yield text, 1 + lines = text.splitlines(keepends=True) + for node in ast.walk(_parse(text, path, 1)): + if ( + isinstance(node, ast.Expr) + and isinstance(node.value, ast.Constant) + and isinstance(node.value.value, str) + ): + docstring = "".join(lines[node.lineno - 1 : node.end_lineno]) + for example in fenced_examples( + path, docstring, line_offset=node.lineno - 1 + ): + yield example.source, example.start_line + 1 + + +def _parse(source: str, path: Path, first_line: int) -> ast.AST: + # Padding keeps every node's line number the file's own. + return compile( + "\n" * (first_line - 1) + source, + str(path), + "exec", + flags=ast.PyCF_ONLY_AST | ast.PyCF_ALLOW_TOP_LEVEL_AWAIT, + ) + + +def _trees(path: Path) -> Iterator[ast.AST]: + for source, first_line in _sources(path): + yield _parse(source, path, first_line) + + +def _supplied_parameters(function: ast.Lambda) -> list[ast.arg]: + """The parameters the query fills: positional ones without a default. + + ``lambda post, f=field: getattr(post, f)`` captures ``f`` by default; the + query only ever passes ``post``. + """ + positional = [*function.args.posonlyargs, *function.args.args] + return positional[: len(positional) - len(function.args.defaults)] + + +def lambda_param_violations(path: Path) -> list[Violation]: + """Report each query lambda in ``path`` with a one-letter parameter.""" + methods = query_methods_taking_callables() + violations = [] + for tree in _trees(path): + for call in ast.walk(tree): + if not ( + isinstance(call, ast.Call) + and isinstance(call.func, ast.Attribute) + and call.func.attr in methods + ): + continue + arguments = [*call.args, *(keyword.value for keyword in call.keywords)] + for argument in arguments: + if not isinstance(argument, ast.Lambda): + continue + for param in _supplied_parameters(argument): + if len(param.arg) < 2: + violations.append( + Violation( + path, + argument.lineno, + f"lambda passed to {call.func.attr}() takes " + f"one-letter parameter {param.arg!r}; name it " + "after the model in lowercase singular", + ) + ) + return sorted(violations, key=lambda violation: violation.line) + + +def _asserts(handler: ast.ExceptHandler) -> bool: + return any( + isinstance(node, ast.Assert) for stmt in handler.body for node in ast.walk(stmt) + ) + + +def _expected(handler: ast.ExceptHandler) -> str: + if handler.type is None: + return "an error" + return ast.unparse(handler.type) + + +def unfailing_error_checks(path: Path) -> list[Violation]: + """Report each ``try`` whose ``except`` asserts but whose ``else`` never raises.""" + violations = [] + for tree in _trees(path): + for node in ast.walk(tree): + if not isinstance(node, ast.Try | ast.TryStar): + continue + checking = [handler for handler in node.handlers if _asserts(handler)] + if not checking: + continue + if any(isinstance(stmt, ast.Raise) for stmt in node.orelse): + continue + violations.append( + Violation( + path, + node.lineno, + "try/except asserts on the error but has no else that raises, " + "so it passes when the error stops happening; add " + f'else: raise AssertionError("expected {_expected(checking[0])}")', + ) + ) + return sorted(violations, key=lambda violation: violation.line) + + +def _files(paths: list[str]) -> Iterator[Path]: + for name in paths: + path = Path(name) + if path.is_dir(): + yield from sorted( + file + for file in path.rglob("*") + if file.suffix in {".py", ".md"} and file.is_file() + ) + elif path.is_file(): + yield path + else: + raise SystemExit(f"not a file or directory: {name}") + + +def main(argv: list[str]) -> int: + if not argv: + print("usage: python -m tests._docs_checks PATH...", file=sys.stderr) + return 2 + violations: list[Violation] = [] + for path in _files(argv): + try: + violations += lambda_param_violations(path) + violations += unfailing_error_checks(path) + except SyntaxError as error: + violations.append( + Violation(path, error.lineno or 0, f"does not parse: {error.msg}") + ) + for violation in violations: + print(violation) + return 1 if violations else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tests/_docs_harness.py b/tests/_docs_harness.py new file mode 100644 index 00000000..d95ddc58 --- /dev/null +++ b/tests/_docs_harness.py @@ -0,0 +1,143 @@ +"""Shared pieces of the docs examples harness. + +A Python fence in a docs page or a docstring can carry settings after its +language: + + ```python {test=run prelude=reference} + print(await Author.where(lambda author: author.id == 1).first()) + ``` + +``test=run`` runs the block. With ``prelude=`` it runs in a fresh +interpreter after ``docs/examples/_preludes/.py``, which declares the +models, connects and opens a session; top-level ``await`` works there. +``lint=check`` lints the block (docs pages only). +""" + +from __future__ import annotations + +import re +import subprocess +import sys +from collections.abc import Iterator +from pathlib import Path + +import griffe +from pytest_examples import CodeExample +from pytest_examples.find_examples import remove_indent + +REPO_ROOT = Path(__file__).resolve().parents[1] +PRELUDES = REPO_ROOT / "docs" / "examples" / "_preludes" + +_SETTING = re.compile(r"""([A-Za-z_][\w-]*)=(?:"([^"]*)"|'([^']*)'|([^\s{}"']+))""") +_FENCE = re.compile(r"(^ *```)( *)(.*?)\n(.+?)\1", flags=re.M | re.S) + + +def fence_settings(example: CodeExample) -> dict[str, str]: + """Read the ``key=value`` settings after a fence's language. + + Values may be bare (``test=run``) or quoted (``lint="check"``). A + ``prelude`` without ``test=run`` is refused: it would silently do nothing. + """ + settings = { + match.group(1): next(g for g in match.groups()[1:] if g is not None) + for match in _SETTING.finditer(example.prefix) + } + if "prelude" in settings and settings.get("test") != "run": + raise ValueError( + f"{example}: prelude={settings['prelude']} needs test=run; " + f"mark the fence {{test=run prelude={settings['prelude']}}}" + ) + return settings + + +def fenced_examples( + path: Path, text: str, *, line_offset: int = 0 +) -> list[CodeExample]: + """Find the Python fences in ``text``, numbered as lines of ``path``. + + ``line_offset`` is the number of lines of ``path`` before ``text``. Each + example's ``start_line`` is its opening fence's line, as pytest-examples + numbers them, so its source starts on the next line. + """ + examples = [] + for match in _FENCE.finditer(text): + opening, space, prefix, source = match.groups() + prefix = prefix.lower() + if not prefix.startswith(("py", "{.py")): + continue + start_line = line_offset + text[: match.start()].count("\n") + 1 + dedented, indent = remove_indent(source) + start_index = match.start() + len(opening) + len(space) + len(prefix) + 1 + examples.append( + CodeExample( + source=dedented, + path=path, + start_line=start_line, + end_line=start_line + source.count("\n") + 1, + start_index=start_index, + end_index=start_index + len(source), + prefix=prefix, + indent=indent, + ) + ) + return examples + + +def _documented(obj: griffe.Object) -> Iterator[griffe.Object]: + yield obj + for member in obj.members.values(): + if not member.is_alias: + yield from _documented(member) + + +def docstring_examples(package: str, search_path: Path) -> list[CodeExample]: + """Collect the Python fences in the docstrings of every module of ``package``. + + griffe finds each docstring and its lines; the fences are read from the + file's own lines, so every example carries its real file and line. + """ + root = griffe.load(package, search_paths=[str(search_path)]) + examples: list[CodeExample] = [] + seen: set[tuple[Path, int]] = set() + for obj in _documented(root): + docstring = obj.docstring + if docstring is None or docstring.lineno is None: + continue + path = Path(obj.filepath) if not isinstance(obj.filepath, list) else None + if path is None or (path, docstring.lineno) in seen: + continue + seen.add((path, docstring.lineno)) + lines = path.read_text(encoding="utf-8").splitlines(keepends=True) + text = "".join(lines[docstring.lineno - 1 : docstring.endlineno]) + examples += fenced_examples(path, text, line_offset=docstring.lineno - 1) + return sorted(examples, key=lambda example: (str(example.path), example.start_line)) + + +def run_fence(example: CodeExample) -> str: + """Run a ``{test=run prelude=}`` example and return what it printed. + + The example runs in a subprocess, so models and engines never leak + between examples. A failure raises ``AssertionError`` carrying the + traceback, whose frames name the example's own file and line. + """ + prelude = fence_settings(example)["prelude"] + result = subprocess.run( + [ + sys.executable, + str(PRELUDES / "__init__.py"), + prelude, + str(example.path), + str(example.start_line + 1), + ], + input=example.source, + capture_output=True, + text=True, + timeout=120, + cwd=REPO_ROOT, + ) + if result.returncode != 0: + raise AssertionError( + f"{example} failed after prelude {prelude!r}\n" + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + return result.stdout diff --git a/tests/test_docs_checks.py b/tests/test_docs_checks.py new file mode 100644 index 00000000..7721d55b --- /dev/null +++ b/tests/test_docs_checks.py @@ -0,0 +1,238 @@ +"""The docs checks: one-letter query lambdas and error checks that cannot fail.""" + +from __future__ import annotations + +import subprocess +import sys +import textwrap +from pathlib import Path + +from tests._docs_checks import ( + Violation, + lambda_param_violations, + query_methods_taking_callables, + unfailing_error_checks, +) + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _write(tmp_path: Path, name: str, text: str) -> Path: + path = tmp_path / name + path.write_text(textwrap.dedent(text).lstrip("\n"), encoding="utf-8") + return path + + +def test_two_letter_lambda_parameter_passes(tmp_path: Path) -> None: + path = _write( + tmp_path, + "ok.py", + """ + authors = Author.where(lambda author: author.id == 1) + """, + ) + assert lambda_param_violations(path) == [] + + +def test_one_letter_lambda_parameter_fails_with_its_line(tmp_path: Path) -> None: + path = _write( + tmp_path, + "bad.py", + """ + import ferro + + authors = Author.where(lambda a: a.id == 1) + """, + ) + assert lambda_param_violations(path) == [ + Violation( + path, + 3, + "lambda passed to where() takes one-letter parameter 'a'; " + "name it after the model in lowercase singular", + ) + ] + + +def test_lambda_passed_to_sorted_is_ignored(tmp_path: Path) -> None: + path = _write( + tmp_path, + "sorted.py", + """ + names = sorted(rows, key=lambda r: r.name) + rows.sort(key=lambda r: r.name) + """, + ) + assert lambda_param_violations(path) == [] + + +def test_parameters_captured_by_default_are_not_query_parameters( + tmp_path: Path, +) -> None: + path = _write( + tmp_path, + "capture.py", + """ + Post.where(lambda post, f=field, v=value: getattr(post, f) == v) + Post.where(lambda p, f=field: getattr(p, f)) + """, + ) + assert [(v.line, "'p'" in v.message) for v in lambda_param_violations(path)] == [ + (2, True) + ] + + +def test_every_named_query_method_is_checked(tmp_path: Path) -> None: + methods = query_methods_taking_callables() + # Derived from the Query and Model signatures, plus the names the + # docs use on aggregate projections. + assert { + "where", + "select", + "order_by", + "include", + "join", + "left_join", + "update", + "exists", + "count", + "aggregate", + "group_by", + "after", + "before", + } <= methods + lines = "\n".join(f"q.{name}(lambda x: x.id)" for name in sorted(methods)) + path = _write(tmp_path, "all.py", lines + "\n") + assert [v.line for v in lambda_param_violations(path)] == list( + range(1, len(methods) + 1) + ) + + +def test_markdown_fences_are_checked_at_their_page_line(tmp_path: Path) -> None: + path = _write( + tmp_path, + "page.md", + """ + # Page + + === "Tab" + + ```python + posts = await Post.where(lambda p: p.published).all() + ``` + + ```text + Post.where(lambda p: p.published) + ``` + """, + ) + assert [(v.line, "'p'" in v.message) for v in lambda_param_violations(path)] == [ + (6, True) + ] + + +def test_docstring_fences_are_checked_at_their_file_line(tmp_path: Path) -> None: + path = _write( + tmp_path, + "module.py", + ''' + """A module. + + ```python + posts = await Post.where(lambda post: post.published).all() + ``` + """ + + + def helper(): + """Return the published posts. + + Examples: + ```python + posts = await Post.where(lambda p: p.published).all() + ``` + """ + ''', + ) + assert [v.line for v in lambda_param_violations(path)] == [14] + + +def test_except_that_asserts_without_else_is_reported(tmp_path: Path) -> None: + path = _write( + tmp_path, + "errors.py", + """ + try: + Post.where(lambda post: post.views.sum() > 100) + except TypeError as exc: + assert "having()" in str(exc) + """, + ) + assert unfailing_error_checks(path) == [ + Violation( + path, + 1, + "try/except asserts on the error but has no else that raises, " + "so it passes when the error stops happening; add " + 'else: raise AssertionError("expected TypeError")', + ) + ] + + +def test_except_that_asserts_with_raising_else_is_not_reported( + tmp_path: Path, +) -> None: + path = _write( + tmp_path, + "errors.py", + """ + try: + Post.where(lambda post: post.views.sum() > 100) + except TypeError as exc: + assert "having()" in str(exc) + else: + raise AssertionError("expected TypeError") + """, + ) + assert unfailing_error_checks(path) == [] + + +def test_except_without_assert_is_not_an_error_check(tmp_path: Path) -> None: + path = _write( + tmp_path, + "cleanup.py", + """ + try: + import tomllib + except ImportError: + tomllib = None + """, + ) + assert unfailing_error_checks(path) == [] + + +def test_cli_lists_violations_and_exits_1(tmp_path: Path) -> None: + _write(tmp_path, "good.py", "Post.where(lambda post: post.id)\n") + bad = _write(tmp_path, "bad.py", "Post.where(lambda p: p.id)\n") + result = subprocess.run( + [sys.executable, "-m", "tests._docs_checks", str(tmp_path)], + capture_output=True, + text=True, + cwd=REPO_ROOT, + ) + assert result.returncode == 1, result.stderr + assert result.stdout.splitlines() == [ + f"{bad}:1: lambda passed to where() takes one-letter parameter 'p'; " + "name it after the model in lowercase singular" + ] + + +def test_cli_exits_0_when_clean(tmp_path: Path) -> None: + good = _write(tmp_path, "good.py", "Post.where(lambda post: post.id)\n") + result = subprocess.run( + [sys.executable, "-m", "tests._docs_checks", str(good)], + capture_output=True, + text=True, + cwd=REPO_ROOT, + ) + assert (result.returncode, result.stdout) == (0, ""), result.stderr diff --git a/tests/test_docs_examples.py b/tests/test_docs_examples.py index a101651a..58772507 100644 --- a/tests/test_docs_examples.py +++ b/tests/test_docs_examples.py @@ -8,6 +8,8 @@ import pytest from pytest_examples import CodeExample, EvalExample, find_examples +from tests._docs_harness import fence_settings, run_fence + REPO_ROOT = Path(__file__).resolve().parents[1] DOCS_PAGES = REPO_ROOT / "docs" / "pages" DOCS_EXAMPLES = REPO_ROOT / "docs" / "examples" @@ -34,9 +36,9 @@ def test_docs_examples(example: CodeExample, eval_example: EvalExample) -> None: flags=ast.PyCF_ALLOW_TOP_LEVEL_AWAIT, ) - settings = example.prefix_settings() + settings = fence_settings(example) - # Opt in for stricter linting using pytest-examples prefix settings: + # Opt in for stricter linting: # ```python {lint=check} # ... # ``` @@ -49,13 +51,21 @@ def test_docs_examples(example: CodeExample, eval_example: EvalExample) -> None: ) eval_example.lint(example) - # Opt in for runnable snippets using pytest-examples prefix settings: + # Opt in for runnable snippets: # ```python {test=run} # ... # ``` + # With a prelude, the block runs in a subprocess after + # docs/examples/_preludes/.py, inside its session: + # ```python {test=run prelude=reference} + # ... + # ``` if settings.get("test") == "run": - eval_example.run_print_check(example) - eval_example.run(example) + if "prelude" in settings: + run_fence(example) + else: + eval_example.run_print_check(example) + eval_example.run(example) @pytest.mark.parametrize( @@ -85,3 +95,53 @@ def test_docs_example_scripts(script: Path) -> None: assert result.returncode == 0, ( f"{script.name} failed\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}" ) + + +def _page(tmp_path: Path, prefix: str, body: str) -> CodeExample: + page = tmp_path / "page.md" + page.write_text(f"# Page\n\n```{prefix}\n{body}\n```\n", encoding="utf-8") + [example] = find_examples(str(page)) + return example + + +def test_prelude_fence_runs_after_its_prelude(tmp_path: Path) -> None: + example = _page( + tmp_path, + "python {test=run prelude=reference}", + "print(await Author.where(lambda author: author.id == 1).first())", + ) + assert fence_settings(example) == {"test": "run", "prelude": "reference"} + output = run_fence(example) + assert "Ada Lovelace" in output + + +def test_prelude_fence_failure_names_the_page_line(tmp_path: Path) -> None: + example = _page( + tmp_path, + "python {test=run prelude=reference}", + 'author = await Author.get(1)\nraise RuntimeError("boom")', + ) + with pytest.raises(AssertionError) as failure: + run_fence(example) + message = str(failure.value) + assert f'File "{example.path}", line 5' in message + assert "RuntimeError: boom" in message + + +def test_unknown_prelude_fails_loudly(tmp_path: Path) -> None: + example = _page(tmp_path, "python {test=run prelude=nope}", "pass") + with pytest.raises(AssertionError, match="no prelude named 'nope'"): + run_fence(example) + + +def test_prelude_without_test_run_is_refused(tmp_path: Path) -> None: + example = _page(tmp_path, "python {prelude=reference}", "pass") + with pytest.raises(ValueError, match=r"prelude=reference needs test=run"): + fence_settings(example) + + +def test_fence_settings_read_bare_and_quoted_values() -> None: + example = CodeExample.create( + "pass", prefix='python {test=run lint="check"}', path=Path("p.md") + ) + assert fence_settings(example) == {"test": "run", "lint": "check"} diff --git a/tests/test_docstring_examples.py b/tests/test_docstring_examples.py new file mode 100644 index 00000000..ad5dbcd1 --- /dev/null +++ b/tests/test_docstring_examples.py @@ -0,0 +1,93 @@ +"""Docstring examples in ``src/ferro``: every one compiles, marked ones run. + +A fenced Python example in a docstring is compiled. One marked +``{test=run prelude=}`` also runs in a subprocess after +``docs/examples/_preludes/.py``, inside its session, the way a marked +fence on a docs page does. +""" + +from __future__ import annotations + +import ast +import textwrap +from pathlib import Path + +import pytest +from pytest_examples import CodeExample + +from tests._docs_harness import docstring_examples, fence_settings, run_fence + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +@pytest.mark.parametrize( + "example", docstring_examples("ferro", REPO_ROOT / "src"), ids=str +) +def test_docstring_example(example: CodeExample) -> None: + compile( + example.source, + str(example.path), + "exec", + flags=ast.PyCF_ALLOW_TOP_LEVEL_AWAIT, + ) + if fence_settings(example).get("test") == "run": + run_fence(example) + + +def _scratch_package(tmp_path: Path, body: str) -> Path: + package = tmp_path / "scratch" + package.mkdir() + (package / "__init__.py").write_text('"""Scratch package."""\n', "utf-8") + (package / "library.py").write_text( + textwrap.dedent(body).lstrip("\n"), encoding="utf-8" + ) + return package / "library.py" + + +def test_marked_docstring_example_is_collected_and_run(tmp_path: Path) -> None: + module = _scratch_package( + tmp_path, + ''' + def first_author(): + """Return the first author. + + Examples: + ```python {test=run prelude=reference} + author = await Author.where(lambda author: author.id == 1).first() + print(author.name) + ``` + + ```python + first_author() + ``` + """ + ''', + ) + examples = docstring_examples("scratch", tmp_path) + assert [(ex.path, ex.start_line) for ex in examples] == [ + (module, 5), + (module, 10), + ] + marked, unmarked = examples + assert fence_settings(marked) == {"test": "run", "prelude": "reference"} + assert fence_settings(unmarked) == {} + assert run_fence(marked).strip() == "Ada Lovelace" + + +def test_failing_docstring_example_names_its_file_line(tmp_path: Path) -> None: + module = _scratch_package( + tmp_path, + ''' + class Library: + """A library. + + ```python {test=run prelude=reference} + raise RuntimeError("boom") + ``` + """ + ''', + ) + [example] = docstring_examples("scratch", tmp_path) + with pytest.raises(AssertionError) as failure: + run_fence(example) + assert f'File "{module}", line 5' in str(failure.value) From f1bf2a4fbaa1bf6e4dcf9a1af430f8201973a422 Mon Sep 17 00:00:00 2001 From: 0x54 Date: Mon, 19 Oct 2026 13:22:06 -0400 Subject: [PATCH 04/33] feat(docs): site configuration fixes and a strict docs build (#701) (#729) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #701 ## What Take a page that embeds `docs/examples/predicates.py:filtering`. Today the reader sees ```text adults = await User.where(lambda user: user.age >= 18).all() ``` with eight leading spaces, because the example section sits inside `async def main():`. After this PR it starts at column 0, so a pasted block runs. The rest of the PR is configuration of the same kind: - One click on "Annotated" switches every Assignment/Annotated pair on the site, and tab anchors read `#defining-a-model-annotated` instead of `#__tabbed_1_2`. - Hovering DDL, RLS, FK, PK, GUC, FFI, IR and similar terms shows a definition, from `docs/includes/abbreviations.md`, appended to every page. - `#647` in the changelog links to the issue. Permalinks carry a title for screen readers. - `/guide/migrations/#auto-migration` now lands on `/guide/schema/auto-migrate/` through a generated redirect page. The hand-written stub page is deleted, so it is gone from the sitemap and search. All 16 old anchors are mapped. - The API reference drops source blocks, puts typed and linked signatures under each heading, and shows summaries, symbol badges and backlinks. `Field` renders once, without its six overloads, and each parameter gets an anchor (`ferro.Field(index)`). `Relation.all()` and `.first()` inherit `Query`'s docstrings. - Theme: instant navigation with a progress bar, anchor tracking, breadcrumbs, prev/next footer, line selection, "Copy as Markdown", a third "follow system" palette state, GitHub and PyPI footer links, and `new`/`deprecated` status badges. `search.suggest` is removed because nothing reads it. - Plugins: `redirects`, `llmstxt` (`/llms.txt`, `/llms-full.txt`, a `.md` copy of every page) and `social` cards on the rust background `#B7410E` (checked: the card's first pixel is `b7410e`). - `create`, `upsert`, `get_or_create` and `update_or_create` annotate `**fields: Any`, which clears the griffe warnings. - The docs build runs with `--strict` in the publish workflow and in a new `docs` job in CI, which `All Checks Passed` now requires. ## Test scenarios - Happy path: `tests/test_docs_site.py` builds the site with `zensical build --strict` once per session in a temporary directory and passes (13 tests). - Error path: with `dedent_subsections = false` the indentation test fails and names `/getting-started/quickstart/` (` await connect("sqlite::memory:", auto_migrate=True)`). With the griffe extension removed, the `Relation` test fails because `Relation.all` has no docstring. I ran both by hand and then restored the config. - Integration: the generated `guide/migrations/` page maps every old anchor to the section that replaced it. The test resolves each target against the old URL and checks that the anchor exists on the target page. ## Verify Ran the ticket's Verify block on `d48d7cc`: passed (`No issues found`; 13 passed). TDD: RED `uv run --no-sync pytest tests/test_docs_site.py -q --no-cov -p no:cacheprovider` failed as expected on `13629e7` (3 failed, 10 errors: no glossary, a non-strict build, the stub page present). GREEN: the same command passed 13/13 on `d48d7cc`. Full suite: 3309 passed, 339 skipped, 2 xfailed. Verify block corrected: no ## Interfaces Interfaces changed: none `built_site` (session fixture returning the site `Path`) and `rendered_pages(built_site) -> Iterator[tuple[str, str]]` are in `tests/test_docs_site.py`. `url_path` reads like `/guide/queries/`, and `/` for the home page. Zensical refuses a `docs_dir` outside the project root, so the fixture copies `zensical.toml`, `docs/pages` and `docs/includes` into the temp project and symlinks `docs/examples`, `src`, `README.md`, `CHANGELOG.md` and `CONTRIBUTING.md`. ## Notes for the reviewer - **`filters = ["!^_"]`, not `"public"`.** The ticket asks for `filters = "public"` and also for a Model page with no dunders or `__ferro_*__` attributes, and one setting cannot deliver both. Griffe counts every dunder as public, so `"public"` still rendered `__ferro_checks__`, `__getattr__`, `__repr__`, `__post_init__` and the rest. `["!^_"]` hides them. Every API directive names a class or function, never a module, so `__all__` (the one thing `"public"` adds) does not come into play. Site-wide this also hides protocol dunders a reader might care about: `Session.__aenter__`, `Rows.__len__`/`__iter__`/`__getitem__`, and the `AggregateExpr` comparisons. Those belong in the class docstrings, which is #722's work. The reasoning is also in a comment in `zensical.toml`. - **Outside Files owned:** - `docs/pages/api/fields.md`: the `show_overloads: false` / `parameter_headings: true` options the ticket asks for go on the `::: ferro.Field` directive, which lives in that file. - `ci.yml`'s two pytest jobs now also sync the `docs` group, so `test_docs_site.py` runs in CI and is not skipped. The file uses `pytest.importorskip("zensical")` for environments without the group. - The strict build needed no docstring fixes. - Duplicate `__codelineno-0-N` ids on API pages are still there. `separate_signature` code blocks are all numbered from 0, which the audit flagged as a separate ticket. Strict mode does not flag them. - `llmstxt` still lists `getting-started/next-steps.md`. The nav ticket (#723) folds that page away and should update the section list. ## Exit steps - [ ] Issue #701 status updated (closed by the orchestrator after this PR merges into `feat/docs-rewrite`) ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .github/workflows/ci.yml | 32 +++- .github/workflows/publish-docs.yml | 2 +- docs/includes/abbreviations.md | 19 ++ docs/pages/api/fields.md | 3 + docs/pages/guide/migrations.md | 40 ----- pyproject.toml | 2 + src/ferro/models.py | 8 +- tests/test_docs_site.py | 268 +++++++++++++++++++++++++++++ uv.lock | 16 ++ zensical.toml | 129 ++++++++++++-- 10 files changed, 460 insertions(+), 59 deletions(-) create mode 100644 docs/includes/abbreviations.md delete mode 100644 docs/pages/guide/migrations.md create mode 100644 tests/test_docs_site.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ccb0a374..8388fda8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,31 @@ jobs: run: | uv run --no-sync --python 3.13 prek run --all-files + docs: + name: Docs build (Zensical, strict) + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install UV + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Install documentation dependencies + run: | + uv sync --only-group docs --no-install-project --python 3.13 + + - name: Build Zensical site + run: | + uv run --no-sync zensical build --clean --strict + test-rust: name: Rust tests runs-on: ubuntu-latest @@ -119,7 +144,7 @@ jobs: - name: Install dependencies run: | - uv sync --only-group ci-test --no-install-project --python 3.13 + uv sync --only-group ci-test --only-group docs --no-install-project --python 3.13 - name: Build Rust extension run: | @@ -170,7 +195,7 @@ jobs: - name: Install dependencies run: | - uv sync --only-group ci-test --no-install-project --python ${{ matrix.python-version }} + uv sync --only-group ci-test --only-group docs --no-install-project --python ${{ matrix.python-version }} - name: Build Rust extension run: | @@ -296,7 +321,7 @@ jobs: all-checks: name: All Checks Passed - needs: [lint-and-format, test-python-pr, test-python-main, test-python-backend-matrix, test-rust] + needs: [lint-and-format, docs, test-python-pr, test-python-main, test-python-backend-matrix, test-rust] runs-on: ubuntu-latest if: always() steps: @@ -310,5 +335,6 @@ jobs: if ! ok "${{ needs.test-python-main.result }}"; then exit 1; fi if ! ok "${{ needs.test-python-backend-matrix.result }}"; then exit 1; fi if ! ok "${{ needs.test-rust.result }}"; then exit 1; fi + if ! ok "${{ needs.docs.result }}"; then exit 1; fi echo "All checks passed!" diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index b3fbe971..79ed5c3e 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -42,7 +42,7 @@ jobs: - name: Build Zensical site run: | - uv run --no-sync zensical build --clean + uv run --no-sync zensical build --clean --strict - name: Upload Pages artifact uses: actions/upload-pages-artifact@v3 diff --git a/docs/includes/abbreviations.md b/docs/includes/abbreviations.md new file mode 100644 index 00000000..2f2acf22 --- /dev/null +++ b/docs/includes/abbreviations.md @@ -0,0 +1,19 @@ +*[ADR]: Architecture Decision Record: a short note that records one design decision and why it was made +*[ASGI]: Asynchronous Server Gateway Interface: the async Python web server standard +*[CRUD]: Create, Read, Update, Delete +*[DBAPI]: Python Database API (PEP 249) +*[DDL]: Data Definition Language: the CREATE, ALTER and DROP statements that change a schema +*[DML]: Data Manipulation Language: the INSERT, UPDATE and DELETE statements that change rows +*[FFI]: Foreign Function Interface: the boundary between Ferro's Python layer and its Rust core +*[FK]: Foreign key +*[FKs]: Foreign keys +*[GIL]: Global Interpreter Lock +*[GUC]: Grand Unified Configuration: a key/value Postgres runtime setting, such as the ones a Session applies for Row-Level Security +*[GUCs]: Grand Unified Configuration settings: key/value Postgres runtime settings, such as the ones a Session applies for Row-Level Security +*[IR]: Intermediate representation: the description of models or a query that Ferro's Python layer hands to its Rust core +*[JSONB]: Postgres binary JSON type +*[ORM]: Object-Relational Mapper +*[PK]: Primary key +*[RLS]: Row-Level Security: Postgres policies that decide which rows each connection may see or change +*[TTY]: Terminal +*[UUID]: Universally Unique Identifier diff --git a/docs/pages/api/fields.md b/docs/pages/api/fields.md index ae043be4..e698777d 100644 --- a/docs/pages/api/fields.md +++ b/docs/pages/api/fields.md @@ -3,6 +3,9 @@ Fields are declared as type annotations on a [`Model`](model.md), with `Field(...)` supplying database options (primary key, unique, index, defaults). The lower-level types on this page โ€” `FerroField`, `DbType`, and friends โ€” power `Annotated[...]` declarations and explicit column-type overrides. ::: ferro.Field + options: + show_overloads: false + parameter_headings: true ::: ferro.FerroField diff --git a/docs/pages/guide/migrations.md b/docs/pages/guide/migrations.md deleted file mode 100644 index ee0d8a06..00000000 --- a/docs/pages/guide/migrations.md +++ /dev/null @@ -1,40 +0,0 @@ -# Schema Migrations has moved - -This page became the **Schema Management** group. Start at the [overview](schema/overview.md); each old section now lives here: - -| Old section | Now | -| :--- | :--- | -| Three Ways to Manage Schema | [Overview โ€บ The ladder](schema/overview.md#the-ladder) | -| Auto-Migration | [Auto-migrate](schema/auto-migrate.md) | -| Applying column changes with `migrate_updates` | [Auto-migrate โ€บ Applying column changes](schema/auto-migrate.md#applying-column-changes-with-migrate_updates) | -| Evolving enums: label addition | [Auto-migrate โ€บ Evolving enums](schema/auto-migrate.md#evolving-enums-label-addition) | -| Destructive drops with `migrate_destructive` | [Auto-migrate โ€บ Destructive drops](schema/auto-migrate.md#destructive-drops-with-migrate_destructive) | -| On-demand `migrate()` | [Auto-migrate โ€บ On-demand `migrate()`](schema/auto-migrate.md#on-demand-migrate) | -| Safety guidance | [Auto-migrate โ€บ Safety guidance](schema/auto-migrate.md#safety-guidance) | -| Alembic for Production | [Alembic](schema/alembic.md) | -| Choosing a Workflow | [Overview โ€บ Choosing](schema/overview.md#choosing) | - - diff --git a/pyproject.toml b/pyproject.toml index 90eb8c95..53abcdcc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,7 @@ docs = [ "zensical>=0.0.71", "mkdocstrings-python>=2.0.9", "pymdown-extensions>=12.3", + "griffe-inherited-docstrings>=1.1.4", ] release = [ "python-semantic-release>=9.0.0", @@ -89,6 +90,7 @@ dev = [ "greenlet>=3.3.1", "ty>=0.0.56", "cyclopts>=5,<6", + "griffe-inherited-docstrings>=1.1.4", ] [tool.pytest.ini_options] diff --git a/src/ferro/models.py b/src/ferro/models.py index 0b3935b6..b9a7caf6 100644 --- a/src/ferro/models.py +++ b/src/ferro/models.py @@ -721,7 +721,7 @@ def using(cls, name: str) -> "ModelConnection[Self]": return ModelConnection(cls, name) @classmethod - async def create(cls, *, session: "Session | None" = None, **fields) -> Self: + async def create(cls, *, session: "Session | None" = None, **fields: Any) -> Self: """Create and persist a new model instance ``create()`` is a plain INSERT: it never updates an existing row. @@ -747,7 +747,7 @@ async def create(cls, *, session: "Session | None" = None, **fields) -> Self: return instance @classmethod - async def upsert(cls, *, session: "Session | None" = None, **fields) -> Self: + async def upsert(cls, *, session: "Session | None" = None, **fields: Any) -> Self: """Insert the row, or update the existing row on primary-key conflict. Equivalent to ``cls(**fields).save(on_conflict="update")``. With an @@ -808,7 +808,7 @@ async def get_or_create( defaults: dict[str, Any] | None = None, *, session: "Session | None" = None, - **fields, + **fields: Any, ) -> tuple[Self, bool]: """Fetch a record by filters or create one when missing @@ -841,7 +841,7 @@ async def update_or_create( defaults: dict[str, Any] | None = None, *, session: "Session | None" = None, - **fields, + **fields: Any, ) -> tuple[Self, bool]: """Update a matched record or create one when missing diff --git a/tests/test_docs_site.py b/tests/test_docs_site.py new file mode 100644 index 00000000..3264065e --- /dev/null +++ b/tests/test_docs_site.py @@ -0,0 +1,268 @@ +"""The built documentation site renders what the configuration promises. + +The site is built once per session with ``zensical build --strict`` into a +temporary directory, so a warning anywhere fails the session, and every test +below reads the HTML a reader would get: + +- a code block embedded from ``docs/examples`` starts at column 0, so a + pasted block runs (``pymdownx.snippets.dedent_subsections``); +- Assignment/Annotated tabs carry word anchors and switch together site-wide; +- acronyms such as DDL carry a hover definition from the glossary; +- ``#NNN`` references link to GitHub; +- the old migrations URL and its anchors redirect to the sections that + replaced them; +- the API reference hides source blocks, dunder and ``__ferro_*__`` members, + links the types in its signatures, renders ``Field`` once and gives + ``Relation.all()`` / ``.first()`` their inherited docstrings; +- the docs build in CI and in the publish workflow is strict. +""" + +from __future__ import annotations + +import html +import json +import re +import shutil +import subprocess +import sys +from collections.abc import Iterator +from pathlib import Path +from urllib.parse import urljoin + +import pytest + +pytest.importorskip("zensical", reason="the docs dependency group is not installed") + +REPO_ROOT = Path(__file__).resolve().parents[1] +WORKFLOWS = REPO_ROOT / ".github" / "workflows" + +COPIED = ("zensical.toml", "docs/pages", "docs/includes") +"""Paths copied into the scratch project: Zensical refuses a ``docs_dir`` +that resolves outside the project root, so the pages are copied.""" + +LINKED = ("docs/examples", "src", "README.md", "CHANGELOG.md", "CONTRIBUTING.md") +"""Paths the pages embed or document, linked into the scratch project.""" + +_CODE_BLOCK = re.compile( + r'
(?:.*?)?' + r"
(.*?)
", + re.DOTALL, +) +_TAG = re.compile(r"<[^>]+>") + + +@pytest.fixture(scope="session") +def built_site(tmp_path_factory: pytest.TempPathFactory) -> Path: + """Build the docs site with ``zensical build --strict``; return its root.""" + project = tmp_path_factory.mktemp("docs-site") + for name in COPIED: + source, target = REPO_ROOT / name, project / name + target.parent.mkdir(parents=True, exist_ok=True) + if source.is_dir(): + shutil.copytree(source, target) + else: + shutil.copy2(source, target) + for name in LINKED: + target = project / name + target.parent.mkdir(parents=True, exist_ok=True) + target.symlink_to(REPO_ROOT / name) + result = subprocess.run( + [sys.executable, "-m", "zensical", "build", "--clean", "--strict"], + cwd=project, + capture_output=True, + text=True, + timeout=600, + ) + assert result.returncode == 0, ( + f"zensical build --strict failed\nstdout:\n{result.stdout}\n" + f"stderr:\n{result.stderr}" + ) + return project / "site" + + +def rendered_pages(built_site: Path) -> Iterator[tuple[str, str]]: + """Yield ``(url_path, html)`` for every HTML page of the built site.""" + for page in sorted(built_site.rglob("*.html")): + relative = page.relative_to(built_site).as_posix() + url_path = "/" + relative.removesuffix("index.html") + yield url_path, page.read_text(encoding="utf-8") + + +def _page(built_site: Path, url_path: str) -> str: + page = built_site / url_path.strip("/") / "index.html" + assert page.is_file(), f"{url_path} was not built" + return page.read_text(encoding="utf-8") + + +def _code_lines(block: str) -> list[str]: + text = html.unescape(_TAG.sub("", block)) + return [line for line in text.splitlines() if line.strip()] + + +def _indented_blocks(page_html: str) -> list[str]: + """Return the code blocks whose every line shares leading whitespace.""" + indented = [] + for match in _CODE_BLOCK.finditer(page_html): + lines = _code_lines(match.group(1)) + if lines and all(line[0] in " \t" for line in lines): + indented.append("\n".join(lines)) + return indented + + +def test_embedded_code_starts_at_column_zero(built_site: Path) -> None: + offenders = { + url: blocks[0] + for url, page_html in rendered_pages(built_site) + if (blocks := _indented_blocks(page_html)) + } + assert not offenders, "code blocks render indented:\n" + "\n\n".join( + f"{url}:\n{block}" for url, block in offenders.items() + ) + + +def test_quickstart_has_code_blocks_to_check(built_site: Path) -> None: + assert len(_CODE_BLOCK.findall(_page(built_site, "getting-started/quickstart"))) > 3 + + +def test_declaration_tabs_have_word_anchors_and_switch_together( + built_site: Path, +) -> None: + page = _page(built_site, "guide/models-and-fields") + assert page.count('class="tabbed-set') >= 2 + labels = re.findall(r'