simple-module-hosting 0.0.27__tar.gz → 0.0.28__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 (64) hide show
  1. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/PKG-INFO +4 -3
  2. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/pyproject.toml +7 -3
  3. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/simple_module_hosting/_inertia_shared.py +20 -5
  4. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/simple_module_hosting/i18n_manifest.py +2 -1
  5. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/simple_module_hosting/middleware.py +1 -1
  6. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/tests/test_inertia_i18n_shared_props.py +83 -0
  7. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/.gitignore +0 -0
  8. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/LICENSE +0 -0
  9. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/README.md +0 -0
  10. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/simple_module_hosting/__init__.py +0 -0
  11. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/simple_module_hosting/__main__.py +0 -0
  12. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/simple_module_hosting/_error_handlers.py +0 -0
  13. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/simple_module_hosting/_host_services.py +0 -0
  14. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/simple_module_hosting/_hydrate_step.py +0 -0
  15. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/simple_module_hosting/_inertia_setup.py +0 -0
  16. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/simple_module_hosting/_observability.py +0 -0
  17. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/simple_module_hosting/_phase_helpers.py +0 -0
  18. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/simple_module_hosting/app_builder.py +0 -0
  19. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/simple_module_hosting/assets.py +0 -0
  20. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/simple_module_hosting/bootstrap_settings.py +0 -0
  21. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/simple_module_hosting/health.py +0 -0
  22. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/simple_module_hosting/host_cli.py +0 -0
  23. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/simple_module_hosting/host_settings.py +0 -0
  24. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/simple_module_hosting/i18n_deps.py +0 -0
  25. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/simple_module_hosting/i18n_middleware.py +0 -0
  26. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/simple_module_hosting/inertia_deps.py +0 -0
  27. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/simple_module_hosting/inertia_utils.py +0 -0
  28. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/simple_module_hosting/logging.py +0 -0
  29. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/simple_module_hosting/manifest.py +0 -0
  30. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/simple_module_hosting/migrations.py +0 -0
  31. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/simple_module_hosting/permissions.py +0 -0
  32. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/simple_module_hosting/py.typed +0 -0
  33. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/simple_module_hosting/redirects.py +0 -0
  34. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/simple_module_hosting/settings.py +0 -0
  35. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/simple_module_hosting/shared_props.py +0 -0
  36. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/simple_module_hosting/static_files.py +0 -0
  37. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/tests/test_app.py +0 -0
  38. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/tests/test_branding_head.py +0 -0
  39. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/tests/test_check_migrations.py +0 -0
  40. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/tests/test_design_packs_wiring.py +0 -0
  41. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/tests/test_error_page_shared_props.py +0 -0
  42. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/tests/test_health.py +0 -0
  43. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/tests/test_host_cli.py +0 -0
  44. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/tests/test_hosting_permissions.py +0 -0
  45. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/tests/test_i18n_manifest.py +0 -0
  46. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/tests/test_inertia_manifest.py +0 -0
  47. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/tests/test_inertia_shared_providers.py +0 -0
  48. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/tests/test_lifespan_order.py +0 -0
  49. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/tests/test_locale_middleware.py +0 -0
  50. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/tests/test_logging.py +0 -0
  51. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/tests/test_manifest.py +0 -0
  52. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/tests/test_menu_urls_are_canonical.py +0 -0
  53. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/tests/test_middleware_order.py +0 -0
  54. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/tests/test_precompressed_static.py +0 -0
  55. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/tests/test_proxy_headers.py +0 -0
  56. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/tests/test_redirects.py +0 -0
  57. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/tests/test_response_compression.py +0 -0
  58. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/tests/test_session_cookie_security.py +0 -0
  59. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/tests/test_settings_i18n.py +0 -0
  60. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/tests/test_settings_secrets.py +0 -0
  61. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/tests/test_static_caching.py +0 -0
  62. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/tests/test_strict_discovery_wiring.py +0 -0
  63. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/tests/test_tenant_middleware.py +0 -0
  64. {simple_module_hosting-0.0.27 → simple_module_hosting-0.0.28}/tests/test_translator_dep.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simple_module_hosting
3
- Version: 0.0.27
3
+ Version: 0.0.28
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
@@ -25,9 +25,10 @@ Requires-Dist: click>=8.1
25
25
  Requires-Dist: fastapi-inertia>=1.0
26
26
  Requires-Dist: fastapi>=0.115
27
27
  Requires-Dist: httpx>=0.27
28
+ Requires-Dist: itsdangerous>=2.0
28
29
  Requires-Dist: jinja2>=3.1
29
- Requires-Dist: simple-module-core==0.0.27
30
- Requires-Dist: simple-module-db==0.0.27
30
+ Requires-Dist: simple-module-core==0.0.28
31
+ Requires-Dist: simple-module-db==0.0.28
31
32
  Requires-Dist: starlette>=0.44
32
33
  Requires-Dist: tomlkit>=0.13
