Skip to content

Commit f38809e

Browse files
authored
FIX: use import system to resolve file executed by kernprof -m (#389)
* Optionally use `importlib` in `kernprof.find_module_script()` * Added test * changelog * Test both explicit profiling and autoprofiling * Fixes - `kernprof.py::find_module_script()`: Added context manager around call to `importlib.util.find_spec()` to minimize side effects - `tests/test_kernprof.py::test_kernprof_sys_restoration()`: Updated test implementation to better protect `sys.modules` (because the test is run in-process) * Don't revert `sys.modules` in `find_module_script()`
1 parent 3e357a2 commit f38809e

File tree

3 files changed

+84
-11
lines changed

3 files changed

+84
-11
lines changed

CHANGELOG.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Changes
88
* FIX: mitigate speed regressions introduced in 5.0.0
99
* ENH: Added capability to combine profiling data both programmatically (``LineStats.__add__()``) and via the CLI (``python -m line_profiler``) (#380, originally proposed in #219)
1010
* FIX: search function in online documentation
11+
* FIX: Use import system to locate module file run by ``kernprof -m`` #389
1112

1213
5.0.0
1314
~~~~~

kernprof.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -307,12 +307,26 @@ def stop(self):
307307
self.is_running = False
308308

309309

310-
def find_module_script(module_name, *, exit_on_error=True):
310+
def find_module_script(module_name, *, static=True, exit_on_error=True):
311311
"""Find the path to the executable script for a module or package."""
312312
from line_profiler.autoprofile.util_static import modname_to_modpath
313+
from importlib.util import find_spec
314+
315+
def resolve_module_path(mod_name): # type: (str) -> str | None
316+
try:
317+
mod_spec = find_spec(mod_name)
318+
except ImportError:
319+
return None
320+
if not mod_spec:
321+
return None
322+
fname = mod_spec.origin # type: str | None
323+
if fname and os.path.exists(fname):
324+
return fname
325+
326+
get_module_path = modname_to_modpath if static else resolve_module_path
313327

314328
for suffix in '.__main__', '':
315-
fname = modname_to_modpath(module_name + suffix)
329+
fname = get_module_path(module_name + suffix)
316330
if fname:
317331
return fname
318332

@@ -1160,8 +1174,8 @@ def _pre_profile(options, module, exit_on_error):
11601174
builtins.__dict__['profile'] = prof
11611175

11621176
if module:
1163-
script_file = find_module_script(options.script,
1164-
exit_on_error=exit_on_error)
1177+
script_file = find_module_script(
1178+
options.script, static=options.static, exit_on_error=exit_on_error)
11651179
else:
11661180
script_file = find_script(options.script, exit_on_error=exit_on_error)
11671181
# Make sure the script's directory is on sys.path instead of

tests/test_kernprof.py

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,59 @@ def main():
125125
assert ('Function: main' in proc.stdout) == profiled_main
126126

127127

128+
@pytest.mark.parametrize('autoprof', [True, False])
129+
@pytest.mark.parametrize('static', [True, False])
130+
def test_kernprof_m_import_resolution(static, autoprof):
131+
"""
132+
Test that `kernprof -m` resolves the module using static and dynamic
133+
as is appropriate (toggled by the undocumented environment variable
134+
:env:`LINE_PROFILER_STATIC_ANALYSIS`; note that static analysis
135+
doesn't handle namespace modules while dynamic resolution does.
136+
"""
137+
code = ub.codeblock('''
138+
import enum
139+
import os
140+
import sys
141+
142+
143+
@profile
144+
def main():
145+
print('Hello world')
146+
147+
148+
if __name__ == '__main__':
149+
main()
150+
''')
151+
cmd = [sys.executable, '-m', 'kernprof', '-lv']
152+
if autoprof:
153+
# Remove the explicit decorator, and use the `--prof-mod` flag
154+
code = '\n'.join(line for line in code.splitlines()
155+
if '@profile' not in line)
156+
cmd += ['-p', 'my_namesapce_pkg.mysubmod']
157+
with tempfile.TemporaryDirectory() as tmpdir:
158+
temp_dpath = ub.Path(tmpdir)
159+
namespace_mod_path = temp_dpath / 'my_namesapce_pkg' / 'mysubmod.py'
160+
namespace_mod_path.parent.mkdir()
161+
namespace_mod_path.write_text(code)
162+
python_path = tmpdir
163+
if 'PYTHONPATH' in os.environ:
164+
python_path += ':' + os.environ['PYTHONPATH']
165+
env = {**os.environ,
166+
# Toggle use of static analysis
167+
'LINE_PROFILER_STATIC_ANALYSIS': str(bool(static)),
168+
# Add the tempdir to `sys.path`
169+
'PYTHONPATH': python_path}
170+
cmd += ['-m', 'my_namesapce_pkg.mysubmod']
171+
proc = ub.cmd(cmd, cwd=temp_dpath, verbose=2, env=env)
172+
if static:
173+
assert proc.returncode
174+
assert proc.stderr.startswith('Could not find module')
175+
else:
176+
proc.check_returncode()
177+
assert proc.stdout.startswith('Hello world\n')
178+
assert 'Function: main' in proc.stdout
179+
180+
128181
@pytest.mark.parametrize('error', [True, False])
129182
@pytest.mark.parametrize(
130183
'args',
@@ -169,13 +222,18 @@ def main():
169222
ctx = pytest.raises(BaseException)
170223
else:
171224
ctx = contextlib.nullcontext()
172-
old_main = sys.modules.get('__main__')
173-
with ctx:
174-
main(['-l', *shlex.split(args), '-m', 'mymod'])
175-
out, _ = capsys.readouterr()
176-
assert out.startswith('1')
177-
assert tmpdir not in sys.path
178-
assert sys.modules.get('__main__') is old_main
225+
old_modules = sys.modules.copy()
226+
try:
227+
old_main = sys.modules.get('__main__')
228+
with ctx:
229+
main(['-l', *shlex.split(args), '-m', 'mymod'])
230+
out, _ = capsys.readouterr()
231+
assert out.startswith('1')
232+
assert tmpdir not in sys.path
233+
assert sys.modules.get('__main__') is old_main
234+
finally:
235+
sys.modules.clear()
236+
sys.modules.update(old_modules)
179237

180238

181239
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)