Fixup the MonkeyPatch patch.

- docs.
 - cleanup patches of boundmethods to not leave cruft behind.
 - NEWS entry.

Sem-Ver: api-break
This commit is contained in:
Robert Collins 2016-05-19 22:25:57 +12:00
parent 76a127d8b3
commit 7e779dab35
4 changed files with 89 additions and 40 deletions

4
NEWS
View File

@ -6,6 +6,10 @@ fixtures release notes
NEXT
~~~~
* Monkeypatching of callables has been thoroughly overhauled: there were deep
flaws in usability because staticmethod and classmethods are actually
descriptors. (Andrew Laski, Robert Collins)
2.0
~~~

3
README
View File

@ -404,6 +404,9 @@ Control the value of a named python attribute.
... pass
>>> fixture = fixtures.MonkeyPatch('__builtin__.open', fake_open)
Note that there are some complexities when patching methods - please see the
API documentation for details.
NestedTempfile
++++++++++++++

View File

@ -17,31 +17,48 @@ __all__ = [
'MonkeyPatch'
]
import functools
import sys
import types
from fixtures import Fixture
def _coerce_values(obj, name, new_value, sentinel):
"""Handle value coercion for static and classmethods.
_class_types = (type, )
if getattr(types, 'ClassType', None):
# Python 2 has multiple types of classes.
_class_types = _class_types + (types.ClassType,)
def _coerce_values(obj, name, new_value, sentinel):
"""Return an adapted (new_value, old_value) for patching obj.name.
setattr transforms a function into an instancemethod when set on a class.
This checks if the attribute to be replaced is either and wraps new_value
if necessary. This also checks getattr(obj, name) and wraps it if necessary
This checks if the attribute to be replaced is a callable descriptor -
staticmethod, classmethod, or types.FunctionType - and wraps new_value if
necessary.
This also checks getattr(obj, name) and wraps it if necessary
since the staticmethod wrapper isn't preserved.
:param obj: The object with an attribute being patched.
:param name: The name of the attribute being patched.
:param new_value: The new value to be assigned.
:param sentinel: If no old_value existed, the sentinel is returned to
indicate that.
"""
old_value = getattr(obj, name, sentinel)
if sys.version_info[0] == 2:
class_types = (type, types.ClassType)
else:
# All classes are <class 'type'> in Python 3
class_types = type
if not isinstance(obj, class_types):
# Nothing special to do here
return (new_value, old_value)
if not isinstance(obj, _class_types):
# We may be dealing with an instance of a class. In that case the
# attribute may be the result of a descriptor lookup (or a __getattr__
# override etc). Its incomplete, but generally good enough to just
# look and see if name is in the instance dict.
try:
obj.__dict__[name]
except (AttributeError, KeyError):
return (new_value, sentinel)
else:
return (new_value, old_value)
# getattr() returns a function, this access pattern will return a
# staticmethod/classmethod if the name method is defined that way
@ -49,24 +66,28 @@ def _coerce_values(obj, name, new_value, sentinel):
if old_attribute is not None:
old_value = old_attribute
# If new_value is not callable it is potentially wrapped with staticmethod
# or classmethod so grab the underlying function. If it has no underlying
# callable thing the following coercion can be skipped, just return.
# If new_value is not callable, no special handling is needed.
# (well, technically the same descriptor issue can happen with
# user supplied descriptors, but that is arguably a feature - someone can
# deliberately install a different descriptor.
if not callable(new_value):
if hasattr(new_value, '__func__'):
new_value = new_value.__func__
else:
return (new_value, old_value)
return (new_value, old_value)
if isinstance(old_value, staticmethod):
new_value = staticmethod(new_value)
if isinstance(old_value, classmethod):
elif isinstance(old_value, classmethod):
new_value = classmethod(new_value)
if isinstance(old_value, types.FunctionType):
captured_method = new_value
def func_wrapper(*args, **kwargs):
return captured_method(*args, **kwargs)
new_value = func_wrapper
elif isinstance(old_value, types.FunctionType):
if hasattr(new_value, '__get__'):
# new_value is a descriptor, and that would result in it being
# rebound if we assign it to a class - we want to preserve the
# bound state rather than having it bound to the new object
# it has been patched onto.
captured_method = new_value
@functools.wraps(old_value)
def avoid_get(*args, **kwargs):
return captured_method(*args, **kwargs)
new_value = avoid_get
return (new_value, old_value)
@ -85,6 +106,26 @@ class MonkeyPatch(Fixture):
During setup the name will be deleted or assigned the requested value,
and this will be restored in cleanUp.
When patching methods, the call signature of name should be a subset
of the parameters which can be used to call new_value.
For instance.
>>> class T:
... def method(self, arg1):
... pass
>>> class N:
... @staticmethod
... def newmethod(arg1):
... pass
Patching N.newmethod on top of T.method and then calling T().method(1)
will not work because they do not have compatible call signatures -
self will be passed to newmethod because the callable (N.newmethod)
is placed onto T as a regular function. This allows capturing all the
supplied parameters while still consulting local state in your
new_value.
"""
Fixture.__init__(self)
self.name = name

