Skip to content

Commit 25f2d3f

Browse files
Inline dependency-versions syntax (#2122)
* Write docs for inline dependency-versions * Change the `inline` keyword to `packages` for better readability * Implement inline package constraints * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add schema for TOML properties * Change the parsing of the option to parse filenames as-is * Add a unit test for table-parsing of the option * Remove unneeded shlex.quote on the dependency-version test * Tidy-ups, comments, docs fixes * Add test for empty packages option value * Fix empty packages scenario And, remove some optionals to reduce the problem space --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent f5e502a commit 25f2d3f

File tree

13 files changed

+377
-98
lines changed

13 files changed

+377
-98
lines changed

bin/generate_schema.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,24 @@
104104
dependency-versions:
105105
default: pinned
106106
description: Specify how cibuildwheel controls the versions of the tools it uses
107-
type: string
107+
oneOf:
108+
- enum: [pinned, latest]
109+
- type: string
110+
description: Path to a file containing dependency versions, or inline package specifications, starting with "packages:"
111+
not:
112+
enum: [pinned, latest]
113+
- type: object
114+
additionalProperties: false
115+
properties:
116+
file:
117+
type: string
118+
- type: object
119+
additionalProperties: false
120+
properties:
121+
packages:
122+
type: array
123+
items:
124+
type: string
108125
enable:
109126
description: Enable or disable certain builds.
110127
oneOf:

cibuildwheel/linux.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ def build_in_container(
166166
container: OCIContainer,
167167
container_project_path: PurePath,
168168
container_package_dir: PurePath,
169+
local_tmp_dir: Path,
169170
) -> None:
170171
container_output_dir = PurePosixPath("/output")
171172

@@ -199,22 +200,22 @@ def build_in_container(
199200

200201
for config in platform_configs:
201202
log.build_start(config.identifier)
203+
local_identifier_tmp_dir = local_tmp_dir / config.identifier
202204
build_options = options.build_options(config.identifier)
203205
build_frontend = build_options.build_frontend or BuildFrontendConfig("pip")
204206
use_uv = build_frontend.name == "build[uv]"
205207
pip = ["uv", "pip"] if use_uv else ["pip"]
206208

207-
dependency_constraint_flags: list[PathOrStr] = []
208-
209209
log.step("Setting up build environment...")
210210

211-
if build_options.dependency_constraints:
212-
constraints_file = build_options.dependency_constraints.get_for_python_version(
213-
config.version
214-
)
211+
dependency_constraint_flags: list[PathOrStr] = []
212+
local_constraints_file = build_options.dependency_constraints.get_for_python_version(
213+
version=config.version,
214+
tmp_dir=local_identifier_tmp_dir,
215+
)
216+
if local_constraints_file:
215217
container_constraints_file = PurePosixPath("/constraints.txt")
216-
217-
container.copy_into(constraints_file, container_constraints_file)
218+
container.copy_into(local_constraints_file, container_constraints_file)
218219
dependency_constraint_flags = ["-c", container_constraints_file]
219220

220221
env = container.get_environment()
@@ -426,7 +427,7 @@ def build_in_container(
426427
log.step_end()
427428

428429

429-
def build(options: Options, tmp_path: Path) -> None: # noqa: ARG001
430+
def build(options: Options, tmp_path: Path) -> None:
430431
python_configurations = get_python_configurations(
431432
options.globals.build_selector, options.globals.architectures
432433
)
@@ -480,6 +481,7 @@ def build(options: Options, tmp_path: Path) -> None: # noqa: ARG001
480481
container=container,
481482
container_project_path=container_project_path,
482483
container_package_dir=container_package_dir,
484+
local_tmp_dir=tmp_path,
483485
)
484486

485487
except subprocess.CalledProcessError as error:

cibuildwheel/macos.py

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -421,12 +421,12 @@ def build(options: Options, tmp_path: Path) -> None:
421421
config_is_arm64 = config.identifier.endswith("arm64")
422422
config_is_universal2 = config.identifier.endswith("universal2")
423423

424-
dependency_constraint_flags: Sequence[PathOrStr] = []
425-
if build_options.dependency_constraints:
426-
dependency_constraint_flags = [
427-
"-c",
428-
build_options.dependency_constraints.get_for_python_version(config.version),
429-
]
424+
constraints_path = build_options.dependency_constraints.get_for_python_version(
425+
version=config.version, tmp_dir=identifier_tmp_dir
426+
)
427+
dependency_constraint_flags: Sequence[PathOrStr] = (
428+
["-c", constraints_path] if constraints_path else []
429+
)
430430

431431
base_python, env = setup_python(
432432
identifier_tmp_dir / "build",
@@ -463,12 +463,9 @@ def build(options: Options, tmp_path: Path) -> None:
463463
build_env = env.copy()
464464
if not use_uv:
465465
build_env["VIRTUALENV_PIP"] = pip_version
466-
if build_options.dependency_constraints:
467-
constraint_path = build_options.dependency_constraints.get_for_python_version(
468-
config.version
469-
)
466+
if constraints_path:
470467
combine_constraints(
471-
build_env, constraint_path, identifier_tmp_dir if use_uv else None
468+
build_env, constraints_path, identifier_tmp_dir if use_uv else None
472469
)
473470

474471
if build_frontend.name == "pip":

cibuildwheel/options.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ class BuildOptions:
9797
repair_command: str
9898
manylinux_images: dict[str, str] | None
9999
musllinux_images: dict[str, str] | None
100-
dependency_constraints: DependencyConstraints | None
100+
dependency_constraints: DependencyConstraints
101101
test_command: str | None
102102
before_test: str | None
103103
test_sources: list[str]
@@ -693,7 +693,6 @@ def _compute_build_options(self, identifier: str | None) -> BuildOptions:
693693
"config-settings", option_format=ShlexTableFormat(sep=" ", pair_sep="=")
694694
)
695695

696-
dependency_versions = self.reader.get("dependency-versions")
697696
test_command = self.reader.get("test-command", option_format=ListFormat(sep=" && "))
698697
before_test = self.reader.get("before-test", option_format=ListFormat(sep=" && "))
699698
test_sources = shlex.split(
@@ -739,15 +738,18 @@ def _compute_build_options(self, identifier: str | None) -> BuildOptions:
739738
with contextlib.suppress(KeyError):
740739
environment.add(env_var_name, self.env[env_var_name], prepend=True)
741740

742-
if dependency_versions == "pinned":
743-
dependency_constraints: DependencyConstraints | None = (
744-
DependencyConstraints.with_defaults()
741+
dependency_versions_str = self.reader.get(
742+
"dependency-versions",
743+
env_plat=True,
744+
option_format=ShlexTableFormat(sep="; ", pair_sep=":", allow_merge=False),
745+
)
746+
try:
747+
dependency_constraints = DependencyConstraints.from_config_string(
748+
dependency_versions_str
745749
)
746-
elif dependency_versions == "latest":
747-
dependency_constraints = None
748-
else:
749-
dependency_versions_path = Path(dependency_versions)
750-
dependency_constraints = DependencyConstraints(dependency_versions_path)
750+
except (ValueError, OSError) as e:
751+
msg = f"Failed to parse dependency versions. {e}"
752+
raise errors.ConfigurationError(msg) from e
751753

752754
if test_extras:
753755
test_extras = f"[{test_extras}]"

cibuildwheel/pyodide.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -265,12 +265,12 @@ def build(options: Options, tmp_path: Path) -> None:
265265
built_wheel_dir.mkdir()
266266
repaired_wheel_dir.mkdir()
267267

268-
dependency_constraint_flags: Sequence[PathOrStr] = []
269-
if build_options.dependency_constraints:
270-
constraints_path = build_options.dependency_constraints.get_for_python_version(
271-
config.version, variant="pyodide"
272-
)
273-
dependency_constraint_flags = ["-c", constraints_path]
268+
constraints_path = build_options.dependency_constraints.get_for_python_version(
269+
version=config.version, variant="pyodide", tmp_dir=identifier_tmp_dir
270+
)
271+
dependency_constraint_flags: Sequence[PathOrStr] = (
272+
["-c", constraints_path] if constraints_path else []
273+
)
274274

275275
env = setup_python(
276276
identifier_tmp_dir / "build",
@@ -319,7 +319,7 @@ def build(options: Options, tmp_path: Path) -> None:
319319
)
320320

321321
build_env = env.copy()
322-
if build_options.dependency_constraints:
322+
if constraints_path:
323323
combine_constraints(build_env, constraints_path, identifier_tmp_dir)
324324
build_env["VIRTUALENV_PIP"] = pip_version
325325
call(

cibuildwheel/resources/cibuildwheel.schema.json

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,45 @@
233233
"dependency-versions": {
234234
"default": "pinned",
235235
"description": "Specify how cibuildwheel controls the versions of the tools it uses",
236-
"type": "string",
236+
"oneOf": [
237+
{
238+
"enum": [
239+
"pinned",
240+
"latest"
241+
]
242+
},
243+
{
244+
"type": "string",
245+
"description": "Path to a file containing dependency versions, or inline package specifications, starting with \"packages:\"",
246+
"not": {
247+
"enum": [
248+
"pinned",
249+
"latest"
250+
]
251+
}
252+
},
253+
{
254+
"type": "object",
255+
"additionalProperties": false,
256+
"properties": {
257+
"file": {
258+
"type": "string"
259+
}
260+
}
261+
},
262+
{
263+
"type": "object",
264+
"additionalProperties": false,
265+
"properties": {
266+
"packages": {
267+
"type": "array",
268+
"items": {
269+
"type": "string"
270+
}
271+
}
272+
}
273+
}
274+
],
237275
"title": "CIBW_DEPENDENCY_VERSIONS"
238276
},
239277
"enable": {

cibuildwheel/util/packaging.py

Lines changed: 91 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,116 @@
1+
import shlex
12
from collections.abc import Mapping, MutableMapping, Sequence
3+
from dataclasses import dataclass, field
24
from pathlib import Path, PurePath
35
from typing import Any, Literal, Self, TypeVar
46

57
from packaging.utils import parse_wheel_filename
68

79
from . import resources
810
from .cmd import call
11+
from .helpers import parse_key_value_string, unwrap
912

1013

14+
@dataclass()
1115
class DependencyConstraints:
12-
def __init__(self, base_file_path: Path):
13-
assert base_file_path.exists()
14-
self.base_file_path = base_file_path.resolve()
16+
base_file_path: Path | None = None
17+
packages: list[str] = field(default_factory=list)
18+
19+
def __post_init__(self) -> None:
20+
if self.packages and self.base_file_path is not None:
21+
msg = "Cannot specify both a file and packages in the dependency constraints"
22+
raise ValueError(msg)
23+
24+
if self.base_file_path is not None:
25+
if not self.base_file_path.exists():
26+
msg = f"Dependency constraints file not found: {self.base_file_path}"
27+
raise FileNotFoundError(msg)
28+
self.base_file_path = self.base_file_path.resolve()
1529

1630
@classmethod
17-
def with_defaults(cls) -> Self:
31+
def pinned(cls) -> Self:
1832
return cls(base_file_path=resources.CONSTRAINTS)
1933

20-
def get_for_python_version(
21-
self, version: str, *, variant: Literal["python", "pyodide"] = "python"
22-
) -> Path:
23-
version_parts = version.split(".")
24-
25-
# try to find a version-specific dependency file e.g. if
26-
# ./constraints.txt is the base, look for ./constraints-python36.txt
27-
specific_stem = self.base_file_path.stem + f"-{variant}{version_parts[0]}{version_parts[1]}"
28-
specific_name = specific_stem + self.base_file_path.suffix
29-
specific_file_path = self.base_file_path.with_name(specific_name)
30-
31-
if specific_file_path.exists():
32-
return specific_file_path
33-
else:
34-
return self.base_file_path
34+
@classmethod
35+
def latest(cls) -> Self:
36+
return cls()
3537

36-
def __repr__(self) -> str:
37-
return f"{self.__class__.__name__}({self.base_file_path!r})"
38+
@classmethod
39+
def from_config_string(cls, config_string: str) -> Self:
40+
if config_string == "pinned":
41+
return cls.pinned()
42+
43+
if config_string == "latest" or not config_string:
44+
return cls.latest()
45+
46+
if config_string.startswith(("file:", "packages:")):
47+
# we only do the table-style parsing if it looks like a table,
48+
# because this option used to be only a file path. We don't want
49+
# to break existing configurations, whose file paths might include
50+
# special characters like ':' or ' ', which would require quoting
51+
# if they were to be passed as a parse_key_value_string positional
52+
# argument.
53+
return cls.from_table_style_config_string(config_string)
3854

39-
def __eq__(self, o: object) -> bool:
40-
if not isinstance(o, DependencyConstraints):
41-
return False
55+
return cls(base_file_path=Path(config_string))
56+
57+
@classmethod
58+
def from_table_style_config_string(cls, config_string: str) -> Self:
59+
config_dict = parse_key_value_string(config_string, kw_arg_names=["file", "packages"])
60+
files = config_dict.get("file")
61+
packages = config_dict.get("packages") or []
4262

43-
return self.base_file_path == o.base_file_path
63+
if files and packages:
64+
msg = "Cannot specify both a file and packages in dependency-versions"
65+
raise ValueError(msg)
66+
67+
if files:
68+
if len(files) > 1:
69+
msg = unwrap("""
70+
Only one file can be specified in dependency-versions.
71+
If you intended to pass only one, perhaps you need to quote the path?
72+
""")
73+
raise ValueError(msg)
74+
75+
return cls(base_file_path=Path(files[0]))
76+
77+
return cls(packages=packages)
78+
79+
def get_for_python_version(
80+
self, *, version: str, variant: Literal["python", "pyodide"] = "python", tmp_dir: Path
81+
) -> Path | None:
82+
if self.packages:
83+
constraint_file = tmp_dir / "constraints.txt"
84+
constraint_file.write_text("\n".join(self.packages))
85+
return constraint_file
86+
87+
if self.base_file_path is not None:
88+
version_parts = version.split(".")
89+
90+
# try to find a version-specific dependency file e.g. if
91+
# ./constraints.txt is the base, look for ./constraints-python36.txt
92+
specific_stem = (
93+
self.base_file_path.stem + f"-{variant}{version_parts[0]}{version_parts[1]}"
94+
)
95+
specific_name = specific_stem + self.base_file_path.suffix
96+
specific_file_path = self.base_file_path.with_name(specific_name)
97+
98+
if specific_file_path.exists():
99+
return specific_file_path
100+
else:
101+
return self.base_file_path
102+
103+
return None
44104

45105
def options_summary(self) -> Any:
46-
if self == DependencyConstraints.with_defaults():
106+
if self == DependencyConstraints.pinned():
47107
return "pinned"
48-
else:
108+
elif self.packages:
109+
return {"packages": " ".join(shlex.quote(p) for p in self.packages)}
110+
elif self.base_file_path is not None:
49111
return self.base_file_path.name
112+
else:
113+
return "latest"
50114

51115

52116
def get_pip_version(env: Mapping[str, str]) -> str:

0 commit comments

Comments
 (0)