Skip to content

Commit 787db02

Browse files
Merge branch 'release/2.2.1'
2 parents 4702d9f + da8f21f commit 787db02

7 files changed

Lines changed: 896 additions & 8 deletions

File tree

docs/api.rst

Lines changed: 439 additions & 0 deletions
Large diffs are not rendered by default.

docs/changes.rst

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,46 @@
11
Release Notes
22
=============
33

4+
Version 2.2.1
5+
-------------
6+
7+
**Bugs Fixed**
8+
9+
* Reverted the change in 2.2.0 which had aligned the C implementation of
10+
``FunctionWrapper.__get__`` with the pure Python implementation by
11+
substituting ``Py_None`` for ``NULL`` before invoking the wrapped
12+
descriptor's ``__get__`` slot. The change was based on a misreading of
13+
what the pure Python path does once it crosses back into C. The pure
14+
Python path calls ``self.__wrapped__.__get__(None, owner)`` from Python,
15+
and for any built-in descriptor that call is dispatched through the
16+
``__get__`` slot wrapper inside CPython, which converts ``Py_None`` back
17+
to ``NULL`` before the wrapped descriptor's ``tp_descr_get`` is invoked.
18+
The pre 2.2.0 C path called ``tp_descr_get`` directly with ``obj`` as
19+
received, which is ``NULL`` on class access, so it was already producing
20+
the same value the Python path produces after the slot wrapper's
21+
``Py_None`` to ``NULL`` conversion. Substituting ``Py_None`` for ``NULL``
22+
before ``tp_descr_get`` was called caused the wrapped descriptor to see
23+
a value it would never see during ordinary class attribute lookup.
24+
Native CPython descriptors other than ``func_descr_get`` fast path on
25+
``obj == NULL`` and return the descriptor unchanged. With ``Py_None``
26+
substituted in they fall through to a type check against the owner type
27+
of the descriptor, and ``NoneType`` does not satisfy that check, so a
28+
``TypeError`` is raised. This broke class attribute access for any
29+
built-in or C extension descriptor (``method_descriptor``,
30+
``wrapper_descriptor``, ``getset_descriptor``, ``member_descriptor``)
31+
wrapped by ``@wrapt.decorator`` or ``@wrapt.function_wrapper``. The
32+
failure mode is most likely to show up in instrumentation libraries that
33+
monkey patch built-in methods onto classes and where some inspection or
34+
binding step then accesses the wrapped attribute through the class. The
35+
existing test suite did not catch the regression because all wrappers in
36+
the test suite are applied to pure Python functions, whose
37+
``func_descr_get`` slot treats ``NULL`` and ``Py_None`` equivalently. A
38+
new regression test has been added which wraps a ``method_descriptor``
39+
and exercises class attribute access, so the missing coverage of
40+
non-function descriptors is now in place. With thanks to
41+
`brettlangdon <https://github.com/brettlangdon>`_ for reporting the
42+
regression and identifying the underlying cause.
43+
444
Version 2.2.0
545
-------------
646

docs/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ Documentation
5454
monkey
5555
typing
5656
bundled
57+
api
5758
examples
5859
benchmarks
5960
changes

docs/issues.rst

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,122 @@ This is an inherent limitation of the transparent proxy pattern: the
201201
proxy can override ``__class__`` at the Python level, but it cannot
202202
change the object's C-level type.
203203

