simple-module-hosting 0.0.37__tar.gz → 0.0.39__tar.gz

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