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.
- simple_module_hosting-0.0.1/.gitignore +59 -0
- simple_module_hosting-0.0.1/LICENSE +21 -0
- simple_module_hosting-0.0.1/PKG-INFO +93 -0
- simple_module_hosting-0.0.1/README.md +58 -0
- simple_module_hosting-0.0.1/pyproject.toml +55 -0
- simple_module_hosting-0.0.1/simple_module_hosting/__init__.py +7 -0
- simple_module_hosting-0.0.1/simple_module_hosting/_error_handlers.py +54 -0
- simple_module_hosting-0.0.1/simple_module_hosting/_hydrate_step.py +39 -0
- simple_module_hosting-0.0.1/simple_module_hosting/_inertia_setup.py +73 -0
- simple_module_hosting-0.0.1/simple_module_hosting/_inertia_shared.py +61 -0
- simple_module_hosting-0.0.1/simple_module_hosting/_observability.py +108 -0
- simple_module_hosting-0.0.1/simple_module_hosting/_phase_helpers.py +160 -0
- simple_module_hosting-0.0.1/simple_module_hosting/app_builder.py +281 -0
- simple_module_hosting-0.0.1/simple_module_hosting/bootstrap_settings.py +55 -0
- simple_module_hosting-0.0.1/simple_module_hosting/cli.py +292 -0
- simple_module_hosting-0.0.1/simple_module_hosting/health.py +79 -0
- simple_module_hosting-0.0.1/simple_module_hosting/host_settings.py +33 -0
- simple_module_hosting-0.0.1/simple_module_hosting/i18n_deps.py +25 -0
- simple_module_hosting-0.0.1/simple_module_hosting/i18n_manifest.py +202 -0
- simple_module_hosting-0.0.1/simple_module_hosting/i18n_middleware.py +95 -0
- simple_module_hosting-0.0.1/simple_module_hosting/inertia_deps.py +27 -0
- simple_module_hosting-0.0.1/simple_module_hosting/inertia_utils.py +31 -0
- simple_module_hosting-0.0.1/simple_module_hosting/logging.py +91 -0
- simple_module_hosting-0.0.1/simple_module_hosting/manifest.py +250 -0
- simple_module_hosting-0.0.1/simple_module_hosting/middleware.py +272 -0
- simple_module_hosting-0.0.1/simple_module_hosting/migrations.py +65 -0
- simple_module_hosting-0.0.1/simple_module_hosting/permissions.py +75 -0
- simple_module_hosting-0.0.1/simple_module_hosting/py.typed +0 -0
- simple_module_hosting-0.0.1/simple_module_hosting/redirects.py +45 -0
- simple_module_hosting-0.0.1/simple_module_hosting/scaffolding.py +294 -0
- simple_module_hosting-0.0.1/simple_module_hosting/settings.py +10 -0
- simple_module_hosting-0.0.1/simple_module_hosting/templates/host/.env.example +20 -0
- simple_module_hosting-0.0.1/simple_module_hosting/templates/host/.gitignore +19 -0
- simple_module_hosting-0.0.1/simple_module_hosting/templates/host/Makefile +24 -0
- simple_module_hosting-0.0.1/simple_module_hosting/templates/host/README.md.tpl +59 -0
- simple_module_hosting-0.0.1/simple_module_hosting/templates/host/alembic.ini +36 -0
- simple_module_hosting-0.0.1/simple_module_hosting/templates/host/client_app/app.tsx +16 -0
- simple_module_hosting-0.0.1/simple_module_hosting/templates/host/client_app/main.tsx +2 -0
- simple_module_hosting-0.0.1/simple_module_hosting/templates/host/client_app/package.json.tpl +23 -0
- simple_module_hosting-0.0.1/simple_module_hosting/templates/host/client_app/pages/Error.tsx +13 -0
- simple_module_hosting-0.0.1/simple_module_hosting/templates/host/client_app/pages.ts +47 -0
- simple_module_hosting-0.0.1/simple_module_hosting/templates/host/client_app/styles.css +7 -0
- simple_module_hosting-0.0.1/simple_module_hosting/templates/host/client_app/tsconfig.json +16 -0
- simple_module_hosting-0.0.1/simple_module_hosting/templates/host/client_app/vite.config.ts +39 -0
- simple_module_hosting-0.0.1/simple_module_hosting/templates/host/main.py +27 -0
- simple_module_hosting-0.0.1/simple_module_hosting/templates/host/migrations/env.py +80 -0
- simple_module_hosting-0.0.1/simple_module_hosting/templates/host/migrations/script.py.mako +26 -0
- simple_module_hosting-0.0.1/simple_module_hosting/templates/host/migrations/versions/.gitkeep +1 -0
- simple_module_hosting-0.0.1/simple_module_hosting/templates/host/pyproject.toml.tpl +17 -0
- simple_module_hosting-0.0.1/simple_module_hosting/templates/host/templates/index.html +12 -0
- simple_module_hosting-0.0.1/simple_module_hosting/templates/module/.github/workflows/ci.yml +32 -0
- simple_module_hosting-0.0.1/simple_module_hosting/templates/module/.github/workflows/publish.yml.tpl +52 -0
- simple_module_hosting-0.0.1/simple_module_hosting/templates/module/.gitignore +14 -0
- simple_module_hosting-0.0.1/simple_module_hosting/templates/module/README.md.tpl +82 -0
- simple_module_hosting-0.0.1/simple_module_hosting/templates/module/__PACKAGE__/__init__.py +0 -0
- simple_module_hosting-0.0.1/simple_module_hosting/templates/module/__PACKAGE__/endpoints/__init__.py +0 -0
- simple_module_hosting-0.0.1/simple_module_hosting/templates/module/__PACKAGE__/endpoints/api.py.tpl +11 -0
- simple_module_hosting-0.0.1/simple_module_hosting/templates/module/__PACKAGE__/module.py.tpl +46 -0
- simple_module_hosting-0.0.1/simple_module_hosting/templates/module/__PACKAGE__/pages/.gitkeep +1 -0
- simple_module_hosting-0.0.1/simple_module_hosting/templates/module/__PACKAGE__/services.py.tpl +22 -0
- simple_module_hosting-0.0.1/simple_module_hosting/templates/module/package.json.tpl +16 -0
- simple_module_hosting-0.0.1/simple_module_hosting/templates/module/pyproject.toml.tpl +39 -0
- simple_module_hosting-0.0.1/simple_module_hosting/templates/module/tests/__init__.py +0 -0
- simple_module_hosting-0.0.1/simple_module_hosting/templates/module/tests/test_module.py.tpl +27 -0
- simple_module_hosting-0.0.1/simple_module_hosting/templates/module/tsconfig.json.tpl +11 -0
- simple_module_hosting-0.0.1/tests/test_app.py +182 -0
- simple_module_hosting-0.0.1/tests/test_cli_new.py +78 -0
- simple_module_hosting-0.0.1/tests/test_health.py +87 -0
- simple_module_hosting-0.0.1/tests/test_hosting_permissions.py +230 -0
- simple_module_hosting-0.0.1/tests/test_i18n_manifest.py +116 -0
- simple_module_hosting-0.0.1/tests/test_inertia_i18n_shared_props.py +97 -0
- simple_module_hosting-0.0.1/tests/test_locale_middleware.py +99 -0
- simple_module_hosting-0.0.1/tests/test_logging.py +225 -0
- simple_module_hosting-0.0.1/tests/test_scaffolding_host.py +171 -0
- simple_module_hosting-0.0.1/tests/test_scaffolding_module.py +179 -0
- simple_module_hosting-0.0.1/tests/test_settings_i18n.py +31 -0
- simple_module_hosting-0.0.1/tests/test_settings_secrets.py +25 -0
- simple_module_hosting-0.0.1/tests/test_tenant_middleware.py +160 -0
- 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
|
+
)
|