Skip to content

Commit 502a191

Browse files
committed
Some perf improvements
1 parent 391bbd9 commit 502a191

File tree

6 files changed

+53
-57
lines changed

6 files changed

+53
-57
lines changed

.vscode/settings.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"cSpell.words": [
2424
"datadir"
2525
],
26-
"python-envs.defaultEnvManager": "ms-python.python:conda",
27-
"python-envs.defaultPackageManager": "ms-python.python:conda",
26+
"python-envs.defaultEnvManager": "ms-python.python:venv",
27+
"python-envs.defaultPackageManager": "ms-python.python:pip",
2828
"python-envs.pythonProjects": []
2929
}

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ dev = [
4747
"pytest-datadir>=1.8.0",
4848
"ruff>=0.14.9",
4949
"types-chevron>=0.14.2.20250103",
50-
"ustache>=0.1.6",
50+
"mstache>=0.3.0",
5151
]
5252

5353
[tool.pytest.ini_options]
@@ -70,5 +70,5 @@ precision = 2
7070
python_version = "3.10"
7171

7272
[[tool.mypy.overrides]]
73-
module = ["yaml", "combustache.*", "pystache.*", "ustache.*"]
73+
module = ["yaml", "combustache.*", "pystache.*", "mstache.*"]
7474
ignore_missing_imports = true

src/mystace/mustache_tree.py

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,16 @@
2828
TokenType.DELIMITER,
2929
)
3030

31+
# Pre-compute common space strings for indentation (avoids repeated multiplication)
32+
_SPACE_CACHE = [""] + [" " * i for i in range(1, 33)] # Cache up to 32 spaces
33+
34+
35+
def _get_spaces(n: int) -> str:
36+
"""Get n spaces, using cache when possible."""
37+
if n < len(_SPACE_CACHE):
38+
return _SPACE_CACHE[n]
39+
return " " * n
40+
3141

3242
class ContextNode:
3343
__slots__ = ("context", "parent_context_node")
@@ -47,11 +57,12 @@ def get(self, key: str) -> t.Any:
4757
if key == ".":
4858
return self.context
4959

50-
chain_iter = iter(key.split("."))
60+
# Split only once and cache
61+
keys = key.split(".")
5162
curr_ctx = self.context
5263
parent_node = self.parent_context_node
5364

54-
first_key = next(chain_iter)
65+
first_key = keys[0]
5566

5667
# TODO I think this is where changes need to be made if we want to
5768
# support indexing and lambdas.
@@ -72,7 +83,7 @@ def get(self, key: str) -> t.Any:
7283
return None
7384

7485
# Loop through the rest
75-
for key in chain_iter:
86+
for key in keys[1:]:
7687
if isinstance(outer_context, list):
7788
try:
7889
int_key = int(key)
@@ -97,17 +108,11 @@ def open_section(self, key: str) -> t.List[ContextNode]:
97108

98109
# In the case of the list, need a new context for each item
99110
if isinstance(new_context, list):
100-
res_list = []
101-
102-
for item in new_context:
103-
new_stack = ContextNode(item, self)
104-
# new_stack.parent_context_node = self
105-
res_list.append(new_stack)
106-
111+
# Pre-allocate list for better performance
112+
res_list = [ContextNode(item, self) for item in new_context]
107113
return res_list
108114

109-
new_stack = ContextNode(new_context, self)
110-
return [new_stack]
115+
return [ContextNode(new_context, self)]
111116

112117

113118
class TagType(enum.Enum):
@@ -186,6 +191,7 @@ def __repr__(self) -> str:
186191
class MustacheRenderer:
187192
mustache_tree: MustacheTreeNode
188193
partials_dict: t.Dict[str, MustacheTreeNode]
194+
__slots__ = ("mustache_tree", "partials_dict")
189195

