simple-module-hosting 0.0.37__tar.gz → 0.0.39__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.37 → simple_module_hosting-0.0.39}/.gitignore +1 -2
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/PKG-INFO +15 -13
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/pyproject.toml +15 -13
- simple_module_hosting-0.0.39/simple_module_hosting/_api_version.py +37 -0
- simple_module_hosting-0.0.39/simple_module_hosting/_app_boot.py +107 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/_body_limit.py +1 -1
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/_boot_checks.py +20 -2
- simple_module_hosting-0.0.39/simple_module_hosting/_cors.py +76 -0
- simple_module_hosting-0.0.39/simple_module_hosting/_deferred_startup.py +65 -0
- simple_module_hosting-0.0.39/simple_module_hosting/_error_handlers.py +75 -0
- simple_module_hosting-0.0.39/simple_module_hosting/_error_negotiation.py +65 -0
- simple_module_hosting-0.0.39/simple_module_hosting/_error_page.py +156 -0
- simple_module_hosting-0.0.39/simple_module_hosting/_forwarded_host.py +135 -0
- simple_module_hosting-0.0.39/simple_module_hosting/_host_settings_support.py +108 -0
- simple_module_hosting-0.0.39/simple_module_hosting/_hydrate_step.py +51 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/_inertia_setup.py +13 -4
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/_inertia_shared.py +38 -4
- simple_module_hosting-0.0.39/simple_module_hosting/_insecure_transport.py +66 -0
- simple_module_hosting-0.0.39/simple_module_hosting/_lease_redis.py +149 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/_lifespan.py +21 -59
- simple_module_hosting-0.0.37/simple_module_hosting/_phase_helpers.py → simple_module_hosting-0.0.39/simple_module_hosting/_middleware_pipeline.py +49 -101
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/_module_routes.py +11 -8
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/_openapi.py +51 -4
- simple_module_hosting-0.0.39/simple_module_hosting/_phase_helpers.py +110 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/_preapp_config.py +89 -80
- simple_module_hosting-0.0.39/simple_module_hosting/_process_services.py +18 -0
- simple_module_hosting-0.0.39/simple_module_hosting/_rate_limit.py +142 -0
- simple_module_hosting-0.0.39/simple_module_hosting/_rate_limit_keys.py +67 -0
- simple_module_hosting-0.0.39/simple_module_hosting/_rate_limit_store.py +126 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/_registrations.py +42 -5
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/_request_guard_responses.py +2 -1
- simple_module_hosting-0.0.39/simple_module_hosting/_route_access.py +106 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/_route_guard.py +19 -17
- simple_module_hosting-0.0.39/simple_module_hosting/_route_paths.py +72 -0
- simple_module_hosting-0.0.39/simple_module_hosting/_secret_key.py +211 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/_settings_registration.py +5 -20
- simple_module_hosting-0.0.39/simple_module_hosting/_spa_mount.py +149 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/_tenant.py +25 -8
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/app_builder.py +38 -82
- simple_module_hosting-0.0.39/simple_module_hosting/db_options.py +32 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/health.py +2 -1
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/host_settings.py +92 -75
- simple_module_hosting-0.0.39/simple_module_hosting/leases.py +55 -0
- simple_module_hosting-0.0.39/simple_module_hosting/locales/de.json +28 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/locales/en.json +1 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/locales/es.json +2 -1
- simple_module_hosting-0.0.39/simple_module_hosting/log_sinks.py +238 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/logging.py +2 -3
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/maintenance.py +6 -29
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/manifest.py +3 -1
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/middleware.py +6 -3
- simple_module_hosting-0.0.39/simple_module_hosting/pagination.py +60 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/permissions.py +5 -12
- simple_module_hosting-0.0.39/simple_module_hosting/public_access.py +33 -0
- simple_module_hosting-0.0.39/simple_module_hosting/rate_limit_deps.py +46 -0
- simple_module_hosting-0.0.39/simple_module_hosting/resource_deps.py +106 -0
- simple_module_hosting-0.0.39/simple_module_hosting/sentry.py +182 -0
- simple_module_hosting-0.0.39/simple_module_hosting/sentry_scrub.py +153 -0
- simple_module_hosting-0.0.39/simple_module_hosting/sentry_scrub_text.py +110 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/session.py +8 -0
- simple_module_hosting-0.0.39/simple_module_hosting/settings.py +15 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/setup_gate.py +15 -9
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/setup_wizard/components/ConnectionList.tsx +10 -16
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/setup_wizard/components/StepForm.tsx +15 -22
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/setup_wizard/migrate.py +2 -2
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/shared_props.py +7 -4
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/tenancy.py +3 -2
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/token_guard.py +40 -1
- simple_module_hosting-0.0.39/simple_module_hosting/ui_locales/de.json +110 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/ui_locales/en.json +6 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/ui_locales/es.json +6 -0
- simple_module_hosting-0.0.39/tests/_hosting_settings_db.py +51 -0
- simple_module_hosting-0.0.39/tests/_resacc_models.py +79 -0
- simple_module_hosting-0.0.39/tests/_sentry_transport.py +34 -0
- simple_module_hosting-0.0.39/tests/conftest.py +15 -0
- simple_module_hosting-0.0.39/tests/test_boot_config_hardening.py +150 -0
- simple_module_hosting-0.0.39/tests/test_build_time_settings_marked.py +75 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_csp_module_sources.py +2 -1
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_error_page_shared_props.py +50 -2
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_error_status_coverage.py +1 -2
- simple_module_hosting-0.0.39/tests/test_forwarded_host.py +158 -0
- simple_module_hosting-0.0.39/tests/test_hosting_cors_api.py +61 -0
- simple_module_hosting-0.0.39/tests/test_hosting_cors_unset.py +19 -0
- simple_module_hosting-0.0.39/tests/test_hosting_csrf_shared_prop.py +85 -0
- simple_module_hosting-0.0.39/tests/test_hosting_error_handling_policy.py +72 -0
- simple_module_hosting-0.0.39/tests/test_hosting_lease_backends.py +169 -0
- simple_module_hosting-0.0.39/tests/test_hosting_lease_selection.py +48 -0
- simple_module_hosting-0.0.39/tests/test_hosting_log_sinks.py +209 -0
- simple_module_hosting-0.0.39/tests/test_hosting_openapi_security.py +114 -0
- simple_module_hosting-0.0.39/tests/test_hosting_pagination_deps.py +51 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_hosting_permissions.py +2 -2
- simple_module_hosting-0.0.39/tests/test_hosting_public_access.py +70 -0
- simple_module_hosting-0.0.39/tests/test_hosting_sentry.py +136 -0
- simple_module_hosting-0.0.39/tests/test_hosting_sentry_scrub.py +180 -0
- simple_module_hosting-0.0.39/tests/test_hosting_sentry_scrub_gaps.py +210 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_i18n_routes.py +1 -1
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_inertia_i18n_shared_props.py +8 -3
- simple_module_hosting-0.0.39/tests/test_insecure_transport_warning.py +86 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_invalidation_wiring.py +13 -13
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_lifespan_deferred_startup.py +4 -3
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_lifespan_order.py +2 -2
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_logging.py +8 -8
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_menu_providers_wiring.py +1 -1
- simple_module_hosting-0.0.39/tests/test_merge_host_settings_strict.py +52 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_middleware_order.py +4 -1
- simple_module_hosting-0.0.39/tests/test_native_link_prefixes_strip.py +13 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_preapp_config.py +6 -30
- simple_module_hosting-0.0.39/tests/test_preapp_invalid_values.py +64 -0
- simple_module_hosting-0.0.39/tests/test_preapp_read_failures.py +80 -0
- simple_module_hosting-0.0.39/tests/test_rate_limit_rules_redis.py +25 -0
- simple_module_hosting-0.0.39/tests/test_rate_limit_rules_registry.py +191 -0
- simple_module_hosting-0.0.39/tests/test_rate_limit_store_shared.py +149 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_request_guard_rate_limit.py +3 -2
- simple_module_hosting-0.0.39/tests/test_resource_deps.py +134 -0
- simple_module_hosting-0.0.39/tests/test_resource_policy_owned.py +113 -0
- simple_module_hosting-0.0.39/tests/test_resource_policy_parity.py +68 -0
- simple_module_hosting-0.0.39/tests/test_resource_types_wiring.py +31 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_route_guard.py +1 -1
- simple_module_hosting-0.0.39/tests/test_route_guard_fastapi_contract.py +159 -0
- simple_module_hosting-0.0.39/tests/test_route_paths.py +89 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_secret_key_bootstrap.py +3 -22
- simple_module_hosting-0.0.39/tests/test_server_rendered_head.py +63 -0
- simple_module_hosting-0.0.39/tests/test_session_cookie_secure.py +81 -0
- simple_module_hosting-0.0.39/tests/test_spa_mounts_541.py +271 -0
- simple_module_hosting-0.0.39/tests/test_tenant_resolver_types.py +70 -0
- simple_module_hosting-0.0.39/tests-js/hosting_SetupWizard.test.tsx +93 -0
- simple_module_hosting-0.0.37/simple_module_hosting/_error_handlers.py +0 -294
- simple_module_hosting-0.0.37/simple_module_hosting/_hydrate_step.py +0 -39
- simple_module_hosting-0.0.37/simple_module_hosting/_rate_limit.py +0 -187
- simple_module_hosting-0.0.37/simple_module_hosting/_secret_key.py +0 -154
- simple_module_hosting-0.0.37/simple_module_hosting/settings.py +0 -10
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/LICENSE +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/README.md +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/__init__.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/__main__.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/_anonymous.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/_csp_dynamic.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/_db_health.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/_dev_boot.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/_favicon.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/_host_services.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/_i18n_build.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/_i18n_target.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/_inertia_cache.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/_observability.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/_project_root.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/assets.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/bootstrap_settings.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/csrf.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/host_cli.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/i18n_deps.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/i18n_manifest.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/i18n_middleware.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/i18n_routes.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/inertia_deps.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/inertia_utils.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/migrations.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/page_globs.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/py.typed +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/redirects.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/setup_wizard/__init__.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/setup_wizard/pages/Wizard.tsx +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/setup_wizard/payloads.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/setup_wizard/routes.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/static_files.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_api_error_negotiation.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_app.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_auth_provider_setting.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_boot_drift_check.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_branding_head.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_check_migrations.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_check_migrations_expression_note.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_csp_providers.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_db_health_check.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_design_packs_wiring.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_diagnostics_state.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_favicon_default.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_grant_sources.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_health.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_home_url_default.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_host_cli.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_host_settings_fields.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_hosting_csrf.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_hosting_error_json_and_session_cookie.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_hosting_openapi.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_i18n_build_overrides.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_i18n_manifest.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_i18n_manifest_published_target.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_i18n_types_cover_installed_modules.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_i18n_ui_namespace.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_inertia_cache.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_inertia_shared_providers.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_locale_middleware.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_maintenance_mode.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_manifest.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_menu_urls_are_canonical.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_migration_listing.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_migration_multihead.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_precompressed_static.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_principal_permissions_claim.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_proxy_headers.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_redirects.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_request_guard_auth_parity.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_request_guard_body_limit.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_request_guard_body_limit_middleware_read.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_request_guard_body_limit_started.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_response_compression.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_session_cookie_security.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_session_cookie_window.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_session_vary_dedupe.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_settings_i18n.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_settings_secrets.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_setup_gate.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_setup_gate_single_flight.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_setup_migrate_error_redaction.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_setup_public_routes.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_setup_registry.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_setup_routes.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_sqlite_path_anchor.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_static_caching.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_strict_discovery_wiring.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_tenancy_api.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_tenant_middleware.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_tenant_resolver_diagnostic.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_tenant_scope_helpers.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_tenant_source.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_token_guard_effective_route.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_token_route_guard.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_translator_dep.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tests/test_view_prefixes.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/tsconfig.json +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.39
|
|
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
|
|
@@ -21,19 +21,21 @@ Classifier: Topic :: Internet :: WWW/HTTP :: WSGI :: Application
|
|
|
21
21
|
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
22
22
|
Classifier: Typing :: Typed
|
|
23
23
|
Requires-Python: >=3.12
|
|
24
|
-
Requires-Dist: click
|
|
25
|
-
Requires-Dist: fastapi
|
|
26
|
-
Requires-Dist: httpx
|
|
27
|
-
Requires-Dist: itsdangerous
|
|
28
|
-
Requires-Dist: jinja2
|
|
29
|
-
Requires-Dist: simple-module-core==0.0.
|
|
30
|
-
Requires-Dist: simple-module-db==0.0.
|
|
31
|
-
Requires-Dist: simple-module-inertia==0.0.
|
|
32
|
-
Requires-Dist: starlette
|
|
33
|
-
Requires-Dist: tomlkit
|
|
34
|
-
Requires-Dist: uvicorn[standard]
|
|
24
|
+
Requires-Dist: click<9,>=8.1
|
|
25
|
+
Requires-Dist: fastapi<0.157,>=0.140
|
|
26
|
+
Requires-Dist: httpx<0.29,>=0.27
|
|
27
|
+
Requires-Dist: itsdangerous<3,>=2.0
|
|
28
|
+
Requires-Dist: jinja2<4,>=3.1
|
|
29
|
+
Requires-Dist: simple-module-core==0.0.39
|
|
30
|
+
Requires-Dist: simple-module-db==0.0.39
|
|
31
|
+
Requires-Dist: simple-module-inertia==0.0.39
|
|
32
|
+
Requires-Dist: starlette<2,>=0.44
|
|
33
|
+
Requires-Dist: tomlkit<0.16,>=0.13
|
|
34
|
+
Requires-Dist: uvicorn[standard]<0.55,>=0.34
|
|
35
35
|
Provides-Extra: redis
|
|
36
|
-
Requires-Dist: redis
|
|
36
|
+
Requires-Dist: redis<7,>=5; extra == 'redis'
|
|
37
|
+
Provides-Extra: sentry
|
|
38
|
+
Requires-Dist: sentry-sdk[celery,fastapi]<3,>=2.20; extra == 'sentry'
|
|
37
39
|
Description-Content-Type: text/markdown
|
|
38
40
|
|
|
39
41
|
# simple_module_hosting
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_hosting"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.39"
|
|
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"
|
|
@@ -21,25 +21,27 @@ classifiers = [
|
|
|
21
21
|
"Typing :: Typed",
|
|
22
22
|
]
|
|
23
23
|
dependencies = [
|
|
24
|
-
"click>=8.1",
|
|
25
|
-
"fastapi>=0.
|
|
26
|
-
"httpx>=0.27",
|
|
24
|
+
"click>=8.1,<9",
|
|
25
|
+
"fastapi>=0.140,<0.157",
|
|
26
|
+
"httpx>=0.27,<0.29",
|
|
27
27
|
# SessionMiddleware (starlette.middleware.sessions) imports itsdangerous at
|
|
28
28
|
# module import time; starlette treats it as an optional extra, so declare
|
|
29
29
|
# it directly — out-of-tree hosts have nothing else pulling it in.
|
|
30
|
-
"itsdangerous>=2.0",
|
|
31
|
-
"jinja2>=3.1",
|
|
32
|
-
"simple_module_core==0.0.
|
|
33
|
-
"simple_module_db==0.0.
|
|
34
|
-
"simple_module_inertia==0.0.
|
|
35
|
-
"starlette>=0.44",
|
|
36
|
-
"tomlkit>=0.13",
|
|
37
|
-
"uvicorn[standard]>=0.34",
|
|
30
|
+
"itsdangerous>=2.0,<3",
|
|
31
|
+
"jinja2>=3.1,<4",
|
|
32
|
+
"simple_module_core==0.0.39",
|
|
33
|
+
"simple_module_db==0.0.39",
|
|
34
|
+
"simple_module_inertia==0.0.39",
|
|
35
|
+
"starlette>=0.44,<2",
|
|
36
|
+
"tomlkit>=0.13,<0.16",
|
|
37
|
+
"uvicorn[standard]>=0.34,<0.55",
|
|
38
38
|
]
|
|
39
39
|
|
|
40
40
|
[project.optional-dependencies]
|
|
41
41
|
# Shared rate-limiter store (SM_REDIS_URL). Without it the limiter counts per worker.
|
|
42
|
-
redis = ["redis>=5"]
|
|
42
|
+
redis = ["redis>=5,<7"]
|
|
43
|
+
# Error reporting (SM_SENTRY_DSN). Never imported unless the DSN is set.
|
|
44
|
+
sentry = ["sentry-sdk[fastapi,celery]>=2.20,<3"]
|
|
43
45
|
|
|
44
46
|
[project.scripts]
|
|
45
47
|
sm-host = "simple_module_hosting.host_cli:app"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Default ``HostSettings.api_version``: the host project's own version."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def default_api_version() -> str:
|
|
7
|
+
"""The host project's version, else ``0.0.0``.
|
|
8
|
+
|
|
9
|
+
This repo's host is the installed ``simple-module-host`` distribution. A
|
|
10
|
+
scaffolded host is ``package = false`` (never installed), so there is no
|
|
11
|
+
distribution to ask: read ``[project].version`` from its ``pyproject.toml``
|
|
12
|
+
under ``SM_PROJECT_ROOT`` (or the cwd) instead.
|
|
13
|
+
"""
|
|
14
|
+
import tomllib
|
|
15
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
16
|
+
|
|
17
|
+
from simple_module_core.dotenv import find_env_file
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
return version("simple-module-host")
|
|
21
|
+
except PackageNotFoundError:
|
|
22
|
+
pass
|
|
23
|
+
# Same project-root convention as .env (SM_PROJECT_ROOT, else a bounded walk
|
|
24
|
+
# up from the cwd), so a CLI run from host/ finds the same project as the app.
|
|
25
|
+
root = find_env_file().parent
|
|
26
|
+
for candidate in (root / "host" / "pyproject.toml", root / "pyproject.toml"):
|
|
27
|
+
try:
|
|
28
|
+
project = tomllib.loads(candidate.read_text(encoding="utf-8")).get("project")
|
|
29
|
+
except (OSError, ValueError): # missing/unreadable, bad UTF-8, bad TOML
|
|
30
|
+
continue
|
|
31
|
+
if not isinstance(project, dict):
|
|
32
|
+
continue
|
|
33
|
+
# The first file declaring a [project] is the host's: a dynamic version
|
|
34
|
+
# must not fall through to an unrelated (workspace-root) project's.
|
|
35
|
+
found = project.get("version")
|
|
36
|
+
return found if isinstance(found, str) and found else "0.0.0"
|
|
37
|
+
return "0.0.0"
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Boot-phase helpers for ``create_app``: app construction, database init, static mount."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from fastapi import Depends, FastAPI
|
|
9
|
+
from simple_module_core.design_packs import DesignPackRegistry
|
|
10
|
+
from simple_module_core.feature_flags import FeatureFlagRegistry
|
|
11
|
+
from simple_module_core.health import HealthRegistry
|
|
12
|
+
from simple_module_core.menu import MenuRegistry
|
|
13
|
+
from simple_module_core.permissions import PermissionRegistry
|
|
14
|
+
from simple_module_core.public_routes import PublicRouteRegistry
|
|
15
|
+
from simple_module_db.listeners import register_listeners
|
|
16
|
+
from simple_module_db.session import init_db
|
|
17
|
+
|
|
18
|
+
from simple_module_hosting._db_health import register_database_check
|
|
19
|
+
from simple_module_hosting._openapi import (
|
|
20
|
+
API_OPENAPI_URL,
|
|
21
|
+
install_openapi_filter,
|
|
22
|
+
make_unique_id_function,
|
|
23
|
+
openapi_served,
|
|
24
|
+
)
|
|
25
|
+
from simple_module_hosting._process_services import configure_process_services
|
|
26
|
+
from simple_module_hosting.db_options import database_options
|
|
27
|
+
from simple_module_hosting.settings import Settings
|
|
28
|
+
from simple_module_hosting.static_files import PrecompressedStaticFiles
|
|
29
|
+
from simple_module_hosting.token_guard import token_route_guard
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger("simple_module_hosting.app_builder")
|
|
32
|
+
|
|
33
|
+
_DOCS_URL = "/api/docs"
|
|
34
|
+
_REDOC_URL = "/api/redoc"
|
|
35
|
+
_STATIC_MOUNT_PATH = "/static"
|
|
36
|
+
_STATIC_DIR_NAME = "static"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_fastapi_app(settings: Settings, lifespan) -> FastAPI:
|
|
40
|
+
serve_openapi = openapi_served(
|
|
41
|
+
is_development=settings.is_development, openapi_public=settings.openapi_public
|
|
42
|
+
)
|
|
43
|
+
app = FastAPI(
|
|
44
|
+
title=settings.api_title,
|
|
45
|
+
version=settings.api_version,
|
|
46
|
+
description=settings.api_description,
|
|
47
|
+
openapi_url=API_OPENAPI_URL if serve_openapi else None,
|
|
48
|
+
generate_unique_id_function=make_unique_id_function(),
|
|
49
|
+
docs_url=_DOCS_URL if settings.is_development else None,
|
|
50
|
+
redoc_url=_REDOC_URL if settings.is_development else None,
|
|
51
|
+
lifespan=lifespan,
|
|
52
|
+
# API tokens reach only permission-gated, non-page routes (or ones
|
|
53
|
+
# marked ``TokenAllowed``); see ``token_guard``.
|
|
54
|
+
dependencies=[Depends(token_route_guard)],
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
install_openapi_filter(app) # AuthMiddleware reads openapi_url: anonymous iff served
|
|
58
|
+
app.state.openapi_url = API_OPENAPI_URL if serve_openapi else None
|
|
59
|
+
return app
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def log_registration_summary(
|
|
63
|
+
menu_registry: MenuRegistry,
|
|
64
|
+
perm_registry: PermissionRegistry,
|
|
65
|
+
ff_registry: FeatureFlagRegistry,
|
|
66
|
+
health_registry: HealthRegistry,
|
|
67
|
+
public_route_registry: PublicRouteRegistry,
|
|
68
|
+
design_pack_registry: DesignPackRegistry,
|
|
69
|
+
) -> None:
|
|
70
|
+
logger.info(
|
|
71
|
+
"Registered %d menu items, %d permissions, %d feature flags, "
|
|
72
|
+
"%d health checks, %d public routes, %d design packs",
|
|
73
|
+
len(menu_registry.all_items),
|
|
74
|
+
len(perm_registry.all_permissions),
|
|
75
|
+
len(ff_registry.all_flags),
|
|
76
|
+
len(health_registry.all_checks),
|
|
77
|
+
len(public_route_registry.routes),
|
|
78
|
+
len(design_pack_registry.all()),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def init_database(settings: Settings, health_registry: HealthRegistry):
|
|
83
|
+
db_state = init_db(
|
|
84
|
+
settings.database_url,
|
|
85
|
+
echo=settings.debug,
|
|
86
|
+
**database_options(settings),
|
|
87
|
+
)
|
|
88
|
+
# Multi-tenant installs fail closed: a tenant-scoped query with no tenant
|
|
89
|
+
# context raises instead of reading every tenant's rows.
|
|
90
|
+
db_state.tenant_strict = settings.multi_tenant
|
|
91
|
+
# Where unbound / all_tenants() writes land on a single-tenant install:
|
|
92
|
+
# its ``default_tenant`` when set, so they stay visible to its requests.
|
|
93
|
+
if not settings.multi_tenant and settings.default_tenant:
|
|
94
|
+
db_state.default_tenant_id = settings.default_tenant
|
|
95
|
+
register_listeners(db_state)
|
|
96
|
+
configure_process_services(settings.redis_url, engine=db_state.engine)
|
|
97
|
+
# The host's own readiness signal, and the only probe-safe check in a
|
|
98
|
+
# default install — module checks reach third parties and are on-demand.
|
|
99
|
+
register_database_check(health_registry, db_state)
|
|
100
|
+
return db_state
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def mount_host_static(app: FastAPI, project_root: Path) -> None:
|
|
104
|
+
static_dir = project_root / "host" / _STATIC_DIR_NAME
|
|
105
|
+
if static_dir.is_dir():
|
|
106
|
+
static = PrecompressedStaticFiles(directory=static_dir)
|
|
107
|
+
app.mount(_STATIC_MOUNT_PATH, static, name=_STATIC_DIR_NAME)
|
{simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/_body_limit.py
RENAMED
|
@@ -22,7 +22,7 @@ from starlette.exceptions import HTTPException
|
|
|
22
22
|
from starlette.requests import Request
|
|
23
23
|
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
24
24
|
|
|
25
|
-
from simple_module_hosting.
|
|
25
|
+
from simple_module_hosting._error_negotiation import _wants_json
|
|
26
26
|
from simple_module_hosting._request_guard_responses import send_guard_response
|
|
27
27
|
|
|
28
28
|
logger = logging.getLogger("simple_module.request_guard")
|
{simple_module_hosting-0.0.37 → simple_module_hosting-0.0.39}/simple_module_hosting/_boot_checks.py
RENAMED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
"""Diagnostics that need the built app, so cannot run from ``make doctor``.
|
|
2
2
|
|
|
3
|
+
SM025 runs in every environment; SM031 (an unguarded ``/api`` route) needs the
|
|
4
|
+
finished route table, so it runs at the end of a development boot only.
|
|
5
|
+
|
|
3
6
|
The module diagnostics in Phase 2 see only module classes and the source tree.
|
|
4
7
|
A check about what modules *registered* has to wait until after Phase 5, and
|
|
5
8
|
unlike the source-tree checks it is meaningful in production too.
|
|
@@ -10,8 +13,11 @@ from __future__ import annotations
|
|
|
10
13
|
import logging
|
|
11
14
|
from typing import TYPE_CHECKING, Any
|
|
12
15
|
|
|
16
|
+
from simple_module_core.app_state import get_tenant_resolver
|
|
13
17
|
from simple_module_core.diagnostics import Diagnostic, check_tenant_resolver, print_diagnostics
|
|
14
18
|
|
|
19
|
+
from simple_module_hosting._route_access import check_unguarded_api_routes
|
|
20
|
+
|
|
15
21
|
if TYPE_CHECKING:
|
|
16
22
|
from fastapi import FastAPI
|
|
17
23
|
from simple_module_core.services import DiagnosticsState
|
|
@@ -22,7 +28,7 @@ logger = logging.getLogger(__name__)
|
|
|
22
28
|
def _tenant_checks(app: FastAPI, settings: Any) -> list[Diagnostic]:
|
|
23
29
|
return check_tenant_resolver(
|
|
24
30
|
multi_tenant=bool(getattr(settings, "multi_tenant", False)),
|
|
25
|
-
resolver=
|
|
31
|
+
resolver=get_tenant_resolver(app),
|
|
26
32
|
)
|
|
27
33
|
|
|
28
34
|
|
|
@@ -44,4 +50,16 @@ def report_tenant_resolution(
|
|
|
44
50
|
return found
|
|
45
51
|
|
|
46
52
|
|
|
47
|
-
|
|
53
|
+
def report_unguarded_routes(app: FastAPI, diagnostics_state: DiagnosticsState) -> list[Diagnostic]:
|
|
54
|
+
"""SM031 (dev boot only; needs the finished route table): print and fold into Doctor."""
|
|
55
|
+
found = check_unguarded_api_routes(app)
|
|
56
|
+
if found:
|
|
57
|
+
print_diagnostics(found)
|
|
58
|
+
runner = diagnostics_state.runner
|
|
59
|
+
if runner is not None:
|
|
60
|
+
diagnostics_state.runner = lambda: [*runner(), *check_unguarded_api_routes(app)]
|
|
61
|
+
diagnostics_state.results = [*diagnostics_state.results, *found]
|
|
62
|
+
return found
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
__all__ = ["report_tenant_resolution", "report_unguarded_routes"]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""CORS dispatch (#527).
|
|
2
|
+
|
|
3
|
+
One Starlette ``CORSMiddleware`` is built per distinct :class:`CorsPolicy`; each
|
|
4
|
+
request is routed to the one whose policy the registry matches for its method and
|
|
5
|
+
path (most specific wins). A request no policy covers passes through untouched,
|
|
6
|
+
so a path nobody opted in never gains CORS headers.
|
|
7
|
+
|
|
8
|
+
Installed between RequestLogging and BodyLimit, so a preflight is answered before
|
|
9
|
+
auth, rate limit, setup or maintenance can reject it, and a 413 from BodyLimit
|
|
10
|
+
still carries the CORS headers the browser needs to read it.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from simple_module_core.cors import CorsPolicy, CorsPolicyRegistry
|
|
16
|
+
from starlette.middleware.cors import CORSMiddleware
|
|
17
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
18
|
+
|
|
19
|
+
from simple_module_hosting.host_settings import HostSettings
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def host_policy(settings: HostSettings) -> CorsPolicy | None:
|
|
23
|
+
"""The ``/api/`` policy from ``cors_*`` settings; ``None`` when no origin is set."""
|
|
24
|
+
# Every field is read even when unset: they are restart-required, and the
|
|
25
|
+
# build-time-settings guard learns that from the reads.
|
|
26
|
+
fields = (
|
|
27
|
+
settings.cors_allow_origins,
|
|
28
|
+
settings.cors_allow_credentials,
|
|
29
|
+
settings.cors_allow_methods,
|
|
30
|
+
settings.cors_allow_headers,
|
|
31
|
+
settings.cors_expose_headers,
|
|
32
|
+
settings.cors_max_age,
|
|
33
|
+
)
|
|
34
|
+
origins, credentials, methods, headers, expose, max_age = fields
|
|
35
|
+
if not origins:
|
|
36
|
+
return None
|
|
37
|
+
return CorsPolicy(
|
|
38
|
+
origins=tuple(origins),
|
|
39
|
+
allow_credentials=credentials,
|
|
40
|
+
methods=tuple(methods),
|
|
41
|
+
headers=tuple(headers),
|
|
42
|
+
expose_headers=tuple(expose),
|
|
43
|
+
max_age=max_age,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CorsMiddleware:
|
|
48
|
+
def __init__(self, app: ASGIApp, *, registry: CorsPolicyRegistry) -> None:
|
|
49
|
+
self.app = app
|
|
50
|
+
self.registry = registry
|
|
51
|
+
self._handlers: dict[CorsPolicy, ASGIApp] = {}
|
|
52
|
+
|
|
53
|
+
def _handler(self, policy: CorsPolicy) -> ASGIApp:
|
|
54
|
+
handler = self._handlers.get(policy)
|
|
55
|
+
if handler is None:
|
|
56
|
+
handler = CORSMiddleware(
|
|
57
|
+
self.app,
|
|
58
|
+
allow_origins=list(policy.origins),
|
|
59
|
+
allow_credentials=policy.allow_credentials,
|
|
60
|
+
allow_methods=list(policy.methods),
|
|
61
|
+
allow_headers=list(policy.headers),
|
|
62
|
+
expose_headers=list(policy.expose_headers),
|
|
63
|
+
max_age=policy.max_age,
|
|
64
|
+
)
|
|
65
|
+
self._handlers[policy] = handler
|
|
66
|
+
return handler
|
|
67
|
+
|
|
68
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
69
|
+
if scope["type"] != "http" or len(self.registry) == 0:
|
|
70
|
+
await self.app(scope, receive, send)
|
|
71
|
+
return
|
|
72
|
+
policy = self.registry.match(scope["method"], scope["path"])
|
|
73
|
+
if policy is None:
|
|
74
|
+
await self.app(scope, receive, send)
|
|
75
|
+
return
|
|
76
|
+
await self._handler(policy)(scope, receive, send)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Settings hydration and deferred startup replay, independent of setup gates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI
|
|
9
|
+
from simple_module_core.app_state import get_deferred_startup, get_deferred_startup_lock
|
|
10
|
+
from simple_module_core.module_settings import get_module_settings_registry
|
|
11
|
+
from simple_module_db.stored_settings import SessionOverrideSource, settings_table_exists
|
|
12
|
+
|
|
13
|
+
from simple_module_hosting._hydrate_step import hydrate_all
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def hydrate_settings_from_db(app: FastAPI) -> None:
|
|
19
|
+
"""Merge DB-stored overrides into every registered settings object.
|
|
20
|
+
|
|
21
|
+
Reads the settings table directly (``simple_module_db.stored_settings``),
|
|
22
|
+
so it works whether or not the ``settings`` plugin is enabled (#500). A
|
|
23
|
+
missing table is a fresh install: nothing is stored yet, so there is
|
|
24
|
+
nothing to merge.
|
|
25
|
+
|
|
26
|
+
Note this is the *second* settings read of a boot. The first happens in
|
|
27
|
+
``_preapp_config`` before ``create_app`` builds anything, because the
|
|
28
|
+
module list, i18n registry and middleware stack are all constructed from
|
|
29
|
+
settings and cannot be rebuilt from here.
|
|
30
|
+
"""
|
|
31
|
+
if get_module_settings_registry(app) is None:
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
async with app.state.sm.db.session_factory() as session:
|
|
35
|
+
if not await settings_table_exists(session):
|
|
36
|
+
return
|
|
37
|
+
await hydrate_all(app, SessionOverrideSource(session), session)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def run_deferred_startup(app: FastAPI) -> None:
|
|
41
|
+
"""Replay the ``on_startup`` hooks that could not run on an unmigrated DB.
|
|
42
|
+
|
|
43
|
+
Called from the wizard's migrations action and from the setup gate's
|
|
44
|
+
schema re-check, so a worker that did not itself run the migrations (or an
|
|
45
|
+
operator's out-of-band ``make migrate``) still finishes starting its
|
|
46
|
+
modules. Serialised per app; a hook that fails again is logged and dropped
|
|
47
|
+
rather than raised, because this runs after the migrations have already
|
|
48
|
+
committed and a 500 there would help nobody.
|
|
49
|
+
"""
|
|
50
|
+
deferred = get_deferred_startup(app)
|
|
51
|
+
if not deferred:
|
|
52
|
+
return
|
|
53
|
+
lock = get_deferred_startup_lock(app)
|
|
54
|
+
if lock is None: # no await between check and set, so this cannot race
|
|
55
|
+
lock = app.state.deferred_startup_lock = asyncio.Lock()
|
|
56
|
+
async with lock:
|
|
57
|
+
if not deferred:
|
|
58
|
+
return
|
|
59
|
+
await hydrate_settings_from_db(app)
|
|
60
|
+
while deferred:
|
|
61
|
+
mod = deferred.pop(0)
|
|
62
|
+
try:
|
|
63
|
+
await mod.on_startup(app)
|
|
64
|
+
except Exception:
|
|
65
|
+
logger.exception("Deferred on_startup of %s failed after migrations", mod.meta.name)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Framework-wide exception handlers that render Inertia error pages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from fastapi.encoders import jsonable_encoder
|
|
8
|
+
from fastapi.exceptions import RequestValidationError
|
|
9
|
+
from fastapi.responses import JSONResponse
|
|
10
|
+
from simple_module_core.exceptions import NotFoundError
|
|
11
|
+
from simple_module_core.permission_denied import PermissionDenied
|
|
12
|
+
from starlette.exceptions import HTTPException
|
|
13
|
+
from starlette.requests import Request
|
|
14
|
+
from starlette.responses import Response
|
|
15
|
+
|
|
16
|
+
from simple_module_hosting._error_negotiation import _wants_json
|
|
17
|
+
from simple_module_hosting._error_page import render_error_page
|
|
18
|
+
from simple_module_hosting._observability import CorrelationIdMiddleware
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
_INERTIA_ERROR_STATUSES = frozenset({401, 403, 404, 413, 419, 422, 429, 500, 503})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def http_exception_handler(request: Request, exc: HTTPException) -> Response:
|
|
26
|
+
# Preserve exception headers (WWW-Authenticate, Retry-After, ...) the way
|
|
27
|
+
# FastAPI's stock handler does — on the rendered page as well as the JSON
|
|
28
|
+
# body, since both answer with the same status.
|
|
29
|
+
headers = getattr(exc, "headers", None)
|
|
30
|
+
if exc.status_code in _INERTIA_ERROR_STATUSES and not _wants_json(request):
|
|
31
|
+
detail = str(exc.detail) if exc.detail else ""
|
|
32
|
+
required = exc.permission if isinstance(exc, PermissionDenied) else None
|
|
33
|
+
return await render_error_page(request, exc.status_code, detail, headers, required)
|
|
34
|
+
return JSONResponse(
|
|
35
|
+
status_code=exc.status_code,
|
|
36
|
+
content={"detail": exc.detail},
|
|
37
|
+
headers=headers,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def not_found_error_handler(request: Request, exc: NotFoundError) -> Response:
|
|
42
|
+
if _wants_json(request):
|
|
43
|
+
return JSONResponse(status_code=404, content={"detail": str(exc)})
|
|
44
|
+
return await render_error_page(request, 404, str(exc))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def request_validation_error_handler(
|
|
48
|
+
request: Request, exc: RequestValidationError
|
|
49
|
+
) -> Response:
|
|
50
|
+
"""Return an Inertia error page for browser requests with invalid params.
|
|
51
|
+
|
|
52
|
+
Same negotiation rule as 403/404/500 (``_wants_json``), so one request
|
|
53
|
+
never sees two different error shapes depending on the error class.
|
|
54
|
+
"""
|
|
55
|
+
if _wants_json(request):
|
|
56
|
+
return JSONResponse(status_code=422, content={"detail": jsonable_encoder(exc.errors())})
|
|
57
|
+
return await render_error_page(request, 422, "The requested URL contains invalid parameters.")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def unhandled_exception_handler(request: Request, exc: Exception) -> Response:
|
|
61
|
+
logger.exception("Unhandled exception: %s", exc)
|
|
62
|
+
if _wants_json(request):
|
|
63
|
+
response: Response = JSONResponse(
|
|
64
|
+
status_code=500, content={"detail": "Internal Server Error"}
|
|
65
|
+
)
|
|
66
|
+
else:
|
|
67
|
+
response = await render_error_page(request, 500, "")
|
|
68
|
+
# A catch-all `Exception` handler runs in Starlette's ServerErrorMiddleware,
|
|
69
|
+
# which wraps *outside* CorrelationIdMiddleware, so the response never
|
|
70
|
+
# passes through it. Without this the 500 — the one response a caller most
|
|
71
|
+
# needs to quote back to support — is the one without the header.
|
|
72
|
+
cid = getattr(request.state, "correlation_id", "")
|
|
73
|
+
if cid:
|
|
74
|
+
response.headers.setdefault(CorrelationIdMiddleware.HEADER, cid)
|
|
75
|
+
return response
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Choose JSON or HTML for framework error responses."""
|
|
2
|
+
|
|
3
|
+
from starlette.requests import Request
|
|
4
|
+
|
|
5
|
+
from simple_module_hosting._inertia_shared import _INERTIA_HEADER
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _explicit_accept_q(accept: str, media_type: str) -> float | None:
|
|
9
|
+
"""Quality the Accept header gives ``media_type`` by explicit listing, or
|
|
10
|
+
``None`` when the caller never named it at all.
|
|
11
|
+
|
|
12
|
+
Only exact entries count (no ``*/*`` / ``type/*`` wildcards) — the
|
|
13
|
+
negotiation below cares whether the caller *named* html or json, and at
|
|
14
|
+
what preference, and separately whether they named it *at all* (``None``
|
|
15
|
+
vs. an explicit ``q=0``). Malformed q-values fall back to 1.0 per RFC 9110.
|
|
16
|
+
"""
|
|
17
|
+
for part in accept.split(","):
|
|
18
|
+
media, _, params = part.partition(";")
|
|
19
|
+
if media.strip().lower() != media_type:
|
|
20
|
+
continue
|
|
21
|
+
q = 1.0
|
|
22
|
+
for param in params.split(";"):
|
|
23
|
+
name, _, value = param.partition("=")
|
|
24
|
+
if name.strip().lower() == "q":
|
|
25
|
+
try:
|
|
26
|
+
q = float(value.strip())
|
|
27
|
+
except ValueError:
|
|
28
|
+
q = 1.0
|
|
29
|
+
return q
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _wants_json(request: Request) -> bool:
|
|
34
|
+
"""API callers get JSON error bodies; browser-shaped requests get the page.
|
|
35
|
+
|
|
36
|
+
A request that explicitly accepts ``text/html`` is a browser navigation —
|
|
37
|
+
and those reach ``/api/*`` too (OAuth login links, file-download hrefs) —
|
|
38
|
+
so it always gets the rendered page, unless the caller *prefers* JSON via
|
|
39
|
+
q-values (``application/json, text/html;q=0.5`` is an API client keeping
|
|
40
|
+
an html fallback). So does an Inertia visit (``X-Inertia`` header),
|
|
41
|
+
whatever its Accept — an Inertia client can only consume Inertia-protocol
|
|
42
|
+
responses, never a bare JSON body. Otherwise ``/api/*``, the documented
|
|
43
|
+
prefix for every module's JSON surface (``ModuleMeta.route_prefix``),
|
|
44
|
+
gets JSON — a bare ``fetch()`` sends ``Accept: */*`` — as does an
|
|
45
|
+
explicit ``Accept: application/json`` anywhere else.
|
|
46
|
+
|
|
47
|
+
``text/html;q=0`` rules html out *everywhere*, not just under ``/api/*``:
|
|
48
|
+
once the caller has explicitly said they won't accept html, this never
|
|
49
|
+
chooses the html page. The (already-moot) html fallback survives only
|
|
50
|
+
when json wasn't mentioned either — there's nothing else to offer, so a
|
|
51
|
+
view path keeps its pre-existing default and an ``/api/*`` path keeps
|
|
52
|
+
getting JSON either way.
|
|
53
|
+
"""
|
|
54
|
+
if request.headers.get(_INERTIA_HEADER):
|
|
55
|
+
return False
|
|
56
|
+
accept = request.headers.get("accept", "")
|
|
57
|
+
html_q = _explicit_accept_q(accept, "text/html")
|
|
58
|
+
json_q = _explicit_accept_q(accept, "application/json")
|
|
59
|
+
if html_q is not None and html_q > 0 and html_q >= (json_q or 0.0):
|
|
60
|
+
return False
|
|
61
|
+
path = request.url.path
|
|
62
|
+
is_api = path == "/api" or path.startswith("/api/")
|
|
63
|
+
if html_q == 0:
|
|
64
|
+
return is_api if json_q is None else True
|
|
65
|
+
return is_api or bool(json_q)
|