Skip to content

Commit a660317

Browse files
committed
Scope matching for LineProfiler.add_*()
line_profiler/autoprofile/line_profiler_utils.py[i] add_imported_function_or_module() Added the `match_scope` argument for limiting the scope of descension into classes in namespaces (classes and modules) line_profiler/line_profiler.py[i] is_c_level_callable() New check for non-profilable C-level callables LineProfiler add_callable(), wrap_callable() Now no-ops on C-level callables add_class(), add_module() - Added the `match_scope` argument for limiting the scope of descension into classes in namespaces (classes and modules) - Added handling for when the `setattr()` on the namespace fails __call__() Added missing method in stub file
1 parent 88bc789 commit a660317

4 files changed

Lines changed: 242 additions & 40 deletions

File tree

line_profiler/autoprofile/line_profiler_utils.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import inspect
22

33

4-
def add_imported_function_or_module(self, item, *, wrap=False):
4+
def add_imported_function_or_module(self, item, *,
5+
match_scope='siblings', wrap=False):
56
"""
67
Method to add an object to `LineProfiler` to be profiled.
78
@@ -12,6 +13,21 @@ def add_imported_function_or_module(self, item, *, wrap=False):
1213
Args:
1314
item (Union[Callable, Type, ModuleType]):
1415
Object to be profiled.
16+
match_scope (Literal['exact', 'siblings', 'descendants',
17+
'none']):
18+
Whether (and how) to match the scope of member classes to
19+
`item` (if a class or module) and decide on whether to add
20+
them:
21+
- 'exact': only add classes defined locally in the body of
22+
`item`
23+
- 'descendants': only add locally-defined classes and
24+
classes defined in submodules or locally-defined class
25+
bodies, and so on.
26+
- 'siblings': only add classes fulfilling 'descendants',
27+
or defined in the same module as `item` (if a class) or in
28+
sibling modules and subpackages to `item` (if a module)
29+
- 'none': don't check scopes and add all classes in the
30+
namespace
1531
wrap (bool):
1632
Whether to replace the wrapped members with wrappers which
1733
automatically enable/disable the profiler when called.
@@ -23,9 +39,9 @@ def add_imported_function_or_module(self, item, *, wrap=False):
2339
`LineProfiler.add_callable()`, `.add_module()`, `.add_class()`
2440
"""
2541
if inspect.isclass(item):
26-
count = self.add_class(item, wrap=wrap)
42+
count = self.add_class(item, match_scope=match_scope, wrap=wrap)
2743
elif inspect.ismodule(item):
28-
count = self.add_module(item, wrap=wrap)
44+
count = self.add_module(item, match_scope=match_scope, wrap=wrap)
2945
else:
3046
try:
3147
count = self.add_callable(item)
Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,21 @@
1-
from typing import Callable, Literal
21
from types import ModuleType
2+
from typing import overload, Any, Literal, TYPE_CHECKING
33

4+
if TYPE_CHECKING: # Stub-only annotations
5+
from ..line_profiler import CLevelCallable, CallableLike, MatchScopeOption
46

7+
8+
@overload
9+
def add_imported_function_or_module(
10+
self, item: CLevelCallable | Any,
11+
match_scope: MatchScopeOption = 'siblings',
12+
wrap: bool = False) -> Literal[0]:
13+
...
14+
15+
16+
@overload
517
def add_imported_function_or_module(
6-
self, item: Callable | type | ModuleType, *,
18+
self, item: CallableLike | type | ModuleType,
19+
match_scope: MatchScopeOption = 'siblings',
720
wrap: bool = False) -> Literal[0, 1]:
821
...

line_profiler/line_profiler.py

Lines changed: 165 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
import tempfile
1111
import os
1212
import sys
13+
import types
14+
import warnings
1315
from argparse import ArgumentError, ArgumentParser
1416

1517
try:
@@ -28,9 +30,28 @@
2830
# NOTE: This needs to be in sync with ../kernprof.py and __init__.py
2931
__version__ = '4.3.0'
3032

