simple-module-hosting 0.0.34__tar.gz → 0.0.35__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 (109) hide show
  1. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/PKG-INFO +4 -4
  2. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/pyproject.toml +5 -4
  3. simple_module_hosting-0.0.35/simple_module_hosting/_dev_boot.py +83 -0
  4. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/_error_handlers.py +9 -13
  5. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/_inertia_setup.py +17 -74
  6. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/_phase_helpers.py +8 -4
  7. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/_registrations.py +2 -0
  8. simple_module_hosting-0.0.35/simple_module_hosting/_tenant.py +97 -0
  9. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/app_builder.py +16 -25
  10. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/host_settings.py +16 -0
  11. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/inertia_deps.py +1 -1
  12. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/manifest.py +4 -17
  13. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/middleware.py +3 -57
  14. simple_module_hosting-0.0.35/simple_module_hosting/page_globs.py +52 -0
  15. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_csp_module_sources.py +1 -0
  16. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_diagnostics_state.py +2 -4
  17. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_error_page_shared_props.py +13 -3
  18. simple_module_hosting-0.0.35/tests/test_invalidation_wiring.py +250 -0
  19. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_manifest.py +48 -0
  20. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_middleware_order.py +10 -0
  21. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_strict_discovery_wiring.py +1 -1
  22. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_tenant_middleware.py +56 -3
  23. simple_module_hosting-0.0.35/tests/test_tenant_scope_helpers.py +99 -0
  24. simple_module_hosting-0.0.34/simple_module_hosting/_inertia_json.py +0 -86
  25. simple_module_hosting-0.0.34/simple_module_hosting/_inertia_url.py +0 -131
  26. simple_module_hosting-0.0.34/tests/test_inertia_json_encoder.py +0 -83
  27. simple_module_hosting-0.0.34/tests/test_inertia_manifest.py +0 -71
  28. simple_module_hosting-0.0.34/tests/test_inertia_relative_url.py +0 -120
  29. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/.gitignore +0 -0
  30. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/LICENSE +0 -0
  31. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/README.md +0 -0
  32. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/__init__.py +0 -0
  33. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/__main__.py +0 -0
  34. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/_db_health.py +0 -0
  35. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/_favicon.py +0 -0
  36. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/_host_services.py +0 -0
  37. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/_hydrate_step.py +0 -0
  38. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/_inertia_cache.py +0 -0
  39. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/_inertia_shared.py +0 -0
  40. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/_lifespan.py +0 -0
  41. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/_module_routes.py +0 -0
  42. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/_observability.py +0 -0
  43. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/_preapp_config.py +0 -0
  44. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/_secret_key.py +0 -0
  45. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/assets.py +0 -0
  46. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/bootstrap_settings.py +0 -0
  47. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/csrf.py +0 -0
  48. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/health.py +0 -0
  49. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/host_cli.py +0 -0
  50. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/i18n_deps.py +0 -0
  51. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/i18n_manifest.py +0 -0
  52. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/i18n_middleware.py +0 -0
  53. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/inertia_utils.py +0 -0
  54. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/logging.py +0 -0
  55. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/maintenance.py +0 -0
  56. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/migrations.py +0 -0
  57. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/permissions.py +0 -0
  58. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/py.typed +0 -0
  59. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/redirects.py +0 -0
  60. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/session.py +0 -0
  61. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/settings.py +0 -0
  62. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/setup_gate.py +0 -0
  63. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/shared_props.py +0 -0
  64. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/simple_module_hosting/static_files.py +0 -0
  65. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_api_error_negotiation.py +0 -0
  66. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_app.py +0 -0
  67. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_auth_provider_setting.py +0 -0
  68. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_boot_drift_check.py +0 -0
  69. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_branding_head.py +0 -0
  70. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_check_migrations.py +0 -0
  71. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_db_health_check.py +0 -0
  72. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_design_packs_wiring.py +0 -0
  73. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_error_status_coverage.py +0 -0
  74. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_favicon_default.py +0 -0
  75. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_health.py +0 -0
  76. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_host_cli.py +0 -0
  77. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_host_settings_fields.py +0 -0
  78. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_hosting_csrf.py +0 -0
  79. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_hosting_permissions.py +0 -0
  80. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_i18n_manifest.py +0 -0
  81. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_i18n_types_cover_installed_modules.py +0 -0
  82. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_inertia_cache.py +0 -0
  83. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_inertia_i18n_shared_props.py +0 -0
  84. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_inertia_shared_providers.py +0 -0
  85. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_lifespan_order.py +0 -0
  86. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_locale_middleware.py +0 -0
  87. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_logging.py +0 -0
  88. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_maintenance_mode.py +0 -0
  89. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_menu_urls_are_canonical.py +0 -0
  90. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_migration_listing.py +0 -0
  91. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_migration_multihead.py +0 -0
  92. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_preapp_config.py +0 -0
  93. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_precompressed_static.py +0 -0
  94. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_proxy_headers.py +0 -0
  95. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_redirects.py +0 -0
  96. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_response_compression.py +0 -0
  97. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_secret_key_bootstrap.py +0 -0
  98. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_session_cookie_security.py +0 -0
  99. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_session_cookie_window.py +0 -0
  100. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_settings_i18n.py +0 -0
  101. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_settings_secrets.py +0 -0
  102. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_setup_gate.py +0 -0
  103. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_setup_password_policy.py +0 -0
  104. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_setup_public_routes.py +0 -0
  105. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_setup_registry.py +0 -0
  106. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_setup_routes.py +0 -0
  107. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_sqlite_path_anchor.py +0 -0
  108. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_static_caching.py +0 -0
  109. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.35}/tests/test_translator_dep.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_hosting
