Skip to content

Commit 92aab5c

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 92aab5c

File tree

4 files changed

+207
-2
lines changed

4 files changed

+207
-2
lines changed

Diff for: 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]]] -->

Diff for: src/scikit_build_core/build/_scripts.py

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

Diff for: src/scikit_build_core/build/wheel.py

+18-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
@@ -371,6 +371,14 @@ def _build_wheel_impl_impl(
371371
),
372372
wheel_dirs["metadata"],
373373
)
374+
add_dynamic_scripts(
375+
metadata=wheel.metadata,
376+
settings=settings,
377+
builder=None,
378+
wheel_dirs=wheel_dirs,
379+
install_dir=install_dir,
380+
create_files=False,
381+
)
374382
dist_info_contents = wheel.dist_info_contents()
375383
dist_info = Path(metadata_directory) / f"{wheel.name_ver}.dist-info"
376384
dist_info.mkdir(parents=True)
@@ -487,6 +495,15 @@ def _build_wheel_impl_impl(
487495
),
488496
wheel_dirs["metadata"],
489497
) as wheel:
498+
add_dynamic_scripts(
499+
metadata=wheel.metadata,
500+
settings=settings,
501+
builder=builder if cmake else None,
502+
wheel_dirs=wheel_dirs,
503+
install_dir=install_dir,
504+
create_files=True,
505+
)
506+
490507
wheel.build(wheel_dirs, exclude=settings.wheel.exclude)
491508

492509
str_pkgs = (

Diff for: 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)