Skip to content

Commit 4a1f9c0

Browse files
committed
Add dynamic script generator
Generate script entry points from installed CMake targets Signed-off-by: Cristian Le <[email protected]>
1 parent aed38e0 commit 4a1f9c0

File tree

4 files changed

+193
-2
lines changed

4 files changed

+193
-2
lines changed

README.md

+3
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,9 @@ build-dir = ""
337337
# Immediately fail the build. This is only useful in overrides.
338338
fail = false
339339

340+
# EXPERIMENTAL: Additional ``project.scripts`` entry-points.
341+
scripts = {}
342+
340343
```
341344

342345
<!-- [[[end]]] -->

src/scikit_build_core/build/_scripts.py

+158-1
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,28 @@
11
from __future__ import annotations
22

33
import contextlib
4+
import os.path
45
import re
56
from typing import TYPE_CHECKING
67

8+
from .._logging import logger
9+
710
if TYPE_CHECKING:
811
from pathlib import Path
912

10-
__all__ = ["process_script_dir"]
13+
from .._vendor.pyproject_metadata import StandardMetadata
14+
from ..builder.builder import Builder
15+
from ..settings.skbuild_model import ScikitBuildSettings
16+
17+
__all__ = ["add_dynamic_scripts", "process_script_dir"]
1118

1219

1320
def __dir__() -> list[str]:
1421
return __all__
1522

1623

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

1927

2028
def process_script_dir(script_dir: Path) -> None:
@@ -33,3 +41,152 @@ def process_script_dir(script_dir: Path) -> None:
3341
if content:
3442
with item.open("w", encoding="utf-8") as f:
3543
f.writelines(content)
44+
45+
46+
WRAPPER = """
47+
import subprocess
48+
import sys
49+
50+
DIR = os.path.abspath(os.path.dirname(__file__))
51+
52+
def {function}() -> None:
53+
exe_path = os.path.join(DIR, "{rel_exe_path}")
54+
sys.exit(subprocess.call([str(exe_path), *sys.argv[2:]]))
55+
56+
"""
57+
58+
WRAPPER_MODULE_EXTRA = """
59+
60+
if __name__ == "__main__"
61+
{function}()
62+
63+
"""
64+
65+
66+
def add_dynamic_scripts(
67+
metadata: StandardMetadata,
68+
settings: ScikitBuildSettings,
69+
builder: Builder | None,
70+
wheel_dirs: dict[str, Path],
71+
install_dir: Path,
72+
) -> None:
73+
"""
74+
Add and create the dynamic ``project.scripts`` from the ``tool.scikit-build.scripts``.
75+
"""
76+
targetlib = "platlib" if "platlib" in wheel_dirs else "purelib"
77+
targetlib_dir = wheel_dirs[targetlib]
78+
if builder:
79+
if not (file_api := builder.config.file_api):
80+
logger.warning("CMake file-api was not generated.")
81+
return
82+
build_type = builder.config.build_type
83+
assert file_api.reply.codemodel_v2
84+
configuration = next(
85+
conf
86+
for conf in file_api.reply.codemodel_v2.configurations
87+
if conf.name == build_type
88+
)
89+
else:
90+
configuration = None
91+
for script, script_info in settings.scripts.items():
92+
if script_info.target is None:
93+
# Early exit if we do not need to create a wrapper
94+
metadata.scripts[script] = script_info.path
95+
continue
96+
if not configuration:
97+
continue
98+
python_file_match = SCRIPT_PATTERN.match(script_info.path)
99+
if not python_file_match:
100+
logger.warning(
101+
"scripts.{script}.path is not a valid entrypoint",
102+
script=script,
103+
)
104+
continue
105+
function = python_file_match.group("function") or "main"
106+
# Try to find the python file
107+
pkg_mod = python_file_match.group("module").rsplit(".", maxsplit=1)
108+
if len(pkg_mod) == 1:
109+
pkg = None
110+
mod = pkg_mod[0]
111+
else:
112+
pkg, mod = pkg_mod
113+
114+
pkg_dir = targetlib_dir
115+
if pkg:
116+
# Make sure all intermediate package files are populated
117+
for pkg_part in pkg.split("."):
118+
pkg_dir = pkg_dir / pkg_part
119+
pkg_file = pkg_dir / "__init__.py"
120+
pkg_dir.mkdir(exist_ok=True)
121+
pkg_file.touch(exist_ok=True)
122+
# Check if module is a module or a package
123+
if (pkg_dir / mod).is_dir():
124+
mod_file = pkg_dir / mod / "__init__.py"
125+
else:
126+
mod_file = pkg_dir / f"{mod}.py"
127+
if mod_file.exists():
128+
logger.warning(
129+
"Wrapper file already exists: {mod_file}",
130+
mod_file=mod_file,
131+
)
132+
continue
133+
# Get the requested target
134+
for target in configuration.targets:
135+
if target.type != "EXECUTABLE":
136+
continue
137+
if target.name == script_info.target:
138+
break
139+
else:
140+
logger.warning(
141+
"Could not find target: {target}",
142+
target=script_info.target,
143+
)
144+
continue
145+
# Find the installed artifact
146+
if len(target.artifacts) > 1:
147+
logger.warning(
148+
"Multiple target artifacts is not supported: {artifacts}",
149+
artifacts=target.artifacts,
150+
)
151+
continue
152+
if not target.install:
153+
logger.warning(
154+
"Target is not installed: {target}",
155+
target=target.name,
156+
)
157+
continue
158+
target_artifact = target.artifacts[0].path
159+
for dest in target.install.destinations:
160+
install_path = dest.path
161+
if install_path.is_absolute():
162+
try:
163+
install_path = install_path.relative_to(targetlib_dir)
164+
except ValueError:
165+
continue
166+
else:
167+
install_path = install_dir / install_path
168+
install_artifact = targetlib_dir / install_path / target_artifact.name
169+
if not install_artifact.exists():
170+
logger.warning(
171+
"Did not find installed executable: {artifact}",
172+
artifact=install_artifact,
173+
)
174+
continue
175+
break
176+
else:
177+
logger.warning(
178+
"Did not find installed files for target: {target}",
179+
target=target.name,
180+
)
181+
continue
182+
# Generate the content
183+
content = WRAPPER.format(
184+
function=function,
185+
rel_exe_path=os.path.relpath(install_artifact, mod_file.parent),
186+
)
187+
if script_info.as_module:
188+
content += WRAPPER_MODULE_EXTRA.format(function=function)
189+
with mod_file.open("w", encoding="utf-8") as f:
190+
f.write(content)
191+
# Finally register this as a script
192+
metadata.scripts[script] = script_info.path

src/scikit_build_core/build/wheel.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from ._pathutil import (
2727
packages_to_file_mapping,
2828
)
29-
from ._scripts import process_script_dir
29+
from ._scripts import add_dynamic_scripts, process_script_dir
3030
from ._wheelfile import WheelMetadata, WheelWriter
3131
from .generate import generate_file_contents
3232
from .metadata import get_standard_metadata
@@ -477,6 +477,14 @@ def _build_wheel_impl_impl(
477477

478478
process_script_dir(wheel_dirs["scripts"])
479479

480+
add_dynamic_scripts(
481+
metadata=metadata,
482+
settings=settings,
483+
builder=builder if cmake else None,
484+
wheel_dirs=wheel_dirs,
485+
install_dir=install_dir,
486+
)
487+
480488
with WheelWriter(
481489
metadata,
482490
Path(wheel_directory),

src/scikit_build_core/settings/skbuild_model.py

+23
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,24 @@ class MessagesSettings:
351351
"""
352352

353353

354+
@dataclasses.dataclass
355+
class ScriptSettings:
356+
path: str
357+
"""
358+
Entry-point path.
359+
"""
360+
361+
target: Optional[str] = None
362+
"""
363+
CMake executable target being wrapped.
364+
"""
365+
366+
as_module: bool = False
367+
"""
368+
Expose the wrapper file as a module.
369+
"""
370+
371+
354372
@dataclasses.dataclass
355373
class ScikitBuildSettings:
356374
cmake: CMakeSettings = dataclasses.field(default_factory=CMakeSettings)
@@ -396,3 +414,8 @@ class ScikitBuildSettings:
396414
"""
397415
Immediately fail the build. This is only useful in overrides.
398416
"""
417+
418+
scripts: Dict[str, ScriptSettings] = dataclasses.field(default_factory=dict)
419+
"""
420+
EXPERIMENTAL: Additional ``project.scripts`` entry-points.
421+
"""

0 commit comments

Comments
 (0)