/
/
/
1"""
2Decide which pytest targets a CI run should execute based on the changed files.
3
4Reads the changed file paths (one per line) from stdin and writes ``mode``,
5``test_paths`` and ``cov_paths`` to ``$GITHUB_OUTPUT`` (and stdout). ``mode`` is one of:
6
7- ``full``: run the entire suite (a shared/core file, dependency or the CI itself changed)
8- ``partial``: run only the listed ``test_paths`` (one or more changed providers)
9- ``skip``: nothing testable changed (docs, unrelated workflows, ...)
10
11``cov_paths`` are the coverage sources matching ``test_paths`` so a partial run only
12measures the providers it actually ran (e.g. ``music_assistant.providers.plex``).
13"""
14
15# ruff: noqa: T201
16
17from __future__ import annotations
18
19import os
20import sys
21from pathlib import Path
22
23# A change to any of these forces the full suite: dependencies and the runtime
24# pin (everything is reinstalled/retested) plus the selector and workflow itself.
25FULL_TRIGGER_FILES = {
26 "pyproject.toml",
27 "requirements_all.txt",
28 "uv.lock",
29 ".python-version",
30 ".github/workflows/test.yml",
31 "scripts/ci_test_scope.py",
32}
33
34
35def provider_name(path: str) -> str | None:
36 """Return the provider a path belongs to, or None if it is not provider-scoped."""
37 # Provider package or provider test dir: <root>/providers/<X>/...
38 # Provider tests must live in their own folder (enforced by check_test_layout).
39 parts = path.split("/")
40 if len(parts) >= 4 and parts[0] in ("music_assistant", "tests") and parts[1] == "providers":
41 return parts[2]
42 return None
43
44
45def target_for_provider(name: str, repo_root: Path) -> str | None:
46 """Return the pytest target dir for a provider, or None if it has no tests."""
47 if (repo_root / "tests" / "providers" / name).is_dir():
48 return f"tests/providers/{name}"
49 return None
50
51
52def cov_source(test_path: str) -> str:
53 """Map a provider test dir (``tests/providers/<X>``) to its coverage package."""
54 return f"music_assistant.providers.{test_path.rsplit('/', 1)[-1]}"
55
56
57def decide(changed: list[str], repo_root: Path) -> tuple[str, list[str]]:
58 """
59 Decide the test scope for a set of changed files.
60
61 :param changed: Changed file paths, relative to the repo root.
62 :param repo_root: Repository root, used to resolve a provider's tests.
63 """
64 providers: set[str] = set()
65 for path in changed:
66 provider = provider_name(path)
67 if provider is not None:
68 providers.add(provider)
69 elif path in FULL_TRIGGER_FILES or path.startswith(("music_assistant/", "tests/")):
70 # Shared/core code changed -> the change can reach anything.
71 return "full", []
72
73 if not providers:
74 return "skip", []
75
76 # Only providers changed: target their tests. If any changed provider has no
77 # tests we cannot get a targeted signal, so fall back to the full suite.
78 paths: list[str] = []
79 for name in sorted(providers):
80 target = target_for_provider(name, repo_root)
81 if target is None:
82 return "full", []
83 paths.append(target)
84 return "partial", paths
85
86
87def main() -> None:
88 """Read changed files from stdin and emit the resolved scope."""
89 changed = [line.strip() for line in sys.stdin if line.strip()]
90 mode, paths = decide(changed, Path.cwd())
91 cov_paths = [cov_source(path) for path in paths]
92 lines = [
93 f"mode={mode}",
94 f"test_paths={' '.join(paths)}",
95 f"cov_paths={' '.join(cov_paths)}",
96 ]
97 if github_output := os.environ.get("GITHUB_OUTPUT"):
98 with open(github_output, "a", encoding="utf-8") as handle:
99 handle.write("\n".join(lines) + "\n")
100 print("\n".join(lines))
101
102
103if __name__ == "__main__":
104 main()
105