Coverage for src/minihtml/_component.py: 98%
100 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-04-06 18:07 +0200
« prev ^ index » next coverage.py v7.6.12, created at 2025-04-06 18:07 +0200
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 """
40 Object to interact with slots from the inside of a component.
41 """
43 def __init__(self, slots: Sequence[str], default: str | None):
44 self._slots: dict[str, list[Node | HasNodes]] = {slot: [] for slot in slots}
45 self._default = default or ""
46 if not slots:
47 self._slots[self._default] = []
49 def add_content(self, slot: str | None, content: list[Node | HasNodes]) -> None:
50 slot = slot or self._default
51 self._slots[slot].extend(content)
53 def slot(self, slot: str | None = None) -> SlotContext:
54 """
55 Insert the contents of a given slot.
57 Args:
58 slot: The slot name, or `None` to refer to the default slot.
60 When used as a context manager, nodes created within the context will
61 be inserted if the slot has not been filled (default content).
62 """
63 for obj in self._slots[slot or self._default]:
64 register_with_context(obj)
65 return SlotContext(capture=self.is_filled(slot))
67 def is_filled(self, slot: str | None = None) -> bool:
68 """
69 Returns whether or not the slot has been filled.
71 Args:
72 slot: The slot name, or `None` to refer to the default slot.
73 """
74 return bool(self._slots[slot or self._default])
77P = ParamSpec("P")
79ComponentImpl: TypeAlias = Callable[Concatenate[Slots, P], Node | HasNodes]
82class Component:
83 """
84 An instance of a component.
86 To fill the default slot, use the component as a context manager.
88 To fill a slot by name, use the :meth:`slot` method.
89 """
91 def __init__(self, callback: Callable[[Slots], Node | HasNodes], slots: Slots):
92 self._callback = callback
93 self._slots = slots
94 self._cached_nodes: list[Node] | None = None
96 def __enter__(self) -> Self:
97 self._capture = ElementNonEmpty("__capture__")
98 push_element_context(self._capture)
99 return self
101 def __exit__(self, *exc_info: object) -> None:
102 parent, content = pop_element_context()
103 assert parent is self._capture
104 if content:
105 self._slots.add_content(None, content)
107 @contextmanager
108 def slot(self, slot: str | None = None) -> Iterator[None]:
109 """
110 Args:
111 slot: The slot name, or `None` to refer to the default slot.
113 Context manager to add content to a slot. Nodes created within the
114 context will be added to the slot.
115 """
116 capture = ElementNonEmpty("__capture__")
117 push_element_context(capture)
118 try:
119 yield
120 finally:
121 parent, content = pop_element_context()
122 assert parent is capture
123 self._slots.add_content(slot, content)
125 def get_nodes(self) -> Iterable[Node]:
126 if self._cached_nodes is None:
127 # Ensure elements created by self._callback are not registered with the currently
128 # active context.
129 capture = ElementNonEmpty("__capture__")
130 push_element_context(capture)
131 result = self._callback(self._slots)
132 parent, _ = pop_element_context()
133 assert parent is capture
134 self._cached_nodes = list(iter_nodes([result]))
135 return self._cached_nodes
137 def __str__(self) -> str:
138 buf = io.StringIO()
139 Node.render_list(buf, self.get_nodes())
140 return buf.getvalue()
143class ComponentWrapper(Generic[P]):
144 """
145 Wraps a component implementation.
147 Decorating a function with the :deco:`component` decorator replaces the
148 function with a `ComponentWrapper`.
149 """
151 def __init__(
152 self,
153 impl: ComponentImpl[P],
154 slots: Sequence[str],
155 default: str | None,
156 styles: Sequence[Node] | None = None,
157 scripts: Sequence[Node] | None = None,
158 ):
159 self._impl = impl
160 self._slots = slots
161 self._default = default
162 self._styles = styles
163 self._scripts = scripts
165 def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Component:
166 callback: Callable[[Slots], Node | HasNodes] = lambda slots: self._impl(
167 slots, *args, **kwargs
168 )
169 component = Component(callback, slots=Slots(self._slots, default=self._default))
170 register_with_context(component)
171 if self._styles:
172 register_template_styles(self._styles)
173 if self._scripts:
174 register_template_scripts(self._scripts)
175 return component
178def component(
179 slots: Sequence[str] | None = None,
180 default: str | None = None,
181 style: Node | Sequence[Node] | None = None,
182 script: Node | Sequence[Node] | None = None,
183) -> Callable[[ComponentImpl[P]], ComponentWrapper[P]]:
184 """
185 Decorator to create a component.
187 Args:
188 slots: A list of slot names. When omitted, the component will have one
189 default unnamed slot.
190 default: One of the names in `slots` that should be the default slot.
191 When `slots` is set but not `default`, the component's slots always
192 have to be referred to by name.
193 style: Associate one or more style nodes with the component.
194 script: Associate one or more script nodes with the component.
196 When called, the decorated function receives a :class:`Slots` object as its
197 first argument.
198 """
199 slots = slots or []
200 if default and not slots:
201 raise ValueError(f"Can't set default without slots: {default!r}")
202 elif default and default not in slots:
203 raise ValueError(
204 f"Invalid default: {default!r}. Available slots: {', '.join(repr(s) for s in slots)}"
205 )
207 styles = [style] if isinstance(style, Node) else style
208 scripts = [script] if isinstance(script, Node) else script
210 def decorator(fn: ComponentImpl[P]) -> ComponentWrapper[P]:
211 return ComponentWrapper(
212 fn, slots=slots, default=default, styles=styles, scripts=scripts
213 )
215 return decorator