33+
# These objects are callables, but are defined in C so we can't handle
34+
# them anyway
35+
c_level_callable_types = (types.BuiltinFunctionType,
36+
types.BuiltinMethodType,
37+
types.ClassMethodDescriptorType,
38+
types.MethodDescriptorType,
39+
types.MethodWrapperType,
40+
types.WrapperDescriptorType)
41+
3142
is_function = inspect.isfunction
3243

3344

45+
def is_c_level_callable(func):
46+
"""
47+
Returns:
48+
func_is_c_level (bool):
49+
Whether a callable is defined at the C level (and is thus
50+
non-profilable).
51+
"""
52+
return isinstance(func, c_level_callable_types)
53+
54+
3455
def load_ipython_extension(ip):
3556
""" API for IPython to recognize this module as an IPython extension.
3657
"""
@@ -63,6 +84,8 @@ def _get_underlying_functions(func):
6384
f'cannot get functions from {type(func)} objects')
6485
if is_function(func):
6586
return [func]
87+
if is_c_level_callable(func):
88+
return []
6689
return [type(func).__call__]
6790

6891

@@ -90,12 +113,20 @@ def __call__(self, func):
90113
start the profiler on function entry and stop it on function
91114
exit.
92115
"""
93-
# Note: if `func` is a `types.FunctionType` which is already
94-
# decorated by the profiler, the same object is returned;
116+
# The same object is returned when:
117+
# - `func` is a `types.FunctionType` which is already
118+
# decorated by the profiler, or
119+
# - `func` is any of the C-level callables that can't be
120+
# profiled
95121
# otherwise, wrapper objects are always returned.
96122
self.add_callable(func)
97123
return self.wrap_callable(func)
98124

125+
def wrap_callable(self, func):
126+
if is_c_level_callable(func): # Non-profilable
127+
return func
128+
return super().wrap_callable(func)
129+
99130
def add_callable(self, func):
100131
"""
101132
Register a function, method, property, partial object, etc. with
@@ -132,50 +163,153 @@ def print_stats(self, stream=None, output_unit=None, stripzeros=False,
132163
stream=stream, stripzeros=stripzeros,
133164
details=details, summarize=summarize, sort=sort, rich=rich)
134165

135-
def _add_namespace(self, namespace, *, wrap=False):
136-
"""
137-
Add the members (callables (wrappers), methods, classes, ...) in
138-
a namespace and profile them.
139-
140-
Args:
141-
namespace (Union[ModuleType, type]):
142-
Module or class to be profiled.
143-
wrap (bool):
144-
Whether to replace the wrapped members with wrappers
145-
which automatically enable/disable the profiler when
146-
called.
147-
148-
Returns:
149-
n (int):
150-
Number of members added to the profiler.
151-
"""
152-
return self._add_namespace_inner(set(), namespace, wrap=wrap)
153-
154-
def _add_namespace_inner(self, duplicate_tracker, namespace, *,
155-
wrap=False):
166+
def _add_namespace(self, duplicate_tracker, namespace, *,
167+
filter_scope=None, wrap=False):
156168
count = 0
157-
add_cls = self._add_namespace_inner
169+
add_cls = self._add_namespace
158170
add_func = self.add_callable
171+
wrap_failures = {}
172+
if filter_scope is None:
173+
def filter_scope(*_):
174+
return True
175+
159176
for attr, value in vars(namespace).items():
160177
if id(value) in duplicate_tracker:
161178
continue
162179
duplicate_tracker.add(id(value))
163180
if isinstance(value, type):
164-
if add_cls(duplicate_tracker, value, wrap=wrap):
165-
count += 1
181+
if filter_scope(namespace, value):
182+
if add_cls(duplicate_tracker, value, wrap=wrap):
183+
count += 1
166184
continue
167185
try:
168-
func_needs_adding = add_func(value)
186+
if not add_func(value):
187+
continue
169188
except TypeError: # Not a callable (wrapper)
170189
continue
171-
if not func_needs_adding:
172-
continue
173190
if wrap:
174-
setattr(namespace, attr, self.wrap_callable(value))
191+
wrapper = self.wrap_callable(value)
192+
if wrapper is not value:
193+
try:
194+
setattr(namespace, attr, wrapper)
195+
except (TypeError, AttributeError):
196+
# Corner case in case if a class/module don't
197+
# allow setting attributes (could e.g. happen
198+
# with some builtin/extension classes, but their
199+
# method should be in C anyway, so
200+
# `.add_callable()` should've returned 0 and we
201+
# shouldn't be here)
202+
wrap_failures[attr] = value
175203
count += 1
204+
if wrap_failures:
205+
msg = (f'cannot wrap {len(wrap_failures)} attribute(s) of '
206+
f'{namespace!r} (`{{attr: value}}`): {wrap_failures!r}')
207+
warnings.warn(msg, stacklevel=2)
176208
return count
177209

