Skip to content

Commit 5faec74

Browse files
committed
Add more tests.
1 parent 0c6805f commit 5faec74

15 files changed

+456
-239
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -599,7 +599,7 @@ used internally by the `html()` function but can also be used independently:
599599

600600
```python
601601
from string.templatelib import Interpolation
602-
from tdom.utils import convert
602+
from tdom.format import convert
603603

604604
# Test convert function
605605
assert convert("hello", "s") == "hello"

tdom/escaping.py

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@
55
escape_html_text = markup_escape # unify api for test of project
66

77

8-
def escape_html_comment(text):
9-
"""Escape text injected into an HTML comment."""
10-
GT = ">"
11-
LT = "<"
8+
GT = ">"
9+
LT = "<"
10+
1211

12+
def escape_html_comment(text: str) -> str:
13+
"""Escape text injected into an HTML comment."""
1314
if not text:
1415
return text
1516
# - text must not start with the string ">"
@@ -21,15 +22,9 @@ def escape_html_comment(text):
2122
text = "-" + GT + text[2:]
2223

2324
# - nor contain the strings "<!--", "-->", or "--!>"
24-
index = text.find("<!--")
25-
if index != -1:
26-
text = text[:index] + LT + text[index + 1]
27-
index = text.find("-->")
28-
if index != -1:
29-
text = text[: index + 2] + GT + text[index + 3]
30-
index = text.find("--!>")
31-
if index != -1:
32-
text = text[: index + 3] + GT + text[index + 4]
25+
text = text.replace("<!--", LT + "!--")
26+
text = text.replace("-->", "--" + GT)
27+
text = text.replace("--!>", "--!" + GT)
3328

3429
# - nor end with the string "<!-".
3530
if text[-3:] == "<!-":
@@ -38,16 +33,27 @@ def escape_html_comment(text):
3833
return text
3934

4035

41-
def escape_html_style(text):
42-
LT = "&lt;"
43-
close_str = "</style>"
44-
close_str_re = re.compile(close_str, re.I | re.A)
45-
replace_str = LT + close_str[1:]
46-
return re.sub(close_str_re, replace_str, text)
36+
STYLE_RES = ((re.compile("</style>", re.I | re.A), LT + "/style>"),)
4737

4838

49-
def escape_html_script(text):
39+
def escape_html_style(text: str) -> str:
40+
"""Escape text injected into an HTML style element."""
41+
for matche_re, replace_text in STYLE_RES:
42+
text = re.sub(matche_re, replace_text, text)
43+
return text
44+
45+
46+
SCRIPT_RES = (
47+
(re.compile("<!--", re.I | re.A), "\x3c!--"),
48+
(re.compile("<script", re.I | re.A), "\x3cscript"),
49+
(re.compile("</script", re.I | re.A), "\x3c/script"),
50+
)
51+
52+
53+
def escape_html_script(text: str) -> str:
5054
"""
55+
Escape text injected into an HTML script element.
56+
5157
https://html.spec.whatwg.org/multipage/scripting.html#restrictions-for-contents-of-script-elements
5258
5359
(from link) The easiest and safest way to avoid the rather strange restrictions
@@ -57,11 +63,6 @@ def escape_html_script(text):
5763
- "<script" as "\x3cscript"
5864
- "</script" as "\x3c/script"`
5965
"""
60-
match_to_replace = (
61-
(re.compile("<!--", re.I | re.A), "\x3c!--"),
62-
(re.compile("<script", re.I | re.A), "\x3cscript"),
63-
(re.compile("</script", re.I | re.A), "\x3c/script"),
64-
)
65-
for match_re, replace_text in match_to_replace:
66+
for match_re, replace_text in SCRIPT_RES:
6667
text = re.sub(match_re, replace_text, text)
6768
return text

tdom/escaping_test.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from .escaping import escape_html_comment, escape_html_script, escape_html_style
2+
3+
4+
def test_escape_html_comment_empty() -> None:
5+
assert escape_html_comment("") == ""
6+
7+
8+
def test_escape_html_comment_no_special() -> None:
9+
assert escape_html_comment("This is a comment.") == "This is a comment."
10+
11+
12+
def test_escape_html_comment_starts_with_gt() -> None:
13+
assert escape_html_comment(">This is a comment.") == "&gt;This is a comment."
14+
15+
16+
def test_escape_html_comment_starts_with_dash_gt() -> None:
17+
assert escape_html_comment("->This is a comment.") == "-&gt;This is a comment."
18+
19+
20+
def test_escape_html_comment_contains_special_strings() -> None:
21+
input_text = "This is <!-- a comment --> with --!> special strings."
22+
expected_output = "This is &lt;!-- a comment --&gt; with --!&gt; special strings."
23+
assert escape_html_comment(input_text) == expected_output
24+
25+
26+
def test_escape_html_comment_ends_with_lt_dash() -> None:
27+
assert escape_html_comment("This is a comment<!-") == "This is a comment&lt;!-"
28+
29+
30+
def test_escape_html_style() -> None:
31+
input_text = "body { color: red; }</style> p { font-SIZE: 12px; }</STYLE>"
32+
expected_output = (
33+
"body { color: red; }&lt;/style> p { font-SIZE: 12px; }&lt;/style>"
34+
)
35+
assert escape_html_style(input_text) == expected_output
36+
37+
38+
def test_escape_html_script() -> None:
39+
input_text = "<!-- <script>var a = 1;</script> </SCRIPT>"
40+
expected_output = "\x3c!-- \x3cscript>var a = 1;\x3c/script> </script>"
41+
assert escape_html_script(input_text) == expected_output

tdom/format.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import typing as t
2+
from string.templatelib import Interpolation, Template
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+
)
89+
90+
91+
def format_template(template: Template) -> str:
92+
"""Fully render a template by formatting its interpolations."""
93+
parts: list[str] = []
94+
for part in template:
95+
if isinstance(part, str):
96+
parts.append(part)
97+
else:
98+
parts.append(str(format_interpolation(part)))
99+
return "".join(parts)

tdom/format_test.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
from string.templatelib import Interpolation
2+
3+
from .format import convert, format_interpolation, format_template
4+
5+
6+
class Convertible:
7+
def __str__(self) -> str:
8+
return "Convertible str"
9+
10+
def __repr__(self) -> str:
11+
return "Convertible repr"
12+
13+
14+
def test_convert_none():
15+
value = Convertible()
16+
assert convert(value, None) is value
17+
18+
19+
def test_convert_a():
20+
value = Convertible()
21+
assert convert(value, "a") == "Convertible repr"
22+
assert convert("Café", "a") == "'Caf\\xe9'"
23+
24+
25+
def test_convert_r():
26+
value = Convertible()
27+
assert convert(value, "r") == "Convertible repr"
28+
29+
30+
def test_convert_s():
31+
value = Convertible()
32+
assert convert(value, "s") == "Convertible str"
33+
34+
35+
def test_format_interpolation_no_formatting():
36+
value = Convertible()
37+
interp = Interpolation(value, expression="", conversion=None, format_spec="")
38+
assert format_interpolation(interp) is value
39+
40+
41+
def test_format_interpolation_a():
42+
value = Convertible()
43+
interp = Interpolation(value, expression="", conversion="a", format_spec="")
44+
assert format_interpolation(interp) == "Convertible repr"
45+
46+
47+
def test_format_interpolation_r():
48+
value = Convertible()
49+
interp = Interpolation(value, expression="", conversion="r", format_spec="")
50+
assert format_interpolation(interp) == "Convertible repr"
51+
52+
53+
def test_format_interpolation_s():
54+
value = Convertible()
55+
interp = Interpolation(value, expression="", conversion="s", format_spec="")
56+
assert format_interpolation(interp) == "Convertible str"
57+
58+
59+
def test_format_interpolation_default_formatting():
60+
value = 42
61+
interp = Interpolation(value, expression="", conversion=None, format_spec="5d")
62+
assert format_interpolation(interp) == " 42"
63+
64+
65+
def test_format_interpolation_custom_formatter_match_exact():
66+
value = 42
67+
interp = Interpolation(value, expression="", conversion=None, format_spec="custom")
68+
69+
def formatter(val: object, spec: str) -> str:
70+
return f"formatted-{val}-{spec}"
71+
72+
assert (
73+
format_interpolation(interp, formatters=[("custom", formatter)])
74+
== "formatted-42-custom"
75+
)
76+
77+
78+
def test_format_interpolation_custom_formatter_match_predicate():
79+
value = 42
80+
interp = Interpolation(
81+
value, expression="", conversion=None, format_spec="custom123"
82+
)
83+
84+
def matcher(spec: str) -> bool:
85+
return spec.startswith("custom")
86+
87+
def formatter(val: object, spec: str) -> str:
88+
return f"formatted-{val}-{spec}"
89+
90+
assert (
91+
format_interpolation(interp, formatters=[(matcher, formatter)])
92+
== "formatted-42-custom123"
93+
)
94+
95+
96+
def test_format_template():
97+
t = t"Value: {42.19:.1f}, Text: {Convertible()!s}, Raw: {Convertible()!r}"
98+
result = format_template(t)
99+
assert result == "Value: 42.2, Text: Convertible str, Raw: Convertible repr"

tdom/nodes.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
RCDATA_CONTENT_ELEMENTS = frozenset(["textarea", "title"])
3333
CONTENT_ELEMENTS = CDATA_CONTENT_ELEMENTS | RCDATA_CONTENT_ELEMENTS
3434

35+
3536
# FUTURE: add a pretty-printer to nodes for debugging
3637
# FUTURE: make nodes frozen (and have the parser work with mutable builders)
3738

tdom/placeholders.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import re
33
import string
44

5-
from .templating import TemplateRef
5+
from .template_utils import TemplateRef
66

77
FRAGMENT_TAG = f"t🐍f-{''.join(random.choices(string.ascii_lowercase, k=4))}-"
88
_PLACEHOLDER_PREFIX = f"t🐍{''.join(random.choices(string.ascii_lowercase, k=2))}-"

0 commit comments

Comments
 (0)