Skip to content

Commit 94bd940

Browse files
Run descriptor __get__ tests against both implementations.
The descriptor __get__ tests added in PR #341 were marked C extension only, which restricted them to one half of the tox matrix and gave no coverage of the pure Python FunctionWrapper.__get__ code path. The bug they guard against only manifests in the C extension because Python's own __get__ slot wrapper performs the Py_None to NULL conversion before the wrapped descriptor is invoked on the pure Python path, but the behavioural assertions are valid for both implementations and the disable-extensions tox environments are the right mechanism for running the suite against the pure Python wrapper. Drop the @c_extension_only skip decorators so the descriptor tests run under both the C and pure Python wrapper variants of the tox matrix. Drop the explicit TestPurePythonWraptPath cases as well: they were only useful while everything else in the file was C extension only, and the disable extensions runs in tox and just now exercise the pure Python wrapper for every test in the file. Drop the TestNativeDescriptorBinding regression cases from test_function_wrapper.py, since they are a strict subset of the cases now running against both implementations from test_descriptor_get_class_access. Expand the module level docstring and add per class comments to describe what each descriptor type test class covers and why it matters.
1 parent 00541d5 commit 94bd940

2 files changed

Lines changed: 65 additions & 109 deletions

File tree

tests/core/test_descriptor_get_class_access.py

