simple-module-hosting 0.0.18__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.
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/.gitignore +1 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/PKG-INFO +3 -3
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/pyproject.toml +3 -3
- simple_module_hosting-0.0.20/simple_module_hosting/__init__.py +18 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/_inertia_setup.py +60 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/_inertia_shared.py +28 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/_phase_helpers.py +24 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/app_builder.py +4 -2
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/middleware.py +6 -1
- simple_module_hosting-0.0.20/simple_module_hosting/shared_props.py +42 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/tests/test_app.py +10 -5
- simple_module_hosting-0.0.20/tests/test_inertia_manifest.py +71 -0
- simple_module_hosting-0.0.20/tests/test_inertia_shared_providers.py +91 -0
- simple_module_hosting-0.0.20/tests/test_static_caching.py +37 -0
- simple_module_hosting-0.0.18/simple_module_hosting/__init__.py +0 -7
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/LICENSE +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/README.md +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/__main__.py +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/_error_handlers.py +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/_host_services.py +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/_hydrate_step.py +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/_observability.py +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/bootstrap_settings.py +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/health.py +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/host_cli.py +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/host_settings.py +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/i18n_deps.py +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/i18n_manifest.py +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/i18n_middleware.py +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/inertia_deps.py +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/inertia_utils.py +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/logging.py +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/manifest.py +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/migrations.py +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/permissions.py +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/py.typed +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/redirects.py +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/settings.py +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/tests/test_check_migrations.py +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/tests/test_health.py +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/tests/test_host_cli.py +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/tests/test_hosting_permissions.py +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/tests/test_i18n_manifest.py +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/tests/test_inertia_i18n_shared_props.py +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/tests/test_lifespan_order.py +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/tests/test_locale_middleware.py +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/tests/test_logging.py +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/tests/test_manifest.py +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/tests/test_middleware_order.py +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/tests/test_redirects.py +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/tests/test_session_cookie_security.py +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/tests/test_settings_i18n.py +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/tests/test_settings_secrets.py +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/tests/test_strict_discovery_wiring.py +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/tests/test_tenant_middleware.py +0 -0
- {simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/tests/test_translator_dep.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: simple_module_hosting
|
|
3
|
-
Version: 0.0.
|
|
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.
|
|
30
|
-
Requires-Dist: simple-module-db==0.0.
|
|
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.
|
|
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.
|
|
30
|
-
"simple_module_db==0.0.
|
|
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."""
|
{simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/app_builder.py
RENAMED
|
@@ -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(
|
|
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
|
|
{simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/middleware.py
RENAMED
|
@@ -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
|
|
35
|
+
paths = effective_route_paths(app)
|
|
34
36
|
# Auth is now contracts-only, so it has no routes — only health remains.
|
|
35
|
-
assert "/dashboard"
|
|
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
|
-
|
|
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
|
-
#
|
|
220
|
-
|
|
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"]
|
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/__main__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/_hydrate_step.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/health.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/host_cli.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/host_settings.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/i18n_deps.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/i18n_manifest.py
RENAMED
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/inertia_deps.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/inertia_utils.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/logging.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/manifest.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/migrations.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/permissions.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/py.typed
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/redirects.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/simple_module_hosting/settings.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/tests/test_check_migrations.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/tests/test_hosting_permissions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/tests/test_locale_middleware.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/tests/test_middleware_order.py
RENAMED
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/tests/test_session_cookie_security.py
RENAMED
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/tests/test_settings_secrets.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/tests/test_strict_discovery_wiring.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.18 → simple_module_hosting-0.0.20}/tests/test_tenant_middleware.py
RENAMED
|
File without changes
|
|
File without changes
|