Skip to content

Commit ad738b2

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

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/__init__.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
from abc import ABC, abstractmethod
1315
from pathlib import Path
@@ -99,6 +101,28 @@ def __init_subclass__(cls) -> None:
99101
if cls._platform:
100102
WheelRepairer._platform_repairers[cls._platform] = cls
101103

104+
@functools.cached_property
105+
def bundled_libs_path(self) -> Path:
106+
"""Staging path for the bundled library directory."""
107+
return Path(self.wheel_dirs["platlib"]) / f"{self.name}.libs"
108+
109+
@functools.cached_property
110+
def bundle_external(self) -> list[re.Pattern[str]]:
111+
"""List of compiled regex patterns of the library files to bundle."""
112+
patterns = []
113+
for pattern_str in self.settings.wheel.repair.bundle_external:
114+
try:
115+
pattern = re.compile(pattern_str)
116+
except re.error as exc:
117+
logger.warning(
118+
'Skipping "{pattern}" as an invalid pattern',
119+
pattern=pattern_str,
120+
)
121+
logger.debug(str(exc))
122+
continue
123+
patterns.append(pattern)
124+
return patterns
125+
102126
@functools.cached_property
103127
def configuration(self) -> Configuration:
104128
"""Current file-api configuration."""
@@ -204,8 +228,91 @@ def get_library_dependencies(self, target: Target) -> list[Target]:
204228
dependencies.append(dep_target)
205229
return dependencies
206230

231+
def try_bundle(self, external_lib: Path) -> Path | None:
232+
"""
233+
Try to bundle an external library file.
234+
235+
:param external_lib: path to actual external library to bundle
236+
:returns: ``None`` if the library is not bundled, otherwise the path
237+
to the bundled file
238+
"""
239+
assert external_lib.is_absolute()
240+
if not external_lib.exists():
241+
logger.warning(
242+
"External library file does not exist: {external_lib}",
243+
external_lib=external_lib,
244+
)
245+
return None
246+
if external_lib.is_dir():
247+
logger.debug(
248+
"Skip bundling directory: {external_lib}",
249+
external_lib=external_lib,
250+
)
251+
return None
252+
libname = external_lib.name
253+
bundled_lib = self.bundled_libs_path / libname
254+
if bundled_lib.exists():
255+
# If we have already bundled the library no need to do it again
256+
return bundled_lib
257+
for pattern in self.bundle_external:
258+
if pattern.match(libname):
259+
logger.debug(
260+
'Bundling library matching "{pattern}": {external_lib}',
261+
external_lib=external_lib,
262+
pattern=pattern.pattern,
263+
)
264+
shutil.copy(external_lib, bundled_lib)
265+
return bundled_lib
266+
logger.debug(
267+
"Skip bundling: {external_lib}",
268+
external_lib=external_lib,
269+
)
270+
return None
271+
272+
def get_package_lib_path(
273+
self, original_lib: Path, relative_to: Path | None = None
274+
) -> Path | None:
275+
"""
276+
Get the file path of a library to be used.
277+
278+
This checks for the settings in ``settings.wheel.repair`` returning either:
279+
- If the dependency should be skipped: ``None``
280+
- If ``original_lib`` is a library in another wheel: a relative path to the original library file
281+
- If ``original_lib`` is a library to be bundled: a relative path to the bundled library file
282+
283+
The relative paths are relative to ``relative_to`` or the ``platlib`` wheel path if not passed.
284+
"""
285+
if not original_lib.is_absolute() or not original_lib.exists():
286+
logger.debug(
287+
"Could not handle {original_lib} because it is either relative or does not exist.",
288+
original_lib=original_lib,
289+
)
290+
return None
291+
if self.path_is_in_site_packages(original_lib):
292+
# The other library is in another wheel
293+
if not self.settings.wheel.repair.cross_wheel:
294+
logger.debug(
295+
"Skipping {original_lib} because it is in another wheel.",
296+
original_lib=original_lib,
297+
)
298+
return None
299+
final_lib = original_lib
300+
# Otherwise, check if we need to bundle the external library
301+
elif not self.bundle_external or not (
302+
final_lib := self.try_bundle(original_lib) # type: ignore[assignment]
303+
):
304+
logger.debug(
305+
"Skipping {original_lib} because it is not being bundled.",
306+
original_lib=original_lib,
307+
)
308+
return None
309+
return self.path_relative_site_packages(final_lib, relative_to=relative_to)
310+
207311
def repair_wheel(self) -> None:
208312
"""Repair the current wheel."""
313+
if self.bundle_external:
314+
self.bundled_libs_path.mkdir(exist_ok=True)
315+
209316
for target in self.targets:
210317
if self._filter_targets:
211318
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
@@ -91,31 +91,40 @@ def get_package_rpaths(self, target: Target, install_path: Path) -> list[str]:
9191
# Skip empty rpaths. Most likely will have on at the end
9292
continue
9393
rpath = Path(rpath_str)
94-
if not self.path_is_in_site_packages(rpath):
95-
# Skip any paths that cannot be handled. We do not check for paths in
96-
# the build directory, it should be covered by `get_dependency_rpaths`
94+
# Relative paths should be covered by `get_dependency_rpaths` so we skip them.
95+
if not rpath.is_absolute():
96+
continue
97+
# Get the relative rpath to either the cross-wheel or bundled file
98+
if not (
99+
rpath := self.get_package_lib_path( # type: ignore[assignment]
100+
rpath, relative_to=install_path
101+
)
102+
):
97103
continue
98-
rpath = self.path_relative_site_packages(rpath, install_path)
99104
new_rpath_str = f"{self._origin_symbol}/{rpath}"
100105
rpaths.append(new_rpath_str)
101106
continue
102107
# The remaining case should be a path
103108
try:
104109
# TODO: how to best catch if a string is a valid path?
105110
rpath = Path(link_part)
106-
if not rpath.is_absolute():
107-
# Relative paths should be handled by `get_dependency_rpaths`
108-
continue
109-
rpath = self.path_relative_site_packages(rpath, install_path)
110-
new_rpath_str = f"{self._origin_symbol}/{rpath.parent}"
111-
rpaths.append(new_rpath_str)
112111
except Exception as exc:
113112
logger.warning(
114113
"Could not parse link-library as a path: {fragment}\nexc = {exc}",
115114
fragment=link_command.fragment,
116115
exc=exc,
117116
)
118117
continue
118+
if not rpath.is_absolute():
119+
# Relative paths should be covered by `get_dependency_rpaths` so we skip them.
120+
continue
121+
# Get the relative rpath to either the cross-wheel or bundled file
122+
if not (
123+
rpath := self.get_package_lib_path(rpath, relative_to=install_path) # type: ignore[assignment]
124+
):
125+
continue
126+
new_rpath_str = f"{self._origin_symbol}/{rpath.parent}"
127+
rpaths.append(new_rpath_str)
119128
return rpaths
120129

