-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtemplate_engine.py
More file actions
230 lines (176 loc) · 8.69 KB
/
Copy pathtemplate_engine.py
File metadata and controls
230 lines (176 loc) · 8.69 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
#!/usr/bin/env python3
import re
from typing import Any, Dict, List, Union, Optional
class Context:
"""A context object that holds variables for template rendering."""
def __init__(self, variables: Optional[Dict[str, Any]] = None):
"""Initialize the context with optional variables.
Args:
variables: A dictionary of variable names and values.
"""
self.variables = variables or {}
def get(self, key: str, default: Any = None) -> Any:
"""Get a variable value by key with optional default.
Args:
key: The variable name to look up.
default: The default value if key is not found.
Returns:
The variable value or default.
"""
return self.variables.get(key, default)
def set(self, key: str, value: Any) -> None:
"""Set a variable in the context.
Args:
key: The variable name.
value: The variable value.
"""
self.variables[key] = value
class TemplateEngine:
"""A simple template engine supporting variable substitution and loops."""
def __init__(self):
"""Initialize the template engine."""
# Pattern for variable substitution: {{ variable }}
self.var_pattern = re.compile(r'\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)\s*\}\}')
# Pattern for variable with default: {{ variable | default }}
self.default_pattern = re.compile(r'\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)\s*\|\s*([^}]+)\s*\}\}')
# Pattern for loops: {% for item in list %}...{% endfor %}
self.loop_pattern = re.compile(r'\{%\s*for\s+([a-zA-Z_][a-zA-Z0-9_]*)\s+in\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*%\}(.*?){%\s*endfor\s*%\}', re.DOTALL)
def _get_nested_value(self, context: Context, key: str) -> Any:
"""Get a nested value from context using dot notation.
Args:
context: The context to look up values in.
key: The key with possible dot notation (e.g., 'user.name').
Returns:
The resolved value or None if not found.
"""
parts = key.split('.')
value = context.get(parts[0])
if value is None:
return None
for part in parts[1:]:
if isinstance(value, dict) and part in value:
value = value[part]
else:
return None
return value
def _render_variables(self, template: str, context: Context) -> str:
"""Render variable substitutions in template.
Args:
template: The template string.
context: The context with variables.
Returns:
The template with variables substituted.
"""
# Handle defaults first
def default_replacer(match):
key = match.group(1)
default_val = match.group(2).strip()
value = self._get_nested_value(context, key)
if value is None:
return default_val
return str(value)
result = self.default_pattern.sub(default_replacer, template)
# Handle regular variables
def var_replacer(match):
key = match.group(1)
value = self._get_nested_value(context, key)
if value is None:
return ''
return str(value)
result = self.var_pattern.sub(var_replacer, result)
return result
def _render_loops(self, template: str, context: Context) -> str:
"""Render loop constructs in template.
Args:
template: The template string.
context: The context with variables.
Returns:
The template with loops rendered.
"""
def loop_replacer(match):
loop_var = match.group(1)
list_var = match.group(2)
inner_template = match.group(3)
list_value = self._get_nested_value(context, list_var)
if not isinstance(list_value, (list, tuple)):
return ''
result = ''
for item in list_value:
# Create a new context for each iteration
loop_context = Context(context.variables.copy())
loop_context.set(loop_var, item)
# Render the inner template with the loop context
rendered = self._render_variables(inner_template, loop_context)
result += rendered
return result
# Keep processing loops until none are left (handle nested loops)
while self.loop_pattern.search(template):
template = self.loop_pattern.sub(loop_replacer, template)
return template
def render(self, template: str, context: Context) -> str:
"""Render a template with the given context.
Args:
template: The template string to render.
context: The context with variables.
Returns:
The rendered template string.
Raises:
TypeError: If template is not a string or context is not a Context.
"""
if not isinstance(template, str):
raise TypeError("Template must be a string")
if not isinstance(context, Context):
raise TypeError("Context must be a Context instance")
# First process loops, then variables
result = self._render_loops(template, context)
result = self._render_variables(result, context)
return result
def main():
"""Self-test: every rendering feature asserted against its exact output —
substitution, defaults, nested paths, loops, loop-scoped contexts."""
engine = TemplateEngine()
# Basic substitution is exact (round-trip a number through the template).
assert engine.render("Hello {{ name }}!", Context({"name": "World"})) == "Hello World!"
assert int(engine.render("{{ n }}", Context({"n": 42}))) == 42
# Defaults fire only when the variable is missing.
assert engine.render("Hello {{ name | Guest }}!", Context({})) == "Hello Guest!"
assert engine.render("Hello {{ name | Guest }}!", Context({"name": "Zed"})) == "Hello Zed!", \
"default overrode a present variable"
# Nested dotted paths resolve.
out = engine.render("User: {{ user.name }}, Age: {{ user.age }}",
Context({"user": {"name": "Alice", "age": 30}}))
assert out == "User: Alice, Age: 30", f"nested paths wrong: {out!r}"
# Loops render each element in order.
out = engine.render("Items: {% for item in items %}{{ item }}, {% endfor %}",
Context({"items": ["apple", "banana", "cherry"]}))
assert out == "Items: apple, banana, cherry, ", f"loop output wrong: {out!r}"
# Loop over dicts: the loop variable scopes nested access per iteration.
out = engine.render("{% for p in people %}{{ p.name }}={{ p.age }};{% endfor %}",
Context({"people": [{"name": "Alice", "age": 30},
{"name": "Bob", "age": 25}]}))
assert out == "Alice=30;Bob=25;", f"loop-scoped nesting wrong: {out!r}"
# Empty list → loop body vanishes; non-list loop target renders nothing.
assert engine.render("A{% for x in xs %}X{% endfor %}B", Context({"xs": []})) == "AB"
assert engine.render("A{% for x in xs %}X{% endfor %}B", Context({"xs": 42})) == "AB", \
"non-iterable loop target must render empty"
# Defaults inside loop bodies (missing price falls back to 0).
out = engine.render("{% for i in items %}{{ i.name }}:{{ i.price | 0 }} {% endfor %}",
Context({"items": [{"name": "Staff", "price": 100},
{"name": "Ring"}]}))
assert out == "Staff:100 Ring:0 ", f"loop defaults wrong: {out!r}"
# The loop variable does not leak into the outer scope.
ctx = Context({"items": ["x"]})
engine.render("{% for item in items %}{{ item }}{% endfor %}", ctx)
assert ctx.get("item") is None if hasattr(ctx, "get") else True
# Type refusals.
for bad_call in (lambda: engine.render(42, Context({})),
lambda: engine.render("x", {"not": "a context"})):
try:
bad_call()
assert False, "invalid render arguments accepted"
except TypeError:
pass
print("template_engine: substitution/defaults/nesting/loops all exact "
"(Alice=30;Bob=25;), empty+non-list loops safe — PASS")
if __name__ == "__main__":
main()