simple-module-hosting 0.0.31__tar.gz → 0.0.33__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 (103) hide show
  1. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/PKG-INFO +3 -3
  2. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/pyproject.toml +3 -3
  3. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/__init__.py +2 -0
  4. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/_error_handlers.py +121 -9
  5. simple_module_hosting-0.0.33/simple_module_hosting/_inertia_cache.py +104 -0
  6. simple_module_hosting-0.0.33/simple_module_hosting/_inertia_json.py +86 -0
  7. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/_inertia_setup.py +5 -1
  8. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/_inertia_shared.py +26 -0
  9. simple_module_hosting-0.0.33/simple_module_hosting/_lifespan.py +130 -0
  10. simple_module_hosting-0.0.33/simple_module_hosting/_module_routes.py +70 -0
  11. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/_phase_helpers.py +40 -36
  12. simple_module_hosting-0.0.33/simple_module_hosting/_preapp_config.py +227 -0
  13. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/_registrations.py +4 -0
  14. simple_module_hosting-0.0.33/simple_module_hosting/_secret_key.py +154 -0
  15. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/app_builder.py +32 -34
  16. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/bootstrap_settings.py +33 -70
  17. simple_module_hosting-0.0.33/simple_module_hosting/host_settings.py +126 -0
  18. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/i18n_manifest.py +18 -0
  19. simple_module_hosting-0.0.33/simple_module_hosting/maintenance.py +125 -0
  20. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/middleware.py +6 -1
  21. simple_module_hosting-0.0.33/simple_module_hosting/migrations.py +217 -0
  22. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/permissions.py +9 -1
  23. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/redirects.py +12 -10
  24. simple_module_hosting-0.0.33/simple_module_hosting/session.py +192 -0
  25. simple_module_hosting-0.0.33/simple_module_hosting/setup_gate.py +187 -0
  26. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_auth_provider_setting.py +25 -0
  27. simple_module_hosting-0.0.33/tests/test_boot_drift_check.py +99 -0
  28. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_check_migrations.py +22 -10
  29. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_csp_module_sources.py +1 -0
  30. simple_module_hosting-0.0.33/tests/test_diagnostics_state.py +112 -0
  31. simple_module_hosting-0.0.33/tests/test_error_page_shared_props.py +183 -0
  32. simple_module_hosting-0.0.33/tests/test_error_status_coverage.py +229 -0
  33. simple_module_hosting-0.0.33/tests/test_host_settings_fields.py +95 -0
  34. simple_module_hosting-0.0.33/tests/test_i18n_types_cover_installed_modules.py +66 -0
  35. simple_module_hosting-0.0.33/tests/test_inertia_cache.py +187 -0
  36. simple_module_hosting-0.0.33/tests/test_inertia_json_encoder.py +83 -0
  37. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_lifespan_order.py +3 -1
  38. simple_module_hosting-0.0.33/tests/test_maintenance_mode.py +270 -0
  39. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_middleware_order.py +30 -0
  40. simple_module_hosting-0.0.33/tests/test_migration_listing.py +106 -0
  41. simple_module_hosting-0.0.33/tests/test_migration_multihead.py +154 -0
  42. simple_module_hosting-0.0.33/tests/test_preapp_config.py +144 -0
  43. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_proxy_headers.py +4 -1
  44. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_redirects.py +29 -0
  45. simple_module_hosting-0.0.33/tests/test_secret_key_bootstrap.py +93 -0
  46. simple_module_hosting-0.0.33/tests/test_session_cookie_window.py +215 -0
  47. simple_module_hosting-0.0.33/tests/test_setup_gate.py +230 -0
  48. simple_module_hosting-0.0.33/tests/test_setup_password_policy.py +69 -0
  49. simple_module_hosting-0.0.33/tests/test_setup_public_routes.py +68 -0
  50. simple_module_hosting-0.0.33/tests/test_setup_registry.py +102 -0
  51. simple_module_hosting-0.0.33/tests/test_setup_routes.py +137 -0
  52. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_strict_discovery_wiring.py +4 -3
  53. simple_module_hosting-0.0.31/simple_module_hosting/host_settings.py +0 -33
  54. simple_module_hosting-0.0.31/simple_module_hosting/migrations.py +0 -65
  55. simple_module_hosting-0.0.31/tests/test_error_page_shared_props.py +0 -80
  56. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/.gitignore +0 -0
  57. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/LICENSE +0 -0
  58. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/README.md +0 -0
  59. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/__main__.py +0 -0
  60. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/_db_health.py +0 -0
  61. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/_host_services.py +0 -0
  62. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/_hydrate_step.py +0 -0
  63. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/_observability.py +0 -0
  64. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/assets.py +0 -0
  65. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/csrf.py +0 -0
  66. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/health.py +0 -0
  67. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/host_cli.py +0 -0
  68. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/i18n_deps.py +0 -0
  69. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/i18n_middleware.py +0 -0
  70. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/inertia_deps.py +0 -0
  71. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/inertia_utils.py +0 -0
  72. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/logging.py +0 -0
  73. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/manifest.py +0 -0
  74. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/py.typed +0 -0
  75. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/settings.py +0 -0
  76. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/shared_props.py +0 -0
  77. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/static_files.py +0 -0
  78. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_api_error_negotiation.py +0 -0
  79. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_app.py +0 -0
  80. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_branding_head.py +0 -0
  81. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_db_health_check.py +0 -0
  82. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_design_packs_wiring.py +0 -0
  83. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_health.py +0 -0
  84. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_host_cli.py +0 -0
  85. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_hosting_csrf.py +0 -0
  86. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_hosting_permissions.py +0 -0
  87. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_i18n_manifest.py +0 -0
  88. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_inertia_i18n_shared_props.py +0 -0
  89. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_inertia_manifest.py +0 -0
  90. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_inertia_shared_providers.py +0 -0
  91. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_locale_middleware.py +0 -0
  92. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_logging.py +0 -0
  93. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_manifest.py +0 -0
  94. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_menu_urls_are_canonical.py +0 -0
  95. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_precompressed_static.py +0 -0
  96. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_response_compression.py +0 -0
  97. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_session_cookie_security.py +0 -0
  98. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_settings_i18n.py +0 -0
  99. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_settings_secrets.py +0 -0
  100. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_sqlite_path_anchor.py +0 -0
  101. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_static_caching.py +0 -0
  102. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_tenant_middleware.py +0 -0
  103. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_translator_dep.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_hosting
