Skip to content

Commit b0d38bf

Browse files
committed
fix(ci): fail cleanly on a malformed cuda.build.version in versions.yml
check_pixi_cuda_version.py returns a diagnostic exit code for every problem it anticipates -- missing versions.yml, missing cuda.build.version, missing pixi.toml, missing feature key -- and then unpacks the version with no checking at all: major, minor, *_ = build_version.split(".") YAML makes that easy to break. `version: "13.3.0"` is quoted today, but drop the quotes and a two-component value loads as a float and a bare number as an int, neither of which has `.split`. Against the real pixi.toml files: version: 13.3 -> AttributeError: 'float' object has no attribute 'split' version: 13 -> AttributeError: 'int' object has no attribute 'split' version: -> AttributeError: 'NoneType' object has no attribute 'split' version: [13, 3] -> AttributeError: 'list' object has no attribute 'split' version: "13" -> ValueError: not enough values to unpack (expected at least 2, got 1) All five escape as an uncaught traceback from a pre-commit hook, pointing at this script rather than at the line the contributor edited. Add `parse_build_version`, which returns `(major, minor)` only for a `<major>.<minor>[.<patch>]` string of digits, and have `main` report the bad value with the same `return 2` shape as its neighbours. The message names the YAML quoting trap, since that is how the value goes wrong in practice. Adds the first tests for this script. The parse tests cover the accepted shapes and every rejected one; the end-to-end tests drive `main()` against a temporary repo layout and assert exit 2 plus the diagnostic.
1 parent 3bd069a commit b0d38bf

2 files changed

Lines changed: 140 additions & 1 deletion

File tree

ci/tools/check_pixi_cuda_version.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,24 @@
1616
PIXI_FILES = [ROOT / d / "pixi.toml" for d in ("cuda_bindings", "cuda_core")]
1717

1818

19+
def parse_build_version(build_version: object) -> tuple[str, str] | None:
20+
"""Split ``cuda.build.version`` into ``(major, minor)``, or ``None``.
21+
22+
Returns ``None`` for anything that is not a ``<major>.<minor>[.…]`` string
23+
of digits. YAML makes this easy to get wrong: an unquoted ``13.3`` loads as
24+
the float ``13.3`` and an unquoted ``13`` as the int ``13``, neither of
25+
which has ``.split``. Without this check those -- and a quoted but
26+
single-component ``"13"`` -- escaped as a raw traceback from a pre-commit
27+
hook whose every other failure path returns a diagnostic exit code.
28+
"""
29+
if not isinstance(build_version, str):
30+
return None
31+
parts = build_version.split(".")
32+
if len(parts) < 2 or not all(part.isdigit() for part in parts[:2]):
33+
return None
34+
return parts[0], parts[1]
35+
36+
1937
def main() -> int:
2038
"""Verify cuda_bindings/cuda_core pixi pins match ci/versions.yml."""
2139
if not VERSIONS_FILE_PATH.is_file():
@@ -27,7 +45,16 @@ def main() -> int:
2745
print(f"error: cuda.build.version not found in {VERSIONS_FILE_PATH}", file=sys.stderr)
2846
return 2
2947

