Skip to content

feat: Add dynamic script generator #1036

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}

```

<!-- [[[end]]] -->
Expand Down
164 changes: 163 additions & 1 deletion src/scikit_build_core/build/_scripts.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,28 @@
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]:
return __all__


SHEBANG_PATTERN = re.compile(r"^#!.*(?:python|pythonw|pypy)[0-9.]*([ \t].*)?$")
SCRIPT_PATTERN = re.compile(r"^(?P<module>[\w\\.]+)(?::(?P<function>\w+))?$")


def process_script_dir(script_dir: Path) -> None:
Expand All @@ -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)
19 changes: 18 additions & 1 deletion src/scikit_build_core/build/wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 = (
Expand Down
23 changes: 23 additions & 0 deletions src/scikit_build_core/settings/skbuild_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
"""
Loading