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/__init__.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""PyWeft -- a UI framework for Python.
|
|
2
|
+
|
|
3
|
+
Markup in XML, logic in Python, one render core that emits transport-agnostic
|
|
4
|
+
patches so desktop (webview), web (Pyodide) and mobile can share a codebase.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from . import dialogs
|
|
8
|
+
from .background import BackgroundError, run_in_background
|
|
9
|
+
from .component import (
|
|
10
|
+
Component,
|
|
11
|
+
ComponentLibrary,
|
|
12
|
+
component_from_source,
|
|
13
|
+
load_component,
|
|
14
|
+
)
|
|
15
|
+
from .differ import DiffError, diff
|
|
16
|
+
from .dommodel import apply_patches
|
|
17
|
+
from .effects import effect
|
|
18
|
+
from .events import Event
|
|
19
|
+
from .expressions import ExpressionError
|
|
20
|
+
from .parser import TemplateError, parse_template, split_source
|
|
21
|
+
from .renderer import EffectLoopError, Renderer, set_title
|
|
22
|
+
from .routing import Link, navigate, router
|
|
23
|
+
from .store import Store
|
|
24
|
+
from .styles import collect_styles, scope_css
|
|
25
|
+
from .vdom import Element, Node, Text, to_dicts
|
|
26
|
+
|
|
27
|
+
__version__ = "0.2.0"
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"Component",
|
|
31
|
+
"ComponentLibrary",
|
|
32
|
+
"component_from_source",
|
|
33
|
+
"load_component",
|
|
34
|
+
"Renderer",
|
|
35
|
+
"effect",
|
|
36
|
+
"Event",
|
|
37
|
+
"EffectLoopError",
|
|
38
|
+
"run_in_background",
|
|
39
|
+
"BackgroundError",
|
|
40
|
+
"router",
|
|
41
|
+
"Link",
|
|
42
|
+
"navigate",
|
|
43
|
+
"Store",
|
|
44
|
+
"set_title",
|
|
45
|
+
"dialogs",
|
|
46
|
+
"collect_styles",
|
|
47
|
+
"scope_css",
|
|
48
|
+
"diff",
|
|
49
|
+
"apply_patches",
|
|
50
|
+
"Element",
|
|
51
|
+
"Text",
|
|
52
|
+
"Node",
|
|
53
|
+
"to_dicts",
|
|
54
|
+
"DiffError",
|
|
55
|
+
"TemplateError",
|
|
56
|
+
"ExpressionError",
|
|
57
|
+
"parse_template",
|
|
58
|
+
"split_source",
|
|
59
|
+
"__version__",
|
|
60
|
+
]
|
pyweft/__main__.py
ADDED
pyweft/background.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""``run_in_background``: run a plain function off the UI thread.
|
|
2
|
+
|
|
3
|
+
The core does not know what "off the UI thread" means on a given target, so a
|
|
4
|
+
host provides it. The browser host runs calls in a Web Worker: a second
|
|
5
|
+
Pyodide on its own browser thread, so the page keeps responding. With no host
|
|
6
|
+
installed (headless tests; the desktop target until its host exists) a call
|
|
7
|
+
runs on a thread via ``asyncio.to_thread``.
|
|
8
|
+
|
|
9
|
+
The worker is a separate interpreter, so a function cannot be sent there as
|
|
10
|
+
an object. It is sent by *name* -- an importable module plus a qualified name,
|
|
11
|
+
or a ``.weft`` file plus a name defined at the top level of its ``<code>``
|
|
12
|
+
block -- and the worker looks it up in its own copy of that code. Arguments
|
|
13
|
+
and the result travel pickled, both ways. The thread fallback goes through
|
|
14
|
+
exactly the same lookup and pickling, so what works in a headless test works
|
|
15
|
+
in the browser, and what would fail in the browser fails in the test too.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import importlib
|
|
22
|
+
import os
|
|
23
|
+
import pickle
|
|
24
|
+
import traceback
|
|
25
|
+
from typing import Any, Callable, Protocol
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BackgroundError(RuntimeError):
|
|
29
|
+
"""A background call failed in a way that could not be raised as the
|
|
30
|
+
original exception (it could not be pickled), or could not be made."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BackgroundHost(Protocol):
|
|
34
|
+
"""What a host provides. ``call`` takes a request from ``pack_call`` and
|
|
35
|
+
returns the reply that ``execute`` produced for it, wherever it ran."""
|
|
36
|
+
|
|
37
|
+
async def call(self, request: bytes) -> bytes: ...
|
|
38
|
+
|
|
39
|
+
def warm_up(self) -> None: ...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_host: BackgroundHost | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def set_host(host: BackgroundHost | None) -> None:
|
|
46
|
+
global _host
|
|
47
|
+
_host = host
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def run_in_background(fn: Callable, /, *args: Any, **kwargs: Any) -> Any:
|
|
51
|
+
"""Run ``fn(*args, **kwargs)`` off the UI thread and return its result.
|
|
52
|
+
|
|
53
|
+
``fn`` must be a plain function the background side can find by name:
|
|
54
|
+
defined at the top level of a ``.weft`` code block, or in an importable
|
|
55
|
+
module (the standard library, a PyPI package). Not a method, lambda or
|
|
56
|
+
nested function: it runs in another interpreter and cannot see the
|
|
57
|
+
component. Arguments and the return value must be picklable.
|
|
58
|
+
|
|
59
|
+
Exceptions raised by ``fn`` are re-raised here. If the awaiting task is
|
|
60
|
+
cancelled, a call that has not started yet is dropped; one already
|
|
61
|
+
running finishes and its result is thrown away.
|
|
62
|
+
"""
|
|
63
|
+
request = pack_call(fn, args, kwargs)
|
|
64
|
+
if _host is None:
|
|
65
|
+
reply = await asyncio.to_thread(execute, request)
|
|
66
|
+
else:
|
|
67
|
+
reply = await _host.call(request)
|
|
68
|
+
return unpack_reply(reply)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def warm_up() -> None:
|
|
72
|
+
"""Start the background worker now, so the first ``run_in_background``
|
|
73
|
+
doesn't wait for it (in the browser that is a few seconds). Optional."""
|
|
74
|
+
if _host is not None:
|
|
75
|
+
_host.warm_up()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ------------------------------------------------------------- the request
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def function_ref(fn: Callable) -> tuple[str, str, str]:
|
|
82
|
+
"""How the background side finds ``fn``: ``("module", name, qualname)``
|
|
83
|
+
or ``("weft", path, name)``."""
|
|
84
|
+
if getattr(fn, "__self__", None) is not None and not isinstance(
|
|
85
|
+
getattr(fn, "__self__"), type(os)
|
|
86
|
+
):
|
|
87
|
+
raise TypeError(
|
|
88
|
+
f"run_in_background needs a plain function, not the method "
|
|
89
|
+
f"{getattr(fn, '__qualname__', fn)!r}: it runs in another "
|
|
90
|
+
"interpreter and cannot see the object. Define it at the top of "
|
|
91
|
+
"the <code> block (no self) and pass the data it needs."
|
|
92
|
+
)
|
|
93
|
+
qualname = getattr(fn, "__qualname__", None)
|
|
94
|
+
if not qualname or "<lambda>" in qualname or "<locals>" in qualname:
|
|
95
|
+
raise TypeError(
|
|
96
|
+
"run_in_background needs a function defined at the top level of "
|
|
97
|
+
"a <code> block or a module, so it can be found by name; got "
|
|
98
|
+
f"{qualname or fn!r} (lambdas and nested functions cannot be)"
|
|
99
|
+
)
|
|
100
|
+
module = getattr(fn, "__module__", None)
|
|
101
|
+
code = getattr(fn, "__code__", None)
|
|
102
|
+
if module is None and code is not None and code.co_filename.endswith(".weft"):
|
|
103
|
+
return ("weft", code.co_filename, qualname)
|
|
104
|
+
if module and module != "__main__":
|
|
105
|
+
return ("module", module, qualname)
|
|
106
|
+
raise TypeError(
|
|
107
|
+
f"run_in_background cannot find {qualname!r} by name: define it at "
|
|
108
|
+
"the top level of a .weft <code> block or in an importable module"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def pack_call(fn: Callable, args: tuple, kwargs: dict) -> bytes:
|
|
113
|
+
ref = function_ref(fn)
|
|
114
|
+
try:
|
|
115
|
+
return pickle.dumps((ref, args, kwargs))
|
|
116
|
+
except Exception as exc:
|
|
117
|
+
raise TypeError(
|
|
118
|
+
f"the arguments to run_in_background({ref[2]}, ...) must be "
|
|
119
|
+
f"picklable: {exc}"
|
|
120
|
+
) from None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def unpack_reply(reply: bytes) -> Any:
|
|
124
|
+
status, value, where = pickle.loads(reply)
|
|
125
|
+
if status == "ok":
|
|
126
|
+
return value
|
|
127
|
+
text, frames = where
|
|
128
|
+
if hasattr(value, "add_note"): # 3.11+
|
|
129
|
+
value.add_note(f"Raised in the background, by:\n{text}")
|
|
130
|
+
# (file, line, code) frames, so error displays can point at the line
|
|
131
|
+
# in the background function, not only at the await that received it.
|
|
132
|
+
value._pyweft_background_frames = frames
|
|
133
|
+
raise value
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# --------------------------------------------------- the background side
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# path -> ((mtime_ns, size), namespace). Keyed on the file's stat so a worker
|
|
140
|
+
# whose files were updated by hot reload runs the new code.
|
|
141
|
+
_weft_namespaces: dict[str, tuple[tuple[int, int], dict[str, Any]]] = {}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _weft_namespace(path: str) -> dict[str, Any]:
|
|
145
|
+
from .component import compile_code_block
|
|
146
|
+
|
|
147
|
+
st = os.stat(path)
|
|
148
|
+
stamp = (st.st_mtime_ns, st.st_size)
|
|
149
|
+
cached = _weft_namespaces.get(path)
|
|
150
|
+
if cached and cached[0] == stamp:
|
|
151
|
+
return cached[1]
|
|
152
|
+
with open(path, encoding="utf-8") as f:
|
|
153
|
+
src = f.read()
|
|
154
|
+
namespace: dict[str, Any] = {"__name__": f"weft:{os.path.basename(path)}"}
|
|
155
|
+
exec(compile_code_block(src, path), namespace)
|
|
156
|
+
_weft_namespaces[path] = (stamp, namespace)
|
|
157
|
+
return namespace
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def resolve(ref: tuple[str, str, str]) -> Callable:
|
|
161
|
+
kind, where, qualname = ref
|
|
162
|
+
if kind == "weft":
|
|
163
|
+
target: Any = _weft_namespace(where)
|
|
164
|
+
first, *rest = qualname.split(".")
|
|
165
|
+
target = target[first]
|
|
166
|
+
else:
|
|
167
|
+
target = importlib.import_module(where)
|
|
168
|
+
rest = qualname.split(".")
|
|
169
|
+
for part in rest:
|
|
170
|
+
target = getattr(target, part)
|
|
171
|
+
return target
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def execute(request: bytes) -> bytes:
|
|
175
|
+
"""Run a packed call and pack its outcome. This is what the worker runs;
|
|
176
|
+
it never raises, so every failure reaches the caller."""
|
|
177
|
+
try:
|
|
178
|
+
ref, args, kwargs = pickle.loads(request)
|
|
179
|
+
fn = resolve(ref)
|
|
180
|
+
result = fn(*args, **kwargs)
|
|
181
|
+
except BaseException as exc: # noqa: BLE001 -- all of it goes back
|
|
182
|
+
return _pack_error(exc)
|
|
183
|
+
try:
|
|
184
|
+
return pickle.dumps(("ok", result, ""))
|
|
185
|
+
except Exception as exc:
|
|
186
|
+
return _pack_error(
|
|
187
|
+
BackgroundError(f"the result of {ref[2]} could not be pickled: {exc}")
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _pack_error(exc: BaseException) -> bytes:
|
|
192
|
+
text = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
|
|
193
|
+
frames = [
|
|
194
|
+
(f.filename, f.lineno, f.line) for f in traceback.extract_tb(exc.__traceback__)
|
|
195
|
+
]
|
|
196
|
+
where = (text, frames)
|
|
197
|
+
try:
|
|
198
|
+
payload = pickle.dumps(("error", exc, where))
|
|
199
|
+
pickle.loads(payload) # some exceptions pickle but do not unpickle
|
|
200
|
+
return payload
|
|
201
|
+
except Exception:
|
|
202
|
+
portable = BackgroundError(f"{type(exc).__name__}: {exc}")
|
|
203
|
+
return pickle.dumps(("error", portable, where))
|
pyweft/binding.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""``@bind``: which property a control binds, and converting what the user
|
|
2
|
+
typed back to the field's type.
|
|
3
|
+
|
|
4
|
+
The type comes from the field's *declaration* -- its annotation, else the type
|
|
5
|
+
of its class-level default -- not from its current value. Converting by the
|
|
6
|
+
current value lost the type for good the moment a bound number box was cleared
|
|
7
|
+
(the field became ``""``, and every later keystroke stayed a string).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import functools
|
|
13
|
+
import inspect
|
|
14
|
+
import types
|
|
15
|
+
import typing
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
# Returned by ``convert`` when the text cannot be stored in the field (letters
|
|
19
|
+
# in a number box, an empty box for a field that cannot be None): the field
|
|
20
|
+
# keeps its last good value while the user is still typing.
|
|
21
|
+
KEEP = object()
|
|
22
|
+
|
|
23
|
+
_SCALARS = (bool, int, float, str)
|
|
24
|
+
_TRUE = {"true", "1", "on", "yes"}
|
|
25
|
+
_FALSE = {"false", "0", "off", "no", ""}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def bind_kind(tag: str, attrs: dict[str, str]) -> str:
|
|
29
|
+
"""``"checkbox"``, ``"radio"`` or ``"value"`` (text, number, textarea,
|
|
30
|
+
select ...): what the binding reads and writes on the element."""
|
|
31
|
+
if tag == "input":
|
|
32
|
+
kind = attrs.get("type", "").lower()
|
|
33
|
+
if kind in ("checkbox", "radio"):
|
|
34
|
+
return kind
|
|
35
|
+
return "value"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@functools.lru_cache(maxsize=1024) # called for every bound element, every render
|
|
39
|
+
def declared_type(cls: type, field: str) -> tuple[type | None, bool]:
|
|
40
|
+
"""(scalar type or None if unknown, whether None is allowed)."""
|
|
41
|
+
annotation = _annotation(cls, field)
|
|
42
|
+
if annotation is not None:
|
|
43
|
+
args = typing.get_args(annotation)
|
|
44
|
+
if typing.get_origin(annotation) in (typing.Union, types.UnionType):
|
|
45
|
+
allows_none = type(None) in args
|
|
46
|
+
scalars = [a for a in args if a in _SCALARS]
|
|
47
|
+
return (scalars[0] if len(scalars) == 1 else None), allows_none
|
|
48
|
+
if annotation in _SCALARS:
|
|
49
|
+
return annotation, False
|
|
50
|
+
default = getattr(cls, field, None)
|
|
51
|
+
if default is None:
|
|
52
|
+
return None, True
|
|
53
|
+
return (type(default) if type(default) in _SCALARS else None), False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _annotation(cls: type, field: str) -> Any:
|
|
57
|
+
for klass in cls.__mro__:
|
|
58
|
+
try:
|
|
59
|
+
annotations = inspect.get_annotations(klass, eval_str=True)
|
|
60
|
+
except Exception:
|
|
61
|
+
try:
|
|
62
|
+
annotations = inspect.get_annotations(klass)
|
|
63
|
+
except Exception:
|
|
64
|
+
continue
|
|
65
|
+
if field in annotations:
|
|
66
|
+
value = annotations[field]
|
|
67
|
+
return None if isinstance(value, str) else value
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def prop_value(cls: type, name: str, text: str) -> Any:
|
|
72
|
+
"""A plain attribute on a component tag, as the declared field's type:
|
|
73
|
+
``cols="3"`` -> 3, ``disabled="true"`` -> True. Raises ValueError (with
|
|
74
|
+
a message for the template author) when the text doesn't fit."""
|
|
75
|
+
target, allows_none = declared_type(cls, name)
|
|
76
|
+
if target is bool:
|
|
77
|
+
lowered = text.strip().lower()
|
|
78
|
+
# An empty value is the attribute's presence: disabled="" is on.
|
|
79
|
+
if lowered in ("", "true", "1", "yes", "on", name.lower()):
|
|
80
|
+
return True
|
|
81
|
+
if lowered in ("false", "0", "no", "off"):
|
|
82
|
+
return False
|
|
83
|
+
raise ValueError(f"expects true or false, got {text!r}")
|
|
84
|
+
if target in (int, float):
|
|
85
|
+
if allows_none and not text.strip():
|
|
86
|
+
return None
|
|
87
|
+
try:
|
|
88
|
+
return target(text.strip())
|
|
89
|
+
except ValueError:
|
|
90
|
+
kind = "a whole number" if target is int else "a number"
|
|
91
|
+
raise ValueError(f"expects {kind}, got {text!r}") from None
|
|
92
|
+
return text
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def convert(text: str, target: type | None, allows_none: bool, number: bool) -> Any:
|
|
96
|
+
"""What to store for ``text`` typed into a field of type ``target``, or
|
|
97
|
+
``KEEP``. ``number`` (``@bind.number``) parses even when the field's type
|
|
98
|
+
is unknown or ``str``."""
|
|
99
|
+
if target is bool:
|
|
100
|
+
lowered = text.strip().lower()
|
|
101
|
+
if lowered in _TRUE:
|
|
102
|
+
return True
|
|
103
|
+
if lowered in _FALSE:
|
|
104
|
+
return False
|
|
105
|
+
return KEEP
|
|
106
|
+
if target in (int, float) or number:
|
|
107
|
+
stripped = text.strip()
|
|
108
|
+
if not stripped:
|
|
109
|
+
return None if allows_none else KEEP
|
|
110
|
+
for parse in ((int,) if target is int else (float,) if target is float else (int, float)):
|
|
111
|
+
try:
|
|
112
|
+
return parse(stripped)
|
|
113
|
+
except ValueError:
|
|
114
|
+
continue
|
|
115
|
+
return KEEP
|
|
116
|
+
if target is None and allows_none and text == "":
|
|
117
|
+
return None
|
|
118
|
+
return text
|