diff --git a/tdom/escaping.py b/tdom/escaping.py index 2caebba..726f00c 100644 --- a/tdom/escaping.py +++ b/tdom/escaping.py @@ -2,6 +2,9 @@ from markupsafe import escape as markup_escape +from .protocols import HasHTMLDunder + + escape_html_text = markup_escape # unify api for test of project @@ -9,10 +12,16 @@ LT = "<" -def escape_html_comment(text: str) -> str: +def escape_html_comment(text: str, allow_markup: bool = False) -> str: """Escape text injected into an HTML comment.""" if not text: return text + elif allow_markup and isinstance(text, HasHTMLDunder): + return text.__html__() + elif not allow_markup and type(text) is not str: + # text manipulation triggers regular html escapes on Markup + text = str(text) + # - text must not start with the string ">" if text[0] == ">": text = GT + text[1:] @@ -39,8 +48,10 @@ def escape_html_comment(text: str) -> str: STYLE_RES = ((re.compile("style)>", re.I | re.A), LT + r"/\g>"),) -def escape_html_style(text: str) -> str: +def escape_html_style(text: str, allow_markup: bool = False) -> str: """Escape text injected into an HTML style element.""" + if allow_markup and isinstance(text, HasHTMLDunder): + return text.__html__() for matche_re, replace_text in STYLE_RES: text = re.sub(matche_re, replace_text, text) return text @@ -62,7 +73,7 @@ def escape_html_style(text: str) -> str: ) -def escape_html_script(text: str) -> str: +def escape_html_script(text: str, allow_markup: bool = False) -> str: """ Escape text injected into an HTML script element. @@ -75,6 +86,8 @@ def escape_html_script(text: str) -> str: - " None: + assert escape_html_text("
") == "<div>" def test_escape_html_comment_empty() -> None: @@ -27,6 +38,15 @@ def test_escape_html_comment_ends_with_lt_dash() -> None: assert escape_html_comment("This is a comment None: + input_text = "-->" + 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 = "" + case TFragment(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 + ) + case TElement(tag, attrs, children): + 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=()) + ) + ) + # @DESIGN: 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 TText(ref): + text_t = Template( + *[ + part + if isinstance(part, str) + else Interpolation(part, "", None, "") + for part in iter(ref) + ] + ) + if ref.is_literal: + yield ref.strings[0] # Trust literals. + elif last_container_tag is None: + # We can't know how to handle this right now, so wait until render time and if + # we still cannot know then probably fail. + yield self._stream_dynamic_texts_interpolation( + last_container_tag, text_t + ) + elif last_container_tag in CDATA_CONTENT_ELEMENTS: + # Must be handled all at once. + yield self._stream_raw_texts_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_texts_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_normal_text_interpolation( + last_container_tag, part.value + ) + case _: + raise ValueError(f"Unrecognized tnode: {tnode}") + + def has_dynamic_attrs(self, attrs: t.Sequence[TAttribute]) -> bool: + """ + Determine if any attributes with interpolations are in attrs sequence. + + This is mainly used to tell if we can pre-emptively serialize an + element's attributes (or not). + """ + for attr in attrs: + if not isinstance(attr, TLiteralAttribute): + return True + return False + + +def resolve_text_without_recursion( + template: Template, container_tag: str, content_t: Template +) -> str | None: + """ + 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. + """ + # @TODO: We should use formatting but not in a way that + # auto-interpolates structured values. + + if len(content_t.interpolations) == 1 and content_t.strings == ("", ""): + i_index = t.cast(int, content_t.interpolations[0].value) + value = template.interpolations[i_index].value + if value is None: + return None + elif isinstance(value, str): + # @DESIGN: Markup() must be used explicitly if you want __html__ supported. + return value + elif isinstance(value, (Template, Iterable)): + raise ValueError( + f"Recursive includes are not supported within {container_tag}" + ) + else: + return str(value) + else: + text = [] + for part in content_t: + if isinstance(part, str): + if part: + text.append(part) + continue + value = template.interpolations[part.value].value + if value is None: + continue + elif ( + type(value) is str + ): # type() check to avoid subclasses, probably something smarter here + if value: + text.append(value) + elif not isinstance(value, str) and 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) + if text: + return "".join(text) + else: + return None + + +def determine_body_start_s_index(tcomp): + """ + Calculate the strings index when the embedded template starts after a component start tag. + + This doesn't actually know or care if the component has a body it just + counts past the dynamic (non-literal) attributes and returns the first strings index + offset by interpolation index for the component callable itself. + """ + return ( + tcomp.start_i_index + + 1 + + len([1 for attr in tcomp.attrs if not isinstance(attr, TLiteralAttribute)]) + ) + + +def extract_embedded_template( + 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 extract_children_template(template, 2, 4) == ( + t'
{content} {footer}
' + ) + starttag = t'<{comp} attr={attr}>' + endtag = t'' + assert template == starttag + extract_children_template(template, 2, 4) + endtag + ``` + @DESIGN: "There must be a better way." + """ + # Copy the parts out of the containing template. + index = body_start_s_index + last_s_index = end_i_index + parts = [] + while index <= last_s_index: + parts.append(template.strings[index]) + if index != last_s_index: + 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(frozen=True) +class RenderService: + transform_api: TransformService + + escape_html_text: Callable = default_escape_html_text + + escape_html_comment: Callable = default_escape_html_comment + + escape_html_script: Callable = default_escape_html_script + + escape_html_style: Callable = default_escape_html_style + + def get_system(self, **kwargs: object): + # @DESIGN: Maybe inject more here? + return {**kwargs} + + def make_render_queue_item( + self, + last_container_tag: str | None, + it: t.Iterable[tuple[InterpolatorProto, Template, InterpolateInfo]], + ) -> RenderQueueItem: + """ + Coerce args into standard structure. + + This is almost only here for tracking and readability. + """ + return (last_container_tag, it) + + def render_template( + self, template: Template, last_container_tag: str | None = None + ) -> str: + """ + 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. + """ + # @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.transform_api.transform_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 + ) + if render_queue_item is not None: + # + # Pause the current iterator and push a new iterator on top of it. + # + q.append((last_container_tag, it)) + q.append(render_queue_item) + break + return "".join(bf) + + def resolve_attrs( + self, attrs: t.Sequence[TAttribute], template: Template + ) -> AttributesDict: + return resolve_dynamic_attrs(attrs, template.interpolations) + + def walk_template_with_context( + self, + bf: list[str], + template: Template, + struct_t: Template, + context_values: tuple[tuple[ContextVar, object], ...] = (), + ) -> Iterable[tuple[InterpolatorProto, Template, InterpolateInfo]]: + if context_values: + cm = ContextVarSetter(context_values=context_values) + else: + cm = nullcontext() + with cm: + yield from self.walk_template(bf, template, struct_t) + + def walk_template( + self, bf: list[str], template: Template, struct_t: Template + ) -> Iterable[tuple[InterpolatorProto, Template, InterpolateInfo]]: + strings = struct_t.strings + ips = struct_t.interpolations + last_str = len(strings) - 1 + idx = 0 + while idx != last_str: + if strings[idx]: + bf.append(strings[idx]) + # @TODO: Should the template just be jammed in here too? + populate, value = ips[idx].value + yield (populate, template, value) + idx += 1 + if strings[idx]: + bf.append(strings[idx]) + + def walk_dynamic_template( + self, bf: list[str], template: Template, text_t: Template, container_tag: str + ) -> t.Iterable[tuple[InterpolatorProto, Template, InterpolateInfo]]: + """ + Walk a `Text()` template that we determined was usable at runtime. + + This happens when a container tag isn't resolvable at parse time and we + have to discover it at runtime. + + bf: + The buffer to write strings out to. + template: + The original values template. + text_t: + A template with i_index references to the original values template. + container_tag: + The tag of the containing element. + """ + strings = text_t.strings + ips = text_t.interpolations + last_s_index = len(strings) - 1 + idx = 0 + while idx < last_s_index: + if strings[idx]: + bf.append(strings[idx]) + ip_info = (container_tag, ips[idx].value) + yield (interpolate_normal_text_from_interpolation, template, ip_info) + idx += 1 + if strings[idx]: + bf.append(strings[idx]) + + +class ContextVarSetter: + """ + 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=()): + self.context_values = 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]) + + +def render_service_factory(transform_api_kwargs=None): + return RenderService(transform_api=TransformService(**(transform_api_kwargs or {}))) + + +def cached_render_service_factory(transform_api_kwargs=None): + return RenderService( + transform_api=CachedTransformService(**(transform_api_kwargs or {})) + ) + + +# +# SHIM: This is here until we can find a way to make a configurable cache. +# +@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) + + def transform_template(self, template: Template) -> Template: + ct = CachableTemplate(template) + return self._transform_template(ct) diff --git a/tdom/transformer_test.py b/tdom/transformer_test.py new file mode 100644 index 0000000..c901168 --- /dev/null +++ b/tdom/transformer_test.py @@ -0,0 +1,546 @@ +from contextvars import ContextVar +from string.templatelib import Template +from markupsafe import Markup, escape as markupsafe_escape +import typing as t +import pytest +from dataclasses import dataclass +from collections.abc import Callable +from itertools import chain + +from .transformer import ( + render_service_factory, + cached_render_service_factory, + CachedTransformService, + RenderService, + TransformService, +) + + +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" + templated = "not literal" + spread_attrs = {"data-on": True} + markup_content = Markup("
safe
") + + def WrapperComponent(children): + return t"
{children}
" + + smoke_t = t""" + + + +literal + +{text_in_element} +{text_in_element} +<{WrapperComponent}>comp body +{markup_content} + +""" + smoke_str = """ + + + +literal + +text is not literal +text is not literal +
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 + ] + ) + + +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() + 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 + 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 + # 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() + # 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.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 + # 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()) + 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 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"") + + +@pytest.mark.parametrize( + "provider", + ( + get_select_t_with_list, + get_select_t_with_generator, + get_select_t_with_concat, + ), +) +def test_render_template_iterables(provider): + render_api = render_service_factory() + + 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) + + no_selection_t = get_color_select_t(set(), provider) + assert ( + render_api.render_template(no_selection_t) + == '' + ) + selected_yellow_t = get_color_select_t({"Y"}, provider) + assert ( + render_api.render_template(selected_yellow_t) + == '' + ) + + +def test_context_provider_pattern(): + def ThemeProvider(theme, children): + 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}
" + + def ThemeConsumer(children): + theme = THEME_CTX.get() + return t'

