Skip to content

Commit 5b689b3

Browse files
authored
Improve speed (#11)
1 parent 391bbd9 commit 5b689b3

File tree

9 files changed

+140
-105
lines changed

9 files changed

+140
-105
lines changed

.github/workflows/lint-python.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,15 @@ name: lint
55

66
on:
77
push:
8-
branches: ["*"]
8+
branches: ["main"]
99
pull_request:
1010
branches: ["*"]
1111

12+
# Cancel duplicate jobs when pushing to a PR branch
13+
concurrency:
14+
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
15+
cancel-in-progress: true
16+
1217
jobs:
1318
ci:
1419
strategy:

.github/workflows/tests.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@ name: tests
22

33
on:
44
push:
5-
branches: ["*"]
5+
branches: ["main"]
66
pull_request:
77
branches: ["*"]
88

9+
# Cancel duplicate jobs when pushing to a PR branch
10+
concurrency:
11+
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
12+
cancel-in-progress: true
13+
914
jobs:
1015
ci:
1116
strategy:

.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: 57 additions & 43 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,6 +57,7 @@ def get(self, key: str) -> t.Any:
4757
if key == ".":
4858
return self.context
4959

60+
# Use iterator to avoid copying keys
5061
chain_iter = iter(key.split("."))
5162
curr_ctx = self.context
5263
parent_node = self.parent_context_node
@@ -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,
@@ -209,7 +215,8 @@ def render(
209215
stringify: t.Callable[[t.Any], str] = str,
210216
html_escape_fn: t.Callable[[str], str] = html_escape,
211217
) -> str:
212-
res_list = []
218+
res_list: t.List[str] = []
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

@@ -337,6 +346,8 @@ def process_raw_token_list(
337346
) -> t.List[TokenTuple]:
338347
indices_to_delete: t.Set[int] = set()
339348
res_token_list: t.List[TokenTuple] = []
349+
res_token_list_append = res_token_list.append # Cache method lookup
350+
340351
for i, token in enumerate(raw_token_list):
341352
token_type, token_data, token_offset = token
342353

@@ -402,67 +413,70 @@ def process_raw_token_list(
402413
if remove_double_prev:
403414
indices_to_delete.add(i - 2)
404415

405-
res_token_list.append(token)
416+
res_token_list_append(token)
406417

407418
handle_final_line_clear(res_token_list, _STANDALONE_TOKENS)
408419
# Don't require a trailing newline to remove leading whitespace.
409420

410-
res_token_list = [
411-
elem for i, elem in enumerate(res_token_list) if i not in indices_to_delete
412-
]
421+
# Only filter if we have deletions to avoid unnecessary list creation
422+
if indices_to_delete:
423+
res_token_list = [
424+
elem for i, elem in enumerate(res_token_list) if i not in indices_to_delete
425+
]
413426

414427
return res_token_list
415428

416429

417430
def create_mustache_tree(thing: str) -> MustacheTreeNode:
418431
root = MustacheTreeNode(TagType.ROOT, "", 0)
419432
work_stack: t.Deque[MustacheTreeNode] = deque([root])
433+
work_stack_append = work_stack.append # Cache method lookup
434+
work_stack_pop = work_stack.pop # Cache method lookup
435+
420436
raw_token_list = mustache_tokenizer(thing)
421437
token_list = process_raw_token_list(raw_token_list)
438+
422439
for token_type, token_data, token_offset in token_list:
423-
# token_data = token_data.decode("utf-8")
424440
if token_type is TokenType.LITERAL:
425-
literal_node = MustacheTreeNode(TagType.LITERAL, token_data, token_offset)
426-
work_stack[-1].add_child(literal_node)
441+
work_stack[-1].add_child(
442+
MustacheTreeNode(TagType.LITERAL, token_data, token_offset)
443+
)
427444

428-
elif token_type in [TokenType.SECTION, TokenType.INVERTED_SECTION]:
445+
elif (
446+
token_type is TokenType.SECTION or token_type is TokenType.INVERTED_SECTION
447+
):
429448
tag_type = (
430449
TagType.SECTION
431450
if token_type is TokenType.SECTION
432451
else TagType.INVERTED_SECTION
433452
)
434453
section_node = MustacheTreeNode(tag_type, token_data, token_offset)
435-
# Add section to list of children
436454
work_stack[-1].add_child(section_node)
437-
438-
# Add section to work stack and descend in on the next iteration.
439-
work_stack.append(section_node)
455+
work_stack_append(section_node)
440456

441457
elif token_type is TokenType.END_SECTION:
442458
if work_stack[-1].data != token_data:
443459
raise StrayClosingTagError(f'Opening tag for "{token_data}" not found.')
460+
work_stack_pop()
444461

445-
# Close the current section by popping off the end of the work stack.
446-
work_stack.pop()
447-
448-
elif token_type in [TokenType.VARIABLE, TokenType.RAW_VARIABLE]:
462+
elif token_type is TokenType.VARIABLE or token_type is TokenType.RAW_VARIABLE:
449463
tag_type = (
450464
TagType.VARIABLE
451465
if token_type is TokenType.VARIABLE
452466
else TagType.VARIABLE_RAW
453467
)
454-
variable_node = MustacheTreeNode(tag_type, token_data, token_offset)
455-
# Add section to list of children
468+
work_stack[-1].add_child(
469+
MustacheTreeNode(tag_type, token_data, token_offset)
470+
)
456471

457-
work_stack[-1].add_child(variable_node)
458-
elif token_type is TokenType.COMMENT:
459-
pass
460-
elif token_type is TokenType.DELIMITER:
461-
# Delimiters don't add nodes to the tree, they're handled during tokenization
472+
elif token_type is TokenType.COMMENT or token_type is TokenType.DELIMITER:
473+
# Comments and delimiters don't add nodes to the tree
462474
pass
475+
463476
elif token_type is TokenType.PARTIAL:
464-
partial_node = MustacheTreeNode(TagType.PARTIAL, token_data, token_offset)
465-
work_stack[-1].add_child(partial_node)
477+
work_stack[-1].add_child(
478+
MustacheTreeNode(TagType.PARTIAL, token_data, token_offset)
479+
)
466480
else:
467481
raise MystaceError
468482

0 commit comments

Comments
 (0)