diff --git a/tdom/cvar_utils.py b/tdom/cvar_utils.py new file mode 100644 index 0000000..8b57db1 --- /dev/null +++ b/tdom/cvar_utils.py @@ -0,0 +1,29 @@ +from contextvars import ContextVar, Token + + +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]) diff --git a/tdom/cvar_utils_test.py b/tdom/cvar_utils_test.py new file mode 100644 index 0000000..6f40194 --- /dev/null +++ b/tdom/cvar_utils_test.py @@ -0,0 +1,72 @@ +import string +from contextvars import ContextVar + +from .cvar_utils import ContextVarSetter + +CtxStr = ContextVar[str]("CtxStr", default="default") +CtxInt = ContextVar[int]("CtxInt", default=0) + + +def _assert_ctx(ctx_str: str = "default", ctx_int: int = 0): + assert CtxStr.get() == ctx_str + assert CtxInt.get() == ctx_int + + +def test_set(): + _assert_ctx() + with ContextVarSetter( + context_values=( + (CtxStr, "new"), + (CtxInt, 1), + ) + ): + _assert_ctx("new", 1) + _assert_ctx() + + +def test_nest(): + _assert_ctx() + with ContextVarSetter( + context_values=( + (CtxStr, "new"), + (CtxInt, 1), + ) + ): + _assert_ctx("new", 1) + with ContextVarSetter( + context_values=( + (CtxStr, "again"), + (CtxInt, 2), + ) + ): + _assert_ctx("again", 2) + _assert_ctx("new", 1) + _assert_ctx() + + +def test_reps(): + _assert_ctx() + for index, leter in enumerate(string.ascii_lowercase): + with ContextVarSetter( + context_values=( + (CtxStr, leter), + (CtxInt, index), + ) + ): + _assert_ctx(leter, index) + _assert_ctx() + + +def test_empty(): + _assert_ctx() + with ContextVarSetter(context_values=()): + # DO NOTHING BUT NOT AN ERROR + _assert_ctx() + _assert_ctx() + + +def test_one(): + _assert_ctx() + with ContextVarSetter(context_values=((CtxStr, "new"),)): + _assert_ctx("new") + _assert_ctx() diff --git a/tdom/processor.py b/tdom/processor.py index 1e975da..72d1274 100644 --- a/tdom/processor.py +++ b/tdom/processor.py @@ -1,5 +1,6 @@ import typing as t from collections.abc import Callable, Iterable, Sequence +from contextvars import ContextVar from dataclasses import dataclass, field from functools import lru_cache from string.templatelib import Interpolation, Template @@ -7,6 +8,7 @@ from markupsafe import Markup from .callables import CallableInfo, get_callable_info +from .cvar_utils import ContextVarSetter from .escaping import ( escape_html_comment as default_escape_html_comment, ) @@ -386,8 +388,37 @@ def _prep_component_kwargs( callable_info: CallableInfo, attrs: AttributesDict, children: Template, + provided_attrs: tuple[Attribute, ...] = (), + raise_on_requires_positional=True, + raise_on_missing=True, ) -> AttributesDict: - if callable_info.requires_positional: + """ + Matchup kwargs from multiple sources to target the given callable. + + `provided_attrs`: + These can be used by extensions that want to provide + attrs even if they are not specified in the component's `attrs` in + the template. If an attribute with the same name is provided in + `attrs` then it takes priority over entries in `provided_attrs`. + @NOTE: These will be injected into any component with `**kwargs` + in their signature unless provided already by `attrs`. + + `raise_on_requires_positional`: + Optionally check and raise `TypeError` if the `callable_info` requires + positional arguments which we cannot fulfill normally. + An exception might not be desired if the caller will finish preparing + the arguments after this call. + + `raise_on_missing`: + Optionally check and raise `TypeError` if we are not able to fulfill all + the arguments the `callable_info` expects since in the common case this + raise an exception whose cause might not be clear. + An exception might not be desired if the caller will finish preparing + the arguments after this call. + """ + + # We can't know what kwarg to put here... + if raise_on_requires_positional and callable_info.requires_positional: raise TypeError( "Component callables cannot have required positional arguments." ) @@ -403,12 +434,20 @@ def _prep_component_kwargs( if "children" in callable_info.named_params or callable_info.kwargs: kwargs["children"] = children + # Add in provided attrs if they haven't been set already and are wanted. + for pattr_name, pattr_value in provided_attrs: + if pattr_name not in kwargs and ( + pattr_name in callable_info.named_params or callable_info.kwargs + ): + kwargs[pattr_name] = pattr_value + # 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)}" - ) + if raise_on_missing: + missing = callable_info.required_named_params - kwargs.keys() + if missing: + raise TypeError( + f"Missing required parameters for component: {', '.join(missing)}" + ) return kwargs @@ -445,10 +484,35 @@ def copy( ) -type FunctionComponent = Callable[..., Template] -type FactoryComponent = Callable[..., ComponentObject] -type ComponentCallable = FunctionComponent | FactoryComponent -type ComponentObject = Callable[[], Template] +@t.runtime_checkable +class IFunctionComponent(t.Protocol): + __call__: Callable[..., Template] + + +@t.runtime_checkable +class IFunctionMiddlewareComponent(t.Protocol): + __call__: Callable[..., tuple[Template, object]] + + +@t.runtime_checkable +class IFactoryComponent(t.Protocol): + __call__: IFunctionComponent + + +@t.runtime_checkable +class IFactoryMiddlewareComponent(t.Protocol): + __call__: IFunctionMiddlewareComponent + + +# type FunctionComponent = Callable[..., Template | tuple[Template, object]] +# type FactoryComponent = Callable[..., ComponentObject] +type ComponentCallable = ( + IFunctionComponent + | IFactoryComponent + | IFunctionMiddlewareComponent + | IFactoryMiddlewareComponent +) +type ComponentObject = IFunctionComponent | IFunctionMiddlewareComponent type NormalTextInterpolationValue = ( @@ -497,6 +561,123 @@ def to_tnode(self, template: Template) -> TNode: return self._to_tnode(CachableTemplate(template)) +class IComponentProcessor(t.Protocol): + """Isolate component processing to allow for replacement.""" + + def process( + self, + template: Template, + last_ctx: ProcessContext, + component_callable: t.Annotated[object, ComponentCallable], + attrs: tuple[TAttribute, ...], + component_template: Template, + provided_attrs: tuple[Attribute, ...] = (), + ) -> Template | tuple[Template, object]: + """ + Process available component details into a Template. + """ + ... + + +class ComponentProcessor(IComponentProcessor): + """ + Default component processor. + """ + + def process( + self, + template: Template, + last_ctx: ProcessContext, + component_callable: t.Annotated[object, IFactoryComponent | IFunctionComponent], + attrs: tuple[TAttribute, ...], + component_template: Template, + provided_attrs: tuple[Attribute, ...] = (), + ) -> Template | tuple[Template, object]: + """ + Process available component details into a Template. + + There are two general "styles" supported: + + 1. FunctionComponent + + Calling `component_callable` with the prepared kwargs should + return a `Template`. + + The primary purpose of this style is to support + using a normal function as a component. + + 2. FactoryComponent + + Calling `component_callable` with the prepared kwargs should + return another `Callable` which when called with no arguments should + return a `Template`. + + The primary purpose of this style is to support + using a `dataclass` with `def __call__(self) -> Template` as a + component. + """ + if not callable(component_callable): + raise TypeError( + f"Component callable must be callable: {type(component_callable)}" + ) + kwargs = _prep_component_kwargs( + get_callable_info(component_callable), + _resolve_t_attrs(attrs, template.interpolations), + children=component_template, + provided_attrs=provided_attrs, + raise_on_requires_positional=True, + raise_on_missing=True, + ) + res1 = component_callable(**kwargs) # ty: ignore[call-top-callable] + if isinstance(res1, Template): + return res1 + elif isinstance(res1, tuple): + return self._check_tuple_response(res1, error_target="callable") + elif callable(res1): + res2 = res1() # ty: ignore[call-top-callable] + if isinstance(res2, Template): + return res2 + elif isinstance(res2, tuple): + return self._check_tuple_response(res2, error_target="object") + else: + raise TypeError( + f"Component object must return Template or 2-tuple when called: {type(res2)}" + ) + else: + raise TypeError( + f"Component callable must return Template, 2-tuple or Callable: {type(res1)}" + ) + + def _check_tuple_response( + self, response: tuple, error_target: str + ) -> tuple[Template, object]: + if len(response) == 2: + if not isinstance(response[0], Template): + raise TypeError( + f"Component {error_target} returned unxpected type in first entry of 2-tuple: {type(response[0])}" + ) + else: + # @TYPING: + # Rebuild tuple so TY can correctly narrow types, + # pyright works with `return response`. + return (response[0], response[1]) + else: + raise TypeError( + f"Component {error_target} returned tuple with length != 2: {len(response)}" + ) + + +@t.runtime_checkable +class IMiddlewareGetContextValues(t.Protocol): + """ + Middleware that provides a tuple of 2-tuples each with a context variable + paired with a value to set when processing the component's template. + """ + + # @TODO: Can we match a contextvar's type with the value to set like this? + def get_context_values(self) -> tuple[tuple[ContextVar, object], ...]: ... + + class ITemplateProcessor(t.Protocol): def process(self, root_template: Template, assume_ctx: ProcessContext) -> str: ... @@ -505,6 +686,10 @@ def process(self, root_template: Template, assume_ctx: ProcessContext) -> str: . class TemplateProcessor(ITemplateProcessor): parser_api: ITemplateParserProxy = field(default_factory=CachedTemplateParserProxy) + component_processor_api: IComponentProcessor = field( + default_factory=ComponentProcessor + ) + escape_html_text: Callable = default_escape_html_text escape_html_comment: Callable = default_escape_html_comment @@ -668,60 +853,106 @@ def _process_attrs( return attrs_str return "" - def _process_component( + def _extract_component_template( self, template: Template, - last_ctx: ProcessContext, attrs: tuple[TAttribute, ...], start_i_index: int, end_i_index: int | None, - ) -> str: - """ - Invoke a component and process the result into a string. - """ + check_callables: bool = True, + ) -> Template: body_start_s_index = ( start_i_index + 1 + len([1 for attr in attrs if not isinstance(attr, TLiteralAttribute)]) ) - start_i = template.interpolations[start_i_index] - component_callable = t.cast(ComponentCallable, start_i.value) if start_i_index != end_i_index and end_i_index is not None: # @TODO: We should do this during parsing. - children_template = extract_embedded_template( - template, body_start_s_index, end_i_index - ) - if component_callable != template.interpolations[end_i_index].value: + if ( + check_callables + and template.interpolations[start_i_index].value + != template.interpolations[end_i_index].value + ): raise TypeError( "Component callable in start tag must match component callable in end tag." ) + return extract_embedded_template(template, body_start_s_index, end_i_index) else: - children_template = t"" + return t"" - if not callable(component_callable): - raise TypeError("Component callable must be callable.") - - kwargs = _prep_component_kwargs( - get_callable_info(component_callable), - _resolve_t_attrs(attrs, template.interpolations), - children=children_template, + def _process_component( + self, + template: Template, + last_ctx: ProcessContext, + attrs: tuple[TAttribute, ...], + start_i_index: int, + end_i_index: int | None, + ) -> str: + """ + Invoke a component and process the result into a string. + """ + children_template = self._extract_component_template( + template, attrs, start_i_index, end_i_index, check_callables=True ) + component_callable = template.interpolations[start_i_index].value + response = self.component_processor_api.process( + template, last_ctx, component_callable, attrs, children_template + ) + if isinstance(response, Template): + response_t = response + response_middleware = None + elif isinstance(response, tuple): + if len(response) == 2: + if not isinstance(response[0], Template): + raise TypeError( + f"Component processor returned unxpected type in first entry of 2-tuple: {type(response[0])}" + ) + else: + response_t, response_middleware = response + else: + raise ValueError( + f"Component processor returned tuple with length != 2: {len(response)}" + ) + else: + raise TypeError( + f"Component processor should return unexpected type: {type(response)}" + ) - result_t = component_callable(**kwargs) - if ( - result_t is not None - and not isinstance(result_t, Template) - and callable(result_t) - ): - component_obj = t.cast(ComponentObject, result_t) # ty: ignore[redundant-cast] - result_t = component_obj() + if response_middleware is not None: + return self._process_middleware( + template, last_ctx, response_t, response_middleware + ) + else: + return self._process_template(response_t, last_ctx) + + def _process_middleware( + self, + template: Template, + last_ctx: ProcessContext, + response_t: Template, + response_middleware: object, + ) -> str: + """ + Process the given middleware and apply it to the component's response + template. + """ + + context_values: tuple[tuple[ContextVar, object], ...] = () + + if isinstance( + response_middleware, IMiddlewareGetContextValues + ): # @TODO: Probably use hasattr. + context_values = response_middleware.get_context_values() else: - component_obj = None + # @DESIGN: It is NOT an error if a middleware object is provided but it + # provides no actual middleware functionality. Should it be? + pass - if isinstance(result_t, Template): - return self._process_template(result_t, last_ctx) + if context_values: + with ContextVarSetter(context_values=context_values): + return self._process_template(response_t, last_ctx) else: - raise TypeError(f"Unknown component return value: {type(result_t)}") + return self._process_template(response_t, last_ctx) def _process_raw_texts( self, diff --git a/tdom/processor_extension_test.py b/tdom/processor_extension_test.py new file mode 100644 index 0000000..bc49099 --- /dev/null +++ b/tdom/processor_extension_test.py @@ -0,0 +1,196 @@ +from contextvars import ContextVar +from dataclasses import dataclass, field +from string.templatelib import Template + +from .processor import ( + Attribute, + ComponentProcessor, + IComponentProcessor, + IFactoryComponent, + IFactoryMiddlewareComponent, + IFunctionComponent, + ProcessContext, + TemplateProcessor, +) +from .tnodes import TAttribute + + +@dataclass(frozen=True, slots=True) +class AppState: + theme_class: str + + +AppStateCtx: ContextVar[AppState | None] = ContextVar("AppStateCtx", default=None) + + +class TestComponentProcessor: + def Body(self, children: Template) -> Template: + return t"{children}" + + @dataclass + class Header(IFactoryComponent): + children: Template + + app_state: AppState + + hdr_class: str = "hdr" + + def __call__(self) -> Template: + return t"
{self.children}
" + + @dataclass + class AppStateComponentProcessor(IComponentProcessor): + # Delegate to the default processor to reuse code. + default_component_processor_api: IComponentProcessor = field( + default_factory=ComponentProcessor + ) + + def process( + self, + template: Template, + last_ctx: ProcessContext, + component_callable: object, + attrs: tuple[TAttribute, ...], + component_template: Template, + provided_attrs: tuple[Attribute, ...] = (), + ) -> Template | tuple[Template, object]: + # For now we just make the app state available to EVERY component + # a smarter strategy would be to only include it if asked via + # the callable's signature or even the callable's typehints. + # But for a test this is OK. + app_state = AppStateCtx.get() + extended_attrs = provided_attrs + (("app_state", app_state),) + return self.default_component_processor_api.process( + template=template, + last_ctx=last_ctx, + component_callable=component_callable, + attrs=attrs, + component_template=component_template, + provided_attrs=extended_attrs, + ) + + def _make_html(self): + app_state_processor = self.AppStateComponentProcessor() + tp = TemplateProcessor(component_processor_api=app_state_processor) + assume_ctx = ProcessContext() + + def _html(template: Template, app_state: AppState | None = None) -> str: + if app_state is None: + app_state = AppState(theme_class="theme-default") + with AppStateCtx.set(app_state): + return tp.process(template, assume_ctx=assume_ctx) + + return _html + + def test_injected_app_state(self): + name = "App" + assert isinstance(self.Body, IFunctionComponent) + body_t = ( + t"<{self.Body}><{self.Header}>

{name}

" + ) + html = self._make_html() + assert ( + html(body_t, app_state=None) + == '

App

' + ) + assert ( + html(body_t, app_state=AppState(theme_class="theme-spring")) + == '

App

' + ) + assert ( + html(body_t, app_state=None) + == '

App

' + ) + + +ThemeCtx: ContextVar[str] = ContextVar("ThemeCtx") + +ModeCtx: ContextVar[str] = ContextVar("ModeCtx") + + +class TestMiddlewareGetContextValues: + @dataclass + class ThemeProvider(IFactoryMiddlewareComponent): + theme_name: str + + children: Template + + mode: str | None = None + + def __call__(self) -> tuple[Template, object]: + # Hit em' with another div... + result_t = t"
{self.children}
" + middleware_api = self + return result_t, middleware_api + + def get_context_values(self) -> tuple[tuple[ContextVar, str], ...]: + context_values = ((ThemeCtx, self.theme_name),) + if self.mode is not None: + context_values += ((ModeCtx, self.mode),) + return context_values + + @dataclass + class ThemeDisplay(IFactoryComponent): + theme_name: str + + mode: str | None = None + + def __call__(self) -> Template: + sep = ":" if self.mode else None + return t"{self.theme_name}{sep}{self.mode}" + + def _make_html( + self, default_theme_name: str = "theme-default", default_mode: str = "mode-dark" + ): + + tp = TemplateProcessor() + + def _html(template: Template, assume_ctx: ProcessContext | None = None): + if assume_ctx is None: + assume_ctx = ProcessContext() + with ThemeCtx.set(default_theme_name), ModeCtx.set(default_mode): + return tp.process(template, assume_ctx) + + return _html + + def _theme_name(self): + return ThemeCtx.get() + + def _mode(self): + return ModeCtx.get() + + def test_default(self): + html = self._make_html() + assert ( + html(t"<{self.ThemeDisplay} theme_name={self._theme_name:callback} />") + == "theme-default" + ) + + def test_provider(self): + html = self._make_html() + child_t = t"<{self.ThemeDisplay} theme_name={self._theme_name:callback} />" + assert ( + html( + t"<{self.ThemeProvider} theme_name='theme-pycon'>{child_t}" + ) + == "
theme-pycon
" + ) + + def test_provider_scope(self): + html = self._make_html() + child_t = t"<{self.ThemeDisplay} theme_name={self._theme_name:callback} />" + wrapped_t = t"<{self.ThemeProvider} theme_name='theme-pycon'>{child_t}" + assert ( + html(wrapped_t + child_t) + == "
theme-pycon
theme-default" + ) + + def test_two_cvars(self): + html = self._make_html() + child_t = t"<{self.ThemeDisplay} theme_name={self._theme_name:callback} mode={self._mode:callback} />" + assert ( + html( + t"<{self.ThemeProvider} theme_name='theme-pycon' mode='mode-light'>{child_t}" + ) + == "
theme-pycon:mode-light
" + ) diff --git a/tdom/processor_test.py b/tdom/processor_test.py index 91828d0..d642610 100644 --- a/tdom/processor_test.py +++ b/tdom/processor_test.py @@ -1801,19 +1801,35 @@ def CloseTag(children: Template) -> Template: with pytest.raises(TypeError): _ = html(t"<{OpenTag}>Hello") - @pytest.mark.parametrize( - "bad_value", ("", "text", None, 1, ("tuple", "of", "strs")) - ) + @pytest.mark.parametrize("bad_value", ("", "text", None, 1)) def test_function_component_returns_nontemplate_fails(self, bad_value): def BadFunctionComp(children: Template): return bad_value - with pytest.raises(TypeError, match="Unknown component return value:"): + with pytest.raises( + TypeError, + match="Component callable must return Template, 2-tuple or Callable:", + ): _ = html(t"<{BadFunctionComp}>Hello") @pytest.mark.parametrize( - "bad_value", ("", "text", None, 1, ("tuple", "of", "strs")) + "bad_value", + ( + (t"",), + (t"", None, None), + ), ) + def test_function_object_returns_mislengthed_tuple_fails(self, bad_value): + def BadFunctionComp(children: Template): + return bad_value + + with pytest.raises( + TypeError, + match="Component callable returned tuple with length != 2:", + ): + _ = html(t"<{BadFunctionComp}>Hello") + + @pytest.mark.parametrize("bad_value", ("", "text", None, 1, ["list", "of", "strs"])) def test_component_object_returns_nontemplate_fails(self, bad_value): def BadFactoryComp(children: Template): def component_object(): @@ -1821,7 +1837,42 @@ def component_object(): return component_object - with pytest.raises(TypeError, match="Unknown component return value:"): + with pytest.raises( + TypeError, + match="Component object must return Template or 2-tuple when called:", + ): + _ = html(t"<{BadFactoryComp}>Hello") + + @pytest.mark.parametrize( + "bad_value", + ( + (t"",), # Too short + (t"", None, None), # Too long + ), + ) + def test_component_object_returns_mislengthed_tuple_fails(self, bad_value): + def BadFactoryComp(children: Template): + def component_object(): + return bad_value + + return component_object + + with pytest.raises( + TypeError, match="Component object returned tuple with length != 2" + ): + _ = html(t"<{BadFactoryComp}>Hello") + + def test_component_object_returns_nontemplate_in_2tuple_fails(self): + def BadFactoryComp(children: Template): + def component_object(): + return (None, t"") + + return component_object + + with pytest.raises( + TypeError, + match="Component object returned unxpected type in first entry of 2-tuple", + ): _ = html(t"<{BadFactoryComp}>Hello")