simple-module-hosting 0.0.17__tar.gz → 0.0.18__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 (51) hide show
  1. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/PKG-INFO +3 -3
  2. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/pyproject.toml +3 -3
  3. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/simple_module_hosting/_phase_helpers.py +14 -0
  4. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/simple_module_hosting/app_builder.py +10 -1
  5. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/simple_module_hosting/bootstrap_settings.py +9 -0
  6. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/tests/test_app.py +89 -0
  7. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/.gitignore +0 -0
  8. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/LICENSE +0 -0
  9. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/README.md +0 -0
  10. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/simple_module_hosting/__init__.py +0 -0
  11. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/simple_module_hosting/__main__.py +0 -0
  12. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/simple_module_hosting/_error_handlers.py +0 -0
  13. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/simple_module_hosting/_host_services.py +0 -0
  14. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/simple_module_hosting/_hydrate_step.py +0 -0
  15. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/simple_module_hosting/_inertia_setup.py +0 -0
  16. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/simple_module_hosting/_inertia_shared.py +0 -0
  17. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/simple_module_hosting/_observability.py +0 -0
  18. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/simple_module_hosting/health.py +0 -0
  19. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/simple_module_hosting/host_cli.py +0 -0
  20. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/simple_module_hosting/host_settings.py +0 -0
  21. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/simple_module_hosting/i18n_deps.py +0 -0
  22. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/simple_module_hosting/i18n_manifest.py +0 -0
  23. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/simple_module_hosting/i18n_middleware.py +0 -0
  24. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/simple_module_hosting/inertia_deps.py +0 -0
  25. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/simple_module_hosting/inertia_utils.py +0 -0
  26. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/simple_module_hosting/logging.py +0 -0
  27. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/simple_module_hosting/manifest.py +0 -0
  28. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/simple_module_hosting/middleware.py +0 -0
  29. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/simple_module_hosting/migrations.py +0 -0
  30. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/simple_module_hosting/permissions.py +0 -0
  31. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/simple_module_hosting/py.typed +0 -0
  32. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/simple_module_hosting/redirects.py +0 -0
  33. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/simple_module_hosting/settings.py +0 -0
  34. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/tests/test_check_migrations.py +0 -0
  35. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/tests/test_health.py +0 -0
  36. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/tests/test_host_cli.py +0 -0
  37. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/tests/test_hosting_permissions.py +0 -0
  38. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/tests/test_i18n_manifest.py +0 -0
  39. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/tests/test_inertia_i18n_shared_props.py +0 -0
  40. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/tests/test_lifespan_order.py +0 -0
  41. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/tests/test_locale_middleware.py +0 -0
  42. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/tests/test_logging.py +0 -0
  43. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/tests/test_manifest.py +0 -0
  44. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/tests/test_middleware_order.py +0 -0
  45. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/tests/test_redirects.py +0 -0
  46. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/tests/test_session_cookie_security.py +0 -0
  47. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/tests/test_settings_i18n.py +0 -0
  48. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/tests/test_settings_secrets.py +0 -0
  49. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/tests/test_strict_discovery_wiring.py +0 -0
  50. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/tests/test_tenant_middleware.py +0 -0
  51. {simple_module_hosting-0.0.17 → simple_module_hosting-0.0.18}/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.17
3
+ Version: 0.0.18
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
@@ -26,8 +26,8 @@ Requires-Dist: fastapi-inertia>=1.0
26
26
  Requires-Dist: fastapi>=0.115
27
27
  Requires-Dist: httpx>=0.27
28
28
  Requires-Dist: jinja2>=3.1
29
- Requires-Dist: simple-module-core==0.0.17
30
- Requires-Dist: simple-module-db==0.0.17
29
+ Requires-Dist: simple-module-core==0.0.18
30
+ Requires-Dist: simple-module-db==0.0.18
31
31
  Requires-Dist: starlette>=0.44
32
32
  Requires-Dist: tomlkit>=0.13
33
33
  Requires-Dist: uvicorn[standard]>=0.34
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_hosting"
3
- version = "0.0.17"
3
+ version = "0.0.18"
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"
@@ -26,8 +26,8 @@ dependencies = [
26
26
  "fastapi-inertia>=1.0",
27
27
  "httpx>=0.27",
28
28
  "jinja2>=3.1",
29
- "simple_module_core==0.0.17",
30
- "simple_module_db==0.0.17",
29
+ "simple_module_core==0.0.18",
30
+ "simple_module_db==0.0.18",
31
31
  "starlette>=0.44",
32
32
  "tomlkit>=0.13",
33
33
  "uvicorn[standard]>=0.34",
@@ -105,6 +105,20 @@ def install_middleware(
105
105
  app.add_middleware(CorrelationIdMiddleware)
106
106
 
107
107
 
108
+ def attach_public_routes(app: FastAPI, settings: Settings, registry) -> None:
109
+ """Seed host-level public paths and publish the registry for AuthMiddleware.
110
+
111
+ Modules contribute method-aware rules through their ``register_public_routes``
112
+ hook (already applied to *registry* by the caller). This adds the host escape
113
+ hatch — ``SM_AUTH_PUBLIC_PATHS`` prefixes — then exposes the registry at
114
+ ``app.state.public_routes``, where ``auth.middleware.AuthMiddleware`` reads it
115
+ on every request.
116
+ """
117
+ for prefix in settings.auth_public_paths:
118
+ registry.add_prefix(prefix)
119
+ app.state.public_routes = registry
120
+
121
+
108
122
  def mount_module_static_dirs(app: FastAPI, modules: list) -> None:
109
123
  """Mount each module's declared static directories.
110
124
 
@@ -18,6 +18,7 @@ from simple_module_core.feature_flags import FeatureFlagRegistry
18
18
  from simple_module_core.health import HealthRegistry
19
19
  from simple_module_core.menu import MenuRegistry
20
20
  from simple_module_core.permissions import PermissionRegistry
21
+ from simple_module_core.public_routes import PublicRouteRegistry
21
22
  from simple_module_core.services import Services
22
23
  from simple_module_db.listeners import register_listeners
23
24
  from simple_module_db.session import init_db
@@ -25,6 +26,7 @@ from simple_module_db.session import init_db
25
26
  from simple_module_hosting._host_services import _HostServices
26
27
  from simple_module_hosting._inertia_setup import setup_inertia
27
28
  from simple_module_hosting._phase_helpers import (
29
+ attach_public_routes,
28
30
  check_settings_registration,
29
31
  install_middleware,
30
32
  mount_module_static_dirs,
@@ -162,6 +164,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
162
164
  ff_registry = FeatureFlagRegistry()
163
165
  event_bus = EventBus()
164
166
  health_registry = HealthRegistry()
167
+ public_route_registry = PublicRouteRegistry()
165
168
 
166
169
  @asynccontextmanager
167
170
  async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
@@ -232,13 +235,18 @@ def create_app(settings: Settings | None = None) -> FastAPI:
232
235
  mod.register_feature_flags(ff_registry)
233
236
  _register_event_handlers(mod, event_bus, app)
234
237
  mod.register_health_checks(health_registry)
238
+ mod.register_public_routes(public_route_registry)
239
+
240
+ attach_public_routes(app, settings, public_route_registry)
235
241
 
236
242
  logger.info(
237
- "Registered %d menu items, %d permissions, %d feature flags, %d health checks",
243
+ "Registered %d menu items, %d permissions, %d feature flags, "
244
+ "%d health checks, %d public routes",
238
245
  len(menu_registry.all_items),
239
246
  len(perm_registry.all_permissions),
240
247
  len(ff_registry.all_flags),
241
248
  len(health_registry.all_checks),
249
+ len(public_route_registry.routes),
242
250
  )
243
251
 
244
252
  # ── Phase 6: Initialize database ───────────────────────
@@ -281,6 +289,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
281
289
  permissions=perm_registry,
282
290
  feature_flags=ff_registry,
283
291
  health_registry=health_registry,
292
+ public_routes=public_route_registry,
284
293
  i18n_registry=i18n_registry,
285
294
  inertia_config=inertia_config,
286
295
  modules=tuple(modules),
@@ -37,6 +37,15 @@ class BootstrapSettings(BaseSettings):
37
37
 
38
38
  modules_enabled: list[str] | None = None
39
39
 
40
+ auth_public_paths: list[str] = []
41
+ """Host-level anonymous-access path prefixes (``SM_AUTH_PUBLIC_PATHS``).
42
+
43
+ An escape hatch for exposing a route without a session when no module owns
44
+ it. Each entry is treated as a prefix rule and seeded into the
45
+ ``PublicRouteRegistry`` at boot. Modules should prefer the
46
+ ``register_public_routes`` hook, which is method-aware.
47
+ """
48
+
40
49
  @property
41
50
  def is_development(self) -> bool:
42
51
  return self.environment == "development"
@@ -65,6 +65,95 @@ class TestCreateApp:
65
65
  paths = {getattr(r, "path", None) for r in app.routes}
66
66
  assert "/modules/fakestatic/static" in paths
67
67
 
68
+ async def test_module_register_public_routes_is_wired(
69
+ self,
70
+ settings: Settings,
71
+ monkeypatch,
72
+ ):
73
+ """register_public_routes() contributions land on app.state.public_routes."""
74
+ from simple_module_core import ModuleBase, ModuleMeta
75
+ from simple_module_hosting import app_builder
76
+
77
+ class FakePublicMod(ModuleBase):
78
+ meta = ModuleMeta(name="FakePublic")
79
+
80
+ def register_public_routes(self, registry):
81
+ registry.add_prefix("/api/fakepublic/stac")
82
+ registry.add_regex(r"/api/fakepublic/datasets/[^/]+/tilejson$", methods={"GET"})
83
+
84
+ real_discover = app_builder.discover_modules
85
+
86
+ def fake_discover(enabled=None, *, strict=False):
87
+ return [*real_discover(enabled=enabled, strict=strict), FakePublicMod()]
88
+
89
+ monkeypatch.setattr(app_builder, "discover_modules", fake_discover)
90
+
91
+ app = create_app(settings)
92
+ registry = app.state.public_routes
93
+ assert registry is app.state.sm.public_routes
94
+ assert registry.matches("GET", "/api/fakepublic/stac/collections")
95
+ assert registry.matches("GET", "/api/fakepublic/datasets/7/tilejson")
96
+ assert not registry.matches("PATCH", "/api/fakepublic/datasets/7/tilejson")
97
+
98
+ async def test_host_public_paths_setting_is_seeded(self, settings: Settings):
99
+ """SM_AUTH_PUBLIC_PATHS prefixes land on the registry as prefix rules."""
100
+ with_paths = settings.model_copy(
101
+ update={"auth_public_paths": ["/api/hostpublic", "/status"]}
102
+ )
103
+ app = create_app(with_paths)
104
+ registry = app.state.public_routes
105
+ assert registry.matches("GET", "/api/hostpublic/anything")
106
+ assert registry.matches("POST", "/status")
107
+ assert not registry.matches("GET", "/api/private")
108
+
109
+ async def test_module_public_route_reachable_anonymously(
110
+ self,
111
+ settings: Settings,
112
+ monkeypatch,
113
+ ):
114
+ """End-to-end: an unauthenticated GET to a module-declared public route
115
+ returns 200, while a sibling gated route under the same prefix 401s.
116
+
117
+ This is the repro from issue #191 — a read-only anonymous API
118
+ (STAC / OGC) consumed without a session cookie.
119
+ """
120
+ from simple_module_core import ModuleBase, ModuleMeta
121
+ from simple_module_hosting import app_builder
122
+
123
+ class FakeGisMod(ModuleBase):
124
+ meta = ModuleMeta(name="FakeGis", route_prefix="/api/fakegis")
125
+
126
+ def register_routes(self, api_router, view_router):
127
+ @api_router.get("/stac")
128
+ async def stac():
129
+ return {"type": "Catalog"}
130
+
131
+ @api_router.get("/secret")
132
+ async def secret():
133
+ return {"private": True}
134
+
135
+ def register_public_routes(self, registry):
136
+ registry.add_prefix("/api/fakegis/stac")
137
+
138
+ real_discover = app_builder.discover_modules
139
+
140
+ def fake_discover(enabled=None, *, strict=False):
141
+ return [*real_discover(enabled=enabled, strict=strict), FakeGisMod()]
142
+
143
+ monkeypatch.setattr(app_builder, "discover_modules", fake_discover)
144
+
145
+ app = create_app(settings)
146
+ transport = httpx.ASGITransport(app=app)
147
+ async with httpx.AsyncClient(
148
+ transport=transport, base_url="http://testserver", follow_redirects=False
149
+ ) as client:
150
+ public = await client.get("/api/fakegis/stac")
151
+ gated = await client.get("/api/fakegis/secret")
152
+
153
+ assert public.status_code == 200
154
+ assert public.json() == {"type": "Catalog"}
155
+ assert gated.status_code == 401
156
+
68
157
  async def test_app_state_has_sm_services(
69
158
  self, monkeypatch: pytest.MonkeyPatch, tmp_path
70
159
  ) -> None: