Skip to content

Commit 95f75b3

Browse files
authored
Merge pull request #2 from michalpokusa/removing-8-x-x-re-module-bug-workarounds
Removing 8.x.x `re` module bug workarounds, `trim_blocks` and `lstrip_blocks` parameters
2 parents 0014d2a + ec4cf3a commit 95f75b3

10 files changed

+146
-77
lines changed

README.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ but it does not implement all of their features and takes a different approach t
3030

3131
Main diffrences from Jinja2 and Django Templates:
3232

33-
- filter are not supported, and there is no plan to support them
33+
- filters are not supported, and there is no plan to support them
3434
- all variables passed inside context must be accessed using the ``context`` object
3535
- you can call methods inside templates just like in Python
3636
- no support for nested blocks, although inheritance is supported

adafruit_templateengine.py

Lines changed: 102 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,18 @@
2929
import os
3030
import re
3131

32+
try:
33+
from sys import implementation
34+
35+
if implementation.name == "circuitpython" and implementation.version < (9, 0, 0):
36+
print(
37+
"Warning: adafruit_templateengine requires CircuitPython 9.0.0, as previous versions"
38+
" will have limited functionality when using block comments and non-ASCII characters."
39+
)
40+
finally:
41+
# Unimport sys to prevent accidental use
42+
del implementation
43+
3244

3345
class Language: # pylint: disable=too-few-public-methods
3446
"""
@@ -59,12 +71,12 @@ def safe_html(value: Any) -> str:
5971
# 1e&minus;10
6072
"""
6173

62-
def replace_amp_or_semi(match: re.Match):
74+
def _replace_amp_or_semi(match: re.Match):
6375
return "&amp;" if match.group(0) == "&" else "&semi;"
6476

6577
return (
6678
# Replace initial & and ; together
67-
re.sub(r"&|;", replace_amp_or_semi, str(value))
79+
re.sub(r"&|;", _replace_amp_or_semi, str(value))
6880
# Replace other characters
6981
.replace('"', "&quot;")
7082
.replace("_", "&lowbar;")
@@ -152,47 +164,48 @@ def safe_markdown(value: Any) -> str:
152164
)
153165

154166

