Skip to content

Commit da7c156

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

File tree

6 files changed

+178
-37
lines changed

6 files changed

+178
-37
lines changed

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,12 @@ wheel.repair.in-wheel = true
268268
# to each other and strict manylinux compliance is not required.
269269
wheel.repair.cross-wheel = false
270270

271+
# A list of external library files that will be bundled in the wheel. Each entry
272+
# is treated as a regex pattern, and only the filenames are considered for the
273+
# match. The libraries are taken from the CMake dependency. The bundled
274+
# libraries are under `site-packages/${name}.libs`
275+
wheel.repair.bundle-external = []
276+
271277
# If CMake is less than this value, backport a copy of FindPython. Set to 0
272278
# disable this, or the empty string.
273279
backport.find-python = "3.26.1"

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 importlib import import_module
@@ -94,6 +96,28 @@ def __init_subclass__(cls) -> None:
9496
if cls._platform:
9597
WheelRepairer._platform_repairers[cls._platform] = cls
9698

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

226+
def try_bundle(self, external_lib: Path) -> Path | None:
227+
"""
228+
Try to bundle an external library file.
229+
230+
:param external_lib: path to actual external library to bundle
231+
:returns: ``None`` if the library is not bundled, otherwise the path
232+
to the bundled file
233+
"""
234+
assert external_lib.is_absolute()
235+
if not external_lib.exists():
236+
logger.warning(
237+
"External library file does not exist: {external_lib}",
238+
external_lib=external_lib,
239+
)
240+
return None
241+
if external_lib.is_dir():
242+
logger.debug(
243+
"Skip bundling directory: {external_lib}",
244+
external_lib=external_lib,
245+
)
246+
return None
247+
libname = external_lib.name
248+
bundled_lib = self.bundled_libs_path / libname
249+
if bundled_lib.exists():
250+
# If we have already bundled the library no need to do it again
251+
return bundled_lib
252+
for pattern in self.bundle_external:
253+
if pattern.match(libname):
254+
logger.debug(
255+
'Bundling library matching "{pattern}": {external_lib}',
256+
external_lib=external_lib,
257+
pattern=pattern.pattern,
258+
)
259+
shutil.copy(external_lib, bundled_lib)
260+
return bundled_lib
261+
logger.debug(
262+
"Skip bundling: {external_lib}",
263+
external_lib=external_lib,
264+
)
265+
return None
266+
267+
def get_package_lib_path(
268+
self, original_lib: Path, relative_to: Path | None = None
269+
) -> Path | None:
270+
"""
271+
Get the file path of a library to be used.
272+
273+
This checks for the settings in ``settings.wheel.repair`` returning either:
274+
- If the dependency should be skipped: ``None``
275+
- If ``original_lib`` is a library in another wheel: a relative path to the original library file
276+
- If ``original_lib`` is a library to be bundled: a relative path to the bundled library file
277+
278+
The relative paths are relative to ``relative_to`` or the ``platlib`` wheel path if not passed.
279+
"""
280+
if not original_lib.is_absolute() or not original_lib.exists():
281+
logger.debug(
282+
"Could not handle {original_lib} because it is either relative or does not exist.",
283+
original_lib=original_lib,
284+
)
285+
return None
286+
if self.path_is_in_site_packages(original_lib):
287+
# The other library is in another wheel
288+
if not self.settings.wheel.repair.cross_wheel:
289+
logger.debug(
290+
"Skipping {original_lib} because it is in another wheel.",
291+
original_lib=original_lib,
292+
)
293+
return None
294+
final_lib = original_lib
295+
# Otherwise, check if we need to bundle the external library
296+
elif not self.bundle_external or not (
297+
final_lib := self.try_bundle(original_lib) # type: ignore[assignment]
298+
):
299+
logger.debug(
300+
"Skipping {original_lib} because it is not being bundled.",
301+
original_lib=original_lib,
302+
)
303+
return None
304+
return self.path_relative_site_packages(final_lib, relative_to=relative_to)
305+
202306
def repair_wheel(self) -> None:
203307
"""Repair the current wheel."""
308+
if self.bundle_external:
309+
self.bundled_libs_path.mkdir(exist_ok=True)
310+
204311
for target in self.targets:
205312
if self._filter_targets:
206313
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. BEWARE that this may result in incompatible wheels. Use this only if the wheels are strongly linked to each other and strict manylinux compliance is not required."
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. Each entry is treated as a regex pattern, and only the filenames are considered for the match. The libraries are taken from the CMake dependency. The bundled libraries are under `site-packages/${name}.libs`"
261268
}
262269
},
263270
"description": "Wheel repair options"

src/scikit_build_core/settings/skbuild_model.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,14 @@ class WheelRepair:
192192
not required.
193193
"""
194194

195+
bundle_external: List[str] = dataclasses.field(default_factory=list)
196+
"""
197+
A list of external library files that will be bundled in the wheel. Each entry
198+
is treated as a regex pattern, and only the filenames are considered for the match.
199+
The libraries are taken from the CMake dependency. The bundled libraries are under
200+
`site-packages/${name}.libs`
201+
"""
202+
195203

196204
@dataclasses.dataclass
197205
class WheelSettings:

0 commit comments

Comments
 (0)