Background
Ran into a bug when writing a new test for #431. So I was rewriting the test-module code passed to kernprof, and upon refactoring I'm now importing two targets instead of one from another module. Curiously, this caused profiling info to be lost for my target function – even when I'm not actively using multiprocessing , so my new code isn't to blame.
Example
Run the following Python code (click to expand; needs a POSIX-ish `diff` executable):
from __future__ import annotations
import ast
import subprocess
from pathlib import Path
from tempfile import TemporaryDirectory
from line_profiler.autoprofile.ast_tree_profiler import AstTreeProfiler
IMPORTS_ON_SAME_LINE = ('''
import foo, bar as baz
from foobar import spam, ham, eggs
def main() -> None:
pass
''').strip('\n')
IMPORTS_ON_SEPARATE_LINES = ('''
import foo
import bar as baz
from foobar import spam
from foobar import ham
from foobar import eggs
def main() -> None:
pass
''').strip('\n')
def main() -> None:
with TemporaryDirectory() as tmpdir_:
tmpdir = Path(tmpdir_)
fname_in = tmpdir / 'input.py'
fname_out = tmpdir / 'output.py'
for header, module_text in {
'imports on the same line': IMPORTS_ON_SAME_LINE,
'imports on separate lines': IMPORTS_ON_SEPARATE_LINES,
}.items():
print('====>>', header, '<<====', end='\n\n')
fname_in.write_text(module_text + '\n')
module_ast = AstTreeProfiler(
str(fname_in), ['foo', 'bar', 'foobar'], False
).profile()
fname_out.write_text(ast.unparse(module_ast) + '\n')
cmd = ['diff', '--unified', str(fname_in), str(fname_out)]
subprocess.run(cmd)
print('')
if __name__ == '__main__':
main()
Output (click to expand):
$ python ../433-demo.py
====>> imports on the same line <<====
--- /var/folders/5b/44kj9cdn7gngz2k1z1wxt0240000gp/T/tmpo20w9kuk/input.py 2026-06-29 19:19:14
+++ /var/folders/5b/44kj9cdn7gngz2k1z1wxt0240000gp/T/tmpo20w9kuk/output.py 2026-06-29 19:19:14
@@ -1,6 +1,7 @@
import foo, bar as baz
+profile.add_imported_function_or_module(baz)
from foobar import spam, ham, eggs
+profile.add_imported_function_or_module(eggs)
-
def main() -> None:
pass
====>> imports on separate lines <<====
--- /var/folders/5b/44kj9cdn7gngz2k1z1wxt0240000gp/T/tmpo20w9kuk/input.py 2026-06-29 19:19:14
+++ /var/folders/5b/44kj9cdn7gngz2k1z1wxt0240000gp/T/tmpo20w9kuk/output.py 2026-06-29 19:19:14
@@ -1,9 +1,13 @@
import foo
+profile.add_imported_function_or_module(foo)
import bar as baz
+profile.add_imported_function_or_module(baz)
from foobar import spam
+profile.add_imported_function_or_module(spam)
from foobar import ham
+profile.add_imported_function_or_module(ham)
from foobar import eggs
+profile.add_imported_function_or_module(eggs)
-
def main() -> None:
pass
Cause
line_profiler.autoprofile.profmod_extractor.ProfmodExtractor._find_modnames_in_tree_imports() Implicitly asserts that each Import[From] AST node in the tree only has one target, overwriting preceding ones with latter ones.
Possible solutions
This would have been otherwise trivial to fix, if not for how it cannot be done without changing the API because the "public" method ProfmodExtractor.run() also assumes the same (1 import target per statement), returning a dict[int, str] instead of e.g. a dict[int, list[str]].
A zanier fix without API changes could be to split multiple imports into their own Import[From] nodes inside ProfmodExtractor.run(), but one can argue that AST changes are the responsibility of AstTreeProfiler and AstProfileTransformer, and ProfmodExtractor should analyze the tree without modifying it.
Background
Ran into a bug when writing a new test for #431. So I was rewriting the test-module code passed to
kernprof, and upon refactoring I'm now importing two targets instead of one from another module. Curiously, this caused profiling info to be lost for my target function – even when I'm not actively usingmultiprocessing, so my new code isn't to blame.Example
Run the following Python code (click to expand; needs a POSIX-ish `diff` executable):
Output (click to expand):
$ python ../433-demo.py ====>> imports on the same line <<==== --- /var/folders/5b/44kj9cdn7gngz2k1z1wxt0240000gp/T/tmpo20w9kuk/input.py 2026-06-29 19:19:14 +++ /var/folders/5b/44kj9cdn7gngz2k1z1wxt0240000gp/T/tmpo20w9kuk/output.py 2026-06-29 19:19:14 @@ -1,6 +1,7 @@ import foo, bar as baz +profile.add_imported_function_or_module(baz) from foobar import spam, ham, eggs +profile.add_imported_function_or_module(eggs) - def main() -> None: pass ====>> imports on separate lines <<==== --- /var/folders/5b/44kj9cdn7gngz2k1z1wxt0240000gp/T/tmpo20w9kuk/input.py 2026-06-29 19:19:14 +++ /var/folders/5b/44kj9cdn7gngz2k1z1wxt0240000gp/T/tmpo20w9kuk/output.py 2026-06-29 19:19:14 @@ -1,9 +1,13 @@ import foo +profile.add_imported_function_or_module(foo) import bar as baz +profile.add_imported_function_or_module(baz) from foobar import spam +profile.add_imported_function_or_module(spam) from foobar import ham +profile.add_imported_function_or_module(ham) from foobar import eggs +profile.add_imported_function_or_module(eggs) - def main() -> None: passCause
line_profiler.autoprofile.profmod_extractor.ProfmodExtractor._find_modnames_in_tree_imports()Implicitly asserts that eachImport[From]AST node in the tree only has one target, overwriting preceding ones with latter ones.Possible solutions
This would have been otherwise trivial to fix, if not for how it cannot be done without changing the API because the "public" method
ProfmodExtractor.run()also assumes the same (1 import target per statement), returning adict[int, str]instead of e.g. adict[int, list[str]].A zanier fix without API changes could be to split multiple imports into their own
Import[From]nodes insideProfmodExtractor.run(), but one can argue that AST changes are the responsibility ofAstTreeProfilerandAstProfileTransformer, andProfmodExtractorshould analyze the tree without modifying it.