Skip to content

Commit 362eab9

Browse files
committed
fix: descriptor protocol methods must not have eat_self applied
_must_skip returned True for __get__, __set__, __delete__, __set_name__ on a class because they are regular methods on a type. But Python's descriptor machinery invokes them directly as (self, instance, owner), not through normal method binding, so wrapping with partial(_, None) consumed one argument slot and caused TypeError on invocation.
1 parent 68e2f28 commit 362eab9

3 files changed

Lines changed: 43 additions & 0 deletions

File tree

mockey/fixture.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,22 @@
2727

2828
_T = TypeVar("_T")
2929

30+
_DESCRIPTOR_DUNDERS = frozenset({"__get__", "__set__", "__delete__", "__set_name__"})
31+
3032

3133
def _must_skip(spec: Any, entry: str, is_type: bool) -> bool:
3234
"""Return whether the first argument must be consumed when autospeccing entry on spec.
3335
3436
Extends mock._must_skip to handle functools.partialmethod, which the
3537
upstream function misidentifies as a non-function and returns False for it.
38+
39+
Descriptor protocol methods (__get__, __set__, __delete__, __set_name__)
40+
are invoked directly by Python's descriptor machinery with (self, instance, owner),
41+
not through normal method binding, so eat_self must never be applied to them.
3642
"""
43+
if entry in _DESCRIPTOR_DUNDERS:
44+
return False
45+
3746
eat_self: bool = mock._must_skip(spec, entry, is_type) # type: ignore[attr-defined]
3847
if not eat_self and is_type and isinstance(spec.__dict__.get(entry), functools.partialmethod):
3948
eat_self = True

tests/test_fixture.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,26 @@ def test_patch_with_explicit_new_skips_autospec(self):
6767
utils.Foo().bar() # type: ignore[call-arg]
6868
replacement.assert_called_once_with()
6969

70+
def test_patch_descriptor_dunder_does_not_eat_self(self):
71+
# Mockey's _must_skip used to return eat_self=True for __get__ on a
72+
# class, then wrap the mock with partial(_, None), consuming one slot.
73+
# Python's descriptor machinery calls __get__(self, instance, owner)
74+
# directly, so that extra partial caused TypeError: too many arguments.
75+
received = []
76+
77+
def my_get(self, instance, owner=None):
78+
received.append((self, instance, owner))
79+
80+
with mock.patch.object(utils._DescriptorClass, "__get__", side_effect=my_get):
81+
owner_obj = utils._OwnerClass()
82+
_ = owner_obj.attr
83+
84+
self.assertEqual(1, len(received))
85+
_self, instance, owner = received[0]
86+
self.assertIsInstance(_self, utils._DescriptorClass)
87+
self.assertIs(owner_obj, instance)
88+
self.assertIs(utils._OwnerClass, owner)
89+
7090

7191
class MockFixtureLifecycleTestCase(testtools.TestCase):
7292
"""Tests that must run without MockAutospecFixture active."""

tests/utils.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,20 @@ def get_none(self) -> None:
6363
pass
6464

6565

66+
class _DescriptorClass:
67+
"""Descriptor whose __get__ / __set__ can be patched for testing."""
68+
69+
def __get__(self, instance, owner=None):
70+
return instance
71+
72+
def __set__(self, instance, value):
73+
pass
74+
75+
76+
class _OwnerClass:
77+
attr = _DescriptorClass()
78+
79+
6680
class _ClientWithPartialMethod:
6781
def __init__(self) -> None:
6882
self._send_get_request = functools.partial(self._send_request, "GET")

0 commit comments

Comments
 (0)