3
- Version: 0.0.31
3
+ Version: 0.0.33
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
31
- Requires-Dist: simple-module-db==0.0.31
30
+ Requires-Dist: simple-module-core==0.0.33
31
+ Requires-Dist: simple-module-db==0.0.33
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.31"
3
+ version = "0.0.33"
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.31",
34
- "simple_module_db==0.0.31",
33
+ "simple_module_core==0.0.33",
34
+ "simple_module_db==0.0.33",
35
35
  "starlette>=0.44",
36
36
  "tomlkit>=0.13",
37
37
  "uvicorn[standard]>=0.34",
@@ -1,5 +1,6 @@
1
1
  """SimpleModule Hosting - App builder, module loader, middleware pipeline."""
2
2
 
3
+ from simple_module_hosting._preapp_config import merge_host_settings
3
4
  from simple_module_hosting.app_builder import create_app
4
5
  from simple_module_hosting.logging import correlation_id, setup_logging
5
6
  from simple_module_hosting.settings import Settings
@@ -13,6 +14,7 @@ __all__ = [
13
14
  "SharedPropsProvider",
14
15
  "correlation_id",
15
16
  "create_app",
17
+ "merge_host_settings",
16
18
  "register_inertia_shared_provider",
17
19
  "setup_logging",
18
20
  ]
@@ -3,6 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
+ from collections.abc import Mapping
7
+ from http import HTTPStatus
6
8
 
7
9
  from fastapi.encoders import jsonable_encoder
8
10
  from fastapi.exceptions import RequestValidationError
@@ -19,10 +21,76 @@ from starlette.requests import Request
19
21
  from starlette.responses import Response
20
22
 
21
23
  from simple_module_hosting._inertia_shared import _INERTIA_HEADER
24
+ from simple_module_hosting.permissions import PERMISSION_DENIED_PREFIX
22
25
 
23
26
  logger = logging.getLogger(__name__)
24
27
 
