diff --git a/CHANGES.rst b/CHANGES.rst index f23b6c96f..36c1a241c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -9,6 +9,8 @@ Unreleased - Use modern packaging metadata with ``pyproject.toml`` instead of ``setup.cfg``. :pr:`1793` - Use ``flit_core`` instead of ``setuptools`` as build backend. +- Fix copy/deepcopy/pickle support for ``Undefined`` objects. + :pr:`2026` Version 3.1.5 diff --git a/src/jinja2/runtime.py b/src/jinja2/runtime.py index 9dcc9d4f6..83e100bd3 100644 --- a/src/jinja2/runtime.py +++ b/src/jinja2/runtime.py @@ -858,7 +858,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 +986,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 +1058,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/tests/test_api.py b/tests/test_api.py index ff3fcb138..ee11a8d69 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): 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