diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cc1c6cd..c0d457a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -56,6 +56,7 @@ repos: additional_dependencies: - pytest - scikit-build-core + - importlib-resources - repo: https://github.com/codespell-project/codespell rev: "v2.3.0" diff --git a/README.md b/README.md index 56cb70a..07b7ee5 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,31 @@ +## Vendoring + +You can vendor FindCython and/or UseCython into your package, as well. This +avoids requiring a dependency at build time and protects you against changes in +this package, at the expense of requiring manual re-vendoring to get bugfixes +and/or improvements. This mechanism is also ideal if you want to support direct +builds, outside of scikit-build-core. + +You should make a CMake helper directory, such as `cmake`. Add this to your +`CMakeLists.txt` like this: + +```cmake +list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake") +``` + +Then, you can vendor our files into that folder: + +```bash +pipx run cython-cmake vendor cmake +``` + +If you want to just vendor one of the two files, use `--member FindCython` or +`--member UseCython`. You can rerun this command to revendor. The directory must +already exist. + [actions-badge]: https://github.com/scikit-build/cython-cmake/workflows/CI/badge.svg [actions-link]: https://github.com/scikit-build/cython-cmake/actions diff --git a/pyproject.toml b/pyproject.toml index f1caf73..c847468 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,11 +30,16 @@ classifiers = [ "Typing :: Typed", ] dynamic = ["version"] -dependencies = [] +dependencies = [ + "importlib_resources; python_version<'3.9'" +] [project.entry-points."cmake.module"] any = "cython_cmake.cmake" +[project.scripts] +cython-cmake = "cython_cmake.__main__:main" + [project.optional-dependencies] test = [ "pytest >=6", @@ -152,7 +157,9 @@ similarities.ignore-imports = "yes" messages_control.disable = [ "design", "fixme", + "invalid-name", "line-too-long", + "missing-class-docstring", "missing-module-docstring", "wrong-import-position", ] diff --git a/src/cython_cmake/__main__.py b/src/cython_cmake/__main__.py new file mode 100644 index 0000000..0daadf1 --- /dev/null +++ b/src/cython_cmake/__main__.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import argparse +import enum +import functools +import operator +from collections.abc import Sequence +from pathlib import Path +from typing import Any + +from ._version import version as __version__ +from .vendor import Members, vendorize + +__all__ = ["main"] + + +def __dir__() -> list[str]: + return __all__ + + +class FlagAction(argparse.Action): + def __init__(self, *args: Any, **kwargs: Any) -> None: + enum_type = kwargs.pop("type", None) + if enum_type is None: + msg = "enum type is required" + raise ValueError(msg) + if not issubclass(enum_type, enum.Flag): + msg = "type must be an Flag when using FlagAction" + raise TypeError(msg) + + kwargs.setdefault("choices", tuple(e.name for e in enum_type)) + + super().__init__(*args, **kwargs) + + self._enum = enum_type + + def __call__( + self, + parser: argparse.ArgumentParser, # noqa: ARG002 + namespace: argparse.Namespace, + values: str | Sequence[Any] | None, + option_string: str | None = None, # noqa: ARG002 + ) -> None: + if not isinstance(values, list): + values = [values] + flags = functools.reduce(operator.or_, (self._enum[e] for e in values)) + setattr(namespace, self.dest, flags) + + +def main() -> None: + """ + Entry point. + """ + parser = argparse.ArgumentParser( + prog="cython_cmake", description="CMake Cython module helper" + ) + parser.add_argument( + "--version", action="version", version=f"%(prog)s {__version__}" + ) + subparser = parser.add_subparsers(required=True) + vendor_parser = subparser.add_parser("vendor", help="Vendor CMake helpers") + vendor_parser.add_argument( + "target", type=Path, help="Directory to vendor the CMake helpers" + ) + vendor_parser.add_argument( + "--members", + type=Members, + nargs="*", + action=FlagAction, + default=functools.reduce(operator.or_, list(Members)), + help="Members to vendor, defaults to all", + ) + args = parser.parse_args() + vendorize(args.target, args.members) + + +if __name__ == "__main__": + main() diff --git a/src/cython_cmake/vendor.py b/src/cython_cmake/vendor.py new file mode 100644 index 0000000..04ff2d5 --- /dev/null +++ b/src/cython_cmake/vendor.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import enum +import sys +from pathlib import Path + +if sys.version_info < (3, 9): + from importlib_resources import files +else: + from importlib.resources import files + +__all__ = ["vendorize", "Members"] + + +def __dir__() -> list[str]: + return __all__ + + +class Members(enum.Flag): + FindCython = enum.auto() + UseCython = enum.auto() + + +def vendorize( + target: Path, members: Members = Members.FindCython | Members.UseCython +) -> None: + """ + Vendorize files into a directory. Directory must exist. + """ + if not target.is_dir(): + msg = f"Target directory {target} does not exist" + raise AssertionError(msg) + + cmake_dir = files("cython_cmake") / "cmake" + if Members.FindCython in members: + find = cmake_dir / "FindCython.cmake" + find_target = target / "FindCython.cmake" + find_target.write_text(find.read_text(encoding="utf-8"), encoding="utf-8") + + if Members.UseCython in members: + use = cmake_dir / "UseCython.cmake" + use_target = target / "UseCython.cmake" + use_target.write_text(use.read_text(encoding="utf-8"), encoding="utf-8") diff --git a/tests/test_vendorize.py b/tests/test_vendorize.py new file mode 100644 index 0000000..bdcdeee --- /dev/null +++ b/tests/test_vendorize.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +from cython_cmake.__main__ import main + +DIR = Path(__file__).parent.resolve() +FIND_CYTHON = DIR.parent.joinpath("src/cython_cmake/cmake/FindCython.cmake").read_text() +USE_CYTHON = DIR.parent.joinpath("src/cython_cmake/cmake/UseCython.cmake").read_text() + + +def test_copy_files(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + path = tmp_path / "copy_all" + path.mkdir() + + monkeypatch.setattr(sys, "argv", [sys.executable, "vendor", str(path)]) + main() + + assert path.joinpath("FindCython.cmake").read_text() == FIND_CYTHON + assert path.joinpath("UseCython.cmake").read_text() == USE_CYTHON + + +def test_copy_only_find(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + path = tmp_path / "copy_find" + path.mkdir() + + monkeypatch.setattr( + sys, "argv", [sys.executable, "vendor", str(path), "--member", "FindCython"] + ) + main() + + assert path.joinpath("FindCython.cmake").read_text() == FIND_CYTHON + assert not path.joinpath("UseCython.cmake").exists()