Coverage for tdom/processor.py: 100%
197 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-17 19:54 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-17 19:54 +0000
1import random
2import string
3import sys
4import typing as t
5from collections.abc import Iterable
6from functools import lru_cache
7from string.templatelib import Interpolation, Template
9from markupsafe import Markup
11from .callables import CallableInfo, get_callable_info
12from .classnames import classnames
13from .nodes import Element, Fragment, Node, Text
14from .parser import parse_html
15from .utils import format_interpolation as base_format_interpolation
18@t.runtime_checkable
19class HasHTMLDunder(t.Protocol):
20 def __html__(self) -> str: ...
23# --------------------------------------------------------------------------
24# Value formatting
25# --------------------------------------------------------------------------
28def _format_safe(value: object, format_spec: str) -> str:
29 """Use Markup() to mark a value as safe HTML."""
30 assert format_spec == "safe"
31 return Markup(value)
34def _format_unsafe(value: object, format_spec: str) -> str:
35 """Convert a value to a plain string, forcing it to be treated as unsafe."""
36 assert format_spec == "unsafe"
37 return str(value)
40CUSTOM_FORMATTERS = (("safe", _format_safe), ("unsafe", _format_unsafe))
43def format_interpolation(interpolation: Interpolation) -> object:
44 return base_format_interpolation(
45 interpolation,
46 formatters=CUSTOM_FORMATTERS,
47 )
50# --------------------------------------------------------------------------
51# Instrumentation, Parsing, and Caching
52# --------------------------------------------------------------------------
54_PLACEHOLDER_PREFIX = f"t🐍-{''.join(random.choices(string.ascii_lowercase, k=4))}-"
55_PP_LEN = len(_PLACEHOLDER_PREFIX)
58def _placeholder(i: int) -> str:
59 """Generate a placeholder for the i-th interpolation."""
60 return f"{_PLACEHOLDER_PREFIX}{i}"
63def _placholder_index(s: str) -> int:
64 """Extract the index from a placeholder string."""
65 return int(s[_PP_LEN:])
68def _instrument(
69 strings: tuple[str, ...], callable_infos: tuple[CallableInfo | None, ...]
70) -> t.Iterable[str]:
71 """
72 Join the strings with placeholders in between where interpolations go.
74 This is used to prepare the template string for parsing, so that we can
75 later substitute the actual interpolated values into the parse tree.
77 The placeholders are chosen to be unlikely to collide with typical HTML
78 content.
79 """
80 count = len(strings)
82 callable_placeholders: dict[int, str] = {}
84 for i, s in enumerate(strings):
85 yield s
86 # There are always count-1 placeholders between count strings.
87 if i < count - 1:
88 placeholder = _placeholder(i)
90 # Special case for component callables: if the interpolation
91 # is a callable, we need to make sure that any matching closing
92 # tag uses the same placeholder.
93 callable_info = callable_infos[i]
94 if callable_info:
95 placeholder = callable_placeholders.setdefault(
96 callable_info.id, placeholder
97 )
99 yield placeholder
102@lru_cache(maxsize=0 if "pytest" in sys.modules else 512)
103def _instrument_and_parse_internal(
104 strings: tuple[str, ...], callable_infos: tuple[CallableInfo | None, ...]
105) -> Node:
106 """
107 Instrument the strings and parse the resulting HTML.
109 The result is cached to avoid re-parsing the same template multiple times.
110 """
111 instrumented = _instrument(strings, callable_infos)
112 return parse_html(instrumented)
115def _callable_info(value: object) -> CallableInfo | None:
116 """Return a unique identifier for a callable, or None if not callable."""
117 return get_callable_info(value) if callable(value) else None
120def _instrument_and_parse(template: Template) -> Node:
121 """Instrument and parse a template, returning a tree of Nodes."""
122 # This is a thin wrapper around the cached internal function that does the
123 # actual work. This exists to handle the syntax we've settled on for
124 # component invocation, namely that callables are directly included as
125 # interpolations both in the open *and* the close tags. We need to make
126 # sure that matching tags... match!
127 #
128 # If we used `tdom`'s approach of component closing tags of <//> then we
129 # wouldn't have to do this. But I worry that tdom's syntax is harder to read
130 # (it's easy to miss the closing tag) and may prove unfamiliar for
131 # users coming from other templating systems.
132 callable_infos = tuple(
133 _callable_info(interpolation.value) for interpolation in template.interpolations
134 )
135 return _instrument_and_parse_internal(template.strings, callable_infos)
138# --------------------------------------------------------------------------
139# Placeholder Substitution
140# --------------------------------------------------------------------------
143def _force_dict(value: t.Any, *, kind: str) -> dict:
144 """Try to convert a value to a dict, raising TypeError if not possible."""
145 try:
146 return dict(value)
147 except (TypeError, ValueError):
148 raise TypeError(
149 f"Cannot use {type(value).__name__} as value for {kind} attributes"
150 ) from None
153def _process_aria_attr(value: object) -> t.Iterable[tuple[str, str | None]]:
154 """Produce aria-* attributes based on the interpolated value for "aria"."""
155 d = _force_dict(value, kind="aria")
156 for sub_k, sub_v in d.items():
157 if sub_v is True:
158 yield f"aria-{sub_k}", "true"
159 elif sub_v is False:
160 yield f"aria-{sub_k}", "false"
161 elif sub_v is None:
162 pass
163 else:
164 yield f"aria-{sub_k}", str(sub_v)
167def _process_data_attr(value: object) -> t.Iterable[tuple[str, str | None]]:
168 """Produce data-* attributes based on the interpolated value for "data"."""
169 d = _force_dict(value, kind="data")
170 for sub_k, sub_v in d.items():
171 if sub_v is True:
172 yield f"data-{sub_k}", None
173 elif sub_v not in (False, None):
174 yield f"data-{sub_k}", str(sub_v)
177def _process_class_attr(value: object) -> t.Iterable[tuple[str, str | None]]:
178 """Substitute a class attribute based on the interpolated value."""
179 yield ("class", classnames(value))
182def _process_style_attr(value: object) -> t.Iterable[tuple[str, str | None]]:
183 """Substitute a style attribute based on the interpolated value."""
184 if isinstance(value, str):
185 yield ("style", value)
186 return
187 try:
188 d = _force_dict(value, kind="style")
189 style_str = "; ".join(f"{k}: {v}" for k, v in d.items())
190 yield ("style", style_str)
191 except TypeError:
192 raise TypeError("'style' attribute value must be a string or dict") from None
195def _substitute_spread_attrs(
196 value: object,
197) -> t.Iterable[tuple[str, object | None]]:
198 """
199 Substitute a spread attribute based on the interpolated value.
201 A spread attribute is one where the key is a placeholder, indicating that
202 the entire attribute set should be replaced by the interpolated value.
203 The value must be a dict or iterable of key-value pairs.
204 """
205 d = _force_dict(value, kind="spread")
206 for sub_k, sub_v in d.items():
207 yield from _process_attr(sub_k, sub_v)
210# A collection of custom handlers for certain attribute names that have
211# special semantics. This is in addition to the special-casing in
212# _substitute_attr() itself.
213CUSTOM_ATTR_PROCESSORS = {
214 "class": _process_class_attr,
215 "data": _process_data_attr,
216 "style": _process_style_attr,
217 "aria": _process_aria_attr,
218}
221def _process_attr(
222 key: str,
223 value: object,
224) -> t.Iterable[tuple[str, object | None]]:
225 """
226 Substitute a single attribute based on its key and the interpolated value.
228 A single parsed attribute with a placeholder may result in multiple
229 attributes in the final output, for instance if the value is a dict or
230 iterable of key-value pairs. Likewise, a value of False will result in
231 the attribute being omitted entirely; nothing is yielded in that case.
232 """
233 # Special handling for certain attribute names that have special semantics
234 if custom_processor := CUSTOM_ATTR_PROCESSORS.get(key):
235 yield from custom_processor(value)
236 return
238 # General handling for all other attributes:
239 match value:
240 case True:
241 yield (key, None)
242 case False | None:
243 pass
244 case _:
245 yield (key, value)
248def _substitute_interpolated_attrs(
249 attrs: dict[str, str | None], interpolations: tuple[Interpolation, ...]
250) -> dict[str, object]:
251 """
252 Replace placeholder values in attributes with their interpolated values.
254 This only handles step (1): value substitution. No special processing
255 of attribute names or value types is performed.
256 """
257 new_attrs: dict[str, object | None] = {}
258 for key, value in attrs.items():
259 if value and value.startswith(_PLACEHOLDER_PREFIX):
260 # Interpolated attribute value
261 index = _placholder_index(value)
262 interpolation = interpolations[index]
263 interpolated_value = format_interpolation(interpolation)
264 new_attrs[key] = interpolated_value
265 elif key.startswith(_PLACEHOLDER_PREFIX):
266 # Spread attributes
267 index = _placholder_index(key)
268 interpolation = interpolations[index]
269 spread_value = format_interpolation(interpolation)
270 for sub_k, sub_v in _substitute_spread_attrs(spread_value):
271 new_attrs[sub_k] = sub_v
272 else:
273 # Static attribute
274 new_attrs[key] = value
275 return new_attrs
278def _process_html_attrs(attrs: dict[str, object]) -> dict[str, str | None]:
279 """
280 Process attributes for HTML elements.
282 This handles steps (2) and (3): special attribute name handling and
283 value type processing (True -> None, False -> omit, etc.)
284 """
285 processed_attrs: dict[str, str | None] = {}
286 for key, value in attrs.items():
287 for sub_k, sub_v in _process_attr(key, value):
288 # Convert to string, preserving None
289 processed_attrs[sub_k] = str(sub_v) if sub_v is not None else None
290 return processed_attrs
293def _substitute_attrs(
294 attrs: dict[str, str | None], interpolations: tuple[Interpolation, ...]
295) -> dict[str, str | None]:
296 """
297 Substitute placeholders in attributes for HTML elements.
299 This is the full pipeline: interpolation + HTML processing.
300 """
301 interpolated_attrs = _substitute_interpolated_attrs(attrs, interpolations)
302 return _process_html_attrs(interpolated_attrs)
305def _substitute_and_flatten_children(
306 children: t.Iterable[Node], interpolations: tuple[Interpolation, ...]
307) -> list[Node]:
308 """Substitute placeholders in a list of children and flatten any fragments."""
309 new_children: list[Node] = []
310 for child in children:
311 substituted = _substitute_node(child, interpolations)
312 if isinstance(substituted, Fragment):
313 # This can happen if an interpolation results in a Fragment, for
314 # instance if it is iterable.
315 new_children.extend(substituted.children)
316 else:
317 new_children.append(substituted)
318 return new_children
321def _node_from_value(value: object) -> Node:
322 """
323 Convert an arbitrary value to a Node.
325 This is the primary action performed when replacing interpolations in child
326 content positions.
327 """
328 match value:
329 case str():
330 return Text(value)
331 case Node():
332 return value
333 case Template():
334 return html(value)
335 # Consider: falsey values, not just False and None?
336 case False | None:
337 return Fragment(children=[])
338 case Iterable():
339 children = [_node_from_value(v) for v in value]
340 return Fragment(children=children)
341 case HasHTMLDunder():
342 # CONSIDER: should we do this lazily?
343 return Text(Markup(value.__html__()))
344 case c if callable(c):
345 # Treat all callable values in child content positions as if
346 # they are zero-arg functions that return a value to be rendered.
347 return _node_from_value(c())
348 case _:
349 # CONSIDER: should we do this lazily?
350 return Text(str(value))
353def _kebab_to_snake(name: str) -> str:
354 """Convert a kebab-case name to snake_case."""
355 return name.replace("-", "_").lower()
358def _invoke_component(
359 tag: str,
360 new_attrs: dict[str, object | None],
361 new_children: list[Node],
362 interpolations: tuple[Interpolation, ...],
363) -> Node:
364 """
365 Invoke a component callable with the provided attributes and children.
367 Components are any callable that meets the required calling signature.
368 Typically, that's a function, but it could also be the constructor or
369 __call__() method for a class; dataclass constructors match our expected
370 invocation style.
372 We validate the callable's signature and invoke it with keyword-only
373 arguments, then convert the result to a Node.
375 Component invocation rules:
377 1. All arguments are passed as keywords only. Components cannot require
378 positional arguments.
380 2. Children are passed via a "children" parameter when:
382 - Child content exists in the template AND
383 - The callable accepts "children" OR has **kwargs
385 If no children exist but the callable accepts "children", we pass an
386 empty tuple.
388 3. All other attributes are converted from kebab-case to snake_case
389 and passed as keyword arguments if the callable accepts them (or has
390 **kwargs). Attributes that don't match parameters are silently ignored.
391 """
392 index = _placholder_index(tag)
393 interpolation = interpolations[index]
394 value = format_interpolation(interpolation)
395 if not callable(value):
396 raise TypeError(
397 f"Expected a callable for component invocation, got {type(value).__name__}"
398 )
399 callable_info = get_callable_info(value)
401 if callable_info.requires_positional:
402 raise TypeError(
403 "Component callables cannot have required positional arguments."
404 )
406 kwargs: dict[str, object] = {}
408 # Add all supported attributes
409 for attr_name, attr_value in new_attrs.items():
410 snake_name = _kebab_to_snake(attr_name)
411 if snake_name in callable_info.named_params or callable_info.kwargs:
412 kwargs[snake_name] = attr_value
414 # Add children if appropriate
415 if "children" in callable_info.named_params or callable_info.kwargs:
416 kwargs["children"] = tuple(new_children)
418 # Check to make sure we've fully satisfied the callable's requirements
419 missing = callable_info.required_named_params - kwargs.keys()
420 if missing:
421 raise TypeError(
422 f"Missing required parameters for component: {', '.join(missing)}"
423 )
425 result = value(**kwargs)
426 return _node_from_value(result)
429def _substitute_node(p_node: Node, interpolations: tuple[Interpolation, ...]) -> Node:
430 """Substitute placeholders in a node based on the corresponding interpolations."""
431 match p_node:
432 case Text(text) if str(text).startswith(_PLACEHOLDER_PREFIX):
433 index = _placholder_index(str(text))
434 interpolation = interpolations[index]
435 value = format_interpolation(interpolation)
436 return _node_from_value(value)
437 case Element(tag=tag, attrs=attrs, children=children):
438 new_children = _substitute_and_flatten_children(children, interpolations)
439 if tag.startswith(_PLACEHOLDER_PREFIX):
440 component_attrs = _substitute_interpolated_attrs(attrs, interpolations)
441 return _invoke_component(
442 tag, component_attrs, new_children, interpolations
443 )
444 else:
445 html_attrs = _substitute_attrs(attrs, interpolations)
446 return Element(tag=tag, attrs=html_attrs, children=new_children)
447 case Fragment(children=children):
448 new_children = _substitute_and_flatten_children(children, interpolations)
449 return Fragment(children=new_children)
450 case _:
451 return p_node
454# --------------------------------------------------------------------------
455# Public API
456# --------------------------------------------------------------------------
459def html(template: Template) -> Node:
460 """Parse a t-string and return a tree of Nodes."""
461 # Parse the HTML, returning a tree of nodes with placeholders
462 # where interpolations go.
463 p_node = _instrument_and_parse(template)
464 return _substitute_node(p_node, template.interpolations)