From 92aab5ce67905ffe7dea3a6b1781e1871979916b Mon Sep 17 00:00:00 2001 From: Cristian Le Date: Tue, 1 Apr 2025 18:51:25 +0200 Subject: [PATCH] Add dynamic script generator Generate script entry points from installed CMake targets Signed-off-by: Cristian Le --- README.md | 3 + src/scikit_build_core/build/_scripts.py | 164 +++++++++++++++++- src/scikit_build_core/build/wheel.py | 19 +- .../settings/skbuild_model.py | 23 +++ 4 files changed, 207 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f28300e8f..0250a2888 100644 --- a/README.md +++ b/README.md @@ -337,6 +337,9 @@ build-dir = "" # Immediately fail the build. This is only useful in overrides. fail = false +# EXPERIMENTAL: Additional ``project.scripts`` entry-points. +scripts = {} + ``` diff --git a/src/scikit_build_core/build/_scripts.py b/src/scikit_build_core/build/_scripts.py index c254471bf..ec9892589 100644 --- a/src/scikit_build_core/build/_scripts.py +++ b/src/scikit_build_core/build/_scripts.py @@ -1,13 +1,20 @@ from __future__ import annotations import contextlib +import os.path import re from typing import TYPE_CHECKING +from .._logging import logger + if TYPE_CHECKING: from pathlib import Path -__all__ = ["process_script_dir"] + from .._vendor.pyproject_metadata import StandardMetadata + from ..builder.builder import Builder + from ..settings.skbuild_model import ScikitBuildSettings + +__all__ = ["add_dynamic_scripts", "process_script_dir"] def __dir__() -> list[str]: @@ -15,6 +22,7 @@ def __dir__() -> list[str]: SHEBANG_PATTERN = re.compile(r"^#!.*(?:python|pythonw|pypy)[0-9.]*([ \t].*)?$") +SCRIPT_PATTERN = re.compile(r"^(?P[\w\\.]+)(?::(?P\w+))?$") def process_script_dir(script_dir: Path) -> None: @@ -33,3 +41,157 @@ def process_script_dir(script_dir: Path) -> None: if content: with item.open("w", encoding="utf-8") as f: f.writelines(content) + + +WRAPPER = """\ +import os.path +import subprocess +import sys + +DIR = os.path.abspath(os.path.dirname(__file__)) + +def {function}() -> None: + exe_path = os.path.join(DIR, "{rel_exe_path}") + sys.exit(subprocess.call([str(exe_path), *sys.argv[2:]])) + +""" + +WRAPPER_MODULE_EXTRA = """\ + +if __name__ == "__main__": + {function}() + +""" + + +def add_dynamic_scripts( + *, + metadata: StandardMetadata, + settings: ScikitBuildSettings, + builder: Builder | None, + wheel_dirs: dict[str, Path], + install_dir: Path, + create_files: bool = False, +) -> None: + """ + Add and create the dynamic ``project.scripts`` from the ``tool.scikit-build.scripts``. + """ + targetlib = "platlib" if "platlib" in wheel_dirs else "purelib" + targetlib_dir = wheel_dirs[targetlib] + if create_files and builder: + if not (file_api := builder.config.file_api): + logger.warning("CMake file-api was not generated.") + return + build_type = builder.config.build_type + assert file_api.reply.codemodel_v2 + configuration = next( + conf + for conf in file_api.reply.codemodel_v2.configurations + if conf.name == build_type + ) + else: + configuration = None + for script, script_info in settings.scripts.items(): + if script_info.target is None: + # Early exit if we do not need to create a wrapper + metadata.scripts[script] = script_info.path + continue + python_file_match = SCRIPT_PATTERN.match(script_info.path) + if not python_file_match: + logger.warning( + "scripts.{script}.path is not a valid entrypoint", + script=script, + ) + continue + function = python_file_match.group("function") or "main" + pkg_mod = python_file_match.group("module").rsplit(".", maxsplit=1) + # Modify the metadata early and exit if we do not need to create the wrapper content + # Make sure to include the default function if it was not provided + metadata.scripts[script] = f"{'.'.join(pkg_mod)}:{function}" + if not create_files or not configuration: + continue + # Create the file contents from here on + # Try to find the python file + if len(pkg_mod) == 1: + pkg = None + mod = pkg_mod[0] + else: + pkg, mod = pkg_mod + + pkg_dir = targetlib_dir + if pkg: + # Make sure all intermediate package files are populated + for pkg_part in pkg.split("."): + pkg_dir = pkg_dir / pkg_part + pkg_file = pkg_dir / "__init__.py" + pkg_dir.mkdir(exist_ok=True) + pkg_file.touch(exist_ok=True) + # Check if module is a module or a package + if (pkg_dir / mod).is_dir(): + mod_file = pkg_dir / mod / "__init__.py" + else: + mod_file = pkg_dir / f"{mod}.py" + if mod_file.exists(): + logger.warning( + "Wrapper file already exists: {mod_file}", + mod_file=mod_file, + ) + continue + # Get the requested target + for target in configuration.targets: + if target.type != "EXECUTABLE": + continue + if target.name == script_info.target: + break + else: + logger.warning( + "Could not find target: {target}", + target=script_info.target, + ) + continue + # Find the installed artifact + if len(target.artifacts) > 1: + logger.warning( + "Multiple target artifacts is not supported: {artifacts}", + artifacts=target.artifacts, + ) + continue + if not target.install: + logger.warning( + "Target is not installed: {target}", + target=target.name, + ) + continue + target_artifact = target.artifacts[0].path + for dest in target.install.destinations: + install_path = dest.path + if install_path.is_absolute(): + try: + install_path = install_path.relative_to(targetlib_dir) + except ValueError: + continue + else: + install_path = install_dir / install_path + install_artifact = targetlib_dir / install_path / target_artifact.name + if not install_artifact.exists(): + logger.warning( + "Did not find installed executable: {artifact}", + artifact=install_artifact, + ) + continue + break + else: + logger.warning( + "Did not find installed files for target: {target}", + target=target.name, + ) + continue + # Generate the content + content = WRAPPER.format( + function=function, + rel_exe_path=os.path.relpath(install_artifact, mod_file.parent), + ) + if script_info.as_module: + content += WRAPPER_MODULE_EXTRA.format(function=function) + with mod_file.open("w", encoding="utf-8") as f: + f.write(content) diff --git a/src/scikit_build_core/build/wheel.py b/src/scikit_build_core/build/wheel.py index 47e223bb6..7ff57914b 100644 --- a/src/scikit_build_core/build/wheel.py +++ b/src/scikit_build_core/build/wheel.py @@ -26,7 +26,7 @@ from ._pathutil import ( packages_to_file_mapping, ) -from ._scripts import process_script_dir +from ._scripts import add_dynamic_scripts, process_script_dir from ._wheelfile import WheelMetadata, WheelWriter from .generate import generate_file_contents from .metadata import get_standard_metadata @@ -371,6 +371,14 @@ def _build_wheel_impl_impl( ), wheel_dirs["metadata"], ) + add_dynamic_scripts( + metadata=wheel.metadata, + settings=settings, + builder=None, + wheel_dirs=wheel_dirs, + install_dir=install_dir, + create_files=False, + ) dist_info_contents = wheel.dist_info_contents() dist_info = Path(metadata_directory) / f"{wheel.name_ver}.dist-info" dist_info.mkdir(parents=True) @@ -487,6 +495,15 @@ def _build_wheel_impl_impl( ), wheel_dirs["metadata"], ) as wheel: + add_dynamic_scripts( + metadata=wheel.metadata, + settings=settings, + builder=builder if cmake else None, + wheel_dirs=wheel_dirs, + install_dir=install_dir, + create_files=True, + ) + wheel.build(wheel_dirs, exclude=settings.wheel.exclude) str_pkgs = ( diff --git a/src/scikit_build_core/settings/skbuild_model.py b/src/scikit_build_core/settings/skbuild_model.py index adc92aaea..ded575eab 100644 --- a/src/scikit_build_core/settings/skbuild_model.py +++ b/src/scikit_build_core/settings/skbuild_model.py @@ -351,6 +351,24 @@ class MessagesSettings: """ +@dataclasses.dataclass +class ScriptSettings: + path: str + """ + Entry-point path. + """ + + target: Optional[str] = None + """ + CMake executable target being wrapped. + """ + + as_module: bool = False + """ + Expose the wrapper file as a module. + """ + + @dataclasses.dataclass class ScikitBuildSettings: cmake: CMakeSettings = dataclasses.field(default_factory=CMakeSettings) @@ -396,3 +414,8 @@ class ScikitBuildSettings: """ Immediately fail the build. This is only useful in overrides. """ + + scripts: Dict[str, ScriptSettings] = dataclasses.field(default_factory=dict) + """ + EXPERIMENTAL: Additional ``project.scripts`` entry-points. + """