rational 0.1.0a1__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.
- rational/__init__.py +99 -0
- rational/__main__.py +3 -0
- rational/_tracking.py +50 -0
- rational/apidocs.py +40 -0
- rational/app.py +377 -0
- rational/backend/__init__.py +14 -0
- rational/backend/database.py +46 -0
- rational/backend/services.py +174 -0
- rational/binding.py +94 -0
- rational/cli.py +299 -0
- rational/component.py +167 -0
- rational/components/__init__.py +52 -0
- rational/components/basic.py +247 -0
- rational/components/forms.py +253 -0
- rational/components/layout.py +138 -0
- rational/context.py +69 -0
- rational/dev.py +208 -0
- rational/document.py +54 -0
- rational/errors.py +99 -0
- rational/events.py +73 -0
- rational/http.py +72 -0
- rational/logging.py +185 -0
- rational/pages.py +181 -0
- rational/protocol.py +142 -0
- rational/py.typed +0 -0
- rational/rendering/__init__.py +5 -0
- rational/rendering/builder.py +86 -0
- rational/rendering/html.py +60 -0
- rational/rendering/nodes.py +121 -0
- rational/router.py +77 -0
- rational/runtime.py +323 -0
- rational/session.py +152 -0
- rational/state.py +113 -0
- rational/static/dev.js +53 -0
- rational/static/runtime.js +311 -0
- rational/styling/__init__.py +6 -0
- rational/styling/compiler.py +301 -0
- rational/styling/responsive.py +53 -0
- rational/styling/style.py +228 -0
- rational/styling/theme.py +338 -0
- rational/testing.py +232 -0
- rational-0.1.0a1.dist-info/METADATA +308 -0
- rational-0.1.0a1.dist-info/RECORD +46 -0
- rational-0.1.0a1.dist-info/WHEEL +4 -0
- rational-0.1.0a1.dist-info/entry_points.txt +2 -0
- rational-0.1.0a1.dist-info/licenses/LICENSE +24 -0
rational/__init__.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Rational Web: a Python-first, object-oriented web framework.
|
|
2
|
+
|
|
3
|
+
Pages and components render on the server; a tiny browser runtime wires up
|
|
4
|
+
events and applies patches. Styles are typed Python values compiled to CSS.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .app import App
|
|
8
|
+
from .component import Component, Page
|
|
9
|
+
from .components import (
|
|
10
|
+
Article,
|
|
11
|
+
Aside,
|
|
12
|
+
Avatar,
|
|
13
|
+
Badge,
|
|
14
|
+
Button,
|
|
15
|
+
ButtonLink,
|
|
16
|
+
Card,
|
|
17
|
+
Checkbox,
|
|
18
|
+
Column,
|
|
19
|
+
Container,
|
|
20
|
+
Divider,
|
|
21
|
+
Footer,
|
|
22
|
+
Form,
|
|
23
|
+
Grid,
|
|
24
|
+
Header,
|
|
25
|
+
Heading,
|
|
26
|
+
Image,
|
|
27
|
+
Input,
|
|
28
|
+
Link,
|
|
29
|
+
Main,
|
|
30
|
+
Nav,
|
|
31
|
+
Row,
|
|
32
|
+
Section,
|
|
33
|
+
Select,
|
|
34
|
+
Spacer,
|
|
35
|
+
Stack,
|
|
36
|
+
Text,
|
|
37
|
+
Textarea,
|
|
38
|
+
)
|
|
39
|
+
from .context import Context
|
|
40
|
+
from .errors import FieldError, Forbidden, NotFound, RemoteError, Unauthorized, redirect
|
|
41
|
+
from .events import Event, js
|
|
42
|
+
from .logging import Logging
|
|
43
|
+
from .rendering import h, unsafe_html
|
|
44
|
+
from .router import Router
|
|
45
|
+
from .state import State
|
|
46
|
+
from .styling import Breakpoint, Style, Theme
|
|
47
|
+
|
|
48
|
+
__version__ = "0.1.0a1"
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"App",
|
|
52
|
+
"Article",
|
|
53
|
+
"Aside",
|
|
54
|
+
"Avatar",
|
|
55
|
+
"Badge",
|
|
56
|
+
"Breakpoint",
|
|
57
|
+
"Button",
|
|
58
|
+
"ButtonLink",
|
|
59
|
+
"Card",
|
|
60
|
+
"Checkbox",
|
|
61
|
+
"Column",
|
|
62
|
+
"Component",
|
|
63
|
+
"Container",
|
|
64
|
+
"Context",
|
|
65
|
+
"Divider",
|
|
66
|
+
"Event",
|
|
67
|
+
"FieldError",
|
|
68
|
+
"Footer",
|
|
69
|
+
"Forbidden",
|
|
70
|
+
"Form",
|
|
71
|
+
"Grid",
|
|
72
|
+
"Header",
|
|
73
|
+
"Heading",
|
|
74
|
+
"Image",
|
|
75
|
+
"Input",
|
|
76
|
+
"Link",
|
|
77
|
+
"Logging",
|
|
78
|
+
"Main",
|
|
79
|
+
"Nav",
|
|
80
|
+
"NotFound",
|
|
81
|
+
"Page",
|
|
82
|
+
"RemoteError",
|
|
83
|
+
"Router",
|
|
84
|
+
"Row",
|
|
85
|
+
"Section",
|
|
86
|
+
"Select",
|
|
87
|
+
"Spacer",
|
|
88
|
+
"Stack",
|
|
89
|
+
"State",
|
|
90
|
+
"Style",
|
|
91
|
+
"Text",
|
|
92
|
+
"Textarea",
|
|
93
|
+
"Theme",
|
|
94
|
+
"Unauthorized",
|
|
95
|
+
"h",
|
|
96
|
+
"js",
|
|
97
|
+
"redirect",
|
|
98
|
+
"unsafe_html",
|
|
99
|
+
]
|
rational/__main__.py
ADDED
rational/_tracking.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Render-time context shared by state descriptors, components and primitives."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import itertools
|
|
6
|
+
import re
|
|
7
|
+
from contextvars import ContextVar
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .component import Component
|
|
13
|
+
from .runtime import PageRuntime
|
|
14
|
+
|
|
15
|
+
Cell = tuple[Any, ...]
|
|
16
|
+
|
|
17
|
+
_SLUG_RE = re.compile(r"[^a-zA-Z0-9]+")
|
|
18
|
+
_fallback_ids = itertools.count(1)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(eq=False)
|
|
22
|
+
class Frame:
|
|
23
|
+
"""The component currently being rendered."""
|
|
24
|
+
|
|
25
|
+
runtime: PageRuntime
|
|
26
|
+
component: Component
|
|
27
|
+
handler_seq: int = 0
|
|
28
|
+
id_seq: int = 0
|
|
29
|
+
|
|
30
|
+
def next_dom_id(self, hint: str = "el") -> str:
|
|
31
|
+
"""A DOM id that is unique on the page and stable across rerenders."""
|
|
32
|
+
self.id_seq += 1
|
|
33
|
+
slug = _SLUG_RE.sub("-", hint).strip("-").lower()[:24] or "el"
|
|
34
|
+
return f"{self.component.cid}-{slug}-{self.id_seq}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
current_frame: ContextVar[Frame | None] = ContextVar("rational_frame", default=None)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def track_read(cell: Cell) -> None:
|
|
41
|
+
frame = current_frame.get()
|
|
42
|
+
if frame is not None:
|
|
43
|
+
frame.runtime.track(frame.component.cid, cell)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def dom_id(hint: str = "el") -> str:
|
|
47
|
+
frame = current_frame.get()
|
|
48
|
+
if frame is None:
|
|
49
|
+
return f"rw-{hint}-{next(_fallback_ids)}"
|
|
50
|
+
return frame.next_dom_id(hint)
|
rational/apidocs.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Interactive API documentation (Swagger UI) for ``@remote`` services.
|
|
2
|
+
|
|
3
|
+
FastAPI's stock page cannot add the CSRF header Rational requires for cookie-authenticated
|
|
4
|
+
calls, so "Try it out" would fail for signed-in users. This page injects the current
|
|
5
|
+
session's token into every request it sends.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .document import json_for_script
|
|
11
|
+
from .rendering.html import escape_attr, escape_text
|
|
12
|
+
|
|
13
|
+
SWAGGER_UI_VERSION = "5.17.14"
|
|
14
|
+
_CDN = f"https://cdn.jsdelivr.net/npm/swagger-ui-dist@{SWAGGER_UI_VERSION}"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def swagger_html(*, title: str, openapi_url: str, csrf: str | None) -> str:
|
|
18
|
+
config = json_for_script({"url": openapi_url, "csrf": csrf or ""})
|
|
19
|
+
return f"""<!doctype html>
|
|
20
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
21
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
22
|
+
<title>{escape_text(title)} · API</title><link rel="icon" href="data:,">
|
|
23
|
+
<link rel="stylesheet" href="{escape_attr(_CDN)}/swagger-ui.css">
|
|
24
|
+
</head><body><div id="swagger-ui"></div>
|
|
25
|
+
<script type="application/json" id="rw-api-config">{config}</script>
|
|
26
|
+
<script src="{escape_attr(_CDN)}/swagger-ui-bundle.js"></script>
|
|
27
|
+
<script>
|
|
28
|
+
const config = JSON.parse(document.getElementById("rw-api-config").textContent);
|
|
29
|
+
window.ui = SwaggerUIBundle({{
|
|
30
|
+
url: config.url,
|
|
31
|
+
dom_id: "#swagger-ui",
|
|
32
|
+
deepLinking: true,
|
|
33
|
+
tryItOutEnabled: true,
|
|
34
|
+
// Requests from this page carry your session cookie, so they also need its CSRF token.
|
|
35
|
+
requestInterceptor: (request) => {{
|
|
36
|
+
if (config.csrf) request.headers["X-RW-CSRF"] = config.csrf;
|
|
37
|
+
return request;
|
|
38
|
+
}},
|
|
39
|
+
}});
|
|
40
|
+
</script></body></html>"""
|
rational/app.py
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""``App``: the composition root. Configuration, registration, sessions and the ASGI entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import inspect
|
|
7
|
+
import logging
|
|
8
|
+
import secrets
|
|
9
|
+
import traceback
|
|
10
|
+
from collections.abc import AsyncIterator, Callable, Mapping
|
|
11
|
+
from contextlib import asynccontextmanager
|
|
12
|
+
from importlib import resources
|
|
13
|
+
from typing import TYPE_CHECKING, Any, TypeVar
|
|
14
|
+
|
|
15
|
+
from fastapi import FastAPI, Request, Response
|
|
16
|
+
from fastapi.exception_handlers import http_exception_handler
|
|
17
|
+
from fastapi.responses import HTMLResponse, PlainTextResponse
|
|
18
|
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
19
|
+
from starlette.types import Receive, Scope, Send
|
|
20
|
+
|
|
21
|
+
from .apidocs import swagger_html
|
|
22
|
+
from .component import Page
|
|
23
|
+
from .context import Context
|
|
24
|
+
from .dev import BOOT_PATH, DEV_SCRIPT_PATH, is_dev_environment
|
|
25
|
+
from .dev import error_page as dev_error_page
|
|
26
|
+
from .http import SESSION_COOKIE, THEME_COOKIE, SecurityHeadersMiddleware, base_path
|
|
27
|
+
from .logging import Logging, TelemetryMiddleware
|
|
28
|
+
from .pages import PageRoute, error_page
|
|
29
|
+
from .protocol import EVENT_PATH, handle_event
|
|
30
|
+
from .router import Router
|
|
31
|
+
from .runtime import PageRuntime
|
|
32
|
+
from .session import SessionBackend, SessionStore, TTLStore
|
|
33
|
+
from .styling.compiler import StyleCompiler
|
|
34
|
+
from .styling.responsive import Breakpoint
|
|
35
|
+
from .styling.theme import Theme, ThemeMode, get_theme
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from .backend.database import Database
|
|
39
|
+
|
|
40
|
+
P = TypeVar("P", bound=type[Page])
|
|
41
|
+
T = TypeVar("T", bound=type)
|
|
42
|
+
|
|
43
|
+
_IMMUTABLE = {"Cache-Control": "public, max-age=31536000, immutable"}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class App:
|
|
47
|
+
"""A Rational Web application, and an ASGI app (``uvicorn module:app``).
|
|
48
|
+
|
|
49
|
+
One origin serves pages at ``/``, ``@remote`` services under ``api_prefix`` (default
|
|
50
|
+
``/api``, with OpenAPI docs at ``/api/docs``) and framework assets under ``/_rational``.
|
|
51
|
+
Run it with ``python app.py --dev`` or ``--prod`` (see :mod:`rational.cli`).
|
|
52
|
+
|
|
53
|
+
Page views are held in process memory: run a single worker, or use sticky sessions.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
*,
|
|
59
|
+
title: str = "Rational App",
|
|
60
|
+
description: str | None = None,
|
|
61
|
+
version: str = "0.1.0",
|
|
62
|
+
theme: str | Theme = "modern",
|
|
63
|
+
theme_mode: ThemeMode = "system",
|
|
64
|
+
lang: str = "en",
|
|
65
|
+
database: str | Database | None = None,
|
|
66
|
+
breakpoints: Mapping[str, Breakpoint] | None = None,
|
|
67
|
+
containers: Mapping[str, Breakpoint] | None = None,
|
|
68
|
+
login_path: str | None = None,
|
|
69
|
+
debug: bool | None = None,
|
|
70
|
+
api_prefix: str = "/api",
|
|
71
|
+
api_docs: bool = True,
|
|
72
|
+
favicon: str | None = None,
|
|
73
|
+
secure_cookies: bool | None = None,
|
|
74
|
+
csp: bool = False,
|
|
75
|
+
session_store: SessionBackend | None = None,
|
|
76
|
+
session_ttl: float = 24 * 60 * 60,
|
|
77
|
+
page_ttl: float = 30 * 60,
|
|
78
|
+
max_pages: int = 5_000,
|
|
79
|
+
event_burst: int = 40,
|
|
80
|
+
event_rate: float = 20.0,
|
|
81
|
+
max_event_bytes: int = 64 * 1024,
|
|
82
|
+
) -> None:
|
|
83
|
+
self.title = title
|
|
84
|
+
self.api_docs = api_docs
|
|
85
|
+
self.theme = get_theme(theme)
|
|
86
|
+
self.theme_mode = theme_mode
|
|
87
|
+
self.lang = lang
|
|
88
|
+
self.login_path = login_path
|
|
89
|
+
self.debug = is_dev_environment() if debug is None else debug
|
|
90
|
+
self.api_prefix = "/" + api_prefix.strip("/")
|
|
91
|
+
self.favicon = favicon
|
|
92
|
+
self.boot_id = secrets.token_hex(8)
|
|
93
|
+
self.secure_cookies = secure_cookies
|
|
94
|
+
self.csp = csp
|
|
95
|
+
self.session_ttl = session_ttl
|
|
96
|
+
self.event_burst = event_burst
|
|
97
|
+
self.event_rate = event_rate
|
|
98
|
+
self.max_event_bytes = max_event_bytes
|
|
99
|
+
self.logging = Logging()
|
|
100
|
+
|
|
101
|
+
self.compiler = StyleCompiler(self.theme, breakpoints, containers)
|
|
102
|
+
self.sessions: SessionBackend = session_store or SessionStore(ttl=session_ttl)
|
|
103
|
+
self.pages: TTLStore[PageRuntime] = TTLStore(page_ttl, max_pages, on_evict=PageRuntime.dispose)
|
|
104
|
+
self.routes: dict[type[Page], PageRoute] = {}
|
|
105
|
+
self.services: list[type] = []
|
|
106
|
+
self.authenticator: Callable[[Context], Any] | None = None
|
|
107
|
+
self._startup: list[Callable[[], Any]] = []
|
|
108
|
+
self._startup_error: BaseException | None = None
|
|
109
|
+
self._requests = 0
|
|
110
|
+
|
|
111
|
+
if isinstance(database, str):
|
|
112
|
+
from .backend.database import Database
|
|
113
|
+
|
|
114
|
+
database = Database(database)
|
|
115
|
+
self.db: Database | None = database
|
|
116
|
+
|
|
117
|
+
self.theme_css = self.theme.css(theme_mode)
|
|
118
|
+
self.theme_href = f"/_rational/theme.css?v={_digest(self.theme_css)}"
|
|
119
|
+
static = resources.files("rational").joinpath("static")
|
|
120
|
+
self.runtime_js = static.joinpath("runtime.js").read_text("utf-8")
|
|
121
|
+
self.dev_js = static.joinpath("dev.js").read_text("utf-8")
|
|
122
|
+
self.runtime_src = f"/_rational/runtime.js?v={_digest(self.runtime_js)}"
|
|
123
|
+
|
|
124
|
+
self.fastapi = FastAPI(
|
|
125
|
+
title=title,
|
|
126
|
+
description=description or "",
|
|
127
|
+
version=version,
|
|
128
|
+
lifespan=self._lifespan,
|
|
129
|
+
docs_url=None, # replaced by a CSRF-aware Swagger UI page, see rational.apidocs
|
|
130
|
+
redoc_url=None,
|
|
131
|
+
openapi_url=f"{self.api_prefix}/openapi.json" if api_docs else None,
|
|
132
|
+
)
|
|
133
|
+
self.fastapi.add_middleware(TelemetryMiddleware, telemetry=self.logging)
|
|
134
|
+
self.fastapi.add_middleware(SecurityHeadersMiddleware)
|
|
135
|
+
routes: list[tuple[str, Callable[..., Any], str]] = [
|
|
136
|
+
*([(f"{self.api_prefix}/docs", self._api_docs, "GET")] if api_docs else []),
|
|
137
|
+
("/_rational/runtime.js", self._asset(self.runtime_js, "text/javascript"), "GET"),
|
|
138
|
+
("/_rational/theme.css", self._asset(self.theme_css, "text/css"), "GET"),
|
|
139
|
+
(EVENT_PATH, self._event, "POST"),
|
|
140
|
+
(DEV_SCRIPT_PATH, self._dev_script, "GET"),
|
|
141
|
+
(BOOT_PATH, self._dev_boot, "GET"),
|
|
142
|
+
]
|
|
143
|
+
for path, endpoint, method in routes:
|
|
144
|
+
self.fastapi.add_api_route(path, endpoint, methods=[method], include_in_schema=False)
|
|
145
|
+
self.fastapi.add_exception_handler(StarletteHTTPException, self._http_error) # type: ignore[arg-type]
|
|
146
|
+
self.fastapi.add_exception_handler(Exception, self._server_error)
|
|
147
|
+
|
|
148
|
+
# -- registration ------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
def page(self, path: str, *, title: str | None = None, auth: str | None = None) -> Callable[[P], P]:
|
|
151
|
+
"""Register a ``Page`` subclass at ``path``. ``{name}`` segments become typed props."""
|
|
152
|
+
|
|
153
|
+
def decorator(cls: P) -> P:
|
|
154
|
+
if not (isinstance(cls, type) and issubclass(cls, Page)):
|
|
155
|
+
raise TypeError("@app.page decorates Page subclasses")
|
|
156
|
+
route = PageRoute(self, path, cls, title, auth)
|
|
157
|
+
|
|
158
|
+
async def endpoint(request: Request) -> Response:
|
|
159
|
+
return await route(request)
|
|
160
|
+
|
|
161
|
+
endpoint.__signature__ = inspect.Signature( # type: ignore[attr-defined]
|
|
162
|
+
[
|
|
163
|
+
inspect.Parameter(
|
|
164
|
+
"request",
|
|
165
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
166
|
+
annotation=Request,
|
|
167
|
+
)
|
|
168
|
+
],
|
|
169
|
+
return_annotation=Response,
|
|
170
|
+
)
|
|
171
|
+
self.fastapi.add_api_route(
|
|
172
|
+
path,
|
|
173
|
+
endpoint,
|
|
174
|
+
methods=["GET"],
|
|
175
|
+
response_model=None,
|
|
176
|
+
include_in_schema=False,
|
|
177
|
+
name=cls.__name__,
|
|
178
|
+
)
|
|
179
|
+
self.routes[cls] = route
|
|
180
|
+
return cls
|
|
181
|
+
|
|
182
|
+
return decorator
|
|
183
|
+
|
|
184
|
+
def service(self, cls: T) -> T:
|
|
185
|
+
"""Register a ``Service``: each ``@remote`` method becomes a typed HTTP endpoint."""
|
|
186
|
+
from .backend.services import register_service
|
|
187
|
+
|
|
188
|
+
register_service(self, cls)
|
|
189
|
+
self.services.append(cls)
|
|
190
|
+
return cls
|
|
191
|
+
|
|
192
|
+
def include(self, router: Router) -> None:
|
|
193
|
+
"""Register everything a :class:`~rational.Router` recorded: pages, services and startup hooks."""
|
|
194
|
+
for entry in router.pages:
|
|
195
|
+
self.page(entry.path, title=entry.title, auth=entry.auth)(entry.page)
|
|
196
|
+
for service in router.services:
|
|
197
|
+
self.service(service)
|
|
198
|
+
for hook in router.startup:
|
|
199
|
+
self.on_startup(hook)
|
|
200
|
+
|
|
201
|
+
def url_for(self, page: type[Page], **params: Any) -> str:
|
|
202
|
+
return self.routes[page].url(**params)
|
|
203
|
+
|
|
204
|
+
def on_startup(self, fn: Callable[[], Any]) -> Callable[[], Any]:
|
|
205
|
+
self._startup.append(fn)
|
|
206
|
+
return fn
|
|
207
|
+
|
|
208
|
+
def authenticate(self, fn: Callable[[Context], Any]) -> Callable[[Context], Any]:
|
|
209
|
+
"""Replace the default authenticator (``session.user``).
|
|
210
|
+
|
|
211
|
+
``fn(context)`` (sync or async) returns the principal or ``None``; it runs once per request.
|
|
212
|
+
"""
|
|
213
|
+
self.authenticator = fn
|
|
214
|
+
return fn
|
|
215
|
+
|
|
216
|
+
def static(self, path: str, directory: str) -> None:
|
|
217
|
+
from fastapi.staticfiles import StaticFiles
|
|
218
|
+
|
|
219
|
+
self.fastapi.mount(path, StaticFiles(directory=directory), name=f"static:{path}")
|
|
220
|
+
|
|
221
|
+
def run(self, argv: list[str] | None = None, **overrides: Any) -> None:
|
|
222
|
+
"""Serve from the command line: ``python app.py --dev`` or ``python app.py --prod``.
|
|
223
|
+
|
|
224
|
+
``argv`` defaults to ``sys.argv[1:]``; keyword ``overrides`` (``mode``, ``host``, ``port``
|
|
225
|
+
and any uvicorn option) take precedence. See :mod:`rational.cli`.
|
|
226
|
+
"""
|
|
227
|
+
from .cli import serve
|
|
228
|
+
|
|
229
|
+
serve(self, argv, **overrides)
|
|
230
|
+
|
|
231
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
232
|
+
if (
|
|
233
|
+
self._startup_error is not None
|
|
234
|
+
and scope["type"] == "http"
|
|
235
|
+
and not scope["path"].endswith((DEV_SCRIPT_PATH, BOOT_PATH))
|
|
236
|
+
):
|
|
237
|
+
await dev_error_page(Request(scope), self._startup_error, stage="while starting the app")(
|
|
238
|
+
scope, receive, send
|
|
239
|
+
)
|
|
240
|
+
return
|
|
241
|
+
await self.fastapi(scope, receive, send)
|
|
242
|
+
|
|
243
|
+
# -- request context & sessions -----------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
async def context_for(self, request: Request, session: Any = None) -> Context:
|
|
246
|
+
if session is None:
|
|
247
|
+
session = self.sessions.get(request.cookies.get(SESSION_COOKIE))
|
|
248
|
+
ctx = Context(self, request, session)
|
|
249
|
+
request.state.rational_context = ctx
|
|
250
|
+
if self.authenticator is not None:
|
|
251
|
+
user = self.authenticator(ctx)
|
|
252
|
+
if inspect.isawaitable(user):
|
|
253
|
+
user = await user
|
|
254
|
+
ctx.set_user(user)
|
|
255
|
+
return ctx
|
|
256
|
+
|
|
257
|
+
def commit_session(self, ctx: Context) -> bool:
|
|
258
|
+
"""Apply a pending id rotation. Returns True if the browser needs a new cookie."""
|
|
259
|
+
session = ctx.peek_session()
|
|
260
|
+
if session is None:
|
|
261
|
+
return False
|
|
262
|
+
if session.rotated:
|
|
263
|
+
self.sessions.rotate(session)
|
|
264
|
+
session.rotated = False
|
|
265
|
+
ctx.session_created = True
|
|
266
|
+
return ctx.session_created
|
|
267
|
+
|
|
268
|
+
def finish(self, request: Request, response: Response, ctx: Context) -> None:
|
|
269
|
+
"""Persist session changes (creation, id rotation) into the response cookie."""
|
|
270
|
+
if self.commit_session(ctx):
|
|
271
|
+
secure = self.secure_cookies if self.secure_cookies is not None else request.url.scheme == "https"
|
|
272
|
+
response.set_cookie(
|
|
273
|
+
SESSION_COOKIE,
|
|
274
|
+
ctx.session.id,
|
|
275
|
+
max_age=int(self.session_ttl),
|
|
276
|
+
httponly=True,
|
|
277
|
+
samesite="lax",
|
|
278
|
+
secure=secure,
|
|
279
|
+
path="/",
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
@asynccontextmanager
|
|
283
|
+
async def session_lock(self, ctx: Context) -> AsyncIterator[None]:
|
|
284
|
+
"""Serialize requests that mutate a session shared by multiple pages."""
|
|
285
|
+
session = ctx.peek_session()
|
|
286
|
+
if session is None:
|
|
287
|
+
yield
|
|
288
|
+
return
|
|
289
|
+
async with session.lock:
|
|
290
|
+
yield
|
|
291
|
+
|
|
292
|
+
def data_theme(self, request: Request) -> str | None:
|
|
293
|
+
chosen = request.cookies.get(THEME_COOKIE)
|
|
294
|
+
if chosen in ("light", "dark"):
|
|
295
|
+
return chosen
|
|
296
|
+
return None if self.theme_mode == "system" else self.theme_mode
|
|
297
|
+
|
|
298
|
+
def housekeeping(self) -> None:
|
|
299
|
+
self._requests += 1
|
|
300
|
+
if self._requests % 500 == 0:
|
|
301
|
+
self.pages.sweep()
|
|
302
|
+
self.sessions.sweep()
|
|
303
|
+
|
|
304
|
+
# -- endpoints ---------------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
async def _event(self, request: Request) -> Response:
|
|
307
|
+
return await handle_event(self, request)
|
|
308
|
+
|
|
309
|
+
async def _api_docs(self, request: Request) -> Response:
|
|
310
|
+
session = (await self.context_for(request)).peek_session()
|
|
311
|
+
html = swagger_html(
|
|
312
|
+
title=self.title,
|
|
313
|
+
openapi_url=f"{base_path(request)}{self.api_prefix}/openapi.json",
|
|
314
|
+
csrf=session.csrf if session else None,
|
|
315
|
+
)
|
|
316
|
+
return HTMLResponse(html, headers={"Cache-Control": "no-store"})
|
|
317
|
+
|
|
318
|
+
def dev_head(self, nonce: str | None, base: str = "") -> str:
|
|
319
|
+
"""The live-reload/error-overlay script tag, in debug mode only."""
|
|
320
|
+
if not self.debug:
|
|
321
|
+
return ""
|
|
322
|
+
nonce_attr = f' nonce="{nonce}"' if nonce else ""
|
|
323
|
+
return f'<script src="{base}{DEV_SCRIPT_PATH}" defer{nonce_attr}></script>'
|
|
324
|
+
|
|
325
|
+
async def _dev_script(self) -> Response:
|
|
326
|
+
if not self.debug:
|
|
327
|
+
return Response(status_code=404)
|
|
328
|
+
return Response(self.dev_js, media_type="text/javascript", headers={"Cache-Control": "no-store"})
|
|
329
|
+
|
|
330
|
+
async def _dev_boot(self) -> Response:
|
|
331
|
+
if not self.debug:
|
|
332
|
+
return Response(status_code=404)
|
|
333
|
+
return PlainTextResponse(self.boot_id, headers={"Cache-Control": "no-store"})
|
|
334
|
+
|
|
335
|
+
async def _http_error(self, request: Request, exc: StarletteHTTPException) -> Response:
|
|
336
|
+
wants_html = "text/html" in request.headers.get("accept", "")
|
|
337
|
+
if wants_html and request.method == "GET" and not request.url.path.startswith("/_rational/"):
|
|
338
|
+
message = "Page not found" if exc.status_code == 404 else str(exc.detail)
|
|
339
|
+
return error_page(self, request, exc.status_code, message)
|
|
340
|
+
return await http_exception_handler(request, exc)
|
|
341
|
+
|
|
342
|
+
async def _server_error(self, request: Request, exc: Exception) -> Response:
|
|
343
|
+
logging.getLogger("rational").exception("unhandled request error", exc_info=(type(exc), exc, exc.__traceback__))
|
|
344
|
+
response = PlainTextResponse("Internal Server Error", status_code=500)
|
|
345
|
+
ctx = getattr(request.state, "rational_context", None)
|
|
346
|
+
if isinstance(ctx, Context):
|
|
347
|
+
self.finish(request, response, ctx)
|
|
348
|
+
return response
|
|
349
|
+
|
|
350
|
+
@staticmethod
|
|
351
|
+
def _asset(content: str, media_type: str) -> Callable[[], Any]:
|
|
352
|
+
async def serve() -> Response:
|
|
353
|
+
return Response(content, media_type=media_type, headers=_IMMUTABLE)
|
|
354
|
+
|
|
355
|
+
return serve
|
|
356
|
+
|
|
357
|
+
@asynccontextmanager
|
|
358
|
+
async def _lifespan(self, _app: FastAPI) -> AsyncIterator[None]:
|
|
359
|
+
try:
|
|
360
|
+
for fn in self._startup:
|
|
361
|
+
result = fn()
|
|
362
|
+
if inspect.isawaitable(result):
|
|
363
|
+
await result
|
|
364
|
+
except Exception as exc:
|
|
365
|
+
if not self.debug:
|
|
366
|
+
raise
|
|
367
|
+
traceback.print_exception(type(exc), exc, exc.__traceback__)
|
|
368
|
+
self._startup_error = exc
|
|
369
|
+
try:
|
|
370
|
+
yield
|
|
371
|
+
finally:
|
|
372
|
+
if self.db is not None:
|
|
373
|
+
await self.db.dispose()
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _digest(text: str) -> str:
|
|
377
|
+
return hashlib.sha256(text.encode()).hexdigest()[:10]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from .services import Service, remote
|
|
4
|
+
|
|
5
|
+
__all__ = ["Database", "Model", "Service", "remote"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def __getattr__(name: str) -> Any:
|
|
9
|
+
# SQLAlchemy is an optional dependency; import it only when asked for.
|
|
10
|
+
if name in ("Database", "Model"):
|
|
11
|
+
from . import database
|
|
12
|
+
|
|
13
|
+
return getattr(database, name)
|
|
14
|
+
raise AttributeError(name)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""SQLAlchemy integration: an async engine plus request-scoped sessions.
|
|
2
|
+
|
|
3
|
+
SQLAlchemy stays the source of truth for ORM semantics; this module only
|
|
4
|
+
creates the engine and hands out sessions. Use Alembic for migrations;
|
|
5
|
+
``create_all()`` is a development convenience.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import AsyncIterator
|
|
11
|
+
from contextlib import asynccontextmanager
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
16
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
17
|
+
except ImportError as exc: # pragma: no cover
|
|
18
|
+
raise ImportError("database support needs SQLAlchemy: pip install 'rational[db]'") from exc
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Model(DeclarativeBase):
|
|
22
|
+
"""Optional declarative base. Any SQLAlchemy declarative base works with ``Database``."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Database:
|
|
26
|
+
def __init__(self, url: str, *, echo: bool = False, **engine_options: Any) -> None:
|
|
27
|
+
self.url = url
|
|
28
|
+
self.engine = create_async_engine(url, echo=echo, **engine_options)
|
|
29
|
+
self.sessionmaker = async_sessionmaker(self.engine, expire_on_commit=False)
|
|
30
|
+
|
|
31
|
+
def session(self) -> AsyncSession:
|
|
32
|
+
"""``async with db.session() as session: ...`` — commit explicitly."""
|
|
33
|
+
return self.sessionmaker()
|
|
34
|
+
|
|
35
|
+
@asynccontextmanager
|
|
36
|
+
async def transaction(self) -> AsyncIterator[AsyncSession]:
|
|
37
|
+
"""A session inside a transaction: commits on success, rolls back on error."""
|
|
38
|
+
async with self.sessionmaker() as session, session.begin():
|
|
39
|
+
yield session
|
|
40
|
+
|
|
41
|
+
async def create_all(self, base: type[DeclarativeBase] = Model) -> None:
|
|
42
|
+
async with self.engine.begin() as conn:
|
|
43
|
+
await conn.run_sync(base.metadata.create_all)
|
|
44
|
+
|
|
45
|
+
async def dispose(self) -> None:
|
|
46
|
+
await self.engine.dispose()
|