Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ 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``.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Link to the issue you opened using :issue:`...`.

:issue:`2103`


Version 3.1.6
Expand Down
11 changes: 11 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,17 @@ 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

.. versionadded:: 3.2

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
Expand Down
13 changes: 6 additions & 7 deletions src/jinja2/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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,
Expand Down
10 changes: 9 additions & 1 deletion src/jinja2/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -813,6 +813,7 @@ class Undefined:
"_undefined_obj",
"_undefined_name",
"_undefined_exception",
"_undefined_context",
)

def __init__(
Expand All @@ -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
Expand Down Expand Up @@ -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:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to consider __cause__ as well?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No.
Setting __cause__ will hide __context__ in the default (“linear”) traceback display, but both are available for more in-depth debugging. (See docs).

exception.__context__ = self._undefined_context
raise exception

@internalcode
def __getattr__(self, name: str) -> t.Any:
Expand Down
4 changes: 2 additions & 2 deletions src/jinja2/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
109 changes: 86 additions & 23 deletions tests/test_api.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import contextlib
import shutil
import tempfile
from pathlib import Path
Expand All @@ -17,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
Expand All @@ -26,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)]"
Expand Down Expand Up @@ -151,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 %}")
Expand Down Expand Up @@ -253,6 +253,30 @@ def test_dump_stream(self, env):
shutil.rmtree(tmp)


@contextlib.contextmanager
def raises_cause_chain(expected_exception, *expected_chain, **kwargs):
"""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
("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():
Expand Down Expand Up @@ -295,7 +319,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) == ""
Expand All @@ -308,15 +333,18 @@ 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() == ""
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")
Expand All @@ -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() == ""
Expand All @@ -348,10 +377,12 @@ 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 }}"
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 (
Expand All @@ -365,26 +396,58 @@ def test_debug_undefined(self):
== f"{{{{ undefined value printed: {undefined_hint} }}}}"
)

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)
@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):
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"
)
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 }}")
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'"):
Expand Down
24 changes: 24 additions & 0 deletions tests/test_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading