From e6d589eb071774afb8c80bbaf24265bcac2895d7 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 22 Jul 2026 10:34:34 +0200 Subject: [PATCH] fix(release): package Core migration runtime --- alembic/env.py | 6 +- pyproject.toml | 6 ++ src/govoplan_core/db/migrations.py | 3 + tests/test_wheel_runtime.py | 143 +++++++++++++++++++++++++++++ 4 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 tests/test_wheel_runtime.py diff --git a/alembic/env.py b/alembic/env.py index b294856..3356dba 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -5,7 +5,11 @@ from logging.config import fileConfig from alembic import context from sqlalchemy import engine_from_config, pool -from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata +try: + from govoplan_access.backend.db import models as access_models # noqa: F401 - populate optional access metadata +except ModuleNotFoundError as exc: + if exc.name != "govoplan_access": + raise from govoplan_core.admin import models as core_admin_models # noqa: F401 - populate core admin metadata from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata from govoplan_core.core.migrations import migration_metadata_plan diff --git a/pyproject.toml b/pyproject.toml index 2dfe484..498cb45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,12 @@ where = ["src"] [tool.setuptools.package-data] govoplan_core = ["py.typed"] +[tool.setuptools.data-files] +"govoplan_core_runtime" = ["alembic.ini"] +"govoplan_core_runtime/alembic" = ["alembic/env.py", "alembic/script.py.mako"] +"govoplan_core_runtime/alembic/versions" = ["alembic/versions/*.py"] +"govoplan_core_runtime/alembic/dev_versions" = ["alembic/dev_versions/*.py"] + [project.scripts] govoplan-config = "govoplan_core.commands.config:main" govoplan-devserver = "govoplan_core.devserver:main" diff --git a/src/govoplan_core/db/migrations.py b/src/govoplan_core/db/migrations.py index cff803d..b681d79 100644 --- a/src/govoplan_core/db/migrations.py +++ b/src/govoplan_core/db/migrations.py @@ -7,6 +7,7 @@ import logging import os from pathlib import Path import re +import sysconfig from typing import Any from alembic import command @@ -462,11 +463,13 @@ def _jsonable_migration_task_details(value: Mapping[str, Any]) -> Mapping[str, A def _repo_root() -> Path: packaged_root = Path(__file__).resolve().parents[3] + installed_runtime_root = Path(sysconfig.get_path("data")) / "govoplan_core_runtime" configured = os.environ.get("GOVOPLAN_CORE_SOURCE_ROOT") candidates = [ Path(configured).expanduser() if configured else None, Path.cwd(), packaged_root, + installed_runtime_root, ] for candidate in candidates: if candidate is None: diff --git a/tests/test_wheel_runtime.py b/tests/test_wheel_runtime.py new file mode 100644 index 0000000..7a8a507 --- /dev/null +++ b/tests/test_wheel_runtime.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import sys +import sysconfig +import tempfile +import unittest + + +class WheelRuntimeTests(unittest.TestCase): + def test_built_wheel_contains_and_runs_core_migrations_without_source_checkout(self) -> None: + repository_root = Path(__file__).resolve().parents[1] + access_repository = repository_root.parent / "govoplan-access" + self.assertTrue(access_repository.is_dir(), "wheel runtime check requires the mandatory Access repository") + with tempfile.TemporaryDirectory(prefix="govoplan-core-wheel-") as directory: + temporary_root = Path(directory) + wheel_directory = temporary_root / "wheels" + wheel_directory.mkdir() + self._run( + sys.executable, + "-m", + "pip", + "wheel", + "--no-deps", + "--wheel-dir", + str(wheel_directory), + str(repository_root), + cwd=temporary_root, + ) + self._run( + sys.executable, + "-m", + "pip", + "wheel", + "--no-deps", + "--wheel-dir", + str(wheel_directory), + str(access_repository), + cwd=temporary_root, + ) + core_wheels = tuple(wheel_directory.glob("govoplan_core-*.whl")) + access_wheels = tuple(wheel_directory.glob("govoplan_access-*.whl")) + self.assertEqual(1, len(core_wheels)) + self.assertEqual(1, len(access_wheels)) + + virtual_environment = temporary_root / "venv" + self._run(sys.executable, "-m", "venv", str(virtual_environment), cwd=temporary_root) + venv_python = virtual_environment / "bin" / "python" + self._run( + str(venv_python), + "-m", + "pip", + "install", + "--no-deps", + str(core_wheels[0]), + str(access_wheels[0]), + cwd=temporary_root, + ) + + database_path = temporary_root / "wheel-runtime.db" + probe = self._run( + str(venv_python), + "-c", + self._probe_script(), + str(database_path), + cwd=temporary_root, + env={ + **os.environ, + # Reuse only third-party test dependencies from the parent + # environment. Editable .pth files are not processed from + # PYTHONPATH, so Core itself can only come from the wheel. + "PYTHONPATH": sysconfig.get_path("purelib"), + "GOVOPLAN_CORE_SOURCE_ROOT": "", + "GOVOPLAN_ENABLED_MODULES": "", + }, + ) + result = json.loads(probe.stdout) + installed_package = Path(result["package_file"]) + runtime_root = Path(result["runtime_root"]) + self.assertTrue(installed_package.is_relative_to(virtual_environment)) + self.assertTrue(runtime_root.is_relative_to(virtual_environment)) + self.assertNotEqual(repository_root, runtime_root) + self.assertTrue((runtime_root / "alembic.ini").is_file()) + self.assertTrue((runtime_root / "alembic" / "env.py").is_file()) + self.assertIn("4f2a9c8e7b6d", result["heads"]) + self.assertIn("core_scopes", result["tables"]) + self.assertIn("core_system_settings", result["tables"]) + + @staticmethod + def _run(*command: str, cwd: Path, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + command, + cwd=cwd, + env=env, + check=False, + capture_output=True, + text=True, + ) + if result.returncode: + raise AssertionError( + f"Command failed ({result.returncode}): {' '.join(command)}\n" + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + return result + + @staticmethod + def _probe_script() -> str: + return """ +import json +from pathlib import Path +import sys + +from alembic import command +from alembic.script import ScriptDirectory +from sqlalchemy import create_engine, inspect + +import govoplan_core +from govoplan_core.db.migrations import _repo_root, alembic_config + +database_url = f"sqlite:///{Path(sys.argv[1])}" +config = alembic_config(database_url=database_url, enabled_modules=()) +command.upgrade(config, "heads") +script = ScriptDirectory.from_config(config) +engine = create_engine(database_url) +try: + with engine.connect() as connection: + tables = sorted(inspect(connection).get_table_names()) +finally: + engine.dispose() +print(json.dumps({ + "package_file": govoplan_core.__file__, + "runtime_root": str(_repo_root()), + "heads": sorted(script.get_heads()), + "tables": tables, +})) +""" + + +if __name__ == "__main__": + unittest.main()