Coverage for tdom / processor.py: 98%
205 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-17 23:32 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-17 23:32 +0000
1import sys
2import typing as t
3from collections.abc import Iterable
4from functools import lru_cache
5from string.templatelib import Interpolation, Template
7from markupsafe import Markup
9from .callables import get_callable_info
10from .classnames import classnames
11from .format import format_interpolation as base_format_interpolation
12from .format import format_template
13from .nodes import Comment, DocumentType, Element, Fragment, Node, Text
14from .parser import (
15 HTMLAttribute,
16 HTMLAttributesDict,
17 TAttribute,
18 TComment,
19 TComponent,
20 TDocumentType,
21 TElement,
22 TemplateParser,
23 TFragment,
24 TInterpolatedAttribute,
25 TLiteralAttribute,
26 TNode,
27 TSpreadAttribute,
28 TTemplatedAttribute,
29 TText,
30)
31from .placeholders import TemplateRef
32from .template_utils import template_from_parts
33from .utils import CachableTemplate, LastUpdatedOrderedDict
36@t.runtime_checkable
37class HasHTMLDunder(t.Protocol):
38 def __html__(self) -> str: ... # pragma: no cover
41@lru_cache(maxsize=0 if "pytest" in sys.modules else 512)
42def _parse_and_cache(cachable: CachableTemplate) -> TNode:
43 return TemplateParser.parse(cachable.template)
46type Attribute = tuple[str, object]
47type AttributesDict = dict[str, object]
50# --------------------------------------------------------------------------
51# Custom formatting for the processor
52# --------------------------------------------------------------------------
55def _format_safe(value: object, format_spec: str) -> str:
56 """Use Markup() to mark a value as safe HTML."""
57 assert format_spec == "safe"
58 return Markup(value)
61def _format_unsafe(value: object, format_spec: str) -> str:
62 """Convert a value to a plain string, forcing it to be treated as unsafe."""
63 assert format_spec == "unsafe"
64 return str(value)
67CUSTOM_FORMATTERS = (("safe", _format_safe), ("unsafe", _format_unsafe))
70def format_interpolation(interpolation: Interpolation) -> object:
71 return base_format_interpolation(
72 interpolation,
73 formatters=CUSTOM_FORMATTERS,
74 )
77# --------------------------------------------------------------------------
78# Placeholder Substitution
79# --------------------------------------------------------------------------
82def _force_dict(value: t.Any, *, kind: str) -> dict:
83 """Try to convert a value to a dict, raising TypeError if not possible."""
84 try:
85 return dict(value)
86 except (TypeError, ValueError):
87 raise TypeError(
88 f"Cannot use {type(value).__name__} as value for {kind} attributes"
89 ) from None
92def _process_aria_attr(value: object) -> t.Iterable[HTMLAttribute]:
93 """Produce aria-* attributes based on the interpolated value for "aria"."""
94 d = _force_dict(value, kind="aria")
95 for sub_k, sub_v in d.items():
96 if sub_v is True:
97 yield f"aria-{sub_k}", "true"
98 elif sub_v is False:
99 yield f"aria-{sub_k}", "false"
100 elif sub_v is None:
101 pass
102 else:
103 yield f"aria-{sub_k}", str(sub_v)
106def _process_data_attr(value: object) -> t.Iterable[Attribute]:
107 """Produce data-* attributes based on the interpolated value for "data"."""
108 d = _force_dict(value, kind="data")
109 for sub_k, sub_v in d.items():
110 if sub_v is True:
111 yield f"data-{sub_k}", True
112 elif sub_v is not False and sub_v is not None:
113 yield f"data-{sub_k}", str(sub_v)
116def _process_class_attr(value: object) -> t.Iterable[HTMLAttribute]:
117 """Substitute a class attribute based on the interpolated value."""
118 yield ("class", classnames(value))
121def _process_style_attr(value: object) -> t.Iterable[HTMLAttribute]:
122 """Substitute a style attribute based on the interpolated value."""
123 if isinstance(value, str):
124 yield ("style", value)
125 return
126 try:
127 d = _force_dict(value, kind="style")
128 style_str = "; ".join(f"{k}: {v}" for k, v in d.items())
129 yield ("style", style_str)
130 except TypeError:
131 raise TypeError("'style' attribute value must be a string or dict") from None
134def _substitute_spread_attrs(value: object) -> t.Iterable[Attribute]:
135 """
136 Substitute a spread attribute based on the interpolated value.
138 A spread attribute is one where the key is a placeholder, indicating that
139 the entire attribute set should be replaced by the interpolated value.
140 The value must be a dict or iterable of key-value pairs.
141 """
142 d = _force_dict(value, kind="spread")
143 for sub_k, sub_v in d.items():
144 yield from _process_attr(sub_k, sub_v)
147# A collection of custom handlers for certain attribute names that have
148# special semantics. This is in addition to the special-casing in
149# _substitute_attr() itself.
150CUSTOM_ATTR_PROCESSORS = {
151 "class": _process_class_attr,
152 "data": _process_data_attr,
153 "style": _process_style_attr,
154 "aria": _process_aria_attr,
155}
158def _process_attr(key: str, value: object) -> t.Iterable[Attribute]:
159 """
160 Substitute a single attribute based on its key and the interpolated value.
162 A single parsed attribute with a placeholder may result in multiple
163 attributes in the final output, for instance if the value is a dict or
164 iterable of key-value pairs. Likewise, a value of False will result in
165 the attribute being omitted entirely; nothing is yielded in that case.
166 """
167 # Special handling for certain attribute names that have special semantics
168 if custom_processor := CUSTOM_ATTR_PROCESSORS.get(key):
169 yield from custom_processor(value)
170 return
171 yield (key, value)
174def _resolve_t_attrs(
175 attrs: t.Sequence[TAttribute], interpolations: tuple[Interpolation, ...]
176) -> AttributesDict:
177 """
178 Replace placeholder values in attributes with their interpolated values.
180 The values returned are not yet processed for HTML output; that is handled
181 in a later step.
182 """
183 new_attrs: AttributesDict = LastUpdatedOrderedDict()
184 for attr in attrs:
185 match attr:
186 case TLiteralAttribute(name=name, value=value):
187 new_attrs[name] = True if value is None else value
188 case TInterpolatedAttribute(name=name, value_i_index=i_index):
189 interpolation = interpolations[i_index]
190 attr_value = format_interpolation(interpolation)
191 for sub_k, sub_v in _process_attr(name, attr_value):
192 new_attrs[sub_k] = sub_v
193 case TTemplatedAttribute(name=name, value_ref=ref):
194 attr_t = _resolve_ref(ref, interpolations)
195 attr_value = format_template(attr_t)
196 new_attrs[name] = attr_value
197 case TSpreadAttribute(i_index=i_index):
198 interpolation = interpolations[i_index]
199 spread_value = format_interpolation(interpolation)
200 for sub_k, sub_v in _substitute_spread_attrs(spread_value):
201 new_attrs[sub_k] = sub_v
202 case _:
203 raise ValueError(f"Unknown TAttribute type: {type(attr).__name__}")
204 return new_attrs
207def _resolve_html_attrs(attrs: AttributesDict) -> HTMLAttributesDict:
208 """Resolve attribute values for HTML output."""
209 html_attrs: HTMLAttributesDict = {}
210 for key, value in attrs.items():
211 match value:
212 case True:
213 html_attrs[key] = None
214 case False | None:
215 pass
216 case _:
217 html_attrs[key] = str(value)
218 return html_attrs
221def _resolve_attrs(
222 attrs: t.Sequence[TAttribute], interpolations: tuple[Interpolation, ...]
223) -> HTMLAttributesDict:
224 """
225 Substitute placeholders in attributes for HTML elements.
227 This is the full pipeline: interpolation + HTML processing.
228 """
229 interpolated_attrs = _resolve_t_attrs(attrs, interpolations)
230 return _resolve_html_attrs(interpolated_attrs)
233def _flatten_nodes(nodes: t.Iterable[Node]) -> list[Node]:
234 """Flatten a list of Nodes, expanding any Fragments."""
235 flat: list[Node] = []
236 for node in nodes:
237 if isinstance(node, Fragment):
238 flat.extend(node.children)
239 else:
240 flat.append(node)
241 return flat
244def _substitute_and_flatten_children(
245 children: t.Iterable[TNode], interpolations: tuple[Interpolation, ...]
246) -> list[Node]:
247 """Substitute placeholders in a list of children and flatten any fragments."""
248 resolved = [_resolve_t_node(child, interpolations) for child in children]
249 flat = _flatten_nodes(resolved)
250 return flat
253def _node_from_value(value: object) -> Node:
254 """
255 Convert an arbitrary value to a Node.
257 This is the primary action performed when replacing interpolations in child
258 content positions.
259 """
260 match value:
261 case str():
262 return Text(value)
263 case Node():
264 return value
265 case Template():
266 return html(value)
267 # Consider: falsey values, not just False and None?
268 case False | None:
269 return Fragment(children=[])
270 case Iterable():
271 children = [_node_from_value(v) for v in value]
272 return Fragment(children=children)
273 case HasHTMLDunder():
274 # CONSIDER: should we do this lazily?
275 return Text(Markup(value.__html__()))
276 case c if callable(c):
277 # Treat all callable values in child content positions as if
278 # they are zero-arg functions that return a value to be rendered.
279 return _node_from_value(c())
280 case _:
281 # CONSIDER: should we do this lazily?
282 return Text(str(value))
285def _kebab_to_snake(name: str) -> str:
286 """Convert a kebab-case name to snake_case."""
287 return name.replace("-", "_").lower()
290def _invoke_component(
291 attrs: AttributesDict,
292 children: list[Node], # TODO: why not TNode, though?
293 interpolation: Interpolation,
294) -> Node:
295 """
296 Invoke a component callable with the provided attributes and children.
298 Components are any callable that meets the required calling signature.
299 Typically, that's a function, but it could also be the constructor or
300 __call__() method for a class; dataclass constructors match our expected
301 invocation style.
303 We validate the callable's signature and invoke it with keyword-only
304 arguments, then convert the result to a Node.
306 Component invocation rules:
308 1. All arguments are passed as keywords only. Components cannot require
309 positional arguments.
311 2. Children are passed via a "children" parameter when:
313 - Child content exists in the template AND
314 - The callable accepts "children" OR has **kwargs
316 If no children exist but the callable accepts "children", we pass an
317 empty tuple.
319 3. All other attributes are converted from kebab-case to snake_case
320 and passed as keyword arguments if the callable accepts them (or has
321 **kwargs). Attributes that don't match parameters are silently ignored.
322 """
323 value = format_interpolation(interpolation)
324 if not callable(value):
325 raise TypeError(
326 f"Expected a callable for component invocation, got {type(value).__name__}"
327 )
328 callable_info = get_callable_info(value)
330 if callable_info.requires_positional:
331 raise TypeError(
332 "Component callables cannot have required positional arguments."
333 )
335 kwargs: AttributesDict = {}
337 # Add all supported attributes
338 for attr_name, attr_value in attrs.items():
339 snake_name = _kebab_to_snake(attr_name)
340 if snake_name in callable_info.named_params or callable_info.kwargs:
341 kwargs[snake_name] = attr_value
343 # Add children if appropriate
344 if "children" in callable_info.named_params or callable_info.kwargs:
345 kwargs["children"] = tuple(children)
347 # Check to make sure we've fully satisfied the callable's requirements
348 missing = callable_info.required_named_params - kwargs.keys()
349 if missing:
350 raise TypeError(
351 f"Missing required parameters for component: {', '.join(missing)}"
352 )
354 result = value(**kwargs)
355 return _node_from_value(result)
358def _resolve_ref(
359 ref: TemplateRef, interpolations: tuple[Interpolation, ...]
360) -> Template:
361 resolved = [interpolations[i_index] for i_index in ref.i_indexes]
362 return template_from_parts(ref.strings, resolved)
365def _resolve_t_text_ref(
366 ref: TemplateRef, interpolations: tuple[Interpolation, ...]
367) -> Text | Fragment:
368 """Resolve a TText ref into Text or Fragment by processing interpolations."""
369 if ref.is_literal:
370 return Text(ref.strings[0])
372 parts = [
373 Text(part)
374 if isinstance(part, str)
375 else _node_from_value(format_interpolation(part))
376 for part in _resolve_ref(ref, interpolations)
377 ]
378 flat = _flatten_nodes(parts)
380 if len(flat) == 1 and isinstance(flat[0], Text):
381 return flat[0]
383 return Fragment(children=flat)
386def _resolve_t_node(t_node: TNode, interpolations: tuple[Interpolation, ...]) -> Node:
387 """Resolve a TNode tree into a Node tree by processing interpolations."""
388 match t_node:
389 case TText(ref=ref):
390 return _resolve_t_text_ref(ref, interpolations)
391 case TComment(ref=ref):
392 comment_t = _resolve_ref(ref, interpolations)
393 comment = format_template(comment_t)
394 return Comment(comment)
395 case TDocumentType(text=text):
396 return DocumentType(text)
397 case TFragment(children=children):
398 resolved_children = _substitute_and_flatten_children(
399 children, interpolations
400 )
401 return Fragment(children=resolved_children)
402 case TElement(tag=tag, attrs=attrs, children=children):
403 resolved_attrs = _resolve_attrs(attrs, interpolations)
404 resolved_children = _substitute_and_flatten_children(
405 children, interpolations
406 )
407 return Element(tag=tag, attrs=resolved_attrs, children=resolved_children)
408 case TComponent(
409 start_i_index=start_i_index,
410 end_i_index=end_i_index,
411 attrs=t_attrs,
412 children=children,
413 ):
414 start_interpolation = interpolations[start_i_index]
415 end_interpolation = (
416 None if end_i_index is None else interpolations[end_i_index]
417 )
418 resolved_attrs = _resolve_t_attrs(t_attrs, interpolations)
419 resolved_children = _substitute_and_flatten_children(
420 children, interpolations
421 )
422 # HERE ALSO BE DRAGONS: validate matching start/end callables, since
423 # the underlying TemplateParser cannot do that for us.
424 if (
425 end_interpolation is not None
426 and end_interpolation.value != start_interpolation.value
427 ):
428 raise TypeError("Mismatched component start and end callables.")
429 return _invoke_component(
430 attrs=resolved_attrs,
431 children=resolved_children,
432 interpolation=start_interpolation,
433 )
434 case _:
435 raise ValueError(f"Unknown TNode type: {type(t_node).__name__}")
438# --------------------------------------------------------------------------
439# Public API
440# --------------------------------------------------------------------------
443def html(template: Template) -> Node:
444 """Parse an HTML t-string, substitue values, and return a tree of Nodes."""
445 cachable = CachableTemplate(template)
446 t_node = _parse_and_cache(cachable)
447 return _resolve_t_node(t_node, template.interpolations)