pyweft 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pyweft/cli.py ADDED
@@ -0,0 +1,1006 @@
1
+ """The ``pyweft`` command line: scaffold, serve, and build apps.
2
+
3
+ A pyweft app is a directory of ``.weft`` files with ``app.weft`` as the root,
4
+ any plain ``.py`` modules they import, and an ordinary ``pyproject.toml``.
5
+ Dependencies declared there (with ``uv add`` or by hand) are installed *in
6
+ the browser* by micropip at page load, so the same file drives local tooling
7
+ and the runtime. Only pure-Python wheels or packages Pyodide ships prebuilt
8
+ will work -- the browser can't compile C.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import mimetypes
16
+ import re
17
+ import shutil
18
+ import socket
19
+ import subprocess
20
+ import sys
21
+ import threading
22
+ import uuid
23
+ from dataclasses import dataclass
24
+ from html import escape
25
+ from html.parser import HTMLParser
26
+ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
27
+ from pathlib import Path
28
+ from urllib.parse import parse_qs, unquote, urlsplit
29
+
30
+ try:
31
+ import tomllib
32
+ except ModuleNotFoundError: # Python 3.10
33
+ tomllib = None
34
+
35
+ _STATIC = Path(__file__).parent / "static"
36
+ _PKG_DIR = Path(__file__).parent
37
+
38
+
39
+ class CliError(RuntimeError):
40
+ pass
41
+
42
+
43
+ # ------------------------------------------------------------------ app model
44
+
45
+
46
+ def app_dependencies(app_dir: Path) -> list[str]:
47
+ """``[project].dependencies`` from the app's pyproject.toml, if any."""
48
+ pyproject = app_dir / "pyproject.toml"
49
+ if not pyproject.exists():
50
+ return []
51
+ text = pyproject.read_text(encoding="utf-8")
52
+ if tomllib is not None:
53
+ return list(tomllib.loads(text).get("project", {}).get("dependencies", []))
54
+ # 3.10 fallback: good enough for the flat list uv/pip write.
55
+ match = re.search(r"^dependencies\s*=\s*\[(.*?)\]", text, re.S | re.M)
56
+ return re.findall(r"[\"']([^\"']+)[\"']", match.group(1)) if match else []
57
+
58
+
59
+ def _flat_table(text: str, name: str) -> dict:
60
+ """3.10 fallback for reading one TOML table of simple values (strings,
61
+ numbers, booleans, lists of those), which is all PyWeft's settings use."""
62
+ import ast
63
+
64
+ match = re.search(rf"^\[{re.escape(name)}\]\s*$(.*?)(?=^\[|\Z)", text, re.S | re.M)
65
+ table: dict = {}
66
+ for line in (match.group(1) if match else "").splitlines():
67
+ line = line.split(" #", 1)[0].strip()
68
+ if not line or line.startswith("#") or "=" not in line:
69
+ continue
70
+ key, _, value = line.partition("=")
71
+ value = re.sub(r"\btrue\b", "True", re.sub(r"\bfalse\b", "False", value.strip()))
72
+ try:
73
+ table[key.strip()] = ast.literal_eval(value)
74
+ except (ValueError, SyntaxError):
75
+ raise CliError(f"pyproject.toml [{name}]: can't read {line!r}") from None
76
+ return table
77
+
78
+
79
+ def app_settings(app_dir: Path) -> dict:
80
+ """The app's ``[tool.pyweft]`` table from its pyproject.toml, with any
81
+ ``[tool.pyweft.desktop]`` under ``"desktop"``; empty without one."""
82
+ pyproject = app_dir / "pyproject.toml"
83
+ if not pyproject.exists():
84
+ return {}
85
+ text = pyproject.read_text(encoding="utf-8")
86
+ if tomllib is not None:
87
+ try:
88
+ data = tomllib.loads(text)
89
+ except tomllib.TOMLDecodeError as exc:
90
+ raise CliError(f"{pyproject}: {exc}") from None
91
+ return dict(data.get("tool", {}).get("pyweft", {}))
92
+ settings = _flat_table(text, "tool.pyweft")
93
+ desktop = _flat_table(text, "tool.pyweft.desktop")
94
+ if desktop:
95
+ settings["desktop"] = desktop
96
+ return settings
97
+
98
+
99
+ ASSETS_DIR = "assets"
100
+ # assets/ is served as-is (images, fonts, vendored CSS/JS), so a .css file in
101
+ # it must not be linked automatically and a .weft file there is not a component.
102
+ # dist-desktop holds `pyweft build --desktop` output.
103
+ _SKIP_DIRS = {"dist", "dist-desktop", "node_modules", ASSETS_DIR}
104
+
105
+
106
+ def _app_files(app_dir: Path, suffix: str) -> list[str]:
107
+ """App-relative POSIX paths of every ``suffix`` file, including subfolders
108
+ a dotted tag can name (``widgets/row.weft`` for ``<widgets.Row>``).
109
+
110
+ Folders that cannot be part of a tag are skipped -- dot-folders such as
111
+ ``.venv``, ``_private`` ones, ``node_modules`` -- and so is build output
112
+ (``dist`` or any folder holding a ``pyweft-src``), which would otherwise
113
+ ship a second copy of the app inside the app.
114
+ """
115
+ found: list[str] = []
116
+
117
+ def walk(folder: Path, prefix: str) -> None:
118
+ for entry in sorted(folder.iterdir(), key=lambda e: e.name):
119
+ if entry.is_dir():
120
+ if (
121
+ entry.is_symlink()
122
+ or not entry.name.isidentifier()
123
+ or entry.name.startswith("_")
124
+ or entry.name in _SKIP_DIRS
125
+ or (entry / "pyweft-src").is_dir()
126
+ ):
127
+ continue
128
+ walk(entry, f"{prefix}{entry.name}/")
129
+ elif entry.suffix == suffix:
130
+ found.append(prefix + entry.name)
131
+
132
+ walk(app_dir, "")
133
+ return found
134
+
135
+
136
+ def app_pyweft_files(app_dir: Path) -> list[str]:
137
+ files = _app_files(app_dir, ".weft")
138
+ if "app.weft" not in files:
139
+ raise CliError(f"no app.weft in {app_dir} -- the root component must be app.weft")
140
+ return files
141
+
142
+
143
+ def app_css_files(app_dir: Path) -> list[str]:
144
+ return _app_files(app_dir, ".css")
145
+
146
+
147
+ # Python files an app folder may hold that are tooling, not app modules.
148
+ _NOT_MODULES = {"setup.py", "conftest.py", "noxfile.py"}
149
+
150
+
151
+ def app_python_files(app_dir: Path) -> list[str]:
152
+ """The app's own modules (``orders_data.py``, ``models/__init__.py``),
153
+ shipped to ``/app`` so components can import them. A name that is not
154
+ an identifier (``build-site.py``) cannot be imported, so it stays home."""
155
+ return [
156
+ rel
157
+ for rel in _app_files(app_dir, ".py")
158
+ if Path(rel).stem.isidentifier() and Path(rel).name not in _NOT_MODULES
159
+ ]
160
+
161
+
162
+ def app_shipped_files(app_dir: Path) -> list[str]:
163
+ """Everything the shell writes into ``/app``: components, then modules."""
164
+ return app_pyweft_files(app_dir) + app_python_files(app_dir)
165
+
166
+
167
+ def app_assets(app_dir: Path) -> list[str]:
168
+ """Paths below ``assets/`` (``logo.svg``, ``fonts/inter.woff2``) of every
169
+ file there; served at ``assets/<path>`` by ``run`` and copied by ``build``."""
170
+ root = app_dir / ASSETS_DIR
171
+ if not root.is_dir():
172
+ return []
173
+ return sorted(
174
+ p.relative_to(root).as_posix()
175
+ for p in root.rglob("*")
176
+ if p.is_file() and not p.is_symlink()
177
+ )
178
+
179
+
180
+ def _asset_path(app_dir: Path, rel: str) -> Path | None:
181
+ """The file ``assets/<rel>`` names, or None if it is missing or outside
182
+ ``assets/`` (``..``, symlinks), so a crafted URL reaches nothing else."""
183
+ root = (app_dir / ASSETS_DIR).resolve()
184
+ try:
185
+ path = (root / rel).resolve()
186
+ except (OSError, ValueError):
187
+ return None
188
+ if path == root or not path.is_relative_to(root) or not path.is_file():
189
+ return None
190
+ return path
191
+
192
+
193
+ _APP_SETTINGS = ("title", "icon", "desktop")
194
+ _ICON_TYPES = (".ico", ".png", ".svg")
195
+
196
+
197
+ @dataclass
198
+ class AppIdentity:
199
+ title: str
200
+ icon: str | None # path below the app folder, always under assets/
201
+
202
+
203
+ def app_identity(app_dir: Path) -> AppIdentity:
204
+ """The app's name and icon, from ``[tool.pyweft]``: the browser tab and
205
+ favicon, and the desktop window. The folder name when there's no title."""
206
+ settings = app_settings(app_dir)
207
+ unknown = sorted(set(settings) - set(_APP_SETTINGS))
208
+ if unknown:
209
+ raise CliError(
210
+ f"pyproject.toml [tool.pyweft] has no setting {unknown[0]!r}; "
211
+ f"the settings are {', '.join(_APP_SETTINGS)}"
212
+ )
213
+ title = settings.get("title", app_dir.resolve().name)
214
+ if not isinstance(title, str) or not title.strip():
215
+ raise CliError('pyproject.toml [tool.pyweft] title must be text, like title = "My App"')
216
+ icon = settings.get("icon")
217
+ if icon is not None:
218
+ if not isinstance(icon, str):
219
+ raise CliError(
220
+ 'pyproject.toml [tool.pyweft] icon must be a path, like icon = "assets/icon.ico"'
221
+ )
222
+ icon = icon.replace("\\", "/").removeprefix("./")
223
+ inside = icon.startswith(ASSETS_DIR + "/")
224
+ if not inside or _asset_path(app_dir, icon[len(ASSETS_DIR) + 1 :]) is None:
225
+ raise CliError(
226
+ f"pyproject.toml [tool.pyweft] icon {icon!r} must be a file in the "
227
+ f"app's {ASSETS_DIR}/ folder, so the browser can show it too"
228
+ )
229
+ if not icon.lower().endswith(_ICON_TYPES):
230
+ raise CliError(
231
+ f"pyproject.toml [tool.pyweft] icon {icon!r} must be one of "
232
+ f"{', '.join(_ICON_TYPES)} (.ico for a Windows window icon)"
233
+ )
234
+ return AppIdentity(title=title.strip(), icon=icon)
235
+
236
+
237
+ # mimetypes reads the Windows registry, which can be wrong or missing these.
238
+ _CONTENT_TYPES = {
239
+ ".css": "text/css",
240
+ ".js": "text/javascript",
241
+ ".mjs": "text/javascript",
242
+ ".json": "application/json",
243
+ ".svg": "image/svg+xml",
244
+ ".png": "image/png",
245
+ ".jpg": "image/jpeg",
246
+ ".jpeg": "image/jpeg",
247
+ ".gif": "image/gif",
248
+ ".webp": "image/webp",
249
+ ".avif": "image/avif",
250
+ ".ico": "image/x-icon",
251
+ ".woff": "font/woff",
252
+ ".woff2": "font/woff2",
253
+ ".ttf": "font/ttf",
254
+ ".otf": "font/otf",
255
+ }
256
+
257
+
258
+ def content_type(path: Path) -> str:
259
+ known = _CONTENT_TYPES.get(path.suffix.lower())
260
+ return known or mimetypes.guess_type(path.name)[0] or "application/octet-stream"
261
+
262
+
263
+ # --------------------------------------------------------- component libraries
264
+
265
+
266
+ @dataclass
267
+ class Library:
268
+ """A dependency that holds .weft components and is installed where
269
+ ``pyweft run`` runs: shipped to the browser from there (``/lib/<name>/``)
270
+ instead of being installed from PyPI."""
271
+
272
+ name: str # the import name
273
+ folder: Path
274
+ files: list[str] # POSIX paths below folder
275
+
276
+
277
+ _LIBRARY_SUFFIXES = {".py", ".weft", ".css"}
278
+
279
+
280
+ def _requirement_name(spec: str) -> str:
281
+ match = re.match(r"\s*([A-Za-z0-9][A-Za-z0-9._-]*)", spec)
282
+ return match.group(1) if match else spec.strip()
283
+
284
+
285
+ def _normalise(name: str) -> str:
286
+ return re.sub(r"[-_.]+", "-", name).lower()
287
+
288
+
289
+ def _distribution(dist_name: str):
290
+ from importlib import metadata
291
+
292
+ try:
293
+ return metadata.distribution(dist_name)
294
+ except metadata.PackageNotFoundError:
295
+ return None
296
+
297
+
298
+ def _import_names(dist_name: str) -> list[str]:
299
+ """Top-level packages a distribution installs, best effort: its
300
+ top_level.txt, else its file list, else the name with - as _."""
301
+ names: list[str] = []
302
+ dist = _distribution(dist_name)
303
+ if dist is not None:
304
+ top = dist.read_text("top_level.txt")
305
+ if top:
306
+ names += top.split()
307
+ for f in dist.files or []:
308
+ first = f.parts[0]
309
+ if len(f.parts) > 1 and first.isidentifier() and not first.endswith("-info"):
310
+ names.append(first)
311
+ names.append(dist_name.replace("-", "_").replace(".", "_"))
312
+ return list(dict.fromkeys(names))
313
+
314
+
315
+ def find_library(dist_name: str) -> Library | None:
316
+ import importlib.util
317
+
318
+ for import_name in _import_names(dist_name):
319
+ try:
320
+ spec = importlib.util.find_spec(import_name)
321
+ except (ImportError, ValueError):
322
+ continue
323
+ if spec is None or not spec.submodule_search_locations:
324
+ continue
325
+ folder = Path(next(iter(spec.submodule_search_locations)))
326
+ files = sorted(
327
+ p.relative_to(folder).as_posix()
328
+ for p in folder.rglob("*")
329
+ if p.is_file()
330
+ and p.suffix in _LIBRARY_SUFFIXES
331
+ and "__pycache__" not in p.parts
332
+ )
333
+ if any(f.endswith(".weft") for f in files):
334
+ return Library(import_name, folder, files)
335
+ return None
336
+
337
+
338
+ def app_libraries(app_dir: Path) -> list[Library]:
339
+ found: dict[str, Library] = {}
340
+ for spec in app_dependencies(app_dir):
341
+ name = _requirement_name(spec)
342
+ if _normalise(name) == "pyweft":
343
+ continue
344
+ library = find_library(name)
345
+ if library is not None:
346
+ found[library.name] = library
347
+ return list(found.values())
348
+
349
+
350
+ def browser_packages(app_dir: Path) -> list[str]:
351
+ """What micropip installs in the browser: the app's dependencies minus
352
+ the libraries shipped from this environment (and PyWeft itself, which
353
+ ships as pyweft-src), plus those libraries' own requirements."""
354
+ packages: list[str] = []
355
+ shipped: set[str] = {"pyweft"}
356
+ requirements: list[str] = []
357
+ for spec in app_dependencies(app_dir):
358
+ name = _requirement_name(spec)
359
+ if _normalise(name) == "pyweft":
360
+ continue
361
+ library = find_library(name)
362
+ if library is None:
363
+ packages.append(spec)
364
+ continue
365
+ shipped |= {_normalise(name), _normalise(library.name)}
366
+ dist = _distribution(name)
367
+ requirements += (dist.requires or []) if dist is not None else []
368
+ for req in requirements:
369
+ if re.search(r"extra\s*==", req):
370
+ continue # optional extras are not installed
371
+ if _normalise(_requirement_name(req)) not in shipped:
372
+ packages.append(req.split(";")[0].strip())
373
+ return list(dict.fromkeys(packages))
374
+
375
+
376
+ # Never shipped to the browser: they run where `pyweft` runs.
377
+ _HOST_ONLY = {"cli.py", "__main__.py", "prerender.py", "desktop.py", "packaging.py"}
378
+
379
+
380
+ def pyweft_modules() -> list[str]:
381
+ return sorted(p.name for p in _PKG_DIR.glob("*.py") if p.name not in _HOST_ONLY)
382
+
383
+
384
+ LOADING_FILE = "loading.html"
385
+
386
+
387
+ def loading_screen(app_dir: Path) -> str:
388
+ """The app's own ``loading.html`` if it has one (an empty file means no
389
+ loading screen), otherwise the built-in default."""
390
+ custom = app_dir / LOADING_FILE
391
+ source = custom if custom.is_file() else _STATIC / LOADING_FILE
392
+ return source.read_text(encoding="utf-8")
393
+
394
+
395
+ HEAD_FILE = "head.html"
396
+
397
+
398
+ def head_extras(app_dir: Path) -> str:
399
+ """The app's ``head.html``, added to the page's ``<head>``: CDN
400
+ stylesheets and scripts, fonts, meta tags. Empty if there is none."""
401
+ path = app_dir / HEAD_FILE
402
+ return path.read_text(encoding="utf-8") if path.is_file() else ""
403
+
404
+
405
+ class _StylesheetLinks(HTMLParser):
406
+ def __init__(self) -> None:
407
+ super().__init__()
408
+ self.hrefs: list[str] = []
409
+
410
+ def handle_starttag(self, tag, attrs):
411
+ a = dict(attrs)
412
+ if tag == "link" and "stylesheet" in (a.get("rel") or "").lower().split():
413
+ if a.get("href"):
414
+ self.hrefs.append(a["href"])
415
+
416
+ handle_startendtag = handle_starttag
417
+
418
+
419
+ def linked_app_css(head: str) -> set[str]:
420
+ """App .css files that head.html links itself (``app/tailwind.css``), and
421
+ which therefore must not be linked a second time, out of its order."""
422
+ parser = _StylesheetLinks()
423
+ parser.feed(head)
424
+ parser.close()
425
+ found = set()
426
+ for href in parser.hrefs:
427
+ href = re.split(r"[?#]", href, maxsplit=1)[0]
428
+ href = href[2:] if href.startswith("./") else href.lstrip("/")
429
+ if href.startswith("app/"):
430
+ found.add(href[len("app/") :])
431
+ return found
432
+
433
+
434
+ _PRERENDER_TIMEOUT = 20 # seconds; past this the app gets its loading screen
435
+ _prerendered: dict[str, tuple[frozenset, dict | None]] = {}
436
+ _prerender_lock = threading.Lock()
437
+
438
+
439
+ def prerender_app(app_dir: Path) -> dict | None:
440
+ """The app's first screen rendered on this machine (see prerender.py),
441
+ or None when it can't be, such as code that only works in a browser.
442
+ Runs in a separate process, once per state of the app's files: the dev
443
+ server asks on every page load."""
444
+ from .prerender import MARK
445
+
446
+ key = frozenset(snapshot(app_dir).items())
447
+ with _prerender_lock:
448
+ cached = _prerendered.get(str(app_dir))
449
+ if cached is not None and cached[0] == key:
450
+ return cached[1]
451
+ try:
452
+ proc = subprocess.run(
453
+ [sys.executable, "-m", "pyweft.prerender", str(app_dir.resolve())],
454
+ cwd=app_dir,
455
+ capture_output=True,
456
+ text=True,
457
+ encoding="utf-8",
458
+ errors="replace",
459
+ timeout=_PRERENDER_TIMEOUT,
460
+ )
461
+ line = next(
462
+ (l for l in reversed(proc.stdout.splitlines()) if l.startswith(MARK)), None
463
+ )
464
+ if line is not None:
465
+ result = json.loads(line[len(MARK) :])
466
+ else:
467
+ lines = proc.stderr.strip().splitlines() or ["it stopped without a result"]
468
+ result = {"error": lines[-1]}
469
+ except subprocess.TimeoutExpired:
470
+ result = {"error": f"it took longer than {_PRERENDER_TIMEOUT} s"}
471
+ if "error" in result:
472
+ # Said once per state of the files, not on every page load.
473
+ reason = "\n ".join(result["error"].splitlines())
474
+ print(
475
+ "pyweft: no pre-rendered page, the loading screen shows instead, "
476
+ f"because rendering the app here failed:\n {reason}",
477
+ file=sys.stderr,
478
+ )
479
+ result = None
480
+ _prerendered[str(app_dir)] = (key, result)
481
+ return result
482
+
483
+
484
+ def render_shell(
485
+ app_dir: Path, dev: bool = False, since: int = 0, prerender: bool = False
486
+ ) -> str:
487
+ """``dev`` turns on hot reload: only ``pyweft run`` pages get it, never
488
+ the static output of ``pyweft build``. ``since`` is how many changes the
489
+ dev server had published when it rendered the page; the page asks for
490
+ the ones after it, so a save made while it was starting is not lost.
491
+ ``prerender`` puts the app's first screen in the page (prerender_app)."""
492
+ pre = prerender_app(app_dir) if prerender else None
493
+ if pre is None:
494
+ app_attrs = ""
495
+ else:
496
+ # Not clickable until Python has taken over; the address it was
497
+ # rendered at, so a page opened elsewhere can drop it (shell.html).
498
+ app_attrs = f' data-pyweft-prerendered="{escape(pre["location"])}" inert'
499
+ identity = app_identity(app_dir)
500
+ icon = f'<link rel="icon" href="{escape(identity.icon)}" />' if identity.icon else ""
501
+ head = head_extras(app_dir)
502
+ linked = linked_app_css(head)
503
+ css = "\n ".join(
504
+ f'<link rel="stylesheet" href="app/{name}" data-pyweft-css="{name}" />'
505
+ for name in app_css_files(app_dir)
506
+ if name not in linked
507
+ )
508
+ html = (_STATIC / "shell.html").read_text(encoding="utf-8")
509
+ html = (
510
+ html.replace("__PYWEFT_ICON__", icon)
511
+ .replace("__PYWEFT_MODULES__", json.dumps(pyweft_modules()))
512
+ .replace("__PYWEFT_APP_FILES__", json.dumps(app_shipped_files(app_dir)))
513
+ .replace("__PYWEFT_PACKAGES__", json.dumps(browser_packages(app_dir)))
514
+ .replace(
515
+ "__PYWEFT_LIBRARIES__",
516
+ json.dumps([{"name": lib.name, "files": lib.files} for lib in app_libraries(app_dir)]),
517
+ )
518
+ .replace("__PYWEFT_DEV__", "true" if dev else "false")
519
+ .replace("__PYWEFT_SINCE__", str(since))
520
+ .replace("__PYWEFT_CSS__", css)
521
+ .replace("__PYWEFT_APP_ATTRS__", app_attrs)
522
+ )
523
+ leftover = re.search(
524
+ r"__PYWEFT_(?!TITLE__|HEAD__|LOADING__|PRERENDER__|PRERENDER_CSS__)[A-Z_]+?__", html
525
+ )
526
+ if leftover:
527
+ # Only possible when shell.html is newer than this process's code:
528
+ # PyWeft was updated while `pyweft run` kept running.
529
+ raise CliError(
530
+ f"shell.html has a placeholder ({leftover.group(0)}) this pyweft "
531
+ "does not fill: PyWeft was updated while `pyweft run` was running. "
532
+ "Restart `pyweft run`."
533
+ )
534
+ # The user's files go in last and in one pass, so text in them is never
535
+ # taken for a placeholder (not even one in the other file). The
536
+ # pre-rendered page counts: it is the app's own text.
537
+ user = {
538
+ "__PYWEFT_TITLE__": escape(identity.title),
539
+ "__PYWEFT_HEAD__": head,
540
+ "__PYWEFT_LOADING__": loading_screen(app_dir),
541
+ "__PYWEFT_PRERENDER__": pre["html"] if pre else "",
542
+ "__PYWEFT_PRERENDER_CSS__": pre["css"] if pre else "",
543
+ }
544
+ return re.sub(
545
+ "__PYWEFT_(?:TITLE|HEAD|LOADING|PRERENDER_CSS|PRERENDER)__",
546
+ lambda m: user[m.group(0)],
547
+ html,
548
+ )
549
+
550
+
551
+ # ------------------------------------------------------------------ commands
552
+
553
+
554
+ _SCAFFOLD_APP = """\
555
+ <template>
556
+ <div class="hello">
557
+ <h1>Hello, {{ name or "stranger" }}!</h1>
558
+ <input @bind="name" placeholder="your name" />
559
+ <button @click="cheer">cheer</button>
560
+ <p>{{ cheers }} cheers so far</p>
561
+ </div>
562
+ </template>
563
+
564
+ <style>
565
+ /* Scoped to this component: these rules cannot leak into other components. */
566
+ .hello { border: 1px solid #ddd; border-radius: 8px; padding: 1.5rem; }
567
+ input, button { font: inherit; padding: 0.35rem 0.7rem; }
568
+ </style>
569
+
570
+ <code>
571
+ name: str = ""
572
+ cheers: int = 0
573
+
574
+ def cheer(self):
575
+ self.cheers += 1
576
+ </code>
577
+ """
578
+
579
+ _SCAFFOLD_CSS = """\
580
+ /* Global styles. Component-specific rules belong in a <style> block in the
581
+ component's .weft file, where they are scoped to that component. */
582
+ body {
583
+ font-family: system-ui, sans-serif;
584
+ max-width: 40rem;
585
+ margin: 3rem auto;
586
+ padding: 0 1rem;
587
+ color: #222;
588
+ }
589
+ """
590
+
591
+ _SCAFFOLD_HEAD = """\
592
+ <!--
593
+ Added to the page's <head>: stylesheets and scripts from a CDN, web fonts,
594
+ meta tags. Uncomment what you need.
595
+
596
+ Tailwind, no build step (development):
597
+ <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
598
+
599
+ Bootstrap's CSS:
600
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" />
601
+
602
+ Your own .css files are linked after this file automatically, in
603
+ alphabetical order. Link one here to choose where it goes instead:
604
+ <link rel="stylesheet" href="app/styles.css" />
605
+
606
+ Files in the assets/ folder are served at assets/...:
607
+ <link rel="icon" href="assets/favicon.svg" />
608
+ -->
609
+ """
610
+
611
+
612
+ def cmd_new(target: Path) -> None:
613
+ if target.exists() and any(target.iterdir()):
614
+ raise CliError(f"{target} already exists and is not empty")
615
+ target.mkdir(parents=True, exist_ok=True)
616
+ (target / "pyproject.toml").write_text(
617
+ f'[project]\nname = "{target.name}"\nversion = "0.1.0"\ndependencies = []\n\n'
618
+ "# The app's name, and an icon from assets/ (the browser tab, favicon\n"
619
+ "# and desktop window). [tool.pyweft.desktop] sets the window's size.\n"
620
+ f'[tool.pyweft]\ntitle = "{target.name}"\n# icon = "assets/icon.ico"\n',
621
+ encoding="utf-8",
622
+ )
623
+ (target / "app.weft").write_text(_SCAFFOLD_APP, encoding="utf-8")
624
+ (target / "styles.css").write_text(_SCAFFOLD_CSS, encoding="utf-8")
625
+ (target / HEAD_FILE).write_text(_SCAFFOLD_HEAD, encoding="utf-8")
626
+ shutil.copy(_STATIC / LOADING_FILE, target / LOADING_FILE)
627
+ print(f"created {target}/")
628
+ print(f" run it: pyweft run {target}")
629
+ print(f" add browser-usable packages with: uv add <pkg> (inside {target}/)")
630
+
631
+
632
+ def cmd_build(app_dir: Path, out: Path, prerender: bool = True) -> None:
633
+ files = app_shipped_files(app_dir) # validates app.weft exists first
634
+ html = render_shell(app_dir, prerender=prerender)
635
+ if out.exists():
636
+ shutil.rmtree(out)
637
+ (out / "pyweft-src").mkdir(parents=True)
638
+ (out / "app").mkdir()
639
+
640
+ (out / "index.html").write_text(html, encoding="utf-8")
641
+ shutil.copy(_STATIC / "runtime.js", out / "runtime.js")
642
+ shutil.copy(_STATIC / "worker.js", out / "worker.js")
643
+ for name in pyweft_modules():
644
+ shutil.copy(_PKG_DIR / name, out / "pyweft-src" / name)
645
+ for name in files + app_css_files(app_dir):
646
+ target = out / "app" / name
647
+ target.parent.mkdir(parents=True, exist_ok=True)
648
+ shutil.copy(app_dir / name, target)
649
+ for name in app_assets(app_dir):
650
+ target = out / ASSETS_DIR / name
651
+ target.parent.mkdir(parents=True, exist_ok=True)
652
+ shutil.copy(app_dir / ASSETS_DIR / name, target)
653
+ for lib in app_libraries(app_dir):
654
+ for name in lib.files:
655
+ target = out / "lib" / lib.name / name
656
+ target.parent.mkdir(parents=True, exist_ok=True)
657
+ shutil.copy(lib.folder / name, target)
658
+ print(f"built {out}/ -- serve it with any static file server")
659
+
660
+
661
+ # ---------------------------------------------------------------- hot reload
662
+
663
+ # Files that only take effect when the page starts: a change means reload.
664
+ _STARTUP_FILES = ("pyproject.toml", LOADING_FILE, HEAD_FILE)
665
+ _FRAMEWORK = "pyweft:" # snapshot key prefix for the framework's own files
666
+ _ASSET = ASSETS_DIR + "/" # snapshot key prefix for assets/ files
667
+ _LIB = "lib:" # snapshot key prefix for component library files
668
+ # Everything else of the framework is re-read per request; this is not.
669
+ _SERVER_CODE = _FRAMEWORK + "cli.py"
670
+ _STALE_MESSAGE = (
671
+ "PyWeft was updated, but this `pyweft run` is still running the old "
672
+ "server code. Restart `pyweft run`, then reload the page."
673
+ )
674
+
675
+
676
+ def snapshot(app_dir: Path) -> dict[str, tuple[int, int]]:
677
+ """(mtime, size) of everything a change notification is sent for: the
678
+ app's .weft/.css/.py files, its startup files and assets, and the
679
+ framework itself (so editing PyWeft while running an app reloads the
680
+ page too)."""
681
+ entries: dict[str, Path] = {}
682
+ shipped = _app_files(app_dir, ".weft") + _app_files(app_dir, ".css")
683
+ for rel in shipped + app_python_files(app_dir):
684
+ entries[rel] = app_dir / rel
685
+ for name in _STARTUP_FILES:
686
+ entries[name] = app_dir / name
687
+ for rel in app_assets(app_dir):
688
+ entries[_ASSET + rel] = app_dir / ASSETS_DIR / rel
689
+ for lib in app_libraries(app_dir):
690
+ for rel in lib.files:
691
+ entries[f"{_LIB}{lib.name}/{rel}"] = lib.folder / rel
692
+ for path in [*_PKG_DIR.glob("*.py"), *_STATIC.iterdir()]:
693
+ entries[_FRAMEWORK + path.name] = path
694
+
695
+ result: dict[str, tuple[int, int]] = {}
696
+ for key, path in entries.items():
697
+ try:
698
+ st = path.stat()
699
+ except OSError:
700
+ continue # absent, or deleted mid-scan
701
+ result[key] = (st.st_mtime_ns, st.st_size)
702
+ return result
703
+
704
+
705
+ def describe_change(old: dict, new: dict) -> dict | None:
706
+ """The message for the browser, or None if nothing changed.
707
+
708
+ ``reload`` when a startup, asset, framework or app ``.py`` file changed
709
+ (an image or font may be referenced from anywhere, and an imported
710
+ module stays cached in ``sys.modules``, so nothing short of a reload is
711
+ sure to show it); otherwise ``update`` listing changed/added and removed
712
+ .weft and .css files, which the page applies without losing state.
713
+ """
714
+ touched = sorted({k for k in old.keys() | new.keys() if old.get(k) != new.get(k)})
715
+ if not touched:
716
+ return None
717
+ for key in touched:
718
+ if (
719
+ key.startswith((_FRAMEWORK, _ASSET, _LIB))
720
+ or key in _STARTUP_FILES
721
+ or key.endswith(".py")
722
+ ):
723
+ name = key[len(_FRAMEWORK) :] if key.startswith(_FRAMEWORK) else key
724
+ return {"type": "reload", "reason": f"{name} changed"}
725
+
726
+ def pick(suffix: str, present: bool) -> list[str]:
727
+ return [k for k in touched if k.endswith(suffix) and (k in new) == present]
728
+
729
+ return {
730
+ "type": "update",
731
+ "weft": pick(".weft", True),
732
+ "weft_removed": pick(".weft", False),
733
+ "css": pick(".css", True),
734
+ "css_removed": pick(".css", False),
735
+ }
736
+
737
+
738
+ class ChangeFeed:
739
+ """Append-only list of change messages that event-stream handlers wait on."""
740
+
741
+ def __init__(self) -> None:
742
+ self.events: list[dict] = []
743
+ self.cond = threading.Condition()
744
+ self.closed = False
745
+ self.stale = False # the server's own code changed since it started
746
+
747
+ def publish(self, event: dict) -> None:
748
+ with self.cond:
749
+ self.events.append(event)
750
+ self.cond.notify_all()
751
+
752
+ def close(self) -> None:
753
+ with self.cond:
754
+ self.closed = True
755
+ self.cond.notify_all()
756
+
757
+
758
+ def _watch(app_dir: Path, feed: ChangeFeed, interval: float, stop: threading.Event):
759
+ current = snapshot(app_dir)
760
+ while not stop.wait(interval):
761
+ new = snapshot(app_dir)
762
+ if new == current:
763
+ continue
764
+ # Editors often save in several writes (truncate, write, rename);
765
+ # give them a moment so the page sees the finished file.
766
+ stop.wait(0.1)
767
+ new = snapshot(app_dir)
768
+ event = describe_change(current, new)
769
+ if current.get(_SERVER_CODE) != new.get(_SERVER_CODE):
770
+ # Reloading would only serve new page files from old server code
771
+ # (missing routes, unfilled placeholders). Say so where the user
772
+ # is looking, and keep saying it to pages that connect later.
773
+ print(f"pyweft: {_STALE_MESSAGE}", file=sys.stderr)
774
+ feed.stale = True
775
+ event = {"type": "stale", "reason": _STALE_MESSAGE}
776
+ current = new
777
+ if event:
778
+ feed.publish(event)
779
+
780
+
781
+ class _DevServer(ThreadingHTTPServer):
782
+ daemon_threads = True # open event streams must not block exit
783
+
784
+ if sys.platform == "win32":
785
+ # SO_REUSEADDR on Windows lets a second server listen on a port that
786
+ # is in use, and requests then reach either one: an old `pyweft run`
787
+ # left running would quietly serve pages from stale code.
788
+ allow_reuse_address = False
789
+
790
+ def server_bind(self):
791
+ self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
792
+ super().server_bind()
793
+
794
+
795
+ def make_dev_server(
796
+ app_dir: Path, port: int, poll_interval: float = 0.3, prerender: bool = False
797
+ ) -> ThreadingHTTPServer:
798
+ """The ``pyweft run`` server, not yet serving: the app plus a change
799
+ feed at ``/__pyweft/events`` that hot reload listens to. Call
800
+ ``serve_forever()``, and ``shutdown_dev()`` to stop it and its watcher.
801
+ ``prerender`` puts the app's first screen in the page, as build does."""
802
+ app_pyweft_files(app_dir) # fail fast with a clear message
803
+ feed = ChangeFeed()
804
+ stop = threading.Event()
805
+ # A new id per server run: a page that reconnects to a restarted server
806
+ # reloads, since the server may have been restarted for a reason.
807
+ server_id = uuid.uuid4().hex
808
+
809
+ class Handler(SimpleHTTPRequestHandler):
810
+ def do_GET(self): # noqa: N802 (stdlib naming)
811
+ route = self.path.split("?", 1)[0]
812
+ if route in ("/", "/index.html"):
813
+ # Re-rendered per request so edits to .weft files, css, or
814
+ # pyproject.toml show up on refresh -- the no-build-step loop.
815
+ with feed.cond:
816
+ since = len(feed.events)
817
+ try:
818
+ page = render_shell(app_dir, dev=True, since=since, prerender=prerender)
819
+ except CliError as exc:
820
+ print(f"error: {exc}", file=sys.stderr)
821
+ self.send_error(500, explain=str(exc))
822
+ return
823
+ return self._send("text/html", page.encode())
824
+ if route == "/__pyweft/events":
825
+ return self._events()
826
+ if route in ("/runtime.js", "/worker.js"):
827
+ return self._file(_STATIC / route[1:], "text/javascript")
828
+ name = Path(route).name # flat lookups only; no traversal
829
+ if route == f"/pyweft-src/{name}" and name in pyweft_modules():
830
+ return self._file(_PKG_DIR / name, "text/x-python")
831
+ if route.startswith("/app/"):
832
+ # Only paths the app actually ships, so a crafted URL cannot
833
+ # reach anything else on disk.
834
+ rel = route[len("/app/") :]
835
+ if rel in app_shipped_files(app_dir) or rel in app_css_files(app_dir):
836
+ ctype = "text/css" if rel.endswith(".css") else "text/plain"
837
+ return self._file(app_dir / rel, ctype)
838
+ if route.startswith("/lib/"):
839
+ # Only files of a library the app depends on, listed by name.
840
+ lib_name, _, rel = route[len("/lib/") :].partition("/")
841
+ for lib in app_libraries(app_dir):
842
+ if lib.name == lib_name and rel in lib.files:
843
+ ctype = "text/css" if rel.endswith(".css") else "text/plain"
844
+ return self._file(lib.folder / rel, ctype)
845
+ if route.startswith(f"/{_ASSET}"):
846
+ path = _asset_path(app_dir, unquote(route[len(_ASSET) + 1 :]))
847
+ if path is not None:
848
+ return self._file(path, content_type(path))
849
+ self.send_error(404)
850
+
851
+ def _file(self, path: Path, ctype: str):
852
+ self._send(ctype, path.read_bytes())
853
+
854
+ def _send(self, ctype: str, body: bytes):
855
+ self.send_response(200)
856
+ textual = ctype.startswith("text/") or ctype in (
857
+ "application/json",
858
+ "image/svg+xml",
859
+ )
860
+ self.send_header(
861
+ "Content-Type", f"{ctype}; charset=utf-8" if textual else ctype
862
+ )
863
+ self.send_header("Content-Length", str(len(body)))
864
+ self.send_header("Cache-Control", "no-store")
865
+ self.end_headers()
866
+ self.wfile.write(body)
867
+
868
+ def _events(self):
869
+ """Server-Sent Events: one JSON message per change, held open.
870
+
871
+ Starts after the change the page says it has seen: ``Last-Event-ID``
872
+ when the browser reconnects, else ``?since=`` from the page itself
873
+ (changes saved while it was loading), else from now.
874
+ """
875
+ self.send_response(200)
876
+ self.send_header("Content-Type", "text/event-stream")
877
+ self.send_header("Cache-Control", "no-store")
878
+ self.end_headers()
879
+ query = parse_qs(urlsplit(self.path).query)
880
+ start = self.headers.get("Last-Event-ID") or query.get("since", [None])[0]
881
+ with feed.cond:
882
+ seen = len(feed.events)
883
+ if start is not None and start.isdigit():
884
+ seen = min(int(start), seen)
885
+ try:
886
+ self._emit({"type": "hello", "server": server_id, "stale": feed.stale})
887
+ while True:
888
+ with feed.cond:
889
+ feed.cond.wait_for(
890
+ lambda: len(feed.events) > seen or feed.closed, timeout=15
891
+ )
892
+ if feed.closed:
893
+ return
894
+ first, new, seen = seen, feed.events[seen:], len(feed.events)
895
+ for number, event in enumerate(new, first + 1):
896
+ self._emit(event, number)
897
+ if not new: # keeps proxies from closing an idle stream
898
+ self.wfile.write(b": keepalive\n\n")
899
+ self.wfile.flush()
900
+ except OSError:
901
+ return # the page went away
902
+
903
+ def _emit(self, event: dict, number: int | None = None):
904
+ # The id is what the browser sends back as Last-Event-ID.
905
+ prefix = f"id: {number}\n" if number is not None else ""
906
+ self.wfile.write(f"{prefix}data: {json.dumps(event)}\n\n".encode())
907
+ self.wfile.flush()
908
+
909
+ def log_message(self, fmt, *args):
910
+ pass # keep the console quiet
911
+
912
+ server = _DevServer(("127.0.0.1", port), Handler)
913
+ watcher = threading.Thread(
914
+ target=_watch, args=(app_dir, feed, poll_interval, stop), daemon=True
915
+ )
916
+ watcher.start()
917
+
918
+ def shutdown_dev() -> None:
919
+ stop.set()
920
+ feed.close()
921
+ server.shutdown()
922
+ server.server_close()
923
+
924
+ server.shutdown_dev = shutdown_dev
925
+ server.feed = feed
926
+ return server
927
+
928
+
929
+ def cmd_run(app_dir: Path, port: int, prerender: bool = True) -> None:
930
+ try:
931
+ server = make_dev_server(app_dir, port, prerender=prerender)
932
+ except OSError as exc:
933
+ raise CliError(
934
+ f"cannot listen on port {port} ({exc.strerror or exc}). Is another "
935
+ "`pyweft run` still running? Stop it, or pick a port with --port"
936
+ ) from None
937
+ print(f"serving {app_dir} at http://127.0.0.1:{port}/ (Ctrl-C to stop)")
938
+ print(" hot reload is on: saved changes appear in the open page")
939
+ try:
940
+ server.serve_forever()
941
+ except KeyboardInterrupt:
942
+ pass
943
+ finally:
944
+ server.feed.close()
945
+
946
+
947
+ def main(argv: list[str] | None = None) -> int:
948
+ parser = argparse.ArgumentParser(prog="pyweft", description=__doc__)
949
+ sub = parser.add_subparsers(dest="command", required=True)
950
+
951
+ p_new = sub.add_parser("new", help="scaffold a new app directory")
952
+ p_new.add_argument("path", type=Path)
953
+
954
+ p_run = sub.add_parser("run", help="serve an app directory for the browser")
955
+ p_run.add_argument("app_dir", type=Path)
956
+ p_run.add_argument("--port", type=int, default=8642)
957
+
958
+ p_build = sub.add_parser("build", help="write a self-contained static site")
959
+ p_build.add_argument("app_dir", type=Path)
960
+ p_build.add_argument("--out", type=Path, default=None)
961
+
962
+ no_prerender = (
963
+ "show the loading screen instead of the app's first screen rendered "
964
+ "ahead of time (which runs the app's code here, up to its first await)"
965
+ )
966
+ for p in (p_run, p_build):
967
+ p.add_argument("--no-prerender", action="store_true", help=no_prerender)
968
+ p_run.add_argument(
969
+ "--desktop",
970
+ action="store_true",
971
+ help="open the app in a native window, with Python running on this "
972
+ 'machine (needs pywebview: uv add "pyweft[desktop]")',
973
+ )
974
+ p_build.add_argument(
975
+ "--desktop",
976
+ action="store_true",
977
+ help="build a desktop app to hand out (an .exe, .app or program) into "
978
+ 'dist-desktop/ (needs PyInstaller: uv add --dev "pyweft[package]")',
979
+ )
980
+
981
+ args = parser.parse_args(argv)
982
+ try:
983
+ if args.command == "new":
984
+ cmd_new(args.path)
985
+ elif args.command == "run" and args.desktop:
986
+ from .desktop import run_desktop
987
+
988
+ run_desktop(args.app_dir)
989
+ elif args.command == "run":
990
+ cmd_run(args.app_dir, args.port, prerender=not args.no_prerender)
991
+ elif args.desktop:
992
+ from .packaging import build_desktop
993
+
994
+ build_desktop(args.app_dir, args.out or args.app_dir / "dist-desktop")
995
+ else:
996
+ cmd_build(
997
+ args.app_dir, args.out or args.app_dir / "dist", prerender=not args.no_prerender
998
+ )
999
+ except CliError as exc:
1000
+ print(f"error: {exc}", file=sys.stderr)
1001
+ return 1
1002
+ return 0
1003
+
1004
+
1005
+ if __name__ == "__main__":
1006
+ sys.exit(main())