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 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
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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