3
- Version: 0.0.34
3
+ Version: 0.0.35
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>=8.1
25
- Requires-Dist: fastapi-inertia>=1.0
26
25
  Requires-Dist: fastapi>=0.115
27
26
  Requires-Dist: httpx>=0.27
28
27
  Requires-Dist: itsdangerous>=2.0
29
28
  Requires-Dist: jinja2>=3.1
30
- Requires-Dist: simple-module-core==0.0.34
31
- Requires-Dist: simple-module-db==0.0.34
29
+ Requires-Dist: simple-module-core==0.0.35
30
+ Requires-Dist: simple-module-db==0.0.35
31
+ Requires-Dist: simple-module-inertia==0.0.35
32
32
  Requires-Dist: starlette>=0.44
33
33
  Requires-Dist: tomlkit>=0.13
34
34
  Requires-Dist: uvicorn[standard]>=0.34
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_hosting"
3
- version = "0.0.34"
3
+ version = "0.0.35"
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"
@@ -23,15 +23,15 @@ classifiers = [
23
23
  dependencies = [
24
24
  "click>=8.1",
25
25
  "fastapi>=0.115",
26
- "fastapi-inertia>=1.0",
27
26
  "httpx>=0.27",
28
27
  # SessionMiddleware (starlette.middleware.sessions) imports itsdangerous at
29
28
  # module import time; starlette treats it as an optional extra, so declare
30
29
  # it directly — out-of-tree hosts have nothing else pulling it in.
31
30
  "itsdangerous>=2.0",
32
31
  "jinja2>=3.1",
33
- "simple_module_core==0.0.34",
34
- "simple_module_db==0.0.34",
32
+ "simple_module_core==0.0.35",
33
+ "simple_module_db==0.0.35",
34
+ "simple_module_inertia==0.0.35",
35
35
  "starlette>=0.44",
36
36
  "tomlkit>=0.13",
37
37
  "uvicorn[standard]>=0.34",
@@ -59,3 +59,4 @@ packages = ["simple_module_hosting"]
59
59
  [tool.uv.sources]
60
60
  simple_module_core = { workspace = true }
61
61
  simple_module_db = { workspace = true }
62
+ simple_module_inertia = { workspace = true }
@@ -0,0 +1,83 @@
1
+ """Development-only boot work: the diagnostics run and the frontend artifacts.
2
+
3
+ Extracted from ``app_builder`` so that file reads as the phase sequence and
4
+ nothing else. Everything here is gated on ``settings.is_development`` at the one
5
+ call site, and everything here reads or writes the *source tree* — a deployed
6
+ wheel has no ``host/client_app`` to write a manifest into and no module sources
7
+ for the AST checks to walk, which is why none of it belongs on a production boot
8
+ path.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING
16
+
17
+ from simple_module_core.diagnostics import DiagnosticLevel, print_diagnostics, run_diagnostics
18
+
19
+ from simple_module_hosting.i18n_manifest import emit_frontend_types_for_modules
20
+
21
+ if TYPE_CHECKING:
22
+ from simple_module_core import ModuleBase
23
+ from simple_module_core.services import DiagnosticsState
24
+
25
+ from simple_module_hosting.settings import Settings
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ __all__ = ["run_dev_boot"]
30
+
31
+
32
+ def run_dev_boot(
33
+ settings: Settings,
34
+ modules: list[ModuleBase],
35
+ installed_modules: list[ModuleBase],
36
+ *,
37
+ i18n_extra,
38
+ diagnostics_state: DiagnosticsState,
39
+ project_root: Path,
40
+ ) -> None:
41
+ """Run the module diagnostics and emit the frontend's generated files.
42
+
43
+ ``diagnostics_state.runner`` is left behind on purpose, not just used: the
44
+ in-app Doctor screen re-invokes this exact call through ``rerun()``, and
45
+ re-deriving the arguments from a request would mean re-discovering modules.
46
+
47
+ An ERROR-level diagnostic ends the process. A developer whose module has a
48
+ duplicate name or a framework→plugin import gets one clear message here
49
+ instead of an inexplicable failure several phases later — and the same
50
+ checks run at production boot, where the app would refuse to start anyway.
51
+ """
52
+ diagnostics_state.runner = lambda: run_diagnostics(
53
+ modules,
54
+ i18n_supported_locales=settings.i18n_supported_locales,
55
+ i18n_default_locale=settings.i18n_default_locale,
56
+ i18n_extra_sources=i18n_extra,
57
+ )
58
+ diagnostics = diagnostics_state.rerun()
59
+ errors = [d for d in diagnostics if d.level == DiagnosticLevel.ERROR]
60
+ if diagnostics:
61
+ print_diagnostics(diagnostics)
62
+ if errors:
63
+ raise SystemExit(f"Module diagnostics: {len(errors)} error(s). Fix before continuing.")
64
+
65
+ _write_pages_manifest(modules, project_root)
66
+ emit_frontend_types_for_modules(settings, installed_modules, project_root)
67
+
68
+
69
+ def _write_pages_manifest(modules: list[ModuleBase], project_root: Path) -> None:
70
+ """Tell Vite where pages shipped inside pip-installed wheels live.
71
+
72
+ Swallows its own failure: a missing or unwritable ``client_app`` must not
73
+ stop the API from booting, and the symptom — pages the frontend cannot find
74
+ — is loud enough on its own. See ``scaffolding.py``.
75
+ """
76
+ try:
77
+ from simple_module_hosting.manifest import write_module_pages_manifest
78
+
79
+ client_app = project_root / "host" / "client_app"
80
+ if client_app.is_dir():
81
+ write_module_pages_manifest(modules, client_app)
82
+ except Exception:
83
+ logger.exception("Failed to write module pages manifest — frontend may miss pages")
@@ -9,19 +9,18 @@ from http import HTTPStatus
9
9
  from fastapi.encoders import jsonable_encoder
10
10
  from fastapi.exceptions import RequestValidationError
11
11
  from fastapi.responses import JSONResponse
12
- from inertia import (
12
+ from simple_module_core.exceptions import NotFoundError
13
+ from simple_module_inertia import (
13
14
  Inertia,
14
15
  InertiaConfig,
15
16
  InertiaVersionConflictException,
16
17
  inertia_version_conflict_exception_handler,
17
18
  )
18
- from simple_module_core.exceptions import NotFoundError
19
19
  from starlette.exceptions import HTTPException
20
20
  from starlette.requests import Request
21
21
  from starlette.responses import Response
22
22
 
23
23
  from simple_module_hosting._inertia_shared import _INERTIA_HEADER
24
- from simple_module_hosting._inertia_url import patch_relative_page_url
25
24
  from simple_module_hosting.permissions import PERMISSION_DENIED_PREFIX
26
25
 
27
26
  logger = logging.getLogger(__name__)
@@ -174,20 +173,17 @@ async def render_error_page(
174
173
  # that raises while reporting an error leaves the caller with nothing.
175
174
  config: InertiaConfig = request.app.state.sm.inertia_config
176
175
  # Prefer the app's configured dependency over constructing Inertia
177
- # directly: it carries the framework's wraps, and an error page built
178
- # around them is an error page rendered differently from every other
179
- # page. That is how the 404 kept throwing the cross-scheme `pushState`
180
- # SecurityError after the page url was made relative everywhere else.
181
- # The raw construction stays as the fallback for a half-built app —
182
- # the case this whole handler exists to survive — but still goes
183
- # through the same url-relativizing patch directly: a fallback that
184
- # skipped it would reintroduce the exact bug this module exists to
185
- # fix, just for the one request that hit it before setup finished.
176
+ # directly, so the error page is built the same way as every other
177
+ # page. The raw construction is the fallback for a half-built app —
178
+ # the case this whole handler exists to survive. Both paths emit a
179
+ # root-relative page url: the adapter does that by construction now,
180
+ # so the fallback no longer needs a separate patch to avoid the
181
+ # cross-scheme `pushState` SecurityError behind a TLS proxy.
186
182
  inertia_dep = getattr(request.app.state, "inertia_dependency", None)
187
183
  if inertia_dep is not None:
188
184
  inertia = inertia_dep(request, None)
189
185
  else:
190
- inertia = patch_relative_page_url(Inertia(request, config))
186
+ inertia = Inertia(request, config)
191
187
  # This does not go through get_inertia, so the share step has to be
192
188
  # repeated here. Without it the error page renders raw translation keys
193
189
  # (host.error.not_found_title) and loses auth/menus, so a signed-in
@@ -1,20 +1,15 @@
1
- """Configure fastapi-inertia with the Jinja2 template."""
1
+ """Configure simple_module_inertia with the Jinja2 template."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import hashlib
6
- import json
7
5
  import logging
8
- import tempfile
9
6
  from pathlib import Path
10
7
 
11
8
  from fastapi import FastAPI
12
- from inertia import InertiaConfig, inertia_dependency_factory
9
+ from simple_module_inertia import InertiaConfig, inertia_dependency_factory
13
10
  from starlette.requests import Request
14
11
 
15
12
  from simple_module_hosting._favicon import default_favicon_data_uri
16
- from simple_module_hosting._inertia_json import json_safe_inertia_dependency
17
- from simple_module_hosting._inertia_url import relative_page_url_dependency
18
13
  from simple_module_hosting.settings import Settings
19
14
 
20
15
  logger = logging.getLogger(__name__)
@@ -28,6 +23,12 @@ _ROOT_DIRECTORY = "."
28
23
  # branding's own default so the unbranded title is identical everywhere.
29
24
  _DEFAULT_APP_NAME = "SimpleModule"
30
25
 
26
+ # Built assets are served from the "/static" mount under "dist/", so production
27
+ # asset URLs are prefixed with "static/dist". The manifest is read where Vite
28
+ # writes it; the adapter looks the entry up by Vite's own key.
29
+ _ASSETS_PREFIX = "static/dist"
30
+ _VITE_MANIFEST_RELPATH = Path("static") / "dist" / ".vite" / "manifest.json"
31
+
31
32
 
32
33
  def branding_head(request: Request) -> dict:
33
34
  """Branding metadata for the root template's ``<head>``.
@@ -61,59 +62,19 @@ def branding_head(request: Request) -> dict:
61
62
  }
62
63
 
63
64
 
64
- # Built assets are served from the "/static" mount under "dist/", so production
65
- # asset URLs are prefixed with "static/dist".
66
- _ASSETS_PREFIX = "static/dist"
67
- _VITE_MANIFEST_RELPATH = Path("static") / "dist" / ".vite" / "manifest.json"
68
-
69
-
70
65
  def _prod_manifest_path(project_root: Path) -> str:
71
- """Return a manifest path fastapi-inertia can read in production.
72
-
73
- fastapi-inertia looks the entry up by ``f"{root_directory}/{entrypoint}"``
74
- (here ``"./main.tsx"``), but Vite keys its manifest by the entry's path
75
- relative to the Vite root (``"main.tsx"``) — so the raw Vite manifest would
76
- ``KeyError``. Read it, re-key the ``isEntry`` chunk under the key
77
- fastapi-inertia expects, and write the normalized copy beside the build
78
- output (falling back to a temp file if that dir is read-only). Returns ``""``
79
- when no built manifest exists, leaving production assets unconfigured rather
80
- than crashing at import time.
81
- """
66
+ """The Vite manifest to read in production, or ``""`` when none is built."""
82
67
  candidates = [
83
68
  project_root / "host" / _VITE_MANIFEST_RELPATH,
84
69
  project_root / _VITE_MANIFEST_RELPATH,
85
70
  ]
86
- vite_manifest = next((p for p in candidates if p.is_file()), None)
87
- if vite_manifest is None:
71
+ found = next((p for p in candidates if p.is_file()), None)
72
+ if found is None:
88
73
  logger.warning(
89
74
  "Production Vite manifest not found (looked in %s)", [str(c) for c in candidates]
90
75
  )
91
76
  return ""
92
- try:
93
- data = json.loads(vite_manifest.read_text())
94
- expected_key = f"{_ROOT_DIRECTORY}/{_ENTRYPOINT_FILENAME}"
95
- if expected_key not in data:
96
- entry = next((v for v in data.values() if v.get("isEntry")), None)
97
- if entry is None:
98
- # No entry to re-key: degrade gracefully (same as no-manifest)
99
- # rather than returning a path that KeyErrors at render time.
100
- logger.warning("No isEntry chunk in Vite manifest %s", vite_manifest)
101
- return ""
102
- data = {**data, expected_key: entry}
103
- out = vite_manifest.parent / "inertia-manifest.json"
104
- try:
105
- out.write_text(json.dumps(data))
106
- except OSError:
107
- # Build dir read-only (e.g. immutable container layer): fall back to
108
- # a temp file keyed by the source manifest path so multiple apps on
109
- # one host don't clobber each other's normalized manifests.
110
- digest = hashlib.sha1(str(vite_manifest).encode()).hexdigest()[:12]
111
- out = Path(tempfile.gettempdir()) / f"sm-inertia-manifest-{digest}.json"
112
- out.write_text(json.dumps(data))
113
- return str(out)
114
- except Exception:
115
- logger.exception("Failed to prepare production Inertia manifest from %s", vite_manifest)
116
- return ""
77
+ return str(found)
117
78
 
118
79
 
119
80
  def setup_inertia(
@@ -122,7 +83,7 @@ def setup_inertia(
122
83
  modules: list,
123
84
  project_root: Path,
124
85
  ) -> InertiaConfig | None:
125
- """Configure fastapi-inertia and attach the dependency factory to app.state.
86
+ """Configure the Inertia adapter and attach the dependency factory to app.state.
126
87
 
127
88
  Two host layouts are supported: ``host/templates`` (the framework's
128
89
  own host package) and ``templates`` at the project root (what
@@ -165,14 +126,9 @@ def setup_inertia(
165
126
  # Expose branding metadata to the root template (pre-hydration head tags).
166
127
  templates.env.globals["branding_head"] = branding_head
167
128
 
168
- # fastapi-inertia only switches to the asset manifest when environment
169
- # equals the literal string "production". Anything else (staging, qa,
170
- # ...) would render a /main.tsx <script> tag served by the SPA fallback
171
- # as text/html, breaking module loading. Normalize:
172
- # * `development`/`testing` → keep the dev-server path (Vite serves
173
- # /main.tsx directly).
174
- # * Anything else (staging, production, qa, ...) → use the production
175
- # manifest so built assets are referenced.
129
+ # The adapter only switches to the asset manifest when environment equals
130
+ # the literal "production"; normalise anything non-dev (staging, qa, ...)
131
+ # to that so built assets are referenced rather than a Vite dev url.
176
132
  from simple_module_core.environments import NON_PROD_ENVIRONMENTS
177
133
 
178
134
  use_dev_server = settings.environment in NON_PROD_ENVIRONMENTS
@@ -182,8 +138,6 @@ def setup_inertia(
182
138
  environment=inertia_environment,
183
139
  version=_INERTIA_VERSION,
184
140
  dev_url=settings.vite_dev_url if use_dev_server else "",
185
- # Production reads built assets from the Vite manifest; dev serves them
186
- # from the Vite dev server, so these only matter when not use_dev_server.
187
141
  manifest_json_path="" if use_dev_server else _prod_manifest_path(project_root),
188
142
  assets_prefix="" if use_dev_server else _ASSETS_PREFIX,
189
143
  templates=templates,
@@ -192,16 +146,5 @@ def setup_inertia(
192
146
  root_directory=_ROOT_DIRECTORY,
193
147
  use_flash_errors=True,
194
148
  )
195
-
196
- # Two wraps over the stock dependency, each closing a gap in upstream:
197
- # * JSON branch builds a Starlette JSONResponse directly, so the encoder
198
- # configured above only ever applies to full page loads — wrap it so a
199
- # client-side visit encodes the same props the same way.
200
- # * The page url is absolute, which the browser rejects for pushState
201
- # behind a TLS-terminating proxy — wrap it back to the root-relative
202
- # path the Inertia protocol specifies.
203
- inertia_dep = relative_page_url_dependency(
204
- json_safe_inertia_dependency(inertia_dependency_factory(inertia_config))
205
- )
206
- app.state.inertia_dependency = inertia_dep
149
+ app.state.inertia_dependency = inertia_dependency_factory(inertia_config)
207
150
  return inertia_config
@@ -14,13 +14,13 @@ from typing import TYPE_CHECKING
14
14
  from fastapi import FastAPI
15
15
  from fastapi.exceptions import RequestValidationError
16
16
  from fastapi.staticfiles import StaticFiles
17
- from inertia import (
18
- InertiaVersionConflictException,
19
- inertia_version_conflict_exception_handler,
20
- )
21
17
  from simple_module_core.diagnostics import Diagnostic, DiagnosticLevel
22
18
  from simple_module_core.exceptions import NotFoundError
23
19
  from simple_module_db import CommitBeforeResponseMiddleware
20
+ from simple_module_inertia import (
21
+ InertiaVersionConflictException,
22
+ inertia_version_conflict_exception_handler,
23
+ )
24
24
  from starlette.exceptions import HTTPException
25
25
  from starlette.middleware.gzip import GZipMiddleware
26
26
  from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
@@ -145,6 +145,10 @@ def install_middleware(
145
145
  )
146
146
  if settings.multi_tenant:
147
147
  app.add_middleware(TenantMiddleware, header=settings.tenant_header or None)
148
+ elif settings.default_tenant:
149
+ # Single-tenant host with tenant-scoped tables: every request is the
150
+ # one tenant (#359). Never used with multi_tenant — no shared fallback.
151
+ app.add_middleware(TenantMiddleware, fixed=settings.default_tenant)
148
152
  for mod in modules:
149
153
  mod.register_middleware(app)
150
154
  app.add_middleware(SessionMiddleware, secret_key=settings.secret_key)
@@ -19,6 +19,7 @@ def run_module_registrations(
19
19
  *,
20
20
  app: FastAPI,
21
21
  event_bus,
22
+ invalidation_bus,
22
23
  menu_registry,
23
24
  perm_registry,
24
25
  ff_registry,
@@ -42,6 +43,7 @@ def run_module_registrations(
42
43
  mod.register_permissions(perm_registry)
43
44
  mod.register_feature_flags(ff_registry)
44
45
  dispatch_event_handlers(mod, event_bus, app)
46
+ mod.register_invalidations(invalidation_bus, app)
45
47
  health_registry.set_owner(mod.meta.name)
46
48
  mod.register_health_checks(health_registry)
47
49
  mod.register_public_routes(public_route_registry)
@@ -0,0 +1,97 @@
1
+ """Tenant-context middleware: which tenant a request acts for."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Awaitable, Callable
6
+
7
+ from simple_module_db import current_tenant_id, is_valid_tenant_id
8
+ from starlette.datastructures import Headers
9
+ from starlette.requests import Request
10
+ from starlette.types import ASGIApp, Receive, Scope, Send
11
+
12
+ _SCOPE_HTTP = "http"
13
+
14
+ TENANT_HEADER = "X-Tenant-ID"
15
+
16
+
17
+ TenantResolver = Callable[[Request], Awaitable[str | None]]
18
+ """Module-owned tenant resolution, registered as ``app.state.tenant_resolver``.
19
+
20
+ Returns the tenant the request acts for, or ``None``. It owns *every* source —
21
+ membership, session, subdomain, header — so it is also where each is
22
+ validated. Without one the middleware falls back to the principal's
23
+ ``tenant_id`` claim."""
24
+
25
+
26
+ class TenantMiddleware:
27
+ """Establish the request's tenant context.
28
+
29
+ Sets the ``current_tenant_id`` context var so that DB queries on
30
+ :class:`~simple_module_db.mixins.MultiTenantMixin` models are
31
+ automatically filtered, and new objects get ``tenant_id`` populated.
32
+
33
+ Also stores the resolved value on ``request.state.tenant_id``.
34
+
35
+ Resolution:
36
+
37
+ 1. If a module registered ``app.state.tenant_resolver`` (the ``tenants``
38
+ module does), its answer is final — including ``None``.
39
+ 2. Otherwise the authenticated user's ``tenant_id`` attribute (from the
40
+ auth token claims).
41
+ 3. Otherwise, for **anonymous** requests only, the configured header —
42
+ useful for API clients and tests. Pass ``header=None`` (the default)
43
+ to disable it. An authenticated user is never allowed to pick a tenant
44
+ by header: a user with no tenant of their own would otherwise be able
45
+ to name anyone's.
46
+ """
47
+
48
+ def __init__(
49
+ self, app: ASGIApp, *, header: str | None = None, fixed: str | None = None
50
+ ) -> None:
51
+ self.app = app
52
+ self.header = header
53
+ # Single-tenant hosts: one tenant for every request, nothing resolved.
54
+ self.fixed = fixed
55
+
56
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
57
+ if scope["type"] != _SCOPE_HTTP:
58
+ await self.app(scope, receive, send)
59
+ return
60
+
61
+ request = Request(scope)
62
+ tenant_id = await self._resolve(request, scope)
63
+ request.state.tenant_id = tenant_id
64
+
65
+ if tenant_id is not None:
66
+ token = current_tenant_id.set(tenant_id)
67
+ try:
68
+ await self.app(scope, receive, send)
69
+ finally:
70
+ current_tenant_id.reset(token)
71
+ return
72
+
73
+ await self.app(scope, receive, send)
74
+
75
+ async def _resolve(self, request: Request, scope: Scope) -> str | None:
76
+ if self.fixed is not None:
77
+ return self.fixed
78
+ app = scope.get("app")
79
+ resolver: TenantResolver | None = getattr(
80
+ getattr(app, "state", None), "tenant_resolver", None
81
+ )
82
+ if resolver is not None:
83
+ return await resolver(request)
84
+
85
+ user = getattr(request.state, "user", None)
86
+ if user is not None:
87
+ return getattr(user, "tenant_id", None)
88
+
89
+ if self.header:
90
+ value = Headers(scope=scope).get(self.header)
91
+ # Unvalidated, an over-long value is a 500 on the first stamped
92
+ # write (VARCHAR(50)) and any junk becomes a tenant name (#366).
93
+ return value if is_valid_tenant_id(value) else None
94
+ return None
95
+
96
+
97
+ __all__ = ["TENANT_HEADER", "TenantMiddleware", "TenantResolver"]
@@ -10,11 +10,12 @@ from fastapi import FastAPI
10
10
  from simple_module_core import CspSourceRegistry
11
11
  from simple_module_core.audit_links import AuditLinkRegistry
12
12
  from simple_module_core.design_packs import DesignPackRegistry
13
- from simple_module_core.diagnostics import DiagnosticLevel, print_diagnostics, run_diagnostics
13
+ from simple_module_core.diagnostics import print_diagnostics
14
14
  from simple_module_core.discovery import discover_modules, select_auth_provider, topological_sort
15
15
  from simple_module_core.events import EventBus
16
16
  from simple_module_core.feature_flags import FeatureFlagRegistry
17
17
  from simple_module_core.health import HealthRegistry
18
+ from simple_module_core.invalidation import InvalidationBus
18
19
  from simple_module_core.menu import MenuRegistry
19
20
  from simple_module_core.permissions import PermissionRegistry
20
21
  from simple_module_core.public_routes import PublicRouteRegistry
@@ -24,6 +25,7 @@ from simple_module_db.listeners import register_listeners
24
25
  from simple_module_db.session import init_db
25
26
 
26
27
  from simple_module_hosting._db_health import register_database_check
28
+ from simple_module_hosting._dev_boot import run_dev_boot
27
29
  from simple_module_hosting._inertia_setup import setup_inertia
28
30
  from simple_module_hosting._lifespan import build_lifespan
29
31
  from simple_module_hosting._phase_helpers import (
@@ -39,7 +41,7 @@ from simple_module_hosting._preapp_config import merge_host_settings
39
41
  from simple_module_hosting._registrations import run_module_registrations
40
42
  from simple_module_hosting._secret_key import assert_not_placeholder
41
43
  from simple_module_hosting.health import router as health_router
42
- from simple_module_hosting.i18n_manifest import build_i18n_registry, emit_frontend_types_for_modules
44
+ from simple_module_hosting.i18n_manifest import build_i18n_registry
43
45
  from simple_module_hosting.settings import Settings
44
46
  from simple_module_hosting.setup_gate import register_migration_step
45
47
  from simple_module_hosting.static_files import PrecompressedStaticFiles
@@ -146,37 +148,21 @@ def create_app(settings: Settings | None = None) -> FastAPI:
146
148
  # renders this exact result and its "Re-run checks" calls ``rerun()``.
147
149
  diagnostics_state = DiagnosticsState()
148
150
  if settings.is_development:
149
- diagnostics_state.runner = lambda: run_diagnostics(
151
+ run_dev_boot(
152
+ settings,
150
153
  modules,
151
- i18n_supported_locales=settings.i18n_supported_locales,
152
- i18n_default_locale=settings.i18n_default_locale,
153
- i18n_extra_sources=i18n_extra,
154
+ installed_modules,
155
+ i18n_extra=i18n_extra,
156
+ diagnostics_state=diagnostics_state,
157
+ project_root=_PROJECT_ROOT,
154
158
  )
155
- diagnostics = diagnostics_state.rerun()
156
- errors = [d for d in diagnostics if d.level == DiagnosticLevel.ERROR]
157
- if diagnostics:
158
- print_diagnostics(diagnostics)
159
- if errors:
160
- raise SystemExit(f"Module diagnostics: {len(errors)} error(s). Fix before continuing.")
161
-
162
- # Emit frontend module-pages manifest so Vite can find pages shipped
163
- # inside pip-installed module wheels. See scaffolding.py.
164
- try:
165
- from simple_module_hosting.manifest import write_module_pages_manifest
166
-
167
- client_app = _PROJECT_ROOT / "host" / "client_app"
168
- if client_app.is_dir():
169
- write_module_pages_manifest(modules, client_app)
170
- except Exception:
171
- logger.exception("Failed to write module pages manifest — frontend may miss pages")
172
-
173
- emit_frontend_types_for_modules(settings, installed_modules, _PROJECT_ROOT)
174
159
 
175
160
  # ── Phase 3: Create FastAPI app ────────────────────────
176
161
  menu_registry = MenuRegistry()
177
162
  perm_registry = PermissionRegistry()
178
163
  ff_registry = FeatureFlagRegistry()
179
164
  event_bus = EventBus()
165
+ invalidation_bus = InvalidationBus()
180
166
  health_registry = HealthRegistry()
181
167
  public_route_registry = PublicRouteRegistry()
182
168
  setup_registry = SetupRegistry()
@@ -211,6 +197,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
211
197
  modules,
212
198
  app=app,
213
199
  event_bus=event_bus,
200
+ invalidation_bus=invalidation_bus,
214
201
  menu_registry=menu_registry,
215
202
  perm_registry=perm_registry,
216
203
  ff_registry=ff_registry,
@@ -248,6 +235,9 @@ def create_app(settings: Settings | None = None) -> FastAPI:
248
235
  pool_pre_ping=settings.db_pool_pre_ping,
249
236
  pool_recycle=settings.db_pool_recycle,
250
237
  )
238
+ # Multi-tenant installs fail closed: a tenant-scoped query with no tenant
239
+ # context raises instead of reading every tenant's rows.
240
+ db_state.tenant_strict = settings.multi_tenant
251
241
  register_listeners(db_state)
252
242
  # The host's own readiness signal, and the only probe-safe check in a
253
243
  # default install — module checks reach third parties and are on-demand.
@@ -292,6 +282,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
292
282
  i18n_registry=i18n_registry,
293
283
  inertia_config=inertia_config,
294
284
  modules=tuple(modules),
285
+ invalidation=invalidation_bus,
295
286
  diagnostics=diagnostics_state,
296
287
  )
297
288
 
@@ -35,6 +35,12 @@ class HostSettings(BaseSettings):
35
35
 
36
36
  multi_tenant: bool = False
37
37
  tenant_header: str = ""
38
+ default_tenant: str = ""
39
+ """Single-tenant hosts (``multi_tenant`` off): the tenant every request acts
40
+ as, so a module's ``MultiTenantMixin`` tables work without a tenant
41
+ resolver. Ignored when ``multi_tenant`` is on — a multi-tenant install
42
+ never falls back to a shared tenant. Jobs and CLI commands use
43
+ ``tenant_context(settings.default_tenant)``."""
38
44
 
39
45
  maintenance_mode: bool = False
40
46
  """Serve everyone but admins a 503 page.
@@ -123,6 +129,16 @@ class HostSettings(BaseSettings):
123
129
  return None
124
130
  return value.strip() or None
125
131
 
132
+ @field_validator("default_tenant", mode="after")
133
+ @classmethod
134
+ def _check_default_tenant(cls, value: str) -> str:
135
+ from simple_module_db import is_valid_tenant_id
136
+
137
+ value = value.strip()
138
+ if value and not is_valid_tenant_id(value):
139
+ raise ValueError(f"default_tenant {value!r} is not a valid tenant id")
140
+ return value
141
+
126
142
  @model_validator(mode="after")
127
143
  def _check_default_locale_supported(self) -> HostSettings:
128
144
  if self.i18n_default_locale not in self.i18n_supported_locales:
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  from typing import Annotated
6
6
 
7
7
  from fastapi import Depends, Request
8
- from inertia import Inertia
8
+ from simple_module_inertia import Inertia
9
9
 
10
10
 
11
11
  async def get_inertia(request: Request) -> Inertia: