Skip to content

FIX: (+ENH?) fixed and extended dispatching for LineProfiler.__call__()#332

Merged
Erotemic merged 7 commits into
pyutils:mainfrom
TTsangSC:class-method-fix
Apr 14, 2025
Merged

FIX: (+ENH?) fixed and extended dispatching for LineProfiler.__call__()#332
Erotemic merged 7 commits into
pyutils:mainfrom
TTsangSC:class-method-fix

Conversation

@TTsangSC
Copy link
Copy Markdown
Collaborator

@TTsangSC TTsangSC commented Apr 13, 2025

(Closes #328)

Synopsis

This PR:

  • Fixes the known bug that explicit decoration/wrapping of class and static (and also bound) methods returns non-instances thereof, resulting in wrong argument bindings.
  • Fixes the lack of handling for async generator functions.
  • Extends LineProfiler so that it can reliably handle decorating the following:
    • property, functools.cached_property
    • functools.partial
    • functools.partialmethod
  • Adds tests for all of the above.

Motivation

  • As noted in BUG: LineProfiler.__call__() bugged on class and static methods #328, decorating class and static methods with LineProfiler (and by extension GlobalProfiler) is currently bugged because the returned object is a regular types.FunctionType, which has different behaviors on .__get__() compared with the decorated objects.
  • It is also noticed that we have a .wrap_function(), a .wrap_generator(), a .wrap_coroutine(), but not a corresponding .wrap_async_generator().
  • As noted in the discussions of Autoprofile is not working as expected. #318, line_profiler.autoprofile currently neglects properties on imports. Indeed, it neglects anything that isn't a vanilla types.FunctionType (like class and static methods) because of rather conservative inspect.isfunction() checks in line_profiler/autoprofile/line_profiler_utils.py::add_imported_function_or_module() and line_profiler/line_profiler.py::LineProfiler.add_module().

Implementation

  • line_profiler/profiler_mixin.py::ByCountProfilerMixin.wrap_callable() now dispatches to the following new methods where appropriate:
    • .wrap_{bound,static,partial}method()
    • .wrap_partial()
    • .wrap_[cached_]property()
    • .wrap_async_generator()
  • Instead of calling .add_function() directly, line_profiler/line_profiler.py::LineProfiler.__call__() now calls the new .add_callable(), which likewise handles dispatch for these object types and passes the underlying function objects to .add_function().
  • New tests tests/test_line_profiler.py::test_{{bound,static,partial}method,[cached_]property,partial,async_gen}_decorator() for the above.
  • New test tests/test_line_profiler.py::test_coroutine_decorator() for completeness.
  • Updated tests tests/test_line_profiler.py::test_{function,gen,classmethod}_decorator() to be more comprehensive and consistent with the new tests.

Caveats

The added dispatching may have one-time performance implications. However, as long as we aren't actively adding new items to be profiled by the profiler from within the profiled code, it should not impact the profiling results.

Further work

By merging this, we can be a bit more liberal with the types of objects we can decorate and/or register with a LineProfiler. This opens avenues for the revision of the implementations of the aforementioned add_imported_function_or_module() and add_module(), allowing e.g. properties and class/static methods to be profiled with kernprof --prof-mod=..., kernprof --prof-imports, etc.

Though not as thorough as import-time AST rewrites (see comment), it will be useful (1) in the meantime before that is integrated, and (2) covering edge cases that AST rewrites may miss.

line_profiler/profiler_mixin.py[i]
    is_{static,bound,partial}method()
    is_partial()
    is_[cached_]property()
        New checker callables
    ByCountProfilerMixin.wrap_callable()
        Extended dispatch table
    ByCountProfilerMixin.wrap_classmethod()
        Fixed implementation;
        now returns a `classmethod` object
    ByCountProfilerMixin.wrap_{static,bound,partial}method()
    ByCountProfilerMixin.wrap_{partial,[cached_]property}()
        New methods
line_profiler/line_profiler.py[i]::LineProfiler
    __call__()
        Added dispatch for calling `.add_function()` on the inner
        callables of callable-wrapper objects
    add_property()
        New helper method for calling `.add_function()` on the getter,
        setter, and deleter implementations of a property

tests/test_line_profiler.py
    test_classmethod_decorator()
        Overhauled to test passing a real `classmethod` (instead of a
        bound method) to the profiler
    test_{static,bound,partial}method_decorator()
    test_{partial,[cached_]property}_decorator()
        New analogous tests for decorating/profiling other
        callable(-like) objects
line_profiler/profiler_mixin.py[i]
    is_async_generator()
        New checker function
    ByCountProfilerMixin.wrap_callable()
        Added dispatch to `.wrap_async_generator()`
    ByCountProfilerMixin.wrap_async_generator()
        New method for wrapping async generator functions
    ByCountProfilerMixin.wrap_generator()
        Simplified implementation

tests/test_line_profiler.py
    test_function_decorator(), test_gen_decorator()
        - Added check for profiler timings before/after running the
          profiled functions
        - Added check that the profiled function is in
          `LineProfiler.functions`
        - (Only for `test_gen_decorator()`) added check that the wrapper
          is a generator function
    test_coroutine_decorator(), test_async_gen_decorator()
        New tests for wrapping coroutines and async generators
line_profiler/line_profiler.py[i]::LineProfiler
    __call__()
        - Now deferring object registration to `.add_callable()`
        - Revised docstring
    add_callable()
        New wrapper method around the underlying `.add_function()`
        Cython method which expects functions, so that we can deal with
        callable-like wrappers like class methods, properties, etc.
        (Separated from `.__call__()`)
    add_property()
        Added docstring
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
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 13, 2025

Codecov Report

Attention: Patch coverage is 36.15385% with 83 lines in your changes missing coverage. Please review.

Project coverage is 62.30%. Comparing base (000a366) to head (7f25e70).
Report is 9 commits behind head on main.

Files with missing lines Patch % Lines
line_profiler/profiler_mixin.py 31.00% 59 Missing and 10 partials ⚠️
line_profiler/line_profiler.py 53.33% 9 Missing and 5 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #332      +/-   ##
==========================================
- Coverage   65.37%   62.30%   -3.08%     
==========================================
  Files          13       13              
  Lines         901     1008     +107     
  Branches      196      225      +29     
==========================================
+ Hits          589      628      +39     
- Misses        262      316      +54     
- Partials       50       64      +14     
Files with missing lines Coverage Δ
line_profiler/line_profiler.py 66.66% <53.33%> (-3.12%) ⬇️
line_profiler/profiler_mixin.py 39.39% <31.00%> (-5.19%) ⬇️

Continue to review full report in Codecov by Sentry.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update f030f48...7f25e70. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@TTsangSC
Copy link
Copy Markdown
Collaborator Author

Coverage in profiler_mixin.py being under-reported, nothing new here. Still haven't figured out why since last time unfortunately.

@Erotemic
Copy link
Copy Markdown
Member

I need to figure out how to disable codecov checks. Do you happen to know? It's inserting a bunch of garbage warnings in the diff that makes it harder to read / review.


def wrap_coroutine(self, func):
"""
Wrap a Python 3.5 coroutine to profile it.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lol old code.

return getattr(func, self._profiler_wrapped_marker, None) == id(self)

def _mark_wrapped(self, func):
setattr(func, self._profiler_wrapped_marker, id(self))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there ever a case where func would be some object where setattr would fail? Some objects with __slots__ cannot have attribute set, and I'm wondering: can we always assume the objects passed here will have settable attributes?

It looks like only wrapper functions are used here, so I think we are ok.

@Erotemic
Copy link
Copy Markdown
Member

This all makes sense and the implementation is clean. Excellent work!

@Erotemic Erotemic merged commit 91c2ad1 into pyutils:main Apr 14, 2025
34 of 36 checks passed
@TTsangSC
Copy link
Copy Markdown
Collaborator Author

On the topic of disabling codecov, I guess one can just

  • Pull the two Codecov upload jobs (L175–188) from .github/workflows/tests.yml
  • Maybe purge .codecov.xml?
  • Pull the |Codecov| badge (L4) from README

But CI is out of my depths so IDK.

Also not sure if ditching coverage (or rather codecov) is a good idea... on the one hand you're definitely right in that it currently clutters the diff, but on the other I still have a tiny sliver of hope that it can be made to work properly. As far as I can tell much of our modern CI workflow (esp. the coverage-related stuff) originated with you in 8366efe, and before that, @s-weigand in 06af7ed – let see if he has anything to add.

I suspect the fundamental issue here is that line_profiler is vying with coverage over control of system wide tracing.1 Will write an issue and try to explore possible solutions.

Footnotes

  1. Ironically, running tests between different Python subprocesses and later combining the coverage data would have alleviated the issue (despite my initially believing that to be the culprit), because coverage.py would have had control over tracing up until the point we .enable() the first LineProfiler instance in a Python process.

@TTsangSC TTsangSC deleted the class-method-fix branch April 17, 2025 19:17
TTsangSC added a commit to TTsangSC/line_profiler that referenced this pull request Apr 21, 2025
line_profiler/autoprofile/line_profiler_utils.py
    add_imported_function_or_module()
        - Added new argument `wrap` for controlling whether to replace
          class and module members with wrappers
        - Refactored object adding to be more aggressive, ditching the
          explicit `inspect.isfunction()` check (since we expanded the
          catalog of addable objects in pyutils#332)
        - Now returning whether any function has been added to the
          profiler

line_profiler/line_profiler.py
    add_module()
        - Now shares an implementation with `.add_class()`
        - Added new argument `wrap` for controlling whether to replace
          members with wrappers
        - Refactored object adding to be more aggressive, ditching the
          explicit `is_function()` check (since we expanded the catalog
          of addable objects in pyutils#332)
    add_class()
        New method (alias to `.add_module()`)
TTsangSC added a commit to TTsangSC/line_profiler that referenced this pull request Apr 27, 2025
line_profiler/autoprofile/line_profiler_utils.py
    add_imported_function_or_module()
        - Added new argument `wrap` for controlling whether to replace
          class and module members with wrappers
        - Refactored object adding to be more aggressive, ditching the
          explicit `inspect.isfunction()` check (since we expanded the
          catalog of addable objects in pyutils#332)
        - Now returning whether any function has been added to the
          profiler

line_profiler/line_profiler.py
    add_module()
        - Now shares an implementation with `.add_class()`
        - Added new argument `wrap` for controlling whether to replace
          members with wrappers
        - Refactored object adding to be more aggressive, ditching the
          explicit `is_function()` check (since we expanded the catalog
          of addable objects in pyutils#332)
    add_class()
        New method (alias to `.add_module()`)
TTsangSC added a commit to TTsangSC/line_profiler that referenced this pull request May 5, 2025
line_profiler/autoprofile/line_profiler_utils.py
    add_imported_function_or_module()
        - Added new argument `wrap` for controlling whether to replace
          class and module members with wrappers
        - Refactored object adding to be more aggressive, ditching the
          explicit `inspect.isfunction()` check (since we expanded the
          catalog of addable objects in pyutils#332)
        - Now returning whether any function has been added to the
          profiler

line_profiler/line_profiler.py
    add_module()
        - Now shares an implementation with `.add_class()`
        - Added new argument `wrap` for controlling whether to replace
          members with wrappers
        - Refactored object adding to be more aggressive, ditching the
          explicit `is_function()` check (since we expanded the catalog
          of addable objects in pyutils#332)
    add_class()
        New method (alias to `.add_module()`)
TTsangSC added a commit to TTsangSC/line_profiler that referenced this pull request May 17, 2025
line_profiler/autoprofile/line_profiler_utils.py
    add_imported_function_or_module()
        - Added new argument `wrap` for controlling whether to replace
          class and module members with wrappers
        - Refactored object adding to be more aggressive, ditching the
          explicit `inspect.isfunction()` check (since we expanded the
          catalog of addable objects in pyutils#332)
        - Now returning whether any function has been added to the
          profiler

line_profiler/line_profiler.py
    add_module()
        - Now shares an implementation with `.add_class()`
        - Added new argument `wrap` for controlling whether to replace
          members with wrappers
        - Refactored object adding to be more aggressive, ditching the
          explicit `is_function()` check (since we expanded the catalog
          of addable objects in pyutils#332)
    add_class()
        New method (alias to `.add_module()`)
TTsangSC added a commit to TTsangSC/line_profiler that referenced this pull request May 18, 2025
line_profiler/autoprofile/line_profiler_utils.py
    add_imported_function_or_module()
        - Added new argument `wrap` for controlling whether to replace
          class and module members with wrappers
        - Refactored object adding to be more aggressive, ditching the
          explicit `inspect.isfunction()` check (since we expanded the
          catalog of addable objects in pyutils#332)
        - Now returning whether any function has been added to the
          profiler

line_profiler/line_profiler.py
    add_module()
        - Now shares an implementation with `.add_class()`
        - Added new argument `wrap` for controlling whether to replace
          members with wrappers
        - Refactored object adding to be more aggressive, ditching the
          explicit `is_function()` check (since we expanded the catalog
          of addable objects in pyutils#332)
    add_class()
        New method (alias to `.add_module()`)
TTsangSC added a commit to TTsangSC/line_profiler that referenced this pull request May 20, 2025
line_profiler/autoprofile/line_profiler_utils.py
    add_imported_function_or_module()
        - Added new argument `wrap` for controlling whether to replace
          class and module members with wrappers
        - Refactored object adding to be more aggressive, ditching the
          explicit `inspect.isfunction()` check (since we expanded the
          catalog of addable objects in pyutils#332)
        - Now returning whether any function has been added to the
          profiler

line_profiler/line_profiler.py
    add_module()
        - Now shares an implementation with `.add_class()`
        - Added new argument `wrap` for controlling whether to replace
          members with wrappers
        - Refactored object adding to be more aggressive, ditching the
          explicit `is_function()` check (since we expanded the catalog
          of addable objects in pyutils#332)
    add_class()
        New method (alias to `.add_module()`)
TTsangSC added a commit to TTsangSC/line_profiler that referenced this pull request May 24, 2025
line_profiler/autoprofile/line_profiler_utils.py
    add_imported_function_or_module()
        - Added new argument `wrap` for controlling whether to replace
          class and module members with wrappers
        - Refactored object adding to be more aggressive, ditching the
          explicit `inspect.isfunction()` check (since we expanded the
          catalog of addable objects in pyutils#332)
        - Now returning whether any function has been added to the
          profiler

line_profiler/line_profiler.py
    add_module()
        - Now shares an implementation with `.add_class()`
        - Added new argument `wrap` for controlling whether to replace
          members with wrappers
        - Refactored object adding to be more aggressive, ditching the
          explicit `is_function()` check (since we expanded the catalog
          of addable objects in pyutils#332)
    add_class()
        New method (alias to `.add_module()`)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

BUG: LineProfiler.__call__() bugged on class and static methods

2 participants