Skip to content

Commit 2ea4b26

Browse files
committed
Use packaging specifiers for CTK constraints.
Align CompatibilityGuardRails with the PEP 440 version syntax users already know, and reuse packaging's parser instead of maintaining custom constraint logic. Made-with: Cursor
1 parent 01ddbe7 commit 2ea4b26

5 files changed

Lines changed: 93 additions & 156 deletions

File tree

cuda_pathfinder/cuda/pathfinder/_compatibility_guard_rails.py

Lines changed: 59 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212
from pathlib import Path
1313
from typing import TypeAlias, cast
1414

15+
from packaging.requirements import InvalidRequirement, Requirement
16+
from packaging.specifiers import InvalidSpecifier, SpecifierSet
17+
from packaging.version import InvalidVersion, Version
18+
1519
from cuda.pathfinder._binaries.find_nvidia_binary_utility import (
1620
find_nvidia_binary_utility as _find_nvidia_binary_utility,
1721
)
@@ -49,12 +53,13 @@
4953

5054
ItemKind: TypeAlias = str
5155
PackagedWith: TypeAlias = str
52-
ConstraintOperator: TypeAlias = str
53-
ConstraintArg: TypeAlias = int | str | tuple[str, int] | None
56+
CtkVersionConstraintArg: TypeAlias = str | SpecifierSet | None
5457

5558
_CTK_VERSION_RE = re.compile(r"^(?P<major>\d+)\.(?P<minor>\d+)")
56-
_REQUIRES_DIST_RE = re.compile(r"^\s*(?P<name>[A-Za-z0-9_.-]+)\s*(?P<specifier_text>[^;]*)(?:\s*;|$)")
57-
_VERSION_SPECIFIER_RE = re.compile(r"^\s*(?P<operator>==|<=|>=|<|>)\s*(?P<version>[0-9][A-Za-z0-9.+-]*?(?:\.\*)?)\s*$")
59+
_CTK_VERSION_CONSTRAINT_ERROR = (
60+
"ctk_version must be None, a non-empty PEP 440 specifier string like '>=13.2,<14', "
61+
"or a packaging.specifiers.SpecifierSet."
62+
)
5863