204+
Deriving from ObjectProxy alongside an ABCMeta-based class
205+
----------------------------------------------------------
206+
207+
A custom proxy that derives from both ``ObjectProxy`` and a second base
208+
class whose metaclass is ``abc.ABCMeta`` will fail when used with
209+
``isinstance()`` or ``issubclass()``::
210+
211+
from abc import ABC
212+
from wrapt import ObjectProxy
213+
214+
class Base(ABC):
215+
pass
216+
217+
class Proxy(ObjectProxy, Base):
218+
pass
219+
220+
isinstance(1, Proxy)
221+
# TypeError: descriptor '__subclasscheck__' for '_wrappers.ObjectProxy'
222+
# objects doesn't apply to a 'type' object
223+
224+
The same failure occurs when the second base class is one of the
225+
abstract base classes exported from ``collections.abc`` (for example
226+
``Hashable``, ``Iterable``, ``Container``), since they too use
227+
``ABCMeta`` as their metaclass.
228+
229+
The cause is the way ``ObjectProxy`` implements ``__instancecheck__``
230+
and ``__subclasscheck__``. These are defined as instance methods on the
231+
proxy class so that an ``ObjectProxy`` instance can appear on the right
232+
hand side of an ``isinstance()`` or ``issubclass()`` check and have the
233+
check delegate to the wrapped type. They rely on ``self`` being a real
234+
proxy instance, and the C extension enforces this at the descriptor
235+
level.
236+
237+
When ``ObjectProxy`` is mixed in as a base class alongside an
238+
``ABCMeta``-based class, those methods are inherited as ordinary
239+
instance methods on the resulting class. Unlike the default
240+
``type.__instancecheck__``, ``ABCMeta.__instancecheck__`` performs its
241+
work by calling ``cls.__subclasscheck__(...)`` via normal attribute
242+
access on the class. That attribute access finds the inherited
243+
``__subclasscheck__`` from ``ObjectProxy`` and invokes it with a class
244+
as the first argument. The C descriptor sees that the first argument is
245+
not an ``ObjectProxy`` instance and raises the ``TypeError`` shown
246+
above.
247+
248+
Mixing ``ObjectProxy`` with one of these abstract base classes at
249+
runtime is almost always the wrong approach to begin with.
250+
``ObjectProxy`` is designed to be used as a single base class, with
251+
derived classes overriding only the specific methods that need to
252+
change. Adding a second, unrelated base class brings in extra
253+
protocol-level behaviour which interacts poorly with what
254+
``ObjectProxy`` already does internally.
255+
256+
The usual motivation for adding an abstract base class such as
257+
``Hashable`` to the base list is to satisfy a static type checker which
258+
has been told to expect the proxy to be declared as a subtype of that
259+
abstract base class. At runtime the inheritance is typically redundant.
260+
The abstract base classes in ``collections.abc`` use a structural
261+
``__subclasshook__`` (``Hashable`` is satisfied by anything that
262+
defines ``__hash__``, ``Iterable`` by anything that defines
263+
``__iter__``, and so on), and ``ObjectProxy`` already defines those
264+
methods where appropriate, forwarding to the wrapped object. So
265+
``isinstance(proxy, Hashable)`` is already ``True`` for an
266+
``ObjectProxy`` instance without any explicit inheritance::
267+
268+
from collections.abc import Hashable
269+
import wrapt
270+
271+
isinstance(wrapt.ObjectProxy("s"), Hashable) # True
272+
273+
The runtime inheritance from the abstract base class adds nothing
274+
useful in this case, and brings in the ``ABCMeta`` metaclass which then
275+
collides with ``ObjectProxy`` as described above.
276+
277+
The recommended approach is to keep the runtime class hierarchy clean
278+
and present the type-checker-required relationship using typing
279+
constructs rather than runtime inheritance. When the annotation site is
280+
under your own control, the cleanest option is to define a
281+
``typing.Protocol`` that captures the required structural shape and use
282+
that as the annotation, instead of inheriting from an abstract base
283+
class. ``ObjectProxy`` will structurally satisfy such a ``Protocol``
284+
through the dunder methods it already forwards, with no inheritance and
285+
no runtime change at all.
286+
287+
When the annotation site is not under your own control and demands a
288+
nominal subtype of a specific abstract base class, the class can be
289+
declared twice in the same file, guarded by ``typing.TYPE_CHECKING``::
290+
291+
from typing import TYPE_CHECKING
292+
293+
from wrapt import ObjectProxy
294+
295+
if TYPE_CHECKING:
296+
from collections.abc import Hashable
297+
298+
class Proxy(ObjectProxy, Hashable):
299+
...
300+
else:
301+
class Proxy(ObjectProxy):
302+
pass
303+
304+
``TYPE_CHECKING`` is ``False`` at runtime and ``True`` during static
305+
analysis, and both mypy and pyright honour this. The type checker sees
306+
the multi-base version, which satisfies whatever annotation required
307+
``Hashable`` to appear in the inheritance chain. The Python interpreter
308+
only ever executes the ``else`` branch, so at runtime ``Proxy`` is a
309+
plain ``ObjectProxy`` subclass with no ``ABCMeta`` in the picture and
310+
the original ``TypeError`` does not occur.
311+
312+
The same trick generalises to any case where the view of a class
313+
presented to a static type checker needs to differ from the runtime
314+
class hierarchy. If several such declarations need to be maintained
315+
together it can be cleaner to lift them into a sibling ``.pyi`` stub
316+
file, which the type checker will honour in preference to the ``.py``
317+
source. For a single case the inline ``TYPE_CHECKING`` form is usually
318+
enough.
319+
204320
Using the json module with ObjectProxy
205321
--------------------------------------
206322

