Skip to content

Commit e3d8e71

Browse files
committed
fix: Route.__repr__ and Route.get_undecorated_callback exceptions.
depr: Inspecting closures in Route.get_undecorated_callback() is deprecated. The functionality will depend on proper use of functools.update_wrapper in the future. fix: Route.get_undecorated_callback() now supports objects with a `__call__` method in addition to normal functions. change: Route.get_undecorated_callback() now follows `__wrapped__` if present. fix: Route.__repr__() now allows route-callbacks without a `__name__`. fix: #1488
1 parent 011016c commit e3d8e71

File tree

3 files changed

+81
-13
lines changed

3 files changed

+81
-13
lines changed

bottle.py

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -529,18 +529,22 @@ def get_undecorated_callback(self):
529529
""" Return the callback. If the callback is a decorated function, try to
530530
recover the original function. """
531531
func = self.callback
532-
func = getattr(func, '__func__', func)
533-
while hasattr(func, '__closure__') and getattr(func, '__closure__'):
534-
attributes = getattr(func, '__closure__')
535-
func = attributes[0].cell_contents
536-
537-
# in case of decorators with multiple arguments
538-
if not isinstance(func, FunctionType):
539-
# pick first FunctionType instance from multiple arguments
540-
func = filter(lambda x: isinstance(x, FunctionType),
541-
map(lambda x: x.cell_contents, attributes))
542-
func = list(func)[0] # py3 support
543-
return func
532+
while True:
533+
if getattr(func, '__wrapped__', False):
534+
func = func.__wrapped__
535+
elif getattr(func, '__func__', False):
536+
func = func.__func__
537+
elif getattr(func, '__closure__', False):
538+
depr(0, 14, "Decorated callback without __wrapped__",
539+
"When applying decorators to route callbacks, make sure" \
540+
" the decorator uses @functools.wraps or update_wrapper." \
541+
" This warning may also trigger if you reference callables" \
542+
" from a nonlocal scope.")
543+
cells_values = (cell.cell_contents for cell in func.__closure__)
544+
isfunc = lambda x: isinstance(x, FunctionType) or hasattr(x, '__call__')
545+
func = next(filter(isfunc, cells_values), func)
546+
else:
547+
return func
544548

545549
def get_callback_args(self):
546550
""" Return a list of argument names the callback (most likely) accepts
@@ -561,7 +565,9 @@ def get_config(self, key, default=None):
561565

562566
def __repr__(self):
563567
cb = self.get_undecorated_callback()
564-
return '<%s %s -> %s:%s>' % (self.method, self.rule, cb.__module__, cb.__name__)
568+
return '<%s %s -> %s:%s>' % (
569+
self.method, self.rule, cb.__module__, getattr(cb, '__name__', '?')
570+
)
565571

566572
###############################################################################
567573
# Application Object ###########################################################

docs/changelog.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ to receive updates on a best-effort basis.
3636
Release 0.14 (in development)
3737
=============================
3838

39+
.. rubric:: Deprecated APIs or behavior
40+
41+
* ``Route.get_undecorated_callback()`` was able to look into closure cells to guess the original function wrapped by a decorator, but this is too aggressive in some cases and may return the wrong function. To avoid this, we will depend on proper use of ``@functools.wraps(orig)`` or ``functools.update_wrapper(wrapper, orig)`` in decorators in the future.
42+
3943
.. rubric:: Removed APIs
4044

4145
* Dropped support for Python 2 (EOL: 2020-01-01) and removed workarounds or helpers that only make sense in a Python 2/3 dual codebase.

test/test_route.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import functools
12
import unittest
23
import bottle
34
from .tools import api
@@ -20,6 +21,7 @@ class TestRoute(unittest.TestCase):
2021
def test_callback_inspection(self):
2122
def x(a, b): pass
2223
def d(f):
24+
@functools.wraps(f)
2325
def w():
2426
return f()
2527
return w
@@ -29,6 +31,7 @@ def w():
2931

3032
def d2(foo):
3133
def d(f):
34+
@functools.wraps(f)
3235
def w():
3336
return f()
3437
return w
@@ -42,6 +45,7 @@ def test_callback_inspection_multiple_args(self):
4245
# decorator with argument, modifying kwargs
4346
def d2(f="1"):
4447
def d(fn):
48+
@functools.wraps(fn)
4549
def w(*args, **kwargs):
4650
# modification of kwargs WITH the decorator argument
4751
# is necessary requirement for the error
@@ -64,3 +68,57 @@ def test_callback_inspection_newsig(self):
6468
eval(compile('def foo(a, *, b=5): pass', '<foo>', 'exec'), env, env)
6569
route = bottle.Route(bottle.Bottle(), None, None, env['foo'])
6670
self.assertEqual(set(route.get_callback_args()), set(['a', 'b']))
71+
72+
def test_unwrap_wrapped(self):
73+
import functools
74+
def func(): pass
75+
@functools.wraps(func)
76+
def wrapped():
77+
return func()
78+
79+
route = bottle.Route(bottle.Bottle(), None, None, wrapped)
80+
self.assertEqual(route.get_undecorated_callback(), func)
81+
82+
@api("0.12", "0.14")
83+
def test_unwrap_closure(self):
84+
def func(): pass
85+
wrapped = _null_decorator(func, update_wrapper=False)
86+
route = bottle.Route(bottle.Bottle(), None, None, wrapped)
87+
self.assertEqual(route.get_undecorated_callback(), func)
88+
89+
# @api("0.15")
90+
# def test_not_unwrap_closure(self):
91+
# def other(): pass
92+
# def func():
93+
# return other()
94+
# route = bottle.Route(bottle.Bottle(), None, None, func)
95+
# self.assertEqual(route.get_undecorated_callback(), func)
96+
97+
@api("0.13", "0.14")
98+
def test_unwrap_closure_callable(self):
99+
class Foo:
100+
def __call__(self): pass
101+
102+
func = Foo()
103+
wrapped = _null_decorator(func, update_wrapper=False)
104+
route = bottle.Route(bottle.Bottle(), None, None, wrapped)
105+
self.assertEqual(route.get_undecorated_callback(), func)
106+
repr(route) # Raised cause cb has no '__name__'
107+
108+
def test_unwrap_method(self):
109+
def func(self): pass
110+
111+
class Foo:
112+
test = _null_decorator(func)
113+
114+
wrapped = Foo().test
115+
route = bottle.Route(bottle.Bottle(), None, None, wrapped)
116+
self.assertEqual(route.get_undecorated_callback(), func)
117+
118+
119+
def _null_decorator(func, update_wrapper=True):
120+
def wrapper():
121+
return func()
122+
if update_wrapper:
123+
functools.update_wrapper(wrapper, func)
124+
return wrapper

0 commit comments

Comments
 (0)