Skip to content

Replace inspect usage with common CPython stdlib pattern #327

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

Sachaa-Thanasius
Copy link

@Sachaa-Thanasius Sachaa-Thanasius commented Apr 6, 2025

Part of #326.

This is the biggest source of import time and hopefully the least controversial to replace; based on local testing, replacing this improves it by ~30% on supported CPython versions and PyPy3.10.

This replaces the expensive inspect import and inspect.stack() usage with an expanded version of a frame inspection pattern used in the CPython standard library.

It's a bit more code, but it's a fairly reasonable amount considering it does the following:

  1. Has various fast paths.
  2. Chooses the fastest working method of inspection at first call to avoid needing to choose again in the future.
  3. Provides robust support for several alternate Python implementations that don't have many, if any, accessible stack frames.
  4. Obviously, avoids an expensive import.

References:

@Sachaa-Thanasius
Copy link
Author

This can be slimmed down (as evidenced by the stdlib's version), but I figured I'd start with the most robust version.

@Sachaa-Thanasius Sachaa-Thanasius changed the title Replace inspect usage with common CPython stdlib pattern. Replace inspect usage with common CPython stdlib pattern Apr 7, 2025
Copy link
Member

@jaraco jaraco left a comment

Choose a reason for hiding this comment

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

Thanks for this PR. It's a great start and definitely a good concrete example of a possible approach. I have a number of concerns, some of which I'm not confident we can resolve easily... and others which we shouldn't bother considering until we've settled on a high level approach.

Let's start by answering my questions and exploring the possibility of supplying a private _inspect.stack() that essentially vendors a lightweight version of inspect.stack() suitable for the purposes here.

except AttributeError: # For platforms without sys._getframemodulename.
global _get_caller_module_name

def _get_caller_module_name(depth: int = 1, default: str = "__main__") -> str:
Copy link
Member

Choose a reason for hiding this comment

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

I don't care for how __main__ gets snuck in here. It wasn't required before. Why have a fallback now (instead of retaining parity)?

Walk the stack and find the frame of the first caller not in this module.
"""
# An expanded version of a CPython stdlib pattern to avoid using the expensive inspect.
def _get_caller_module_name(depth: int = 1, default: str = "__main__") -> str:
Copy link
Member

Choose a reason for hiding this comment

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

I don't love the repetitive nature of this function. For example, the literal "__main__" is used multiple times, and the default depth=1 is mentioned three times. And depth+1 has to be implemented twice. I'd like to see it refactored so that these common concerns are implemented in exactly one place.

Comment on lines +86 to +92
# PYUPDATE: 3.15 - Update depth & explanation for package_to_anchor()'s removal.
# Expected parent stack frames for depth=4:
# 0) resolve()'s singledispatch dispatch
# 1) resolve()'s singledispatch wrapper
# 2) files()
# 3) package_to_anchor()
# 4) <caller>()
Copy link
Member

Choose a reason for hiding this comment

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

I don't love this comment. It's nice to know, but it distracts from the function's purpose and represents tech debt. Usually, I like to abstract tech debt into separate modules that provide the compatible and future uses. If we end up sticking with the static depth for detecting the caller, I'd like to see the depth itself encapsulated in a _caller_depth() function, whose docstring explains why the number 4 is there, when it might need to change, and maybe a static copy of the current call stack (although I think I'd rather omit that instead ensure there are tests capturing the expectation).
Are there tests that fail if the depth doesn't match the implementation?

try:
frame = sys.exc_info()[2].tb_frame # type: ignore[union-attr] # Guarded.
for _ in range(depth + 1):
frame = frame.f_back
Copy link
Member

Choose a reason for hiding this comment

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

I don't love that the current implementation goes from 2 levels of indentation to 6. I'll take it on myself to refactor the implementation to be more of a composition of smaller functions.

for _ in range(depth + 1):
frame = frame.f_back
return frame
except Exception:
Copy link
Member

Choose a reason for hiding this comment

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

I don't love the bare Exception here. What is its purpose?

Comment on lines +141 to +143
try:
raise TypeError
except TypeError:
Copy link
Member

Choose a reason for hiding this comment

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

It took me a while to understand the purpose of this code. It's there to cause the interpreter to generate a stack trace. There should probably be a comment to this effect.

# also exclude 'wrapper' due to singledispatch in the call stack
callers = itertools.filterfalse(is_wrapper, not_this_file)
return next(callers).frame
return _get_frame()
Copy link
Member

Choose a reason for hiding this comment

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

While the previous implementation was just a few lines, this new implementation has a lot of hacks and internal details. We probably should move it all to its own _inspect module.

# 2) files()
# 3) package_to_anchor()
# 4) <caller>()
return resolve(_get_caller_module_name(depth=4))
Copy link
Member

Choose a reason for hiding this comment

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

I'm quite uneasy hard-coding literals (like depth=4). I understand it represents the expected call stack, but now it needs to be kept in sync with the implementation, entangling those concerns. The current implementation avoided entangling those concerns by tracking a more abstract concept (the first caller in the stack that's not this module). Is there a reason not to instead simply implement a replacement for inspect.stack() that avoids inspect?

Comment on lines +127 to +129
# This attempts to support Python implementations that either don't have sys._getframe
# (e.g. Jython) or don't support sys._getframe(x) where x >= 1 (e.g. IronPython).
# We avoid inspect.stack because of how expensive inspect is to import.
Copy link
Member

Choose a reason for hiding this comment

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

I'm not sure it's worth supporting Jython or IronPython. Maybe best would be to simply rely on inspect in those cases.

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.

None yet

2 participants