ENH: auto-profile module execution#323
Conversation
Erotemic
left a comment
There was a problem hiding this comment.
Still need to look at this in more detail. But I'm liking what I see so far.
| """Find the path to the executable script for a module.""" | ||
| from line_profiler.autoprofile.util_static import modname_to_modpath | ||
|
|
||
| for suiifx in '.__main__', '': |
There was a problem hiding this comment.
... yikes. Thanks for the note; fixed just now.
On the topic of typos though... Just noticed that the files line_profiler/autoprofile/ast_profle_transformer.py[i] might have missed an i in the name. That could probably be fixed while retaining backwards compatibility by:
- Migrating
ast_profle_transformer.py[i]toast_profile_transformer.py[i] - Adding module-level
__getattr__()and__dir__()atast_profle_transformer.pyto alias to the relocated module, and also to issueDeprecationWarnings orFutureWarnings when it is used.- The former is probably enough, since
kernprofis a dev-tool, andDeprecationWarnings not being visible to end-users by default is probably not a concern. - And maybe the typo-ed module alias can be dropped in v4.5.0 or something.
- The former is probably enough, since
- Rewriting
ast_profle_transformer.pyiaccordingly.
If you deem this worthy of a fix, I'll spin it off into its own issue and submit another PR for that.
One concern though is how module-level attribute access customization (PEP 562) is Python 3.7+, while according to the pyproject.toml and requirements/*.txt we may be supporting as far down to Python 3.6. That said, anything beneath 3.9 has already been EoL-ed, so maybe it isn't an issue? (Apropos, maybe we can also have a CONTRIBUTING.txt for the expected scope of support for new features, among other things?)
There was a problem hiding this comment.
We can remove support for <3.9. I tend to support EOL python versions until there is any friction, and then I drop them. I'll take care of removing older python support as I have scripts for that. I'll put up a PR. (EDIT: I only see support up to 3.8 in pyproject.toml and setup.py, not sure where you see 3.6)
ast_profle_transformer
Ooph, I had to read your comment about 4 times before I saw the spelling error. I don't think anyone is explicitly importing the ast_profle_transformer, so we be backwards incompatible in that corner case. If you want to fix that, a separate PR would be best.
With all these changes we can bump to 4.3.0, add a note in the CHANGELOG and call it good enough.
There was a problem hiding this comment.
I see, thanks for the clarification. Was a bit confused because pyproject.toml::[build-system.requires] and the requirements/*.txt still have lines reading {<pkg>}; python_version < '3.8' and python_version >= '3.6', but I missed the most important python_requires in setup.py.
Just submitted #325 for the typo.
Thanks for taking care of matters.
|
Hi, I noticed that you've assigned the PR to yourself. Does this mean you're still in the progress of reviewing it? If you're done, should I just rebase -> add changelog -> force-push now? Cheers. |
|
Please do the rebase, changelog, etc... I assigned myself so I don't lose track of it. I've given it a baseline read, and it looks good, but I need to find time to step through it in detail and try it out myself. |
159d667 to
69a82d8
Compare
| elif options.module: | ||
| prof.runctx(f'rmod_({options.script!r}, globals(), "__main__")', | ||
| # FIXME: weird CI bug when running this with `globals()` | ||
| # (like `execfile()` below) instead of `None`, saying | ||
| # that 'Another profiling tool is already active'... | ||
| # I thought `_kernprof_overwrite()` took care of that? | ||
| prof.runctx(f'rmod_({options.script!r}, None, "__main__")', | ||
| ns, | ||
| ns) | ||
| else: |
There was a problem hiding this comment.
Hi @Erotemic, I need some help with this... weird stuff going on here.
- Somewhere like two commits ago (after rebasing) I noticed that the existing
-mflag doesn't cover when we don't useautoprofile(i.e. if wekernprof -mwithout-l), so I went ahead and reduplicated the non-autoprofilelogics inkernprof.pyby replacingkernprof.execfile(script_file, ...)with the equivalentrunpy.run_module(options.script, ...)calls. - Currently
tests/test_explicit_profile.py::test_explicit_profile_with_kernprof_m[False-*](a new test) is the only test going thru this code path. Strangely enough, it passes on my machine but fails in CI (run #1, run #2), saying that "Another profiling tool is already active" in theContextualProfile.enable_by_count()call.- I tried various solutions (like swapping out
globals()withNoneas above), but in both cases the test invariably passed on local and failed on CI. This is also weird, given how theline_profiler.profile._kernprof_overwrite(prof)call some 40 lines above is supposed to (I assume) have replaced the globalcProfile.Profileobject withprof. - Also tried both editable and non-editable installs, but more of the same.
- I tried various solutions (like swapping out
So I figured that perhaps its just my new code that is subtly wrong, and I just needed to try harder to resolve it. Out of curiosity though, I tried to crash the else: branch below (which again I borrowed the code from) by inserting a raise Exception. To my horror though nothing changed – all tests still passed on local, which means that the block
else:
prof.runctx('execfile_(%r, globals())' % (script_file,), ns, ns)has actually not been covered by any test:
line_profiler.pyonly has a coverage of 55% on CI, which is lower than the 63% I got locally (see screenshot below) – which didn't coverrunctx().- After explicitly combing thru the test suite, the only other tests which "uses" both explicit profiling and
kernprof.pyseem to be:tests/test_explicit_profile.py::test_explicit_profile_with_kernprof()tests/test_complex_case.py::test_varied_complex_invocations()
Unfortunately, both the above tests use kernprof -l, which sets options.builtin = True and thus don't actually go to that branch.
Given the above, I suspect there's something wrong with line_profiler.LineProfiler.runctx() that we didn't know of and never tested until now. But given that the test does pass on local, it's incredibly hard for me to debug on my own. Any pointers? And since you're already testing out this PR (presumably on your machine), can you also take a closer look at this in particular? Cheers.
There was a problem hiding this comment.
To confirm: you're saying that there is an existing bug
I probably won't have time to review in depth in the near future. It's going to be on a weekend when I don't have other things going on.
It does look like some of the python versions are passing, but it's failing on 3.12. Is that the version you are testing with locally?
Try using cibuildwheel locally to run the 3.12 build / tests to see if it reproduces:
CIBW_BUILD="cp312-*" cibuildwheel --config-file pyproject.toml --platform linux --arch x86_64
Oh, but now I see in your comment an sdist build also failed, which shouldn't be using cibuildwheel..., but that's also using 3.13, which might have other issues. I just switched my local env to 3.13, and I've found a bunch of oddities come up from changes due to PEP 667.
You can try disabling python versions to test in the CI to make things faster. You can also likely use docker to get a clean local state an mimic the CI environment. I haven't used it in awhile, but I think act can run github action pipelines locally.
There was a problem hiding this comment.
(EDITS 19 Mar): typos
you're saying that there is an existing bug
Could be. Maybe I can even try to jury-rig a test for the aforementioned, currently-untested runctx() code path just to see what happens... if that works out, we'd also have more coverage.
won't have time to review in depth in the near future
Understandable, please take your time. I apologize if at any point of the PR I was a bit pushy/overbearing/etc.
Is that the version you are testing with locally?
Locally I'm on 3.13, which worked like a charm. But I do have everything from 8 to 13 (except 10 for whatever reason), so maybe I'll use each of them to fuzz the tests.
Thanks for the tip, that could very well be the issue. locals() did show some expected (EDIT 19 Mar: unexpected) behaviors when I tinkered with the PR and maybe that was (part of) what went wrong.
cibuildwheel
docker
Not familiar with those but will try to look into them. Seem like good tools to have in the box...
Anyhow, thanks for the suggestions. Let's keep ourselves update (EDIT 19 Mar: updated) here if anything new comes up.
There was a problem hiding this comment.
Could be. Maybe I can even try to jury-rig a test for the aforementioned, currently-untested runctx() code path just to see what happens... if that works out, we'd also have more coverage.
If you find one and can add it independently as a separate PR, that would be valuable. You don't have to fix it in a separate PR, but something minimal that just shows it is an existing problem might be a good first step. If it is a non-trivial amount of work, then don't worry about it, but that's what I would do if I saw this behavior.
cibuildwheel will let you test all of the python versions on your local machine using docker. I also have a helper script in my xdev package, that can help. If you pip install xdev, then cd into the line profiler directory and run:
xdev freshpyenv --image=python:3.12
Which will run this script.
It will drop you into a docker minimal container with 3.12 as the main python. You will be in a clone of your local repo state (i.e. if you commit something on your local host, and then pull in the docker container you can get the changes without having to restart the image).
From there you can:
pip install -e .[all-strict] -v
To install line-profiler and minimal test dependencies in development mode, then you can run pytest. As previously mentioned, if you make a change, then just commit it (no need to push). The repo in the container points to a volume mount of your local repo, so it can pull in the changes.
I typically develop in a local environment on my host system, but when I get tricky things like these, using docker can help isolate the problem from any confounding factors on my host system.
EDIT: I was able to reproduce the error on 3.12 this way.
There was a problem hiding this comment.
Hi, please forgive me for the tangent, but I've been working on some tool of my own that uses this new AstTreeModuleProfiler added here. One corner case that I've noticed is that it still errors on relative imports in __init__.py, because the resolved module name there is that of the containing package, without the trailing .__init__ (so if foo/bar/__init__.py does a from . import baz, it is rewritten to from foo import baz instead of the intended from foo.bar import baz).
Now the fix is super easy, we can always just special-case __init__.py files in the code to resolve to the correct namespace, like we're already doing for __main__.py to support running packages. However, it may also be gratuitous for our use-case, because __init__.pys are normally not the file run with python -m, except if someone tries to be cute and do python -m something.__init__, which is probably a bad idea anyway.
Do you reckon that I should also patch this corner case up in this PR, or leave the feature as-is, and go back to fix the important bug that we've been discussing? Cheers.
There was a problem hiding this comment.
Do the modname_to_modpath and modpath_to_modname functions in line_profiler/autoprofile/util_static.py not handle this?
I don't think there are any ambiguities in implementing this correctly, so might as well do it.
|
I just rebased on #326 (which itself has been rebased on the newest |
|
Update: to facilitate review I've pushed the rebased changes to a new branch at https://github.com/TTsangSC/line_profiler/commits/autoprofile-module-review/. Here's a summary:
The two commits below concern adjacent features (
If you find these OK after reviewing, I'll rebase and force-push to the current branch; cheers. |
edef0a3 to
21d3c47
Compare
|
Just rebased and force-pushed. Sorry for it taking this long, I was confused by how my new test Since you mentioned needing the feature for something, I guess it's for the best that I keep the aforementioned |
Codecov ReportAttention: Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #323 +/- ##
==========================================
+ Coverage 62.45% 65.37% +2.91%
==========================================
Files 12 13 +1
Lines 855 901 +46
Branches 186 196 +10
==========================================
+ Hits 534 589 +55
+ Misses 268 262 -6
+ Partials 53 50 -3
... and 3 files with indirect coverage changes Continue to review full report in Codecov by Sentry.
🚀 New features to boost your workflow:
|
21d3c47 to
8464097
Compare
|
I've been thinking about this a bit. I'm not sure that we want The way I think about If we do want to auto-profile and run Granted, this starts to get burdensome, but I think it's the correct way to do it as it does not hinder expressiveness. I think we can also ease this burden with using pyproject.toml configuration. I can envision a section: [tool.line_profiler]
prof-mod = ["mymod"]to control what is automatically profiled in a controlled way. Let me know what you think. Also, I have this branch checked out, and I seem unable to even run kernprof with And in invoke.sh I have # Test auto-profile kernprof with an path invocation
kernprof -p mymod -p mymod.helpers -p mymod.__init__ -l run_mymod.py
python -m line_profiler -rmt "run_mymod.py.lprof"or kernprof -p mymod,mymod.helpers -l run_mymod.pyAttached is the demo I setup: autoprofile-demo.zip |
|
Fair point, I'll disable the implicit setting of The TOML thing is an interesting angle – thanks for the suggestion – and sounds like a good idea for doing what was intended in a more explicit way. Since in your reply to #29 you also mentioned Thanks for the example. Curious, I thought I covered |
|
Yes, I agree that any pyproject.toml awareness should be taken care of in a separate issue / PR. But the more I think about it the more I like it. I was also thinking it would be a good way to configure the global profiler, and it makes sense with the way the python ecosystem is moving. Let's get this working such that |
line_profiler/autoprofile/autoprofile.py[i]
run()
Added optional argument `as_module`, which if supplied causes a
modified `AstTreeProfiler` subclass to be instantiated
line_profiler/autoprofile/run_module.py[i]
New module handling the execution of Python modules:
- get_module_from_importfrom()
Helper function for consolidating relative imports
- AstTreeModuleProfiler
`AstTreeProfiler` subclass which rewrites relative imports in the
module file to absolute imports, so that the file can be run as-is
kernprof.py
find_module_script()
Substitution for `find_script()` when running modules
Option `-p`/`--prof-mod`
Now having default of `None` instead of `''` so that we can tell
between when the flag is not passed vs when `-p ''` is passed
Option `-m`/`--module`
New option for interpreting the `script` argument as a module,
locating its script and running it;
also set `-p script` as a default
Option `-l`/`--line-by-line`
If autoprofile is used, now passing the `as_module` argument to
`autoprofile.run()` where appropriate
line_profiler/autoprofile/run_module.py[i]
get_module_from_importfrom()
Added optional argument `main` for treating
'some/pkg/__main__.py' and 'some/pkg/submodule.py' differently
ImportFromTransformer
Added argument and attribute `main`
AstTreeProfiler
Now passing the `main` argument to the created
`ImportFromTransformer` when creating the file AST
tests/test_autoprofile.py
test_autoprofile_exec_package(), test_autoprofile_exec_module()
New tests for `kernprof --line-profile -m`
CHANGELOG.rst
Added entry for this PR
kernprof.py
__doc__
Updated with the new `-m` flag
<Others>
Now using `runpy.run_module()` to run the module/package loaded
when `-m` is passed to handle relative imports, so that the
non-`autoprofile` modes can also savely use the `-m` flag
tests/test_explicit_profile.py
test_explicit_profile_with_kernprof_m()
New test for running `kernprof -m` in non-auto-profiling mode
test_explicit_profile_with_duplicate_functions()
Added one trailing empty line (flake8 E305)
kernprof.py::main()
Updated call to `line_profiler.autoprofile.autoprofile.run()`
line_profiler/autoprofile/autoprofile.py[i]::run(...)
`as_module` is now a boolean parameter
line_profiler/autoprofile/run_module.py[i]
get_module_from_importfrom()
ImportFromTransformer
- Removed unused `main` parameter and attribute
- Streamlined doctest and implementation
AstTreeModuleProfiler
- Removed unused `.__init__()` method;
now no longer taking `module_name` as an argument, and has the
same call signature as the superclass
- Updated internal AST-tree-generating method to resolve the
module name from the script name as needed
kernprof.py
No longer setting `--prof-mod=<module>` implicitly when in `-m` mode
tests/test_autoprofile.py
test_autoprofile_exec_package()
- Updated test case without an explicit `--prof-mod` flag
- Added test case with explicit `--prof-mod=<module>`
test_autoprofile_exec_module()
Updated test case without an explicit `--prof-mod` flag
|
Just took a look at your demo.
The right thing to do now would be to:
Will do in a couple minutes. Apropos, when going thru the |
tests/test_autoprofile.py::test_autoprofile_exec_{package,module}()
Added subtests where `kernprof -m` instead of
`python -m kernprof -m` is called
kernprof.py::main()
Added cleanup for global states which can be monkey-patched during
execution:
- `sys.path`
- `sys.argv`
- `line_profiler.profile`
8464097 to
90cb652
Compare
|
I think I've fixed the |
| help="List of modules, functions and/or classes to profile specified by their name or path. " | ||
| "List is comma separated, adding the current script path profiles full script. " | ||
| "Only works with line_profiler -l, --line-by-line") | ||
| parser.add_argument('-m', '--module', action='store_true', |
There was a problem hiding this comment.
Not sure if there is any way to "terminate the option list" after a -m is seen with argparse. That would better match Python's -m behavior.
There was a problem hiding this comment.
We may have to implement some pre-parsing before handing things off to argparse. As it is now, -m is simply a boolean flag and its position doesn't matter at all (and that I agree isn't ideal)... lemme think over this for a moment
CHANGELOG.rst
Updated changelog entry
kernprof.py::main()
`-p`/`--prof-mod`
Now permitting passing multiple copies of this flag and
later collating the results
`-m`/`--module`
Fixed inaccurate help text (`-m` no longer provides the default
for `-p` as of 9131a67)
tests/test_autoprofile.py:test_autoprofile_exec_package()
Added subtest for collating multiple `-p` flags
|
Just pushed another fix for Will get back to you in about an hour on getting |
|
Ok, this looks good. I'm going to merge. Are you interested in working on more of the features we discussed? I'd like to continue streamlining usage of the module, while maintaining backwards compatibility. I think we have a 2 paths foward:
for the pyproject.toml configuration, I would like it to have a 1-to-1 match with the CLI with the exclusion of special args (This is the principle I designed scriptconfig around). There we have kernprof and the explicit profile decorator and they have different configs. So either they need to be unified, or we have to special case. As a simple start maybe something like this: [tool.line_profiler.kernprof]
module = "modname" # <str|None>
rich = true
view = true
skip-zero = false
builtin = false
outfile = 'auto' # rectify with explicit write config
unit = 1e6
setup = ""
line-by-line = false # we may want to consider just defaulting to true in a future major release
output-interval = "disabled"
prof-mod = ["list", "of", "submods"] # we may want to change this name
[tool.line_profiler.write]
lprof = true
text = true
timestamped_text = true
stdout = true
[tool.line_profiler.show]
sort = true
rich = true
details = true
summarize = trueThe above could likely be unified and reworked into something much cleaner, but it's a start. for better autoprofiling, maybe we do have to pre-import the modules marked by prof-mod in order to get the intuitive behavior. (If you specify a module you should get results from that module). For instance in my example, I would expect: to autoprofile the helpers module. In terms of duplicate -p flags, I'd be fine with supporting that, but I'd like it to work more like "extend" in that each value to Ultimately it would be nice to have the ergonomics of running either: In the LINE_PROFILER=1 case, it would require that line_profiler was imported somewhere in the Python runtime and then it could configure itself. It might be tricky because if line_profiler is imported late, then we either couldn't profile those modules or we would have to rewrite module state, which could get ugly. Maybe we could warn the user if they requested an auto-profile of a module that hasn't been imported yet. Running kernprof should be as simple as pre-importing the modules to profile before starting the script. But importing those modules in a different order and context than would happen if kernprof was not used could cause issues (especially with binary libraries that compete for dependencies). I'm wondering if there is a way to hook into the Python import mechanism to intercept and rewrite the module AST with decorated functions as they are imported. That way a module would never be imported out of the intended order. |
|
The TOML stuff looks exciting – count me in. The duplicate On-import AST rewrites are certainly doable, it's what I'm doing in the other project I mentioned. See https://gitlab.com/TTsangSC/pytest-autoprofile/-/blob/master/src/pytest_autoprofile/importers.py?ref_type=heads. Again, we can work out how much of that to put back upstream here – feel free to shop around and see what is to your liking. (EDIT: since you mentioned import ordering, we may have to look into installing Oh, and do you want to merge now or wait till I'm done special-casing |
kernprof.py
_restore_list()
Renamed argument from `l` to `lst` for clarity
pre_parse_single_arg_directive()
New function for parsing out flags like `-m <module>`, which
should cut off further parsing when encountered
main()
- Adapted to use the new special-casing for `-m`, so that calls
like `kernprof -m <flags> <module>` is no longer valid
- Flag `-m` no longer supports the long form `--module`
tests/test_explicit_profile.py::test_explicit_profile_with_kernprof_m()
Updated implementation to comply with the new, stricter syntactics
for `kernprof`
tests/test_kernprof.py::test_kernprof_m_parsing()
New test to check that the `kernprof -m` syntactics behave as
expected
|
Sorry for the delay, but we now have option-list termination for |
|
Looks great. Merging. |
|
Cheers! |

Motivation
In issue #29, it was requested that support for running modules be added to
kernprofvia an-mflag. While it was noted that explicit decoration with theLINE_PROFILE=1environment-variable switch can mitigate having to callkernprofoff the command line, this does not cover the use-case of auto-profiling module execution, like how the existing invocationkernprof -l -p... script.pycovers script execution.Code changes
This PR adds the requested (but see Caveats) functionality by:
-m/EDIT 13 Apr: long-option form no longer supported boolean flag to--modulekernprof.py::main()Convenience: whenEDIT 12 Apr: this is no longer the case, users should supply-mis passed without specifying-p/--prof-mod, it defaults to-p {<the_specified_module>}-p<module>explicitlykernprof -mupdated to behave more likepython -m;kernprof -mnow:--kernprof.py::find_module_script(), which leveragesline_profiler/autoprofile/util_static.py::modname_to_modpath()to convert module/package dotted paths to their corresponding file paths; packages are detected and referred to the appropriate__main__.pyline_profiler/autoprofile/run_module.py::AstTreeModuleProfiler, which rewrites relative imports into absolute imports, so that the resultant ASTs can becompile()-ed and subsequentlyexec()-ed as-is.tests/test_autoprofile.py:: test_autoprofile_exec_{package,module}()for the new functionality (kernprof -l ... -m {<some_package_with_a_main_py>}andkernprof -l ... -m {<some_module>})tests/test_explicit_profile.py::test_explicit_profile_with_kernprof_m()for the corresponding cases without the-l/--line-by-lineflagkernprof.py::main()clean up after itself by restoringsys.pathandsys.argvafter its execution, as well as undoing theline_profiler.profile._kernprof_overwrite()with theLineProfilerinstance created insidemain()tests/test_kernprof.py::test_kernprof_m_parsing()to test the special-casing of the-mflag (which must follow all otherkernprofoptions and and be followed by a module name)Caveats
While this PR technically covers #29's use-case, in that
kernprof -l -m pytest {<tests>}does profile whatever is already decorated with@profilein the tests, we still aren't auto-profiling tests, since it is thepytestmodule that would have been auto-profiled. Actually implementing auto-profiling forpytesttests (whichpytest-line-profilerdoes not handle, owing to how auto-profiling did not exist when the package was last updated) may be nontrivial, due to howpytestalso does its own AST rewrites.