Skip to content

Commit 5c8d543

Browse files
committed
Guard against wrapping an already-wrapped callable
line_profiler/line_profiler.py::LineProfiler add_property() Rolled back into `add_callable()`, no longer a standalone method add_callable() - Now returning 1 if a function is added to the profiler, and 0 otherwise - Now checking against adding the wrapper callable around an already-added callable line_profiler/profiler_mixin.py::ByCountProfilerMixin wrap_{function,coroutine,[async_]generator}() - Now checking if `func` is a wrapper around an already-added callable, and returns the same wrapper without rewrapping if that is the case - Now marking wrappers so that they can be recognized and not rewrapped tests/test_line_profiler.py test_double_decoration() New test that double-wrapping doesn't erroneously profile the wrapper instead of the intended callable test_property_decorator() Updated because we are no longer double-adding the property getter
1 parent 13e9a85 commit 5c8d543

File tree

3 files changed

+115
-45
lines changed

3 files changed

+115
-45
lines changed

line_profiler/line_profiler.py

Lines changed: 46 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
# NOTE: This needs to be in sync with ../kernprof.py and __init__.py
2929
__version__ = '4.3.0'
3030

31+
is_function = inspect.isfunction
32+
3133

3234
def load_ipython_extension(ip):
3335
""" API for IPython to recognize this module as an IPython extension.
@@ -36,6 +38,34 @@ def load_ipython_extension(ip):
3638
ip.register_magics(LineProfilerMagics)
3739

3840

41+
def _get_underlying_functions(func):
42+
"""
43+
Get the underlying function objects of a callable or an adjacent
44+
object.
45+
46+
Returns:
47+
funcs (list[Callable])
48+
"""
49+
if any(check(func)
50+
for check in (is_boundmethod, is_classmethod, is_staticmethod)):
51+
return _get_underlying_functions(func.__func__)
52+
if any(check(func)
53+
for check in (is_partial, is_partialmethod, is_cached_property)):
54+
return _get_underlying_functions(func.func)
55+
if is_property(func):
56+
result = []
57+
for impl in func.fget, func.fset, func.fdel:
58+
if impl is not None:
59+
result.extend(_get_underlying_functions(impl))
60+
return result
61+
if not callable(func):
62+
raise TypeError(f'func = {func!r}: '
63+
f'cannot get functions from {type(func)} objects')
64+
if is_function(func):
65+
return [func]
66+
return [type(func).__call__]
67+
68+
3969
class LineProfiler(CLineProfiler, ByCountProfilerMixin):
4070
"""
4171
A profiler that records the execution times of individual lines.
@@ -60,35 +90,30 @@ def __call__(self, func):
6090
start the profiler on function entry and stop it on function
6191
exit.
6292
"""
93+
# Note: if `func` is a `types.FunctionType` which is already
94+
# decorated by the profiler, the same object is returned;
95+
# otherwise, wrapper objects are always returned.
6396
self.add_callable(func)
6497
return self.wrap_callable(func)
6598

6699
def add_callable(self, func):
67100
"""
68101
Register a function, method, property, partial object, etc. with
69102
the underlying Cython profiler.
70-
"""
71-
if is_property(func):
72-
self.add_property(func)
73-
elif any(check(func)
74-
for check in (is_boundmethod,
75-
is_classmethod, is_staticmethod)):
76-
self.add_function(func.__func__)
77-
elif any(check(func) for check in (is_partial, is_partialmethod,
78-
is_cached_property)):
79-
self.add_function(func.func)
80-
else:
81-
self.add_function(func)
82103
83-
def add_property(self, func):
84-
"""
85-
Register a `property`'s getter, setter, and deleter
86-
implementations with the underlying Cython profiler.
104+
Returns:
105+
1 if any function is added to the profiler, 0 otherwise
87106
"""
88-
for impl in func.fget, func.fset, func.fdel:
89-
if impl is None:
107+
guard = self._already_wrapped
108+
109+
nadded = 0
110+
for impl in _get_underlying_functions(func):
111+
if guard(impl):
90112
continue
91113
self.add_function(impl)
114+
nadded += 1
115+
116+
return 1 if nadded else 0
92117

93118
def dump_stats(self, filename):
94119
""" Dump a representation of the data to a file as a pickled LineStats
@@ -110,16 +135,16 @@ def print_stats(self, stream=None, output_unit=None, stripzeros=False,
110135
def add_module(self, mod):
111136
""" Add all the functions in a module and its classes.
112137
"""
113-
from inspect import isclass, isfunction
138+
from inspect import isclass
114139

115140
nfuncsadded = 0
116141
for item in mod.__dict__.values():
117142
if isclass(item):
118143
for k, v in item.__dict__.items():
119-
if isfunction(v):
144+
if is_function(v):
120145
self.add_function(v)
121146
nfuncsadded += 1
122-
elif isfunction(item):
147+
elif is_function(item):
123148
self.add_function(item)
124149
nfuncsadded += 1
125150

line_profiler/profiler_mixin.py

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -195,8 +195,12 @@ def wrap_cached_property(self, func):
195195

196196
def wrap_async_generator(self, func):
197197
"""
198-
Wrap an async generator to profile it.
198+
Wrap an async generator function to profile it.
199199
"""
200+
# Prevent double-wrap
201+
if self._already_wrapped(func):
202+
return func
203+
200204
@functools.wraps(func)
201205
async def wrapper(*args, **kwds):
202206
g = func(*args, **kwds)
@@ -212,12 +216,16 @@ async def wrapper(*args, **kwds):
212216
self.disable_by_count()
213217
input_ = (yield item)
214218

215-
return wrapper
219+
return self._mark_wrapped(wrapper)
216220

217221
def wrap_coroutine(self, func):
218222
"""
219-
Wrap a Python 3.5 coroutine to profile it.
223+
Wrap a coroutine function to profile it.
220224
"""
225+
# Prevent double-wrap
226+
if self._already_wrapped(func):
227+
return func
228+
221229
@functools.wraps(func)
222230
async def wrapper(*args, **kwds):
223231
self.enable_by_count()
@@ -227,12 +235,16 @@ async def wrapper(*args, **kwds):
227235
self.disable_by_count()
228236
return result
229237

230-
return wrapper
238+
return self._mark_wrapped(wrapper)
231239

232240
def wrap_generator(self, func):
233241
"""
234-
Wrap a generator to profile it.
242+
Wrap a generator function to profile it.
235243
"""
244+
# Prevent double-wrap
245+
if self._already_wrapped(func):
246+
return func
247+
236248
@functools.wraps(func)
237249
def wrapper(*args, **kwds):
238250
g = func(*args, **kwds)
@@ -248,11 +260,16 @@ def wrapper(*args, **kwds):
248260
self.disable_by_count()
249261
input_ = (yield item)
250262

251-
return wrapper
263+
return self._mark_wrapped(wrapper)
252264

253265
def wrap_function(self, func):
254-
""" Wrap a function to profile it.
255266
"""
267+
Wrap a function to profile it.
268+
"""
269+
# Prevent double-wrap
270+
if self._already_wrapped(func):
271+
return func
272+
256273
@functools.wraps(func)
257274
def wrapper(*args, **kwds):
258275
self.enable_by_count()
@@ -261,7 +278,15 @@ def wrapper(*args, **kwds):
261278
finally:
262279
self.disable_by_count()
263280
return result
264-
return wrapper
281+
282+
return self._mark_wrapped(wrapper)
283+
284+
def _already_wrapped(self, func):
285+
return getattr(func, self._profiler_wrapped_marker, None) == id(self)
286+
287+
def _mark_wrapped(self, func):
288+
setattr(func, self._profiler_wrapped_marker, id(self))
289+
return func
265290

266291
def run(self, cmd):
267292
""" Profile a single executable statment in the main namespace.
@@ -295,3 +320,5 @@ def __enter__(self):
295320

296321
def __exit__(self, *_, **__):
297322
self.disable_by_count()
323+
324+
_profiler_wrapped_marker = '__line_profiler_id__'

tests/test_line_profiler.py

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,25 @@ def test_enable_disable():
102102
assert lp.last_time == {}
103103

104104

105+
def test_double_decoration():
106+
"""
107+
Test that wrapping the same function twice does not result in
108+
spurious profiling entries.
109+
"""
110+
profile = LineProfiler()
111+
f_wrapped = profile(f)
112+
f_double_wrapped = profile(f_wrapped)
113+
assert f_double_wrapped is f_wrapped
114+
115+
with check_timings(profile):
116+
assert profile.enable_count == 0
117+
value = f_wrapped(10)
118+
assert profile.enable_count == 0
119+
assert value == f(10)
120+
121+
assert len(profile.get_stats().timings) == 1
122+
123+
105124
def test_function_decorator():
106125
"""
107126
Test for `LineProfiler.wrap_function()`.
@@ -398,27 +417,26 @@ def test_property_decorator():
398417
"""
399418
profile = LineProfiler()
400419

401-
with pytest.warns(UserWarning,
402-
match='Adding a function with a __wrapped__ attribute'):
403-
class Object:
404-
def __init__(self, x: int) -> None:
405-
self.x = x
420+
class Object:
421+
def __init__(self, x: int) -> None:
422+
self.x = x
423+
424+
@profile
425+
@property
426+
def foo(self) -> int:
427+
return self.x * 2
406428

407-
@profile
408-
@property
409-
def foo(self) -> int:
410-
return self.x * 2
429+
# The profiler sees both the setter and the already-wrapped
430+
# getter here, but it shouldn't re-wrap the getter
411431

412-
@profile
413-
@foo.setter
414-
def foo(self, foo) -> None:
415-
self.x = foo // 2
432+
@profile
433+
@foo.setter
434+
def foo(self, foo) -> None:
435+
self.x = foo // 2
416436

417437
assert isinstance(Object.foo, property)
418438
assert profile.enable_count == 0
419-
# XXX: should we try do remove duplicates? (getter added twice here)
420-
# (that's also why there is the warning)
421-
assert len(profile.functions) == 3
439+
assert len(profile.functions) == 2
422440
obj = Object(3)
423441
assert obj.foo == 6 # Use getter
424442
obj.foo = 10 # Use setter

0 commit comments

Comments
 (0)