diff --git a/README.md b/README.md
index 1a6e4c73..fbd3a186 100644
--- a/README.md
+++ b/README.md
@@ -143,6 +143,7 @@ Options
| | [`CIBW_DEPENDENCY_VERSIONS`](https://cibuildwheel.pypa.io/en/stable/options/#dependency-versions) | Specify how cibuildwheel controls the versions of the tools it uses |
| **Testing** | [`CIBW_TEST_COMMAND`](https://cibuildwheel.pypa.io/en/stable/options/#test-command) | Execute a shell command to test each built wheel |
| | [`CIBW_BEFORE_TEST`](https://cibuildwheel.pypa.io/en/stable/options/#before-test) | Execute a shell command before testing each wheel |
+| | [`CIBW_TEST_SOURCES`](https://cibuildwheel.pypa.io/en/stable/options/#test-sources) | Files and folders from the source tree that are copied into an isolated tree before running the tests |
| | [`CIBW_TEST_REQUIRES`](https://cibuildwheel.pypa.io/en/stable/options/#test-requires) | Install Python dependencies before running the tests |
| | [`CIBW_TEST_EXTRAS`](https://cibuildwheel.pypa.io/en/stable/options/#test-extras) | Install your wheel for testing using extras_require |
| | [`CIBW_TEST_SKIP`](https://cibuildwheel.pypa.io/en/stable/options/#test-skip) | Skip running tests on some builds |
diff --git a/cibuildwheel/linux.py b/cibuildwheel/linux.py
index 1ffe96f9..f1104de4 100644
--- a/cibuildwheel/linux.py
+++ b/cibuildwheel/linux.py
@@ -21,12 +21,12 @@ from .typing import PathOrStr
from .util import (
BuildFrontendConfig,
BuildSelector,
+ copy_test_sources,
find_compatible_wheel,
get_build_verbosity_extra_flags,
prepare_command,
read_python_configs,
split_config_settings,
- test_fail_cwd_file,
unwrap,
)
@@ -401,9 +401,19 @@ def build_in_container(
package=container_package_dir,
wheel=wheel_to_test,
)
- test_cwd = testing_temp_dir / "test_cwd"
- container.call(["mkdir", "-p", test_cwd])
- container.copy_into(test_fail_cwd_file, test_cwd / "test_fail.py")
+
+ if build_options.test_sources:
+ test_cwd = testing_temp_dir / "test_cwd"
+ container.call(["mkdir", "-p", test_cwd])
+ copy_test_sources(
+ build_options.test_sources,
+ build_options.package_dir,
+ test_cwd,
+ copy_into=container.copy_into,
+ )
+ else:
+ # There are no test sources. Run the tests in the project directory.
+ test_cwd = PurePosixPath(container_project_path)
container.call(["sh", "-c", test_command_prepared], cwd=test_cwd, env=virtualenv_env)
diff --git a/cibuildwheel/macos.py b/cibuildwheel/macos.py
index fcc88dac..357baa84 100644
--- a/cibuildwheel/macos.py
+++ b/cibuildwheel/macos.py
@@ -31,6 +31,7 @@ from .util import (
BuildSelector,
call,
combine_constraints,
+ copy_test_sources,
detect_ci_provider,
download,
find_compatible_wheel,
@@ -44,7 +45,6 @@ from .util import (
read_python_configs,
shell,
split_config_settings,
- test_fail_cwd_file,
unwrap,
virtualenv,
)
@@ -736,9 +736,17 @@ def build(options: Options, tmp_path: Path) -> None:
wheel=repaired_wheel,
)
- test_cwd = identifier_tmp_dir / "test_cwd"
- test_cwd.mkdir(exist_ok=True)
- (test_cwd / "test_fail.py").write_text(test_fail_cwd_file.read_text())
+ if build_options.test_sources:
+ test_cwd = identifier_tmp_dir / "test_cwd"
+ test_cwd.mkdir(exist_ok=True)
+ copy_test_sources(
+ build_options.test_sources,
+ build_options.package_dir,
+ test_cwd,
+ )
+ else:
+ # There are no test sources. Run the tests in the project directory.
+ test_cwd = Path(".").resolve()
shell_with_arch(test_command_prepared, cwd=test_cwd, env=virtualenv_env)
diff --git a/cibuildwheel/options.py b/cibuildwheel/options.py
index 3fb9ebb7..c57bb6cb 100644
--- a/cibuildwheel/options.py
+++ b/cibuildwheel/options.py
@@ -9,7 +9,7 @@ import enum
import functools
import shlex
import textwrap
-from collections.abc import Generator, Iterable, Set
+from collections.abc import Callable, Generator, Iterable, Set
from pathlib import Path
from typing import Any, Literal, Mapping, Sequence, Union # noqa: TID251
@@ -92,6 +92,7 @@ class BuildOptions:
dependency_constraints: DependencyConstraints | None
test_command: str | None
before_test: str | None
+ test_sources: list[str]
test_requires: list[str]
test_extras: str
test_groups: list[str]
@@ -171,11 +172,12 @@ class ListFormat(OptionFormat):
A format that joins lists with a separator.
"""
- def __init__(self, sep: str) -> None:
+ def __init__(self, sep: str, quote: Callable[[str], str] | None = None) -> None:
self.sep = sep
+ self.quote = quote if quote else lambda s: s
def format_list(self, value: SettingList) -> str:
- return self.sep.join(str(v) for v in value)
+ return self.sep.join(self.quote(str(v)) for v in value)
def merge_values(self, before: str, after: str) -> str:
return f"{before}{self.sep}{after}"
@@ -711,6 +713,11 @@ class Options:
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(
+ self.reader.get(
+ "test-sources", option_format=ListFormat(sep=" ", quote=shlex.quote)
+ )
+ )
test_requires = self.reader.get(
"test-requires", option_format=ListFormat(sep=" ")
).split()
@@ -819,6 +826,7 @@ class Options:
return BuildOptions(
globals=self.globals,
test_command=test_command,
+ test_sources=test_sources,
test_requires=[*test_requires, *test_requirements_from_groups],
test_extras=test_extras,
test_groups=test_groups,
diff --git a/cibuildwheel/pyodide.py b/cibuildwheel/pyodide.py
index 19d47e5e..540a27fd 100644
--- a/cibuildwheel/pyodide.py
+++ b/cibuildwheel/pyodide.py
@@ -21,6 +21,7 @@ from .util import (
BuildSelector,
call,
combine_constraints,
+ copy_test_sources,
download,
ensure_node,
extract_zip,
@@ -31,7 +32,6 @@ from .util import (
read_python_configs,
shell,
split_config_settings,
- test_fail_cwd_file,
virtualenv,
)
@@ -387,9 +387,17 @@ def build(options: Options, tmp_path: Path) -> None:
package=build_options.package_dir.resolve(),
)
- test_cwd = identifier_tmp_dir / "test_cwd"
- test_cwd.mkdir(exist_ok=True)
- (test_cwd / "test_fail.py").write_text(test_fail_cwd_file.read_text())
+ if build_options.test_sources:
+ test_cwd = identifier_tmp_dir / "test_cwd"
+ test_cwd.mkdir(exist_ok=True)
+ copy_test_sources(
+ build_options.test_sources,
+ build_options.package_dir,
+ test_cwd,
+ )
+ else:
+ # There are no test sources. Run the tests in the project directory.
+ test_cwd = Path(".").resolve()
shell(test_command_prepared, cwd=test_cwd, env=virtualenv_env)
diff --git a/cibuildwheel/resources/cibuildwheel.schema.json b/cibuildwheel/resources/cibuildwheel.schema.json
index e9ef3985..1ddbfcf1 100644
--- a/cibuildwheel/resources/cibuildwheel.schema.json
+++ b/cibuildwheel/resources/cibuildwheel.schema.json
@@ -426,6 +426,21 @@
],
"title": "CIBW_TEST_EXTRAS"
},
+ "test-sources": {
+ "description": "Test files that are required by the test environment",
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ ],
+ "title": "CIBW_TEST_SOURCES"
+ },
"test-groups": {
"description": "Install extra groups when testing",
"oneOf": [
@@ -529,6 +544,9 @@
"test-extras": {
"$ref": "#/$defs/inherit"
},
+ "test-sources": {
+ "$ref": "#/$defs/inherit"
+ },
"test-requires": {
"$ref": "#/$defs/inherit"
}
@@ -618,6 +636,9 @@
"test-extras": {
"$ref": "#/properties/test-extras"
},
+ "test-sources": {
+ "$ref": "#/properties/test-sources"
+ },
"test-groups": {
"$ref": "#/properties/test-groups"
},
@@ -728,6 +749,9 @@
"test-extras": {
"$ref": "#/properties/test-extras"
},
+ "test-sources": {
+ "$ref": "#/properties/test-sources"
+ },
"test-groups": {
"$ref": "#/properties/test-groups"
},
@@ -776,6 +800,9 @@
"test-extras": {
"$ref": "#/properties/test-extras"
},
+ "test-sources": {
+ "$ref": "#/properties/test-sources"
+ },
"test-groups": {
"$ref": "#/properties/test-groups"
},
@@ -837,6 +864,9 @@
"test-extras": {
"$ref": "#/properties/test-extras"
},
+ "test-sources": {
+ "$ref": "#/properties/test-sources"
+ },
"test-groups": {
"$ref": "#/properties/test-groups"
},
@@ -885,6 +915,9 @@
"test-extras": {
"$ref": "#/properties/test-extras"
},
+ "test-sources": {
+ "$ref": "#/properties/test-sources"
+ },
"test-groups": {
"$ref": "#/properties/test-groups"
},
diff --git a/cibuildwheel/resources/defaults.toml b/cibuildwheel/resources/defaults.toml
index 3c56dfc5..7d32fb97 100644
--- a/cibuildwheel/resources/defaults.toml
+++ b/cibuildwheel/resources/defaults.toml
@@ -19,6 +19,7 @@ repair-wheel-command = ""
test-command = ""
before-test = ""
+test-sources = []
test-requires = []
test-extras = []
test-groups = []
diff --git a/cibuildwheel/resources/testing_temp_dir_file.py b/cibuildwheel/resources/testing_temp_dir_file.py
deleted file mode 100644
index 1788e7cf..00000000
--- a/cibuildwheel/resources/testing_temp_dir_file.py
+++ /dev/null
@@ -1,17 +0,0 @@
-# this file is copied to the testing cwd, to raise the below error message if
-# pytest/unittest is run from there.
-
-import unittest
-
-
-class TestStringMethods(unittest.TestCase):
- def test_fail(self):
- self.fail(
- "cibuildwheel executes tests from a different working directory to "
- "your project. This ensures only your wheel is imported, preventing "
- "Python from accessing files that haven't been packaged into the "
- "wheel. Please specify a path to your tests when invoking pytest "
- "using the {project} placeholder, e.g. `pytest {project}` or "
- "`pytest {project}/tests`. cibuildwheel will replace {project} with "
- "the path to your project."
- )
diff --git a/cibuildwheel/util.py b/cibuildwheel/util.py
index 905dd541..172fa335 100644
--- a/cibuildwheel/util.py
+++ b/cibuildwheel/util.py
@@ -17,7 +17,7 @@ import time
import typing
import urllib.request
from collections import defaultdict
-from collections.abc import Generator, Iterable, Mapping, MutableMapping, Sequence
+from collections.abc import Callable, Generator, Iterable, Mapping, MutableMapping, Sequence
from dataclasses import dataclass
from enum import Enum
from functools import lru_cache, total_ordering
@@ -36,6 +36,7 @@ from packaging.utils import parse_wheel_filename
from packaging.version import Version
from platformdirs import user_cache_path
+from . import errors
from ._compat import tomllib
from .architecture import Architecture
from .errors import FatalError
@@ -66,8 +67,6 @@ install_certifi_script: Final[Path] = resources_dir / "install_certifi.py"
free_thread_enable_313: Final[Path] = resources_dir / "free-threaded-enable-313.xml"
-test_fail_cwd_file: Final[Path] = resources_dir / "testing_temp_dir_file.py"
-
class EnableGroups(enum.Enum):
"""
@@ -425,6 +424,42 @@ def move_file(src_file: Path, dst_file: Path) -> Path:
return Path(resulting_file).resolve(strict=True)
+def copy_into_local(src: Path, dst: PurePath) -> None:
+ """Copy a path from src to dst, regardless of whether it's a file or a directory."""
+ # Ensure the target folder location exists
+ Path(dst.parent).mkdir(exist_ok=True, parents=True)
+
+ if src.is_dir():
+ shutil.copytree(src, dst)
+ else:
+ shutil.copy(src, dst)
+
+
+def copy_test_sources(
+ test_sources: list[str],
+ package_dir: Path,
+ test_dir: PurePath,
+ copy_into: Callable[[Path, PurePath], None] = copy_into_local,
+) -> None:
+ """Copy the list of test sources from the package to the test directory.
+
+ :param test_sources: A list of test paths, relative to the package_dir.
+ :param package_dir: The root of the package directory.
+ :param test_dir: The folder where test sources should be placed.
+ :param copy_info: The copy function to use. By default, does a local
+ filesystem copy; but an OCIContainer.copy_info method (or equivalent)
+ can be provided.
+ """
+ for test_path in test_sources:
+ source = package_dir.resolve() / test_path
+
+ if not source.exists():
+ msg = f"Test source {test_path} does not exist."
+ raise errors.FatalError(msg)
+
+ copy_into(source, test_dir / test_path)
+
+
class DependencyConstraints:
def __init__(self, base_file_path: Path):
assert base_file_path.exists()
diff --git a/cibuildwheel/windows.py b/cibuildwheel/windows.py
index 8f5633d6..722c0f55 100644
--- a/cibuildwheel/windows.py
+++ b/cibuildwheel/windows.py
@@ -27,6 +27,7 @@ from .util import (
BuildSelector,
call,
combine_constraints,
+ copy_test_sources,
download,
extract_zip,
find_compatible_wheel,
@@ -38,7 +39,6 @@ from .util import (
read_python_configs,
shell,
split_config_settings,
- test_fail_cwd_file,
unwrap,
virtualenv,
)
@@ -572,9 +572,17 @@ def build(options: Options, tmp_path: Path) -> None:
package=options.globals.package_dir.resolve(),
wheel=repaired_wheel,
)
- test_cwd = identifier_tmp_dir / "test_cwd"
- test_cwd.mkdir()
- (test_cwd / "test_fail.py").write_text(test_fail_cwd_file.read_text())
+ if build_options.test_sources:
+ test_cwd = identifier_tmp_dir / "test_cwd"
+ test_cwd.mkdir()
+ copy_test_sources(
+ build_options.test_sources,
+ build_options.package_dir,
+ test_cwd,
+ )
+ else:
+ # There are no test sources. Run the tests in the project directory.
+ test_cwd = Path(".").resolve()
shell(test_command_prepared, cwd=test_cwd, env=virtualenv_env)
diff --git a/docs/options.md b/docs/options.md
index a1452e17..fbd29556 100644
--- a/docs/options.md
+++ b/docs/options.md
@@ -1424,17 +1424,22 @@ Platform-specific environment variables are also available:
> Execute a shell command to test each built wheel
Shell command to run tests after the build. The wheel will be installed
-automatically and available for import from the tests. To ensure the wheel is
-imported by your tests (instead of your source copy), **tests are not run from
-your project directory**. Use the placeholders `{project}` and `{package}` when
-specifying paths in your project. If this variable is not set, your wheel will
-not be installed after building.
+automatically and available for import from the tests. If this variable is not
+set, your wheel will not be installed after building.
+
+By default, tests are executed from from your project directory. When specifying
+`CIBW_TEST_COMMAND`, you can use the placeholders `{project}` and `{package}` to
+pass in the location of your test code:
- `{project}` is an absolute path to the project root - the working directory
where cibuildwheel was called.
- `{package}` is the path to the package being built - the `package_dir`
argument supplied to cibuildwheel on the command line.
+Alternatively, you can use the [`CIBW_TEST_SOURCES`](#test-sources) setting to
+create a temporary folder populated with a specific subset of project files to
+run your test suite.
+
The command is run in a shell, so you can write things like `cmd1 && cmd2`.
Platform-specific environment variables are also available:
@@ -1549,6 +1554,37 @@ Platform-specific environment variables are also available:
In configuration files, you can use an array, and the items will be joined with `&&`.
+### `CIBW_TEST_SOURCES` {: #test-sources}
+> Files and folders from the source tree that are copied into an isolated tree before running the tests
+
+A space-separated list of files and folders, relative to the root of the
+project, required for running the tests. If specified, these files and folders
+will be copied into a temporary folder, and that temporary folder will be used
+as the working directory for running the test suite.
+
+Platform-specific environment variables are also available:
+`CIBW_TEST_SOURCES_MACOS` | `CIBW_TEST_SOURCES_WINDOWS` | `CIBW_TEST_SOURCES_LINUX` | `CIBW_TEST_SOURCES_PYODIDE`
+
+#### Examples
+
+!!! tab examples "Environment variables"
+
+ ```yaml
+ # Copy the "tests" folder, plus "data/test-image.png" from the source folder to the test folder.
+ CIBW_TEST_SOURCES: tests data/test-image.png
+ ```
+
+!!! tab examples "pyproject.toml"
+
+ ```toml
+ # Copy the "tests" folder, plus "data/test-image.png" from the source folder to the test folder.
+ [tool.cibuildwheel]
+ test-sources = ["tests", "data/test-image.png"]
+ ```
+
+ In configuration files, you can use an array, and the items will be joined with a space.
+
+
### `CIBW_TEST_REQUIRES` {: #test-requires}
> Install Python dependencies before running the tests
diff --git a/test/test_abi_variants.py b/test/test_abi_variants.py
index a2dd43ac..11bea544 100644
--- a/test/test_abi_variants.py
+++ b/test/test_abi_variants.py
@@ -101,18 +101,19 @@ ctypes_project.files["setup.py"] = textwrap.dedent(
setup(
name="ctypesexample",
version="1.0.0",
+ package_dir = {"": "src"},
py_modules = ["ctypesexample.summing"],
ext_modules=[
CTypesExtension(
"ctypesexample.csumlib",
- ["ctypesexample/csumlib.c"],
+ ["src/ctypesexample/csumlib.c"],
),
],
cmdclass={'build_ext': build_ext, 'bdist_wheel': bdist_wheel_abi_none},
)
"""
)
-ctypes_project.files["ctypesexample/csumlib.c"] = textwrap.dedent(
+ctypes_project.files["src/ctypesexample/csumlib.c"] = textwrap.dedent(
"""
#ifdef _WIN32
#define LIBRARY_API __declspec(dllexport)
@@ -136,7 +137,7 @@ ctypes_project.files["ctypesexample/csumlib.c"] = textwrap.dedent(
}
"""
)
-ctypes_project.files["ctypesexample/summing.py"] = textwrap.dedent(
+ctypes_project.files["src/ctypesexample/summing.py"] = textwrap.dedent(
"""
import ctypes
import pathlib
diff --git a/test/test_testing.py b/test/test_testing.py
index b94e3ee9..f052a13e 100644
--- a/test/test_testing.py
+++ b/test/test_testing.py
@@ -186,34 +186,43 @@ def test_failing_test(tmp_path):
@pytest.mark.parametrize("test_runner", ["pytest", "unittest"])
-def test_bare_pytest_invocation(
- tmp_path: Path, capfd: pytest.CaptureFixture[str], test_runner: str
-) -> None:
- """Check that if a user runs pytest in the the test cwd, it raises a helpful error"""
+def test_bare_pytest_invocation(tmp_path: Path, test_runner: str) -> None:
+ """Check that if a user runs a bare test suite, it runs in the project folder"""
project_dir = tmp_path / "project"
- output_dir = tmp_path / "output"
project_with_a_test.generate(project_dir)
- with pytest.raises(subprocess.CalledProcessError):
- utils.cibuildwheel_run(
- project_dir,
- output_dir=output_dir,
- add_env={
- "CIBW_TEST_REQUIRES": "pytest" if test_runner == "pytest" else "",
- "CIBW_TEST_COMMAND": (
- "python -m pytest" if test_runner == "pytest" else "python -m unittest"
- ),
- # Skip CPython 3.8 on macOS arm64, see comment above in
- # 'test_failing_test'
- "CIBW_SKIP": "cp38-macosx_arm64",
- },
- )
-
- assert len(os.listdir(output_dir)) == 0
-
- captured = capfd.readouterr()
-
- assert (
- "Please specify a path to your tests when invoking pytest using the {project} placeholder"
- in captured.out + captured.err
+ actual_wheels = utils.cibuildwheel_run(
+ project_dir,
+ add_env={
+ "CIBW_TEST_REQUIRES": "pytest" if test_runner == "pytest" else "",
+ "CIBW_TEST_COMMAND": (
+ "python -m pytest"
+ if test_runner == "pytest"
+ else "python -m unittest discover test spam_test.py"
+ ),
+ },
)
+
+ # check that we got the right wheels
+ expected_wheels = utils.expected_wheels("spam", "0.1.0")
+ assert set(actual_wheels) == set(expected_wheels)
+
+
+def test_test_sources(tmp_path):
+ project_dir = tmp_path / "project"
+ project_with_a_test.generate(project_dir)
+
+ # build and test the wheels in the test cwd, after copying in the test sources.
+ actual_wheels = utils.cibuildwheel_run(
+ project_dir,
+ add_env={
+ "CIBW_TEST_REQUIRES": "pytest",
+ "CIBW_TEST_COMMAND": "pytest",
+ "CIBW_TEST_COMMAND_WINDOWS": "pytest",
+ "CIBW_TEST_SOURCES": "test",
+ },
+ )
+
+ # also check that we got the right wheels
+ expected_wheels = utils.expected_wheels("spam", "0.1.0")
+ assert set(actual_wheels) == set(expected_wheels)
diff --git a/unit_test/options_test.py b/unit_test/options_test.py
index fb6727b0..04dab329 100644
--- a/unit_test/options_test.py
+++ b/unit_test/options_test.py
@@ -24,6 +24,7 @@ skip = ["*musllinux*"]
environment = {FOO="BAR"}
test-command = "pyproject"
+test-sources = ["test", "other dir"]
manylinux-x86_64-image = "manylinux1"
@@ -74,11 +75,13 @@ def test_options_1(tmp_path, monkeypatch):
local = options.build_options("cp38-manylinux_x86_64")
assert local.manylinux_images is not None
assert local.test_command == "pyproject"
+ assert local.test_sources == ["test", "other dir"]
assert local.manylinux_images["x86_64"] == pinned_x86_64_container_image["manylinux1"]
local = options.build_options("cp37-manylinux_x86_64")
assert local.manylinux_images is not None
assert local.test_command == "pyproject-override"
+ assert local.test_sources == ["test", "other dir"]
assert local.manylinux_images["x86_64"] == pinned_x86_64_container_image["manylinux2014"]
diff --git a/unit_test/options_toml_test.py b/unit_test/options_toml_test.py
index 5eda6cbb..2045ab16 100644
--- a/unit_test/options_toml_test.py
+++ b/unit_test/options_toml_test.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import shlex
from pathlib import Path
import pytest
@@ -23,6 +24,7 @@ test-command = "pyproject"
test-requires = "something"
test-extras = ["one", "two"]
test-groups = ["three", "four"]
+test-sources = ["five", "six and seven"]
manylinux-x86_64-image = "manylinux1"
@@ -50,6 +52,10 @@ def test_simple_settings(tmp_path, platform, fname):
assert options_reader.get("test-command") == "pyproject"
assert options_reader.get("archs", option_format=ListFormat(" ")) == "auto"
+ assert (
+ options_reader.get("test-sources", option_format=ListFormat(" ", quote=shlex.quote))
+ == "five 'six and seven'"
+ )
assert (
options_reader.get("test-requires", option_format=ListFormat(" "))
== {"windows": "something", "macos": "else", "linux": "other many"}[platform]
@@ -90,6 +96,7 @@ def test_envvar_override(tmp_path, platform):
"CIBW_TEST_GROUPS": "mgroup two",
"CIBW_TEST_REQUIRES_LINUX": "scod",
"CIBW_TEST_GROUPS_LINUX": "lgroup",
+ "CIBW_TEST_SOURCES": 'first "second third"',
},
)
@@ -99,6 +106,10 @@ def test_envvar_override(tmp_path, platform):
assert options_reader.get("manylinux-x86_64-image") == "manylinux_2_24"
assert options_reader.get("manylinux-i686-image") == "manylinux2014"
+ assert (
+ options_reader.get("test-sources", option_format=ListFormat(" ", quote=shlex.quote))
+ == 'first "second third"'
+ )
assert (
options_reader.get("test-requires", option_format=ListFormat(" "))
== {"windows": "docs", "macos": "docs", "linux": "scod"}[platform]
diff --git a/unit_test/utils_test.py b/unit_test/utils_test.py
index e3b87be8..c26d9bbf 100644
--- a/unit_test/utils_test.py
+++ b/unit_test/utils_test.py
@@ -2,11 +2,14 @@ from __future__ import annotations
import textwrap
from pathlib import PurePath
+from unittest.mock import Mock, call
import pytest
+from cibuildwheel import errors
from cibuildwheel.util import (
FlexibleVersion,
+ copy_test_sources,
find_compatible_wheel,
fix_ansi_codes_for_github_actions,
format_safe,
@@ -221,3 +224,144 @@ def test_flexible_version_comparisons():
assert FlexibleVersion("1.0.1-rhel") > FlexibleVersion("1.0")
assert FlexibleVersion("1.0.1-rhel") < FlexibleVersion("1.1")
assert FlexibleVersion("1.0.1") == FlexibleVersion("v1.0.1")
+
+
+@pytest.fixture
+def sample_project(tmp_path):
+ """Create a directory structure that contains a range of files."""
+ project_path = tmp_path / "project"
+
+ (project_path / "src/deep").mkdir(parents=True)
+ (project_path / "tests/deep").mkdir(parents=True)
+ (project_path / "other").mkdir(parents=True)
+
+ (project_path / "pyproject.toml").write_text("A pyproject.toml file")
+ (project_path / "test.cfg").write_text("A test config file")
+
+ (project_path / "src/__init__.py").write_text("source init")
+ (project_path / "src/module.py").write_text("source module")
+ (project_path / "src/deep/__init__.py").write_text("deep source init")
+
+ (project_path / "tests/test_module.py").write_text("test module")
+ (project_path / "tests/deep/test_module.py").write_text("deep test module")
+ (project_path / "tests/deep/__init__.py").write_text("deep test init")
+
+ (project_path / "other/module.py").write_text("other module")
+
+ return project_path
+
+
+@pytest.mark.parametrize(
+ ("test_sources", "expected", "not_expected"),
+ [
+ # Empty test_sources copies nothing.
+ pytest.param(
+ [],
+ [],
+ [
+ "pyproject.toml",
+ "test.cfg",
+ "other/module.py",
+ "src/__init__.py",
+ "src/module.py",
+ "src/deep/__init__.py",
+ "tests/test_module.py",
+ "tests/deep/__init__.py",
+ "tests/deep/test_module.py",
+ ],
+ id="empty",
+ ),
+ # Single standalone files
+ pytest.param(
+ ["pyproject.toml", "tests/deep/test_module.py"],
+ ["pyproject.toml", "tests/deep/test_module.py"],
+ [
+ "test.cfg",
+ "other/module.py",
+ "src/__init__.py",
+ "src/module.py",
+ "src/deep/__init__.py",
+ "tests/test_module.py",
+ "tests/deep/__init__.py",
+ ],
+ id="single-file",
+ ),
+ # A full Directory
+ pytest.param(
+ ["tests"],
+ [
+ "tests/test_module.py",
+ "tests/deep/__init__.py",
+ "tests/deep/test_module.py",
+ ],
+ [
+ "pyproject.toml",
+ "test.cfg",
+ "other/module.py",
+ "src/__init__.py",
+ "src/module.py",
+ "src/deep/__init__.py",
+ ],
+ id="top-level-directory",
+ ),
+ # A partial deep directory
+ pytest.param(
+ ["tests/deep"],
+ [
+ "tests/deep/__init__.py",
+ "tests/deep/test_module.py",
+ ],
+ [
+ "pyproject.toml",
+ "test.cfg",
+ "other/module.py",
+ "src/__init__.py",
+ "src/module.py",
+ "src/deep/__init__.py",
+ "tests/test_module.py",
+ ],
+ id="partial-directory",
+ ),
+ ],
+)
+def test_copy_test_sources(tmp_path, sample_project, test_sources, expected, not_expected):
+ """Test sources can be copied into the test directory."""
+ target = tmp_path / "somewhere/test_cwd"
+ copy_test_sources(test_sources, sample_project, target)
+
+ for path in expected:
+ assert (tmp_path / "somewhere/test_cwd" / path).is_file()
+
+ for path in not_expected:
+ assert not (tmp_path / "somewhere/test_cwd" / path).exists()
+
+
+def test_copy_test_sources_missing_file(tmp_path, sample_project):
+ """If test_sources references a folder that doesn't exist, an error is raised."""
+
+ with pytest.raises(
+ errors.FatalError,
+ match=r"Test source tests/does_not_exist.py does not exist.",
+ ):
+ copy_test_sources(
+ ["pyproject.toml", "tests/does_not_exist.py"],
+ sample_project,
+ tmp_path / "somewhere/test_cwd",
+ )
+
+
+def test_copy_test_sources_alternate_copy_into(sample_project):
+ """If an alternate copy_into method is provided, it is used."""
+
+ target = PurePath("/container/test_cwd")
+ copy_into = Mock()
+
+ copy_test_sources(["pyproject.toml", "tests"], sample_project, target, copy_into=copy_into)
+
+ copy_into.assert_has_calls(
+ [
+ call(sample_project / "pyproject.toml", target / "pyproject.toml"),
+ call(sample_project / "tests", target / "tests"),
+ ],
+ any_order=True,
+ )