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/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}>"