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)
```
			
			
This commit is contained in:
		
							parent
							
								
									8730a7bb14
								
							
						
					
					
						commit
						98891a5947
					
				|  | @ -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. | ||||||
|  | @ -5,6 +5,7 @@ import inspect | ||||||
| import itertools | import itertools | ||||||
| import os | import os | ||||||
| import sys | import sys | ||||||
|  | import types | ||||||
| import typing | import typing | ||||||
| import warnings | import warnings | ||||||
| from collections import Counter | from collections import Counter | ||||||
|  | @ -343,6 +344,26 @@ class PyobjMixin: | ||||||
|         return fspath, lineno, modpath |         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): | class PyCollector(PyobjMixin, nodes.Collector): | ||||||
|     def funcnamefilter(self, name: str) -> bool: |     def funcnamefilter(self, name: str) -> bool: | ||||||
|         return self._matches_prefix_or_glob_option("python_functions", name) |         return self._matches_prefix_or_glob_option("python_functions", name) | ||||||
|  | @ -404,6 +425,8 @@ class PyCollector(PyobjMixin, nodes.Collector): | ||||||
|             # Note: seems like the dict can change during iteration - |             # Note: seems like the dict can change during iteration - | ||||||
|             # be careful not to remove the list() without consideration. |             # be careful not to remove the list() without consideration. | ||||||
|             for name, obj in list(dic.items()): |             for name, obj in list(dic.items()): | ||||||
|  |                 if name in IGNORED_ATTRIBUTES: | ||||||
|  |                     continue | ||||||
|                 if name in seen: |                 if name in seen: | ||||||
|                     continue |                     continue | ||||||
|                 seen.add(name) |                 seen.add(name) | ||||||
|  |  | ||||||
|  | @ -885,6 +885,34 @@ class TestConftestCustomization: | ||||||
|         result = testdir.runpytest_subprocess() |         result = testdir.runpytest_subprocess() | ||||||
|         result.stdout.fnmatch_lines(["*1 passed*"]) |         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): | def test_setup_only_available_in_subdir(testdir): | ||||||
|     sub1 = testdir.mkpydir("sub1") |     sub1 = testdir.mkpydir("sub1") | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue