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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.