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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,18 @@ build-dir = ""
# Immediately fail the build. This is only useful in overrides.
fail = false

# Entry-point name.
scripts[].name = ""

# Entry-point path.
scripts[].path = ""

# CMake executable target being wrapped.
scripts[].target = ""

# Expose the wrapper file as a module.
scripts[].as-module = false

```

<!-- [[[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 @@
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 in settings.scripts:
if script.target is None:

Check warning on line 95 in src/scikit_build_core/build/_scripts.py

View check run for this annotation

Codecov / codecov/patch

src/scikit_build_core/build/_scripts.py#L95

Added line #L95 was not covered by tests
# Early exit if we do not need to create a wrapper
metadata.scripts[script.name] = script.path
continue
python_file_match = SCRIPT_PATTERN.match(script.path)
if not python_file_match:
logger.warning(

Check warning on line 101 in src/scikit_build_core/build/_scripts.py

View check run for this annotation

Codecov / codecov/patch

src/scikit_build_core/build/_scripts.py#L97-L101

Added lines #L97 - L101 were not covered by tests
"scripts.{script}.path is not a valid entrypoint",
script=script.name,
)
continue
function = python_file_match.group("function") or "main"
pkg_mod = python_file_match.group("module").rsplit(".", maxsplit=1)

Check warning on line 107 in src/scikit_build_core/build/_scripts.py

View check run for this annotation

Codecov / codecov/patch

src/scikit_build_core/build/_scripts.py#L105-L107

Added lines #L105 - L107 were not covered by tests
# 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.name] = f"{'.'.join(pkg_mod)}:{function}"
if not create_files or not configuration:
continue

Check warning on line 112 in src/scikit_build_core/build/_scripts.py

View check run for this annotation

Codecov / codecov/patch

src/scikit_build_core/build/_scripts.py#L110-L112

Added lines #L110 - L112 were not covered by tests
# Create the file contents from here on
# Try to find the python file
if len(pkg_mod) == 1:
pkg = None
mod = pkg_mod[0]

Check warning on line 117 in src/scikit_build_core/build/_scripts.py

View check run for this annotation

Codecov / codecov/patch

src/scikit_build_core/build/_scripts.py#L115-L117

Added lines #L115 - L117 were not covered by tests
else:
pkg, mod = pkg_mod

Check warning on line 119 in src/scikit_build_core/build/_scripts.py

View check run for this annotation

Codecov / codecov/patch

src/scikit_build_core/build/_scripts.py#L119

Added line #L119 was not covered by tests

pkg_dir = targetlib_dir
if pkg:

Check warning on line 122 in src/scikit_build_core/build/_scripts.py

View check run for this annotation

Codecov / codecov/patch

src/scikit_build_core/build/_scripts.py#L121-L122

Added lines #L121 - L122 were not covered by tests
# 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 warning on line 128 in src/scikit_build_core/build/_scripts.py

View check run for this annotation

Codecov / codecov/patch

src/scikit_build_core/build/_scripts.py#L124-L128

Added lines #L124 - L128 were not covered by tests
# Check if module is a module or a package
if (pkg_dir / mod).is_dir():
mod_file = pkg_dir / mod / "__init__.py"

Check warning on line 131 in src/scikit_build_core/build/_scripts.py

View check run for this annotation

Codecov / codecov/patch

src/scikit_build_core/build/_scripts.py#L130-L131

Added lines #L130 - L131 were not covered by tests
else:
mod_file = pkg_dir / f"{mod}.py"
if mod_file.exists():
logger.warning(

Check warning on line 135 in src/scikit_build_core/build/_scripts.py

View check run for this annotation

Codecov / codecov/patch

src/scikit_build_core/build/_scripts.py#L133-L135

Added lines #L133 - L135 were not covered by tests
"Wrapper file already exists: {mod_file}",
mod_file=mod_file,
)
continue

Check warning on line 139 in src/scikit_build_core/build/_scripts.py

View check run for this annotation

Codecov / codecov/patch

src/scikit_build_core/build/_scripts.py#L139

Added line #L139 was not covered by tests
# Get the requested target
for target in configuration.targets:
if target.type != "EXECUTABLE":
continue
if target.name == script.target:
break

Check warning on line 145 in src/scikit_build_core/build/_scripts.py

View check run for this annotation

Codecov / codecov/patch

src/scikit_build_core/build/_scripts.py#L141-L145

Added lines #L141 - L145 were not covered by tests
else:
logger.warning(

Check warning on line 147 in src/scikit_build_core/build/_scripts.py

View check run for this annotation

Codecov / codecov/patch

src/scikit_build_core/build/_scripts.py#L147

Added line #L147 was not covered by tests
"Could not find target: {target}",
target=script.target,
)
continue

Check warning on line 151 in src/scikit_build_core/build/_scripts.py

View check run for this annotation

Codecov / codecov/patch

src/scikit_build_core/build/_scripts.py#L151

Added line #L151 was not covered by tests
# Find the installed artifact
if len(target.artifacts) > 1:
logger.warning(

Check warning on line 154 in src/scikit_build_core/build/_scripts.py

View check run for this annotation

Codecov / codecov/patch

src/scikit_build_core/build/_scripts.py#L153-L154

Added lines #L153 - L154 were not covered by tests
"Multiple target artifacts is not supported: {artifacts}",
artifacts=target.artifacts,
)
continue
if not target.install:
logger.warning(

Check warning on line 160 in src/scikit_build_core/build/_scripts.py

View check run for this annotation

Codecov / codecov/patch

src/scikit_build_core/build/_scripts.py#L158-L160

Added lines #L158 - L160 were not covered by tests
"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

Check warning on line 172 in src/scikit_build_core/build/_scripts.py

View check run for this annotation

Codecov / codecov/patch

src/scikit_build_core/build/_scripts.py#L164-L172

Added lines #L164 - L172 were not covered by tests
else:
install_path = install_dir / install_path
install_artifact = targetlib_dir / install_path / target_artifact.name
if not install_artifact.exists():
logger.warning(

Check warning on line 177 in src/scikit_build_core/build/_scripts.py

View check run for this annotation

Codecov / codecov/patch

src/scikit_build_core/build/_scripts.py#L174-L177

Added lines #L174 - L177 were not covered by tests
"Did not find installed executable: {artifact}",
artifact=install_artifact,
)
continue
break

Check warning on line 182 in src/scikit_build_core/build/_scripts.py

View check run for this annotation

Codecov / codecov/patch

src/scikit_build_core/build/_scripts.py#L181-L182

Added lines #L181 - L182 were not covered by tests
else:
logger.warning(

Check warning on line 184 in src/scikit_build_core/build/_scripts.py

View check run for this annotation

Codecov / codecov/patch

src/scikit_build_core/build/_scripts.py#L184

Added line #L184 was not covered by tests
"Did not find installed files for target: {target}",
target=target.name,
)
continue

Check warning on line 188 in src/scikit_build_core/build/_scripts.py

View check run for this annotation

Codecov / codecov/patch

src/scikit_build_core/build/_scripts.py#L188

Added line #L188 was not covered by tests
# Generate the content
content = WRAPPER.format(

Check warning on line 190 in src/scikit_build_core/build/_scripts.py

View check run for this annotation

Codecov / codecov/patch

src/scikit_build_core/build/_scripts.py#L190

Added line #L190 was not covered by tests
function=function,
rel_exe_path=os.path.relpath(install_artifact, mod_file.parent),
)
if script.as_module:
content += WRAPPER_MODULE_EXTRA.format(function=function)
with mod_file.open("w", encoding="utf-8") as f:
f.write(content)

Check warning on line 197 in src/scikit_build_core/build/_scripts.py

View check run for this annotation

Codecov / codecov/patch

src/scikit_build_core/build/_scripts.py#L194-L197

Added lines #L194 - L197 were not covered by tests
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
34 changes: 34 additions & 0 deletions src/scikit_build_core/resources/scikit-build.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,37 @@
"default": false,
"description": "Immediately fail the build. This is only useful in overrides."
},
"scripts": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"name",
"path"
],
"properties": {
"name": {
"type": "string",
"description": "Entry-point name."
},
"path": {
"type": "string",
"description": "Entry-point path."
},
"target": {
"type": "string",
"description": "CMake executable target being wrapped."
},
"as-module": {
"type": "boolean",
"default": false,
"description": "Expose the wrapper file as a module."
}
}
},
"description": "EXPERIMENTAL: Additional ``project.scripts`` entry-points."
},
"overrides": {
"type": "array",
"description": "A list of overrides to apply to the settings, based on the `if` selector.",
Expand Down Expand Up @@ -649,6 +680,9 @@
},
"fail": {
"$ref": "#/properties/fail"
},
"scripts": {
"$ref": "#/properties/scripts"
}
}
}
Expand Down
28 changes: 28 additions & 0 deletions src/scikit_build_core/settings/skbuild_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,29 @@ class MessagesSettings:
"""


@dataclasses.dataclass
class ScriptSettings:
name: str
"""
Entry-point name.
"""

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 @@ -418,3 +441,8 @@ class ScikitBuildSettings:
"""
Immediately fail the build. This is only useful in overrides.
"""

scripts: List[ScriptSettings] = dataclasses.field(default_factory=list)
"""
EXPERIMENTAL: Additional ``project.scripts`` entry-points.
"""
Loading