Skip to content
Merged
3 changes: 3 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ Changes
confusion with non-profiled "twins" (#425)
* FIX: Stop reverting ``sys.modules`` after calling ``kernprof.main()``
to avoid edge-case issues with e.g. pickling (#437)
* FIX: Fixed bug where ``kernprof -l`` misses ``--prof-mod`` targets if
multiple thereof are imported in the same (from-)import statement
(#434)


5.0.2
Expand Down
35 changes: 21 additions & 14 deletions line_profiler/autoprofile/ast_tree_profiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import ast
import os
from typing import Type

from .ast_profile_transformer import (
AstProfileTransformer,
Expand All @@ -29,8 +28,12 @@ def __init__(
script_file: str,
prof_mod: list[str],
profile_imports: bool,
ast_transformer_class_handler: Type = AstProfileTransformer,
profmod_extractor_class_handler: Type = ProfmodExtractor,
ast_transformer_class_handler: (
type[AstProfileTransformer]
) = AstProfileTransformer,
profmod_extractor_class_handler: (
type[ProfmodExtractor]
) = ProfmodExtractor,
) -> None:
"""Initializes the AST tree profiler instance with the script file path

Expand All @@ -46,10 +49,10 @@ def __init__(
profile_imports (bool):
if True, when auto-profiling whole script, profile all imports aswell.

ast_transformer_class_handler (Type):
ast_transformer_class_handler (type[AstProfileTransformer]):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm unsure of the modern utility of these google style docstring blocks. When I started using them back in Python 2.7 they were indispensable. Even in the early 3.x days I had strong arguments that these were better than the weak state of type annotations. But now type annotations in Python are a lot better, and these are mostly redundant. My only concern about dropping them would be the impact they have on readthedocs. I'm not sure if the type annotations in the signature are properly merged with the parsed documentation here. Nothing needs to be done about it here, but it's on my mind.

the AstProfileTransformer class that handles profiling the whole script.

profmod_extractor_class_handler (Type):
profmod_extractor_class_handler (type[ProfmodExtractor]):
the ProfmodExtractor class that handles mapping prof_mod to objects in the script.
"""
self._script_file = script_file
Expand Down Expand Up @@ -106,7 +109,7 @@ def _get_script_ast_tree(script_file: str) -> ast.Module:
def _profile_ast_tree(
self,
tree: ast.Module,
tree_imports_to_profile_dict: dict[int, str],
tree_imports_to_profile_dict: dict[int, list[str]],
profile_full_script: bool = False,
profile_imports: bool = False,
) -> ast.Module:
Expand All @@ -122,12 +125,13 @@ def _profile_ast_tree(
tree (_ast.Module):
abstract syntax tree to be profiled.

tree_imports_to_profile_dict (Dict[int,str]):
tree_imports_to_profile_dict (dict[int, list[str]]):
dict of imports to profile
key (int):
index of import in AST
value (str):
alias (or name if no alias used) of import
value (list[str]):
list of aliases (or names if no alias used) to
import

profile_full_script (bool):
if True, profile whole script.
Expand All @@ -144,10 +148,13 @@ def _profile_ast_tree(
list(tree_imports_to_profile_dict), reverse=True
)
for tree_index in argsort_tree_indexes:
name = tree_imports_to_profile_dict[tree_index]
expr = ast_create_profile_node(name)
tree.body.insert(tree_index + 1, expr)
profiled_imports.append(name)
names = tree_imports_to_profile_dict[tree_index]
for name in reversed(names):
# Reversing keeps the order of the inserted nodes
# consistent with the imports
expr = ast_create_profile_node(name)
tree.body.insert(tree_index + 1, expr)
profiled_imports.append(name)
if profile_full_script:
tree = self._ast_transformer_class_handler(
profile_imports=profile_imports,
Expand Down Expand Up @@ -179,7 +186,7 @@ def profile(self) -> ast.Module:

tree_imports_to_profile_dict = self._profmod_extractor_class_handler(
tree, self._script_file, self._prof_mod
).run()
).extract_all()
tree_profiled = self._profile_ast_tree(
tree,
tree_imports_to_profile_dict,
Expand Down
118 changes: 94 additions & 24 deletions line_profiler/autoprofile/profmod_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
import ast
import os
import sys
from typing import List, cast, Any, Union
from typing import Literal, cast
from warnings import warn
from .util_static import (
modname_to_modpath,
modpath_to_modname,
package_modpaths,
)
from .. import _diagnostics as diagnostics


class ProfmodExtractor:
Expand All @@ -30,7 +32,7 @@ def __init__(
script_file (str):
path to script being profiled.

prof_mod (List[str]):
prof_mod (list[str]):
list of imports to profile in script.
passing the path to script will profile the whole script.
the objects can be specified using its dotted path or full path (if applicable).
Expand Down Expand Up @@ -77,13 +79,13 @@ def _get_modnames_to_profile_from_prof_mod(
script_file (str):
path to script being profiled.

prof_mod (List[str]):
prof_mod (list[str]):
list of imports to profile in script.
passing the path to script will profile the whole script.
the objects can be specified using its dotted path or full path (if applicable).

Returns:
modnames_to_profile (List[str]):
modnames_to_profile (list[str]):
list of dotted paths to profile.
"""
script_directory = os.path.realpath(os.path.dirname(script_file))
Expand All @@ -105,7 +107,7 @@ def _get_modnames_to_profile_from_prof_mod(
so we check if the item is path and whether that path exists, else skip the item.
"""
modpath = modname_to_modpath(
mod, sys_path=cast(List[Union[str, os.PathLike]], new_sys_path)
mod, sys_path=cast('list[str | os.PathLike]', new_sys_path)
)
if modpath is None:
"""if cannot convert to modpath, check if already path and if invalid"""
Expand Down Expand Up @@ -148,7 +150,7 @@ def _ast_get_imports_from_tree(
abstract syntax tree to fetch imports from.

Returns:
module_dict_list (List[Dict[str,Union[str,int]]]):
module_dict_list (list[Dict[str, str | int]]):
list of dicts of all imports in the tree, containing:
name (str):
the real name of the import. e.g. foo from "import foo as bar"
Expand Down Expand Up @@ -193,7 +195,7 @@ def _ast_get_imports_from_tree(
def _find_modnames_in_tree_imports(
modnames_to_profile: list[str],
module_dict_list: list[dict[str, str | int | None]],
) -> dict[int, str]:
) -> dict[int, list[str]]:
"""Map modnames to imports from an abstract sytax tree.

Find imports in modue_dict_list, created from an abstract syntax tree, that match
Expand All @@ -205,21 +207,22 @@ def _find_modnames_in_tree_imports(
The import's alias is stored in the output dict.

Args:
modnames_to_profile (List[str]):
modnames_to_profile (list[str]):
list of dotted paths to profile.

module_dict_list (List[Dict[str,Union[str,int]]]):
module_dict_list (list[Dict[str, str | int]]):
list of dicts of all imports in the tree.

Returns:
modnames_found_in_tree (Dict[int,str]):
modnames_found_in_tree (dict[int, list[str]]):
dict of imports found
key (int):
index of import in AST
value (str):
alias (or name if no alias used) of import
index of the (from-)import statement in AST
value (list[str]):
list of aliases (or names if no alias used) to
import
"""
modnames_found_in_tree: dict[int, str] = {}
modnames_found_in_tree: dict[int, list[str]] = {}
modname_added_list = []
for i, module_dict in enumerate(module_dict_list):
modname = module_dict['name']
Expand All @@ -240,30 +243,97 @@ def _find_modnames_in_tree_imports(
tree_index = module_dict['tree_index']
if not isinstance(tree_index, int):
raise TypeError('should have gotten an int')
modnames_found_in_tree[tree_index] = name
modnames_found_in_tree.setdefault(tree_index, []).append(name)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is a nitpick, because this is a very standard pattern, but I've always found it difficult to parse. I would prefer the dumb check and initialize, and then append, or using a collections.defaultdict. Feel free to ignore this, I'm not going to enforce this preference.

return modnames_found_in_tree

def run(self) -> dict[int, str]:
def extract_all(self) -> dict[int, list[str]]:
"""Map prof_mod to imports in an abstract syntax tree.

Takes the paths and dotted paths in prod_mod and finds their respective imports in an
abstract syntax tree, returning their alias and the index they appear in the AST.
Takes the paths and dotted paths in prof_mod and finds their respective imports in an
abstract syntax tree, returning their aliases and the index they appear in the AST.

Returns:
(Dict[int,str]): tree_imports_to_profile_dict
tree_imports_to_profile_dict (dict[int, str] | dict[int, list[str]]);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
tree_imports_to_profile_dict (dict[int, str] | dict[int, list[str]]);
tree_imports_to_profile_dict (dict[int, list[str]]);

I need to figure out exactly how sphinx readthedocs interacts with these return strings and the type annotations. We shouldn't have to double specify them. The only thing I'm reserved about is that I've grown so used to seeing the type right next to the help string, which I find valuable. Uggg, if only Python had a nice way to attach help docs to types. Annotated exists, but I think it's ugly.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Poked around a bit with a local Sphinx build:
image

Note:

  • prof_mod doesn't have its type in parenthesis next to it, unlike the other params. That is because I deleted its Google-style annotation before the build just ot see what would happen
  • The param types are picked up by Sphinx and are separately formatted (with <em> instead of <strong>), but don't result in resolved links to the docs of the type objects.
  • All the params still have their type annotations parsed from the code as seen in the blue banner above. The return-type annotation for .extract_all() is likewise shown in the gray method. The type hints in the banner are also resolved and clickable.
  • Meanwhile the erroneous Google-style annotation for the return value of .extract_all() (next to tree_imports_to_profile_dict) doesn't seem to be special-cased by Sphinx (as it did with the parameters), and is just formatted with <dd> text like most of the rest of the Returns: segment.

So I guess it does suffice to just keep type annotations in-code and not duplicate them in-doc: the in-code annotations are more functional, and maintaining a separate set of annotations leaves us open to gaffes like this where we forget to update the docstring. But this comes at the cost of not having the type next to the per-param doc as you've said. Still, if the concern is only with the webdocs instead of the docstrings themselves, tox-dev/sphinx-autodoc-typehints purportedly does the job of auto-interpolating the annotations back.

As for typing.Annotated... yeah that's ugly. One thing that I've used it for though is to provide extra info without actually modifying the param types, esp. for user-facing functions:

class AlNumMeta(type):
    def __instancecheck__(cls, obj: object) -> bool:
        return isinstance(obj, str) and obj.isalnum()


class AlNumString(str, metaclass=AlNumMeta):
    ...


def func(alnum_str: Annotated[str, AlNumString]) -> None:
    # If we annotated `alnum_str` with `AlNumString` directly, the runtime behavior doesn't
    # change, but static typing would go ballistic on a user doing e.g. `func('12345')` instead
    # of `func(AlNumString('12345'))`
    assert isinstance(alnum_str, AlNumString)
    ...

But even then one can argue that it's probably more productive to either just put the extra info in the docs or use a value object if one needs assurance that the arg .isalnum().

dict of imports to profile
key (int):
index of import in AST
value (str):
alias (or name if no alias used) of import
value (str | list[str]):
list of aliases (or names if no alias used) to
import
"""
modnames_to_profile = self._get_modnames_to_profile_from_prof_mod(
self._script_file, self._prof_mod
)

module_dict_list = self._ast_get_imports_from_tree(self._tree)

tree_imports_to_profile_dict = self._find_modnames_in_tree_imports(
modnames_to_profile, module_dict_list
return self._find_modnames_in_tree_imports(
modnames_to_profile, module_dict_list,
)
return tree_imports_to_profile_dict

def run(self) -> dict[int, str]:
"""
Deprecated, legacy method kept for backward compatibility.

Returns:
tree_imports_to_profile_dict (dict[int, str])
dict of imports to profile
key (int):
index of import in AST
value (str):
alias (or name if no alias used) of the LAST
target to import in the corresponding
:py:class:`ast.Import` or
:py:class:`ast.ImportFrom` statement

Notes:
- New code should use the :py:meth:`.extract_all` method,
which handles multi-target import statements (see #434).

- Calling this method issues a
:py:class:`DeprecationWarning`.

- For multi-target import statements, this only preserves
the last target. If this results in import targets being
dropped, a :py:class:`UserWarning` is issued.
"""
msg = (
'`ProfmodExtractor.run()` is now deprecated, because it cannot '
'correctly resolve multi-target import statements; '
'use `ProfmodExtractor.extract_all()` instead.'
)
_issue_warning(msg, DeprecationWarning, stacklevel=2)
result: dict[int, str] = {}
dropped_names: set[str] = set()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks like this would fail for something where a name is imported and then redefined. Not sure if we want to handle this case or not. We probably should if it isn't too hard. But we don't want this static analysis to end up becoming a full execution runtime either.

import foo, bar
import baz as foo

@TTsangSC TTsangSC Jul 18, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Adding a dropped_names.discard(last) should be easy enough, will do.

What just came to my mind though is that currently any import statement not directly in Module.body is skipped. Beside imports nested inside functions and classes (whether we want to deal with which is debatable1), this also means that some other common patterns are also ignored:

import json  # This is extracted
from sys import version_info
from typing import TYPE_CHECKING

if version_info[:2] >= (3, 11) or TYPE_CHECKING:
    import tomllib   # ... but this isn't
else:
    import tomli as tomlib   # ... and neither is this

try:
    from os import fork  # ... nor this
except ImportError:
    _HAS_FORK = False
else:
    _HAS_FORK = True


def main():
    # ... and finally not this; XXX: but do we *want* to cater to this?
    import textwrap
    ...

But I guess that can be another issue and PR.

Footnotes

  1. I vaguely remember this being mentioned in some discussion somewhere in the repo. Don't remember where though, nor whether it was you and I or someone else in the discussion.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I vaguely recall that too, and I think the decision was to punt on it at the time. I don't remember exactly what the conversation was but looking at this code and thinking about the problem I'm 99% sure I didn't want to have to resolve arbitrary expressions in a static check. And I also think that nested imports are something that should be documented as explicitly out of scope for autoprofile. Working on static code means we are forced to make certain assumptions to get the speed and safety of static analysis.

for i, names in self.extract_all().items():

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks like there is still an issue here:

Details
import sys
import types


# Create three distinct importable module objects.
module_names = [
    'lp_shadow_demo_foo',
    'lp_shadow_demo_bar',
    'lp_shadow_demo_baz',
]
modules = {
    name: types.ModuleType(name)
    for name in module_names
}
sys.modules.update(modules)


class RecordingProfiler:
    def __init__(self):
        self.seen = []

    def add_imported_function_or_module(self, obj):
        self.seen.append(obj)


try:
    # This is the grouped result that extract_all() would produce:
    #
    #   import lp_shadow_demo_foo as x, lp_shadow_demo_bar
    #       -> ['x', 'lp_shadow_demo_bar']
    #
    #   import lp_shadow_demo_baz as x
    #       -> ['x']
    grouped = {
        0: ['x', 'lp_shadow_demo_bar'],
        1: ['x'],
    }

    def current_accounting(grouped):
        """
        Equivalent to the current PR implementation.
        """
        result = {}
        dropped_names = set()

        for tree_index, names in grouped.items():
            *remainder, last = names
            dropped_names.update(remainder)

            # Current attempted shadow handling
            dropped_names.discard(last)

            result[tree_index] = last

        dropped_names -= set(result.values())
        return result, dropped_names

    def correct_accounting(grouped):
        """
        Preserve dropped bindings per import statement.
        """
        result = {}
        dropped = {}

        for tree_index, names in grouped.items():
            *remainder, last = names
            result[tree_index] = last

            if remainder:
                dropped[tree_index] = remainder

        return result, dropped

    current_result, current_dropped = current_accounting(grouped)
    correct_result, correct_dropped = correct_accounting(grouped)

    # Execute the program that a consumer of the lossy legacy result can
    # construct. Only the last selected name from each import statement is
    # available.
    source = """
import lp_shadow_demo_foo as x, lp_shadow_demo_bar
profile.add_imported_function_or_module(lp_shadow_demo_bar)

import lp_shadow_demo_baz as x
profile.add_imported_function_or_module(x)
"""

    profile = RecordingProfiler()
    exec(source, {'profile': profile})

    requested_objects = [
        modules['lp_shadow_demo_foo'],
        modules['lp_shadow_demo_bar'],
        modules['lp_shadow_demo_baz'],
    ]

    missing_objects = [
        obj
        for obj in requested_objects
        if not any(obj is seen for seen in profile.seen)
    ]

    print('Objects requested:')
    print([obj.__name__ for obj in requested_objects])

    print('\nObjects actually profiled:')
    print([obj.__name__ for obj in profile.seen])

    print('\nObjects actually dropped:')
    print([obj.__name__ for obj in missing_objects])

    print('\nCurrent accounting says dropped:')
    print(current_dropped)

    print('\nCorrect statement-local accounting says dropped:')
    print(correct_dropped)

finally:
    for name in module_names:
        sys.modules.pop(name, None)

So... thinking about it. Do we even need this function? extract_all seems to handle it correctly. I don't think this is part of the public API. Can we just hard-deprecate run and raise a RuntimeError if someone calls it? I almost want to just delete it, but we should probably be nice in the rare case someone is using it, and tell them what to call instead.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

IDK... on the one hand, I do think a lot of line_profiler.autoprofile could've been private, and basically has been since a while to the end-user – and yeah, since ProfmodExtractor.run() doesn't work correctly nor can it, it makes a lot of sense to do away with the method. On the other, ProfmodExtractor and its methods (incl. run()) have been public API since as long as auto-profiling has been a thing, and having them in the docs without any additional qualifications only serves to reinforce that. But at the end of the day my take is that maybe we shouldn't entirely drop the method before 6.0.1

Not sure if I follow as to the issue though. I thought the failure that you originally mentioned when shadowing occurs was that since the name is shadowed (and thus not used further down the code), it doesn't matter if it's correctly resolved and/or profiled, and thus reporting it in the warning would've been a false positive.

But looking at the newer example, it seems that your issue is with how we're only (not) reporting that x is dropped, without actually pointing the user towards what x actually is (lp_shadow_demo_foo or lp_shadow_demo_baz)? So in this case, your concern is that it should be reported that... ?

UserWarning: 1 import target(s) dropped in multi-target import statements:
- Line 1: `x` (= `lp_shadow_demo_foo`)

Of course this can be done, but we'll need extra metadata that .extract_all() elided. Maybe we can update ._find_modnames_in_tree_imports()2 to return dict[int, list[_ModuleDict]]3 instead of dict[int, list[str]], and have both .extract_all() and .run() be thin wrappers around that which strip the unnecessary metadata?

Footnotes

  1. Just so that we're on the same page, is the current plan to have this and FEAT: extend profiling to child processes #431 in 5.1 or 6.0? Line Profiler 6.0 Roadmap #374 is there, but IDK where on the timeline we are.

  2. Again this tempers with the return types on methods, but at least it's a private one this time.

  3. BTW I think this module would hugely benefit from having a _ModuleDict typed dict/data-class type, with which we can annotate e.g. the return value of ._ast_get_imports_from_tree(). Maybe I'll go ahead and do just that.

*remainder, last = names
dropped_names.update(remainder)
# In case a later import shadows a dropped name from an
# earlier import
dropped_names.discard(last)
result[i] = last
dropped_names -= set(result.values())
if dropped_names:
msg = (
'{}: {} would-be profiling target(s) dropped because the '
'import statement(s) are multi-target: {!r}'
).format(
self._script_file, len(dropped_names), sorted(dropped_names),
)
_issue_warning(msg, stacklevel=2)
return result


def _issue_warning(
msg: str,
category: type[Warning] | None = None,
stacklevel: int = 1,
*args,
**kwargs,
) -> None:
if category is None:
log_msg = msg
else:
log_msg = f'{category.__name__}: {msg}'
diagnostics.log.warning(log_msg)
warn(msg, category, stacklevel + 1, *args, **kwargs)
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,6 @@ docstring-code-format = false
unused-ignore-comment = "ignore"
unused-type-ignore-comment = "ignore"
unresolved-import = "ignore"

[tool.ty.terminal]
error-on-warning = false # No longer the default since 0.0.52
Loading
Loading