simple-module-hosting 0.0.2__tar.gz → 0.0.4__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.
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/.gitignore +4 -0
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/PKG-INFO +3 -3
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/pyproject.toml +6 -3
- simple_module_hosting-0.0.4/simple_module_hosting/__main__.py +14 -0
- simple_module_hosting-0.0.4/simple_module_hosting/_host_services.py +21 -0
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/_inertia_setup.py +15 -2
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/_phase_helpers.py +36 -1
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/app_builder.py +34 -26
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/host_cli.py +7 -2
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/middleware.py +12 -2
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/tests/test_app.py +4 -26
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/tests/test_host_cli.py +22 -0
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/LICENSE +0 -0
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/README.md +0 -0
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/__init__.py +0 -0
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/_error_handlers.py +0 -0
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/_hydrate_step.py +0 -0
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/_inertia_shared.py +0 -0
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/_observability.py +0 -0
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/bootstrap_settings.py +0 -0
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/health.py +0 -0
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/host_settings.py +0 -0
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/i18n_deps.py +0 -0
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/i18n_manifest.py +0 -0
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/i18n_middleware.py +0 -0
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/inertia_deps.py +0 -0
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/inertia_utils.py +0 -0
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/logging.py +0 -0
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/manifest.py +0 -0
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/migrations.py +0 -0
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/permissions.py +0 -0
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/py.typed +0 -0
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/redirects.py +0 -0
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/settings.py +0 -0
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/tests/test_health.py +0 -0
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/tests/test_hosting_permissions.py +0 -0
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/tests/test_i18n_manifest.py +0 -0
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/tests/test_inertia_i18n_shared_props.py +0 -0
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/tests/test_locale_middleware.py +0 -0
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/tests/test_logging.py +0 -0
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/tests/test_settings_i18n.py +0 -0
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/tests/test_settings_secrets.py +0 -0
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/tests/test_tenant_middleware.py +0 -0
- {simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/tests/test_translator_dep.py +0 -0
|
@@ -36,6 +36,10 @@ uploads/
|
|
|
36
36
|
# Vite
|
|
37
37
|
host/static/dist/
|
|
38
38
|
|
|
39
|
+
# VitePress (docs)
|
|
40
|
+
docs/.vitepress/cache/
|
|
41
|
+
docs/.vitepress/dist/
|
|
42
|
+
|
|
39
43
|
# Auto-generated frontend module manifest (regenerated by the host at boot
|
|
40
44
|
# or via `make gen-pages`).
|
|
41
45
|
host/client_app/modules.manifest.json
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: simple_module_hosting
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.4
|
|
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.
|
|
30
|
-
Requires-Dist: simple-module-db==0.0.
|
|
29
|
+
Requires-Dist: simple-module-core==0.0.4
|
|
30
|
+
Requires-Dist: simple-module-db==0.0.4
|
|
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.
|
|
3
|
+
version = "0.0.4"
|
|
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,13 +26,16 @@ dependencies = [
|
|
|
26
26
|
"fastapi-inertia>=1.0",
|
|
27
27
|
"httpx>=0.27",
|
|
28
28
|
"jinja2>=3.1",
|
|
29
|
-
"simple_module_core==0.0.
|
|
30
|
-
"simple_module_db==0.0.
|
|
29
|
+
"simple_module_core==0.0.4",
|
|
30
|
+
"simple_module_db==0.0.4",
|
|
31
31
|
"starlette>=0.44",
|
|
32
32
|
"tomlkit>=0.13",
|
|
33
33
|
"uvicorn[standard]>=0.34",
|
|
34
34
|
]
|
|
35
35
|
|
|
36
|
+
[project.scripts]
|
|
37
|
+
sm-host = "simple_module_hosting.host_cli:app"
|
|
38
|
+
|
|
36
39
|
[project.entry-points."simple_module_cli.cli_plugins"]
|
|
37
40
|
host = "simple_module_hosting.host_cli:app"
|
|
38
41
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Module entry point: ``python -m simple_module_hosting`` invokes the host CLI.
|
|
2
|
+
|
|
3
|
+
Without this, ``python -m simple_module_hosting.host_cli`` would import the
|
|
4
|
+
module without running the Typer app — silently no-op'ing commands like
|
|
5
|
+
``gen-pages``. Provides the same Typer ``app`` callable that the
|
|
6
|
+
``simple_module_cli.cli_plugins`` entry point exposes as ``sm host``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from simple_module_hosting.host_cli import app
|
|
12
|
+
|
|
13
|
+
if __name__ == "__main__":
|
|
14
|
+
app()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""``_HostServices`` container exposed on ``app.state.host``.
|
|
2
|
+
|
|
3
|
+
Module-scope so the type is stable across ``create_app`` calls — tests
|
|
4
|
+
that build multiple apps in one process can ``isinstance``-check
|
|
5
|
+
``app.state.host`` against the same class object.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from simple_module_hosting.host_settings import HostSettings
|
|
13
|
+
|
|
14
|
+
__all__ = ["_HostServices"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class _HostServices:
|
|
19
|
+
"""Container for host-level services exposed on ``app.state.host``."""
|
|
20
|
+
|
|
21
|
+
settings: HostSettings
|
{simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/_inertia_setup.py
RENAMED
|
@@ -57,10 +57,23 @@ def setup_inertia(
|
|
|
57
57
|
|
|
58
58
|
templates = Jinja2Templates(directory=directories)
|
|
59
59
|
|
|
60
|
+
# fastapi-inertia only switches to the asset manifest when environment
|
|
61
|
+
# equals the literal string "production". Anything else (staging, qa,
|
|
62
|
+
# ...) would render a /main.tsx <script> tag served by the SPA fallback
|
|
63
|
+
# as text/html, breaking module loading. Normalize:
|
|
64
|
+
# * `development`/`testing` → keep the dev-server path (Vite serves
|
|
65
|
+
# /main.tsx directly).
|
|
66
|
+
# * Anything else (staging, production, qa, ...) → use the production
|
|
67
|
+
# manifest so built assets are referenced.
|
|
68
|
+
from simple_module_core.environments import NON_PROD_ENVIRONMENTS
|
|
69
|
+
|
|
70
|
+
use_dev_server = settings.environment in NON_PROD_ENVIRONMENTS
|
|
71
|
+
inertia_environment = "development" if use_dev_server else "production"
|
|
72
|
+
|
|
60
73
|
inertia_config = InertiaConfig(
|
|
61
|
-
environment=
|
|
74
|
+
environment=inertia_environment,
|
|
62
75
|
version=_INERTIA_VERSION,
|
|
63
|
-
dev_url=settings.vite_dev_url if
|
|
76
|
+
dev_url=settings.vite_dev_url if use_dev_server else "",
|
|
64
77
|
templates=templates,
|
|
65
78
|
root_template_filename=_ROOT_TEMPLATE_FILENAME,
|
|
66
79
|
entrypoint_filename=_ENTRYPOINT_FILENAME,
|
{simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/_phase_helpers.py
RENAMED
|
@@ -11,7 +11,8 @@ import logging
|
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
from typing import TYPE_CHECKING
|
|
13
13
|
|
|
14
|
-
from fastapi import FastAPI
|
|
14
|
+
from fastapi import APIRouter, FastAPI
|
|
15
|
+
from fastapi.routing import APIRoute
|
|
15
16
|
from fastapi.staticfiles import StaticFiles
|
|
16
17
|
from inertia import (
|
|
17
18
|
InertiaVersionConflictException,
|
|
@@ -158,3 +159,37 @@ def check_settings_registration(app: FastAPI, modules: list) -> list[Diagnostic]
|
|
|
158
159
|
)
|
|
159
160
|
)
|
|
160
161
|
return diagnostics
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def wire_module_routes(app: FastAPI, module) -> None:
|
|
165
|
+
"""Attach a module's API + view routers to ``app`` using its Meta prefixes.
|
|
166
|
+
|
|
167
|
+
The single canonical implementation so ``create_app`` and the test harness
|
|
168
|
+
in ``simple_module_test`` stay in lockstep if ``ModuleBase`` ever gains
|
|
169
|
+
a new router type.
|
|
170
|
+
|
|
171
|
+
Bare-prefix view routes (``view_prefix="/foo"`` + ``@router.get("/")``)
|
|
172
|
+
are also mounted at the trailing-slash-less form ``"/foo"``. Without this,
|
|
173
|
+
FastAPI's ``redirect_slashes=True`` fires a 307 to ``"/foo/"``, which
|
|
174
|
+
clients like httpx strip ``X-Inertia`` from on follow — turning every
|
|
175
|
+
Inertia navigation into a broken HTML response. Cloning the route at the
|
|
176
|
+
bare-prefix path serves the same handler directly, no redirect.
|
|
177
|
+
"""
|
|
178
|
+
api_router = APIRouter(prefix=module.meta.route_prefix, tags=[module.meta.name])
|
|
179
|
+
view_router = APIRouter(prefix=module.meta.view_prefix, tags=[f"{module.meta.name} Views"])
|
|
180
|
+
module.register_routes(api_router, view_router)
|
|
181
|
+
if module.meta.view_prefix:
|
|
182
|
+
bare_target = f"{module.meta.view_prefix}/"
|
|
183
|
+
for route in list(view_router.routes):
|
|
184
|
+
if isinstance(route, APIRoute) and route.path == bare_target:
|
|
185
|
+
view_router.add_api_route(
|
|
186
|
+
"",
|
|
187
|
+
route.endpoint,
|
|
188
|
+
methods=list(route.methods or {"GET"}),
|
|
189
|
+
response_model=route.response_model,
|
|
190
|
+
include_in_schema=False,
|
|
191
|
+
dependencies=route.dependencies,
|
|
192
|
+
name=f"{route.name}__bare",
|
|
193
|
+
)
|
|
194
|
+
app.include_router(api_router)
|
|
195
|
+
app.include_router(view_router)
|
{simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/app_builder.py
RENAMED
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import inspect
|
|
5
6
|
import logging
|
|
6
7
|
import os
|
|
7
8
|
from collections.abc import AsyncGenerator
|
|
8
9
|
from contextlib import asynccontextmanager
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
|
|
11
|
-
from fastapi import
|
|
12
|
+
from fastapi import FastAPI
|
|
12
13
|
from fastapi.staticfiles import StaticFiles
|
|
13
14
|
from simple_module_core.diagnostics import DiagnosticLevel, print_diagnostics, run_diagnostics
|
|
14
15
|
from simple_module_core.discovery import discover_modules, topological_sort
|
|
@@ -21,14 +22,17 @@ from simple_module_core.services import Services
|
|
|
21
22
|
from simple_module_db.listeners import register_listeners
|
|
22
23
|
from simple_module_db.session import init_db
|
|
23
24
|
|
|
25
|
+
from simple_module_hosting._host_services import _HostServices
|
|
24
26
|
from simple_module_hosting._inertia_setup import setup_inertia
|
|
25
27
|
from simple_module_hosting._phase_helpers import (
|
|
26
28
|
check_settings_registration,
|
|
27
29
|
install_middleware,
|
|
28
30
|
mount_module_static_dirs,
|
|
29
31
|
register_exception_handlers,
|
|
32
|
+
wire_module_routes,
|
|
30
33
|
)
|
|
31
34
|
from simple_module_hosting.health import router as health_router
|
|
35
|
+
from simple_module_hosting.host_settings import HostSettings
|
|
32
36
|
from simple_module_hosting.i18n_manifest import build_i18n_registry, emit_frontend_types
|
|
33
37
|
from simple_module_hosting.migrations import check_migrations
|
|
34
38
|
from simple_module_hosting.settings import Settings
|
|
@@ -44,39 +48,50 @@ _STATIC_DIR_NAME = "static"
|
|
|
44
48
|
_ENV_PROJECT_ROOT = "SM_PROJECT_ROOT"
|
|
45
49
|
|
|
46
50
|
|
|
51
|
+
_PROJECT_ROOT_SENTINELS = ("pyproject.toml", ".env", "alembic.ini")
|
|
52
|
+
|
|
53
|
+
|
|
47
54
|
def _resolve_project_root() -> Path:
|
|
48
55
|
"""Return the project root directory.
|
|
49
56
|
|
|
50
|
-
Prefers the ``SM_PROJECT_ROOT`` environment variable
|
|
51
|
-
``host/main.py``) so the framework works even when installed from a
|
|
52
|
-
wheel into ``site-packages`` — in that layout the fallback walk-up
|
|
53
|
-
below would escape the package into ``site-packages/..`` and miss
|
|
54
|
-
``host/static`` entirely.
|
|
57
|
+
Prefers the ``SM_PROJECT_ROOT`` environment variable when set.
|
|
55
58
|
|
|
56
|
-
|
|
57
|
-
|
|
59
|
+
Otherwise walks up from the current working directory looking for a
|
|
60
|
+
project sentinel (``pyproject.toml``, ``.env`` or ``alembic.ini``). This
|
|
61
|
+
works whether the framework is installed as a wheel into ``site-packages``
|
|
62
|
+
or run from a workspace clone.
|
|
63
|
+
|
|
64
|
+
Falls back to ``parents[3]`` for the in-tree dev loop only when the walk
|
|
65
|
+
finds nothing — which still keeps ``framework/`` users working without
|
|
66
|
+
setting the env var explicitly.
|
|
58
67
|
"""
|
|
59
68
|
override = os.environ.get(_ENV_PROJECT_ROOT)
|
|
60
69
|
if override:
|
|
61
70
|
return Path(override)
|
|
71
|
+
cwd = Path.cwd().resolve()
|
|
72
|
+
for candidate in (cwd, *cwd.parents):
|
|
73
|
+
if any((candidate / s).exists() for s in _PROJECT_ROOT_SENTINELS):
|
|
74
|
+
return candidate
|
|
62
75
|
return Path(__file__).resolve().parents[3]
|
|
63
76
|
|
|
64
77
|
|
|
65
78
|
_PROJECT_ROOT = _resolve_project_root()
|
|
66
79
|
|
|
67
80
|
|
|
68
|
-
def
|
|
69
|
-
"""
|
|
81
|
+
def _register_event_handlers(mod, event_bus: EventBus, app: FastAPI) -> None:
|
|
82
|
+
"""Dispatch to ``mod.register_event_handlers`` with or without ``app``.
|
|
70
83
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
a new router type.
|
|
84
|
+
Back-compat shim for modules that still override the one-arg form
|
|
85
|
+
``(self, bus)``; passing ``app=`` to those crashes.
|
|
74
86
|
"""
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
87
|
+
sig = inspect.signature(mod.register_event_handlers)
|
|
88
|
+
accepts_app = "app" in sig.parameters or any(
|
|
89
|
+
p.kind is inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
|
|
90
|
+
)
|
|
91
|
+
if accepts_app:
|
|
92
|
+
mod.register_event_handlers(event_bus, app=app)
|
|
93
|
+
else:
|
|
94
|
+
mod.register_event_handlers(event_bus)
|
|
80
95
|
|
|
81
96
|
|
|
82
97
|
def create_app(settings: Settings | None = None) -> FastAPI:
|
|
@@ -198,18 +213,11 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|
|
198
213
|
# AST plugin-free while still hitting the real helper at runtime.
|
|
199
214
|
if hasattr(app.state, "settings"):
|
|
200
215
|
import importlib
|
|
201
|
-
from dataclasses import dataclass as _dataclass
|
|
202
|
-
|
|
203
|
-
from simple_module_hosting.host_settings import HostSettings
|
|
204
216
|
|
|
205
217
|
_register_module_settings = importlib.import_module(
|
|
206
218
|
"settings.registration"
|
|
207
219
|
).register_module_settings
|
|
208
220
|
|
|
209
|
-
@_dataclass
|
|
210
|
-
class _HostServices:
|
|
211
|
-
settings: HostSettings
|
|
212
|
-
|
|
213
221
|
_register_module_settings(app, "host", HostSettings, lambda s: _HostServices(settings=s))
|
|
214
222
|
|
|
215
223
|
if settings.is_development:
|
|
@@ -222,7 +230,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|
|
222
230
|
mod.register_menu_items(menu_registry)
|
|
223
231
|
mod.register_permissions(perm_registry)
|
|
224
232
|
mod.register_feature_flags(ff_registry)
|
|
225
|
-
mod
|
|
233
|
+
_register_event_handlers(mod, event_bus, app)
|
|
226
234
|
mod.register_health_checks(health_registry)
|
|
227
235
|
|
|
228
236
|
logger.info(
|
{simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/host_cli.py
RENAMED
|
@@ -45,8 +45,9 @@ def gen_pages(
|
|
|
45
45
|
modules = discover_modules()
|
|
46
46
|
written = write_module_pages_manifest(modules, host_dir)
|
|
47
47
|
typer.echo(
|
|
48
|
-
f"
|
|
49
|
-
f"{written['
|
|
48
|
+
f"Module pages manifest: {len(modules)} module(s) "
|
|
49
|
+
f"→ {written['manifest'].name}, {written['generated'].name}, "
|
|
50
|
+
f"{written['css'].name} in {host_dir}"
|
|
50
51
|
)
|
|
51
52
|
|
|
52
53
|
|
|
@@ -115,3 +116,7 @@ def sync_js_deps(
|
|
|
115
116
|
return
|
|
116
117
|
result = subprocess.run(cmd, cwd=repo_root, check=False)
|
|
117
118
|
raise typer.Exit(code=result.returncode)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
if __name__ == "__main__":
|
|
122
|
+
app()
|
{simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/middleware.py
RENAMED
|
@@ -45,7 +45,10 @@ _HEADER_HSTS = "Strict-Transport-Security"
|
|
|
45
45
|
# Security response header values
|
|
46
46
|
_XCTO_NOSNIFF = "nosniff"
|
|
47
47
|
_XFO_SAMEORIGIN = "SAMEORIGIN"
|
|
48
|
-
|
|
48
|
+
# OWASP/MDN now recommend disabling the legacy XSS auditor — older
|
|
49
|
+
# browser implementations introduced reflected-XSS vectors of their own.
|
|
50
|
+
# Modern protection comes from the CSP below (strict-dynamic + nonces).
|
|
51
|
+
_XXSS_DISABLED = "0"
|
|
49
52
|
_REFERRER_STRICT_ORIGIN = "strict-origin-when-cross-origin"
|
|
50
53
|
|
|
51
54
|
__all__ = [
|
|
@@ -79,6 +82,11 @@ class SecurityHeadersMiddleware:
|
|
|
79
82
|
"img-src 'self' data: blob:; "
|
|
80
83
|
"font-src 'self' https://fonts.gstatic.com data:; "
|
|
81
84
|
"connect-src 'self'; "
|
|
85
|
+
# Allow blob: workers — MapLibre, comlink, web-tree-sitter and most
|
|
86
|
+
# WASM libs ship their worker as a Blob URL. `child-src` is the
|
|
87
|
+
# legacy fallback some browsers consult before `worker-src`.
|
|
88
|
+
"worker-src 'self' blob:; "
|
|
89
|
+
"child-src 'self' blob:; "
|
|
82
90
|
"frame-ancestors 'self'; "
|
|
83
91
|
"base-uri 'self'; "
|
|
84
92
|
"form-action 'self'"
|
|
@@ -104,6 +112,8 @@ class SecurityHeadersMiddleware:
|
|
|
104
112
|
"img-src 'self' data: blob:; "
|
|
105
113
|
"font-src 'self' https://fonts.gstatic.com data:; "
|
|
106
114
|
f"connect-src 'self' {vite_dev_url} {ws_url}; "
|
|
115
|
+
"worker-src 'self' blob:; "
|
|
116
|
+
"child-src 'self' blob:; "
|
|
107
117
|
"frame-ancestors 'self'; "
|
|
108
118
|
"base-uri 'self'; "
|
|
109
119
|
"form-action 'self'"
|
|
@@ -130,7 +140,7 @@ class SecurityHeadersMiddleware:
|
|
|
130
140
|
headers = MutableHeaders(scope=message)
|
|
131
141
|
headers[_HEADER_X_CONTENT_TYPE_OPTIONS] = _XCTO_NOSNIFF
|
|
132
142
|
headers[_HEADER_X_FRAME_OPTIONS] = _XFO_SAMEORIGIN
|
|
133
|
-
headers[_HEADER_X_XSS_PROTECTION] =
|
|
143
|
+
headers[_HEADER_X_XSS_PROTECTION] = _XXSS_DISABLED
|
|
134
144
|
headers[_HEADER_REFERRER_POLICY] = _REFERRER_STRICT_ORIGIN
|
|
135
145
|
if self.csp:
|
|
136
146
|
headers[_HEADER_CSP] = self.csp
|
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from collections import defaultdict
|
|
6
|
-
|
|
7
5
|
import httpx
|
|
8
6
|
import pytest
|
|
9
7
|
from fastapi import FastAPI
|
|
@@ -29,12 +27,11 @@ class TestCreateApp:
|
|
|
29
27
|
|
|
30
28
|
async def test_modules_enabled_limits_loaded_modules(self, settings: Settings):
|
|
31
29
|
"""Host respects settings.modules_enabled — only listed modules contribute routes."""
|
|
32
|
-
# Only Auth should be loaded;
|
|
30
|
+
# Only Auth should be loaded; Dashboard routes must be absent.
|
|
33
31
|
restricted = settings.model_copy(update={"modules_enabled": ["Auth"]})
|
|
34
32
|
app = create_app(restricted)
|
|
35
33
|
paths: set[str] = {str(r.path) for r in app.routes if hasattr(r, "path")}
|
|
36
34
|
# Auth is now contracts-only, so it has no routes — only health remains.
|
|
37
|
-
assert not any(p.startswith("/api/products") for p in paths)
|
|
38
35
|
assert "/dashboard" not in paths
|
|
39
36
|
|
|
40
37
|
async def test_module_static_mounts_become_app_routes(
|
|
@@ -117,9 +114,6 @@ class TestRouteRegistration:
|
|
|
117
114
|
assert "/health/live" in route_paths
|
|
118
115
|
assert "/health/ready" in route_paths
|
|
119
116
|
|
|
120
|
-
assert "/api/products/" in route_paths
|
|
121
|
-
assert "/api/products/{product_id}" in route_paths
|
|
122
|
-
|
|
123
117
|
# Users module owns login, register, etc. Auth module is contracts-only.
|
|
124
118
|
assert "/users/login" in route_paths
|
|
125
119
|
|
|
@@ -127,19 +121,8 @@ class TestRouteRegistration:
|
|
|
127
121
|
# landing page at "/" is owned by the host and added in host/main.py,
|
|
128
122
|
# which the create_app fixture doesn't run.
|
|
129
123
|
assert "/dashboard/" in route_paths
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
"""Products endpoints should support the correct HTTP methods."""
|
|
133
|
-
routes_by_path: dict[str, set[str]] = defaultdict(set)
|
|
134
|
-
for route in app.routes:
|
|
135
|
-
if hasattr(route, "path") and hasattr(route, "methods"):
|
|
136
|
-
routes_by_path[route.path].update(route.methods)
|
|
137
|
-
|
|
138
|
-
assert "GET" in routes_by_path.get("/api/products/", set())
|
|
139
|
-
assert "POST" in routes_by_path.get("/api/products/", set())
|
|
140
|
-
assert "GET" in routes_by_path.get("/api/products/{product_id}", set())
|
|
141
|
-
assert "PUT" in routes_by_path.get("/api/products/{product_id}", set())
|
|
142
|
-
assert "DELETE" in routes_by_path.get("/api/products/{product_id}", set())
|
|
124
|
+
# Bare-prefix alias — see wire_module_routes for the X-Inertia rationale.
|
|
125
|
+
assert "/dashboard" in route_paths
|
|
143
126
|
|
|
144
127
|
|
|
145
128
|
class TestProtectedPages:
|
|
@@ -148,18 +131,13 @@ class TestProtectedPages:
|
|
|
148
131
|
assert resp.status_code == 302
|
|
149
132
|
assert "/users/login" in resp.headers["location"]
|
|
150
133
|
|
|
151
|
-
async def test_products_page_redirects_unauthenticated(self, client: httpx.AsyncClient):
|
|
152
|
-
resp = await client.get("/products/", follow_redirects=False)
|
|
153
|
-
assert resp.status_code == 302
|
|
154
|
-
assert "/users/login" in resp.headers["location"]
|
|
155
|
-
|
|
156
134
|
|
|
157
135
|
class TestSecurityHeaders:
|
|
158
136
|
async def test_security_headers_present(self, client: httpx.AsyncClient):
|
|
159
137
|
resp = await client.get("/health")
|
|
160
138
|
assert resp.headers["x-content-type-options"] == "nosniff"
|
|
161
139
|
assert resp.headers["x-frame-options"] == "SAMEORIGIN"
|
|
162
|
-
assert resp.headers["x-xss-protection"] == "
|
|
140
|
+
assert resp.headers["x-xss-protection"] == "0"
|
|
163
141
|
assert resp.headers["referrer-policy"] == "strict-origin-when-cross-origin"
|
|
164
142
|
|
|
165
143
|
|
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
5
7
|
from pathlib import Path
|
|
6
8
|
|
|
9
|
+
import pytest
|
|
7
10
|
import typer
|
|
8
11
|
from simple_module_hosting.host_cli import app
|
|
9
12
|
from typer.testing import CliRunner
|
|
@@ -26,3 +29,22 @@ def test_gen_pages_errors_on_missing_client_app(tmp_path: Path) -> None:
|
|
|
26
29
|
result = runner.invoke(app, ["gen-pages", "--host-dir", str(tmp_path / "does-not-exist")])
|
|
27
30
|
assert result.exit_code != 0
|
|
28
31
|
assert "not found" in result.output.lower() or "not found" in (result.stderr or "").lower()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.mark.parametrize(
|
|
35
|
+
"module_target",
|
|
36
|
+
["simple_module_hosting", "simple_module_hosting.host_cli"],
|
|
37
|
+
)
|
|
38
|
+
def test_python_dash_m_invocation_runs_cli(module_target: str) -> None:
|
|
39
|
+
"""Both ``python -m simple_module_hosting`` and ``...host_cli`` must invoke
|
|
40
|
+
the Typer app — without ``__main__.py`` / a ``__name__ == "__main__"`` block
|
|
41
|
+
these silently no-op'd, breaking the documented ``gen-pages`` workflow.
|
|
42
|
+
"""
|
|
43
|
+
result = subprocess.run(
|
|
44
|
+
[sys.executable, "-m", module_target, "--help"],
|
|
45
|
+
capture_output=True,
|
|
46
|
+
text=True,
|
|
47
|
+
check=False,
|
|
48
|
+
)
|
|
49
|
+
assert result.returncode == 0, result.stderr
|
|
50
|
+
assert "gen-pages" in result.stdout
|
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/__init__.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/_error_handlers.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/_hydrate_step.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/_inertia_shared.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/_observability.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/host_settings.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/i18n_deps.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/i18n_manifest.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/i18n_middleware.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/inertia_deps.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/inertia_utils.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/logging.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/manifest.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/migrations.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/permissions.py
RENAMED
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/redirects.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/simple_module_hosting/settings.py
RENAMED
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/tests/test_hosting_permissions.py
RENAMED
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.2 → simple_module_hosting-0.0.4}/tests/test_inertia_i18n_shared_props.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|