simple-module-hosting 0.0.30__tar.gz → 0.0.31__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.30 → simple_module_hosting-0.0.31}/.gitignore +1 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/PKG-INFO +4 -4
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/pyproject.toml +3 -3
- simple_module_hosting-0.0.31/simple_module_hosting/_db_health.py +56 -0
- simple_module_hosting-0.0.31/simple_module_hosting/_error_handlers.py +159 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/_phase_helpers.py +32 -10
- simple_module_hosting-0.0.31/simple_module_hosting/_registrations.py +69 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/app_builder.py +30 -26
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/assets.py +20 -4
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/bootstrap_settings.py +49 -1
- simple_module_hosting-0.0.31/simple_module_hosting/csrf.py +98 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/health.py +5 -1
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/middleware.py +5 -2
- simple_module_hosting-0.0.31/tests/test_api_error_negotiation.py +143 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_auth_provider_setting.py +3 -0
- simple_module_hosting-0.0.31/tests/test_csp_module_sources.py +68 -0
- simple_module_hosting-0.0.31/tests/test_db_health_check.py +40 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_error_page_shared_props.py +13 -0
- simple_module_hosting-0.0.31/tests/test_hosting_csrf.py +98 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_middleware_order.py +9 -1
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_settings_i18n.py +3 -0
- simple_module_hosting-0.0.31/tests/test_sqlite_path_anchor.py +182 -0
- simple_module_hosting-0.0.30/simple_module_hosting/_error_handlers.py +0 -75
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/LICENSE +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/README.md +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/__init__.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/__main__.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/_host_services.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/_hydrate_step.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/_inertia_setup.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/_inertia_shared.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/_observability.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/host_cli.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/host_settings.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/i18n_deps.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/i18n_manifest.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/i18n_middleware.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/inertia_deps.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/inertia_utils.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/logging.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/manifest.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/migrations.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/permissions.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/py.typed +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/redirects.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/settings.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/shared_props.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/static_files.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_app.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_branding_head.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_check_migrations.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_design_packs_wiring.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_health.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_host_cli.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_hosting_permissions.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_i18n_manifest.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_inertia_i18n_shared_props.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_inertia_manifest.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_inertia_shared_providers.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_lifespan_order.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_locale_middleware.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_logging.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_manifest.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_menu_urls_are_canonical.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_precompressed_static.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_proxy_headers.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_redirects.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_response_compression.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_session_cookie_security.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_settings_secrets.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_static_caching.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_strict_discovery_wiring.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_tenant_middleware.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_translator_dep.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: simple_module_hosting
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.31
|
|
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
|
|
@@ -27,8 +27,8 @@ Requires-Dist: fastapi>=0.115
|
|
|
27
27
|
Requires-Dist: httpx>=0.27
|
|
28
28
|
Requires-Dist: itsdangerous>=2.0
|
|
29
29
|
Requires-Dist: jinja2>=3.1
|
|
30
|
-
Requires-Dist: simple-module-core==0.0.
|
|
31
|
-
Requires-Dist: simple-module-db==0.0.
|
|
30
|
+
Requires-Dist: simple-module-core==0.0.31
|
|
31
|
+
Requires-Dist: simple-module-db==0.0.31
|
|
32
32
|
Requires-Dist: starlette>=0.44
|
|
33
33
|
Requires-Dist: tomlkit>=0.13
|
|
34
34
|
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.31"
|
|
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"
|
|
@@ -30,8 +30,8 @@ dependencies = [
|
|
|
30
30
|
# it directly — out-of-tree hosts have nothing else pulling it in.
|
|
31
31
|
"itsdangerous>=2.0",
|
|
32
32
|
"jinja2>=3.1",
|
|
33
|
-
"simple_module_core==0.0.
|
|
34
|
-
"simple_module_db==0.0.
|
|
33
|
+
"simple_module_core==0.0.31",
|
|
34
|
+
"simple_module_db==0.0.31",
|
|
35
35
|
"starlette>=0.44",
|
|
36
36
|
"tomlkit>=0.13",
|
|
37
37
|
"uvicorn[standard]>=0.34",
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""The host's own readiness check: can we reach the database?
|
|
2
|
+
|
|
3
|
+
Registered by ``create_app`` rather than by a module, because it is the one
|
|
4
|
+
dependency no request can do without — unlike a mailer or an object store,
|
|
5
|
+
which a page load never touches.
|
|
6
|
+
|
|
7
|
+
It also gives ``/health/ready`` something to actually report. Modules
|
|
8
|
+
contribute checks for third-party services, and those are ``probe=False``
|
|
9
|
+
(polling an SMTP login every 10s earns a rate-limit), so without this the
|
|
10
|
+
readiness endpoint would answer "healthy" from an empty check set — a green
|
|
11
|
+
light that proves nothing.
|
|
12
|
+
|
|
13
|
+
It is owned by the host, not a module, so it deliberately does not light up any
|
|
14
|
+
tile on the dashboard's per-module health strip — those are keyed by
|
|
15
|
+
``HealthCheck.module``.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from simple_module_core.health import HealthCheck, HealthCheckResult, HealthStatus
|
|
21
|
+
from sqlalchemy import text
|
|
22
|
+
|
|
23
|
+
CHECK_DATABASE = "host.database"
|
|
24
|
+
|
|
25
|
+
_MODULE = "Host"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_database_check(db_state):
|
|
29
|
+
"""Return an async check issuing the cheapest possible round trip."""
|
|
30
|
+
|
|
31
|
+
async def check() -> HealthCheckResult:
|
|
32
|
+
try:
|
|
33
|
+
async with db_state.session_factory() as session:
|
|
34
|
+
await session.execute(text("SELECT 1"))
|
|
35
|
+
except Exception as exc:
|
|
36
|
+
return HealthCheckResult(status=HealthStatus.UNHEALTHY, detail=str(exc))
|
|
37
|
+
return HealthCheckResult(status=HealthStatus.HEALTHY, detail="Database reachable")
|
|
38
|
+
|
|
39
|
+
return check
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def register_database_check(health_registry, db_state) -> None:
|
|
43
|
+
"""Add the database check to *health_registry*.
|
|
44
|
+
|
|
45
|
+
``probe=True``: a ``SELECT 1`` against an already-pooled connection is
|
|
46
|
+
cheap enough to run on a probe timer, and it is exactly what a readiness
|
|
47
|
+
probe should be asking.
|
|
48
|
+
"""
|
|
49
|
+
health_registry.add(
|
|
50
|
+
HealthCheck(
|
|
51
|
+
name=CHECK_DATABASE,
|
|
52
|
+
check=build_database_check(db_state),
|
|
53
|
+
module=_MODULE,
|
|
54
|
+
probe=True,
|
|
55
|
+
)
|
|
56
|
+
)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Framework-wide exception handlers that render Inertia error pages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from fastapi.encoders import jsonable_encoder
|
|
8
|
+
from fastapi.exceptions import RequestValidationError
|
|
9
|
+
from fastapi.responses import JSONResponse
|
|
10
|
+
from inertia import (
|
|
11
|
+
Inertia,
|
|
12
|
+
InertiaConfig,
|
|
13
|
+
InertiaVersionConflictException,
|
|
14
|
+
inertia_version_conflict_exception_handler,
|
|
15
|
+
)
|
|
16
|
+
from simple_module_core.exceptions import NotFoundError
|
|
17
|
+
from starlette.exceptions import HTTPException
|
|
18
|
+
from starlette.requests import Request
|
|
19
|
+
from starlette.responses import Response
|
|
20
|
+
|
|
21
|
+
from simple_module_hosting._inertia_shared import _INERTIA_HEADER
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
_INERTIA_ERROR_STATUSES = frozenset({403, 404, 500})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _explicit_accept_q(accept: str, media_type: str) -> float | None:
|
|
29
|
+
"""Quality the Accept header gives ``media_type`` by explicit listing, or
|
|
30
|
+
``None`` when the caller never named it at all.
|
|
31
|
+
|
|
32
|
+
Only exact entries count (no ``*/*`` / ``type/*`` wildcards) — the
|
|
33
|
+
negotiation below cares whether the caller *named* html or json, and at
|
|
34
|
+
what preference, and separately whether they named it *at all* (``None``
|
|
35
|
+
vs. an explicit ``q=0``). Malformed q-values fall back to 1.0 per RFC 9110.
|
|
36
|
+
"""
|
|
37
|
+
for part in accept.split(","):
|
|
38
|
+
media, _, params = part.partition(";")
|
|
39
|
+
if media.strip().lower() != media_type:
|
|
40
|
+
continue
|
|
41
|
+
q = 1.0
|
|
42
|
+
for param in params.split(";"):
|
|
43
|
+
name, _, value = param.partition("=")
|
|
44
|
+
if name.strip().lower() == "q":
|
|
45
|
+
try:
|
|
46
|
+
q = float(value.strip())
|
|
47
|
+
except ValueError:
|
|
48
|
+
q = 1.0
|
|
49
|
+
return q
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _wants_json(request: Request) -> bool:
|
|
54
|
+
"""API callers get JSON error bodies; browser-shaped requests get the page.
|
|
55
|
+
|
|
56
|
+
A request that explicitly accepts ``text/html`` is a browser navigation —
|
|
57
|
+
and those reach ``/api/*`` too (OAuth login links, file-download hrefs) —
|
|
58
|
+
so it always gets the rendered page, unless the caller *prefers* JSON via
|
|
59
|
+
q-values (``application/json, text/html;q=0.5`` is an API client keeping
|
|
60
|
+
an html fallback). So does an Inertia visit (``X-Inertia`` header),
|
|
61
|
+
whatever its Accept — an Inertia client can only consume Inertia-protocol
|
|
62
|
+
responses, never a bare JSON body. Otherwise ``/api/*``, the documented
|
|
63
|
+
prefix for every module's JSON surface (``ModuleMeta.route_prefix``),
|
|
64
|
+
gets JSON — a bare ``fetch()`` sends ``Accept: */*`` — as does an
|
|
65
|
+
explicit ``Accept: application/json`` anywhere else.
|
|
66
|
+
|
|
67
|
+
``text/html;q=0`` rules html out *everywhere*, not just under ``/api/*``:
|
|
68
|
+
once the caller has explicitly said they won't accept html, this never
|
|
69
|
+
chooses the html page. The (already-moot) html fallback survives only
|
|
70
|
+
when json wasn't mentioned either — there's nothing else to offer, so a
|
|
71
|
+
view path keeps its pre-existing default and an ``/api/*`` path keeps
|
|
72
|
+
getting JSON either way.
|
|
73
|
+
"""
|
|
74
|
+
if request.headers.get(_INERTIA_HEADER):
|
|
75
|
+
return False
|
|
76
|
+
accept = request.headers.get("accept", "")
|
|
77
|
+
html_q = _explicit_accept_q(accept, "text/html")
|
|
78
|
+
json_q = _explicit_accept_q(accept, "application/json")
|
|
79
|
+
if html_q is not None and html_q > 0 and html_q >= (json_q or 0.0):
|
|
80
|
+
return False
|
|
81
|
+
path = request.url.path
|
|
82
|
+
is_api = path == "/api" or path.startswith("/api/")
|
|
83
|
+
if html_q == 0:
|
|
84
|
+
return is_api if json_q is None else True
|
|
85
|
+
return is_api or bool(json_q)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def render_error_page(request: Request, status_code: int, message: str) -> Response:
|
|
89
|
+
config: InertiaConfig = request.app.state.sm.inertia_config
|
|
90
|
+
try:
|
|
91
|
+
inertia = Inertia(request, config)
|
|
92
|
+
# This builds its own Inertia instead of going through get_inertia, so
|
|
93
|
+
# the share step has to be repeated here. Without it the error page
|
|
94
|
+
# renders raw translation keys (host.error.not_found_title) and loses
|
|
95
|
+
# auth/menus, so a signed-in user's 404 has no layout.
|
|
96
|
+
shared = getattr(request.state, "inertia_shared", None)
|
|
97
|
+
if shared:
|
|
98
|
+
inertia.share(**shared)
|
|
99
|
+
# The correlation id is the only handle a user has on their own failed
|
|
100
|
+
# request — without it a support report is just "it broke". It is already
|
|
101
|
+
# on every log line for this request, so quoting it back makes the page
|
|
102
|
+
# and the logs joinable.
|
|
103
|
+
response = await inertia.render(
|
|
104
|
+
"Error",
|
|
105
|
+
{
|
|
106
|
+
"status": status_code,
|
|
107
|
+
"message": message,
|
|
108
|
+
"correlation_id": getattr(request.state, "correlation_id", "") or "",
|
|
109
|
+
},
|
|
110
|
+
)
|
|
111
|
+
response.status_code = status_code
|
|
112
|
+
return response
|
|
113
|
+
except InertiaVersionConflictException as exc:
|
|
114
|
+
return await inertia_version_conflict_exception_handler(request, exc)
|
|
115
|
+
except Exception:
|
|
116
|
+
# Fallback if Inertia rendering itself fails (e.g. missing session)
|
|
117
|
+
logger.exception("Error page rendering failed, falling back to JSON")
|
|
118
|
+
return JSONResponse(
|
|
119
|
+
status_code=status_code, content={"detail": message or "Internal Server Error"}
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def http_exception_handler(request: Request, exc: HTTPException) -> Response:
|
|
124
|
+
if exc.status_code in _INERTIA_ERROR_STATUSES and not _wants_json(request):
|
|
125
|
+
detail = str(exc.detail) if exc.detail else ""
|
|
126
|
+
return await render_error_page(request, exc.status_code, detail)
|
|
127
|
+
# Preserve exception headers (WWW-Authenticate, Retry-After, ...) the way
|
|
128
|
+
# FastAPI's stock handler does.
|
|
129
|
+
return JSONResponse(
|
|
130
|
+
status_code=exc.status_code,
|
|
131
|
+
content={"detail": exc.detail},
|
|
132
|
+
headers=getattr(exc, "headers", None),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
async def not_found_error_handler(request: Request, exc: NotFoundError) -> Response:
|
|
137
|
+
if _wants_json(request):
|
|
138
|
+
return JSONResponse(status_code=404, content={"detail": str(exc)})
|
|
139
|
+
return await render_error_page(request, 404, str(exc))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def request_validation_error_handler(
|
|
143
|
+
request: Request, exc: RequestValidationError
|
|
144
|
+
) -> Response:
|
|
145
|
+
"""Return an Inertia error page for browser requests with invalid params.
|
|
146
|
+
|
|
147
|
+
Same negotiation rule as 403/404/500 (``_wants_json``), so one request
|
|
148
|
+
never sees two different error shapes depending on the error class.
|
|
149
|
+
"""
|
|
150
|
+
if _wants_json(request):
|
|
151
|
+
return JSONResponse(status_code=422, content={"detail": jsonable_encoder(exc.errors())})
|
|
152
|
+
return await render_error_page(request, 422, "The requested URL contains invalid parameters.")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
async def unhandled_exception_handler(request: Request, exc: Exception) -> Response:
|
|
156
|
+
logger.exception("Unhandled exception: %s", exc)
|
|
157
|
+
if _wants_json(request):
|
|
158
|
+
return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
|
|
159
|
+
return await render_error_page(request, 500, "")
|
|
@@ -21,6 +21,7 @@ from inertia import (
|
|
|
21
21
|
)
|
|
22
22
|
from simple_module_core.diagnostics import Diagnostic, DiagnosticLevel
|
|
23
23
|
from simple_module_core.exceptions import NotFoundError
|
|
24
|
+
from simple_module_db import CommitBeforeResponseMiddleware
|
|
24
25
|
from starlette.exceptions import HTTPException
|
|
25
26
|
from starlette.middleware.gzip import GZipMiddleware
|
|
26
27
|
from starlette.middleware.sessions import SessionMiddleware
|
|
@@ -46,6 +47,7 @@ from simple_module_hosting.settings import Settings
|
|
|
46
47
|
from simple_module_hosting.static_files import PrecompressedStaticFiles
|
|
47
48
|
|
|
48
49
|
if TYPE_CHECKING:
|
|
50
|
+
from simple_module_core.csp import CspSourceRegistry
|
|
49
51
|
from simple_module_core.menu import MenuRegistry
|
|
50
52
|
from simple_module_core.permissions import PermissionRegistry
|
|
51
53
|
|
|
@@ -72,19 +74,41 @@ def register_exception_handlers(app: FastAPI, modules: list) -> None:
|
|
|
72
74
|
mod.register_exception_handlers(app)
|
|
73
75
|
|
|
74
76
|
|
|
77
|
+
def build_csp(settings: Settings, csp_registry: CspSourceRegistry) -> str:
|
|
78
|
+
"""Choose the CSP for this boot and fold in module-declared sources.
|
|
79
|
+
|
|
80
|
+
Development gets the Vite-widened policy; production the strict default.
|
|
81
|
+
``csp_registry`` carries origins modules declared via
|
|
82
|
+
``register_csp_sources`` (e.g. an external font host) — merged here so
|
|
83
|
+
both variants honor them. An empty registry leaves the policy unchanged.
|
|
84
|
+
"""
|
|
85
|
+
base = (
|
|
86
|
+
SecurityHeadersMiddleware.dev_csp(settings.vite_dev_url)
|
|
87
|
+
if settings.is_development
|
|
88
|
+
else SecurityHeadersMiddleware.DEFAULT_CSP
|
|
89
|
+
)
|
|
90
|
+
return csp_registry.extend_policy(base)
|
|
91
|
+
|
|
92
|
+
|
|
75
93
|
def install_middleware(
|
|
76
94
|
app: FastAPI,
|
|
77
95
|
settings: Settings,
|
|
78
96
|
modules: list,
|
|
79
97
|
menu_registry: MenuRegistry,
|
|
80
98
|
perm_registry: PermissionRegistry,
|
|
99
|
+
csp_registry: CspSourceRegistry,
|
|
81
100
|
) -> None:
|
|
82
101
|
"""Install the full middleware pipeline.
|
|
83
102
|
|
|
84
103
|
Order matters: last added = first executed. Execution order:
|
|
85
104
|
(ProxyHeaders, if trusted_proxy) → CorrelationId → RequestLogging
|
|
86
|
-
→ Security → Session → [module] → (Tenant, if multi_tenant) → Locale
|
|
105
|
+
→ Security → Session → [module] → (Tenant, if multi_tenant) → Locale
|
|
106
|
+
→ Inertia → CommitBeforeResponse.
|
|
87
107
|
"""
|
|
108
|
+
# Added first, so it is innermost and its send-wrapper is the first to see
|
|
109
|
+
# the response: the request's DB work commits before any byte reaches the
|
|
110
|
+
# client, instead of in get_db's post-response exit code (GH #257).
|
|
111
|
+
app.add_middleware(CommitBeforeResponseMiddleware)
|
|
88
112
|
app.add_middleware(
|
|
89
113
|
InertiaLayoutDataMiddleware,
|
|
90
114
|
menu_registry=menu_registry,
|
|
@@ -102,16 +126,14 @@ def install_middleware(
|
|
|
102
126
|
mod.register_middleware(app)
|
|
103
127
|
app.add_middleware(SessionMiddleware, secret_key=settings.secret_key)
|
|
104
128
|
# In dev, relax CSP so the browser can fetch @vite/client, main.tsx, and
|
|
105
|
-
# the HMR WebSocket from the Vite origin
|
|
106
|
-
# dev runs over plain HTTP on loopback.
|
|
129
|
+
# the HMR WebSocket from the Vite origin (build_csp picks the variant).
|
|
130
|
+
# HSTS is suppressed in dev because it runs over plain HTTP on loopback.
|
|
131
|
+
security_kwargs: dict[str, str | None] = {
|
|
132
|
+
"content_security_policy": build_csp(settings, csp_registry)
|
|
133
|
+
}
|
|
107
134
|
if settings.is_development:
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
content_security_policy=SecurityHeadersMiddleware.dev_csp(settings.vite_dev_url),
|
|
111
|
-
strict_transport_security=None,
|
|
112
|
-
)
|
|
113
|
-
else:
|
|
114
|
-
app.add_middleware(SecurityHeadersMiddleware)
|
|
135
|
+
security_kwargs["strict_transport_security"] = None
|
|
136
|
+
app.add_middleware(SecurityHeadersMiddleware, **security_kwargs)
|
|
115
137
|
# Compress response bodies. Added here so it sits inside CorrelationId and
|
|
116
138
|
# RequestLogging (which set headers and read request state) but outside
|
|
117
139
|
# everything that produces a body — including the /static mount, where it
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Phase 5 of boot — every module's declarative registration hooks.
|
|
2
|
+
|
|
3
|
+
Extracted from ``app_builder.py`` to keep that file readable; this is the one
|
|
4
|
+
place that knows the full set of hooks a module may implement, and the order
|
|
5
|
+
they run in. ``app_builder.create_app`` is the only intended caller.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import inspect
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from fastapi import FastAPI
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run_module_registrations(
|
|
18
|
+
modules: list,
|
|
19
|
+
*,
|
|
20
|
+
app: FastAPI,
|
|
21
|
+
event_bus,
|
|
22
|
+
menu_registry,
|
|
23
|
+
perm_registry,
|
|
24
|
+
ff_registry,
|
|
25
|
+
health_registry,
|
|
26
|
+
public_route_registry,
|
|
27
|
+
design_pack_registry,
|
|
28
|
+
audit_link_registry,
|
|
29
|
+
csp_registry,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Invoke each module's registration hooks, in dependency order.
|
|
32
|
+
|
|
33
|
+
Health checks are attributed to the module that registers them, so the
|
|
34
|
+
dashboard can report health per module rather than one global number. The
|
|
35
|
+
owner is cleared afterwards: anything registered later — a module's
|
|
36
|
+
``on_startup``, say — belongs to no module in this loop, and inheriting
|
|
37
|
+
the last one's name would be a lie.
|
|
38
|
+
"""
|
|
39
|
+
for mod in modules:
|
|
40
|
+
mod.register_menu_items(menu_registry)
|
|
41
|
+
mod.register_permissions(perm_registry)
|
|
42
|
+
mod.register_feature_flags(ff_registry)
|
|
43
|
+
dispatch_event_handlers(mod, event_bus, app)
|
|
44
|
+
health_registry.set_owner(mod.meta.name)
|
|
45
|
+
mod.register_health_checks(health_registry)
|
|
46
|
+
mod.register_public_routes(public_route_registry)
|
|
47
|
+
# csp before design packs — the documented lifecycle order
|
|
48
|
+
# (docs/framework/lifecycle.md).
|
|
49
|
+
mod.register_csp_sources(csp_registry)
|
|
50
|
+
mod.register_design_packs(design_pack_registry)
|
|
51
|
+
mod.register_audit_links(audit_link_registry)
|
|
52
|
+
|
|
53
|
+
health_registry.set_owner("")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def dispatch_event_handlers(mod, event_bus, app: FastAPI) -> None:
|
|
57
|
+
"""Call ``mod.register_event_handlers`` with or without ``app``.
|
|
58
|
+
|
|
59
|
+
Back-compat shim for modules that still override the one-arg form
|
|
60
|
+
``(self, bus)``; passing ``app=`` to those crashes.
|
|
61
|
+
"""
|
|
62
|
+
sig = inspect.signature(mod.register_event_handlers)
|
|
63
|
+
accepts_app = "app" in sig.parameters or any(
|
|
64
|
+
p.kind is inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
|
|
65
|
+
)
|
|
66
|
+
if accepts_app:
|
|
67
|
+
mod.register_event_handlers(event_bus, app=app)
|
|
68
|
+
else:
|
|
69
|
+
mod.register_event_handlers(event_bus)
|
{simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/app_builder.py
RENAMED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import inspect
|
|
6
5
|
import logging
|
|
7
6
|
import os
|
|
8
7
|
from collections.abc import AsyncGenerator
|
|
@@ -10,6 +9,8 @@ from contextlib import asynccontextmanager
|
|
|
10
9
|
from pathlib import Path
|
|
11
10
|
|
|
12
11
|
from fastapi import FastAPI
|
|
12
|
+
from simple_module_core import CspSourceRegistry
|
|
13
|
+
from simple_module_core.audit_links import AuditLinkRegistry
|
|
13
14
|
from simple_module_core.design_packs import DesignPackRegistry
|
|
14
15
|
from simple_module_core.diagnostics import DiagnosticLevel, print_diagnostics, run_diagnostics
|
|
15
16
|
from simple_module_core.discovery import discover_modules, select_auth_provider, topological_sort
|
|
@@ -23,6 +24,7 @@ from simple_module_core.services import Services
|
|
|
23
24
|
from simple_module_db.listeners import register_listeners
|
|
24
25
|
from simple_module_db.session import init_db
|
|
25
26
|
|
|
27
|
+
from simple_module_hosting._db_health import register_database_check
|
|
26
28
|
from simple_module_hosting._inertia_setup import setup_inertia
|
|
27
29
|
from simple_module_hosting._phase_helpers import (
|
|
28
30
|
attach_public_routes,
|
|
@@ -33,6 +35,7 @@ from simple_module_hosting._phase_helpers import (
|
|
|
33
35
|
register_host_settings,
|
|
34
36
|
wire_module_routes,
|
|
35
37
|
)
|
|
38
|
+
from simple_module_hosting._registrations import run_module_registrations
|
|
36
39
|
from simple_module_hosting.health import router as health_router
|
|
37
40
|
from simple_module_hosting.i18n_manifest import build_i18n_registry, emit_frontend_types
|
|
38
41
|
from simple_module_hosting.migrations import check_migrations
|
|
@@ -66,6 +69,10 @@ def _resolve_project_root() -> Path:
|
|
|
66
69
|
Falls back to ``parents[3]`` for the in-tree dev loop only when the walk
|
|
67
70
|
finds nothing — which still keeps ``framework/`` users working without
|
|
68
71
|
setting the env var explicitly.
|
|
72
|
+
|
|
73
|
+
Compare ``simple_module_core.dotenv.find_env_file``: both honor
|
|
74
|
+
``SM_PROJECT_ROOT`` first, but this anchors the static/i18n root while
|
|
75
|
+
that anchors which ``.env`` loads — different sentinels, kept separate.
|
|
69
76
|
"""
|
|
70
77
|
override = os.environ.get(_ENV_PROJECT_ROOT)
|
|
71
78
|
if override:
|
|
@@ -80,22 +87,6 @@ def _resolve_project_root() -> Path:
|
|
|
80
87
|
_PROJECT_ROOT = _resolve_project_root()
|
|
81
88
|
|
|
82
89
|
|
|
83
|
-
def _register_event_handlers(mod, event_bus: EventBus, app: FastAPI) -> None:
|
|
84
|
-
"""Dispatch to ``mod.register_event_handlers`` with or without ``app``.
|
|
85
|
-
|
|
86
|
-
Back-compat shim for modules that still override the one-arg form
|
|
87
|
-
``(self, bus)``; passing ``app=`` to those crashes.
|
|
88
|
-
"""
|
|
89
|
-
sig = inspect.signature(mod.register_event_handlers)
|
|
90
|
-
accepts_app = "app" in sig.parameters or any(
|
|
91
|
-
p.kind is inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
|
|
92
|
-
)
|
|
93
|
-
if accepts_app:
|
|
94
|
-
mod.register_event_handlers(event_bus, app=app)
|
|
95
|
-
else:
|
|
96
|
-
mod.register_event_handlers(event_bus)
|
|
97
|
-
|
|
98
|
-
|
|
99
90
|
def create_app(settings: Settings | None = None) -> FastAPI:
|
|
100
91
|
"""Build and configure the full FastAPI application.
|
|
101
92
|
|
|
@@ -173,6 +164,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|
|
173
164
|
health_registry = HealthRegistry()
|
|
174
165
|
public_route_registry = PublicRouteRegistry()
|
|
175
166
|
design_pack_registry = DesignPackRegistry()
|
|
167
|
+
audit_link_registry = AuditLinkRegistry()
|
|
176
168
|
|
|
177
169
|
@asynccontextmanager
|
|
178
170
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|
@@ -219,14 +211,20 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|
|
219
211
|
print_diagnostics(settings_diagnostics)
|
|
220
212
|
|
|
221
213
|
# ── Phase 5: Module registrations ──────────────────────
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
214
|
+
csp_registry = CspSourceRegistry()
|
|
215
|
+
run_module_registrations(
|
|
216
|
+
modules,
|
|
217
|
+
app=app,
|
|
218
|
+
event_bus=event_bus,
|
|
219
|
+
menu_registry=menu_registry,
|
|
220
|
+
perm_registry=perm_registry,
|
|
221
|
+
ff_registry=ff_registry,
|
|
222
|
+
health_registry=health_registry,
|
|
223
|
+
public_route_registry=public_route_registry,
|
|
224
|
+
design_pack_registry=design_pack_registry,
|
|
225
|
+
audit_link_registry=audit_link_registry,
|
|
226
|
+
csp_registry=csp_registry,
|
|
227
|
+
)
|
|
230
228
|
|
|
231
229
|
attach_public_routes(app, settings, public_route_registry)
|
|
232
230
|
|
|
@@ -255,6 +253,9 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|
|
255
253
|
pool_recycle=settings.db_pool_recycle,
|
|
256
254
|
)
|
|
257
255
|
register_listeners(db_state)
|
|
256
|
+
# The host's own readiness signal, and the only probe-safe check in a
|
|
257
|
+
# default install — module checks reach third parties and are on-demand.
|
|
258
|
+
register_database_check(health_registry, db_state)
|
|
258
259
|
|
|
259
260
|
# ── Phase 7: Inertia + exception handlers ──────────────
|
|
260
261
|
inertia_config = setup_inertia(app, settings, modules, _PROJECT_ROOT)
|
|
@@ -263,7 +264,9 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|
|
263
264
|
register_exception_handlers(app, modules)
|
|
264
265
|
|
|
265
266
|
# ── Phase 8: Middleware pipeline ───────────────────────
|
|
266
|
-
install_middleware(
|
|
267
|
+
install_middleware(
|
|
268
|
+
app, settings, modules, menu_registry, perm_registry, csp_registry=csp_registry
|
|
269
|
+
)
|
|
267
270
|
|
|
268
271
|
# ── Phase 9: Routes, health, static files ──────────────
|
|
269
272
|
for mod in modules:
|
|
@@ -288,6 +291,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|
|
288
291
|
health_registry=health_registry,
|
|
289
292
|
public_routes=public_route_registry,
|
|
290
293
|
design_packs=design_pack_registry,
|
|
294
|
+
audit_links=audit_link_registry,
|
|
291
295
|
i18n_registry=i18n_registry,
|
|
292
296
|
inertia_config=inertia_config,
|
|
293
297
|
modules=tuple(modules),
|
{simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/assets.py
RENAMED
|
@@ -47,6 +47,8 @@ logger = logging.getLogger(__name__)
|
|
|
47
47
|
THEME_CSS = "theme.css"
|
|
48
48
|
STYLES_CSS = "styles.css"
|
|
49
49
|
PACKAGE_JSON = "package.json"
|
|
50
|
+
PAGES_DIR = "pages"
|
|
51
|
+
COMPONENTS_DIR = "components"
|
|
50
52
|
|
|
51
53
|
|
|
52
54
|
@dataclass(frozen=True)
|
|
@@ -60,6 +62,7 @@ class ModuleAssets:
|
|
|
60
62
|
theme_css: Path | None
|
|
61
63
|
styles_css: Path | None
|
|
62
64
|
npm_name: str | None = None
|
|
65
|
+
components_dir: Path | None = None
|
|
63
66
|
|
|
64
67
|
|
|
65
68
|
def find_npm_name(pkg_root: Path) -> str | None:
|
|
@@ -114,7 +117,8 @@ def compute_module_assets(modules: Sequence[ModuleBase]) -> list[ModuleAssets]:
|
|
|
114
117
|
"Module '%s': package %s not importable — skipping", mod.meta.name, pkg_name
|
|
115
118
|
)
|
|
116
119
|
continue
|
|
117
|
-
pages_dir = pkg_root /
|
|
120
|
+
pages_dir = pkg_root / PAGES_DIR
|
|
121
|
+
components_dir = pkg_root / COMPONENTS_DIR
|
|
118
122
|
theme = pkg_root / THEME_CSS
|
|
119
123
|
styles = pkg_root / STYLES_CSS
|
|
120
124
|
entry = ModuleAssets(
|
|
@@ -125,8 +129,9 @@ def compute_module_assets(modules: Sequence[ModuleBase]) -> list[ModuleAssets]:
|
|
|
125
129
|
theme_css=theme.resolve() if theme.is_file() else None,
|
|
126
130
|
styles_css=styles.resolve() if styles.is_file() else None,
|
|
127
131
|
npm_name=find_npm_name(pkg_root),
|
|
132
|
+
components_dir=components_dir.resolve() if components_dir.is_dir() else None,
|
|
128
133
|
)
|
|
129
|
-
if entry.pages_dir or entry.theme_css or entry.styles_css:
|
|
134
|
+
if entry.pages_dir or entry.theme_css or entry.styles_css or entry.components_dir:
|
|
130
135
|
result.append(entry)
|
|
131
136
|
return result
|
|
132
137
|
|
|
@@ -157,13 +162,23 @@ def render_modules_css(
|
|
|
157
162
|
``@import`` is emitted for *every* module, in-repo and wheel alike, because
|
|
158
163
|
there is no static-glob equivalent for CSS.
|
|
159
164
|
|
|
165
|
+
Both ``pages/`` and ``components/`` are scanned. A wheel module's widgets
|
|
166
|
+
live under ``components/``, and leaving them out meant every host had to
|
|
167
|
+
hand-write a ``@source`` pointing into ``.venv`` — which cannot be written
|
|
168
|
+
portably: POSIX venvs nest under ``lib/python3.x/site-packages`` while
|
|
169
|
+
Windows uses ``Lib/site-packages``. Tailwind accepts a glob that matches
|
|
170
|
+
nothing without complaint, so the hand-written POSIX path silently dropped
|
|
171
|
+
every widget class on Windows. Emitting the resolved absolute path here
|
|
172
|
+
works on both. See GH #258.
|
|
173
|
+
|
|
160
174
|
Every path is absolute, so nothing here depends on the host's
|
|
161
175
|
``vite.config.ts`` — see the module docstring for why that matters.
|
|
162
176
|
"""
|
|
163
177
|
source_lines = [
|
|
164
|
-
f'@source "{
|
|
178
|
+
f'@source "{d.as_posix()}/**/*.{{ts,tsx}}";'
|
|
165
179
|
for e in assets
|
|
166
|
-
|
|
180
|
+
for d in (e.pages_dir, e.components_dir)
|
|
181
|
+
if d and not in_repo(d)
|
|
167
182
|
]
|
|
168
183
|
theme_lines = [f'@import "{e.theme_css.as_posix()}";' for e in assets if e.theme_css]
|
|
169
184
|
style_lines = [
|
|
@@ -205,6 +220,7 @@ def render_assets_json(assets: Sequence[ModuleAssets]) -> str:
|
|
|
205
220
|
"package_name": e.package_name,
|
|
206
221
|
"package": e.package_dir.as_posix(),
|
|
207
222
|
"pages": e.pages_dir.as_posix() if e.pages_dir else None,
|
|
223
|
+
"components": e.components_dir.as_posix() if e.components_dir else None,
|
|
208
224
|
"theme": e.theme_css.as_posix() if e.theme_css else None,
|
|
209
225
|
"styles": e.styles_css.as_posix() if e.styles_css else None,
|
|
210
226
|
"npm_name": e.npm_name,
|