diff --git a/backend/app/downloads/runner.py b/backend/app/downloads/runner.py index bbcf3db3..54280c61 100644 --- a/backend/app/downloads/runner.py +++ b/backend/app/downloads/runner.py @@ -16,6 +16,7 @@ import shutil import subprocess import sys +import app.tables # noqa: F401 — a fresh interpreter: every table, before the first query from app import logs from app import logsink from app.config import settings diff --git a/backend/app/main.py b/backend/app/main.py index 1c5c00c6..f11aad8b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -28,6 +28,7 @@ log = logs.get_logger(__name__) from app import bodylimit, collation, logsink, state, sysconfig # noqa: E402 — logging is configured first from app.config import settings # noqa: E402 — logging is configured first from app.db import SessionLocal, engine # noqa: E402 — logging is configured first +import app.tables # noqa: E402 — every table, before the first query from app.downloads import service as download_service # noqa: E402 — logging is configured first from app.downloads import storage # noqa: E402 — logging is configured first from app.plex import stream as plex_stream # noqa: E402 — logging is configured first diff --git a/backend/app/worker.py b/backend/app/worker.py index 11db8327..ecad43a2 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -31,6 +31,7 @@ from functools import partial from sqlalchemy import text from sqlalchemy.orm import Session +import app.tables # noqa: F401 — every table, before the first query from app import logs from app import logsink from app import sysconfig diff --git a/backend/tests/test_entry_points.py b/backend/tests/test_entry_points.py new file mode 100644 index 00000000..c622b30f --- /dev/null +++ b/backend/tests/test_entry_points.py @@ -0,0 +1,63 @@ +"""Every process the app runs as sees every table before its first query. + +The units declare their tables in their own `models` modules, and a core model names a unit's by +string (`User.subscriptions` → `Subscription`). A fresh interpreter that never imported the unit's +module fails to configure its mappers on its first query: the download child once did exactly that, +and every job failed, while this suite stayed green — it imports `app.tables` in-process, which hides +the gap. So each entry point is imported here in a clean interpreter, and must register the whole +schema on its own. +""" + +import ast +import subprocess +import sys +from pathlib import Path + +import app.tables # noqa: F401 +from app.db import Base + +BACKEND = Path(__file__).parent.parent +APP = BACKEND / "app" + +# Each `python -m` module the deployment starts, and the API's app module. +ENTRY_POINTS = ("app.main", "app.worker", "app.downloads.runner") + +_PROBE = ( + "import importlib, sys\n" + "importlib.import_module(sys.argv[1])\n" + "from sqlalchemy.orm import configure_mappers\n" + "from app.db import Base\n" + "configure_mappers()\n" + "print('\\n'.join(sorted(Base.metadata.tables)))\n" +) + + +def test_each_entry_point_registers_the_whole_schema_on_its_own(): + whole = sorted(Base.metadata.tables) + for module in ENTRY_POINTS: + r = subprocess.run( + [sys.executable, "-c", _PROBE, module], + cwd=BACKEND, + capture_output=True, + text=True, + timeout=120, + check=False, + ) + assert r.returncode == 0, (module, r.stderr[-2000:]) + assert r.stdout.split() == whole, module + + +def test_every_module_that_runs_as_a_process_is_listed(): + mains = set() + for path in APP.rglob("*.py"): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in tree.body: + if ( + isinstance(node, ast.If) + and isinstance(node.test, ast.Compare) + and isinstance(node.test.left, ast.Name) + and node.test.left.id == "__name__" + ): + rel = path.relative_to(BACKEND).with_suffix("").as_posix() + mains.add(rel.replace("/", ".")) + assert mains <= set(ENTRY_POINTS), mains - set(ENTRY_POINTS) diff --git a/scripts/e2e_seed.py b/scripts/e2e_seed.py index e0c14551..c1f6e470 100644 --- a/scripts/e2e_seed.py +++ b/scripts/e2e_seed.py @@ -24,6 +24,7 @@ sys.path.insert(0, "/app") from sqlalchemy import delete, func, select from app.db import SessionLocal +import app.tables # noqa: F401 — every table, before the first query from app.catalog.models import Channel, Subscription, Video from app.models import AppState, User from app.reader.models import Playlist, PlaylistItem, VideoState