snackapp 0.1.1__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.
- snackapp/__init__.py +26 -0
- snackapp/__main__.py +6 -0
- snackapp/app.py +494 -0
- snackapp/cli.py +177 -0
- snackapp/config.py +76 -0
- snackapp/discovery.py +162 -0
- snackapp/exceptions.py +24 -0
- snackapp/fields/__init__.py +15 -0
- snackapp/fields/filters.py +126 -0
- snackapp/fields/registry.py +236 -0
- snackapp/fields/validate.py +50 -0
- snackapp/provision.py +123 -0
- snackapp/py.typed +0 -0
- snackapp/schema.py +286 -0
- snackapp/schema_sync.py +96 -0
- snackapp/static/.gitkeep +0 -0
- snackapp/static/assets/index-DiBbpiCA.css +1 -0
- snackapp/static/assets/index-DoUWBAjL.js +40 -0
- snackapp/static/index.html +13 -0
- snackapp/ui.py +271 -0
- snackapp-0.1.1.dist-info/METADATA +60 -0
- snackapp-0.1.1.dist-info/RECORD +24 -0
- snackapp-0.1.1.dist-info/WHEEL +4 -0
- snackapp-0.1.1.dist-info/entry_points.txt +2 -0
snackapp/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""SnackApp — Python application framework with SnackBase embedded."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from snackapp import ui
|
|
6
|
+
from snackapp.app import App, Ctx, Table
|
|
7
|
+
from snackapp.discovery import action, page
|
|
8
|
+
from snackapp.schema import Field, Schema, rules_for
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
__version__ = version("snackapp")
|
|
12
|
+
except PackageNotFoundError:
|
|
13
|
+
__version__ = "0.1.1"
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"App",
|
|
17
|
+
"Ctx",
|
|
18
|
+
"Field",
|
|
19
|
+
"Schema",
|
|
20
|
+
"Table",
|
|
21
|
+
"action",
|
|
22
|
+
"page",
|
|
23
|
+
"rules_for",
|
|
24
|
+
"ui",
|
|
25
|
+
"__version__",
|
|
26
|
+
]
|
snackapp/__main__.py
ADDED
snackapp/app.py
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
"""The App object: pages, actions, auth, and the ASGI mount."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import re
|
|
7
|
+
import traceback
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from fastapi import APIRouter, Cookie, FastAPI, Request, Response
|
|
14
|
+
from fastapi.responses import HTMLResponse, JSONResponse
|
|
15
|
+
from fastapi.staticfiles import StaticFiles
|
|
16
|
+
from starlette.concurrency import run_in_threadpool
|
|
17
|
+
|
|
18
|
+
from snackapp import ui
|
|
19
|
+
from snackapp.exceptions import DuplicateRouteError, ValidationError
|
|
20
|
+
from snackapp.fields.registry import serialize_registry
|
|
21
|
+
from snackapp.schema import CollectionDef, Schema
|
|
22
|
+
|
|
23
|
+
STATIC = Path(__file__).parent / "static"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _prepare_shell(at: str) -> str:
|
|
27
|
+
"""Rewrite the Vite index.html so hashed assets load from ``/_sa/static``."""
|
|
28
|
+
fallback = (
|
|
29
|
+
"<!doctype html><html><body><div id='root'></div>"
|
|
30
|
+
f"<script src='{at}/_sa/static/app.js' data-base='{at}'></script></body></html>"
|
|
31
|
+
)
|
|
32
|
+
path = STATIC / "index.html"
|
|
33
|
+
if not path.exists():
|
|
34
|
+
return fallback
|
|
35
|
+
html = path.read_text()
|
|
36
|
+
prefix = f"{at}/_sa/static"
|
|
37
|
+
html = html.replace('src="./assets/', f'src="{prefix}/assets/')
|
|
38
|
+
html = html.replace("src='./assets/", f"src='{prefix}/assets/")
|
|
39
|
+
html = html.replace('href="./assets/', f'href="{prefix}/assets/')
|
|
40
|
+
html = html.replace("href='./assets/", f"href='{prefix}/assets/")
|
|
41
|
+
if "data-base" not in html:
|
|
42
|
+
html = html.replace("<script", f'<script data-base="{at}"', 1)
|
|
43
|
+
else:
|
|
44
|
+
html = html.replace('data-base=""', f'data-base="{at}"')
|
|
45
|
+
return html
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class Page:
|
|
50
|
+
path: str
|
|
51
|
+
fn: Callable[..., Any]
|
|
52
|
+
title: str
|
|
53
|
+
nav: bool
|
|
54
|
+
icon: str | None
|
|
55
|
+
regex: re.Pattern[str]
|
|
56
|
+
params: list[str]
|
|
57
|
+
order: int = 0
|
|
58
|
+
public: bool = False
|
|
59
|
+
command: bool = False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _compile_path(path: str) -> tuple[re.Pattern[str], list[str]]:
|
|
63
|
+
params = re.findall(r"\{(\w+)\}", path)
|
|
64
|
+
pattern = "^" + re.sub(r"\{(\w+)\}", r"(?P<\1>[^/]+)", path) + "$"
|
|
65
|
+
return re.compile(pattern), params
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def route_specificity(path: str) -> tuple[int, int]:
|
|
69
|
+
"""Static segments beat parameterised ones; longer paths first."""
|
|
70
|
+
return (path.count("{"), -len(path))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Ctx:
|
|
74
|
+
def __init__(self, app: App, client: Any, user: dict[str, Any], query: dict[str, str]) -> None:
|
|
75
|
+
self.app = app
|
|
76
|
+
self.db = client
|
|
77
|
+
self.user = user
|
|
78
|
+
self.query = query
|
|
79
|
+
self._refresh: list[str] = []
|
|
80
|
+
self._reads: dict[str, set[str]] = {}
|
|
81
|
+
self._page_reads: set[str] = set()
|
|
82
|
+
self._wrote: set[str] = set()
|
|
83
|
+
self._redirect: str | None = None
|
|
84
|
+
self._toast: str | None = None
|
|
85
|
+
|
|
86
|
+
def refresh(self, *fragments: str) -> None:
|
|
87
|
+
self._refresh.extend(fragments)
|
|
88
|
+
|
|
89
|
+
def go(self, path: str) -> None:
|
|
90
|
+
self._redirect = path
|
|
91
|
+
|
|
92
|
+
def toast(self, msg: str) -> None:
|
|
93
|
+
self._toast = msg
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Table:
|
|
97
|
+
def __init__(self, cd: CollectionDef, ctx_getter: Callable[[], Ctx]) -> None:
|
|
98
|
+
self.cd = cd
|
|
99
|
+
self.name = cd.name
|
|
100
|
+
self._ctx = ctx_getter
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def db(self) -> Any:
|
|
104
|
+
return self._ctx().db
|
|
105
|
+
|
|
106
|
+
def _read(self) -> None:
|
|
107
|
+
ctx = self._ctx()
|
|
108
|
+
ctx._page_reads.add(self.name)
|
|
109
|
+
for frag in ui.active_fragments():
|
|
110
|
+
ctx._reads.setdefault(frag, set()).add(self.name)
|
|
111
|
+
|
|
112
|
+
def _write(self) -> None:
|
|
113
|
+
self._ctx()._wrote.add(self.name)
|
|
114
|
+
|
|
115
|
+
def list(self, **params: Any) -> list[dict[str, Any]]:
|
|
116
|
+
self._read()
|
|
117
|
+
payload = _run(self.db.list(self.name, **params))
|
|
118
|
+
if isinstance(payload, dict):
|
|
119
|
+
return list(payload.get("items") or payload.get("data") or [])
|
|
120
|
+
return list(payload or [])
|
|
121
|
+
|
|
122
|
+
def get(self, rid: str, expand: str | None = None) -> dict[str, Any]:
|
|
123
|
+
self._read()
|
|
124
|
+
return _run(self.db.get(self.name, rid, expand=expand))
|
|
125
|
+
|
|
126
|
+
def create(self, **data: Any) -> dict[str, Any]:
|
|
127
|
+
self._write()
|
|
128
|
+
return _run(self.db.create(self.name, data))
|
|
129
|
+
|
|
130
|
+
def update(self, rid: str, **data: Any) -> dict[str, Any]:
|
|
131
|
+
self._write()
|
|
132
|
+
return _run(self.db.update(self.name, rid, data))
|
|
133
|
+
|
|
134
|
+
def delete(self, rid: str) -> None:
|
|
135
|
+
self._write()
|
|
136
|
+
_run(self.db.delete(self.name, rid))
|
|
137
|
+
|
|
138
|
+
def aggregate(self, **params: Any) -> dict[str, Any]:
|
|
139
|
+
self._read()
|
|
140
|
+
return _run(self.db.aggregate(self.name, **params))
|
|
141
|
+
|
|
142
|
+
def count(self, filter: str | None = None) -> int:
|
|
143
|
+
self._read()
|
|
144
|
+
payload = _run(self.db.aggregate(self.name, functions="count()", filter=filter))
|
|
145
|
+
if isinstance(payload, dict):
|
|
146
|
+
results = payload.get("results") or []
|
|
147
|
+
if results and isinstance(results[0], dict):
|
|
148
|
+
row = results[0]
|
|
149
|
+
for key in ("count", "count()"):
|
|
150
|
+
if key in row:
|
|
151
|
+
return int(row[key] or 0)
|
|
152
|
+
for value in row.values():
|
|
153
|
+
if isinstance(value, (int, float)):
|
|
154
|
+
return int(value)
|
|
155
|
+
if payload.get("total") is not None:
|
|
156
|
+
return int(payload["total"] or 0)
|
|
157
|
+
if payload.get("count") is not None:
|
|
158
|
+
return int(payload["count"] or 0)
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _run(result: Any) -> Any:
|
|
163
|
+
import asyncio
|
|
164
|
+
|
|
165
|
+
if asyncio.iscoroutine(result):
|
|
166
|
+
try:
|
|
167
|
+
asyncio.get_running_loop()
|
|
168
|
+
except RuntimeError:
|
|
169
|
+
return asyncio.run(result)
|
|
170
|
+
import concurrent.futures
|
|
171
|
+
|
|
172
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
|
173
|
+
return pool.submit(asyncio.run, result).result()
|
|
174
|
+
return result
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def intersecting_fragments(
|
|
178
|
+
reads: dict[str, set[str]], wrote: set[str], page_reads: set[str]
|
|
179
|
+
) -> list[str]:
|
|
180
|
+
"""Fragments whose recorded reads intersect the action's wrote set."""
|
|
181
|
+
out: list[str] = []
|
|
182
|
+
for name, deps in reads.items():
|
|
183
|
+
target = deps if deps else page_reads
|
|
184
|
+
if target & wrote:
|
|
185
|
+
out.append(name)
|
|
186
|
+
return sorted(out)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class App:
|
|
190
|
+
def __init__(self, title: str, account: str | None = None) -> None:
|
|
191
|
+
self.title = title
|
|
192
|
+
self.account = account
|
|
193
|
+
self.schema = Schema()
|
|
194
|
+
self.base_path = ""
|
|
195
|
+
self.pages: list[Page] = []
|
|
196
|
+
self.actions: dict[str, Callable[..., Any]] = {}
|
|
197
|
+
self.tables: dict[str, Table] = {}
|
|
198
|
+
import contextvars
|
|
199
|
+
|
|
200
|
+
self._ctx_var: contextvars.ContextVar[Ctx] = contextvars.ContextVar("sa_ctx")
|
|
201
|
+
self._host: FastAPI | None = None
|
|
202
|
+
|
|
203
|
+
def collection(self, name: str, *fields: dict[str, Any], **kw: Any) -> Table:
|
|
204
|
+
cd = self.schema.collection(name, *fields, **kw)
|
|
205
|
+
table = Table(cd, lambda: self._ctx_var.get())
|
|
206
|
+
self.tables[name] = table
|
|
207
|
+
return table
|
|
208
|
+
|
|
209
|
+
def page(
|
|
210
|
+
self,
|
|
211
|
+
path: str,
|
|
212
|
+
title: str = "",
|
|
213
|
+
nav: bool = False,
|
|
214
|
+
icon: str | None = None,
|
|
215
|
+
order: int = 0,
|
|
216
|
+
public: bool = False,
|
|
217
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
218
|
+
def deco(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
219
|
+
existing = [p for p in self.pages if p.path == path]
|
|
220
|
+
if existing:
|
|
221
|
+
raise DuplicateRouteError(
|
|
222
|
+
f"duplicate route {path!r}: {existing[0].fn.__module__} and {fn.__module__}"
|
|
223
|
+
)
|
|
224
|
+
rx, params = _compile_path(path)
|
|
225
|
+
self.pages.append(
|
|
226
|
+
Page(
|
|
227
|
+
path,
|
|
228
|
+
fn,
|
|
229
|
+
title or fn.__name__,
|
|
230
|
+
nav,
|
|
231
|
+
icon,
|
|
232
|
+
rx,
|
|
233
|
+
params,
|
|
234
|
+
order,
|
|
235
|
+
public,
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
self.pages.sort(key=lambda p: route_specificity(p.path))
|
|
239
|
+
return fn
|
|
240
|
+
|
|
241
|
+
return deco
|
|
242
|
+
|
|
243
|
+
def autodiscover(
|
|
244
|
+
self,
|
|
245
|
+
pages: str | Path = "pages",
|
|
246
|
+
actions: str | Path | None = "actions",
|
|
247
|
+
base: str | Path | None = None,
|
|
248
|
+
) -> dict[str, list[str]]:
|
|
249
|
+
from snackapp.discovery import load_package
|
|
250
|
+
|
|
251
|
+
root = Path(base) if base else Path(self._caller_dir())
|
|
252
|
+
out = {"pages": load_package(self, root / Path(pages), "pages")}
|
|
253
|
+
if actions:
|
|
254
|
+
out["actions"] = load_package(self, root / Path(actions), "actions")
|
|
255
|
+
self.pages.sort(key=lambda p: route_specificity(p.path))
|
|
256
|
+
return out
|
|
257
|
+
|
|
258
|
+
@staticmethod
|
|
259
|
+
def _caller_dir() -> str:
|
|
260
|
+
for frame in inspect.stack():
|
|
261
|
+
f = frame.filename
|
|
262
|
+
if "snackapp/" not in f and not f.startswith("<"):
|
|
263
|
+
return str(Path(f).resolve().parent)
|
|
264
|
+
return str(Path.cwd())
|
|
265
|
+
|
|
266
|
+
def action(
|
|
267
|
+
self, fn: Callable[..., Any] | None = None, *, command: bool = False
|
|
268
|
+
) -> Callable[..., Any]:
|
|
269
|
+
def deco(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
270
|
+
setattr(func, "command", command)
|
|
271
|
+
self.actions[func.__name__] = func
|
|
272
|
+
return func
|
|
273
|
+
|
|
274
|
+
if fn is None:
|
|
275
|
+
return deco # type: ignore[return-value]
|
|
276
|
+
return deco(fn)
|
|
277
|
+
|
|
278
|
+
def _client_for(self, token: str | None) -> Any:
|
|
279
|
+
from snackbase.embedded import create_client
|
|
280
|
+
from snackbase.infrastructure.api.app import app as host
|
|
281
|
+
|
|
282
|
+
return create_client(app=self._host or host, token=token, account=self.account)
|
|
283
|
+
|
|
284
|
+
def _match(self, path: str) -> tuple[Page, dict[str, str]] | None:
|
|
285
|
+
for page in self.pages:
|
|
286
|
+
matched = page.regex.match(path)
|
|
287
|
+
if matched:
|
|
288
|
+
return page, matched.groupdict()
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
def _render(self, ctx: Ctx, page: Page, params: dict[str, str]) -> dict[str, Any]:
|
|
292
|
+
root, token = ui.new_tree()
|
|
293
|
+
tok2 = self._ctx_var.set(ctx)
|
|
294
|
+
try:
|
|
295
|
+
sig = inspect.signature(page.fn).parameters
|
|
296
|
+
kwargs: dict[str, Any] = {k: v for k, v in params.items() if k in sig}
|
|
297
|
+
if "ctx" in sig:
|
|
298
|
+
kwargs["ctx"] = ctx
|
|
299
|
+
page.fn(**kwargs)
|
|
300
|
+
finally:
|
|
301
|
+
ui.end_tree(token)
|
|
302
|
+
self._ctx_var.reset(tok2)
|
|
303
|
+
|
|
304
|
+
def stamp(node: dict[str, Any]) -> None:
|
|
305
|
+
if node.get("kind") == "fragment":
|
|
306
|
+
own = ctx._reads.get(node["name"])
|
|
307
|
+
node["deps"] = sorted(own if own else ctx._page_reads)
|
|
308
|
+
for child in node.get("children", []):
|
|
309
|
+
stamp(child)
|
|
310
|
+
|
|
311
|
+
stamp(root)
|
|
312
|
+
return root
|
|
313
|
+
|
|
314
|
+
def nav_items(self) -> list[dict[str, Any]]:
|
|
315
|
+
items = [p for p in self.pages if p.nav]
|
|
316
|
+
items.sort(key=lambda p: (p.order, p.title))
|
|
317
|
+
return [{"path": p.path, "title": p.title, "icon": p.icon} for p in items]
|
|
318
|
+
|
|
319
|
+
def router(self, at: str = "") -> APIRouter:
|
|
320
|
+
r = APIRouter(prefix=f"{at}/_sa")
|
|
321
|
+
|
|
322
|
+
@r.post("/login")
|
|
323
|
+
async def login(request: Request) -> Response:
|
|
324
|
+
body = await request.json()
|
|
325
|
+
client = self._client_for(None)
|
|
326
|
+
try:
|
|
327
|
+
payload = await client.request(
|
|
328
|
+
"POST",
|
|
329
|
+
"/api/v1/auth/login",
|
|
330
|
+
json={
|
|
331
|
+
"email": body["email"],
|
|
332
|
+
"password": body["password"],
|
|
333
|
+
"account": body.get("account") or self.account,
|
|
334
|
+
},
|
|
335
|
+
)
|
|
336
|
+
except Exception as exc:
|
|
337
|
+
status = getattr(exc, "status_code", 401)
|
|
338
|
+
return JSONResponse({"error": str(exc)}, status_code=int(status) or 401)
|
|
339
|
+
token = payload.get("token") or payload.get("access_token")
|
|
340
|
+
resp = JSONResponse({"ok": True})
|
|
341
|
+
resp.set_cookie(
|
|
342
|
+
"sa_token",
|
|
343
|
+
token,
|
|
344
|
+
httponly=True,
|
|
345
|
+
samesite="lax",
|
|
346
|
+
path="/",
|
|
347
|
+
)
|
|
348
|
+
return resp
|
|
349
|
+
|
|
350
|
+
@r.post("/logout")
|
|
351
|
+
async def logout() -> Response:
|
|
352
|
+
resp = JSONResponse({"ok": True})
|
|
353
|
+
resp.delete_cookie("sa_token", path="/")
|
|
354
|
+
return resp
|
|
355
|
+
|
|
356
|
+
@r.get("/meta")
|
|
357
|
+
async def meta(sa_token: str | None = Cookie(default=None)) -> dict[str, Any]:
|
|
358
|
+
out: dict[str, Any] = {
|
|
359
|
+
"title": self.title,
|
|
360
|
+
"nav": self.nav_items(),
|
|
361
|
+
"user": None,
|
|
362
|
+
"fields": serialize_registry(),
|
|
363
|
+
"single_account": True,
|
|
364
|
+
}
|
|
365
|
+
if sa_token:
|
|
366
|
+
try:
|
|
367
|
+
user = await self._client_for(sa_token).me()
|
|
368
|
+
out["user"] = user
|
|
369
|
+
except Exception:
|
|
370
|
+
pass
|
|
371
|
+
return out
|
|
372
|
+
|
|
373
|
+
@r.get("/view")
|
|
374
|
+
async def view(
|
|
375
|
+
path: str,
|
|
376
|
+
request: Request,
|
|
377
|
+
sa_token: str | None = Cookie(default=None),
|
|
378
|
+
) -> Any:
|
|
379
|
+
hit = self._match(path)
|
|
380
|
+
if not hit:
|
|
381
|
+
return JSONResponse({"error": f"no page for {path}"}, status_code=404)
|
|
382
|
+
page, params = hit
|
|
383
|
+
if not page.public and not sa_token:
|
|
384
|
+
return JSONResponse({"error": "auth"}, status_code=401)
|
|
385
|
+
client = self._client_for(sa_token)
|
|
386
|
+
user: dict[str, Any] = {}
|
|
387
|
+
if sa_token:
|
|
388
|
+
try:
|
|
389
|
+
user = await client.me()
|
|
390
|
+
except Exception:
|
|
391
|
+
if not page.public:
|
|
392
|
+
return JSONResponse({"error": "auth"}, status_code=401)
|
|
393
|
+
query = dict(request.query_params)
|
|
394
|
+
query.pop("path", None)
|
|
395
|
+
ctx = Ctx(self, client, user, query)
|
|
396
|
+
try:
|
|
397
|
+
tree = await run_in_threadpool(self._render, ctx, page, params)
|
|
398
|
+
except Exception as exc:
|
|
399
|
+
return JSONResponse(
|
|
400
|
+
{
|
|
401
|
+
"error": "render",
|
|
402
|
+
"detail": str(exc),
|
|
403
|
+
"trace": traceback.format_exc()[-1500:],
|
|
404
|
+
},
|
|
405
|
+
status_code=200,
|
|
406
|
+
)
|
|
407
|
+
return {"title": page.title, "tree": tree, "user": user}
|
|
408
|
+
|
|
409
|
+
@r.post("/action/{name}")
|
|
410
|
+
async def run_action(
|
|
411
|
+
name: str,
|
|
412
|
+
request: Request,
|
|
413
|
+
sa_token: str | None = Cookie(default=None),
|
|
414
|
+
) -> Any:
|
|
415
|
+
if not sa_token:
|
|
416
|
+
return JSONResponse({"error": "auth"}, status_code=401)
|
|
417
|
+
fn = self.actions.get(name)
|
|
418
|
+
if not fn:
|
|
419
|
+
return JSONResponse({"error": f"no action {name}"}, status_code=404)
|
|
420
|
+
client = self._client_for(sa_token)
|
|
421
|
+
body = await request.json()
|
|
422
|
+
user = await client.me()
|
|
423
|
+
ctx = Ctx(self, client, user, {})
|
|
424
|
+
sig = inspect.signature(fn)
|
|
425
|
+
params_ = sig.parameters
|
|
426
|
+
has_kwargs = any(
|
|
427
|
+
p.kind is inspect.Parameter.VAR_KEYWORD for p in params_.values()
|
|
428
|
+
)
|
|
429
|
+
kwargs = {k: v for k, v in body.items() if has_kwargs or k in params_}
|
|
430
|
+
kwargs.pop("ctx", None)
|
|
431
|
+
if "ctx" in params_:
|
|
432
|
+
kwargs["ctx"] = ctx
|
|
433
|
+
|
|
434
|
+
def _call() -> None:
|
|
435
|
+
tok = self._ctx_var.set(ctx)
|
|
436
|
+
try:
|
|
437
|
+
fn(**kwargs)
|
|
438
|
+
finally:
|
|
439
|
+
self._ctx_var.reset(tok)
|
|
440
|
+
|
|
441
|
+
try:
|
|
442
|
+
await run_in_threadpool(_call)
|
|
443
|
+
except ValidationError as exc:
|
|
444
|
+
return JSONResponse(
|
|
445
|
+
{"ok": False, "error": exc.message, "field": exc.field}
|
|
446
|
+
)
|
|
447
|
+
except Exception as exc:
|
|
448
|
+
return JSONResponse({"ok": False, "error": str(exc)})
|
|
449
|
+
auto = intersecting_fragments(ctx._reads, ctx._wrote, ctx._page_reads)
|
|
450
|
+
refresh = list(dict.fromkeys([*ctx._refresh, *auto]))
|
|
451
|
+
return {
|
|
452
|
+
"ok": True,
|
|
453
|
+
"refresh": refresh,
|
|
454
|
+
"wrote": sorted(ctx._wrote),
|
|
455
|
+
"redirect": ctx._redirect,
|
|
456
|
+
"toast": ctx._toast,
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return r
|
|
460
|
+
|
|
461
|
+
def mount(self, api: FastAPI, at: str = "") -> FastAPI:
|
|
462
|
+
at = at.rstrip("/")
|
|
463
|
+
self.base_path = at
|
|
464
|
+
self._host = api
|
|
465
|
+
if not at:
|
|
466
|
+
api.router.routes = [
|
|
467
|
+
route for route in api.router.routes if getattr(route, "path", None) != "/"
|
|
468
|
+
]
|
|
469
|
+
api.include_router(self.router(at))
|
|
470
|
+
if STATIC.exists():
|
|
471
|
+
api.mount(f"{at}/_sa/static", StaticFiles(directory=STATIC), name="sa_static")
|
|
472
|
+
|
|
473
|
+
shell = _prepare_shell(at)
|
|
474
|
+
|
|
475
|
+
@api.api_route(at + "/{full_path:path}", methods=["GET", "HEAD"], include_in_schema=False)
|
|
476
|
+
async def spa(full_path: str) -> Response:
|
|
477
|
+
if full_path.startswith(("api/", "_sa/")):
|
|
478
|
+
return JSONResponse({"error": "not found"}, status_code=404)
|
|
479
|
+
return HTMLResponse(
|
|
480
|
+
shell,
|
|
481
|
+
headers={"Cache-Control": "no-store"},
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
if at:
|
|
485
|
+
@api.api_route(at, methods=["GET", "HEAD"], include_in_schema=False)
|
|
486
|
+
async def spa_root() -> Response:
|
|
487
|
+
return HTMLResponse(shell, headers={"Cache-Control": "no-store"})
|
|
488
|
+
|
|
489
|
+
return api
|
|
490
|
+
|
|
491
|
+
def fastapi(self, at: str = "") -> FastAPI:
|
|
492
|
+
from snackbase.infrastructure.api.app import create_app
|
|
493
|
+
|
|
494
|
+
return self.mount(create_app(), at=at)
|
snackapp/cli.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Console script for SnackApp."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from importlib.metadata import version
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
|
|
14
|
+
from snackapp.config import resolve
|
|
15
|
+
from snackapp.exceptions import DestructiveChangeError, UnknownRevisionError
|
|
16
|
+
|
|
17
|
+
INIT_APP = '''from snackapp import App, Field
|
|
18
|
+
|
|
19
|
+
app = App("Demo")
|
|
20
|
+
items = app.collection(
|
|
21
|
+
"items",
|
|
22
|
+
Field.text("name", required=True),
|
|
23
|
+
access="team",
|
|
24
|
+
)
|
|
25
|
+
'''
|
|
26
|
+
|
|
27
|
+
INIT_MODELS = '''from snackapp import Field
|
|
28
|
+
'''
|
|
29
|
+
|
|
30
|
+
INIT_PAGE = '''from snackapp import page, ui
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@page("/", title="Home", nav=True)
|
|
34
|
+
def index():
|
|
35
|
+
ui.header("Home")
|
|
36
|
+
ui.text("Edit pages/index.py to get started.")
|
|
37
|
+
'''
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@click.group(invoke_without_command=True)
|
|
41
|
+
@click.version_option(version=version("snackapp"), prog_name="snackapp")
|
|
42
|
+
@click.pass_context
|
|
43
|
+
def cli(ctx: click.Context) -> None:
|
|
44
|
+
"""SnackApp — Python apps with SnackBase embedded."""
|
|
45
|
+
if ctx.invoked_subcommand is None and not ctx.resilient_parsing:
|
|
46
|
+
click.echo(ctx.get_help())
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main() -> None:
|
|
50
|
+
cli(standalone_mode=True)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@cli.command()
|
|
54
|
+
@click.argument("name")
|
|
55
|
+
def init(name: str) -> None:
|
|
56
|
+
"""Scaffold a new SnackApp project."""
|
|
57
|
+
root = Path(name)
|
|
58
|
+
if root.exists() and any(root.iterdir()):
|
|
59
|
+
raise click.ClickException(f"{name} is not empty")
|
|
60
|
+
(root / "pages").mkdir(parents=True)
|
|
61
|
+
(root / "app.py").write_text(INIT_APP)
|
|
62
|
+
(root / "models.py").write_text(INIT_MODELS)
|
|
63
|
+
(root / "pages" / "index.py").write_text(INIT_PAGE)
|
|
64
|
+
(root / ".gitignore").write_text(".snackapp/\n")
|
|
65
|
+
(root / "snackapp.toml").write_text("port = 8000\n")
|
|
66
|
+
click.echo(f"Created {name}. Next: cd {name} && snackapp run")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@cli.command()
|
|
70
|
+
@click.option("--port", type=int, default=None)
|
|
71
|
+
@click.option("--host", type=str, default=None)
|
|
72
|
+
@click.option("--reload", is_flag=True, default=False)
|
|
73
|
+
def run(port: int | None, host: str | None, reload: bool) -> None:
|
|
74
|
+
"""Start the embedded SnackBase + application process."""
|
|
75
|
+
import asyncio
|
|
76
|
+
|
|
77
|
+
import uvicorn
|
|
78
|
+
|
|
79
|
+
from snackapp.provision import apply_runtime_env, provision_first_user
|
|
80
|
+
|
|
81
|
+
settings = resolve(cli={"port": port, "host": host, "reload": reload or None})
|
|
82
|
+
apply_runtime_env()
|
|
83
|
+
os.environ["SNACKAPP_ADMIN"] = settings.admin
|
|
84
|
+
if settings.backend_url:
|
|
85
|
+
os.environ["SNACKAPP_BACKEND_URL"] = settings.backend_url
|
|
86
|
+
|
|
87
|
+
app_obj = _load_app()
|
|
88
|
+
|
|
89
|
+
async def _boot() -> None:
|
|
90
|
+
from snackbase.infrastructure.persistence.database import init_database
|
|
91
|
+
|
|
92
|
+
from snackapp.schema_sync import apply_schema
|
|
93
|
+
|
|
94
|
+
await init_database()
|
|
95
|
+
creds = await provision_first_user()
|
|
96
|
+
if creds:
|
|
97
|
+
click.echo(
|
|
98
|
+
"First run credentials (shown once):\n"
|
|
99
|
+
f" account: {creds['account']}\n"
|
|
100
|
+
f" email: {creds['email']}\n"
|
|
101
|
+
f" password: {creds['password']}"
|
|
102
|
+
)
|
|
103
|
+
await apply_schema(app_obj, confirm=False)
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
asyncio.run(_boot())
|
|
107
|
+
except UnknownRevisionError as exc:
|
|
108
|
+
raise SystemExit(str(exc)) from exc
|
|
109
|
+
except DestructiveChangeError as exc:
|
|
110
|
+
raise SystemExit(str(exc)) from exc
|
|
111
|
+
|
|
112
|
+
asgi = app_obj.fastapi()
|
|
113
|
+
uvicorn.run(
|
|
114
|
+
asgi,
|
|
115
|
+
host=settings.host,
|
|
116
|
+
port=settings.port,
|
|
117
|
+
reload=settings.reload,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@cli.command()
|
|
122
|
+
def routes() -> None:
|
|
123
|
+
"""Print the resolved route table."""
|
|
124
|
+
app_obj = _load_app()
|
|
125
|
+
for page in app_obj.pages:
|
|
126
|
+
click.echo(f"{page.path:20} {page.fn.__module__}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@cli.command()
|
|
130
|
+
@click.option("--plan", is_flag=True, default=False)
|
|
131
|
+
@click.option("--confirm", is_flag=True, default=False)
|
|
132
|
+
def migrate(plan: bool, confirm: bool) -> None:
|
|
133
|
+
"""Plan or apply schema migrations."""
|
|
134
|
+
from snackapp.provision import apply_runtime_env
|
|
135
|
+
|
|
136
|
+
apply_runtime_env()
|
|
137
|
+
app_obj = _load_app()
|
|
138
|
+
if plan:
|
|
139
|
+
diff = _plan_schema(app_obj)
|
|
140
|
+
click.echo(diff)
|
|
141
|
+
return
|
|
142
|
+
_sync_schema(app_obj, confirm=confirm)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _load_app() -> Any:
|
|
146
|
+
path = Path("app.py")
|
|
147
|
+
if not path.exists():
|
|
148
|
+
raise click.ClickException("no app.py in the current directory")
|
|
149
|
+
spec = importlib.util.spec_from_file_location("user_app", path)
|
|
150
|
+
if spec is None or spec.loader is None:
|
|
151
|
+
raise click.ClickException("cannot load app.py")
|
|
152
|
+
module = importlib.util.module_from_spec(spec)
|
|
153
|
+
sys.modules["user_app"] = module
|
|
154
|
+
spec.loader.exec_module(module)
|
|
155
|
+
app_obj = getattr(module, "app", None)
|
|
156
|
+
if app_obj is None:
|
|
157
|
+
raise click.ClickException("app.py must define `app = App(...)`")
|
|
158
|
+
pages = Path("pages")
|
|
159
|
+
if pages.exists():
|
|
160
|
+
app_obj.autodiscover(pages="pages", actions="actions" if Path("actions").exists() else None)
|
|
161
|
+
return app_obj
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _plan_schema(app_obj: Any) -> dict[str, Any]:
|
|
165
|
+
import asyncio
|
|
166
|
+
|
|
167
|
+
from snackapp.schema_sync import current_schemas, plan_schema
|
|
168
|
+
|
|
169
|
+
return plan_schema(app_obj, asyncio.run(current_schemas()))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _sync_schema(app_obj: Any, *, confirm: bool) -> None:
|
|
173
|
+
import asyncio
|
|
174
|
+
|
|
175
|
+
from snackapp.schema_sync import apply_schema
|
|
176
|
+
|
|
177
|
+
asyncio.run(apply_schema(app_obj, confirm=confirm))
|