Skip to content

Commit 15950db

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

File tree

5 files changed

+255
-2
lines changed

5 files changed

+255
-2
lines changed

README.md

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

340+
# Entry-point name.
341+
scripts[].name = ""
342+
343+
# Entry-point path.
344+
scripts[].path = ""
345+
346+
# CMake executable target being wrapped.
347+
scripts[].target = ""
348+
349+
# Expose the wrapper file as a module.
350+
scripts[].as-module = false
351+
340352
```
341353

342354
<!-- [[[end]]] -->

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 in settings.scripts:
95+
if script.target is None:
96+
# Early exit if we do not need to create a wrapper
97+
metadata.scripts[script.name] = script.path
98+
continue
99+
python_file_match = SCRIPT_PATTERN.match(script.path)
100+
if not python_file_match:
101+
logger.warning(
102+
"scripts.{script}.path is not a valid entrypoint",
103+
script=script.name,
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.name] = 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.target:
145+
break
146+
else:
147+
logger.warning(
148+
"Could not find target: {target}",
149+
target=script.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.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)

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 = (

src/scikit_build_core/resources/scikit-build.schema.json

+34
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,37 @@
494494
"default": false,
495495
"description": "Immediately fail the build. This is only useful in overrides."
496496
},
497+
"scripts": {
498+
"type": "array",
499+
"items": {
500+
"type": "object",
501+
"additionalProperties": false,
502+
"required": [
503+
"name",
504+
"path"
505+
],
506+
"properties": {
507+
"name": {
508+
"type": "string",
509+
"description": "Entry-point name."
510+
},
511+
"path": {
512+
"type": "string",
513+
"description": "Entry-point path."
514+
},
515+
"target": {
516+
"type": "string",
517+
"description": "CMake executable target being wrapped."
518+
},
519+
"as-module": {
520+
"type": "boolean",
521+
"default": false,
522+
"description": "Expose the wrapper file as a module."
523+
}
524+
}
525+
},
526+
"description": "EXPERIMENTAL: Additional ``project.scripts`` entry-points."
527+
},
497528
"overrides": {
498529
"type": "array",
499530
"description": "A list of overrides to apply to the settings, based on the `if` selector.",
@@ -649,6 +680,9 @@
649680
},
650681
"fail": {
651682
"$ref": "#/properties/fail"
683+
},
684+
"scripts": {
685+
"$ref": "#/properties/scripts"
652686
}
653687
}
654688
}

src/scikit_build_core/settings/skbuild_model.py

+28
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,29 @@ class MessagesSettings:
368368
"""
369369

370370

371+
@dataclasses.dataclass
372+
class ScriptSettings:
373+
name: str
374+
"""
375+
Entry-point name.
376+
"""
377+
378+
path: str
379+
"""
380+
Entry-point path.
381+
"""
382+
383+
target: Optional[str] = None
384+
"""
385+
CMake executable target being wrapped.
386+
"""
387+
388+
as_module: bool = False
389+
"""
390+
Expose the wrapper file as a module.
391+
"""
392+
393+
371394
@dataclasses.dataclass
372395
class ScikitBuildSettings:
373396
cmake: CMakeSettings = dataclasses.field(default_factory=CMakeSettings)
@@ -418,3 +441,8 @@ class ScikitBuildSettings:
418441
"""
419442
Immediately fail the build. This is only useful in overrides.
420443
"""
444+
445+
scripts: List[ScriptSettings] = dataclasses.field(default_factory=list)
446+
"""
447+
EXPERIMENTAL: Additional ``project.scripts`` entry-points.
448+
"""

0 commit comments

Comments
 (0)