simple-module-hosting 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. simple_module_hosting/__init__.py +7 -0
  2. simple_module_hosting/_error_handlers.py +54 -0
  3. simple_module_hosting/_hydrate_step.py +39 -0
  4. simple_module_hosting/_inertia_setup.py +73 -0
  5. simple_module_hosting/_inertia_shared.py +61 -0
  6. simple_module_hosting/_observability.py +108 -0
  7. simple_module_hosting/_phase_helpers.py +160 -0
  8. simple_module_hosting/app_builder.py +281 -0
  9. simple_module_hosting/bootstrap_settings.py +55 -0
  10. simple_module_hosting/cli.py +292 -0
  11. simple_module_hosting/health.py +79 -0
  12. simple_module_hosting/host_settings.py +33 -0
  13. simple_module_hosting/i18n_deps.py +25 -0
  14. simple_module_hosting/i18n_manifest.py +202 -0
  15. simple_module_hosting/i18n_middleware.py +95 -0
  16. simple_module_hosting/inertia_deps.py +27 -0
  17. simple_module_hosting/inertia_utils.py +31 -0
  18. simple_module_hosting/logging.py +91 -0
  19. simple_module_hosting/manifest.py +250 -0
  20. simple_module_hosting/middleware.py +272 -0
  21. simple_module_hosting/migrations.py +65 -0
  22. simple_module_hosting/permissions.py +75 -0
  23. simple_module_hosting/py.typed +0 -0
  24. simple_module_hosting/redirects.py +45 -0
  25. simple_module_hosting/scaffolding.py +294 -0
  26. simple_module_hosting/settings.py +10 -0
  27. simple_module_hosting/templates/host/.env.example +20 -0
  28. simple_module_hosting/templates/host/.gitignore +19 -0
  29. simple_module_hosting/templates/host/Makefile +24 -0
  30. simple_module_hosting/templates/host/README.md.tpl +59 -0
  31. simple_module_hosting/templates/host/alembic.ini +36 -0
  32. simple_module_hosting/templates/host/client_app/app.tsx +16 -0
  33. simple_module_hosting/templates/host/client_app/main.tsx +2 -0
  34. simple_module_hosting/templates/host/client_app/package.json.tpl +23 -0
  35. simple_module_hosting/templates/host/client_app/pages/Error.tsx +13 -0
  36. simple_module_hosting/templates/host/client_app/pages.ts +47 -0
  37. simple_module_hosting/templates/host/client_app/styles.css +7 -0
  38. simple_module_hosting/templates/host/client_app/tsconfig.json +16 -0
  39. simple_module_hosting/templates/host/client_app/vite.config.ts +39 -0
  40. simple_module_hosting/templates/host/main.py +27 -0
  41. simple_module_hosting/templates/host/migrations/env.py +80 -0
  42. simple_module_hosting/templates/host/migrations/script.py.mako +26 -0
  43. simple_module_hosting/templates/host/migrations/versions/.gitkeep +1 -0
  44. simple_module_hosting/templates/host/pyproject.toml.tpl +17 -0
  45. simple_module_hosting/templates/host/templates/index.html +12 -0
  46. simple_module_hosting/templates/module/.github/workflows/ci.yml +32 -0
  47. simple_module_hosting/templates/module/.github/workflows/publish.yml.tpl +52 -0
  48. simple_module_hosting/templates/module/.gitignore +14 -0
  49. simple_module_hosting/templates/module/README.md.tpl +82 -0
  50. simple_module_hosting/templates/module/__PACKAGE__/__init__.py +0 -0
  51. simple_module_hosting/templates/module/__PACKAGE__/endpoints/__init__.py +0 -0
  52. simple_module_hosting/templates/module/__PACKAGE__/endpoints/api.py.tpl +11 -0
  53. simple_module_hosting/templates/module/__PACKAGE__/module.py.tpl +46 -0
  54. simple_module_hosting/templates/module/__PACKAGE__/pages/.gitkeep +1 -0
  55. simple_module_hosting/templates/module/__PACKAGE__/services.py.tpl +22 -0
  56. simple_module_hosting/templates/module/package.json.tpl +16 -0
  57. simple_module_hosting/templates/module/pyproject.toml.tpl +39 -0
  58. simple_module_hosting/templates/module/tests/__init__.py +0 -0
  59. simple_module_hosting/templates/module/tests/test_module.py.tpl +27 -0
  60. simple_module_hosting/templates/module/tsconfig.json.tpl +11 -0
  61. simple_module_hosting-0.0.1.dist-info/METADATA +93 -0
  62. simple_module_hosting-0.0.1.dist-info/RECORD +65 -0
  63. simple_module_hosting-0.0.1.dist-info/WHEEL +4 -0
  64. simple_module_hosting-0.0.1.dist-info/entry_points.txt +3 -0
  65. simple_module_hosting-0.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,7 @@
