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.
- astris/__init__.py +7 -0
- astris/assets/favicon.ico +0 -0
- astris/auth/__init__.py +29 -0
- astris/auth/installer.py +512 -0
- astris/auth/session.py +241 -0
- astris/cli.py +421 -0
- astris/config.py +50 -0
- astris/database/__init__.py +28 -0
- astris/database/migrations.py +259 -0
- astris/database/session.py +108 -0
- astris/http/__init__.py +25 -0
- astris/http/static.py +31 -0
- astris/inertia/__init__.py +8 -0
- astris/inertia/exceptions.py +133 -0
- astris/inertia/response.py +99 -0
- astris/inertia/shared.py +176 -0
- astris/inertia/vite.py +89 -0
- astris/installer.py +595 -0
- astris/kernel.py +230 -0
- astris/py.typed +0 -0
- astris/routing/__init__.py +31 -0
- astris/routing/router.py +38 -0
- astris/security/__init__.py +3 -0
- astris/security/csrf.py +100 -0
- astris_python-0.1.0.dist-info/METADATA +241 -0
- astris_python-0.1.0.dist-info/RECORD +29 -0
- astris_python-0.1.0.dist-info/WHEEL +4 -0
- astris_python-0.1.0.dist-info/entry_points.txt +5 -0
- astris_python-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|
astris/inertia/shared.py
ADDED
|
@@ -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
|
+
)
|