25
- _INERTIA_ERROR_STATUSES = frozenset({403, 404, 500})
28
+ _INERTIA_ERROR_STATUSES = frozenset({401, 403, 404, 419, 422, 429, 500, 503})
29
+
30
+ # Statuses whose remedy is "sign in", so the page offers that as its primary
31
+ # action rather than sending the visitor to the landing page.
32
+ _SIGN_IN_STATUSES = frozenset({401, 419})
33
+
34
+
35
+ def _specific_detail(status_code: int, message: str) -> str:
36
+ """The detail, unless it only restates the status.
37
+
38
+ Starlette defaults ``HTTPException.detail`` to ``HTTPStatus(code).phrase``,
39
+ so a plain ``HTTPException(404)`` reaches the page carrying "Not Found" and
40
+ a bare ``HTTPException(403)`` carrying "Forbidden". The page prefers a
41
+ server message over its own catalog description — right when the message
42
+ says something ("Administrator access required"), useless when it is the
43
+ title again in Title Case, and it made the page's own 404/403 sentences
44
+ unreachable on the very paths that produce them.
45
+
46
+ Only an exact, case-insensitive match is dropped: anything a caller
47
+ actually wrote survives, including a sentence that merely starts with the
48
+ phrase. Non-standard codes (419) have no phrase to collide with.
49
+ """
50
+ try:
51
+ phrase = HTTPStatus(status_code).phrase
52
+ except ValueError:
53
+ return message
54
+ return "" if message.strip().casefold() == phrase.casefold() else message
55
+
56
+
57
+ def _required_permission(message: str) -> str | None:
58
+ """The permission named by a ``RequiresPermission`` denial, if this is one.
59
+
60
+ The 403 page says "Your role doesn't include ``<perm>``. Ask an admin to
61
+ grant it." — it needs the bare permission name, not the sentence the guard
62
+ wrote for logs and API clients. Reading it back off the detail (using the
63
+ guard's own exported prefix) keeps ``RequiresPermission`` as the single
64
+ place that knows the name; the alternative, stashing it on
65
+ ``request.state``, only works when the exception travels the one code path
66
+ that set it, and 403s are also raised by hand.
67
+
68
+ Anything else — a role-gated ``/admin`` 403, a hand-raised
69
+ ``HTTPException(403, "Administrator access required")`` — yields ``None``,
70
+ and the page falls back to copy that names no permission.
71
+ """
72
+ if not message.startswith(PERMISSION_DENIED_PREFIX):
73
+ return None
74
+ return message[len(PERMISSION_DENIED_PREFIX) :].strip() or None
75
+
76
+
77
+ def _login_url(request: Request) -> str | None:
78
+ """Best-effort login URL for the sign-in statuses.
79
+
80
+ Read off ``app.state`` rather than imported: the auth provider is a plugin
81
+ concern and ``SM009`` forbids framework code importing ``modules/*``. An
82
+ app with no auth provider installed simply gets no sign-in button.
83
+ """
84
+ auth_state = getattr(request.app.state, "auth", None)
85
+ provider = getattr(auth_state, "auth_provider", None)
86
+ if provider is None:
87
+ return None
88
+ try:
89
+ return provider.get_login_url(request)
90
+ except Exception:
91
+ # A broken provider must not turn an error page into a second error.
92
+ logger.exception("Auth provider failed to supply a login URL")
93
+ return None
26
94
 
27
95
 
28
96
  def _explicit_accept_q(accept: str, media_type: str) -> float | None:
@@ -85,9 +153,25 @@ def _wants_json(request: Request) -> bool:
85
153
  return is_api or bool(json_q)
86
154
 
87
155
 
88
- async def render_error_page(request: Request, status_code: int, message: str) -> Response:
89
- config: InertiaConfig = request.app.state.sm.inertia_config
156
+ async def render_error_page(
157
+ request: Request,
158
+ status_code: int,
159
+ message: str,
160
+ headers: Mapping[str, str] | None = None,
161
+ ) -> Response:
162
+ """Render the Inertia error page for *status_code*.
163
+
164
+ ``headers`` carries an ``HTTPException``'s own headers through to the
165
+ response. A rendered page is still the same status as the JSON body it
166
+ replaces, so ``WWW-Authenticate`` on a 401 and ``Retry-After`` on a
167
+ 429/503 have to survive the switch — widening the set of statuses that
168
+ render a page must not quietly narrow what those responses carry.
169
+ """
90
170
  try:
