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.
Files changed (46) hide show
  1. rational/__init__.py +99 -0
  2. rational/__main__.py +3 -0
  3. rational/_tracking.py +50 -0
  4. rational/apidocs.py +40 -0
  5. rational/app.py +377 -0
  6. rational/backend/__init__.py +14 -0
  7. rational/backend/database.py +46 -0
  8. rational/backend/services.py +174 -0
  9. rational/binding.py +94 -0
  10. rational/cli.py +299 -0
  11. rational/component.py +167 -0
  12. rational/components/__init__.py +52 -0
  13. rational/components/basic.py +247 -0
  14. rational/components/forms.py +253 -0
  15. rational/components/layout.py +138 -0
  16. rational/context.py +69 -0
  17. rational/dev.py +208 -0
  18. rational/document.py +54 -0
  19. rational/errors.py +99 -0
  20. rational/events.py +73 -0
  21. rational/http.py +72 -0
  22. rational/logging.py +185 -0
  23. rational/pages.py +181 -0
  24. rational/protocol.py +142 -0
  25. rational/py.typed +0 -0
  26. rational/rendering/__init__.py +5 -0
  27. rational/rendering/builder.py +86 -0
  28. rational/rendering/html.py +60 -0
  29. rational/rendering/nodes.py +121 -0
  30. rational/router.py +77 -0
  31. rational/runtime.py +323 -0
  32. rational/session.py +152 -0
  33. rational/state.py +113 -0
  34. rational/static/dev.js +53 -0
  35. rational/static/runtime.js +311 -0
  36. rational/styling/__init__.py +6 -0
  37. rational/styling/compiler.py +301 -0
  38. rational/styling/responsive.py +53 -0
  39. rational/styling/style.py +228 -0
  40. rational/styling/theme.py +338 -0
  41. rational/testing.py +232 -0
  42. rational-0.1.0a1.dist-info/METADATA +308 -0
  43. rational-0.1.0a1.dist-info/RECORD +46 -0
  44. rational-0.1.0a1.dist-info/WHEEL +4 -0
  45. rational-0.1.0a1.dist-info/entry_points.txt +2 -0
  46. 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
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
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()