html-tstring 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ from markupsafe import Markup, escape
2
+
3
+ from .nodes import Comment, DocumentType, Element, Fragment, Text
4
+ from .processor import html
5
+
6
+ # We consider `Markup` and `escape` to be part of this module's public API
7
+
8
+ __all__ = [
9
+ "Comment",
10
+ "DocumentType",
11
+ "Element",
12
+ "escape",
13
+ "Fragment",
14
+ "html",
15
+ "Markup",
16
+ "Text",
17
+ ]
@@ -0,0 +1,46 @@
1
+ def classnames(*args: object) -> str:
2
+ """
3
+ Construct a space-separated class string from various inputs.
4
+
5
+ Accepts strings, lists/tuples of strings, and dicts mapping class names to
6
+ boolean values. Ignores None and False values.
7
+
8
+ Examples:
9
+ classnames("btn", "btn-primary") -> "btn btn-primary"
10
+ classnames("btn", {"btn-primary": True, "disabled": False}) -> "btn btn-primary"
11
+ classnames(["btn", "btn-primary"], {"disabled": True}) -> "btn btn-primary disabled"
12
+ classnames("btn", None, False, "active") -> "btn active"
13
+
14
+ Args:
15
+ *args: Variable length argument list containing strings, lists/tuples,
16
+ or dicts.
17
+
18
+ Returns:
19
+ A single string with class names separated by spaces.
20
+ """
21
+ classes: list[str] = []
22
+ # Use a queue to process arguments iteratively, preserving order.
23
+ queue = list(args)
24
+
25
+ while queue:
26
+ arg = queue.pop(0)
27
+
28
+ if not arg: # Handles None, False, empty strings/lists/dicts
29
+ continue
30
+
31
+ if isinstance(arg, str):
32
+ classes.append(arg)
33
+ elif isinstance(arg, dict):
34
+ for key, value in arg.items():
35
+ if value:
36
+ classes.append(key)
37
+ elif isinstance(arg, (list, tuple)):
38
+ # Add items to the front of the queue to process them next, in order.
39
+ queue[0:0] = arg
40
+ elif isinstance(arg, bool):
41
+ pass # Explicitly ignore booleans not in a dict
42
+ else:
43
+ raise ValueError(f"Invalid class argument type: {type(arg).__name__}")
44
+
45
+ # Filter out empty strings and join the result.
46
+ return " ".join(stripped for c in classes if (stripped := c.strip()))
html_tstring/nodes.py ADDED
@@ -0,0 +1,134 @@
1
+ import typing as t
2
+ from dataclasses import dataclass, field
3
+ from functools import cached_property
4
+ from html import escape
5
+
6
+ # See https://developer.mozilla.org/en-US/docs/Glossary/Void_element
7
+ VOID_ELEMENTS = frozenset(
8
+ [
9
+ "area",
10
+ "base",
11
+ "br",
12
+ "col",
13
+ "embed",
14
+ "hr",
15
+ "img",
16
+ "input",
17
+ "link",
18
+ "meta",
19
+ "param",
20
+ "source",
21
+ "track",
22
+ "wbr",
23
+ ]
24
+ )
25
+
26
+
27
+ CDATA_CONTENT_ELEMENTS = frozenset(["script", "style"])
28
+ RCDATA_CONTENT_ELEMENTS = frozenset(["textarea", "title"])
29
+ CONTENT_ELEMENTS = CDATA_CONTENT_ELEMENTS | RCDATA_CONTENT_ELEMENTS
30
+
31
+ # TODO: add a pretty-printer for nodes for debugging
32
+ # TODO: consider how significant whitespace is handled from t-string to nodes
33
+
34
+
35
+ @t.runtime_checkable
36
+ class HasHTMLDunder(t.Protocol):
37
+ def __html__(self) -> str: ...
38
+
39
+
40
+ type HTMLDunder = t.Callable[[], str]
41
+
42
+
43
+ @dataclass(slots=True)
44
+ class Node:
45
+ def __html__(self) -> str:
46
+ """Return the HTML representation of the node."""
47
+ # By default, just return the string representation
48
+ return str(self)
49
+
50
+
51
+ @dataclass(slots=False)
52
+ class Text(Node):
53
+ # Django's `SafeString` and Markupsafe/Jinja2's `Markup` both inherit
54
+ # from `str`, but that is not a requirement for the `__html__` dunder.
55
+ text: str | HasHTMLDunder
56
+
57
+ @cached_property
58
+ def _cached_str(self) -> str:
59
+ if isinstance(self.text, HasHTMLDunder):
60
+ return self.text.__html__()
61
+ return escape(t.cast(str, self.text), quote=False)
62
+
63
+ def _as_unescaped(self) -> str:
64
+ """Return the text as-is, without escaping. For internal use only."""
65
+ if isinstance(self.text, HasHTMLDunder):
66
+ return self.text.__html__()
67
+ return self.text
68
+
69
+ def __str__(self) -> str:
70
+ return self._cached_str
71
+
72
+
73
+ @dataclass(slots=True)
74
+ class Fragment(Node):
75
+ children: list[Node] = field(default_factory=list)
76
+
77
+ def __str__(self) -> str:
78
+ return "".join(str(child) for child in self.children)
79
+
80
+
81
+ @dataclass(slots=True)
82
+ class Comment(Node):
83
+ text: str
84
+
85
+ def __str__(self) -> str:
86
+ return f"<!--{self.text}-->"
87
+
88
+
89
+ @dataclass(slots=True)
90
+ class DocumentType(Node):
91
+ text: str = "html"
92
+
93
+ def __str__(self) -> str:
94
+ return f"<!DOCTYPE {self.text}>"
95
+
96
+
97
+ @dataclass(slots=True)
98
+ class Element(Node):
99
+ tag: str
100
+ attrs: dict[str, str | None] = field(default_factory=dict)
101
+ children: list[Node] = field(default_factory=list)
102
+
103
+ def __post_init__(self):
104
+ """Ensure all preconditions are met."""
105
+ if not self.tag:
106
+ raise ValueError("Element tag cannot be empty.")
107
+
108
+ # Void elements cannot have children
109
+ if self.is_void and self.children:
110
+ raise ValueError(f"Void element <{self.tag}> cannot have children.")
111
+
112
+ @property
113
+ def is_void(self) -> bool:
114
+ return self.tag in VOID_ELEMENTS
115
+
116
+ def __str__(self) -> str:
117
+ # TODO: CONSIDER: should values in attrs support the __html__ dunder?
118
+ attrs_str = "".join(
119
+ f" {key}" if value is None else f' {key}="{escape(value, quote=True)}"'
120
+ for key, value in self.attrs.items()
121
+ )
122
+ if self.is_void:
123
+ return f"<{self.tag}{attrs_str} />"
124
+ if not self.children:
125
+ return f"<{self.tag}{attrs_str}></{self.tag}>"
126
+ if self.tag in CONTENT_ELEMENTS:
127
+ # Content elements should not escape their content
128
+ children_str = "".join(
129
+ child._as_unescaped() if isinstance(child, Text) else str(child)
130
+ for child in self.children
131
+ )
132
+ else:
133
+ children_str = "".join(str(child) for child in self.children)
134
+ return f"<{self.tag}{attrs_str}>{children_str}</{self.tag}>"
html_tstring/parser.py ADDED
@@ -0,0 +1,132 @@
1
+ import typing as t
2
+ from html.parser import HTMLParser
3
+
4
+ from .nodes import VOID_ELEMENTS, Comment, DocumentType, Element, Fragment, Node, Text
5
+
6
+
7
+ class NodeParser(HTMLParser):
8
+ root: Fragment
9
+ stack: list[Element]
10
+
11
+ def __init__(self):
12
+ super().__init__()
13
+ self.root = Fragment(children=[])
14
+ self.stack = []
15
+
16
+ def handle_starttag(
17
+ self, tag: str, attrs: t.Sequence[tuple[str, str | None]]
18
+ ) -> None:
19
+ element = Element(tag, attrs=dict(attrs), children=[])
20
+ self.stack.append(element)
21
+
22
+ # Unfortunately, Python's built-in HTMLParser has inconsistent behavior
23
+ # with void elements. In particular, it calls handle_endtag() for them
24
+ # only if they explicitly self-close (e.g., <br />). But in the HTML
25
+ # spec itself, *there is no distinction* between <br> and <br />.
26
+ # So we need to handle this case ourselves.
27
+ #
28
+ # See https://github.com/python/cpython/issues/69445
29
+ if tag in VOID_ELEMENTS:
30
+ # Always call handle_endtag for void elements. If it happens
31
+ # to be self-closed in the input, handle_endtag() will effectively
32
+ # be called twice. We ignore the second call there.
33
+ self.handle_endtag(tag)
34
+
35
+ def handle_endtag(self, tag: str) -> None:
36
+ if tag in VOID_ELEMENTS:
37
+ # Special case: handle Python issue #69445 (see comment above).
38
+ open_element = self.get_open_element()
39
+ if open_element and open_element.tag == tag:
40
+ _ = self.stack.pop()
41
+ self.append_child(open_element)
42
+ return
43
+ most_recent_closed = self.get_most_recent_closed_element()
44
+ if most_recent_closed and most_recent_closed.tag == tag:
45
+ # Ignore this call; we've already closed it.
46
+ return
47
+ raise ValueError(f"Unexpected closing tag </{tag}> with no open element.")
48
+
49
+ element = self.stack.pop()
50
+ if element.tag != tag:
51
+ raise ValueError(f"Mismatched closing tag </{tag}> for <{element.tag}>.")
52
+
53
+ self.append_child(element)
54
+
55
+ def handle_data(self, data: str) -> None:
56
+ text = Text(data)
57
+ self.append_child(text)
58
+
59
+ def handle_comment(self, data: str) -> None:
60
+ comment = Comment(data)
61
+ self.append_child(comment)
62
+
63
+ def handle_decl(self, decl: str) -> None:
64
+ if decl.upper().startswith("DOCTYPE"):
65
+ doctype_content = decl[7:].strip()
66
+ doctype = DocumentType(doctype_content)
67
+ self.append_child(doctype)
68
+ # For simplicity, we ignore other declarations.
69
+ pass
70
+
71
+ def get_parent(self) -> Fragment | Element:
72
+ """Return the current parent node to which new children should be added."""
73
+ return self.stack[-1] if self.stack else self.root
74
+
75
+ def get_open_element(self) -> Element | None:
76
+ """Return the currently open Element, if any."""
77
+ return self.stack[-1] if self.stack else None
78
+
79
+ def get_most_recent_closed_element(self) -> Element | None:
80
+ """Return the most recently closed Element, if any."""
81
+ parent = self.get_parent()
82
+ if parent.children and isinstance(parent.children[-1], Element):
83
+ return parent.children[-1]
84
+ return None
85
+
86
+ def append_child(self, child: Node) -> None:
87
+ parent = self.get_parent()
88
+ # We *know* our parser is using lists for children, so this cast is safe.
89
+ parent.children.append(child)
90
+
91
+ def close(self) -> None:
92
+ if self.stack:
93
+ raise ValueError("Invalid HTML structure: unclosed tags remain.")
94
+ super().close()
95
+
96
+ def get_node(self) -> Node:
97
+ """Get the Node tree parsed from the input HTML."""
98
+ assert not self.stack, "Did you forget to call close()?"
99
+ if len(self.root.children) > 1:
100
+ # The parse structure results in multiple root elements, so we
101
+ # return a Fragment to hold them all.
102
+ return self.root
103
+ elif len(self.root.children) == 1:
104
+ # The parse structure results in a single root element, so we
105
+ # return that element directly. This will be a non-Fragment Node.
106
+ return self.root.children[0]
107
+ else:
108
+ # Special case: the parse structure is empty; we treat
109
+ # this as an empty Text Node.
110
+ return Text("")
111
+
112
+
113
+ def parse_html(input_html: str) -> Node:
114
+ """Parse an HTML string into a Node tree."""
115
+ parser = NodeParser()
116
+ parser.feed(input_html)
117
+ parser.close()
118
+ return parser.get_node()
119
+
120
+
121
+ def parse_html_iter(input_html: t.Iterable[str]) -> Node:
122
+ """
123
+ Parse a sequence of HTML string chunks into a Node tree.
124
+
125
+ This is particularly useful if your sequence keeps separate text nodes
126
+ that you wish to preserve intact.
127
+ """
128
+ parser = NodeParser()
129
+ for chunk in input_html:
130
+ parser.feed(chunk)
131
+ parser.close()
132
+ return parser.get_node()
@@ -0,0 +1,356 @@
1
+ import random
2
+ import string
3
+ import typing as t
4
+ from collections.abc import Iterable
5
+ from functools import lru_cache
6
+ from string.templatelib import Interpolation, Template
7
+
8
+ from markupsafe import Markup
9
+
10
+ from .classnames import classnames
11
+ from .nodes import Element, Fragment, HasHTMLDunder, Node, Text
12
+ from .parser import parse_html_iter
13
+ from .utils import format_interpolation as base_format_interpolation
14
+
15
+ # --------------------------------------------------------------------------
16
+ # Value formatting
17
+ # --------------------------------------------------------------------------
18
+
19
+
20
+ def _format_safe(value: object, format_spec: str) -> str:
21
+ assert format_spec == "safe"
22
+ return Markup(value)
23
+
24
+
25
+ CUSTOM_FORMATTERS = (("safe", _format_safe),)
26
+
27
+
28
+ def format_interpolation(interpolation: Interpolation) -> object:
29
+ return base_format_interpolation(
30
+ interpolation,
31
+ formatters=CUSTOM_FORMATTERS,
32
+ )
33
+
34
+
35
+ # --------------------------------------------------------------------------
36
+ # Instrumentation, Parsing, and Caching
37
+ # --------------------------------------------------------------------------
38
+
39
+ _PLACEHOLDER_PREFIX = f"t🐍-{''.join(random.choices(string.ascii_lowercase, k=4))}-"
40
+ _PP_LEN = len(_PLACEHOLDER_PREFIX)
41
+
42
+
43
+ def _placeholder(i: int) -> str:
44
+ """Generate a placeholder for the i-th interpolation."""
45
+ return f"{_PLACEHOLDER_PREFIX}{i}"
46
+
47
+
48
+ def _placholder_index(s: str) -> int:
49
+ """Extract the index from a placeholder string."""
50
+ return int(s[_PP_LEN:])
51
+
52
+
53
+ def _instrument(
54
+ strings: tuple[str, ...], callable_ids: tuple[int | None, ...]
55
+ ) -> t.Iterable[str]:
56
+ """
57
+ Join the strings with placeholders in between where interpolations go.
58
+
59
+ This is used to prepare the template string for parsing, so that we can
60
+ later substitute the actual interpolated values into the parse tree.
61
+
62
+ The placeholders are chosen to be unlikely to collide with typical HTML
63
+ content.
64
+ """
65
+ count = len(strings)
66
+
67
+ callable_placeholders: dict[int, str] = {}
68
+
69
+ for i, s in enumerate(strings):
70
+ yield s
71
+ # There are always count-1 placeholders between count strings.
72
+ if i < count - 1:
73
+ # Special case for component callables: if the interpolation
74
+ # is a callable, we need to make sure that any matching closing
75
+ # tag uses the same placeholder.
76
+ callable_id = callable_ids[i]
77
+ if callable_id is not None:
78
+ # This interpolation is a callable, so we need to make sure
79
+ # that any matching closing tag uses the same placeholder.
80
+ if callable_id not in callable_placeholders:
81
+ callable_placeholders[callable_id] = _placeholder(i)
82
+ yield callable_placeholders[callable_id]
83
+ else:
84
+ yield _placeholder(i)
85
+
86
+
87
+ @lru_cache()
88
+ def _instrument_and_parse_internal(
89
+ strings: tuple[str, ...], callable_ids: tuple[int | None, ...]
90
+ ) -> Node:
91
+ """
92
+ Instrument the strings and parse the resulting HTML.
93
+
94
+ The result is cached to avoid re-parsing the same template multiple times.
95
+ """
96
+ instrumented = _instrument(strings, callable_ids)
97
+ return parse_html_iter(instrumented)
98
+
99
+
100
+ def _callable_id(value: object) -> int | None:
101
+ """Return a unique identifier for a callable, or None if not callable."""
102
+ return id(value) if callable(value) else None
103
+
104
+
105
+ def _instrument_and_parse(template: Template) -> Node:
106
+ """Instrument and parse a template, returning a tree of Nodes."""
107
+ # This is a thin wrapper around the cached internal function that does the
108
+ # actual work. This exists to handle the syntax we've settled on for
109
+ # component invocation, namely that callables are directly included as
110
+ # interpolations both in the open *and* the close tags. We need to make
111
+ # sure that matching tags... match!
112
+ #
113
+ # If we used `tdom`'s approach of component closing tags of <//> then we
114
+ # wouldn't have to do this. But I worry that tdom's syntax is harder to read
115
+ # (it's easy to miss the closing tag) and may prove unfamiliar for
116
+ # users coming from other templating systems.
117
+ callable_ids = tuple(
118
+ _callable_id(interpolation.value) for interpolation in template.interpolations
119
+ )
120
+ return _instrument_and_parse_internal(template.strings, callable_ids)
121
+
122
+
123
+ # --------------------------------------------------------------------------
124
+ # Placeholder Substitution
125
+ # --------------------------------------------------------------------------
126
+
127
+
128
+ def _force_dict(value: t.Any, *, kind: str) -> dict:
129
+ """Try to convert a value to a dict, raising TypeError if not possible."""
130
+ try:
131
+ return dict(value)
132
+ except (TypeError, ValueError):
133
+ raise TypeError(
134
+ f"Cannot use {type(value).__name__} as value for {kind} attributes"
135
+ ) from None
136
+
137
+
138
+ def _substitute_aria_attrs(value: object) -> t.Iterable[tuple[str, str | None]]:
139
+ """Produce aria-* attributes based on the interpolated value for "aria"."""
140
+ d = _force_dict(value, kind="aria")
141
+ for sub_k, sub_v in d.items():
142
+ if sub_v is True:
143
+ yield f"aria-{sub_k}", "true"
144
+ elif sub_v is False:
145
+ yield f"aria-{sub_k}", "false"
146
+ elif sub_v is None:
147
+ pass
148
+ else:
149
+ yield f"aria-{sub_k}", str(sub_v)
150
+
151
+
152
+ def _substitute_data_attrs(value: object) -> t.Iterable[tuple[str, str | None]]:
153
+ """Produce data-* attributes based on the interpolated value for "data"."""
154
+ d = _force_dict(value, kind="data")
155
+ for sub_k, sub_v in d.items():
156
+ if sub_v is True:
157
+ yield f"data-{sub_k}", None
158
+ elif sub_v not in (False, None):
159
+ yield f"data-{sub_k}", str(sub_v)
160
+
161
+
162
+ def _substitute_class_attr(value: object) -> t.Iterable[tuple[str, str | None]]:
163
+ """Substitute a class attribute based on the interpolated value."""
164
+ yield ("class", classnames(value))
165
+
166
+
167
+ def _substitute_style_attr(value: object) -> t.Iterable[tuple[str, str | None]]:
168
+ """Substitute a style attribute based on the interpolated value."""
169
+ try:
170
+ d = _force_dict(value, kind="style")
171
+ style_str = "; ".join(f"{k}: {v}" for k, v in d.items())
172
+ yield ("style", style_str)
173
+ except TypeError:
174
+ yield ("style", str(value))
175
+
176
+
177
+ def _substitute_spread_attrs(value: object) -> t.Iterable[tuple[str, str | None]]:
178
+ """
179
+ Substitute a spread attribute based on the interpolated value.
180
+
181
+ A spread attribute is one where the key is a placeholder, indicating that
182
+ the entire attribute set should be replaced by the interpolated value.
183
+ The value must be a dict or iterable of key-value pairs.
184
+ """
185
+ d = _force_dict(value, kind="spread")
186
+ for sub_k, sub_v in d.items():
187
+ yield from _substitute_attr(sub_k, sub_v)
188
+
189
+
190
+ # A collection of custom handlers for certain attribute names that have
191
+ # special semantics. This is in addition to the special-casing in
192
+ # _substitute_attr() itself.
193
+ CUSTOM_ATTR_HANDLERS = {
194
+ "class": _substitute_class_attr,
195
+ "data": _substitute_data_attrs,
196
+ "style": _substitute_style_attr,
197
+ "aria": _substitute_aria_attrs,
198
+ }
199
+
200
+
201
+ def _substitute_attr(
202
+ key: str,
203
+ value: object,
204
+ ) -> t.Iterable[tuple[str, str | None]]:
205
+ """
206
+ Substitute a single attribute based on its key and the interpolated value.
207
+
208
+ A single parsed attribute with a placeholder may result in multiple
209
+ attributes in the final output, for instance if the value is a dict or
210
+ iterable of key-value pairs. Likewise, a value of False will result in
211
+ the attribute being omitted entirely; nothing is yielded in that case.
212
+ """
213
+ # Special handling for certain attribute names that have special semantics
214
+ if custom_handler := CUSTOM_ATTR_HANDLERS.get(key):
215
+ yield from custom_handler(value)
216
+ return
217
+
218
+ # General handling for all other attributes:
219
+ match value:
220
+ case str():
221
+ yield (key, value)
222
+ case True:
223
+ yield (key, None)
224
+ case False | None:
225
+ pass
226
+ case _:
227
+ yield (key, str(value))
228
+
229
+
230
+ def _substitute_attrs(
231
+ attrs: dict[str, str | None], interpolations: tuple[Interpolation, ...]
232
+ ) -> dict[str, str | None]:
233
+ """Substitute placeholders in attributes based on the corresponding interpolations."""
234
+ new_attrs: dict[str, str | None] = {}
235
+ for key, value in attrs.items():
236
+ if value and value.startswith(_PLACEHOLDER_PREFIX):
237
+ index = _placholder_index(value)
238
+ interpolation = interpolations[index]
239
+ value = format_interpolation(interpolation)
240
+ for sub_k, sub_v in _substitute_attr(key, value):
241
+ new_attrs[sub_k] = sub_v
242
+ elif key.startswith(_PLACEHOLDER_PREFIX):
243
+ index = _placholder_index(key)
244
+ interpolation = interpolations[index]
245
+ value = format_interpolation(interpolation)
246
+ for sub_k, sub_v in _substitute_spread_attrs(value):
247
+ new_attrs[sub_k] = sub_v
248
+ else:
249
+ new_attrs[key] = value
250
+ return new_attrs
251
+
252
+
253
+ def _substitute_and_flatten_children(
254
+ children: t.Iterable[Node], interpolations: tuple[Interpolation, ...]
255
+ ) -> list[Node]:
256
+ """Substitute placeholders in a list of children and flatten any fragments."""
257
+ new_children: list[Node] = []
258
+ for child in children:
259
+ substituted = _substitute_node(child, interpolations)
260
+ if isinstance(substituted, Fragment):
261
+ # This can happen if an interpolation results in a Fragment, for
262
+ # instance if it is iterable.
263
+ new_children.extend(substituted.children)
264
+ else:
265
+ new_children.append(substituted)
266
+ return new_children
267
+
268
+
269
+ def _node_from_value(value: object) -> Node:
270
+ """
271
+ Convert an arbitrary value to a Node.
272
+
273
+ This is the primary substitution performed when replacing interpolations
274
+ in child content positions.
275
+ """
276
+ match value:
277
+ case str():
278
+ return Text(value)
279
+ case Node():
280
+ return value
281
+ case Template():
282
+ return html(value)
283
+ case HasHTMLDunder():
284
+ return Text(value)
285
+ case False:
286
+ return Text("")
287
+ case Iterable():
288
+ children = [_node_from_value(v) for v in value]
289
+ return Fragment(children=children)
290
+ case _:
291
+ return Text(str(value))
292
+
293
+
294
+ def _invoke_component(
295
+ tag: str,
296
+ new_attrs: dict[str, str | None],
297
+ new_children: list[Node],
298
+ interpolations: tuple[Interpolation, ...],
299
+ ) -> Node:
300
+ """Substitute a component invocation based on the corresponding interpolations."""
301
+ index = _placholder_index(tag)
302
+ interpolation = interpolations[index]
303
+ value = format_interpolation(interpolation)
304
+ if not callable(value):
305
+ raise TypeError(
306
+ f"Expected a callable for component invocation, got {type(value).__name__}"
307
+ )
308
+ # Call the component and return the resulting node
309
+ result = value(*new_children, **new_attrs)
310
+ match result:
311
+ case Node():
312
+ return result
313
+ case Template():
314
+ return html(result)
315
+ case HasHTMLDunder() | str():
316
+ return Text(result)
317
+ case _:
318
+ raise TypeError(
319
+ f"Component callable must return a Node, Template, str, or "
320
+ f"HasHTMLDunder, got {type(result).__name__}"
321
+ )
322
+
323
+
324
+ def _substitute_node(p_node: Node, interpolations: tuple[Interpolation, ...]) -> Node:
325
+ """Substitute placeholders in a node based on the corresponding interpolations."""
326
+ match p_node:
327
+ case Text(text) if str(text).startswith(_PLACEHOLDER_PREFIX):
328
+ index = _placholder_index(str(text))
329
+ interpolation = interpolations[index]
330
+ value = format_interpolation(interpolation)
331
+ return _node_from_value(value)
332
+ case Element(tag=tag, attrs=attrs, children=children):
333
+ new_attrs = _substitute_attrs(attrs, interpolations)
334
+ new_children = _substitute_and_flatten_children(children, interpolations)
335
+ if tag.startswith(_PLACEHOLDER_PREFIX):
336
+ return _invoke_component(tag, new_attrs, new_children, interpolations)
337
+ else:
338
+ return Element(tag=tag, attrs=new_attrs, children=new_children)
339
+ case Fragment(children=children):
340
+ new_children = _substitute_and_flatten_children(children, interpolations)
341
+ return Fragment(children=new_children)
342
+ case _:
343
+ return p_node
344
+
345
+
346
+ # --------------------------------------------------------------------------
347
+ # Public API
348
+ # --------------------------------------------------------------------------
349
+
350
+
351
+ def html(template: Template) -> Node:
352
+ """Parse a t-string and return a tree of Nodes."""
353
+ # Parse the HTML, returning a tree of nodes with placeholders
354
+ # where interpolations go.
355
+ p_node = _instrument_and_parse(template)
356
+ return _substitute_node(p_node, template.interpolations)
html_tstring/utils.py ADDED
@@ -0,0 +1,88 @@
1
+ import typing as t
2
+ from string.templatelib import Interpolation
3
+
4
+
5
+ @t.overload
6
+ def convert[T](value: T, conversion: None) -> T: ...
7
+
8
+
9
+ @t.overload
10
+ def convert(value: object, conversion: t.Literal["a", "r", "s"]) -> str: ...
11
+
12
+
13
+ def convert[T](value: T, conversion: t.Literal["a", "r", "s"] | None) -> T | str:
14
+ """
15
+ Convert a value according to the given conversion specifier.
16
+
17
+ In the future, something like this should probably ship with Python itself.
18
+ """
19
+ if conversion == "a":
20
+ return ascii(value)
21
+ elif conversion == "r":
22
+ return repr(value)
23
+ elif conversion == "s":
24
+ return str(value)
25
+ else:
26
+ return value
27
+
28
+
29
+ type FormatMatcher = t.Callable[[str], bool]
30
+ """A predicate function that returns True if a given format specifier matches its criteria."""
31
+
32
+ type CustomFormatter = t.Callable[[object, str], str]
33
+ """A function that takes a value and a format specifier and returns a formatted string."""
34
+
35
+ type MatcherAndFormatter = tuple[str | FormatMatcher, CustomFormatter]
36
+ """
37
+ A pair of a matcher and its corresponding formatter.
38
+
39
+ The matcher is used to determine if the formatter should be applied to a given
40
+ format specifier. If the matcher is a string, it must exactly match the format
41
+ specifier. If it is a FormatMatcher, it is called with the format specifier and
42
+ should return True if the formatter should be used.
43
+ """
44
+
45
+
46
+ def _matcher_matches(matcher: str | FormatMatcher, format_spec: str) -> bool:
47
+ """Check if a matcher matches a given format specifier."""
48
+ return matcher == format_spec if isinstance(matcher, str) else matcher(format_spec)
49
+
50
+
51
+ def _format_interpolation(
52
+ value: object,
53
+ format_spec: str,
54
+ conversion: t.Literal["a", "r", "s"] | None,
55
+ *,
56
+ formatters: t.Sequence[MatcherAndFormatter],
57
+ ) -> object:
58
+ converted = convert(value, conversion)
59
+ if format_spec:
60
+ for matcher, formatter in formatters:
61
+ if _matcher_matches(matcher, format_spec):
62
+ return formatter(converted, format_spec)
63
+ return format(converted, format_spec)
64
+ return converted
65
+
66
+
67
+ def format_interpolation(
68
+ interpolation: Interpolation,
69
+ *,
70
+ formatters: t.Sequence[MatcherAndFormatter] = tuple(),
71
+ ) -> object:
72
+ """
73
+ Format an Interpolation's value according to its format spec and conversion.
74
+
75
+ PEP 750 allows t-string processing code to decide whether, and how, to
76
+ interpret format specifiers. This function takes an optional sequence of
77
+ (matcher, formatter) pairs. If a matcher returns True for the given format
78
+ spec, the corresponding formatter is used to format the value. If no
79
+ matchers match, the default formatting behavior is used.
80
+
81
+ Conversions are always applied before formatting.
82
+ """
83
+ return _format_interpolation(
84
+ interpolation.value,
85
+ interpolation.format_spec,
86
+ interpolation.conversion,
87
+ formatters=formatters,
88
+ )
@@ -0,0 +1,316 @@
1
+ Metadata-Version: 2.4
2
+ Name: html-tstring
3
+ Version: 0.1.0
4
+ Summary: A 🤘 rockin' t-string HTML templating system for Python 3.14.
5
+ Project-URL: Homepage, https://github.com/t-strings/html-tstring
6
+ Project-URL: Changelog, https://github.com/t-strings/html-tstring/releases
7
+ Project-URL: Issues, https://github.com/t-strings/html-tstring/issues
8
+ Project-URL: CI, https://github.com/t-strings/html-tstring/actions
9
+ Author-email: Dave Peck <davepeck@davepeck.org>
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Requires-Python: >=3.14
16
+ Requires-Dist: markupsafe>=3.0.2
17
+ Description-Content-Type: text/markdown
18
+
19
+ # html-tstring
20
+
21
+ A 🤘 rockin' t-string HTML templating system for Python 3.14.
22
+
23
+ [![PyPI](https://img.shields.io/pypi/v/html-tstring.svg)](https://pypi.org/project/html-tstring/)
24
+ [![Tests](https://github.com/t-strings/html-tstring/actions/workflows/ci.yml/badge.svg)](https://github.com/t-strings/tdom/actions/workflows/pytest.yml)
25
+ [![Changelog](https://img.shields.io/github/v/release/t-strings/html-tstring?include_prereleases&label=changelog)](https://github.com/t-strings/html-tstring/releases)
26
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/t-strings/html-tstring/blob/main/LICENSE)
27
+
28
+ ## Installation
29
+
30
+ Just run:
31
+
32
+ ```bash
33
+ pip install html-tstring
34
+ ```
35
+
36
+ Python 3.14 isn't out yet, but you can use [Astral's `uv`](https://docs.astral.sh/uv/) to easily try `html-tstring` in a Python 3.14 environment:
37
+
38
+ ```bash
39
+ uv run --with html-tstring --python 3.14 python
40
+ ```
41
+
42
+ ## Usage
43
+
44
+ `html-tstring` leverages Python 3.14's [new t-strings feature](https://t-strings.help/introduction.html) to provide a powerful HTML templating system that feels familiar if you've used JSX, Jinja2, or Django templates.
45
+
46
+ T-strings work just like f-strings but use a `t` prefix and [create `Template` objects](https://docs.python.org/3.14/library/string.templatelib.html#template-strings) instead of strings.
47
+
48
+ Once you have a `Template`, you can call this package's `html()` function to convert it into a tree of `Node` objects that represent your HTML structure. From there, you can render it to a string, manipulate it programmatically, or compose it with other templates for maximum flexibility.
49
+
50
+ ### Getting Started
51
+
52
+ Import the `html` function and start creating templates:
53
+
54
+ ```python
55
+ from html_tstring import html
56
+ greeting = html(t"<h1>Hello, World!</h1>")
57
+ print(type(greeting)) # <class 'html_tstring.nodes.Element'>
58
+ print(greeting) # <h1>Hello, World!</h1>
59
+ ```
60
+
61
+ ### Variable Interpolation
62
+
63
+ Just like f-strings, you can interpolate (substitute) variables directly into your templates:
64
+
65
+ ```python
66
+ name = "Alice"
67
+ age = 30
68
+ user_info = html(t"<p>Hello, {name}! You are {age} years old.</p>")
69
+ print(user_info) # <p>Hello, Alice! You are 30 years old.</p>
70
+ ```
71
+
72
+ The `html()` function ensures that interpolated values are automatically escaped to prevent XSS attacks:
73
+
74
+ ```python
75
+ user_name = "<script>alert('owned')</script>"
76
+ safe_output = html(t"<p>Hello, {user_name}!</p>")
77
+ print(safe_output) # <p>Hello, &lt;script&gt;alert('owned')&lt;/script&gt;!</p>
78
+ ```
79
+
80
+ ### Attribute Substitution
81
+
82
+ The `html()` function provides a number of convenient ways to define HTML attributes.
83
+
84
+ #### Direct Attribute Values
85
+
86
+ You can place values directly in attribute positions:
87
+
88
+ ```python
89
+ url = "https://example.com"
90
+ link = html(t'<a href="{url}">Visit our site</a>')
91
+ # <a href="https://example.com">Visit our site</a>
92
+ ```
93
+
94
+ You don't _have_ to wrap your attribute values in quotes:
95
+
96
+ ```python
97
+ element_id = "my-button"
98
+ button = html(t"<button id={element_id}>Click me</button>")
99
+ # <button id="my-button">Click me</button>
100
+ ```
101
+
102
+ Boolean attributes are supported too. Just use a boolean value in the attribute position:
103
+
104
+ ```python
105
+ form_button = html(t"<button disabled={True} hidden={False}>Submit</button>")
106
+ print(form_button)
107
+ # <button disabled>Submit</button>
108
+ ```
109
+
110
+ #### The `class` Attribute
111
+
112
+ The `class` attribute has special handling to make it easy to combine multiple classes from different sources. The simplest way is to provide a list of class names:
113
+
114
+ ```python
115
+ classes = ["btn", "btn-primary", "active"]
116
+ button = html(t'<button class="{classes}">Click me</button>')
117
+ # <button class="btn btn-primary active">Click me</button>
118
+ ```
119
+
120
+ For flexibility, you can also provide a list of strings, dictionaries, or a mix of both:
121
+
122
+ ```python
123
+ classes = ["btn", "btn-primary", {"active": True}, None, False and "disabled"]
124
+ button = html(t'<button class="{classes}">Click me</button>')
125
+ # <button class="btn btn-primary active">Click me</button>
126
+ ```
127
+
128
+ See the [`classnames()`](./html_tstring/classnames_test.py) helper function for more information on how class names are combined.
129
+
130
+ #### The `style` Attribute
131
+
132
+ In addition to strings, you can also provide a dictionary of CSS properties and values for the `style` attribute:
133
+
134
+ ```python
135
+ # Style attributes from dictionaries
136
+ styles = {"color": "red", "font-weight": "bold", "margin": "10px"}
137
+ styled = html(t"<p style={styles}>Important text</p>")
138
+ # <p style="color: red; font-weight: bold; margin: 10px">Important text</p>
139
+ ```
140
+
141
+ #### The `data` and `aria` Attributes
142
+
143
+ The `data` and `aria` attributes also have special handling to convert dictionary keys to the appropriate attribute names:
144
+
145
+ ```python
146
+ data_attrs = {"user-id": 123, "role": "admin"}
147
+ aria_attrs = {"label": "Close dialog", "hidden": True}
148
+ element = html(t"<div data={data_attrs} aria={aria_attrs}>Content</div>")
149
+ # <div data-user-id="123" data-role="admin" aria-label="Close dialog"
150
+ # aria-hidden="true">Content</div>
151
+ ```
152
+
153
+ Note that boolean values in `aria` attributes are converted to `"true"` or `"false"` as per [the ARIA specification](https://www.w3.org/TR/wai-aria-1.2/).
154
+
155
+ #### Attribute Spreading
156
+
157
+ It's possible to specify multiple attributes at once by using a dictionary and spreading it into an element using curly braces:
158
+
159
+ ```python
160
+ attrs = {"href": "https://example.com", "target": "_blank"}
161
+ link = html(t"<a {attrs}>External link</a>")
162
+ # <a href="https://example.com" target="_blank">External link</a>
163
+ ```
164
+
165
+ You can also combine spreading with individual attributes:
166
+
167
+ ```python
168
+ base_attrs = {"id": "my-link"}
169
+ target = "_blank"
170
+ link = html(t'<a {base_attrs} target="{target}">Link</a>')
171
+ # <a id="my-link" target="_blank">Link</a>
172
+ ```
173
+
174
+ Special attributes likes `class` behave as expected when combined with spreading:
175
+
176
+ ```python
177
+ classes = ["btn", {"active": True}]
178
+ attrs = {"class": classes, "id": "act_now", "data": {"wow": "such-attr"}}
179
+ button = html(t'<button {attrs}>Click me</button>')
180
+ # <button class="btn active" id="act_now" data-wow="such-attr">Click me</button>
181
+ ```
182
+
183
+ ### Conditional Rendering
184
+
185
+ You can use Python's conditional expressions for dynamic content:
186
+
187
+ ```python
188
+ is_logged_in = True
189
+ user_content = t"<span>Welcome back!</span>"
190
+ guest_content = t"<a href='/login'>Please log in</a>"
191
+ header = html(t"<div>{user_content if is_logged_in else guest_content}</div>")
192
+ # <div><span>Welcome back!</span></div>
193
+ ```
194
+
195
+ Short-circuit evaluation is also supported for conditionally including elements:
196
+
197
+ ```python
198
+ show_warning = False
199
+ warning = t'<div class="alert">Warning message</div>'
200
+ page = html(t"<main>{show_warning and warning}</main>")
201
+ # <main></main>
202
+ ```
203
+
204
+ ### Lists and Iteration
205
+
206
+ Generate repeated elements using list comprehensions:
207
+
208
+ ```python
209
+ fruits = ["Apple", "Banana", "Cherry"]
210
+ fruit_list = html(t"<ul>{[t'<li>{fruit}</li>' for fruit in fruits]}</ul>")
211
+ # <ul><li>Apple</li><li>Banana</li><li>Cherry</li></ul>
212
+ ```
213
+
214
+ ### Raw HTML Injection
215
+
216
+ The `html-tstring` package provides several ways to include trusted raw HTML content in your templates. This is useful when you have HTML content that you _know_ is safe and do not wish to escape.
217
+
218
+ Under the hood, `html-tstring` builds on top of the familiar [MarkupSafe](https://pypi.org/project/MarkupSafe/) library to handle trusted HTML content. If you've used Flask, Jinja2, or similar libraries, this will feel very familiar.
219
+
220
+ The `Markup` class from MarkupSafe is available for use:
221
+
222
+ ```python
223
+ from html_tstring import html, Markup
224
+
225
+ trusted_html = Markup("<strong>This is safe HTML</strong>")
226
+ content = html(t"<div>{trusted_html}</div>")
227
+ # <div><strong>This is safe HTML</strong></div>
228
+ ```
229
+
230
+ As a convenience, `html-tstring` also supports a `:safe` format specifier that marks a string as safe HTML:
231
+
232
+ ```python
233
+ trusted_html = "<em>Emphasized text</em>"
234
+ page = html(t"<p>Here is some {trusted_html:safe} content.</p>")
235
+ # <p>Here is some <em>Emphasized text</em> content.</p>
236
+ ```
237
+
238
+ For interoperability with other templating libraries, any object that implements a `__html__` method will be treated as safe HTML. Many popular libraries (including MarkupSafe and Django) use this convention:
239
+
240
+ ```python
241
+ class SafeWidget:
242
+ def __html__(self):
243
+ return "<button>Custom Widget</button>"
244
+
245
+ page = html(t"<div>My widget: {SafeWidget()}</div>")
246
+ # <div>My widget: <button>Custom Widget</button></div>
247
+ ```
248
+
249
+ TODO: support explicitly marking content as `unsafe` with a format specifier, too.
250
+
251
+ ### Template Composition
252
+
253
+ You can easily combine multiple templates and create reusable components.
254
+
255
+ Template nesting is straightforward:
256
+
257
+ ```python
258
+ content = t"<h1>My Site</h1>"
259
+ page = html(t"<div>{content}</div>")
260
+ # <div><h1>My Site</h1></div>
261
+ ```
262
+
263
+ In the example above, `content` is a `Template` object that gets correctly parsed and embedded within the outer template. You can also explicitly call `html()` on nested templates if you prefer:
264
+
265
+ ```python
266
+ content = html(t"<h1>My Site</h1>")
267
+ page = html(t"<div>{content}</div>")
268
+ # <div><h1>My Site</h1></div>
269
+ ```
270
+
271
+ The result is the same either way.
272
+
273
+ ### Advanced Features
274
+
275
+ #### Component Functions
276
+
277
+ You can create reusable component functions that generate templates with dynamic content and attributes. Use these like custom HTML elements in your templates.
278
+
279
+ The basic form of all component functions is:
280
+
281
+ ```python
282
+ from typing import Any
283
+
284
+ def MyComponent(*children: Node, **attrs: Any) -> Template:
285
+ # Build your template using the provided props
286
+ return t"<div {attrs}>{children}</div>"
287
+ ```
288
+
289
+ To _invoke_ your component within an HTML template, use the special `<{ComponentName} ... />` syntax:
290
+
291
+ ```python
292
+ result = html(t"<{MyComponent} id='comp1'>Hello, Component!</{MyComponent}>")
293
+ # <div id="comp1">Hello, Component!</div>
294
+ ```
295
+
296
+ Because attributes are passed as keyword arguments, you can explicitly provide type hints for better editor support:
297
+
298
+ ```python
299
+ from typing import Any
300
+
301
+ def Link(*, href: str, text: str, **props: Any) -> Template:
302
+ return t'<a href="{href}" {props}>{text}</a>'
303
+
304
+ result = html(t'<{Link} href="https://example.com" text="Example" target="_blank" />')
305
+ # <a href="https://example.com" target="_blank">Example</a>
306
+ ```
307
+
308
+ In addition to returning a `Template` directly, component functions may also return any `Node` type found in [`html_tstring.nodes`](./html_tstring/nodes.py). This allows you to build more complex components that manipulate the HTML structure programmatically.
309
+
310
+ #### Context
311
+
312
+ TODO: implement context feature
313
+
314
+ #### Working with `Node` Objects
315
+
316
+ TODO: say more about working with them directly
@@ -0,0 +1,10 @@
1
+ html_tstring/__init__.py,sha256=qKgfEk1p3-rGfGUIIGpRneBAEJ4354MpCKsIFkLkxNs,342
2
+ html_tstring/classnames.py,sha256=nRELUKM5CSE0ROnbA7eIFEluCadA32DPcoZ9eq0TLdU,1712
3
+ html_tstring/nodes.py,sha256=xD2T9bK7H5oz_f7qy5kslITZtdD7wFmkfZU4Q7w-Oc8,3779
4
+ html_tstring/parser.py,sha256=oprMhiEk1f61i9c16-THubRLFvcNyfOBxtHv-VBiSxg,4979
5
+ html_tstring/processor.py,sha256=zirUi-CBK6UTAVmyma2zcDvFWWdVOgQMwSwleBY2qv4,13007
6
+ html_tstring/utils.py,sha256=SqAA4jZKv5D7MHmp7hXOMuPBEwA2ELKGIiiwKC7psJs,2910
7
+ html_tstring-0.1.0.dist-info/METADATA,sha256=uhO_3ZuN6LoUPhkxBgp_9Q_Sy9Sc2fglgPgaKmcFjz0,11193
8
+ html_tstring-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
9
+ html_tstring-0.1.0.dist-info/licenses/LICENSE,sha256=wsfEeu57NkGuQ7NHD5ZcL5g0EnqLN_PGCaq-D--b-HQ,1090
10
+ html_tstring-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Dave Peck <davepeck@davepeck.org>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.