171
+ # Inside the try, not above it: this lookup is exactly the kind of
172
+ # thing that is missing when the app is half-built, and an error page
173
+ # that raises while reporting an error leaves the caller with nothing.
174
+ config: InertiaConfig = request.app.state.sm.inertia_config
91
175
  inertia = Inertia(request, config)
92
176
  # This builds its own Inertia instead of going through get_inertia, so
93
177
  # the share step has to be repeated here. Without it the error page
@@ -104,11 +188,23 @@ async def render_error_page(request: Request, status_code: int, message: str) ->
104
188
  "Error",
105
189
  {
106
190
  "status": status_code,
107
- "message": message,
191
+ # Not the raw detail: a default one is just the status phrase,
192
+ # and the page renders any message it is given ahead of its own
193
+ # copy. See `_specific_detail`.
194
+ "message": _specific_detail(status_code, message),
108
195
  "correlation_id": getattr(request.state, "correlation_id", "") or "",
196
+ # Always present, null when no permission is involved: the page
197
+ # branches on it, and an absent prop is indistinguishable there
198
+ # from one that was never wired.
199
+ "required_permission": _required_permission(message),
200
+ "login_url": (_login_url(request) if status_code in _SIGN_IN_STATUSES else None),
201
+ # Set by MaintenanceMiddleware. A planned outage reads very
202
+ # differently from the same status code arriving unbidden.
203
+ "maintenance": bool(getattr(request.state, "maintenance", False)),
109
204
  },
110
205
  )
111
206
  response.status_code = status_code
207
+ _apply_headers(response, headers)
112
208
  return response
113
209
  except InertiaVersionConflictException as exc:
114
210
  return await inertia_version_conflict_exception_handler(request, exc)
@@ -116,20 +212,36 @@ async def render_error_page(request: Request, status_code: int, message: str) ->
116
212
  # Fallback if Inertia rendering itself fails (e.g. missing session)
117
213
  logger.exception("Error page rendering failed, falling back to JSON")
118
214
  return JSONResponse(
119
- status_code=status_code, content={"detail": message or "Internal Server Error"}
215
+ status_code=status_code,
216
+ content={"detail": message or "Internal Server Error"},
217
+ headers=dict(headers) if headers else None,
120
218
  )
121
219
 
122
220
 
221
+ def _apply_headers(response: Response, headers: Mapping[str, str] | None) -> None:
222
+ """Copy exception headers onto an already-built response.
223
+
224
+ Set rather than appended: these are single-valued response headers, and
225
+ a duplicate ``Retry-After`` is worse than none.
226
+ """
227
+ if not headers:
228
+ return
229
+ for key, value in headers.items():
230
+ response.headers[key] = value
231
+
232
+
123
233
  async def http_exception_handler(request: Request, exc: HTTPException) -> Response:
234
+ # Preserve exception headers (WWW-Authenticate, Retry-After, ...) the way
235
+ # FastAPI's stock handler does — on the rendered page as well as the JSON
236
+ # body, since both answer with the same status.
237
+ headers = getattr(exc, "headers", None)
124
238
  if exc.status_code in _INERTIA_ERROR_STATUSES and not _wants_json(request):
125
239
  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.
240
+ return await render_error_page(request, exc.status_code, detail, headers)
129
241
  return JSONResponse(
130
242
  status_code=exc.status_code,
131
243
  content={"detail": exc.detail},
132
- headers=getattr(exc, "headers", None),
244
+ headers=headers,
133
245
  )
134
246
 
135
247
 
@@ -0,0 +1,104 @@
1
+ """Keep the Inertia payload out of caches that answer page requests.
2
+
3
+ Every Inertia route serves one URL as two representations, chosen on the
4
+ request's ``X-Inertia`` header: an HTML document for a full page load, a JSON
5
+ payload for a client-side visit. Nothing in either response says so, which
6
+ leaves a cache free to store one and hand it back for the other. Visit a page
7
+ through the SPA, then open the same URL directly, and the browser can serve the
8
+ stored payload as the document — the visitor gets
9
+ ``{"component":"...","props":{...}}`` where the page should be.
10
+
11
+ That only bites once a route marks itself cacheable, which a public-content
12
+ module reasonably does. The framework is what makes it unsafe:
13
+ :class:`~simple_module_hosting.middleware.InertiaLayoutDataMiddleware` merges
14
+ the signed-in user's ``auth`` block, their permission list and the menus their
15
+ roles resolve to into *every* Inertia payload. A route author choosing
16
+ ``Cache-Control: public`` for their page content has no way to know that, so
17
+ the guarantee belongs here rather than in each module:
18
+
19
+ * **An Inertia payload is never stored.** ``private, no-store``, and the ETag
20
+ is dropped so no cache can revalidate its way back to a copy it should not
21
+ have kept. The cost is per-visit caching on client-side navigation, which was
22
+ never safe to take — those bytes are specific to one user.
23
+ * **Both representations declare ``Vary: X-Inertia``**, so a cache that honours
24
+ Vary keeps them in separate entries instead of inferring it from ``Accept``.
25
+
26
+ ``Vary`` is added to the document only when the response is HTML, so static
27
+ assets and JSON APIs keep the validators and cache entries they had.
28
+
29
+ A module that wants its public page content cached should give the *document*
30
+ its own ``Cache-Control`` and an ETag that identifies the representation; this
31
+ middleware leaves that path alone and only governs the payload.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from starlette.datastructures import MutableHeaders
37
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
38
+
39
+ _SCOPE_HTTP = "http"
40
+ _HEADER_INERTIA = b"x-inertia"
41
+
42
+ #: What an Inertia payload is allowed to say about its own cacheability.
43
+ PAYLOAD_CACHE_CONTROL = "private, no-store"
44
+
45
+ #: The request header that selects the representation, and therefore the one
46
+ #: caches must key on.
47
+ VARY_FIELD = "X-Inertia"
48
+
49
+
50
+ def add_vary(headers: MutableHeaders, field: str = VARY_FIELD) -> None:
51
+ """Add a field to ``Vary``, keeping whatever is already listed."""
52
+ existing = headers.get("vary")
53
+ if not existing:
54
+ headers["vary"] = field
55
+ return
56
+ if any(part.strip().lower() == field.lower() for part in existing.split(",")):
57
+ return
58
+ headers["vary"] = f"{existing}, {field}"
59
+
60
+
61
+ def is_inertia_request(scope: Scope) -> bool:
62
+ """Whether this request asked for the JSON representation.
63
+
64
+ Mirrors ``fastapi-inertia``'s own ``Inertia._is_inertia_request`` exactly —
65
+ presence of the header, any value — rather than requiring it to equal
66
+ ``"true"``. The library renders JSON for *any* ``X-Inertia`` value, so a
67
+ stricter check here would disagree with it: a request the renderer treats
68
+ as Inertia would sail through uncached, undoing the whole fix.
69
+ """
70
+ return any(key == _HEADER_INERTIA for key, _ in scope.get("headers", ()))
71
+
72
+
73
+ def _is_html(headers: MutableHeaders) -> bool:
74
+ return headers.get("content-type", "").split(";", 1)[0].strip() == "text/html"
75
+
76
+
77
+ class InertiaCacheMiddleware:
78
+ """Stop an Inertia payload being cached as though it were the page."""
79
+
80
+ def __init__(self, app: ASGIApp) -> None:
81
+ self.app = app
82
+
83
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
84
+ if scope["type"] != _SCOPE_HTTP:
85
+ await self.app(scope, receive, send)
86
+ return
87
+
88
+ inertia = is_inertia_request(scope)
89
+
90
+ async def send_with_cache_rules(message: Message) -> None:
91
+ if message["type"] == "http.response.start":
92
+ headers = MutableHeaders(scope=message)
93
+ if inertia:
94
+ headers["cache-control"] = PAYLOAD_CACHE_CONTROL
95
+ del headers["etag"]
96
+ add_vary(headers)
97
+ elif _is_html(headers):
98
+ add_vary(headers)
99
+ await send(message)
100
+
101
+ await self.app(scope, receive, send_with_cache_rules)
102
+
103
+
104
+ __all__ = ["PAYLOAD_CACHE_CONTROL", "VARY_FIELD", "InertiaCacheMiddleware", "add_vary"]
@@ -0,0 +1,86 @@
1
+ """Make Inertia's JSON branch encode props the way its HTML branch does.
2
+
3
+ ``fastapi-inertia`` renders the same props two ways and configures only one of
4
+ them::
5
+
6
+ # full page load — honours InertiaConfig.json_encoder
7
+ json_string = json.dumps(page_data, cls=self._config.json_encoder)
8
+
9
+ # client-side visit — Starlette's JSONResponse, so plain json.dumps
10
+ return JSONResponse(content=await self._get_page_data(), ...)
11
+
12
+ ``InertiaJsonEncoder`` exists precisely to run the payload through FastAPI's
13
+ ``jsonable_encoder``, and the second branch never reaches it. The effect is a
14
+ route that works on a reload and 500s on every client-side visit, for any prop
15
+ the stdlib encoder cannot handle — ``Path``, ``Decimal``, ``UUID``, a dataclass,
16
+ an enum.
17
+
18
+ Settings → Modules is where this surfaced: it reflects each installed module's
19
+ pydantic settings back to the admin, and a module whose settings carry a
20
+ ``Path`` field (``PagebuilderSettings.media_root``) puts a ``PosixPath`` in the
21
+ payload::
22
+
23
+ TypeError: Object of type PosixPath is not JSON serializable
24
+
25
+ Fixing it here rather than in each module is deliberate. A module putting a
26
+ rich value in props is not doing anything wrong — the HTML branch has always
27
+ accepted it — so the asymmetry is the defect, and closing it at the point of
28
+ serialisation covers every module and every prop type at once.
29
+
30
+ Applied by wrapping the dependency published on ``app.state.inertia_dependency``,
31
+ which ``inertia_deps.get_inertia`` calls once per request. The replacement binds
32
+ to the instance, so the library's class is untouched and any other construction
33
+ path keeps the stock behaviour.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import logging
39
+ from typing import Any
40
+
41
+ from fastapi.encoders import jsonable_encoder
42
+ from starlette.responses import JSONResponse
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+ #: The headers upstream's ``_render_json`` sets, reproduced so the wrap is a
47
+ #: pure encoder change. ``InertiaCacheMiddleware`` owns the caching rules and
48
+ #: extends ``Vary`` on the way out.
49
+ _JSON_HEADERS = {"X-Inertia": "true", "Vary": "Accept"}
50
+
51
+
52
+ def json_safe_inertia_dependency(inertia_dep: Any) -> Any:
53
+ """Wrap an Inertia dependency so its JSON branch uses ``jsonable_encoder``.
54
+
55
+ The wrapped callable keeps the ``(request, client)`` shape
56
+ ``inertia_dependency_factory`` returns. An instance that doesn't expose the
57
+ private hook this relies on is handed back untouched: the failure mode is
58
+ the 500 that already happens, and refusing to boot because an upstream
59
+ attribute moved would be a worse trade.
60
+ """
61
+
62
+ def dependency(request: Any, client: Any = None) -> Any:
63
+ inertia = inertia_dep(request, client)
64
+ if hasattr(inertia, "_get_page_data"):
65
+ inertia._render_json = _render_json_for(inertia)
66
+ else: # pragma: no cover - upstream layout changed
67
+ logger.warning(
68
+ "Inertia instance has no _get_page_data; JSON responses keep the "
69
+ "stock encoder and non-JSON-native props will fail to serialise"
70
+ )
71
+ return inertia
72
+
73
+ return dependency
74
+
75
+
76
+ def _render_json_for(inertia: Any) -> Any:
77
+ """Build the instance's replacement ``_render_json``."""
78
+
79
+ async def _render_json() -> JSONResponse:
80
+ page_data = await inertia._get_page_data()
81
+ return JSONResponse(content=jsonable_encoder(page_data), headers=_JSON_HEADERS)
82
+
83
+ return _render_json
84
+
85
+
86
+ __all__ = ["json_safe_inertia_dependency"]
@@ -12,6 +12,7 @@ from fastapi import FastAPI
12
12
  from inertia import InertiaConfig, inertia_dependency_factory
13
13
  from starlette.requests import Request
14
14
 
15
+ from simple_module_hosting._inertia_json import json_safe_inertia_dependency
15
16
  from simple_module_hosting.settings import Settings
16
17
 
17
18
  logger = logging.getLogger(__name__)
@@ -184,6 +185,9 @@ def setup_inertia(
184
185
  use_flash_errors=True,
185
186
  )
186
187
 
187
- inertia_dep = inertia_dependency_factory(inertia_config)
188
+ # Upstream's JSON branch builds a Starlette JSONResponse directly, so the
189
+ # encoder configured above only ever applies to full page loads. Wrap the
190
+ # dependency so a client-side visit encodes the same props the same way.
191
+ inertia_dep = json_safe_inertia_dependency(inertia_dependency_factory(inertia_config))
188
192
  app.state.inertia_dependency = inertia_dep
189
193
  return inertia_config
@@ -3,8 +3,10 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
+ from collections.abc import Callable
6
7
  from typing import Any
7
8
 
9
+ from simple_module_core.i18n import Translator
8
10
  from starlette.datastructures import Headers
9
11
  from starlette.requests import Request
10
12
  from starlette.types import Scope
@@ -77,6 +79,30 @@ def build_i18n_block(scope: Scope, request: Request, *, is_authenticated: bool =
77
79
  }
