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.
- terp_migrations-0.1.0/.gitignore +47 -0
- terp_migrations-0.1.0/PKG-INFO +8 -0
- terp_migrations-0.1.0/pyproject.toml +26 -0
- terp_migrations-0.1.0/src/terp/migrations/__init__.py +70 -0
- terp_migrations-0.1.0/src/terp/migrations/_alembic/env.py +16 -0
- terp_migrations-0.1.0/src/terp/migrations/_alembic/script.py.mako +29 -0
- terp_migrations-0.1.0/src/terp/migrations/_config.py +60 -0
- terp_migrations-0.1.0/src/terp/migrations/_runtime.py +418 -0
- terp_migrations-0.1.0/src/terp/migrations/cli.py +302 -0
- terp_migrations-0.1.0/src/terp/migrations/errors.py +75 -0
- terp_migrations-0.1.0/src/terp/migrations/guard.py +146 -0
- terp_migrations-0.1.0/src/terp/migrations/orchestrate.py +580 -0
- terp_migrations-0.1.0/src/terp/migrations/py.typed +0 -0
|
@@ -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
|
+
]
|