From 98891a59479e3de6fe4cabc770aaad894211822a Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 21 Aug 2020 17:27:06 +0300 Subject: [PATCH] python: skip pytest_pycollect_makeitem work on certain names When a Python object (module/class/instance) is collected, for each name in `obj.__dict__` (and up its MRO) the pytest_pycollect_makeitem hook is called for potentially creating a node for it. These Python objects have a bunch of builtin attributes that are extremely unlikely to be collected. But due to their pervasiveness, dispatching the hook for them ends up being mildly expensive and also pollutes PYTEST_DEBUG=1 output and such. Let's just ignore these attributes. On the pandas test suite commit 04e9e0afd476b1b8bed930e47bf60e, collect only, irrelevant lines snipped, about 5% improvement: Before: ``` 51195095 function calls (48844352 primitive calls) in 39.089 seconds ncalls tottime percall cumtime percall filename:lineno(function) 226602/54 0.145 0.000 38.940 0.721 manager.py:90(_hookexec) 72227 0.285 0.000 20.146 0.000 python.py:424(_makeitem) 72227 0.171 0.000 16.678 0.000 python.py:218(pytest_pycollect_makeitem) ``` After: ``` 48410921 function calls (46240870 primitive calls) in 36.950 seconds ncalls tottime percall cumtime percall filename:lineno(function) 181429/54 0.113 0.000 36.777 0.681 manager.py:90(_hookexec) 27054 0.130 0.000 17.755 0.001 python.py:465(_makeitem) 27054 0.121 0.000 16.219 0.001 python.py:218(pytest_pycollect_makeitem) ``` --- changelog/7671.trivial.rst | 6 ++++++ src/_pytest/python.py | 23 +++++++++++++++++++++++ testing/python/collect.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+) create mode 100644 changelog/7671.trivial.rst diff --git a/changelog/7671.trivial.rst b/changelog/7671.trivial.rst new file mode 100644 index 00000000000..6dddf4cf042 --- /dev/null +++ b/changelog/7671.trivial.rst @@ -0,0 +1,6 @@ +When collecting tests, pytest finds test classes and functions by examining the +attributes of python objects (modules, classes and instances). To speed up this +process, pytest now ignores builtin attributes (like ``__class__``, +``__delattr__`` and ``__new__``) without consulting the ``python_classes`` and +``python_functions`` configuration options and without passing them to plugins +using the ``pytest_pycollect_makeitem`` hook. diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 21aa8457611..be21b61d61e 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -5,6 +5,7 @@ import itertools import os import sys +import types import typing import warnings from collections import Counter @@ -343,6 +344,26 @@ def reportinfo(self) -> Tuple[Union[py.path.local, str], int, str]: return fspath, lineno, modpath +# As an optimization, these builtin attribute names are pre-ignored when +# iterating over an object during collection -- the pytest_pycollect_makeitem +# hook is not called for them. +# fmt: off +class _EmptyClass: pass # noqa: E701 +IGNORED_ATTRIBUTES = frozenset.union( # noqa: E305 + frozenset(), + # Module. + dir(types.ModuleType("empty_module")), + # Some extra module attributes the above doesn't catch. + {"__builtins__", "__file__", "__cached__"}, + # Class. + dir(_EmptyClass), + # Instance. + dir(_EmptyClass()), +) +del _EmptyClass +# fmt: on + + class PyCollector(PyobjMixin, nodes.Collector): def funcnamefilter(self, name: str) -> bool: return self._matches_prefix_or_glob_option("python_functions", name) @@ -404,6 +425,8 @@ def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]: # Note: seems like the dict can change during iteration - # be careful not to remove the list() without consideration. for name, obj in list(dic.items()): + if name in IGNORED_ATTRIBUTES: + continue if name in seen: continue seen.add(name) diff --git a/testing/python/collect.py b/testing/python/collect.py index 778ceeddf8d..ab4a6fbb832 100644 --- a/testing/python/collect.py +++ b/testing/python/collect.py @@ -885,6 +885,34 @@ def test_something(): result = testdir.runpytest_subprocess() result.stdout.fnmatch_lines(["*1 passed*"]) + def test_early_ignored_attributes(self, testdir: Testdir) -> None: + """Builtin attributes should be ignored early on, even if + configuration would otherwise allow them. + + This tests a performance optimization, not correctness, really, + although it tests PytestCollectionWarning is not raised, while + it would have been raised otherwise. + """ + testdir.makeini( + """ + [pytest] + python_classes=* + python_functions=* + """ + ) + testdir.makepyfile( + """ + class TestEmpty: + pass + test_empty = TestEmpty() + def test_real(): + pass + """ + ) + items, rec = testdir.inline_genitems() + assert rec.ret == 0 + assert len(items) == 1 + def test_setup_only_available_in_subdir(testdir): sub1 = testdir.mkpydir("sub1")