Skip to content

Commit e9a5532

Browse files
committed
Add option to bundle external libraries
Signed-off-by: Cristian Le <[email protected]>
1 parent 2c3bc27 commit e9a5532

File tree

7 files changed

+187
-37
lines changed

7 files changed

+187
-37
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,9 @@ wheel.repair.in-wheel = true
229229
# Patch the dynamic links to libraries in other wheels.
230230
wheel.repair.cross-wheel = false
231231

232+
# A list of external library files that will be bundled in the wheel.
233+
wheel.repair.bundle-external = []
234+
232235
# If CMake is less than this value, backport a copy of FindPython.
233236
backport.find-python = "3.26.1"
234237

docs/reference/configs.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,17 @@ print(mk_skbuild_docs())
576576

577577
## wheel.repair
578578

579+
```{eval-rst}
580+
.. confval:: wheel.repair.bundle-external
581+
:type: ``list[str]``
582+
583+
A list of external library files that will be bundled in the wheel.
584+
585+
Each entry is treated as a regex pattern, and only the filenames are considered
586+
for the match. The libraries are taken from the CMake dependency during the CMake
587+
build. The bundled libraries are installed under ``site-packages/${name}.libs``
588+
```
589+
579590
```{eval-rst}
580591
.. confval:: wheel.repair.cross-wheel
581592
:type: ``bool``

src/scikit_build_core/repair_wheel/base.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import functools
99
import os
1010
import platform
11+
import re
12+
import shutil
1113
import sysconfig
1214
import typing
1315
from abc import ABC, abstractmethod
@@ -90,6 +92,28 @@ def __init_subclass__(cls) -> None:
9092
if cls._platform:
9193
WheelRepairer._platform_repairers[cls._platform] = cls
9294

95+
@functools.cached_property
96+
def bundled_libs_path(self) -> Path:
97+
"""Staging path for the bundled library directory."""
98+
return Path(self.wheel_dirs["platlib"]) / f"{self.name}.libs"
99+
100+
@functools.cached_property
101+
def bundle_external(self) -> list[re.Pattern[str]]:
102+
"""List of compiled regex patterns of the library files to bundle."""
103+
patterns = []
104+
for pattern_str in self.settings.wheel.repair.bundle_external:
105+
try:
106+
pattern = re.compile(pattern_str)
107+
except re.error as exc:
108+
logger.warning(
109+
'Skipping "{pattern}" as an invalid pattern',
110+
pattern=pattern_str,
111+
)
112+
logger.debug(str(exc))
113+
continue
114+
patterns.append(pattern)
115+
return patterns
116+
93117
@functools.cached_property
94118
def configuration(self) -> Configuration:
95119
"""Current file-api configuration."""
@@ -195,8 +219,91 @@ def get_library_dependencies(self, target: Target) -> list[Target]:
195219
dependencies.append(dep_target)
196220
return dependencies
197221

222+
def try_bundle(self, external_lib: Path) -> Path | None:
223+
"""
224+
Try to bundle an external library file.
225+
226+
:param external_lib: path to actual external library to bundle
227+
:returns: ``None`` if the library is not bundled, otherwise the path
228+
to the bundled file
229+
"""
230+
assert external_lib.is_absolute()
231+
if not external_lib.exists():
232+
logger.warning(
233+
"External library file does not exist: {external_lib}",
234+
external_lib=external_lib,
235+
)
236+
return None
237+
if external_lib.is_dir():
238+
logger.debug(
239+
"Skip bundling directory: {external_lib}",
240+
external_lib=external_lib,
241+
)
242+
return None
243+
libname = external_lib.name
244+
bundled_lib = self.bundled_libs_path / libname
245+
if bundled_lib.exists():
246+
# If we have already bundled the library no need to do it again
247+
return bundled_lib
248+
for pattern in self.bundle_external:
249+
if pattern.match(libname):
250+
logger.debug(
251+
'Bundling library matching "{pattern}": {external_lib}',
252+
external_lib=external_lib,
253+
pattern=pattern.pattern,
254+
)
255+
shutil.copy(external_lib, bundled_lib)
256+
return bundled_lib
257+
logger.debug(
258+
"Skip bundling: {external_lib}",
259+
external_lib=external_lib,
260+
)
261+
return None
262+
263+
def get_package_lib_path(
264+
self, original_lib: Path, relative_to: Path | None = None
265+
) -> Path | None:
266+
"""
267+
Get the file path of a library to be used.
268+
269+
This checks for the settings in ``settings.wheel.repair`` returning either:
270+
- If the dependency should be skipped: ``None``
271+
- If ``original_lib`` is a library in another wheel: a relative path to the original library file
272+
- If ``original_lib`` is a library to be bundled: a relative path to the bundled library file
273+
274+
The relative paths are relative to ``relative_to`` or the ``platlib`` wheel path if not passed.
275+
"""
276+
if not original_lib.is_absolute() or not original_lib.exists():
277+
logger.debug(
278+
"Could not handle {original_lib} because it is either relative or does not exist.",
279+
original_lib=original_lib,
280+
)
281+
return None
282+
if self.path_is_in_site_packages(original_lib):
283+
# The other library is in another wheel
284+
if not self.settings.wheel.repair.cross_wheel:
285+
logger.debug(
286+
"Skipping {original_lib} because it is in another wheel.",
287+
original_lib=original_lib,
288+
)
289+
return None
290+
final_lib = original_lib
291+
# Otherwise, check if we need to bundle the external library
292+
elif not self.bundle_external or not (
293+
final_lib := self.try_bundle(original_lib) # type: ignore[assignment]
294+
):
295+
logger.debug(
296+
"Skipping {original_lib} because it is not being bundled.",
297+
original_lib=original_lib,
298+
)
299+
return None
300+
return self.path_relative_site_packages(final_lib, relative_to=relative_to)
301+
198302
def repair_wheel(self) -> None:
199303
"""Repair the current wheel."""
304+
if self.bundle_external:
305+
self.bundled_libs_path.mkdir(exist_ok=True)
306+
200307
for target in self.targets:
201308
if self._filter_targets:
202309
if target.type == "STATIC_LIBRARY":

src/scikit_build_core/repair_wheel/rpath.py

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -94,31 +94,40 @@ def get_package_rpaths(self, target: Target, install_path: Path) -> list[str]:
9494
# Skip empty rpaths. Most likely will have on at the end
9595
continue
9696
rpath = Path(rpath_str)
97-
if not self.path_is_in_site_packages(rpath):
98-
# Skip any paths that cannot be handled. We do not check for paths in
99-
# the build directory, it should be covered by `get_dependency_rpaths`
97+
# Relative paths should be covered by `get_dependency_rpaths` so we skip them.
98+
if not rpath.is_absolute():
99+
continue
100+
# Get the relative rpath to either the cross-wheel or bundled file
101+
if not (
102+
rpath := self.get_package_lib_path( # type: ignore[assignment]
103+
rpath, relative_to=install_path
104+
)
105+
):
100106
continue
101-
rpath = self.path_relative_site_packages(rpath, install_path)
102107
new_rpath_str = f"{self._origin_symbol}/{rpath}"
103108
rpaths.append(new_rpath_str)
104109
continue
105110
# The remaining case should be a path
106111
try:
107112
# TODO: how to best catch if a string is a valid path?
108113
rpath = Path(link_part)
109-
if not rpath.is_absolute():
110-
# Relative paths should be handled by `get_dependency_rpaths`
111-
continue
112-
rpath = self.path_relative_site_packages(rpath, install_path)
113-
new_rpath_str = f"{self._origin_symbol}/{rpath.parent}"
114-
rpaths.append(new_rpath_str)
115114
except Exception as exc:
116115
logger.warning(
117116
"Could not parse link-library as a path: {fragment}\nexc = {exc}",
118117
fragment=link_command.fragment,
119118
exc=exc,
120119
)
121120
continue
121+
if not rpath.is_absolute():
122+
# Relative paths should be covered by `get_dependency_rpaths` so we skip them.
123+
continue
124+
# Get the relative rpath to either the cross-wheel or bundled file
125+
if not (
126+
rpath := self.get_package_lib_path(rpath, relative_to=install_path) # type: ignore[assignment]
127+
):
128+
continue
129+
new_rpath_str = f"{self._origin_symbol}/{rpath.parent}"
130+
rpaths.append(new_rpath_str)
122131
return rpaths
123132

124133
def get_existing_rpaths(self, artifact: Path) -> list[str]:
@@ -171,10 +180,7 @@ def patch_target(self, target: Target) -> None:
171180
dependency_rpaths = self.get_dependency_rpaths(target, install_path)
172181
else:
173182
dependency_rpaths = []
174-
if self.settings.wheel.repair.cross_wheel:
175-
package_rpaths = self.get_package_rpaths(target, install_path)
176-
else:
177-
package_rpaths = []
183+
package_rpaths = self.get_package_rpaths(target, install_path)
178184
existing_rpaths = self.get_existing_rpaths(artifact_path)
179185
logger.debug(
180186
"Patching rpaths for artifact {artifact}\n"

src/scikit_build_core/repair_wheel/windows.py

Lines changed: 30 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from __future__ import annotations
66

77
import dataclasses
8+
import functools
89
import os.path
910
import textwrap
1011
from pathlib import Path
@@ -14,6 +15,8 @@
1415
from .base import WheelRepairer, _get_buildenv_platlib
1516

1617
if TYPE_CHECKING:
18+
import re
19+
1720
from ..file_api.model.codemodel import Target
1821

1922
__all__ = ["WindowsWheelRepairer"]
@@ -52,8 +55,20 @@ def _skbuild_patch_dll_dir():
5255
dll_dirs: set[Path] = dataclasses.field(default_factory=set, init=False)
5356
"""All dll paths used relative to ``platlib``."""
5457

58+
@functools.cached_property
59+
def bundle_external(self) -> list[re.Pattern[str]]:
60+
if self.settings.wheel.repair.bundle_external:
61+
logger.warning("Bundling Windows dll files is not supported yet.")
62+
return []
63+
64+
def try_bundle(self, external_lib: Path) -> Path | None:
65+
# Everything should be gated by `bundle_external` so this should not be called
66+
# TODO: figure out a better way to find the corresponding dll file of the linked lib file
67+
raise NotImplementedError
68+
5569
def get_dll_path_from_lib(self, lib_path: Path) -> Path | None:
5670
"""Guess the dll path from lib path."""
71+
# TODO: rework the logic of this to work with `try_bundle`
5772
dll_path = None
5873
platlib = Path(_get_buildenv_platlib())
5974
lib_path = lib_path.relative_to(platlib)
@@ -181,32 +196,27 @@ def get_package_dll(self, target: Target) -> list[Path]:
181196
try:
182197
# TODO: how to best catch if a string is a valid path?
183198
lib_path = Path(link_command.fragment)
184-
if not lib_path.is_absolute():
185-
# If the link_command is a space-separated list of libraries, this should be skipped
186-
logger.debug(
187-
"Skipping non-absolute-path library: {fragment}",
188-
fragment=link_command.fragment,
189-
)
190-
continue
191-
try:
192-
self.path_relative_site_packages(lib_path)
193-
except ValueError:
194-
logger.debug(
195-
"Skipping library outside site-package path: {lib_path}",
196-
lib_path=lib_path,
197-
)
198-
continue
199-
dll_path = self.get_dll_path_from_lib(lib_path)
200-
if not dll_path:
201-
continue
202-
dll_paths.append(dll_path.parent)
203199
except Exception as exc:
204200
logger.warning(
205201
"Could not parse link-library as a path: {fragment}\nexc = {exc}",
206202
fragment=link_command.fragment,
207203
exc=exc,
208204
)
209205
continue
206+
if not lib_path.is_absolute():
207+
# If the link_command is a space-separated list of libraries, this should be skipped
208+
logger.debug(
209+
"Skipping non-absolute-path library: {fragment}",
210+
fragment=link_command.fragment,
211+
)
212+
continue
213+
# TODO: Handle this better when revisiting `try_bundle`
214+
if not self.get_package_lib_path(lib_path):
215+
continue
216+
dll_path = self.get_dll_path_from_lib(lib_path)
217+
if not dll_path:
218+
continue
219+
dll_paths.append(dll_path.parent)
210220
return dll_paths
211221

212222
def patch_target(self, target: Target) -> None:
@@ -215,10 +225,7 @@ def patch_target(self, target: Target) -> None:
215225
dependency_dlls = self.get_dependency_dll(target)
216226
else:
217227
dependency_dlls = []
218-
if self.settings.wheel.repair.cross_wheel:
219-
package_dlls = self.get_package_dll(target)
220-
else:
221-
package_dlls = []
228+
package_dlls = self.get_package_dll(target)
222229

223230
if not package_dlls and not dependency_dlls:
224231
logger.warning(

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,13 @@
258258
"type": "boolean",
259259
"default": false,
260260
"description": "Patch the dynamic links to libraries in other wheels."
261+
},
262+
"bundle-external": {
263+
"type": "array",
264+
"items": {
265+
"type": "string"
266+
},
267+
"description": "A list of external library files that will be bundled in the wheel."
261268
}
262269
},
263270
"description": "Wheel repair options"

src/scikit_build_core/settings/skbuild_model.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,15 @@ class WheelRepair:
247247
not required.
248248
"""
249249

250+
bundle_external: List[str] = dataclasses.field(default_factory=list)
251+
"""
252+
A list of external library files that will be bundled in the wheel.
253+
254+
Each entry is treated as a regex pattern, and only the filenames are considered
255+
for the match. The libraries are taken from the CMake dependency during the CMake
256+
build. The bundled libraries are installed under ``site-packages/${name}.libs``
257+
"""
258+
250259

251260
@dataclasses.dataclass
252261
class WheelSettings:

0 commit comments

Comments
 (0)