main
py 925 lines 38.3 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Check that test files are wired into ("tethered to") the Buildkite CI.
4
5 A test file is *tethered* when at least one Buildkite job actually collects it -
6 some job's ``commands`` run ``pytest`` (or ``python`` / ``torchrun``, or a
7 ``find ... | xargs pytest`` pipeline) over a path that includes the file. A test
8 that is collected by no job silently never runs: it passes review, merges, and
9 rots.
10
11 How it works
12 ------------
13 1. Parse every job in every Buildkite pipeline yaml (and each ``mirror.amd``
14 sub-step), and turn each shell command into a :class:`Selection` describing
15 which test files it runs. The pipeline directories are read from
16 ``.buildkite/ci_config*.yaml`` (``job_dirs:``) - currently ``test_areas/``,
17 ``hardware_tests/`` and ``intel_jobs/`` across the CUDA, ROCm and Intel
18 pipelines - plus the legacy ``.buildkite/test-amd.yaml``.
19 2. Build the inventory of test files under ``tests/``.
20 3. A file is a problem if no selection runs it and it is not in the allowlist
21 (``tools/pre_commit/test_tethering_allowlist.txt`` - the set of pre-existing
22 gaps, which is only ever meant to shrink). Every allowlist entry must carry a
23 trailing ``# <reason>``; a bare path line is itself an error.
24
25 Modes
26 -----
27 * **changed-files** (default): pre-commit passes the changed test files as
28 arguments; each one must be tethered or allowlisted.
29 * **full scan**: ``--all``, or automatically whenever the change set touches a
30 pipeline yaml or the allowlist itself - because editing a job command can
31 orphan a test that is not otherwise in the diff. The full scan also reports
32 allowlist entries that have since become tethered, or whose file is gone, so
33 the list stays honest.
34
35 Coverage from *any* of those pipelines counts - the question is "does some
36 Buildkite job run this test", not "which pipeline". ``test-amd.yaml`` is being
37 folded into ``test_areas/`` a group at a time; when it is gone the checker stops
38 parsing it automatically.
39
40 Usage::
41
42 python tools/pre_commit/check_test_tethering.py [FILES...]
43 python tools/pre_commit/check_test_tethering.py --all
44 """
45
46 import argparse
47 import fnmatch
48 import shlex
49 import subprocess
50 import sys
51 from dataclasses import dataclass, field
52 from pathlib import Path
53
54 import regex as re
55 import yaml
56
57 REPO_ROOT = Path(__file__).resolve().parents[2]
58 BUILDKITE_DIR = REPO_ROOT / ".buildkite"
59 # Fallback pipeline dir, always parsed even if ``ci_config*.yaml`` can't be read.
60 TEST_AREAS_DIR = BUILDKITE_DIR / "test_areas"
61 # The legacy hand-maintained AMD pipeline. It is being migrated into
62 # ``test_areas/`` piecemeal; until that finishes, a test wired only into this
63 # file is still genuinely run by CI, so it counts as tethered. When the file is
64 # removed, ``_pipeline_yaml_paths()`` stops appending it automatically.
65 TEST_AMD_YAML = BUILDKITE_DIR / "test-amd.yaml"
66 TESTS_DIR = REPO_ROOT / "tests"
67 ALLOWLIST_PATH = Path(__file__).resolve().parent / "test_tethering_allowlist.txt"
68
69 # `pytest` options that take a value in the *next* token, so that token must not
70 # be mistaken for a test path (e.g. `-k expr`, `--shard-id 3`). Options that only
71 # ever take their value with `=` (`--cov=...`) don't belong here - a bare
72 # `--cov` takes no value and would wrongly swallow the following test path.
73 PYTEST_OPTIONS_WITH_VALUE = {
74 "-k", "-m", "-p", "-n", "-c", "-o", "-W", "-r",
75 "--shard-id", "--num-shards", "--dist", "--durations", "--maxfail",
76 "--timeout", "--rootdir", "--junitxml", "--tb",
77 } # fmt: skip
78
79 # `pytest` options whose value is a path that is *removed* from collection at
80 # file granularity. ``--ignore`` takes a path prefix; ``--ignore-glob`` takes a
81 # glob that pytest matches against the whole path with ``fnmatch`` (so its ``*``
82 # *does* cross a ``/``), which is why the two are modelled separately.
83 PYTEST_IGNORE_OPTIONS = {"--ignore", "--ignore-glob"}
84
85 # `--deselect` takes a value too, but it removes individual node IDs - a file
86 # with one parametrization deselected is still collected and still "run".
87 PYTEST_DESELECT_OPTION = "--deselect"
88
89 PYTEST_COMMANDS = {"pytest", "py.test"}
90
91 # A leading ``$VAR/`` or ``${VAR}/`` path prefix - CI shell scripts routinely
92 # name a test by an absolute path rooted at a shell var
93 # (``"${GIT_ROOT}/tests/v1/..."``). The prefix is dropped before the path is
94 # interpreted; an unresolved ``$`` anywhere else still makes the token unusable.
95 _LEADING_VAR_PREFIX = re.compile(r"^\$\{?\w+\}?/")
96
97 # Commands that execute a Python file directly. Only the *first* path argument is
98 # the script that runs; everything after it is that script's own argv, not more
99 # test files (``python tensorize.py --suffix v1`` runs one file, not ``tests/v1``).
100 FILE_RUNNER_COMMANDS = {"python", "python3", "torchrun", "coverage"}
101
102
103 # --------------------------------------------------------------------------- #
104 # Shell-token helpers
105 # --------------------------------------------------------------------------- #
106
107
108 def _is_env_assignment(token: str) -> bool:
109 """True for a leading ``FOO=bar`` shell env-var assignment - the kind some
110 commands put before ``pytest`` (``VLLM_TEST_FORCE_LOAD_FORMAT=auto pytest``).
111 """
112 if "=" not in token:
113 return False
114 name = token.split("=", 1)[0]
115 return bool(name) and name.replace("_", "").isupper()
116
117
118 def normalize_test_path(raw: str) -> str:
119 """Reduce a path as it appears in a command to one comparable form:
120 relative to ``tests/``, no surrounding quotes, no ``::nodeid`` selector, no
121 trailing slash.
122
123 Commands run from either the repo root or from ``tests/``, so both a
124 ``tests/`` and a ``/vllm-workspace/tests/`` prefix are stripped, as is a
125 leading ``$VAR/`` (``"${GIT_ROOT}/tests/..."``).
126 """
127 path = raw.strip().strip("'\"")
128 path = path.split("::", 1)[0]
129 path = _LEADING_VAR_PREFIX.sub("", path)
130 for prefix in ("./", "tests/", "/vllm-workspace/tests/"):
131 if path.startswith(prefix):
132 path = path[len(prefix) :]
133 return path.rstrip("/")
134
135
136 def _token_is_test_path(token: str) -> bool:
137 """True if a shell token is a path argument rather than a flag, an env-var
138 assignment, a Buildkite ``$VAR``, or a ``-m`` / ``-k`` expression value.
139
140 A token with a ``/`` or a ``.py`` ending is always a path. A bare word
141 (``samplers``) only counts if it is really a directory under ``tests/``. A
142 leading ``$VAR/`` is dropped first, but a ``$`` anywhere else means the token
143 can't be resolved to a known path.
144 """
145 if not token or token.startswith("-"):
146 return False
147 if _is_env_assignment(token):
148 return False
149 token = _LEADING_VAR_PREFIX.sub("", token)
150 if "$" in token:
151 return False
152 if "/" in token or token.endswith(".py"):
153 return True
154 return (TESTS_DIR / normalize_test_path(token)).is_dir()
155
156
157 def _path_is_within(parent: str, path: str) -> bool:
158 """True if ``path`` is ``parent`` itself or nested underneath it. A ``''`` /
159 ``'.'`` parent (a command run with no path arg) contains everything."""
160 if parent in ("", "."):
161 return True
162 return path == parent or path.startswith(parent + "/")
163
164
165 def _glob_match_path(pattern: str, path: str) -> bool:
166 """Like :func:`fnmatch.fnmatch`, but ``*`` and ``?`` do not match across a
167 ``/`` - the way the shell and ``pytest`` treat an unexpanded glob path
168 argument (``kernels/test_foo_*.py`` selects one directory, not the tree).
169 A ``**`` component keeps the cross-separator meaning."""
170 if not any(ch in pattern for ch in "*?["):
171 return pattern == path
172 if "**" in pattern:
173 return fnmatch.fnmatch(path, pattern)
174 pattern_parts = pattern.split("/")
175 path_parts = path.split("/")
176 return len(pattern_parts) == len(path_parts) and all(
177 fnmatch.fnmatch(part, glob) for part, glob in zip(path_parts, pattern_parts)
178 )
179
180
181 # --------------------------------------------------------------------------- #
182 # Selections - "which test files does this command run?"
183 # --------------------------------------------------------------------------- #
184
185
186 @dataclass
187 class PytestSelection:
188 """A ``pytest``-style command: run everything under ``included_paths``
189 except what an ``--ignore`` (path prefix) or ``--ignore-glob`` (whole-path
190 ``fnmatch``) removes."""
191
192 included_paths: list[str] = field(default_factory=list)
193 ignored_paths: list[str] = field(default_factory=list)
194 ignored_globs: list[str] = field(default_factory=list)
195
196 def runs(self, test_file: str) -> bool:
197 included = any(self._arg_covers(arg, test_file) for arg in self.included_paths)
198 if not included:
199 return False
200 if any(self._arg_covers(arg, test_file) for arg in self.ignored_paths):
201 return False
202 # `--ignore-glob` is a plain `fnmatch` over the whole path - `*_tp.py`
203 # drops `kernels/nested/test_a_tp.py`, unlike a positional glob arg.
204 return not any(
205 fnmatch.fnmatch(test_file, normalize_test_path(glob))
206 for glob in self.ignored_globs
207 )
208
209 @staticmethod
210 def _arg_covers(path_arg: str, test_file: str) -> bool:
211 """True if a single pytest path argument selects ``test_file`` - by
212 exact match, by being a parent directory, or as a glob."""
213 path_arg = normalize_test_path(path_arg)
214 if _path_is_within(path_arg, test_file):
215 return True
216 return _glob_match_path(path_arg, test_file)
217
218
219 @dataclass
220 class FindSelection:
221 """A ``find <root> -name ... [-not -name ...] [-maxdepth N] | xargs pytest``
222 pipeline. ``pytest`` runs one file per ``find`` hit, so the ``find``
223 expression alone defines the selected set."""
224
225 root: str
226 name_globs: list[str] = field(default_factory=list)
227 exclude_name_globs: list[str] = field(default_factory=list)
228 max_depth: int | None = None
229
230 def runs(self, test_file: str) -> bool:
231 root = normalize_test_path(self.root)
232 if not _path_is_within(root, test_file):
233 return False
234
235 # `find <root>` puts a direct child at depth 1, whether <root> is a real
236 # subdir ("compile") or the tests/ root itself ("" / ".").
237 below = test_file if root in ("", ".") else test_file[len(root) :]
238 depth_below_root = below.lstrip("/").count("/") + 1
239 if self.max_depth is not None and depth_below_root > self.max_depth:
240 return False
241
242 filename = test_file.rsplit("/", 1)[-1]
243 if self.name_globs and not _glob_any(filename, self.name_globs):
244 return False
245 return not _glob_any(filename, self.exclude_name_globs)
246
247
248 Selection = PytestSelection | FindSelection
249
250
251 def _glob_any(name: str, globs: list[str]) -> bool:
252 return any(fnmatch.fnmatch(name, glob) for glob in globs)
253
254
255 # --------------------------------------------------------------------------- #
256 # Parsing CI commands into selections
257 # --------------------------------------------------------------------------- #
258
259
260 def _parse_pytest_command(tokens: list[str]) -> PytestSelection:
261 """Read a ``pytest ...`` invocation's tokens into a :class:`PytestSelection`.
262
263 Positional path args are the included paths; ``--ignore`` values (and a
264 bare-path ``--deselect``) are ignored paths, ``--ignore-glob`` values are
265 ignored globs. Value-taking options and their values, and every other flag,
266 are skipped. ``-k`` / ``-m`` filtering is intentionally *not* modelled - a
267 file that a marker expression narrows is still "run".
268 """
269 selection = PytestSelection()
270
271 # Advance past the pytest token itself (and any env-var / wrapper prefix).
272 tokens = iter(tokens)
273 for token in tokens:
274 if token in PYTEST_COMMANDS:
275 break
276
277 for token in tokens:
278 opt, eq, inline = token.partition("=")
279 if opt in PYTEST_IGNORE_OPTIONS:
280 value = inline if eq else next(tokens, "")
281 if opt == "--ignore-glob":
282 selection.ignored_globs.append(value)
283 else:
284 selection.ignored_paths.append(value)
285 elif opt == PYTEST_DESELECT_OPTION:
286 value = inline if eq else next(tokens, "")
287 # A `path::node_id` deselect leaves the file collected; only a
288 # bare-path deselect removes anything at file granularity.
289 if value and "::" not in value:
290 selection.ignored_paths.append(value)
291 elif token in PYTEST_OPTIONS_WITH_VALUE:
292 next(tokens, None) # consume and discard the option's value
293 elif token.startswith("-"):
294 continue
295 elif _token_is_test_path(token):
296 selection.included_paths.append(token)
297
298 return selection
299
300
301 def _parse_find_command(tokens: list[str]) -> FindSelection | None:
302 """Read a ``find <root> -name ...`` command's tokens into a
303 :class:`FindSelection`, or return ``None`` if there is no usable ``find``."""
304 if "find" not in tokens:
305 return None
306
307 root: str | None = None
308 name_globs: list[str] = []
309 exclude_name_globs: list[str] = []
310 max_depth: int | None = None
311
312 tokens = iter(tokens[tokens.index("find") + 1 :])
313 negated = False
314 for token in tokens:
315 if token in ("-not", "!"):
316 negated = True
317 continue
318 if token in ("-name", "-iname"):
319 glob = next(tokens, "").strip("'\"")
320 (exclude_name_globs if negated else name_globs).append(glob)
321 elif token == "-maxdepth":
322 depth = next(tokens, "")
323 max_depth = int(depth) if depth.isdigit() else None
324 elif (
325 root is None and not token.startswith("-") and token not in PYTEST_COMMANDS
326 ):
327 root = token
328 negated = False
329
330 if root is None:
331 return None
332 return FindSelection(root, name_globs, exclude_name_globs, max_depth)
333
334
335 def _dash_c_code_operand(tokens: list[str]) -> str | None:
336 """For ``bash -c '<code>' [$0] [args]`` / ``sh -c ...`` / a combined short
337 group with ``c`` anywhere in it (``-ec``, ``-ce``, ``-euxc``), the ``<code>``
338 string the shell runs. ``None`` when this is not a ``-c`` invocation (plain
339 ``bash script.sh`` / ``bash -x script.sh``). Everything after the code
340 operand is a positional parameter (``$0``, ``$1``, ...), not a script or
341 command that executes."""
342 if not tokens or tokens[0].rsplit("/", 1)[-1] not in ("bash", "sh"):
343 return None
344 for i, token in enumerate(tokens[1:], start=1):
345 if not token.startswith("-"):
346 return None # script operand reached; a later `-c` is *its* flag
347 # a short-option group (single dash) with `c` among its flags.
348 if not token.startswith("--") and "c" in token[1:]:
349 return tokens[i + 1] if i + 1 < len(tokens) else None
350 return None
351
352
353 def _parse_shell_script(tokens: list[str], visited: set[str]) -> list[Selection]:
354 """Follow a ``bash tests/foo.sh`` command: read the script and parse the
355 pytest / runner lines inside it. ``visited`` stops a script that (directly or
356 otherwise) refers back to itself; nesting is one level deep in practice.
357 """
358 if _dash_c_code_operand(tokens) is not None:
359 return [] # the `-c` code string is parsed by the caller, not a file
360 script_arg = next((t for t in tokens if t.endswith(".sh")), None)
361 if script_arg is None:
362 return []
363
364 rel = script_arg.strip().strip("'\"").replace("/vllm-workspace/", "")
365 rel = rel.replace("vllm-workspace/", "").removeprefix("./").removeprefix("/")
366 candidates = [REPO_ROOT / rel, TESTS_DIR / normalize_test_path(rel)]
367 script_path = next((p for p in candidates if p.is_file()), None)
368 if script_path is None or str(script_path) in visited:
369 return []
370 visited.add(str(script_path))
371
372 # Join `\`-continued lines (a `pytest ... \` with its path on the next line
373 # is common), then track literal `VAR=value` assignments so a later
374 # `pytest "$VAR"` - the import-canary pattern - resolves to a real path.
375 text = script_path.read_text().replace("\\\n", " ")
376 var_values: dict[str, str] = {}
377 selections: list[Selection] = []
378 for line in text.splitlines():
379 line = line.strip()
380 if not line or line.startswith("#"):
381 continue
382 assignment = re.match(r"^(\w+)=(\S+)$", line)
383 if assignment:
384 var_values[assignment.group(1)] = assignment.group(2).strip("'\"")
385 continue
386 if var_values:
387 line = _expand_vars(line, var_values)
388 selections.extend(_parse_command(line, visited))
389 return selections
390
391
392 def _expand_vars(line: str, var_values: dict[str, str]) -> str:
393 """Substitute known ``$VAR`` / ``${VAR}`` in a script line - but not inside
394 single quotes, where the shell does not expand (``pytest '$X'`` passes a
395 literal ``$X``, collecting nothing)."""
396
397 def sub(segment: str) -> str:
398 return re.sub(
399 r"\$\{(\w+)\}|\$(\w+)",
400 lambda m: var_values.get(m.group(1) or m.group(2), m.group(0)),
401 segment,
402 )
403
404 # Splitting on `'`: even-indexed segments are outside single quotes, odd are
405 # inside. (A `'` within double quotes splits wrongly, but that only ever
406 # drops a substitution - the safe direction.)
407 parts = line.split("'")
408 return "'".join(sub(p) if i % 2 == 0 else p for i, p in enumerate(parts))
409
410
411 # Shell tokens (or runs of them) that separate one sub-command from the next.
412 _SHELL_SEP_CHARS = set("|&;()<>")
413 _RUNNER_KEYWORDS = PYTEST_COMMANDS | FILE_RUNNER_COMMANDS
414
415 # Tokens that can precede the real command word without being it.
416 _COMMAND_WRAPPERS = {"if", "!", "then", "time", "exec", "nice", "command", "builtin"}
417
418
419 def _effective_command_index(tokens: list[str]) -> int:
420 """Index of the token that names the command actually executed, past leading
421 ``FOO=bar`` assignments and simple wrappers - ``if ! env FOO=1 bash x.sh``
422 resolves to the ``bash``, so ``echo x.sh`` stays ``echo`` and is not
423 followed. Returns ``len(tokens)`` when nothing executable is left."""
424 i = 0
425 while i < len(tokens):
426 token = tokens[i]
427 if _is_env_assignment(token) or token in _COMMAND_WRAPPERS:
428 i += 1
429 elif token == "env":
430 i += 1
431 while i < len(tokens) and (
432 _is_env_assignment(tokens[i]) or "$" in tokens[i] or tokens[i] == "-"
433 ):
434 i += 1
435 elif token == "timeout" and i + 1 < len(tokens):
436 i += 1
437 while i < len(tokens) and tokens[i].startswith("-"):
438 # `-k` / `--kill-after` / `-s` / `--signal` take a value.
439 if tokens[i] in ("-k", "--kill-after", "-s", "--signal"):
440 i += 1
441 i += 1
442 i += 1 # the DURATION operand
443 else:
444 break
445 return i
446
447
448 def _split_shell_stages(command: str) -> list[tuple[str, list[str]]]:
449 """Tokenize a shell line - honouring quotes - and split it at the shell
450 operators, tagging each stage with the operator token that *precedes* it
451 (``""`` for the first stage, then ``"|"`` / ``"&&"`` / ``"||"`` / ``";"`` /
452 ``"&"`` / a subshell paren). Knowing which operator joins two stages is what
453 lets a ``find`` sweep count only when it is genuinely piped into ``pytest``,
454 not merely on the same line as one. A line that cannot be tokenized
455 (unbalanced quote, ...) yields nothing: better to miss a selection than to
456 misparse one. Splitting *after* tokenizing means an operator inside a quoted
457 argument stays part of that argument."""
458 lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
459 lexer.whitespace_split = True
460 try:
461 tokens = list(lexer)
462 except ValueError:
463 return []
464
465 stages: list[tuple[str, list[str]]] = [("", [])]
466 in_exec = False # inside a `find -exec ... \;` action - its `;` terminates
467 for token in tokens:
468 if in_exec and token in (";", "+"):
469 stages[-1][1].append(token)
470 in_exec = False
471 elif token and set(token) <= _SHELL_SEP_CHARS:
472 stages.append((token, []))
473 in_exec = False
474 else:
475 stages[-1][1].append(token)
476 if token in ("-exec", "-execdir"):
477 in_exec = True
478 return [(sep, sub) for sep, sub in stages if sub]
479
480
481 def _looks_like_command_string(token: str) -> bool:
482 """True for a single token that is itself a command line - e.g. a quoted
483 ``"pytest a.py && torchrun b.py"`` passed as an argument to a runner
484 script."""
485 return " " in token and any(word in _RUNNER_KEYWORDS for word in token.split())
486
487
488 def _classify_subcommand(tokens: list[str], visited: set[str]) -> list[Selection]:
489 """Map one already-tokenized sub-command to the selections it implies:
490 find / pytest / ``bash <script>`` / direct file runner. Anything else
491 contributes nothing - better to miss a real selection than to invent
492 coverage that isn't there."""
493 if not tokens:
494 return []
495
496 is_find = "find" in tokens and any(t in ("-name", "-iname") for t in tokens)
497 if is_find:
498 find_selection = _parse_find_command(tokens)
499 return [find_selection] if find_selection is not None else []
500
501 # A `pytest` token only counts when `pytest` is the command that actually
502 # runs - `echo pytest kernels` prints text, it does not collect anything.
503 if _runs_pytest(tokens):
504 return [_parse_pytest_command(tokens)]
505
506 # Resolve the real command word past `FOO=1` / `env` / `if ! ...` wrappers,
507 # so `env FOO=1 bash x.sh` is followed but `echo x.sh` (Buildkite just
508 # printing a filename) is not.
509 cmd_index = _effective_command_index(tokens)
510 command_name = (
511 tokens[cmd_index].rsplit("/", 1)[-1] if cmd_index < len(tokens) else ""
512 )
513
514 if command_name in ("bash", "sh") or command_name.endswith(".sh"):
515 run_tokens = tokens[cmd_index:]
516 code = _dash_c_code_operand(run_tokens)
517 if code is not None:
518 # `bash -c '<code>' [$0] [args]` - only the `-c` string runs; the
519 # trailing args are positional params, not commands.
520 return _parse_command(code, visited)
521 selections = _parse_shell_script(run_tokens, visited)
522 # Runner scripts (run-multi-node-test.sh, ...) take the real test
523 # commands as quoted string arguments - parse those too.
524 for arg in run_tokens[1:]:
525 if _looks_like_command_string(arg):
526 selections.extend(_parse_command(arg, visited))
527 return selections
528
529 # `python` / `torchrun` / `coverage <file>.py`.
530 if command_name in FILE_RUNNER_COMMANDS:
531 rest = tokens[cmd_index + 1 :]
532 # `python -c '<code>' [argv...]` runs the code string, not a file - a
533 # later `-m pytest` or `.py` is that program's argv. Only honour a `-c`
534 # that is the interpreter's own (nothing script-like precedes it).
535 if "-c" in rest:
536 before_c = rest[: rest.index("-c")]
537 if "-m" not in before_c and not any(t.endswith(".py") for t in before_c):
538 return []
539 # `-m <module>` picks the module, not a script: only `-m pytest` runs
540 # tests. `python -m compileall kernels` collects nothing - the trailing
541 # `kernels` is compileall's argv, not a test path.
542 if "-m" in rest:
543 after_m = rest[rest.index("-m") + 1 :]
544 if after_m[:1] and after_m[0] in PYTEST_COMMANDS:
545 return [_parse_pytest_command(after_m)]
546 return []
547 # `python foo.py a b/c --suffix v1` executes only `foo.py`; the tokens
548 # after it are that script's argv - treating them as test paths tethers
549 # whatever directory an argument happens to name (`v1` -> `tests/v1`).
550 script = next(
551 (t for t in rest if t.endswith(".py") and _token_is_test_path(t)), None
552 )
553 if script is not None:
554 return [PytestSelection(included_paths=[script])]
555
556 return []
557
558
559 # `xargs` options that consume the following token as their value.
560 _XARGS_VALUE_FLAGS = {
561 "-n", "-L", "-P", "-s", "-I", "-i", "-J", "-E", "-d",
562 "--max-args", "--max-lines", "--max-procs", "--replace", "--delimiter",
563 } # fmt: skip
564
565
566 def _xargs_command_tokens(tokens: list[str]) -> list[str]:
567 """The command (plus its args) that ``xargs`` will exec, with ``xargs``' own
568 options stripped: ``xargs -0 -n1 -I{} env FOO=1 pytest`` -> ``['env',
569 'FOO=1', 'pytest']``."""
570 i = 0
571 while i < len(tokens):
572 if tokens[i] in _XARGS_VALUE_FLAGS:
573 i += 2
574 elif tokens[i].startswith("-"):
575 i += 1
576 else:
577 return tokens[i:]
578 return []
579
580
581 def _runs_pytest(tokens: list[str]) -> bool:
582 """True if ``tokens`` is a command that executes ``pytest``, seeing through
583 ``env FOO=1`` / ``timeout`` / ``if !`` wrappers (``xargs echo pytest`` is
584 not - ``echo`` is what runs)."""
585 idx = _effective_command_index(tokens)
586 return idx < len(tokens) and tokens[idx] in PYTEST_COMMANDS
587
588
589 # Pipe stages that reorder or echo the stream without dropping any line, so
590 # every `find` match still reaches a downstream `xargs pytest`. Anything else
591 # (`head`, `grep`, `shuf`, `sed -n`, ...) filters, and then only some matches
592 # run - not enough to claim the whole sweep is tethered.
593 _PIPE_PASSTHROUGH = {"sort", "tee", "cat", "tac"}
594
595
596 def _find_feeds_pytest(stages: list[tuple[str, list[str]]], find_index: int) -> bool:
597 """True if the ``find`` stage at ``find_index`` actually runs its matches
598 through ``pytest``: a *terminated* ``-exec pytest ... \\;`` / ``... +`` in the
599 ``find`` itself, or an ``xargs`` whose command operand is ``pytest``, reached
600 through ``|`` pipes of non-filtering stages only. A ``find`` piped to
601 ``wc`` / ``head`` / an ``xargs echo``, an unterminated ``-exec``, or a
602 ``pytest`` in a separate ``;`` / ``&&`` command, runs nothing (or not the
603 whole sweep)."""
604 find_tokens = stages[find_index][1]
605 for i, token in enumerate(find_tokens):
606 if token in ("-exec", "-execdir"):
607 action = find_tokens[i + 1 :]
608 if _exec_runs_matches_through_pytest(action):
609 return True
610 for sep, tokens in stages[find_index + 1 :]:
611 if sep != "|":
612 break
613 if tokens[:1] == ["xargs"] and _runs_pytest(_xargs_command_tokens(tokens[1:])):
614 return True
615 if tokens[:1] and tokens[0] in _PIPE_PASSTHROUGH:
616 continue
617 break
618 return False
619
620
621 def _terminated(exec_action: list[str]) -> bool:
622 """A ``find -exec`` action only runs if it ends with a ``;`` or ``+`` token
623 (from ``\\;`` / ``+`` on the command line)."""
624 return ";" in exec_action or "+" in exec_action
625
626
627 def _exec_runs_matches_through_pytest(action: list[str]) -> bool:
628 """True if a ``find -exec``/``-execdir`` action runs ``pytest`` over each
629 *matched* file: ``pytest`` is the command, the action is terminated
630 (``\\;`` / ``+``), and the ``{}`` placeholder lands as a positional test-path
631 argument - not as the value of an option (``-exec pytest --ignore {} \\;``
632 only ever runs whatever positional file is also on the line)."""
633 if not action or action[0] not in PYTEST_COMMANDS or not _terminated(action):
634 return False
635 # Substitute a real-looking path for `{}` and see whether pytest's own arg
636 # parser keeps it as an included path rather than eating it as an option value.
637 probe = [t.replace("{}", "_find_match_.py") for t in action[:-1]]
638 selection = _parse_pytest_command(probe)
639 return any("_find_match_" in path for path in selection.included_paths)
640
641
642 def _parse_command(command: str, visited: set[str] | None = None) -> list[Selection]:
643 """Turn one yaml ``commands:`` entry into the selections it implies.
644
645 The line is tokenized (respecting quotes) and split into stages at the shell
646 operators, and each stage is classified independently so that, e.g., a
647 ``find`` and a ``pytest`` on the same line are both handled. A ``find -name``
648 sweep is kept only when :func:`_find_feeds_pytest` confirms its output
649 actually reaches ``pytest`` - otherwise it would invent coverage.
650 """
651 visited = visited if visited is not None else set()
652 stages = _split_shell_stages(command)
653 selections: list[Selection] = []
654 for index, (_, tokens) in enumerate(stages):
655 for selection in _classify_subcommand(tokens, visited):
656 if isinstance(selection, FindSelection) and not _find_feeds_pytest(
657 stages, index
658 ):
659 continue
660 selections.append(selection)
661 return selections
662
663
664 def _iter_job_steps(yaml_doc: dict):
665 """Yield each job step in a test_areas yaml, plus a merged view of each
666 ``mirror.*`` sub-step. The AMD mirror can override ``commands``, so it has to
667 be checked as its own step."""
668 for step in yaml_doc.get("steps") or []:
669 if not isinstance(step, dict):
670 continue
671 yield step
672
673 for mirror_step in (step.get("mirror") or {}).values():
674 if isinstance(mirror_step, dict):
675 merged = {**step, **mirror_step}
676 merged.pop("mirror", None)
677 yield merged
678
679
680 def _pipeline_job_dirs() -> list[Path]:
681 """The Buildkite job directories, read from every ``.buildkite/ci_config*.yaml``
682 (``job_dirs:``). Buildkite itself uses these files to assemble each pipeline,
683 so a test wired into any job under any of them is genuinely run by CI. Falls
684 back to ``test_areas/`` alone if no config is readable."""
685 job_dirs: set[Path] = {TEST_AREAS_DIR}
686 for config in sorted((REPO_ROOT / ".buildkite").glob("ci_config*.yaml")):
687 try:
688 doc = yaml.safe_load(config.read_text()) or {}
689 except yaml.YAMLError:
690 continue
691 for rel in doc.get("job_dirs") or []:
692 job_dirs.add(REPO_ROOT / rel)
693 return sorted(job_dirs)
694
695
696 def _pipeline_yaml_paths() -> list[Path]:
697 """Every Buildkite pipeline yaml whose jobs count as CI coverage: the yamls
698 in every :func:`_pipeline_job_dirs` directory plus the legacy ``test-amd.yaml``
699 (see ``TEST_AMD_YAML``)."""
700 paths: list[Path] = []
701 for job_dir in _pipeline_job_dirs():
702 paths.extend(sorted(job_dir.glob("*.yaml")))
703 if TEST_AMD_YAML.is_file():
704 paths.append(TEST_AMD_YAML)
705 return paths
706
707
708 def load_selections() -> list[Selection]:
709 """Parse every CI pipeline yaml into the full list of selections - the CI's
710 complete picture of which test files it runs.
711
712 A yaml that doesn't parse is fatal: silently skipping it would drop whatever
713 coverage it defines and produce false "untethered" reports.
714 """
715 selections: list[Selection] = []
716 for yaml_path in _pipeline_yaml_paths():
717 try:
718 yaml_doc = yaml.safe_load(yaml_path.read_text()) or {}
719 except yaml.YAMLError as e:
720 rel = yaml_path.relative_to(REPO_ROOT)
721 raise SystemExit(f"error: {rel} is not valid YAML: {e}") from None
722 if not isinstance(yaml_doc, dict):
723 rel = yaml_path.relative_to(REPO_ROOT)
724 raise SystemExit(
725 f"error: {rel} is not a Buildkite pipeline "
726 f"(top-level {type(yaml_doc).__name__}, expected a mapping)"
727 )
728 for step in _iter_job_steps(yaml_doc):
729 commands = list(step.get("commands") or [])
730 if step.get("command"): # some steps use the singular form
731 commands.append(step["command"])
732 for command in commands:
733 if isinstance(command, str):
734 selections.extend(_parse_command(command))
735 return selections
736
737
738 def is_tethered(test_file: str, selections: list[Selection]) -> bool:
739 """True if any CI selection runs ``test_file`` (path relative to ``tests/``
740 or to the repo root - both are accepted)."""
741 normalized = normalize_test_path(test_file)
742 return any(selection.runs(normalized) for selection in selections)
743
744
745 # --------------------------------------------------------------------------- #
746 # Test-file inventory and allowlist
747 # --------------------------------------------------------------------------- #
748
749
750 def is_test_module(repo_path: str) -> bool:
751 """True if ``repo_path`` is a pytest test module CI is expected to run - a
752 ``tests/**/test_*.py`` or ``*_test.py``, but not ``conftest.py`` /
753 ``__init__.py`` or other helper modules."""
754 if not repo_path.startswith("tests/") or not repo_path.endswith(".py"):
755 return False
756 filename = repo_path.rsplit("/", 1)[-1]
757 if filename in ("conftest.py", "__init__.py"):
758 return False
759 return filename.startswith("test_") or filename.endswith("_test.py")
760
761
762 def all_test_modules() -> list[str]:
763 """Every test module under ``tests/``, sorted. Prefers ``git ls-files`` so a
764 developer's local scratch files are never flagged; falls back to a
765 filesystem walk when run outside a git checkout - the CI test images bake in
766 ``tests/`` with no ``.git``, and there are no untracked scratch files there.
767 """
768 try:
769 tracked = subprocess.run(
770 ["git", "-C", str(REPO_ROOT), "ls-files", "tests/"],
771 capture_output=True,
772 text=True,
773 check=True,
774 ).stdout
775 paths = tracked.splitlines()
776 except (OSError, subprocess.CalledProcessError):
777 paths = [p.relative_to(REPO_ROOT).as_posix() for p in TESTS_DIR.rglob("*.py")]
778 return sorted(path for path in paths if is_test_module(path))
779
780
781 def load_allowlist() -> set[str]:
782 """Read the allowlist file into a set of repo-relative paths. Blank lines and
783 ``#`` comments (whole-line or trailing) are ignored."""
784 if not ALLOWLIST_PATH.exists():
785 return set()
786
787 paths = set()
788 for line in ALLOWLIST_PATH.read_text().splitlines():
789 path = line.split("#", 1)[0].strip()
790 if path:
791 paths.add(path)
792 return paths
793
794
795 def allowlist_entries_missing_reason() -> list[str]:
796 """Allowlist paths with no trailing ``# reason`` on their own line. Every gap
797 has to say why the test can't be wired into a job, so a bare ``path`` line
798 (the easy way to silence the gate) is itself an error."""
799 if not ALLOWLIST_PATH.exists():
800 return []
801
802 offenders = []
803 for line in ALLOWLIST_PATH.read_text().splitlines():
804 path, _, comment = line.partition("#")
805 if path.strip() and not comment.strip():
806 offenders.append(path.strip())
807 return offenders
808
809
810 # --------------------------------------------------------------------------- #
811 # The two check modes
812 # --------------------------------------------------------------------------- #
813
814
815 def run_full_scan(
816 selections: list[Selection], allowlist: set[str], *, strict: bool
817 ) -> int:
818 """Scan every test module. Untethered files that aren't allowlisted are
819 always errors. Allowlist entries that have become tethered, or whose file no
820 longer exists, are reported either way but are only errors when ``strict``
821 (an explicit ``--all`` run, as opposed to a PR that happened to touch CI
822 config - which shouldn't be blocked by unrelated allowlist cleanup).
823 """
824 untethered = [
825 path
826 for path in all_test_modules()
827 if path not in allowlist and not is_tethered(path, selections)
828 ]
829 now_tethered = sorted(path for path in allowlist if is_tethered(path, selections))
830 deleted = sorted(path for path in allowlist if not (REPO_ROOT / path).exists())
831
832 for path in untethered:
833 print(f"error: {path} is not run by any Buildkite job")
834 for path in now_tethered:
835 print(f"note: {path} is now tethered - remove it from the allowlist")
836 for path in deleted:
837 print(f"note: {path} no longer exists - remove it from the allowlist")
838
839 if untethered:
840 print(
841 f"\n{len(untethered)} test file(s) exist but run in no CI job. Wire "
842 "each into a job's `commands` in a .buildkite/ pipeline yaml, or add "
843 f"it to {ALLOWLIST_PATH.relative_to(REPO_ROOT)} with a reason."
844 )
845
846 error_count = len(untethered)
847 if strict:
848 error_count += len(now_tethered) + len(deleted)
849 return 1 if error_count else 0
850
851
852 def run_changed_files_check(
853 paths: list[str], selections: list[Selection], allowlist: set[str]
854 ) -> int:
855 """Check only the given paths (what pre-commit passes). Non-test files, and
856 paths that don't exist on disk, are ignored."""
857 untethered = []
858 for raw_path in paths:
859 repo_path = _to_repo_relative(raw_path)
860 if not is_test_module(repo_path) or not (REPO_ROOT / repo_path).is_file():
861 continue
862 if repo_path in allowlist or is_tethered(repo_path, selections):
863 continue
864 untethered.append(repo_path)
865
866 for path in untethered:
867 print(f"error: {path} is not run by any Buildkite job")
868 if untethered:
869 print(
870 "\nAdd the test to a job's `commands` (and `source_file_dependencies`)"
871 " in a .buildkite/ pipeline yaml, or, if it is intentionally not run "
872 f"in CI, add it to {ALLOWLIST_PATH.relative_to(REPO_ROOT)} with a "
873 "comment explaining why."
874 )
875 return 1 if untethered else 0
876
877
878 def _to_repo_relative(path: str) -> str:
879 if Path(path).is_absolute():
880 path = str(Path(path).resolve().relative_to(REPO_ROOT))
881 return path.replace("\\", "/")
882
883
884 def _change_set_touches_ci_config(paths: list[str]) -> bool:
885 """A change to a pipeline yaml, a ci_config, or the allowlist can orphan a
886 test that is not itself in the change set, so those changes force a full
887 scan."""
888 return any(
889 ".buildkite/test_areas/" in path
890 or ".buildkite/hardware_tests/" in path
891 or ".buildkite/intel_jobs/" in path
892 or ".buildkite/ci_config" in path
893 or path.endswith("test-amd.yaml")
894 or path.endswith("test_tethering_allowlist.txt")
895 for path in paths
896 )
897
898
899 def main() -> int:
900 parser = argparse.ArgumentParser(description=__doc__)
901 parser.add_argument("files", nargs="*", help="test files to check")
902 parser.add_argument(
903 "--all", action="store_true", help="scan every test file in the tree"
904 )
905 args = parser.parse_args()
906
907 selections = load_selections()
908 allowlist = load_allowlist()
909
910 unreasoned = allowlist_entries_missing_reason()
911 for path in unreasoned:
912 print(
913 f"error: {ALLOWLIST_PATH.relative_to(REPO_ROOT)}: {path} has no "
914 "trailing '# <reason>' - say why it can't be wired into a job"
915 )
916
917 if args.all or _change_set_touches_ci_config(args.files):
918 rc = run_full_scan(selections, allowlist, strict=args.all)
919 else:
920 rc = run_changed_files_check(args.files, selections, allowlist)
921 return 1 if (unreasoned or rc) else 0
922
923
924 if __name__ == "__main__":
925 sys.exit(main())