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