|
| 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 | + ) |
0 commit comments