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.
Files changed (231) hide show
  1. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/PKG-INFO +13 -13
  2. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/pyproject.toml +13 -13
  3. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_body_limit.py +1 -1
  4. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_boot_checks.py +2 -1
  5. simple_module_hosting-0.0.40/simple_module_hosting/_deferred_startup.py +65 -0
  6. simple_module_hosting-0.0.40/simple_module_hosting/_error_handlers.py +75 -0
  7. simple_module_hosting-0.0.40/simple_module_hosting/_error_negotiation.py +65 -0
  8. 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
  9. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_inertia_setup.py +13 -5
  10. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_inertia_shared.py +5 -4
  11. simple_module_hosting-0.0.40/simple_module_hosting/_lifespan.py +145 -0
  12. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_middleware_pipeline.py +2 -1
  13. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_rate_limit.py +8 -6
  14. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_request_guard_responses.py +2 -1
  15. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_route_access.py +4 -16
  16. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_spa_mount.py +4 -3
  17. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_tenant.py +2 -3
  18. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/app_builder.py +2 -0
  19. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/csrf.py +15 -2
  20. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/health.py +2 -1
  21. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/maintenance.py +6 -30
  22. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/middleware.py +2 -3
  23. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/permissions.py +2 -1
  24. simple_module_hosting-0.0.40/simple_module_hosting/public_access.py +33 -0
  25. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/sentry.py +13 -0
  26. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/session.py +22 -0
  27. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/setup_gate.py +15 -9
  28. simple_module_hosting-0.0.40/simple_module_hosting/setup_wizard/_migration_lock.py +53 -0
  29. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/setup_wizard/migrate.py +6 -6
  30. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/shared_props.py +7 -4
  31. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/tenancy.py +3 -2
  32. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/token_guard.py +2 -1
  33. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_error_status_coverage.py +1 -2
  34. simple_module_hosting-0.0.40/tests/test_hosting_asset_version.py +71 -0
  35. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_hosting_csrf.py +29 -0
  36. simple_module_hosting-0.0.40/tests/test_hosting_error_handling_policy.py +72 -0
  37. simple_module_hosting-0.0.40/tests/test_hosting_lifespan_cleanup.py +53 -0
  38. simple_module_hosting-0.0.40/tests/test_hosting_public_access.py +70 -0
  39. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_lifespan_deferred_startup.py +4 -3
  40. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_menu_providers_wiring.py +1 -1
  41. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_route_guard_fastapi_contract.py +31 -0
  42. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_setup_migrate_error_redaction.py +14 -2
  43. simple_module_hosting-0.0.40/tests/test_setup_migrate_worker_lock.py +74 -0
  44. simple_module_hosting-0.0.38/simple_module_hosting/_lifespan.py +0 -197
  45. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/.gitignore +0 -0
  46. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/LICENSE +0 -0
  47. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/README.md +0 -0
  48. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/__init__.py +0 -0
  49. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/__main__.py +0 -0
  50. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_anonymous.py +0 -0
  51. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_api_version.py +0 -0
  52. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_app_boot.py +0 -0
  53. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_cors.py +0 -0
  54. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_csp_dynamic.py +0 -0
  55. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_db_health.py +0 -0
  56. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_dev_boot.py +0 -0
  57. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_favicon.py +0 -0
  58. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_forwarded_host.py +0 -0
  59. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_host_services.py +0 -0
  60. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_host_settings_support.py +0 -0
  61. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_hydrate_step.py +0 -0
  62. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_i18n_build.py +0 -0
  63. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_i18n_target.py +0 -0
  64. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_inertia_cache.py +0 -0
  65. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_insecure_transport.py +0 -0
  66. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_lease_redis.py +0 -0
  67. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_module_routes.py +0 -0
  68. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_observability.py +0 -0
  69. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_openapi.py +0 -0
  70. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_phase_helpers.py +0 -0
  71. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_preapp_config.py +0 -0
  72. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_process_services.py +0 -0
  73. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_project_root.py +0 -0
  74. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_rate_limit_keys.py +0 -0
  75. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_rate_limit_store.py +0 -0
  76. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_registrations.py +0 -0
  77. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_route_guard.py +0 -0
  78. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_route_paths.py +0 -0
  79. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_secret_key.py +0 -0
  80. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/_settings_registration.py +0 -0
  81. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/assets.py +0 -0
  82. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/bootstrap_settings.py +0 -0
  83. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/db_options.py +0 -0
  84. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/host_cli.py +0 -0
  85. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/host_settings.py +0 -0
  86. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/i18n_deps.py +0 -0
  87. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/i18n_manifest.py +0 -0
  88. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/i18n_middleware.py +0 -0
  89. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/i18n_routes.py +0 -0
  90. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/inertia_deps.py +0 -0
  91. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/inertia_utils.py +0 -0
  92. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/leases.py +0 -0
  93. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/locales/de.json +0 -0
  94. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/locales/en.json +0 -0
  95. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/locales/es.json +0 -0
  96. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/log_sinks.py +0 -0
  97. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/logging.py +0 -0
  98. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/manifest.py +0 -0
  99. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/migrations.py +0 -0
  100. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/page_globs.py +0 -0
  101. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/pagination.py +0 -0
  102. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/py.typed +0 -0
  103. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/rate_limit_deps.py +0 -0
  104. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/redirects.py +0 -0
  105. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/resource_deps.py +0 -0
  106. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/sentry_scrub.py +0 -0
  107. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/sentry_scrub_text.py +0 -0
  108. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/settings.py +0 -0
  109. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/setup_wizard/__init__.py +0 -0
  110. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/setup_wizard/components/ConnectionList.tsx +0 -0
  111. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/setup_wizard/components/StepForm.tsx +0 -0
  112. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/setup_wizard/pages/Wizard.tsx +0 -0
  113. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/setup_wizard/payloads.py +0 -0
  114. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/setup_wizard/routes.py +0 -0
  115. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/static_files.py +0 -0
  116. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/ui_locales/de.json +0 -0
  117. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/ui_locales/en.json +0 -0
  118. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/simple_module_hosting/ui_locales/es.json +0 -0
  119. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/_hosting_settings_db.py +0 -0
  120. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/_resacc_models.py +0 -0
  121. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/_sentry_transport.py +0 -0
  122. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/conftest.py +0 -0
  123. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_api_error_negotiation.py +0 -0
  124. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_app.py +0 -0
  125. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_auth_provider_setting.py +0 -0
  126. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_boot_config_hardening.py +0 -0
  127. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_boot_drift_check.py +0 -0
  128. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_branding_head.py +0 -0
  129. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_build_time_settings_marked.py +0 -0
  130. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_check_migrations.py +0 -0
  131. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_check_migrations_expression_note.py +0 -0
  132. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_csp_module_sources.py +0 -0
  133. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_csp_providers.py +0 -0
  134. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_db_health_check.py +0 -0
  135. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_design_packs_wiring.py +0 -0
  136. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_diagnostics_state.py +0 -0
  137. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_error_page_shared_props.py +0 -0
  138. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_favicon_default.py +0 -0
  139. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_forwarded_host.py +0 -0
  140. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_grant_sources.py +0 -0
  141. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_health.py +0 -0
  142. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_home_url_default.py +0 -0
  143. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_host_cli.py +0 -0
  144. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_host_settings_fields.py +0 -0
  145. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_hosting_cors_api.py +0 -0
  146. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_hosting_cors_unset.py +0 -0
  147. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_hosting_csrf_shared_prop.py +0 -0
  148. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_hosting_error_json_and_session_cookie.py +0 -0
  149. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_hosting_lease_backends.py +0 -0
  150. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_hosting_lease_selection.py +0 -0
  151. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_hosting_log_sinks.py +0 -0
  152. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_hosting_openapi.py +0 -0
  153. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_hosting_openapi_security.py +0 -0
  154. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_hosting_pagination_deps.py +0 -0
  155. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_hosting_permissions.py +0 -0
  156. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_hosting_sentry.py +0 -0
  157. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_hosting_sentry_scrub.py +0 -0
  158. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_hosting_sentry_scrub_gaps.py +0 -0
  159. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_i18n_build_overrides.py +0 -0
  160. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_i18n_manifest.py +0 -0
  161. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_i18n_manifest_published_target.py +0 -0
  162. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_i18n_routes.py +0 -0
  163. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_i18n_types_cover_installed_modules.py +0 -0
  164. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_i18n_ui_namespace.py +0 -0
  165. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_inertia_cache.py +0 -0
  166. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_inertia_i18n_shared_props.py +0 -0
  167. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_inertia_shared_providers.py +0 -0
  168. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_insecure_transport_warning.py +0 -0
  169. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_invalidation_wiring.py +0 -0
  170. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_lifespan_order.py +0 -0
  171. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_locale_middleware.py +0 -0
  172. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_logging.py +0 -0
  173. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_maintenance_mode.py +0 -0
  174. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_manifest.py +0 -0
  175. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_menu_urls_are_canonical.py +0 -0
  176. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_merge_host_settings_strict.py +0 -0
  177. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_middleware_order.py +0 -0
  178. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_migration_listing.py +0 -0
  179. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_migration_multihead.py +0 -0
  180. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_native_link_prefixes_strip.py +0 -0
  181. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_preapp_config.py +0 -0
  182. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_preapp_invalid_values.py +0 -0
  183. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_preapp_read_failures.py +0 -0
  184. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_precompressed_static.py +0 -0
  185. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_principal_permissions_claim.py +0 -0
  186. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_proxy_headers.py +0 -0
  187. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_rate_limit_rules_redis.py +0 -0
  188. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_rate_limit_rules_registry.py +0 -0
  189. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_rate_limit_store_shared.py +0 -0
  190. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_redirects.py +0 -0
  191. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_request_guard_auth_parity.py +0 -0
  192. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_request_guard_body_limit.py +0 -0
  193. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_request_guard_body_limit_middleware_read.py +0 -0
  194. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_request_guard_body_limit_started.py +0 -0
  195. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_request_guard_rate_limit.py +0 -0
  196. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_resource_deps.py +0 -0
  197. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_resource_policy_owned.py +0 -0
  198. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_resource_policy_parity.py +0 -0
  199. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_resource_types_wiring.py +0 -0
  200. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_response_compression.py +0 -0
  201. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_route_guard.py +0 -0
  202. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_route_paths.py +0 -0
  203. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_secret_key_bootstrap.py +0 -0
  204. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_server_rendered_head.py +0 -0
  205. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_session_cookie_secure.py +0 -0
  206. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_session_cookie_security.py +0 -0
  207. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_session_cookie_window.py +0 -0
  208. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_session_vary_dedupe.py +0 -0
  209. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_settings_i18n.py +0 -0
  210. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_settings_secrets.py +0 -0
  211. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_setup_gate.py +0 -0
  212. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_setup_gate_single_flight.py +0 -0
  213. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_setup_public_routes.py +0 -0
  214. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_setup_registry.py +0 -0
  215. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_setup_routes.py +0 -0
  216. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_spa_mounts_541.py +0 -0
  217. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_sqlite_path_anchor.py +0 -0
  218. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_static_caching.py +0 -0
  219. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_strict_discovery_wiring.py +0 -0
  220. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_tenancy_api.py +0 -0
  221. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_tenant_middleware.py +0 -0
  222. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_tenant_resolver_diagnostic.py +0 -0
  223. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_tenant_resolver_types.py +0 -0
  224. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_tenant_scope_helpers.py +0 -0
  225. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_tenant_source.py +0 -0
  226. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_token_guard_effective_route.py +0 -0
  227. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_token_route_guard.py +0 -0
  228. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_translator_dep.py +0 -0
  229. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests/test_view_prefixes.py +0 -0
  230. {simple_module_hosting-0.0.38 → simple_module_hosting-0.0.40}/tests-js/hosting_SetupWizard.test.tsx +0 -0
  231. {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.38
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>=8.1
25
- Requires-Dist: fastapi<0.157,>=0.140
26
- Requires-Dist: httpx>=0.27
27
- Requires-Dist: itsdangerous>=2.0
28
- Requires-Dist: jinja2>=3.1
29
- Requires-Dist: simple-module-core==0.0.38
30
- Requires-Dist: simple-module-db==0.0.38
31
- Requires-Dist: simple-module-inertia==0.0.38
32
- Requires-Dist: starlette>=0.44
33
- Requires-Dist: tomlkit>=0.13
34
- Requires-Dist: uvicorn[standard]>=0.34
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>=5; extra == '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.38"
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.157",
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.38",
33
- "simple_module_db==0.0.38",
34
- "simple_module_inertia==0.0.38",
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
 
@@ -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._error_handlers import _wants_json
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")
@@ -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=getattr(app.state, "tenant_resolver", None),
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
- """Framework-wide exception handlers that render Inertia error pages."""
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.exceptions import NotFoundError
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
- auth_state = getattr(request.app.state, "auth", None)
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 = getattr(request.app.state, "inertia_dependency", None)
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 getattr(
54
- request.app.state, "branding", None
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 = getattr(request.app.state, "host", None)
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=_INERTIA_VERSION,
158
+ version=version,
151
159
  dev_url=settings.vite_dev_url if use_dev_server else "",
152
- manifest_json_path="" if use_dev_server else _prod_manifest_path(project_root),
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 = getattr(request.app.state, "sm", None)
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 = getattr(request.app.state, "sm", None)
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 = getattr(app.state, "inertia_shared_providers", None) or ()
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 = getattr(app.state, "sm", None)
191
+ sm = get_services(app)
191
192
  prefix = getattr(sm, "view_prefixes", {}).get("Dashboard")
192
193
  return f"{prefix}/" if prefix else "/"