Skip to content

Commit 11b7cad

Browse files
Middleware example.
1 parent b8b7472 commit 11b7cad

5 files changed

Lines changed: 299 additions & 13 deletions

File tree

tdom/cvar_utils.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from contextvars import ContextVar, Token
2+
3+
4+
class ContextVarSetter:
5+
"""
6+
Context manager for working with many context vars (instead of only 1).
7+
8+
This is meant to be created, used immediately and then discarded.
9+
10+
This allows for dynamically specifying a tuple of var / value pairs that
11+
another part of the program can use to wrap some called code without knowing
12+
anything about either.
13+
"""
14+
15+
context_values: tuple[tuple[ContextVar, object], ...] # Cvar / value pair.
16+
tokens: tuple[Token, ...]
17+
18+
def __init__(self, context_values=()):
19+
self.context_values = context_values
20+
self.tokens = ()
21+
22+
def __enter__(self):
23+
"""Set every given context var to its paired value."""
24+
self.tokens = tuple(var.set(val) for var, val in self.context_values)
25+
26+
def __exit__(self, exc_type, exc_value, traceback):
27+
"""Reset every given context var."""
28+
for idx, var_value in enumerate(self.context_values):
29+
var_value[0].reset(self.tokens[idx])

tdom/cvar_utils_test.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import string
2+
from contextvars import ContextVar
3+
4+
from .cvar_utils import ContextVarSetter
5+
6+
CtxStr = ContextVar[str]("CtxStr", default="default")
7+
CtxInt = ContextVar[int]("CtxInt", default=0)
8+
9+
10+
def _assert_ctx(ctx_str: str = "default", ctx_int: int = 0):
11+
assert CtxStr.get() == ctx_str
12+
assert CtxInt.get() == ctx_int
13+
14+
15+
def test_set():
16+
_assert_ctx()
17+
with ContextVarSetter(
18+
context_values=(
19+
(CtxStr, "new"),
20+
(CtxInt, 1),
21+
)
22+
):
23+
_assert_ctx("new", 1)
24+
_assert_ctx()
25+
26+
27+
def test_nest():
28+
_assert_ctx()
29+
with ContextVarSetter(
30+
context_values=(
31+
(CtxStr, "new"),
32+
(CtxInt, 1),
33+
)
34+
):
35+
_assert_ctx("new", 1)
36+
with ContextVarSetter(
37+
context_values=(
38+
(CtxStr, "again"),
39+
(CtxInt, 2),
40+
)
41+
):
42+
_assert_ctx("again", 2)
43+
_assert_ctx("new", 1)
44+
_assert_ctx()
45+
46+
47+
def test_reps():
48+
_assert_ctx()
49+
for index, leter in enumerate(string.ascii_lowercase):
50+
with ContextVarSetter(
51+
context_values=(
52+
(CtxStr, leter),
53+
(CtxInt, index),
54+
)
55+
):
56+
_assert_ctx(leter, index)
57+
_assert_ctx()
58+
59+
60+
def test_empty():
61+
_assert_ctx()
62+
with ContextVarSetter(context_values=()):
63+
# DO NOTHING BUT NOT AN ERROR
64+
_assert_ctx()
65+
_assert_ctx()
66+
67+
68+
def test_one():
69+
_assert_ctx()
70+
with ContextVarSetter(context_values=((CtxStr, "new"),)):
71+
_assert_ctx("new")
72+
_assert_ctx()

tdom/processor.py

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import typing as t
22
from collections.abc import Callable, Iterable, Sequence
3+
from contextvars import ContextVar
34
from dataclasses import dataclass, field
45
from functools import lru_cache
56
from string.templatelib import Interpolation, Template
67

78
from markupsafe import Markup
89

910
from .callables import CallableInfo, get_callable_info
11+
from .cvar_utils import ContextVarSetter
1012
from .escaping import (
1113
escape_html_comment as default_escape_html_comment,
1214
)
@@ -527,7 +529,7 @@ def process(
527529
attrs: tuple[TAttribute, ...],
528530
component_template: Template,
529531
provided_attrs: tuple[Attribute, ...] = (),
530-
) -> Template:
532+
) -> Template | tuple[Template, object]:
531533
"""
532534
Process available component details into a Template.
533535
"""
@@ -547,7 +549,7 @@ def process(
547549
attrs: tuple[TAttribute, ...],
548550
component_template: Template,
549551
provided_attrs: tuple[Attribute, ...] = (),
550-
) -> Template:
552+
) -> Template | tuple[Template, object]:
551553
"""
552554
Process available component details into a Template.
553555
@@ -585,21 +587,48 @@ def process(
585587
)
586588
res1 = component_callable(**kwargs) # ty: ignore[call-top-callable]
587589
if isinstance(res1, Template):
588-
return res1
590+
return res1, None
589591
elif callable(res1):
590592
res2 = res1() # ty: ignore[call-top-callable]
591593
if isinstance(res2, Template):
592594
return res2
595+
elif isinstance(res2, tuple):
596+
if len(res2) == 2:
597+
if not isinstance(res2[0], Template):
598+
raise TypeError(
599+
f"Component object returned unxpected type in first entry of 2-tuple: {type(res2[0])}"
600+
)
601+
else:
602+
# @TYPING:
603+
# Rebuild tuple so TY can correctly narrow types,
604+
# pyright works with `return res2`.
605+
return (res2[0], res2[1])
606+
else:
607+
raise ValueError(
608+
f"Component object returned tuple with length != 2: {len(res2)}"
609+
)
610+
593611
else:
594612
raise TypeError(
595-
f"Component object must return Template when called: {type(res2)}"
613+
f"Component object must return Template or 2-tuple when called: {type(res2)}"
596614
)
597615
else:
598616
raise TypeError(
599617
f"Component callable must return Template or Callable: {type(res1)}"
600618
)
601619

602620

621+
@t.runtime_checkable
622+
class IMiddlewareGetContextValues(t.Protocol):
623+
"""
624+
Middleware that provides a tuple of 2-tuples each with a context variable
625+
paired with a value to set when processing the component's template.
626+
"""
627+
628+
# @TODO: Can we match a contextvar's type with the value to set like this?
629+
def get_context_values(self) -> tuple[tuple[ContextVar, object], ...]: ...
630+
631+
603632
class ITemplateProcessor(t.Protocol):
604633
def process(self, root_template: Template, assume_ctx: ProcessContext) -> str: ...
605634

@@ -817,10 +846,44 @@ def _process_component(
817846
template, attrs, start_i_index, end_i_index, check_callables=True
818847
)
819848
component_callable = template.interpolations[start_i_index].value
820-
result_t = self.component_processor_api.process(
849+
result = self.component_processor_api.process(
821850
template, last_ctx, component_callable, attrs, children_template
822851
)
823-
return self._process_template(result_t, last_ctx)
852+
if isinstance(result, Template):
853+
result_t, middleware_api = result, None
854+
elif isinstance(result, tuple):
855+
if len(result) == 2:
856+
if not isinstance(result[0], Template):
857+
raise TypeError(
858+
f"Component processor returned unxpected type in first entry of 2-tuple: {type(result[0])}"
859+
)
860+
else:
861+
result_t, middleware_api = result
862+
else:
863+
raise ValueError(
864+
f"Component processor returned tuple with length != 2: {len(result)}"
865+
)
866+
else:
867+
raise TypeError(
868+
f"Component processor should return unexpected type: {type(result)}"
869+
)
870+
871+
context_values: tuple[tuple[ContextVar, object], ...] = ()
872+
873+
if middleware_api is not None:
874+
if isinstance(middleware_api, IMiddlewareGetContextValues):
875+
context_values = middleware_api.get_context_values()
876+
else:
877+
# @DESIGN: It is NOT an error if a middleware object is provided but it
878+
# provides no actual middleware functionality. Should it be?
879+
pass
880+
881+
# Try to consolidate "final" call(s) to the last block regardless of middleware.
882+
if context_values:
883+
with ContextVarSetter(context_values=context_values):
884+
return self._process_template(result_t, last_ctx)
885+
else:
886+
return self._process_template(result_t, last_ctx)
824887

825888
def _process_raw_texts(
826889
self,

tdom/processor_extension_test.py

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ def process(
101101
attrs: tuple[TAttribute, ...],
102102
component_template: Template,
103103
provided_attrs: tuple[Attribute, ...] = (),
104-
) -> Template:
104+
) -> Template | tuple[Template, object]:
105105
from inspect import isclass
106106

107107
system_ctx = SystemCtx.get()
@@ -149,3 +149,96 @@ def test_replacement(self):
149149
"</div>"
150150
"</div>"
151151
)
152+
153+
154+
ThemeCtx: ContextVar[str] = ContextVar("ThemeCtx")
155+
156+
ModeCtx: ContextVar[str] = ContextVar("ModeCtx")
157+
158+
159+
class TestMiddlewareGetContextValues:
160+
@dataclass
161+
class ThemeProvider:
162+
theme_name: str
163+
164+
children: Template
165+
166+
mode: str | None = None
167+
168+
def __call__(self) -> tuple[Template, object]:
169+
# Hit em' with another div...
170+
result_t = t"<div>{self.children}</div>"
171+
middleware_api = self
172+
return result_t, middleware_api
173+
174+
def get_context_values(self) -> tuple[tuple[ContextVar, str], ...]:
175+
context_values = ((ThemeCtx, self.theme_name),)
176+
if self.mode is not None:
177+
context_values += ((ModeCtx, self.mode),)
178+
return context_values
179+
180+
@dataclass
181+
class ThemeDisplay:
182+
theme_name: str
183+
184+
mode: str | None = None
185+
186+
def __call__(self) -> Template:
187+
sep = ":" if self.mode else None
188+
return t"<span>{self.theme_name}{sep}{self.mode}</span>"
189+
190+
def _make_html(
191+
self, default_theme_name: str = "theme-default", default_mode: str = "mode-dark"
192+
):
193+
194+
tp = TemplateProcessor()
195+
196+
def _html(template: Template, assume_ctx: ProcessContext | None = None):
197+
if assume_ctx is None:
198+
assume_ctx = ProcessContext()
199+
with ThemeCtx.set(default_theme_name), ModeCtx.set(default_mode):
200+
return tp.process(template, assume_ctx)
201+
202+
return _html
203+
204+
def _theme_name(self):
205+
return ThemeCtx.get()
206+
207+
def _mode(self):
208+
return ModeCtx.get()
209+
210+
def test_default(self):
211+
html = self._make_html()
212+
assert (
213+
html(t"<{self.ThemeDisplay} theme_name={self._theme_name:callback} />")
214+
== "<span>theme-default</span>"
215+
)
216+
217+
def test_provider(self):
218+
html = self._make_html()
219+
child_t = t"<{self.ThemeDisplay} theme_name={self._theme_name:callback} />"
220+
assert (
221+
html(
222+
t"<{self.ThemeProvider} theme_name='theme-pycon'>{child_t}</{self.ThemeProvider}>"
223+
)
224+
== "<div><span>theme-pycon</span></div>"
225+
)
226+
227+
def test_provider_scope(self):
228+
html = self._make_html()
229+
child_t = t"<{self.ThemeDisplay} theme_name={self._theme_name:callback} />"
230+
wrapped_t = t"<{self.ThemeProvider} theme_name='theme-pycon'>{child_t}</{self.ThemeProvider}>"
231+
assert (
232+
html(wrapped_t + child_t)
233+
== "<div><span>theme-pycon</span></div><span>theme-default</span>"
234+
)
235+
236+
def test_two_cvars(self):
237+
html = self._make_html()
238+
child_t = t"<{self.ThemeDisplay} theme_name={self._theme_name:callback} mode={self._mode:callback} />"
239+
assert (
240+
html(
241+
t"<{self.ThemeProvider} theme_name='theme-pycon' mode='mode-light'>{child_t}</{self.ThemeProvider}>"
242+
)
243+
== "<div><span>theme-pycon:mode-light</span></div>"
244+
)

0 commit comments

Comments
 (0)