178-
add_class = add_module = _add_namespace
210+
def add_class(self, cls, *, match_scope='siblings', wrap=False):
211+
"""
212+
Add the members (callables (wrappers), methods, classes, ...) in
213+
a class' local namespace and profile them.
214+
215+
Args:
216+
cls (type):
217+
Class to be profiled.
218+
match_scope (Literal['exact', 'siblings', 'descendants',
219+
'none']):
220+
Whether (and how) to match the scope of member classes
221+
and decide on whether to add them:
222+
- 'exact': only add classes defined locally in this
223+
namespace, i.e. in the body of `cls`, as "inner
224+
classes"
225+
- 'descendants': only add "inner classes", their "inner
226+
classes", and so on.
227+
- 'siblings': only add classes fulfilling 'descendants',
228+
or defined in the same module as `cls`
229+
- 'none': don't check scopes and add all classes in the
230+
namespace
231+
wrap (bool):
232+
Whether to replace the wrapped members with wrappers
233+
which automatically enable/disable the profiler when
234+
called.
235+
236+
Returns:
237+
n (int):
238+
Number of members added to the profiler.
239+
"""
240+
def class_is_child(cls, other):
241+
if not modules_are_equal(cls, other):
242+
return False
243+
return other.__qualname__ == f'{cls.__qualname__}.{other.__name__}'
244+
245+
def modules_are_equal(cls, other): # = sibling check
246+
return cls.__module__ == other.__module__
247+
248+
def class_is_descendant(cls, other):
249+
if not modules_are_equal(cls, other):
250+
return False
251+
return other.__qualname__.startswith(cls.__qualname__ + '.')
252+
253+
filter_scope = {'exact': class_is_child,
254+
'descendants': class_is_descendant,
255+
'siblings': modules_are_equal,
256+
'none': None}[match_scope]
257+
return self._add_namespace(set(), cls,
258+
filter_scope=filter_scope, wrap=wrap)
259+
260+
def add_module(self, mod, *, match_scope='siblings', wrap=False):
261+
"""
262+
Add the members (callables (wrappers), methods, classes, ...) in
263+
a module's local namespace and profile them.
264+
265+
Args:
266+
mod (ModuleType):
267+
Module to be profiled.
268+
match_scope (Literal['exact', 'siblings', 'descendants',
269+
'none']):
270+
Whether (and how) to match the scope of member classes
271+
and decide on whether to add them:
272+
- 'exact': only add classes defined locally in this
273+
namespace, i.e. in the body of `mod`
274+
- 'descendants': only add locally-defined classes,
275+
classes locally defined in their bodies, and so on
276+
- 'siblings': only add classes fulfilling 'descendants',
277+
or defined in sibling modules/subpackages to `mod` (if
278+
`mod` is part of a package)
279+
- 'none': don't check scopes and add all classes in the
280+
namespace
281+
wrap (bool):
282+
Whether to replace the wrapped members with wrappers
283+
which automatically enable/disable the profiler when
284+
called.
285+
286+
Returns:
287+
n (int):
288+
Number of members added to the profiler.
289+
"""
290+
def match_prefix(s: str, prefix: str, sep: str = '.') -> bool:
291+
return s == prefix or s.startswith(prefix + sep)
292+
293+
def class_is_child(mod, other):
294+
return other.__module__ == mod.__name__
295+
296+
def class_is_descendant(mod, other):
297+
return match_prefix(other.__module__, mod.__name__)
298+
299+
def class_is_cousin(mod, other):
300+
if class_is_descendant(mod, other):
301+
return True
302+
return match_prefix(other.__module__, parent)
303+
304+
parent, _, basename = mod.__name__.rpartition('.')
305+
filter_scope = {'exact': class_is_child,
306+
'descendants': class_is_descendant,
307+
'siblings': (class_is_cousin # Only if a pkg
308+
if basename else
309+
class_is_descendant),
310+
'none': None}[match_scope]
311+
return self._add_namespace(set(), mod,
312+
filter_scope=filter_scope, wrap=wrap)
179313

