diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index afc8b6f19..590455bd3 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -29,6 +29,7 @@ repos:
args: ["--python-version=3.8"]
additional_dependencies: &mypy-dependencies
- bracex
+ - dependency-groups>=1.2
- nox
- orjson
- packaging
diff --git a/bin/generate_schema.py b/bin/generate_schema.py
index 41f4ad4cd..fa03284a2 100755
--- a/bin/generate_schema.py
+++ b/bin/generate_schema.py
@@ -164,6 +164,9 @@
test-extras:
description: Install your wheel for testing using `extras_require`
type: string_array
+ test-groups:
+ description: Install extra groups when testing
+ type: string_array
test-requires:
description: Install Python dependencies before running the tests
type: string_array
diff --git a/cibuildwheel/options.py b/cibuildwheel/options.py
index 5e1e71d16..7f813f009 100644
--- a/cibuildwheel/options.py
+++ b/cibuildwheel/options.py
@@ -22,7 +22,7 @@
from .environment import EnvironmentParseError, ParsedEnvironment, parse_environment
from .logger import log
from .oci_container import OCIContainerEngineConfig
-from .projectfiles import get_requires_python_str
+from .projectfiles import get_dependency_groups, get_requires_python_str
from .typing import PLATFORMS, PlatformName
from .util import (
MANYLINUX_ARCHS,
@@ -92,6 +92,7 @@ class BuildOptions:
before_test: str | None
test_requires: list[str]
test_extras: str
+ test_groups: list[str]
build_verbosity: int
build_frontend: BuildFrontendConfig | None
config_settings: str
@@ -568,6 +569,13 @@ def __init__(
disallow=DISALLOWED_OPTIONS,
)
+ self.project_dir = Path(command_line_arguments.package_dir)
+ try:
+ with self.project_dir.joinpath("pyproject.toml").open("rb") as f:
+ self.pyproject_toml = tomllib.load(f)
+ except FileNotFoundError:
+ self.pyproject_toml = {}
+
@property
def config_file_path(self) -> Path | None:
args = self.command_line_arguments
@@ -584,8 +592,10 @@ def config_file_path(self) -> Path | None:
@functools.cached_property
def package_requires_python_str(self) -> str | None:
- args = self.command_line_arguments
- return get_requires_python_str(Path(args.package_dir))
+ return get_requires_python_str(self.project_dir, self.pyproject_toml)
+
+ def dependency_groups(self, *groups: str) -> tuple[str, ...]:
+ return get_dependency_groups(self.pyproject_toml, *groups)
@property
def globals(self) -> GlobalOptions:
@@ -672,6 +682,10 @@ def build_options(self, identifier: str | None) -> BuildOptions:
"test-requires", option_format=ListFormat(sep=" ")
).split()
test_extras = self.reader.get("test-extras", option_format=ListFormat(sep=","))
+ test_groups = self.reader.get("test-groups", option_format=ListFormat(sep=",")).split(
+ ","
+ )
+ test_dependency_groups = self.dependency_groups(*test_groups)
build_verbosity_str = self.reader.get("build-verbosity")
build_frontend_str = self.reader.get(
@@ -771,8 +785,9 @@ def build_options(self, identifier: str | None) -> BuildOptions:
return BuildOptions(
globals=self.globals,
test_command=test_command,
- test_requires=test_requires,
+ test_requires=[*test_requires, *test_dependency_groups],
test_extras=test_extras,
+ test_groups=test_groups,
before_test=before_test,
before_build=before_build,
before_all=before_all,
diff --git a/cibuildwheel/projectfiles.py b/cibuildwheel/projectfiles.py
index c1a376696..d7f229204 100644
--- a/cibuildwheel/projectfiles.py
+++ b/cibuildwheel/projectfiles.py
@@ -4,8 +4,9 @@
import configparser
import contextlib
from pathlib import Path
+from typing import Any
-from ._compat import tomllib
+import dependency_groups
def get_parent(node: ast.AST | None, depth: int = 1) -> ast.AST | None:
@@ -84,15 +85,12 @@ def setup_py_python_requires(content: str) -> str | None:
return None
-def get_requires_python_str(package_dir: Path) -> str | None:
+def get_requires_python_str(package_dir: Path, pyproject_toml: dict[str, Any]) -> str | None:
"""Return the python requires string from the most canonical source available, or None"""
# Read in from pyproject.toml:project.requires-python
- with contextlib.suppress(FileNotFoundError):
- with (package_dir / "pyproject.toml").open("rb") as f1:
- info = tomllib.load(f1)
- with contextlib.suppress(KeyError, IndexError, TypeError):
- return str(info["project"]["requires-python"])
+ with contextlib.suppress(KeyError, IndexError, TypeError):
+ return str(pyproject_toml["project"]["requires-python"])
# Read in from setup.cfg:options.python_requires
config = configparser.ConfigParser()
@@ -106,3 +104,28 @@ def get_requires_python_str(package_dir: Path) -> str | None:
return setup_py_python_requires(f2.read())
return None
+
+
+def get_dependency_groups(pyproject_toml: dict[str, Any], *groups: str) -> tuple[str, ...]:
+ """
+ Get the packages in dependency-groups for a package.
+ """
+
+ if not groups:
+ return ()
+
+ try:
+ dependency_groups_toml = pyproject_toml["dependency-groups"]
+ except KeyError as e:
+ notes = "__notes__"
+ setattr(
+ e,
+ notes,
+ [
+ *getattr(e, "__notes__", []),
+ f"Didn't find [dependency-groups], which are needed to resolve {', '.join(groups)}.",
+ ],
+ )
+ raise
+
+ return dependency_groups.resolve(dependency_groups_toml, *groups)
diff --git a/cibuildwheel/resources/defaults.toml b/cibuildwheel/resources/defaults.toml
index 21bac7a0c..f7839643e 100644
--- a/cibuildwheel/resources/defaults.toml
+++ b/cibuildwheel/resources/defaults.toml
@@ -20,6 +20,7 @@ test-command = ""
before-test = ""
test-requires = []
test-extras = []
+test-groups = []
container-engine = "docker"
diff --git a/docs/options.md b/docs/options.md
index 37f54bfab..5ed692d63 100644
--- a/docs/options.md
+++ b/docs/options.md
@@ -1604,6 +1604,40 @@ Platform-specific environment variables are also available:
In configuration files, you can use an inline array, and the items will be joined with a comma.
+
+### `CIBW_TEST_GROUPS` {: #test-groups}
+> Install your wheel for testing using `dependency-groups`
+
+List of
+[dependency-groups](https://peps.python.org/pep-0735)
+that should be included when installing the wheel prior to running the
+tests. This can be used to avoid having to redefine test dependencies in
+`CIBW_TEST_REQUIRES` if they are already defined in `pyproject.toml`.
+
+Platform-specific environment variables are also available:
+`CIBW_TEST_GROUPS_MACOS` | `CIBW_TEST_GROUPS_WINDOWS` | `CIBW_TEST_GROUPS_LINUX` | `CIBW_TEST_GROUPS_PYODIDE`
+
+#### Examples
+
+!!! tab examples "Environment variables"
+
+ ```yaml
+ # Will cause the wheel to be installed with these groups of dependencies
+ CIBW_TEST_GROUPS: "test,qt"
+ ```
+
+ Separate multiple items with a comma.
+
+!!! tab examples "pyproject.toml"
+
+ ```toml
+ [tool.cibuildwheel]
+ # Will cause the wheel to be installed with these groups of dependencies
+ test-groups = ["test", "qt"]
+ ```
+
+ In configuration files, you can use an inline array, and the items will be joined with a comma.
+
### `CIBW_TEST_SKIP` {: #test-skip}
> Skip running tests on some builds
diff --git a/pyproject.toml b/pyproject.toml
index 2dd8588f6..f31708d30 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -43,6 +43,7 @@ dependencies = [
"bashlex!=0.13",
"bracex",
"certifi",
+ "dependency-groups>=1.2",
"filelock",
"packaging>=20.9",
"platformdirs",
diff --git a/unit_test/options_toml_test.py b/unit_test/options_toml_test.py
index da21d884b..584c272d8 100644
--- a/unit_test/options_toml_test.py
+++ b/unit_test/options_toml_test.py
@@ -22,6 +22,7 @@
test-command = "pyproject"
test-requires = "something"
test-extras = ["one", "two"]
+test-groups = ["three", "four"]
manylinux-x86_64-image = "manylinux1"
@@ -60,6 +61,7 @@ def test_simple_settings(tmp_path, platform, fname):
== 'THING="OTHER" FOO="BAR"'
)
assert options_reader.get("test-extras", option_format=ListFormat(",")) == "one,two"
+ assert options_reader.get("test-groups", option_format=ListFormat(",")) == "three,four"
assert options_reader.get("manylinux-x86_64-image") == "manylinux1"
assert options_reader.get("manylinux-i686-image") == "manylinux2014"
@@ -85,7 +87,9 @@ def test_envvar_override(tmp_path, platform):
"CIBW_MANYLINUX_X86_64_IMAGE": "manylinux_2_24",
"CIBW_TEST_COMMAND": "mytest",
"CIBW_TEST_REQUIRES": "docs",
+ "CIBW_TEST_GROUPS": "mgroup,two",
"CIBW_TEST_REQUIRES_LINUX": "scod",
+ "CIBW_TEST_GROUPS_LINUX": "lgroup",
},
)
@@ -99,6 +103,10 @@ def test_envvar_override(tmp_path, platform):
options_reader.get("test-requires", option_format=ListFormat(" "))
== {"windows": "docs", "macos": "docs", "linux": "scod"}[platform]
)
+ assert (
+ options_reader.get("test-groups", option_format=ListFormat(","))
+ == {"windows": "mgroup,two", "macos": "mgroup,two", "linux": "lgroup"}[platform]
+ )
assert options_reader.get("test-command") == "mytest"
diff --git a/unit_test/projectfiles_test.py b/unit_test/projectfiles_test.py
index b1839eda3..08f511c4c 100644
--- a/unit_test/projectfiles_test.py
+++ b/unit_test/projectfiles_test.py
@@ -2,7 +2,12 @@
from textwrap import dedent
-from cibuildwheel.projectfiles import get_requires_python_str, setup_py_python_requires
+from cibuildwheel._compat import tomllib
+from cibuildwheel.projectfiles import (
+ get_dependency_groups,
+ get_requires_python_str,
+ setup_py_python_requires,
+)
def test_read_setup_py_simple(tmp_path):
@@ -23,7 +28,7 @@ def test_read_setup_py_simple(tmp_path):
)
assert setup_py_python_requires(tmp_path.joinpath("setup.py").read_text()) == "1.23"
- assert get_requires_python_str(tmp_path) == "1.23"
+ assert get_requires_python_str(tmp_path, {}) == "1.23"
def test_read_setup_py_if_main(tmp_path):
@@ -45,7 +50,7 @@ def test_read_setup_py_if_main(tmp_path):
)
assert setup_py_python_requires(tmp_path.joinpath("setup.py").read_text()) == "1.23"
- assert get_requires_python_str(tmp_path) == "1.23"
+ assert get_requires_python_str(tmp_path, {}) == "1.23"
def test_read_setup_py_if_main_reversed(tmp_path):
@@ -67,7 +72,7 @@ def test_read_setup_py_if_main_reversed(tmp_path):
)
assert setup_py_python_requires(tmp_path.joinpath("setup.py").read_text()) == "1.23"
- assert get_requires_python_str(tmp_path) == "1.23"
+ assert get_requires_python_str(tmp_path, {}) == "1.23"
def test_read_setup_py_if_invalid(tmp_path):
@@ -89,7 +94,7 @@ def test_read_setup_py_if_invalid(tmp_path):
)
assert not setup_py_python_requires(tmp_path.joinpath("setup.py").read_text())
- assert not get_requires_python_str(tmp_path)
+ assert not get_requires_python_str(tmp_path, {})
def test_read_setup_py_full(tmp_path):
@@ -115,7 +120,7 @@ def test_read_setup_py_full(tmp_path):
assert (
setup_py_python_requires(tmp_path.joinpath("setup.py").read_text(encoding="utf8")) == "1.24"
)
- assert get_requires_python_str(tmp_path) == "1.24"
+ assert get_requires_python_str(tmp_path, {}) == "1.24"
def test_read_setup_py_assign(tmp_path):
@@ -138,7 +143,7 @@ def test_read_setup_py_assign(tmp_path):
)
assert setup_py_python_requires(tmp_path.joinpath("setup.py").read_text()) is None
- assert get_requires_python_str(tmp_path) is None
+ assert get_requires_python_str(tmp_path, {}) is None
def test_read_setup_py_None(tmp_path):
@@ -161,7 +166,7 @@ def test_read_setup_py_None(tmp_path):
)
assert setup_py_python_requires(tmp_path.joinpath("setup.py").read_text()) is None
- assert get_requires_python_str(tmp_path) is None
+ assert get_requires_python_str(tmp_path, {}) is None
def test_read_setup_py_empty(tmp_path):
@@ -183,7 +188,7 @@ def test_read_setup_py_empty(tmp_path):
)
assert setup_py_python_requires(tmp_path.joinpath("setup.py").read_text()) is None
- assert get_requires_python_str(tmp_path) is None
+ assert get_requires_python_str(tmp_path, {}) is None
def test_read_setup_cfg(tmp_path):
@@ -199,7 +204,7 @@ def test_read_setup_cfg(tmp_path):
)
)
- assert get_requires_python_str(tmp_path) == "1.234"
+ assert get_requires_python_str(tmp_path, {}) == "1.234"
def test_read_setup_cfg_empty(tmp_path):
@@ -215,7 +220,7 @@ def test_read_setup_cfg_empty(tmp_path):
)
)
- assert get_requires_python_str(tmp_path) is None
+ assert get_requires_python_str(tmp_path, {}) is None
def test_read_pyproject_toml(tmp_path):
@@ -231,8 +236,10 @@ def test_read_pyproject_toml(tmp_path):
"""
)
)
+ with open(tmp_path / "pyproject.toml", "rb") as f:
+ pyproject_toml = tomllib.load(f)
- assert get_requires_python_str(tmp_path) == "1.654"
+ assert get_requires_python_str(tmp_path, pyproject_toml) == "1.654"
def test_read_pyproject_toml_empty(tmp_path):
@@ -245,5 +252,15 @@ def test_read_pyproject_toml_empty(tmp_path):
"""
)
)
+ with open(tmp_path / "pyproject.toml", "rb") as f:
+ pyproject_toml = tomllib.load(f)
- assert get_requires_python_str(tmp_path) is None
+ assert get_requires_python_str(tmp_path, pyproject_toml) is None
+
+
+def test_read_dep_groups():
+ pyproject_toml = {"dependency-groups": {"group1": ["pkg1", "pkg2"], "group2": ["pkg3"]}}
+ assert get_dependency_groups(pyproject_toml) == ()
+ assert get_dependency_groups(pyproject_toml, "group1") == ("pkg1", "pkg2")
+ assert get_dependency_groups(pyproject_toml, "group2") == ("pkg3",)
+ assert get_dependency_groups(pyproject_toml, "group1", "group2") == ("pkg1", "pkg2", "pkg3")