From 99948cfbaf70e03577f19535d59fff59d2825dee Mon Sep 17 00:00:00 2001 From: sobolevn Date: Tue, 24 Sep 2024 13:32:44 +0300 Subject: [PATCH 1/4] gh-124176: Add special support for dataclasses to `create_autospec` --- .../test_unittest/testmock/testhelpers.py | 69 +++++++++++++++++++ Lib/unittest/mock.py | 25 +++++-- ...-09-24-13-32-16.gh-issue-124176.6hmOPz.rst | 4 ++ 3 files changed, 92 insertions(+), 6 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2024-09-24-13-32-16.gh-issue-124176.6hmOPz.rst diff --git a/Lib/test/test_unittest/testmock/testhelpers.py b/Lib/test/test_unittest/testmock/testhelpers.py index c9c20f008ca5a2..54bb0f7acc2e8e 100644 --- a/Lib/test/test_unittest/testmock/testhelpers.py +++ b/Lib/test/test_unittest/testmock/testhelpers.py @@ -1034,6 +1034,75 @@ def f(a): pass self.assertEqual(mock.mock_calls, []) self.assertEqual(rv.mock_calls, []) + def test_dataclass(self): + from dataclasses import dataclass, field, InitVar + from typing import ClassVar + + @dataclass + class WithPostInit: + a: int = field(init=False) + b: int = field(init=False) + def __post_init__(self): + self.a = 1 + self.b = 2 + + for mock in [ + create_autospec(WithPostInit, instance=True), + create_autospec(WithPostInit()), + ]: + with self.subTest(mock=mock): + self.assertIsInstance(mock.a, int) + self.assertIsInstance(mock.b, int) + + # Classes do not have these fields: + mock = create_autospec(WithPostInit) + msg = "Mock object has no attribute" + with self.assertRaisesRegex(AttributeError, msg): + mock.a + with self.assertRaisesRegex(AttributeError, msg): + mock.b + + @dataclass + class WithDefault: + a: int + b: int = 0 + + for mock in [ + create_autospec(WithDefault, instance=True), + create_autospec(WithDefault(1)), + ]: + with self.subTest(mock=mock): + self.assertIsInstance(mock.a, int) + self.assertIsInstance(mock.b, int) + + @dataclass + class WithMethod: + a: int + def b(self) -> int: + return 1 + + for mock in [ + create_autospec(WithMethod, instance=True), + create_autospec(WithMethod(1)), + ]: + with self.subTest(mock=mock): + self.assertIsInstance(mock.a, int) + mock.b.assert_not_called() + + @dataclass + class WithNonFields: + a: ClassVar[int] + b: InitVar[int] + + for mock in [ + create_autospec(WithNonFields, instance=True), + create_autospec(WithNonFields(1)), + ]: + with self.subTest(mock=mock): + with self.assertRaisesRegex(AttributeError, msg): + mock.a + with self.assertRaisesRegex(AttributeError, msg): + mock.b class TestCallList(unittest.TestCase): diff --git a/Lib/unittest/mock.py b/Lib/unittest/mock.py index bb34c7436047ad..53bca8f663c1ff 100644 --- a/Lib/unittest/mock.py +++ b/Lib/unittest/mock.py @@ -2754,7 +2754,19 @@ def create_autospec(spec, spec_set=False, instance=False, _parent=None, raise InvalidSpecError(f'Cannot autospec a Mock object. ' f'[object={spec!r}]') is_async_func = _is_async_func(spec) - _kwargs = {'spec': spec} + + placeholder = object() + entries = [(entry, placeholder) for entry in dir(spec)] + # Not using `is_dataclass` to avoid an import of dataclasses module + # for types that don't need that. + if is_type and instance and hasattr(spec, '__dataclass_fields__'): + from dataclasses import fields + dataclass_fields = fields(spec) + entries.extend((f.name, f.type) for f in dataclass_fields) + _kwargs = {'spec': [f.name for f in dataclass_fields]} + else: + _kwargs = {'spec': spec} + if spec_set: _kwargs = {'spec_set': spec} elif spec is None: @@ -2811,7 +2823,7 @@ def create_autospec(spec, spec_set=False, instance=False, _parent=None, _name='()', _parent=mock, wraps=wrapped) - for entry in dir(spec): + for entry, original in entries: if _is_magic(entry): # MagicMock already does the useful magic methods for us continue @@ -2825,10 +2837,11 @@ def create_autospec(spec, spec_set=False, instance=False, _parent=None, # AttributeError on being fetched? # we could be resilient against it, or catch and propagate the # exception when the attribute is fetched from the mock - try: - original = getattr(spec, entry) - except AttributeError: - continue + if original is placeholder: + try: + original = getattr(spec, entry) + except AttributeError: + continue child_kwargs = {'spec': original} # Wrap child attributes also. diff --git a/Misc/NEWS.d/next/Library/2024-09-24-13-32-16.gh-issue-124176.6hmOPz.rst b/Misc/NEWS.d/next/Library/2024-09-24-13-32-16.gh-issue-124176.6hmOPz.rst new file mode 100644 index 00000000000000..38c030668b6b42 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2024-09-24-13-32-16.gh-issue-124176.6hmOPz.rst @@ -0,0 +1,4 @@ +Add support for :func:`dataclasses.dataclass` in +:func:`unittest.mock.create_autospec`. Now ``create_autospec`` will check +for potential dataclasses and use :func:`dataclasses.fields` function to +retrieve the spec information. From 5dcf523ce2a28eaf6f93fae61d259dda9ad9f491 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Thu, 26 Sep 2024 19:36:35 +0300 Subject: [PATCH 2/4] Address review --- Lib/test/test_unittest/testmock/testhelpers.py | 10 ++++++---- Lib/unittest/mock.py | 9 ++++----- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/Lib/test/test_unittest/testmock/testhelpers.py b/Lib/test/test_unittest/testmock/testhelpers.py index 54bb0f7acc2e8e..b8beeb34e72023 100644 --- a/Lib/test/test_unittest/testmock/testhelpers.py +++ b/Lib/test/test_unittest/testmock/testhelpers.py @@ -8,8 +8,10 @@ Mock, ANY, _CallList, patch, PropertyMock, _callable ) +from dataclasses import dataclass, field, InitVar from datetime import datetime from functools import partial +from typing import ClassVar class SomeClass(object): def one(self, a, b): pass @@ -1034,10 +1036,7 @@ def f(a): pass self.assertEqual(mock.mock_calls, []) self.assertEqual(rv.mock_calls, []) - def test_dataclass(self): - from dataclasses import dataclass, field, InitVar - from typing import ClassVar - + def test_dataclass_post_init(self): @dataclass class WithPostInit: a: int = field(init=False) @@ -1062,6 +1061,7 @@ def __post_init__(self): with self.assertRaisesRegex(AttributeError, msg): mock.b + def test_dataclass_default(self): @dataclass class WithDefault: a: int @@ -1075,6 +1075,7 @@ class WithDefault: self.assertIsInstance(mock.a, int) self.assertIsInstance(mock.b, int) + def test_dataclass_with_method(self): @dataclass class WithMethod: a: int @@ -1089,6 +1090,7 @@ def b(self) -> int: self.assertIsInstance(mock.a, int) mock.b.assert_not_called() + def test_dataclass_with_non_fields(self): @dataclass class WithNonFields: a: ClassVar[int] diff --git a/Lib/unittest/mock.py b/Lib/unittest/mock.py index 53bca8f663c1ff..d68a59b1eff8e7 100644 --- a/Lib/unittest/mock.py +++ b/Lib/unittest/mock.py @@ -34,6 +34,7 @@ import pkgutil from inspect import iscoroutinefunction import threading +from dataclasses import fields, is_dataclass from types import CodeType, ModuleType, MethodType from unittest.util import safe_repr from functools import wraps, partial @@ -2755,12 +2756,10 @@ def create_autospec(spec, spec_set=False, instance=False, _parent=None, f'[object={spec!r}]') is_async_func = _is_async_func(spec) - placeholder = object() - entries = [(entry, placeholder) for entry in dir(spec)] + entries = [(entry, _missing) for entry in dir(spec)] # Not using `is_dataclass` to avoid an import of dataclasses module # for types that don't need that. - if is_type and instance and hasattr(spec, '__dataclass_fields__'): - from dataclasses import fields + if is_type and instance and is_dataclass(spec): dataclass_fields = fields(spec) entries.extend((f.name, f.type) for f in dataclass_fields) _kwargs = {'spec': [f.name for f in dataclass_fields]} @@ -2837,7 +2836,7 @@ def create_autospec(spec, spec_set=False, instance=False, _parent=None, # AttributeError on being fetched? # we could be resilient against it, or catch and propagate the # exception when the attribute is fetched from the mock - if original is placeholder: + if original is _missing: try: original = getattr(spec, entry) except AttributeError: From bcfa7787213c040ac09d8923efefba9b4b16bcb5 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Thu, 26 Sep 2024 19:51:15 +0300 Subject: [PATCH 3/4] Fixup --- Lib/test/test_unittest/testmock/testhelpers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Lib/test/test_unittest/testmock/testhelpers.py b/Lib/test/test_unittest/testmock/testhelpers.py index b8beeb34e72023..f260769eb8c35e 100644 --- a/Lib/test/test_unittest/testmock/testhelpers.py +++ b/Lib/test/test_unittest/testmock/testhelpers.py @@ -1096,6 +1096,7 @@ class WithNonFields: a: ClassVar[int] b: InitVar[int] + msg = "Mock object has no attribute" for mock in [ create_autospec(WithNonFields, instance=True), create_autospec(WithNonFields(1)), From de9a328acd8b29d42f3e1e3dec3670133b25ee74 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Thu, 26 Sep 2024 23:24:13 +0300 Subject: [PATCH 4/4] Address review --- Lib/unittest/mock.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/Lib/unittest/mock.py b/Lib/unittest/mock.py index d68a59b1eff8e7..5d093d1d4b9420 100644 --- a/Lib/unittest/mock.py +++ b/Lib/unittest/mock.py @@ -2757,8 +2757,6 @@ def create_autospec(spec, spec_set=False, instance=False, _parent=None, is_async_func = _is_async_func(spec) entries = [(entry, _missing) for entry in dir(spec)] - # Not using `is_dataclass` to avoid an import of dataclasses module - # for types that don't need that. if is_type and instance and is_dataclass(spec): dataclass_fields = fields(spec) entries.extend((f.name, f.type) for f in dataclass_fields)