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/component.py
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
"""Component base class and the ``.weft`` loader.
|
|
2
|
+
|
|
3
|
+
Reactivity model is Blazor's, not React's: there are no observables and no
|
|
4
|
+
proxies. An event handler runs, then the component re-renders and the diff is
|
|
5
|
+
sent. If you mutate state outside a handler (a timer, an async callback), call
|
|
6
|
+
``state_has_changed()`` yourself.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import copy
|
|
12
|
+
import functools
|
|
13
|
+
import inspect
|
|
14
|
+
import re
|
|
15
|
+
import textwrap
|
|
16
|
+
import types
|
|
17
|
+
from contextvars import ContextVar
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Callable
|
|
21
|
+
|
|
22
|
+
from .parser import (
|
|
23
|
+
TElement,
|
|
24
|
+
TemplateError,
|
|
25
|
+
TFill,
|
|
26
|
+
TFor,
|
|
27
|
+
TIf,
|
|
28
|
+
TSlot,
|
|
29
|
+
block_spans,
|
|
30
|
+
parse_template,
|
|
31
|
+
split_source,
|
|
32
|
+
)
|
|
33
|
+
from .store import Store
|
|
34
|
+
from .styles import scope_css
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Component:
|
|
38
|
+
__template__: list[Any] = []
|
|
39
|
+
# Set by the loader when the file has a <style> block: the id stamped on
|
|
40
|
+
# every element this component renders (as ``data-w-<scope>``) and the
|
|
41
|
+
# block's CSS with its selectors rewritten to match only those elements.
|
|
42
|
+
__scope__: str | None = None
|
|
43
|
+
__style__: str = ""
|
|
44
|
+
# Tag name -> component class, consulted by the renderer when a template
|
|
45
|
+
# element's tag is capitalised. Filled by the loader or passed explicitly.
|
|
46
|
+
components: dict[str, type] = {}
|
|
47
|
+
|
|
48
|
+
def __init__(self, **params):
|
|
49
|
+
self._dirty = True
|
|
50
|
+
self._on_change: Callable[[Component], None] | None = None
|
|
51
|
+
self._rendered_once = False
|
|
52
|
+
|
|
53
|
+
# Class-level mutable defaults (``history: list = []`` in a code block)
|
|
54
|
+
# would otherwise be shared by every instance -- a classic Python
|
|
55
|
+
# footgun that would look like a framework bug. Copy them per instance.
|
|
56
|
+
cls = type(self)
|
|
57
|
+
for name in dir(cls):
|
|
58
|
+
if name.startswith("_") or name == "components":
|
|
59
|
+
continue
|
|
60
|
+
try:
|
|
61
|
+
value = getattr(cls, name)
|
|
62
|
+
except AttributeError:
|
|
63
|
+
continue
|
|
64
|
+
if isinstance(value, (list, dict, set)):
|
|
65
|
+
setattr(self, name, copy.deepcopy(value))
|
|
66
|
+
|
|
67
|
+
for key, value in params.items():
|
|
68
|
+
setattr(self, key, value)
|
|
69
|
+
|
|
70
|
+
# ------------------------------------------------------------ lifecycle
|
|
71
|
+
|
|
72
|
+
def on_init(self) -> None:
|
|
73
|
+
"""Called once before the first render. May be ``async``: the part
|
|
74
|
+
before the first ``await`` is in the first render, and the component
|
|
75
|
+
renders again when it finishes."""
|
|
76
|
+
|
|
77
|
+
def on_after_render(self, first: bool) -> None:
|
|
78
|
+
"""Called after each render has been applied. May be ``async``."""
|
|
79
|
+
|
|
80
|
+
def on_dispose(self) -> None:
|
|
81
|
+
"""Called when the component is torn down. Must not be ``async``:
|
|
82
|
+
the component's tasks are cancelled just before this runs."""
|
|
83
|
+
|
|
84
|
+
def start_task(self, awaitable: Any) -> Any:
|
|
85
|
+
"""Run a coroutine (or other awaitable) as a task this component owns,
|
|
86
|
+
and return the ``asyncio.Task``.
|
|
87
|
+
|
|
88
|
+
The component re-renders when the task finishes and the task is
|
|
89
|
+
cancelled if the component is removed first. Call
|
|
90
|
+
``state_has_changed()`` inside it to show progress before the end.
|
|
91
|
+
Works once the component is rendered, from ``on_init`` onwards.
|
|
92
|
+
"""
|
|
93
|
+
start = self.__dict__.get("_pw_start")
|
|
94
|
+
if start is None:
|
|
95
|
+
if hasattr(awaitable, "close"):
|
|
96
|
+
awaitable.close() # no "never awaited" warning on top
|
|
97
|
+
raise RuntimeError(
|
|
98
|
+
f"{type(self).__name__}.start_task() needs the component to be "
|
|
99
|
+
"rendered (call it from on_init or later, not __init__), and "
|
|
100
|
+
"not yet removed"
|
|
101
|
+
)
|
|
102
|
+
return start(awaitable)
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------- focus
|
|
105
|
+
# Requests carried out by the host once the next render is on the page,
|
|
106
|
+
# so they can name an element that this very handler makes appear.
|
|
107
|
+
|
|
108
|
+
def focus(self, ref: str) -> None:
|
|
109
|
+
"""Move keyboard focus to the element marked ``ref="..."`` in this
|
|
110
|
+
component's template. On a component tag, focus goes to the first
|
|
111
|
+
thing inside it that can take focus."""
|
|
112
|
+
self._pw_request("focus", ref)
|
|
113
|
+
|
|
114
|
+
def focus_first(self, ref: str) -> None:
|
|
115
|
+
"""Focus the first focusable element inside ``ref``."""
|
|
116
|
+
self._pw_request("focus_first", ref)
|
|
117
|
+
|
|
118
|
+
def focus_last(self, ref: str) -> None:
|
|
119
|
+
"""Focus the last focusable element inside ``ref``."""
|
|
120
|
+
self._pw_request("focus_last", ref)
|
|
121
|
+
|
|
122
|
+
def scroll_to(self, ref: str) -> None:
|
|
123
|
+
"""Scroll the page so the element marked ``ref`` is in view."""
|
|
124
|
+
self._pw_request("scroll_to", ref)
|
|
125
|
+
|
|
126
|
+
def save_focus(self) -> None:
|
|
127
|
+
"""Remember what has focus now, for ``restore_focus()``: a popup
|
|
128
|
+
saves it as it opens and gives it back as it closes."""
|
|
129
|
+
self._pw_request("save_focus")
|
|
130
|
+
|
|
131
|
+
def restore_focus(self) -> None:
|
|
132
|
+
"""Focus what the matching ``save_focus()`` remembered, if it is
|
|
133
|
+
still on the page."""
|
|
134
|
+
self._pw_request("restore_focus")
|
|
135
|
+
|
|
136
|
+
def _pw_request(self, op: str, ref: str | None = None) -> None:
|
|
137
|
+
command = self.__dict__.get("_pw_command")
|
|
138
|
+
if command is None:
|
|
139
|
+
raise RuntimeError(
|
|
140
|
+
f"{type(self).__name__}.{op}() needs the component to be rendered "
|
|
141
|
+
"(call it from on_init or later), and not yet removed"
|
|
142
|
+
)
|
|
143
|
+
command(op, ref)
|
|
144
|
+
# Even when nothing else changed, the host must render and settle to
|
|
145
|
+
# reach the point where it carries requests out.
|
|
146
|
+
self.state_has_changed()
|
|
147
|
+
|
|
148
|
+
# ---------------------------------------------------------------- state
|
|
149
|
+
|
|
150
|
+
def state_has_changed(self) -> None:
|
|
151
|
+
self._dirty = True
|
|
152
|
+
if self._on_change is not None:
|
|
153
|
+
self._on_change(self)
|
|
154
|
+
# Set by the Renderer that owns this instance; how a change made
|
|
155
|
+
# outside a handler (timer, JS callback, asyncio task) reaches the host.
|
|
156
|
+
invalidate = self.__dict__.get("_pw_invalidate")
|
|
157
|
+
if invalidate is not None:
|
|
158
|
+
invalidate()
|
|
159
|
+
|
|
160
|
+
def has_slot(self, name: str = "default") -> bool:
|
|
161
|
+
"""Whether the parent passed content for this slot, for wrappers that
|
|
162
|
+
should only render around it: ``<If cond="has_slot('footer')">``."""
|
|
163
|
+
return name in self.__dict__.get("_pw_slots", {})
|
|
164
|
+
|
|
165
|
+
def emit(self, event: str, payload: Any = None) -> Any:
|
|
166
|
+
"""Invoke the parent's ``@event`` callback, Vue-style.
|
|
167
|
+
|
|
168
|
+
``<Child @remove="drop(item)" />`` in a parent template arrives here as
|
|
169
|
+
an ``on_remove`` callable prop; ``self.emit("remove")`` in the child
|
|
170
|
+
calls it. Emitting an event nobody listens to is a no-op, as in Vue.
|
|
171
|
+
"""
|
|
172
|
+
callback = getattr(self, f"on_{event}", None)
|
|
173
|
+
if callable(callback):
|
|
174
|
+
return callback(payload)
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def compile_code_block(src: str, filename: str) -> Any:
|
|
179
|
+
"""The ``<code>`` block of a ``.weft`` source, compiled so that line
|
|
180
|
+
numbers in tracebacks are lines of the file, not of the block.
|
|
181
|
+
(split_source's own output stays unpadded.)"""
|
|
182
|
+
_, code_src, _ = split_source(src)
|
|
183
|
+
span = block_spans(src).get("code")
|
|
184
|
+
if span:
|
|
185
|
+
code_src = "\n" * src.count("\n", 0, span[0]) + code_src
|
|
186
|
+
return compile(textwrap.dedent(code_src), filename, "exec")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _imported_helper(value: Any) -> bool:
|
|
190
|
+
"""A function imported into a code block (``from x import toast``), to be
|
|
191
|
+
called as itself: as a class attribute it would otherwise become a
|
|
192
|
+
method, and ``toast("Saved")`` in a template would get the component as
|
|
193
|
+
its message. Functions the code block defines have no ``__module__``
|
|
194
|
+
(the exec namespace has no ``__name__``); an imported one whose first
|
|
195
|
+
parameter is ``self`` is meant as a method and stays one."""
|
|
196
|
+
if not isinstance(value, types.FunctionType) or value.__module__ is None:
|
|
197
|
+
return False
|
|
198
|
+
names = value.__code__.co_varnames[: value.__code__.co_argcount]
|
|
199
|
+
return not names or names[0] != "self"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def component_from_source(
|
|
203
|
+
src: str,
|
|
204
|
+
name: str = "Component",
|
|
205
|
+
base: type = Component,
|
|
206
|
+
components: dict[str, type] | None = None,
|
|
207
|
+
scope: str | None = None,
|
|
208
|
+
filename: str | None = None,
|
|
209
|
+
) -> type:
|
|
210
|
+
"""``scope`` is the style scope id; it defaults to the snake-cased ``name``
|
|
211
|
+
and only matters when the source has a ``<style>`` block. ``filename`` is
|
|
212
|
+
what tracebacks show for errors in the code block."""
|
|
213
|
+
template_src, _, style_src = split_source(src)
|
|
214
|
+
nodes = parse_template(template_src)
|
|
215
|
+
|
|
216
|
+
namespace: dict[str, Any] = {}
|
|
217
|
+
exec(compile_code_block(src, filename or f"<{name}.weft>"), namespace)
|
|
218
|
+
namespace.pop("__builtins__", None)
|
|
219
|
+
for key, value in namespace.items():
|
|
220
|
+
if _imported_helper(value):
|
|
221
|
+
namespace[key] = staticmethod(value)
|
|
222
|
+
namespace["__template__"] = nodes
|
|
223
|
+
if style_src.strip():
|
|
224
|
+
scope = scope or _snake(name)
|
|
225
|
+
namespace["__scope__"] = scope
|
|
226
|
+
namespace["__style__"] = scope_css(style_src, scope).strip()
|
|
227
|
+
else:
|
|
228
|
+
namespace["__scope__"] = None
|
|
229
|
+
namespace["__style__"] = ""
|
|
230
|
+
# A `components` dict in the code block wins over the passed-in registry.
|
|
231
|
+
namespace["components"] = {**(components or {}), **namespace.get("components", {})}
|
|
232
|
+
|
|
233
|
+
return type(name, (base,), namespace)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
_CONTROL_TAGS = {"If", "ElseIf", "Else", "For", "Slot", "Fill", "Dynamic"}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def is_component_tag(tag: str) -> bool:
|
|
240
|
+
"""``TodoItem`` and ``widgets.Row`` are components; ``div`` is not. The
|
|
241
|
+
last dotted segment decides, so folder names can stay lowercase."""
|
|
242
|
+
return tag.rsplit(".", 1)[-1][:1].isupper() and tag not in _CONTROL_TAGS
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def component_named(cls: type, tag: str) -> type | None:
|
|
246
|
+
"""The component a tag names through the code block's own names:
|
|
247
|
+
``from pyweft_ui import ui`` makes ``<ui.Button>`` resolve, and
|
|
248
|
+
``from widgets import Row`` makes ``<Row>`` resolve."""
|
|
249
|
+
target: Any = cls
|
|
250
|
+
for part in tag.split("."):
|
|
251
|
+
target = getattr(target, part, None)
|
|
252
|
+
if target is None:
|
|
253
|
+
return None
|
|
254
|
+
if isinstance(target, type) and issubclass(target, Component):
|
|
255
|
+
return target
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# Not props even though they live on the class: imports, nested classes,
|
|
260
|
+
# methods, computed values, and shared stores (imported, not passed in).
|
|
261
|
+
_NOT_PROPS = (types.ModuleType, type, property, staticmethod, classmethod, Store)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@functools.lru_cache(maxsize=1024)
|
|
265
|
+
def declared_props(cls: type) -> frozenset[str]:
|
|
266
|
+
"""Names a parent may set on this component: its fields (annotated or
|
|
267
|
+
given a default in the code block), including ``on_<event>`` callbacks.
|
|
268
|
+
Any other attribute written on its tag goes to its root element."""
|
|
269
|
+
names: set[str] = set()
|
|
270
|
+
for klass in cls.__mro__:
|
|
271
|
+
if klass is Component or klass is object:
|
|
272
|
+
continue
|
|
273
|
+
try:
|
|
274
|
+
names.update(inspect.get_annotations(klass))
|
|
275
|
+
except Exception:
|
|
276
|
+
pass
|
|
277
|
+
for name, value in vars(klass).items():
|
|
278
|
+
if callable(value) or isinstance(value, _NOT_PROPS):
|
|
279
|
+
continue
|
|
280
|
+
names.add(name)
|
|
281
|
+
return frozenset(n for n in names if not n.startswith("_") and n != "components")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _component_tags(nodes: list[Any], found: set[str]) -> set[str]:
|
|
285
|
+
for node in nodes:
|
|
286
|
+
if isinstance(node, TElement):
|
|
287
|
+
if is_component_tag(node.tag):
|
|
288
|
+
found.add(node.tag)
|
|
289
|
+
_component_tags(node.children, found)
|
|
290
|
+
elif isinstance(node, TIf):
|
|
291
|
+
for _, children in node.branches:
|
|
292
|
+
_component_tags(children, found)
|
|
293
|
+
elif isinstance(node, (TFor, TSlot, TFill)):
|
|
294
|
+
_component_tags(node.children, found)
|
|
295
|
+
return found
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _snake(name: str) -> str:
|
|
299
|
+
return re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@dataclass
|
|
303
|
+
class _LoadContext:
|
|
304
|
+
root: Path # the app root: where dotted tags resolve from
|
|
305
|
+
loading: set[Path] # files being loaded right now, for cycle detection
|
|
306
|
+
current_dir: Path # folder of the file whose code block is executing
|
|
307
|
+
# Files already loaded in this load, so a file used by several components
|
|
308
|
+
# becomes one class: compiled once, and an unambiguous target when hot
|
|
309
|
+
# reload moves live instances onto freshly compiled classes.
|
|
310
|
+
loaded: dict[Path, type]
|
|
311
|
+
# Prepended to style scope ids, so a library's button.weft and an app's
|
|
312
|
+
# button.weft cannot share styles.
|
|
313
|
+
scope_prefix: str | None = None
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# Set while a .weft file is being loaded, so a load_component() call inside
|
|
317
|
+
# its code block resolves relative paths against that file (not the process
|
|
318
|
+
# cwd, which in the browser is not the app folder) and shares the same root
|
|
319
|
+
# and cycle detection.
|
|
320
|
+
_LOAD: ContextVar[_LoadContext | None] = ContextVar("pyweft_load", default=None)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _scope_id(path: Path, root: Path) -> str:
|
|
324
|
+
"""Style scope id: the path below the app root with folders joined by
|
|
325
|
+
``-`` (``widgets/row.weft`` -> ``widgets-row``), so equal file names in
|
|
326
|
+
different folders cannot share styles. Top-level files keep their stem."""
|
|
327
|
+
try:
|
|
328
|
+
rel = path.relative_to(root)
|
|
329
|
+
except ValueError:
|
|
330
|
+
return path.stem # loaded from outside the app root
|
|
331
|
+
return "-".join(rel.with_suffix("").parts)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _find_component(tag: str, referrer: Path, root: Path) -> Path:
|
|
335
|
+
*folders, leaf = tag.split(".")
|
|
336
|
+
for folder in folders:
|
|
337
|
+
if not folder.isidentifier():
|
|
338
|
+
raise TemplateError(
|
|
339
|
+
f"<{tag}> in {referrer.name}: {folder!r} is not a valid folder "
|
|
340
|
+
"name for a component tag (use letters, digits and underscores)"
|
|
341
|
+
)
|
|
342
|
+
base = root.joinpath(*folders) if folders else referrer.parent
|
|
343
|
+
candidates = (base / f"{leaf}.weft", base / f"{_snake(leaf)}.weft")
|
|
344
|
+
for candidate in candidates:
|
|
345
|
+
if candidate.is_file():
|
|
346
|
+
return candidate
|
|
347
|
+
where = (
|
|
348
|
+
f"in {'/'.join(folders)}/ under the app root"
|
|
349
|
+
if folders
|
|
350
|
+
else f"alongside {referrer.name}"
|
|
351
|
+
)
|
|
352
|
+
raise TemplateError(
|
|
353
|
+
f"component <{tag}> used in {referrer.name} but no {candidates[0].name} "
|
|
354
|
+
f"or {candidates[1].name} found {where}"
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def load_component(
|
|
359
|
+
path: str | Path,
|
|
360
|
+
name: str | None = None,
|
|
361
|
+
*,
|
|
362
|
+
root: str | Path | None = None,
|
|
363
|
+
scope_prefix: str | None = None,
|
|
364
|
+
) -> type:
|
|
365
|
+
"""Load a ``.weft`` file and, recursively, the components it uses.
|
|
366
|
+
|
|
367
|
+
A capitalised tag is looked up, in order:
|
|
368
|
+
|
|
369
|
+
* in the code block's explicit ``components`` dict
|
|
370
|
+
* through the code block's own names: after ``from pyweft_ui import ui``,
|
|
371
|
+
``<ui.Button>`` is ``ui.Button``
|
|
372
|
+
* on disk: ``<TodoItem>`` is ``TodoItem.weft`` or ``todo_item.weft`` next
|
|
373
|
+
to the file that uses it; ``<widgets.Row>`` is ``widgets/Row.weft`` or
|
|
374
|
+
``widgets/row.weft`` under the app root
|
|
375
|
+
|
|
376
|
+
``root`` defaults to the folder of the first file loaded.
|
|
377
|
+
"""
|
|
378
|
+
ctx = _LOAD.get()
|
|
379
|
+
p = Path(path)
|
|
380
|
+
if not p.is_absolute() and ctx is not None:
|
|
381
|
+
p = ctx.current_dir / p
|
|
382
|
+
p = p.resolve()
|
|
383
|
+
if root is not None:
|
|
384
|
+
root_dir = Path(root).resolve()
|
|
385
|
+
elif ctx is not None:
|
|
386
|
+
root_dir = ctx.root
|
|
387
|
+
scope_prefix = scope_prefix or ctx.scope_prefix
|
|
388
|
+
else:
|
|
389
|
+
root_dir = p.parent
|
|
390
|
+
loading = ctx.loading if ctx is not None else set()
|
|
391
|
+
loaded = ctx.loaded if ctx is not None else {}
|
|
392
|
+
|
|
393
|
+
if p in loaded:
|
|
394
|
+
return loaded[p]
|
|
395
|
+
if p in loading:
|
|
396
|
+
raise TemplateError(f"circular component reference involving {p.name}")
|
|
397
|
+
loading.add(p)
|
|
398
|
+
token = _LOAD.set(_LoadContext(root_dir, loading, p.parent, loaded, scope_prefix))
|
|
399
|
+
try:
|
|
400
|
+
text = p.read_text(encoding="utf-8")
|
|
401
|
+
scope = _scope_id(p, root_dir)
|
|
402
|
+
if scope_prefix:
|
|
403
|
+
scope = f"{scope_prefix}-{scope}"
|
|
404
|
+
try:
|
|
405
|
+
cls = component_from_source(
|
|
406
|
+
text, name or p.stem, scope=scope, filename=str(p)
|
|
407
|
+
)
|
|
408
|
+
except TemplateError as exc:
|
|
409
|
+
try:
|
|
410
|
+
where = p.relative_to(root_dir).as_posix()
|
|
411
|
+
except ValueError:
|
|
412
|
+
where = p.name
|
|
413
|
+
raise TemplateError(f"{where}: {exc}") from exc
|
|
414
|
+
cls.__weft_file__ = str(p)
|
|
415
|
+
cls.__weft_src__ = text
|
|
416
|
+
|
|
417
|
+
registry = dict(cls.components)
|
|
418
|
+
for tag in sorted(_component_tags(cls.__template__, set())):
|
|
419
|
+
if tag not in registry:
|
|
420
|
+
imported = component_named(cls, tag)
|
|
421
|
+
if imported is not None:
|
|
422
|
+
registry[tag] = imported
|
|
423
|
+
continue
|
|
424
|
+
found = _find_component(tag, p, root_dir)
|
|
425
|
+
registry[tag] = load_component(found, tag.rsplit(".", 1)[-1])
|
|
426
|
+
cls.components = registry
|
|
427
|
+
loaded[p] = cls
|
|
428
|
+
return cls
|
|
429
|
+
finally:
|
|
430
|
+
_LOAD.reset(token)
|
|
431
|
+
loading.discard(p)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
class ComponentLibrary:
|
|
435
|
+
"""A folder of ``.weft`` components, as attributes. This is how a package
|
|
436
|
+
ships components::
|
|
437
|
+
|
|
438
|
+
# my_widgets/__init__.py
|
|
439
|
+
from pathlib import Path
|
|
440
|
+
from pyweft import ComponentLibrary
|
|
441
|
+
|
|
442
|
+
ui = ComponentLibrary(Path(__file__).parent, prefix="ui")
|
|
443
|
+
|
|
444
|
+
and how an app uses them: ``from my_widgets import ui`` in its code
|
|
445
|
+
block, then ``<ui.Button>``, ``<ui.Card>`` in its template.
|
|
446
|
+
|
|
447
|
+
``ui.Button`` loads ``Button.weft`` or ``button.weft`` on first use.
|
|
448
|
+
Inside the folder, components refer to each other as usual (siblings by
|
|
449
|
+
name, subfolders by dotted tags from the folder). ``prefix`` keeps the
|
|
450
|
+
library's scoped styles apart from the app's.
|
|
451
|
+
"""
|
|
452
|
+
|
|
453
|
+
def __init__(self, folder: str | Path, prefix: str | None = None):
|
|
454
|
+
self._folder = Path(folder).resolve()
|
|
455
|
+
self._prefix = prefix or self._folder.name
|
|
456
|
+
|
|
457
|
+
def __getattr__(self, name: str) -> type:
|
|
458
|
+
if name.startswith("_") or not name[:1].isupper():
|
|
459
|
+
raise AttributeError(name)
|
|
460
|
+
for candidate in (self._folder / f"{name}.weft", self._folder / f"{_snake(name)}.weft"):
|
|
461
|
+
if candidate.is_file():
|
|
462
|
+
break
|
|
463
|
+
else:
|
|
464
|
+
raise AttributeError(
|
|
465
|
+
f"{self._prefix} has no component {name} (no {name}.weft or "
|
|
466
|
+
f"{_snake(name)}.weft in {self._folder})"
|
|
467
|
+
)
|
|
468
|
+
# A load of its own: the library's root, prefix and cache must not
|
|
469
|
+
# mix with those of the app that happens to be loading it.
|
|
470
|
+
token = _LOAD.set(None)
|
|
471
|
+
try:
|
|
472
|
+
cls = load_component(candidate, name, root=self._folder, scope_prefix=self._prefix)
|
|
473
|
+
finally:
|
|
474
|
+
_LOAD.reset(token)
|
|
475
|
+
setattr(self, name, cls) # loaded once; __getattr__ is not asked again
|
|
476
|
+
return cls
|
|
477
|
+
|
|
478
|
+
def __dir__(self) -> list[str]:
|
|
479
|
+
"""Component names, for completion: text_field.weft -> TextField."""
|
|
480
|
+
stems = (p.stem for p in self._folder.glob("*.weft"))
|
|
481
|
+
return sorted(
|
|
482
|
+
s if s[:1].isupper() else "".join(w.capitalize() for w in s.split("_"))
|
|
483
|
+
for s in stems
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
def __repr__(self) -> str:
|
|
487
|
+
return f"<ComponentLibrary {self._prefix} at {self._folder}>"
|