src/wrapt/__init__.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,18 @@
22
Wrapt is a library for decorators, wrappers and monkey patching.
33
"""
44

5+
56
def _format_version(parts):
67
base = ".".join(parts[:3])
78
if len(parts) == 3:
89
return base
910
suffix = parts[3]
10-
return f"{base}.{suffix}" if suffix.startswith(("dev", "post")) else f"{base}{suffix}"
11+
return (
12+
f"{base}.{suffix}" if suffix.startswith(("dev", "post")) else f"{base}{suffix}"
13+
)
14+
1115

12-
__version_info__ = ("2", "2", "0")
16+
__version_info__ = ("2", "2", "1")
1317
__version__ = _format_version(__version_info__)
1418

1519
from .__wrapt__ import (

src/wrapt/_wrappers.c

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3821,9 +3821,6 @@ WraptFunctionWrapperBase_descr_get(WraptFunctionWrapperObject *self,
38213821
return (PyObject *)self;
38223822
}
38233823

3824-
if (obj == NULL)
3825-
obj = Py_None;
3826-
38273824
descriptor = (Py_TYPE(self->object_proxy.wrapped)->tp_descr_get)(
38283825
self->object_proxy.wrapped, obj, type);
38293826

@@ -3845,6 +3842,9 @@ WraptFunctionWrapperBase_descr_get(WraptFunctionWrapperObject *self,
38453842
}
38463843
}
38473844

3845+
if (obj == NULL)
3846+
obj = Py_None;
3847+
38483848
result = PyObject_CallFunctionObjArgs(
38493849
bound_type ? bound_type : (PyObject *)state->BoundFunctionWrapper_Type,
38503850
descriptor, obj, self->wrapper, self->enabled, self->binding, self,
@@ -3899,9 +3899,6 @@ WraptFunctionWrapperBase_descr_get(WraptFunctionWrapperObject *self,
38993899
return NULL;
39003900
}
39013901

3902-
if (obj == NULL)
3903-
obj = Py_None;
3904-
39053902
descriptor = (Py_TYPE(wrapped)->tp_descr_get)(wrapped, obj, type);
39063903

39073904
Py_DECREF(wrapped);
@@ -3925,6 +3922,9 @@ WraptFunctionWrapperBase_descr_get(WraptFunctionWrapperObject *self,
39253922
}
39263923
}
39273924

3925+
if (obj == NULL)
3926+
obj = Py_None;
3927+
39283928
result = PyObject_CallFunctionObjArgs(
39293929
bound_type ? bound_type : (PyObject *)state->BoundFunctionWrapper_Type,
39303930
descriptor, obj, self->wrapper, self->enabled, self->binding,

0 commit comments

Comments
 (0)