diff --git a/tests/conftest.py b/tests/conftest.py index 47a5b9b9..7e9b0d8f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -160,6 +160,10 @@ def prepare_no_build_isolation(self) -> None: @pytest.fixture def isolated(tmp_path: Path, pep518_wheelhouse: Path) -> VEnv: + """Isolated virtual environment. + + To control build isolation, see :py:func:`isolate` + """ path = tmp_path / "venv" return VEnv(path, wheelhouse=pep518_wheelhouse) @@ -212,6 +216,13 @@ def package( tmp_path_factory: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch, ) -> PackageInfo: + """Get the test package. + + Parameterize this fixture with the package name in the tests/packages directory. + (Use ``indirect=True`` to pass the parameterization value to the fixture instead of + directly to the test function.) + https://docs.pytest.org/en/stable/example/parametrize.html#indirect-parametrization + """ pkg_name = request.param assert isinstance(pkg_name, str) package = PackageInfo(pkg_name, tmp_path_factory.mktemp("pkg")) @@ -256,12 +267,18 @@ def package_simple_pyproject_ext( @dataclasses.dataclass(frozen=True) class Isolate: + """Selection for build isolation.""" + state: bool flags: list[str] @pytest.fixture(params=[True, False], ids=["isolated", "not_isolated"]) def isolate(request: pytest.FixtureRequest, isolated: VEnv) -> Isolate: + """Control build isolation. + + For an isolated virtual environment, see :py:func:`isolated` + """ isolate_request = request.param assert isinstance(isolate_request, bool) if not isolate_request: diff --git a/tests/packages/cmake_generated/CMakeLists.txt b/tests/packages/cmake_generated/CMakeLists.txt new file mode 100644 index 00000000..ec619e9c --- /dev/null +++ b/tests/packages/cmake_generated/CMakeLists.txt @@ -0,0 +1,52 @@ +cmake_minimum_required(VERSION 3.15) + +project( + ${SKBUILD_PROJECT_NAME} + LANGUAGES CXX + VERSION 1.2.3) + +# Generate files at config time (configure_file) and at build time +# (add_custom_command) Note that bundling a generated file with sdist is out of +# scope for now. Note: cmake_generated/nested1/generated.py should try to open +# both generated and static files. +configure_file(src/cmake_generated/nested1/generated.py.in generated.py) +# We always expect the install phase to run, so the build tree layout can be +# different than the package layout. +install(FILES ${CMAKE_CURRENT_BINARY_DIR}/generated.py + DESTINATION ${SKBUILD_PROJECT_NAME}/nested1) + +file( + GENERATE + OUTPUT configured_file + CONTENT "value written by cmake file generation") +install(FILES ${CMAKE_CURRENT_BINARY_DIR}/configured_file + DESTINATION ${SKBUILD_PROJECT_NAME}) + +set(OUTPUT_FILE "${CMAKE_CURRENT_BINARY_DIR}/generated_data") +set(FILE_CONTENT "value written by cmake custom_command") +set(GENERATE_SCRIPT "file(WRITE \"${OUTPUT_FILE}\" \"${FILE_CONTENT}\")") +add_custom_command( + OUTPUT "${OUTPUT_FILE}" + COMMAND "${CMAKE_COMMAND}" -P + "${CMAKE_CURRENT_BINARY_DIR}/generate_file.cmake" + DEPENDS "${CMAKE_CURRENT_BINARY_DIR}/generate_file.cmake" + COMMENT "Generating ${OUTPUT_FILE} using CMake scripting at build time" + VERBATIM) +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/generate_file.cmake" + "${GENERATE_SCRIPT}") +add_custom_target(generate_file ALL DEPENDS "${OUTPUT_FILE}") +install(FILES "${OUTPUT_FILE}" DESTINATION ${SKBUILD_PROJECT_NAME}/namespace1) + +add_library(pkg MODULE src/cmake_generated/pkg.cpp) +include(GenerateExportHeader) +generate_export_header(pkg) +target_include_directories(pkg PRIVATE ${CMAKE_CURRENT_BINARY_DIR}) + +if(NOT WIN32) + # Explicitly set the bundle extension to .so + set_target_properties(pkg PROPERTIES SUFFIX ".so") +endif() + +# Set the library name to "pkg", regardless of the OS convention. +set_target_properties(pkg PROPERTIES PREFIX "") +install(TARGETS pkg DESTINATION ${SKBUILD_PROJECT_NAME}) diff --git a/tests/packages/cmake_generated/pyproject.toml b/tests/packages/cmake_generated/pyproject.toml new file mode 100644 index 00000000..da2c8081 --- /dev/null +++ b/tests/packages/cmake_generated/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["scikit-build-core"] +build-backend = "scikit_build_core.build" + +[project] +name = "cmake_generated" +dynamic = ["version"] + +[tool.scikit-build] +# Bundling a generated file in the sdist is not supported at this time. +# sdist.cmake = false +wheel.license-files = [] +wheel.exclude = ["**.cpp", "**.in"] + +[tool.scikit-build.metadata.version] +provider = "scikit_build_core.metadata.regex" +input = "CMakeLists.txt" +regex = 'project\([^)]+ VERSION (?P[0-9.]+)' + +[[tool.scikit-build.generate]] +path = "cmake_generated/_version.py" +template = ''' +__version__ = "${version}" +''' diff --git a/tests/packages/cmake_generated/src/cmake_generated/__init__.py b/tests/packages/cmake_generated/src/cmake_generated/__init__.py new file mode 100644 index 00000000..d507ff21 --- /dev/null +++ b/tests/packages/cmake_generated/src/cmake_generated/__init__.py @@ -0,0 +1,75 @@ +"""Package that includes several non-Python non-module files. + +Support some test cases aimed at testing our ability to find generated files +and static package data files in editable installations. + +We are exercising the importlib machinery to find files that +are generated in different phases of the build and in different parts of +the package layout to check that the redirection works correctly in an +editable installation. + +The test package includes raw data files and shared object libraries that +are accessed via `ctypes`. + +We test files (generated and static) + +* at the top level of the package, +* in subpackages, and +* in a namespace package. + +We test access + +* from modules at the same level as the files, +* one level above and below, and +* from parallel subpackages. +""" + +import ctypes +import sys +from importlib.resources import as_file, files, read_text + +try: + from ._version import __version__ # type: ignore[import-not-found] +except ImportError: + __version__ = None + + +def get_static_data(): + return read_text("cmake_generated", "static_data").rstrip() + + +def get_configured_data(): + return files("cmake_generated").joinpath("configured_file").read_text().rstrip() + + +def get_namespace_static_data(): + if sys.version_info[0:2] == (3, 9): + return ( + files("cmake_generated") + .joinpath("namespace1/static_data") + .read_text() + .rstrip() + ) + # (except in Python 3.9) read_text is able to handle a namespace subpackage directly, though `files()` is not. + return read_text("cmake_generated.namespace1", "static_data").rstrip() + + +def get_namespace_generated_data(): + # Note that `files("cmake_generated.namespace1")` doesn't work. + # Ref https://github.com/python/importlib_resources/issues/262 + return ( + files("cmake_generated") + .joinpath("namespace1/generated_data") + .read_text() + .rstrip() + ) + + +def ctypes_function(): + if sys.platform == "win32": + lib_suffix = "dll" + else: + lib_suffix = "so" + with as_file(files("cmake_generated").joinpath(f"pkg.{lib_suffix}")) as lib_path: + lib = ctypes.cdll.LoadLibrary(str(lib_path)) + return lib.func diff --git a/tests/packages/cmake_generated/src/cmake_generated/namespace1/static_data b/tests/packages/cmake_generated/src/cmake_generated/namespace1/static_data new file mode 100644 index 00000000..2e114a4b --- /dev/null +++ b/tests/packages/cmake_generated/src/cmake_generated/namespace1/static_data @@ -0,0 +1 @@ +static value in namespace package diff --git a/tests/packages/cmake_generated/src/cmake_generated/nested1/__init__.py b/tests/packages/cmake_generated/src/cmake_generated/nested1/__init__.py new file mode 100644 index 00000000..71741dd5 --- /dev/null +++ b/tests/packages/cmake_generated/src/cmake_generated/nested1/__init__.py @@ -0,0 +1,5 @@ +from importlib.resources import read_text + + +def get_static_data(): + return read_text("cmake_generated.nested1", "static_data").rstrip() diff --git a/tests/packages/cmake_generated/src/cmake_generated/nested1/generated.py.in b/tests/packages/cmake_generated/src/cmake_generated/nested1/generated.py.in new file mode 100644 index 00000000..5d11f6bf --- /dev/null +++ b/tests/packages/cmake_generated/src/cmake_generated/nested1/generated.py.in @@ -0,0 +1,43 @@ +"""Try to open both generated and static files from various parts of the package.""" +import sys +from importlib.resources import files, read_text +from types import ModuleType + +from .. import __version__ + +try: + from .. import nested2 +except ImportError as e: + nested2 = None + import_error = e.msg +else: + import_error = None + +def cmake_generated_static_data(): + return read_text("cmake_generated", "static_data").rstrip() + +def cmake_generated_nested_static_data(): + return files("cmake_generated.nested1").joinpath("static_data").read_text().rstrip() + +def cmake_generated_namespace_static_data(): + # Note that `files("cmake_generated.namespace1")` doesn't work. + # Ref https://github.com/python/importlib_resources/issues/262 + return files("cmake_generated").joinpath("namespace1/static_data").read_text().rstrip() + +def get_configured_data(): + return files("cmake_generated").joinpath("configured_file").read_text().rstrip() + +def cmake_generated_namespace_generated_data(): + if sys.version_info[0:2] == (3, 9): + return files("cmake_generated").joinpath("namespace1/generated_data").read_text().rstrip() + else: + # (except in Python 3.9) read_text is able to handle a namespace subpackage directly, though `files()` is not. + return read_text("cmake_generated.namespace1","generated_data").rstrip() + +nested_data = "success" + +def nested2_check(): + if import_error is not None: + return import_error + assert isinstance(nested2, ModuleType) + return "success" diff --git a/tests/packages/cmake_generated/src/cmake_generated/nested1/static_data b/tests/packages/cmake_generated/src/cmake_generated/nested1/static_data new file mode 100644 index 00000000..bdc4abc1 --- /dev/null +++ b/tests/packages/cmake_generated/src/cmake_generated/nested1/static_data @@ -0,0 +1 @@ +static value in subpackage 1 diff --git a/tests/packages/cmake_generated/src/cmake_generated/nested2/__init__.py b/tests/packages/cmake_generated/src/cmake_generated/nested2/__init__.py new file mode 100644 index 00000000..a21566ce --- /dev/null +++ b/tests/packages/cmake_generated/src/cmake_generated/nested2/__init__.py @@ -0,0 +1,5 @@ +def nested1_generated_check(): + # noinspection PyUnresolvedReferences + from ..nested1.generated import nested_data # type: ignore[import-not-found] + + return nested_data diff --git a/tests/packages/cmake_generated/src/cmake_generated/pkg.cpp b/tests/packages/cmake_generated/src/cmake_generated/pkg.cpp new file mode 100644 index 00000000..7a3cd84d --- /dev/null +++ b/tests/packages/cmake_generated/src/cmake_generated/pkg.cpp @@ -0,0 +1,3 @@ +#include "pkg_export.h" + +extern "C" PKG_EXPORT int func() {return 42;} diff --git a/tests/packages/cmake_generated/src/cmake_generated/static_data b/tests/packages/cmake_generated/src/cmake_generated/static_data new file mode 100644 index 00000000..6d040033 --- /dev/null +++ b/tests/packages/cmake_generated/src/cmake_generated/static_data @@ -0,0 +1 @@ +static value in top-level package diff --git a/tests/test_editable_generated.py b/tests/test_editable_generated.py new file mode 100644 index 00000000..fa6348c3 --- /dev/null +++ b/tests/test_editable_generated.py @@ -0,0 +1,330 @@ +"""Test regular and editable installs with generated files. + +Illustrate the supported and correct ways to use generated files +(other than traditional compiled extension modules). + +Check a variety of scenarios in which package files (modules or data) are +not present in the source tree to confirm that we can find resources as expected, +either by ``import`` or with tools such as `importlib.resources.files()`. +Note that `importlib.resources.files()` requires an argument before Python 3.12. +""" + +from __future__ import annotations + +import sys + +import pytest + + +@pytest.mark.compile +@pytest.mark.configure +@pytest.mark.integration +@pytest.mark.parametrize("package", ["cmake_generated"], indirect=True) +@pytest.mark.usefixtures("package") +@pytest.mark.usefixtures("isolate") +@pytest.mark.parametrize( + "editable", + [ + pytest.param(None, id="not_editable"), + "redirect", + pytest.param( + "inplace", + marks=pytest.mark.skip( + "`inplace` editable mode requires build tree layout to match package layout." + ), + ), + ], + indirect=True, +) +@pytest.mark.skipif( + sys.version_info < (3, 9), + reason="importlib.resources.files is introduced in Python 3.9", +) +def test_basic_data_resources(editable, isolated): + isolated.install( + "-v", + *editable.flags, + ".", + ) + value = isolated.execute( + "import cmake_generated; print(cmake_generated.get_static_data())" + ) + assert value == "static value in top-level package" + + value = isolated.execute( + "import cmake_generated.nested1; print(cmake_generated.nested1.get_static_data())" + ) + assert value == "static value in subpackage 1" + + value = isolated.execute( + "import cmake_generated; print(cmake_generated.get_namespace_static_data())" + ) + assert value == "static value in namespace package" + + +@pytest.mark.compile +@pytest.mark.configure +@pytest.mark.integration +@pytest.mark.parametrize("package", ["cmake_generated"], indirect=True) +@pytest.mark.usefixtures("package") +@pytest.mark.usefixtures("isolate") +@pytest.mark.parametrize( + "editable", + [ + pytest.param(None, id="not_editable"), + pytest.param( + "redirect", + marks=pytest.mark.xfail(reason="Redirection requires #808", strict=True), + ), + pytest.param( + "inplace", + marks=pytest.mark.skip( + "`inplace` editable mode requires build tree layout to match package layout." + ), + ), + ], + indirect=True, +) +@pytest.mark.skipif( + sys.version_info < (3, 9), + reason="importlib.resources.files is introduced in Python 3.9", +) +def test_configure_time_generated_data(editable, isolated): + isolated.install( + "-v", + *editable.flags, + ".", + ) + value = isolated.execute( + "import cmake_generated; print(cmake_generated.get_configured_data())" + ) + assert value == "value written by cmake file generation" + + +@pytest.mark.compile +@pytest.mark.configure +@pytest.mark.integration +@pytest.mark.parametrize("package", ["cmake_generated"], indirect=True) +@pytest.mark.usefixtures("package") +@pytest.mark.usefixtures("isolate") +@pytest.mark.parametrize( + "editable", + [ + pytest.param(None, id="not_editable"), + pytest.param( + "redirect", + marks=pytest.mark.xfail(reason="Redirection requires #808", strict=True), + ), + pytest.param( + "inplace", + marks=pytest.mark.skip( + "`inplace` editable mode requires build tree layout to match package layout." + ), + ), + ], + indirect=True, +) +@pytest.mark.skipif( + sys.version_info < (3, 9), + reason="importlib.resources.files is introduced in Python 3.9", +) +def test_build_time_generated_data(editable, isolated): + isolated.install( + "-v", + *editable.flags, + ".", + ) + value = isolated.execute( + "import cmake_generated; print(cmake_generated.get_namespace_generated_data())" + ) + assert value == "value written by cmake custom_command" + + +@pytest.mark.compile +@pytest.mark.configure +@pytest.mark.integration +@pytest.mark.parametrize("package", ["cmake_generated"], indirect=True) +@pytest.mark.usefixtures("package") +@pytest.mark.usefixtures("isolate") +@pytest.mark.parametrize( + "editable", + [ + pytest.param(None, id="not_editable"), + pytest.param( + "redirect", + marks=pytest.mark.xfail(reason="Redirection requires #808", strict=True), + ), + pytest.param( + "inplace", + marks=pytest.mark.skip( + "`inplace` editable mode requires build tree layout to match package layout." + ), + ), + ], + indirect=True, +) +@pytest.mark.skipif( + sys.version_info < (3, 9), + reason="importlib.resources.files is introduced in Python 3.9", +) +def test_compiled_ctypes_resource(editable, isolated): + isolated.install( + "-v", + *editable.flags, + ".", + ) + value = isolated.execute( + "import cmake_generated; print(cmake_generated.ctypes_function()())" + ) + assert value == str(42) + + +@pytest.mark.compile +@pytest.mark.configure +@pytest.mark.integration +@pytest.mark.parametrize("package", ["cmake_generated"], indirect=True) +@pytest.mark.usefixtures("package") +@pytest.mark.usefixtures("isolate") +@pytest.mark.parametrize( + "editable", + [ + pytest.param(None, id="not_editable"), + "redirect", + pytest.param( + "inplace", + marks=pytest.mark.skip( + "`inplace` editable mode requires build tree layout to match package layout." + ), + ), + ], + indirect=True, +) +@pytest.mark.skipif( + sys.version_info < (3, 9), + reason="importlib.resources.files is introduced in Python 3.9", +) +def test_configure_time_generated_module(editable, isolated): + isolated.install( + "-v", + *editable.flags, + ".", + ) + # Check that a generated module can access and be accessed by all parts of the package + + value = isolated.execute( + "from cmake_generated.nested1.generated import __version__; print(__version__)" + ) + assert value == "1.2.3" + + value = isolated.execute( + "from cmake_generated.nested1.generated import cmake_generated_static_data; print(cmake_generated_static_data())" + ) + assert value == "static value in top-level package" + + value = isolated.execute( + "from cmake_generated.nested1.generated import cmake_generated_nested_static_data; print(cmake_generated_nested_static_data())" + ) + assert value == "static value in subpackage 1" + + value = isolated.execute( + "from cmake_generated.nested1.generated import cmake_generated_namespace_static_data; print(cmake_generated_namespace_static_data())" + ) + assert value == "static value in namespace package" + + value = isolated.execute( + "from cmake_generated.nested1.generated import nested_data; print(nested_data)" + ) + assert value == "success" + value = isolated.execute( + "from cmake_generated.nested1.generated import nested2_check; print(nested2_check())" + ) + assert value == "success" + value = isolated.execute( + "from cmake_generated.nested2 import nested1_generated_check; print(nested1_generated_check())" + ) + assert value == "success" + + +@pytest.mark.compile +@pytest.mark.configure +@pytest.mark.integration +@pytest.mark.parametrize("package", ["cmake_generated"], indirect=True) +@pytest.mark.usefixtures("package") +@pytest.mark.usefixtures("isolate") +@pytest.mark.parametrize( + "editable", + [ + pytest.param(None, id="not_editable"), + pytest.param( + "redirect", + marks=pytest.mark.xfail( + sys.version_info[0:2] == (3, 9), + reason="Python 3.9 redirect doesn't work for generated data in a namespace subpackage from a generated module.", + ), + ), + pytest.param( + "inplace", + marks=pytest.mark.skip( + "`inplace` editable mode requires build tree layout to match package layout." + ), + ), + ], + indirect=True, +) +@pytest.mark.skipif( + sys.version_info < (3, 9), + reason="importlib.resources.files is introduced in Python 3.9", +) +def test_generated_module_generated_data(editable, isolated): + isolated.install( + "-v", + *editable.flags, + ".", + ) + # Check that a generated module can access generated data in a namespace subpackage + + value = isolated.execute( + "from cmake_generated.nested1.generated import cmake_generated_namespace_generated_data; print(cmake_generated_namespace_generated_data())" + ) + assert value == "value written by cmake custom_command" + + +@pytest.mark.compile +@pytest.mark.configure +@pytest.mark.integration +@pytest.mark.parametrize("package", ["cmake_generated"], indirect=True) +@pytest.mark.usefixtures("package") +@pytest.mark.usefixtures("isolate") +@pytest.mark.parametrize( + "editable", + [ + pytest.param(None, id="not_editable"), + "redirect", + pytest.param( + "inplace", + marks=pytest.mark.skip( + "`inplace` editable mode requires build tree layout to match package layout." + ), + ), + ], + indirect=True, +) +@pytest.mark.skipif( + sys.version_info < (3, 9), + reason="importlib.resources.files is introduced in Python 3.9", +) +def test_build_time_generated_module(editable, isolated): + isolated.install( + "-v", + *editable.flags, + ".", + ) + # Check generated _version module + attr_value = isolated.execute( + "import cmake_generated; print(cmake_generated.__version__)" + ) + assert attr_value == "1.2.3" + metadata_value = isolated.execute( + "import importlib.metadata; print(importlib.metadata.version('cmake_generated'))" + ) + assert metadata_value == "1.2.3"