simple-module-inertia 0.0.35__py3-none-any.whl
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/__init__.py +47 -0
- simple_module_inertia/config.py +41 -0
- simple_module_inertia/deps.py +23 -0
- simple_module_inertia/errors.py +53 -0
- simple_module_inertia/inertia.py +145 -0
- simple_module_inertia/manifest.py +70 -0
- simple_module_inertia/page.py +105 -0
- simple_module_inertia/props.py +121 -0
- simple_module_inertia/py.typed +0 -0
- simple_module_inertia/request.py +59 -0
- simple_module_inertia/resolve.py +99 -0
- simple_module_inertia/response.py +46 -0
- simple_module_inertia/templating.py +74 -0
- simple_module_inertia-0.0.35.dist-info/METADATA +102 -0
- simple_module_inertia-0.0.35.dist-info/RECORD +18 -0
- simple_module_inertia-0.0.35.dist-info/WHEEL +4 -0
- simple_module_inertia-0.0.35.dist-info/licenses/LICENSE +21 -0
- simple_module_inertia-0.0.35.dist-info/licenses/NOTICE +25 -0
|
@@ -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
|
+
)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Locate the entry's JS and CSS for the current environment.
|
|
2
|
+
|
|
3
|
+
Vite keys its manifest by the entry's path relative to the Vite root
|
|
4
|
+
(``"main.tsx"``); upstream looked it up as ``f"{root_directory}/{entrypoint}"``
|
|
5
|
+
and ``KeyError``'d, which is why hosting used to rewrite the manifest file.
|
|
6
|
+
Try Vite's key first, upstream's shape second, the first ``isEntry`` chunk
|
|
7
|
+
last — and say which manifest was searched when none of them hit.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import posixpath
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from functools import lru_cache
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from simple_module_inertia.config import InertiaConfig
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class InertiaFiles:
|
|
24
|
+
js: str
|
|
25
|
+
css: list[str] = field(default_factory=list)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@lru_cache
|
|
29
|
+
def read_manifest(path: str) -> dict[str, Any]:
|
|
30
|
+
with Path(path).open(encoding="utf-8") as fh:
|
|
31
|
+
return json.load(fh)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _find_entry(manifest: dict[str, Any], config: InertiaConfig) -> dict[str, Any] | None:
|
|
35
|
+
keys = (
|
|
36
|
+
config.entrypoint_filename,
|
|
37
|
+
f"{config.root_directory}/{config.entrypoint_filename}",
|
|
38
|
+
)
|
|
39
|
+
for key in keys:
|
|
40
|
+
if key in manifest:
|
|
41
|
+
return manifest[key]
|
|
42
|
+
return next((chunk for chunk in manifest.values() if chunk.get("isEntry")), None)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _public(prefix: str, file: str) -> str:
|
|
46
|
+
return posixpath.join("/", prefix, file) if prefix else posixpath.join("/", file)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def entry_assets(config: InertiaConfig) -> InertiaFiles:
|
|
50
|
+
if config.environment == "production" or config.ssr_enabled:
|
|
51
|
+
if not config.manifest_json_path:
|
|
52
|
+
raise LookupError(
|
|
53
|
+
"Production rendering needs InertiaConfig.manifest_json_path to point at "
|
|
54
|
+
"the built Vite manifest; it is empty"
|
|
55
|
+
)
|
|
56
|
+
manifest = read_manifest(config.manifest_json_path)
|
|
57
|
+
entry = _find_entry(manifest, config)
|
|
58
|
+
if entry is None:
|
|
59
|
+
raise LookupError(
|
|
60
|
+
f"No entry chunk for {config.entrypoint_filename!r} in {config.manifest_json_path}"
|
|
61
|
+
)
|
|
62
|
+
return InertiaFiles(
|
|
63
|
+
js=_public(config.assets_prefix, entry["file"]),
|
|
64
|
+
css=[_public(config.assets_prefix, f) for f in entry.get("css") or []],
|
|
65
|
+
)
|
|
66
|
+
root = config.root_directory.strip("/")
|
|
67
|
+
path = (
|
|
68
|
+
config.entrypoint_filename if root in ("", ".") else f"{root}/{config.entrypoint_filename}"
|
|
69
|
+
)
|
|
70
|
+
return InertiaFiles(js=f"{config.dev_url.rstrip('/')}/{path}")
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Page-object assembly and serialisation.
|
|
2
|
+
|
|
3
|
+
Two upstream defects are closed here rather than patched around it:
|
|
4
|
+
|
|
5
|
+
* the page ``url`` is root-relative, as the protocol specifies — an absolute
|
|
6
|
+
url is handed to ``history.pushState`` and rejected behind a TLS-terminating
|
|
7
|
+
proxy (GH #223);
|
|
8
|
+
* one encoder serves both the HTML and JSON branches, so a prop that renders
|
|
9
|
+
on a full page load also renders on a client-side visit.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
from urllib.parse import urlsplit
|
|
16
|
+
|
|
17
|
+
from fastapi.encoders import jsonable_encoder
|
|
18
|
+
|
|
19
|
+
from simple_module_inertia.resolve import ResolvedProps
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PropEncodingError(ValueError):
|
|
23
|
+
"""A prop could not be serialised; ``prop_path`` names it."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, prop_path: str, cause: Exception) -> None:
|
|
26
|
+
self.prop_path = prop_path
|
|
27
|
+
super().__init__(f"Inertia prop {prop_path} is not JSON-serialisable: {cause}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def to_relative_url(url: str) -> str:
|
|
31
|
+
"""Path-and-query only. Already-relative input is returned unchanged."""
|
|
32
|
+
try:
|
|
33
|
+
parts = urlsplit(url)
|
|
34
|
+
except ValueError: # pragma: no cover - urlsplit is near-total
|
|
35
|
+
return url
|
|
36
|
+
if not parts.scheme and not parts.netloc:
|
|
37
|
+
return url
|
|
38
|
+
relative = parts.path or "/"
|
|
39
|
+
return f"{relative}?{parts.query}" if parts.query else relative
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_page(
|
|
43
|
+
*,
|
|
44
|
+
component: str,
|
|
45
|
+
resolved: ResolvedProps,
|
|
46
|
+
url: str,
|
|
47
|
+
version: str,
|
|
48
|
+
errors: dict[str, Any],
|
|
49
|
+
encrypt_history: bool = False,
|
|
50
|
+
clear_history: bool = False,
|
|
51
|
+
preserve_fragment: bool = False,
|
|
52
|
+
) -> dict[str, Any]:
|
|
53
|
+
"""The page object. Conditional fields are present only when set."""
|
|
54
|
+
page: dict[str, Any] = {
|
|
55
|
+
"component": component,
|
|
56
|
+
"props": {**resolved.props, "errors": errors},
|
|
57
|
+
"url": to_relative_url(url),
|
|
58
|
+
"version": version,
|
|
59
|
+
}
|
|
60
|
+
optional_fields: list[tuple[str, Any]] = [
|
|
61
|
+
("deferredProps", resolved.deferred),
|
|
62
|
+
("mergeProps", resolved.merge),
|
|
63
|
+
("prependProps", resolved.prepend),
|
|
64
|
+
("deepMergeProps", resolved.deep_merge),
|
|
65
|
+
("matchPropsOn", resolved.match_on),
|
|
66
|
+
("onceProps", resolved.once),
|
|
67
|
+
("scrollProps", resolved.scroll),
|
|
68
|
+
("sharedProps", resolved.shared),
|
|
69
|
+
]
|
|
70
|
+
for key, value in optional_fields:
|
|
71
|
+
if value:
|
|
72
|
+
page[key] = value
|
|
73
|
+
for key, flag in (
|
|
74
|
+
("encryptHistory", encrypt_history),
|
|
75
|
+
("clearHistory", clear_history),
|
|
76
|
+
("preserveFragment", preserve_fragment),
|
|
77
|
+
):
|
|
78
|
+
if flag:
|
|
79
|
+
page[key] = True
|
|
80
|
+
return page
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _locate_failure(value: Any, path: str) -> str:
|
|
84
|
+
"""Find the deepest prop path that fails to encode, for the error message."""
|
|
85
|
+
if isinstance(value, dict):
|
|
86
|
+
for key, child in value.items():
|
|
87
|
+
try:
|
|
88
|
+
jsonable_encoder(child)
|
|
89
|
+
except Exception:
|
|
90
|
+
return _locate_failure(child, f"{path}.{key}")
|
|
91
|
+
elif isinstance(value, list):
|
|
92
|
+
for index, child in enumerate(value):
|
|
93
|
+
try:
|
|
94
|
+
jsonable_encoder(child)
|
|
95
|
+
except Exception:
|
|
96
|
+
return _locate_failure(child, f"{path}[{index}]")
|
|
97
|
+
return path
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def encode_page(page: dict[str, Any]) -> Any:
|
|
101
|
+
"""JSON-safe structure for both render branches; names the failing prop."""
|
|
102
|
+
try:
|
|
103
|
+
return jsonable_encoder(page)
|
|
104
|
+
except Exception as exc:
|
|
105
|
+
raise PropEncodingError(_locate_failure(page["props"], "props"), exc) from exc
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Prop wrappers.
|
|
2
|
+
|
|
3
|
+
One frozen ``Prop`` carries every v3 flag; each factory sets one of them and
|
|
4
|
+
accepts either a raw value or an existing ``Prop``, so wrappers compose by
|
|
5
|
+
stacking: ``merge(defer(load_feed), match_on="id")``. The resolver reads the
|
|
6
|
+
flags; nothing here talks to a request.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import inspect
|
|
12
|
+
import warnings
|
|
13
|
+
from dataclasses import dataclass, replace
|
|
14
|
+
from typing import Any, Literal
|
|
15
|
+
|
|
16
|
+
MergeMode = Literal["append", "prepend", "deep"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class ScrollMeta:
|
|
21
|
+
"""Infinite-scroll cursor metadata, reported under ``scrollProps``."""
|
|
22
|
+
|
|
23
|
+
page_name: str
|
|
24
|
+
current_page: int
|
|
25
|
+
previous_page: int | None
|
|
26
|
+
next_page: int | None
|
|
27
|
+
reset: bool = False
|
|
28
|
+
|
|
29
|
+
def as_page_object(self) -> dict[str, Any]:
|
|
30
|
+
return {
|
|
31
|
+
"pageName": self.page_name,
|
|
32
|
+
"currentPage": self.current_page,
|
|
33
|
+
"previousPage": self.previous_page,
|
|
34
|
+
"nextPage": self.next_page,
|
|
35
|
+
"reset": self.reset,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Prop:
|
|
41
|
+
value: Any
|
|
42
|
+
optional: bool = False
|
|
43
|
+
always: bool = False
|
|
44
|
+
deferred_group: str | None = None
|
|
45
|
+
merge_mode: MergeMode | None = None
|
|
46
|
+
match_on: str | None = None
|
|
47
|
+
once_key: str | None = None
|
|
48
|
+
once_expires_at: int | None = None
|
|
49
|
+
scroll: ScrollMeta | None = None
|
|
50
|
+
# ``once(value)`` with no key still marks the prop as once; the key defaults
|
|
51
|
+
# to the prop name at resolve time. A private flag keeps that distinction
|
|
52
|
+
# without making ``once_key`` a sentinel.
|
|
53
|
+
_once_flag: bool = False
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def is_once(self) -> bool:
|
|
57
|
+
return self._once_flag
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def as_prop(value: Any) -> Prop:
|
|
61
|
+
return value if isinstance(value, Prop) else Prop(value)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def optional(value: Any) -> Prop:
|
|
65
|
+
return replace(as_prop(value), optional=True)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def always(value: Any) -> Prop:
|
|
69
|
+
return replace(as_prop(value), always=True)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def defer(value: Any, group: str = "default") -> Prop:
|
|
73
|
+
return replace(as_prop(value), deferred_group=group)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def merge(value: Any, match_on: str | None = None) -> Prop:
|
|
77
|
+
return replace(as_prop(value), merge_mode="append", match_on=match_on)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def prepend(value: Any, match_on: str | None = None) -> Prop:
|
|
81
|
+
return replace(as_prop(value), merge_mode="prepend", match_on=match_on)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def deep_merge(value: Any, match_on: str | None = None) -> Prop:
|
|
85
|
+
return replace(as_prop(value), merge_mode="deep", match_on=match_on)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def once(value: Any, key: str | None = None, expires_at: int | None = None) -> Prop:
|
|
89
|
+
return replace(as_prop(value), once_key=key, once_expires_at=expires_at, _once_flag=True)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def scroll(
|
|
93
|
+
value: Any,
|
|
94
|
+
*,
|
|
95
|
+
page_name: str,
|
|
96
|
+
current_page: int,
|
|
97
|
+
previous_page: int | None,
|
|
98
|
+
next_page: int | None,
|
|
99
|
+
reset: bool = False,
|
|
100
|
+
) -> Prop:
|
|
101
|
+
if not page_name:
|
|
102
|
+
raise ValueError("scroll() requires a non-empty page_name")
|
|
103
|
+
meta = ScrollMeta(page_name, current_page, previous_page, next_page, reset)
|
|
104
|
+
return replace(as_prop(value), scroll=meta)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def lazy(value: Any) -> Prop:
|
|
108
|
+
"""Deprecated alias kept for callers migrating from fastapi-inertia."""
|
|
109
|
+
warnings.warn("lazy() is deprecated; use optional()", DeprecationWarning, stacklevel=2)
|
|
110
|
+
return optional(value)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def resolve_value(value: Any) -> Any:
|
|
114
|
+
"""Unwrap a ``Prop``, call a callable, await an awaitable — recursively."""
|
|
115
|
+
if isinstance(value, Prop):
|
|
116
|
+
value = value.value
|
|
117
|
+
if callable(value):
|
|
118
|
+
value = value()
|
|
119
|
+
if inspect.isawaitable(value):
|
|
120
|
+
value = await value
|
|
121
|
+
return value
|
|
File without changes
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""The request half of the protocol: every ``X-Inertia-*`` header, typed.
|
|
2
|
+
|
|
3
|
+
A pure function of the header mapping and method — no Starlette ``Request``
|
|
4
|
+
crosses this boundary, so the resolver and its conformance tests never need
|
|
5
|
+
a server.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
MergeIntent = Literal["append", "prepend"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _csv(value: str | None) -> frozenset[str]:
|
|
18
|
+
if not value:
|
|
19
|
+
return frozenset()
|
|
20
|
+
return frozenset(part.strip() for part in value.split(",") if part.strip())
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class InertiaRequest:
|
|
25
|
+
is_inertia: bool
|
|
26
|
+
version: str | None
|
|
27
|
+
partial_component: str | None
|
|
28
|
+
partial_data: frozenset[str]
|
|
29
|
+
partial_except: frozenset[str]
|
|
30
|
+
reset: frozenset[str]
|
|
31
|
+
error_bag: str | None
|
|
32
|
+
merge_intent: MergeIntent | None
|
|
33
|
+
except_once_props: frozenset[str]
|
|
34
|
+
is_prefetch: bool
|
|
35
|
+
method: str
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def from_headers(cls, headers: Mapping[str, str], method: str) -> InertiaRequest:
|
|
39
|
+
lower = {k.lower(): v for k, v in headers.items()}
|
|
40
|
+
intent = (lower.get("x-inertia-infinite-scroll-merge-intent") or "").lower()
|
|
41
|
+
return cls(
|
|
42
|
+
is_inertia="x-inertia" in lower,
|
|
43
|
+
version=lower.get("x-inertia-version"),
|
|
44
|
+
partial_component=lower.get("x-inertia-partial-component") or None,
|
|
45
|
+
partial_data=_csv(lower.get("x-inertia-partial-data")),
|
|
46
|
+
partial_except=_csv(lower.get("x-inertia-partial-except")),
|
|
47
|
+
reset=_csv(lower.get("x-inertia-reset")),
|
|
48
|
+
error_bag=lower.get("x-inertia-error-bag") or None,
|
|
49
|
+
merge_intent=intent if intent in ("append", "prepend") else None,
|
|
50
|
+
except_once_props=_csv(lower.get("x-inertia-except-once-props")),
|
|
51
|
+
is_prefetch=(lower.get("purpose") or "").lower() == "prefetch",
|
|
52
|
+
method=method.upper(),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def is_partial_for(self, component: str) -> bool:
|
|
56
|
+
"""A partial reload targets one component and names data or exclusions."""
|
|
57
|
+
return self.partial_component == component and bool(
|
|
58
|
+
self.partial_data or self.partial_except
|
|
59
|
+
)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Prop resolution — the v3 rules in one place.
|
|
2
|
+
|
|
3
|
+
Input: the page's props, the shared props, the component being rendered and
|
|
4
|
+
the typed request. Output: the resolved values plus every piece of metadata
|
|
5
|
+
the page object announces (deferred groups, merge strategies, once keys,
|
|
6
|
+
scroll cursors, shared keys). Nothing here knows about HTTP.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from simple_module_inertia.props import Prop, as_prop, resolve_value
|
|
16
|
+
from simple_module_inertia.request import InertiaRequest
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ResolvedProps:
|
|
21
|
+
props: dict[str, Any] = field(default_factory=dict)
|
|
22
|
+
deferred: dict[str, list[str]] = field(default_factory=dict)
|
|
23
|
+
merge: list[str] = field(default_factory=list)
|
|
24
|
+
prepend: list[str] = field(default_factory=list)
|
|
25
|
+
deep_merge: list[str] = field(default_factory=list)
|
|
26
|
+
match_on: list[str] = field(default_factory=list)
|
|
27
|
+
once: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
28
|
+
scroll: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
29
|
+
shared: list[str] = field(default_factory=list)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def _deep_resolve(value: Any) -> Any:
|
|
33
|
+
"""Resolve callables/awaitables/Props anywhere inside a container."""
|
|
34
|
+
value = await resolve_value(value)
|
|
35
|
+
if isinstance(value, dict):
|
|
36
|
+
return {k: await _deep_resolve(v) for k, v in value.items()}
|
|
37
|
+
if isinstance(value, list):
|
|
38
|
+
return [await _deep_resolve(v) for v in value]
|
|
39
|
+
return value
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _wants(name: str, prop: Prop, req: InertiaRequest, partial: bool) -> bool:
|
|
43
|
+
"""Should this prop's value be resolved for this response?"""
|
|
44
|
+
if prop.is_once and name in req.except_once_props:
|
|
45
|
+
return False
|
|
46
|
+
if prop.always:
|
|
47
|
+
return True
|
|
48
|
+
if partial:
|
|
49
|
+
if req.partial_data and name not in req.partial_data:
|
|
50
|
+
return False
|
|
51
|
+
return name not in req.partial_except
|
|
52
|
+
return prop.optional is False and prop.deferred_group is None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _announce_merge(name: str, prop: Prop, req: InertiaRequest, out: ResolvedProps) -> None:
|
|
56
|
+
if name in req.reset:
|
|
57
|
+
return
|
|
58
|
+
mode = prop.merge_mode
|
|
59
|
+
if prop.scroll is not None:
|
|
60
|
+
mode = req.merge_intent or "append"
|
|
61
|
+
if mode == "append":
|
|
62
|
+
out.merge.append(name)
|
|
63
|
+
elif mode == "prepend":
|
|
64
|
+
out.prepend.append(name)
|
|
65
|
+
elif mode == "deep":
|
|
66
|
+
out.deep_merge.append(name)
|
|
67
|
+
else:
|
|
68
|
+
return
|
|
69
|
+
if prop.match_on:
|
|
70
|
+
out.match_on.append(f"{name}.{prop.match_on}")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def resolve_props(
|
|
74
|
+
page_props: Mapping[str, Any],
|
|
75
|
+
shared_props: Mapping[str, Any],
|
|
76
|
+
component: str,
|
|
77
|
+
req: InertiaRequest,
|
|
78
|
+
) -> ResolvedProps:
|
|
79
|
+
out = ResolvedProps()
|
|
80
|
+
partial = req.is_partial_for(component)
|
|
81
|
+
combined: dict[str, Any] = {**shared_props, **page_props}
|
|
82
|
+
|
|
83
|
+
for name, raw in combined.items():
|
|
84
|
+
prop = as_prop(raw)
|
|
85
|
+
if not partial and prop.deferred_group is not None:
|
|
86
|
+
out.deferred.setdefault(prop.deferred_group, []).append(name)
|
|
87
|
+
if prop.merge_mode is not None or prop.scroll is not None:
|
|
88
|
+
_announce_merge(name, prop, req, out)
|
|
89
|
+
if not _wants(name, prop, req, partial):
|
|
90
|
+
continue
|
|
91
|
+
out.props[name] = await _deep_resolve(prop)
|
|
92
|
+
if prop.is_once:
|
|
93
|
+
key = prop.once_key or name
|
|
94
|
+
out.once[key] = {"prop": name, "expiresAt": prop.once_expires_at}
|
|
95
|
+
if prop.scroll is not None:
|
|
96
|
+
out.scroll[name] = prop.scroll.as_page_object()
|
|
97
|
+
|
|
98
|
+
out.shared = [k for k in shared_props if k in out.props and k not in page_props]
|
|
99
|
+
return out
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""HTTP responses for each protocol outcome.
|
|
2
|
+
|
|
3
|
+
``Vary: Accept`` is upstream's value and is kept on purpose: hosting's
|
|
4
|
+
``InertiaCacheMiddleware`` appends ``X-Inertia`` on the way out and its tests
|
|
5
|
+
pin today's header set.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from fastapi import status
|
|
13
|
+
from fastapi.responses import HTMLResponse, JSONResponse
|
|
14
|
+
from starlette.responses import RedirectResponse, Response
|
|
15
|
+
|
|
16
|
+
from simple_module_inertia.page import encode_page
|
|
17
|
+
|
|
18
|
+
InertiaResponse = HTMLResponse | JSONResponse
|
|
19
|
+
|
|
20
|
+
_JSON_HEADERS = {"X-Inertia": "true", "Vary": "Accept"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def json_response(page: dict[str, Any]) -> JSONResponse:
|
|
24
|
+
return JSONResponse(content=encode_page(page), headers=_JSON_HEADERS)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def version_conflict(url: str, version: str) -> Response:
|
|
28
|
+
return Response(
|
|
29
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
30
|
+
headers={"X-Inertia-Location": url, "X-Inertia-Version": version},
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def redirect(url: str, *, method: str) -> RedirectResponse:
|
|
35
|
+
code = (
|
|
36
|
+
status.HTTP_307_TEMPORARY_REDIRECT if method.upper() == "GET" else status.HTTP_303_SEE_OTHER
|
|
37
|
+
)
|
|
38
|
+
return RedirectResponse(url=url, status_code=code)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def location(url: str) -> Response:
|
|
42
|
+
return Response(status_code=status.HTTP_409_CONFLICT, headers={"X-Inertia-Location": url})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def fragment_redirect(url: str) -> Response:
|
|
46
|
+
return Response(status_code=status.HTTP_409_CONFLICT, headers={"X-Inertia-Redirect": url})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Jinja glue: the template context and the ``inertia_head``/``inertia_body`` tags.
|
|
2
|
+
|
|
3
|
+
Ported from upstream. The SSR branches stay so the template contract is the
|
|
4
|
+
same, but nothing in this package calls an SSR server.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
from jinja2 import nodes
|
|
13
|
+
from jinja2.ext import Extension
|
|
14
|
+
from jinja2.parser import Parser
|
|
15
|
+
from jinja2.runtime import Context
|
|
16
|
+
from markupsafe import Markup
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class InertiaContext:
|
|
21
|
+
environment: Literal["development", "production"]
|
|
22
|
+
dev_url: str
|
|
23
|
+
css: list[str]
|
|
24
|
+
js: str
|
|
25
|
+
is_ssr: bool
|
|
26
|
+
data: str | None = None
|
|
27
|
+
ssr_head: str | None = None
|
|
28
|
+
ssr_body: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class InertiaExtension(Extension):
|
|
32
|
+
tags = {"inertia_head", "inertia_body"} # noqa: RUF012 - Jinja declares this an instance attr
|
|
33
|
+
|
|
34
|
+
def parse(self, parser: Parser) -> nodes.Node:
|
|
35
|
+
tag_name = next(parser.stream).value
|
|
36
|
+
lineno = parser.stream.current.lineno
|
|
37
|
+
node = self.call_method(f"_render_{tag_name}", [nodes.ContextReference()], lineno=lineno)
|
|
38
|
+
return nodes.Output([node]).set_lineno(lineno)
|
|
39
|
+
|
|
40
|
+
def _render_inertia_head(self, context: Context) -> Markup:
|
|
41
|
+
inertia: InertiaContext = context["inertia"]
|
|
42
|
+
fragments: list[str] = []
|
|
43
|
+
if inertia.environment == "development":
|
|
44
|
+
fragments.append(
|
|
45
|
+
f'<script type="module" src="{inertia.dev_url}/@vite/client"></script>'
|
|
46
|
+
)
|
|
47
|
+
if inertia.is_ssr:
|
|
48
|
+
if inertia.ssr_head is None:
|
|
49
|
+
raise ValueError("SSR is enabled but no SSR head was provided")
|
|
50
|
+
fragments.append(inertia.ssr_head)
|
|
51
|
+
fragments.extend(f'<link rel="stylesheet" href="{css}">' for css in inertia.css)
|
|
52
|
+
return Markup("\n".join(fragments))
|
|
53
|
+
|
|
54
|
+
def _render_inertia_body(self, context: Context) -> Markup:
|
|
55
|
+
inertia: InertiaContext = context["inertia"]
|
|
56
|
+
fragments: list[str] = []
|
|
57
|
+
if inertia.is_ssr:
|
|
58
|
+
if inertia.ssr_body is None:
|
|
59
|
+
raise ValueError("SSR is enabled but no SSR body was provided")
|
|
60
|
+
fragments.append(inertia.ssr_body)
|
|
61
|
+
else:
|
|
62
|
+
if inertia.data is None:
|
|
63
|
+
raise ValueError("No data was provided for the Inertia page")
|
|
64
|
+
# Inertia 3's getInitialPageFromDOM reads only the JSON script
|
|
65
|
+
# element; Inertia 2 read only the data-page attribute. Emit both
|
|
66
|
+
# so the adapter ships before the client migrates and keeps working
|
|
67
|
+
# after. ``data`` is htmlsafe_json_dumps output, so it cannot break
|
|
68
|
+
# out of either the attribute or the script element.
|
|
69
|
+
fragments.append(
|
|
70
|
+
f'<script data-page="app" type="application/json">{inertia.data}</script>'
|
|
71
|
+
)
|
|
72
|
+
fragments.append(f"<div id=\"app\" data-page='{inertia.data}'></div>")
|
|
73
|
+
fragments.append(f'<script type="module" src="{inertia.js}"></script>')
|
|
74
|
+
return Markup("\n".join(fragments))
|
|
@@ -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,18 @@
|
|
|
1
|
+
simple_module_inertia/__init__.py,sha256=m4a7iyProurReZX_H2W0usjUOLdzzYIH1XaTyiX7rkg,1218
|
|
2
|
+
simple_module_inertia/config.py,sha256=cGcID9UV4TmnY95eoDNKQ74WOZcL4S9lpSX40nww7xY,1461
|
|
3
|
+
simple_module_inertia/deps.py,sha256=YkqA2WFOqoki84urzdiwCz1MBWu69G86SvJhj-s2-z4,740
|
|
4
|
+
simple_module_inertia/errors.py,sha256=sMjXj59P2uQLL2jh4YEVyz8VxmPzTHNulvyJgYJ4mx8,1842
|
|
5
|
+
simple_module_inertia/inertia.py,sha256=gLKyKxAyW9bsAqSMxmMdN71AfieOnYD0S_oB5Jt35nU,5597
|
|
6
|
+
simple_module_inertia/manifest.py,sha256=Y5R3bUzuoOFCHPuYhI0j5UsmYfjK07xwtYC60Sn_Wyc,2473
|
|
7
|
+
simple_module_inertia/page.py,sha256=hyZQTGLXJvKTWUNwE3ZCUflE02_4cRloQkjrIBT_6kE,3463
|
|
8
|
+
simple_module_inertia/props.py,sha256=p3jCLjbauW2NEpOTVZ40Q2QRhf-ulkN5LpMvhYzGVMc,3554
|
|
9
|
+
simple_module_inertia/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
simple_module_inertia/request.py,sha256=SvqkN3V_xxcPwR6qr7ANM96fWPavZFLr6-esUarWPq8,2197
|
|
11
|
+
simple_module_inertia/resolve.py,sha256=RJsJRRwO0Wx0Fvu70QMxdGFyZIVFFWqRY1s5Jqo59Gk,3598
|
|
12
|
+
simple_module_inertia/response.py,sha256=kSjJIB6F-GaXx512JmRQqz-ivot2zDDhBr16emX-Fkk,1436
|
|
13
|
+
simple_module_inertia/templating.py,sha256=814QcF8P4yQ6fJbvEn0CMyO_g7uTEtgW-n74w3aW88o,3010
|
|
14
|
+
simple_module_inertia-0.0.35.dist-info/METADATA,sha256=wq0SaiBR5dQwmiEY1VhdoFNK17Zner82n9CbQw_WdyE,3590
|
|
15
|
+
simple_module_inertia-0.0.35.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
16
|
+
simple_module_inertia-0.0.35.dist-info/licenses/LICENSE,sha256=Yn66lhLklsF5p7pa85_ksQrJ79Q-FgOaUAHevLBjer4,1068
|
|
17
|
+
simple_module_inertia-0.0.35.dist-info/licenses/NOTICE,sha256=DSPMTNIt3v8BzJXayZth8xzn72FMhfFigTNxmzMFzpk,1266
|
|
18
|
+
simple_module_inertia-0.0.35.dist-info/RECORD,,
|
|
@@ -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.
|