Coverage for src/minihtml/_component.py: 98%
100 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-02-22 21:44 +0100
« prev ^ index » next coverage.py v7.6.12, created at 2025-02-22 21:44 +0100
1import io
2import sys
3from collections.abc import Iterable, Iterator, Sequence
4from contextlib import contextmanager
5from typing import Callable, Concatenate, Generic, ParamSpec, TypeAlias
7if sys.version_info >= (3, 11): 7 ↛ 10line 7 didn't jump to line 10 because the condition on line 7 was always true
8 from typing import Self
9else:
10 from typing_extensions import Self
12from ._core import (
13 ElementNonEmpty,
14 HasNodes,
15 Node,
16 iter_nodes,
17 pop_element_context,
18 push_element_context,
19 register_with_context,
20)
21from ._template_context import register_template_scripts, register_template_styles
24class SlotContext:
25 def __init__(self, capture: bool):
26 self._capture = capture
28 def __enter__(self) -> None:
29 if self._capture:
30 capture = ElementNonEmpty("__capture__")
31 push_element_context(capture)
33 def __exit__(self, *exc_info: object) -> None:
34 if self._capture:
35 pop_element_context()
38class Slots:
39 def __init__(self, slots: Sequence[str], default: str | None):
40 self._slots: dict[str, list[Node | HasNodes]] = {slot: [] for slot in slots}
41 self._default = default or ""
42 if not slots:
43 self._slots[""] = []
45 def add_content(self, slot: str | None, content: list[Node | HasNodes]) -> None:
46 slot = slot or self._default
47 self._slots[slot].extend(content)
49 def slot(self, slot: str | None = None) -> SlotContext:
50 for obj in self._slots[slot or self._default]:
51 register_with_context(obj)
52 return SlotContext(capture=self.is_filled(slot))
54 def is_filled(self, slot: str | None = None) -> bool:
55 return bool(self._slots[slot or self._default])
58P = ParamSpec("P")
60ComponentImpl: TypeAlias = Callable[Concatenate[Slots, P], Node | HasNodes]
63class Component:
64 def __init__(self, callback: Callable[[Slots], Node | HasNodes], slots: Slots):
65 self._callback = callback
66 self._slots = slots
67 self._cached_nodes: list[Node] | None = None
69 def __enter__(self) -> Self:
70 self._capture = ElementNonEmpty("__capture__")
71 push_element_context(self._capture)
72 return self
74 def __exit__(self, *exc_info: object) -> None:
75 parent, children = pop_element_context()
76 assert parent is self._capture
77 if children:
78 self._slots.add_content("", children)
80 @contextmanager
81 def slot(self, slot: str = "") -> Iterator[None]:
82 capture = ElementNonEmpty("__capture__")
83 push_element_context(capture)
84 try:
85 yield
86 finally:
87 parent, children = pop_element_context()
88 assert parent is capture
89 self._slots.add_content(slot, children)
91 def get_nodes(self) -> Iterable[Node]:
92 if self._cached_nodes is None:
93 # Ensure elements created by self._callback are not registered with the currently
94 # active context.
95 capture = ElementNonEmpty("__capture__")
96 push_element_context(capture)
97 result = self._callback(self._slots)
98 parent, _ = pop_element_context()
99 assert parent is capture
100 self._cached_nodes = list(iter_nodes([result]))
101 return self._cached_nodes
103 def __str__(self) -> str:
104 buf = io.StringIO()
105 Node.render_list(buf, self.get_nodes())
106 return buf.getvalue()
109class ComponentWrapper(Generic[P]):
110 def __init__(
111 self,
112 impl: ComponentImpl[P],
113 slots: Sequence[str],
114 default: str | None,
115 styles: Sequence[Node] | None = None,
116 scripts: Sequence[Node] | None = None,
117 ):
118 self._impl = impl
119 self._slots = slots
120 self._default = default
121 self._styles = styles
122 self._scripts = scripts
124 def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Component:
125 callback: Callable[[Slots], Node | HasNodes] = lambda slots: self._impl(
126 slots, *args, **kwargs
127 )
128 component = Component(callback, slots=Slots(self._slots, default=self._default))
129 register_with_context(component)
130 if self._styles:
131 register_template_styles(self._styles)
132 if self._scripts:
133 register_template_scripts(self._scripts)
134 return component
137def component(
138 slots: Sequence[str] | None = None,
139 default: str | None = None,
140 style: Node | Sequence[Node] | None = None,
141 script: Node | Sequence[Node] | None = None,
142) -> Callable[[ComponentImpl[P]], ComponentWrapper[P]]:
143 slots = slots or []
144 if default and not slots:
145 raise ValueError(f"Can't set default without slots: {default!r}")
146 elif default and default not in slots:
147 raise ValueError(
148 f"Invalid default: {default!r}. Available slots: {', '.join(repr(s) for s in slots)}"
149 )
151 styles = [style] if isinstance(style, Node) else style
152 scripts = [script] if isinstance(script, Node) else script
154 def decorator(fn: ComponentImpl[P]) -> ComponentWrapper[P]:
155 return ComponentWrapper(
156 fn, slots=slots, default=default, styles=styles, scripts=scripts
157 )
159 return decorator