Skip to content

fix #8991 and enhance collection performance #9125

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

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/9125.improvement.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Improve collection performance by caching the internal hook proxy objects.
30 changes: 26 additions & 4 deletions src/_pytest/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@

import attr

try:
from pluggy.hooks import _HookCaller
except ImportError:
from pluggy._hooks import _HookCaller

import _pytest._code
from _pytest import nodes
from _pytest.compat import final
Expand All @@ -34,6 +39,7 @@
from _pytest.config import PytestPluginManager
from _pytest.config import UsageError
from _pytest.config.argparsing import Parser
from _pytest.config.compat import PathAwareHookProxy
from _pytest.fixtures import FixtureManager
from _pytest.outcomes import exit
from _pytest.pathlib import absolutepath
Expand Down Expand Up @@ -478,6 +484,12 @@ def __init__(self, config: Config) -> None:
self.trace = config.trace.root.get("collection")
self._initialpaths: FrozenSet[Path] = frozenset()

# Cache of hookproxy objects, which speeds up collection.
# The cache is cleared whenever a new plugin got registered.
# See #9125 for a detailed summary of the gains.
self._fspath_hookproxy_cache: Dict[
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 the fspath_ part is redundant, just _hookproxy_cache is enough and matches gethookproxy.

os.PathLike[str], Union[_HookCaller, PathAwareHookProxy]
] = {}
self._bestrelpathcache: Dict[Path, str] = _bestrelpath_cache(config.rootpath)

self.config.pluginmanager.register(self, name="session")
Expand Down Expand Up @@ -542,12 +554,20 @@ def isinitpath(self, path: Union[str, "os.PathLike[str]"]) -> bool:
path_ = path if isinstance(path, Path) else Path(path)
return path_ in self._initialpaths

def gethookproxy(self, fspath: "os.PathLike[str]"):
def gethookproxy(
self, fspath: "os.PathLike[str]"
) -> Union[_HookCaller, PathAwareHookProxy]:
# Optimization: Path(Path(...)) is much slower than isinstance.
path = fspath if isinstance(fspath, Path) else Path(fspath)
pm = self.config.pluginmanager
# Check if we have the common case of running
# hooks with all conftest.py files.

pm = self.config.pluginmanager
proxy: Optional[PathAwareHookProxy] = self._fspath_hookproxy_cache.get(fspath)
Copy link
Member

@bluetech bluetech Oct 10, 2021

Choose a reason for hiding this comment

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

The cache should be keyed off path, not fspath, this way it's normalized to a single unsurprising type.

Copy link
Member

Choose a reason for hiding this comment

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

Why the annotation? It looks like it (incorrectly) gets rid of the _HookCaller part of the union.


if proxy is not None:
return proxy

my_conftestmodules = pm._getconftestmodules(
path,
self.config.getoption("importmode"),
Expand All @@ -556,14 +576,16 @@ def gethookproxy(self, fspath: "os.PathLike[str]"):
remove_mods = pm._conftest_plugins.difference(my_conftestmodules)
if remove_mods:
# One or more conftests are not in use at this fspath.
from .config.compat import PathAwareHookProxy

proxy = PathAwareHookProxy(FSHookProxy(pm, remove_mods))
else:
# All plugins are active for this fspath.
proxy = self.config.hook
self._fspath_hookproxy_cache[fspath] = proxy
return proxy

def pytest_plugin_registered(self, plugin):
self._fspath_hookproxy_cache.clear()
Copy link
Member

Choose a reason for hiding this comment

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

im wondering if we could limit the invalidations to conftest registrations (and possibly do partial invalidations later

this is a followup idea

Copy link
Member

Choose a reason for hiding this comment

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

What is the argument that this is sufficient for the cache invalidation here? (would be good as a code comment)

Copy link
Member

Choose a reason for hiding this comment

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

i believe this could need a note on conftests,

i suspect it may be acceptable to do the invalidation only on the registration of conftests


def _recurse(self, direntry: "os.DirEntry[str]") -> bool:
if direntry.name == "__pycache__":
return False
Expand Down