Files
govoplan-core/src/govoplan_core/db/migrations.py
Albrecht Degering a00ef54821
All checks were successful
Dependency Audit / dependency-audit (push) Successful in 1m35s
Release v0.1.7
2026-07-11 03:11:06 +02:00

741 lines
30 KiB
Python

from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
import json
import os
from pathlib import Path
from typing import Any
from alembic import command
from alembic.config import Config
from alembic.runtime.migration import MigrationContext
from alembic.script import ScriptDirectory
from sqlalchemy import create_engine, inspect, text
from govoplan_core.core.migrations import MigrationMetadataPlan, migration_metadata_plan
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.core.modules import ModuleMigrationTaskContext, ModuleMigrationTaskResult
from govoplan_core.db.session import configure_database, get_database
from govoplan_core.server.default_config import get_server_config
from govoplan_core.server.config import ManifestFactory
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
from govoplan_core.settings import settings
# Historic development databases could be created partly through Alembic and
# partly through Base.metadata.create_all(). In that state Alembic still says
# "2c..." while the 3d/4e file-storage tables already exist, so a normal
# upgrade attempts to create file_blobs again. This reconciliation is kept
# deliberately narrow and only advances the marker when the complete expected
# schema for the skipped revisions is already present.
REVISION_AUTH_RBAC = "2c3d4e5f6a7b"
REVISION_FILE_STORAGE = "3d4e5f6a7b8c"
REVISION_FILE_FOLDERS = "4e5f6a7b8c9d"
REVISION_NAMESPACE_PLATFORM_TABLES = "2e3f4a5b6c7d"
REVISION_CORE_CHANGE_SEQUENCE = "3f4a5b6c7d8e"
REVISION_HIERARCHICAL_SETTINGS = "f5a6b7c8d9e0"
LEGACY_SCOPE_TABLE = "tenancy_tenants"
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)
_NAMESPACE_TABLE_RENAMES = (
("tenants", "tenancy_tenants"),
("accounts", "access_accounts"),
("users", "access_users"),
("groups", "access_groups"),
("roles", "access_roles"),
("system_role_assignments", "access_system_role_assignments"),
("user_group_memberships", "access_user_group_memberships"),
("user_role_assignments", "access_user_role_assignments"),
("group_role_assignments", "access_group_role_assignments"),
("api_keys", "access_api_keys"),
("auth_sessions", "access_auth_sessions"),
("system_settings", "core_system_settings"),
("governance_templates", "admin_governance_templates"),
("governance_template_assignments", "admin_governance_template_assignments"),
)
_FILE_STORAGE_TABLES = {
"file_blobs",
"file_assets",
"file_versions",
"file_shares",
"campaign_attachment_uses",
}
_FILE_FOLDER_TABLES = {"file_folders"}
_FILE_STORAGE_COLUMNS = {
"file_blobs": {
"id",
"tenant_id",
"storage_backend",
"storage_key",
"checksum_sha256",
"size_bytes",
},
"file_assets": {
"id",
"tenant_id",
"owner_type",
"display_path",
"filename",
"current_version_id",
},
"file_versions": {
"id",
"file_asset_id",
"blob_id",
"version_number",
"checksum_sha256",
},
"file_shares": {"id", "file_asset_id", "target_type", "target_id", "permission"},
"campaign_attachment_uses": {
"id",
"campaign_id",
"campaign_version_id",
"file_asset_id",
"file_version_id",
"file_blob_id",
},
"file_folders": {"id", "tenant_id", "owner_type", "path"},
}
_CREATE_ALL_THROUGH_HIERARCHICAL_TABLES = {
"access_accounts",
"access_auth_sessions",
"audit_log",
"campaign_versions",
"campaign_jobs",
"send_attempts",
"access_system_role_assignments",
"core_system_settings",
"admin_governance_templates",
"admin_governance_template_assignments",
"mail_server_profiles",
}
_CREATE_ALL_THROUGH_HIERARCHICAL_COLUMNS = {
"access_auth_sessions": {"account_id", "csrf_token_hash"},
"audit_log": {"scope", "tenant_id", "user_id", "api_key_id"},
"campaign_versions": {
"workflow_state",
"current_flow",
"user_lock_state",
"user_locked_at",
"user_locked_by_user_id",
"execution_snapshot",
"execution_snapshot_hash",
"execution_snapshot_at",
},
"campaign_jobs": {"claimed_at", "claim_token", "smtp_started_at", "outcome_unknown_at", "eml_sha256"},
"send_attempts": {"status", "claim_token"},
"access_users": {"account_id", "settings", "mail_profile_policy"},
"access_groups": {"system_template_id", "system_required", "settings", "mail_profile_policy"},
"access_roles": {"system_template_id", "system_required"},
"campaigns": {"settings", "mail_profile_policy"},
"mail_server_profiles": {"scope_type", "scope_id"},
"core_system_settings": {"settings", "allow_tenant_custom_groups", "allow_tenant_custom_roles", "allow_tenant_api_keys"},
}
_SCOPE_TABLE_COLUMNS = {"settings", "allow_custom_groups", "allow_custom_roles", "allow_api_keys"}
_CREATE_ALL_THROUGH_HIERARCHICAL_INDEXES = {
"file_folders": {"uq_file_folders_active_user_path", "uq_file_folders_active_group_path"},
"campaign_versions": {
"ix_campaign_versions_user_lock_state",
"ix_campaign_versions_user_locked_by_user_id",
"ix_campaign_versions_execution_snapshot_hash",
},
"campaign_jobs": {"ix_campaign_jobs_claim_token", "ix_campaign_jobs_eml_sha256"},
"send_attempts": {"ix_send_attempts_status", "ix_send_attempts_claim_token"},
"audit_log": {"ix_audit_log_scope_created_at", "ix_audit_log_tenant_scope_created_at"},
"access_auth_sessions": {"ix_access_auth_sessions_account_id"},
"access_users": {"ix_access_users_account_id"},
"access_groups": {"ix_access_groups_system_template_id"},
"access_roles": {"ix_access_roles_system_template_id"},
"mail_server_profiles": {"ix_mail_server_profiles_scope_type", "ix_mail_server_profiles_scope_id", "ix_mail_server_profiles_scope"},
}
@dataclass(frozen=True, slots=True)
class MigrationResult:
previous_revision: str | None
reconciled_revision: str | None
current_revision: str | None
class ModuleMigrationTaskExecutionError(RuntimeError):
def __init__(self, message: str, *, records: tuple[dict[str, object], ...]) -> None:
super().__init__(message)
self.records = records
def registered_module_migration_plan(
database_url: str | None = None,
*,
enabled_modules: tuple[str, ...] | list[str] | None = None,
manifest_factories: tuple[ManifestFactory, ...] = (),
) -> MigrationMetadataPlan:
return migration_metadata_plan(_registered_module_registry(
database_url=database_url,
enabled_modules=enabled_modules,
manifest_factories=manifest_factories,
))
def _registered_module_registry(
*,
database_url: str | None = None,
enabled_modules: tuple[str, ...] | list[str] | None = None,
manifest_factories: tuple[ManifestFactory, ...] = (),
):
server_config = get_server_config()
active_database_url = database_url or settings.database_url
if active_database_url:
configure_database(active_database_url)
active_manifest_factories = manifest_factories or tuple(server_config.manifest_factories)
raw_enabled_modules = tuple(enabled_modules) if enabled_modules is not None else load_startup_enabled_modules(server_config.enabled_modules)
candidate_modules = startup_candidate_module_ids(server_config.enabled_modules, raw_enabled_modules)
available_modules = available_module_manifests(
active_manifest_factories,
enabled_modules=candidate_modules,
ignore_load_errors=True,
)
active_enabled_modules = (
tuple(enabled_modules)
if enabled_modules is not None
else load_startup_enabled_modules(server_config.enabled_modules, available=available_modules)
)
registry = build_platform_registry(
active_enabled_modules,
manifest_factories=active_manifest_factories,
)
return registry
def run_registered_module_migration_tasks(
*,
database_url: str | None = None,
enabled_modules: tuple[str, ...] | list[str] | None = None,
migration_module_order: tuple[str, ...] | list[str] | None = None,
phases: tuple[str, ...] | list[str] = MIGRATION_TASK_PHASES,
dry_run: bool = False,
manifest_factories: tuple[ManifestFactory, ...] = (),
) -> tuple[dict[str, object], ...]:
active_phases = tuple(dict.fromkeys(str(item).strip() for item in phases if str(item).strip()))
invalid_phases = tuple(phase for phase in active_phases if phase not in MIGRATION_TASK_PHASES)
if invalid_phases:
raise ValueError("Unsupported module migration task phase(s): " + ", ".join(invalid_phases))
url = database_url or settings.database_url
registry = _registered_module_registry(
database_url=url,
enabled_modules=enabled_modules,
manifest_factories=manifest_factories,
)
manifests = {manifest.id: manifest for manifest in registry.manifests()}
ordered_ids = tuple(dict.fromkeys([
*(str(item).strip() for item in (migration_module_order or ()) if str(item).strip()),
*manifests.keys(),
]))
records: list[dict[str, object]] = []
database = get_database()
with database.SessionLocal() as session:
for phase in active_phases:
for module_id in ordered_ids:
manifest = manifests.get(module_id)
if manifest is None or manifest.migration_spec is None:
continue
for task in manifest.migration_spec.migration_tasks:
if task.phase != phase:
continue
record: dict[str, object] = {
"module_id": manifest.id,
"task_id": task.task_id,
"phase": task.phase,
"summary": task.summary,
"task_version": task.task_version,
"safety": task.safety,
"idempotent": task.idempotent,
"dry_run": dry_run,
}
if task.timeout_seconds is not None:
record["timeout_seconds"] = task.timeout_seconds
if not task.idempotent:
record.update({"status": "blocked", "message": "Task is not idempotent."})
records.append(record)
raise ModuleMigrationTaskExecutionError(
f"Module migration task {manifest.id}/{task.task_id} is not idempotent.",
records=tuple(records),
)
if dry_run:
record.update({"status": "skipped", "message": "Dry run; executor was not called."})
records.append(record)
continue
if task.executor is None:
record.update({"status": "blocked", "message": "Task has no executor."})
records.append(record)
raise ModuleMigrationTaskExecutionError(
f"Module migration task {manifest.id}/{task.task_id} has no executor.",
records=tuple(records),
)
context = ModuleMigrationTaskContext(
module_id=manifest.id,
task_id=task.task_id,
phase=task.phase,
database_url=url,
target_version=manifest.version,
session=session,
dry_run=dry_run,
metadata=task.metadata,
)
try:
result = task.executor(context)
normalized = _normalize_migration_task_result(result)
except Exception as exc:
session.rollback()
record.update({
"status": "blocked",
"message": f"{type(exc).__name__}: {exc}",
})
records.append(record)
raise ModuleMigrationTaskExecutionError(
f"Module migration task {manifest.id}/{task.task_id} failed.",
records=tuple(records),
) from exc
record.update({
"status": normalized.status,
"message": normalized.message,
"details": _jsonable_migration_task_details(normalized.details),
})
records.append(record)
if normalized.status == "blocked":
session.rollback()
raise ModuleMigrationTaskExecutionError(
normalized.message or f"Module migration task {manifest.id}/{task.task_id} blocked migration.",
records=tuple(records),
)
session.commit()
return tuple(records)
def _normalize_migration_task_result(result: ModuleMigrationTaskResult | None) -> ModuleMigrationTaskResult:
if result is None:
return ModuleMigrationTaskResult()
if not isinstance(result, ModuleMigrationTaskResult):
raise TypeError("Module migration task executors must return ModuleMigrationTaskResult or None.")
if result.status not in {"ok", "warning", "blocked", "skipped"}:
raise ValueError(f"Unsupported module migration task status: {result.status!r}")
return result
def _jsonable_migration_task_details(value: Mapping[str, Any]) -> Mapping[str, Any] | str:
try:
json.dumps(value)
except (TypeError, ValueError):
return str(dict(value))
return value
def _repo_root() -> Path:
packaged_root = Path(__file__).resolve().parents[3]
configured = os.environ.get("GOVOPLAN_CORE_SOURCE_ROOT")
candidates = [
Path(configured).expanduser() if configured else None,
Path.cwd(),
packaged_root,
]
for candidate in candidates:
if candidate is None:
continue
resolved = candidate.resolve()
if (resolved / "alembic.ini").exists() and (resolved / "alembic").is_dir():
return resolved
return packaged_root
def alembic_config(
*,
database_url: str | None = None,
enabled_modules: tuple[str, ...] | list[str] | None = None,
manifest_factories: tuple[ManifestFactory, ...] = (),
) -> Config:
root = _repo_root()
config = Config(str(root / "alembic.ini"))
config.set_main_option("script_location", str(root / "alembic"))
active_database_url = database_url or settings.database_url
plan = registered_module_migration_plan(
active_database_url,
enabled_modules=enabled_modules,
manifest_factories=manifest_factories,
)
version_locations = [str(root / "alembic" / "versions")]
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.attributes["database_url"] = active_database_url
if enabled_modules is not None:
config.attributes["enabled_modules"] = tuple(enabled_modules)
if manifest_factories:
config.attributes["manifest_factories"] = tuple(manifest_factories)
return config
def database_revision(database_url: str | None = None) -> str | None:
url = database_url or settings.database_url
engine = create_engine(url)
try:
with engine.connect() as connection:
heads = MigrationContext.configure(connection).get_current_heads()
if not heads:
return None
if len(heads) == 1:
return heads[0]
return ",".join(sorted(heads))
finally:
engine.dispose()
def _has_columns(inspector, table_name: str, required: set[str]) -> bool:
try:
actual = {column["name"] for column in inspector.get_columns(table_name)}
except Exception:
return False
return required.issubset(actual)
def _has_indexes(inspector, table_name: str, required: set[str]) -> bool:
try:
actual = {index["name"] for index in inspector.get_indexes(table_name)}
except Exception:
return False
return required.issubset(actual)
def _has_create_all_schema_through_hierarchical_settings(inspector, tables: set[str]) -> bool:
if not _CREATE_ALL_THROUGH_HIERARCHICAL_TABLES.issubset(tables):
return False
if not any(
table_name in tables and _has_columns(inspector, table_name, _SCOPE_TABLE_COLUMNS)
for table_name in (CORE_SCOPE_TABLE, LEGACY_SCOPE_TABLE)
):
return False
for table_name, required_columns in _CREATE_ALL_THROUGH_HIERARCHICAL_COLUMNS.items():
if table_name not in tables or not _has_columns(inspector, table_name, required_columns):
return False
for table_name, required_indexes in _CREATE_ALL_THROUGH_HIERARCHICAL_INDEXES.items():
if table_name not in tables or not _has_indexes(inspector, table_name, required_indexes):
return False
return True
def _backfill_user_lock_state_for_create_all_schema(database_url: str) -> None:
engine = create_engine(database_url)
try:
with engine.begin() as connection:
connection.execute(text("""
UPDATE campaign_versions
SET user_lock_state = 'permanent',
user_locked_at = published_at,
user_locked_by_user_id = NULL
WHERE published_at IS NOT NULL
AND user_lock_state IS NULL
"""))
finally:
engine.dispose()
def _row_count(connection, table_name: str) -> int:
quoted = connection.dialect.identifier_preparer.quote(table_name)
return int(connection.execute(text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one())
def _drop_table(connection, table_name: str) -> None:
quoted = connection.dialect.identifier_preparer.quote(table_name)
connection.execute(text(f"DROP TABLE {quoted}"))
def _rename_table(connection, old_name: str, new_name: str) -> None:
quoted_old = connection.dialect.identifier_preparer.quote(old_name)
quoted_new = connection.dialect.identifier_preparer.quote(new_name)
connection.execute(text(f"ALTER TABLE {quoted_old} RENAME TO {quoted_new}"))
def _reconcile_scope_table_names(connection, tables: set[str]) -> bool:
if LEGACY_SCOPE_TABLE not in tables:
return False
changed = False
if CORE_SCOPE_TABLE in tables:
if _row_count(connection, CORE_SCOPE_TABLE) == 0:
_drop_table(connection, CORE_SCOPE_TABLE)
tables.remove(CORE_SCOPE_TABLE)
changed = True
elif _row_count(connection, LEGACY_SCOPE_TABLE) == 0:
_drop_table(connection, LEGACY_SCOPE_TABLE)
tables.remove(LEGACY_SCOPE_TABLE)
changed = True
else:
raise RuntimeError(f"Cannot reconcile non-empty {LEGACY_SCOPE_TABLE} over non-empty {CORE_SCOPE_TABLE}")
if LEGACY_SCOPE_TABLE in tables and CORE_SCOPE_TABLE not in tables:
_rename_table(connection, LEGACY_SCOPE_TABLE, CORE_SCOPE_TABLE)
tables.remove(LEGACY_SCOPE_TABLE)
tables.add(CORE_SCOPE_TABLE)
changed = True
return changed
def reconcile_scope_table_retirement_drift(database_url: str | None = None) -> bool:
"""Repair databases that are stamped after the scope-table rename.
The real Alembic migration performs the same rename. This helper covers
development databases that were manually stamped or partially created via
create_all(), where the version marker can be newer than the table name.
It runs after Alembic upgrades so older migrations can still use the
historical table name while they execute.
"""
url = database_url or settings.database_url
changed = False
engine = create_engine(url)
try:
with engine.begin() as connection:
tables = set(inspect(connection).get_table_names())
changed = _reconcile_scope_table_names(connection, tables)
finally:
engine.dispose()
return changed
def reconcile_namespace_table_drift(database_url: str | None = None) -> bool:
"""Repair dev databases stamped past the namespace-table migration.
During the repository split some development databases were stamped at the
newer Alembic heads while still carrying the old platform table names. The
real migration only renames tables, so replay that idempotent operation
before startup bootstrap code touches the ORM.
"""
url = database_url or settings.database_url
changed = False
engine = create_engine(url)
try:
with engine.begin() as connection:
schema = inspect(connection)
tables = set(schema.get_table_names())
if not any(old_name in tables and new_name not in tables for old_name, new_name in _NAMESPACE_TABLE_RENAMES):
return False
old_tables_to_drop: set[str] = set()
new_tables_to_drop: set[str] = set()
for old_name, new_name in _NAMESPACE_TABLE_RENAMES:
if old_name not in tables or new_name not in tables:
continue
if _row_count(connection, old_name) == 0:
old_tables_to_drop.add(old_name)
elif _row_count(connection, new_name) == 0:
new_tables_to_drop.add(new_name)
else:
raise RuntimeError(f"Cannot reconcile non-empty {old_name} over non-empty {new_name}")
for old_name, _new_name in reversed(_NAMESPACE_TABLE_RENAMES):
if old_name in old_tables_to_drop:
_drop_table(connection, old_name)
tables.remove(old_name)
changed = True
for _old_name, new_name in reversed(_NAMESPACE_TABLE_RENAMES):
if new_name in new_tables_to_drop:
_drop_table(connection, new_name)
tables.remove(new_name)
changed = True
for old_name, new_name in _NAMESPACE_TABLE_RENAMES:
if old_name not in tables or new_name in tables:
continue
_rename_table(connection, old_name, new_name)
tables.remove(old_name)
tables.add(new_name)
changed = True
finally:
engine.dispose()
return changed
def reconcile_change_sequence_retention_floor_drift(database_url: str | None = None) -> bool:
"""Repair databases stamped after the change-sequence migration changed.
Early development databases may have applied the change-sequence revision
before the retention-floor table was added to that migration. The main
change-sequence table is enough for full snapshots, but incremental delta
requests need the retention floor to decide whether a watermark is stale.
"""
url = database_url or settings.database_url
changed = False
engine = create_engine(url)
try:
with engine.begin() as connection:
schema = inspect(connection)
tables = set(schema.get_table_names())
if ChangeSequenceEntry.__tablename__ not in tables:
return False
if ChangeSequenceRetentionFloor.__tablename__ not in tables:
ChangeSequenceRetentionFloor.__table__.create(bind=connection, checkfirst=True)
changed = True
else:
indexes = {
index["name"]
for index in schema.get_indexes(ChangeSequenceRetentionFloor.__tablename__)
}
for index in ChangeSequenceRetentionFloor.__table__.indexes:
if index.name not in indexes:
index.create(bind=connection)
changed = True
finally:
engine.dispose()
return changed
def reconcile_legacy_create_all_schema(database_url: 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
repair was necessary. A partial/unknown schema is intentionally left alone
so Alembic can fail visibly instead of guessing.
"""
url = database_url or settings.database_url
engine = create_engine(url)
try:
with engine.connect() as connection:
heads = MigrationContext.configure(connection).get_current_heads()
current = heads[0] if len(heads) == 1 else None
has_no_revision = len(heads) == 0
schema = inspect(connection)
tables = set(schema.get_table_names())
has_file_storage = _FILE_STORAGE_TABLES.issubset(tables) and all(
_has_columns(schema, table, _FILE_STORAGE_COLUMNS[table])
for table in _FILE_STORAGE_TABLES
)
has_file_folders = _FILE_FOLDER_TABLES.issubset(tables) and _has_columns(
schema,
"file_folders",
_FILE_STORAGE_COLUMNS["file_folders"],
)
has_create_all_hierarchical_schema = _has_create_all_schema_through_hierarchical_settings(schema, tables)
finally:
engine.dispose()
target: str | None = None
if current == REVISION_AUTH_RBAC and has_file_storage and has_file_folders:
target = REVISION_FILE_FOLDERS
elif current == REVISION_AUTH_RBAC and has_file_storage:
target = REVISION_FILE_STORAGE
elif current == REVISION_FILE_STORAGE and has_file_folders:
target = REVISION_FILE_FOLDERS
elif current == REVISION_FILE_FOLDERS and has_create_all_hierarchical_schema:
# Development DBs may be stamped at 4e after earlier create_all
# reconciliation even though later tables/columns were already created
# by a newer model set. Skip only when that complete known schema is
# present, then let newer migrations such as mail credential usernames
# run normally.
_backfill_user_lock_state_for_create_all_schema(url)
target = REVISION_HIERARCHICAL_SETTINGS
elif has_no_revision and has_create_all_hierarchical_schema:
_backfill_user_lock_state_for_create_all_schema(url)
target = REVISION_HIERARCHICAL_SETTINGS
elif has_no_revision and has_file_storage and has_file_folders:
# This is the other create_all-only development shape. The strict
# column checks above ensure that we only stamp a complete known schema.
target = REVISION_FILE_FOLDERS
if target is None:
return None
command.stamp(alembic_config(database_url=url), target)
return target
def migrate_database(
*,
database_url: str | None = None,
reconcile_legacy_schema: bool = True,
enabled_modules: tuple[str, ...] | list[str] | None = None,
migration_module_order: tuple[str, ...] | list[str] | None = None,
manifest_factories: tuple[ManifestFactory, ...] = (),
) -> MigrationResult:
url = database_url or settings.database_url
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
config = alembic_config(
database_url=url,
enabled_modules=enabled_modules,
manifest_factories=manifest_factories,
)
if migration_module_order:
plan = registered_module_migration_plan(
url,
enabled_modules=enabled_modules,
manifest_factories=manifest_factories,
)
_upgrade_ordered_module_heads(config, plan, tuple(migration_module_order))
command.upgrade(config, "heads")
if reconcile_legacy_schema:
reconcile_scope_table_retirement_drift(url)
current = database_revision(url)
return MigrationResult(
previous_revision=previous,
reconciled_revision=reconciled,
current_revision=current,
)
def _upgrade_ordered_module_heads(
config: Config,
plan: MigrationMetadataPlan,
module_order: tuple[str, ...],
) -> None:
module_heads = _module_heads_by_id(config, plan)
upgraded: set[str] = set()
for module_id in dict.fromkeys(module_order):
for head in module_heads.get(module_id, ()):
if head in upgraded:
continue
command.upgrade(config, head)
upgraded.add(head)
def _module_heads_by_id(config: Config, plan: MigrationMetadataPlan) -> dict[str, tuple[str, ...]]:
script = ScriptDirectory.from_config(config)
heads = script.get_heads()
module_locations = {
item.module_id: Path(item.script_location).resolve()
for item in plan.modules
if item.script_location
}
grouped: dict[str, list[str]] = {module_id: [] for module_id in module_locations}
for head in heads:
revision = script.get_revision(head)
path_text = getattr(revision, "path", None)
if revision is None or not path_text:
continue
revision_path = Path(path_text).resolve()
for module_id, location in module_locations.items():
try:
revision_path.relative_to(location)
except ValueError:
continue
grouped[module_id].append(head)
break
return {module_id: tuple(sorted(values)) for module_id, values in grouped.items() if values}