From e50ce6bb58e08ee22cd96e1cc6f8d358c1f6a54b Mon Sep 17 00:00:00 2001
From: michalpokusa <72110769+michalpokusa@users.noreply.github.com>
Date: Thu, 7 Mar 2024 23:11:39 +0000
Subject: [PATCH 01/18] Moved to offset based teplate processing logic, Token
class, function for underlining token in template
---
adafruit_templateengine.py | 257 ++++++++++++++++++++++++++++---------
1 file changed, 195 insertions(+), 62 deletions(-)
diff --git a/adafruit_templateengine.py b/adafruit_templateengine.py
index 21234cf..1de3ef0 100644
--- a/adafruit_templateengine.py
+++ b/adafruit_templateengine.py
@@ -57,6 +57,17 @@ class Language: # pylint: disable=too-few-public-methods
"""Markdown language"""
+class Token: # pylint: disable=too-few-public-methods
+ """Stores a token with its position in a template."""
+
+ def __init__(self, template: str, start_position: int, end_position: int):
+ self.template = template
+ self.start_position = start_position
+ self.end_position = end_position
+
+ self.content = template[start_position:end_position]
+
+
def safe_html(value: Any) -> str:
"""
Encodes unsafe symbols in ``value`` to HTML entities and returns the string that can be safely
@@ -191,6 +202,64 @@ def _find_named_endblock(template: str, name: str):
return re.search(r"{% endblock " + name + r" %}", template)
+def _underline_token_in_template(
+ token: Token, *, lines_around: int = 5, symbol: str = "^"
+) -> str:
+ """
+ Return ``number_of_lines`` lines before and after the token, with the token content underlined
+ with ``symbol`` e.g.:
+
+ ```html
+ [8 lines skipped]
+ Shopping list:
+
+ {% for item in context["items"] %}
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ - {{ item["name"] }} - ${{ item["price"] }}
+ {% empty %}
+ [5 lines skipped]
+ ```
+ """
+
+ template_before_token = token.template[: token.start_position]
+ if (skipped_lines := template_before_token.count("\n") - lines_around) > 0:
+ template_before_token = f"[{skipped_lines} lines skipped]\n" + "\n".join(
+ template_before_token.split("\n")[-(lines_around + 1) :]
+ )
+
+ template_after_token = token.template[token.end_position :]
+ if (skipped_lines := template_after_token.count("\n") - lines_around) > 0:
+ template_after_token = (
+ "\n".join(template_after_token.split("\n")[: (lines_around + 1)])
+ + f"\n[{skipped_lines} lines skipped]"
+ )
+
+ lines_before_line_with_token = template_before_token.rsplit("\n", 1)[0]
+
+ line_with_token = (
+ template_before_token.rsplit("\n", 1)[-1]
+ + token.content
+ + template_after_token.split("\n")[0]
+ )
+
+ line_with_underline = (
+ " " * len(template_before_token.rsplit("\n", 1)[-1])
+ + symbol * len(token.content)
+ + " " * len(template_after_token.split("\n")[0])
+ )
+
+ lines_after_line_with_token = template_after_token.split("\n", 1)[-1]
+
+ return "\n".join(
+ [
+ lines_before_line_with_token,
+ line_with_token,
+ line_with_underline,
+ lines_after_line_with_token,
+ ]
+ )
+
+
def _exists_and_is_file(path: str) -> bool:
try:
return (os.stat(path)[0] & 0b_11110000_00000000) == 0b_10000000_00000000
@@ -205,7 +274,12 @@ def _resolve_includes(template: str):
# TODO: Restrict include to specific directory
if not _exists_and_is_file(template_path):
- raise OSError(f"Include template not found: {template_path}")
+ raise OSError(
+ f"Include template not found: {template_path}\n\n"
+ + _underline_token_in_template(
+ Token(template, include_match.start(), include_match.end())
+ )
+ )
# Replace the include with the template content
with open(template_path, "rt", encoding="utf-8") as template_file:
@@ -217,11 +291,6 @@ def _resolve_includes(template: str):
return template
-def _check_for_unsupported_nested_blocks(template: str):
- if _find_block(template) is not None:
- raise SyntaxError("Nested blocks are not supported")
-
-
def _resolve_includes_blocks_and_extends(template: str):
block_replacements: "dict[str, str]" = {}
@@ -235,24 +304,45 @@ def _resolve_includes_blocks_and_extends(template: str):
) as extended_template_file:
extended_template = extended_template_file.read()
- # Removed the extend tag
- template = template[extends_match.end() :]
+ offset = extends_match.end()
# Resolve includes
template = _resolve_includes(template)
# Save block replacements
- while (block_match := _find_block(template)) is not None:
+ while (block_match := _find_block(template[offset:])) is not None:
block_name = block_match.group(0)[9:-3]
- endblock_match = _find_named_endblock(template, block_name)
+ endblock_match = _find_named_endblock(template[offset:], block_name)
if endblock_match is None:
- raise SyntaxError("Missing {% endblock %} for block: " + block_name)
-
- block_content = template[block_match.end() : endblock_match.start()]
+ raise SyntaxError(
+ "Missing {% endblock %}:\n\n"
+ + _underline_token_in_template(
+ Token(
+ template,
+ offset + block_match.start(),
+ offset + block_match.end(),
+ )
+ )
+ )
- _check_for_unsupported_nested_blocks(block_content)
+ block_content = template[
+ offset + block_match.end() : offset + endblock_match.start()
+ ]
+
+ # Check for unsupported nested blocks
+ if (nested_block_match := _find_block(block_content)) is not None:
+ raise SyntaxError(
+ "Nested blocks are not supported:\n\n"
+ + _underline_token_in_template(
+ Token(
+ template,
+ offset + block_match.end() + nested_block_match.start(),
+ offset + block_match.end() + nested_block_match.end(),
+ )
+ )
+ )
if block_name in block_replacements:
block_replacements[block_name] = block_replacements[block_name].replace(
@@ -261,9 +351,7 @@ def _resolve_includes_blocks_and_extends(template: str):
else:
block_replacements.setdefault(block_name, block_content)
- template = (
- template[: block_match.start()] + template[endblock_match.end() :]
- )
+ offset += endblock_match.end()
template = extended_template
@@ -292,7 +380,18 @@ def _replace_blocks_with_replacements(template: str, replacements: "dict[str, st
else:
block_content = template[block_match.end() : endblock_match.start()]
- _check_for_unsupported_nested_blocks(block_content)
+ # Check for unsupported nested blocks
+ if (nested_block_match := _find_block(block_content)) is not None:
+ raise SyntaxError(
+ "Nested blocks are not supported:\n\n"
+ + _underline_token_in_template(
+ Token(
+ template,
+ block_match.end() + nested_block_match.start(),
+ block_match.end() + nested_block_match.end(),
+ )
+ )
+ )
# No replacement for this block, use default content
if block_name not in replacements:
@@ -387,19 +486,24 @@ def _create_template_rendering_function( # pylint: disable=,too-many-locals,too
indent, indentation_level = " ", 1
# Keep track of the template state
- nested_if_statements: "list[str]" = []
- nested_for_loops: "list[str]" = []
- nested_while_loops: "list[str]" = []
- nested_autoescape_modes: "list[str]" = []
+ nested_if_statements: "list[Token]" = []
+ nested_for_loops: "list[Token]" = []
+ nested_while_loops: "list[Token]" = []
+ nested_autoescape_modes: "list[Token]" = []
last_token_was_block = False
+ offset = 0
# Resolve tokens
- while (token_match := _find_token(template)) is not None:
- token = token_match.group(0)
+ while (token_match := _find_token(template[offset:])) is not None:
+ token = Token(
+ template,
+ offset + token_match.start(),
+ offset + token_match.end(),
+ )
# Add the text before the token
- if text_before_token := template[: token_match.start()]:
- if lstrip_blocks and token.startswith(r"{% "):
+ if text_before_token := template[offset : offset + token_match.start()]:
+ if lstrip_blocks and token.content.startswith(r"{% "):
if _token_is_on_own_line(text_before_token):
text_before_token = text_before_token.rstrip(" ")
@@ -415,11 +519,11 @@ def _create_template_rendering_function( # pylint: disable=,too-many-locals,too
function_string += indent * indentation_level + "pass\n"
# Token is an expression
- if token.startswith(r"{{ "):
+ if token.content.startswith(r"{{ "):
last_token_was_block = False
if nested_autoescape_modes:
- autoescape = nested_autoescape_modes[-1][14:-3] == "on"
+ autoescape = nested_autoescape_modes[-1].content[14:-3] == "on"
else:
autoescape = True
@@ -427,125 +531,154 @@ def _create_template_rendering_function( # pylint: disable=,too-many-locals,too
if autoescape:
function_string += (
indent * indentation_level
- + f"yield safe_{language.lower()}({token[3:-3]})\n"
+ + f"yield safe_{language.lower()}({token.content[3:-3]})\n"
)
# Expression should not be escaped
else:
function_string += (
- indent * indentation_level + f"yield str({token[3:-3]})\n"
+ indent * indentation_level + f"yield str({token.content[3:-3]})\n"
)
# Token is a statement
- elif token.startswith(r"{% "):
+ elif token.content.startswith(r"{% "):
last_token_was_block = True
# Token is a some sort of if statement
- if token.startswith(r"{% if "):
- function_string += indent * indentation_level + f"{token[3:-3]}:\n"
+ if token.content.startswith(r"{% if "):
+ function_string += (
+ indent * indentation_level + f"{token.content[3:-3]}:\n"
+ )
indentation_level += 1
nested_if_statements.append(token)
- elif token.startswith(r"{% elif "):
+ elif token.content.startswith(r"{% elif "):
indentation_level -= 1
- function_string += indent * indentation_level + f"{token[3:-3]}:\n"
+ function_string += (
+ indent * indentation_level + f"{token.content[3:-3]}:\n"
+ )
indentation_level += 1
- elif token == r"{% else %}":
+ elif token.content == r"{% else %}":
indentation_level -= 1
function_string += indent * indentation_level + "else:\n"
indentation_level += 1
- elif token == r"{% endif %}":
+ elif token.content == r"{% endif %}":
indentation_level -= 1
if not nested_if_statements:
- raise SyntaxError("Missing {% if ... %} block for {% endif %}")
+ raise SyntaxError(
+ "Missing {% if ... %}\n\n" + _underline_token_in_template(token)
+ )
nested_if_statements.pop()
# Token is a for loop
- elif token.startswith(r"{% for "):
- function_string += indent * indentation_level + f"{token[3:-3]}:\n"
+ elif token.content.startswith(r"{% for "):
+ function_string += (
+ indent * indentation_level + f"{token.content[3:-3]}:\n"
+ )
indentation_level += 1
nested_for_loops.append(token)
- elif token == r"{% empty %}":
+ elif token.content == r"{% empty %}":
indentation_level -= 1
- last_forloop_iterable = nested_for_loops[-1][3:-3].split(" in ", 1)[1]
+ last_forloop_iterable = (
+ nested_for_loops[-1].content[3:-3].split(" in ", 1)[1]
+ )
function_string += (
indent * indentation_level + f"if not {last_forloop_iterable}:\n"
)
indentation_level += 1
- elif token == r"{% endfor %}":
+ elif token.content == r"{% endfor %}":
indentation_level -= 1
if not nested_for_loops:
- raise SyntaxError("Missing {% for ... %} block for {% endfor %}")
+ raise SyntaxError(
+ "Missing {% for ... %}\n\n"
+ + _underline_token_in_template(token)
+ )
nested_for_loops.pop()
# Token is a while loop
- elif token.startswith(r"{% while "):
- function_string += indent * indentation_level + f"{token[3:-3]}:\n"
+ elif token.content.startswith(r"{% while "):
+ function_string += (
+ indent * indentation_level + f"{token.content[3:-3]}:\n"
+ )
indentation_level += 1
nested_while_loops.append(token)
- elif token == r"{% endwhile %}":
+ elif token.content == r"{% endwhile %}":
indentation_level -= 1
if not nested_while_loops:
raise SyntaxError(
- "Missing {% while ... %} block for {% endwhile %}"
+ "Missing {% while ... %}\n\n"
+ + _underline_token_in_template(token)
)
nested_while_loops.pop()
# Token is a Python code
- elif token.startswith(r"{% exec "):
- expression = token[8:-3]
+ elif token.content.startswith(r"{% exec "):
+ expression = token.content[8:-3]
function_string += indent * indentation_level + f"{expression}\n"
# Token is autoescape mode change
- elif token.startswith(r"{% autoescape "):
- mode = token[14:-3]
+ elif token.content.startswith(r"{% autoescape "):
+ mode = token.content[14:-3]
if mode not in ("on", "off"):
raise ValueError(f"Unknown autoescape mode: {mode}")
nested_autoescape_modes.append(token)
- elif token == r"{% endautoescape %}":
+ elif token.content == r"{% endautoescape %}":
if not nested_autoescape_modes:
raise SyntaxError(
- "Missing {% autoescape ... %} block for {% endautoescape %}"
+ "Missing {% autoescape ... %}\n\n"
+ + _underline_token_in_template(token)
)
nested_autoescape_modes.pop()
else:
- raise SyntaxError(f"Unknown token type: {token}")
+ raise SyntaxError(
+ f"Unknown token type: {token.content}\n\n"
+ + _underline_token_in_template(token)
+ )
else:
- raise SyntaxError(f"Unknown token type: {token}")
+ raise SyntaxError(
+ f"Unknown token type: {token.content}\n\n"
+ + _underline_token_in_template(token)
+ )
- # Continue with the rest of the template
- template = template[token_match.end() :]
+ # Move offset to the end of the token
+ offset += token_match.end()
# Checking for unclosed blocks
if len(nested_if_statements) > 0:
last_if_statement = nested_if_statements[-1]
- raise SyntaxError("Missing {% endif %} for " + last_if_statement)
+ raise SyntaxError(
+ "Missing {% endif %}\n\n" + _underline_token_in_template(last_if_statement)
+ )
if len(nested_for_loops) > 0:
last_for_loop = nested_for_loops[-1]
- raise SyntaxError("Missing {% endfor %} for " + last_for_loop)
+ raise SyntaxError(
+ "Missing {% endfor %}\n\n" + _underline_token_in_template(last_for_loop)
+ )
if len(nested_while_loops) > 0:
last_while_loop = nested_while_loops[-1]
- raise SyntaxError("Missing {% endwhile %} for " + last_while_loop)
+ raise SyntaxError(
+ "Missing {% endwhile %}\n\n" + _underline_token_in_template(last_while_loop)
+ )
# No check for unclosed autoescape blocks, as they are optional and do not result in errors
# Add the text after the last token (if any)
- text_after_last_token = template
+ text_after_last_token = template[offset:]
if text_after_last_token:
if trim_blocks and text_after_last_token.startswith("\n"):
From 36ae07f32afb835600282821371354ff8012d2d0 Mon Sep 17 00:00:00 2001
From: michalpokusa <72110769+michalpokusa@users.noreply.github.com>
Date: Sat, 9 Mar 2024 15:14:35 +0000
Subject: [PATCH 02/18] Added TemplateSyntaxError and moved underlining
function to error class method
---
adafruit_templateengine.py | 238 +++++++++++++++++--------------------
1 file changed, 108 insertions(+), 130 deletions(-)
diff --git a/adafruit_templateengine.py b/adafruit_templateengine.py
index 1de3ef0..7ce4d40 100644
--- a/adafruit_templateengine.py
+++ b/adafruit_templateengine.py
@@ -68,6 +68,71 @@ def __init__(self, template: str, start_position: int, end_position: int):
self.content = template[start_position:end_position]
+class TemplateSyntaxError(SyntaxError):
+ """Raised when a syntax error is encountered in a template."""
+
+ def __init__(self, message: str, token: Token):
+ super().__init__(f"{message}\n\n" + self._underline_token_in_template(token))
+
+ @staticmethod
+ def _underline_token_in_template(
+ token: Token, *, lines_around: int = 4, symbol: str = "^"
+ ) -> str:
+ """
+ Return ``number_of_lines`` lines before and after the token, with the token content underlined
+ with ``symbol`` e.g.:
+
+ ```html
+ [8 lines skipped]
+ Shopping list:
+
+ {% for item in context["items"] %}
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ - {{ item["name"] }} - ${{ item["price"] }}
+ {% empty %}
+ [5 lines skipped]
+ ```
+ """
+
+ template_before_token = token.template[: token.start_position]
+ if (skipped_lines := template_before_token.count("\n") - lines_around) > 0:
+ template_before_token = f"[{skipped_lines} lines skipped]\n" + "\n".join(
+ template_before_token.split("\n")[-(lines_around + 1) :]
+ )
+
+ template_after_token = token.template[token.end_position :]
+ if (skipped_lines := template_after_token.count("\n") - lines_around) > 0:
+ template_after_token = (
+ "\n".join(template_after_token.split("\n")[: (lines_around + 1)])
+ + f"\n[{skipped_lines} lines skipped]"
+ )
+
+ lines_before_line_with_token = template_before_token.rsplit("\n", 1)[0]
+
+ line_with_token = (
+ template_before_token.rsplit("\n", 1)[-1]
+ + token.content
+ + template_after_token.split("\n")[0]
+ )
+
+ line_with_underline = (
+ " " * len(template_before_token.rsplit("\n", 1)[-1])
+ + symbol * len(token.content)
+ + " " * len(template_after_token.split("\n")[0])
+ )
+
+ lines_after_line_with_token = template_after_token.split("\n", 1)[-1]
+
+ return "\n".join(
+ [
+ lines_before_line_with_token,
+ line_with_token,
+ line_with_underline,
+ lines_after_line_with_token,
+ ]
+ )
+
+
def safe_html(value: Any) -> str:
"""
Encodes unsafe symbols in ``value`` to HTML entities and returns the string that can be safely
@@ -202,64 +267,6 @@ def _find_named_endblock(template: str, name: str):
return re.search(r"{% endblock " + name + r" %}", template)
-def _underline_token_in_template(
- token: Token, *, lines_around: int = 5, symbol: str = "^"
-) -> str:
- """
- Return ``number_of_lines`` lines before and after the token, with the token content underlined
- with ``symbol`` e.g.:
-
- ```html
- [8 lines skipped]
- Shopping list:
-
- {% for item in context["items"] %}
- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
- - {{ item["name"] }} - ${{ item["price"] }}
- {% empty %}
- [5 lines skipped]
- ```
- """
-
- template_before_token = token.template[: token.start_position]
- if (skipped_lines := template_before_token.count("\n") - lines_around) > 0:
- template_before_token = f"[{skipped_lines} lines skipped]\n" + "\n".join(
- template_before_token.split("\n")[-(lines_around + 1) :]
- )
-
- template_after_token = token.template[token.end_position :]
- if (skipped_lines := template_after_token.count("\n") - lines_around) > 0:
- template_after_token = (
- "\n".join(template_after_token.split("\n")[: (lines_around + 1)])
- + f"\n[{skipped_lines} lines skipped]"
- )
-
- lines_before_line_with_token = template_before_token.rsplit("\n", 1)[0]
-
- line_with_token = (
- template_before_token.rsplit("\n", 1)[-1]
- + token.content
- + template_after_token.split("\n")[0]
- )
-
- line_with_underline = (
- " " * len(template_before_token.rsplit("\n", 1)[-1])
- + symbol * len(token.content)
- + " " * len(template_after_token.split("\n")[0])
- )
-
- lines_after_line_with_token = template_after_token.split("\n", 1)[-1]
-
- return "\n".join(
- [
- lines_before_line_with_token,
- line_with_token,
- line_with_underline,
- lines_after_line_with_token,
- ]
- )
-
-
def _exists_and_is_file(path: str) -> bool:
try:
return (os.stat(path)[0] & 0b_11110000_00000000) == 0b_10000000_00000000
@@ -274,12 +281,7 @@ def _resolve_includes(template: str):
# TODO: Restrict include to specific directory
if not _exists_and_is_file(template_path):
- raise OSError(
- f"Include template not found: {template_path}\n\n"
- + _underline_token_in_template(
- Token(template, include_match.start(), include_match.end())
- )
- )
+ raise OSError(f"Template file not found: {template_path}")
# Replace the include with the template content
with open(template_path, "rt", encoding="utf-8") as template_file:
@@ -316,15 +318,13 @@ def _resolve_includes_blocks_and_extends(template: str):
endblock_match = _find_named_endblock(template[offset:], block_name)
if endblock_match is None:
- raise SyntaxError(
- "Missing {% endblock %}:\n\n"
- + _underline_token_in_template(
- Token(
- template,
- offset + block_match.start(),
- offset + block_match.end(),
- )
- )
+ raise TemplateSyntaxError(
+ "Missing {% endblock %}",
+ Token(
+ template,
+ offset + block_match.start(),
+ offset + block_match.end(),
+ ),
)
block_content = template[
@@ -333,15 +333,13 @@ def _resolve_includes_blocks_and_extends(template: str):
# Check for unsupported nested blocks
if (nested_block_match := _find_block(block_content)) is not None:
- raise SyntaxError(
- "Nested blocks are not supported:\n\n"
- + _underline_token_in_template(
- Token(
- template,
- offset + block_match.end() + nested_block_match.start(),
- offset + block_match.end() + nested_block_match.end(),
- )
- )
+ raise TemplateSyntaxError(
+ "Nested blocks are not supported",
+ Token(
+ template,
+ offset + block_match.end() + nested_block_match.start(),
+ offset + block_match.end() + nested_block_match.end(),
+ ),
)
if block_name in block_replacements:
@@ -382,15 +380,13 @@ def _replace_blocks_with_replacements(template: str, replacements: "dict[str, st
# Check for unsupported nested blocks
if (nested_block_match := _find_block(block_content)) is not None:
- raise SyntaxError(
- "Nested blocks are not supported:\n\n"
- + _underline_token_in_template(
- Token(
- template,
- block_match.end() + nested_block_match.start(),
- block_match.end() + nested_block_match.end(),
- )
- )
+ raise TemplateSyntaxError(
+ "Nested blocks are not supported",
+ Token(
+ template,
+ block_match.end() + nested_block_match.start(),
+ block_match.end() + nested_block_match.end(),
+ ),
)
# No replacement for this block, use default content
@@ -552,23 +548,26 @@ def _create_template_rendering_function( # pylint: disable=,too-many-locals,too
nested_if_statements.append(token)
elif token.content.startswith(r"{% elif "):
+ if not nested_if_statements:
+ raise TemplateSyntaxError("Missing {% if ... %}", token)
+
indentation_level -= 1
function_string += (
indent * indentation_level + f"{token.content[3:-3]}:\n"
)
indentation_level += 1
elif token.content == r"{% else %}":
+ if not nested_if_statements:
+ raise TemplateSyntaxError("Missing {% if ... %}", token)
+
indentation_level -= 1
function_string += indent * indentation_level + "else:\n"
indentation_level += 1
elif token.content == r"{% endif %}":
- indentation_level -= 1
-
if not nested_if_statements:
- raise SyntaxError(
- "Missing {% if ... %}\n\n" + _underline_token_in_template(token)
- )
+ raise TemplateSyntaxError("Missing {% if ... %}", token)
+ indentation_level -= 1
nested_if_statements.pop()
# Token is a for loop
@@ -580,24 +579,22 @@ def _create_template_rendering_function( # pylint: disable=,too-many-locals,too
nested_for_loops.append(token)
elif token.content == r"{% empty %}":
+ if not nested_for_loops:
+ raise TemplateSyntaxError("Missing {% for ... %}", token)
+
indentation_level -= 1
last_forloop_iterable = (
nested_for_loops[-1].content[3:-3].split(" in ", 1)[1]
)
-
function_string += (
indent * indentation_level + f"if not {last_forloop_iterable}:\n"
)
indentation_level += 1
elif token.content == r"{% endfor %}":
- indentation_level -= 1
-
if not nested_for_loops:
- raise SyntaxError(
- "Missing {% for ... %}\n\n"
- + _underline_token_in_template(token)
- )
+ raise TemplateSyntaxError("Missing {% for ... %}", token)
+ indentation_level -= 1
nested_for_loops.pop()
# Token is a while loop
@@ -609,14 +606,10 @@ def _create_template_rendering_function( # pylint: disable=,too-many-locals,too
nested_while_loops.append(token)
elif token.content == r"{% endwhile %}":
- indentation_level -= 1
-
if not nested_while_loops:
- raise SyntaxError(
- "Missing {% while ... %}\n\n"
- + _underline_token_in_template(token)
- )
+ raise TemplateSyntaxError("Missing {% while ... %}", token)
+ indentation_level -= 1
nested_while_loops.pop()
# Token is a Python code
@@ -634,24 +627,15 @@ def _create_template_rendering_function( # pylint: disable=,too-many-locals,too
elif token.content == r"{% endautoescape %}":
if not nested_autoescape_modes:
- raise SyntaxError(
- "Missing {% autoescape ... %}\n\n"
- + _underline_token_in_template(token)
- )
+ raise TemplateSyntaxError("Missing {% autoescape ... %}", token)
nested_autoescape_modes.pop()
else:
- raise SyntaxError(
- f"Unknown token type: {token.content}\n\n"
- + _underline_token_in_template(token)
- )
+ raise TemplateSyntaxError(f"Unknown token type: {token.content}", token)
else:
- raise SyntaxError(
- f"Unknown token type: {token.content}\n\n"
- + _underline_token_in_template(token)
- )
+ raise TemplateSyntaxError(f"Unknown token type: {token.content}", token)
# Move offset to the end of the token
offset += token_match.end()
@@ -659,21 +643,15 @@ def _create_template_rendering_function( # pylint: disable=,too-many-locals,too
# Checking for unclosed blocks
if len(nested_if_statements) > 0:
last_if_statement = nested_if_statements[-1]
- raise SyntaxError(
- "Missing {% endif %}\n\n" + _underline_token_in_template(last_if_statement)
- )
+ raise TemplateSyntaxError("Missing {% endif %}", last_if_statement)
if len(nested_for_loops) > 0:
last_for_loop = nested_for_loops[-1]
- raise SyntaxError(
- "Missing {% endfor %}\n\n" + _underline_token_in_template(last_for_loop)
- )
+ raise TemplateSyntaxError("Missing {% endfor %}", last_for_loop)
if len(nested_while_loops) > 0:
last_while_loop = nested_while_loops[-1]
- raise SyntaxError(
- "Missing {% endwhile %}\n\n" + _underline_token_in_template(last_while_loop)
- )
+ raise TemplateSyntaxError("Missing {% endwhile %}", last_while_loop)
# No check for unclosed autoescape blocks, as they are optional and do not result in errors
From 386a359495fcaaf79b686cfbd20c9676dc14b48c Mon Sep 17 00:00:00 2001
From: michalpokusa <72110769+michalpokusa@users.noreply.github.com>
Date: Sat, 9 Mar 2024 15:16:36 +0000
Subject: [PATCH 03/18] Fix: Considering single skipped line in message, moved
to separate method
---
adafruit_templateengine.py | 13 +++++++++----
1 file changed, 9 insertions(+), 4 deletions(-)
diff --git a/adafruit_templateengine.py b/adafruit_templateengine.py
index 7ce4d40..5872bd6 100644
--- a/adafruit_templateengine.py
+++ b/adafruit_templateengine.py
@@ -75,8 +75,12 @@ def __init__(self, message: str, token: Token):
super().__init__(f"{message}\n\n" + self._underline_token_in_template(token))
@staticmethod
+ def _skipped_lines_message(nr_of_lines: int) -> str:
+ return f"[{nr_of_lines} line{'s' if nr_of_lines > 1 else ''} skipped]"
+
+ @classmethod
def _underline_token_in_template(
- token: Token, *, lines_around: int = 4, symbol: str = "^"
+ cls, token: Token, *, lines_around: int = 4, symbol: str = "^"
) -> str:
"""
Return ``number_of_lines`` lines before and after the token, with the token content underlined
@@ -96,15 +100,16 @@ def _underline_token_in_template(
template_before_token = token.template[: token.start_position]
if (skipped_lines := template_before_token.count("\n") - lines_around) > 0:
- template_before_token = f"[{skipped_lines} lines skipped]\n" + "\n".join(
- template_before_token.split("\n")[-(lines_around + 1) :]
+ template_before_token = (
+ f"{cls._skipped_lines_message(skipped_lines)}\n"
+ + "\n".join(template_before_token.split("\n")[-(lines_around + 1) :])
)
template_after_token = token.template[token.end_position :]
if (skipped_lines := template_after_token.count("\n") - lines_around) > 0:
template_after_token = (
"\n".join(template_after_token.split("\n")[: (lines_around + 1)])
- + f"\n[{skipped_lines} lines skipped]"
+ + f"\n{cls._skipped_lines_message(skipped_lines)}"
)
lines_before_line_with_token = template_before_token.rsplit("\n", 1)[0]
From da7f54a096bb540a0dc5572c692cb1a3ea7246c0 Mon Sep 17 00:00:00 2001
From: michalpokusa <72110769+michalpokusa@users.noreply.github.com>
Date: Sat, 9 Mar 2024 17:38:21 +0000
Subject: [PATCH 04/18] Changed Missing to No Matching in error messages
---
adafruit_templateengine.py | 28 ++++++++++++++--------------
1 file changed, 14 insertions(+), 14 deletions(-)
diff --git a/adafruit_templateengine.py b/adafruit_templateengine.py
index 5872bd6..2a67516 100644
--- a/adafruit_templateengine.py
+++ b/adafruit_templateengine.py
@@ -324,7 +324,7 @@ def _resolve_includes_blocks_and_extends(template: str):
if endblock_match is None:
raise TemplateSyntaxError(
- "Missing {% endblock %}",
+ "No matching {% endblock %}",
Token(
template,
offset + block_match.start(),
@@ -554,7 +554,7 @@ def _create_template_rendering_function( # pylint: disable=,too-many-locals,too
nested_if_statements.append(token)
elif token.content.startswith(r"{% elif "):
if not nested_if_statements:
- raise TemplateSyntaxError("Missing {% if ... %}", token)
+ raise TemplateSyntaxError("No matching {% if ... %}", token)
indentation_level -= 1
function_string += (
@@ -563,14 +563,14 @@ def _create_template_rendering_function( # pylint: disable=,too-many-locals,too
indentation_level += 1
elif token.content == r"{% else %}":
if not nested_if_statements:
- raise TemplateSyntaxError("Missing {% if ... %}", token)
+ raise TemplateSyntaxError("No matching {% if ... %}", token)
indentation_level -= 1
function_string += indent * indentation_level + "else:\n"
indentation_level += 1
elif token.content == r"{% endif %}":
if not nested_if_statements:
- raise TemplateSyntaxError("Missing {% if ... %}", token)
+ raise TemplateSyntaxError("No matching {% if ... %}", token)
indentation_level -= 1
nested_if_statements.pop()
@@ -585,7 +585,7 @@ def _create_template_rendering_function( # pylint: disable=,too-many-locals,too
nested_for_loops.append(token)
elif token.content == r"{% empty %}":
if not nested_for_loops:
- raise TemplateSyntaxError("Missing {% for ... %}", token)
+ raise TemplateSyntaxError("No matching {% for ... %}", token)
indentation_level -= 1
last_forloop_iterable = (
@@ -597,7 +597,7 @@ def _create_template_rendering_function( # pylint: disable=,too-many-locals,too
indentation_level += 1
elif token.content == r"{% endfor %}":
if not nested_for_loops:
- raise TemplateSyntaxError("Missing {% for ... %}", token)
+ raise TemplateSyntaxError("No matching {% for ... %}", token)
indentation_level -= 1
nested_for_loops.pop()
@@ -612,7 +612,7 @@ def _create_template_rendering_function( # pylint: disable=,too-many-locals,too
nested_while_loops.append(token)
elif token.content == r"{% endwhile %}":
if not nested_while_loops:
- raise TemplateSyntaxError("Missing {% while ... %}", token)
+ raise TemplateSyntaxError("No matching {% while ... %}", token)
indentation_level -= 1
nested_while_loops.pop()
@@ -622,7 +622,7 @@ def _create_template_rendering_function( # pylint: disable=,too-many-locals,too
expression = token.content[8:-3]
function_string += indent * indentation_level + f"{expression}\n"
- # Token is autoescape mode change
+ # Token is a autoescape mode change
elif token.content.startswith(r"{% autoescape "):
mode = token.content[14:-3]
if mode not in ("on", "off"):
@@ -632,15 +632,15 @@ def _create_template_rendering_function( # pylint: disable=,too-many-locals,too
elif token.content == r"{% endautoescape %}":
if not nested_autoescape_modes:
- raise TemplateSyntaxError("Missing {% autoescape ... %}", token)
+ raise TemplateSyntaxError("No matching {% autoescape ... %}", token)
nested_autoescape_modes.pop()
else:
- raise TemplateSyntaxError(f"Unknown token type: {token.content}", token)
+ raise TemplateSyntaxError(f"Unknown token: {token.content}", token)
else:
- raise TemplateSyntaxError(f"Unknown token type: {token.content}", token)
+ raise TemplateSyntaxError(f"Unknown token: {token.content}", token)
# Move offset to the end of the token
offset += token_match.end()
@@ -648,15 +648,15 @@ def _create_template_rendering_function( # pylint: disable=,too-many-locals,too
# Checking for unclosed blocks
if len(nested_if_statements) > 0:
last_if_statement = nested_if_statements[-1]
- raise TemplateSyntaxError("Missing {% endif %}", last_if_statement)
+ raise TemplateSyntaxError("No matching {% endif %}", last_if_statement)
if len(nested_for_loops) > 0:
last_for_loop = nested_for_loops[-1]
- raise TemplateSyntaxError("Missing {% endfor %}", last_for_loop)
+ raise TemplateSyntaxError("No matching {% endfor %}", last_for_loop)
if len(nested_while_loops) > 0:
last_while_loop = nested_while_loops[-1]
- raise TemplateSyntaxError("Missing {% endwhile %}", last_while_loop)
+ raise TemplateSyntaxError("No matching {% endwhile %}", last_while_loop)
# No check for unclosed autoescape blocks, as they are optional and do not result in errors
From 4922aeb0d1419df9d5c48dcc3e5d2451cf166150 Mon Sep 17 00:00:00 2001
From: michalpokusa <72110769+michalpokusa@users.noreply.github.com>
Date: Sat, 9 Mar 2024 17:41:00 +0000
Subject: [PATCH 05/18] Checking for additional error in templates and some
minor code refactor
---
adafruit_templateengine.py | 55 ++++++++++++++++++++++++++++++--------
1 file changed, 44 insertions(+), 11 deletions(-)
diff --git a/adafruit_templateengine.py b/adafruit_templateengine.py
index 2a67516..3b42a7f 100644
--- a/adafruit_templateengine.py
+++ b/adafruit_templateengine.py
@@ -99,14 +99,14 @@ def _underline_token_in_template(
"""
template_before_token = token.template[: token.start_position]
- if (skipped_lines := template_before_token.count("\n") - lines_around) > 0:
+ if skipped_lines := template_before_token.count("\n") - lines_around:
template_before_token = (
f"{cls._skipped_lines_message(skipped_lines)}\n"
+ "\n".join(template_before_token.split("\n")[-(lines_around + 1) :])
)
template_after_token = token.template[token.end_position :]
- if (skipped_lines := template_after_token.count("\n") - lines_around) > 0:
+ if skipped_lines := template_after_token.count("\n") - lines_around:
template_after_token = (
"\n".join(template_after_token.split("\n")[: (lines_around + 1)])
+ f"\n{cls._skipped_lines_message(skipped_lines)}"
@@ -264,12 +264,12 @@ def _find_block(template: str):
return _BLOCK_PATTERN.search(template)
-def _find_include(template: str):
- return _INCLUDE_PATTERN.search(template)
+def _find_endblock(template: str, name: str = r"\w+?"):
+ return re.search(r"{% endblock " + name + r" %}", template)
-def _find_named_endblock(template: str, name: str):
- return re.search(r"{% endblock " + name + r" %}", template)
+def _find_include(template: str):
+ return _INCLUDE_PATTERN.search(template)
def _exists_and_is_file(path: str) -> bool:
@@ -303,11 +303,14 @@ def _resolve_includes_blocks_and_extends(template: str):
# Processing nested child templates
while (extends_match := _find_extends(template)) is not None:
- extended_template_name = extends_match.group(0)[12:-4]
+ extended_template_path = extends_match.group(0)[12:-4]
+
+ if not _exists_and_is_file(extended_template_path):
+ raise OSError(f"Template file not found: {extended_template_path}")
# Load extended template
with open(
- extended_template_name, "rt", encoding="utf-8"
+ extended_template_path, "rt", encoding="utf-8"
) as extended_template_file:
extended_template = extended_template_file.read()
@@ -316,13 +319,35 @@ def _resolve_includes_blocks_and_extends(template: str):
# Resolve includes
template = _resolve_includes(template)
+ # Check for any stacked extends
+ if stacked_extends_match := _find_extends(template[extends_match.end() :]):
+ raise TemplateSyntaxError(
+ "Incorrect use of {% extends ... %}",
+ Token(
+ template,
+ extends_match.end() + stacked_extends_match.start(),
+ extends_match.end() + stacked_extends_match.end(),
+ ),
+ )
+
# Save block replacements
while (block_match := _find_block(template[offset:])) is not None:
block_name = block_match.group(0)[9:-3]
- endblock_match = _find_named_endblock(template[offset:], block_name)
+ # Check for any unopened endblock tags before current block
+ if unopened_endblock_match := _find_endblock(
+ template[offset : offset + block_match.start()]
+ ):
+ raise TemplateSyntaxError(
+ "No matching {% block %}",
+ Token(
+ template,
+ offset + unopened_endblock_match.start(),
+ offset + unopened_endblock_match.end(),
+ ),
+ )
- if endblock_match is None:
+ if not (endblock_match := _find_endblock(template[offset:], block_name)):
raise TemplateSyntaxError(
"No matching {% endblock %}",
Token(
@@ -370,7 +395,7 @@ def _replace_blocks_with_replacements(template: str, replacements: "dict[str, st
block_name = block_match.group(0)[9:-3]
# Self-closing block tag without default content
- if (endblock_match := _find_named_endblock(template, block_name)) is None:
+ if (endblock_match := _find_endblock(template, block_name)) is None:
replacement = replacements.get(block_name, "")
template = (
@@ -636,6 +661,14 @@ def _create_template_rendering_function( # pylint: disable=,too-many-locals,too
nested_autoescape_modes.pop()
+ # Token is a endblock in top-level template
+ elif token.content.startswith(r"{% endblock "):
+ raise TemplateSyntaxError("No matching {% block ... %}", token)
+
+ # Token is a extends in top-level template
+ elif token.content.startswith(r"{% extends "):
+ raise TemplateSyntaxError("Incorrect use of {% extends ... %}", token)
+
else:
raise TemplateSyntaxError(f"Unknown token: {token.content}", token)
From 16c516085668c012ad0fec78e7601f9b13994cbc Mon Sep 17 00:00:00 2001
From: michalpokusa <72110769+michalpokusa@users.noreply.github.com>
Date: Sun, 10 Mar 2024 21:30:35 +0000
Subject: [PATCH 06/18] Checking for circular extends and tokens between blocks
---
adafruit_templateengine.py | 24 +++++++++++++++++++-----
1 file changed, 19 insertions(+), 5 deletions(-)
diff --git a/adafruit_templateengine.py b/adafruit_templateengine.py
index 3b42a7f..c3be94c 100644
--- a/adafruit_templateengine.py
+++ b/adafruit_templateengine.py
@@ -299,6 +299,7 @@ def _resolve_includes(template: str):
def _resolve_includes_blocks_and_extends(template: str):
+ extended_templates: "set[str]" = set()
block_replacements: "dict[str, str]" = {}
# Processing nested child templates
@@ -308,6 +309,19 @@ def _resolve_includes_blocks_and_extends(template: str):
if not _exists_and_is_file(extended_template_path):
raise OSError(f"Template file not found: {extended_template_path}")
+ # Check for circular extends
+ if extended_template_path in extended_templates:
+ raise TemplateSyntaxError(
+ f"Circular extends",
+ Token(
+ template,
+ extends_match.start(),
+ extends_match.end(),
+ ),
+ )
+ else:
+ extended_templates.add(extended_template_path)
+
# Load extended template
with open(
extended_template_path, "rt", encoding="utf-8"
@@ -334,16 +348,16 @@ def _resolve_includes_blocks_and_extends(template: str):
while (block_match := _find_block(template[offset:])) is not None:
block_name = block_match.group(0)[9:-3]
- # Check for any unopened endblock tags before current block
- if unopened_endblock_match := _find_endblock(
+ # Check for any tokens between blocks
+ if token_between_blocks_match := _find_token(
template[offset : offset + block_match.start()]
):
raise TemplateSyntaxError(
- "No matching {% block %}",
+ "Token between blocks",
Token(
template,
- offset + unopened_endblock_match.start(),
- offset + unopened_endblock_match.end(),
+ offset + token_between_blocks_match.start(),
+ offset + token_between_blocks_match.end(),
),
)
From 4f436529bb9ddb0136f43cfcf3240bbc2813fb7c Mon Sep 17 00:00:00 2001
From: michalpokusa <72110769+michalpokusa@users.noreply.github.com>
Date: Sun, 10 Mar 2024 21:56:40 +0000
Subject: [PATCH 07/18] Refactor: Moved _find function to top
---
adafruit_templateengine.py | 32 ++++++++++++++++----------------
1 file changed, 16 insertions(+), 16 deletions(-)
diff --git a/adafruit_templateengine.py b/adafruit_templateengine.py
index c3be94c..07ac30c 100644
--- a/adafruit_templateengine.py
+++ b/adafruit_templateengine.py
@@ -272,6 +272,22 @@ def _find_include(template: str):
return _INCLUDE_PATTERN.search(template)
+def _find_hash_comment(template: str):
+ return _HASH_COMMENT_PATTERN.search(template)
+
+
+def _find_block_comment(template: str):
+ return _BLOCK_COMMENT_PATTERN.search(template)
+
+
+def _find_token(template: str):
+ return _TOKEN_PATTERN.search(template)
+
+
+def _token_is_on_own_line(text_before_token: str) -> bool:
+ return _LSTRIP_BLOCK_PATTERN.search(text_before_token) is not None
+
+
def _exists_and_is_file(path: str) -> bool:
try:
return (os.stat(path)[0] & 0b_11110000_00000000) == 0b_10000000_00000000
@@ -456,14 +472,6 @@ def _replace_blocks_with_replacements(template: str, replacements: "dict[str, st
return template
-def _find_hash_comment(template: str):
- return _HASH_COMMENT_PATTERN.search(template)
-
-
-def _find_block_comment(template: str):
- return _BLOCK_COMMENT_PATTERN.search(template)
-
-
def _remove_comments(
template: str,
*,
@@ -497,14 +505,6 @@ def _remove_matched_comment(template: str, comment_match: re.Match):
return template
-def _find_token(template: str):
- return _TOKEN_PATTERN.search(template)
-
-
-def _token_is_on_own_line(text_before_token: str) -> bool:
- return _LSTRIP_BLOCK_PATTERN.search(text_before_token) is not None
-
-
def _create_template_rendering_function( # pylint: disable=,too-many-locals,too-many-branches,too-many-statements
template: str,
language: str = Language.HTML,
From a51ab4f235b4d293492ca9851a90a8f012d980e6 Mon Sep 17 00:00:00 2001
From: michalpokusa <72110769+michalpokusa@users.noreply.github.com>
Date: Sun, 10 Mar 2024 22:27:49 +0000
Subject: [PATCH 08/18] Added error for missing template file in FileTemplate
---
adafruit_templateengine.py | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/adafruit_templateengine.py b/adafruit_templateengine.py
index 07ac30c..fe58b53 100644
--- a/adafruit_templateengine.py
+++ b/adafruit_templateengine.py
@@ -831,6 +831,10 @@ def __init__(self, template_path: str, *, language: str = Language.HTML) -> None
:param str template_path: Path to a file containing the template to be rendered
:param str language: Language for autoescaping. Defaults to HTML
"""
+
+ if not _exists_and_is_file(template_path):
+ raise OSError(f"Template file not found: {template_path}")
+
with open(template_path, "rt", encoding="utf-8") as template_file:
template_string = template_file.read()
super().__init__(template_string, language=language)
From fc59b9433b0e74213d5f53ba1f681af0a32ae637 Mon Sep 17 00:00:00 2001
From: michalpokusa <72110769+michalpokusa@users.noreply.github.com>
Date: Mon, 11 Mar 2024 03:47:30 +0000
Subject: [PATCH 09/18] Added TemplateNotFoundError and docstring to
TemplateSyntaxError
---
adafruit_templateengine.py | 15 ++++++++++++---
docs/api.rst | 1 -
2 files changed, 12 insertions(+), 4 deletions(-)
diff --git a/adafruit_templateengine.py b/adafruit_templateengine.py
index fe58b53..24f43d4 100644
--- a/adafruit_templateengine.py
+++ b/adafruit_templateengine.py
@@ -68,10 +68,19 @@ def __init__(self, template: str, start_position: int, end_position: int):
self.content = template[start_position:end_position]
+class TemplateNotFoundError(OSError):
+ """Raised when a template file is not found."""
+
+ def __init__(self, path: str):
+ """Specified template file that was not found."""
+ super().__init__(f"Template file not found: {path}")
+
+
class TemplateSyntaxError(SyntaxError):
"""Raised when a syntax error is encountered in a template."""
def __init__(self, message: str, token: Token):
+ """Provided token is not a valid template syntax at the specified position."""
super().__init__(f"{message}\n\n" + self._underline_token_in_template(token))
@staticmethod
@@ -302,7 +311,7 @@ def _resolve_includes(template: str):
# TODO: Restrict include to specific directory
if not _exists_and_is_file(template_path):
- raise OSError(f"Template file not found: {template_path}")
+ raise TemplateNotFoundError(template_path)
# Replace the include with the template content
with open(template_path, "rt", encoding="utf-8") as template_file:
@@ -323,7 +332,7 @@ def _resolve_includes_blocks_and_extends(template: str):
extended_template_path = extends_match.group(0)[12:-4]
if not _exists_and_is_file(extended_template_path):
- raise OSError(f"Template file not found: {extended_template_path}")
+ raise TemplateNotFoundError(extended_template_path)
# Check for circular extends
if extended_template_path in extended_templates:
@@ -833,7 +842,7 @@ def __init__(self, template_path: str, *, language: str = Language.HTML) -> None
"""
if not _exists_and_is_file(template_path):
- raise OSError(f"Template file not found: {template_path}")
+ raise TemplateNotFoundError(template_path)
with open(template_path, "rt", encoding="utf-8") as template_file:
template_string = template_file.read()
diff --git a/docs/api.rst b/docs/api.rst
index ff79193..ea4ed84 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -6,4 +6,3 @@
.. automodule:: adafruit_templateengine
:members:
- :inherited-members:
From 60e4615c6030bd16dfe1e20e6f201011b5eda316 Mon Sep 17 00:00:00 2001
From: michalpokusa <72110769+michalpokusa@users.noreply.github.com>
Date: Mon, 11 Mar 2024 04:00:20 +0000
Subject: [PATCH 10/18] CI fixes and slight change in TemplateSyntaxError
---
adafruit_templateengine.py | 51 +++++++++++++++++++-------------------
1 file changed, 25 insertions(+), 26 deletions(-)
diff --git a/adafruit_templateengine.py b/adafruit_templateengine.py
index 24f43d4..87f4edb 100644
--- a/adafruit_templateengine.py
+++ b/adafruit_templateengine.py
@@ -79,9 +79,9 @@ def __init__(self, path: str):
class TemplateSyntaxError(SyntaxError):
"""Raised when a syntax error is encountered in a template."""
- def __init__(self, message: str, token: Token):
+ def __init__(self, token: Token, reason: str):
"""Provided token is not a valid template syntax at the specified position."""
- super().__init__(f"{message}\n\n" + self._underline_token_in_template(token))
+ super().__init__(self._underline_token_in_template(token) + f"\n\n{reason}")
@staticmethod
def _skipped_lines_message(nr_of_lines: int) -> str:
@@ -92,8 +92,8 @@ def _underline_token_in_template(
cls, token: Token, *, lines_around: int = 4, symbol: str = "^"
) -> str:
"""
- Return ``number_of_lines`` lines before and after the token, with the token content underlined
- with ``symbol`` e.g.:
+ Return ``number_of_lines`` lines before and after ``token``, with the token content
+ underlined with ``symbol`` e.g.:
```html
[8 lines skipped]
@@ -337,17 +337,16 @@ def _resolve_includes_blocks_and_extends(template: str):
# Check for circular extends
if extended_template_path in extended_templates:
raise TemplateSyntaxError(
- f"Circular extends",
Token(
template,
extends_match.start(),
extends_match.end(),
),
+ "Circular extends",
)
- else:
- extended_templates.add(extended_template_path)
# Load extended template
+ extended_templates.add(extended_template_path)
with open(
extended_template_path, "rt", encoding="utf-8"
) as extended_template_file:
@@ -361,12 +360,12 @@ def _resolve_includes_blocks_and_extends(template: str):
# Check for any stacked extends
if stacked_extends_match := _find_extends(template[extends_match.end() :]):
raise TemplateSyntaxError(
- "Incorrect use of {% extends ... %}",
Token(
template,
extends_match.end() + stacked_extends_match.start(),
extends_match.end() + stacked_extends_match.end(),
),
+ "Incorrect use of {% extends ... %}",
)
# Save block replacements
@@ -378,22 +377,22 @@ def _resolve_includes_blocks_and_extends(template: str):
template[offset : offset + block_match.start()]
):
raise TemplateSyntaxError(
- "Token between blocks",
Token(
template,
offset + token_between_blocks_match.start(),
offset + token_between_blocks_match.end(),
),
+ "Token between blocks",
)
if not (endblock_match := _find_endblock(template[offset:], block_name)):
raise TemplateSyntaxError(
- "No matching {% endblock %}",
Token(
template,
offset + block_match.start(),
offset + block_match.end(),
),
+ "No matching {% endblock %}",
)
block_content = template[
@@ -403,12 +402,12 @@ def _resolve_includes_blocks_and_extends(template: str):
# Check for unsupported nested blocks
if (nested_block_match := _find_block(block_content)) is not None:
raise TemplateSyntaxError(
- "Nested blocks are not supported",
Token(
template,
offset + block_match.end() + nested_block_match.start(),
offset + block_match.end() + nested_block_match.end(),
),
+ "Nested blocks are not supported",
)
if block_name in block_replacements:
@@ -450,12 +449,12 @@ def _replace_blocks_with_replacements(template: str, replacements: "dict[str, st
# Check for unsupported nested blocks
if (nested_block_match := _find_block(block_content)) is not None:
raise TemplateSyntaxError(
- "Nested blocks are not supported",
Token(
template,
block_match.end() + nested_block_match.start(),
block_match.end() + nested_block_match.end(),
),
+ "Nested blocks are not supported",
)
# No replacement for this block, use default content
@@ -602,7 +601,7 @@ def _create_template_rendering_function( # pylint: disable=,too-many-locals,too
nested_if_statements.append(token)
elif token.content.startswith(r"{% elif "):
if not nested_if_statements:
- raise TemplateSyntaxError("No matching {% if ... %}", token)
+ raise TemplateSyntaxError(token, "No matching {% if ... %}")
indentation_level -= 1
function_string += (
@@ -611,14 +610,14 @@ def _create_template_rendering_function( # pylint: disable=,too-many-locals,too
indentation_level += 1
elif token.content == r"{% else %}":
if not nested_if_statements:
- raise TemplateSyntaxError("No matching {% if ... %}", token)
+ raise TemplateSyntaxError(token, "No matching {% if ... %}")
indentation_level -= 1
function_string += indent * indentation_level + "else:\n"
indentation_level += 1
elif token.content == r"{% endif %}":
if not nested_if_statements:
- raise TemplateSyntaxError("No matching {% if ... %}", token)
+ raise TemplateSyntaxError(token, "No matching {% if ... %}")
indentation_level -= 1
nested_if_statements.pop()
@@ -633,7 +632,7 @@ def _create_template_rendering_function( # pylint: disable=,too-many-locals,too
nested_for_loops.append(token)
elif token.content == r"{% empty %}":
if not nested_for_loops:
- raise TemplateSyntaxError("No matching {% for ... %}", token)
+ raise TemplateSyntaxError(token, "No matching {% for ... %}")
indentation_level -= 1
last_forloop_iterable = (
@@ -645,7 +644,7 @@ def _create_template_rendering_function( # pylint: disable=,too-many-locals,too
indentation_level += 1
elif token.content == r"{% endfor %}":
if not nested_for_loops:
- raise TemplateSyntaxError("No matching {% for ... %}", token)
+ raise TemplateSyntaxError(token, "No matching {% for ... %}")
indentation_level -= 1
nested_for_loops.pop()
@@ -660,7 +659,7 @@ def _create_template_rendering_function( # pylint: disable=,too-many-locals,too
nested_while_loops.append(token)
elif token.content == r"{% endwhile %}":
if not nested_while_loops:
- raise TemplateSyntaxError("No matching {% while ... %}", token)
+ raise TemplateSyntaxError(token, "No matching {% while ... %}")
indentation_level -= 1
nested_while_loops.pop()
@@ -680,23 +679,23 @@ def _create_template_rendering_function( # pylint: disable=,too-many-locals,too
elif token.content == r"{% endautoescape %}":
if not nested_autoescape_modes:
- raise TemplateSyntaxError("No matching {% autoescape ... %}", token)
+ raise TemplateSyntaxError(token, "No matching {% autoescape ... %}")
nested_autoescape_modes.pop()
# Token is a endblock in top-level template
elif token.content.startswith(r"{% endblock "):
- raise TemplateSyntaxError("No matching {% block ... %}", token)
+ raise TemplateSyntaxError(token, "No matching {% block ... %}")
# Token is a extends in top-level template
elif token.content.startswith(r"{% extends "):
- raise TemplateSyntaxError("Incorrect use of {% extends ... %}", token)
+ raise TemplateSyntaxError(token, "Incorrect use of {% extends ... %}")
else:
- raise TemplateSyntaxError(f"Unknown token: {token.content}", token)
+ raise TemplateSyntaxError(token, f"Unknown token: {token.content}")
else:
- raise TemplateSyntaxError(f"Unknown token: {token.content}", token)
+ raise TemplateSyntaxError(token, f"Unknown token: {token.content}")
# Move offset to the end of the token
offset += token_match.end()
@@ -704,15 +703,15 @@ def _create_template_rendering_function( # pylint: disable=,too-many-locals,too
# Checking for unclosed blocks
if len(nested_if_statements) > 0:
last_if_statement = nested_if_statements[-1]
- raise TemplateSyntaxError("No matching {% endif %}", last_if_statement)
+ raise TemplateSyntaxError(last_if_statement, "No matching {% endif %}")
if len(nested_for_loops) > 0:
last_for_loop = nested_for_loops[-1]
- raise TemplateSyntaxError("No matching {% endfor %}", last_for_loop)
+ raise TemplateSyntaxError(last_for_loop, "No matching {% endfor %}")
if len(nested_while_loops) > 0:
last_while_loop = nested_while_loops[-1]
- raise TemplateSyntaxError("No matching {% endwhile %}", last_while_loop)
+ raise TemplateSyntaxError(last_while_loop, "No matching {% endwhile %}")
# No check for unclosed autoescape blocks, as they are optional and do not result in errors
From 407430ffc849fd7b1b37145991829fda6fe86fad Mon Sep 17 00:00:00 2001
From: michalpokusa <72110769+michalpokusa@users.noreply.github.com>
Date: Tue, 12 Mar 2024 03:02:45 +0000
Subject: [PATCH 11/18] Addded caching for render_... functions
---
adafruit_templateengine.py | 68 ++++++++++++++++++++++++++++++++++----
1 file changed, 62 insertions(+), 6 deletions(-)
diff --git a/adafruit_templateengine.py b/adafruit_templateengine.py
index 87f4edb..623a205 100644
--- a/adafruit_templateengine.py
+++ b/adafruit_templateengine.py
@@ -848,12 +848,16 @@ def __init__(self, template_path: str, *, language: str = Language.HTML) -> None
super().__init__(template_string, language=language)
+_CACHE: "dict[int, Template| FileTemplate]" = {}
+
+
def render_string_iter(
template_string: str,
context: dict = None,
*,
chunk_size: int = None,
language: str = Language.HTML,
+ cache: bool = True,
):
"""
Creates a `Template` from the given ``template_string`` and renders it using the provided
@@ -863,6 +867,7 @@ def render_string_iter(
:param int chunk_size: Size of the chunks to be yielded. If ``None``, the generator yields
the template in chunks sized specifically for the given template
:param str language: Language for autoescaping. Defaults to HTML
+ :param bool cache: When ``True``, the template is saved and reused on next calls.
Example::
@@ -872,8 +877,20 @@ def render_string_iter(
list(render_string_iter(r"Hello {{ name }}!", {"name": "CircuitPython"}, chunk_size=3))
# ['Hel', 'lo ', 'Cir', 'cui', 'tPy', 'tho', 'n!']
"""
- return Template(template_string, language=language).render_iter(
- context or {}, chunk_size=chunk_size
+ key = hash(template_string)
+
+ if cache and key in _CACHE:
+ return _yield_as_sized_chunks(
+ _CACHE[key].render_iter(context or {}, chunk_size), chunk_size
+ )
+
+ template = Template(template_string, language=language)
+
+ if cache:
+ _CACHE[key] = template
+
+ return _yield_as_sized_chunks(
+ template.render_iter(context or {}), chunk_size=chunk_size
)
@@ -882,6 +899,7 @@ def render_string(
context: dict = None,
*,
language: str = Language.HTML,
+ cache: bool = True,
):
"""
Creates a `Template` from the given ``template_string`` and renders it using the provided
@@ -889,13 +907,24 @@ def render_string(
:param dict context: Dictionary containing the context for the template
:param str language: Language for autoescaping. Defaults to HTML
+ :param bool cache: When ``True``, the template is saved and reused on next calls.
Example::
render_string(r"Hello {{ name }}!", {"name": "World"})
# 'Hello World!'
"""
- return Template(template_string, language=language).render(context or {})
+ key = hash(template_string)
+
+ if cache and key in _CACHE:
+ return _CACHE[key].render(context or {})
+
+ template = Template(template_string, language=language)
+
+ if cache:
+ _CACHE[key] = template
+
+ return template.render(context or {})
def render_template_iter(
@@ -904,6 +933,7 @@ def render_template_iter(
*,
chunk_size: int = None,
language: str = Language.HTML,
+ cache: bool = True,
):
"""
Creates a `FileTemplate` from the given ``template_path`` and renders it using the provided
@@ -913,6 +943,7 @@ def render_template_iter(
:param int chunk_size: Size of the chunks to be yielded. If ``None``, the generator yields
the template in chunks sized specifically for the given template
:param str language: Language for autoescaping. Defaults to HTML
+ :param bool cache: When ``True``, the template is saved and reused on next calls.
Example::
@@ -922,8 +953,20 @@ def render_template_iter(
list(render_template_iter(..., {"name": "CircuitPython"}, chunk_size=3))
# ['Hel', 'lo ', 'Cir', 'cui', 'tPy', 'tho', 'n!']
"""
- return FileTemplate(template_path, language=language).render_iter(
- context or {}, chunk_size=chunk_size
+ key = hash(template_path)
+
+ if cache and key in _CACHE:
+ return _yield_as_sized_chunks(
+ _CACHE[key].render_iter(context or {}, chunk_size), chunk_size
+ )
+
+ template = FileTemplate(template_path, language=language)
+
+ if cache:
+ _CACHE[key] = template
+
+ return _yield_as_sized_chunks(
+ template.render_iter(context or {}, chunk_size=chunk_size), chunk_size
)
@@ -932,6 +975,7 @@ def render_template(
context: dict = None,
*,
language: str = Language.HTML,
+ cache: bool = True,
):
"""
Creates a `FileTemplate` from the given ``template_path`` and renders it using the provided
@@ -939,10 +983,22 @@ def render_template(
:param dict context: Dictionary containing the context for the template
:param str language: Language for autoescaping. Defaults to HTML
+ :param bool cache: When ``True``, the template is saved and reused on next calls.
Example::
render_template(..., {"name": "World"}) # r"Hello {{ name }}!"
# 'Hello World!'
"""
- return FileTemplate(template_path, language=language).render(context or {})
+
+ key = hash(template_path)
+
+ if cache and key in _CACHE:
+ return _CACHE[key].render(context or {})
+
+ template = FileTemplate(template_path, language=language)
+
+ if cache:
+ _CACHE[key] = template
+
+ return template.render(context or {})
From 089ccdf7fea0f1d4c0770d8e0ccbc0d9097b433c Mon Sep 17 00:00:00 2001
From: michalpokusa <72110769+michalpokusa@users.noreply.github.com>
Date: Tue, 12 Mar 2024 03:32:24 +0000
Subject: [PATCH 12/18] Updated docs and example for reusing templates
---
docs/examples.rst | 35 ++++++++++--------------------
examples/templateengine_reusing.py | 28 ++++++++++++++++++++----
2 files changed, 36 insertions(+), 27 deletions(-)
diff --git a/docs/examples.rst b/docs/examples.rst
index 76a63fc..3c1e79d 100644
--- a/docs/examples.rst
+++ b/docs/examples.rst
@@ -9,30 +9,28 @@ This example is printing a basic HTML page with with a dynamic paragraph.
:lines: 5-
:linenos:
-Reusing templates
------------------
+Caching/Reusing templates
+-------------------------
-The are two main ways of rendering templates:
+The are two ways of rendering templates:
+- manually creating a ``Template`` or ``FileTemplate`` object and calling its method
- using one of ``render_...`` methods
-- manually creating a ``Template`` object and calling its method
-While the first method is simpler, it also compiles the template on every call.
-The second method is more efficient when rendering the same template multiple times, as it allows
-to reuse the compiled template, at the cost of more memory usage.
-Both methods can be used interchangeably, as they both return the same result.
-It is up to the user to decide which method is more suitable for a given use case.
+By dafault, the ``render_...`` methods cache the template and reuse it on next calls.
+This speeds up the rendering process, but also uses more memory.
-**Generally, the first method will be sufficient for most use cases.**
+If for some reason the caching is not desired, you can disable it by passing ``cache=False`` to
+the ``render_...`` method. This will cause the template to be recreated on every call, which is slower,
+but uses less memory. This might be useful when rendering a large number of different templates that
+might not fit in the memory at the same time or are not used often enough to justify caching them.
-It is also worth noting that compiling all used templates using the second method might not be possible,
-depending on the project and board used, due to the limited amount of RAM.
.. literalinclude:: ../examples/templateengine_reusing.py
:caption: examples/templateengine_reusing.py
:lines: 5-
- :emphasize-lines: 1,16,20
+ :emphasize-lines: 22,27,34
:linenos:
Expressions
@@ -234,7 +232,7 @@ Autoescaping unsafe characters
------------------------------
Token ``{% autoescape off %} ... {% endautoescape %}`` is used for marking a block of code that should
-be not be autoescaped. Consequently using ``{% autoescape off %} ...`` does the opposite and turns
+be not be autoescaped. Consequently using ``{% autoescape on %} ...`` does the opposite and turns
the autoescaping back on.
By default the template engine will escape all HTML-unsafe characters in expressions
@@ -242,21 +240,12 @@ By default the template engine will escape all HTML-unsafe characters in express
Content outside expressions is not escaped and is rendered as-is.
-For escaping XML and Markdown, you can use the ``language=`` parameter, both in ``render_...`` methods
-and in all ``Template`` constructors.
-
.. literalinclude:: ../examples/autoescape.html
:caption: examples/autoescape.html
:lines: 7-
:language: html
:linenos:
-.. literalinclude:: ../examples/autoescape.md
- :caption: examples/autoescape.md
- :lines: 5-
- :language: markdown
- :linenos:
-
.. literalinclude:: ../examples/templateengine_autoescape.py
:caption: examples/templateengine_autoescape.py
:lines: 5-
diff --git a/examples/templateengine_reusing.py b/examples/templateengine_reusing.py
index 0dd1429..5ccb7c8 100644
--- a/examples/templateengine_reusing.py
+++ b/examples/templateengine_reusing.py
@@ -2,7 +2,7 @@
#
# SPDX-License-Identifier: Unlicense
-from adafruit_templateengine import Template
+from adafruit_templateengine import Template, render_string
template_string = r"""
@@ -17,8 +17,28 @@