astris-python 0.1.0__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,99 @@
1
+ import html
2
+ import json
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from fastapi import Request, Response
7
+ from starlette.responses import HTMLResponse, JSONResponse
8
+ from starlette.types import Receive, Scope, Send
9
+
10
+ from astris.inertia.shared import resolve_shared_props
11
+ from astris.inertia.vite import get_vite_tags
12
+
13
+
14
+ class InertiaResponse(Response):
15
+ """Inertia response handler returning JSON on dynamic visits and HTML on initial load."""
16
+
17
+ def __init__(
18
+ self,
19
+ request: Request,
20
+ component: str,
21
+ props: dict[str, Any] | None = None,
22
+ root_template: str = "resources/views/root.html",
23
+ status_code: int = 200,
24
+ headers: dict[str, str] | None = None,
25
+ ) -> None:
26
+ self.request = request
27
+ self.component = component
28
+ self.props = props or {}
29
+ self.root_template = root_template
30
+ self.status_code = status_code
31
+ self._custom_headers = headers or {}
32
+ super().__init__(status_code=status_code)
33
+
34
+ async def __call__(
35
+ self,
36
+ scope: Scope,
37
+ receive: Receive,
38
+ send: Send,
39
+ ) -> None:
40
+ is_inertia = self.request.headers.get("X-Inertia") == "true"
41
+
42
+ shared_props = await resolve_shared_props(self.request)
43
+ merged_props = {**shared_props, **self.props}
44
+
45
+ page_data = {
46
+ "component": self.component,
47
+ "props": merged_props,
48
+ "url": str(self.request.url.path),
49
+ "version": "",
50
+ }
51
+
52
+ if is_inertia:
53
+ response: Response = JSONResponse(
54
+ content=page_data,
55
+ status_code=self.status_code,
56
+ headers={
57
+ "X-Inertia": "true",
58
+ "Vary": "X-Inertia",
59
+ **self._custom_headers,
60
+ },
61
+ )
62
+ else:
63
+ template_path = Path.cwd() / self.root_template
64
+ vite_tags = get_vite_tags(base_path=Path.cwd())
65
+
66
+ if not template_path.exists():
67
+ content = f"""<!DOCTYPE html>
68
+ <html lang="en">
69
+ <head>
70
+ <meta charset="UTF-8">
71
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
72
+ <title>Astris App</title>
73
+ </head>
74
+ <body>
75
+ <div id="app" data-page="{html.escape(json.dumps(page_data))}"></div>
76
+ {vite_tags}
77
+ </body>
78
+ </html>"""
79
+ else:
80
+ raw_html = template_path.read_text(encoding="utf-8")
81
+ escaped_page = html.escape(json.dumps(page_data))
82
+ content = raw_html.replace(
83
+ "@inertia", f'<div id="app" data-page="{escaped_page}"></div>'
84
+ )
85
+ if "@vite" in content:
86
+ content = content.replace("@vite", vite_tags)
87
+
88
+ response = HTMLResponse(
89
+ content=content,
90
+ status_code=self.status_code,
91
+ headers=self._custom_headers,
92
+ )
93
+
94
+ if self.request.cookies.get("_inertia_flash"):
95
+ response.delete_cookie(key="_inertia_flash", path="/")
96
+ if self.request.cookies.get("_inertia_errors"):
97
+ response.delete_cookie(key="_inertia_errors", path="/")
98
+
99
+ await response(scope, receive, send)
@@ -0,0 +1,176 @@
1
+ import inspect
2
+ import json
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+ from urllib.parse import quote, unquote
6
+
7
+ from fastapi import Request
8
+ from starlette.datastructures import MutableHeaders
9
+ from starlette.responses import Response
10
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
11
+
12
+ # Global registry for shared prop factories
13
+ _SHARED_PROPS: dict[str, Any] = {}
14
+
15
+
16
+ def share(
17
+ key_or_dict: str | dict[str, Any] | Callable[[Request], dict[str, Any]],
18
+ value: Any = None,
19
+ ) -> None:
20
+ """Register global shared props that will be injected into every Inertia response.
21
+
22
+ Can be called with:
23
+ - share("app_name", "My App")
24
+ - share({"app_name": "My App", "version": "1.0.0"})
25
+ - share(lambda request: {"auth": {"user": getattr(request.state, "user", None)}})
26
+ """
27
+ if callable(key_or_dict) and value is None:
28
+ _SHARED_PROPS[f"__callable_{id(key_or_dict)}"] = key_or_dict
29
+ elif isinstance(key_or_dict, dict):
30
+ for k, v in key_or_dict.items():
31
+ _SHARED_PROPS[str(k)] = v
32
+ elif isinstance(key_or_dict, str):
33
+ _SHARED_PROPS[key_or_dict] = value
34
+
35
+
36
+ def flash(
37
+ target: Request | Response,
38
+ category: str,
39
+ message: str,
40
+ ) -> None:
41
+ """Flash a session message (e.g. 'success', 'error', 'info') to the next Inertia response."""
42
+ if isinstance(target, Request):
43
+ # Attach to request state for current response or session
44
+ if not hasattr(target.state, "flash_messages"):
45
+ target.state.flash_messages = {}
46
+ target.state.flash_messages[category] = message
47
+
48
+ if hasattr(target, "session"):
49
+ session_flashes = target.session.get("_flash", {})
50
+ session_flashes[category] = message
51
+ target.session["_flash"] = session_flashes
52
+ elif isinstance(target, Response):
53
+ # Attach cookie-based flash for redirect responses
54
+ cookie_flashes = {category: message}
55
+ target.set_cookie(
56
+ key="_inertia_flash",
57
+ value=quote(json.dumps(cookie_flashes)),
58
+ path="/",
59
+ httponly=True,
60
+ samesite="lax",
61
+ )
62
+
63
+
64
+ class FlashMiddleware:
65
+ """Middleware that persists request.state.flash_messages across redirect cycles."""
66
+
67
+ def __init__(self, app: ASGIApp) -> None:
68
+ self.app = app
69
+
70
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
71
+ if scope["type"] != "http":
72
+ await self.app(scope, receive, send)
73
+ return
74
+
75
+ request = Request(scope)
76
+
77
+ async def send_wrapper(message: Message) -> None:
78
+ if message["type"] == "http.response.start":
79
+ flashes = getattr(request.state, "flash_messages", None)
80
+ if flashes and not hasattr(request, "session"):
81
+ headers = MutableHeaders(scope=message)
82
+ cookie_val = quote(json.dumps(flashes))
83
+ cookie_str = (
84
+ f"_inertia_flash={cookie_val}; Path=/; SameSite=lax; HttpOnly"
85
+ )
86
+ headers.append("set-cookie", cookie_str)
87
+ await send(message)
88
+
89
+ await self.app(scope, receive, send_wrapper)
90
+
91
+
92
+ async def resolve_shared_props(request: Request) -> dict[str, Any]:
93
+ """Evaluate and resolve all global and request-scoped shared props."""
94
+ user = getattr(request.state, "user", None)
95
+ if user is None and hasattr(request, "session"):
96
+ user = request.session.get("user_data")
97
+ if user is None and "user_id" in request.session:
98
+ user = {"id": request.session.get("user_id")}
99
+
100
+ resolved: dict[str, Any] = {
101
+ "errors": {},
102
+ "flash": {},
103
+ "auth": {"user": user},
104
+ }
105
+
106
+ # 1. Resolve global registry
107
+ for key, val in _SHARED_PROPS.items():
108
+ if key.startswith("__callable_") and callable(val):
109
+ res = val(request)
110
+ if inspect.isawaitable(res):
111
+ res = await res
112
+ if isinstance(res, dict):
113
+ resolved.update(res)
114
+ elif callable(val):
115
+ res = val(request)
116
+ if inspect.isawaitable(res):
117
+ res = await res
118
+ resolved[key] = res
119
+ else:
120
+ resolved[key] = val
121
+
122
+ # 2. Resolve request.state shared props if set by middleware
123
+ state_shared = getattr(request.state, "inertia_shared_props", None)
124
+ if isinstance(state_shared, dict):
125
+ resolved.update(state_shared)
126
+
127
+ # 3. Resolve flash messages
128
+ flash_messages: dict[str, str] = {}
129
+
130
+ # Check request.state
131
+ state_flashes = getattr(request.state, "flash_messages", None)
132
+ if isinstance(state_flashes, dict):
133
+ flash_messages.update(state_flashes)
134
+
135
+ # Check session if available
136
+ if hasattr(request, "session"):
137
+ session_flashes = request.session.pop("_flash", None)
138
+ if isinstance(session_flashes, dict):
139
+ flash_messages.update(session_flashes)
140
+
141
+ # Check cookie fallback
142
+ cookie_flash = request.cookies.get("_inertia_flash")
143
+ if cookie_flash:
144
+ try:
145
+ parsed = json.loads(unquote(cookie_flash))
146
+ if isinstance(parsed, dict):
147
+ flash_messages.update(parsed)
148
+ except json.JSONDecodeError:
149
+ pass
150
+
151
+ resolved["flash"] = flash_messages
152
+
153
+ # 4. Resolve session / cookie flashed validation errors
154
+ errors: dict[str, Any] = {}
155
+ if hasattr(request, "session"):
156
+ session_errors = request.session.pop("_errors", None)
157
+ if isinstance(session_errors, dict):
158
+ errors.update(session_errors)
159
+
160
+ state_errors = getattr(request.state, "errors", None)
161
+ if isinstance(state_errors, dict):
162
+ errors.update(state_errors)
163
+
164
+ cookie_errors = request.cookies.get("_inertia_errors")
165
+ if cookie_errors:
166
+ try:
167
+ parsed_errors = json.loads(unquote(cookie_errors))
168
+ if isinstance(parsed_errors, dict):
169
+ errors.update(parsed_errors)
170
+ except json.JSONDecodeError:
171
+ pass
172
+
173
+ if errors:
174
+ resolved["errors"] = errors
175
+
176
+ return resolved
astris/inertia/vite.py ADDED
@@ -0,0 +1,89 @@
1
+ import json
2
+ import socket
3
+ from pathlib import Path
4
+ from urllib.parse import urlparse
5
+
6
+
7
+ def is_vite_running(url: str = "http://localhost:5173", timeout: float = 0.05) -> bool:
8
+ """Check if the Vite dev server is reachable."""
9
+ parsed = urlparse(url)
10
+ host = parsed.hostname or "127.0.0.1"
11
+ port = parsed.port or 5173
12
+ try:
13
+ with socket.create_connection((host, port), timeout=timeout):
14
+ return True
15
+ except (OSError, TimeoutError):
16
+ return False
17
+
18
+
19
+ def get_vite_tags(
20
+ entry: str = "resources/js/app.ts",
21
+ base_path: Path | None = None,
22
+ vite_dev_url: str = "http://localhost:5173",
23
+ ) -> str:
24
+ """Generate script and link tags by detecting active Vite dev server or parsing public/build/.vite/manifest.json."""
25
+ base = base_path or Path.cwd()
26
+
27
+ # 1. Check for hot file (e.g., public/hot) or active Vite dev server
28
+ hot_file = base / "public" / "hot"
29
+ dev_url = vite_dev_url
30
+ if hot_file.exists():
31
+ dev_url = hot_file.read_text(encoding="utf-8").strip()
32
+
33
+ if is_vite_running(dev_url):
34
+ clean_url = dev_url.rstrip("/")
35
+ clean_entry = entry.lstrip("/")
36
+ return (
37
+ f"<!-- Vite Dev Server Scripts -->\n"
38
+ f'<script type="module" src="{clean_url}/@vite/client"></script>\n'
39
+ f'<script type="module" src="{clean_url}/{clean_entry}"></script>'
40
+ )
41
+
42
+ # 2. Parse production manifest from public/build/.vite/manifest.json or public/build/manifest.json
43
+ manifest_paths = [
44
+ base / "public" / "build" / ".vite" / "manifest.json",
45
+ base / "public" / "build" / "manifest.json",
46
+ ]
47
+
48
+ manifest_data: dict[str, dict] | None = None
49
+ for manifest_path in manifest_paths:
50
+ if manifest_path.exists():
51
+ try:
52
+ manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
53
+ break
54
+ except (json.JSONDecodeError, OSError):
55
+ continue
56
+
57
+ if manifest_data:
58
+ clean_entry = entry.lstrip("/")
59
+ entry_info = manifest_data.get(clean_entry)
60
+ if not entry_info:
61
+ for key, val in manifest_data.items():
62
+ if key.endswith(clean_entry) or val.get("src") == clean_entry:
63
+ entry_info = val
64
+ break
65
+
66
+ if entry_info:
67
+ tags = ["<!-- Production Vite Assets -->"]
68
+ # CSS links
69
+ for css_file in entry_info.get("css", []):
70
+ tags.append(
71
+ f'<link rel="stylesheet" href="/build/{css_file.lstrip("/")}">'
72
+ )
73
+ # JS script
74
+ js_file = entry_info.get("file", "")
75
+ if js_file:
76
+ tags.append(
77
+ f'<script type="module" src="/build/{js_file.lstrip("/")}"></script>'
78
+ )
79
+
80
+ return "\n".join(tags)
81
+
82
+ # 3. Fallback if neither active dev server nor manifest is found
83
+ clean_url = vite_dev_url.rstrip("/")
84
+ clean_entry = entry.lstrip("/")
85
+ return (
86
+ f"<!-- Vite Assets (Fallback) -->\n"
87
+ f'<script type="module" src="{clean_url}/@vite/client"></script>\n'
88
+ f'<script type="module" src="{clean_url}/{clean_entry}"></script>'
89
+ )