Coverage for src/minihtml/_core.py: 99%
242 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 re
3import sys
4from collections.abc import Iterable, Iterator
5from contextvars import ContextVar
6from dataclasses import dataclass
7from html import escape
8from itertools import zip_longest
9from typing import Literal, Protocol, TextIO, overload
11if sys.version_info >= (3, 11): 11 ↛ 14line 11 didn't jump to line 14 because the condition on line 11 was always true
12 from typing import Self
13else:
14 from typing_extensions import Self
16# We also disallow '&', '<', ';'
17ATTRIBUTE_NAME_RE = re.compile(r"^[a-zA-Z0-9!#$%()*+,.:?@\[\]^_`{|}~-]+$")
20class CircularReferenceError(Exception):
21 """
22 Raised when a circular reference between elements is detected.
23 """
25 pass
28class Node:
29 """
30 Base class for text nodes and elements.
31 """
33 _inline: bool
35 def write(self, f: TextIO, indent: int = 0) -> None:
36 raise NotImplementedError
38 def __str__(self) -> str:
39 buffer = io.StringIO()
40 self.write(buffer)
41 return buffer.getvalue()
43 @staticmethod
44 def render_list(f: TextIO, nodes: Iterable["Node"]) -> None:
45 node_list = list(nodes)
46 for node, next_ in zip_longest(node_list, node_list[1:]):
47 node.write(f)
48 if next_ is not None:
49 if node._inline != next_._inline or not (node._inline or next_._inline):
50 f.write("\n")
53class HasNodes(Protocol):
54 def get_nodes(self) -> Iterable[Node]: ... # pragma: no cover
57def iter_nodes(objects: Iterable[Node | HasNodes | str]) -> Iterator[Node]:
58 for obj in objects:
59 match obj:
60 case str(s):
61 yield Text(s)
62 case Node():
63 yield obj
64 case _:
65 for node in obj.get_nodes():
66 yield node
69class Text(Node):
70 """
71 A text node.
73 Use the :func:`text` and :func:`safe` functions to create text nodes.
74 """
76 def __init__(self, s: str, escape: bool = True):
77 self._text = s
78 self._inline = True
79 self._escape = escape
81 def write(self, f: TextIO, indent: int = 0) -> None:
82 if self._escape:
83 f.write(escape(self._text, quote=False))
84 else:
85 f.write(self._text)
88def text(s: str) -> Text:
89 """
90 Create a text node.
92 When called inside an element context, adds text content to the parent
93 element.
94 """
95 node = Text(s)
96 register_with_context(node)
97 return node
100def safe(s: str) -> Text:
101 """
102 Create a text node with HTML escaping disabled.
104 When called inside an element context, adds text content to the parent
105 element.
106 """
107 node = Text(s, escape=False)
108 register_with_context(node)
109 return node
112def _format_attrs(attrs: dict[str, str]) -> str:
113 return " ".join(f'{k}="{escape(v, quote=True)}"' for k, v in attrs.items())
116class Element(Node):
117 """
118 Base class for elements.
119 """
121 _tag: str
122 _attrs: dict[str, str]
124 def __getitem__(self, key: str) -> Self:
125 class_names: list[str] = []
126 for name in key.split():
127 if name[0] == "#":
128 self._attrs["id"] = name[1:]
129 else:
130 class_names.append(name)
131 if class_names:
132 old_names = self._attrs.get("class", "").split()
133 self._attrs["class"] = " ".join(old_names + class_names)
134 return self
136 def __repr__(self) -> str:
137 return f"<{type(self).__name__} {self._tag}>"
140class ElementEmpty(Element):
141 """
142 An empty element.
143 """
145 def __init__(self, tag: str, *, inline: bool = False, omit_end_tag: bool):
146 self._tag = tag
147 self._inline = inline
148 self._omit_end_tag = omit_end_tag
149 self._attrs: dict[str, str] = {}
151 def __call__(self, **attrs: str | bool) -> Self:
152 for name, value in attrs.items():
153 name = name if name == "_" else name.rstrip("_").replace("_", "-")
154 if not ATTRIBUTE_NAME_RE.fullmatch(name):
155 raise ValueError(f"Invalid attribute name: {name!r}")
156 if value is True:
157 self._attrs[name] = name
158 elif value is not False:
159 self._attrs[name] = value
161 return self
163 def write(self, f: TextIO, indent: int = 0) -> None:
164 attrs = f" {_format_attrs(self._attrs)}" if self._attrs else ""
165 if self._omit_end_tag:
166 f.write(f"<{self._tag}{attrs}>")
167 else:
168 f.write(f"<{self._tag}{attrs}></{self._tag}>")
171class ElementNonEmpty(Element):
172 """
173 An element that can have content.
174 """
176 def __init__(self, tag: str, *, inline: bool = False):
177 self._tag = tag
178 self._attrs: dict[str, str] = {}
179 self._children: list[Node] = []
180 self._inline = inline
182 def __call__(self, *content: Node | HasNodes | str, **attrs: str | bool) -> Self:
183 for name, value in attrs.items():
184 name = name if name == "_" else name.rstrip("_").replace("_", "-")
185 if not ATTRIBUTE_NAME_RE.fullmatch(name):
186 raise ValueError(f"Invalid attribute name: {name!r}")
187 if value is True:
188 self._attrs[name] = name
189 elif value is not False:
190 self._attrs[name] = value
192 for obj in content:
193 if not isinstance(obj, str):
194 deregister_from_context(obj)
195 self._children.extend(iter_nodes(content))
197 return self
199 def __enter__(self) -> Self:
200 push_element_context(self)
201 return self
203 def __exit__(self, *exc_info: object) -> None:
204 parent, content = pop_element_context()
205 assert parent is self
206 parent(*content)
208 def write(self, f: TextIO, indent: int = 0) -> None:
209 ids_seen = _rendering_context.get(None)
210 if ids_seen is not None:
211 if id(self) in ids_seen:
212 raise CircularReferenceError
213 ids_seen.add(id(self))
214 else:
215 ids_seen = {id(self)}
216 _rendering_context.set(ids_seen)
218 try:
219 inline_mode = self._inline or all([c._inline for c in self._children])
220 first_child_is_block = self._children and not self._children[0]._inline
221 indent_next_child = not inline_mode or first_child_is_block
223 attrs = f" {_format_attrs(self._attrs)}" if self._attrs else ""
224 f.write(f"<{self._tag}{attrs}>")
225 for node in self._children:
226 if indent_next_child or not node._inline:
227 f.write(f"\n{' ' * (indent + 1)}")
228 node.write(f, indent + 1)
229 indent_next_child = not node._inline
231 if self._children and (indent_next_child or not inline_mode):
232 f.write(f"\n{' ' * indent}")
234 f.write(f"</{self._tag}>")
235 finally:
236 ids_seen.remove(id(self))
239@dataclass(slots=True)
240class ElementContext:
241 parent: ElementNonEmpty
242 collected_content: list[Node | HasNodes]
243 registered_content: set[Node | HasNodes]
246_context_stack = ContextVar[list[ElementContext]]("context_stack")
247_rendering_context = ContextVar[set[int]]("rendering_context")
250def push_element_context(parent: ElementNonEmpty) -> None:
251 ctx = ElementContext(parent=parent, collected_content=[], registered_content=set())
252 if stack := _context_stack.get(None):
253 stack.append(ctx)
254 else:
255 _context_stack.set([ctx])
258def pop_element_context() -> tuple[ElementNonEmpty, list[Node | HasNodes]]:
259 ctx = _context_stack.get().pop()
260 return ctx.parent, [
261 obj for obj in ctx.collected_content if obj in ctx.registered_content
262 ]
265def register_with_context(obj: Node | HasNodes) -> None:
266 if stack := _context_stack.get(None):
267 ctx = stack[-1]
268 if obj not in ctx.registered_content: 268 ↛ exitline 268 didn't return from function 'register_with_context' because the condition on line 268 was always true
269 ctx.registered_content.add(obj)
270 ctx.collected_content.append(obj)
273def deregister_from_context(obj: Node | HasNodes) -> None:
274 if stack := _context_stack.get(None):
275 ctx = stack[-1]
276 ctx.registered_content.discard(obj)
279class Fragment:
280 """
281 A collection of nodes without a parent element.
283 Use the :func:`fragment` function to create fragments.
284 """
286 def __init__(self, *content: Node | HasNodes | str):
287 self._content = list(content)
288 for obj in content:
289 if not isinstance(obj, str):
290 deregister_from_context(obj)
292 def get_nodes(self) -> Iterable[Node]:
293 return iter_nodes(self._content)
295 def __enter__(self) -> Self:
296 self._capture = ElementNonEmpty("__capture__")
297 push_element_context(self._capture)
298 return self
300 def __exit__(self, *exc_info: object) -> None:
301 parent, content = pop_element_context()
302 assert parent is self._capture
303 self._content.extend(content)
305 def __str__(self) -> str:
306 buf = io.StringIO()
307 Node.render_list(buf, self.get_nodes())
308 return buf.getvalue()
311def fragment(*content: Node | HasNodes | str) -> Fragment:
312 """
313 Create a fragment.
315 A fragment is a collection of nodes without a parent element.
317 When called inside an element context, adds the fragment contents to the
318 parent element.
319 """
320 f = Fragment(*content)
321 register_with_context(f)
322 return f
325class Prototype:
326 """
327 Base class for element prototypes.
328 """
330 _tag: str
332 def _get_repr(self, **flags: bool) -> str:
333 flag_names = [k for k, v in flags.items() if v]
334 flag_str = f" ({', '.join(flag_names)})" if flag_names else ""
335 return f"<{type(self).__name__} {self._tag}{flag_str}>"
338class PrototypeEmpty(Prototype):
339 """
340 A prototype for emtpy elements.
342 Use the :func:`make_prototype` function to create new prototypes.
343 """
345 def __init__(self, tag: str, *, inline: bool, omit_end_tag: bool):
346 self._tag = tag
347 self._inline = inline
348 self._omit_end_tag = omit_end_tag
350 def __call__(self, **attrs: str | bool) -> ElementEmpty:
351 elem = ElementEmpty(
352 self._tag, inline=self._inline, omit_end_tag=self._omit_end_tag
353 )(**attrs)
354 register_with_context(elem)
355 return elem
357 def __getitem__(self, key: str) -> ElementEmpty:
358 elem = ElementEmpty(
359 self._tag, inline=self._inline, omit_end_tag=self._omit_end_tag
360 )[key]
361 register_with_context(elem)
362 return elem
364 def __repr__(self) -> str:
365 return self._get_repr(inline=self._inline, omit_end_tag=self._omit_end_tag)
368class PrototypeNonEmpty(Prototype):
369 """
370 A prototype for elements that can have content.
372 Use the :func:`make_prototype` function to create new prototypes.
373 """
375 def __init__(self, tag: str, *, inline: bool):
376 self._tag = tag
377 self._inline = inline
379 def __call__(
380 self, *content: Node | HasNodes | str, **attrs: str | bool
381 ) -> ElementNonEmpty:
382 elem = ElementNonEmpty(self._tag, inline=self._inline)(*content, **attrs)
383 register_with_context(elem)
384 return elem
386 def __getitem__(self, key: str) -> ElementNonEmpty:
387 elem = ElementNonEmpty(self._tag, inline=self._inline)[key]
388 register_with_context(elem)
389 return elem
391 def __enter__(self) -> ElementNonEmpty:
392 elem = ElementNonEmpty(self._tag, inline=self._inline)
393 register_with_context(elem)
394 push_element_context(elem)
395 return elem
397 def __exit__(self, *exc_info: object) -> None:
398 parent, content = pop_element_context()
399 parent(*content)
401 def __repr__(self) -> str:
402 return self._get_repr(inline=self._inline)
405@overload
406def make_prototype(tag: str, *, inline: bool = ...) -> PrototypeNonEmpty: ...
409@overload
410def make_prototype(
411 tag: str, *, inline: bool = ..., empty: Literal[False]
412) -> PrototypeNonEmpty: ...
415@overload
416def make_prototype(
417 tag: str, *, inline: bool = ..., empty: Literal[True], omit_end_tag: bool = ...
418) -> PrototypeEmpty: ...
421def make_prototype(
422 tag: str, *, inline: bool = False, empty: bool = False, omit_end_tag: bool = False
423) -> PrototypeNonEmpty | PrototypeEmpty:
424 """
425 Factory function to create a new element prototype.
427 Args:
428 tag: The tag name.
429 inline: Whether or not the element is an inline (``True``) or block
430 element (``False``).
431 empty: Whether or not the element is allowed to have content.
432 omit_end_tag: When `empty=True`, whether or not the end tag should be
433 omitted when rendering.
435 Returns:
436 An element prototype.
437 """
438 if empty:
439 return PrototypeEmpty(tag, inline=inline, omit_end_tag=omit_end_tag)
440 return PrototypeNonEmpty(tag, inline=inline)