From 5a5e34e5e671f54f31c22dc3c9d7432b10fde18e Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Tue, 25 Nov 2025 23:55:18 -0800 Subject: [PATCH 01/81] Transformer prototype. --- tdom/transformer.py | 446 +++++++++++++++++++++++++++++++++++++++ tdom/transformer_test.py | 18 ++ 2 files changed, 464 insertions(+) create mode 100644 tdom/transformer.py create mode 100644 tdom/transformer_test.py diff --git a/tdom/transformer.py b/tdom/transformer.py new file mode 100644 index 0000000..b7d8dfc --- /dev/null +++ b/tdom/transformer.py @@ -0,0 +1,446 @@ +from dataclasses import dataclass, field +from string.templatelib import Interpolation, Template +from io import StringIO, BufferedWriter +from typing import Callable +from collections.abc import Iterable +import typing as t +from collections import deque + +from .parser import parse_html +from .nodes import ( + TNode, + TFragment, + TElement, + TText, + DocumentType, + TComment, + ComponentInfo, + VOID_ELEMENTS, + CDATA_CONTENT_ELEMENTS, + RCDATA_CONTENT_ELEMENTS, + ) +from .escaping import ( + escape_html_content_in_tag as default_escape_html_content_in_tag, + escape_html_text as default_escape_html_text, + escape_html_script as default_escape_html_script, + escape_html_comment as default_escape_html_comment, + ) +from .processor import LastUpdatedOrderedDict + + +@t.runtime_checkable +class HasHTMLDunder(t.Protocol): + def __html__(self) -> str: ... # pragma: no cover + + +@dataclass +class EndTag: + end_tag: str + + +@dataclass +class TransformService: + """ + Turn a structure node tree into an optimized Template that can be quickly interpolated into a string. + + - Tag attributes with any interpolations are replaced with a single interpolation. + - Component invocations are replaced with a single interpolation. + - Runs of strings are consolidated around or in between interpolations. + If there are no strings provided to build a proper template then empty strings are injected. + """ + + escape_html_text: Callable = default_escape_html_text + + slash_void: bool = False # Apply a xhtml-style slash to void html elements. + + def transform_template(self, values_template: Template) -> Template: + """ Transform the given template into a template for rendering. """ + struct_node = self.to_struct_node(values_template) + return self.to_struct_template(struct_node) + + def to_struct_node(self, values_template: Template) -> Node: + return parse_html(values_template) + + def to_struct_template(self, struct_node: Node) -> Template: + """ Recombine stream of tokens from node trees into a new template. """ + return Template(*self.streamer(struct_node)) + + def component_streamer(self, component_node: Component, last_container_tag: str|None=None) -> Generator[str|Interpolation, ...]: + """ Stream the component contents but not the component itself. """ + yield from self.streamer(TFragment(component_node.children), last_container_tag=last_container_tag) + + def _stream_comment_interpolation(self, template): + return Interpolation(('' + case TFragment(children): + q.extend([(last_container_tag, child) for child in reversed(children)]) + case TElement(tag, attrs, children, component_info): + match component_info: + case None: + yield f'<{tag}' + if self.has_dynamic_attrs(attrs): + yield self._stream_attrs_interpolation(attrs) + else: + yield self.static_attrs_to_str(attrs) + # This is just a want to have. + if self.slash_void and tag in VOID_ELEMENTS: + yield ' />' + else: + yield '>' + if tag not in VOID_ELEMENTS: + q.append((last_container_tag, EndTag(f''))) + q.extend([(tag, child) for child in reversed(children)]) + case ComponentInfo() as comp_info: + yield self._stream_component_interpolation(last_container_tag, attrs, comp_info) + case TText(text_t): + if last_container_tag in CDATA_CONTENT_ELEMENTS: + # Must be handled all at once. + yield self._stream_raw_text_interpolation(last_container_tag, text_t) + elif last_container_tag in RCDATA_CONTENT_ELEMENTS: + # We can handle all at once because there are no non-text children and everything must be string-ified. + yield self._stream_escapable_raw_text_interpolation(last_container_tag, text_t) + else: + # Flatten the template back out into the stream because each interpolation can + # be escaped as is and structured content can be injected between text anyways. + for part in text_t: + if isinstance(part, str): + yield part + else: + yield self._stream_text_interpolation(last_container_tag, part.value) + case _: + raise ValueError(f'Unrecognized tnode: {tnode}') + + def static_attrs_to_str(self, attrs: tuple[tuple[str | int, ...], ...]) -> str: + return ''.join(f' {attr[0]}' if len(attr) == 1 else f' {attr[0]}="{self.escape_html_text(attr[1])}"' for attr in attrs) + + def has_dynamic_attrs(self, attrs: tuple[tuple[str|int, ...], ...]) -> bool: + for attr in attrs: + match attr: + case [str()] | [str(), str()]: + continue + case _: + return True + return False + + def extract_embedded_template(self, template: Template, start_index: int, end_index: int): + """ + Extract the template parts exclusively from start tag to end tag. + + @TODO: "There must be a better way." + """ + assert end_index is not None and start_index <= end_index + # Copy the parts out of the containing template. + index = start_index + parts = [] + while index < end_index: + parts.append(template.strings[index]) + if index < end_index - 1: + parts.append(template.interpolations[index]) + index += 1 + # Now trim the first part to the end of the opening tag. + parts[0] = parts[0][parts[0].find('>')+1:] + # Now trim the last part (could also be the first) to the start of the closing tag. + parts[-1] = parts[-1][:parts[-1].rfind('<')] + return Template(*parts) + + +@dataclass +class RenderService: + + transform_api: TransformService + + escape_html_text: Callable = default_escape_html_text + + escape_html_comment: Callable = default_escape_html_comment + + escape_html_content_in_tag: Callable = default_escape_html_content_in_tag + + def render_template(self, template, struct_cache=None, last_container_tag=None): + """ + Iterate left to right and pause and push new iterators when descending depth-first. + + Every interpolation becomes an iterator. + + Every iterator could return more iterators. + + The last container tag is used to determine how to handle + text processing. When working with fragments we might not know the + container tag until the fragment is included at render-time. + """ + if struct_cache is None: + struct_cache = {} + + bf = StringIO() + + # Each item should contain (container_tag, contents_iterator). + dq: Deque[tuple[ + str|None, + Iterator[ + tuple[ + tuple[ + str|None, + Template|None], + object]]]] = deque() + dq.append((last_container_tag, iter(self.walk_template(template, self.process_template(template, struct_cache))))) + while dq: + last_container_tag, it = dq.pop() + for (chunk_type, template), chunk in it: + if chunk_type is None: + bf.write(chunk) # string in t-string + continue + match chunk_type: + case 'html_normal_interpolation'|'user_html_content': + if chunk_type == 'html_normal_interpolation': + container_tag, ip_index = chunk + value = template.interpolations[ip_index].value # data provided to a parsed t-string + if not container_tag: + container_tag = last_container_tag + else: + value = chunk # directly from interpolated iterable + container_tag = last_container_tag + match value: + case Template(): + dq.append((last_container_tag, it)) + # How do we know to process a template this way? + dq.append((container_tag, iter(self.walk_template(value, self.process_template(value, struct_cache))))) + break + case str(): + if container_tag not in ('style', 'script', 'title', 'textarea', '' case TFragment(children): q.extend([(last_container_tag, child) for child in reversed(children)]) - case TElement(tag, attrs, children, component_info): - if component_info is None: - yield f'<{tag}' - if self.has_dynamic_attrs(attrs): - yield self._stream_attrs_interpolation(tag, attrs) - else: - yield self.static_attrs_to_str(attrs) - # This is just a want to have. - if self.slash_void and tag in VOID_ELEMENTS: - yield ' />' - else: - yield '>' - if tag not in VOID_ELEMENTS: - q.append((last_container_tag, EndTag(f''))) - q.extend([(tag, child) for child in reversed(children)]) + case TComponent(start_i_index, end_i_index, attrs, children): + yield self._stream_component_interpolation(last_container_tag, attrs, start_i_index, end_i_index) + case TElement(tag, attrs, children): + yield f'<{tag}' + if self.has_dynamic_attrs(attrs): + yield self._stream_attrs_interpolation(tag, attrs) + else: + # @TODO: Probably find a less risky way to do this. + yield ''.join((f'{k}={default_escape_html_text(v)}' if v is not None else f'{k}' for k, v in _resolve_t_attrs(attrs, ()).items() if v is not False and v is not None)) + # This is just a want to have. + if self.slash_void and tag in VOID_ELEMENTS: + yield ' />' else: - yield self._stream_component_interpolation(last_container_tag, attrs, component_info) - case TText(text_t): + yield '>' + if tag not in VOID_ELEMENTS: + q.append((last_container_tag, EndTag(f''))) + q.extend([(tag, child) for child in reversed(children)]) + case TText(ref): + text_t = Template(*[part if isinstance(part, str) else Interpolation(part, '', None, '') for part in iter(ref)]) if last_container_tag in CDATA_CONTENT_ELEMENTS: # Must be handled all at once. yield self._stream_raw_text_interpolation(last_container_tag, text_t) @@ -277,31 +286,25 @@ def streamer(self, root: TNode, last_container_tag: str|None=None) -> Generator[ case _: raise ValueError(f'Unrecognized tnode: {tnode}') - def static_attrs_to_str(self, attrs: tuple[tuple[str | int, ...], ...]) -> str: - return ''.join(f' {attr[0]}' if len(attr) == 1 else f' {attr[0]}="{self.escape_html_text(attr[1])}"' for attr in attrs) - - def has_dynamic_attrs(self, attrs: tuple[tuple[str|int, ...], ...]) -> bool: + def has_dynamic_attrs(self, attrs: t.Sequence[TAttribute]) -> bool: for attr in attrs: - match attr: - case [str()] | [str(), str()]: - continue - case _: - return True + if not isinstance(attr, TLiteralAttribute): + return True return False - def extract_embedded_template(self, template: Template, start_index: int, end_index: int): + def extract_embedded_template(self, template: Template, body_start_s_index: int, end_i_index: int): """ Extract the template parts exclusively from start tag to end tag. @TODO: "There must be a better way." """ - assert end_index is not None and start_index <= end_index # Copy the parts out of the containing template. - index = start_index + index = body_start_s_index + last_s_index = end_i_index parts = [] - while index < end_index: + while index <= last_s_index: parts.append(template.strings[index]) - if index < end_index - 1: + if index != last_s_index: parts.append(template.interpolations[index]) index += 1 # Now trim the first part to the end of the opening tag. @@ -396,98 +399,9 @@ def process_template(self, template, struct_cache): struct_cache[template.strings] = self.transform_api.transform_template(template) return struct_cache[template.strings] - def interpolate_attrs(self, attrs, template) -> Generator[tuple[str, object|None]]: + def interpolate_attrs(self, attrs, template) -> AttributesDict: """ Plug `template` values into any attribute interpolations. """ - for attr in attrs: - match attr: - case [str()]: - yield (attr[0], True) - case [str(), str()]: - yield (attr[0], attr[1]) - case [str(), int()]: - yield (attr[0], template.interpolations[attr[1]].value) - case [str(), str()|int(), _, *_]: - v_str = ''.join(part if isinstance(part, str) else str(template.interpolations[part.value].value) for part in attr[1:]) - yield (attr[0], v_str) - case [int()]: - spread_attrs = template.interpolations[attr[0]].value - yield from spread_attrs.items() if hasattr(spread_attrs, 'items') else spread_attrs - case _: - raise ValueError(f'Unrecognized attr format {attr}') - - def resolve_attrs(self, attrs) -> dict[str, object|None]: - # @TODO: This should be using the processor to resolve these. - new_attrs = LastUpdatedOrderedDict() - klass = {} - for k, v in attrs: - match k: - case 'class': - # Special cases to allow unsetting all classes. Do we really need all over these? - if v is True: - new_attrs['class'] = v - klass.clear() - elif v is None: - new_attrs['class'] = None - klass.clear() - elif v == '': - new_attrs['class'] = '' - klass.clear() - else: - q = [v] - changes = {} - while q: - sub_v = q.pop() - match sub_v: - case str(): - if ' ' not in sub_v: - changes[sub_v] = True - else: - for cn in sub_v.split(): - changes[cn] = True - case dict(): - for cn, enabled in sub_v.items(): - changes[cn] = enabled - case Iterable(): - q.extend(reversed(sub_v)) - case None|False: - pass - case _: - raise ValueError(f'Unrecognized format for class attribute: {sub_v}') - if changes: - klass.update(changes) - class_str = ' '.join(cn for cn, enabled in klass.items() if enabled) - if class_str != new_attrs.get('class', None): - new_attrs['class'] = class_str - case 'style': - match v: - case None: - new_attrs['style'] = None - case str(): - new_attrs['style'] = v - case Iterable(): - new_attrs['style'] = '; '.join(f'{pn}: {pv}' for pn, pv in (v.items() if hasattr(v, 'items') else v)) - case _: - raise ValueError(f'Unrecognized format for style attribute: {v}') - case 'aria': - for an, av in (v.items() if hasattr(v, 'items') else v): - full_name = f'aria-{an}' - match av: - case True: - new_attrs[full_name] = 'true' - case False: - new_attrs[full_name] = 'false' - case None: - new_attrs[full_name] = None - case str(): - new_attrs[full_name] = av - case _: - new_attrs[full_name] = str(av) - case 'data': - for dn, dv in (v.items() if hasattr(v, 'items') else v): - new_attrs[f'data-{dn}'] = dv - case _: - new_attrs[k] = v - return new_attrs + return _resolve_t_attrs(attrs, template.interpolations) def walk_template_with_context(self, bf, template, struct_t, context_values=None): if context_values: @@ -512,19 +426,17 @@ def walk_template(self, bf, template, struct_t): if strings[idx]: bf.append(strings[idx]) -from contextlib import nullcontext -from contextvars import ContextVar, Token + class ContextVarSetter: - context_values: tuple[tuple[ContextVar, object]] - tokens: tuple[Token] | None = None + context_values: tuple[tuple[ContextVar, object],...] + tokens: tuple[Token,...] - def __init__(self, context_values=None): + def __init__(self, context_values=()): self.context_values = context_values + self.tokens = () def __enter__(self): self.tokens = tuple([var.set(val) for var, val in self.context_values]) - print (f'{[var.get() for var, _ in self.context_values]}') - print (f'{self.tokens=}') def __exit__(self, exc_type, exc_value, traceback): for idx, var_value in enumerate(self.context_values): @@ -534,3 +446,17 @@ def __exit__(self, exc_type, exc_value, traceback): def render_service_factory(): return RenderService(transform_api=TransformService()) + +# +# SHIM: This is here until we can find a way to make a configurable cache. +# +@dataclass(frozen=True) +class CachedRenderService(RenderService): + + @functools.lru_cache + def _process_template(self, cached_template: CachableTemplate): + return self.transform_api.transform_template(cached_template.template) + + def process_template(self, template: Template, struct_cache: dict): + ct = CachableTemplate(template) + return self._process_template(ct) From e37d2281a6444d5a4ab2d1f92219b8e6c8237dee Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Wed, 31 Dec 2025 18:05:30 -0800 Subject: [PATCH 13/81] Expand tests. --- tdom/transformer_test.py | 83 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 80 insertions(+), 3 deletions(-) diff --git a/tdom/transformer_test.py b/tdom/transformer_test.py index 5098470..146e8d4 100644 --- a/tdom/transformer_test.py +++ b/tdom/transformer_test.py @@ -1,4 +1,8 @@ from .transformer import render_service_factory +from contextvars import ContextVar + + +theme_context_var = ContextVar('theme', default='default') def test_render_template(): @@ -34,13 +38,13 @@ def get_color_select_t(selected_values: set) -> Template: assert render_api.render_template(get_color_select_t(set()), struct_cache) == '' assert render_api.render_template(get_color_select_t({'Y'}), struct_cache) == '' -from contextvars import ContextVar -theme_context_var = ContextVar('theme', default='default') -def test_component(): + + +def test_render_component_with_context(): def ThemeContext(attrs, embedded_t, embedded_struct): context_values = ((theme_context_var, attrs.get('value', 'normal')),) @@ -56,3 +60,76 @@ def ThemedDiv(attrs, embedded_t, embedded_struct): assert render_api.render_template(body_t) == '
Cheers!
' assert theme_context_var.get() == 'not-the-default' assert theme_context_var.get() == 'default' + + +def test_render_template_components_smoketest(): + + def PageComponent(attrs, content_t, content_struct): + return t'''
{content_t}
''', () + + def FooterComponent(attrs, body_t, body_struct): + return t'', () + + def LayoutComponent(attrs, body_t, body_struct): + return t''' + + + + + + + {body_t}<{FooterComponent} /> + +''', () + + render_api = render_service_factory() + content = 'HTML never goes out of style.' + content_str = render_api.render_template(t'<{LayoutComponent}><{PageComponent}>{content}') + assert content_str == ''' + + + + + + +
HTML never goes out of style.
+ +''' + + +def test_render_template_functions_smoketest(): + + def make_page_t(content: str) -> Template: + return t'''
{content}
''' + + def make_footer_t() -> Template: + return t'' + + def make_layout_t(body_t: Template) -> Template: + footer_t = make_footer_t() + return t''' + + + + + + + {body_t}{footer_t} + +''' + + render_api = render_service_factory() + content = 'HTML never goes out of style.' + layout_t = make_layout_t(make_page_t(content)) + content_str = render_api.render_template(layout_t) + assert content_str == ''' + + + + + + +
HTML never goes out of style.
+ +''' + From 8df29bd60683cf01e9e20f2ca56f49005bf0d533 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Wed, 31 Dec 2025 21:53:40 -0800 Subject: [PATCH 14/81] Delegate as much attribute resolution to processor as possible. --- tdom/transformer.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tdom/transformer.py b/tdom/transformer.py index 7316b43..2b19857 100644 --- a/tdom/transformer.py +++ b/tdom/transformer.py @@ -8,7 +8,7 @@ import functools from markupsafe import Markup -from .parser import TemplateParser +from .parser import TemplateParser, HTMLAttributesDict from .tnodes import ( TNode, TFragment, @@ -31,7 +31,7 @@ escape_html_comment as default_escape_html_comment, ) from .utils import CachableTemplate -from .processor import _resolve_t_attrs, AttributesDict +from .processor import _resolve_t_attrs, AttributesDict, _resolve_html_attrs @dataclass @@ -39,6 +39,10 @@ class EndTag: end_tag: str +def render_html_attrs(html_attrs: HTMLAttributesDict, escape: Callable = default_escape_html_text) -> str: + return ''.join((f' {k}="{v}"' if v is not None else f' {k}' for k, v in html_attrs.items())) + + class Interpolator(t.Protocol): def __call__(self, render_api, struct_cache, q, bf, last_container_tag, template, ip_info) -> RenderQueueItem | None: @@ -85,8 +89,8 @@ def interpolate_comment(render_api, struct_cache, q, bf, last_container_tag, tem def interpolate_attrs(render_api, struct_cache, q, bf, last_container_tag, template, ip_info) -> RenderQueueItem | None: container_tag, attrs = ip_info - attrs = render_api.interpolate_attrs(attrs, template) - attrs_str = ''.join(f' {k}' if v is True else f' {k}="{render_api.escape_html_text(v)}"' for k, v in attrs.items() if v is not None and v is not False) + html_attrs = render_api.interpolate_attrs(attrs, template) + attrs_str = render_html_attrs(_resolve_html_attrs(html_attrs)) bf.append(attrs_str) @@ -257,8 +261,7 @@ def streamer(self, root: TNode, last_container_tag: str|None=None) -> t.Iterable if self.has_dynamic_attrs(attrs): yield self._stream_attrs_interpolation(tag, attrs) else: - # @TODO: Probably find a less risky way to do this. - yield ''.join((f'{k}={default_escape_html_text(v)}' if v is not None else f'{k}' for k, v in _resolve_t_attrs(attrs, ()).items() if v is not False and v is not None)) + yield render_html_attrs(_resolve_html_attrs(_resolve_t_attrs(attrs, interpolations=()))) # This is just a want to have. if self.slash_void and tag in VOID_ELEMENTS: yield ' />' From f862917625e039c061305f268560d01c2cb4d6b0 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Wed, 31 Dec 2025 21:55:13 -0800 Subject: [PATCH 15/81] Cleanup transformer tests. --- tdom/transformer_test.py | 47 ++++++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/tdom/transformer_test.py b/tdom/transformer_test.py index 146e8d4..4351d09 100644 --- a/tdom/transformer_test.py +++ b/tdom/transformer_test.py @@ -1,47 +1,48 @@ from .transformer import render_service_factory from contextvars import ContextVar +from string.templatelib import Template theme_context_var = ContextVar('theme', default='default') -def test_render_template(): +def test_render_template_repeated(): def get_sample_t(idx, spread_attrs, button_text): return t'''
''' render_api = render_service_factory() struct_cache = {} - for count in (1, 1, 100): - for idx in range(count): - spread_attrs = {'data-enabled': True} - button_text = 'RENDER' - sample_t = get_sample_t(idx, spread_attrs, button_text) - assert ''.join(render_api.render_template(sample_t, struct_cache)) == f'
' - - new_sample_t = get_sample_t("zebra", {"diff": "yes"}, t"
{sample_t}
") # You dirty dog! Stay. In. Your. Scope. - assert ''.join(render_api.render_template(new_sample_t, struct_cache)) == \ - f'
' - -def test_render_select(): + for idx in range(3): + spread_attrs = {'data-enabled': True} + button_text = 'RENDER' + sample_t = get_sample_t(idx, spread_attrs, button_text) + assert render_api.render_template(sample_t, struct_cache) == f'
' + +def test_render_template_iterables(): render_api = render_service_factory() - def get_select_t(options, selected_values): + def get_select_t_with_list(options, selected_values): return t'''''' + def get_select_t_with_generator(options, selected_values): + return t'''''' + def get_select_t_with_concat(options, selected_values): + parts = [t'') + return sum(parts, t"") - def get_color_select_t(selected_values: set) -> Template: + def get_color_select_t(selected_values: set, provider: Callable) -> Template: PRIMARY_COLORS = [("R", "Red"), ("Y", "Yellow"), ("B", "Blue")] assert set(selected_values).issubset(set([opt[0] for opt in PRIMARY_COLORS])) - return get_select_t(PRIMARY_COLORS, selected_values) + return provider(PRIMARY_COLORS, selected_values) struct_cache = {} - assert render_api.render_template(get_color_select_t(set()), struct_cache) == '' - assert render_api.render_template(get_color_select_t({'Y'}), struct_cache) == '' - - - - - + for provider in (get_select_t_with_list,get_select_t_with_generator,get_select_t_with_concat): + assert render_api.render_template(get_color_select_t(set(), provider), struct_cache) == '' + assert render_api.render_template(get_color_select_t({'Y'}, provider), struct_cache) == '' def test_render_component_with_context(): From 23260fbf6a793bca47afc161376b66833e83f64a Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Thu, 1 Jan 2026 00:01:56 -0800 Subject: [PATCH 16/81] Use another lru cache. --- tdom/transformer.py | 68 +++++++++++++++++++++------------------------ 1 file changed, 31 insertions(+), 37 deletions(-) diff --git a/tdom/transformer.py b/tdom/transformer.py index 2b19857..6136428 100644 --- a/tdom/transformer.py +++ b/tdom/transformer.py @@ -45,19 +45,13 @@ def render_html_attrs(html_attrs: HTMLAttributesDict, escape: Callable = default class Interpolator(t.Protocol): - def __call__(self, render_api, struct_cache, q, bf, last_container_tag, template, ip_info) -> RenderQueueItem | None: + def __call__(self, render_api, q, bf, last_container_tag, template, ip_info) -> RenderQueueItem | None: """ Populates an interpolation or returns iterator to decende into. If recursion is required then pushes current iterator render_api The current render api, provides various helper methods to the interpolator. - struct_cache - The struct cache, this is mainly needed because it is not bound to the render api - and this might change. If we want to run a new template through the render api we need - the cache to pass in which is kind of kludgy. Maybe the render_api should be more of a - render_session or something that binds the cache and the render service together. OR - we can just pass around 50 params... q A list-like queue of iterators paired with the container tag the results are in. bf @@ -81,20 +75,20 @@ def __call__(self, render_api, struct_cache, q, bf, last_container_tag, template type RenderQueueItem = tuple[str | None, Iterable[tuple[Interpolator, Template, InterpolationInfo]]] -def interpolate_comment(render_api, struct_cache, q, bf, last_container_tag, template, ip_info) -> RenderQueueItem | None: +def interpolate_comment(render_api, q, bf, last_container_tag, template, ip_info) -> RenderQueueItem | None: container_tag, comment_t = ip_info assert container_tag == ' +literal + +{text_in_element} +{text_in_element} +<{comp}>comp body + +''' + smoke_str = ''' + + + +literal + +text is not literal +text is not literal +
comp body
+ +''' + render_api = render_service_factory() + assert render_api.render_template(smoke_t) == smoke_str + + +def struct_repr(st): + return st.strings, tuple([(i.value, i.expression, i.conversion, i.format_spec) for i in st.interpolations]) + + +def test_process_template_internal_cache(): + sample_t = t'''
{'content'}
''' + sample_diff_t = t'''
{'diffcontent'}
''' + alt_t = t'''{'content'}''' + render_api = render_service_factory() + cached_render_api = cached_render_service_factory() + #CachedTransformService + tnode1 = render_api.process_template(sample_t) + tnode2 = render_api.process_template(sample_t) + cached_tnode1 = cached_render_api.process_template(sample_t) + cached_tnode2 = cached_render_api.process_template(sample_t) + cached_tnode3 = cached_render_api.process_template(sample_diff_t) + assert tnode1 is not cached_tnode1 + assert tnode1 is not cached_tnode2 + assert tnode1 is not cached_tnode3 + assert tnode1 is not tnode2 + assert cached_tnode1 is cached_tnode2 + assert cached_tnode1 is cached_tnode3 + assert struct_repr(tnode1) == struct_repr(cached_tnode1) + assert struct_repr(tnode2) == struct_repr(cached_tnode1) + # @TODO: Maybe we should be acting directly on the transform service here? + ci = cached_render_api.transform_api._transform_template.cache_info() + assert ci.hits == 2, "lookup #2 and lookup #3" + assert ci.misses == 1, "lookup #1" + cached_tnode4 = cached_render_api.process_template(alt_t) + assert cached_tnode1 is not cached_tnode4 + assert struct_repr(cached_tnode1) != struct_repr(cached_tnode4) + + def test_render_template_repeated(): def get_sample_t(idx, spread_attrs, button_text): return t'''
''' - render_api = render_service_factory() - struct_cache = {} - for idx in range(3): - spread_attrs = {'data-enabled': True} - button_text = 'RENDER' - sample_t = get_sample_t(idx, spread_attrs, button_text) - assert render_api.render_template(sample_t, struct_cache) == f'
' + render_apis = (render_service_factory(), cached_render_service_factory()) + for render_api in render_apis: + for idx in range(3): + spread_attrs = {'data-enabled': True} + button_text = 'RENDER' + sample_t = get_sample_t(idx, spread_attrs, button_text) + assert render_api.render_template(sample_t) == f'
' def test_render_template_iterables(): render_api = render_service_factory() @@ -39,10 +106,9 @@ def get_color_select_t(selected_values: set, provider: Callable) -> Template: assert set(selected_values).issubset(set([opt[0] for opt in PRIMARY_COLORS])) return provider(PRIMARY_COLORS, selected_values) - struct_cache = {} for provider in (get_select_t_with_list,get_select_t_with_generator,get_select_t_with_concat): - assert render_api.render_template(get_color_select_t(set(), provider), struct_cache) == '' - assert render_api.render_template(get_color_select_t({'Y'}, provider), struct_cache) == '' + assert render_api.render_template(get_color_select_t(set(), provider)) == '' + assert render_api.render_template(get_color_select_t({'Y'}, provider)) == '' def test_render_component_with_context(): From 7c41ee142e013a7ec6f14d4453772d25f63d4edb Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Thu, 1 Jan 2026 00:12:29 -0800 Subject: [PATCH 18/81] Fix signature. --- tdom/transformer.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tdom/transformer.py b/tdom/transformer.py index 6136428..0f248be 100644 --- a/tdom/transformer.py +++ b/tdom/transformer.py @@ -451,9 +451,9 @@ def cached_render_service_factory(): class CachedTransformService(TransformService): @functools.lru_cache(512) - def _transform_template(self, cached_template: CachableTemplate) -> TNode: + def _transform_template(self, cached_template: CachableTemplate) -> Template: return super().transform_template(cached_template.template) - def transform_template(self, template: Template) -> TNode: - ct = CachableTemplate(template) + def transform_template(self, values_template: Template) -> Template: + ct = CachableTemplate(values_template) return self._transform_template(ct) From 5d65cc73ee25dfe2db87810fb9fa4a0038baa875 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Thu, 1 Jan 2026 00:12:41 -0800 Subject: [PATCH 19/81] Add __html__ to smoke test. --- tdom/transformer_test.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tdom/transformer_test.py b/tdom/transformer_test.py index 08cdb29..8d4626d 100644 --- a/tdom/transformer_test.py +++ b/tdom/transformer_test.py @@ -1,7 +1,7 @@ from .transformer import render_service_factory, cached_render_service_factory, CachedTransformService from contextvars import ContextVar from string.templatelib import Template - +from markupsafe import Markup theme_context_var = ContextVar('theme', default='default') @@ -12,6 +12,7 @@ def test_render_template_smoketest(): text_in_element = 'text is not literal' templated = "not literal" spread_attrs={'data-on': True} + markup_content = Markup('
safe
') def comp(attrs, body_t, body_struct): return t'
{body_t}
', () smoke_t = t''' @@ -23,6 +24,7 @@ def comp(attrs, body_t, body_struct): {text_in_element} {text_in_element} <{comp}>comp body +{markup_content} ''' smoke_str = ''' @@ -34,6 +36,7 @@ def comp(attrs, body_t, body_struct): text is not literal text is not literal
comp body
+
safe
''' render_api = render_service_factory() From 808a0c3a7ba3f868be879665cfaca18fd076f62a Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Thu, 1 Jan 2026 01:22:28 -0800 Subject: [PATCH 20/81] Add in experiment with callable info style components. --- tdom/transformer.py | 76 ++++++++++++++++++++++++++++++++++++++-- tdom/transformer_test.py | 46 ++++++++++++++++++++---- 2 files changed, 113 insertions(+), 9 deletions(-) diff --git a/tdom/transformer.py b/tdom/transformer.py index 0f248be..f6c3c3f 100644 --- a/tdom/transformer.py +++ b/tdom/transformer.py @@ -31,7 +31,8 @@ escape_html_comment as default_escape_html_comment, ) from .utils import CachableTemplate -from .processor import _resolve_t_attrs, AttributesDict, _resolve_html_attrs +from .processor import _resolve_t_attrs, AttributesDict, _resolve_html_attrs, _kebab_to_snake +from .callables import get_callable_info @dataclass @@ -88,6 +89,62 @@ def interpolate_attrs(render_api, q, bf, last_container_tag, template, ip_info) bf.append(attrs_str) +def invoke_passthru(component_callable, attrs, embedded_template, embedded_struct_t): + result_template = component_callable(attrs, embedded_template, embedded_struct_t) + return result_template, () + + +def invoke_passthru_cvalues(component_callable, attrs, embedded_template, embedded_struct_t): + result_template, context_values = component_callable(attrs, embedded_template, embedded_struct_t) + return result_template, context_values + + +def _prep_cinfo(component_callable, attrs, embedded_template, embedded_struct_t): + # @TODO: This is lifted from the processor and then grossified. + # Not sure this will work out but maybe we'd unify these. + callable_info = get_callable_info(component_callable) + + if callable_info.requires_positional: + raise TypeError( + "Component callables cannot have required positional arguments." + ) + + kwargs: AttributesDict = {} + + # Add all supported attributes + for attr_name, attr_value in attrs.items(): + snake_name = _kebab_to_snake(attr_name) + if snake_name in callable_info.named_params or callable_info.kwargs: + kwargs[snake_name] = attr_value + + # Add system vars if appropriate + if "embedded_template" in callable_info.named_params or callable_info.kwargs: + kwargs["embedded_template"] = embedded_template + # I'm sure the phone is ringing of the hook for this one. + if "embedded_struct_t" in callable_info.named_params or callable_info.kwargs: + kwargs["embedded_struct_t"] = embedded_struct_t + + # Check to make sure we've fully satisfied the callable's requirements + missing = callable_info.required_named_params - kwargs.keys() + if missing: + raise TypeError( + f"Missing required parameters for component: {', '.join(missing)}" + ) + return kwargs + + +def invoke_cinfo(component_callable, attrs, embedded_template, embedded_struct_t): + kwargs = _prep_cinfo(component_callable, attrs, embedded_template, embedded_struct_t) + result = component_callable(**kwargs) + return result, () + + +def invoke_cinfo_cvalues(component_callable, attrs, embedded_template, embedded_struct_t): + kwargs = _prep_cinfo(component_callable, attrs, embedded_template, embedded_struct_t) + result, context_values = component_callable(**kwargs) + return result, context_values + + def interpolate_component(render_api, q, bf, last_container_tag, template, ip_info) -> RenderQueueItem | None: """ - Extract embedded template or use empty template. @@ -106,10 +163,23 @@ def interpolate_component(render_api, q, bf, last_container_tag, template, ip_in embedded_template = Template('') embedded_struct_t = render_api.process_template(embedded_template) attrs = render_api.interpolate_attrs(attrs, template) - component_callable = template.interpolations[start_i_index].value + start_i = template.interpolations[start_i_index] + component_callable = start_i.value if start_i_index != end_i_index and end_i_index is not None and component_callable != template.interpolations[end_i_index].value: raise TypeError('Component callable in start tag must match component callable in end tag.') - result_template, context_values = component_callable(attrs, embedded_template, embedded_struct_t) + + # @TODO: Amazingly this works! It looks terrible though! Maybe we'll keep pushing symbols around... + if start_i.format_spec == '' or start_i.format_spec == 'passthru': + invoke_strat = invoke_passthru + elif start_i.format_spec == 'passthru+cvalues': + invoke_strat = invoke_passthru_cvalues + elif start_i.format_spec == 'cinfo': + invoke_strat = invoke_cinfo + elif start_i.format_spec == 'cinfo+cvalues': + invoke_strat = invoke_cinfo_cvalues + else: + raise ValueError(f'Unknown format spec: {start_i.format_spec}') + result_template, context_values = invoke_strat(component_callable, attrs, embedded_template, embedded_struct_t) if result_template: result_struct = render_api.process_template(result_template) if context_values: diff --git a/tdom/transformer_test.py b/tdom/transformer_test.py index 8d4626d..05770b2 100644 --- a/tdom/transformer_test.py +++ b/tdom/transformer_test.py @@ -14,7 +14,7 @@ def test_render_template_smoketest(): spread_attrs={'data-on': True} markup_content = Markup('
safe
') def comp(attrs, body_t, body_struct): - return t'
{body_t}
', () + return t'
{body_t}
' smoke_t = t''' @@ -122,10 +122,10 @@ def ThemeContext(attrs, embedded_t, embedded_struct): def ThemedDiv(attrs, embedded_t, embedded_struct): theme = theme_context_var.get() - return t'
{embedded_t}
', () + return t'
{embedded_t}
' render_api = render_service_factory() - body_t = t"
<{ThemeContext} value='holiday'><{ThemedDiv}>Cheers!
" + body_t = t"
<{ThemeContext:passthru+cvalues} value='holiday'><{ThemedDiv}>Cheers!
" with theme_context_var.set('not-the-default'): assert render_api.render_template(body_t) == '
Cheers!
' assert theme_context_var.get() == 'not-the-default' @@ -135,10 +135,10 @@ def ThemedDiv(attrs, embedded_t, embedded_struct): def test_render_template_components_smoketest(): def PageComponent(attrs, content_t, content_struct): - return t'''
{content_t}
''', () + return t'''
{content_t}
''' def FooterComponent(attrs, body_t, body_struct): - return t'', () + return t'' def LayoutComponent(attrs, body_t, body_struct): return t''' @@ -150,7 +150,7 @@ def LayoutComponent(attrs, body_t, body_struct): {body_t}<{FooterComponent} /> -''', () +''' render_api = render_service_factory() content = 'HTML never goes out of style.' @@ -203,3 +203,37 @@ def make_layout_t(body_t: Template) -> Template: ''' +def test_render_template_components_cinfo_smoketest(): + + def PageComponent(embedded_template=None): + return t'''
{embedded_template}
''' + + def FooterComponent(): + return t'' + + def LayoutComponent(embedded_template=None): + assert embedded_template + return t''' + + + + + + + {embedded_template}<{FooterComponent:cinfo} /> + +''' + + render_api = render_service_factory() + content = 'HTML never goes out of style.' + content_str = render_api.render_template(t'<{LayoutComponent:cinfo}><{PageComponent:cinfo}>{content}') + assert content_str == ''' + + + + + + +
HTML never goes out of style.
+ +''' From 7c93227c2a8e48354ad9ce256e3633d647c7e036 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Sat, 3 Jan 2026 22:34:09 -0800 Subject: [PATCH 21/81] Type checking fixes. --- tdom/transformer_test.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tdom/transformer_test.py b/tdom/transformer_test.py index 05770b2..9d64a70 100644 --- a/tdom/transformer_test.py +++ b/tdom/transformer_test.py @@ -1,7 +1,10 @@ -from .transformer import render_service_factory, cached_render_service_factory, CachedTransformService from contextvars import ContextVar from string.templatelib import Template from markupsafe import Markup +import typing as t + +from .transformer import render_service_factory, cached_render_service_factory, CachedTransformService + theme_context_var = ContextVar('theme', default='default') @@ -67,7 +70,8 @@ def test_process_template_internal_cache(): assert cached_tnode1 is cached_tnode3 assert struct_repr(tnode1) == struct_repr(cached_tnode1) assert struct_repr(tnode2) == struct_repr(cached_tnode1) - # @TODO: Maybe we should be acting directly on the transform service here? + # Technically this could be the superclass which doesn't have cached method. + assert isinstance(cached_render_api.transform_api, CachedTransformService) ci = cached_render_api.transform_api._transform_template.cache_info() assert ci.hits == 2, "lookup #2 and lookup #3" assert ci.misses == 1, "lookup #1" @@ -104,7 +108,7 @@ def get_select_t_with_concat(options, selected_values): parts.append(t'') return sum(parts, t"") - def get_color_select_t(selected_values: set, provider: Callable) -> Template: + def get_color_select_t(selected_values: set, provider: t.Callable) -> Template: PRIMARY_COLORS = [("R", "Red"), ("Y", "Yellow"), ("B", "Blue")] assert set(selected_values).issubset(set([opt[0] for opt in PRIMARY_COLORS])) return provider(PRIMARY_COLORS, selected_values) From b93c4d74502b8eeee057688975f9d57dfc5f588c Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Sat, 3 Jan 2026 22:34:48 -0800 Subject: [PATCH 22/81] Cleanup attr handling names. --- tdom/transformer.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/tdom/transformer.py b/tdom/transformer.py index f6c3c3f..a7cd66a 100644 --- a/tdom/transformer.py +++ b/tdom/transformer.py @@ -31,7 +31,7 @@ escape_html_comment as default_escape_html_comment, ) from .utils import CachableTemplate -from .processor import _resolve_t_attrs, AttributesDict, _resolve_html_attrs, _kebab_to_snake +from .processor import _resolve_t_attrs, AttributesDict, _resolve_html_attrs as coerce_to_html_attrs, _kebab_to_snake from .callables import get_callable_info @@ -84,8 +84,8 @@ def interpolate_comment(render_api, q, bf, last_container_tag, template, ip_info def interpolate_attrs(render_api, q, bf, last_container_tag, template, ip_info) -> RenderQueueItem | None: container_tag, attrs = ip_info - html_attrs = render_api.interpolate_attrs(attrs, template) - attrs_str = render_html_attrs(_resolve_html_attrs(html_attrs)) + resolved_attrs = render_api.resolve_attrs(attrs, template) + attrs_str = render_html_attrs(coerce_to_html_attrs(resolved_attrs)) bf.append(attrs_str) @@ -162,7 +162,7 @@ def interpolate_component(render_api, q, bf, last_container_tag, template, ip_in else: embedded_template = Template('') embedded_struct_t = render_api.process_template(embedded_template) - attrs = render_api.interpolate_attrs(attrs, template) + resolved_attrs = render_api.resolve_attrs(attrs, template) start_i = template.interpolations[start_i_index] component_callable = start_i.value if start_i_index != end_i_index and end_i_index is not None and component_callable != template.interpolations[end_i_index].value: @@ -179,7 +179,7 @@ def interpolate_component(render_api, q, bf, last_container_tag, template, ip_in invoke_strat = invoke_cinfo_cvalues else: raise ValueError(f'Unknown format spec: {start_i.format_spec}') - result_template, context_values = invoke_strat(component_callable, attrs, embedded_template, embedded_struct_t) + result_template, context_values = invoke_strat(component_callable, resolved_attrs, embedded_template, embedded_struct_t) if result_template: result_struct = render_api.process_template(result_template) if context_values: @@ -325,7 +325,7 @@ def streamer(self, root: TNode, last_container_tag: str|None=None) -> t.Iterable if self.has_dynamic_attrs(attrs): yield self._stream_attrs_interpolation(tag, attrs) else: - yield render_html_attrs(_resolve_html_attrs(_resolve_t_attrs(attrs, interpolations=()))) + yield render_html_attrs(coerce_to_html_attrs(_resolve_t_attrs(attrs, interpolations=()))) # This is just a want to have. if self.slash_void and tag in VOID_ELEMENTS: yield ' />' @@ -462,8 +462,12 @@ def process_template(self, template): """ This is just a wrap-point for caching. """ return self.transform_api.transform_template(template) - def interpolate_attrs(self, attrs, template) -> AttributesDict: - """ Plug `template` values into any attribute interpolations. """ + def resolve_attrs(self, attrs, template) -> AttributesDict: + """ + - interpolate interpolations + - perform special attribute handling + - merge + """ return _resolve_t_attrs(attrs, template.interpolations) def walk_template_with_context(self, bf, template, struct_t, context_values=None): From f273e943b19d96baf9b1c2a9c7c5778126721907 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Sat, 3 Jan 2026 23:04:39 -0800 Subject: [PATCH 23/81] Add in a few explainations. --- tdom/transformer.py | 40 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/tdom/transformer.py b/tdom/transformer.py index a7cd66a..0a3f32b 100644 --- a/tdom/transformer.py +++ b/tdom/transformer.py @@ -359,10 +359,27 @@ def has_dynamic_attrs(self, attrs: t.Sequence[TAttribute]) -> bool: return True return False - def extract_embedded_template(self, template: Template, body_start_s_index: int, end_i_index: int): + def extract_embedded_template(self, template: Template, body_start_s_index: int, end_i_index: int) -> Template: """ Extract the template parts exclusively from start tag to end tag. + Note that interpolations INSIDE the start tag make this more complex + than just "the `s_index` after the component callable's `i_index`". + + Example: + ```python + template = ( + t'<{comp} attr={attr}>' + t'
{content} {footer}
' + t'' + ) + assert self.extract_embedded_template(template, 2, 4) == ( + t'
{content} {footer}
' + ) + starttag = t'<{comp} attr={attr}>' + endtag = t'' + assert template == starttag + self.extract_embedded_template(template, 2, 4) + endtag + ``` @TODO: "There must be a better way." """ # Copy the parts out of the containing template. @@ -421,6 +438,14 @@ def render_template(self, template, last_container_tag=None) -> str: return ''.join(bf) def resolve_text_without_recursion(self, template, container_tag, content_t) -> str: + """ + Resolve the text in the given template without recursing into more structured text. + + This can be bypassed by interpolating an exact match with an object with `__html__()`. + + A non-exact match is not allowed because we cannot process escaping + across the boundary between other content and the pass-through content. + """ parts = list(content_t) exact = len(parts) == 1 and len(content_t.interpolations) == 1 if exact: @@ -495,7 +520,16 @@ def walk_template(self, bf, template, struct_t): class ContextVarSetter: - context_values: tuple[tuple[ContextVar, object],...] + """ + Context manager for working with many context vars (instead of only 1). + + This is meant to be created, used immediately and then discarded. + + This allows for dynamically specifying a tuple of var / value pairs that + another part of the program can use to wrap some called code without knowing + anything about either. + """ + context_values: tuple[tuple[ContextVar, object],...] # Cvar / value pair. tokens: tuple[Token,...] def __init__(self, context_values=()): @@ -503,9 +537,11 @@ def __init__(self, context_values=()): self.tokens = () def __enter__(self): + """ Set every given context var to its paired value. """ self.tokens = tuple([var.set(val) for var, val in self.context_values]) def __exit__(self, exc_type, exc_value, traceback): + """ Reset every given context var. """ for idx, var_value in enumerate(self.context_values): var_value[0].reset(self.tokens[idx]) From cdf531b237b58a3c14c6dc4161a42a015b634a44 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Tue, 13 Jan 2026 00:11:28 -0800 Subject: [PATCH 24/81] Use clearer name. --- tdom/transformer.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tdom/transformer.py b/tdom/transformer.py index 0a3f32b..1815868 100644 --- a/tdom/transformer.py +++ b/tdom/transformer.py @@ -31,7 +31,12 @@ escape_html_comment as default_escape_html_comment, ) from .utils import CachableTemplate -from .processor import _resolve_t_attrs, AttributesDict, _resolve_html_attrs as coerce_to_html_attrs, _kebab_to_snake +from .processor import ( + _resolve_t_attrs as resolve_dynamic_attrs, + AttributesDict, + _resolve_html_attrs as coerce_to_html_attrs, + _kebab_to_snake, +) from .callables import get_callable_info @@ -325,7 +330,7 @@ def streamer(self, root: TNode, last_container_tag: str|None=None) -> t.Iterable if self.has_dynamic_attrs(attrs): yield self._stream_attrs_interpolation(tag, attrs) else: - yield render_html_attrs(coerce_to_html_attrs(_resolve_t_attrs(attrs, interpolations=()))) + yield render_html_attrs(coerce_to_html_attrs(resolve_dynamic_attrs(attrs, interpolations=()))) # This is just a want to have. if self.slash_void and tag in VOID_ELEMENTS: yield ' />' @@ -493,7 +498,7 @@ def resolve_attrs(self, attrs, template) -> AttributesDict: - perform special attribute handling - merge """ - return _resolve_t_attrs(attrs, template.interpolations) + return resolve_dynamic_attrs(attrs, template.interpolations) def walk_template_with_context(self, bf, template, struct_t, context_values=None): if context_values: From dc8b381f3060926418dc0f9249a460a849ab2db9 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Tue, 13 Jan 2026 23:49:45 -0800 Subject: [PATCH 25/81] Try to push API in the direction of tdom's node api. --- tdom/transformer.py | 86 +++++++++++++++++++++------------------------ 1 file changed, 41 insertions(+), 45 deletions(-) diff --git a/tdom/transformer.py b/tdom/transformer.py index 1815868..785d26a 100644 --- a/tdom/transformer.py +++ b/tdom/transformer.py @@ -94,18 +94,8 @@ def interpolate_attrs(render_api, q, bf, last_container_tag, template, ip_info) bf.append(attrs_str) -def invoke_passthru(component_callable, attrs, embedded_template, embedded_struct_t): - result_template = component_callable(attrs, embedded_template, embedded_struct_t) - return result_template, () - - -def invoke_passthru_cvalues(component_callable, attrs, embedded_template, embedded_struct_t): - result_template, context_values = component_callable(attrs, embedded_template, embedded_struct_t) - return result_template, context_values - - -def _prep_cinfo(component_callable, attrs, embedded_template, embedded_struct_t): - # @TODO: This is lifted from the processor and then grossified. +def _prep_cinfo(component_callable, attrs, system): + # @DESIGN: This is lifted from the processor and then grossified. # Not sure this will work out but maybe we'd unify these. callable_info = get_callable_info(component_callable) @@ -116,38 +106,30 @@ def _prep_cinfo(component_callable, attrs, embedded_template, embedded_struct_t) kwargs: AttributesDict = {} - # Add all supported attributes + # Inject system kwargs first. + if system: + if callable_info.kwargs: + kwargs.update(system) + else: + for kw in system: + if kw in callable_info.named_params: + kwargs[kw] = system[kw] + + # Plaster attributes in over top of system kwargs. for attr_name, attr_value in attrs.items(): snake_name = _kebab_to_snake(attr_name) if snake_name in callable_info.named_params or callable_info.kwargs: kwargs[snake_name] = attr_value - # Add system vars if appropriate - if "embedded_template" in callable_info.named_params or callable_info.kwargs: - kwargs["embedded_template"] = embedded_template - # I'm sure the phone is ringing of the hook for this one. - if "embedded_struct_t" in callable_info.named_params or callable_info.kwargs: - kwargs["embedded_struct_t"] = embedded_struct_t - # Check to make sure we've fully satisfied the callable's requirements missing = callable_info.required_named_params - kwargs.keys() if missing: raise TypeError( f"Missing required parameters for component: {', '.join(missing)}" ) - return kwargs - -def invoke_cinfo(component_callable, attrs, embedded_template, embedded_struct_t): - kwargs = _prep_cinfo(component_callable, attrs, embedded_template, embedded_struct_t) - result = component_callable(**kwargs) - return result, () - - -def invoke_cinfo_cvalues(component_callable, attrs, embedded_template, embedded_struct_t): - kwargs = _prep_cinfo(component_callable, attrs, embedded_template, embedded_struct_t) - result, context_values = component_callable(**kwargs) - return result, context_values + expect_cvalues = (not callable_info.return_is_undefined) and (callable_info.return_origin is not Template) + return kwargs, expect_cvalues def interpolate_component(render_api, q, bf, last_container_tag, template, ip_info) -> RenderQueueItem | None: @@ -163,6 +145,7 @@ def interpolate_component(render_api, q, bf, last_container_tag, template, ip_in """ (container_tag, attrs, start_i_index, end_i_index, body_start_s_index) = ip_info if start_i_index != end_i_index and end_i_index is not None: + # @DESIGN: We extract the embedded template from the original outer template. embedded_template = render_api.transform_api.extract_embedded_template(template, body_start_s_index, end_i_index) else: embedded_template = Template('') @@ -173,18 +156,23 @@ def interpolate_component(render_api, q, bf, last_container_tag, template, ip_in if start_i_index != end_i_index and end_i_index is not None and component_callable != template.interpolations[end_i_index].value: raise TypeError('Component callable in start tag must match component callable in end tag.') - # @TODO: Amazingly this works! It looks terrible though! Maybe we'll keep pushing symbols around... - if start_i.format_spec == '' or start_i.format_spec == 'passthru': - invoke_strat = invoke_passthru - elif start_i.format_spec == 'passthru+cvalues': - invoke_strat = invoke_passthru_cvalues - elif start_i.format_spec == 'cinfo': - invoke_strat = invoke_cinfo - elif start_i.format_spec == 'cinfo+cvalues': - invoke_strat = invoke_cinfo_cvalues + # @DESIGN: Inject system vars via manager? + system_dict = render_api.get_system(children=embedded_template, children_struct=embedded_struct_t) + # @DESIGN: Determine return signature from callable info (cached inspection) ? + kwargs, expect_cvalues = _prep_cinfo(component_callable, resolved_attrs, system_dict) + res = component_callable(**kwargs) + # @DESIGN: Determine return signature via runtime inspection? + if isinstance(res, tuple): + result_template, comp_info = res + context_values = comp_info.get('context_values', ()) if comp_info else () else: - raise ValueError(f'Unknown format spec: {start_i.format_spec}') - result_template, context_values = invoke_strat(component_callable, resolved_attrs, embedded_template, embedded_struct_t) + result_template = res + comp_info = None + context_values = () + + # @DESIGN: Use open-ended dict for opt-in second return argument? + context_values = comp_info.get('context_values', ()) if comp_info else () + if result_template: result_struct = render_api.process_template(result_template) if context_values: @@ -330,8 +318,10 @@ def streamer(self, root: TNode, last_container_tag: str|None=None) -> t.Iterable if self.has_dynamic_attrs(attrs): yield self._stream_attrs_interpolation(tag, attrs) else: + # @DESIGN: We can't customize the html attrs rendering here because we are not even + # in the RENDERER! yield render_html_attrs(coerce_to_html_attrs(resolve_dynamic_attrs(attrs, interpolations=()))) - # This is just a want to have. + # @DESIGN: This is just a want to have. if self.slash_void and tag in VOID_ELEMENTS: yield ' />' else: @@ -385,7 +375,7 @@ def extract_embedded_template(self, template: Template, body_start_s_index: int, endtag = t'' assert template == starttag + self.extract_embedded_template(template, 2, 4) + endtag ``` - @TODO: "There must be a better way." + @DESIGN: "There must be a better way." """ # Copy the parts out of the containing template. index = body_start_s_index @@ -414,6 +404,9 @@ class RenderService: escape_html_content_in_tag: Callable = default_escape_html_content_in_tag + def get_system(self, children, children_struct): + return {'children': children, 'children_struct': children_struct} + def render_template(self, template, last_container_tag=None) -> str: """ Iterate left to right and pause and push new iterators when descending depth-first. @@ -426,6 +419,8 @@ def render_template(self, template, last_container_tag=None) -> str: text processing. When working with fragments we might not know the container tag until the fragment is included at render-time. """ + # @DESIGN: We put all the strings in a list and then ''.join them at + # the end. bf: list[str] = [] q: list[RenderQueueItem] = [] q.append((last_container_tag, self.walk_template(bf, template, self.process_template(template)))) @@ -572,3 +567,4 @@ def _transform_template(self, cached_template: CachableTemplate) -> Template: def transform_template(self, values_template: Template) -> Template: ct = CachableTemplate(values_template) return self._transform_template(ct) + From b7f2a1357b6bad9bcac0021b35c17a9545c3b7cb Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Wed, 14 Jan 2026 21:21:51 -0800 Subject: [PATCH 26/81] Actually use the escape function. --- tdom/transformer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tdom/transformer.py b/tdom/transformer.py index 785d26a..f565204 100644 --- a/tdom/transformer.py +++ b/tdom/transformer.py @@ -46,7 +46,7 @@ class EndTag: def render_html_attrs(html_attrs: HTMLAttributesDict, escape: Callable = default_escape_html_text) -> str: - return ''.join((f' {k}="{v}"' if v is not None else f' {k}' for k, v in html_attrs.items())) + return ''.join((f' {k}="{escape(v)}"' if v is not None else f' {k}' for k, v in html_attrs.items())) class Interpolator(t.Protocol): From 4ff937e7316bc4c2f11f637b0145d36e74d020e6 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Wed, 14 Jan 2026 21:22:10 -0800 Subject: [PATCH 27/81] Use call info style. --- tdom/transformer_test.py | 42 +++++++++++++++++++--------------------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/tdom/transformer_test.py b/tdom/transformer_test.py index 9d64a70..f6b9a1b 100644 --- a/tdom/transformer_test.py +++ b/tdom/transformer_test.py @@ -16,8 +16,8 @@ def test_render_template_smoketest(): templated = "not literal" spread_attrs={'data-on': True} markup_content = Markup('
safe
') - def comp(attrs, body_t, body_struct): - return t'
{body_t}
' + def comp(children): + return t'
{children}
' smoke_t = t''' @@ -120,16 +120,15 @@ def get_color_select_t(selected_values: set, provider: t.Callable) -> Template: def test_render_component_with_context(): - def ThemeContext(attrs, embedded_t, embedded_struct): - context_values = ((theme_context_var, attrs.get('value', 'normal')),) - return embedded_t, context_values + def ThemeContext(theme, children): + return children, {'context_values': ((theme_context_var, theme),)} - def ThemedDiv(attrs, embedded_t, embedded_struct): + def ThemedDiv(children): theme = theme_context_var.get() - return t'
{embedded_t}
' + return t'
{children}
' render_api = render_service_factory() - body_t = t"
<{ThemeContext:passthru+cvalues} value='holiday'><{ThemedDiv}>Cheers!
" + body_t = t"
<{ThemeContext} theme='holiday'><{ThemedDiv}>Cheers!
" with theme_context_var.set('not-the-default'): assert render_api.render_template(body_t) == '
Cheers!
' assert theme_context_var.get() == 'not-the-default' @@ -138,13 +137,13 @@ def ThemedDiv(attrs, embedded_t, embedded_struct): def test_render_template_components_smoketest(): - def PageComponent(attrs, content_t, content_struct): - return t'''
{content_t}
''' + def PageComponent(children): + return t'''
{children}
''' - def FooterComponent(attrs, body_t, body_struct): + def FooterComponent(): return t'' - def LayoutComponent(attrs, body_t, body_struct): + def LayoutComponent(children): return t''' @@ -152,7 +151,7 @@ def LayoutComponent(attrs, body_t, body_struct): - {body_t}<{FooterComponent} /> + {children}<{FooterComponent} /> ''' @@ -179,7 +178,7 @@ def make_page_t(content: str) -> Template: def make_footer_t() -> Template: return t'' - def make_layout_t(body_t: Template) -> Template: + def make_layout_t(children) -> Template: footer_t = make_footer_t() return t''' @@ -188,7 +187,7 @@ def make_layout_t(body_t: Template) -> Template: - {body_t}{footer_t} + {children}{footer_t} ''' @@ -207,16 +206,15 @@ def make_layout_t(body_t: Template) -> Template: ''' -def test_render_template_components_cinfo_smoketest(): +def test_render_template_components_smoketest(): - def PageComponent(embedded_template=None): - return t'''
{embedded_template}
''' + def PageComponent(children): + return t'''
{children}
''' def FooterComponent(): return t'' - def LayoutComponent(embedded_template=None): - assert embedded_template + def LayoutComponent(children): return t''' @@ -224,13 +222,13 @@ def LayoutComponent(embedded_template=None): - {embedded_template}<{FooterComponent:cinfo} /> + {children}<{FooterComponent} /> ''' render_api = render_service_factory() content = 'HTML never goes out of style.' - content_str = render_api.render_template(t'<{LayoutComponent:cinfo}><{PageComponent:cinfo}>{content}') + content_str = render_api.render_template(t'<{LayoutComponent}><{PageComponent}>{content}') assert content_str == ''' From 216bfc091c1e3339e7940ee5c3e0a82f366923e1 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Wed, 14 Jan 2026 21:31:10 -0800 Subject: [PATCH 28/81] Stop using cinfo to determine if context values will be returned. --- tdom/transformer.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tdom/transformer.py b/tdom/transformer.py index f565204..7a83690 100644 --- a/tdom/transformer.py +++ b/tdom/transformer.py @@ -128,8 +128,7 @@ def _prep_cinfo(component_callable, attrs, system): f"Missing required parameters for component: {', '.join(missing)}" ) - expect_cvalues = (not callable_info.return_is_undefined) and (callable_info.return_origin is not Template) - return kwargs, expect_cvalues + return kwargs def interpolate_component(render_api, q, bf, last_container_tag, template, ip_info) -> RenderQueueItem | None: @@ -159,7 +158,7 @@ def interpolate_component(render_api, q, bf, last_container_tag, template, ip_in # @DESIGN: Inject system vars via manager? system_dict = render_api.get_system(children=embedded_template, children_struct=embedded_struct_t) # @DESIGN: Determine return signature from callable info (cached inspection) ? - kwargs, expect_cvalues = _prep_cinfo(component_callable, resolved_attrs, system_dict) + kwargs = _prep_cinfo(component_callable, resolved_attrs, system_dict) res = component_callable(**kwargs) # @DESIGN: Determine return signature via runtime inspection? if isinstance(res, tuple): From f83e63b24e4a93fe733eb84a05bcc82119bb771c Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Wed, 14 Jan 2026 22:39:01 -0800 Subject: [PATCH 29/81] Match other calls. --- tdom/transformer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tdom/transformer.py b/tdom/transformer.py index 7a83690..8aa8b29 100644 --- a/tdom/transformer.py +++ b/tdom/transformer.py @@ -181,8 +181,8 @@ def interpolate_component(render_api, q, bf, last_container_tag, template, ip_in return (container_tag, iter(walker)) -def interpolate_raw_text(render_api, q, bf, last_container_tag, template, value) -> RenderQueueItem | None: - container_tag, content_t = value +def interpolate_raw_text(render_api, q, bf, last_container_tag, template, ip_info) -> RenderQueueItem | None: + container_tag, content_t = ip_info bf.append(render_api.escape_html_content_in_tag(container_tag, render_api.resolve_text_without_recursion(template, container_tag, content_t))) From 620bcf2fe3f4e4f155e1c108719bfb9eac227ea8 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Wed, 14 Jan 2026 23:59:51 -0800 Subject: [PATCH 30/81] Add more comments to tests and make more idiomatic with processor. --- tdom/transformer_test.py | 142 ++++++++++++++++++++------------------- 1 file changed, 73 insertions(+), 69 deletions(-) diff --git a/tdom/transformer_test.py b/tdom/transformer_test.py index f6b9a1b..eedfca7 100644 --- a/tdom/transformer_test.py +++ b/tdom/transformer_test.py @@ -6,7 +6,7 @@ from .transformer import render_service_factory, cached_render_service_factory, CachedTransformService -theme_context_var = ContextVar('theme', default='default') +THEME_CTX = ContextVar('theme', default='default') def test_render_template_smoketest(): @@ -16,7 +16,7 @@ def test_render_template_smoketest(): templated = "not literal" spread_attrs={'data-on': True} markup_content = Markup('
safe
') - def comp(children): + def WrapperComponent(children): return t'
{children}
' smoke_t = t''' @@ -26,7 +26,7 @@ def comp(children): {text_in_element} {text_in_element} -<{comp}>comp body +<{WrapperComponent}>comp body {markup_content} ''' @@ -47,40 +47,56 @@ def comp(children): def struct_repr(st): + """ Breakdown Templates into comparable parts for test verification. """ return st.strings, tuple([(i.value, i.expression, i.conversion, i.format_spec) for i in st.interpolations]) def test_process_template_internal_cache(): + """ Test that cache and non-cache both generally work as expected. """ sample_t = t'''
{'content'}
''' sample_diff_t = t'''
{'diffcontent'}
''' alt_t = t'''{'content'}''' render_api = render_service_factory() cached_render_api = cached_render_service_factory() - #CachedTransformService tnode1 = render_api.process_template(sample_t) tnode2 = render_api.process_template(sample_t) cached_tnode1 = cached_render_api.process_template(sample_t) cached_tnode2 = cached_render_api.process_template(sample_t) cached_tnode3 = cached_render_api.process_template(sample_diff_t) + # Check that the uncached and cached services are actually + # returning non-identical results. assert tnode1 is not cached_tnode1 assert tnode1 is not cached_tnode2 assert tnode1 is not cached_tnode3 + # Check that the uncached service returns a brand new result everytime. assert tnode1 is not tnode2 + # Check that the cached service is returning the exact same, identical, result. assert cached_tnode1 is cached_tnode2 - assert cached_tnode1 is cached_tnode3 + # Even if the input templates are not identical (but are still equivalent). + assert cached_tnode1 is cached_tnode3 and sample_t is not sample_diff_t + # Check that the cached service and uncached services return + # results that are equivalent (even though they are not (id)entical). assert struct_repr(tnode1) == struct_repr(cached_tnode1) assert struct_repr(tnode2) == struct_repr(cached_tnode1) # Technically this could be the superclass which doesn't have cached method. assert isinstance(cached_render_api.transform_api, CachedTransformService) + # Now that we are setup we check that the cache is internally + # working as we intended. ci = cached_render_api.transform_api._transform_template.cache_info() - assert ci.hits == 2, "lookup #2 and lookup #3" - assert ci.misses == 1, "lookup #1" + # cached_tnode2 and cached_tnode3 are hits after cached_tnode1 + assert ci.hits == 2 + # cached_tnode1 was a miss because cache was empty (brand new) + assert ci.misses == 1 cached_tnode4 = cached_render_api.process_template(alt_t) + # A different template produces a brand new tnode. assert cached_tnode1 is not cached_tnode4 + # The template is new AND has a different structure so it also + # produces an unequivalent tnode. assert struct_repr(cached_tnode1) != struct_repr(cached_tnode4) def test_render_template_repeated(): + """ Crude check for any unintended state being kept between calls. """ def get_sample_t(idx, spread_attrs, button_text): return t'''
''' render_apis = (render_service_factory(), cached_render_service_factory()) @@ -118,32 +134,41 @@ def get_color_select_t(selected_values: set, provider: t.Callable) -> Template: assert render_api.render_template(get_color_select_t({'Y'}, provider)) == '' -def test_render_component_with_context(): +def test_context_provider_pattern(): + def ThemeProvider(theme, children): + return children, {'context_values': ((THEME_CTX, theme),)} - def ThemeContext(theme, children): - return children, {'context_values': ((theme_context_var, theme),)} + def IntermediateWrapper(children): + # Wrap in between the provider and consumer just to make sure there + # is no direct interaction. + return t'
{children}
' - def ThemedDiv(children): - theme = theme_context_var.get() - return t'
{children}
' + def ThemeConsumer(children): + theme = THEME_CTX.get() + return t'

{children}

' render_api = render_service_factory() - body_t = t"
<{ThemeContext} theme='holiday'><{ThemedDiv}>Cheers!
" - with theme_context_var.set('not-the-default'): - assert render_api.render_template(body_t) == '
Cheers!
' - assert theme_context_var.get() == 'not-the-default' - assert theme_context_var.get() == 'default' + body_t = t"<{ThemeProvider} theme='holiday'><{IntermediateWrapper}><{ThemeConsumer}>Cheers!" + # Set the context var to a different value while rendering + # to make sure this value will be masked + with THEME_CTX.set('not-the-default'): + # During rendering the provider should overlay a new value. + assert render_api.render_template(body_t) == '

Cheers!

' + # But afterwards we should be back to the old value. + assert THEME_CTX.get() == 'not-the-default' + # But after all that we should be back to the context var's offical default. + assert THEME_CTX.get() == 'default' def test_render_template_components_smoketest(): + """ Broadly test that common template component usage works. """ + def PageComponent(children, root_attrs=None): + return t'''
{children}
''' - def PageComponent(children): - return t'''
{children}
''' - - def FooterComponent(): - return t'' + def FooterComponent(classes=('footer-default',)): + return t'' - def LayoutComponent(children): + def LayoutComponent(children, body_classes=None): return t''' @@ -151,13 +176,16 @@ def LayoutComponent(children): - {children}<{FooterComponent} /> + + {children} + <{FooterComponent} /> + ''' render_api = render_service_factory() content = 'HTML never goes out of style.' - content_str = render_api.render_template(t'<{LayoutComponent}><{PageComponent}>{content}') + content_str = render_api.render_template(t'<{LayoutComponent} body_classes={["theme-default"]}><{PageComponent}>{content}') assert content_str == ''' @@ -165,20 +193,24 @@ def LayoutComponent(children): -
HTML never goes out of style.
+ +
HTML never goes out of style.
+ + ''' def test_render_template_functions_smoketest(): + """ Broadly test that common template function usage works. """ - def make_page_t(content: str) -> Template: - return t'''
{content}
''' + def make_page_t(content, root_attrs=None) -> Template: + return t'''
{content}
''' - def make_footer_t() -> Template: - return t'' + def make_footer_t(classes=('footer-default',)) -> Template: + return t'' - def make_layout_t(children) -> Template: + def make_layout_t(body_t, body_classes=None) -> Template: footer_t = make_footer_t() return t''' @@ -187,13 +219,16 @@ def make_layout_t(children) -> Template: - {children}{footer_t} + + {body_t} + {footer_t} + ''' render_api = render_service_factory() content = 'HTML never goes out of style.' - layout_t = make_layout_t(make_page_t(content)) + layout_t = make_layout_t(make_page_t(content), "theme-default") content_str = render_api.render_template(layout_t) assert content_str == ''' @@ -202,40 +237,9 @@ def make_layout_t(children) -> Template: -
HTML never goes out of style.
- -''' - -def test_render_template_components_smoketest(): - - def PageComponent(children): - return t'''
{children}
''' - - def FooterComponent(): - return t'' - - def LayoutComponent(children): - return t''' - - - - - - - {children}<{FooterComponent} /> - -''' - - render_api = render_service_factory() - content = 'HTML never goes out of style.' - content_str = render_api.render_template(t'<{LayoutComponent}><{PageComponent}>{content}') - assert content_str == ''' - - - - - - -
HTML never goes out of style.
+ +
HTML never goes out of style.
+ + ''' From 166533631b5bd9f482b0cf41554d154107796460 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Thu, 15 Jan 2026 00:29:15 -0800 Subject: [PATCH 31/81] Call embedded template a children template to avoid confusion. --- tdom/transformer.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tdom/transformer.py b/tdom/transformer.py index 8aa8b29..fb41ce5 100644 --- a/tdom/transformer.py +++ b/tdom/transformer.py @@ -133,22 +133,22 @@ def _prep_cinfo(component_callable, attrs, system): def interpolate_component(render_api, q, bf, last_container_tag, template, ip_info) -> RenderQueueItem | None: """ - - Extract embedded template or use empty template. - - Transform embedded template into struct template. + - Extract children template or use empty template. + - Transform children template into struct template. - Resolve attrs but don't stringify. - Resolve callable. - - Invoke callable with attrs, embedded template, embedded struct template. (no garbage barge? how can we pass the cache?) + - Invoke callable with attrs - If callable returns a result template then * transform it to a struct template * iteratively recurse into that result template and start outputting it """ (container_tag, attrs, start_i_index, end_i_index, body_start_s_index) = ip_info if start_i_index != end_i_index and end_i_index is not None: - # @DESIGN: We extract the embedded template from the original outer template. - embedded_template = render_api.transform_api.extract_embedded_template(template, body_start_s_index, end_i_index) + # @DESIGN: We extract the children template from the original outer template. + children_template = render_api.transform_api.extract_children_template(template, body_start_s_index, end_i_index) else: - embedded_template = Template('') - embedded_struct_t = render_api.process_template(embedded_template) + children_template = Template('') + children_struct_t = render_api.process_template(children_template) resolved_attrs = render_api.resolve_attrs(attrs, template) start_i = template.interpolations[start_i_index] component_callable = start_i.value @@ -156,7 +156,7 @@ def interpolate_component(render_api, q, bf, last_container_tag, template, ip_in raise TypeError('Component callable in start tag must match component callable in end tag.') # @DESIGN: Inject system vars via manager? - system_dict = render_api.get_system(children=embedded_template, children_struct=embedded_struct_t) + system_dict = render_api.get_system(children=children_template, children_struct=children_struct_t) # @DESIGN: Determine return signature from callable info (cached inspection) ? kwargs = _prep_cinfo(component_callable, resolved_attrs, system_dict) res = component_callable(**kwargs) @@ -353,7 +353,7 @@ def has_dynamic_attrs(self, attrs: t.Sequence[TAttribute]) -> bool: return True return False - def extract_embedded_template(self, template: Template, body_start_s_index: int, end_i_index: int) -> Template: + def extract_children_template(self, template: Template, body_start_s_index: int, end_i_index: int) -> Template: """ Extract the template parts exclusively from start tag to end tag. @@ -367,12 +367,12 @@ def extract_embedded_template(self, template: Template, body_start_s_index: int, t'
{content} {footer}
' t'' ) - assert self.extract_embedded_template(template, 2, 4) == ( + assert self.extract_children_template(template, 2, 4) == ( t'
{content} {footer}
' ) starttag = t'<{comp} attr={attr}>' endtag = t'' - assert template == starttag + self.extract_embedded_template(template, 2, 4) + endtag + assert template == starttag + self.extract_children_template(template, 2, 4) + endtag ``` @DESIGN: "There must be a better way." """ From 507457228934c35b2b463c91924aa1097e30e484 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Thu, 15 Jan 2026 12:45:25 -0800 Subject: [PATCH 32/81] Fix indent error. --- tdom/transformer.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tdom/transformer.py b/tdom/transformer.py index fb41ce5..86237b3 100644 --- a/tdom/transformer.py +++ b/tdom/transformer.py @@ -469,17 +469,17 @@ def resolve_text_without_recursion(self, template, container_tag, content_t) -> value = template.interpolations[part.value].value if value is None or value is False: continue - elif isinstance(value , str): + elif isinstance(value, str): if value: text.append(value) - elif isinstance(value, (Template, Iterable)): - raise ValueError(f'Recursive includes are not supported within {container_tag}') - elif hasattr(value, '__html__'): - raise ValueError(f'Non-exact trusted interpolations are not supported within {container_tag}') - else: - value_str = str(value) - if value_str: - text.append(value_str) + elif isinstance(value, (Template, Iterable)): + raise ValueError(f'Recursive includes are not supported within {container_tag}') + elif hasattr(value, '__html__'): + raise ValueError(f'Non-exact trusted interpolations are not supported within {container_tag}') + else: + value_str = str(value) + if value_str: + text.append(value_str) return ''.join(text) def process_template(self, template): From 4b0ac3f9f82a0391e7c82eb048e6cb012a7f50bf Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Thu, 15 Jan 2026 12:59:44 -0800 Subject: [PATCH 33/81] Add some common escape tests. --- tdom/transformer_test.py | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/tdom/transformer_test.py b/tdom/transformer_test.py index eedfca7..55eaf4f 100644 --- a/tdom/transformer_test.py +++ b/tdom/transformer_test.py @@ -1,7 +1,8 @@ from contextvars import ContextVar from string.templatelib import Template -from markupsafe import Markup +from markupsafe import Markup, escape as markupsafe_escape import typing as t +import pytest from .transformer import render_service_factory, cached_render_service_factory, CachedTransformService @@ -243,3 +244,36 @@ def make_layout_t(body_t, body_classes=None) -> Template: ''' + + +def test_text_interpolation_with_dynamic_parent(): + render_api = render_service_factory() + with pytest.raises(ValueError, match='Recursive includes are not supported within script'): + content = '' + content_t = t'{content}' + _ = render_api.render_template(t'') + + +@pytest.mark.skip('Can we allow this?') +def test_escape_escapable_raw_text_with_dynamic_parent(): + content = '' + content_t = t'{content}' + render_api = render_service_factory() + LT, GT, DQ = map(markupsafe_escape, ['<', '>', '"']) + assert render_api.render_template(t'') == f'' + + +def test_escape_structured_text_with_dynamic_parent(): + content = '' + content_t = t'{content}' + render_api = render_service_factory() + LT, GT, DQ = map(markupsafe_escape, ['<', '>', '"']) + assert render_api.render_template(t'
{content_t}
') == f'
{LT}script{GT}console.log({DQ}123!{DQ});{LT}/script{GT}
' + + +def test_escape_structured_text(): + content = '' + content_t = t'
{content}
' + render_api = render_service_factory() + LT, GT, DQ = map(markupsafe_escape, ['<', '>', '"']) + assert render_api.render_template(content_t) == f'
{LT}script{GT}console.log({DQ}123!{DQ});{LT}/script{GT}
' From 3932447887160080fd428b48e8628a7a8f956cad Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Thu, 15 Jan 2026 13:05:59 -0800 Subject: [PATCH 34/81] After switching from yield it seems we don't need to haul around the q arg anymore. --- tdom/transformer.py | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/tdom/transformer.py b/tdom/transformer.py index 86237b3..b6dbaf6 100644 --- a/tdom/transformer.py +++ b/tdom/transformer.py @@ -51,15 +51,13 @@ def render_html_attrs(html_attrs: HTMLAttributesDict, escape: Callable = default class Interpolator(t.Protocol): - def __call__(self, render_api, q, bf, last_container_tag, template, ip_info) -> RenderQueueItem | None: + def __call__(self, render_api, bf, last_container_tag, template, ip_info) -> RenderQueueItem | None: """ Populates an interpolation or returns iterator to decende into. If recursion is required then pushes current iterator render_api The current render api, provides various helper methods to the interpolator. - q - A list-like queue of iterators paired with the container tag the results are in. bf A list-like output buffer. last_container_tag @@ -81,13 +79,13 @@ def __call__(self, render_api, q, bf, last_container_tag, template, ip_info) -> type RenderQueueItem = tuple[str | None, Iterable[tuple[Interpolator, Template, InterpolationInfo]]] -def interpolate_comment(render_api, q, bf, last_container_tag, template, ip_info) -> RenderQueueItem | None: +def interpolate_comment(render_api, bf, last_container_tag, template, ip_info) -> RenderQueueItem | None: container_tag, comment_t = ip_info assert container_tag == '' + yield "-->" case TFragment(children): - q.extend([(last_container_tag, child) for child in reversed(children)]) + q.extend( + [(last_container_tag, child) for child in reversed(children)] + ) case TComponent(start_i_index, end_i_index, attrs, children): - yield self._stream_component_interpolation(last_container_tag, attrs, start_i_index, end_i_index) + yield self._stream_component_interpolation( + last_container_tag, attrs, start_i_index, end_i_index + ) case TElement(tag, attrs, children): - yield f'<{tag}' + yield f"<{tag}" if self.has_dynamic_attrs(attrs): yield self._stream_attrs_interpolation(tag, attrs) else: # @DESIGN: We can't customize the html attrs rendering here because we are not even # in the RENDERER! - yield render_html_attrs(coerce_to_html_attrs(resolve_dynamic_attrs(attrs, interpolations=()))) + yield render_html_attrs( + coerce_to_html_attrs( + resolve_dynamic_attrs(attrs, interpolations=()) + ) + ) # @DESIGN: This is just a want to have. if self.slash_void and tag in VOID_ELEMENTS: - yield ' />' + yield " />" else: - yield '>' + yield ">" if tag not in VOID_ELEMENTS: - q.append((last_container_tag, EndTag(f''))) + q.append((last_container_tag, EndTag(f""))) q.extend([(tag, child) for child in reversed(children)]) case TText(ref): - text_t = Template(*[part if isinstance(part, str) else Interpolation(part, '', None, '') for part in iter(ref)]) + text_t = Template( + *[ + part + if isinstance(part, str) + else Interpolation(part, "", None, "") + for part in iter(ref) + ] + ) if last_container_tag in CDATA_CONTENT_ELEMENTS: # Must be handled all at once. - yield self._stream_raw_text_interpolation(last_container_tag, text_t) + yield self._stream_raw_text_interpolation( + last_container_tag, text_t + ) elif last_container_tag in RCDATA_CONTENT_ELEMENTS: # We can handle all at once because there are no non-text children and everything must be string-ified. - yield self._stream_escapable_raw_text_interpolation(last_container_tag, text_t) + yield self._stream_escapable_raw_text_interpolation( + last_container_tag, text_t + ) else: # Flatten the template back out into the stream because each interpolation can # be escaped as is and structured content can be injected between text anyways. @@ -341,9 +467,11 @@ def streamer(self, root: TNode, last_container_tag: str|None=None) -> t.Iterable if isinstance(part, str): yield part else: - yield self._stream_text_interpolation(last_container_tag, part.value) + yield self._stream_text_interpolation( + last_container_tag, part.value + ) case _: - raise ValueError(f'Unrecognized tnode: {tnode}') + raise ValueError(f"Unrecognized tnode: {tnode}") def has_dynamic_attrs(self, attrs: t.Sequence[TAttribute]) -> bool: for attr in attrs: @@ -351,7 +479,9 @@ def has_dynamic_attrs(self, attrs: t.Sequence[TAttribute]) -> bool: return True return False - def extract_children_template(self, template: Template, body_start_s_index: int, end_i_index: int) -> Template: + def extract_children_template( + self, template: Template, body_start_s_index: int, end_i_index: int + ) -> Template: """ Extract the template parts exclusively from start tag to end tag. @@ -384,15 +514,14 @@ def extract_children_template(self, template: Template, body_start_s_index: int, parts.append(template.interpolations[index]) index += 1 # Now trim the first part to the end of the opening tag. - parts[0] = parts[0][parts[0].find('>')+1:] + parts[0] = parts[0][parts[0].find(">") + 1 :] # Now trim the last part (could also be the first) to the start of the closing tag. - parts[-1] = parts[-1][:parts[-1].rfind('<')] + parts[-1] = parts[-1][: parts[-1].rfind("<")] return Template(*parts) @dataclass(frozen=True) class RenderService: - transform_api: TransformService escape_html_text: Callable = default_escape_html_text @@ -402,7 +531,7 @@ class RenderService: escape_html_content_in_tag: Callable = default_escape_html_content_in_tag def get_system(self, children, children_struct): - return {'children': children, 'children_struct': children_struct} + return {"children": children, "children_struct": children_struct} def render_template(self, template, last_container_tag=None) -> str: """ @@ -420,11 +549,18 @@ def render_template(self, template, last_container_tag=None) -> str: # the end. bf: list[str] = [] q: list[RenderQueueItem] = [] - q.append((last_container_tag, self.walk_template(bf, template, self.process_template(template)))) + q.append( + ( + last_container_tag, + self.walk_template(bf, template, self.process_template(template)), + ) + ) while q: last_container_tag, it = q.pop() - for (interpolator, template, ip_info) in it: - render_queue_item = interpolator(self, bf, last_container_tag, template, ip_info) + for interpolator, template, ip_info in it: + render_queue_item = interpolator( + self, bf, last_container_tag, template, ip_info + ) if render_queue_item is not None: # # Pause the current iterator and push a new iterator on top of it. @@ -432,7 +568,7 @@ def render_template(self, template, last_container_tag=None) -> str: q.append((last_container_tag, it)) q.append(render_queue_item) break - return ''.join(bf) + return "".join(bf) def resolve_text_without_recursion(self, template, container_tag, content_t) -> str: """ @@ -448,13 +584,15 @@ def resolve_text_without_recursion(self, template, container_tag, content_t) -> if exact: value = template.interpolations[parts[0].value].value if value is None or value is False: - return '' + return "" elif isinstance(value, str): return value - elif hasattr(value, '__html__'): + elif hasattr(value, "__html__"): return Markup(value.__html__()) elif isinstance(value, (Template, Iterable)): - raise ValueError(f'Recursive includes are not supported within {container_tag}') + raise ValueError( + f"Recursive includes are not supported within {container_tag}" + ) else: return str(value) else: @@ -471,17 +609,21 @@ def resolve_text_without_recursion(self, template, container_tag, content_t) -> if value: text.append(value) elif isinstance(value, (Template, Iterable)): - raise ValueError(f'Recursive includes are not supported within {container_tag}') - elif hasattr(value, '__html__'): - raise ValueError(f'Non-exact trusted interpolations are not supported within {container_tag}') + raise ValueError( + f"Recursive includes are not supported within {container_tag}" + ) + elif hasattr(value, "__html__"): + raise ValueError( + f"Non-exact trusted interpolations are not supported within {container_tag}" + ) else: value_str = str(value) if value_str: text.append(value_str) - return ''.join(text) + return "".join(text) def process_template(self, template): - """ This is just a wrap-point for caching. """ + """This is just a wrap-point for caching.""" return self.transform_api.transform_template(template) def resolve_attrs(self, attrs, template) -> AttributesDict: @@ -526,19 +668,20 @@ class ContextVarSetter: another part of the program can use to wrap some called code without knowing anything about either. """ - context_values: tuple[tuple[ContextVar, object],...] # Cvar / value pair. - tokens: tuple[Token,...] + + context_values: tuple[tuple[ContextVar, object], ...] # Cvar / value pair. + tokens: tuple[Token, ...] def __init__(self, context_values=()): self.context_values = context_values self.tokens = () def __enter__(self): - """ Set every given context var to its paired value. """ + """Set every given context var to its paired value.""" self.tokens = tuple([var.set(val) for var, val in self.context_values]) def __exit__(self, exc_type, exc_value, traceback): - """ Reset every given context var. """ + """Reset every given context var.""" for idx, var_value in enumerate(self.context_values): var_value[0].reset(self.tokens[idx]) @@ -556,7 +699,6 @@ def cached_render_service_factory(): # @dataclass(frozen=True) class CachedTransformService(TransformService): - @functools.lru_cache(512) def _transform_template(self, cached_template: CachableTemplate) -> Template: return super().transform_template(cached_template.template) @@ -564,4 +706,3 @@ def _transform_template(self, cached_template: CachableTemplate) -> Template: def transform_template(self, values_template: Template) -> Template: ct = CachableTemplate(values_template) return self._transform_template(ct) - diff --git a/tdom/transformer_test.py b/tdom/transformer_test.py index 55eaf4f..0f4e730 100644 --- a/tdom/transformer_test.py +++ b/tdom/transformer_test.py @@ -4,22 +4,28 @@ import typing as t import pytest -from .transformer import render_service_factory, cached_render_service_factory, CachedTransformService +from .transformer import ( + render_service_factory, + cached_render_service_factory, + CachedTransformService, +) -THEME_CTX = ContextVar('theme', default='default') +THEME_CTX = ContextVar("theme", default="default") def test_render_template_smoketest(): - comment_text = 'comment is not literal' - interpolated_class = 'red' - text_in_element = 'text is not literal' + comment_text = "comment is not literal" + interpolated_class = "red" + text_in_element = "text is not literal" templated = "not literal" - spread_attrs={'data-on': True} - markup_content = Markup('
safe
') + spread_attrs = {"data-on": True} + markup_content = Markup("
safe
") + def WrapperComponent(children): - return t'
{children}
' - smoke_t = t''' + return t"
{children}
" + + smoke_t = t""" @@ -30,8 +36,8 @@ def WrapperComponent(children): <{WrapperComponent}>comp body {markup_content} -''' - smoke_str = ''' +""" + smoke_str = """ @@ -42,21 +48,26 @@ def WrapperComponent(children):
comp body
safe
-''' +""" render_api = render_service_factory() assert render_api.render_template(smoke_t) == smoke_str def struct_repr(st): - """ Breakdown Templates into comparable parts for test verification. """ - return st.strings, tuple([(i.value, i.expression, i.conversion, i.format_spec) for i in st.interpolations]) + """Breakdown Templates into comparable parts for test verification.""" + return st.strings, tuple( + [ + (i.value, i.expression, i.conversion, i.format_spec) + for i in st.interpolations + ] + ) def test_process_template_internal_cache(): - """ Test that cache and non-cache both generally work as expected. """ - sample_t = t'''
{'content'}
''' - sample_diff_t = t'''
{'diffcontent'}
''' - alt_t = t'''{'content'}''' + """Test that cache and non-cache both generally work as expected.""" + sample_t = t"""
{"content"}
""" + sample_diff_t = t"""
{"diffcontent"}
""" + alt_t = t"""{"content"}""" render_api = render_service_factory() cached_render_api = cached_render_service_factory() tnode1 = render_api.process_template(sample_t) @@ -97,32 +108,51 @@ def test_process_template_internal_cache(): def test_render_template_repeated(): - """ Crude check for any unintended state being kept between calls. """ + """Crude check for any unintended state being kept between calls.""" + def get_sample_t(idx, spread_attrs, button_text): - return t'''
''' + return t"""
""" + render_apis = (render_service_factory(), cached_render_service_factory()) for render_api in render_apis: for idx in range(3): - spread_attrs = {'data-enabled': True} - button_text = 'RENDER' + spread_attrs = {"data-enabled": True} + button_text = "RENDER" sample_t = get_sample_t(idx, spread_attrs, button_text) - assert render_api.render_template(sample_t) == f'
' + assert ( + render_api.render_template(sample_t) + == f'
' + ) + def test_render_template_iterables(): render_api = render_service_factory() def get_select_t_with_list(options, selected_values): - return t'''''' + return t"""""" + def get_select_t_with_generator(options, selected_values): - return t'''''' + return t"""""" + def get_select_t_with_concat(options, selected_values): - parts = [t'') + parts = [t"") return sum(parts, t"") def get_color_select_t(selected_values: set, provider: t.Callable) -> Template: @@ -130,19 +160,29 @@ def get_color_select_t(selected_values: set, provider: t.Callable) -> Template: assert set(selected_values).issubset(set([opt[0] for opt in PRIMARY_COLORS])) return provider(PRIMARY_COLORS, selected_values) - for provider in (get_select_t_with_list,get_select_t_with_generator,get_select_t_with_concat): - assert render_api.render_template(get_color_select_t(set(), provider)) == '' - assert render_api.render_template(get_color_select_t({'Y'}, provider)) == '' + for provider in ( + get_select_t_with_list, + get_select_t_with_generator, + get_select_t_with_concat, + ): + assert ( + render_api.render_template(get_color_select_t(set(), provider)) + == '' + ) + assert ( + render_api.render_template(get_color_select_t({"Y"}, provider)) + == '' + ) def test_context_provider_pattern(): def ThemeProvider(theme, children): - return children, {'context_values': ((THEME_CTX, theme),)} + return children, {"context_values": ((THEME_CTX, theme),)} def IntermediateWrapper(children): # Wrap in between the provider and consumer just to make sure there # is no direct interaction. - return t'
{children}
' + return t"
{children}
" def ThemeConsumer(children): theme = THEME_CTX.get() @@ -152,25 +192,29 @@ def ThemeConsumer(children): body_t = t"<{ThemeProvider} theme='holiday'><{IntermediateWrapper}><{ThemeConsumer}>Cheers!" # Set the context var to a different value while rendering # to make sure this value will be masked - with THEME_CTX.set('not-the-default'): + with THEME_CTX.set("not-the-default"): # During rendering the provider should overlay a new value. - assert render_api.render_template(body_t) == '

Cheers!

' + assert ( + render_api.render_template(body_t) + == '

Cheers!

' + ) # But afterwards we should be back to the old value. - assert THEME_CTX.get() == 'not-the-default' + assert THEME_CTX.get() == "not-the-default" # But after all that we should be back to the context var's offical default. - assert THEME_CTX.get() == 'default' + assert THEME_CTX.get() == "default" def test_render_template_components_smoketest(): - """ Broadly test that common template component usage works. """ + """Broadly test that common template component usage works.""" + def PageComponent(children, root_attrs=None): - return t'''
{children}
''' + return t"""
{children}
""" - def FooterComponent(classes=('footer-default',)): + def FooterComponent(classes=("footer-default",)): return t'' def LayoutComponent(children, body_classes=None): - return t''' + return t""" @@ -182,12 +226,16 @@ def LayoutComponent(children, body_classes=None): <{FooterComponent} /> -''' +""" render_api = render_service_factory() - content = 'HTML never goes out of style.' - content_str = render_api.render_template(t'<{LayoutComponent} body_classes={["theme-default"]}><{PageComponent}>{content}') - assert content_str == ''' + content = "HTML never goes out of style." + content_str = render_api.render_template( + t"<{LayoutComponent} body_classes={['theme-default']}><{PageComponent}>{content}" + ) + assert ( + content_str + == """ @@ -199,21 +247,22 @@ def LayoutComponent(children, body_classes=None): -''' +""" + ) def test_render_template_functions_smoketest(): - """ Broadly test that common template function usage works. """ + """Broadly test that common template function usage works.""" def make_page_t(content, root_attrs=None) -> Template: - return t'''
{content}
''' + return t"""
{content}
""" - def make_footer_t(classes=('footer-default',)) -> Template: + def make_footer_t(classes=("footer-default",)) -> Template: return t'' def make_layout_t(body_t, body_classes=None) -> Template: footer_t = make_footer_t() - return t''' + return t""" @@ -225,13 +274,15 @@ def make_layout_t(body_t, body_classes=None) -> Template: {footer_t} -''' +""" render_api = render_service_factory() - content = 'HTML never goes out of style.' + content = "HTML never goes out of style." layout_t = make_layout_t(make_page_t(content), "theme-default") content_str = render_api.render_template(layout_t) - assert content_str == ''' + assert ( + content_str + == """ @@ -243,37 +294,49 @@ def make_layout_t(body_t, body_classes=None) -> Template: -''' +""" + ) def test_text_interpolation_with_dynamic_parent(): render_api = render_service_factory() - with pytest.raises(ValueError, match='Recursive includes are not supported within script'): + with pytest.raises( + ValueError, match="Recursive includes are not supported within script" + ): content = '' - content_t = t'{content}' - _ = render_api.render_template(t'') + content_t = t"{content}" + _ = render_api.render_template(t"") -@pytest.mark.skip('Can we allow this?') +@pytest.mark.skip("Can we allow this?") def test_escape_escapable_raw_text_with_dynamic_parent(): content = '' - content_t = t'{content}' + content_t = t"{content}" render_api = render_service_factory() - LT, GT, DQ = map(markupsafe_escape, ['<', '>', '"']) - assert render_api.render_template(t'') == f'' + LT, GT, DQ = map(markupsafe_escape, ["<", ">", '"']) + assert ( + render_api.render_template(t"") + == f"" + ) def test_escape_structured_text_with_dynamic_parent(): content = '' - content_t = t'{content}' + content_t = t"{content}" render_api = render_service_factory() - LT, GT, DQ = map(markupsafe_escape, ['<', '>', '"']) - assert render_api.render_template(t'
{content_t}
') == f'
{LT}script{GT}console.log({DQ}123!{DQ});{LT}/script{GT}
' + LT, GT, DQ = map(markupsafe_escape, ["<", ">", '"']) + assert ( + render_api.render_template(t"
{content_t}
") + == f"
{LT}script{GT}console.log({DQ}123!{DQ});{LT}/script{GT}
" + ) def test_escape_structured_text(): content = '' - content_t = t'
{content}
' + content_t = t"
{content}
" render_api = render_service_factory() - LT, GT, DQ = map(markupsafe_escape, ['<', '>', '"']) - assert render_api.render_template(content_t) == f'
{LT}script{GT}console.log({DQ}123!{DQ});{LT}/script{GT}
' + LT, GT, DQ = map(markupsafe_escape, ["<", ">", '"']) + assert ( + render_api.render_template(content_t) + == f"
{LT}script{GT}console.log({DQ}123!{DQ});{LT}/script{GT}
" + ) From 8bbc0d1c85b12d8697c737deb2c4a8eeefaf459c Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Thu, 15 Jan 2026 15:10:57 -0800 Subject: [PATCH 36/81] Stop pre-processing the component template and just act on return value. --- tdom/transformer.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tdom/transformer.py b/tdom/transformer.py index ff332db..dcc2eb9 100644 --- a/tdom/transformer.py +++ b/tdom/transformer.py @@ -170,7 +170,7 @@ def interpolate_component( ) else: children_template = Template("") - children_struct_t = render_api.process_template(children_template) + # @DESIGN: children_struct_t = render_api.process_template(children_template) ? resolved_attrs = render_api.resolve_attrs(attrs, template) start_i = template.interpolations[start_i_index] component_callable = start_i.value @@ -185,7 +185,7 @@ def interpolate_component( # @DESIGN: Inject system vars via manager? system_dict = render_api.get_system( - children=children_template, children_struct=children_struct_t + children=children_template #@DESIGN: children_struct=children_struct_t ? ) # @DESIGN: Determine return signature from callable info (cached inspection) ? kwargs = _prep_cinfo(component_callable, resolved_attrs, system_dict) @@ -530,8 +530,9 @@ class RenderService: escape_html_content_in_tag: Callable = default_escape_html_content_in_tag - def get_system(self, children, children_struct): - return {"children": children, "children_struct": children_struct} + def get_system(self, **kwargs): + # @DESIGN: Maybe inject more here? + return {**kwargs} def render_template(self, template, last_container_tag=None) -> str: """ From aad23649337c22339e8e39d14a5fca39e8c3c51c Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Thu, 15 Jan 2026 15:11:32 -0800 Subject: [PATCH 37/81] Start cleaning comments. --- tdom/transformer.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/tdom/transformer.py b/tdom/transformer.py index dcc2eb9..0efa691 100644 --- a/tdom/transformer.py +++ b/tdom/transformer.py @@ -63,7 +63,8 @@ def __call__( """ Populates an interpolation or returns iterator to decende into. - If recursion is required then pushes current iterator + If recursion is required then pushes current iterator. + render_api The current render api, provides various helper methods to the interpolator. bf @@ -152,16 +153,6 @@ def _prep_cinfo(component_callable, attrs, system): def interpolate_component( render_api, bf, last_container_tag, template, ip_info ) -> RenderQueueItem | None: - """ - - Extract children template or use empty template. - - Transform children template into struct template. - - Resolve attrs but don't stringify. - - Resolve callable. - - Invoke callable with attrs - - If callable returns a result template then - * transform it to a struct template - * iteratively recurse into that result template and start outputting it - """ (container_tag, attrs, start_i_index, end_i_index, body_start_s_index) = ip_info if start_i_index != end_i_index and end_i_index is not None: # @DESIGN: We extract the children template from the original outer template. From 1a4bd02a9f7875a4486c75e4e3e428f3600bbe45 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Thu, 15 Jan 2026 15:16:11 -0800 Subject: [PATCH 38/81] Stop proxying transform api and just call it directly. --- tdom/transformer.py | 12 ++++-------- tdom/transformer_test.py | 12 ++++++------ 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/tdom/transformer.py b/tdom/transformer.py index 0efa691..7bc72d7 100644 --- a/tdom/transformer.py +++ b/tdom/transformer.py @@ -161,7 +161,7 @@ def interpolate_component( ) else: children_template = Template("") - # @DESIGN: children_struct_t = render_api.process_template(children_template) ? + # @DESIGN: children_struct_t = render_api.transform_api.transform_template(children_template) ? resolved_attrs = render_api.resolve_attrs(attrs, template) start_i = template.interpolations[start_i_index] component_callable = start_i.value @@ -194,7 +194,7 @@ def interpolate_component( context_values = comp_info.get("context_values", ()) if comp_info else () if result_template: - result_struct = render_api.process_template(result_template) + result_struct = render_api.transform_api.transform_template(result_template) if context_values: walker = render_api.walk_template_with_context( bf, result_template, result_struct, context_values=context_values @@ -272,7 +272,7 @@ def interpolate_text( container_tag, iter( render_api.walk_template( - bf, value, render_api.process_template(value) + bf, value, render_api.transform_api.transform_template(value) ) ), ) @@ -544,7 +544,7 @@ def render_template(self, template, last_container_tag=None) -> str: q.append( ( last_container_tag, - self.walk_template(bf, template, self.process_template(template)), + self.walk_template(bf, template, self.transform_api.transform_template(template)), ) ) while q: @@ -614,10 +614,6 @@ def resolve_text_without_recursion(self, template, container_tag, content_t) -> text.append(value_str) return "".join(text) - def process_template(self, template): - """This is just a wrap-point for caching.""" - return self.transform_api.transform_template(template) - def resolve_attrs(self, attrs, template) -> AttributesDict: """ - interpolate interpolations diff --git a/tdom/transformer_test.py b/tdom/transformer_test.py index 0f4e730..589b743 100644 --- a/tdom/transformer_test.py +++ b/tdom/transformer_test.py @@ -70,11 +70,11 @@ def test_process_template_internal_cache(): alt_t = t"""{"content"}""" render_api = render_service_factory() cached_render_api = cached_render_service_factory() - tnode1 = render_api.process_template(sample_t) - tnode2 = render_api.process_template(sample_t) - cached_tnode1 = cached_render_api.process_template(sample_t) - cached_tnode2 = cached_render_api.process_template(sample_t) - cached_tnode3 = cached_render_api.process_template(sample_diff_t) + tnode1 = render_api.transform_api.transform_template(sample_t) + tnode2 = render_api.transform_api.transform_template(sample_t) + cached_tnode1 = cached_render_api.transform_api.transform_template(sample_t) + cached_tnode2 = cached_render_api.transform_api.transform_template(sample_t) + cached_tnode3 = cached_render_api.transform_api.transform_template(sample_diff_t) # Check that the uncached and cached services are actually # returning non-identical results. assert tnode1 is not cached_tnode1 @@ -99,7 +99,7 @@ def test_process_template_internal_cache(): assert ci.hits == 2 # cached_tnode1 was a miss because cache was empty (brand new) assert ci.misses == 1 - cached_tnode4 = cached_render_api.process_template(alt_t) + cached_tnode4 = cached_render_api.transform_api.transform_template(alt_t) # A different template produces a brand new tnode. assert cached_tnode1 is not cached_tnode4 # The template is new AND has a different structure so it also From 6e337fbef2ed8ace20ce8bbe92141f71b1990f84 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Fri, 16 Jan 2026 13:35:39 -0800 Subject: [PATCH 39/81] Draft of using types. --- tdom/transformer.py | 201 ++++++++++++++++++++++++++++++++------------ 1 file changed, 148 insertions(+), 53 deletions(-) diff --git a/tdom/transformer.py b/tdom/transformer.py index 7bc72d7..cfee555 100644 --- a/tdom/transformer.py +++ b/tdom/transformer.py @@ -33,9 +33,10 @@ from .utils import CachableTemplate from .processor import ( _resolve_t_attrs as resolve_dynamic_attrs, - AttributesDict, _resolve_html_attrs as coerce_to_html_attrs, _kebab_to_snake, + HasHTMLDunder, + AttributesDict, ) from .callables import get_callable_info @@ -56,9 +57,22 @@ def render_html_attrs( ) -class Interpolator(t.Protocol): +type InterpolateInfo = tuple + + +type RenderQueueItem = tuple[ + str | None, t.Iterable[tuple[InterpolatorProto, Template, InterpolateInfo]] +] + + +class InterpolatorProto(t.Protocol): def __call__( - self, render_api, bf, last_container_tag, template, ip_info + self, + render_api: RenderService, + bf: list[str], + last_container_tag: str | None, + template: Template, + ip_info: InterpolateInfo, ) -> RenderQueueItem | None: """ Populates an interpolation or returns iterator to decende into. @@ -79,21 +93,20 @@ def __call__( Returns a render queue item when the main iteration loops needs to be paused and restarted to descend. """ - pass - + raise NotImplementedError -type InterpolationInfo = object | None - -type RenderQueueItem = tuple[ - str | None, Iterable[tuple[Interpolator, Template, InterpolationInfo]] -] +type InterpolateCommentInfo = tuple[str, Template] def interpolate_comment( - render_api, bf, last_container_tag, template, ip_info + render_api: RenderService, + bf: list[str], + last_container_tag: str | None, + template: Template, + ip_info: InterpolateInfo, ) -> RenderQueueItem | None: - container_tag, comment_t = ip_info + container_tag, comment_t = t.cast(InterpolateCommentInfo, ip_info) assert container_tag == "" + escaped_text = "-->" + out = escape_html_comment(Markup(input_text), allow_markup=False) + assert out != input_text and out == escaped_text + out = escape_html_comment(Markup(input_text), allow_markup=True) + assert out == input_text and out != escaped_text + + def test_escape_html_style() -> None: input_text = "body { color: red; } p { font-SIZE: 12px; }" expected_output = ( @@ -35,6 +55,15 @@ def test_escape_html_style() -> None: assert escape_html_style(input_text) == expected_output +def test_escape_html_style_markup() -> None: + input_text = "" + escaped_text = "</STYLE>" + out = escape_html_style(Markup(input_text), allow_markup=False) + assert out != input_text and out == escaped_text + out = escape_html_style(Markup(input_text), allow_markup=True) + assert out == input_text and out != escaped_text + + def test_escape_html_script() -> None: input_text = "