8000 Close #13266: Add inspect.unwrap · python/cpython@e8c45d6 · GitHub
[go: up one dir, main page]

Skip to content

Commit e8c45d6

Browse files
committed
Close #13266: Add inspect.unwrap
Initial patch by Daniel Urban and Aaron Iles
1 parent 7757820 commit e8c45d6

File tree

5 files changed

+141
-11
lines changed

5 files changed

+141
-11
lines changed

Doc/library/inspect.rst

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -797,6 +797,23 @@ Classes and functions
797797
.. versionadded:: 3.3
798798

799799

800+
.. function:: unwrap(func, *, stop=None)
801+
802+
Get the object wrapped by *func*. It follows the chain of :attr:`__wrapped__`
803+
attributes returning the last object in the chain.
804+
805+
*stop* is an optional callback accepting an object in the wrapper chain
806+
as its sole argument that allows the unwrapping to be terminated early if
807+
the callback returns a true value. If the callback never returns a true
808+
value, the last object in the chain is returned as usual. For example,
809+
:func:`signature` uses this to stop unwrapping if any object in the
810+
chain has a ``__signature__`` attribute defined.
811+
812+
:exc:`ValueError` is raised if a cycle is encountered.
813+
814+
.. versionadded:: 3.4
815+
816+
800817
.. _inspect-stack:
801818

802819
The interpreter stack

Doc/whatsnew/3.4.rst

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,15 @@ functools
185185

186186
New :func:`functools.single 8000 dispatch` decorator: see the :pep:`443`.
187187

188+
189+
inspect
190+
-------
191+
192+
:func:`~inspect.unwrap` makes it easy to unravel wrapper function chains
193+
created by :func:`functools.wraps` (and any other API that sets the
194+
``__wrapped__`` attribute on a wrapper function).
195+
196+
188197
smtplib
189198
-------
190199

@@ -327,6 +336,5 @@ that may require changes to your code.
327336
wrapped attribute set. This means ``__wrapped__`` attributes now correctly
328337
link a stack of decorated functions rather than every ``__wrapped__``
329338
attribute in the chain referring to the innermost function. Introspection
330-
libraries that assumed the previous behaviour was intentional will need to
331-
be updated to walk the chain of ``__wrapped__`` attributes to find the
332-
innermost function.
339+
libraries that assumed the previous behaviour was intentional can use
340+
:func:`inspect.unwrap` to gain equivalent behaviour.

Lib/inspect.py

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,40 @@ def getmro(cls):
360360
"Return tuple of base classes (including cls) in method resolution order."
361361
return cls.__mro__
362362

363+
# -------------------------------------------------------- function helpers
364+
365+
def unwrap(func, *, stop=None):
366+
"""Get the object wrapped by *func*.
367+
368+
Follows the chain of :attr:`__wrapped__` attributes returning the last
369+
object in the chain.
370+
371+
*stop* is an optional callback accepting an object in the wrapper chain
372+
as its sole argument that allows the unwrapping to be terminated early if
373+
the callback returns a true value. If the callback never returns a true
374+
value, the last object in the chain is returned as usual. For example,
375+
:func:`signature` uses this to stop unwrapping if any object in the
376+
chain has a ``__signature__`` attribute defined.
377+
378+
:exc:`ValueError` is raised if a cycle is encountered.
379+
380+
"""
381+
if stop is None:
382+
def _is_wrapper(f):
383+
return hasattr(f, '__wrapped__')
384+
else:
385+
def _is_wrapper(f):
386+
return hasattr(f, '__wrapped__') and not stop(f)
387+
f = func # remember the original func for error reporting
388+
memo = {id(f)} # Memoise by id to tolerate non-hashable objects
389+
while _is_wrapper(func):
390+
func = func.__wrapped__
391+
id_func = id(func)
392+
if id_func in memo:
393+
raise ValueError('wrapper loop when unwrapping {!r}'.format(f))
394+
memo.add(id_func)
395+
return func
396+
363397
# -------------------------------------------------- source code extraction
364398
def indentsize(line):
365399
"""Return the indent size, in spaces, at the start of a line of text."""
@@ -1346,6 +1380,9 @@ def signature(obj):
13461380
sig = signature(obj.__func__)
13471381
return sig.replace(parameters=tuple(sig.parameters.values())[1:])
13481382

1383+
# Was this function wrapped by a decorator?
1384+
obj = unwrap(obj, stop=(lambda f: hasattr(f, "__signature__")))
1385+
13491386
try:
13501387
sig = obj.__signature__
13511388
except AttributeError:
@@ -1354,13 +1391,6 @@ def signature(obj):
13541391
if sig is not None:
13551392
return sig
13561393

1357-
try:
1358-
# Was this function wrapped by a decorator?
1359-
wrapped = obj.__wrapped__
1360-
except AttributeError:
1361-
pass
1362-
else:
1363-
return signature(wrapped)
13641394

13651395
if isinstance(obj, types.FunctionType):
13661396
return Signature.from_function(obj)

