From 6688fe80f008566b5e548b807c7f5ba1684b45eb Mon Sep 17 00:00:00 2001 From: Taylor Date: Wed, 21 Oct 2026 14:28:39 -0400 Subject: [PATCH 01/13] test(docs): one fence reader and one runner; docstring fences must be marked - tests/_docs_harness.py: fenced_blocks() reads every fence (language, info, dedented body, opening line, the note above it) and refuses an unclosed one; run_python() is the one subprocess runner; check_fence() holds a fence to its contract; page_examples() also collects fences from Markdown a page pulls in with --8<-- (CONTRIBUTING.md). - The README, TOML, step-file and output-block readers and the example, migrations, fixtures and autogenerate runners use them. - A docstring fence without a test mode is refused, as a page fence is. - The nine entry points of story 72 must each keep a test=run example. - CONTRIBUTING.md's test example says why it only compiles. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP --- CONTRIBUTING.md | 2 +- tests/_docs_harness.py | 361 +++++++++++++++++++++++----- tests/test_docs_examples.py | 103 +++----- tests/test_docs_harness.py | 99 ++++++++ tests/test_docs_migrations.py | 62 +---- tests/test_docs_testing_fixtures.py | 29 +-- tests/test_docstring_examples.py | 138 +++++++++-- 7 files changed, 582 insertions(+), 212 deletions(-) create mode 100644 tests/test_docs_harness.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1c6eb779..a14ab02c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -314,7 +314,7 @@ Version bumps are determined by commit types: Tests that cross the Python and Rust bridge, or exercise Pydantic models, live in `tests/`. A test connects with the `db_url` fixture, opens a session and asserts on what comes back: -```python +```python {test=compile reason="a test for this repository's suite, which supplies its db_url fixture"} import pytest from ferro import Field, Model, connect, engines diff --git a/tests/_docs_harness.py b/tests/_docs_harness.py index 6f0782c3..d53084d4 100644 --- a/tests/_docs_harness.py +++ b/tests/_docs_harness.py @@ -18,29 +18,34 @@ how-to hands readers; ``tests/test_docs_testing_fixtures.py`` writes it at that path in a scratch project and runs pytest there. -``lint=check`` also lints the block (docs pages only). Every Python fence on -a docs page carries a ``test`` mode (``page_fence_mode``); a docstring fence -without one is compiled. Any other setting, or a mode without its setting, is -refused with the marker to write. +``lint=check`` also lints the block. Every Python fence, on a docs page or in +a docstring, carries a ``test`` mode (``fence_mode``). Any other setting, or a +mode without its setting, is refused with the marker to write. + +One reader finds every fenced block (``fenced_blocks``), and one runner starts +every subprocess the docs tests start (``run_python``). """ from __future__ import annotations +import ast +import os import re import subprocess import sys -from collections.abc import Iterator +from collections.abc import Iterator, Sequence +from dataclasses import dataclass from pathlib import Path import griffe -from pytest_examples import CodeExample +from pytest_examples import CodeExample, EvalExample from pytest_examples.find_examples import remove_indent REPO_ROOT = Path(__file__).resolve().parents[1] +DOCS_PAGES = REPO_ROOT / "docs" / "pages" 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) _TEST_MODES = ("run", "compile", "pytest") @@ -54,6 +59,243 @@ } +# -- the one runner ---------------------------------------------------------------- + + +def run_python( + args: Sequence[str], + *, + timeout: float = 180, + env: dict[str, str] | None = None, + cwd: Path = REPO_ROOT, + input: str | None = None, # noqa: A002 - subprocess.run's own name + clean_env: bool = False, +) -> subprocess.CompletedProcess[str]: + """Run this interpreter with ``args`` and return the finished process. + + Args: + args: The arguments after the interpreter (a script and its arguments, + ``-m module`` or ``-c code``). + timeout: Seconds before the run is killed. Defaults to 180. + env: Variables to set for the run, over this process's environment. + cwd: The working directory. Defaults to the repository root. + input: Text piped to the run's stdin. + clean_env: Start from an empty environment instead of this process's, + so only ``env`` reaches the run. + + Returns: + The process, its ``stdout`` and ``stderr`` as text. + + Raises: + AssertionError: The run exited non-zero. The message names the command, + the exit code and both streams. + """ + command = [sys.executable, *args] + result = subprocess.run( + command, + input=input, + capture_output=True, + text=True, + timeout=timeout, + cwd=cwd, + env={**({} if clean_env else os.environ), **(env or {})}, + ) + if result.returncode != 0: + raise AssertionError( + f"{' '.join(command)} exited {result.returncode}\n" + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + return result + + +# -- the one fence reader ---------------------------------------------------------- + + +@dataclass(frozen=True) +class Fence: + """One fenced block of a Markdown file or a docstring.""" + + path: Path + """The file the block is in.""" + line: int + """The line of the block's opening fence.""" + language: str + """The first word of the info string (``python``, ``sql``, ``text``), or ``""``.""" + info: str + """Everything after the opening backticks, stripped.""" + body: str + """Everything between the fences, the fence's own indentation removed.""" + indent: str + """The indentation of the opening fence.""" + note: tuple[str, str] | None + """The ```` comment on the line above the fence, at + the same indentation, as ``(kind, value)``.""" + + @property + def where(self) -> str: + """The block's file and opening line, as ``:``.""" + return f"{self.path}:{self.line}" + + +_OPENING = re.compile(r"^(?P[ \t]*)(?P`{3,}|~{3,})(?P[^`\n]*)$") +_NOTE = re.compile(r"^$") + + +def _language(info: str) -> str: + word = info.lstrip("{").split(" ", 1)[0].split("{", 1)[0] + return word.lstrip(".").lower() + + +def fenced_blocks( + path: Path, text: str, language: str | None = None, *, line_offset: int = 0 +) -> list[Fence]: + """Read every fenced block of ``text``, numbered as lines of ``path``. + + A fence opens with three or more backticks (or tildes) and closes on a line + holding only the same run at the same indentation, so a longer fence can + quote a shorter one. Each block's ``note`` is the HTML comment directly + above it, the way pages mark where a block comes from + (````). + + Args: + path: The file ``text`` comes from, named in each block and error. + text: The Markdown, or a docstring's lines as they sit in the file. + language: Keep only the blocks in this language. Defaults to every block. + line_offset: The number of lines of ``path`` before ``text``. + + Returns: + The blocks in file order. + + Raises: + ValueError: A fence is never closed; the message names its line. + """ + lines = text.splitlines(keepends=True) + blocks: list[Fence] = [] + index = 0 + while index < len(lines): + opening = _OPENING.match(lines[index].rstrip("\n")) + if opening is None: + index += 1 + continue + indent, ticks = opening["indent"], opening["ticks"] + info = opening["info"].strip() + closing = re.compile( + rf"^{re.escape(indent)}{re.escape(ticks[0])}{{{len(ticks)},}}[ \t]*$" + ) + end = next( + (n for n in range(index + 1, len(lines)) if closing.match(lines[n])), + None, + ) + if end is None: + raise ValueError( + f"{path}:{line_offset + index + 1}: the {ticks}{_language(info)} " + "fence is never closed" + ) + note = None + if index > 0: + above = lines[index - 1].rstrip("\n") + if above.startswith(indent): + marked = _NOTE.match(above[len(indent) :]) + if marked is not None: + note = (marked["kind"], marked["value"]) + body = "".join(line.removeprefix(indent) for line in lines[index + 1 : end]) + fence = Fence( + path, line_offset + index + 1, _language(info), info, body, indent, note + ) + if language is None or fence.language == language: + blocks.append(fence) + index = end + 1 + return blocks + + +def page_fences(language: str | None = None) -> list[Fence]: + """Read every fenced block a docs page shows, numbered as lines of its file. + + A page's own blocks come first, then the blocks of every Markdown section a + page pulls in with ``--8<-- ".md:
"`` (``CONTRIBUTING.md``, + ``README.md``, ``docs/includes``), each named by its own file and line, so + a block reaches the reader only through a reader that sees it. + """ + blocks: list[Fence] = [] + included: set[tuple[Path, str]] = set() + for page in sorted(DOCS_PAGES.rglob("*.md")): + text = page.read_text(encoding="utf-8") + blocks += fenced_blocks(page, text, language) + for match in _INCLUDE.finditer(text): + target = REPO_ROOT / match["file"] + if target.name == "CHANGELOG.md": + continue # Release tooling writes it; it holds no examples. + included.add((target, match["section"] or "")) + for target, section in sorted(included): + text, offset = _section(target, section) + blocks += fenced_blocks(target, text, language, line_offset=offset) + return blocks + + +_INCLUDE = re.compile(r'--8<-- "(?P[^":]+\.md)(?::(?P
[A-Za-z_-]+))?"') + + +def _section(path: Path, section: str) -> tuple[str, int]: + """The text of ``path`` between its ``[start:
]`` and + ``[end:
]`` lines, and the number of lines before it.""" + lines = path.read_text(encoding="utf-8").splitlines(keepends=True) + if not section: + return "".join(lines), 0 + start = next( + (n for n, line in enumerate(lines) if f"[start:{section}]" in line), None + ) + end = next((n for n, line in enumerate(lines) if f"[end:{section}]" in line), None) + if start is None or end is None: + raise ValueError(f"{path} has no section {section!r}") + return "".join(lines[start + 1 : end]), start + 1 + + +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. + """ + return [ + _example(fence) + for fence in fenced_blocks(path, text, line_offset=line_offset) + if fence.language in ("py", "python") + ] + + +def _example(fence: Fence) -> CodeExample: + source, indent = remove_indent(fence.body) + return CodeExample( + source=source, + path=fence.path, + start_line=fence.line, + end_line=fence.line + fence.body.count("\n") + 1, + start_index=0, + end_index=0, + prefix=fence.info.lower(), + indent=len(fence.indent) + indent, + ) + + +def page_examples() -> list[CodeExample]: + """Every Python fence a docs page shows (``page_fences``), as an example. + + A fence holding a ``--8<--`` line embeds an example script, which + ``tests/test_docs_examples.py`` runs as a whole, and is left out. + """ + return [ + _example(fence) + for fence in page_fences() + if fence.language in ("py", "python") and "--8<--" not in fence.body + ] + + +# -- fence settings --------------------------------------------------------------- + + def preludes() -> list[str]: """Name every prelude a fence can ask for.""" return sorted(p.stem for p in PRELUDES.glob("*.py") if not p.stem.startswith("_")) @@ -102,10 +344,10 @@ def fence_settings(example: CodeExample) -> dict[str, str]: return settings -def page_fence_mode(example: CodeExample) -> str: - """Return how a docs page's Python fence is tested: run, compile or pytest. +def fence_mode(example: CodeExample) -> str: + """Return how a Python fence is tested: run, compile or pytest. - A page fence must say: one that runs is marked + A fence on a docs page or in a docstring must say: one that runs is marked ``{test=run prelude=}``, one that cannot run says why with ``{test=compile reason="..."}``. An unmarked fence is refused, so no block is compile-only by accident. @@ -120,37 +362,48 @@ def page_fence_mode(example: CodeExample) -> str: return mode -def fenced_examples( - path: Path, text: str, *, line_offset: int = 0 -) -> list[CodeExample]: - """Find the Python fences in ``text``, numbered as lines of ``path``. +_LINT_IGNORES = ["D", "T", "B", "C4", "E711", "E712", "E721"] +"""Ruff rules a docs block may break: blocks print, and show partial code.""" - ``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. + +def check_fence( + example: CodeExample, *, eval_example: EvalExample | None = None +) -> str | None: + """Hold one Python fence to its contract and return what it printed. + + The fence compiles (top-level ``await`` allowed), says how it is tested + (``fence_mode``), is linted when marked ``lint=check``, and runs when + marked ``test=run``. A ``test=pytest`` fence is run by the testing + how-to's own test. + + Args: + example: The fence, from a docs page or a docstring. + eval_example: pytest-examples' fixture, which lints a ``lint=check`` + fence. A fence marked ``lint=check`` without one is refused. + + Returns: + What a ``test=run`` fence printed, or ``None`` for one that only + compiles. + + Raises: + ValueError: The fence is unmarked or mis-marked. + AssertionError: The fence failed to run. """ - 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, - ) + compile( + example.source, str(example.path), "exec", flags=ast.PyCF_ALLOW_TOP_LEVEL_AWAIT + ) + mode = fence_mode(example) + if fence_settings(example).get("lint") == "check": + if eval_example is None: + raise ValueError(f"{example}: lint=check needs the eval_example fixture") + eval_example.set_config( + ruff_ignore=_LINT_IGNORES, line_length=100, target_version="py313" ) - return examples + eval_example.lint(example) + return run_fence(example) if mode == "run" else None + + +# -- docstrings ------------------------------------------------------------------- def _documented(obj: griffe.Object) -> Iterator[griffe.Object]: @@ -200,23 +453,19 @@ def run_fence(example: CodeExample) -> str: f"{example}: only a fence marked {{test=run prelude=}} runs" ) prelude = settings["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}" + try: + result = run_python( + [ + str(PRELUDES / "__init__.py"), + prelude, + str(example.path), + str(example.start_line + 1), + ], + input=example.source, + timeout=120, ) + except AssertionError as failure: + raise AssertionError( + f"{example} failed after prelude {prelude!r}\n{failure}" + ) from None return result.stdout diff --git a/tests/test_docs_examples.py b/tests/test_docs_examples.py index 2aa4631f..8e219799 100644 --- a/tests/test_docs_examples.py +++ b/tests/test_docs_examples.py @@ -1,58 +1,40 @@ from __future__ import annotations -import ast import re -import subprocess -import sys from pathlib import Path import pytest -from pytest_examples import CodeExample, EvalExample, find_examples - -from tests._docs_harness import fence_settings, page_fence_mode, run_fence +from pytest_examples import CodeExample, EvalExample + +from tests._docs_harness import ( + check_fence, + fence_mode, + fence_settings, + fenced_blocks, + fenced_examples, + page_examples, + run_fence, + run_python, +) REPO_ROOT = Path(__file__).resolve().parents[1] DOCS_PAGES = REPO_ROOT / "docs" / "pages" DOCS_EXAMPLES = REPO_ROOT / "docs" / "examples" -def _inline_examples() -> list[CodeExample]: - # Snippet directives (--8<--) are expanded by the docs build, not by - # pytest-examples, so blocks containing them are not valid Python here. - return [ - example - for example in find_examples(str(DOCS_PAGES)) - if "--8<--" not in example.source - ] - - -@pytest.mark.parametrize("example", _inline_examples(), ids=str) +@pytest.mark.parametrize("example", page_examples(), ids=str) def test_docs_examples(example: CodeExample, eval_example: EvalExample) -> None: """Every inline page block compiles; one marked to run, runs. - Each block says how it is tested (``tests._docs_harness.page_fence_mode``): - ``{test=run prelude=}`` runs it after that prelude, - ``{test=compile reason="..."}`` compiles it and says why it cannot run, - ``{test=pytest path="tests/.py"}`` runs it as a test module - (``tests/test_docs_testing_fixtures.py``). ``lint=check`` also lints it. + The blocks are every Python fence a page shows, including those of the + Markdown a page pulls in (``CONTRIBUTING.md``). Each says how it is tested + (``tests._docs_harness.fence_mode``): ``{test=run prelude=}`` runs it + after that prelude, ``{test=compile reason="..."}`` compiles it and says + why it cannot run, ``{test=pytest path="tests/.py"}`` runs it as a + test module (``tests/test_docs_testing_fixtures.py``). ``lint=check`` also + lints it. """ - compile( - example.source, - str(example.path), - "exec", - flags=ast.PyCF_ALLOW_TOP_LEVEL_AWAIT, - ) - mode = page_fence_mode(example) - if fence_settings(example).get("lint") == "check": - eval_example.set_config( - # Docs snippets often intentionally use print statements or partial examples. - ruff_ignore=["D", "T", "B", "C4", "E711", "E712", "E721"], - line_length=100, - target_version="py313", - ) - eval_example.lint(example) - if mode == "run": - run_fence(example) + check_fence(example, eval_example=eval_example) @pytest.mark.parametrize( @@ -72,16 +54,7 @@ def test_docs_example_scripts(script: Path) -> None: examples build their project in a temporary directory and drive ``ferro migrate`` against SQLite there. """ - result = subprocess.run( - [sys.executable, str(script)], - capture_output=True, - text=True, - timeout=120, - cwd=REPO_ROOT, - ) - assert result.returncode == 0, ( - f"{script.name} failed\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}" - ) + run_python([str(script)]) @pytest.mark.postgres_only @@ -100,22 +73,13 @@ def test_row_level_security_example_runs_on_postgres( another tenant is refused. Without arguments the script runs on SQLite (``test_docs_example_scripts``), where no policy exists to check. """ - result = subprocess.run( - [sys.executable, str(DOCS_EXAMPLES / script), db_url, pg_role("app")], - capture_output=True, - text=True, - timeout=120, - cwd=REPO_ROOT, - ) - assert result.returncode == 0, ( - f"{script} failed\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}" - ) + run_python([str(DOCS_EXAMPLES / script), db_url, pg_role("app")]) 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)) + [example] = fenced_examples(page, page.read_text(encoding="utf-8")) return example @@ -208,40 +172,31 @@ def test_unmarked_page_fence_must_say_how_it_is_tested() -> None: with pytest.raises( ValueError, match=r'says neither test=run.*test=compile reason="' ): - page_fence_mode(_fence("python")) + fence_mode(_fence("python")) def test_compile_only_page_fence_states_its_reason() -> None: assert ( - page_fence_mode(_fence('python {test=compile reason="needs Postgres"}')) - == "compile" + fence_mode(_fence('python {test=compile reason="needs Postgres"}')) == "compile" ) README = REPO_ROOT / "README.md" -_README_FENCE = re.compile(r"^```python\n(.*?)^```", flags=re.M | re.S) _MODELS_SECTION = re.compile( r"# --8<-- \[start:models\]\n(.*?)# --8<-- \[end:models\]", flags=re.S ) def _readme_quick_start() -> str: - [source] = _README_FENCE.findall(README.read_text(encoding="utf-8")) - return source + [fence] = fenced_blocks(README, README.read_text(encoding="utf-8"), "python") + return fence.body def test_readme_quick_start_runs_and_prints_what_it_claims(tmp_path: Path) -> None: """The README's Python block runs as written and prints its stated output.""" script = tmp_path / "readme_quick_start.py" script.write_text(_readme_quick_start(), encoding="utf-8") - result = subprocess.run( - [sys.executable, str(script)], - capture_output=True, - text=True, - timeout=120, - cwd=tmp_path, - ) - assert result.returncode == 0, result.stderr + result = run_python([str(script)], cwd=tmp_path) assert "Found 1 active users." in README.read_text(encoding="utf-8") assert result.stdout.strip() == "Found 1 active users." diff --git a/tests/test_docs_harness.py b/tests/test_docs_harness.py new file mode 100644 index 00000000..69d7337e --- /dev/null +++ b/tests/test_docs_harness.py @@ -0,0 +1,99 @@ +"""The docs harness's shared pieces: the one fence reader and the one runner.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +from tests._docs_harness import Fence, fenced_blocks, fenced_examples, run_python + +PAGE = """\ +# Page + +```python {test=run prelude=reference} +print(1) +``` + +!!! note + + + ```text + Rent -1450 + ``` + +````markdown +```python +not a fence of its own +``` +```` + +```sql +-- predicates.py:first-query (sqlite) +SELECT 1; +``` +""" + + +def test_fenced_blocks_reads_every_fence_with_its_line_and_note() -> None: + blocks = fenced_blocks(Path("page.md"), PAGE) + assert [(block.language, block.line) for block in blocks] == [ + ("python", 3), + ("text", 10), + ("markdown", 14), + ("sql", 20), + ] + python, text, markdown, sql = blocks + assert python.info == "python {test=run prelude=reference}" + assert python.body == "print(1)\n" + assert text.body == "Rent -1450\n" + assert text.indent == " " + assert text.note == ("prints", "predicates.py:first-query") + assert python.note is None + assert markdown.body == "```python\nnot a fence of its own\n```\n" + assert sql.where == "page.md:20" + + +def test_fenced_blocks_filters_by_language() -> None: + [sql] = fenced_blocks(Path("page.md"), PAGE, language="sql") + assert sql == Fence( + path=Path("page.md"), + line=20, + language="sql", + info="sql", + body="-- predicates.py:first-query (sqlite)\nSELECT 1;\n", + indent="", + note=None, + ) + + +def test_an_unclosed_fence_is_refused_naming_its_line() -> None: + with pytest.raises( + ValueError, match=r"page\.md:3: the ```python fence is never closed" + ): + fenced_blocks(Path("page.md"), "# Page\n\n```python\nprint(1)\n") + + +def test_fenced_examples_are_the_python_fences() -> None: + [example] = fenced_examples(Path("page.md"), PAGE) + assert (example.start_line, example.prefix, example.source) == ( + 3, + "python {test=run prelude=reference}", + "print(1)\n", + ) + + +def test_run_python_returns_what_the_script_printed() -> None: + result = run_python( + ["-c", "import os; print(os.environ['DOCS_X'])"], env={"DOCS_X": "1"} + ) + assert result.stdout == "1\n" + + +def test_run_python_raises_with_both_streams() -> None: + with pytest.raises(AssertionError) as failure: + run_python(["-c", "print('out'); import sys; sys.exit('err')"]) + message = str(failure.value) + assert message.startswith(f"{sys.executable} -c ") + assert "exited 1\nstdout:\nout\n\nstderr:\nerr\n" in message diff --git a/tests/test_docs_migrations.py b/tests/test_docs_migrations.py index 922d1c54..70206035 100644 --- a/tests/test_docs_migrations.py +++ b/tests/test_docs_migrations.py @@ -41,6 +41,7 @@ import pytest import ferro.migrations +from tests._docs_harness import fenced_blocks, run_python REPO_ROOT = Path(__file__).resolve().parents[1] DOCS_PAGES = REPO_ROOT / "docs" / "pages" @@ -71,16 +72,7 @@ def _files(root: Path) -> dict[str, bytes]: def _run_example(name: str, project: Path) -> None: - result = subprocess.run( - [sys.executable, str(DOCS_EXAMPLES / f"{name}.py"), str(project)], - capture_output=True, - text=True, - timeout=180, - cwd=REPO_ROOT, - ) - assert result.returncode == 0, ( - f"{name} failed\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}" - ) + run_python([str(DOCS_EXAMPLES / f"{name}.py"), str(project)]) @pytest.mark.parametrize("name", MIGRATION_EXAMPLES) @@ -1010,18 +1002,11 @@ def _quoted_blocks(page: Path) -> list[tuple[str | None, str | None, str, str]]: """Every ```text block of ``page`` as (kind, id, first line, body), the kind and id from the ```` marker above it, if any.""" blocks = [] - pattern = re.compile( - r"^(?P[ \t]*)(?:\n" - r"(?P=indent))?```text\n(?P.*?)^(?P=indent)```", - flags=re.DOTALL | re.MULTILINE, - ) - for match in pattern.finditer(page.read_text()): - indent = match["indent"] - body = "".join( - line.removeprefix(indent) - for line in match["body"].splitlines(keepends=True) - ) - blocks.append((match["kind"], match["id"], body.split("\n", 1)[0], body)) + for fence in fenced_blocks(page, page.read_text(), "text"): + kind, block_id = fence.note or (None, None) + if kind not in ("output", "refusal"): + kind = block_id = None + blocks.append((kind, block_id, fence.body.split("\n", 1)[0], fence.body)) return blocks @@ -1238,26 +1223,6 @@ def _invoice_rls(project: Path) -> None: data step the page shows already written by hand, which nothing generates.""" -def _fenced_blocks(page: Path) -> list[tuple[int, str]]: - """Every fenced block of ``page`` as (line number of its opening fence, body), - the body dedented by the fence's own indentation.""" - pattern = re.compile( - r"^(?P[ \t]*)```[^\n]*\n(?P.*?)^(?P=indent)```", - flags=re.DOTALL | re.MULTILINE, - ) - text = page.read_text() - return [ - ( - text.count("\n", 0, match.start()) + 1, - "".join( - line.removeprefix(match["indent"]) - for line in match["body"].splitlines(keepends=True) - ), - ) - for match in pattern.finditer(text) - ] - - def _step_files_in(body: str) -> list[tuple[str, str, str]]: """The step files one block quotes, as (migration, file, text): the text runs from under its header line to the next header or the block's end, blank lines @@ -1288,7 +1253,9 @@ def _quoted_step_files() -> list[tuple[str, int, str, str, str]]: return [ (page.relative_to(DOCS_PAGES).as_posix(), line, migration, file, text) for page in sorted(DOCS_PAGES.rglob("*.md")) - for line, body in _fenced_blocks(page) + for line, body in ( + (f.line, f.body) for f in fenced_blocks(page, page.read_text()) + ) for migration, file, text in _step_files_in(body) ] @@ -1450,15 +1417,12 @@ def test_a_quoted_revision_is_what_autogenerate_writes( printed = "" for models, step in ((before, "create"), (after, "render")): _write_models(project, models) - result = subprocess.run( - [sys.executable, "autogenerate.py", step], - capture_output=True, - text=True, + result = run_python( + ["autogenerate.py", step], timeout=120, cwd=project, - env={**os.environ, "PYTHONPATH": str(project)}, + env={"PYTHONPATH": str(project)}, ) - assert result.returncode == 0, result.stderr printed = result.stdout assert printed == _quoted_revisions().get(revision), ( diff --git a/tests/test_docs_testing_fixtures.py b/tests/test_docs_testing_fixtures.py index 4576b2ec..ce96066c 100644 --- a/tests/test_docs_testing_fixtures.py +++ b/tests/test_docs_testing_fixtures.py @@ -15,21 +15,22 @@ from __future__ import annotations -import os import re -import subprocess -import sys from pathlib import Path import pytest -from tests._docs_harness import fence_settings, fenced_examples +from tests._docs_harness import ( + fence_settings, + fenced_blocks, + fenced_examples, + run_python, +) REPO_ROOT = Path(__file__).resolve().parents[1] PAGE = REPO_ROOT / "docs" / "pages" / "howto" / "testing.md" EXAMPLES = REPO_ROOT / "docs" / "examples" -_TOML_FENCE = re.compile(r"^```toml\n(.*?)^```", flags=re.M | re.S) _PYTEST_OUTPUT = re.compile( r"^```text\n\$ pytest -q (?P\S+)\n.*?^(?P\d+) passed", flags=re.M | re.S, @@ -50,9 +51,9 @@ def _page_test_modules() -> dict[str, str]: def _write_project(root: Path, extra: dict[str, str] | None = None) -> None: text = PAGE.read_text(encoding="utf-8") [pyproject] = [ - block - for block in _TOML_FENCE.findall(text) - if "[tool.pytest.ini_options]" in block + fence.body + for fence in fenced_blocks(PAGE, text, "toml") + if "[tool.pytest.ini_options]" in fence.body ] (root / "pyproject.toml").write_text(pyproject, encoding="utf-8") (root / "myapp").mkdir() @@ -71,17 +72,11 @@ def _write_project(root: Path, extra: dict[str, str] | None = None) -> None: def _pytest(root: Path, *args: str, env: dict[str, str] | None = None) -> str: - result = subprocess.run( - [sys.executable, "-m", "pytest", "-q", "-p", "no:cacheprovider", *args], - capture_output=True, - text=True, + result = run_python( + ["-m", "pytest", "-q", "-p", "no:cacheprovider", *args], timeout=120, cwd=root, - env={**os.environ, **(env or {})}, - ) - assert result.returncode == 0, ( - f"pytest failed in the testing how-to's project\n" - f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + env=env, ) return result.stdout diff --git a/tests/test_docstring_examples.py b/tests/test_docstring_examples.py index 7781dc41..76271932 100644 --- a/tests/test_docstring_examples.py +++ b/tests/test_docstring_examples.py @@ -1,21 +1,29 @@ -"""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. +"""Docstring examples in ``src/ferro``: every one is marked, compiles, and runs +when marked to. + +A fenced Python example in a docstring holds to the contract a docs page fence +does (``tests._docs_harness.check_fence``): it says how it is tested, compiles, +and one marked ``{test=run prelude=}`` runs in a subprocess after +``docs/examples/_preludes/.py``, inside its session. An unmarked fence is +refused. The entry points the reference leads with each keep an example that +runs (``RUNNING_ENTRY_POINTS``). """ from __future__ import annotations -import ast import textwrap from pathlib import Path +import griffe import pytest from pytest_examples import CodeExample -from tests._docs_harness import docstring_examples, fence_settings, run_fence +from tests._docs_harness import ( + check_fence, + docstring_examples, + fence_settings, + run_fence, +) REPO_ROOT = Path(__file__).resolve().parents[1] @@ -24,14 +32,114 @@ "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, + check_fence(example) + + +RUNNING_ENTRY_POINTS = [ + "models.Model.create", + "models.Model.get", + "models.Model.where", + "models.Model.save", + "models.Model.delete", + "query.builder.Query.after", + "query.builder.Query.before", + "query.builder.Query.position_of", + "migrate", +] +"""The entry points whose docstrings must each keep an ``Examples:`` fence +marked ``{test=run prelude=}``, as paths inside the ``ferro`` package.""" + + +def entry_points_without_a_running_example( + package: str, search_path: Path, paths: list[str] +) -> list[str]: + """Name each of ``paths`` whose docstring holds no fence marked ``test=run``. + + Args: + package: The package to load, such as ``ferro``. + search_path: The directory that holds the package. + paths: Objects inside the package, such as ``models.Model.create``. + + Returns: + The paths with no running example, or with no docstring at all. + """ + root = griffe.load(package, search_paths=[str(search_path)], allow_inspection=False) + running: dict[Path, list[int]] = {} + for example in docstring_examples(package, search_path): + if fence_settings(example).get("test") == "run": + running.setdefault(Path(example.path), []).append(example.start_line) + missing = [] + for path in paths: + obj = root[path] + docstring = obj.docstring + lines = running.get(Path(str(obj.filepath)), []) + if docstring is None or not any( + docstring.lineno <= line <= (docstring.endlineno or 0) for line in lines + ): + missing.append(path) + return missing + + +def test_each_entry_point_keeps_a_running_example() -> None: + assert ( + entry_points_without_a_running_example( + "ferro", REPO_ROOT / "src", RUNNING_ENTRY_POINTS + ) + == [] ) - if fence_settings(example).get("test") == "run": - run_fence(example) + + +def test_an_entry_point_whose_example_only_compiles_is_named(tmp_path: Path) -> None: + _scratch_package( + tmp_path, + ''' + def first_author(): + """Return the first author. + + Examples: + ```python {test=compile reason="shows a call"} + first_author() + ``` + """ + + + def last_author(): + """Return the last author. + + Examples: + ```python {test=run prelude=reference} + print(1) + ``` + """ + + + def any_author(): + """Return an author.""" + ''', + ) + assert entry_points_without_a_running_example( + "scratch", + tmp_path, + ["library.first_author", "library.last_author", "library.any_author"], + ) == ["library.first_author", "library.any_author"] + + +def test_an_unmarked_docstring_fence_is_refused(tmp_path: Path) -> None: + _scratch_package( + tmp_path, + ''' + def first_author(): + """Return the first author. + + ```python + first_author() + ``` + """ + ''', + ) + [example] = docstring_examples("scratch", tmp_path) + with pytest.raises(ValueError, match=r"says neither test=run nor test=compile"): + check_fence(example) def _scratch_package(tmp_path: Path, body: str) -> Path: From c021e80d73b907e54b023b030d9bcf08f8e830d2 Mon Sep 17 00:00:00 2001 From: Taylor Date: Wed, 21 Oct 2026 14:34:33 -0400 Subject: [PATCH 02/13] test(docs): move the SQL capture into tests/_docs_sql.py and record printed output captured(example, dialect, url) runs an example once and reports, per snippet region, the statements it sent and the lines it printed, plus its stdout and the models' SchemaIR; check_block and check_same_on_postgres compare a page's block with it. test_docs_sql.py is now the gate over PAGES, and its capture tests assert through by_region() and captured() on a scratch example. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP --- tests/_docs_sql.py | 589 ++++++++++++++++++++++++++++++++++++ tests/test_docs_sql.py | 663 +++++++++++------------------------------ 2 files changed, 756 insertions(+), 496 deletions(-) create mode 100644 tests/_docs_sql.py diff --git a/tests/_docs_sql.py b/tests/_docs_sql.py new file mode 100644 index 00000000..b2e21efd --- /dev/null +++ b/tests/_docs_sql.py @@ -0,0 +1,589 @@ +"""What a docs example sends and prints, region by region, and the blocks that show it. + +A page shows two things an example script does: the SQL a snippet region +sends (a ```sql block opening with ``-- .py: ()``) +and what it prints (a ```text block under ````). +Both are regenerated, never typed: ``captured`` runs the script once per +dialect in a subprocess, records each statement and each printed line at the +example line that caused it, and groups them by snippet region; ``check_block`` +and ``check_printed`` compare a page's block with that record byte for byte. + +A capture runs ``python -m tests._docs_sql [url]``: +the script runs as ``__main__`` with ``ferro.connect`` pointed at ``url`` when +one is given, and the record is written to ``out.json``. + +What a block shows: + +- A query terminal's statement as its ``.sql()`` renders it, and any other + statement as the statement log records it. Schema statements, the Postgres + catalog probe, session settings and a ``transaction()`` block's own + ``BEGIN`` / ``COMMIT`` are left out (``HIDDEN_OPERATIONS``). A region that + sends nothing but prints statements (a ``create_tables()`` report) shows + what it prints. +- Each statement laid out one clause per line (``layout``), ending in ``;``, + with a blank line between two. +- A printed line belongs to the innermost line of the example inside a region + on the call stack, so a helper defined outside the region that prints for it + counts for the region. +""" + +from __future__ import annotations + +import io +import json +import logging +import re +import sys +import tempfile +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from tests._docs_harness import DOCS_PAGES, REPO_ROOT, Fence, run_python + +DOCS_EXAMPLES = REPO_ROOT / "docs" / "examples" + +MARKER = re.compile( + r"^-- (?P[A-Za-z0-9_]+\.py):(?P[A-Za-z0-9_-]+) " + r"\((?Psqlite|postgres)\)$" +) +"""The first line of a ```sql block: ``-- .py: ()``.""" + +PRINTS = re.compile(r"^(?P[A-Za-z0-9_]+\.py):(?P[A-Za-z0-9_-]+)$") +"""The value of a ```text block's ```` note.""" + +SNIPPET = re.compile(r"#\s*--8<--\s*\[(?Pstart|end):(?P[A-Za-z0-9_-]+)\]") +"""A snippet region's start or end line in an example script.""" + +HIDDEN_OPERATIONS = frozenset({"migrate", "catalog", "settings", "transaction"}) +"""Statement-log operations a block does not show: schema statements, the +Postgres catalog probe, session settings and a transaction block's own +statements.""" + +CLAUSES = ( + "FROM", + "INNER JOIN", + "LEFT JOIN", + "WHERE", + "GROUP BY", + "HAVING", + "ORDER BY", + "LIMIT", + "OFFSET", + "SET", + "VALUES", + "ON CONFLICT", + "RETURNING", +) +"""The keywords that start a new line of a laid-out statement, at the top +level of the statement only (never inside parentheses or quotes).""" + +PLACEHOLDER = re.compile(r"\$\d+|\?") +"""A bind placeholder: SQLite's ``?`` or PostgreSQL's ``$1``.""" + + +# -- the layout ------------------------------------------------------------------ + + +def layout(sql: str) -> str: + """Lay ``sql`` out one clause per line. + + A line break replaces the single space before each top-level clause + keyword (``CLAUSES``). A ``CREATE TABLE`` statement also puts each column + and constraint of its parenthesised body on its own line, indented two + spaces. Removing each line's indentation and joining the lines with + single spaces gives back ``sql``. + """ + table = sql.startswith("CREATE TABLE") + breaks: dict[int, str] = {} + depth = 0 + quote: str | None = None + for index, char in enumerate(sql): + if quote is not None: + if char == quote: + quote = None + continue + if char in "\"'": + quote = char + elif char == "(": + depth += 1 + elif char == ")": + depth -= 1 + elif char != " ": + continue + elif depth == 0: + rest = sql[index + 1 :] + if any( + rest.startswith(keyword) + and (len(rest) == len(keyword) or rest[len(keyword)] in " (") + for keyword in CLAUSES + ): + breaks[index] = "\n" + elif table and depth == 1: + # At depth 1 the body's own "(" or a "," between its items comes + # before the space; its closing ")" comes after it. + if sql[index - 1] in "(,": + breaks[index] = "\n " + elif sql[index + 1 : index + 2] == ")": + breaks[index] = "\n" + return "".join(breaks.get(index, char) for index, char in enumerate(sql)) + + +def unlaid(laid: str) -> str: + """Return the statement ``layout`` laid out: its lines unindented and joined.""" + return " ".join(line.lstrip(" ") for line in laid.splitlines()) + + +def statements(sqls: list[str]) -> str: + """Return the body a ```sql block holds for ``sqls``. + + Each statement is laid out and ends in ``;``, with a blank line between two. + """ + return "\n".join(f"{layout(sql)};\n" for sql in sqls) + + +def snippet_ranges(source: str) -> dict[str, tuple[int, int]]: + """Return each snippet region of an example's ``source`` as its (start, end) lines.""" + starts: dict[str, int] = {} + ranges: dict[str, tuple[int, int]] = {} + for number, line in enumerate(source.splitlines(), start=1): + match = SNIPPET.search(line) + if match is None: + continue + if match["edge"] == "start": + starts[match["name"]] = number + elif match["name"] in starts: + ranges[match["name"]] = (starts.pop(match["name"]), number) + return ranges + + +# -- what a run records ---------------------------------------------------------- + + +@dataclass +class Region: + """What one snippet region of an example did during a run.""" + + sent: list[str] = field(default_factory=list) + """The statements it sent, in order.""" + printed: list[str] = field(default_factory=list) + """The lines it printed, in order, without their line ends.""" + + @property + def shown_sql(self) -> list[str]: + """The statements a ```sql block shows: what it sent, or, for a region + that sends nothing, the statements it prints.""" + return self.sent or self.printed + + @property + def shown_output(self) -> str: + """The text a ```text block under ```` shows.""" + return "".join(f"{line}\n" for line in self.printed) + + +@dataclass +class Run: + """Everything one run of an example script did.""" + + regions: dict[str, Region] + """Each snippet region's statements and printed lines.""" + stdout: str + """Everything the script printed.""" + schemas: list[Any] + """The registered models' SchemaIR each time the script connected or + created tables, and once at its end, consecutive repeats dropped.""" + + +class Capture: + """What one example run sends and prints, attributed to the example lines.""" + + def __init__(self, path: Path, dialect: str) -> None: + self.path = str(path) + self.dialect = dialect + self.root = str(path.parent) + self.ranges = snippet_ranges(path.read_text(encoding="utf-8")) + self.where: tuple[str, int] | None = None + """The docs example file and line running now.""" + self.depth = 0 + """How many terminals are running; their own statements are rendered.""" + self.sent: list[tuple[int, str]] = [] + self.printed: list[tuple[int, str]] = [] + self.stdout = "" + self.pending = "" + self.schemas: list[Any] = [] + + def on_line(self, code: Any, line: int) -> Any: + """Note the example line running now (a ``sys.monitoring`` LINE callback).""" + if code.co_filename.startswith(self.root): + self.where = (code.co_filename, line) + return None + return sys.monitoring.DISABLE + + def record(self, sql: str) -> None: + if self.where is not None and self.where[0] == self.path: + self.sent.append((self.where[1], sql)) + + @property + def line(self) -> str: + """The example line running now, as ``:``.""" + if self.where is None: + return f"{Path(self.path).name}:?" + return f"{Path(self.where[0]).name}:{self.where[1]}" + + async def run_terminal( + self, render: Callable[[], Awaitable[Any]], call: Awaitable[Any] + ) -> Any: + """Record what a terminal sends, rendered by its ``.sql()``, then run it. + + A terminal another terminal runs is not rendered again: the outer + rendering already holds its statement. Its own statements reach the + statement log while ``depth`` is up, and are not recorded twice. + + When ``.sql()`` raises, the call must raise the same exception type, + having sent nothing; a call that then succeeds, or fails otherwise, + would send a statement no block could show, so it fails the capture + naming the example line. + """ + if self.depth: + return await call + self.depth += 1 + try: + line = self.line + try: + rendered = await render() + except Exception as refused: + try: + result = await call + except type(refused): + raise + except Exception as other: + raise AssertionError( + f"{line}: .sql() raised {refused!r} but the call raised " + f"{other!r}; a terminal must refuse the same way in both" + ) from other + raise AssertionError( + f"{line}: .sql() raised {refused!r} but the call succeeded " + f"(returned {result!r}), so what it sent cannot be shown" + ) from refused + assert rendered.dialect == self.dialect, ( + f"{line}: rendered for {rendered.dialect}, ran on {self.dialect}" + ) + self.record(str(rendered)) + return await call + finally: + self.depth -= 1 + + def _printing_line(self) -> int | None: + """The example line a print belongs to: the innermost one on the stack + inside a region, else the line running now.""" + frame = sys._getframe(1) + while frame is not None: + if frame.f_code.co_filename == self.path and any( + start < frame.f_lineno < end for start, end in self.ranges.values() + ): + return frame.f_lineno + frame = frame.f_back + if self.where is not None and self.where[0] == self.path: + return self.where[1] + return None + + def write(self, text: str) -> None: + """Record each line the example prints, at the example line printing it.""" + self.stdout += text + self.pending += text + *lines, self.pending = self.pending.split("\n") + if lines: + line = self._printing_line() + if line is not None: + self.printed.extend((line, printed) for printed in lines) + + def on_statement(self, record: logging.LogRecord) -> None: + operation = getattr(record, "operation", None) + if self.depth == 0 and operation not in HIDDEN_OPERATIONS: + assert record.dialect == self.dialect, record.dialect # type: ignore[attr-defined] + self.record(record.sql) # type: ignore[attr-defined] + + def snapshot(self) -> None: + """Record the registered models' SchemaIR, unless it is the last one.""" + from ferro.ir.compiler import compile_registry_schema_ir + + schema = json.loads(json.dumps(compile_registry_schema_ir(), default=str)) + if not self.schemas or self.schemas[-1] != schema: + self.schemas.append(schema) + + def by_region(self) -> dict[str, Region]: + """Return what each snippet region sent and printed.""" + return { + name: Region( + sent=[sql for line, sql in self.sent if start < line < end], + printed=[text for line, text in self.printed if start < line < end], + ) + for name, (start, end) in self.ranges.items() + } + + def run(self) -> Run: + """Return the record of the run so far.""" + return Run(self.by_region(), self.stdout, self.schemas) + + +def _run_capture(path: Path, dialect: str, url: str | None) -> Run: + """Run the example at ``path`` here, as ``__main__``, and return its record.""" + import runpy + + import ferro + from ferro.query.rendered import Terminal + + capture = Capture(path, dialect) + + class _Handler(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + capture.on_statement(record) + + logger = logging.getLogger("ferro.sql") + logger.addHandler(_Handler()) + logger.setLevel(logging.DEBUG) + + call = Terminal.__call__ + + def render_then_call(self: Terminal, *args: Any, **kwargs: Any) -> Any: + # Called first, so a terminal that refuses its query before returning + # an awaitable raises here, where the example expects it. + awaitable = call(self, *args, **kwargs) + + return capture.run_terminal(lambda: self.sql(*args, **kwargs), awaitable) + + Terminal.__call__ = render_then_call # type: ignore[method-assign] + + connect, create_tables = ferro.connect, ferro.create_tables + + async def connect_to(_url: str, *args: Any, **kwargs: Any) -> Any: + capture.snapshot() + return await connect(url or _url, *args, **kwargs) + + async def create_then_snapshot(*args: Any, **kwargs: Any) -> Any: + capture.snapshot() + return await create_tables(*args, **kwargs) + + ferro.connect = connect_to # type: ignore[assignment] + ferro.create_tables = create_then_snapshot # type: ignore[assignment] + + tool = 4 + sys.monitoring.use_tool_id(tool, "docs-capture") + sys.monitoring.register_callback(tool, sys.monitoring.events.LINE, capture.on_line) + sys.monitoring.set_events(tool, sys.monitoring.events.LINE) + sys.path.insert(0, str(path.parent)) + sys.argv = [str(path)] + stdout = sys.stdout + + class _Tee(io.TextIOBase): + def write(self, text: str) -> int: + capture.write(text) + return stdout.write(text) + + sys.stdout = _Tee() + try: + runpy.run_path(str(path), run_name="__main__") + finally: + sys.stdout = stdout + sys.monitoring.set_events(tool, 0) + sys.monitoring.free_tool_id(tool) + capture.snapshot() + return capture.run() + + +_captured: dict[tuple[str, str, str | None], Run] = {} +"""Each example's run per dialect and database, made once per test process.""" + + +def captured( + example: str | Path, dialect: str = "sqlite", url: str | None = None +) -> Run: + """Run an example script once in a subprocess and return what it did. + + Args: + example: A script name in ``docs/examples`` (``predicates.py``), or a + path to a script elsewhere. + dialect: The dialect the script runs on, ``sqlite`` or ``postgres``. + url: The database ``ferro.connect`` is pointed at. Defaults to the URL + the script passes. + + Returns: + The run's record, cached for the rest of the test process. + + Raises: + AssertionError: The script failed; the message carries its output. + """ + path = example if isinstance(example, Path) else DOCS_EXAMPLES / example + key = (str(path), dialect, url) + if key not in _captured: + with tempfile.TemporaryDirectory() as scratch: + out = Path(scratch) / "run.json" + command = ["-m", "tests._docs_sql", str(path), dialect, str(out)] + if url is not None: + command.append(url) + run_python(command) + record = json.loads(out.read_text(encoding="utf-8")) + _captured[key] = Run( + regions={ + name: Region(**region) for name, region in record["regions"].items() + }, + stdout=record["stdout"], + schemas=record["schemas"], + ) + return _captured[key] + + +# -- the pages' blocks --------------------------------------------------------------- + + +def page_name(fence: Fence) -> str: + """The block's page relative to ``docs/pages``, as ``:``.""" + try: + page = Path(fence.path).relative_to(DOCS_PAGES).as_posix() + except ValueError: + page = str(fence.path) + return f"{page}:{fence.line}" + + +def sql_marker(block: Fence) -> re.Match[str] | None: + """The ``-- .py: ()`` first line of a ```sql block.""" + return MARKER.match(block.body.partition("\n")[0]) + + +def _named_region(where: str, example: str, snippet: str, named: str) -> None: + path = DOCS_EXAMPLES / example + assert path.is_file(), f"{named}: docs/examples/{example} does not exist" + ranges = snippet_ranges(path.read_text(encoding="utf-8")) + assert snippet in ranges, f"{named}: {example} has no snippet region {snippet!r}" + + +def check_block(block: Fence, run: Callable[[str, str], Run] = captured) -> None: + """Assert a ```sql block is marked and holds what its example's region sends. + + Args: + block: The page's block. + run: Given an example script's name and a dialect, its run's record. + Defaults to ``captured`` on the dialect's own database. + + Raises: + AssertionError: The block has no marker, names an example or region + that does not exist, or differs from what the region sends. The + message names the page, the line and the example. + """ + where = page_name(block) + marker = sql_marker(block) + assert marker is not None, ( + f"{where}: a ```sql block must open with a marker line " + f"'-- .py: ()' naming where its SQL comes " + f"from; this one opens with {block.body.partition(chr(10))[0]!r}" + ) + example, snippet, dialect = marker["example"], marker["snippet"], marker["dialect"] + named = f"{where} ({example}:{snippet}, {dialect})" + _named_region(where, example, snippet, named) + region = run(example, dialect).regions.get(snippet, Region()) + sent = region.shown_sql + assert sent, f"{named}: the region {snippet!r} of {example} sends nothing" + expected = statements(sent) + shown = block.body.partition("\n")[2] + assert shown == expected, ( + f"{named}: the block differs from what {example}:{snippet} sends on " + f"{dialect}. It sends:\n{expected}" + ) + + +def check_same_on_postgres(block: Fence, run: Callable[[str, str], Run]) -> None: + """Assert a SQLite ```sql block stands for PostgreSQL too. + + The region must send the same statements there once each placeholder is + read as ``?``. A region whose SQL differs in more than its placeholders + shows a ``(postgres)`` block of its own, and this check does not apply to it. + + Raises: + AssertionError: PostgreSQL's statements differ in more than their + placeholders. The message names the page, the line and the example. + """ + where = page_name(block) + marker = sql_marker(block) + assert marker is not None, f"{where}: a ```sql block has no marker line" + example, snippet = marker["example"], marker["snippet"] + sent = run(example, "postgres").regions.get(snippet, Region()).shown_sql + expected = PLACEHOLDER.sub("?", statements(sent)) + shown = block.body.partition("\n")[2] + assert PLACEHOLDER.sub("?", shown) == expected, ( + f"{where} ({example}:{snippet}, sqlite): the block stands for both " + f"databases, but on postgres the region sends more than other " + f"placeholders; show a (postgres) block beside it. It sends:\n" + f"{statements(sent)}" + ) + + +NOT_PRINTED = "shows" +"""The note kind of a ```text block that is not what a script prints (a shell +transcript, a traceback, a file tree), which says what it shows instead: +````.""" + +TEXT_NOTES = { + "prints": "what an example region prints, regenerated by tests/test_docs_output.py", + "output": "what ferro migrate prints, regenerated by tests/test_docs_migrations.py", + "refusal": "a refusal ferro prints, regenerated by tests/test_docs_migrations.py", + NOT_PRINTED: "not a script's output; the note says what it shows", +} +"""Every note a ```text block can carry, and what checks it.""" + + +def check_printed(block: Fence, run: Callable[[str, str], Run] = captured) -> None: + """Assert a ```text block says where it comes from, and holds what it says. + + A block under ```` must equal what + that region prints on SQLite, byte for byte. A block under another note + (``TEXT_NOTES``) is checked elsewhere, or says what it shows instead. + + Raises: + AssertionError: The block has no note, an unknown one, names an + example or region that does not exist, or differs from what the + region prints. The message names the page and the line. + """ + where = page_name(block) + notes = ", ".join(f"" for kind in TEXT_NOTES) + assert block.note is not None, ( + f"{where}: a ```text block must sit under a note saying where it comes " + f"from: for what an example " + f"prints, or for text no script prints " + f"(one of {notes})" + ) + kind, value = block.note + assert kind in TEXT_NOTES, f"{where}: unknown note {kind!r}; use one of {notes}" + if kind != "prints": + return + marker = PRINTS.match(value) + assert marker is not None, ( + f"{where}: must name .py:" + ) + example, snippet = marker["example"], marker["snippet"] + named = f"{where} ({example}:{snippet})" + _named_region(where, example, snippet, named) + printed = run(example, "sqlite").regions.get(snippet, Region()).shown_output + assert printed, f"{named}: the region {snippet!r} of {example} prints nothing" + assert block.body == printed, ( + f"{named}: the block differs from what {example}:{snippet} prints. " + f"It prints:\n{printed}" + ) + + +if __name__ == "__main__": + script, dialect, out, *rest = sys.argv[1:] + result = _run_capture(Path(script).resolve(), dialect, rest[0] if rest else None) + Path(out).write_text( + json.dumps( + { + "regions": { + name: {"sent": region.sent, "printed": region.printed} + for name, region in result.regions.items() + }, + "stdout": result.stdout, + "schemas": result.schemas, + } + ), + encoding="utf-8", + ) diff --git a/tests/test_docs_sql.py b/tests/test_docs_sql.py index be64caa1..88c86cb1 100644 --- a/tests/test_docs_sql.py +++ b/tests/test_docs_sql.py @@ -15,36 +15,39 @@ (``migrate``), Postgres catalog probes (``catalog``), session settings and a ``transaction()`` block's own ``BEGIN`` / ``COMMIT`` are not shown. -Each statement is laid out with one clause per line (``_layout``), ends -with ``;`` and is set off from the next by a blank line. Joining a statement's lines with single spaces gives back the -rendered SQL byte for byte, so the layout adds line breaks and nothing else. - -The test runs each named example once per dialect in a subprocess -(``python -m tests.test_docs_sql [url]``, the capture -below), records what every snippet region sent and compares each marked -block with that record. A block marked ``(postgres)`` runs under the +Each statement is laid out with one clause per line (``layout``), ends with +``;`` and is set off from the next by a blank line. Joining a statement's +lines with single spaces gives back the rendered SQL byte for byte, so the +layout adds line breaks and nothing else. + +The capture, the layout and the block checks live in ``tests/_docs_sql.py``; +this module is the gate over the pages in ``PAGES``. Each named example runs +once per dialect in a subprocess, and each marked block is compared with what +its region sent. A block marked ``(postgres)`` runs under the ``postgres_only`` marker, against the database ``--db-backends`` selects. """ from __future__ import annotations import asyncio -import io -import json -import logging -import re -import subprocess -import sys -from collections.abc import Awaitable, Callable -from dataclasses import dataclass from pathlib import Path +from types import SimpleNamespace from typing import Any import pytest -REPO_ROOT = Path(__file__).resolve().parents[1] -DOCS_PAGES = REPO_ROOT / "docs" / "pages" -DOCS_EXAMPLES = REPO_ROOT / "docs" / "examples" +from tests._docs_harness import DOCS_PAGES, Fence, fenced_blocks +from tests._docs_sql import ( + Capture, + Region, + Run, + captured, + check_block, + check_same_on_postgres, + layout, + sql_marker, + unlaid, +) PAGES = [ "guide/queries.md", @@ -54,203 +57,24 @@ ] """The pages whose every ```sql block is regenerated by this test.""" -MARKER = re.compile( - r"^-- (?P[A-Za-z0-9_]+\.py):(?P[A-Za-z0-9_-]+) " - r"\((?Psqlite|postgres)\)$" -) -"""The first line of a marked block: ``-- .py: ()``.""" - -SNIPPET = re.compile(r"#\s*--8<--\s*\[(?Pstart|end):(?P[A-Za-z0-9_-]+)\]") -"""A snippet region's start or end line in an example script.""" - -HIDDEN_OPERATIONS = frozenset({"migrate", "catalog", "settings", "transaction"}) -"""Statement-log operations a block does not show: schema statements, the -Postgres catalog probe, session settings and a transaction block's own -statements.""" - -CLAUSES = ( - "FROM", - "INNER JOIN", - "LEFT JOIN", - "WHERE", - "GROUP BY", - "HAVING", - "ORDER BY", - "LIMIT", - "OFFSET", - "SET", - "VALUES", - "ON CONFLICT", - "RETURNING", -) -"""The keywords that start a new line of a laid-out statement, at the top -level of the statement only (never inside parentheses or quotes).""" - - -# -- the layout ------------------------------------------------------------------ - - -def _layout(sql: str) -> str: - """``sql`` laid out one clause per line. - - A line break replaces the single space before each top-level clause - keyword (``CLAUSES``). A ``CREATE TABLE`` statement also puts each column - and constraint of its parenthesised body on its own line, indented two - spaces. Removing each line's indentation and joining the lines with - single spaces gives back ``sql``. - """ - table = sql.startswith("CREATE TABLE") - breaks: dict[int, str] = {} - depth = 0 - quote: str | None = None - for index, char in enumerate(sql): - if quote is not None: - if char == quote: - quote = None - continue - if char in "\"'": - quote = char - elif char == "(": - depth += 1 - elif char == ")": - depth -= 1 - elif char != " ": - continue - elif depth == 0: - rest = sql[index + 1 :] - if any( - rest.startswith(keyword) - and (len(rest) == len(keyword) or rest[len(keyword)] in " (") - for keyword in CLAUSES - ): - breaks[index] = "\n" - elif table and depth == 1: - # At depth 1 the body's own "(" or a "," between its items comes - # before the space; its closing ")" comes after it. - if sql[index - 1] in "(,": - breaks[index] = "\n " - elif sql[index + 1 : index + 2] == ")": - breaks[index] = "\n" - return "".join(breaks.get(index, char) for index, char in enumerate(sql)) - - -def _unlaid(laid: str) -> str: - """The statement ``_layout`` laid out: its lines unindented and joined by spaces.""" - return " ".join(line.lstrip(" ") for line in laid.splitlines()) - - -def _statements(sqls: list[str]) -> str: - """The body a marked block holds for ``sqls``: each laid out and ending in - ``;``, with a blank line between two statements.""" - return "\n".join(f"{_layout(sql)};\n" for sql in sqls) - - -# -- the pages' blocks ----------------------------------------------------------- - - -@dataclass(frozen=True) -class SqlBlock: - """One ```sql block on a page.""" - - page: str - """The page, relative to ``docs/pages``.""" - line: int - """The line of the block's opening fence.""" - body: str - """Everything between the fences, the fence's indentation removed.""" - - @property - def where(self) -> str: - return f"{self.page}:{self.line}" - - @property - def marker(self) -> re.Match[str] | None: - return MARKER.match(self.body.partition("\n")[0]) - - @property - def shown(self) -> str: - """The block's statements: its body after the marker line.""" - return self.body.partition("\n")[2] - - -FENCE = re.compile( - r"^(?P[ \t]*)```(?P[^\n]*)\n(?P.*?)^(?P=indent)```", - flags=re.DOTALL | re.MULTILINE, -) - -def _sql_blocks(page: str, text: str | None = None) -> list[SqlBlock]: +def _sql_blocks(page: str, text: str | None = None) -> list[Fence]: """Every ```sql block of ``page`` (or of ``text``, read as that page).""" + path = DOCS_PAGES / page if text is None: - text = (DOCS_PAGES / page).read_text(encoding="utf-8") - blocks = [] - for match in FENCE.finditer(text): - if match["info"].strip().split(" ")[0] != "sql": - continue - body = "".join( - line.removeprefix(match["indent"]) - for line in match["body"].splitlines(keepends=True) - ) - blocks.append(SqlBlock(page, text.count("\n", 0, match.start()) + 1, body)) - return blocks + text = path.read_text(encoding="utf-8") + return fenced_blocks(path, text, "sql") -def _all_blocks() -> list[SqlBlock]: +def _all_blocks() -> list[Fence]: return [block for page in PAGES for block in _sql_blocks(page)] -def _snippet_ranges(source: str) -> dict[str, tuple[int, int]]: - """Each snippet region of an example's ``source``, as its (start, end) lines.""" - starts: dict[str, int] = {} - ranges: dict[str, tuple[int, int]] = {} - for number, line in enumerate(source.splitlines(), start=1): - match = SNIPPET.search(line) - if match is None: - continue - if match["edge"] == "start": - starts[match["name"]] = number - elif match["name"] in starts: - ranges[match["name"]] = (starts.pop(match["name"]), number) - return ranges +def _where(block: Fence) -> str: + return f"{block.path.relative_to(DOCS_PAGES).as_posix()}:{block.line}" -def check_block( - block: SqlBlock, sent: Callable[[str, str], dict[str, list[str]]] -) -> None: - """Assert ``block`` is marked and holds what its example's region sends. - - Args: - block: The page's block. - sent: Given an example script's name and a dialect, what each of its - snippet regions sends, as ``{region: [sql, ...]}``. - - Raises: - AssertionError: The block has no marker, names an example or region - that does not exist, or differs from what the region sends. The - message names the page, the line and the example. - """ - marker = block.marker - assert marker is not None, ( - f"{block.where}: a ```sql block must open with a marker line " - f"'-- .py: ()' naming where its SQL comes " - f"from; this one opens with {block.body.partition(chr(10))[0]!r}" - ) - example, snippet, dialect = marker["example"], marker["snippet"], marker["dialect"] - named = f"{block.where} ({example}:{snippet}, {dialect})" - path = DOCS_EXAMPLES / example - assert path.is_file(), f"{named}: docs/examples/{example} does not exist" - ranges = _snippet_ranges(path.read_text(encoding="utf-8")) - assert snippet in ranges, f"{named}: {example} has no snippet region {snippet!r}" - statements = sent(example, dialect).get(snippet, []) - assert statements, f"{named}: the region {snippet!r} of {example} sends nothing" - expected = _statements(statements) - assert block.shown == expected, ( - f"{named}: the block differs from what {example}:{snippet} sends on " - f"{dialect}. It sends:\n{expected}" - ) - - -# -- the tests -------------------------------------------------------------------- +# -- the layout ------------------------------------------------------------------- def test_layout_only_adds_line_breaks() -> None: @@ -259,7 +83,7 @@ def test_layout_only_adds_line_breaks() -> None: 'WHERE EXISTS (SELECT 1 FROM "s" WHERE "s"."t_id" = "t"."id") ' 'AND "t"."memo" = \'FROM x\' ORDER BY "t"."id" ASC LIMIT ? OFFSET ?' ) - laid = _layout(sql) + laid = layout(sql) assert laid.splitlines() == [ 'SELECT "t"."id"', 'FROM "t"', @@ -270,7 +94,7 @@ def test_layout_only_adds_line_breaks() -> None: "LIMIT ?", "OFFSET ?", ] - assert _unlaid(laid) == sql + assert unlaid(laid) == sql def test_layout_puts_each_column_of_a_create_table_on_its_own_line() -> None: @@ -279,7 +103,7 @@ def test_layout_puts_each_column_of_a_create_table_on_its_own_line() -> None: 'CONSTRAINT "fk_t_a_id_a" FOREIGN KEY ("a_id") REFERENCES "a" ("id") ' "ON DELETE CASCADE )" ) - laid = _layout(sql) + laid = layout(sql) assert laid.splitlines() == [ 'CREATE TABLE IF NOT EXISTS "t" (', ' "id" integer NOT NULL,', @@ -288,21 +112,25 @@ def test_layout_puts_each_column_of_a_create_table_on_its_own_line() -> None: "ON DELETE CASCADE", ")", ] - assert _unlaid(laid) == sql + assert unlaid(laid) == sql + + +# -- the block check -------------------------------------------------------------- -SENT = { - "predicates.py": { - "first-query": ['SELECT "id" FROM "transaction" WHERE "amount" <= ?'] - } -} +def _fake(regions: dict[str, Region]) -> Any: + """A run of ``predicates.py`` whose regions did ``regions``.""" + return lambda example, dialect: Run( + regions if example == "predicates.py" else {}, "", [] + ) -def _fake_sent(example: str, dialect: str) -> dict[str, list[str]]: - return SENT.get(example, {}) +SENT = _fake( + {"first-query": Region(sent=['SELECT "id" FROM "transaction" WHERE "amount" <= ?'])} +) -def _page_with(body: str) -> SqlBlock: +def _page_with(body: str) -> Fence: [block] = _sql_blocks( "guide/queries.md", f"# Queries\n\nIt sends:\n\n```sql\n{body}```\n" ) @@ -315,7 +143,7 @@ def test_a_block_holding_what_its_region_sends_passes() -> None: "-- predicates.py:first-query (sqlite)\n" 'SELECT "id"\nFROM "transaction"\nWHERE "amount" <= ?;\n' ), - _fake_sent, + SENT, ) @@ -341,267 +169,104 @@ def test_a_block_holding_what_its_region_sends_passes() -> None: "guide/queries.md:5 (predicates.py:first-queries, sqlite): " "predicates.py has no snippet region 'first-queries'", ), - ( - "-- predicates.py:typo (sqlite)\nSELECT 1;\n", - "guide/queries.md:5 (predicates.py:typo, sqlite): " - "the region 'typo' of predicates.py sends nothing", - ), ], - ids=["edited", "unmarked", "unknown-example", "unknown-region", "sends-nothing"], + ids=["edited", "unmarked", "unknown-example", "unknown-region"], ) def test_a_block_that_is_not_what_its_region_sends_fails_naming_it( body: str, message: str ) -> None: with pytest.raises(AssertionError) as raised: - check_block(_page_with(body), _fake_sent) + check_block(_page_with(body), SENT) assert str(raised.value).startswith(message), str(raised.value) +def test_a_block_for_a_region_that_sends_nothing_fails_naming_it() -> None: + block = _page_with("-- predicates.py:first-query (sqlite)\nSELECT 1;\n") + with pytest.raises(AssertionError) as raised: + check_block(block, _fake({})) + assert str(raised.value).startswith( + "guide/queries.md:5 (predicates.py:first-query, sqlite): " + "the region 'first-query' of predicates.py sends nothing" + ) + + def test_every_page_shows_sql() -> None: """Each of the four pages shows the SQL of its queries; none is left without.""" for page in PAGES: assert _sql_blocks(page), f"{page} shows no SQL" -PLACEHOLDER = re.compile(r"\$\d+|\?") -"""A bind placeholder: SQLite's ``?`` or PostgreSQL's ``$1``.""" - - -def check_same_on_postgres( - block: SqlBlock, sent: Callable[[str, str], dict[str, list[str]]] -) -> None: - """Assert a SQLite block stands for PostgreSQL too: the region sends the - same statements there once each placeholder is read as ``?``. - - A region whose SQL differs in more than its placeholders shows a - ``(postgres)`` block of its own, and this check does not apply to it. - - Raises: - AssertionError: PostgreSQL's statements differ in more than their - placeholders. The message names the page, the line and the example. - """ - marker = block.marker - assert marker is not None, f"{block.where}: a ```sql block has no marker line" - example, snippet = marker["example"], marker["snippet"] - statements = sent(example, "postgres").get(snippet, []) - expected = PLACEHOLDER.sub("?", _statements(statements)) - assert PLACEHOLDER.sub("?", block.shown) == expected, ( - f"{block.where} ({example}:{snippet}, sqlite): the block stands for both " - f"databases, but on postgres the region sends more than other " - f"placeholders; show a (postgres) block beside it. It sends:\n" - f"{_statements(statements)}" - ) - - def test_a_block_differing_on_postgres_only_in_placeholders_stands_for_both() -> None: block = _page_with( "-- predicates.py:first-query (sqlite)\n" 'SELECT "id"\nFROM "transaction"\nWHERE "amount" <= ?;\n' ) - numbered = {"first-query": ['SELECT "id" FROM "transaction" WHERE "amount" <= $1']} - check_same_on_postgres(block, lambda example, dialect: numbered) - - cast = { - "first-query": [ - 'SELECT "id" FROM "transaction" WHERE "amount" <= CAST($1 AS numeric)' - ] - } + numbered = 'SELECT "id" FROM "transaction" WHERE "amount" <= $1' + check_same_on_postgres(block, _fake({"first-query": Region(sent=[numbered])})) + + cast = 'SELECT "id" FROM "transaction" WHERE "amount" <= CAST($1 AS numeric)' with pytest.raises(AssertionError) as raised: - check_same_on_postgres(block, lambda example, dialect: cast) + check_same_on_postgres(block, _fake({"first-query": Region(sent=[cast])})) assert str(raised.value).startswith( "guide/queries.md:5 (predicates.py:first-query, sqlite): the block stands " "for both databases" ) -def _capture(example: str, dialect: str, url: str | None) -> dict[str, list[str]]: - """What each snippet region of ``example`` sends on ``dialect``.""" - command = [sys.executable, "-m", "tests.test_docs_sql", example, dialect] - if url is not None: - command.append(url) - result = subprocess.run( - command, - capture_output=True, - text=True, - timeout=180, - cwd=REPO_ROOT, - ) - assert result.returncode == 0, ( - f"capturing {example} on {dialect} failed\n" - f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" - ) - return json.loads(result.stdout.strip().splitlines()[-1]) - - -_captured: dict[tuple[str, str], dict[str, list[str]]] = {} -"""Each example's capture per dialect, made once per test session.""" +# -- the gate --------------------------------------------------------------------- -def _sent_on(url: str | None) -> Callable[[str, str], dict[str, list[str]]]: - def sent(example: str, dialect: str) -> dict[str, list[str]]: - if (example, dialect) not in _captured: - if not (DOCS_EXAMPLES / example).is_file(): - return {} - _captured[example, dialect] = _capture(example, dialect, url) - return _captured[example, dialect] +def _on(url: str | None) -> Any: + return lambda example, dialect: captured(example, dialect, url) - return sent - -def _dialect(block: SqlBlock) -> str: +def _dialect(block: Fence) -> str: """The dialect a block is marked for; an unmarked block counts as SQLite's, so it fails there naming itself.""" - return block.marker["dialect"] if block.marker else "sqlite" - - -def _postgres_twins() -> set[tuple[str, str, str]]: - """The (page, example, region) of every block marked ``(postgres)``.""" - return { - (block.page, block.marker["example"], block.marker["snippet"]) - for block in _all_blocks() - if block.marker and block.marker["dialect"] == "postgres" - } + marker = sql_marker(block) + return marker["dialect"] if marker else "sqlite" -@pytest.mark.parametrize( - "block", - [block for block in _all_blocks() if _dialect(block) == "sqlite"], - ids=lambda block: block.where, -) -def test_a_sqlite_block_is_what_its_example_sends(block: SqlBlock) -> None: - check_block(block, _sent_on(None)) +def _key(block: Fence) -> tuple[Path, str, str] | None: + marker = sql_marker(block) + return (block.path, marker["example"], marker["snippet"]) if marker else None -def _postgres_checked() -> list[SqlBlock]: +def _postgres_checked() -> list[Fence]: """Every block but a SQLite one whose region has a ``(postgres)`` twin.""" - twins = _postgres_twins() + twins = {_key(block) for block in _all_blocks() if _dialect(block) == "postgres"} return [ block for block in _all_blocks() if not ( - block.marker - and block.marker["dialect"] == "sqlite" - and (block.page, block.marker["example"], block.marker["snippet"]) in twins + _dialect(block) == "sqlite" and sql_marker(block) and _key(block) in twins ) ] +@pytest.mark.parametrize( + "block", + [block for block in _all_blocks() if _dialect(block) == "sqlite"], + ids=_where, +) +def test_a_sqlite_block_is_what_its_example_sends(block: Fence) -> None: + check_block(block, _on(None)) + + @pytest.mark.postgres_only -@pytest.mark.parametrize("block", _postgres_checked(), ids=lambda block: block.where) +@pytest.mark.parametrize("block", _postgres_checked(), ids=_where) def test_a_block_is_what_its_example_sends_on_postgres( - block: SqlBlock, db_url: str + block: Fence, db_url: str ) -> None: """A ``(postgres)`` block is what its region sends on PostgreSQL; a SQLite block with no ``(postgres)`` twin is too, once placeholders are read alike.""" if _dialect(block) == "postgres": - check_block(block, _sent_on(db_url)) + check_block(block, _on(db_url)) return - check_same_on_postgres(block, _sent_on(db_url)) - - -# -- the capture ------------------------------------------------------------------- -# -# ``python -m tests.test_docs_sql [url]`` runs one example -# script as ``__main__`` and prints, as its last line, a JSON object mapping -# each snippet region to the statements sent while a line inside it ran. - - -class _Capture: - """What one example run sends, attributed to the example line that sent it.""" - - def __init__(self, path: Path, dialect: str) -> None: - self.path = str(path) - self.dialect = dialect - self.examples = str(DOCS_EXAMPLES) - self.where: tuple[str, int] | None = None - """The docs example file and line running now.""" - self.depth = 0 - """How many terminals are running; their own statements are rendered.""" - self.sent: list[tuple[int, str]] = [] - self.printed: list[tuple[int, str]] = [] - self.pending = "" - - def on_line(self, code: Any, line: int) -> Any: - if code.co_filename.startswith(self.examples): - self.where = (code.co_filename, line) - return None - return sys.monitoring.DISABLE - - def record(self, sql: str) -> None: - if self.where is not None and self.where[0] == self.path: - self.sent.append((self.where[1], sql)) - - @property - def line(self) -> str: - """The example line running now, as ``:``.""" - if self.where is None: - return f"{Path(self.path).name}:?" - return f"{Path(self.where[0]).name}:{self.where[1]}" - - async def run_terminal( - self, render: Callable[[], Awaitable[Any]], call: Awaitable[Any] - ) -> Any: - """Record what a terminal sends, rendered by its ``.sql()``, then run it. - - A terminal another terminal runs is not rendered again: the outer - rendering already holds its statement. Its own statements reach the - statement log while ``depth`` is up, and are not recorded twice. - - When ``.sql()`` raises, the call must raise the same exception type, - having sent nothing; a call that then succeeds, or fails otherwise, - would send a statement no block could show, so it fails the capture - naming the example line. - """ - if self.depth: - return await call - self.depth += 1 - try: - line = self.line - try: - rendered = await render() - except Exception as refused: - try: - result = await call - except type(refused): - raise - except Exception as other: - raise AssertionError( - f"{line}: .sql() raised {refused!r} but the call raised " - f"{other!r}; a terminal must refuse the same way in both" - ) from other - raise AssertionError( - f"{line}: .sql() raised {refused!r} but the call succeeded " - f"(returned {result!r}), so what it sent cannot be shown" - ) from refused - assert rendered.dialect == self.dialect, ( - f"{line}: rendered for {rendered.dialect}, ran on {self.dialect}" - ) - self.record(str(rendered)) - return await call - finally: - self.depth -= 1 - - def write(self, text: str) -> None: - """Record each line the example prints, at the example line printing it.""" - self.pending += text - *lines, self.pending = self.pending.split("\n") - if self.where is not None and self.where[0] == self.path: - self.printed.extend((self.where[1], line) for line in lines) - - def on_statement(self, record: logging.LogRecord) -> None: - operation = getattr(record, "operation", None) - if self.depth == 0 and operation not in HIDDEN_OPERATIONS: - assert record.dialect == self.dialect, record.dialect # type: ignore[attr-defined] - self.record(record.sql) # type: ignore[attr-defined] - - def by_region(self) -> dict[str, list[str]]: - ranges = _snippet_ranges(Path(self.path).read_text(encoding="utf-8")) - regions = {} - for name, (start, end) in ranges.items(): - sent = [sql for line, sql in self.sent if start < line < end] - printed = [text for line, text in self.printed if start < line < end] - regions[name] = sent or printed - return regions + check_same_on_postgres(block, _on(db_url)) + + +# -- the capture, through what it reports ----------------------------------------- class _Rendered: @@ -612,9 +277,20 @@ def __str__(self) -> str: return self.sql -def _capture_at(line: int) -> _Capture: - capture = _Capture(DOCS_EXAMPLES / "predicates.py", "sqlite") - capture.where = (capture.path, line) +SCRATCH = """\ +async def main(): + # --8<-- [start:query] + rows = await query() + # --8<-- [end:query] +""" + + +def _capture_at(tmp_path: Path, line: int) -> Capture: + """A capture of a scratch example whose line ``line`` is running now.""" + path = tmp_path / "scratch.py" + path.write_text(SCRATCH, encoding="utf-8") + capture = Capture(path, "sqlite") + capture.on_line(SimpleNamespace(co_filename=str(path)), line) return capture @@ -626,10 +302,10 @@ async def _raises(error: Exception) -> Any: raise error -def test_a_terminal_whose_sql_raises_but_whose_call_succeeds_fails_the_capture() -> ( - None -): - capture = _capture_at(42) +def test_a_terminal_whose_sql_raises_but_whose_call_succeeds_fails_the_capture( + tmp_path: Path, +) -> None: + capture = _capture_at(tmp_path, 3) with pytest.raises(AssertionError) as raised: asyncio.run( capture.run_terminal( @@ -637,14 +313,15 @@ def test_a_terminal_whose_sql_raises_but_whose_call_succeeds_fails_the_capture() ) ) assert str(raised.value).startswith( - "predicates.py:42: .sql() raised ValueError('cannot render') but the call " - "succeeded" + "scratch.py:3: .sql() raised ValueError('cannot render') but the call succeeded" ), str(raised.value) -def test_a_terminal_whose_sql_and_call_refuse_differently_fails_the_capture() -> None: - capture = _capture_at(7) - with pytest.raises(AssertionError, match=r"^predicates\.py:7: \.sql\(\) raised"): +def test_a_terminal_whose_sql_and_call_refuse_differently_fails_the_capture( + tmp_path: Path, +) -> None: + capture = _capture_at(tmp_path, 3) + with pytest.raises(AssertionError, match=r"^scratch\.py:3: \.sql\(\) raised"): asyncio.run( capture.run_terminal( lambda: _raises(ValueError("no")), _raises(TypeError("no")) @@ -652,83 +329,77 @@ def test_a_terminal_whose_sql_and_call_refuse_differently_fails_the_capture() -> ) -def test_a_terminal_that_refuses_in_both_raises_its_error_and_records_nothing() -> None: - capture = _capture_at(7) +def test_a_terminal_that_refuses_in_both_raises_its_error_and_records_nothing( + tmp_path: Path, +) -> None: + capture = _capture_at(tmp_path, 3) with pytest.raises(ValueError, match="refused"): asyncio.run( capture.run_terminal( lambda: _raises(ValueError("cannot")), _raises(ValueError("refused")) ) ) - assert capture.sent == [] and capture.depth == 0 - - -def test_a_terminal_records_its_rendering_and_returns_its_result() -> None: - capture = _capture_at(9) - result = asyncio.run( - capture.run_terminal(lambda: _returns(_Rendered("SELECT 1")), _returns(3)) - ) - assert (result, capture.sent, capture.depth) == (3, [(9, "SELECT 1")], 0) + assert capture.by_region() == {"query": Region()} -def _run_capture(example: str, dialect: str, url: str | None) -> dict[str, list[str]]: - import runpy - - import ferro - from ferro.query.rendered import Terminal +def test_a_terminal_records_its_rendering_once_and_returns_its_result( + tmp_path: Path, +) -> None: + capture = _capture_at(tmp_path, 3) - capture = _Capture(DOCS_EXAMPLES / example, dialect) + async def outer() -> Any: + # A terminal another runs is rendered by the outer one only. + inner = capture.run_terminal( + lambda: _returns(_Rendered("SELECT 2")), _returns(2) + ) + return await capture.run_terminal( + lambda: _returns(_Rendered("SELECT 1")), inner + ) - class _Handler(logging.Handler): - def emit(self, record: logging.LogRecord) -> None: - capture.on_statement(record) + assert asyncio.run(outer()) == 2 + assert capture.by_region() == {"query": Region(sent=["SELECT 1"])} - logger = logging.getLogger("ferro.sql") - logger.addHandler(_Handler()) - logger.setLevel(logging.DEBUG) - call = Terminal.__call__ +EXAMPLE = """\ +import asyncio - def render_then_call(self: Terminal, *args: Any, **kwargs: Any) -> Any: - # Called first, so a terminal that refuses its query before returning - # an awaitable raises here, where the example expects it. - awaitable = call(self, *args, **kwargs) +from ferro import Field, Model, connect, engines - return capture.run_terminal(lambda: self.sql(*args, **kwargs), awaitable) - Terminal.__call__ = render_then_call # type: ignore[method-assign] +class Note(Model): + id: int | None = Field(default=None, primary_key=True) + text: str - if url is not None: - connect = ferro.connect - async def connect_to(_url: str, *args: Any, **kwargs: Any) -> Any: - return await connect(url, *args, **kwargs) +def show(notes): + print([note.text for note in notes]) - ferro.connect = connect_to # type: ignore[assignment] - tool = 4 - sys.monitoring.use_tool_id(tool, "docs-sql") - sys.monitoring.register_callback(tool, sys.monitoring.events.LINE, capture.on_line) - sys.monitoring.set_events(tool, sys.monitoring.events.LINE) - sys.path.insert(0, str(DOCS_EXAMPLES)) - stdout = sys.stdout +async def main(): + await connect("sqlite::memory:", auto_migrate=True) + async with engines.session(): + # --8<-- [start:create] + await Note.create(text="first") + print("created") + # --8<-- [end:create] + # --8<-- [start:list] + show(await Note.where(lambda note: note.id >= 1).all()) + # --8<-- [end:list] - class _Tee(io.TextIOBase): - def write(self, text: str) -> int: - capture.write(text) - return stdout.write(text) - sys.stdout = _Tee() - try: - runpy.run_path(str(DOCS_EXAMPLES / example), run_name="__main__") - finally: - sys.stdout = stdout - sys.monitoring.set_events(tool, 0) - sys.monitoring.free_tool_id(tool) - return capture.by_region() +asyncio.run(main()) +""" -if __name__ == "__main__": - example, dialect, *rest = sys.argv[1:] - regions = _run_capture(example, dialect, rest[0] if rest else None) - print(json.dumps(regions)) +def test_a_run_reports_what_each_region_sends_and_prints(tmp_path: Path) -> None: + script = tmp_path / "notes.py" + script.write_text(EXAMPLE, encoding="utf-8") + run = captured(script) + assert run.regions["create"].printed == ["created"] + assert [sql.split(" ")[0] for sql in run.regions["create"].sent] == ["INSERT"] + # The helper prints outside any region; the line inside "list" that + # called it owns the output. + assert run.regions["list"].shown_output == "['first']\n" + assert run.regions["list"].sent[0].startswith("SELECT ") + assert run.stdout == "created\n['first']\n" + assert run.schemas, "the run records the models' schema" From 78d0293ec32222988a600a7873e65b969ecb4892 Mon Sep 17 00:00:00 2001 From: Taylor Date: Wed, 21 Oct 2026 14:40:57 -0400 Subject: [PATCH 03/13] test(docs): every text block says where it comes from; printed output is regenerated tests/test_docs_output.py refuses a ```text block with no note above it and compares each block with what that region prints (captured once per example), and each block with what the run fence above it prints, byte for byte. Text no script prints says so with . The aggregation error regions print the error they catch, so the page shows each message whole instead of an elided copy; the backends page runs the unsupported-scheme connect it quotes. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP --- docs/examples/aggregations.py | 32 +-- docs/examples/concepts.py | 15 ++ docs/pages/concepts/backends.md | 7 + docs/pages/concepts/row-level-security.md | 1 + docs/pages/concepts/type-safety.md | 1 + docs/pages/getting-started/installation.md | 1 + docs/pages/guide/aggregations.md | 14 +- docs/pages/guide/connections.md | 3 + docs/pages/guide/mutations.md | 2 + docs/pages/guide/queries.md | 2 + docs/pages/guide/raw-sql.md | 1 + docs/pages/guide/relationships.md | 2 + docs/pages/guide/schema/auto-migrate.md | 5 + docs/pages/guide/schema/data-steps.md | 1 + docs/pages/guide/schema/deploying.md | 3 + docs/pages/guide/schema/migrations.md | 1 + docs/pages/guide/schema/overview.md | 1 + .../guide/schema/renames-and-retirements.md | 1 + docs/pages/howto/multiple-databases.md | 5 + docs/pages/howto/testing.md | 1 + docs/pages/index.md | 1 + docs/pages/reference/configuration.md | 1 + docs/pages/reference/row-level-security.md | 1 + docs/pages/upgrading.md | 5 + tests/_docs_sql.py | 58 ++++- tests/_docs_twins.py | 226 ++++++++++++++++++ tests/test_docs_output.py | 135 +++++++++++ 27 files changed, 489 insertions(+), 37 deletions(-) create mode 100644 tests/_docs_twins.py create mode 100644 tests/test_docs_output.py diff --git a/docs/examples/aggregations.py b/docs/examples/aggregations.py index 8981ede0..531caafe 100644 --- a/docs/examples/aggregations.py +++ b/docs/examples/aggregations.py @@ -253,12 +253,7 @@ async def main() -> None: try: Transaction.select(lambda transaction: {"x": transaction.memo.sum()}) except TypeError as exc: - assert str(exc) == ( - "t.memo.sum() is not supported: Transaction.memo is string-typed, " - "and sum() takes a numeric column (int, float, or Decimal). No " - "portable cross-backend meaning exists for this aggregate over a " - "string column." - ) + print(f"{type(exc).__name__}: {exc}") else: raise AssertionError("expected TypeError") # --8<-- [end:error-family] @@ -268,11 +263,7 @@ async def main() -> None: try: Transaction.where(lambda transaction: transaction.amount.sum() > 100) except TypeError as exc: - assert str(exc).startswith( - "t.amount.sum() > ... is not a where() predicate: WHERE filters " - "rows before aggregation, so an aggregate cannot appear in it. " - "Post-aggregation filtering is having()" - ) + print(f"{type(exc).__name__}: {exc}") else: raise AssertionError("expected TypeError") # --8<-- [end:error-where] @@ -286,10 +277,7 @@ async def main() -> None: } ) except TypeError as exc: - assert str(exc).startswith( - "t.amount is a column reference and cannot be iterated; " - "did you mean t.amount.sum()?" - ) + print(f"{type(exc).__name__}: {exc}") else: raise AssertionError("expected TypeError") # --8<-- [end:error-builtin-sum] @@ -299,8 +287,7 @@ async def main() -> None: try: Transaction.select(lambda transaction: transaction.amount.sum()) except TypeError as exc: - assert str(exc).startswith("aggregate fields are user-named") - assert "give t.amount.sum() an output name" in str(exc) + print(f"{type(exc).__name__}: {exc}") else: raise AssertionError("expected TypeError") # --8<-- [end:error-unnamed] @@ -315,10 +302,7 @@ async def main() -> None: } ).order_by("memo") except ValueError as exc: - assert str(exc).startswith( - "order_by('memo') on an aggregate projection must name a group key " - "or an aggregate" - ) + print(f"{type(exc).__name__}: {exc}") else: raise AssertionError("expected ValueError") # --8<-- [end:error-order-key] @@ -333,11 +317,7 @@ async def main() -> None: } ).count() except ValueError as exc: - assert str(exc) == ( - "count() on an aggregate projection is ambiguous: count the matching " - "rows with an unprojected query (Model.where(...).count()), or count " - "the groups with len(await q.all())." - ) + print(f"{type(exc).__name__}: {exc}") else: raise AssertionError("expected ValueError") # --8<-- [end:error-count] diff --git a/docs/examples/concepts.py b/docs/examples/concepts.py index 4f5e4484..b8a7a982 100644 --- a/docs/examples/concepts.py +++ b/docs/examples/concepts.py @@ -126,6 +126,20 @@ async def backends(url: str = "sqlite::memory:") -> None: # --8<-- [end:backends] +async def unsupported_scheme() -> None: + reset_engine() + # --8<-- [start:unsupported-scheme] + from ferro import InterfaceError, connect + + try: + await connect("mysql://user:password@localhost:3306/app") + except InterfaceError as error: + print(f"InterfaceError: {error}") + else: + raise AssertionError("expected InterfaceError") + # --8<-- [end:unsupported-scheme] + + async def main() -> None: # --8<-- [start:connect] await connect("sqlite::memory:", auto_migrate=True) @@ -136,6 +150,7 @@ async def main() -> None: await sessionless() await without_identity_map() await backends() + await unsupported_scheme() print("concepts: ok") diff --git a/docs/pages/concepts/backends.md b/docs/pages/concepts/backends.md index 699e06c2..d3894bdb 100644 --- a/docs/pages/concepts/backends.md +++ b/docs/pages/concepts/backends.md @@ -39,6 +39,13 @@ Hosted PostgreSQL services take the same `postgresql://` URL; [Connections › S Ferro reads the backend from the URL once, in `connect()`. From then on the engine holds a SQLite pool or a PostgreSQL pool, two separate types, and no operation looks at the URL again. Any other scheme fails in `connect()`, naming the ones it accepts: +```python +--8<-- "docs/examples/concepts.py:unsupported-scheme" +``` + +It prints: + + ```text InterfaceError: DB Connection failed for mysql://***: Unsupported database URL scheme 'mysql'. Supported schemes: sqlite, postgres, postgresql ``` diff --git a/docs/pages/concepts/row-level-security.md b/docs/pages/concepts/row-level-security.md index b7ed72f4..e30d1ad5 100644 --- a/docs/pages/concepts/row-level-security.md +++ b/docs/pages/concepts/row-level-security.md @@ -92,6 +92,7 @@ Settings a session declares itself must be honoured, so opening a session with ` A setting made with plain `SET` stays on the server connection. Behind a pooler in transaction mode (PgBouncer's default, and the front door of most managed Postgres services), that server connection passes to another client between transactions, so two requests can share it seconds apart: + ```text tenant A → pooler → server connection 7: SET app.tenant_id = 'A' tenant B → pooler → server connection 7: SELECT … FROM invoice diff --git a/docs/pages/concepts/type-safety.md b/docs/pages/concepts/type-safety.md index 282303b0..016f0efb 100644 --- a/docs/pages/concepts/type-safety.md +++ b/docs/pages/concepts/type-safety.md @@ -56,6 +56,7 @@ else: It prints: + ```text 1 validation error for User age diff --git a/docs/pages/getting-started/installation.md b/docs/pages/getting-started/installation.md index 14d3789f..9938789e 100644 --- a/docs/pages/getting-started/installation.md +++ b/docs/pages/getting-started/installation.md @@ -77,6 +77,7 @@ print(ferro.version()) The number is the installed Ferro version: + ```text 0.25.0 ``` diff --git a/docs/pages/guide/aggregations.md b/docs/pages/guide/aggregations.md index 08d5fd93..911cad73 100644 --- a/docs/pages/guide/aggregations.md +++ b/docs/pages/guide/aggregations.md @@ -327,6 +327,7 @@ Each form below fails when you build the query, before any SQL is sent, with an --8<-- "docs/examples/aggregations.py:error-family" ``` + ```text TypeError: t.memo.sum() is not supported: Transaction.memo is string-typed, and sum() takes a numeric column (int, float, or Decimal). No portable cross-backend meaning exists for this aggregate over a string column. ``` @@ -337,8 +338,9 @@ TypeError: t.memo.sum() is not supported: Transaction.memo is string-typed, and --8<-- "docs/examples/aggregations.py:error-where" ``` + ```text -TypeError: t.amount.sum() > ... is not a where() predicate: WHERE filters rows before aggregation, so an aggregate cannot appear in it. Post-aggregation filtering is having() ... +TypeError: t.amount.sum() > ... is not a where() predicate: WHERE filters rows before aggregation, so an aggregate cannot appear in it. Post-aggregation filtering is having() (#291); until it lands, filter rows with where() and compare the aggregated result in Python. ``` Python's builtin `sum()` over a column reference raises instead of failing obscurely. The error text writes the lambda parameter as `t`, whatever you named it: @@ -347,8 +349,9 @@ Python's builtin `sum()` over a column reference raises instead of failing obscu --8<-- "docs/examples/aggregations.py:error-builtin-sum" ``` + ```text -TypeError: t.amount is a column reference and cannot be iterated; did you mean t.amount.sum()? ... +TypeError: t.amount is a column reference and cannot be iterated; did you mean t.amount.sum()? Aggregates are methods on the column (.count()/.sum()/.avg()/.min()/.max()). ``` An aggregate outside the dict form has no output name, so it raises: @@ -357,8 +360,9 @@ An aggregate outside the dict form has no output name, so it raises: --8<-- "docs/examples/aggregations.py:error-unnamed" ``` + ```text -TypeError: aggregate fields are user-named ...: give t.amount.sum() an output name with the dict selector form (e.g. select(lambda t: {"total": t.amount.sum()})). +TypeError: aggregate fields are user-named (#294): give t.amount.sum() an output name with the dict selector form (e.g. select(lambda t: {"total": t.amount.sum()})). ``` Sorting groups by a column that is not a group key raises, as the ordering rules above say: @@ -367,8 +371,9 @@ Sorting groups by a column that is not a group key raises, as the ordering rules --8<-- "docs/examples/aggregations.py:error-order-key" ``` + ```text -ValueError: order_by('memo') on an aggregate projection must name a group key or an aggregate: ... +ValueError: order_by('memo') on an aggregate projection must name a group key or an aggregate: it is neither an output field nor a group key's source column, so each group would answer with an arbitrary row's value. Sort by an output field name, or project the column as a group key. ``` `count()` and `exists()` on an aggregate projection are ambiguous between rows and groups, so both raise and name both spellings. Count matching rows with an unprojected query (`Transaction.where(...).count()`), and count groups with `len(await query.all())`: @@ -377,6 +382,7 @@ ValueError: order_by('memo') on an aggregate projection must name a group key or --8<-- "docs/examples/aggregations.py:error-count" ``` + ```text ValueError: count() on an aggregate projection is ambiguous: count the matching rows with an unprojected query (Model.where(...).count()), or count the groups with len(await q.all()). ``` diff --git a/docs/pages/guide/connections.md b/docs/pages/guide/connections.md index ba58e4b9..0ce7b08c 100644 --- a/docs/pages/guide/connections.md +++ b/docs/pages/guide/connections.md @@ -115,6 +115,7 @@ else: It prints: + ```text No database route for this operation. Open a session (`async with ferro.engines.session(...)`) or pass `using=`/`session=` explicitly. Implicit default-connection routing was removed in v0.14 (deprecated since v0.12); see the upgrade guide. ``` @@ -281,6 +282,7 @@ for statement in report.statements: On SQLite it prints: + ```text CREATE TABLE IF NOT EXISTS "author" ( "email" varchar NOT NULL, "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "name" varchar NOT NULL ) CREATE UNIQUE INDEX IF NOT EXISTS "uq_author_email" ON "author" ("email") @@ -327,6 +329,7 @@ logging.getLogger("ferro.sql").setLevel(logging.DEBUG) A schema change now prints each statement it runs, with its parameters and how long it took: + ```text ferro.sql DEBUG CREATE TABLE IF NOT EXISTS "post" (...) params=() 1.2ms ferro.sql DEBUG ALTER TABLE "author" ADD COLUMN "bio" varchar params=() 0.8ms diff --git a/docs/pages/guide/mutations.md b/docs/pages/guide/mutations.md index 1d202704..d7c79eca 100644 --- a/docs/pages/guide/mutations.md +++ b/docs/pages/guide/mutations.md @@ -475,6 +475,7 @@ SQL has no portable `UPDATE ... LIMIT` or `DELETE ... LIMIT`. Rather than change The error prints: + ```text delete() does not support limit/offset/after/before: portable SQL has no DELETE ... LIMIT. Remove the .limit()/.offset()/.after()/.before() call, or fetch primary keys first and delete by primary-key set. ``` @@ -729,6 +730,7 @@ Every database failure raises a typed exception under `FerroError`, so you never On SQLite it prints: + ```text 2067 None diff --git a/docs/pages/guide/queries.md b/docs/pages/guide/queries.md index bf3bda48..057a505e 100644 --- a/docs/pages/guide/queries.md +++ b/docs/pages/guide/queries.md @@ -14,6 +14,7 @@ Here is a whole program. It connects, opens a session, and asks for every transa It prints: + ```text Rent -1450 To savings -500 @@ -1321,6 +1322,7 @@ Reading a relation runs a query. A list of 100 transactions that shows each acco It prints: + ```text Weekly shop | Checking Office coffee | Petty cash diff --git a/docs/pages/guide/raw-sql.md b/docs/pages/guide/raw-sql.md index 9a76459e..230a9d21 100644 --- a/docs/pages/guide/raw-sql.md +++ b/docs/pages/guide/raw-sql.md @@ -28,6 +28,7 @@ Here is a whole program. It connects, opens a session, adds two clicks and a sig It prints: + ```text [{'kind': 'click', 'n': 2}, {'kind': 'signup', 'n': 1}] ``` diff --git a/docs/pages/guide/relationships.md b/docs/pages/guide/relationships.md index 61730143..83ec617a 100644 --- a/docs/pages/guide/relationships.md +++ b/docs/pages/guide/relationships.md @@ -14,6 +14,7 @@ Here is a whole program. It creates the tables, adds a team and a player, then r It prints: + ```text Rustaceans ['Ferris'] ``` @@ -214,6 +215,7 @@ Awaiting a forward relation in a loop runs one query per row. When a query will It prints: + ```text Corro | Rustaceans Ferris | Rustaceans diff --git a/docs/pages/guide/schema/auto-migrate.md b/docs/pages/guide/schema/auto-migrate.md index 41cc44de..a1696432 100644 --- a/docs/pages/guide/schema/auto-migrate.md +++ b/docs/pages/guide/schema/auto-migrate.md @@ -64,6 +64,7 @@ Rules worth knowing: - **A new required column needs a literal default.** Existing rows must get a value, so a new required field without a literal default fails the connect: + ```text Cannot add NOT NULL column 'author.bio' to an existing table: it has no literal default to backfill existing rows. Make the field nullable, give it a literal default, or generate a reviewed migration with `ferro migrate new`. ``` @@ -87,6 +88,7 @@ Rules worth knowing: SQLite cannot add, change or drop a constraint, a type or a nullability on a table that exists; the only way is to create the table again in its new shape and copy the rows across (a *table rebuild*). Auto-migrate never rebuilds a table, because a rebuild drops whatever the copy does not carry. It raises a `UserWarning` naming the object and the door that can, and changes nothing. Declare `db_check=True` on an existing column `author.kind`, for example, and the connect warns: + ```text Check constraint 'ck_author_kind' on column 'author.kind' is declared but missing from the live table, and SQLite cannot add a constraint to an existing column (it requires a full table rebuild). The invariant is not database-enforced; generate a reviewed migration with `ferro migrate new` to apply it. ``` @@ -185,6 +187,7 @@ ALTER TABLE "author" ALTER COLUMN "slug" DROP DEFAULT Under `migrate_updates` alone the default stays, and the pass warns on every start: + ```text author.slug has default '' in the database, none declared; it is left in place: connect(..., migrate_destructive=True) drops it, or run ALTER TABLE "author" ALTER COLUMN "slug" DROP DEFAULT. ``` @@ -230,12 +233,14 @@ except ferro.OperationalError as error: Every auto-migrate pass takes the same run lock `ferro migrate up` takes, so two processes booting together never collide: the second waits, saying so with a `UserWarning`, and sees the first one's DDL: + ```text connect(auto_migrate=…) is waiting: another ferro migration run or auto-migrate pass holds the run lock on this database. It goes on once that one finishes. ``` It waits up to the project's [`lock_timeout`](../../reference/configuration.md#lock_timeout) (default `30s`; the setting's `"0"` waits without a limit, unlike the CLI's `--lock-timeout 0`, which refuses at once), then refuses: + ```text connect(auto_migrate=…) gave up waiting for the run lock on public: another ferro migration run or auto-migrate pass held it longer than lock_timeout (30s). Nothing was applied. Wait for that run to finish and try again, or raise lock_timeout; `ferro migrate status` shows a migration run while it holds the lock. ``` diff --git a/docs/pages/guide/schema/data-steps.md b/docs/pages/guide/schema/data-steps.md index cef03013..f2e8c6ec 100644 --- a/docs/pages/guide/schema/data-steps.md +++ b/docs/pages/guide/schema/data-steps.md @@ -162,6 +162,7 @@ When you know no existing row needs a value (the table is new in production, or The two migrations side by side: + ```text ferro migrate new author_slug ferro migrate new author_slug --no-backfill author.slug diff --git a/docs/pages/guide/schema/deploying.md b/docs/pages/guide/schema/deploying.md index 32ae551a..74fe0aad 100644 --- a/docs/pages/guide/schema/deploying.md +++ b/docs/pages/guide/schema/deploying.md @@ -65,6 +65,7 @@ async def start() -> None: Behind its migrations, it raises `PendingMigrationsError`: + ```text ferro.migrations.PendingMigrationsError: ferro.migrations: this database is behind its migrations. pending 0002_author_slug @@ -125,6 +126,7 @@ In a rolling deploy, old code keeps running while the migration applies. A migra Between the two, release 0 code meets a database that has applied a migration its checkout does not have. `require_applied()` and `up()` refuse that by default: + ```text ferro.migrations.DatabaseAheadError: ferro migrate: this database has applied 0008_author_slug, which is not in migrations/. The directory is behind the database: check out the branch that holds it. Nothing was applied. @@ -139,6 +141,7 @@ await ferro.migrations.require_applied(allow_ahead="expand-only") It lets the database through when every step the old checkout lacks is an *expand* step: one the old release's writes keep succeeding beside. `0008_author_slug` adds a nullable column, so release 0 starts. A database ahead by a *contract* step is refused, naming the first one (`ferro migrate up --allow-ahead=expand-only` does the same): + ```text ferro.migrations.DatabaseAheadError: ferro migrate: this database has applied 0008_drop_bio, which is not in migrations/. The directory is behind the database: check out the branch that holds it. diff --git a/docs/pages/guide/schema/migrations.md b/docs/pages/guide/schema/migrations.md index 8a77a669..08b626b1 100644 --- a/docs/pages/guide/schema/migrations.md +++ b/docs/pages/guide/schema/migrations.md @@ -101,6 +101,7 @@ The example `docs/examples/migrations_quickstart.py` in the repository runs the `new` writes one directory per migration: + ```text migrations/ .gitattributes written once: the directory is byte-exact (no line-ending conversion) diff --git a/docs/pages/guide/schema/overview.md b/docs/pages/guide/schema/overview.md index a072c565..4c8597c3 100644 --- a/docs/pages/guide/schema/overview.md +++ b/docs/pages/guide/schema/overview.md @@ -42,6 +42,7 @@ Rungs 1 and 2 are the first door ([Auto-migrate](auto-migrate.md)); each flag im A database is managed by auto-migrate **or** by migrations, never both. Once a database has run a migration, it carries the tracking table `_ferro_migrations`, and `connect()` refuses every auto-migrate flag on it before running any DDL: + ```text connect(auto_migrate=…) is refused: main is governed by ferro migrations (main._ferro_migrations). Use ferro migrate up, or drop the tracking tables to leave migrations. ``` diff --git a/docs/pages/guide/schema/renames-and-retirements.md b/docs/pages/guide/schema/renames-and-retirements.md index 478d8b52..c4dbfcab 100644 --- a/docs/pages/guide/schema/renames-and-retirements.md +++ b/docs/pages/guide/schema/renames-and-retirements.md @@ -165,6 +165,7 @@ Removing a label in the release that stops writing it breaks the release still s The member stays, so a row that holds `paused` still loads as `Status.PAUSED`. Writing the label is refused, at construction and by attribute assignment. `Author(name="Grace Hopper", email="grace@example.com", bio="Admiral", status="paused")` raises a validation error: + ```text 1 validation error for Author status diff --git a/docs/pages/howto/multiple-databases.md b/docs/pages/howto/multiple-databases.md index b3fd6fe0..145cefa8 100644 --- a/docs/pages/howto/multiple-databases.md +++ b/docs/pages/howto/multiple-databases.md @@ -14,6 +14,7 @@ Give each connection a name, mark one as the default, and route calls to each: It prints: + ```text 1 1 ``` @@ -48,6 +49,7 @@ To run a whole block on one named connection, open a session on it: It prints: + ```text ['page_views'] ``` @@ -62,6 +64,7 @@ Inside a session, `.using()` must name the session's connection. Naming another It prints: + ```text Explicit `using='analytics'` conflicts with the ambient session bound to connection 'app'. Pass an explicit `session=` for that connection, or open a session on 'analytics'. ``` @@ -80,6 +83,7 @@ Routing is per call: `.using()` changes no global state, so two coroutines can u It prints: + ```text Operations inside a transaction inherit the transaction connection 2 @@ -97,6 +101,7 @@ The `clicks` metric landed on `analytics`, which now holds two metrics; the call It prints: + ```text ['metric'] ``` diff --git a/docs/pages/howto/testing.md b/docs/pages/howto/testing.md index 769ef1cb..d84c3790 100644 --- a/docs/pages/howto/testing.md +++ b/docs/pages/howto/testing.md @@ -76,6 +76,7 @@ async def test_email_is_unique(db): Run the file: + ```text $ pytest -q tests/test_users.py .. [100%] diff --git a/docs/pages/index.md b/docs/pages/index.md index 0b06284f..d3b62b4b 100644 --- a/docs/pages/index.md +++ b/docs/pages/index.md @@ -36,6 +36,7 @@ Connect, open a session, create two users and query the active ones: Only `alice` is active, so the script prints: + ```text Found 1 active users. ``` diff --git a/docs/pages/reference/configuration.md b/docs/pages/reference/configuration.md index 80748733..e781f356 100644 --- a/docs/pages/reference/configuration.md +++ b/docs/pages/reference/configuration.md @@ -87,6 +87,7 @@ How long a DDL statement Ferro runs on Postgres, in a migration step or in an au How long a run waits for the run lock while another run holds it: `"500ms"`, `"30s"`, `"1m"`, a number of seconds, or `"0"` to wait without a limit. It is the default of `--lock-timeout` on `ferro migrate up`, `down`, `baseline` and `rerecord` (and of `lock_timeout=` on their Python calls); the flag wins. An auto-migrate pass (`connect(auto_migrate=…)`, `ferro.create_tables()`, `ferro.migrate()`) waits for it too, so a replica booting while another one's pass runs waits instead of failing: + ```text connect(auto_migrate=…) gave up waiting for the run lock on public: another ferro migration run or auto-migrate pass held it longer than lock_timeout (30s). Nothing was applied. Wait for that run to finish and try again, or raise lock_timeout; `ferro migrate status` shows a migration run while it holds the lock. ``` diff --git a/docs/pages/reference/row-level-security.md b/docs/pages/reference/row-level-security.md index 0fa65bee..0b7b0aba 100644 --- a/docs/pages/reference/row-level-security.md +++ b/docs/pages/reference/row-level-security.md @@ -203,6 +203,7 @@ A session that only inherits settings from an outer Postgres session opens on SQ `connect(migrate_updates=True)` (or `migrate_destructive=True`) warns when the connecting role is neither a superuser nor `BYPASSRLS` and an existing table it migrates declares `RowSecurity(force=True)`: + ```text ferro auto-migrate: The connected role is neither a superuser nor BYPASSRLS, and this migration touches table(s) 'invoice' with FORCE ROW LEVEL SECURITY. Row policies apply to the migrating role too, so a backfill or data step can silently see and update zero rows. Migrate as a role with BYPASSRLS if this pass moves data. ``` diff --git a/docs/pages/upgrading.md b/docs/pages/upgrading.md index e887dbb0..ee568f63 100644 --- a/docs/pages/upgrading.md +++ b/docs/pages/upgrading.md @@ -113,6 +113,7 @@ Also new, and opt-in: [`Field(retired=True)`](guide/schema/renames-and-retiremen ??? example "Automate this: copy this prompt to your coding agent" + ```text You are helping upgrade a Python codebase from Ferro ORM 0.22.x to 0.23. Reference: https://ferro-orm.x54.sh/upgrading/#upgrading-to-023 @@ -215,6 +216,7 @@ The value matches Ada's row. A string from `isoformat()` still misses, because t ??? example "Automate this: copy this prompt to your coding agent" + ```text You are helping upgrade a Python codebase from Ferro ORM 0.20.x to 0.21. Reference: https://ferro-orm.x54.sh/upgrading/#upgrading-to-021 @@ -306,6 +308,7 @@ The column stays plain `json`, and `0.15` writes no migration for it. `db_type=" ??? example "Automate this: copy this prompt to your coding agent" + ```text You are helping upgrade a Python codebase from Ferro ORM 0.14.x to 0.15. Reference: https://ferro-orm.x54.sh/upgrading/#upgrading-to-015 @@ -335,6 +338,7 @@ The column stays plain `json`, and `0.15` writes no migration for it. `db_type=" ??? example "Automate this: copy this prompt to your coding agent" + ```text You are helping upgrade a Python codebase from Ferro ORM 0.13.x to 0.14. Reference: https://ferro-orm.x54.sh/upgrading/#upgrading-to-014 @@ -639,6 +643,7 @@ The runtime emitter used to store a `datetime.time` field as `varchar` (the brid ??? example "Automate this: copy this prompt to your coding agent" + ```text You are helping upgrade a Python codebase from Ferro ORM 0.11.x to 0.12. Reference: https://ferro-orm.x54.sh/upgrading/#upgrading-to-012 diff --git a/tests/_docs_sql.py b/tests/_docs_sql.py index b2e21efd..cc2ec7c2 100644 --- a/tests/_docs_sql.py +++ b/tests/_docs_sql.py @@ -40,7 +40,15 @@ from pathlib import Path from typing import Any -from tests._docs_harness import DOCS_PAGES, REPO_ROOT, Fence, run_python +from tests._docs_harness import ( + DOCS_PAGES, + REPO_ROOT, + Fence, + fence_settings, + fenced_examples, + run_fence, + run_python, +) DOCS_EXAMPLES = REPO_ROOT / "docs" / "examples" @@ -524,7 +532,10 @@ def check_same_on_postgres(block: Fence, run: Callable[[str, str], Run]) -> None ````.""" TEXT_NOTES = { - "prints": "what an example region prints, regenerated by tests/test_docs_output.py", + "prints": ( + "what an example region, or the run fence above, prints; regenerated by " + "tests/test_docs_output.py" + ), "output": "what ferro migrate prints, regenerated by tests/test_docs_migrations.py", "refusal": "a refusal ferro prints, regenerated by tests/test_docs_migrations.py", NOT_PRINTED: "not a script's output; the note says what it shows", @@ -536,19 +547,28 @@ def check_printed(block: Fence, run: Callable[[str, str], Run] = captured) -> No """Assert a ```text block says where it comes from, and holds what it says. A block under ```` must equal what - that region prints on SQLite, byte for byte. A block under another note - (``TEXT_NOTES``) is checked elsewhere, or says what it shows instead. + that region prints on SQLite, byte for byte; one under + ```` must equal what the nearest Python fence above + it prints, a fence marked ``{test=run prelude=}``. A block under + another note (``TEXT_NOTES``) is checked elsewhere, or says what it shows + instead. + + Args: + block: The page's ```text block. + run: Given an example script's name and a dialect, its run's record. + Defaults to ``captured``. Raises: AssertionError: The block has no note, an unknown one, names an - example or region that does not exist, or differs from what the - region prints. The message names the page and the line. + example, region or fence that does not exist, or differs from what + it prints. The message names the page and the line. """ where = page_name(block) notes = ", ".join(f"" for kind in TEXT_NOTES) assert block.note is not None, ( f"{where}: a ```text block must sit under a note saying where it comes " f"from: for what an example " + f"region prints, for what the run fence above " f"prints, or for text no script prints " f"(one of {notes})" ) @@ -556,9 +576,13 @@ def check_printed(block: Fence, run: Callable[[str, str], Run] = captured) -> No assert kind in TEXT_NOTES, f"{where}: unknown note {kind!r}; use one of {notes}" if kind != "prints": return + if value == "above": + _check_printed_above(block, where) + return marker = PRINTS.match(value) assert marker is not None, ( - f"{where}: must name .py:" + f"{where}: must name .py:, " + "or say above" ) example, snippet = marker["example"], marker["snippet"] named = f"{where} ({example}:{snippet})" @@ -571,6 +595,26 @@ def check_printed(block: Fence, run: Callable[[str, str], Run] = captured) -> No ) +def _check_printed_above(block: Fence, where: str) -> None: + path = Path(block.path) + fences = [ + example + for example in fenced_examples(path, path.read_text(encoding="utf-8")) + if example.start_line < block.line + ] + assert fences, f"{where}: has no Python fence above it" + above = fences[-1] + assert fence_settings(above).get("test") == "run", ( + f"{where}: needs the fence above " + f"(line {above.start_line}) marked {{test=run prelude=}}" + ) + printed = run_fence(above) + assert block.body == printed, ( + f"{where}: the block differs from what the fence at line " + f"{above.start_line} prints. It prints:\n{printed}" + ) + + if __name__ == "__main__": script, dialect, out, *rest = sys.argv[1:] result = _run_capture(Path(script).resolve(), dialect, rest[0] if rest else None) diff --git a/tests/_docs_twins.py b/tests/_docs_twins.py new file mode 100644 index 00000000..0f5d1916 --- /dev/null +++ b/tests/_docs_twins.py @@ -0,0 +1,226 @@ +"""The Annotated twin of a docs example script, derived from its Assignment form. + +AGENTS.md I-7 shows every field-declaring example in two tabs, and the two +differ only in where the field options sit. A twin is therefore not written by +hand: ``annotated_twin`` rewrites each ``name: T = Field(...)`` of the +Assignment script into the one Annotated idiom of +``docs/solutions/patterns/docs-page-conventions.md``: + +- options move inside ``Annotated[T, Field(...)]``; +- a ``default`` stays the assigned value (``= None``), where type checkers + see it; +- a ``default_factory`` stays an assigned ``Field(default_factory=...)``, for + the same reason; +- everything else in the script (data, queries, prints, asserts) is the same + text, so both twins print the same thing. + +Run ``python -m tests._docs_twins docs/examples/.py`` to write +``_annotated.py`` after changing ``.py``. +""" + +from __future__ import annotations + +import ast +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +DOCS_EXAMPLES = REPO_ROOT / "docs" / "examples" + +_FIELD_CALLS = frozenset({"Field", "FerroField"}) +_ASSIGNED = frozenset({"default", "default_factory"}) + + +def twin_pairs() -> list[tuple[Path, Path]]: + """Every ``docs/examples/.py`` that has a ``_annotated.py``.""" + return [ + (path.with_name(path.name.removesuffix("_annotated.py") + ".py"), path) + for path in sorted(DOCS_EXAMPLES.glob("*_annotated.py")) + ] + + +def _segment(source: str, node: ast.AST) -> str: + text = ast.get_source_segment(source, node) + assert text is not None + return text + + +def _declaration(source: str, node: ast.AnnAssign) -> str | None: + """The Annotated form of one ``name: T = Field(...)``, or ``None`` when the + statement is not one.""" + call = node.value + if not ( + isinstance(call, ast.Call) + and isinstance(call.func, ast.Name) + and call.func.id in _FIELD_CALLS + ): + return None + if any(keyword.arg is None for keyword in call.keywords): + raise ValueError( + f"line {node.lineno}: a **kwargs Field call has no Annotated twin; " + "spell the options out" + ) + default: str | None = None + if call.args: + if len(call.args) > 1: + raise ValueError(f"line {node.lineno}: Field takes one positional default") + default = _segment(source, call.args[0]) + factory: str | None = None + options: list[str] = [] + for keyword in call.keywords: + value = _segment(source, keyword.value) + if keyword.arg == "default": + default = value + elif keyword.arg == "default_factory": + factory = value + else: + options.append(f"{keyword.arg}={value}") + target = _segment(source, node.target) + annotation = _segment(source, node.annotation) + if options: + metadata = f"{call.func.id}({', '.join(options)})" + if ( + isinstance(node.annotation, ast.Subscript) + and isinstance(node.annotation.value, ast.Name) + and node.annotation.value.id == "Annotated" + ): + annotation = f"{annotation[:-1]}, {metadata}]" + else: + annotation = f"Annotated[{annotation}, {metadata}]" + declaration = f"{target}: {annotation}" + if factory is not None: + declaration += f" = {call.func.id}(default_factory={factory})" + elif default is not None: + declaration += f" = {default}" + return declaration + + +def _with_annotated_import(source: str) -> str: + """``source`` importing ``Annotated`` from ``typing``, where ruff's import + order puts it.""" + tree = ast.parse(source) + imports = [ + node for node in tree.body if isinstance(node, ast.Import | ast.ImportFrom) + ] + for node in imports: + if isinstance(node, ast.ImportFrom) and node.module == "typing": + names = [alias.name for alias in node.names] + if "Annotated" in names: + return source + names.append("Annotated") + names.sort( + key=lambda name: ( + 0 + if name.isupper() and len(name) > 1 + else 1 + if name[0].isupper() + else 2, + name, + ) + ) + lines = source.splitlines(keepends=True) + lines[node.lineno - 1 : node.end_lineno] = [ + f"from typing import {', '.join(names)}\n" + ] + return "".join(lines) + stdlib = [ + node + for node in imports + if (node.module if isinstance(node, ast.ImportFrom) else node.names[0].name) + and ( + (node.module if isinstance(node, ast.ImportFrom) else node.names[0].name) + or "" + ).split(".")[0] + in sys.stdlib_module_names + and not (isinstance(node, ast.ImportFrom) and node.module == "__future__") + ] + lines = source.splitlines(keepends=True) + if stdlib: + after = [ + node + for node in stdlib + if isinstance(node, ast.ImportFrom) and (node.module or "") > "typing" + ] + if after: + lines.insert(after[0].lineno - 1, "from typing import Annotated\n") + else: + lines.insert(stdlib[-1].end_lineno or 0, "from typing import Annotated\n") + return "".join(lines) + first = next( + node + for node in imports + if not (isinstance(node, ast.ImportFrom) and node.module == "__future__") + ) + lines.insert(first.lineno - 1, "from typing import Annotated\n\n") + return "".join(lines) + + +def _formatted(source: str) -> str: + result = subprocess.run( + [sys.executable, "-m", "ruff", "format", "--stdin-filename", "twin.py", "-"], + input=source, + capture_output=True, + text=True, + cwd=REPO_ROOT, + check=False, + ) + if result.returncode != 0: + raise ValueError(f"ruff format refused the twin:\n{result.stderr}") + return result.stdout + + +def annotated_twin(source: str) -> str: + """Rewrite an Assignment-style example script into its Annotated twin. + + Args: + source: The Assignment script, formatted by ruff. + + Returns: + The twin, formatted by ruff: every ``Field(...)`` declaration in the + Annotated idiom, ``Annotated`` imported, every other line unchanged. + + Raises: + ValueError: A declaration has no Annotated twin (``Field(**options)``), + or a comment sits inside a declaration the rewrite would drop. + """ + tree = ast.parse(source) + lines = source.splitlines(keepends=True) + rewrites: list[tuple[int, int, str]] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.AnnAssign) or node.value is None: + continue + declaration = _declaration(source, node) + if declaration is None: + continue + end = node.end_lineno or node.lineno + span = "".join(lines[node.lineno - 1 : end]) + indent = lines[node.lineno - 1][: node.col_offset] + rest = span.replace(_segment(source, node), "", 1) + trailing = "" + if "#" in rest: + before, _, comment = rest.partition("#") + if end != node.lineno or before.strip(): + raise ValueError( + f"line {node.lineno}: a comment inside a multi-line Field " + "declaration would be lost; move it above the field" + ) + trailing = f" #{comment.rstrip(chr(10))}" + rewrites.append((node.lineno, end, f"{indent}{declaration}{trailing}\n")) + if not rewrites: + return source + for start, end, text in sorted(rewrites, reverse=True): + lines[start - 1 : end] = [text] + return _formatted(_with_annotated_import("".join(lines))) + + +def main(argv: list[str]) -> None: + for name in argv: + path = Path(name) + twin = path.with_name(f"{path.stem}_annotated.py") + twin.write_text(annotated_twin(path.read_text(encoding="utf-8")), "utf-8") + print(f"wrote {twin}") + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/tests/test_docs_output.py b/tests/test_docs_output.py new file mode 100644 index 00000000..5ee01d24 --- /dev/null +++ b/tests/test_docs_output.py @@ -0,0 +1,135 @@ +"""The output a page shows is what its example prints. + +Every ```text block on a docs page sits under a note saying where its text +comes from (``tests._docs_sql.TEXT_NOTES``): + +- ````: what that snippet region of + ``docs/examples/.py`` prints. This test runs the script once on + SQLite and compares the block with the region's printed lines byte for byte. +- ````: what the nearest Python fence above it prints, + a fence marked ``{test=run prelude=}``, run the way + ``tests/test_docs_examples.py`` runs it. +- ```` and ````: what + ``ferro migrate`` prints, regenerated by ``tests/test_docs_migrations.py``. +- ````: text no script prints (a shell transcript, + a traceback, a file tree, a prompt), skipped on purpose and saying so. + +An unmarked block is refused, so typed output cannot slip onto a page. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from tests._docs_harness import Fence, fenced_blocks, page_fences +from tests._docs_sql import Region, Run, check_printed, page_name + + +@pytest.mark.parametrize("block", page_fences("text"), ids=page_name) +def test_a_text_block_is_what_it_says_it_is(block: Fence) -> None: + check_printed(block) + + +def _fake(example: str, dialect: str) -> Run: + regions = {"first-query": Region(printed=["Rent -1450", "Groceries -82.40"])} + return Run(regions if example == "predicates.py" else {}, "", []) + + +def _block(note: str, body: str) -> Fence: + [block] = fenced_blocks( + Path("docs/pages/guide/queries.md"), + f"# Queries\n\nIt prints:\n\n{note}\n```text\n{body}```\n", + "text", + ) + return block + + +def test_a_block_holding_what_its_region_prints_passes() -> None: + check_printed( + _block( + "", + "Rent -1450\nGroceries -82.40\n", + ), + _fake, + ) + + +def test_a_block_that_says_what_it_shows_is_not_compared() -> None: + check_printed(_block("", "Traceback\n"), _fake) + + +def _page_below(tmp_path: Path, fence: str, body: str) -> Fence: + page = tmp_path / "page.md" + page.write_text( + f"# Page\n\n{fence}\n\nIt prints:\n\n\n```text\n" + f"{body}```\n", + encoding="utf-8", + ) + [block] = fenced_blocks(page, page.read_text(encoding="utf-8"), "text") + return block + + +RUN_FENCE = ( + "```python {test=run prelude=reference}\n" + "print((await Author.where(lambda author: author.id == 1).first()).name)\n```" +) + + +def test_a_block_under_a_run_fence_holds_what_the_fence_prints( + tmp_path: Path, +) -> None: + check_printed(_page_below(tmp_path, RUN_FENCE, "Ada Lovelace\n")) + with pytest.raises(AssertionError) as raised: + check_printed(_page_below(tmp_path, RUN_FENCE, "Grace Hopper\n")) + assert "the block differs from what the fence at line 3 prints" in str( + raised.value + ) + + +def test_a_block_under_a_fence_that_does_not_run_is_refused(tmp_path: Path) -> None: + fence = '```python {test=compile reason="needs Postgres"}\nprint(1)\n```' + with pytest.raises(AssertionError, match=r"needs the fence above \(line 3\)"): + check_printed(_page_below(tmp_path, fence, "1\n")) + + +@pytest.mark.parametrize( + ("note", "body", "message"), + [ + ( + "", + "Rent -1450\n", + "docs/pages/guide/queries.md:6 (predicates.py:first-query): the block " + "differs from what predicates.py:first-query prints", + ), + ( + "", + "Rent -1450\nGroceries -82.40\n", + "docs/pages/guide/queries.md:6: a ```text block must sit under a note", + ), + ( + "", + "Rent -1450\n", + "docs/pages/guide/queries.md:6: unknown note 'printed'", + ), + ( + "", + "Rent -1450\n", + "docs/pages/guide/queries.md:6: must name", + ), + ( + "", + "Rent -1450\n", + "docs/pages/guide/queries.md:6 (predicates.py:no-such-region): " + "predicates.py has no snippet region 'no-such-region'", + ), + ], + ids=["edited", "unmarked", "unknown-note", "no-region", "unknown-region"], +) +def test_a_block_that_is_not_what_it_says_fails_naming_it( + note: str, body: str, message: str +) -> None: + with pytest.raises(AssertionError) as raised: + check_printed(_block(note, body), _fake) + assert str(raised.value).startswith(message), str(raised.value) From 62aa81f335c33c78b2300624ed2b05ab4cbb51e7 Mon Sep 17 00:00:00 2001 From: Taylor Date: Wed, 21 Oct 2026 14:49:37 -0400 Subject: [PATCH 04/13] docs: Annotated twins are generated from their Assignment scripts and pinned equivalent tests/_docs_twins.py rewrites an Assignment example into the one Annotated idiom (options inside Annotated, the default or default_factory assigned, a quoted declaration in a comment quoted the same way) and leaves every other line alone. All 29 twins are regenerated with it, so both tabs run the same data, queries and prints; before, most twins were shorter, different programs. tests/test_docs_twins.py fails when a twin is not its script rewritten, or when the pair declares a different schema (SchemaIR at each connect and at the end) or prints different output. A default_factory stays an assigned Field in the Annotated tab, because type checkers see no default inside Annotated (ty: missing-argument). The conventions record that rule, the generated twins and the printed-output notes. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP --- docs/examples/aggregations_annotated.py | 289 ++++++++++++++++- docs/examples/concepts_annotated.py | 132 +++++++- docs/examples/faq_annotated.py | 8 +- docs/examples/from_sqlalchemy_annotated.py | 2 +- .../examples/migrations_backfill_annotated.py | 3 +- .../migrations_quickstart_annotated.py | 3 +- docs/examples/migrations_rename_annotated.py | 2 +- docs/examples/migrations_retire_annotated.py | 2 +- .../migrations_retire_label_annotated.py | 2 +- docs/examples/migrations_squash_annotated.py | 3 +- docs/examples/migrations_testing_annotated.py | 3 +- docs/examples/models_and_fields_annotated.py | 198 ++++++++++-- docs/examples/multiple_databases_annotated.py | 68 +++- docs/examples/mutations_annotated.py | 211 ++++++++++++- docs/examples/pagination_annotated.py | 102 +++++- docs/examples/predicates_annotated.py | 291 ++++++++++++++++-- docs/examples/quickstart_annotated.py | 110 ++++++- docs/examples/raw_sql_annotated.py | 77 ++++- docs/examples/readme_quickstart_annotated.py | 10 +- docs/examples/relationships_annotated.py | 181 +++++++++-- docs/examples/row_level_security_annotated.py | 19 +- docs/examples/soft_deletes_annotated.py | 39 ++- docs/examples/table_checks_annotated.py | 46 ++- docs/examples/testing_models_annotated.py | 9 +- docs/examples/timestamps_annotated.py | 56 +++- docs/examples/transactions_annotated.py | 115 ++++++- docs/examples/upgrade_account_annotated.py | 8 +- docs/examples/upgrade_keep_annotated.py | 4 +- .../examples/upgrade_primary_key_annotated.py | 2 +- .../patterns/docs-page-conventions.md | 72 ++++- tests/_docs_sql.py | 8 +- tests/_docs_twins.py | 206 +++++++++---- tests/test_docs_output.py | 4 +- tests/test_docs_twins.py | 177 +++++++++++ 34 files changed, 2158 insertions(+), 304 deletions(-) create mode 100644 tests/test_docs_twins.py diff --git a/docs/examples/aggregations_annotated.py b/docs/examples/aggregations_annotated.py index 3402cfa3..27c2785b 100644 --- a/docs/examples/aggregations_annotated.py +++ b/docs/examples/aggregations_annotated.py @@ -1,11 +1,14 @@ -"""Annotated-style companion to aggregations.py (AGENTS.md I-7). +"""Runnable companion to the "Aggregations and grouped queries" guide +(docs/pages/guide/aggregations.md). -Field options move into ``Annotated[...]``. Aggregation is independent of the -declaration style, so the queries are the ones in aggregations.py; only the -schema and one grouped round trip live here. +Seeds a small ledger of four transactions, then runs every aggregate query +the guide shows and asserts the exact records that come back. The +``aggregations`` prelude imports ``Account``, ``Transaction`` and ``seed`` from +here, so the guide's inline blocks run against the same rows. """ import asyncio +from collections import defaultdict from decimal import Decimal from typing import Annotated @@ -30,23 +33,95 @@ class Transaction(Model): # --8<-- [end:schema] +async def seed() -> None: + """Add the guide's rows. Call inside a session.""" + checking = await Account.create(id=1, label="Checking") + savings = await Account.create(id=2, label="Savings") + await Transaction.create( + id=1, amount=10, price=Decimal("1.25"), memo="coffee", account=checking + ) + await Transaction.create( + id=2, amount=20, price=Decimal("2.75"), memo="lunch", account=checking + ) + await Transaction.create( + id=3, amount=45, price=Decimal("5.00"), memo="dinner", account=savings + ) + await Transaction.create(id=4, amount=40, memo="orphan") # no account, no price + + async def main() -> None: await connect("sqlite::memory:", auto_migrate=True) + # --8<-- [start:global] async with engines.session(): - checking = await Account.create(id=1, label="Checking") - savings = await Account.create(id=2, label="Savings") - await Transaction.create( - id=1, amount=10, price=Decimal("1.25"), memo="coffee", account=checking - ) - await Transaction.create( - id=2, amount=20, price=Decimal("2.75"), memo="lunch", account=checking + await seed() + + # An aggregate-only projection collapses to exactly one record, + # read idiomatically with first(). + row = await Transaction.select( + lambda transaction: { + "n": transaction.id.count(), + "total": transaction.amount.sum(), + "average": transaction.amount.avg(), + "smallest": transaction.amount.min(), + "largest": transaction.amount.max(), + } + ).first() + + assert row is not None + assert row.model_dump() == { + "n": 4, + "total": 115, + "average": 28.75, + "smallest": 10, + "largest": 45, + } + # --8<-- [end:global] + + # --8<-- [start:global-where] + # Aggregates measure whatever where() leaves in, traversal included. + row = await ( + Transaction.select(lambda transaction: {"total": transaction.amount.sum()}) + .where(lambda transaction: transaction.account.label == "Checking") + .first() ) - await Transaction.create( - id=3, amount=45, price=Decimal("5.00"), memo="dinner", account=savings + assert row is not None and row.total == 30 + # --8<-- [end:global-where] + + # --8<-- [start:empty] + # Over zero matching rows: one record, None for sum, 0 for count. + row = await ( + Transaction.select( + lambda transaction: { + "n": transaction.id.count(), + "total": transaction.amount.sum(), + } + ) + .where(lambda transaction: transaction.amount > 10_000) + .first() ) - await Transaction.create(id=4, amount=40, memo="orphan") + assert row is not None + assert row.n == 0 + assert row.total is None + # --8<-- [end:empty] + + # --8<-- [start:count-nulls] + # COUNT(column) counts the non-NULL values of that column. Three of + # the four transactions have a price; the orphan has none. + row = await Transaction.select( + lambda transaction: { + "rows": transaction.id.count(), + "priced": transaction.price.count(), + } + ).first() + assert row is not None + assert row.rows == 4 + assert row.priced == 3 + # --8<-- [end:count-nulls] + # --8<-- [start:grouped] + # Mix a plain field in and the query becomes grouped: every + # non-aggregate field is a group key. There is no group_by(). rows = await ( Transaction.select( lambda transaction: { @@ -62,8 +137,192 @@ async def main() -> None: {"acct": 1, "total": 30}, {"acct": 2, "total": 45}, ] + # --8<-- [end:grouped] + + # --8<-- [start:grouped-traversed] + # Group keys may traverse. The account-less transaction drops out, + # exactly as it would from a where() predicate on the same path. + rows = await ( + Transaction.select( + lambda transaction: { + "account_label": transaction.account.label, + "total": transaction.amount.sum(), + } + ) + .order_by("account_label") + .all() + ) + assert rows.model_dump() == [ + {"account_label": "Checking", "total": 30}, + {"account_label": "Savings", "total": 45}, + ] + # --8<-- [end:grouped-traversed] + + # --8<-- [start:none-bucket] + # left_join() keeps the account-less transaction, so "no account" + # becomes a group keyed None instead of a dropped row. + rows = await ( + Transaction.select( + lambda transaction: { + "account_label": transaction.account.label, + "total": transaction.amount.sum(), + } + ) + .left_join(lambda transaction: transaction.account) + .all() + ) + buckets = {row.account_label: row.total for row in rows} + assert buckets == {"Checking": 30, "Savings": 45, None: 40} + # --8<-- [end:none-bucket] + + # --8<-- [start:top-n] + # The top-N idiom: keys and aggregates, ordered by the aggregate's + # output name, with limit() applied to the groups. + top = await ( + Transaction.select( + lambda transaction: { + "account_label": transaction.account.label, + "total": transaction.amount.sum(), + } + ) + .order_by("total", "desc") + .limit(1) + .all() + ) + assert top.model_dump() == [{"account_label": "Savings", "total": 45}] + # --8<-- [end:top-n] + + # --8<-- [start:order-by-lambda] + # order_by()'s lambda form spells the source expression, aggregate + # included. The largest total comes first. + rows = await ( + Transaction.select( + lambda transaction: { + "acct": transaction.account_id, + "total": transaction.amount.sum(), + } + ) + .where(lambda transaction: transaction.account_id != None) + .order_by(lambda transaction: transaction.amount.sum(), "desc") + .all() + ) + assert [(row.acct, row.total) for row in rows] == [(2, 45), (1, 30)] + # --8<-- [end:order-by-lambda] + + # --8<-- [start:zero-groups] + # A grouped query over zero rows returns zero records. + rows = await ( + Transaction.select( + lambda transaction: { + "acct": transaction.account_id, + "total": transaction.amount.sum(), + } + ) + .where(lambda transaction: transaction.amount > 10_000) + .all() + ) + assert len(rows) == 0 + # --8<-- [end:zero-groups] + + # --8<-- [start:decimal-contract] + # avg() over a Decimal column stays Decimal, on SQLite and Postgres. + row = await ( + Transaction.select( + lambda transaction: {"avg_price": transaction.price.avg()} + ) + .where(lambda transaction: transaction.price != None) + .first() + ) + assert row is not None + assert isinstance(row.avg_price, Decimal) + assert row.avg_price == Decimal("3") + # --8<-- [end:decimal-contract] + + # --8<-- [start:partition] + # Whole instances bucketed by a key: partition a full query in Python. + by_account: dict[int | None, list[Transaction]] = defaultdict(list) + for transaction in await Transaction.select().all(): + by_account[transaction.account_id].append(transaction) + + sizes = {key: len(rows) for key, rows in by_account.items()} + assert sizes == {1: 2, 2: 1, None: 1} + # --8<-- [end:partition] + + # --8<-- [start:error-family] + # sum() over a string column has no portable meaning. + try: + Transaction.select(lambda transaction: {"x": transaction.memo.sum()}) + except TypeError as exc: + print(f"{type(exc).__name__}: {exc}") + else: + raise AssertionError("expected TypeError") + # --8<-- [end:error-family] + + # --8<-- [start:error-where] + # An aggregate inside where() raises and points at having(). + try: + Transaction.where(lambda transaction: transaction.amount.sum() > 100) + except TypeError as exc: + print(f"{type(exc).__name__}: {exc}") + else: + raise AssertionError("expected TypeError") + # --8<-- [end:error-where] + + # --8<-- [start:error-builtin-sum] + # Python's builtin sum() over a column reference raises. + try: + Transaction.select( + lambda transaction: { + "x": sum(transaction.amount) # type: ignore[arg-type] + } + ) + except TypeError as exc: + print(f"{type(exc).__name__}: {exc}") + else: + raise AssertionError("expected TypeError") + # --8<-- [end:error-builtin-sum] + + # --8<-- [start:error-unnamed] + # An aggregate outside the dict form has no output name. + try: + Transaction.select(lambda transaction: transaction.amount.sum()) + except TypeError as exc: + print(f"{type(exc).__name__}: {exc}") + else: + raise AssertionError("expected TypeError") + # --8<-- [end:error-unnamed] + + # --8<-- [start:error-order-key] + # Sorting groups by a column that is not a group key raises. + try: + Transaction.select( + lambda transaction: { + "acct": transaction.account_id, + "total": transaction.amount.sum(), + } + ).order_by("memo") + except ValueError as exc: + print(f"{type(exc).__name__}: {exc}") + else: + raise AssertionError("expected ValueError") + # --8<-- [end:error-order-key] + + # --8<-- [start:error-count] + # count() on an aggregate projection is ambiguous: rows or groups? + try: + Transaction.select( + lambda transaction: { + "acct": transaction.account_id, + "total": transaction.amount.sum(), + } + ).count() + except ValueError as exc: + print(f"{type(exc).__name__}: {exc}") + else: + raise AssertionError("expected ValueError") + # --8<-- [end:error-count] - print("aggregations_annotated example ran successfully") + print("aggregations example ran successfully") if __name__ == "__main__": diff --git a/docs/examples/concepts_annotated.py b/docs/examples/concepts_annotated.py index 65220052..1bc47841 100644 --- a/docs/examples/concepts_annotated.py +++ b/docs/examples/concepts_annotated.py @@ -1,16 +1,18 @@ -"""Annotated-style companion to concepts.py (AGENTS.md I-7). - -Declares the Concepts pages' ``User`` with field options inside -``Annotated[...]``, adds the same four users and checks that the -architecture page's query and the identity map page's guarantee hold for -these models too. +"""Runnable companion to the Concepts pages (docs/pages/concepts/). + +Declares the ``User`` model that the architecture, identity map, type safety +and performance pages share, adds four users and runs the blocks those pages +embed: the query the architecture page follows through each layer, one row +resolving to one instance, sessionless loads, a connection with the +identity map turned off, and the backends page's connect-and-query block. The ``concepts`` prelude +(``_preludes/concepts.py``) imports the model and the rows from here for the +pages' inline blocks. """ import asyncio - -# --8<-- [start:models] from typing import Annotated +# --8<-- [start:models] from ferro import Field, Model @@ -24,7 +26,7 @@ class User(Model): # --8<-- [end:models] -from ferro import connect, engines # noqa: E402 +from ferro import connect, engines, reset_engine # noqa: E402 USERS = [ {"username": "ada", "email": "ada@example.com", "age": 36}, @@ -34,10 +36,15 @@ class User(Model): ] -async def main() -> None: - await connect("sqlite::memory:", auto_migrate=True) +async def seed(model: type[User] = User) -> None: + """Add the four users, in their own session.""" async with engines.session(): - await User.bulk_create([User(**row) for row in USERS]) + await model.bulk_create([model(**row) for row in USERS]) + + +async def query() -> None: + # --8<-- [start:query] + from ferro import engines async with engines.session(): users = await ( @@ -47,10 +54,105 @@ async def main() -> None: .all() ) assert [user.username for user in users] == ["grace", "ada", "alan"] + # --8<-- [end:query] + + +async def refresh_on_load() -> None: + # --8<-- [start:refresh-on-load] + from ferro import engines, execute + + async with engines.session(): + ada = await User.get(1) + + # Something else (another request, a script, raw SQL) changes the + # row without going through `ada`. + await execute('UPDATE "user" SET email = ? WHERE id = ?', "ada@lovelace.dev", 1) + + again = await User.get(1) + assert again is ada # the same object + assert ada.email == "ada@lovelace.dev" # refreshed in place + + # An unsaved edit does not survive a re-fetch: the database wins. + ada.email = "unsaved edit" + await User.get(1) + assert ada.email == "ada@lovelace.dev" + # --8<-- [end:refresh-on-load] + + +async def sessionless() -> None: + # --8<-- [start:sessionless] + # `using=` alone: no session, so no identity map. + first = await User.using("default").get(2) + second = await User.using("default").get(2) + assert first is not second + assert first.email == second.email == "grace@example.com" - ada = await User.where(lambda user: user.username == "ada").first() - assert await User.get(1) is ada - print("concepts_annotated: ok") + # No session and no `using=`: Ferro has no route for the call. + try: + await User.get(2) + except RuntimeError as error: + assert str(error).startswith("No database route for this operation.") + else: + raise AssertionError("a call with no route must raise RuntimeError") + # --8<-- [end:sessionless] + + +async def without_identity_map() -> None: + reset_engine() + # --8<-- [start:opt-out] + from ferro import connect, engines + + await connect("sqlite::memory:", auto_migrate=True, identity_map=False) + + async with engines.session(): + ada = await User.create(username="ada", email="ada@example.com", age=36) + first = await User.get(ada.id) + second = await User.get(ada.id) + assert first is not ada + assert first is not second # no map, even inside a session + # --8<-- [end:opt-out] + + +async def backends(url: str = "sqlite::memory:") -> None: + reset_engine() + # --8<-- [start:backends] + from ferro import connect, engines + + await connect(url, auto_migrate=True) + + async with engines.session(): + await User.create(username="ada", email="ada@example.com", age=36) + adults = await User.where(lambda user: user.age >= 18).all() + assert [user.username for user in adults] == ["ada"] + # --8<-- [end:backends] + + +async def unsupported_scheme() -> None: + reset_engine() + # --8<-- [start:unsupported-scheme] + from ferro import InterfaceError, connect + + try: + await connect("mysql://user:password@localhost:3306/app") + except InterfaceError as error: + print(f"InterfaceError: {error}") + else: + raise AssertionError("expected InterfaceError") + # --8<-- [end:unsupported-scheme] + + +async def main() -> None: + # --8<-- [start:connect] + await connect("sqlite::memory:", auto_migrate=True) + # --8<-- [end:connect] + await seed() + await query() + await refresh_on_load() + await sessionless() + await without_identity_map() + await backends() + await unsupported_scheme() + print("concepts: ok") if __name__ == "__main__": diff --git a/docs/examples/faq_annotated.py b/docs/examples/faq_annotated.py index 7ec352e0..38e40032 100644 --- a/docs/examples/faq_annotated.py +++ b/docs/examples/faq_annotated.py @@ -1,8 +1,4 @@ -"""Annotated-style companion to faq.py (AGENTS.md I-7). - -Same models as docs/examples/faq.py, with the field options inside -``Annotated[...]`` and the defaults assigned. -""" +"""Runnable companion to the FAQ (docs/pages/faq.md): the Author and Post models.""" import asyncio @@ -49,7 +45,7 @@ async def main() -> None: # --8<-- [end:databases] assert (len(authors), len(analytics_authors)) == (1, 0) - print("faq annotated example ran successfully") + print("faq example ran successfully") if __name__ == "__main__": diff --git a/docs/examples/from_sqlalchemy_annotated.py b/docs/examples/from_sqlalchemy_annotated.py index 8e22a4d5..4f2863f8 100644 --- a/docs/examples/from_sqlalchemy_annotated.py +++ b/docs/examples/from_sqlalchemy_annotated.py @@ -1,4 +1,4 @@ -"""Annotated twin of from_sqlalchemy.py for the SQLAlchemy migration how-to (docs/pages/howto/migrate-from-sqlalchemy.md).""" +"""Runnable companion to the SQLAlchemy migration how-to (docs/pages/howto/migrate-from-sqlalchemy.md).""" import asyncio diff --git a/docs/examples/migrations_backfill_annotated.py b/docs/examples/migrations_backfill_annotated.py index 1b95bc92..7023203a 100644 --- a/docs/examples/migrations_backfill_annotated.py +++ b/docs/examples/migrations_backfill_annotated.py @@ -1,6 +1,5 @@ # ruff: noqa: E402 - the models come first, as the docs page shows them -"""Runnable companion for backfills and guards, Annotated style -(docs/pages/guide/schema/data-steps.md). +"""Runnable companion for backfills and guards (docs/pages/guide/schema/data-steps.md). A required column added to a table that has rows: the guard refuses, the backfill is written where it says todo(...), the late-row recovery is diff --git a/docs/examples/migrations_quickstart_annotated.py b/docs/examples/migrations_quickstart_annotated.py index 1c94b443..d0e04318 100644 --- a/docs/examples/migrations_quickstart_annotated.py +++ b/docs/examples/migrations_quickstart_annotated.py @@ -1,6 +1,5 @@ # ruff: noqa: E402 - the models come first, as the docs page shows them -"""Runnable companion for the Migrations loop, Annotated style -(docs/pages/guide/schema/migrations.md). +"""Runnable companion for the Migrations loop (docs/pages/guide/schema/migrations.md). init, new, up; then a model edit, check, new, up, drift, down, on SQLite. """ diff --git a/docs/examples/migrations_rename_annotated.py b/docs/examples/migrations_rename_annotated.py index fb85c910..ebaaeab3 100644 --- a/docs/examples/migrations_rename_annotated.py +++ b/docs/examples/migrations_rename_annotated.py @@ -1,5 +1,5 @@ # ruff: noqa: E402, F811 - the models come first, as the docs page shows them: before, then after -"""Runnable companion for rename hints, Annotated style +"""Runnable companion for rename hints (docs/pages/guide/schema/renames-and-retirements.md). A table, a column and an enum label renamed in one migration, applied over a diff --git a/docs/examples/migrations_retire_annotated.py b/docs/examples/migrations_retire_annotated.py index 77daff12..defb179c 100644 --- a/docs/examples/migrations_retire_annotated.py +++ b/docs/examples/migrations_retire_annotated.py @@ -1,5 +1,5 @@ # ruff: noqa: E402 - the models come first, as the docs page shows them -"""Runnable companion for retiring a column, Annotated style +"""Runnable companion for retiring a column (docs/pages/guide/schema/renames-and-retirements.md). Release A declares ``bio`` required. Release B retires it: the migration only diff --git a/docs/examples/migrations_retire_label_annotated.py b/docs/examples/migrations_retire_label_annotated.py index ff52d1bb..f7fc697e 100644 --- a/docs/examples/migrations_retire_label_annotated.py +++ b/docs/examples/migrations_retire_label_annotated.py @@ -1,5 +1,5 @@ # ruff: noqa: E402 - the models come first, as the docs page shows them -"""Runnable companion for retiring an enum label, Annotated style +"""Runnable companion for retiring an enum label (docs/pages/guide/schema/renames-and-retirements.md). Release A writes ``paused``. Release B retires it: the member stays so rows diff --git a/docs/examples/migrations_squash_annotated.py b/docs/examples/migrations_squash_annotated.py index f51479e0..5e2266df 100644 --- a/docs/examples/migrations_squash_annotated.py +++ b/docs/examples/migrations_squash_annotated.py @@ -1,6 +1,5 @@ # ruff: noqa: E402, F811 - the models come first, as the docs page shows them, once per task -"""Runnable companion for squashing, Annotated style -(docs/pages/guide/schema/squash.md). +"""Runnable companion for squashing (docs/pages/guide/schema/squash.md). Two tasks on one branch: the first renames ``author.nickname`` to ``handle`` with a rename hint, the second deletes the hint, now inert, and adds ``Team``. diff --git a/docs/examples/migrations_testing_annotated.py b/docs/examples/migrations_testing_annotated.py index 220c5b24..0336c55e 100644 --- a/docs/examples/migrations_testing_annotated.py +++ b/docs/examples/migrations_testing_annotated.py @@ -1,6 +1,5 @@ # ruff: noqa: E402, F811 - the models come first, and each of the page's files imports what it uses -"""Runnable companion for testing migrations, Annotated style -(docs/pages/guide/schema/testing.md). +"""Runnable companion for testing migrations (docs/pages/guide/schema/testing.md). Generates two migrations (the second backfills a new required column), then runs the page's tests under pytest, in the project, the way a reader runs diff --git a/docs/examples/models_and_fields_annotated.py b/docs/examples/models_and_fields_annotated.py index 4a80ae35..bc131371 100644 --- a/docs/examples/models_and_fields_annotated.py +++ b/docs/examples/models_and_fields_annotated.py @@ -1,8 +1,9 @@ -"""Annotated-style companion to models_and_fields.py (AGENTS.md I-7). +"""Runnable companion to the Models and fields guide (docs/pages/guide/models-and-fields.md). -The same models, with each ``Field(...)`` moved inside ``Annotated[...]`` and -the default kept as the assigned value. ``main()`` checks that they create -the same schema, statement for statement, as the assignment-style models. +A product catalog. ``Product`` is the running example; ``Category``, +``Order`` and ``Review`` join it in the sections that need a model of their +own. Each SQL block on the page is a section of ``DDL`` below, and ``main()`` +checks ``DDL`` against the statements ``create_tables()`` ran. """ # --8<-- [start:program] @@ -22,12 +23,16 @@ class Status(StrEnum): class Product(Model): + # --8<-- [start:composite-uniques] __ferro_composite_uniques__: ClassVar[tuple[tuple[str, ...], ...]] = ( ("category", "name"), ) + # --8<-- [end:composite-uniques] + # --8<-- [start:composite-indexes] __ferro_composite_indexes__: ClassVar[tuple[tuple[str, ...], ...]] = ( ("status", "created_at"), ) + # --8<-- [end:composite-indexes] # --8<-- [start:primary-key] id: Annotated[int | None, Field(primary_key=True)] = None @@ -40,12 +45,14 @@ class Product(Model): # --8<-- [start:validation-fields] price: Annotated[Decimal, Field(ge=0, decimal_places=2)] # --8<-- [end:validation-fields] + # --8<-- [start:enum] status: Status = Status.DRAFT + # --8<-- [end:enum] # --8<-- [start:json] - attributes: Annotated[dict[str, str], Field(default_factory=dict)] - tags: Annotated[list[str], Field(default_factory=list)] + attributes: dict[str, str] = Field(default_factory=dict) + tags: list[str] = Field(default_factory=list) # --8<-- [end:json] - created_at: Annotated[datetime, Field(default_factory=lambda: datetime.now(UTC))] + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) async def main() -> None: @@ -62,27 +69,28 @@ async def main() -> None: assert lamp.id == 1 # the database assigned it assert lamp.status is Status.DRAFT # --8<-- [end:program] - assert _assignment_style_ddl() == sorted( + assert _statements(DDL) == sorted( _normalized(statement.sql) for statement in report.statements ) - await Category.create(code="lighting", title="Lighting") - order = await Order.create(total=Decimal("68.40")) - assert (await Order.get(order.id)).total == Decimal("68.40") - await Review.create(product_sku="LMP-001", rating=5) - assert await Timestamped.select().count() == 0 + await tour(lamp) - print("models_and_fields_annotated example ran successfully") + print("models_and_fields example ran successfully") -import ast # noqa: E402 import re # noqa: E402 import uuid # noqa: E402 -from pathlib import Path # noqa: E402 +from typing import Annotated # noqa: E402 + +from pydantic import ValidationError # noqa: E402 + +from ferro import UniqueViolationError, fetch_one # noqa: E402 # --8<-- [start:category] class Category(Model): + # --8<-- [start:table-name] __ferro_table__: ClassVar[str] = "categories" + # --8<-- [end:table-name] code: Annotated[str, Field(primary_key=True)] # a natural key, such as "lighting" title: str @@ -93,7 +101,9 @@ class Category(Model): # --8<-- [start:order] class Order(Model): - id: Annotated[uuid.UUID, Field(default_factory=uuid.uuid4, primary_key=True)] + id: Annotated[uuid.UUID, Field(primary_key=True)] = Field( + default_factory=uuid.uuid4 + ) total: Decimal @@ -102,7 +112,7 @@ class Order(Model): # --8<-- [start:base-class] class Timestamped(Model): - created_at: Annotated[datetime, Field(default_factory=lambda: datetime.now(UTC))] + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) class Review(Timestamped): @@ -114,24 +124,148 @@ class Review(Timestamped): # --8<-- [end:base-class] +async def tour(lamp: Product) -> None: + # --8<-- [start:save] + floor_lamp = Product( + sku="LMP-002", + name="Floor lamp", + category="lighting", + price=Decimal("129.00"), + ) + assert floor_lamp.id is None + await floor_lamp.save() + assert floor_lamp.id == 2 + # --8<-- [end:save] + + # --8<-- [start:json-round-trip] + shade = await Product.create( + sku="SHD-001", + name="Linen shade", + category="lighting", + price=Decimal("24.50"), + attributes={"colour": "oat", "fabric": "linen"}, + tags=["bedroom", "living room"], + ) + stored = await Product.where(lambda product: product.sku == "SHD-001").first() + assert stored.attributes == {"colour": "oat", "fabric": "linen"} + assert stored.tags == ["bedroom", "living room"] + # --8<-- [end:json-round-trip] + assert stored is shade + + # --8<-- [start:enum-value] + lamp.status = Status.ACTIVE + await lamp.save() + row = await fetch_one('SELECT "status" FROM "product" WHERE "id" = ?', lamp.id) + assert row == {"status": "active"} + # --8<-- [end:enum-value] + + await Category.create(code="lighting", title="Lighting") + assert (await Category.get("lighting")).title == "Lighting" + + # --8<-- [start:uuid-value] + order = await Order.create(total=Decimal("68.40")) + row = await fetch_one('SELECT "id" FROM "order"') + assert row == {"id": str(order.id)} # 36 characters, hyphens included + # --8<-- [end:uuid-value] + + # --8<-- [start:composite-unique-violation] + try: + await Product.create( + sku="LMP-003", + name="Desk lamp", + category="lighting", + price=Decimal("42.00"), + ) + except UniqueViolationError as exc: + assert "UNIQUE constraint failed: product.category, product.name" in str(exc) + # --8<-- [end:composite-unique-violation] + else: + raise AssertionError("expected UniqueViolationError") + + # --8<-- [start:validation] + try: + await Product.create( + sku="LMP-004", + name="Clamp lamp", + category="lighting", + price=Decimal("-5.00"), + ) + except ValidationError as exc: + [error] = exc.errors() + assert error["loc"] == ("price",) + assert error["msg"] == "Input should be greater than or equal to 0" + # --8<-- [end:validation] + else: + raise AssertionError("expected ValidationError") + + # --8<-- [start:base-class-rows] + await Review.create(product_sku="LMP-001", rating=5) + assert await Review.select().count() == 1 + assert await Timestamped.select().count() == 0 # a table of its own + # --8<-- [end:base-class-rows] + + +DDL = """ +# --8<-- [start:product-ddl] +# --8<-- [start:product-table] +CREATE TABLE IF NOT EXISTS "product" ( + "attributes" JSON NOT NULL, + "category" varchar NOT NULL, + "created_at" DATETIME NOT NULL, + "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, +# --8<-- [start:name-column] + "name" varchar(200) NOT NULL, +# --8<-- [end:name-column] + "price" NUMERIC NOT NULL, + "sku" varchar NOT NULL, +# --8<-- [start:status-column] + "status" varchar(7) NOT NULL, +# --8<-- [end:status-column] + "tags" JSON NOT NULL +); +# --8<-- [end:product-table] +# --8<-- [start:sku-unique] +CREATE UNIQUE INDEX IF NOT EXISTS "uq_product_sku" ON "product" ("sku"); +# --8<-- [end:sku-unique] +# --8<-- [start:composite-unique-ddl] +CREATE UNIQUE INDEX IF NOT EXISTS "uq_product_category_name" ON "product" ("category", "name"); +# --8<-- [end:composite-unique-ddl] +# --8<-- [start:composite-index-ddl] +CREATE INDEX IF NOT EXISTS "idx_product_status_created_at" ON "product" ("status", "created_at"); +# --8<-- [end:composite-index-ddl] +# --8<-- [end:product-ddl] +# --8<-- [start:category-ddl] +CREATE TABLE IF NOT EXISTS "categories" ( + "code" varchar NOT NULL PRIMARY KEY, + "title" varchar NOT NULL +); +# --8<-- [end:category-ddl] +# --8<-- [start:order-ddl] +CREATE TABLE IF NOT EXISTS "order" ( + "id" CHAR(32) NOT NULL PRIMARY KEY, + "total" NUMERIC NOT NULL +); +# --8<-- [end:order-ddl] +# --8<-- [start:base-class-ddl] +CREATE TABLE IF NOT EXISTS "timestamped" ( + "created_at" DATETIME NOT NULL +); +CREATE TABLE IF NOT EXISTS "review" ( + "created_at" DATETIME NOT NULL, + "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, + "product_sku" varchar NOT NULL, + "rating" integer NOT NULL +); +# --8<-- [end:base-class-ddl] +""" + + def _normalized(statement: str) -> str: return re.sub(r"\s+", " ", statement).strip() -def _assignment_style_ddl() -> list[str]: - """The ``DDL`` string of models_and_fields.py, read without importing it - (importing would register its models beside these).""" - source = (Path(__file__).parent / "models_and_fields.py").read_text( - encoding="utf-8" - ) - [ddl] = [ - node.value.value - for node in ast.parse(source).body - if isinstance(node, ast.Assign) - and [target.id for target in node.targets if isinstance(target, ast.Name)] - == ["DDL"] - and isinstance(node.value, ast.Constant) - ] +def _statements(ddl: str) -> list[str]: + """The statements of ``ddl``, snippet markers left out, one line each.""" text = "\n".join(line for line in ddl.splitlines() if "--8<--" not in line) return sorted(_normalized(sql) for sql in text.split(";") if sql.strip()) diff --git a/docs/examples/multiple_databases_annotated.py b/docs/examples/multiple_databases_annotated.py index 1a1447e6..08a14552 100644 --- a/docs/examples/multiple_databases_annotated.py +++ b/docs/examples/multiple_databases_annotated.py @@ -1,16 +1,11 @@ -"""Annotated-style companion to multiple_databases.py (AGENTS.md I-7). - -Same ``Metric`` model as docs/examples/multiple_databases.py, declared with -``Annotated`` metadata instead of assignment. -""" +"""Runnable companion to the multiple-databases how-to (docs/pages/howto/multiple-databases.md).""" import asyncio +from typing import Annotated -from ferro import connect, engines +from ferro import connect, create_tables, engines, transaction # --8<-- [start:models] -from typing import Annotated - from ferro import Field, Model @@ -24,17 +19,66 @@ class Metric(Model): async def main() -> None: + # --8<-- [start:connect] await connect("sqlite::memory:", name="app", default=True, auto_migrate=True) await connect("sqlite::memory:", name="analytics", auto_migrate=True) async with engines.session(): - await Metric.create(name="signups", value=12) + await Metric.create(name="signups", value=12) # the default, "app" + app_metrics = await Metric.all() + await Metric.using("analytics").create(name="page_views", value=1840) + analytics_metrics = await Metric.using("analytics").all() + + print(len(app_metrics), len(analytics_metrics)) + # --8<-- [end:connect] + assert [metric.name for metric in app_metrics] == ["signups"] + assert [metric.name for metric in analytics_metrics] == ["page_views"] + + # --8<-- [start:session] + async with engines.session("analytics") as session: + busy = ( + await session.query(Metric).where(lambda metric: metric.value > 100).all() + ) + + print([metric.name for metric in busy]) + # --8<-- [end:session] + assert [metric.name for metric in busy] == ["page_views"] + + # --8<-- [start:conflict] + async with engines.session(): + try: + await Metric.using("analytics").all() + except ValueError as error: + print(error) + else: + raise AssertionError("expected ValueError") + # --8<-- [end:conflict] + + # --8<-- [start:transaction] + async with transaction(using="analytics"): + await Metric.create(name="clicks", value=42) # runs on "analytics" + try: + await Metric.using("app").create(name="clicks", value=42) + except ValueError as error: + print(error) + else: + raise AssertionError("expected ValueError") + + print(len(await Metric.using("analytics").all())) + # --8<-- [end:transaction] + async with engines.session(): + assert len(await Metric.all()) == 1 + + # --8<-- [start:create-tables] + await connect("sqlite::memory:", name="archive") + report = await create_tables(using="archive") - assert len(await Metric.using("app").all()) == 1 - assert len(await Metric.using("analytics").all()) == 1 + print([statement.subject for statement in report.statements]) + # --8<-- [end:create-tables] + assert len(await Metric.using("archive").all()) == 0 - print("multiple_databases_annotated example ran successfully") + print("multiple_databases example ran successfully") if __name__ == "__main__": diff --git a/docs/examples/mutations_annotated.py b/docs/examples/mutations_annotated.py index 0a2c3527..57e2ef38 100644 --- a/docs/examples/mutations_annotated.py +++ b/docs/examples/mutations_annotated.py @@ -1,13 +1,8 @@ -"""Annotated-style companion to mutations.py (AGENTS.md I-7). - -Same ``Customer`` model as docs/examples/mutations.py, declared with -``Annotated`` metadata instead of assignment. Field options move into -``Annotated[...]``; the default stays as the assigned value. -""" +"""Runnable companion to the Mutations guide (docs/pages/guide/mutations.md).""" import asyncio -from ferro import UniqueViolationError, connect, engines +from ferro import ModelDoesNotExist, UniqueViolationError, connect, engines, now # --8<-- [start:models] from datetime import datetime @@ -31,27 +26,219 @@ class Customer(Model): # --8<-- [start:conversation] class Conversation(Model): id: Annotated[int | None, Field(primary_key=True)] = None - turns: Annotated[dict, Field(default_factory=dict)] + turns: dict = Field(default_factory=dict) # --8<-- [end:conversation] async def main() -> None: + # --8<-- [start:session] await connect("sqlite::memory:", auto_migrate=True) async with engines.session(): alice = await Customer.create(email="alice@example.com", name="Alice") assert (alice.id, alice.plan) == (1, "free") - + # --8<-- [end:session] + + # --8<-- [start:create] + bob = await Customer.create(email="bob@example.com", name="Bob", plan="pro") + + inserted = await Customer.bulk_create( + [ + Customer(email="carol@example.com", name="Carol"), + Customer(email="dave@example.com", name="Dave", plan="pro"), + ] + ) + assert inserted == 2 + assert await Customer.select().count() == 4 + # --8<-- [end:create] + + # --8<-- [start:bulk-identity] + erin = Customer(email="erin@example.com", name="Erin") + await Customer.bulk_create([erin]) + assert erin.id is None # not written back + + erin = await Customer.where( + lambda customer: customer.email == "erin@example.com" + ).first() + assert erin.id == 5 + # --8<-- [end:bulk-identity] + + # --8<-- [start:create-strict] try: - await Customer.create(email="alice@example.com") + await Customer.create(email="alice@example.com", name="Alice again") except UniqueViolationError: - ... # email is unique in this style too + ... # the existing row is untouched + else: + raise AssertionError("expected UniqueViolationError") + assert (await Customer.get(alice.id)).name == "Alice" + # --8<-- [end:create-strict] + + # --8<-- [start:save-insert-update] + frank = Customer(email="frank@example.com", name="Frank") + await frank.save() # never persisted: INSERT + assert frank.id is not None # the database assigned it + + frank.plan = "pro" + await frank.save() # persisted: UPDATE by primary key + + franks = await Customer.where(lambda customer: customer.name == "Frank").count() + assert franks == 1 # two saves, one row + # --8<-- [end:save-insert-update] + + # --8<-- [start:save-missing] + await Customer.where(lambda customer: customer.id == frank.id).delete() + frank.name = "Franklin" + try: + await frank.save() + except ModelDoesNotExist: + ... # the row is gone; save() does not insert it again + else: + raise AssertionError("expected ModelDoesNotExist") + # --8<-- [end:save-missing] + + # --8<-- [start:save-only] + row = await Customer.get(bob.id) + row.name = "Robert" + row.updated_at = datetime(2026, 3, 1, 9, 30) + row.plan = "enterprise" # not in only=, so not written + await row.save(only={"name", "updated_at"}) + assert row.plan == "enterprise" # the instance keeps its change + + await row.refresh() + assert (row.name, row.plan) == ("Robert", "pro") # the row kept "pro" + # --8<-- [end:save-only] + + # --8<-- [start:save-exclude] + row.name = "Bobby" + row.plan = "enterprise" # excluded, so not written + await row.save(exclude={"plan"}) + + await row.refresh() + assert (row.name, row.plan) == ("Bobby", "pro") + # --8<-- [end:save-exclude] + + # --8<-- [start:update-delete] + upgraded = await Customer.where( + lambda customer: customer.plan == "free" + ).update(plan="trial") + assert upgraded == 3 # Alice, Carol and Erin + + removed = await Customer.where( + lambda customer: customer.name == "Erin" + ).delete() + assert removed == 1 + # --8<-- [end:update-delete] + + # --8<-- [start:recipe-update] + await Customer.where(lambda customer: customer.id == alice.id).update( + lambda customer: { + "views": customer.views + 1, + "updated_at": now, + } + ) + + await alice.refresh() + assert alice.views == 1 and alice.updated_at is not None + # --8<-- [end:recipe-update] + + # .merge() exists on PostgreSQL only; the rendered statement says + # which database this session runs on. + on_postgres = (await Customer.select().count.sql()).dialect == "postgres" + if on_postgres: + # --8<-- [start:merge] + chat = await Conversation.create() + await Conversation.where( + lambda conversation: conversation.id == chat.id + ).update( + lambda conversation: { + "turns": conversation.turns.merge({"r1": {"role": "user"}}) + } + ) + + await chat.refresh() + assert chat.turns == {"r1": {"role": "user"}} + # --8<-- [end:merge] + + # --8<-- [start:mutation-guard] + trial = Customer.where(lambda customer: customer.plan == "trial") + try: + await trial.limit(1).delete() + except ValueError as error: + print(error) + else: + raise AssertionError("expected ValueError") + # --8<-- [end:mutation-guard] + + # --8<-- [start:mutation-by-key] + oldest = await trial.order_by(lambda customer: customer.id).limit(1).all() + ids = [customer.id for customer in oldest] + removed = await Customer.where(lambda customer: customer.id.in_(ids)).delete() + assert removed == 1 # Alice, the oldest trial customer + # --8<-- [end:mutation-by-key] + + # --8<-- [start:delete-one] + carol = await Customer.where( + lambda customer: customer.email == "carol@example.com" + ).first() + await carol.delete() + + assert await Customer.get_or_none(carol.id) is None + # --8<-- [end:delete-one] + + # --8<-- [start:upsert] + grace = await Customer.upsert(id=100, email="grace@example.com", name="Grace") + + grace = await Customer.upsert(id=100, email="grace@example.com", plan="pro") + assert grace.plan == "pro" + assert await Customer.where(lambda customer: customer.id == 100).count() == 1 + + stored = await Customer.get(100) + assert (stored.name, stored.plan) == ("", "pro") # name reset to its default + # --8<-- [end:upsert] + + # --8<-- [start:get-or-create] + heidi, created = await Customer.get_or_create( + email="heidi@example.com", + defaults={"name": "Heidi"}, + ) + assert created is True + + same, created = await Customer.get_or_create(email="heidi@example.com") + assert created is False and same.id == heidi.id + # --8<-- [end:get-or-create] + + # --8<-- [start:update-or-create] + heidi, created = await Customer.update_or_create( + email="heidi@example.com", + defaults={"plan": "pro"}, + ) + assert created is False and heidi.plan == "pro" + # --8<-- [end:update-or-create] + + # --8<-- [start:refresh] + await Customer.where(lambda customer: customer.id == heidi.id).update( + name="Heidi L." + ) + assert heidi.name == "Heidi" # the instance has not seen the update + + await heidi.refresh() + assert heidi.name == "Heidi L." + # --8<-- [end:refresh] + + # --8<-- [start:handling-errors] + try: + await Customer.create(email="heidi@example.com") # email already taken + except UniqueViolationError as error: + print(error.sqlstate) + print(error.constraint) + print(error.driver_message) else: raise AssertionError("expected UniqueViolationError") + # --8<-- [end:handling-errors] - print("mutations_annotated example ran successfully") + print("mutations example ran successfully") if __name__ == "__main__": diff --git a/docs/examples/pagination_annotated.py b/docs/examples/pagination_annotated.py index 7d0d452f..24d81fb8 100644 --- a/docs/examples/pagination_annotated.py +++ b/docs/examples/pagination_annotated.py @@ -1,4 +1,9 @@ -"""Annotated-style companion to pagination.py (AGENTS.md I-7).""" +"""Runnable companion to the pagination how-to (docs/pages/howto/pagination.md). + +Seven articles, newest first, paged three at a time: by page number with +``offset()``, by position with ``after()`` and ``before()``, and through +``article_page()``, the function the page's FastAPI endpoint calls. +""" import asyncio @@ -18,6 +23,54 @@ class Article(Model): # --8<-- [end:model] +# --8<-- [start:offset] +async def get_page(page: int, per_page: int) -> list[Article]: + return ( + await Article.select() + .order_by(lambda article: article.published_on, "desc") + .order_by(lambda article: article.id, "desc") + .limit(per_page) + .offset((page - 1) * per_page) + .all() + ) + + +# --8<-- [end:offset] + + +# --8<-- [start:cursor] +NEWEST_FIRST = ( + Article.select() + .order_by(lambda article: article.published_on, "desc") + .order_by(lambda article: article.id, "desc") +) + + +def encode_cursor(article: Article) -> str: + published_on, article_id = NEWEST_FIRST.position_of(article) + return f"{published_on.isoformat()}_{article_id}" + + +def decode_cursor(cursor: str) -> tuple[date, int]: + published_on, article_id = cursor.split("_") + return date.fromisoformat(published_on), int(article_id) + + +async def article_page(cursor: str | None = None, limit: int = 20) -> dict: + query = NEWEST_FIRST.limit(limit + 1) + if cursor is not None: + query = query.after(decode_cursor(cursor)) + rows = await query.all() + items = rows[:limit] + return { + "items": items, + "next_cursor": encode_cursor(items[-1]) if len(rows) > limit else None, + } + + +# --8<-- [end:cursor] + + async def main() -> None: # --8<-- [start:session] await connect("sqlite::memory:", auto_migrate=True) @@ -36,21 +89,66 @@ async def main() -> None: ) # --8<-- [end:session] + # --8<-- [start:offset-call] + page_two = await get_page(page=2, per_page=3) + assert [article.title for article in page_two] == [ + "Keyset paging", + "Reading a query plan", + "Indexes that pay off", + ] + # --8<-- [end:offset-call] + + # --8<-- [start:after] newest = ( Article.select() .order_by(lambda article: article.published_on, "desc") .order_by(lambda article: article.id, "desc") .limit(3) ) + first_page = await newest.all() + assert [article.title for article in first_page] == [ + "Testing with SQLite", + "Storing UTC", + "Soft deletes", + ] + second_page = await newest.after(first_page[-1]).all() assert [article.title for article in second_page] == [ "Keyset paging", "Reading a query plan", "Indexes that pay off", ] + # --8<-- [end:after] + + # --8<-- [start:before] + previous_page = await newest.before(second_page[0]).all() + assert previous_page == first_page + # --8<-- [end:before] + + # --8<-- [start:cursor-calls] + first = await article_page(limit=3) + assert first["next_cursor"] == "2026-01-26_5" + + second = await article_page(cursor=first["next_cursor"], limit=3) + assert second["next_cursor"] == "2026-01-12_2" + + last = await article_page(cursor=second["next_cursor"], limit=3) + assert [article.title for article in last["items"]] == [ + "Choosing a primary key" + ] + assert last["next_cursor"] is None + # --8<-- [end:cursor-calls] + + # A page that ends exactly on the last row reports no next page. + exact = await article_page(cursor="2026-01-12_3", limit=2) + assert [article.title for article in exact["items"]] == [ + "Indexes that pay off", + "Choosing a primary key", + ] + assert exact["next_cursor"] is None - print("pagination_annotated example ran successfully") + print("pagination example ran successfully") if __name__ == "__main__": diff --git a/docs/examples/predicates_annotated.py b/docs/examples/predicates_annotated.py index 6ae0e848..21bef9f6 100644 --- a/docs/examples/predicates_annotated.py +++ b/docs/examples/predicates_annotated.py @@ -1,10 +1,9 @@ -"""Annotated-style companion to predicates.py (AGENTS.md I-7). +"""Runnable companion to the Queries guide (docs/pages/guide/queries.md). -Declares the Queries guide's ledger with field options inside -``Annotated[...]``, adds the same rows and checks that a few of the guide's -queries return what they return against predicates.py's models. Foreign keys -and ``BackRef()`` / ``ManyToMany()`` have one form and read the same in both -files. +Declares the ledger every section of the guide queries, adds its rows +(``_ledger_seed.py``), then runs the filtering, ordering, paging and terminal +examples and asserts the rows that come back. The guide's other example +scripts import these models from here. """ import asyncio @@ -83,42 +82,290 @@ class Tag(Model): async def main() -> None: from _ledger_seed import seed + # --8<-- [start:first-query] from ferro import connect, engines await connect("sqlite::memory:", auto_migrate=True) async with engines.session(): - await seed(globals()) + await seed(globals()) # adds the rows this page queries large = ( await Transaction.where(lambda transaction: transaction.amount <= -100) .order_by(lambda transaction: transaction.amount) .all() ) + for transaction in large: + print(transaction.memo, transaction.amount) + # --8<-- [end:first-query] assert [transaction.memo for transaction in large] == ["Rent", "To savings"] - on_ada_accounts = await Transaction.where( - lambda transaction: transaction.account.owner.name == "Ada Lovelace" - ).count() - assert on_ada_accounts == 6 + # --8<-- [start:filtering] + uncleared = await Transaction.where( + lambda transaction: transaction.cleared_on == None + ).all() + assert {transaction.memo for transaction in uncleared} == { + "Train to Lyon", + "Dinner in Lyon", + "Weekly shop", + } + # --8<-- [end:filtering] + + # --8<-- [start:typo] + try: + Transaction.where(lambda transaction: transaction.amout < 0) + except AttributeError as error: + assert str(error) == ( + "Transaction has no queryable column 'amout'. Did you mean " + "'amount'? Valid columns: account_id, amount, category_id, " + "cleared_on, id, memo, posted_on. Valid relations: account, " + "category." + ) + else: + raise AssertionError("a misspelled column must raise AttributeError") + # --8<-- [end:typo] - split = await Transaction.where( - lambda transaction: transaction.lines.exists() + # --8<-- [start:operators] + coffee = await Transaction.where( + lambda transaction: transaction.memo.like("%coffee") ).all() - assert [transaction.memo for transaction in split] == ["Weekly shop"] + assert {transaction.memo for transaction in coffee} == {"Office coffee"} - reimbursable = await Transaction.where( - lambda transaction: transaction.tags.exists( - lambda tag: tag.name == "reimbursable" + lyon = await Transaction.where( + lambda transaction: transaction.posted_on.in_( + [date(2026, 3, 9), date(2026, 3, 10)] + ) + ).all() + assert {transaction.memo for transaction in lyon} == { + "Train to Lyon", + "Dinner in Lyon", + } + # --8<-- [end:operators] + + # --8<-- [start:combining] + # & is AND, | is OR: parenthesize each side. + small_uncleared = await Transaction.where( + lambda transaction: ( + (transaction.cleared_on == None) & (transaction.amount > -90) + ) + ).all() + assert {transaction.memo for transaction in small_uncleared} == { + "Train to Lyon", + "Dinner in Lyon", + } + + # Chained where() calls AND together. + march_debits = ( + await Transaction.where(lambda transaction: transaction.amount < 0) + .where(lambda transaction: transaction.posted_on >= date(2026, 3, 10)) + .all() + ) + assert {transaction.memo for transaction in march_debits} == { + "Dinner in Lyon", + "Office coffee", + "Weekly shop", + } + # --8<-- [end:combining] + + # --8<-- [start:negation] + not_lyon = await Transaction.where( + lambda transaction: ( + ~transaction.memo.like("%Lyon") & (transaction.amount < -50) ) + ).all() + assert {transaction.memo for transaction in not_lyon} == { + "Rent", + "Market run", + "To savings", + "Weekly shop", + } + # --8<-- [end:negation] + + # --8<-- [start:three-valued] + before_march_5 = await Transaction.where( + lambda transaction: transaction.cleared_on < date(2026, 3, 5) + ).all() + not_before_march_5 = await Transaction.where( + lambda transaction: ~(transaction.cleared_on < date(2026, 3, 5)) + ).all() + assert {transaction.memo for transaction in before_march_5} == { + "Salary", + "Rent", + } + # The three uncleared rows are in neither list. + assert {transaction.memo for transaction in not_before_march_5} == { + "Market run", + "To savings", + "From checking", + "Office coffee", + } + # --8<-- [end:three-valued] + + # --8<-- [start:keep-nulls] + not_before_or_uncleared = await Transaction.where( + lambda transaction: ( + ~(transaction.cleared_on < date(2026, 3, 5)) + | (transaction.cleared_on == None) + ) + ).all() + assert len(not_before_or_uncleared) == 7 + # --8<-- [end:keep-nulls] + + # --8<-- [start:get] + from ferro import ModelDoesNotExist + + rent = await Transaction.get(2) + assert rent.memo == "Rent" + + try: + await Transaction.get(99) + except ModelDoesNotExist as error: + assert (error.model, error.pk) == (Transaction, 99) + else: + raise AssertionError("a missing row must raise ModelDoesNotExist") + + assert await Transaction.get_or_none(99) is None + # --8<-- [end:get] + + # --8<-- [start:ordering-slicing] + largest_debits = ( + await Transaction.select() + .order_by(lambda transaction: transaction.amount) + .limit(3) + .all() + ) + assert [transaction.memo for transaction in largest_debits] == [ + "Rent", + "To savings", + "Weekly shop", + ] + + next_three = ( + await Transaction.select() + .order_by(lambda transaction: transaction.amount) + .limit(3) + .offset(3) + .all() + ) + assert [transaction.memo for transaction in next_three] == [ + "Market run", + "Train to Lyon", + "Dinner in Lyon", + ] + # --8<-- [end:ordering-slicing] + + # --8<-- [start:nulls-ordering] + uncleared_first = ( + await Transaction.select() + .order_by(lambda transaction: transaction.cleared_on, "desc", nulls="first") + .order_by(lambda transaction: transaction.id) + .limit(4) + .all() + ) + assert [transaction.memo for transaction in uncleared_first] == [ + "Train to Lyon", + "Dinner in Lyon", + "Weekly shop", + "Office coffee", + ] + # --8<-- [end:nulls-ordering] + + # --8<-- [start:after-paging] + by_date = ( + Transaction.select() + .order_by(lambda transaction: transaction.posted_on) + .order_by(lambda transaction: transaction.id) + ) + first_page = await by_date.limit(3).all() + second_page = await by_date.after(first_page[-1]).limit(3).all() + assert [transaction.memo for transaction in first_page] == [ + "Salary", + "Rent", + "Market run", + ] + assert [transaction.memo for transaction in second_page] == [ + "To savings", + "From checking", + "Train to Lyon", + ] + # --8<-- [end:after-paging] + + # --8<-- [start:position-of] + position = by_date.position_of(first_page[-1]) + assert position == (date(2026, 3, 5), 3) + + same_page = await by_date.after(position).limit(3).all() + assert same_page == second_page + # --8<-- [end:position-of] + + # --8<-- [start:before-paging] + previous_page = await by_date.before(second_page[0]).limit(2).all() + assert [transaction.memo for transaction in previous_page] == [ + "Rent", + "Market run", + ] + + everything_earlier = await by_date.before(second_page[0]).all() + assert [transaction.memo for transaction in everything_earlier] == [ + "Salary", + "Rent", + "Market run", + ] + + previous_row = await by_date.before(second_page[0]).first() + assert previous_row is not None and previous_row.memo == "Market run" + # --8<-- [end:before-paging] + + # --8<-- [start:after-null-paging] + by_clearing = ( + Transaction.select() + .order_by(lambda transaction: transaction.cleared_on, "desc") + .order_by(lambda transaction: transaction.id) + ) + rest_of_uncleared = await by_clearing.after((None, 6)).all() + assert [transaction.memo for transaction in rest_of_uncleared] == [ + "Dinner in Lyon", + "Weekly shop", + ] + # --8<-- [end:after-null-paging] + + # --8<-- [start:terminals] + everything = await Transaction.all() + assert len(everything) == 9 + + newest = ( + await Transaction.select() + .order_by(lambda transaction: transaction.posted_on, "desc") + .first() + ) + assert newest is not None and newest.memo == "Weekly shop" + + uncleared_count = await Transaction.where( + lambda transaction: transaction.cleared_on == None ).count() - assert reimbursable == 2 + assert uncleared_count == 3 + + any_refunds = await Transaction.where( + lambda transaction: transaction.memo.like("Refund%") + ).exists() + assert any_refunds is False + # --8<-- [end:terminals] - under_food = await Category.where( - lambda category: category.parent.name == "Food" + # --8<-- [start:immutability] + debits = Transaction.where(lambda transaction: transaction.amount < 0) + + debit_count = await debits.count() + biggest = await debits.order_by(lambda transaction: transaction.amount).first() + uncleared_debits = await debits.where( + lambda transaction: transaction.cleared_on == None ).count() - assert under_food == 2 - print("predicates_annotated example ran successfully") + assert debit_count == 7 + assert biggest is not None and biggest.memo == "Rent" + assert uncleared_debits == 3 + assert await debits.count() == 7 # debits itself never changed + # --8<-- [end:immutability] + + print("predicates example ran successfully") if __name__ == "__main__": diff --git a/docs/examples/quickstart_annotated.py b/docs/examples/quickstart_annotated.py index 6510d0e3..0d2b614d 100644 --- a/docs/examples/quickstart_annotated.py +++ b/docs/examples/quickstart_annotated.py @@ -1,14 +1,11 @@ -"""Annotated-style companion to quickstart.py (AGENTS.md I-7). - -Same models as docs/examples/quickstart.py, declared with ``Annotated`` -metadata instead of assignment. Field options move into ``Annotated[...]``; -the default stays as the assigned value. Relationship declarations are -identical in both styles. -""" +"""Runnable companion to the Quickstart tutorial (docs/pages/getting-started/quickstart.md).""" +# --8<-- [start:imports] import asyncio -from ferro import connect, engines +from ferro import connect, engines, transaction + +# --8<-- [end:imports] # --8<-- [start:models] from datetime import UTC, datetime @@ -29,7 +26,7 @@ class Post(Model): title: str body: str published: bool = False - created_at: Annotated[datetime, Field(default_factory=lambda: datetime.now(UTC))] + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) author: Annotated[Author, ForeignKey(related_name="posts")] @@ -37,20 +34,99 @@ class Post(Model): async def main() -> None: + # --8<-- [start:connect] await connect("sqlite::memory:", auto_migrate=True) async with engines.session(): + assert await Author.select().count() == 0 + # --8<-- [end:connect] + + # --8<-- [start:create] alice = await Author.create(name="Alice", email="alice@example.com") post = await Post.create( - title="Hello", body="First post.", published=True, author=alice + title="Why Ferro is fast", + body="Ferro hands SQL generation and row hydration to a Rust engine.", + published=True, + author=alice, ) - - assert post.id == 1 - assert (await post.author).email == "alice@example.com" - published = await alice.posts.where(lambda post: post.published == True).all() - assert len(published) == 1 - - print("quickstart_annotated example ran successfully") + assert post.id == 1 # the database assigned it + + await Post.bulk_create( + [ + Post( + title="Async patterns", + body="One event loop, many queries.", + published=True, + author=alice, + ), + Post( + title="Unfinished draft", + body="Still to write.", + author=alice, + ), + ] + ) + assert await Post.select().count() == 3 + # --8<-- [end:create] + + # --8<-- [start:get] + same_post = await Post.get(post.id) + assert same_post is post # one row, one object + # --8<-- [end:get] + + # --8<-- [start:filter] + published = ( + await Post.where(lambda post: post.published == True) + .order_by(lambda post: post.title) + .limit(10) + .all() + ) + assert [post.title for post in published] == [ + "Async patterns", + "Why Ferro is fast", + ] + # --8<-- [end:filter] + + # --8<-- [start:aggregate] + total = await Post.select().count() + has_drafts = await Post.where(lambda post: post.published == False).exists() + assert (total, has_drafts) == (3, True) + # --8<-- [end:aggregate] + + # --8<-- [start:relationships] + author = await post.author + assert author.name == "Alice" + + alice_posts = await author.posts.where( + lambda post: post.published == True + ).all() + assert len(alice_posts) == 2 + # --8<-- [end:relationships] + + # --8<-- [start:update-delete] + post.title = "Why Ferro is really fast" + await post.save() + assert (await Post.get(post.id)).title == "Why Ferro is really fast" + + updated = await Post.where(lambda post: post.published == False).update( + published=True + ) + assert updated == 1 + + deleted = await Post.where( + lambda post: post.title == "Unfinished draft" + ).delete() + assert deleted == 1 + # --8<-- [end:update-delete] + + # --8<-- [start:transaction] + async with transaction(): + bob = await Author.create(name="Bob", email="bob@example.com") + await Post.create(title="Hello", body="First post.", author=bob) + assert await Author.select().count() == 2 # both writes committed + # --8<-- [end:transaction] + + print("quickstart example ran successfully") if __name__ == "__main__": diff --git a/docs/examples/raw_sql_annotated.py b/docs/examples/raw_sql_annotated.py index 0e50bb91..77d3b751 100644 --- a/docs/examples/raw_sql_annotated.py +++ b/docs/examples/raw_sql_annotated.py @@ -1,14 +1,15 @@ -"""Annotated-style companion to raw_sql.py (AGENTS.md I-7). +"""Runnable companion to the Raw SQL guide (docs/pages/guide/raw-sql.md). -Declares the same ``Event`` model with its field options inside -``Annotated[...]`` and checks it creates the same ``event`` table. +Every block on the page uses the ``event`` table this script declares and +fills; the ``raw_sql`` prelude (``docs/examples/_preludes/raw_sql.py``) +imports both for the page's inline blocks. """ import asyncio from datetime import UTC, datetime from typing import Annotated -from ferro import Field, Model, connect, engines, fetch_all +from ferro import Field, Model # --8<-- [start:models] @@ -21,18 +22,72 @@ class Event(Model): # --8<-- [end:models] +async def add_events() -> None: + """Add the page's three events: two clicks and a signup.""" + await Event.bulk_create( + [ + Event(kind="click", occurred_at=datetime(2026, 3, 2, 9, 0, tzinfo=UTC)), + Event(kind="signup", occurred_at=datetime(2026, 3, 2, 9, 5, tzinfo=UTC)), + Event(kind="click", occurred_at=datetime(2026, 3, 2, 9, 30, tzinfo=UTC)), + ] + ) + + async def main() -> None: + # --8<-- [start:first] + from ferro import connect, engines, fetch_all + await connect("sqlite::memory:", auto_migrate=True) async with engines.session(): - await Event.create( - kind="click", occurred_at=datetime(2026, 3, 2, 9, 0, tzinfo=UTC) + await add_events() # two clicks and a signup + + counts = await fetch_all( + "SELECT kind, COUNT(*) AS n FROM event GROUP BY kind ORDER BY n DESC" ) - rows = await fetch_all("SELECT id, kind, occurred_at FROM event") - assert rows == [ - {"id": 1, "kind": "click", "occurred_at": "2026-03-02T09:00:00Z"} - ] + print(counts) + # --8<-- [end:first] + assert counts == [{"kind": "click", "n": 2}, {"kind": "signup", "n": 1}] + + # --8<-- [start:fetch-one] + from ferro import fetch_one + + earliest = await fetch_one( + "SELECT kind, occurred_at FROM event ORDER BY occurred_at LIMIT 1" + ) + assert earliest == {"kind": "click", "occurred_at": "2026-03-02T09:00:00Z"} + + nothing = await fetch_one("SELECT kind FROM event WHERE kind = ?", "purchase") + assert nothing is None + # --8<-- [end:fetch-one] + + # --8<-- [start:execute] + from ferro import execute + + renamed = await execute( + "UPDATE event SET kind = ? WHERE kind = ?", "tap", "click" + ) + assert renamed == 2 + # --8<-- [end:execute] + + # --8<-- [start:in-transaction] + from ferro import transaction + + async with transaction() as tx: + await tx.execute("DELETE FROM event WHERE kind = ?", "tap") + remaining = await tx.fetch_all("SELECT id, kind FROM event") + assert remaining == [{"id": 2, "kind": "signup"}] + # --8<-- [end:in-transaction] + + # --8<-- [start:no-session] + try: + await fetch_all("SELECT kind FROM event") + except RuntimeError as error: + assert str(error).startswith("No database route for this operation.") + else: + raise AssertionError("raw SQL outside a session must raise RuntimeError") + # --8<-- [end:no-session] - print("raw_sql_annotated example ran successfully") + print("raw_sql example ran successfully") if __name__ == "__main__": diff --git a/docs/examples/readme_quickstart_annotated.py b/docs/examples/readme_quickstart_annotated.py index 60c14cf4..5192a7ae 100644 --- a/docs/examples/readme_quickstart_annotated.py +++ b/docs/examples/readme_quickstart_annotated.py @@ -1,10 +1,13 @@ -"""Annotated-style companion to readme_quickstart.py (AGENTS.md I-7).""" +"""The README quick start (README.md), runnable. -import asyncio +tests/test_docs_examples.py checks that the README's Python block is this +file's code, marker comments aside, and runs it. +""" -# --8<-- [start:models] +import asyncio from typing import Annotated +# --8<-- [start:models] from ferro import Field, Model, connect, engines @@ -26,7 +29,6 @@ async def main(): await User.create(username="bob", is_active=False) active_users = await User.where(lambda user: user.is_active == True).all() - assert [user.username for user in active_users] == ["alice"] print(f"Found {len(active_users)} active users.") # --8<-- [end:query] diff --git a/docs/examples/relationships_annotated.py b/docs/examples/relationships_annotated.py index b3c984b1..b49a2b49 100644 --- a/docs/examples/relationships_annotated.py +++ b/docs/examples/relationships_annotated.py @@ -1,26 +1,15 @@ -"""Annotated-style companion to relationships.py (AGENTS.md I-7). +"""Runnable companion to the Relationships guide (docs/pages/guide/relationships.md). -The roster's field options move into ``Annotated[...]``. The relationship -declarations are the same in both styles: forward foreign keys are always -``Annotated[Target, ForeignKey(...)]`` and ``BackRef()``/``ManyToMany()`` -are always assignments. ``main()`` checks this declaration creates the same -tables as the assignment one. +One roster runs through the whole page: teams have players, a player may +have a profile and a mentor, players enter tournaments, and a tournament +may have a winning team. Every section's block is a range of ``main()``, +in page order, so later sections build on the rows earlier ones added. """ import asyncio from typing import Annotated -from ferro import ( - BackRef, - Field, - ForeignKey, - ManyToMany, - Model, - Relation, - connect, - create_tables, - engines, -) +from ferro import BackRef, Field, ForeignKey, ManyToMany, Model, Relation # --8<-- [start:models] @@ -62,34 +51,158 @@ class Tournament(Model): # --8<-- [end:models] +def table_ddl(report, table: str) -> list[str]: + """The statements ``create_tables()`` ran for one table, in order.""" + return [ + statement.sql for statement in report.statements if statement.subject == table + ] + + async def main() -> None: + # --8<-- [start:first] + from ferro import connect, create_tables, engines + await connect("sqlite::memory:") - report = await create_tables() - assert [statement.subject for statement in report.statements] == [ - "team", - "tournament", - "player", - "player", - "profile", - "profile", - "player_tournaments", - "player_tournaments", - "player_tournaments", - ] + report = await create_tables() # creates the tables and lists what it ran async with engines.session(): rustaceans = await Team.create(name="Rustaceans") ferris = await Player.create(name="Ferris", team=rustaceans) - crabby = await Player.create(name="Crabby", team=rustaceans, mentor=ferris) + + team = await ferris.team # forward: runs one query + roster = await rustaceans.players.all() # reverse: a query you run + print(team.name, [player.name for player in roster]) + # --8<-- [end:first] + assert (team.name, [player.name for player in roster]) == ( + "Rustaceans", + ["Ferris"], + ) + + # --8<-- [start:one-to-many] + corro = await Player.create(name="Corro", team=rustaceans) + gophers = await Team.create(name="Gophers") + gordon = await Player.create(name="Gordon", team=gophers) + + roster = await rustaceans.players.order_by(lambda player: player.name).all() + assert [player.name for player in roster] == ["Corro", "Ferris"] + # --8<-- [end:one-to-many] + + # --8<-- [start:shadow-column] + assert ferris.team_id == rustaceans.id # read without a query + + on_rustaceans = await Player.where( + lambda player: player.team_id == rustaceans.id + ).count() + assert on_rustaceans == 2 + # --8<-- [end:shadow-column] + + # --8<-- [start:player-ddl] + for statement in report.statements: + if statement.subject == "player": + print(statement.sql) + # --8<-- [end:player-ddl] + # The page shows these statements whole, on both databases + # (tests/test_docs_sql.py); here, the names each relation gives them. + player_ddl = "\n".join(table_ddl(report, "player")) + assert 'CONSTRAINT "fk_player_mentor_id_player"' in player_ddl + assert 'CONSTRAINT "fk_player_team_id_team"' in player_ddl + assert '"idx_player_team_id" ON "player" ("team_id")' in player_ddl + + # --8<-- [start:populated] + players = ( + await Player.select() + .include(lambda player: player.team) + .order_by(lambda player: player.name) + .all() + ) + for player in players: + print(player.name, "|", player.team.name) # no await + # --8<-- [end:populated] + assert [(player.name, player.team.name) for player in players] == [ + ("Corro", "Rustaceans"), + ("Ferris", "Rustaceans"), + ("Gordon", "Gophers"), + ] + + # --8<-- [start:one-to-one] await Profile.create(bio="Plays in the front row", player=ferris) + + profile = await ferris.profile # one instance, not a list + assert profile.bio == "Plays in the front row" + assert await corro.profile is None # Corro has no profile + # --8<-- [end:one-to-one] + # unique=True gives the shadow column a unique index. + assert ( + 'CREATE UNIQUE INDEX IF NOT EXISTS "uq_profile_player_id" ON "profile" ' + '("player_id")' + ) in table_ddl(report, "profile") + + # --8<-- [start:many-to-many] spring = await Tournament.create(name="Spring Open") - await ferris.tournaments.add(spring) + autumn = await Tournament.create(name="Autumn Cup") + + await ferris.tournaments.add(spring, autumn) + await gordon.tournaments.add(spring) + + entered = await ferris.tournaments.order_by( + lambda tournament: tournament.name + ).all() + assert [tournament.name for tournament in entered] == [ + "Autumn Cup", + "Spring Open", + ] + + field = await spring.players.order_by(lambda player: player.name).all() + assert [player.name for player in field] == ["Ferris", "Gordon"] + # --8<-- [end:many-to-many] + + # --8<-- [start:m2m-remove] + await ferris.tournaments.remove(autumn) + entered = await ferris.tournaments.all() + assert [tournament.name for tournament in entered] == ["Spring Open"] + + await ferris.tournaments.clear() + assert await ferris.tournaments.count() == 0 + assert await spring.players.count() == 1 # Gordon is still entered + # --8<-- [end:m2m-remove] + + # --8<-- [start:join-table-ddl] + for statement in report.statements: + if statement.subject == "player_tournaments": + print(statement.sql) + # --8<-- [end:join-table-ddl] + link_ddl = "\n".join(table_ddl(report, "player_tournaments")) + assert 'CONSTRAINT "fk_player_tournaments_player_id_player"' in link_ddl + assert '"idx_player_tournaments_tournament_id_player_id"' in link_ddl + assert '"uq_player_tournaments_player_id_tournament_id"' in link_ddl + + # --8<-- [start:self-referential] + crabby = await Player.create(name="Crabby", team=rustaceans, mentor=ferris) assert (await crabby.mentor).name == "Ferris" - assert (await ferris.profile).bio == "Plays in the front row" - assert [player.name for player in await spring.players.all()] == ["Ferris"] + mentees = await ferris.mentees.all() + assert [player.name for player in mentees] == ["Crabby"] + assert ferris.mentor_id is None # Ferris has no mentor + # --8<-- [end:self-referential] + + # --8<-- [start:nullable] + assert autumn.winner_id is None # no winner yet + + spring.winner_id = rustaceans.id # set the shadow column, then save + await spring.save() + titles = await rustaceans.titles.all() + assert [tournament.name for tournament in titles] == ["Spring Open"] + # --8<-- [end:nullable] + + # --8<-- [start:on-delete] + await rustaceans.delete() + + assert [player.name for player in await Player.select().all()] == ["Gordon"] + assert await Profile.select().count() == 0 + assert (await Tournament.get(spring.id)).winner_id is None + # --8<-- [end:on-delete] - print("relationships_annotated example ran successfully") + print("relationships example ran successfully") if __name__ == "__main__": diff --git a/docs/examples/row_level_security_annotated.py b/docs/examples/row_level_security_annotated.py index c3348e0b..929de53c 100644 --- a/docs/examples/row_level_security_annotated.py +++ b/docs/examples/row_level_security_annotated.py @@ -1,6 +1,19 @@ -"""Annotated-style companion to row_level_security.py (AGENTS.md I-7). +"""Runnable companion for the row-level security pages. -Runs the same way: no arguments for SQLite, or `` ``. +Embedded by docs/pages/guide/row-level-security.md, +docs/pages/concepts/row-level-security.md and +docs/pages/reference/row-level-security.md. + +Row-level security is a Postgres feature, so the pages' demo needs Postgres: + + python docs/examples/row_level_security.py + +connects to ```` as the migrating role (a superuser, or a role +with BYPASSRLS), creates the tables, creates ```` as the +application's NOSUPERUSER login role, and checks what each session sees. +``tests/test_docs_examples.py`` runs it that way when a Postgres server is +configured. With no arguments it runs on SQLite, where Ferro creates the +tables without their policies and warns. """ import asyncio @@ -223,7 +236,7 @@ async def main(argv: list[str]) -> None: await on_postgres(migrator_url, role) else: await on_sqlite() - print("row_level_security_annotated example ran successfully") + print("row_level_security example ran successfully") if __name__ == "__main__": diff --git a/docs/examples/soft_deletes_annotated.py b/docs/examples/soft_deletes_annotated.py index aa1075ae..3679240d 100644 --- a/docs/examples/soft_deletes_annotated.py +++ b/docs/examples/soft_deletes_annotated.py @@ -1,4 +1,4 @@ -"""Annotated-style companion to soft_deletes.py (AGENTS.md I-7).""" +"""Runnable companion to the soft-deletes how-to (docs/pages/howto/soft-deletes.md).""" import asyncio @@ -52,14 +52,49 @@ async def main() -> None: await Invoice.create(number="INV-003") # --8<-- [end:session] + # --8<-- [start:usage] await invoice.soft_delete() assert await Invoice.active().count() == 2 assert await Invoice.select().count() == 3 await invoice.restore() assert await Invoice.active().count() == 3 + # --8<-- [end:usage] - print("soft_deletes_annotated example ran successfully") + await invoice.soft_delete() + + # --8<-- [start:querying] + live = await ( + Invoice.active() + .where(lambda invoice: invoice.number >= "INV-002") + .order_by(lambda invoice: invoice.number) + .all() + ) + assert [invoice.number for invoice in live] == ["INV-002", "INV-003"] + + trashed = await Invoice.where(lambda invoice: invoice.is_deleted == True).all() + assert [invoice.number for invoice in trashed] == ["INV-001"] + # --8<-- [end:querying] + + # --8<-- [start:unique] + from ferro import UniqueViolationError + + try: + await Invoice.create(number="INV-001") + except UniqueViolationError: + print("INV-001 is still taken") + else: + raise AssertionError("expected UniqueViolationError") + # --8<-- [end:unique] + + # --8<-- [start:purge] + soft_deleted = Invoice.where(lambda invoice: invoice.is_deleted == True) + purged = await soft_deleted.delete() + assert purged == 1 + assert await Invoice.select().count() == 2 + # --8<-- [end:purge] + + print("soft_deletes example ran successfully") if __name__ == "__main__": diff --git a/docs/examples/table_checks_annotated.py b/docs/examples/table_checks_annotated.py index 24c93fb2..b2a00623 100644 --- a/docs/examples/table_checks_annotated.py +++ b/docs/examples/table_checks_annotated.py @@ -1,9 +1,23 @@ -"""Annotated-style companion to table_checks.py (AGENTS.md I-7).""" +"""Runnable companion for table checks (docs/pages/guide/models-and-fields.md). + +A payment carries a card token or a bank account, never both. The SQL block +on the page is the ``DDL`` section below, checked against the statement +``create_tables()`` ran. +""" import asyncio +import re from typing import Annotated, ClassVar -from ferro import Check, CheckViolationError, Field, Model, connect, engines +from ferro import ( + Check, + CheckViolationError, + Field, + Model, + connect, + create_tables, + engines, +) # --8<-- [start:models] @@ -24,13 +38,34 @@ class Payment(Model): # --8<-- [end:models] +DDL = """ +# --8<-- [start:ddl] +CREATE TABLE IF NOT EXISTS "payment" ( + "bank_account" varchar, + "card_token" varchar, + "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, + CONSTRAINT "ck_payment_at_most_one_method" CHECK (("card_token" IS NULL) OR ("bank_account" IS NULL)) +); +# --8<-- [end:ddl] +""" + + +def _normalized(sql: str) -> str: + return re.sub(r"\s+", " ", sql).strip().rstrip(";").strip() + async def main() -> None: - await connect("sqlite::memory:", auto_migrate=True) + await connect("sqlite::memory:") + report = await create_tables() + [created] = [statement.sql for statement in report.statements] + expected = "\n".join(line for line in DDL.splitlines() if "--8<--" not in line) + assert _normalized(created) == _normalized(expected), created async with engines.session(): await Payment.create(card_token="tok_visa_4242") + await Payment.create(bank_account="GB33BUKB20201555555555") + # --8<-- [start:violation] try: await Payment.create( card_token="tok_visa_4242", @@ -38,10 +73,13 @@ async def main() -> None: ) except CheckViolationError as exc: assert "ck_payment_at_most_one_method" in str(exc) + # --8<-- [end:violation] else: raise AssertionError("expected CheckViolationError") - print("table_checks_annotated example ran successfully") + assert await Payment.select().count() == 2 + + print("table_checks example ran successfully") if __name__ == "__main__": diff --git a/docs/examples/testing_models_annotated.py b/docs/examples/testing_models_annotated.py index 6bc26825..9aeba433 100644 --- a/docs/examples/testing_models_annotated.py +++ b/docs/examples/testing_models_annotated.py @@ -1,4 +1,9 @@ -"""Annotated-style companion to testing_models.py (AGENTS.md I-7).""" +"""The models of the testing how-to (docs/pages/howto/testing.md). + +The page's readers keep these in ``myapp/models.py``; +``tests/test_docs_testing_fixtures.py`` copies this file there and runs the +page's tests against it. +""" import asyncio @@ -34,7 +39,7 @@ async def main() -> None: post = await Post.create(title="Notes", content="On the engine", author=ada) assert (await post.author).username == "ada" - print("testing_models_annotated example ran successfully") + print("testing_models example ran successfully") if __name__ == "__main__": diff --git a/docs/examples/timestamps_annotated.py b/docs/examples/timestamps_annotated.py index 9e4c780c..798fa9f6 100644 --- a/docs/examples/timestamps_annotated.py +++ b/docs/examples/timestamps_annotated.py @@ -1,6 +1,15 @@ -"""Annotated-style companion to timestamps.py (AGENTS.md I-7).""" +"""Runnable companion to the timestamps how-to (docs/pages/howto/timestamps.md). + +Besides the recipe, it pins on SQLite that a batch ``update()`` that writes +``utcnow()`` stores the same text ``save()`` stores, so rows touched either +way sort together by ``updated_at`` and read back as aware datetimes +(AGENTS.md I-13). +""" import asyncio +import re + +from ferro import fetch_all # --8<-- [start:model] from datetime import UTC, datetime @@ -28,8 +37,8 @@ async def save(self, **kwargs) -> None: class Note(TimestampMixin, Model): id: Annotated[int | None, Field(primary_key=True)] = None text: str - created_at: Annotated[datetime, Field(default_factory=utcnow)] - updated_at: Annotated[datetime, Field(default_factory=utcnow)] + created_at: datetime = Field(default_factory=utcnow) + updated_at: datetime = Field(default_factory=utcnow) # --8<-- [end:model] @@ -44,12 +53,49 @@ async def main() -> None: assert note.created_at.tzinfo is UTC # --8<-- [end:session] + # --8<-- [start:usage] + created = note.created_at edited_before = note.updated_at + note.text = "Second draft" await note.save() - assert note.updated_at > edited_before - print("timestamps_annotated example ran successfully") + assert note.created_at == created + assert note.updated_at > edited_before + # --8<-- [end:usage] + + # --8<-- [start:batch] + archived = await Note.create(text="Old idea") + await Note.create(text="Shopping list") + + touched = await Note.where(lambda note: note.text == "Old idea").update( + text="Old idea (archived)", updated_at=utcnow() + ) + assert touched == 1 + + latest = ( + await Note.select().order_by(lambda note: note.updated_at, "desc").first() + ) + assert latest.text == "Old idea (archived)" + assert latest.updated_at.tzinfo is not None + # --8<-- [end:batch] + + # SQLite stores datetimes as text. The batch update wrote the same + # text shape save() writes (I-13), so the rows sort by time, and + # every value reads back aware, comparable with the mixin's clock. + stored = await fetch_all('SELECT text, updated_at FROM "note"') + shapes = {re.sub(r"\d", "9", row["updated_at"]) for row in stored} + assert len(shapes) == 1, shapes + rows = await Note.select().order_by(lambda note: note.updated_at).all() + assert [row.text for row in rows] == [ + "Second draft", + "Shopping list", + "Old idea (archived)", + ] + assert archived.id == rows[-1].id + assert all(row.updated_at <= utcnow() for row in rows) + + print("timestamps example ran successfully") if __name__ == "__main__": diff --git a/docs/examples/transactions_annotated.py b/docs/examples/transactions_annotated.py index 3deba78f..856fbfb7 100644 --- a/docs/examples/transactions_annotated.py +++ b/docs/examples/transactions_annotated.py @@ -1,16 +1,11 @@ -"""Annotated-style companion to transactions.py (AGENTS.md I-7). - -Same ``Account`` model as docs/examples/transactions.py, declared with -``Annotated`` metadata instead of assignment. -""" +"""Runnable companion to the Transactions guide (docs/pages/guide/transactions.md).""" import asyncio +from typing import Annotated from ferro import IntegrityError, connect, engines, transaction # --8<-- [start:models] -from typing import Annotated - from ferro import Field, Model @@ -24,24 +19,110 @@ class Account(Model): # --8<-- [end:models] +async def quote_interest(account: Account) -> int: + """Stand-in for a slow call made outside the transaction.""" + await asyncio.sleep(0) + return account.balance // 50 + + async def main() -> None: await connect("sqlite::memory:", auto_migrate=True) + # --8<-- [start:basic] async with engines.session(): + checking = await Account.create(number="CHK-001", owner="Alice", balance=100) + savings = await Account.create(number="SAV-001", owner="Alice", balance=0) + async with transaction(): - await Account.create(number="CHK-001", owner="Alice", balance=100) + checking.balance -= 25 + await checking.save() - try: - async with transaction(): - await Account.create(number="CHK-001", owner="Bob") - except IntegrityError: - ... # number is unique in this style too - else: - raise AssertionError("expected IntegrityError") + savings.balance += 25 + await savings.save() - assert await Account.select().count() == 1 + assert (checking.balance, savings.balance) == (75, 25) + # --8<-- [end:basic] - print("transactions_annotated example ran successfully") + # --8<-- [start:rollback] + try: + async with transaction(): + checking.balance -= 1000 + await checking.save() + raise RuntimeError("insufficient funds") + except RuntimeError: + ... # the block raised, so everything in it rolled back + + assert checking.balance == -925 # the Python object kept the change + + await checking.refresh() + assert checking.balance == 75 # the row did not + # --8<-- [end:rollback] + + # --8<-- [start:transfer] + async def transfer(src: Account, dst: Account, amount: int) -> bool: + try: + async with transaction(): + src.balance -= amount + await src.save() + if src.balance < 0: + raise ValueError("insufficient funds") + dst.balance += amount + await dst.save() + except ValueError: + await src.refresh() # undo the in-memory change too + return False + return True + + assert await transfer(checking, savings, 1000) is False + assert (checking.balance, savings.balance) == (75, 25) + + assert await transfer(checking, savings, 50) is True + assert (checking.balance, savings.balance) == (25, 75) + # --8<-- [end:transfer] + + # --8<-- [start:nested] + opened = [] + async with transaction(): + for number in ["CHK-002", "CHK-001", "SAV-002"]: + try: + async with transaction(): # a savepoint for each account + await Account.create(number=number, owner="Bob") + opened.append(number) + except IntegrityError: + ... # CHK-001 is taken: only this savepoint rolled back + + assert opened == ["CHK-002", "SAV-002"] + assert await Account.where(lambda account: account.owner == "Bob").count() == 2 + # --8<-- [end:nested] + + # --8<-- [start:handle] + async with transaction() as tx: + await Account.create(number="CHK-003", owner="Carol") + rows = await tx.fetch_all("SELECT COUNT(*) AS n FROM account") + + assert rows[0]["n"] == 5 # the count saw Carol's uncommitted row + # --8<-- [end:handle] + + # --8<-- [start:immediate] + async with transaction(immediate=True): + savings = await Account.get(savings.id) # read, then write + savings.balance += 10 + await savings.save() + + assert savings.balance == 85 + # --8<-- [end:immediate] + + # --8<-- [start:short] + interest = await quote_interest(savings) # slow work first + + async with transaction(): # then a short write + savings.balance += interest + await savings.save() + + assert savings.balance == 86 + # --8<-- [end:short] + + print("transactions example ran successfully") if __name__ == "__main__": diff --git a/docs/examples/upgrade_account_annotated.py b/docs/examples/upgrade_account_annotated.py index 2ab63b53..caa87c59 100644 --- a/docs/examples/upgrade_account_annotated.py +++ b/docs/examples/upgrade_account_annotated.py @@ -1,8 +1,8 @@ """The running example of the Upgrade guide (docs/pages/upgrading.md). -One ``Account`` model, declared in the Annotated style, and the session a -current release runs its queries in. The assignment twin is -``upgrade_account.py``. +One ``Account`` model, declared in the assignment style, and the session a +current release runs its queries in. The Annotated twin is +``upgrade_account_annotated.py``. """ import asyncio @@ -29,7 +29,7 @@ class Account(Model): age: int active: bool = True seen_at: datetime | None = None - prefs: Annotated[dict, Field(default_factory=dict)] + prefs: dict = Field(default_factory=dict) # --8<-- [end:models] diff --git a/docs/examples/upgrade_keep_annotated.py b/docs/examples/upgrade_keep_annotated.py index cc77afdf..e65146aa 100644 --- a/docs/examples/upgrade_keep_annotated.py +++ b/docs/examples/upgrade_keep_annotated.py @@ -3,7 +3,7 @@ ``Account`` with the three fields that opt out of a derived storage type that changed: a naive timestamp, an enum stored as text with a check, and plain-JSON -storage. The assignment twin is ``upgrade_keep.py``. +storage. The Annotated twin is ``upgrade_keep_annotated.py``. """ import asyncio @@ -30,7 +30,7 @@ class Account(Model): role: Annotated[Role, Field(db_type="text", db_check=True)] = Role.MEMBER # --8<-- [end:keep-varchar] # --8<-- [start:keep-json] - prefs: Annotated[dict, Field(db_type="json", default_factory=dict)] + prefs: Annotated[dict, Field(db_type="json")] = Field(default_factory=dict) # --8<-- [end:keep-json] diff --git a/docs/examples/upgrade_primary_key_annotated.py b/docs/examples/upgrade_primary_key_annotated.py index 5e97cba1..6c26aee5 100644 --- a/docs/examples/upgrade_primary_key_annotated.py +++ b/docs/examples/upgrade_primary_key_annotated.py @@ -2,7 +2,7 @@ (docs/pages/upgrading.md). A model with two ``primary_key=True`` fields is refused at class definition. -The assignment twin is ``upgrade_primary_key.py``. +The Annotated twin is ``upgrade_primary_key_annotated.py``. """ from typing import Annotated diff --git a/docs/solutions/patterns/docs-page-conventions.md b/docs/solutions/patterns/docs-page-conventions.md index 548eccaf..549230c6 100644 --- a/docs/solutions/patterns/docs-page-conventions.md +++ b/docs/solutions/patterns/docs-page-conventions.md @@ -1,13 +1,18 @@ --- -title: Docs page conventions (kind, running example, results, session, SQL, glossary, Annotated twins) +title: Docs page conventions (kind, running example, results, printed output, session, SQL, glossary, Annotated twins) type: pattern tags: [docs, convention, zensical, pydantic] related_files: - AGENTS.md - docs/examples/ - docs/includes/abbreviations.md + - tests/_docs_harness.py + - tests/_docs_sql.py + - tests/_docs_twins.py - tests/test_docs_examples.py + - tests/test_docs_output.py - tests/test_docs_sql.py + - tests/test_docs_twins.py - zensical.toml related_issues: [698, 700, 701, 703, 704, 725] related_prs: [] @@ -69,6 +74,35 @@ assert [user.name for user in adults] == ["Ada", "Grace"] # --8<-- [end:filtering] ``` +### Printed output is regenerated, never typed + +A ```text block shows what something printed, and says where that came from +in an HTML comment on the line directly above it, at the fence's indentation: + +````markdown + +```text +Rent -1450 +To savings -500 +``` +```` + +| Note | The block is | Checked by | +| --- | --- | --- | +| `` | What that snippet region prints | `tests/test_docs_output.py`, byte for byte | +| `` | What the nearest Python fence above prints; that fence is marked `{test=run prelude=}` | `tests/test_docs_output.py`, byte for byte | +| ``, `` | What `ferro migrate` prints | `tests/test_docs_migrations.py` | +| `` | Text no script prints: a shell session, a file tree, an agent prompt, or an error from a scenario no example runs | Nothing; the note says what it is | + +`tests/test_docs_output.py` refuses a ```text block with no note. To add one +under an embedded example, put the `print` inside the snippet region (a +region that catches an error prints `f"{type(exc).__name__}: {exc}"`), write +the note and an empty fence, and fill the fence from the failure message. A +printed line belongs to the innermost line of the example inside a region on +the call stack, so a helper defined outside the region can do the printing. +Prefer `prints:` to `shows:`: an error a page quotes is better raised by an +example region than typed. + ### The session wrapper Every ORM call runs inside `async with engines.session():` and raises @@ -107,7 +141,11 @@ that sends nothing but prints statements (a `create_tables()` report's `tests/test_docs_sql.py` regenerates every `sql` fence on the pages it lists (`PAGES`: Queries, Aggregations, Relationships, Mutations) and compares byte -for byte, failing with the page, the line and the example. To add a block: +for byte, failing with the page, the line and the example. The capture and +the checks live in `tests/_docs_sql.py` (`captured(example, dialect, url)`, +`check_block`, `check_printed`), which a new gate reuses; one run of an +example records both the SQL and the printed output of every region. To add +a block: 1. Write the query in a snippet region of an example script; the region is what the block shows, so keep one example per region. @@ -154,8 +192,10 @@ An inline Python block that does not run under the harness says why in its fence: `{test=compile reason="needs a Postgres database"}`. Generated files and `env.py` excerpts are the usual cases; a block that can run, runs. -Every Python fence on a page carries one of three markers, and -`tests/test_docs_examples.py` fails on a fence with none: +Every Python fence on a page, in Markdown a page pulls in with `--8<--` +(`CONTRIBUTING.md`, `README.md` sections, `docs/includes`) and in a +docstring carries one of three markers; `tests/test_docs_examples.py` and +`tests/test_docstring_examples.py` fail on a fence with none: | Marker | What the harness does | | --- | --- | @@ -218,6 +258,30 @@ line. Use `Field`, the name the Assignment tab imports; (`Annotated[Target, ForeignKey(...)]`) and `BackRef()` / `ManyToMany()` assignments have one form and appear the same in both tabs. +A `default_factory` is a default too, and type checkers do not see it +inside `Annotated` either (`ty` reports `missing-argument` for +`id: Annotated[UUID, Field(primary_key=True, default_factory=uuid4)]`). It +stays the assigned value, as a `Field` of its own; only the other options +move: + +```python +id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) +created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) +``` + +```python +id: Annotated[uuid.UUID, Field(primary_key=True)] = Field(default_factory=uuid.uuid4) +created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) +``` + +The twin is generated, never written by hand. `tests/_docs_twins.py` +rewrites the Assignment script into the Annotated idiom and leaves every +other line alone, so both tabs run the same data, queries and prints. After +editing `.py`, run `python -m tests._docs_twins docs/examples/.py` +to rewrite `_annotated.py`. `tests/test_docs_twins.py` fails when a +twin is not its script rewritten, or when the two declare a different schema +or print different output. + Link the tabs with the labels `Assignment` and `Annotated`, exactly; the `content.tabs.link` feature only syncs tabs whose labels match. diff --git a/tests/_docs_sql.py b/tests/_docs_sql.py index cc2ec7c2..99090bcc 100644 --- a/tests/_docs_sql.py +++ b/tests/_docs_sql.py @@ -316,7 +316,13 @@ def snapshot(self) -> None: """Record the registered models' SchemaIR, unless it is the last one.""" from ferro.ir.compiler import compile_registry_schema_ir - schema = json.loads(json.dumps(compile_registry_schema_ir(), default=str)) + try: + ir = compile_registry_schema_ir() + except TypeError as refused: + # A model the script declared and Ferro refused (two primary keys) + # stays registered; the refusal is the schema the script ends with. + ir = {"refused": str(refused)} + schema = json.loads(json.dumps(ir, default=str)) if not self.schemas or self.schemas[-1] != schema: self.schemas.append(schema) diff --git a/tests/_docs_twins.py b/tests/_docs_twins.py index 0f5d1916..47d0738d 100644 --- a/tests/_docs_twins.py +++ b/tests/_docs_twins.py @@ -11,6 +11,8 @@ see it; - a ``default_factory`` stays an assigned ``Field(default_factory=...)``, for the same reason; +- a comment quoting a declaration (``# before: bio: str = Field(index=True)``) + quotes it in the same idiom; - everything else in the script (data, queries, prints, asserts) is the same text, so both twins print the same thing. @@ -21,8 +23,11 @@ from __future__ import annotations import ast +import io +import re import subprocess import sys +import tokenize from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] @@ -96,66 +101,136 @@ def _declaration(source: str, node: ast.AnnAssign) -> str | None: return declaration +def _module_name(node: ast.Import | ast.ImportFrom) -> str: + if isinstance(node, ast.ImportFrom): + return node.module or "" + return node.names[0].name + + +def _sorted_names(names: list[str]) -> list[str]: + """``names`` in ruff's order: constants, then classes, then the rest.""" + return sorted( + names, + key=lambda name: ( + 0 if name.isupper() and len(name) > 1 else 1 if name[0].isupper() else 2, + name, + ), + ) + + +def _import_groups(tree: ast.Module) -> list[tuple[list[ast.stmt], int]]: + """Each run of top-level imports, with the line where the code it serves ends. + + An example whose regions each stand alone imports again at the top of each + region, so each run serves the code up to the next run. + """ + groups: list[list[ast.stmt]] = [] + previous_was_import = False + for node in tree.body: + is_import = isinstance(node, ast.Import | ast.ImportFrom) + if is_import and not previous_was_import: + groups.append([]) + if is_import: + groups[-1].append(node) + previous_was_import = is_import + ends = [group[0].lineno - 1 for group in groups[1:]] + [10**9] + return list(zip(groups, ends, strict=True)) + + +def _shared_comment(lines: list[str], group: list[ast.stmt]) -> str: + """The trailing comment every import of ``group`` carries (``# noqa: E402`` + on imports placed after code), to carry onto the one added beside them.""" + comments = { + lines[(node.end_lineno or node.lineno) - 1].partition(" #")[2].rstrip("\n") + for node in group + } + [comment] = comments if len(comments) == 1 else [""] + return f" #{comment}" if comment else "" + + def _with_annotated_import(source: str) -> str: - """``source`` importing ``Annotated`` from ``typing``, where ruff's import - order puts it.""" + """``source`` importing ``Annotated`` from ``typing`` in every run of imports + whose code uses it, where ruff's import order puts it.""" tree = ast.parse(source) - imports = [ - node for node in tree.body if isinstance(node, ast.Import | ast.ImportFrom) - ] - for node in imports: - if isinstance(node, ast.ImportFrom) and node.module == "typing": - names = [alias.name for alias in node.names] - if "Annotated" in names: - return source - names.append("Annotated") - names.sort( - key=lambda name: ( - 0 - if name.isupper() and len(name) > 1 - else 1 - if name[0].isupper() - else 2, - name, - ) - ) - lines = source.splitlines(keepends=True) - lines[node.lineno - 1 : node.end_lineno] = [ - f"from typing import {', '.join(names)}\n" - ] - return "".join(lines) - stdlib = [ - node - for node in imports - if (node.module if isinstance(node, ast.ImportFrom) else node.names[0].name) - and ( - (node.module if isinstance(node, ast.ImportFrom) else node.names[0].name) - or "" - ).split(".")[0] - in sys.stdlib_module_names - and not (isinstance(node, ast.ImportFrom) and node.module == "__future__") - ] + used = sorted( + node.lineno + for node in ast.walk(tree) + if isinstance(node, ast.Name) and node.id == "Annotated" + ) lines = source.splitlines(keepends=True) - if stdlib: - after = [ + edits: list[tuple[int, int, list[str]]] = [] + for group, ends_at in _import_groups(tree): + starts_at = group[-1].end_lineno or group[-1].lineno + if not any(starts_at < line <= ends_at for line in used): + continue + typing = next( + ( + node + for node in group + if isinstance(node, ast.ImportFrom) and node.module == "typing" + ), + None, + ) + if typing is not None: + names = [alias.name for alias in typing.names] + if "Annotated" not in names: + names = _sorted_names([*names, "Annotated"]) + edits.append( + ( + typing.lineno - 1, + typing.end_lineno or typing.lineno, + [ + f"from typing import {', '.join(names)}" + f"{_shared_comment(lines, group)}\n" + ], + ) + ) + continue + stdlib = [ node - for node in stdlib - if isinstance(node, ast.ImportFrom) and (node.module or "") > "typing" + for node in group + if _module_name(node).split(".")[0] in sys.stdlib_module_names + and _module_name(node) != "__future__" ] - if after: - lines.insert(after[0].lineno - 1, "from typing import Annotated\n") + line = f"from typing import Annotated{_shared_comment(lines, group)}\n" + if stdlib: + after = [ + node + for node in stdlib + if isinstance(node, ast.ImportFrom) and _module_name(node) > "typing" + ] + at = after[0].lineno - 1 if after else stdlib[-1].end_lineno or 0 + edits.append((at, at, [line])) else: - lines.insert(stdlib[-1].end_lineno or 0, "from typing import Annotated\n") - return "".join(lines) - first = next( - node - for node in imports - if not (isinstance(node, ast.ImportFrom) and node.module == "__future__") - ) - lines.insert(first.lineno - 1, "from typing import Annotated\n\n") + first = next(node for node in group if _module_name(node) != "__future__") + edits.append((first.lineno - 1, first.lineno - 1, [line, "\n"])) + for start, stop, text in sorted(edits, reverse=True): + lines[start:stop] = text return "".join(lines) +_QUOTED = re.compile(r"^# (?P[a-z ]+: )(?P\w+: .+)$") + + +def _quoted_declaration(comment: str) -> str | None: + """A whole-line comment quoting a declaration (``# before: bio: str = + Field(index=True)``), with the declaration in the Annotated idiom, or + ``None`` when the comment quotes none.""" + match = _QUOTED.match(comment) + if match is None: + return None + try: + [node] = ast.parse(match["code"]).body + except SyntaxError: + return None + if not isinstance(node, ast.AnnAssign) or node.value is None: + return None + declaration = _declaration(match["code"], node) + if declaration is None: + return None + return f"# {match['lead']}{declaration}" + + def _formatted(source: str) -> str: result = subprocess.run( [sys.executable, "-m", "ruff", "format", "--stdin-filename", "twin.py", "-"], @@ -186,6 +261,11 @@ def annotated_twin(source: str) -> str: """ tree = ast.parse(source) lines = source.splitlines(keepends=True) + comments = { + token.start[0]: token + for token in tokenize.generate_tokens(io.StringIO(source).readline) + if token.type == tokenize.COMMENT + } rewrites: list[tuple[int, int, str]] = [] for node in ast.walk(tree): if not isinstance(node, ast.AnnAssign) or node.value is None: @@ -194,19 +274,21 @@ def annotated_twin(source: str) -> str: if declaration is None: continue end = node.end_lineno or node.lineno - span = "".join(lines[node.lineno - 1 : end]) + inside = [line for line in range(node.lineno, end) if line in comments] + if inside: + raise ValueError( + f"line {inside[0]}: a comment inside a multi-line Field " + "declaration would be lost; move it above the field" + ) + trailing = f" {comments[end].string}" if end in comments else "" indent = lines[node.lineno - 1][: node.col_offset] - rest = span.replace(_segment(source, node), "", 1) - trailing = "" - if "#" in rest: - before, _, comment = rest.partition("#") - if end != node.lineno or before.strip(): - raise ValueError( - f"line {node.lineno}: a comment inside a multi-line Field " - "declaration would be lost; move it above the field" - ) - trailing = f" #{comment.rstrip(chr(10))}" rewrites.append((node.lineno, end, f"{indent}{declaration}{trailing}\n")) + for line, token in comments.items(): + quoted = _quoted_declaration(token.string) + if quoted is not None and not any(s <= line <= e for s, e, _ in rewrites): + start = token.start[1] + text = lines[line - 1] + rewrites.append((line, line, f"{text[:start]}{quoted}\n")) if not rewrites: return source for start, end, text in sorted(rewrites, reverse=True): diff --git a/tests/test_docs_output.py b/tests/test_docs_output.py index 5ee01d24..46767efb 100644 --- a/tests/test_docs_output.py +++ b/tests/test_docs_output.py @@ -83,9 +83,7 @@ def test_a_block_under_a_run_fence_holds_what_the_fence_prints( check_printed(_page_below(tmp_path, RUN_FENCE, "Ada Lovelace\n")) with pytest.raises(AssertionError) as raised: check_printed(_page_below(tmp_path, RUN_FENCE, "Grace Hopper\n")) - assert "the block differs from what the fence at line 3 prints" in str( - raised.value - ) + assert "the block differs from what the fence at line 3 prints" in str(raised.value) def test_a_block_under_a_fence_that_does_not_run_is_refused(tmp_path: Path) -> None: diff --git a/tests/test_docs_twins.py b/tests/test_docs_twins.py new file mode 100644 index 00000000..3d6119f5 --- /dev/null +++ b/tests/test_docs_twins.py @@ -0,0 +1,177 @@ +"""Each example's Annotated twin is the same program in the other declaration style. + +AGENTS.md I-7 shows every field-declaring example in an Assignment tab and an +Annotated tab. For every ``docs/examples/.py`` with a +``_annotated.py``: + +- the twin is the Assignment script with each ``Field(...)`` declaration + rewritten into the Annotated idiom (``tests._docs_twins.annotated_twin``), + and nothing else changed, so the tabs differ only in field syntax; +- both declare the same schema (the registered models' SchemaIR each time the + script connects or creates tables) and print the same output. + +After editing an Assignment script, run +``python -m tests._docs_twins docs/examples/.py`` to rewrite its twin. +""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +import pytest + +from tests._docs_sql import captured +from tests._docs_twins import annotated_twin, twin_pairs + + +def _twin(body: str) -> str: + return annotated_twin(textwrap.dedent(body).lstrip("\n")) + + +def test_options_move_inside_annotated_and_the_default_stays_assigned() -> None: + assert _twin( + """ + from ferro import Field, Model + + + class User(Model): + id: int | None = Field(default=None, primary_key=True) + email: str = Field(unique=True) + nickname: str | None = Field(None, index=True) + name: str + """ + ) == textwrap.dedent( + """\ + from typing import Annotated + + from ferro import Field, Model + + + class User(Model): + id: Annotated[int | None, Field(primary_key=True)] = None + email: Annotated[str, Field(unique=True)] + nickname: Annotated[str | None, Field(index=True)] = None + name: str + """ + ) + + +def test_a_default_factory_stays_an_assigned_field() -> None: + """Type checkers do not see a default inside Annotated, so the factory stays + the assigned value; only the other options move.""" + assert _twin( + """ + import uuid + from datetime import UTC, datetime + + from ferro import Field, Model + + + class Event(Model): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + """ + ) == textwrap.dedent( + """\ + import uuid + from datetime import UTC, datetime + from typing import Annotated + + from ferro import Field, Model + + + class Event(Model): + id: Annotated[uuid.UUID, Field(primary_key=True)] = Field( + default_factory=uuid.uuid4 + ) + at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + """ + ) + + +def test_existing_annotated_metadata_and_typing_imports_are_kept() -> None: + assert _twin( + """ + from typing import TYPE_CHECKING, Annotated + + from ferro import Field, ForeignKey, Model + + + class Post(Model): + author: Annotated["Author", ForeignKey(related_name="posts")] + slug: Annotated[str, "slug"] = Field(unique=True) # a URL part + """ + ) == textwrap.dedent( + """\ + from typing import TYPE_CHECKING, Annotated + + from ferro import Field, ForeignKey, Model + + + class Post(Model): + author: Annotated["Author", ForeignKey(related_name="posts")] + slug: Annotated[str, "slug", Field(unique=True)] # a URL part + """ + ) + + +def test_a_comment_quoting_a_declaration_quotes_it_in_the_same_idiom() -> None: + assert _twin( + """ + from typing import Annotated + + from ferro import Field, Model + + + class Author(Model): + # before: nickname: str | None = Field(default=None, index=True) + handle: str | None = Field(default=None, renamed_from="nickname") + """ + ).splitlines()[6:8] == [ + " # before: nickname: Annotated[str | None, Field(index=True)] = None", + ' handle: Annotated[str | None, Field(renamed_from="nickname")] = None', + ] + + +def test_a_comment_inside_a_multi_line_declaration_is_refused() -> None: + with pytest.raises(ValueError, match="line 5: a comment inside a multi-line"): + _twin( + """ + from ferro import Field, Model + + class User(Model): + email: str = Field( + unique=True, # one account per address + ) + """ + ) + + +@pytest.mark.parametrize( + ("assignment", "annotated"), twin_pairs(), ids=lambda path: path.name +) +def test_a_twin_is_its_assignment_script_rewritten( + assignment: Path, annotated: Path +) -> None: + expected = annotated_twin(assignment.read_text(encoding="utf-8")) + assert annotated.read_text(encoding="utf-8") == expected, ( + f"docs/examples/{annotated.name} is not docs/examples/{assignment.name} " + f"in the Annotated idiom; run python -m tests._docs_twins " + f"docs/examples/{assignment.name} to rewrite it" + ) + + +@pytest.mark.parametrize( + ("assignment", "annotated"), twin_pairs(), ids=lambda path: path.name +) +def test_twins_declare_the_same_schema_and_print_the_same( + assignment: Path, annotated: Path +) -> None: + left, right = captured(assignment.name), captured(annotated.name) + assert right.schemas == left.schemas, ( + f"{annotated.name} declares a different schema from {assignment.name}" + ) + assert right.stdout == left.stdout, ( + f"{annotated.name} prints something other than {assignment.name}" + ) From 1221e7f3993d2253673f69ae315959a652360882 Mon Sep 17 00:00:00 2001 From: Taylor Date: Wed, 21 Oct 2026 14:50:06 -0400 Subject: [PATCH 05/13] ci(docs): run every tests/test_doc*.py by glob; pin that docs tests match it Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP --- .github/workflows/ci.yml | 14 +++----------- tests/test_docs_site.py | 20 +++++++++++++++----- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7b45acc..84c2d87c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,18 +102,10 @@ jobs: uv run --no-sync python -m tests._docs_checks docs/pages docs/examples src/ferro - name: Run the docs tests + # Every docs test module is named test_doc*.py, which + # tests/test_docs_site.py pins, so a new one runs here unlisted. run: | - uv run --no-sync pytest --no-cov -p no:cacheprovider \ - tests/test_docs_site.py \ - tests/test_docs_examples.py \ - tests/test_docstring_examples.py \ - tests/test_docs_migrations.py \ - tests/test_docs_sql.py \ - tests/test_docs_testing_fixtures.py \ - tests/test_docs_checks.py \ - tests/test_docs_lint.py \ - tests/test_docs_field_type_table.py \ - tests/test_documentation_features.py + uv run --no-sync pytest --no-cov -p no:cacheprovider tests/test_doc*.py test-rust: name: Rust tests diff --git a/tests/test_docs_site.py b/tests/test_docs_site.py index abe564a0..71911976 100644 --- a/tests/test_docs_site.py +++ b/tests/test_docs_site.py @@ -313,13 +313,23 @@ def test_docs_build_in_workflows_is_strict(workflow: str) -> None: def test_ci_docs_job_runs_every_docs_test_and_the_lint() -> None: jobs = yaml.safe_load((WORKFLOWS / "ci.yml").read_text(encoding="utf-8"))["jobs"] + """The job runs every module matching ``tests/test_doc*.py``, by glob, and + every test module that uses the docs harness is named to match it.""" commands = "\n".join(step.get("run", "") for step in jobs["docs"]["steps"]) - docs_tests = sorted( - path.relative_to(REPO_ROOT).as_posix() - for path in (REPO_ROOT / "tests").glob("test_doc*.py") + assert re.search( + r"pytest [^\n]*tests/test_doc\*\.py", commands.replace("\\\n", " ") + ), "the CI docs job must run pytest over tests/test_doc*.py" + helpers = ("tests._docs_harness", "tests._docs_sql", "tests._docs_twins") + misnamed = sorted( + path.name + for path in (REPO_ROOT / "tests").glob("test_*.py") + if not path.name.startswith("test_doc") + and any(helper in path.read_text(encoding="utf-8") for helper in helpers) + ) + assert not misnamed, ( + f"{misnamed} use the docs harness but the CI docs job's glob, " + "tests/test_doc*.py, does not reach them; rename them test_docs_*.py" ) - missing = [test for test in docs_tests if test not in commands] - assert not missing, f"the CI docs job does not run: {missing}" assert "python -m tests._docs_checks docs/pages docs/examples src/ferro" in commands From d96f394c3c352982c5c2b913c24a6e6dddb71a7f Mon Sep 17 00:00:00 2001 From: Taylor Date: Wed, 21 Oct 2026 14:51:04 -0400 Subject: [PATCH 06/13] docs(cli): list ferro migrate up --allow-ahead; pin every verb's flags against --help tests/test_docs_cli_reference.py fails when a ferro migrate verb's --help prints a flag (bar the global options and positional keyword spellings) that the verb's section of reference/cli.md does not list. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP --- docs/pages/reference/cli.md | 2 + tests/test_docs_cli_reference.py | 91 ++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 tests/test_docs_cli_reference.py diff --git a/docs/pages/reference/cli.md b/docs/pages/reference/cli.md index c53a01a2..aa5ca991 100644 --- a/docs/pages/reference/cli.md +++ b/docs/pages/reference/cli.md @@ -199,6 +199,8 @@ Apply every pending migration, in order, under the run lock. Prints one line per | Flag | What it does | | :--- | :--- | | `--lock-timeout DURATION` | How long to wait for another run's lock: `30s`, `500ms`, `1m`, a number of seconds, or `0` to refuse at once. Defaults to the database's [`lock_timeout`](configuration.md#lock_timeout) (`30s`); the flag wins. | +| `--allow-ahead=expand-only` | Run beside a database that has applied migrations this directory lacks, when every step they hold is an expand step: the previous release of a [rolling deploy](../guide/schema/deploying.md#rolling-deploys-and-the-two-release-contract). Refused, naming the first contract step, otherwise. | +| `--allow-ahead` | Run beside a database that has applied migrations this directory lacks, whatever steps they hold. Without either form, `up` refuses a database ahead of its directory. | ```text diff --git a/tests/test_docs_cli_reference.py b/tests/test_docs_cli_reference.py new file mode 100644 index 00000000..a07b0d02 --- /dev/null +++ b/tests/test_docs_cli_reference.py @@ -0,0 +1,91 @@ +"""The CLI reference lists every flag each ``ferro migrate`` verb accepts. + +For each verb, every ``--flag`` its ``--help`` prints, bar the global options +(``--config``, ``--database``, ``--url``, ``--help``, ``--version``), appears in +the verb's ``## `ferro migrate ...``` section of +``docs/pages/reference/cli.md``. The global options appear in its +``## Global options`` section. A flag added to the CLI and not to the page +fails here, naming the verb and the flag. +""" + +from __future__ import annotations + +import re + +import pytest + +from ferro.cli import app +from tests._docs_harness import DOCS_PAGES + +CLI_REFERENCE = DOCS_PAGES / "reference" / "cli.md" +GLOBAL_OPTIONS = ("--config", "--database", "--url", "--help", "--version") +FLAG = re.compile(r"(? list[str]: + migrate = app._meta_parent._commands["migrate"] + return sorted(name for name in migrate._commands if not name.startswith("-")) + + +POSITIONAL = re.compile( + r"(?[A-Z][A-Z_]*) +(?P--[a-z][a-z0-9-]*)" +) +"""A positional argument as ``--help`` lists it, with its keyword spelling: +``NAME --name``.""" + + +def _help_flags(verb: str, capsys: pytest.CaptureFixture[str]) -> set[str]: + """The verb's flags as ``--help`` prints them, bar the global options and + the keyword spellings of its positional arguments (the heading names + those, as ``ferro migrate new NAME``).""" + try: + app(["migrate", verb, "--help"]) + except SystemExit: + pass + printed = capsys.readouterr().out + positionals = {match["flag"] for match in POSITIONAL.finditer(printed)} + return set(FLAG.findall(printed)) - set(GLOBAL_OPTIONS) - positionals + + +def _section(heading: re.Pattern[str]) -> str: + """The text of the cli.md section whose ``##`` heading matches ``heading``.""" + text = CLI_REFERENCE.read_text(encoding="utf-8") + sections = re.split(r"(?m)^(?=## )", text) + found = [section for section in sections if heading.match(section)] + assert len(found) == 1, f"cli.md has {len(found)} sections matching {heading}" + return found[0] + + +@pytest.mark.parametrize("verb", _verbs()) +def test_the_reference_lists_every_flag_of_the_verb( + verb: str, capsys: pytest.CaptureFixture[str] +) -> None: + flags = _help_flags(verb, capsys) + section = _section(re.compile(rf"## `ferro migrate {verb}[` ]")) + listed = set(FLAG.findall(section)) + missing = sorted(flags - listed) + assert not missing, ( + f"ferro migrate {verb} --help prints {missing}, which its section of " + f"docs/pages/reference/cli.md does not list" + ) + + +def test_the_reference_lists_every_global_option() -> None: + section = _section(re.compile(r"## Global options")) + missing = [flag for flag in GLOBAL_OPTIONS if flag not in section] + assert not missing, f"cli.md's Global options section omits {missing}" + + +def test_every_verb_has_a_section() -> None: + assert _verbs() == [ + "baseline", + "check", + "down", + "drift", + "init", + "new", + "rerecord", + "squash", + "status", + "up", + ] From c36a48edc9008347bd5e5e238f36ca71b3990ecd Mon Sep 17 00:00:00 2001 From: Taylor Date: Wed, 21 Oct 2026 14:51:46 -0400 Subject: [PATCH 07/13] docs(aggregations): the first program connects and seeds; five numbers, not four; drop the unused prelude The global region now starts at the imports and connect(), as the Queries page's first program does. The aggregations prelude was cited by no fence; it is removed, and a test fails on any prelude no fence asks for. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP --- docs/examples/_preludes/aggregations.py | 27 ------------------------- docs/examples/aggregations.py | 13 ++++++------ docs/examples/aggregations_annotated.py | 13 ++++++------ docs/pages/guide/aggregations.md | 4 ++-- tests/test_docs_examples.py | 15 ++++++++++++++ 5 files changed, 29 insertions(+), 43 deletions(-) delete mode 100644 docs/examples/_preludes/aggregations.py diff --git a/docs/examples/_preludes/aggregations.py b/docs/examples/_preludes/aggregations.py deleted file mode 100644 index 5e4a6ce9..00000000 --- a/docs/examples/_preludes/aggregations.py +++ /dev/null @@ -1,27 +0,0 @@ -"""The running example of the Aggregations guide. - -The ledger declared in ``docs/examples/aggregations.py``, with its four -transactions, in an in-memory SQLite database. A block marked -``{test=run prelude=aggregations}`` runs inside ``opened()`` and can use -``Account``, ``Transaction`` and ``Decimal`` directly. -""" - -import sys -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - -from aggregations import Account, Decimal, Transaction, seed # noqa: E402,F401 - -from ferro import connect, engines # noqa: E402 - - -@asynccontextmanager -async def opened() -> AsyncIterator[None]: - """Connect, add the ledger's rows and hold a session open around the block.""" - await connect("sqlite::memory:", auto_migrate=True) - async with engines.session(): - await seed() - yield diff --git a/docs/examples/aggregations.py b/docs/examples/aggregations.py index 531caafe..4bc79add 100644 --- a/docs/examples/aggregations.py +++ b/docs/examples/aggregations.py @@ -2,9 +2,7 @@ (docs/pages/guide/aggregations.md). Seeds a small ledger of four transactions, then runs every aggregate query -the guide shows and asserts the exact records that come back. The -``aggregations`` prelude imports ``Account``, ``Transaction`` and ``seed`` from -here, so the guide's inline blocks run against the same rows. +the guide shows and asserts the exact records that come back. """ import asyncio @@ -12,7 +10,7 @@ from decimal import Decimal from typing import Annotated -from ferro import BackRef, Field, ForeignKey, Model, Relation, connect, engines +from ferro import BackRef, Field, ForeignKey, Model, Relation # --8<-- [start:schema] @@ -50,11 +48,12 @@ async def seed() -> None: async def main() -> None: - await connect("sqlite::memory:", auto_migrate=True) - # --8<-- [start:global] + from ferro import connect, engines + + await connect("sqlite::memory:", auto_migrate=True) async with engines.session(): - await seed() + await seed() # adds the ledger's four transactions # An aggregate-only projection collapses to exactly one record, # read idiomatically with first(). diff --git a/docs/examples/aggregations_annotated.py b/docs/examples/aggregations_annotated.py index 27c2785b..d764b64f 100644 --- a/docs/examples/aggregations_annotated.py +++ b/docs/examples/aggregations_annotated.py @@ -2,9 +2,7 @@ (docs/pages/guide/aggregations.md). Seeds a small ledger of four transactions, then runs every aggregate query -the guide shows and asserts the exact records that come back. The -``aggregations`` prelude imports ``Account``, ``Transaction`` and ``seed`` from -here, so the guide's inline blocks run against the same rows. +the guide shows and asserts the exact records that come back. """ import asyncio @@ -12,7 +10,7 @@ from decimal import Decimal from typing import Annotated -from ferro import BackRef, Field, ForeignKey, Model, Relation, connect, engines +from ferro import BackRef, Field, ForeignKey, Model, Relation # --8<-- [start:schema] @@ -50,11 +48,12 @@ async def seed() -> None: async def main() -> None: - await connect("sqlite::memory:", auto_migrate=True) - # --8<-- [start:global] + from ferro import connect, engines + + await connect("sqlite::memory:", auto_migrate=True) async with engines.session(): - await seed() + await seed() # adds the ledger's four transactions # An aggregate-only projection collapses to exactly one record, # read idiomatically with first(). diff --git a/docs/pages/guide/aggregations.md b/docs/pages/guide/aggregations.md index 911cad73..299a1baa 100644 --- a/docs/pages/guide/aggregations.md +++ b/docs/pages/guide/aggregations.md @@ -6,13 +6,13 @@ description: "Count, total and average rows with count(), sum(), avg(), min() an This guide shows how to total, count and average rows with the five aggregate methods (`count()`, `sum()`, `avg()`, `min()` and `max()`) inside a `select()` lambda, and how a record that mixes plain fields with aggregates becomes a grouped query. The full signatures are in the [aggregates reference](../api/queries.md#aggregates-and-grouped-queries). -Here is a whole program. It adds four transactions and asks for the sum of the amount, the largest amount and the average amount over all of them: +Here is a program that connects, opens a session, adds the four transactions of [the ledger](#the-ledger) and asks for their count, total, average, smallest and largest amount in one query: ```python --8<-- "docs/examples/aggregations.py:global" ``` -The single record holds four numbers over the four rows: they total 115, so the average is 28.75. Every query on this page runs inside a session like this one; see [Sessions](connections.md#sessions) for why and how to open one. +The single record holds five numbers over the four rows: a count of 4, a total of 115, an average of 28.75, a smallest amount of 10 and a largest of 45. Every query on this page runs inside a session like this one; see [Sessions](connections.md#sessions) for why and how to open one. Every example on this page is followed by the SQL it sends, captured from Ferro when the example runs. `first()` sends this statement; on PostgreSQL only the placeholders differ, `$1` where SQLite has `?`: diff --git a/tests/test_docs_examples.py b/tests/test_docs_examples.py index 8e219799..dc2a0ece 100644 --- a/tests/test_docs_examples.py +++ b/tests/test_docs_examples.py @@ -76,6 +76,21 @@ def test_row_level_security_example_runs_on_postgres( run_python([str(DOCS_EXAMPLES / script), db_url, pg_role("app")]) +def test_every_prelude_is_cited_by_a_fence() -> None: + """A prelude no page or docstring fence asks for is dead weight; remove it.""" + from tests._docs_harness import REPO_ROOT, docstring_examples, preludes + + cited = { + fence_settings(example).get("prelude") + for example in [ + *page_examples(), + *docstring_examples("ferro", REPO_ROOT / "src"), + ] + } + unused = sorted(set(preludes()) - cited) + assert not unused, f"no fence uses the preludes {unused}; delete them" + + 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") From bc4598827c25d6f99b7fdfcf2c3e61bd7ab6af17 Mon Sep 17 00:00:00 2001 From: Taylor Date: Wed, 21 Oct 2026 14:51:54 -0400 Subject: [PATCH 08/13] docs(auto-migrate): say a SQLite pass that fails partway can leave a table half changed Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP --- docs/pages/guide/schema/auto-migrate.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/pages/guide/schema/auto-migrate.md b/docs/pages/guide/schema/auto-migrate.md index a1696432..4244d157 100644 --- a/docs/pages/guide/schema/auto-migrate.md +++ b/docs/pages/guide/schema/auto-migrate.md @@ -227,7 +227,7 @@ except ferro.OperationalError as error: changed = {s.subject for s in error.report.statements if s.role == "schema"} ``` -`changed` names every table whose changes committed. `connect(..., auto_migrate=True)` runs the same pass and returns nothing; its failure carries the same `.report`. Each statement is also a record on the `ferro.sql` logger as it finishes, with its `sql`, `subject` and duration as attributes (see [Logging](../connections.md#logging)); the log is for watching, and the report is still the structured answer. +`changed` names every table whose changes committed. On SQLite the pass applies its statements one at a time, so a failure can leave earlier statements of the same table applied; when a change must land whole, make it a [migration](migrations.md) instead. `connect(..., auto_migrate=True)` runs the same pass and returns nothing; its failure carries the same `.report`. Each statement is also a record on the `ferro.sql` logger as it finishes, with its `sql`, `subject` and duration as attributes (see [Logging](../connections.md#logging)); the log is for watching, and the report is still the structured answer. ## Concurrent passes and the run lock From f06e7c8fee4e1b011c337f3ad3904cabbf1343e9 Mon Sep 17 00:00:00 2001 From: Taylor Date: Wed, 21 Oct 2026 14:56:48 -0400 Subject: [PATCH 09/13] docs(nav): the RLS how-to sits in the How-to tab; redirect six renamed anchors and narrow MOVED_ANCHORS's claim The page keeps its path, which two API docstrings link. MOVED_ANCHORS now says what it covers: the split pages, the deleted Next steps page and six renamed sections outside links use (get-or-create, nesting-behavior, sessions-recommended, what-is-ferro, writing-your-first-test, save-no-longer-upserts), each redirected. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP --- tests/test_docs_site.py | 31 +++++++++++++++++++++++++++---- zensical.toml | 9 ++++++++- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/tests/test_docs_site.py b/tests/test_docs_site.py index 71911976..9f058dee 100644 --- a/tests/test_docs_site.py +++ b/tests/test_docs_site.py @@ -23,8 +23,8 @@ job runs every docs test file and the docs lint; - the nav has six tabs holding every page once, each label equal to its page's title; every page has a one-sentence description and new pages are - marked new; every moved page, and every anchor that moved, redirects to the - section that replaced it; + marked new; every moved page, and every anchor ``MOVED_ANCHORS`` lists, + redirects to the section that replaced it; - no page renders a literal Sphinx role, an unresolved cross-reference, a list merged into a paragraph, a docstring section as prose or a section table row that stops mid-sentence, and griffe reads every docstring without @@ -552,6 +552,14 @@ def test_links_from_api_pages_land_on_existing_anchors(built_site: Path) -> None "guide/row-level-security.md#connection-delivery-mode": "concepts/row-level-security.md#connection-delivery", "guide/row-level-security.md#raw-policies-and-drift": "concepts/row-level-security.md#drift-and-ownership", "guide/row-level-security.md#migrations-and-alembic": "guide/row-level-security.md#deploy-order", + # Sections renamed on pages the rewrite kept, the ones linked from outside + # the docs (the README, issues, the old upgrade guide). + "guide/mutations.md#get-or-create": "guide/mutations.md#get_or_create-and-update_or_create", + "guide/transactions.md#nesting-behavior": "guide/transactions.md#nested-transactions", + "guide/connections.md#sessions-recommended": "guide/connections.md#sessions", + "faq.md#what-is-ferro": "faq.md#what-ferro-is", + "howto/testing.md#writing-your-first-test": "howto/testing.md#a-first-test", + "howto/upgrade-guide.md#save-no-longer-upserts": "upgrading.md#save-and-upserts", # The deleted Next steps page: every section lands on the quickstart's. "getting-started/next-steps.md#learn-by-use-case": _NEXT_STEPS, "getting-started/next-steps.md#building-an-api": _NEXT_STEPS, @@ -561,8 +569,14 @@ def test_links_from_api_pages_land_on_existing_anchors(built_site: Path) -> None "getting-started/next-steps.md#reference-material": _NEXT_STEPS, "getting-started/next-steps.md#get-help": _NEXT_STEPS, } -"""Every anchor that moved to another page, or was renamed on its own page, -during the docs rewrite, and the section that replaced it.""" +"""The anchors the docs rewrite redirects, and the section that replaced each. + +They are every section of the split pages (the migrations guide and the +row-level security guide) and of the deleted Next steps page, and the renamed +sections that outside links point at. The rewrite renamed most other sections +of the pages it rewrote without a redirect: an old link to one lands at the +top of its page, which still exists (``MOVED_PAGES`` redirects each page that +does not).""" def _site_config() -> dict: @@ -604,6 +618,15 @@ def test_nav_has_the_six_tabs() -> None: assert tabs == NAV_TABS +def test_the_row_level_security_split_sits_in_its_three_tabs() -> None: + """The RLS guide split into a how-to, an explanation and a reference; each + part sits in the tab of its kind.""" + tabs = {page: trail[0] for trail, _, page in _nav_pages(_site_config()["nav"])} + assert tabs["guide/row-level-security.md"] == "How-to" + assert tabs["concepts/row-level-security.md"] == "Concepts" + assert tabs["reference/row-level-security.md"] == "Reference" + + def test_every_page_is_in_the_nav_once() -> None: in_nav = [page for _, _, page in _nav_pages(_site_config()["nav"])] duplicated = sorted({page for page in in_nav if in_nav.count(page) > 1}) diff --git a/zensical.toml b/zensical.toml index 66c146b9..125c70a0 100644 --- a/zensical.toml +++ b/zensical.toml @@ -39,7 +39,6 @@ nav = [ { "Adopting migrations on an existing database" = "guide/schema/adopting-migrations.md" }, { "Alembic bridge" = "guide/schema/alembic.md" }, ] }, - { "Scope rows by tenant with Postgres row-level security" = "guide/row-level-security.md" }, ] }, { "How-to" = [ { "How to test a Ferro app with pytest" = "howto/testing.md" }, @@ -47,6 +46,7 @@ nav = [ { "How to record created and updated times" = "howto/timestamps.md" }, { "How to soft-delete rows" = "howto/soft-deletes.md" }, { "How to use several databases in one app" = "howto/multiple-databases.md" }, + { "Scope rows by tenant with Postgres row-level security" = "guide/row-level-security.md" }, { "Migrate from SQLAlchemy" = "howto/migrate-from-sqlalchemy.md" }, ] }, { "Concepts" = [ @@ -191,6 +191,13 @@ full_output = "llms-full.txt" "getting-started/next-steps.md#get-help" = "getting-started/quickstart.md#next-steps" "howto/adopting-migrations.md" = "guide/schema/adopting-migrations.md" "howto/upgrade-guide.md" = "upgrading.md" +# Sections renamed on pages the rewrite kept, linked from outside the docs. +"guide/mutations.md#get-or-create" = "guide/mutations.md#get_or_create-and-update_or_create" +"guide/transactions.md#nesting-behavior" = "guide/transactions.md#nested-transactions" +"guide/connections.md#sessions-recommended" = "guide/connections.md#sessions" +"faq.md#what-is-ferro" = "faq.md#what-ferro-is" +"howto/testing.md#writing-your-first-test" = "howto/testing.md#a-first-test" +"howto/upgrade-guide.md#save-no-longer-upserts" = "upgrading.md#save-and-upserts" # Sections the migrations guide split (#714) moved to other pages, or renamed. "guide/schema/migrations.md#renames" = "guide/schema/renames-and-retirements.md#rename-hints" "guide/schema/migrations.md#retiring-a-column" = "guide/schema/renames-and-retirements.md#retiring-a-column" From 08603236ab38c56853e9dbe4b6ad918e86de3737 Mon Sep 17 00:00:00 2001 From: Taylor Date: Wed, 21 Oct 2026 14:57:58 -0400 Subject: [PATCH 10/13] docs(cli): list ferro migrate status --no-steps and --no-json; the capture runs under the interpreter's hash seed Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP --- docs/pages/reference/cli.md | 2 ++ docs/solutions/patterns/docs-page-conventions.md | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/pages/reference/cli.md b/docs/pages/reference/cli.md index aa5ca991..a91a63bf 100644 --- a/docs/pages/reference/cli.md +++ b/docs/pages/reference/cli.md @@ -246,6 +246,8 @@ Show which migrations this database has applied, without changing it or taking a | :--- | :--- | | `--steps` | Print every migration's steps, not only those needing attention. | | `--json` | Print the report as a JSON document, and nothing else on stdout. It carries `url` (the database's URL, password masked), `applied_at` on each migration and `finished_at` on each step, beside the keys it always had. | +| `--no-steps` | Print only the steps needing attention. This is the default. | +| `--no-json` | Print the plain report. This is the default. | Exit 0 when everything is applied, 3 when something is pending, 4 when something needs attention. diff --git a/docs/solutions/patterns/docs-page-conventions.md b/docs/solutions/patterns/docs-page-conventions.md index 549230c6..66ad14a5 100644 --- a/docs/solutions/patterns/docs-page-conventions.md +++ b/docs/solutions/patterns/docs-page-conventions.md @@ -167,8 +167,8 @@ Each statement is laid out one clause per line (`FROM`, `JOIN`, `WHERE`, `GROUP BY`, `ORDER BY`, `LIMIT` and the rest, at the top level only), a `CREATE TABLE` one column per line, and ends with `;`. Unindenting the lines and joining them with single spaces gives back the statement Ferro sent. The -example runs with `PYTHONHASHSEED=0`, because a partial `save(only=...)` -writes its `SET` columns in set order. An example that a `(postgres)` block +example runs under the interpreter's own hash seed, so output that varies from +run to run fails the gate. An example that a `(postgres)` block or a lone SQLite block names must run on PostgreSQL too: `ferro.connect` is pointed at the test database, so raw SQL in it uses `$1` placeholders, which SQLite also accepts. From 92d78212c387e34369a9fff51b8f362b3f876d56 Mon Sep 17 00:00:00 2001 From: Taylor Date: Wed, 21 Oct 2026 15:22:08 -0400 Subject: [PATCH 11/13] docs: every error and warning a page quotes is regenerated from a scenario that raises it Twelve text blocks quoted Ferro's own errors and warnings under shows:, which nothing compared. Each is now a prints: block backed by an example region: docs/examples/auto_migrate_errors.py (the required column, the SQLite check, both run-lock texts, the foreign default on Postgres), migration_errors.py (behind, ahead, ahead by a contract step, the tracked database), a region of migrations_retire_label.py and one of row_level_security.py (the forced-table warning, on Postgres). prints: takes (postgres): the region runs on PostgreSQL as .py under postgres_only. shows: is narrowed to text Ferro does not print, and a shows: block that reads like Ferro's output fails. Regenerating found two stale blocks: the forced-table warning names 'document' too, and the warnings start 'ferro auto-migrate:'. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP --- docs/examples/auto_migrate_errors.py | 163 ++++++++++++++++++ docs/examples/migration_errors.py | 104 +++++++++++ docs/examples/migrations_retire_label.py | 12 ++ .../migrations_retire_label_annotated.py | 12 ++ docs/examples/row_level_security.py | 8 + docs/examples/row_level_security_annotated.py | 8 + docs/pages/api/migrations.md | 4 +- docs/pages/guide/schema/auto-migrate.md | 20 +-- docs/pages/guide/schema/deploying.md | 20 +-- docs/pages/guide/schema/overview.md | 2 +- .../guide/schema/renames-and-retirements.md | 2 +- docs/pages/reference/configuration.md | 6 +- docs/pages/reference/row-level-security.md | 4 +- .../patterns/docs-page-conventions.md | 14 +- tests/_docs_sql.py | 74 +++++--- tests/test_docs_output.py | 69 +++++++- 16 files changed, 461 insertions(+), 61 deletions(-) create mode 100644 docs/examples/auto_migrate_errors.py create mode 100644 docs/examples/migration_errors.py diff --git a/docs/examples/auto_migrate_errors.py b/docs/examples/auto_migrate_errors.py new file mode 100644 index 00000000..dbcf7541 --- /dev/null +++ b/docs/examples/auto_migrate_errors.py @@ -0,0 +1,163 @@ +"""Runnable companion for the errors and warnings the Auto-migrate guide quotes +(docs/pages/guide/schema/auto-migrate.md, docs/pages/reference/configuration.md). + +Each region brings a database to the state the page describes, connects, and +prints what Ferro raises or warns. With no arguments it runs on SQLite files in +a temporary directory. ``python docs/examples/auto_migrate_errors.py `` +runs the Postgres scenario instead: a server default no model declares. +""" + +import asyncio +import os +import sys +import tempfile +import warnings +from enum import StrEnum +from pathlib import Path + +from ferro import ( + Field, + Model, + clear_registry, + connect, + engines, + execute, + reset_engine, +) + + +class Kind(StrEnum): + PERSON = "person" + COMPANY = "company" + + +def declare_author(release: str) -> type[Model]: + """Declare ``Author`` as the release ``release`` has it. + + Every release declares the same model, so each declaration replaces the + last, as a new deploy of the app would. + """ + reset_engine() + clear_registry() + if release == "base": + + class Author(Model): + id: int | None = Field(default=None, primary_key=True) + name: str + + elif release == "required-bio": + + class Author(Model): + id: int | None = Field(default=None, primary_key=True) + name: str + bio: str # required, with no default + + elif release == "kind": + + class Author(Model): + id: int | None = Field(default=None, primary_key=True) + name: str + kind: Kind = Field(default=Kind.PERSON, db_type="text") + + elif release == "checked-kind": + + class Author(Model): + id: int | None = Field(default=None, primary_key=True) + name: str + kind: Kind = Field(default=Kind.PERSON, db_type="text", db_check=True) + + elif release == "slug": + + class Author(Model): + id: int | None = Field(default=None, primary_key=True) + name: str + slug: str + + else: + raise ValueError(release) + return Author + + +async def required_column(url: str) -> None: + declare_author("base") + await connect(url, auto_migrate=True) + declare_author("required-bio") + # --8<-- [start:required-column] + try: + await connect(url, migrate_updates=True) + except ValueError as error: + print(error) + # --8<-- [end:required-column] + + +async def sqlite_check(url: str) -> None: + declare_author("kind") + await connect(url, auto_migrate=True) + declare_author("checked-kind") + # --8<-- [start:sqlite-check] + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + await connect(url, migrate_updates=True) + for warning in caught: + print(warning.message) + # --8<-- [end:sqlite-check] + + +async def run_lock(root: Path, url: str) -> None: + import fcntl + + declare_author("base") + (root / "ferro.toml").write_text( + 'models = ["__main__"]\ndialects = ["sqlite"]\nlock_timeout = "1s"\n' + ) + # Another process's migration run holds the SQLite run lock: an OS lock on + # the database's .ferro-migrate.lock file. + with open(root / "app.db.ferro-migrate.lock", "a+") as held: + fcntl.flock(held, fcntl.LOCK_EX | fcntl.LOCK_NB) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + try: + await connect(url, auto_migrate=True) + except Exception as error: # noqa: BLE001 - printed below + gave_up = error + else: + raise AssertionError("the pass ran while the lock was held") + # --8<-- [start:lock-wait] + for warning in caught: + print(warning.message) + # --8<-- [end:lock-wait] + # --8<-- [start:lock-timeout] + print(gave_up) + # --8<-- [end:lock-timeout] + (root / "ferro.toml").unlink() + + +async def foreign_default(url: str) -> None: + declare_author("slug") + await connect(url, auto_migrate=True) + async with engines.session(): + await execute('ALTER TABLE "author" ALTER COLUMN "slug" SET DEFAULT \'\'') + declare_author("slug") + # --8<-- [start:foreign-default] + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + await connect(url, migrate_updates=True) + for warning in caught: + print(warning.message) + # --8<-- [end:foreign-default] + + +async def main(argv: list[str]) -> None: + if argv: + await foreign_default(argv[0]) + return + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + os.chdir(root) + await required_column(f"sqlite:{root / 'required.db'}?mode=rwc") + await sqlite_check(f"sqlite:{root / 'check.db'}?mode=rwc") + await run_lock(root, f"sqlite:{root / 'app.db'}?mode=rwc") + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1:])) diff --git a/docs/examples/migration_errors.py b/docs/examples/migration_errors.py new file mode 100644 index 00000000..f46488b6 --- /dev/null +++ b/docs/examples/migration_errors.py @@ -0,0 +1,104 @@ +# ruff: noqa: E402, F811 - the models come first, before then after, as the other migrations examples +"""Runnable companion for the errors the migrations pages quote +(docs/pages/guide/schema/deploying.md, docs/pages/guide/schema/overview.md). + +A project on SQLite walks through the states those errors describe: a +database behind its migrations, one ahead of its checkout by an expand step +and then by a contract step, and a tracked database that ``connect()`` is +asked to auto-migrate. Each region prints the error Ferro raises. +""" + +# --8<-- [start:models] +from ferro import Field, Model + + +class Author(Model): + id: int | None = Field(default=None, primary_key=True) + name: str + bio: str | None = None + slug: str | None = None # new + + +# --8<-- [end:models] + +# --8<-- [start:without-bio] +from ferro import Field, Model + + +class Author(Model): + id: int | None = Field(default=None, primary_key=True) + name: str + slug: str | None = None + + +# --8<-- [end:without-bio] + +import asyncio +import os +import shutil +import sys + +import ferro +import ferro.migrations +from _migrations_project import project + + +async def main() -> None: + with project(__file__) as app: + app.models_before() + app.ferro("migrate", "new", "create_author") + app.ferro("migrate", "up") + app.models_after() + app.ferro("migrate", "new", "author_slug") + os.chdir(app.root) + sys.path.insert(0, str(app.root)) + await ferro.connect(app.url) + + # --8<-- [start:behind] + try: + await ferro.migrations.require_applied() + except ferro.migrations.PendingMigrationsError as error: + print(f"ferro.migrations.PendingMigrationsError: {error}") + # --8<-- [end:behind] + + # Release 1 applies 0002_author_slug; release 0's checkout lacks it. + app.ferro("migrate", "up") + expand = app.migrations / "0002_author_slug" + kept = app.root / "0002_author_slug" + shutil.move(expand, kept) + + # --8<-- [start:ahead] + try: + await ferro.migrations.require_applied() + except ferro.migrations.DatabaseAheadError as error: + print(f"ferro.migrations.DatabaseAheadError: {error}") + # --8<-- [end:ahead] + + # An expand step lets release 0 through. + await ferro.migrations.require_applied(allow_ahead="expand-only") + + # A contract step does not. + shutil.move(kept, expand) + app.models_region("without-bio") + app.ferro("migrate", "new", "drop_bio") + app.ferro("migrate", "up") + shutil.rmtree(app.migrations / "0003_drop_bio") + + # --8<-- [start:ahead-by-contract] + try: + await ferro.migrations.require_applied(allow_ahead="expand-only") + except ferro.migrations.DatabaseAheadError as error: + print(f"ferro.migrations.DatabaseAheadError: {error}") + # --8<-- [end:ahead-by-contract] + + ferro.reset_engine() + # --8<-- [start:tracked] + try: + await ferro.connect(app.url, auto_migrate=True) + except ferro.migrations.MigrationRefused as error: + print(error) + # --8<-- [end:tracked] + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs/examples/migrations_retire_label.py b/docs/examples/migrations_retire_label.py index 9c7433bf..7f46f9e6 100644 --- a/docs/examples/migrations_retire_label.py +++ b/docs/examples/migrations_retire_label.py @@ -74,6 +74,18 @@ def main() -> None: assert not (app.migrations / "0002_retire_paused").exists() app.ferro("migrate", "check") + # --8<-- [start:retired-write] + try: + Author( + name="Grace Hopper", + email="grace@example.com", + bio="Admiral", + status="paused", + ) + except ValueError as error: + print(error) + # --8<-- [end:retired-write] + subprocess.run( [sys.executable, "-c", PROBE], cwd=app.root, check=True, timeout=120 ) diff --git a/docs/examples/migrations_retire_label_annotated.py b/docs/examples/migrations_retire_label_annotated.py index f7fc697e..7dc1eaba 100644 --- a/docs/examples/migrations_retire_label_annotated.py +++ b/docs/examples/migrations_retire_label_annotated.py @@ -75,6 +75,18 @@ def main() -> None: assert not (app.migrations / "0002_retire_paused").exists() app.ferro("migrate", "check") + # --8<-- [start:retired-write] + try: + Author( + name="Grace Hopper", + email="grace@example.com", + bio="Admiral", + status="paused", + ) + except ValueError as error: + print(error) + # --8<-- [end:retired-write] + subprocess.run( [sys.executable, "-c", PROBE], cwd=app.root, check=True, timeout=120 ) diff --git a/docs/examples/row_level_security.py b/docs/examples/row_level_security.py index 3bc2c994..0c81bfa6 100644 --- a/docs/examples/row_level_security.py +++ b/docs/examples/row_level_security.py @@ -199,6 +199,14 @@ async def scope_to_tenant(tenant_id: uuid.UUID) -> None: assert [document.title for document in documents] == ["Handbook", "Q3 plan"] # --8<-- [end:composed_query] + # --8<-- [start:forced_warning] + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + await connect(app_url, name="app_migrate", migrate_updates=True) + for warning in caught: + print(warning.message) + # --8<-- [end:forced_warning] + async def on_sqlite() -> None: # --8<-- [start:sqlite] diff --git a/docs/examples/row_level_security_annotated.py b/docs/examples/row_level_security_annotated.py index 929de53c..badefbc4 100644 --- a/docs/examples/row_level_security_annotated.py +++ b/docs/examples/row_level_security_annotated.py @@ -199,6 +199,14 @@ async def scope_to_tenant(tenant_id: uuid.UUID) -> None: assert [document.title for document in documents] == ["Handbook", "Q3 plan"] # --8<-- [end:composed_query] + # --8<-- [start:forced_warning] + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + await connect(app_url, name="app_migrate", migrate_updates=True) + for warning in caught: + print(warning.message) + # --8<-- [end:forced_warning] + async def on_sqlite() -> None: # --8<-- [start:sqlite] diff --git a/docs/pages/api/migrations.md b/docs/pages/api/migrations.md index d3b197b6..2fab02d9 100644 --- a/docs/pages/api/migrations.md +++ b/docs/pages/api/migrations.md @@ -62,13 +62,13 @@ Passing both `using=` and `url=` raises `MigrationRefused`. `check` reads only t ## Errors -A database ahead of the checkout raises `DatabaseAheadError`: `.ahead` names the migrations it has applied that the directory lacks, and `.contract_steps` each of their steps whose class is not `expand` (`["0008_drop_bio:01"]`), the steps `allow_ahead="expand-only"` refuses on. The [rolling-deploy recipe](../guide/schema/deploying.md#rolling-deploys-and-the-two-release-contract) shows it in use. +A database ahead of the checkout raises `DatabaseAheadError`: `.ahead` names the migrations it has applied that the directory lacks, and `.contract_steps` each of their steps whose class is not `expand` (`["0003_drop_bio:01"]`), the steps `allow_ahead="expand-only"` refuses on. The [rolling-deploy recipe](../guide/schema/deploying.md#rolling-deploys-and-the-two-release-contract) shows it in use. ```python {test=compile reason="needs a database ahead of the checkout"} try: await ferro.migrations.require_applied(allow_ahead="expand-only") except ferro.migrations.DatabaseAheadError as ahead: - print(ahead.contract_steps) # ['0008_drop_bio:01'] + print(ahead.contract_steps) # ['0003_drop_bio:01'] ``` ::: ferro.migrations.MigrationRefused diff --git a/docs/pages/guide/schema/auto-migrate.md b/docs/pages/guide/schema/auto-migrate.md index 4244d157..b25f4831 100644 --- a/docs/pages/guide/schema/auto-migrate.md +++ b/docs/pages/guide/schema/auto-migrate.md @@ -64,7 +64,7 @@ Rules worth knowing: - **A new required column needs a literal default.** Existing rows must get a value, so a new required field without a literal default fails the connect: - + ```text Cannot add NOT NULL column 'author.bio' to an existing table: it has no literal default to backfill existing rows. Make the field nullable, give it a literal default, or generate a reviewed migration with `ferro migrate new`. ``` @@ -88,9 +88,9 @@ Rules worth knowing: SQLite cannot add, change or drop a constraint, a type or a nullability on a table that exists; the only way is to create the table again in its new shape and copy the rows across (a *table rebuild*). Auto-migrate never rebuilds a table, because a rebuild drops whatever the copy does not carry. It raises a `UserWarning` naming the object and the door that can, and changes nothing. Declare `db_check=True` on an existing column `author.kind`, for example, and the connect warns: - + ```text -Check constraint 'ck_author_kind' on column 'author.kind' is declared but missing from the live table, and SQLite cannot add a constraint to an existing column (it requires a full table rebuild). The invariant is not database-enforced; generate a reviewed migration with `ferro migrate new` to apply it. +ferro auto-migrate: Check constraint 'ck_author_kind' on column 'author.kind' is declared but missing from the live table, and SQLite cannot add a constraint to an existing column (it requires a full table rebuild). The invariant is not database-enforced; generate a reviewed migration with `ferro migrate new` to apply it. ``` The connect goes on, and the column stays unchecked. [Migrations](migrations.md#sqlite-table-rebuilds) generate the rebuild as a reviewed step. A *foreign default* (a server default on a column that no model declares, [below](#destructive-drops-with-migrate_destructive)) is the one exception: no migration plans it, since the migrations' snapshots never hold it, so the warning names the hand rebuild that drops it, or a hand-written SQL step that does (`ferro migrate new --sql-step drop_default`). Row-level security is Postgres-only on every door: SQLite gets one warning per table and no DDL. @@ -187,9 +187,9 @@ ALTER TABLE "author" ALTER COLUMN "slug" DROP DEFAULT Under `migrate_updates` alone the default stays, and the pass warns on every start: - + ```text -author.slug has default '' in the database, none declared; it is left in place: connect(..., migrate_destructive=True) drops it, or run ALTER TABLE "author" ALTER COLUMN "slug" DROP DEFAULT. +ferro auto-migrate: author.slug has default '' in the database, none declared; it is left in place: connect(..., migrate_destructive=True) drops it, or run ALTER TABLE "author" ALTER COLUMN "slug" DROP DEFAULT. ``` Ferro's own two defaults are never foreign: a `serial` key's sequence on Postgres (recognised by the sequence the column owns, so a renamed table's still counts), and on SQLite a literal default on a `NOT NULL` column (what `migrate_updates` keeps when it adds a required column, above). On SQLite any other default (an expression such as `CURRENT_TIMESTAMP`, or any default on a nullable column) is foreign, and the pass warns and leaves it, since only a rebuild drops a default there. `ferro migrate drift` and `ferro migrate baseline` report the same defaults. @@ -233,16 +233,16 @@ except ferro.OperationalError as error: Every auto-migrate pass takes the same run lock `ferro migrate up` takes, so two processes booting together never collide: the second waits, saying so with a `UserWarning`, and sees the first one's DDL: - + ```text -connect(auto_migrate=…) is waiting: another ferro migration run or auto-migrate pass holds the run lock on this database. It goes on once that one finishes. +ferro auto-migrate: connect(auto_migrate=…) is waiting: another ferro migration run or auto-migrate pass holds the run lock on this database. It goes on once that one finishes. ``` -It waits up to the project's [`lock_timeout`](../../reference/configuration.md#lock_timeout) (default `30s`; the setting's `"0"` waits without a limit, unlike the CLI's `--lock-timeout 0`, which refuses at once), then refuses: +It waits up to the project's [`lock_timeout`](../../reference/configuration.md#lock_timeout) (default `30s`; the setting's `"0"` waits without a limit, unlike the CLI's `--lock-timeout 0`, which refuses at once), then refuses. With `lock_timeout = "1s"`, on SQLite (whose schema is `main`): - + ```text -connect(auto_migrate=…) gave up waiting for the run lock on public: another ferro migration run or auto-migrate pass held it longer than lock_timeout (30s). Nothing was applied. Wait for that run to finish and try again, or raise lock_timeout; `ferro migrate status` shows a migration run while it holds the lock. +connect(auto_migrate=…) gave up waiting for the run lock on main: another ferro migration run or auto-migrate pass held it longer than lock_timeout (1s). Nothing was applied. Wait for that run to finish and try again, or raise lock_timeout; `ferro migrate status` shows a migration run while it holds the lock. ``` The refused process applied nothing. A database Ferro migrations govern is refused before the pass waits at all, so a boot never sits behind a `ferro migrate up` only to be refused once it finishes. diff --git a/docs/pages/guide/schema/deploying.md b/docs/pages/guide/schema/deploying.md index 74fe0aad..274112df 100644 --- a/docs/pages/guide/schema/deploying.md +++ b/docs/pages/guide/schema/deploying.md @@ -65,7 +65,7 @@ async def start() -> None: Behind its migrations, it raises `PendingMigrationsError`: - + ```text ferro.migrations.PendingMigrationsError: ferro.migrations: this database is behind its migrations. pending 0002_author_slug @@ -121,14 +121,14 @@ In a rolling deploy, old code keeps running while the migration applies. A migra | Release | Model | Migration | Old code during the deploy | | :--- | :--- | :--- | :--- | -| 1 | `slug: str \| None = None` | `0008_author_slug`: one schema step, `ADD COLUMN`, nullable | runs against a database one migration ahead of it | -| 2 | `slug: str` | `0009_author_slug_required`: backfill, add-constraint, contract | release 1 code already writes `slug` | +| 1 | `slug: str \| None = None` | `0002_author_slug`: one schema step, `ADD COLUMN`, nullable | runs against a database one migration ahead of it | +| 2 | `slug: str` | `0003_author_slug_required`: backfill, add-constraint, contract | release 1 code already writes `slug` | Between the two, release 0 code meets a database that has applied a migration its checkout does not have. `require_applied()` and `up()` refuse that by default: - + ```text -ferro.migrations.DatabaseAheadError: ferro migrate: this database has applied 0008_author_slug, which is not in migrations/. +ferro.migrations.DatabaseAheadError: ferro migrate: this database has applied 0002_author_slug, which is not in migrations/. The directory is behind the database: check out the branch that holds it. Nothing was applied. Pass allow_ahead=True to run beside them (a rolling deploy). ``` @@ -139,17 +139,17 @@ So the old release starts with `allow_ahead="expand-only"`: await ferro.migrations.require_applied(allow_ahead="expand-only") ``` -It lets the database through when every step the old checkout lacks is an *expand* step: one the old release's writes keep succeeding beside. `0008_author_slug` adds a nullable column, so release 0 starts. A database ahead by a *contract* step is refused, naming the first one (`ferro migrate up --allow-ahead=expand-only` does the same): +It lets the database through when every step the old checkout lacks is an *expand* step: one the old release's writes keep succeeding beside. `0002_author_slug` adds a nullable column, so release 0 starts. A database ahead by a *contract* step is refused, naming the first one (`ferro migrate up --allow-ahead=expand-only` does the same): - + ```text -ferro.migrations.DatabaseAheadError: ferro migrate: this database has applied 0008_drop_bio, which is not in migrations/. +ferro.migrations.DatabaseAheadError: ferro migrate: this database has applied 0003_drop_bio, which is not in migrations/. The directory is behind the database: check out the branch that holds it. -It is ahead by contract steps, starting with 0008_drop_bio:01 (contract: a write the previous release makes can fail beside it), so allow_ahead="expand-only" does not let it through. Nothing was applied. +It is ahead by contract steps, starting with 0003_drop_bio:01 (contract: a write the previous release makes can fail beside it), so allow_ahead="expand-only" does not let it through. Nothing was applied. Pass allow_ahead=True to run beside them (a rolling deploy). ``` -`.contract_steps` lists every such step (`["0008_drop_bio:01"]`). Each step's class is decided from what the step does: adding a nullable column is expand, dropping a column is contract. The [step classes](../../reference/migrations.md#step-classes) table lists the class of every kind of step. +`.contract_steps` lists every such step (`["0003_drop_bio:01"]`). Each step's class is decided from what the step does: adding a nullable column is expand, dropping a column is contract. The [step classes](../../reference/migrations.md#step-classes) table lists the class of every kind of step. The class is about **writes**. `expand` means the previous release's writes keep succeeding beside the step, not that its reads only meet values it knows. Adding a label is `expand`, yet release 0 reading a row that holds the new label gets a value its enum does not have. So add a label in one release and write it in the next. diff --git a/docs/pages/guide/schema/overview.md b/docs/pages/guide/schema/overview.md index 4c8597c3..f5ea1c67 100644 --- a/docs/pages/guide/schema/overview.md +++ b/docs/pages/guide/schema/overview.md @@ -42,7 +42,7 @@ Rungs 1 and 2 are the first door ([Auto-migrate](auto-migrate.md)); each flag im A database is managed by auto-migrate **or** by migrations, never both. Once a database has run a migration, it carries the tracking table `_ferro_migrations`, and `connect()` refuses every auto-migrate flag on it before running any DDL: - + ```text connect(auto_migrate=…) is refused: main is governed by ferro migrations (main._ferro_migrations). Use ferro migrate up, or drop the tracking tables to leave migrations. ``` diff --git a/docs/pages/guide/schema/renames-and-retirements.md b/docs/pages/guide/schema/renames-and-retirements.md index c4dbfcab..0aa4aa72 100644 --- a/docs/pages/guide/schema/renames-and-retirements.md +++ b/docs/pages/guide/schema/renames-and-retirements.md @@ -165,7 +165,7 @@ Removing a label in the release that stops writing it breaks the release still s The member stays, so a row that holds `paused` still loads as `Status.PAUSED`. Writing the label is refused, at construction and by attribute assignment. `Author(name="Grace Hopper", email="grace@example.com", bio="Admiral", status="paused")` raises a validation error: - + ```text 1 validation error for Author status diff --git a/docs/pages/reference/configuration.md b/docs/pages/reference/configuration.md index e781f356..77a3c505 100644 --- a/docs/pages/reference/configuration.md +++ b/docs/pages/reference/configuration.md @@ -85,11 +85,11 @@ How long a DDL statement Ferro runs on Postgres, in a migration step or in an au ### `lock_timeout` -How long a run waits for the run lock while another run holds it: `"500ms"`, `"30s"`, `"1m"`, a number of seconds, or `"0"` to wait without a limit. It is the default of `--lock-timeout` on `ferro migrate up`, `down`, `baseline` and `rerecord` (and of `lock_timeout=` on their Python calls); the flag wins. An auto-migrate pass (`connect(auto_migrate=…)`, `ferro.create_tables()`, `ferro.migrate()`) waits for it too, so a replica booting while another one's pass runs waits instead of failing: +How long a run waits for the run lock while another run holds it: `"500ms"`, `"30s"`, `"1m"`, a number of seconds, or `"0"` to wait without a limit. It is the default of `--lock-timeout` on `ferro migrate up`, `down`, `baseline` and `rerecord` (and of `lock_timeout=` on their Python calls); the flag wins. An auto-migrate pass (`connect(auto_migrate=…)`, `ferro.create_tables()`, `ferro.migrate()`) waits for it too, so a replica booting while another one's pass runs waits instead of failing. Once the wait passes `lock_timeout` (here `"1s"`, on SQLite) it refuses: - + ```text -connect(auto_migrate=…) gave up waiting for the run lock on public: another ferro migration run or auto-migrate pass held it longer than lock_timeout (30s). Nothing was applied. Wait for that run to finish and try again, or raise lock_timeout; `ferro migrate status` shows a migration run while it holds the lock. +connect(auto_migrate=…) gave up waiting for the run lock on main: another ferro migration run or auto-migrate pass held it longer than lock_timeout (1s). Nothing was applied. Wait for that run to finish and try again, or raise lock_timeout; `ferro migrate status` shows a migration run while it holds the lock. ``` Unlike the flag, whose `0` refuses at once, the setting's `"0"` waits without a limit. As with `ddl_lock_timeout`, every configured database must set the same `lock_timeout` for an auto-migrate pass. diff --git a/docs/pages/reference/row-level-security.md b/docs/pages/reference/row-level-security.md index 0b7b0aba..7dd9ce86 100644 --- a/docs/pages/reference/row-level-security.md +++ b/docs/pages/reference/row-level-security.md @@ -203,9 +203,9 @@ A session that only inherits settings from an outer Postgres session opens on SQ `connect(migrate_updates=True)` (or `migrate_destructive=True`) warns when the connecting role is neither a superuser nor `BYPASSRLS` and an existing table it migrates declares `RowSecurity(force=True)`: - + ```text -ferro auto-migrate: The connected role is neither a superuser nor BYPASSRLS, and this migration touches table(s) 'invoice' with FORCE ROW LEVEL SECURITY. Row policies apply to the migrating role too, so a backfill or data step can silently see and update zero rows. Migrate as a role with BYPASSRLS if this pass moves data. +ferro auto-migrate: The connected role is neither a superuser nor BYPASSRLS, and this migration touches table(s) 'document', 'invoice' with FORCE ROW LEVEL SECURITY. Row policies apply to the migrating role too, so a backfill or data step can silently see and update zero rows. Migrate as a role with BYPASSRLS if this pass moves data. ``` It does not check whether the table's live `FORCE` flag was already set. A table created by the same connect has no rows to miss, so `auto_migrate=True` and `create_tables()` never print it. diff --git a/docs/solutions/patterns/docs-page-conventions.md b/docs/solutions/patterns/docs-page-conventions.md index 66ad14a5..1a4388f5 100644 --- a/docs/solutions/patterns/docs-page-conventions.md +++ b/docs/solutions/patterns/docs-page-conventions.md @@ -90,9 +90,10 @@ To savings -500 | Note | The block is | Checked by | | --- | --- | --- | | `` | What that snippet region prints | `tests/test_docs_output.py`, byte for byte | +| `` | What the region prints on PostgreSQL; the script runs as `.py ` | `tests/test_docs_output.py` under `postgres_only`, byte for byte | | `` | What the nearest Python fence above prints; that fence is marked `{test=run prelude=}` | `tests/test_docs_output.py`, byte for byte | | ``, `` | What `ferro migrate` prints | `tests/test_docs_migrations.py` | -| `` | Text no script prints: a shell session, a file tree, an agent prompt, or an error from a scenario no example runs | Nothing; the note says what it is | +| `` | Text Ferro does not print: a sketch, a file tree, another tool's shell session, a prompt to paste | Nothing; the note says what it is | `tests/test_docs_output.py` refuses a ```text block with no note. To add one under an embedded example, put the `print` inside the snippet region (a @@ -100,8 +101,15 @@ region that catches an error prints `f"{type(exc).__name__}: {exc}"`), write the note and an empty fence, and fill the fence from the failure message. A printed line belongs to the innermost line of the example inside a region on the call stack, so a helper defined outside the region can do the printing. -Prefer `prints:` to `shows:`: an error a page quotes is better raised by an -example region than typed. +Every error, warning and message Ferro prints is a `prints:` block: write the +scenario that raises it as an example region, however small (a model, a +connect, a held lock, a migrations project from `_migrations_project.py`). +`docs/examples/auto_migrate_errors.py` and `migration_errors.py` are the +pattern; a region not shown on any page is fine, since the page shows the +text. A `shows:` block that reads like Ferro's output (`ferro.`, +`ferro auto-migrate:`, `SomethingError:`, `N validation error for`, +`connect(`) fails the test. The one exception, in `VARIES`, is the statement +log on the Connections guide, whose lines carry timings that change on every run. ### The session wrapper diff --git a/tests/_docs_sql.py b/tests/_docs_sql.py index 99090bcc..428f3612 100644 --- a/tests/_docs_sql.py +++ b/tests/_docs_sql.py @@ -8,9 +8,10 @@ example line that caused it, and groups them by snippet region; ``check_block`` and ``check_printed`` compare a page's block with that record byte for byte. -A capture runs ``python -m tests._docs_sql [url]``: -the script runs as ``__main__`` with ``ferro.connect`` pointed at ``url`` when -one is given, and the record is written to ``out.json``. +A capture runs ``python -m tests._docs_sql [args...]``: +the script runs as ``__main__`` with ``args`` as its arguments and +``ferro.connect`` pointed at ``url`` unless it is ``-``, and the record is +written to ``out.json``. What a block shows: @@ -58,8 +59,12 @@ ) """The first line of a ```sql block: ``-- .py: ()``.""" -PRINTS = re.compile(r"^(?P[A-Za-z0-9_]+\.py):(?P[A-Za-z0-9_-]+)$") -"""The value of a ```text block's ```` note.""" +PRINTS = re.compile( + r"^(?P[A-Za-z0-9_]+\.py):(?P[A-Za-z0-9_-]+)" + r"(?: \((?Ppostgres)\))?$" +) +"""The value of a ```text block's ```` +note, with `` (postgres)`` after it for what the region prints on PostgreSQL.""" SNIPPET = re.compile(r"#\s*--8<--\s*\[(?Pstart|end):(?P[A-Za-z0-9_-]+)\]") """A snippet region's start or end line in an example script.""" @@ -341,7 +346,7 @@ def run(self) -> Run: return Run(self.by_region(), self.stdout, self.schemas) -def _run_capture(path: Path, dialect: str, url: str | None) -> Run: +def _run_capture(path: Path, dialect: str, url: str | None, args: list[str]) -> Run: """Run the example at ``path`` here, as ``__main__``, and return its record.""" import runpy @@ -387,7 +392,7 @@ async def create_then_snapshot(*args: Any, **kwargs: Any) -> Any: sys.monitoring.register_callback(tool, sys.monitoring.events.LINE, capture.on_line) sys.monitoring.set_events(tool, sys.monitoring.events.LINE) sys.path.insert(0, str(path.parent)) - sys.argv = [str(path)] + sys.argv = [str(path), *args] stdout = sys.stdout class _Tee(io.TextIOBase): @@ -406,12 +411,15 @@ def write(self, text: str) -> int: return capture.run() -_captured: dict[tuple[str, str, str | None], Run] = {} +_captured: dict[tuple[str, str, str | None, tuple[str, ...]], Run] = {} """Each example's run per dialect and database, made once per test process.""" def captured( - example: str | Path, dialect: str = "sqlite", url: str | None = None + example: str | Path, + dialect: str = "sqlite", + url: str | None = None, + args: tuple[str, ...] = (), ) -> Run: """Run an example script once in a subprocess and return what it did. @@ -421,6 +429,7 @@ def captured( dialect: The dialect the script runs on, ``sqlite`` or ``postgres``. url: The database ``ferro.connect`` is pointed at. Defaults to the URL the script passes. + args: The script's command-line arguments (``sys.argv[1:]``). Returns: The run's record, cached for the rest of the test process. @@ -429,13 +438,12 @@ def captured( AssertionError: The script failed; the message carries its output. """ path = example if isinstance(example, Path) else DOCS_EXAMPLES / example - key = (str(path), dialect, url) + key = (str(path), dialect, url, args) if key not in _captured: with tempfile.TemporaryDirectory() as scratch: out = Path(scratch) / "run.json" command = ["-m", "tests._docs_sql", str(path), dialect, str(out)] - if url is not None: - command.append(url) + command += [url or "-", *args] run_python(command) record = json.loads(out.read_text(encoding="utf-8")) _captured[key] = Run( @@ -533,9 +541,10 @@ def check_same_on_postgres(block: Fence, run: Callable[[str, str], Run]) -> None NOT_PRINTED = "shows" -"""The note kind of a ```text block that is not what a script prints (a shell -transcript, a traceback, a file tree), which says what it shows instead: -````.""" +"""The note kind of a ```text block holding text Ferro does not print (a +sketch, a file tree, another tool's shell session, a prompt to paste), which +says what it shows instead: ````. Whatever +Ferro prints, raises or warns is a ``prints:`` block.""" TEXT_NOTES = { "prints": ( @@ -544,25 +553,33 @@ def check_same_on_postgres(block: Fence, run: Callable[[str, str], Run]) -> None ), "output": "what ferro migrate prints, regenerated by tests/test_docs_migrations.py", "refusal": "a refusal ferro prints, regenerated by tests/test_docs_migrations.py", - NOT_PRINTED: "not a script's output; the note says what it shows", + NOT_PRINTED: "text Ferro does not print; the note says what it shows", } """Every note a ```text block can carry, and what checks it.""" -def check_printed(block: Fence, run: Callable[[str, str], Run] = captured) -> None: +def check_printed( + block: Fence, + run: Callable[[str, str], Run] = captured, + *, + dialect: str = "sqlite", +) -> None: """Assert a ```text block says where it comes from, and holds what it says. A block under ```` must equal what - that region prints on SQLite, byte for byte; one under - ```` must equal what the nearest Python fence above - it prints, a fence marked ``{test=run prelude=}``. A block under - another note (``TEXT_NOTES``) is checked elsewhere, or says what it shows - instead. + that region prints on SQLite, byte for byte, and one under + ```` what it prints on + PostgreSQL. One under ```` must equal what the + nearest Python fence above it prints, a fence marked + ``{test=run prelude=}``. A block under another note + (``TEXT_NOTES``) is checked elsewhere, or says what it shows instead. Args: block: The page's ```text block. run: Given an example script's name and a dialect, its run's record. Defaults to ``captured``. + dialect: The database this check runs against. A ``prints:`` block for + the other one only has its example and region checked. Raises: AssertionError: The block has no note, an unknown one, names an @@ -583,7 +600,8 @@ def check_printed(block: Fence, run: Callable[[str, str], Run] = captured) -> No if kind != "prints": return if value == "above": - _check_printed_above(block, where) + if dialect == "sqlite": + _check_printed_above(block, where) return marker = PRINTS.match(value) assert marker is not None, ( @@ -593,7 +611,9 @@ def check_printed(block: Fence, run: Callable[[str, str], Run] = captured) -> No example, snippet = marker["example"], marker["snippet"] named = f"{where} ({example}:{snippet})" _named_region(where, example, snippet, named) - printed = run(example, "sqlite").regions.get(snippet, Region()).shown_output + if (marker["dialect"] or "sqlite") != dialect: + return + printed = run(example, dialect).regions.get(snippet, Region()).shown_output assert printed, f"{named}: the region {snippet!r} of {example} prints nothing" assert block.body == printed, ( f"{named}: the block differs from what {example}:{snippet} prints. " @@ -622,8 +642,10 @@ def _check_printed_above(block: Fence, where: str) -> None: if __name__ == "__main__": - script, dialect, out, *rest = sys.argv[1:] - result = _run_capture(Path(script).resolve(), dialect, rest[0] if rest else None) + script, dialect, out, url, *args = sys.argv[1:] + result = _run_capture( + Path(script).resolve(), dialect, None if url == "-" else url, args + ) Path(out).write_text( json.dumps( { diff --git a/tests/test_docs_output.py b/tests/test_docs_output.py index 46767efb..5008ab84 100644 --- a/tests/test_docs_output.py +++ b/tests/test_docs_output.py @@ -6,13 +6,19 @@ - ````: what that snippet region of ``docs/examples/.py`` prints. This test runs the script once on SQLite and compares the block with the region's printed lines byte for byte. +- ````: what the region + prints on PostgreSQL, where the script runs as + ``.py `` (a fresh schema and a role name the + test owns), under the ``postgres_only`` marker. - ````: what the nearest Python fence above it prints, a fence marked ``{test=run prelude=}``, run the way ``tests/test_docs_examples.py`` runs it. - ```` and ````: what ``ferro migrate`` prints, regenerated by ``tests/test_docs_migrations.py``. -- ````: text no script prints (a shell transcript, - a traceback, a file tree, a prompt), skipped on purpose and saying so. +- ````: text Ferro does not print (a sketch, a + file tree, another tool's shell session, a prompt to paste), skipped on + purpose and saying so. Ferro's own errors and warnings are ``prints:`` blocks, + and a ``shows:`` block that reads like one is refused. An unmarked block is refused, so typed output cannot slip onto a page. """ @@ -21,10 +27,12 @@ from pathlib import Path +import re + import pytest from tests._docs_harness import Fence, fenced_blocks, page_fences -from tests._docs_sql import Region, Run, check_printed, page_name +from tests._docs_sql import PRINTS, Region, Run, captured, check_printed, page_name @pytest.mark.parametrize("block", page_fences("text"), ids=page_name) @@ -32,6 +40,61 @@ def test_a_text_block_is_what_it_says_it_is(block: Fence) -> None: check_printed(block) +def _postgres_blocks() -> list[Fence]: + return [ + block + for block in page_fences("text") + if block.note + and block.note[0] == "prints" + and (marker := PRINTS.match(block.note[1])) + and marker["dialect"] == "postgres" + ] + + +@pytest.mark.postgres_only +@pytest.mark.parametrize("block", _postgres_blocks(), ids=page_name) +def test_a_postgres_text_block_is_what_its_example_prints_there( + block: Fence, db_url: str, pg_role +) -> None: + args = (db_url, pg_role("app")) + check_printed( + block, + lambda example, dialect: captured(example, dialect, args=args), + dialect="postgres", + ) + + +FERRO_OUTPUT = re.compile( + r"(?m)^(?:ferro\.|ferro auto-migrate:|[\w.]*(?:Error|Refused|Warning):" + r"|\d+ validation errors? for |connect\()" +) +"""A line that reads like something Ferro prints, raises or warns.""" + + +VARIES = { + "guide/connections.md": "the statement log's lines end with how long each " + "statement took, which differs on every run", +} +"""Pages whose ``shows:`` block holds Ferro's output that no run reproduces +byte for byte, and why.""" + + +@pytest.mark.parametrize( + "block", + [block for block in page_fences("text") if block.note and block.note[0] == "shows"], + ids=page_name, +) +def test_a_shows_block_holds_nothing_ferro_prints(block: Fence) -> None: + if page_name(block).rsplit(":", 1)[0] in VARIES: + return + line = FERRO_OUTPUT.search(block.body) + assert line is None, ( + f"{page_name(block)}: a block quotes what Ferro prints " + f"({line.group(0)!r}...); make it a block backed by an " + "example region that triggers it" + ) + + def _fake(example: str, dialect: str) -> Run: regions = {"first-query": Region(printed=["Rent -1450", "Groceries -82.40"])} return Run(regions if example == "predicates.py" else {}, "", []) From df521563261d3b334e1e0b2681d982d5f62a281b Mon Sep 17 00:00:00 2001 From: Taylor Date: Wed, 21 Oct 2026 15:23:51 -0400 Subject: [PATCH 12/13] test(docs): backend=postgres runs a page fence on PostgreSQL; eight Postgres blocks run A test=run fence marked backend=postgres runs under postgres_only with every connect() in its prelude and block pointed at the test database, and only compiles without Postgres. The Raw SQL, Connections, Backends and Auto-migrate blocks that needed a server now run on it; the five web-framework blocks on the RLS how-to stay compile-only (Litestar and FastAPI are not installed). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP --- docs/examples/_preludes/__init__.py | 22 +++++++- docs/pages/concepts/backends.md | 2 +- docs/pages/guide/connections.md | 6 +-- docs/pages/guide/raw-sql.md | 6 +-- docs/pages/guide/schema/auto-migrate.md | 2 +- .../patterns/docs-page-conventions.md | 3 ++ tests/_docs_harness.py | 53 +++++++++++++++++-- tests/test_docs_examples.py | 44 +++++++++++++++ 8 files changed, 124 insertions(+), 14 deletions(-) diff --git a/docs/examples/_preludes/__init__.py b/docs/examples/_preludes/__init__.py index 6a6ee158..20fdfc39 100644 --- a/docs/examples/_preludes/__init__.py +++ b/docs/examples/_preludes/__init__.py @@ -11,7 +11,10 @@ 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. +points at the docs page or docstring, not at this runner. With +``FERRO_DOCS_DATABASE_URL`` set (a ``backend=postgres`` fence), every +``connect()`` the prelude or the block makes goes to that database instead of +the URL it names. """ from __future__ import annotations @@ -20,6 +23,7 @@ import asyncio import importlib.util import inspect +import os import sys from pathlib import Path from types import ModuleType @@ -80,8 +84,24 @@ async def run_block(prelude: str, source: str, path: str, first_line: int) -> No await result +def point_connect_at(url: str) -> None: + """Send every ``ferro.connect`` call to ``url``, whatever URL it names.""" + import ferro + + connect = ferro.connect + + async def connect_to(_url: str, *args: object, **kwargs: object) -> None: + await connect(url, *args, **kwargs) + + ferro.connect = connect_to + + def main(argv: list[str]) -> None: prelude, path, first_line = argv + url = os.environ.get("FERRO_DOCS_DATABASE_URL") + if url: + # Before the prelude imports connect, so its name is this one too. + point_connect_at(url) asyncio.run(run_block(prelude, sys.stdin.read(), path, int(first_line))) diff --git a/docs/pages/concepts/backends.md b/docs/pages/concepts/backends.md index d3894bdb..db14fd7a 100644 --- a/docs/pages/concepts/backends.md +++ b/docs/pages/concepts/backends.md @@ -85,7 +85,7 @@ Because SQLite stores any value in any column, a SQLite column whose declared ty Add `ferro_search_path` to a PostgreSQL URL and every pooled connection runs `SET search_path` to that schema: -```python {test=compile reason="needs a PostgreSQL server"} +```python {test=run prelude=connections backend=postgres} from ferro import connect await connect( diff --git a/docs/pages/guide/connections.md b/docs/pages/guide/connections.md index 0ce7b08c..bc962834 100644 --- a/docs/pages/guide/connections.md +++ b/docs/pages/guide/connections.md @@ -54,7 +54,7 @@ await connect("sqlite:app.db?mode=rwc") Pass a `postgresql://` URL; add `sslmode=require` to require TLS: -```python {test=compile reason="needs a PostgreSQL server"} +```python {test=run prelude=connections backend=postgres} from ferro import connect await connect("postgresql://user:password@localhost:5432/app?sslmode=require") @@ -203,7 +203,7 @@ The [`connect`](../api/connection.md#ferro.connect) reference lists every option Size a connection's pool with `PoolConfig`: -```python {test=compile reason="needs a PostgreSQL server"} +```python {test=run prelude=connections backend=postgres} from ferro import PoolConfig, connect await connect( @@ -295,7 +295,7 @@ CREATE TABLE IF NOT EXISTS "post" ( "author_id" integer NOT NULL, "body" varchar Add the `ferro_search_path` parameter to a PostgreSQL URL to pin the connection to one schema: -```python {test=compile reason="needs a PostgreSQL server"} +```python {test=run prelude=connections backend=postgres} from ferro import connect await connect("postgresql://user:password@localhost:5432/app?ferro_search_path=tenant_a") diff --git a/docs/pages/guide/raw-sql.md b/docs/pages/guide/raw-sql.md index 230a9d21..bd4441b3 100644 --- a/docs/pages/guide/raw-sql.md +++ b/docs/pages/guide/raw-sql.md @@ -66,7 +66,7 @@ Both clicks matched, so `execute()` returns `2`. Pass one statement per call; a A few Postgres statements refuse to run inside a transaction block: `CREATE INDEX CONCURRENTLY`, `VACUUM`, and `ALTER TYPE ... ADD VALUE` before Postgres 12. In a session that carries settings, Ferro runs every operation inside a transaction, so these statements fail with error `25001`. Pass `autocommit=True` to run one outside it: -```python {test=compile reason="needs a Postgres database"} +```python {test=run prelude=raw_sql backend=postgres} async with engines.session(settings={"myapp.tenant_id": "acme"}): await execute( "CREATE INDEX CONCURRENTLY idx_event_kind ON event (kind)", @@ -95,7 +95,7 @@ Ferro hands your SQL to the driver unchanged, so you write each backend's own pl Numbered `$1`, `$2`, ... placeholders: - ```python {test=compile reason="needs a Postgres database"} + ```python {test=run prelude=raw_sql backend=postgres} rows = await fetch_all( "SELECT id, kind FROM event WHERE kind = $1 AND id >= $2", "click", 2 ) @@ -127,7 +127,7 @@ Raw SQL has no schema map, so Ferro does not cast bind values for you, as asyncp On PostgreSQL, write the cast in the SQL wherever the column type is stricter than text: -```python {test=compile reason="needs a Postgres database"} +```python {test=run prelude=raw_sql backend=postgres} from datetime import UTC, datetime since = datetime(2026, 3, 2, 9, 5, tzinfo=UTC) diff --git a/docs/pages/guide/schema/auto-migrate.md b/docs/pages/guide/schema/auto-migrate.md index b25f4831..c407ae0f 100644 --- a/docs/pages/guide/schema/auto-migrate.md +++ b/docs/pages/guide/schema/auto-migrate.md @@ -144,7 +144,7 @@ On PostgreSQL, a `StrEnum` field creates a **native enum type**, and a type that Connect to the Postgres database with `migrate_updates=True`: -```python {test=compile reason="label addition needs a Postgres database"} +```python {test=run prelude=auto_migrate backend=postgres} await ferro.connect("postgresql://localhost/app", migrate_updates=True) ``` diff --git a/docs/solutions/patterns/docs-page-conventions.md b/docs/solutions/patterns/docs-page-conventions.md index 1a4388f5..4bc65378 100644 --- a/docs/solutions/patterns/docs-page-conventions.md +++ b/docs/solutions/patterns/docs-page-conventions.md @@ -208,9 +208,12 @@ docstring carries one of three markers; `tests/test_docs_examples.py` and | Marker | What the harness does | | --- | --- | | `{test=run prelude=}` | Runs the block after `docs/examples/_preludes/.py`. | +| `{test=run prelude= backend=postgres}` | Runs it on PostgreSQL under the `postgres_only` marker, every `connect()` in the prelude and the block pointed at the test database whatever URL it names; without Postgres the test is skipped and the block only compiles. | | `{test=compile reason="..."}` | Compiles the block; the reason says why it cannot run. | | `{test=pytest path="tests/.py"}` | Writes the block at that path in a scratch project and runs pytest there (`tests/test_docs_testing_fixtures.py`, for the testing how-to). | +A block that needs Postgres runs with `backend=postgres`; `test=compile` is for a block that cannot run anywhere the suite can (a web framework the test groups do not install, a generated file). + `lint=check` can be added to any of them. Any other setting is refused with the marker to write instead, so `{compile-only="..."}` or a bare `{test=run}` fails the test rather than being ignored. diff --git a/tests/_docs_harness.py b/tests/_docs_harness.py index d53084d4..bef80fce 100644 --- a/tests/_docs_harness.py +++ b/tests/_docs_harness.py @@ -18,6 +18,11 @@ how-to hands readers; ``tests/test_docs_testing_fixtures.py`` writes it at that path in a scratch project and runs pytest there. +``backend=postgres`` on a ``test=run`` fence runs it against PostgreSQL: +every ``connect()`` the prelude or the block makes is pointed at the test +database, whatever URL it names. Such a fence runs only under the +``postgres_only`` marker, so a run without Postgres skips it, saying so. + ``lint=check`` also lints the block. Every Python fence, on a docs page or in a docstring, carries a ``test`` mode (``fence_mode``). Any other setting, or a mode without its setting, is refused with the marker to write. @@ -51,7 +56,9 @@ _TEST_MODES = ("run", "compile", "pytest") # The setting each test mode needs, and the only mode that takes it. _MODE_SETTING = {"run": "prelude", "compile": "reason", "pytest": "path"} -_KNOWN_SETTINGS = frozenset({"test", "lint", *_MODE_SETTING.values()}) +_KNOWN_SETTINGS = frozenset({"test", "lint", "backend", *_MODE_SETTING.values()}) +_BACKENDS = ("postgres",) +"""The databases a ``test=run`` fence can ask for besides SQLite.""" _MODE_EXAMPLE = { "run": "prelude=", "compile": 'reason=""', @@ -341,6 +348,14 @@ def fence_settings(example: CodeExample) -> dict[str, str]: ) if "lint" in settings and settings["lint"] != "check": raise ValueError(f"{example}: lint={settings['lint']} is not lint=check") + backend = settings.get("backend") + if backend is not None and backend not in _BACKENDS: + raise ValueError(f"{example}: backend={backend} is not backend=postgres") + if backend is not None and mode != "run": + raise ValueError( + f"{example}: backend={backend} needs test=run; mark the fence " + f"{{test=run prelude= backend={backend}}}" + ) return settings @@ -367,19 +382,25 @@ def fence_mode(example: CodeExample) -> str: def check_fence( - example: CodeExample, *, eval_example: EvalExample | None = None + example: CodeExample, + *, + eval_example: EvalExample | None = None, + database_url: str | None = None, ) -> str | None: """Hold one Python fence to its contract and return what it printed. The fence compiles (top-level ``await`` allowed), says how it is tested (``fence_mode``), is linted when marked ``lint=check``, and runs when marked ``test=run``. A ``test=pytest`` fence is run by the testing - how-to's own test. + how-to's own test. A ``backend=postgres`` fence runs only when given + ``database_url``. Args: example: The fence, from a docs page or a docstring. eval_example: pytest-examples' fixture, which lints a ``lint=check`` fence. A fence marked ``lint=check`` without one is refused. + database_url: The PostgreSQL database a ``backend=postgres`` fence runs + against. Without it such a fence is only compiled and checked. Returns: What a ``test=run`` fence printed, or ``None`` for one that only @@ -400,7 +421,11 @@ def check_fence( ruff_ignore=_LINT_IGNORES, line_length=100, target_version="py313" ) eval_example.lint(example) - return run_fence(example) if mode == "run" else None + if mode != "run": + return None + if fence_settings(example).get("backend") == "postgres" and database_url is None: + return None + return run_fence(example, database_url=database_url) # -- docstrings ------------------------------------------------------------------- @@ -440,12 +465,21 @@ def docstring_examples(package: str, search_path: Path) -> list[CodeExample]: return sorted(examples, key=lambda example: (str(example.path), example.start_line)) -def run_fence(example: CodeExample) -> str: +def run_fence(example: CodeExample, *, database_url: str | None = None) -> 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. + + Args: + example: The fence. + database_url: The PostgreSQL database a ``backend=postgres`` fence runs + against; every ``connect()`` in the run is pointed at it. + + Raises: + ValueError: The fence is not marked ``test=run``, or is marked + ``backend=postgres`` and no ``database_url`` was given. """ settings = fence_settings(example) if settings.get("test") != "run": @@ -453,6 +487,14 @@ def run_fence(example: CodeExample) -> str: f"{example}: only a fence marked {{test=run prelude=}} runs" ) prelude = settings["prelude"] + env = None + if settings.get("backend") == "postgres": + if database_url is None: + raise ValueError( + f"{example}: backend=postgres needs a database_url; it runs " + "under the postgres_only marker" + ) + env = {"FERRO_DOCS_DATABASE_URL": database_url} try: result = run_python( [ @@ -463,6 +505,7 @@ def run_fence(example: CodeExample) -> str: ], input=example.source, timeout=120, + env=env, ) except AssertionError as failure: raise AssertionError( diff --git a/tests/test_docs_examples.py b/tests/test_docs_examples.py index dc2a0ece..c0a305ca 100644 --- a/tests/test_docs_examples.py +++ b/tests/test_docs_examples.py @@ -37,6 +37,50 @@ def test_docs_examples(example: CodeExample, eval_example: EvalExample) -> None: check_fence(example, eval_example=eval_example) +def _on_postgres(examples: list[CodeExample]) -> list[CodeExample]: + return [ + example + for example in examples + if fence_settings(example).get("backend") == "postgres" + ] + + +@pytest.mark.postgres_only +@pytest.mark.parametrize("example", _on_postgres(page_examples()), ids=str) +def test_docs_examples_on_postgres(example: CodeExample, db_url: str) -> None: + """A page fence marked ``backend=postgres`` runs against PostgreSQL, every + ``connect()`` in it pointed at the test's database.""" + check_fence(example, database_url=db_url) + + +def test_a_postgres_fence_without_a_database_is_refused_by_run_fence() -> None: + example = _fence("python {test=run prelude=reference backend=postgres}") + with pytest.raises(ValueError, match="backend=postgres needs a database_url"): + run_fence(example) + + +def test_backend_needs_test_run() -> None: + with pytest.raises(ValueError, match=r"backend=postgres needs test=run"): + fence_settings(_fence('python {test=compile reason="x" backend=postgres}')) + + +def test_only_postgres_is_a_backend() -> None: + with pytest.raises(ValueError, match=r"backend=mysql is not backend=postgres"): + fence_settings(_fence("python {test=run prelude=reference backend=mysql}")) + + +@pytest.mark.postgres_only +def test_a_postgres_fence_runs_on_postgres(tmp_path: Path, db_url: str) -> None: + example = _page( + tmp_path, + "python {test=run prelude=reference backend=postgres}", + "from ferro import fetch_one\n" + 'row = await fetch_one("SELECT version() AS v")\nprint(row["v"].split()[0])\n' + "print((await Author.get(1)).name)", + ) + assert run_fence(example, database_url=db_url) == "PostgreSQL\nAda Lovelace\n" + + @pytest.mark.parametrize( "script", sorted( From 21ed328f139bc92119f1cbb68a6bb2e8d8c39e54 Mon Sep 17 00:00:00 2001 From: Taylor Date: Wed, 21 Oct 2026 15:23:58 -0400 Subject: [PATCH 13/13] test(docs): a docstring fence marked backend=postgres runs on PostgreSQL too Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP --- tests/test_docstring_examples.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_docstring_examples.py b/tests/test_docstring_examples.py index 76271932..934b9200 100644 --- a/tests/test_docstring_examples.py +++ b/tests/test_docstring_examples.py @@ -35,6 +35,21 @@ def test_docstring_example(example: CodeExample) -> None: check_fence(example) +@pytest.mark.postgres_only +@pytest.mark.parametrize( + "example", + [ + example + for example in docstring_examples("ferro", REPO_ROOT / "src") + if fence_settings(example).get("backend") == "postgres" + ], + ids=str, +) +def test_docstring_example_on_postgres(example: CodeExample, db_url: str) -> None: + """A docstring fence marked ``backend=postgres`` runs against PostgreSQL.""" + check_fence(example, database_url=db_url) + + RUNNING_ENTRY_POINTS = [ "models.Model.create", "models.Model.get",