30-
major, minor, *_ = build_version.split(".")
48+
parsed = parse_build_version(build_version)
49+
if parsed is None:
50+
print(
51+
f"error: cuda.build.version={build_version!r} in {VERSIONS_FILE_PATH} is not a "
52+
f"'<major>.<minor>[.<patch>]' version string. Quote the value in YAML so it is "
53+
f"not loaded as a number (13.3 becomes a float, 13 becomes an int).",
54+
file=sys.stderr,
55+
)
56+
return 2
57+
major, minor = parsed
3158
expected = f"{major}.{minor}.*"
3259
cuda_feature = f"cu{major}"
3360

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
from __future__ import annotations
5+
6+
import os
7+
import sys
8+
import textwrap
9+
10+
import pytest
11+
12+
# check_pixi_cuda_version imports PyYAML at module scope (the pre-commit hook
13+
# declares it via additional_dependencies), so skip rather than fail collection
14+
# when this module is exercised outside that environment.
15+
pytest.importorskip("yaml")
16+
17+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
18+
import check_pixi_cuda_version as mod
19+
from check_pixi_cuda_version import parse_build_version
20+
21+
PIXI_TOML = textwrap.dedent("""\
22+
[workspace.build-variants]
23+
cuda-version = ["12.*", "13.3.*"]
24+
25+
[feature.cu13.dependencies]
26+
cuda-version = "13.3.*"
27+
""")
28+
29+
30+
@pytest.mark.agent_authored(model="claude-opus-5")
31+
@pytest.mark.parametrize(
32+
("raw", "expected"),
33+
[
34+
pytest.param("13.3.0", ("13", "3"), id="three-part"),
35+
pytest.param("12.9.1", ("12", "9"), id="three-part-other"),
36+
pytest.param("13.3", ("13", "3"), id="two-part"),
37+
pytest.param("13.3.0.1", ("13", "3"), id="four-part"),
38+
],
39+
)
40+
def test_parse_build_version_accepts_version_strings(raw, expected):
41+
assert parse_build_version(raw) == expected
42+
43+
44+
@pytest.mark.agent_authored(model="claude-opus-5")
45+
@pytest.mark.parametrize(
46+
"raw",
47+
[
48+
# YAML turns an unquoted `version: 13.3` into a float and an unquoted
49+
# `version: 13` into an int. Neither has .split(), so the tool used to
50+
# die with an AttributeError traceback.
51+
pytest.param(13.3, id="float-from-unquoted-yaml"),
52+
pytest.param(13, id="int-from-unquoted-yaml"),
53+
pytest.param(None, id="none-from-empty-yaml-value"),
54+
pytest.param(["13", "3"], id="list"),
55+
# Quoted, but not a <major>.<minor> version: the tuple unpacking used
56+
# to die with "not enough values to unpack".
57+
pytest.param("13", id="single-component"),
58+
pytest.param("", id="empty-string"),
59+
pytest.param("13.", id="trailing-dot"),
60+
pytest.param(".3", id="leading-dot"),
61+
pytest.param("cuda.13", id="non-numeric-major"),
62+
],
63+
)
64+
def test_parse_build_version_rejects_everything_else(raw):
65+
assert parse_build_version(raw) is None
66+
67+
68+
@pytest.mark.agent_authored(model="claude-opus-5")
69+
@pytest.mark.parametrize(
70+
("yaml_value", "note"),
71+
[
72+
pytest.param("13.3", "unquoted two-part version loads as a float", id="unquoted-float"),
73+
pytest.param("13", "unquoted single number loads as an int", id="unquoted-int"),
74+
pytest.param('"13"', "quoted but missing a minor component", id="quoted-single-component"),
75+
],
76+
)
77+
def test_main_reports_a_malformed_build_version(tmp_path, monkeypatch, capsys, yaml_value, note):
78+
"""A malformed ci/versions.yml must produce this tool's own diagnostic and
79+
exit 2, not an uncaught traceback out of a pre-commit hook."""
80+
(tmp_path / "ci").mkdir()
81+
(tmp_path / "ci" / "versions.yml").write_text(f"cuda:\n build:\n version: {yaml_value}\n", encoding="utf-8")
82+
pixi_files = []
83+
for package in ("cuda_bindings", "cuda_core"):
84+
(tmp_path / package).mkdir()
85+
path = tmp_path / package / "pixi.toml"
86+
path.write_text(PIXI_TOML, encoding="utf-8")
87+
pixi_files.append(path)
88+
89+
monkeypatch.setattr(mod, "ROOT", tmp_path)
90+
monkeypatch.setattr(mod, "VERSIONS_FILE_PATH", tmp_path / "ci" / "versions.yml")
91+
monkeypatch.setattr(mod, "PIXI_FILES", pixi_files)
92+
93+
assert mod.main() == 2, note
94+
assert "is not a '<major>.<minor>[.<patch>]' version string" in capsys.readouterr().err
95+
96+
97+
@pytest.mark.agent_authored(model="claude-opus-5")
98+
def test_main_accepts_a_well_formed_build_version(tmp_path, monkeypatch):
99+
(tmp_path / "ci").mkdir()
100+
(tmp_path / "ci" / "versions.yml").write_text('cuda:\n build:\n version: "13.3.0"\n', encoding="utf-8")
101+
pixi_files = []
102+
for package in ("cuda_bindings", "cuda_core"):
103+
(tmp_path / package).mkdir()
104+
path = tmp_path / package / "pixi.toml"
105+
path.write_text(PIXI_TOML, encoding="utf-8")
106+
pixi_files.append(path)
107+
108+
monkeypatch.setattr(mod, "ROOT", tmp_path)
109+
monkeypatch.setattr(mod, "VERSIONS_FILE_PATH", tmp_path / "ci" / "versions.yml")
110+
monkeypatch.setattr(mod, "PIXI_FILES", pixi_files)
111+
112+
assert mod.main() == 0

0 commit comments

Comments
 (0)