simple-module-hosting 0.0.19__tar.gz → 0.0.20__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 (56) hide show
  1. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/.gitignore +1 -0
  2. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/PKG-INFO +3 -3
  3. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/pyproject.toml +3 -3
  4. simple_module_hosting-0.0.20/simple_module_hosting/__init__.py +18 -0
  5. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/simple_module_hosting/_inertia_setup.py +60 -0
  6. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/simple_module_hosting/_inertia_shared.py +28 -0
  7. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/simple_module_hosting/_phase_helpers.py +24 -0
  8. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/simple_module_hosting/app_builder.py +4 -2
  9. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/simple_module_hosting/middleware.py +6 -1
  10. simple_module_hosting-0.0.20/simple_module_hosting/shared_props.py +42 -0
  11. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/tests/test_app.py +10 -5
  12. simple_module_hosting-0.0.20/tests/test_inertia_manifest.py +71 -0
  13. simple_module_hosting-0.0.20/tests/test_inertia_shared_providers.py +91 -0
  14. simple_module_hosting-0.0.20/tests/test_static_caching.py +37 -0
  15. simple_module_hosting-0.0.19/simple_module_hosting/__init__.py +0 -7
  16. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/LICENSE +0 -0
  17. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/README.md +0 -0
  18. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/simple_module_hosting/__main__.py +0 -0
  19. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/simple_module_hosting/_error_handlers.py +0 -0
  20. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/simple_module_hosting/_host_services.py +0 -0
  21. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/simple_module_hosting/_hydrate_step.py +0 -0
  22. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/simple_module_hosting/_observability.py +0 -0
  23. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/simple_module_hosting/bootstrap_settings.py +0 -0
  24. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/simple_module_hosting/health.py +0 -0
  25. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/simple_module_hosting/host_cli.py +0 -0
  26. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/simple_module_hosting/host_settings.py +0 -0
  27. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/simple_module_hosting/i18n_deps.py +0 -0
  28. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/simple_module_hosting/i18n_manifest.py +0 -0
  29. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/simple_module_hosting/i18n_middleware.py +0 -0
  30. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/simple_module_hosting/inertia_deps.py +0 -0
  31. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/simple_module_hosting/inertia_utils.py +0 -0
  32. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/simple_module_hosting/logging.py +0 -0
  33. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/simple_module_hosting/manifest.py +0 -0
  34. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/simple_module_hosting/migrations.py +0 -0
  35. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/simple_module_hosting/permissions.py +0 -0
  36. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/simple_module_hosting/py.typed +0 -0
  37. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/simple_module_hosting/redirects.py +0 -0
  38. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/simple_module_hosting/settings.py +0 -0
  39. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/tests/test_check_migrations.py +0 -0
  40. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/tests/test_health.py +0 -0
  41. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/tests/test_host_cli.py +0 -0
  42. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/tests/test_hosting_permissions.py +0 -0
  43. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/tests/test_i18n_manifest.py +0 -0
  44. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/tests/test_inertia_i18n_shared_props.py +0 -0
  45. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/tests/test_lifespan_order.py +0 -0
  46. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/tests/test_locale_middleware.py +0 -0
  47. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/tests/test_logging.py +0 -0
  48. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/tests/test_manifest.py +0 -0
  49. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/tests/test_middleware_order.py +0 -0
  50. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/tests/test_redirects.py +0 -0
  51. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/tests/test_session_cookie_security.py +0 -0
  52. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/tests/test_settings_i18n.py +0 -0
  53. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/tests/test_settings_secrets.py +0 -0
  54. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/tests/test_strict_discovery_wiring.py +0 -0
  55. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/tests/test_tenant_middleware.py +0 -0
  56. {simple_module_hosting-0.0.19 → simple_module_hosting-0.0.20}/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.20
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.20
30
+ Requires-Dist: simple-module-db==0.0.20
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.20"
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.20",
30
+ "simple_module_db==0.0.20",
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
+ ]
@@ -2,7 +2,10 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import hashlib
6
+ import json
5
7
  import logging
8
+ import tempfile
6
9
  from pathlib import Path
7
10
 
8
11
  from fastapi import FastAPI
@@ -16,6 +19,59 @@ _INERTIA_VERSION = "1.0"
16
19
  _ROOT_TEMPLATE_FILENAME = "index.html"
17
20
  _ENTRYPOINT_FILENAME = "main.tsx"
18
21
  _ROOT_DIRECTORY = "."
22
+ # Built assets are served from the "/static" mount under "dist/", so production
23
+ # asset URLs are prefixed with "static/dist".
24
+ _ASSETS_PREFIX = "static/dist"
25
+ _VITE_MANIFEST_RELPATH = Path("static") / "dist" / ".vite" / "manifest.json"
26
+
27
+
28
+ def _prod_manifest_path(project_root: Path) -> str:
29
+ """Return a manifest path fastapi-inertia can read in production.
30
+
31
+ fastapi-inertia looks the entry up by ``f"{root_directory}/{entrypoint}"``
32
+ (here ``"./main.tsx"``), but Vite keys its manifest by the entry's path
33
+ relative to the Vite root (``"main.tsx"``) — so the raw Vite manifest would
34
+ ``KeyError``. Read it, re-key the ``isEntry`` chunk under the key
35
+ fastapi-inertia expects, and write the normalized copy beside the build
36
+ output (falling back to a temp file if that dir is read-only). Returns ``""``
37
+ when no built manifest exists, leaving production assets unconfigured rather
38
+ than crashing at import time.
39
+ """
40
+ candidates = [
41
+ project_root / "host" / _VITE_MANIFEST_RELPATH,
42
+ project_root / _VITE_MANIFEST_RELPATH,
43
+ ]
44
+ vite_manifest = next((p for p in candidates if p.is_file()), None)
45
+ if vite_manifest is None:
46
+ logger.warning(
47
+ "Production Vite manifest not found (looked in %s)", [str(c) for c in candidates]
48
+ )
49
+ return ""
50
+ try:
51
+ data = json.loads(vite_manifest.read_text())
52
+ expected_key = f"{_ROOT_DIRECTORY}/{_ENTRYPOINT_FILENAME}"
53
+ if expected_key not in data:
54
+ entry = next((v for v in data.values() if v.get("isEntry")), None)
55
+ if entry is None:
56
+ # No entry to re-key: degrade gracefully (same as no-manifest)
57
+ # rather than returning a path that KeyErrors at render time.
58
+ logger.warning("No isEntry chunk in Vite manifest %s", vite_manifest)
59
+ return ""
60
+ data = {**data, expected_key: entry}
61
+ out = vite_manifest.parent / "inertia-manifest.json"
62
+ try:
63
+ out.write_text(json.dumps(data))
64
+ except OSError:
65
+ # Build dir read-only (e.g. immutable container layer): fall back to
66
+ # a temp file keyed by the source manifest path so multiple apps on
67
+ # one host don't clobber each other's normalized manifests.
68
+ digest = hashlib.sha1(str(vite_manifest).encode()).hexdigest()[:12]
69
+ out = Path(tempfile.gettempdir()) / f"sm-inertia-manifest-{digest}.json"
70
+ out.write_text(json.dumps(data))
71
+ return str(out)
72
+ except Exception:
73
+ logger.exception("Failed to prepare production Inertia manifest from %s", vite_manifest)
74
+ return ""
19
75
 
20
76
 
21
77
  def setup_inertia(
@@ -82,6 +138,10 @@ def setup_inertia(
82
138
  environment=inertia_environment,
83
139
  version=_INERTIA_VERSION,
84
140
  dev_url=settings.vite_dev_url if use_dev_server else "",
141
+ # Production reads built assets from the Vite manifest; dev serves them
142
+ # from the Vite dev server, so these only matter when not use_dev_server.
143
+ manifest_json_path="" if use_dev_server else _prod_manifest_path(project_root),
144
+ assets_prefix="" if use_dev_server else _ASSETS_PREFIX,
85
145
  templates=templates,
86
146
  root_template_filename=_ROOT_TEMPLATE_FILENAME,
87
147
  entrypoint_filename=_ENTRYPOINT_FILENAME,
@@ -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,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"]