simple-module-hosting 0.0.1__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 (79) hide show
  1. simple_module_hosting-0.0.1/.gitignore +59 -0
  2. simple_module_hosting-0.0.1/LICENSE +21 -0
  3. simple_module_hosting-0.0.1/PKG-INFO +93 -0
  4. simple_module_hosting-0.0.1/README.md +58 -0
  5. simple_module_hosting-0.0.1/pyproject.toml +55 -0
  6. simple_module_hosting-0.0.1/simple_module_hosting/__init__.py +7 -0
  7. simple_module_hosting-0.0.1/simple_module_hosting/_error_handlers.py +54 -0
  8. simple_module_hosting-0.0.1/simple_module_hosting/_hydrate_step.py +39 -0
  9. simple_module_hosting-0.0.1/simple_module_hosting/_inertia_setup.py +73 -0
  10. simple_module_hosting-0.0.1/simple_module_hosting/_inertia_shared.py +61 -0
  11. simple_module_hosting-0.0.1/simple_module_hosting/_observability.py +108 -0
  12. simple_module_hosting-0.0.1/simple_module_hosting/_phase_helpers.py +160 -0
  13. simple_module_hosting-0.0.1/simple_module_hosting/app_builder.py +281 -0
  14. simple_module_hosting-0.0.1/simple_module_hosting/bootstrap_settings.py +55 -0
  15. simple_module_hosting-0.0.1/simple_module_hosting/cli.py +292 -0
  16. simple_module_hosting-0.0.1/simple_module_hosting/health.py +79 -0
  17. simple_module_hosting-0.0.1/simple_module_hosting/host_settings.py +33 -0
  18. simple_module_hosting-0.0.1/simple_module_hosting/i18n_deps.py +25 -0
  19. simple_module_hosting-0.0.1/simple_module_hosting/i18n_manifest.py +202 -0
  20. simple_module_hosting-0.0.1/simple_module_hosting/i18n_middleware.py +95 -0
  21. simple_module_hosting-0.0.1/simple_module_hosting/inertia_deps.py +27 -0
  22. simple_module_hosting-0.0.1/simple_module_hosting/inertia_utils.py +31 -0
  23. simple_module_hosting-0.0.1/simple_module_hosting/logging.py +91 -0
  24. simple_module_hosting-0.0.1/simple_module_hosting/manifest.py +250 -0
  25. simple_module_hosting-0.0.1/simple_module_hosting/middleware.py +272 -0
  26. simple_module_hosting-0.0.1/simple_module_hosting/migrations.py +65 -0
  27. simple_module_hosting-0.0.1/simple_module_hosting/permissions.py +75 -0
  28. simple_module_hosting-0.0.1/simple_module_hosting/py.typed +0 -0
  29. simple_module_hosting-0.0.1/simple_module_hosting/redirects.py +45 -0
  30. simple_module_hosting-0.0.1/simple_module_hosting/scaffolding.py +294 -0
  31. simple_module_hosting-0.0.1/simple_module_hosting/settings.py +10 -0
  32. simple_module_hosting-0.0.1/simple_module_hosting/templates/host/.env.example +20 -0
  33. simple_module_hosting-0.0.1/simple_module_hosting/templates/host/.gitignore +19 -0
  34. simple_module_hosting-0.0.1/simple_module_hosting/templates/host/Makefile +24 -0
  35. simple_module_hosting-0.0.1/simple_module_hosting/templates/host/README.md.tpl +59 -0
  36. simple_module_hosting-0.0.1/simple_module_hosting/templates/host/alembic.ini +36 -0
  37. simple_module_hosting-0.0.1/simple_module_hosting/templates/host/client_app/app.tsx +16 -0
  38. simple_module_hosting-0.0.1/simple_module_hosting/templates/host/client_app/main.tsx +2 -0
  39. simple_module_hosting-0.0.1/simple_module_hosting/templates/host/client_app/package.json.tpl +23 -0
  40. simple_module_hosting-0.0.1/simple_module_hosting/templates/host/client_app/pages/Error.tsx +13 -0
  41. simple_module_hosting-0.0.1/simple_module_hosting/templates/host/client_app/pages.ts +47 -0
  42. simple_module_hosting-0.0.1/simple_module_hosting/templates/host/client_app/styles.css +7 -0
  43. simple_module_hosting-0.0.1/simple_module_hosting/templates/host/client_app/tsconfig.json +16 -0
  44. simple_module_hosting-0.0.1/simple_module_hosting/templates/host/client_app/vite.config.ts +39 -0
  45. simple_module_hosting-0.0.1/simple_module_hosting/templates/host/main.py +27 -0
  46. simple_module_hosting-0.0.1/simple_module_hosting/templates/host/migrations/env.py +80 -0
  47. simple_module_hosting-0.0.1/simple_module_hosting/templates/host/migrations/script.py.mako +26 -0
  48. simple_module_hosting-0.0.1/simple_module_hosting/templates/host/migrations/versions/.gitkeep +1 -0
  49. simple_module_hosting-0.0.1/simple_module_hosting/templates/host/pyproject.toml.tpl +17 -0
  50. simple_module_hosting-0.0.1/simple_module_hosting/templates/host/templates/index.html +12 -0
  51. simple_module_hosting-0.0.1/simple_module_hosting/templates/module/.github/workflows/ci.yml +32 -0
  52. simple_module_hosting-0.0.1/simple_module_hosting/templates/module/.github/workflows/publish.yml.tpl +52 -0
  53. simple_module_hosting-0.0.1/simple_module_hosting/templates/module/.gitignore +14 -0
  54. simple_module_hosting-0.0.1/simple_module_hosting/templates/module/README.md.tpl +82 -0
  55. simple_module_hosting-0.0.1/simple_module_hosting/templates/module/__PACKAGE__/__init__.py +0 -0
  56. simple_module_hosting-0.0.1/simple_module_hosting/templates/module/__PACKAGE__/endpoints/__init__.py +0 -0
  57. simple_module_hosting-0.0.1/simple_module_hosting/templates/module/__PACKAGE__/endpoints/api.py.tpl +11 -0
  58. simple_module_hosting-0.0.1/simple_module_hosting/templates/module/__PACKAGE__/module.py.tpl +46 -0
  59. simple_module_hosting-0.0.1/simple_module_hosting/templates/module/__PACKAGE__/pages/.gitkeep +1 -0
  60. simple_module_hosting-0.0.1/simple_module_hosting/templates/module/__PACKAGE__/services.py.tpl +22 -0
  61. simple_module_hosting-0.0.1/simple_module_hosting/templates/module/package.json.tpl +16 -0
  62. simple_module_hosting-0.0.1/simple_module_hosting/templates/module/pyproject.toml.tpl +39 -0
  63. simple_module_hosting-0.0.1/simple_module_hosting/templates/module/tests/__init__.py +0 -0
  64. simple_module_hosting-0.0.1/simple_module_hosting/templates/module/tests/test_module.py.tpl +27 -0
  65. simple_module_hosting-0.0.1/simple_module_hosting/templates/module/tsconfig.json.tpl +11 -0
  66. simple_module_hosting-0.0.1/tests/test_app.py +182 -0
  67. simple_module_hosting-0.0.1/tests/test_cli_new.py +78 -0
  68. simple_module_hosting-0.0.1/tests/test_health.py +87 -0
  69. simple_module_hosting-0.0.1/tests/test_hosting_permissions.py +230 -0
  70. simple_module_hosting-0.0.1/tests/test_i18n_manifest.py +116 -0
  71. simple_module_hosting-0.0.1/tests/test_inertia_i18n_shared_props.py +97 -0
  72. simple_module_hosting-0.0.1/tests/test_locale_middleware.py +99 -0
  73. simple_module_hosting-0.0.1/tests/test_logging.py +225 -0
  74. simple_module_hosting-0.0.1/tests/test_scaffolding_host.py +171 -0
  75. simple_module_hosting-0.0.1/tests/test_scaffolding_module.py +179 -0
  76. simple_module_hosting-0.0.1/tests/test_settings_i18n.py +31 -0
  77. simple_module_hosting-0.0.1/tests/test_settings_secrets.py +25 -0
  78. simple_module_hosting-0.0.1/tests/test_tenant_middleware.py +160 -0
  79. simple_module_hosting-0.0.1/tests/test_translator_dep.py +61 -0
