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.
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/PKG-INFO +3 -3
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/pyproject.toml +3 -3
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/__init__.py +2 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/_error_handlers.py +121 -9
- simple_module_hosting-0.0.33/simple_module_hosting/_inertia_cache.py +104 -0
- simple_module_hosting-0.0.33/simple_module_hosting/_inertia_json.py +86 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/_inertia_setup.py +5 -1
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/_inertia_shared.py +26 -0
- simple_module_hosting-0.0.33/simple_module_hosting/_lifespan.py +130 -0
- simple_module_hosting-0.0.33/simple_module_hosting/_module_routes.py +70 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/_phase_helpers.py +40 -36
- simple_module_hosting-0.0.33/simple_module_hosting/_preapp_config.py +227 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/_registrations.py +4 -0
- simple_module_hosting-0.0.33/simple_module_hosting/_secret_key.py +154 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/app_builder.py +32 -34
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/bootstrap_settings.py +33 -70
- simple_module_hosting-0.0.33/simple_module_hosting/host_settings.py +126 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/i18n_manifest.py +18 -0
- simple_module_hosting-0.0.33/simple_module_hosting/maintenance.py +125 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/middleware.py +6 -1
- simple_module_hosting-0.0.33/simple_module_hosting/migrations.py +217 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/permissions.py +9 -1
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/redirects.py +12 -10
- simple_module_hosting-0.0.33/simple_module_hosting/session.py +192 -0
- simple_module_hosting-0.0.33/simple_module_hosting/setup_gate.py +187 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_auth_provider_setting.py +25 -0
- simple_module_hosting-0.0.33/tests/test_boot_drift_check.py +99 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_check_migrations.py +22 -10
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_csp_module_sources.py +1 -0
- simple_module_hosting-0.0.33/tests/test_diagnostics_state.py +112 -0
- simple_module_hosting-0.0.33/tests/test_error_page_shared_props.py +183 -0
- simple_module_hosting-0.0.33/tests/test_error_status_coverage.py +229 -0
- simple_module_hosting-0.0.33/tests/test_host_settings_fields.py +95 -0
- simple_module_hosting-0.0.33/tests/test_i18n_types_cover_installed_modules.py +66 -0
- simple_module_hosting-0.0.33/tests/test_inertia_cache.py +187 -0
- simple_module_hosting-0.0.33/tests/test_inertia_json_encoder.py +83 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_lifespan_order.py +3 -1
- simple_module_hosting-0.0.33/tests/test_maintenance_mode.py +270 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_middleware_order.py +30 -0
- simple_module_hosting-0.0.33/tests/test_migration_listing.py +106 -0
- simple_module_hosting-0.0.33/tests/test_migration_multihead.py +154 -0
- simple_module_hosting-0.0.33/tests/test_preapp_config.py +144 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_proxy_headers.py +4 -1
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_redirects.py +29 -0
- simple_module_hosting-0.0.33/tests/test_secret_key_bootstrap.py +93 -0
- simple_module_hosting-0.0.33/tests/test_session_cookie_window.py +215 -0
- simple_module_hosting-0.0.33/tests/test_setup_gate.py +230 -0
- simple_module_hosting-0.0.33/tests/test_setup_password_policy.py +69 -0
- simple_module_hosting-0.0.33/tests/test_setup_public_routes.py +68 -0
- simple_module_hosting-0.0.33/tests/test_setup_registry.py +102 -0
- simple_module_hosting-0.0.33/tests/test_setup_routes.py +137 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_strict_discovery_wiring.py +4 -3
- simple_module_hosting-0.0.31/simple_module_hosting/host_settings.py +0 -33
- simple_module_hosting-0.0.31/simple_module_hosting/migrations.py +0 -65
- simple_module_hosting-0.0.31/tests/test_error_page_shared_props.py +0 -80
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/.gitignore +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/LICENSE +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/README.md +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/__main__.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/_db_health.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/_host_services.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/_hydrate_step.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/_observability.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/assets.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/csrf.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/health.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/host_cli.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/i18n_deps.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/i18n_middleware.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/inertia_deps.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/inertia_utils.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/logging.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/manifest.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/py.typed +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/settings.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/shared_props.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/static_files.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_api_error_negotiation.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_app.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_branding_head.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_db_health_check.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_design_packs_wiring.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_health.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_host_cli.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_hosting_csrf.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_hosting_permissions.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_i18n_manifest.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_inertia_i18n_shared_props.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_inertia_manifest.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_inertia_shared_providers.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_locale_middleware.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_logging.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_manifest.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_menu_urls_are_canonical.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_precompressed_static.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_response_compression.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_session_cookie_security.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_settings_i18n.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_settings_secrets.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_sqlite_path_anchor.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_static_caching.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/tests/test_tenant_middleware.py +0 -0
- {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.
|
|
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
|
-
Requires-Dist: simple-module-db==0.0.
|
|
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.
|
|
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.
|
|
34
|
-
"simple_module_db==0.0.
|
|
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",
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.33}/simple_module_hosting/__init__.py
RENAMED
|
@@ -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(
|
|
89
|
-
|
|
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
|
-
|
|
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,
|
|
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=
|
|
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
|
-
|
|
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
|