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, + )