121130
def get_existing_rpaths(self, artifact: Path) -> list[str]:
@@ -168,10 +177,7 @@ def patch_target(self, target: Target) -> None:
168177
dependency_rpaths = self.get_dependency_rpaths(target, install_path)
169178
else:
170179
dependency_rpaths = []
171-
if self.settings.wheel.repair.cross_wheel:
172-
package_rpaths = self.get_package_rpaths(target, install_path)
173-
else:
174-
package_rpaths = []
180+
package_rpaths = self.get_package_rpaths(target, install_path)
175181
existing_rpaths = self.get_existing_rpaths(artifact_path)
176182
logger.debug(
177183
"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 . 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)
@@ -180,32 +195,27 @@ def get_package_dll(self, target: Target) -> list[Path]:
180195
try:
181196
# TODO: how to best catch if a string is a valid path?
182197
lib_path = Path(link_command.fragment)
183-
if not lib_path.is_absolute():
184-
# If the link_command is a space-separated list of libraries, this should be skipped
185-
logger.debug(
186-
"Skipping non-absolute-path library: {fragment}",
187-
fragment=link_command.fragment,
188-
)
189-
continue
190-
try:
191-
self.path_relative_site_packages(lib_path)
192-
except ValueError:
193-
logger.debug(
194-
"Skipping library outside site-package path: {lib_path}",
195-
lib_path=lib_path,
196-
)
197-
continue
198-
dll_path = self.get_dll_path_from_lib(lib_path)
199-
if not dll_path:
200-
continue
201-
dll_paths.append(dll_path.parent)
202198
except Exception as exc:
203199
logger.warning(
204200
"Could not parse link-library as a path: {fragment}\nexc = {exc}",
205201
fragment=link_command.fragment,
206202
exc=exc,
207203
)
208204
continue
205+
if not lib_path.is_absolute():
206+
# If the link_command is a space-separated list of libraries, this should be skipped
207+
logger.debug(
208+
"Skipping non-absolute-path library: {fragment}",
209+
fragment=link_command.fragment,
210+
)
211+
continue
212+
# TODO: Handle this better when revisiting `try_bundle`
213+
if not self.get_package_lib_path(lib_path):
214+
continue
215+
dll_path = self.get_dll_path_from_lib(lib_path)
216+
if not dll_path:
217+
continue
218+
dll_paths.append(dll_path.parent)
209219
return dll_paths
210220

211221
def patch_target(self, target: Target) -> None:
@@ -214,10 +224,7 @@ def patch_target(self, target: Target) -> None:
214224
dependency_dlls = self.get_dependency_dll(target)
215225
else:
216226
dependency_dlls = []
217-
if self.settings.wheel.repair.cross_wheel:
218-
package_dlls = self.get_package_dll(target)
219-
else:
220-
package_dlls = []
227+
package_dlls = self.get_package_dll(target)
221228

222229
if not package_dlls and not dependency_dlls:
223230
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)