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