Skip to content

Commit 74dd119

Browse files
authored
feat: package table (#841)
This is an implementation of #669. --------- Signed-off-by: Henry Schreiner <[email protected]>
1 parent f640ad2 commit 74dd119

File tree

12 files changed

+173
-31
lines changed

12 files changed

+173
-31
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,8 @@ sdist.cmake = false
212212
# A list of packages to auto-copy into the wheel. If this is not set, it will
213213
# default to the first of ``src/<package>``, ``python/<package>``, or
214214
# ``<package>`` if they exist. The prefix(s) will be stripped from the package
215-
# name inside the wheel.
215+
# name inside the wheel. If a dict, provides a mapping of package name to source
216+
# directory.
216217
wheel.packages = ["src/<package>", "python/<package>", "<package>"]
217218

218219
# The Python tags. The default (empty string) will use the default Python

docs/configuration.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -249,8 +249,24 @@ list packages explicitly, you can. The final path element is the package.
249249
wheel.packages = ["python/src/mypackage"]
250250
```
251251

252-
Or you can disable Python file inclusion entirely, and rely only on CMake's
253-
install mechanism, you can do that instead:
252+
This can also be a table, allowing full customization of where a source package
253+
maps to a wheel directory. The final components of both paths must match due to the
254+
way editable installs work. The equivalent of the above is:
255+
256+
```toml
257+
[tool.scikit-build.wheel.packages]
258+
mypackage = "python/src/mypackage"
259+
```
260+
261+
But you can also do more complex moves:
262+
263+
```toml
264+
[tool.scikit-build.wheel.packages]
265+
"mypackage/subpackage" = "python/src/subpackage"
266+
```
267+
268+
You can disable Python file inclusion entirely, and rely only on CMake's
269+
install mechanism:
254270

255271
```toml
256272
[tool.scikit-build]

src/scikit_build_core/build/_pathutil.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from ._file_processor import each_unignored_file
1010

1111
if TYPE_CHECKING:
12-
from collections.abc import Generator, Sequence
12+
from collections.abc import Generator, Mapping, Sequence
1313

1414
__all__ = ["is_valid_module", "packages_to_file_mapping", "path_to_module", "scantree"]
1515

@@ -36,24 +36,28 @@ def path_to_module(path: Path) -> str:
3636

3737
def packages_to_file_mapping(
3838
*,
39-
packages: Sequence[str],
39+
packages: Mapping[str, str],
4040
platlib_dir: Path,
4141
include: Sequence[str],
4242
src_exclude: Sequence[str],
4343
target_exclude: Sequence[str],
4444
) -> dict[str, str]:
45+
"""
46+
This will output a mapping of source files to target files.
47+
"""
4548
mapping = {}
4649
exclude_spec = pathspec.GitIgnoreSpec.from_lines(target_exclude)
47-
for package in packages:
48-
source_package = Path(package)
49-
base_path = source_package.parent
50+
for package_str, source_str in packages.items():
51+
package_dir = Path(package_str)
52+
source_dir = Path(source_str)
53+
5054
for filepath in each_unignored_file(
51-
source_package,
55+
source_dir,
5256
include=include,
5357
exclude=src_exclude,
5458
):
55-
rel_path = filepath.relative_to(base_path)
56-
target_path = platlib_dir / rel_path
59+
rel_path = filepath.relative_to(source_dir)
60+
target_path = platlib_dir / package_dir / rel_path
5761
if not exclude_spec.match_file(rel_path) and not target_path.is_file():
5862
mapping[str(filepath)] = str(target_path)
5963

src/scikit_build_core/build/wheel.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import sys
77
import sysconfig
88
import tempfile
9+
from collections.abc import Mapping
910
from pathlib import Path
1011
from typing import TYPE_CHECKING, Any
1112

@@ -86,21 +87,23 @@ def _make_editable(
8687

8788
def _get_packages(
8889
*,
89-
packages: Sequence[str] | None,
90+
packages: Sequence[str] | Mapping[str, str] | None,
9091
name: str,
91-
) -> list[str]:
92+
) -> dict[str, str]:
9293
if packages is not None:
93-
return list(packages)
94+
if isinstance(packages, Mapping):
95+
return dict(packages)
96+
return {str(Path(p).name): p for p in packages}
9497

9598
# Auto package discovery
96-
packages = []
99+
packages = {}
97100
for base_path in (Path("src"), Path("python"), Path()):
98101
path = base_path / name
99102
if path.is_dir() and (
100103
(path / "__init__.py").is_file() or (path / "__init__.pyi").is_file()
101104
):
102105
logger.info("Discovered Python package at {}", path)
103-
packages += [str(path)]
106+
packages[name] = str(path)
104107
break
105108
else:
106109
logger.debug("Didn't find a Python package for {}", name)
@@ -457,7 +460,9 @@ def _build_wheel_impl_impl(
457460
) as wheel:
458461
wheel.build(wheel_dirs, exclude=settings.wheel.exclude)
459462

460-
str_pkgs = (str(Path.cwd().joinpath(p).parent.resolve()) for p in packages)
463+
str_pkgs = (
464+
str(Path.cwd().joinpath(p).parent.resolve()) for p in packages.values()
465+
)
461466
if editable and settings.editable.mode == "redirect":
462467
reload_dir = build_dir.resolve() if settings.build_dir else None
463468

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

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -167,11 +167,23 @@
167167
"additionalProperties": false,
168168
"properties": {
169169
"packages": {
170-
"type": "array",
171-
"items": {
172-
"type": "string"
173-
},
174-
"description": "A list of packages to auto-copy into the wheel. If this is not set, it will default to the first of ``src/<package>``, ``python/<package>``, or ``<package>`` if they exist. The prefix(s) will be stripped from the package name inside the wheel."
170+
"oneOf": [
171+
{
172+
"type": "array",
173+
"items": {
174+
"type": "string"
175+
}
176+
},
177+
{
178+
"type": "object",
179+
"patternProperties": {
180+
".+": {
181+
"type": "string"
182+
}
183+
}
184+
}
185+
],
186+
"description": "A list of packages to auto-copy into the wheel. If this is not set, it will default to the first of ``src/<package>``, ``python/<package>``, or ``<package>`` if they exist. The prefix(s) will be stripped from the package name inside the wheel. If a dict, provides a mapping of package name to source directory."
175187
},
176188
"py-api": {
177189
"type": "string",

src/scikit_build_core/settings/json_schema.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,13 @@ def convert_type(t: Any, *, normalize_keys: bool) -> dict[str, Any]:
142142
next(iter(a for a in args if a is not type(None))),
143143
normalize_keys=normalize_keys,
144144
)
145-
return {"oneOf": [convert_type(a, normalize_keys=normalize_keys) for a in args]}
145+
return {
146+
"oneOf": [
147+
convert_type(a, normalize_keys=normalize_keys)
148+
for a in args
149+
if a is not type(None)
150+
]
151+
}
146152
if origin is Literal:
147153
return {"enum": list(args)}
148154

src/scikit_build_core/settings/skbuild_model.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,12 +142,13 @@ class SDistSettings:
142142

143143
@dataclasses.dataclass
144144
class WheelSettings:
145-
packages: Optional[List[str]] = None
145+
packages: Optional[Union[List[str], Dict[str, str]]] = None
146146
"""
147147
A list of packages to auto-copy into the wheel. If this is not set, it will
148148
default to the first of ``src/<package>``, ``python/<package>``, or
149149
``<package>`` if they exist. The prefix(s) will be stripped from the
150-
package name inside the wheel.
150+
package name inside the wheel. If a dict, provides a mapping of package
151+
name to source directory.
151152
"""
152153

153154
py_api: str = ""

src/scikit_build_core/settings/skbuild_read_settings.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,13 @@ def __init__(
205205
)
206206
raise CMakeConfigError(msg)
207207

208+
if isinstance(self.settings.wheel.packages, dict):
209+
for key, value in self.settings.wheel.packages.items():
210+
if Path(key).name != Path(value).name:
211+
rich_error(
212+
"wheel.packages table must match in the last component of the paths"
213+
)
214+
208215
if self.settings.editable.rebuild:
209216
if self.settings.editable.mode == "inplace":
210217
rich_error("editable rebuild is incompatible with inplace mode")

src/scikit_build_core/settings/skbuild_schema.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,10 @@ def generate_skbuild_schema(tool_name: str = "scikit-build") -> dict[str, Any]:
157157
kk: {"$ref": "#/$defs/inherit"}
158158
for kk, vv in v["properties"].items()
159159
if vv.get("type", "") in {"object", "array"}
160+
or any(
161+
vvv.get("type", "") in {"object", "array"}
162+
for vvv in vv.get("oneOf", {})
163+
)
160164
}
161165
for k, v in schema["properties"].items()
162166
if v.get("type", "") == "object"

src/scikit_build_core/settings/sources.py

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
- ``Union[str, ...]``: Supports other input types in TOML form (bool currently). Otherwise a string.
3737
- ``List[T]``: A list of items. `;` separated supported in EnvVar/config forms. T can be a dataclass (TOML only).
3838
- ``Dict[str, T]``: A table of items. TOML supports a layer of nesting. Any is supported as an item type.
39+
- ``Union[list[T], Dict[str, T]]`` (TOML only): A list or dict of items.
3940
- ``Literal[...]``: A list of strings, the result must be in the list.
4041
- ``Annotated[Dict[...], "EnvVar"]``: A dict of items, where each item can be a string or a dict with "env" and "default" keys.
4142
@@ -246,6 +247,9 @@ def get_item(
246247

247248
@classmethod
248249
def convert(cls, item: str, target: type[Any]) -> object:
250+
"""
251+
Convert an item from the environment (always a string) into a target type.
252+
"""
249253
target, _ = _process_annotated(target)
250254
raw_target = _get_target_raw_type(target)
251255
if dataclasses.is_dataclass(raw_target):
@@ -265,6 +269,23 @@ def convert(cls, item: str, target: type[Any]) -> object:
265269
if raw_target is Union and str in get_args(target):
266270
return item
267271

272+
if raw_target is Union:
273+
args = {_get_target_raw_type(t): t for t in get_args(target)}
274+
if str in args:
275+
return item
276+
if dict in args and "=" in item:
277+
items = (i.strip().split("=") for i in item.split(";"))
278+
return {
279+
k: cls.convert(v, _get_inner_type(args[dict])) for k, v in items
280+
}
281+
if list in args:
282+
return [
283+
cls.convert(i.strip(), _get_inner_type(args[list]))
284+
for i in item.split(";")
285+
]
286+
msg = f"Can't convert into {target}"
287+
raise TypeError(msg)
288+
268289
if raw_target is Literal:
269290
if item not in get_args(_process_union(target)):
270291
msg = f"{item!r} not in {get_args(_process_union(target))!r}"
@@ -376,21 +397,32 @@ def convert(
376397
if isinstance(item, list):
377398
return [cls.convert(i, _get_inner_type(target)) for i in item]
378399
if isinstance(item, (dict, bool)):
379-
msg = f"Expected {target}, got {type(item).__name__}"
400+
msg = f"Expected {target}, got {type(item)}"
380401
raise TypeError(msg)
381402
return [
382403
cls.convert(i.strip(), _get_inner_type(target)) for i in item.split(";")
383404
]
384405
if raw_target is dict:
385406
assert not isinstance(item, (str, list, bool))
386407
return {k: cls.convert(v, _get_inner_type(target)) for k, v in item.items()}
408+
if raw_target is Union:
409+
args = {_get_target_raw_type(t): t for t in get_args(target)}
410+
if str in args:
411+
return item
412+
if dict in args and isinstance(item, dict):
413+
return {
414+
k: cls.convert(v, _get_inner_type(args[dict]))
415+
for k, v in item.items()
416+
}
417+
if list in args and isinstance(item, list):
418+
return [cls.convert(i, _get_inner_type(args[list])) for i in item]
419+
msg = f"Can't convert into {target}"
420+
raise TypeError(msg)
387421
if isinstance(item, (list, dict)):
388422
msg = f"Expected {target}, got {type(item).__name__}"
389423
raise TypeError(msg)
390424
if raw_target is bool:
391425
return item if isinstance(item, bool) else _process_bool(item)
392-
if raw_target is Union and str in get_args(target):
393-
return item
394426
if raw_target is Literal:
395427
if item not in get_args(_process_union(target)):
396428
msg = f"{item!r} not in {get_args(_process_union(target))!r}"
@@ -453,6 +485,9 @@ def get_item(self, *fields: str, is_dict: bool) -> Any: # noqa: ARG002
453485

454486
@classmethod
455487
def convert(cls, item: Any, target: type[Any]) -> object:
488+
"""
489+
Convert an ``item`` from TOML into a ``target`` type.
490+
"""
456491
target, annotations = _process_annotated(target)
457492
raw_target = _get_target_raw_type(target)
458493
if dataclasses.is_dataclass(raw_target):
@@ -482,8 +517,17 @@ def convert(cls, item: Any, target: type[Any]) -> object:
482517
return {k: cls.convert(v, _get_inner_type(target)) for k, v in item.items()}
483518
if raw_target is Any:
484519
return item
485-
if raw_target is Union and type(item) in get_args(target):
486-
return item
520+
if raw_target is Union:
521+
args = {_get_target_raw_type(t): t for t in get_args(target)}
522+
if type(item) in args:
523+
if isinstance(item, dict):
524+
return {
525+
k: cls.convert(v, _get_inner_type(args[dict]))
526+
for k, v in item.items()
527+
}
528+
if isinstance(item, list):
529+
return [cls.convert(i, _get_inner_type(args[list])) for i in item]
530+
return item
487531
if raw_target is Literal:
488532
if item not in get_args(_process_union(target)):
489533
msg = f"{item!r} not in {get_args(_process_union(target))!r}"

0 commit comments

Comments
 (0)