Skip to content

Commit 68ada9c

Browse files
authored
Merge pull request #118 from kaste/partial-unstub
2 parents c0bc58d + 3df8fc4 commit 68ada9c

10 files changed

Lines changed: 392 additions & 12 deletions

CHANGES.txt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,24 @@ Release 2.0.0
8686
(e.g. `sys.stdout`, `sys.argv`, and environment/config dictionaries) with
8787
context-manager support and restoration through `unstub`.
8888

89+
E.g.::
90+
91+
patch_attr("sys.argv", ["foo", "bar"])
92+
with patch_attr("sys.stdout", StringIO()) as stdout: ...
93+
with patch_dict(os.environ, {"user": "bob"}): ...
94+
95+
- Added explicit partial-`unstub` targeting by host + attribute name.
96+
This complements method-reference partial unstub (e.g. `unstub(cat.meow)`)
97+
and supports tuple form and shorthand form, including multiple attributes
98+
in one call.
99+
100+
E.g.::
101+
102+
unstub(cat.meow)
103+
unstub((cat, "meow"))
104+
unstub(cat, "meow")
105+
unstub((cat, "meow"), (os.path, "exists"))
106+
89107

90108

91109

docs/walk-through.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ When patching, you **MUST** **not** forget to :func:`unstub` of course! You can
5252
::
5353

5454
from mockito import unstub
55-
unstub() # restore os.path module
55+
unstub() # restore all patched/stubbed objects
5656

5757
Usually you do this unconditionally in your `teardown` function. If you're using `pytest`, you could define a fixture instead
5858

mockito/mock_registry.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,14 @@ def mock_for(self, obj: object) -> Mock | None:
8888
def obj_for(self, mock: Mock) -> object | None:
8989
return self.mocks.lookup(mock)
9090

91-
def unstub(self, obj: object) -> None:
91+
def unstub(self, obj: object) -> bool:
9292
try:
9393
mock = self.mocks.pop(obj)
9494
except KeyError:
95-
pass
95+
return False
9696
else:
9797
mock.unstub()
98+
return True
9899

99100
def unstub_mock(self, mock: Mock) -> None:
100101
self.mocks.pop_value(mock)

mockito/mocking.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,24 @@ def forget_stubbed_invocation(
540540

541541
mock_registry.unstub(self.mocked_obj)
542542

543+
def unstub_method(self, method_name: str) -> None:
544+
invocations = [
545+
invoc
546+
for invoc in self.stubbed_invocations
547+
if invoc.method_name == method_name
548+
]
549+
if not invocations:
550+
return
551+
552+
for invoc in invocations:
553+
invoc.forget_self()
554+
555+
self.invocations = [
556+
invocation
557+
for invocation in self.invocations
558+
if invocation.method_name != method_name
559+
]
560+
543561
def unstub(self) -> None:
544562
while self._methods_to_unstub:
545563
_, patch = self._methods_to_unstub.popitem()

mockito/mockito.py

Lines changed: 129 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -448,20 +448,142 @@ def unstub(*objs):
448448
If you don't pass in any argument, *all* registered mocks and
449449
patched modules, classes etc. will be unstubbed.
450450
451+
You can also unstub a single method/function target, e.g.::
452+
453+
unstub(os.path.exists)
454+
unstub("os.path.exists")
455+
unstub(cat.meow)
456+
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.
465+
451466
Note that additionally, the underlying registry will be cleaned.
452467
After an `unstub` you can't :func:`verify` anymore because all
453468
interactions will be forgotten.
454469
"""
455470

456-
if objs:
457-
for obj in objs:
458-
if isinstance(obj, str):
459-
obj = get_obj(obj)
460-
mock_registry.unstub(obj)
461-
patcher.unstub_matching(obj)
462-
else:
471+
if not objs:
463472
mock_registry.unstub_all()
464473
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+
560+
561+
562+
def _resolve_unstub_attr_target(target):
563+
if not callable(target):
564+
return None
565+
566+
host = getattr(target, "__self__", None)
567+
attr_name = getattr(target, "__name__", None)
568+
if host is not None and attr_name is not None:
569+
return host, attr_name
570+
571+
target_function = _unwrap_unstub_target(target)
572+
for theMock in mock_registry.get_registered_mocks():
573+
for method_name, patch in theMock._methods_to_unstub.items():
574+
replacement = getattr(patch, "replacement", None)
575+
if _unwrap_unstub_target(replacement) is target_function:
576+
return theMock.mocked_obj, method_name
577+
578+
return None
579+
580+
581+
582+
def _unwrap_unstub_target(target):
583+
if isinstance(target, (staticmethod, classmethod)):
584+
return target.__func__
585+
586+
return getattr(target, "__func__", target)
465587

466588

467589
def forget_invocations(*objs):

mockito/patching.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,28 @@ def patch_dictionary(
6868
self._register_patch(dict_patch)
6969
return dict_patch
7070

71-
def unstub_matching(self, obj: object) -> None:
71+
def unstub_matching(self, obj: object) -> bool:
7272
matching = [
7373
patch for patch in self._patches
7474
if patch.matches_unstub_target(obj)
7575
]
7676
for patch in reversed(matching):
7777
patch.restore_and_unregister()
7878

79+
return bool(matching)
80+
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+
7993
def unstub_all(self) -> None:
8094
for patch in reversed(self._patches.copy()):
8195
patch.restore_and_unregister()

tests/modulefunctions_test.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,42 @@ def testUnstubsByDottedPath(self):
4545

4646
self.assertEqual(False, os.path.exists("test"))
4747

48+
def testCanUnstubSingleFunctionByFunctionTarget(self):
49+
when(os.path).exists("test").thenReturn(True)
50+
when(os.path).dirname(any(str)).thenReturn("mocked")
51+
52+
unstub(os.path.exists)
53+
54+
self.assertEqual(False, os.path.exists("test"))
55+
self.assertEqual("mocked", os.path.dirname("/tmp/file.txt"))
56+
57+
def testCanUnstubSingleFunctionByDottedFunctionPath(self):
58+
when(os.path).exists("test").thenReturn(True)
59+
when(os.path).dirname(any(str)).thenReturn("mocked")
60+
61+
unstub("os.path.exists")
62+
63+
self.assertEqual(False, os.path.exists("test"))
64+
self.assertEqual("mocked", os.path.dirname("/tmp/file.txt"))
65+
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+
4884
def testStubs(self):
4985
when(os.path).exists("test").thenReturn(True)
5086

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/staticmethods_test.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,15 @@ def testUnstubs(self):
5050
unstub()
5151
self.assertEqual("woof", Dog.bark())
5252

53+
def testCanUnstubSingleStaticmethodByFunctionTarget(self):
54+
when(Dog).bark().thenReturn("miau")
55+
when(Dog).barkHardly(1, 2).thenReturn("arf")
56+
57+
unstub(Dog.bark)
58+
59+
self.assertEqual("woof", Dog.bark())
60+
self.assertEqual("arf", Dog.barkHardly(1, 2))
61+
5362
# TODO decent test case please :) without testing irrelevant implementation
5463
# details
5564
def testUnstubShouldPreserveMethodType(self):

0 commit comments

Comments
 (0)