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}"