-
-
Notifications
You must be signed in to change notification settings - Fork 32.4k
gh-104745: Limit starting a patcher more than once without stopping it #126649
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
Changes from 1 commit
f78011f
1b9c6d4
399bb66
e28c3fb
a07d3b5
3c4edf2
916ac69
862d5a0
7719894
1180b63
7d73933
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||
---|---|---|---|---|---|---|---|---|
|
@@ -1320,6 +1320,14 @@ def _check_spec_arg_typos(kwargs_to_check): | |||||||
) | ||||||||
|
||||||||
|
||||||||
class _PatchStartedContext(object): | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To reduce the memory footprint, how about using a namedtuple? I would also call it only |
||||||||
def __init__(self, exit_stack, is_local, target, temp_original): | ||||||||
self.exit_stack = exit_stack | ||||||||
self.is_local = is_local | ||||||||
self.target = target | ||||||||
self.temp_original = temp_original | ||||||||
|
||||||||
|
||||||||
class _patch(object): | ||||||||
|
||||||||
attribute_name = None | ||||||||
|
@@ -1360,6 +1368,7 @@ def __init__( | |||||||
self.autospec = autospec | ||||||||
self.kwargs = kwargs | ||||||||
self.additional_patchers = [] | ||||||||
self._started_context = None | ||||||||
|
||||||||
|
||||||||
def copy(self): | ||||||||
|
@@ -1469,13 +1478,31 @@ def get_original(self): | |||||||
) | ||||||||
return original, local | ||||||||
|
||||||||
@property | ||||||||
def is_started(self): | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Previously, those were writable attributes and now it's no more the case. Could there be some code in the wild assuming so? (for instance pytest which makes quite hacky things, though I don't know if they do hacky things with this specific part of CPython). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I guess so... I have committed property setters just in case. Will write tests if needed when we figure out what to do with There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed them. A bit ugly but anyway these setters exist not for an intended usecase but for backwards compatibility only |
||||||||
return self._started_context is not None | ||||||||
|
||||||||
@property | ||||||||
def is_local(self): | ||||||||
return self._started_context.is_local | ||||||||
|
||||||||
@property | ||||||||
def target(self): | ||||||||
return self._started_context.target | ||||||||
|
||||||||
@property | ||||||||
def temp_original(self): | ||||||||
return self._started_context.temp_original | ||||||||
|
||||||||
def __enter__(self): | ||||||||
"""Perform the patch.""" | ||||||||
if self.is_started: | ||||||||
raise RuntimeError("Patch is already started") | ||||||||
|
||||||||
new, spec, spec_set = self.new, self.spec, self.spec_set | ||||||||
autospec, kwargs = self.autospec, self.kwargs | ||||||||
new_callable = self.new_callable | ||||||||
self.target = self.getter() | ||||||||
target = self.getter() | ||||||||
|
||||||||
# normalise False to None | ||||||||
if spec is False: | ||||||||
|
@@ -1491,7 +1518,7 @@ def __enter__(self): | |||||||
spec_set not in (True, None)): | ||||||||
raise TypeError("Can't provide explicit spec_set *and* spec or autospec") | ||||||||
|
||||||||
original, local = self.get_original() | ||||||||
original, is_local = self.get_original() | ||||||||
cjw296 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||
|
||||||||
if new is DEFAULT and autospec is None: | ||||||||
inherit = False | ||||||||
|
@@ -1579,17 +1606,17 @@ def __enter__(self): | |||||||
if autospec is True: | ||||||||
autospec = original | ||||||||
|
||||||||
if _is_instance_mock(self.target): | ||||||||
if _is_instance_mock(target): | ||||||||
raise InvalidSpecError( | ||||||||
f'Cannot autospec attr {self.attribute!r} as the patch ' | ||||||||
f'target has already been mocked out. ' | ||||||||
f'[target={self.target!r}, attr={autospec!r}]') | ||||||||
f'[target={target!r}, attr={autospec!r}]') | ||||||||
if _is_instance_mock(autospec): | ||||||||
target_name = getattr(self.target, '__name__', self.target) | ||||||||
target_name = getattr(target, '__name__', target) | ||||||||
raise InvalidSpecError( | ||||||||
f'Cannot autospec attr {self.attribute!r} from target ' | ||||||||
f'{target_name!r} as it has already been mocked out. ' | ||||||||
f'[target={self.target!r}, attr={autospec!r}]') | ||||||||
f'[target={target!r}, attr={autospec!r}]') | ||||||||
|
||||||||
new = create_autospec(autospec, spec_set=spec_set, | ||||||||
_name=self.attribute, **kwargs) | ||||||||
|
@@ -1600,17 +1627,20 @@ def __enter__(self): | |||||||
|
||||||||
new_attr = new | ||||||||
|
||||||||
self.temp_original = original | ||||||||
self.is_local = local | ||||||||
self._exit_stack = contextlib.ExitStack() | ||||||||
self._started_context = _PatchStartedContext( | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think the naming There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||
exit_stack=contextlib.ExitStack(), | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||
is_local=is_local, | ||||||||
target=self.getter(), | ||||||||
Red4Ru marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||
temp_original=original, | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This attribute can be called |
||||||||
) | ||||||||
try: | ||||||||
setattr(self.target, self.attribute, new_attr) | ||||||||
if self.attribute_name is not None: | ||||||||
extra_args = {} | ||||||||
if self.new is DEFAULT: | ||||||||
extra_args[self.attribute_name] = new | ||||||||
for patching in self.additional_patchers: | ||||||||
arg = self._exit_stack.enter_context(patching) | ||||||||
arg = self._started_context.exit_stack.enter_context(patching) | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||
if patching.new is DEFAULT: | ||||||||
extra_args.update(arg) | ||||||||
return extra_args | ||||||||
|
@@ -1622,6 +1652,9 @@ def __enter__(self): | |||||||
|
||||||||
def __exit__(self, *exc_info): | ||||||||
"""Undo the patch.""" | ||||||||
if not self.is_started: | ||||||||
return | ||||||||
|
||||||||
if self.is_local and self.temp_original is not DEFAULT: | ||||||||
setattr(self.target, self.attribute, self.temp_original) | ||||||||
else: | ||||||||
|
@@ -1633,11 +1666,8 @@ def __exit__(self, *exc_info): | |||||||
# needed for proxy objects like django settings | ||||||||
setattr(self.target, self.attribute, self.temp_original) | ||||||||
|
||||||||
del self.temp_original | ||||||||
del self.is_local | ||||||||
del self.target | ||||||||
exit_stack = self._exit_stack | ||||||||
del self._exit_stack | ||||||||
exit_stack = self._started_context.exit_stack | ||||||||
self._started_context = None | ||||||||
return exit_stack.__exit__(*exc_info) | ||||||||
|
||||||||
|
||||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
Limit starting a patcher (from :func:`unittest.mock.patch`, | ||
:func:`unittest.mock.patch.object` or :func:`unittest.mock.dict`) more than | ||
picnixz marked this conversation as resolved.
Show resolved
Hide resolved
|
||
once without stopping it. Previously, this would cause an | ||
:exc:`AttributeError` if the patch stopped more than once after this, and | ||
would also disrupt the original patched object. | ||
Red4Ru marked this conversation as resolved.
Show resolved
Hide resolved
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice spot!