Skip to content

Commit bb9d5b3

Browse files
committed
Add explicit unstub target syntax for object attributes
1 parent 4c4c70f commit bb9d5b3

5 files changed

Lines changed: 195 additions & 24 deletions

File tree

mockito/mockito.py

Lines changed: 95 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -454,38 +454,109 @@ def unstub(*objs):
454454
unstub("os.path.exists")
455455
unstub(cat.meow)
456456
457-
In these cases only that one attribute is restored, while other stubs on
458-
the same object stay active.
457+
Or explicitly target one attribute by host and name, e.g.::
458+
459+
unstub((cat, "meow"))
460+
unstub(cat, "meow")
461+
unstub((cat, "meow"), (os.path, "exists"))
462+
463+
In these cases only the selected attributes are restored, while other stubs
464+
on the same objects stay active.
459465
460466
Note that additionally, the underlying registry will be cleaned.
461467
After an `unstub` you can't :func:`verify` anymore because all
462468
interactions will be forgotten.
463469
"""
464470

465-
if objs:
466-
for obj in objs:
467-
if isinstance(obj, str):
468-
obj = get_obj(obj)
469-
470-
# mock_registry.unstub(obj)
471-
# patcher.unstub_matching(obj)
472-
if (
473-
mock_registry.unstub(obj)
474-
or patcher.unstub_matching(obj)
475-
):
476-
return
477-
478-
resolved_target = _resolve_unstub_attr_target(obj)
479-
if resolved_target is None:
480-
continue
481-
482-
host, attr_name = resolved_target
483-
host_mock = mock_registry.mock_for(host)
484-
if host_mock is not None:
485-
host_mock.unstub_method(attr_name)
486-
else:
471+
if not objs:
487472
mock_registry.unstub_all()
488473
patcher.unstub_all()
474+
return
475+
476+
explicit_attr_targets, generic_targets = _partition_unstub_targets(objs)
477+
478+
for host, attr_name in explicit_attr_targets:
479+
_unstub_attr_target(host, attr_name)
480+
481+
for obj in generic_targets:
482+
if isinstance(obj, str):
483+
obj = get_obj(obj)
484+
485+
if mock_registry.unstub(obj) or patcher.unstub_matching(obj):
486+
continue
487+
488+
resolved_target = _resolve_unstub_attr_target(obj)
489+
if resolved_target is None:
490+
continue
491+
492+
host, attr_name = resolved_target
493+
_unstub_attr_target(host, attr_name)
494+
495+
496+
497+
def _partition_unstub_targets(objs):
498+
if _is_unstub_attr_pair_arguments(objs):
499+
host, attr_name = objs
500+
return [
501+
_normalize_unstub_attr_target(host, attr_name)
502+
], []
503+
504+
explicit_attr_targets = []
505+
generic_targets = []
506+
507+
for obj in objs:
508+
explicit_attr_target = _coerce_unstub_attr_target_tuple(obj)
509+
if explicit_attr_target is None:
510+
generic_targets.append(obj)
511+
continue
512+
513+
explicit_attr_targets.append(explicit_attr_target)
514+
515+
return explicit_attr_targets, generic_targets
516+
517+
518+
519+
def _is_unstub_attr_pair_arguments(objs):
520+
return (
521+
len(objs) == 2
522+
and not isinstance(objs[0], tuple)
523+
and _looks_like_attr_name(objs[1])
524+
)
525+
526+
527+
528+
def _coerce_unstub_attr_target_tuple(target):
529+
if not isinstance(target, tuple) or len(target) != 2:
530+
return None
531+
532+
host, attr_name = target
533+
if not _looks_like_attr_name(attr_name):
534+
return None
535+
536+
return _normalize_unstub_attr_target(host, attr_name)
537+
538+
539+
540+
def _normalize_unstub_attr_target(host, attr_name):
541+
if isinstance(host, str):
542+
host = get_obj(host)
543+
544+
return host, attr_name
545+
546+
547+
548+
def _looks_like_attr_name(value):
549+
return isinstance(value, str) and bool(value) and "." not in value
550+
551+
552+
553+
def _unstub_attr_target(host, attr_name):
554+
host_mock = mock_registry.mock_for(host)
555+
if host_mock is not None:
556+
host_mock.unstub_method(attr_name)
557+
558+
patcher.unstub_attribute(host, attr_name)
559+
489560

490561

491562
def _resolve_unstub_attr_target(target):

mockito/patching.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,18 @@ def unstub_matching(self, obj: object) -> bool:
7878

7979
return bool(matching)
8080

81+
def unstub_attribute(self, obj: object, attr_name: str) -> bool:
82+
matching = [
83+
patch for patch in self._patches
84+
if isinstance(patch, _AttrPatch)
85+
if patch.obj is obj
86+
if patch.attr_name == attr_name
87+
]
88+
for patch in reversed(matching):
89+
patch.restore_and_unregister()
90+
91+
return bool(matching)
92+
8193
def unstub_all(self) -> None:
8294
for patch in reversed(self._patches.copy()):
8395
patch.restore_and_unregister()

tests/modulefunctions_test.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,24 @@ def testCanUnstubSingleFunctionByDottedFunctionPath(self):
6363
self.assertEqual(False, os.path.exists("test"))
6464
self.assertEqual("mocked", os.path.dirname("/tmp/file.txt"))
6565

66+
def testCanUnstubSingleFunctionByExplicitTargetTuple(self):
67+
when(os.path).exists("test").thenReturn(True)
68+
when(os.path).dirname(any(str)).thenReturn("mocked")
69+
70+
unstub((os.path, "exists"))
71+
72+
self.assertEqual(False, os.path.exists("test"))
73+
self.assertEqual("mocked", os.path.dirname("/tmp/file.txt"))
74+
75+
def testCanUnstubSingleFunctionByExplicitTargetArguments(self):
76+
when(os.path).exists("test").thenReturn(True)
77+
when(os.path).dirname(any(str)).thenReturn("mocked")
78+
79+
unstub(os.path, "exists")
80+
81+
self.assertEqual(False, os.path.exists("test"))
82+
self.assertEqual("mocked", os.path.dirname("/tmp/file.txt"))
83+
6684
def testStubs(self):
6785
when(os.path).exists("test").thenReturn(True)
6886

tests/patch_attr_test.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,31 @@ def test_patch_attr_can_be_unstubbed_by_replacement_object():
5656
assert holder.value == "original"
5757

5858

59+
def test_patch_attr_can_be_unstubbed_by_explicit_object_and_attribute():
60+
holder = Holder()
61+
62+
patch_attr(holder, "value", "patched")
63+
assert holder.value == "patched"
64+
65+
unstub(holder, "value")
66+
assert holder.value == "original"
67+
68+
69+
def test_explicit_unstub_attribute_restores_patch_attr_layer_over_when_stub():
70+
class LocalHolder:
71+
def value(self):
72+
return "original"
73+
74+
holder = LocalHolder()
75+
76+
when(holder).value().thenReturn("stubbed")
77+
patch_attr(holder, "value", lambda: "patched")
78+
assert holder.value() == "patched"
79+
80+
unstub(holder, "value")
81+
assert holder.value() == "original"
82+
83+
5984
def test_nested_patch_attr_restores_correctly():
6085
holder = Holder()
6186

tests/unstub_test.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,51 @@ def testPartialUnstubByMethodReferenceKeepsDetachedChainAlive(self):
7777

7878
assert grand.sleep() == "ok"
7979

80+
def testPartialUnstubByExplicitTargetTuple(self):
81+
cat = mock(strict=True)
82+
83+
when(cat).meow().thenReturn("Miau")
84+
when(cat).runs().thenReturn("Yip")
85+
86+
unstub((cat, "meow"))
87+
88+
with pytest.raises(AttributeError):
89+
cat.meow()
90+
91+
assert cat.runs() == "Yip"
92+
93+
def testPartialUnstubByExplicitTargetArguments(self):
94+
cat = mock(strict=True)
95+
96+
when(cat).meow().thenReturn("Miau")
97+
when(cat).runs().thenReturn("Yip")
98+
99+
unstub(cat, "meow")
100+
101+
with pytest.raises(AttributeError):
102+
cat.meow()
103+
104+
assert cat.runs() == "Yip"
105+
106+
def testPartialUnstubByMultipleExplicitTargetTuples(self):
107+
cat = mock(strict=True)
108+
dog = mock(strict=True)
109+
110+
when(cat).meow().thenReturn("Miau")
111+
when(cat).runs().thenReturn("Yip")
112+
when(dog).waggle().thenReturn("Yup")
113+
when(dog).bark().thenReturn("Wuff")
114+
115+
unstub((cat, "meow"), (dog, "waggle"))
116+
117+
with pytest.raises(AttributeError):
118+
cat.meow()
119+
with pytest.raises(AttributeError):
120+
dog.waggle()
121+
122+
assert cat.runs() == "Yip"
123+
assert dog.bark() == "Wuff"
124+
80125
def testPartialUnstubByMethodReferenceForgetsMethodInvocations(self):
81126
cat = mock()
82127

0 commit comments

Comments
 (0)