feat: monorepo migration phase 5
App checks / changes (push) Canceled after 0s
App checks / tournament (push) Canceled after 0s
App checks / shifts (push) Canceled after 0s

This commit is contained in:
2026-10-07 15:20:27 +01:00
parent d534588e27
commit 66b72d1348
28 changed files with 406 additions and 26 deletions
+3
View File
@@ -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
+5 -2
View File
@@ -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
+2
View File
@@ -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
```
+1
View File
@@ -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/
+5
View File
@@ -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
+6 -7
View File
@@ -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:
+4
View File
@@ -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"]
+6 -1
View File
@@ -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.
+1
View File
@@ -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/
+5
View File
@@ -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
+6 -6
View File
@@ -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)
+4
View File
@@ -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"]
+58 -3
View File
@@ -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.
+16 -2
View File
@@ -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.")
@@ -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"
}
}
}
@@ -0,0 +1,10 @@
{
"baseline_sha256": "2ecd64d83d35d482f8eed7f46b65754f6db30d34c48a06943c0b9bd11ddf2b59",
"definitions": {
"alembic_config": {
"before": "2952ea6c5985853514aae3e8a7b74f0f1d0d49b56b45ca0c82874d7673ae574f",
"after": "e69c3eca19d4867bd29a0809a5676e41618033fe08be2920c9216fc359cb7440",
"shared_helper": "crr_common.migrations.migration_config"
}
}
}
+26
View File
@@ -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.
+21
View File
@@ -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"
@@ -0,0 +1 @@
"""Low-level infrastructure shared by the independently deployed CRR apps."""
@@ -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)
@@ -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
@@ -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}
@@ -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()
+1 -3
View File
@@ -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"]
+3 -2
View File
@@ -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": ".",
+2
View File
@@ -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("*"))
+35
View File
@@ -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()
Generated
+28
View File
@@ -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" },