151 lines
5.3 KiB
Python
151 lines
5.3 KiB
Python
"""Hygiene lint over migrations/*.py.
|
|
|
|
Guards three invariants of the migrations directory:
|
|
|
|
* Version prefixes are unique — except the grandfathered V010 pair
|
|
(``V010__meta_skill_runs`` / ``V010__transcript_turn_usage``), which has
|
|
distinct yoyo ids and is safe today but must never gain company.
|
|
* Every migration declares ``__depends__`` so yoyo orders it explicitly.
|
|
* Every migration is reachable from the head of the dependency graph, so
|
|
dependency-aware partial-apply tooling cannot silently skip a leaf
|
|
(the V010 transcript-usage migration was such a leaf until V018
|
|
reconnected it).
|
|
|
|
Prefixes are NOT required to be strictly increasing or gap-free.
|
|
|
|
``__depends__`` is parsed with ``ast`` instead of importing the modules:
|
|
migration files call yoyo's ``step()`` at import time, which only works
|
|
inside yoyo's collector machinery.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import re
|
|
from pathlib import Path
|
|
|
|
MIGRATIONS_DIR = Path(__file__).resolve().parents[2] / "migrations"
|
|
|
|
# The single allowed duplicate-prefix exception. Any NEW duplicate fails.
|
|
GRANDFATHERED_DUPLICATE_PREFIX = "V010"
|
|
GRANDFATHERED_V010_IDS = {
|
|
"V010__meta_skill_runs",
|
|
"V010__transcript_turn_usage",
|
|
}
|
|
|
|
_NAME_RE = re.compile(r"^(V(\d+))__.+\.py$")
|
|
|
|
|
|
def _migration_files() -> list[Path]:
|
|
files = sorted(p for p in MIGRATIONS_DIR.glob("V*.py") if p.is_file())
|
|
assert files, f"no migration files found under {MIGRATIONS_DIR}"
|
|
return files
|
|
|
|
|
|
def _parse_depends(path: Path) -> set[str] | None:
|
|
"""Return the module-level ``__depends__`` value, or None when absent."""
|
|
tree = ast.parse(path.read_text(encoding="utf-8"))
|
|
for node in tree.body:
|
|
target: str | None = None
|
|
value: ast.expr | None = None
|
|
if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
|
target, value = node.target.id, node.value
|
|
elif (
|
|
isinstance(node, ast.Assign)
|
|
and len(node.targets) == 1
|
|
and isinstance(node.targets[0], ast.Name)
|
|
):
|
|
target, value = node.targets[0].id, node.value
|
|
if target != "__depends__" or value is None:
|
|
continue
|
|
if (
|
|
isinstance(value, ast.Call)
|
|
and isinstance(value.func, ast.Name)
|
|
and value.func.id == "set"
|
|
and not value.args
|
|
):
|
|
return set()
|
|
deps = ast.literal_eval(value)
|
|
return {str(dep) for dep in deps}
|
|
return None
|
|
|
|
|
|
def test_migration_filenames_match_convention() -> None:
|
|
bad = [p.name for p in _migration_files() if not _NAME_RE.match(p.name)]
|
|
assert not bad, f"migration files must be named Vnnn__name.py: {bad}"
|
|
|
|
|
|
def test_version_prefixes_unique_except_grandfathered_v010() -> None:
|
|
by_prefix: dict[str, list[str]] = {}
|
|
for path in _migration_files():
|
|
match = _NAME_RE.match(path.name)
|
|
assert match is not None, path.name
|
|
by_prefix.setdefault(match.group(1), []).append(path.stem)
|
|
|
|
for prefix, ids in sorted(by_prefix.items()):
|
|
if prefix == GRANDFATHERED_DUPLICATE_PREFIX:
|
|
assert set(ids) == GRANDFATHERED_V010_IDS, (
|
|
f"the {prefix} prefix is grandfathered ONLY for "
|
|
f"{sorted(GRANDFATHERED_V010_IDS)}; found {sorted(ids)}"
|
|
)
|
|
continue
|
|
assert len(ids) == 1, (
|
|
f"duplicate migration version prefix {prefix}: {sorted(ids)}. "
|
|
"Pick the next unused Vnnn prefix — duplicate prefixes are a "
|
|
"hygiene hazard (only the historical V010 pair is allowed)."
|
|
)
|
|
|
|
|
|
def test_every_migration_declares_depends() -> None:
|
|
missing = [
|
|
path.name for path in _migration_files() if _parse_depends(path) is None
|
|
]
|
|
assert not missing, (
|
|
f"migrations without a module-level __depends__: {missing}. "
|
|
"Every migration must declare its dependencies explicitly."
|
|
)
|
|
|
|
|
|
def test_all_migrations_reachable_from_dependency_head() -> None:
|
|
"""The max-prefix migration's __depends__ closure must cover every file.
|
|
|
|
A migration outside the closure is a dangling leaf: dependency-aware
|
|
partial-apply tooling targeting the head can skip it entirely. New
|
|
migrations must name any current leaves in __depends__ (V018 did this
|
|
for V010__transcript_turn_usage).
|
|
"""
|
|
depends: dict[str, set[str]] = {}
|
|
prefix_num: dict[str, int] = {}
|
|
for path in _migration_files():
|
|
match = _NAME_RE.match(path.name)
|
|
assert match is not None, path.name
|
|
deps = _parse_depends(path)
|
|
assert deps is not None, path.name
|
|
depends[path.stem] = deps
|
|
prefix_num[path.stem] = int(match.group(2))
|
|
|
|
unknown = {
|
|
(mig, dep)
|
|
for mig, deps in depends.items()
|
|
for dep in deps
|
|
if dep not in depends
|
|
}
|
|
assert not unknown, f"__depends__ references unknown migrations: {unknown}"
|
|
|
|
head = max(depends, key=lambda mig: (prefix_num[mig], mig))
|
|
reachable: set[str] = set()
|
|
todo = [head]
|
|
while todo:
|
|
mig = todo.pop()
|
|
if mig in reachable:
|
|
continue
|
|
reachable.add(mig)
|
|
todo.extend(depends[mig])
|
|
|
|
dangling = sorted(set(depends) - reachable)
|
|
assert not dangling, (
|
|
f"migrations unreachable from dependency head {head}: {dangling}. "
|
|
"Add them to a newer migration's __depends__ so partial-apply "
|
|
"tooling cannot skip them."
|
|
)
|