Skip to content

Commit 7e09f90

Browse files
Support runtime subscripting of ObjectProxy via __class_getitem__.
The type stubs declare ObjectProxy, BaseObjectProxy and their subclasses (AutoObjectProxy, LazyObjectProxy, CallableObjectProxy, FunctionWrapper, BoundFunctionWrapper) as generic on the wrapped type, so users can write annotations like `proxy: BaseObjectProxy[int] = ...`. The runtime classes did not implement __class_getitem__, so such annotations failed with TypeError: type 'ObjectProxy' is not subscriptable on Python versions that evaluate annotations eagerly. The same error was raised by subclassing with a subscripted base (`class MyProxy(ObjectProxy[int]):`) and by typing.get_type_hints() resolving deferred annotations on any supported version. Add __class_getitem__ to both the Python and C implementations of ObjectProxy. The Python side uses classmethod(types.GenericAlias). The C side adds Py_GenericAlias with METH_O | METH_CLASS to the WraptObjectProxy methods array. All proxy and wrapper subclasses inherit the method via MRO so no per-class additions are required.
1 parent 3cf97b9 commit 7e09f90

6 files changed

Lines changed: 352 additions & 13 deletions

File tree

docs/typing.rst

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,3 +253,139 @@ constructor of the class at the point it it is being created.
253253

