scripts: validate versions of python deps pulled in via vpython.
Adds a unit test that checks a list of vpython specs in chromite
and ensures that the versions of packages they pull in are
consistent.
To parse the vpython specs, out-of-tree python bindings for the
vpython spec.proto are built using https://crrev.com/c/4672299
and the command
./compile_build_api_proto --destination gen_test \
--source-root=.../infra/go/src \
.../infra/go/src/go.chromium.org/luci/vpython/api/vpython
BUG=b:290711879
TEST=Adds the unit test, vpython_consistency_unittest.py
Change-Id: I1e229f9ccb74e83e622b86d15793e65fd3bcc4e0
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/chromite/+/4672293
Tested-by: Trent Apted <tapted@chromium.org>
Commit-Queue: Trent Apted <tapted@chromium.org>
Reviewed-by: Jack Rosenthal <jrosenth@chromium.org>
diff --git a/scripts/vpython_consistency_unittest.py b/scripts/vpython_consistency_unittest.py
new file mode 100644
index 0000000..498304a
--- /dev/null
+++ b/scripts/vpython_consistency_unittest.py
@@ -0,0 +1,124 @@
+# Copyright 2023 The ChromiumOS Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""Test to ensure consistency between vpython environments."""
+
+from pathlib import Path
+from typing import Dict, List, Set, Tuple
+
+from chromite.third_party.google.protobuf import text_format
+from packaging import version
+
+from chromite.api.gen_test.go.chromium.org.luci.vpython.api.vpython import (
+ spec_pb2,
+)
+from chromite.lib import constants
+
+
+# The list of vpython environments to check for consistency. Paths are relative
+# to CHROMITE_DIR.
+_INPUTS = [
+ "bin/export_to_gcloud.vpython3",
+ "scripts/black",
+ "scripts/isort",
+ "scripts/mypy",
+ "scripts/pylint",
+ "scripts/run_tests.vpython3",
+ "scripts/vpython_wrapper.py",
+]
+
+# The list of exceptions in the format emitted by assertions in this test. I.e.,
+# <wheel>: <path> wants <old-version> but <path> has <latest-version>
+_EXCEPTIONS = {
+ "infra/python/wheels/mypy-extensions-py3: scripts/run_tests.vpython3"
+ " wants 0.4.3 but scripts/mypy has 1.0.0",
+ "infra/python/wheels/mypy-extensions-py3: scripts/black"
+ " wants 0.4.3 but scripts/mypy has 1.0.0",
+ "infra/python/wheels/mypy-extensions-py3: bin/export_to_gcloud.vpython3"
+ " wants 0.4.3 but scripts/mypy has 1.0.0",
+ "infra/python/wheels/protobuf-py2_py3: scripts/run_tests.vpython3"
+ " wants 3.13.0 but bin/export_to_gcloud.vpython3 has 3.18.1",
+ "infra/python/wheels/pyasn1-py2_py3: scripts/vpython_wrapper.py"
+ " wants 0.2.3 but scripts/run_tests.vpython3 has 0.4.8",
+ "infra/python/wheels/pyasn1_modules-py2_py3: scripts/vpython_wrapper.py"
+ " wants 0.0.8 but scripts/run_tests.vpython3 has 0.2.8",
+ "infra/python/wheels/typing-extensions-py3: scripts/mypy"
+ " wants 3.10.0.2 but scripts/run_tests.vpython3 has 4.0.1",
+ "infra/python/wheels/typing-extensions-py3: bin/export_to_gcloud.vpython3"
+ " wants 3.7.4.3 but scripts/run_tests.vpython3 has 4.0.1",
+ "infra/python/wheels/tomli-py3: scripts/mypy"
+ " wants 1.1.0 but scripts/run_tests.vpython3 has 2.0.1",
+ "infra/python/wheels/tomli-py3: scripts/black"
+ " wants 1.1.0 but scripts/run_tests.vpython3 has 2.0.1",
+ "infra/python/wheels/isort-py3: scripts/isort"
+ " wants 5.8.0 but scripts/pylint has 5.10.1",
+}
+
+_BEGIN_GUARD = "[VPYTHON:BEGIN]"
+_END_GUARD = "[VPYTHON:END]"
+
+
+def _parse(path: Path) -> Tuple[Path, spec_pb2.Spec]:
+ resolved_path = constants.CHROMITE_DIR / path
+ assert resolved_path.is_file(), f"{path}: Input file must exist."
+ assert (
+ not resolved_path.is_symlink()
+ ), f"{path}: Check only real files, not symlinks."
+
+ lines = resolved_path.read_text().splitlines()
+
+ # Extract the textproto from embedded specs. See
+ # https://crsrc.org/i/go/src/go.chromium.org/luci/vpython/spec/load.go
+ start_marker = next(
+ (i for i, v in enumerate(lines) if v.endswith(_BEGIN_GUARD)), -1
+ )
+ if start_marker >= 0:
+ end = next(i for i, v in enumerate(lines) if v.endswith(_END_GUARD))
+ prefix_len = lines[start_marker].find(_BEGIN_GUARD)
+ lines = [line[prefix_len:] for line in lines[start_marker + 1 : end]]
+
+ spec = spec_pb2.Spec()
+ text_format.Parse("\n".join(lines), spec)
+ return (path, spec)
+
+
+def test_vpython_consistency() -> None:
+ specs = [_parse(Path(f)) for f in _INPUTS]
+
+ # Map of package names, and the list of versions for it used by each file.
+ wheels: Dict[str, List[Tuple[version.Version, Path]]] = {}
+ for path, spec in specs:
+ for wheel in spec.wheel:
+ ver = version.parse(wheel.version.replace("version:", ""))
+ wheels.setdefault(wheel.name, []).append((ver, path))
+
+ # Sort so that the latest version is always at index[0].
+ for versions in wheels.values():
+ versions.sort(reverse=True)
+
+ violations: Set[str] = set()
+ for wheel, v in wheels.items():
+ best = None
+ for ver, path in v:
+ if best and ver != best:
+ violations.add(
+ f"{wheel}: {path} wants {ver} but {v[0][1]} has {best}"
+ )
+ else:
+ best = ver
+
+ expired = "\n".join(_EXCEPTIONS - violations)
+ new = "\n".join(violations - _EXCEPTIONS)
+
+ # Fail if an entry in _EXCEPTIONS is no longer detected and should be
+ # removed.
+ assert not expired, f"Exception no longer needed:\n{expired}"
+
+ # Fail if there are new inconsistencies. To resolve a failure here:
+ # 1. If a new version has been introduced ("foo has <new-version>"):
+ # - try to uprev other environments that want it to <new-version>.
+ # 2. If an old version has been introduced ("foo wants <old-version>"):
+ # - try to use the newest version for foo.
+ # 3. If stuff breaks, add to _EXCEPTIONS.
+ assert not new, f"New vpython version inconsistencies:\n{new}"