| #!/usr/bin/env python3 |
| # SPDX-FileCopyrightText: The Eigen Authors |
| # SPDX-License-Identifier: MPL-2.0 |
| |
| """Select the tests affected by a set of changed files. |
| |
| Eigen is header-only, so a test is affected by a change exactly when its |
| translation unit textually includes the changed file. This script builds the |
| include graph over ``Eigen/``, ``unsupported/Eigen/`` and the test trees, then |
| maps changed paths to the CMake test targets that reach them. |
| |
| The graph follows every ``#include`` regardless of preprocessor guards, so the |
| closure is a strict superset of the true compile dependency and no test is |
| dropped because a conditional branch was not taken. Over-approximation is the |
| safe direction here: the point is to widen coverage relative to the fixed smoke |
| list, not to minimise work. Changes that invalidate the mapping itself (CMake, |
| CI, the BLAS/LAPACK shims) fall back to the full ``buildtests`` target. |
| |
| Two output files are written, both consumed by the build and test scripts of each |
| platform in the tier -- ``ci/scripts/build.linux.script.sh`` and |
| ``ci/scripts/test.linux.script.sh``, and their ``.windows.script.ps1`` |
| counterparts: |
| |
| targets.txt ``NONE``, a newline-separated target list, or the |
| full-suite list of ``buildtests`` and the targets it does |
| not aggregate |
| ctest_regex.txt ``ALL``, ``NONE``, or a CTest ``-R`` regex |
| |
| The selected names are CMake target names, not CTest test names: a split test |
| ``foo`` registers ``foo_1``..``foo_N`` as tests but a single ``foo`` target that |
| aggregates them, so selecting ``foo`` builds and runs every part. Targets that |
| a given configuration does not register (optional dependencies such as CHOLMOD |
| or SYCL) are filtered out by the build scripts, the only place that knows what |
| CMake actually configured. |
| |
| Two registrations do not fit that shape: |
| |
| * Each compile-failure test under ``failtest/`` compiles its own target from |
| inside CTest, so those are selected as CTest names and never handed to the |
| build job. |
| * ``buildtests`` aggregates the ``ei_add_test`` targets only. A bare |
| ``add_executable`` such as ``bug1213`` is attached to nothing, so the |
| full-suite mode has to name those targets next to ``buildtests``. |
| |
| A registered target need not compile a ``.cpp``, and need not be named by a |
| literal: ``ei_add_test`` takes the source extension from |
| ``EIGEN_ADD_TEST_FILENAME_EXTENSION``, which the GPU blocks set to ``cu``, and |
| the GPU module registers families of tests from ``foreach`` item lists. Both |
| are read out of the CMake source, so a translation unit under a test root with |
| no registration the parser can see is an error rather than an assumption. |
| Those targets are selected like any other; the build script drops them in |
| configurations that did not register them. ``test/buildsystem/`` is skipped: |
| its consumer projects are separate CMake projects configured by their own CI |
| job, so an ``add_executable`` there is not a test registration. |
| """ |
| |
| import argparse |
| import collections |
| import fnmatch |
| import os |
| import re |
| import subprocess |
| import sys |
| |
| # Directories scanned to build the include graph. |
| SCAN_ROOTS = ("Eigen", "unsupported/Eigen", "test", "unsupported/test", "failtest") |
| |
| # Directories whose .cpp files are test translation units. |
| TEST_ROOTS = ("test", "unsupported/test") |
| |
| # Subtrees of TEST_ROOTS that are not part of this build. test/buildsystem/ |
| # holds standalone CMake projects that test:linux:buildsystem configures on |
| # their own, so their add_executable() calls register targets no configuration |
| # here has, and their sources are not test translation units. |
| EXCLUDED_TEST_DIRS = ("test/buildsystem",) |
| |
| # Compile-failure suite. ei_add_failtest registers <name>_ok and <name>_ko as |
| # CTest tests whose test action is a build of an EXCLUDE_FROM_ALL target. |
| FAILTEST_ROOT = "failtest" |
| |
| # Extensions a registered test translation unit can have. ".cu" comes from the |
| # GPU registrations; see CMAKE_REGISTRATION_RE. |
| TEST_SOURCE_SUFFIXES = (".cpp", ".cu") |
| |
| # Share of the test suite above which an explicit selection is replaced by the |
| # full ``buildtests`` target. |
| DEFAULT_MAX_FRACTION = 0.85 |
| |
| # Changes matching these patterns cannot affect which tests exist or what they |
| # cover, so they select nothing. |
| IGNORED_PATTERNS = ( |
| ".gitattributes", |
| ".gitignore", |
| ".clang-format", |
| ".clang-tidy", |
| "*.md", |
| "*.dox", |
| "AGENTS.md", |
| "COPYING*", |
| "INSTALL", |
| "README*", |
| "REUSE.toml", |
| ".agents/*", |
| ".gitlab/*", |
| "LICENSES/*", |
| "benchmarks/*", |
| # The CI YAML is orchestration -- rules, tags, artifacts, job names -- and |
| # cannot change which test includes which header, which is the only mapping |
| # this selector maintains. Ignored rather than merely dropped from |
| # FULL_REBUILD_PATTERNS below: these files are not under SCAN_ROOTS, so an |
| # un-forced path would instead fall through to "not in the include graph" |
| # and force the full suite anyway. |
| "ci/*.gitlab-ci.yml", |
| "ci/CTest2JUnit.xsl", |
| "debug/*", |
| "demos/*", |
| "doc/*", |
| "unsupported/benchmarks/*", |
| "unsupported/doc/*", |
| ) |
| |
| # Changes matching these patterns invalidate the include-graph mapping itself |
| # (test registration, split counts, the CI drivers, or shim libraries whose |
| # tests are not modelled here), so they force the full test suite. Checked |
| # after IGNORED_PATTERNS, so a benchmark's or the docs' own CMakeLists.txt does |
| # not drag in the whole suite. |
| FULL_REBUILD_PATTERNS = ( |
| "CMakeLists.txt", |
| "*/CMakeLists.txt", |
| "*.cmake", |
| "*.cmake.in", |
| # Keeps forcing: it defines the selector-to-driver contract itself |
| # (EIGEN_CI_BUILD_TARGET_FILE, EIGEN_CI_CTEST_REGEX_FILE). |
| ".gitlab-ci.yml", |
| # ci/ splits: the scripts and the image definitions can change what a build |
| # produces or how a test runs. The *.gitlab-ci.yml files cannot, and are |
| # ignored above. |
| "ci/scripts/*", |
| "ci/docker/*", |
| "cmake/*", |
| "scripts/*", |
| "blas/*", |
| "lapack/*", |
| ) |
| |
| INCLUDE_RE = re.compile(r'^[ \t]*#[ \t]*include[ \t]*[<"]([^>"]+)[>"]', re.MULTILINE) |
| # One pass over a test CMakeLists.txt, in source order, because what a |
| # registration means depends on the state at that point: the source extension |
| # comes from EIGEN_ADD_TEST_FILENAME_EXTENSION, which the CUDA and HIP blocks |
| # set to "cu" and unset again, so gpu_basic is test/gpu_basic.cu while its |
| # neighbours are .cpp; and a name spelled ${var} resolves against the item list |
| # of the enclosing foreach(), which is how the GPU module registers its |
| # cusolver_* and cudss_* tests. |
| CMAKE_REGISTRATION_RE = re.compile( |
| r"^[ \t]*(?:" |
| r'(?P<scope>set|unset)\([ \t]*EIGEN_ADD_TEST_FILENAME_EXTENSION[ \t]*"?(?P<extension>[A-Za-z_0-9]*)"?' |
| r"|(?:ei_add_test|ei_add_gpu_test)\([ \t]*" |
| r"(?P<test>[A-Za-z_][A-Za-z_0-9]*|\$\{[A-Za-z_][A-Za-z_0-9]*\})" |
| r"|add_executable\([ \t]*(?P<executable>[A-Za-z_][A-Za-z_0-9]*)[ \t\r\n]+(?P<sources>[^)]*)\)" |
| r"|foreach\((?P<loop>[^)]*)\)" |
| r"|(?P<endloop>endforeach)\(" |
| r")", |
| re.MULTILINE, |
| ) |
| # A bare CMake identifier, used to recognise a foreach loop variable. |
| CMAKE_NAME_RE = re.compile(r"[A-Za-z_][A-Za-z_0-9]*\Z") |
| CMAKE_FAILTEST_RE = re.compile( |
| r'^[ \t]*ei_add_failtest\([ \t]*"?([A-Za-z_][A-Za-z_0-9]*)"?', |
| re.MULTILINE, |
| ) |
| |
| |
| def _matches(path, patterns): |
| return any(fnmatch.fnmatch(path, p) for p in patterns) |
| |
| |
| def _under(path, roots): |
| return any(path.startswith(root + "/") for root in roots) |
| |
| |
| class IncludeGraph: |
| """Textual ``#include`` graph over the scanned source roots.""" |
| |
| def __init__(self, source_dir): |
| self.source_dir = source_dir |
| self.files = set() |
| self._direct = {} |
| self._by_suffix = {} |
| self._scan() |
| |
| def _scan(self): |
| for root in SCAN_ROOTS: |
| abs_root = os.path.join(self.source_dir, root) |
| if not os.path.isdir(abs_root): |
| continue |
| for dirpath, dirnames, filenames in os.walk(abs_root): |
| dirnames[:] = [d for d in dirnames if not d.startswith(".")] |
| for name in filenames: |
| rel = os.path.relpath(os.path.join(dirpath, name), self.source_dir) |
| self.files.add(rel) |
| # Index every path suffix so that an include spelled relative to a |
| # directory outside the scanned roots still resolves. Ambiguous |
| # suffixes are dropped rather than guessed. |
| candidates = {} |
| for rel in self.files: |
| parts = rel.split("/") |
| for i in range(len(parts)): |
| candidates.setdefault("/".join(parts[i:]), []).append(rel) |
| self._by_suffix = {suffix: matches[0] |
| for suffix, matches in candidates.items() if len(matches) == 1} |
| |
| def read_text(self, rel): |
| """Contents of a file in the tree, or ``''`` if it cannot be read.""" |
| try: |
| with open(os.path.join(self.source_dir, rel), "r", errors="ignore") as handle: |
| return handle.read() |
| except OSError: |
| return "" |
| |
| def direct_includes(self, rel): |
| """Resolved includes of a single file.""" |
| cached = self._direct.get(rel) |
| if cached is not None: |
| return cached |
| resolved = set() |
| self._direct[rel] = resolved # placed first: the graph has cycles |
| directory = os.path.dirname(rel) |
| for spelling in INCLUDE_RE.findall(self.read_text(rel)): |
| candidate = os.path.normpath(os.path.join(directory, spelling)) |
| if candidate in self.files: |
| resolved.add(candidate) |
| elif spelling in self.files: |
| resolved.add(spelling) |
| elif spelling in self._by_suffix: |
| resolved.add(self._by_suffix[spelling]) |
| return resolved |
| |
| def closure(self, rel): |
| """Every file reachable from ``rel`` through includes.""" |
| seen = set() |
| stack = [rel] |
| while stack: |
| for nxt in self.direct_includes(stack.pop()): |
| if nxt not in seen: |
| seen.add(nxt) |
| stack.append(nxt) |
| return seen |
| |
| |
| # targets -- test translation unit -> the CMake target that compiles it |
| # standalone -- targets the ``buildtests`` aggregate does not depend on |
| # failtests -- failtest translation unit -> the CTest names that compile it |
| Registrations = collections.namedtuple("Registrations", "targets standalone failtests") |
| |
| |
| def _loop_binding(arguments): |
| """Names a ``foreach(...)`` binds, or ``None`` when they are not literal.""" |
| tokens = [token.strip('"') for token in arguments.split()] |
| if not tokens or not CMAKE_NAME_RE.match(tokens[0]): |
| return None |
| variable, items = tokens[0], tokens[1:] |
| if items[:2] == ["IN", "ITEMS"]: |
| items = items[2:] |
| elif items[:1] == ["IN"]: |
| # IN LISTS and IN ZIP_LISTS iterate variables, not literal names. |
| return None |
| return variable, [item for item in items if "$" not in item] |
| |
| |
| def _loop_expand(token, loops): |
| """Resolve a registration name against the enclosing ``foreach`` bindings.""" |
| if not token.startswith("$"): |
| return [token] |
| name = token[2:-1] |
| for binding in reversed(loops): |
| if binding is not None and binding[0] == name: |
| return binding[1] |
| return [] |
| |
| |
| def test_registrations(graph): |
| """Map registered translation units to what CI has to build or run.""" |
| source_targets = {} |
| standalone = set() |
| |
| def register(source, target): |
| previous = source_targets.get(source) |
| if previous is not None and previous != target: |
| raise ValueError("%s is registered by both %s and %s" % (source, previous, target)) |
| source_targets[source] = target |
| |
| cmake_files = sorted( |
| rel |
| for rel in graph.files |
| if os.path.basename(rel) == "CMakeLists.txt" |
| and _under(rel, TEST_ROOTS) |
| and not _under(rel, EXCLUDED_TEST_DIRS) |
| ) |
| for cmake_file in cmake_files: |
| directory = os.path.dirname(cmake_file) |
| extension = "cpp" |
| # foreach() bindings in effect, innermost last. A loop over anything |
| # but a literal item list pushes None so endforeach() stays balanced. |
| loops = [] |
| for match in CMAKE_REGISTRATION_RE.finditer(graph.read_text(cmake_file)): |
| if match.group("scope"): |
| # unset(), or a set() with no value, restores the default. |
| extension = match.group("extension") if match.group("scope") == "set" else "" |
| extension = extension or "cpp" |
| continue |
| if match.group("loop") is not None: |
| loops.append(_loop_binding(match.group("loop"))) |
| continue |
| if match.group("endloop"): |
| if loops: |
| loops.pop() |
| continue |
| if match.group("test"): |
| for target in _loop_expand(match.group("test"), loops): |
| source = os.path.normpath( |
| os.path.join(directory, "%s.%s" % (target, extension))) |
| if source in graph.files: |
| register(source, target) |
| continue |
| target = match.group("executable") |
| for token in re.findall(r'"[^"]*"|[^\s]+', match.group("sources")): |
| token = token.strip('"') |
| if not token.endswith(TEST_SOURCE_SUFFIXES) or "$" in token: |
| continue |
| source = os.path.normpath(os.path.join(directory, token)) |
| if source in graph.files: |
| register(source, target) |
| standalone.add(target) |
| |
| failtests = {} |
| for name in CMAKE_FAILTEST_RE.findall(graph.read_text(FAILTEST_ROOT + "/CMakeLists.txt")): |
| source = "%s/%s.cpp" % (FAILTEST_ROOT, name) |
| if source in graph.files: |
| failtests[source] = (name + "_ok", name + "_ko") |
| |
| return Registrations(source_targets, standalone, failtests) |
| |
| |
| def full_suite(graph, reasons): |
| """Full-suite selection, naming the targets ``buildtests`` does not build.""" |
| try: |
| standalone = test_registrations(graph).standalone |
| except ValueError: |
| # A broken registration is reported by the paths that depend on the |
| # mapping; the full suite stays available without it. |
| standalone = () |
| return Selection("all", reasons=reasons, standalone=standalone) |
| |
| |
| def reverse_map(graph, sources): |
| """Map each included file to the test sources that reach it.""" |
| reverse = {} |
| for src in sources: |
| for dep in graph.closure(src): |
| reverse.setdefault(dep, set()).add(src) |
| return reverse |
| |
| |
| class Selection: |
| """Outcome: the full suite, explicit targets, no tests, or an error.""" |
| |
| def __init__(self, mode, targets=(), reasons=(), ctest_names=(), standalone=()): |
| self.mode = mode # "all", "targets", "none", or "error" |
| self.targets = set(targets) |
| self.reasons = list(reasons) |
| # CTest names with no build target of their own. |
| self.ctest_names = set(ctest_names) |
| # Targets to name alongside ``buildtests`` in "all" mode. |
| self.standalone = set(standalone) |
| |
| @property |
| def targets_file(self): |
| if self.mode == "error": |
| raise ValueError("an invalid selection has no target file") |
| if self.mode == "all": |
| return "".join(name + "\n" for name in ["buildtests"] + sorted(self.standalone)) |
| if self.mode == "none": |
| return "NONE\n" |
| return "".join(name + "\n" for name in sorted(self.targets)) |
| |
| @property |
| def regex_file(self): |
| if self.mode == "error": |
| raise ValueError("an invalid selection has no regex file") |
| if self.mode == "all": |
| return "ALL\n" |
| if self.mode == "none": |
| return "NONE\n" |
| names = sorted(self.targets) + sorted(self.ctest_names) |
| return "^(%s)(_[0-9]+)?$\n" % "|".join(re.escape(name) for name in names) |
| |
| |
| def select(graph, changed_files, max_fraction=DEFAULT_MAX_FRACTION): |
| """Map changed paths to the tests that must run.""" |
| paths = [] |
| for path in changed_files: |
| path = path.strip() |
| if path and not _matches(path, IGNORED_PATTERNS): |
| paths.append(path) |
| if not paths: |
| return Selection("none", reasons=["no change reaches a test"]) |
| for path in paths: |
| if _matches(path, FULL_REBUILD_PATTERNS): |
| return full_suite(graph, ["%s forces the full suite" % path]) |
| |
| try: |
| registered = test_registrations(graph) |
| except ValueError as error: |
| return Selection("error", reasons=[str(error)]) |
| sources = sorted(registered.targets) |
| reverse = reverse_map(graph, sources) |
| failtest_reverse = reverse_map(graph, sorted(registered.failtests)) |
| |
| selected = set() |
| selected_failtests = set() |
| reasons = [] |
| for path in paths: |
| reached_by = set(reverse.get(path, ())) |
| if path in registered.targets: |
| reached_by.add(path) |
| failtests = set(failtest_reverse.get(path, ())) |
| if path in registered.failtests: |
| failtests.add(path) |
| if reached_by or failtests: |
| selected |= reached_by |
| selected_failtests |= failtests |
| continue |
| if path in graph.files: |
| # A source file in the tree that nothing includes: either a new |
| # header not yet wired up or an unregistered translation unit. |
| roots = TEST_ROOTS + (FAILTEST_ROOT,) |
| if (_under(path, roots) and not _under(path, EXCLUDED_TEST_DIRS) |
| and path.endswith(TEST_SOURCE_SUFFIXES)): |
| return Selection("error", reasons=["%s has no CMake test target" % path]) |
| reasons.append("%s is in the tree but reaches no test" % path) |
| continue |
| # Deleted, renamed, or outside every scanned root: the graph cannot say |
| # what it affected, so do not guess. |
| return full_suite(graph, ["%s is not in the include graph" % path]) |
| |
| if not selected and not selected_failtests: |
| return Selection("none", reasons=reasons or ["no change reaches a test"]) |
| |
| if len(selected) > max_fraction * len(sources): |
| reasons.append( |
| "%d of %d test sources selected (>%.0f%%)" |
| % (len(selected), len(sources), 100 * max_fraction) |
| ) |
| return full_suite(graph, reasons) |
| |
| ctest_names = set() |
| for source in sorted(selected_failtests): |
| ctest_names.update(registered.failtests[source]) |
| if ctest_names: |
| reasons.append("%d compile-failure test(s) build from inside CTest" |
| % len(selected_failtests)) |
| return Selection("targets", (registered.targets[s] for s in selected), reasons, |
| ctest_names=ctest_names) |
| |
| |
| def changed_files_from_git(source_dir, base_sha, head="HEAD"): |
| """Paths changed between ``base_sha`` and ``head``.""" |
| result = subprocess.run( |
| ["git", "diff", "--no-renames", "--name-only", "%s...%s" % (base_sha, head)], |
| cwd=source_dir, |
| capture_output=True, |
| text=True, |
| ) |
| if result.returncode != 0: |
| raise RuntimeError("git diff failed: %s" % result.stderr.strip()) |
| return [line for line in result.stdout.splitlines() if line.strip()] |
| |
| |
| def parse_args(argv): |
| parser = argparse.ArgumentParser(description=__doc__, |
| formatter_class=argparse.RawDescriptionHelpFormatter) |
| default_source = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
| parser.add_argument("--source-dir", default=default_source, |
| help="Eigen source tree (default: the tree containing this script)") |
| parser.add_argument("--base-sha", |
| help="compute changed files from 'git diff BASE...HEAD'") |
| parser.add_argument("--head", default="HEAD", help="head revision for --base-sha") |
| parser.add_argument("--changed-files", |
| help="read newline-separated changed paths from this file ('-' for stdin)") |
| parser.add_argument("--output-dir", |
| help="write targets.txt and ctest_regex.txt here") |
| parser.add_argument("--max-fraction", type=float, default=DEFAULT_MAX_FRACTION, |
| help="degrade to the full suite above this fraction (default: %(default)s)") |
| return parser.parse_args(argv) |
| |
| |
| def main(argv=None): |
| args = parse_args(argv) |
| |
| if args.changed_files: |
| if args.changed_files == "-": |
| changed = sys.stdin.read().splitlines() |
| else: |
| with open(args.changed_files) as handle: |
| changed = handle.read().splitlines() |
| elif args.base_sha: |
| try: |
| changed = changed_files_from_git(args.source_dir, args.base_sha, args.head) |
| except RuntimeError as error: |
| # Without a usable diff there is no basis for narrowing. |
| print("%s; selecting the full suite" % error, file=sys.stderr) |
| changed = None |
| else: |
| print("one of --base-sha or --changed-files is required", file=sys.stderr) |
| return 2 |
| |
| graph = IncludeGraph(args.source_dir) |
| if changed is None: |
| selection = full_suite(graph, ["the merge-base diff is unavailable"]) |
| else: |
| selection = select(graph, changed, args.max_fraction) |
| |
| print("mode: %s" % selection.mode, file=sys.stderr) |
| for reason in selection.reasons: |
| print(" %s" % reason, file=sys.stderr) |
| if selection.mode == "error": |
| return 1 |
| if selection.mode == "targets": |
| print(" %d targets: %s" % (len(selection.targets), |
| " ".join(sorted(selection.targets))), file=sys.stderr) |
| if selection.ctest_names: |
| print(" %d CTest-only: %s" % (len(selection.ctest_names), |
| " ".join(sorted(selection.ctest_names))), |
| file=sys.stderr) |
| |
| if args.output_dir: |
| os.makedirs(args.output_dir, exist_ok=True) |
| with open(os.path.join(args.output_dir, "targets.txt"), "w") as handle: |
| handle.write(selection.targets_file) |
| with open(os.path.join(args.output_dir, "ctest_regex.txt"), "w") as handle: |
| handle.write(selection.regex_file) |
| else: |
| sys.stdout.write(selection.targets_file) |
| |
| return 0 |
| |
| |
| if __name__ == "__main__": |
| sys.exit(main()) |