View File

@ -188,12 +188,12 @@ class TestMonkeyPatch(testtools.TestCase, TestWithFixtures):
'fixtures.tests._fixtures.test_monkeypatch.C.foo_cls',
D.bar_cls_args)
with fixture:
cls, tgtcls = C.foo_cls()
cls, target_class = C.foo_cls()
self.expectThat(cls, Is(D))
self.expectThat(tgtcls, Is(C))
cls, tgtcls = C().foo_cls()
self.expectThat(target_class, Is(C))
cls, target_class = C().foo_cls()
self.expectThat(cls, Is(D))
self.expectThat(tgtcls, Is(C))
self.expectThat(target_class, Is(C))
self._check_restored_static_or_class_method(oldmethod, oldmethod_inst,
C, 'foo_cls')
@ -310,6 +310,8 @@ class TestMonkeyPatch(testtools.TestCase, TestWithFixtures):
with fixture:
INST_C.foo()
self.assertEqual(oldmethod, INST_C.foo)
sentinel = object()
self.assertEqual(sentinel, INST_C.__dict__.get('foo', sentinel))
def test_patch_unboundmethod_with_staticmethod(self):
oldmethod = C.foo
@ -317,9 +319,8 @@ class TestMonkeyPatch(testtools.TestCase, TestWithFixtures):
'fixtures.tests._fixtures.test_monkeypatch.C.foo',
D.bar_static_args)
with fixture:
c = C()
tgtslf, arg = c.foo(1)
self.expectThat(tgtslf, Is(c))
target_self, arg = INST_C.foo(1)
self.expectThat(target_self, Is(INST_C))
self.assertEqual(1, arg)
self.assertEqual(oldmethod, C.foo)
@ -330,9 +331,9 @@ class TestMonkeyPatch(testtools.TestCase, TestWithFixtures):
D.bar_cls_args)
with fixture:
c = C()
cls, tgtslf, arg = c.foo(1)
cls, target_self, arg = c.foo(1)
self.expectThat(cls, Is(D))
self.expectThat(tgtslf, Is(c))
self.expectThat(target_self, Is(c))
self.assertEqual(1, arg)
self.assertEqual(oldmethod, C.foo)
@ -343,8 +344,8 @@ class TestMonkeyPatch(testtools.TestCase, TestWithFixtures):
fake)
with fixture:
c = C()
tgtslf, arg = c.foo(1)
self.expectThat(tgtslf, Is(c))
target_self, arg = c.foo(1)
self.expectThat(target_self, Is(c))
self.assertTrue(1, arg)
self.assertEqual(oldmethod, C.foo)
@ -356,9 +357,9 @@ class TestMonkeyPatch(testtools.TestCase, TestWithFixtures):
d.bar_two_args)
with fixture:
c = C()
slf, tgtslf = c.foo()
slf, target_self = c.foo()
self.expectThat(slf, Is(d))
self.expectThat(tgtslf, Is(c))
self.expectThat(target_self, Is(c))
self.assertEqual(oldmethod, C.foo)
def test_double_patch_instancemethod(self):