254254
@ClassDecorator("string") # <-- Invalid error warning.
255255
def function(): ...
256+
257+
Object Proxies
258+
--------------
259+
260+
In addition to the decorator factories, the **wrapt** module exposes a family
261+
of object proxy classes that can be used to build custom wrappers for
262+
arbitrary objects. The core classes are ``wrapt.BaseObjectProxy``,
263+
``wrapt.ObjectProxy``, ``wrapt.AutoObjectProxy``, ``wrapt.LazyObjectProxy``
264+
and ``wrapt.CallableObjectProxy``. Each is generic on a single type
265+
parameter that stands for the type of the wrapped object.
266+
267+
``wrapt.BaseObjectProxy`` is the recommended base for custom proxy
268+
subclasses; ``wrapt.ObjectProxy`` is retained for backward compatibility
269+
and is a thin subclass of ``BaseObjectProxy``. The discussion below applies
270+
equally to both.
271+
272+
Annotating a proxy with the type of its wrapped value lets the type checker
273+
reason about ``proxy.__wrapped__``:
274+
275+
::
276+
277+
import wrapt
278+
279+
proxy: wrapt.ObjectProxy[int] = wrapt.ObjectProxy(5)
280+
281+
value: int = proxy.__wrapped__
282+
283+
Subclassing a proxy with a specific wrapped type is also supported, and is
284+
the idiomatic way to define a proxy for a particular kind of object:
285+
286+
::
287+
288+
from io import TextIOWrapper
289+
from typing import Any
290+
291+
class FileProxy(wrapt.ObjectProxy[TextIOWrapper[Any]]):
292+
pass
293+
294+
fp = FileProxy(open("/path/to/file"))
295+
296+
wrapped_file: TextIOWrapper[Any] = fp.__wrapped__
297+
298+
What the type parameter does and does not propagate
299+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
300+
301+
The type parameter only affects the ``__wrapped__`` attribute directly.
302+
Most other access through the proxy is intentionally typed as ``Any``,
303+
because the runtime forwarding depends on the wrapped object's interface,
304+
which the stubs cannot claim statically without sacrificing flexibility:
305+
306+
* Attribute access via ``proxy.foo`` returns ``Any``, since the proxy's
307+
``__getattr__`` forwards to the wrapped object.
308+
* Arithmetic and bitwise operators such as ``proxy + 1`` return ``Any``
309+
because the result type depends on the wrapped value's implementation.
310+
* Container operations (``proxy[key]``, ``len(proxy)``, ...) return
311+
permissive types.
312+
* Context-manager usage ``with proxy as v:`` binds ``v`` as ``Any``,
313+
because the wrapped object's ``__enter__`` can return a value of any
314+
type (for example, ``threading.Lock.__enter__`` returns ``bool``, not
315+
the lock itself).
316+
317+
If you need a statically typed view of a specific attribute or operation,
318+
access the underlying value via ``proxy.__wrapped__`` where it is typed as
319+
the wrapped type, or assign the result of an expression to a variable with
320+
an explicit annotation:
321+
322+
::
323+
324+
proxy: wrapt.ObjectProxy[int] = wrapt.ObjectProxy(5)
325+
326+
bits: int = proxy.__wrapped__.bit_length() # Inferred as int.
327+
328+
Using a proxy class without a type parameter
329+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
330+
331+
Writing ``wrapt.ObjectProxy`` on its own as a type annotation, without a
332+
``[T]`` parameter, is equivalent to ``wrapt.ObjectProxy[Any]`` from the type
333+
checker's perspective. It is valid code, but the wrapped object's type is
334+
unknown and ``proxy.__wrapped__`` is typed as ``Any`` rather than a specific
335+
type:
336+
337+
::
338+
339+
proxy: wrapt.ObjectProxy = wrapt.ObjectProxy(5) # ObjectProxy[Any].
340+
341+
value = proxy.__wrapped__ # Any.
342+
343+
Strict type checker modes (for example ``mypy --strict``) flag the
344+
unparameterised form as missing type parameters, and require you to write
345+
``wrapt.ObjectProxy[Any]`` explicitly if that is what you intend. Under
346+
default settings the two forms are interchangeable for type checking
347+
purposes, but subscripting with a concrete type is preferred whenever the
348+
wrapped type is known.
349+
350+
Function Wrappers
351+
-----------------
352+
353+
``wrapt.FunctionWrapper`` and ``wrapt.BoundFunctionWrapper`` are the runtime
354+
types produced by ``@wrapt.decorator`` and ``@wrapt.function_wrapper``. Both
355+
are generic on two parameters: a ``ParamSpec`` representing the wrapped
356+
callable's parameter signature, and a ``TypeVar`` representing its return
357+
type.
358+
359+
In most cases you do not need to annotate a function wrapper at all. The
360+
decorator machinery infers the type parameters from the signature of the
361+
wrapped function, so a decorated function continues to appear to the type
362+
checker as a callable with the same arguments and return type:
363+
364+
::
365+
366+
@pass_through
367+
def add(a: int, b: int) -> int:
368+
return a + b
369+
370+
# Inferred as wrapt.FunctionWrapper[[int, int], int].
371+
372+
If you want to store a reference to a decorated function in a container,
373+
pass it to another function, or otherwise name the type explicitly, the
374+
subscripted form can be used:
375+
376+
::
377+
378+
wrapped_add: wrapt.FunctionWrapper[[int, int], int] = add
379+
380+
As with object proxies, writing ``wrapt.FunctionWrapper`` on its own is
381+
equivalent to ``wrapt.FunctionWrapper[Any, Any]``: a callable of any
382+
signature returning any type. The idiomatic spelling for "any signature" is
383+
``wrapt.FunctionWrapper[..., Any]``, using ``...`` in the parameter-spec
384+
position. Strict type checker modes will ask for explicit parameters in
385+
either case.
386+
387+
``wrapt.BoundFunctionWrapper`` is the type produced when a
388+
``FunctionWrapper`` is accessed via the descriptor protocol on an instance
389+
or class (for example, a decorated method accessed through ``self``). You
390+
rarely need to name this type directly; it is produced automatically and
391+
flows through type inference.

