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