From 43fe3eb68571817f17769f536c164716fe939dae Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 3 Aug 2026 14:59:01 +0200 Subject: [PATCH 1/2] style: format Python code blocks in Markdown (ruff 0.16) ruff 0.16 began formatting Python code blocks inside Markdown files. `ruff>=0.8` is unpinned, so CI picked the behaviour up and `make ci-python-lint` started failing on 74 documentation files the formatter had never touched before. Changes are confined to ```python fences: verified that zero prose lines changed across all 74 files (every added/removed non-blank line falls inside a python fence, using CommonMark fence rules). Also fixes 2 RUF036 errors (`None` not at the end of a type union) in modules/settings/settings/contracts/accessor.py, surfaced by the same ruff bump and independently failing the lint job. Annotation order only; runtime behaviour is unchanged and the settings suite (112 tests) passes. Claude-Session: https://claude.ai/code/session_01TtYUkaUAJmUcCwB5QGxPqN --- docs/database/migrations.md | 5 +- docs/database/mixins.md | 23 +- docs/database/models.md | 6 + docs/database/per-module-base.md | 2 + docs/database/sessions.md | 6 +- docs/framework-conventions.md | 27 +- docs/framework/discovery.md | 10 +- docs/framework/events.md | 31 +- docs/framework/i18n.md | 5 +- docs/framework/lifecycle.md | 59 ++-- docs/framework/middleware.md | 5 +- docs/framework/overview.md | 5 +- docs/framework/permissions.md | 36 +-- docs/framework/public-routes.md | 1 + docs/framework/settings.md | 8 +- docs/frontend/inertia.md | 6 +- docs/frontend/shared-props.md | 21 +- docs/guide/configuration.md | 4 + docs/guide/first-module.md | 31 +- docs/module-authoring.md | 14 +- docs/modules/auth.md | 2 + docs/modules/background_tasks.md | 16 +- docs/modules/feature_flags.md | 2 + docs/modules/file_storage.md | 18 +- docs/modules/keycloak.md | 1 + docs/modules/permissions.md | 13 +- docs/modules/settings.md | 15 +- docs/modules/users.md | 24 +- .../2026-04-13-alembic-migrations-design.md | 10 +- docs/plans/2026-04-13-alembic-migrations.md | 44 +-- docs/plans/2026-04-14-dx-hardening-design.md | 2 +- docs/plans/2026-04-14-dx-hardening.md | 9 +- .../2026-04-13-module-lifecycle-hooks.md | 17 +- .../plans/2026-04-15-i18n-localization.md | 90 +++--- .../2026-04-17-app-state-organization.md | 5 +- .../2026-04-17-users-module-quality-pass.md | 8 +- .../plans/2026-04-20-users-admin-ux.md | 268 +++++++++++------- .../2026-04-21-db-backed-module-settings.md | 141 +++++---- .../plans/2026-04-21-public-release.md | 58 ++-- .../2026-04-26-cli-modules-and-bg-jobs.md | 88 ++++-- .../2026-04-26-standalone-cli-package.md | 53 ++-- ...-01-background-tasks-worker-status-page.md | 4 +- .../2026-05-21-auth-principal-resolver.md | 116 ++++---- .../plans/2026-05-27-audit-log-module.md | 117 ++++---- .../2026-05-27-pluggable-auth-keycloak.md | 132 ++++----- .../plans/2026-06-03-microsoft-oidc.md | 106 ++++--- .../plans/2026-06-21-admin-user-crud.md | 16 +- ...026-04-13-module-lifecycle-hooks-design.md | 2 + ...026-04-15-dashboard-improvements-design.md | 11 +- .../2026-04-15-i18n-localization-design.md | 7 +- ...026-04-17-app-state-organization-design.md | 1 + ...-04-21-db-backed-module-settings-design.md | 1 + ...26-04-26-cli-modules-and-bg-jobs-design.md | 58 ++-- ...kground-tasks-worker-status-page-design.md | 3 +- ...26-05-21-auth-principal-resolver-design.md | 8 +- ...26-05-27-pluggable-auth-keycloak-design.md | 48 +++- .../specs/2026-06-03-microsoft-oidc-design.md | 10 +- .../2026-06-19-admin-user-crud-design.md | 2 + docs/testing/fixtures.md | 32 ++- framework/core/README.md | 1 + framework/hosting/README.md | 5 +- modules/background_tasks/README.md | 6 +- modules/feature_flags/README.md | 2 +- modules/file_storage/README.md | 5 +- modules/permissions/README.md | 2 +- .../settings/settings/contracts/accessor.py | 4 +- modules/users/README.md | 3 +- skills/simple-module-conventions/SKILL.md | 9 +- skills/simple-module-creating/SKILL.md | 5 +- skills/simple-module-database/SKILL.md | 7 +- skills/simple-module-inertia-pages/SKILL.md | 2 + skills/simple-module-locales/SKILL.md | 2 + skills/simple-module-migrations/SKILL.md | 6 +- skills/simple-module-registries/SKILL.md | 35 ++- skills/simple-module-testing/SKILL.md | 2 + 75 files changed, 1122 insertions(+), 837 deletions(-) diff --git a/docs/database/migrations.md b/docs/database/migrations.md index bff07a36..99937fef 100644 --- a/docs/database/migrations.md +++ b/docs/database/migrations.md @@ -53,7 +53,7 @@ When you scaffold a module with `smpy create-module`, the *first* autogenerate r revision = "..." down_revision = "..." -branch_labels = ("orders",) # ← add this +branch_labels = ("orders",) # ← add this depends_on = None ``` @@ -133,8 +133,7 @@ For long-running backfills on big tables, break into batches and run outside Ale ```python # scripts/backfill_orders_total.py -async def backfill(batch_size=1000): - ... +async def backfill(batch_size=1000): ... ``` ## Migration drift in monorepos diff --git a/docs/database/mixins.md b/docs/database/mixins.md index e94a12cd..f5938b51 100644 --- a/docs/database/mixins.md +++ b/docs/database/mixins.md @@ -5,13 +5,21 @@ Standard mixins in `simple_module_db.mixins`. Compose as many as you need alongs ```python from simple_module_db.base import create_module_base from simple_module_db.mixins import ( - AuditMixin, SoftDeleteMixin, MultiTenantMixin, VersionedMixin, + AuditMixin, + SoftDeleteMixin, + MultiTenantMixin, + VersionedMixin, ) Base = create_module_base("orders") + class Order( - Base, AuditMixin, SoftDeleteMixin, MultiTenantMixin, VersionedMixin, + Base, + AuditMixin, + SoftDeleteMixin, + MultiTenantMixin, + VersionedMixin, table=True, ): __tablename__ = "orders_order" @@ -65,9 +73,8 @@ If you genuinely need to remove the row, call the underlying SQLAlchemy DELETE ```python from sqlmodel import delete -await session.exec( - delete(Order).where(Order.id == order_id) -) + +await session.exec(delete(Order).where(Order.id == order_id)) ``` Use sparingly — audit trails and downstream systems may depend on historical rows. @@ -118,9 +125,9 @@ Order matters only for MRO of columns that share names; the mixins here don't ov ```python class Order( Base, - AuditMixin, # created/updated timestamps + author - SoftDeleteMixin, # is_deleted flag + auto-filter - MultiTenantMixin, # tenant_id + auto-filter + AuditMixin, # created/updated timestamps + author + SoftDeleteMixin, # is_deleted flag + auto-filter + MultiTenantMixin, # tenant_id + auto-filter table=True, ): __tablename__ = "orders_order" diff --git a/docs/database/models.md b/docs/database/models.md index d324bebc..87d140f2 100644 --- a/docs/database/models.md +++ b/docs/database/models.md @@ -13,6 +13,7 @@ from sqlmodel import Field Base = create_module_base("orders") + class Order(Base, AuditMixin, table=True): __tablename__ = "orders_order" @@ -43,6 +44,7 @@ id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) ```python from sqlmodel import Field, Relationship + class OrderLine(Base, table=True): __tablename__ = "orders_order_line" @@ -66,13 +68,16 @@ from datetime import datetime from pydantic import ConfigDict from sqlmodel import Field, SQLModel + class OrderCreate(SQLModel): customer_email: str = Field(min_length=1, max_length=200) total: Decimal = Field(ge=0) + class OrderUpdate(SQLModel): status: str | None = Field(default=None, max_length=20) + class OrderOut(SQLModel): model_config = ConfigDict(from_attributes=True) @@ -130,6 +135,7 @@ class Order(Base, table=True): id: int | None = Field(default=None, primary_key=True) lines: list["OrderLine"] = Relationship(back_populates="order") + class OrderLine(Base, table=True): id: int | None = Field(default=None, primary_key=True) order_id: int = Field(foreign_key="orders_order.id") diff --git a/docs/database/per-module-base.md b/docs/database/per-module-base.md index 430882f4..4242f710 100644 --- a/docs/database/per-module-base.md +++ b/docs/database/per-module-base.md @@ -7,6 +7,7 @@ from simple_module_db.base import create_module_base Base = create_module_base("orders") + class Order(Base, table=True): __tablename__ = "orders_order" id: int | None = Field(default=None, primary_key=True) @@ -59,6 +60,7 @@ If your module needs to reference another module's table by foreign key, import # modules/invoices/invoices/models.py from orders.models import Order + class Invoice(Base, table=True): __tablename__ = "invoices_invoice" id: int | None = Field(default=None, primary_key=True) diff --git a/docs/database/sessions.md b/docs/database/sessions.md index 9d261a7a..457ee3af 100644 --- a/docs/database/sessions.md +++ b/docs/database/sessions.md @@ -30,6 +30,7 @@ from simple_module_db.deps import get_db SessionDep = Annotated[AsyncSession, Depends(get_db)] + @router.post("") async def create_order(data: OrderCreate, session: SessionDep): ... ``` @@ -40,9 +41,11 @@ Most modules wrap `get_db` in their own `deps.py` so the service is injected rat # modules/orders/orders/deps.py from orders.service import OrderService + def _order_service(session: SessionDep) -> OrderService: return OrderService(session) + OrderServiceDep = Annotated[OrderService, Depends(_order_service)] ``` @@ -78,7 +81,7 @@ Instead, **flush** when you need a DB-assigned value (e.g. an auto-generated `id async def create(self, data: OrderCreate) -> OrderOut: order = Order(**data.model_dump()) self.session.add(order) - await self.session.flush() # populates order.id, stays inside the transaction + await self.session.flush() # populates order.id, stays inside the transaction # further logic can see order.id but won't persist until the dependency commits return OrderOut.model_validate(order) ``` @@ -92,6 +95,7 @@ If you need finer control — e.g. a background worker that processes many items ```python from simple_module_db import DatabaseState + async def worker(db: DatabaseState): async with db.session_factory() as session: async with session.begin(): diff --git a/docs/framework-conventions.md b/docs/framework-conventions.md index f8d26be1..d943bc4a 100644 --- a/docs/framework-conventions.md +++ b/docs/framework-conventions.md @@ -29,10 +29,10 @@ Every `ModuleBase` subclass **must** declare a `meta` class attribute: ```python class OrdersModule(ModuleBase): meta = ModuleMeta( - name="Orders", # PascalCase, unique - route_prefix="/api/orders", # mounted on the API router - view_prefix="/orders", # mounted on the view router - depends_on=["Products"], # strict load order + name="Orders", # PascalCase, unique + route_prefix="/api/orders", # mounted on the API router + view_prefix="/orders", # mounted on the view router + depends_on=["Products"], # strict load order version="1.0.0", ) ``` @@ -144,20 +144,24 @@ from sqlmodel import Field Base = create_module_base("orders") + class Order(Base, AuditMixin, table=True): __tablename__ = "orders_order" id: int | None = Field(default=None, primary_key=True) name: str = Field(max_length=200) + # modules/orders/orders/contracts/schemas.py from pydantic import ConfigDict from sqlmodel import Field, SQLModel + class OrderOut(SQLModel): model_config = ConfigDict(from_attributes=True) id: int name: str + class OrderCreate(SQLModel): name: str = Field(min_length=1, max_length=200) ``` @@ -228,9 +232,15 @@ Use `InertiaDep` from `simple_module_hosting.inertia_deps` — it attaches the s Modules declare permissions in `register_permissions(registry)` grouped by a name prefix: ```python -registry.add_group("Orders", [ - "orders.view", "orders.create", "orders.edit", "orders.delete", -]) +registry.add_group( + "Orders", + [ + "orders.view", + "orders.create", + "orders.edit", + "orders.delete", + ], +) ``` Enforce with the `RequiresPermission` dependency: @@ -349,6 +359,7 @@ Modules ship translations as JSON under `/locales/.json` and decl import importlib.resources from pathlib import Path + class OrdersModule(ModuleBase): def locale_dirs(self) -> dict[str, Path]: return {"orders": Path(str(importlib.resources.files(__package__) / "locales"))} @@ -373,7 +384,7 @@ t('orders.greeting', { name: user.name }) // frontend ``` ```python -t.t("orders.greeting", name=user.name) # backend +t.t("orders.greeting", name=user.name) # backend ``` Missing placeholders are left verbatim (`"Hello, {name}"`) rather than raising. diff --git a/docs/framework/discovery.md b/docs/framework/discovery.md index 6852ef44..b2168016 100644 --- a/docs/framework/discovery.md +++ b/docs/framework/discovery.md @@ -39,11 +39,11 @@ Failures: from simple_module_core.module import ModuleMeta meta = ModuleMeta( - name="Orders", # PascalCase, globally unique - route_prefix="/api/orders", # where the API router mounts - view_prefix="/orders", # where the view router mounts - depends_on=["Products"], # hard ordering requirements - version="1.0.0", # semver for the module + name="Orders", # PascalCase, globally unique + route_prefix="/api/orders", # where the API router mounts + view_prefix="/orders", # where the view router mounts + depends_on=["Products"], # hard ordering requirements + version="1.0.0", # semver for the module ) ``` diff --git a/docs/framework/events.md b/docs/framework/events.md index 1bf5c0bf..ca648855 100644 --- a/docs/framework/events.md +++ b/docs/framework/events.md @@ -14,12 +14,14 @@ from dataclasses import dataclass from decimal import Decimal from simple_module_core.events import Event + @dataclass class OrderPlaced(Event): order_id: int customer_email: str total: Decimal + @dataclass class OrderCancelled(Event): order_id: int @@ -67,12 +69,14 @@ class OrderService: async def place(self, data: OrderCreate) -> Order: order = Order(**data.model_dump()) self.session.add(order) - await self.session.flush() # need the id - await self.bus.publish(OrderPlaced( - order_id=order.id, - customer_email=order.customer_email, - total=order.total, - )) + await self.session.flush() # need the id + await self.bus.publish( + OrderPlaced( + order_id=order.id, + customer_email=order.customer_email, + total=order.total, + ) + ) return order ``` @@ -83,9 +87,11 @@ Get the bus as a FastAPI dependency: from fastapi import Depends, Request from simple_module_core.events import EventBus + def _event_bus(request: Request) -> EventBus: return request.app.state.sm.event_bus + EventBusDep = Annotated[EventBus, Depends(_event_bus)] ``` @@ -97,14 +103,17 @@ The bus (backed by `pyee`'s `AsyncIOEventEmitter`) keys handlers by the **exact* @dataclass class OrderEvent(Event): ... + @dataclass class OrderPlaced(OrderEvent): ... + @dataclass class OrderCancelled(OrderEvent): ... -bus.subscribe(OrderEvent, audit_handler) # receives only OrderEvent, NOT subclasses -bus.subscribe(OrderPlaced, specific_handler) # receives only OrderPlaced + +bus.subscribe(OrderEvent, audit_handler) # receives only OrderEvent, NOT subclasses +bus.subscribe(OrderPlaced, specific_handler) # receives only OrderPlaced ``` If you want a handler to see several event types, subscribe it to each one explicitly. @@ -122,8 +131,10 @@ If you need durable delivery across processes, handlers should enqueue a Celery def register_event_handlers(self, bus: EventBus) -> None: bus.subscribe(OrderPlaced, self._enqueue_invoice) + async def _enqueue_invoice(self, event: OrderPlaced) -> None: from invoices.tasks import create_invoice_task + create_invoice_task.delay(event.order_id) ``` @@ -135,9 +146,7 @@ Subscribe a spy in a test fixture: @pytest.mark.asyncio async def test_place_order_publishes_event(db_session, app): received: list[OrderPlaced] = [] - app.state.sm.event_bus.subscribe( - OrderPlaced, lambda e: received.append(e) - ) + app.state.sm.event_bus.subscribe(OrderPlaced, lambda e: received.append(e)) service = OrderService(db_session, app.state.sm.event_bus) await service.place(OrderCreate(customer_email="a@b.c", total=Decimal("1"))) diff --git a/docs/framework/i18n.md b/docs/framework/i18n.md index 59fd11f3..076a7ce4 100644 --- a/docs/framework/i18n.md +++ b/docs/framework/i18n.md @@ -17,12 +17,11 @@ Declare them from `ModuleBase.locale_dirs()`: import importlib.resources from pathlib import Path + class OrdersModule(ModuleBase): def locale_dirs(self) -> dict[str, Path]: return { - "orders": Path( - str(importlib.resources.files(__package__) / "locales") - ), + "orders": Path(str(importlib.resources.files(__package__) / "locales")), } ``` diff --git a/docs/framework/lifecycle.md b/docs/framework/lifecycle.md index a318cdf7..fe2c3b75 100644 --- a/docs/framework/lifecycle.md +++ b/docs/framework/lifecycle.md @@ -40,14 +40,16 @@ Add entries to the global `MenuRegistry`. Items are grouped by `MenuSection` and ```python def register_menu_items(self, registry: MenuRegistry) -> None: - registry.add(MenuItem( - section=MenuSection.SIDEBAR, - label="orders.menu.orders", # i18n key, resolved client-side - url="/orders", - icon="package", - roles=["admin"], # empty = all authenticated users - order=20, - )) + registry.add( + MenuItem( + section=MenuSection.SIDEBAR, + label="orders.menu.orders", # i18n key, resolved client-side + url="/orders", + icon="package", + roles=["admin"], # empty = all authenticated users + order=20, + ) + ) ``` `roles` filters the item to users holding at least one of the listed roles (empty list = visible to all authenticated users); `requires_auth` (default `True`) hides it from anonymous visitors. `order` is a stable sort key (lower = earlier). @@ -60,9 +62,15 @@ Declare permission strings your module enforces. Grouped by a display name for t ```python def register_permissions(self, registry: PermissionRegistry) -> None: - registry.add_group("Orders", [ - "orders.view", "orders.create", "orders.edit", "orders.delete", - ]) + registry.add_group( + "Orders", + [ + "orders.view", + "orders.create", + "orders.edit", + "orders.delete", + ], + ) ``` Permissions become available in the role admin UI (`/settings/permissions`). See [Permissions](/framework/permissions). @@ -73,10 +81,12 @@ Declare feature flags with defaults. The admin can toggle them at `/settings/fea ```python def register_feature_flags(self, registry: FeatureFlagRegistry) -> None: - registry.add(FeatureFlagDefinition( - name="orders.new_checkout", - default_enabled=False, - )) + registry.add( + FeatureFlagDefinition( + name="orders.new_checkout", + default_enabled=False, + ) + ) ``` Query from code: @@ -94,10 +104,11 @@ Subscribe to events on the in-process `EventBus`. Handlers can be sync or async; ```python def register_event_handlers(self, bus: EventBus) -> None: from orders.contracts.events import OrderPlaced + bus.subscribe(OrderPlaced, self._on_order_placed) -async def _on_order_placed(self, event: OrderPlaced) -> None: - ... + +async def _on_order_placed(self, event: OrderPlaced) -> None: ... ``` Handlers are keyed by the exact event type and run concurrently on publish. See [Events](/framework/events). @@ -110,8 +121,8 @@ Register named async checks. They're surfaced at `/health/ready`: def register_health_checks(self, registry: HealthRegistry) -> None: registry.add(HealthCheck(name="orders.db", check=self._check_db)) -async def _check_db(self) -> HealthCheckResult: - ... + +async def _check_db(self) -> HealthCheckResult: ... ``` Each check returns a `HealthCheckResult(status=HealthStatus.HEALTHY | DEGRADED | UNHEALTHY, detail=...)`. The `/health/ready` endpoint runs all checks concurrently and reports the worst status (a raising check counts as `UNHEALTHY`). @@ -124,9 +135,8 @@ Register FastAPI exception handlers scoped to your module's exceptions: def register_exception_handlers(self, app: FastAPI) -> None: app.add_exception_handler(OrderNotFound, self._handle_not_found) -async def _handle_not_found( - self, request: Request, exc: OrderNotFound -) -> Response: + +async def _handle_not_found(self, request: Request, exc: OrderNotFound) -> Response: return JSONResponse({"detail": str(exc)}, status_code=404) ``` @@ -148,9 +158,7 @@ When two modules at the same dependency tier both add middleware, the module tha Mount your API and Inertia view routers onto the two framework-provided routers. ```python -def register_routes( - self, api_router: APIRouter, view_router: APIRouter -) -> None: +def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None: from orders.endpoints.api import router as api from orders.endpoints.views import router as views @@ -171,6 +179,7 @@ Async lifespan hooks that run after all modules are registered. async def on_startup(self, app: FastAPI) -> None: await self._worker_pool.start() + async def on_shutdown(self, app: FastAPI) -> None: await self._worker_pool.stop() ``` diff --git a/docs/framework/middleware.md b/docs/framework/middleware.md index 388b24dd..27a102bd 100644 --- a/docs/framework/middleware.md +++ b/docs/framework/middleware.md @@ -16,7 +16,7 @@ if settings.multi_tenant: app.add_middleware(TenantMiddleware, ...) for module in discovered_modules: - module.register_middleware(app) # each module may add 0+ middleware + module.register_middleware(app) # each module may add 0+ middleware app.add_middleware(SessionMiddleware, secret_key=...) app.add_middleware(SecurityHeadersMiddleware, ...) @@ -66,12 +66,14 @@ Every record emitted via the stdlib `logging` setup configured by `setup_logging import structlog from simple_module_hosting.logging import correlation_id + def add_correlation_id(_, __, event_dict): cid = correlation_id.get("") if cid: event_dict.setdefault("correlation_id", cid) return event_dict + structlog.configure( processors=[ add_correlation_id, @@ -141,6 +143,7 @@ Use the Starlette pattern. Keep it asynchronous. from starlette.middleware.base import BaseHTTPMiddleware from starlette.types import ASGIApp + class OrdersRateLimitMiddleware(BaseHTTPMiddleware): def __init__(self, app: ASGIApp, rate: int = 10) -> None: super().__init__(app) diff --git a/docs/framework/overview.md b/docs/framework/overview.md index 22df9b99..40de7a6c 100644 --- a/docs/framework/overview.md +++ b/docs/framework/overview.md @@ -77,12 +77,11 @@ Framework code must never import from `modules/*`. Diagnostic `SM009` enforces t class PrincipalSerializerRegistry: def register(self, fn: Callable[[User], dict]) -> None: ... + # Module registers during register_settings class UsersModule(ModuleBase): def register_settings(self, app: FastAPI) -> None: - app.state.sm.inertia_config.register_principal_serializer( - serialize_user - ) + app.state.sm.inertia_config.register_principal_serializer(serialize_user) ``` This is how the `auth.user` shared prop is built: framework middleware calls whatever serializer the `users` module registered — without importing `users`. diff --git a/docs/framework/permissions.md b/docs/framework/permissions.md index c04e2b8c..73f2c29e 100644 --- a/docs/framework/permissions.md +++ b/docs/framework/permissions.md @@ -9,15 +9,19 @@ Inside a module's `register_permissions(registry)`: ```python from simple_module_core.permissions import PermissionRegistry + class OrdersModule(ModuleBase): def register_permissions(self, registry: PermissionRegistry) -> None: - registry.add_group("Orders", [ - "orders.view", - "orders.create", - "orders.edit", - "orders.delete", - "orders.export", - ]) + registry.add_group( + "Orders", + [ + "orders.view", + "orders.create", + "orders.edit", + "orders.delete", + "orders.export", + ], + ) ``` The group name (`"Orders"`) is the display label in the role editor. Permission strings conventionally use `.` lowercase. @@ -90,12 +94,14 @@ The active auth provider (the `users` or `keycloak` module) owns extraction from `MenuItem.roles` hides an item from users who hold none of the listed roles (an empty list = visible to all authenticated users); `requires_auth` (default `True`) hides it from anonymous visitors. Filtering happens in `MenuRegistry.get_for_user`, called by `InertiaLayoutDataMiddleware` *before* the menu reaches the client — so the client never sees menu items it can't use. ```python -registry.add(MenuItem( - section=MenuSection.SIDEBAR, - label="orders.menu.orders", - url="/orders", - roles=["admin"], -)) +registry.add( + MenuItem( + section=MenuSection.SIDEBAR, + label="orders.menu.orders", + url="/orders", + roles=["admin"], + ) +) ``` Menu visibility is **role**-based (`MenuItem` has no `required_permission` field) — see `simple_module_core.menu`. Enforce the actual permission on the endpoint with `RequiresPermission`. @@ -133,9 +139,7 @@ async def test_create_requires_permission(client, db_session): # users.bootstrap only ships create_admin). ... # Sign in via the local-auth API to get the session cookie. - r = await client.post( - "/api/users/auth/login", data={"username": "u@e.com", "password": "x"} - ) + r = await client.post("/api/users/auth/login", data={"username": "u@e.com", "password": "x"}) assert r.status_code in (200, 204) r = await client.post("/api/orders", json={...}) diff --git a/docs/framework/public-routes.md b/docs/framework/public-routes.md index a1d3f3f6..8d893c6e 100644 --- a/docs/framework/public-routes.md +++ b/docs/framework/public-routes.md @@ -19,6 +19,7 @@ authenticated" because read and write routes share a prefix. ```python from simple_module_core import ModuleBase, ModuleMeta, PublicRouteRegistry + class GisModule(ModuleBase): meta = ModuleMeta(name="Gis", route_prefix="/api/gis") diff --git a/docs/framework/settings.md b/docs/framework/settings.md index 081df4b8..a1ddc533 100644 --- a/docs/framework/settings.md +++ b/docs/framework/settings.md @@ -43,6 +43,7 @@ Each module that needs configuration declares a **`Env`** pydantic-setti # modules/users/users/settings.py from pydantic_settings import BaseSettings + class UsersEnv(BaseSettings): allow_signup: bool = False mailer: str = "console" @@ -60,10 +61,11 @@ class UsersEnv(BaseSettings): # modules/users/users/state.py from dataclasses import dataclass + @dataclass class UsersState: settings: UsersEnv - mailer: Mailer # picked based on settings.mailer + mailer: Mailer # picked based on settings.mailer principal_serializer: Callable[[User], dict] ``` @@ -92,6 +94,7 @@ class UsersModule(ModuleBase): ```python from fastapi import Request + @router.get("/config") async def users_config(request: Request): state = request.app.state.users @@ -105,11 +108,14 @@ Or as a typed dependency — cleaner when you use it in many handlers: from typing import Annotated from fastapi import Depends, Request + def _users_state(request: Request) -> UsersState: return request.app.state.users + UsersStateDep = Annotated[UsersState, Depends(_users_state)] + @router.get("/config") async def users_config(state: UsersStateDep): return {"allow_signup": state.settings.allow_signup} diff --git a/docs/frontend/inertia.md b/docs/frontend/inertia.md index c66671b7..df72c95e 100644 --- a/docs/frontend/inertia.md +++ b/docs/frontend/inertia.md @@ -21,6 +21,7 @@ from orders.deps import OrderServiceDep router = APIRouter() + @router.get("") async def browse(inertia: InertiaDep, service: OrderServiceDep): orders: list[OrderOut] = await service.list() @@ -80,10 +81,9 @@ Inertia expects a redirect response (302/303) after a successful mutation: # modules/orders/orders/endpoints/views.py from fastapi.responses import RedirectResponse + @router.post("") -async def create( - data: OrderCreate, inertia: InertiaDep, service: OrderServiceDep -): +async def create(data: OrderCreate, inertia: InertiaDep, service: OrderServiceDep): order = await service.create(data) return RedirectResponse(f"/orders/{order.id}", status_code=303) ``` diff --git a/docs/frontend/shared-props.md b/docs/frontend/shared-props.md index ad606a12..eeae4de4 100644 --- a/docs/frontend/shared-props.md +++ b/docs/frontend/shared-props.md @@ -70,6 +70,7 @@ def _serialize_principal(user: UserContext) -> dict: "roles": user.roles, } + class AuthModule(ModuleBase): def register_settings(self, app: FastAPI) -> None: app.state.auth = AuthState() @@ -83,15 +84,17 @@ Without a registered serializer, `auth.user` is `None` even when a user is authe Populated from the `MenuRegistry`. Each module adds items during `register_menu_items`: ```python -registry.add(MenuItem( - section=MenuSection.SIDEBAR, - label="Orders", - url="/orders", - icon="package", - requires_auth=True, - roles=["admin"], # empty list = visible to all authenticated users - order=20, -)) +registry.add( + MenuItem( + section=MenuSection.SIDEBAR, + label="Orders", + url="/orders", + icon="package", + requires_auth=True, + roles=["admin"], # empty list = visible to all authenticated users + order=20, + ) +) ``` `menu_registry.get_for_user(is_authenticated=..., roles=...)` filters by `requires_auth` and `roles` before sending — users without a matching role never see the item. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 07aedb71..9802e1f1 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -88,19 +88,23 @@ When you write your own module, declare settings as a `pydantic_settings.BaseSet # modules/orders/orders/settings.py from pydantic_settings import BaseSettings, SettingsConfigDict + class OrdersSettings(BaseSettings): model_config = SettingsConfigDict(env_prefix="SM_ORDERS_", extra="ignore") max_items_per_order: int = 100 + # modules/orders/orders/services.py from dataclasses import dataclass from orders.settings import OrdersSettings + @dataclass class OrdersServices: settings: OrdersSettings + # modules/orders/orders/module.py class OrdersModule(ModuleBase): def register_settings(self, app: FastAPI) -> None: diff --git a/docs/guide/first-module.md b/docs/guide/first-module.md index e5abb49b..7b8c2c7f 100644 --- a/docs/guide/first-module.md +++ b/docs/guide/first-module.md @@ -25,6 +25,7 @@ from sqlmodel import Field Base = create_module_base("orders") + class Order(Base, AuditMixin, SoftDeleteMixin, table=True): __tablename__ = "orders_order" @@ -48,13 +49,16 @@ from datetime import datetime from pydantic import ConfigDict from sqlmodel import Field, SQLModel + class OrderCreate(SQLModel): customer_email: str = Field(min_length=1, max_length=200) total: Decimal = Field(ge=0) + class OrderUpdate(SQLModel): status: str | None = Field(default=None, max_length=20) + class OrderOut(SQLModel): model_config = ConfigDict(from_attributes=True) @@ -96,6 +100,7 @@ from sqlmodel import select from orders.contracts.schemas import OrderCreate, OrderOut, OrderUpdate from orders.models import Order + class OrderService: def __init__(self, session: AsyncSession) -> None: self.session = session @@ -103,7 +108,7 @@ class OrderService: async def create(self, data: OrderCreate) -> OrderOut: order = Order(**data.model_dump()) self.session.add(order) - await self.session.flush() # get the DB-assigned id + await self.session.flush() # get the DB-assigned id return OrderOut.model_validate(order) async def list(self) -> list[OrderOut]: @@ -134,6 +139,7 @@ from simple_module_core.permissions import PermissionRegistry from orders.endpoints.api import router as api_router from orders.endpoints.views import router as view_router + class OrdersModule(ModuleBase): meta = ModuleMeta( name="Orders", @@ -143,9 +149,15 @@ class OrdersModule(ModuleBase): ) def register_permissions(self, registry: PermissionRegistry) -> None: - registry.add_group("Orders", [ - "orders.view", "orders.create", "orders.edit", "orders.delete", - ]) + registry.add_group( + "Orders", + [ + "orders.view", + "orders.create", + "orders.edit", + "orders.delete", + ], + ) def register_menu_items(self, registry: MenuRegistry) -> None: registry.add( @@ -159,9 +171,7 @@ class OrdersModule(ModuleBase): ) ) - def register_routes( - self, api_router: APIRouter, view_router: APIRouter - ) -> None: + def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None: from orders.endpoints.api import router as api from orders.endpoints.views import router as views @@ -184,6 +194,7 @@ from orders.deps import OrderServiceDep router = APIRouter(tags=["orders"]) + @router.get( "", dependencies=[Depends(RequiresPermission("orders.view"))], @@ -191,14 +202,13 @@ router = APIRouter(tags=["orders"]) async def list_orders(service: OrderServiceDep) -> list[OrderOut]: return await service.list() + @router.post( "", status_code=201, dependencies=[Depends(RequiresPermission("orders.create"))], ) -async def create_order( - data: OrderCreate, service: OrderServiceDep -) -> OrderOut: +async def create_order(data: OrderCreate, service: OrderServiceDep) -> OrderOut: return await service.create(data) ``` @@ -272,6 +282,7 @@ Keys flatten at boot: `orders.menu.orders`, `orders.browse.title`, etc. See [Int # modules/orders/tests/test_orders.py import pytest + @pytest.mark.asyncio async def test_create_and_list_orders(authenticated_client): r = await authenticated_client.post( diff --git a/docs/module-authoring.md b/docs/module-authoring.md index 36a9ff81..8b57cb48 100644 --- a/docs/module-authoring.md +++ b/docs/module-authoring.md @@ -69,14 +69,15 @@ my_module = "my_module.module:MyModule" ```python from simple_module_core import ModuleBase, ModuleMeta + class MyModule(ModuleBase): meta = ModuleMeta( name="MyModule", route_prefix="/api/my-module", view_prefix="/my-module", - depends_on=[], # other module names - version="0.1.0", # your module's semver - requires_framework=">=1.0,<2.0", # framework API range + depends_on=[], # other module names + version="0.1.0", # your module's semver + requires_framework=">=1.0,<2.0", # framework API range ) ``` @@ -131,6 +132,7 @@ from simple_module_core import FeatureFlagRegistry, ModuleBase from my_module.constants import FLAG_BULK_IMPORT + class MyModule(ModuleBase): def register_feature_flags(self, registry: FeatureFlagRegistry) -> None: registry.add(FLAG_BULK_IMPORT) @@ -206,8 +208,8 @@ The host's `env.py` (scaffolded from the framework's template) calls: ```python from simple_module_db import build_module_metadata, make_include_object -target_metadata = build_module_metadata() # every installed module -include_object = make_include_object(target_metadata) +target_metadata = build_module_metadata() # every installed module +include_object = make_include_object(target_metadata) ``` `build_module_metadata()` imports each installed module's `.models` @@ -252,6 +254,7 @@ the wheel and expose it via `ModuleBase.static_mounts()`: ```python from importlib.resources import files + class MyModule(ModuleBase): def static_mounts(self): root = files("my_module") @@ -299,6 +302,7 @@ Example test: ```python from my_feature.module import MyFeatureModule + async def test_api_emits_event(build_test_app, fake_event_bus): app = build_test_app(MyFeatureModule) # ... exercise the route via httpx.AsyncClient ... diff --git a/docs/modules/auth.md b/docs/modules/auth.md index ae4b346e..092fd45e 100644 --- a/docs/modules/auth.md +++ b/docs/modules/auth.md @@ -53,10 +53,12 @@ from auth.deps import CurrentUser, require_permission router = APIRouter() + @router.get("/me") async def me(user: CurrentUser) -> dict: return {"email": user.email, "roles": user.roles} + @router.delete( "/orders/{order_id}", dependencies=[Depends(require_permission("orders.delete"))], diff --git a/docs/modules/background_tasks.md b/docs/modules/background_tasks.md index 01cbfb89..0772e693 100644 --- a/docs/modules/background_tasks.md +++ b/docs/modules/background_tasks.md @@ -17,9 +17,9 @@ Celery + Redis task queue with persistent history, an admin UI for monitoring + # modules/orders/orders/tasks.py from celery import shared_task + @shared_task(name="orders.send_receipt") -def send_receipt(order_id: int) -> None: - ... +def send_receipt(order_id: int) -> None: ... ``` That's it. Celery's `autodiscover_tasks(packages, related_name="tasks")` finds it at worker startup — no extra registration. From request code: @@ -41,9 +41,10 @@ A module schedules recurring work the same way it registers tasks — by shippin from celery import shared_task from celery.schedules import crontab + @shared_task(name="invoices.generate_recurring") -def generate_recurring() -> int: - ... +def generate_recurring() -> int: ... + # Discovered by build_celery and merged into the beat schedule. BEAT_SCHEDULE = { @@ -94,8 +95,11 @@ All require `background_tasks.view`; retry additionally needs `background_tasks. ```python from background_tasks.contracts.events import TaskFailed, TaskRetried from background_tasks.contracts.schemas import ( - TaskExecutionListItem, TaskExecutionDetail, TaskExecutionListResponse, - WorkerInfo, WorkerSnapshot, + TaskExecutionListItem, + TaskExecutionDetail, + TaskExecutionListResponse, + WorkerInfo, + WorkerSnapshot, ) ``` diff --git a/docs/modules/feature_flags.md b/docs/modules/feature_flags.md index 1ca60939..21ac570d 100644 --- a/docs/modules/feature_flags.md +++ b/docs/modules/feature_flags.md @@ -18,6 +18,7 @@ Anywhere a module has access to `app.state.sm.feature_flags` (typically inside ` ```python from simple_module_core.feature_flags import FeatureFlag + class OrdersModule(ModuleBase): def register_feature_flags(self, registry): registry.register( @@ -36,6 +37,7 @@ Once registered, the flag shows up at `/feature_flags` in the admin UI. Unregist ```python from feature_flags.deps import FeatureFlagRegistryDep + @router.post("/checkout") async def checkout( body: CheckoutRequest, diff --git a/docs/modules/file_storage.md b/docs/modules/file_storage.md index c6c23ee8..f2c3ccfb 100644 --- a/docs/modules/file_storage.md +++ b/docs/modules/file_storage.md @@ -33,11 +33,16 @@ Pluggable file storage with two shipped backends — local filesystem and S3-com ```python from file_storage.contracts import ( - StoredFileOut, StoredFileListOut, - FileUploaded, FileDeleted, + StoredFileOut, + StoredFileListOut, + FileUploaded, + FileDeleted, StorageBackend, - StorageError, StorageNotFoundError, StorageBackendError, - NotSupportedError, ConfigurationError, + StorageError, + StorageNotFoundError, + StorageBackendError, + NotSupportedError, + ConfigurationError, ) ``` @@ -102,6 +107,7 @@ Implement the `StorageBackend` protocol: ```python from file_storage.contracts import StorageBackend, StoredFile + class GcsBackend(StorageBackend): backend_id = "gcs" supports_presigned_url = True @@ -142,12 +148,12 @@ Subscribe from any other module's `register_event_handlers`: ```python from file_storage.contracts import FileUploaded + class MyModule(ModuleBase): def register_event_handlers(self, bus): bus.subscribe(FileUploaded, self._on_uploaded) - async def _on_uploaded(self, event: FileUploaded) -> None: - ... + async def _on_uploaded(self, event: FileUploaded) -> None: ... ``` ## Inertia pages diff --git a/docs/modules/keycloak.md b/docs/modules/keycloak.md index 066f9378..efa87c5c 100644 --- a/docs/modules/keycloak.md +++ b/docs/modules/keycloak.md @@ -21,6 +21,7 @@ The module sets the class flag `_is_auth_provider = True`, which is what the aut @runtime_checkable class AuthProvider(Protocol): name: str + async def resolve_user(self, request: Request) -> UserContext | None: ... def get_login_url(self, request: Request, next_url: str | None = None) -> str: ... def get_logout_url(self, request: Request) -> str: ... diff --git a/docs/modules/permissions.md b/docs/modules/permissions.md index f100a788..432a88e0 100644 --- a/docs/modules/permissions.md +++ b/docs/modules/permissions.md @@ -49,12 +49,12 @@ from permissions.deps import RequiresPermission router = APIRouter() + @router.delete( "/orders/{order_id}", dependencies=[Depends(RequiresPermission("orders.delete"))], ) -async def delete_order(order_id: int) -> None: - ... +async def delete_order(order_id: int) -> None: ... ``` `RequiresPermission(permission)` takes a **single** permission key and 403s unless the request's user holds it, considering: @@ -69,8 +69,13 @@ For something tied to *only* role membership (no direct grants), use `auth.deps. ```python from permissions.contracts.schemas import ( - PermissionGroupOut, RoleOut, RolePermissionsOut, RolePermissionsUpdate, - UserOut, UserPermissionsOut, UserPermissionsUpdate, + PermissionGroupOut, + RoleOut, + RolePermissionsOut, + RolePermissionsUpdate, + UserOut, + UserPermissionsOut, + UserPermissionsUpdate, ) ``` diff --git a/docs/modules/settings.md b/docs/modules/settings.md index c6297aaa..66224dd7 100644 --- a/docs/modules/settings.md +++ b/docs/modules/settings.md @@ -32,6 +32,7 @@ The pattern (pydantic `BaseSettings` subclass + `register_module_settings` in `r ```python from settings.contracts import SettingsDep + @router.get("/check") async def check(settings: SettingsDep) -> dict: if await settings.get_bool("orders.beta", default=False): @@ -56,6 +57,7 @@ async def check(settings: SettingsDep) -> dict: ```python from settings.contracts import SettingsRegistry, SettingDefinition + class OrdersModule(ModuleBase): def register_settings(self, app): registry: SettingsRegistry = app.state.sm.settings_registry @@ -108,9 +110,16 @@ All write endpoints require `settings.edit` / `settings.create` / `settings.dele ```python from settings.contracts import ( - SettingOut, SettingCreate, SettingUpdate, SettingUpsert, - SettingScope, SettingValueType, - SettingsAccessor, SettingsDep, SettingsRegistry, SettingDefinition, + SettingOut, + SettingCreate, + SettingUpdate, + SettingUpsert, + SettingScope, + SettingValueType, + SettingsAccessor, + SettingsDep, + SettingsRegistry, + SettingDefinition, SettingsReloaded, ) ``` diff --git a/docs/modules/users.md b/docs/modules/users.md index 016ecc35..5c7fba80 100644 --- a/docs/modules/users.md +++ b/docs/modules/users.md @@ -90,14 +90,26 @@ Admin (`users.manage`): ```python from users.contracts.schemas import ( - UserRead, UserCreate, UserUpdate, UserInvite, - UserAdminCreate, UserDetailsUpdate, - UserListItem, RoleListItem, RoleAssignment, - AcceptInviteRequest, PasswordResetLink, SelfProfileUpdate, + UserRead, + UserCreate, + UserUpdate, + UserInvite, + UserAdminCreate, + UserDetailsUpdate, + UserListItem, + RoleListItem, + RoleAssignment, + AcceptInviteRequest, + PasswordResetLink, + SelfProfileUpdate, ) from users.contracts.events import ( - UserRegistered, UserInvited, UserCreated, UserDeleted, - UserDisabled, RoleAssigned, + UserRegistered, + UserInvited, + UserCreated, + UserDeleted, + UserDisabled, + RoleAssigned, ) ``` diff --git a/docs/plans/2026-04-13-alembic-migrations-design.md b/docs/plans/2026-04-13-alembic-migrations-design.md index 6f8c8c84..a5c5be66 100644 --- a/docs/plans/2026-04-13-alembic-migrations-design.md +++ b/docs/plans/2026-04-13-alembic-migrations-design.md @@ -44,6 +44,7 @@ discover_modules() # Combine metadata from sqlalchemy import MetaData + target_metadata = MetaData() for base in all_module_bases: for table in base.metadata.tables.values(): @@ -69,9 +70,11 @@ async def _check_migrations(engine): head = script.get_current_head() async with engine.connect() as conn: + def get_current(sync_conn): ctx = MigrationContext.configure(sync_conn) return ctx.get_current_revision() + current = await conn.run_sync(get_current) if current != head: @@ -112,11 +115,8 @@ Alembic's autogenerate reflects all tables in the database and compares against **Solution:** Allowlist approach via `include_object` in `env.py`. ```python -MODULE_TABLES = { - t.name - for base in all_module_bases - for t in base.metadata.tables.values() -} +MODULE_TABLES = {t.name for base in all_module_bases for t in base.metadata.tables.values()} + def include_object(object, name, type_, reflected, compare_to): if type_ == "table": diff --git a/docs/plans/2026-04-13-alembic-migrations.md b/docs/plans/2026-04-13-alembic-migrations.md index 24c469c8..3fc03559 100644 --- a/docs/plans/2026-04-13-alembic-migrations.md +++ b/docs/plans/2026-04-13-alembic-migrations.md @@ -327,9 +327,11 @@ async def _check_migrations(engine, alembic_ini_path: str = "host/alembic.ini") head = script.get_current_head() async with engine.connect() as conn: + def _get_current(sync_conn): ctx = MigrationContext.configure(sync_conn) return ctx.get_current_revision() + current = await conn.run_sync(_get_current) is_current = current == head @@ -640,10 +642,7 @@ class MigrationDiagnostics: Diagnostic( level=DiagnosticLevel.ERROR, code="SM009", - message=( - f"Database at revision {current_revision!r}, " - f"expected {head_revision!r}" - ), + message=(f"Database at revision {current_revision!r}, expected {head_revision!r}"), module_name="migrations", suggestion="Run: make migrate", ) @@ -724,9 +723,7 @@ def run_diagnostics( ) ) if module_tables is not None and migrated_tables is not None: - diagnostics.extend( - migration_diag.check_table_coverage(module_tables, migrated_tables) - ) + diagnostics.extend(migration_diag.check_table_coverage(module_tables, migrated_tables)) return diagnostics ``` @@ -736,7 +733,12 @@ def run_diagnostics( In `framework/core/src/simple_module_core/__init__.py`, add to imports: ```python -from simple_module_core.diagnostics import DiagnosticLevel, MigrationDiagnostics, print_diagnostics, run_diagnostics +from simple_module_core.diagnostics import ( + DiagnosticLevel, + MigrationDiagnostics, + print_diagnostics, + run_diagnostics, +) ``` And add `"MigrationDiagnostics"` to `__all__`. @@ -774,20 +776,18 @@ Note: The module-level diagnostics stay as-is. The migration diagnostics run ins After `app.state.migration = await _check_migrations(app.state.db.engine)`, add: ```python - if app.state.settings.is_development: - from simple_module_db.base import all_module_bases - from simple_module_core.diagnostics import MigrationDiagnostics, print_diagnostics - - module_tables = { - t.name for base in all_module_bases for t in base.metadata.tables.values() - } - mig_diag = MigrationDiagnostics() - mig_diagnostics = mig_diag.check_table_coverage( - module_tables=module_tables, - migrated_tables=module_tables, # TODO: extract from migration scripts - ) - if mig_diagnostics: - print_diagnostics(mig_diagnostics) +if app.state.settings.is_development: + from simple_module_db.base import all_module_bases + from simple_module_core.diagnostics import MigrationDiagnostics, print_diagnostics + + module_tables = {t.name for base in all_module_bases for t in base.metadata.tables.values()} + mig_diag = MigrationDiagnostics() + mig_diagnostics = mig_diag.check_table_coverage( + module_tables=module_tables, + migrated_tables=module_tables, # TODO: extract from migration scripts + ) + if mig_diagnostics: + print_diagnostics(mig_diagnostics) ``` **Step 4: Run full test suite** diff --git a/docs/plans/2026-04-14-dx-hardening-design.md b/docs/plans/2026-04-14-dx-hardening-design.md index 0003d626..8542832d 100644 --- a/docs/plans/2026-04-14-dx-hardening-design.md +++ b/docs/plans/2026-04-14-dx-hardening-design.md @@ -96,7 +96,7 @@ Keep the existing `SM001` diagnostic — it still helps dev users catch the issu ```python multi_tenant: bool = False -tenant_header: str = "" # empty → header source disabled +tenant_header: str = "" # empty → header source disabled ``` In `app_builder`: diff --git a/docs/plans/2026-04-14-dx-hardening.md b/docs/plans/2026-04-14-dx-hardening.md index 260fd659..f2327480 100644 --- a/docs/plans/2026-04-14-dx-hardening.md +++ b/docs/plans/2026-04-14-dx-hardening.md @@ -83,16 +83,14 @@ Order is roughly "safest, smallest" first so the branch stays green after each c ``` with: ```python - _PROJECT_ROOT = Path( - os.environ.get("SM_PROJECT_ROOT") - or Path(__file__).resolve().parents[3] - ) + _PROJECT_ROOT = Path(os.environ.get("SM_PROJECT_ROOT") or Path(__file__).resolve().parents[3]) ``` Add `import os` if missing. 2. In `host/main.py`, before `create_app(...)`: ```python import os from pathlib import Path + os.environ.setdefault("SM_PROJECT_ROOT", str(Path(__file__).resolve().parent.parent)) ``` 3. Add a test that sets `SM_PROJECT_ROOT` to a tmp_path and asserts `app_builder` resolves static / templates under it (use `monkeypatch.setenv` + import reload or, cleaner, refactor the fallback into a helper and test the helper directly). @@ -170,6 +168,7 @@ Order is roughly "safest, smallest" first so the branch stays green after each c router = APIRouter() + @router.get("/", response_model=None) async def landing(inertia: InertiaDep) -> InertiaResponse: return await inertia.render("Landing") @@ -198,7 +197,7 @@ Order is roughly "safest, smallest" first so the branch stays green after each c 1. In `Settings`, add: ```python multi_tenant: bool = False - tenant_header: str = "" # empty disables header-based tenant resolution + tenant_header: str = "" # empty disables header-based tenant resolution ``` 2. In `TenantMiddleware.__init__`, accept `header: str | None = None`; skip the header lookup path when `header` is None or empty. 3. In `app_builder.create_app`, replace the unconditional `app.add_middleware(TenantMiddleware)` with: diff --git a/docs/superpowers/plans/2026-04-13-module-lifecycle-hooks.md b/docs/superpowers/plans/2026-04-13-module-lifecycle-hooks.md index d43575fc..af9c168f 100644 --- a/docs/superpowers/plans/2026-04-13-module-lifecycle-hooks.md +++ b/docs/superpowers/plans/2026-04-13-module-lifecycle-hooks.md @@ -144,10 +144,10 @@ from simple_module_core.health import HealthCheck, HealthCheckResult, HealthRegi Add to `__all__`: ```python - "HealthCheck", - "HealthCheckResult", - "HealthRegistry", - "HealthStatus", +("HealthCheck",) +("HealthCheckResult",) +("HealthRegistry",) +("HealthStatus",) ``` - [ ] **Step 5: Run tests to verify they pass** @@ -675,8 +675,12 @@ def _check_settings_registration(modules: list, app: FastAPI) -> None: # Snapshot known framework keys on app.state framework_keys = { - "menu_registry", "perm_registry", "ff_registry", - "event_bus", "health_registry", "settings", + "menu_registry", + "perm_registry", + "ff_registry", + "event_bus", + "health_registry", + "settings", } state_keys = {k for k in vars(app.state) if not k.startswith("_")} module_added_keys = state_keys - framework_keys @@ -856,6 +860,7 @@ async def readiness(request: Request) -> dict: # Run all checks concurrently results: dict[str, HealthCheckResult] = {} + async def _run_check(name: str, check_fn): try: return name, await check_fn() diff --git a/docs/superpowers/plans/2026-04-15-i18n-localization.md b/docs/superpowers/plans/2026-04-15-i18n-localization.md index 7bb7e8f4..6969d594 100644 --- a/docs/superpowers/plans/2026-04-15-i18n-localization.md +++ b/docs/superpowers/plans/2026-04-15-i18n-localization.md @@ -608,35 +608,36 @@ Expected: first test fails — `items_one` lookup only tries the base key. Replace the `Translator.t` method in `framework/core/simple_module_core/i18n.py` with: ```python - def t(self, key: str, **params: Any) -> str: - """Translate ``key`` with optional interpolation and plural resolution. +def t(self, key: str, **params: Any) -> str: + """Translate ``key`` with optional interpolation and plural resolution. - When ``count`` is in params, look up ``_`` using - Babel's CLDR plural rule for the active locale, falling back to - ``_other`` and finally ````. - """ - resolved_key = self._resolve_plural_key(key, params) - template = self._lookup(resolved_key) - if template is None and resolved_key != key: - template = self._lookup(key) - if template is None: - logger.debug("i18n: missing key '%s' in locale '%s'", key, self.locale) - return key - return template.format_map(_SafeFormatDict(params)) + When ``count`` is in params, look up ``_`` using + Babel's CLDR plural rule for the active locale, falling back to + ``_other`` and finally ````. + """ + resolved_key = self._resolve_plural_key(key, params) + template = self._lookup(resolved_key) + if template is None and resolved_key != key: + template = self._lookup(key) + if template is None: + logger.debug("i18n: missing key '%s' in locale '%s'", key, self.locale) + return key + return template.format_map(_SafeFormatDict(params)) - def _resolve_plural_key(self, key: str, params: dict[str, Any]) -> str: - count = params.get("count") - if count is None: - return key - form = _plural_form(self.locale, count) - # Prefer the exact form; fall back to _other if that form has no entry. - candidate = f"{key}_{form}" - if self._lookup(candidate) is not None: - return candidate - other = f"{key}_other" - if self._lookup(other) is not None: - return other + +def _resolve_plural_key(self, key: str, params: dict[str, Any]) -> str: + count = params.get("count") + if count is None: return key + form = _plural_form(self.locale, count) + # Prefer the exact form; fall back to _other if that form has no entry. + candidate = f"{key}_{form}" + if self._lookup(candidate) is not None: + return candidate + other = f"{key}_other" + if self._lookup(other) is not None: + return other + return key ``` Add the `_plural_form` helper above the class (after the imports): @@ -760,12 +761,12 @@ from simple_module_core.i18n import I18nRegistry, Translator And in the `__all__` list (alphabetically): ```python - "I18nRegistry", +("I18nRegistry",) ``` (after `"HealthStatus"`) ```python - "Translator", +("Translator",) ``` (after `"PermissionRegistry"`) @@ -1416,15 +1417,13 @@ Expected: 3 passed. In `framework/hosting/simple_module_hosting/app_builder.py`, find the block that emits `modules.generated.ts` (the `write_module_pages_manifest` call around lines 130-139). Immediately after it, add generation of resources: ```python - try: - from simple_module_hosting.i18n_manifest import write_generated_resources - - if client_app.is_dir(): - write_generated_resources(i18n_registry, client_app) - except Exception: - logger.exception( - "Failed to write generated-resources.ts — frontend types will be stale" - ) +try: + from simple_module_hosting.i18n_manifest import write_generated_resources + + if client_app.is_dir(): + write_generated_resources(i18n_registry, client_app) +except Exception: + logger.exception("Failed to write generated-resources.ts — frontend types will be stale") ``` - [ ] **Step 6: Commit** @@ -2539,9 +2538,7 @@ class I18nDiagnostics: Diagnostic( level=DiagnosticLevel.WARNING, code="SM013", - message=( - f"Missing locale file {locale}.json for namespace '{namespace}'" - ), + message=(f"Missing locale file {locale}.json for namespace '{namespace}'"), module_name=module_name, file=str(path), suggestion=f"Create {path} (even if empty: '{{}}')", @@ -2640,6 +2637,7 @@ def run_diagnostics( if i18n_supported_locales and i18n_default_locale: from simple_module_core.diagnostics._i18n import I18nDiagnostics + diagnostics.extend( I18nDiagnostics( supported_locales=i18n_supported_locales, @@ -2657,9 +2655,7 @@ def run_diagnostics( ) ) if module_tables is not None and migrated_tables is not None: - diagnostics.extend( - migration_diag.check_table_coverage(module_tables, migrated_tables) - ) + diagnostics.extend(migration_diag.check_table_coverage(module_tables, migrated_tables)) return diagnostics ``` @@ -3172,11 +3168,11 @@ def locales_en_json(ctx: ScaffoldContext) -> str: } """ % ( ctx.class_name, # title - ctx.name, # description plural + ctx.name, # description plural ctx.singular_class, # new button - ctx.name, # search placeholder - ctx.name, # empty title - ctx.singular, # empty description + ctx.name, # search placeholder + ctx.name, # empty title + ctx.singular, # empty description ctx.singular_class, # create button ctx.singular_class, # toast created ctx.singular_class, # toast updated diff --git a/docs/superpowers/plans/2026-04-17-app-state-organization.md b/docs/superpowers/plans/2026-04-17-app-state-organization.md index e4723856..bcbb9ddd 100644 --- a/docs/superpowers/plans/2026-04-17-app-state-organization.md +++ b/docs/superpowers/plans/2026-04-17-app-state-organization.md @@ -983,6 +983,7 @@ Open the file and scan for any `app.state.users_settings`, `app.state.mailer`, ` ```python from users.services import UsersServices + app.state.users = UsersServices(settings=...) ``` @@ -1272,7 +1273,7 @@ In `framework/hosting/simple_module_hosting/_inertia_setup.py` line 67, the assi Find the `Services(...)` construction block added in Task 2 Step 3; the line reads: ```python - inertia_config=app.state.inertia_config, +inertia_config = (app.state.inertia_config,) ``` Refactor the boot so `setup_inertia` returns the config rather than stashing it loose. In `_inertia_setup.py`, change the signature and return type: @@ -1319,7 +1320,7 @@ In `app_builder.py`, update the caller: Then the `Services(...)` line becomes: ```python - inertia_config=inertia_config, +inertia_config = (inertia_config,) ``` (If `inertia_config` is `None`, the boot cannot proceed anyway — make this explicit. Add a guard before `Services(...)`: diff --git a/docs/superpowers/plans/2026-04-17-users-module-quality-pass.md b/docs/superpowers/plans/2026-04-17-users-module-quality-pass.md index b4172ea1..f39afa1f 100644 --- a/docs/superpowers/plans/2026-04-17-users-module-quality-pass.md +++ b/docs/superpowers/plans/2026-04-17-users-module-quality-pass.md @@ -502,18 +502,14 @@ Add a new test class at the bottom of `test_api_admin.py`: class TestAdminResetPasswordLink: @pytest.mark.anyio async def test_nonexistent_returns_404(self, admin_client): - resp = await admin_client.post( - f"/api/users/admin/{uuid.uuid4()}/reset-password-link" - ) + resp = await admin_client.post(f"/api/users/admin/{uuid.uuid4()}/reset-password-link") assert resp.status_code == 404 assert resp.json()["detail"] == "User not found" @pytest.mark.anyio async def test_returns_link(self, admin_client, users_db): user = await _make_user(users_db, email="linktarget@example.com") - resp = await admin_client.post( - f"/api/users/admin/{user.id}/reset-password-link" - ) + resp = await admin_client.post(f"/api/users/admin/{user.id}/reset-password-link") assert resp.status_code == 200 body = resp.json() assert body["link"].startswith("http://testserver/users/reset-password?token=") diff --git a/docs/superpowers/plans/2026-04-20-users-admin-ux.md b/docs/superpowers/plans/2026-04-20-users-admin-ux.md index d379d042..26051708 100644 --- a/docs/superpowers/plans/2026-04-20-users-admin-ux.md +++ b/docs/superpowers/plans/2026-04-20-users-admin-ux.md @@ -155,15 +155,27 @@ async def test_list_users_status_disabled_filter(users_db): from users.models import User pw = PasswordHelper() - users_db.add(User( - id=_uuid.uuid4(), email="active@x.com", hashed_password=pw.hash("x"), - is_active=True, is_superuser=False, is_verified=True, - )) - users_db.add(User( - id=_uuid.uuid4(), email="off@x.com", hashed_password=pw.hash("x"), - is_active=False, is_superuser=False, is_verified=True, - disabled_at=datetime.now(UTC), - )) + users_db.add( + User( + id=_uuid.uuid4(), + email="active@x.com", + hashed_password=pw.hash("x"), + is_active=True, + is_superuser=False, + is_verified=True, + ) + ) + users_db.add( + User( + id=_uuid.uuid4(), + email="off@x.com", + hashed_password=pw.hash("x"), + is_active=False, + is_superuser=False, + is_verified=True, + disabled_at=datetime.now(UTC), + ) + ) await users_db.flush() svc = UserService(users_db, UserManager(None)) @@ -197,12 +209,20 @@ async def test_list_users_role_filter(users_db): admin_role = (await users_db.execute(select(Role).where(Role.name == "admin"))).scalar_one() admin_user = User( - id=_uuid.uuid4(), email="role-a@x.com", hashed_password=pw.hash("x"), - is_active=True, is_superuser=False, is_verified=True, + id=_uuid.uuid4(), + email="role-a@x.com", + hashed_password=pw.hash("x"), + is_active=True, + is_superuser=False, + is_verified=True, ) plain_user = User( - id=_uuid.uuid4(), email="role-b@x.com", hashed_password=pw.hash("x"), - is_active=True, is_superuser=False, is_verified=True, + id=_uuid.uuid4(), + email="role-b@x.com", + hashed_password=pw.hash("x"), + is_active=True, + is_superuser=False, + is_verified=True, ) users_db.add(admin_user) users_db.add(plain_user) @@ -225,10 +245,26 @@ async def test_list_users_verified_filter(users_db): from users.models import User pw = PasswordHelper() - users_db.add(User(id=_uuid.uuid4(), email="v@x.com", hashed_password=pw.hash("x"), - is_active=True, is_superuser=False, is_verified=True)) - users_db.add(User(id=_uuid.uuid4(), email="u@x.com", hashed_password=pw.hash("x"), - is_active=True, is_superuser=False, is_verified=False)) + users_db.add( + User( + id=_uuid.uuid4(), + email="v@x.com", + hashed_password=pw.hash("x"), + is_active=True, + is_superuser=False, + is_verified=True, + ) + ) + users_db.add( + User( + id=_uuid.uuid4(), + email="u@x.com", + hashed_password=pw.hash("x"), + is_active=True, + is_superuser=False, + is_verified=False, + ) + ) await users_db.flush() svc = UserService(users_db, UserManager(None)) @@ -248,13 +284,38 @@ async def test_list_users_sort_last_login_desc_nulls_last(users_db): pw = PasswordHelper() now = datetime.now(UTC) - users_db.add(User(id=_uuid.uuid4(), email="recent@x.com", hashed_password=pw.hash("x"), - is_active=True, is_superuser=False, is_verified=True, last_login_at=now)) - users_db.add(User(id=_uuid.uuid4(), email="old@x.com", hashed_password=pw.hash("x"), - is_active=True, is_superuser=False, is_verified=True, - last_login_at=now - timedelta(days=7))) - users_db.add(User(id=_uuid.uuid4(), email="never@x.com", hashed_password=pw.hash("x"), - is_active=True, is_superuser=False, is_verified=True)) + users_db.add( + User( + id=_uuid.uuid4(), + email="recent@x.com", + hashed_password=pw.hash("x"), + is_active=True, + is_superuser=False, + is_verified=True, + last_login_at=now, + ) + ) + users_db.add( + User( + id=_uuid.uuid4(), + email="old@x.com", + hashed_password=pw.hash("x"), + is_active=True, + is_superuser=False, + is_verified=True, + last_login_at=now - timedelta(days=7), + ) + ) + users_db.add( + User( + id=_uuid.uuid4(), + email="never@x.com", + hashed_password=pw.hash("x"), + is_active=True, + is_superuser=False, + is_verified=True, + ) + ) await users_db.flush() svc = UserService(users_db, UserManager(None)) @@ -276,77 +337,75 @@ Expected: all FAIL with TypeError on unknown kwargs. Replace `modules/users/users/service.py:65-91` with: ```python - async def list_users( - self, - *, - page: int = 1, - per_page: int = 20, - search: str | None = None, - status: str | None = None, - role_name: str | None = None, - verified: str | None = None, - sort: str = "email", - order: str = "asc", - ) -> tuple[list[UserListItem], int]: - """Returns (items, total_count). - - Filters: - - search: email/full_name ILIKE pattern - - status: "active" | "disabled" | None (=all) - - role_name: users holding this role - - verified: "yes" | "no" | None (=all) - Sort columns: "email" | "last_login_at" | "created_at" - Order: "asc" | "desc". last_login_at sorts NULLs last in both directions. - """ - stmt = select(User).options(selectinload(User.roles)) - count_stmt = select(func.count()).select_from(User) - - conditions = [] - if search: - pattern = f"%{search}%" - conditions.append( - or_(User.email.ilike(pattern), User.full_name.ilike(pattern)) - ) - if status == "active": - conditions.append(User.is_active.is_(True)) - elif status == "disabled": - conditions.append(User.is_active.is_(False)) - if verified == "yes": - conditions.append(User.is_verified.is_(True)) - elif verified == "no": - conditions.append(User.is_verified.is_(False)) - if role_name: - subq = ( - select(UserRole.user_id) - .join(Role, Role.id == UserRole.role_id) - .where(Role.name == role_name) - ) - conditions.append(User.id.in_(subq)) - - if conditions: - for cond in conditions: - stmt = stmt.where(cond) - count_stmt = count_stmt.where(cond) - - total = (await self._db.execute(count_stmt)).scalar_one() - - sort_col = { - "email": User.email, - "last_login_at": User.last_login_at, - "created_at": User.created_at, - }.get(sort, User.email) - if sort == "last_login_at": - # Always NULLs last — admins picking recency don't want never-logged-in on top - order_clause = ( - sort_col.desc().nulls_last() if order == "desc" else sort_col.asc().nulls_last() - ) - else: - order_clause = sort_col.desc() if order == "desc" else sort_col.asc() - stmt = stmt.order_by(order_clause).offset((page - 1) * per_page).limit(per_page) +async def list_users( + self, + *, + page: int = 1, + per_page: int = 20, + search: str | None = None, + status: str | None = None, + role_name: str | None = None, + verified: str | None = None, + sort: str = "email", + order: str = "asc", +) -> tuple[list[UserListItem], int]: + """Returns (items, total_count). + + Filters: + - search: email/full_name ILIKE pattern + - status: "active" | "disabled" | None (=all) + - role_name: users holding this role + - verified: "yes" | "no" | None (=all) + Sort columns: "email" | "last_login_at" | "created_at" + Order: "asc" | "desc". last_login_at sorts NULLs last in both directions. + """ + stmt = select(User).options(selectinload(User.roles)) + count_stmt = select(func.count()).select_from(User) + + conditions = [] + if search: + pattern = f"%{search}%" + conditions.append(or_(User.email.ilike(pattern), User.full_name.ilike(pattern))) + if status == "active": + conditions.append(User.is_active.is_(True)) + elif status == "disabled": + conditions.append(User.is_active.is_(False)) + if verified == "yes": + conditions.append(User.is_verified.is_(True)) + elif verified == "no": + conditions.append(User.is_verified.is_(False)) + if role_name: + subq = ( + select(UserRole.user_id) + .join(Role, Role.id == UserRole.role_id) + .where(Role.name == role_name) + ) + conditions.append(User.id.in_(subq)) + + if conditions: + for cond in conditions: + stmt = stmt.where(cond) + count_stmt = count_stmt.where(cond) + + total = (await self._db.execute(count_stmt)).scalar_one() + + sort_col = { + "email": User.email, + "last_login_at": User.last_login_at, + "created_at": User.created_at, + }.get(sort, User.email) + if sort == "last_login_at": + # Always NULLs last — admins picking recency don't want never-logged-in on top + order_clause = ( + sort_col.desc().nulls_last() if order == "desc" else sort_col.asc().nulls_last() + ) + else: + order_clause = sort_col.desc() if order == "desc" else sort_col.asc() + stmt = stmt.order_by(order_clause).offset((page - 1) * per_page).limit(per_page) - rows = (await self._db.execute(stmt)).scalars().all() - items = [await self.to_list_item(u) for u in rows] - return items, total + rows = (await self._db.execute(stmt)).scalars().all() + items = [await self.to_list_item(u) for u in rows] + return items, total ``` - [ ] **Step 2.6: Run filter tests — must pass** @@ -389,6 +448,7 @@ class TestAdminListFilters: await _make_user(users_db, email="on@x.com") # Build a disabled user directly from datetime import UTC, datetime + u = await _make_user(users_db, email="off@x.com") u.is_active = False u.disabled_at = datetime.now(UTC) @@ -411,6 +471,7 @@ class TestAdminListFilters: @pytest.mark.anyio async def test_sort_last_login_desc(self, admin_client, users_db): from datetime import UTC, datetime, timedelta + now = datetime.now(UTC) a = await _make_user(users_db, email="alpha@x.com") b = await _make_user(users_db, email="beta@x.com") @@ -418,9 +479,7 @@ class TestAdminListFilters: b.last_login_at = now await users_db.commit() - resp = await admin_client.get( - "/api/users/admin?sort=last_login_at&order=desc&per_page=50" - ) + resp = await admin_client.get("/api/users/admin?sort=last_login_at&order=desc&per_page=50") assert resp.status_code == 200 emails = [u["email"] for u in resp.json()] assert emails.index("beta@x.com") < emails.index("alpha@x.com") @@ -650,8 +709,12 @@ async def test_mark_verified_sets_flag_and_is_idempotent(users_db): pw = PasswordHelper() user = User( - id=_uuid.uuid4(), email="unv@x.com", hashed_password=pw.hash("x"), - is_active=True, is_superuser=False, is_verified=False, + id=_uuid.uuid4(), + email="unv@x.com", + hashed_password=pw.hash("x"), + is_active=True, + is_superuser=False, + is_verified=False, ) users_db.add(user) await users_db.flush() @@ -725,6 +788,7 @@ class TestAdminVerify: @pytest.mark.anyio async def test_verify_unknown_returns_404(self, admin_client): import uuid as _uuid + resp = await admin_client.patch(f"/api/users/admin/{_uuid.uuid4()}/verify") assert resp.status_code == 404 ``` @@ -791,8 +855,10 @@ class TestAdminEditCrosslink: # Fake that the permissions module is installed by stubbing app.state.sm.modules class _FakeMeta: name = "Permissions" + class _FakeMod: meta = _FakeMeta() + original = app.state.sm.modules app.state.sm = app.state.sm._replace(modules=(*original, _FakeMod())) try: @@ -851,9 +917,7 @@ async def admin_edit_page( except UserNotFoundError: raise HTTPException(status_code=404) from None - has_permissions = any( - m.meta.name == "Permissions" for m in request.app.state.sm.modules - ) + has_permissions = any(m.meta.name == "Permissions" for m in request.app.state.sm.modules) return await inertia.render( "Users/Users/Edit", diff --git a/docs/superpowers/plans/2026-04-21-db-backed-module-settings.md b/docs/superpowers/plans/2026-04-21-db-backed-module-settings.md index 891500ac..097e0c8f 100644 --- a/docs/superpowers/plans/2026-04-21-db-backed-module-settings.md +++ b/docs/superpowers/plans/2026-04-21-db-backed-module-settings.md @@ -262,16 +262,14 @@ class SettingsStore: for item in items: if not item.key.startswith(prefix): continue - field_name = item.key[len(prefix):] + field_name = item.key[len(prefix) :] # Skip nested keys — namespaces are flat for module settings. if "." in field_name: continue out[field_name] = (item.value, item.value_type) return out - async def set_override( - self, package: str, field: str, value: str, value_type: str - ) -> None: + async def set_override(self, package: str, field: str, value: str, value_type: str) -> None: await self._service.upsert_scoped( SettingScope.SYSTEM, SYSTEM_SCOPE_ID, @@ -556,7 +554,9 @@ def app() -> FastAPI: app = FastAPI() app.state.settings = SettingsServices( settings=SettingsSettings(), - registry=__import__("settings.contracts.registry", fromlist=["SettingsRegistry"]).SettingsRegistry(), + registry=__import__( + "settings.contracts.registry", fromlist=["SettingsRegistry"] + ).SettingsRegistry(), module_registry=ModuleSettingsRegistry(), ) return app @@ -818,7 +818,10 @@ async def test_apply_changes_updates_app_state_and_fires_event(app_and_bus, db_s store = SettingsStore(SettingService(db_session)) new_settings = await apply_changes_and_reload( - app, bus, store, package="users", + app, + bus, + store, + package="users", changes={"allow_signup": True, "smtp_port": 587}, ) @@ -840,7 +843,10 @@ async def test_apply_changes_validation_error_rolls_back(app_and_bus, db_session with pytest.raises(ValidationError): await apply_changes_and_reload( - app, bus, store, package="users", + app, + bus, + store, + package="users", changes={"smtp_port": "not-an-int"}, # fails pydantic int coercion ) @@ -855,7 +861,11 @@ async def test_apply_changes_unknown_package_raises(app_and_bus, db_session): with pytest.raises(KeyError): await apply_changes_and_reload( - app, bus, store, package="unknown", changes={"x": 1}, + app, + bus, + store, + package="unknown", + changes={"x": 1}, ) ``` @@ -1191,9 +1201,10 @@ This hooks `HostSettings` into the existing `ModuleSettingsRegistry` so the admi async def test_host_settings_registered_as_host_package(app): registry = app.state.settings.module_registry assert registry.get("host").__name__ == "HostSettings" - assert isinstance(app.state.host.settings, __import__( - "simple_module_hosting.host_settings", fromlist=["HostSettings"] - ).HostSettings) + assert isinstance( + app.state.host.settings, + __import__("simple_module_hosting.host_settings", fromlist=["HostSettings"]).HostSettings, + ) ``` - [ ] **Step 2: Run and confirm failure** @@ -1206,23 +1217,25 @@ Expected: FAIL — no "host" entry. Modify `framework/hosting/simple_module_hosting/app_builder.py` after Phase 4 (module settings registration, ~line 172): ```python - # ── Phase 4: Module settings ─────────────────────────── - for mod in modules: - mod.register_settings(app) - - # Register host-level settings under package="host" (DB-backed). The - # Settings module must already have run register_settings (topo order - # should put it early; its meta.depends_on = [] so it's scheduled first - # among leaves). - from dataclasses import dataclass - from simple_module_hosting.host_settings import HostSettings - from settings.registration import register_module_settings +# ── Phase 4: Module settings ─────────────────────────── +for mod in modules: + mod.register_settings(app) + +# Register host-level settings under package="host" (DB-backed). The +# Settings module must already have run register_settings (topo order +# should put it early; its meta.depends_on = [] so it's scheduled first +# among leaves). +from dataclasses import dataclass +from simple_module_hosting.host_settings import HostSettings +from settings.registration import register_module_settings + - @dataclass - class _HostServices: - settings: HostSettings +@dataclass +class _HostServices: + settings: HostSettings - register_module_settings(app, "host", HostSettings, lambda s: _HostServices(settings=s)) + +register_module_settings(app, "host", HostSettings, lambda s: _HostServices(settings=s)) ``` Note: we create the dataclass inline to avoid yet another tiny module. If lint flags it, extract to `framework/hosting/simple_module_hosting/_host_services.py`. @@ -1276,6 +1289,7 @@ async def test_lifespan_hydrates_host_settings_from_db(app, db_session): await store.set_override("host", "multi_tenant", "true", "bool") from simple_module_hosting._hydrate_step import hydrate_all + await hydrate_all(app, store) assert app.state.host.settings.multi_tenant is True @@ -1318,9 +1332,7 @@ async def hydrate_all(app: FastAPI, store: SettingsStore) -> None: hydrated = await hydrate_settings(cls, store, package) except Exception: # One bad override shouldn't prevent boot — log and keep defaults. - logger.exception( - "Hydrating %s failed; falling back to defaults", package - ) + logger.exception("Hydrating %s failed; falling back to defaults", package) continue services = getattr(app.state, package, None) if services is None: @@ -1464,15 +1476,17 @@ Keep the `@model_validator` for placeholder token secrets — it now runs on DB- Open `modules/users/users/module.py`. Find `register_settings` and rewrite it as: ```python - def register_settings(self, app: FastAPI) -> None: - from users.services import UsersServices # or whatever the dataclass is called - from users.settings import UsersSettings - from settings.registration import register_module_settings +def register_settings(self, app: FastAPI) -> None: + from users.services import UsersServices # or whatever the dataclass is called + from users.settings import UsersSettings + from settings.registration import register_module_settings - register_module_settings( - app, "users", UsersSettings, - lambda s: UsersServices(settings=s), - ) + register_module_settings( + app, + "users", + UsersSettings, + lambda s: UsersServices(settings=s), + ) ``` If `UsersServices` doesn't exist as a standalone dataclass, look at the existing `register_settings` and use the same factory signature. @@ -1535,6 +1549,7 @@ from pydantic import Field # ... + class BackgroundTasksSettings(BaseSettings): model_config = SettingsConfigDict(extra="ignore") @@ -1589,16 +1604,20 @@ Same pattern as 3.2. Group S3 fields: `json_schema_extra={"group": "S3"}`. Group - [ ] **Step 1: Strip env config; add group metadata** ```python - model_config = SettingsConfigDict(extra="ignore") - - backend: str = constants.DEFAULT_BACKEND - fs_root_path: str = Field(default=constants.DEFAULT_FS_ROOT, json_schema_extra={"group": "Filesystem"}) - s3_bucket: str = Field(default="", json_schema_extra={"group": "S3"}) - s3_region: str = Field(default="", json_schema_extra={"group": "S3"}) - s3_access_key_id: str = Field(default="", json_schema_extra={"group": "S3"}) - s3_secret_access_key: str = Field(default="", json_schema_extra={"group": "S3"}) - s3_endpoint_url: str = Field(default="", json_schema_extra={"group": "S3"}) - s3_presign_ttl_seconds: int = Field(default=constants.DEFAULT_PRESIGN_TTL_SECONDS, json_schema_extra={"group": "S3"}) +model_config = SettingsConfigDict(extra="ignore") + +backend: str = constants.DEFAULT_BACKEND +fs_root_path: str = Field( + default=constants.DEFAULT_FS_ROOT, json_schema_extra={"group": "Filesystem"} +) +s3_bucket: str = Field(default="", json_schema_extra={"group": "S3"}) +s3_region: str = Field(default="", json_schema_extra={"group": "S3"}) +s3_access_key_id: str = Field(default="", json_schema_extra={"group": "S3"}) +s3_secret_access_key: str = Field(default="", json_schema_extra={"group": "S3"}) +s3_endpoint_url: str = Field(default="", json_schema_extra={"group": "S3"}) +s3_presign_ttl_seconds: int = Field( + default=constants.DEFAULT_PRESIGN_TTL_SECONDS, json_schema_extra={"group": "S3"} +) ``` - [ ] **Step 2: Update `register_settings` to use the helper** @@ -1720,9 +1739,16 @@ def test_collect_exposes_type_requires_restart_group(): app.state.demo = _DemoServices() app.state.sm = Services( - settings=None, db=None, event_bus=None, menu_registry=None, - permissions=None, feature_flags=None, health_registry=None, - i18n_registry=None, inertia_config=None, modules=(), + settings=None, + db=None, + event_bus=None, + menu_registry=None, + permissions=None, + feature_flags=None, + health_registry=None, + i18n_registry=None, + inertia_config=None, + modules=(), ) views = collect_module_settings(app) @@ -1765,6 +1791,7 @@ Extend `_field_view` to read the type (via `value_type_for_field` from `hydrate. ```python from settings.hydrate import value_type_for_field + def _field_view(name: str, settings: BaseSettings, prefix: str) -> ModuleSettingField: info = type(settings).model_fields[name] raw_value = getattr(settings, name) @@ -1855,14 +1882,10 @@ async def test_put_validation_error_surfaces_422(authenticated_client, app): @pytest.mark.asyncio async def test_delete_field_resets_to_default(authenticated_client, app): - await authenticated_client.put( - "/api/settings/modules/host", json={"multi_tenant": True} - ) + await authenticated_client.put("/api/settings/modules/host", json={"multi_tenant": True}) assert app.state.host.settings.multi_tenant is True - resp = await authenticated_client.delete( - "/api/settings/modules/host/multi_tenant" - ) + resp = await authenticated_client.delete("/api/settings/modules/host/multi_tenant") assert resp.status_code == 204 assert app.state.host.settings.multi_tenant is False # back to default @@ -1939,9 +1962,9 @@ async def update_module( raise HTTPException(404, f"Unknown module: {package}") from settings._module_settings import _SECRET_PATTERNS # reuse secret regex + cleaned = { - k: v for k, v in body.items() - if not (_SECRET_PATTERNS.search(k) and v == _MASK_SENTINEL) + k: v for k, v in body.items() if not (_SECRET_PATTERNS.search(k) and v == _MASK_SENTINEL) } if not cleaned: return {"ok": True, "changed": []} @@ -1979,11 +2002,13 @@ async def reset_field( # Re-hydrate from DB (now missing the override) and reassign. from settings.hydrate import hydrate_settings + hydrated = await hydrate_settings(cls, store, package) services = getattr(request.app.state, package) services.settings = hydrated from settings.contracts.events import SettingsReloaded + await request.app.state.sm.event_bus.publish( SettingsReloaded(package=package, changed=(field,)) ) diff --git a/docs/superpowers/plans/2026-04-21-public-release.md b/docs/superpowers/plans/2026-04-21-public-release.md index fbf6cd29..928055dc 100644 --- a/docs/superpowers/plans/2026-04-21-public-release.md +++ b/docs/superpowers/plans/2026-04-21-public-release.md @@ -294,6 +294,7 @@ Before touching package metadata, build the scripts that enforce the metadata ru ```python """Shared fixtures for the release-scripts test suite.""" + from __future__ import annotations from pathlib import Path @@ -339,6 +340,7 @@ git commit -m "test: add scripts/tests package for release-script unit tests" ```python """Tests for scripts/check_metadata.py.""" + from __future__ import annotations from pathlib import Path @@ -562,6 +564,7 @@ Rules: Exit code 0 on success, 1 on any violation. Prints violations to stderr. """ + from __future__ import annotations import argparse @@ -602,9 +605,7 @@ def check_python_package(pyproject: Path) -> list[str]: urls = project.get("urls", {}) if str(urls.get("Repository", "")) != CANONICAL_REPO: - errors.append( - f"{rel}: project.urls.Repository must equal '{CANONICAL_REPO}'" - ) + errors.append(f"{rel}: project.urls.Repository must equal '{CANONICAL_REPO}'") return errors @@ -723,6 +724,7 @@ git commit -m "feat(scripts): add check_metadata.py lint for all 17 packages" ```python """Tests for scripts/check_readmes.py.""" + from __future__ import annotations from pathlib import Path @@ -795,6 +797,7 @@ Rules for every package directory (under framework/*, modules/*, packages/*): Exit 0 if all pass, 1 otherwise. """ + from __future__ import annotations import argparse @@ -1567,6 +1570,7 @@ class OrdersModule(ModuleBase): def register_routes(self, api_router, view_router): from .endpoints import api, views + api_router.include_router(api.router) view_router.include_router(views.router) ``` @@ -1701,11 +1705,12 @@ Minimal `main.py`: from simple_module_hosting import create_app from simple_module_hosting.settings import Settings -settings = Settings() # reads SM_* env vars -app = create_app(settings) # discovers + registers every installed module +settings = Settings() # reads SM_* env vars +app = create_app(settings) # discovers + registers every installed module if __name__ == "__main__": import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) ``` @@ -1903,7 +1908,8 @@ SM_USERS_BOOTSTRAP_PASSWORD=change-me Program: ```python -from users.deps import CurrentUser # type: ignore[import-not-found] +from users.deps import CurrentUser # type: ignore[import-not-found] + @router.get("/profile") async def profile(user: CurrentUser): @@ -2027,7 +2033,7 @@ Guard a route: ```python from fastapi import APIRouter, Depends -from permissions.deps import HasPermission # type: ignore[import-not-found] +from permissions.deps import HasPermission # type: ignore[import-not-found] router = APIRouter() @@ -2083,12 +2089,11 @@ Declare a task in a module: ```python # modules/reports/reports/tasks.py -from background_tasks import celery_app # type: ignore[import-not-found] +from background_tasks import celery_app # type: ignore[import-not-found] @celery_app.task(name="reports.generate") -def generate_report(report_id: int) -> None: - ... +def generate_report(report_id: int) -> None: ... ``` Register it: @@ -2161,7 +2166,8 @@ pip install "simple-module-file-storage[s3]" From another module: ```python -from file_storage.service import FileStorageService # type: ignore[import-not-found] +from file_storage.service import FileStorageService # type: ignore[import-not-found] + async def attach_receipt( svc: FileStorageService = Depends(FileStorageService), @@ -2285,7 +2291,7 @@ pip install simple-module-feature-flags Gate a route: ```python -from feature_flags import is_enabled # type: ignore[import-not-found] +from feature_flags import is_enabled # type: ignore[import-not-found] from fastapi import APIRouter, Depends, HTTPException router = APIRouter() @@ -2670,6 +2676,7 @@ git commit -m "docs: fix residual README issues flagged by check_readmes" || ech ```python """Tests for scripts/bump_version.py.""" + from __future__ import annotations import json @@ -2745,9 +2752,7 @@ NPM_SAMPLE = { def test_npm_bump_updates_version_and_inter_pkg(tmp_pkg_dir: Path, writer) -> None: - p = writer( - tmp_pkg_dir / "package.json", json.dumps(NPM_SAMPLE, indent=2) + "\n" - ) + p = writer(tmp_pkg_dir / "package.json", json.dumps(NPM_SAMPLE, indent=2) + "\n") bump_npm_package(p, "0.0.2") data = json.loads(p.read_text()) assert data["version"] == "0.0.2" @@ -2759,6 +2764,7 @@ def test_npm_bump_updates_version_and_inter_pkg(tmp_pkg_dir: Path, writer) -> No # -------- main() orchestration -------- + def _fake_repo(tmp_path: Path, writer) -> Path: writer( tmp_path / "framework/core/pyproject.toml", @@ -2781,10 +2787,7 @@ def test_main_bumps_all(tmp_path: Path, monkeypatch, writer) -> None: assert main(["0.0.2"]) == 0 assert 'version = "0.0.2"' in (tmp_path / "framework/core/pyproject.toml").read_text() assert 'version = "0.0.2"' in (tmp_path / "framework/db/pyproject.toml").read_text() - assert ( - '"simple-module-core==0.0.2"' - in (tmp_path / "framework/db/pyproject.toml").read_text() - ) + assert '"simple-module-core==0.0.2"' in (tmp_path / "framework/db/pyproject.toml").read_text() data = json.loads((tmp_path / "packages/ui/package.json").read_text()) assert data["version"] == "0.0.2" @@ -2838,6 +2841,7 @@ Usage: python scripts/bump_version.py 0.0.2 python scripts/bump_version.py 0.0.2 --check """ + from __future__ import annotations import argparse @@ -2972,7 +2976,9 @@ def main(argv: list[str] | None = None) -> int: return 1 if args.dry_run: - print(f"(dry-run) Would bump {len(py_files)} python + {len(npm_files)} npm packages to {args.version}.") + print( + f"(dry-run) Would bump {len(py_files)} python + {len(npm_files)} npm packages to {args.version}." + ) return 0 print(f"Bumped {len(py_files)} python + {len(npm_files)} npm packages to {args.version}.") @@ -3026,6 +3032,7 @@ This phase extends the existing `simple_module_hosting.cli` with a new `new` sub ```python """Tests for the `smpy new` / `simple-module new` CLI subcommand.""" + from __future__ import annotations import json @@ -3177,9 +3184,7 @@ def create_app_project( exact framework versions against PyPI/npm 0.0.x. """ if target.exists() and any(target.iterdir()): - raise FileExistsError( - f"Refusing to scaffold into non-empty directory: {target}" - ) + raise FileExistsError(f"Refusing to scaffold into non-empty directory: {target}") _create_host(target, name=name, modules=["users", "dashboard", "permissions"]) @@ -3329,8 +3334,11 @@ def new_project( ): result = subprocess.run(cmd, cwd=target, check=False) if result.returncode != 0: - click.echo(f"WARNING: {' '.join(cmd)} failed (exit {result.returncode}); " - f"finish setup manually.", err=True) + click.echo( + f"WARNING: {' '.join(cmd)} failed (exit {result.returncode}); " + f"finish setup manually.", + err=True, + ) return # Try Alembic upgrade — best-effort. diff --git a/docs/superpowers/plans/2026-04-26-cli-modules-and-bg-jobs.md b/docs/superpowers/plans/2026-04-26-cli-modules-and-bg-jobs.md index eb90cb54..c9f021fd 100644 --- a/docs/superpowers/plans/2026-04-26-cli-modules-and-bg-jobs.md +++ b/docs/superpowers/plans/2026-04-26-cli-modules-and-bg-jobs.md @@ -113,9 +113,7 @@ def test_catalog_keys_match_entry_names() -> None: def test_every_requires_value_is_a_known_catalog_key() -> None: for entry in CATALOG.values(): for required in entry.requires: - assert required in CATALOG, ( - f"{entry.name} requires unknown module {required!r}" - ) + assert required in CATALOG, f"{entry.name} requires unknown module {required!r}" def test_presets_only_reference_known_modules() -> None: @@ -224,23 +222,40 @@ class ModuleEntry: # Keys are snake_case; values mirror each module's real # ``ModuleMeta.depends_on`` (transcribed to catalog keys). CATALOG: dict[str, ModuleEntry] = { - "auth": ModuleEntry("auth", "simple_module_auth", "Auth"), - "users": ModuleEntry("users", "simple_module_users", "Users", requires=("auth",)), - "permissions": ModuleEntry("permissions", "simple_module_permissions", "Permissions", requires=("auth", "users")), - "products": ModuleEntry("products", "simple_module_products", "Products"), - "dashboard": ModuleEntry("dashboard", "simple_module_dashboard", "Dashboard", requires=("users", "products")), - "settings": ModuleEntry("settings", "simple_module_settings", "Settings"), - "feature_flags": ModuleEntry("feature_flags", "simple_module_feature_flags", "Feature Flags"), - "file_storage": ModuleEntry("file_storage", "simple_module_file_storage", "File Storage", requires=("settings",)), - "background_tasks": ModuleEntry("background_tasks", "simple_module_background_tasks", "Background Tasks", requires=("users",), recipe="background_tasks"), - "datasets": ModuleEntry("datasets", "simple_module_datasets", "Datasets", requires=("file_storage", "background_tasks")), + "auth": ModuleEntry("auth", "simple_module_auth", "Auth"), + "users": ModuleEntry("users", "simple_module_users", "Users", requires=("auth",)), + "permissions": ModuleEntry( + "permissions", "simple_module_permissions", "Permissions", requires=("auth", "users") + ), + "products": ModuleEntry("products", "simple_module_products", "Products"), + "dashboard": ModuleEntry( + "dashboard", "simple_module_dashboard", "Dashboard", requires=("users", "products") + ), + "settings": ModuleEntry("settings", "simple_module_settings", "Settings"), + "feature_flags": ModuleEntry("feature_flags", "simple_module_feature_flags", "Feature Flags"), + "file_storage": ModuleEntry( + "file_storage", "simple_module_file_storage", "File Storage", requires=("settings",) + ), + "background_tasks": ModuleEntry( + "background_tasks", + "simple_module_background_tasks", + "Background Tasks", + requires=("users",), + recipe="background_tasks", + ), + "datasets": ModuleEntry( + "datasets", + "simple_module_datasets", + "Datasets", + requires=("file_storage", "background_tasks"), + ), } PRESETS: dict[str, tuple[str, ...]] = { - "minimal": ("users",), + "minimal": ("users",), "standard": ("users", "dashboard", "permissions"), - "full": tuple(CATALOG), + "full": tuple(CATALOG), } @@ -254,9 +269,7 @@ def expand_deps(selected: Iterable[str]) -> tuple[list[str], list[tuple[str, str for name in selected_list: if name not in CATALOG: available = ", ".join(sorted(CATALOG)) - raise KeyError( - f"unknown module: {name!r}; available: {available}" - ) + raise KeyError(f"unknown module: {name!r}; available: {available}") explicit = set(selected_list) resolved: list[str] = [] @@ -444,7 +457,8 @@ def run_wizard(*, default_db: str, default_tenancy: bool) -> tuple[str, bool, li if preset_name == "custom": picked = [ - name for name in CATALOG + name + for name in CATALOG if click.confirm(f"Include {CATALOG[name].display}?", default=False) ] else: @@ -702,7 +716,8 @@ def test_background_tasks_recipe_registered() -> None: def test_recipe_writes_run_worker_script(tmp_path: Path) -> None: _scaffold_minimal_host(tmp_path) BackgroundTasksRecipe().apply( - tmp_path, ScaffoldCtx(name="demo", db="sqlite", tenancy=False, selected=("background_tasks",)) + tmp_path, + ScaffoldCtx(name="demo", db="sqlite", tenancy=False, selected=("background_tasks",)), ) script = tmp_path / "scripts" / "run_worker.py" assert script.is_file() @@ -714,7 +729,8 @@ def test_recipe_writes_run_worker_script(tmp_path: Path) -> None: def test_recipe_writes_compose_with_redis_worker_beat(tmp_path: Path) -> None: _scaffold_minimal_host(tmp_path) BackgroundTasksRecipe().apply( - tmp_path, ScaffoldCtx(name="demo", db="sqlite", tenancy=False, selected=("background_tasks",)) + tmp_path, + ScaffoldCtx(name="demo", db="sqlite", tenancy=False, selected=("background_tasks",)), ) compose = (tmp_path / "docker-compose.yml").read_text() assert "redis:" in compose @@ -726,7 +742,8 @@ def test_recipe_writes_compose_with_redis_worker_beat(tmp_path: Path) -> None: def test_recipe_writes_worker_dockerfile(tmp_path: Path) -> None: _scaffold_minimal_host(tmp_path) BackgroundTasksRecipe().apply( - tmp_path, ScaffoldCtx(name="demo", db="sqlite", tenancy=False, selected=("background_tasks",)) + tmp_path, + ScaffoldCtx(name="demo", db="sqlite", tenancy=False, selected=("background_tasks",)), ) dockerfile = (tmp_path / "docker" / "worker.Dockerfile").read_text() assert "FROM python:3.12-slim" in dockerfile @@ -736,7 +753,8 @@ def test_recipe_writes_worker_dockerfile(tmp_path: Path) -> None: def test_recipe_appends_makefile_targets(tmp_path: Path) -> None: _scaffold_minimal_host(tmp_path) BackgroundTasksRecipe().apply( - tmp_path, ScaffoldCtx(name="demo", db="sqlite", tenancy=False, selected=("background_tasks",)) + tmp_path, + ScaffoldCtx(name="demo", db="sqlite", tenancy=False, selected=("background_tasks",)), ) makefile = (tmp_path / "Makefile").read_text() assert "worker:" in makefile @@ -747,7 +765,8 @@ def test_recipe_appends_makefile_targets(tmp_path: Path) -> None: def test_recipe_sets_broker_url_env_var(tmp_path: Path) -> None: _scaffold_minimal_host(tmp_path) BackgroundTasksRecipe().apply( - tmp_path, ScaffoldCtx(name="demo", db="sqlite", tenancy=False, selected=("background_tasks",)) + tmp_path, + ScaffoldCtx(name="demo", db="sqlite", tenancy=False, selected=("background_tasks",)), ) env_text = (tmp_path / ".env.example").read_text() assert "SM_BG_TASKS_BROKER_URL=redis://redis:6379/0" in env_text @@ -923,7 +942,9 @@ def test_create_app_project_with_selected_kwarg(tmp_path: Path) -> None: from simple_module_hosting.scaffolding import create_app_project target = tmp_path / "demo" - create_app_project(target, name="demo", db="sqlite", tenancy=False, selected=["users", "background_tasks"]) + create_app_project( + target, name="demo", db="sqlite", tenancy=False, selected=["users", "background_tasks"] + ) pyproject = (target / "pyproject.toml").read_text() # background_tasks selected -> dep listed @@ -938,7 +959,9 @@ def test_create_app_project_runs_recipe_for_background_tasks(tmp_path: Path) -> from simple_module_hosting.scaffolding import create_app_project target = tmp_path / "demo" - create_app_project(target, name="demo", db="sqlite", tenancy=False, selected=["background_tasks"]) + create_app_project( + target, name="demo", db="sqlite", tenancy=False, selected=["background_tasks"] + ) assert (target / "scripts" / "run_worker.py").is_file() assert (target / "docker-compose.yml").is_file() @@ -1089,12 +1112,16 @@ def test_sm_new_with_explicit_with_flag(tmp_path: Path) -> None: result = runner.invoke( main, [ - "new", "demo", + "new", + "demo", "--yes", - "--preset", "minimal", - "--with", "background_tasks", + "--preset", + "minimal", + "--with", + "background_tasks", "--no-install", - "--dest", str(target), + "--dest", + str(target), ], ) assert result.exit_code == 0, result.output @@ -1302,6 +1329,7 @@ In `framework/hosting/simple_module_hosting/cli/__init__.py`: ```python from .new import new_project as _new_project + main.add_command(_new_project) ``` diff --git a/docs/superpowers/plans/2026-04-26-standalone-cli-package.md b/docs/superpowers/plans/2026-04-26-standalone-cli-package.md index 15bae97b..433d2dd9 100644 --- a/docs/superpowers/plans/2026-04-26-standalone-cli-package.md +++ b/docs/superpowers/plans/2026-04-26-standalone-cli-package.md @@ -322,6 +322,7 @@ def create_app_project( from simple_module_hosting.cli.catalog import CATALOG, PRESETS, expand_deps from simple_module_hosting.cli.recipes import RECIPES, ScaffoldCtx from simple_module_hosting.scaffolding import create_host + ... ``` @@ -700,6 +701,7 @@ def create_app_project( from simple_module.catalog import CATALOG, PRESETS, expand_deps from simple_module.recipes import RECIPES, ScaffoldCtx from simple_module.scaffolding import create_host + ... ``` @@ -939,9 +941,7 @@ def new_project( raise typer.Exit(code=1) try: - create_app_project( - target, name=name, db=db_value, tenancy=tenancy_value, selected=resolved - ) + create_app_project(target, name=name, db=db_value, tenancy=tenancy_value, selected=resolved) except FileExistsError as exc: typer.echo(f"ERROR: {exc}", err=True) raise typer.Exit(code=1) from exc @@ -995,9 +995,7 @@ _PRESET_CHOICES = ("minimal", "standard", "full", "custom") def run_wizard(*, default_db: str, default_tenancy: bool) -> tuple[str, bool, list[str]]: - db = typer.prompt( - "Database backend", default=default_db, type=str - ) + db = typer.prompt("Database backend", default=default_db, type=str) if db not in ("sqlite", "postgres"): typer.echo(f"Invalid database: {db!r}; expected sqlite or postgres", err=True) raise typer.Abort() @@ -1262,10 +1260,12 @@ The two tests that import `from simple_module_hosting.cli import main` (`framewo ```python # Before from simple_module_hosting.cli import main + runner.invoke(main, ["create-host", ...]) # After from simple_module.cli import app + runner.invoke(app, ["create-host", ...]) ``` @@ -1376,9 +1376,7 @@ def test_discover_mounts_valid_plugin(monkeypatch, fake_plugin_module) -> None: def test_discover_skips_broken_plugin(monkeypatch) -> None: bad = _make_entry("broken", "nonexistent_module:app") - monkeypatch.setattr( - "simple_module.plugins._iter_plugin_entries", lambda: [bad] - ) + monkeypatch.setattr("simple_module.plugins._iter_plugin_entries", lambda: [bad]) root = typer.Typer() discover_and_mount(root) # should not raise @@ -1390,9 +1388,7 @@ def test_discover_skips_broken_plugin(monkeypatch) -> None: def test_discover_warns_on_duplicate_subgroup(monkeypatch, fake_plugin_module, capsys) -> None: a = _make_entry("dup", fake_plugin_module) b = _make_entry("dup", fake_plugin_module) - monkeypatch.setattr( - "simple_module.plugins._iter_plugin_entries", lambda: [a, b] - ) + monkeypatch.setattr("simple_module.plugins._iter_plugin_entries", lambda: [a, b]) root = typer.Typer() discover_and_mount(root) captured = capsys.readouterr() @@ -1461,8 +1457,7 @@ def discover_and_mount(root: typer.Typer) -> None: plugin_app = entry.load() except Exception as exc: # noqa: BLE001 — plugin authors can fail in any way print( - f"[simple-module] failed to load plugin '{entry.name}' " - f"({entry.value}): {exc}", + f"[simple-module] failed to load plugin '{entry.name}' ({entry.value}): {exc}", file=sys.stderr, ) continue @@ -1542,9 +1537,7 @@ def test_help_lists_gen_pages_and_sync_js_deps() -> None: def test_gen_pages_errors_on_missing_client_app(tmp_path: Path) -> None: runner = CliRunner() - result = runner.invoke( - app, ["gen-pages", "--host-dir", str(tmp_path / "does-not-exist")] - ) + result = runner.invoke(app, ["gen-pages", "--host-dir", str(tmp_path / "does-not-exist")]) assert result.exit_code != 0 assert "not found" in result.output.lower() or "not found" in (result.stderr or "").lower() ``` @@ -1660,8 +1653,14 @@ def sync_js_deps( workspace = str(host_client_app.resolve()) cmd = [ - npm, "install", "--workspace", workspace, - "--save=false", "--no-audit", "--no-fund", *deduped, + npm, + "install", + "--workspace", + workspace, + "--save=false", + "--no-audit", + "--no-fund", + *deduped, ] typer.echo("Installing module JS deps:") for spec in deduped: @@ -1957,7 +1956,18 @@ from importlib.metadata import distribution def _normalize(req: str) -> str: """'typer (>=0.12)' -> 'typer'. Strip version specs + extras + spaces.""" - return req.split(";")[0].split("(")[0].split(">=")[0].split(">")[0].split("<")[0].split("==")[0].split("[")[0].strip().lower().replace("_", "-") + return ( + req.split(";")[0] + .split("(")[0] + .split(">=")[0] + .split(">")[0] + .split("<")[0] + .split("==")[0] + .split("[")[0] + .strip() + .lower() + .replace("_", "-") + ) def test_simple_module_runtime_deps_are_minimal() -> None: @@ -1966,8 +1976,7 @@ def test_simple_module_runtime_deps_are_minimal() -> None: # Allowed: declared deps + their transitive obligations are NOT checked here; # only the direct deps of `simple-module` itself. assert names == {"typer", "tomlkit"}, ( - f"simple-module direct deps drifted; got {sorted(names)}, " - "expected {'typer', 'tomlkit'}" + f"simple-module direct deps drifted; got {sorted(names)}, expected {{'typer', 'tomlkit'}}" ) ``` diff --git a/docs/superpowers/plans/2026-05-01-background-tasks-worker-status-page.md b/docs/superpowers/plans/2026-05-01-background-tasks-worker-status-page.md index d1d702dd..e5e4fdcb 100644 --- a/docs/superpowers/plans/2026-05-01-background-tasks-worker-status-page.md +++ b/docs/superpowers/plans/2026-05-01-background-tasks-worker-status-page.md @@ -408,9 +408,7 @@ class TestWorkersJsonEndpoint: ): from background_tasks import worker_inspector as wi - monkeypatch.setattr( - wi.WorkerInspector, "snapshot", lambda self: fake_snapshot - ) + monkeypatch.setattr(wi.WorkerInspector, "snapshot", lambda self: fake_snapshot) resp = await authenticated_client.get(f"{JSON_BASE}/workers") assert resp.status_code == 200 diff --git a/docs/superpowers/plans/2026-05-21-auth-principal-resolver.md b/docs/superpowers/plans/2026-05-21-auth-principal-resolver.md index 554aaffe..175e6a21 100644 --- a/docs/superpowers/plans/2026-05-21-auth-principal-resolver.md +++ b/docs/superpowers/plans/2026-05-21-auth-principal-resolver.md @@ -655,62 +655,60 @@ from starlette.responses import JSONResponse, RedirectResponse **(b)** Replace the body of `__call__` from the `session = scope["session"]` line through the end of the `if user_ctx is None and not is_public:` block with the version below. The DB-load fast path, `request.state.user` assignment, and `current_user_id` ContextVar lifecycle stay exactly as they were. ```python - session = scope["session"] - raw_user_id = session.get(_SESSION_USER_ID_KEY) - - user_ctx: UserContext | None = None - if raw_user_id: - user_id_str = str(raw_user_id) - # Fast path — rebuild from the signed session cookie. - user_ctx = UserContext.from_session_dict(session.get(SESSION_USER_CTX_KEY)) - if user_ctx is None or user_ctx.id != user_id_str: - try: - user_uuid = uuid.UUID(user_id_str) - except (ValueError, TypeError): - logger.warning("Invalid user_id in session: %r", raw_user_id) - session.pop(_SESSION_USER_ID_KEY, None) - session.pop(SESSION_USER_CTX_KEY, None) - user_ctx = None - else: - user_ctx = await self._load_user(scope, user_uuid) - if user_ctx is None: - # User was deleted / disabled since session creation. - session.pop(_SESSION_USER_ID_KEY, None) - session.pop(SESSION_USER_CTX_KEY, None) - else: - session[SESSION_USER_CTX_KEY] = user_ctx.to_session_dict() - - # Fall-through: registered principal resolvers (PAT, API key, ...). - # The session-cookie path above is authoritative; resolvers only run - # when no session-authenticated user was resolved. - if user_ctx is None: - auth_state = getattr(scope["app"].state, "auth", None) - resolvers = getattr(auth_state, "principal_resolvers", ()) if auth_state else () - if resolvers: - request = Request(scope) - for resolver in resolvers: - try: - user_ctx = await resolver(request) - except Exception: - logger.exception( - "Principal resolver %r raised; treating as no-match", - resolver, - ) - continue - if user_ctx is not None: - break - - if user_ctx is None and not is_public: - if path.startswith("/api/"): - response = JSONResponse( - {"detail": "Not authenticated"}, status_code=401 - ) +session = scope["session"] +raw_user_id = session.get(_SESSION_USER_ID_KEY) + +user_ctx: UserContext | None = None +if raw_user_id: + user_id_str = str(raw_user_id) + # Fast path — rebuild from the signed session cookie. + user_ctx = UserContext.from_session_dict(session.get(SESSION_USER_CTX_KEY)) + if user_ctx is None or user_ctx.id != user_id_str: + try: + user_uuid = uuid.UUID(user_id_str) + except (ValueError, TypeError): + logger.warning("Invalid user_id in session: %r", raw_user_id) + session.pop(_SESSION_USER_ID_KEY, None) + session.pop(SESSION_USER_CTX_KEY, None) + user_ctx = None + else: + user_ctx = await self._load_user(scope, user_uuid) + if user_ctx is None: + # User was deleted / disabled since session creation. + session.pop(_SESSION_USER_ID_KEY, None) + session.pop(SESSION_USER_CTX_KEY, None) else: - request = Request(scope) - session[_SESSION_NEXT_KEY] = str(request.url) - response = RedirectResponse(_LOGIN_REDIRECT, status_code=302) - await response(scope, receive, send) - return + session[SESSION_USER_CTX_KEY] = user_ctx.to_session_dict() + +# Fall-through: registered principal resolvers (PAT, API key, ...). +# The session-cookie path above is authoritative; resolvers only run +# when no session-authenticated user was resolved. +if user_ctx is None: + auth_state = getattr(scope["app"].state, "auth", None) + resolvers = getattr(auth_state, "principal_resolvers", ()) if auth_state else () + if resolvers: + request = Request(scope) + for resolver in resolvers: + try: + user_ctx = await resolver(request) + except Exception: + logger.exception( + "Principal resolver %r raised; treating as no-match", + resolver, + ) + continue + if user_ctx is not None: + break + +if user_ctx is None and not is_public: + if path.startswith("/api/"): + response = JSONResponse({"detail": "Not authenticated"}, status_code=401) + else: + request = Request(scope) + session[_SESSION_NEXT_KEY] = str(request.url) + response = RedirectResponse(_LOGIN_REDIRECT, status_code=302) + await response(scope, receive, send) + return ``` (Everything after this block — the `if user_ctx is not None:` setting `request.state.user` and managing `current_user_id` — stays exactly as today.) @@ -788,9 +786,7 @@ async def app_with_pat_resolver(app): @pytest.fixture async def pat_client(app_with_pat_resolver) -> AsyncGenerator[httpx.AsyncClient, None]: transport = httpx.ASGITransport(app=app_with_pat_resolver) - async with httpx.AsyncClient( - transport=transport, base_url="http://testserver" - ) as c: + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as c: yield c @@ -822,9 +818,7 @@ async def test_invalid_bearer_token_returns_401_on_api_path(pat_client): @pytest.mark.anyio async def test_no_auth_header_on_api_returns_401(pat_client): """No Authorization header on a private /api/* path → 401 JSON.""" - resp = await pat_client.get( - "/api/users/admin/users", follow_redirects=False - ) + resp = await pat_client.get("/api/users/admin/users", follow_redirects=False) assert resp.status_code == 401 assert resp.json() == {"detail": "Not authenticated"} diff --git a/docs/superpowers/plans/2026-05-27-audit-log-module.md b/docs/superpowers/plans/2026-05-27-audit-log-module.md index 916b8304..576601ec 100644 --- a/docs/superpowers/plans/2026-05-27-audit-log-module.md +++ b/docs/superpowers/plans/2026-05-27-audit-log-module.md @@ -265,14 +265,16 @@ def collect_audit_records( continue val = getattr(obj, col, None) changes.append({"field": col, "new": _serialize(val)}) - records.append(AuditRecord( - entity_type=type(obj).__name__, - entity_id=_entity_pk_str(obj), - action="created", - changes=changes, - user_id=user_id, - correlation_id=correlation_id, - )) + records.append( + AuditRecord( + entity_type=type(obj).__name__, + entity_id=_entity_pk_str(obj), + action="created", + changes=changes, + user_id=user_id, + correlation_id=correlation_id, + ) + ) for obj in list(session.dirty): if not session.is_modified(obj): @@ -290,32 +292,38 @@ def collect_audit_records( continue old_val = hist.deleted[0] if hist.deleted else None new_val = hist.added[0] if hist.added else None - changes.append({ - "field": col, - "old": _serialize(old_val), - "new": _serialize(new_val), - }) + changes.append( + { + "field": col, + "old": _serialize(old_val), + "new": _serialize(new_val), + } + ) if changes: - records.append(AuditRecord( - entity_type=type(obj).__name__, - entity_id=_entity_pk_str(obj), - action="updated", - changes=changes, - user_id=user_id, - correlation_id=correlation_id, - )) + records.append( + AuditRecord( + entity_type=type(obj).__name__, + entity_id=_entity_pk_str(obj), + action="updated", + changes=changes, + user_id=user_id, + correlation_id=correlation_id, + ) + ) for obj in list(session.deleted): if _is_excluded(obj): continue - records.append(AuditRecord( - entity_type=type(obj).__name__, - entity_id=_entity_pk_str(obj), - action="deleted", - changes=[], - user_id=user_id, - correlation_id=correlation_id, - )) + records.append( + AuditRecord( + entity_type=type(obj).__name__, + entity_id=_entity_pk_str(obj), + action="deleted", + changes=[], + user_id=user_id, + correlation_id=correlation_id, + ) + ) return records ``` @@ -429,6 +437,7 @@ In `framework/db/simple_module_db/session.py`, add the field to the dataclass: ```python from collections.abc import Callable + @dataclass class DatabaseState: """Holds all database state for a single application instance.""" @@ -471,21 +480,21 @@ def register_listeners(db_state: DatabaseState) -> None: At the end of `_before_flush_listener` (after the deleted loop, around line 186), add: ```python - # Audit callback — collect diffs and delegate to the registered consumer - if _db_state is not None and _db_state.audit_callback is not None: - from simple_module_db.audit import collect_audit_records +# Audit callback — collect diffs and delegate to the registered consumer +if _db_state is not None and _db_state.audit_callback is not None: + from simple_module_db.audit import collect_audit_records - correlation_id_val: str | None = None - try: - from simple_module_hosting.logging import correlation_id as _cid_var + correlation_id_val: str | None = None + try: + from simple_module_hosting.logging import correlation_id as _cid_var - correlation_id_val = _cid_var.get("") or None - except ImportError: - pass + correlation_id_val = _cid_var.get("") or None + except ImportError: + pass - records = collect_audit_records(session, user_id, correlation_id_val) - if records: - _db_state.audit_callback(session, records) + records = collect_audit_records(session, user_id, correlation_id_val) + if records: + _db_state.audit_callback(session, records) ``` - [ ] **Step 3: Re-export AuditRecord from __init__.py** @@ -761,9 +770,7 @@ class AuditEntry(Base, table=True): # ty: ignore[unsupported-base] action: str = Field(max_length=ACTION_MAX_LENGTH) changes: dict | list = Field(default_factory=list, sa_column=Column(JSON)) user_id: str | None = Field(default=None, max_length=USER_ID_MAX_LENGTH) - correlation_id: str | None = Field( - default=None, max_length=CORRELATION_ID_MAX_LENGTH - ) + correlation_id: str | None = Field(default=None, max_length=CORRELATION_ID_MAX_LENGTH) created_at: datetime = Field( default_factory=lambda: datetime.now(UTC), sa_type=DateTime(timezone=True), @@ -945,11 +952,7 @@ class AuditLogService: ) async def distinct_entity_types(self) -> list[str]: - stmt = ( - select(AuditEntry.entity_type) - .distinct() - .order_by(AuditEntry.entity_type) - ) + stmt = select(AuditEntry.entity_type).distinct().order_by(AuditEntry.entity_type) result = await self.db.execute(stmt) return list(result.scalars()) ``` @@ -1804,9 +1807,7 @@ class TestAuditLogCapture: assert entry["entity_type"] == "Setting" assert any(c["field"] == "key" for c in entry["changes"]) - async def test_update_entity_produces_diff( - self, authenticated_client: httpx.AsyncClient - ): + async def test_update_entity_produces_diff(self, authenticated_client: httpx.AsyncClient): """Updating a setting should record old/new values.""" create_resp = await authenticated_client.post( "/api/settings/", @@ -1863,9 +1864,7 @@ class TestAuditLogCapture: ) assert resp.status_code == 200 data = resp.json() - delete_entries = [ - i for i in data["items"] if i["action"] in ("deleted", "soft_deleted") - ] + delete_entries = [i for i in data["items"] if i["action"] in ("deleted", "soft_deleted")] assert len(delete_entries) >= 1 @@ -1893,9 +1892,7 @@ class TestAuditLogAPI: assert data["page_size"] == 2 assert len(data["items"]) <= 2 - async def test_unauthenticated_returns_redirect( - self, client: httpx.AsyncClient - ): + async def test_unauthenticated_returns_redirect(self, client: httpx.AsyncClient): resp = await client.get("/api/audit_log/", follow_redirects=False) assert resp.status_code in (302, 303, 403) @@ -1903,9 +1900,7 @@ class TestAuditLogAPI: class TestAuditLogRecursionGuard: """Verify that AuditEntry writes don't trigger more audit entries.""" - async def test_no_infinite_recursion( - self, authenticated_client: httpx.AsyncClient - ): + async def test_no_infinite_recursion(self, authenticated_client: httpx.AsyncClient): """Creating a setting should not cause exponential audit entries.""" await authenticated_client.post( "/api/settings/", diff --git a/docs/superpowers/plans/2026-05-27-pluggable-auth-keycloak.md b/docs/superpowers/plans/2026-05-27-pluggable-auth-keycloak.md index b064121c..cfda05b3 100644 --- a/docs/superpowers/plans/2026-05-27-pluggable-auth-keycloak.md +++ b/docs/superpowers/plans/2026-05-27-pluggable-auth-keycloak.md @@ -456,9 +456,11 @@ def _build_app(provider, *, principal_resolvers=None): @app.get("/{path:path}") async def catch_all(request: Request, path: str = ""): user = getattr(request.state, "user", None) - return JSONResponse({ - "user": user.to_session_dict() if user else None, - }) + return JSONResponse( + { + "user": user.to_session_dict() if user else None, + } + ) app.add_middleware(AuthMiddleware) app.add_middleware(SessionMiddleware, secret_key=SECRET) @@ -492,42 +494,32 @@ async def test_unauthenticated_browser_redirects_to_login(unauthenticated_app): async def test_unauthenticated_api_returns_401(unauthenticated_app): - async with httpx.AsyncClient( - app=unauthenticated_app, base_url="http://test" - ) as c: + async with httpx.AsyncClient(app=unauthenticated_app, base_url="http://test") as c: resp = await c.get("/api/protected") assert resp.status_code == 401 assert resp.json()["detail"] == "Not authenticated" async def test_unauthenticated_bearer_returns_401(unauthenticated_app): - async with httpx.AsyncClient( - app=unauthenticated_app, base_url="http://test" - ) as c: + async with httpx.AsyncClient(app=unauthenticated_app, base_url="http://test") as c: resp = await c.get("/some/page", headers={"Authorization": "Bearer bad"}) assert resp.status_code == 401 async def test_public_paths_skip_auth(unauthenticated_app): - async with httpx.AsyncClient( - app=unauthenticated_app, base_url="http://test" - ) as c: + async with httpx.AsyncClient(app=unauthenticated_app, base_url="http://test") as c: resp = await c.get("/stub/login") assert resp.status_code == 200 async def test_framework_public_paths_skip_auth(unauthenticated_app): - async with httpx.AsyncClient( - app=unauthenticated_app, base_url="http://test" - ) as c: + async with httpx.AsyncClient(app=unauthenticated_app, base_url="http://test") as c: resp = await c.get("/health") assert resp.status_code == 200 async def test_root_is_public(unauthenticated_app): - async with httpx.AsyncClient( - app=unauthenticated_app, base_url="http://test" - ) as c: + async with httpx.AsyncClient(app=unauthenticated_app, base_url="http://test") as c: resp = await c.get("/") assert resp.status_code == 200 @@ -555,9 +547,7 @@ async def test_resolver_exception_is_logged_and_skipped(): raise RuntimeError("boom") app = _build_app(_StubProvider(user=None), principal_resolvers=[bad_resolver]) - async with httpx.AsyncClient( - app=app, base_url="http://test", follow_redirects=False - ) as c: + async with httpx.AsyncClient(app=app, base_url="http://test", follow_redirects=False) as c: resp = await c.get("/protected/page") assert resp.status_code == 302 ``` @@ -633,9 +623,7 @@ class AuthMiddleware: ) if not is_public: prefix_paths, exact_paths = provider.get_public_paths() - is_public = ( - any(path.startswith(p) for p in prefix_paths) or path in exact_paths - ) + is_public = any(path.startswith(p) for p in prefix_paths) or path in exact_paths request = Request(scope) user_ctx = await provider.resolve_user(request) @@ -655,15 +643,11 @@ class AuthMiddleware: if user_ctx is None and not is_public: if path.startswith("/api/") or provider.is_bearer_request(request): - response = JSONResponse( - {"detail": "Not authenticated"}, status_code=401 - ) + response = JSONResponse({"detail": "Not authenticated"}, status_code=401) else: session = scope.get("session", {}) session[_SESSION_NEXT_KEY] = str(request.url) - response = RedirectResponse( - provider.get_login_url(request), status_code=302 - ) + response = RedirectResponse(provider.get_login_url(request), status_code=302) await response(scope, receive, send) return @@ -1013,11 +997,7 @@ class UsersAuthProvider: session_factory = scope["app"].state.sm.db.session_factory async with session_factory() as db_session: - stmt = ( - select(User) - .where(User.id == user_id) - .options(selectinload(User.roles)) - ) + stmt = select(User).where(User.id == user_id).options(selectinload(User.roles)) user = (await db_session.execute(stmt)).scalar_one_or_none() if user is None or not user.is_active or user.disabled_at is not None: return None @@ -1211,38 +1191,37 @@ In `framework/core/simple_module_core/diagnostics/_module.py`, add to the `run() Add the new method to `ModuleDiagnostics`: ```python - def _check_auth_provider_conflict(self, modules: list[ModuleBase]) -> list[Diagnostic]: - """SM020/SM021: exactly one auth provider module must be installed.""" - providers = [m for m in modules if getattr(m, "_is_auth_provider", False)] - diags: list[Diagnostic] = [] - if len(providers) > 1: - names = ", ".join(m.meta.name for m in providers) - diags.append( - Diagnostic( - level=DiagnosticLevel.ERROR, - code="SM020", - message=f"Multiple auth provider modules installed: {names}", - module_name=providers[0].meta.name, - suggestion=( - "Install only one auth provider " - "(e.g. 'users' OR 'keycloak', not both)" - ), - ) +def _check_auth_provider_conflict(self, modules: list[ModuleBase]) -> list[Diagnostic]: + """SM020/SM021: exactly one auth provider module must be installed.""" + providers = [m for m in modules if getattr(m, "_is_auth_provider", False)] + diags: list[Diagnostic] = [] + if len(providers) > 1: + names = ", ".join(m.meta.name for m in providers) + diags.append( + Diagnostic( + level=DiagnosticLevel.ERROR, + code="SM020", + message=f"Multiple auth provider modules installed: {names}", + module_name=providers[0].meta.name, + suggestion=( + "Install only one auth provider (e.g. 'users' OR 'keycloak', not both)" + ), ) - elif len(providers) == 0: - diags.append( - Diagnostic( - level=DiagnosticLevel.WARNING, - code="SM021", - message="No auth provider module installed", - module_name="(none)", - suggestion=( - "Install an auth provider module " - "(e.g. 'simple-module-users' or 'simple-module-keycloak')" - ), - ) + ) + elif len(providers) == 0: + diags.append( + Diagnostic( + level=DiagnosticLevel.WARNING, + code="SM021", + message="No auth provider module installed", + module_name="(none)", + suggestion=( + "Install an auth provider module " + "(e.g. 'simple-module-users' or 'simple-module-keycloak')" + ), ) - return diags + ) + return diags ``` - [ ] **Step 4: Add `_is_auth_provider = True` to UsersModule** @@ -1594,11 +1573,7 @@ class KeycloakModule(ModuleBase): provider.jwks_cache = state.jwks_cache def locale_dirs(self) -> dict[str, Path]: - return { - "keycloak": Path( - str(importlib.resources.files(__package__) / "locales") - ) - } + return {"keycloak": Path(str(importlib.resources.files(__package__) / "locales"))} ``` - [ ] **Step 7: Update root pyproject.toml** @@ -2362,9 +2337,7 @@ class KeycloakAuthProvider: session_factory = request.app.state.sm.db.session_factory sub = claims["sub"] async with session_factory() as db: - stmt = select(KeycloakUserCache).where( - KeycloakUserCache.keycloak_sub == sub - ) + stmt = select(KeycloakUserCache).where(KeycloakUserCache.keycloak_sub == sub) row = (await db.execute(stmt)).scalar_one_or_none() if row is None: import uuid as uuid_mod @@ -2780,9 +2753,7 @@ async def token_login(body: TokenRequest, request: Request, db: AsyncSession = D @router.post("/token/refresh", response_model=TokenResponse) -async def token_refresh( - body: RefreshRequest, request: Request, db: AsyncSession = Depends(get_db) -): +async def token_refresh(body: RefreshRequest, request: Request, db: AsyncSession = Depends(get_db)): """Exchange a refresh token for a new token pair (rotation).""" try: token_uuid = uuid_mod.UUID(body.refresh_token) @@ -2807,9 +2778,7 @@ async def token_refresh( @router.delete("/token") -async def token_revoke( - body: RefreshRequest, db: AsyncSession = Depends(get_db) -): +async def token_revoke(body: RefreshRequest, db: AsyncSession = Depends(get_db)): """Revoke a refresh token (mobile logout).""" try: token_uuid = uuid_mod.UUID(body.refresh_token) @@ -2863,8 +2832,9 @@ async def _create_token_pair( In `modules/users/users/module.py`, inside `register_routes`, add: ```python - from users.auth_local.token_api import router as token_router - api_router.include_router(token_router) +from users.auth_local.token_api import router as token_router + +api_router.include_router(token_router) ``` - [ ] **Step 6: Generate migration for refresh_token table** diff --git a/docs/superpowers/plans/2026-06-03-microsoft-oidc.md b/docs/superpowers/plans/2026-06-03-microsoft-oidc.md index 10e4cd2e..2efffee8 100644 --- a/docs/superpowers/plans/2026-06-03-microsoft-oidc.md +++ b/docs/superpowers/plans/2026-06-03-microsoft-oidc.md @@ -72,33 +72,31 @@ Expected: FAIL — `oauth_microsoft_*` attributes don't exist / `json_schema_ext In `modules/users/users/settings.py`, replace the current block (the comment + `oauth_google_*` / `oauth_github_*` / `oauth_oidc_*` fields, lines ~87–100) with: ```python - # OAuth / OIDC providers — configured via the admin settings UI - # (/settings/modules → Users). Credentials live in the DB-backed settings - # store and hydrate after boot; secret fields are masked in the UI (the - # same treatment the SMTP password gets). Provider changes apply live via - # the SettingsReloaded event — no restart (see users/module.py). - oauth_google_client_id: str = Field(default="", json_schema_extra={"group": "Google OAuth"}) - oauth_google_client_secret: str = Field(default="", json_schema_extra={"group": "Google OAuth"}) - oauth_github_client_id: str = Field(default="", json_schema_extra={"group": "GitHub OAuth"}) - oauth_github_client_secret: str = Field(default="", json_schema_extra={"group": "GitHub OAuth"}) - # Generic OIDC — any provider that exposes a discovery URL - # (Keycloak, Authentik, Auth0, Zitadel, ...). - oauth_oidc_client_id: str = Field(default="", json_schema_extra={"group": "OIDC"}) - oauth_oidc_client_secret: str = Field(default="", json_schema_extra={"group": "OIDC"}) - oauth_oidc_discovery_url: str = Field(default="", json_schema_extra={"group": "OIDC"}) - oauth_oidc_display_name: str = Field(default="OIDC", json_schema_extra={"group": "OIDC"}) - # Microsoft Entra ID / Microsoft accounts. tenant: "common" (any work/school - # or personal account), "organizations" (work/school only), or a tenant GUID - # to restrict sign-in to a single Entra tenant. - oauth_microsoft_client_id: str = Field( - default="", json_schema_extra={"group": "Microsoft OAuth"} - ) - oauth_microsoft_client_secret: str = Field( - default="", json_schema_extra={"group": "Microsoft OAuth"} - ) - oauth_microsoft_tenant: str = Field( - default="common", json_schema_extra={"group": "Microsoft OAuth"} - ) +# OAuth / OIDC providers — configured via the admin settings UI +# (/settings/modules → Users). Credentials live in the DB-backed settings +# store and hydrate after boot; secret fields are masked in the UI (the +# same treatment the SMTP password gets). Provider changes apply live via +# the SettingsReloaded event — no restart (see users/module.py). +oauth_google_client_id: str = Field(default="", json_schema_extra={"group": "Google OAuth"}) +oauth_google_client_secret: str = Field(default="", json_schema_extra={"group": "Google OAuth"}) +oauth_github_client_id: str = Field(default="", json_schema_extra={"group": "GitHub OAuth"}) +oauth_github_client_secret: str = Field(default="", json_schema_extra={"group": "GitHub OAuth"}) +# Generic OIDC — any provider that exposes a discovery URL +# (Keycloak, Authentik, Auth0, Zitadel, ...). +oauth_oidc_client_id: str = Field(default="", json_schema_extra={"group": "OIDC"}) +oauth_oidc_client_secret: str = Field(default="", json_schema_extra={"group": "OIDC"}) +oauth_oidc_discovery_url: str = Field(default="", json_schema_extra={"group": "OIDC"}) +oauth_oidc_display_name: str = Field(default="OIDC", json_schema_extra={"group": "OIDC"}) +# Microsoft Entra ID / Microsoft accounts. tenant: "common" (any work/school +# or personal account), "organizations" (work/school only), or a tenant GUID +# to restrict sign-in to a single Entra tenant. +oauth_microsoft_client_id: str = Field(default="", json_schema_extra={"group": "Microsoft OAuth"}) +oauth_microsoft_client_secret: str = Field( + default="", json_schema_extra={"group": "Microsoft OAuth"} +) +oauth_microsoft_tenant: str = Field( + default="common", json_schema_extra={"group": "Microsoft OAuth"} +) ``` Notes: @@ -507,11 +505,10 @@ to: Replace the button-list assignment (current line ~178) `state.oauth_providers = enabled_provider_names(s)` with: ```python - state.oauth_clients = build_client_map(s) - state.oauth_providers = [ - {"name": p.name, "display_name": p.display_name} - for p in state.oauth_clients.values() - ] +state.oauth_clients = build_client_map(s) +state.oauth_providers = [ + {"name": p.name, "display_name": p.display_name} for p in state.oauth_clients.values() +] ``` - [ ] **Step 6: Remove `enabled_provider_names`** @@ -622,32 +619,31 @@ Expected: FAIL — publishing the event has no effect (no subscriber yet); the " In `modules/users/users/module.py`, add this method to the `UsersModule` class (place it after `register_settings`). Keep the `EventBus`/`FastAPI` types behind `TYPE_CHECKING` — `FastAPI` is already imported there; add `EventBus` to that block: ```python - def register_event_handlers(self, bus: EventBus, app: FastAPI | None = None) -> None: - """Rebuild the OAuth client cache when the users settings reload. - - Routes mount at construction (before DB hydration), so the cache is the - single source of truth at request time. Rebuilding it here lets an admin - add/remove a provider via the settings UI without a restart. - """ - if app is None: - return +def register_event_handlers(self, bus: EventBus, app: FastAPI | None = None) -> None: + """Rebuild the OAuth client cache when the users settings reload. - import importlib + Routes mount at construction (before DB hydration), so the cache is the + single source of truth at request time. Rebuilding it here lets an admin + add/remove a provider via the settings UI without a restart. + """ + if app is None: + return - settings_reloaded = importlib.import_module("settings.contracts.events").SettingsReloaded - from users.oauth.providers import build_client_map + import importlib + + settings_reloaded = importlib.import_module("settings.contracts.events").SettingsReloaded + from users.oauth.providers import build_client_map + + async def _rebuild_oauth_clients(event) -> None: + if event.package != "users": + return + state = app.state.users + state.oauth_clients = build_client_map(state.settings) + state.oauth_providers = [ + {"name": p.name, "display_name": p.display_name} for p in state.oauth_clients.values() + ] - async def _rebuild_oauth_clients(event) -> None: - if event.package != "users": - return - state = app.state.users - state.oauth_clients = build_client_map(state.settings) - state.oauth_providers = [ - {"name": p.name, "display_name": p.display_name} - for p in state.oauth_clients.values() - ] - - bus.subscribe(settings_reloaded, _rebuild_oauth_clients) + bus.subscribe(settings_reloaded, _rebuild_oauth_clients) ``` Add the `EventBus` import to the `TYPE_CHECKING` block at the top of the file: diff --git a/docs/superpowers/plans/2026-06-21-admin-user-crud.md b/docs/superpowers/plans/2026-06-21-admin-user-crud.md index d0d9ceac..84a2799a 100644 --- a/docs/superpowers/plans/2026-06-21-admin-user-crud.md +++ b/docs/superpowers/plans/2026-06-21-admin-user-crud.md @@ -656,9 +656,7 @@ async def admin_create_user( ) from None except fa_exceptions.InvalidPasswordException as exc: raise HTTPException(status_code=400, detail=exc.reason) from None - await bus.publish( - UserCreated(user_id=user.id, email=user.email, created_by=created_by) - ) + await bus.publish(UserCreated(user_id=user.id, email=user.email, created_by=created_by)) return service.to_list_item(user) ``` @@ -712,9 +710,7 @@ async def test_update_details_changes_email_and_name(users_app): async with users_app.state.sm.db.session_factory() as session: user = await _make_user(session, email="old@example.com") svc = _build_service(session, users_app) - updated = await svc.update_details( - user.id, email="new@example.com", full_name="New Name" - ) + updated = await svc.update_details(user.id, email="new@example.com", full_name="New Name") assert updated.email == "new@example.com" assert updated.full_name == "New Name" @@ -739,9 +735,7 @@ async def test_update_details_same_email_is_allowed(users_app): async with users_app.state.sm.db.session_factory() as session: user = await _make_user(session, email="keep@example.com") svc = _build_service(session, users_app) - updated = await svc.update_details( - user.id, email="keep@example.com", full_name="Renamed" - ) + updated = await svc.update_details(user.id, email="keep@example.com", full_name="Renamed") assert updated.email == "keep@example.com" assert updated.full_name == "Renamed" ``` @@ -1074,9 +1068,7 @@ class TestAdminDelete: async with users_app.state.sm.db.session_factory() as session: admin = ( - await session.execute( - select(User).where(User.email == "admin@example.com") - ) + await session.execute(select(User).where(User.email == "admin@example.com")) ).scalar_one() resp = await admin_client.delete(f"/api/users/admin/{admin.id}") assert resp.status_code == 400 diff --git a/docs/superpowers/specs/2026-04-13-module-lifecycle-hooks-design.md b/docs/superpowers/specs/2026-04-13-module-lifecycle-hooks-design.md index 716f830d..ab56d90c 100644 --- a/docs/superpowers/specs/2026-04-13-module-lifecycle-hooks-design.md +++ b/docs/superpowers/specs/2026-04-13-module-lifecycle-hooks-design.md @@ -106,9 +106,11 @@ class AuthSettings(BaseSettings): keycloak_client_id: str = "simple-module-app" keycloak_client_secret: str = "" + # In AuthModule def register_settings(self, app: FastAPI) -> None: from sm_auth.settings import AuthSettings + app.state.auth_settings = AuthSettings() ``` diff --git a/docs/superpowers/specs/2026-04-15-dashboard-improvements-design.md b/docs/superpowers/specs/2026-04-15-dashboard-improvements-design.md index 3199391d..8053a5ad 100644 --- a/docs/superpowers/specs/2026-04-15-dashboard-improvements-design.md +++ b/docs/superpowers/specs/2026-04-15-dashboard-improvements-design.md @@ -80,10 +80,13 @@ The Inertia view endpoint (`GET /dashboard`) passes all stats as page props: @router.get("/") async def dashboard(inertia: InertiaDep, t: TranslatorDep, db: ...) -> InertiaResponse: stats = await fetch_dashboard_stats(db, request) - return await inertia.render("Dashboard/Home", { - "welcome": t.t("dashboard.home.welcome_message"), - **stats, - }) + return await inertia.render( + "Dashboard/Home", + { + "welcome": t.t("dashboard.home.welcome_message"), + **stats, + }, + ) ``` ### Section 5: Frontend — Home.tsx (rewritten) diff --git a/docs/superpowers/specs/2026-04-15-i18n-localization-design.md b/docs/superpowers/specs/2026-04-15-i18n-localization-design.md index 1526766d..ae6bad59 100644 --- a/docs/superpowers/specs/2026-04-15-i18n-localization-design.md +++ b/docs/superpowers/specs/2026-04-15-i18n-localization-design.md @@ -138,6 +138,7 @@ Interpolation placeholders use `{name}` syntax (consistent between frontend and ```python # framework/core/simple_module_core/i18n.py + class I18nRegistry: """Merged view of all module locale JSON files, keyed by locale.""" @@ -192,7 +193,7 @@ from babel.plural import PluralRule from babel import Locale rule = Locale.parse(locale).plural_form # Callable[[int|float], str] -category = rule(params["count"]) # "zero" | "one" | ... | "other" +category = rule(params["count"]) # "zero" | "one" | ... | "other" pluralized_key = f"{key}_{category}" ``` @@ -210,6 +211,7 @@ pluralized_key = f"{key}_{category}" ```python async def get_translator(request: Request) -> Translator: ... + TranslatorDep = Annotated[Translator, Depends(get_translator)] ``` @@ -243,7 +245,7 @@ pluralized_key = f"{key}_{category}" ```python # In Settings (pydantic-settings model): i18n_default_locale: str = "en" -i18n_supported_locales: list[str] = ["en"] # comma-separated in env +i18n_supported_locales: list[str] = ["en"] # comma-separated in env i18n_cookie_name: str = "locale" ``` @@ -401,6 +403,7 @@ Scaffolded `module.py` includes: ```python def locale_dirs(self) -> dict[str, Path]: from importlib.resources import files + return {"orders": files(__package__) / "locales"} ``` diff --git a/docs/superpowers/specs/2026-04-17-app-state-organization-design.md b/docs/superpowers/specs/2026-04-17-app-state-organization-design.md index 119aef60..b08a4a15 100644 --- a/docs/superpowers/specs/2026-04-17-app-state-organization-design.md +++ b/docs/superpowers/specs/2026-04-17-app-state-organization-design.md @@ -33,6 +33,7 @@ New module `framework/core/simple_module_core/services.py` defines: ```python from dataclasses import dataclass + @dataclass(frozen=True, slots=True) class Services: settings: Settings diff --git a/docs/superpowers/specs/2026-04-21-db-backed-module-settings-design.md b/docs/superpowers/specs/2026-04-21-db-backed-module-settings-design.md index 6e546562..eb28f297 100644 --- a/docs/superpowers/specs/2026-04-21-db-backed-module-settings-design.md +++ b/docs/superpowers/specs/2026-04-21-db-backed-module-settings-design.md @@ -70,6 +70,7 @@ Keys in the `Setting` table are written as `"."` to avoid collis def load_settings(cls: type[T]) -> T: """Construct a BaseSettings using pydantic defaults only (no DB, no env).""" + async def hydrate_settings(cls: type[T], store: SettingsStore, package: str) -> T: """Return a BaseSettings where each field is (DB value > default).""" ``` diff --git a/docs/superpowers/specs/2026-04-26-cli-modules-and-bg-jobs-design.md b/docs/superpowers/specs/2026-04-26-cli-modules-and-bg-jobs-design.md index 65a994ab..ef3d615e 100644 --- a/docs/superpowers/specs/2026-04-26-cli-modules-and-bg-jobs-design.md +++ b/docs/superpowers/specs/2026-04-26-cli-modules-and-bg-jobs-design.md @@ -53,33 +53,53 @@ Each file has one responsibility and stays under the 300-line cap. Existing comm # cli/catalog.py from dataclasses import dataclass + @dataclass(frozen=True) class ModuleEntry: - name: str # "background_tasks" — snake_case key - package: str # "simple_module_background_tasks" - display: str # "Background Tasks" - requires: tuple[str, ...] = () # other catalog keys - recipe: str | None = None # key into RECIPES, or None + name: str # "background_tasks" — snake_case key + package: str # "simple_module_background_tasks" + display: str # "Background Tasks" + requires: tuple[str, ...] = () # other catalog keys + recipe: str | None = None # key into RECIPES, or None + CATALOG: dict[str, ModuleEntry] = { - "auth": ModuleEntry("auth", "simple_module_auth", "Auth"), - "users": ModuleEntry("users", "simple_module_users", "Users", requires=("auth",)), - "permissions": ModuleEntry("permissions", "simple_module_permissions", "Permissions", requires=("auth", "users")), - "dashboard": ModuleEntry("dashboard", "simple_module_dashboard", "Dashboard", requires=("users", "products")), - "settings": ModuleEntry("settings", "simple_module_settings", "Settings"), - "feature_flags": ModuleEntry("feature_flags", "simple_module_feature_flags", "Feature Flags"), - "file_storage": ModuleEntry("file_storage", "simple_module_file_storage", "File Storage", requires=("settings",)), - "products": ModuleEntry("products", "simple_module_products", "Products"), - "datasets": ModuleEntry("datasets", "simple_module_datasets", "Datasets", requires=("file_storage", "background_tasks")), - "background_tasks": ModuleEntry("background_tasks", "simple_module_background_tasks", "Background Tasks", requires=("users",), recipe="background_tasks"), + "auth": ModuleEntry("auth", "simple_module_auth", "Auth"), + "users": ModuleEntry("users", "simple_module_users", "Users", requires=("auth",)), + "permissions": ModuleEntry( + "permissions", "simple_module_permissions", "Permissions", requires=("auth", "users") + ), + "dashboard": ModuleEntry( + "dashboard", "simple_module_dashboard", "Dashboard", requires=("users", "products") + ), + "settings": ModuleEntry("settings", "simple_module_settings", "Settings"), + "feature_flags": ModuleEntry("feature_flags", "simple_module_feature_flags", "Feature Flags"), + "file_storage": ModuleEntry( + "file_storage", "simple_module_file_storage", "File Storage", requires=("settings",) + ), + "products": ModuleEntry("products", "simple_module_products", "Products"), + "datasets": ModuleEntry( + "datasets", + "simple_module_datasets", + "Datasets", + requires=("file_storage", "background_tasks"), + ), + "background_tasks": ModuleEntry( + "background_tasks", + "simple_module_background_tasks", + "Background Tasks", + requires=("users",), + recipe="background_tasks", + ), } PRESETS: dict[str, tuple[str, ...]] = { - "minimal": ("users",), + "minimal": ("users",), "standard": ("users", "dashboard", "permissions"), - "full": tuple(CATALOG), + "full": tuple(CATALOG), } + def expand_deps(selected: Iterable[str]) -> tuple[list[str], list[tuple[str, str]]]: """Return (resolved_topo_order, auto_added_pairs). auto_added_pairs is [(added_module, required_by), ...] for printing.""" @@ -129,6 +149,7 @@ from typing import Protocol from dataclasses import dataclass from pathlib import Path + @dataclass class ScaffoldCtx: name: str @@ -136,9 +157,11 @@ class ScaffoldCtx: tenancy: bool selected: tuple[str, ...] + class Recipe(Protocol): def apply(self, target: Path, ctx: ScaffoldCtx) -> None: ... + RECIPES: dict[str, Recipe] = { "background_tasks": BackgroundTasksRecipe(), } @@ -174,6 +197,7 @@ The `_optional/` segment is filtered out by `_apply_template_files` so it's not ```python from background_tasks.celery_app import build_celery from background_tasks.settings import BackgroundTasksSettings + celery = build_celery(BackgroundTasksSettings()) ``` diff --git a/docs/superpowers/specs/2026-05-01-background-tasks-worker-status-page-design.md b/docs/superpowers/specs/2026-05-01-background-tasks-worker-status-page-design.md index d6b83e46..d5c9539f 100644 --- a/docs/superpowers/specs/2026-05-01-background-tasks-worker-status-page-design.md +++ b/docs/superpowers/specs/2026-05-01-background-tasks-worker-status-page-design.md @@ -74,7 +74,8 @@ class WorkerInfo(SQLModel): active_task_count: int = 0 pool_size: int | None = None total_processed: int | None = None # sum across stats()['total'] - software: str | None = None # e.g. "celery:5.3.6" + software: str | None = None # e.g. "celery:5.3.6" + class WorkerSnapshot(SQLModel): broker_reachable: bool diff --git a/docs/superpowers/specs/2026-05-21-auth-principal-resolver-design.md b/docs/superpowers/specs/2026-05-21-auth-principal-resolver-design.md index 0ecf827c..2f1c46eb 100644 --- a/docs/superpowers/specs/2026-05-21-auth-principal-resolver-design.md +++ b/docs/superpowers/specs/2026-05-21-auth-principal-resolver-design.md @@ -47,6 +47,7 @@ Documented invariants on every `PrincipalResolver`: from dataclasses import dataclass, field from auth.contracts.resolver import PrincipalResolver + @dataclass class AuthState: principal_resolvers: list[PrincipalResolver] = field(default_factory=list) @@ -57,6 +58,7 @@ class AuthState: ```python def register_settings(self, app: FastAPI) -> None: from auth.state import AuthState + app.state.auth = AuthState() ``` @@ -88,9 +90,7 @@ if user_ctx is None: try: user_ctx = await resolver(request) except Exception: - logger.exception( - "Principal resolver %r raised; treating as no-match", resolver - ) + logger.exception("Principal resolver %r raised; treating as no-match", resolver) continue if user_ctx is not None: break @@ -124,6 +124,7 @@ No other change is visible to session-cookie users. The resolver chain is empty # In a downstream module's on_startup from auth import PrincipalResolver, UserContext + async def my_pat_resolver(request: Request) -> UserContext | None: header = request.headers.get("Authorization") if not header or not header.startswith("Bearer "): @@ -138,6 +139,7 @@ async def my_pat_resolver(request: Request) -> UserContext | None: return None return UserContext.from_user(user) + # in on_startup: app.state.auth.principal_resolvers.append(my_pat_resolver) ``` diff --git a/docs/superpowers/specs/2026-05-27-pluggable-auth-keycloak-design.md b/docs/superpowers/specs/2026-05-27-pluggable-auth-keycloak-design.md index 1cb25e03..3b34805f 100644 --- a/docs/superpowers/specs/2026-05-27-pluggable-auth-keycloak-design.md +++ b/docs/superpowers/specs/2026-05-27-pluggable-auth-keycloak-design.md @@ -51,6 +51,7 @@ from typing import Protocol, runtime_checkable from starlette.requests import Request from auth.contracts.schemas import UserContext + @runtime_checkable class AuthProvider(Protocol): name: str @@ -97,9 +98,17 @@ Both `users` and `keycloak` modules implement this protocol. The active provider The current `users/middleware.py` hardcodes session-key reading and DB user loading. The new middleware delegates to the provider: ```python -_FRAMEWORK_PUBLIC_PREFIXES = ("/health", "/static/", "/api/docs", "/api/redoc", "/openapi.json", "/i18n/") +_FRAMEWORK_PUBLIC_PREFIXES = ( + "/health", + "/static/", + "/api/docs", + "/api/redoc", + "/openapi.json", + "/i18n/", +) _FRAMEWORK_PUBLIC_EXACT = ("/",) + class AuthMiddleware: def __init__(self, app: ASGIApp) -> None: self.app = app @@ -201,9 +210,16 @@ class UsersAuthProvider: def get_public_paths(self) -> tuple[tuple[str, ...], tuple[str, ...]]: return ( - ("/users/login", "/users/register", "/users/forgot-password", - "/users/reset-password", "/users/verify", "/users/invite/accept", - "/api/users/auth/", "/api/users/register"), + ( + "/users/login", + "/users/register", + "/users/forgot-password", + "/users/reset-password", + "/users/verify", + "/users/invite/accept", + "/api/users/auth/", + "/api/users/register", + ), (), ) @@ -383,28 +399,32 @@ Purpose: ```python def _check_auth_provider_conflict(self, modules: list[ModuleBase]) -> list[Diagnostic]: - providers = [m for m in modules if getattr(m, '_is_auth_provider', False)] + providers = [m for m in modules if getattr(m, "_is_auth_provider", False)] if len(providers) > 1: names = ", ".join(m.meta.name for m in providers) - return [Diagnostic( - level=DiagnosticLevel.ERROR, - code="SM020", - message=f"Multiple auth provider modules installed: {names}", - suggestion="Install only one auth provider (e.g. 'users' OR 'keycloak', not both)", - )] + return [ + Diagnostic( + level=DiagnosticLevel.ERROR, + code="SM020", + message=f"Multiple auth provider modules installed: {names}", + suggestion="Install only one auth provider (e.g. 'users' OR 'keycloak', not both)", + ) + ] return [] ``` **SM021 — No auth provider.** Warns (not errors) if no auth provider is installed — allows headless/API-only deployments that handle auth externally. ```python - if len(providers) == 0: - return [Diagnostic( +if len(providers) == 0: + return [ + Diagnostic( level=DiagnosticLevel.WARNING, code="SM021", message="No auth provider module installed", suggestion="Install an auth provider module (e.g. 'simple-module-users' or 'simple-module-keycloak')", - )] + ) + ] ``` The marker `_is_auth_provider = True` is a class attribute on both `UsersModule` and `KeycloakModule`. In production (`strict=True`), SM020 (error) fails boot; SM021 (warning) logs only. diff --git a/docs/superpowers/specs/2026-06-03-microsoft-oidc-design.md b/docs/superpowers/specs/2026-06-03-microsoft-oidc-design.md index 52b8ca15..13a9850c 100644 --- a/docs/superpowers/specs/2026-06-03-microsoft-oidc-design.md +++ b/docs/superpowers/specs/2026-06-03-microsoft-oidc-design.md @@ -71,9 +71,9 @@ Add Microsoft fields as plain `Field` (no `env_str`), grouped for the admin UI: ```python _MS_OAUTH = {"group": "Microsoft OAuth"} -oauth_microsoft_client_id: str = Field(default="", json_schema_extra=_MS_OAUTH) -oauth_microsoft_client_secret: str = Field(default="", json_schema_extra=_MS_OAUTH) # auto-masked -oauth_microsoft_tenant: str = Field(default="common", json_schema_extra=_MS_OAUTH) +oauth_microsoft_client_id: str = Field(default="", json_schema_extra=_MS_OAUTH) +oauth_microsoft_client_secret: str = Field(default="", json_schema_extra=_MS_OAUTH) # auto-masked +oauth_microsoft_tenant: str = Field(default="common", json_schema_extra=_MS_OAUTH) ``` - Migrate the existing `oauth_google_*`, `oauth_github_*`, `oauth_oidc_*` fields @@ -100,6 +100,7 @@ Documented as a security note. ```python from httpx_oauth.clients.microsoft import MicrosoftGraphOAuth2 + MicrosoftGraphOAuth2( settings.oauth_microsoft_client_id, settings.oauth_microsoft_client_secret, @@ -154,8 +155,7 @@ the single dispatcher. ```python state.oauth_clients = build_client_map(s) state.oauth_providers = [ - {"name": p.name, "display_name": p.display_name} - for p in state.oauth_clients.values() + {"name": p.name, "display_name": p.display_name} for p in state.oauth_clients.values() ] ``` - Override `register_event_handlers(self, bus, app)`: subscribe to diff --git a/docs/superpowers/specs/2026-06-19-admin-user-crud-design.md b/docs/superpowers/specs/2026-06-19-admin-user-crud-design.md index d8f48f33..43f73120 100644 --- a/docs/superpowers/specs/2026-06-19-admin-user-crud-design.md +++ b/docs/superpowers/specs/2026-06-19-admin-user-crud-design.md @@ -89,6 +89,7 @@ class UserAdminCreate(SQLModel): full_name: str | None = None role_names: list[str] = [] + class UserDetailsUpdate(SQLModel): email: EmailStr full_name: str | None = None @@ -103,6 +104,7 @@ class UserCreated(Event): email: str created_by: str | None + @dataclass class UserDeleted(Event): user_id: uuid.UUID diff --git a/docs/testing/fixtures.md b/docs/testing/fixtures.md index dfa274c5..ebd81025 100644 --- a/docs/testing/fixtures.md +++ b/docs/testing/fixtures.md @@ -34,6 +34,7 @@ The workhorse. Creates a fresh in-memory DB, runs `CREATE TABLE` for every modul from decimal import Decimal from orders.models import Order + @pytest.mark.asyncio async def test_create_order(db_session): order = Order(customer_email="a@b.c", total=Decimal("1")) @@ -108,13 +109,12 @@ The admin has `*` permission (via `DEFAULT_ROLE_PERMISSIONS["admin"]`), so it by @pytest.mark.asyncio async def test_non_admin_denied(client, db_session): from users.admin.service import UserService + svc = UserService(db_session) await svc.create(email="u@e.com", password="x", roles=["viewer"]) await db_session.commit() - login = await client.post( - "/users/login", data={"email": "u@e.com", "password": "x"} - ) + login = await client.post("/users/login", data={"email": "u@e.com", "password": "x"}) assert login.status_code in (200, 303) r = await client.post("/api/orders", json={...}) @@ -132,6 +132,7 @@ Adding your own fixtures at module level: import pytest from orders.service import OrderService + @pytest.fixture def order_service(db_session): return OrderService(db_session) @@ -147,14 +148,15 @@ FastAPI dependency overrides work as usual: @pytest.mark.asyncio async def test_with_mock_mailer(app, authenticated_client): from users.deps import _mailer + captured = [] + def fake_mailer(): return type("Mailer", (), {"send": lambda *a: captured.append(a)})() + app.dependency_overrides[_mailer] = fake_mailer - await authenticated_client.post( - "/users/admin/invite", data={"email": "x@y.z"} - ) + await authenticated_client.post("/users/admin/invite", data={"email": "x@y.z"}) assert len(captured) == 1 ``` @@ -167,6 +169,7 @@ Use `freezegun`: ```python from freezegun import freeze_time + @pytest.mark.asyncio async def test_order_timestamps(db_session): with freeze_time("2026-01-01T12:00:00Z"): @@ -197,15 +200,16 @@ async def test_webhook_delivery(httpx_mock, db_session): Standard pytest: ```python -@pytest.mark.parametrize("status,expected", [ - ("pending", 200), - ("shipped", 200), - ("invalid", 422), -]) +@pytest.mark.parametrize( + "status,expected", + [ + ("pending", 200), + ("shipped", 200), + ("invalid", 422), + ], +) @pytest.mark.asyncio async def test_status_transitions(authenticated_client, status, expected): - r = await authenticated_client.patch( - "/api/orders/1", json={"status": status} - ) + r = await authenticated_client.patch("/api/orders/1", json={"status": status}) assert r.status_code == expected ``` diff --git a/framework/core/README.md b/framework/core/README.md index 52574ed0..682f5f42 100644 --- a/framework/core/README.md +++ b/framework/core/README.md @@ -32,6 +32,7 @@ class OrdersModule(ModuleBase): def register_routes(self, api_router, view_router): from .endpoints import api, views + api_router.include_router(api.router) view_router.include_router(views.router) ``` diff --git a/framework/hosting/README.md b/framework/hosting/README.md index 5bc3f8e4..61e3a906 100644 --- a/framework/hosting/README.md +++ b/framework/hosting/README.md @@ -29,11 +29,12 @@ Minimal `main.py`: from simple_module_hosting import create_app from simple_module_hosting.settings import Settings -settings = Settings() # reads SM_* env vars -app = create_app(settings) # discovers + registers every installed module +settings = Settings() # reads SM_* env vars +app = create_app(settings) # discovers + registers every installed module if __name__ == "__main__": import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) ``` diff --git a/modules/background_tasks/README.md b/modules/background_tasks/README.md index c07cb35e..2040e924 100644 --- a/modules/background_tasks/README.md +++ b/modules/background_tasks/README.md @@ -26,8 +26,7 @@ from celery import shared_task @shared_task(name="reports.generate") -def generate_report(report_id: int) -> None: - ... +def generate_report(report_id: int) -> None: ... ``` Declaring `background_tasks` as a `depends_on` ensures the Celery app is built before your tasks run: @@ -69,10 +68,11 @@ domain `job_id` that named a Celery task is the canonical example): from background_tasks import bind_task_context from celery import shared_task + @shared_task def process_dataset(job_id: int) -> None: with bind_task_context(job_id=job_id): - logger.info("starting ingest") # now carries job_id too + logger.info("starting ingest") # now carries job_id too ``` Bindings nest cleanly and restore on exit. structlog users can mount the diff --git a/modules/feature_flags/README.md b/modules/feature_flags/README.md index 549d5bf4..26a13f61 100644 --- a/modules/feature_flags/README.md +++ b/modules/feature_flags/README.md @@ -33,7 +33,7 @@ Gate a route using the registry dependency: ```python from fastapi import APIRouter, HTTPException -from feature_flags.deps import FeatureFlagRegistryDep # type: ignore[import-not-found] +from feature_flags.deps import FeatureFlagRegistryDep # type: ignore[import-not-found] router = APIRouter() diff --git a/modules/file_storage/README.md b/modules/file_storage/README.md index 3004e13d..6b70103b 100644 --- a/modules/file_storage/README.md +++ b/modules/file_storage/README.md @@ -26,8 +26,9 @@ From another module, inject the service via its dependency: ```python from fastapi import Depends, File, UploadFile -from file_storage.deps import get_file_storage_service # type: ignore[import-not-found] -from file_storage.service import FileStorageService # type: ignore[import-not-found] +from file_storage.deps import get_file_storage_service # type: ignore[import-not-found] +from file_storage.service import FileStorageService # type: ignore[import-not-found] + async def attach_receipt( upload: UploadFile = File(...), diff --git a/modules/permissions/README.md b/modules/permissions/README.md index 4e5db026..f1fc7b2a 100644 --- a/modules/permissions/README.md +++ b/modules/permissions/README.md @@ -37,7 +37,7 @@ Guard a route: ```python from fastapi import APIRouter, Depends -from permissions.deps import RequiresPermission # type: ignore[import-not-found] +from permissions.deps import RequiresPermission # type: ignore[import-not-found] router = APIRouter() diff --git a/modules/settings/settings/contracts/accessor.py b/modules/settings/settings/contracts/accessor.py index b814ff9c..c0df3e26 100644 --- a/modules/settings/settings/contracts/accessor.py +++ b/modules/settings/settings/contracts/accessor.py @@ -118,8 +118,8 @@ def registry(self) -> SettingsRegistry | None: def bind( self, *, - user_id: str | None | _Unset = _UNSET, - tenant_id: str | None | _Unset = _UNSET, + user_id: str | _Unset | None = _UNSET, + tenant_id: str | _Unset | None = _UNSET, ) -> SettingsAccessor: """Return a new accessor with the given user/tenant overrides. diff --git a/modules/users/README.md b/modules/users/README.md index 74ff9417..64b4cccf 100644 --- a/modules/users/README.md +++ b/modules/users/README.md @@ -38,7 +38,8 @@ SM_USERS_BOOTSTRAP_PASSWORD=change-me Program: ```python -from auth.deps import CurrentUser # type: ignore[import-not-found] +from auth.deps import CurrentUser # type: ignore[import-not-found] + @router.get("/profile") async def profile(user: CurrentUser): diff --git a/skills/simple-module-conventions/SKILL.md b/skills/simple-module-conventions/SKILL.md index e27bfab6..08d43e27 100644 --- a/skills/simple-module-conventions/SKILL.md +++ b/skills/simple-module-conventions/SKILL.md @@ -17,12 +17,14 @@ from simple_module_db.base import create_module_base Base = create_module_base("orders") -class Order(Base, AuditMixin, table=True): # table + +class Order(Base, AuditMixin, table=True): # table __tablename__ = "orders_order" id: int | None = Field(default=None, primary_key=True) name: str = Field(max_length=200) -class OrderOut(SQLModel): # DTO — plain SQLModel, no table=True + +class OrderOut(SQLModel): # DTO — plain SQLModel, no table=True model_config = ConfigDict(from_attributes=True) id: int name: str @@ -39,8 +41,9 @@ Treat 300 lines as the design pressure on every `.py`, `.ts`, and `.tsx` file (t ```python class UsersModule(ModuleBase): def register_settings(self, app: FastAPI) -> None: - from users.settings import UsersSettings # reads SM_USERS_* env + from users.settings import UsersSettings # reads SM_USERS_* env from users.state import UsersState + app.state.users = UsersState(settings=UsersSettings()) ``` diff --git a/skills/simple-module-creating/SKILL.md b/skills/simple-module-creating/SKILL.md index c58caf48..628c6a27 100644 --- a/skills/simple-module-creating/SKILL.md +++ b/skills/simple-module-creating/SKILL.md @@ -72,12 +72,13 @@ orders = "orders.module:OrdersModule" # modules/orders/orders/module.py from simple_module_core.module import ModuleBase, ModuleMeta + class OrdersModule(ModuleBase): meta = ModuleMeta( - name="Orders", # PascalCase, must be unique + name="Orders", # PascalCase, must be unique route_prefix="/api/orders", view_prefix="/orders", - depends_on=[], # other module names (PascalCase) + depends_on=[], # other module names (PascalCase) ) ``` diff --git a/skills/simple-module-database/SKILL.md b/skills/simple-module-database/SKILL.md index 5a7d2f23..1b465027 100644 --- a/skills/simple-module-database/SKILL.md +++ b/skills/simple-module-database/SKILL.md @@ -17,8 +17,9 @@ from simple_module_db.mixins import AuditMixin Base = create_module_base("orders") + class Order(Base, AuditMixin, table=True): - __tablename__ = "orders_order" # module-name prefix; required + __tablename__ = "orders_order" # module-name prefix; required id: int | None = Field(default=None, primary_key=True) name: str = Field(max_length=200) ``` @@ -70,9 +71,9 @@ A pending-writes flag is set by an `after_flush` listener and by inspecting `ses async def create_order(session: AsyncSession, payload: OrderCreate) -> Order: order = Order(name=payload.name) session.add(order) - await session.flush() # order.id is now populated + await session.flush() # order.id is now populated audit_log.record(order_id=order.id) - return order # the dependency commits when the request returns + return order # the dependency commits when the request returns ``` ## Pitfalls diff --git a/skills/simple-module-inertia-pages/SKILL.md b/skills/simple-module-inertia-pages/SKILL.md index ff31e669..bf8c5ac6 100644 --- a/skills/simple-module-inertia-pages/SKILL.md +++ b/skills/simple-module-inertia-pages/SKILL.md @@ -43,6 +43,7 @@ Boot regenerates it too; mid-session adds need the manual call before HMR sees t def _serialize_principal(user: UserContext) -> dict: return {"id": user.id, "name": user.name, "email": user.email, "roles": user.roles} + class AuthModule(ModuleBase): def register_settings(self, app: FastAPI) -> None: app.state.principal_serializer = _serialize_principal @@ -59,6 +60,7 @@ from simple_module_hosting.inertia_deps import InertiaDep router = APIRouter() + @router.get("/") async def list_orders(inertia: InertiaDep, service: OrdersServiceDep): return await inertia.render("Orders/List", {"orders": await service.list()}) diff --git a/skills/simple-module-locales/SKILL.md b/skills/simple-module-locales/SKILL.md index 94955398..45be018e 100644 --- a/skills/simple-module-locales/SKILL.md +++ b/skills/simple-module-locales/SKILL.md @@ -22,6 +22,7 @@ modules/orders/orders/ import importlib.resources from pathlib import Path + class OrdersModule(ModuleBase): def locale_dirs(self) -> dict[str, Path]: # key = namespace, value = directory holding .json files @@ -101,6 +102,7 @@ Inject `TranslatorDep` (from `simple_module_hosting.i18n_deps`) into an endpoint ```python from simple_module_hosting.i18n_deps import TranslatorDep + async def create(t: TranslatorDep): raise HTTPException(404, t.t("orders.errors.not_found", id=order_id)) ``` diff --git a/skills/simple-module-migrations/SKILL.md b/skills/simple-module-migrations/SKILL.md index e07e7e8f..4cf0e488 100644 --- a/skills/simple-module-migrations/SKILL.md +++ b/skills/simple-module-migrations/SKILL.md @@ -37,8 +37,8 @@ from simple_module_db import ( render_item, ) -target_metadata = build_module_metadata() # imports every installed module's .models -include_object = make_include_object(target_metadata) +target_metadata = build_module_metadata() # imports every installed module's .models +include_object = make_include_object(target_metadata) process_revision_directives = make_process_revision_directives(target_metadata) # context.configure(..., render_item=render_item) ``` @@ -74,7 +74,7 @@ Each new module's first revision should set a `branch_labels` tuple matching the # host/migrations/versions/70786227af4c_add_audit_log_tables.py revision = "70786227af4c" down_revision = "41cf2c53660e" -branch_labels = ("audit_log",) # ← add by hand on this first revision +branch_labels = ("audit_log",) # ← add by hand on this first revision depends_on = None ``` diff --git a/skills/simple-module-registries/SKILL.md b/skills/simple-module-registries/SKILL.md index 729b6df0..39a0868f 100644 --- a/skills/simple-module-registries/SKILL.md +++ b/skills/simple-module-registries/SKILL.md @@ -14,6 +14,7 @@ All four are populated in **Phase 5** of `app_builder.build_app`, in this per-mo ```python from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection + class OrdersModule(ModuleBase): def register_menu_items(self, registry: MenuRegistry) -> None: registry.add( @@ -23,7 +24,7 @@ class OrdersModule(ModuleBase): icon="shopping-cart", order=10, section=MenuSection.SIDEBAR, - roles=["admin", "staff"], # empty list = all authenticated users + roles=["admin", "staff"], # empty list = all authenticated users ) ) ``` @@ -35,13 +36,17 @@ class OrdersModule(ModuleBase): ```python from simple_module_core.permissions import PermissionRegistry + class OrdersModule(ModuleBase): def register_permissions(self, registry: PermissionRegistry) -> None: - registry.add_group("Orders", [ - "orders.view", - "orders.create", - "orders.delete", - ]) + registry.add_group( + "Orders", + [ + "orders.view", + "orders.create", + "orders.delete", + ], + ) registry.map_role("staff", ["orders.view", "orders.create"]) ``` @@ -56,13 +61,16 @@ To check inside an endpoint, depend on `RequiresPermission(".")` ```python from simple_module_core.feature_flags import FeatureFlagDefinition, FeatureFlagRegistry + class OrdersModule(ModuleBase): def register_feature_flags(self, registry: FeatureFlagRegistry) -> None: - registry.add(FeatureFlagDefinition( - name="orders.bulk_import", - description="Enables the CSV bulk-import UI on /orders/import", - default_enabled=False, - )) + registry.add( + FeatureFlagDefinition( + name="orders.bulk_import", + description="Enables the CSV bulk-import UI on /orders/import", + default_enabled=False, + ) + ) ``` **Resolution order at request time:** tenant override > system override > `default_enabled`. Per-tenant overrides come from the multi-tenant context (`request.state.tenant_id` from `TenantMiddleware`); system overrides come from the settings module's persisted overrides table. @@ -101,6 +109,7 @@ The event bus is async and in-process (backed by `pyee`'s `AsyncIOEventEmitter`) from dataclasses import dataclass from simple_module_core.events import Event + @dataclass class OrderPlaced(Event): order_id: int @@ -113,12 +122,12 @@ class OrderPlaced(Event): from simple_module_core.events import EventBus from orders.contracts.events import OrderPlaced + class NotificationsModule(ModuleBase): def register_event_handlers(self, bus: EventBus, app: FastAPI | None = None) -> None: bus.subscribe(OrderPlaced, self._send_receipt) - async def _send_receipt(self, event: OrderPlaced) -> None: - ... + async def _send_receipt(self, event: OrderPlaced) -> None: ... ``` ```python diff --git a/skills/simple-module-testing/SKILL.md b/skills/simple-module-testing/SKILL.md index a311ac20..309aa511 100644 --- a/skills/simple-module-testing/SKILL.md +++ b/skills/simple-module-testing/SKILL.md @@ -30,11 +30,13 @@ async def test_service_creates_order(db_session): order = await OrdersService(db_session).create(name="x") assert order.id is not None + # API test — JSON endpoint async def test_api_lists_orders(authenticated_client): resp = await authenticated_client.get("/api/orders") assert resp.status_code == 200 + # View test — Inertia endpoint (X-Inertia header) async def test_view_renders_index(authenticated_client): resp = await authenticated_client.get("/orders/", headers={"X-Inertia": "true"}) From 689fc76d1a57f43e897700cd3c633b54c13d257a Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 3 Aug 2026 15:05:42 +0200 Subject: [PATCH 2/2] style: format Python blocks in markdown added by #232 #232 merged with the lint job overridden, bringing one new unformatted doc onto main. Formats it so `ruff format --check` is clean against the merged tree. Verified zero prose lines changed. Claude-Session: https://claude.ai/code/session_01TtYUkaUAJmUcCwB5QGxPqN --- .../plans/2026-08-02-navigation-perf.md | 144 +++++++++--------- 1 file changed, 68 insertions(+), 76 deletions(-) diff --git a/docs/superpowers/plans/2026-08-02-navigation-perf.md b/docs/superpowers/plans/2026-08-02-navigation-perf.md index 49e05b9b..daf2ff04 100644 --- a/docs/superpowers/plans/2026-08-02-navigation-perf.md +++ b/docs/superpowers/plans/2026-08-02-navigation-perf.md @@ -538,11 +538,7 @@ class CatalogService: total = (await self.db.execute(count_stmt)).scalar_one() order_by = _SORT_CLAUSES.get(sort, _SORT_CLAUSES[SORT_CREATED]) - stmt = ( - cols.order_by(order_by, Product.id) - .offset((page - 1) * page_size) - .limit(page_size) - ) + stmt = cols.order_by(order_by, Product.id).offset((page - 1) * page_size).limit(page_size) rows = (await self.db.execute(stmt)).all() items = [ProductRead(**row._mapping) for row in rows] @@ -1411,25 +1407,25 @@ _CATALOG_TERMS = ("solution", "system", "network", "matrix", "portal", "e") ``` ```python - @task(14) - def catalog_list_api(self) -> None: - page = random.randint(1, 100) - self.client.get(f"/api/catalog/products?page={page}&page_size=20", name="/api/catalog/products") - - @task(10) - def catalog_list_view(self) -> None: - page = random.randint(1, 100) - self.client.get( - f"/catalog/?page={page}&page_size=20", headers=_INERTIA, name="/catalog/" - ) +@task(14) +def catalog_list_api(self) -> None: + page = random.randint(1, 100) + self.client.get(f"/api/catalog/products?page={page}&page_size=20", name="/api/catalog/products") - @task(6) - def catalog_search(self) -> None: - term = random.choice(_CATALOG_TERMS) - self.client.get( - f"/api/catalog/products?q={term}&page=1&page_size=20", - name="/api/catalog/products?q", - ) + +@task(10) +def catalog_list_view(self) -> None: + page = random.randint(1, 100) + self.client.get(f"/catalog/?page={page}&page_size=20", headers=_INERTIA, name="/catalog/") + + +@task(6) +def catalog_search(self) -> None: + term = random.choice(_CATALOG_TERMS) + self.client.get( + f"/api/catalog/products?q={term}&page=1&page_size=20", + name="/api/catalog/products?q", + ) ``` The `name=` argument groups paginated URLs into one stats row, matching the convention already used by every other task in the file. @@ -1509,17 +1505,13 @@ def menu_registry() -> MenuRegistry: def permission_registry() -> PermissionRegistry: registry = PermissionRegistry() for g in range(N_PERMISSION_GROUPS): - registry.add_group( - f"Group{g}", [f"group{g}.perm{p}" for p in range(N_PERMS_PER_GROUP)] - ) + registry.add_group(f"Group{g}", [f"group{g}.perm{p}" for p in range(N_PERMS_PER_GROUP)]) return registry def test_menu_get_for_user(benchmark, menu_registry: MenuRegistry) -> None: """Per-request menu filtering + dict construction.""" - result = benchmark( - lambda: menu_registry.get_for_user(is_authenticated=True, roles=ADMIN_ROLES) - ) + result = benchmark(lambda: menu_registry.get_for_user(is_authenticated=True, roles=ADMIN_ROLES)) assert result[MenuSection.SIDEBAR.value] @@ -1847,57 +1839,57 @@ Expected: `test_returned_menu_is_not_mutable_by_callers` FAILS once caching is a In `menu.py`, add a cache dict to `__init__`, clear it in `_invalidate`, and memoize in `get_for_user`: ```python - def __init__(self) -> None: - self._items: list[MenuItem] = [] - self._sorted: list[MenuItem] | None = None - self._user_cache: dict[tuple[bool, frozenset[str]], dict[str, list[dict]]] = {} +def __init__(self) -> None: + self._items: list[MenuItem] = [] + self._sorted: list[MenuItem] | None = None + self._user_cache: dict[tuple[bool, frozenset[str]], dict[str, list[dict]]] = {} + - def _invalidate(self) -> None: - self._sorted = None - self._user_cache.clear() +def _invalidate(self) -> None: + self._sorted = None + self._user_cache.clear() ``` ```python - def get_for_user( - self, - *, - is_authenticated: bool, - roles: list[str] | None = None, - ) -> dict[str, list[dict]]: - """Return menu items grouped by section, filtered by auth/roles. - - Memoized on ``(is_authenticated, frozenset(roles))`` — the only inputs - that vary the output — because this runs on every page render. Callers - get a fresh shallow structure so mutating the result can't corrupt the - cached entry. - """ - roles = roles or [] - key = (is_authenticated, frozenset(roles)) - cached = self._user_cache.get(key) - if cached is None: - cached = self._build_for_user(is_authenticated, roles) - self._user_cache[key] = cached - return {section: list(items) for section, items in cached.items()} - - def _build_for_user( - self, is_authenticated: bool, roles: list[str] - ) -> dict[str, list[dict]]: - result: dict[str, list[dict]] = {s.value: [] for s in MenuSection} - for item in self.all_items: - if item.requires_auth and not is_authenticated: - continue - if item.roles and not any(r in item.roles for r in roles): - continue - result[item.section.value].append( - { - "label": item.label, - "url": item.url, - "icon": item.icon, - "method": item.method, - "group": item.group, - } - ) - return result +def get_for_user( + self, + *, + is_authenticated: bool, + roles: list[str] | None = None, +) -> dict[str, list[dict]]: + """Return menu items grouped by section, filtered by auth/roles. + + Memoized on ``(is_authenticated, frozenset(roles))`` — the only inputs + that vary the output — because this runs on every page render. Callers + get a fresh shallow structure so mutating the result can't corrupt the + cached entry. + """ + roles = roles or [] + key = (is_authenticated, frozenset(roles)) + cached = self._user_cache.get(key) + if cached is None: + cached = self._build_for_user(is_authenticated, roles) + self._user_cache[key] = cached + return {section: list(items) for section, items in cached.items()} + + +def _build_for_user(self, is_authenticated: bool, roles: list[str]) -> dict[str, list[dict]]: + result: dict[str, list[dict]] = {s.value: [] for s in MenuSection} + for item in self.all_items: + if item.requires_auth and not is_authenticated: + continue + if item.roles and not any(r in item.roles for r in roles): + continue + result[item.section.value].append( + { + "label": item.label, + "url": item.url, + "icon": item.icon, + "method": item.method, + "group": item.group, + } + ) + return result ``` The per-call `list(items)` copy is deliberate: it keeps the item dicts shared (cheap) while making the lists private to the caller, so an accidental `.append()` downstream cannot poison every subsequent request.