Skip to content

Commit c33d62b

Browse files
committed
Tests and fixes
line_profiler/line_profiler.py::LineProfiler.add_class(), add_module() Fixed implementation of internal method used (wrong interpretation of the `match_scope` parameter) tests/test_explicit_profile.py test_profiler_add_methods() test_profiler_add_class_recursion_guard() Simplified implementations test_profiler_warn_unwrappable() New test for the warning in `LineProfiler.add_*(wrap=True)` test_profiler_scope_matching() New test for `LineProfiler.add_*(match_scope=...)` tests/test_line_profiler.py::test_profiler_c_callable_no_op() New test for how the profiler leaves C-level callables untouched
1 parent a660317 commit c33d62b

File tree

3 files changed

+247
-77
lines changed

3 files changed

+247
-77
lines changed

line_profiler/line_profiler.py

Lines changed: 60 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
inspect its output. This depends on the :py:mod:`line_profiler._line_profiler`
55
Cython backend.
66
"""
7+
import functools
78
import pickle
89
import inspect
910
import linecache
@@ -164,31 +165,37 @@ def print_stats(self, stream=None, output_unit=None, stripzeros=False,
164165
details=details, summarize=summarize, sort=sort, rich=rich)
165166

166167
def _add_namespace(self, duplicate_tracker, namespace, *,
167-
filter_scope=None, wrap=False):
168+
match_scope='none', wrap=False):
168169
count = 0
169-
add_cls = self._add_namespace
170+
add_cls = functools.partial(self._add_namespace, duplicate_tracker,
171+
match_scope=match_scope, wrap=wrap)
170172
add_func = self.add_callable
173+
remember = duplicate_tracker.add
174+
wrap_func = self.wrap_callable
171175
wrap_failures = {}
172-
if filter_scope is None:
173-
def filter_scope(*_):
176+
if match_scope == 'none':
177+
def check(*_):
174178
return True
179+
elif isinstance(namespace, type):
180+
check = self._add_class_filter(namespace, match_scope)
181+
else:
182+
check = self._add_module_filter(namespace, match_scope)
175183

176184
for attr, value in vars(namespace).items():
177185
if id(value) in duplicate_tracker:
178186
continue
179-
duplicate_tracker.add(id(value))
187+
remember(id(value))
180188
if isinstance(value, type):
181-
if filter_scope(namespace, value):
182-
if add_cls(duplicate_tracker, value, wrap=wrap):
183-
count += 1
189+
if check(value) and add_cls(value):
190+
count += 1
184191
continue
185192
try:
186193
if not add_func(value):
187194
continue
188195
except TypeError: # Not a callable (wrapper)
189196
continue
190197
if wrap:
191-
wrapper = self.wrap_callable(value)
198+
wrapper = add_func(value)
192199
if wrapper is not value:
193200
try:
194201
setattr(namespace, attr, wrapper)
@@ -207,6 +214,48 @@ def filter_scope(*_):
207214
warnings.warn(msg, stacklevel=2)
208215
return count
209216

217+
@staticmethod
218+
def _add_module_filter(mod, match_scope):
219+
def match_prefix(s, prefix, sep='.'):
220+
return s == prefix or s.startswith(prefix + sep)
221+
222+
def class_is_child(other):
223+
return other.__module__ == mod.__name__
224+
225+
def class_is_descendant(other):
226+
return match_prefix(other.__module__, mod.__name__)
227+
228+
def class_is_cousin(other):
229+
if class_is_descendant(other):
230+
return True
231+
return match_prefix(other.__module__, parent)
232+
233+
parent, _, basename = mod.__name__.rpartition('.')
234+
return {'exact': class_is_child,
235+
'descendants': class_is_descendant,
236+
'siblings': (class_is_cousin # Only if a pkg
237+
if basename else
238+
class_is_descendant)}[match_scope]
239+
240+
@staticmethod
241+
def _add_class_filter(cls, match_scope):
242+
def class_is_child(other):
243+
if not modules_are_equal(other):
244+
return False
245+
return other.__qualname__ == f'{cls.__qualname__}.{other.__name__}'
246+
247+
def modules_are_equal(other): # = sibling check
248+
return cls.__module__ == other.__module__
249+
250+
def class_is_descendant(other):
251+
if not modules_are_equal(other):
252+
return False
253+
return other.__qualname__.startswith(cls.__qualname__ + '.')
254+
255+
return {'exact': class_is_child,
256+
'descendants': class_is_descendant,
257+
'siblings': modules_are_equal}[match_scope]
258+
210259
def add_class(self, cls, *, match_scope='siblings', wrap=False):
211260
"""
212261
Add the members (callables (wrappers), methods, classes, ...) in
@@ -237,25 +286,8 @@ def add_class(self, cls, *, match_scope='siblings', wrap=False):
237286
n (int):
238287
Number of members added to the profiler.
239288
"""
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]
257289
return self._add_namespace(set(), cls,
258-
filter_scope=filter_scope, wrap=wrap)
290+
match_scope=match_scope, wrap=wrap)
259291

260292
def add_module(self, mod, *, match_scope='siblings', wrap=False):
261293
"""
@@ -287,29 +319,8 @@ def add_module(self, mod, *, match_scope='siblings', wrap=False):
287319
n (int):
288320
Number of members added to the profiler.
289321
"""
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]
311322
return self._add_namespace(set(), mod,
312-
filter_scope=filter_scope, wrap=wrap)
323+
match_scope=match_scope, wrap=wrap)
313324

314325

315326
# This could be in the ipython_extension submodule,

tests/test_explicit_profile.py

Lines changed: 165 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@
88
import ubelt as ub
99

1010

11+
@contextlib.contextmanager
12+
def enter_tmpdir():
13+
with contextlib.ExitStack() as stack:
14+
enter = stack.enter_context
15+
tmpdir = os.path.abspath(enter(tempfile.TemporaryDirectory()))
16+
enter(ub.ChDir(tmpdir))
17+
yield ub.Path(tmpdir)
18+
19+
1120
def test_simple_explicit_nonglobal_usage():
1221
"""
1322
python -c "from test_explicit_profile import *; test_simple_explicit_nonglobal_usage()"
@@ -458,10 +467,7 @@ def write(path, code):
458467
'' if wrap_class is None else f', wrap={wrap_class}',
459468
reset_enable_count))
460469

