Coverage for tdom/processor.py: 98%
422 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-23 04:35 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-23 04:35 +0000
1import typing as t
2from collections.abc import Callable, Iterable, Sequence
3from dataclasses import dataclass, field
4from functools import lru_cache
5from string.templatelib import Interpolation, Template
7from markupsafe import Markup
9from .callables import CallableInfo, get_callable_info
10from .escaping import (
11 escape_html_comment as default_escape_html_comment,
12)
13from .escaping import (
14 escape_html_script as default_escape_html_script,
15)
16from .escaping import (
17 escape_html_style as default_escape_html_style,
18)
19from .escaping import (
20 escape_html_text as default_escape_html_text,
21)
22from .format import format_interpolation as base_format_interpolation
23from .format import format_template
24from .htmlspec import (
25 CDATA_CONTENT_ELEMENTS,
26 DEFAULT_NORMAL_TEXT_ELEMENT,
27 RCDATA_CONTENT_ELEMENTS,
28 SVG_ATTR_FIX,
29 SVG_TAG_FIX,
30 VOID_ELEMENTS,
31)
32from .parser import (
33 HTMLAttribute,
34 TAttribute,
35 TComment,
36 TComponent,
37 TDocumentType,
38 TElement,
39 TemplateParser,
40 TFragment,
41 TInterpolatedAttribute,
42 TLiteralAttribute,
43 TNode,
44 TSpreadAttribute,
45 TTemplatedAttribute,
46 TText,
47)
48from .protocols import HasHTMLDunder
49from .scope import ScopedTemplate
50from .template_utils import TemplateRef
51from .utils import CachableTemplate, LastUpdatedOrderedDict
53type Attribute = tuple[str, object]
54type AttributesDict = dict[str, object]
57# --------------------------------------------------------------------------
58# Custom formatting for the processor
59# --------------------------------------------------------------------------
62def _format_safe(value: object, format_spec: str) -> str:
63 """Use Markup() to mark a value as safe HTML."""
64 assert format_spec == "safe"
65 return Markup(value)
68def _format_unsafe(value: object, format_spec: str) -> str:
69 """Convert a value to a plain string, forcing it to be treated as unsafe."""
70 assert format_spec == "unsafe"
71 return str(value)
74def _format_callback(value: Callable[..., object], format_spec: str) -> object:
75 """Execute a callback and return the value."""
76 assert format_spec == "callback"
77 return value()
80CUSTOM_FORMATTERS = (
81 ("safe", _format_safe),
82 ("unsafe", _format_unsafe),
83 ("callback", _format_callback),
84)
87def format_interpolation(interpolation: Interpolation) -> object:
88 return base_format_interpolation(
89 interpolation,
90 formatters=CUSTOM_FORMATTERS,
91 )
94# --------------------------------------------------------------------------
95# Placeholder Substitution
96# --------------------------------------------------------------------------
99def _expand_aria_attr(value: object) -> Iterable[HTMLAttribute]:
100 """Produce aria-* attributes based on the interpolated value for "aria"."""
101 if value is None:
102 return
103 elif isinstance(value, dict):
104 for sub_k, sub_v in value.items():
105 if sub_v is True:
106 yield f"aria-{sub_k}", "true"
107 elif sub_v is False:
108 yield f"aria-{sub_k}", "false"
109 elif sub_v is None:
110 yield f"aria-{sub_k}", None
111 else:
112 yield f"aria-{sub_k}", str(sub_v)
113 else:
114 raise TypeError(
115 f"Cannot use {type(value).__name__} as value for aria attribute"
116 )
119def _expand_data_attr(value: object) -> Iterable[Attribute]:
120 """Produce data-* attributes based on the interpolated value for "data"."""
121 if value is None:
122 return
123 elif isinstance(value, dict):
124 for sub_k, sub_v in value.items():
125 if sub_v is True or sub_v is False or sub_v is None:
126 yield f"data-{sub_k}", sub_v
127 else:
128 yield f"data-{sub_k}", str(sub_v)
129 else:
130 raise TypeError(
131 f"Cannot use {type(value).__name__} as value for data attribute"
132 )
135def _substitute_spread_attrs(value: object) -> Iterable[Attribute]:
136 """
137 Substitute a spread attribute based on the interpolated value.
139 A spread attribute is one where the key is a placeholder, indicating that
140 the entire attribute set should be replaced by the interpolated value.
141 The value must be a dict or iterable of key-value pairs.
142 """
143 if value is None:
144 return
145 elif isinstance(value, dict):
146 yield from value.items()
147 else:
148 raise TypeError(
149 f"Cannot use {type(value).__name__} as value for spread attributes"
150 )
153ATTR_EXPANDERS = {
154 "data": _expand_data_attr,
155 "aria": _expand_aria_attr,
156}
159def parse_style_attribute_value(style_str: str) -> list[tuple[str, str | None]]:
160 """
161 Parse the style declarations out of a style attribute string.
162 """
163 props = [p.strip() for p in style_str.split(";")]
164 styles: list[tuple[str, str | None]] = []
165 for prop in props:
166 if prop:
167 prop_parts = [p.strip() for p in prop.split(":") if p.strip()]
168 if len(prop_parts) != 2:
169 raise ValueError(
170 f"Invalid number of parts for style property {prop} in {style_str}"
171 )
172 styles.append((prop_parts[0], prop_parts[1]))
173 return styles
176def make_style_accumulator(old_value: object) -> StyleAccumulator:
177 """
178 Initialize the style accumulator.
179 """
180 match old_value:
181 case str():
182 styles = {
183 name: value for name, value in parse_style_attribute_value(old_value)
184 }
185 case True: # A bare attribute will just default to {}.
186 styles = {}
187 case _:
188 raise TypeError(f"Unexpected value: {old_value}")
189 return StyleAccumulator(styles=styles)
192@dataclass
193class StyleAccumulator:
194 styles: dict[str, str | None]
196 def merge_value(self, value: object) -> None:
197 """
198 Merge in an interpolated style value.
199 """
200 match value:
201 case str():
202 self.styles.update(
203 {name: value for name, value in parse_style_attribute_value(value)}
204 )
205 case dict():
206 self.styles.update(
207 {
208 str(pn): str(pv) if pv is not None else None
209 for pn, pv in value.items()
210 }
211 )
212 case None:
213 pass
214 case _:
215 raise TypeError(
216 f"Unknown interpolated style value {value}, use '' to omit."
217 )
219 def to_value(self) -> str | None:
220 """
221 Serialize the special style value back into a string.
223 @NOTE: If the result would be `''` then use `None` to omit the attribute.
224 """
225 style_value = "; ".join(
226 [f"{pn}: {pv}" for pn, pv in self.styles.items() if pv is not None]
227 )
228 return style_value if style_value else None
231def make_class_accumulator(old_value: object) -> ClassAccumulator:
232 """
233 Initialize the class accumulator.
234 """
235 match old_value:
236 case str():
237 toggled_classes = {cn: True for cn in old_value.split()}
238 case True:
239 toggled_classes = {}
240 case _:
241 raise ValueError(f"Unexpected value {old_value}")
242 return ClassAccumulator(toggled_classes=toggled_classes)
245@dataclass
246class ClassAccumulator:
247 toggled_classes: dict[str, bool]
249 def merge_value(self, value: object) -> None:
250 """
251 Merge in an interpolated class value.
252 """
253 if isinstance(value, dict):
254 self.toggled_classes.update(
255 {str(cn): bool(toggle) for cn, toggle in value.items()}
256 )
257 else:
258 if not isinstance(value, str) and isinstance(value, Sequence):
259 items = value[:]
260 else:
261 items = (value,)
262 for item in items:
263 match item:
264 case str():
265 self.toggled_classes.update({cn: True for cn in item.split()})
266 case None:
267 pass
268 case _:
269 if item == value:
270 raise TypeError(
271 f"Unknown interpolated class value: {value}"
272 )
273 else:
274 raise TypeError(
275 f"Unknown interpolated class item in {value}: {item}"
276 )
278 def to_value(self) -> str | None:
279 """
280 Serialize the special class value back into a string.
282 @NOTE: If the result would be `''` then use `None` to omit the attribute.
283 """
284 class_value = " ".join(
285 [cn for cn, toggle in self.toggled_classes.items() if toggle]
286 )
287 return class_value if class_value else None
290ATTR_ACCUMULATOR_MAKERS = {
291 "class": make_class_accumulator,
292 "style": make_style_accumulator,
293}
296type AttributeValueAccumulator = StyleAccumulator | ClassAccumulator
299def _resolve_t_attrs(
300 attrs: Sequence[TAttribute], interpolations: tuple[Interpolation, ...]
301) -> AttributesDict:
302 """
303 Replace placeholder values in attributes with their interpolated values.
305 The values returned are not yet processed for HTML output; that is handled
306 in a later step.
308 @NOTE: We "touch" the key when accumulating values so that we can predict
309 what order that attribute will be ordered. We skip this step when setting
310 the final value so that the order is not disturbed.
311 """
312 new_attrs: AttributesDict = LastUpdatedOrderedDict()
313 attr_accs: dict[str, AttributeValueAccumulator] = {}
314 for attr in attrs:
315 match attr:
316 case TLiteralAttribute(name=name, value=value):
317 attr_value = True if value is None else value
318 if name in ATTR_ACCUMULATOR_MAKERS and name in new_attrs:
319 if name not in attr_accs:
320 attr_accs[name] = ATTR_ACCUMULATOR_MAKERS[name](new_attrs[name])
321 new_attrs[name] = attr_accs[name].merge_value(attr_value)
322 else:
323 new_attrs[name] = attr_value
324 case TInterpolatedAttribute(name=name, value_i_index=i_index):
325 interpolation = interpolations[i_index]
326 attr_value = format_interpolation(interpolation)
327 if name in ATTR_ACCUMULATOR_MAKERS:
328 if name not in attr_accs:
329 attr_accs[name] = ATTR_ACCUMULATOR_MAKERS[name](
330 new_attrs.get(name, True)
331 )
332 new_attrs[name] = attr_accs[name].merge_value(attr_value)
333 elif expander := ATTR_EXPANDERS.get(name):
334 for sub_k, sub_v in expander(attr_value):
335 new_attrs[sub_k] = sub_v
336 else:
337 new_attrs[name] = attr_value
338 case TTemplatedAttribute(name=name, value_ref=ref):
339 attr_t = ref.resolve(interpolations)
340 attr_value = format_template(attr_t)
341 if name in ATTR_ACCUMULATOR_MAKERS:
342 if name not in attr_accs:
343 attr_accs[name] = ATTR_ACCUMULATOR_MAKERS[name](
344 new_attrs.get(name, True)
345 )
346 new_attrs[name] = attr_accs[name].merge_value(attr_value)
347 elif expander := ATTR_EXPANDERS.get(name):
348 raise TypeError(f"{name} attributes cannot be templated")
349 else:
350 new_attrs[name] = attr_value
351 case TSpreadAttribute(i_index=i_index):
352 interpolation = interpolations[i_index]
353 spread_value = format_interpolation(interpolation)
354 for sub_k, sub_v in _substitute_spread_attrs(spread_value):
355 if sub_k in ATTR_ACCUMULATOR_MAKERS:
356 if sub_k not in attr_accs:
357 attr_accs[sub_k] = ATTR_ACCUMULATOR_MAKERS[sub_k](
358 new_attrs.get(sub_k, True)
359 )
360 new_attrs[sub_k] = attr_accs[sub_k].merge_value(sub_v)
361 elif expander := ATTR_EXPANDERS.get(sub_k):
362 for exp_k, exp_v in expander(sub_v):
363 new_attrs[exp_k] = exp_v
364 else:
365 new_attrs[sub_k] = sub_v
366 case _:
367 raise ValueError(f"Unknown TAttribute type: {type(attr).__name__}")
368 for acc_name, acc in attr_accs.items():
369 # Skip "touching" the key here so that the order remains intact.
370 super(type(new_attrs), new_attrs).__setitem__(acc_name, acc.to_value())
371 return new_attrs
374def _resolve_html_attrs(attrs: AttributesDict) -> Iterable[HTMLAttribute]:
375 """Resolve attribute values for HTML output."""
376 for key, value in attrs.items():
377 match value:
378 case True:
379 yield key, None
380 case False | None:
381 pass
382 case _:
383 yield key, str(value)
386def _kebab_to_snake(name: str) -> str:
387 """Convert a kebab-case name to snake_case."""
388 return name.replace("-", "_").lower()
391def _prep_component_kwargs(
392 callable_info: CallableInfo,
393 attrs: AttributesDict,
394 children: Template,
395 provided_attrs: tuple[Attribute, ...] = (),
396 raise_on_requires_positional=True,
397 raise_on_missing=True,
398) -> AttributesDict:
399 """
400 Matchup kwargs from multiple sources to target the given callable.
402 `provided_attrs`:
403 These can be used by extensions that want to provide
404 attrs even if they are not specified in the component's `attrs` in
405 the template. If an attribute with the same name is provided in
406 `attrs` then it takes priority over entries in `provided_attrs`.
408 `raise_on_requires_positional`:
409 Optionally check and raise `TypeError` if the `callable_info` requires
410 positional arguments which we cannot fulfill normally.
411 An exception might not be desired if the caller will finish preparing
412 the arguments after this call.
414 `raise_on_missing`:
415 Optionally check and raise `TypeError` if we are not able to fulfill all
416 the arguments the `callable_info` expects since in the common case this
417 raise an exception whose cause might not be clear.
418 An exception might not be desired if the caller will finish preparing
419 the arguments after this call.
420 """
422 # We can't know what kwarg to put here...
423 if raise_on_requires_positional and callable_info.requires_positional:
424 raise TypeError(
425 "Component callables cannot have required positional arguments."
426 )
428 kwargs: AttributesDict = {}
430 # Add all supported attributes
431 for attr_name, attr_value in attrs.items():
432 snake_name = _kebab_to_snake(attr_name)
433 if snake_name in callable_info.named_params or callable_info.kwargs:
434 kwargs[snake_name] = attr_value
435 else:
436 raise ValueError(f"Unexpected attribute {snake_name}.")
438 if "children" in kwargs:
439 raise ValueError("The children attribute is reserved for component children.")
441 if "children" in callable_info.named_params:
442 kwargs["children"] = children
444 # Add in provided attrs if they haven't been set already and are wanted.
445 for pattr_name, pattr_value in provided_attrs:
446 if pattr_name not in kwargs and pattr_name in callable_info.named_params:
447 kwargs[pattr_name] = pattr_value
449 # Check to make sure we've fully satisfied the callable's requirements
450 if raise_on_missing:
451 missing = callable_info.required_named_params - kwargs.keys()
452 if missing:
453 raise TypeError(
454 f"Missing required parameters for component: {', '.join(missing)}"
455 )
457 return kwargs
460def serialize_html_attrs(
461 html_attrs: Iterable[HTMLAttribute], escape: Callable = default_escape_html_text
462) -> str:
463 return "".join(
464 (f' {k}="{escape(v)}"' if v is not None else f" {k}" for k, v in html_attrs)
465 )
468def _fix_svg_attrs(html_attrs: Iterable[HTMLAttribute]) -> Iterable[HTMLAttribute]:
469 """
470 Fix the attr name-case of any html attributes on a tag within an SVG namespace.
471 """
472 for k, v in html_attrs:
473 yield SVG_ATTR_FIX.get(k, k), v
476@dataclass(frozen=True, slots=True)
477class ProcessContext:
478 parent_tag: str = DEFAULT_NORMAL_TEXT_ELEMENT
479 ns: str = "html"
481 def copy(
482 self,
483 ns: str | None = None,
484 parent_tag: str | None = None,
485 ) -> ProcessContext:
486 return ProcessContext(
487 parent_tag=parent_tag if parent_tag is not None else self.parent_tag,
488 ns=ns if ns is not None else self.ns,
489 )
492type FunctionComponent = Callable[..., Template]
493type FactoryComponent = Callable[..., ComponentObject]
494type ComponentCallable = FunctionComponent | FactoryComponent
495type ComponentObject = Callable[[], Template]
498type NormalTextInterpolationValue = (
499 None
500 | bool # to support `showValue and value` idiom
501 | str
502 | HasHTMLDunder
503 | Template
504 | Iterable[NormalTextInterpolationValue]
505 | object
506)
507# Applies to both escapable raw text and raw text.
508type RawTextExactInterpolationValue = (
509 None
510 | bool # to support `showValue and value` idiom
511 | str
512 | HasHTMLDunder
513 | object
514)
515# Applies to both escapable raw text and raw text.
516type RawTextInexactInterpolationValue = (
517 None
518 | bool # to support `showValue and value` idiom
519 | str
520 | object
521)
524class ITemplateParserProxy(t.Protocol):
525 def to_tnode(self, template: Template) -> TNode: ...
528@dataclass(frozen=True)
529class TemplateParserProxy(ITemplateParserProxy):
530 def to_tnode(self, template: Template) -> TNode:
531 return TemplateParser.parse(template)
534@dataclass(frozen=True)
535class CachedTemplateParserProxy(TemplateParserProxy):
536 @lru_cache(512) # noqa: B019
537 def _to_tnode(self, ct: CachableTemplate) -> TNode:
538 return super().to_tnode(ct.template)
540 def to_tnode(self, template: Template) -> TNode:
541 return self._to_tnode(CachableTemplate(template))
544class IComponentProcessor(t.Protocol):
545 """Isolate component processing to allow for replacement."""
547 def process(
548 self,
549 template: Template,
550 last_ctx: ProcessContext,
551 component_callable: t.Annotated[object, ComponentCallable],
552 attrs: tuple[TAttribute, ...],
553 component_template: Template,
554 provided_attrs: tuple[Attribute, ...] = (),
555 ) -> Template | ScopedTemplate:
556 """
557 Process available component details into a `Template` (or a
558 `ScopedTemplate`, for context-provider components).
559 """
560 ...
563class ComponentProcessor(IComponentProcessor):
564 """
565 Default component processor.
566 """
568 def process(
569 self,
570 template: Template,
571 last_ctx: ProcessContext,
572 component_callable: t.Annotated[object, ComponentCallable],
573 attrs: tuple[TAttribute, ...],
574 component_template: Template,
575 provided_attrs: tuple[Attribute, ...] = (),
576 ) -> Template | ScopedTemplate:
577 """
578 Process available component details into a Template.
580 Two general "styles" are supported:
582 1. FunctionComponent
584 Calling `component_callable` with the prepared kwargs should
585 return a `Template`.
587 The primary purpose of this style is to support
588 using a normal function as a component.
590 2. FactoryComponent
592 Calling `component_callable` with the prepared kwargs should
593 return another `Callable` which when called with no arguments should
594 return a `Template`.
596 The primary purpose of this style is to support
597 using a `dataclass` with `def __call__(self) -> Template` as a
598 component.
600 Either style may instead return a `ScopedTemplate` -- a
601 `Template` bundled with a `Scope` to activate around its render.
602 Context providers (`tdom.make_provider(cv)` /
603 `tdom.create_context(...)`) use this shape; user code generally
604 won't construct one directly.
605 """
606 if not callable(component_callable):
607 raise TypeError(
608 f"Component callable must be callable: {type(component_callable)}"
609 )
610 kwargs = _prep_component_kwargs(
611 get_callable_info(component_callable),
612 _resolve_t_attrs(attrs, template.interpolations),
613 children=component_template,
614 provided_attrs=provided_attrs,
615 raise_on_requires_positional=True,
616 raise_on_missing=True,
617 )
618 res1 = component_callable(**kwargs) # ty: ignore[call-top-callable]
619 if isinstance(res1, (Template, ScopedTemplate)):
620 return res1
621 elif callable(res1):
622 res2 = res1() # ty: ignore[call-top-callable]
623 if isinstance(res2, (Template, ScopedTemplate)):
624 return res2
625 else:
626 raise TypeError(
627 f"Component object must return Template when called: {type(res2)}"
628 )
629 else:
630 raise TypeError(
631 f"Component callable must return Template or Callable: {type(res1)}"
632 )
635class ITemplateProcessor(t.Protocol):
636 def process(self, root_template: Template, assume_ctx: ProcessContext) -> str: ...
639@dataclass(frozen=True)
640class TemplateProcessor(ITemplateProcessor):
641 parser_api: ITemplateParserProxy = field(default_factory=CachedTemplateParserProxy)
643 component_processor_api: IComponentProcessor = field(
644 default_factory=ComponentProcessor
645 )
647 escape_html_text: Callable = default_escape_html_text
649 escape_html_comment: Callable = default_escape_html_comment
651 escape_html_script: Callable = default_escape_html_script
653 escape_html_style: Callable = default_escape_html_style
655 slash_void: bool = False # Apply a xhtml-style slash to void html elements.
657 uppercase_doctype: bool = False # DOCTYPE vs doctype
659 def process(
660 self,
661 root_template: Template,
662 assume_ctx: ProcessContext,
663 ) -> str:
664 """
665 Process a TDOM compatible template into a string.
666 """
667 return self._process_template(root_template, assume_ctx)
669 def _process_template(self, template: Template, last_ctx: ProcessContext) -> str:
670 root = self.parser_api.to_tnode(template)
671 return self._process_tnode(template, last_ctx, root)
673 def _process_tnode(
674 self, template: Template, last_ctx: ProcessContext, tnode: TNode
675 ) -> str:
676 """
677 Process a tnode from a template's "t-tree" into a string.
678 """
679 match tnode:
680 case TDocumentType(text):
681 return self._process_document_type(last_ctx, text)
682 case TComment(ref):
683 return self._process_comment(template, last_ctx, ref)
684 case TFragment(children):
685 return self._process_fragment(template, last_ctx, children)
686 case TComponent(start_i_index, end_i_index, children_ref, attrs):
687 return self._process_component(
688 template,
689 last_ctx,
690 attrs,
691 start_i_index,
692 end_i_index,
693 children_ref,
694 )
695 case TElement(tag, attrs, children):
696 return self._process_element(template, last_ctx, tag, attrs, children)
697 case TText(ref):
698 return self._process_texts(template, last_ctx, ref)
699 case _:
700 raise ValueError(f"Unrecognized tnode: {tnode}")
702 def _process_document_type(
703 self,
704 last_ctx: ProcessContext,
705 text: str,
706 ) -> str:
707 if last_ctx.ns != "html":
708 # Nit
709 raise ValueError(
710 "Cannot process document type in subtree of a foreign element."
711 )
712 if self.uppercase_doctype:
713 return f"<!DOCTYPE {text}>"
714 else:
715 return f"<!doctype {text}>"
717 def _process_fragment(
718 self,
719 template: Template,
720 last_ctx: ProcessContext,
721 children: Iterable[TNode],
722 ) -> str:
723 return "".join(
724 self._process_tnode(template, last_ctx, child) for child in children
725 )
727 def _process_texts(
728 self,
729 template: Template,
730 last_ctx: ProcessContext,
731 ref: TemplateRef,
732 ) -> str:
733 if last_ctx.parent_tag in CDATA_CONTENT_ELEMENTS:
734 # Must be handled all at once.
735 return self._process_raw_texts(template, last_ctx, ref)
736 elif last_ctx.parent_tag in RCDATA_CONTENT_ELEMENTS:
737 # We can handle all at once because there are no non-text children and everything must be string-ified.
738 return self._process_escapable_raw_texts(template, last_ctx, ref)
739 else:
740 return self._process_normal_texts(template, last_ctx, ref)
742 def _process_comment(
743 self,
744 template: Template,
745 last_ctx: ProcessContext,
746 content_ref: TemplateRef,
747 ) -> str:
748 """
749 Process a comment into a string.
750 """
751 content_str = resolve_text_without_recursion(template, "<!--", content_ref)
752 escaped_comment_str = self.escape_html_comment(content_str, allow_markup=True)
753 return f"<!--{escaped_comment_str}-->"
755 def _process_element(
756 self,
757 template: Template,
758 last_ctx: ProcessContext,
759 tag: str,
760 attrs: tuple[TAttribute, ...],
761 children: tuple[TNode, ...],
762 ) -> str:
763 out: list[str] = []
764 if tag == "svg":
765 our_ctx = last_ctx.copy(parent_tag=tag, ns="svg")
766 elif tag == "math":
767 our_ctx = last_ctx.copy(parent_tag=tag, ns="math")
768 else:
769 our_ctx = last_ctx.copy(parent_tag=tag)
770 if our_ctx.ns == "svg":
771 starttag = endtag = SVG_TAG_FIX.get(tag, tag)
772 else:
773 starttag = endtag = tag
774 out.append(f"<{starttag}")
775 if attrs:
776 out.append(self._process_attrs(template, our_ctx, attrs))
777 # @TODO: How can we tell if we write out children or not in
778 # order to self-close in non-html contexts, ie. SVG?
779 if self.slash_void and tag in VOID_ELEMENTS:
780 out.append(" />")
781 else:
782 out.append(">")
783 if tag not in VOID_ELEMENTS:
784 # We were still in SVG but now we default back into HTML
785 if tag == "foreignobject":
786 child_ctx = our_ctx.copy(ns="html")
787 else:
788 child_ctx = our_ctx
789 out.extend(
790 self._process_tnode(template, child_ctx, child) for child in children
791 )
792 out.append(f"</{endtag}>")
793 return "".join(out)
795 def _process_attrs(
796 self,
797 template: Template,
798 last_ctx: ProcessContext,
799 attrs: tuple[TAttribute, ...],
800 ) -> str:
801 """
802 Process an element's attributes into a string.
803 """
804 resolved_attrs = _resolve_t_attrs(attrs, template.interpolations)
805 if last_ctx.ns == "svg":
806 attrs_str = serialize_html_attrs(
807 _fix_svg_attrs(_resolve_html_attrs(resolved_attrs))
808 )
809 else:
810 attrs_str = serialize_html_attrs(_resolve_html_attrs(resolved_attrs))
811 if attrs_str:
812 return attrs_str
813 return ""
815 def _process_component(
816 self,
817 template: Template,
818 last_ctx: ProcessContext,
819 attrs: tuple[TAttribute, ...],
820 start_i_index: int,
821 end_i_index: int | None,
822 children_ref: TemplateRef,
823 ) -> str:
824 """
825 Invoke a component and process the result into a string.
826 """
827 children_template = children_ref.resolve(template.interpolations)
828 if (
829 start_i_index != end_i_index
830 and end_i_index is not None
831 and template.interpolations[start_i_index].value
832 != template.interpolations[end_i_index].value
833 ):
834 raise TypeError(
835 "Component callable in start tag must match component callable in end tag."
836 )
837 component_callable = template.interpolations[start_i_index].value
838 result_t = self.component_processor_api.process(
839 template, last_ctx, component_callable, attrs, children_template
840 )
841 if isinstance(result_t, ScopedTemplate):
842 with result_t.scope.activate():
843 return self._process_template(result_t.template, last_ctx)
844 return self._process_template(result_t, last_ctx)
846 def _process_raw_texts(
847 self,
848 template: Template,
849 last_ctx: ProcessContext,
850 content_ref: TemplateRef,
851 ) -> str:
852 """
853 Process the given content into a string as "raw text".
854 """
855 assert last_ctx.parent_tag in CDATA_CONTENT_ELEMENTS
856 content = resolve_text_without_recursion(
857 template, last_ctx.parent_tag, content_ref
858 )
859 if last_ctx.parent_tag == "script":
860 return self.escape_html_script(
861 content,
862 allow_markup=True,
863 )
864 elif last_ctx.parent_tag == "style":
865 return self.escape_html_style(
866 content,
867 allow_markup=True,
868 )
869 else:
870 raise NotImplementedError(
871 f"Parent tag {last_ctx.parent_tag} is not supported."
872 )
874 def _process_escapable_raw_texts(
875 self,
876 template: Template,
877 last_ctx: ProcessContext,
878 content_ref: TemplateRef,
879 ) -> str:
880 """
881 Process the given content into a string as "escapable raw text".
882 """
883 assert last_ctx.parent_tag in RCDATA_CONTENT_ELEMENTS
884 content = resolve_text_without_recursion(
885 template, last_ctx.parent_tag, content_ref
886 )
887 return self.escape_html_text(content)
889 def _process_normal_texts(
890 self, template: Template, last_ctx: ProcessContext, content_ref: TemplateRef
891 ):
892 """
893 Process the given context into a string as "normal text".
894 """
895 return "".join(
896 (
897 self.escape_html_text(part)
898 if isinstance(part, str)
899 else self._process_normal_text(template, last_ctx, t.cast(int, part))
900 )
901 for part in content_ref
902 )
904 def _process_normal_text(
905 self,
906 template: Template,
907 last_ctx: ProcessContext,
908 values_index: int,
909 ) -> str:
910 """
911 Process the value of the interpolation into a string as "normal text".
913 @NOTE: This is an interpolation that must be formatted to get the value.
914 """
915 value = format_interpolation(template.interpolations[values_index])
916 value = t.cast(NormalTextInterpolationValue, value) # ty: ignore[redundant-cast]
917 return self._process_normal_text_from_value(template, last_ctx, value)
919 def _process_normal_text_from_value(
920 self,
921 template: Template,
922 last_ctx: ProcessContext,
923 value: NormalTextInterpolationValue,
924 ) -> str:
925 """
926 Process a single value into a string as "normal text".
928 @NOTE: This is an actual value and NOT an interpolation. This is meant to be
929 used when processing an iterable of values as normal text.
930 """
931 if value is None or isinstance(value, bool):
932 return ""
933 elif isinstance(value, str):
934 # @NOTE: This would apply to Markup() but not to a custom object
935 # implementing HasHTMLDunder.
936 return self.escape_html_text(value)
937 elif isinstance(value, Template):
938 return self._process_template(value, last_ctx)
939 elif isinstance(value, Iterable):
940 return "".join(
941 self._process_normal_text_from_value(template, last_ctx, v)
942 for v in value
943 )
944 elif isinstance(value, HasHTMLDunder):
945 # @NOTE: markupsafe's escape does this for us but we put this in
946 # here for completeness.
947 # @NOTE: An actual Markup() would actually pass as a str() but a
948 # custom object with __html__ might not.
949 return Markup(value.__html__())
950 else:
951 # @DESIGN: Everything that isn't an object we recognize is
952 # coerced to a str() and emitted.
953 return self.escape_html_text(value)
956def resolve_text_without_recursion(
957 template: Template, parent_tag: str, content_ref: TemplateRef
958) -> str:
959 """
960 Resolve the text in the given template without recursing into more structured text.
962 This can be bypassed by interpolating an exact match with an object with `__html__()`.
964 A non-exact match is not allowed because we cannot process escaping
965 across the boundary between other content and the pass-through content.
966 """
967 if content_ref.is_singleton:
968 value = format_interpolation(template.interpolations[content_ref.i_indexes[0]])
969 value = t.cast(RawTextExactInterpolationValue, value) # ty: ignore[redundant-cast]
970 if value is None or isinstance(value, bool):
971 return ""
972 elif isinstance(value, str):
973 return value
974 elif isinstance(value, HasHTMLDunder):
975 # @DESIGN: We could also force callers to use `:safe` to trigger
976 # the interpolation in this special case.
977 return Markup(value.__html__())
978 elif isinstance(value, (Template, Iterable)):
979 raise ValueError(
980 f"Recursive includes are not supported within {parent_tag}"
981 )
982 else:
983 return str(value)
984 else:
985 text = []
986 for part in content_ref:
987 if isinstance(part, str):
988 if part:
989 text.append(part)
990 continue
991 value = format_interpolation(template.interpolations[part])
992 value = t.cast(RawTextInexactInterpolationValue, value) # ty: ignore[redundant-cast]
993 if value is None or isinstance(value, bool):
994 continue
995 elif (
996 type(value) is str
997 ): # type() check to avoid subclasses, probably something smarter here
998 if value:
999 text.append(value)
1000 elif not isinstance(value, str) and isinstance(value, (Template, Iterable)):
1001 raise ValueError(
1002 f"Recursive includes are not supported within {parent_tag}"
1003 )
1004 elif isinstance(value, HasHTMLDunder):
1005 raise ValueError(
1006 f"Non-exact trusted interpolations are not supported within {parent_tag}"
1007 )
1008 else:
1009 value_str = str(value)
1010 if value_str:
1011 text.append(value_str)
1012 return "".join(text)
1015def _make_default_template_processor(
1016 parser_api: ITemplateParserProxy | None = None,
1017) -> ITemplateProcessor:
1018 """
1019 Wrap our default options but allow parser api to change for testing.
1020 """
1021 return TemplateProcessor(
1022 parser_api=CachedTemplateParserProxy() if parser_api is None else parser_api,
1023 slash_void=True,
1024 uppercase_doctype=True,
1025 )
1028_default_template_processor_api: ITemplateProcessor = _make_default_template_processor()
1031# --------------------------------------------------------------------------
1032# Public API
1033# --------------------------------------------------------------------------
1036def html(template: Template, assume_ctx: ProcessContext | None = None) -> str:
1037 """Parse an HTML t-string, substitute values, and return a string of HTML."""
1038 if assume_ctx is None:
1039 assume_ctx = ProcessContext()
1040 return _default_template_processor_api.process(template, assume_ctx)
1043def svg(template: Template, assume_ctx: ProcessContext | None = None) -> str:
1044 """Parse a standalone SVG fragment and return a string of HTML.
1046 Use when the template does not contain an ``<svg>`` wrapper element.
1047 Tag and attribute case-fixing (e.g. ``clipPath``, ``viewBox``) are applied
1048 from the root, exactly as they would be inside ``html(t"<svg>...</svg>")``.
1050 When the template does contain ``<svg>``, use ``html()`` — the SVG context
1051 is detected automatically.
1052 """
1053 if assume_ctx is None:
1054 assume_ctx = ProcessContext(ns="svg")
1055 return html(template, assume_ctx=assume_ctx)