Skip to content

Commit 552be9d

Browse files
mariocj89vstinner
authored andcommitted
bpo-30541: Add new method to seal mocks (GH61923)
The new method allows the developer to control when to stop the feature of mocks that automagically creates new mocks when accessing an attribute that was not declared before Signed-off-by: Mario Corchero <[email protected]>
1 parent 2bd37c2 commit 552be9d

File tree

5 files changed

+249
-2
lines changed

5 files changed

+249
-2
lines changed

Doc/library/unittest.mock.rst

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2365,3 +2365,23 @@ alternative object as the *autospec* argument:
23652365
a mocked class to create a mock instance *does not* create a real instance.
23662366
It is only attribute lookups - along with calls to :func:`dir` - that are done.
23672367
2368+
Sealing mocks
2369+
~~~~~~~~~~~~~
2370+
2371+
.. function:: seal(mock)
2372+
2373+
Seal will disable the creation of mock children by preventing to get or set
2374+
any new attribute on the sealed mock. The sealing process is performed recursively.
2375+
2376+
If a mock instance is assigned to an attribute instead of being dynamically created
2377+
it wont be considered in the sealing chain. This allows to prevent seal from fixing
2378+
part of the mock object.
2379+
2380+
>>> mock = Mock()
2381+
>>> mock.submock.attribute1 = 2
2382+
>>> mock.not_submock = mock.Mock()
2383+
>>> seal(mock)
2384+
>>> mock.submock.attribute2 # This will raise AttributeError.
2385+
>>> mock.not_submock.attribute2 # This won't raise.
2386+
2387+
.. versionadded:: 3.7

Doc/whatsnew/3.7.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,11 @@ The :const:`~unittest.mock.sentinel` attributes now preserve their identity
304304
when they are :mod:`copied <copy>` or :mod:`pickled <pickle>`. (Contributed by
305305
Serhiy Storchaka in :issue:`20804`.)
306306

307+
New function :const:`~unittest.mock.seal` will disable the creation of mock
308+
children by preventing to get or set any new attribute on the sealed mock.
309+
The sealing process is performed recursively. (Contributed by Mario Corchero
310+
in :issue:`30541`.)
311+
307312
xmlrpc.server
308313
-------------
309314

Lib/unittest/mock.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
'NonCallableMagicMock',
1919
'mock_open',
2020
'PropertyMock',
21+
'seal',
2122
)
2223

2324

