Skip to content

Conversation

@TTsangSC
Copy link
Collaborator

@TTsangSC TTsangSC commented Apr 20, 2025

Synopsis

This PR adds the capability to configure kernprof and line_profiler via TOML files like pyproject.toml.

Namespaces

  • [tool.line_profiler.kernprof]:
    Defaults for command-line options of kernprof
  • [tool.line_profiler.cli]:
    Defaults for command-line options of python -m line_profiler
  • [tool.line_profiler.setup]:
    Defaults for the line_profiler.profile.setup_config table
  • [tool.line_profiler.write]:
    Defaults for the line_profiler.profile.write_config table, except for the output_prefix key, which maps instead to line_profiler.profile.output_prefix
  • [tool.line_profiler.show]:
    Defaults for the line_profiler.profile.show_config table, except for the subtable [tool.line_profiler.show.column_widths] (see below)
  • [tool.line_profiler.show.column_widths]:
    Default column widths for line_profiler.LineProfiler.print_stats(), line_profiler.line_profiler.show_text(), etc.

Specification and discovery

The TOML config file in question can be specified by

  • (Updated 18 May) Passing the new --config flag (or -c for python -m line_profiler) to the kernprof and python -m line_profiler CLI apps;
  • Passing the new config argument to line_profiler.explicit_profiler.GlobalProfiler.__init__(), line_profiler.line_profiler.LineProfiler.print_stats(), and line_profiler.line_profiler.show_text() and .show_func(); or
  • Setting the LINE_PROFILER_RC environment variable.

Failing that, the config file is looked up on the file system starting from the current directory. The filenames pyproject.toml and line_profiler.toml (the latter taking priority) are checked, and if any of them is valid TOML it is chosen to load configs from. Otherwise, we check in the parent directory, and so on, until we reach the file-system/drive root.

The looked up file (if any) is then merged with the default configs (line_profiler/rc/line_profiler.toml) to form the final configuration table, which is guaranteed to contain the same subtables and keys as the default one.

Motivation

In #323's discussion (comments 1, 2, 3), it was suggested that TOML config files can be used to:

  • Avoid common repetition patterns like kernprof -l -p <script> <script>, and
  • Make hard-coded tables like line_profiler.explicit_profiler.GlobalProfiler.show_config configurable.

It was also suggested that all config options used by this package be placed under the tool.line_profiler namespace.

Code changes

