diff --git a/CLAUDE.md b/CLAUDE.md index aadb8405c..b455682da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,7 +98,7 @@ Meaningful codes when reading `make doctor` output: `SM001` missing meta (error) ## Tests & fixtures -Root `conftest.py` provides app-level fixtures available to every test directory: +The `simple_module_test` plugin provides app-level fixtures available to every test directory — auto-loaded via its `pytest11` entry point (defined in `framework/testing/simple_module_test/fixtures.py`), so the root `conftest.py` is intentionally thin: - `settings` — in-memory SQLite `Settings` with `multi_tenant=True`. - `db_state`, `engine`, `db_session` — fresh in-memory `DatabaseState` per test; `db_session` also creates all module tables and stamps `alembic_version` at head so the boot-time migration check passes. - `app` — `create_app(settings)` with lifespan started/stopped. diff --git a/README.md b/README.md index 5386a1c85..412851567 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,6 @@ Historical, point-in-time design docs live under [`docs/plans/`](docs/plans/) an ## Contributing -- Write tests with the fixtures in `conftest.py` (`db_session`, `authenticated_client`). +- Write tests with the fixtures from the `simple_module_test` plugin (`db_session`, `authenticated_client`). - Lint with `make lint` before pushing; CI runs all four checks in parallel. - Stick to the conventions in `docs/framework-conventions.md` — they're what diagnostics enforce. diff --git a/conftest.py b/conftest.py index e95e67dca..e4ecfb533 100644 --- a/conftest.py +++ b/conftest.py @@ -1,175 +1,15 @@ -"""Root conftest — shared fixtures available to all test directories.""" +"""Root conftest — intentionally thin. -from __future__ import annotations - -import contextlib -import importlib -from collections.abc import AsyncGenerator -from functools import lru_cache - -import httpx -import pytest -from simple_module_core.discovery import discover_modules -from simple_module_db.base import all_module_bases -from simple_module_db.session import DatabaseState, init_db -from simple_module_hosting.settings import Settings -from simple_module_test import forge_session_cookie -from sqlalchemy.ext.asyncio import ( - AsyncEngine, - AsyncSession, -) - - -@pytest.fixture -def settings() -> Settings: - """Settings configured for testing with in-memory SQLite. - - Multi-tenancy stays on so the existing ``TenantMiddleware`` tests - (and the ``X-Tenant-ID`` header paths they rely on) keep working. - Individual tests that want the tenant middleware absent construct - their own ``Settings(multi_tenant=False, ...)`` in the test body. - """ - return Settings( - database_url="sqlite+aiosqlite:///:memory:", - environment="testing", - secret_key="test-secret-key", - multi_tenant=True, - tenant_header="X-Tenant-ID", - ) - - -@pytest.fixture -async def db_state() -> AsyncGenerator[DatabaseState, None]: - """Create a fresh in-memory DatabaseState with listeners registered.""" - from simple_module_db.listeners import register_listeners - - state = init_db("sqlite+aiosqlite:///:memory:") - register_listeners(state) - yield state - await state.engine.dispose() - - -@pytest.fixture -async def engine(db_state: DatabaseState) -> AsyncEngine: - """Return the engine from the test DatabaseState.""" - return db_state.engine - - -@lru_cache(maxsize=1) -def _ensure_models_imported() -> list: - """Import all module models so all_module_bases is populated (cached).""" - for mod in discover_modules(): - pkg = type(mod).__module__.split(".")[0] - with contextlib.suppress(ModuleNotFoundError): - importlib.import_module(f"{pkg}.models") - return list(all_module_bases) - - -@lru_cache(maxsize=1) -def _alembic_head() -> str | None: - """Cached head revision — cannot change within a pytest run.""" - from simple_module_hosting.migrations import resolve_head_revision - - return resolve_head_revision() - - -async def _create_all_tables(engine) -> None: - """Create all module tables in a single connection. - - Also stamps the alembic_version table at head so the app's startup - migration check (``check_migrations``) treats the test DB as current. - Without the stamp the check would raise because ``create_all`` doesn't - touch alembic_version. - """ - from sqlalchemy import text - - bases = _ensure_models_imported() - head = _alembic_head() +The shared app/db/client fixtures (``settings``, ``db_state``, ``engine``, +``db_session``, ``app``, ``client``, ``authenticated_client``) live in the +``simple_module_test`` package and are auto-registered via its ``pytest11`` +entry point — installing the package is enough, no conftest import needed. They +used to be duplicated here; they were moved so the *published* plugin actually +ships what its README advertises (GH #200), and this repo now dogfoods that +plugin like any consumer would. - async with engine.begin() as conn: +Add genuinely repo-local fixtures here if the need arises; shared ones belong in +``framework/testing/simple_module_test/fixtures.py``. +""" - def _sync_create_all(sync_conn): - for base in bases: - base.metadata.create_all(sync_conn) - - await conn.run_sync(_sync_create_all) - - if head: - await conn.execute( - text( - "CREATE TABLE IF NOT EXISTS alembic_version " - "(version_num VARCHAR(32) NOT NULL PRIMARY KEY)" - ) - ) - await conn.execute(text("DELETE FROM alembic_version")) - await conn.execute( - text("INSERT INTO alembic_version (version_num) VALUES (:v)"), - {"v": head}, - ) - - -@pytest.fixture -async def db_session(db_state: DatabaseState) -> AsyncGenerator[AsyncSession, None]: - """Yield an async session backed by in-memory SQLite.""" - await _create_all_tables(db_state.engine) - - async with db_state.session_factory() as session: - yield session - - -@pytest.fixture -async def app(settings: Settings): - """Create a FastAPI app with tables pre-created and lifespan triggered.""" - from simple_module_hosting.app_builder import create_app - - application = create_app(settings) - - await _create_all_tables(application.state.sm.db.engine) - - # Trigger lifespan startup so app.state.migration is populated - ctx = application.router.lifespan_context(application) - await ctx.__aenter__() - - yield application - - # Lifespan shutdown disposes the engine - await ctx.__aexit__(None, None, None) - - -@pytest.fixture -async def client(app) -> AsyncGenerator[httpx.AsyncClient, None]: - """Unauthenticated async HTTP client.""" - transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient( - transport=transport, - base_url="http://testserver", - ) as c: - yield c - - -@pytest.fixture -async def authenticated_client(app) -> AsyncGenerator[httpx.AsyncClient, None]: - """HTTPX client with a signed session cookie carrying a seeded admin user's id.""" - from users.bootstrap import create_admin - - async with app.state.sm.db.session_factory() as session: - result = await create_admin( - session, - email="admin@test", - password="test-password", - full_name="Test Admin", - ) - user_id = str(result.user.id) - - signed = forge_session_cookie( - app.state.sm.settings.secret_key, - {"user_id": user_id}, - ) - - transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient( - transport=transport, - base_url="http://testserver", - cookies={"session": signed}, - ) as c: - yield c +from __future__ import annotations diff --git a/docs/database/sessions.md b/docs/database/sessions.md index ca8941c42..9d261a7ad 100644 --- a/docs/database/sessions.md +++ b/docs/database/sessions.md @@ -110,7 +110,7 @@ Cross-tenant admin work runs outside a tenant-scoped request (a CLI or worker wh ## Sessions in tests -The `db_session` fixture in `conftest.py` creates a fresh in-memory SQLite DB, creates every module's tables, stamps `alembic_version` at head, and yields an `AsyncSession`. Each test gets a fresh one — no shared state, no transaction rollback hacks. +The `db_session` fixture from the `simple_module_test` plugin creates a fresh in-memory SQLite DB, creates every module's tables, stamps `alembic_version` at head, and yields an `AsyncSession`. Each test gets a fresh one — no shared state, no transaction rollback hacks. ```python @pytest.mark.asyncio diff --git a/docs/framework/events.md b/docs/framework/events.md index f8c49de01..1bf5c0bf9 100644 --- a/docs/framework/events.md +++ b/docs/framework/events.md @@ -146,7 +146,7 @@ async def test_place_order_publishes_event(db_session, app): assert received[0].customer_email == "a@b.c" ``` -The `app` fixture from `conftest.py` provides a fresh app with a fresh `EventBus` per test. +The `app` fixture from the `simple_module_test` plugin provides a fresh app with a fresh `EventBus` per test. ## Design guidelines diff --git a/docs/framework/permissions.md b/docs/framework/permissions.md index eb2fced29..c04e2b8c5 100644 --- a/docs/framework/permissions.md +++ b/docs/framework/permissions.md @@ -123,7 +123,7 @@ export default function OrdersToolbar() { ## Testing with permissions -The `authenticated_client` fixture in `conftest.py` seeds an admin user (who has `*`). For tests that need a less-privileged user, build one from the `users` module fixtures or flip the principal temporarily: +The `authenticated_client` fixture from the `simple_module_test` plugin seeds an admin user (who has `*`). For tests that need a less-privileged user, build one from the `users` module fixtures or flip the principal temporarily: ```python @pytest.mark.asyncio diff --git a/docs/guide/first-module.md b/docs/guide/first-module.md index 990a0d9a9..e5abb49b3 100644 --- a/docs/guide/first-module.md +++ b/docs/guide/first-module.md @@ -287,7 +287,7 @@ async def test_create_and_list_orders(authenticated_client): assert any(o["id"] == created["id"] for o in r.json()) ``` -The `authenticated_client` fixture from `conftest.py` seeds an admin user and carries a signed session cookie. The `db_session` fixture creates all module tables and stamps the Alembic head so the boot-time migration check passes. See [Fixtures](/testing/fixtures). +The `authenticated_client` fixture from the `simple_module_test` plugin seeds an admin user and carries a signed session cookie. The `db_session` fixture creates all module tables and stamps the Alembic head so the boot-time migration check passes. See [Fixtures](/testing/fixtures). Run: diff --git a/docs/index.md b/docs/index.md index 1a09a3c47..164799f26 100644 --- a/docs/index.md +++ b/docs/index.md @@ -54,7 +54,7 @@ features:

Framework

Discovery, lifecycle hooks, middleware, permissions, events, i18n.

Database

SQLModel conventions, per-module Base, mixins, sessions, Alembic.

Frontend

Inertia page keys, shared props, page discovery, React layout.

-

Testing

The fixtures in conftest.py, unit tests, end-to-end tests.

+

Testing

The simple_module_test plugin fixtures, unit tests, end-to-end tests.

Modules

Reference for each bundled module: routes, contracts, settings.

Reference

CLI commands, env vars, diagnostic codes, deployment.

diff --git a/docs/module-authoring.md b/docs/module-authoring.md index 59575de45..f28fc8929 100644 --- a/docs/module-authoring.md +++ b/docs/module-authoring.md @@ -277,6 +277,11 @@ The package registers pytest fixtures via a `pytest11` entry_point — no |---|---| | `build_test_app` | Callable `(ModuleCls) -> FastAPI` — wraps a single module in a minimal FastAPI app with its routes registered. | | `fake_event_bus` | A `FakeEventBus` that records every `publish`/`publish_nowait` call so tests can assert emitted events. | +| `settings` | In-memory-SQLite `Settings` (`multi_tenant=True`) for the test app. | +| `db_state` / `engine` / `db_session` | Fresh in-memory `DatabaseState` per test; `db_session` creates every installed module's tables and stamps `alembic_version` at head. | +| `app` | A full `create_app(settings)` with lifespan started/stopped. | +| `client` | `httpx.AsyncClient` bound to the test app (anonymous). | +| `authenticated_client` | Same, with a seeded admin + signed session cookie. Requires the `users` module installed (seeds via `users.bootstrap`). | Example test: diff --git a/docs/modules/background_tasks.md b/docs/modules/background_tasks.md index 49dab57dd..01cbfb898 100644 --- a/docs/modules/background_tasks.md +++ b/docs/modules/background_tasks.md @@ -32,6 +32,32 @@ send_receipt.delay(order_id=42) Every dispatch / start / completion / failure / retry / revoke is recorded in the `background_tasks_task_execution` table by Celery signal handlers, so you can see history even after the Celery result backend has expired the result. +## Scheduling periodic work (beat) + +A module schedules recurring work the same way it registers tasks — by shipping a `tasks.py` — and additionally exporting a module-level `BEAT_SCHEDULE` dict. `build_celery` merges every installed module's `BEAT_SCHEDULE` into the beat schedule at boot (identically in the web and worker processes), so `make beat` runs them alongside the built-ins: + +```python +# modules/invoices/invoices/tasks.py +from celery import shared_task +from celery.schedules import crontab + +@shared_task(name="invoices.generate_recurring") +def generate_recurring() -> int: + ... + +# Discovered by build_celery and merged into the beat schedule. +BEAT_SCHEDULE = { + "invoices-generate-recurring-daily": { + "task": "invoices.generate_recurring", + "schedule": crontab(hour=6, minute=0), # 0 6 * * * + }, +} +``` + +Entry values are plain Celery [beat entries](https://docs.celeryq.dev/en/stable/userguide/periodic-tasks.html#entries): `schedule` accepts a number of seconds, a `crontab(...)`, or a `solar(...)`. The two built-in entry names (`background-tasks-sweep-stuck`, `background-tasks-purge-old`) are authoritative — a module reusing one is ignored with a warning. + +> **Don't reach for `from celery.signals import on_after_configure`.** It's an *app-instance* signal (not a member of `celery.signals`, so the import raises), and `build_celery` runs `conf.update(...)` — which fires it — *before* `autodiscover_tasks` imports your `tasks.py`, so a handler would miss the window. The declarative `BEAT_SCHEDULE` dict above is the supported mechanism. If you need entries computed at runtime, Celery's `@app.on_after_finalize.connect` + `sender.add_periodic_task(...)` also works. + ## Running workers | Command | Use case | @@ -145,6 +171,8 @@ Bootstrap env-var equivalents (`SM_BG_TASKS_*`) only seed pydantic defaults at f A `background_tasks.demo_echo` task is also registered for smoke tests; not scheduled. +Other modules contribute their own periodic entries via `tasks.BEAT_SCHEDULE` — see [Scheduling periodic work (beat)](#scheduling-periodic-work-beat). + ## Events Published (and consumable from any other module): diff --git a/docs/modules/dashboard.md b/docs/modules/dashboard.md index 52dce4cfc..3fe04b25c 100644 --- a/docs/modules/dashboard.md +++ b/docs/modules/dashboard.md @@ -67,6 +67,18 @@ Top-level keys in `dashboard/locales/en.json`: - `home.system_info_title`, `home.system_info.python_version`, `home.system_info.health_checks`, `home.system_info.modules` - `home.welcome_card_title`, `home.description_body` +## Extending it + +The bundled dashboard renders a **fixed** set of cards — `dashboard/stats.py` returns a hardcoded shape and the module exposes only `register_routes` + `register_menu_items`. This is intentional: there is **no** `register_dashboard_cards` hook or card/widget registry, and the module deliberately keeps no extension surface so it stays a small, predictable landing template rather than a framework subsystem to maintain. + +To show app-specific tiles, **build your own dashboard page in your module** rather than contributing to this one: + +- Add an Inertia view route + page (`register_routes` → `pages/Home.tsx`) and a sidebar entry (`register_menu_items`), exactly like any other module page. +- Make it the post-login landing page by pointing `users.login_redirect_url` at your route (see [Replacing it](#replacing-it)). You can drop the bundled dashboard entirely via `SM_MODULES_ENABLED` minus `dashboard`. +- Your page can still reuse this module's data — call `GET /api/dashboard/stats` for the system overview alongside your own module's endpoints. + +> Resolved as **by design** (GH #203): consumer modules build their own dashboard page; the bundled one is not a contribution point. + ## Replacing it If you want a different post-login landing page, set `users.login_redirect_url` in the [admin settings UI](/modules/settings) (or via `smpy settings import-from-env` from `SM_USERS_LOGIN_REDIRECT_URL`) to your route. You can keep the dashboard module installed for the menu entry, or set `SM_MODULES_ENABLED` without `dashboard` to drop it entirely. The `users` module auto-detects whether `dashboard` is installed; if not, it redirects to the first other module that exposes view routes (e.g. the GIS module on apps like smpy_gis), falling back to `/` only as a last resort. diff --git a/docs/testing/fixtures.md b/docs/testing/fixtures.md index d939a1f16..dfa274c52 100644 --- a/docs/testing/fixtures.md +++ b/docs/testing/fixtures.md @@ -1,6 +1,6 @@ # Fixtures -The root `conftest.py` provides app-level fixtures that every test directory inherits. Everything you need for a typical integration test — app, DB session, HTTP client, authenticated client — is already wired up. +The `simple_module_test` plugin provides app-level fixtures that every test directory inherits — auto-loaded via its `pytest11` entry point, so installing the package is enough (no `conftest.py` import needed). Everything you need for a typical integration test — app, DB session, HTTP client, authenticated client — is already wired up. ## Available fixtures @@ -90,6 +90,8 @@ Same as `client`, but the fixture also: 2. Forges a signed session cookie for that user. 3. Attaches the cookie to the client. +> Because it seeds via `users.bootstrap`, this fixture requires the `users` module to be installed (the import is deferred to the fixture body, so the rest of the plugin loads without it). Apps scaffolded by `smpy` include `users`. + ```python @pytest.mark.asyncio async def test_create_order_as_admin(authenticated_client): diff --git a/docs/testing/overview.md b/docs/testing/overview.md index 9e3f81e5a..6c6506861 100644 --- a/docs/testing/overview.md +++ b/docs/testing/overview.md @@ -29,7 +29,8 @@ The root `pyproject.toml` sets `asyncio_mode = "auto"`. Async tests don't need ` ## File layout ```text -conftest.py # root fixtures: app, db_session, client, authenticated_client +conftest.py # intentionally thin — shared fixtures ship in the simple_module_test plugin +framework/testing/ # simple_module_test plugin: app, db_session, client, authenticated_client fixtures framework//tests/ # tests against each framework package (core, db, hosting, cli, testing) host/tests/ # host-level tests modules//tests/ # per-module pytest tests @@ -93,5 +94,5 @@ Before marking a test flaky, check: ## Next steps -- [Fixtures](/testing/fixtures) — the shared fixtures in `conftest.py` and how to extend them. +- [Fixtures](/testing/fixtures) — the shared fixtures from the `simple_module_test` plugin and how to extend them. - [E2E tests](/e2e-testing) — the Playwright suite. diff --git a/framework/cli/simple_module_cli/cli.py b/framework/cli/simple_module_cli/cli.py index 8b6674063..cec0998cc 100644 --- a/framework/cli/simple_module_cli/cli.py +++ b/framework/cli/simple_module_cli/cli.py @@ -56,7 +56,17 @@ def create_host( target = dest or Path.cwd() / name selected = [m.strip() for m in modules.split(",") if m.strip()] try: - _create_host(target, name=name, modules=selected) + # Pin the host's framework + module deps to the installed framework + # version so the generated host's first `uv sync` resolves (the + # template's >=1.0,<2.0 / >=0.1,<1.0 ranges don't exist pre-1.0). The + # workspace `smpy new` path rewrites these via _rewrite_pyproject, but + # standalone create-host never did — see GH #206. + _create_host( + target, + name=name, + modules=selected, + framework_version=resolve_framework_version(), + ) except FileExistsError as exc: typer.echo(f"ERROR: {exc}", err=True) raise typer.Exit(code=1) from exc diff --git a/framework/cli/simple_module_cli/pins.py b/framework/cli/simple_module_cli/pins.py new file mode 100644 index 000000000..f711b9a72 --- /dev/null +++ b/framework/cli/simple_module_cli/pins.py @@ -0,0 +1,66 @@ +"""Resolve and pin the framework version that scaffolded apps depend on. + +Kept separate from :mod:`simple_module_cli.scaffolding` (template +materialization) because version resolution + dependency pinning is its own +concern: the scaffolders, ``cli`` commands, and ``app_project`` all reach for +it independently. The module templates ship forward-looking ``simple_module_*`` +ranges (``>=1.0,<2.0`` / ``>=0.1,<1.0``) against the framework's eventual stable +line, but the published distributions are pre-1.0 (``0.0.x``), so those ranges +resolve to nothing on PyPI. Rewriting them to an exact ``==`` pin lets a freshly +scaffolded app/module resolve against the framework version that created it. +See GH #195 / #206. +""" + +from __future__ import annotations + +from pathlib import Path + + +def resolve_framework_version() -> str: + """Resolve the framework version that scaffolded apps should pin against. + + The CLI ships in lockstep with the rest of the framework (one + ``bump_version.py`` rewrites every ``pyproject.toml``), so its own + installed distribution version is the source of truth. Falls back to a + placeholder for editable installs lacking dist-info — never reached from a + release wheel. + """ + from importlib.metadata import PackageNotFoundError + from importlib.metadata import version as pkg_version + + try: + return pkg_version("simple_module_cli") + except PackageNotFoundError: + return "0.0.0" + + +def _pin_one(dep: str, version: str) -> str: + """Pin a single ``simple_module_*`` requirement to ``==version``; else pass through.""" + pkg = dep.split(">=", 1)[0].split("==", 1)[0].split("<", 1)[0].strip() + if pkg.startswith(("simple_module_", "simple-module-")): + return f"{pkg}=={version}" + return dep + + +def pin_framework_deps(pyproject_path: Path, version: str) -> None: + """Pin every ``simple_module_*`` requirement in a pyproject to ``==version``. + + Rewrites both ``dependencies`` and every ``optional-dependencies`` extra + (the module template's ``dev`` extra pins ``simple_module_test``). Used by + ``create_module`` and ``create_host`` so a freshly scaffolded package + resolves against the framework version that created it. See GH #195 / #206. + """ + import tomlkit + + doc = tomlkit.parse(pyproject_path.read_text(encoding="utf-8")) + project = doc.get("project") + if project is None: + return + deps = project.get("dependencies") + if deps is not None: + project["dependencies"] = [_pin_one(dep, version) for dep in deps] + optional = project.get("optional-dependencies") + if optional is not None: + for extra, items in list(optional.items()): + optional[extra] = [_pin_one(dep, version) for dep in items] + pyproject_path.write_text(tomlkit.dumps(doc), encoding="utf-8") diff --git a/framework/cli/simple_module_cli/scaffolding.py b/framework/cli/simple_module_cli/scaffolding.py index 22652829c..fad870307 100644 --- a/framework/cli/simple_module_cli/scaffolding.py +++ b/framework/cli/simple_module_cli/scaffolding.py @@ -28,6 +28,10 @@ validate_scaffold_name, ) +# Re-exported so the scaffolders and their long-standing callers keep importing +# version pinning from one place; the implementations live in pins.py. +from simple_module_cli.pins import pin_framework_deps, resolve_framework_version + __all__ = [ "SAFE_PRESERVED_NAMES", "create_host", @@ -57,57 +61,15 @@ def _module_to_pypi_name(name: str) -> str: return f"simple_module_{name.lower()}" -def resolve_framework_version() -> str: - """Resolve the framework version that scaffolded apps should pin against. - - The CLI ships in lockstep with the rest of the framework (one - ``bump_version.py`` rewrites every ``pyproject.toml``), so its own - installed distribution version is the source of truth. Falls back to a - placeholder for editable installs lacking dist-info — never reached from a - release wheel. - """ - from importlib.metadata import PackageNotFoundError - from importlib.metadata import version as pkg_version - - try: - return pkg_version("simple_module_cli") - except PackageNotFoundError: - return "0.0.0" - - -def _pin_one(dep: str, version: str) -> str: - """Pin a single ``simple_module_*`` requirement to ``==version``; else pass through.""" - pkg = dep.split(">=", 1)[0].split("==", 1)[0].split("<", 1)[0].strip() - if pkg.startswith(("simple_module_", "simple-module-")): - return f"{pkg}=={version}" - return dep - - -def pin_framework_deps(pyproject_path: Path, version: str) -> None: - """Pin every ``simple_module_*`` requirement in a pyproject to ``==version``. +def _should_pin_framework_version(version: str | None) -> bool: + """Whether ``version`` is a concrete pin rather than a skip sentinel. - The module template ships forward-looking ranges (``>=1.0,<2.0``) against - the framework's eventual stable line, but the published distributions are - pre-1.0 (``0.0.x``), so those ranges resolve to nothing on PyPI. Rewriting - to an exact pin lets a freshly created module resolve against the framework - version that created it — e.g. ``uv add ./modules/`` into the same - workspace. Both ``dependencies`` and every ``optional-dependencies`` extra - (the ``dev`` extra pins ``simple_module_test``) are rewritten. See GH #195. + Both scaffolders skip pinning for ``None`` (a library caller that wants the + template's ranges kept verbatim) and ``"*"`` (the npm-wildcard default, + which would otherwise render the invalid PEP 508 specifier ``==*``). One + rule, so the two paths can't drift. See GH #195 / #206. """ - import tomlkit - - doc = tomlkit.parse(pyproject_path.read_text(encoding="utf-8")) - project = doc.get("project") - if project is None: - return - deps = project.get("dependencies") - if deps is not None: - project["dependencies"] = [_pin_one(dep, version) for dep in deps] - optional = project.get("optional-dependencies") - if optional is not None: - for extra, items in list(optional.items()): - optional[extra] = [_pin_one(dep, version) for dep in items] - pyproject_path.write_text(tomlkit.dumps(doc), encoding="utf-8") + return bool(version) and version != "*" def _iter_template_files(template_root: Path): @@ -242,6 +204,12 @@ def create_host( }, preserve_existing=preserve_existing, ) + # Pin every simple_module_* host dep (framework packages *and* selected + # bundled modules) to the lockstep version so the host's first `uv sync` + # resolves — the template's >=1.0,<2.0 / >=0.1,<1.0 ranges match nothing + # against pre-1.0 dists. See GH #206. + if _should_pin_framework_version(framework_version): + pin_framework_deps(dest / "pyproject.toml", framework_version) logger.info( "Scaffolded host '%s' at %s (modules: %s)", name, dest, ", ".join(modules) or "" ) @@ -257,11 +225,11 @@ def create_module( ) -> Path: """Scaffold a module package at ``dest``. - When ``framework_version`` is given, the template's forward-looking - ``simple_module_*`` ranges are rewritten to an exact pin so the module - resolves against that framework version (e.g. ``uv add`` into the workspace - that created it). Left as ``None``, the template's ranges are kept verbatim. - See GH #195. + When ``framework_version`` is a concrete version, the template's + forward-looking ``simple_module_*`` ranges are rewritten to an exact pin so + the module resolves against that framework version (e.g. ``uv add`` into the + workspace that created it). Left as ``None`` (or the ``"*"`` sentinel), the + template's ranges are kept verbatim. See GH #195. """ dest = Path(dest) existed_before = dest.exists() @@ -281,7 +249,7 @@ def create_module( }, path_rewrites={_PACKAGE_PATH_TOKEN: package_name}, ) - if framework_version is not None: + if _should_pin_framework_version(framework_version): pin_framework_deps(dest / "pyproject.toml", framework_version) except Exception: # Rollback so a half-scaffolded directory doesn't leave the user diff --git a/framework/cli/tests/test_module_pages_manifest.py b/framework/cli/tests/test_module_pages_manifest.py new file mode 100644 index 000000000..23079665b --- /dev/null +++ b/framework/cli/tests/test_module_pages_manifest.py @@ -0,0 +1,70 @@ +"""Tests for the module-pages manifest emitted by `smpy gen-pages`.""" + +from __future__ import annotations + +import re + + +class TestModulePagesManifest: + async def test_compute_returns_existing_page_dirs(self): + """Returns {ModuleName: Path} for installed modules that ship a pages/ dir.""" + from simple_module_core import discover_modules + from simple_module_hosting.manifest import compute_module_pages + + modules = discover_modules() + result = compute_module_pages(modules) + + # Dashboard ships pages/; Auth is API-only (no frontend pages). + assert "Dashboard" in result + assert "Auth" not in result + for name, path in result.items(): + assert path.is_dir(), f"{name} -> {path} should exist" + assert path.name == "pages" + + async def test_compute_skips_modules_without_pages_dir(self, tmp_path, monkeypatch): + """A module whose package has no pages/ dir is omitted (not an error).""" + from simple_module_core import ModuleBase, ModuleMeta + from simple_module_hosting.manifest import compute_module_pages + + class HeadlessMod(ModuleBase): + meta = ModuleMeta(name="Headless") + + result = compute_module_pages([HeadlessMod()]) + assert "Headless" not in result + + async def test_write_manifest_emits_json_and_ts(self, tmp_path): + """write_module_pages_manifest emits the JSON manifest, TS glob, and Tailwind CSS files.""" + import json + + from simple_module_core import discover_modules + from simple_module_hosting.manifest import write_module_pages_manifest + + modules = discover_modules() + written = write_module_pages_manifest(modules, tmp_path) + + manifest = tmp_path / "modules.manifest.json" + generated = tmp_path / "modules.generated.ts" + css = tmp_path / "modules.generated.css" + assert manifest.is_file() + assert generated.is_file() + assert css.is_file() + assert written == {"manifest": manifest, "generated": generated, "css": css} + + data = json.loads(manifest.read_text(encoding="utf-8")) + assert "Dashboard" in data + assert data["Dashboard"].endswith("pages") or data["Dashboard"].endswith("pages/") + + ts = generated.read_text(encoding="utf-8") + assert "import.meta.glob" in ts + assert "Dashboard" in ts + assert "AUTO-GENERATED" in ts or "auto-generated" in ts.lower() + # Glob patterns must be relative to output_dir — Vite treats + # leading-slash paths as project-root-relative and silently matches + # nothing for FS-absolute paths. + for match in re.findall(r'import\.meta\.glob\("([^"]+)"\)', ts): + assert match.startswith(("./", "../")), ( + f"glob pattern {match!r} must be relative, not absolute" + ) + + css_text = css.read_text(encoding="utf-8") + assert "AUTO-GENERATED" in css_text or "auto-generated" in css_text.lower() diff --git a/framework/cli/tests/test_scaffolding_host.py b/framework/cli/tests/test_scaffolding_host.py index b2e4c3da6..edad462dd 100644 --- a/framework/cli/tests/test_scaffolding_host.py +++ b/framework/cli/tests/test_scaffolding_host.py @@ -1,77 +1,13 @@ -"""Tests for the module-pages manifest and `smpy create-host` scaffolding.""" +"""Tests for `smpy create-host` scaffolding. -from __future__ import annotations +Module-pages manifest tests live in ``test_module_pages_manifest.py``. +""" -import re +from __future__ import annotations import pytest -class TestModulePagesManifest: - async def test_compute_returns_existing_page_dirs(self): - """Returns {ModuleName: Path} for installed modules that ship a pages/ dir.""" - from simple_module_core import discover_modules - from simple_module_hosting.manifest import compute_module_pages - - modules = discover_modules() - result = compute_module_pages(modules) - - # Dashboard ships pages/; Auth is API-only (no frontend pages). - assert "Dashboard" in result - assert "Auth" not in result - for name, path in result.items(): - assert path.is_dir(), f"{name} -> {path} should exist" - assert path.name == "pages" - - async def test_compute_skips_modules_without_pages_dir(self, tmp_path, monkeypatch): - """A module whose package has no pages/ dir is omitted (not an error).""" - from simple_module_core import ModuleBase, ModuleMeta - from simple_module_hosting.manifest import compute_module_pages - - class HeadlessMod(ModuleBase): - meta = ModuleMeta(name="Headless") - - result = compute_module_pages([HeadlessMod()]) - assert "Headless" not in result - - async def test_write_manifest_emits_json_and_ts(self, tmp_path): - """write_module_pages_manifest emits the JSON manifest, TS glob, and Tailwind CSS files.""" - import json - - from simple_module_core import discover_modules - from simple_module_hosting.manifest import write_module_pages_manifest - - modules = discover_modules() - written = write_module_pages_manifest(modules, tmp_path) - - manifest = tmp_path / "modules.manifest.json" - generated = tmp_path / "modules.generated.ts" - css = tmp_path / "modules.generated.css" - assert manifest.is_file() - assert generated.is_file() - assert css.is_file() - assert written == {"manifest": manifest, "generated": generated, "css": css} - - data = json.loads(manifest.read_text(encoding="utf-8")) - assert "Dashboard" in data - assert data["Dashboard"].endswith("pages") or data["Dashboard"].endswith("pages/") - - ts = generated.read_text(encoding="utf-8") - assert "import.meta.glob" in ts - assert "Dashboard" in ts - assert "AUTO-GENERATED" in ts or "auto-generated" in ts.lower() - # Glob patterns must be relative to output_dir — Vite treats - # leading-slash paths as project-root-relative and silently matches - # nothing for FS-absolute paths. - for match in re.findall(r'import\.meta\.glob\("([^"]+)"\)', ts): - assert match.startswith(("./", "../")), ( - f"glob pattern {match!r} must be relative, not absolute" - ) - - css_text = css.read_text(encoding="utf-8") - assert "AUTO-GENERATED" in css_text or "auto-generated" in css_text.lower() - - class TestCreateHost: async def test_creates_expected_backend_files(self, tmp_path): """create_host writes the full backend + frontend scaffold.""" @@ -144,6 +80,65 @@ async def test_refuses_existing_non_empty_dir(self, tmp_path): with pytest.raises(FileExistsError): create_host(dest, name="demo", modules=[]) + async def test_pins_framework_deps_when_version_given(self, tmp_path): + """Regression #206: create_host(..., framework_version=X) rewrites the + template's >=1.0,<2.0 framework ranges (and the >=0.1,<1.0 selected-module + ranges) to ==X so the generated host's first `uv sync` resolves against the + lockstep-published framework version — pre-1.0 dists satisfy neither range.""" + from simple_module_cli.scaffolding import create_host + + dest = tmp_path / "demo" + create_host(dest, name="demo", modules=["Dashboard"], framework_version="0.0.18") + pyproject = (dest / "pyproject.toml").read_text(encoding="utf-8") + + for pkg in ( + "simple_module_core", + "simple_module_db", + "simple_module_hosting", + "simple_module_settings", + ): + assert f"{pkg}==0.0.18" in pyproject + assert f"{pkg}>=1.0,<2.0" not in pyproject + # Selected module deps are pinned to the same lockstep version too — + # the template's >=0.1,<1.0 range is itself unsatisfiable at 0.0.x. + assert "simple_module_dashboard==0.0.18" in pyproject + assert "simple_module_dashboard>=0.1,<1.0" not in pyproject + # Non-framework deps are left untouched. + assert "uvicorn[standard]>=0.34" in pyproject + + async def test_keeps_ranges_without_version(self, tmp_path): + """Without framework_version the template ranges are kept verbatim, so + direct-library callers that don't want an exact pin (and the existing + scaffolding tests) are unaffected.""" + from simple_module_cli.scaffolding import create_host + + dest = tmp_path / "demo" + create_host(dest, name="demo", modules=["Dashboard"]) + pyproject = (dest / "pyproject.toml").read_text(encoding="utf-8") + assert "simple_module_core>=1.0,<2.0" in pyproject + assert "simple_module_dashboard>=0.1,<1.0" in pyproject + + async def test_cli_create_host_pins_to_framework_version(self, tmp_path): + """The `smpy create-host` command pins framework + module deps so the + generated host's first `uv sync` resolves against the lockstep release + that scaffolded it (#206).""" + from simple_module_cli.cli import app + from simple_module_cli.scaffolding import resolve_framework_version + from typer.testing import CliRunner + + runner = CliRunner() + dest = tmp_path / "out" + result = runner.invoke( + app, ["create-host", "demo", "--dest", str(dest), "--with", "Dashboard"] + ) + assert result.exit_code == 0, result.output + + pyproject = (dest / "pyproject.toml").read_text(encoding="utf-8") + version = resolve_framework_version() + assert f"simple_module_core=={version}" in pyproject + assert "simple_module_core>=1.0,<2.0" not in pyproject + assert f"simple_module_dashboard=={version}" in pyproject + async def test_env_py_uses_shared_helper(self, tmp_path): """Scaffolded migrations/env.py delegates to the shared helper, not inline logic.""" from simple_module_cli.scaffolding import create_host diff --git a/framework/cli/tests/test_scaffolding_module.py b/framework/cli/tests/test_scaffolding_module.py index c17651385..445385858 100644 --- a/framework/cli/tests/test_scaffolding_module.py +++ b/framework/cli/tests/test_scaffolding_module.py @@ -118,6 +118,18 @@ async def test_create_module_keeps_ranges_without_version(self, tmp_path): pyproject = (dest / "pyproject.toml").read_text(encoding="utf-8") assert "simple_module_core>=1.0,<2.0" in pyproject + async def test_create_module_treats_star_as_skip_not_a_pin(self, tmp_path): + """The ``"*"`` sentinel keeps the template ranges (same as ``None``) — it + must NOT render the invalid PEP 508 specifier ``==*``. Guards the + harmonized pin rule shared with create_host.""" + from simple_module_cli.scaffolding import create_module + + dest = tmp_path / "simple-module-orders" + create_module(dest, name="Orders", framework_version="*") + pyproject = (dest / "pyproject.toml").read_text(encoding="utf-8") + assert "simple_module_core>=1.0,<2.0" in pyproject + assert "==*" not in pyproject + async def test_cli_create_module_pins_to_framework_version(self, tmp_path): """The `smpy create-module` command pins framework deps so `uv add` resolves into the app that created it (#195).""" diff --git a/framework/testing/README.md b/framework/testing/README.md index 90c0b6d23..9425f5a8e 100644 --- a/framework/testing/README.md +++ b/framework/testing/README.md @@ -14,11 +14,15 @@ pip install "simple_module_hosting[dev]" ## What it provides +All fixtures below are auto-registered via the `pytest11` entry point — installing the package is enough. + +- `fake_event_bus` — a fresh recording `EventBus` per test that captures every `publish`/`publish_nowait` call. +- `build_test_app` — callable returning a minimal FastAPI app that loads exactly one module. - `settings` — a ready-to-use `Settings` instance with an in-memory SQLite database and multi-tenancy enabled. - `db_state`, `engine`, `db_session` — fresh `DatabaseState` per test; `db_session` also creates all module tables and stamps `alembic_version` at head so the boot-time migration check passes. - `app` — a `create_app(settings)` instance with `lifespan` started and stopped. - `client` — an `httpx.AsyncClient` bound to the test app. -- `authenticated_client` — same but with an admin user seeded and a forged session cookie attached. +- `authenticated_client` — same but with an admin user seeded and a forged session cookie attached. **Requires the `users` module** to be installed (it seeds the admin via `users.bootstrap`); apps scaffolded by `smpy` include it. ## Usage diff --git a/framework/testing/simple_module_test/__init__.py b/framework/testing/simple_module_test/__init__.py index 6535bc993..a658960c5 100644 --- a/framework/testing/simple_module_test/__init__.py +++ b/framework/testing/simple_module_test/__init__.py @@ -12,9 +12,12 @@ * :func:`build_test_app` — return a minimal FastAPI app loading exactly one module, with an in-memory SQLite DB. -The corresponding pytest fixtures are ``fake_event_bus``, ``test_app``, -``test_client``, and ``test_db_session``. See ``simple_module_test.plugin`` -for their definitions. +The pytest plugin auto-registers these fixtures (no imports needed): +``fake_event_bus``, ``build_test_app``, ``settings``, ``db_state``, +``engine``, ``db_session``, ``app``, ``client``, and ``authenticated_client``. +See ``simple_module_test.plugin`` (and ``simple_module_test.fixtures``) for +their definitions. ``authenticated_client`` additionally requires the ``users`` +module to be installed — it seeds an admin via ``users.bootstrap``. """ from simple_module_test.app_factory import build_test_app diff --git a/framework/testing/simple_module_test/fixtures.py b/framework/testing/simple_module_test/fixtures.py new file mode 100644 index 000000000..c827adc98 --- /dev/null +++ b/framework/testing/simple_module_test/fixtures.py @@ -0,0 +1,197 @@ +"""Headline app/db/client fixtures shipped by the pytest plugin. + +These are the fixtures the README advertises — ``settings``, ``db_state``, +``engine``, ``db_session``, ``app``, ``client``, ``authenticated_client`` — +made available to any test run in an environment that installs +``simple_module_test`` (the ``pytest11`` entry point re-exports them from +:mod:`simple_module_test.plugin`). They build a real ``create_app`` against an +in-memory SQLite DB with every installed module's tables created and the +``alembic_version`` row stamped at head, so the boot-time migration check +passes. + +Module-scope imports here reference only framework packages +(``simple_module_core`` / ``simple_module_db`` / ``simple_module_hosting``), +which are this package's declared dependencies. The one plugin-module coupling +— ``authenticated_client`` seeding an admin via ``users.bootstrap`` — is a lazy +import inside the fixture body, so importing this module never requires the +``users`` module to be installed (mirroring ``plugin._bootstrap_eager_celery``, +which soft-imports ``background_tasks``). +""" + +from __future__ import annotations + +import contextlib +import importlib +from collections.abc import AsyncGenerator +from functools import lru_cache + +import httpx +import pytest +from simple_module_core.discovery import discover_modules +from simple_module_db.base import all_module_bases +from simple_module_db.session import DatabaseState, init_db +from simple_module_hosting.settings import Settings +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession + +from simple_module_test.session_cookie import forge_session_cookie + + +@pytest.fixture +def settings() -> Settings: + """Settings configured for testing with in-memory SQLite. + + Multi-tenancy stays on so the existing ``TenantMiddleware`` tests + (and the ``X-Tenant-ID`` header paths they rely on) keep working. + Individual tests that want the tenant middleware absent construct + their own ``Settings(multi_tenant=False, ...)`` in the test body. + """ + return Settings( + database_url="sqlite+aiosqlite:///:memory:", + environment="testing", + secret_key="test-secret-key", + multi_tenant=True, + tenant_header="X-Tenant-ID", + ) + + +@pytest.fixture +async def db_state() -> AsyncGenerator[DatabaseState, None]: + """Create a fresh in-memory DatabaseState with listeners registered.""" + from simple_module_db.listeners import register_listeners + + state = init_db("sqlite+aiosqlite:///:memory:") + register_listeners(state) + yield state + await state.engine.dispose() + + +@pytest.fixture +async def engine(db_state: DatabaseState) -> AsyncEngine: + """Return the engine from the test DatabaseState.""" + return db_state.engine + + +@lru_cache(maxsize=1) +def _ensure_models_imported() -> list: + """Import all module models so all_module_bases is populated (cached).""" + for mod in discover_modules(): + pkg = type(mod).__module__.split(".")[0] + with contextlib.suppress(ModuleNotFoundError): + importlib.import_module(f"{pkg}.models") + return list(all_module_bases) + + +@lru_cache(maxsize=1) +def _alembic_head() -> str | None: + """Cached head revision — cannot change within a pytest run.""" + from simple_module_hosting.migrations import resolve_head_revision + + return resolve_head_revision() + + +async def _create_all_tables(engine) -> None: + """Create all module tables in a single connection. + + Also stamps the alembic_version table at head so the app's startup + migration check (``check_migrations``) treats the test DB as current. + Without the stamp the check would raise because ``create_all`` doesn't + touch alembic_version. + """ + from sqlalchemy import text + + bases = _ensure_models_imported() + head = _alembic_head() + + async with engine.begin() as conn: + + def _sync_create_all(sync_conn): + for base in bases: + base.metadata.create_all(sync_conn) + + await conn.run_sync(_sync_create_all) + + if head: + await conn.execute( + text( + "CREATE TABLE IF NOT EXISTS alembic_version " + "(version_num VARCHAR(32) NOT NULL PRIMARY KEY)" + ) + ) + await conn.execute(text("DELETE FROM alembic_version")) + await conn.execute( + text("INSERT INTO alembic_version (version_num) VALUES (:v)"), + {"v": head}, + ) + + +@pytest.fixture +async def db_session(db_state: DatabaseState) -> AsyncGenerator[AsyncSession, None]: + """Yield an async session backed by in-memory SQLite.""" + await _create_all_tables(db_state.engine) + + async with db_state.session_factory() as session: + yield session + + +@pytest.fixture +async def app(settings: Settings): + """Create a FastAPI app with tables pre-created and lifespan triggered.""" + from simple_module_hosting.app_builder import create_app + + application = create_app(settings) + + await _create_all_tables(application.state.sm.db.engine) + + # Trigger lifespan startup so app.state.migration is populated + ctx = application.router.lifespan_context(application) + await ctx.__aenter__() + + yield application + + # Lifespan shutdown disposes the engine + await ctx.__aexit__(None, None, None) + + +@pytest.fixture +async def client(app) -> AsyncGenerator[httpx.AsyncClient, None]: + """Unauthenticated async HTTP client.""" + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, + base_url="http://testserver", + ) as c: + yield c + + +@pytest.fixture +async def authenticated_client(app) -> AsyncGenerator[httpx.AsyncClient, None]: + """HTTPX client with a signed session cookie carrying a seeded admin user's id. + + Requires the ``users`` module to be installed — it seeds the admin via + ``users.bootstrap.create_admin``. The import is deferred to here (not module + scope) so this plugin imports cleanly without ``users``; a consumer app that + uses this fixture without ``users`` gets a clear ImportError naming it. + """ + from users.bootstrap import create_admin + + async with app.state.sm.db.session_factory() as session: + result = await create_admin( + session, + email="admin@test", + password="test-password", + full_name="Test Admin", + ) + user_id = str(result.user.id) + + signed = forge_session_cookie( + app.state.sm.settings.secret_key, + {"user_id": user_id}, + ) + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, + base_url="http://testserver", + cookies={"session": signed}, + ) as c: + yield c diff --git a/framework/testing/simple_module_test/plugin.py b/framework/testing/simple_module_test/plugin.py index 8276909f3..85a9328f9 100644 --- a/framework/testing/simple_module_test/plugin.py +++ b/framework/testing/simple_module_test/plugin.py @@ -15,6 +15,21 @@ from simple_module_test.app_factory import build_test_app as _build_test_app from simple_module_test.fake_events import FakeEventBus +# Re-export the headline app/db/client fixtures the README advertises. pytest +# collects fixture-decorated attributes of the plugin module, so importing them +# here registers them for every test run that installs simple_module_test — +# without a per-consumer conftest. Treat this surface like a public API: deleting +# one breaks external modules' suites. See GH #200. +from simple_module_test.fixtures import ( # noqa: F401 + app, + authenticated_client, + client, + db_session, + db_state, + engine, + settings, +) + def _bootstrap_eager_celery() -> None: """Register a process-wide eager Celery app before any test runs. diff --git a/framework/testing/tests/test_plugin_fixtures.py b/framework/testing/tests/test_plugin_fixtures.py new file mode 100644 index 000000000..91d001f23 --- /dev/null +++ b/framework/testing/tests/test_plugin_fixtures.py @@ -0,0 +1,62 @@ +"""GH #200: the published ``simple_module_test`` plugin must register the +headline fixtures its README/``__init__`` advertise — not just ``fake_event_bus`` ++ ``build_test_app``. Before the fix those fixtures lived only in the framework +repo's root ``conftest.py``, so a consumer that installed the plugin (but wasn't +the framework repo) got "fixture not found". +""" + +from __future__ import annotations + +# The fixtures the plugin promises. Treat this set as the public fixture +# surface — dropping one breaks external modules' suites. +_HEADLINE_FIXTURES = ( + "fake_event_bus", + "build_test_app", + "settings", + "db_state", + "engine", + "db_session", + "app", + "client", + "authenticated_client", +) + + +def _is_pytest_fixture(obj) -> bool: + """True if ``obj`` was produced by ``@pytest.fixture`` (version-robust). + + pytest >=8.4 returns a ``FixtureFunctionDefinition`` instance; older + versions left the function in place with a ``_pytestfixturefunction`` + marker attribute. Cover both so this test doesn't pin a pytest version. + """ + return hasattr(obj, "_pytestfixturefunction") or ( + type(obj).__name__ == "FixtureFunctionDefinition" + ) + + +def test_plugin_module_exports_every_headline_fixture(): + """Each advertised fixture is a real ``@pytest.fixture`` on the plugin module.""" + import simple_module_test.plugin as plugin + + for name in _HEADLINE_FIXTURES: + obj = getattr(plugin, name, None) + assert obj is not None, f"{name!r} is not exported by simple_module_test.plugin" + # A plain function imported by mistake would not satisfy this. + assert _is_pytest_fixture(obj), f"{name!r} is not a pytest fixture" + + +def test_plugin_is_registered_via_pytest11_entry_point(pytestconfig): + """pytest discovered the plugin through its ``pytest11`` entry point, so a + consumer gets the fixtures by installing the package alone.""" + assert pytestconfig.pluginmanager.hasplugin("simple_module_test") + + +async def test_db_session_fixture_injects_without_a_local_conftest(db_session): + """``db_session`` resolves in this test dir, which has no conftest defining + it — it can only come from the installed plugin (root conftest no longer + provides it). Proves the moved fixture is genuinely shipped, not just + re-exported on paper.""" + from sqlalchemy import text + + result = await db_session.execute(text("SELECT 1")) + assert result.scalar_one() == 1 diff --git a/modules/background_tasks/background_tasks/celery_app.py b/modules/background_tasks/background_tasks/celery_app.py index bc15b1dec..272fc474d 100644 --- a/modules/background_tasks/background_tasks/celery_app.py +++ b/modules/background_tasks/background_tasks/celery_app.py @@ -15,6 +15,7 @@ from __future__ import annotations +import importlib import logging from importlib.metadata import entry_points @@ -47,10 +48,71 @@ def _discover_task_packages() -> list[str]: return sorted(packages) +def _collect_module_beat_schedules(packages: list[str]) -> dict: + """Merge every installed module's optional ``tasks.BEAT_SCHEDULE`` mapping. + + A module registers periodic work the same way it registers tasks — by + shipping a ``tasks.py`` — and additionally exporting a module-level + ``BEAT_SCHEDULE`` dict of ``{entry_name: celery beat entry}``. Because + ``build_celery`` runs identically in the web and worker processes, this is + worker-safe and deterministic, unlike runtime registration via + ``add_periodic_task`` / the ``on_after_finalize`` signal (and unlike the + commonly-cited ``on_after_configure``, which never fires here — see GH #199). + + Importing ``.tasks`` is idempotent (autodiscover imports the same + modules), so doing it eagerly here has no extra side effects. + """ + merged: dict = {} + for pkg in packages: + try: + tasks_mod = importlib.import_module(f"{pkg}.tasks") + except ModuleNotFoundError as exc: + # The module simply ships no tasks.py — skip. But a tasks.py that + # *exists* and fails to import (its own dependency is missing) must + # surface loudly, not be mistaken for "no tasks.py" and silently + # drop the module's periodic work. + if exc.name in (pkg, f"{pkg}.tasks"): + continue + raise + schedule_dict = getattr(tasks_mod, "BEAT_SCHEDULE", None) + if not isinstance(schedule_dict, dict): + continue + for name, entry in schedule_dict.items(): + if name in merged: + logger.warning( + "Beat entry %r from %s.tasks overrides an earlier module's entry", name, pkg + ) + merged[name] = entry + return merged + + def build_celery(settings: BackgroundTasksSettings) -> Celery: """Construct a Celery app wired to the project's Redis broker.""" celery = Celery(MODULE_NAME) + packages = _discover_task_packages() + + # The two internal entries always ship; modules contribute more via their + # own ``tasks.BEAT_SCHEDULE``. ``setdefault`` keeps the built-ins authoritative + # if a module reuses one of their entry names. See GH #199. + beat_schedule = { + "background-tasks-sweep-stuck": { + "task": INTERNAL_TASK_SWEEP_STUCK, + "schedule": schedule(settings.stuck_sweep_interval_seconds), + }, + "background-tasks-purge-old": { + "task": INTERNAL_TASK_PURGE_OLD, + "schedule": schedule(settings.purge_interval_seconds), + }, + } + for name, entry in _collect_module_beat_schedules(packages).items(): + if name in beat_schedule: + logger.warning( + "Module beat entry %r clashes with a built-in entry; the built-in wins", name + ) + continue + beat_schedule[name] = entry + celery.conf.update( broker_url=settings.broker_url, result_backend=settings.result_backend, @@ -74,22 +136,12 @@ def build_celery(settings: BackgroundTasksSettings) -> Celery: timezone="UTC", enable_utc=True, broker_connection_retry_on_startup=True, - beat_schedule={ - "background-tasks-sweep-stuck": { - "task": INTERNAL_TASK_SWEEP_STUCK, - "schedule": schedule(settings.stuck_sweep_interval_seconds), - }, - "background-tasks-purge-old": { - "task": INTERNAL_TASK_PURGE_OLD, - "schedule": schedule(settings.purge_interval_seconds), - }, - }, + beat_schedule=beat_schedule, beat_scheduler="celery.beat:PersistentScheduler", ) # Autodiscover across every installed simple_module — a module just ships # a `tasks.py` and the worker picks it up. - packages = _discover_task_packages() logger.info("Celery autodiscover_tasks across: %s", packages) celery.autodiscover_tasks(packages, related_name="tasks", force=True) diff --git a/modules/background_tasks/tests/test_beat_schedule.py b/modules/background_tasks/tests/test_beat_schedule.py new file mode 100644 index 000000000..bb3d29921 --- /dev/null +++ b/modules/background_tasks/tests/test_beat_schedule.py @@ -0,0 +1,105 @@ +"""GH #199: a consumer module can register Celery beat entries by shipping a +``tasks.py`` that exports a module-level ``BEAT_SCHEDULE`` dict — ``build_celery`` +merges them into the beat schedule, worker-safe and declarative (no reliance on +the broken ``on_after_configure`` signal). +""" + +from __future__ import annotations + +import importlib +import logging +import sys +import types + +import pytest +from background_tasks import celery_app +from background_tasks.constants import INTERNAL_TASK_SWEEP_STUCK +from background_tasks.settings import BackgroundTasksSettings + + +def _install_fake_module(monkeypatch, pkg: str, beat_schedule: dict | None) -> None: + """Register ``pkg`` + ``pkg.tasks`` in sys.modules for the test duration. + + When ``beat_schedule`` is None the tasks module ships no ``BEAT_SCHEDULE`` + attribute (a module that has tasks but no periodic work). + """ + parent = types.ModuleType(pkg) + tasks = types.ModuleType(f"{pkg}.tasks") + if beat_schedule is not None: + tasks.BEAT_SCHEDULE = beat_schedule + monkeypatch.setitem(sys.modules, pkg, parent) + monkeypatch.setitem(sys.modules, f"{pkg}.tasks", tasks) + + +class TestCollectModuleBeatSchedules: + def test_merges_module_beat_schedule(self, monkeypatch): + entry = {"task": "fakepkg.daily", "schedule": 3600} + _install_fake_module(monkeypatch, "fakepkg", {"fakepkg-daily": entry}) + + merged = celery_app._collect_module_beat_schedules(["fakepkg"]) + assert merged == {"fakepkg-daily": entry} + + def test_skips_package_without_beat_schedule_attr(self, monkeypatch): + _install_fake_module(monkeypatch, "nobeat", None) + assert celery_app._collect_module_beat_schedules(["nobeat"]) == {} + + def test_skips_package_without_tasks_module(self): + # A package with no importable ``.tasks`` submodule is ignored, not fatal. + assert celery_app._collect_module_beat_schedules(["definitely_not_a_real_pkg"]) == {} + + def test_ignores_non_dict_beat_schedule(self, monkeypatch): + _install_fake_module(monkeypatch, "weird", None) + sys.modules["weird.tasks"].BEAT_SCHEDULE = ["not", "a", "dict"] + assert celery_app._collect_module_beat_schedules(["weird"]) == {} + + def test_propagates_error_from_a_broken_tasks_module(self, monkeypatch): + """A tasks.py that *exists* but fails its own import (missing dependency) + must surface, not be mistaken for 'module ships no tasks.py' and silently + drop its periodic work.""" + real_import = importlib.import_module + + def fake_import(name, *args, **kwargs): + if name == "brokenpkg.tasks": + # The module's own `import some_dep` fails — note the missing + # name is NOT brokenpkg/brokenpkg.tasks. + raise ModuleNotFoundError("No module named 'some_dep'", name="some_dep") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(importlib, "import_module", fake_import) + with pytest.raises(ModuleNotFoundError, match="some_dep"): + celery_app._collect_module_beat_schedules(["brokenpkg"]) + + +class TestBuildCeleryBeatSchedule: + def test_module_beat_entry_lands_in_schedule(self, monkeypatch): + entry = {"task": "fakeinv.generate_recurring", "schedule": 3600} + _install_fake_module(monkeypatch, "fakeinv", {"fakeinv-gen-daily": entry}) + monkeypatch.setattr( + celery_app, "_discover_task_packages", lambda: ["background_tasks", "fakeinv"] + ) + + app = celery_app.build_celery(BackgroundTasksSettings(task_always_eager=True)) + schedule = app.conf.beat_schedule + + assert schedule["fakeinv-gen-daily"] == entry + # The two built-in entries still ship. + assert "background-tasks-sweep-stuck" in schedule + assert "background-tasks-purge-old" in schedule + + def test_builtin_entries_win_on_name_clash_and_warn(self, monkeypatch, caplog): + # A module that reuses a built-in entry name must not clobber it, and the + # drop must be diagnosable (not silent). + rogue = {"task": "rogue.evil", "schedule": 1} + _install_fake_module(monkeypatch, "rogue", {"background-tasks-sweep-stuck": rogue}) + monkeypatch.setattr( + celery_app, "_discover_task_packages", lambda: ["background_tasks", "rogue"] + ) + + with caplog.at_level(logging.WARNING, logger=celery_app.__name__): + app = celery_app.build_celery(BackgroundTasksSettings(task_always_eager=True)) + + assert ( + app.conf.beat_schedule["background-tasks-sweep-stuck"]["task"] + == INTERNAL_TASK_SWEEP_STUCK + ) + assert any("clashes with a built-in entry" in r.message for r in caplog.records)