{children}

' + + render_api = render_service_factory() + 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 FooterComponent(classes=("footer-default",)): + return t'' + + def LayoutComponent(children, body_classes=None): + return t""" + + + + + + + + {children} + <{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 + == """ + + + + + + + +
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, root_attrs=None) -> Template: + return t"""
{content}
""" + + 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""" + + + + + + + + {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), "theme-default") + content_str = render_api.render_template(layout_t) + assert ( + content_str + == """ + + + + + + + +
HTML never goes out of style.
+ + + +""" + ) + + +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}
" + ) + + +@dataclass +class Pager: + left_pages: tuple = () + page: int = 0 + right_pages: tuple = () + prev_page: int | None = None + next_page: int | None = None + + +@dataclass +class PagerDisplay: + pager: Pager + paginate_url: Callable[[int], str] + root_classes: tuple[str, ...] = ("cb", "tc", "w-100") + part_classes: tuple[str, ...] = ("dib", "pa1") + + def __call__(self) -> Template: + parts = [t"
"] + if self.pager.prev_page: + parts.append( + t"Prev" + ) + for left_page in self.pager.left_pages: + parts.append( + t'{left_page}' + ) + parts.append(t"{self.pager.page}") + for right_page in self.pager.right_pages: + parts.append( + t'{right_page}' + ) + if self.pager.next_page: + parts.append( + t"Next" + ) + parts.append(t"
") + return Template(*chain.from_iterable(parts)) + + +def test_class_component(): + def paginate_url(page: int) -> str: + return f"/pages?page={page}" + + def Footer(pager, paginate_url, footer_classes=("footer",)) -> Template: + return t"
<{PagerDisplay} pager={pager} paginate_url={paginate_url} />
" + + pager = Pager( + left_pages=(1, 2), page=3, right_pages=(4, 5), next_page=6, prev_page=None + ) + content_t = t"<{Footer} pager={pager} paginate_url={paginate_url} />" + render_api = render_service_factory() + res = render_api.render_template(content_t) + print(res) + assert ( + res + == '' + ) + + +def test_mathml(): + num = 1 + denom = 3 + mathml_t = t"""

