simple-module-hosting 0.0.34__tar.gz → 0.0.36__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.34 → simple_module_hosting-0.0.36}/PKG-INFO +6 -4
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/pyproject.toml +9 -4
- simple_module_hosting-0.0.36/simple_module_hosting/_body_limit.py +191 -0
- simple_module_hosting-0.0.36/simple_module_hosting/_boot_checks.py +47 -0
- simple_module_hosting-0.0.36/simple_module_hosting/_csp_dynamic.py +99 -0
- simple_module_hosting-0.0.36/simple_module_hosting/_dev_boot.py +86 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/_error_handlers.py +24 -16
- simple_module_hosting-0.0.36/simple_module_hosting/_i18n_build.py +58 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/_inertia_setup.py +22 -75
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/_inertia_shared.py +33 -4
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/_lifespan.py +54 -1
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/_module_routes.py +7 -1
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/_phase_helpers.py +39 -71
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/_preapp_config.py +1 -1
- simple_module_hosting-0.0.36/simple_module_hosting/_project_root.py +39 -0
- simple_module_hosting-0.0.36/simple_module_hosting/_rate_limit.py +187 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/_registrations.py +5 -0
- simple_module_hosting-0.0.36/simple_module_hosting/_request_guard_responses.py +50 -0
- simple_module_hosting-0.0.36/simple_module_hosting/_route_guard.py +100 -0
- simple_module_hosting-0.0.36/simple_module_hosting/_settings_registration.py +70 -0
- simple_module_hosting-0.0.36/simple_module_hosting/_tenant.py +197 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/app_builder.py +40 -65
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/assets.py +50 -1
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/bootstrap_settings.py +5 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/host_settings.py +50 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/i18n_manifest.py +62 -39
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/inertia_deps.py +1 -1
- simple_module_hosting-0.0.36/simple_module_hosting/locales/en.json +24 -0
- simple_module_hosting-0.0.36/simple_module_hosting/locales/es.json +24 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/manifest.py +11 -19
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/middleware.py +38 -64
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/migrations.py +28 -0
- simple_module_hosting-0.0.36/simple_module_hosting/page_globs.py +52 -0
- simple_module_hosting-0.0.36/simple_module_hosting/permissions.py +183 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/session.py +19 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/setup_gate.py +60 -7
- simple_module_hosting-0.0.36/simple_module_hosting/setup_wizard/__init__.py +72 -0
- simple_module_hosting-0.0.36/simple_module_hosting/setup_wizard/components/ConnectionList.tsx +84 -0
- simple_module_hosting-0.0.36/simple_module_hosting/setup_wizard/components/StepForm.tsx +132 -0
- simple_module_hosting-0.0.36/simple_module_hosting/setup_wizard/migrate.py +92 -0
- simple_module_hosting-0.0.36/simple_module_hosting/setup_wizard/pages/Wizard.tsx +131 -0
- simple_module_hosting-0.0.36/simple_module_hosting/setup_wizard/payloads.py +117 -0
- simple_module_hosting-0.0.36/simple_module_hosting/setup_wizard/routes.py +135 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/shared_props.py +4 -4
- simple_module_hosting-0.0.36/simple_module_hosting/tenancy.py +92 -0
- simple_module_hosting-0.0.36/simple_module_hosting/ui_locales/en.json +74 -0
- simple_module_hosting-0.0.36/simple_module_hosting/ui_locales/es.json +74 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_branding_head.py +16 -0
- simple_module_hosting-0.0.36/tests/test_check_migrations_expression_note.py +30 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_csp_module_sources.py +1 -0
- simple_module_hosting-0.0.36/tests/test_csp_providers.py +163 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_diagnostics_state.py +2 -4
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_error_page_shared_props.py +13 -3
- simple_module_hosting-0.0.36/tests/test_grant_sources.py +122 -0
- simple_module_hosting-0.0.36/tests/test_home_url_default.py +35 -0
- simple_module_hosting-0.0.36/tests/test_hosting_error_json_and_session_cookie.py +82 -0
- simple_module_hosting-0.0.36/tests/test_i18n_build_overrides.py +36 -0
- simple_module_hosting-0.0.36/tests/test_i18n_ui_namespace.py +15 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_inertia_i18n_shared_props.py +1 -1
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_inertia_shared_providers.py +12 -0
- simple_module_hosting-0.0.36/tests/test_invalidation_wiring.py +250 -0
- simple_module_hosting-0.0.36/tests/test_lifespan_deferred_startup.py +94 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_manifest.py +48 -0
- simple_module_hosting-0.0.36/tests/test_menu_providers_wiring.py +95 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_middleware_order.py +29 -2
- simple_module_hosting-0.0.36/tests/test_principal_permissions_claim.py +91 -0
- simple_module_hosting-0.0.36/tests/test_request_guard_auth_parity.py +116 -0
- simple_module_hosting-0.0.36/tests/test_request_guard_body_limit.py +181 -0
- simple_module_hosting-0.0.36/tests/test_request_guard_body_limit_middleware_read.py +41 -0
- simple_module_hosting-0.0.36/tests/test_request_guard_body_limit_started.py +67 -0
- simple_module_hosting-0.0.36/tests/test_request_guard_rate_limit.py +293 -0
- simple_module_hosting-0.0.36/tests/test_route_guard.py +131 -0
- simple_module_hosting-0.0.36/tests/test_session_vary_dedupe.py +39 -0
- simple_module_hosting-0.0.36/tests/test_setup_gate_single_flight.py +137 -0
- simple_module_hosting-0.0.36/tests/test_setup_migrate_error_redaction.py +55 -0
- simple_module_hosting-0.0.36/tests/test_setup_routes.py +135 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_strict_discovery_wiring.py +1 -1
- simple_module_hosting-0.0.36/tests/test_tenancy_api.py +168 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_tenant_middleware.py +56 -3
- simple_module_hosting-0.0.36/tests/test_tenant_resolver_diagnostic.py +47 -0
- simple_module_hosting-0.0.36/tests/test_tenant_scope_helpers.py +116 -0
- simple_module_hosting-0.0.36/tests/test_tenant_source.py +171 -0
- simple_module_hosting-0.0.36/tests/test_view_prefixes.py +39 -0
- simple_module_hosting-0.0.36/tsconfig.json +12 -0
- simple_module_hosting-0.0.34/simple_module_hosting/_inertia_json.py +0 -86
- simple_module_hosting-0.0.34/simple_module_hosting/_inertia_url.py +0 -131
- simple_module_hosting-0.0.34/simple_module_hosting/permissions.py +0 -104
- simple_module_hosting-0.0.34/tests/test_inertia_json_encoder.py +0 -83
- simple_module_hosting-0.0.34/tests/test_inertia_manifest.py +0 -71
- simple_module_hosting-0.0.34/tests/test_inertia_relative_url.py +0 -120
- simple_module_hosting-0.0.34/tests/test_setup_password_policy.py +0 -69
- simple_module_hosting-0.0.34/tests/test_setup_routes.py +0 -137
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/.gitignore +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/LICENSE +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/README.md +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/__init__.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/__main__.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/_db_health.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/_favicon.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/_host_services.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/_hydrate_step.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/_inertia_cache.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/_observability.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/_secret_key.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/csrf.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/health.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/host_cli.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/i18n_deps.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/i18n_middleware.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/inertia_utils.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/logging.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/maintenance.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/py.typed +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/redirects.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/settings.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/static_files.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_api_error_negotiation.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_app.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_auth_provider_setting.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_boot_drift_check.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_check_migrations.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_db_health_check.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_design_packs_wiring.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_error_status_coverage.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_favicon_default.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_health.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_host_cli.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_host_settings_fields.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_hosting_csrf.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_hosting_permissions.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_i18n_manifest.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_i18n_types_cover_installed_modules.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_inertia_cache.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_lifespan_order.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_locale_middleware.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_logging.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_maintenance_mode.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_menu_urls_are_canonical.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_migration_listing.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_migration_multihead.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_preapp_config.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_precompressed_static.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_proxy_headers.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_redirects.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_response_compression.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_secret_key_bootstrap.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_session_cookie_security.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_session_cookie_window.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_settings_i18n.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_settings_secrets.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_setup_gate.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_setup_public_routes.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_setup_registry.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_sqlite_path_anchor.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_static_caching.py +0 -0
- {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/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.36
|
|
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
|
|
@@ -22,16 +22,18 @@ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
|
22
22
|
Classifier: Typing :: Typed
|
|
23
23
|
Requires-Python: >=3.12
|
|
24
24
|
Requires-Dist: click>=8.1
|
|
25
|
-
Requires-Dist: fastapi-inertia>=1.0
|
|
26
25
|
Requires-Dist: fastapi>=0.115
|
|
27
26
|
Requires-Dist: httpx>=0.27
|
|
28
27
|
Requires-Dist: itsdangerous>=2.0
|
|
29
28
|
Requires-Dist: jinja2>=3.1
|
|
30
|
-
Requires-Dist: simple-module-core==0.0.
|
|
31
|
-
Requires-Dist: simple-module-db==0.0.
|
|
29
|
+
Requires-Dist: simple-module-core==0.0.36
|
|
30
|
+
Requires-Dist: simple-module-db==0.0.36
|
|
31
|
+
Requires-Dist: simple-module-inertia==0.0.36
|
|
32
32
|
Requires-Dist: starlette>=0.44
|
|
33
33
|
Requires-Dist: tomlkit>=0.13
|
|
34
34
|
Requires-Dist: uvicorn[standard]>=0.34
|
|
35
|
+
Provides-Extra: redis
|
|
36
|
+
Requires-Dist: redis>=5; extra == 'redis'
|
|
35
37
|
Description-Content-Type: text/markdown
|
|
36
38
|
|
|
37
39
|
# simple_module_hosting
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_hosting"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.36"
|
|
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"
|
|
@@ -23,20 +23,24 @@ classifiers = [
|
|
|
23
23
|
dependencies = [
|
|
24
24
|
"click>=8.1",
|
|
25
25
|
"fastapi>=0.115",
|
|
26
|
-
"fastapi-inertia>=1.0",
|
|
27
26
|
"httpx>=0.27",
|
|
28
27
|
# SessionMiddleware (starlette.middleware.sessions) imports itsdangerous at
|
|
29
28
|
# module import time; starlette treats it as an optional extra, so declare
|
|
30
29
|
# it directly — out-of-tree hosts have nothing else pulling it in.
|
|
31
30
|
"itsdangerous>=2.0",
|
|
32
31
|
"jinja2>=3.1",
|
|
33
|
-
"simple_module_core==0.0.
|
|
34
|
-
"simple_module_db==0.0.
|
|
32
|
+
"simple_module_core==0.0.36",
|
|
33
|
+
"simple_module_db==0.0.36",
|
|
34
|
+
"simple_module_inertia==0.0.36",
|
|
35
35
|
"starlette>=0.44",
|
|
36
36
|
"tomlkit>=0.13",
|
|
37
37
|
"uvicorn[standard]>=0.34",
|
|
38
38
|
]
|
|
39
39
|
|
|
40
|
+
[project.optional-dependencies]
|
|
41
|
+
# Shared rate-limiter store (SM_REDIS_URL). Without it the limiter counts per worker.
|
|
42
|
+
redis = ["redis>=5"]
|
|
43
|
+
|
|
40
44
|
[project.scripts]
|
|
41
45
|
sm-host = "simple_module_hosting.host_cli:app"
|
|
42
46
|
|
|
@@ -59,3 +63,4 @@ packages = ["simple_module_hosting"]
|
|
|
59
63
|
[tool.uv.sources]
|
|
60
64
|
simple_module_core = { workspace = true }
|
|
61
65
|
simple_module_db = { workspace = true }
|
|
66
|
+
simple_module_inertia = { workspace = true }
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Request-body size guard (GH #345).
|
|
2
|
+
|
|
3
|
+
Pure ASGI. Refuses an oversized body *before* a route reads it, so the limit
|
|
4
|
+
protects the process and not just the database:
|
|
5
|
+
|
|
6
|
+
* ``Content-Length`` over the ceiling is refused outright, body unread.
|
|
7
|
+
* No (or a lying) ``Content-Length`` — chunked transfer — is caught by counting
|
|
8
|
+
the bytes the app actually pulls through ``receive`` and aborting once the
|
|
9
|
+
ceiling is crossed.
|
|
10
|
+
|
|
11
|
+
The ceiling is the global ``max_request_body_bytes`` unless a module declared a
|
|
12
|
+
per-path override with ``register_body_limits``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
|
|
19
|
+
from simple_module_core.body_limits import BodyLimitRegistry
|
|
20
|
+
from starlette.datastructures import Headers
|
|
21
|
+
from starlette.exceptions import HTTPException
|
|
22
|
+
from starlette.requests import Request
|
|
23
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
24
|
+
|
|
25
|
+
from simple_module_hosting._error_handlers import _wants_json
|
|
26
|
+
from simple_module_hosting._request_guard_responses import send_guard_response
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger("simple_module.request_guard")
|
|
29
|
+
|
|
30
|
+
_MESSAGE = "Request body is too large."
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _BodyTooLargeError(Exception):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class BodyLimitMiddleware:
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
app: ASGIApp,
|
|
41
|
+
*,
|
|
42
|
+
max_bytes: int,
|
|
43
|
+
registry: BodyLimitRegistry | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
self.app = app
|
|
46
|
+
self.max_bytes = max_bytes
|
|
47
|
+
self.registry = registry
|
|
48
|
+
|
|
49
|
+
def _ceiling(self, scope: Scope) -> int:
|
|
50
|
+
override = None
|
|
51
|
+
if self.registry is not None:
|
|
52
|
+
override = self.registry.limit_for(scope["method"], scope["path"], scope.get("app"))
|
|
53
|
+
return self.max_bytes if override is None else override
|
|
54
|
+
|
|
55
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
56
|
+
if scope["type"] != "http":
|
|
57
|
+
await self.app(scope, receive, send)
|
|
58
|
+
return
|
|
59
|
+
limit = self._ceiling(scope)
|
|
60
|
+
if limit <= 0:
|
|
61
|
+
await self.app(scope, receive, send)
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
declared = Headers(scope=scope).get("content-length")
|
|
65
|
+
too_big = (
|
|
66
|
+
declared is not None
|
|
67
|
+
and declared.isascii()
|
|
68
|
+
and declared.isdigit()
|
|
69
|
+
and int(declared) > limit
|
|
70
|
+
)
|
|
71
|
+
# A browser-shaped request is answered with the Inertia error page, which
|
|
72
|
+
# needs the session / locale / shared props the layers *inside* this one
|
|
73
|
+
# set up. So it is not refused here: the body read raises an
|
|
74
|
+
# ``HTTPException(413)`` that the app's own handler renders in full.
|
|
75
|
+
# (FastAPI re-raises HTTPException from body parsing untouched.) API
|
|
76
|
+
# callers are refused right here, before anything runs.
|
|
77
|
+
if not self._wants_json(scope):
|
|
78
|
+
await self._handle_page(scope, receive, send, limit, too_big)
|
|
79
|
+
return
|
|
80
|
+
if too_big:
|
|
81
|
+
self._log(scope, limit, int(declared))
|
|
82
|
+
await self._refuse(scope, receive, send, limit)
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
seen = 0
|
|
86
|
+
exceeded = False
|
|
87
|
+
refused = False
|
|
88
|
+
started = False
|
|
89
|
+
|
|
90
|
+
async def counting_receive() -> Message:
|
|
91
|
+
nonlocal seen, exceeded
|
|
92
|
+
message = await receive()
|
|
93
|
+
if message["type"] == "http.request":
|
|
94
|
+
seen += len(message.get("body", b""))
|
|
95
|
+
if seen > limit:
|
|
96
|
+
exceeded = True
|
|
97
|
+
raise _BodyTooLargeError
|
|
98
|
+
return message
|
|
99
|
+
|
|
100
|
+
async def guarded_send(message: Message) -> None:
|
|
101
|
+
nonlocal refused, started
|
|
102
|
+
if exceeded and not started:
|
|
103
|
+
# The app is answering a body we cut off (often with a 400 from
|
|
104
|
+
# its own parser). Swallow it; the 413 below is the real answer.
|
|
105
|
+
if not refused:
|
|
106
|
+
refused = True
|
|
107
|
+
self._log(scope, limit, seen)
|
|
108
|
+
await self._refuse(scope, receive, send, limit)
|
|
109
|
+
return
|
|
110
|
+
# Once the app's own response has started it is passed through whole:
|
|
111
|
+
# swallowing the rest would leave the client hanging on a half-sent
|
|
112
|
+
# response. (Any exception it raises for the cut-off body is re-raised
|
|
113
|
+
# below, which aborts the connection instead.)
|
|
114
|
+
if message["type"] == "http.response.start":
|
|
115
|
+
started = True
|
|
116
|
+
await send(message)
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
await self.app(scope, counting_receive, guarded_send)
|
|
120
|
+
except Exception:
|
|
121
|
+
if not exceeded or started:
|
|
122
|
+
if exceeded:
|
|
123
|
+
self._log(scope, limit, seen)
|
|
124
|
+
raise
|
|
125
|
+
if exceeded and not refused and not started:
|
|
126
|
+
self._log(scope, limit, seen)
|
|
127
|
+
await self._refuse(scope, receive, send, limit)
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def _wants_json(scope: Scope) -> bool:
|
|
131
|
+
return _wants_json(Request(scope))
|
|
132
|
+
|
|
133
|
+
async def _handle_page(
|
|
134
|
+
self, scope: Scope, receive: Receive, send: Send, limit: int, too_big: bool
|
|
135
|
+
) -> None:
|
|
136
|
+
seen = 0
|
|
137
|
+
|
|
138
|
+
async def receive_or_413() -> Message:
|
|
139
|
+
nonlocal seen
|
|
140
|
+
if too_big:
|
|
141
|
+
self._log(scope, limit, seen)
|
|
142
|
+
raise HTTPException(status_code=413, detail=_MESSAGE)
|
|
143
|
+
message = await receive()
|
|
144
|
+
if message["type"] == "http.request":
|
|
145
|
+
seen += len(message.get("body", b""))
|
|
146
|
+
if seen > limit:
|
|
147
|
+
self._log(scope, limit, seen)
|
|
148
|
+
raise HTTPException(status_code=413, detail=_MESSAGE)
|
|
149
|
+
return message
|
|
150
|
+
|
|
151
|
+
started = False
|
|
152
|
+
|
|
153
|
+
async def tracking_send(message: Message) -> None:
|
|
154
|
+
nonlocal started
|
|
155
|
+
if message["type"] == "http.response.start":
|
|
156
|
+
started = True
|
|
157
|
+
await send(message)
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
await self.app(scope, receive_or_413, tracking_send)
|
|
161
|
+
except HTTPException as exc:
|
|
162
|
+
# Raised by a middleware that reads the body *outside* the router's
|
|
163
|
+
# exception handling (e.g. a form gate). Without this it would
|
|
164
|
+
# surface as a bare 500 from ServerErrorMiddleware.
|
|
165
|
+
if exc.status_code != 413 or started:
|
|
166
|
+
raise
|
|
167
|
+
await self._refuse(scope, receive, send, limit)
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def _log(scope: Scope, limit: int, size: int) -> None:
|
|
171
|
+
cid = scope.get("state", {}).get("correlation_id", "")
|
|
172
|
+
logger.warning(
|
|
173
|
+
"Request body over limit: %s %s (%d > %d bytes) correlation_id=%s",
|
|
174
|
+
scope["method"],
|
|
175
|
+
scope["path"],
|
|
176
|
+
size,
|
|
177
|
+
limit,
|
|
178
|
+
cid,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
@staticmethod
|
|
182
|
+
async def _refuse(scope: Scope, receive: Receive, send: Send, limit: int) -> None:
|
|
183
|
+
await send_guard_response(
|
|
184
|
+
scope,
|
|
185
|
+
receive,
|
|
186
|
+
send,
|
|
187
|
+
413,
|
|
188
|
+
_MESSAGE,
|
|
189
|
+
headers={"Connection": "close"},
|
|
190
|
+
extra={"max_bytes": limit},
|
|
191
|
+
)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Diagnostics that need the built app, so cannot run from ``make doctor``.
|
|
2
|
+
|
|
3
|
+
The module diagnostics in Phase 2 see only module classes and the source tree.
|
|
4
|
+
A check about what modules *registered* has to wait until after Phase 5, and
|
|
5
|
+
unlike the source-tree checks it is meaningful in production too.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from simple_module_core.diagnostics import Diagnostic, check_tenant_resolver, print_diagnostics
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from fastapi import FastAPI
|
|
17
|
+
from simple_module_core.services import DiagnosticsState
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _tenant_checks(app: FastAPI, settings: Any) -> list[Diagnostic]:
|
|
23
|
+
return check_tenant_resolver(
|
|
24
|
+
multi_tenant=bool(getattr(settings, "multi_tenant", False)),
|
|
25
|
+
resolver=getattr(app.state, "tenant_resolver", None),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def report_tenant_resolution(
|
|
30
|
+
app: FastAPI, settings: Any, diagnostics_state: DiagnosticsState
|
|
31
|
+
) -> list[Diagnostic]:
|
|
32
|
+
"""SM025: report, and fold into the Doctor screen's results when it has a runner."""
|
|
33
|
+
found = _tenant_checks(app, settings)
|
|
34
|
+
if found:
|
|
35
|
+
if settings.is_development:
|
|
36
|
+
print_diagnostics(found)
|
|
37
|
+
else:
|
|
38
|
+
for diag in found:
|
|
39
|
+
logger.warning("%s", diag)
|
|
40
|
+
runner = diagnostics_state.runner
|
|
41
|
+
if runner is not None:
|
|
42
|
+
diagnostics_state.runner = lambda: [*runner(), *_tenant_checks(app, settings)]
|
|
43
|
+
diagnostics_state.results = [*diagnostics_state.results, *found]
|
|
44
|
+
return found
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
__all__ = ["report_tenant_resolution"]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Per-response evaluation of module CSP providers (#434).
|
|
2
|
+
|
|
3
|
+
``register_csp_sources`` runs before DB settings hydrate, so origins that come
|
|
4
|
+
from a setting are declared as providers (``registry.add_provider(fn)``) and
|
|
5
|
+
evaluated here on each response. The finished policy is cached by the *value*
|
|
6
|
+
the providers return, so a settings edit changes the key on the next response
|
|
7
|
+
without any invalidation wiring.
|
|
8
|
+
|
|
9
|
+
Fail closed: a provider that raises, or a pair that fails the same validation
|
|
10
|
+
``CspSourceRegistry.add`` applies, contributes nothing — the static policy
|
|
11
|
+
still ships. Each distinct failure is logged once per process.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from simple_module_core.csp import CspSourceRegistry, is_valid_source
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
_CACHE_MAX = 32
|
|
24
|
+
_LOGGED_MAX = 256
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DynamicCsp:
|
|
28
|
+
"""Builds the CSP for one response from a static base plus provider output."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, base: str, registry: CspSourceRegistry) -> None:
|
|
31
|
+
self.base = base
|
|
32
|
+
self._registry = registry
|
|
33
|
+
self._providers = registry.providers
|
|
34
|
+
self._cache: dict[frozenset[tuple[str, str]], str] = {}
|
|
35
|
+
self._logged: set[str] = set()
|
|
36
|
+
|
|
37
|
+
def policy_for(self, app: Any) -> str:
|
|
38
|
+
pairs = frozenset(self._collect(app))
|
|
39
|
+
if not pairs:
|
|
40
|
+
return self.base
|
|
41
|
+
policy = self._cache.get(pairs)
|
|
42
|
+
if policy is None:
|
|
43
|
+
if len(self._cache) >= _CACHE_MAX:
|
|
44
|
+
self._cache.clear()
|
|
45
|
+
# Sorted so the clause order is stable for a given set of pairs.
|
|
46
|
+
try:
|
|
47
|
+
policy = self._registry.extend_policy(self.base, extra=sorted(pairs))
|
|
48
|
+
except Exception as exc:
|
|
49
|
+
# Last line of the fail-closed guarantee: this runs inside the
|
|
50
|
+
# response for every page, so a bug here must cost the extra
|
|
51
|
+
# origins, never the response.
|
|
52
|
+
self._warn_once(
|
|
53
|
+
f"build:{type(exc).__name__}:{exc}",
|
|
54
|
+
"CSP provider policy build failed (%r); serving the static policy",
|
|
55
|
+
exc,
|
|
56
|
+
)
|
|
57
|
+
return self.base
|
|
58
|
+
self._cache[pairs] = policy
|
|
59
|
+
return policy
|
|
60
|
+
|
|
61
|
+
def _collect(self, app: Any) -> list[tuple[str, str]]:
|
|
62
|
+
out: list[tuple[str, str]] = []
|
|
63
|
+
for provider in self._providers:
|
|
64
|
+
name = getattr(provider, "__qualname__", repr(provider))
|
|
65
|
+
try:
|
|
66
|
+
pairs = list(provider(app))
|
|
67
|
+
except Exception as exc:
|
|
68
|
+
self._warn_once(
|
|
69
|
+
f"{name}:{type(exc).__name__}:{exc}",
|
|
70
|
+
"CSP provider %s raised %r; its sources are skipped",
|
|
71
|
+
name,
|
|
72
|
+
exc,
|
|
73
|
+
)
|
|
74
|
+
continue
|
|
75
|
+
for pair in pairs:
|
|
76
|
+
if (
|
|
77
|
+
isinstance(pair, tuple)
|
|
78
|
+
and len(pair) == 2
|
|
79
|
+
and isinstance(pair[0], str)
|
|
80
|
+
and isinstance(pair[1], str)
|
|
81
|
+
and is_valid_source(pair[0], pair[1])
|
|
82
|
+
):
|
|
83
|
+
out.append((pair[0], pair[1].strip()))
|
|
84
|
+
else:
|
|
85
|
+
self._warn_once(
|
|
86
|
+
f"{name}:{pair!r}",
|
|
87
|
+
"CSP provider %s returned invalid pair %r; dropped",
|
|
88
|
+
name,
|
|
89
|
+
pair,
|
|
90
|
+
)
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
def _warn_once(self, key: str, msg: str, *args: object) -> None:
|
|
94
|
+
if key in self._logged:
|
|
95
|
+
return
|
|
96
|
+
if len(self._logged) >= _LOGGED_MAX:
|
|
97
|
+
self._logged.clear()
|
|
98
|
+
self._logged.add(key)
|
|
99
|
+
logger.warning(msg, *args)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Development-only boot work: the diagnostics run and the frontend artifacts.
|
|
2
|
+
|
|
3
|
+
Extracted from ``app_builder`` so that file reads as the phase sequence and
|
|
4
|
+
nothing else. Everything here is gated on ``settings.is_development`` at the one
|
|
5
|
+
call site, and everything here reads or writes the *source tree* — a deployed
|
|
6
|
+
wheel has no ``host/client_app`` to write a manifest into and no module sources
|
|
7
|
+
for the AST checks to walk, which is why none of it belongs on a production boot
|
|
8
|
+
path.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from simple_module_core.diagnostics import DiagnosticLevel, print_diagnostics, run_diagnostics
|
|
18
|
+
|
|
19
|
+
from simple_module_hosting.i18n_manifest import emit_frontend_types_for_modules
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from simple_module_core import ModuleBase
|
|
23
|
+
from simple_module_core.services import DiagnosticsState
|
|
24
|
+
|
|
25
|
+
from simple_module_hosting.settings import Settings
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
__all__ = ["run_dev_boot"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run_dev_boot(
|
|
33
|
+
settings: Settings,
|
|
34
|
+
modules: list[ModuleBase],
|
|
35
|
+
installed_modules: list[ModuleBase],
|
|
36
|
+
*,
|
|
37
|
+
i18n_extra,
|
|
38
|
+
i18n_unknown_overrides: dict[str, list[str]] | None = None,
|
|
39
|
+
diagnostics_state: DiagnosticsState,
|
|
40
|
+
project_root: Path,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Run the module diagnostics and emit the frontend's generated files.
|
|
43
|
+
|
|
44
|
+
``diagnostics_state.runner`` is left behind on purpose, not just used: the
|
|
45
|
+
in-app Doctor screen re-invokes this exact call through ``rerun()``, and
|
|
46
|
+
re-deriving the arguments from a request would mean re-discovering modules.
|
|
47
|
+
|
|
48
|
+
An ERROR-level diagnostic ends the process. A developer whose module has a
|
|
49
|
+
duplicate name or a framework→plugin import gets one clear message here
|
|
50
|
+
instead of an inexplicable failure several phases later — and the same
|
|
51
|
+
checks run at production boot, where the app would refuse to start anyway.
|
|
52
|
+
"""
|
|
53
|
+
diagnostics_state.runner = lambda: run_diagnostics(
|
|
54
|
+
modules,
|
|
55
|
+
i18n_supported_locales=settings.i18n_supported_locales,
|
|
56
|
+
i18n_default_locale=settings.i18n_default_locale,
|
|
57
|
+
i18n_extra_sources=i18n_extra,
|
|
58
|
+
i18n_unknown_overrides=i18n_unknown_overrides,
|
|
59
|
+
database_dialect=settings.database_url.split(":", 1)[0].split("+", 1)[0],
|
|
60
|
+
)
|
|
61
|
+
diagnostics = diagnostics_state.rerun()
|
|
62
|
+
errors = [d for d in diagnostics if d.level == DiagnosticLevel.ERROR]
|
|
63
|
+
if diagnostics:
|
|
64
|
+
print_diagnostics(diagnostics)
|
|
65
|
+
if errors:
|
|
66
|
+
raise SystemExit(f"Module diagnostics: {len(errors)} error(s). Fix before continuing.")
|
|
67
|
+
|
|
68
|
+
_write_pages_manifest(modules, project_root)
|
|
69
|
+
emit_frontend_types_for_modules(settings, installed_modules, project_root)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _write_pages_manifest(modules: list[ModuleBase], project_root: Path) -> None:
|
|
73
|
+
"""Tell Vite where pages shipped inside pip-installed wheels live.
|
|
74
|
+
|
|
75
|
+
Swallows its own failure: a missing or unwritable ``client_app`` must not
|
|
76
|
+
stop the API from booting, and the symptom — pages the frontend cannot find
|
|
77
|
+
— is loud enough on its own. See ``scaffolding.py``.
|
|
78
|
+
"""
|
|
79
|
+
try:
|
|
80
|
+
from simple_module_hosting.manifest import write_module_pages_manifest
|
|
81
|
+
|
|
82
|
+
client_app = project_root / "host" / "client_app"
|
|
83
|
+
if client_app.is_dir():
|
|
84
|
+
write_module_pages_manifest(modules, client_app)
|
|
85
|
+
except Exception:
|
|
86
|
+
logger.exception("Failed to write module pages manifest — frontend may miss pages")
|
|
@@ -9,24 +9,24 @@ from http import HTTPStatus
|
|
|
9
9
|
from fastapi.encoders import jsonable_encoder
|
|
10
10
|
from fastapi.exceptions import RequestValidationError
|
|
11
11
|
from fastapi.responses import JSONResponse
|
|
12
|
-
from
|
|
12
|
+
from simple_module_core.exceptions import NotFoundError
|
|
13
|
+
from simple_module_inertia import (
|
|
13
14
|
Inertia,
|
|
14
15
|
InertiaConfig,
|
|
15
16
|
InertiaVersionConflictException,
|
|
16
17
|
inertia_version_conflict_exception_handler,
|
|
17
18
|
)
|
|
18
|
-
from simple_module_core.exceptions import NotFoundError
|
|
19
19
|
from starlette.exceptions import HTTPException
|
|
20
20
|
from starlette.requests import Request
|
|
21
21
|
from starlette.responses import Response
|
|
22
22
|
|
|
23
23
|
from simple_module_hosting._inertia_shared import _INERTIA_HEADER
|
|
24
|
-
from simple_module_hosting.
|
|
24
|
+
from simple_module_hosting._observability import CorrelationIdMiddleware
|
|
25
25
|
from simple_module_hosting.permissions import PERMISSION_DENIED_PREFIX
|
|
26
26
|
|
|
27
27
|
logger = logging.getLogger(__name__)
|
|
28
28
|
|
|
29
|
-
_INERTIA_ERROR_STATUSES = frozenset({401, 403, 404, 419, 422, 429, 500, 503})
|
|
29
|
+
_INERTIA_ERROR_STATUSES = frozenset({401, 403, 404, 413, 419, 422, 429, 500, 503})
|
|
30
30
|
|
|
31
31
|
# Statuses whose remedy is "sign in", so the page offers that as its primary
|
|
32
32
|
# action rather than sending the visitor to the landing page.
|
|
@@ -174,20 +174,17 @@ async def render_error_page(
|
|
|
174
174
|
# that raises while reporting an error leaves the caller with nothing.
|
|
175
175
|
config: InertiaConfig = request.app.state.sm.inertia_config
|
|
176
176
|
# Prefer the app's configured dependency over constructing Inertia
|
|
177
|
-
# directly
|
|
178
|
-
#
|
|
179
|
-
#
|
|
180
|
-
#
|
|
181
|
-
#
|
|
182
|
-
#
|
|
183
|
-
# through the same url-relativizing patch directly: a fallback that
|
|
184
|
-
# skipped it would reintroduce the exact bug this module exists to
|
|
185
|
-
# fix, just for the one request that hit it before setup finished.
|
|
177
|
+
# directly, so the error page is built the same way as every other
|
|
178
|
+
# page. The raw construction is the fallback for a half-built app —
|
|
179
|
+
# the case this whole handler exists to survive. Both paths emit a
|
|
180
|
+
# root-relative page url: the adapter does that by construction now,
|
|
181
|
+
# so the fallback no longer needs a separate patch to avoid the
|
|
182
|
+
# cross-scheme `pushState` SecurityError behind a TLS proxy.
|
|
186
183
|
inertia_dep = getattr(request.app.state, "inertia_dependency", None)
|
|
187
184
|
if inertia_dep is not None:
|
|
188
185
|
inertia = inertia_dep(request, None)
|
|
189
186
|
else:
|
|
190
|
-
inertia =
|
|
187
|
+
inertia = Inertia(request, config)
|
|
191
188
|
# This does not go through get_inertia, so the share step has to be
|
|
192
189
|
# repeated here. Without it the error page renders raw translation keys
|
|
193
190
|
# (host.error.not_found_title) and loses auth/menus, so a signed-in
|
|
@@ -282,5 +279,16 @@ async def request_validation_error_handler(
|
|
|
282
279
|
async def unhandled_exception_handler(request: Request, exc: Exception) -> Response:
|
|
283
280
|
logger.exception("Unhandled exception: %s", exc)
|
|
284
281
|
if _wants_json(request):
|
|
285
|
-
|
|
286
|
-
|
|
282
|
+
response: Response = JSONResponse(
|
|
283
|
+
status_code=500, content={"detail": "Internal Server Error"}
|
|
284
|
+
)
|
|
285
|
+
else:
|
|
286
|
+
response = await render_error_page(request, 500, "")
|
|
287
|
+
# A catch-all `Exception` handler runs in Starlette's ServerErrorMiddleware,
|
|
288
|
+
# which wraps *outside* CorrelationIdMiddleware, so the response never
|
|
289
|
+
# passes through it. Without this the 500 — the one response a caller most
|
|
290
|
+
# needs to quote back to support — is the one without the header.
|
|
291
|
+
cid = getattr(request.state, "correlation_id", "")
|
|
292
|
+
if cid:
|
|
293
|
+
response.headers.setdefault(CorrelationIdMiddleware.HEADER, cid)
|
|
294
|
+
return response
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Construct the i18n registry from module, host and UI sources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from simple_module_core import ModuleBase
|
|
8
|
+
from simple_module_core.i18n import I18nRegistry
|
|
9
|
+
|
|
10
|
+
from simple_module_hosting.settings import Settings
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_i18n_registry(
|
|
14
|
+
settings: Settings,
|
|
15
|
+
modules: list[ModuleBase],
|
|
16
|
+
project_root: Path,
|
|
17
|
+
) -> tuple[I18nRegistry, list[tuple[str, str, Path]]]:
|
|
18
|
+
"""Construct the i18n registry from module + host + UI sources.
|
|
19
|
+
|
|
20
|
+
Returns ``(registry, extra_sources)`` where ``extra_sources`` is the list
|
|
21
|
+
of ``(reporter_name, namespace, dir)`` triples the diagnostic runner
|
|
22
|
+
needs to validate non-module locale directories.
|
|
23
|
+
"""
|
|
24
|
+
registry = I18nRegistry(
|
|
25
|
+
default_locale=settings.i18n_default_locale,
|
|
26
|
+
supported_locales=settings.i18n_supported_locales,
|
|
27
|
+
)
|
|
28
|
+
extra_sources: list[tuple[str, str, Path]] = []
|
|
29
|
+
|
|
30
|
+
for mod in modules:
|
|
31
|
+
audience = getattr(mod.meta, "i18n_audience", "public")
|
|
32
|
+
for namespace, locale_dir in mod.locale_dirs().items():
|
|
33
|
+
registry.add_source(namespace, locale_dir, audience=audience)
|
|
34
|
+
|
|
35
|
+
# The framework's own catalog — the setup wizard ships with this package,
|
|
36
|
+
# so its strings must too, whatever host is serving it.
|
|
37
|
+
hosting_locales = Path(__file__).resolve().parent / "locales"
|
|
38
|
+
registry.add_source("hosting", hosting_locales)
|
|
39
|
+
extra_sources.append(("simple_module_hosting", "hosting", hosting_locales))
|
|
40
|
+
|
|
41
|
+
host_locales = project_root / "host" / "locales"
|
|
42
|
+
if host_locales.is_dir():
|
|
43
|
+
registry.add_source("host", host_locales)
|
|
44
|
+
extra_sources.append(("host", "host", host_locales))
|
|
45
|
+
|
|
46
|
+
overrides = host_locales / "overrides"
|
|
47
|
+
if overrides.is_dir():
|
|
48
|
+
registry.add_overrides(overrides)
|
|
49
|
+
|
|
50
|
+
# Shared UI strings ship inside this package (not ``packages/ui``) so a
|
|
51
|
+
# scaffolded app, which has no ``packages/ui`` checkout, still resolves
|
|
52
|
+
# every ``ui.*`` key.
|
|
53
|
+
ui_locales = Path(__file__).resolve().parent / "ui_locales"
|
|
54
|
+
registry.add_source("ui", ui_locales)
|
|
55
|
+
extra_sources.append(("simple_module_hosting/ui", "ui", ui_locales))
|
|
56
|
+
|
|
57
|
+
registry.load()
|
|
58
|
+
return registry, extra_sources
|