78
80
 
79
81
 
82
+ def build_menu_translator(request: Request) -> Callable[[str], str] | None:
83
+ """Return a locale-bound translator for menu labels, or None if unavailable.
84
+
85
+ Menu labels are translated on the server rather than in the client: the
86
+ payload then carries finished text, so the sidebar, topbar and command
87
+ palette all keep rendering ``item.label`` unchanged. It also sidesteps the
88
+ catalog audience split — an admin-audience module's labels never have to be
89
+ present in the anonymous snapshot to be readable.
90
+
91
+ Mirrors ``build_i18n_block``'s defensive lookups: test fixtures build apps
92
+ with a partial ``app.state.sm``, and a missing registry must degrade to the
93
+ literal labels rather than raise on every request.
94
+ """
95
+ sm = getattr(request.app.state, "sm", None)
96
+ registry = getattr(sm, "i18n_registry", None) if sm is not None else None
97
+ locale = getattr(request.state, "locale", None)
98
+ if registry is None or locale is None:
99
+ return None
100
+ settings = getattr(sm, "settings", None)
101
+ default_locale = getattr(settings, "i18n_default_locale", "en") if settings else "en"
102
+ translator = Translator(registry, locale=locale, default_locale=default_locale)
103
+ return translator.t
104
+
105
+
80
106
  def merge_shared_prop_providers(app: Any, request: Request, shared: dict) -> None:
81
107
  """Merge module-registered Inertia shared-prop providers into ``shared`` in place.
82
108
 
@@ -0,0 +1,130 @@
1
+ """The app's startup and shutdown sequence.
2
+
3
+ Split out of ``app_builder`` so that file stays focused on *wiring* — which
4
+ modules load, what middleware is installed, where routes mount — while this
5
+ one owns *when things happen* at run time.
6
+
7
+ Ordering here is load-bearing. Settings hydrate from the database before any
8
+ module's ``on_startup`` runs, so startup code reads DB-backed values rather
9
+ than pydantic defaults. Shutdown runs in reverse module order, mirroring
10
+ dependency direction, and disposes the engine last so a module's shutdown hook
11
+ can still reach the database.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import AsyncGenerator, Callable, Sequence
17
+ from contextlib import asynccontextmanager
18
+
19
+ from fastapi import FastAPI
20
+
21
+ from simple_module_hosting.migrations import migration_status
22
+ from simple_module_hosting.setup_gate import STEP_MIGRATIONS
23
+
24
+
25
+ async def hydrate_settings_from_db(app: FastAPI) -> None:
26
+ """Merge DB-stored overrides into every registered settings object.
27
+
28
+ Resolved through ``importlib`` rather than a static import because the
29
+ SM009 coupling check is AST-based and forbids ``framework/*`` from naming
30
+ a plugin package. No-ops when the settings module isn't installed.
31
+
32
+ Note this is the *second* settings read of a boot. The first happens in
33
+ ``_preapp_config`` before ``create_app`` builds anything, because the
34
+ module list, i18n registry and middleware stack are all constructed from
35
+ settings and cannot be rebuilt from here.
36
+ """
37
+ if not hasattr(app.state, "settings"):
38
+ return
39
+
40
+ import importlib
41
+
42
+ from simple_module_hosting._hydrate_step import hydrate_all
43
+
44
+ service_cls = importlib.import_module("settings.service").SettingService
45
+ store_cls = importlib.import_module("settings.store").SettingsStore
46
+
47
+ async with app.state.sm.db.session_factory() as session:
48
+ await hydrate_all(app, store_cls(service_cls(session)))
49
+
50
+
51
+ async def _is_first_run(app: FastAPI) -> bool:
52
+ """Whether this install has never been set up at all.
53
+
54
+ The distinction the boot check turns on. Two very different situations both
55
+ leave ``host.migrations`` outstanding:
56
+
57
+ - A **fresh deployment**: nothing is configured, so the administrator step
58
+ is pending too. Serving the wizard is exactly right — running the
59
+ migrations is one of the things it exists to do.
60
+ - A **configured install whose schema drifted**: someone deployed code
61
+ ahead of the migration job. It has administrators; only the schema is
62
+ behind.
63
+
64
+ Treating the second as "setup mode" is wrong twice over. It serves traffic
65
+ against a schema the code does not match — the failure ``SM010`` exists to
66
+ prevent — and it re-opens ``/setup``, whose migration endpoint is
67
+ unauthenticated, letting an anonymous request trigger an Alembic run on a
68
+ live system.
69
+
70
+ So: first run means *something other than the schema* is also outstanding.
71
+ ``False`` when no registry exists, since an app built outside
72
+ ``create_app`` has no steps to reason about and the old raising behaviour
73
+ is the safer default there.
74
+
75
+ Deliberately does not delegate to ``registry.incomplete()``: that method
76
+ treats a step whose predicate raises as *complete*, which is right for
77
+ ``SetupMiddleware``'s steady-state gate (a transient DB error must not
78
+ lock a working install out) but wrong here. On a genuinely fresh
79
+ database the non-migration steps' own tables don't exist yet — the
80
+ administrator check is a query against a table migrations create — so it
81
+ raises too, and swallowing that would make every step but the schema
82
+ look satisfied, exactly the "is setup complete" trap this function
83
+ exists to avoid. Here, a required step raising while the schema is
84
+ behind counts as pending: it is itself evidence the install is new.
85
+ """
86
+ registry = getattr(app.state.sm, "setup_registry", None)
87
+ if not registry:
88
+ return False
89
+ for step in registry.required_steps:
90
+ if step.id == STEP_MIGRATIONS:
91
+ continue
92
+ try:
93
+ done = await step.is_complete(app)
94
+ except Exception:
95
+ return True
96
+ if not done:
97
+ return True
98
+ return False
99
+
100
+
101
+ def build_lifespan(modules: Sequence) -> Callable:
102
+ """Return the ``lifespan`` context manager for an app over *modules*."""
103
+
104
+ @asynccontextmanager
105
+ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
106
+ # Report first, decide second. On a genuinely fresh install a
107
+ # behind-head database must still boot far enough to serve the wizard —
108
+ # running the migrations is one of the things that wizard does, and
109
+ # check_migrations' RuntimeError would make it unreachable. Anywhere
110
+ # else the original behaviour stands: a schema the code does not match
111
+ # fails the boot. See _is_first_run for why those two cases cannot be
112
+ # told apart by "is setup complete".
113
+ app.state.migration = await migration_status(app.state.sm.db.engine)
114
+ if not app.state.migration["is_current"] and not await _is_first_run(app):
115
+ raise RuntimeError(
116
+ f"Database is {app.state.migration['pending_count']} revision(s) behind "
117
+ f"(at {app.state.migration['current_revision']!r}, head is "
118
+ f"{app.state.migration['head_revision']!r}). Run: make migrate"
119
+ )
120
+
121
+ await hydrate_settings_from_db(app)
122
+
123
+ for mod in modules:
124
+ await mod.on_startup(app)
125
+ yield
126
+ for mod in reversed(modules):
127
+ await mod.on_shutdown(app)
128
+ await app.state.sm.db.engine.dispose()
129
+
130
+ return lifespan