simple-module-hosting 0.0.39__tar.gz → 0.0.41__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 (230) hide show
  1. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/PKG-INFO +5 -5
  2. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/pyproject.toml +5 -5
  3. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_inertia_setup.py +9 -2
  4. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_lifespan.py +44 -44
  5. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/csrf.py +15 -2
  6. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/session.py +22 -0
  7. simple_module_hosting-0.0.41/simple_module_hosting/setup_wizard/_migration_lock.py +53 -0
  8. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/setup_wizard/migrate.py +4 -4
  9. simple_module_hosting-0.0.41/tests/test_hosting_asset_version.py +71 -0
  10. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_hosting_csrf.py +29 -0
  11. simple_module_hosting-0.0.41/tests/test_hosting_lifespan_cleanup.py +53 -0
  12. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_setup_migrate_error_redaction.py +14 -2
  13. simple_module_hosting-0.0.41/tests/test_setup_migrate_worker_lock.py +74 -0
  14. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/.gitignore +0 -0
  15. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/LICENSE +0 -0
  16. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/README.md +0 -0
  17. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/__init__.py +0 -0
  18. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/__main__.py +0 -0
  19. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_anonymous.py +0 -0
  20. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_api_version.py +0 -0
  21. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_app_boot.py +0 -0
  22. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_body_limit.py +0 -0
  23. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_boot_checks.py +0 -0
  24. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_cors.py +0 -0
  25. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_csp_dynamic.py +0 -0
  26. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_db_health.py +0 -0
  27. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_deferred_startup.py +0 -0
  28. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_dev_boot.py +0 -0
  29. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_error_handlers.py +0 -0
  30. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_error_negotiation.py +0 -0
  31. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_error_page.py +0 -0
  32. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_favicon.py +0 -0
  33. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_forwarded_host.py +0 -0
  34. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_host_services.py +0 -0
  35. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_host_settings_support.py +0 -0
  36. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_hydrate_step.py +0 -0
  37. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_i18n_build.py +0 -0
  38. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_i18n_target.py +0 -0
  39. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_inertia_cache.py +0 -0
  40. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_inertia_shared.py +0 -0
  41. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_insecure_transport.py +0 -0
  42. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_lease_redis.py +0 -0
  43. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_middleware_pipeline.py +0 -0
  44. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_module_routes.py +0 -0
  45. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_observability.py +0 -0
  46. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_openapi.py +0 -0
  47. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_phase_helpers.py +0 -0
  48. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_preapp_config.py +0 -0
  49. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_process_services.py +0 -0
  50. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_project_root.py +0 -0
  51. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_rate_limit.py +0 -0
  52. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_rate_limit_keys.py +0 -0
  53. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_rate_limit_store.py +0 -0
  54. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_registrations.py +0 -0
  55. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_request_guard_responses.py +0 -0
  56. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_route_access.py +0 -0
  57. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_route_guard.py +0 -0
  58. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_route_paths.py +0 -0
  59. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_secret_key.py +0 -0
  60. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_settings_registration.py +0 -0
  61. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_spa_mount.py +0 -0
  62. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/_tenant.py +0 -0
  63. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/app_builder.py +0 -0
  64. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/assets.py +0 -0
  65. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/bootstrap_settings.py +0 -0
  66. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/db_options.py +0 -0
  67. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/health.py +0 -0
  68. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/host_cli.py +0 -0
  69. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/host_settings.py +0 -0
  70. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/i18n_deps.py +0 -0
  71. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/i18n_manifest.py +0 -0
  72. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/i18n_middleware.py +0 -0
  73. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/i18n_routes.py +0 -0
  74. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/inertia_deps.py +0 -0
  75. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/inertia_utils.py +0 -0
  76. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/leases.py +0 -0
  77. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/locales/de.json +0 -0
  78. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/locales/en.json +0 -0
  79. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/locales/es.json +0 -0
  80. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/log_sinks.py +0 -0
  81. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/logging.py +0 -0
  82. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/maintenance.py +0 -0
  83. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/manifest.py +0 -0
  84. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/middleware.py +0 -0
  85. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/migrations.py +0 -0
  86. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/page_globs.py +0 -0
  87. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/pagination.py +0 -0
  88. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/permissions.py +0 -0
  89. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/public_access.py +0 -0
  90. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/py.typed +0 -0
  91. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/rate_limit_deps.py +0 -0
  92. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/redirects.py +0 -0
  93. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/resource_deps.py +0 -0
  94. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/sentry.py +0 -0
  95. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/sentry_scrub.py +0 -0
  96. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/sentry_scrub_text.py +0 -0
  97. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/settings.py +0 -0
  98. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/setup_gate.py +0 -0
  99. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/setup_wizard/__init__.py +0 -0
  100. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/setup_wizard/components/ConnectionList.tsx +0 -0
  101. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/setup_wizard/components/StepForm.tsx +0 -0
  102. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/setup_wizard/pages/Wizard.tsx +0 -0
  103. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/setup_wizard/payloads.py +0 -0
  104. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/setup_wizard/routes.py +0 -0
  105. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/shared_props.py +0 -0
  106. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/static_files.py +0 -0
  107. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/tenancy.py +0 -0
  108. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/token_guard.py +0 -0
  109. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/ui_locales/de.json +0 -0
  110. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/ui_locales/en.json +0 -0
  111. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/simple_module_hosting/ui_locales/es.json +0 -0
  112. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/_hosting_settings_db.py +0 -0
  113. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/_resacc_models.py +0 -0
  114. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/_sentry_transport.py +0 -0
  115. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/conftest.py +0 -0
  116. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_api_error_negotiation.py +0 -0
  117. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_app.py +0 -0
  118. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_auth_provider_setting.py +0 -0
  119. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_boot_config_hardening.py +0 -0
  120. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_boot_drift_check.py +0 -0
  121. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_branding_head.py +0 -0
  122. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_build_time_settings_marked.py +0 -0
  123. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_check_migrations.py +0 -0
  124. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_check_migrations_expression_note.py +0 -0
  125. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_csp_module_sources.py +0 -0
  126. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_csp_providers.py +0 -0
  127. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_db_health_check.py +0 -0
  128. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_design_packs_wiring.py +0 -0
  129. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_diagnostics_state.py +0 -0
  130. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_error_page_shared_props.py +0 -0
  131. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_error_status_coverage.py +0 -0
  132. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_favicon_default.py +0 -0
  133. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_forwarded_host.py +0 -0
  134. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_grant_sources.py +0 -0
  135. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_health.py +0 -0
  136. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_home_url_default.py +0 -0
  137. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_host_cli.py +0 -0
  138. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_host_settings_fields.py +0 -0
  139. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_hosting_cors_api.py +0 -0
  140. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_hosting_cors_unset.py +0 -0
  141. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_hosting_csrf_shared_prop.py +0 -0
  142. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_hosting_error_handling_policy.py +0 -0
  143. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_hosting_error_json_and_session_cookie.py +0 -0
  144. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_hosting_lease_backends.py +0 -0
  145. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_hosting_lease_selection.py +0 -0
  146. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_hosting_log_sinks.py +0 -0
  147. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_hosting_openapi.py +0 -0
  148. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_hosting_openapi_security.py +0 -0
  149. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_hosting_pagination_deps.py +0 -0
  150. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_hosting_permissions.py +0 -0
  151. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_hosting_public_access.py +0 -0
  152. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_hosting_sentry.py +0 -0
  153. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_hosting_sentry_scrub.py +0 -0
  154. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_hosting_sentry_scrub_gaps.py +0 -0
  155. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_i18n_build_overrides.py +0 -0
  156. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_i18n_manifest.py +0 -0
  157. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_i18n_manifest_published_target.py +0 -0
  158. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_i18n_routes.py +0 -0
  159. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_i18n_types_cover_installed_modules.py +0 -0
  160. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_i18n_ui_namespace.py +0 -0
  161. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_inertia_cache.py +0 -0
  162. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_inertia_i18n_shared_props.py +0 -0
  163. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_inertia_shared_providers.py +0 -0
  164. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_insecure_transport_warning.py +0 -0
  165. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_invalidation_wiring.py +0 -0
  166. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_lifespan_deferred_startup.py +0 -0
  167. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_lifespan_order.py +0 -0
  168. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_locale_middleware.py +0 -0
  169. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_logging.py +0 -0
  170. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_maintenance_mode.py +0 -0
  171. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_manifest.py +0 -0
  172. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_menu_providers_wiring.py +0 -0
  173. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_menu_urls_are_canonical.py +0 -0
  174. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_merge_host_settings_strict.py +0 -0
  175. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_middleware_order.py +0 -0
  176. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_migration_listing.py +0 -0
  177. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_migration_multihead.py +0 -0
  178. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_native_link_prefixes_strip.py +0 -0
  179. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_preapp_config.py +0 -0
  180. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_preapp_invalid_values.py +0 -0
  181. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_preapp_read_failures.py +0 -0
  182. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_precompressed_static.py +0 -0
  183. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_principal_permissions_claim.py +0 -0
  184. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_proxy_headers.py +0 -0
  185. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_rate_limit_rules_redis.py +0 -0
  186. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_rate_limit_rules_registry.py +0 -0
  187. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_rate_limit_store_shared.py +0 -0
  188. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_redirects.py +0 -0
  189. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_request_guard_auth_parity.py +0 -0
  190. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_request_guard_body_limit.py +0 -0
  191. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_request_guard_body_limit_middleware_read.py +0 -0
  192. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_request_guard_body_limit_started.py +0 -0
  193. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_request_guard_rate_limit.py +0 -0
  194. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_resource_deps.py +0 -0
  195. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_resource_policy_owned.py +0 -0
  196. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_resource_policy_parity.py +0 -0
  197. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_resource_types_wiring.py +0 -0
  198. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_response_compression.py +0 -0
  199. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_route_guard.py +0 -0
  200. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_route_guard_fastapi_contract.py +0 -0
  201. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_route_paths.py +0 -0
  202. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_secret_key_bootstrap.py +0 -0
  203. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_server_rendered_head.py +0 -0
  204. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_session_cookie_secure.py +0 -0
  205. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_session_cookie_security.py +0 -0
  206. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_session_cookie_window.py +0 -0
  207. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_session_vary_dedupe.py +0 -0
  208. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_settings_i18n.py +0 -0
  209. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_settings_secrets.py +0 -0
  210. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_setup_gate.py +0 -0
  211. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_setup_gate_single_flight.py +0 -0
  212. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_setup_public_routes.py +0 -0
  213. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_setup_registry.py +0 -0
  214. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_setup_routes.py +0 -0
  215. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_spa_mounts_541.py +0 -0
  216. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_sqlite_path_anchor.py +0 -0
  217. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_static_caching.py +0 -0
  218. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_strict_discovery_wiring.py +0 -0
  219. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_tenancy_api.py +0 -0
  220. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_tenant_middleware.py +0 -0
  221. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_tenant_resolver_diagnostic.py +0 -0
  222. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_tenant_resolver_types.py +0 -0
  223. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_tenant_scope_helpers.py +0 -0
  224. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_tenant_source.py +0 -0
  225. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_token_guard_effective_route.py +0 -0
  226. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_token_route_guard.py +0 -0
  227. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_translator_dep.py +0 -0
  228. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests/test_view_prefixes.py +0 -0
  229. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tests-js/hosting_SetupWizard.test.tsx +0 -0
  230. {simple_module_hosting-0.0.39 → simple_module_hosting-0.0.41}/tsconfig.json +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_hosting
