Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

gh-111874: Call __set_name__ on objects that define the method inside a typing.NamedTuple class dictionary as part of the creation of that class #111876

Merged
merged 17 commits into from Nov 27, 2023
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
74 changes: 74 additions & 0 deletions Lib/test/test_typing.py
Expand Up @@ -7519,6 +7519,80 @@ class GenericNamedTuple(NamedTuple, Generic[T]):

self.assertEqual(CallNamedTuple.__orig_bases__, (NamedTuple,))

def test_setname_called_on_values_in_class_dictionary(self):
class Vanilla:
def __set_name__(self, owner, name):
self.name = name

class Foo(NamedTuple):
attr = Vanilla()
AlexWaygood marked this conversation as resolved.
Show resolved Hide resolved

foo = Foo()
self.assertEqual(len(foo), 0)
self.assertNotIn('attr', Foo._fields)
self.assertIsInstance(foo.attr, Vanilla)
self.assertEqual(foo.attr.name, "attr")

class Bar(NamedTuple):
attr: Vanilla = Vanilla()

bar = Bar()
self.assertEqual(len(bar), 1)
self.assertIn('attr', Bar._fields)
self.assertIsInstance(bar.attr, Vanilla)
self.assertEqual(bar.attr.name, "attr")

def test_setname_raises_the_same_as_on_other_classes(self):
class CustomException(BaseException): pass

class Annoying:
def __set_name__(self, owner, name):
raise CustomException("Cannot do that!")

with self.assertRaisesRegex(CustomException, "Cannot do that!") as cm:
AlexWaygood marked this conversation as resolved.
Show resolved Hide resolved
class NormalClass:
attr = Annoying()
normal_exception = cm.exception

with self.assertRaisesRegex(CustomException, "Cannot do that!") as cm:
class NamedTupleClass(NamedTuple):
attr = Annoying()
namedtuple_exception = cm.exception

self.assertIs(type(namedtuple_exception), CustomException)
self.assertIs(type(namedtuple_exception), type(normal_exception))

self.assertEqual(len(namedtuple_exception.__notes__), 1)
self.assertEqual(
len(namedtuple_exception.__notes__), len(normal_exception.__notes__)
)

expected_note = (
"Error calling __set_name__ on 'Annoying' instance "
"'attr' in 'NamedTupleClass'"
)
self.assertEqual(namedtuple_exception.__notes__[0], expected_note)
self.assertEqual(
namedtuple_exception.__notes__[0],
normal_exception.__notes__[0].replace("NormalClass", "NamedTupleClass")
)

def test_no_exception_raised_if_accessing_set_name_causes_strange_error(self):
class Meta(type):
def __getattribute__(self, attr):
if attr == "__set_name__":
raise TypeError("NO")
AlexWaygood marked this conversation as resolved.
Show resolved Hide resolved

class VeryAnnoying(metaclass=Meta): pass

# Sanity check to make sure the test is setup correctly:
with self.assertRaises(TypeError):
VeryAnnoying.__set_name__

# The real test here is just that this class creation succeeds:
class Foo(NamedTuple):
attr = VeryAnnoying()


class TypedDictTests(BaseTestCase):
def test_basics_functional_syntax(self):
Expand Down
21 changes: 18 additions & 3 deletions Lib/typing.py
Expand Up @@ -2726,11 +2726,26 @@ def __new__(cls, typename, bases, ns):
class_getitem = _generic_class_getitem
nm_tpl.__class_getitem__ = classmethod(class_getitem)
# update from user namespace without overriding special namedtuple attributes
for key in ns:
for key, val in ns.items():
if key in _prohibited:
raise AttributeError("Cannot overwrite NamedTuple attribute " + key)
elif key not in _special and key not in nm_tpl._fields:
setattr(nm_tpl, key, ns[key])
elif key not in _special:
if key not in nm_tpl._fields:
setattr(nm_tpl, key, val)
try:
set_name = type(val).__set_name__
except Exception:
AlexWaygood marked this conversation as resolved.
Show resolved Hide resolved
pass
else:
try:
set_name(val, nm_tpl, key)
except BaseException as e:
e.add_note(
f"Error calling __set_name__ on {type(val).__name__!r} "
f"instance {key!r} in {typename!r}"
)
raise

if Generic in bases:
nm_tpl.__init_subclass__()
return nm_tpl
Expand Down
@@ -0,0 +1,4 @@
When creating a :class:`typing.NamedTuple` class, ensure
:func:`~object.__set_name__` is called on all objects that define
``__set_name__`` and exist in the values of the ``NamedTuple`` class's class
dictionary. Patch by Alex Waygood.