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 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
@@ -0,0 +1,6 @@
1
+ """`python -m snackapp` is equivalent to the `snackapp` console script."""
2
+
3
+ from snackapp.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
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))