From f99416519ecc8921d3020520ff76c10ffe466fab Mon Sep 17 00:00:00 2001 From: Taylor Date: Wed, 21 Oct 2026 16:14:39 -0400 Subject: [PATCH 1/5] docs: the Deploying guide's rolling deploy is the example's two-release chain migration_errors.py now builds the chain the guide's table describes: 0002_author_slug_nullable (one expand step) and 0003_author_slug_required (backfill, add-constraint, contract), so one migration has one shape on the page and no number names two migrations. The DDL lock-retry output uses the same nullable migration. Each error region prints the class it caught (type(error).__name__), never a typed class path. The step-class test keeps its runtime half (expand-only raises DatabaseAheadError naming the contract step) and checks only that the page's refusal is the regenerated prints: block, which test_docs_output pins. Panel round 2 on #777: F26, F32, F36. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP --- docs/examples/migration_errors.py | 51 ++++++++++++++++------------ docs/pages/guide/schema/deploying.md | 22 ++++++------ tests/test_docs_migrations.py | 2 +- tests/test_step_class.py | 25 ++++++++------ 4 files changed, 57 insertions(+), 43 deletions(-) diff --git a/docs/examples/migration_errors.py b/docs/examples/migration_errors.py index f46488b6..c55b2ca5 100644 --- a/docs/examples/migration_errors.py +++ b/docs/examples/migration_errors.py @@ -2,10 +2,13 @@ """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. +A project on SQLite walks through the two-release change the Deploying guide +tabulates: release 1 adds a nullable ``slug`` (one expand step), release 2 +makes it required (backfill, add-constraint, contract). On the way it reaches +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] @@ -15,23 +18,22 @@ 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] +# --8<-- [start:slug-required] from ferro import Field, Model class Author(Model): id: int | None = Field(default=None, primary_key=True) name: str - slug: str | None = None + slug: str -# --8<-- [end:without-bio] +# --8<-- [end:slug-required] import asyncio import os @@ -42,14 +44,18 @@ class Author(Model): import ferro.migrations from _migrations_project import project +TODO = ' author.slug = todo("the slug for an existing author")\n' +WRITTEN = ' author.slug = author.name.lower().replace(" ", "-")\n' + async def main() -> None: with project(__file__) as app: app.models_before() app.ferro("migrate", "new", "create_author") app.ferro("migrate", "up") + # Release 1: a nullable slug, one expand step. app.models_after() - app.ferro("migrate", "new", "author_slug") + app.ferro("migrate", "new", "author_slug_nullable") os.chdir(app.root) sys.path.insert(0, str(app.root)) await ferro.connect(app.url) @@ -58,37 +64,40 @@ async def main() -> None: try: await ferro.migrations.require_applied() except ferro.migrations.PendingMigrationsError as error: - print(f"ferro.migrations.PendingMigrationsError: {error}") + print(f"{type(error).__name__}: {error}") # --8<-- [end:behind] - # Release 1 applies 0002_author_slug; release 0's checkout lacks it. + # Release 1 applies 0002; 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) + release_1 = app.migrations / "0002_author_slug_nullable" + kept = app.root / release_1.name + shutil.move(release_1, kept) # --8<-- [start:ahead] try: await ferro.migrations.require_applied() except ferro.migrations.DatabaseAheadError as error: - print(f"ferro.migrations.DatabaseAheadError: {error}") + print(f"{type(error).__name__}: {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") + # Release 2 makes slug required; release 1's checkout lacks 0003. + shutil.move(kept, release_1) + app.models_region("slug-required") + app.ferro("migrate", "new", "author_slug_required") + release_2 = app.migrations / "0003_author_slug_required" + backfill = release_2 / "01_backfill_author.py" + backfill.write_text(backfill.read_text().replace(TODO, WRITTEN)) app.ferro("migrate", "up") - shutil.rmtree(app.migrations / "0003_drop_bio") + shutil.rmtree(release_2) # --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}") + print(f"{type(error).__name__}: {error}") # --8<-- [end:ahead-by-contract] ferro.reset_engine() diff --git a/docs/pages/guide/schema/deploying.md b/docs/pages/guide/schema/deploying.md index 274112df..d9ce7e31 100644 --- a/docs/pages/guide/schema/deploying.md +++ b/docs/pages/guide/schema/deploying.md @@ -67,8 +67,8 @@ Behind its migrations, it raises `PendingMigrationsError`: ```text -ferro.migrations.PendingMigrationsError: ferro.migrations: this database is behind its migrations. - pending 0002_author_slug +PendingMigrationsError: ferro.migrations: this database is behind its migrations. + pending 0002_author_slug_nullable Run `ferro migrate up` (or `await ferro.migrations.up()`) before serving. ``` @@ -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` | `0002_author_slug`: one schema step, `ADD COLUMN`, nullable | runs against a database one migration ahead of it | +| 1 | `slug: str \| None = None` | `0002_author_slug_nullable`: 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 0002_author_slug, which is not in migrations/. +DatabaseAheadError: ferro migrate: this database has applied 0002_author_slug_nullable, 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. `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): +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_nullable` adds a nullable column, so release 0 starts. Release 2's deploy is different: `0003_author_slug_required` ends in a *contract* step, and a database ahead by one is refused, naming the first (`ferro migrate up --allow-ahead=expand-only` does the same): ```text -ferro.migrations.DatabaseAheadError: ferro migrate: this database has applied 0003_drop_bio, which is not in migrations/. +DatabaseAheadError: ferro migrate: this database has applied 0003_author_slug_required, 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 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. +It is ahead by contract steps, starting with 0003_author_slug_required:03 (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 (`["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. +`.contract_steps` lists every such step (`["0003_author_slug_required:03"]`). Each step's class is decided from what the step does: adding a nullable column is expand, making it required is contract, since a write that leaves it empty then fails. Release 1 code writes `slug` on every save, so it can run beside the contract; a deployment that knows this starts it with `allow_ahead=True` (below). 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. @@ -210,9 +210,9 @@ Two different waits apply: ```text default · postgresql://app:***@localhost/app - 0002_author_slug 01_schema waiting for a lock on "author" (attempt 1 of 10, retry in 1s) - 0002_author_slug 01_schema waiting for a lock on "author" (attempt 2 of 10, retry in 2s) - 0002_author_slug 01_schema applied (2214 ms) + 0002_author_slug_nullable 01_schema waiting for a lock on "author" (attempt 1 of 10, retry in 1s) + 0002_author_slug_nullable 01_schema waiting for a lock on "author" (attempt 2 of 10, retry in 2s) + 0002_author_slug_nullable 01_schema applied (2214 ms) ``` It does not apply to data steps. diff --git a/tests/test_docs_migrations.py b/tests/test_docs_migrations.py index 70206035..4fbd67fc 100644 --- a/tests/test_docs_migrations.py +++ b/tests/test_docs_migrations.py @@ -858,7 +858,7 @@ def _ddl_lock_retry(project: Path) -> None: _run_ok(project, "migrate", "new", "create_author") _run_ok(project, "migrate", "up") _write_models(project, NAMED_AUTHOR_OPTIONAL_SLUG) - _run_ok(project, "migrate", "new", "author_slug") + _run_ok(project, "migrate", "new", "author_slug_nullable") @contextmanager diff --git a/tests/test_step_class.py b/tests/test_step_class.py index ca945443..ed40434d 100644 --- a/tests/test_step_class.py +++ b/tests/test_step_class.py @@ -457,17 +457,25 @@ def test_generated_files_carry_no_class_header(project, pkg, db): DEPLOYING = Path(__file__).resolve().parents[1] / "docs/pages/guide/schema/deploying.md" -def test_the_deploying_guide_quotes_the_refusal_expand_only_raises(project, pkg, db): - """The rolling-deploy recipe is ``"expand-only"``, and the refusal it - quotes is the text ferro raises (the guide's migration is ``0008``).""" +def test_expand_only_refuses_a_contract_step_as_the_deploying_guide_shows( + project, pkg, db +): + """The rolling-deploy recipe is ``"expand-only"``, and it refuses a + database ahead by a contract step, naming the step. + + The refusal the guide quotes is regenerated from + ``docs/examples/migration_errors.py`` (``tests/test_docs_output.py``), so + the page half here checks only that the guide shows the recipe and that + its refusal is that regenerated block, never a typed copy. + """ page = DEPLOYING.read_text() recipe = 'await ferro.migrations.require_applied(allow_ahead="expand-only")' assert re.search(rf"```python[^\n]*\n{re.escape(recipe)}\n```", page) - quoted = re.search( - r"```text\n(ferro\.migrations\.DatabaseAheadError: [^`]*?0008_drop_bio:01[^`]*)```", + assert re.search( + r"\n```text\n" + r"DatabaseAheadError: ", page, ) - assert quoted is not None configure(project, pkg, db.backend) write_models(project, pkg, BIO) @@ -479,7 +487,4 @@ def test_the_deploying_guide_quotes_the_refusal_expand_only_raises(project, pkg, with pytest.raises(DatabaseAheadError) as raised: require_applied(db, allow_ahead="expand-only") assert raised.value.contract_steps == ["0002_drop_bio:01"] - raised_text = f"ferro.migrations.DatabaseAheadError: {raised.value}\n".replace( - "0002_drop_bio", "0008_drop_bio" - ) - assert quoted.group(1) == raised_text + assert "starting with 0002_drop_bio:01 (contract" in str(raised.value) From e112bfb740ff2b92172e549fd96c5471ee76dc45 Mon Sep 17 00:00:00 2001 From: Taylor Date: Wed, 21 Oct 2026 16:15:16 -0400 Subject: [PATCH 2/5] docs: the 0.26 upgrade notes name the reworded no-route error and field-order writes The section no longer says 0.26 asks nothing: it adds the routeless RuntimeError's new ending (v0.14, the upgrade guide) for code matching the old text, and the field-order column lists of every write for tests and logs that compare captured SQL. The no-route pin is renamed to say it pins the current wording, and a test holds the section to both notes. Panel round 2 on #777: F31. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP --- docs/pages/upgrading.md | 4 +++- tests/test_routing_errors.py | 23 +++++++++++++++++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/docs/pages/upgrading.md b/docs/pages/upgrading.md index ee568f63..2bcc1453 100644 --- a/docs/pages/upgrading.md +++ b/docs/pages/upgrading.md @@ -71,10 +71,12 @@ If you are moving from `0.12` or earlier straight past `0.14`, the deprecated AP ## Upgrading to 0.26 -`0.26` asks nothing of code that runs today. Two changes can show up in a type checker or an import: +Four changes in `0.26` can reach code that runs today. Two show up in a type checker or an import, two in text you compare: - `Session.query` is now typed: it takes `type[M]` and returns `Query[M]`, so a type checker may flag a call that passes a class that is not a `Model`. - The undocumented module `ferro.migrations.chunked` is now the private `ferro.migrations._chunk_loop`. `from ferro.migrations import chunked` still imports the [`chunked`](api/migrations.md#ferro.migrations.chunked) decorator, unchanged. +- The `RuntimeError` an operation raises with no database route ends differently. It said "Implicit default-connection routing was removed in v0.13 (deprecated since v0.12); see the sessions migration guide." and now says "Implicit default-connection routing was removed in v0.14 (deprecated since v0.12); see the upgrade guide." Code that matches the message on its old ending needs the new one; a match on its start, `No database route`, still holds. +- Every write lists its columns in the model's field order: `save()` of a new or fetched record, `save(only=...)`, `save(exclude=...)`, `bulk_create()` and `update()`. Before, a `bytes` or secret column came last, a fetched record's `save()` followed the order its `SELECT` returned, and `save(only=...)` or `save(exclude=...)` could change order from one run to the next. The rows written are the same; the statement text is not, so a test or a log check that compares captured SQL (the [statement log](guide/connections.md#logging) or `.sql()`) needs its expected text updated. ## Upgrading to 0.25 diff --git a/tests/test_routing_errors.py b/tests/test_routing_errors.py index 65333297..e34dc560 100644 --- a/tests/test_routing_errors.py +++ b/tests/test_routing_errors.py @@ -4,6 +4,8 @@ using=. The silent using-bypasses-session path is a ValueError. """ +from pathlib import Path + import pytest from ferro import Field, Model, connect, engines, execute @@ -29,14 +31,31 @@ class RtA(Model): @pytest.mark.asyncio -async def test_no_route_message_is_the_shipped_text(db_url): - # Callers match on this text; it is part of the error's surface. +async def test_no_route_message_is_the_current_wording(db_url): + # Pins today's wording (reworded since 0.25.0, which said "removed in + # v0.13 ... see the sessions migration guide"); the upgrade guide's 0.26 + # section names the change for callers that match on the text. await connect(db_url, auto_migrate=True) with pytest.raises(RuntimeError) as raised: await execute("SELECT 1") assert str(raised.value) == NO_ROUTE_MESSAGE +def test_the_upgrade_guide_names_the_reworded_no_route_message(): + guide = ( + Path(__file__).resolve().parents[1] / "docs/pages/upgrading.md" + ).read_text() + section = guide.split("## Upgrading to 0.26", 1)[1].split("\n## ", 1)[0] + assert "asks nothing" not in section + new_tail = NO_ROUTE_MESSAGE.split("routing was ", 1)[1] + assert new_tail in section, new_tail + assert ( + "removed in v0.13 (deprecated since v0.12); see the sessions migration guide." + in section + ) + assert "field order" in section + + @pytest.mark.asyncio async def test_raw_with_no_route_raises(db_url): await connect(db_url, auto_migrate=True) From 3fae90c573b1e99f58775307e5207a08ebb59809 Mon Sep 17 00:00:00 2001 From: Taylor Date: Wed, 21 Oct 2026 16:26:35 -0400 Subject: [PATCH 3/5] test(docs): one region reader and one connect redirect; Postgres fences that prove what they say tests/_docs_shared.py holds snippet_regions (name -> start, end, body; a duplicated, unopened or unclosed region is refused naming its line) and point_connect_at, the one redirect, which keeps the PostgreSQL query options a block's URL carries and creates a ferro_search_path schema first. The capture, the include reader, the README check and the migrations examples' helper read regions through it; the capture and the prelude runner install the redirect. tests/test_docs_shared.py pins both. The label-addition block runs on Postgres against a plan type holding only 'free' (prelude label_addition, models from docs/examples/enum_labels.py and its generated twin, now embedded in the page's tabs) and prints the type's labels under a prints: above block. A prints: above block under a backend=postgres fence is compared in the Postgres job. The JSON-claims block and the two Session docstring fences run on Postgres (preludes jwt_claims, invoices); the sslmode=require block is compile-only ("needs a TLS-enabled server"). The conventions state one rule for a Postgres block; the rowsecurity and testing docstring fences give true reasons. Panel round 2 on #777: F27, F28, F29, F33, F34, F35. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP --- docs/examples/_migrations_project.py | 12 +- docs/examples/_preludes/__init__.py | 19 +- docs/examples/_preludes/invoices.py | 43 +++++ docs/examples/_preludes/jwt_claims.py | 34 ++++ docs/examples/_preludes/label_addition.py | 44 +++++ docs/examples/enum_labels.py | 46 +++++ docs/examples/enum_labels_annotated.py | 47 +++++ docs/pages/guide/connections.md | 2 +- docs/pages/guide/row-level-security.md | 12 +- docs/pages/guide/schema/auto-migrate.md | 54 ++---- .../patterns/docs-page-conventions.md | 11 +- src/ferro/migrations/testing.py | 4 +- src/ferro/rowsecurity.py | 2 +- src/ferro/session.py | 4 +- tests/_docs_harness.py | 30 +-- tests/_docs_shared.py | 182 ++++++++++++++++++ tests/_docs_sql.py | 70 ++++--- tests/test_docs_examples.py | 10 +- tests/test_docs_output.py | 63 +++++- tests/test_docs_shared.py | 147 ++++++++++++++ 20 files changed, 710 insertions(+), 126 deletions(-) create mode 100644 docs/examples/_preludes/invoices.py create mode 100644 docs/examples/_preludes/jwt_claims.py create mode 100644 docs/examples/_preludes/label_addition.py create mode 100644 docs/examples/enum_labels.py create mode 100644 docs/examples/enum_labels_annotated.py create mode 100644 tests/_docs_shared.py create mode 100644 tests/test_docs_shared.py diff --git a/docs/examples/_migrations_project.py b/docs/examples/_migrations_project.py index 8ddcd524..0ed9c679 100644 --- a/docs/examples/_migrations_project.py +++ b/docs/examples/_migrations_project.py @@ -34,6 +34,10 @@ from contextlib import contextmanager from pathlib import Path +# The docs harness's region reader, so a region reads the same here as on a page. +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from tests._docs_shared import snippet_regions # noqa: E402 + __all__ = ["Project", "project"] FERRO_TOML = 'models = ["blog.models"]\ndialects = ["postgres", "sqlite"]\n' @@ -42,10 +46,10 @@ def _region(script: Path, name: str = "models") -> str: - source = script.read_text() - opening, closing = f"# --8<-- [start:{name}]", f"# --8<-- [end:{name}]" - start = source.index(opening) + len(opening) - return source[start : source.index(closing)].strip("\n") + "\n" + regions = snippet_regions(script.read_text(), where=str(script)) + if name not in regions: + raise ValueError(f"{script} has no snippet region {name!r}") + return regions[name].body.strip("\n") + "\n" def _before(region: str) -> str: diff --git a/docs/examples/_preludes/__init__.py b/docs/examples/_preludes/__init__.py index 20fdfc39..8f92078a 100644 --- a/docs/examples/_preludes/__init__.py +++ b/docs/examples/_preludes/__init__.py @@ -14,7 +14,8 @@ 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. +the URL it names, keeping the PostgreSQL query options that URL carries +(``tests._docs_shared.point_connect_at``). """ from __future__ import annotations @@ -30,6 +31,10 @@ PRELUDES = Path(__file__).resolve().parent +# The docs harness's connect redirect, the one the SQL capture installs too. +sys.path.insert(0, str(PRELUDES.parents[2])) +from tests._docs_shared import point_connect_at # noqa: E402 + def load_prelude(name: str) -> ModuleType: """Import ``.py`` from this directory, refusing a name that has none.""" @@ -84,18 +89,6 @@ 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") diff --git a/docs/examples/_preludes/invoices.py b/docs/examples/_preludes/invoices.py new file mode 100644 index 00000000..284b9c14 --- /dev/null +++ b/docs/examples/_preludes/invoices.py @@ -0,0 +1,43 @@ +"""Invoices of two tenants, for the docstrings of session settings. + +A block marked ``{test=run prelude=invoices backend=postgres}`` runs on +PostgreSQL, where a session can send settings (``set_config``). ``opened()`` +connects, adds three invoices and does not open a session: the block opens +its own, with the settings it is about. ``resolve_tenant(request)`` stands for +the application's own lookup and returns ``"acme"``. +""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +import ferro +from ferro import Field, Model, connect, engines + + +class Invoice(Model): + id: int | None = Field(default=None, primary_key=True) + tenant: str + total: float + + +request = object() +"""The incoming request a web handler would hold.""" + + +async def resolve_tenant(request: object) -> str: + """The tenant the request belongs to.""" + return "acme" + + +@asynccontextmanager +async def opened() -> AsyncIterator[None]: + """Connect and add the invoices; the block opens its own session.""" + await connect("postgresql://localhost/app", auto_migrate=True) + try: + async with engines.session(): + await Invoice.create(tenant="acme", total=250.0) + await Invoice.create(tenant="acme", total=40.0) + await Invoice.create(tenant="globex", total=900.0) + yield + finally: + ferro.reset_engine() diff --git a/docs/examples/_preludes/jwt_claims.py b/docs/examples/_preludes/jwt_claims.py new file mode 100644 index 00000000..48c88993 --- /dev/null +++ b/docs/examples/_preludes/jwt_claims.py @@ -0,0 +1,34 @@ +"""The Row-level security guide's JSON claims: a Postgres connection named +``"app"`` and the ``auth_uid()`` function the guide creates. + +A block marked ``{test=run prelude=jwt_claims backend=postgres}`` runs on +PostgreSQL. ``opened()`` connects under the name ``"app"`` and creates +``auth_uid()`` with the guide's own statement; the block opens its own +session, with the claims it sends. +""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +import ferro +from ferro import engines, execute + +AUTH_UID = """\ +CREATE OR REPLACE FUNCTION auth_uid() RETURNS uuid +LANGUAGE sql STABLE +AS $$ + SELECT (NULLIF(current_setting('request.jwt.claims', true), '')::json ->> 'sub')::uuid +$$""" +"""The function the guide creates in a migration, as the guide shows it.""" + + +@asynccontextmanager +async def opened() -> AsyncIterator[None]: + """Connect as ``"app"`` and create ``auth_uid()``.""" + await ferro.connect("postgresql://localhost/app", name="app") + async with engines.session("app"): + await execute(AUTH_UID) + try: + yield + finally: + ferro.reset_engine() diff --git a/docs/examples/_preludes/label_addition.py b/docs/examples/_preludes/label_addition.py new file mode 100644 index 00000000..9d8d4378 --- /dev/null +++ b/docs/examples/_preludes/label_addition.py @@ -0,0 +1,44 @@ +"""The Auto-migrate page's label addition: ``Plan`` gained ``PRO``, and the +database is from before. + +The models are ``docs/examples/enum_labels.py``'s. A block marked +``{test=run prelude=label_addition backend=postgres}`` runs on PostgreSQL, +where ``opened()`` first builds what an earlier release built: the native +type ``plan`` with only ``'free'``, and ``author`` with a ``plan`` column of +it, as that release's create pass left them. It does not leave a +connection open: the block connects itself, with the flag it is about. +""" + +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])) + +import ferro # noqa: E402 +from enum_labels import Author, Plan # noqa: E402, F401 - names the blocks use +from ferro import engines, execute, fetch_all # noqa: E402, F401 + +BEFORE = [ + """CREATE TYPE "plan" AS ENUM ('free')""", + """CREATE TABLE "author" ( "email" varchar NOT NULL, "id" serial PRIMARY KEY""" + """ NOT NULL, "name" varchar NOT NULL, "nickname" varchar, "plan" plan NOT NULL )""", + """CREATE INDEX "idx_author_nickname" ON "author" ("nickname")""", + """CREATE UNIQUE INDEX "uq_author_email" ON "author" ("email")""", +] +"""What the create pass sends for ``Author`` while ``Plan`` has only ``FREE``.""" + + +@asynccontextmanager +async def opened() -> AsyncIterator[None]: + """Build the earlier release's schema, then run the block.""" + await ferro.connect("postgresql://localhost/app") + async with engines.session(): + for statement in BEFORE: + await execute(statement) + ferro.reset_engine() + try: + yield + finally: + ferro.reset_engine() diff --git a/docs/examples/enum_labels.py b/docs/examples/enum_labels.py new file mode 100644 index 00000000..620f6c98 --- /dev/null +++ b/docs/examples/enum_labels.py @@ -0,0 +1,46 @@ +# ruff: noqa: E402 - the models come first, as the docs page shows them +"""Runnable companion for label addition (docs/pages/guide/schema/auto-migrate.md). + +``Plan`` after it gained ``PRO``. The page's connect block runs these models +on PostgreSQL against a database whose ``plan`` type has only ``'free'`` +(``docs/examples/_preludes/enum_labels.py``). On its own the script saves an +author on SQLite, where an enum is stored as text and has no type to grow. +""" + +# --8<-- [start:models] +from enum import StrEnum + +from ferro import Field, Model + + +class Plan(StrEnum): + FREE = "free" + PRO = "pro" # new: the live type has only 'free' + + +class Author(Model): + id: int | None = Field(default=None, primary_key=True) + name: str + email: str = Field(unique=True) + nickname: str | None = Field(default=None, index=True) + plan: Plan = Plan.FREE + + +# --8<-- [end:models] + +import asyncio + +from ferro import connect, engines + + +async def main() -> None: + await connect("sqlite::memory:", auto_migrate=True) + async with engines.session(): + author = await Author.create( + name="Ada Lovelace", email="ada@example.com", plan=Plan.PRO + ) + assert (await Author.get(author.id)).plan is Plan.PRO + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs/examples/enum_labels_annotated.py b/docs/examples/enum_labels_annotated.py new file mode 100644 index 00000000..07bce26a --- /dev/null +++ b/docs/examples/enum_labels_annotated.py @@ -0,0 +1,47 @@ +# ruff: noqa: E402 - the models come first, as the docs page shows them +"""Runnable companion for label addition (docs/pages/guide/schema/auto-migrate.md). + +``Plan`` after it gained ``PRO``. The page's connect block runs these models +on PostgreSQL against a database whose ``plan`` type has only ``'free'`` +(``docs/examples/_preludes/enum_labels.py``). On its own the script saves an +author on SQLite, where an enum is stored as text and has no type to grow. +""" + +# --8<-- [start:models] +from enum import StrEnum +from typing import Annotated + +from ferro import Field, Model + + +class Plan(StrEnum): + FREE = "free" + PRO = "pro" # new: the live type has only 'free' + + +class Author(Model): + id: Annotated[int | None, Field(primary_key=True)] = None + name: str + email: Annotated[str, Field(unique=True)] + nickname: Annotated[str | None, Field(index=True)] = None + plan: Plan = Plan.FREE + + +# --8<-- [end:models] + +import asyncio + +from ferro import connect, engines + + +async def main() -> None: + await connect("sqlite::memory:", auto_migrate=True) + async with engines.session(): + author = await Author.create( + name="Ada Lovelace", email="ada@example.com", plan=Plan.PRO + ) + assert (await Author.get(author.id)).plan is Plan.PRO + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs/pages/guide/connections.md b/docs/pages/guide/connections.md index bc962834..5985e837 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=run prelude=connections backend=postgres} +```python {test=compile reason="needs a TLS-enabled server"} from ferro import connect await connect("postgresql://user:password@localhost:5432/app?sslmode=require") diff --git a/docs/pages/guide/row-level-security.md b/docs/pages/guide/row-level-security.md index ffedc798..948cadd8 100644 --- a/docs/pages/guide/row-level-security.md +++ b/docs/pages/guide/row-level-security.md @@ -310,17 +310,23 @@ $$; Send the claims as one setting: -```python {test=compile reason="needs a Postgres connection and the auth_uid() function above"} +```python {test=run prelude=jwt_claims backend=postgres} import json claims = {"sub": "5f0c1a4e-2b7d-4c1e-9a3f-6d8e2b1c0a94", "role": "editor"} async with ferro.engines.session( "app", settings={"request.jwt.claims": json.dumps(claims)} ): - ... + row = await ferro.fetch_one("SELECT auth_uid() AS uid") +print(row["uid"]) ``` -Every query in that session can read the claims through `auth_uid()`. +Every query in that session can read the claims through `auth_uid()`: + + +```text +5f0c1a4e-2b7d-4c1e-9a3f-6d8e2b1c0a94 +``` Then call the function from a policy written in SQL, inside a scalar subquery: diff --git a/docs/pages/guide/schema/auto-migrate.md b/docs/pages/guide/schema/auto-migrate.md index c407ae0f..f8538732 100644 --- a/docs/pages/guide/schema/auto-migrate.md +++ b/docs/pages/guide/schema/auto-migrate.md @@ -101,54 +101,34 @@ On PostgreSQL, a `StrEnum` field creates a **native enum type**, and a type that === "Assignment" - ```python {test=compile reason="declares models only; label addition needs Postgres"} - from enum import StrEnum - - from ferro import Field, Model - - - class Plan(StrEnum): - FREE = "free" - PRO = "pro" # new: the live type has only 'free' - - - class Author(Model): - id: int | None = Field(default=None, primary_key=True) - name: str - email: str = Field(unique=True) - nickname: str | None = Field(default=None, index=True) - plan: Plan = Plan.FREE + ```python + --8<-- "docs/examples/enum_labels.py:models" ``` === "Annotated" - ```python {test=compile reason="declares models only; label addition needs Postgres"} - from enum import StrEnum - from typing import Annotated - - from ferro import Field, Model - - - class Plan(StrEnum): - FREE = "free" - PRO = "pro" # new: the live type has only 'free' - - - class Author(Model): - id: Annotated[int | None, Field(primary_key=True)] = None - name: str - email: Annotated[str, Field(unique=True)] - nickname: Annotated[str | None, Field(index=True)] = None - plan: Plan = Plan.FREE + ```python + --8<-- "docs/examples/enum_labels_annotated.py:models" ``` Connect to the Postgres database with `migrate_updates=True`: -```python {test=run prelude=auto_migrate backend=postgres} +```python {test=run prelude=label_addition backend=postgres} await ferro.connect("postgresql://localhost/app", migrate_updates=True) + +async with engines.session(): + labels = await fetch_all("SELECT unnest(enum_range(NULL::plan))::text AS label") +print([row["label"] for row in labels]) +``` + +The connect runs `ALTER TYPE "plan" ADD VALUE IF NOT EXISTS 'pro'`, so the type's labels are now: + + +```text +['free', 'pro'] ``` -The connect runs `ALTER TYPE "plan" ADD VALUE IF NOT EXISTS 'pro'`, and from then on an author can be saved with `plan=Plan.PRO`. +From then on an author can be saved with `plan=Plan.PRO`. !!! warning "Existing enum types without `migrate_updates`" Under plain `auto_migrate=True`, an existing enum type is never updated: like every existing object, it belongs to the update pass. Every test suite that creates its schema fresh gets the complete enum and stays green, while every existing database rejects the new member at runtime with `invalid input value for enum`. No test against a throwaway schema can catch this. If your models' enums evolve, run with `migrate_updates=True`, or generate the change as a [migration](migrations.md) (the [Alembic bridge](alembic.md) sees the same drift). diff --git a/docs/solutions/patterns/docs-page-conventions.md b/docs/solutions/patterns/docs-page-conventions.md index 4bc65378..1e5c9fd1 100644 --- a/docs/solutions/patterns/docs-page-conventions.md +++ b/docs/solutions/patterns/docs-page-conventions.md @@ -197,8 +197,9 @@ recreated index). A data step the page shows written by hand is listed with ### Compile-only blocks say why 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. +fence: `{test=compile reason="needs Litestar, which the test groups do not install"}`. +Generated files, `env.py` excerpts and model fragments whose imports are left +out are the usual cases; a block that can run, runs. Every Python fence on a page, in Markdown a page pulls in with `--8<--` (`CONTRIBUTING.md`, `README.md` sections, `docs/includes`) and in a @@ -208,11 +209,13 @@ 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=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, keeping the PostgreSQL query options that URL carries (a `ferro_search_path` schema is created first); without Postgres the test is skipped and the block only compiles. A `` block under it is compared on PostgreSQL. | | `{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). +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). An option the test server cannot honour (`sslmode=require` needs a TLS-enabled server) makes the block compile-only, saying so. + +The redirect (`point_connect_at`) and the snippet-region reader (`snippet_regions`) live in `tests/_docs_shared.py`, stdlib-only so the prelude runner and `docs/examples/_migrations_project.py` import them too; `tests/_docs_harness.py` re-exports both. Nothing else patches `ferro.connect` or parses `--8<-- [start:]` markers. `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 diff --git a/src/ferro/migrations/testing.py b/src/ferro/migrations/testing.py index 5b4446f6..263e5b5e 100644 --- a/src/ferro/migrations/testing.py +++ b/src/ferro/migrations/testing.py @@ -53,14 +53,14 @@ class RoundTripResult: For a chain whose every down reaches its parent: - ```python {test=compile reason="shows the value round_trip returns"} + ```python {test=compile reason="a value `round_trip` returns; `RoundTripResult` is not imported here"} RoundTripResult(applied=["0001_create_author", "0002_add_slug"], reverted_to=None, irreversible=None) ``` and for one whose `0002` declares a step irreversible: - ```python {test=compile reason="shows the value round_trip returns"} + ```python {test=compile reason="a value `round_trip` returns; `RoundTripResult` is not imported here"} RoundTripResult(applied=["0001_create_author", "0002_add_slug"], reverted_to="0002_add_slug", irreversible=("0002_add_slug", "01_schema", "the index is shared")) diff --git a/src/ferro/rowsecurity.py b/src/ferro/rowsecurity.py index d943cd9f..12ea49f4 100644 --- a/src/ferro/rowsecurity.py +++ b/src/ferro/rowsecurity.py @@ -2,7 +2,7 @@ A model says once who is allowed to see its rows: -```python {test=compile reason="declares a model whose row security needs Postgres"} +```python {test=compile reason="a model fragment; its imports are left out"} class Invoice(Model): id: int | None = Field(default=None, primary_key=True) tenant_id: UUID diff --git a/src/ferro/session.py b/src/ferro/session.py index 08ae2631..62c2759f 100644 --- a/src/ferro/session.py +++ b/src/ferro/session.py @@ -292,7 +292,7 @@ async def set_config(self, key: str, value: str) -> None: ValueError: `key` is not a dotted custom setting name. Examples: - ```python {test=compile reason="needs a Postgres database"} + ```python {test=run prelude=invoices backend=postgres} async with engines.session() as session: tenant = await resolve_tenant(request) await session.set_config("myapp.tenant_id", tenant) @@ -419,7 +419,7 @@ def session( With settings, on Postgres: - ```python {test=compile reason="needs a Postgres database"} + ```python {test=run prelude=invoices backend=postgres} async with engines.session(settings={"myapp.tenant_id": "acme"}): invoices = await Invoice.where(lambda invoice: invoice.total > 100).all() ``` diff --git a/tests/_docs_harness.py b/tests/_docs_harness.py index bef80fce..6d147100 100644 --- a/tests/_docs_harness.py +++ b/tests/_docs_harness.py @@ -46,6 +46,15 @@ from pytest_examples import CodeExample, EvalExample from pytest_examples.find_examples import remove_indent +# Re-exported: the region reader and the connect redirect are the harness's, +# kept stdlib-only in their own module for the scripts the harness starts. +from tests._docs_shared import ( # noqa: F401 + SnippetRegion, + point_connect_at, + redirected_url, + snippet_regions, +) + REPO_ROOT = Path(__file__).resolve().parents[1] DOCS_PAGES = REPO_ROOT / "docs" / "pages" PRELUDES = REPO_ROOT / "docs" / "examples" / "_preludes" @@ -234,7 +243,7 @@ def page_fences(language: str | None = None) -> list[Fence]: 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) + text, offset = _included(target, section) blocks += fenced_blocks(target, text, language, line_offset=offset) return blocks @@ -242,19 +251,16 @@ def page_fences(language: str | None = None) -> list[Fence]: _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) +def _included(path: Path, section: str) -> tuple[str, int]: + """The text of ``path``'s region ``section`` (all of it when ``section`` is + empty), and the number of lines before it.""" + text = path.read_text(encoding="utf-8") 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: + return text, 0 + region = snippet_regions(text, where=str(path)).get(section) + if region is None: raise ValueError(f"{path} has no section {section!r}") - return "".join(lines[start + 1 : end]), start + 1 + return region.body, region.start def fenced_examples( diff --git a/tests/_docs_shared.py b/tests/_docs_shared.py new file mode 100644 index 00000000..7abda701 --- /dev/null +++ b/tests/_docs_shared.py @@ -0,0 +1,182 @@ +"""The two pieces the docs harness shares with the scripts it runs. + +- ``snippet_regions`` reads the snippet-region grammar + (``--8<-- [start:]`` … ``--8<-- [end:]``) of an example script + or a Markdown file. The harness (``_docs_harness``, ``_docs_sql``), the + docs tests and ``docs/examples/_migrations_project.py`` all read regions + through it. +- ``point_connect_at`` points every ``ferro.connect`` call at the test + database. The SQL and output capture (``python -m tests._docs_sql``) and + the prelude runner (``docs/examples/_preludes/__init__.py``) both install it. + +It imports nothing beyond the standard library at import time, so a script +the harness starts (an example helper, the prelude runner) imports it without +loading the harness's own dependencies. ``tests._docs_harness`` re-exports both. +""" + +from __future__ import annotations + +import atexit +import re +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + +# -- snippet regions ------------------------------------------------------------------ + +SNIPPET = re.compile(r"--8<--\s*\[(?Pstart|end):(?P[A-Za-z0-9_-]+)\]") +"""A snippet region's start or end marker: ``# --8<-- [start:]`` in a +script, ```` in Markdown.""" + + +@dataclass(frozen=True) +class SnippetRegion: + """One snippet region of a file.""" + + start: int + """The line of its ``[start:]`` marker, counting from 1.""" + end: int + """The line of its ``[end:]`` marker.""" + body: str + """The lines between the two markers, as written, each with its line end.""" + + +def snippet_regions(text: str, *, where: str = "") -> dict[str, SnippetRegion]: + """Read every snippet region of ``text``, by name. + + A region runs from the line holding ``--8<-- [start:]`` to the line + holding ``--8<-- [end:]``; regions may nest or overlap. One marker per + line is read. + + Args: + text: An example script or a Markdown file. + where: The file's name, for the error message. + + Returns: + Each region's marker lines and body, in the order the regions start. + + Raises: + ValueError: A name starts twice, ends without starting, or never + ends. The message names the file, the line and the region. + """ + lines = text.splitlines(keepends=True) + open_at: dict[str, int] = {} + regions: dict[str, SnippetRegion] = {} + order: list[str] = [] + for number, line in enumerate(lines, start=1): + marker = SNIPPET.search(line) + if marker is None: + continue + name = marker["name"] + if marker["edge"] == "start": + if name in open_at or name in regions: + raise ValueError( + f"{where}:{number}: the snippet region {name!r} starts a " + "second time; give each region its own name" + ) + open_at[name] = number + order.append(name) + continue + if name not in open_at: + raise ValueError( + f"{where}:{number}: the snippet region {name!r} ends without " + f"starting; add '--8<-- [start:{name}]' above it" + ) + start = open_at.pop(name) + regions[name] = SnippetRegion(start, number, "".join(lines[start : number - 1])) + if open_at: + name, number = next(iter(open_at.items())) + raise ValueError( + f"{where}:{number}: the snippet region {name!r} never ends; add " + f"'--8<-- [end:{name}]' below it" + ) + return {name: regions[name] for name in order} + + +# -- the connect redirect ------------------------------------------------------------- + + +def redirected_url(named: str, target: str) -> str: + """Return the URL a ``connect(named)`` reaches when pointed at ``target``. + + The database is ``target``'s. When both name PostgreSQL, the query options + ``named`` carries are set on it too, over ``target``'s own, so a block + that connects with ``?ferro_search_path=tenant_a`` runs with that option. + Options of another database's URL (SQLite's ``mode=rwc``) mean nothing to + ``target`` and are left behind. + """ + if not (_is_postgres(named) and _is_postgres(target)): + return target + shown = dict(parse_qsl(urlsplit(named).query, keep_blank_values=True)) + if not shown: + return target + parts = urlsplit(target) + options = dict(parse_qsl(parts.query, keep_blank_values=True)) | shown + return urlunsplit(parts._replace(query=urlencode(options))) + + +def _is_postgres(url: str) -> bool: + return urlsplit(url).scheme in ("postgres", "postgresql") + + +def point_connect_at( + url: str | None, *, before: Callable[[], object] | None = None +) -> None: + """Send every ``ferro.connect`` call to ``url``, whatever URL it names. + + The URL is ``redirected_url(, url)``. A + ``ferro_search_path`` it carries names a schema the test database may + lack, so the schema is created before the connect and dropped when the + process exits. Install it before anything imports ``connect`` by name. + + Args: + url: The database to connect to. ``None`` keeps the URL each call + names, so only ``before`` is added. + before: Called with no arguments before each connect. + """ + import ferro + + connect = ferro.connect + + async def connect_to(named: str, *args: Any, **kwargs: Any) -> Any: + if before is not None: + before() + reached = named if url is None else redirected_url(named, url) + if url is not None and reached != url: + _create_search_path_schema(reached) + return await connect(reached, *args, **kwargs) + + ferro.connect = connect_to # type: ignore[assignment] + + +_created_schemas: set[tuple[str, str]] = set() + + +def _create_search_path_schema(url: str) -> None: + parts = urlsplit(url) + options = dict(parse_qsl(parts.query, keep_blank_values=True)) + schema = options.pop("ferro_search_path", None) + if schema is None or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", schema): + return # connect() itself refuses a value that is not one identifier + server = urlunsplit(parts._replace(query=urlencode(options))) + if (server, schema) in _created_schemas: + return + import psycopg + + with psycopg.connect(server, autocommit=True) as admin: + exists = admin.execute( + "SELECT 1 FROM pg_namespace WHERE nspname = %s", (schema,) + ).fetchone() + if exists: + return # the test's own schema, or one this run does not own + admin.execute(f'CREATE SCHEMA "{schema}"') + _created_schemas.add((server, schema)) + atexit.register(_drop_schema, server, schema) + + +def _drop_schema(server: str, schema: str) -> None: + import psycopg + + with psycopg.connect(server, autocommit=True) as admin: + admin.execute(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE') diff --git a/tests/_docs_sql.py b/tests/_docs_sql.py index 428f3612..dce5a2b0 100644 --- a/tests/_docs_sql.py +++ b/tests/_docs_sql.py @@ -47,8 +47,10 @@ Fence, fence_settings, fenced_examples, + point_connect_at, run_fence, run_python, + snippet_regions, ) DOCS_EXAMPLES = REPO_ROOT / "docs" / "examples" @@ -66,9 +68,6 @@ """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.""" - 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 @@ -156,19 +155,12 @@ def statements(sqls: list[str]) -> str: 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 +def region_lines(source: str, *, where: str = "") -> dict[str, tuple[int, int]]: + """Return each snippet region of an example's ``source`` as its marker lines.""" + return { + name: (region.start, region.end) + for name, region in snippet_regions(source, where=where).items() + } # -- what a run records ---------------------------------------------------------- @@ -215,7 +207,7 @@ 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.ranges = region_lines(path.read_text(encoding="utf-8"), where=self.path) self.where: tuple[str, int] | None = None """The docs example file and line running now.""" self.depth = 0 @@ -374,17 +366,13 @@ def render_then_call(self: Terminal, *args: Any, **kwargs: Any) -> Any: 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) + create_tables = ferro.create_tables 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] + point_connect_at(url, before=capture.snapshot) ferro.create_tables = create_then_snapshot # type: ignore[assignment] tool = 4 @@ -476,7 +464,7 @@ def sql_marker(block: Fence) -> re.Match[str] | None: 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")) + ranges = snippet_regions(path.read_text(encoding="utf-8"), where=str(path)) assert snippet in ranges, f"{named}: {example} has no snippet region {snippet!r}" @@ -563,6 +551,7 @@ def check_printed( run: Callable[[str, str], Run] = captured, *, dialect: str = "sqlite", + database_url: str | None = None, ) -> None: """Assert a ```text block says where it comes from, and holds what it says. @@ -579,7 +568,10 @@ def check_printed( 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. + the other one only has its example and region checked, or its + fence found. + database_url: The PostgreSQL database a ``prints: above`` block's + fence runs against when that fence is marked ``backend=postgres``. Raises: AssertionError: The block has no note, an unknown one, names an @@ -600,8 +592,7 @@ def check_printed( if kind != "prints": return if value == "above": - if dialect == "sqlite": - _check_printed_above(block, where) + _check_printed_above(block, where, dialect, database_url) return marker = PRINTS.match(value) assert marker is not None, ( @@ -621,20 +612,37 @@ def check_printed( ) -def _check_printed_above(block: Fence, where: str) -> None: +def printing_fence(block: Fence) -> Any: + """The Python fence a ```` block shows the output of: + the nearest one above it, or ``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] + return fences[-1] if fences else None + + +def on_postgres(fence: Any) -> bool: + """Whether a Python fence is marked ``backend=postgres``.""" + return fence_settings(fence).get("backend") == "postgres" + + +def _check_printed_above( + block: Fence, where: str, dialect: str, database_url: str | None +) -> None: + above = printing_fence(block) + assert above is not None, ( + f"{where}: has no Python fence above it" + ) 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) + if dialect != ("postgres" if on_postgres(above) else "sqlite"): + return + printed = run_fence(above, database_url=database_url) assert block.body == printed, ( f"{where}: the block differs from what the fence at line " f"{above.start_line} prints. It prints:\n{printed}" diff --git a/tests/test_docs_examples.py b/tests/test_docs_examples.py index c0a305ca..47ccea87 100644 --- a/tests/test_docs_examples.py +++ b/tests/test_docs_examples.py @@ -1,6 +1,5 @@ from __future__ import annotations -import re from pathlib import Path import pytest @@ -15,6 +14,7 @@ page_examples, run_fence, run_python, + snippet_regions, ) REPO_ROOT = Path(__file__).resolve().parents[1] @@ -241,9 +241,6 @@ def test_compile_only_page_fence_states_its_reason() -> None: README = REPO_ROOT / "README.md" -_MODELS_SECTION = re.compile( - r"# --8<-- \[start:models\]\n(.*?)# --8<-- \[end:models\]", flags=re.S -) def _readme_quick_start() -> str: @@ -262,7 +259,6 @@ def test_readme_quick_start_runs_and_prints_what_it_claims(tmp_path: Path) -> No def test_readme_model_is_the_home_page_example() -> None: """The README's model is the one the home page embeds in both tabs.""" - [models] = _MODELS_SECTION.findall( - (DOCS_EXAMPLES / "readme_quickstart.py").read_text(encoding="utf-8") - ) + script = DOCS_EXAMPLES / "readme_quickstart.py" + models = snippet_regions(script.read_text(encoding="utf-8"))["models"].body assert models.strip() in _readme_quick_start() diff --git a/tests/test_docs_output.py b/tests/test_docs_output.py index 5008ab84..64d198ec 100644 --- a/tests/test_docs_output.py +++ b/tests/test_docs_output.py @@ -32,7 +32,16 @@ import pytest from tests._docs_harness import Fence, fenced_blocks, page_fences -from tests._docs_sql import PRINTS, Region, Run, captured, check_printed, page_name +from tests._docs_sql import ( + PRINTS, + Region, + Run, + printing_fence, + captured, + on_postgres, + check_printed, + page_name, +) @pytest.mark.parametrize("block", page_fences("text"), ids=page_name) @@ -40,15 +49,20 @@ def test_a_text_block_is_what_it_says_it_is(block: Fence) -> None: check_printed(block) +def _on_postgres(block: Fence) -> bool: + """Whether a ``prints:`` block shows what something prints on PostgreSQL: + an example region marked ``(postgres)``, or a ``backend=postgres`` fence.""" + if not block.note or block.note[0] != "prints": + return False + if block.note[1] == "above": + fence = printing_fence(block) + return fence is not None and on_postgres(fence) + marker = PRINTS.match(block.note[1]) + return marker is not None and marker["dialect"] == "postgres" + + 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" - ] + return [block for block in page_fences("text") if _on_postgres(block)] @pytest.mark.postgres_only @@ -61,6 +75,7 @@ def test_a_postgres_text_block_is_what_its_example_prints_there( block, lambda example, dialect: captured(example, dialect, args=args), dialect="postgres", + database_url=db_url, ) @@ -194,3 +209,33 @@ def test_a_block_that_is_not_what_it_says_fails_naming_it( with pytest.raises(AssertionError) as raised: check_printed(_block(note, body), _fake) assert str(raised.value).startswith(message), str(raised.value) + + +PG_FENCE = ( + "```python {test=run prelude=reference backend=postgres}\n" + "from ferro import fetch_one\n\n" + 'row = await fetch_one("SELECT version() AS v")\nprint(row["v"].split()[0])\n```' +) + + +def test_a_block_under_a_postgres_fence_is_compared_only_on_postgres( + tmp_path: Path, +) -> None: + """On SQLite the fence above is only found and its marker checked; the + PostgreSQL job runs it and compares.""" + block = _page_below(tmp_path, PG_FENCE, "anything\n") + check_printed(block) + assert printing_fence(block) is not None + + +@pytest.mark.postgres_only +@pytest.mark.parametrize("shown", ["PostgreSQL\n", "SQLite\n"], ids=["same", "edited"]) +def test_a_block_under_a_postgres_fence_holds_what_it_prints_there( + tmp_path: Path, db_url: str, shown: str +) -> None: + block = _page_below(tmp_path, PG_FENCE, shown) + if shown == "PostgreSQL\n": + check_printed(block, dialect="postgres", database_url=db_url) + return + with pytest.raises(AssertionError, match="differs from what the fence at line 3"): + check_printed(block, dialect="postgres", database_url=db_url) diff --git a/tests/test_docs_shared.py b/tests/test_docs_shared.py new file mode 100644 index 00000000..28714547 --- /dev/null +++ b/tests/test_docs_shared.py @@ -0,0 +1,147 @@ +"""The snippet-region reader and the connect redirect the docs harness shares.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from tests import _docs_harness, _docs_sql +from tests._docs_shared import ( + SnippetRegion, + redirected_url, + snippet_regions, +) + +REPO_ROOT = Path(__file__).resolve().parents[1] + +SCRIPT = """\ +from ferro import Model +# --8<-- [start:models] +class Author(Model): + # --8<-- [start:field] + name: str + # --8<-- [end:field] +# --8<-- [end:models] +print("done") +""" + + +def test_a_region_is_its_marker_lines_and_the_lines_between() -> None: + assert snippet_regions(SCRIPT) == { + "models": SnippetRegion( + start=2, + end=7, + body=( + "class Author(Model):\n" + " # --8<-- [start:field]\n" + " name: str\n" + " # --8<-- [end:field]\n" + ), + ), + "field": SnippetRegion(start=4, end=6, body=" name: str\n"), + } + + +def test_markdown_regions_read_the_same_way() -> None: + text = "intro\n\nHello.\n\n" + assert snippet_regions(text) == {"intro": SnippetRegion(2, 4, "Hello.\n")} + + +@pytest.mark.parametrize( + ("text", "message"), + [ + ( + "# --8<-- [start:a]\n# --8<-- [end:a]\n# --8<-- [start:a]\n# --8<-- [end:a]\n", + "ex.py:3: the snippet region 'a' starts a second time", + ), + ( + "x\n# --8<-- [end:a]\n", + "ex.py:2: the snippet region 'a' ends without starting", + ), + ("# --8<-- [start:a]\nx\n", "ex.py:1: the snippet region 'a' never ends"), + ], + ids=["twice", "end-only", "unclosed"], +) +def test_a_malformed_region_is_refused_naming_its_line(text: str, message: str) -> None: + with pytest.raises(ValueError) as raised: + snippet_regions(text, where="ex.py") + assert str(raised.value).startswith(message), str(raised.value) + + +def _region_files() -> list[Path]: + scripts = [ + path + for path in (REPO_ROOT / "docs" / "examples").glob("*.py") + if not path.name.startswith("_") + ] + markdown = [ + REPO_ROOT / "README.md", + REPO_ROOT / "CONTRIBUTING.md", + *(REPO_ROOT / "docs" / "includes").glob("*.md"), + ] + return sorted([*scripts, *markdown]) + + +@pytest.mark.parametrize("path", _region_files(), ids=lambda p: p.name) +def test_every_file_with_regions_reads_cleanly(path: Path) -> None: + snippet_regions(path.read_text(encoding="utf-8"), where=str(path)) + + +def test_the_four_region_readers_are_one() -> None: + """The capture, the include reader, the README check and the migrations + examples' helper all read regions with ``snippet_regions``.""" + import importlib + import sys + + from tests import test_docs_examples + + assert _docs_harness.snippet_regions is snippet_regions + assert not hasattr(_docs_sql, "snippet_ranges") + assert not hasattr(_docs_harness, "_section") + assert not hasattr(test_docs_examples, "_MODELS_SECTION") + sys.path.insert(0, str(REPO_ROOT / "docs" / "examples")) + try: + helper = importlib.import_module("_migrations_project") + finally: + sys.path.remove(str(REPO_ROOT / "docs" / "examples")) + assert helper.snippet_regions is snippet_regions + + +PG = "postgresql://postgres:password@localhost:5432/postgres?ferro_search_path=ferro_ab12" + + +@pytest.mark.parametrize( + ("named", "reached"), + [ + ("postgresql://localhost/app", PG), + ( + "postgresql://user:password@localhost:5432/app?ferro_search_path=tenant_a", + "postgresql://postgres:password@localhost:5432/postgres" + "?ferro_search_path=tenant_a", + ), + ( + "postgresql://user:password@localhost:5432/app?sslmode=require", + "postgresql://postgres:password@localhost:5432/postgres" + "?ferro_search_path=ferro_ab12&sslmode=require", + ), + ("sqlite:dev.db?mode=rwc", PG), + ("sqlite::memory:", PG), + ], + ids=["no-options", "search-path", "other-option", "sqlite-options", "sqlite"], +) +def test_a_redirect_keeps_the_postgres_options_the_block_names( + named: str, reached: str +) -> None: + assert redirected_url(named, PG) == reached + + +def test_the_capture_and_the_prelude_runner_install_the_one_redirect() -> None: + """Neither patches ``ferro.connect`` itself.""" + for path in ( + REPO_ROOT / "tests" / "_docs_sql.py", + REPO_ROOT / "docs" / "examples" / "_preludes" / "__init__.py", + ): + source = path.read_text(encoding="utf-8") + assert "ferro.connect =" not in source, path + assert "point_connect_at(" in source, path From c70297e3da9ccdfa6304f7dc104836574b631936 Mon Sep 17 00:00:00 2001 From: Taylor Date: Wed, 21 Oct 2026 16:30:53 -0400 Subject: [PATCH 4/5] test(docs): the shows: guard recognises every kind of Ferro output; output: and refusal: are compared on every page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FERRO_OUTPUT now matches every block the pages show as Ferro's own words: each ferro migrate output and refusal (the ` · ` line, `ferro migrate[ verb]: `, the config refusals), a line naming any exception class Ferro defines (found by walking its subclasses), a traceback frame, and a `path:N: ` prefix before a warning. A test runs it over every output:, refusal: and prints: block whose source prints what Ferro raised or warned, and VARIES is keyed by page and line, with a test that each entry still names a shows: block. Regions that caught an error and printed it bare now print f"{type(error).__name__}: {error}", as the conventions say; seven blocks regenerated. output: and refusal: blocks are read from every page through the one fence reader (no regex reader for refusals), so a typed block under either note on any page must name a registered scenario; a fabricated-block test pins it. Panel round 2 on #777: F30, F37 (and F38's refusal reader). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP --- docs/examples/auto_migrate_errors.py | 2 +- docs/examples/migration_errors.py | 2 +- docs/examples/migrations_retire_label.py | 2 +- .../migrations_retire_label_annotated.py | 2 +- docs/examples/multiple_databases.py | 4 +- docs/examples/multiple_databases_annotated.py | 4 +- docs/examples/mutations.py | 2 +- docs/examples/mutations_annotated.py | 2 +- docs/pages/guide/connections.md | 4 +- docs/pages/guide/mutations.md | 2 +- docs/pages/guide/schema/auto-migrate.md | 2 +- docs/pages/guide/schema/overview.md | 2 +- .../guide/schema/renames-and-retirements.md | 2 +- docs/pages/howto/multiple-databases.md | 4 +- tests/test_docs_migrations.py | 84 ++++++++---- tests/test_docs_output.py | 129 ++++++++++++++++-- 16 files changed, 199 insertions(+), 50 deletions(-) diff --git a/docs/examples/auto_migrate_errors.py b/docs/examples/auto_migrate_errors.py index dbcf7541..37baa5a9 100644 --- a/docs/examples/auto_migrate_errors.py +++ b/docs/examples/auto_migrate_errors.py @@ -86,7 +86,7 @@ async def required_column(url: str) -> None: try: await connect(url, migrate_updates=True) except ValueError as error: - print(error) + print(f"{type(error).__name__}: {error}") # --8<-- [end:required-column] diff --git a/docs/examples/migration_errors.py b/docs/examples/migration_errors.py index c55b2ca5..9391ad84 100644 --- a/docs/examples/migration_errors.py +++ b/docs/examples/migration_errors.py @@ -105,7 +105,7 @@ async def main() -> None: try: await ferro.connect(app.url, auto_migrate=True) except ferro.migrations.MigrationRefused as error: - print(error) + print(f"{type(error).__name__}: {error}") # --8<-- [end:tracked] diff --git a/docs/examples/migrations_retire_label.py b/docs/examples/migrations_retire_label.py index 7f46f9e6..83655159 100644 --- a/docs/examples/migrations_retire_label.py +++ b/docs/examples/migrations_retire_label.py @@ -83,7 +83,7 @@ def main() -> None: status="paused", ) except ValueError as error: - print(error) + print(f"{type(error).__name__}: {error}") # --8<-- [end:retired-write] subprocess.run( diff --git a/docs/examples/migrations_retire_label_annotated.py b/docs/examples/migrations_retire_label_annotated.py index 7dc1eaba..ceca5d0a 100644 --- a/docs/examples/migrations_retire_label_annotated.py +++ b/docs/examples/migrations_retire_label_annotated.py @@ -84,7 +84,7 @@ def main() -> None: status="paused", ) except ValueError as error: - print(error) + print(f"{type(error).__name__}: {error}") # --8<-- [end:retired-write] subprocess.run( diff --git a/docs/examples/multiple_databases.py b/docs/examples/multiple_databases.py index c614ae7b..125948b8 100644 --- a/docs/examples/multiple_databases.py +++ b/docs/examples/multiple_databases.py @@ -49,7 +49,7 @@ async def main() -> None: try: await Metric.using("analytics").all() except ValueError as error: - print(error) + print(f"{type(error).__name__}: {error}") else: raise AssertionError("expected ValueError") # --8<-- [end:conflict] @@ -60,7 +60,7 @@ async def main() -> None: try: await Metric.using("app").create(name="clicks", value=42) except ValueError as error: - print(error) + print(f"{type(error).__name__}: {error}") else: raise AssertionError("expected ValueError") diff --git a/docs/examples/multiple_databases_annotated.py b/docs/examples/multiple_databases_annotated.py index 08a14552..bac13592 100644 --- a/docs/examples/multiple_databases_annotated.py +++ b/docs/examples/multiple_databases_annotated.py @@ -50,7 +50,7 @@ async def main() -> None: try: await Metric.using("analytics").all() except ValueError as error: - print(error) + print(f"{type(error).__name__}: {error}") else: raise AssertionError("expected ValueError") # --8<-- [end:conflict] @@ -61,7 +61,7 @@ async def main() -> None: try: await Metric.using("app").create(name="clicks", value=42) except ValueError as error: - print(error) + print(f"{type(error).__name__}: {error}") else: raise AssertionError("expected ValueError") diff --git a/docs/examples/mutations.py b/docs/examples/mutations.py index 5cd0cca8..02515c54 100644 --- a/docs/examples/mutations.py +++ b/docs/examples/mutations.py @@ -165,7 +165,7 @@ async def main() -> None: try: await trial.limit(1).delete() except ValueError as error: - print(error) + print(f"{type(error).__name__}: {error}") else: raise AssertionError("expected ValueError") # --8<-- [end:mutation-guard] diff --git a/docs/examples/mutations_annotated.py b/docs/examples/mutations_annotated.py index 57e2ef38..fe46f550 100644 --- a/docs/examples/mutations_annotated.py +++ b/docs/examples/mutations_annotated.py @@ -166,7 +166,7 @@ async def main() -> None: try: await trial.limit(1).delete() except ValueError as error: - print(error) + print(f"{type(error).__name__}: {error}") else: raise AssertionError("expected ValueError") # --8<-- [end:mutation-guard] diff --git a/docs/pages/guide/connections.md b/docs/pages/guide/connections.md index 5985e837..f3844651 100644 --- a/docs/pages/guide/connections.md +++ b/docs/pages/guide/connections.md @@ -108,7 +108,7 @@ await connect("sqlite::memory:", auto_migrate=True) try: await Author.select().count() except RuntimeError as error: - print(error) + print(f"{type(error).__name__}: {error}") else: raise AssertionError("expected RuntimeError") ``` @@ -117,7 +117,7 @@ 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. +RuntimeError: 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. ``` The only other route is an explicit one: `Author.using("name")`, or `using=` / `session=` on a call. Explicit routes are covered under [Named connections](#named-connections). diff --git a/docs/pages/guide/mutations.md b/docs/pages/guide/mutations.md index d7c79eca..e9077487 100644 --- a/docs/pages/guide/mutations.md +++ b/docs/pages/guide/mutations.md @@ -477,7 +477,7 @@ 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. +ValueError: 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. ``` To change a bounded set of rows, fetch their primary keys first, then filter on them: diff --git a/docs/pages/guide/schema/auto-migrate.md b/docs/pages/guide/schema/auto-migrate.md index f8538732..4959321d 100644 --- a/docs/pages/guide/schema/auto-migrate.md +++ b/docs/pages/guide/schema/auto-migrate.md @@ -66,7 +66,7 @@ Rules worth knowing: ```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`. + ValueError: 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`. ``` Nothing was added. A migration splits that change into an expand step, a [backfill](data-steps.md) you write, and a contract step. diff --git a/docs/pages/guide/schema/overview.md b/docs/pages/guide/schema/overview.md index f5ea1c67..7bd7183b 100644 --- a/docs/pages/guide/schema/overview.md +++ b/docs/pages/guide/schema/overview.md @@ -44,7 +44,7 @@ A database is managed by auto-migrate **or** by migrations, never both. Once a d ```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. +RunRefused: 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. ``` Nothing has run, and the message names the two ways out. `main` is SQLite's schema; on Postgres the message names the schema the connection works in, such as `public`. diff --git a/docs/pages/guide/schema/renames-and-retirements.md b/docs/pages/guide/schema/renames-and-retirements.md index 0aa4aa72..f22e40ad 100644 --- a/docs/pages/guide/schema/renames-and-retirements.md +++ b/docs/pages/guide/schema/renames-and-retirements.md @@ -167,7 +167,7 @@ The member stays, so a row that holds `paused` still loads as `Status.PAUSED`. W ```text -1 validation error for Author +ValidationError: 1 validation error for Author status Status.PAUSED ('paused') is retired in this release: the previous release may still write it, this one does not [type=retired_enum_label, input_value='paused', input_type=str] ``` diff --git a/docs/pages/howto/multiple-databases.md b/docs/pages/howto/multiple-databases.md index 145cefa8..dc57684a 100644 --- a/docs/pages/howto/multiple-databases.md +++ b/docs/pages/howto/multiple-databases.md @@ -66,7 +66,7 @@ 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'. +ValueError: 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'. ``` A call with no session and no `using=` raises `RuntimeError`; Ferro never falls back to the default connection on its own. @@ -85,7 +85,7 @@ It prints: ```text -Operations inside a transaction inherit the transaction connection +ValueError: Operations inside a transaction inherit the transaction connection 2 ``` diff --git a/tests/test_docs_migrations.py b/tests/test_docs_migrations.py index 4fbd67fc..79327d10 100644 --- a/tests/test_docs_migrations.py +++ b/tests/test_docs_migrations.py @@ -41,7 +41,7 @@ import pytest import ferro.migrations -from tests._docs_harness import fenced_blocks, run_python +from tests._docs_harness import Fence, fenced_blocks, page_fences, run_python REPO_ROOT = Path(__file__).resolve().parents[1] DOCS_PAGES = REPO_ROOT / "docs" / "pages" @@ -227,17 +227,67 @@ def _empty_squash(project: Path) -> None: } +def _marked(kind: str, blocks: list[Fence] | None = None) -> dict[str, str]: + """Each ```text block marked ````, by id. + + ``blocks`` defaults to every block a docs page shows, so a marked block + on any page is compared: there is no page where the note goes unchecked. + + Raises: + AssertionError: Two blocks carry one id. + """ + found: dict[str, str] = {} + for fence in page_fences("text") if blocks is None else blocks: + if fence.note is None or fence.note[0] != kind: + continue + block_id = fence.note[1] + assert block_id not in found, ( + f"{fence.where}: {kind} id {block_id!r} marks two blocks" + ) + found[block_id] = fence.body + return found + + +def _registry_gaps( + kind: str, registry: dict, blocks: list[Fence] | None = None +) -> list[str]: + """What keeps the marked blocks and ``registry`` from naming the same ids.""" + marked = _marked(kind, blocks) + return [ + *( + f" has no entry in the registry" + for block_id in sorted(set(marked) - set(registry)) + ), + *( + f"the registry's {block_id!r} marks no block" + for block_id in sorted(set(registry) - set(marked)) + ), + ] + + def _quoted_refusals() -> dict[str, str]: - """Each ```text block the CLI reference marks ````.""" - page = CLI_REFERENCE.read_text() - found = re.findall( - r"\s*```text\n(.*?)```", page, flags=re.DOTALL - ) - return {key: text for key, text in found} + return _marked("refusal") -def test_the_cli_reference_quotes_each_database_free_refusal() -> None: - assert sorted(_quoted_refusals()) == sorted(REFUSALS) +def test_the_pages_quote_each_database_free_refusal() -> None: + assert _registry_gaps("refusal", REFUSALS) == [] + + +@pytest.mark.parametrize("kind", ["output", "refusal"]) +def test_a_marked_block_on_any_page_is_compared(kind: str) -> None: + """A typed block under ``output:`` or ``refusal:`` on a page outside the + migrations guides still has to name a registered scenario.""" + page = DOCS_PAGES / "howto" / "testing.md" + fabricated = fenced_blocks( + page, + f"# Testing\n\n\n```text\nferro migrate: typed\n```\n", + "text", + ) + registry = OUTPUTS if kind == "output" else REFUSALS + blocks = [*page_fences("text"), *fabricated] + assert _registry_gaps(kind, registry, blocks) == [ + f" has no entry in the registry" + ] @pytest.mark.parametrize("refusal", sorted(REFUSALS)) @@ -1011,12 +1061,7 @@ def _quoted_blocks(page: Path) -> list[tuple[str | None, str | None, str, str]]: def _quoted_outputs() -> dict[str, str]: - return { - block_id: body - for page in OUTPUT_PAGES - for kind, block_id, _first, body in _quoted_blocks(page) - if kind == "output" - } + return _marked("output") def _normalise(text: str) -> str: @@ -1036,14 +1081,7 @@ def _normalise(text: str) -> str: def test_every_output_block_has_a_registry_entry_and_the_reverse() -> None: - marked = [ - block_id - for page in OUTPUT_PAGES - for kind, block_id, _first, _body in _quoted_blocks(page) - if kind == "output" - ] - assert len(marked) == len(set(marked)), "an output id marks two blocks" - assert sorted(marked) == sorted(OUTPUTS) + assert _registry_gaps("output", OUTPUTS) == [] CLI_LINE = re.compile( diff --git a/tests/test_docs_output.py b/tests/test_docs_output.py index 64d198ec..d685434f 100644 --- a/tests/test_docs_output.py +++ b/tests/test_docs_output.py @@ -31,7 +31,13 @@ import pytest -from tests._docs_harness import Fence, fenced_blocks, page_fences +from tests._docs_harness import ( + REPO_ROOT, + Fence, + fenced_blocks, + page_fences, + snippet_regions, +) from tests._docs_sql import ( PRINTS, Region, @@ -79,19 +85,124 @@ def test_a_postgres_text_block_is_what_its_example_prints_there( ) +def _ferro_exception_names() -> list[str]: + """The name of every exception class Ferro defines.""" + import ferro.migrations + import ferro.migrations._chunk_loop # noqa: F401 - defines BatchFailed + + found: set[str] = set() + pending: list[type] = [BaseException] + while pending: + cls = pending.pop() + pending.extend(cls.__subclasses__()) + if cls.__module__.split(".")[0] == "ferro": + found.add(cls.__name__) + return sorted(found, key=lambda name: (-len(name), name)) + + FERRO_OUTPUT = re.compile( - r"(?m)^(?:ferro\.|ferro auto-migrate:|[\w.]*(?:Error|Refused|Warning):" - r"|\d+ validation errors? for |connect\()" + r"(?m)^\s*(?:[^\s:]+:\d+: )?" # a warning's or a traceback's `path:N: ` + r"(?:ferro\." + r"|ferro (?:auto-)?migrate(?: [a-z]+)?: " + rf"|(?:[\w.]*\.)?(?:{'|'.join(_ferro_exception_names())}):" + r"|[\w.]*(?:Error|Refused|Warning|Exception):" + r"|Traceback \(most recent call last\)" + r'|File "[^"]+", line \d+, in ' + r"|\d+ validation errors? for " + r"|connect\(" + r"|No database route " + r"|Cannot add NOT NULL column " + r"|\S+ \u00b7 \S+$" # the ` · ` line every verb but init opens with + r"|Wrote (?:ferro\.toml|\[tool\.ferro\]) " + r"|\S+\.toml: " + r"|\S+ holds two ferro configs" + r"|squash --from \S+: " + r"|changing the primary key of \S+ is not generated" + r")" ) -"""A line that reads like something Ferro prints, raises or warns.""" +"""A line that reads like something Ferro prints, raises or warns: an +exception or warning line (with its ``path:N:`` prefix, if any), a +``ferro migrate`` output or refusal, or one of Ferro's own messages.""" VARIES = { - "guide/connections.md": "the statement log's lines end with how long each " - "statement took, which differs on every run", + "guide/connections.md:333": "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.""" +"""The ``shows:`` blocks holding Ferro's output that no run reproduces byte +for byte, by page and line, and why.""" + + +_PRINTS_A_MESSAGE = re.compile( + r"print\((?:f\"\{type\((\w+)\)\.__name__\}: \{\1\}\"|\w+\.message)\)" +) +"""A ``print`` of what was raised (``f"{type(error).__name__}: {error}"``) or +warned (``warning.message``), the forms the conventions give.""" + + +def _shows_a_message(block: Fence) -> bool: + """Whether a block shows Ferro's own words: ``ferro migrate``'s output or + a refusal, or a ``prints:`` block whose source prints what Ferro raised or + warned.""" + kind, value = block.note or ("", "") + if kind in ("output", "refusal"): + return True + if kind != "prints": + return False + if value == "above": + fence = printing_fence(block) + source = fence.source if fence is not None else "" + else: + marker = PRINTS.match(value) + assert marker is not None, page_name(block) + script = REPO_ROOT / "docs" / "examples" / marker["example"] + regions = snippet_regions(script.read_text(encoding="utf-8")) + source = regions[marker["snippet"]].body + return _PRINTS_A_MESSAGE.search(source) is not None + + +def _ferro_output_blocks() -> list[Fence]: + """Every block the pages show as Ferro's own words.""" + return [block for block in page_fences("text") if _shows_a_message(block)] + + +@pytest.mark.parametrize( + "line", + [ + "No database route for this operation. Open a session", + "Cannot add NOT NULL column 'author.bio' to an existing table", + "ferro.exceptions.ModelDoesNotExist: Author with pk 7 does not exist", + ' File "app.py", line 3, in ', + "ferro.migrations.BatchFailed: batch 3 failed", + "app.py:12: UserWarning: Check constraint 'ck_author_kind' is declared", + "Traceback (most recent call last):", + "ferro migrate: this database has applied 0002_author_slug", + "ferro migrate squash: nothing to squash", + ], +) +def test_the_recogniser_knows_each_kind_of_ferro_output(line: str) -> None: + assert FERRO_OUTPUT.search(line), line + + +@pytest.mark.parametrize("block", _ferro_output_blocks(), ids=page_name) +def test_the_recogniser_matches_every_block_of_ferro_output(block: Fence) -> None: + """The ``shows:`` guard is only as good as this pattern: it must match every + block the pages already show as Ferro's output.""" + assert FERRO_OUTPUT.search(block.body), ( + f"{page_name(block)}: FERRO_OUTPUT does not recognise this block of " + f"Ferro's output, so a block holding it would pass:\n" + f"{block.body}" + ) + + +def test_each_varies_entry_names_a_shows_block() -> None: + shows = { + page_name(block) + for block in page_fences("text") + if block.note and block.note[0] == "shows" + } + stale = sorted(set(VARIES) - shows) + assert not stale, f"VARIES names no shows: block at {stale}; update the line" @pytest.mark.parametrize( @@ -100,7 +211,7 @@ def test_a_postgres_text_block_is_what_its_example_prints_there( ids=page_name, ) def test_a_shows_block_holds_nothing_ferro_prints(block: Fence) -> None: - if page_name(block).rsplit(":", 1)[0] in VARIES: + if page_name(block) in VARIES: return line = FERRO_OUTPUT.search(block.body) assert line is None, ( From 74ee91e29cc3305dd84cb6fafe922bc26e364a75 Mon Sep 17 00:00:00 2001 From: Taylor Date: Wed, 21 Oct 2026 16:49:54 -0400 Subject: [PATCH 5/5] docs: the 0.26 note gives update() its own column-order rule; Deploying names one migration per number update() keeps the order the call passes its columns, as it did in 0.25; the note now says so, and lists only save(), save(only=), save(exclude=) and bulk_create() for field order. A test pins that wording, and a new column-order test pins update() keeping a bytes column where the call put it. The Deploying guide's CI check, deploy-step run and status report now come from the two-release chain the rest of the page follows (0002_author_slug_nullable, 0003_author_slug_required), so no number names two migrations; a test pins it. Review of #782: F31 (update order), F32 (one number per migration). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP --- docs/pages/guide/schema/deploying.md | 27 ++++++++--------- docs/pages/upgrading.md | 2 +- tests/test_docs_migrations.py | 43 ++++++++++++++++++++++++++-- tests/test_routing_errors.py | 1 - tests/test_save_column_order.py | 43 ++++++++++++++++++++++++++++ 5 files changed, 98 insertions(+), 18 deletions(-) diff --git a/docs/pages/guide/schema/deploying.md b/docs/pages/guide/schema/deploying.md index d9ce7e31..3191d3c9 100644 --- a/docs/pages/guide/schema/deploying.md +++ b/docs/pages/guide/schema/deploying.md @@ -13,7 +13,7 @@ Migrations reach a database in one of two ways: a deploy step runs `ferro migrat ```text default · migrations -ok: models match 0002_author_slug +ok: models match 0003_author_slug_required ``` The models match the newest migration, so the check passes. It exits 3, naming each problem, when a model change has no migration, the snapshot chain is broken, a number is duplicated or missing, a step lacks a target dialect's rendering, or a data step still says `todo(...)`. Run it on every pull request. @@ -43,10 +43,10 @@ The deploy step applies every pending migration, in order, under the run lock: ```text default · sqlite:///./app.db -0002_author_slug 01_expand applied (0 ms) -0002_author_slug 02_backfill_author applied (0 ms) -0002_author_slug 03_add_constraint applied (0 ms) -0002_author_slug 04_contract applied (1 ms) +0002_author_slug_nullable 01_schema applied (0 ms) +0003_author_slug_required 01_backfill_author applied (2 ms) +0003_author_slug_required 02_add_constraint applied (0 ms) +0003_author_slug_required 03_contract applied (1 ms) ``` It exits 0 when it applied everything (or there was nothing to apply), 1 when it refused or a step failed; the message says why and how to go on, and the next `up` resumes at the failed step. The URL comes from `$DATABASE_URL` (or the variable the configuration's `url_env` names), or `--url`. @@ -219,20 +219,21 @@ Two different waits apply: ## Database status -`ferro migrate status` reads the tracking table without a lock and changes nothing. Here a contract step failed on a [late row](data-steps.md#when-a-late-row-arrives), so `0002_author_slug` is `partial` and the failed step carries its error: +`ferro migrate status` reads the tracking table without a lock and changes nothing. Here a contract step failed on a [late row](data-steps.md#when-a-late-row-arrives), so `0003_author_slug_required` is `partial` and the failed step carries its error: ```text default · sqlite:///./app.db !! 1 failed · 1 pending main._ferro_migrations - ok 0001_create_author applied 2026-10-15 04:30Z + ok 0001_create_author applied 2026-10-15 04:30Z 01_schema.up.sqlite.sql applied expand - ~~ 0002_author_slug partial, 3 of 4 steps 2026-10-15 04:30Z - 01_expand.up.sqlite.sql applied expand - 02_backfill_author.py applied expand - 03_add_constraint.up.sqlite.sql applied expand - 04_contract.up.sqlite.sql failed [data-dependent] contract - 1 row still has NULL "slug" in "author"; run ferro migrate down --to 0002:01 then ferro migrate up to re-run the backfill + ok 0002_author_slug_nullable applied 2026-10-15 04:30Z + 01_schema.up.sqlite.sql applied expand + ~~ 0003_author_slug_required partial, 2 of 3 steps 2026-10-15 04:30Z + 01_backfill_author.py applied expand + 02_add_constraint.up.sqlite.sql applied expand + 03_contract.up.sqlite.sql failed [data-dependent] contract + 1 row still has NULL "slug" in "author"; run ferro migrate down --to 0002 then ferro migrate up to re-run the backfill ``` With `--steps`, each step's [class](#rolling-deploys-and-the-two-release-contract) follows its flags. Without it, only the migrations that need a look list their steps. diff --git a/docs/pages/upgrading.md b/docs/pages/upgrading.md index 2bcc1453..7cd68b82 100644 --- a/docs/pages/upgrading.md +++ b/docs/pages/upgrading.md @@ -76,7 +76,7 @@ Four changes in `0.26` can reach code that runs today. Two show up in a type che - `Session.query` is now typed: it takes `type[M]` and returns `Query[M]`, so a type checker may flag a call that passes a class that is not a `Model`. - The undocumented module `ferro.migrations.chunked` is now the private `ferro.migrations._chunk_loop`. `from ferro.migrations import chunked` still imports the [`chunked`](api/migrations.md#ferro.migrations.chunked) decorator, unchanged. - The `RuntimeError` an operation raises with no database route ends differently. It said "Implicit default-connection routing was removed in v0.13 (deprecated since v0.12); see the sessions migration guide." and now says "Implicit default-connection routing was removed in v0.14 (deprecated since v0.12); see the upgrade guide." Code that matches the message on its old ending needs the new one; a match on its start, `No database route`, still holds. -- Every write lists its columns in the model's field order: `save()` of a new or fetched record, `save(only=...)`, `save(exclude=...)`, `bulk_create()` and `update()`. Before, a `bytes` or secret column came last, a fetched record's `save()` followed the order its `SELECT` returned, and `save(only=...)` or `save(exclude=...)` could change order from one run to the next. The rows written are the same; the statement text is not, so a test or a log check that compares captured SQL (the [statement log](guide/connections.md#logging) or `.sql()`) needs its expected text updated. +- `save()` of a new or fetched record, `save(only=...)`, `save(exclude=...)` and `bulk_create()` list their columns in the model's field order. Before, a `bytes` or secret column came last, a fetched record's `save()` followed the order its `SELECT` returned, and `save(only=...)` or `save(exclude=...)` could change order from one run to the next. The rows written are the same; the statement text is not, so a test or a log check that compares captured SQL (the [statement log](guide/connections.md#logging) or `.sql()`) needs its expected text updated. `update()` is unchanged: it lists the columns in the order the call passes them, a `bytes` or secret column included. ## Upgrading to 0.25 diff --git a/tests/test_docs_migrations.py b/tests/test_docs_migrations.py index 79327d10..82fd7348 100644 --- a/tests/test_docs_migrations.py +++ b/tests/test_docs_migrations.py @@ -795,6 +795,33 @@ def _slug_late_row_reverted(project: Path) -> None: step.write_text(NOT_APPLICABLE) +# The Deploying guide's two-release chain, as docs/examples/migration_errors.py +# builds it: 0002_author_slug_nullable (one expand step), then +# 0003_author_slug_required (backfill, add-constraint, contract), so the page +# names one migration per number. + +REQUIRED = "0003_author_slug_required" + + +def _chain_written(project: Path) -> None: + _slug_edited(project) + _write_models(project, NAMED_AUTHOR_OPTIONAL_SLUG) + _run_ok(project, "migrate", "new", "author_slug_nullable") + _write_models(project, NAMED_AUTHOR_SLUG) + _run_ok(project, "migrate", "new", "author_slug_required") + backfill = project / "migrations" / REQUIRED / "01_backfill_author.py" + backfill.write_text(backfill.read_text().replace(TODO, WRITTEN)) + + +def _chain_late_row_failed(project: Path) -> None: + """A writer inserts a row without a slug after the backfill, so the + contract step fails.""" + _chain_written(project) + step = project / "migrations" / REQUIRED / "02_add_constraint.up.sqlite.sql" + step.write_text(LATE_ROW) + assert _ferro(project, "migrate", "up", scratch=True).returncode == 1 + + # A database built by auto-migrate, about to be baselined. @@ -1029,9 +1056,9 @@ def _drop_postgres_schemas(templates: list[Path]) -> None: # deploying "run-lock-wait": (_lock_wait, ("migrate", "up", "--lock-timeout", "1s")), "ddl-lock-retry": (_ddl_lock_retry, ("migrate", "up")), - "ci-check": (_slug_written, ("migrate", "check")), - "server-up": (_slug_written, ("migrate", "up")), - "status-steps": (_slug_late_row_failed, ("migrate", "status", "--steps")), + "ci-check": (_chain_written, ("migrate", "check")), + "server-up": (_chain_written, ("migrate", "up")), + "status-steps": (_chain_late_row_failed, ("migrate", "status", "--steps")), "edited-up": (_loop_edited_step, ("migrate", "up")), "edited-rerecord": (_loop_edited_step, ("migrate", "rerecord", "0001:01")), # adopting @@ -1467,3 +1494,13 @@ def test_a_quoted_revision_is_what_autogenerate_writes( f"guide/schema/alembic.md quotes revision {revision!r}; autogenerate writes:\n" f"{printed}" ) + + +def test_the_deploying_guide_names_one_migration_per_number() -> None: + """Every block and sentence of the Deploying guide follows one project, so + a number never names two migrations there.""" + page = (DOCS_PAGES / "guide" / "schema" / "deploying.md").read_text() + names: dict[str, set[str]] = {} + for name in re.findall(r"\b(\d{4})_([a-z0-9_]+)", page): + names.setdefault(name[0], set()).add(name[1]) + assert {number: sorted(n) for number, n in names.items() if len(n) > 1} == {} diff --git a/tests/test_routing_errors.py b/tests/test_routing_errors.py index e34dc560..e68d7914 100644 --- a/tests/test_routing_errors.py +++ b/tests/test_routing_errors.py @@ -53,7 +53,6 @@ def test_the_upgrade_guide_names_the_reworded_no_route_message(): "removed in v0.13 (deprecated since v0.12); see the sessions migration guide." in section ) - assert "field order" in section @pytest.mark.asyncio diff --git a/tests/test_save_column_order.py b/tests/test_save_column_order.py index eacb6ec1..f9d0ba44 100644 --- a/tests/test_save_column_order.py +++ b/tests/test_save_column_order.py @@ -21,6 +21,7 @@ class Customer(Model): import logging import re +from pathlib import Path from typing import Annotated import pytest @@ -187,3 +188,45 @@ async def test_query_update_lists_columns_in_keyword_order(statement_records): (update,) = _sent(statement_records, "UPDATE") assert _set_columns(update) == ["e", "a", "d"] + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("saved_row") +async def test_query_update_keeps_a_raw_column_where_the_call_puts_it( + statement_records, +): + async with engines.session(): + await ColOrder.where(lambda col_order: col_order.id == 1).update( + e="x", blob=b"y", a="z" + ) + + (update,) = _sent(statement_records, "UPDATE") + assert _set_columns(update) == ["e", "blob", "a"] + + +def _upgrade_bullet() -> str: + guide = ( + Path(__file__).resolve().parents[1] / "docs/pages/upgrading.md" + ).read_text() + section = guide.split("## Upgrading to 0.26", 1)[1].split("\n## ", 1)[0] + [bullet] = [line for line in section.splitlines() if "field order" in line] + return bullet + + +def test_the_upgrade_guide_states_each_write_door_s_column_order(): + """The 0.26 note says what the tests above pin: the save doors and + ``bulk_create`` write in field order, ``update()`` in the call's order.""" + bullet = _upgrade_bullet() + field_order, _, rest = bullet.partition("field order") + for door in ( + "`save()`", + "`save(only=...)`", + "`save(exclude=...)`", + "`bulk_create()`", + ): + assert door in field_order, door + assert "update()" not in field_order + assert ( + "`update()` is unchanged: it lists the columns in the order the call passes them" + in rest + )