src/wrapt-stubs/__init__.pyi

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -93,12 +93,13 @@ if sys.version_info >= (3, 10):
9393
# AutoObjectProxy based on the wrapped interface) are intentionally
9494
# NOT claimed statically on BaseObjectProxy.
9595
#
96-
# `__enter__`/`__aenter__` return `_T` rather than `Any` because the
97-
# runtime forwards to the wrapped object's `__enter__`, which for the
98-
# common "wrap a context manager" use case returns the wrapped object
99-
# itself -- this preserves type information through `with proxy as x`.
100-
# Other operator dunders return `Any` since the result depends on the
101-
# wrapped type.
96+
# `__enter__`/`__aenter__` return `Any` because the runtime forwards to
97+
# the wrapped object's `__enter__`, which may return a value of any type
98+
# (for example, `threading.Lock.__enter__` returns `bool`, not the lock
99+
# itself). Returning `_T` would be a false claim that the value bound by
100+
# `with proxy as x` is the wrapped type, which is only sometimes true.
101+
# Other operator dunders return `Any` for the same reason: the result
102+
# depends on the wrapped type.
102103

103104
class BaseObjectProxy(Generic[_T]):
104105
__wrapped__: _T
@@ -111,14 +112,14 @@ if sys.version_info >= (3, 10):
111112
def __mro_entries__(self, bases: tuple[type, ...]) -> tuple[type, ...]: ...
112113

113114
# Context managers.
114-
def __enter__(self) -> _T: ...
115+
def __enter__(self) -> Any: ...
115116
def __exit__(
116117
self,
117118
exc_type: type[BaseException] | None,
118119
exc_value: BaseException | None,
119120
traceback: TracebackType | None,
120121
) -> bool | None: ...
121-
async def __aenter__(self) -> _T: ...
122+
async def __aenter__(self) -> Any: ...
122123
async def __aexit__(
123124
self,
124125
exc_type: type[BaseException] | None,

src/wrapt/_wrappers.c

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3155,6 +3155,7 @@ static PyMethodDef WraptObjectProxy_methods[] = {
31553155
METH_O, 0},
31563156
{"__subclasscheck__", (PyCFunction)WraptObjectProxy_subclasscheck,
31573157
METH_VARARGS, 0},
3158+
{"__class_getitem__", Py_GenericAlias, METH_O | METH_CLASS, 0},
31583159
{NULL, NULL},
31593160
};
31603161

src/wrapt/wrappers.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import inspect
44
import operator
55
import sys
6+
import types
67

78

89
class WrapperNotInitializedError(ValueError):
@@ -163,6 +164,8 @@ class ObjectProxy(_ObjectProxyDictBase, metaclass=_ObjectProxyMetaType):
163164
"""A transparent object proxy that delegates attribute access to a
164165
wrapped object."""
165166

167+
__class_getitem__ = classmethod(types.GenericAlias)
168+
166169
def __init__(self, wrapped):
167170
"""Create an object proxy around the given object."""
168171

tests/core/test_object_proxy.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,16 +53,27 @@ def test_init_called_wrapped_deleted(self):
5353

5454
del a.__wrapped_factory__
5555

56+
# Removing the class-level __wrapped_factory__ is necessary to
57+
# observe the post-init / pre-wrapped state, but the attribute
58+
# must be restored so later tests in the suite that construct a
59+
# LazyObjectProxy do not see it missing.
60+
61+
restore = []
5662
for cls in type(a).__mro__:
5763
if "__wrapped_factory__" in cls.__dict__:
64+
restore.append((cls, cls.__dict__["__wrapped_factory__"]))
5865
delattr(cls, "__wrapped_factory__")
5966
break
6067

61-
with self.assertRaises(wrapt.wrappers.WrapperNotInitializedError):
62-
a.__wrapped__
63-
64-
with self.assertRaises(ValueError):
65-
a.__wrapped__
68+
try:
69+
with self.assertRaises(wrapt.wrappers.WrapperNotInitializedError):
70+
a.__wrapped__
71+
72+
with self.assertRaises(ValueError):
73+
a.__wrapped__
74+
finally:
75+
for cls, attr in restore:
76+
setattr(cls, "__wrapped_factory__", attr)
6677

6778
def test_attributes(self):
6879
def function1(*args, **kwargs):

0 commit comments

Comments
 (0)