@@ -382,6 +383,7 @@ def __init__(
382383
__dict__['_mock_name'] = name
383384
__dict__['_mock_new_name'] = _new_name
384385
__dict__['_mock_new_parent'] = _new_parent
386+
__dict__['_mock_sealed'] = False
385387

386388
if spec_set is not None:
387389
spec = spec_set
@@ -608,7 +610,7 @@ def __getattr__(self, name):
608610
return result
609611

610612

611-
def __repr__(self):
613+
def _extract_mock_name(self):
612614
_name_list = [self._mock_new_name]
613615
_parent = self._mock_new_parent
614616
last = self
@@ -638,7 +640,10 @@ def __repr__(self):
638640
if _name_list[1] not in ('()', '().'):
639641
_first += '.'
640642
_name_list[0] = _first
641-
name = ''.join(_name_list)
643+
return ''.join(_name_list)
644+
645+
def __repr__(self):
646+
name = self._extract_mock_name()
642647

643648
name_string = ''
644649
if name not in ('mock', 'mock.'):
@@ -705,6 +710,11 @@ def __setattr__(self, name, value):
705710
else:
706711
if _check_and_set_parent(self, value, name, name):
707712
self._mock_children[name] = value
713+
714+
if self._mock_sealed and not hasattr(self, name):
715+
mock_name = f'{self._extract_mock_name()}.{name}'
716+
raise AttributeError(f'Cannot set {mock_name}')
717+
708718
return object.__setattr__(self, name, value)
709719

710720

@@ -888,6 +898,12 @@ def _get_child_mock(self, **kw):
888898
klass = Mock
889899
else:
890900
klass = _type.__mro__[1]
901+
902+
if self._mock_sealed:
903+
attribute = "." + kw["name"] if "name" in kw else "()"
904+
mock_name = self._extract_mock_name() + attribute
905+
raise AttributeError(mock_name)
906+
891907
return klass(**kw)
892908

893909

@@ -2401,3 +2417,26 @@ def __get__(self, obj, obj_type):
24012417
return self()
24022418
def __set__(self, obj, val):
24032419
self(val)
2420+
2421+
2422+
def seal(mock):
2423+
"""Disable the automatic generation of "submocks"
2424+
2425+
Given an input Mock, seals it to ensure no further mocks will be generated
2426+
when accessing an attribute that was not already defined.
2427+
2428+
Submocks are defined as all mocks which were created DIRECTLY from the
2429+
parent. If a mock is assigned to an attribute of an existing mock,
2430+
it is not considered a submock.
2431+
2432+
"""
2433+
mock._mock_sealed = True
2434+
for attr in dir(mock):
2435+
try:
2436+
m = getattr(mock, attr)
2437+
except AttributeError:
2438+
continue
2439+
if not isinstance(m, NonCallableMock):
2440+
continue
2441+
if m._mock_new_parent is mock:
2442+
seal(m)
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import unittest
2+
from unittest import mock
3+
4+
5+
class SampleObject:
6+
def __init__(self):
7+
self.attr_sample1 = 1
8+
self.attr_sample2 = 1
9+
10+
def method_sample1(self):
11+
pass
12+
13+
def method_sample2(self):
14+
pass
15+
16+
17+
class TestSealable(unittest.TestCase):
18+
19+
def test_attributes_return_more_mocks_by_default(self):
20+
m = mock.Mock()
21+
22+
self.assertIsInstance(m.test, mock.Mock)
23+
self.assertIsInstance(m.test(), mock.Mock)
24+
self.assertIsInstance(m.test().test2(), mock.Mock)
25+
26+
def test_new_attributes_cannot_be_accessed_on_seal(self):
27+
m = mock.Mock()
28+
29+
mock.seal(m)
30+
with self.assertRaises(AttributeError):
31+
m.test
32+
with self.assertRaises(AttributeError):
33+
m()
34+
35+
def test_new_attributes_cannot_be_set_on_seal(self):
36+
m = mock.Mock()
37+
38+
mock.seal(m)
39+
with self.assertRaises(AttributeError):
40+
m.test = 1
41+
42+
def test_existing_attributes_can_be_set_on_seal(self):
43+
m = mock.Mock()
44+
m.test.test2 = 1
45+
46+
mock.seal(m)
47+
m.test.test2 = 2
48+
self.assertEqual(m.test.test2, 2)
49+
50+
def test_new_attributes_cannot_be_set_on_child_of_seal(self):
51+
m = mock.Mock()
52+
m.test.test2 = 1
53+
54+
mock.seal(m)
55+
with self.assertRaises(AttributeError):
56+
m.test.test3 = 1
57+
58+
def test_existing_attributes_allowed_after_seal(self):
59+
m = mock.Mock()
60+
61+
m.test.return_value = 3
62+
63+
mock.seal(m)
64+
self.assertEqual(m.test(), 3)
65+
66+
def test_initialized_attributes_allowed_after_seal(self):
67+
m = mock.Mock(test_value=1)
68+
69+
mock.seal(m)
70+
self.assertEqual(m.test_value, 1)
71+
72+
def test_call_on_sealed_mock_fails(self):
73+
m = mock.Mock()
74+
75+
mock.seal(m)
76+
with self.assertRaises(AttributeError):
77+
m()
78+
79+
def test_call_on_defined_sealed_mock_succeeds(self):
80+
m = mock.Mock(return_value=5)
81+
82+
mock.seal(m)
83+
self.assertEqual(m(), 5)
84+
85+
def test_seals_recurse_on_added_attributes(self):
86+
m = mock.Mock()
87+
88+
m.test1.test2().test3 = 4
89+
90+
mock.seal(m)
91+
self.assertEqual(m.test1.test2().test3, 4)
92+
with self.assertRaises(AttributeError):
93+
m.test1.test2().test4
94+
with self.assertRaises(AttributeError):
95+
m.test1.test3
96+
97+
def test_seals_recurse_on_magic_methods(self):
98+
m = mock.MagicMock()
99+
100+
m.test1.test2["a"].test3 = 4
101+
m.test1.test3[2:5].test3 = 4
102+
103+
mock.seal(m)
104+
self.assertEqual(m.test1.test2["a"].test3, 4)
105+
self.assertEqual(m.test1.test2[2:5].test3, 4)
106+
with self.assertRaises(AttributeError):
107+
m.test1.test2["a"].test4
108+
with self.assertRaises(AttributeError):
109+
m.test1.test3[2:5].test4
110+
111+
def test_seals_dont_recurse_on_manual_attributes(self):
112+
m = mock.Mock(name="root_mock")
113+
114+
m.test1.test2 = mock.Mock(name="not_sealed")
115+
m.test1.test2.test3 = 4
116+
117+
mock.seal(m)
118+
self.assertEqual(m.test1.test2.test3, 4)
119+
m.test1.test2.test4 # Does not raise
120+
m.test1.test2.test4 = 1 # Does not raise
121+
122+
def test_integration_with_spec_att_definition(self):
123+
"""You are not restricted when using mock with spec"""
124+
m = mock.Mock(SampleObject)
125+
126+
m.attr_sample1 = 1
127+
m.attr_sample3 = 3
128+
129+
mock.seal(m)
130+
self.assertEqual(m.attr_sample1, 1)
131+
self.assertEqual(m.attr_sample3, 3)
132+
with self.assertRaises(AttributeError):
133+
m.attr_sample2
134+
135+
def test_integration_with_spec_method_definition(self):
136+
"""You need to defin the methods, even if they are in the spec"""
137+
m = mock.Mock(SampleObject)
138+
139+
m.method_sample1.return_value = 1
140+
141+
mock.seal(m)
142+
self.assertEqual(m.method_sample1(), 1)
143+
with self.assertRaises(AttributeError):
144+
m.method_sample2()
145+
146+
def test_integration_with_spec_method_definition_respects_spec(self):
147+
"""You cannot define methods out of the spec"""
148+
m = mock.Mock(SampleObject)
149+
150+
with self.assertRaises(AttributeError):
151+
m.method_sample3.return_value = 3
152+
153+
def test_sealed_exception_has_attribute_name(self):
154+
m = mock.Mock()
155+
156+
mock.seal(m)
157+
with self.assertRaises(AttributeError) as cm:
158+
m.SECRETE_name
159+
self.assertIn("SECRETE_name", str(cm.exception))
160+
161+
def test_attribute_chain_is_maintained(self):
162+
m = mock.Mock(name="mock_name")
163+
m.test1.test2.test3.test4
164+
165+
mock.seal(m)
166+
with self.assertRaises(AttributeError) as cm:
167+
m.test1.test2.test3.test4.boom
168+
self.assertIn("mock_name.test1.test2.test3.test4.boom", str(cm.exception))
169+
170+
def test_call_chain_is_maintained(self):
171+
m = mock.Mock()
172+
m.test1().test2.test3().test4
173+
174+
mock.seal(m)
175+
with self.assertRaises(AttributeError) as cm:
176+
m.test1().test2.test3().test4()
177+
self.assertIn("mock.test1().test2.test3().test4", str(cm.exception))
178+
179+
180+
if __name__ == "__main__":
181+
unittest.main()
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Add new function to seal a mock and prevent the automatically creation of
2+
child mocks. Patch by Mario Corchero.

0 commit comments

Comments
 (0)