190196
def __init__(
191197
self,
@@ -210,6 +216,7 @@ def render(
210216
html_escape_fn: t.Callable[[str], str] = html_escape,
211217
) -> str:
212218
res_list = []
219+
res_list_append = res_list.append # Cache method lookup
213220
starting_context = ContextNode(data)
214221
last_was_newline = True # Track if we're at the start of a line
215222

@@ -220,18 +227,20 @@ def render(
220227
(node, starting_context, self.mustache_tree.offset)
221228
for node in self.mustache_tree.children
222229
)
230+
work_deque_popleft = work_deque.popleft # Cache method lookup
231+
work_deque_appendleft = work_deque.appendleft # Cache method lookup
223232
while work_deque:
224-
curr_node, curr_context, curr_offset = work_deque.popleft()
233+
curr_node, curr_context, curr_offset = work_deque_popleft()
225234
if curr_node.tag_type is TagType.LITERAL:
226-
res_list.append(curr_node.data)
235+
res_list_append(curr_node.data)
227236
last_was_newline = curr_node.data.endswith("\n")
228237

229238
# Add offset for partials after newline, but only if there's
230239
# more content coming at the same offset level
231240
if last_was_newline and curr_offset > 0:
232241
# Check if the next node also has the same offset
233242
if work_deque and work_deque[0][2] == curr_offset:
234-
res_list.append(curr_offset * " ")
243+
res_list_append(_get_spaces(curr_offset))
235244

236245
elif (
237246
curr_node.tag_type is TagType.VARIABLE
@@ -246,7 +255,7 @@ def render(
246255
if curr_node.tag_type is TagType.VARIABLE:
247256
str_content = html_escape_fn(str_content)
248257

249-
res_list.append(str_content)
258+
res_list_append(str_content)
250259
elif curr_node.tag_type is TagType.SECTION:
251260
new_context_stacks = curr_context.open_section(curr_node.data)
252261

@@ -255,7 +264,7 @@ def render(
255264
for new_context_stack in reversed(new_context_stacks):
256265
for child_node in reversed(curr_node.children):
257266
# No need to make a copy of the context per-child, it's immutable
258-
work_deque.appendleft((child_node, new_context_stack, 0))
267+
work_deque_appendleft((child_node, new_context_stack, 0))
259268

260269
elif curr_node.tag_type is TagType.INVERTED_SECTION:
261270
# No need to add to the context stack, inverted sections
@@ -266,7 +275,7 @@ def render(
266275

267276
if not bool(lookup_data):
268277
for child_node in reversed(curr_node.children):
269-
work_deque.appendleft((child_node, curr_context, 0))
278+
work_deque_appendleft((child_node, curr_context, 0))
270279

271280
elif curr_node.tag_type is TagType.PARTIAL:
272281
partial_tree = self.partials_dict.get(curr_node.data)
@@ -279,12 +288,12 @@ def render(
279288
# For standalone partials, add indentation at the beginning
280289
# Only add if we're at the start of a line (last output was newline)
281290
if curr_node.offset > 0 and last_was_newline:
282-
res_list.append(curr_node.offset * " ")
291+
res_list_append(_get_spaces(curr_node.offset))
283292
last_was_newline = False
284293

285294
# Propagate the combined offset through the partial content
286295
for child_node in reversed(partial_tree.children):
287-
work_deque.appendleft(
296+
work_deque_appendleft(
288297
(child_node, curr_context, curr_offset + curr_node.offset)
289298
)
290299

src/mystace/util.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,19 @@
22

33
LAMBDA = "<lambda>"
44

5+
# Pre-build translation table for HTML escaping (much faster than multiple replace calls)
6+
_HTML_ESCAPE_TABLE = str.maketrans(
7+
{
8+
"&": "&amp;",
9+
"<": "&lt;",
10+
">": "&gt;",
11+
'"': "&quot;",
12+
}
13+
)
14+
515

616
def html_escape(s: str) -> str:
7-
s = s.replace("&", "&amp;") # Must be done first!
8-
s = s.replace("<", "&lt;")
9-
s = s.replace(">", "&gt;")
10-
s = s.replace('"', "&quot;")
11-
return s
17+
return s.translate(_HTML_ESCAPE_TABLE)
1218

1319

1420
def is_whitespace(string: str) -> bool:

tests/test_speed.py

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,7 @@
2424
"combustache",
2525
"moosetash",
2626
"pystache",
27-
"ustache",
28-
"ustache-full",
27+
"mstache",
2928
"mystace-full",
3029
]
3130
TestCaseT = t.Tuple[str, t.Dict[str, int]]
@@ -331,15 +330,10 @@ def render_function(_, obj):
331330
import pystache
332331

333332
render_function = pystache.render
334-
elif render_function_name == "ustache-full":
335-
import ustache
333+
elif render_function_name == "mstache":
334+
import mstache
336335

337-
def render_function(x, y):
338-
return ustache.render(x, y, cache={})
339-
elif render_function_name == "ustache":
340-
import ustache
341-
342-
render_function = ustache.render
336+
render_function = mstache.render
343337
else:
344338
assert_never(render_function_name)
345339

uv.lock

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

0 commit comments

Comments
 (0)