Coverage for src/minihtml/_core.py: 99%
228 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 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 pass
24class Node:
25 _inline: bool
27 def write(self, f: TextIO, indent: int = 0) -> None:
28 raise NotImplementedError
30 def __str__(self) -> str:
31 buffer = io.StringIO()
32 self.write(buffer)
33 return buffer.getvalue()
35 @staticmethod
36 def render_list(f: TextIO, nodes: Iterable["Node"]) -> None:
37 node_list = list(nodes)
38 for node, next_ in zip_longest(node_list, node_list[1:]):
39 node.write(f)
40 if next_ is not None:
41 if node._inline != next_._inline or not (node._inline or next_._inline):
42 f.write("\n")
45class HasNodes(Protocol):
46 def get_nodes(self) -> Iterable[Node]: ... # pragma: no cover
49def iter_nodes(objects: Iterable[Node | HasNodes | str]) -> Iterator[Node]:
50 for obj in objects:
51 match obj:
52 case str(s):
53 yield Text(s)
54 case Node():
55 yield obj
56 case _:
57 for node in obj.get_nodes():
58 yield node
61class Text(Node):
62 def __init__(self, s: str, escape: bool = True):
63 self._text = s
64 self._inline = True
65 self._escape = escape
67 def write(self, f: TextIO, indent: int = 0) -> None:
68 if self._escape:
69 f.write(escape(self._text, quote=False))
70 else:
71 f.write(self._text)
74def text(s: str) -> Text:
75 node = Text(s)
76 register_with_context(node)
77 return node
80def safe(s: str) -> Text:
81 node = Text(s, escape=False)
82 register_with_context(node)
83 return node
86def _format_attrs(attrs: dict[str, str]) -> str:
87 return " ".join(f'{k}="{escape(v, quote=True)}"' for k, v in attrs.items())
90class Element(Node):
91 _tag: str
92 _attrs: dict[str, str]
94 def __getitem__(self, key: str) -> Self:
95 class_names: list[str] = []
96 for name in key.split():
97 if name[0] == "#":
98 self._attrs["id"] = name[1:]
99 else:
100 class_names.append(name)
101 if class_names:
102 old_names = self._attrs.get("class", "").split()
103 self._attrs["class"] = " ".join(old_names + class_names)
104 return self
106 def __repr__(self) -> str:
107 return f"<{type(self).__name__} {self._tag}>"
110class ElementEmpty(Element):
111 def __init__(self, tag: str, *, inline: bool = False, omit_end_tag: bool):
112 self._tag = tag
113 self._inline = inline
114 self._omit_end_tag = omit_end_tag
115 self._attrs: dict[str, str] = {}
117 def __call__(self, **attrs: str | bool) -> Self:
118 for name, value in attrs.items():
119 name = name if name == "_" else name.rstrip("_").replace("_", "-")
120 if not ATTRIBUTE_NAME_RE.fullmatch(name):
121 raise ValueError(f"Invalid attribute name: {name!r}")
122 if value is True:
123 self._attrs[name] = name
124 elif value is not False:
125 self._attrs[name] = value
127 register_with_context(self)
128 return self
130 def write(self, f: TextIO, indent: int = 0) -> None:
131 attrs = f" {_format_attrs(self._attrs)}" if self._attrs else ""
132 if self._omit_end_tag:
133 f.write(f"<{self._tag}{attrs}>")
134 else:
135 f.write(f"<{self._tag}{attrs}></{self._tag}>")
138class ElementNonEmpty(Element):
139 def __init__(self, tag: str, *, inline: bool = False):
140 self._tag = tag
141 self._attrs: dict[str, str] = {}
142 self._children: list[Node] = []
143 self._inline = inline
145 def __call__(self, *children: Node | HasNodes | str, **attrs: str | bool) -> Self:
146 for name, value in attrs.items():
147 name = name if name == "_" else name.rstrip("_").replace("_", "-")
148 if not ATTRIBUTE_NAME_RE.fullmatch(name):
149 raise ValueError(f"Invalid attribute name: {name!r}")
150 if value is True:
151 self._attrs[name] = name
152 elif value is not False:
153 self._attrs[name] = value
155 child_nodes = list(iter_nodes(children))
156 for child in child_nodes:
157 deregister_from_context(child)
158 self._children.extend(child_nodes)
160 register_with_context(self)
161 return self
163 def __enter__(self) -> Self:
164 push_element_context(self)
165 return self
167 def __exit__(self, *exc_info: object) -> None:
168 parent, children = pop_element_context()
169 assert parent is self
170 parent(*children)
172 def write(self, f: TextIO, indent: int = 0) -> None:
173 ids_seen = _rendering_context.get(None)
174 if ids_seen is not None:
175 if id(self) in ids_seen:
176 raise CircularReferenceError
177 ids_seen.add(id(self))
178 else:
179 ids_seen = {
180 id(self),
181 }
182 _rendering_context.set(ids_seen)
184 try:
185 inline_mode = self._inline or all([c._inline for c in self._children])
186 first_child_is_block = self._children and not self._children[0]._inline
187 indent_next_child = not inline_mode or first_child_is_block
189 attrs = f" {_format_attrs(self._attrs)}" if self._attrs else ""
190 f.write(f"<{self._tag}{attrs}>")
191 for node in self._children:
192 if indent_next_child or not node._inline:
193 f.write(f"\n{' ' * (indent + 1)}")
194 node.write(f, indent + 1)
195 indent_next_child = not node._inline
197 if self._children and (indent_next_child or not inline_mode):
198 f.write(f"\n{' ' * indent}")
200 f.write(f"</{self._tag}>")
201 finally:
202 ids_seen.remove(id(self))
205@dataclass(slots=True)
206class ElementContext:
207 parent: ElementNonEmpty
208 collected_nodes: list[Node | HasNodes]
209 registered_nodes: set[Node | HasNodes]
212_context_stack = ContextVar[list[ElementContext]]("context_stack")
213_rendering_context = ContextVar[set[int]]("rendering_context")
216def push_element_context(parent: ElementNonEmpty) -> None:
217 ctx = ElementContext(parent=parent, collected_nodes=[], registered_nodes=set())
218 if stack := _context_stack.get(None):
219 stack.append(ctx)
220 else:
221 _context_stack.set([ctx])
224def pop_element_context() -> tuple[ElementNonEmpty, list[Node | HasNodes]]:
225 ctx = _context_stack.get().pop()
226 return ctx.parent, [
227 node for node in ctx.collected_nodes if node in ctx.registered_nodes
228 ]
231def register_with_context(node: Node | HasNodes) -> None:
232 if stack := _context_stack.get(None):
233 ctx = stack[-1]
234 if node not in ctx.registered_nodes:
235 ctx.registered_nodes.add(node)
236 ctx.collected_nodes.append(node)
239def deregister_from_context(node: Node | HasNodes) -> None:
240 if stack := _context_stack.get(None):
241 ctx = stack[-1]
242 ctx.registered_nodes.discard(node)
245class Fragment:
246 def __init__(self, *content: Node | HasNodes | str):
247 self._content = list(content)
249 def get_nodes(self) -> Iterable[Node]:
250 return iter_nodes(self._content)
252 def __enter__(self) -> Self:
253 self._capture = ElementNonEmpty("__capture__")
254 push_element_context(self._capture)
255 return self
257 def __exit__(self, *exc_info: object) -> None:
258 parent, children = pop_element_context()
259 assert parent is self._capture
260 self._content.extend(children)
262 def __str__(self) -> str:
263 buf = io.StringIO()
264 Node.render_list(buf, self.get_nodes())
265 return buf.getvalue()
268def fragment(*content: Node | HasNodes | str) -> Fragment:
269 f = Fragment(*content)
270 register_with_context(f)
271 return f
274class Prototype:
275 _tag: str
277 def __repr__(self) -> str:
278 return f"<{type(self).__name__} {self._tag}>"
281class PrototypeEmpty(Prototype):
282 def __init__(self, tag: str, *, inline: bool, omit_end_tag: bool):
283 self._tag = tag
284 self._inline = inline
285 self._omit_end_tag = omit_end_tag
287 def __call__(self, **attrs: str | bool) -> ElementEmpty:
288 return ElementEmpty(
289 self._tag, inline=self._inline, omit_end_tag=self._omit_end_tag
290 )(**attrs)
292 def __getitem__(self, key: str) -> ElementEmpty:
293 return ElementEmpty(
294 self._tag, inline=self._inline, omit_end_tag=self._omit_end_tag
295 )[key]
298class PrototypeNonEmpty(Prototype):
299 def __init__(self, tag: str, *, inline: bool):
300 self._tag = tag
301 self._inline = inline
303 def __call__(
304 self, *children: Node | HasNodes | str, **attrs: str | bool
305 ) -> ElementNonEmpty:
306 elem = ElementNonEmpty(self._tag, inline=self._inline)(*children, **attrs)
307 register_with_context(elem)
308 return elem
310 def __getitem__(self, key: str) -> ElementNonEmpty:
311 return ElementNonEmpty(self._tag, inline=self._inline)[key]
313 def __enter__(self) -> ElementNonEmpty:
314 elem = ElementNonEmpty(self._tag, inline=self._inline)
315 push_element_context(elem)
316 return elem
318 def __exit__(self, *exc_info: object) -> None:
319 parent, children = pop_element_context()
320 parent(*children)
323@overload
324def make_prototype(tag: str, *, inline: bool = ...) -> PrototypeNonEmpty: ...
327@overload
328def make_prototype(
329 tag: str, *, inline: bool = ..., empty: Literal[False]
330) -> PrototypeNonEmpty: ...
333@overload
334def make_prototype(
335 tag: str, *, inline: bool = ..., empty: Literal[True], omit_end_tag: bool = ...
336) -> PrototypeEmpty: ...
339def make_prototype(
340 tag: str, *, inline: bool = False, empty: bool = False, omit_end_tag: bool = False
341) -> PrototypeNonEmpty | PrototypeEmpty:
342 if empty:
343 return PrototypeEmpty(tag, inline=inline, omit_end_tag=omit_end_tag)
344 return PrototypeNonEmpty(tag, inline=inline)