Repository: scikit-build/scikit-build-core
Pull request #841 (2024-08-01 18:49:10)

feat: package table
This is an implementation of #669.


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/scikit-build/scikit-build-core/pull/841?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-build) Report
Attention: Patch coverage is `77.08333%` with `11 lines` in your changes missing coverage. Please review.
> Project coverage is 83.50%. Comparing base [(`6afb1d6`)](https://app.codecov.io/gh/scikit-build/scikit-build-core/commit/6afb1d622e4b8d31caf5598229912a312cbc3412?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-build) to head [(`1bafd4c`)](https://app.codecov.io/gh/scikit-build/scikit-build-core/commit/1bafd4cf206003a219cdc323cb924d4916aeaa8f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-build).
> Report is 1 commits behind head on main.

| [Files](https://app.codecov.io/gh/scikit-build/scikit-build-core/pull/841?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-build) | Patch % | Lines |
|---|---|---|
| [src/scikit\_build\_core/settings/sources.py](https://app.codecov.io/gh/scikit-build/scikit-build-core/pull/841?src=pr&el=tree&filepath=src%2Fscikit_build_core%2Fsettings%2Fsources.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-build#diff-c3JjL3NjaWtpdF9idWlsZF9jb3JlL3NldHRpbmdzL3NvdXJjZXMucHk=) | 76.66% | [7 Missing :warning: ](https://app.codecov.io/gh/scikit-build/scikit-build-core/pull/841?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-build) |
| [...cikit\_build\_core/settings/skbuild\_read\_settings.py](https://app.codecov.io/gh/scikit-build/scikit-build-core/pull/841?src=pr&el=tree&filepath=src%2Fscikit_build_core%2Fsettings%2Fskbuild_read_settings.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-build#diff-c3JjL3NjaWtpdF9idWlsZF9jb3JlL3NldHRpbmdzL3NrYnVpbGRfcmVhZF9zZXR0aW5ncy5weQ==) | 25.00% | [3 Missing :warning: ](https://app.codecov.io/gh/scikit-build/scikit-build-core/pull/841?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-build) |
| [src/scikit\_build\_core/build/wheel.py](https://app.codecov.io/gh/scikit-build/scikit-build-core/pull/841?src=pr&el=tree&filepath=src%2Fscikit_build_core%2Fbuild%2Fwheel.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-build#diff-c3JjL3NjaWtpdF9idWlsZF9jb3JlL2J1aWxkL3doZWVsLnB5) | 85.71% | [1 Missing :warning: ](https://app.codecov.io/gh/scikit-build/scikit-build-core/pull/841?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-build) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #841      +/-   ##
==========================================
- Coverage   83.68%   83.50%   -0.18%     
==========================================
  Files          74       74              
  Lines        4204     4244      +40     
==========================================
+ Hits         3518     3544      +26     
- Misses        686      700      +14     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/scikit-build/scikit-build-core/pull/841?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-build).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-build).


Patch:
diff --git a/README.md b/README.md
index 845632c..adaf264 100644
--- a/README.md
+++ b/README.md
@@ -63,8 +63,8 @@ features over classic Scikit-build:
   soon)
 - Experimental editable mode support, with optional experimental auto rebuilds
   on import and optional in-place mode
-- Supports WebAssembly (Emscripten/Pyodide).
-- Supports free-threaded Python 3.13.
+- Supports WebAssembly (Emscripten/[Pyodide](https://pyodide.org)).
+- Supports [free-threaded Python 3.13](https://py-free-threading.github.io).
 
 The following limitations are present compared to classic scikit-build:
 
@@ -117,7 +117,7 @@ the minimum to get started.
 An example `CMakeLists.txt`:
 
 ```cmake
-cmake_minimum_required(VERSION 3.15...3.27)
+cmake_minimum_required(VERSION 3.15...3.30)
 project(${SKBUILD_PROJECT_NAME} LANGUAGES C)
 
 find_package(Python COMPONENTS Interpreter Development.Module REQUIRED)
@@ -212,7 +212,8 @@ sdist.cmake = false
 # 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.
+# name inside the wheel. If a dict, provides a mapping of package name to source
+# directory.
 wheel.packages = ["src/<package>", "python/<package>", "<package>"]
 
 # The Python tags. The default (empty string) will use the default Python
diff --git a/docs/changelog.md b/docs/changelog.md
index 5377d73..457565d 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -1,12 +1,21 @@
 # Changelog
 
-## Version 0.9.9
+
+## Version 0.9.10
 
 This release backports a couple of fixes made during work towards 0.10.
 
 Fixes:
 
 - Regression for `'-'` in version name by @henryiii in #834
+- Improve version regex to capture typed version attributes by @maxbachmann in #816
+
+## Version 0.9.9
+
+This release backports a fix made during work towards 0.10.
+
+Fixes:
+
 - Strip epoch from `SKBUILD_PROJECT_VERSION` by @henryiii in #829
 
 **Full Changelog**: https://github.com/scikit-build/scikit-build-core/compare/v0.9.8...v0.9.9
diff --git a/docs/configuration.md b/docs/configuration.md
index e245e40..7f50d7f 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -249,8 +249,24 @@ list packages explicitly, you can. The final path element is the package.
 wheel.packages = ["python/src/mypackage"]
 ```
 
-Or you can disable Python file inclusion entirely, and rely only on CMake's
-install mechanism, you can do that instead:
+This can also be a table, allowing full customization of where a source package
+maps to a wheel directory. The final components of both paths must match due to the
+way editable installs work. The equivalent of the above is:
+
+```toml
+[tool.scikit-build.wheel.packages]
+mypackage = "python/src/mypackage"
+```
+
+But you can also do more complex moves:
+
+```toml
+[tool.scikit-build.wheel.packages]
+"mypackage/subpackage" = "python/src/subpackage"
+```
+
+You can disable Python file inclusion entirely, and rely only on CMake's
+install mechanism:
 
 ```toml
 [tool.scikit-build]
diff --git a/docs/overrides.md b/docs/overrides.md
index 6afd649..f4478bf 100644
--- a/docs/overrides.md
+++ b/docs/overrides.md
@@ -29,7 +29,7 @@ Example:
 
 ```toml
 [[tool.scikit-build.overrides]]
-if.python_version = ">=3.13"
+if.python-version = ">=3.13"
 wheel.cmake = false
 ```
 
@@ -84,6 +84,10 @@ A few sample values:
 | Windows | ARM           | `ARM64`           |
 
 
+### `abi-flags` (string)
+
+A sorted list of the ABI flags. `t` is the free-threaded build.
+
 ### `platform-node` (string)
 
 The value of `platform.node()`. This is generally your computer's name. Takes a
diff --git a/src/scikit_build_core/build/_pathutil.py b/src/scikit_build_core/build/_pathutil.py
index f013f80..cc1ff5e 100644
--- a/src/scikit_build_core/build/_pathutil.py
+++ b/src/scikit_build_core/build/_pathutil.py
@@ -9,7 +9,7 @@ import pathspec
 from ._file_processor import each_unignored_file
 
 if TYPE_CHECKING:
-    from collections.abc import Generator, Sequence
+    from collections.abc import Generator, Mapping, Sequence
 
 __all__ = ["is_valid_module", "packages_to_file_mapping", "path_to_module", "scantree"]
 
@@ -36,24 +36,28 @@ def path_to_module(path: Path) -> str:
 
 def packages_to_file_mapping(
     *,
-    packages: Sequence[str],
+    packages: Mapping[str, str],
     platlib_dir: Path,
     include: Sequence[str],
     src_exclude: Sequence[str],
     target_exclude: Sequence[str],
 ) -> dict[str, str]:
+    """
+    This will output a mapping of source files to target files.
+    """
     mapping = {}
     exclude_spec = pathspec.GitIgnoreSpec.from_lines(target_exclude)
-    for package in packages:
-        source_package = Path(package)
-        base_path = source_package.parent
+    for package_str, source_str in packages.items():
+        package_dir = Path(package_str)
+        source_dir = Path(source_str)
+
         for filepath in each_unignored_file(
-            source_package,
+            source_dir,
             include=include,
             exclude=src_exclude,
         ):
-            rel_path = filepath.relative_to(base_path)
-            target_path = platlib_dir / rel_path
+            rel_path = filepath.relative_to(source_dir)
+            target_path = platlib_dir / package_dir / rel_path
             if not exclude_spec.match_file(rel_path) and not target_path.is_file():
                 mapping[str(filepath)] = str(target_path)
 
diff --git a/src/scikit_build_core/build/wheel.py b/src/scikit_build_core/build/wheel.py
index d993a1d..ce23e96 100644
--- a/src/scikit_build_core/build/wheel.py
+++ b/src/scikit_build_core/build/wheel.py
@@ -6,6 +6,7 @@ import shutil
 import sys
 import sysconfig
 import tempfile
+from collections.abc import Mapping
 from pathlib import Path
 from typing import TYPE_CHECKING, Any
 
@@ -86,21 +87,23 @@ def _make_editable(
 
 def _get_packages(
     *,
-    packages: Sequence[str] | None,
+    packages: Sequence[str] | Mapping[str, str] | None,
     name: str,
-) -> list[str]:
+) -> dict[str, str]:
     if packages is not None:
-        return list(packages)
+        if isinstance(packages, Mapping):
+            return dict(packages)
+        return {str(Path(p).name): p for p in packages}
 
     # Auto package discovery
-    packages = []
+    packages = {}
     for base_path in (Path("src"), Path("python"), Path()):
         path = base_path / name
         if path.is_dir() and (
             (path / "__init__.py").is_file() or (path / "__init__.pyi").is_file()
         ):
             logger.info("Discovered Python package at {}", path)
-            packages += [str(path)]
+            packages[name] = str(path)
             break
     else:
         logger.debug("Didn't find a Python package for {}", name)
@@ -457,7 +460,9 @@ def _build_wheel_impl_impl(
         ) as wheel:
             wheel.build(wheel_dirs, exclude=settings.wheel.exclude)
 
-            str_pkgs = (str(Path.cwd().joinpath(p).parent.resolve()) for p in packages)
+            str_pkgs = (
+                str(Path.cwd().joinpath(p).parent.resolve()) for p in packages.values()
+            )
             if editable and settings.editable.mode == "redirect":
                 reload_dir = build_dir.resolve() if settings.build_dir else None
 
diff --git a/src/scikit_build_core/builder/sysconfig.py b/src/scikit_build_core/builder/sysconfig.py
index 89e8bd8..ad6539c 100644
--- a/src/scikit_build_core/builder/sysconfig.py
+++ b/src/scikit_build_core/builder/sysconfig.py
@@ -7,12 +7,15 @@ import sysconfig
 from pathlib import Path
 from typing import TYPE_CHECKING
 
+import packaging.tags
+
 from .._logging import logger, rich_print
 
 if TYPE_CHECKING:
     from collections.abc import Mapping
 
 __all__ = [
+    "get_abi_flags",
     "get_cmake_platform",
     "get_numpy_include_dir",
     "get_python_include_dir",
@@ -184,6 +187,19 @@ def get_numpy_include_dir() -> Path | None:
     return Path(np.get_include())
 
 
+def get_abi_flags() -> str:
+    """
+    Return the ABI flags for the current Python interpreter. Derived from
+    ``packaging.tags.sys_tags()`` since that works on Windows.
+    """
+
+    most_compatible = next(iter(packaging.tags.sys_tags()))
+    full_abi = most_compatible.abi
+    vers = packaging.tags.interpreter_version()
+    abi_flags = full_abi[full_abi.find(vers) + len(vers) :]
+    return "".join(sorted(abi_flags))
+
+
 def info_print(*, color: str = "") -> None:
     """
     Print information about the Python environment.
@@ -208,6 +224,7 @@ def info_print(*, color: str = "") -> None:
     rich_print(
         f"[bold {color}]Detected ABI3 SOABI:[/bold] {get_soabi(os.environ, abi3=True)}"
     )
+    rich_print(f"[bold {color}Detected ABI flags:[/bold] {get_abi_flags()}")
 
 
 if __name__ == "__main__":
diff --git a/src/scikit_build_core/resources/scikit-build.schema.json b/src/scikit_build_core/resources/scikit-build.schema.json
index 427a030..f9401d8 100644
--- a/src/scikit_build_core/resources/scikit-build.schema.json
+++ b/src/scikit_build_core/resources/scikit-build.schema.json
@@ -167,11 +167,23 @@
       "additionalProperties": false,
       "properties": {
         "packages": {
-          "type": "array",
-          "items": {
-            "type": "string"
-          },
-          "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."
+          "oneOf": [
+            {
+              "type": "array",
+              "items": {
+                "type": "string"
+              }
+            },
+            {
+              "type": "object",
+              "patternProperties": {
+                ".+": {
+                  "type": "string"
+                }
+              }
+            }
+          ],
+          "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."
         },
         "py-api": {
           "type": "string",
@@ -667,6 +679,10 @@
           "type": "boolean",
           "description": "Whether a cmake wheel is known to be provided for this system."
         },
+        "abi-flags": {
+          "type": "string",
+          "description": "A sorted string of the abi flags. Takes a regex."
+        },
         "env": {
           "type": "object",
           "patternProperties": {
diff --git a/src/scikit_build_core/settings/json_schema.py b/src/scikit_build_core/settings/json_schema.py
index 3b23cbe..dfb7da1 100644
--- a/src/scikit_build_core/settings/json_schema.py
+++ b/src/scikit_build_core/settings/json_schema.py
@@ -142,7 +142,13 @@ def convert_type(t: Any, *, normalize_keys: bool) -> dict[str, Any]:
                 next(iter(a for a in args if a is not type(None))),
                 normalize_keys=normalize_keys,
             )
-        return {"oneOf": [convert_type(a, normalize_keys=normalize_keys) for a in args]}
+        return {
+            "oneOf": [
+                convert_type(a, normalize_keys=normalize_keys)
+                for a in args
+                if a is not type(None)
+            ]
+        }
     if origin is Literal:
         return {"enum": list(args)}
 
diff --git a/src/scikit_build_core/settings/skbuild_model.py b/src/scikit_build_core/settings/skbuild_model.py
index 6c18cdc..b33e008 100644
--- a/src/scikit_build_core/settings/skbuild_model.py
+++ b/src/scikit_build_core/settings/skbuild_model.py
@@ -142,12 +142,13 @@ class SDistSettings:
 
 @dataclasses.dataclass
 class WheelSettings:
-    packages: Optional[List[str]] = None
+    packages: Optional[Union[List[str], Dict[str, str]]] = None
     """
     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.
+    package name inside the wheel. If a dict, provides a mapping of package
+    name to source directory.
     """
 
     py_api: str = ""
diff --git a/src/scikit_build_core/settings/skbuild_overrides.py b/src/scikit_build_core/settings/skbuild_overrides.py
index 88d11dd..3c73f61 100644
--- a/src/scikit_build_core/settings/skbuild_overrides.py
+++ b/src/scikit_build_core/settings/skbuild_overrides.py
@@ -12,6 +12,7 @@ from packaging.specifiers import SpecifierSet
 
 from .._compat import tomllib
 from .._logging import logger
+from ..builder.sysconfig import get_abi_flags
 from ..cmake import CMake
 from ..errors import CMakeNotFoundError
 from ..resources import resources
@@ -76,6 +77,7 @@ def override_match(
     failed: bool | None = None,
     system_cmake: str | None = None,
     cmake_wheel: bool | None = None,
+    abi_flags: str | None = None,
 ) -> tuple[dict[str, str], set[str]]:
     """
     Check if the current environment matches the overrides. Returns a dict
@@ -191,6 +193,14 @@ def override_match(
         else:
             failed_set.add("cmake-wheel")
 
+    if abi_flags is not None:
+        current_abi_flags = get_abi_flags()
+        match_msg = regex_match(current_abi_flags, abi_flags)
+        if match_msg:
+            passed_dict["abi-flags"] = match_msg
+        else:
+            failed_set.add("abi-flags")
+
     if env:
         for key, value in env.items():
             if key not in current_env:
diff --git a/src/scikit_build_core/settings/skbuild_read_settings.py b/src/scikit_build_core/settings/skbuild_read_settings.py
index c458523..caff05b 100644
--- a/src/scikit_build_core/settings/skbuild_read_settings.py
+++ b/src/scikit_build_core/settings/skbuild_read_settings.py
@@ -205,6 +205,13 @@ class SettingsReader:
                 )
                 raise CMakeConfigError(msg)
 
+        if isinstance(self.settings.wheel.packages, dict):
+            for key, value in self.settings.wheel.packages.items():
+                if Path(key).name != Path(value).name:
+                    rich_error(
+                        "wheel.packages table must match in the last component of the paths"
+                    )
+
         if self.settings.editable.rebuild:
             if self.settings.editable.mode == "inplace":
                 rich_error("editable rebuild is incompatible with inplace mode")
diff --git a/src/scikit_build_core/settings/skbuild_schema.py b/src/scikit_build_core/settings/skbuild_schema.py
index 1aaa7eb..081f57c 100644
--- a/src/scikit_build_core/settings/skbuild_schema.py
+++ b/src/scikit_build_core/settings/skbuild_schema.py
@@ -132,6 +132,10 @@ def generate_skbuild_schema(tool_name: str = "scikit-build") -> dict[str, Any]:
                 "type": "boolean",
                 "description": "Whether a cmake wheel is known to be provided for this system.",
             },
+            "abi-flags": {
+                "type": "string",
+                "description": "A sorted string of the abi flags. Takes a regex.",
+            },
             "env": {
                 "type": "object",
                 "patternProperties": {
@@ -153,6 +157,10 @@ def generate_skbuild_schema(tool_name: str = "scikit-build") -> dict[str, Any]:
             kk: {"$ref": "#/$defs/inherit"}
             for kk, vv in v["properties"].items()
             if vv.get("type", "") in {"object", "array"}
+            or any(
+                vvv.get("type", "") in {"object", "array"}
+                for vvv in vv.get("oneOf", {})
+            )
         }
         for k, v in schema["properties"].items()
         if v.get("type", "") == "object"
diff --git a/src/scikit_build_core/settings/sources.py b/src/scikit_build_core/settings/sources.py
index ffc147b..4882aa8 100644
--- a/src/scikit_build_core/settings/sources.py
+++ b/src/scikit_build_core/settings/sources.py
@@ -36,6 +36,7 @@ When setting up your dataclasses, these types are handled:
 - ``Union[str, ...]``: Supports other input types in TOML form (bool currently). Otherwise a string.
 - ``List[T]``: A list of items. `;` separated supported in EnvVar/config forms. T can be a dataclass (TOML only).
 - ``Dict[str, T]``: A table of items. TOML supports a layer of nesting. Any is supported as an item type.
+- ``Union[list[T], Dict[str, T]]`` (TOML only): A list or dict of items.
 - ``Literal[...]``: A list of strings, the result must be in the list.
 - ``Annotated[Dict[...], "EnvVar"]``: A dict of items, where each item can be a string or a dict with "env" and "default" keys.
 
@@ -246,6 +247,9 @@ class EnvSource:
 
     @classmethod
     def convert(cls, item: str, target: type[Any]) -> object:
+        """
+        Convert an item from the environment (always a string) into a target type.
+        """
         target, _ = _process_annotated(target)
         raw_target = _get_target_raw_type(target)
         if dataclasses.is_dataclass(raw_target):
@@ -265,6 +269,23 @@ class EnvSource:
         if raw_target is Union and str in get_args(target):
             return item
 
+        if raw_target is Union:
+            args = {_get_target_raw_type(t): t for t in get_args(target)}
+            if str in args:
+                return item
+            if dict in args and "=" in item:
+                items = (i.strip().split("=") for i in item.split(";"))
+                return {
+                    k: cls.convert(v, _get_inner_type(args[dict])) for k, v in items
+                }
+            if list in args:
+                return [
+                    cls.convert(i.strip(), _get_inner_type(args[list]))
+                    for i in item.split(";")
+                ]
+            msg = f"Can't convert into {target}"
+            raise TypeError(msg)
+
         if raw_target is Literal:
             if item not in get_args(_process_union(target)):
                 msg = f"{item!r} not in {get_args(_process_union(target))!r}"
@@ -376,7 +397,7 @@ class ConfSource:
             if isinstance(item, list):
                 return [cls.convert(i, _get_inner_type(target)) for i in item]
             if isinstance(item, (dict, bool)):
-                msg = f"Expected {target}, got {type(item).__name__}"
+                msg = f"Expected {target}, got {type(item)}"
                 raise TypeError(msg)
             return [
                 cls.convert(i.strip(), _get_inner_type(target)) for i in item.split(";")
@@ -384,13 +405,24 @@ class ConfSource:
         if raw_target is dict:
             assert not isinstance(item, (str, list, bool))
             return {k: cls.convert(v, _get_inner_type(target)) for k, v in item.items()}
+        if raw_target is Union:
+            args = {_get_target_raw_type(t): t for t in get_args(target)}
+            if str in args:
+                return item
+            if dict in args and isinstance(item, dict):
+                return {
+                    k: cls.convert(v, _get_inner_type(args[dict]))
+                    for k, v in item.items()
+                }
+            if list in args and isinstance(item, list):
+                return [cls.convert(i, _get_inner_type(args[list])) for i in item]
+            msg = f"Can't convert into {target}"
+            raise TypeError(msg)
         if isinstance(item, (list, dict)):
             msg = f"Expected {target}, got {type(item).__name__}"
             raise TypeError(msg)
         if raw_target is bool:
             return item if isinstance(item, bool) else _process_bool(item)
-        if raw_target is Union and str in get_args(target):
-            return item
         if raw_target is Literal:
             if item not in get_args(_process_union(target)):
                 msg = f"{item!r} not in {get_args(_process_union(target))!r}"
@@ -453,6 +485,9 @@ class TOMLSource:
 
     @classmethod
     def convert(cls, item: Any, target: type[Any]) -> object:
+        """
+        Convert an ``item`` from TOML into a ``target`` type.
+        """
         target, annotations = _process_annotated(target)
         raw_target = _get_target_raw_type(target)
         if dataclasses.is_dataclass(raw_target):
@@ -482,8 +517,17 @@ class TOMLSource:
             return {k: cls.convert(v, _get_inner_type(target)) for k, v in item.items()}
         if raw_target is Any:
             return item
-        if raw_target is Union and type(item) in get_args(target):
-            return item
+        if raw_target is Union:
+            args = {_get_target_raw_type(t): t for t in get_args(target)}
+            if type(item) in args:
+                if isinstance(item, dict):
+                    return {
+                        k: cls.convert(v, _get_inner_type(args[dict]))
+                        for k, v in item.items()
+                    }
+                if isinstance(item, list):
+                    return [cls.convert(i, _get_inner_type(args[list])) for i in item]
+                return item
         if raw_target is Literal:
             if item not in get_args(_process_union(target)):
                 msg = f"{item!r} not in {get_args(_process_union(target))!r}"


Test patch:
diff --git a/tests/test_editable_unit.py b/tests/test_editable_unit.py
index a0451f1..ab3e24b 100644
--- a/tests/test_editable_unit.py
+++ b/tests/test_editable_unit.py
@@ -134,7 +134,7 @@ def test_navigate_editable_pkg(editable_package: EditablePackage, virtualenv: VE
     site_packages, pkg_dir, src_pkg_dir = editable_package
 
     # Create a fake editable install
-    packages = ["pkg"]
+    packages = {"pkg": "pkg"}
     mapping = packages_to_file_mapping(
         packages=packages,
         platlib_dir=site_packages,
diff --git a/tests/test_settings.py b/tests/test_settings.py
index bee7bfe..de5cbdd 100644
--- a/tests/test_settings.py
+++ b/tests/test_settings.py
@@ -31,6 +31,7 @@ class SettingChecker:
     literal: Literal["one", "two", "three"] = "one"
     # TOML only
     ten: Dict[str, Any] = dataclasses.field(default_factory=dict)
+    eleven: Optional[Union[List[str], Dict[str, str]]] = None
 
 
 def test_empty(monkeypatch):
@@ -70,6 +71,7 @@ def test_env(monkeypatch):
     monkeypatch.setenv("SKBUILD_SEVEN", "7")
     monkeypatch.setenv("SKBUILD_EIGHT", "thing=8;thought=9")
     monkeypatch.setenv("SKBUILD_NINE", "thing=8")
+    monkeypatch.setenv("SKBUILD_ELEVEN", "one;two")
     monkeypatch.setenv("SKBUILD_LITERAL", "two")
 
     sources = SourceChain(
@@ -89,9 +91,27 @@ def test_env(monkeypatch):
     assert settings.seven == 7
     assert settings.eight == {"thing": "8", "thought": "9"}
     assert settings.nine == {"thing": 8}
+    assert settings.eleven == ["one", "two"]
     assert settings.literal == "two"
 
 
+def test_env_union(monkeypatch):
+    monkeypatch.setenv("SKBUILD_ZERO", "zero")
+    monkeypatch.setenv("SKBUILD_ONE", "one")
+    monkeypatch.setenv("SKBUILD_TWO", "2")
+    monkeypatch.setenv("SKBUILD_THREE", "three")
+    monkeypatch.setenv("SKBUILD_ELEVEN", "a=one;b=two")
+
+    sources = SourceChain(
+        EnvSource("SKBUILD"),
+        ConfSource(settings={}),
+        TOMLSource(settings={}),
+    )
+    settings = sources.convert_target(SettingChecker)
+
+    assert settings.eleven == {"a": "one", "b": "two"}
+
+
 def test_conf():
     config_settings: Dict[str, Union[str, List[str]]] = {
         "zero": "zero",
@@ -105,6 +125,7 @@ def test_conf():
         "eight.foo": "one",
         "eight.bar": "two",
         "nine.thing": "8",
+        "eleven": ["one", "two"],
         "literal": "three",
     }
 
@@ -141,6 +162,7 @@ def test_toml():
         "eight": {"one": "one", "two": "two", "bool": False},
         "nine": {"thing": 8},
         "ten": {"a": {"b": 3}},
+        "eleven": ["one", "two"],
         "literal": "three",
     }
 
@@ -162,9 +184,29 @@ def test_toml():
     assert settings.eight == {"one": "one", "two": "two", "bool": False}
     assert settings.nine == {"thing": 8}
     assert settings.ten == {"a": {"b": 3}}
+    assert settings.eleven == ["one", "two"]
     assert settings.literal == "three"
 
 
+def test_toml_union():
+    toml_settings = {
+        "zero": "zero",
+        "one": "one",
+        "two": 2,
+        "three": ["three"],
+        "eleven": {"a": "one", "b": "two"},
+    }
+
+    sources = SourceChain(
+        EnvSource("SKBUILD"),
+        ConfSource(settings={}),
+        TOMLSource(settings=toml_settings),
+    )
+    settings = sources.convert_target(SettingChecker)
+
+    assert settings.eleven == {"a": "one", "b": "two"}
+
+
 def test_all_names():
     keys = [x.name for x in dataclasses.fields(SettingChecker)]
 
diff --git a/tests/test_settings_overrides.py b/tests/test_settings_overrides.py
index bd49222..645fd70 100644
--- a/tests/test_settings_overrides.py
+++ b/tests/test_settings_overrides.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import sysconfig
 import typing
 from pathlib import Path
 from textwrap import dedent
@@ -540,6 +541,7 @@ def test_skbuild_overrides_from_sdist(
         tmp_path / ("from_sdist" if from_sdist else "not_from_sdist") / "pyproject.toml"
     )
     pyproject_toml.parent.mkdir(exist_ok=True)
+
     pyproject_toml.write_text(
         dedent(
             """\
@@ -666,3 +668,23 @@ def test_system_cmake(
     settings_reader = SettingsReader.from_file(pyproject_toml, retry=False)
     settings = settings_reader.settings
     assert settings.wheel.cmake == (cmake_version == "3.27")
+
+
+def test_free_threaded_override(tmp_path: Path):
+    pyproject_toml = tmp_path / "pyproject.toml"
+    pyproject_toml.write_text(
+        dedent(
+            """\
+            [tool.scikit-build]
+            wheel.cmake = false
+
+            [[tool.scikit-build.overrides]]
+            if.abi-flags = "t"
+            wheel.cmake = true
+            """
+        )
+    )
+
+    settings_reader = SettingsReader.from_file(pyproject_toml, state="wheel")
+    settings = settings_reader.settings
+    assert settings.wheel.cmake == bool(sysconfig.get_config_var("Py_GIL_DISABLED"))