Release v0.1.8

This commit is contained in:
2026-07-11 17:00:37 +02:00
parent a00ef54821
commit 9a0c467d55
102 changed files with 2150 additions and 9272 deletions

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from dataclasses import dataclass, replace
import json
import os
from pathlib import Path
@@ -41,6 +41,9 @@ CORE_SCOPE_TABLE = "core_scopes"
PRE_MIGRATION_TASK_PHASES = ("pre_migration_check", "pre_migration_prepare")
POST_MIGRATION_TASK_PHASES = ("post_migration_backfill", "post_migration_verify")
MIGRATION_TASK_PHASES = (*PRE_MIGRATION_TASK_PHASES, *POST_MIGRATION_TASK_PHASES)
MIGRATION_TRACK_RELEASE = "release"
MIGRATION_TRACK_DEV = "dev"
MIGRATION_TRACKS = (MIGRATION_TRACK_RELEASE, MIGRATION_TRACK_DEV)
_NAMESPACE_TABLE_RENAMES = (
("tenants", "tenancy_tenants"),
@@ -179,12 +182,14 @@ def registered_module_migration_plan(
*,
enabled_modules: tuple[str, ...] | list[str] | None = None,
manifest_factories: tuple[ManifestFactory, ...] = (),
migration_track: str | None = None,
) -> MigrationMetadataPlan:
return migration_metadata_plan(_registered_module_registry(
plan = migration_metadata_plan(_registered_module_registry(
database_url=database_url,
enabled_modules=enabled_modules,
manifest_factories=manifest_factories,
))
return _migration_plan_for_track(plan, _normalize_migration_track(migration_track))
def _registered_module_registry(
@@ -357,13 +362,76 @@ def _repo_root() -> Path:
return packaged_root
def _normalize_migration_track(value: str | None = None) -> str:
raw_value = value if value is not None else settings.migration_track
track = str(raw_value or MIGRATION_TRACK_RELEASE).strip().lower()
aliases = {
"baseline": MIGRATION_TRACK_RELEASE,
"prod": MIGRATION_TRACK_RELEASE,
"production": MIGRATION_TRACK_RELEASE,
"releases": MIGRATION_TRACK_RELEASE,
"detailed": MIGRATION_TRACK_DEV,
"development": MIGRATION_TRACK_DEV,
}
track = aliases.get(track, track)
if track not in MIGRATION_TRACKS:
raise ValueError(
"Unsupported migration track "
f"{raw_value!r}; expected one of: {', '.join(MIGRATION_TRACKS)}"
)
return track
def _core_version_location(root: Path, migration_track: str) -> Path:
if migration_track == MIGRATION_TRACK_DEV:
dev_versions = root / "alembic" / "dev_versions"
if dev_versions.is_dir():
return dev_versions
return root / "alembic" / "versions"
def _script_location_for_track(location: str | None, migration_track: str) -> str | None:
if not location or migration_track != MIGRATION_TRACK_DEV:
return location
path = Path(location)
if path.name != "versions":
return location
dev_location = path.with_name("dev_versions")
if dev_location.is_dir():
return str(dev_location)
return location
def _migration_plan_for_track(plan: MigrationMetadataPlan, migration_track: str) -> MigrationMetadataPlan:
if migration_track == MIGRATION_TRACK_RELEASE:
return plan
script_locations = tuple(
location
for location in (
_script_location_for_track(location, migration_track)
for location in plan.script_locations
)
if location
)
modules = tuple(
replace(
item,
script_location=_script_location_for_track(item.script_location, migration_track),
)
for item in plan.modules
)
return replace(plan, script_locations=script_locations, modules=modules)
def alembic_config(
*,
database_url: str | None = None,
enabled_modules: tuple[str, ...] | list[str] | None = None,
manifest_factories: tuple[ManifestFactory, ...] = (),
migration_track: str | None = None,
) -> Config:
root = _repo_root()
active_migration_track = _normalize_migration_track(migration_track)
config = Config(str(root / "alembic.ini"))
config.set_main_option("script_location", str(root / "alembic"))
@@ -372,13 +440,15 @@ def alembic_config(
active_database_url,
enabled_modules=enabled_modules,
manifest_factories=manifest_factories,
migration_track=active_migration_track,
)
version_locations = [str(root / "alembic" / "versions")]
version_locations = [str(_core_version_location(root, active_migration_track))]
version_locations.extend(location for location in plan.script_locations if location)
config.set_main_option("version_locations", os.pathsep.join(dict.fromkeys(version_locations)))
config.set_main_option("version_path_separator", "os")
config.set_main_option("path_separator", "os")
config.attributes["database_url"] = active_database_url
config.attributes["migration_track"] = active_migration_track
if enabled_modules is not None:
config.attributes["enabled_modules"] = tuple(enabled_modules)
if manifest_factories:
@@ -602,7 +672,67 @@ def reconcile_change_sequence_retention_floor_drift(database_url: str | None = N
return changed
def reconcile_legacy_create_all_schema(database_url: str | None = None) -> str | None:
def reconcile_covered_alembic_dependency_heads(
database_url: str | None = None,
*,
config: Config | None = None,
) -> tuple[str, ...]:
"""Remove stale parent/dependency rows from ``alembic_version``.
Alembic's version table represents current graph heads, not every owner
head that contributed to the schema. After the v0.1.7 baseline squash,
development databases may still carry dependency parents such as the core
baseline beside child module heads. That shape is semantically already
upgraded, but Alembic rejects it as an overlapping current revision set.
"""
url = database_url or settings.database_url
active_config = config or alembic_config(database_url=url)
script = ScriptDirectory.from_config(active_config)
engine = create_engine(url)
removed: set[str] = set()
try:
with engine.begin() as connection:
tables = set(inspect(connection).get_table_names())
if "alembic_version" not in tables:
return ()
current = {
str(row[0])
for row in connection.execute(text("SELECT version_num FROM alembic_version")).all()
if row[0]
}
if len(current) < 2:
return ()
covered: set[str] = set()
for revision_id in current:
try:
revision = script.get_revision(revision_id)
except Exception:
continue
ancestors = {
item.revision
for item in script.revision_map._get_ancestor_nodes( # noqa: SLF001 - Alembic has no public dependency-ancestor API.
[revision],
include_dependencies=True,
)
}
covered.update(ancestors - {revision_id})
removed = current.intersection(covered)
for revision_id in sorted(removed):
connection.execute(
text("DELETE FROM alembic_version WHERE version_num = :revision"),
{"revision": revision_id},
)
finally:
engine.dispose()
return tuple(sorted(removed))
def reconcile_legacy_create_all_schema(
database_url: str | None = None,
*,
migration_track: str | None = None,
) -> str | None:
"""Repair the known Alembic/create_all drift without modifying application data.
Returns the revision stamped during reconciliation, or ``None`` when no
@@ -659,7 +789,7 @@ def reconcile_legacy_create_all_schema(database_url: str | None = None) -> str |
if target is None:
return None
command.stamp(alembic_config(database_url=url), target)
command.stamp(alembic_config(database_url=url, migration_track=migration_track), target)
return target
@@ -670,23 +800,32 @@ def migrate_database(
enabled_modules: tuple[str, ...] | list[str] | None = None,
migration_module_order: tuple[str, ...] | list[str] | None = None,
manifest_factories: tuple[ManifestFactory, ...] = (),
migration_track: str | None = None,
) -> MigrationResult:
url = database_url or settings.database_url
active_migration_track = _normalize_migration_track(migration_track)
if reconcile_legacy_schema:
reconcile_namespace_table_drift(url)
reconcile_change_sequence_retention_floor_drift(url)
previous = database_revision(url)
reconciled = reconcile_legacy_create_all_schema(url) if reconcile_legacy_schema else None
reconciled = (
reconcile_legacy_create_all_schema(url, migration_track=active_migration_track)
if reconcile_legacy_schema
else None
)
config = alembic_config(
database_url=url,
enabled_modules=enabled_modules,
manifest_factories=manifest_factories,
migration_track=active_migration_track,
)
reconcile_covered_alembic_dependency_heads(url, config=config)
if migration_module_order:
plan = registered_module_migration_plan(
url,
enabled_modules=enabled_modules,
manifest_factories=manifest_factories,
migration_track=active_migration_track,
)
_upgrade_ordered_module_heads(config, plan, tuple(migration_module_order))
command.upgrade(config, "heads")