diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7d2f1809..2a824d14 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -12,6 +12,7 @@ Changes * FIX: Fixed namespace bug when running ``kernprof -m`` on certain modules (e.g. ``calendar`` on Python 3.12+). * FIX: Fixed ``@contextlib.contextmanager`` bug where the cleanup code (e.g. restoration of ``sys`` attributes) is not run if exceptions occurred inside the context * ENH: Added CLI arguments ``-c`` to ``kernprof`` for (auto-)profiling module/package/inline-script execution instead of that of script files; passing ``'-'`` as the script-file name now also reads from and profiles ``stdin`` +* ENH: Added capability to parse TOML config files for defaults for ``kernprof`` and ``python -m line_profiler`` CLI options, ``GlobalProfiler`` configurations, and profiler output (e.g. ``LineProfiler.print_stats()``) formatting #335 4.2.0 ~~~~~ diff --git a/MANIFEST.in b/MANIFEST.in index c9d9793e..93aba83b 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,4 @@ +graft line_profiler/rc include *.md include *.rst include *.py diff --git a/kernprof.py b/kernprof.py index 39bbfde5..d1860226 100755 --- a/kernprof.py +++ b/kernprof.py @@ -63,10 +63,20 @@ def main(): .. code:: - usage: kernprof [-h] [-V] [-l] [-b] [-o OUTFILE] [-s SETUP] [-v] [-r] [-u UNIT] [-z] [-i [OUTPUT_INTERVAL]] [-p PROF_MOD] [--prof-imports] - {path/to/script | -m path.to.module | -c "literal code"} ... - - Run and profile a python script. + usage: kernprof [-h] [-V] [--config CONFIG] [--no-config] + [--line-by-line [Y[es] | N[o] | T[rue] | F[alse] | on | off | 1 | 0]] + [--builtin [Y[es] | N[o] | T[rue] | F[alse] | on | off | 1 | 0]] + [-s SETUP] [-p PROF_MOD] + [--prof-imports [Y[es] | N[o] | T[rue] | F[alse] | on | off | 1 | 0]] + [-o OUTFILE] + [--view [Y[es] | N[o] | T[rue] | F[alse] | on | off | 1 | 0]] + [--rich [Y[es] | N[o] | T[rue] | F[alse] | on | off | 1 | 0]] + [-u UNIT] + [--skip-zero [Y[es] | N[o] | T[rue] | F[alse] | on | off | 1 | 0]] + [-i [OUTPUT_INTERVAL]] + {script | -m module} ... + + Run and profile a python script or module. positional arguments: {path/to/script | -m path.to.module | -c "literal code"} @@ -76,23 +86,59 @@ def main(): options: -h, --help show this help message and exit -V, --version show program's version number and exit - -l, --line-by-line Use the line-by-line profiler instead of cProfile. Implies --builtin. - -b, --builtin Put 'profile' in the builtins. Use 'profile.enable()'/'.disable()', '@profile' to decorate functions, or 'with profile:' to profile a section of code. + --config CONFIG Path to the TOML file, from the `tool.line_profiler.kernprof` + table of which to load defaults for the options. (Default: + 'pyproject.toml') + --no-config Disable the loading of configuration files other than the + default one + + profiling options: + --line-by-line [Y[es] | N[o] | T[rue] | F[alse] | on | off | 1 | 0] + Use the line-by-line profiler instead of cProfile. Implies + `--builtin`. (Default: False; short form: -l) + --builtin [Y[es] | N[o] | T[rue] | F[alse] | on | off | 1 | 0] + Put `profile` in the builtins. Use + `profile.enable()`/`.disable()` to toggle profiling, + `@profile` to decorate functions, or `with profile:` to + profile a section of code. (Default: False; short form: -b) + -s, --setup SETUP Path to the Python source file containing setup code to + execute before the code to profile. (Default: N/A) + -p, --prof-mod PROF_MOD + 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 the full script. Multiple copies + of this flag can be supplied and the list is extended (e.g. + `-p this.module,another.module -p some.func`). Only works with + line profiling (`-l`/`--line-by-line`). (Default: N/A) + --prof-imports [Y[es] | N[o] | T[rue] | F[alse] | on | off | 1 | 0] + If the script/module profiled is in `--prof-mod`, autoprofile + all its imports. Only works with line profiling (`-l`/`--line- + by-line`). (Default: False) + + output options: -o, --outfile OUTFILE - Save stats to (default: 'scriptname.lprof' with --line-by-line, 'scriptname.prof' without) - -s, --setup SETUP Code to execute before the code to profile - -v, --view View the results of the profile in addition to saving it - -r, --rich Use rich formatting if viewing output - -u, --unit UNIT Output unit (in seconds) in which the timing info is displayed (default: 1e-6) - -z, --skip-zero Hide functions which have not been called + Save stats to OUTFILE. (Default: + '.lprof' in line-profiling mode + (`-l`/`--line-by-line`); '.prof' + otherwise) + --view [Y[es] | N[o] | T[rue] | F[alse] | on | off | 1 | 0] + View the results of the profile in addition to saving it. + (Default: False; short form: -v) + --rich [Y[es] | N[o] | T[rue] | F[alse] | on | off | 1 | 0] + Use rich formatting if viewing output. (Default: False; short + form: -r) + -u, --unit UNIT Output unit (in seconds) in which the timing info is + displayed. (Default: 1e-06 s) + --skip-zero [Y[es] | N[o] | T[rue] | F[alse] | on | off | 1 | 0] + Hide functions which have not been called. (Default: False; + short form: -z) -i, --output-interval [OUTPUT_INTERVAL] - Enables outputting of cumulative profiling results to file every n seconds. Uses the threading module. Minimum value is 1 (second). Defaults to - disabled. - -p, --prof-mod PROF_MOD - 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 - the full script. Multiple copies of this flag can be supplied and the.list is extended. Only works with line_profiler -l, --line-by-line - --prof-imports If specified, modules specified to `--prof-mod` will also autoprofile modules that they import. Only works with line_profiler -l, --line-by-line -""" + Enables outputting of cumulative profiling results to OUTFILE + every OUTPUT_INTERVAL seconds. Uses the threading module. + Minimum value (and the value implied if the bare option is + given) is 1 s. (Default: 0 s (disabled)) +""" # noqa: E501 +import argparse import builtins import functools import os @@ -102,7 +148,6 @@ def main(): import concurrent.futures # NOQA import tempfile import time -from argparse import ArgumentError, ArgumentParser from runpy import run_module # NOTE: This version needs to be manually maintained in @@ -116,6 +161,11 @@ def main(): except ImportError: from profile import Profile # type: ignore[assignment,no-redef] +import line_profiler +from line_profiler.cli_utils import ( + add_argument, get_cli_config, + get_python_executable as _python_command, # Compatibility + positive_float, short_string_path) from line_profiler.profiler_mixin import ByCountProfilerMixin @@ -166,7 +216,7 @@ class RepeatedTimer: References: .. [SO474528] https://stackoverflow.com/questions/474528/execute-function-every-x-seconds/40965385#40965385 - """ + """ # noqa: E501 def __init__(self, interval, dump_func, outfile): self._timer = None self.interval = interval @@ -184,7 +234,8 @@ def _run(self): def start(self): if not self.is_running: self.next_call += self.interval - self._timer = threading.Timer(self.next_call - time.time(), self._run) + self._timer = threading.Timer(self.next_call - time.time(), + self._run) self._timer.start() self.is_running = True @@ -193,7 +244,7 @@ def stop(self): self.is_running = False -def find_module_script(module_name): +def find_module_script(module_name, *, exit_on_error=True): """Find the path to the executable script for a module or package.""" from line_profiler.autoprofile.util_static import modname_to_modpath @@ -202,11 +253,15 @@ def find_module_script(module_name): if fname: return fname - sys.stderr.write('Could not find module %s\n' % module_name) - raise SystemExit(1) + msg = f'Could not find module `{module_name}`' + if exit_on_error: + print(msg, file=sys.stderr) + raise SystemExit(1) + else: + raise ModuleNotFoundError(msg) -def find_script(script_name): +def find_script(script_name, *, exit_on_error=True): """ Find the script. If the input is not a file, then $PATH will be searched. @@ -221,19 +276,12 @@ def find_script(script_name): if os.path.isfile(fn): return fn - sys.stderr.write('Could not find script %s\n' % script_name) - raise SystemExit(1) - - -def _python_command(): - """ - Return a command that corresponds to :py:obj:`sys.executable`. - """ - import shutil - for abbr in 'python', 'python3': - if os.path.samefile(shutil.which(abbr), sys.executable): - return abbr - return sys.executable + msg = f'Could not find script {script_name!r}' + if exit_on_error: + print(msg, file=sys.stderr) + raise SystemExit(1) + else: + raise FileNotFoundError(msg) class _restore_list: @@ -341,19 +389,15 @@ def pre_parse_single_arg_directive(args, flag, sep='--'): @_restore_list(sys.argv) @_restore_list(sys.path) -def main(args=None): +def main(args=None, exit_on_error=True): """ Runs the command line interface """ - def positive_float(value): - val = float(value) - if val <= 0: - raise ArgumentError - return val - create_parser = functools.partial( - ArgumentParser, - description='Run and profile a python script.') + argparse.ArgumentParser, + description='Run and profile a python script or module.') + get_kernprof_config = functools.partial(get_cli_config, 'kernprof') + defaults, default_source = get_kernprof_config() if args is None: args = sys.argv[1:] @@ -387,57 +431,136 @@ def positive_float(value): help_parser = create_parser() parsers = [real_parser, help_parser] for parser in parsers: - parser.add_argument('-V', '--version', action='version', version=__version__) - parser.add_argument('-l', '--line-by-line', action='store_true', - help='Use the line-by-line profiler instead of cProfile. Implies --builtin.') - parser.add_argument('-b', '--builtin', action='store_true', - help="Put 'profile' in the builtins. Use 'profile.enable()'/'.disable()', " - "'@profile' to decorate functions, or 'with profile:' to profile a " - 'section of code.') - parser.add_argument('-o', '--outfile', - help="Save stats to (default: 'scriptname.lprof' with " - "--line-by-line, 'scriptname.prof' without)") - parser.add_argument('-s', '--setup', - help='Code to execute before the code to profile') - parser.add_argument('-v', '--view', action='store_true', - help='View the results of the profile in addition to saving it') - parser.add_argument('-r', '--rich', action='store_true', - help='Use rich formatting if viewing output') - parser.add_argument('-u', '--unit', default='1e-6', type=positive_float, - help='Output unit (in seconds) in which the timing info is ' - 'displayed (default: 1e-6)') - parser.add_argument('-z', '--skip-zero', action='store_true', - help="Hide functions which have not been called") - parser.add_argument('-i', '--output-interval', type=int, default=0, const=0, nargs='?', - help="Enables outputting of cumulative profiling results to file every n seconds. Uses the threading module. " - "Minimum value is 1 (second). Defaults to disabled.") - parser.add_argument('-p', '--prof-mod', action='append', type=str, - 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 the full script. " - "Multiple copies of this flag can be supplied and the.list is extended. " - "Only works with line_profiler -l, --line-by-line") - parser.add_argument('--prof-imports', action='store_true', - help="If specified, modules specified to `--prof-mod` will also autoprofile modules that they import. " - "Only works with line_profiler -l, --line-by-line") + add_argument(parser, '-V', '--version', + action='version', version=__version__) + add_argument(parser, '--config', + help='Path to the TOML file, from the ' + '`tool.line_profiler.kernprof` table of which to load ' + 'defaults for the options. ' + f'(Default: {short_string_path(default_source)!r})') + add_argument(parser, '--no-config', + action='store_const', dest='config', const=False, + help='Disable the loading of configuration files other ' + 'than the default one') + prof_opts = parser.add_argument_group('profiling options') + add_argument(prof_opts, '-l', '--line-by-line', action='store_true', + help='Use the line-by-line profiler instead of cProfile. ' + 'Implies `--builtin`. ' + f'(Default: {defaults["line_by_line"]})') + add_argument(prof_opts, '-b', '--builtin', action='store_true', + help="Put `profile` in the builtins. " + "Use `profile.enable()`/`.disable()` to " + "toggle profiling, " + "`@profile` to decorate functions, " + "or `with profile:` to profile a section of code. " + f"(Default: {defaults['builtin']})") + if defaults['setup']: + def_setupfile = repr(defaults['setup']) + else: + def_setupfile = 'N/A' + add_argument(prof_opts, '-s', '--setup', + help='Path to the Python source file containing setup ' + 'code to execute before the code to profile. ' + f'(Default: {def_setupfile})') + if defaults['prof_mod']: + def_prof_mod = repr(defaults['prof_mod']) + else: + def_prof_mod = 'N/A' + add_argument(prof_opts, '-p', '--prof-mod', action='append', + 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 the full script. " + "Multiple copies of this flag can be supplied and " + "the list is extended " + "(e.g. `-p this.module,another.module -p some.func`). " + "Only works with line profiling (`-l`/`--line-by-line`). " + f"(Default: {def_prof_mod})") + add_argument(prof_opts, '--prof-imports', action='store_true', + help="If the script/module profiled is in `--prof-mod`, " + "autoprofile all its imports. " + "Only works with line profiling (`-l`/`--line-by-line`). " + f"(Default: {defaults['prof_imports']})") + out_opts = parser.add_argument_group('output options') + if defaults['outfile']: + def_outfile = repr(defaults['outfile']) + else: + def_outfile = ( + "'.lprof' in line-profiling mode " + "(`-l`/`--line-by-line`); " + "'.prof' otherwise") + add_argument(out_opts, '-o', '--outfile', + help=f'Save stats to OUTFILE. (Default: {def_outfile})') + add_argument(out_opts, '-v', '--view', action='store_true', + help='View the results of the profile ' + 'in addition to saving it. ' + f'(Default: {defaults["view"]})') + add_argument(out_opts, '-r', '--rich', action='store_true', + help='Use rich formatting if viewing output. ' + f'(Default: {defaults["rich"]})') + add_argument(out_opts, '-u', '--unit', type=positive_float, + help='Output unit (in seconds) in which ' + 'the timing info is displayed. ' + f'(Default: {defaults["unit"]} s)') + add_argument(out_opts, '-z', '--skip-zero', action='store_true', + help="Hide functions which have not been called. " + f"(Default: {defaults['skip_zero']})") + if defaults['output_interval']: + def_out_int = f'{defaults["output_interval"]} s' + else: + def_out_int = '0 s (disabled)' + add_argument(out_opts, '-i', '--output-interval', + type=int, const=1, nargs='?', + help="Enables outputting of cumulative profiling results " + "to OUTFILE every OUTPUT_INTERVAL seconds. " + "Uses the threading module. " + "Minimum value (and the value implied if the bare option " + f"is given) is 1 s. (Default: {def_out_int})") if parser is help_parser or module is literal_code is None: - parser.add_argument('script', - metavar='{path/to/script' - ' | -m path.to.module | -c "literal code"}', - help='The python script file, module, or ' - 'literal code to run') - parser.add_argument('args', nargs='...', help='Optional script arguments') + add_argument(parser, 'script', + metavar='{path/to/script' + ' | -m path.to.module | -c "literal code"}', + help='The python script file, module, or ' + 'literal code to run') + add_argument(parser, 'args', + nargs='...', help='Optional script arguments') # Hand off to the dummy parser if necessary to generate the help # text - options = real_parser.parse_args(args) + try: + options = real_parser.parse_args(args) + except SystemExit as e: + # If `exit_on_error` is true, let `SystemExit` bubble up and + # kill the interpretor; + # else, catch and handle it more gracefully + # (Note: can't use `ArgumentParser(exit_on_error=False)` in + # Python 3.8) + if exit_on_error: + raise + elif e.code: + raise RuntimeError from None + else: + return if help_parser and getattr(options, 'help', False): help_parser.print_help() - exit() + if exit_on_error: + raise SystemExit(0) + else: + return + + # Parse the provided config file (if any), and resolve the values + # of the un-specified options try: del options.help except AttributeError: pass + if options.config: + defaults, options.config = get_kernprof_config(options.config) + for key, default in defaults.items(): + if getattr(options, key, None) is None: + setattr(options, key, default) + # Add in the pre-partitioned arguments cut off by `-m ` or # `-c