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
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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": ".",
|
||||
|
||||
@@ -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("*"))
|
||||
|
||||
@@ -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()
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user