1
+ """SimpleModule Hosting - App builder, module loader, middleware pipeline."""
2
+
3
+ from simple_module_hosting.app_builder import create_app
4
+ from simple_module_hosting.logging import correlation_id, setup_logging
5
+ from simple_module_hosting.settings import Settings
6
+
7
+ __all__ = ["Settings", "correlation_id", "create_app", "setup_logging"]
@@ -0,0 +1,54 @@
1
+ """Framework-wide exception handlers that render Inertia error pages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ from fastapi.responses import JSONResponse
8
+ from inertia import (
9
+ Inertia,
10
+ InertiaConfig,
11
+ InertiaVersionConflictException,
12
+ inertia_version_conflict_exception_handler,
13
+ )
14
+ from simple_module_core.exceptions import NotFoundError
15
+ from starlette.exceptions import HTTPException
16
+ from starlette.requests import Request
17
+ from starlette.responses import Response
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ _INERTIA_ERROR_STATUSES = frozenset({403, 404, 500})
22
+
23
+
24
+ async def render_error_page(request: Request, status_code: int, message: str) -> Response:
25
+ config: InertiaConfig = request.app.state.sm.inertia_config
26
+ try:
27
+ inertia = Inertia(request, config)
28
+ response = await inertia.render("Error", {"status": status_code, "message": message})
29
+ response.status_code = status_code
30
+ return response
31
+ except InertiaVersionConflictException as exc:
32
+ return await inertia_version_conflict_exception_handler(request, exc)
33
+ except Exception:
34
+ # Fallback if Inertia rendering itself fails (e.g. missing session)
35
+ logger.exception("Error page rendering failed, falling back to JSON")
36
+ return JSONResponse(
37
+ status_code=status_code, content={"detail": message or "Internal Server Error"}
38
+ )
39
+
40
+
41
+ async def http_exception_handler(request: Request, exc: HTTPException) -> Response:
42
+ if exc.status_code in _INERTIA_ERROR_STATUSES:
43
+ detail = str(exc.detail) if exc.detail else ""
44
+ return await render_error_page(request, exc.status_code, detail)
45
+ return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
46
+
47
+
48
+ async def not_found_error_handler(request: Request, exc: NotFoundError) -> Response:
49
+ return await render_error_page(request, 404, str(exc))
50
+
51
+
52
+ async def unhandled_exception_handler(request: Request, exc: Exception) -> Response:
53
+ logger.exception("Unhandled exception: %s", exc)
54
+ return await render_error_page(request, 500, "")
@@ -0,0 +1,39 @@
1
+ """Hydrate every registered module's settings from the DB at lifespan start.
2
+
3
+ Runs before any module ``on_startup`` hook so startup code sees DB-backed
4
+ values. ``importlib`` is used to resolve plugin names lazily so the
5
+ framework→plugin AST check (SM009) stays clean.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+ import logging
12
+ from typing import Any
13
+
14
+ from fastapi import FastAPI
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ _MODULE_PACKAGE = "settings"
19
+
20
+
21
+ async def hydrate_all(app: FastAPI, store: Any) -> None:
22
+ """Resolve every registered module's settings from the DB."""
23
+ settings_services = getattr(app.state, _MODULE_PACKAGE, None)
24
+ if settings_services is None:
25
+ return
26
+
27
+ hydrate_settings = importlib.import_module("settings.hydrate").hydrate_settings
28
+
29
+ for package, cls in settings_services.module_registry.items():
30
+ try:
31
+ hydrated = await hydrate_settings(cls, store, package)
32
+ except Exception:
33
+ logger.exception("Hydrating %s failed; falling back to defaults", package)
34
+ continue
35
+ services = getattr(app.state, package, None)
36
+ if services is None:
37
+ logger.warning("app.state.%s missing during hydrate — skipping", package)
38
+ continue
39
+ services.settings = hydrated
@@ -0,0 +1,73 @@
1
+ """Configure fastapi-inertia with the Jinja2 template."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+
8
+ from fastapi import FastAPI
9
+ from inertia import InertiaConfig, inertia_dependency_factory
10
+
11
+ from simple_module_hosting.settings import Settings
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ _INERTIA_VERSION = "1.0"
16
+ _ROOT_TEMPLATE_FILENAME = "index.html"
17
+ _ENTRYPOINT_FILENAME = "main.tsx"
18
+ _ROOT_DIRECTORY = "."
19
+
20
+
21
+ def setup_inertia(
22
+ app: FastAPI,
23
+ settings: Settings,
24
+ modules: list,
25
+ project_root: Path,
26
+ ) -> InertiaConfig | None:
27
+ """Configure fastapi-inertia and attach the dependency factory to app.state.
28
+
29
+ The host's own ``host/templates`` directory is first in the search path so
30
+ it can override module-contributed templates. Each installed module
31
+ contributes additional directories via ``ModuleBase.template_dirs()``.
32
+ """
33
+ from fastapi.templating import Jinja2Templates
34
+
35
+ host_templates = project_root / "host" / "templates"
36
+ directories: list[Path] = []
37
+
38
+ if host_templates.is_dir():
39
+ directories.append(host_templates)
40
+ else:
41
+ logger.warning("Host templates directory not found at %s", host_templates)
42
+
43
+ for mod in modules:
44
+ for path in mod.template_dirs():
45
+ if Path(path).is_dir():
46
+ directories.append(Path(path))
47
+ else:
48
+ logger.warning(
49
+ "Module '%s' declared template dir %s but it does not exist",
50
+ mod.meta.name,
51
+ path,
52
+ )
53
+
54
+ if not directories:
55
+ logger.warning("No usable template directories — Inertia will fail to render views")
56
+ return None
57
+
58
+ templates = Jinja2Templates(directory=directories)
59
+
60
+ inertia_config = InertiaConfig(
61
+ environment=settings.environment,
62
+ version=_INERTIA_VERSION,
63
+ dev_url=settings.vite_dev_url if settings.is_development else "",
64
+ templates=templates,
65
+ root_template_filename=_ROOT_TEMPLATE_FILENAME,
66
+ entrypoint_filename=_ENTRYPOINT_FILENAME,
67
+ root_directory=_ROOT_DIRECTORY,
68
+ use_flash_errors=True,
69
+ )
70
+
71
+ inertia_dep = inertia_dependency_factory(inertia_config)
72
+ app.state.inertia_dependency = inertia_dep
73
+ return inertia_config
@@ -0,0 +1,61 @@
1
+ """Helpers for building Inertia shared-props payloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ from starlette.datastructures import Headers
8
+ from starlette.requests import Request
9
+ from starlette.types import Scope
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ _I18N_SESSION_LOCALE_KEY = "__i18n_locale"
14
+ _INERTIA_HEADER = "x-inertia"
15
+ _INERTIA_HEADER_TRUE = "true"
16
+
17
+
18
+ def build_i18n_block(scope: Scope, request: Request) -> dict:
19
+ """Assemble the ``i18n`` shared-props block for the current request.
20
+
21
+ Rules:
22
+
23
+ * No registry / no locale → serve an empty English block and log once.
24
+ * Inertia XHR partials (``X-Inertia: true``) reuse the client-side
25
+ cached messages; send ``messages: None`` unless the locale differs
26
+ from what was last served on this session.
27
+ * Full page loads and locale transitions ship the complete dict.
28
+ """
29
+ # Test fixtures sometimes build a bare FastAPI with a partial app.state.sm
30
+ # stub (e.g. permissions-only, no i18n); guard both lookups to keep them usable.
31
+ sm = getattr(request.app.state, "sm", None)
32
+ registry = getattr(sm, "i18n_registry", None) if sm is not None else None
33
+ locale = getattr(request.state, "locale", None)
34
+ if registry is None or locale is None:
35
+ logger.warning(
36
+ "InertiaLayoutDataMiddleware: i18n not fully wired "
37
+ "(registry_present=%s, locale_present=%s); serving empty messages",
38
+ registry is not None,
39
+ locale is not None,
40
+ )
41
+ return {"locale": "en", "supportedLocales": ["en"], "messages": {}}
42
+
43
+ is_inertia = Headers(scope=scope).get(_INERTIA_HEADER) == _INERTIA_HEADER_TRUE
44
+ session_dict = scope.get("session")
45
+ # When the session is absent (pre-session-middleware routes, WebSocket
46
+ # upgrades), treat locale as "unchanged" so Inertia XHR requests still
47
+ # skip the messages payload. Non-Inertia requests will always ship them
48
+ # regardless of the session state.
49
+ if session_dict is not None:
50
+ last_locale = session_dict.get(_I18N_SESSION_LOCALE_KEY)
51
+ locale_changed = last_locale != locale
52
+ if locale_changed:
53
+ session_dict[_I18N_SESSION_LOCALE_KEY] = locale
54
+ else:
55
+ locale_changed = False
56
+ send_messages = (not is_inertia) or locale_changed
57
+ return {
58
+ "locale": locale,
59
+ "supportedLocales": registry.available_locales(),
60
+ "messages": registry.messages_snapshot(locale) if send_messages else None,
61
+ }
@@ -0,0 +1,108 @@
1
+ """ASGI middlewares for correlation IDs and structured request logging."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ import uuid
8
+
9
+ from starlette.datastructures import Headers, MutableHeaders
10
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
11
+
12
+ from simple_module_hosting.logging import correlation_id
13
+
14
+ _LOGGER_NAME = "simple_module.request"
15
+ _request_logger = logging.getLogger(_LOGGER_NAME)
16
+
17
+ _SCOPE_HTTP = "http"
18
+ _MSG_RESPONSE_START = "http.response.start"
19
+ _EVENT_REQUEST_STARTED = "request.started"
20
+ _EVENT_REQUEST_COMPLETED = "request.completed"
21
+
22
+ # Paths that produce noisy, low-value log entries
23
+ _QUIET_PREFIXES = ("/health", "/static/")
24
+
25
+
26
+ class CorrelationIdMiddleware:
27
+ """Generate or propagate a correlation ID for every request.
28
+
29
+ Reads the incoming ``X-Correlation-ID`` header (or generates a UUID4) and
30
+ stores it in a :class:`~contextvars.ContextVar` so that every log record
31
+ emitted during the request automatically includes the ID. The same value
32
+ is echoed back in the response header.
33
+ """
34
+
35
+ HEADER = "X-Correlation-ID"
36
+
37
+ def __init__(self, app: ASGIApp) -> None:
38
+ self.app = app
39
+
40
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
41
+ if scope["type"] != _SCOPE_HTTP:
42
+ await self.app(scope, receive, send)
43
+ return
44
+
45
+ cid = Headers(scope=scope).get(self.HEADER) or uuid.uuid4().hex
46
+
47
+ async def send_with_header(message: Message) -> None:
48
+ if message["type"] == _MSG_RESPONSE_START:
49
+ headers = MutableHeaders(scope=message)
50
+ headers[self.HEADER] = cid
51
+ await send(message)
52
+
53
+ token = correlation_id.set(cid)
54
+ try:
55
+ await self.app(scope, receive, send_with_header)
56
+ finally:
57
+ correlation_id.reset(token)
58
+
59
+
60
+ class RequestLoggingMiddleware:
61
+ """Log every request/response pair with timing and status information."""
62
+
63
+ def __init__(self, app: ASGIApp) -> None:
64
+ self.app = app
65
+
66
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
67
+ if scope["type"] != _SCOPE_HTTP:
68
+ await self.app(scope, receive, send)
69
+ return
70
+
71
+ path = scope["path"]
72
+ if any(path.startswith(p) for p in _QUIET_PREFIXES):
73
+ await self.app(scope, receive, send)
74
+ return
75
+
76
+ method = scope["method"]
77
+ client = scope.get("client")
78
+ client_ip = client[0] if client else "unknown"
79
+
80
+ _request_logger.debug(
81
+ _EVENT_REQUEST_STARTED,
82
+ extra={"method": method, "path": path, "client_ip": client_ip},
83
+ )
84
+
85
+ status_code: int | None = None
86
+ start = time.perf_counter()
87
+
88
+ async def send_capture(message: Message) -> None:
89
+ nonlocal status_code
90
+ if message["type"] == _MSG_RESPONSE_START:
91
+ status_code = message["status"]
92
+ await send(message)
93
+
94
+ try:
95
+ await self.app(scope, receive, send_capture)
96
+ finally:
97
+ # Log completion even when the inner app raises, so 500s are observable.
98
+ duration_ms = round((time.perf_counter() - start) * 1000, 2)
99
+ _request_logger.info(
100
+ _EVENT_REQUEST_COMPLETED,
101
+ extra={
102
+ "method": method,
103
+ "path": path,
104
+ "status_code": status_code,
105
+ "duration_ms": duration_ms,
106
+ "client_ip": client_ip,
107
+ },
108
+ )
@@ -0,0 +1,160 @@
1
+ """Helpers extracted from ``app_builder.py`` — exception handlers, middleware
2
+ pipeline installation, static mounts, and the SM012 post-registration check.
3
+
4
+ Kept private to the hosting package; ``app_builder.create_app`` is the only
5
+ intended caller.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING
13
+
14
+ from fastapi import FastAPI
15
+ from fastapi.staticfiles import StaticFiles
16
+ from inertia import (
17
+ InertiaVersionConflictException,
18
+ inertia_version_conflict_exception_handler,
19
+ )
20
+ from simple_module_core.diagnostics import Diagnostic, DiagnosticLevel
21
+ from simple_module_core.exceptions import NotFoundError
22
+ from starlette.exceptions import HTTPException
23
+ from starlette.middleware.sessions import SessionMiddleware
24
+
25
+ from simple_module_hosting._error_handlers import (
26
+ http_exception_handler,
27
+ not_found_error_handler,
28
+ unhandled_exception_handler,
29
+ )
30
+ from simple_module_hosting.i18n_middleware import LocaleMiddleware
31
+ from simple_module_hosting.middleware import (
32
+ CorrelationIdMiddleware,
33
+ InertiaLayoutDataMiddleware,
34
+ RequestLoggingMiddleware,
35
+ SecurityHeadersMiddleware,
36
+ TenantMiddleware,
37
+ )
38
+ from simple_module_hosting.settings import Settings
39
+
40
+ if TYPE_CHECKING:
41
+ from simple_module_core.menu import MenuRegistry
42
+ from simple_module_core.permissions import PermissionRegistry
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+
47
+ def register_exception_handlers(app: FastAPI, modules: list) -> None:
48
+ """Install framework-level exception handlers, then per-module handlers."""
49
+ app.add_exception_handler(
50
+ InertiaVersionConflictException,
51
+ inertia_version_conflict_exception_handler,
52
+ )
53
+ app.add_exception_handler(HTTPException, http_exception_handler)
54
+ app.add_exception_handler(NotFoundError, not_found_error_handler)
55
+ app.add_exception_handler(Exception, unhandled_exception_handler)
56
+ for mod in modules:
57
+ mod.register_exception_handlers(app)
58
+
59
+
60
+ def install_middleware(
61
+ app: FastAPI,
62
+ settings: Settings,
63
+ modules: list,
64
+ menu_registry: MenuRegistry,
65
+ perm_registry: PermissionRegistry,
66
+ ) -> None:
67
+ """Install the full middleware pipeline.
68
+
69
+ Order matters: last added = first executed. Execution order:
70
+ CorrelationId → RequestLogging → Security → Session
71
+ → [module] → (Tenant, if multi_tenant) → Locale → Inertia.
72
+ """
73
+ app.add_middleware(
74
+ InertiaLayoutDataMiddleware,
75
+ menu_registry=menu_registry,
76
+ permission_registry=perm_registry,
77
+ )
78
+ app.add_middleware(
79
+ LocaleMiddleware,
80
+ supported_locales=settings.i18n_supported_locales,
81
+ default_locale=settings.i18n_default_locale,
82
+ cookie_name=settings.i18n_cookie_name,
83
+ )
84
+ if settings.multi_tenant:
85
+ app.add_middleware(TenantMiddleware, header=settings.tenant_header or None)
86
+ for mod in modules:
87
+ mod.register_middleware(app)
88
+ app.add_middleware(SessionMiddleware, secret_key=settings.secret_key)
89
+ # In dev, relax CSP so the browser can fetch @vite/client, main.tsx, and
90
+ # the HMR WebSocket from the Vite origin. HSTS is also suppressed because
91
+ # dev runs over plain HTTP on loopback.
92
+ if settings.is_development:
93
+ app.add_middleware(
94
+ SecurityHeadersMiddleware,
95
+ content_security_policy=SecurityHeadersMiddleware.dev_csp(settings.vite_dev_url),
96
+ strict_transport_security=None,
97
+ )
98
+ else:
99
+ app.add_middleware(SecurityHeadersMiddleware)
100
+ app.add_middleware(RequestLoggingMiddleware)
101
+ app.add_middleware(CorrelationIdMiddleware)
102
+
103
+
104
+ def mount_module_static_dirs(app: FastAPI, modules: list) -> None:
105
+ """Mount each module's declared static directories.
106
+
107
+ Modules typically expose ``/modules/<name>/static`` for pre-bundled
108
+ frontend assets shipped inside the wheel.
109
+ """
110
+ for mod in modules:
111
+ for url_prefix, directory in mod.static_mounts().items():
112
+ directory_path = Path(directory)
113
+ if not directory_path.is_dir():
114
+ logger.warning(
115
+ "Module '%s' declared static mount %s -> %s but directory does not exist",
116
+ mod.meta.name,
117
+ url_prefix,
118
+ directory_path,
119
+ )
120
+ continue
121
+ app.mount(
122
+ url_prefix,
123
+ StaticFiles(directory=directory_path),
124
+ name=f"static:{mod.meta.name}",
125
+ )
126
+
127
+
128
+ def check_settings_registration(app: FastAPI, modules: list) -> list[Diagnostic]:
129
+ """SM012: warn if a module overrides register_settings but added nothing to app.state.
130
+
131
+ Must run after Phase 4 (register_settings) and therefore can't join the
132
+ Phase 2 diagnostics pass; returning a list lets the caller route it through
133
+ the same ``print_diagnostics`` sink.
134
+ """
135
+ diagnostics: list[Diagnostic] = []
136
+ for mod in modules:
137
+ cls = type(mod)
138
+ if "register_settings" not in cls.__dict__:
139
+ continue
140
+ # Match the convention actually used by modules: `app.state.<package>`
141
+ # (snake_case package name, e.g. `background_tasks`), which aligns
142
+ # with Settings-module autodiscovery in `settings._module_settings`.
143
+ package = cls.__module__.split(".", 1)[0]
144
+ candidates = (package, mod.meta.name.lower())
145
+ if any(hasattr(app.state, c) for c in candidates):
146
+ continue
147
+ mod_prefix = package
148
+ diagnostics.append(
149
+ Diagnostic(
150
+ level=DiagnosticLevel.WARNING,
151
+ code="SM012",
152
+ message="register_settings() was overridden but added nothing to app.state",
153
+ module_name=mod.meta.name,
154
+ suggestion=(
155
+ f"Store your module state on app.state "
156
+ f"(e.g., app.state.{mod_prefix} = {mod.meta.name}Services(...))"
157
+ ),
158
+ )
159
+ )
160
+ return diagnostics