Skip to content

Commit ba8847a

Browse files
authored
Preserve __slots__ metadata on Undefined types (#2026)
2 parents 39d9fff + d4fb0e8 commit ba8847a

File tree

4 files changed

+69
-21
lines changed

4 files changed

+69
-21
lines changed

CHANGES.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ Unreleased
2222
:issue:`1921`
2323
- Make compiling deterministic for tuple unpacking in a ``{% set ... %}``
2424
call. :issue:`2021`
25+
- Fix dunder protocol (`copy`/`pickle`/etc) interaction with ``Undefined``
26+
objects. :issue:`2025`
2527

2628

2729
Version 3.1.4

src/jinja2/runtime.py

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -860,7 +860,11 @@ def _fail_with_undefined_error(
860860

861861
@internalcode
862862
def __getattr__(self, name: str) -> t.Any:
863-
if name[:2] == "__":
863+
# Raise AttributeError on requests for names that appear to be unimplemented
864+
# dunder methods to keep Python's internal protocol probing behaviors working
865+
# properly in cases where another exception type could cause unexpected or
866+
# difficult-to-diagnose failures.
867+
if name[:2] == "__" and name[-2:] == "__":
864868
raise AttributeError(name)
865869

866870
return self._fail_with_undefined_error()
@@ -984,10 +988,20 @@ class ChainableUndefined(Undefined):
984988
def __html__(self) -> str:
985989
return str(self)
986990

987-
def __getattr__(self, _: str) -> "ChainableUndefined":
991+
def __getattr__(self, name: str) -> "ChainableUndefined":
992+
# Raise AttributeError on requests for names that appear to be unimplemented
993+
# dunder methods to avoid confusing Python with truthy non-method objects that
994+
# do not implement the protocol being probed for. e.g., copy.copy(Undefined())
995+
# fails spectacularly if getattr(Undefined(), '__setstate__') returns an
996+
# Undefined object instead of raising AttributeError to signal that it does not
997+
# support that style of object initialization.
998+
if name[:2] == "__" and name[-2:] == "__":
999+
raise AttributeError(name)
1000+
9881001
return self
9891002

990-
__getitem__ = __getattr__ # type: ignore
1003+
def __getitem__(self, _name: str) -> "ChainableUndefined": # type: ignore[override]
1004+
return self
9911005

9921006

9931007
class DebugUndefined(Undefined):
@@ -1046,13 +1060,3 @@ class StrictUndefined(Undefined):
10461060
__iter__ = __str__ = __len__ = Undefined._fail_with_undefined_error
10471061
__eq__ = __ne__ = __bool__ = __hash__ = Undefined._fail_with_undefined_error
10481062
__contains__ = Undefined._fail_with_undefined_error
1049-
1050-
1051-
# Remove slots attributes, after the metaclass is applied they are
1052-
# unneeded and contain wrong data for subclasses.
1053-
del (
1054-
Undefined.__slots__,
1055-
ChainableUndefined.__slots__,
1056-
DebugUndefined.__slots__,
1057-
StrictUndefined.__slots__,
1058-
)

tests/test_api.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -323,8 +323,6 @@ def test_default_undefined(self):
323323
assert und1 == und2
324324
assert und1 != 42
325325
assert hash(und1) == hash(und2) == hash(Undefined())
326-
with pytest.raises(AttributeError):
327-
getattr(Undefined, "__slots__") # noqa: B009
328326

329327
def test_chainable_undefined(self):
330328
env = Environment(undefined=ChainableUndefined)
@@ -335,8 +333,6 @@ def test_chainable_undefined(self):
335333
assert env.from_string("{{ foo.missing }}").render(foo=42) == ""
336334
assert env.from_string("{{ not missing }}").render() == "True"
337335
pytest.raises(UndefinedError, env.from_string("{{ missing - 1}}").render)
338-
with pytest.raises(AttributeError):
339-
getattr(ChainableUndefined, "__slots__") # noqa: B009
340336

341337
# The following tests ensure subclass functionality works as expected
342338
assert env.from_string('{{ missing.bar["baz"] }}').render() == ""
@@ -368,8 +364,6 @@ def test_debug_undefined(self):
368364
str(DebugUndefined(hint=undefined_hint))
369365
== f"{{{{ undefined value printed: {undefined_hint} }}}}"
370366
)
371-
with pytest.raises(AttributeError):
372-
getattr(DebugUndefined, "__slots__") # noqa: B009
373367

374368
def test_strict_undefined(self):
375369
env = Environment(undefined=StrictUndefined)
@@ -386,8 +380,6 @@ def test_strict_undefined(self):
386380
env.from_string('{{ missing|default("default", true) }}').render()
387381
== "default"
388382
)
389-
with pytest.raises(AttributeError):
390-
getattr(StrictUndefined, "__slots__") # noqa: B009
391383
assert env.from_string('{{ "foo" if false }}').render() == ""
392384

393385
def test_indexing_gives_undefined(self):

tests/test_runtime.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
1+
import copy
12
import itertools
3+
import pickle
24

5+
import pytest
6+
7+
from jinja2 import ChainableUndefined
8+
from jinja2 import DebugUndefined
9+
from jinja2 import StrictUndefined
310
from jinja2 import Template
11+
from jinja2 import TemplateRuntimeError
12+
from jinja2 import Undefined
413
from jinja2.runtime import LoopContext
514

615
TEST_IDX_TEMPLATE_STR_1 = (
@@ -73,3 +82,44 @@ def __call__(self, *args, **kwargs):
7382
out = t.render(calc=Calc())
7483
# Would be "1" if context argument was passed.
7584
assert out == "0"
85+
86+
87+
_undefined_types = (Undefined, ChainableUndefined, DebugUndefined, StrictUndefined)
88+
89+
90+
@pytest.mark.parametrize("undefined_type", _undefined_types)
91+
def test_undefined_copy(undefined_type):
92+
undef = undefined_type("a hint", ["foo"], "a name", TemplateRuntimeError)
93+
copied = copy.copy(undef)
94+
95+
assert copied is not undef
96+
assert copied._undefined_hint is undef._undefined_hint
97+
assert copied._undefined_obj is undef._undefined_obj
98+
assert copied._undefined_name is undef._undefined_name
99+
assert copied._undefined_exception is undef._undefined_exception
100+
101+
102+
@pytest.mark.parametrize("undefined_type", _undefined_types)
103+
def test_undefined_deepcopy(undefined_type):
104+
undef = undefined_type("a hint", ["foo"], "a name", TemplateRuntimeError)
105+
copied = copy.deepcopy(undef)
106+
107+
assert copied._undefined_hint is undef._undefined_hint
108+
assert copied._undefined_obj is not undef._undefined_obj
109+
assert copied._undefined_obj == undef._undefined_obj
110+
assert copied._undefined_name is undef._undefined_name
111+
assert copied._undefined_exception is undef._undefined_exception
112+
113+
114+
@pytest.mark.parametrize("undefined_type", _undefined_types)
115+
def test_undefined_pickle(undefined_type):
116+
undef = undefined_type("a hint", ["foo"], "a name", TemplateRuntimeError)
117+
copied = pickle.loads(pickle.dumps(undef))
118+
119+
assert copied._undefined_hint is not undef._undefined_hint
120+
assert copied._undefined_hint == undef._undefined_hint
121+
assert copied._undefined_obj is not undef._undefined_obj
122+
assert copied._undefined_obj == undef._undefined_obj
123+
assert copied._undefined_name is not undef._undefined_name
124+
assert copied._undefined_name == undef._undefined_name
125+
assert copied._undefined_exception is undef._undefined_exception

0 commit comments

Comments
 (0)