3
- Version: 0.0.39
3
+ Version: 0.0.41
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
@@ -22,13 +22,13 @@ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
22
22
  Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.12
24
24
  Requires-Dist: click<9,>=8.1
25
- Requires-Dist: fastapi<0.157,>=0.140
25
+ Requires-Dist: fastapi<0.159,>=0.140
26
26
  Requires-Dist: httpx<0.29,>=0.27
27
27
  Requires-Dist: itsdangerous<3,>=2.0
28
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
29
+ Requires-Dist: simple-module-core==0.0.41
30
+ Requires-Dist: simple-module-db==0.0.41
31
+ Requires-Dist: simple-module-inertia==0.0.41
32
32
  Requires-Dist: starlette<2,>=0.44
33
33
  Requires-Dist: tomlkit<0.16,>=0.13
34
34
  Requires-Dist: uvicorn[standard]<0.55,>=0.34
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_hosting"
3
- version = "0.0.39"
3
+ version = "0.0.41"
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"
@@ -22,16 +22,16 @@ classifiers = [
22
22
  ]
23
23
  dependencies = [
24
24
  "click>=8.1,<9",
25
- "fastapi>=0.140,<0.157",
25
+ "fastapi>=0.140,<0.159",
26
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
30
  "itsdangerous>=2.0,<3",
31
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",
32
+ "simple_module_core==0.0.41",
33
+ "simple_module_db==0.0.41",
34
+ "simple_module_inertia==0.0.41",
35
35
  "starlette>=0.44,<2",
36
36
  "tomlkit>=0.13,<0.16",
37
37
  "uvicorn[standard]>=0.34,<0.55",
@@ -2,6 +2,7 @@
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
 
@@ -145,12 +146,18 @@ def setup_inertia(
145
146
 
146
147
  use_dev_server = settings.environment in NON_PROD_ENVIRONMENTS
147
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
+ )
148
155
 
149
156
  inertia_config = InertiaConfig(
150
157
  environment=inertia_environment,
151
- version=_INERTIA_VERSION,
158
+ version=version,
152
159
  dev_url=settings.vite_dev_url if use_dev_server else "",
153
- manifest_json_path="" if use_dev_server else _prod_manifest_path(project_root),
160
+ manifest_json_path=manifest_path,
154
161
  assets_prefix="" if use_dev_server else _ASSETS_PREFIX,
155
162
  templates=templates,
156
163
  root_template_filename=_ROOT_TEMPLATE_FILENAME,
@@ -15,7 +15,7 @@ from __future__ import annotations
15
15
 
16
16
  import logging
17
17
  from collections.abc import AsyncGenerator, Callable, Sequence
18
- from contextlib import asynccontextmanager
18
+ from contextlib import AsyncExitStack, asynccontextmanager
19
19
 
20
20
  from fastapi import FastAPI
21
21
  from simple_module_core.app_state import get_migration_status
@@ -97,49 +97,49 @@ def build_lifespan(modules: Sequence) -> Callable:
97
97
 
98
98
  @asynccontextmanager
99
99
  async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
100
- # Report first, decide second. On a genuinely fresh install a
101
- # behind-head database must still boot far enough to serve the wizard —
102
- # running the migrations is one of the things that wizard does, and
103
- # check_migrations' RuntimeError would make it unreachable. Anywhere
104
- # else the original behaviour stands: a schema the code does not match
105
- # fails the boot. See _is_first_run for why those two cases cannot be
106
- # told apart by "is setup complete".
107
- app.state.migration = await migration_status(app.state.sm.db.engine)
108
- if not app.state.migration["is_current"] and not await _is_first_run(app):
109
- raise RuntimeError(
110
- f"Database is {app.state.migration['pending_count']} revision(s) behind "
111
- f"(at {app.state.migration['current_revision']!r}, head is "
112
- f"{app.state.migration['head_revision']!r}). Run: make migrate"
113
- )
114
-
115
- await hydrate_settings_from_db(app)
116
-
117
- # Behind head on a first run the tables a module's on_startup reads do
118
- # not exist yet. Boot must still reach the wizard, so a hook that fails
119
- # here is deferred and replayed once the wizard has applied the
120
- # migrations (run_deferred_startup) rather than aborting the process.
121
- app.state.deferred_startup = []
122
- tolerate = not app.state.migration["is_current"]
123
- for mod in modules:
124
- try:
125
- await mod.on_startup(app)
126
- except Exception:
127
- if not tolerate:
128
- raise
129
- logger.warning(
130
- "on_startup of %s failed on an unmigrated database; "
131
- "deferring it until the setup wizard has run the migrations",
132
- mod.meta.name,
133
- exc_info=True,
100
+ async with AsyncExitStack() as cleanup:
101
+ cleanup.push_async_callback(app.state.sm.db.engine.dispose)
102
+ # Report first, decide second. On a genuinely fresh install a
103
+ # behind-head database must still boot far enough to serve the wizard —
104
+ # running the migrations is one of the things that wizard does, and
105
+ # check_migrations' RuntimeError would make it unreachable. Anywhere
106
+ # else the original behaviour stands: a schema the code does not match
107
+ # fails the boot. See _is_first_run for why those two cases cannot be
108
+ # told apart by "is setup complete".
109
+ app.state.migration = await migration_status(app.state.sm.db.engine)
110
+ if not app.state.migration["is_current"] and not await _is_first_run(app):
111
+ raise RuntimeError(
112
+ f"Database is {app.state.migration['pending_count']} revision(s) behind "
113
+ f"(at {app.state.migration['current_revision']!r}, head is "
114
+ f"{app.state.migration['head_revision']!r}). Run: make migrate"
134
115
  )
135
- app.state.deferred_startup.append(mod)
136
- # Here rather than in create_app: the host includes its own routers
137
- # after create_app returns, and those should be guarded too.
138
- settings = getattr(app.state.sm, "settings", None)
139
- guard_included_routers(app, warm=getattr(settings, "environment", None) != "testing")
140
- yield
141
- for mod in reversed(modules):
142
- await mod.on_shutdown(app)
143
- await app.state.sm.db.engine.dispose()
116
+
117
+ await hydrate_settings_from_db(app)
118
+
119
+ # Behind head on a first run the tables a module's on_startup reads do
120
+ # not exist yet. Boot must still reach the wizard, so a hook that fails
121
+ # here is deferred and replayed once the wizard has applied the
122
+ # migrations (run_deferred_startup) rather than aborting the process.
123
+ app.state.deferred_startup = []
124
+ tolerate = not app.state.migration["is_current"]
125
+ for mod in modules:
126
+ try:
127
+ await mod.on_startup(app)
128
+ except Exception:
129
+ if not tolerate:
130
+ raise
131
+ logger.warning(
132
+ "on_startup of %s failed on an unmigrated database; "
133
+ "deferring it until the setup wizard has run the migrations",
134
+ mod.meta.name,
135
+ exc_info=True,
136
+ )
137
+ app.state.deferred_startup.append(mod)
138
+ cleanup.push_async_callback(mod.on_shutdown, app)
139
+ # Here rather than in create_app: the host includes its own routers
140
+ # after create_app returns, and those should be guarded too.
141
+ settings = getattr(app.state.sm, "settings", None)
142
+ guard_included_routers(app, warm=getattr(settings, "environment", None) != "testing")
143
+ yield
144
144
 
145
145
  return lifespan
@@ -19,7 +19,9 @@ bind a token to.
19
19
 
20
20
  This lifts the design the pagebuilder module shipped first
21
21
  (``pagebuilder/security.py``) into the framework, so every module shares
22
- one header name and one token-discovery convention.
22
+ one header name and one token-discovery convention. Sign-in clears the users
23
+ session, rotating its token; principal changes that retain the session must
24
+ call ``rotate_csrf_token`` before rendering the next page.
23
25
  """
24
26
 
25
27
  from __future__ import annotations
@@ -28,7 +30,7 @@ import secrets
28
30
 
29
31
  from fastapi import HTTPException, Request
30
32
 
31
- __all__ = ["CSRF_HEADER", "RequiresCsrf", "get_csrf_token"]
33
+ __all__ = ["CSRF_HEADER", "RequiresCsrf", "get_csrf_token", "rotate_csrf_token"]
32
34
 
33
35
  CSRF_HEADER = "X-CSRF-Token"
34
36
  _SESSION_KEY = "sm_csrf_token"
@@ -61,6 +63,17 @@ def get_csrf_token(request: Request) -> str:
61
63
  return token
62
64
 
63
65
 
66
+ def rotate_csrf_token(request: Request) -> None:
67
+ """Discard the old token; the next page render mints a fresh one.
68
+
69
+ Use when changing the principal while deliberately retaining the session.
70
+ Clearing the whole session already discards the token. No session is a no-op.
71
+ """
72
+ session = request.scope.get("session")
73
+ if session is not None:
74
+ session.pop(_SESSION_KEY, None)
75
+
76
+
64
77
  class RequiresCsrf:
65
78
  """FastAPI dependency enforcing the CSRF header on unsafe methods.
66
79
 
@@ -72,6 +72,28 @@ which would sign the entire user base out on deploy — or accepting it forever,
72
72
  which would leave the hole this key was added to close.
73
73
  """
74
74
 
75
+ _SIGN_IN_SURVIVORS_ATTR = "sign_in_preserved_session_keys"
76
+
77
+
78
+ def preserve_across_sign_in(app: Any, key: str) -> None:
79
+ """Keep ``key`` when sign-in replaces the session (it is not user-bound state).
80
+
81
+ For gate state that belongs to the browser rather than the principal, such
82
+ as the site-lock unlock marker: clearing it at sign-in would re-prompt the
83
+ visitor who just passed the gate. Call from ``register_settings``.
84
+ """
85
+ keys = getattr(app.state, _SIGN_IN_SURVIVORS_ATTR, None)
86
+ if keys is None:
87
+ keys = set()
88
+ setattr(app.state, _SIGN_IN_SURVIVORS_ATTR, keys)
89
+ keys.add(key)
90
+
91
+
92
+ def sign_in_preserved_keys(app: Any) -> frozenset[str]:
93
+ """Keys this app registered through :func:`preserve_across_sign_in`."""
94
+ return frozenset(getattr(app.state, _SIGN_IN_SURVIVORS_ATTR, ()))
95
+
96
+
75
97
  SESSION_COOKIE_NAME = "session"
76
98
  """The session cookie's name (Starlette's default, pinned so the OpenAPI export can cite it)."""
77
99
 
@@ -0,0 +1,53 @@
1
+ """Database advisory locks that also work before the first migration (#634)."""
2
+
3
+ import logging
4
+ from collections.abc import Generator
5
+ from contextlib import closing, contextmanager
6
+ from pathlib import Path
7
+
8
+ from sqlalchemy import create_engine
9
+ from sqlalchemy.engine import URL
10
+ from sqlalchemy.pool import NullPool
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ _POSTGRES_LOCK = 634539
15
+
16
+
17
+ @contextmanager
18
+ def migration_lock(url: URL) -> Generator[None]:
19
+ """Hold until the migration thread exits; no TTL or request cancellation gap."""
20
+ if url.get_backend_name() == "sqlite":
21
+ if not url.database or url.database == ":memory:":
22
+ # An in-memory database cannot be shared by different workers.
23
+ yield
24
+ return
25
+ import sqlite3
26
+
27
+ # A separate SQLite database supplies the advisory lock without locking
28
+ # the application database against Alembic's own connections.
29
+ path = str(Path(url.database).resolve()) + ".migrate-lock.db"
30
+ with closing(sqlite3.connect(path, timeout=300)) as connection:
31
+ try:
32
+ connection.execute("BEGIN EXCLUSIVE")
33
+ yield
34
+ finally:
35
+ connection.rollback()
36
+ return
37
+ if url.get_backend_name() != "postgresql":
38
+ logger.warning(
39
+ "No cross-worker migration lock for %s; relying on the per-process lock",
40
+ url.get_backend_name(),
41
+ )
42
+ yield
43
+ return
44
+ engine = create_engine(url.set(drivername="postgresql+psycopg2"), poolclass=NullPool)
45
+ try:
46
+ with engine.connect() as connection:
47
+ connection.exec_driver_sql("SELECT pg_advisory_lock(%s)", (_POSTGRES_LOCK,))
48
+ try:
49
+ yield
50
+ finally:
51
+ connection.exec_driver_sql("SELECT pg_advisory_unlock(%s)", (_POSTGRES_LOCK,))
52
+ finally:
53
+ engine.dispose()
@@ -16,12 +16,12 @@ from collections.abc import Generator
16
16
  from fastapi import HTTPException, Request
17
17
 
18
18
  from simple_module_hosting._deferred_startup import run_deferred_startup
19
+ from simple_module_hosting.setup_wizard._migration_lock import migration_lock
19
20
 
20
21
  logger = logging.getLogger(__name__)
21
22
 
22
- # The endpoint is anonymous, so nothing stops two requests (a double click, a
23
- # second tab) from starting two Alembic runs against one database at once.
24
- # Serialized here; the second run then finds the schema at head and is a no-op.
23
+ # Alembic and logging have process-global state, even for different databases.
24
+ # Cross-worker serialization is provided by migration_lock inside the thread.
25
25
  _MIGRATION_LOCK = asyncio.Lock()
26
26
 
27
27
 
@@ -68,7 +68,7 @@ async def apply_migrations(request: Request, _data: dict) -> dict:
68
68
  # branch_labels, so the history legitimately has several heads and
69
69
  # "head" raises CommandError("Multiple head revisions are present").
70
70
  # This is what `make migrate` runs.
71
- with _preserve_logging():
71
+ with migration_lock(request.app.state.sm.db.engine.url), _preserve_logging():
72
72
  command.upgrade(AlembicConfig(ini_path), "heads")
73
73
 
74
74
  try:
@@ -0,0 +1,71 @@
1
+ """Hosting versions built assets by manifest content, so stale visits reload."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+ from fastapi import FastAPI
8
+ from simple_module_hosting._inertia_setup import setup_inertia
9
+ from simple_module_hosting.settings import Settings
10
+ from simple_module_inertia import (
11
+ InertiaVersionConflictException,
12
+ inertia_version_conflict_exception_handler,
13
+ )
14
+ from simple_module_inertia.config import resolved_version
15
+ from starlette.requests import Request
16
+
17
+
18
+ def _setup(root: Path, environment: str):
19
+ (root / "templates").mkdir(exist_ok=True)
20
+ app = FastAPI()
21
+ config = setup_inertia(app, Settings(environment=environment), [], root)
22
+ assert config is not None
23
+ return app, config
24
+
25
+
26
+ @pytest.mark.parametrize("layout", ["host", "."])
27
+ @pytest.mark.parametrize("environment", ["production", "staging"])
28
+ async def test_new_manifest_reloads_visits_from_previous_build(tmp_path, layout, environment):
29
+ manifest = tmp_path / layout / "static" / "dist" / ".vite" / "manifest.json"
30
+ manifest.parent.mkdir(parents=True)
31
+ manifest.write_text(json.dumps({"main.tsx": {"file": "assets/old.js"}}))
32
+ _, old_config = _setup(tmp_path, environment)
33
+ old_version = resolved_version(old_config)
34
+ _, same_config = _setup(tmp_path, environment)
35
+ assert resolved_version(same_config) == old_version
36
+
37
+ manifest.write_text(json.dumps({"main.tsx": {"file": "assets/new.js"}}))
38
+ app, new_config = _setup(tmp_path, environment)
39
+ new_version = resolved_version(new_config)
40
+ assert new_version != old_version
41
+ request = Request(
42
+ {
43
+ "type": "http",
44
+ "method": "GET",
45
+ "scheme": "http",
46
+ "server": ("test", 80),
47
+ "path": "/page",
48
+ "query_string": b"",
49
+ "headers": [(b"x-inertia", b"true"), (b"x-inertia-version", old_version.encode())],
50
+ "session": {},
51
+ }
52
+ )
53
+ with pytest.raises(InertiaVersionConflictException) as exc:
54
+ app.state.inertia_dependency(request)
55
+ stale = await inertia_version_conflict_exception_handler(request, exc.value)
56
+ assert stale.status_code == 409
57
+ assert stale.headers["X-Inertia-Location"] == "http://test/page"
58
+ request.scope["headers"] = [
59
+ (b"x-inertia", b"true"),
60
+ (b"x-inertia-version", new_version.encode()),
61
+ ]
62
+ current = await app.state.inertia_dependency(Request(request.scope)).render("Page")
63
+ assert current.status_code == 200
64
+ assert json.loads(current.body)["version"] == new_version
65
+
66
+
67
+ @pytest.mark.parametrize("environment", ["development", "testing", "production"])
68
+ def test_setup_without_built_assets_preserves_fallback(tmp_path, environment):
69
+ _, config = _setup(tmp_path, environment)
70
+ assert resolved_version(config) == "1.0"
71
+ assert config.manifest_json_path == ""
@@ -96,3 +96,32 @@ class TestWithoutSessionMiddleware:
96
96
  async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
97
97
  assert (await c.get("/token")).json()["token"] == ""
98
98
  assert (await c.post("/things")).status_code == 200
99
+
100
+
101
+ @pytest.mark.parametrize("with_session", [False, True])
102
+ def test_rotation_discards_only_csrf_state(with_session):
103
+ from simple_module_hosting.csrf import rotate_csrf_token
104
+
105
+ scope = {"type": "http"}
106
+ if with_session:
107
+ scope["session"] = {"user_id": "keep"}
108
+ request = Request(scope)
109
+ old = get_csrf_token(request)
110
+ rotate_csrf_token(request)
111
+ rotate_csrf_token(request) # safe even before another token has been minted
112
+ if with_session:
113
+ assert request.session == {"user_id": "keep"}
114
+ assert get_csrf_token(request) != old
115
+ else:
116
+ assert get_csrf_token(request) == ""
117
+
118
+
119
+ def test_sign_in_survivors_registry():
120
+ from types import SimpleNamespace
121
+
122
+ from simple_module_hosting.session import preserve_across_sign_in, sign_in_preserved_keys
123
+
124
+ app = SimpleNamespace(state=SimpleNamespace())
125
+ assert sign_in_preserved_keys(app) == frozenset()
126
+ preserve_across_sign_in(app, "gate_marker")
127
+ assert sign_in_preserved_keys(app) == {"gate_marker"}
@@ -0,0 +1,53 @@
1
+ """Lifecycle failures must still unwind modules and dispose the engine (#634)."""
2
+
3
+ from types import SimpleNamespace
4
+ from unittest.mock import AsyncMock
5
+
6
+ import pytest
7
+ from fastapi import FastAPI
8
+ from simple_module_hosting import _lifespan
9
+
10
+
11
+ @pytest.mark.parametrize("failure", ["startup", "shutdown", "body", "migration"])
12
+ async def test_lifespan_unwinds_after_failure(monkeypatch, failure):
13
+ calls = []
14
+ app = FastAPI()
15
+
16
+ async def dispose():
17
+ calls.append("dispose")
18
+
19
+ app.state.sm = SimpleNamespace(db=SimpleNamespace(engine=SimpleNamespace(dispose=dispose)))
20
+ status = AsyncMock(return_value={"is_current": True})
21
+ if failure == "migration":
22
+ status.side_effect = RuntimeError("migration")
23
+ monkeypatch.setattr(_lifespan, "migration_status", status)
24
+ monkeypatch.setattr(_lifespan, "hydrate_settings_from_db", AsyncMock())
25
+ modules = []
26
+ for name in ["a", "b", "c"]:
27
+
28
+ async def start(app, name=name):
29
+ calls.append(f"start:{name}")
30
+ if failure == "startup" and name == "b":
31
+ raise RuntimeError("startup")
32
+
33
+ async def stop(app, name=name):
34
+ calls.append(f"stop:{name}")
35
+ if failure in {"startup", "shutdown"} and name == "a":
36
+ raise ValueError("cleanup")
37
+ if failure == "shutdown" and name == "c":
38
+ raise RuntimeError("shutdown")
39
+
40
+ modules.append(SimpleNamespace(on_startup=start, on_shutdown=stop))
41
+
42
+ with pytest.raises((RuntimeError, ValueError)):
43
+ async with _lifespan.build_lifespan(modules)(app):
44
+ if failure == "body":
45
+ raise RuntimeError("body")
46
+
47
+ expected = {
48
+ "startup": ["start:a", "start:b", "stop:a", "dispose"],
49
+ "shutdown": ["start:a", "start:b", "start:c", "stop:c", "stop:b", "stop:a", "dispose"],
50
+ "body": ["start:a", "start:b", "start:c", "stop:c", "stop:b", "stop:a", "dispose"],
51
+ "migration": ["dispose"],
52
+ }
53
+ assert calls == expected[failure]
@@ -8,6 +8,18 @@ import pytest
8
8
  from alembic import command
9
9
  from fastapi import HTTPException
10
10
  from simple_module_hosting.setup_wizard.migrate import apply_migrations
11
+ from sqlalchemy.engine import make_url
12
+
13
+
14
+ def _request(correlation_id):
15
+ engine = SimpleNamespace(url=make_url("sqlite+aiosqlite:///:memory:"))
16
+ return SimpleNamespace(
17
+ state=SimpleNamespace(correlation_id=correlation_id),
18
+ app=SimpleNamespace(
19
+ state=SimpleNamespace(sm=SimpleNamespace(db=SimpleNamespace(engine=engine)))
20
+ ),
21
+ )
22
+
11
23
 
12
24
  _SECRET = "postgresql://admin:hunter2@db.internal/prod"
13
25
 
@@ -17,7 +29,7 @@ async def test_migration_failure_detail_is_redacted(monkeypatch, caplog) -> None
17
29
  raise RuntimeError(f"could not connect to {_SECRET}")
18
30
 
19
31
  monkeypatch.setattr(command, "upgrade", _boom)
20
- request = SimpleNamespace(state=SimpleNamespace(correlation_id="cid-123"))
32
+ request = _request("cid-123")
21
33
 
22
34
  with pytest.raises(HTTPException) as info:
23
35
  await apply_migrations(request, {})
@@ -45,7 +57,7 @@ async def test_in_process_migration_keeps_app_logging(monkeypatch) -> None:
45
57
  raise RuntimeError("stop after clobbering")
46
58
 
47
59
  monkeypatch.setattr(command, "upgrade", _clobber)
48
- request = SimpleNamespace(state=SimpleNamespace(correlation_id=""))
60
+ request = _request("")
49
61
  try:
50
62
  with pytest.raises(HTTPException):
51
63
  await apply_migrations(request, {})
@@ -0,0 +1,74 @@
1
+ """Independent wizard workers must serialize even on a fresh database (#634)."""
2
+
3
+ import asyncio
4
+ import importlib.util
5
+ import threading
6
+ import time
7
+ from concurrent.futures import ThreadPoolExecutor
8
+ from types import SimpleNamespace
9
+ from unittest.mock import AsyncMock
10
+
11
+ import pytest
12
+ from alembic import command
13
+ from simple_module_hosting.setup_wizard import migrate
14
+ from simple_module_hosting.setup_wizard._migration_lock import migration_lock
15
+ from sqlalchemy.engine import make_url
16
+
17
+
18
+ def test_independent_workers_serialize_migrations(monkeypatch, tmp_path):
19
+ # Separate module globals reproduce workers with distinct asyncio locks.
20
+ spec = importlib.util.spec_from_file_location("other_worker", migrate.__file__)
21
+ other = importlib.util.module_from_spec(spec)
22
+ spec.loader.exec_module(other)
23
+ active = maximum = 0
24
+ guard = threading.Lock()
25
+ ready = threading.Barrier(2)
26
+
27
+ def upgrade(config, target):
28
+ nonlocal active, maximum
29
+ assert target == "heads"
30
+ with guard:
31
+ active += 1
32
+ maximum = max(maximum, active)
33
+ time.sleep(0.1) # intentional-sleep: hold the lock so an overlap would show
34
+ with guard:
35
+ active -= 1
36
+
37
+ async def inline_thread(func):
38
+ ready.wait(timeout=5)
39
+ return func()
40
+
41
+ monkeypatch.setattr(asyncio, "to_thread", inline_thread)
42
+ monkeypatch.setattr(command, "upgrade", upgrade)
43
+ monkeypatch.setattr("simple_module_hosting.migrations.migration_status", AsyncMock())
44
+ monkeypatch.setattr(migrate, "run_deferred_startup", AsyncMock())
45
+ monkeypatch.setattr(other, "run_deferred_startup", AsyncMock())
46
+ url = make_url(f"sqlite+aiosqlite:///{tmp_path / 'fresh.db'}")
47
+
48
+ def request():
49
+ return SimpleNamespace(
50
+ app=SimpleNamespace(
51
+ state=SimpleNamespace(
52
+ sm=SimpleNamespace(db=SimpleNamespace(engine=SimpleNamespace(url=url)))
53
+ )
54
+ ),
55
+ state=SimpleNamespace(),
56
+ )
57
+
58
+ with ThreadPoolExecutor(max_workers=2) as workers:
59
+ futures = [
60
+ workers.submit(asyncio.run, worker.apply_migrations(request(), {}))
61
+ for worker in [migrate, other]
62
+ ]
63
+ for future in futures:
64
+ future.result(timeout=5)
65
+ assert maximum == 1
66
+
67
+
68
+ def test_sqlite_migration_lock_released_on_failure(tmp_path):
69
+ url = make_url(f"sqlite+aiosqlite:///{tmp_path / 'fresh.db'}")
70
+ with pytest.raises(RuntimeError, match="migration failed"), migration_lock(url):
71
+ raise RuntimeError("migration failed")
72
+ # Reacquiring on another connection must work after an exception.
73
+ with migration_lock(url):
74
+ pass