simple-module-hosting 0.0.19__tar.gz → 0.0.21__tar.gz

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 (58) hide show
  1. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/.gitignore +1 -0
  2. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/PKG-INFO +3 -3
  3. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/pyproject.toml +3 -3
  4. simple_module_hosting-0.0.21/simple_module_hosting/__init__.py +18 -0
  5. simple_module_hosting-0.0.21/simple_module_hosting/_inertia_setup.py +184 -0
  6. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/simple_module_hosting/_inertia_shared.py +28 -0
  7. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/simple_module_hosting/_phase_helpers.py +24 -0
  8. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/simple_module_hosting/app_builder.py +4 -2
  9. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/simple_module_hosting/middleware.py +6 -1
  10. simple_module_hosting-0.0.21/simple_module_hosting/shared_props.py +42 -0
  11. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/tests/test_app.py +10 -5
  12. simple_module_hosting-0.0.21/tests/test_branding_head.py +32 -0
  13. simple_module_hosting-0.0.21/tests/test_inertia_manifest.py +71 -0
  14. simple_module_hosting-0.0.21/tests/test_inertia_shared_providers.py +91 -0
  15. simple_module_hosting-0.0.21/tests/test_static_caching.py +37 -0
  16. simple_module_hosting-0.0.19/simple_module_hosting/__init__.py +0 -7
  17. simple_module_hosting-0.0.19/simple_module_hosting/_inertia_setup.py +0 -94
  18. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/LICENSE +0 -0
  19. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/README.md +0 -0
  20. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/simple_module_hosting/__main__.py +0 -0
  21. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/simple_module_hosting/_error_handlers.py +0 -0
  22. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/simple_module_hosting/_host_services.py +0 -0
  23. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/simple_module_hosting/_hydrate_step.py +0 -0
  24. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/simple_module_hosting/_observability.py +0 -0
  25. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/simple_module_hosting/bootstrap_settings.py +0 -0
  26. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/simple_module_hosting/health.py +0 -0
  27. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/simple_module_hosting/host_cli.py +0 -0
  28. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/simple_module_hosting/host_settings.py +0 -0
  29. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/simple_module_hosting/i18n_deps.py +0 -0
  30. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/simple_module_hosting/i18n_manifest.py +0 -0
  31. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/simple_module_hosting/i18n_middleware.py +0 -0
  32. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/simple_module_hosting/inertia_deps.py +0 -0
  33. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/simple_module_hosting/inertia_utils.py +0 -0
  34. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/simple_module_hosting/logging.py +0 -0
  35. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/simple_module_hosting/manifest.py +0 -0
  36. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/simple_module_hosting/migrations.py +0 -0
  37. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/simple_module_hosting/permissions.py +0 -0
  38. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/simple_module_hosting/py.typed +0 -0
  39. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/simple_module_hosting/redirects.py +0 -0
  40. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/simple_module_hosting/settings.py +0 -0
  41. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/tests/test_check_migrations.py +0 -0
  42. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/tests/test_health.py +0 -0
  43. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/tests/test_host_cli.py +0 -0
  44. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/tests/test_hosting_permissions.py +0 -0
  45. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/tests/test_i18n_manifest.py +0 -0
  46. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/tests/test_inertia_i18n_shared_props.py +0 -0
  47. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/tests/test_lifespan_order.py +0 -0
  48. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/tests/test_locale_middleware.py +0 -0
  49. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/tests/test_logging.py +0 -0
  50. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/tests/test_manifest.py +0 -0
  51. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/tests/test_middleware_order.py +0 -0
  52. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/tests/test_redirects.py +0 -0
  53. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/tests/test_session_cookie_security.py +0 -0
  54. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/tests/test_settings_i18n.py +0 -0
  55. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/tests/test_settings_secrets.py +0 -0
  56. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/tests/test_strict_discovery_wiring.py +0 -0
  57. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/tests/test_tenant_middleware.py +0 -0
  58. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.21}/tests/test_translator_dep.py +0 -0