180314

181315
# This could be in the ipython_extension submodule,

line_profiler/line_profiler.pyi

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,51 @@
1-
from types import ModuleType
2-
from typing import List, Literal, Tuple
1+
from functools import cached_property, partial, partialmethod
2+
from inspect import isfunction as is_function
3+
from types import (FunctionType, MethodType, ModuleType,
4+
BuiltinFunctionType, BuiltinMethodType,
5+
ClassMethodDescriptorType, MethodDescriptorType,
6+
MethodWrapperType, WrapperDescriptorType)
7+
from typing import (overload,
8+
Any, Callable, List, Literal, Tuple, TypeGuard, TypeVar)
39
import io
410
from ._line_profiler import LineProfiler as CLineProfiler
511
from .profiler_mixin import ByCountProfilerMixin
612
from _typeshed import Incomplete
713

814

15+
CLevelCallable = TypeVar('CLevelCallable',
16+
BuiltinFunctionType, BuiltinMethodType,
17+
ClassMethodDescriptorType, MethodDescriptorType,
18+
MethodWrapperType, WrapperDescriptorType)
19+
CallableLike = TypeVar('CallableLike',
20+
FunctionType, partial, property, cached_property,
21+
MethodType, staticmethod, classmethod, partialmethod)
22+
MatchScopeOption = Literal['exact', 'descendants', 'siblings', 'none']
23+
24+
25+
def is_c_level_callable(func: Any) -> TypeGuard[CLevelCallable]:
26+
...
27+
28+
929
def load_ipython_extension(ip) -> None:
1030
...
1131

1232

1333
class LineProfiler(CLineProfiler, ByCountProfilerMixin):
34+
@overload
35+
def __call__(self, # type: ignore[overload-overlap]
36+
func: CLevelCallable) -> CLevelCallable:
37+
...
38+
39+
@overload
40+
def __call__(self, # type: ignore[overload-overlap]
41+
func: CallableLike) -> CallableLike:
42+
...
43+
44+
# Fallback: just wrap the `.__call__()` of a generic callable
45+
46+
@overload
47+
def __call__(self, func: Callable) -> FunctionType:
48+
...
1449

1550
def add_callable(self, func) -> Literal[0, 1]:
1651
...
@@ -28,10 +63,14 @@ class LineProfiler(CLineProfiler, ByCountProfilerMixin):
2863
rich: bool = ...) -> None:
2964
...
3065

31-
def add_module(self, mod: ModuleType, *, wrap: bool = False) -> int:
66+
def add_module(self, mod: ModuleType, *,
67+
match_scope: MatchScopeOption = 'siblings',
68+
wrap: bool = False) -> int:
3269
...
3370

34-
def add_class(self, cls: type, *, wrap: bool = False) -> int:
71+
def add_class(self, cls: type, *,
72+
match_scope: MatchScopeOption = 'siblings',
73+
wrap: bool = False) -> int:
3574
...
3675

3776

0 commit comments

Comments
 (0)