Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Inline dependency-versions syntax #2122

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
19 changes: 18 additions & 1 deletion bin/generate_schema.py
Original file line number Diff line number Diff line change
@@ -104,7 +104,24 @@
dependency-versions:
default: pinned
description: Specify how cibuildwheel controls the versions of the tools it uses
type: string
oneOf:
- enum: [pinned, latest]
- type: string
description: Path to a file containing dependency versions, or inline package specifications, starting with "packages:"
not:
enum: [pinned, latest]
- type: object
additionalProperties: false
properties:
file:
type: string
- type: object
additionalProperties: false
properties:
packages:
type: array
items:
type: string
enable:
description: Enable or disable certain builds.
oneOf:
12 changes: 8 additions & 4 deletions cibuildwheel/linux.py
Original file line number Diff line number Diff line change
@@ -168,6 +168,7 @@ def build_in_container(
container: OCIContainer,
container_project_path: PurePath,
container_package_dir: PurePath,
local_tmp_dir: Path,
) -> None:
container_output_dir = PurePosixPath("/output")

@@ -201,6 +202,7 @@ def build_in_container(

for config in platform_configs:
log.build_start(config.identifier)
local_identifier_tmp_dir = local_tmp_dir / config.identifier
build_options = options.build_options(config.identifier)
build_frontend = build_options.build_frontend or BuildFrontendConfig("pip")
use_uv = build_frontend.name == "build[uv]" and Version(config.version) >= Version("3.8")
@@ -211,12 +213,13 @@ def build_in_container(
log.step("Setting up build environment...")

if build_options.dependency_constraints:
constraints_file = build_options.dependency_constraints.get_for_python_version(
config.version
local_constraints_file = build_options.dependency_constraints.get_for_python_version(
version=config.version,
tmp_dir=local_identifier_tmp_dir,
)
container_constraints_file = PurePosixPath("/constraints.txt")

container.copy_into(constraints_file, container_constraints_file)
container.copy_into(local_constraints_file, container_constraints_file)
dependency_constraint_flags = ["-c", container_constraints_file]

env = container.get_environment()
@@ -428,7 +431,7 @@ def build_in_container(
log.step_end()


def build(options: Options, tmp_path: Path) -> None: # noqa: ARG001
def build(options: Options, tmp_path: Path) -> None:
python_configurations = get_python_configurations(
options.globals.build_selector, options.globals.architectures
)
@@ -482,6 +485,7 @@ def build(options: Options, tmp_path: Path) -> None: # noqa: ARG001
container=container,
container_project_path=container_project_path,
container_package_dir=container_package_dir,
local_tmp_dir=tmp_path,
)

except subprocess.CalledProcessError as error:
6 changes: 4 additions & 2 deletions cibuildwheel/macos.py
Original file line number Diff line number Diff line change
@@ -436,7 +436,9 @@ def build(options: Options, tmp_path: Path) -> None:
if build_options.dependency_constraints:
dependency_constraint_flags = [
"-c",
build_options.dependency_constraints.get_for_python_version(config.version),
build_options.dependency_constraints.get_for_python_version(
version=config.version, tmp_dir=identifier_tmp_dir
),
]

base_python, env = setup_python(
@@ -476,7 +478,7 @@ def build(options: Options, tmp_path: Path) -> None:
build_env["VIRTUALENV_PIP"] = pip_version
if build_options.dependency_constraints:
constraint_path = build_options.dependency_constraints.get_for_python_version(
config.version
version=config.version, tmp_dir=identifier_tmp_dir
)
combine_constraints(
build_env, constraint_path, identifier_tmp_dir if use_uv else None
22 changes: 12 additions & 10 deletions cibuildwheel/options.py
Original file line number Diff line number Diff line change
@@ -690,7 +690,6 @@ def build_options(self, identifier: str | None) -> BuildOptions:
"config-settings", option_format=ShlexTableFormat(sep=" ", pair_sep="=")
)

dependency_versions = self.reader.get("dependency-versions")
test_command = self.reader.get("test-command", option_format=ListFormat(sep=" && "))
before_test = self.reader.get("before-test", option_format=ListFormat(sep=" && "))
test_sources = shlex.split(
@@ -736,15 +735,18 @@ def build_options(self, identifier: str | None) -> BuildOptions:
with contextlib.suppress(KeyError):
environment.add(env_var_name, self.env[env_var_name], prepend=True)

if dependency_versions == "pinned":
dependency_constraints: None | (
DependencyConstraints
) = DependencyConstraints.with_defaults()
elif dependency_versions == "latest":
dependency_constraints = None
else:
dependency_versions_path = Path(dependency_versions)
dependency_constraints = DependencyConstraints(dependency_versions_path)
dependency_versions_str = self.reader.get(
"dependency-versions",
env_plat=True,
option_format=ShlexTableFormat(sep="; ", pair_sep=":", allow_merge=False),
)
try:
dependency_constraints = DependencyConstraints.from_config_string(
dependency_versions_str
)
except ValueError as e:
msg = f"Failed to parse dependency versions. {e}"
raise errors.ConfigurationError(msg) from e

if test_extras:
test_extras = f"[{test_extras}]"
2 changes: 1 addition & 1 deletion cibuildwheel/pyodide.py
Original file line number Diff line number Diff line change
@@ -270,7 +270,7 @@ def build(options: Options, tmp_path: Path) -> None:
dependency_constraint_flags: Sequence[PathOrStr] = []
if build_options.dependency_constraints:
constraints_path = build_options.dependency_constraints.get_for_python_version(
config.version, variant="pyodide"
version=config.version, variant="pyodide", tmp_dir=identifier_tmp_dir
)
dependency_constraint_flags = ["-c", constraints_path]

40 changes: 39 additions & 1 deletion cibuildwheel/resources/cibuildwheel.schema.json
Original file line number Diff line number Diff line change
@@ -233,7 +233,45 @@
"dependency-versions": {
"default": "pinned",
"description": "Specify how cibuildwheel controls the versions of the tools it uses",
"type": "string",
"oneOf": [
{
"enum": [
"pinned",
"latest"
]
},
{
"type": "string",
"description": "Path to a file containing dependency versions, or inline package specifications, starting with \"packages:\"",
"not": {
"enum": [
"pinned",
"latest"
]
}
},
{
"type": "object",
"additionalProperties": false,
"properties": {
"file": {
"type": "string"
}
}
},
{
"type": "object",
"additionalProperties": false,
"properties": {
"packages": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
],
"title": "CIBW_DEPENDENCY_VERSIONS"
},
"enable": {
87 changes: 74 additions & 13 deletions cibuildwheel/util/packaging.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,92 @@
from __future__ import annotations

import shlex
from collections.abc import Mapping, MutableMapping, Sequence
from dataclasses import dataclass
from pathlib import Path, PurePath
from typing import Any, Literal, TypeVar

from packaging.utils import parse_wheel_filename

from . import resources
from .cmd import call
from .helpers import parse_key_value_string, unwrap


@dataclass()
class DependencyConstraints:
def __init__(self, base_file_path: Path):
assert base_file_path.exists()
self.base_file_path = base_file_path.resolve()
base_file_path: Path | None = None
packages: list[str] | None = None

def __post_init__(self) -> None:
if self.packages is not None and self.base_file_path is not None:
msg = "Cannot specify both a file and packages in the dependency constraints"
raise ValueError(msg)

if self.base_file_path is not None:
assert self.base_file_path.exists()
self.base_file_path = self.base_file_path.resolve()

@staticmethod
def with_defaults() -> DependencyConstraints:
return DependencyConstraints(base_file_path=resources.CONSTRAINTS)

@staticmethod
def from_config_string(config_string: str) -> DependencyConstraints | None:
if config_string == "pinned":
return DependencyConstraints.with_defaults()

if config_string == "latest":
return None

if config_string.startswith(("file:", "packages:")):
# we only do the table-style parsing if it looks like a table,
# because this option used to be only a file path. We don't want
# to break existing configurations, whose file paths might include
# special characters like ':' or ' ', which would require quoting
# if they were to be passed as a parse_key_value_string positional
# argument.
return DependencyConstraints.from_table_style_config_string(config_string)

return DependencyConstraints(base_file_path=Path(config_string))

@staticmethod
def from_table_style_config_string(config_string: str) -> DependencyConstraints | None:
config_dict = parse_key_value_string(config_string, kw_arg_names=["file", "packages"])
files = config_dict.get("file")
packages = config_dict.get("packages")

if files and packages:
msg = "Cannot specify both a file and packages in dependency-versions"
raise ValueError(msg)

if packages:
return DependencyConstraints(packages=packages)

if not files:
return DependencyConstraints.with_defaults()

if len(files) > 1:
msg = unwrap("""
Only one file can be specified in dependency-versions.
If you intended to pass only one, perhaps you need to quote the path?
""")
raise ValueError(msg)

return DependencyConstraints(base_file_path=Path(files[0]))

def get_for_python_version(
self, version: str, *, variant: Literal["python", "pyodide"] = "python"
self, *, version: str, variant: Literal["python", "pyodide"] = "python", tmp_dir: Path
) -> Path:
if self.packages:
constraint_file = tmp_dir / "constraints.txt"
constraint_file.write_text("\n".join(self.packages))
return constraint_file

assert self.base_file_path is not None, (
"DependencyConstraints should have either a file or packages"
)

version_parts = version.split(".")

# try to find a version-specific dependency file e.g. if
@@ -35,19 +100,15 @@ def get_for_python_version(
else:
return self.base_file_path

def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.base_file_path!r})"

def __eq__(self, o: object) -> bool:
if not isinstance(o, DependencyConstraints):
return False

return self.base_file_path == o.base_file_path

def options_summary(self) -> Any:
if self == DependencyConstraints.with_defaults():
return "pinned"
elif self.packages:
return {"packages": " ".join(shlex.quote(p) for p in self.packages)}
else:
assert self.base_file_path is not None, (
"DependencyConstraints should have either a file or packages"
)
return self.base_file_path.name


8 changes: 6 additions & 2 deletions cibuildwheel/windows.py
Original file line number Diff line number Diff line change
@@ -373,7 +373,10 @@ def build(options: Options, tmp_path: Path) -> None:
if build_options.dependency_constraints:
dependency_constraint_flags = [
"-c",
build_options.dependency_constraints.get_for_python_version(config.version),
build_options.dependency_constraints.get_for_python_version(
version=config.version,
tmp_dir=identifier_tmp_dir,
),
]

# install Python
@@ -418,7 +421,8 @@ def build(options: Options, tmp_path: Path) -> None:

if build_options.dependency_constraints:
constraints_path = build_options.dependency_constraints.get_for_python_version(
config.version
version=config.version,
tmp_dir=identifier_tmp_dir,
)
combine_constraints(build_env, constraints_path, identifier_tmp_dir)

26 changes: 22 additions & 4 deletions docs/options.md
Original file line number Diff line number Diff line change
@@ -1350,9 +1350,10 @@ Options can be supplied after the name.


### `CIBW_DEPENDENCY_VERSIONS` {: #dependency-versions}
> Specify how cibuildwheel controls the versions of the tools it uses

Options: `pinned` `latest` `<your constraints file>`
> Control the versions of the tools cibuildwheel uses

Options: `pinned` `latest` `packages: SPECIFIER...` `<your constraints file>`

Default: `pinned`

@@ -1367,7 +1368,8 @@ fixes that can't wait for a new cibuildwheel release.

To control the versions of dependencies yourself, you can supply a [pip
constraints](https://pip.pypa.io/en/stable/user_guide/#constraints-files) file
here and it will be used instead.
here and it will be used instead. Alternatively, you can list constraint
specifiers inline with the `packages: SPECIFIER...` syntax.

!!! note
If you need different dependencies for each python version, provide them
@@ -1398,6 +1400,15 @@ Platform-specific environment variables are also available:<br/>

# Use your own pip constraints file
CIBW_DEPENDENCY_VERSIONS: ./constraints.txt

# Specify requirements inline
CIBW_DEPENDENCY_VERSIONS: "packages: auditwheel==6.2.0"

# Choose a specific pyodide-build version
CIBW_DEPENDENCY_VERSIONS_PYODIDE: "packages: pyodide-build==0.29.1"

# Use shell-style quoting around spaces package specifiers
CIBW_DEPENDENCY_VERSIONS: "packages: 'pip >=16.0.0, !=17'"
```

!!! tab examples "pyproject.toml"
@@ -1411,7 +1422,14 @@ Platform-specific environment variables are also available:<br/>
dependency-versions = "latest"

# Use your own pip constraints file
dependency-versions = "./constraints.txt"
dependency-versions = { file = "./constraints.txt" }

# Specify requirements inline
dependency-versions = { packages = ["auditwheel==6.2.0"] }

[tool.cibuildwheel.pyodide]
# Choose a specific pyodide-build version
dependency-versions = { packages = ["pyodide-build==0.29.1"] }
```


30 changes: 20 additions & 10 deletions test/test_dependency_versions.py
Original file line number Diff line number Diff line change
@@ -96,7 +96,8 @@ def test_pinned_versions(tmp_path, python_version, build_frontend_env_nouv):
assert set(actual_wheels) == set(expected_wheels)


def test_dependency_constraints_file(tmp_path, build_frontend_env_nouv):
@pytest.mark.parametrize("method", ["inline", "file"])
def test_dependency_constraints(method, tmp_path, build_frontend_env_nouv):
if utils.platform == "linux":
pytest.skip("linux doesn't pin individual tool versions, it pins manylinux images instead")

@@ -108,15 +109,24 @@ def test_dependency_constraints_file(tmp_path, build_frontend_env_nouv):
"delocate": "0.10.3",
}

constraints_file = tmp_path / "constraints file.txt"
constraints_file.write_text(
textwrap.dedent(
"""
pip=={pip}
delocate=={delocate}
""".format(**tool_versions)
if method == "file":
constraints_file = tmp_path / "constraints file.txt"
constraints_file.write_text(
textwrap.dedent(
"""
pip=={pip}
delocate=={delocate}
""".format(**tool_versions)
)
)
)
dependency_version_option = str(constraints_file)
elif method == "inline":
dependency_version_option = "packages: " + " ".join(
f"{k}=={v}" for k, v in tool_versions.items()
)
else:
msg = f"Unknown method: {method}"
raise ValueError(msg)

build_environment = {}

@@ -131,7 +141,7 @@ def test_dependency_constraints_file(tmp_path, build_frontend_env_nouv):
project_dir,
add_env={
"CIBW_ENVIRONMENT": cibw_environment_option,
"CIBW_DEPENDENCY_VERSIONS": str(constraints_file),
"CIBW_DEPENDENCY_VERSIONS": dependency_version_option,
"CIBW_SKIP": "cp36-*",
**build_frontend_env_nouv,
},
21 changes: 17 additions & 4 deletions unit_test/dependency_constraints_test.py
Original file line number Diff line number Diff line change
@@ -5,19 +5,32 @@
from cibuildwheel.util.packaging import DependencyConstraints


def test_defaults():
def test_defaults(tmp_path: Path) -> None:
dependency_constraints = DependencyConstraints.with_defaults()

project_root = Path(__file__).parents[1]
resources_dir = project_root / "cibuildwheel" / "resources"

assert dependency_constraints.base_file_path
assert dependency_constraints.base_file_path.samefile(resources_dir / "constraints.txt")
assert dependency_constraints.get_for_python_version("3.99").samefile(
assert dependency_constraints.get_for_python_version(version="3.99", tmp_dir=tmp_path).samefile(
resources_dir / "constraints.txt"
)
assert dependency_constraints.get_for_python_version("3.9").samefile(
assert dependency_constraints.get_for_python_version(version="3.9", tmp_dir=tmp_path).samefile(
resources_dir / "constraints-python39.txt"
)
assert dependency_constraints.get_for_python_version("3.6").samefile(
assert dependency_constraints.get_for_python_version(version="3.6", tmp_dir=tmp_path).samefile(
resources_dir / "constraints-python36.txt"
)


def test_inline_packages(tmp_path: Path) -> None:
dependency_constraints = DependencyConstraints(
base_file_path=None,
packages=["foo==1.2.3", "bar==4.5.6"],
)

constraint_file = dependency_constraints.get_for_python_version(version="x.x", tmp_dir=tmp_path)
constraints_file_contents = constraint_file.read_text()

assert constraints_file_contents == "foo==1.2.3\nbar==4.5.6"
40 changes: 40 additions & 0 deletions unit_test/main_tests/main_options_test.py
Original file line number Diff line number Diff line change
@@ -13,6 +13,7 @@
from cibuildwheel.options import BuildOptions, _get_pinned_container_images
from cibuildwheel.selector import BuildSelector, EnableGroup
from cibuildwheel.util import resources
from cibuildwheel.util.packaging import DependencyConstraints

# CIBW_PLATFORM is tested in main_platform_test.py

@@ -348,6 +349,45 @@ def test_before_all(before_all, platform_specific, platform, intercepted_build_a
assert build_options.before_all == (before_all or "")


@pytest.mark.parametrize(
"dependency_versions",
[None, "pinned", "latest", "FILE", "packages: pip==21.0.0"],
)
@pytest.mark.parametrize("platform_specific", [False, True])
def test_dependency_versions(
dependency_versions, platform_specific, platform, intercepted_build_args, monkeypatch, tmp_path
):
option_value = dependency_versions

if dependency_versions == "FILE":
constraints_file = tmp_path / "constraints.txt"
constraints_file.write_text("foo==1.2.3\nbar==4.5.6")
option_value = str(constraints_file)

if option_value is not None:
if platform_specific:
monkeypatch.setenv("CIBW_DEPENDENCY_VERSIONS_" + platform.upper(), option_value)
monkeypatch.setenv("CIBW_DEPENDENCY_VERSIONS", "overwritten")
else:
monkeypatch.setenv("CIBW_DEPENDENCY_VERSIONS", option_value)

main()

build_options: BuildOptions = intercepted_build_args.args[0].build_options(identifier=None)
dependency_constraints = build_options.dependency_constraints
if dependency_versions is None or dependency_versions == "pinned":
assert dependency_constraints == DependencyConstraints.with_defaults()
elif dependency_versions == "latest":
assert dependency_constraints is None
elif dependency_versions == "FILE":
assert dependency_constraints
assert dependency_constraints.base_file_path
assert dependency_constraints.base_file_path.samefile(Path(option_value))
elif dependency_versions.startswith("packages:"):
assert dependency_constraints
assert dependency_constraints.packages == ["pip==21.0.0"]


@pytest.mark.parametrize("method", ["unset", "command_line", "env_var"])
def test_debug_traceback(monkeypatch, method, capfd):
if method == "command_line":
61 changes: 61 additions & 0 deletions unit_test/options_test.py
Original file line number Diff line number Diff line change
@@ -16,6 +16,7 @@
_get_pinned_container_images,
)
from cibuildwheel.selector import EnableGroup
from cibuildwheel.util import resources

PYPROJECT_1 = """
[tool.cibuildwheel]
@@ -470,3 +471,63 @@ def test_free_threaded_support(
assert EnableGroup.CPythonFreeThreading in options.globals.build_selector.enable
else:
assert EnableGroup.CPythonFreeThreading not in options.globals.build_selector.enable


@pytest.mark.parametrize(
("toml_assignment", "base_file_path", "packages"),
[
("", resources.CONSTRAINTS, None),
("dependency-versions = 'pinned'", resources.CONSTRAINTS, None),
("dependency-versions = 'latest'", None, None),
("dependency-versions = 'constraints file.txt'", Path("constraints file.txt"), None),
(
"dependency-versions = \"file:'constraints file.txt'\"",
Path("constraints file.txt"),
None,
),
(
"dependency-versions = {file = 'constraints file.txt'}",
Path("constraints file.txt"),
None,
),
(
"dependency-versions = 'packages: foo==1.2.3 bar==4.5.6'",
None,
["foo==1.2.3", "bar==4.5.6"],
),
],
)
def test_dependency_versions_toml(
tmp_path: Path,
toml_assignment: str,
base_file_path: Path | None,
packages: list[str] | None,
monkeypatch: pytest.MonkeyPatch,
) -> None:
args = CommandLineArguments.defaults()
args.package_dir = tmp_path

(tmp_path / "constraints file.txt").write_text("")
monkeypatch.chdir(tmp_path)

pyproject_toml: Path = tmp_path / "pyproject.toml"
pyproject_toml.write_text(
textwrap.dedent(
f"""\
[tool.cibuildwheel]
{toml_assignment}
"""
)
)

options = Options(platform="linux", command_line_arguments=args, env={})
parsed_dependency_constraints = options.build_options(None).dependency_constraints
if base_file_path is None and packages is None:
assert parsed_dependency_constraints is None
else:
assert parsed_dependency_constraints
if parsed_dependency_constraints.base_file_path and base_file_path:
assert parsed_dependency_constraints.base_file_path.samefile(base_file_path)
else:
assert parsed_dependency_constraints.base_file_path == base_file_path
assert parsed_dependency_constraints.packages == packages