terp-migrations 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,47 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ .venv/
9
+ .venv-*/
10
+ venv/
11
+ .pytest_cache/
12
+ .mypy_cache/
13
+ .ruff_cache/
14
+ .coverage
15
+ htmlcov/
16
+
17
+ # uv
18
+ uv.lock
19
+
20
+ # Node
21
+ node_modules/
22
+ .pnpm-store/
23
+ *.tsbuildinfo
24
+
25
+ # Playwright (conformance e2e) artifacts
26
+ test-results/
27
+ playwright-report/
28
+ blob-report/
29
+ playwright/.cache/
30
+ .last-run.json
31
+
32
+ # Local frontend template render checks
33
+ apps/example/_frontend_tpl_check/
34
+
35
+ # Editor / OS
36
+ .DS_Store
37
+ .idea/
38
+ *.local
39
+
40
+ # Local environment overrides — never commit (a real .env may hold SECRET_KEY).
41
+ # The tracked template is `.env.example`.
42
+ .env
43
+ .env.*
44
+ !.env.example
45
+ !.env.example.jinja
46
+ # Rendered app-declared variables (environment.schema.json) — may hold secrets.
47
+ .app.env
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: terp-migrations
3
+ Version: 0.1.0
4
+ Summary: Terp migrations — Alembic integration for independent per-package histories (terp migrate).
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.13
7
+ Requires-Dist: alembic>=1.13
8
+ Requires-Dist: terp-core==0.1.0
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "terp-migrations"
7
+ version = "0.1.0"
8
+ description = "Terp migrations — Alembic integration for independent per-package histories (terp migrate)."
9
+ requires-python = ">=3.13"
10
+ license = "Apache-2.0"
11
+ dependencies = [
12
+ "terp-core==0.1.0",
13
+ "alembic>=1.13",
14
+ ]
15
+
16
+ # A standalone entry point as well as the unified `terp migrate` subcommand
17
+ # (terp-cli delegates here), so the migration tooling is usable on its own.
18
+ [project.scripts]
19
+ terp-migrate = "terp.migrations.cli:migrate_main"
20
+
21
+ # PEP 420 namespace package: this distribution owns only `terp.migrations`.
22
+ # `only-include` ships the package tree, including the shared `_alembic/` env +
23
+ # script template (env.py / script.py.mako) consumed by Alembic at runtime.
24
+ [tool.hatch.build.targets.wheel]
25
+ sources = ["src"]
26
+ only-include = ["src/terp/migrations"]
@@ -0,0 +1,70 @@
1
+ """terp.migrations — Alembic integration for independent per-package histories.
2
+
3
+ Each table-owning package (capability or app module) owns a **linear** Alembic
4
+ history with its own ``alembic_version_<label>`` table; ``terp migrate`` discovers
5
+ and orchestrates them through the pure :mod:`terp.core.migrations` seam (ADR 0027).
6
+ There is no shared multi-branch graph, so packages never branch across one another; a
7
+ *within*-package divergence (two developers off one head) is the only kind, surfaced
8
+ by ``terp migrate heads`` and resolved by ``terp migrate merge``. The fail-closed
9
+ :func:`assert_migrations_current` boot guard (wired via
10
+ ``create_app(migration_check=...)``) makes "the consumer must migrate for a new
11
+ version" an enforced guarantee, not a hope.
12
+
13
+ The kernel (:mod:`terp.core`) never imports this package, keeping Alembic out of the
14
+ layer-0 boundary.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from terp.migrations._runtime import unmapped_tables, unowned_tables
20
+ from terp.migrations.cli import migrate_main
21
+ from terp.migrations.errors import (
22
+ MigrationDriftError,
23
+ MigrationError,
24
+ MissingMigrationsError,
25
+ PendingMigrationsError,
26
+ )
27
+ from terp.migrations.guard import (
28
+ assert_migrations_current,
29
+ assert_migrations_match_models,
30
+ assert_no_missing_histories,
31
+ )
32
+ from terp.migrations.orchestrate import (
33
+ MigrationStatus,
34
+ adopt_schemas,
35
+ downgrade,
36
+ ensure_database_search_path,
37
+ grant_runtime_role,
38
+ heads,
39
+ make,
40
+ merge_heads,
41
+ migration_status,
42
+ stamp,
43
+ upgrade,
44
+ upgrade_sql,
45
+ )
46
+
47
+ __all__ = [
48
+ "MigrationDriftError",
49
+ "MigrationError",
50
+ "MigrationStatus",
51
+ "MissingMigrationsError",
52
+ "PendingMigrationsError",
53
+ "adopt_schemas",
54
+ "assert_migrations_current",
55
+ "assert_migrations_match_models",
56
+ "assert_no_missing_histories",
57
+ "downgrade",
58
+ "ensure_database_search_path",
59
+ "grant_runtime_role",
60
+ "heads",
61
+ "make",
62
+ "merge_heads",
63
+ "migrate_main",
64
+ "migration_status",
65
+ "stamp",
66
+ "unmapped_tables",
67
+ "unowned_tables",
68
+ "upgrade",
69
+ "upgrade_sql",
70
+ ]
@@ -0,0 +1,16 @@
1
+ """Terp-owned Alembic environment, shared by every package's history.
2
+
3
+ Thin shim by design: the per-run logic lives in the covered
4
+ ``terp.migrations._runtime`` module, parameterized by the ``terp_import_path`` /
5
+ ``terp_version_table`` config options ``terp migrate`` sets. Migrating a package
6
+ isolates its history in its own ``alembic_version_<label>`` table and scopes
7
+ autogenerate to that package's tables.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from alembic import context
13
+
14
+ from terp.migrations._runtime import run_migrations
15
+
16
+ run_migrations(context)
@@ -0,0 +1,29 @@
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Sequence
11
+
12
+ from alembic import op
13
+ import sqlalchemy as sa
14
+ import sqlmodel
15
+ ${imports if imports else ""}
16
+
17
+ # revision identifiers, used by Alembic.
18
+ revision: str = ${repr(up_revision)}
19
+ down_revision: str | None = ${repr(down_revision)}
20
+ branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
21
+ depends_on: str | Sequence[str] | None = ${repr(depends_on)}
22
+
23
+
24
+ def upgrade() -> None:
25
+ ${upgrades if upgrades else "pass"}
26
+
27
+
28
+ def downgrade() -> None:
29
+ ${downgrades if downgrades else "pass"}
@@ -0,0 +1,60 @@
1
+ """Build an Alembic ``Config`` for a single package's independent history.
2
+
3
+ Every package history shares **one** Terp-owned Alembic environment (the
4
+ ``_alembic`` directory holding ``env.py`` + ``script.py.mako``) but targets its own
5
+ ``versions/`` directory and its own ``alembic_version_<label>`` table, so the
6
+ histories never share state. The owning package and version table are passed as
7
+ custom config options the shared ``env.py`` reads at runtime.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import pathlib
13
+
14
+ from alembic.config import Config
15
+
16
+ from terp.core import get_settings
17
+ from terp.core.migrations import MigrationTree
18
+
19
+ _ALEMBIC_DIR = pathlib.Path(__file__).resolve().parent / "_alembic"
20
+
21
+
22
+ def alembic_config_for(
23
+ tree: MigrationTree,
24
+ database_url: str,
25
+ *,
26
+ app_root: str | pathlib.Path | None = None,
27
+ package: str = "app",
28
+ schema_layout: str | None = None,
29
+ ) -> Config:
30
+ """An Alembic ``Config`` scoped to *tree* against *database_url*.
31
+
32
+ ``path_separator = newline`` makes the single ``version_locations`` path
33
+ robust to spaces and Windows drive colons (a lone path never contains a
34
+ newline), so discovery-resolved absolute paths always parse as one location.
35
+
36
+ *app_root* / *package* are forwarded to the shared ``env.py`` so it can discover
37
+ and import every package's models (cross-package foreign keys resolve during
38
+ autogenerate); they are empty for the capability-only path. *schema_layout*
39
+ (default: ``settings.DB_SCHEMA_LAYOUT``) selects the physical table layout the
40
+ env applies per run (ADR 0070) — ``flat`` is a no-op; ``per-module`` routes this
41
+ package's DDL into its own PostgreSQL schema.
42
+ """
43
+ config = Config()
44
+ config.set_main_option("script_location", str(_ALEMBIC_DIR))
45
+ config.set_main_option("version_locations", str(tree.versions_path))
46
+ config.set_main_option("path_separator", "newline")
47
+ config.set_main_option("sqlalchemy.url", database_url)
48
+ config.set_main_option("terp_import_path", tree.import_path)
49
+ config.set_main_option("terp_version_table", tree.version_table)
50
+ config.set_main_option("terp_label", tree.label)
51
+ config.set_main_option("terp_app_root", "" if app_root is None else str(app_root))
52
+ config.set_main_option("terp_package", package)
53
+ config.set_main_option(
54
+ "terp_schema_layout",
55
+ schema_layout if schema_layout is not None else get_settings().DB_SCHEMA_LAYOUT,
56
+ )
57
+ return config
58
+
59
+
60
+ __all__ = ["alembic_config_for"]
@@ -0,0 +1,418 @@
1
+ """The Alembic ``env.py`` delegate (covered logic behind the thin shim).
2
+
3
+ ``_alembic/env.py`` is intentionally three lines that call :func:`run_migrations`;
4
+ the real per-run logic lives here so it is exercised (and line-covered) by the
5
+ migration test suite rather than hidden inside an Alembic-exec'd script.
6
+
7
+ Each run targets exactly one package: its models are imported to register their
8
+ tables, its ``alembic_version_<label>`` table isolates its history, and
9
+ autogenerate is scoped to the tables that package *owns* (a table whose mapped
10
+ class lives under the package's import path), so one package's ``make`` never
11
+ proposes another package's tables.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import importlib
17
+ from collections.abc import Callable, Iterable
18
+ from typing import Any
19
+
20
+ from sqlalchemy import MetaData, create_engine, pool
21
+ from sqlmodel import SQLModel
22
+
23
+ from terp.core.migrations import MigrationTree, resolve_all_migration_trees
24
+ from terp.migrations.errors import MigrationError
25
+
26
+
27
+ def owned_table_names(import_path: str) -> frozenset[str]:
28
+ """Table names whose mapped class lives under *import_path* (ownership scope)."""
29
+ prefix = f"{import_path}."
30
+ owned: set[str] = set()
31
+ for mapper in SQLModel._sa_registry.mappers:
32
+ module = getattr(mapper.class_, "__module__", "")
33
+ if module == import_path or module.startswith(prefix):
34
+ owned.add(mapper.local_table.name)
35
+ return frozenset(owned)
36
+
37
+
38
+ def scoped_filters(
39
+ owned: frozenset[str],
40
+ ) -> tuple[Callable[..., bool], Callable[..., bool]]:
41
+ """Alembic ``include_name`` / ``include_object`` limiting tables to *owned*.
42
+
43
+ Non-table objects (columns, indexes, constraints) are always included — they
44
+ belong to an owned table that already passed the table filter — while a table
45
+ outside the owning package is excluded from both reflection and metadata
46
+ comparison, keeping autogenerate scoped to this package.
47
+ """
48
+
49
+ def include_name(name: str | None, type_: str, parent_names: Any) -> bool:
50
+ if type_ == "table":
51
+ return name in owned
52
+ return True
53
+
54
+ def include_object(
55
+ obj: Any, name: str | None, type_: str, reflected: bool, compare_to: Any
56
+ ) -> bool:
57
+ if type_ == "table":
58
+ return name in owned
59
+ return True
60
+
61
+ return include_name, include_object
62
+
63
+
64
+ def _render_as_batch(connection: Any) -> bool:
65
+ """Batch ALTER is a SQLite workaround; native dialects ALTER directly (ADR 0027).
66
+
67
+ Rendering every change as ``op.batch_alter_table`` is required only for SQLite
68
+ (which cannot ``ALTER`` in place); on Postgres / MySQL it is needless noise and a
69
+ latent footgun — an op that trips a batch *recreate* becomes a destructive
70
+ copy-and-swap of the whole table — so batch mode is gated to the SQLite dialect.
71
+ """
72
+ return connection.dialect.name == "sqlite"
73
+
74
+
75
+ def _model_modules(import_path: str, app_root: str | None, package: str) -> list[str]:
76
+ """The target's models module plus every model-bearing declared package's.
77
+
78
+ Autogenerate compares ``SQLModel.metadata``, so a cross-package / cross-module
79
+ foreign key only resolves when the *target* table is registered too. Importing
80
+ every discovered package's models populates the shared metadata (the FK targets
81
+ resolve) while ``include_name`` / ``include_object`` still scope the *emitted*
82
+ tables to the package being migrated — the independent per-package history is
83
+ unchanged, only FK resolution is fixed. The *all-trees* discovery is used (not the
84
+ runnable set) so a referenced module that has authored no revision yet — e.g. a
85
+ sibling targeted by this package's very first migration — is still imported, so its
86
+ table resolves as a foreign-key target. A route-only/support module with no
87
+ ``models.py`` is skipped; only the target package itself must define models.
88
+ """
89
+ target_module = f"{import_path}.models"
90
+ modules = {target_module}
91
+ for tree in resolve_all_migration_trees(app_root, package=package):
92
+ if tree.models_module == target_module or _tree_has_models(tree):
93
+ modules.add(tree.models_module)
94
+ return sorted(modules)
95
+
96
+
97
+ def _tree_has_models(tree: MigrationTree) -> bool:
98
+ """True when a discovered package actually ships a models module/package."""
99
+ package_dir = tree.path.parent
100
+ return (package_dir / "models.py").is_file() or (package_dir / "models").is_dir()
101
+
102
+
103
+ def _import_model_module(module: str, *, required: bool) -> bool:
104
+ """Import *module*, optionally treating a missing module as an absent table owner.
105
+
106
+ Table-owning migration targets must define ``models.py`` so autogenerate has a
107
+ source of truth. Other discovered modules are imported only when present: a
108
+ route-only/support module with no ``models.py`` is not a migration participant.
109
+ Import errors raised *inside* an existing models module still propagate loudly.
110
+ """
111
+ try:
112
+ importlib.import_module(module)
113
+ except ModuleNotFoundError as exc:
114
+ missing = exc.name or ""
115
+ if missing == module or module.startswith(f"{missing}."):
116
+ if required:
117
+ package = module.removesuffix(".models")
118
+ raise MigrationError(
119
+ f"migration target {package!r} has no importable models module "
120
+ f"({module}); table-owning packages must define models.py"
121
+ ) from exc
122
+ return False
123
+ raise
124
+ return True
125
+
126
+
127
+ def unowned_tables(import_paths: Iterable[str]) -> frozenset[str]:
128
+ """Mapped tables owned by none of *import_paths* (a homeless table, surfaced loudly).
129
+
130
+ A table whose mapped class lives under no migration-owning package — most often a
131
+ bare SQLAlchemy-core association ``Table`` (which has no mapper) or a model in a
132
+ shared base module — is silently skipped by every package's scoped autogenerate, so
133
+ it would never be created. A consumer's test can compare against the full discovered
134
+ set (see :func:`terp.migrations.assert_migrations_match_models`) to catch it.
135
+ """
136
+ owned: set[str] = set()
137
+ for path in import_paths:
138
+ owned |= owned_table_names(path)
139
+ return frozenset(SQLModel.metadata.tables) - owned
140
+
141
+
142
+ def _mapped_table_names() -> frozenset[str]:
143
+ """Names of every table backed by a SQLModel mapped class in the shared registry."""
144
+ return frozenset(mapper.local_table.name for mapper in SQLModel._sa_registry.mappers)
145
+
146
+
147
+ def unmapped_tables() -> frozenset[str]:
148
+ """Tables in the shared metadata with no mapped class (a bare association ``Table``).
149
+
150
+ A bare SQLAlchemy-core ``Table`` has no mapper, so no package owns it and every
151
+ package's scoped autogenerate skips it silently — it would never be created.
152
+ ``make`` fails closed on these so the omission is loud, not silent.
153
+ """
154
+ return frozenset(SQLModel.metadata.tables) - _mapped_table_names()
155
+
156
+
157
+ def _references_any(table: Any, owned: set[str]) -> bool:
158
+ """True if *table* has a foreign key into one of the *owned* tables."""
159
+ return any(fk.column.table.name in owned for fk in table.foreign_keys)
160
+
161
+
162
+ def _homeless_tables(
163
+ tables: Any, owned: set[str], mapped: frozenset[str]
164
+ ) -> tuple[list[str], list[str]]:
165
+ """Split unowned-but-FK-connected tables into ``(bare, mapped_unowned)``.
166
+
167
+ A table owned by no migration package is "homeless" — every package's scoped
168
+ autogenerate skips it, so it would never be created — *when it is wired into the
169
+ schema by a foreign key into or out of an owned table*. That FK-connection test is
170
+ what keeps the check scoped: an unrelated table polluting the shared
171
+ ``SQLModel.metadata`` (no foreign key to an owned table) is ignored, so the guard
172
+ never false-positives. The connected homeless tables are returned split by whether a
173
+ mapped class backs them, because the remedy differs — a bare ``Table`` should become
174
+ a SQLModel link-model, while a mapped-but-unowned class should move under (or
175
+ declare) a migration-owning package.
176
+ """
177
+ referenced_by_owned: set[str] = set()
178
+ for name in owned:
179
+ table = tables.get(name)
180
+ if table is not None:
181
+ referenced_by_owned.update(fk.column.table.name for fk in table.foreign_keys)
182
+ bare: list[str] = []
183
+ mapped_unowned: list[str] = []
184
+ for name, table in tables.items():
185
+ if name in owned:
186
+ continue
187
+ if name not in referenced_by_owned and not _references_any(table, owned):
188
+ continue
189
+ (mapped_unowned if name in mapped else bare).append(name)
190
+ return sorted(bare), sorted(mapped_unowned)
191
+
192
+
193
+ def assert_no_homeless_tables(
194
+ tree: MigrationTree, app_root: str | None, package: str
195
+ ) -> None:
196
+ """Fail closed if a table wired into the schema is owned by no migration package.
197
+
198
+ Imports the target package's models plus every *declared* package's (runnable or
199
+ not — a referenced module may have no revision yet), then raises if a table that no
200
+ package owns is wired into an owned table by a foreign key. Two kinds slip through a
201
+ package's scoped autogenerate and would never be created:
202
+
203
+ * a bare association ``Table`` (no mapped class, so no package owns it), and
204
+ * a *mapped* SQLModel class whose module lives outside every discovered package's
205
+ import prefix (e.g. a shared base module that ships no migration history).
206
+
207
+ Each is reported with its own remedy. Unrelated tables (no foreign key into or out
208
+ of an owned table) are ignored, so the check never false-positives on a test fixture
209
+ or an externally-managed table polluting the shared metadata.
210
+ """
211
+ trees = resolve_all_migration_trees(app_root, package=package)
212
+ _import_model_module(tree.models_module, required=True)
213
+ for other in trees:
214
+ if other.models_module != tree.models_module and _tree_has_models(other):
215
+ _import_model_module(other.models_module, required=False)
216
+ owned: set[str] = set(owned_table_names(tree.import_path))
217
+ for other in trees:
218
+ owned |= owned_table_names(other.import_path)
219
+ bare, mapped_unowned = _homeless_tables(
220
+ SQLModel.metadata.tables, owned, _mapped_table_names()
221
+ )
222
+ if bare:
223
+ raise MigrationError(
224
+ f"these tables have no mapped model yet are wired into the schema by a "
225
+ f"foreign key, so no package owns them and autogenerate would silently skip "
226
+ f"them: {bare}; define each as a SQLModel link-model class (table=True)"
227
+ )
228
+ if mapped_unowned:
229
+ raise MigrationError(
230
+ f"these tables are mapped but no migration package owns them (their model "
231
+ f"lives outside every discovered package's import path) yet they are wired "
232
+ f"into the schema by a foreign key, so autogenerate would silently skip "
233
+ f"them: {mapped_unowned}; move each model under a migration-owning package, "
234
+ f"or give its package a terp.migrations entry point or a migrations/ directory"
235
+ )
236
+
237
+
238
+ def _dependency_edges(
239
+ metadata: MetaData, label_of_table: dict[str, str]
240
+ ) -> dict[str, set[str]]:
241
+ """Map each package label to the labels its tables' foreign keys reference."""
242
+ deps: dict[str, set[str]] = {}
243
+ for table in metadata.tables.values():
244
+ owner = label_of_table.get(table.name)
245
+ if owner is None:
246
+ continue
247
+ edges = deps.setdefault(owner, set())
248
+ for foreign_key in table.foreign_keys:
249
+ target = label_of_table.get(foreign_key.column.table.name)
250
+ if target is not None and target != owner:
251
+ edges.add(target)
252
+ return deps
253
+
254
+
255
+ def _toposort(
256
+ trees: list[MigrationTree], deps: dict[str, set[str]]
257
+ ) -> list[MigrationTree]:
258
+ """Order *trees* so a package precedes any it references (FK-dependency order).
259
+
260
+ Kahn's algorithm with the input order as a deterministic tie-break, so without
261
+ cross-package foreign keys the order is unchanged (capabilities first, then app
262
+ modules, each alphabetical). A cross-package FK cycle cannot be linearised, so it
263
+ fails closed rather than produce an order that breaks at create time.
264
+ """
265
+ order_index = {tree.label: position for position, tree in enumerate(trees)}
266
+ remaining = list(trees)
267
+ placed: set[str] = set()
268
+ ordered: list[MigrationTree] = []
269
+ while remaining:
270
+ ready = [tree for tree in remaining if deps.get(tree.label, set()) <= placed]
271
+ if not ready:
272
+ cycle = sorted(tree.label for tree in remaining)
273
+ raise MigrationError(
274
+ f"cross-package foreign-key cycle among {cycle}; break it (e.g. a "
275
+ "nullable FK populated in a later migration) so the histories can order"
276
+ )
277
+ chosen = min(ready, key=lambda tree: order_index[tree.label])
278
+ ordered.append(chosen)
279
+ placed.add(chosen.label)
280
+ remaining.remove(chosen)
281
+ return ordered
282
+
283
+
284
+ def order_trees_by_dependencies(trees: list[MigrationTree]) -> list[MigrationTree]:
285
+ """Order discovered *trees* so a referenced package migrates before a referencing one.
286
+
287
+ Capabilities are FK-less leaves, so this is identity for them; it matters for a
288
+ consumer whose app modules carry cross-module foreign keys, making ``upgrade``
289
+ create the referenced table first regardless of label ordering (``downgrade``
290
+ reverses it). Reads the shared metadata, so it imports each package's models first.
291
+ """
292
+ for tree in trees:
293
+ importlib.import_module(tree.models_module)
294
+ label_of_table: dict[str, str] = {}
295
+ for tree in trees:
296
+ for name in owned_table_names(tree.import_path):
297
+ label_of_table[name] = tree.label
298
+ deps = _dependency_edges(SQLModel.metadata, label_of_table)
299
+ return _toposort(trees, deps)
300
+
301
+
302
+ def run_migrations(context: Any) -> None:
303
+ """Run the migrations for the package named by the active Alembic *context*."""
304
+ config = context.config
305
+ import_path = config.get_main_option("terp_import_path")
306
+ version_table = config.get_main_option("terp_version_table")
307
+ label = config.get_main_option("terp_label")
308
+ database_url = config.get_main_option("sqlalchemy.url")
309
+ app_root = config.get_main_option("terp_app_root") or None
310
+ package = config.get_main_option("terp_package") or "app"
311
+ schema_layout = config.get_main_option("terp_schema_layout") or "flat"
312
+
313
+ target_module = f"{import_path}.models"
314
+ for module in _model_modules(import_path, app_root, package):
315
+ _import_model_module(module, required=module == target_module)
316
+ include_name, include_object = scoped_filters(owned_table_names(import_path))
317
+
318
+ if context.is_offline_mode():
319
+ # Offline (--sql) rendering: no engine, no connection — Alembic renders the
320
+ # DDL (and the version-table bookkeeping) against the URL's dialect only.
321
+ # The per-module layout rides *session* state (search_path) that a static
322
+ # script cannot carry faithfully, so it fails closed here (ADR 0072).
323
+ if schema_layout == "per-module":
324
+ raise MigrationError(
325
+ "offline SQL (--sql) supports the flat layout only; a per-module "
326
+ "database migrates online so the layout's session state routes "
327
+ "each package's DDL (ADR 0072)"
328
+ )
329
+ context.configure(
330
+ url=database_url,
331
+ target_metadata=SQLModel.metadata,
332
+ version_table=version_table,
333
+ include_name=include_name,
334
+ include_object=include_object,
335
+ compare_type=True,
336
+ literal_binds=True,
337
+ )
338
+ with context.begin_transaction():
339
+ context.run_migrations()
340
+ return
341
+
342
+ connectable = create_engine(database_url, poolclass=pool.NullPool)
343
+ try:
344
+ with connectable.connect() as connection:
345
+ configure_opts: dict[str, Any] = {}
346
+ if schema_layout == "per-module":
347
+ labels = [
348
+ tree.label for tree in resolve_all_migration_trees(app_root, package=package)
349
+ ]
350
+ _enter_per_module_schema(connection, label, labels)
351
+ # The version table stays in the default schema so status / the boot
352
+ # guard read it over a plain connection, layout-unaware (ADR 0070).
353
+ configure_opts["version_table_schema"] = "public"
354
+ context.configure(
355
+ connection=connection,
356
+ target_metadata=SQLModel.metadata,
357
+ version_table=version_table,
358
+ include_name=include_name,
359
+ include_object=include_object,
360
+ compare_type=True,
361
+ render_as_batch=_render_as_batch(connection),
362
+ **configure_opts,
363
+ )
364
+ with context.begin_transaction():
365
+ context.run_migrations()
366
+ finally:
367
+ connectable.dispose()
368
+
369
+
370
+ def search_path_statement(own_label: str, labels: Iterable[str]) -> str:
371
+ """The session ``SET search_path`` for one package's migration run (ADR 0070).
372
+
373
+ The owning package's schema comes **first** — PostgreSQL creates unqualified
374
+ tables in the first search_path entry, which is exactly how a revision written
375
+ without any schema token lands in its package's schema. Every other package's
376
+ schema follows so a cross-module foreign key's unqualified target still
377
+ resolves, and ``public`` stays last for anything shared (extensions, the
378
+ pinned ``alembic_version_*`` tables).
379
+ """
380
+ ordered = [own_label, *[item for item in labels if item != own_label], "public"]
381
+ joined = ", ".join(f'"{name}"' for name in dict.fromkeys(ordered))
382
+ return f"SET search_path TO {joined}"
383
+
384
+
385
+ def _enter_per_module_schema(connection: Any, label: str, labels: Iterable[str]) -> None:
386
+ """Route this run's DDL into the owning package's PostgreSQL schema.
387
+
388
+ The documented Alembic recipe for schema-level separation: create the schema,
389
+ point the session ``search_path`` at it (own schema first), and pin the
390
+ dialect's ``default_schema_name`` so autogenerate reflects the package's tables
391
+ as schema-less — keeping revisions token-free and the drift check meaningful
392
+ under the layout. Fails closed on any non-PostgreSQL dialect: schemas are a
393
+ PostgreSQL feature, and silently running flat would desynchronize the layout.
394
+ """
395
+ if connection.dialect.name != "postgresql":
396
+ raise MigrationError(
397
+ "schema layout 'per-module' requires PostgreSQL; "
398
+ f"got dialect {connection.dialect.name!r} (ADR 0070)"
399
+ )
400
+ connection.exec_driver_sql(f'CREATE SCHEMA IF NOT EXISTS "{label}"')
401
+ connection.exec_driver_sql(search_path_statement(label, labels))
402
+ # Commit the autobegun setup transaction: Alembic treats a connection with an
403
+ # in-progress transaction as caller-managed and would never commit the migration
404
+ # DDL (a silent full rollback). The session-level search_path survives the commit.
405
+ connection.commit()
406
+ connection.dialect.default_schema_name = label
407
+
408
+
409
+ __all__ = [
410
+ "assert_no_homeless_tables",
411
+ "order_trees_by_dependencies",
412
+ "owned_table_names",
413
+ "run_migrations",
414
+ "scoped_filters",
415
+ "search_path_statement",
416
+ "unmapped_tables",
417
+ "unowned_tables",
418
+ ]