@@ -61,3 +61,4 @@ Thumbs.db
61
61
  .playwright-mcp/*
62
62
  host/client_app/.playwright-cli/*
63
63
  .superpowers/
64
+ .qa/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simple_module_hosting
3
- Version: 0.0.19
3
+ Version: 0.0.21
4
4
  Summary: FastAPI + Inertia.js host runtime for simple_module — app_builder, middleware stack, CLI (sm / simple-module), scaffolding
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -26,8 +26,8 @@ Requires-Dist: fastapi-inertia>=1.0
26
26
  Requires-Dist: fastapi>=0.115
27
27
  Requires-Dist: httpx>=0.27
28
28
  Requires-Dist: jinja2>=3.1
29
- Requires-Dist: simple-module-core==0.0.19
30
- Requires-Dist: simple-module-db==0.0.19
29
+ Requires-Dist: simple-module-core==0.0.21
30
+ Requires-Dist: simple-module-db==0.0.21
31
31
  Requires-Dist: starlette>=0.44
32
32
  Requires-Dist: tomlkit>=0.13
33
33
  Requires-Dist: uvicorn[standard]>=0.34
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_hosting"
3
- version = "0.0.19"
3
+ version = "0.0.21"
4
4
  description = "FastAPI + Inertia.js host runtime for simple_module — app_builder, middleware stack, CLI (sm / simple-module), scaffolding"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -26,8 +26,8 @@ dependencies = [
26
26
  "fastapi-inertia>=1.0",
27
27
  "httpx>=0.27",
28
28
  "jinja2>=3.1",
29
- "simple_module_core==0.0.19",
30
- "simple_module_db==0.0.19",
29
+ "simple_module_core==0.0.21",
30
+ "simple_module_db==0.0.21",
31
31
  "starlette>=0.44",
32
32
  "tomlkit>=0.13",
33
33
  "uvicorn[standard]>=0.34",
@@ -0,0 +1,18 @@
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
+ from simple_module_hosting.shared_props import (
7
+ SharedPropsProvider,
8
+ register_inertia_shared_provider,
9
+ )
10
+
11
+ __all__ = [
12
+ "Settings",
13
+ "SharedPropsProvider",
14
+ "correlation_id",
15
+ "create_app",
16
+ "register_inertia_shared_provider",
17
+ "setup_logging",
18
+ ]
@@ -0,0 +1,184 @@
1
+ """Configure fastapi-inertia with the Jinja2 template."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import logging
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+ from fastapi import FastAPI
12
+ from inertia import InertiaConfig, inertia_dependency_factory
13
+ from starlette.requests import Request
14
+
15
+ from simple_module_hosting.settings import Settings
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ _INERTIA_VERSION = "1.0"
20
+ _ROOT_TEMPLATE_FILENAME = "index.html"
21
+ _ENTRYPOINT_FILENAME = "main.tsx"
22
+ _ROOT_DIRECTORY = "."
23
+
24
+ # Fallback app name when the (optional) branding module isn't installed. Mirrors
25
+ # branding's own default so the unbranded title is identical everywhere.
26
+ _DEFAULT_APP_NAME = "SimpleModule"
27
+
28
+
29
+ def branding_head(request: Request) -> dict:
30
+ """Branding metadata for the root template's ``<head>``.
31
+
32
+ Reads the optional branding module's settings off ``app.state`` by name
33
+ (duck-typed, never imported) so the static ``<title>`` and ``theme-color``
34
+ are already branded *before* React hydrates. Degrades to the framework
35
+ default when branding isn't installed. Only plain settings strings are
36
+ surfaced here — the favicon is applied client-side by ``BrandingHead`` via
37
+ Inertia's ``<Head>``, keeping file_storage's download-route shape out of
38
+ framework code.
39
+ """
40
+ services = getattr(request.app.state, "branding", None)
41
+ settings = getattr(services, "settings", None)
42
+ if settings is None:
43
+ return {"app_name": _DEFAULT_APP_NAME, "theme_color": None}
44
+ return {
45
+ "app_name": getattr(settings, "app_name", "") or _DEFAULT_APP_NAME,
46
+ "theme_color": getattr(settings, "primary_color", "") or None,
47
+ }
48
+
49
+
50
+ # Built assets are served from the "/static" mount under "dist/", so production
51
+ # asset URLs are prefixed with "static/dist".
52
+ _ASSETS_PREFIX = "static/dist"
53
+ _VITE_MANIFEST_RELPATH = Path("static") / "dist" / ".vite" / "manifest.json"
54
+
55
+
56
+ def _prod_manifest_path(project_root: Path) -> str:
57
+ """Return a manifest path fastapi-inertia can read in production.
58
+
59
+ fastapi-inertia looks the entry up by ``f"{root_directory}/{entrypoint}"``
60
+ (here ``"./main.tsx"``), but Vite keys its manifest by the entry's path
61
+ relative to the Vite root (``"main.tsx"``) — so the raw Vite manifest would
62
+ ``KeyError``. Read it, re-key the ``isEntry`` chunk under the key
63
+ fastapi-inertia expects, and write the normalized copy beside the build
64
+ output (falling back to a temp file if that dir is read-only). Returns ``""``
65
+ when no built manifest exists, leaving production assets unconfigured rather
66
+ than crashing at import time.
67
+ """
68
+ candidates = [
69
+ project_root / "host" / _VITE_MANIFEST_RELPATH,
70
+ project_root / _VITE_MANIFEST_RELPATH,
71
+ ]
72
+ vite_manifest = next((p for p in candidates if p.is_file()), None)
73
+ if vite_manifest is None:
74
+ logger.warning(
75
+ "Production Vite manifest not found (looked in %s)", [str(c) for c in candidates]
76
+ )
77
+ return ""
78
+ try:
79
+ data = json.loads(vite_manifest.read_text())
80
+ expected_key = f"{_ROOT_DIRECTORY}/{_ENTRYPOINT_FILENAME}"
81
+ if expected_key not in data:
82
+ entry = next((v for v in data.values() if v.get("isEntry")), None)
83
+ if entry is None:
84
+ # No entry to re-key: degrade gracefully (same as no-manifest)
85
+ # rather than returning a path that KeyErrors at render time.
86
+ logger.warning("No isEntry chunk in Vite manifest %s", vite_manifest)
87
+ return ""
88
+ data = {**data, expected_key: entry}
89
+ out = vite_manifest.parent / "inertia-manifest.json"
90
+ try:
91
+ out.write_text(json.dumps(data))
92
+ except OSError:
93
+ # Build dir read-only (e.g. immutable container layer): fall back to
94
+ # a temp file keyed by the source manifest path so multiple apps on
95
+ # one host don't clobber each other's normalized manifests.
96
+ digest = hashlib.sha1(str(vite_manifest).encode()).hexdigest()[:12]
97
+ out = Path(tempfile.gettempdir()) / f"sm-inertia-manifest-{digest}.json"
98
+ out.write_text(json.dumps(data))
99
+ return str(out)
100
+ except Exception:
101
+ logger.exception("Failed to prepare production Inertia manifest from %s", vite_manifest)
102
+ return ""
103
+
104
+
105
+ def setup_inertia(
106
+ app: FastAPI,
107
+ settings: Settings,
108
+ modules: list,
109
+ project_root: Path,
110
+ ) -> InertiaConfig | None:
111
+ """Configure fastapi-inertia and attach the dependency factory to app.state.
112
+
113
+ Two host layouts are supported: ``host/templates`` (the framework's
114
+ own host package) and ``templates`` at the project root (what
115
+ ``smpy new`` produces). The first one found wins so it can override
116
+ module-contributed templates.
117
+ """
118
+ from fastapi.templating import Jinja2Templates
119
+
120
+ candidate_dirs = [
121
+ project_root / "host" / "templates",
122
+ project_root / "templates",
123
+ ]
124
+ directories: list[Path] = []
125
+
126
+ host_templates = next((p for p in candidate_dirs if p.is_dir()), None)
127
+ if host_templates is not None:
128
+ directories.append(host_templates)
129
+ else:
130
+ logger.warning(
131
+ "Host templates directory not found (looked in %s)",
132
+ ", ".join(str(p) for p in candidate_dirs),
133
+ )
134
+
135
+ for mod in modules:
136
+ for path in mod.template_dirs():
137
+ if Path(path).is_dir():
138
+ directories.append(Path(path))
139
+ else:
140
+ logger.warning(
141
+ "Module '%s' declared template dir %s but it does not exist",
142
+ mod.meta.name,
143
+ path,
144
+ )
145
+
146
+ if not directories:
147
+ logger.warning("No usable template directories — Inertia will fail to render views")
148
+ return None
149
+
150
+ templates = Jinja2Templates(directory=directories)
151
+ # Expose branding metadata to the root template (pre-hydration head tags).
152
+ templates.env.globals["branding_head"] = branding_head
153
+
154
+ # fastapi-inertia only switches to the asset manifest when environment
155
+ # equals the literal string "production". Anything else (staging, qa,
156
+ # ...) would render a /main.tsx <script> tag served by the SPA fallback
157
+ # as text/html, breaking module loading. Normalize:
158
+ # * `development`/`testing` → keep the dev-server path (Vite serves
159
+ # /main.tsx directly).
160
+ # * Anything else (staging, production, qa, ...) → use the production
161
+ # manifest so built assets are referenced.
162
+ from simple_module_core.environments import NON_PROD_ENVIRONMENTS
163
+
164
+ use_dev_server = settings.environment in NON_PROD_ENVIRONMENTS
165
+ inertia_environment = "development" if use_dev_server else "production"
166
+
167
+ inertia_config = InertiaConfig(
168
+ environment=inertia_environment,
169
+ version=_INERTIA_VERSION,
170
+ dev_url=settings.vite_dev_url if use_dev_server else "",
171
+ # Production reads built assets from the Vite manifest; dev serves them
172
+ # from the Vite dev server, so these only matter when not use_dev_server.
173
+ manifest_json_path="" if use_dev_server else _prod_manifest_path(project_root),
174
+ assets_prefix="" if use_dev_server else _ASSETS_PREFIX,
175
+ templates=templates,
176
+ root_template_filename=_ROOT_TEMPLATE_FILENAME,
177
+ entrypoint_filename=_ENTRYPOINT_FILENAME,
178
+ root_directory=_ROOT_DIRECTORY,
179
+ use_flash_errors=True,
180
+ )
181
+
182
+ inertia_dep = inertia_dependency_factory(inertia_config)
183
+ app.state.inertia_dependency = inertia_dep
184
+ return inertia_config
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
+ from typing import Any
6
7
 
7
8
  from starlette.datastructures import Headers
8
9
  from starlette.requests import Request
@@ -59,3 +60,30 @@ def build_i18n_block(scope: Scope, request: Request) -> dict:
59
60
  "supportedLocales": registry.available_locales(),
60
61
  "messages": registry.messages_snapshot(locale) if send_messages else None,
61
62
  }
63
+
64
+
65
+ def merge_shared_prop_providers(app: Any, request: Request, shared: dict) -> None:
66
+ """Merge module-registered Inertia shared-prop providers into ``shared`` in place.
67
+
68
+ Providers are read off ``app.state.inertia_shared_providers`` (never importing
69
+ the plugin — preserves SM009). A provider that raises is skipped and logged; a
70
+ provider may not clobber a framework-owned key (auth/menus/i18n) or an earlier
71
+ provider's key.
72
+ """
73
+ providers = getattr(app.state, "inertia_shared_providers", None) or ()
74
+ for provider in providers:
75
+ name = getattr(provider, "__name__", provider)
76
+ try:
77
+ extra = provider(request)
78
+ except Exception: # a bad provider must not break the page render
79
+ logger.warning("shared-prop provider %r raised; skipping", name, exc_info=True)
80
+ continue
81
+ for key, value in (extra or {}).items():
82
+ if key in shared:
83
+ logger.warning(
84
+ "provider %r tried to overwrite reserved shared-prop %r; ignoring",
85
+ name,
86
+ key,
87
+ )
88
+ continue
89
+ shared[key] = value
@@ -23,6 +23,8 @@ from simple_module_core.diagnostics import Diagnostic, DiagnosticLevel
23
23
  from simple_module_core.exceptions import NotFoundError
24
24
  from starlette.exceptions import HTTPException
25
25
  from starlette.middleware.sessions import SessionMiddleware
26
+ from starlette.responses import Response
27
+ from starlette.types import Scope
26
28
 
27
29
  from simple_module_hosting._error_handlers import (
28
30
  http_exception_handler,
@@ -46,6 +48,28 @@ if TYPE_CHECKING:
46
48
 
47
49
  logger = logging.getLogger(__name__)
48
50
 
51
+ _IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable"
52
+
53
+
54
+ class ImmutableStaticFiles(StaticFiles):
55
+ """StaticFiles that marks Vite's content-hashed build assets immutable.
56
+
57
+ Vite emits files under ``dist/assets/`` with a content hash in the filename
58
+ (e.g. ``main-3YbShAJ4.js``), so the bytes for a given URL never change —
59
+ browsers can cache them indefinitely and skip even the revalidation
60
+ round-trip. The default StaticFiles only sets ETag/Last-Modified, forcing a
61
+ conditional GET per asset on every visit. Non-hashed paths (the manifest,
62
+ etc.) keep the default behaviour.
63
+ """
64
+
65
+ async def get_response(self, path: str, scope: Scope) -> Response:
66
+ response = await super().get_response(path, scope)
67
+ # StaticFiles hands us an OS-separator path (backslashes on Windows), so
68
+ # normalize before matching the forward-slash asset prefix.
69
+ if response.status_code == 200 and path.replace("\\", "/").startswith("dist/assets/"):
70
+ response.headers["Cache-Control"] = _IMMUTABLE_CACHE_CONTROL
71
+ return response
72
+
49
73
 
50
74
  def register_exception_handlers(app: FastAPI, modules: list) -> None:
51
75
  """Install framework-level exception handlers, then per-module handlers."""
@@ -10,7 +10,6 @@ from contextlib import asynccontextmanager
10
10
  from pathlib import Path
11
11
 
12
12
  from fastapi import FastAPI
13
- from fastapi.staticfiles import StaticFiles
14
13
  from simple_module_core.diagnostics import DiagnosticLevel, print_diagnostics, run_diagnostics
15
14
  from simple_module_core.discovery import discover_modules, topological_sort
16
15
  from simple_module_core.events import EventBus
@@ -26,6 +25,7 @@ from simple_module_db.session import init_db
26
25
  from simple_module_hosting._host_services import _HostServices
27
26
  from simple_module_hosting._inertia_setup import setup_inertia
28
27
  from simple_module_hosting._phase_helpers import (
28
+ ImmutableStaticFiles,
29
29
  attach_public_routes,
30
30
  check_settings_registration,
31
31
  install_middleware,
@@ -277,7 +277,9 @@ def create_app(settings: Settings | None = None) -> FastAPI:
277
277
 
278
278
  static_dir = _PROJECT_ROOT / "host" / _STATIC_DIR_NAME
279
279
  if static_dir.is_dir():
280
- app.mount(_STATIC_MOUNT_PATH, StaticFiles(directory=static_dir), name=_STATIC_DIR_NAME)
280
+ app.mount(
281
+ _STATIC_MOUNT_PATH, ImmutableStaticFiles(directory=static_dir), name=_STATIC_DIR_NAME
282
+ )
281
283
 
282
284
  mount_module_static_dirs(app, modules)
283
285
 
@@ -18,7 +18,7 @@ from starlette.datastructures import Headers, MutableHeaders
18
18
  from starlette.requests import Request
19
19
  from starlette.types import ASGIApp, Message, Receive, Scope, Send
20
20
 
21
- from simple_module_hosting._inertia_shared import build_i18n_block
21
+ from simple_module_hosting._inertia_shared import build_i18n_block, merge_shared_prop_providers
22
22
  from simple_module_hosting._observability import (
23
23
  CorrelationIdMiddleware,
24
24
  RequestLoggingMiddleware,
@@ -277,6 +277,11 @@ class InertiaLayoutDataMiddleware:
277
277
  ),
278
278
  "i18n": i18n_block,
279
279
  }
280
+
281
+ # Merge module-registered shared-prop providers (e.g. branding) — read off
282
+ # app.state without importing the plugin (SM009), defensively.
283
+ merge_shared_prop_providers(scope["app"], request, shared)
284
+
280
285
  request.state.inertia_shared = shared
281
286
 
282
287
  await self.app(scope, receive, send)
@@ -0,0 +1,42 @@
1
+ """Module-registered Inertia shared-prop providers.
2
+
3
+ A generic extension point so plugin modules can contribute layout-wide Inertia
4
+ shared props (e.g. branding) on every page, without the framework importing the
5
+ plugin. This mirrors the ``principal_serializer`` precedent: the framework reads
6
+ a registered callable off ``app.state`` rather than reaching into module code,
7
+ keeping the ``SM009`` framework→plugin import ban intact.
8
+
9
+ A provider is ``Callable[[Request], dict]``. It must be cheap and total — it runs
10
+ for every request. :class:`InertiaLayoutDataMiddleware` merges each provider's
11
+ returned dict into the ``shared`` payload after the built-in ``auth``/``menus``/
12
+ ``i18n`` blocks; a provider that raises is skipped and logged, never failing the
13
+ request.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Callable
19
+ from typing import TYPE_CHECKING
20
+
21
+ if TYPE_CHECKING:
22
+ from fastapi import FastAPI
23
+ from starlette.requests import Request
24
+
25
+ SharedPropsProvider = Callable[["Request"], dict]
26
+ """A function mapping a request to a dict merged into Inertia shared props."""
27
+
28
+ _STATE_ATTR = "inertia_shared_providers"
29
+
30
+
31
+ def register_inertia_shared_provider(app: FastAPI, provider: SharedPropsProvider) -> None:
32
+ """Register a shared-props provider on the app.
33
+
34
+ Idempotently initialises ``app.state.inertia_shared_providers`` (a list) and
35
+ appends ``provider``. Safe to call from a module lifecycle hook before the
36
+ framework has set up the list.
37
+ """
38
+ providers = getattr(app.state, _STATE_ATTR, None)
39
+ if providers is None:
40
+ providers = []
41
+ setattr(app.state, _STATE_ATTR, providers)
42
+ providers.append(provider)
@@ -27,12 +27,14 @@ class TestCreateApp:
27
27
 
28
28
  async def test_modules_enabled_limits_loaded_modules(self, settings: Settings):
29
29
  """Host respects settings.modules_enabled — only listed modules contribute routes."""
30
+ from simple_module_test import effective_route_paths
31
+
30
32
  # Only Auth should be loaded; Dashboard routes must be absent.
31
33
  restricted = settings.model_copy(update={"modules_enabled": ["Auth"]})
32
34
  app = create_app(restricted)
33
- paths: set[str] = {str(r.path) for r in app.routes if hasattr(r, "path")}
35
+ paths = effective_route_paths(app)
34
36
  # Auth is now contracts-only, so it has no routes — only health remains.
35
- assert "/dashboard" not in paths
37
+ assert not any(p.startswith("/dashboard") for p in paths)
36
38
 
37
39
  async def test_module_static_mounts_become_app_routes(
38
40
  self,
@@ -203,7 +205,9 @@ class TestResolveProjectRoot:
203
205
  class TestRouteRegistration:
204
206
  async def test_expected_routes_registered(self, app: FastAPI):
205
207
  """All modules should have their routes registered in the app."""
206
- route_paths = [r.path for r in app.routes if hasattr(r, "path")]
208
+ from simple_module_test import effective_route_paths
209
+
210
+ route_paths = effective_route_paths(app)
207
211
 
208
212
  assert "/health" in route_paths
209
213
  assert "/health/live" in route_paths
@@ -216,8 +220,9 @@ class TestRouteRegistration:
216
220
  # landing page at "/" is owned by the host and added in host/main.py,
217
221
  # which the create_app fixture doesn't run.
218
222
  assert "/dashboard/" in route_paths
219
- # Bare-prefix alias — see wire_module_routes for the X-Inertia rationale.
220
- assert "/dashboard" in route_paths
223
+ # The bare-prefix Inertia alias ("/dashboard" without the slash) is
224
+ # registered with include_in_schema=False, so it isn't enumerable here;
225
+ # TestProtectedPages::test_dashboard_redirects_unauthenticated covers it.
221
226
 
222
227
 
223
228
  class TestProtectedPages:
@@ -0,0 +1,32 @@
1
+ """Tests for the root-template branding head metadata helper."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from types import SimpleNamespace
6
+
7
+ from simple_module_hosting._inertia_setup import branding_head
8
+
9
+
10
+ def _request(branding: object | None) -> SimpleNamespace:
11
+ state = SimpleNamespace()
12
+ if branding is not None:
13
+ state.branding = branding
14
+ return SimpleNamespace(app=SimpleNamespace(state=state))
15
+
16
+
17
+ def test_defaults_when_branding_not_installed() -> None:
18
+ meta = branding_head(_request(None))
19
+ assert meta == {"app_name": "SimpleModule", "theme_color": None}
20
+
21
+
22
+ def test_reads_app_name_and_theme_color() -> None:
23
+ settings = SimpleNamespace(app_name="Acme", primary_color="#1a7dd1")
24
+ meta = branding_head(_request(SimpleNamespace(settings=settings)))
25
+ assert meta["app_name"] == "Acme"
26
+ assert meta["theme_color"] == "#1a7dd1"
27
+
28
+
29
+ def test_blank_values_fall_back() -> None:
30
+ settings = SimpleNamespace(app_name="", primary_color="")
31
+ meta = branding_head(_request(SimpleNamespace(settings=settings)))
32
+ assert meta == {"app_name": "SimpleModule", "theme_color": None}
@@ -0,0 +1,71 @@
1
+ """Tests for production Inertia manifest normalization (_prod_manifest_path).
2
+
3
+ fastapi-inertia looks the entry up by ``f"{root_directory}/{entrypoint}"`` =
4
+ ``"./main.tsx"``, but Vite keys its manifest by the entry's source path
5
+ (``"main.tsx"``). _prod_manifest_path bridges the two so production page
6
+ rendering doesn't KeyError. Regression guard for that bug.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from pathlib import Path
13
+
14
+ from simple_module_hosting._inertia_setup import _prod_manifest_path
15
+
16
+
17
+ def _write_vite_manifest(project_root: Path) -> Path:
18
+ manifest_dir = project_root / "host" / "static" / "dist" / ".vite"
19
+ manifest_dir.mkdir(parents=True)
20
+ manifest = {
21
+ "main.tsx": {
22
+ "file": "assets/main-ABC123.js",
23
+ "css": ["assets/main-DEF456.css"],
24
+ "isEntry": True,
25
+ },
26
+ "pages/Foo.tsx": {"file": "assets/Foo-XYZ.js"},
27
+ }
28
+ path = manifest_dir / "manifest.json"
29
+ path.write_text(json.dumps(manifest))
30
+ return path
31
+
32
+
33
+ def test_rekeys_entry_for_fastapi_inertia(tmp_path: Path):
34
+ _write_vite_manifest(tmp_path)
35
+ result = _prod_manifest_path(tmp_path)
36
+
37
+ assert result, "expected a manifest path, got empty string"
38
+ data = json.loads(Path(result).read_text())
39
+ # fastapi-inertia will look up f"{root_directory}/{entrypoint}" == "./main.tsx"
40
+ assert "./main.tsx" in data
41
+ assert data["./main.tsx"]["file"] == "assets/main-ABC123.js"
42
+ assert data["./main.tsx"]["css"] == ["assets/main-DEF456.css"]
43
+ # original keys are preserved (other chunks still resolvable)
44
+ assert "pages/Foo.tsx" in data
45
+
46
+
47
+ def test_returns_empty_when_no_built_manifest(tmp_path: Path):
48
+ # No host/static/dist/.vite/manifest.json present.
49
+ assert _prod_manifest_path(tmp_path) == ""
50
+
51
+
52
+ def test_returns_empty_when_no_entry_chunk(tmp_path: Path):
53
+ # A manifest with no isEntry chunk must degrade to "" (not return a path
54
+ # that would KeyError at render) — same graceful path as no-manifest.
55
+ manifest_dir = tmp_path / "host" / "static" / "dist" / ".vite"
56
+ manifest_dir.mkdir(parents=True)
57
+ (manifest_dir / "manifest.json").write_text(json.dumps({"pages/Foo.tsx": {"file": "f.js"}}))
58
+ assert _prod_manifest_path(tmp_path) == ""
59
+
60
+
61
+ def test_scaffolded_layout_without_host_dir(tmp_path: Path):
62
+ # smpy-new apps keep static/ at the project root (no host/ subdir).
63
+ manifest_dir = tmp_path / "static" / "dist" / ".vite"
64
+ manifest_dir.mkdir(parents=True)
65
+ (manifest_dir / "manifest.json").write_text(
66
+ json.dumps({"main.tsx": {"file": "assets/main-A.js", "isEntry": True}})
67
+ )
68
+ result = _prod_manifest_path(tmp_path)
69
+ assert result
70
+ data = json.loads(Path(result).read_text())
71
+ assert data["./main.tsx"]["file"] == "assets/main-A.js"
@@ -0,0 +1,91 @@
1
+ """Verify InertiaLayoutDataMiddleware merges module-registered shared-prop providers.
2
+
3
+ Modules contribute layout-wide Inertia shared props (e.g. branding) without the
4
+ framework importing the plugin — mirroring the ``principal_serializer`` precedent.
5
+ Providers are registered on ``app.state.inertia_shared_providers`` and merged into
6
+ the ``shared`` dict for every request.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+
13
+ from fastapi import FastAPI
14
+ from simple_module_core.menu import MenuRegistry
15
+ from simple_module_core.permissions import PermissionRegistry
16
+ from simple_module_hosting.middleware import InertiaLayoutDataMiddleware
17
+ from simple_module_hosting.shared_props import register_inertia_shared_provider
18
+ from starlette.middleware.sessions import SessionMiddleware
19
+ from starlette.requests import Request
20
+ from starlette.responses import JSONResponse
21
+ from starlette.testclient import TestClient
22
+
23
+
24
+ def _build_app() -> FastAPI:
25
+ app = FastAPI()
26
+
27
+ @app.get("/shared")
28
+ def shared(request: Request) -> JSONResponse:
29
+ return JSONResponse(request.state.inertia_shared)
30
+
31
+ app.add_middleware(
32
+ InertiaLayoutDataMiddleware,
33
+ menu_registry=MenuRegistry(),
34
+ permission_registry=PermissionRegistry(),
35
+ )
36
+ app.add_middleware(SessionMiddleware, secret_key="test-secret")
37
+ return app
38
+
39
+
40
+ def test_registered_provider_dict_merged_into_shared() -> None:
41
+ app = _build_app()
42
+ register_inertia_shared_provider(app, lambda _req: {"branding": {"appName": "Acme"}})
43
+
44
+ body = TestClient(app).get("/shared").json()
45
+
46
+ assert body["branding"] == {"appName": "Acme"}
47
+ # Built-in blocks still present.
48
+ assert "auth" in body
49
+ assert "menus" in body
50
+
51
+
52
+ def test_provider_that_raises_is_skipped_not_fatal(caplog) -> None:
53
+ app = _build_app()
54
+
55
+ def boom(_req: Request) -> dict:
56
+ raise RuntimeError("provider blew up")
57
+
58
+ register_inertia_shared_provider(app, boom)
59
+ register_inertia_shared_provider(app, lambda _req: {"branding": {"appName": "Acme"}})
60
+
61
+ with caplog.at_level(logging.WARNING, logger="simple_module_hosting.middleware"):
62
+ resp = TestClient(app).get("/shared")
63
+
64
+ assert resp.status_code == 200
65
+ body = resp.json()
66
+ # The good provider still applied; the failing one was skipped.
67
+ assert body["branding"] == {"appName": "Acme"}
68
+ assert "auth" in body
69
+ assert any("shared-prop" in rec.message.lower() for rec in caplog.records)
70
+
71
+
72
+ def test_no_providers_leaves_shared_unchanged() -> None:
73
+ body = TestClient(_build_app()).get("/shared").json()
74
+ assert "branding" not in body
75
+ assert "auth" in body and "menus" in body
76
+
77
+
78
+ def test_provider_cannot_clobber_framework_keys(caplog) -> None:
79
+ app = _build_app()
80
+ # A misbehaving provider tries to overwrite the framework-owned auth block.
81
+ register_inertia_shared_provider(
82
+ app, lambda _req: {"auth": "HIJACKED", "branding": {"ok": True}}
83
+ )
84
+
85
+ with caplog.at_level(logging.WARNING, logger="simple_module_hosting.middleware"):
86
+ body = TestClient(app).get("/shared").json()
87
+
88
+ # auth stays the framework's dict; only the non-reserved key is added.
89
+ assert isinstance(body["auth"], dict)
90
+ assert body["branding"] == {"ok": True}
91
+ assert any("reserved shared-prop" in rec.message for rec in caplog.records)
@@ -0,0 +1,37 @@
1
+ """ImmutableStaticFiles marks Vite's content-hashed assets immutable.
2
+
3
+ Hashed filenames (``main-3YbShAJ4.js``) are content-addressed, so browsers can
4
+ cache them forever and skip the per-asset revalidation round-trip. Non-hashed
5
+ paths keep StaticFiles' default (ETag/Last-Modified only).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ from simple_module_hosting._phase_helpers import ImmutableStaticFiles
13
+
14
+ _GET_SCOPE = {"type": "http", "method": "GET", "headers": []}
15
+
16
+
17
+ def _make_tree(root: Path) -> None:
18
+ (root / "dist" / "assets").mkdir(parents=True)
19
+ (root / "dist" / "assets" / "main-ABC123.js").write_text("console.log(1)")
20
+ (root / "dist" / ".vite").mkdir(parents=True)
21
+ (root / "dist" / ".vite" / "manifest.json").write_text("{}")
22
+
23
+
24
+ async def test_hashed_asset_is_immutable(tmp_path: Path):
25
+ _make_tree(tmp_path)
26
+ static = ImmutableStaticFiles(directory=tmp_path)
27
+ resp = await static.get_response("dist/assets/main-ABC123.js", _GET_SCOPE)
28
+ assert resp.status_code == 200
29
+ assert resp.headers["cache-control"] == "public, max-age=31536000, immutable"
30
+
31
+
32
+ async def test_non_asset_keeps_default_caching(tmp_path: Path):
33
+ _make_tree(tmp_path)
34
+ static = ImmutableStaticFiles(directory=tmp_path)
35
+ resp = await static.get_response("dist/.vite/manifest.json", _GET_SCOPE)
36
+ assert resp.status_code == 200
37
+ assert "immutable" not in resp.headers.get("cache-control", "")
@@ -1,7 +0,0 @@
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"]
@@ -1,94 +0,0 @@
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
- Two host layouts are supported: ``host/templates`` (the framework's
30
- own host package) and ``templates`` at the project root (what
31
- ``smpy new`` produces). The first one found wins so it can override
32
- module-contributed templates.
33
- """
34
- from fastapi.templating import Jinja2Templates
35
-
36
- candidate_dirs = [
37
- project_root / "host" / "templates",
38
- project_root / "templates",
39
- ]
40
- directories: list[Path] = []
41
-
42
- host_templates = next((p for p in candidate_dirs if p.is_dir()), None)
43
- if host_templates is not None:
44
- directories.append(host_templates)
45
- else:
46
- logger.warning(
47
- "Host templates directory not found (looked in %s)",
48
- ", ".join(str(p) for p in candidate_dirs),
49
- )
50
-
51
- for mod in modules:
52
- for path in mod.template_dirs():
53
- if Path(path).is_dir():
54
- directories.append(Path(path))
55
- else:
56
- logger.warning(
57
- "Module '%s' declared template dir %s but it does not exist",
58
- mod.meta.name,
59
- path,
60
- )
61
-
62
- if not directories:
63
- logger.warning("No usable template directories — Inertia will fail to render views")
64
- return None
65
-
66
- templates = Jinja2Templates(directory=directories)
67
-
68
- # fastapi-inertia only switches to the asset manifest when environment
69
- # equals the literal string "production". Anything else (staging, qa,
70
- # ...) would render a /main.tsx <script> tag served by the SPA fallback
71
- # as text/html, breaking module loading. Normalize:
72
- # * `development`/`testing` → keep the dev-server path (Vite serves
73
- # /main.tsx directly).
74
- # * Anything else (staging, production, qa, ...) → use the production
75
- # manifest so built assets are referenced.
76
- from simple_module_core.environments import NON_PROD_ENVIRONMENTS
77
-
78
- use_dev_server = settings.environment in NON_PROD_ENVIRONMENTS
79
- inertia_environment = "development" if use_dev_server else "production"
80
-
81
- inertia_config = InertiaConfig(
82
- environment=inertia_environment,
83
- version=_INERTIA_VERSION,
84
- dev_url=settings.vite_dev_url if use_dev_server else "",
85
- templates=templates,
86
- root_template_filename=_ROOT_TEMPLATE_FILENAME,
87
- entrypoint_filename=_ENTRYPOINT_FILENAME,
88
- root_directory=_ROOT_DIRECTORY,
89
- use_flash_errors=True,
90
- )
91
-
92
- inertia_dep = inertia_dependency_factory(inertia_config)
93
- app.state.inertia_dependency = inertia_dep
94
- return inertia_config