simple-module-hosting 0.0.38__tar.gz → 0.0.40__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.38 → simple_module_hosting-0.0.40}/PKG-INFO +13 -13
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/pyproject.toml +13 -13
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_body_limit.py +1 -1
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_boot_checks.py +2 -1
- simple_module_hosting-0.0.40/simple_module_hosting/_deferred_startup.py +65 -0
- simple_module_hosting-0.0.40/simple_module_hosting/_error_handlers.py +75 -0
- simple_module_hosting-0.0.40/simple_module_hosting/_error_negotiation.py +65 -0
- simple_module_hosting-0.0.38/simple_module_hosting/_error_handlers.py → simple_module_hosting-0.0.40/simple_module_hosting/_error_page.py +4 -127
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_inertia_setup.py +13 -5
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_inertia_shared.py +5 -4
- simple_module_hosting-0.0.40/simple_module_hosting/_lifespan.py +145 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_middleware_pipeline.py +2 -1
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_rate_limit.py +8 -6
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_request_guard_responses.py +2 -1
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_route_access.py +4 -16
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_spa_mount.py +4 -3
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_tenant.py +2 -3
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/app_builder.py +2 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/csrf.py +15 -2
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/health.py +2 -1
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/maintenance.py +6 -30
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/middleware.py +2 -3
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/permissions.py +2 -1
- simple_module_hosting-0.0.40/simple_module_hosting/public_access.py +33 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/sentry.py +13 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/session.py +22 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/setup_gate.py +15 -9
- simple_module_hosting-0.0.40/simple_module_hosting/setup_wizard/_migration_lock.py +53 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/setup_wizard/migrate.py +6 -6
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/shared_props.py +7 -4
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/tenancy.py +3 -2
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/token_guard.py +2 -1
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_error_status_coverage.py +1 -2
- simple_module_hosting-0.0.40/tests/test_hosting_asset_version.py +71 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_hosting_csrf.py +29 -0
- simple_module_hosting-0.0.40/tests/test_hosting_error_handling_policy.py +72 -0
- simple_module_hosting-0.0.40/tests/test_hosting_lifespan_cleanup.py +53 -0
- simple_module_hosting-0.0.40/tests/test_hosting_public_access.py +70 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_lifespan_deferred_startup.py +4 -3
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_menu_providers_wiring.py +1 -1
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_route_guard_fastapi_contract.py +31 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_setup_migrate_error_redaction.py +14 -2
- simple_module_hosting-0.0.40/tests/test_setup_migrate_worker_lock.py +74 -0
- simple_module_hosting-0.0.38/simple_module_hosting/_lifespan.py +0 -197
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/.gitignore +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/LICENSE +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/README.md +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/__init__.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/__main__.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_anonymous.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_api_version.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_app_boot.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_cors.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_csp_dynamic.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_db_health.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_dev_boot.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_favicon.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_forwarded_host.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_host_services.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_host_settings_support.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_hydrate_step.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_i18n_build.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_i18n_target.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_inertia_cache.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_insecure_transport.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_lease_redis.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_module_routes.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_observability.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_openapi.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_phase_helpers.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_preapp_config.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_process_services.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_project_root.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_rate_limit_keys.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_rate_limit_store.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_registrations.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_route_guard.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_route_paths.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_secret_key.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_settings_registration.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/assets.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/bootstrap_settings.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/db_options.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/host_cli.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/host_settings.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/i18n_deps.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/i18n_manifest.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/i18n_middleware.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/i18n_routes.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/inertia_deps.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/inertia_utils.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/leases.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/locales/de.json +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/locales/en.json +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/locales/es.json +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/log_sinks.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/logging.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/manifest.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/migrations.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/page_globs.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/pagination.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/py.typed +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/rate_limit_deps.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/redirects.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/resource_deps.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/sentry_scrub.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/sentry_scrub_text.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/settings.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/setup_wizard/__init__.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/setup_wizard/components/ConnectionList.tsx +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/setup_wizard/components/StepForm.tsx +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/setup_wizard/pages/Wizard.tsx +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/setup_wizard/payloads.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/setup_wizard/routes.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/static_files.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/ui_locales/de.json +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/ui_locales/en.json +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/ui_locales/es.json +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/_hosting_settings_db.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/_resacc_models.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/_sentry_transport.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/conftest.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_api_error_negotiation.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_app.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_auth_provider_setting.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_boot_config_hardening.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_boot_drift_check.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_branding_head.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_build_time_settings_marked.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_check_migrations.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_check_migrations_expression_note.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_csp_module_sources.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_csp_providers.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_db_health_check.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_design_packs_wiring.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_diagnostics_state.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_error_page_shared_props.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_favicon_default.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_forwarded_host.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_grant_sources.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_health.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_home_url_default.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_host_cli.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_host_settings_fields.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_hosting_cors_api.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_hosting_cors_unset.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_hosting_csrf_shared_prop.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_hosting_error_json_and_session_cookie.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_hosting_lease_backends.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_hosting_lease_selection.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_hosting_log_sinks.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_hosting_openapi.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_hosting_openapi_security.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_hosting_pagination_deps.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_hosting_permissions.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_hosting_sentry.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_hosting_sentry_scrub.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_hosting_sentry_scrub_gaps.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_i18n_build_overrides.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_i18n_manifest.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_i18n_manifest_published_target.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_i18n_routes.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_i18n_types_cover_installed_modules.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_i18n_ui_namespace.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_inertia_cache.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_inertia_i18n_shared_props.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_inertia_shared_providers.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_insecure_transport_warning.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_invalidation_wiring.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_lifespan_order.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_locale_middleware.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_logging.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_maintenance_mode.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_manifest.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_menu_urls_are_canonical.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_merge_host_settings_strict.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_middleware_order.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_migration_listing.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_migration_multihead.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_native_link_prefixes_strip.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_preapp_config.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_preapp_invalid_values.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_preapp_read_failures.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_precompressed_static.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_principal_permissions_claim.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_proxy_headers.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_rate_limit_rules_redis.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_rate_limit_rules_registry.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_rate_limit_store_shared.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_redirects.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_request_guard_auth_parity.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_request_guard_body_limit.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_request_guard_body_limit_middleware_read.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_request_guard_body_limit_started.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_request_guard_rate_limit.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_resource_deps.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_resource_policy_owned.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_resource_policy_parity.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_resource_types_wiring.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_response_compression.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_route_guard.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_route_paths.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_secret_key_bootstrap.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_server_rendered_head.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_session_cookie_secure.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_session_cookie_security.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_session_cookie_window.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_session_vary_dedupe.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_settings_i18n.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_settings_secrets.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_setup_gate.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_setup_gate_single_flight.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_setup_public_routes.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_setup_registry.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_setup_routes.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_spa_mounts_541.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_sqlite_path_anchor.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_static_caching.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_strict_discovery_wiring.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_tenancy_api.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_tenant_middleware.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_tenant_resolver_diagnostic.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_tenant_resolver_types.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_tenant_scope_helpers.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_tenant_source.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_token_guard_effective_route.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_token_route_guard.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_translator_dep.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_view_prefixes.py +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests-js/hosting_SetupWizard.test.tsx +0 -0
- {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/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.40
|
|
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,19 @@ 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<0.
|
|
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.159,>=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.40
|
|
30
|
+
Requires-Dist: simple-module-db==0.0.40
|
|
31
|
+
Requires-Dist: simple-module-inertia==0.0.40
|
|
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
37
|
Provides-Extra: sentry
|
|
38
38
|
Requires-Dist: sentry-sdk[celery,fastapi]<3,>=2.20; extra == 'sentry'
|
|
39
39
|
Description-Content-Type: text/markdown
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_hosting"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.40"
|
|
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,25 @@ classifiers = [
|
|
|
21
21
|
"Typing :: Typed",
|
|
22
22
|
]
|
|
23
23
|
dependencies = [
|
|
24
|
-
"click>=8.1",
|
|
25
|
-
"fastapi>=0.140,<0.
|
|
26
|
-
"httpx>=0.27",
|
|
24
|
+
"click>=8.1,<9",
|
|
25
|
+
"fastapi>=0.140,<0.159",
|
|
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.40",
|
|
33
|
+
"simple_module_db==0.0.40",
|
|
34
|
+
"simple_module_inertia==0.0.40",
|
|
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
43
|
# Error reporting (SM_SENTRY_DSN). Never imported unless the DSN is set.
|
|
44
44
|
sentry = ["sentry-sdk[fastapi,celery]>=2.20,<3"]
|
|
45
45
|
|
{simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/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.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_boot_checks.py
RENAMED
|
@@ -13,6 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
import logging
|
|
14
14
|
from typing import TYPE_CHECKING, Any
|
|
15
15
|
|
|
16
|
+
from simple_module_core.app_state import get_tenant_resolver
|
|
16
17
|
from simple_module_core.diagnostics import Diagnostic, check_tenant_resolver, print_diagnostics
|
|
17
18
|
|
|
18
19
|
from simple_module_hosting._route_access import check_unguarded_api_routes
|
|
@@ -27,7 +28,7 @@ logger = logging.getLogger(__name__)
|
|
|
27
28
|
def _tenant_checks(app: FastAPI, settings: Any) -> list[Diagnostic]:
|
|
28
29
|
return check_tenant_resolver(
|
|
29
30
|
multi_tenant=bool(getattr(settings, "multi_tenant", False)),
|
|
30
|
-
resolver=
|
|
31
|
+
resolver=get_tenant_resolver(app),
|
|
31
32
|
)
|
|
32
33
|
|
|
33
34
|
|
|
@@ -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)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Render error responses without depending on handlers or middleware wiring."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -6,28 +6,19 @@ import logging
|
|
|
6
6
|
from collections.abc import Mapping
|
|
7
7
|
from http import HTTPStatus
|
|
8
8
|
|
|
9
|
-
from fastapi.encoders import jsonable_encoder
|
|
10
|
-
from fastapi.exceptions import RequestValidationError
|
|
11
9
|
from fastapi.responses import JSONResponse
|
|
12
|
-
from simple_module_core.
|
|
13
|
-
from simple_module_core.permission_denied import PermissionDenied
|
|
10
|
+
from simple_module_core.app_state import get_auth_provider, get_inertia_dependency
|
|
14
11
|
from simple_module_inertia import (
|
|
15
12
|
Inertia,
|
|
16
13
|
InertiaConfig,
|
|
17
14
|
InertiaVersionConflictException,
|
|
18
15
|
inertia_version_conflict_exception_handler,
|
|
19
16
|
)
|
|
20
|
-
from starlette.exceptions import HTTPException
|
|
21
17
|
from starlette.requests import Request
|
|
22
18
|
from starlette.responses import Response
|
|
23
19
|
|
|
24
|
-
from simple_module_hosting._inertia_shared import _INERTIA_HEADER
|
|
25
|
-
from simple_module_hosting._observability import CorrelationIdMiddleware
|
|
26
|
-
|
|
27
20
|
logger = logging.getLogger(__name__)
|
|
28
21
|
|
|
29
|
-
_INERTIA_ERROR_STATUSES = frozenset({401, 403, 404, 413, 419, 422, 429, 500, 503})
|
|
30
|
-
|
|
31
22
|
# Statuses whose remedy is "sign in", so the page offers that as its primary
|
|
32
23
|
# action rather than sending the visitor to the landing page.
|
|
33
24
|
_SIGN_IN_STATUSES = frozenset({401, 419})
|
|
@@ -62,8 +53,7 @@ def _login_url(request: Request) -> str | None:
|
|
|
62
53
|
concern and ``SM009`` forbids framework code importing ``modules/*``. An
|
|
63
54
|
app with no auth provider installed simply gets no sign-in button.
|
|
64
55
|
"""
|
|
65
|
-
|
|
66
|
-
provider = getattr(auth_state, "auth_provider", None)
|
|
56
|
+
provider = get_auth_provider(request.app)
|
|
67
57
|
if provider is None:
|
|
68
58
|
return None
|
|
69
59
|
try:
|
|
@@ -74,66 +64,6 @@ def _login_url(request: Request) -> str | None:
|
|
|
74
64
|
return None
|
|
75
65
|
|
|
76
66
|
|
|
77
|
-
def _explicit_accept_q(accept: str, media_type: str) -> float | None:
|
|
78
|
-
"""Quality the Accept header gives ``media_type`` by explicit listing, or
|
|
79
|
-
``None`` when the caller never named it at all.
|
|
80
|
-
|
|
81
|
-
Only exact entries count (no ``*/*`` / ``type/*`` wildcards) — the
|
|
82
|
-
negotiation below cares whether the caller *named* html or json, and at
|
|
83
|
-
what preference, and separately whether they named it *at all* (``None``
|
|
84
|
-
vs. an explicit ``q=0``). Malformed q-values fall back to 1.0 per RFC 9110.
|
|
85
|
-
"""
|
|
86
|
-
for part in accept.split(","):
|
|
87
|
-
media, _, params = part.partition(";")
|
|
88
|
-
if media.strip().lower() != media_type:
|
|
89
|
-
continue
|
|
90
|
-
q = 1.0
|
|
91
|
-
for param in params.split(";"):
|
|
92
|
-
name, _, value = param.partition("=")
|
|
93
|
-
if name.strip().lower() == "q":
|
|
94
|
-
try:
|
|
95
|
-
q = float(value.strip())
|
|
96
|
-
except ValueError:
|
|
97
|
-
q = 1.0
|
|
98
|
-
return q
|
|
99
|
-
return None
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def _wants_json(request: Request) -> bool:
|
|
103
|
-
"""API callers get JSON error bodies; browser-shaped requests get the page.
|
|
104
|
-
|
|
105
|
-
A request that explicitly accepts ``text/html`` is a browser navigation —
|
|
106
|
-
and those reach ``/api/*`` too (OAuth login links, file-download hrefs) —
|
|
107
|
-
so it always gets the rendered page, unless the caller *prefers* JSON via
|
|
108
|
-
q-values (``application/json, text/html;q=0.5`` is an API client keeping
|
|
109
|
-
an html fallback). So does an Inertia visit (``X-Inertia`` header),
|
|
110
|
-
whatever its Accept — an Inertia client can only consume Inertia-protocol
|
|
111
|
-
responses, never a bare JSON body. Otherwise ``/api/*``, the documented
|
|
112
|
-
prefix for every module's JSON surface (``ModuleMeta.route_prefix``),
|
|
113
|
-
gets JSON — a bare ``fetch()`` sends ``Accept: */*`` — as does an
|
|
114
|
-
explicit ``Accept: application/json`` anywhere else.
|
|
115
|
-
|
|
116
|
-
``text/html;q=0`` rules html out *everywhere*, not just under ``/api/*``:
|
|
117
|
-
once the caller has explicitly said they won't accept html, this never
|
|
118
|
-
chooses the html page. The (already-moot) html fallback survives only
|
|
119
|
-
when json wasn't mentioned either — there's nothing else to offer, so a
|
|
120
|
-
view path keeps its pre-existing default and an ``/api/*`` path keeps
|
|
121
|
-
getting JSON either way.
|
|
122
|
-
"""
|
|
123
|
-
if request.headers.get(_INERTIA_HEADER):
|
|
124
|
-
return False
|
|
125
|
-
accept = request.headers.get("accept", "")
|
|
126
|
-
html_q = _explicit_accept_q(accept, "text/html")
|
|
127
|
-
json_q = _explicit_accept_q(accept, "application/json")
|
|
128
|
-
if html_q is not None and html_q > 0 and html_q >= (json_q or 0.0):
|
|
129
|
-
return False
|
|
130
|
-
path = request.url.path
|
|
131
|
-
is_api = path == "/api" or path.startswith("/api/")
|
|
132
|
-
if html_q == 0:
|
|
133
|
-
return is_api if json_q is None else True
|
|
134
|
-
return is_api or bool(json_q)
|
|
135
|
-
|
|
136
|
-
|
|
137
67
|
async def render_error_page(
|
|
138
68
|
request: Request,
|
|
139
69
|
status_code: int,
|
|
@@ -164,7 +94,7 @@ async def render_error_page(
|
|
|
164
94
|
# root-relative page url: the adapter does that by construction now,
|
|
165
95
|
# so the fallback no longer needs a separate patch to avoid the
|
|
166
96
|
# cross-scheme `pushState` SecurityError behind a TLS proxy.
|
|
167
|
-
inertia_dep =
|
|
97
|
+
inertia_dep = get_inertia_dependency(request.app)
|
|
168
98
|
if inertia_dep is not None:
|
|
169
99
|
inertia = inertia_dep(request, None)
|
|
170
100
|
else:
|
|
@@ -224,56 +154,3 @@ def _apply_headers(response: Response, headers: Mapping[str, str] | None) -> Non
|
|
|
224
154
|
return
|
|
225
155
|
for key, value in headers.items():
|
|
226
156
|
response.headers[key] = value
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
async def http_exception_handler(request: Request, exc: HTTPException) -> Response:
|
|
230
|
-
# Preserve exception headers (WWW-Authenticate, Retry-After, ...) the way
|
|
231
|
-
# FastAPI's stock handler does — on the rendered page as well as the JSON
|
|
232
|
-
# body, since both answer with the same status.
|
|
233
|
-
headers = getattr(exc, "headers", None)
|
|
234
|
-
if exc.status_code in _INERTIA_ERROR_STATUSES and not _wants_json(request):
|
|
235
|
-
detail = str(exc.detail) if exc.detail else ""
|
|
236
|
-
required = exc.permission if isinstance(exc, PermissionDenied) else None
|
|
237
|
-
return await render_error_page(request, exc.status_code, detail, headers, required)
|
|
238
|
-
return JSONResponse(
|
|
239
|
-
status_code=exc.status_code,
|
|
240
|
-
content={"detail": exc.detail},
|
|
241
|
-
headers=headers,
|
|
242
|
-
)
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
async def not_found_error_handler(request: Request, exc: NotFoundError) -> Response:
|
|
246
|
-
if _wants_json(request):
|
|
247
|
-
return JSONResponse(status_code=404, content={"detail": str(exc)})
|
|
248
|
-
return await render_error_page(request, 404, str(exc))
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
async def request_validation_error_handler(
|
|
252
|
-
request: Request, exc: RequestValidationError
|
|
253
|
-
) -> Response:
|
|
254
|
-
"""Return an Inertia error page for browser requests with invalid params.
|
|
255
|
-
|
|
256
|
-
Same negotiation rule as 403/404/500 (``_wants_json``), so one request
|
|
257
|
-
never sees two different error shapes depending on the error class.
|
|
258
|
-
"""
|
|
259
|
-
if _wants_json(request):
|
|
260
|
-
return JSONResponse(status_code=422, content={"detail": jsonable_encoder(exc.errors())})
|
|
261
|
-
return await render_error_page(request, 422, "The requested URL contains invalid parameters.")
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
async def unhandled_exception_handler(request: Request, exc: Exception) -> Response:
|
|
265
|
-
logger.exception("Unhandled exception: %s", exc)
|
|
266
|
-
if _wants_json(request):
|
|
267
|
-
response: Response = JSONResponse(
|
|
268
|
-
status_code=500, content={"detail": "Internal Server Error"}
|
|
269
|
-
)
|
|
270
|
-
else:
|
|
271
|
-
response = await render_error_page(request, 500, "")
|
|
272
|
-
# A catch-all `Exception` handler runs in Starlette's ServerErrorMiddleware,
|
|
273
|
-
# which wraps *outside* CorrelationIdMiddleware, so the response never
|
|
274
|
-
# passes through it. Without this the 500 — the one response a caller most
|
|
275
|
-
# needs to quote back to support — is the one without the header.
|
|
276
|
-
cid = getattr(request.state, "correlation_id", "")
|
|
277
|
-
if cid:
|
|
278
|
-
response.headers.setdefault(CorrelationIdMiddleware.HEADER, cid)
|
|
279
|
-
return response
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import hashlib
|
|
5
6
|
import logging
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
|
|
8
9
|
from fastapi import FastAPI
|
|
10
|
+
from simple_module_core.app_state import get_branding, get_host_services
|
|
9
11
|
from simple_module_inertia import InertiaConfig, SiteInfo, inertia_dependency_factory
|
|
10
12
|
from starlette.requests import Request
|
|
11
13
|
|
|
@@ -50,8 +52,8 @@ def branding_head(request: Request) -> dict:
|
|
|
50
52
|
"""
|
|
51
53
|
# A per-request object (same duck shape) wins: branding resolves a tenant's
|
|
52
54
|
# own theme into ``request.state.branding`` from its shared-props provider.
|
|
53
|
-
services = getattr(getattr(request, "state", None), "branding", None) or
|
|
54
|
-
request.app
|
|
55
|
+
services = getattr(getattr(request, "state", None), "branding", None) or get_branding(
|
|
56
|
+
request.app
|
|
55
57
|
)
|
|
56
58
|
settings = getattr(services, "settings", None)
|
|
57
59
|
app_name = getattr(settings, "app_name", "") or _DEFAULT_APP_NAME
|
|
@@ -68,7 +70,7 @@ def branding_head(request: Request) -> dict:
|
|
|
68
70
|
|
|
69
71
|
def site_info(request: Request) -> SiteInfo:
|
|
70
72
|
"""The public base URL (operator setting, never the request ``Host``) and app name."""
|
|
71
|
-
host =
|
|
73
|
+
host = get_host_services(request.app)
|
|
72
74
|
base_url = getattr(getattr(host, "settings", None), "public_base_url", "") or ""
|
|
73
75
|
return SiteInfo(base_url=base_url, app_name=branding_head(request)["app_name"])
|
|
74
76
|
|
|
@@ -144,12 +146,18 @@ def setup_inertia(
|
|
|
144
146
|
|
|
145
147
|
use_dev_server = settings.environment in NON_PROD_ENVIRONMENTS
|
|
146
148
|
inertia_environment = "development" if use_dev_server else "production"
|
|
149
|
+
manifest_path = "" if use_dev_server else _prod_manifest_path(project_root)
|
|
150
|
+
version = (
|
|
151
|
+
hashlib.sha256(Path(manifest_path).read_bytes()).hexdigest()
|
|
152
|
+
if manifest_path
|
|
153
|
+
else _INERTIA_VERSION
|
|
154
|
+
)
|
|
147
155
|
|
|
148
156
|
inertia_config = InertiaConfig(
|
|
149
157
|
environment=inertia_environment,
|
|
150
|
-
version=
|
|
158
|
+
version=version,
|
|
151
159
|
dev_url=settings.vite_dev_url if use_dev_server else "",
|
|
152
|
-
manifest_json_path=
|
|
160
|
+
manifest_json_path=manifest_path,
|
|
153
161
|
assets_prefix="" if use_dev_server else _ASSETS_PREFIX,
|
|
154
162
|
templates=templates,
|
|
155
163
|
root_template_filename=_ROOT_TEMPLATE_FILENAME,
|
|
@@ -7,6 +7,7 @@ import logging
|
|
|
7
7
|
from collections.abc import Callable
|
|
8
8
|
from typing import Any
|
|
9
9
|
|
|
10
|
+
from simple_module_core.app_state import get_inertia_shared_providers, get_services
|
|
10
11
|
from simple_module_core.i18n import Translator
|
|
11
12
|
from starlette.datastructures import Headers
|
|
12
13
|
from starlette.requests import Request
|
|
@@ -45,7 +46,7 @@ def build_i18n_block(scope: Scope, request: Request, *, is_authenticated: bool =
|
|
|
45
46
|
"""
|
|
46
47
|
# Test fixtures sometimes build a bare FastAPI with a partial app.state.sm
|
|
47
48
|
# stub (e.g. permissions-only, no i18n); guard both lookups to keep them usable.
|
|
48
|
-
sm =
|
|
49
|
+
sm = get_services(request.app)
|
|
49
50
|
registry = getattr(sm, "i18n_registry", None) if sm is not None else None
|
|
50
51
|
locale = getattr(request.state, "locale", None)
|
|
51
52
|
if registry is None or locale is None:
|
|
@@ -138,7 +139,7 @@ def build_menu_translator(request: Request) -> Callable[[str], str] | None:
|
|
|
138
139
|
with a partial ``app.state.sm``, and a missing registry must degrade to the
|
|
139
140
|
literal labels rather than raise on every request.
|
|
140
141
|
"""
|
|
141
|
-
sm =
|
|
142
|
+
sm = get_services(request.app)
|
|
142
143
|
registry = getattr(sm, "i18n_registry", None) if sm is not None else None
|
|
143
144
|
locale = getattr(request.state, "locale", None)
|
|
144
145
|
if registry is None or locale is None:
|
|
@@ -158,7 +159,7 @@ async def merge_shared_prop_providers(app: Any, request: Request, shared: dict)
|
|
|
158
159
|
provider's key. A provider may be ``async`` (its result is awaited) — a
|
|
159
160
|
per-tenant lookup cannot be answered from a process-wide object.
|
|
160
161
|
"""
|
|
161
|
-
providers =
|
|
162
|
+
providers = get_inertia_shared_providers(app) or ()
|
|
162
163
|
for provider in providers:
|
|
163
164
|
name = getattr(provider, "__name__", provider)
|
|
164
165
|
try:
|
|
@@ -187,6 +188,6 @@ def default_home_url(app: Any) -> str:
|
|
|
187
188
|
named by string from ``sm.view_prefixes`` so nothing is imported (SM009);
|
|
188
189
|
``/`` is a host route in every app, so the link can never 404.
|
|
189
190
|
"""
|
|
190
|
-
sm =
|
|
191
|
+
sm = get_services(app)
|
|
191
192
|
prefix = getattr(sm, "view_prefixes", {}).get("Dashboard")
|
|
192
193
|
return f"{prefix}/" if prefix else "/"
|