155-
_PRECOMPILED_EXTENDS_PATTERN = re.compile(r"{% extends '.+?' %}|{% extends \".+?\" %}")
156-
_PRECOMPILED_BLOCK_PATTERN = re.compile(r"{% block \w+? %}")
157-
_PRECOMPILED_INCLUDE_PATTERN = re.compile(r"{% include '.+?' %}|{% include \".+?\" %}")
158-
_PRECOMPILED_HASH_COMMENT_PATTERN = re.compile(r"{# .+? #}")
159-
_PRECOMPILED_BLOCK_COMMENT_PATTERN = re.compile(
167+
_EXTENDS_PATTERN = re.compile(r"{% extends '.+?' %}|{% extends \".+?\" %}")
168+
_BLOCK_PATTERN = re.compile(r"{% block \w+? %}")
169+
_INCLUDE_PATTERN = re.compile(r"{% include '.+?' %}|{% include \".+?\" %}")
170+
_HASH_COMMENT_PATTERN = re.compile(r"{# .+? #}")
171+
_BLOCK_COMMENT_PATTERN = re.compile(
160172
r"{% comment ('.*?' |\".*?\" )?%}[\s\S]*?{% endcomment %}"
161173
)
162-
_PRECOMPILED_TOKEN_PATTERN = re.compile(r"{{ .+? }}|{% .+? %}")
174+
_TOKEN_PATTERN = re.compile(r"{{ .+? }}|{% .+? %}")
175+
_LSTRIP_BLOCK_PATTERN = re.compile(r"\n( )+$")
163176

164177

165-
def _find_next_extends(template: str):
166-
return _PRECOMPILED_EXTENDS_PATTERN.search(template)
178+
def _find_extends(template: str):
179+
return _EXTENDS_PATTERN.search(template)
167180

168181

169-
def _find_next_block(template: str):
170-
return _PRECOMPILED_BLOCK_PATTERN.search(template)
182+
def _find_block(template: str):
183+
return _BLOCK_PATTERN.search(template)
171184

172185

173-
def _find_next_include(template: str):
174-
return _PRECOMPILED_INCLUDE_PATTERN.search(template)
186+
def _find_include(template: str):
187+
return _INCLUDE_PATTERN.search(template)
175188

176189

177190
def _find_named_endblock(template: str, name: str):
178191
return re.search(r"{% endblock " + name + r" %}", template)
179192

180193

181-
def _exists_and_is_file(path: str):
194+
def _exists_and_is_file(path: str) -> bool:
182195
try:
183196
return (os.stat(path)[0] & 0b_11110000_00000000) == 0b_10000000_00000000
184197
except OSError:
185198
return False
186199

187200

188201
def _resolve_includes(template: str):
189-
while (include_match := _find_next_include(template)) is not None:
202+
while (include_match := _find_include(template)) is not None:
190203
template_path = include_match.group(0)[12:-4]
191204

192205
# TODO: Restrict include to specific directory
193206

194207
if not _exists_and_is_file(template_path):
195-
raise FileNotFoundError(f"Include template not found: {template_path}")
208+
raise OSError(f"Include template not found: {template_path}")
196209

197210
# Replace the include with the template content
198211
with open(template_path, "rt", encoding="utf-8") as template_file:
@@ -205,15 +218,15 @@ def _resolve_includes(template: str):
205218

206219

207220
def _check_for_unsupported_nested_blocks(template: str):
208-
if _find_next_block(template) is not None:
221+
if _find_block(template) is not None:
209222
raise ValueError("Nested blocks are not supported")
210223

211224

212225
def _resolve_includes_blocks_and_extends(template: str):
213226
block_replacements: "dict[str, str]" = {}
214227

215228
# Processing nested child templates
216-
while (extends_match := _find_next_extends(template)) is not None:
229+
while (extends_match := _find_extends(template)) is not None:
217230
extended_template_name = extends_match.group(0)[12:-4]
218231

219232
# Load extended template
@@ -229,20 +242,15 @@ def _resolve_includes_blocks_and_extends(template: str):
229242
template = _resolve_includes(template)
230243

231244
# Save block replacements
232-
while (block_match := _find_next_block(template)) is not None:
245+
while (block_match := _find_block(template)) is not None:
233246
block_name = block_match.group(0)[9:-3]
234247

235248
endblock_match = _find_named_endblock(template, block_name)
236249

237250
if endblock_match is None:
238251
raise ValueError(r"Missing {% endblock %} for block: " + block_name)
239252

240-
# Workaround for bug in re module https://github.com/adafruit/circuitpython/issues/6860
241-
block_content = template.encode("utf-8")[
242-
block_match.end() : endblock_match.start()
243-
].decode("utf-8")
244-
# TODO: Uncomment when bug is fixed
245-
# block_content = template[block_match.end() : endblock_match.start()]
253+
block_content = template[block_match.end() : endblock_match.start()]
246254

247255
_check_for_unsupported_nested_blocks(block_content)
248256

@@ -267,7 +275,7 @@ def _resolve_includes_blocks_and_extends(template: str):
267275

268276
def _replace_blocks_with_replacements(template: str, replacements: "dict[str, str]"):
269277
# Replace blocks in top-level template
270-
while (block_match := _find_next_block(template)) is not None:
278+
while (block_match := _find_block(template)) is not None:
271279
block_name = block_match.group(0)[9:-3]
272280

273281
# Self-closing block tag without default content
@@ -309,34 +317,61 @@ def _replace_blocks_with_replacements(template: str, replacements: "dict[str, st
309317
return template
310318

311319

312-
def _find_next_hash_comment(template: str):
313-
return _PRECOMPILED_HASH_COMMENT_PATTERN.search(template)
320+
def _find_hash_comment(template: str):
321+
return _HASH_COMMENT_PATTERN.search(template)
322+
323+
324+
def _find_block_comment(template: str):
325+
return _BLOCK_COMMENT_PATTERN.search(template)
326+
327+
328+
def _remove_comments(
329+
template: str,
330+
*,
331+
trim_blocks: bool = True,
332+
lstrip_blocks: bool = True,
333+
):
334+
def _remove_matched_comment(template: str, comment_match: re.Match):
335+
text_before_comment = template[: comment_match.start()]
336+
text_after_comment = template[comment_match.end() :]
314337

338+
if text_before_comment:
339+
if lstrip_blocks:
340+
if _token_is_on_own_line(text_before_comment):
341+
text_before_comment = text_before_comment.rstrip(" ")
315342

316-
def _find_next_block_comment(template: str):
317-
return _PRECOMPILED_BLOCK_COMMENT_PATTERN.search(template)
343+
if text_after_comment:
344+
if trim_blocks:
345+
if text_after_comment.startswith("\n"):
346+
text_after_comment = text_after_comment[1:]
318347

348+
return text_before_comment + text_after_comment
319349

320-
def _remove_comments(template: str):
321350
# Remove hash comments: {# ... #}
322-
while (comment_match := _find_next_hash_comment(template)) is not None:
323-
template = template[: comment_match.start()] + template[comment_match.end() :]
351+
while (comment_match := _find_hash_comment(template)) is not None:
352+
template = _remove_matched_comment(template, comment_match)
324353

325354
# Remove block comments: {% comment %} ... {% endcomment %}
326-
while (comment_match := _find_next_block_comment(template)) is not None:
327-
template = template[: comment_match.start()] + template[comment_match.end() :]
355+
while (comment_match := _find_block_comment(template)) is not None:
356+
template = _remove_matched_comment(template, comment_match)
328357

329358
return template
330359

331360

332-
def _find_next_token(template: str):
333-
return _PRECOMPILED_TOKEN_PATTERN.search(template)
361+
def _find_token(template: str):
362+
return _TOKEN_PATTERN.search(template)
363+
364+
365+
def _token_is_on_own_line(text_before_token: str) -> bool:
366+
return _LSTRIP_BLOCK_PATTERN.search(text_before_token) is not None
334367

335368

336369
def _create_template_function( # pylint: disable=,too-many-locals,too-many-branches,too-many-statements
337370
template: str,
338371
language: str = Language.HTML,
339372
*,
373+
trim_blocks: bool = True,
374+
lstrip_blocks: bool = True,
340375
function_name: str = "_",
341376
context_name: str = "context",
342377
dry_run: bool = False,
@@ -351,22 +386,34 @@ def _create_template_function( # pylint: disable=,too-many-locals,too-many-bran
351386
function_string = f"def {function_name}({context_name}):\n"
352387
indent, indentation_level = " ", 1
353388

354-
# Keep track of the tempalte state
389+
# Keep track of the template state
355390
forloop_iterables: "list[str]" = []
356391
autoescape_modes: "list[bool]" = ["default_on"]
392+
last_token_was_block = False
357393

358394
# Resolve tokens
359-
while (token_match := _find_next_token(template)) is not None:
395+
while (token_match := _find_token(template)) is not None:
360396
token = token_match.group(0)
361397

362398
# Add the text before the token
363399
if text_before_token := template[: token_match.start()]:
364-
function_string += (
365-
indent * indentation_level + f"yield {repr(text_before_token)}\n"
366-
)
400+
if lstrip_blocks and token.startswith(r"{% "):
401+
if _token_is_on_own_line(text_before_token):
402+
text_before_token = text_before_token.rstrip(" ")
403+
404+
if trim_blocks:
405+
if last_token_was_block and text_before_token.startswith("\n"):
406+
text_before_token = text_before_token[1:]
407+
408+
if text_before_token:
409+
function_string += (
410+
indent * indentation_level + f"yield {repr(text_before_token)}\n"
411+
)
367412

368413
# Token is an expression
369414
if token.startswith(r"{{ "):
415+
last_token_was_block = False
416+
370417
autoescape = autoescape_modes[-1] in ("on", "default_on")
371418

372419
# Expression should be escaped with language-specific function
@@ -383,6 +430,8 @@ def _create_template_function( # pylint: disable=,too-many-locals,too-many-bran
383430

384431
# Token is a statement
385432
elif token.startswith(r"{% "):
433+
last_token_was_block = True
434+
386435
# Token is a some sort of if statement
387436
if token.startswith(r"{% if "):
388437
function_string += indent * indentation_level + f"{token[3:-3]}:\n"
@@ -449,9 +498,16 @@ def _create_template_function( # pylint: disable=,too-many-locals,too-many-bran
449498
# Continue with the rest of the template
450499
template = template[token_match.end() :]
451500

452-
# Add the text after the last token (if any) and return
453-
if template:
454-
function_string += indent * indentation_level + f"yield {repr(template)}\n"
501+
# Add the text after the last token (if any)
502+
text_after_last_token = template
503+
504+
if text_after_last_token:
505+
if trim_blocks and text_after_last_token.startswith("\n"):
506+
text_after_last_token = text_after_last_token[1:]
507+
508+
function_string += (
509+
indent * indentation_level + f"yield {repr(text_after_last_token)}\n"
510+
)
455511

456512
# If dry run, return the template function string
457513
if dry_run:

docs/examples.rst

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ It is up to the user to decide which method is more suitable for a given use cas
2727
**Generally, the first method will be sufficient for most use cases.**
2828

2929
It is also worth noting that compiling all used templates using the second method might not be possible,
30-
depending one the project and board used, due to the limited amount of RAM.
30+
depending on the project and board used, due to the limited amount of RAM.
3131

3232
.. literalinclude:: ../examples/templateengine_reusing.py
3333
:caption: examples/templateengine_reusing.py
@@ -51,7 +51,7 @@ Every expression that would be valid in an f-string is also valid in the templat
5151
This includes, but is not limited to:
5252

5353
- mathemathical operations e.g. ``{{ 5 + 2 ** 3 }}`` will be replaced with ``"13"``
54-
- string operations e.g. ``{{ 'hello'.title() }}`` will be replaced with ``"Hello"``
54+
- string operations e.g. ``{{ 'hello'.upper() }}`` will be replaced with ``"HELLO"``
5555
- logical operations e.g. ``{{ 1 == 2 }}`` will be replaced with ``"False"``
5656
- ternary operator e.g. ``{{ 'ON' if True else 'OFF' }}`` will be replaced with ``"ON"``
5757
- built-in functions e.g. ``{{ len('Adafruit Industries') }}`` will be replaced with ``"19"``
@@ -140,13 +140,13 @@ and then include it in multiple pages.
140140

141141
.. literalinclude:: ../examples/footer.html
142142
:caption: examples/footer.html
143-
:lines: 5-
143+
:lines: 7-
144144
:language: html
145145
:linenos:
146146

147147
.. literalinclude:: ../examples/base_without_footer.html
148148
:caption: examples/base_without_footer.html
149-
:lines: 5-
149+
:lines: 7-
150150
:language: html
151151
:emphasize-lines: 12
152152
:linenos:
@@ -173,13 +173,13 @@ This allows sharing whole layout, not only single parts.
173173

174174
.. literalinclude:: ../examples/child.html
175175
:caption: examples/child.html
176-
:lines: 5-
176+
:lines: 7-
177177
:language: html
178178
:linenos:
179179

180180
.. literalinclude:: ../examples/parent_layout.html
181181
:caption: examples/parent_layout.html
182-
:lines: 5-
182+
:lines: 7-
183183
:language: html
184184
:linenos:
185185

@@ -196,7 +196,7 @@ Executing Python code in templates
196196
----------------------------------
197197

198198
It is also possible to execute Python code in templates.
199-
This an be used for e.g. defining variables, modifying context, or breaking from loops.
199+
This can be used for e.g. defining variables, modifying context, or breaking from loops.
200200

201201

202202
.. literalinclude:: ../examples/templateengine_exec.py
@@ -221,7 +221,7 @@ Supported comment syntaxes:
221221

222222
.. literalinclude:: ../examples/comments.html
223223
:caption: examples/comments.html
224-
:lines: 5-
224+
:lines: 7-
225225
:language: html
226226
:linenos:
227227

@@ -247,12 +247,13 @@ and in all ``Template`` constructors.
247247

248248
.. literalinclude:: ../examples/autoescape.html
249249
:caption: examples/autoescape.html
250-
:lines: 5-
250+
:lines: 7-
251251
:language: html
252252
:linenos:
253253

254254
.. literalinclude:: ../examples/autoescape.md
255255
:caption: examples/autoescape.md
256+
:lines: 5-
256257
:language: markdown
257258
:linenos:
258259

examples/autoescape.html

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
# SPDX-FileCopyrightText: Copyright (c) 2023 Michał Pokusa
2-
#
3-
# SPDX-License-Identifier: Unlicense
1+
<!--
2+
SPDX-FileCopyrightText: Copyright (c) 2023 Michał Pokusa
3+
4+
SPDX-License-Identifier: Unlicense
5+
-->
46

57
<!DOCTYPE html>
68
<html>

0 commit comments

Comments
 (0)