Skip to content

FIX: handling multi-target (from-)import statements#434

Open
TTsangSC wants to merge 8 commits into
pyutils:mainfrom
TTsangSC:433-fix
Open

FIX: handling multi-target (from-)import statements#434
TTsangSC wants to merge 8 commits into
pyutils:mainfrom
TTsangSC:433-fix

Conversation

@TTsangSC

@TTsangSC TTsangSC commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Closes #433.

Changes

  • Refactored line_profiler.autoprofile.profmod_extractor.ProfmodExtractor:
    • .run() is now deprecated with a DeprecationWarning, and in addition a UserWarning when using it results in profiling targets being dropped from the output.
    • The new .extract_all() method returns instead dict[tuple[str | int, ...], list[AstTreeProfiler]] so that multi-target imports can be handled, and that we retain enough flexibility to handle other cases like star-imports and conditional imports via future extensions, without having to change the API like in this PR.
    • Added stop-gap handling for from ... import * statements (see item F4 in fable-review-fullrepo-2026-07-05.md in Fable review of profile child process TTsangSC/line_profiler#5): instead of resulting in syntactically wrong profile.add_imported_function_or_module(*) statements, star-imports are now dropped from the output of .run() and .extract_all() with a warning.
  • Introduced a new line_profiler.autoprofile.profmod_extractor.ImportTarget class for hauling info around.
  • line_profiler.autoprofile.ast_tree_profiler.AstTreeProfiler has been correspondingly updated to insert prof.add_imported_function_or_module() statements for each eligible target in multi-target imports.
  • Added ty config line to pyproject.toml to guard against v0.0.52's making redundant casts an error.
  • Added new tests in tests/test_autoprofile.py to verify the fixes:
    • test_multitarget_import_resolution() and test_multitarget_import_transformation_execution() check that AstTreeProfiler.profile() now correctly transforms ASTs with multi-target import statements.
    • test_profmod_extractor_multitarget_behavior() checks that ProfmodExtractor.run()'s return type and warning issuance are as aformentioned.

@codecov

codecov Bot commented Jun 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.30769% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.59%. Comparing base (5845332) to head (80c6002).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
line_profiler/autoprofile/profmod_extractor.py 92.50% 4 Missing and 5 partials ⚠️
line_profiler/autoprofile/ast_tree_profiler.py 91.30% 1 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #434      +/-   ##
==========================================
+ Coverage   82.76%   84.59%   +1.82%     
==========================================
  Files          20       20              
  Lines        2298     2395      +97     
  Branches      361      376      +15     
==========================================
+ Hits         1902     2026     +124     
+ Misses        300      271      -29     
- Partials       96       98       +2     
Files with missing lines Coverage Δ
line_profiler/autoprofile/ast_tree_profiler.py 96.61% <91.30%> (+10.56%) ⬆️
line_profiler/autoprofile/profmod_extractor.py 87.84% <92.50%> (+26.84%) ⬆️

... and 2 files with indirect coverage changes


Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 7c1e41f...80c6002. 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
Copy Markdown
Member

Given that now we have #437 in the queue, perhaps it makes sense to merge that and this before #431

@TTsangSC

TTsangSC commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator Author

Fair enough. Should I also begin working on from <module> import * (fable-review-fullrepo-2026-07-05.md‎ item F4 in TTsangSC#5)? Or should we deal with #431 first before starting that?

@Erotemic Erotemic left a comment

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.

Overall the patch looks right, there are few minor polishing issues to take care of, and then we can merge.

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.


def run(
self, *, assume_single_target_imports: bool = True,
) -> 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.

This new return type is introduced in this PR? We might simplify the typing if we just deprecate this function and introduce a new one with the new types.

@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.

... yeah on second thought that's definitely the way to go, IDK what I was on (probably too much chips and cola).

Code coverage should still be preserved with minimal additional fixes to the test suite given that we're already directly constructing a ProfmodExtractor in test_profmod_extractor_multitarget_behavior().

We may want to think a bit more about what the new method looks like. Maybe

def extract_all(self) -> dict[int, list[str]]:
    ...

and then .run() could just become

def run(self) -> dict[int, str]:
    warnings.warn(DeprecationWarning, ...)
    result: dict[int, str] = {}
    dropped: set[str] = set()
    for loc, names in self.extract_all().items():
        *remainder, last = names
        dropped.update(remainder)
        result[i] = last
    if dropped:
        warnings.warn(...)
    return result

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.

extract_all makes sense. Nothing is really "running". I think that's a better method name.

Comment thread tests/test_autoprofile.py Outdated
r"2 .* target.* dropped .* \['ham', 'spam'\]")])
def test_profmod_extractor_multitarget_behavior(
prof_mod: list[str],
expected: dict[str, int] | dict[str, list[int]],

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.

Key and value type ares reversed here. Are we not running type checks on the tests? We probably should be. I'll have to check that.

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.

Good catch(es).

  • ... yeah no, there's currently no type checks for the test suite on CI. Probably intentional because most of the older tests aren't type-annotated and would've been skipped (at least by mypy, probably not ty) anyway.
  • When writing new tests I always type-annotate them and run them through both mypy and ty. On local, at least.
  • But this one's on me and is really unfortunate – expected is only used in one place in the test (equality check with the return value), and as a result anything goes.

Will fix.

@TTsangSC TTsangSC Jul 19, 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.

Update: ty is very noisy when applied to the (older) test modules.

  • The number 1 reason is of course that it peeks into untyped functions with (AFAIK) no option to turn that off, unlike mypy's --[no]-check-untyped-defs.
  • The number 2 reason is that a lot of tests use ubelt.util_cmd.cmd(...) -> CmdOutput, and since unlike subprocess.CompletedProcess ubelt.util_cmd.CmdOutput isn't parametrized (see typeshed/stdlib/subprocess.pyi), ty has to assume the worst about CmdOutput.stdout and .stderr (that they are str | bytes | None). But even with parametrization, introducing all the @overloads upstream in ubelt to make this work is probably gonna be a huge PITA.

Comment thread tests/test_autoprofile.py
depr_warning_pattern = 'assume_single_target_imports'
targets_warning_pattern = 'profiling target.* dropped.* multi-target'
checks: list[Callable[[Sequence[WarningMessage]], None]] = []
with contextlib.ExitStack() as stack:

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.

Your temp dir helper from the other PR probably cleans this up a bit? It can stay as is for this one.

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.

Here we don't (nor do we need to) chdir, just a tempdir to hold the singular tempfile. Semantically tempfile.NamedTemporaryFile would've been a better fit, but I've had bad experience of that misbehaving esp. on Windows. And we'd still need the ExitStack either way because we want to catch and analyze the warnings.

As for tests/test_kernprof.py::chdir_temp, I wonder if it makes better sense to (1) patch it up into a fixture and plonk it into a conftest.py, or (2) make a new test_<sometime>_utils.py and have tests import it from there? Probably the latter, fixture-izing it has the disadvantage of more coarse-grained lifetime control (the tempdir itself and the cd would be alive for the entirely of the test).

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.

experience of that misbehaving esp. on Windows

I've had the same bad experience.

I've probably said this before, but I really don't like fixtures. They feel too magic for me. I want to trace any statement I express to either the base language or a line that imports it from somewhere else. I don't want to have to be aware of magic context when I run code - or have to explain it to someone else when I'm teaching them.

That being said, I've acquiesced on this. Fixtures are ubiquitous and for some reason other good developers seem to like them. But in this case, I do prefer the test_<sometime>_utils.py approach.

Comment thread tests/test_autoprofile.py Outdated
if assume_single_target_imports is None: # Default
result: dict[int, str] | dict[int, list[str]] = extractor.run()
else:
result = extractor.run( # `mypy` needs a bit of help here

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.

Mypy is failing here because I think you are missing an explicit overload on bool. The reason is very ugly.

Consider:

from typing import Literal, overload


@overload
def func(flag: Literal[True]) -> str: ...


@overload
def func(flag: Literal[False]) -> int: ...


def func(flag: bool) -> str | int:
    return "yes" if flag else 0


literal_result = func(True)  # OK: str

flag: bool = bool(input())
dynamic_result = func(flag)  # Type-checker error



@overload
def func2(flag: Literal[True]) -> str: ...


@overload
def func2(flag: Literal[False]) -> int: ...


# Yeah, this is unintuitive and ugly.
# The function itself doesn't count
@overload
def func2(flag: bool) -> str | int


def func2(flag: bool) -> str | int:
    return "yes" if flag else 0


flag: bool = bool(input())
dynamic_result = func2(flag)  # no issue

Reason is documented here.

The non-@overload-decorated definition, meanwhile, will be used at runtime but should be ignored by a type checker

I'm just learning about this. It feels very ugly. The reason seems to be the runtime function needs a broader scope that encompass everything, but if that broad scope is also valid, it would be nice if typing gave a way to mark it as such.

Comment thread tests/test_autoprofile.py Outdated

warnings = stack.enter_context(catch_warnings(record=True))
if assume_single_target_imports in (True, None):
checks.append(partial(

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 just a test so I'm not going to block on this, but for whatever reason building up lists of partial functions and executing them feels obfuscating to me. I generally prefer patterns where I build up some concrete set of data and then make the execution dependent on the data, rather than difficult to inspect function pointers.

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.

If I'm understanding correctly, you mean that you'd prefer something like ...?

checks: list[tuple[bool, str, type[Warning]]] = []
if assume_single_target_imports in (True, None):
    checks.append((True, depr_warning_pattern, DeprecationWarning))
...
for warning_expected, pattern, WarningType in checks:
    regex = re.compile(pattern)
    matches: list[WarningMessages] = [
        msg for msg in msgs
        if issubclass(msg.category, WarningType)
        if regex.search(msg.message)
    ]
    assert bool(matches) == warning_expected, ...  # TODO: format the message

I guess it is clearer this way and avoids an additional stack frame. Will do.

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.

Yeah, roughly. My default is almost always to think in terms of List[Dict] row organized data frames, so the names are clear at pack and unpack times. But it is unnecessary overhead. A list[tuple] is perfectly clear here, and I'm starting to get used to the pattern now that I've been diving into rust.

The main issue I like to avoid is having to reason about what exact function is being called via the symbols used in the code. This is much more explicit and easier to reason about. (for me at least)

)
issue_warning(msg, DeprecationWarning, stacklevel=2)
conflated_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.

Comment thread tests/test_autoprofile.py
f'^Function: {prefix}{func}', raw_output, re.MULTILINE
)
assert bool(in_output) == (func in profiled_funcs)

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.

Might also include an executable variant of the tests. This was suggested by GPT 5.6

Suggested change
def test_multitarget_import_transformation_executes() -> None:
"""
Test the runtime behavior of the transformed AST, including:
- multiple targets in one import statement;
- aliases;
- preservation of profiling-call order;
- passing the actual imported objects to the profiler.
"""
from xml.etree.ElementTree import Element, XMLParser
input_module = ub.codeblock(
"""
import os, sys as system
from xml.etree.ElementTree import Element, XMLParser as Parser
"""
)
with tempfile.TemporaryDirectory() as tmp:
fpath = ub.Path(tmp) / 'script.py'
fpath.write_text(input_module)
module_ast = AstTreeProfiler(
str(fpath),
[
'os',
'sys',
'xml.etree.ElementTree.Element',
'xml.etree.ElementTree.XMLParser',
],
False,
).profile()
profiled_objects = []
class RecordingProfiler:
def add_imported_function_or_module(self, obj) -> None:
profiled_objects.append(obj)
namespace = {
'profile': RecordingProfiler(),
}
code = compile(module_ast, str(fpath), 'exec')
exec(code, namespace)
assert profiled_objects == [
os,
sys,
Element,
XMLParser,
]
# Also verify that the aliases created by the original imports resolve
# to the same objects that were passed to the profiler.
assert namespace['system'] is sys
assert namespace['Element'] is Element
assert namespace['Parser'] is XMLParser

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.

Actually I just realized that this is largely covered by test_multitarget_import_resolution() which directly checks the ast.unparse()-ed source code, but of course it doesn't hurt to have a more "in-code" test that directly works with objects and namespaces. I've also taken the liberty to update this new test with a target which isn't selected by prof_mod.

Comment thread tests/test_autoprofile.py Outdated
(['foo', 'foobar.ham'], {0: 'foo', 2: 'ham'}, True, None),
# Also test not passing `assume_single_target_imports` (defaults to
# true)
(['foo', 'foobar.ham'], {0: 'foo', 2: 'ham'}, True, None),

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
(['foo', 'foobar.ham'], {0: 'foo', 2: 'ham'}, True, None),
(['foo', 'foobar.ham'], {0: 'foo', 2: 'ham'}, None, None),

Should the True be None? Otherwise this is just the same as the previous case.

Comment thread tests/test_autoprofile.py Outdated

- Warn against dropped profiling targets (if any)

``assume_single_target_imports=True``

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
``assume_single_target_imports=True``
``assume_single_target_imports=False``

Typo?

@Erotemic

Copy link
Copy Markdown
Member

Fair enough. Should I also begin working on from <module> import * (fable-review-fullrepo-2026-07-05.md‎ item F4 in TTsangSC#5)? Or should we deal with #431 first before starting that?

Either way is fine. You're doing all the hard work. At this point, I'm just acting as the skeptical independent quality gate for your excellent contributions.

TTsangSC added 5 commits July 18, 2026 18:33
line_profiler/autoprofile/
    ast_tree_profiler.py::AstTreeProfiler.profile()
        Updated implementation to account for multiple import targets
        in the same (from-)import statement
    profmod_extractor.py
        - Removed unused typing imports
        - Updated implementation and return type of
          `ProfmodExtractor.run()` to account for multiple import
          targets in the same (from-)import statement

XXX:
    should we add a switch for `ProfmodExtractor.run()` so that it
    behaves as before by default?
CHANGELOG.rst
    Added entry

tests/test_autoprofile.py::test_multitarget_import_resolution()
    New parametrized test testing that issue pyutils#433 has been fixed
line_profiler/autoprofile/ast_tree_profiler.py::AstTreeProfiler
    __init__()
        Updated type hints for `ast_transformer_class_handler` and
        `profmod_extractor_class_handler` to be more specific
    profile()
        Updated call to `ProfmodExtractor.run()` to include
        `assume_single_target_imports=False` (see below)

line_profiler/autoprofile/profmod_extractor.py::ProfmodExtractor.run()
    Updated call signature:
    - New parameter `assume_single_target_imports` defauling to true,
      restoring the legacy behavior
    - If `assume_single_target_imports` is true, a `DeprecationWarning`
      is issued; and if it results in an valid profiling target being
      dropped, a `UserWarning` is issued
    - If false, the new behavior (returning a `dict[int, list[str]]`) is
      used

tests/test_autoprofile.py::test_profmod_extractor_multitarget_behavior()
    New parametrized test to cover the aforementioned warning behavior,
    ensure that backward compatibility is retained while users are
    warned against the shortcomings
@TTsangSC

Copy link
Copy Markdown
Collaborator Author

Thanks for the review and the inputs. Will work on them after dinner.

IDK if this is the right place, but I just want to say that I'm glad and grateful that you're here to QA (and maintain the repo in general) – in this day and age code is cheap, and QA is only getting more and more important.1 LLMs have their uses, as was well-demonstrated by your TTsangSC#5;2 but I'd imagine that you did a lot of prompt engineering,3 planning, harnesses, and precautions behind the scene, for things to have gone as well as they did. And when more and more people just use the tools to churn out code and issues en masse, unfortunately without such degree of thought and care... yeah.

Footnotes

  1. Read: the last line of defense getting increasingly embattled by the day, especially in OSS. Luckily this repo seem to been spared by the slop onslaught so far, looking at the recent issues and PRs – hopefully not jinxing it.

  2. Honestly, as a 100% manual coder to date (not even having used copilot4), your PR was kinda eye-opening as to how well they can do.

  3. Or as some circles would call it, "incantations"

  4. Hopefully admitting to this isn't gonna be a detriment to my future career. Oh well.

@Erotemic

Copy link
Copy Markdown
Member

IDK if this is the right place

Yes it is. If you're feeling it, and then its the right place. I often feel like the "this is not the right place" is more often made to silence people and dismiss unpleasant realities than it is to guide productive conversation.

Honestly, as a 100% manual coder to date (not even having used copilot4), your PR was kinda eye-opening as to how well they can do.

I strongly encourage you not to sleep on LLMs. I'm biased as I'm an AI researcher. But they are the real deal. At least the frontier ones are. I'm holding out that Qwen4 can start to approach opus or gpt 5.5 level. Not a huge fan of the power concentration and sociopolitical structure around them, but from an objective "I want to do good work and make the world a better place" standpoint, their value is unquestionable IMHO. July of 2025 was when I think they really crossed the threshold towards true usefulness.

Luckily this repo seem to been spared by the slop onslaught so far, looking at the recent issues and PRs – hopefully not jinxing

I'm actually a little offended none of my repos are getting hit, perhaps they aren't big enough targets 🤷. My view on it is that they have lowered the bar required to contribute or get your foot in the door. I view that as a good thing. I'm not a big fan of gate-keeping. I think they go a long way towards empowering people to do incredible new things. But it's not all good, and there are major growing pains right now. I hope my optimism is well-placed. But I think we need to start expecting more from people. There needs to be a higher bar on critical thought, inquisitiveness, and integrity. Not knowing about something existent hasn't been an excuse since Wikipedia was a thing, and now its even less of an excuse.

you did a lot of prompt engineering

Probably more than I think. I'm a trained scientist, I consider myself a critical thinker, and I place an enormous value on truth and honesty. Sadly those terms have been co-opted these days. But I don't know any better words. I don't have the full trace for my prompt history to get the fable review, but it looks like I started off by working with GPT 5.5 just pointing it at the URLs for these PRs and then I had it build a prompt for Fable. Note: GPT 5.6 is a big leap over 5.5, but it wasn't out at the time, and 5.5 can be iffy at points. In any case the prompt it constructed was something like:

Details

You are a very strong code-review and architecture agent working on pyutils/line_profiler.\n\nYour first priority is to verify whether TTsangSC’s changes are valid, correct, maintainable, and do not introduce new issues. Inspect the PR branch against main, run focused tests, and identify bugs, regressions, overclaims, missing tests, bad abstractions, and risky semantics.\n\nYour second priority is a broader repo review: use this PR as an entry point, but scrutinize the whole project for correctness, elegance, maintainability, packaging, CI, test quality, API stability, and performance issues. README and docs review are out of scope, but docstring review is not. Anything in the module directory is fair game, and so are all of the infrastructure tools, but keep in mind much of the CI is generated by /home/joncrall/code/xcookie, so improvements to the CI or generated scripts need to be written as improvements to xcookie (which is also in scope).\n\nYour primary output should be a committed review/plan in the repository. It should clearly identify the core issues and provide a detailed remediation plan that a weaker agent can follow to bring the PR and adjacent code into the best state. Significant changes in direction must be justified deeply. Smaller obvious improvements can be concise.\n\nDo not primarily fix code. However, if you find tasks that are too subtle or high-risk for an Opus 4.8 / GPT-5.5-level agent to safely execute later from the written plan, mention that and let me know explicitly at the end so I can review and determine if I want you to attack them directly or not.\n\nThis is not a vibe-coded project. Assume a human will carefully audit every line. LLM contributions are acceptable only when the reasoning is strong, the changes are reviewable, and the correctness argument is clear.\n\n---\n\nThis summary was prepared by GPT-5.5 Thinking after reading the PR and current patch state, but you must independently verify all claims from the code:\n\nYou have a fresh checkout with main and the PR branch for PR #431, “FEAT: extend profiling to child processes.” \n\nPR #431 adds experimental kernprof support for profiling child Python processes. The user-facing flags are --prof-child-procs[=...] and --no-prof-child-procs. The intended feature is broader than just multiprocessing: a profiled Python process should propagate profiling into child Python interpreters created through mechanisms such as subprocess, os.system, and multiprocessing, then gather and merge child profile stats into the parent output.\n\nThe important support boundary is:\n\n* Supported target: cooperative Python child processes that inherit the profiling environment, start through normal Python startup, and either exit normally or reach an explicit profile flush point.\n* Best-effort target: children that error but still reach cleanup or a flush point.\n* Not guaranteed: hard-killed or crashed children, SIGKILL, os._exit, Windows hard termination, skipped Python startup such as -S, non-Python children, cleared environments, embedded/frozen runtimes not explicitly supported, or any child that dies before Python-level cleanup/profile flushing can run.\n\nThis is a fundamental process-semantics boundary, not just a line_profiler limitation. On Linux, SIGKILL cannot be caught, blocked, or ignored, so no Python cleanup handler can run. The implementation should make this boundary explicit rather than imply perfect profiling under arbitrary termination.\n\nRelevant PR discussion context:\n\n* Reviewer concern: the .pth hook might import too much of line_profiler for every Python process sharing the environment. Current patch appears to address this by adding a tiny top-level _line_profiler_hooks.py module with deferred imports and an early env/parent-PID guard.\n* Reviewer concern: defective or empty child .lprof files should probably warn, not be silently ignored. Current patch appears to default gather_stats(..., on_empty='warn', on_defective='warn'), while tracking known helper/idle PIDs to suppress expected empty outputs.\n* Reviewer concern: retry-marked tests may hide nondeterministic races. Author explained that failing multiprocessing workloads can leave workers in limbo, especially around termination and Windows signal semantics. Current design appears to have shifted toward earlier cooperative flushing, especially in Pool workers, which may reduce reliance on termination handling.\n* Reviewer concern: explicit Process.terminate() behavior and patched/unpatched equivalence. Current design appears less centered on delaying termination and more centered on writing stats before task results return or at direct Process bootstrap exit.\n* Reviewer concern: a cleanup argument was unused. Current code likely addressed this during the cleanup/patch-priority refactor.\n* Author previously debugged two difficult areas: legacy tracing vs sys.monitoring behavior in threaded / multiprocessing.dummy cases, and Linux forkserver behavior where child setup did not follow the expected runpy.run_path path.\n\nCurrent patch shape to verify:\n\n* _line_profiler_hooks.py: minimal startup hook for temporary .pth files.\n* LineProfilingCache: serialized session state, env var injection, cache directory, .pth hook writing, child setup, stats dumping/gathering, cleanup, debug logs.\n* multiprocessing_patches/: split into registry/infrastructure, mandatory patches, profiling patches, optional logging patches, queue wrappers, and config.\n* Pool workers appear to flush profile stats at cooperative points before sending task results to the parent.\n* Direct multiprocessing.Process children appear to dump stats atBaseProcess._bootstrap exit.\n* Pool workers are marked so Pool-managed processes and direct Process objects can be handled differently.\n* Forkserver is rebooted using private CPython internals so long-lived forkserver state inherits the right profiling environment and does not leak profiling state after cleanup.\n* Resource tracker PIDs are tracked so expected empty files from helper processes do not create noisy warnings.\n* Tests were expanded from a single tests/test_child_procs.py into a package with utilities and separate multiprocessing example modules.\n\nGPT5.5 current assessment:\n\n* The architectural direction is promising and substantially better than earlier iterations.\n* The elegant design choice is separating activation from finalization:\n\n * activation: ensure child Python interpreters start with the right profiling session;\n * finalization: gather completed child stats, while making incomplete child data visible and diagnosable.\n* The strongest improvement appears to be moving from “try to make termination reliable” toward “flush at cooperative boundaries before termination becomes relevant.”\n* The riskiest code is likely around Pool queue wrapping, multiprocessing private internals, forkserver lifecycle, warning suppression, cleanup ordering, stale .pth files, nested/concurrent sessions, and platform/version differences.\n* CI is currently green\n\nFocus especially on:\n\n* whether the child-process profiling support contract is correct and documented;\n* whether --prof-child-procs overclaims;\n* startup overhead and .pth file failure modes;\n* env var leakage and cleanup;\n* nested or concurrent profiling sessions;\n* direct fork, spawn, and forkserver behavior;\n* private multiprocessing API usage;\n* Pool worker queue wrapping and result semantics;\n* direct Process behavior;\n* failure, exception, terminate, and hard-kill behavior;\n* empty/corrupt .lprof handling;\n* warning quality and false suppression;\n* debug logging usefulness;\n* interaction with legacy tracing, sys.monitoring, and threads;\n* performance overhead, especially per-task flushing;\n* test determinism and retry usage;\n* packaging and installation implications of _line_profiler_hooks.py;\n* impact on existing line_profiler public behavior.

And this is probably from me throwing around the words "elegant" a lot. I always ask it: "Is this code the elegant way to accomplish the goal"? Or something to that effect. I try to use the Socratic method with it, so I force a debate to try and poke holes in ideas that are presented. My actual prompt I type aren't nearly that long, but I'll often be discussing plans with one LLM and then when we seem to come to a consensus I tell it to write a prompt for another agent.

I think I only added one prompt after fable did its thing, and that was with an opus model:

Details

I added .llm_resource_tally and I want it commited and I want to start tracking our utilization precisely. Make sure the ledger is backfilled. I want you to fix the fable-hard problems on this new branch I've started. Commit the fable-hard changes as logical commits, and if there is any hard fix that is independent from TTsangSC's branch then put those at the end, but on top of the branch. I want to submit a PR of this branch to TTsangSC's branch, then I will merge that into main, and then finally I will merge the non-PR related changes - that is, if there are fable hard problems in the larger repo even on the main branch - as a separate PR, but still built on top of this state, so we are targeting building a clean and pristine package that is safe to release. I will then have Opus work on the rest of the problems. Note, I'm not sure if all those tasks are related to this PR or not, or if you checked the larger state of the repo well enough to know if there are fable-hard problems in some subtle way we are accessing memory in C or something like that.

Note, in the larger repo I would like to improve the robustness of the AST pipeline, and pyi files will all go away for modern type annotations. I want those to be reflected in the larger plan.

Put the plans reviews in dev/planning and logically separate the files as fable-review-pr431-.md fable-review-fullrepo-.md fable-pr431-plan-.md, fable-fullrepo-plan-.md and the plans should serve as a central place where agents can update with what they did and post questions for other agents or humans to adjudicate.

precautions behind the scene, for things to have gone as well as they did

Really it's the same precautions I use with people. I ask questions where things don't make sense and I try to cut to the technical core of the problem.

Hopefully admitting to this isn't gonna be a detriment to my future career. Oh well

I find myself wondering the same thing. We still need to solve the power problem and scale these things down, but the future is far more uncertain than its ever been for me. I really thought vision was going to be the problem that neural networks would solve - and they are - but the NLP folks surprised me. I really didn't expect the Turing test to go down in my lifetime, but here we are.

line_profiler/autoprofile/ast_tree_profiler.py
::AstTreeProfiler.profile()
    Now calling `ProfmodExtractor.extract_all()`

line_profiler/autoprofile/profmod_extractor.py
    ProfmodExtractor.run()
        - Reverted the `assume_single_target_imports` argument
          (071222a); the whole method (1) always use the pre-PR type
          signature and (2) is just deprecated
        - Added a check that a dropped name isn't shadowed by a later
          import so as to reduce false alarms
    ProfmodExtractor.extract_all()
        New method behaving like `assume_single_target_imports=False`
        before this commit, returning `dict[int, list[str]]` instead of
        `.run()`'s `dict[int, str]`
    _issue_warning()
        New private function extracted from inside
        `ProfmodExtractor.run()`; fixed warning attribution by adding a
        `stacklevel` to the `warnings.warn()` call

tests/test_autoprofile.py
    test_profmod_extractor_multitarget_behavior()
        - Replaced parameter `assume_single_target_imports` with
          `method`, which chooses whether to call
          `ProfmodExtractor.run()` or `.extract_all()`
        - Fixed type annotation of parameter `expected`
        - Added subtests for when a later imports shadows a name in an
          older one
        - Refactored implementation to be more explicit and readable
          with the warning-message checks
    test_multitarget_import_transformation_executes()
        New test similar to `test_multitarget_import_resolution()` using
        `AstTreeProfiler()` to check the transformed code, except that
        instead of inspecting the code text, we `exec()` the code and
        verify its effects on a namespace

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().

_issue_warning(msg, DeprecationWarning, stacklevel=2)
result: dict[int, str] = {}
dropped_names: set[str] = set()
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.

line_profiler/autoprofile/profmod_extractor.py::_ImportTarget
    New dataclass as a more structured alternative to the dicts
    returned by the old

    .from_ast_nodes()
        Refactored from
        `ProfmodExtractor._ast_get_imports_from_tree()`, returning a
        list of the class from a sequence of nodes (so that it can be
        later refactored with ease for imports nested in other AST
        nodes (e.g. `FunctionDef.body`, `Try.orslse`))

line_profiler/autoprofile/profmod_extractor.py::ProfmodExtractor
    ._ast_get_imports_from_tree()
        Now a thin wrapper around `_ImportTarget.from_ast_nodes()`,
        returning a list of `_ImportTarget`s
    ._find_modnames_in_tree_imports()
        Each dict value in the return value now a `list[_ImportTarget]`
        instead of `list[str]`
    ._extract_all()
        - Refactored from `.extract_all()`, returning
          `dict[int, list[_ImportTarget]]` instead of
          `dict[int, list[str]]`
        - Now gracefully handling `from ... import *` statements (see
          item F4 in
          `fable-review-fullrepo-2026-07-05.md` of #5):
          instead of erroring out, we now issue a warning that it is not
          currently supported
    .extract_all()
        - Fixed erroneous return-value annotation in docstring
        - Now a thin wrapper around `._extract_all()`
    .run()
        Now wrapping around `._extract_all()` instead of
        `.extract_all()`, so as to emit more detailed warning messages
        with fully-qualified import-target names

tests/test_autoprofile.py::test_profmod_extractor_multitarget_behavior()
    - Updated the warning messages tested for
    - Added subtests where target-dropping by `ProfmodExtractor.run()`
      happens on multiple import statements (thus changing how the
      warning message is formatted)
    - Added subtests for warnings emitted when trying to profile
      `from ... import *` statements

TODO: fix star-imports in a future PR
@TTsangSC

TTsangSC commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Did a bit of refactoring of ProfmodExtractor for more informative warning messages, as well as to guard against from ... import * statements, which now result in warnings (that we don't current support those) instead of SyntaxErrors. I've also introduced the _ImportTarget class which replaces the dict[str, str | int | None] in the previous code, hopefully making for easier maintenance and better readability, and enabling future extensions (e.g. handling star-imports).

CI seems to be stuck in limbo though – it's been 15 full minutes and none of the jobs has started. Any idea why?

EDIT ah yeah Actions are borked.

EDIT 2 fixed

line_profiler/autoprofile/ast_tree_profiler.py::AstTreeProfiler
    ._profile_ast_tree()
        Updated to accommodate the new return type of
        `ProfmodExtractor.extract_all()` (see below)
    ._descend()
        New helper method for descending into nested objects given a
        chain of indices and attribute names

line_profiler/autoprofile/profmod_extractor.py
    ImportTarget
        - Renamed from `_ImportTarget`
        - Added caution to delineate the public API
        - Renamed method `.from_ast_nodes()` -> `._from_ast_nodes()`
        - Removed property `._star_import_source`
        - Property `.resolved_name` is now `None` for star-imports
    ProfmodExtractor
        ._ast_get_imports_from_tree()
            Changed key type in returned dict to
            `tuple[str | int, ...]]` in preparation of allowing nested
            imports (e.g. conditional imports) to be processed
        .extract_all()
            - Supersedes `._extract_all()`
            - Changed return type to
              `dict[tuple[str | int, ...], list[ImportTarget]]` to allow
              for future extensiblity (e.g. handling nested imports and
              star-imports)
            - Updated implementation
            - Now clearly documenting that only top-level imports are
              currently handled
        .run()
            - Updated implementation
            - Now clearly documenting that only top-level imports are
              handled

tests/test_autoprofile.py::test_profmod_extractor_multitarget_behavior()
    Updated implementation so that the parametrization remains simple:
    we only supply the `Module.body` indices in the dict keys, and the
    lists of `ImportTarget.resolved_name`s in the dict values
@TTsangSC

Copy link
Copy Markdown
Collaborator Author

Just realized that since we're going through the trouble of introducing a new method which is technically public API to replace a deprecated one (again, the public methods of ProfmodExtractor are all implied to be public by naming and by documentation), we might as well do it in a way that is future-proof. However, the current return type dict[int, list[str]] is patently not so:

As such, I've refactored ProfmodExtractor.extract_all() more radically and now it returns dict[tuple[str | int, ...], list[ImportTarget]]; the tuple[str | int, ...] keys are currently always ('body', <int>), but we're leaving ourselves options to generalize to nested imports. And the list[ImportTarget] values should make it easier to preserve enough info so that AstTreeProfiler._profile_ast_tree() can take them and generate the nodes for handing star-imports.

@Erotemic

Copy link
Copy Markdown
Member

is technically public API

I feel like if it isn't a top level __init__.py exposed definition, we shouldn't consider it a public API. Perhaps we can make that more explicit with adding an empty __all__ in the modules we don't want to declare as public. Yes, there is the standard convention of underscore prefix, but that's just a convention. I don't think we have an explicit list of things considered public API, and we probably should.

In the meantime, I think just having the RuntimeError is fine, and we shouldn't overthink it.

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.

autoprofile cannot handle multiple imports on the same line

2 participants