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.
- rendr-0.1.5/.github/workflows/publish.yml +35 -0
- rendr-0.1.5/PKG-INFO +16 -0
- rendr-0.1.5/README.md +3 -0
- rendr-0.1.5/pyproject.toml +25 -0
- rendr-0.1.5/src/rendr/__init__.py +324 -0
- rendr-0.1.5/src/rendr/tests.py +307 -0
|
@@ -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,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()
|