From 4eae1848a59969cf414a526ad39fca9e2d5e79ec Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Fri, 13 Jun 2025 11:35:46 +0200 Subject: [PATCH 1/5] Use the currently handled exception as UndefinedError.__cause__ Add ``Undefined._undefined_cause`` attribute, which is used as the default ``__cause__`` for exceptions raised by that ``Undefined``. When an ``Undefined`` is created, automatically set this attribute to the currently handled exception (or ``None``). This mirrors Python's own exception chaining. Also, restructure `Environment.getitem` and `Environment.getattr` uses exception chaning to preserve the exceptions they encounter. --- CHANGES.rst | 4 +++ docs/api.rst | 12 ++++++++ src/jinja2/environment.py | 13 ++++---- src/jinja2/runtime.py | 10 +++++- tests/test_api.py | 65 ++++++++++++++++++++++++++++++--------- tests/test_runtime.py | 24 +++++++++++++++ 6 files changed, 106 insertions(+), 22 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 338dc9966..782fa3c6f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -13,6 +13,10 @@ Unreleased - Use modern packaging metadata with ``pyproject.toml`` instead of ``setup.cfg``. :pr:`1793` - Use ``flit_core`` instead of ``setuptools`` as build backend. +- When ``Undefined`` is created in an ``except`` block, the handled + exception is stored in a new attribute, ``_undefined_context``, + and used as the default ``__context__`` for errors raised by + ``_fail_with_undefined_error``. Version 3.1.6 diff --git a/docs/api.rst b/docs/api.rst index a7e32b215..d13160cac 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -289,6 +289,9 @@ others fail. The closest to regular Python behavior is the :class:`StrictUndefined` which disallows all operations beside testing if it's an undefined object. +When :class:`Undefined` is created in an ``except`` or ``finally`` clause, + + .. autoclass:: jinja2.Undefined() .. attribute:: _undefined_hint @@ -311,6 +314,15 @@ disallows all operations beside testing if it's an undefined object. The exception that the undefined object wants to raise. This is usually one of :exc:`UndefinedError` or :exc:`SecurityError`. + .. attribute:: _undefined_context + + The default ``__context__`` for exceptions raised when operations + on this undefined value fail. + + When :class:`Undefined` is created while an exception is being + handled (for example, inside an ``except`` clause), + ``_undefined_context`` is automatically set to the handled exception. + .. method:: _fail_with_undefined_error(\*args, \**kwargs) When called with any arguments this method raises diff --git a/src/jinja2/environment.py b/src/jinja2/environment.py index acaaffb59..f62592aee 100644 --- a/src/jinja2/environment.py +++ b/src/jinja2/environment.py @@ -473,12 +473,12 @@ def getitem(self, obj: t.Any, argument: str | t.Any) -> t.Any | Undefined: try: attr = str(argument) except Exception: - pass + return self.undefined(obj=obj, name=argument) else: try: return getattr(obj, attr) except AttributeError: - pass + return self.undefined(obj=obj, name=argument) return self.undefined(obj=obj, name=argument) def getattr(self, obj: t.Any, attribute: str) -> t.Any: @@ -488,11 +488,10 @@ def getattr(self, obj: t.Any, attribute: str) -> t.Any: try: return getattr(obj, attribute) except AttributeError: - pass - try: - return obj[attribute] - except (TypeError, LookupError, AttributeError): - return self.undefined(obj=obj, name=attribute) + try: + return obj[attribute] + except (TypeError, LookupError, AttributeError): + return self.undefined(obj=obj, name=attribute) def _filter_test_common( self, diff --git a/src/jinja2/runtime.py b/src/jinja2/runtime.py index 667c0416d..c40852144 100644 --- a/src/jinja2/runtime.py +++ b/src/jinja2/runtime.py @@ -813,6 +813,7 @@ class Undefined: "_undefined_obj", "_undefined_name", "_undefined_exception", + "_undefined_context", ) def __init__( @@ -827,6 +828,10 @@ def __init__( self._undefined_name = name self._undefined_exception = exc + cause: BaseException | None + _, cause, _ = sys.exc_info() + self._undefined_context = cause + @property def _undefined_message(self) -> str: """Build a message about the undefined value based on how it was @@ -856,7 +861,10 @@ def _fail_with_undefined_error( """Raise an :exc:`UndefinedError` when operations are performed on the undefined value. """ - raise self._undefined_exception(self._undefined_message) + exception = self._undefined_exception(self._undefined_message) + if exception.__context__ is None: + exception.__context__ = self._undefined_context + raise exception @internalcode def __getattr__(self, name: str) -> t.Any: diff --git a/tests/test_api.py b/tests/test_api.py index 4472b85ac..35a7e4d76 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,3 +1,4 @@ +import contextlib import shutil import tempfile from pathlib import Path @@ -253,6 +254,30 @@ def test_dump_stream(self, env): shutil.rmtree(tmp) +@contextlib.contextmanager +def raises_cause_chain(expected_exception, *expected_chain, **kwargs): + """Like ``, but assert a specific __cause__/__context__ chain + + Used `with pytest.raises(expected_exception):`, but additional positional + arguments must match types of exceptions in the __cause__/__context__ chain + ("The above exception was the direct cause of the following exception" and + "During handling of the above exception, another exception occurred" in + tracebacks). + """ + with pytest.raises(expected_exception, **kwargs) as info: + yield info + got_chain = [] + current = info.value + while current: + got_chain.append(type(current)) + current = current.__cause__ or current.__context__ + try: + assert got_chain == [expected_exception, *expected_chain] + except AssertionError as exc: + raise exc from info.value + return info + + class TestUndefined: def test_stopiteration_is_undefined(self): def test(): @@ -295,7 +320,8 @@ def error(self, msg, *args): logging_undefined = make_logging_undefined(DebugLogger()) env = Environment(undefined=logging_undefined) assert env.from_string("{{ missing }}").render() == "" - pytest.raises(UndefinedError, env.from_string("{{ missing.attribute }}").render) + with raises_cause_chain(UndefinedError): + env.from_string("{{ missing.attribute }}").render() assert env.from_string("{{ missing|list }}").render() == "[]" assert env.from_string("{{ missing is not defined }}").render() == "True" assert env.from_string("{{ foo.missing }}").render(foo=42) == "" @@ -311,12 +337,14 @@ def error(self, msg, *args): def test_default_undefined(self): env = Environment(undefined=Undefined) assert env.from_string("{{ missing }}").render() == "" - pytest.raises(UndefinedError, env.from_string("{{ missing.attribute }}").render) + with raises_cause_chain(UndefinedError): + env.from_string("{{ missing.attribute }}").render() assert env.from_string("{{ missing|list }}").render() == "[]" assert env.from_string("{{ missing is not defined }}").render() == "True" 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 raises_cause_chain(UndefinedError): + env.from_string("{{ missing - 1}}").render() assert env.from_string("{{ 'foo' in missing }}").render() == "False" und1 = Undefined(name="x") und2 = Undefined(name="y") @@ -332,7 +360,8 @@ def test_chainable_undefined(self): assert env.from_string("{{ missing is not defined }}").render() == "True" 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 raises_cause_chain(UndefinedError): + env.from_string("{{ missing - 1}}").render() # The following tests ensure subclass functionality works as expected assert env.from_string('{{ missing.bar["baz"] }}').render() == "" @@ -351,7 +380,8 @@ def test_chainable_undefined(self): def test_debug_undefined(self): env = Environment(undefined=DebugUndefined) assert env.from_string("{{ missing }}").render() == "{{ missing }}" - pytest.raises(UndefinedError, env.from_string("{{ missing.attribute }}").render) + with raises_cause_chain(UndefinedError): + env.from_string("{{ missing.attribute }}").render() assert env.from_string("{{ missing|list }}").render() == "[]" assert env.from_string("{{ missing is not defined }}").render() == "True" assert ( @@ -367,15 +397,21 @@ def test_debug_undefined(self): def test_strict_undefined(self): env = Environment(undefined=StrictUndefined) - pytest.raises(UndefinedError, env.from_string("{{ missing }}").render) - pytest.raises(UndefinedError, env.from_string("{{ missing.attribute }}").render) - pytest.raises(UndefinedError, env.from_string("{{ missing|list }}").render) - pytest.raises(UndefinedError, env.from_string("{{ 'foo' in missing }}").render) + with raises_cause_chain(UndefinedError): + env.from_string("{{ missing }}").render() + with raises_cause_chain(UndefinedError): + env.from_string("{{ missing.attribute }}").render() + with raises_cause_chain(UndefinedError): + env.from_string("{{ missing|list }}").render() + with raises_cause_chain(UndefinedError): + env.from_string("{{ 'foo' in missing }}").render() assert env.from_string("{{ missing is not defined }}").render() == "True" - pytest.raises( - UndefinedError, env.from_string("{{ foo.missing }}").render, foo=42 - ) - pytest.raises(UndefinedError, env.from_string("{{ not missing }}").render) + with raises_cause_chain(UndefinedError, TypeError, AttributeError): + env.from_string("{{ foo.missing }}").render(foo=42) + with raises_cause_chain(UndefinedError, AttributeError, TypeError): + env.from_string("{{ foo['missing'] }}").render(foo=42) + with raises_cause_chain(UndefinedError): + env.from_string("{{ not missing }}").render() assert ( env.from_string('{{ missing|default("default", true) }}').render() == "default" @@ -384,7 +420,8 @@ def test_strict_undefined(self): def test_indexing_gives_undefined(self): t = Template("{{ var[42].foo }}") - pytest.raises(UndefinedError, t.render, var=0) + with raises_cause_chain(UndefinedError, TypeError): + t.render(var=0) def test_none_gives_proper_error(self): with pytest.raises(UndefinedError, match="'None' has no attribute 'split'"): diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 3cd3be15f..457f0d881 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -123,3 +123,27 @@ def test_undefined_pickle(undefined_type): assert copied._undefined_name is not undef._undefined_name assert copied._undefined_name == undef._undefined_name assert copied._undefined_exception is undef._undefined_exception + + +@pytest.mark.parametrize("undefined_type", _undefined_types) +def test_undefined_cause(undefined_type): + exception = ValueError("foo") + try: + raise exception + except ValueError: + undef = undefined_type() + assert undef._undefined_context is exception + try: + undef._fail_with_undefined_error() + except TemplateRuntimeError as exc: + assert exc.__context__ is exception + + +@pytest.mark.parametrize("undefined_type", _undefined_types) +def test_undefined_no_cause(undefined_type): + undef = undefined_type() + assert undef._undefined_context is None + try: + undef._fail_with_undefined_error() + except TemplateRuntimeError as exc: + assert exc.__context__ is None From 3c1abcd1e5a8af58cf0064066ca7e0548acf9426 Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Fri, 13 Jun 2025 14:21:14 +0200 Subject: [PATCH 2/5] Fix docstring --- tests/test_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_api.py b/tests/test_api.py index 35a7e4d76..5b5f1ee2d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -256,7 +256,7 @@ def test_dump_stream(self, env): @contextlib.contextmanager def raises_cause_chain(expected_exception, *expected_chain, **kwargs): - """Like ``, but assert a specific __cause__/__context__ chain + """Like pytest.raises, but assert a specific __cause__/__context__ chain Used `with pytest.raises(expected_exception):`, but additional positional arguments must match types of exceptions in the __cause__/__context__ chain From 90a7e9c99bc4a9068788201f78c9e03f33b7ec9d Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Mon, 16 Jun 2025 14:47:08 +0200 Subject: [PATCH 3/5] Docs fixes --- CHANGES.rst | 1 + docs/api.rst | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 782fa3c6f..7fa37e4e5 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -17,6 +17,7 @@ Unreleased exception is stored in a new attribute, ``_undefined_context``, and used as the default ``__context__`` for errors raised by ``_fail_with_undefined_error``. + :issue:`2103` Version 3.1.6 diff --git a/docs/api.rst b/docs/api.rst index d13160cac..be0d243fd 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -289,8 +289,6 @@ others fail. The closest to regular Python behavior is the :class:`StrictUndefined` which disallows all operations beside testing if it's an undefined object. -When :class:`Undefined` is created in an ``except`` or ``finally`` clause, - .. autoclass:: jinja2.Undefined() @@ -316,6 +314,8 @@ When :class:`Undefined` is created in an ``except`` or ``finally`` clause, .. attribute:: _undefined_context + .. versionadded:: 3.2 + The default ``__context__`` for exceptions raised when operations on this undefined value fail. From 89bcbb2678adae3712e413f4e060a819ab8abcac Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Mon, 16 Jun 2025 15:10:17 +0200 Subject: [PATCH 4/5] Adjust SandboxedEnvironment; add tests for it and NativeEnvironment --- src/jinja2/sandbox.py | 4 ++-- tests/test_api.py | 44 ++++++++++++++++++++++++++++++++++--------- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/src/jinja2/sandbox.py b/src/jinja2/sandbox.py index 05b3950b8..5fd164e9a 100644 --- a/src/jinja2/sandbox.py +++ b/src/jinja2/sandbox.py @@ -299,7 +299,7 @@ def getitem(self, obj: t.Any, argument: str | t.Any) -> t.Any | Undefined: try: value = getattr(obj, attr) except AttributeError: - pass + return self.undefined(obj=obj, name=argument) else: fmt = self.wrap_str_format(value) if fmt is not None: @@ -319,7 +319,7 @@ def getattr(self, obj: t.Any, attribute: str) -> t.Any | Undefined: try: return obj[attribute] except (TypeError, LookupError): - pass + return self.undefined(obj=obj, name=attribute) else: fmt = self.wrap_str_format(value) if fmt is not None: diff --git a/tests/test_api.py b/tests/test_api.py index 5b5f1ee2d..f236e495e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -18,7 +18,9 @@ from jinja2 import Undefined from jinja2 import UndefinedError from jinja2.compiler import CodeGenerator +from jinja2.nativetypes import NativeEnvironment from jinja2.runtime import Context +from jinja2.sandbox import SandboxedEnvironment from jinja2.utils import Cycler from jinja2.utils import pass_context from jinja2.utils import pass_environment @@ -27,8 +29,6 @@ class TestExtendedAPI: def test_item_and_attribute(self, env): - from jinja2.sandbox import SandboxedEnvironment - for env in Environment(), SandboxedEnvironment(): tmpl = env.from_string("{{ foo.items()|list }}") assert tmpl.render(foo={"items": 42}) == "[('items', 42)]" @@ -152,7 +152,6 @@ def select_autoescape(name): def test_sandbox_max_range(self, env): from jinja2.sandbox import MAX_RANGE - from jinja2.sandbox import SandboxedEnvironment env = SandboxedEnvironment() t = env.from_string("{% for item in range(total) %}{{ item }}{% endfor %}") @@ -334,8 +333,9 @@ def error(self, msg, *args): "W:Template variable warning: 'missing' is undefined", ] - def test_default_undefined(self): - env = Environment(undefined=Undefined) + @pytest.mark.parametrize("env_class", [Environment, SandboxedEnvironment]) + def test_default_undefined(self, env_class): + env = env_class(undefined=Undefined) assert env.from_string("{{ missing }}").render() == "" with raises_cause_chain(UndefinedError): env.from_string("{{ missing.attribute }}").render() @@ -377,8 +377,9 @@ def test_chainable_undefined(self): == "baz" ) - def test_debug_undefined(self): - env = Environment(undefined=DebugUndefined) + @pytest.mark.parametrize("env_class", [Environment, SandboxedEnvironment]) + def test_debug_undefined(self, env_class): + env = env_class(undefined=DebugUndefined) assert env.from_string("{{ missing }}").render() == "{{ missing }}" with raises_cause_chain(UndefinedError): env.from_string("{{ missing.attribute }}").render() @@ -395,8 +396,9 @@ def test_debug_undefined(self): == f"{{{{ undefined value printed: {undefined_hint} }}}}" ) - def test_strict_undefined(self): - env = Environment(undefined=StrictUndefined) + @pytest.mark.parametrize("env_class", [Environment, SandboxedEnvironment]) + def test_strict_undefined(self, env_class): + env = env_class(undefined=StrictUndefined) with raises_cause_chain(UndefinedError): env.from_string("{{ missing }}").render() with raises_cause_chain(UndefinedError): @@ -418,6 +420,30 @@ def test_strict_undefined(self): ) assert env.from_string('{{ "foo" if false }}').render() == "" + def test_strict_undefined_native_env(self): + # Like test_strict_undefined, but with additional str() calls to raise errors + env = NativeEnvironment(undefined=StrictUndefined) + with raises_cause_chain(UndefinedError): + str(env.from_string("{{ missing }}").render()) + with raises_cause_chain(UndefinedError): + env.from_string("{{ missing.attribute }}").render() + with raises_cause_chain(UndefinedError): + env.from_string("{{ missing|list }}").render() + with raises_cause_chain(UndefinedError): + env.from_string("{{ 'foo' in missing }}").render() + assert str(env.from_string("{{ missing is not defined }}").render()) == "True" + with raises_cause_chain(UndefinedError, TypeError, AttributeError): + str(env.from_string("{{ foo.missing }}").render(foo=42)) + with raises_cause_chain(UndefinedError, AttributeError, TypeError): + str(env.from_string("{{ foo['missing'] }}").render(foo=42)) + with raises_cause_chain(UndefinedError): + env.from_string("{{ not missing }}").render() + assert ( + env.from_string('{{ missing|default("default", true) }}').render() + == "default" + ) + assert str(env.from_string('{{ "foo" if false }}').render()) == "" + def test_indexing_gives_undefined(self): t = Template("{{ var[42].foo }}") with raises_cause_chain(UndefinedError, TypeError): From 445e5a41c153c958a305d83de087f6b87ed3e1d4 Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Mon, 16 Jun 2025 15:14:12 +0200 Subject: [PATCH 5/5] Remove extra blank line --- docs/api.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/api.rst b/docs/api.rst index be0d243fd..fcb2a056c 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -289,7 +289,6 @@ others fail. The closest to regular Python behavior is the :class:`StrictUndefined` which disallows all operations beside testing if it's an undefined object. - .. autoclass:: jinja2.Undefined() .. attribute:: _undefined_hint