Skip to content

Commit e200b9e

Browse files
committed
Added PBT
1 parent 8864455 commit e200b9e

4 files changed

Lines changed: 310 additions & 0 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Python-generated files
22
__pycache__/
33
*.py[oc]
4+
.hypothesis/
5+
.pytest_cache/
46
build/
57
dist/
68
wheels/

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ fstache = "fstache._cli:main"
2626
dev = [
2727
"ruff>=0.6.0",
2828
"chevron==0.14.0",
29+
"hypothesis",
2930
"mstache==0.3.0",
3031
"pystache==0.6.8",
3132
"pytest",

tests/test_properties.py

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
from collections.abc import Mapping
2+
from typing import Final
3+
4+
import fstache
5+
from hypothesis import example, given, settings, strategies as st
6+
7+
8+
_GENERATED_TEMPLATE_NAME: Final[str] = "generated.mustache"
9+
_KNOWN_SYNTAX_ERROR_KINDS: Final[frozenset[str]] = frozenset(
10+
{
11+
"invalid_delimiter",
12+
"invalid_name",
13+
"section_syntax",
14+
"unclosed_tag",
15+
"unsupported_tag",
16+
}
17+
)
18+
_MAX_SYNTAX_EXCERPT_CHARS: Final[int] = 80
19+
_CR_BYTE: Final[int] = ord("\r")
20+
_LF_BYTE: Final[int] = ord("\n")
21+
_LINE_BYTES: Final = st.lists(
22+
st.sampled_from(
23+
tuple(byte for byte in range(128) if byte not in (_CR_BYTE, _LF_BYTE))
24+
),
25+
max_size=20,
26+
).map(bytes)
27+
28+
29+
@example(b"")
30+
@example(b"{{")
31+
@example(b"{{{name}}")
32+
@example(b"{{#x}}")
33+
@example(b"{{/x}}")
34+
@example(b"{{=<% %>=}}<%#x%>")
35+
@example(b"line\rnext\nlast\r\nend")
36+
@example(b"{{\xff}}")
37+
@example(b"{{! invalid \xff comment }}")
38+
@settings(max_examples=500, deadline=None)
39+
@given(st.binary(min_size=0, max_size=512))
40+
def test_arbitrary_template_bytes_compile_or_render_coherently(
41+
template: bytes,
42+
) -> None:
43+
try:
44+
compiled = fstache.compile(template, name=_GENERATED_TEMPLATE_NAME)
45+
except fstache.TemplateSyntaxError as exc:
46+
assert_syntax_error_is_coherent(exc, template)
47+
48+
return
49+
50+
first_render = render_generated_template(compiled)
51+
first_bytes = first_render.to_bytes()
52+
first_chunks = list(first_render.iter_chunks())
53+
54+
assert isinstance(first_bytes, bytes)
55+
assert first_render.to_bytes() == first_bytes
56+
assert all(isinstance(chunk, bytes | memoryview) for chunk in first_chunks)
57+
assert b"".join(bytes(chunk) for chunk in first_chunks) == first_bytes
58+
assert first_render.to_string(errors="replace") == first_bytes.decode(
59+
"utf-8", "replace"
60+
)
61+
assert render_generated_template(compiled).to_bytes() == first_bytes
62+
63+
64+
@settings(max_examples=500, deadline=None)
65+
@given(
66+
value=st.one_of(
67+
st.none(),
68+
st.booleans(),
69+
st.integers(),
70+
st.floats(allow_nan=False, allow_infinity=False),
71+
st.text(),
72+
st.binary(),
73+
st.binary().map(memoryview),
74+
),
75+
unescaped=st.booleans(),
76+
)
77+
def test_variable_interpolation_matches_value_oracle(
78+
value: object,
79+
unescaped: bool,
80+
) -> None:
81+
template = b"{{{value}}}" if unescaped else b"{{value}}"
82+
compiled = fstache.compile(template, name=_GENERATED_TEMPLATE_NAME)
83+
84+
rendered = render_generated_template(compiled, {"value": value}).to_bytes()
85+
raw_value = expected_raw_interpolation_value(value)
86+
expected = raw_value if unescaped else fstache.html_escape(raw_value)
87+
88+
assert rendered == expected
89+
90+
91+
@settings(max_examples=500, deadline=None)
92+
@given(
93+
value=st.one_of(
94+
st.none(),
95+
st.booleans(),
96+
st.integers(),
97+
st.text(),
98+
st.dictionaries(st.text(min_size=1, max_size=5), st.integers(), max_size=3),
99+
st.lists(st.integers(), max_size=20),
100+
st.lists(st.integers(), max_size=20).map(tuple),
101+
)
102+
)
103+
def test_sections_and_inverted_sections_match_truthiness_oracle(
104+
value: object,
105+
) -> None:
106+
compiled = fstache.compile(
107+
b"a{{#items}}x{{/items}}b{{^items}}y{{/items}}c",
108+
name=_GENERATED_TEMPLATE_NAME,
109+
)
110+
111+
rendered = render_generated_template(compiled, {"items": value}).to_bytes()
112+
expected = expected_section_truthiness_render(value)
113+
114+
assert rendered == expected
115+
116+
117+
@settings(max_examples=500, deadline=None)
118+
@given(
119+
indent=st.lists(st.sampled_from((ord(" "), ord("\t"))), max_size=8).map(bytes),
120+
lines=st.lists(_LINE_BYTES, min_size=1, max_size=8),
121+
final_newline=st.booleans(),
122+
)
123+
def test_standalone_partial_indents_each_partial_line(
124+
indent: bytes,
125+
lines: list[bytes],
126+
final_newline: bool,
127+
) -> None:
128+
partial = b"\n".join(lines)
129+
if final_newline:
130+
partial += b"\n"
131+
132+
compiled = fstache.compile(
133+
b"Begin.\n" + indent + b"{{>partial}}\nEnd.",
134+
name=_GENERATED_TEMPLATE_NAME,
135+
)
136+
137+
rendered = render_generated_template(
138+
compiled,
139+
partials={"partial": fstache.compile(partial)},
140+
).to_bytes()
141+
expected = expected_standalone_partial_render(indent, partial)
142+
143+
assert rendered == expected
144+
145+
146+
def assert_syntax_error_is_coherent(
147+
exc: fstache.TemplateSyntaxError,
148+
template: bytes,
149+
) -> None:
150+
assert exc.reason
151+
assert exc.template_name == _GENERATED_TEMPLATE_NAME
152+
assert exc.kind is None or exc.kind in _KNOWN_SYNTAX_ERROR_KINDS
153+
154+
if exc.offset is not None:
155+
assert 0 <= exc.offset <= len(template)
156+
157+
if exc.line is not None or exc.column is not None:
158+
assert exc.line is not None
159+
assert exc.column is not None
160+
assert exc.line > 0
161+
assert exc.column > 0
162+
163+
if exc.excerpt is not None:
164+
assert len(exc.excerpt) <= _MAX_SYNTAX_EXCERPT_CHARS
165+
assert "\n" not in exc.excerpt
166+
assert "\r" not in exc.excerpt
167+
168+
169+
def expected_raw_interpolation_value(value: object) -> bytes:
170+
if value is None:
171+
return b""
172+
173+
if type(value) is bytes:
174+
return value
175+
176+
if type(value) is memoryview:
177+
return bytes(value)
178+
179+
return str(value).encode()
180+
181+
182+
def expected_section_truthiness_render(value: object) -> bytes:
183+
if not value:
184+
return b"abyc"
185+
186+
if type(value) is list or type(value) is tuple:
187+
return b"a" + (b"x" * len(value)) + b"bc"
188+
189+
return b"axbc"
190+
191+
192+
def expected_standalone_partial_render(
193+
indent: bytes,
194+
partial: bytes,
195+
) -> bytes:
196+
if not partial:
197+
return b"Begin.\nEnd."
198+
199+
chunks = split_literal_line_chunks(partial)
200+
expected_partial = b"".join(
201+
chunk if index == 0 else indent + chunk for index, chunk in enumerate(chunks)
202+
)
203+
204+
return b"Begin.\n" + indent + expected_partial + b"End."
205+
206+
207+
def split_literal_line_chunks(value: bytes) -> list[bytes]:
208+
chunks: list[bytes] = []
209+
position = 0
210+
index = 0
211+
value_len = len(value)
212+
while index < value_len:
213+
byte = value[index]
214+
if byte == _CR_BYTE and index + 1 < value_len and value[index + 1] == _LF_BYTE:
215+
line_break_end = index + 2
216+
elif byte == _CR_BYTE or byte == _LF_BYTE:
217+
line_break_end = index + 1
218+
else:
219+
index += 1
220+
continue
221+
222+
chunks.append(value[position:line_break_end])
223+
position = line_break_end
224+
index = line_break_end
225+
226+
if position < value_len:
227+
chunks.append(value[position:])
228+
229+
return chunks
230+
231+
232+
def render_generated_template(
233+
compiled: fstache.CompiledTemplate,
234+
data: object | None = None,
235+
*,
236+
partials: Mapping[str, fstache.CompiledTemplate] | None = None,
237+
) -> fstache.RenderedTemplate:
238+
def load_template(name: str) -> fstache.CompiledTemplate:
239+
if name == _GENERATED_TEMPLATE_NAME:
240+
return compiled
241+
242+
if partials is not None:
243+
partial = partials.get(name)
244+
if partial is not None:
245+
return partial
246+
247+
return fstache.resolve_missing_template_as_empty(name)
248+
249+
if data is None:
250+
data = {}
251+
252+
return fstache.render(
253+
_GENERATED_TEMPLATE_NAME,
254+
data,
255+
load_template,
256+
resolve_missing_variable=fstache.resolve_missing_variable_as_none,
257+
)

uv.lock

Lines changed: 50 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)