Lines changed: 65 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
1-
"""Tests for ``FunctionWrapper.__get__`` with native C descriptors (slot
2-
wrappers, method descriptors, getset descriptors, member descriptors) and
3-
Python descriptors (property, classmethod, staticmethod) at both class-level
4-
and instance-level access.
1+
"""Tests for ``FunctionWrapper.__get__`` against the full range of descriptor
2+
types found in CPython: the native C descriptor types (slot wrappers, method
3+
descriptors, classmethod descriptors, getset descriptors, member descriptors)
4+
as well as the Python-level descriptors (property, classmethod, staticmethod,
5+
and user-defined descriptors). Each is exercised at class-level access (where
6+
the C descriptor protocol passes ``NULL`` for the instance argument of the
7+
``tp_descr_get`` slot) and at instance-level access where applicable.
8+
9+
These cases verify that ``FunctionWrapper.__get__`` forwards the instance
10+
argument it receives through to the wrapped descriptor's ``__get__`` slot
11+
without modification. Native CPython descriptor types other than the
12+
descriptor for ordinary Python functions distinguish between ``NULL`` and
13+
``Py_None`` for the instance argument and raise ``TypeError`` if ``Py_None``
14+
is passed where ``NULL`` is expected, so any future change which substitutes
15+
``Py_None`` for ``NULL`` before invoking the wrapped descriptor is detected
16+
here. The same tests are executed against the C and pure Python wrapper
17+
implementations.
518
"""
619

720
import io
@@ -12,18 +25,6 @@
1225
import _pickle
1326

1427
import wrapt
15-
import wrapt.__wrapt__
16-
17-
# ``wrapt.FunctionWrapper`` resolves to the C type when the extension is
18-
# loaded, so the pure-Python class is only reachable via ``wrapt.wrappers``.
19-
from wrapt.wrappers import FunctionWrapper as PurePythonFunctionWrapper
20-
21-
USING_C_EXTENSION = wrapt.__wrapt__._using_c_extension
22-
23-
c_extension_only = unittest.skipUnless(
24-
USING_C_EXTENSION,
25-
"exercises a code path that only exists in the C extension",
26-
)
2728

2829

2930
def _wrapper(wrapped, instance, args, kwargs):
@@ -44,18 +45,17 @@ def _find_member_descriptor():
4445

4546

4647
class TestSlotWrapperDescriptor(unittest.TestCase):
47-
# ``wrapper_descriptor`` — the descriptor type for C-level type slots,
48-
# e.g. ``_pickle.Unpickler.load`` (displayed as
49-
# ``<slot wrapper 'load' of '_pickle.Unpickler' objects>``).
50-
# A C extension type is used so the slot is not a plain Python function.
48+
# ``wrapper_descriptor`` is the descriptor type for C-level type slots
49+
# such as ``_pickle.Unpickler.load`` (displayed as
50+
# ``<slot wrapper 'load' of '_pickle.Unpickler' objects>``). A C
51+
# extension type is used so the slot wrapper is backed by a C function
52+
# rather than a Python function.
5153

52-
@c_extension_only
5354
def test_class_level_get(self):
5455
wrapped = _wrap(_pickle.Unpickler.load)
5556
result = wrapped.__get__(None, _pickle.Unpickler)
5657
self.assertIsNotNone(result.__doc__)
5758

58-
@c_extension_only
5959
def test_subclass_class_level_get(self):
6060
class Sub(_pickle.Unpickler):
6161
pass
@@ -64,7 +64,6 @@ class Sub(_pickle.Unpickler):
6464
result = wrapped.__get__(None, Sub)
6565
self.assertIsNotNone(result.__doc__)
6666

67-
@c_extension_only
6867
def test_instance_level_get(self):
6968
wrapped = _wrap(_pickle.Unpickler.load)
7069
instance = _pickle.Unpickler(io.BytesIO())
@@ -73,13 +72,14 @@ def test_instance_level_get(self):
7372

7473

7574
class TestMethodDescriptor(unittest.TestCase):
76-
@c_extension_only
75+
# ``method_descriptor`` is the descriptor type for built-in methods
76+
# defined in C such as ``str.upper``.
77+
7778
def test_class_level_get(self):
7879
wrapped = _wrap(str.upper)
7980
result = wrapped.__get__(None, str)
8081
self.assertIsNotNone(result.__doc__)
8182

82-
@c_extension_only
8383
def test_subclass_class_level_get(self):
8484
class SubStr(str):
8585
pass
@@ -88,46 +88,53 @@ class SubStr(str):
8888
result = wrapped.__get__(None, SubStr)
8989
self.assertIsNotNone(result.__doc__)
9090

91-
@c_extension_only
9291
def test_instance_level_call(self):
9392
wrapped = _wrap(str.upper)
9493
bound = wrapped.__get__("hello", str)
9594
self.assertEqual(bound(), "HELLO")
9695

9796

9897
class TestClassMethodDescriptor(unittest.TestCase):
99-
# ``classmethod_descriptor`` — classmethod descriptors ignore ``obj``
100-
# entirely, so class-level and instance-level access behave identically.
98+
# ``classmethod_descriptor`` is the descriptor type for C-level
99+
# classmethods such as ``dict.fromkeys``. A classmethod descriptor
100+
# ignores the instance argument entirely, so class-level and
101+
# instance-level access behave identically.
101102

102-
@c_extension_only
103103
def test_class_level_get(self):
104104
wrapped = _wrap(dict.fromkeys)
105105
bound = wrapped.__get__(None, dict)
106106
self.assertEqual(bound(["a", "b"], 1), {"a": 1, "b": 1})
107107

108108

109109
class TestGetSetDescriptor(unittest.TestCase):
110+
# ``getset_descriptor`` is the descriptor type generated for attributes
111+
# defined via ``PyGetSetDef`` in C, such as ``type.__name__``.
112+
110113
@staticmethod
111114
def _descriptor():
112115
# Resolve via __dict__ so the lookup doesn't run the descriptor itself.
113116
descr = type.__dict__["__name__"]
114117
assert isinstance(descr, types.GetSetDescriptorType)
115118
return descr
116119

117-
@c_extension_only
118120
def test_class_level_get(self):
121+
# Class-level access must return the descriptor itself rather than
122+
# invoking the getter.
119123
wrapped = _wrap(self._descriptor())
120124
result = wrapped.__get__(None, type)
121125
self.assertIsInstance(result, types.GetSetDescriptorType)
122126

123-
@c_extension_only
124127
def test_instance_level_get(self):
128+
# Instance-level access must invoke the getter and return the value
129+
# of the attribute on the instance.
125130
wrapped = _wrap(self._descriptor())
126131
self.assertEqual(wrapped.__get__(str, type), "str")
127132

128133

129134
class TestMemberDescriptor(unittest.TestCase):
130-
@c_extension_only
135+
# ``member_descriptor`` is the descriptor type generated for slots
136+
# defined via ``PyMemberDef`` in C and for entries in ``__slots__``.
137+
131138
def test_class_level_get(self):
132139
descr = _find_member_descriptor()
133140
if descr is None:
@@ -138,6 +145,10 @@ def test_class_level_get(self):
138145

139146

140147
class TestProperty(unittest.TestCase):
148+
# ``property`` is the Python-level descriptor for read/write computed
149+
# attributes. Class-level access returns the property object itself
150+
# whereas instance-level access invokes the getter.
151+
141152
def test_class_level_get(self):
142153
class C:
143154
@property
@@ -159,6 +170,11 @@ def value(self):
159170

160171

161172
class TestClassMethod(unittest.TestCase):
173+
# ``classmethod`` is the Python-level descriptor which binds the owning
174+
# class as the first argument. The wrapper must forward to the
175+
# classmethod's ``__get__`` so that calling the bound result invokes
176+
# the underlying function with the class.
177+
162178
def test_class_level_get(self):
163179
class C:
164180
@classmethod
@@ -170,6 +186,11 @@ def make(cls):
170186

171187

172188
class TestStaticMethod(unittest.TestCase):
189+
# ``staticmethod`` is the Python-level descriptor which returns the
190+
# underlying function unchanged. The wrapper must forward to the
191+
# staticmethod's ``__get__`` so that calling the bound result invokes
192+
# the function with no implicit first argument.
193+
173194
def test_class_level_get(self):
174195
class C:
175196
@staticmethod
@@ -181,6 +202,12 @@ def make():
181202

182203

183204
class TestCustomDescriptor(unittest.TestCase):
205+
# A user-defined Python descriptor receives the arguments
206+
# ``FunctionWrapper.__get__`` was called with. This case asserts on the
207+
# exact ``(instance, owner)`` tuple seen by the inner descriptor's
208+
# ``__get__`` so that any modification of the instance argument by the
209+
# wrapper between caller and wrapped descriptor is detected.
210+
184211
def test_class_level_get_forwards_to_descriptor_get(self):
185212
calls = []
186213

@@ -197,7 +224,12 @@ def __get__(self, instance, owner):
197224

198225

199226
class TestSubclassAttributeAccess(unittest.TestCase):
200-
@c_extension_only
227+
# A wrapped descriptor installed on a subclass must remain accessible
228+
# via class attribute access on that subclass. This is the realistic
229+
# monkey patching pattern used by instrumentation libraries: take an
230+
# existing descriptor off a parent class, wrap it, and install the
231+
# wrapper as an attribute of a subclass.
232+
201233
def test_wrapped_slot_wrapper_via_subclass(self):
202234
class Sub(_pickle.Unpickler):
203235
pass
@@ -206,7 +238,6 @@ class Sub(_pickle.Unpickler):
206238
Sub.load = wrapped
207239
self.assertIsNotNone(Sub.load.__doc__)
208240

209-
@c_extension_only
210241
def test_wrapped_dict_slot_via_subclass(self):
211242
class SubDict(dict):
212243
pass
@@ -227,17 +258,5 @@ class Child(Parent):
227258
self.assertEqual(Child().method(), "parent")
228259

229260

230-
class TestPurePythonWraptPath(unittest.TestCase):
231-
def test_slot_wrapper(self):
232-
wrapped = PurePythonFunctionWrapper(_pickle.Unpickler.load, _wrapper)
233-
result = wrapped.__get__(None, _pickle.Unpickler)
234-
self.assertIsNotNone(result.__doc__)
235-
236-
def test_method_descriptor(self):
237-
wrapped = PurePythonFunctionWrapper(str.upper, _wrapper)
238-
result = wrapped.__get__(None, str)
239-
self.assertIsNotNone(result.__doc__)
240-
241-
242261
if __name__ == "__main__":
243262
unittest.main()

tests/core/test_function_wrapper.py

Lines changed: 0 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -594,68 +594,5 @@ def run(*args):
594594
self.assertRaises(TypeError, run, ())
595595

596596

597-
class TestNativeDescriptorBinding(unittest.TestCase):
598-
# Verifies that FunctionWrapper.__get__ correctly delegates to the
599-
# wrapped descriptor when the wrapped object is a native C descriptor
600-
# accessed from a class rather than an instance. Native descriptors
601-
# such as method_descriptor, wrapper_descriptor, getset_descriptor and
602-
# member_descriptor fast path on NULL for the obj argument of their
603-
# tp_descr_get slot, but raise TypeError if Py_None is passed instead
604-
# because NoneType does not satisfy the type check against the owner.
605-
# FunctionWrapper.__get__ must therefore pass the obj argument through
606-
# to the wrapped descriptor unchanged.
607-
608-
def _decorator(self):
609-
@wrapt.decorator
610-
def _passthrough(wrapped, instance, args, kwargs):
611-
return wrapped(*args, **kwargs)
612-
613-
return _passthrough
614-
615-
def test_method_descriptor_explicit_get_none(self):
616-
# Wrapping a method_descriptor and explicitly invoking __get__ with
617-
# None as the instance must return a bound wrapper around the
618-
# original descriptor rather than raising TypeError.
619-
wrapped = self._decorator()(str.upper)
620-
bound = wrapped.__get__(None, str)
621-
self.assertIs(bound.__wrapped__, str.upper)
622-
623-
def test_method_descriptor_class_attribute_access(self):
624-
# A wrapped built-in installed as a class attribute must be
625-
# retrievable via class attribute access. Class attribute access
626-
# dispatches through tp_descr_get with obj == NULL on the C side
627-
# and instance binding through the wrapper must still produce the
628-
# expected callable behaviour.
629-
decorator = self._decorator()
630-
631-
class MyStr(str):
632-
pass
633-
634-
MyStr.upper = decorator(MyStr.upper)
635-
636-
bound = MyStr.upper
637-
self.assertIs(bound.__wrapped__, str.upper)
638-
639-
self.assertEqual(MyStr("hi").upper(), "HI")
640-
641-
def test_wrapper_descriptor_explicit_get_none(self):
642-
# Same as the method_descriptor case, but for a slot wrapper such
643-
# as str.__add__.
644-
wrapped = self._decorator()(str.__add__)
645-
bound = wrapped.__get__(None, str)
646-
self.assertIs(bound.__wrapped__, str.__add__)
647-
648-
def test_getset_descriptor_explicit_get_none(self):
649-
# Same as the method_descriptor case, but for a getset_descriptor
650-
# such as BaseException.args.
651-
self.assertEqual(
652-
type(BaseException.__dict__["args"]).__name__, "getset_descriptor"
653-
)
654-
655-
wrapped = self._decorator()(BaseException.args)
656-
bound = wrapped.__get__(None, BaseException)
657-
self.assertIs(bound.__wrapped__, BaseException.args)
658-
659-
660597
if __name__ == "__main__":
661598
unittest.main()

0 commit comments

Comments
 (0)