|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +check_orphaned_platform_patches.py |
| 4 | +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
| 5 | +Detect patch files in ``patch/`` that vinca will silently never wire |
| 6 | +into any recipe's ``patches:`` list. |
| 7 | +
|
| 8 | +Background |
| 9 | +---------- |
| 10 | +vinca (see ``vinca/main.py`` around the ``patch_dir`` glob, and |
| 11 | +``vinca/utils.py::add_package_name_variants``) builds a dict keyed by |
| 12 | +the patch filename's prefix (everything before an optional |
| 13 | +``.osx``/``.win``/``.linux``/``.unix``/``.emscripten`` suffix), then |
| 14 | +cross-links name-prefix variants of the *same* logical package |
| 15 | +(``X`` <-> ``ros-X`` <-> ``ros2-X`` <-> ``ros-<distro>-X``) via |
| 16 | +``dict.setdefault()``. |
| 17 | +
|
| 18 | +``setdefault`` only fills in a key that is still *absent*. If a |
| 19 | +package has a plain patch under one prefix (say ``ros2-foo.patch``) |
| 20 | +and a platform-specific patch under a *different* prefix (say |
| 21 | +``ros-jazzy-foo.osx.patch``), both prefixes already exist as their own |
| 22 | +dict entries by the time the cross-link step runs, so the two never |
| 23 | +merge. Whichever prefix vinca does *not* resolve as the package's |
| 24 | +final conda name for a given recipe simply never appears in that |
| 25 | +recipe's ``patches:`` list -- with no error and no warning. This |
| 26 | +exact bug orphaned ``ros-jazzy-sick-scan-xd.osx.patch`` for months |
| 27 | +before it was renamed to ``ros2-sick-scan-xd.osx.patch`` (matching the |
| 28 | +prefix jazzy actually resolves sick_scan_xd's own patch under). |
| 29 | +
|
| 30 | +``check_patches_clean_apply.py`` does not catch this: it verifies that |
| 31 | +every patch file on disk applies cleanly to source, but never checks |
| 32 | +whether vinca's real name-resolution would actually attach that file |
| 33 | +to any package's generated recipe at all. |
| 34 | +
|
| 35 | +What this script does |
| 36 | +---------------------- |
| 37 | +Replicates vinca's exact patch-dict-construction and |
| 38 | +``add_package_name_variants`` shortname-stripping logic (kept in sync |
| 39 | +with whatever revision this repo's ``pixi.toml`` pins vinca to -- if |
| 40 | +that mechanism ever changes upstream, re-check this script). It groups |
| 41 | +patch-file prefixes by their computed "shortname" and flags any group |
| 42 | +where more than one *distinct* literal prefix was actually used by a |
| 43 | +file on disk: only one of those prefixes can ever be the resolved |
| 44 | +package name for a given recipe, so content under the others is dead. |
| 45 | +
|
| 46 | +Exit code is non-zero (and the offending groups are printed) if any |
| 47 | +such collision is found. |
| 48 | +""" |
| 49 | + |
| 50 | +from __future__ import annotations |
| 51 | + |
| 52 | +import glob |
| 53 | +import os |
| 54 | +import re |
| 55 | +import sys |
| 56 | +from pathlib import Path |
| 57 | + |
| 58 | +REPO_ROOT = Path(__file__).resolve().parent |
| 59 | +PATCH_DIR = REPO_ROOT / "patch" |
| 60 | + |
| 61 | +_ROS_DISTRO_RE = re.compile(r"^ros_distro:\s*(\S+)\s*$", re.MULTILINE) |
| 62 | + |
| 63 | + |
| 64 | +def get_ros_distro() -> str: |
| 65 | + vinca_yaml = (REPO_ROOT / "vinca.yaml").read_text() |
| 66 | + match = _ROS_DISTRO_RE.search(vinca_yaml) |
| 67 | + if not match: |
| 68 | + print("Could not find 'ros_distro:' in vinca.yaml", file=sys.stderr) |
| 69 | + sys.exit(2) |
| 70 | + return match.group(1) |
| 71 | + |
| 72 | + |
| 73 | +def build_patches_dict(patch_dir: Path) -> dict[str, dict[str, list[str]]]: |
| 74 | + """Mirrors the glob loop in vinca/main.py that builds vinca_conf['_patches'].""" |
| 75 | + patches: dict[str, dict[str, list[str]]] = {} |
| 76 | + for x in sorted(glob.glob(os.path.join(str(patch_dir), "*.patch"))): |
| 77 | + splitted = os.path.basename(x).split(".") |
| 78 | + if splitted[0] not in patches: |
| 79 | + patches[splitted[0]] = { |
| 80 | + "any": [], |
| 81 | + "osx": [], |
| 82 | + "linux": [], |
| 83 | + "win": [], |
| 84 | + "emscripten": [], |
| 85 | + } |
| 86 | + if len(splitted) == 3: |
| 87 | + if splitted[1] in ("osx", "linux", "win", "emscripten"): |
| 88 | + patches[splitted[0]][splitted[1]].append(x) |
| 89 | + continue |
| 90 | + if splitted[1] == "unix": |
| 91 | + patches[splitted[0]]["linux"].append(x) |
| 92 | + patches[splitted[0]]["osx"].append(x) |
| 93 | + continue |
| 94 | + patches[splitted[0]]["any"].append(x) |
| 95 | + return patches |
| 96 | + |
| 97 | + |
| 98 | +def shortname_of(name: str, ros_distro: str) -> str: |
| 99 | + """Mirrors the prefix-stripping in vinca/utils.py::add_package_name_variants.""" |
| 100 | + legacy_prefix = f"ros-{ros_distro}-" |
| 101 | + if name.startswith(legacy_prefix): |
| 102 | + return name[len(legacy_prefix):] |
| 103 | + elif name.startswith("ros2-"): |
| 104 | + return name[len("ros2-"):] |
| 105 | + elif name.startswith("ros-"): |
| 106 | + return name[len("ros-"):] |
| 107 | + else: |
| 108 | + return name |
| 109 | + |
| 110 | + |
| 111 | +def main() -> int: |
| 112 | + ros_distro = get_ros_distro() |
| 113 | + patches = build_patches_dict(PATCH_DIR) |
| 114 | + |
| 115 | + groups: dict[str, list[str]] = {} |
| 116 | + for prefix in patches: |
| 117 | + groups.setdefault(shortname_of(prefix, ros_distro), []).append(prefix) |
| 118 | + |
| 119 | + collisions = { |
| 120 | + shortname: prefixes |
| 121 | + for shortname, prefixes in groups.items() |
| 122 | + if len(prefixes) > 1 |
| 123 | + } |
| 124 | + |
| 125 | + if not collisions: |
| 126 | + print(f"OK: no orphaned platform-specific patches ({len(patches)} patch-file prefixes scanned).") |
| 127 | + return 0 |
| 128 | + |
| 129 | + print( |
| 130 | + "ORPHANED PLATFORM PATCH RISK: the following packages have patch files " |
| 131 | + "spread across more than one name-prefix variant. vinca's " |
| 132 | + "add_package_name_variants() cross-links prefix variants via " |
| 133 | + "dict.setdefault(), which is a no-op once a variant already exists as its " |
| 134 | + "own entry -- so only ONE of the prefixes below will end up attached to " |
| 135 | + "the package's real generated recipe; any platform-specific patch under " |
| 136 | + "the others is silently never applied.\n", |
| 137 | + file=sys.stderr, |
| 138 | + ) |
| 139 | + for shortname, prefixes in sorted(collisions.items()): |
| 140 | + print(f" {shortname}:", file=sys.stderr) |
| 141 | + for prefix in sorted(prefixes): |
| 142 | + files = [ |
| 143 | + os.path.basename(f) |
| 144 | + for platform_files in patches[prefix].values() |
| 145 | + for f in platform_files |
| 146 | + ] |
| 147 | + print(f" {prefix}: {', '.join(sorted(files))}", file=sys.stderr) |
| 148 | + print( |
| 149 | + "\nFix: rename the patch file(s) so every file for a given package shares " |
| 150 | + "the SAME name prefix (matching whichever prefix that package's own " |
| 151 | + "recipe.yaml actually resolves to -- check recipes/<pkg>/*/recipe.yaml's " |
| 152 | + "source.patches entries, or regenerate recipes locally and inspect).", |
| 153 | + file=sys.stderr, |
| 154 | + ) |
| 155 | + return 1 |
| 156 | + |
| 157 | + |
| 158 | +if __name__ == "__main__": |
| 159 | + sys.exit(main()) |
0 commit comments