diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index cbde1fa..8600184 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -41,6 +41,7 @@ jobs: - 'apps/shifts/**' - 'migration/check_shifts_structure.py' - 'migration/phase4/baseline.json' + - 'migration/phase5/shifts-definition-overrides.json' - 'migration/fixtures/shifts.sqlite' - 'packages/crr-brand/**' - 'packages/crr-python/**' @@ -68,6 +69,7 @@ jobs: version: '0.11.31' enable-cache: true - run: make tournament-install tournament-test tournament-build + - run: make python-common-test - run: docker compose config --quiet - run: docker build -f apps/tournament/Dockerfile -t crr-tournament . @@ -82,5 +84,6 @@ jobs: version: '0.11.31' enable-cache: true - run: make shifts-test + - run: make python-common-test - run: docker compose config --quiet - run: make shifts-build diff --git a/Makefile b/Makefile index 7e6d80b..5cb7338 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ UV ?= uv PYTHON ?= python3 .DEFAULT_GOAL := test -.PHONY: baseline-test baseline-fixtures baseline-screenshots baseline-preserve migration-visual-check branding-visual-check images-smoke-test tournament-install tournament-backend-dev tournament-frontend-dev tournament-test tournament-compat-test tournament-build shifts-dev shifts-test shifts-compat-test shifts-build test +.PHONY: baseline-test baseline-fixtures baseline-screenshots baseline-preserve migration-visual-check branding-visual-check images-smoke-test python-common-test tournament-install tournament-backend-dev tournament-frontend-dev tournament-test tournament-compat-test tournament-build shifts-dev shifts-test shifts-compat-test shifts-build test baseline-test: $(PYTHON) tools/migration_baseline.py all @@ -28,6 +28,9 @@ branding-visual-check: images-smoke-test: $(PYTHON) tools/check_images.py +python-common-test: + $(UV) run --directory packages/crr-python --package crr-python --locked pytest + tournament-install: npm --prefix apps/tournament ci @@ -64,4 +67,4 @@ shifts-compat-test: shifts-build: docker build -f apps/shifts/Dockerfile -t crr-shifts . -test: tournament-test shifts-test +test: python-common-test tournament-test shifts-test diff --git a/README.md b/README.md index dc3cbe3..62cfe3f 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Two independently runnable applications migrating into one monorepo: - [Tournament](apps/tournament/README.md): Vue 3 and FastAPI on Python 3.13. - [Shifts](apps/shifts/README.md): FastAPI, Jinja, SQLAlchemy and Alembic on Python 3.13. - [CRR brand](packages/crr-brand/README.md): shared logo, favicon and CSS primitives. +- [CRR Python](packages/crr-python/README.md): shared engine/threading and Alembic configuration helpers. See [the migration plan](MONOREPO_MIGRATION_PLAN.md) and [verified progress and baseline evidence](migration/README.md). @@ -17,6 +18,7 @@ Requires Node 22+, Python 3.13, `uv`, and `make`. make tournament-install make tournament-test tournament-build make shifts-test +make python-common-test make test ``` diff --git a/apps/shifts/Dockerfile b/apps/shifts/Dockerfile index 094a7c5..f616a9b 100644 --- a/apps/shifts/Dockerfile +++ b/apps/shifts/Dockerfile @@ -6,6 +6,7 @@ WORKDIR /workspace COPY pyproject.toml uv.lock ./ COPY apps/shifts/backend/pyproject.toml apps/shifts/backend/pyproject.toml COPY apps/tournament/backend/pyproject.toml apps/tournament/backend/pyproject.toml +COPY packages/crr-python/ packages/crr-python/ RUN uv sync --frozen --package crr-shifts --no-dev --no-editable COPY apps/shifts/backend/ apps/shifts/backend/ COPY packages/crr-brand/ packages/crr-brand/ diff --git a/apps/shifts/README.md b/apps/shifts/README.md index 5d96e91..6800290 100644 --- a/apps/shifts/README.md +++ b/apps/shifts/README.md @@ -5,6 +5,11 @@ services, security primitives and domain routers are organized separately, with the established Jinja/form contracts and `0001_initial` Alembic history. Python dependencies are managed with `uv` and the root `uv.lock`. +The internal `crr-python` workspace dependency supplies synchronous engine +construction and Alembic configuration. Shifts owns its sessions, backups, +startup adoption rules and migration history. Docker installs the shared package +as a wheel alongside the app's dependencies. + The canonical logo, favicon and common CSS live in `packages/crr-brand`, mounted at `/brand` with root-path-aware Jinja links. PDF exports read that same logo. App-specific layouts and forms remain in `backend/app/static/app.css`; Docker diff --git a/apps/shifts/backend/app/database.py b/apps/shifts/backend/app/database.py index 7d75590..913654a 100644 --- a/apps/shifts/backend/app/database.py +++ b/apps/shifts/backend/app/database.py @@ -11,7 +11,9 @@ 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 +from crr_common.database import create_sync_engine +from crr_common.migrations import migration_config +from sqlalchemy import inspect from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker from .config import settings @@ -23,16 +25,13 @@ class Base(DeclarativeBase): pass -connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {} -engine = create_engine(settings.database_url, connect_args=connect_args, future=True) +engine = create_sync_engine(settings.database_url, future=True) SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False, class_=Session) def alembic_config() -> Config: - config = Config(str(MIGRATIONS_DIR.parent / "alembic.ini")) - config.set_main_option("script_location", str(MIGRATIONS_DIR)) - config.set_main_option("sqlalchemy.url", settings.database_url) - return config + return migration_config(MIGRATIONS_DIR.parent / "alembic.ini", MIGRATIONS_DIR, + database_url=settings.database_url) def run_migrations() -> None: diff --git a/apps/shifts/backend/pyproject.toml b/apps/shifts/backend/pyproject.toml index 60ddac4..e39f6b9 100644 --- a/apps/shifts/backend/pyproject.toml +++ b/apps/shifts/backend/pyproject.toml @@ -4,6 +4,7 @@ version = "0.1.0" description = "Contínuos CRR shift rota application" requires-python = ">=3.13,<3.14" dependencies = [ + "crr-python", "fastapi>=0.115,<1", "uvicorn[standard]>=0.30,<1", "SQLAlchemy>=2.0,<3", @@ -19,6 +20,9 @@ dependencies = [ [dependency-groups] dev = ["pytest>=8,<9", "httpx>=0.27,<1", "pypdf>=5,<7"] +[tool.uv.sources] +crr-python = {workspace = true} + [tool.pytest.ini_options] pythonpath = ["."] testpaths = ["tests"] diff --git a/apps/shifts/docs/ARCHITECTURE.md b/apps/shifts/docs/ARCHITECTURE.md index f1b1b65..7d56132 100644 --- a/apps/shifts/docs/ARCHITECTURE.md +++ b/apps/shifts/docs/ARCHITECTURE.md @@ -33,6 +33,9 @@ on startup, as they do in production. - `services/pdf.py`: ReportLab-rendered A4 schedule lists, with embedded Unicode fonts and adaptive layout to keep the selected range on one page. - `backend/app/cli.py`: maintenance commands that can be run manually or by cron. +- `packages/crr-python`: shared synchronous engine/threading and Alembic-config + construction. Each app supplies its own database setting, script location and + migration policy; the package contains no models or authentication code. ## Data lifecycle @@ -71,4 +74,6 @@ schema/ORM/relationships/OpenAPI plus 96 HTTP requests across `/` and `/crr` against the pre-refactor baseline. Runtime-only randomness/timestamps are normalized, and calendar clocks are fixed for reproducible comparisons. Raw comparison results are under ignored `.migration/phase4-results/`; verified -evidence is under `migration/phase4/`. +evidence is under `migration/phase4/` and `migration/phase5/`. Phase 5 records the +exact `alembic_config` helper delegation by old/new AST hashes while retaining +the original Phase 4 baseline and guarding the other definitions. diff --git a/apps/tournament/Dockerfile b/apps/tournament/Dockerfile index a907395..521e048 100644 --- a/apps/tournament/Dockerfile +++ b/apps/tournament/Dockerfile @@ -16,6 +16,7 @@ WORKDIR /workspace COPY pyproject.toml uv.lock ./ COPY apps/tournament/backend/pyproject.toml apps/tournament/backend/pyproject.toml COPY apps/shifts/backend/pyproject.toml apps/shifts/backend/pyproject.toml +COPY packages/crr-python/ packages/crr-python/ RUN uv sync --frozen --package crr-tournament --no-dev --no-editable COPY apps/tournament/backend/ apps/tournament/backend/ COPY --from=frontend /workspace/apps/tournament/dist/client apps/tournament/dist/client/ diff --git a/apps/tournament/README.md b/apps/tournament/README.md index 2c0a2cf..21d327a 100644 --- a/apps/tournament/README.md +++ b/apps/tournament/README.md @@ -39,6 +39,11 @@ controls both the frontend build path and backend path (default `/jogo/`). The backend project metadata. The image builds Vue with Node 22 and runs Python 3.13 as UID/GID 1000, matching the old container's database ownership. +Both backends consume the internal `crr-python` workspace dependency for +synchronous engine construction and Alembic configuration. Tournament owns its +connection pragmas, legacy-schema validation and transactional adoption policy. +Production installs the shared package as a wheel inside the image. + Tests require Node 22+ and uv. `make tournament-test` runs backend pytest, frontend/shared tests, Vue typechecking and the preserved legacy compatibility assertions against actual Python services and HTTP/SSE servers. Those assertions diff --git a/apps/tournament/backend/app/database.py b/apps/tournament/backend/app/database.py index 2ccbc9f..fc65294 100644 --- a/apps/tournament/backend/app/database.py +++ b/apps/tournament/backend/app/database.py @@ -2,8 +2,9 @@ import re from pathlib import Path from alembic import command -from alembic.config import Config -from sqlalchemy import CheckConstraint, DefaultClause, UniqueConstraint, URL, create_engine, event, inspect +from crr_common.database import create_sync_engine +from crr_common.migrations import migration_config +from sqlalchemy import CheckConstraint, DefaultClause, UniqueConstraint, URL, event, inspect from sqlalchemy.engine import Connection, Engine from sqlalchemy.pool import StaticPool @@ -16,8 +17,7 @@ def create_database_engine(path: str) -> Engine: if path != ":memory:": Path(path).resolve().parent.mkdir(parents=True, exist_ok=True) options = {"poolclass": StaticPool} if path == ":memory:" else {} - engine = create_engine(URL.create("sqlite", database=path), - connect_args={"check_same_thread": False}, **options) + engine = create_sync_engine(URL.create("sqlite", database=path), **options) @event.listens_for(engine, "connect") def pragmas(connection, _record): @@ -91,8 +91,8 @@ def run_migrations(engine: Engine) -> None: # SQLite's legacy driver transaction mode does not begin on DDL. # Explicitly enclose validation, stamping and upgrade in one real transaction. connection.exec_driver_sql("BEGIN IMMEDIATE") - config = Config(str(BACKEND / "alembic.ini")) - config.attributes["connection"] = connection + config = migration_config(BACKEND / "alembic.ini", BACKEND / "migrations", + database_url=engine.url, connection=connection) tables = set(inspect(connection).get_table_names()) if tables and "alembic_version" not in tables: validate_legacy_schema(connection) diff --git a/apps/tournament/backend/pyproject.toml b/apps/tournament/backend/pyproject.toml index 4ca91d0..ffc5aea 100644 --- a/apps/tournament/backend/pyproject.toml +++ b/apps/tournament/backend/pyproject.toml @@ -4,6 +4,7 @@ version = "1.0.0" description = "CRR Tournament FastAPI backend" requires-python = ">=3.13,<3.14" dependencies = [ + "crr-python", "fastapi>=0.115,<1", "uvicorn[standard]>=0.30,<1", "SQLAlchemy>=2.0,<3", @@ -14,6 +15,9 @@ dependencies = [ [dependency-groups] dev = ["pytest>=8,<9", "httpx>=0.27,<1"] +[tool.uv.sources] +crr-python = {workspace = true} + [tool.pytest.ini_options] pythonpath = ["."] testpaths = ["tests"] diff --git a/migration/README.md b/migration/README.md index 31d945f..dbad3ea 100644 --- a/migration/README.md +++ b/migration/README.md @@ -286,12 +286,67 @@ Do not rerun the one-time generator or overwrite the baseline. The original snapshots and branding/compatibility baselines remain intact. Remote CI is still unverified. +## Phase 5 — proven Python infrastructure: locally verified complete + +Compared both final backends and extracted two concrete common operations into +the internal `crr-python` uv member (`src/crr_common`): + +- `create_sync_engine`: synchronous SQLAlchemy construction with SQLite request- + worker threading support. It recognizes SQLAlchemy URL/dialect objects, honors + explicit connection options and forwards engine/pool options. Each app still + controls its connection pragmas, filesystem setup and session factories. +- `migration_config`: Alembic ini/script/database wiring, with absolute paths, + interpolation-safe URLs and an optional caller-owned connection. Baseline + validation, stamping/upgrades, transactions and history stay app-specific. + +This removes duplicated setup without app-name switches or domain concepts. +No models, schemas, auth primitives, error messages or business rules moved into +the package. Both applications declare an explicit workspace dependency and +resolve it through the root lockfile. Local commands use the workspace install; +both independent production images install a non-editable wheel in `/opt/venv`. + +Added `make python-common-test`, included it in root `make test` and both affected +app CI jobs. Shared-package paths already trigger both jobs. Docker contexts now +include the package before their frozen uv install step. + +| Check | Result | +| --- | --- | +| Shared package tests | 7 passed: cross-thread SQLite, memory/pool forwarding, consumer-owned pragmas, non-SQLite/explicit options, paths/URL escaping and connection ownership | +| Tournament backend | 28 passed | +| Tournament frontend/shared + Vue typecheck/build | 13 passed; typecheck and build passed | +| Legacy Tournament assertions on Python | 44 passed | +| Shifts pytest | 16 passed | +| Shifts workflow/schema/ORM/OpenAPI compatibility | 96 requests matched across both prefixes; mappings, data and histories preserved | +| `make migration-visual-check` | All 14 screenshots match Phase 2 byte-for-byte | +| Independent Docker builds | Both passed | +| Shared-package imports in both images | Verified `/opt/venv/lib/python3.13/site-packages/crr_common` | +| Container startup/resource/adoption smoke checks | Fresh Tournament, populated Node fixture and Shifts passed | + +The Phase 4 baseline remains unchanged. `phase5/shifts-definition-overrides.json` +records only the intentional `alembic_config` delegation, with exact before/after +AST fingerprints and the baseline checksum. The verifier still guards the other +172 definitions and all route/form/ORM/data/history expectations; its latest +evidence is in `phase5/shifts-compatibility.json`. Unknown changes continue to +fail rather than being normalized away. `tools/record_shared_infrastructure.py` +was the one-time manifest generator and refuses to overwrite recorded evidence. + +Remote GitHub Actions execution is still unverified. The apps retain independent +versions, images, databases and auth models; `crr-python` has an internal 0.1.0 +version and is released through its consuming applications. + +Historical-source checksum revalidation is blocked: `originals/` is currently +empty, and both referenced legacy directories are missing. `make baseline-preserve` +therefore cannot verify their checksums. Phase 5 did not modify these directories; +its app/package tests use saved fixtures and owned compatibility contracts, all +of which remain present and pass. Snapshot retention/restoration must be resolved +before declaring the full migration acceptance criteria complete. + ## Next work 1. Verify the new CI jobs on Node 22/Python 3.13 after publishing the changes. -2. Phase 5: compare the two final Python backends and extract only infrastructure - that demonstrably removes generic duplication without app-specific switches. +2. Phase 6: final layout and retired Node-source cleanup, compatibility-scaffolding + review, dependency/documentation cleanup and independent release workflows. -Phases 5–6 are not complete. No shared Python package has been extracted yet. +Phase 6 is not complete. Independent tag-triggered release workflows and final dependency cleanup belong to later phases. diff --git a/migration/check_shifts_structure.py b/migration/check_shifts_structure.py index febcbf5..143abe0 100644 --- a/migration/check_shifts_structure.py +++ b/migration/check_shifts_structure.py @@ -296,10 +296,20 @@ def main(backend: Path, capture: bool): output.write_text(json.dumps(result, indent=2, ensure_ascii=False, sort_keys=True) + "\n") if not capture: baseline = json.loads((REPORT / "baseline.json").read_text()) + override_path = ROOT / "migration/phase5/shifts-definition-overrides.json" + overrides = {} + if override_path.exists(): + override_report = json.loads(override_path.read_text()) + assert override_report["baseline_sha256"] == hashlib.sha256((REPORT / "baseline.json").read_bytes()).hexdigest() + overrides = override_report["definitions"] + for name, fingerprints in overrides.items(): + assert baseline["definitions"][name] == fingerprints["before"], name + assert result["definitions"][name] == fingerprints["after"], name + baseline["definitions"][name] = fingerprints["after"] comparison = differences(normalized_report(baseline), normalized_report(result)) (results / "differences.json").write_text(json.dumps(comparison, indent=2, ensure_ascii=False) + "\n") assert not comparison, f"{len(comparison)} structural/workflow differences; inspect .migration/phase4-results/differences.json" - verification = {"definitions_preserved": len(result["definitions"]), + verification = {"definitions_preserved": len(result["definitions"]) - len(overrides), "orm_mapping_and_relationships_unchanged": True, "openapi_unchanged": True, "migration_files_unchanged": True, "existing_fixture_rows_preserved_at_startup": True, @@ -307,7 +317,11 @@ def main(backend: Path, capture: bool): "deployment_prefixes": list(result["workflows"]), "fixture_sha256": hashlib.sha256(FIXTURE.read_bytes()).hexdigest(), "baseline_sha256": hashlib.sha256((REPORT / "baseline.json").read_bytes()).hexdigest()} - (REPORT / "verification.json").write_text(json.dumps(verification, indent=2) + "\n") + verification_path = REPORT / "verification.json" + if overrides: + verification["shared_infrastructure_delegations"] = overrides + verification_path = ROOT / "migration/phase5/shifts-compatibility.json" + verification_path.write_text(json.dumps(verification, indent=2) + "\n") print(f"Shifts {'baseline captured' if capture else 'structural/workflow parity verified'}: {sum(len(value['requests']) for value in result['workflows'].values())} requests across both deployment prefixes.") diff --git a/migration/phase5/shifts-compatibility.json b/migration/phase5/shifts-compatibility.json new file mode 100644 index 0000000..558a3da --- /dev/null +++ b/migration/phase5/shifts-compatibility.json @@ -0,0 +1,21 @@ +{ + "definitions_preserved": 172, + "orm_mapping_and_relationships_unchanged": true, + "openapi_unchanged": true, + "migration_files_unchanged": true, + "existing_fixture_rows_preserved_at_startup": true, + "workflow_requests_matched": 96, + "deployment_prefixes": [ + "", + "/crr" + ], + "fixture_sha256": "95b06188ce5076a17b842523c6a969358cdd48e30221de4292e6628f40c9b292", + "baseline_sha256": "2ecd64d83d35d482f8eed7f46b65754f6db30d34c48a06943c0b9bd11ddf2b59", + "shared_infrastructure_delegations": { + "alembic_config": { + "before": "2952ea6c5985853514aae3e8a7b74f0f1d0d49b56b45ca0c82874d7673ae574f", + "after": "e69c3eca19d4867bd29a0809a5676e41618033fe08be2920c9216fc359cb7440", + "shared_helper": "crr_common.migrations.migration_config" + } + } +} diff --git a/migration/phase5/shifts-definition-overrides.json b/migration/phase5/shifts-definition-overrides.json new file mode 100644 index 0000000..ea81e54 --- /dev/null +++ b/migration/phase5/shifts-definition-overrides.json @@ -0,0 +1,10 @@ +{ + "baseline_sha256": "2ecd64d83d35d482f8eed7f46b65754f6db30d34c48a06943c0b9bd11ddf2b59", + "definitions": { + "alembic_config": { + "before": "2952ea6c5985853514aae3e8a7b74f0f1d0d49b56b45ca0c82874d7673ae574f", + "after": "e69c3eca19d4867bd29a0809a5676e41618033fe08be2920c9216fc359cb7440", + "shared_helper": "crr_common.migrations.migration_config" + } + } +} diff --git a/packages/crr-python/README.md b/packages/crr-python/README.md new file mode 100644 index 0000000..c3140ec --- /dev/null +++ b/packages/crr-python/README.md @@ -0,0 +1,26 @@ +# CRR Python infrastructure + +Internal uv workspace package, imported as `crr_common`, targeting Python 3.13. +Both applications consume it explicitly through workspace dependencies. + +## Proven shared needs + +- `database.create_sync_engine`: both FastAPI applications use synchronous + SQLAlchemy and allow SQLite connections to cross request-worker threads. + This wraps SQLAlchemy URL handling and supplies `check_same_thread=False` for + SQLite, while forwarding caller engine options and honoring explicit connection + arguments. Other database dialects receive no SQLite-specific arguments. +- `migrations.migration_config`: both applications create an Alembic configuration + from their own ini file, migration directory and database setting. It resolves + paths, escapes URL interpolation characters, and optionally attaches a caller- + owned connection for transactional migrations. + +Schema validation, baseline revisions, upgrades/stamps, transaction ownership, +SQLite pragmas, backups, sessions, domain models and authentication belong to +each application. The package contains no app imports, switches or domain code. +It neither opens migration connections nor runs DDL. + +Run `make python-common-test` from the repository root. `make test` includes +these checks, and shared-package changes trigger both app CI jobs. Docker images +install the package as a wheel with `uv sync --no-editable`; local uv commands +use the workspace package. Its internal version is not a public release stream. diff --git a/packages/crr-python/pyproject.toml b/packages/crr-python/pyproject.toml new file mode 100644 index 0000000..05226f9 --- /dev/null +++ b/packages/crr-python/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "crr-python" +version = "0.1.0" +description = "Shared CRR Python infrastructure" +requires-python = ">=3.13,<3.14" +dependencies = ["SQLAlchemy>=2.0,<3", "alembic>=1.13,<2"] +readme = "README.md" + +[build-system] +requires = ["hatchling>=1.26,<2"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/crr_common"] + +[dependency-groups] +dev = ["pytest>=8,<9"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra" diff --git a/packages/crr-python/src/crr_common/__init__.py b/packages/crr-python/src/crr_common/__init__.py new file mode 100644 index 0000000..5568f62 --- /dev/null +++ b/packages/crr-python/src/crr_common/__init__.py @@ -0,0 +1 @@ +"""Low-level infrastructure shared by the independently deployed CRR apps.""" diff --git a/packages/crr-python/src/crr_common/database.py b/packages/crr-python/src/crr_common/database.py new file mode 100644 index 0000000..894c82c --- /dev/null +++ b/packages/crr-python/src/crr_common/database.py @@ -0,0 +1,18 @@ +from collections.abc import Mapping +from typing import Any + +from sqlalchemy import URL, create_engine +from sqlalchemy.engine import Engine, make_url + + +def create_sync_engine( + url: str | URL, + *, + connect_args: Mapping[str, Any] | None = None, + **engine_options: Any, +) -> Engine: + """Construct a synchronous engine without changing connection/schema policy.""" + arguments = dict(connect_args or {}) + if make_url(url).get_backend_name() == "sqlite": + arguments.setdefault("check_same_thread", False) + return create_engine(url, connect_args=arguments, **engine_options) diff --git a/packages/crr-python/src/crr_common/migrations.py b/packages/crr-python/src/crr_common/migrations.py new file mode 100644 index 0000000..17777cc --- /dev/null +++ b/packages/crr-python/src/crr_common/migrations.py @@ -0,0 +1,23 @@ +from pathlib import Path + +from alembic.config import Config +from sqlalchemy import URL +from sqlalchemy.engine import Connection + + +def migration_config( + ini_path: str | Path, + script_location: str | Path, + *, + database_url: str | URL | None = None, + connection: Connection | None = None, +) -> Config: + """Prepare Alembic wiring; the caller owns migration policy and connections.""" + config = Config(str(Path(ini_path).resolve())) + config.set_main_option("script_location", str(Path(script_location).resolve()).replace("%", "%%")) + if database_url is not None: + url = database_url.render_as_string(hide_password=False) if isinstance(database_url, URL) else database_url + config.set_main_option("sqlalchemy.url", url.replace("%", "%%")) + if connection is not None: + config.attributes["connection"] = connection + return config diff --git a/packages/crr-python/tests/test_database.py b/packages/crr-python/tests/test_database.py new file mode 100644 index 0000000..2de0e15 --- /dev/null +++ b/packages/crr-python/tests/test_database.py @@ -0,0 +1,54 @@ +from concurrent.futures import ThreadPoolExecutor + +from sqlalchemy import URL +from sqlalchemy.pool import StaticPool + +from crr_common.database import create_sync_engine + + +def test_sqlite_connection_can_cross_request_worker_threads(tmp_path): + engine = create_sync_engine(URL.create("sqlite", database=str(tmp_path / "threads.sqlite"))) + try: + with engine.connect() as connection, ThreadPoolExecutor(max_workers=1) as pool: + assert pool.submit(lambda: connection.exec_driver_sql("SELECT 42").scalar_one()).result() == 42 + finally: + engine.dispose() + + +def test_caller_pool_options_and_memory_database_are_preserved(): + engine = create_sync_engine("sqlite:///:memory:", poolclass=StaticPool) + try: + assert isinstance(engine.pool, StaticPool) + with engine.begin() as connection: + connection.exec_driver_sql("CREATE TABLE example(value INTEGER)") + connection.exec_driver_sql("INSERT INTO example VALUES(7)") + with engine.connect() as connection: + assert connection.exec_driver_sql("SELECT value FROM example").scalar_one() == 7 + finally: + engine.dispose() + + +def test_shared_factory_leaves_sqlite_pragmas_to_the_consumer(tmp_path): + engine = create_sync_engine(URL.create("sqlite", database=str(tmp_path / "policy.sqlite"))) + try: + with engine.connect() as connection: + assert connection.exec_driver_sql("PRAGMA foreign_keys").scalar_one() == 0 + assert connection.exec_driver_sql("PRAGMA journal_mode").scalar_one() == "delete" + finally: + engine.dispose() + + +def test_non_sqlite_urls_and_explicit_connection_options(monkeypatch): + from crr_common import database + calls = [] + marker = object() + def factory(url, **options): + calls.append((url, options)) + return marker + monkeypatch.setattr(database, "create_engine", factory) + assert create_sync_engine("postgresql://localhost/example", pool_pre_ping=True) is marker + assert calls[-1][1] == {"connect_args": {}, "pool_pre_ping": True} + arguments = {"check_same_thread": True, "timeout": 10} + assert create_sync_engine("sqlite://", connect_args=arguments) is marker + assert calls[-1][1]["connect_args"] == arguments + assert arguments == {"check_same_thread": True, "timeout": 10} diff --git a/packages/crr-python/tests/test_migrations.py b/packages/crr-python/tests/test_migrations.py new file mode 100644 index 0000000..8be4572 --- /dev/null +++ b/packages/crr-python/tests/test_migrations.py @@ -0,0 +1,41 @@ +from sqlalchemy import URL + +from crr_common.database import create_sync_engine +from crr_common.migrations import migration_config + + +def ini(tmp_path): + path = tmp_path / "alembic.ini" + path.write_text("[alembic]\nscript_location = %(here)s/migrations\n") + return path + + +def test_configuration_resolves_paths_and_preserves_url_percent_escapes(tmp_path): + scripts = tmp_path / "migration%files" + url = "postgresql://reader:p%25ss@localhost/database" + config = migration_config(ini(tmp_path), scripts, database_url=url) + assert config.config_file_name == str(tmp_path / "alembic.ini") + assert config.get_main_option("script_location") == str(scripts) + assert config.get_main_option("sqlalchemy.url") == url + assert "connection" not in config.attributes + + +def test_sqlalchemy_url_objects_are_rendered_without_redacting_connection_credentials(tmp_path): + url = URL.create("postgresql", username="test-user", password="test%only", host="localhost", database="example") + config = migration_config(ini(tmp_path), tmp_path / "migrations", database_url=url) + assert config.get_main_option("sqlalchemy.url") == url.render_as_string(hide_password=False) + + +def test_configuration_reuses_caller_connection_without_opening_or_committing_it(tmp_path): + engine = create_sync_engine("sqlite:///:memory:") + try: + with engine.connect() as connection: + transaction = connection.begin() + config = migration_config(ini(tmp_path), tmp_path / "migrations", connection=connection) + assert config.attributes["connection"] is connection + assert connection.in_transaction() + assert not connection.closed + assert config.get_main_option("sqlalchemy.url") is None + transaction.rollback() + finally: + engine.dispose() diff --git a/pyproject.toml b/pyproject.toml index 4312019..17465d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,4 +1,2 @@ [tool.uv.workspace] -members = ["apps/shifts/backend", "apps/tournament/backend"] - -# Shared Python infrastructure is introduced only after proven duplication. +members = ["apps/shifts/backend", "apps/tournament/backend", "packages/crr-python"] diff --git a/pyrightconfig.json b/pyrightconfig.json index d4793f2..dfa9a98 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -1,8 +1,9 @@ { - "include": ["apps/shifts/backend/app", "apps/shifts/backend/tests", "apps/tournament/backend/app", "apps/tournament/backend/tests"], + "include": ["apps/shifts/backend/app", "apps/shifts/backend/tests", "apps/tournament/backend/app", "apps/tournament/backend/tests", "packages/crr-python/src", "packages/crr-python/tests"], "executionEnvironments": [ {"root": "apps/shifts/backend"}, - {"root": "apps/tournament/backend"} + {"root": "apps/tournament/backend"}, + {"root": "packages/crr-python", "extraPaths": ["packages/crr-python/src"]} ], "exclude": ["originals", ".migration", "**/node_modules", "**/__pycache__"], "venvPath": ".", diff --git a/tools/migration_baseline.py b/tools/migration_baseline.py index e9adf3f..d8231fd 100644 --- a/tools/migration_baseline.py +++ b/tools/migration_baseline.py @@ -19,6 +19,8 @@ IGNORED = {".git", "node_modules", ".venv", "__pycache__", ".pytest_cache", "dis def hashes(source: Path) -> dict[str, str]: + if not source.is_dir(): + raise FileNotFoundError(f"Baseline source directory is missing: {source}") return { str(path.relative_to(source)): hashlib.sha256(path.read_bytes()).hexdigest() for path in sorted(source.rglob("*")) diff --git a/tools/record_shared_infrastructure.py b/tools/record_shared_infrastructure.py new file mode 100644 index 0000000..4b9aa61 --- /dev/null +++ b/tools/record_shared_infrastructure.py @@ -0,0 +1,35 @@ +"""Record the exact infrastructure delegation that supersedes one Phase 4 AST.""" + +import ast +import hashlib +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def main(): + baseline_path = ROOT / "migration/phase4/baseline.json" + baseline = json.loads(baseline_path.read_text()) + database = ROOT / "apps/shifts/backend/app/database.py" + function = next(node for node in ast.parse(database.read_text()).body + if isinstance(node, ast.FunctionDef) and node.name == "alembic_config") + fingerprint = hashlib.sha256(ast.dump(function, include_attributes=False).encode()).hexdigest() + report = { + "baseline_sha256": hashlib.sha256(baseline_path.read_bytes()).hexdigest(), + "definitions": {"alembic_config": { + "before": baseline["definitions"]["alembic_config"], + "after": fingerprint, + "shared_helper": "crr_common.migrations.migration_config", + }}, + } + path = ROOT / "migration/phase5/shifts-definition-overrides.json" + if path.exists(): + raise FileExistsError(f"Refusing to overwrite {path}") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(report, indent=2) + "\n") + print("Recorded the shared Alembic-config delegation; all other Phase 4 definitions stay guarded.") + + +if __name__ == "__main__": + main() diff --git a/uv.lock b/uv.lock index 866983a..05bddb7 100644 --- a/uv.lock +++ b/uv.lock @@ -4,6 +4,7 @@ requires-python = "==3.13.*" [manifest] members = [ + "crr-python", "crr-shifts", "crr-tournament", ] @@ -126,12 +127,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "crr-python" +version = "0.1.0" +source = { editable = "packages/crr-python" } +dependencies = [ + { name = "alembic" }, + { name = "sqlalchemy" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "alembic", specifier = ">=1.13,<2" }, + { name = "sqlalchemy", specifier = ">=2.0,<3" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8,<9" }] + [[package]] name = "crr-shifts" version = "0.1.0" source = { virtual = "apps/shifts/backend" } dependencies = [ { name = "alembic" }, + { name = "crr-python" }, { name = "email-validator" }, { name = "fastapi" }, { name = "itsdangerous" }, @@ -153,6 +178,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.13,<2" }, + { name = "crr-python", editable = "packages/crr-python" }, { name = "email-validator", specifier = ">=2.2,<3" }, { name = "fastapi", specifier = ">=0.115,<1" }, { name = "itsdangerous", specifier = ">=2.2,<3" }, @@ -177,6 +203,7 @@ version = "1.0.0" source = { virtual = "apps/tournament/backend" } dependencies = [ { name = "alembic" }, + { name = "crr-python" }, { name = "fastapi" }, { name = "pydantic-settings" }, { name = "sqlalchemy" }, @@ -192,6 +219,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.13,<2" }, + { name = "crr-python", editable = "packages/crr-python" }, { name = "fastapi", specifier = ">=0.115,<1" }, { name = "pydantic-settings", specifier = ">=2.5,<3" }, { name = "sqlalchemy", specifier = ">=2.0,<3" },