Lib/test/test_inspect.py

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import collections
99
import os
1010
import shutil
11+
import functools
1112
from os.path import normcase
1213

1314
from test.support import run_unittest, TESTFN, DirsOnSysPath
@@ -1719,6 +1720,17 @@ def __call__(self, a, b):
17191720
((('b', ..., ..., "positional_or_keyword"),),
17201721
...))
17211722

1723+
# Test we handle __signature__ partway down the wrapper stack
1724+
def wrapped_foo_call():
1725+
pass
1726+
wrapped_foo_call.__wrapped__ = Foo.__call__
1727+
1728+
self.assertEqual(self.signature(wrapped_foo_call),
1729+
((('a', ..., ..., "positional_or_keyword"),
1730+
('b', ..., ..., "positional_or_keyword")),
1731+
...))
1732+
1733+
17221734
def test_signature_on_class(self):
17231735
class C:
17241736
def __init__(self, a):
@@ -1833,6 +1845,10 @@ class Wrapped:
18331845
self.assertEqual(self.signature(Wrapped),
18341846
((('a', ..., ..., "positional_or_keyword"),),
18351847
...))
1848+
# wrapper loop:
1849+
Wrapped.__wrapped__ = Wrapped
1850+
with self.assertRaisesRegex(ValueError, 'wrapper loop'):
1851+
self.signature(Wrapped)
18361852

18371853
def test_signature_on_lambdas(self):
18381854
self.assertEqual(self.signature((lambda a=10: a)),
@@ -2284,14 +2300,70 @@ def bar(b): pass
22842300
self.assertNotEqual(ba, ba4)
22852301

22862302

2303+
class TestUnwrap(unittest.TestCase):
2304+
2305+
def test_unwrap_one(self):
2306+
def func(a, b):
2307+
return a + b
2308+
wrapper = functools.lru_cache(maxsize=20)(func)
2309+
self.assertIs(inspect.unwrap(wrapper), func)
2310+
2311+
def test_unwrap_several(self):
2312+
def func(a, b):
2313+
return a + b
2314+
wrapper = func
2315+
for __ in range(10):
2316+
@functools.wraps(wrapper)
2317+
def wrapper():
2318+
pass
2319+
self.assertIsNot(wrapper.__wrapped__, func)
2320+
self.assertIs(inspect.unwrap(wrapper), func)
2321+
2322+
def test_stop(self):
2323+
def func1(a, b):
2324+
return a + b
2325+
@functools.wraps(func1)
2326+
def func2():
2327+
pass
2328+
@functools.wraps(func2)
2329+
def wrapper():
2330+
pass
2331+
func2.stop_here = 1
2332+
unwrapped = inspect.unwrap(wrapper,
2333+
stop=(lambda f: hasattr(f, "stop_here")))
2334+
self.assertIs(unwrapped, func2)
2335+
2336+
def test_cycle(self):
2337+
def func1(): pass
2338+
func1.__wrapped__ = func1
2339+
with self.assertRaisesRegex(ValueError, 'wrapper loop'):
2340+
inspect.unwrap(func1)
2341+
2342+
def func2(): pass
2343+
func2.__wrapped__ = func1
2344+
func1.__wrapped__ = func2
2345+
with self.assertRaisesRegex(ValueError, 'wrapper loop'):
2346+
inspect.unwrap(func1)
2347+
with self.assertRaisesRegex(ValueError, 'wrapper loop'):
2348+
inspect.unwrap(func2)
2349+
2350+
def test_unhashable(self):
2351+
def func(): pass
2352+
func.__wrapped__ = None
2353+
class C:
2354+
__hash__ = None
2355+
__wrapped__ = func
2356+
self.assertIsNone(inspect.unwrap(C()))
2357+
2358+
22872359
def test_main():
22882360
run_unittest(
22892361
TestDecorators, TestRetrievingSourceCode, TestOneliners, TestBuggyCases,
22902362
TestInterpreterStack, TestClassesAndFunctions, TestPredicates,
22912363
TestGetcallargsFunctions, TestGetcallargsMethods,
22922364
TestGetcallargsUnboundMethods, TestGetattrStatic, TestGetGeneratorState,
22932365
TestNoEOL, TestSignatureObject, TestSignatureBind, TestParameterObject,
2294-
TestBoundArguments, TestGetClosureVars
2366+
TestBoundArguments, TestGetClosureVars, TestUnwrap
22952367
)
22962368

22972369
if __name__ == "__main__":

Misc/NEWS

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,9 @@ Core and Builtins
171171
Library
172172
-------
173173

174+
- Issue #13266: Added inspect.unwrap to easily unravel __wrapped__ chains
175+
(initial patch by Daniel Urban and Aaron Iles)
176+
174177
- Issue #18561: Skip name in ctypes' _build_callargs() if name is NULL.
175178

176179
- Issue #18559: Fix NULL pointer dereference error in _pickle module

0 commit comments

Comments
 (0)
0