simple-module-hosting 0.0.35__tar.gz → 0.0.37__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.35 → simple_module_hosting-0.0.37}/.gitignore +1 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/PKG-INFO +6 -4
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/pyproject.toml +8 -4
- simple_module_hosting-0.0.37/simple_module_hosting/_anonymous.py +27 -0
- simple_module_hosting-0.0.37/simple_module_hosting/_body_limit.py +191 -0
- simple_module_hosting-0.0.37/simple_module_hosting/_boot_checks.py +47 -0
- simple_module_hosting-0.0.37/simple_module_hosting/_csp_dynamic.py +99 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/_dev_boot.py +3 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/_error_handlers.py +16 -4
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/_hydrate_step.py +1 -1
- simple_module_hosting-0.0.37/simple_module_hosting/_i18n_build.py +58 -0
- simple_module_hosting-0.0.37/simple_module_hosting/_i18n_target.py +40 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/_inertia_setup.py +5 -1
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/_inertia_shared.py +33 -4
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/_lifespan.py +54 -1
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/_module_routes.py +30 -6
- simple_module_hosting-0.0.37/simple_module_hosting/_openapi.py +98 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/_phase_helpers.py +32 -68
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/_preapp_config.py +1 -1
- simple_module_hosting-0.0.37/simple_module_hosting/_project_root.py +39 -0
- simple_module_hosting-0.0.37/simple_module_hosting/_rate_limit.py +187 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/_registrations.py +3 -0
- simple_module_hosting-0.0.37/simple_module_hosting/_request_guard_responses.py +50 -0
- simple_module_hosting-0.0.37/simple_module_hosting/_route_guard.py +100 -0
- simple_module_hosting-0.0.37/simple_module_hosting/_settings_registration.py +70 -0
- simple_module_hosting-0.0.37/simple_module_hosting/_tenant.py +223 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/app_builder.py +49 -45
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/assets.py +50 -1
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/bootstrap_settings.py +5 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/host_cli.py +41 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/host_settings.py +80 -1
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/i18n_manifest.py +77 -46
- simple_module_hosting-0.0.37/simple_module_hosting/i18n_routes.py +52 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/inertia_deps.py +6 -0
- simple_module_hosting-0.0.37/simple_module_hosting/locales/en.json +27 -0
- simple_module_hosting-0.0.37/simple_module_hosting/locales/es.json +27 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/maintenance.py +2 -2
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/manifest.py +7 -2
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/middleware.py +60 -19
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/migrations.py +28 -0
- simple_module_hosting-0.0.37/simple_module_hosting/permissions.py +265 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/session.py +19 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/setup_gate.py +60 -7
- simple_module_hosting-0.0.37/simple_module_hosting/setup_wizard/__init__.py +72 -0
- simple_module_hosting-0.0.37/simple_module_hosting/setup_wizard/components/ConnectionList.tsx +84 -0
- simple_module_hosting-0.0.37/simple_module_hosting/setup_wizard/components/StepForm.tsx +132 -0
- simple_module_hosting-0.0.37/simple_module_hosting/setup_wizard/migrate.py +92 -0
- simple_module_hosting-0.0.37/simple_module_hosting/setup_wizard/pages/Wizard.tsx +131 -0
- simple_module_hosting-0.0.37/simple_module_hosting/setup_wizard/payloads.py +117 -0
- simple_module_hosting-0.0.37/simple_module_hosting/setup_wizard/routes.py +135 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/shared_props.py +4 -4
- simple_module_hosting-0.0.37/simple_module_hosting/tenancy.py +92 -0
- simple_module_hosting-0.0.37/simple_module_hosting/token_guard.py +217 -0
- simple_module_hosting-0.0.37/simple_module_hosting/ui_locales/en.json +104 -0
- simple_module_hosting-0.0.37/simple_module_hosting/ui_locales/es.json +104 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_app.py +9 -10
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_branding_head.py +16 -0
- simple_module_hosting-0.0.37/tests/test_check_migrations_expression_note.py +30 -0
- simple_module_hosting-0.0.37/tests/test_csp_providers.py +163 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_error_page_shared_props.py +2 -2
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_error_status_coverage.py +10 -6
- simple_module_hosting-0.0.37/tests/test_grant_sources.py +122 -0
- simple_module_hosting-0.0.37/tests/test_home_url_default.py +35 -0
- simple_module_hosting-0.0.37/tests/test_hosting_error_json_and_session_cookie.py +82 -0
- simple_module_hosting-0.0.37/tests/test_hosting_openapi.py +158 -0
- simple_module_hosting-0.0.37/tests/test_i18n_build_overrides.py +36 -0
- simple_module_hosting-0.0.37/tests/test_i18n_manifest_published_target.py +74 -0
- simple_module_hosting-0.0.37/tests/test_i18n_routes.py +113 -0
- simple_module_hosting-0.0.37/tests/test_i18n_ui_namespace.py +15 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_inertia_i18n_shared_props.py +1 -1
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_inertia_shared_providers.py +12 -0
- simple_module_hosting-0.0.37/tests/test_lifespan_deferred_startup.py +94 -0
- simple_module_hosting-0.0.37/tests/test_menu_providers_wiring.py +111 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_middleware_order.py +19 -2
- simple_module_hosting-0.0.37/tests/test_principal_permissions_claim.py +91 -0
- simple_module_hosting-0.0.37/tests/test_request_guard_auth_parity.py +116 -0
- simple_module_hosting-0.0.37/tests/test_request_guard_body_limit.py +181 -0
- simple_module_hosting-0.0.37/tests/test_request_guard_body_limit_middleware_read.py +41 -0
- simple_module_hosting-0.0.37/tests/test_request_guard_body_limit_started.py +67 -0
- simple_module_hosting-0.0.37/tests/test_request_guard_rate_limit.py +293 -0
- simple_module_hosting-0.0.37/tests/test_route_guard.py +131 -0
- simple_module_hosting-0.0.37/tests/test_session_vary_dedupe.py +39 -0
- simple_module_hosting-0.0.37/tests/test_setup_gate_single_flight.py +137 -0
- simple_module_hosting-0.0.37/tests/test_setup_migrate_error_redaction.py +55 -0
- simple_module_hosting-0.0.37/tests/test_setup_routes.py +135 -0
- simple_module_hosting-0.0.37/tests/test_tenancy_api.py +168 -0
- simple_module_hosting-0.0.37/tests/test_tenant_resolver_diagnostic.py +47 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_tenant_scope_helpers.py +17 -0
- simple_module_hosting-0.0.37/tests/test_tenant_source.py +171 -0
- simple_module_hosting-0.0.37/tests/test_token_guard_effective_route.py +150 -0
- simple_module_hosting-0.0.37/tests/test_token_route_guard.py +205 -0
- simple_module_hosting-0.0.37/tests/test_view_prefixes.py +39 -0
- simple_module_hosting-0.0.37/tsconfig.json +12 -0
- simple_module_hosting-0.0.35/simple_module_hosting/_tenant.py +0 -97
- simple_module_hosting-0.0.35/simple_module_hosting/permissions.py +0 -104
- simple_module_hosting-0.0.35/tests/test_setup_password_policy.py +0 -69
- simple_module_hosting-0.0.35/tests/test_setup_routes.py +0 -137
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/LICENSE +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/README.md +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/__init__.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/__main__.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/_db_health.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/_favicon.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/_host_services.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/_inertia_cache.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/_observability.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/_secret_key.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/csrf.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/health.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/i18n_deps.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/i18n_middleware.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/inertia_utils.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/logging.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/page_globs.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/py.typed +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/redirects.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/settings.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/static_files.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_api_error_negotiation.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_auth_provider_setting.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_boot_drift_check.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_check_migrations.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_csp_module_sources.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_db_health_check.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_design_packs_wiring.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_diagnostics_state.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_favicon_default.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_health.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_host_cli.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_host_settings_fields.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_hosting_csrf.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_hosting_permissions.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_i18n_manifest.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_i18n_types_cover_installed_modules.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_inertia_cache.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_invalidation_wiring.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_lifespan_order.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_locale_middleware.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_logging.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_maintenance_mode.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_manifest.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_menu_urls_are_canonical.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_migration_listing.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_migration_multihead.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_preapp_config.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_precompressed_static.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_proxy_headers.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_redirects.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_response_compression.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_secret_key_bootstrap.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_session_cookie_security.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_session_cookie_window.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_settings_i18n.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_settings_secrets.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_setup_gate.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_setup_public_routes.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_setup_registry.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_sqlite_path_anchor.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_static_caching.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_strict_discovery_wiring.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/tests/test_tenant_middleware.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/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.37
|
|
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
|
|
@@ -26,12 +26,14 @@ Requires-Dist: fastapi>=0.115
|
|
|
26
26
|
Requires-Dist: httpx>=0.27
|
|
27
27
|
Requires-Dist: itsdangerous>=2.0
|
|
28
28
|
Requires-Dist: jinja2>=3.1
|
|
29
|
-
Requires-Dist: simple-module-core==0.0.
|
|
30
|
-
Requires-Dist: simple-module-db==0.0.
|
|
31
|
-
Requires-Dist: simple-module-inertia==0.0.
|
|
29
|
+
Requires-Dist: simple-module-core==0.0.37
|
|
30
|
+
Requires-Dist: simple-module-db==0.0.37
|
|
31
|
+
Requires-Dist: simple-module-inertia==0.0.37
|
|
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.37"
|
|
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"
|
|
@@ -29,14 +29,18 @@ dependencies = [
|
|
|
29
29
|
# it directly — out-of-tree hosts have nothing else pulling it in.
|
|
30
30
|
"itsdangerous>=2.0",
|
|
31
31
|
"jinja2>=3.1",
|
|
32
|
-
"simple_module_core==0.0.
|
|
33
|
-
"simple_module_db==0.0.
|
|
34
|
-
"simple_module_inertia==0.0.
|
|
32
|
+
"simple_module_core==0.0.37",
|
|
33
|
+
"simple_module_db==0.0.37",
|
|
34
|
+
"simple_module_inertia==0.0.37",
|
|
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
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Demote a request's principal to the anonymous visitor, mid-request.
|
|
2
|
+
|
|
3
|
+
Used when a credential authenticated but must not act as its account here: an
|
|
4
|
+
API token on a public route (``token_guard``), or a token whose owner no
|
|
5
|
+
longer holds the permission it requires (``permissions``). "Anonymous" means
|
|
6
|
+
all of it — no user, no permissions, no audit identity, and the tenant an
|
|
7
|
+
anonymous request would have resolved to rather than the one the credential
|
|
8
|
+
pinned.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from simple_module_db.listeners import current_user_id
|
|
14
|
+
from starlette.requests import HTTPConnection
|
|
15
|
+
|
|
16
|
+
from simple_module_hosting._tenant import reresolve_tenant
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def demote_to_anonymous(request: HTTPConnection) -> None:
|
|
20
|
+
"""Drop the principal and re-run tenant resolution as anonymous."""
|
|
21
|
+
request.state.user = None
|
|
22
|
+
request.state.resolved_permissions = set()
|
|
23
|
+
current_user_id.set(None)
|
|
24
|
+
await reresolve_tenant(request)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__all__ = ["demote_to_anonymous"]
|
|
@@ -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)
|
{simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/_dev_boot.py
RENAMED
|
@@ -35,6 +35,7 @@ def run_dev_boot(
|
|
|
35
35
|
installed_modules: list[ModuleBase],
|
|
36
36
|
*,
|
|
37
37
|
i18n_extra,
|
|
38
|
+
i18n_unknown_overrides: dict[str, list[str]] | None = None,
|
|
38
39
|
diagnostics_state: DiagnosticsState,
|
|
39
40
|
project_root: Path,
|
|
40
41
|
) -> None:
|
|
@@ -54,6 +55,8 @@ def run_dev_boot(
|
|
|
54
55
|
i18n_supported_locales=settings.i18n_supported_locales,
|
|
55
56
|
i18n_default_locale=settings.i18n_default_locale,
|
|
56
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],
|
|
57
60
|
)
|
|
58
61
|
diagnostics = diagnostics_state.rerun()
|
|
59
62
|
errors = [d for d in diagnostics if d.level == DiagnosticLevel.ERROR]
|
|
@@ -21,11 +21,12 @@ 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._observability import CorrelationIdMiddleware
|
|
24
25
|
from simple_module_hosting.permissions import PERMISSION_DENIED_PREFIX
|
|
25
26
|
|
|
26
27
|
logger = logging.getLogger(__name__)
|
|
27
28
|
|
|
28
|
-
_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})
|
|
29
30
|
|
|
30
31
|
# Statuses whose remedy is "sign in", so the page offers that as its primary
|
|
31
32
|
# action rather than sending the visitor to the landing page.
|
|
@@ -186,7 +187,7 @@ async def render_error_page(
|
|
|
186
187
|
inertia = Inertia(request, config)
|
|
187
188
|
# This does not go through get_inertia, so the share step has to be
|
|
188
189
|
# repeated here. Without it the error page renders raw translation keys
|
|
189
|
-
# (
|
|
190
|
+
# (ui.error_page.not_found_title) and loses auth/menus, so a signed-in
|
|
190
191
|
# user's 404 has no layout.
|
|
191
192
|
shared = getattr(request.state, "inertia_shared", None)
|
|
192
193
|
if shared:
|
|
@@ -278,5 +279,16 @@ async def request_validation_error_handler(
|
|
|
278
279
|
async def unhandled_exception_handler(request: Request, exc: Exception) -> Response:
|
|
279
280
|
logger.exception("Unhandled exception: %s", exc)
|
|
280
281
|
if _wants_json(request):
|
|
281
|
-
|
|
282
|
-
|
|
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
|
{simple_module_hosting-0.0.35 → simple_module_hosting-0.0.37}/simple_module_hosting/_hydrate_step.py
RENAMED
|
@@ -28,7 +28,7 @@ async def hydrate_all(app: FastAPI, store: Any) -> None:
|
|
|
28
28
|
|
|
29
29
|
for package, cls in settings_services.module_registry.items():
|
|
30
30
|
try:
|
|
31
|
-
hydrated = await hydrate_settings(cls, store, package)
|
|
31
|
+
hydrated = await hydrate_settings(cls, store, package, lenient=True)
|
|
32
32
|
except Exception:
|
|
33
33
|
logger.exception("Hydrating %s failed; falling back to defaults", package)
|
|
34
34
|
continue
|
|
@@ -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
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Where generated i18n key files are written: monorepo checkout or published npm package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
RESOURCE_KEY_RE = re.compile(r"^\s+'([^']+)': '',$", re.MULTILINE)
|
|
9
|
+
|
|
10
|
+
_PUBLISHED_PKG = Path("node_modules") / "@simple-module-py" / "i18n" / "src"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def resolve_i18n_types_dir(project_root: Path) -> tuple[Path | None, bool]:
|
|
14
|
+
"""Where the generated key files go, and whether that is a published package.
|
|
15
|
+
|
|
16
|
+
The monorepo's ``packages/i18n/src`` wins. A scaffolded app has no such
|
|
17
|
+
checkout — ``@simple-module-py/i18n`` is an npm dependency — so walk up from
|
|
18
|
+
``project_root`` to the nearest ``node_modules`` holding it (npm workspaces
|
|
19
|
+
hoist it to the workspace root, above the host; a flat host installs into
|
|
20
|
+
``client_app/``). ``(None, False)`` when
|
|
21
|
+
neither exists, the normal shape of a wheel-installed prod app.
|
|
22
|
+
"""
|
|
23
|
+
local = project_root / "packages" / "i18n" / "src"
|
|
24
|
+
if local.is_dir():
|
|
25
|
+
return local, False
|
|
26
|
+
root = project_root.resolve()
|
|
27
|
+
# A flat host (``smpy new --flat``) keeps its npm install in client_app/.
|
|
28
|
+
for base in (root, root / "client_app", *root.parents):
|
|
29
|
+
published = base / _PUBLISHED_PKG
|
|
30
|
+
if published.is_dir():
|
|
31
|
+
return published, True
|
|
32
|
+
return None, False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def read_resource_keys(resources_path: Path) -> frozenset[str]:
|
|
36
|
+
"""Keys listed in an existing ``generated-resources.ts`` (empty if unreadable)."""
|
|
37
|
+
try:
|
|
38
|
+
return frozenset(RESOURCE_KEY_RE.findall(resources_path.read_text(encoding="utf-8")))
|
|
39
|
+
except OSError:
|
|
40
|
+
return frozenset()
|
|
@@ -48,7 +48,11 @@ def branding_head(request: Request) -> dict:
|
|
|
48
48
|
rather than ``None``, because omitting the tag sends the browser to
|
|
49
49
|
``/favicon.ico``, which this app does not serve.
|
|
50
50
|
"""
|
|
51
|
-
|
|
51
|
+
# A per-request object (same duck shape) wins: branding resolves a tenant's
|
|
52
|
+
# own theme into ``request.state.branding`` from its shared-props provider.
|
|
53
|
+
services = getattr(getattr(request, "state", None), "branding", None) or getattr(
|
|
54
|
+
request.app.state, "branding", None
|
|
55
|
+
)
|
|
52
56
|
settings = getattr(services, "settings", None)
|
|
53
57
|
app_name = getattr(settings, "app_name", "") or _DEFAULT_APP_NAME
|
|
54
58
|
accent = getattr(settings, "primary_color", "") or ""
|