(Click to expand)
  • line_profiler/rc:
    New subpackage for containing config files (currently only line_profiler.toml)
  • line_profiler/cli_tools.py:
    New module for common functions shared between kernprof and python -m line_profiler
  • line_profiler/toml_config.py:
    (Updated 21 Jul) New module for finding and loading configs from TOML files
    • ConfigSource.from_config(): load the configs from an explicitly-supplied TOML file, the file discovered from the environment, or the default file
    • ConfigSource.from_default(): load the configs from the default TOML file (line_profiler/line_profiler.toml)
  • kernprof.py, line_profiler/line_profiler.py:
    • Moved common utilities used by the CLI apps out to line_profiler.cli_tools
    • (Updated 18 May) Added new option --config (and -c only for line_profiler/line_profiler.py since kernprof -c is taken (ENH: auto-profile stdin or literal snippets #338)) for specifying a TOML config file
    • Added new option --no-config for disabling loading of TOML configs (that isn't the default file)
    • For each boolean option --some-flag (action='store_true' or 'store_false'):
      • The default value is None and the action replaced with 'store_const' or 'store', so that:
        • Boolean options can default to true (based on the configs), and
        • We can distinguish between when the option is explicitly set to false and when it is not supplied
      • The long and short flags are shunted into separate actions, so that:
        • Short-flag actions remain simple 'store_const's, which allow them to be concatenated
        • Long-flag actions can optionally take a single argument, which is parsed into a boolean (allowing invocations like --some-flag=false --other-flag, which resolves into Namespace(some_flag=False, other_flag=True))
      • A complementary --no-some-flag is added
    • Added the loaded-from-config defaults to the option help texts
    • Updated the --help output
    • Most options except special ones (-V/--version; -c/--config/--no-config; -m) now have corresponding TOML entries (in [tool.line_profiler.kernprof] and [tool.line_profiler.cli]) and load their defaults therefrom
  • kernprof.py:
    • Updated __doc__
    • Grouped CLI options with argument groups
    • Revised the help text of --prof-imports to better reflect its function
    • (Updated 7 Jul) -p/--prof-mod now permits receiving an empty string, which invalidates the previously accumulated profiling targets (e.g. via earlier -p flags or the config file)
    • (Updated 7 Jul) Superseded the previous boolean option --no-preimports (introduced in ENH: more intuitive profiling-target selection #337) with --preimports; the previous form still works thanks to the aforementioned formulation for boolean options
    • Updated the const (value stored when the flag is passed without an argument) of the -i/--output-interval option, since the old value (0) is un-intuitively equivalent to disabling it
    • Removed redundant instantiation of RepeatedTimer
    • Wrapped some long lines
    • (Updated 7 Jul) Added logging for the loaded config file
    • (Updated 7 Jul) Now abbreviating filenames in log messages with ~.cli_utils.short_string_path()
    • (Updated 21 Jul) Added option --summarize
  • line_profiler/line_profiler.py:
    • Added the argument config to LineProfiler.print_stats(), show_func(), and show_text() so that the output column widths can be customized
      • The minimal widths are pinned to the default values, so that the output columns never become too narrow
    • Replaced hard-coded table of column widths in show_func() with the config table [tool.line_profiler.show.column_widths]
  • line_profiler/explicit_profiler.py:
    • Added the argument config to GlobalProfiler.__init__() so that configs can be explicitly specified
    • Replaced hard-coded values for GlobalProfiler.setup_config, .write_config, .show_config, and .output_prefix with the config tables [tool.line_profiler.setup], [.write], and [.show]

Packaging changes

(Click to expand)
  • line_profiler/rc/line_profiler.toml:
    New global-default config file
  • requirements/runtime.txt:
    Updated so that tomli is installed for Python versions (< 3.11) without tomllib in the stdlib
  • setup.py, MANIFEST.in:
    Updated so that line_profiler/rc/line_proiler.toml is included in both wheels and source distributions

Documentation changes

(Click to expand)

(Added 21 Jul)

  • docs/source/manual/examples/example_toml_config.rst:
    (Contributed by @Erotemic) Tutorial for using the config system
  • docs/source/auto/line_profiler.cli_utils.rst, /line_profiler.toml_config.rst:
    Doc pages for the new modules

Test suite changes

(Click to expand)
  • tests/test_toml_config.py:
    New tests for line_profiler/toml_config.py
    • test_environment_isolation():
      Test that tests in this test module are reasonably isolated from its environment (env variables and file system)
    • test_default_config_deep_copy():
      Test that each call to line_profiler.toml_config.get_default_config() returns a fresh and separate set of configs
    • test_table_normalization():
      Test that config files with missing and/or extra keys are normalized into the same layout as the default file
    • test_malformed_table():
      Test that malformed config files (e.g. one which sets [tool.line_profiler.show.column_widths] to an array, instead of the expected table result in a ValueError
    • test_config_lookup_hierarchy():
      Test that the choice of config file follows the resolution scheme outlined in Specification and discovery
  • tests/test_explicit_profile.py:
    • Refactored existing tests to use tempfile.TemporaryDirectory to ensure the removal of the temporary directories
    • test_explicit_profiler_with_customized_config():
      New test for the configuration of line_profiler.explicit_profiler.GlobalProfiler (i.e. @line_profiler.profile) via TOML config files
  • tests/test_autoprofile.py:
    • Refactored existing tests to use tempfile.TemporaryDirectory to ensure the removal of the temporary directories
    • test_autoprofile_with_customized_config():
      New test for the configuration of kernprof, python -m line_profiler, and line_profiler.line_profiler.show_text() via TOML config files
    • test_autoprofile_with_no_config():
      New test for disabling user-config loading in kernprof and python -m line_profiler via the --no-config flag
  • tests/test_cli.py:
    • test_boolean_argument_help_text():
      New test for the augmented help texts generated by line_profiler.cli_utils.add_argument() for boolean options
    • test_boolean_argument_parsing():
      New test for the parsing of boolean options generated by line_profiler.cli_utils.add_argument()

Caveats

This PR further couples kernprof to line_profiler. But it's probably fine, given that:

  • The ship has sailed as of FIX: kernprof erroring out in non--l, non--b mode in Python 3.12+ #326, which made kernprof.contextualProfiler directly dependent on line_profiler.profiler_mixin.ByCountProfilerMixin.
  • The rationale for isolating the parts where kernprof is dependent on line_profiler and making the former (somewhat) usable without the latter was to avoid necessitating the configuration of C(-ython) compilation. But since the whole package can now just be pip install-ed with no additional configuration, it's a mostly solved problem.

Acknowledgement

The idea for the PR originated with @Erotemic, who also wrote TTsangSC#4, adding documentation for the new feature.

@codecov
Copy link

codecov bot commented Apr 20, 2025

Codecov Report

Attention: Patch coverage is 92.33716% with 20 lines in your changes missing coverage. Please review.

Project coverage is 88.18%. Comparing base (60e928f) to head (a7032a5).
Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
line_profiler/cli_utils.py 88.88% 8 Missing and 2 partials ⚠️
line_profiler/toml_config.py 93.44% 4 Missing and 4 partials ⚠️
line_profiler/explicit_profiler.py 95.00% 0 Missing and 1 partial ⚠️
line_profiler/line_profiler.py 96.55% 0 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #335      +/-   ##
==========================================
+ Coverage   87.56%   88.18%   +0.61%     
==========================================
  Files          18       20       +2     
  Lines        1641     1862     +221     
  Branches      348      401      +53     
==========================================
+ Hits         1437     1642     +205     
- Misses        149      160      +11     
- Partials       55       60       +5     
Files with missing lines Coverage Δ
line_profiler/explicit_profiler.py 97.40% <95.00%> (+3.50%) ⬆️
line_profiler/line_profiler.py 99.36% <96.55%> (-0.31%) ⬇️
line_profiler/toml_config.py 93.44% <93.44%> (ø)
line_profiler/cli_utils.py 88.88% <88.88%> (ø)

Continue to review full report in Codecov by Sentry.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 9e09196...a7032a5. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@Erotemic
Copy link
Member

Erotemic commented May 3, 2025

Getting a start on this review. There are a lot of changes, so I'm going to need to do a few passes over it before we merge, but at a high level it looks very good.

In my first pass, I see a few things:

  • I think we should use line_profiler.toml instead of line_profiler_rc.toml. It seems like it is more standard to just use .toml (e.g. cibuildwheel, black).

  • Should tomli be an optional requirement in Python <3.11? I'm on the fence about this, on one hand it's such a small package, on the other, I do like keeping all dependencies optional for packages like this.

  • Can we defer the call to get_default_config() in the top-level? I prefer to avoid executing any code at module import time.

  • For the --config option, we should add the ability to set it to None or null to indicate that all external configs should be ignored.

  • For resource files I like having a .rc submodule to store them and logic to access them. So I think we should make a line_profiler.rc submodule here. Here is an example of a place where I've done this before.

  • FYI: we can keep the changes to the doctests where >>> are replaced with ... , but a main motivation of xdoctest is to allow the more flexible syntax.

I'm also curious, what was the LLM usage on this PR like? I'm wondering if the minor changes came from a LLM editor, or if you were explicitly making the style consistent.

@TTsangSC
Copy link
Collaborator Author

TTsangSC commented May 3, 2025

line_profiler.toml

having a .rc submodule to store them

Good point, will do. So basically instead of line_profiler/line_profiler_rc.toml we do line_profiler/rc/line_profiler.toml?

tomli

Yeah this could use some deliberation... on the one hand, it's very convenient to just have to specify all the config in a central TOML file (including our defaults) and read from it, but on the other it does introduce a dependency. Maybe just for the sake of keeping the requirement list short, it is worth it to have a bit of duplication and put the default configs also in the Python code... it's a tradeoff that we have to decide on.

(One concern though is that that may cause the behaviors between platforms to go out of sync, if a future contributor (could easily be us in a month) only updated the code-side configs and forgot to do so to the rc-side configs. But since our dev machines all have newer Python versions I guess we can put a dev/ script for syncing the two, or maybe include a test to automatically check for equivalence.)

get_default_config()

I guess you mean when we call that to get the line_profiler/line_profiler.py::minimum_column_widths? I see what you mean, guess that it can be isolated into a @functools.lru_cache-ed function.

indicate that all external configs should be ignored

That's a brilliant idea! Yep, we're currently missing the option to ignore config files, and that'd be helpful.

xdoctest

making the style consistent

I see your point. In hindsight I was perhaps overzealous in making those changes... the intent was to make them backwards compatible with vanilla doctest, but then again the examples probably wouldn't have been fully backwards compatible anyway, given the differences in how xdoctest and doctest function (e.g. the parsing and verification of expected outputs). Sorry if that made for unduly big diffs. (Same for the reflowing of lines, I just find long lines a bit hard to work with...)

LLM

Never really used one – I'm staunchly a vim + terminal guy, and perhaps too much so for my own good. But my colleagues seem to be having a blast using Copilot as a productivity tool, and maybe it's worth looking into someday...

@Erotemic
Copy link
Member

Erotemic commented May 3, 2025

Yes: line_profiler/rc/line_profiler.toml specifies the default toml config. The user can choose to overwrite / merge it with their local config based on the priority rules you outlined for line_profiler.toml and pyproject.toml.

On tomli: I'm fine with keeping it as is. It's so small and standard, and modern Python versions don't need it.

On @functools.lru_cache-ed function: That works for me, and is how I've handled it in the past. I try to make imports happen as soon as possible. Nothing is worse than a bottleneck in response time when you startup python (e.g. torch, pytorch-lightning, etc...).

The option to ignore configs is critical for testing and debugging.

xdoctest: it's fine to leave it. The more compatible the better I suppose. All doctest code should be forward compatible with xdoctest. Using xdoctest as a runner isn't supposed to be "better", it just makes it faster for me to write - although I suppose it would be possible to write a "normalizer" in xdoctest so it can factor itself out if needed.

LLM: I'm gvim+terminal as well (gvim users exist), but I have been using the free LLMs like DeepSeek, ChatGPT, and a few ones I can run locally. They save a ton of time. It would be interesting to see if you gave it a default config and asked it to generate documentation (maybe provide a diff of the other code for context, so it can figure out what they do), and see how that compares to the docs you wrote.

First thing I did for this is throw the diff at an LLM and ask it for places to look into first. It was also used for finding things (like checking if you did implement the null config case, or if it just wasn't documented). All my usage is copy+paste, but I've also been considering a switch to vscode to get copilot. If you have a GPU, open-webui in a docker container is a nice way to use them. I think nvim might have good integrations, but vim/gvim do seem to be lacking.

Copy link
Member

@Erotemic Erotemic left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partial review while I had a spare cycle.

kernprof.py Outdated
usage: kernprof [-h] [-V] [-c CONFIG] [-l] [-b] [-s SETUP] [-p PROF_MOD] [--prof-imports] [-o OUTFILE] [-v] [-r] [-u UNIT] [-z] [-i [OUTPUT_INTERVAL]] {script | -m module} ...
Run and profile a python script.
Run and profile a python script or module.Boolean options can be negated by passing the corresponding flag (e.g. `--no-view` for `--view`).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Run and profile a python script or module.Boolean options can be negated by passing the corresponding flag (e.g. `--no-view` for `--view`).
Run and profile a python script or module.

The extra docs "Boolean options can be negated by passing the corresponding flag (e.g. --no-view for --view" should probably be less prominent in some notes section if we want it at all.

What do you think about instead of using the --flag --no-flag convention, using --flag always results in setting the option to True, but if you want to explicitly set it you use a key/value pair --flag=True or --flag=False.

Another one of my design soapboxes is that you shouldn't have to delete a flag to disable it, you should always be able to set state via key/value pairs on the command line. However, I didn't add that feature here as my primary goal with this package is maintenance and facilitating development from other devs like yourself, as I spend quite a bit of effort developing other packages.

One of those packages is scriptconfig. I don't want to add a dependency on it here, but we could borrow some code from it. Namely: BooleanFlagOrKeyValAction, which allows for flexible specification of boolean variables, e.g.:

        --flag        > {'flag': True}
        --flag=1      > {'flag': True}
        --flag True   > {'flag': True}
        --flag True   > {'flag': True}
        --flag False  > {'flag': False}
        --flag 0      > {'flag': False}
        --no-flag     > {'flag': False}
        --no-flag=0   > {'flag': True}
        --no-flag=1   > {'flag': False}

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, big fan of this style of boolean flags (I think I did the same for pytest-autoprofile); will implement.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just realized that there will be unintended consequences: short flags can no longer be concatenated since they are no longer simple boolean flags. But that can be circumvented by creating separate actions for the short and long flags I guess.

kernprof.py Outdated
__name__ = '__main__'

if options.output_interval:
# XXX: why are we doing this here (5a38626) and again below?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can just remove one of these. I noticed this too, and I think it was a mistake.

config options are loaded
"""
conf, source = get_config(*args, **kwargs)
cli_conf = {key.replace('-', '_'): value
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've also tackled this in scriptconfig, although the code for doing so was complicated by internal changes to argparse in python/cpython@c02b7ae

Code is here:
https://gitlab.kitware.com/utils/scriptconfig/-/blob/main/scriptconfig/argparse_ext.py?ref_type=heads#L423

Might not be worth the extra complexity, but I figured I would point it out.

@TTsangSC TTsangSC force-pushed the toml-config branch 2 times, most recently from 736db2a to 7c70075 Compare May 18, 2025 08:28
TTsangSC added 21 commits May 20, 2025 22:54
line_profiler/line_profiler_rc.toml
    Default configuration file

line_profiler/toml_config.py[i]
    New module for reading TOML config files
MANIFEST.in, setup.py
    Configured to include `line_profiler/line_profiler_rc.toml` in
    source and wheel distributions

requirements/runtime.txt
    Added dependency `tomli` for Python < 3.11 (to stand in for
    `tomllib`)
kernprof.py
    - Made all `line_profiler` imports unconditional (the ship has
      sailed, there's already an unconditional import for
      `line_profiler.profiler_mixin.ByCountProfilerMixin`)
    - For each boolean option (e.g. `--view`):
      - Added a corresponding negative option (e.g. `--no-view`)
      - Changed the default value from `False` to `None`, so that we can
        distinguish between cases where the negative option is passed
        and no option is passed (and in that case read from the config
        (TODO))

main(), find_module_script(), find_script()
    Added argument `exit_on_error` to optionally prevent parsing errors
    from killing the interpretor
kernprof.py
    __doc__
        Updated with newest `kernprof --help` output
    short_string_path()
        New helper function for abbreviating paths
    _python_command()
        - Replaced string comparison between paths with
          `os.path.samefile()`
        - Updated to use abbreviated paths where possible
    main()
        - Updated description to include documentation for the negative
          options
        - Added option `--config` for loading config from a specific
          file instead of going through lookup
        - Updated `const` value for the bare `-i`/`--output-intereval`
          option (the old value 0, equivalent to not specifying the
          option, doesn't really make sense)
        - Grouped options into argument groups for better organization
        - Updated help texts for options to be more stylistically
          consistent and to show the default values
        - Updated help texts for the `-p`/`--prof-mod` option to show an
          example
        - Updated help texts for the `--prof-imports` to be more in line
          with what it actually does (see docstring of
          `line_profiler.autoprofile.ast_tree_profiler.AstTreeProfiler`)
        - Added option resolution: values of un-specified flags now
          taken from the specified/looked-up config file
line_profiler/toml_config.py[i]
    find_and_read_config_file()
        New argument `env_var` for controlling whether/whence to read
        the config from the environment if not specified via `config`
    get_config()
        New arguemnt `read_env` for controlling whether to read the
        config from the environment variable `${LINE_PROFILER_RC}` if
        specified via `config`
kernprof.py
    Moved common utilities to `line_profiler/cli_utils.py`

line_profiler/cli_utils.py[i]
    New module for common utilities shared by `kernprof` and
    `python -m line_profiler`
kernprof.py::main()
    Now passing the received `-c`/`--config` onto
    `LineProfiler.print_stats()`

line_profiler/line_profiler.py[i]
    <Overall>
        Formatting changes (avoid using hanging indents where suitable,
        to be more consistent with the rest of the codebase)
    LineProfiler.print_stats(), show_func(), show_text()
        - Added optional argument `config` to allow for specifying the
          config file
        - Now reading output column widths from the
          `tool.line_profiler.show.column_widths` table in config files
    main()
        - Refactored to use the `.cli_utils`
        - Added description for the CLI application
        - Added negative options to the boolean options
        - Added option `-c`/`--config` to read default values for the
          options from the `tool.line_profiler.cli` table

line_profiler/line_profiler_rc.toml
    - Added documentation for the environment variable
      `${LINE_PROFILER_RC}`
    - Added subtables `tool.line_profiler.cli` and
      `tool.line_profiler.show.column_widths`
line_profiler.toml_config.py[i]
    get_subtable()
        - Added doctest
        - Added type check that the returned object is a mapping
    get_headers()
        New function for getting the subtable headers from a table
    get_config()
        - Updated docs with reference to the new
          `tool.line_profiler.cli` and `.show.column_widths` tables
        - Added check for subtable existence
        - Fixed traceback and error message when the table is malformed
kernprof.py::_python_command
    Migrated definition to
    `line_profiler/cli_utils.py::get_python_executable()`

line_profiler/cli_utils.py::get_python_executable()
    New function used by both `kernprof` and
    `line_profiler.explicit_profiler`
line_profiler/explicit_profiler.py
    GlobalProfiler
        __doc__
            Updated
        __init__()
            - Added argument `config` to allow for explicitly providing
              a config file
            - Now reading `.{setup,write,show}_config` and
              `.output_prefix` from the config file, instead of using
              hard-coded values
        show()
            Minor refatoring and reformatting
    _python_command()
        Now using `.cli_utils.get_python_executable()`

line_profiler/line_profiler_rc.toml::[tool.line_profiler.write]
    Added item `output_prefix`, corresponding to
    `line_profiler.explicit_profiler.GlobalProfiler.output_prefix`
line_profiler/toml_config.py::get_config()
    - Now promoting `config` to `pathlib.Path` objects early so as to
      catch bad arg types
    - Now raising `ValueError` or `FileNotFoundError` if a `config` is
      specified and loading fails

tests/test_toml_config.py
    New test module for tests related to `line_profiler.toml_config`:
    - test_environment_isolation()
      Test that the fixture we use suffices to isolate the tests from
      the environment
    - test_default_config_deep_copy()
      Test that `get_default_config()` returns fresh, deep copies
    - test_table_normalization()
      Test that `get_config()` always normalizes the config entires to
      supply missing entires and remove spurious ones
    - test_malformed_table()
      Test that we get a `ValueError` from malformed TOML files
    - test_config_lookup_hierarchy()
      Test the hierarchy according to which we resolve which TOML to
      read the configs from
tests/test_explicit_profile.py
    test_*()
        Updated to use `tempfile.TemporaryDirectory()` instead of
        `tempfile.mkdtemp()` so that the tmpdirs are cleaned up
        regardless of the test outcome
    test_explicit_profile_with_customized_config()
        New test for customizing explicit profiling with a user-supplied
        TOML config file
tests/test_autoprofile.py
    test_*()
        Updated to use `tempfile.TemporaryDirectory()` instead of
        `tempfile.mkdtemp()` so that the tmpdirs are cleaned up
        regardless of the test outcome
    test_autoprofile_with_customized_config()
        New test for customizing `kernprof` auto-profiling and
        `python -m line_profiler` output formatting with a user-supplied
        TOML config file
line_profiler/explicit_profiler.py::GlobalProfiler
    Updated docstring

line_profiler/toml_config.py::targets
    Changed lookup target from `line_profiler_rc.toml` ->
    `line_profiler.toml`

tests/test_toml_config.py::test_config_lookup_hierarchy()
    Updated test to use `line_profiler.toml` instead of
    `line_profiler_rc.toml`
line_profiler/line_profiler.py[i]
    minimum_column_widths
        Removed global constant
    get_minimum_column_widths()
        New cached callable for getting the above value
kernprof.py
    __doc__
        Updated with new `kernprof --help` output
    main()
        Added a `--no-config` flag for disabling the loading of
        non-default configs

line_profiler/cli_utils.py[i]::get_cli_config()
    - Added the explicit named argument `config`
    - Added processing for boolean values of `config` (true -> default
      behavior, false -> fall back for `get_default_config()`)

line_profiler/line_profiler.py[i]
    LineProfiler.print_stats(), show_func(), show_text()
        Added handling for boolean values of `config`
    main()
        Added a `--no-config` flag for disabling the loading of
        non-default configs

tests/test_autoprofile.py
    test_autoprofile_with_customized_config()
        Fixed malformed indentation
    test_autoprofile_with_no_config()
        New test for disabling config lookup
line_profiler/cli_utils.py[i]::get_cli_config()
    Rolled back last commit

line_profiler/explicit_profiler.py[i]::GlobalProfiler
    - Updated docstring
    - Added missing `config` argument to `.__init__()` in the stub file

line_profiler/line_profiler.py
    Removed wrapper function around
    `line_profiler.toml_config.get_config()`

line_profiler/toml_config.py[i]::get_config()
    Added handling for `config = <bool>`:
    - `False`: don't look up or load any user-supplied config and just
      use the default
    - `True`: same as `None` (default behavior)

tests/test_toml_config.py::test_config_lookup_hierarchy()
    Now also testing `get_config(True)` and `get_config(False)`
TTsangSC added 3 commits July 7, 2025 10:32
Extra changes:
    kernprof.py
        - New boolean flag `--preimports` with `--no-preimports` as its
          negative alias
        - Previous boolean flag `-v`/`--view` now the count flag
          `-v`/`--verbose`/`--view`
    line_profiler/rc/line_profiler.toml
        - Added boolean switch `preimports`
        - Moved boolean switch `view` to the integer `verbose`
    tests/test_autoprofile.py::test_autoprofile_with_customized_config()
        Updated implementation

TODO:
    Fix `test_autoprofile_with_customized_config()` and
    `test_autoprofile_with_no_config()`
kernprof.py
    __doc__
        Updated
    _add_core_parser_arguments()
        Updated help text of `-p`/`--prof-mod`
    _parse_arguments()
        - Fixed bug where the config file is stil looked up despite
          passing `--no-config`
        - Added logging output for the loading of configs
    _normalize_profiling_targets()
        Now allow for an empty string to invalidate previous targets
        (so that e.g. `--prof-mod ''` can be used to drop profiling
        targets specified in the `--config`)
    main(), _write_tempfile(), _write_preimports(), _pre_profile()
        Now using `short_string_path()` to abbreviate filenames in
        output
@TTsangSC
Copy link
Collaborator Author

TTsangSC commented Jul 7, 2025

Just merged from main. A couple more things (on my end) before it's ready I guess:

  • We'll probably want to make ~.scoping_policies.DEFAULT_SCOPING_POLICIES configurable, or at the very least read from ~/rc/line_profiler.toml.
  • That and we add a corresponding config table and options for kernprof. Since we already have eager --prof-mod (ENH: more intuitive profiling-target selection #337) which handles descent into packages, I guess there isn't really a use-case for loosening the module-scoping policy (and it's prudent to not make it too easy for users to set it, lest we descend into arbitrary imported modules willy-nilly). But it's probably a good idea to make the function- and class-scoping policies settable via options (e.g. to narrow the profiling scope from the default siblings to children).
  • Just noticed some bugs with logging and the debug mode in kernprof. Since those are kinda lightweight (but urgent) I'll write a separate small PR for them.

@Erotemic
Copy link
Member

Erotemic commented Jul 8, 2025

I believe this is the last big PR to merge. Let's get everything else merged in and then tackle this one. We don't need to make everything configurable in this PR. In fact, perhaps its better to leave some items as non-configurable until we have the basic config structure down and merged.

@TTsangSC
Copy link
Collaborator Author

TTsangSC commented Jul 8, 2025

Just to confirm:

Let's get everything else merged in and then tackle this one.

By "everything else", do you mean just #344, #349, and #351 (the smaller and more manageable PRs working on Cython code), or are you also including #334 (the big Cython PR)? Since #334 kinda predates all of the currently active PRs (and a lot has happened in between), I'm starting to wonder if I should completely rewrite that PR. If it isn't on your timeline for 4.3.0, maybe I should wait till then (when the code is stabilized) for the overhaul.

@Erotemic
Copy link
Member

Erotemic commented Jul 8, 2025

I was including #334, but if you want to push that off until later we can.

@TTsangSC
Copy link
Collaborator Author

TTsangSC commented Jul 9, 2025

Ah got it. We can work on that once we're done with #351 then.

@TTsangSC
Copy link
Collaborator Author

TTsangSC commented Jul 18, 2025

Should I rebase on main now? @Erotemic

EDIT: never mind, I forgot how disastrous the last rebase attempt turned out. I'll just merge main into it.

Copy link
Member

@Erotemic Erotemic left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems more or less ready. I have a few nitpicks here, and I've also put up a PR to this branch that adds more documentation:

TTsangSC#4

I did notice an interesting (not sure if desirable) autoprofile behavior in the example. I have a main function in demo_pkg.core, and that uses two functions:

from demo_pkg.utils import leq
from demo_pkg import utils

# Using
leq
utils.add

I noticed that when I autoprofile demo_pkg.core, the leq function is also profiled even though it is defined in utils. However, if I autoprofile demo_pkg.utils, both leq and utils are profiled. The second case I think is desirable, but for the first case, we may want to check if the module the function was defined in is in the set of modules that we requested to autoprofile.

provided.
Return:
conf_dict, path (tuple[dict, Path]):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For google style docstrings, it should be:

Returns:
    <type-expr> : <description-text>

env_var = 'LINE_PROFILER_RC'

_defaults = None

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be nice to define a named tuple:

from typing import NamedTuple, Optional
class ConfigSource(NamedTuple):
    conf_dict: dict
    source: Optional[pathlib.Path]

which I find to be more readable than magic unpacking based on indexes. You could even add get_default_config as a classmethod called default, and perhaps the get_config method could become the coerce classmethod. That might be nice because it logically groups the two "constructors" of this dict/path tuple.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIRC mypy doesn't allow having class methods on a NamedTuple. We can probably just refactor into a data class though...

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the refactor.

@@ -0,0 +1,205 @@
########################################################################
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On one hand, I like there being a resource file with the default config, but on the other hand, I'm wondering if this would make more sense to define in-code using a dataclass / scriptconfig / pydantic like syntax where the help docs can be coupled with the arguments. This would serve as the single source of truth for all options in the system, and be able to serialize itself as TOML, load from TOML (or a dict), and generate and argparse CLI.

I may want to try to see if I can write a minimal version of scriptconfig that does all of this, and can be vendored into the package and support this use case. Perhaps that can also be a roadmap item, as I don't want to hold this PR up too long. It works well enough, but that is the sort of road I want to head down.

I'm thinking this file might eventually look something like this:

from scriptconfig import DataConfig, Value, Flag


class ShowColumnWidthsConfig(DataConfig):
    """Column width settings for line_profiler output"""
    line = Value(6, type=int, help="Line number column width")
    hits = Value(9, type=int, help="Hits column width")
    time = Value(12, type=int, help="Time column width")
    perhit = Value(8, type=int, help="Time per hit column width")
    percent = Value(8, type=int, help="Percent time column width")


class ShowConfig(DataConfig):
    """Configuration for `line_profiler.show`"""
    sort = Flag(True, help="Sort lines by time spent")
    stripzeros = Flag(True, help="Omit lines with 0 time")
    rich = Flag(True, help="Use rich formatting")
    details = Flag(False, help="Show additional details")
    summarize = Flag(True, help="Summarize output")
    column_widths = Value(ShowColumnWidthsConfig, help="Customize column widths")


class WriteConfig(DataConfig):
    """GlobalProfiler write options"""
    output_prefix = Value('profile_output', help="Prefix for output files")
    lprof = Flag(True, help="Write .lprof binary output")
    text = Flag(True, help="Write plain text output")
    timestamped_text = Flag(True, help="Write timestamped text output")
    stdout = Flag(True, help="Print to stdout")


class SetupConfig(DataConfig):
    """Configuration for GlobalProfiler auto-enabling"""
    environ_flags = Value(['LINE_PROFILE'], help="Environment variables to enable profiling")
    cli_flags = Value(['--line-profile', '--line_profile'], help="CLI flags to enable profiling")


class CLIConfig(DataConfig):
    """Configuration for `python -m line_profiler`"""
    unit = Value(1e-6, type=float, help="Timing unit")
    rich = Flag(False, help="Enable rich display output")
    skip_zero = Flag(False, help="Omit lines with 0 time")
    sort = Flag(False, help="Sort output by time")
    summarize = Flag(False, help="Summarize multiple profiles")


class KernprofConfig(DataConfig):
    """Kernprof CLI options"""
    line_by_line = Flag(False, help="Enable line-by-line profiling", short_alias=['l'])
    builtin = Flag(False, help="Profile built-in functions")
    rich = Flag(False, help="Enable rich output")
    skip_zero = Flag(False, help="Omit lines with 0 time")
    preimports = Flag(True, help="Import modules before executing")
    prof_imports = Flag(False, help="Profile imports")
    verbose = Value(0, type=int, help="Verbosity level", short_alias=['v'])
    outfile = Value('', help="Output filename", short_alias=['o'])
    setup = Value('', help="Setup code to run before the script")
    unit = Value(1e-6, type=float, help="Timing unit", short_alias=['u'])
    output_interval = Value(0, type=int, help="Seconds between intermediate dumps")
    prof_mod = Value([], help="Additional modules to profile")

Thus all of the good information you have commented here becomes codified, introspectable, and able to generate a CLI or base config dict.

Comment on lines 219 to 256
def boolean(value, *, fallback=None, invert=False):
"""
Arguments:
value (str)
Value to be parsed into a boolean (case insensitive)
fallback (Union[bool, None])
Optional value to fall back to in case ``value`` doesn't
match any of the specified
invert (bool)
If ``True``, invert the result of parsing ``value`` (but not
``fallback``)
Returns:
b (bool)
Notes:
These values are parsed into ``False``:
* The empty string
* ``'0'``, ``'F'``, ``'N'``
* ``'off'``, ``'False'``, ``'no'``
And these into ``True``:
* ``'1'``, ``'T'``, ``'Y'``
* ``'on'``, ``'True'``, ``'yes'``
"""
try:
result = _BOOLEAN_VALUES[value.casefold()]
except KeyError:
pass
else:
return (not result) if invert else result
if fallback is None:
raise ValueError(f'value = {value!r}: '
'cannot be parsed into a boolean; valid values are'
f'({{string: bool}}): {_BOOLEAN_VALUES!r}')
return fallback
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def boolean(value, *, fallback=None, invert=False):
"""
Arguments:
value (str)
Value to be parsed into a boolean (case insensitive)
fallback (Union[bool, None])
Optional value to fall back to in case ``value`` doesn't
match any of the specified
invert (bool)
If ``True``, invert the result of parsing ``value`` (but not
``fallback``)
Returns:
b (bool)
Notes:
These values are parsed into ``False``:
* The empty string
* ``'0'``, ``'F'``, ``'N'``
* ``'off'``, ``'False'``, ``'no'``
And these into ``True``:
* ``'1'``, ``'T'``, ``'Y'``
* ``'on'``, ``'True'``, ``'yes'``
"""
try:
result = _BOOLEAN_VALUES[value.casefold()]
except KeyError:
pass
else:
return (not result) if invert else result
if fallback is None:
raise ValueError(f'value = {value!r}: '
'cannot be parsed into a boolean; valid values are'
f'({{string: bool}}): {_BOOLEAN_VALUES!r}')
return fallback
def boolean(value, *, fallback=None, invert=False):
"""
Arguments:
value (str)
Value to be parsed into a boolean (case insensitive)
fallback (Optional[bool])
Optional value to fall back to in case ``value`` doesn't
match any of the specified
invert (bool)
If ``True``, invert the result of parsing ``value`` (but not
``fallback``)
Returns:
bool: The parsed (and optionally inverted) boolean.
Notes:
>>> # These values are parsed into ``False``:
>>> assert all(False is boolean(v) for v in ['0', 'F', 'N'])
>>> assert all(False is boolean(v) for v in ['off', 'False', 'no'])
>>> # And these into ``True``:
>>> assert all(True is boolean(v) for v in ['1', 'T', 'Y'])
>>> assert all(True is boolean(v) for v in ['on', 'True', 'yes'])
"""
try:
result = _BOOLEAN_VALUES[value.casefold()]
except KeyError:
pass
else:
return (not result) if invert else result
if fallback is None:
raise ValueError(f'value = {value!r}: '
'cannot be parsed into a boolean; valid values are'
f'({{string: bool}}): {_BOOLEAN_VALUES!r}')
return fallback

Convert the example text into a runnable doctest.

sort = false
# - `summarize` (bool):
# `-m`/`--summarize` (true) or `--no-summarize` (false)
summarize = false
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels strange that there is no way to summarize when running kernprof on the CLI. Maybe that should be added to tool.line_profiler.kernprof, but without the short-alias?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

... didn't notice before but you're right, we should've provided for that. Will do.

TTsangSC and others added 2 commits July 21, 2025 00:48
Co-authored-by: Jon Crall <erotemic@gmail.com>
Co-authored-by: Jon Crall <erotemic@gmail.com>
@TTsangSC
Copy link
Collaborator Author

Regarding the demo_pkg case you mentioned in the comment, it's because of the scoping policy (ScopingPolicy.SIBLINGS) we have in place; IMO there's merit to profiling functions that originates from the same package as long as it's found in the namespace since whole-package profiling may include too much, while just profiling what is defined in the module (ScopingPolicy.EXACT) as you mentioned may include too little, esp. if there's an API module gathering bits and pieces from all over the package.

(I guess this perfectly illustrates why scoping policies should be somewhat configurable. But maybe that can be the next PR...)

Erotemic and others added 7 commits July 20, 2025 21:33
Add example of using TOML config to docs
kernprof.py
    Updated implementations

line_profiler/cli_utils.py[i]
    <General>
        Reformatted docstrings
    get_cli_config()
        Now returning a `ConfigSource`
    boolean()
        Added doctest

line_profiler/explicit_profiler.py::GlobalProfiler
    - Reformatted docstring
    - Updated implementation

line_profiler/line_profiler.py[i]
    get_column_widths()
        Refactored from `get_minimum_column_widths()`
    show_text()
    main()
        Updated implementations

line_profiler/toml_config.py[i]
    <General>
        Reformatted docstrings
    NAMESPACE, TARGETS, ENV_VAR
        Renamed from lowercased constants
    ConfigSource
        New data class refactored from `get_config()` and
        `get_default_config()`

tests/test_toml_config.py
    Updated implementations
- Fixed typos and links
- Added doc pages for `line_profiler.toml_config` and
  `line_profiler.cli_utils`
kernprof.py
    Added flag `--summarize` as an analog for
    `python -m line_profiler --summarize`

line_profiler/line_profiler.py
    Fixed bug where the wrong defaults are shown in the help texts of
    the `--sort` and `--summarize` flags

line_profiler/rc/line_profiler.toml::[tool.line_profiler.kernprof]
    Added new boolean value `summarize`
kernprof.py::_post_profile()
    - Added diagnostic debug message for the call to
      `LineProfiler.print_stats()`
    - Fixed bug where `options.summarize` is not passed to
      `.print_stats()`
@TTsangSC
Copy link
Collaborator Author

Done with the tinkering for now:

  • Refactored line_profiler.toml_config.get_config() and .get_default_config() into the new ConfigSource class
  • Added the --summarize flag to kernprof

Guess that the only thing left is whether we want to also change the default scoping policy.

@Erotemic Erotemic merged commit 49f8658 into pyutils:main Jul 21, 2025
36 checks passed
@Erotemic
Copy link
Member

You've convinced me on scoping policy, but let's do it in a separate PR. That can wait until after 5.0 or not.

When I get a chance I'm going to start the release process.

@TTsangSC
Copy link
Collaborator Author

Cheers!

@TTsangSC TTsangSC deleted the toml-config branch July 21, 2025 18:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants