From 6d9602bf84fa3af29b5aff240ccf0fa5942a9760 Mon Sep 17 00:00:00 2001 From: Taylor Date: Wed, 21 Oct 2026 14:22:42 -0400 Subject: [PATCH 1/8] fix(cli): restore ferro migrate status --no-steps and --no-json A docstrings commit set negative="" on status's two bool flags, which removed the --no-steps / --no-json spellings that shipped. Drop it, and pin both spellings and their place in --help. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP --- src/ferro/cli/migrate.py | 8 ++------ tests/test_cli_help.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/ferro/cli/migrate.py b/src/ferro/cli/migrate.py index 796a94d4..3f958079 100644 --- a/src/ferro/cli/migrate.py +++ b/src/ferro/cli/migrate.py @@ -557,15 +557,11 @@ def status( steps: Annotated[ bool, Parameter( - help="Print every migration's steps, not only those needing attention.", - negative="", + help="Print every migration's steps, not only those needing attention." ), ] = False, json_: Annotated[ - bool, - Parameter( - name="--json", negative="", help="Print the report as a JSON document." - ), + bool, Parameter(name="--json", help="Print the report as a JSON document.") ] = False, glob: Annotated[Global, Parameter(parse=False)], ) -> int: diff --git a/tests/test_cli_help.py b/tests/test_cli_help.py index 4198b4de..443508ef 100644 --- a/tests/test_cli_help.py +++ b/tests/test_cli_help.py @@ -89,3 +89,31 @@ def test_help_summary_is_one_line(path, command) -> None: f"{' '.join(('ferro', *path))}: summary {summary!r} is not one capitalised line ending " "with a period" ) + + +@pytest.mark.parametrize( + ("flag", "parameter", "value"), + [ + ("--steps", "steps", True), + ("--no-steps", "steps", False), + ("--json", "json_", True), + ("--no-json", "json_", False), + ], +) +def test_status_accepts_both_spellings_of_its_flags(flag, parameter, value) -> None: + # `--no-steps` and `--no-json` shipped with `ferro migrate status`; a + # script that passes them must keep parsing. + _, bound, _ = app["migrate"].parse_args( + ["status", flag], exit_on_error=False, print_error=False + ) + assert bound.arguments[parameter] is value + + +def test_status_help_lists_the_negative_flags(capsys) -> None: + try: + app(["migrate", "status", "--help"]) + except SystemExit: + pass + out = capsys.readouterr().out + assert "--no-steps" in out + assert "--no-json" in out From 1aa2438bce70943314e98bc237e8d18af90cc541 Mon Sep 17 00:00:00 2001 From: Taylor Date: Wed, 21 Oct 2026 14:22:49 -0400 Subject: [PATCH 2/8] fix(pool): restore PoolConfig.validate The bounds validator was renamed _check_bounds, so PoolConfig(...).validate() fell through to pydantic's deprecated classmethod and raised TypeError. Restore the public name, give it a reference docstring, and pin that it returns the instance. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP --- src/ferro/__init__.py | 13 ++++++++++++- tests/test_connection.py | 6 ++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/ferro/__init__.py b/src/ferro/__init__.py index 9860ce1d..1dd5a47e 100644 --- a/src/ferro/__init__.py +++ b/src/ferro/__init__.py @@ -304,7 +304,18 @@ class PoolConfig(BaseModel): """ @model_validator(mode="after") - def _check_bounds(self) -> "PoolConfig": + def validate(self) -> "PoolConfig": + """Check that `min_connections` does not exceed `max_connections`. + + Pydantic runs it on construction, so a `PoolConfig` you hold has + already passed it. + + Returns: + This configuration, unchanged. + + Raises: + ValueError: `min_connections` exceeds `max_connections`. + """ if self.min_connections > self.max_connections: raise ValueError("min_connections cannot exceed max_connections") return self diff --git a/tests/test_connection.py b/tests/test_connection.py index b5db02e2..0df598d2 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -492,6 +492,12 @@ async def test_pool_config_rejects_min_connections_above_max(tmp_path): ferro.PoolConfig(max_connections=1, min_connections=2) +def test_pool_config_validate_returns_the_instance(): + """`PoolConfig.validate` is the public bounds check, not pydantic's classmethod.""" + pool = ferro.PoolConfig(max_connections=3, min_connections=1) + assert pool.validate() is pool + + @pytest.mark.asyncio async def test_connect_passes_pool_config_to_core(monkeypatch): """Validated pool configuration should cross the Python/Rust API boundary.""" From c05df7ad75ba8ec38b833653d07ccf0ead4545de Mon Sep 17 00:00:00 2001 From: Taylor Date: Wed, 21 Oct 2026 14:22:49 -0400 Subject: [PATCH 3/8] fix(session): restore the shipped no-route RuntimeError text The message was reworded in a docs commit; callers may match on it. Restore it, pin it exactly, and quote it as sent on the Connections page. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP --- docs/pages/guide/connections.md | 2 +- src/ferro/state.py | 4 ++-- tests/test_routing_errors.py | 17 +++++++++++++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/docs/pages/guide/connections.md b/docs/pages/guide/connections.md index 65aa5761..70607102 100644 --- a/docs/pages/guide/connections.md +++ b/docs/pages/guide/connections.md @@ -116,7 +116,7 @@ else: It prints: ```text -No database route for this operation. Open a session (`async with ferro.engines.session(...)`) or pass `using=`/`session=` explicitly. Implicit default-connection routing was removed in v0.14 (deprecated since v0.12); see the upgrade guide. +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.13 (deprecated since v0.12); see the sessions migration 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/src/ferro/state.py b/src/ferro/state.py index d19cab56..79ccccd9 100644 --- a/src/ferro/state.py +++ b/src/ferro/state.py @@ -54,8 +54,8 @@ def _ensure_active_session(session: SessionLike | None) -> None: _NO_ROUTE_MESSAGE = ( "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." + "explicitly. Implicit default-connection routing was removed in v0.13 " + "(deprecated since v0.12); see the sessions migration guide." ) diff --git a/tests/test_routing_errors.py b/tests/test_routing_errors.py index fff3f73c..5389bb23 100644 --- a/tests/test_routing_errors.py +++ b/tests/test_routing_errors.py @@ -20,6 +20,23 @@ class RtA(Model): await RtA.all() +NO_ROUTE_MESSAGE = ( + "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.13 " + "(deprecated since v0.12); see the sessions migration guide." +) + + +@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. + await connect(db_url, auto_migrate=True) + with pytest.raises(RuntimeError) as raised: + await execute("SELECT 1") + assert str(raised.value) == NO_ROUTE_MESSAGE + + @pytest.mark.asyncio async def test_raw_with_no_route_raises(db_url): await connect(db_url, auto_migrate=True) From a4996aa2dc0d2a220031640d94c9dbf29cc0fdbd Mon Sep 17 00:00:00 2001 From: Taylor Date: Wed, 21 Oct 2026 14:22:49 -0400 Subject: [PATCH 4/8] docs(api): name AttributeError in select()'s Raises Model.select and Query.select raise AttributeError for a column the model does not have; Model.select's entry said ValueError. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP --- src/ferro/models.py | 4 +++- src/ferro/query/builder.py | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ferro/models.py b/src/ferro/models.py index 02fbc937..485fb6e1 100644 --- a/src/ferro/models.py +++ b/src/ferro/models.py @@ -791,9 +791,11 @@ def select( [`ProjectedQuery`][ferro.query.builder.ProjectedQuery]. Raises: + AttributeError: A selector names a column the model does not have. TypeError: The selectors mix a lambda with column names, or are not a lambda or strings. - ValueError: A selector names a field the model does not have. + ValueError: The selection is empty, two fields share an output + name, or a string names a related column. Examples: ```python {test=run prelude=reference} diff --git a/src/ferro/query/builder.py b/src/ferro/query/builder.py index 81bd59f2..eadc3e04 100644 --- a/src/ferro/query/builder.py +++ b/src/ferro/query/builder.py @@ -784,6 +784,7 @@ def select(self, *selectors: "RowSelector[T] | str") -> "Self | ProjectedQuery[T [`ProjectedQuery`][ferro.query.builder.ProjectedQuery]. Raises: + AttributeError: A selector names a column the model does not have. TypeError: The selectors mix a lambda with column names, select a relation or a comparison instead of a field, or nest a dict and a tuple. From fa6dceabbe10a1bed1f09d092f4bec7891cda226 Mon Sep 17 00:00:00 2001 From: Taylor Date: Wed, 21 Oct 2026 14:25:11 -0400 Subject: [PATCH 5/8] fix(save): write columns in field declaration order save(only=) and save(exclude=) built their write set as a Python set, so the UPDATE listed its columns in hash order, which changes per process. A fetched instance also dumped in the SELECT's (alphabetical) order, and bytes and secret columns were appended last. save_bind_payload now returns its map in field declaration order and the write-set filter keeps that order, so save(), save(only=), save(exclude=) and bulk_create emit one statement per call on every run. The docs SQL gate no longer pins PYTHONHASHSEED=0, the Mutations page's save-only and save-exclude blocks are regenerated, and its sentence saying the order is not fixed is gone. Closes #776 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP --- docs/pages/guide/mutations.md | 10 +- src/ferro/_bind_payload.py | 23 +++- tests/test_docs_sql.py | 9 +- tests/test_save_column_order.py | 189 ++++++++++++++++++++++++++++++++ 4 files changed, 213 insertions(+), 18 deletions(-) create mode 100644 tests/test_save_column_order.py diff --git a/docs/pages/guide/mutations.md b/docs/pages/guide/mutations.md index 5ddc688c..1d202704 100644 --- a/docs/pages/guide/mutations.md +++ b/docs/pages/guide/mutations.md @@ -297,7 +297,7 @@ The fetch, the partial save and the refresh send these statements. PostgreSQL al LIMIT ?; UPDATE "customer" - SET "updated_at" = ?, "name" = ? + SET "name" = ?, "updated_at" = ? WHERE "id" = ?; SELECT "customer"."email", "customer"."id", "customer"."name", "customer"."plan", "customer"."updated_at", "customer"."views" @@ -316,7 +316,7 @@ The fetch, the partial save and the refresh send these statements. PostgreSQL al LIMIT $2; UPDATE "customer" - SET "updated_at" = CAST($1 AS timestamptz), "name" = $2 + SET "name" = $1, "updated_at" = CAST($2 AS timestamptz) WHERE "id" = $3; SELECT "customer"."email", "customer"."id", "customer"."name", "customer"."plan", "customer"."updated_at", "customer"."views" @@ -325,7 +325,7 @@ The fetch, the partial save and the refresh send these statements. PostgreSQL al LIMIT $2; ``` -The `UPDATE` sets only `name` and `updated_at`. Ferro writes the columns of a partial save in no fixed order, so a log may show them the other way round. +The `UPDATE` sets only `name` and `updated_at`. `save(exclude=...)` writes every column except the ones you name: @@ -342,7 +342,7 @@ The save and the refresh send these statements. PostgreSQL also casts the `datet ```sql -- mutations.py:save-exclude (sqlite) UPDATE "customer" - SET "updated_at" = ?, "name" = ?, "views" = ?, "email" = ? + SET "email" = ?, "name" = ?, "views" = ?, "updated_at" = ? WHERE "id" = ?; SELECT "customer"."email", "customer"."id", "customer"."name", "customer"."plan", "customer"."updated_at", "customer"."views" @@ -356,7 +356,7 @@ The save and the refresh send these statements. PostgreSQL also casts the `datet ```sql -- mutations.py:save-exclude (postgres) UPDATE "customer" - SET "updated_at" = CAST($1 AS timestamptz), "name" = $2, "views" = $3, "email" = $4 + SET "email" = $1, "name" = $2, "views" = $3, "updated_at" = CAST($4 AS timestamptz) WHERE "id" = $5; SELECT "customer"."email", "customer"."id", "customer"."name", "customer"."plan", "customer"."updated_at", "customer"."views" diff --git a/src/ferro/_bind_payload.py b/src/ferro/_bind_payload.py index 95b49e98..d97d441c 100644 --- a/src/ferro/_bind_payload.py +++ b/src/ferro/_bind_payload.py @@ -148,16 +148,28 @@ def canonicalize_wire_scalar(value: Any) -> Any: def save_bind_payload(instance: Any) -> dict[str, Any]: - """Column->value map for ``save``/``bulk_create``. + """Column->value map for ``save``/``bulk_create``, in field order. Columns go through pydantic ``model_dump(mode="json")`` (honoring field - serializers/aliases), except bytes and secret columns, which are overlaid + serializers/aliases), except bytes and secret columns, which are bound raw: bytes verbatim, a secret as ``unwrap_secret`` gives it. + + The map is in field declaration order, so a statement lists its columns + the same way on every call: the dump follows the instance's ``__dict__``, + which a fetch fills in the ``SELECT``'s column order, and the raw columns + are bound apart from it. """ raw = _raw_bound_fields(instance) payload: dict[str, Any] = instance.model_dump(mode="json", exclude=set(raw)) - payload.update(raw) - return payload + ordered: dict[str, Any] = {} + for name in type(instance).model_fields: + if name in raw: + ordered[name] = raw[name] + elif name in payload: + ordered[name] = payload[name] + for name, value in payload.items(): + ordered.setdefault(name, value) + return ordered def normalize_save_columns(names: object, *, param: str) -> set[str]: @@ -255,7 +267,8 @@ def _keep_write_set( f"save({param}=...) bind payload is missing " f"{sorted(missing)!r}. Legal persisted columns: {legal}." ) - return {key: payload[key] for key in keep} + # The payload is in field order; a set's order changes with the hash seed. + return {key: value for key, value in payload.items() if key in keep} def apply_save_only( diff --git a/tests/test_docs_sql.py b/tests/test_docs_sql.py index 9fa99e1f..be64caa1 100644 --- a/tests/test_docs_sql.py +++ b/tests/test_docs_sql.py @@ -32,7 +32,6 @@ import io import json import logging -import os import re import subprocess import sys @@ -416,12 +415,7 @@ def test_a_block_differing_on_postgres_only_in_placeholders_stands_for_both() -> def _capture(example: str, dialect: str, url: str | None) -> dict[str, list[str]]: - """What each snippet region of ``example`` sends on ``dialect``. - - The example runs with ``PYTHONHASHSEED=0``: ``save(only=...)`` and - ``save(exclude=...)`` write their columns in the order of a Python set, so - the order of their ``SET`` list follows string hashing. - """ + """What each snippet region of ``example`` sends on ``dialect``.""" command = [sys.executable, "-m", "tests.test_docs_sql", example, dialect] if url is not None: command.append(url) @@ -431,7 +425,6 @@ def _capture(example: str, dialect: str, url: str | None) -> dict[str, list[str] text=True, timeout=180, cwd=REPO_ROOT, - env={**os.environ, "PYTHONHASHSEED": "0"}, ) assert result.returncode == 0, ( f"capturing {example} on {dialect} failed\n" diff --git a/tests/test_save_column_order.py b/tests/test_save_column_order.py new file mode 100644 index 00000000..eacb6ec1 --- /dev/null +++ b/tests/test_save_column_order.py @@ -0,0 +1,189 @@ +"""A write lists its columns in the model's field order (#776). + +```python +class Customer(Model): + id: int | None = Field(default=None, primary_key=True) + name: str = "" + updated_at: datetime | None = None + +await customer.save(only={"updated_at", "name"}) +# UPDATE "customer" SET "name" = ?, "updated_at" = ? WHERE "id" = ? +``` + +``only=`` and ``exclude=`` take a set, whose iteration order changes with +the process's hash seed, and a fetched instance holds its values in the +order the ``SELECT`` listed them. Neither may reach the statement: every +write lists its columns in the order the model declares its fields, so the +statement log and ``.sql()`` show one statement for one call. A bytes or +secret column, bound raw rather than through the JSON dump, keeps its +declared place too. +""" + +import logging +import re +from typing import Annotated + +import pytest +import pytest_asyncio +from pydantic import SecretStr + +import ferro +from ferro import Model, engines +from ferro.base import FerroField + +pytestmark = pytest.mark.backend_matrix + +# Declared out of alphabetical order, which is the order a fetch hydrates in. +FIELDS = ["h", "b", "blob", "f", "a", "token", "d", "c", "g", "e"] + + +class ColOrder(Model): + id: Annotated[int | None, FerroField(primary_key=True)] = None + h: str = "h" + b: str = "b" + blob: bytes = b"\x00" + f: int = 0 + a: str = "a" + token: SecretStr = SecretStr("s") + d: int = 0 + c: str = "c" + g: str = "g" + e: str = "e" + + +class PlainOrder(Model): + """No bytes or secret column: every value goes through the JSON dump.""" + + id: Annotated[int | None, FerroField(primary_key=True)] = None + zeta: str = "z" + mid: int = 0 + alpha: str = "a" + + +SET_COLUMN = re.compile(r'"(\w+)" = ') + + +def _set_columns(sql: str) -> list[str]: + """The columns of an ``UPDATE``'s ``SET`` clause, in the order it lists them.""" + head, _, _ = sql.partition(" WHERE ") + _, _, assignments = head.partition(" SET ") + return SET_COLUMN.findall(assignments) + + +def _insert_columns(sql: str) -> list[str]: + columns = sql.partition("(")[2].partition(")")[0] + return [c for c in re.findall(r'"(\w+)"', columns) if c != "id"] + + +def _sent(records: list[logging.LogRecord], prefix: str) -> list[str]: + return [ + r.sql + for r in records + if r.operation not in ("migrate", "catalog") and r.sql.startswith(prefix) + ] + + +def _in_field_order(columns: set[str]) -> list[str]: + return [name for name in FIELDS if name in columns] + + +@pytest_asyncio.fixture +async def saved_row(db_url): + await ferro.connect(db_url, auto_migrate=True) + async with engines.session(): + await ColOrder(id=1).save() + await PlainOrder(id=1).save() + yield + ferro.reset_engine() + + +ONLY = {"h", "b", "token", "f", "a", "blob", "d"} +EXCLUDE = {"c", "g"} + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("saved_row") +async def test_save_only_lists_columns_in_field_order(statement_records): + async with engines.session(): + row = await ColOrder.get(1) + await row.save(only=ONLY) + + (update,) = _sent(statement_records, "UPDATE") + assert _set_columns(update) == _in_field_order(ONLY) + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("saved_row") +async def test_save_only_two_columns_in_field_order(statement_records): + async with engines.session(): + row = await ColOrder.get(1) + await row.save(only={"a", "b"}) + + (update,) = _sent(statement_records, "UPDATE") + assert _set_columns(update) == ["b", "a"] + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("saved_row") +async def test_save_exclude_lists_columns_in_field_order(statement_records): + async with engines.session(): + row = await ColOrder.get(1) + await row.save(exclude=EXCLUDE) + + (update,) = _sent(statement_records, "UPDATE") + assert _set_columns(update) == [n for n in FIELDS if n not in EXCLUDE] + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("saved_row") +async def test_a_full_save_lists_columns_in_field_order(statement_records): + async with engines.session(): + row = await ColOrder.get(1) + await row.save() + await ColOrder(id=2).save() + + (update,) = _sent(statement_records, "UPDATE") + assert _set_columns(update) == FIELDS + (insert,) = _sent(statement_records, "INSERT") + assert _insert_columns(insert) == FIELDS + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("saved_row") +async def test_a_fetched_instance_without_raw_columns_writes_in_field_order( + statement_records, +): + async with engines.session(): + row = await PlainOrder.get(1) + await row.save() + await row.save(only={"alpha", "zeta"}) + await row.save(exclude={"mid"}) + + updates = _sent(statement_records, "UPDATE") + assert [_set_columns(update) for update in updates] == [ + ["zeta", "mid", "alpha"], + ["zeta", "alpha"], + ["zeta", "alpha"], + ] + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("saved_row") +async def test_bulk_create_lists_columns_in_field_order(statement_records): + async with engines.session(): + await ColOrder.bulk_create([ColOrder(id=3), ColOrder(id=4)]) + + (insert,) = _sent(statement_records, "INSERT") + assert _insert_columns(insert) == FIELDS + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("saved_row") +async def test_query_update_lists_columns_in_keyword_order(statement_records): + async with engines.session(): + await ColOrder.where(lambda col_order: col_order.id == 1).update( + e="x", a="y", d=3 + ) + + (update,) = _sent(statement_records, "UPDATE") + assert _set_columns(update) == ["e", "a", "d"] From def5030636f0bc73aa29986a6b37e13c453eeecf Mon Sep 17 00:00:00 2001 From: Taylor Date: Wed, 21 Oct 2026 14:25:32 -0400 Subject: [PATCH 6/8] docs(upgrading): note the typed Session.query and the private chunk loop module Add the 0.26 section: Session.query now takes type[M] and returns Query[M], and ferro.migrations.chunked (never documented) is now the private ferro.migrations._chunk_loop; the chunked decorator is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP --- docs/pages/upgrading.md | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/docs/pages/upgrading.md b/docs/pages/upgrading.md index e53e80c6..e887dbb0 100644 --- a/docs/pages/upgrading.md +++ b/docs/pages/upgrading.md @@ -15,7 +15,7 @@ description: "What upgrading Ferro asks of your code, release by release, and ho # Upgrading between versions -This guide lists what upgrading Ferro asks of your code, release by release from `0.11` to `0.25`; the [changelog](changelog.md) is the exhaustive record. After an upgrade, a query reads like this, inside a session: +This guide lists what upgrading Ferro asks of your code, release by release from `0.11` to `0.26`; the [changelog](changelog.md) is the exhaustive record. After an upgrade, a query reads like this, inside a session: ```python {test=run prelude=upgrade} from ferro import engines @@ -47,11 +47,12 @@ Sections run newest first. From version X, read every section for a release newe | You are on | Moving to | Read | | --- | --- | --- | -| `0.24.x` | `0.25` | [Upgrading to 0.25](#upgrading-to-025) | -| `0.15.x` | `0.25` | Every `0.16` to `0.25` section, and the [no-action table](#releases-that-need-no-action) | -| `0.14.x` | `0.25` | The `0.15` section as well | -| `0.12.x` or `0.13.x` | `0.25` | The `0.14` section as well | -| `0.11.x` or earlier | `0.25` | Every section | +| `0.25.x` | `0.26` | [Upgrading to 0.26](#upgrading-to-026) | +| `0.24.x` | `0.26` | [Upgrading to 0.26](#upgrading-to-026) and [Upgrading to 0.25](#upgrading-to-025) | +| `0.15.x` | `0.26` | Every `0.16` to `0.26` section, and the [no-action table](#releases-that-need-no-action) | +| `0.14.x` | `0.26` | The `0.15` section as well | +| `0.12.x` or `0.13.x` | `0.26` | The `0.14` section as well | +| `0.11.x` or earlier | `0.26` | Every section | | `0.11.x` or earlier | `0.13` | [Upgrading to 0.12](#upgrading-to-012) only | Each section with code to change includes a copy/paste prompt for a coding agent. If you cross more than one boundary, run the prompts **newest first, one at a time**, and run your tests between them. @@ -68,6 +69,13 @@ The run catches every change a release announced with a `DeprecationWarning`. It If you are moving from `0.12` or earlier straight past `0.14`, the deprecated APIs are already removed there and fail with `AttributeError` or `RuntimeError`, not a warning, so the run above catches nothing. Run your suite on `0.13` first, or apply the prompts before you upgrade. +## 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: + +- `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. + ## Upgrading to 0.25 `0.25` adds [`ferro migrate squash`](reference/cli.md#ferro-migrate-squash-name-from-target), which asks nothing of you. It also writes two things `0.24` cannot read, which matters only while both versions share one migrations directory and one database, as in a rolling deploy: From 0c7f7e60b306b0aa5e01c66dbb5d8d2a0a0f4219 Mon Sep 17 00:00:00 2001 From: Taylor Date: Wed, 21 Oct 2026 14:26:32 -0400 Subject: [PATCH 7/8] docs(api): mark every docstring fence in src/ferro Nineteen Python fences in module and class docstrings carried no {test=...} marker. Each is now {test=compile reason="..."} with why it cannot run against the reference prelude, and the two that listed bare values (StatusReport's fields, FerroSettings' database) are written as code. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP --- src/ferro/base.py | 2 +- src/ferro/migrations/__init__.py | 2 +- src/ferro/migrations/_chunk_loop.py | 2 +- src/ferro/migrations/alembic.py | 2 +- src/ferro/migrations/context.py | 2 +- src/ferro/migrations/errors.py | 2 +- src/ferro/migrations/report.py | 8 ++++---- src/ferro/migrations/runner.py | 2 +- src/ferro/migrations/scaffold.py | 6 +++--- src/ferro/migrations/steps.py | 2 +- src/ferro/migrations/target.py | 2 +- src/ferro/migrations/testing.py | 4 ++-- src/ferro/migrations/translate.py | 4 ++-- src/ferro/rowsecurity.py | 2 +- src/ferro/settings.py | 8 ++++---- 15 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/ferro/base.py b/src/ferro/base.py index 4645f07d..0a9ce4d2 100644 --- a/src/ferro/base.py +++ b/src/ferro/base.py @@ -204,7 +204,7 @@ class ForeignKey: that `related_name` names. The models these examples run against declare the pair like this, other fields left out: - ```python + ```python {test=compile reason="the reference prelude already declares these models"} class Author(Model): ... posts: Relation[list["Post"]] = BackRef() diff --git a/src/ferro/migrations/__init__.py b/src/ferro/migrations/__init__.py index 5db38400..c5a2995d 100644 --- a/src/ferro/migrations/__init__.py +++ b/src/ferro/migrations/__init__.py @@ -9,7 +9,7 @@ decisions with prompts. A refusal is raised, never returned: an application that called `up()` must not go on serving a database the run did not reach. -```python +```python {test=compile reason="needs a project with a migrations directory"} await ferro.connect(url) await ferro.migrations.up() # a desktop app with no deploy step await ferro.migrations.require_applied() # a server that migrates in its deploy step diff --git a/src/ferro/migrations/_chunk_loop.py b/src/ferro/migrations/_chunk_loop.py index 47868659..db47f109 100644 --- a/src/ferro/migrations/_chunk_loop.py +++ b/src/ferro/migrations/_chunk_loop.py @@ -3,7 +3,7 @@ The public declaration is [`chunked`][ferro.migrations.chunked]; this private module runs it. -```python +```python {test=compile reason="a data step file; the migration runner calls it"} @chunked( lambda models: models.Author.where(lambda author: author.slug == None) .order_by(lambda author: author.id), diff --git a/src/ferro/migrations/alembic.py b/src/ferro/migrations/alembic.py index 3afcdb88..24cd8bde 100644 --- a/src/ferro/migrations/alembic.py +++ b/src/ferro/migrations/alembic.py @@ -1,6 +1,6 @@ """The Alembic bridge: Ferro's models as Alembic metadata, and one comparator that writes what the planner decides. -```python +```python {test=compile reason="an Alembic env.py fragment; Alembic runs it"} # env.py from ferro.migrations import ferro_options, get_metadata diff --git a/src/ferro/migrations/context.py b/src/ferro/migrations/context.py index c4e1f484..46b2958d 100644 --- a/src/ferro/migrations/context.py +++ b/src/ferro/migrations/context.py @@ -1,6 +1,6 @@ """What a data step is handed: `StepContext` and `HistoricalModels`. -```python +```python {test=compile reason="a data step body; the migration runner calls it"} @atomic async def up(ctx): for author in await ctx.models.Author.where(lambda author: author.slug == None).all(): diff --git a/src/ferro/migrations/errors.py b/src/ferro/migrations/errors.py index c502414b..4e2d99b2 100644 --- a/src/ferro/migrations/errors.py +++ b/src/ferro/migrations/errors.py @@ -1,6 +1,6 @@ """What the migration calls raise. -```python +```python {test=compile reason="needs a database behind its migrations directory"} await ferro.connect(url) await ferro.migrations.require_applied() # PendingMigrationsError: ferro.migrations: this database is behind its migrations. diff --git a/src/ferro/migrations/report.py b/src/ferro/migrations/report.py index c2324b74..7b6632f4 100644 --- a/src/ferro/migrations/report.py +++ b/src/ferro/migrations/report.py @@ -428,10 +428,10 @@ class StatusReport: For a database with `0006_add_teams` applied and `0007_nickname` part-way: - ```python - report.head_applied.name # "0006_add_teams" - report.unfinished.name # "0007_nickname" - report.pending # True + ```python {test=compile reason="needs a database with a migration part-way applied"} + assert report.head_applied.name == "0006_add_teams" + assert report.unfinished.name == "0007_nickname" + assert report.pending ``` """ diff --git a/src/ferro/migrations/runner.py b/src/ferro/migrations/runner.py index 138f3975..def620a8 100644 --- a/src/ferro/migrations/runner.py +++ b/src/ferro/migrations/runner.py @@ -6,7 +6,7 @@ 0002_add_teams 01_schema applied (9 ms) ``` -```python +```python {test=compile reason="needs a project with a migrations directory"} await ferro.migrations.up() # the same run, in-process await ferro.migrations.require_applied() # where it stands, raised when behind ``` diff --git a/src/ferro/migrations/scaffold.py b/src/ferro/migrations/scaffold.py index 2993f9b0..a0085a72 100644 --- a/src/ferro/migrations/scaffold.py +++ b/src/ferro/migrations/scaffold.py @@ -8,7 +8,7 @@ `ferro migrate new author_slug` writes it between the expand and the contract: -```python +```python {test=compile reason="a generated data step file; the migration runner calls it"} # migrations/0012_author_slug/02_backfill_author.py from ferro.migrations import chunked, nothing_to_reverse, todo @@ -39,7 +39,7 @@ def down(ctx): ... it, each given another label (the historical model's enum still declares the removed one, and the label written is looked up in it): -```python +```python {test=compile reason="a generated data step body; the migration runner calls it"} @chunked( lambda models: models.Order.where(lambda order: order.status == "canceled") .order_by(lambda order: order.id), @@ -58,7 +58,7 @@ async def up(ctx, batch): A hand-requested data step (`ferro migrate new backfill_slugs --data-step Author`): -```python +```python {test=compile reason="a generated data step file; the migration runner calls it"} from ferro.migrations import atomic, todo diff --git a/src/ferro/migrations/steps.py b/src/ferro/migrations/steps.py index dc949a80..4a0ced51 100644 --- a/src/ferro/migrations/steps.py +++ b/src/ferro/migrations/steps.py @@ -1,6 +1,6 @@ """Data step declarations, `todo()`, and the loader that reads a step file. -```python +```python {test=compile reason="a data step file; the migration runner calls it"} # migrations/0011_backfill_slugs/01_backfill_author.py from ferro.migrations import atomic, nothing_to_reverse, todo diff --git a/src/ferro/migrations/target.py b/src/ferro/migrations/target.py index c4ab74bf..cdbfab40 100644 --- a/src/ferro/migrations/target.py +++ b/src/ferro/migrations/target.py @@ -1,6 +1,6 @@ """The database a migration verb works on, and the connection that reaches it. -```python +```python {test=compile reason="needs a project with a migrations directory"} await ferro.migrations.up() # the configured database, default connection await ferro.migrations.up(database="analytics", using="reporting") await ferro.migrations.up(url="postgres://…/app") # a private connection, closed after diff --git a/src/ferro/migrations/testing.py b/src/ferro/migrations/testing.py index c33cd220..5b4446f6 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 + ```python {test=compile reason="shows the value round_trip returns"} RoundTripResult(applied=["0001_create_author", "0002_add_slug"], reverted_to=None, irreversible=None) ``` and for one whose `0002` declares a step irreversible: - ```python + ```python {test=compile reason="shows the value round_trip returns"} 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/migrations/translate.py b/src/ferro/migrations/translate.py index 95c19142..4f157d44 100644 --- a/src/ferro/migrations/translate.py +++ b/src/ferro/migrations/translate.py @@ -1,12 +1,12 @@ """The Alembic bridge's translator: the one planner's ops as Alembic ops. -```python +```python {test=compile reason="a model fragment; its imports are left out"} class Card(Model): flavor: str | None = None # new __ferro_checks__ = (Check("flavor_set", lambda card: card.flavor != None),) ``` -```python +```python {test=compile reason="the revision Alembic writes; Alembic runs it"} def upgrade(): op.add_column('card', sa.Column('flavor', sa.String(), nullable=True)) op.execute(sa.DDL('ALTER TABLE "card" ADD CONSTRAINT "ck_card_flavor_set" CHECK (...)')) diff --git a/src/ferro/rowsecurity.py b/src/ferro/rowsecurity.py index 61a577cc..d943cd9f 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 +```python {test=compile reason="declares a model whose row security needs Postgres"} class Invoice(Model): id: int | None = Field(default=None, primary_key=True) tenant_id: UUID diff --git a/src/ferro/settings.py b/src/ferro/settings.py index 4e57ff5f..ad1919ed 100644 --- a/src/ferro/settings.py +++ b/src/ferro/settings.py @@ -8,14 +8,14 @@ dialects = ["postgres", "sqlite"] ``` -```python +```python {test=compile reason="needs a project with a ferro.toml and its database URL set"} from ferro import FerroSettings settings = FerroSettings() db = settings.database() # implied: there is one -db.directory # /migrations -db.url_for() # $DATABASE_URL, never the file -db.import_models() # imports myapp.models, returns its models +directory = db.directory # /migrations +url = db.url_for() # $DATABASE_URL, never the file +models = db.import_models() # imports myapp.models, returns its models ``` The rules, each enforced here with a refusal that names its fix: From 88ae5e52d2fc8e851e27101c1cd93ede4a104931 Mon Sep 17 00:00:00 2001 From: Taylor Date: Wed, 21 Oct 2026 14:33:07 -0400 Subject: [PATCH 8/8] fix(session): name v0.14 and the upgrade guide in the no-route error Implicit default-connection routing was removed in v0.14.0 (a328cfc), and the sessions migration guide no longer exists, so the shipped text named the wrong release and a missing page. Use the accurate wording, pin it exactly, quote it on the Connections page, and correct that page's Changed note to 0.14. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP --- docs/pages/guide/connections.md | 4 ++-- src/ferro/state.py | 4 ++-- tests/test_routing_errors.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/pages/guide/connections.md b/docs/pages/guide/connections.md index 70607102..ba58e4b9 100644 --- a/docs/pages/guide/connections.md +++ b/docs/pages/guide/connections.md @@ -116,7 +116,7 @@ else: It prints: ```text -No database route for this operation. Open a session (`async with ferro.engines.session(...)`) or pass `using=`/`session=` explicitly. Implicit default-connection routing was removed in v0.13 (deprecated since v0.12); see the sessions migration guide. +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). @@ -184,7 +184,7 @@ Rules for closing a session: The [`Session`](../api/connection.md#ferro.Session) and [`engines`](../api/connection.md#ferro.engines) reference entries list every argument, including `settings=` for [row-level security](../concepts/row-level-security.md#session-settings). -!!! note "Changed in 0.13" +!!! note "Changed in 0.14" Calls outside a session used to run on the default connection; they now raise `RuntimeError`. The [upgrade guide](../upgrading.md#connection-routing-requires-a-session) shows how to move old call sites into sessions. ## Connection options diff --git a/src/ferro/state.py b/src/ferro/state.py index 79ccccd9..d19cab56 100644 --- a/src/ferro/state.py +++ b/src/ferro/state.py @@ -54,8 +54,8 @@ def _ensure_active_session(session: SessionLike | None) -> None: _NO_ROUTE_MESSAGE = ( "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.13 " - "(deprecated since v0.12); see the sessions migration guide." + "explicitly. Implicit default-connection routing was removed in v0.14 " + "(deprecated since v0.12); see the upgrade guide." ) diff --git a/tests/test_routing_errors.py b/tests/test_routing_errors.py index 5389bb23..65333297 100644 --- a/tests/test_routing_errors.py +++ b/tests/test_routing_errors.py @@ -23,8 +23,8 @@ class RtA(Model): NO_ROUTE_MESSAGE = ( "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.13 " - "(deprecated since v0.12); see the sessions migration guide." + "explicitly. Implicit default-connection routing was removed in v0.14 " + "(deprecated since v0.12); see the upgrade guide." )