+ The fraction + + + {num} + {denom} + + + is not a decimal number. +

""" + render_api = render_service_factory() + res = render_api.render_template(mathml_t) + assert ( + str(res) + == """

+ The fraction + + + 1 + 3 + + + is not a decimal number. +

""" + ) + + +def test_svg(): + cx, cy, r, fill = 150, 100, 80, "green" + svg_t = t""" + + + SVG +""" + render_api = render_service_factory() + res = render_api.render_template(svg_t) + assert ( + str(res) + == """ + + + SVG +""" + ) + + +@pytest.mark.skip("""Need foreign element mode. Could work like last container.""") +def test_svg_self_closing_empty_elements(): + cx, cy, r, fill = 150, 100, 80, "green" + svg_t = t""" + + + SVG +""" + render_api = render_service_factory() + res = render_api.render_template(svg_t) + assert ( + str(res) + == """ + + + SVG +""" + ) + + +@dataclass +class FakeUser: + name: str + id: int + + +@dataclass +class FakeRequest: + user: FakeUser | None = None + + +@dataclass(frozen=True) +class RequestRenderService(RenderService): + request: FakeRequest | None = None + + def get_system(self, **kwargs): + return {**kwargs, "request": self.request} + + +class UserProto(t.Protocol): + name: str + + +class RequestProto(t.Protocol): + user: UserProto | None + + +def test_system_context(): + """Test providing context to components horizontally via *extra* system provided kwargs.""" + + def request_render_api(request): + return RequestRenderService(request=request, transform_api=TransformService()) + + def UserStatus(request: RequestProto, children: Template | None = None) -> Template: + user = request.user + if user: + classes = ("account-online",) + status_t = t"Logged in as {user.name}" + else: + classes = ("account-offline",) + status_t = t"Not logged in" + return t"" + + page_t = t"""
<{UserStatus}>
""" + render_api = request_render_api(FakeRequest(user=FakeUser(name="Guido", id=1000))) + res = render_api.render_template(page_t) + assert ( + res + == """
""" + ) + render_api = request_render_api(FakeRequest(user=None)) + res = render_api.render_template(page_t) + assert ( + res + == """
""" + ) + + render_api = RenderService(transform_api=TransformService()) + with pytest.raises(TypeError) as excinfo: + res = render_api.render_template(page_t) + assert "Missing required parameters" in str(excinfo.value)