Coverage for tdom / processor.py: 98%
432 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-03 21:23 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-03 21:23 +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 .template_utils import TemplateRef
50from .utils import CachableTemplate, LastUpdatedOrderedDict
52type Attribute = tuple[str, object]
53type AttributesDict = dict[str, object]
56# --------------------------------------------------------------------------
57# Custom formatting for the processor
58# --------------------------------------------------------------------------
61def _format_safe(value: object, format_spec: str) -> str:
62 """Use Markup() to mark a value as safe HTML."""
63 assert format_spec == "safe"
64 return Markup(value)
67def _format_unsafe(value: object, format_spec: str) -> str:
68 """Convert a value to a plain string, forcing it to be treated as unsafe."""
69 assert format_spec == "unsafe"
70 return str(value)
73def _format_callback(value: Callable[..., object], format_spec: str) -> object:
74 """Execute a callback and return the value."""
75 assert format_spec == "callback"
76 return value()
79CUSTOM_FORMATTERS = (
80 ("safe", _format_safe),
81 ("unsafe", _format_unsafe),
82 ("callback", _format_callback),
83)
86def format_interpolation(interpolation: Interpolation) -> object:
87 return base_format_interpolation(
88 interpolation,
89 formatters=CUSTOM_FORMATTERS,
90 )
93# --------------------------------------------------------------------------
94# Placeholder Substitution
95# --------------------------------------------------------------------------
98def _expand_aria_attr(value: object) -> Iterable[HTMLAttribute]:
99 """Produce aria-* attributes based on the interpolated value for "aria"."""
100 if value is None:
101 return
102 elif isinstance(value, dict):
103 for sub_k, sub_v in value.items():
104 if sub_v is True:
105 yield f"aria-{sub_k}", "true"
106 elif sub_v is False:
107 yield f"aria-{sub_k}", "false"
108 elif sub_v is None:
109 yield f"aria-{sub_k}", None
110 else:
111 yield f"aria-{sub_k}", str(sub_v)
112 else:
113 raise TypeError(
114 f"Cannot use {type(value).__name__} as value for aria attribute"
115 )
118def _expand_data_attr(value: object) -> Iterable[Attribute]:
119 """Produce data-* attributes based on the interpolated value for "data"."""
120 if value is None:
121 return
122 elif isinstance(value, dict):
123 for sub_k, sub_v in value.items():
124 if sub_v is True or sub_v is False or sub_v is None:
125 yield f"data-{sub_k}", sub_v
126 else:
127 yield f"data-{sub_k}", str(sub_v)
128 else:
129 raise TypeError(
130 f"Cannot use {type(value).__name__} as value for data attribute"
131 )
134def _substitute_spread_attrs(value: object) -> Iterable[Attribute]:
135 """
136 Substitute a spread attribute based on the interpolated value.
138 A spread attribute is one where the key is a placeholder, indicating that
139 the entire attribute set should be replaced by the interpolated value.
140 The value must be a dict or iterable of key-value pairs.
141 """
142 if value is None:
143 return
144 elif isinstance(value, dict):
145 yield from value.items()
146 else:
147 raise TypeError(
148 f"Cannot use {type(value).__name__} as value for spread attributes"
149 )
152ATTR_EXPANDERS = {
153 "data": _expand_data_attr,
154 "aria": _expand_aria_attr,
155}
158def parse_style_attribute_value(style_str: str) -> list[tuple[str, str | None]]:
159 """
160 Parse the style declarations out of a style attribute string.
161 """
162 props = [p.strip() for p in style_str.split(";")]
163 styles: list[tuple[str, str | None]] = []
164 for prop in props:
165 if prop:
166 prop_parts = [p.strip() for p in prop.split(":") if p.strip()]
167 if len(prop_parts) != 2:
168 raise ValueError(
169 f"Invalid number of parts for style property {prop} in {style_str}"
170 )
171 styles.append((prop_parts[0], prop_parts[1]))
172 return styles
175def make_style_accumulator(old_value: object) -> StyleAccumulator:
176 """
177 Initialize the style accumulator.
178 """
179 match old_value:
180 case str():
181 styles = {
182 name: value for name, value in parse_style_attribute_value(old_value)
183 }
184 case True: # A bare attribute will just default to {}.
185 styles = {}
186 case _:
187 raise TypeError(f"Unexpected value: {old_value}")
188 return StyleAccumulator(styles=styles)
191@dataclass
192class StyleAccumulator:
193 styles: dict[str, str | None]
195 def merge_value(self, value: object) -> None:
196 """
197 Merge in an interpolated style value.
198 """
199 match value:
200 case str():
201 self.styles.update(
202 {name: value for name, value in parse_style_attribute_value(value)}
203 )
204 case dict():
205 self.styles.update(
206 {
207 str(pn): str(pv) if pv is not None else None
208 for pn, pv in value.items()
209 }
210 )
211 case None:
212 pass
213 case _:
214 raise TypeError(
215 f"Unknown interpolated style value {value}, use '' to omit."
216 )
218 def to_value(self) -> str | None:
219 """
220 Serialize the special style value back into a string.
222 @NOTE: If the result would be `''` then use `None` to omit the attribute.
223 """
224 style_value = "; ".join(
225 [f"{pn}: {pv}" for pn, pv in self.styles.items() if pv is not None]
226 )
227 return style_value if style_value else None
230def make_class_accumulator(old_value: object) -> ClassAccumulator:
231 """
232 Initialize the class accumulator.
233 """
234 match old_value:
235 case str():
236 toggled_classes = {cn: True for cn in old_value.split()}
237 case True:
238 toggled_classes = {}
239 case _:
240 raise ValueError(f"Unexpected value {old_value}")
241 return ClassAccumulator(toggled_classes=toggled_classes)
244@dataclass
245class ClassAccumulator:
246 toggled_classes: dict[str, bool]
248 def merge_value(self, value: object) -> None:
249 """
250 Merge in an interpolated class value.
251 """
252 if isinstance(value, dict):
253 self.toggled_classes.update(
254 {str(cn): bool(toggle) for cn, toggle in value.items()}
255 )
256 else:
257 if not isinstance(value, str) and isinstance(value, Sequence):
258 items = value[:]
259 else:
260 items = (value,)
261 for item in items:
262 match item:
263 case str():
264 self.toggled_classes.update({cn: True for cn in item.split()})
265 case None:
266 pass
267 case _:
268 if item == value:
269 raise TypeError(
270 f"Unknown interpolated class value: {value}"
271 )
272 else:
273 raise TypeError(
274 f"Unknown interpolated class item in {value}: {item}"
275 )
277 def to_value(self) -> str | None:
278 """
279 Serialize the special class value back into a string.
281 @NOTE: If the result would be `''` then use `None` to omit the attribute.
282 """
283 class_value = " ".join(
284 [cn for cn, toggle in self.toggled_classes.items() if toggle]
285 )
286 return class_value if class_value else None
289ATTR_ACCUMULATOR_MAKERS = {
290 "class": make_class_accumulator,
291 "style": make_style_accumulator,
292}
295type AttributeValueAccumulator = StyleAccumulator | ClassAccumulator
298def _resolve_t_attrs(
299 attrs: Sequence[TAttribute], interpolations: tuple[Interpolation, ...]
300) -> 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.
306 """
307 new_attrs: AttributesDict = LastUpdatedOrderedDict()
308 attr_accs: dict[str, AttributeValueAccumulator] = {}
309 for attr in attrs:
310 match attr:
311 case TLiteralAttribute(name=name, value=value):
312 attr_value = True if value is None else value
313 if name in ATTR_ACCUMULATOR_MAKERS and name in new_attrs:
314 if name not in attr_accs:
315 attr_accs[name] = ATTR_ACCUMULATOR_MAKERS[name](new_attrs[name])
316 new_attrs[name] = attr_accs[name].merge_value(attr_value)
317 else:
318 new_attrs[name] = attr_value
319 case TInterpolatedAttribute(name=name, value_i_index=i_index):
320 interpolation = interpolations[i_index]
321 attr_value = format_interpolation(interpolation)
322 if name in ATTR_ACCUMULATOR_MAKERS:
323 if name not in attr_accs:
324 attr_accs[name] = ATTR_ACCUMULATOR_MAKERS[name](
325 new_attrs.get(name, True)
326 )
327 new_attrs[name] = attr_accs[name].merge_value(attr_value)
328 elif expander := ATTR_EXPANDERS.get(name):
329 for sub_k, sub_v in expander(attr_value):
330 new_attrs[sub_k] = sub_v
331 else:
332 new_attrs[name] = attr_value
333 case TTemplatedAttribute(name=name, value_ref=ref):
334 attr_t = ref.resolve(interpolations)
335 attr_value = format_template(attr_t)
336 if name in ATTR_ACCUMULATOR_MAKERS:
337 if name not in attr_accs:
338 attr_accs[name] = ATTR_ACCUMULATOR_MAKERS[name](
339 new_attrs.get(name, True)
340 )
341 new_attrs[name] = attr_accs[name].merge_value(attr_value)
342 elif expander := ATTR_EXPANDERS.get(name):
343 raise TypeError(f"{name} attributes cannot be templated")
344 else:
345 new_attrs[name] = attr_value
346 case TSpreadAttribute(i_index=i_index):
347 interpolation = interpolations[i_index]
348 spread_value = format_interpolation(interpolation)
349 for sub_k, sub_v in _substitute_spread_attrs(spread_value):
350 if sub_k in ATTR_ACCUMULATOR_MAKERS:
351 if sub_k not in attr_accs:
352 attr_accs[sub_k] = ATTR_ACCUMULATOR_MAKERS[sub_k](
353 new_attrs.get(sub_k, True)
354 )
355 new_attrs[sub_k] = attr_accs[sub_k].merge_value(sub_v)
356 elif expander := ATTR_EXPANDERS.get(sub_k):
357 for exp_k, exp_v in expander(sub_v):
358 new_attrs[exp_k] = exp_v
359 else:
360 new_attrs[sub_k] = sub_v
361 case _:
362 raise ValueError(f"Unknown TAttribute type: {type(attr).__name__}")
363 for acc_name, acc in attr_accs.items():
364 new_attrs[acc_name] = acc.to_value()
365 return new_attrs
368def _resolve_html_attrs(attrs: AttributesDict) -> Iterable[HTMLAttribute]:
369 """Resolve attribute values for HTML output."""
370 for key, value in attrs.items():
371 match value:
372 case True:
373 yield key, None
374 case False | None:
375 pass
376 case _:
377 yield key, str(value)
380def _kebab_to_snake(name: str) -> str:
381 """Convert a kebab-case name to snake_case."""
382 return name.replace("-", "_").lower()
385def _prep_component_kwargs(
386 callable_info: CallableInfo,
387 attrs: AttributesDict,
388 children: Template,
389 provided_attrs: tuple[Attribute, ...] = (),
390 raise_on_requires_positional=True,
391 raise_on_missing=True,
392) -> AttributesDict:
393 """
394 Matchup kwargs from multiple sources to target the given callable.
396 `provided_attrs`:
397 These can be used by extensions that want to provide
398 attrs even if they are not specified in the component's `attrs` in
399 the template. If an attribute with the same name is provided in
400 `attrs` then it takes priority over entries in `provided_attrs`.
401 @NOTE: These will be injected into any component with `**kwargs`
402 in their signature unless provided already by `attrs`.
404 `raise_on_requires_positional`:
405 Optionally check and raise `TypeError` if the `callable_info` requires
406 positional arguments which we cannot fulfill normally.
407 An exception might not be desired if the caller will finish preparing
408 the arguments after this call.
410 `raise_on_missing`:
411 Optionally check and raise `TypeError` if we are not able to fulfill all
412 the arguments the `callable_info` expects since in the common case this
413 raise an exception whose cause might not be clear.
414 An exception might not be desired if the caller will finish preparing
415 the arguments after this call.
416 """
418 # We can't know what kwarg to put here...
419 if raise_on_requires_positional and callable_info.requires_positional:
420 raise TypeError(
421 "Component callables cannot have required positional arguments."
422 )
424 kwargs: AttributesDict = {}
426 # Add all supported attributes
427 for attr_name, attr_value in attrs.items():
428 snake_name = _kebab_to_snake(attr_name)
429 if snake_name in callable_info.named_params or callable_info.kwargs:
430 kwargs[snake_name] = attr_value
432 if "children" in callable_info.named_params or callable_info.kwargs:
433 kwargs["children"] = children
435 # Add in provided attrs if they haven't been set already and are wanted.
436 for pattr_name, pattr_value in provided_attrs:
437 if pattr_name not in kwargs and (
438 pattr_name in callable_info.named_params or callable_info.kwargs
439 ):
440 kwargs[pattr_name] = pattr_value
442 # Check to make sure we've fully satisfied the callable's requirements
443 if raise_on_missing:
444 missing = callable_info.required_named_params - kwargs.keys()
445 if missing:
446 raise TypeError(
447 f"Missing required parameters for component: {', '.join(missing)}"
448 )
450 return kwargs
453def serialize_html_attrs(
454 html_attrs: Iterable[HTMLAttribute], escape: Callable = default_escape_html_text
455) -> str:
456 return "".join(
457 (f' {k}="{escape(v)}"' if v is not None else f" {k}" for k, v in html_attrs)
458 )
461def _fix_svg_attrs(html_attrs: Iterable[HTMLAttribute]) -> Iterable[HTMLAttribute]:
462 """
463 Fix the attr name-case of any html attributes on a tag within an SVG namespace.
464 """
465 for k, v in html_attrs:
466 yield SVG_ATTR_FIX.get(k, k), v
469@dataclass(frozen=True, slots=True)
470class ProcessContext:
471 parent_tag: str = DEFAULT_NORMAL_TEXT_ELEMENT
472 ns: str = "html"
474 def copy(
475 self,
476 ns: str | None = None,
477 parent_tag: str | None = None,
478 ) -> ProcessContext:
479 return ProcessContext(
480 parent_tag=parent_tag if parent_tag is not None else self.parent_tag,
481 ns=ns if ns is not None else self.ns,
482 )
485type FunctionComponent = Callable[..., Template]
486type FactoryComponent = Callable[..., ComponentObject]
487type ComponentCallable = FunctionComponent | FactoryComponent
488type ComponentObject = Callable[[], Template]
491type NormalTextInterpolationValue = (
492 None
493 | bool # to support `showValue and value` idiom
494 | str
495 | HasHTMLDunder
496 | Template
497 | Iterable[NormalTextInterpolationValue]
498 | object
499)
500# Applies to both escapable raw text and raw text.
501type RawTextExactInterpolationValue = (
502 None
503 | bool # to support `showValue and value` idiom
504 | str
505 | HasHTMLDunder
506 | object
507)
508# Applies to both escapable raw text and raw text.
509type RawTextInexactInterpolationValue = (
510 None
511 | bool # to support `showValue and value` idiom
512 | str
513 | object
514)
517class ITemplateParserProxy(t.Protocol):
518 def to_tnode(self, template: Template) -> TNode: ...
521@dataclass(frozen=True)
522class TemplateParserProxy(ITemplateParserProxy):
523 def to_tnode(self, template: Template) -> TNode:
524 return TemplateParser.parse(template)
527@dataclass(frozen=True)
528class CachedTemplateParserProxy(TemplateParserProxy):
529 @lru_cache(512) # noqa: B019
530 def _to_tnode(self, ct: CachableTemplate) -> TNode:
531 return super().to_tnode(ct.template)
533 def to_tnode(self, template: Template) -> TNode:
534 return self._to_tnode(CachableTemplate(template))
537class IComponentProcessor(t.Protocol):
538 """Isolate component processing to allow for replacement."""
540 def process(
541 self,
542 template: Template,
543 last_ctx: ProcessContext,
544 component_callable: t.Annotated[object, ComponentCallable],
545 attrs: tuple[TAttribute, ...],
546 component_template: Template,
547 provided_attrs: tuple[Attribute, ...] = (),
548 ) -> Template:
549 """
550 Process available component details into a Template.
551 """
552 ...
555class ComponentProcessor(IComponentProcessor):
556 """
557 Default component processor.
558 """
560 def process(
561 self,
562 template: Template,
563 last_ctx: ProcessContext,
564 component_callable: t.Annotated[object, ComponentCallable],
565 attrs: tuple[TAttribute, ...],
566 component_template: Template,
567 provided_attrs: tuple[Attribute, ...] = (),
568 ) -> Template:
569 """
570 Process available component details into a Template.
572 There are two general "styles" supported:
574 1. FunctionComponent
576 Calling `component_callable` with the prepared kwargs should
577 return a `Template`.
579 The primary purpose of this style is to support
580 using a normal function as a component.
582 2. FactoryComponent
584 Calling `component_callable` with the prepared kwargs should
585 return another `Callable` which when called with no arguments should
586 return a `Template`.
588 The primary purpose of this style is to support
589 using a `dataclass` with `def __call__(self) -> Template` as a
590 component.
591 """
592 if not callable(component_callable):
593 raise TypeError(
594 f"Component callable must be callable: {type(component_callable)}"
595 )
596 kwargs = _prep_component_kwargs(
597 get_callable_info(component_callable),
598 _resolve_t_attrs(attrs, template.interpolations),
599 children=component_template,
600 provided_attrs=provided_attrs,
601 raise_on_requires_positional=True,
602 raise_on_missing=True,
603 )
604 res1 = component_callable(**kwargs) # ty: ignore[call-top-callable]
605 if isinstance(res1, Template):
606 return res1
607 elif callable(res1):
608 res2 = res1() # ty: ignore[call-top-callable]
609 if isinstance(res2, Template):
610 return res2
611 else:
612 raise TypeError(
613 f"Component object must return Template when called: {type(res2)}"
614 )
615 else:
616 raise TypeError(
617 f"Component callable must return Template or Callable: {type(res1)}"
618 )
621class ITemplateProcessor(t.Protocol):
622 def process(self, root_template: Template, assume_ctx: ProcessContext) -> str: ...
625@dataclass(frozen=True)
626class TemplateProcessor(ITemplateProcessor):
627 parser_api: ITemplateParserProxy = field(default_factory=CachedTemplateParserProxy)
629 component_processor_api: IComponentProcessor = field(
630 default_factory=ComponentProcessor
631 )
633 escape_html_text: Callable = default_escape_html_text
635 escape_html_comment: Callable = default_escape_html_comment
637 escape_html_script: Callable = default_escape_html_script
639 escape_html_style: Callable = default_escape_html_style
641 slash_void: bool = False # Apply a xhtml-style slash to void html elements.
643 uppercase_doctype: bool = False # DOCTYPE vs doctype
645 def process(
646 self,
647 root_template: Template,
648 assume_ctx: ProcessContext,
649 ) -> str:
650 """
651 Process a TDOM compatible template into a string.
652 """
653 return self._process_template(root_template, assume_ctx)
655 def _process_template(self, template: Template, last_ctx: ProcessContext) -> str:
656 root = self.parser_api.to_tnode(template)
657 return self._process_tnode(template, last_ctx, root)
659 def _process_tnode(
660 self, template: Template, last_ctx: ProcessContext, tnode: TNode
661 ) -> str:
662 """
663 Process a tnode from a template's "t-tree" into a string.
664 """
665 match tnode:
666 case TDocumentType(text):
667 return self._process_document_type(last_ctx, text)
668 case TComment(ref):
669 return self._process_comment(template, last_ctx, ref)
670 case TFragment(children):
671 return self._process_fragment(template, last_ctx, children)
672 case TComponent(start_i_index, end_i_index, attrs, children):
673 return self._process_component(
674 template, last_ctx, attrs, start_i_index, end_i_index
675 )
676 case TElement(tag, attrs, children):
677 return self._process_element(template, last_ctx, tag, attrs, children)
678 case TText(ref):
679 return self._process_texts(template, last_ctx, ref)
680 case _:
681 raise ValueError(f"Unrecognized tnode: {tnode}")
683 def _process_document_type(
684 self,
685 last_ctx: ProcessContext,
686 text: str,
687 ) -> str:
688 if last_ctx.ns != "html":
689 # Nit
690 raise ValueError(
691 "Cannot process document type in subtree of a foreign element."
692 )
693 if self.uppercase_doctype:
694 return f"<!DOCTYPE {text}>"
695 else:
696 return f"<!doctype {text}>"
698 def _process_fragment(
699 self,
700 template: Template,
701 last_ctx: ProcessContext,
702 children: Iterable[TNode],
703 ) -> str:
704 return "".join(
705 self._process_tnode(template, last_ctx, child) for child in children
706 )
708 def _process_texts(
709 self,
710 template: Template,
711 last_ctx: ProcessContext,
712 ref: TemplateRef,
713 ) -> str:
714 if last_ctx.parent_tag in CDATA_CONTENT_ELEMENTS:
715 # Must be handled all at once.
716 return self._process_raw_texts(template, last_ctx, ref)
717 elif last_ctx.parent_tag in RCDATA_CONTENT_ELEMENTS:
718 # We can handle all at once because there are no non-text children and everything must be string-ified.
719 return self._process_escapable_raw_texts(template, last_ctx, ref)
720 else:
721 return self._process_normal_texts(template, last_ctx, ref)
723 def _process_comment(
724 self,
725 template: Template,
726 last_ctx: ProcessContext,
727 content_ref: TemplateRef,
728 ) -> str:
729 """
730 Process a comment into a string.
731 """
732 content_str = resolve_text_without_recursion(template, "<!--", content_ref)
733 escaped_comment_str = self.escape_html_comment(content_str, allow_markup=True)
734 return f"<!--{escaped_comment_str}-->"
736 def _process_element(
737 self,
738 template: Template,
739 last_ctx: ProcessContext,
740 tag: str,
741 attrs: tuple[TAttribute, ...],
742 children: tuple[TNode, ...],
743 ) -> str:
744 out: list[str] = []
745 if tag == "svg":
746 our_ctx = last_ctx.copy(parent_tag=tag, ns="svg")
747 elif tag == "math":
748 our_ctx = last_ctx.copy(parent_tag=tag, ns="math")
749 else:
750 our_ctx = last_ctx.copy(parent_tag=tag)
751 if our_ctx.ns == "svg":
752 starttag = endtag = SVG_TAG_FIX.get(tag, tag)
753 else:
754 starttag = endtag = tag
755 out.append(f"<{starttag}")
756 if attrs:
757 out.append(self._process_attrs(template, our_ctx, attrs))
758 # @TODO: How can we tell if we write out children or not in
759 # order to self-close in non-html contexts, ie. SVG?
760 if self.slash_void and tag in VOID_ELEMENTS:
761 out.append(" />")
762 else:
763 out.append(">")
764 if tag not in VOID_ELEMENTS:
765 # We were still in SVG but now we default back into HTML
766 if tag == "foreignobject":
767 child_ctx = our_ctx.copy(ns="html")
768 else:
769 child_ctx = our_ctx
770 out.extend(
771 self._process_tnode(template, child_ctx, child) for child in children
772 )
773 out.append(f"</{endtag}>")
774 return "".join(out)
776 def _process_attrs(
777 self,
778 template: Template,
779 last_ctx: ProcessContext,
780 attrs: tuple[TAttribute, ...],
781 ) -> str:
782 """
783 Process an element's attributes into a string.
784 """
785 resolved_attrs = _resolve_t_attrs(attrs, template.interpolations)
786 if last_ctx.ns == "svg":
787 attrs_str = serialize_html_attrs(
788 _fix_svg_attrs(_resolve_html_attrs(resolved_attrs))
789 )
790 else:
791 attrs_str = serialize_html_attrs(_resolve_html_attrs(resolved_attrs))
792 if attrs_str:
793 return attrs_str
794 return ""
796 def _extract_component_template(
797 self,
798 template: Template,
799 attrs: tuple[TAttribute, ...],
800 start_i_index: int,
801 end_i_index: int | None,
802 check_callables: bool = True,
803 ) -> Template:
804 body_start_s_index = (
805 start_i_index
806 + 1
807 + len([1 for attr in attrs if not isinstance(attr, TLiteralAttribute)])
808 )
809 if start_i_index != end_i_index and end_i_index is not None:
810 # @TODO: We should do this during parsing.
811 if (
812 check_callables
813 and template.interpolations[start_i_index].value
814 != template.interpolations[end_i_index].value
815 ):
816 raise TypeError(
817 "Component callable in start tag must match component callable in end tag."
818 )
819 return extract_embedded_template(template, body_start_s_index, end_i_index)
820 else:
821 return t""
823 def _process_component(
824 self,
825 template: Template,
826 last_ctx: ProcessContext,
827 attrs: tuple[TAttribute, ...],
828 start_i_index: int,
829 end_i_index: int | None,
830 ) -> str:
831 """
832 Invoke a component and process the result into a string.
833 """
834 children_template = self._extract_component_template(
835 template, attrs, start_i_index, end_i_index, check_callables=True
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 return self._process_template(result_t, last_ctx)
843 def _process_raw_texts(
844 self,
845 template: Template,
846 last_ctx: ProcessContext,
847 content_ref: TemplateRef,
848 ) -> str:
849 """
850 Process the given content into a string as "raw text".
851 """
852 assert last_ctx.parent_tag in CDATA_CONTENT_ELEMENTS
853 content = resolve_text_without_recursion(
854 template, last_ctx.parent_tag, content_ref
855 )
856 if last_ctx.parent_tag == "script":
857 return self.escape_html_script(
858 content,
859 allow_markup=True,
860 )
861 elif last_ctx.parent_tag == "style":
862 return self.escape_html_style(
863 content,
864 allow_markup=True,
865 )
866 else:
867 raise NotImplementedError(
868 f"Parent tag {last_ctx.parent_tag} is not supported."
869 )
871 def _process_escapable_raw_texts(
872 self,
873 template: Template,
874 last_ctx: ProcessContext,
875 content_ref: TemplateRef,
876 ) -> str:
877 """
878 Process the given content into a string as "escapable raw text".
879 """
880 assert last_ctx.parent_tag in RCDATA_CONTENT_ELEMENTS
881 content = resolve_text_without_recursion(
882 template, last_ctx.parent_tag, content_ref
883 )
884 return self.escape_html_text(content)
886 def _process_normal_texts(
887 self, template: Template, last_ctx: ProcessContext, content_ref: TemplateRef
888 ):
889 """
890 Process the given context into a string as "normal text".
891 """
892 return "".join(
893 (
894 self.escape_html_text(part)
895 if isinstance(part, str)
896 else self._process_normal_text(template, last_ctx, t.cast(int, part))
897 )
898 for part in content_ref
899 )
901 def _process_normal_text(
902 self,
903 template: Template,
904 last_ctx: ProcessContext,
905 values_index: int,
906 ) -> str:
907 """
908 Process the value of the interpolation into a string as "normal text".
910 @NOTE: This is an interpolation that must be formatted to get the value.
911 """
912 value = format_interpolation(template.interpolations[values_index])
913 value = t.cast(NormalTextInterpolationValue, value) # ty: ignore[redundant-cast]
914 return self._process_normal_text_from_value(template, last_ctx, value)
916 def _process_normal_text_from_value(
917 self,
918 template: Template,
919 last_ctx: ProcessContext,
920 value: NormalTextInterpolationValue,
921 ) -> str:
922 """
923 Process a single value into a string as "normal text".
925 @NOTE: This is an actual value and NOT an interpolation. This is meant to be
926 used when processing an iterable of values as normal text.
927 """
928 if value is None or isinstance(value, bool):
929 return ""
930 elif isinstance(value, str):
931 # @NOTE: This would apply to Markup() but not to a custom object
932 # implementing HasHTMLDunder.
933 return self.escape_html_text(value)
934 elif isinstance(value, Template):
935 return self._process_template(value, last_ctx)
936 elif isinstance(value, Iterable):
937 return "".join(
938 self._process_normal_text_from_value(template, last_ctx, v)
939 for v in value
940 )
941 elif isinstance(value, HasHTMLDunder):
942 # @NOTE: markupsafe's escape does this for us but we put this in
943 # here for completeness.
944 # @NOTE: An actual Markup() would actually pass as a str() but a
945 # custom object with __html__ might not.
946 return Markup(value.__html__())
947 else:
948 # @DESIGN: Everything that isn't an object we recognize is
949 # coerced to a str() and emitted.
950 return self.escape_html_text(value)
953def resolve_text_without_recursion(
954 template: Template, parent_tag: str, content_ref: TemplateRef
955) -> str:
956 """
957 Resolve the text in the given template without recursing into more structured text.
959 This can be bypassed by interpolating an exact match with an object with `__html__()`.
961 A non-exact match is not allowed because we cannot process escaping
962 across the boundary between other content and the pass-through content.
963 """
964 if content_ref.is_singleton:
965 value = format_interpolation(template.interpolations[content_ref.i_indexes[0]])
966 value = t.cast(RawTextExactInterpolationValue, value) # ty: ignore[redundant-cast]
967 if value is None or isinstance(value, bool):
968 return ""
969 elif isinstance(value, str):
970 return value
971 elif isinstance(value, HasHTMLDunder):
972 # @DESIGN: We could also force callers to use `:safe` to trigger
973 # the interpolation in this special case.
974 return Markup(value.__html__())
975 elif isinstance(value, (Template, Iterable)):
976 raise ValueError(
977 f"Recursive includes are not supported within {parent_tag}"
978 )
979 else:
980 return str(value)
981 else:
982 text = []
983 for part in content_ref:
984 if isinstance(part, str):
985 if part:
986 text.append(part)
987 continue
988 value = format_interpolation(template.interpolations[part])
989 value = t.cast(RawTextInexactInterpolationValue, value) # ty: ignore[redundant-cast]
990 if value is None or isinstance(value, bool):
991 continue
992 elif (
993 type(value) is str
994 ): # type() check to avoid subclasses, probably something smarter here
995 if value:
996 text.append(value)
997 elif not isinstance(value, str) and isinstance(value, (Template, Iterable)):
998 raise ValueError(
999 f"Recursive includes are not supported within {parent_tag}"
1000 )
1001 elif isinstance(value, HasHTMLDunder):
1002 raise ValueError(
1003 f"Non-exact trusted interpolations are not supported within {parent_tag}"
1004 )
1005 else:
1006 value_str = str(value)
1007 if value_str:
1008 text.append(value_str)
1009 return "".join(text)
1012def extract_embedded_template(
1013 template: Template, body_start_s_index: int, end_i_index: int
1014) -> Template:
1015 """
1016 Extract the template parts exclusively from start tag to end tag.
1018 Note that interpolations INSIDE the start tag make this more complex
1019 than just "the `s_index` after the component callable's `i_index`".
1021 Example:
1022 ```python
1023 template = (
1024 t'<{comp} attr={attr}>'
1025 t'<div>{content} <span>{footer}</span></div>'
1026 t'</{comp}>'
1027 )
1028 assert extract_children_template(template, 2, 4) == (
1029 t'<div>{content} <span>{footer}</span></div>'
1030 )
1031 starttag = t'<{comp} attr={attr}>'
1032 endtag = t'</{comp}>'
1033 assert template == starttag + extract_children_template(template, 2, 4) + endtag
1034 ```
1035 @DESIGN: "There must be a better way."
1036 """
1037 # Copy the parts out of the containing template.
1038 index = body_start_s_index
1039 last_s_index = end_i_index
1040 parts = []
1041 while index <= last_s_index:
1042 parts.append(template.strings[index])
1043 if index != last_s_index:
1044 parts.append(template.interpolations[index])
1045 index += 1
1046 # Now trim the first part to the end of the opening tag.
1047 parts[0] = parts[0][parts[0].find(">") + 1 :]
1048 # Now trim the last part (could also be the first) to the start of the closing tag.
1049 parts[-1] = parts[-1][: parts[-1].rfind("<")]
1050 return Template(*parts)
1053def _make_default_template_processor(
1054 parser_api: ITemplateParserProxy | None = None,
1055) -> ITemplateProcessor:
1056 """
1057 Wrap our default options but allow parser api to change for testing.
1058 """
1059 return TemplateProcessor(
1060 parser_api=CachedTemplateParserProxy() if parser_api is None else parser_api,
1061 slash_void=True,
1062 uppercase_doctype=True,
1063 )
1066_default_template_processor_api: ITemplateProcessor = _make_default_template_processor()
1069# --------------------------------------------------------------------------
1070# Public API
1071# --------------------------------------------------------------------------
1074def html(template: Template, assume_ctx: ProcessContext | None = None) -> str:
1075 """Parse an HTML t-string, substitute values, and return a string of HTML."""
1076 if assume_ctx is None:
1077 assume_ctx = ProcessContext()
1078 return _default_template_processor_api.process(template, assume_ctx)
1081def svg(template: Template, assume_ctx: ProcessContext | None = None) -> str:
1082 """Parse a standalone SVG fragment and return a string of HTML.
1084 Use when the template does not contain an ``<svg>`` wrapper element.
1085 Tag and attribute case-fixing (e.g. ``clipPath``, ``viewBox``) are applied
1086 from the root, exactly as they would be inside ``html(t"<svg>...</svg>")``.
1088 When the template does contain ``<svg>``, use ``html()`` — the SVG context
1089 is detected automatically.
1090 """
1091 if assume_ctx is None:
1092 assume_ctx = ProcessContext(ns="svg")
1093 return html(template, assume_ctx=assume_ctx)