Add a test-sources configuration option. (#2062)

* Add test_sources configuration option.

* Add pyodide implementation.

* Modify default test behavior to run in the project directory.

* Update the test for running in the project directory.

* Apply suggestions from code review

Co-authored-by: Joe Rickerby <joerick@mac.com>

* Add shlex quoting to test-sources.

* Restructure ctypes example to avoid issues running from the project folder.

---------

Co-authored-by: Joe Rickerby <joerick@mac.com>
This commit is contained in:
Russell Keith-Magee
2025-01-10 15:24:24 +00:00
committed by GitHub
co-authored by Joe Rickerby
parent 4d0d9f6fd0
commit 0b82613d6f
16 changed files with 373 additions and 74 deletions
+14 -4
View File
@@ -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)
+12 -4
View File
@@ -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)
+11 -3
View File
@@ -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,
+12 -4
View File
@@ -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)
@@ -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"
},
+1
View File
@@ -19,6 +19,7 @@ repair-wheel-command = ""
test-command = ""
before-test = ""
test-sources = []
test-requires = []
test-extras = []
test-groups = []
@@ -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."
)
+38 -3
View File
@@ -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()
+12 -4
View File
@@ -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)