From 8c7624c3b82225e5642f4507b20aabfd51af019d Mon Sep 17 00:00:00 2001 From: Joseph Martinot-Lagarde Date: Thu, 17 Mar 2022 16:34:40 +0100 Subject: [PATCH] Make IPython optional (#125) * IPython is now optional * Manage imports in __init__.py * Forgot local import * Use load_ipython_extension from line_profiler to get a useful message * Typo * Rework IPython plugin layout * Update CHANGELOG * Load IPython only when needed * Add basic ipython test * Fix deprecation warning * Check output Co-authored-by: Jon Crall --- CHANGELOG.rst | 1 + README.rst | 4 + line_profiler/__init__.py | 5 +- line_profiler/ipython_extension.py | 143 +++++++++++++++++++++++++++ line_profiler/line_profiler.py | 151 ++--------------------------- requirements.txt | 1 + requirements/ipython.txt | 2 + requirements/runtime.txt | 2 - requirements/tests.txt | 2 + setup.py | 1 + tests/test_ipython.py | 21 ++++ 11 files changed, 187 insertions(+), 146 deletions(-) create mode 100644 line_profiler/ipython_extension.py create mode 100644 requirements/ipython.txt create mode 100644 tests/test_ipython.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index cb8073ac..0c6cd4a0 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,7 @@ Changes ~~~~~ * FIX: #109 kernprof fails to write to stdout if stdout was replaced * FIX: Fixes max of an empty sequence error #118 +* Make IPython optional * FIX: #100 Exception raise ZeroDivisionError 3.4.0 diff --git a/README.rst b/README.rst index abd7e7c0..62d39daa 100644 --- a/README.rst +++ b/README.rst @@ -33,6 +33,10 @@ Releases of `line_profiler` can be installed using pip:: $ pip install line_profiler +Installation while ensuring a compatible IPython version can also be installed using pip:: + + $ pip install line_profiler[ipython] + Source releases and any binaries can be downloaded from the PyPI link. http://pypi.python.org/pypi/line_profiler diff --git a/line_profiler/__init__.py b/line_profiler/__init__.py index 2136be93..93d77008 100644 --- a/line_profiler/__init__.py +++ b/line_profiler/__init__.py @@ -3,6 +3,7 @@ """ __submodules__ = [ 'line_profiler', + 'ipython_extension', ] __autogen__ = """ @@ -13,10 +14,10 @@ from .line_profiler import __version__ -from .line_profiler import (LineProfiler, LineProfilerMagics, +from .line_profiler import (LineProfiler, load_ipython_extension, load_stats, main, show_func, show_text,) -__all__ = ['LineProfiler', 'LineProfilerMagics', 'line_profiler', +__all__ = ['LineProfiler', 'line_profiler', 'load_ipython_extension', 'load_stats', 'main', 'show_func', 'show_text', '__version__'] diff --git a/line_profiler/ipython_extension.py b/line_profiler/ipython_extension.py new file mode 100644 index 00000000..87c662ac --- /dev/null +++ b/line_profiler/ipython_extension.py @@ -0,0 +1,143 @@ +from io import StringIO + +from IPython.core.magic import Magics, magics_class, line_magic +from IPython.core.page import page +from IPython.utils.ipstruct import Struct +from IPython.core.error import UsageError + +from .line_profiler import LineProfiler + + +@magics_class +class LineProfilerMagics(Magics): + @line_magic + def lprun(self, parameter_s=""): + """ Execute a statement under the line-by-line profiler from the + line_profiler module. + + Usage: + %lprun -f func1 -f func2 + + The given statement (which doesn't require quote marks) is run via the + LineProfiler. Profiling is enabled for the functions specified by the -f + options. The statistics will be shown side-by-side with the code through the + pager once the statement has completed. + + Options: + + -f : LineProfiler only profiles functions and methods it is told + to profile. This option tells the profiler about these functions. Multiple + -f options may be used. The argument may be any expression that gives + a Python function or method object. However, one must be careful to avoid + spaces that may confuse the option parser. + + -m : Get all the functions/methods in a module + + One or more -f or -m options are required to get any useful results. + + -D : dump the raw statistics out to a pickle file on disk. The + usual extension for this is ".lprof". These statistics may be viewed later + by running line_profiler.py as a script. + + -T : dump the text-formatted statistics with the code side-by-side + out to a text file. + + -r: return the LineProfiler object after it has completed profiling. + + -s: strip out all entries from the print-out that have zeros. + + -u: specify time unit for the print-out in seconds. + """ + + # Escape quote markers. + opts_def = Struct(D=[""], T=[""], f=[], m=[], u=None) + parameter_s = parameter_s.replace('"', r"\"").replace("'", r"\'") + opts, arg_str = self.parse_options(parameter_s, "rsf:m:D:T:u:", list_all=True) + opts.merge(opts_def) + + global_ns = self.shell.user_global_ns + local_ns = self.shell.user_ns + + # Get the requested functions. + funcs = [] + for name in opts.f: + try: + funcs.append(eval(name, global_ns, local_ns)) + except Exception as e: + raise UsageError( + f"Could not find module {name}.\n{e.__class__.__name__}: {e}" + ) + + profile = LineProfiler(*funcs) + + # Get the modules, too + for modname in opts.m: + try: + mod = __import__(modname, fromlist=[""]) + profile.add_module(mod) + except Exception as e: + raise UsageError( + f"Could not find module {modname}.\n{e.__class__.__name__}: {e}" + ) + + if opts.u is not None: + try: + output_unit = float(opts.u[0]) + except Exception: + raise TypeError("Timer unit setting must be a float.") + else: + output_unit = None + + # Add the profiler to the builtins for @profile. + import builtins + + if "profile" in builtins.__dict__: + had_profile = True + old_profile = builtins.__dict__["profile"] + else: + had_profile = False + old_profile = None + builtins.__dict__["profile"] = profile + + try: + try: + profile.runctx(arg_str, global_ns, local_ns) + message = "" + except SystemExit: + message = """*** SystemExit exception caught in code being profiled.""" + except KeyboardInterrupt: + message = ( + "*** KeyboardInterrupt exception caught in code being " "profiled." + ) + finally: + if had_profile: + builtins.__dict__["profile"] = old_profile + + # Trap text output. + stdout_trap = StringIO() + profile.print_stats( + stdout_trap, output_unit=output_unit, stripzeros="s" in opts + ) + output = stdout_trap.getvalue() + output = output.rstrip() + + page(output) + print(message, end="") + + dump_file = opts.D[0] + if dump_file: + profile.dump_stats(dump_file) + print(f"\n*** Profile stats pickled to file {dump_file!r}. {message}") + + text_file = opts.T[0] + if text_file: + pfile = open(text_file, "w") + pfile.write(output) + pfile.close() + print(f"\n*** Profile printout saved to text file {text_file!r}. {message}") + + return_value = None + if "r" in opts: + return_value = profile + + return return_value diff --git a/line_profiler/line_profiler.py b/line_profiler/line_profiler.py index c8e2cb84..7d9312a9 100755 --- a/line_profiler/line_profiler.py +++ b/line_profiler/line_profiler.py @@ -6,14 +6,8 @@ import tempfile import os import sys -from io import StringIO from argparse import ArgumentError, ArgumentParser -from IPython.core.magic import (Magics, magics_class, line_magic) -from IPython.core.page import page -from IPython.utils.ipstruct import Struct -from IPython.core.error import UsageError - try: from ._line_profiler import LineProfiler as CLineProfiler except ImportError as ex: @@ -25,6 +19,13 @@ __version__ = '3.5.0' +def load_ipython_extension(ip): + """ API for IPython to recognize this module as an IPython extension. + """ + from .ipython_extension import LineProfilerMagics + ip.register_magics(LineProfilerMagics) + + def is_coroutine(f): return False @@ -155,6 +156,8 @@ def add_module(self, mod): return nfuncsadded +# This could be in the ipython_extension submodule, +# but it doesn't depend on the IPython module so it's easier to just let it stay here. def is_ipython_kernel_cell(filename): """ Return True if a filename corresponds to a Jupyter Notebook cell """ @@ -248,142 +251,6 @@ def show_text(stats, unit, output_unit=None, stream=None, stripzeros=False): output_unit=output_unit, stream=stream, stripzeros=stripzeros) - -@magics_class -class LineProfilerMagics(Magics): - - @line_magic - def lprun(self, parameter_s=''): - """ Execute a statement under the line-by-line profiler from the - line_profiler module. - - Usage: - %lprun -f func1 -f func2 - - The given statement (which doesn't require quote marks) is run via the - LineProfiler. Profiling is enabled for the functions specified by the -f - options. The statistics will be shown side-by-side with the code through the - pager once the statement has completed. - - Options: - - -f : LineProfiler only profiles functions and methods it is told - to profile. This option tells the profiler about these functions. Multiple - -f options may be used. The argument may be any expression that gives - a Python function or method object. However, one must be careful to avoid - spaces that may confuse the option parser. - - -m : Get all the functions/methods in a module - - One or more -f or -m options are required to get any useful results. - - -D : dump the raw statistics out to a pickle file on disk. The - usual extension for this is ".lprof". These statistics may be viewed later - by running line_profiler.py as a script. - - -T : dump the text-formatted statistics with the code side-by-side - out to a text file. - - -r: return the LineProfiler object after it has completed profiling. - - -s: strip out all entries from the print-out that have zeros. - - -u: specify time unit for the print-out in seconds. - """ - - # Escape quote markers. - opts_def = Struct(D=[''], T=[''], f=[], m=[], u=None) - parameter_s = parameter_s.replace('"', r'\"').replace("'", r"\'") - opts, arg_str = self.parse_options(parameter_s, 'rsf:m:D:T:u:', list_all=True) - opts.merge(opts_def) - - global_ns = self.shell.user_global_ns - local_ns = self.shell.user_ns - - # Get the requested functions. - funcs = [] - for name in opts.f: - try: - funcs.append(eval(name, global_ns, local_ns)) - except Exception as e: - raise UsageError(f'Could not find module {name}.\n{e.__class__.__name__}: {e}') - - profile = LineProfiler(*funcs) - - # Get the modules, too - for modname in opts.m: - try: - mod = __import__(modname, fromlist=['']) - profile.add_module(mod) - except Exception as e: - raise UsageError(f'Could not find module {modname}.\n{e.__class__.__name__}: {e}') - - if opts.u is not None: - try: - output_unit = float(opts.u[0]) - except Exception: - raise TypeError('Timer unit setting must be a float.') - else: - output_unit = None - - # Add the profiler to the builtins for @profile. - import builtins - - if 'profile' in builtins.__dict__: - had_profile = True - old_profile = builtins.__dict__['profile'] - else: - had_profile = False - old_profile = None - builtins.__dict__['profile'] = profile - - try: - try: - profile.runctx(arg_str, global_ns, local_ns) - message = '' - except SystemExit: - message = """*** SystemExit exception caught in code being profiled.""" - except KeyboardInterrupt: - message = ('*** KeyboardInterrupt exception caught in code being ' - 'profiled.') - finally: - if had_profile: - builtins.__dict__['profile'] = old_profile - - # Trap text output. - stdout_trap = StringIO() - profile.print_stats(stdout_trap, output_unit=output_unit, stripzeros='s' in opts) - output = stdout_trap.getvalue() - output = output.rstrip() - - page(output) - print(message, end='') - - dump_file = opts.D[0] - if dump_file: - profile.dump_stats(dump_file) - print(f'\n*** Profile stats pickled to file {dump_file!r}. {message}') - - text_file = opts.T[0] - if text_file: - pfile = open(text_file, 'w') - pfile.write(output) - pfile.close() - print(f'\n*** Profile printout saved to text file {text_file!r}. {message}') - - return_value = None - if 'r' in opts: - return_value = profile - - return return_value - - -def load_ipython_extension(ip): - """ API for IPython to recognize this module as an IPython extension. - """ - ip.register_magics(LineProfilerMagics) - - def load_stats(filename): """ Utility function to load a pickled LineStats object from a given filename. diff --git a/requirements.txt b/requirements.txt index 17fa364f..db2de0ea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ -r requirements/runtime.txt +-r requirements/ipython.txt -r requirements/build.txt -r requirements/tests.txt diff --git a/requirements/ipython.txt b/requirements/ipython.txt new file mode 100644 index 00000000..bec65883 --- /dev/null +++ b/requirements/ipython.txt @@ -0,0 +1,2 @@ +IPython >=0.13 ; python_version >= '3.7' +IPython >=0.13, <7.17.0 ; python_version <= '3.6' diff --git a/requirements/runtime.txt b/requirements/runtime.txt index bec65883..e69de29b 100644 --- a/requirements/runtime.txt +++ b/requirements/runtime.txt @@ -1,2 +0,0 @@ -IPython >=0.13 ; python_version >= '3.7' -IPython >=0.13, <7.17.0 ; python_version <= '3.6' diff --git a/requirements/tests.txt b/requirements/tests.txt index f5cec548..b960c337 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -2,3 +2,5 @@ pytest >= 4.6.11 pytest-cov >= 2.10.1 coverage[toml] >= 5.3 ubelt >= 1.0.1 +IPython >=0.13 ; python_version >= '3.7' +IPython >=0.13, <7.17.0 ; python_version <= '3.6' diff --git a/setup.py b/setup.py index 10300857..dae26cd5 100755 --- a/setup.py +++ b/setup.py @@ -259,6 +259,7 @@ def native_mb_python_tag(plat_impl=None, version_info=None): install_requires=parse_requirements('requirements/runtime.txt'), extras_require={ 'all': parse_requirements('requirements.txt'), + 'ipython': parse_requirements('requirements/ipython.txt'), 'tests': parse_requirements('requirements/tests.txt'), 'build': parse_requirements('requirements/build.txt'), }, diff --git a/tests/test_ipython.py b/tests/test_ipython.py new file mode 100644 index 00000000..76eb0f83 --- /dev/null +++ b/tests/test_ipython.py @@ -0,0 +1,21 @@ +import unittest +import io + +from IPython.testing.globalipapp import get_ipython + +class TestIPython(unittest.TestCase): + def test_init(self): + ip = get_ipython() + ip.run_line_magic('load_ext', 'line_profiler') + ip.run_cell(raw_cell='def func():\n return 2**20') + lprof = ip.run_line_magic('lprun', '-r -f func func()') + + timings = lprof.get_stats().timings + self.assertEqual(len(timings), 1) # 1 function + + func_data, lines_data = next(iter(timings.items())) + self.assertEqual(func_data[1], 1) # lineno of the function + self.assertEqual(func_data[2], "func") # function name + self.assertEqual(len(lines_data), 1) # 1 line of code + self.assertEqual(lines_data[0][0], 2) # lineno + self.assertEqual(lines_data[0][1], 1) # hits