Skip to content

Commit b2287f1

Browse files
Component Process API Remix (#118)
* Refactor component processor into injectable helper class. * Allow extra attrs to be provided. * Test replacing the component processor. * Add callable check back into default component processor. * Flush out simple dependency injection pattern. * Expand example to include a provided attribute. * Internalize the 2nd call for factory components in the injected api, adjust types to match reality. * Add exceptional flags. * Cleanup after rebase. * Move extension tests into their own test module. * Drop capturing object for middleware api until we have middleware. * Simplify extension test. * Draft docstrings for flags in prep_component_kwargs.
1 parent c468593 commit b2287f1

3 files changed

Lines changed: 268 additions & 44 deletions

File tree

tdom/processor.py

Lines changed: 159 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -386,8 +386,37 @@ def _prep_component_kwargs(
386386
callable_info: CallableInfo,
387387
attrs: AttributesDict,
388388
children: Template,
389+
provided_attrs: tuple[Attribute, ...] = (),
390+
raise_on_requires_positional=True,
391+
raise_on_missing=True,
389392
) -> AttributesDict:
390-
if callable_info.requires_positional:
393+
"""
394+
Matchup kwargs from multiple sources to target the given callable.
395+
396+
`provided_attrs`:
397+
These can be used by extensions that want to provide
398+
attrs even if they are not specified in the component's `attrs` in
399+
the template. If an attribute with the same name is provided in
400+
`attrs` then it takes priority over entries in `provided_attrs`.
401+
@NOTE: These will be injected into any component with `**kwargs`
402+
in their signature unless provided already by `attrs`.
403+
404+
`raise_on_requires_positional`:
405+
Optionally check and raise `TypeError` if the `callable_info` requires
406+
positional arguments which we cannot fulfill normally.
407+
An exception might not be desired if the caller will finish preparing
408+
the arguments after this call.
409+
410+
`raise_on_missing`:
411+
Optionally check and raise `TypeError` if we are not able to fulfill all
412+
the arguments the `callable_info` expects since in the common case this
413+
raise an exception whose cause might not be clear.
414+
An exception might not be desired if the caller will finish preparing
415+
the arguments after this call.
416+
"""
417+
418+
# We can't know what kwarg to put here...
419+
if raise_on_requires_positional and callable_info.requires_positional:
391420
raise TypeError(
392421
"Component callables cannot have required positional arguments."
393422
)
@@ -403,12 +432,20 @@ def _prep_component_kwargs(
403432
if "children" in callable_info.named_params or callable_info.kwargs:
404433
kwargs["children"] = children
405434

435+
# Add in provided attrs if they haven't been set already and are wanted.
436+
for pattr_name, pattr_value in provided_attrs:
437+
if pattr_name not in kwargs and (
438+
pattr_name in callable_info.named_params or callable_info.kwargs
439+
):
440+
kwargs[pattr_name] = pattr_value
441+
406442
# Check to make sure we've fully satisfied the callable's requirements
407-
missing = callable_info.required_named_params - kwargs.keys()
408-
if missing:
409-
raise TypeError(
410-
f"Missing required parameters for component: {', '.join(missing)}"
411-
)
443+
if raise_on_missing:
444+
missing = callable_info.required_named_params - kwargs.keys()
445+
if missing:
446+
raise TypeError(
447+
f"Missing required parameters for component: {', '.join(missing)}"
448+
)
412449

413450
return kwargs
414451

@@ -497,6 +534,90 @@ def to_tnode(self, template: Template) -> TNode:
497534
return self._to_tnode(CachableTemplate(template))
498535

499536

537+
class IComponentProcessor(t.Protocol):
538+
"""Isolate component processing to allow for replacement."""
539+
540+
def process(
541+
self,
542+
template: Template,
543+
last_ctx: ProcessContext,
544+
component_callable: t.Annotated[object, ComponentCallable],
545+
attrs: tuple[TAttribute, ...],
546+
component_template: Template,
547+
provided_attrs: tuple[Attribute, ...] = (),
548+
) -> Template:
549+
"""
550+
Process available component details into a Template.
551+
"""
552+
...
553+
554+
555+
class ComponentProcessor(IComponentProcessor):
556+
"""
557+
Default component processor.
558+
"""
559+
560+
def process(
561+
self,
562+
template: Template,
563+
last_ctx: ProcessContext,
564+
component_callable: t.Annotated[object, ComponentCallable],
565+
attrs: tuple[TAttribute, ...],
566+
component_template: Template,
567+
provided_attrs: tuple[Attribute, ...] = (),
568+
) -> Template:
569+
"""
570+
Process available component details into a Template.
571+
572+
There are two general "styles" supported:
573+
574+
1. FunctionComponent
575+
576+
Calling `component_callable` with the prepared kwargs should
577+
return a `Template`.
578+
579+
The primary purpose of this style is to support
580+
using a normal function as a component.
581+
582+
2. FactoryComponent
583+
584+
Calling `component_callable` with the prepared kwargs should
585+
return another `Callable` which when called with no arguments should
586+
return a `Template`.
587+
588+
The primary purpose of this style is to support
589+
using a `dataclass` with `def __call__(self) -> Template` as a
590+
component.
591+
"""
592+
if not callable(component_callable):
593+
raise TypeError(
594+
f"Component callable must be callable: {type(component_callable)}"
595+
)
596+
kwargs = _prep_component_kwargs(
597+
get_callable_info(component_callable),
598+
_resolve_t_attrs(attrs, template.interpolations),
599+
children=component_template,
600+
provided_attrs=provided_attrs,
601+
raise_on_requires_positional=True,
602+
raise_on_missing=True,
603+
)
604+
res1 = component_callable(**kwargs) # ty: ignore[call-top-callable]
605+
if isinstance(res1, Template):
606+
return res1
607+
elif callable(res1):
608+
res2 = res1() # ty: ignore[call-top-callable]
609+
if isinstance(res2, Template):
610+
return res2
611+
else:
612+
raise TypeError(
613+
f"Component object must return Template when called: {type(res2)}"
614+
)
615+
else:
616+
raise TypeError(
617+
f"Component callable must return Template or Callable: {type(res1)}"
618+
)
619+
620+
500621
class ITemplateProcessor(t.Protocol):
501622
def process(self, root_template: Template, assume_ctx: ProcessContext) -> str: ...
502623

@@ -505,6 +626,10 @@ def process(self, root_template: Template, assume_ctx: ProcessContext) -> str: .
505626
class TemplateProcessor(ITemplateProcessor):
506627
parser_api: ITemplateParserProxy = field(default_factory=CachedTemplateParserProxy)
507628

629+
component_processor_api: IComponentProcessor = field(
630+
default_factory=ComponentProcessor
631+
)
632+
508633
escape_html_text: Callable = default_escape_html_text
509634

510635
escape_html_comment: Callable = default_escape_html_comment
@@ -668,60 +793,52 @@ def _process_attrs(
668793
return attrs_str
669794
return ""
670795

671-
def _process_component(
796+
def _extract_component_template(
672797
self,
673798
template: Template,
674-
last_ctx: ProcessContext,
675799
attrs: tuple[TAttribute, ...],
676800
start_i_index: int,
677801
end_i_index: int | None,
678-
) -> str:
679-
"""
680-
Invoke a component and process the result into a string.
681-
"""
802+
check_callables: bool = True,
803+
) -> Template:
682804
body_start_s_index = (
683805
start_i_index
684806
+ 1
685807
+ len([1 for attr in attrs if not isinstance(attr, TLiteralAttribute)])
686808
)
687-
start_i = template.interpolations[start_i_index]
688-
component_callable = t.cast(ComponentCallable, start_i.value)
689809
if start_i_index != end_i_index and end_i_index is not None:
690810
# @TODO: We should do this during parsing.
691-
children_template = extract_embedded_template(
692-
template, body_start_s_index, end_i_index
693-
)
694-
if component_callable != template.interpolations[end_i_index].value:
811+
if (
812+
check_callables
813+
and template.interpolations[start_i_index].value
814+
!= template.interpolations[end_i_index].value
815+
):
695816
raise TypeError(
696817
"Component callable in start tag must match component callable in end tag."
697818
)
819+
return extract_embedded_template(template, body_start_s_index, end_i_index)
698820
else:
699-
children_template = t""
821+
return t""
700822

701-
if not callable(component_callable):
702-
raise TypeError("Component callable must be callable.")
703-
704-
kwargs = _prep_component_kwargs(
705-
get_callable_info(component_callable),
706-
_resolve_t_attrs(attrs, template.interpolations),
707-
children=children_template,
823+
def _process_component(
824+
self,
825+
template: Template,
826+
last_ctx: ProcessContext,
827+
attrs: tuple[TAttribute, ...],
828+
start_i_index: int,
829+
end_i_index: int | None,
830+
) -> str:
831+
"""
832+
Invoke a component and process the result into a string.
833+
"""
834+
children_template = self._extract_component_template(
835+
template, attrs, start_i_index, end_i_index, check_callables=True
708836
)
709-
710-
result_t = component_callable(**kwargs)
711-
if (
712-
result_t is not None
713-
and not isinstance(result_t, Template)
714-
and callable(result_t)
715-
):
716-
component_obj = t.cast(ComponentObject, result_t) # ty: ignore[redundant-cast]
717-
result_t = component_obj()
718-
else:
719-
component_obj = None
720-
721-
if isinstance(result_t, Template):
722-
return self._process_template(result_t, last_ctx)
723-
else:
724-
raise TypeError(f"Unknown component return value: {type(result_t)}")
837+
component_callable = template.interpolations[start_i_index].value
838+
result_t = self.component_processor_api.process(
839+
template, last_ctx, component_callable, attrs, children_template
840+
)
841+
return self._process_template(result_t, last_ctx)
725842

726843
def _process_raw_texts(
727844
self,

tdom/processor_extension_test.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
from contextvars import ContextVar
2+
from dataclasses import dataclass, field
3+
from string.templatelib import Template
4+
5+
from .processor import (
6+
Attribute,
7+
ComponentProcessor,
8+
IComponentProcessor,
9+
ProcessContext,
10+
TemplateProcessor,
11+
)
12+
from .tnodes import TAttribute
13+
14+
15+
@dataclass(frozen=True, slots=True)
16+
class AppState:
17+
theme_class: str
18+
19+
20+
AppStateCtx: ContextVar[AppState | None] = ContextVar("AppStateCtx", default=None)
21+
22+
23+
class TestComponentProcessor:
24+
@dataclass
25+
class Body:
26+
children: Template
27+
28+
def __call__(self) -> Template:
29+
return t"<body>{self.children}</body>"
30+
31+
@dataclass
32+
class Header:
33+
children: Template
34+
35+
app_state: AppState
36+
37+
hdr_class: str = "hdr"
38+
39+
def __call__(self) -> Template:
40+
return t"<div class={self.hdr_class} class={self.app_state.theme_class}>{self.children}</div>"
41+
42+
@dataclass
43+
class AppStateComponentProcessor(IComponentProcessor):
44+
# Delegate to the default processor to reuse code.
45+
default_component_processor_api: IComponentProcessor = field(
46+
default_factory=ComponentProcessor
47+
)
48+
49+
def process(
50+
self,
51+
template: Template,
52+
last_ctx: ProcessContext,
53+
component_callable: object,
54+
attrs: tuple[TAttribute, ...],
55+
component_template: Template,
56+
provided_attrs: tuple[Attribute, ...] = (),
57+
) -> Template:
58+
# For now we just make the app state available to EVERY component
59+
# a smarter strategy would be to only include it if asked via
60+
# the callable's signature or even the callable's typehints.
61+
# But for a test this is OK.
62+
app_state = AppStateCtx.get()
63+
extended_attrs = provided_attrs + (("app_state", app_state),)
64+
return self.default_component_processor_api.process(
65+
template=template,
66+
last_ctx=last_ctx,
67+
component_callable=component_callable,
68+
attrs=attrs,
69+
component_template=component_template,
70+
provided_attrs=extended_attrs,
71+
)
72+
73+
def _make_html(self):
74+
app_state_processor = self.AppStateComponentProcessor()
75+
tp = TemplateProcessor(component_processor_api=app_state_processor)
76+
assume_ctx = ProcessContext()
77+
78+
def _html(template: Template, app_state: AppState | None = None) -> str:
79+
if app_state is None:
80+
app_state = AppState(theme_class="theme-default")
81+
with AppStateCtx.set(app_state):
82+
return tp.process(template, assume_ctx=assume_ctx)
83+
84+
return _html
85+
86+
def test_injected_app_state(self):
87+
name = "App"
88+
body_t = (
89+
t"<{self.Body}><{self.Header}><h1>{name}</h1></{self.Header}></{self.Body}>"
90+
)
91+
html = self._make_html()
92+
assert (
93+
html(body_t, app_state=None)
94+
== '<body><div class="hdr theme-default"><h1>App</h1></div></body>'
95+
)
96+
assert (
97+
html(body_t, app_state=AppState(theme_class="theme-spring"))
98+
== '<body><div class="hdr theme-spring"><h1>App</h1></div></body>'
99+
)
100+
assert (
101+
html(body_t, app_state=None)
102+
== '<body><div class="hdr theme-default"><h1>App</h1></div></body>'
103+
)

tdom/processor_test.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1808,7 +1808,9 @@ def test_function_component_returns_nontemplate_fails(self, bad_value):
18081808
def BadFunctionComp(children: Template):
18091809
return bad_value
18101810

1811-
with pytest.raises(TypeError, match="Unknown component return value:"):
1811+
with pytest.raises(
1812+
TypeError, match="Component callable must return Template or Callable:"
1813+
):
18121814
_ = html(t"<{BadFunctionComp}>Hello</{BadFunctionComp}>")
18131815

18141816
@pytest.mark.parametrize(
@@ -1821,7 +1823,9 @@ def component_object():
18211823

18221824
return component_object
18231825

1824-
with pytest.raises(TypeError, match="Unknown component return value:"):
1826+
with pytest.raises(
1827+
TypeError, match="Component object must return Template when called:"
1828+
):
18251829
_ = html(t"<{BadFactoryComp}>Hello</{BadFactoryComp}>")
18261830

18271831

0 commit comments

Comments
 (0)