461-
with contextlib.ExitStack() as stack:
462-
enter = stack.enter_context
463-
enter(ub.ChDir(enter(tempfile.TemporaryDirectory())))
464-
curdir = ub.Path.cwd()
470+
with enter_tmpdir() as curdir:
465471
write(curdir / 'script.py', script)
466472
write(curdir / 'my_module_1.py',
467473
'''
@@ -507,49 +513,180 @@ def test_profiler_add_class_recursion_guard():
507513
has a reference to the other in its namespace, we don't end up in
508514
infinite recursion.
509515
"""
510-
with contextlib.ExitStack() as stack:
511-
enter = stack.enter_context
512-
enter(ub.ChDir(enter(tempfile.TemporaryDirectory())))
513-
curdir = ub.Path.cwd()
514-
(curdir / 'script.py').write_text(ub.codeblock("""
515-
from line_profiler import LineProfiler
516+
from line_profiler import LineProfiler
517+
518+
class Class1:
519+
def method1(self):
520+
pass
521+
522+
class ChildClass1:
523+
def child_method_1(self):
524+
pass
525+
526+
class Class2:
527+
def method2(self):
528+
pass
529+
530+
class ChildClass2:
531+
def child_method_2(self):
532+
pass
533+
534+
OtherClass = Class1
535+
# A duplicate reference shouldn't affect profiling either
536+
YetAnotherClass = Class1
537+
538+
# Add self/mutual references
539+
Class1.ThisClass = Class1
540+
Class1.OtherClass = Class2
516541

542+
profile = LineProfiler()
543+
profile.add_class(Class1)
544+
assert len(profile.functions) == 4
545+
assert Class1.method1 in profile.functions
546+
assert Class2.method2 in profile.functions
547+
assert Class1.ChildClass1.child_method_1 in profile.functions
548+
assert Class2.ChildClass2.child_method_2 in profile.functions
517549

550+
551+
def test_profiler_warn_unwrappable():
552+
"""
553+
Test for warnings when using `LineProfiler.add_*(wrap=True)` with a
554+
namespace which doesn't allow attribute assignment.
555+
"""
556+
from line_profiler import LineProfiler
557+
558+
class ProblamticMeta(type):
559+
def __init__(cls, *args, **kwargs):
560+
super(ProblamticMeta, cls).__init__(*args, **kwargs)
561+
cls._initialized = True
562+
563+
def __setattr__(cls, attr, value):
564+
if not getattr(cls, '_initialized', None):
565+
return super(ProblamticMeta, cls).__setattr__(attr, value)
566+
raise AttributeError(
567+
f'cannot set attribute on {type(cls)} instance')
568+
569+
class ProblematicClass(metaclass=ProblamticMeta):
570+
def method(self):
571+
pass
572+
573+
profile = LineProfiler()
574+
vanilla_method = ProblematicClass.method
575+
576+
with pytest.warns(match=r"cannot wrap 1 attribute\(s\) of "
577+
r"<class '.*\.ProblematicClass'> \(`\{attr: value\}`\): "
578+
r"\{'method': <function .*\.method at 0x.*>\}"):
579+
# The method is added to the profiler, but we can't assign its
580+
# wrapper back into the class namespace
581+
assert profile.add_class(ProblematicClass, wrap=True) == 1
582+
583+
assert ProblematicClass.method is vanilla_method
584+
585+
586+
@pytest.mark.parametrize(
587+
('match_scope', 'add_module_targets', 'add_class_targets'),
588+
[('exact',
589+
{'class2_method', 'child_class2_method'},
590+
{'class3_method', 'child_class3_method'}),
591+
('descendants',
592+
{'class2_method', 'child_class2_method',
593+
'class3_method', 'child_class3_method'},
594+
{'class3_method', 'child_class3_method'}),
595+
('siblings',
596+
{'class1_method', 'child_class1_method',
597+
'class2_method', 'child_class2_method',
598+
'class3_method', 'child_class3_method', 'other_class3_method'},
599+
{'class3_method', 'child_class3_method', 'other_class3_method'}),
600+
('none',
601+
{'class1_method', 'child_class1_method',
602+
'class2_method', 'child_class2_method',
603+
'class3_method', 'child_class3_method', 'other_class3_method'},
604+
{'child_class1_method',
605+
'class3_method', 'child_class3_method', 'other_class3_method'})])
606+
def test_profiler_scope_matching(monkeypatch,
607+
match_scope,
608+
add_module_targets,
609+
add_class_targets):
610+
"""
611+
Test for the scope-matching strategies of the `LineProfiler.add_*()`
612+
methods.
613+
"""
614+
def write(path, code=None):
615+
path.parent.mkdir(exist_ok=True, parents=True)
616+
if code is None:
617+
path.touch()
618+
else:
619+
path.write_text(ub.codeblock(code))
620+
621+
with enter_tmpdir() as curdir:
622+
pkg_dir = curdir / 'packages' / 'my_pkg'
623+
write(pkg_dir / '__init__.py')
624+
write(pkg_dir / 'submod1.py',
625+
"""
518626
class Class1:
519-
def method1(self):
627+
def class1_method(self):
520628
pass
521629
522630
class ChildClass1:
523-
def child_method_1(self):
631+
def child_class1_method(self):
524632
pass
633+
""")
634+
write(pkg_dir / 'subpkg2' / '__init__.py',
635+
"""
636+
from ..submod1 import Class1 # Import from a sibling
637+
from .submod3 import Class3 # Import from a descendant
525638
526639
527640
class Class2:
528-
def method2(self):
641+
def class2_method(self):
529642
pass
530643
531644
class ChildClass2:
532-
def child_method_2(self):
645+
def child_class2_method(self):
533646
pass
534647
535-
OtherClass = Class1
536-
# A duplicate reference shouldn't affect profiling either
537-
YetAnotherClass = Class1
648+
BorrowedChildClass = Class1.ChildClass1 # Non-sibling class
649+
""")
650+
write(pkg_dir / 'subpkg2' / 'submod3.py',
651+
"""
652+
from ..submod1 import Class1
538653
539654
540-
# Add self/mutual references
541-
Class1.ThisClass = Class1
542-
Class1.OtherClass = Class2
655+
class Class3:
656+
def class3_method(self):
657+
pass
543658
659+
class OtherChildClass3:
660+
def child_class3_method(self):
661+
pass
662+
663+
# Unrelated class
664+
BorrowedChildClass1 = Class1.ChildClass1
665+
666+
class OtherClass3:
667+
def other_class3_method(self):
668+
pass
669+
670+
# Sibling class
671+
Class3.BorrowedChildClass3 = OtherClass3
672+
""")
673+
monkeypatch.syspath_prepend(pkg_dir.parent)
674+
675+
from my_pkg import subpkg2
676+
from line_profiler import LineProfiler
677+
678+
# Add a module
679+
profile = LineProfiler()
680+
profile.add_module(subpkg2, match_scope=match_scope)
681+
assert len(profile.functions) == len(add_module_targets)
682+
added = {func.__name__ for func in profile.functions}
683+
assert added == set(add_module_targets)
684+
# Add a class
544685
profile = LineProfiler()
545-
profile.add_class(Class1)
546-
assert len(profile.functions) == 4
547-
assert Class1.method1 in profile.functions
548-
assert Class2.method2 in profile.functions
549-
assert Class1.ChildClass1.child_method_1 in profile.functions
550-
assert Class2.ChildClass2.child_method_2 in profile.functions
551-
"""))
552-
ub.cmd([sys.executable, 'script.py'], verbose=2).check_returncode()
686+
profile.add_class(subpkg2.Class3, match_scope=match_scope)
687+
assert len(profile.functions) == len(add_class_targets)
688+
added = {func.__name__ for func in profile.functions}
689+
assert added == set(add_class_targets)
553690

554691

555692
if __name__ == '__main__':

0 commit comments

Comments
 (0)