diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..21c125c
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,11 @@
+# SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries
+#
+# SPDX-License-Identifier: Unlicense
+
+.py text eol=lf
+.rst text eol=lf
+.txt text eol=lf
+.yaml text eol=lf
+.toml text eol=lf
+.license text eol=lf
+.md text eol=lf
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 70ade69..ff19dde 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,42 +1,21 @@
-# SPDX-FileCopyrightText: 2020 Diego Elio Pettenò
+# SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries
#
# SPDX-License-Identifier: Unlicense
repos:
- - repo: https://github.com/python/black
- rev: 23.3.0
- hooks:
- - id: black
- - repo: https://github.com/fsfe/reuse-tool
- rev: v1.1.2
- hooks:
- - id: reuse
- repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v4.4.0
+ rev: v4.5.0
hooks:
- id: check-yaml
- id: end-of-file-fixer
- id: trailing-whitespace
- - repo: https://github.com/pycqa/pylint
- rev: v2.17.4
+ - repo: https://github.com/astral-sh/ruff-pre-commit
+ rev: v0.3.4
hooks:
- - id: pylint
- name: pylint (library code)
- types: [python]
- args:
- - --disable=consider-using-f-string
- exclude: "^(docs/|examples/|tests/|setup.py$)"
- - id: pylint
- name: pylint (example code)
- description: Run pylint rules on "examples/*.py" files
- types: [python]
- files: "^examples/"
- args:
- - --disable=missing-docstring,invalid-name,consider-using-f-string,duplicate-code
- - id: pylint
- name: pylint (test code)
- description: Run pylint rules on "tests/*.py" files
- types: [python]
- files: "^tests/"
- args:
- - --disable=missing-docstring,consider-using-f-string,duplicate-code
+ - id: ruff-format
+ - id: ruff
+ args: ["--fix"]
+ - repo: https://github.com/fsfe/reuse-tool
+ rev: v3.0.1
+ hooks:
+ - id: reuse
diff --git a/.pylintrc b/.pylintrc
deleted file mode 100644
index f945e92..0000000
--- a/.pylintrc
+++ /dev/null
@@ -1,399 +0,0 @@
-# SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries
-#
-# SPDX-License-Identifier: Unlicense
-
-[MASTER]
-
-# A comma-separated list of package or module names from where C extensions may
-# be loaded. Extensions are loading into the active Python interpreter and may
-# run arbitrary code
-extension-pkg-whitelist=
-
-# Add files or directories to the ignore-list. They should be base names, not
-# paths.
-ignore=CVS
-
-# Add files or directories matching the regex patterns to the ignore-list. The
-# regex matches against base names, not paths.
-ignore-patterns=
-
-# Python code to execute, usually for sys.path manipulation such as
-# pygtk.require().
-#init-hook=
-
-# Use multiple processes to speed up Pylint.
-jobs=1
-
-# List of plugins (as comma separated values of python modules names) to load,
-# usually to register additional checkers.
-load-plugins=pylint.extensions.no_self_use
-
-# Pickle collected data for later comparisons.
-persistent=yes
-
-# Specify a configuration file.
-#rcfile=
-
-# Allow loading of arbitrary C extensions. Extensions are imported into the
-# active Python interpreter and may run arbitrary code.
-unsafe-load-any-extension=no
-
-
-[MESSAGES CONTROL]
-
-# Only show warnings with the listed confidence levels. Leave empty to show
-# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED
-confidence=
-
-# Disable the message, report, category or checker with the given id(s). You
-# can either give multiple identifiers separated by comma (,) or put this
-# option multiple times (only on the command line, not in the configuration
-# file where it should appear only once).You can also use "--disable=all" to
-# disable everything first and then reenable specific checks. For example, if
-# you want to run only the similarities checker, you can use "--disable=all
-# --enable=similarities". If you want to run only the classes checker, but have
-# no Warning level messages displayed, use"--disable=all --enable=classes
-# --disable=W"
-# disable=import-error,raw-checker-failed,bad-inline-option,locally-disabled,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,deprecated-str-translate-call
-disable=raw-checker-failed,bad-inline-option,locally-disabled,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,import-error,pointless-string-statement,unspecified-encoding
-
-# Enable the message, report, category or checker with the given id(s). You can
-# either give multiple identifier separated by comma (,) or put this option
-# multiple time (only on the command line, not in the configuration file where
-# it should appear only once). See also the "--disable" option for examples.
-enable=
-
-
-[REPORTS]
-
-# Python expression which should return a note less than 10 (10 is the highest
-# note). You have access to the variables errors warning, statement which
-# respectively contain the number of errors / warnings messages and the total
-# number of statements analyzed. This is used by the global evaluation report
-# (RP0004).
-evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)
-
-# Template used to display messages. This is a python new-style format string
-# used to format the message information. See doc for all details
-#msg-template=
-
-# Set the output format. Available formats are text, parseable, colorized, json
-# and msvs (visual studio).You can also give a reporter class, eg
-# mypackage.mymodule.MyReporterClass.
-output-format=text
-
-# Tells whether to display a full report or only the messages
-reports=no
-
-# Activate the evaluation score.
-score=yes
-
-
-[REFACTORING]
-
-# Maximum number of nested blocks for function / method body
-max-nested-blocks=5
-
-
-[LOGGING]
-
-# Logging modules to check that the string format arguments are in logging
-# function parameter format
-logging-modules=logging
-
-
-[SPELLING]
-
-# Spelling dictionary name. Available dictionaries: none. To make it working
-# install python-enchant package.
-spelling-dict=
-
-# List of comma separated words that should not be checked.
-spelling-ignore-words=
-
-# A path to a file that contains private dictionary; one word per line.
-spelling-private-dict-file=
-
-# Tells whether to store unknown words to indicated private dictionary in
-# --spelling-private-dict-file option instead of raising a message.
-spelling-store-unknown-words=no
-
-
-[MISCELLANEOUS]
-
-# List of note tags to take in consideration, separated by a comma.
-# notes=FIXME,XXX,TODO
-notes=FIXME,XXX
-
-
-[TYPECHECK]
-
-# List of decorators that produce context managers, such as
-# contextlib.contextmanager. Add to this list to register other decorators that
-# produce valid context managers.
-contextmanager-decorators=contextlib.contextmanager
-
-# List of members which are set dynamically and missed by pylint inference
-# system, and so shouldn't trigger E1101 when accessed. Python regular
-# expressions are accepted.
-generated-members=
-
-# Tells whether missing members accessed in mixin class should be ignored. A
-# mixin class is detected if its name ends with "mixin" (case insensitive).
-ignore-mixin-members=yes
-
-# This flag controls whether pylint should warn about no-member and similar
-# checks whenever an opaque object is returned when inferring. The inference
-# can return multiple potential results while evaluating a Python object, but
-# some branches might not be evaluated, which results in partial inference. In
-# that case, it might be useful to still emit no-member and other checks for
-# the rest of the inferred objects.
-ignore-on-opaque-inference=yes
-
-# List of class names for which member attributes should not be checked (useful
-# for classes with dynamically set attributes). This supports the use of
-# qualified names.
-ignored-classes=optparse.Values,thread._local,_thread._local
-
-# List of module names for which member attributes should not be checked
-# (useful for modules/projects where namespaces are manipulated during runtime
-# and thus existing member attributes cannot be deduced by static analysis. It
-# supports qualified module names, as well as Unix pattern matching.
-ignored-modules=board
-
-# Show a hint with possible names when a member name was not found. The aspect
-# of finding the hint is based on edit distance.
-missing-member-hint=yes
-
-# The minimum edit distance a name should have in order to be considered a
-# similar match for a missing member name.
-missing-member-hint-distance=1
-
-# The total number of similar names that should be taken in consideration when
-# showing a hint for a missing member.
-missing-member-max-choices=1
-
-
-[VARIABLES]
-
-# List of additional names supposed to be defined in builtins. Remember that
-# you should avoid to define new builtins when possible.
-additional-builtins=
-
-# Tells whether unused global variables should be treated as a violation.
-allow-global-unused-variables=yes
-
-# List of strings which can identify a callback function by name. A callback
-# name must start or end with one of those strings.
-callbacks=cb_,_cb
-
-# A regular expression matching the name of dummy variables (i.e. expectedly
-# not used).
-dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
-
-# Argument names that match this expression will be ignored. Default to name
-# with leading underscore
-ignored-argument-names=_.*|^ignored_|^unused_
-
-# Tells whether we should check for unused import in __init__ files.
-init-import=no
-
-# List of qualified module names which can have objects that can redefine
-# builtins.
-redefining-builtins-modules=six.moves,future.builtins
-
-
-[FORMAT]
-
-# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
-# expected-line-ending-format=
-expected-line-ending-format=LF
-
-# Regexp for a line that is allowed to be longer than the limit.
-ignore-long-lines=^\s*(# )??$
-
-# Number of spaces of indent required inside a hanging or continued line.
-indent-after-paren=4
-
-# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
-# tab).
-indent-string=' '
-
-# Maximum number of characters on a single line.
-max-line-length=100
-
-# Maximum number of lines in a module
-max-module-lines=1000
-
-# Allow the body of a class to be on the same line as the declaration if body
-# contains single statement.
-single-line-class-stmt=no
-
-# Allow the body of an if to be on the same line as the test if there is no
-# else.
-single-line-if-stmt=no
-
-
-[SIMILARITIES]
-
-# Ignore comments when computing similarities.
-ignore-comments=yes
-
-# Ignore docstrings when computing similarities.
-ignore-docstrings=yes
-
-# Ignore imports when computing similarities.
-ignore-imports=yes
-
-# Minimum lines number of a similarity.
-min-similarity-lines=12
-
-
-[BASIC]
-
-# Regular expression matching correct argument names
-argument-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$
-
-# Regular expression matching correct attribute names
-attr-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$
-
-# Bad variable names which should always be refused, separated by a comma
-bad-names=foo,bar,baz,toto,tutu,tata
-
-# Regular expression matching correct class attribute names
-class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$
-
-# Regular expression matching correct class names
-# class-rgx=[A-Z_][a-zA-Z0-9]+$
-class-rgx=[A-Z_][a-zA-Z0-9_]+$
-
-# Regular expression matching correct constant names
-const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$
-
-# Minimum line length for functions/classes that require docstrings, shorter
-# ones are exempt.
-docstring-min-length=-1
-
-# Regular expression matching correct function names
-function-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$
-
-# Good variable names which should always be accepted, separated by a comma
-# good-names=i,j,k,ex,Run,_
-good-names=r,g,b,w,i,j,k,n,x,y,z,ex,ok,Run,_
-
-# Include a hint for the correct naming format with invalid-name
-include-naming-hint=no
-
-# Regular expression matching correct inline iteration names
-inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$
-
-# Regular expression matching correct method names
-method-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$
-
-# Regular expression matching correct module names
-module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$
-
-# Colon-delimited sets of names that determine each other's naming style when
-# the name regexes allow several styles.
-name-group=
-
-# Regular expression which should only match function or class names that do
-# not require a docstring.
-no-docstring-rgx=^_
-
-# List of decorators that produce properties, such as abc.abstractproperty. Add
-# to this list to register other decorators that produce valid properties.
-property-classes=abc.abstractproperty
-
-# Regular expression matching correct variable names
-variable-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$
-
-
-[IMPORTS]
-
-# Allow wildcard imports from modules that define __all__.
-allow-wildcard-with-all=no
-
-# Analyse import fallback blocks. This can be used to support both Python 2 and
-# 3 compatible code, which means that the block might have code that exists
-# only in one or another interpreter, leading to false positives when analysed.
-analyse-fallback-blocks=no
-
-# Deprecated modules which should not be used, separated by a comma
-deprecated-modules=optparse,tkinter.tix
-
-# Create a graph of external dependencies in the given file (report RP0402 must
-# not be disabled)
-ext-import-graph=
-
-# Create a graph of every (i.e. internal and external) dependencies in the
-# given file (report RP0402 must not be disabled)
-import-graph=
-
-# Create a graph of internal dependencies in the given file (report RP0402 must
-# not be disabled)
-int-import-graph=
-
-# Force import order to recognize a module as part of the standard
-# compatibility libraries.
-known-standard-library=
-
-# Force import order to recognize a module as part of a third party library.
-known-third-party=enchant
-
-
-[CLASSES]
-
-# List of method names used to declare (i.e. assign) instance attributes.
-defining-attr-methods=__init__,__new__,setUp
-
-# List of member names, which should be excluded from the protected access
-# warning.
-exclude-protected=_asdict,_fields,_replace,_source,_make
-
-# List of valid names for the first argument in a class method.
-valid-classmethod-first-arg=cls
-
-# List of valid names for the first argument in a metaclass class method.
-valid-metaclass-classmethod-first-arg=mcs
-
-
-[DESIGN]
-
-# Maximum number of arguments for function / method
-max-args=5
-
-# Maximum number of attributes for a class (see R0902).
-# max-attributes=7
-max-attributes=11
-
-# Maximum number of boolean expressions in a if statement
-max-bool-expr=5
-
-# Maximum number of branch for function / method body
-max-branches=12
-
-# Maximum number of locals for function / method body
-max-locals=15
-
-# Maximum number of parents for a class (see R0901).
-max-parents=7
-
-# Maximum number of public methods for a class (see R0904).
-max-public-methods=20
-
-# Maximum number of return / yield for function / method body
-max-returns=6
-
-# Maximum number of statements in function / method body
-max-statements=50
-
-# Minimum number of public methods for a class (see R0903).
-min-public-methods=1
-
-
-[EXCEPTIONS]
-
-# Exceptions that will emit a warning when being caught. Defaults to
-# "Exception"
-overgeneral-exceptions=builtins.Exception
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
index b79ec5b..fe4faae 100644
--- a/.readthedocs.yaml
+++ b/.readthedocs.yaml
@@ -8,6 +8,9 @@
# Required
version: 2
+sphinx:
+ configuration: docs/conf.py
+
build:
os: ubuntu-20.04
tools:
diff --git a/README.rst b/README.rst
index 2a95cde..606ca76 100644
--- a/README.rst
+++ b/README.rst
@@ -17,9 +17,9 @@ Introduction
:alt: Build Status
-.. image:: https://img.shields.io/badge/code%20style-black-000000.svg
- :target: https://github.com/psf/black
- :alt: Code Style: Black
+.. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json
+ :target: https://github.com/astral-sh/ruff
+ :alt: Code Style: Ruff
Templating engine to substitute variables into a template string. Templates can also include conditional logic and loops. Often used for web pages.
@@ -30,7 +30,7 @@ but it does not implement all of their features and takes a different approach t
Main diffrences from Jinja2 and Django Templates:
-- filter are not supported, and there is no plan to support them
+- filters are not supported, and there is no plan to support them
- all variables passed inside context must be accessed using the ``context`` object
- you can call methods inside templates just like in Python
- no support for nested blocks, although inheritance is supported
diff --git a/adafruit_templateengine.py b/adafruit_templateengine.py
index b5c6394..02ad530 100644
--- a/adafruit_templateengine.py
+++ b/adafruit_templateengine.py
@@ -29,20 +29,111 @@
import os
import re
+try:
+ from sys import implementation
-class Language: # pylint: disable=too-few-public-methods
- """
- Enum-like class that contains languages supported for escaping.
- """
+ if implementation.name == "circuitpython" and implementation.version < (9, 0, 0):
+ print(
+ "Warning: adafruit_templateengine requires CircuitPython 9.0.0, as previous versions"
+ " will have limited functionality when using block comments and non-ASCII characters."
+ )
+finally:
+ # Unimport sys to prevent accidental use
+ del implementation
+
+
+class Token:
+ """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]
+
+
+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, token: Token, reason: str):
+ """Provided token is not a valid template syntax at the specified position."""
+ super().__init__(self._underline_token_in_template(token) + f"\n\n{reason}")
+
+ @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(
+ cls, token: Token, *, lines_around: int = 4, symbol: str = "^"
+ ) -> str:
+ """
+ Return ``number_of_lines`` lines before and after ``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 top_skipped_lines := template_before_token.count("\n") - lines_around:
+ template_before_token = "\n".join(
+ template_before_token.split("\n")[-(lines_around + 1) :]
+ )
- HTML = "html"
- """HTML language"""
+ if 0 < top_skipped_lines:
+ top_skipped_lines_message = cls._skipped_lines_message(top_skipped_lines)
+ template_before_token = f"{top_skipped_lines_message}\n{template_before_token}"
- XML = "xml"
- """XML language"""
+ template_after_token = token.template[token.end_position :]
+ if bottom_skipped_lines := template_after_token.count("\n") - lines_around:
+ template_after_token = "\n".join(template_after_token.split("\n")[: (lines_around + 1)])
- MARKDOWN = "markdown"
- """Markdown language"""
+ if 0 < bottom_skipped_lines:
+ bottom_skipped_lines_message = cls._skipped_lines_message(bottom_skipped_lines)
+ template_after_token = f"{template_after_token}\n{bottom_skipped_lines_message}"
+
+ 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" + "\n".join(
+ [
+ lines_before_line_with_token,
+ line_with_token,
+ line_with_underline,
+ lines_after_line_with_token,
+ ]
+ )
def safe_html(value: Any) -> str:
@@ -59,12 +150,12 @@ def safe_html(value: Any) -> str:
# 1e−10
"""
- def replace_amp_or_semi(match: re.Match):
+ def _replace_amp_or_semi(match: re.Match):
return "&" if match.group(0) == "&" else ";"
return (
# Replace initial & and ; together
- re.sub(r"&|;", replace_amp_or_semi, str(value))
+ re.sub(r"&|;", _replace_amp_or_semi, str(value))
# Replace other characters
.replace('"', """)
.replace("_", "_")
@@ -99,86 +190,57 @@ def replace_amp_or_semi(match: re.Match):
)
-def safe_xml(value: Any) -> str:
- """
- Encodes unsafe symbols in ``value`` to XML entities and returns the string that can be safely
- used in XML.
+_EXTENDS_PATTERN = re.compile(r"{% extends '.+?' %}|{% extends \".+?\" %}")
+_BLOCK_PATTERN = re.compile(r"{% block \w+? %}")
+_INCLUDE_PATTERN = re.compile(r"{% include '.+?' %}|{% include \".+?\" %}")
+_HASH_COMMENT_PATTERN = re.compile(r"{# .+? #}")
+_BLOCK_COMMENT_PATTERN = re.compile(r"{% comment ('.*?' |\".*?\" )?%}[\s\S]*?{% endcomment %}")
+_TOKEN_PATTERN = re.compile(r"{{ .+? }}|{% .+? %}")
+_LSTRIP_BLOCK_PATTERN = re.compile(r"\n +$")
+_YIELD_PATTERN = re.compile(r"\n +yield ")
- Example::
- safe_xml('CircuitPython')
- # <a href="https://circuitpython.org/">CircuitPython</a>
- """
+def _find_extends(template: str):
+ return _EXTENDS_PATTERN.search(template)
- return (
- str(value)
- .replace("&", "&")
- .replace('"', """)
- .replace("'", "'")
- .replace("<", "<")
- .replace(">", ">")
- )
+def _find_block(template: str):
+ return _BLOCK_PATTERN.search(template)
-def safe_markdown(value: Any) -> str:
- """
- Encodes unsafe symbols in ``value`` and returns the string that can be safely used in Markdown.
- Example::
+def _find_any_non_whitespace(template: str):
+ return re.search(r"\S+", template)
- safe_markdown('[CircuitPython](https://circuitpython.org/)')
- # \\[CircuitPython\\]\\(https://circuitpython.org/\\)
- """
- return (
- str(value)
- .replace("_", "\\_")
- .replace("-", "\\-")
- .replace("!", "\\!")
- .replace("(", "\\(")
- .replace(")", "\\)")
- .replace("[", "\\[")
- .replace("]", "\\]")
- .replace("*", "\\*")
- .replace("*", "\\*")
- .replace("&", "\\&")
- .replace("#", "\\#")
- .replace("`", "\\`")
- .replace("+", "\\+")
- .replace("<", "\\<")
- .replace(">", "\\>")
- .replace("|", "\\|")
- .replace("~", "\\~")
- )
+def _find_endblock(template: str, name: str = r"\w+?"):
+ return re.search(r"{% endblock " + name + r" %}", template)
-_PRECOMPILED_EXTENDS_PATTERN = re.compile(r"{% extends '.+?' %}|{% extends \".+?\" %}")
-_PRECOMPILED_BLOCK_PATTERN = re.compile(r"{% block \w+? %}")
-_PRECOMPILED_INCLUDE_PATTERN = re.compile(r"{% include '.+?' %}|{% include \".+?\" %}")
-_PRECOMPILED_HASH_COMMENT_PATTERN = re.compile(r"{# .+? #}")
-_PRECOMPILED_BLOCK_COMMENT_PATTERN = re.compile(
- r"{% comment ('.*?' |\".*?\" )?%}[\s\S]*?{% endcomment %}"
-)
-_PRECOMPILED_TOKEN_PATTERN = re.compile(r"{{ .+? }}|{% .+? %}")
+def _find_include(template: str):
+ return _INCLUDE_PATTERN.search(template)
-def _find_next_extends(template: str):
- return _PRECOMPILED_EXTENDS_PATTERN.search(template)
+def _find_hash_comment(template: str):
+ return _HASH_COMMENT_PATTERN.search(template)
-def _find_next_block(template: str):
- return _PRECOMPILED_BLOCK_PATTERN.search(template)
+def _find_block_comment(template: str):
+ return _BLOCK_COMMENT_PATTERN.search(template)
-def _find_next_include(template: str):
- return _PRECOMPILED_INCLUDE_PATTERN.search(template)
+def _find_token(template: str):
+ return _TOKEN_PATTERN.search(template)
-def _find_named_endblock(template: str, name: str):
- return re.search(r"{% endblock " + name + r" %}", template)
+def _token_is_on_own_line(text_before_token: str) -> bool:
+ return _LSTRIP_BLOCK_PATTERN.search(text_before_token) is not None
+
+def _contains_any_yield_statement(function_def: str) -> bool:
+ return _YIELD_PATTERN.search(function_def) is not None
-def _exists_and_is_file(path: str):
+
+def _exists_and_is_file(path: str) -> bool:
try:
return (os.stat(path)[0] & 0b_11110000_00000000) == 0b_10000000_00000000
except OSError:
@@ -186,16 +248,16 @@ def _exists_and_is_file(path: str):
def _resolve_includes(template: str):
- while (include_match := _find_next_include(template)) is not None:
+ while (include_match := _find_include(template)) is not None:
template_path = include_match.group(0)[12:-4]
# TODO: Restrict include to specific directory
if not _exists_and_is_file(template_path):
- raise FileNotFoundError(f"Include template 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:
+ with open(template_path, encoding="utf-8") as template_file:
template = (
template[: include_match.start()]
+ template_file.read()
@@ -204,47 +266,90 @@ def _resolve_includes(template: str):
return template
-def _check_for_unsupported_nested_blocks(template: str):
- if _find_next_block(template) is not None:
- raise ValueError("Nested blocks are not supported")
-
-
-def _resolve_includes_blocks_and_extends(template: str):
+def _resolve_includes_blocks_and_extends(
+ template: str,
+):
+ extended_templates: "set[str]" = set()
block_replacements: "dict[str, str]" = {}
# Processing nested child templates
- while (extends_match := _find_next_extends(template)) is not None:
- extended_template_name = extends_match.group(0)[12:-4]
+ while (extends_match := _find_extends(template)) is not None:
+ extended_template_path = extends_match.group(0)[12:-4]
+
+ if not _exists_and_is_file(extended_template_path):
+ raise TemplateNotFoundError(extended_template_path)
+
+ # Check for circular extends
+ if extended_template_path in extended_templates:
+ raise TemplateSyntaxError(
+ Token(
+ template,
+ extends_match.start(),
+ extends_match.end(),
+ ),
+ "Circular extends",
+ )
# Load extended template
- with open(
- extended_template_name, "rt", encoding="utf-8"
- ) as extended_template_file:
+ extended_templates.add(extended_template_path)
+ with open(extended_template_path, encoding="utf-8") 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)
+ # Check for any stacked extends
+ if stacked_extends_match := _find_extends(template[extends_match.end() :]):
+ raise TemplateSyntaxError(
+ Token(
+ template,
+ extends_match.end() + stacked_extends_match.start(),
+ extends_match.end() + stacked_extends_match.end(),
+ ),
+ "Incorrect use of {% extends ... %}",
+ )
+
# Save block replacements
- while (block_match := _find_next_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)
-
- if endblock_match is None:
- raise ValueError(r"Missing {% endblock %} for block: " + block_name)
+ # Check for anything between blocks
+ if content_between_blocks := _find_any_non_whitespace(
+ template[offset : offset + block_match.start()]
+ ):
+ raise TemplateSyntaxError(
+ Token(
+ template,
+ offset + content_between_blocks.start(),
+ offset + content_between_blocks.end(),
+ ),
+ "Content outside block",
+ )
- # Workaround for bug in re module https://github.com/adafruit/circuitpython/issues/6860
- block_content = template.encode("utf-8")[
- block_match.end() : endblock_match.start()
- ].decode("utf-8")
- # TODO: Uncomment when bug is fixed
- # block_content = template[block_match.end() : endblock_match.start()]
+ if not (endblock_match := _find_endblock(template[offset:], block_name)):
+ raise TemplateSyntaxError(
+ Token(
+ template,
+ offset + block_match.start(),
+ offset + block_match.end(),
+ ),
+ "No matching {% endblock %}",
+ )
- _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 TemplateSyntaxError(
+ 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:
block_replacements[block_name] = block_replacements[block_name].replace(
@@ -253,8 +358,16 @@ 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()
+
+ if content_after_last_endblock := _find_any_non_whitespace(template[offset:]):
+ raise TemplateSyntaxError(
+ Token(
+ template,
+ offset + content_after_last_endblock.start(),
+ offset + content_after_last_endblock.end(),
+ ),
+ "Content outside block",
)
template = extended_template
@@ -267,24 +380,29 @@ def _resolve_includes_blocks_and_extends(template: str):
def _replace_blocks_with_replacements(template: str, replacements: "dict[str, str]"):
# Replace blocks in top-level template
- while (block_match := _find_next_block(template)) is not None:
+ while (block_match := _find_block(template)) is not None:
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 = (
- template[: block_match.start()]
- + replacement
- + template[block_match.end() :]
- )
+ template = template[: block_match.start()] + replacement + template[block_match.end() :]
# Block with default content
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 TemplateSyntaxError(
+ 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
if block_name not in replacements:
@@ -296,51 +414,56 @@ def _replace_blocks_with_replacements(template: str, replacements: "dict[str, st
# Replace default content with replacement
else:
- replacement = replacements[block_name].replace(
- r"{{ block.super }}", block_content
- )
+ replacement = replacements[block_name].replace(r"{{ block.super }}", block_content)
template = (
- template[: block_match.start()]
- + replacement
- + template[endblock_match.end() :]
+ template[: block_match.start()] + replacement + template[endblock_match.end() :]
)
return template
-def _find_next_hash_comment(template: str):
- return _PRECOMPILED_HASH_COMMENT_PATTERN.search(template)
+def _remove_comments(
+ template: str,
+ *,
+ trim_blocks: bool = True,
+ lstrip_blocks: bool = True,
+):
+ def _remove_matched_comment(template: str, comment_match: re.Match):
+ text_before_comment = template[: comment_match.start()]
+ text_after_comment = template[comment_match.end() :]
+ if text_before_comment:
+ if lstrip_blocks:
+ if _token_is_on_own_line(text_before_comment):
+ text_before_comment = text_before_comment.rstrip(" ")
-def _find_next_block_comment(template: str):
- return _PRECOMPILED_BLOCK_COMMENT_PATTERN.search(template)
+ if text_after_comment:
+ if trim_blocks:
+ if text_after_comment.startswith("\n"):
+ text_after_comment = text_after_comment[1:]
+ return text_before_comment + text_after_comment
-def _remove_comments(template: str):
# Remove hash comments: {# ... #}
- while (comment_match := _find_next_hash_comment(template)) is not None:
- template = template[: comment_match.start()] + template[comment_match.end() :]
+ while (comment_match := _find_hash_comment(template)) is not None:
+ template = _remove_matched_comment(template, comment_match)
# Remove block comments: {% comment %} ... {% endcomment %}
- while (comment_match := _find_next_block_comment(template)) is not None:
- template = template[: comment_match.start()] + template[comment_match.end() :]
+ while (comment_match := _find_block_comment(template)) is not None:
+ template = _remove_matched_comment(template, comment_match)
return template
-def _find_next_token(template: str):
- return _PRECOMPILED_TOKEN_PATTERN.search(template)
-
-
-def _create_template_function( # pylint: disable=,too-many-locals,too-many-branches,too-many-statements
+def _create_template_rendering_function(
template: str,
- language: str = Language.HTML,
*,
- function_name: str = "_",
+ trim_blocks: bool = True,
+ lstrip_blocks: bool = True,
+ function_name: str = "__template_rendering_function",
context_name: str = "context",
- dry_run: bool = False,
-) -> "Generator[str] | str":
+) -> "Generator[str]":
# Resolve includes, blocks and extends
template = _resolve_includes_blocks_and_extends(template)
@@ -348,136 +471,211 @@ def _create_template_function( # pylint: disable=,too-many-locals,too-many-bran
template = _remove_comments(template)
# Create definition of the template function
- function_string = f"def {function_name}({context_name}):\n"
- indent, indentation_level = " ", 1
+ function_def = f"def {function_name}({context_name}):\n"
+ indent_level = 1
+
+ def indented(fragment: str, end: str = "\n") -> str:
+ nonlocal indent_level
+ return " " * indent_level + fragment + end
- # Keep track of the tempalte state
- forloop_iterables: "list[str]" = []
- autoescape_modes: "list[bool]" = ["default_on"]
+ # Keep track of the template state
+ 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_next_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()]:
- function_string += (
- indent * indentation_level + f"yield {repr(text_before_token)}\n"
- )
+ 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(" ")
+
+ if trim_blocks:
+ if last_token_was_block and text_before_token.startswith("\n"):
+ text_before_token = text_before_token[1:]
+
+ if text_before_token:
+ function_def += indented(f"yield {repr(text_before_token)}")
+ else:
+ function_def += indented("pass")
# Token is an expression
- if token.startswith(r"{{ "):
- autoescape = autoescape_modes[-1] in ("on", "default_on")
+ if token.content.startswith(r"{{ "):
+ last_token_was_block = False
+
+ if nested_autoescape_modes:
+ autoescape = nested_autoescape_modes[-1].content[14:-3] == "on"
+ else:
+ autoescape = True
- # Expression should be escaped with language-specific function
+ # Expression should be escaped
if autoescape:
- function_string += (
- indent * indentation_level
- + f"yield safe_{language.lower()}({token[3:-3]})\n"
- )
+ function_def += indented(f"yield safe_html({token.content[3:-3]})")
# Expression should not be escaped
else:
- function_string += (
- indent * indentation_level + f"yield str({token[3:-3]})\n"
- )
+ function_def += indented(f"yield {token.content[3:-3]}")
# 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"
- indentation_level += 1
- elif token.startswith(r"{% elif "):
- indentation_level -= 1
- function_string += indent * indentation_level + f"{token[3:-3]}:\n"
- indentation_level += 1
- elif token == r"{% else %}":
- indentation_level -= 1
- function_string += indent * indentation_level + "else:\n"
- indentation_level += 1
- elif token == r"{% endif %}":
- indentation_level -= 1
+ if token.content.startswith(r"{% if "):
+ function_def += indented(f"if {token.content[6:-3]}:")
+ indent_level += 1
+
+ nested_if_statements.append(token)
+ elif token.content.startswith(r"{% elif "):
+ if not nested_if_statements:
+ raise TemplateSyntaxError(token, "No matching {% if ... %}")
+
+ indent_level -= 1
+ function_def += indented(f"elif {token.content[8:-3]}:")
+ indent_level += 1
+ elif token.content == r"{% else %}":
+ if not nested_if_statements:
+ raise TemplateSyntaxError(token, "No matching {% if ... %}")
+
+ indent_level -= 1
+ function_def += indented("else:")
+ indent_level += 1
+ elif token.content == r"{% endif %}":
+ if not nested_if_statements:
+ raise TemplateSyntaxError(token, "No matching {% if ... %}")
+
+ indent_level -= 1
+ nested_if_statements.pop()
# Token is a for loop
- elif token.startswith(r"{% for "):
- function_string += indent * indentation_level + f"{token[3:-3]}:\n"
- indentation_level += 1
+ elif token.content.startswith(r"{% for "):
+ function_def += indented(f"for {token.content[7:-3]}:")
+ indent_level += 1
- forloop_iterables.append(token[3:-3].split(" in ", 1)[1])
- elif token == r"{% empty %}":
- indentation_level -= 1
+ nested_for_loops.append(token)
+ elif token.content == r"{% empty %}":
+ if not nested_for_loops:
+ raise TemplateSyntaxError(token, "No matching {% for ... %}")
- function_string += (
- indent * indentation_level + f"if not {forloop_iterables[-1]}:\n"
- )
- indentation_level += 1
- elif token == r"{% endfor %}":
- indentation_level -= 1
- forloop_iterables.pop()
+ last_forloop_iterable = nested_for_loops[-1].content[3:-3].split(" in ", 1)[1]
+
+ indent_level -= 1
+ function_def += indented(f"if not {last_forloop_iterable}:")
+ indent_level += 1
+ elif token.content == r"{% endfor %}":
+ if not nested_for_loops:
+ raise TemplateSyntaxError(token, "No matching {% for ... %}")
+
+ indent_level -= 1
+ nested_for_loops.pop()
# Token is a while loop
- elif token.startswith(r"{% while "):
- function_string += indent * indentation_level + f"{token[3:-3]}:\n"
- indentation_level += 1
- elif token == r"{% endwhile %}":
- indentation_level -= 1
+ elif token.content.startswith(r"{% while "):
+ function_def += indented(f"while {token.content[9:-3]}:")
+ indent_level += 1
+
+ nested_while_loops.append(token)
+ elif token.content == r"{% endwhile %}":
+ if not nested_while_loops:
+ raise TemplateSyntaxError(token, "No matching {% while ... %}")
+
+ indent_level -= 1
+ nested_while_loops.pop()
# Token is a Python code
- elif token.startswith(r"{% exec "):
- expression = token[8:-3]
- function_string += indent * indentation_level + f"{expression}\n"
-
- # Token is autoescape mode change
- elif token.startswith(r"{% autoescape "):
- mode = token[14:-3]
- if mode not in ("on", "off"):
+ elif token.content.startswith(r"{% exec "):
+ function_def += indented(f"{token.content[8:-3]}")
+
+ # Token is a autoescape mode change
+ elif token.content.startswith(r"{% autoescape "):
+ mode = token.content[14:-3]
+ if mode not in {"on", "off"}:
raise ValueError(f"Unknown autoescape mode: {mode}")
- autoescape_modes.append(mode)
- elif token == r"{% endautoescape %}":
- if autoescape_modes == ["default_on"]:
- raise ValueError("No autoescape mode to end")
- autoescape_modes.pop()
+
+ nested_autoescape_modes.append(token)
+
+ elif token.content == r"{% endautoescape %}":
+ if not nested_autoescape_modes:
+ 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(token, "No matching {% block ... %}")
+
+ # Token is a extends in top-level template
+ elif token.content.startswith(r"{% extends "):
+ raise TemplateSyntaxError(token, "Incorrect use of {% extends ... %}")
else:
- raise ValueError(
- f"Unknown token type: {token} at {token_match.start()}"
- )
+ raise TemplateSyntaxError(token, f"Unknown token: {token.content}")
else:
- raise ValueError(f"Unknown token type: {token} at {token_match.start()}")
+ raise TemplateSyntaxError(token, f"Unknown token: {token.content}")
+
+ # 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 TemplateSyntaxError(last_if_statement, "No matching {% endif %}")
+
+ if len(nested_for_loops) > 0:
+ last_for_loop = nested_for_loops[-1]
+ raise TemplateSyntaxError(last_for_loop, "No matching {% endfor %}")
- # Continue with the rest of the template
- template = template[token_match.end() :]
+ if len(nested_while_loops) > 0:
+ last_while_loop = nested_while_loops[-1]
+ raise TemplateSyntaxError(last_while_loop, "No matching {% endwhile %}")
- # Add the text after the last token (if any) and return
- if template:
- function_string += indent * indentation_level + f"yield {repr(template)}\n"
+ # No check for unclosed autoescape blocks, as they are optional and do not result in errors
- # If dry run, return the template function string
- if dry_run:
- return function_string
+ # Add the text after the last token (if any)
+ text_after_last_token = template[offset:]
+
+ if text_after_last_token:
+ if trim_blocks and text_after_last_token.startswith("\n"):
+ text_after_last_token = text_after_last_token[1:]
+
+ function_def += indented(f"yield {repr(text_after_last_token)}")
+
+ # Make sure the function definition contains at least one yield statement
+ if not _contains_any_yield_statement(function_def):
+ function_def += indented('yield ""')
# Create and return the template function
- exec(function_string) # pylint: disable=exec-used
+ exec(function_def)
return locals()[function_name]
-def _yield_as_sized_chunks(
- generator: "Generator[str]", chunk_size: int
-) -> "Generator[str]":
+def _yield_as_sized_chunks(generator: "Generator[str]", chunk_size: int) -> "Generator[str]":
"""Yields resized chunks from the ``generator``."""
# Yield chunks with a given size
chunk = ""
+ already_yielded = False
+
for item in generator:
chunk += item
if chunk_size <= len(chunk):
yield chunk[:chunk_size]
chunk = chunk[chunk_size:]
+ already_yielded = True
# Yield the last chunk
- if chunk:
+ if chunk or not already_yielded:
yield chunk
@@ -488,24 +686,15 @@ class Template:
_template_function: "Generator[str]"
- def __init__(self, template_string: str, *, language: str = Language.HTML) -> None:
+ def __init__(self, template_string: str) -> None:
"""
Creates a reusable template from the given template string.
- For better performance, instantiate the template in global scope and reuse it as many times.
- If memory is a concern, instantiate the template in a function or method that uses it.
-
- By default, the template is rendered as HTML. To render it as XML or Markdown, use the
- ``language`` parameter.
-
:param str template_string: String containing the template to be rendered
- :param str language: Language for autoescaping. Defaults to HTML
"""
- self._template_function = _create_template_function(template_string, language)
+ self._template_function = _create_template_rendering_function(template_string)
- def render_iter(
- self, context: dict = None, *, chunk_size: int = None
- ) -> "Generator[str]":
+ def render_iter(self, context: dict = None, *, chunk_size: int = None) -> "Generator[str]":
"""
Renders the template using the provided context and returns a generator that yields the
rendered output.
@@ -551,22 +740,22 @@ class FileTemplate(Template):
Class that loads a template from a file and allows to rendering it with different contexts.
"""
- def __init__(self, template_path: str, *, language: str = Language.HTML) -> None:
+ def __init__(self, template_path: str) -> None:
"""
Loads a file and creates a reusable template from its contents.
- For better performance, instantiate the template in global scope and reuse it as many times.
- If memory is a concern, instantiate the template in a function or method that uses it.
-
- By default, the template is rendered as HTML. To render it as XML or Markdown, use the
- ``language`` parameter.
-
:param str template_path: Path to a file containing the template to be rendered
- :param str language: Language for autoescaping. Defaults to HTML
"""
- with open(template_path, "rt", encoding="utf-8") as template_file:
+
+ if not _exists_and_is_file(template_path):
+ raise TemplateNotFoundError(template_path)
+
+ with open(template_path, encoding="utf-8") as template_file:
template_string = template_file.read()
- super().__init__(template_string, language=language)
+ super().__init__(template_string)
+
+
+CACHED_TEMPLATES: "dict[int, Template| FileTemplate]" = {}
def render_string_iter(
@@ -574,16 +763,19 @@ def render_string_iter(
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
``context``. Returns a generator that yields the rendered output.
+ If ``cache`` is ``True``, the template is saved and reused on next calls, even with different
+ contexts.
+
:param dict context: Dictionary containing the context for the template
: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::
@@ -593,30 +785,51 @@ 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 CACHED_TEMPLATES:
+ return CACHED_TEMPLATES[key].render_iter(context or {}, chunk_size=chunk_size)
+
+ template = Template(template_string)
+
+ if cache:
+ CACHED_TEMPLATES[key] = template
+
+ return template.render_iter(context or {}, chunk_size=chunk_size)
def render_string(
template_string: str,
context: dict = None,
*,
- language: str = Language.HTML,
+ cache: bool = True,
):
"""
Creates a `Template` from the given ``template_string`` and renders it using the provided
``context``. Returns the rendered output as a string.
+ If ``cache`` is ``True``, the template is saved and reused on next calls, even with different
+ contexts.
+
: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 CACHED_TEMPLATES:
+ return CACHED_TEMPLATES[key].render(context or {})
+
+ template = Template(template_string)
+
+ if cache:
+ CACHED_TEMPLATES[key] = template
+
+ return template.render(context or {})
def render_template_iter(
@@ -624,16 +837,19 @@ def render_template_iter(
context: dict = None,
*,
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
``context``. Returns a generator that yields the rendered output.
+ If ``cache`` is ``True``, the template is saved and reused on next calls, even with different
+ contexts.
+
:param dict context: Dictionary containing the context for the template
: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::
@@ -643,27 +859,49 @@ 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 CACHED_TEMPLATES:
+ return CACHED_TEMPLATES[key].render_iter(context or {}, chunk_size=chunk_size)
+
+ template = FileTemplate(template_path)
+
+ if cache:
+ CACHED_TEMPLATES[key] = template
+
+ return template.render_iter(context or {}, chunk_size=chunk_size)
def render_template(
template_path: str,
context: dict = None,
*,
- language: str = Language.HTML,
+ cache: bool = True,
):
"""
Creates a `FileTemplate` from the given ``template_path`` and renders it using the provided
``context``. Returns the rendered output as a string.
+ If ``cache`` is ``True``, the template is saved and reused on next calls, even with different
+ contexts.
+
: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 CACHED_TEMPLATES:
+ return CACHED_TEMPLATES[key].render(context or {})
+
+ template = FileTemplate(template_path)
+
+ if cache:
+ CACHED_TEMPLATES[key] = template
+
+ return template.render(context or {})
diff --git a/docs/api.rst b/docs/api.rst
index ff79193..c19826c 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -4,6 +4,8 @@
.. If your library file(s) are nested in a directory (e.g. /adafruit_foo/foo.py)
.. use this format as the module name: "adafruit_foo.foo"
+API Reference
+#############
+
.. automodule:: adafruit_templateengine
:members:
- :inherited-members:
diff --git a/docs/conf.py b/docs/conf.py
index e6aedfb..36b7621 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -1,12 +1,10 @@
-# -*- coding: utf-8 -*-
-
# SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries
#
# SPDX-License-Identifier: MIT
+import datetime
import os
import sys
-import datetime
sys.path.insert(0, os.path.abspath(".."))
@@ -53,9 +51,7 @@
creation_year = "2023"
current_year = str(datetime.datetime.now().year)
year_duration = (
- current_year
- if current_year == creation_year
- else creation_year + " - " + current_year
+ current_year if current_year == creation_year else creation_year + " - " + current_year
)
copyright = year_duration + " Michał Pokusa, Tim Cocks"
author = "Michał Pokusa, Tim Cocks"
@@ -119,7 +115,6 @@
import sphinx_rtd_theme
html_theme = "sphinx_rtd_theme"
- html_theme_path = [sphinx_rtd_theme.get_html_theme_path(), "."]
except:
html_theme = "default"
html_theme_path = ["."]
diff --git a/docs/examples.rst b/docs/examples.rst
index 422a84e..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 one 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
@@ -51,7 +49,7 @@ Every expression that would be valid in an f-string is also valid in the templat
This includes, but is not limited to:
- mathemathical operations e.g. ``{{ 5 + 2 ** 3 }}`` will be replaced with ``"13"``
-- string operations e.g. ``{{ 'hello'.title() }}`` will be replaced with ``"Hello"``
+- string operations e.g. ``{{ 'hello'.upper() }}`` will be replaced with ``"HELLO"``
- logical operations e.g. ``{{ 1 == 2 }}`` will be replaced with ``"False"``
- ternary operator e.g. ``{{ 'ON' if True else 'OFF' }}`` will be replaced with ``"ON"``
- built-in functions e.g. ``{{ len('Adafruit Industries') }}`` will be replaced with ``"19"``
@@ -140,13 +138,13 @@ and then include it in multiple pages.
.. literalinclude:: ../examples/footer.html
:caption: examples/footer.html
- :lines: 5-
+ :lines: 7-
:language: html
:linenos:
.. literalinclude:: ../examples/base_without_footer.html
:caption: examples/base_without_footer.html
- :lines: 5-
+ :lines: 7-
:language: html
:emphasize-lines: 12
:linenos:
@@ -173,13 +171,13 @@ This allows sharing whole layout, not only single parts.
.. literalinclude:: ../examples/child.html
:caption: examples/child.html
- :lines: 5-
+ :lines: 7-
:language: html
:linenos:
.. literalinclude:: ../examples/parent_layout.html
:caption: examples/parent_layout.html
- :lines: 5-
+ :lines: 7-
:language: html
:linenos:
@@ -196,7 +194,7 @@ Executing Python code in templates
----------------------------------
It is also possible to execute Python code in templates.
-This an be used for e.g. defining variables, modifying context, or breaking from loops.
+This can be used for e.g. defining variables, modifying context, or breaking from loops.
.. literalinclude:: ../examples/templateengine_exec.py
@@ -221,7 +219,7 @@ Supported comment syntaxes:
.. literalinclude:: ../examples/comments.html
:caption: examples/comments.html
- :lines: 5-
+ :lines: 7-
:language: html
:linenos:
@@ -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,20 +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: 5-
+ :lines: 7-
:language: html
:linenos:
-.. literalinclude:: ../examples/autoescape.md
- :caption: examples/autoescape.md
- :language: markdown
- :linenos:
-
.. literalinclude:: ../examples/templateengine_autoescape.py
:caption: examples/templateengine_autoescape.py
:lines: 5-
diff --git a/docs/requirements.txt b/docs/requirements.txt
index 9ccaf8e..979f568 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -2,6 +2,6 @@
#
# SPDX-License-Identifier: Unlicense
-sphinx>=4.0.0
+sphinx
sphinxcontrib-jquery
sphinx-rtd-theme
diff --git a/examples/autoescape.html b/examples/autoescape.html
index 4abf2b2..299ebf0 100644
--- a/examples/autoescape.html
+++ b/examples/autoescape.html
@@ -1,6 +1,8 @@
-# SPDX-FileCopyrightText: Copyright (c) 2023 Michał Pokusa
-#
-# SPDX-License-Identifier: Unlicense
+
@@ -20,7 +22,7 @@
This is a {{ "bold text" }}, because autoescaping is turned off in this block.
{% autoescape on %}
- And againg, this is not a {{ "bold text" }},
+ And again, this is not a {{ "bold text" }},
because in this block autoescaping is turned on again.
{% endautoescape %}
{% endautoescape %}
diff --git a/examples/autoescape.md b/examples/autoescape.md
deleted file mode 100644
index 2771736..0000000
--- a/examples/autoescape.md
+++ /dev/null
@@ -1,17 +0,0 @@
-# SPDX-FileCopyrightText: Copyright (c) 2023 Michał Pokusa
-#
-# SPDX-License-Identifier: Unlicense
-
-## Example of autoescaping unsafe characters in Markdown
-
-This would be a {{ "__bold text__" }}, but autoescaping is turned on, so all unsafe characters are escaped.
-
-{% autoescape off %}
-This is a {{ "__bold text__" }}, because autoescaping is turned off in this block.
-
-{% autoescape on %}
-And againg, this is not a {{ "__bold text__" }},
-because in this block autoescaping is turned on again.
-{% endautoescape %}
-
-{% endautoescape %}
diff --git a/examples/base_without_footer.html b/examples/base_without_footer.html
index cc7b2c4..59dce0b 100644
--- a/examples/base_without_footer.html
+++ b/examples/base_without_footer.html
@@ -1,6 +1,8 @@
-# SPDX-FileCopyrightText: Copyright (c) 2023 Michał Pokusa
-#
-# SPDX-License-Identifier: Unlicense
+
diff --git a/examples/child.html b/examples/child.html
index ed9cf67..5abc6e9 100644
--- a/examples/child.html
+++ b/examples/child.html
@@ -1,6 +1,8 @@
-# SPDX-FileCopyrightText: Copyright (c) 2023 Michał Pokusa
-#
-# SPDX-License-Identifier: Unlicense
+
{% extends "./examples/parent_layout.html" %}
diff --git a/examples/comments.html b/examples/comments.html
index 9173aad..e376157 100644
--- a/examples/comments.html
+++ b/examples/comments.html
@@ -1,6 +1,8 @@
-# SPDX-FileCopyrightText: Copyright (c) 2023 Michał Pokusa
-#
-# SPDX-License-Identifier: Unlicense
+
diff --git a/examples/footer.html b/examples/footer.html
index c32d42b..e6563d0 100644
--- a/examples/footer.html
+++ b/examples/footer.html
@@ -1,6 +1,8 @@
-# SPDX-FileCopyrightText: Copyright (c) 2023 Michał Pokusa
-#
-# SPDX-License-Identifier: Unlicense
+