5964
_STATIC_LIBS_PACKAGED_WITH: dict[str, PackagedWith] = {
6065
"cudadevrt": "ctk",
@@ -89,33 +94,20 @@ class CtkVersion:
8994
def __str__(self) -> str:
9095
return f"{self.major}.{self.minor}"
9196

97+
def as_pep440_version(self) -> Version:
98+
return Version(str(self))
9299

93-
@dataclass(frozen=True, slots=True)
94-
class ComparisonConstraint:
95-
operator: ConstraintOperator
96-
value: int
97-
98-
def matches(self, candidate: int) -> bool:
99-
if self.operator == "==":
100-
return candidate == self.value
101-
if self.operator == "<":
102-
return candidate < self.value
103-
if self.operator == "<=":
104-
return candidate <= self.value
105-
if self.operator == ">":
106-
return candidate > self.value
107-
if self.operator == ">=":
108-
return candidate >= self.value
109-
raise AssertionError(f"Unsupported operator: {self.operator!r}")
110100

111-
def __str__(self) -> str:
112-
return f"{self.operator}{self.value}"
101+
@dataclass(frozen=True, slots=True)
102+
class CtkVersionConstraint:
103+
specifier: SpecifierSet
104+
text: str
113105

106+
def matches(self, candidate: CtkVersion) -> bool:
107+
return bool(self.specifier.contains(candidate.as_pep440_version(), prereleases=True))
114108

115-
@dataclass(frozen=True, slots=True)
116-
class VersionSpecifier:
117-
operator: ConstraintOperator
118-
version: str
109+
def __str__(self) -> str:
110+
return self.text
119111

120112

121113
@dataclass(frozen=True, slots=True)
@@ -147,37 +139,30 @@ def require_compatible(self) -> None:
147139
raise CompatibilityCheckError(self.message)
148140

149141

150-
def _coerce_constraint(name: str, raw_value: ConstraintArg) -> ComparisonConstraint | None:
151-
if raw_value is None:
152-
return None
153-
if isinstance(raw_value, int):
154-
return ComparisonConstraint("==", raw_value)
155-
if isinstance(raw_value, tuple):
156-
if len(raw_value) != 2:
157-
raise ValueError(f"{name} tuple constraints must have exactly two elements.")
158-
operator, value = raw_value
159-
if operator not in ("==", "<", "<=", ">", ">="):
160-
raise ValueError(f"{name} has unsupported operator {operator!r}.")
161-
if not isinstance(value, int):
162-
raise ValueError(f"{name} constraint value must be an integer.")
163-
return ComparisonConstraint(operator, value)
164-
if isinstance(raw_value, str):
165-
match = re.fullmatch(r"\s*(==|<|<=|>|>=)?\s*(\d+)\s*", raw_value)
166-
if match is None:
167-
raise ValueError(f"{name} must be an int, a (operator, value) tuple, or a string like '>=12'.")
168-
operator = match.group(1) or "=="
169-
value = int(match.group(2))
170-
return ComparisonConstraint(operator, value)
171-
raise ValueError(f"{name} must be an int, a (operator, value) tuple, or a string like '>=12'.")
172-
173-
174142
def _parse_ctk_version(cuda_version: str) -> CtkVersion | None:
175143
match = _CTK_VERSION_RE.match(cuda_version)
176144
if match is None:
177145
return None
178146
return CtkVersion(major=int(match.group("major")), minor=int(match.group("minor")))
179147

180148

149+
def _coerce_ctk_version_constraint(raw_value: CtkVersionConstraintArg) -> CtkVersionConstraint | None:
150+
if raw_value is None:
151+
return None
152+
if isinstance(raw_value, SpecifierSet):
153+
return CtkVersionConstraint(specifier=raw_value, text=str(raw_value))
154+
if isinstance(raw_value, str):
155+
stripped = raw_value.strip()
156+
if not stripped:
157+
raise ValueError(_CTK_VERSION_CONSTRAINT_ERROR)
158+
try:
159+
specifier = SpecifierSet(stripped)
160+
except InvalidSpecifier as exc:
161+
raise ValueError(_CTK_VERSION_CONSTRAINT_ERROR) from exc
162+
return CtkVersionConstraint(specifier=specifier, text=stripped)
163+
raise ValueError(_CTK_VERSION_CONSTRAINT_ERROR)
164+
165+
181166
def _normalize_distribution_name(name: str) -> str:
182167
return re.sub(r"[-_.]+", "-", name).lower()
183168

@@ -190,63 +175,6 @@ def _distribution_name(dist: importlib.metadata.Distribution) -> str | None:
190175
return metadata.get("Name")
191176

192177

193-
def _release_version_parts(version: str) -> tuple[int, ...] | None:
194-
match = re.match(r"^\d+(?:\.\d+)*", version)
195-
if match is None:
196-
return None
197-
return tuple(int(part) for part in match.group(0).split("."))
198-
199-
200-
def _compare_release_versions(lhs: tuple[int, ...], rhs: tuple[int, ...]) -> int:
201-
max_len = max(len(lhs), len(rhs))
202-
lhs_padded = lhs + (0,) * (max_len - len(lhs))
203-
rhs_padded = rhs + (0,) * (max_len - len(rhs))
204-
if lhs_padded < rhs_padded:
205-
return -1
206-
if lhs_padded > rhs_padded:
207-
return 1
208-
return 0
209-
210-
211-
def _parse_version_specifiers(specifier_text: str) -> tuple[VersionSpecifier, ...]:
212-
stripped = specifier_text.strip()
213-
if not stripped:
214-
return ()
215-
parsed: list[VersionSpecifier] = []
216-
for raw_clause in stripped.split(","):
217-
match = _VERSION_SPECIFIER_RE.match(raw_clause)
218-
if match is None:
219-
return ()
220-
parsed.append(VersionSpecifier(operator=match.group("operator"), version=match.group("version")))
221-
return tuple(parsed)
222-
223-
224-
def _version_satisfies_specifiers(version: str, specifiers: tuple[VersionSpecifier, ...]) -> bool:
225-
if not specifiers:
226-
return False
227-
for specifier in specifiers:
228-
if specifier.operator == "==":
229-
prefix = specifier.version.removesuffix(".*")
230-
if version == prefix or version.startswith(prefix + "."):
231-
continue
232-
return False
233-
candidate_parts = _release_version_parts(version)
234-
required_parts = _release_version_parts(specifier.version)
235-
if candidate_parts is None or required_parts is None:
236-
return False
237-
comparison = _compare_release_versions(candidate_parts, required_parts)
238-
if specifier.operator == "<" and comparison < 0:
239-
continue
240-
if specifier.operator == "<=" and comparison <= 0:
241-
continue
242-
if specifier.operator == ">" and comparison > 0:
243-
continue
244-
if specifier.operator == ">=" and comparison >= 0:
245-
continue
246-
return False
247-
return True
248-
249-
250178
@functools.cache
251179
def _owned_distribution_candidates(abs_path: str) -> tuple[tuple[str, str], ...]:
252180
normalized_abs_path = os.path.normpath(os.path.abspath(abs_path))
@@ -263,41 +191,33 @@ def _owned_distribution_candidates(abs_path: str) -> tuple[tuple[str, str], ...]
263191

264192

265193
@functools.cache
266-
def _cuda_toolkit_requirement_maps() -> tuple[
267-
tuple[str, CtkVersion, dict[str, tuple[tuple[VersionSpecifier, ...], ...]]], ...
268-
]:
269-
results: list[tuple[str, CtkVersion, dict[str, tuple[tuple[VersionSpecifier, ...], ...]]]] = []
194+
def _cuda_toolkit_requirement_maps() -> tuple[tuple[str, CtkVersion, dict[str, tuple[SpecifierSet, ...]]], ...]:
195+
results: list[tuple[str, CtkVersion, dict[str, tuple[SpecifierSet, ...]]]] = []
270196
for dist in importlib.metadata.distributions():
271197
dist_name = _distribution_name(dist)
272198
if _normalize_distribution_name(dist_name or "") != "cuda-toolkit":
273199
continue
274200
ctk_version = _parse_ctk_version(dist.version)
275201
if ctk_version is None:
276202
continue
277-
requirement_map: dict[str, set[tuple[VersionSpecifier, ...]]] = {}
278-
for requirement in dist.requires or ():
279-
match = _REQUIRES_DIST_RE.match(requirement)
280-
if match is None:
203+
requirement_map: dict[str, set[str]] = {}
204+
for requirement_text in dist.requires or ():
205+
try:
206+
requirement = Requirement(requirement_text)
207+
except InvalidRequirement:
281208
continue
282-
req_name = _normalize_distribution_name(match.group("name"))
283-
parsed_specifiers = _parse_version_specifiers(match.group("specifier_text"))
284-
if not parsed_specifiers:
209+
specifier_text = str(requirement.specifier)
210+
if not specifier_text:
285211
continue
286-
requirement_map.setdefault(req_name, set()).add(parsed_specifiers)
212+
req_name = _normalize_distribution_name(requirement.name)
213+
requirement_map.setdefault(req_name, set()).add(specifier_text)
287214
results.append(
288215
(
289216
dist.version,
290217
ctk_version,
291218
{
292-
name: tuple(
293-
sorted(
294-
specifier_sets,
295-
key=lambda specifiers: tuple(
296-
(specifier.operator, specifier.version) for specifier in specifiers
297-
),
298-
)
299-
)
300-
for name, specifier_sets in requirement_map.items()
219+
name: tuple(SpecifierSet(specifier_text) for specifier_text in sorted(specifier_set_texts))
220+
for name, specifier_set_texts in requirement_map.items()
301221
},
302222
)
303223
)
@@ -307,11 +227,16 @@ def _cuda_toolkit_requirement_maps() -> tuple[
307227
def _wheel_metadata_for_abs_path(abs_path: str) -> CtkMetadata | None:
308228
matched_versions: dict[CtkVersion, str] = {}
309229
for owner_name, owner_version in _owned_distribution_candidates(abs_path):
230+
try:
231+
owner_parsed_version = Version(owner_version)
232+
except InvalidVersion:
233+
continue
310234
normalized_owner_name = _normalize_distribution_name(owner_name)
311235
for toolkit_dist_version, ctk_version, requirement_map in _cuda_toolkit_requirement_maps():
312236
requirement_specifier_sets = requirement_map.get(normalized_owner_name, ())
313237
if not any(
314-
_version_satisfies_specifiers(owner_version, specifiers) for specifiers in requirement_specifier_sets
238+
specifier_set.contains(owner_parsed_version, prereleases=True)
239+
for specifier_set in requirement_specifier_sets
315240
):
316241
continue
317242
matched_versions[ctk_version] = (
@@ -527,12 +452,10 @@ class CompatibilityGuardRails:
527452
def __init__(
528453
self,
529454
*,
530-
ctk_major: ConstraintArg = None,
531-
ctk_minor: ConstraintArg = None,
455+
ctk_version: CtkVersionConstraintArg = None,
532456
driver_cuda_version: DriverCudaVersion | None = None,
533457
) -> None:
534-
self._ctk_major_constraint = _coerce_constraint("ctk_major", ctk_major)
535-
self._ctk_minor_constraint = _coerce_constraint("ctk_minor", ctk_minor)
458+
self._ctk_version_constraint = _coerce_ctk_version_constraint(ctk_version)
536459
self._configured_driver_cuda_version = driver_cuda_version
537460
self._driver_cuda_version = driver_cuda_version
538461
self._resolved_items: list[ResolvedItem] = []
@@ -567,15 +490,10 @@ def _enforce_ctk_metadata(self, item: ResolvedItem) -> None:
567490

568491
def _enforce_constraints(self, item: ResolvedItem) -> None:
569492
assert item.ctk_version is not None
570-
if self._ctk_major_constraint is not None and not self._ctk_major_constraint.matches(item.ctk_version.major):
571-
raise CompatibilityCheckError(
572-
f"{item.describe()} resolves to CTK {item.ctk_version}, which does not satisfy "
573-
f"ctk_major{self._ctk_major_constraint}."
574-
)
575-
if self._ctk_minor_constraint is not None and not self._ctk_minor_constraint.matches(item.ctk_version.minor):
493+
if self._ctk_version_constraint is not None and not self._ctk_version_constraint.matches(item.ctk_version):
576494
raise CompatibilityCheckError(
577495
f"{item.describe()} resolves to CTK {item.ctk_version}, which does not satisfy "
578-
f"ctk_minor{self._ctk_minor_constraint}."
496+
f"ctk_version{self._ctk_version_constraint}."
579497
)
580498

581499
def _anchor_item(self) -> ResolvedItem | None:

cuda_pathfinder/pixi.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cuda_pathfinder/pixi.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ setuptools = ">=80"
7070
setuptools-scm = ">=8"
7171

7272
[package.run-dependencies]
73+
packaging = "*"
7374
python = ">=3.10"
7475

7576
[target.linux.tasks.test]

cuda_pathfinder/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ authors = [{ name = "NVIDIA Corporation", email = "cuda-python-conduct@nvidia.co
88
license = "Apache-2.0"
99
requires-python = ">=3.10"
1010
dynamic = ["version", "readme"]
11-
dependencies = []
11+
dependencies = ["packaging"]
1212

1313
[dependency-groups]
1414
test = [

0 commit comments

Comments
 (0)