diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml
index 727518c69..d609abdb6 100644
--- a/.github/workflows/publish.yaml
+++ b/.github/workflows/publish.yaml
@@ -23,7 +23,7 @@ jobs:
- name: generate hash
id: hash
run: cd dist && echo "hash=$(sha256sum * | base64 -w0)" >> $GITHUB_OUTPUT
- - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
+ - uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
with:
path: ./dist
provenance:
@@ -64,10 +64,6 @@ jobs:
id-token: write
steps:
- uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
- - uses: pypa/gh-action-pypi-publish@f7600683efdcb7656dec5b29656edb7bc586e597 # v1.10.3
- with:
- repository-url: https://test.pypi.org/legacy/
- packages-dir: artifact/
- - uses: pypa/gh-action-pypi-publish@f7600683efdcb7656dec5b29656edb7bc586e597 # v1.10.3
+ - uses: pypa/gh-action-pypi-publish@67339c736fd9354cd4f8cb0b744f2b82a74b5c70 # v1.12.3
with:
packages-dir: artifact/
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index 068f09c5e..6e69c03d8 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -42,7 +42,7 @@ jobs:
cache: pip
cache-dependency-path: requirements*/*.txt
- name: cache mypy
- uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
+ uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
with:
path: ./.mypy_cache
key: mypy|${{ hashFiles('pyproject.toml') }}
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 6ad19aacd..0b3109a79 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
- rev: v0.7.1
+ rev: v0.8.4
hooks:
- id: ruff
- id: ruff-format
diff --git a/CHANGES.rst b/CHANGES.rst
index f23b6c96f..5019208e4 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -14,8 +14,16 @@ Unreleased
Version 3.1.5
-------------
-Unreleased
-
+Released 2024-12-21
+
+- The sandboxed environment handles indirect calls to ``str.format``, such as
+ by passing a stored reference to a filter that calls its argument.
+ :ghsa:`q2x7-8rv6-6q7h`
+- Escape template name before formatting it into error messages, to avoid
+ issues with names that contain f-string syntax.
+ :issue:`1792`, :ghsa:`gmj6-6f8f-6699`
+- Sandbox does not allow ``clear`` and ``pop`` on known mutable sequence
+ types. :issue:`2032`
- Calling sync ``render`` for an async template uses ``asyncio.run``.
:pr:`1952`
- Avoid unclosed ``auto_aiter`` warnings. :pr:`1960`
@@ -25,6 +33,32 @@ Unreleased
``Template.generate_async``. :pr:`1960`
- Avoid leaving async generators unclosed in blocks, includes and extends.
:pr:`1960`
+- The runtime uses the correct ``concat`` function for the current environment
+ when calling block references. :issue:`1701`
+- Make ``|unique`` async-aware, allowing it to be used after another
+ async-aware filter. :issue:`1781`
+- ``|int`` filter handles ``OverflowError`` from scientific notation.
+ :issue:`1921`
+- Make compiling deterministic for tuple unpacking in a ``{% set ... %}``
+ call. :issue:`2021`
+- Fix dunder protocol (`copy`/`pickle`/etc) interaction with ``Undefined``
+ objects. :issue:`2025`
+- Fix `copy`/`pickle` support for the internal ``missing`` object.
+ :issue:`2027`
+- ``Environment.overlay(enable_async)`` is applied correctly. :pr:`2061`
+- The error message from ``FileSystemLoader`` includes the paths that were
+ searched. :issue:`1661`
+- ``PackageLoader`` shows a clearer error message when the package does not
+ contain the templates directory. :issue:`1705`
+- Improve annotations for methods returning copies. :pr:`1880`
+- ``urlize`` does not add ``mailto:`` to values like `@a@b`. :pr:`1870`
+- Tests decorated with `@pass_context`` can be used with the ``|select``
+ filter. :issue:`1624`
+- Using ``set`` for multiple assignment (``a, b = 1, 2``) does not fail when the
+ target is a namespace attribute. :issue:`1413`
+- Using ``set`` in all branches of ``{% if %}{% elif %}{% else %}`` blocks
+ does not cause the variable to be considered initially undefined.
+ :issue:`1253`
Version 3.1.4
@@ -1012,7 +1046,7 @@ Released 2008-07-17, codename Jinjavitus
evaluates to ``false``.
- Improved error reporting for undefined values by providing a
position.
-- ``filesizeformat`` filter uses decimal prefixes now per default and
+- ``filesizeformat`` filter uses decimal prefixes now by default and
can be set to binary mode with the second parameter.
- Fixed bug in finalizer
diff --git a/docs/api.rst b/docs/api.rst
index 70d9a46be..a7e32b215 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -666,8 +666,8 @@ Now it can be used in templates:
.. sourcecode:: jinja
- {{ article.pub_date|datetimeformat }}
- {{ article.pub_date|datetimeformat("%B %Y") }}
+ {{ article.pub_date|datetime_format }}
+ {{ article.pub_date|datetime_format("%B %Y") }}
Some decorators are available to tell Jinja to pass extra information to
the filter. The object is passed as the first argument, making the value
diff --git a/docs/conf.py b/docs/conf.py
index 02c74a86b..280610e53 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -24,7 +24,7 @@
extlinks = {
"issue": ("https://github.com/pallets/jinja/issues/%s", "#%s"),
"pr": ("https://github.com/pallets/jinja/pull/%s", "#%s"),
- "ghsa": ("https://github.com/advisories/GHSA-%s", "GHSA-%s"),
+ "ghsa": ("https://github.com/pallets/jinja/security/advisories/GHSA-%s", "GHSA-%s"),
}
intersphinx_mapping = {
"python": ("https://docs.python.org/3/", None),
diff --git a/docs/faq.rst b/docs/faq.rst
index 493dc38c6..a53ae12ff 100644
--- a/docs/faq.rst
+++ b/docs/faq.rst
@@ -70,6 +70,8 @@ these document types.
While automatic escaping means that you are less likely have an XSS
problem, it also requires significant extra processing during compiling
-and rendering, which can reduce performance. Jinja uses MarkupSafe for
+and rendering, which can reduce performance. Jinja uses `MarkupSafe`_ for
escaping, which provides optimized C code for speed, but it still
introduces overhead to track escaping across methods and formatting.
+
+.. _MarkupSafe: https://markupsafe.palletsprojects.com/
diff --git a/docs/nativetypes.rst b/docs/nativetypes.rst
index 1a08700b0..fb2a76718 100644
--- a/docs/nativetypes.rst
+++ b/docs/nativetypes.rst
@@ -55,6 +55,17 @@ Foo
>>> print(result.value)
15
+Sandboxed Native Environment
+----------------------------
+
+You can combine :class:`.SandboxedEnvironment` and :class:`NativeEnvironment` to
+get both behaviors.
+
+.. code-block:: python
+
+ class SandboxedNativeEnvironment(SandboxedEnvironment, NativeEnvironment):
+ pass
+
API
---
diff --git a/docs/templates.rst b/docs/templates.rst
index 2471cea39..9f376a13c 100644
--- a/docs/templates.rst
+++ b/docs/templates.rst
@@ -202,10 +202,11 @@ option can also be set to strip tabs and spaces from the beginning of a
line to the start of a block. (Nothing will be stripped if there are
other characters before the start of the block.)
-With both `trim_blocks` and `lstrip_blocks` enabled, you can put block tags
-on their own lines, and the entire block line will be removed when
-rendered, preserving the whitespace of the contents. For example,
-without the `trim_blocks` and `lstrip_blocks` options, this template::
+With both ``trim_blocks`` and ``lstrip_blocks`` disabled (the default), block
+tags on their own lines will be removed, but a blank line will remain and the
+spaces in the content will be preserved. For example, this template:
+
+.. code-block:: jinja
{% if True %}
@@ -213,7 +214,10 @@ without the `trim_blocks` and `lstrip_blocks` options, this template::
{% endif %}
-gets rendered with blank lines inside the div::
+With both ``trim_blocks`` and ``lstrip_blocks`` disabled, the template is
+rendered with blank lines inside the div:
+
+.. code-block:: text
@@ -221,8 +225,10 @@ gets rendered with blank lines inside the div::
-But with both `trim_blocks` and `lstrip_blocks` enabled, the template block
-lines are removed and other whitespace is preserved::
+With both ``trim_blocks`` and ``lstrip_blocks`` enabled, the template block
+lines are completely removed:
+
+.. code-block:: text
yay
@@ -522,8 +528,8 @@ However, the name after the `endblock` word must match the block name.
Block Nesting and Scope
~~~~~~~~~~~~~~~~~~~~~~~
-Blocks can be nested for more complex layouts. However, per default blocks
-may not access variables from outer scopes::
+Blocks can be nested for more complex layouts. By default, a block may not
+access variables from outside the block (outer scopes)::
{% for item in seq %}
{% block loop_item %}{{ item }}{% endblock %}
@@ -1080,34 +1086,34 @@ Assignments use the `set` tag and can have multiple targets::
Block Assignments
~~~~~~~~~~~~~~~~~
-.. versionadded:: 2.8
+It's possible to use `set` as a block to assign the content of the block to a
+variable. This can be used to create multi-line strings, since Jinja doesn't
+support Python's triple quotes (``"""``, ``'''``).
-Starting with Jinja 2.8, it's possible to also use block assignments to
-capture the contents of a block into a variable name. This can be useful
-in some situations as an alternative for macros. In that case, instead of
-using an equals sign and a value, you just write the variable name and then
-everything until ``{% endset %}`` is captured.
+Instead of using an equals sign and a value, you only write the variable name,
+and everything until ``{% endset %}`` is captured.
-Example::
+.. code-block:: jinja
{% set navigation %}
Index
Downloads
{% endset %}
-The `navigation` variable then contains the navigation HTML source.
-
-.. versionchanged:: 2.10
-
-Starting with Jinja 2.10, the block assignment supports filters.
+Filters applied to the variable name will be applied to the block's content.
-Example::
+.. code-block:: jinja
{% set reply | wordwrap %}
You wrote:
{{ message }}
{% endset %}
+.. versionadded:: 2.8
+
+.. versionchanged:: 2.10
+
+ Block assignment supports filters.
.. _extends:
@@ -1406,28 +1412,32 @@ Comparisons
Logic
~~~~~
-For ``if`` statements, ``for`` filtering, and ``if`` expressions, it can be useful to
-combine multiple expressions:
+For ``if`` statements, ``for`` filtering, and ``if`` expressions, it can be
+useful to combine multiple expressions.
``and``
- Return true if the left and the right operand are true.
+ For ``x and y``, if ``x`` is false, then the value is ``x``, else ``y``. In
+ a boolean context, this will be treated as ``True`` if both operands are
+ truthy.
``or``
- Return true if the left or the right operand are true.
+ For ``x or y``, if ``x`` is true, then the value is ``x``, else ``y``. In a
+ boolean context, this will be treated as ``True`` if at least one operand is
+ truthy.
``not``
- negate a statement (see below).
-
-``(expr)``
- Parentheses group an expression.
-
-.. admonition:: Note
+ For ``not x``, if ``x`` is false, then the value is ``True``, else
+ ``False``.
- The ``is`` and ``in`` operators support negation using an infix notation,
- too: ``foo is not bar`` and ``foo not in bar`` instead of ``not foo is bar``
- and ``not foo in bar``. All other expressions require a prefix notation:
+ Prefer negating ``is`` and ``in`` using their infix notation:
+ ``foo is not bar`` instead of ``not foo is bar``; ``foo not in bar`` instead
+ of ``not foo in bar``. All other expressions require prefix notation:
``not (foo and bar).``
+``(expr)``
+ Parentheses group an expression. This is used to change evaluation order, or
+ to make a long expression easier to read or less ambiguous.
+
Other Operators
~~~~~~~~~~~~~~~
@@ -1668,6 +1678,9 @@ The following functions are available in the global scope by default:
.. versionadded:: 2.10
+ .. versionchanged:: 3.2
+ Namespace attributes can be assigned to in multiple assignment.
+
Extensions
----------
@@ -1778,7 +1791,7 @@ It's possible to translate strings in expressions with these functions:
- ``_(message)``: Alias for ``gettext``.
- ``gettext(message)``: Translate a message.
-- ``ngettext(singluar, plural, n)``: Translate a singular or plural
+- ``ngettext(singular, plural, n)``: Translate a singular or plural
message based on a count variable.
- ``pgettext(context, message)``: Like ``gettext()``, but picks the
translation based on the context string.
diff --git a/docs/tricks.rst b/docs/tricks.rst
index b58c5bb09..3a7084a6d 100644
--- a/docs/tricks.rst
+++ b/docs/tricks.rst
@@ -21,7 +21,7 @@ for a neat trick.
Usually child templates extend from one template that adds a basic HTML
skeleton. However it's possible to put the `extends` tag into an `if` tag to
only extend from the layout template if the `standalone` variable evaluates
-to false which it does per default if it's not defined. Additionally a very
+to false, which it does by default if it's not defined. Additionally a very
basic skeleton is added to the file so that if it's indeed rendered with
`standalone` set to `True` a very basic HTML skeleton is added::
diff --git a/examples/basic/test.py b/examples/basic/test.py
index 7a58e1ad4..30f5dd6b3 100644
--- a/examples/basic/test.py
+++ b/examples/basic/test.py
@@ -6,9 +6,9 @@
{
"child.html": """\
{% extends default_layout or 'default.html' %}
-{% include helpers = 'helpers.html' %}
+{% import 'helpers.html' as helpers %}
{% macro get_the_answer() %}42{% endmacro %}
-{% title = 'Hello World' %}
+{% set title = 'Hello World' %}
{% block body %}
{{ get_the_answer() }}
{{ helpers.conspirate() }}
diff --git a/requirements/build.txt b/requirements/build.txt
index 1b13b0552..9d6dd1040 100644
--- a/requirements/build.txt
+++ b/requirements/build.txt
@@ -6,7 +6,7 @@
#
build==1.2.2.post1
# via -r build.in
-packaging==24.1
+packaging==24.2
# via build
pyproject-hooks==1.2.0
# via build
diff --git a/requirements/dev.txt b/requirements/dev.txt
index ba73d911c..c90a78168 100644
--- a/requirements/dev.txt
+++ b/requirements/dev.txt
@@ -6,7 +6,7 @@
#
alabaster==1.0.0
# via sphinx
-attrs==24.2.0
+attrs==24.3.0
# via
# outcome
# trio
@@ -16,7 +16,7 @@ build==1.2.2.post1
# via pip-tools
cachetools==5.5.0
# via tox
-certifi==2024.8.30
+certifi==2024.12.14
# via requests
cfgv==3.4.0
# via pre-commit
@@ -38,7 +38,7 @@ filelock==3.16.1
# via
# tox
# virtualenv
-identify==2.6.1
+identify==2.6.3
# via pre-commit
idna==3.10
# via
@@ -52,15 +52,15 @@ jinja2==3.1.4
# via sphinx
markupsafe==3.0.2
# via jinja2
-mypy==1.13.0
- # via -r typing.in
+mypy==1.14.0
+ # via -r /Users/david/Projects/jinja/requirements/typing.in
mypy-extensions==1.0.0
# via mypy
nodeenv==1.9.1
# via pre-commit
outcome==1.3.0.post0
# via trio
-packaging==24.1
+packaging==24.2
# via
# build
# pallets-sphinx-themes
@@ -69,8 +69,8 @@ packaging==24.1
# sphinx
# tox
pallets-sphinx-themes==2.3.0
- # via -r docs.in
-pip-compile-multi==2.6.4
+ # via -r /Users/david/Projects/jinja/requirements/docs.in
+pip-compile-multi==2.7.1
# via -r dev.in
pip-tools==7.4.1
# via pip-compile-multi
@@ -92,8 +92,8 @@ pyproject-hooks==1.2.0
# via
# build
# pip-tools
-pytest==8.3.3
- # via -r tests.in
+pytest==8.3.4
+ # via -r /Users/david/Projects/jinja/requirements/tests.in
pyyaml==6.0.2
# via pre-commit
requests==2.32.3
@@ -106,13 +106,13 @@ sortedcontainers==2.4.0
# via trio
sphinx==8.1.3
# via
- # -r docs.in
+ # -r /Users/david/Projects/jinja/requirements/docs.in
# pallets-sphinx-themes
# sphinx-issues
# sphinx-notfound-page
# sphinxcontrib-log-cabinet
sphinx-issues==5.0.0
- # via -r docs.in
+ # via -r /Users/david/Projects/jinja/requirements/docs.in
sphinx-notfound-page==1.0.4
# via pallets-sphinx-themes
sphinxcontrib-applehelp==2.0.0
@@ -124,7 +124,7 @@ sphinxcontrib-htmlhelp==2.1.0
sphinxcontrib-jsmath==1.0.1
# via sphinx
sphinxcontrib-log-cabinet==1.0.1
- # via -r docs.in
+ # via -r /Users/david/Projects/jinja/requirements/docs.in
sphinxcontrib-qthelp==2.0.0
# via sphinx
sphinxcontrib-serializinghtml==2.0.0
@@ -134,16 +134,16 @@ toposort==1.10
tox==4.23.2
# via -r dev.in
trio==0.27.0
- # via -r tests.in
+ # via -r /Users/david/Projects/jinja/requirements/tests.in
typing-extensions==4.12.2
# via mypy
urllib3==2.2.3
# via requests
-virtualenv==20.27.0
+virtualenv==20.28.0
# via
# pre-commit
# tox
-wheel==0.44.0
+wheel==0.45.1
# via pip-tools
# The following packages are considered to be unsafe in a requirements file:
diff --git a/requirements/docs.txt b/requirements/docs.txt
index 453a7cb5d..2283fa9b5 100644
--- a/requirements/docs.txt
+++ b/requirements/docs.txt
@@ -8,7 +8,7 @@ alabaster==1.0.0
# via sphinx
babel==2.16.0
# via sphinx
-certifi==2024.8.30
+certifi==2024.12.14
# via requests
charset-normalizer==3.4.0
# via requests
@@ -22,7 +22,7 @@ jinja2==3.1.4
# via sphinx
markupsafe==3.0.2
# via jinja2
-packaging==24.1
+packaging==24.2
# via
# pallets-sphinx-themes
# sphinx
diff --git a/requirements/tests.txt b/requirements/tests.txt
index e019ba988..71dad37da 100644
--- a/requirements/tests.txt
+++ b/requirements/tests.txt
@@ -4,7 +4,7 @@
#
# pip-compile tests.in
#
-attrs==24.2.0
+attrs==24.3.0
# via
# outcome
# trio
@@ -14,11 +14,11 @@ iniconfig==2.0.0
# via pytest
outcome==1.3.0.post0
# via trio
-packaging==24.1
+packaging==24.2
# via pytest
pluggy==1.5.0
# via pytest
-pytest==8.3.3
+pytest==8.3.4
# via -r tests.in
sniffio==1.3.1
# via trio
diff --git a/requirements/typing.txt b/requirements/typing.txt
index 1cf3727a5..f50d6d667 100644
--- a/requirements/typing.txt
+++ b/requirements/typing.txt
@@ -4,7 +4,7 @@
#
# pip-compile typing.in
#
-mypy==1.13.0
+mypy==1.14.0
# via -r typing.in
mypy-extensions==1.0.0
# via mypy
diff --git a/src/jinja2/compiler.py b/src/jinja2/compiler.py
index 91720c5f9..a4ff6a1b1 100644
--- a/src/jinja2/compiler.py
+++ b/src/jinja2/compiler.py
@@ -216,7 +216,7 @@ def __init__(
# or compile time.
self.soft_frame = False
- def copy(self) -> "Frame":
+ def copy(self) -> "te.Self":
"""Create a copy of the current one."""
rv = object.__new__(self.__class__)
rv.__dict__.update(self.__dict__)
@@ -229,7 +229,7 @@ def inner(self, isolated: bool = False) -> "Frame":
return Frame(self.eval_ctx, level=self.symbols.level + 1)
return Frame(self.eval_ctx, self)
- def soft(self) -> "Frame":
+ def soft(self) -> "te.Self":
"""Return a soft frame. A soft frame may not be modified as
standalone thing as it shares the resources with the frame it
was created of, but it's not a rootlevel frame any longer.
@@ -811,7 +811,7 @@ def pop_assign_tracking(self, frame: Frame) -> None:
self.writeline("_block_vars.update({")
else:
self.writeline("context.vars.update({")
- for idx, name in enumerate(vars):
+ for idx, name in enumerate(sorted(vars)):
if idx:
self.write(", ")
ref = frame.symbols.ref(name)
@@ -821,7 +821,7 @@ def pop_assign_tracking(self, frame: Frame) -> None:
if len(public_names) == 1:
self.writeline(f"context.exported_vars.add({public_names[0]!r})")
else:
- names_str = ", ".join(map(repr, public_names))
+ names_str = ", ".join(map(repr, sorted(public_names)))
self.writeline(f"context.exported_vars.update(({names_str}))")
# -- Statement Visitors
@@ -1141,9 +1141,14 @@ def visit_FromImport(self, node: nodes.FromImport, frame: Frame) -> None:
)
self.writeline(f"if {frame.symbols.ref(alias)} is missing:")
self.indent()
+ # The position will contain the template name, and will be formatted
+ # into a string that will be compiled into an f-string. Curly braces
+ # in the name must be replaced with escapes so that they will not be
+ # executed as part of the f-string.
+ position = self.position(node).replace("{", "{{").replace("}", "}}")
message = (
"the template {included_template.__name__!r}"
- f" (imported on {self.position(node)})"
+ f" (imported on {position})"
f" does not export the requested name {name!r}"
)
self.writeline(
@@ -1576,6 +1581,29 @@ def visit_Output(self, node: nodes.Output, frame: Frame) -> None:
def visit_Assign(self, node: nodes.Assign, frame: Frame) -> None:
self.push_assign_tracking()
+
+ # ``a.b`` is allowed for assignment, and is parsed as an NSRef. However,
+ # it is only valid if it references a Namespace object. Emit a check for
+ # that for each ref here, before assignment code is emitted. This can't
+ # be done in visit_NSRef as the ref could be in the middle of a tuple.
+ seen_refs: t.Set[str] = set()
+
+ for nsref in node.find_all(nodes.NSRef):
+ if nsref.name in seen_refs:
+ # Only emit the check for each reference once, in case the same
+ # ref is used multiple times in a tuple, `ns.a, ns.b = c, d`.
+ continue
+
+ seen_refs.add(nsref.name)
+ ref = frame.symbols.ref(nsref.name)
+ self.writeline(f"if not isinstance({ref}, Namespace):")
+ self.indent()
+ self.writeline(
+ "raise TemplateRuntimeError"
+ '("cannot assign attribute on non-namespace object")'
+ )
+ self.outdent()
+
self.newline(node)
self.visit(node.target, frame)
self.write(" = ")
@@ -1632,17 +1660,11 @@ def visit_Name(self, node: nodes.Name, frame: Frame) -> None:
self.write(ref)
def visit_NSRef(self, node: nodes.NSRef, frame: Frame) -> None:
- # NSRefs can only be used to store values; since they use the normal
- # `foo.bar` notation they will be parsed as a normal attribute access
- # when used anywhere but in a `set` context
+ # NSRef is a dotted assignment target a.b=c, but uses a[b]=c internally.
+ # visit_Assign emits code to validate that each ref is to a Namespace
+ # object only. That can't be emitted here as the ref could be in the
+ # middle of a tuple assignment.
ref = frame.symbols.ref(node.name)
- self.writeline(f"if not isinstance({ref}, Namespace):")
- self.indent()
- self.writeline(
- "raise TemplateRuntimeError"
- '("cannot assign attribute on non-namespace object")'
- )
- self.outdent()
self.writeline(f"{ref}[{node.attr!r}]")
def visit_Const(self, node: nodes.Const, frame: Frame) -> None:
diff --git a/src/jinja2/environment.py b/src/jinja2/environment.py
index 0b303d597..a99cc2d1d 100644
--- a/src/jinja2/environment.py
+++ b/src/jinja2/environment.py
@@ -125,7 +125,7 @@ def load_extensions(
return result
-def _environment_config_check(environment: "Environment") -> "Environment":
+def _environment_config_check(environment: _env_bound) -> _env_bound:
"""Perform a sanity check on the environment."""
assert issubclass(
environment.undefined, Undefined
@@ -408,8 +408,8 @@ def overlay(
cache_size: int = missing,
auto_reload: bool = missing,
bytecode_cache: t.Optional["BytecodeCache"] = missing,
- enable_async: bool = False,
- ) -> "Environment":
+ enable_async: bool = missing,
+ ) -> "te.Self":
"""Create a new overlay environment that shares all the data with the
current environment except for cache and the overridden attributes.
Extensions cannot be removed for an overlayed environment. An overlayed
@@ -421,8 +421,11 @@ def overlay(
copied over so modifications on the original environment may not shine
through.
+ .. versionchanged:: 3.1.5
+ ``enable_async`` is applied correctly.
+
.. versionchanged:: 3.1.2
- Added the ``newline_sequence``,, ``keep_trailing_newline``,
+ Added the ``newline_sequence``, ``keep_trailing_newline``,
and ``enable_async`` parameters to match ``__init__``.
"""
args = dict(locals())
diff --git a/src/jinja2/ext.py b/src/jinja2/ext.py
index 9fad0aa19..d8ab7eff1 100644
--- a/src/jinja2/ext.py
+++ b/src/jinja2/ext.py
@@ -89,7 +89,7 @@ def __init_subclass__(cls) -> None:
def __init__(self, environment: Environment) -> None:
self.environment = environment
- def bind(self, environment: Environment) -> "Extension":
+ def bind(self, environment: Environment) -> "te.Self":
"""Create a copy of this extension bound to another environment."""
rv = object.__new__(self.__class__)
rv.__dict__.update(self.__dict__)
diff --git a/src/jinja2/filters.py b/src/jinja2/filters.py
index 14208770d..e5b5a00c5 100644
--- a/src/jinja2/filters.py
+++ b/src/jinja2/filters.py
@@ -438,7 +438,7 @@ def do_sort(
@pass_environment
-def do_unique(
+def sync_do_unique(
environment: "Environment",
value: "t.Iterable[V]",
case_sensitive: bool = False,
@@ -470,6 +470,18 @@ def do_unique(
yield item
+@async_variant(sync_do_unique) # type: ignore
+async def do_unique(
+ environment: "Environment",
+ value: "t.Union[t.AsyncIterable[V], t.Iterable[V]]",
+ case_sensitive: bool = False,
+ attribute: t.Optional[t.Union[str, int]] = None,
+) -> "t.Iterator[V]":
+ return sync_do_unique(
+ environment, await auto_to_list(value), case_sensitive, attribute
+ )
+
+
def _min_or_max(
environment: "Environment",
value: "t.Iterable[V]",
@@ -987,7 +999,7 @@ def do_int(value: t.Any, default: int = 0, base: int = 10) -> int:
# this quirk is necessary so that "42.23"|int gives 42.
try:
return int(float(value))
- except (TypeError, ValueError):
+ except (TypeError, ValueError, OverflowError):
return default
@@ -1629,8 +1641,8 @@ def sync_do_selectattr(
.. code-block:: python
- (u for user in users if user.is_active)
- (u for user in users if test_none(user.email))
+ (user for user in users if user.is_active)
+ (user for user in users if test_none(user.email))
.. versionadded:: 2.7
"""
@@ -1667,8 +1679,8 @@ def sync_do_rejectattr(
.. code-block:: python
- (u for user in users if not user.is_active)
- (u for user in users if not test_none(user.email))
+ (user for user in users if not user.is_active)
+ (user for user in users if not test_none(user.email))
.. versionadded:: 2.7
"""
@@ -1768,7 +1780,7 @@ def transfunc(x: V) -> V:
args = args[1 + off :]
def func(item: t.Any) -> t.Any:
- return context.environment.call_test(name, item, args, kwargs)
+ return context.environment.call_test(name, item, args, kwargs, context)
except LookupError:
func = bool # type: ignore
diff --git a/src/jinja2/idtracking.py b/src/jinja2/idtracking.py
index d6cb635b2..e6dd8cd11 100644
--- a/src/jinja2/idtracking.py
+++ b/src/jinja2/idtracking.py
@@ -3,6 +3,9 @@
from . import nodes
from .visitor import NodeVisitor
+if t.TYPE_CHECKING:
+ import typing_extensions as te
+
VAR_LOAD_PARAMETER = "param"
VAR_LOAD_RESOLVE = "resolve"
VAR_LOAD_ALIAS = "alias"
@@ -83,7 +86,7 @@ def ref(self, name: str) -> str:
)
return rv
- def copy(self) -> "Symbols":
+ def copy(self) -> "te.Self":
rv = object.__new__(self.__class__)
rv.__dict__.update(self.__dict__)
rv.refs = self.refs.copy()
@@ -118,23 +121,20 @@ def load(self, name: str) -> None:
self._define_ref(name, load=(VAR_LOAD_RESOLVE, name))
def branch_update(self, branch_symbols: t.Sequence["Symbols"]) -> None:
- stores: t.Dict[str, int] = {}
+ stores: t.Set[str] = set()
+
for branch in branch_symbols:
- for target in branch.stores:
- if target in self.stores:
- continue
- stores[target] = stores.get(target, 0) + 1
+ stores.update(branch.stores)
+
+ stores.difference_update(self.stores)
for sym in branch_symbols:
self.refs.update(sym.refs)
self.loads.update(sym.loads)
self.stores.update(sym.stores)
- for name, branch_count in stores.items():
- if branch_count == len(branch_symbols):
- continue
-
- target = self.find_ref(name) # type: ignore
+ for name in stores:
+ target = self.find_ref(name)
assert target is not None, "should not happen"
if self.parent is not None:
diff --git a/src/jinja2/lexer.py b/src/jinja2/lexer.py
index 6dc94b67d..9b1c96979 100644
--- a/src/jinja2/lexer.py
+++ b/src/jinja2/lexer.py
@@ -262,7 +262,7 @@ def __init__(
self.message = message
self.error_class = cls
- def __call__(self, lineno: int, filename: str) -> "te.NoReturn":
+ def __call__(self, lineno: int, filename: t.Optional[str]) -> "te.NoReturn":
raise self.error_class(self.message, lineno, filename)
@@ -757,7 +757,7 @@ def tokeniter(
for idx, token in enumerate(tokens):
# failure group
- if token.__class__ is Failure:
+ if isinstance(token, Failure):
raise token(lineno, filename)
# bygroup is a bit more complex, in that case we
# yield for the current token the first named
@@ -778,7 +778,7 @@ def tokeniter(
data = groups[idx]
if data or token not in ignore_if_empty:
- yield lineno, token, data
+ yield lineno, token, data # type: ignore[misc]
lineno += data.count("\n") + newlines_stripped
newlines_stripped = 0
diff --git a/src/jinja2/loaders.py b/src/jinja2/loaders.py
index 8ca32cbb0..725ecfcd5 100644
--- a/src/jinja2/loaders.py
+++ b/src/jinja2/loaders.py
@@ -204,7 +204,12 @@ def get_source(
if os.path.isfile(filename):
break
else:
- raise TemplateNotFound(template)
+ plural = "path" if len(self.searchpath) == 1 else "paths"
+ paths_str = ", ".join(repr(p) for p in self.searchpath)
+ raise TemplateNotFound(
+ template,
+ f"{template!r} not found in search {plural}: {paths_str}",
+ )
with open(filename, encoding=self.encoding) as f:
contents = f.read()
@@ -322,7 +327,6 @@ def __init__(
assert loader is not None, "A loader was not found for the package."
self._loader = loader
self._archive = None
- template_root = None
if isinstance(loader, zipimport.zipimporter):
self._archive = loader.archive
@@ -339,18 +343,23 @@ def __init__(
elif spec.origin is not None:
roots.append(os.path.dirname(spec.origin))
+ if not roots:
+ raise ValueError(
+ f"The {package_name!r} package was not installed in a"
+ " way that PackageLoader understands."
+ )
+
for root in roots:
root = os.path.join(root, package_path)
if os.path.isdir(root):
template_root = root
break
-
- if template_root is None:
- raise ValueError(
- f"The {package_name!r} package was not installed in a"
- " way that PackageLoader understands."
- )
+ else:
+ raise ValueError(
+ f"PackageLoader could not find a {package_path!r} directory"
+ f" in the {package_name!r} package."
+ )
self._template_root = template_root
@@ -427,7 +436,7 @@ class DictLoader(BaseLoader):
>>> loader = DictLoader({'index.html': 'source here'})
- Because auto reloading is rarely useful this is disabled per default.
+ Because auto reloading is rarely useful this is disabled by default.
"""
def __init__(self, mapping: t.Mapping[str, str]) -> None:
@@ -610,10 +619,7 @@ class ModuleLoader(BaseLoader):
Example usage:
- >>> loader = ChoiceLoader([
- ... ModuleLoader('/path/to/compiled/templates'),
- ... FileSystemLoader('/path/to/templates')
- ... ])
+ >>> loader = ModuleLoader('/path/to/compiled/templates')
Templates can be precompiled with :meth:`Environment.compile_templates`.
"""
diff --git a/src/jinja2/parser.py b/src/jinja2/parser.py
index 817abeccf..5f6dfa893 100644
--- a/src/jinja2/parser.py
+++ b/src/jinja2/parser.py
@@ -487,21 +487,18 @@ def parse_assign_target(
"""
target: nodes.Expr
- if with_namespace and self.stream.look().type == "dot":
- token = self.stream.expect("name")
- next(self.stream) # dot
- attr = self.stream.expect("name")
- target = nodes.NSRef(token.value, attr.value, lineno=token.lineno)
- elif name_only:
+ if name_only:
token = self.stream.expect("name")
target = nodes.Name(token.value, "store", lineno=token.lineno)
else:
if with_tuple:
target = self.parse_tuple(
- simplified=True, extra_end_rules=extra_end_rules
+ simplified=True,
+ extra_end_rules=extra_end_rules,
+ with_namespace=with_namespace,
)
else:
- target = self.parse_primary()
+ target = self.parse_primary(with_namespace=with_namespace)
target.set_ctx("store")
@@ -643,17 +640,25 @@ def parse_unary(self, with_filter: bool = True) -> nodes.Expr:
node = self.parse_filter_expr(node)
return node
- def parse_primary(self) -> nodes.Expr:
+ def parse_primary(self, with_namespace: bool = False) -> nodes.Expr:
+ """Parse a name or literal value. If ``with_namespace`` is enabled, also
+ parse namespace attr refs, for use in assignments."""
token = self.stream.current
node: nodes.Expr
if token.type == "name":
+ next(self.stream)
if token.value in ("true", "false", "True", "False"):
node = nodes.Const(token.value in ("true", "True"), lineno=token.lineno)
elif token.value in ("none", "None"):
node = nodes.Const(None, lineno=token.lineno)
+ elif with_namespace and self.stream.current.type == "dot":
+ # If namespace attributes are allowed at this point, and the next
+ # token is a dot, produce a namespace reference.
+ next(self.stream)
+ attr = self.stream.expect("name")
+ node = nodes.NSRef(token.value, attr.value, lineno=token.lineno)
else:
node = nodes.Name(token.value, "load", lineno=token.lineno)
- next(self.stream)
elif token.type == "string":
next(self.stream)
buf = [token.value]
@@ -683,6 +688,7 @@ def parse_tuple(
with_condexpr: bool = True,
extra_end_rules: t.Optional[t.Tuple[str, ...]] = None,
explicit_parentheses: bool = False,
+ with_namespace: bool = False,
) -> t.Union[nodes.Tuple, nodes.Expr]:
"""Works like `parse_expression` but if multiple expressions are
delimited by a comma a :class:`~jinja2.nodes.Tuple` node is created.
@@ -690,8 +696,9 @@ def parse_tuple(
if no commas where found.
The default parsing mode is a full tuple. If `simplified` is `True`
- only names and literals are parsed. The `no_condexpr` parameter is
- forwarded to :meth:`parse_expression`.
+ only names and literals are parsed; ``with_namespace`` allows namespace
+ attr refs as well. The `no_condexpr` parameter is forwarded to
+ :meth:`parse_expression`.
Because tuples do not require delimiters and may end in a bogus comma
an extra hint is needed that marks the end of a tuple. For example
@@ -704,13 +711,14 @@ def parse_tuple(
"""
lineno = self.stream.current.lineno
if simplified:
- parse = self.parse_primary
- elif with_condexpr:
- parse = self.parse_expression
+
+ def parse() -> nodes.Expr:
+ return self.parse_primary(with_namespace=with_namespace)
+
else:
def parse() -> nodes.Expr:
- return self.parse_expression(with_condexpr=False)
+ return self.parse_expression(with_condexpr=with_condexpr)
args: t.List[nodes.Expr] = []
is_tuple = False
diff --git a/src/jinja2/runtime.py b/src/jinja2/runtime.py
index 9dcc9d4f6..da30c814f 100644
--- a/src/jinja2/runtime.py
+++ b/src/jinja2/runtime.py
@@ -367,7 +367,7 @@ def super(self) -> t.Union["BlockReference", "Undefined"]:
@internalcode
async def _async_call(self) -> str:
- rv = concat(
+ rv = self._context.environment.concat( # type: ignore
[x async for x in self._stack[self._depth](self._context)] # type: ignore
)
@@ -381,7 +381,9 @@ def __call__(self) -> str:
if self._context.environment.is_async:
return self._async_call() # type: ignore
- rv = concat(self._stack[self._depth](self._context))
+ rv = self._context.environment.concat( # type: ignore
+ self._stack[self._depth](self._context)
+ )
if self._context.eval_ctx.autoescape:
return Markup(rv)
@@ -792,8 +794,8 @@ def __repr__(self) -> str:
class Undefined:
- """The default undefined type. This undefined type can be printed and
- iterated over, but every other access will raise an :exc:`UndefinedError`:
+ """The default undefined type. This can be printed, iterated, and treated as
+ a boolean. Any other operation will raise an :exc:`UndefinedError`.
>>> foo = Undefined(name='foo')
>>> str(foo)
@@ -858,7 +860,11 @@ def _fail_with_undefined_error(
@internalcode
def __getattr__(self, name: str) -> t.Any:
- if name[:2] == "__":
+ # Raise AttributeError on requests for names that appear to be unimplemented
+ # dunder methods to keep Python's internal protocol probing behaviors working
+ # properly in cases where another exception type could cause unexpected or
+ # difficult-to-diagnose failures.
+ if name[:2] == "__" and name[-2:] == "__":
raise AttributeError(name)
return self._fail_with_undefined_error()
@@ -982,10 +988,20 @@ class ChainableUndefined(Undefined):
def __html__(self) -> str:
return str(self)
- def __getattr__(self, _: str) -> "ChainableUndefined":
+ def __getattr__(self, name: str) -> "ChainableUndefined":
+ # Raise AttributeError on requests for names that appear to be unimplemented
+ # dunder methods to avoid confusing Python with truthy non-method objects that
+ # do not implement the protocol being probed for. e.g., copy.copy(Undefined())
+ # fails spectacularly if getattr(Undefined(), '__setstate__') returns an
+ # Undefined object instead of raising AttributeError to signal that it does not
+ # support that style of object initialization.
+ if name[:2] == "__" and name[-2:] == "__":
+ raise AttributeError(name)
+
return self
- __getitem__ = __getattr__ # type: ignore
+ def __getitem__(self, _name: str) -> "ChainableUndefined": # type: ignore[override]
+ return self
class DebugUndefined(Undefined):
@@ -1044,13 +1060,3 @@ class StrictUndefined(Undefined):
__iter__ = __str__ = __len__ = Undefined._fail_with_undefined_error
__eq__ = __ne__ = __bool__ = __hash__ = Undefined._fail_with_undefined_error
__contains__ = Undefined._fail_with_undefined_error
-
-
-# Remove slots attributes, after the metaclass is applied they are
-# unneeded and contain wrong data for subclasses.
-del (
- Undefined.__slots__,
- ChainableUndefined.__slots__,
- DebugUndefined.__slots__,
- StrictUndefined.__slots__,
-)
diff --git a/src/jinja2/sandbox.py b/src/jinja2/sandbox.py
index ce276156c..9c9dae22f 100644
--- a/src/jinja2/sandbox.py
+++ b/src/jinja2/sandbox.py
@@ -8,6 +8,7 @@
from _string import formatter_field_name_split # type: ignore
from collections import abc
from collections import deque
+from functools import update_wrapper
from string import Formatter
from markupsafe import EscapeFormatter
@@ -60,7 +61,9 @@
),
(
abc.MutableSequence,
- frozenset(["append", "reverse", "insert", "sort", "extend", "remove"]),
+ frozenset(
+ ["append", "clear", "pop", "reverse", "insert", "sort", "extend", "remove"]
+ ),
),
(
deque,
@@ -81,20 +84,6 @@
)
-def inspect_format_method(callable: t.Callable[..., t.Any]) -> t.Optional[str]:
- if not isinstance(
- callable, (types.MethodType, types.BuiltinMethodType)
- ) or callable.__name__ not in ("format", "format_map"):
- return None
-
- obj = callable.__self__
-
- if isinstance(obj, str):
- return obj
-
- return None
-
-
def safe_range(*args: int) -> range:
"""A range that can't generate ranges with a length of more than
MAX_RANGE items.
@@ -314,6 +303,9 @@ def getitem(
except AttributeError:
pass
else:
+ fmt = self.wrap_str_format(value)
+ if fmt is not None:
+ return fmt
if self.is_safe_attribute(obj, argument, value):
return value
return self.unsafe_undefined(obj, argument)
@@ -331,6 +323,9 @@ def getattr(self, obj: t.Any, attribute: str) -> t.Union[t.Any, Undefined]:
except (TypeError, LookupError):
pass
else:
+ fmt = self.wrap_str_format(value)
+ if fmt is not None:
+ return fmt
if self.is_safe_attribute(obj, attribute, value):
return value
return self.unsafe_undefined(obj, attribute)
@@ -346,34 +341,49 @@ def unsafe_undefined(self, obj: t.Any, attribute: str) -> Undefined:
exc=SecurityError,
)
- def format_string(
- self,
- s: str,
- args: t.Tuple[t.Any, ...],
- kwargs: t.Dict[str, t.Any],
- format_func: t.Optional[t.Callable[..., t.Any]] = None,
- ) -> str:
- """If a format call is detected, then this is routed through this
- method so that our safety sandbox can be used for it.
+ def wrap_str_format(self, value: t.Any) -> t.Optional[t.Callable[..., str]]:
+ """If the given value is a ``str.format`` or ``str.format_map`` method,
+ return a new function than handles sandboxing. This is done at access
+ rather than in :meth:`call`, so that calls made without ``call`` are
+ also sandboxed.
"""
+ if not isinstance(
+ value, (types.MethodType, types.BuiltinMethodType)
+ ) or value.__name__ not in ("format", "format_map"):
+ return None
+
+ f_self: t.Any = value.__self__
+
+ if not isinstance(f_self, str):
+ return None
+
+ str_type: t.Type[str] = type(f_self)
+ is_format_map = value.__name__ == "format_map"
formatter: SandboxedFormatter
- if isinstance(s, Markup):
- formatter = SandboxedEscapeFormatter(self, escape=s.escape)
+
+ if isinstance(f_self, Markup):
+ formatter = SandboxedEscapeFormatter(self, escape=f_self.escape)
else:
formatter = SandboxedFormatter(self)
- if format_func is not None and format_func.__name__ == "format_map":
- if len(args) != 1 or kwargs:
- raise TypeError(
- "format_map() takes exactly one argument"
- f" {len(args) + (kwargs is not None)} given"
- )
+ vformat = formatter.vformat
+
+ def wrapper(*args: t.Any, **kwargs: t.Any) -> str:
+ if is_format_map:
+ if kwargs:
+ raise TypeError("format_map() takes no keyword arguments")
+
+ if len(args) != 1:
+ raise TypeError(
+ f"format_map() takes exactly one argument ({len(args)} given)"
+ )
+
+ kwargs = args[0]
+ args = ()
- kwargs = args[0]
- args = ()
+ return str_type(vformat(f_self, args, kwargs))
- rv = formatter.vformat(s, args, kwargs)
- return type(s)(rv)
+ return update_wrapper(wrapper, value)
def call(
__self, # noqa: B902
@@ -383,9 +393,6 @@ def call(
**kwargs: t.Any,
) -> t.Any:
"""Call an object from sandboxed code."""
- fmt = inspect_format_method(__obj)
- if fmt is not None:
- return __self.format_string(fmt, args, kwargs, __obj)
# the double prefixes are to avoid double keyword argument
# errors when proxying the call.
diff --git a/src/jinja2/utils.py b/src/jinja2/utils.py
index 5c1ff5d7b..7c922629a 100644
--- a/src/jinja2/utils.py
+++ b/src/jinja2/utils.py
@@ -18,8 +18,17 @@
F = t.TypeVar("F", bound=t.Callable[..., t.Any])
-# special singleton representing missing values for the runtime
-missing: t.Any = type("MissingType", (), {"__repr__": lambda x: "missing"})()
+
+class _MissingType:
+ def __repr__(self) -> str:
+ return "missing"
+
+ def __reduce__(self) -> str:
+ return "missing"
+
+
+missing: t.Any = _MissingType()
+"""Special singleton representing missing values for the runtime."""
internal_code: t.MutableSet[CodeType] = set()
@@ -324,6 +333,8 @@ def trim_url(x: str) -> str:
elif (
"@" in middle
and not middle.startswith("www.")
+ # ignore values like `@a@b`
+ and not middle.startswith("@")
and ":" not in middle
and _email_re.match(middle)
):
@@ -453,7 +464,7 @@ def __setstate__(self, d: t.Mapping[str, t.Any]) -> None:
def __getnewargs__(self) -> t.Tuple[t.Any, ...]:
return (self.capacity,)
- def copy(self) -> "LRUCache":
+ def copy(self) -> "te.Self":
"""Return a shallow copy of the instance."""
rv = self.__class__(self.capacity)
rv._mapping.update(self._mapping)
diff --git a/tests/test_api.py b/tests/test_api.py
index ff3fcb138..4472b85ac 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -323,8 +323,6 @@ def test_default_undefined(self):
assert und1 == und2
assert und1 != 42
assert hash(und1) == hash(und2) == hash(Undefined())
- with pytest.raises(AttributeError):
- getattr(Undefined, "__slots__") # noqa: B009
def test_chainable_undefined(self):
env = Environment(undefined=ChainableUndefined)
@@ -335,8 +333,6 @@ def test_chainable_undefined(self):
assert env.from_string("{{ foo.missing }}").render(foo=42) == ""
assert env.from_string("{{ not missing }}").render() == "True"
pytest.raises(UndefinedError, env.from_string("{{ missing - 1}}").render)
- with pytest.raises(AttributeError):
- getattr(ChainableUndefined, "__slots__") # noqa: B009
# The following tests ensure subclass functionality works as expected
assert env.from_string('{{ missing.bar["baz"] }}').render() == ""
@@ -368,8 +364,6 @@ def test_debug_undefined(self):
str(DebugUndefined(hint=undefined_hint))
== f"{{{{ undefined value printed: {undefined_hint} }}}}"
)
- with pytest.raises(AttributeError):
- getattr(DebugUndefined, "__slots__") # noqa: B009
def test_strict_undefined(self):
env = Environment(undefined=StrictUndefined)
@@ -386,8 +380,6 @@ def test_strict_undefined(self):
env.from_string('{{ missing|default("default", true) }}').render()
== "default"
)
- with pytest.raises(AttributeError):
- getattr(StrictUndefined, "__slots__") # noqa: B009
assert env.from_string('{{ "foo" if false }}').render() == ""
def test_indexing_gives_undefined(self):
@@ -433,3 +425,11 @@ class CustomEnvironment(Environment):
env = CustomEnvironment()
tmpl = env.from_string("{{ foo }}")
assert tmpl.render() == "resolve-foo"
+
+
+def test_overlay_enable_async(env):
+ assert not env.is_async
+ assert not env.overlay().is_async
+ env_async = env.overlay(enable_async=True)
+ assert env_async.is_async
+ assert not env_async.overlay(enable_async=False).is_async
diff --git a/tests/test_async_filters.py b/tests/test_async_filters.py
index 88ae5f41e..6e44b9a29 100644
--- a/tests/test_async_filters.py
+++ b/tests/test_async_filters.py
@@ -266,6 +266,13 @@ def test_slice(env_async, items):
)
+def test_unique_with_async_gen(env_async):
+ items = ["a", "b", "c", "c", "a", "d", "z"]
+ tmpl = env_async.from_string("{{ items|reject('==', 'z')|unique|list }}")
+ out = tmpl.render(items=items)
+ assert out == "['a', 'b', 'c', 'd']"
+
+
def test_custom_async_filter(env_async, run_async_fn):
async def customfilter(val):
return str(val)
diff --git a/tests/test_compile.py b/tests/test_compile.py
index 42a773f21..e1a5391ea 100644
--- a/tests/test_compile.py
+++ b/tests/test_compile.py
@@ -1,6 +1,9 @@
import os
import re
+import pytest
+
+from jinja2 import UndefinedError
from jinja2.environment import Environment
from jinja2.loaders import DictLoader
@@ -26,3 +29,80 @@ def test_import_as_with_context_deterministic(tmp_path):
expect = [f"'bar{i}': " for i in range(10)]
found = re.findall(r"'bar\d': ", content)[:10]
assert found == expect
+
+
+def test_top_level_set_vars_unpacking_deterministic(tmp_path):
+ src = "\n".join(f"{{% set a{i}, b{i}, c{i} = tuple_var{i} %}}" for i in range(10))
+ env = Environment(loader=DictLoader({"foo": src}))
+ env.compile_templates(tmp_path, zip=None)
+ name = os.listdir(tmp_path)[0]
+ content = (tmp_path / name).read_text("utf8")
+ expect = [
+ f"context.vars.update({{'a{i}': l_0_a{i}, 'b{i}': l_0_b{i}, 'c{i}': l_0_c{i}}})"
+ for i in range(10)
+ ]
+ found = re.findall(
+ r"context\.vars\.update\(\{'a\d': l_0_a\d, 'b\d': l_0_b\d, 'c\d': l_0_c\d\}\)",
+ content,
+ )[:10]
+ assert found == expect
+ expect = [
+ f"context.exported_vars.update(('a{i}', 'b{i}', 'c{i}'))" for i in range(10)
+ ]
+ found = re.findall(
+ r"context\.exported_vars\.update\(\('a\d', 'b\d', 'c\d'\)\)",
+ content,
+ )[:10]
+ assert found == expect
+
+
+def test_loop_set_vars_unpacking_deterministic(tmp_path):
+ src = "\n".join(f" {{% set a{i}, b{i}, c{i} = tuple_var{i} %}}" for i in range(10))
+ src = f"{{% for i in seq %}}\n{src}\n{{% endfor %}}"
+ env = Environment(loader=DictLoader({"foo": src}))
+ env.compile_templates(tmp_path, zip=None)
+ name = os.listdir(tmp_path)[0]
+ content = (tmp_path / name).read_text("utf8")
+ expect = [
+ f"_loop_vars.update({{'a{i}': l_1_a{i}, 'b{i}': l_1_b{i}, 'c{i}': l_1_c{i}}})"
+ for i in range(10)
+ ]
+ found = re.findall(
+ r"_loop_vars\.update\(\{'a\d': l_1_a\d, 'b\d': l_1_b\d, 'c\d': l_1_c\d\}\)",
+ content,
+ )[:10]
+ assert found == expect
+
+
+def test_block_set_vars_unpacking_deterministic(tmp_path):
+ src = "\n".join(f" {{% set a{i}, b{i}, c{i} = tuple_var{i} %}}" for i in range(10))
+ src = f"{{% block test %}}\n{src}\n{{% endblock test %}}"
+ env = Environment(loader=DictLoader({"foo": src}))
+ env.compile_templates(tmp_path, zip=None)
+ name = os.listdir(tmp_path)[0]
+ content = (tmp_path / name).read_text("utf8")
+ expect = [
+ f"_block_vars.update({{'a{i}': l_0_a{i}, 'b{i}': l_0_b{i}, 'c{i}': l_0_c{i}}})"
+ for i in range(10)
+ ]
+ found = re.findall(
+ r"_block_vars\.update\(\{'a\d': l_0_a\d, 'b\d': l_0_b\d, 'c\d': l_0_c\d\}\)",
+ content,
+ )[:10]
+ assert found == expect
+
+
+def test_undefined_import_curly_name():
+ env = Environment(
+ loader=DictLoader(
+ {
+ "{bad}": "{% from 'macro' import m %}{{ m() }}",
+ "macro": "",
+ }
+ )
+ )
+
+ # Must not raise `NameError: 'bad' is not defined`, as that would indicate
+ # that `{bad}` is being interpreted as an f-string. It must be escaped.
+ with pytest.raises(UndefinedError):
+ env.get_template("{bad}").render()
diff --git a/tests/test_core_tags.py b/tests/test_core_tags.py
index 4bb95e024..2d847a2c9 100644
--- a/tests/test_core_tags.py
+++ b/tests/test_core_tags.py
@@ -538,6 +538,14 @@ def test_namespace_macro(self, env_trim):
)
assert tmpl.render() == "13|37"
+ def test_namespace_set_tuple(self, env_trim):
+ tmpl = env_trim.from_string(
+ "{% set ns = namespace(a=12, b=36) %}"
+ "{% set ns.a, ns.b = ns.a + 1, ns.b + 1 %}"
+ "{{ ns.a }}|{{ ns.b }}"
+ )
+ assert tmpl.render() == "13|37"
+
def test_block_escaping_filtered(self):
env = Environment(autoescape=True)
tmpl = env.from_string(
diff --git a/tests/test_filters.py b/tests/test_filters.py
index d8e9114d0..2cb53ac9d 100644
--- a/tests/test_filters.py
+++ b/tests/test_filters.py
@@ -196,6 +196,7 @@ def test_indent_width_string(self, env):
("abc", "0"),
("32.32", "32"),
("12345678901234567890", "12345678901234567890"),
+ ("1e10000", "0"),
),
)
def test_int(self, env, value, expect):
diff --git a/tests/test_loader.py b/tests/test_loader.py
index e0cff6720..377290b71 100644
--- a/tests/test_loader.py
+++ b/tests/test_loader.py
@@ -179,6 +179,24 @@ def test_filename_normpath(self):
t = e.get_template("foo/test.html")
assert t.filename == str(self.searchpath / "foo" / "test.html")
+ def test_error_includes_paths(self, env, filesystem_loader):
+ env.loader = filesystem_loader
+
+ with pytest.raises(TemplateNotFound) as info:
+ env.get_template("missing")
+
+ e_str = str(info.value)
+ assert e_str.startswith("'missing' not found in search path: ")
+
+ filesystem_loader.searchpath.append("other")
+
+ with pytest.raises(TemplateNotFound) as info:
+ env.get_template("missing")
+
+ e_str = str(info.value)
+ assert e_str.startswith("'missing' not found in search paths: ")
+ assert ", 'other'" in e_str
+
class TestModuleLoader:
archive = None
@@ -411,3 +429,8 @@ def exec_module(self, module):
assert "test.html" in package_loader.list_templates()
finally:
sys.meta_path[:] = before
+
+
+def test_package_loader_no_dir() -> None:
+ with pytest.raises(ValueError, match="could not find a 'templates' directory"):
+ PackageLoader("jinja2")
diff --git a/tests/test_nativetypes.py b/tests/test_nativetypes.py
index 355799cc7..df802a15e 100644
--- a/tests/test_nativetypes.py
+++ b/tests/test_nativetypes.py
@@ -177,3 +177,13 @@ def test_macro(env):
result = t.render()
assert result == 2
assert isinstance(result, int)
+
+
+def test_block(env):
+ t = env.from_string(
+ "{% block b %}{% for i in range(1) %}{{ loop.index }}{% endfor %}"
+ "{% endblock %}{{ self.b() }}"
+ )
+ result = t.render()
+ assert result == 11
+ assert isinstance(result, int)
diff --git a/tests/test_regression.py b/tests/test_regression.py
index 7bd4d1564..93d72c5e6 100644
--- a/tests/test_regression.py
+++ b/tests/test_regression.py
@@ -737,6 +737,28 @@ def test_nested_loop_scoping(self, env):
)
assert tmpl.render() == "hellohellohello"
+ def test_pass_context_with_select(self, env):
+ @pass_context
+ def is_foo(ctx, s):
+ assert ctx is not None
+ return s == "foo"
+
+ env.tests["foo"] = is_foo
+ tmpl = env.from_string(
+ "{% for x in ['one', 'foo'] | select('foo') %}{{ x }}{% endfor %}"
+ )
+ assert tmpl.render() == "foo"
+
+
+def test_load_parameter_when_set_in_all_if_branches(env):
+ tmpl = env.from_string(
+ "{% if True %}{{ a.b }}{% set a = 1 %}"
+ "{% elif False %}{% set a = 2 %}"
+ "{% else %}{% set a = 3 %}{% endif %}"
+ "{{ a }}"
+ )
+ assert tmpl.render(a={"b": 0}) == "01"
+
@pytest.mark.parametrize("unicode_char", ["\N{FORM FEED}", "\x85"])
def test_unicode_whitespace(env, unicode_char):
diff --git a/tests/test_runtime.py b/tests/test_runtime.py
index 1978c6410..3cd3be15f 100644
--- a/tests/test_runtime.py
+++ b/tests/test_runtime.py
@@ -1,6 +1,15 @@
+import copy
import itertools
+import pickle
+import pytest
+
+from jinja2 import ChainableUndefined
+from jinja2 import DebugUndefined
+from jinja2 import StrictUndefined
from jinja2 import Template
+from jinja2 import TemplateRuntimeError
+from jinja2 import Undefined
from jinja2.runtime import LoopContext
TEST_IDX_TEMPLATE_STR_1 = (
@@ -73,3 +82,44 @@ def __call__(self, *args, **kwargs):
out = t.render(calc=Calc())
# Would be "1" if context argument was passed.
assert out == "0"
+
+
+_undefined_types = (Undefined, ChainableUndefined, DebugUndefined, StrictUndefined)
+
+
+@pytest.mark.parametrize("undefined_type", _undefined_types)
+def test_undefined_copy(undefined_type):
+ undef = undefined_type("a hint", ["foo"], "a name", TemplateRuntimeError)
+ copied = copy.copy(undef)
+
+ assert copied is not undef
+ assert copied._undefined_hint is undef._undefined_hint
+ assert copied._undefined_obj is undef._undefined_obj
+ assert copied._undefined_name is undef._undefined_name
+ assert copied._undefined_exception is undef._undefined_exception
+
+
+@pytest.mark.parametrize("undefined_type", _undefined_types)
+def test_undefined_deepcopy(undefined_type):
+ undef = undefined_type("a hint", ["foo"], "a name", TemplateRuntimeError)
+ copied = copy.deepcopy(undef)
+
+ assert copied._undefined_hint is undef._undefined_hint
+ assert copied._undefined_obj is not undef._undefined_obj
+ assert copied._undefined_obj == undef._undefined_obj
+ assert copied._undefined_name is undef._undefined_name
+ assert copied._undefined_exception is undef._undefined_exception
+
+
+@pytest.mark.parametrize("undefined_type", _undefined_types)
+def test_undefined_pickle(undefined_type):
+ undef = undefined_type("a hint", ["foo"], "a name", TemplateRuntimeError)
+ copied = pickle.loads(pickle.dumps(undef))
+
+ assert copied._undefined_hint is not undef._undefined_hint
+ assert copied._undefined_hint == undef._undefined_hint
+ assert copied._undefined_obj is not undef._undefined_obj
+ assert copied._undefined_obj == undef._undefined_obj
+ assert copied._undefined_name is not undef._undefined_name
+ assert copied._undefined_name == undef._undefined_name
+ assert copied._undefined_exception is undef._undefined_exception
diff --git a/tests/test_security.py b/tests/test_security.py
index 0e8dc5c03..864d5f7f9 100644
--- a/tests/test_security.py
+++ b/tests/test_security.py
@@ -58,6 +58,8 @@ def test_unsafe(self, env):
def test_immutable_environment(self, env):
env = ImmutableSandboxedEnvironment()
pytest.raises(SecurityError, env.from_string("{{ [].append(23) }}").render)
+ pytest.raises(SecurityError, env.from_string("{{ [].clear() }}").render)
+ pytest.raises(SecurityError, env.from_string("{{ [1].pop() }}").render)
pytest.raises(SecurityError, env.from_string("{{ {1:2}.clear() }}").render)
def test_restricted(self, env):
@@ -171,3 +173,20 @@ def test_safe_format_all_okay(self):
'{{ ("a{x.foo}b{y}"|safe).format_map({"x":{"foo": 42}, "y":""}) }}'
)
assert t.render() == "a42b<foo>"
+
+ def test_indirect_call(self):
+ def run(value, arg):
+ return value.run(arg)
+
+ env = SandboxedEnvironment()
+ env.filters["run"] = run
+ t = env.from_string(
+ """{% set
+ ns = namespace(run="{0.__call__.__builtins__[__import__]}".format)
+ %}
+ {{ ns | run(not_here) }}
+ """
+ )
+
+ with pytest.raises(SecurityError):
+ t.render()
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 7b58af144..b50a6b4c6 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,3 +1,4 @@
+import copy
import pickle
import random
from collections import deque
@@ -141,6 +142,14 @@ def test_escape_urlize_target(self):
"http://example.org"
)
+ def test_urlize_mail_mastodon(self):
+ fr = "nabijaczleweli@nabijaczleweli.xyz\n@eater@cijber.social\n"
+ to = (
+ ''
+ "nabijaczleweli@nabijaczleweli.xyz\n@eater@cijber.social\n"
+ )
+ assert urlize(fr) == to
+
class TestLoremIpsum:
def test_lorem_ipsum_markup(self):
@@ -183,3 +192,14 @@ def test_consume():
consume(x)
with pytest.raises(StopIteration):
next(x)
+
+
+@pytest.mark.parametrize("protocol", range(pickle.HIGHEST_PROTOCOL + 1))
+def test_pickle_missing(protocol: int) -> None:
+ """Test that missing can be pickled while remaining a singleton."""
+ assert pickle.loads(pickle.dumps(missing, protocol)) is missing
+
+
+def test_copy_missing() -> None:
+ """Test that missing can be copied while remaining a singleton."""
+ assert copy.copy(missing) is missing