33
34
  Requires-Dist: uvicorn[standard]>=0.34
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_hosting"
3
- version = "0.0.27"
3
+ version = "0.0.28"
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"
@@ -25,9 +25,13 @@ dependencies = [
25
25
  "fastapi>=0.115",
26
26
  "fastapi-inertia>=1.0",
27
27
  "httpx>=0.27",
28
+ # SessionMiddleware (starlette.middleware.sessions) imports itsdangerous at
29
+ # module import time; starlette treats it as an optional extra, so declare
30
+ # it directly — out-of-tree hosts have nothing else pulling it in.
31
+ "itsdangerous>=2.0",
28
32
  "jinja2>=3.1",
29
- "simple_module_core==0.0.27",
30
- "simple_module_db==0.0.27",
33
+ "simple_module_core==0.0.28",
34
+ "simple_module_db==0.0.28",
31
35
  "starlette>=0.44",
32
36
  "tomlkit>=0.13",
33
37
  "uvicorn[standard]>=0.34",
@@ -12,20 +12,25 @@ from starlette.types import Scope
12
12
  logger = logging.getLogger(__name__)
13
13
 
14
14
  _I18N_SESSION_LOCALE_KEY = "__i18n_locale"
15
+ _I18N_SESSION_AUDIENCE_KEY = "__i18n_audience"
15
16
  _INERTIA_HEADER = "x-inertia"
16
17
  _INERTIA_HEADER_TRUE = "true"
17
18
 
18
19
 
19
- def build_i18n_block(scope: Scope, request: Request) -> dict:
20
+ def build_i18n_block(scope: Scope, request: Request, *, is_authenticated: bool = True) -> dict:
20
21
  """Assemble the ``i18n`` shared-props block for the current request.
21
22
 
22
23
  Rules:
23
24
 
24
25
  * No registry / no locale → serve an empty English block and log once.
25
26
  * Inertia XHR partials (``X-Inertia: true``) reuse the client-side
26
- cached messages; send ``messages: None`` unless the locale differs
27
- from what was last served on this session.
27
+ cached messages; send ``messages: None`` unless the locale — or the
28
+ audience, see below — differs from what was last served this session.
28
29
  * Full page loads and locale transitions ship the complete dict.
30
+ * Anonymous visitors receive the public snapshot: catalogs of modules
31
+ declaring ``i18n_audience="admin"`` are withheld (issue #248). A login
32
+ or logout mid-session counts as a change so the freshly-authenticated
33
+ client isn't left holding the anonymous catalog (or vice versa).
29
34
  """
30
35
  # Test fixtures sometimes build a bare FastAPI with a partial app.state.sm
31
36
  # stub (e.g. permissions-only, no i18n); guard both lookups to keep them usable.
@@ -42,6 +47,7 @@ def build_i18n_block(scope: Scope, request: Request) -> dict:
42
47
  return {"locale": "en", "supportedLocales": ["en"], "messages": {}}
43
48
 
44
49
  is_inertia = Headers(scope=scope).get(_INERTIA_HEADER) == _INERTIA_HEADER_TRUE
50
+ audience = "full" if is_authenticated else "public"
45
51
  session_dict = scope.get("session")
46
52
  # When the session is absent (pre-session-middleware routes, WebSocket
47
53
  # upgrades), treat locale as "unchanged" so Inertia XHR requests still
@@ -52,13 +58,22 @@ def build_i18n_block(scope: Scope, request: Request) -> dict:
52
58
  locale_changed = last_locale != locale
53
59
  if locale_changed:
54
60
  session_dict[_I18N_SESSION_LOCALE_KEY] = locale
61
+ last_audience = session_dict.get(_I18N_SESSION_AUDIENCE_KEY)
62
+ audience_changed = last_audience != audience
63
+ if audience_changed:
64
+ session_dict[_I18N_SESSION_AUDIENCE_KEY] = audience
55
65
  else:
56
66
  locale_changed = False
57
- send_messages = (not is_inertia) or locale_changed
67
+ audience_changed = False
68
+ send_messages = (not is_inertia) or locale_changed or audience_changed
58
69
  return {
59
70
  "locale": locale,
60
71
  "supportedLocales": registry.available_locales(),
61
- "messages": registry.messages_snapshot(locale) if send_messages else None,
72
+ "messages": (
73
+ registry.messages_snapshot(locale, include_admin=is_authenticated)
74
+ if send_messages
75
+ else None
76
+ ),
62
77
  }
63
78
 
64
79
 
@@ -44,8 +44,9 @@ def build_i18n_registry(
44
44
  extra_sources: list[tuple[str, str, Path]] = []
45
45
 
46
46
  for mod in modules:
47
+ audience = getattr(mod.meta, "i18n_audience", "public")
47
48
  for namespace, locale_dir in mod.locale_dirs().items():
48
- registry.add_source(namespace, locale_dir)
49
+ registry.add_source(namespace, locale_dir, audience=audience)
49
50
 
50
51
  host_locales = project_root / "host" / "locales"
51
52
  if host_locales.is_dir():
@@ -256,7 +256,7 @@ class InertiaLayoutDataMiddleware:
256
256
  all_perms = self.permission_registry.all_permissions
257
257
  frontend_permissions = expand_permissions(resolved, all_perms) if is_authenticated else []
258
258
 
259
- i18n_block = build_i18n_block(scope, request)
259
+ i18n_block = build_i18n_block(scope, request, is_authenticated=is_authenticated)
260
260
 
261
261
  principal_serializer: PrincipalSerializer | None = getattr(
262
262
  scope["app"].state, "principal_serializer", None
@@ -64,6 +64,89 @@ def test_inertia_shared_props_reflect_cookie_locale() -> None:
64
64
  assert body["i18n"]["messages"] == {"hello": "Hola"}
65
65
 
66
66
 
67
+ def _build_audience_app(tmp_path) -> FastAPI:
68
+ """App with a *loaded* registry (public + admin sources) and header-driven auth.
69
+
70
+ ``X-Test-Auth: 1`` marks the request authenticated, so one client session
71
+ can flip auth state mid-session — the login/logout transition the i18n
72
+ block must react to.
73
+ """
74
+ import json
75
+ from types import SimpleNamespace
76
+
77
+ for ns, data in (
78
+ ("pages", {"title": "Pages"}),
79
+ ("settings", {"key": "Key"}),
80
+ ):
81
+ (tmp_path / ns).mkdir(exist_ok=True)
82
+ (tmp_path / ns / "en.json").write_text(json.dumps(data))
83
+ reg = I18nRegistry(default_locale="en", supported_locales=["en"])
84
+ reg.add_source("pages", tmp_path / "pages")
85
+ reg.add_source("settings", tmp_path / "settings", audience="admin")
86
+ reg.load()
87
+
88
+ app = FastAPI()
89
+ app.state.sm = SimpleNamespace(i18n_registry=reg)
90
+
91
+ @app.get("/shared")
92
+ def shared(request: Request) -> JSONResponse:
93
+ return JSONResponse(request.state.inertia_shared)
94
+
95
+ app.add_middleware(
96
+ InertiaLayoutDataMiddleware,
97
+ menu_registry=MenuRegistry(),
98
+ permission_registry=PermissionRegistry(),
99
+ )
100
+ app.add_middleware(
101
+ LocaleMiddleware,
102
+ supported_locales=["en"],
103
+ default_locale="en",
104
+ )
105
+
106
+ class _HeaderAuth:
107
+ def __init__(self, app_):
108
+ self.app = app_
109
+
110
+ async def __call__(self, scope, receive, send):
111
+ if scope["type"] == "http":
112
+ request = Request(scope)
113
+ if request.headers.get("X-Test-Auth") == "1":
114
+ request.state.user = SimpleNamespace(roles=[])
115
+ await self.app(scope, receive, send)
116
+
117
+ app.add_middleware(_HeaderAuth)
118
+ app.add_middleware(SessionMiddleware, secret_key="test-secret")
119
+ return app
120
+
121
+
122
+ def test_anonymous_visitors_receive_only_public_catalogs(tmp_path) -> None:
123
+ client = TestClient(_build_audience_app(tmp_path))
124
+ body = client.get("/shared").json()
125
+ assert body["i18n"]["messages"] == {"pages.title": "Pages"}
126
+
127
+
128
+ def test_authenticated_users_receive_admin_catalogs_too(tmp_path) -> None:
129
+ client = TestClient(_build_audience_app(tmp_path))
130
+ body = client.get("/shared", headers={"X-Test-Auth": "1"}).json()
131
+ assert body["i18n"]["messages"] == {"pages.title": "Pages", "settings.key": "Key"}
132
+
133
+
134
+ def test_login_mid_session_reships_messages_on_an_inertia_partial(tmp_path) -> None:
135
+ """An Inertia partial normally skips messages — but not right after login,
136
+ or the freshly-authenticated client would keep the anonymous catalog."""
137
+ client = TestClient(_build_audience_app(tmp_path))
138
+ client.get("/shared") # anonymous full load seeds the session audience
139
+ body = client.get("/shared", headers={"X-Test-Auth": "1", "X-Inertia": "true"}).json()
140
+ assert body["i18n"]["messages"] == {"pages.title": "Pages", "settings.key": "Key"}
141
+
142
+
143
+ def test_inertia_partial_with_unchanged_audience_still_skips_messages(tmp_path) -> None:
144
+ client = TestClient(_build_audience_app(tmp_path))
145
+ client.get("/shared")
146
+ body = client.get("/shared", headers={"X-Inertia": "true"}).json()
147
+ assert body["i18n"]["messages"] is None
148
+
149
+
67
150
  def test_inertia_shared_props_fallback_when_registry_missing(
68
151
  caplog,
69
152
  ) -> None: