simple-module-hosting 0.0.32__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.32 → simple_module_hosting-0.0.33}/PKG-INFO +3 -3
  2. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/pyproject.toml +3 -3
  3. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/simple_module_hosting/__init__.py +2 -0
  4. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/simple_module_hosting/_error_handlers.py +121 -9
  5. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/simple_module_hosting/_inertia_shared.py +26 -0
  6. simple_module_hosting-0.0.33/simple_module_hosting/_lifespan.py +130 -0
  7. simple_module_hosting-0.0.33/simple_module_hosting/_module_routes.py +70 -0
  8. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/simple_module_hosting/_phase_helpers.py +34 -36
  9. simple_module_hosting-0.0.33/simple_module_hosting/_preapp_config.py +227 -0
  10. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/simple_module_hosting/_registrations.py +4 -0
  11. simple_module_hosting-0.0.33/simple_module_hosting/_secret_key.py +154 -0
  12. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/simple_module_hosting/app_builder.py +32 -34
  13. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/simple_module_hosting/bootstrap_settings.py +33 -70
  14. simple_module_hosting-0.0.33/simple_module_hosting/host_settings.py +126 -0
  15. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/simple_module_hosting/i18n_manifest.py +18 -0
  16. simple_module_hosting-0.0.33/simple_module_hosting/maintenance.py +125 -0
  17. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/simple_module_hosting/middleware.py +6 -1
  18. simple_module_hosting-0.0.33/simple_module_hosting/migrations.py +217 -0
  19. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/simple_module_hosting/permissions.py +9 -1
  20. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/simple_module_hosting/redirects.py +12 -10
  21. simple_module_hosting-0.0.33/simple_module_hosting/session.py +192 -0
  22. simple_module_hosting-0.0.33/simple_module_hosting/setup_gate.py +187 -0
  23. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/tests/test_auth_provider_setting.py +25 -0
  24. simple_module_hosting-0.0.33/tests/test_boot_drift_check.py +99 -0
  25. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/tests/test_check_migrations.py +22 -10
  26. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/tests/test_csp_module_sources.py +1 -0
  27. simple_module_hosting-0.0.33/tests/test_diagnostics_state.py +112 -0
  28. simple_module_hosting-0.0.33/tests/test_error_page_shared_props.py +183 -0
  29. simple_module_hosting-0.0.33/tests/test_error_status_coverage.py +229 -0
  30. simple_module_hosting-0.0.33/tests/test_host_settings_fields.py +95 -0
  31. simple_module_hosting-0.0.33/tests/test_i18n_types_cover_installed_modules.py +66 -0
  32. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/tests/test_lifespan_order.py +3 -1
  33. simple_module_hosting-0.0.33/tests/test_maintenance_mode.py +270 -0
  34. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/tests/test_middleware_order.py +23 -1
  35. simple_module_hosting-0.0.33/tests/test_migration_listing.py +106 -0
  36. simple_module_hosting-0.0.33/tests/test_migration_multihead.py +154 -0
  37. simple_module_hosting-0.0.33/tests/test_preapp_config.py +144 -0
  38. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/tests/test_proxy_headers.py +4 -1
  39. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/tests/test_redirects.py +29 -0
  40. simple_module_hosting-0.0.33/tests/test_secret_key_bootstrap.py +93 -0
  41. simple_module_hosting-0.0.33/tests/test_session_cookie_window.py +215 -0
  42. simple_module_hosting-0.0.33/tests/test_setup_gate.py +230 -0
  43. simple_module_hosting-0.0.33/tests/test_setup_password_policy.py +69 -0
  44. simple_module_hosting-0.0.33/tests/test_setup_public_routes.py +68 -0
  45. simple_module_hosting-0.0.33/tests/test_setup_registry.py +102 -0
  46. simple_module_hosting-0.0.33/tests/test_setup_routes.py +137 -0
  47. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/tests/test_strict_discovery_wiring.py +4 -3
  48. simple_module_hosting-0.0.32/simple_module_hosting/host_settings.py +0 -33
  49. simple_module_hosting-0.0.32/simple_module_hosting/migrations.py +0 -65
  50. simple_module_hosting-0.0.32/tests/test_error_page_shared_props.py +0 -80
  51. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/.gitignore +0 -0
  52. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/LICENSE +0 -0
  53. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/README.md +0 -0
  54. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/simple_module_hosting/__main__.py +0 -0
  55. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/simple_module_hosting/_db_health.py +0 -0
  56. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/simple_module_hosting/_host_services.py +0 -0
  57. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/simple_module_hosting/_hydrate_step.py +0 -0
  58. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/simple_module_hosting/_inertia_cache.py +0 -0
  59. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/simple_module_hosting/_inertia_json.py +0 -0
  60. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/simple_module_hosting/_inertia_setup.py +0 -0
  61. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/simple_module_hosting/_observability.py +0 -0
  62. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/simple_module_hosting/assets.py +0 -0
  63. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/simple_module_hosting/csrf.py +0 -0
  64. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/simple_module_hosting/health.py +0 -0
  65. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/simple_module_hosting/host_cli.py +0 -0
  66. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/simple_module_hosting/i18n_deps.py +0 -0
  67. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/simple_module_hosting/i18n_middleware.py +0 -0
  68. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/simple_module_hosting/inertia_deps.py +0 -0
  69. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/simple_module_hosting/inertia_utils.py +0 -0
  70. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/simple_module_hosting/logging.py +0 -0
  71. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/simple_module_hosting/manifest.py +0 -0
  72. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/simple_module_hosting/py.typed +0 -0
  73. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/simple_module_hosting/settings.py +0 -0
  74. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/simple_module_hosting/shared_props.py +0 -0
  75. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/simple_module_hosting/static_files.py +0 -0
  76. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/tests/test_api_error_negotiation.py +0 -0
  77. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/tests/test_app.py +0 -0
  78. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/tests/test_branding_head.py +0 -0
  79. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/tests/test_db_health_check.py +0 -0
  80. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/tests/test_design_packs_wiring.py +0 -0
  81. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/tests/test_health.py +0 -0
  82. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/tests/test_host_cli.py +0 -0
  83. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/tests/test_hosting_csrf.py +0 -0
  84. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/tests/test_hosting_permissions.py +0 -0
  85. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/tests/test_i18n_manifest.py +0 -0
  86. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/tests/test_inertia_cache.py +0 -0
  87. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/tests/test_inertia_i18n_shared_props.py +0 -0
  88. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/tests/test_inertia_json_encoder.py +0 -0
  89. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/tests/test_inertia_manifest.py +0 -0
  90. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/tests/test_inertia_shared_providers.py +0 -0
  91. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/tests/test_locale_middleware.py +0 -0
  92. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/tests/test_logging.py +0 -0
  93. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/tests/test_manifest.py +0 -0
  94. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/tests/test_menu_urls_are_canonical.py +0 -0
  95. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/tests/test_precompressed_static.py +0 -0
  96. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/tests/test_response_compression.py +0 -0
  97. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/tests/test_session_cookie_security.py +0 -0
  98. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/tests/test_settings_i18n.py +0 -0
  99. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/tests/test_settings_secrets.py +0 -0
  100. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/tests/test_sqlite_path_anchor.py +0 -0
  101. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/tests/test_static_caching.py +0 -0
  102. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.33}/tests/test_tenant_middleware.py +0 -0
  103. {simple_module_hosting-0.0.32 → 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.32
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.32
31
- Requires-Dist: simple-module-db==0.0.32
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.32"
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.32",
34
- "simple_module_db==0.0.32",
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
 
@@ -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
@@ -0,0 +1,70 @@
1
+ """Mount a module's routers onto the app using its ``ModuleMeta`` prefixes.
2
+
3
+ Split out of ``_phase_helpers`` once the admin router made this its own
4
+ responsibility — and to keep both files under the 300-line cap.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from fastapi import APIRouter, FastAPI
10
+ from fastapi.routing import APIRoute
11
+
12
+ __all__ = ["wire_module_routes"]
13
+
14
+
15
+ def wire_module_routes(app: FastAPI, module) -> None:
16
+ """Attach a module's API + view (+ admin view) routers to ``app``.
17
+
18
+ The single canonical implementation so ``create_app`` and the test harness
19
+ in ``simple_module_test`` stay in lockstep if ``ModuleBase`` ever gains
20
+ a new router type.
21
+ """
22
+ api_router = APIRouter(prefix=module.meta.route_prefix, tags=[module.meta.name])
23
+ view_router = APIRouter(prefix=module.meta.view_prefix, tags=[f"{module.meta.name} Views"])
24
+ module.register_routes(api_router, view_router)
25
+ _clone_bare_prefix_route(view_router, module.meta.view_prefix)
26
+ app.include_router(api_router)
27
+ app.include_router(view_router)
28
+
29
+ # Second view router for modules that serve both public and admin pages
30
+ # and therefore cannot express both under a single view_prefix — see
31
+ # ``ModuleMeta.admin_view_prefix``.
32
+ admin_prefix = getattr(module.meta, "admin_view_prefix", "")
33
+ if admin_prefix:
34
+ admin_router = APIRouter(
35
+ prefix=admin_prefix,
36
+ tags=[f"{module.meta.name} Admin Views"],
37
+ )
38
+ module.register_admin_routes(admin_router)
39
+ _clone_bare_prefix_route(admin_router, admin_prefix)
40
+ app.include_router(admin_router)
41
+
42
+
43
+ def _clone_bare_prefix_route(router: APIRouter, prefix: str) -> None:
44
+ """Serve ``"/foo"`` as well as ``"/foo/"`` for a bare-prefix route.
45
+
46
+ Without this, FastAPI's ``redirect_slashes=True`` fires a 307 to
47
+ ``"/foo/"``, which clients like httpx strip ``X-Inertia`` from on follow —
48
+ turning that Inertia navigation into a broken HTML response.
49
+
50
+ Only covers routes declared *directly* on the router. A route contributed
51
+ via ``router.include_router(...)`` — which is how most modules register —
52
+ is not visible here: ``include_router`` stores a placeholder and only
53
+ flattens into ``APIRoute`` objects later, so there is nothing to match
54
+ yet. Modules in that (majority) case point their menu item at the
55
+ canonical trailing-slash URL instead, which costs no redirect either.
56
+ """
57
+ if not prefix:
58
+ return
59
+ bare_target = f"{prefix}/"
60
+ for route in list(router.routes):
61
+ if isinstance(route, APIRoute) and route.path == bare_target:
62
+ router.add_api_route(
63
+ "",
64
+ route.endpoint,
65
+ methods=list(route.methods or {"GET"}),
66
+ response_model=route.response_model,
67
+ include_in_schema=False,
68
+ dependencies=route.dependencies,
69
+ name=f"{route.name}__bare",
70
+ )
@@ -11,9 +11,8 @@ import logging
11
11
  from pathlib import Path
12
12
  from typing import TYPE_CHECKING
13
13
 
14
- from fastapi import APIRouter, FastAPI
14
+ from fastapi import FastAPI
15
15
  from fastapi.exceptions import RequestValidationError
16
- from fastapi.routing import APIRoute
17
16
  from fastapi.staticfiles import StaticFiles
18
17
  from inertia import (
19
18
  InertiaVersionConflictException,
@@ -24,7 +23,6 @@ from simple_module_core.exceptions import NotFoundError
24
23
  from simple_module_db import CommitBeforeResponseMiddleware
25
24
  from starlette.exceptions import HTTPException
26
25
  from starlette.middleware.gzip import GZipMiddleware
27
- from starlette.middleware.sessions import SessionMiddleware
28
26
  from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
29
27
 
30
28
  from simple_module_hosting._error_handlers import (
@@ -35,8 +33,10 @@ from simple_module_hosting._error_handlers import (
35
33
  )
36
34
  from simple_module_hosting._host_services import _HostServices
37
35
  from simple_module_hosting._inertia_cache import InertiaCacheMiddleware
36
+ from simple_module_hosting._module_routes import wire_module_routes
38
37
  from simple_module_hosting.host_settings import HostSettings
39
38
  from simple_module_hosting.i18n_middleware import LocaleMiddleware
39
+ from simple_module_hosting.maintenance import MaintenanceMiddleware
40
40
  from simple_module_hosting.middleware import (
41
41
  CorrelationIdMiddleware,
42
42
  InertiaLayoutDataMiddleware,
@@ -44,7 +44,9 @@ from simple_module_hosting.middleware import (
44
44
  SecurityHeadersMiddleware,
45
45
  TenantMiddleware,
46
46
  )
47
+ from simple_module_hosting.session import SessionMiddleware
47
48
  from simple_module_hosting.settings import Settings
49
+ from simple_module_hosting.setup_gate import SETUP_PATH, SetupMiddleware
48
50
  from simple_module_hosting.static_files import PrecompressedStaticFiles
49
51
 
50
52
  if TYPE_CHECKING:
@@ -104,12 +106,27 @@ def install_middleware(
104
106
  Order matters: last added = first executed. Execution order:
105
107
  (ProxyHeaders, if trusted_proxy) → CorrelationId → RequestLogging
106
108
  → Security → Session → [module] → (Tenant, if multi_tenant) → Locale
107
- → Inertia → InertiaCache → CommitBeforeResponse.
109
+ → Inertia → InertiaCache → Setup → Maintenance → CommitBeforeResponse.
108
110
  """
109
111
  # Added first, so it is innermost and its send-wrapper is the first to see
110
112
  # the response: the request's DB work commits before any byte reaches the
111
113
  # client, instead of in get_db's post-response exit code (GH #257).
112
114
  app.add_middleware(CommitBeforeResponseMiddleware)
115
+ # Inside InertiaCache, so its short-circuit is still governed by it. The
116
+ # maintenance 503 renders through Inertia and carries this user's auth
117
+ # block and menus like any other payload; short-circuiting *outside* the
118
+ # cache guard would ship exactly the per-user payload GH #272 exists to
119
+ # keep out of caches. Added before Inertia so it *executes* after it: the
120
+ # page needs the shared props (auth, menus, i18n) to render with a layout,
121
+ # and auth + locale — both further out — to know who is asking and in which
122
+ # language to answer.
123
+ app.add_middleware(MaintenanceMiddleware)
124
+ # Added after Maintenance so it *executes* before it: an install that has
125
+ # never been set up has nothing meaningful to put into maintenance mode,
126
+ # and the setup redirect should win. Inside InertiaCache for the same
127
+ # reason Maintenance is — this short-circuits, and the redirect must not
128
+ # be stored by any cache.
129
+ app.add_middleware(SetupMiddleware)
113
130
  # Paired with InertiaLayoutDataMiddleware below, which is what puts this
114
131
  # user's auth, permissions and menus into every Inertia payload: this one
115
132
  # makes sure the payload that results is never stored where a page request
@@ -166,6 +183,18 @@ def attach_public_routes(app: FastAPI, settings: Settings, registry) -> None:
166
183
  ``app.state.public_routes``, where ``auth.middleware.AuthMiddleware`` reads it
167
184
  on every request.
168
185
  """
186
+ # The setup wizard is anonymous by necessity: it is served precisely when
187
+ # no account exists, so gating it behind auth would redirect the operator
188
+ # to a login they cannot pass. Its own handlers 404 once setup completes,
189
+ # which is what keeps this exemption from outliving its purpose.
190
+ #
191
+ # Exact + trailing-slash prefix, not a bare "/setup" prefix: the latter
192
+ # would also hand anonymous access to any unrelated route that happens to
193
+ # start with those six characters ("/setup-guide"), and nothing about that
194
+ # route asked to be public.
195
+ registry.add_exact(SETUP_PATH)
196
+ registry.add_prefix(f"{SETUP_PATH}/")
197
+
169
198
  for prefix in settings.auth_public_paths:
170
199
  registry.add_prefix(prefix)
171
200
  app.state.public_routes = registry
@@ -256,35 +285,4 @@ def check_settings_registration(app: FastAPI, modules: list) -> list[Diagnostic]
256
285
  return diagnostics
257
286
 
258
287
 
259
- def wire_module_routes(app: FastAPI, module) -> None:
260
- """Attach a module's API + view routers to ``app`` using its Meta prefixes.
261
-
262
- The single canonical implementation so ``create_app`` and the test harness
263
- in ``simple_module_test`` stay in lockstep if ``ModuleBase`` ever gains
264
- a new router type.
265
-
266
- Bare-prefix view routes (``view_prefix="/foo"`` + ``@router.get("/")``)
267
- are also mounted at the trailing-slash-less form ``"/foo"``. Without this,
268
- FastAPI's ``redirect_slashes=True`` fires a 307 to ``"/foo/"``, which
269
- clients like httpx strip ``X-Inertia`` from on follow — turning every
270
- Inertia navigation into a broken HTML response. Cloning the route at the
271
- bare-prefix path serves the same handler directly, no redirect.
272
- """
273
- api_router = APIRouter(prefix=module.meta.route_prefix, tags=[module.meta.name])
274
- view_router = APIRouter(prefix=module.meta.view_prefix, tags=[f"{module.meta.name} Views"])
275
- module.register_routes(api_router, view_router)
276
- if module.meta.view_prefix:
277
- bare_target = f"{module.meta.view_prefix}/"
278
- for route in list(view_router.routes):
279
- if isinstance(route, APIRoute) and route.path == bare_target:
280
- view_router.add_api_route(
281
- "",
282
- route.endpoint,
283
- methods=list(route.methods or {"GET"}),
284
- response_model=route.response_model,
285
- include_in_schema=False,
286
- dependencies=route.dependencies,
287
- name=f"{route.name}__bare",
288
- )
289
- app.include_router(api_router)
290
- app.include_router(view_router)
288
+ __all__ = ["wire_module_routes"]