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/__init__.py +60 -0
- pyweft/__main__.py +4 -0
- pyweft/background.py +203 -0
- pyweft/binding.py +118 -0
- pyweft/cli.py +1006 -0
- pyweft/component.py +487 -0
- pyweft/desktop.py +606 -0
- pyweft/dialogs.py +123 -0
- pyweft/differ.py +195 -0
- pyweft/dommodel.py +63 -0
- pyweft/effects.py +191 -0
- pyweft/events.py +102 -0
- pyweft/expressions.py +184 -0
- pyweft/hotreload.py +122 -0
- pyweft/packaging.py +186 -0
- pyweft/parser.py +426 -0
- pyweft/prerender.py +121 -0
- pyweft/renderer.py +802 -0
- pyweft/routing.py +364 -0
- pyweft/static/loading.html +102 -0
- pyweft/static/runtime.js +341 -0
- pyweft/static/shell.html +693 -0
- pyweft/static/worker.js +89 -0
- pyweft/store.py +118 -0
- pyweft/styles.py +171 -0
- pyweft/vdom.py +57 -0
- pyweft-0.2.0.dist-info/METADATA +324 -0
- pyweft-0.2.0.dist-info/RECORD +31 -0
- pyweft-0.2.0.dist-info/WHEEL +4 -0
- pyweft-0.2.0.dist-info/entry_points.txt +2 -0
- pyweft-0.2.0.dist-info/licenses/LICENSE +21 -0
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())
|