11"""Custom lint tests."""
22
3- import subprocess
4- import sys
53from pathlib import Path
6- from typing import TYPE_CHECKING
74
85import pytest
96import yaml
107from beartype import beartype
118
12- if TYPE_CHECKING :
13- from collections .abc import Iterable
14-
159
1610@beartype
1711def _ci_patterns (* , repository_root : Path ) -> set [str ]:
@@ -25,34 +19,44 @@ def _ci_patterns(*, repository_root: Path) -> set[str]:
2519 return ci_patterns
2620
2721
22+ class _CollectPlugin :
23+ """Pytest plugin that records the node IDs of collected items."""
24+
25+ def __init__ (self ) -> None :
26+ """Start with an empty set of collected node IDs."""
27+ self .collected : set [str ] = set ()
28+
29+ def pytest_itemcollected (self , item : pytest .Item ) -> None :
30+ """Record each collected item's node ID."""
31+ self .collected .add (item .nodeid )
32+
33+
2834@beartype
29- def _collect (
30- * , ci_pattern : str , repository_root : Path
31- ) -> subprocess .CompletedProcess [str ]:
32- """Run ``pytest --collect-only`` for ``ci_pattern`` in a fresh
33- subprocess.
34-
35- A real subprocess (not ``pytest.main``) is used so that plugin state
36- (notably ``pytest-beartype-tests`` re-wrapping the same test
37- functions) does not accumulate across iterations and trigger
38- ``Cannot stringify annotation containing string formatting`` under
39- Python 3.14 deferred annotations.
40- """
41- return subprocess .run (
35+ def _tests_from_pattern (* , ci_pattern : str ) -> set [str ]:
36+ """From a CI pattern, get all tests ``pytest`` would collect."""
37+ plugin = _CollectPlugin ()
38+ pytest .main (
4239 args = [
43- sys .executable ,
44- "-m" ,
45- "pytest" ,
4640 "-q" ,
4741 "--collect-only" ,
42+ # Disable pytest-retry to avoid:
43+ # ```
44+ # ValueError: no option named 'filtered_exceptions'
45+ # ```
46+ # which causes the nested run to exit with INTERNAL_ERROR
47+ # before any items are collected.
48+ "-p" ,
49+ "no:pytest-retry" ,
50+ # Disable warnings to avoid many instances of:
51+ # ```
52+ # Unknown config option: retry_delay
53+ # ```
4854 "--disable-warnings" ,
4955 ci_pattern ,
5056 ],
51- check = False ,
52- cwd = repository_root ,
53- capture_output = True ,
54- text = True ,
57+ plugins = [plugin ],
5558 )
59+ return plugin .collected
5660
5761
5862def test_ci_patterns_valid (request : pytest .FixtureRequest ) -> None :
@@ -61,38 +65,43 @@ def test_ci_patterns_valid(request: pytest.FixtureRequest) -> None:
6165 test in
6266 the test suite.
6367 """
64- repository_root = request .config .rootpath
65- ci_patterns = _ci_patterns (repository_root = repository_root )
68+ ci_patterns = _ci_patterns (repository_root = request .config .rootpath )
6669
6770 for ci_pattern in ci_patterns :
68- result = _collect (
69- ci_pattern = ci_pattern ,
70- repository_root = repository_root ,
71- )
72- message = (
73- f'"{ ci_pattern } " does not match any tests.\n '
74- f"stdout:\n { result .stdout } \n stderr:\n { result .stderr } "
71+ collect_only_result = pytest .main (
72+ args = [
73+ "--collect-only" ,
74+ ci_pattern ,
75+ # Disable pytest-retry to avoid:
76+ # ```
77+ # ValueError: no option named 'filtered_exceptions'
78+ # ````
79+ "-p" ,
80+ "no:pytest-retry" ,
81+ # Disable warnings to avoid many instances of:
82+ # ```
83+ # Unknown config option: retry_delay
84+ # ```
85+ "--disable-warnings" ,
86+ ],
7587 )
76- assert result .returncode == 0 , message
7788
89+ message = f'"{ ci_pattern } " does not match any tests.'
90+ assert collect_only_result == 0 , message
7891
79- def test_tests_collected_once (
80- * ,
81- request : pytest .FixtureRequest ,
82- ) -> None :
92+
93+ def test_tests_collected_once (request : pytest .FixtureRequest ) -> None :
8394 """Each test in the test suite is collected exactly once.
8495
8596 This does not necessarily mean that they are run - they may be skipped.
8697 """
87- repository_root = request .config .rootpath
88- ci_patterns = _ci_patterns (repository_root = repository_root )
98+ ci_patterns = _ci_patterns (repository_root = request .config .rootpath )
99+ all_tests = _tests_from_pattern (ci_pattern = "." )
100+ assert all_tests
89101 tests_to_patterns : dict [str , set [str ]] = {}
90102
91103 for pattern in ci_patterns :
92- tests = _tests_from_pattern (
93- ci_pattern = pattern ,
94- repository_root = repository_root ,
95- )
104+ tests = _tests_from_pattern (ci_pattern = pattern )
96105 for test in tests :
97106 if test in tests_to_patterns :
98107 tests_to_patterns [test ].add (pattern )
@@ -107,32 +116,5 @@ def test_tests_collected_once(
107116 )
108117 assert len (patterns ) == 1 , message
109118
110- all_tests = _tests_from_pattern (
111- ci_pattern = "." ,
112- repository_root = repository_root ,
113- )
114- # Exclude this file's own meta-tests from the comparison: they are
115- # not part of any CI pattern by design (they validate the patterns).
116- all_tests = {t for t in all_tests if not t .startswith ("ci/" )}
117119 assert tests_to_patterns .keys () - all_tests == set ()
118120 assert all_tests - tests_to_patterns .keys () == set ()
119-
120-
121- @beartype
122- def _tests_from_pattern (
123- * ,
124- ci_pattern : str ,
125- repository_root : Path ,
126- ) -> set [str ]:
127- """From a CI pattern, get all tests ``pytest`` would collect."""
128- result = _collect (
129- ci_pattern = ci_pattern ,
130- repository_root = repository_root ,
131- )
132- tests : Iterable [str ] = set ()
133- for line in result .stdout .splitlines ():
134- # We filter empty lines and lines which look like
135- # "9 tests collected in 0.01s".
136- if line and "collected in" not in line :
137- tests = {* tests , line }
138- return set (tests )
0 commit comments