simple-module-inertia 0.0.35__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_inertia-0.0.35/.gitignore +66 -0
- simple_module_inertia-0.0.35/LICENSE +21 -0
- simple_module_inertia-0.0.35/NOTICE +25 -0
- simple_module_inertia-0.0.35/PKG-INFO +102 -0
- simple_module_inertia-0.0.35/README.md +75 -0
- simple_module_inertia-0.0.35/pyproject.toml +38 -0
- simple_module_inertia-0.0.35/simple_module_inertia/__init__.py +47 -0
- simple_module_inertia-0.0.35/simple_module_inertia/config.py +41 -0
- simple_module_inertia-0.0.35/simple_module_inertia/deps.py +23 -0
- simple_module_inertia-0.0.35/simple_module_inertia/errors.py +53 -0
- simple_module_inertia-0.0.35/simple_module_inertia/inertia.py +145 -0
- simple_module_inertia-0.0.35/simple_module_inertia/manifest.py +70 -0
- simple_module_inertia-0.0.35/simple_module_inertia/page.py +105 -0
- simple_module_inertia-0.0.35/simple_module_inertia/props.py +121 -0
- simple_module_inertia-0.0.35/simple_module_inertia/py.typed +0 -0
- simple_module_inertia-0.0.35/simple_module_inertia/request.py +59 -0
- simple_module_inertia-0.0.35/simple_module_inertia/resolve.py +99 -0
- simple_module_inertia-0.0.35/simple_module_inertia/response.py +46 -0
- simple_module_inertia-0.0.35/simple_module_inertia/templating.py +74 -0
- simple_module_inertia-0.0.35/tests/test_adapter_config.py +35 -0
- simple_module_inertia-0.0.35/tests/test_adapter_errors.py +54 -0
- simple_module_inertia-0.0.35/tests/test_adapter_manifest.py +107 -0
- simple_module_inertia-0.0.35/tests/test_adapter_package.py +23 -0
- simple_module_inertia-0.0.35/tests/test_adapter_page.py +150 -0
- simple_module_inertia-0.0.35/tests/test_adapter_props.py +72 -0
- simple_module_inertia-0.0.35/tests/test_adapter_render.py +100 -0
- simple_module_inertia-0.0.35/tests/test_adapter_request.py +72 -0
- simple_module_inertia-0.0.35/tests/test_adapter_resolve_metadata.py +113 -0
- simple_module_inertia-0.0.35/tests/test_adapter_resolve_visits.py +116 -0
- simple_module_inertia-0.0.35/tests/test_adapter_response.py +50 -0
- simple_module_inertia-0.0.35/tests/test_adapter_templating.py +46 -0
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
# VitePress (docs)
|
|
40
|
+
docs/.vitepress/cache/
|
|
41
|
+
docs/.vitepress/dist/
|
|
42
|
+
|
|
43
|
+
# Auto-generated frontend module manifest (regenerated by the host at boot
|
|
44
|
+
# or via `make gen-pages`).
|
|
45
|
+
host/client_app/modules.manifest.json
|
|
46
|
+
host/client_app/modules.generated.ts
|
|
47
|
+
host/client_app/modules.generated.css
|
|
48
|
+
host/client_app/modules.assets.json
|
|
49
|
+
|
|
50
|
+
# Worktrees
|
|
51
|
+
.worktrees/
|
|
52
|
+
|
|
53
|
+
# Performance profiles
|
|
54
|
+
.memray/
|
|
55
|
+
.benchmarks/
|
|
56
|
+
|
|
57
|
+
# OS
|
|
58
|
+
.DS_Store
|
|
59
|
+
Thumbs.db
|
|
60
|
+
|
|
61
|
+
.playwright-cli/*
|
|
62
|
+
.playwright-mcp/*
|
|
63
|
+
host/client_app/.playwright-cli/*
|
|
64
|
+
.superpowers/
|
|
65
|
+
.qa/
|
|
66
|
+
.verify/
|
|
@@ -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,25 @@
|
|
|
1
|
+
This package is a fork of fastapi-inertia 1.1.0 (https://github.com/hxjo/fastapi-inertia),
|
|
2
|
+
distributed under the MIT License. The original license text follows, as the
|
|
3
|
+
MIT License requires.
|
|
4
|
+
|
|
5
|
+
The MIT License (MIT)
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2022 Bellawatt
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in
|
|
17
|
+
all copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
25
|
+
THE SOFTWARE.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: simple_module_inertia
|
|
3
|
+
Version: 0.0.35
|
|
4
|
+
Summary: Inertia.js v3 server adapter for FastAPI — the protocol half of simple_module's React frontend
|
|
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
|
+
License-File: NOTICE
|
|
13
|
+
Keywords: fastapi,inertia,inertiajs,react,simple-module
|
|
14
|
+
Classifier: Development Status :: 3 - Alpha
|
|
15
|
+
Classifier: Framework :: FastAPI
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.12
|
|
23
|
+
Requires-Dist: fastapi>=0.115
|
|
24
|
+
Requires-Dist: jinja2>=3.1
|
|
25
|
+
Requires-Dist: starlette>=0.44
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# simple_module_inertia
|
|
29
|
+
|
|
30
|
+
Inertia.js **v3** server adapter for FastAPI. This is the protocol half of a
|
|
31
|
+
simple_module app's React frontend: it turns `inertia.render("Users/Index", props)`
|
|
32
|
+
into either the JSON page object an Inertia visit expects or the HTML document a
|
|
33
|
+
full page load expects, and implements every v3 prop type (`optional`, `always`,
|
|
34
|
+
`defer`, `merge`, `prepend`, `deep_merge`, `once`, `scroll`).
|
|
35
|
+
|
|
36
|
+
Forked from [fastapi-inertia](https://github.com/hxjo/fastapi-inertia) (MIT) —
|
|
37
|
+
see `NOTICE`. Precognition and SSR are not implemented.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
`simple_module_hosting` depends on this package, so any app scaffolded with
|
|
42
|
+
`smpy new` already has it. To use the adapter on its own:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
uv add simple_module_inertia
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
Inside a simple_module app, reach the per-request `Inertia` instance through
|
|
51
|
+
`InertiaDep` and import the response type and prop factories from here:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from simple_module_hosting.inertia_deps import InertiaDep
|
|
55
|
+
from simple_module_inertia import InertiaResponse, defer, merge, optional
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def index(inertia: InertiaDep) -> InertiaResponse:
|
|
59
|
+
return await inertia.render(
|
|
60
|
+
"Feed/Index",
|
|
61
|
+
{
|
|
62
|
+
"posts": merge(load_posts, match_on="id"), # append on navigation
|
|
63
|
+
"analytics": defer(load_analytics), # loaded after first paint
|
|
64
|
+
"stats": optional(load_stats), # only on partial reloads that ask
|
|
65
|
+
},
|
|
66
|
+
)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Standalone, configure it once and register the two exception handlers:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from fastapi import Depends, FastAPI
|
|
73
|
+
from fastapi.templating import Jinja2Templates
|
|
74
|
+
from simple_module_inertia import (
|
|
75
|
+
Inertia,
|
|
76
|
+
InertiaConfig,
|
|
77
|
+
InertiaVersionConflictException,
|
|
78
|
+
inertia_dependency_factory,
|
|
79
|
+
inertia_version_conflict_exception_handler,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
config = InertiaConfig(
|
|
83
|
+
templates=Jinja2Templates(directory="templates"),
|
|
84
|
+
entrypoint_filename="main.tsx",
|
|
85
|
+
root_directory=".",
|
|
86
|
+
dev_url="http://localhost:5173",
|
|
87
|
+
)
|
|
88
|
+
inertia_dep = inertia_dependency_factory(config)
|
|
89
|
+
|
|
90
|
+
app = FastAPI()
|
|
91
|
+
app.add_exception_handler(
|
|
92
|
+
InertiaVersionConflictException, inertia_version_conflict_exception_handler
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@app.get("/")
|
|
97
|
+
async def home(inertia: Inertia = Depends(inertia_dep)):
|
|
98
|
+
return await inertia.render("Home", {"greeting": "hello"})
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The root template needs `{% inertia_head %}` in `<head>` and `{% inertia_body %}`
|
|
102
|
+
in `<body>`; the page object lands in `<div id="app" data-page="…">`.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# simple_module_inertia
|
|
2
|
+
|
|
3
|
+
Inertia.js **v3** server adapter for FastAPI. This is the protocol half of a
|
|
4
|
+
simple_module app's React frontend: it turns `inertia.render("Users/Index", props)`
|
|
5
|
+
into either the JSON page object an Inertia visit expects or the HTML document a
|
|
6
|
+
full page load expects, and implements every v3 prop type (`optional`, `always`,
|
|
7
|
+
`defer`, `merge`, `prepend`, `deep_merge`, `once`, `scroll`).
|
|
8
|
+
|
|
9
|
+
Forked from [fastapi-inertia](https://github.com/hxjo/fastapi-inertia) (MIT) —
|
|
10
|
+
see `NOTICE`. Precognition and SSR are not implemented.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
`simple_module_hosting` depends on this package, so any app scaffolded with
|
|
15
|
+
`smpy new` already has it. To use the adapter on its own:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
uv add simple_module_inertia
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
Inside a simple_module app, reach the per-request `Inertia` instance through
|
|
24
|
+
`InertiaDep` and import the response type and prop factories from here:
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from simple_module_hosting.inertia_deps import InertiaDep
|
|
28
|
+
from simple_module_inertia import InertiaResponse, defer, merge, optional
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def index(inertia: InertiaDep) -> InertiaResponse:
|
|
32
|
+
return await inertia.render(
|
|
33
|
+
"Feed/Index",
|
|
34
|
+
{
|
|
35
|
+
"posts": merge(load_posts, match_on="id"), # append on navigation
|
|
36
|
+
"analytics": defer(load_analytics), # loaded after first paint
|
|
37
|
+
"stats": optional(load_stats), # only on partial reloads that ask
|
|
38
|
+
},
|
|
39
|
+
)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Standalone, configure it once and register the two exception handlers:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from fastapi import Depends, FastAPI
|
|
46
|
+
from fastapi.templating import Jinja2Templates
|
|
47
|
+
from simple_module_inertia import (
|
|
48
|
+
Inertia,
|
|
49
|
+
InertiaConfig,
|
|
50
|
+
InertiaVersionConflictException,
|
|
51
|
+
inertia_dependency_factory,
|
|
52
|
+
inertia_version_conflict_exception_handler,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
config = InertiaConfig(
|
|
56
|
+
templates=Jinja2Templates(directory="templates"),
|
|
57
|
+
entrypoint_filename="main.tsx",
|
|
58
|
+
root_directory=".",
|
|
59
|
+
dev_url="http://localhost:5173",
|
|
60
|
+
)
|
|
61
|
+
inertia_dep = inertia_dependency_factory(config)
|
|
62
|
+
|
|
63
|
+
app = FastAPI()
|
|
64
|
+
app.add_exception_handler(
|
|
65
|
+
InertiaVersionConflictException, inertia_version_conflict_exception_handler
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.get("/")
|
|
70
|
+
async def home(inertia: Inertia = Depends(inertia_dep)):
|
|
71
|
+
return await inertia.render("Home", {"greeting": "hello"})
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The root template needs `{% inertia_head %}` in `<head>` and `{% inertia_body %}`
|
|
75
|
+
in `<body>`; the page object lands in `<div id="app" data-page="…">`.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "simple_module_inertia"
|
|
3
|
+
version = "0.0.35"
|
|
4
|
+
description = "Inertia.js v3 server adapter for FastAPI — the protocol half of simple_module's React frontend"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE", "NOTICE"]
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
authors = [{ name = "Anto Subash", email = "antosubash@live.com" }]
|
|
10
|
+
keywords = ["simple-module", "inertia", "inertiajs", "fastapi", "react"]
|
|
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
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"fastapi>=0.115",
|
|
23
|
+
"jinja2>=3.1",
|
|
24
|
+
"starlette>=0.44",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/antosubash/simple_module_python"
|
|
29
|
+
Repository = "https://github.com/antosubash/simple_module_python"
|
|
30
|
+
Issues = "https://github.com/antosubash/simple_module_python/issues"
|
|
31
|
+
Changelog = "https://github.com/antosubash/simple_module_python/blob/main/CHANGELOG.md"
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["hatchling"]
|
|
35
|
+
build-backend = "hatchling.build"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["simple_module_inertia"]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Inertia.js v3 server adapter for FastAPI. See README.md and NOTICE."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from simple_module_inertia.config import InertiaConfig
|
|
6
|
+
from simple_module_inertia.deps import inertia_dependency_factory
|
|
7
|
+
from simple_module_inertia.errors import (
|
|
8
|
+
InertiaVersionConflictException,
|
|
9
|
+
inertia_request_validation_exception_handler,
|
|
10
|
+
inertia_version_conflict_exception_handler,
|
|
11
|
+
)
|
|
12
|
+
from simple_module_inertia.inertia import Inertia
|
|
13
|
+
from simple_module_inertia.page import PropEncodingError
|
|
14
|
+
from simple_module_inertia.props import (
|
|
15
|
+
always,
|
|
16
|
+
deep_merge,
|
|
17
|
+
defer,
|
|
18
|
+
lazy,
|
|
19
|
+
merge,
|
|
20
|
+
once,
|
|
21
|
+
optional,
|
|
22
|
+
prepend,
|
|
23
|
+
scroll,
|
|
24
|
+
)
|
|
25
|
+
from simple_module_inertia.response import InertiaResponse
|
|
26
|
+
from simple_module_inertia.templating import InertiaExtension
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"Inertia",
|
|
30
|
+
"InertiaConfig",
|
|
31
|
+
"InertiaExtension",
|
|
32
|
+
"InertiaResponse",
|
|
33
|
+
"InertiaVersionConflictException",
|
|
34
|
+
"PropEncodingError",
|
|
35
|
+
"always",
|
|
36
|
+
"deep_merge",
|
|
37
|
+
"defer",
|
|
38
|
+
"inertia_dependency_factory",
|
|
39
|
+
"inertia_request_validation_exception_handler",
|
|
40
|
+
"inertia_version_conflict_exception_handler",
|
|
41
|
+
"lazy",
|
|
42
|
+
"merge",
|
|
43
|
+
"once",
|
|
44
|
+
"optional",
|
|
45
|
+
"prepend",
|
|
46
|
+
"scroll",
|
|
47
|
+
]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Adapter configuration.
|
|
2
|
+
|
|
3
|
+
Field names match upstream ``fastapi-inertia`` so ``simple_module_hosting``'s
|
|
4
|
+
``setup_inertia`` keeps constructing it the same way. The one addition is that
|
|
5
|
+
``version`` may be a zero-arg callable, so an app can derive the asset version
|
|
6
|
+
from its Vite manifest hash at boot instead of hard-coding ``"1.0"``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any, Literal
|
|
14
|
+
|
|
15
|
+
from fastapi.templating import Jinja2Templates
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class InertiaConfig:
|
|
20
|
+
templates: Jinja2Templates
|
|
21
|
+
environment: Literal["development", "production"] = "development"
|
|
22
|
+
version: str | Callable[[], str] = "1.0"
|
|
23
|
+
dev_url: str = "http://localhost:5173"
|
|
24
|
+
ssr_url: str = "http://localhost:13714"
|
|
25
|
+
ssr_enabled: bool = False
|
|
26
|
+
manifest_json_path: str = ""
|
|
27
|
+
root_directory: str = "src"
|
|
28
|
+
root_template_filename: str = "index.html"
|
|
29
|
+
entrypoint_filename: str = "main.js"
|
|
30
|
+
use_flash_messages: bool = False
|
|
31
|
+
use_flash_errors: bool = False
|
|
32
|
+
flash_message_key: str = "messages"
|
|
33
|
+
flash_error_key: str = "errors"
|
|
34
|
+
assets_prefix: str = ""
|
|
35
|
+
extra_template_context: dict[str, Any] = field(default_factory=dict)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def resolved_version(config: InertiaConfig) -> str:
|
|
39
|
+
"""The asset version as a string, calling ``version`` if it is a callable."""
|
|
40
|
+
version = config.version
|
|
41
|
+
return str(version()) if callable(version) else str(version)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Build the per-request dependency hosting publishes on ``app.state``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fastapi import Request
|
|
9
|
+
|
|
10
|
+
from simple_module_inertia.config import InertiaConfig
|
|
11
|
+
from simple_module_inertia.inertia import Inertia
|
|
12
|
+
from simple_module_inertia.templating import InertiaExtension
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def inertia_dependency_factory(config: InertiaConfig) -> Callable[..., Inertia]:
|
|
16
|
+
env = config.templates.env
|
|
17
|
+
if InertiaExtension not in env.extensions.values():
|
|
18
|
+
env.add_extension(InertiaExtension)
|
|
19
|
+
|
|
20
|
+
def inertia_dependency(request: Request, client: Any = None) -> Inertia:
|
|
21
|
+
return Inertia(request, config, client)
|
|
22
|
+
|
|
23
|
+
return inertia_dependency
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Exception types and handlers. Names match upstream — hosting imports them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastapi import Request, Response, status
|
|
8
|
+
from fastapi.exception_handlers import request_validation_exception_handler
|
|
9
|
+
from fastapi.exceptions import RequestValidationError
|
|
10
|
+
from starlette.responses import RedirectResponse
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class InertiaVersionConflictException(Exception): # noqa: N818 - upstream name; hosting imports it
|
|
14
|
+
"""Raised on a GET whose ``X-Inertia-Version`` no longer matches ours."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, url: str) -> None:
|
|
17
|
+
self.url = url
|
|
18
|
+
super().__init__()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def inertia_version_conflict_exception_handler(
|
|
22
|
+
_: Request, exc: InertiaVersionConflictException
|
|
23
|
+
) -> Response:
|
|
24
|
+
return Response(
|
|
25
|
+
status_code=status.HTTP_409_CONFLICT, headers={"X-Inertia-Location": str(exc.url)}
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _field(loc: tuple[Any, ...]) -> Any:
|
|
30
|
+
return loc[1] if len(loc) > 1 else loc[0]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def inertia_request_validation_exception_handler(
|
|
34
|
+
request: Request, exc: RequestValidationError
|
|
35
|
+
) -> Response:
|
|
36
|
+
"""Flash validation errors and send the browser back, scoped by error bag."""
|
|
37
|
+
if "x-inertia" not in request.headers:
|
|
38
|
+
return await request_validation_exception_handler(request, exc)
|
|
39
|
+
|
|
40
|
+
errors: dict[str, Any] = {}
|
|
41
|
+
bag = request.headers.get("X-Inertia-Error-Bag")
|
|
42
|
+
for error in exc.errors():
|
|
43
|
+
field = _field(tuple(error["loc"]))
|
|
44
|
+
if bag is None:
|
|
45
|
+
errors[field] = error["msg"]
|
|
46
|
+
else:
|
|
47
|
+
errors.setdefault(bag, {})[field] = error["msg"]
|
|
48
|
+
request.session["_errors"] = errors
|
|
49
|
+
|
|
50
|
+
code = (
|
|
51
|
+
status.HTTP_307_TEMPORARY_REDIRECT if request.method == "GET" else status.HTTP_303_SEE_OTHER
|
|
52
|
+
)
|
|
53
|
+
return RedirectResponse(url=request.headers.get("Referer", "/"), status_code=code)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""The request-scoped facade. Thin: each stage lives in its own module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
from fastapi import Request
|
|
8
|
+
from jinja2.utils import htmlsafe_json_dumps
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
from starlette.responses import RedirectResponse, Response
|
|
11
|
+
|
|
12
|
+
from simple_module_inertia.config import InertiaConfig, resolved_version
|
|
13
|
+
from simple_module_inertia.errors import InertiaVersionConflictException
|
|
14
|
+
from simple_module_inertia.manifest import entry_assets
|
|
15
|
+
from simple_module_inertia.page import build_page, encode_page
|
|
16
|
+
from simple_module_inertia.request import InertiaRequest
|
|
17
|
+
from simple_module_inertia.resolve import resolve_props
|
|
18
|
+
from simple_module_inertia.response import (
|
|
19
|
+
InertiaResponse,
|
|
20
|
+
fragment_redirect,
|
|
21
|
+
json_response,
|
|
22
|
+
location,
|
|
23
|
+
redirect,
|
|
24
|
+
)
|
|
25
|
+
from simple_module_inertia.templating import InertiaContext
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Inertia:
|
|
29
|
+
def __init__(self, request: Request, config: InertiaConfig, client: Any = None) -> None:
|
|
30
|
+
self._request = request
|
|
31
|
+
self._config = config
|
|
32
|
+
self._client = client # kept for signature compatibility; SSR is not implemented
|
|
33
|
+
self._req = InertiaRequest.from_headers(request.headers, request.method)
|
|
34
|
+
self._version = resolved_version(config)
|
|
35
|
+
self._shared: dict[str, Any] = {}
|
|
36
|
+
self._encrypt_history = False
|
|
37
|
+
self._clear_history = False
|
|
38
|
+
self._preserve_fragment = False
|
|
39
|
+
if self._is_stale():
|
|
40
|
+
raise InertiaVersionConflictException(url=str(request.url))
|
|
41
|
+
|
|
42
|
+
# -- request state ----------------------------------------------------
|
|
43
|
+
|
|
44
|
+
def _is_stale(self) -> bool:
|
|
45
|
+
"""Only a GET is rejected; the client re-submits mutations itself."""
|
|
46
|
+
return (
|
|
47
|
+
self._req.is_inertia
|
|
48
|
+
and self._req.method == "GET"
|
|
49
|
+
and self._req.version is not None
|
|
50
|
+
and self._req.version != self._version
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def share(self, **props: Any) -> None:
|
|
54
|
+
self._shared.update(props)
|
|
55
|
+
|
|
56
|
+
def flash(self, message: str, category: str) -> None:
|
|
57
|
+
if not self._config.use_flash_messages:
|
|
58
|
+
raise NotImplementedError("Flash messages are not enabled")
|
|
59
|
+
self._request.session.setdefault("_messages", []).append(
|
|
60
|
+
{"message": message, "category": category}
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def encrypt_history(self) -> Inertia:
|
|
64
|
+
self._encrypt_history = True
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
def clear_history(self) -> Inertia:
|
|
68
|
+
self._clear_history = True
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
def preserve_fragment(self) -> Inertia:
|
|
72
|
+
self._preserve_fragment = True
|
|
73
|
+
return self
|
|
74
|
+
|
|
75
|
+
# -- redirects --------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def redirect(self, url: str) -> RedirectResponse:
|
|
78
|
+
return redirect(url, method=self._request.method)
|
|
79
|
+
|
|
80
|
+
def back(self) -> RedirectResponse:
|
|
81
|
+
return self.redirect(self._request.headers.get("Referer", "/"))
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def location(url: str) -> Response:
|
|
85
|
+
return location(url)
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def redirect_with_fragment(url: str) -> Response:
|
|
89
|
+
return fragment_redirect(url)
|
|
90
|
+
|
|
91
|
+
# -- render -----------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def _pop_session(self, key: str, default: Any) -> Any:
|
|
94
|
+
session = self._request.session
|
|
95
|
+
return session.pop(key) if key in session else default
|
|
96
|
+
|
|
97
|
+
async def render(
|
|
98
|
+
self, component: str, props: dict[str, Any] | BaseModel | None = None
|
|
99
|
+
) -> InertiaResponse:
|
|
100
|
+
page_props: dict[str, Any] = (
|
|
101
|
+
props.model_dump() if isinstance(props, BaseModel) else dict(props or {})
|
|
102
|
+
)
|
|
103
|
+
if self._config.use_flash_messages:
|
|
104
|
+
page_props[self._config.flash_message_key] = self._pop_session("_messages", [])
|
|
105
|
+
errors = self._pop_session("_errors", {}) if self._config.use_flash_errors else {}
|
|
106
|
+
# ``errors`` is the protocol's always-prop; keep the configured key
|
|
107
|
+
# pointing at the same dict so a custom flash_error_key still works.
|
|
108
|
+
if self._config.flash_error_key != "errors":
|
|
109
|
+
page_props[self._config.flash_error_key] = errors
|
|
110
|
+
|
|
111
|
+
resolved = await resolve_props(page_props, self._shared, component, self._req)
|
|
112
|
+
page = build_page(
|
|
113
|
+
component=component,
|
|
114
|
+
resolved=resolved,
|
|
115
|
+
url=str(self._request.url),
|
|
116
|
+
version=self._version,
|
|
117
|
+
errors=errors,
|
|
118
|
+
encrypt_history=self._encrypt_history,
|
|
119
|
+
clear_history=self._clear_history,
|
|
120
|
+
preserve_fragment=self._preserve_fragment,
|
|
121
|
+
)
|
|
122
|
+
if self._req.is_inertia:
|
|
123
|
+
return json_response(page)
|
|
124
|
+
return self._render_html(page)
|
|
125
|
+
|
|
126
|
+
def _render_html(self, page: dict[str, Any]) -> InertiaResponse:
|
|
127
|
+
files = entry_assets(self._config)
|
|
128
|
+
# encode_page yields a JSON-safe dict; htmlsafe_json_dumps serialises it
|
|
129
|
+
# exactly once with HTML-safe escaping. Passing a pre-dumped string here
|
|
130
|
+
# would double-encode the page object into a quoted JSON literal.
|
|
131
|
+
page_json = htmlsafe_json_dumps(encode_page(page))
|
|
132
|
+
context = {
|
|
133
|
+
"inertia": InertiaContext(
|
|
134
|
+
environment=self._config.environment,
|
|
135
|
+
dev_url=self._config.dev_url,
|
|
136
|
+
is_ssr=False,
|
|
137
|
+
data=cast(str, page_json),
|
|
138
|
+
js=files.js,
|
|
139
|
+
css=files.css,
|
|
140
|
+
),
|
|
141
|
+
**self._config.extra_template_context,
|
|
142
|
+
}
|
|
143
|
+
return self._config.templates.TemplateResponse(
|
|
144
|
+
name=self._config.root_template_filename, request=self._request, context=context
|
|
145
|
+
)
|