Skip to content

Commit 4cd463f

Browse files
committed
ci: resolve exact local wheel dependencies
1 parent 00b960f commit 4cd463f

3 files changed

Lines changed: 264 additions & 14 deletions

File tree

‎.github/workflows/build-wheel.yml‎

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,14 @@ jobs:
194194
run: |
195195
twine check --strict cuda_pathfinder/*.whl
196196
197+
- name: Resolve cuda.bindings build dependencies
198+
if: ${{ inputs.build-bindings }}
199+
run: |
200+
python ci/tools/write_wheel_constraints.py \
201+
--output wheel-constraints/cuda-bindings.txt \
202+
--host-platform "${{ inputs.host-platform }}" \
203+
--wheel cuda-pathfinder cuda_pathfinder
204+
197205
- name: Upload cuda.pathfinder build artifacts
198206
if: ${{ strategy.job-index == 0 && inputs.host-platform == 'linux-64' }}
199207
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -227,8 +235,8 @@ jobs:
227235
CIBW_ENVIRONMENT_LINUX: >
228236
CUDA_PATH=/host/${{ env.CUDA_PATH }}
229237
CUDA_PYTHON_PARALLEL_LEVEL=${{ env.CUDA_PYTHON_PARALLEL_LEVEL }}
230-
PIP_FIND_LINKS=/host/${{ github.workspace }}/cuda_pathfinder
231-
PIP_PRE=1
238+
PIP_BUILD_CONSTRAINT=/host/${{ github.workspace }}/wheel-constraints/cuda-bindings.txt
239+
PIP_CONSTRAINT=/host/${{ github.workspace }}/wheel-constraints/cuda-bindings.txt
232240
CC="/host/${{ env.SCCACHE_PATH }} cc"
233241
CXX="/host/${{ env.SCCACHE_PATH }} c++"
234242
SCCACHE_GHA_ENABLED=true
@@ -242,8 +250,8 @@ jobs:
242250
CIBW_ENVIRONMENT_WINDOWS: >
243251
CUDA_PATH="$(cygpath -w ${{ env.CUDA_PATH }})"
244252
CUDA_PYTHON_PARALLEL_LEVEL=${{ env.CUDA_PYTHON_PARALLEL_LEVEL }}
245-
PIP_FIND_LINKS="$(cygpath -w ./cuda_pathfinder)"
246-
PIP_PRE=1
253+
PIP_BUILD_CONSTRAINT="$(cygpath -w ./wheel-constraints/cuda-bindings.txt)"
254+
PIP_CONSTRAINT="$(cygpath -w ./wheel-constraints/cuda-bindings.txt)"
247255
# check cache stats before leaving cibuildwheel
248256
CIBW_BEFORE_TEST_LINUX: >
249257
"/host/${{ env.SCCACHE_PATH }}" --show-adv-stats &&
@@ -286,6 +294,15 @@ jobs:
286294
run: |
287295
twine check --strict ${{ env.CUDA_BINDINGS_ARTIFACTS_DIR }}/*.whl
288296
297+
- name: Resolve cuda.core build dependencies
298+
if: ${{ inputs.build-core }}
299+
run: |
300+
python ci/tools/write_wheel_constraints.py \
301+
--output wheel-constraints/cuda-core.txt \
302+
--host-platform "${{ inputs.host-platform }}" \
303+
--wheel cuda-pathfinder cuda_pathfinder \
304+
--wheel cuda-bindings "${{ env.CUDA_BINDINGS_ARTIFACTS_DIR }}"
305+
289306
- name: Upload cuda.bindings build artifacts
290307
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
291308
with:
@@ -311,8 +328,8 @@ jobs:
311328
CUDA_PATH=/host/${{ env.CUDA_PATH }}
312329
CUDA_PYTHON_PARALLEL_LEVEL=${{ env.CUDA_PYTHON_PARALLEL_LEVEL }}
313330
CUDA_CORE_BUILD_MAJOR=${{ env.BUILD_CUDA_MAJOR }}
314-
PIP_FIND_LINKS="/host/${{ env.CUDA_BINDINGS_ARTIFACTS_DIR }} /host/${{ github.workspace }}/cuda_pathfinder"
315-
PIP_PRE=1
331+
PIP_BUILD_CONSTRAINT=/host/${{ github.workspace }}/wheel-constraints/cuda-core.txt
332+
PIP_CONSTRAINT=/host/${{ github.workspace }}/wheel-constraints/cuda-core.txt
316333
CC="/host/${{ env.SCCACHE_PATH }} cc"
317334
CXX="/host/${{ env.SCCACHE_PATH }} c++"
318335
SCCACHE_GHA_ENABLED=true
@@ -327,8 +344,8 @@ jobs:
327344
CUDA_PATH="$(cygpath -w ${{ env.CUDA_PATH }})"
328345
CUDA_PYTHON_PARALLEL_LEVEL=${{ env.CUDA_PYTHON_PARALLEL_LEVEL }}
329346
CUDA_CORE_BUILD_MAJOR=${{ env.BUILD_CUDA_MAJOR }}
330-
PIP_FIND_LINKS="$(cygpath -w ${{ env.CUDA_BINDINGS_ARTIFACTS_DIR }}) $(cygpath -w ./cuda_pathfinder)"
331-
PIP_PRE=1
347+
PIP_BUILD_CONSTRAINT="$(cygpath -w ./wheel-constraints/cuda-core.txt)"
348+
PIP_CONSTRAINT="$(cygpath -w ./wheel-constraints/cuda-core.txt)"
332349
# check cache stats before leaving cibuildwheel
333350
CIBW_BEFORE_TEST_LINUX: >
334351
"/host${{ env.SCCACHE_PATH }}" --show-adv-stats &&
@@ -602,14 +619,24 @@ jobs:
602619
OLD_BRANCH=$(yq '.backport_branch' ci/versions.yml)
603620
OLD_BASENAME="cuda-bindings-python${PYTHON_VERSION_FORMATTED}-cuda*-${{ inputs.host-platform }}*"
604621
LATEST_PRIOR_RUN_ID=$(./ci/tools/lookup-run-id --branch "${OLD_BRANCH}" NVIDIA/cuda-python "CI")
622+
PREV_BINDINGS_DIR="cuda_bindings/dist-prev"
605623
606624
gh run download $LATEST_PRIOR_RUN_ID -p ${OLD_BASENAME} -R NVIDIA/cuda-python
607625
rm -rf ${OLD_BASENAME}-tests # exclude cython test artifacts
608626
ls -al $OLD_BASENAME
609-
mkdir -p "${{ env.CUDA_BINDINGS_ARTIFACTS_DIR }}"
610-
mv $OLD_BASENAME/*.whl "${{ env.CUDA_BINDINGS_ARTIFACTS_DIR }}"
627+
mkdir -p "${PREV_BINDINGS_DIR}"
628+
mv $OLD_BASENAME/*.whl "${PREV_BINDINGS_DIR}"
611629
rmdir $OLD_BASENAME
612630
631+
- name: Resolve previous cuda.core build dependencies
632+
if: ${{ inputs.build-core }}
633+
run: |
634+
python ci/tools/write_wheel_constraints.py \
635+
--output wheel-constraints/cuda-core-prev.txt \
636+
--host-platform "${{ inputs.host-platform }}" \
637+
--wheel cuda-pathfinder cuda_pathfinder \
638+
--wheel cuda-bindings cuda_bindings/dist-prev
639+
613640
- name: Build cuda.core wheel
614641
if: ${{ inputs.build-core }}
615642
uses: pypa/cibuildwheel@294735312765b09d24a2fbec22660ce817587d55 # v4.1.0
@@ -628,8 +655,8 @@ jobs:
628655
CUDA_PATH=/host/${{ env.CUDA_PATH }}
629656
CUDA_PYTHON_PARALLEL_LEVEL=${{ env.CUDA_PYTHON_PARALLEL_LEVEL }}
630657
CUDA_CORE_BUILD_MAJOR=${{ env.BUILD_PREV_CUDA_MAJOR }}
631-
PIP_FIND_LINKS="/host/${{ env.CUDA_BINDINGS_ARTIFACTS_DIR }} /host/${{ github.workspace }}/cuda_pathfinder"
632-
PIP_PRE=1
658+
PIP_BUILD_CONSTRAINT=/host/${{ github.workspace }}/wheel-constraints/cuda-core-prev.txt
659+
PIP_CONSTRAINT=/host/${{ github.workspace }}/wheel-constraints/cuda-core-prev.txt
633660
CC="/host/${{ env.SCCACHE_PATH }} cc"
634661
CXX="/host/${{ env.SCCACHE_PATH }} c++"
635662
SCCACHE_GHA_ENABLED=true
@@ -644,8 +671,8 @@ jobs:
644671
CUDA_PATH="$(cygpath -w ${{ env.CUDA_PATH }})"
645672
CUDA_PYTHON_PARALLEL_LEVEL=${{ env.CUDA_PYTHON_PARALLEL_LEVEL }}
646673
CUDA_CORE_BUILD_MAJOR=${{ env.BUILD_PREV_CUDA_MAJOR }}
647-
PIP_FIND_LINKS="$(cygpath -w ${{ env.CUDA_BINDINGS_ARTIFACTS_DIR }}) $(cygpath -w ./cuda_pathfinder)"
648-
PIP_PRE=1
674+
PIP_BUILD_CONSTRAINT="$(cygpath -w ./wheel-constraints/cuda-core-prev.txt)"
675+
PIP_CONSTRAINT="$(cygpath -w ./wheel-constraints/cuda-core-prev.txt)"
649676
# check cache stats before leaving cibuildwheel
650677
CIBW_BEFORE_TEST_LINUX: >
651678
"/host${{ env.SCCACHE_PATH }}" --show-adv-stats &&
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
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 sys
7+
import zipfile
8+
from pathlib import Path
9+
10+
import pytest
11+
12+
sys.path.insert(0, str(Path(__file__).parent.parent))
13+
from write_wheel_constraints import WheelConstraintError, write_constraints
14+
15+
16+
def make_wheel(directory: Path, project: str, version: str) -> Path:
17+
"""Create the wheel metadata needed by the constraint writer."""
18+
directory.mkdir(parents=True, exist_ok=True)
19+
filename_project = project.replace("-", "_").replace(".", "_")
20+
wheel_path = directory / f"{filename_project}-{version}-py3-none-any.whl"
21+
metadata_dir = f"{filename_project}-{version}.dist-info"
22+
with zipfile.ZipFile(wheel_path, "w") as wheel:
23+
wheel.writestr(
24+
f"{metadata_dir}/METADATA",
25+
f"Metadata-Version: 2.1\nName: {project}\nVersion: {version}\n",
26+
)
27+
return wheel_path
28+
29+
30+
@pytest.mark.agent_authored(model="gpt-5.6")
31+
def test_writes_exact_linux_file_references(tmp_path: Path) -> None:
32+
pathfinder_dir = tmp_path / "pathfinder wheels"
33+
bindings_dir = tmp_path / "bindings wheels"
34+
pathfinder = make_wheel(pathfinder_dir, "cuda.pathfinder", "1.7.0.dev1")
35+
bindings = make_wheel(bindings_dir, "cuda_bindings", "13.1.0.dev2")
36+
output = tmp_path / "constraints" / "cuda-core.txt"
37+
38+
write_constraints(
39+
output,
40+
"linux-64",
41+
[("cuda-pathfinder", pathfinder_dir), ("cuda-bindings", bindings_dir)],
42+
)
43+
44+
expected_pathfinder = Path("/host", *pathfinder.resolve().parts[1:]).as_uri()
45+
expected_bindings = Path("/host", *bindings.resolve().parts[1:]).as_uri()
46+
assert output.read_text(encoding="utf-8") == (
47+
f"cuda-pathfinder @ {expected_pathfinder}\ncuda-bindings @ {expected_bindings}\n"
48+
)
49+
50+
51+
@pytest.mark.agent_authored(model="gpt-5.6")
52+
def test_rejects_missing_project_wheel(tmp_path: Path) -> None:
53+
wheel_dir = tmp_path / "wheels"
54+
make_wheel(wheel_dir, "another-project", "1.0")
55+
output = tmp_path / "constraints.txt"
56+
57+
with pytest.raises(WheelConstraintError, match="expected exactly one cuda-pathfinder wheel"):
58+
write_constraints(output, "linux-64", [("cuda-pathfinder", wheel_dir)])
59+
60+
assert not output.exists()
61+
62+
63+
@pytest.mark.agent_authored(model="gpt-5.6")
64+
def test_rejects_multiple_project_wheels(tmp_path: Path) -> None:
65+
wheel_dir = tmp_path / "wheels"
66+
make_wheel(wheel_dir, "cuda-pathfinder", "1.7.0.dev1")
67+
make_wheel(wheel_dir, "cuda-pathfinder", "1.7.0.dev2")
68+
output = tmp_path / "constraints.txt"
69+
70+
with pytest.raises(WheelConstraintError, match="found 2"):
71+
write_constraints(output, "linux-64", [("cuda-pathfinder", wheel_dir)])
72+
73+
assert not output.exists()
74+
75+
76+
@pytest.mark.agent_authored(model="gpt-5.6")
77+
def test_rejects_duplicate_project_constraints(tmp_path: Path) -> None:
78+
wheel_dir = tmp_path / "wheels"
79+
make_wheel(wheel_dir, "cuda-pathfinder", "1.7.0.dev1")
80+
output = tmp_path / "constraints.txt"
81+
82+
with pytest.raises(WheelConstraintError, match="duplicate project"):
83+
write_constraints(
84+
output,
85+
"linux-64",
86+
[("cuda-pathfinder", wheel_dir), ("cuda_pathfinder", wheel_dir)],
87+
)
88+
89+
assert not output.exists()
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Write pip constraints that refer to exact wheel files."""
5+
6+
from __future__ import annotations
7+
8+
import argparse
9+
import re
10+
import sys
11+
import zipfile
12+
from email.parser import BytesParser
13+
from pathlib import Path
14+
from typing import Sequence
15+
16+
17+
class WheelConstraintError(RuntimeError):
18+
"""Raised when an exact wheel cannot be selected safely."""
19+
20+
21+
def normalize_project_name(name: str) -> str:
22+
"""Normalize a project name using the package-name rules from PEP 503."""
23+
return re.sub(r"[-_.]+", "-", name).lower()
24+
25+
26+
def read_wheel_identity(wheel_path: Path) -> tuple[str, str]:
27+
"""Read the project name and version from a wheel's core metadata."""
28+
try:
29+
with zipfile.ZipFile(wheel_path) as wheel:
30+
metadata_paths = [name for name in wheel.namelist() if name.endswith(".dist-info/METADATA")]
31+
if len(metadata_paths) != 1:
32+
raise WheelConstraintError(
33+
f"expected exactly one .dist-info/METADATA entry in {wheel_path}, found {len(metadata_paths)}"
34+
)
35+
metadata = BytesParser().parsebytes(wheel.read(metadata_paths[0]))
36+
except zipfile.BadZipFile as exc:
37+
raise WheelConstraintError(f"invalid wheel archive: {wheel_path}") from exc
38+
39+
name = metadata.get("Name")
40+
version = metadata.get("Version")
41+
if not name or not version:
42+
raise WheelConstraintError(f"wheel metadata is missing Name or Version: {wheel_path}")
43+
return name, version
44+
45+
46+
def find_exact_wheel(directory: Path, project: str) -> tuple[Path, str]:
47+
"""Find exactly one wheel for project in directory."""
48+
if not directory.is_dir():
49+
raise WheelConstraintError(f"wheel directory does not exist: {directory}")
50+
51+
expected_name = normalize_project_name(project)
52+
inspected: list[tuple[Path, str, str]] = []
53+
matches: list[tuple[Path, str]] = []
54+
for wheel_path in sorted(directory.glob("*.whl")):
55+
name, version = read_wheel_identity(wheel_path)
56+
inspected.append((wheel_path, name, version))
57+
if normalize_project_name(name) == expected_name:
58+
matches.append((wheel_path.resolve(), version))
59+
60+
if len(matches) != 1:
61+
details = "\n".join(f" {path}: {name} {version}" for path, name, version in inspected)
62+
if not details:
63+
details = " <no wheel files>"
64+
raise WheelConstraintError(
65+
f"expected exactly one {project} wheel in {directory}, found {len(matches)}:\n{details}"
66+
)
67+
68+
return matches[0]
69+
70+
71+
def wheel_uri(wheel_path: Path, host_platform: str) -> str:
72+
"""Return the wheel URI as seen by the cibuildwheel build environment."""
73+
wheel_path = wheel_path.resolve()
74+
if host_platform.startswith("linux"):
75+
if wheel_path.anchor != "/":
76+
raise WheelConstraintError(f"expected an absolute POSIX wheel path, got {wheel_path}")
77+
wheel_path = Path("/host", *wheel_path.parts[1:])
78+
elif not host_platform.startswith("win"):
79+
raise WheelConstraintError(f"unsupported host platform: {host_platform}")
80+
return wheel_path.as_uri()
81+
82+
83+
def write_constraints(
84+
output_path: Path,
85+
host_platform: str,
86+
wheels: Sequence[tuple[str, Path]],
87+
) -> None:
88+
"""Resolve wheels and write direct-reference pip constraints."""
89+
constraints: list[str] = []
90+
seen_projects: set[str] = set()
91+
for project, directory in wheels:
92+
normalized_name = normalize_project_name(project)
93+
if normalized_name in seen_projects:
94+
raise WheelConstraintError(f"duplicate project requested: {project}")
95+
seen_projects.add(normalized_name)
96+
97+
wheel_path, version = find_exact_wheel(directory, project)
98+
constraints.append(f"{normalized_name} @ {wheel_uri(wheel_path, host_platform)}")
99+
print(f"Resolved {normalized_name} {version}: {wheel_path}")
100+
101+
output_path.parent.mkdir(parents=True, exist_ok=True)
102+
output_path.write_text("\n".join(constraints) + "\n", encoding="utf-8")
103+
print(f"Wrote {output_path}")
104+
105+
106+
def main(argv: Sequence[str] | None = None) -> int:
107+
"""Command-line entry point."""
108+
parser = argparse.ArgumentParser(description=__doc__)
109+
parser.add_argument("--output", type=Path, required=True, help="Constraint file to write")
110+
parser.add_argument("--host-platform", required=True, help="CI host platform, such as linux-64 or win-64")
111+
parser.add_argument(
112+
"--wheel",
113+
action="append",
114+
nargs=2,
115+
required=True,
116+
metavar=("PROJECT", "DIRECTORY"),
117+
help="Project name and directory containing its wheel; may be repeated",
118+
)
119+
args = parser.parse_args(argv)
120+
121+
try:
122+
write_constraints(
123+
args.output,
124+
args.host_platform,
125+
[(project, Path(directory)) for project, directory in args.wheel],
126+
)
127+
except WheelConstraintError as exc:
128+
print(f"error: {exc}", file=sys.stderr)
129+
return 1
130+
return 0
131+
132+
133+
if __name__ == "__main__":
134+
raise SystemExit(main())

0 commit comments

Comments
 (0)