@@ -0,0 +1,59 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .venv/
8
+ *.egg
9
+
10
+ # UV
11
+ uv.lock
12
+
13
+ # Node
14
+ node_modules/
15
+
16
+ # IDE
17
+ .idea/
18
+ .vscode/
19
+ *.swp
20
+ *.swo
21
+
22
+ # Environment
23
+ .env
24
+
25
+ # Database
26
+ *.db
27
+ *.sqlite3
28
+
29
+ # Module-managed runtime state (e.g. uploaded dataset files,
30
+ # default storage_dir for SM_DATASETS_STORAGE_DIR).
31
+ var/
32
+
33
+ # file_storage filesystem backend default root (override via SM_FILE_STORAGE_FS_ROOT_PATH).
34
+ uploads/
35
+
36
+ # Vite
37
+ host/static/dist/
38
+
39
+ # Auto-generated frontend module manifest (regenerated by the host at boot
40
+ # or via `make gen-pages`).
41
+ host/client_app/modules.manifest.json
42
+ host/client_app/modules.generated.ts
43
+ host/client_app/modules.generated.css
44
+
45
+ # Worktrees
46
+ .worktrees/
47
+
48
+ # Performance profiles
49
+ .memray/
50
+ .benchmarks/
51
+
52
+ # OS
53
+ .DS_Store
54
+ Thumbs.db
55
+
56
+ .playwright-cli/*
57
+ .playwright-mcp/*
58
+ host/client_app/.playwright-cli/*
59
+ .superpowers/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anto Subash
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: simple_module_hosting
3
+ Version: 0.0.1
4
+ Summary: FastAPI + Inertia.js host runtime for simple_module — app_builder, middleware stack, CLI (sm / simple-module), scaffolding
5
+ Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
+ Project-URL: Repository, https://github.com/antosubash/simple_module_python
7
+ Project-URL: Issues, https://github.com/antosubash/simple_module_python/issues
8
+ Project-URL: Changelog, https://github.com/antosubash/simple_module_python/blob/main/CHANGELOG.md
9
+ Author-email: Anto Subash <antosubash@live.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: fastapi,inertia,scaffolding,simple-module,starlette,uvicorn
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Framework :: FastAPI
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Internet :: WWW/HTTP :: WSGI :: Application
21
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.12
24
+ Requires-Dist: click>=8.1
25
+ Requires-Dist: fastapi-inertia>=1.0
26
+ Requires-Dist: fastapi>=0.115
27
+ Requires-Dist: httpx>=0.27
28
+ Requires-Dist: jinja2>=3.1
29
+ Requires-Dist: simple-module-core==0.0.1
30
+ Requires-Dist: simple-module-db==0.0.1
31
+ Requires-Dist: starlette>=0.44
32
+ Requires-Dist: tomlkit>=0.13
33
+ Requires-Dist: uvicorn[standard]>=0.34
34
+ Description-Content-Type: text/markdown
35
+
36
+ # simple_module_hosting
37
+
38
+ FastAPI + Inertia.js host runtime for the [simple_module](https://github.com/antosubash/simple_module_python) framework — builds the app, wires the middleware pipeline, exposes the `sm` / `simple-module` CLI, and ships the project scaffolder.
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ pip install simple_module_hosting
44
+ ```
45
+
46
+ For a new project, most users run the generator instead:
47
+
48
+ ```bash
49
+ uvx simple-module new my-app
50
+ ```
51
+
52
+ ## What it provides
53
+
54
+ - `create_app(settings)` — returns a fully-wired `FastAPI` instance with all discovered modules registered.
55
+ - Middleware pipeline (execution order): CorrelationId → RequestLogging → SecurityHeaders → Session → `<module middleware>` → Tenant (opt-in) → Locale → InertiaLayoutData → app.
56
+ - Inertia wiring — shared props (`auth`, `menus`, `i18n`), `InertiaDep`, page-route lookup.
57
+ - CLI entry points: both `sm` and `simple-module` are installed and alias the same Click tree.
58
+ - Scaffolders — `sm create-host`, `sm create-module`, `sm new` (greenfield app with users + dashboard + permissions pre-wired), `sm gen-pages`.
59
+
60
+ ## Usage
61
+
62
+ Minimal `main.py`:
63
+
64
+ ```python
65
+ from simple_module_hosting import create_app
66
+ from simple_module_hosting.settings import Settings
67
+
68
+ settings = Settings() # reads SM_* env vars
69
+ app = create_app(settings) # discovers + registers every installed module
70
+
71
+ if __name__ == "__main__":
72
+ import uvicorn
73
+ uvicorn.run(app, host="0.0.0.0", port=8000)
74
+ ```
75
+
76
+ CLI:
77
+
78
+ ```bash
79
+ simple-module new my-app # scaffold a new project
80
+ simple-module doctor # diagnostic codes (SM001-SM017)
81
+ simple-module gen-pages # regenerate client_app/modules.generated.ts
82
+ ```
83
+
84
+ `sm` works identically to `simple-module`.
85
+
86
+ ## Depends on
87
+
88
+ - `simple_module_core`, `simple_module_db`
89
+ - `fastapi`, `fastapi-inertia`, `starlette`, `uvicorn`, `click`, `jinja2`, `httpx`
90
+
91
+ ## License
92
+
93
+ MIT — see [LICENSE](https://github.com/antosubash/simple_module_python/blob/main/LICENSE).
@@ -0,0 +1,58 @@
1
+ # simple_module_hosting
2
+
3
+ FastAPI + Inertia.js host runtime for the [simple_module](https://github.com/antosubash/simple_module_python) framework — builds the app, wires the middleware pipeline, exposes the `sm` / `simple-module` CLI, and ships the project scaffolder.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install simple_module_hosting
9
+ ```
10
+
11
+ For a new project, most users run the generator instead:
12
+
13
+ ```bash
14
+ uvx simple-module new my-app
15
+ ```
16
+
17
+ ## What it provides
18
+
19
+ - `create_app(settings)` — returns a fully-wired `FastAPI` instance with all discovered modules registered.
20
+ - Middleware pipeline (execution order): CorrelationId → RequestLogging → SecurityHeaders → Session → `<module middleware>` → Tenant (opt-in) → Locale → InertiaLayoutData → app.
21
+ - Inertia wiring — shared props (`auth`, `menus`, `i18n`), `InertiaDep`, page-route lookup.
22
+ - CLI entry points: both `sm` and `simple-module` are installed and alias the same Click tree.
23
+ - Scaffolders — `sm create-host`, `sm create-module`, `sm new` (greenfield app with users + dashboard + permissions pre-wired), `sm gen-pages`.
24
+
25
+ ## Usage
26
+
27
+ Minimal `main.py`:
28
+
29
+ ```python
30
+ from simple_module_hosting import create_app
31
+ from simple_module_hosting.settings import Settings
32
+
33
+ settings = Settings() # reads SM_* env vars
34
+ app = create_app(settings) # discovers + registers every installed module
35
+
36
+ if __name__ == "__main__":
37
+ import uvicorn
38
+ uvicorn.run(app, host="0.0.0.0", port=8000)
39
+ ```
40
+
41
+ CLI:
42
+
43
+ ```bash
44
+ simple-module new my-app # scaffold a new project
45
+ simple-module doctor # diagnostic codes (SM001-SM017)
46
+ simple-module gen-pages # regenerate client_app/modules.generated.ts
47
+ ```
48
+
49
+ `sm` works identically to `simple-module`.
50
+
51
+ ## Depends on
52
+
53
+ - `simple_module_core`, `simple_module_db`
54
+ - `fastapi`, `fastapi-inertia`, `starlette`, `uvicorn`, `click`, `jinja2`, `httpx`
55
+
56
+ ## License
57
+
58
+ MIT — see [LICENSE](https://github.com/antosubash/simple_module_python/blob/main/LICENSE).
@@ -0,0 +1,55 @@
1
+ [project]
2
+ name = "simple_module_hosting"
3
+ version = "0.0.1"
4
+ description = "FastAPI + Inertia.js host runtime for simple_module — app_builder, middleware stack, CLI (sm / simple-module), scaffolding"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.12"
9
+ authors = [{ name = "Anto Subash", email = "antosubash@live.com" }]
10
+ keywords = ["simple-module", "fastapi", "inertia", "starlette", "uvicorn", "scaffolding"]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Framework :: FastAPI",
14
+ "Intended Audience :: Developers",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Operating System :: OS Independent",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Topic :: Internet :: WWW/HTTP :: WSGI :: Application",
20
+ "Topic :: Software Development :: Libraries :: Application Frameworks",
21
+ "Typing :: Typed",
22
+ ]
23
+ dependencies = [
24
+ "click>=8.1",
25
+ "fastapi>=0.115",
26
+ "fastapi-inertia>=1.0",
27
+ "httpx>=0.27",
28
+ "jinja2>=3.1",
29
+ "simple_module_core==0.0.1",
30
+ "simple_module_db==0.0.1",
31
+ "starlette>=0.44",
32
+ "tomlkit>=0.13",
33
+ "uvicorn[standard]>=0.34",
34
+ ]
35
+
36
+ [project.scripts]
37
+ sm = "simple_module_hosting.cli:main"
38
+ simple-module = "simple_module_hosting.cli:main"
39
+
40
+ [project.urls]
41
+ Homepage = "https://github.com/antosubash/simple_module_python"
42
+ Repository = "https://github.com/antosubash/simple_module_python"
43
+ Issues = "https://github.com/antosubash/simple_module_python/issues"
44
+ Changelog = "https://github.com/antosubash/simple_module_python/blob/main/CHANGELOG.md"
45
+
46
+ [build-system]
47
+ requires = ["hatchling"]
48
+ build-backend = "hatchling.build"
49
+
50
+ [tool.hatch.build.targets.wheel]
51
+ packages = ["simple_module_hosting"]
52
+
53
+ [tool.uv.sources]
54
+ simple_module_core = { workspace = true }
55
+ simple_module_db = { workspace = true }
@@ -0,0 +1,7 @@
1
+ """SimpleModule Hosting - App builder, module loader, middleware pipeline."""
2
+
3
+ from simple_module_hosting.app_builder import create_app
4
+ from simple_module_hosting.logging import correlation_id, setup_logging
5
+ from simple_module_hosting.settings import Settings
6
+
7
+ __all__ = ["Settings", "correlation_id", "create_app", "setup_logging"]
@@ -0,0 +1,54 @@
1
+ """Framework-wide exception handlers that render Inertia error pages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ from fastapi.responses import JSONResponse
8
+ from inertia import (
9
+ Inertia,
10
+ InertiaConfig,
11
+ InertiaVersionConflictException,
12
+ inertia_version_conflict_exception_handler,
13
+ )
14
+ from simple_module_core.exceptions import NotFoundError
15
+ from starlette.exceptions import HTTPException
16
+ from starlette.requests import Request
17
+ from starlette.responses import Response
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ _INERTIA_ERROR_STATUSES = frozenset({403, 404, 500})
22
+
23
+
24
+ async def render_error_page(request: Request, status_code: int, message: str) -> Response:
25
+ config: InertiaConfig = request.app.state.sm.inertia_config
26
+ try:
27
+ inertia = Inertia(request, config)
28
+ response = await inertia.render("Error", {"status": status_code, "message": message})
29
+ response.status_code = status_code
30
+ return response
31
+ except InertiaVersionConflictException as exc:
32
+ return await inertia_version_conflict_exception_handler(request, exc)
33
+ except Exception:
34
+ # Fallback if Inertia rendering itself fails (e.g. missing session)
35
+ logger.exception("Error page rendering failed, falling back to JSON")
36
+ return JSONResponse(
37
+ status_code=status_code, content={"detail": message or "Internal Server Error"}
38
+ )
39
+
40
+
41
+ async def http_exception_handler(request: Request, exc: HTTPException) -> Response:
42
+ if exc.status_code in _INERTIA_ERROR_STATUSES:
43
+ detail = str(exc.detail) if exc.detail else ""
44
+ return await render_error_page(request, exc.status_code, detail)
45
+ return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
46
+
47
+
48
+ async def not_found_error_handler(request: Request, exc: NotFoundError) -> Response:
49
+ return await render_error_page(request, 404, str(exc))
50
+
51
+
52
+ async def unhandled_exception_handler(request: Request, exc: Exception) -> Response:
53
+ logger.exception("Unhandled exception: %s", exc)
54
+ return await render_error_page(request, 500, "")
@@ -0,0 +1,39 @@
1
+ """Hydrate every registered module's settings from the DB at lifespan start.
2
+
3
+ Runs before any module ``on_startup`` hook so startup code sees DB-backed
4
+ values. ``importlib`` is used to resolve plugin names lazily so the
5
+ framework→plugin AST check (SM009) stays clean.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+ import logging
12
+ from typing import Any
13
+
14
+ from fastapi import FastAPI
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ _MODULE_PACKAGE = "settings"
19
+
20
+
21
+ async def hydrate_all(app: FastAPI, store: Any) -> None:
22
+ """Resolve every registered module's settings from the DB."""
23
+ settings_services = getattr(app.state, _MODULE_PACKAGE, None)
24
+ if settings_services is None:
25
+ return
26
+
27
+ hydrate_settings = importlib.import_module("settings.hydrate").hydrate_settings
28
+
29
+ for package, cls in settings_services.module_registry.items():
30
+ try:
31
+ hydrated = await hydrate_settings(cls, store, package)
32
+ except Exception:
33
+ logger.exception("Hydrating %s failed; falling back to defaults", package)
34
+ continue
35
+ services = getattr(app.state, package, None)
36
+ if services is None:
37
+ logger.warning("app.state.%s missing during hydrate — skipping", package)
38
+ continue
39
+ services.settings = hydrated
@@ -0,0 +1,73 @@
1
+ """Configure fastapi-inertia with the Jinja2 template."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+
8
+ from fastapi import FastAPI
9
+ from inertia import InertiaConfig, inertia_dependency_factory
10
+
11
+ from simple_module_hosting.settings import Settings
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ _INERTIA_VERSION = "1.0"
16
+ _ROOT_TEMPLATE_FILENAME = "index.html"
17
+ _ENTRYPOINT_FILENAME = "main.tsx"
18
+ _ROOT_DIRECTORY = "."
19
+
20
+
21
+ def setup_inertia(
22
+ app: FastAPI,
23
+ settings: Settings,
24
+ modules: list,
25
+ project_root: Path,
26
+ ) -> InertiaConfig | None:
27
+ """Configure fastapi-inertia and attach the dependency factory to app.state.
28
+
29
+ The host's own ``host/templates`` directory is first in the search path so
30
+ it can override module-contributed templates. Each installed module
31
+ contributes additional directories via ``ModuleBase.template_dirs()``.
32
+ """
33
+ from fastapi.templating import Jinja2Templates
34
+
35
+ host_templates = project_root / "host" / "templates"
36
+ directories: list[Path] = []
37
+
38
+ if host_templates.is_dir():
39
+ directories.append(host_templates)
40
+ else:
41
+ logger.warning("Host templates directory not found at %s", host_templates)
42
+
43
+ for mod in modules:
44
+ for path in mod.template_dirs():
45
+ if Path(path).is_dir():
46
+ directories.append(Path(path))
47
+ else:
48
+ logger.warning(
49
+ "Module '%s' declared template dir %s but it does not exist",
50
+ mod.meta.name,
51
+ path,
52
+ )
53
+
54
+ if not directories:
55
+ logger.warning("No usable template directories — Inertia will fail to render views")
56
+ return None
57
+
58
+ templates = Jinja2Templates(directory=directories)
59
+
60
+ inertia_config = InertiaConfig(
61
+ environment=settings.environment,
62
+ version=_INERTIA_VERSION,
63
+ dev_url=settings.vite_dev_url if settings.is_development else "",
64
+ templates=templates,
65
+ root_template_filename=_ROOT_TEMPLATE_FILENAME,
66
+ entrypoint_filename=_ENTRYPOINT_FILENAME,
67
+ root_directory=_ROOT_DIRECTORY,
68
+ use_flash_errors=True,
69
+ )
70
+
71
+ inertia_dep = inertia_dependency_factory(inertia_config)
72
+ app.state.inertia_dependency = inertia_dep
73
+ return inertia_config
@@ -0,0 +1,61 @@
1
+ """Helpers for building Inertia shared-props payloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ from starlette.datastructures import Headers
8
+ from starlette.requests import Request
9
+ from starlette.types import Scope
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ _I18N_SESSION_LOCALE_KEY = "__i18n_locale"
14
+ _INERTIA_HEADER = "x-inertia"
15
+ _INERTIA_HEADER_TRUE = "true"
16
+
17
+
18
+ def build_i18n_block(scope: Scope, request: Request) -> dict:
19
+ """Assemble the ``i18n`` shared-props block for the current request.
20
+
21
+ Rules:
22
+
23
+ * No registry / no locale → serve an empty English block and log once.
24
+ * Inertia XHR partials (``X-Inertia: true``) reuse the client-side
25
+ cached messages; send ``messages: None`` unless the locale differs
26
+ from what was last served on this session.
27
+ * Full page loads and locale transitions ship the complete dict.
28
+ """
29
+ # Test fixtures sometimes build a bare FastAPI with a partial app.state.sm
30
+ # stub (e.g. permissions-only, no i18n); guard both lookups to keep them usable.
31
+ sm = getattr(request.app.state, "sm", None)
32
+ registry = getattr(sm, "i18n_registry", None) if sm is not None else None
33
+ locale = getattr(request.state, "locale", None)
34
+ if registry is None or locale is None:
35
+ logger.warning(
36
+ "InertiaLayoutDataMiddleware: i18n not fully wired "
37
+ "(registry_present=%s, locale_present=%s); serving empty messages",
38
+ registry is not None,
39
+ locale is not None,
40
+ )
41
+ return {"locale": "en", "supportedLocales": ["en"], "messages": {}}
42
+
43
+ is_inertia = Headers(scope=scope).get(_INERTIA_HEADER) == _INERTIA_HEADER_TRUE
44
+ session_dict = scope.get("session")
45
+ # When the session is absent (pre-session-middleware routes, WebSocket
46
+ # upgrades), treat locale as "unchanged" so Inertia XHR requests still
47
+ # skip the messages payload. Non-Inertia requests will always ship them
48
+ # regardless of the session state.
49
+ if session_dict is not None:
50
+ last_locale = session_dict.get(_I18N_SESSION_LOCALE_KEY)
51
+ locale_changed = last_locale != locale
52
+ if locale_changed:
53
+ session_dict[_I18N_SESSION_LOCALE_KEY] = locale
54
+ else:
55
+ locale_changed = False
56
+ send_messages = (not is_inertia) or locale_changed
57
+ return {
58
+ "locale": locale,
59
+ "supportedLocales": registry.available_locales(),
60
+ "messages": registry.messages_snapshot(locale) if send_messages else None,
61
+ }
@@ -0,0 +1,108 @@
1
+ """ASGI middlewares for correlation IDs and structured request logging."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ import uuid
8
+
9
+ from starlette.datastructures import Headers, MutableHeaders
10
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
11
+
12
+ from simple_module_hosting.logging import correlation_id
13
+
14
+ _LOGGER_NAME = "simple_module.request"
15
+ _request_logger = logging.getLogger(_LOGGER_NAME)
16
+
17
+ _SCOPE_HTTP = "http"
18
+ _MSG_RESPONSE_START = "http.response.start"
19
+ _EVENT_REQUEST_STARTED = "request.started"
20
+ _EVENT_REQUEST_COMPLETED = "request.completed"
21
+
22
+ # Paths that produce noisy, low-value log entries
23
+ _QUIET_PREFIXES = ("/health", "/static/")
24
+
25
+
26
+ class CorrelationIdMiddleware:
27
+ """Generate or propagate a correlation ID for every request.
28
+
29
+ Reads the incoming ``X-Correlation-ID`` header (or generates a UUID4) and
30
+ stores it in a :class:`~contextvars.ContextVar` so that every log record
31
+ emitted during the request automatically includes the ID. The same value
32
+ is echoed back in the response header.
33
+ """
34
+
35
+ HEADER = "X-Correlation-ID"
36
+
37
+ def __init__(self, app: ASGIApp) -> None:
38
+ self.app = app
39
+
40
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
41
+ if scope["type"] != _SCOPE_HTTP:
42
+ await self.app(scope, receive, send)
43
+ return
44
+
45
+ cid = Headers(scope=scope).get(self.HEADER) or uuid.uuid4().hex
46
+
47
+ async def send_with_header(message: Message) -> None:
48
+ if message["type"] == _MSG_RESPONSE_START:
49
+ headers = MutableHeaders(scope=message)
50
+ headers[self.HEADER] = cid
51
+ await send(message)
52
+
53
+ token = correlation_id.set(cid)
54
+ try:
55
+ await self.app(scope, receive, send_with_header)
56
+ finally:
57
+ correlation_id.reset(token)
58
+
59
+
60
+ class RequestLoggingMiddleware:
61
+ """Log every request/response pair with timing and status information."""
62
+
63
+ def __init__(self, app: ASGIApp) -> None:
64
+ self.app = app
65
+
66
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
67
+ if scope["type"] != _SCOPE_HTTP:
68
+ await self.app(scope, receive, send)
69
+ return
70
+
71
+ path = scope["path"]
72
+ if any(path.startswith(p) for p in _QUIET_PREFIXES):
73
+ await self.app(scope, receive, send)
74
+ return
75
+
76
+ method = scope["method"]
77
+ client = scope.get("client")
78
+ client_ip = client[0] if client else "unknown"
79
+
80
+ _request_logger.debug(
81
+ _EVENT_REQUEST_STARTED,
82
+ extra={"method": method, "path": path, "client_ip": client_ip},
83
+ )
84
+
85
+ status_code: int | None = None
86
+ start = time.perf_counter()
87
+
88
+ async def send_capture(message: Message) -> None:
89
+ nonlocal status_code
90
+ if message["type"] == _MSG_RESPONSE_START:
91
+ status_code = message["status"]
92
+ await send(message)
93
+
94
+ try:
95
+ await self.app(scope, receive, send_capture)
96
+ finally:
97
+ # Log completion even when the inner app raises, so 500s are observable.
98
+ duration_ms = round((time.perf_counter() - start) * 1000, 2)
99
+ _request_logger.info(
100
+ _EVENT_REQUEST_COMPLETED,
101
+ extra={
102
+ "method": method,
103
+ "path": path,
104
+ "status_code": status_code,
105
+ "duration_ms": duration_ms,
106
+ "client_ip": client_ip,
107
+ },
108
+ )