rendr 0.1.5__tar.gz

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,35 @@
1
+ name: Build and publish
2
+
3
+ on:
4
+ push:
5
+
6
+ jobs:
7
+ publish:
8
+ runs-on: ubuntu-latest
9
+
10
+ environment:
11
+ name: pypi
12
+ url: https://pypi.org/p/rendr
13
+
14
+ permissions:
15
+ id-token: write
16
+
17
+ steps:
18
+ - name: Check out repository
19
+ uses: actions/checkout@v6
20
+ with:
21
+ persist-credentials: false
22
+
23
+ - name: Set up Python
24
+ uses: actions/setup-python@v6
25
+ with:
26
+ python-version: "3.x"
27
+
28
+ - name: Install build
29
+ run: python -m pip install --upgrade pip build
30
+
31
+ - name: Build distributions
32
+ run: python -m build
33
+
34
+ - name: Publish to PyPI
35
+ uses: pypa/gh-action-pypi-publish@release/v1
rendr-0.1.5/PKG-INFO ADDED
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.5
2
+ Name: rendr
3
+ Version: 0.1.5
4
+ Summary: A lightweight templating language inspired by Jinja2.
5
+ Author-email: Mizuki Hikaru <mizuki@hikaru.org>
6
+ License: MIT
7
+ Classifier: License :: OSI Approved :: MIT License
8
+ Classifier: Operating System :: OS Independent
9
+ Classifier: Programming Language :: Python :: 3
10
+ Requires-Python: >=3.9
11
+ Requires-Dist: mymarkup
12
+ Description-Content-Type: text/markdown
13
+
14
+ # rendr
15
+
16
+ Please find the documentation at [hikaru.org](https://hikaru.org/projects/rendr).
rendr-0.1.5/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # rendr
2
+
3
+ Please find the documentation at [hikaru.org](https://hikaru.org/projects/rendr).
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "rendr"
7
+ version = "0.1.5"
8
+ description = "A lightweight templating language inspired by Jinja2."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = {text = "MIT"}
12
+ authors = [
13
+ { name="Mizuki Hikaru", email="mizuki@hikaru.org" },
14
+ ]
15
+ dependencies = [
16
+ "mymarkup",
17
+ ]
18
+ classifiers = [
19
+ "Programming Language :: Python :: 3",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Operating System :: OS Independent",
22
+ ]
23
+
24
+ [tool.hatch.build.targets.wheel]
25
+ packages = ["src/rendr"]
@@ -0,0 +1,324 @@
1
+ import re
2
+ import html
3
+ import mymarkup
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Optional, Union
7
+
8
+
9
+ class Undefined:
10
+ def __repr__(self):
11
+ return "Undefined"
12
+
13
+ def __bool__(self):
14
+ return False
15
+
16
+ def __str__(self):
17
+ return ""
18
+
19
+ def __iter__(self):
20
+ return iter(())
21
+
22
+ def __getattr__(self, name):
23
+ return self
24
+
25
+ def __getitem__(self, key):
26
+ return self
27
+
28
+ def __call__(self, *args, **kwargs):
29
+ return self
30
+
31
+
32
+ class DotDict(dict):
33
+ def __getattribute__(self, name):
34
+ if name.startswith("__"):
35
+ return super().__getattribute__(name)
36
+ try:
37
+ return super().__getattribute__(name)
38
+ except AttributeError:
39
+ pass
40
+ if name in self:
41
+ return self[name]
42
+ return Undefined()
43
+
44
+ def __getitem__(self, key):
45
+ try:
46
+ return super().__getitem__(key)
47
+ except KeyError:
48
+ return Undefined()
49
+
50
+ def __setattr__(self, name, value):
51
+ self[name] = value
52
+
53
+
54
+ def dot(value):
55
+ if isinstance(value, DotDict):
56
+ return value
57
+ if isinstance(value, dict):
58
+ return DotDict({key: dot(value) for key, value in value.items()})
59
+ if isinstance(value, list):
60
+ return [dot(value) for value in value]
61
+ if isinstance(value, tuple):
62
+ return tuple(dot(value) for value in value)
63
+ return value
64
+
65
+
66
+ @dataclass
67
+ class Markup:
68
+ html: str
69
+
70
+
71
+ @dataclass
72
+ class Context:
73
+ path: Optional[Path] = None
74
+ active_includes: list[Path] = field(default_factory=list)
75
+
76
+
77
+ class Token:
78
+ subclasses = []
79
+
80
+ def __init_subclass__(cls):
81
+ Token.subclasses.append(cls)
82
+
83
+ @classmethod
84
+ def parse(cls, source: str, i: int) -> tuple["Token", int]:
85
+ for subclass in Token.subclasses:
86
+ token, ni = subclass.parse(source, i)
87
+ if token is not None:
88
+ return token, ni
89
+ raise Exception(f"No token matched position {i}: {source[i:i+30]!r}")
90
+
91
+
92
+ @dataclass
93
+ class Document:
94
+ children: list[Token]
95
+
96
+ @classmethod
97
+ def parse(cls, source: str) -> "Document":
98
+ children = []
99
+ i = 0
100
+ while i < len(source):
101
+ token, i = Token.parse(source, i)
102
+ children.append(token)
103
+ return Document(children)
104
+
105
+ def render(self, variables: DotDict, context: Context):
106
+ return "".join([x.render(variables, context) for x in self.children])
107
+
108
+
109
+ @dataclass
110
+ class Expr(Token):
111
+ expression: str
112
+
113
+ @classmethod
114
+ def opening_re(cls) -> re.Pattern[str]:
115
+ return re.compile(r"\{\{\s*(.+?)\s*\}\}")
116
+
117
+ @classmethod
118
+ def parse(cls, source: str, i: int) -> tuple[Optional["Expr"], int]:
119
+ match = Expr.opening_re().match(source, i)
120
+ if not match:
121
+ return None, i
122
+ return Expr(match.group(1)), match.end()
123
+
124
+ def render(self, variables: DotDict, context: Context):
125
+ value = eval(self.expression, variables)
126
+ if isinstance(value, Markup):
127
+ return value.html
128
+ return html.escape(str(value))
129
+
130
+
131
+ @dataclass
132
+ class Include(Token):
133
+ path: str
134
+
135
+ @classmethod
136
+ def opening_re(cls) -> re.Pattern[str]:
137
+ return re.compile(r"\{%\s*include\s+['\"]([^'\"]+)['\"]\s*%\}")
138
+
139
+ @classmethod
140
+ def parse(cls, source: str, i: int) -> tuple[Optional["Include"], int]:
141
+ match = Include.opening_re().match(source, i)
142
+ if not match:
143
+ return None, i
144
+ return Include(match.group(1)), match.end()
145
+
146
+ def render(self, variables, context):
147
+ if context.path is None:
148
+ raise Exception("{% include ... %} called without file path")
149
+ path = (context.path.parent / self.path).resolve()
150
+ if path in context.active_includes:
151
+ chain = " -> ".join(str(p) for p in context.active_includes) + " -> " + str(path)
152
+ raise Exception(f"Circular include detected: {chain}")
153
+ context.active_includes.append(path)
154
+ try:
155
+ source = path.read_text(encoding="utf-8")
156
+ return render(source, Context(path, context.active_includes), **variables)
157
+ finally:
158
+ context.active_includes.pop()
159
+
160
+
161
+ class BlockTag:
162
+ @classmethod
163
+ def opening_re(cls) -> re.Pattern[str]:
164
+ return re.compile(r"\{%\s*(for|if)\s+(.+?)\s*%\}")
165
+
166
+ @classmethod
167
+ def closing_re(cls) -> re.Pattern[str]:
168
+ return re.compile(r"\{%\s*end(for|if)\s*%\}")
169
+
170
+ @classmethod
171
+ def else_re(cls) -> re.Pattern[str]:
172
+ return re.compile(r"\{%\s*else\s*%\}")
173
+
174
+ @classmethod
175
+ def extract_documents(cls, tag: str, source: str, i: int) -> tuple[Document, Document, int]:
176
+ document_strs = [""]
177
+ stack = []
178
+ while i < len(source):
179
+ opening_match = BlockTag.opening_re().match(source, i)
180
+ closing_match = BlockTag.closing_re().match(source, i)
181
+ else_match = BlockTag.else_re().match(source, i)
182
+ if opening_match:
183
+ stack.append(opening_match.group(1))
184
+ i = opening_match.end()
185
+ document_strs[-1] += opening_match.group(0)
186
+ elif closing_match:
187
+ closing_tag = closing_match.group(1)
188
+ if not stack:
189
+ if closing_tag != tag:
190
+ raise Exception(f"Closing tag mismatch: end{closing_tag}, expected end{tag}")
191
+ if len(document_strs) < 2:
192
+ document_strs.append("")
193
+ documents = (
194
+ Document.parse(document_strs[0]),
195
+ Document.parse(document_strs[1]),
196
+ )
197
+ return (*documents, closing_match.end())
198
+ if closing_tag != stack[-1]:
199
+ raise Exception(f"Closing tag mismatch: end{closing_tag}, expected end{stack[-1]}")
200
+ stack.pop()
201
+ i = closing_match.end()
202
+ document_strs[-1] += closing_match.group(0)
203
+ elif else_match:
204
+ if not stack:
205
+ if tag != "if":
206
+ raise Exception("For loop encountered else tag")
207
+ if len(document_strs) > 1:
208
+ raise Exception("Multiple else encountered")
209
+ document_strs.append("")
210
+ else:
211
+ if stack[-1] != "if":
212
+ raise Exception("For loop encountered else tag")
213
+ document_strs[-1] += else_match.group(0)
214
+ i = else_match.end()
215
+ else:
216
+ document_strs[-1] += source[i]
217
+ i += 1
218
+ raise Exception(f"Unclosed {{% {tag} %}}, missing {{% end{tag} %}}")
219
+
220
+
221
+ @dataclass
222
+ class For(Token):
223
+ names: list[str]
224
+ expression: str
225
+ document: Document
226
+
227
+ @classmethod
228
+ def opening_re(cls) -> re.Pattern[str]:
229
+ return re.compile(r"\{%\s*for\s+([A-Za-z_][A-Za-z0-9_]*(?:\s*,\s*[A-Za-z_][A-Za-z0-9_]*)*)\s+in\s+(.+?)\s*%\}")
230
+
231
+ @classmethod
232
+ def closing_re(cls) -> re.Pattern[str]:
233
+ return re.compile(r"\{%\s*endfor\s*%\}")
234
+
235
+ @classmethod
236
+ def parse(cls, source: str, i: int) -> tuple[Optional["For"], int]:
237
+ match = For.opening_re().match(source, i)
238
+ if not match:
239
+ return None, i
240
+ names = [x.strip() for x in match.group(1).split(",")]
241
+ expression = match.group(2)
242
+ body_start = match.end()
243
+ document, _, end = BlockTag.extract_documents("for", source, body_start)
244
+ return For(names, expression, document), end
245
+
246
+ def render(self, variables: DotDict, context: Context):
247
+ values = eval(self.expression, variables)
248
+ rendered = []
249
+ for value in values:
250
+ loop_variables = DotDict(variables)
251
+ if len(self.names) == 1:
252
+ loop_variables[self.names[0]] = dot(value)
253
+ else:
254
+ values = list(value)
255
+ if len(values) != len(self.names):
256
+ raise Exception(f"Could not unpack loop value into {len(self.names)} variables")
257
+ for name, value in zip(self.names, values):
258
+ loop_variables[name] = dot(value)
259
+ rendered.append(self.document.render(loop_variables, context))
260
+ return "".join(rendered)
261
+
262
+
263
+ @dataclass
264
+ class If(Token):
265
+ clause: str
266
+ then_document: Document
267
+ else_document: Document
268
+
269
+ @classmethod
270
+ def opening_re(cls) -> re.Pattern[str]:
271
+ return re.compile(r"\{%\s*if\s+(.+?)\s*%\}")
272
+
273
+ @classmethod
274
+ def closing_re(cls) -> re.Pattern[str]:
275
+ return re.compile(r"\{%\s*endif\s*%\}")
276
+
277
+ @classmethod
278
+ def parse(cls, source: str, i: int) -> tuple[Optional["If"], int]:
279
+ match = If.opening_re().match(source, i)
280
+ if not match:
281
+ return None, i
282
+ clause = match.group(1)
283
+ then_start = match.end()
284
+ then_document, else_document, end = BlockTag.extract_documents("if", source, then_start)
285
+ return If(clause, then_document, else_document), end
286
+
287
+ def render(self, variables: DotDict, context: Context):
288
+ if eval(self.clause, variables):
289
+ return self.then_document.render(variables, context)
290
+ return self.else_document.render(variables, context)
291
+
292
+
293
+ @dataclass
294
+ class Text(Token):
295
+ value: str
296
+
297
+ @classmethod
298
+ def parse(cls, source: str, i: int) -> tuple[Optional["Text"], int]:
299
+ j = i
300
+ while j < len(source):
301
+ if source[j:j+2] in ("{%", "{{"):
302
+ break
303
+ j += 1
304
+ if j == i:
305
+ return None, i
306
+ return Text(source[i:j]), j
307
+
308
+ def render(self, variables: DotDict, context: Context):
309
+ return self.value
310
+
311
+
312
+ def parse(source: str) -> Document:
313
+ return Document.parse(source)
314
+
315
+
316
+ def render(source: str, context: Context = Context(), **kwargs) -> str:
317
+ variables = dot(kwargs)
318
+ variables["mymarkup"] = lambda value: Markup(mymarkup.render(value))
319
+ return parse(source).render(variables, context)
320
+
321
+
322
+ def render_file(path: Union[Path, str], **kwargs) -> str:
323
+ path = Path(path)
324
+ return render(path.read_text(encoding="utf-8"), Context(path), **kwargs)
@@ -0,0 +1,307 @@
1
+ from . import parse, render, Document, Text, Expr, Include, For, If
2
+
3
+
4
+ def _single(doc: Document):
5
+ assert len(doc.children) == 1
6
+ return doc.children[0]
7
+
8
+
9
+ def test_plain_text():
10
+ doc = parse("hello world")
11
+ assert isinstance(doc, Document)
12
+ assert len(doc.children) == 1
13
+ assert isinstance(doc.children[0], Text)
14
+ assert doc.children[0].value == "hello world"
15
+
16
+
17
+ def test_text_with_whitespace():
18
+ doc = parse(" line one\n line two ")
19
+ node = _single(doc)
20
+ assert isinstance(node, Text)
21
+ assert node.value == " line one\n line two "
22
+
23
+
24
+ def test_empty():
25
+ doc = parse("")
26
+ assert isinstance(doc, Document)
27
+ assert doc.children == []
28
+
29
+
30
+ def test_simple_expression():
31
+ doc = parse("{{ name }}")
32
+ node = _single(doc)
33
+ assert isinstance(node, Expr)
34
+ assert node.expression == "name"
35
+
36
+
37
+ def test_expression_no_spaces():
38
+ doc = parse("{{name}}")
39
+ node = _single(doc)
40
+ assert isinstance(node, Expr)
41
+ assert node.expression == "name"
42
+
43
+
44
+ def test_expression_dotted():
45
+ doc = parse("{{ user.name }}")
46
+ node = _single(doc)
47
+ assert isinstance(node, Expr)
48
+ assert node.expression == "user.name"
49
+
50
+
51
+ def test_expression_surrounded_by_text():
52
+ doc = parse("Hello {{ name }}!")
53
+ assert len(doc.children) == 3
54
+ assert isinstance(doc.children[0], Text) and doc.children[0].value == "Hello "
55
+ assert isinstance(doc.children[1], Expr) and doc.children[1].expression == "name"
56
+ assert isinstance(doc.children[2], Text) and doc.children[2].value == "!"
57
+
58
+
59
+ def test_multiple_expressions():
60
+ doc = parse("{{ a }} and {{ b }}")
61
+ assert len(doc.children) == 3
62
+ assert isinstance(doc.children[0], Expr) and doc.children[0].expression == "a"
63
+ assert isinstance(doc.children[1], Text) and doc.children[1].value == " and "
64
+ assert isinstance(doc.children[2], Expr) and doc.children[2].expression == "b"
65
+
66
+
67
+ def test_include():
68
+ doc = parse("{% include 'header.html' %}")
69
+ node = _single(doc)
70
+ assert isinstance(node, Include)
71
+ assert node.path == "header.html"
72
+
73
+
74
+ def test_include_double_quotes():
75
+ doc = parse('{% include "styles.css" %}')
76
+ node = _single(doc)
77
+ assert isinstance(node, Include)
78
+ assert node.path == "styles.css"
79
+
80
+
81
+ def test_include_between_text():
82
+ doc = parse("before{% include 'x.html' %}after")
83
+ assert len(doc.children) == 3
84
+ assert isinstance(doc.children[0], Text) and doc.children[0].value == "before"
85
+ assert isinstance(doc.children[1], Include) and doc.children[1].path == "x.html"
86
+ assert isinstance(doc.children[2], Text) and doc.children[2].value == "after"
87
+
88
+
89
+ def test_for_simple():
90
+ doc = parse("{% for x in items %}{{ x }}{% endfor %}")
91
+ node = _single(doc)
92
+ assert isinstance(node, For)
93
+ assert node.names == ["x"]
94
+ assert node.expression == "items"
95
+ assert isinstance(node.document, Document)
96
+ body_child = _single(node.document)
97
+ assert isinstance(body_child, Expr) and body_child.expression == "x"
98
+
99
+
100
+ def test_for_with_text_around():
101
+ doc = parse("start {% for n in ns %}{{ n }}{% endfor %} end")
102
+ assert len(doc.children) == 3
103
+ assert isinstance(doc.children[0], Text) and doc.children[0].value == "start "
104
+ assert isinstance(doc.children[1], For)
105
+ assert isinstance(doc.children[2], Text) and doc.children[2].value == " end"
106
+ loop = doc.children[1]
107
+ assert loop.names == ["n"]
108
+ assert loop.expression == "ns"
109
+ inner = _single(loop.document)
110
+ assert isinstance(inner, Expr) and inner.expression == "n"
111
+
112
+
113
+ def test_for_tuple_unpack():
114
+ doc = parse("{% for k, v in d.items() %}{{ k }}:{{ v }}{% endfor %}")
115
+ loop = _single(doc)
116
+ assert isinstance(loop, For)
117
+ assert loop.names == ["k", "v"]
118
+ assert loop.expression == "d.items()"
119
+ assert len(loop.document.children) == 3
120
+ assert isinstance(loop.document.children[0], Expr) and loop.document.children[0].expression == "k"
121
+ assert isinstance(loop.document.children[1], Text) and loop.document.children[1].value == ":"
122
+ assert isinstance(loop.document.children[2], Expr) and loop.document.children[2].expression == "v"
123
+
124
+
125
+ def test_for_nested():
126
+ doc = parse(
127
+ "{% for row in rows %}{% for col in row %}{{ col }}{% endfor %}{% endfor %}"
128
+ )
129
+ outer = _single(doc)
130
+ assert isinstance(outer, For) and outer.names == ["row"] and outer.expression == "rows"
131
+ inner = _single(outer.document)
132
+ assert isinstance(inner, For) and inner.names == ["col"] and inner.expression == "row"
133
+ cell = _single(inner.document)
134
+ assert isinstance(cell, Expr) and cell.expression == "col"
135
+
136
+
137
+ def test_for_inside_if():
138
+ doc = parse(
139
+ "{% if ok %}{% for x in xs %}{{ x }}{% endfor %}{% endif %}"
140
+ )
141
+ cond = _single(doc)
142
+ assert isinstance(cond, If) and cond.clause == "ok"
143
+ loop = _single(cond.then_document)
144
+ assert isinstance(loop, For) and loop.names == ["x"] and loop.expression == "xs"
145
+ inner = _single(loop.document)
146
+ assert isinstance(inner, Expr) and inner.expression == "x"
147
+ assert isinstance(cond.else_document, Document)
148
+ assert cond.else_document.children == []
149
+
150
+
151
+ def test_if_true_branch_only():
152
+ doc = parse("{% if visible %}shown{% endif %}")
153
+ cond = _single(doc)
154
+ assert isinstance(cond, If)
155
+ assert cond.clause == "visible"
156
+ then_text = _single(cond.then_document)
157
+ assert isinstance(then_text, Text) and then_text.value == "shown"
158
+ assert isinstance(cond.else_document, Document)
159
+ assert cond.else_document.children == []
160
+
161
+
162
+ def test_if_with_else():
163
+ doc = parse("{% if ok %}yes{% else %}no{% endif %}")
164
+ cond = _single(doc)
165
+ assert isinstance(cond, If) and cond.clause == "ok"
166
+ assert _single(cond.then_document).value == "yes"
167
+ assert _single(cond.else_document).value == "no"
168
+
169
+
170
+ def test_if_with_complex_else():
171
+ doc = parse(
172
+ "{% if user %}Hello {{ user.name }}{% else %}Please {% include 'login.html' %}{% endif %}"
173
+ )
174
+ cond = _single(doc)
175
+ assert isinstance(cond, If) and cond.clause == "user"
176
+ assert len(cond.then_document.children) == 2
177
+ assert isinstance(cond.then_document.children[0], Text) and cond.then_document.children[0].value == "Hello "
178
+ assert isinstance(cond.then_document.children[1], Expr) and cond.then_document.children[1].expression == "user.name"
179
+ assert len(cond.else_document.children) == 2
180
+ assert isinstance(cond.else_document.children[0], Text) and cond.else_document.children[0].value == "Please "
181
+ assert isinstance(cond.else_document.children[1], Include) and cond.else_document.children[1].path == "login.html"
182
+
183
+
184
+ def test_nested_if_else_inside_for():
185
+ doc = parse(
186
+ "{% for item in items %}{% if item.done %}done{% else %}todo{% endif %}{% endfor %}"
187
+ )
188
+ loop = _single(doc)
189
+ assert isinstance(loop, For) and loop.names == ["item"] and loop.expression == "items"
190
+ cond = _single(loop.document)
191
+ assert isinstance(cond, If) and cond.clause == "item.done"
192
+ assert _single(cond.then_document).value == "done"
193
+ assert _single(cond.else_document).value == "todo"
194
+
195
+
196
+ def test_if_else_ignores_else_inside_nested_for():
197
+ doc = parse(
198
+ "{% if flag %}{% for x in xs %}{% if x %}{% else %}{% endif %}{% endfor %}{% else %}outer{% endif %}"
199
+ )
200
+ cond = _single(doc)
201
+ assert isinstance(cond, If) and cond.clause == "flag"
202
+ assert _single(cond.else_document).value == "outer"
203
+ inner_loop = _single(cond.then_document)
204
+ assert isinstance(inner_loop, For) and inner_loop.names == ["x"] and inner_loop.expression == "xs"
205
+
206
+
207
+ def test_render():
208
+ template = """
209
+ {{ user.name }}
210
+
211
+ {% if user.active %}
212
+ Hello {{ user.name }}
213
+ {% endif %}
214
+
215
+ {% for user in users %}
216
+ {{ user.name }}
217
+ {% endfor %}
218
+
219
+ {% for key, value in values.items() %}
220
+ {{ key }} = {{ value }}
221
+ {% endfor %}
222
+ """
223
+
224
+ variables = {
225
+ "user": {"name": "Alice", "active": True},
226
+ "users": [
227
+ {"name": "Bob"},
228
+ {"name": "Charlie"},
229
+ {"name": "Diana"},
230
+ ],
231
+ "values": {"a": 1, "b": 2, "c": 3},
232
+ }
233
+
234
+ expected = """
235
+ Alice
236
+
237
+
238
+ Hello Alice
239
+
240
+
241
+
242
+ Bob
243
+
244
+ Charlie
245
+
246
+ Diana
247
+
248
+
249
+
250
+ a = 1
251
+
252
+ b = 2
253
+
254
+ c = 3
255
+
256
+ """
257
+
258
+ assert render(template, **variables) == expected
259
+
260
+
261
+ def test_render_if_else_and_inactive():
262
+ assert render("{% if ok %}yes{% else %}no{% endif %}", ok=True) == "yes"
263
+ assert render("{% if ok %}yes{% else %}no{% endif %}", ok=False) == "no"
264
+
265
+ template_inactive = """
266
+ {{ user.name }}
267
+ {% if user.active %}Should NOT appear{% endif %}
268
+ """
269
+
270
+ expected_inactive = """
271
+ Eve
272
+
273
+ """
274
+
275
+ assert render(template_inactive, user={"name": "Eve", "active": False}) == expected_inactive
276
+
277
+
278
+ def main():
279
+ test_plain_text()
280
+ test_text_with_whitespace()
281
+ test_empty()
282
+ test_simple_expression()
283
+ test_expression_no_spaces()
284
+ test_expression_dotted()
285
+ test_expression_surrounded_by_text()
286
+ test_multiple_expressions()
287
+ test_include()
288
+ test_include_double_quotes()
289
+ test_include_between_text()
290
+ test_for_simple()
291
+ test_for_with_text_around()
292
+ test_for_tuple_unpack()
293
+ test_for_nested()
294
+ test_for_inside_if()
295
+ test_if_true_branch_only()
296
+ test_if_with_else()
297
+ test_if_with_complex_else()
298
+ test_nested_if_else_inside_for()
299
+ test_if_else_ignores_else_inside_nested_for()
300
+ test_render()
301
+ test_render_if_else_and_inactive()
302
+
303
+ print("All tests passed")
304
+
305
+
306
+ if __name__ == "__main__":
307
+ main()