Repository: scikit-build/scikit-build-core
Pull request #241 (2023-03-21 12:57:46)

ci: Further configuration for FedoraProject
So this is already a continuation of packit/packit#201. Seems that implementing `packit` would resolve the issue of keeping the spec file up to date. E.g. I have pushed a tag of `v0.2.3-rc1` on my fork, and as you can see in the [copr build](https://copr.fedorainfracloud.org/coprs/lecris/scikit-build-core/builds/) it updates to that latest version: [generated spec file](https://download.copr.fedorainfracloud.org/results/lecris/scikit-build-core/fedora-rawhide-x86_64/05689712-python-scikit-build-core/python-scikit-build-core.spec) for reference.

The main thing that `packit` does here:
- [`copr_build`](https://packit.dev/docs/configuration/#copr_build) Submit builds to `copr` (using the latest version of the source)
- [`propose_downstream`](https://packit.dev/docs/configuration/#propose_downstream), create a PR on `src.fedoraproject.org` with the relevant changes (files defined in `files_to_sync`)
- `koji_build` and `bodhi_update` are only used downstream

There are still a few things to address:
- [x] Move the builds to a `scikit-build` group. I've [requested](https://pagure.io/fedora-infrastructure/issue/11202) to have this group created
- [x] `Packit` requires the installation of the [packit app](https://packit.dev/docs/guide/#github) to the repo. Can check the [branch](https://github.com/LecrisUT/scikit-build-core/tree/packit) for how it looks in action
- [x] Move the build conditions to the `main` branch. But do we want it on every commit/PR or release?
- [x] Implement `propose_downstream`

Depends on: packit/packit#247

codecov[bot]: ## [Codecov](https://codecov.io/gh/scikit-build/scikit-build-core/pull/241?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-build) Report
> Merging [#241](https://codecov.io/gh/scikit-build/scikit-build-core/pull/241?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-build) (bd50ee3) into [main](https://codecov.io/gh/scikit-build/scikit-build-core/commit/39444df22151410de821e6fe8ea083070ff66fbc?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-build) (39444df) will **not change** coverage.
> The diff coverage is `n/a`.

```diff
@@           Coverage Diff           @@
##             main     #241   +/-   ##
=======================================
  Coverage   89.35%   89.35%           
=======================================
  Files          50       50           
  Lines        2160     2160           
=======================================
  Hits         1930     1930           
  Misses        230      230           
```



Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-build). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-build)

hrnciar: Personally, I don't have any experience with `packit` since I don't have any project that I am actively developing that I could use it for. But I have one small suggestion regarding the general workflow of shipping updates in Fedora.

> The main thing that `packit` does here:
> 
>     * Submit builds to copr (using the latest version of the source)
> 
>     * propose_downstream, create a PR on `src.fedoraproject.org` with the relevant changes (files defined in `files_to_sync`)
> 

In my team, we try to check the impact of new releases on the Fedora ecosystem. We want to avoid a situation when the update of package `foo` breaks package `bar` which is depending on it. Hence, for every new package update, we rebuild also all of its depending packages in [copr](https://copr.fedorainfracloud.org/). This way we can find potential breakages before the update is shipped and help with fixing them (or at least opening bugzillas for package maintainers so they are informed). I think, this is especially important for build systems like `scikit-build`/`scikit-build-core`, because in future a lot of packages could depend on it. [Here](https://hackmd.io/81DXky5lRj-cUiSTeiag2A) is the document covering the process of how we do impact checks with all commands. I am willing to help you with this, feel free to add me as a co-maintainer and I'll try to review the PRs. [This document](https://github.com/fedora-python/pull-request-review-guide/blob/main/fedora-pr-review-guide.md) describe how we do reviews.

>     * koji_build and bodhi_update are only used downstream
> 
> 

---
I've also enable the tracking of `scikit-build-core` in [Anitya](https://release-monitoring.org/project/302380/). This should create a bugzilla when new release is available. 
hrnciar: I wanted to point out that building the dependents should happen *before* `koji` build, that's the reason I put my text in between the bullets of the workflow. Once the package was built in `koji` it's available in `rawhide` repos and it can break stuff (on stable releases there is a waiting period, but not in `rawhide`). All the testing should happen before the PR is merged. 

As far as I know, currently, there is no automated tooling for building dependents. We would love to have it, but currently, we are using a set of commands as described in the [guide](https://hackmd.io/81DXky5lRj-cUiSTeiag2A) as a part of the PR review. 
LecrisUT: Of course. `propose_downstream` only creates a PR, it is our responsibility to check others work. I don't know how to make the PR depend on an external CI, or at least if `packit` can create a custom message that we can link to other build statuses.

I think what we want is the 3rd workflow centered on `copr`. It seems the external dependency is possible, it is just the triggering of rebuild that needs to be done. I can add a job for this as soon as we have `scikit-build` group where we can create the relevant projects.

> As far as I know, currently, there is no automated tooling for building dependents. We would love to have it, but currently, we are using a set of commands as described in the [guide](https://hackmd.io/81DXky5lRj-cUiSTeiag2A) as a part of the PR review.

I will look into that later, but tldr, on this package side, we just need to push a release copr so others can pull from right? Also over on matrix there is the suggestion of [`koschei`](https://github.com/fedora-infra/koschei) and there is talk about [`koschei` on `copr`](https://github.com/fedora-copr/copr/issues/1441#issuecomment-1447754461)
LecrisUT: @hrnciar Can I confirm about the guide you mentioned:
- Periodically `repoquery` to get all packages that require `python3-scikit-build-core` and save a list of it
- At each release MANUALLY submit copr builds for al dependent packages?

I think that would be a bit though to maintain. Maybe we can temporarily maintain it for rawhide, but isn't `koschei` already doing that for us? At least can we put it as a script, then maybe we can automate it here as a [packit action](https://packit.dev/docs/actions/)
hrnciar: > @hrnciar Can I confirm about the guide you mentioned:
> 
>     * Periodically `repoquery` to get all packages that require `python3-scikit-build-core` and save a list of it
> 
Yes.
>     * At each release MANUALLY submit copr builds for al dependent packages?
> 

If you have a list of packages depending on `python3-scikit-build-core` you can easily rebuild them with these two commands:
~~~~
# Using parallel from the moreutils-parallel package.
$ parallel copr add-package-distgit $COPR --webhook-rebuild on --commit rawhide --name -- $(cat packages.txt)
$ parallel copr build-package $COPR --background --nowait --name -- $(cat packages.txt
~~~~

The tricky part comes later when you need to investigate the failures. You need to differentiate between the failures related to changes in the latest release and unrelated failures due to some other changes in the distro. What we usually do is that we rebuild all depending packages in the so-called "[control](https://hackmd.io/81DXky5lRj-cUiSTeiag2A#Rebuild-failures-in-a-control-COPR)" copr without the change that we are testing. When you compare these two coprs, you will have:
a) packages that succeeded in the main copr, and succeeded also in the control one - no breakage
b) packages that failed in the main copr, but succeeded in the control one - these packages need investigation because they are broken with the new release of the package
c) packages that succeeded in the main copr, and succeeded in the control one - ignore them, these packages were broken prior to your update

> 
> I think that would be a bit though to maintain. Maybe we can temporarily maintain it for rawhide, but isn't `koschei` already doing that for us? At least can we put it as a script, then maybe we can automate it here as a [packit action](https://packit.dev/docs/actions/)

I understand it is complicated, but we want to avoid making people unhappy by breaking stuff. When you mentioned `packit` and automation I started to worry about the automatic shipping of updates without testing its impact. I am trying to provide the context of why it is necessary. Of course, you can decide to ship the update and react retroactively when users start to file bugs. 

You are right that Koschei helps you detect the problem, but when the package is "red" in Koschei it's already *after* it was shipped to the users and we would like to avoid that. 

As I said before, I am offering my help. Feel free to ping me and I'll do the testing for you. It won't be my top priority unless I won't need the update for some other package I maintain, but if you ping me, I'll try to make time and help with the impact check. It might sound complicated, but in reality, for easy updates, it takes a couple of minutes to run the scripts and then some time to check the results. When we discover a higher number of broken packages, we usually write a [Change Proposal](https://fedoraproject.org/wiki/Changes/Update_python-packaging_to_version_22_plus) so maintainers are informed about what's happening.


hrnciar: > I understand your concern, but we also should not abuse the copr resources unnecessarily.

This is exactly what is copr intended for. We communicate with copr folks daily and they are happy when people use it. I am regularly submitting thousands of builds when rebuilding all Fedora packages with development versions of Python 3.12. Just use `--background` option when submitting builds to give them lower priority in the queue. 

> IMO, we should be fine for `rawhide` at the very least. That version is meant for development.
> 
> Also note that I wrote the automation to update only `rawhide` and most recent release, so effect on older versions should be minimal. Maybe we can change it and only update latest release on request where we run your script. And when Fedora is forked, all packages are rebuilt right? So I don't think it is that drastic.
> 

Everything I've written above was written with `rawhide` in mind, I wasn't talking about stable releases. 

> I would suggest to provide a copr project, e.g. `@scikit-build/release`, and instruct downstream to link to it and follow it, and we only run the script on those that don't have `copr` presence, although realistically they should all have. But could you write a script so that this can be automated to maybe a temporary project `@scikit-build/downstream`?
> 

Sorry, I don't understand this part. Downstream usually follows official releases on Pypi. That's why I enabled tracking.

> > I've also enable the tracking of scikit-build-core in [Anitya](https://release-monitoring.org/project/302380/). This should create a bugzilla when new release is available.

> As for Change Proposal, that sounds like a good idea, but that would mean that we have to only release large changes so we don't pollute it with each package update? Maybe we are jumping a head too quickly on this since we have very few packages that depend on this. For `spglib` I am going to link it, and as soon as `Koschei` for copr is available or I can create a manual webhook, or there is any alternative I will implement it.

I wouldn't worry about a Change Proposal for now. I wanted to give you some perspective on what we do when there is a big impact on the distro. Upstream developers usually don't talk to us about their plans. So when the new version is released we evaluate the impact and then we decide if it's okay to just ship it or if it needs a more careful coordinated approach and then we create a Change Proposal so other maintainers are aware. 

LecrisUT: Thanks for the pointers I'll keep them in mind when reviewing PRs.

> Sorry, I don't understand this part. Downstream usually follows official releases on Pypi. That's why I enabled tracking.

So let's look at what I am implementing with this PR using packit:
- `copr_build` triggered on PRs and commits. This will submit builds to be CI tested to the copr @group/project: `@scikit-build/PR` and/or `@scikit-build/latest`. This is only for `scikit-build` purpose
- `propose_downstream` triggered on release. This will create a PR on `src.fedoraproject.org` to be reviewed. Whether or not we will merge it into rawhide depends on the release and the next stage
- `copr_build` triggered on release. This will submit builds to `@scikit-build/release`. Other copr projects are recommended to include that project as a dependency so they can run tests on their build
  - Ideally if there is `Koschei` or equivalent way to notify others, have them review the changes and work accordingly
  - Otherwise and what we can do for now, get all of the dependent packages using `scikit-build-core` and trigger a rebuild according to their `dist-git` in `@scikit-build/downstream`. But for this one, again, I would like your help on making a script to incorporate it here.
henryiii: I'm happy to add packit for the repo. Something just to make sure the recipe is mostly correct is fine.
henryiii: I've set up a Fedora account, I think that _might_ allow it to work now.

Patch:
diff --git a/.distro/python-scikit-build-core.rpmlintrc b/.distro/python-scikit-build-core.rpmlintrc
new file mode 100644
index 0000000..5a161f2
--- /dev/null
+++ b/.distro/python-scikit-build-core.rpmlintrc
@@ -0,0 +1,1 @@
+addFilter("files-duplicate .*/__init__.py")
diff --git a/scikit-build-core.spec b/.distro/scikit-build-core.spec
similarity index 61%
rename from scikit-build-core.spec
rename to .distro/scikit-build-core.spec
index 8ddf7f7..d6a7e16 100644
--- a/scikit-build-core.spec
+++ b/.distro/scikit-build-core.spec
@@ -1,11 +1,14 @@
+%global pypi_name scikit_build_core
+
 Name:           python-scikit-build-core
-Version:        0.2.2
+Version:        0.0.0
 Release:        %{autorelease}
 Summary:        Build backend for CMake based projects
 
 License:        Apache-2.0
 URL:            https://github.com/scikit-build/scikit-build-core
-Source0:        https://github.com/scikit-build/scikit-build-core/archive/refs/tags/v%{version}.tar.gz
+Source0:        %{pypi_source %{pypi_name}}
+Source1:        %{name}.rpmlintrc
 
 BuildArch:      noarch
 BuildRequires:  python3-devel
@@ -13,12 +16,6 @@ BuildRequires:  cmake
 BuildRequires:  ninja-build
 BuildRequires:  gcc
 BuildRequires:  gcc-c++
-Requires:       cmake
-Recommends:     (ninja-build or make)
-Recommends:     python3dist(pyproject-metadata)
-Recommends:     python3dist(pathspec)
-Suggests:       ninja-build
-Suggests:       gcc
 
 %global _description %{expand:
 A next generation Python CMake adaptor and Python API for plugins}
@@ -27,19 +24,16 @@ A next generation Python CMake adaptor and Python API for plugins}
 
 %package -n python3-scikit-build-core
 Summary:        %{summary}
+Requires:       cmake
+Recommends:     (ninja-build or make)
+Recommends:     python3dist(pyproject-metadata)
+Recommends:     python3dist(pathspec)
+Suggests:       ninja-build
+Suggests:       gcc
 %description -n python3-scikit-build-core %_description
 
 %prep
-# This assumes the source is not retrieved from tar ball, but built in place
-# This makes it possible to build with `tito build --test`
-# Change to `%%autosetup -n %%{pypi_name}-%%{version}` for release
-# TODO: There should be a format to satisfy both
-%setup -q
-# TODO: Remove when tito upstream issue is fixed
-# https://github.com/rpm-software-management/tito/issues/444
-if grep -q "describe-name: \$Format" .git_archival.txt; then
-   sed -i "s/describe-name:.*/describe-name: v%{version}/g" .git_archival.txt
-fi
+%autosetup -n %{pypi_name}-%{version}
 
 %generate_buildrequires
 %pyproject_buildrequires -x test
@@ -50,7 +44,7 @@ fi
 
 %install
 %pyproject_install
-%pyproject_save_files scikit_build_core
+%pyproject_save_files %{pypi_name}
 
 
 %check
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 7a28d1c..2cf6fcb 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -287,3 +287,51 @@ cmake_extensions=[CMakeExtension("cmake_example")],
 ```
 
 Which should eventually support multiple extensions.
+
+# Downstream packaging
+
+## Fedora packaging
+
+We are using [`packit`](https://packit.dev/) to keep maintain the
+[Fedora package](https://src.fedoraproject.org/rpms/python-scikit-build-core/).
+There are two `packit` jobs one needs to keep in mind here:
+
+- `copr_build`: Submits copr builds to:
+  - `@scikit-build/nightly` if there is a commit to `main`. This is intended for
+    users to test the current development build
+  - `@scikit-build/scikit-build-core` if there is a PR request. This is for CI
+    purposes to confirm that the build is successful
+  - `@scikit-build/release` whenever a new release is published on GitHub. Users
+    can use this to get the latest release before they land on Fedora. This is
+    also used for other copr projects to check the future release
+- `propose_downstream`: Submits a PR to `src.fedoraproject.org` once a release
+  is published
+
+To interact with `packit`, you can use
+[`/packit command`](https://packit.dev/docs/guide/#how-to-re-trigger-packit-actions-in-your-pull-request)
+in PRs and commit messages or [`packit` CLI](https://packit.dev/docs/cli/).
+These interactions are primarily intended for controlling the CI managed on
+`scikit-build`.
+
+To debug and build locally or on your own copr project you may use
+`packit build` commands, e.g. to build locally using `mock` for fedora rawhide:
+
+```console
+$ packit build in-mock -r /etc/mock/fedora-rawhide-x86_64.cfg
+```
+
+or for copr project `copr_user/scikit-build-core`:
+
+```console
+$ copr-cli edit-permissions --builder=packit copr_user/scikit-build-core
+$ packit build in-copr --owner copr_user --project scikit-build-core
+```
+
+(Here we are making sure `packit` has the appropriate permission for
+`copr_user/scikit-build-core` via the `copr-cli` command. You may need to
+configure [`~/.config/copr`](https://packit.dev/docs/cli/build/copr/)) first
+
+Both of these methods automatically edit the `Version` in the
+[spec file](../.dist/scikit-build-core.spec), therefore it is intentionally
+marked as `0.0.0` there to avoid manually updating. Make sure to not push these
+changes in a PR.
diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml
index 47157f0..8ca8eb0 100644
--- a/.github/workflows/cd.yml
+++ b/.github/workflows/cd.yml
@@ -24,7 +24,7 @@ jobs:
         with:
           path: dist/*
 
-      - uses: pypa/gh-action-pypi-publish@v1.8.4
+      - uses: pypa/gh-action-pypi-publish@v1.8.5
         if: github.event_name == 'release' && github.event.action == 'published'
         with:
           password: ${{ secrets.pypi_password }}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 28ad3d1..79e45fb 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -85,6 +85,12 @@ jobs:
           pip install .[test,cov] cmake ninja rich hatch-fancy-pypi-readme
           setuptools-scm
 
+      - name: Add NumPy
+        if: >
+          (matrix.runs-on == 'ubuntu-latest' || matrix.python-version !=
+          'pypy-3.8') && matrix.python-version != '3.12-dev'
+        run: "pip install --only-binary=:all: numpy"
+
       - name: Test package
         run: pytest -ra --showlocals --cov=scikit_build_core
 
@@ -105,7 +111,9 @@ jobs:
         with:
           cmake-version: "${{ matrix.cmake-version }}"
 
+      # Skipped on pypy to keep the tests fast
       - name: Test min package
+        if: matrix.python-version != 'pypy-3.8'
         run: pytest -ra --showlocals
 
   cygwin:
@@ -121,8 +129,7 @@ jobs:
         with:
           platform: x86_64
           packages:
-            cmake ninja git make gcc-g++ gcc-fortran python39 python39-devel
-            python39-pip
+            cmake ninja git make gcc-g++ python39 python39-devel python39-pip
 
       - name: Install
         run: python3.9 -m pip install .[test]
@@ -131,7 +138,7 @@ jobs:
         run: python3.9 -m pytest -ra --showlocals -m "not virtualenv"
 
   msys:
-    name: Tests on 🐍 3 • msys
+    name: Tests on 🐍 3 • msys UCRT
     runs-on: windows-latest
 
     defaults:
@@ -141,13 +148,13 @@ jobs:
     steps:
       - uses: msys2/setup-msys2@v2
         with:
-          msystem: msys
+          msystem: UCRT64
           path-type: minimal
           update: true
           install: >-
             base-devel git
           pacboy: >-
-            python python-pip gcc cmake
+            python:p python-pip:p gcc:p cmake:p
 
       - uses: actions/checkout@v3
         with:
@@ -157,7 +164,7 @@ jobs:
         run: python -m pip install .[test]
 
       - name: Test package
-        run: python -m pytest -ra --showlocals
+        run: python -m pytest -ra --showlocals -m "not broken_on_urct"
 
   mingw64:
     name: Tests on 🐍 3 • mingw64
diff --git a/.gitignore b/.gitignore
index 8cc0dc8..aabfd9a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -153,7 +153,9 @@ tests/**/build/
 *.swp
 
 # RPM spec file
-!/scikit-build-core.spec
+!/.distro/scikit-build-core.spec
+/.distro/*.tar.gz
+*.rpm
 
 # ruff
 .ruff_cache/
diff --git a/.packit.yaml b/.packit.yaml
new file mode 100644
index 0000000..786d682
--- /dev/null
+++ b/.packit.yaml
@@ -0,0 +1,57 @@
+# See the documentation for more information:
+# https://packit.dev/docs/configuration/
+
+specfile_path: .distro/scikit-build-core.spec
+
+files_to_sync:
+  - src: .distro/scikit-build-core.spec
+    dest: python-scikit-build-core.spec
+  - .packit.yaml
+  - src: .distro/python-scikit-build-core.rpmlintrc
+    dest: python-scikit-build-core.rpmlintrc
+upstream_package_name: scikit_build_core
+downstream_package_name: python-scikit-build-core
+update_release: false
+upstream_tag_template: v{version}
+
+jobs:
+  - job: copr_build
+    trigger: pull_request
+    owner: "@scikit-build"
+    project: scikit-build-core
+    update_release: true
+    release_suffix: "{PACKIT_RPMSPEC_RELEASE}"
+    targets:
+      - fedora-development
+  - job: copr_build
+    trigger: commit
+    branch: main
+    owner: "@scikit-build"
+    project: nightly
+    targets:
+      - fedora-development-x86_64
+      - fedora-latest-x86_64
+      - fedora-development-aarch64
+      - fedora-latest-aarch64
+  - job: copr_build
+    trigger: release
+    owner: "@scikit-build"
+    project: release
+    targets:
+      - fedora-development-x86_64
+      - fedora-latest-x86_64
+      - fedora-development-aarch64
+      - fedora-latest-aarch64
+  - job: propose_downstream
+    trigger: release
+    dist_git_branches:
+      - fedora-development
+      - fedora-latest
+  - job: koji_build
+    trigger: commit
+    dist_git_branches:
+      - fedora-all
+  - job: bodhi_update
+    trigger: commit
+    dist_git_branches:
+      - fedora-branched
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index addb1cd..b5aa6c1 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -54,7 +54,7 @@ repos:
         exclude: "^tests"
 
   - repo: https://github.com/charliermarsh/ruff-pre-commit
-    rev: v0.0.259
+    rev: v0.0.260
     hooks:
       - id: ruff
         args: ["--fix", "--show-fixes"]
diff --git a/.tito/packages/.readme b/.tito/packages/.readme
deleted file mode 100644
index b9411e2..0000000
--- a/.tito/packages/.readme
+++ /dev/null
@@ -1,3 +0,0 @@
-the .tito/packages directory contains metadata files
-named after their packages. Each file has the latest tagged
-version and the project's relative directory.
diff --git a/.tito/tito.props b/.tito/tito.props
deleted file mode 100644
index eab3f19..0000000
--- a/.tito/tito.props
+++ /dev/null
@@ -1,5 +0,0 @@
-[buildconfig]
-builder = tito.builder.Builder
-tagger = tito.tagger.VersionTagger
-changelog_do_not_remove_cherrypick = 0
-changelog_format = %s (%ae)
diff --git a/docs/configuration.md b/docs/configuration.md
index e59d4e0..cddc243 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -372,8 +372,11 @@ CMAKE_ARGS: -DSOME_DEFINE=ON;-DOTHER=OFF
 
 ## Dynamic metadata (WIP)
 
-Scikit-build-core 0.3.0 will support dynamic metadata. This is experimental
-until a release. The public interface for making a plugin is very experimental.
+Scikit-build-core 0.3.0 will support dynamic metadata. This is not ready for
+plugin development outside of scikit-build-core;
+`tool.scikit-build.expiremental=true` is required to use external plugins, since
+the interface is provisional. Nested arbitrary dicts (as seen here) are not
+supported in config-settings or environment variables.
 
 There currently are two built-in plugins for dynamic metadata.
 
@@ -388,7 +391,7 @@ name = "mypackage"
 dynamic = ["version"]
 
 [tool.scikit-build]
-metadata.version = "scikit_build_core.metadata.setuptools_scm"
+metadata.version.provider = "scikit_build_core.metadata.setuptools_scm"
 ```
 
 This sets the python project version according to
@@ -425,7 +428,7 @@ name = "mypackage"
 dynamic = ["readme"]
 
 [tool.scikit-build]
-metadata.readme = "scikit_build_core.metadata.fancy_pypi_readme"
+metadata.readme.provider = "scikit_build_core.metadata.fancy_pypi_readme"
 ```
 
 :::
diff --git a/noxfile.py b/noxfile.py
index 7c0f26b..eee0acd 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -46,6 +46,8 @@ def tests(session: nox.Session) -> None:
         extra.append("cmake")
     if shutil.which("ninja") is None:
         extra.append("ninja")
+    if (3, 8) <= sys.version_info < (3, 12):
+        extra.append("numpy")
 
     session.install("-e.[test]", *extra)
     session.run("pytest", *session.posargs, env=env)
diff --git a/pyproject.toml b/pyproject.toml
index 9e14d12..09519ad 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -55,7 +55,6 @@ test = [
     "cattrs >=22.2.0",
     "gitpython",
     "importlib_metadata; python_version<'3.8'",
-    "numpy; python_version>='3.8' and python_version<'3.12'",
     "pathspec >=0.10.1",
     "pybind11",
     "pyproject-metadata >=0.5",
@@ -108,13 +107,14 @@ filterwarnings = [
 log_cli_level = "info"
 testpaths = ["tests"]
 markers = [
+    "broken_on_urct: Broken for now due to lib not found",
     "compile: Compiles code",
     "configure: Configures CMake code",
     "fortran: Fortran code",
     "integration: Full package build",
+    "isolated: Needs an isolated virtualenv",
     "setuptools: Tests setuptools integration",
     "virtualenv: Needs a virtualenv",
-    "isolated: Needs an isolated virtualenv",
 ]
 
 
diff --git a/src/scikit_build_core/build/wheel.py b/src/scikit_build_core/build/wheel.py
index 31619e3..90d140e 100644
--- a/src/scikit_build_core/build/wheel.py
+++ b/src/scikit_build_core/build/wheel.py
@@ -73,7 +73,7 @@ def _build_wheel_impl(
 
     settings_reader.validate_may_exit()
 
-    metadata = get_standard_metadata(pyproject, settings, config_settings)
+    metadata = get_standard_metadata(pyproject, settings)
 
     if metadata.version is None:
         msg = "project.version is not statically specified, must be present currently"
diff --git a/src/scikit_build_core/builder/get_requires.py b/src/scikit_build_core/builder/get_requires.py
index c199508..1d8c25b 100644
--- a/src/scikit_build_core/builder/get_requires.py
+++ b/src/scikit_build_core/builder/get_requires.py
@@ -2,7 +2,6 @@ from __future__ import annotations
 
 import dataclasses
 import functools
-import importlib
 import os
 import sys
 from collections.abc import Generator, Mapping
@@ -20,6 +19,7 @@ from ..program_search import (
     get_ninja_programs,
 )
 from ..resources import resources
+from ..settings._load_provider import load_provider
 from ..settings.skbuild_read_settings import SettingsReader
 
 __all__ = ["GetRequires"]
@@ -40,15 +40,6 @@ def is_known_platform(platforms: frozenset[str]) -> bool:
     return any(tag.platform in platforms for tag in sys_tags())
 
 
-def _load_get_requires_hook(
-    mod_name: str,
-    config_settings: Mapping[str, list[str] | str] | None = None,
-) -> list[str]:
-    module = importlib.import_module(mod_name)
-    hook = getattr(module, "get_requires_for_dynamic_metadata", None)
-    return [] if hook is None else hook(config_settings)  # type: ignore[no-any-return]
-
-
 @dataclasses.dataclass
 class GetRequires:
     config_settings: Mapping[str, list[str] | str] | None = None
@@ -103,5 +94,12 @@ class GetRequires:
         yield f"ninja>={ninja_min}"
 
     def dynamic_metadata(self) -> Generator[str, None, None]:
-        for plugins in self._settings.metadata.values():
-            yield from _load_get_requires_hook(plugins, self.config_settings)
+        for dynamic_metadata in self._settings.metadata.values():
+            if "provider" in dynamic_metadata:
+                config = dynamic_metadata.copy()
+                provider = config.pop("provider")
+                provider_path = config.pop("provider-path", None)
+                module = load_provider(provider, provider_path)
+                yield from getattr(
+                    module, "get_requires_for_dynamic_metadata", lambda _: []
+                )(config)
diff --git a/src/scikit_build_core/builder/sysconfig.py b/src/scikit_build_core/builder/sysconfig.py
index add693f..862564d 100644
--- a/src/scikit_build_core/builder/sysconfig.py
+++ b/src/scikit_build_core/builder/sysconfig.py
@@ -65,6 +65,9 @@ def get_python_library(env: Mapping[str, str], *, abi3: bool = False) -> Path |
             libpath = libdir / ldlibrary
             if Path(os.path.expandvars(libpath)).is_file():
                 return libpath
+            logger.warning("libdir/ldlibrary: {} is not a real file!", libpath)
+        else:
+            logger.warning("libdir: {} is not a directory", libdir)
 
     framework_prefix = sysconfig.get_config_var("PYTHONFRAMEWORKPREFIX")
     if framework_prefix and Path(framework_prefix).is_dir() and ldlibrary:
diff --git a/src/scikit_build_core/metadata/fancy_pypi_readme.py b/src/scikit_build_core/metadata/fancy_pypi_readme.py
index b0ba5d0..7b2953b 100644
--- a/src/scikit_build_core/metadata/fancy_pypi_readme.py
+++ b/src/scikit_build_core/metadata/fancy_pypi_readme.py
@@ -1,6 +1,8 @@
 from __future__ import annotations
 
-from typing import Any
+from pathlib import Path
+
+from .._compat import tomllib
 
 __all__ = ["dynamic_metadata"]
 
@@ -10,12 +12,23 @@ def __dir__() -> list[str]:
 
 
 def dynamic_metadata(
-    pyproject_dict: dict[str, Any],
-    _config_settings: dict[str, list[str] | str] | None = None,
+    fields: frozenset[str],
+    settings: dict[str, list[str] | str] | None = None,
 ) -> dict[str, str | dict[str, str | None]]:
     from hatch_fancy_pypi_readme._builder import build_text
     from hatch_fancy_pypi_readme._config import load_and_validate_config
 
+    if fields != {"readme"}:
+        msg = "Only the 'readme' field is supported"
+        raise ValueError(msg)
+
+    if settings:
+        msg = "No inline configuration is supported"
+        raise ValueError(msg)
+
+    with Path("pyproject.toml").open("rb") as f:
+        pyproject_dict = tomllib.load(f)
+
     config = load_and_validate_config(
         pyproject_dict["tool"]["hatch"]["metadata"]["hooks"]["fancy-pypi-readme"]
     )
@@ -29,6 +42,6 @@ def dynamic_metadata(
 
 
 def get_requires_for_dynamic_metadata(
-    _config_settings: dict[str, list[str] | str] | None = None,
+    _settings: dict[str, object] | None = None,
 ) -> list[str]:
     return ["hatch-fancy-pypi-readme"]
diff --git a/src/scikit_build_core/metadata/setuptools_scm.py b/src/scikit_build_core/metadata/setuptools_scm.py
index 1988c83..2f902ef 100644
--- a/src/scikit_build_core/metadata/setuptools_scm.py
+++ b/src/scikit_build_core/metadata/setuptools_scm.py
@@ -8,11 +8,20 @@ def __dir__() -> list[str]:
 
 
 def dynamic_metadata(
-    pyproject_dict: dict[str, object],  # noqa: ARG001
-    _config_settings: dict[str, list[str] | str] | None = None,
+    fields: frozenset[str],
+    settings: dict[str, object] | None = None,
 ) -> dict[str, str | dict[str, str | None]]:
     # this is a classic implementation, waiting for the release of
     # vcs-versioning and an improved public interface
+
+    if fields != {"version"}:
+        msg = "Only the 'version' field is supported"
+        raise ValueError(msg)
+
+    if settings:
+        msg = "No inline configuration is supported"
+        raise ValueError(msg)
+
     from setuptools_scm import Configuration, _get_version
 
     config = Configuration.from_file("pyproject.toml")
@@ -22,6 +31,6 @@ def dynamic_metadata(
 
 
 def get_requires_for_dynamic_metadata(
-    _config_settings: dict[str, list[str] | str] | None = None,
+    _settings: dict[str, object] | None = None,
 ) -> list[str]:
     return ["setuptools-scm"]
diff --git a/src/scikit_build_core/settings/_load_provider.py b/src/scikit_build_core/settings/_load_provider.py
new file mode 100644
index 0000000..b735a2b
--- /dev/null
+++ b/src/scikit_build_core/settings/_load_provider.py
@@ -0,0 +1,45 @@
+from __future__ import annotations
+
+import importlib
+import sys
+from collections.abc import Iterable
+from pathlib import Path
+from typing import Any
+
+from .._compat.typing import Protocol
+
+__all__ = ["load_provider"]
+
+
+def __dir__() -> list[str]:
+    return __all__
+
+
+class DynamicMetadataProtocol(Protocol):
+    def dynamic_metadata(
+        self, fields: Iterable[str], settings: dict[str, Any]
+    ) -> dict[str, Any]:
+        ...
+
+
+class DynamicMetadataRequirementsProtocol(DynamicMetadataProtocol, Protocol):
+    def get_requires_for_dynamic_metadata(self, settings: dict[str, Any]) -> list[str]:
+        ...
+
+
+def load_provider(
+    provider: str,
+    provider_path: str | None = None,
+) -> DynamicMetadataProtocol | DynamicMetadataRequirementsProtocol:
+    if provider_path is None:
+        return importlib.import_module(provider)
+
+    if not Path(provider_path).is_dir():
+        msg = "provider-path must be an existing directory"
+        raise AssertionError(msg)
+
+    try:
+        sys.path.insert(0, provider_path)
+        return importlib.import_module(provider)
+    finally:
+        sys.path.pop(0)
diff --git a/src/scikit_build_core/settings/metadata.py b/src/scikit_build_core/settings/metadata.py
index 184da92..f0462ca 100644
--- a/src/scikit_build_core/settings/metadata.py
+++ b/src/scikit_build_core/settings/metadata.py
@@ -1,11 +1,11 @@
 from __future__ import annotations
 
-import importlib
 from typing import Any
 
 from pyproject_metadata import StandardMetadata
 
 from ..settings.skbuild_model import ScikitBuildSettings
+from ._load_provider import load_provider
 
 __all__ = ["get_standard_metadata"]
 
@@ -14,37 +14,34 @@ def __dir__() -> list[str]:
     return __all__
 
 
-def _load(
-    mod_name: str,
-    pyproject_dict: dict[str, Any],
-    config_settings: dict[str, list[str] | str] | None = None,
-) -> dict[str, Any]:
-    return importlib.import_module(mod_name).dynamic_metadata(pyproject_dict, config_settings)  # type: ignore[no-any-return]
-
-
 # If pyproject-metadata eventually supports updates, this can be simplified
 def get_standard_metadata(
     pyproject_dict: dict[str, Any],
     settings: ScikitBuildSettings,
-    config_settings: dict[str, list[str] | str] | None = None,
 ) -> StandardMetadata:
     # Handle any dynamic metadata
-    for field in settings.metadata:
+    calls: dict[frozenset[tuple[str, Any]], set[str]] = {}
+    for field, raw_settings in settings.metadata.items():
         if field not in pyproject_dict.get("project", {}).get("dynamic", []):
             msg = f"{field} is not in project.dynamic"
             raise KeyError(msg)
-
-    plugins = set(settings.metadata.values())
-    cached_plugins = {
-        key: _load(key, pyproject_dict, config_settings) for key in plugins
-    }
-
-    for field, mod_name in settings.metadata.items():
-        if field not in cached_plugins[mod_name]:
-            msg = f"{field} is not provided by plugin {mod_name}"
+        if "provider" not in raw_settings:
+            msg = f"{field} is missing provider"
             raise KeyError(msg)
-
-        pyproject_dict["project"][field] = cached_plugins[mod_name][field]
-        pyproject_dict["project"]["dynamic"].remove(field)
+        calls.setdefault(frozenset(raw_settings.items()), set()).add(field)
+
+    for call, fields in calls.items():
+        args = dict(call)
+        provider = args.pop("provider")
+        provider_path = args.pop("provider-path", None)
+        computed = load_provider(provider, provider_path).dynamic_metadata(
+            frozenset(fields), args
+        )
+        if set(computed) != fields:
+            msg = f"{provider} did not return requested fields"
+            raise KeyError(msg)
+        pyproject_dict["project"].update(computed)
+        for field in fields:
+            pyproject_dict["project"]["dynamic"].remove(field)
 
     return StandardMetadata.from_pyproject(pyproject_dict)
diff --git a/src/scikit_build_core/settings/skbuild_model.py b/src/scikit_build_core/settings/skbuild_model.py
index bc98f96..613d20d 100644
--- a/src/scikit_build_core/settings/skbuild_model.py
+++ b/src/scikit_build_core/settings/skbuild_model.py
@@ -1,5 +1,5 @@
 import dataclasses
-from typing import Dict, List, Optional
+from typing import Any, Dict, List, Optional
 
 __all__ = [
     "ScikitBuildSettings",
@@ -110,7 +110,7 @@ class ScikitBuildSettings:
     sdist: SDistSettings = dataclasses.field(default_factory=SDistSettings)
     wheel: WheelSettings = dataclasses.field(default_factory=WheelSettings)
     backport: BackportSettings = dataclasses.field(default_factory=BackportSettings)
-    metadata: Dict[str, str] = dataclasses.field(default_factory=dict)
+    metadata: Dict[str, Dict[str, Any]] = dataclasses.field(default_factory=dict)
 
     #: Strictly check all config options. If False, warnings will be
     #: printed for unknown options. If True, an error will be raised.
diff --git a/src/scikit_build_core/settings/skbuild_read_settings.py b/src/scikit_build_core/settings/skbuild_read_settings.py
index 1a65fe8..bb04053 100644
--- a/src/scikit_build_core/settings/skbuild_read_settings.py
+++ b/src/scikit_build_core/settings/skbuild_read_settings.py
@@ -35,6 +35,7 @@ class SettingsReader:
             EnvSource("SKBUILD"),
             ConfSource(settings=config_settings, verify=verify_conf),
             TOMLSource("tool", "scikit-build", settings=pyproject),
+            prefixes=["tool", "scikit-build"],
         )
         self.settings = self.sources.convert_target(ScikitBuildSettings)
 
@@ -86,6 +87,23 @@ class SettingsReader:
                 raise SystemExit(7)
             logger.warning("Unrecognized options: {}", ", ".join(unrecognized))
 
+        for key, value in self.settings.metadata.items():
+            if "provider" not in value:
+                sys.stdout.flush()
+                rich_print(
+                    f"[red][bold]ERROR:[/bold] provider= must be provided in {key!r}:"
+                )
+                raise SystemExit(7)
+            if not self.settings.experimental and (
+                "provider-path" in value
+                or not value["provider"].startswith("scikit_build_core.")
+            ):
+                sys.stdout.flush()
+                rich_print(
+                    "[red][bold]ERROR:[/bold] experimental must be enabled currently to use plugins not provided by scikit-build-core"
+                )
+                raise SystemExit(7)
+
     @classmethod
     def from_file(
         cls,
diff --git a/src/scikit_build_core/settings/sources.py b/src/scikit_build_core/settings/sources.py
index 7d7b385..61eaa04 100644
--- a/src/scikit_build_core/settings/sources.py
+++ b/src/scikit_build_core/settings/sources.py
@@ -121,7 +121,7 @@ def _nested_dataclass_to_names(target: type[Any], *inner: str) -> Iterator[list[
 class Source(Protocol):
     def has_item(self, *fields: str, is_dict: bool) -> bool:
         """
-        Check if the source contains a chain of fields. For example, feilds =
+        Check if the source contains a chain of fields. For example, fields =
         [Field(name="a"), Field(name="b")] will check if the source contains the
         key "a.b".
         """
@@ -276,16 +276,18 @@ class ConfSource:
         if raw_target == list:
             if isinstance(item, list):
                 return [cls.convert(i, _get_inner_type(target)) for i in item]
-            assert not isinstance(item, dict)
+            if isinstance(item, dict):
+                msg = f"Expected {target}, got {type(item).__name__}"
+                raise TypeError(msg)
             return [
                 cls.convert(i.strip(), _get_inner_type(target)) for i in item.split(";")
             ]
         if raw_target == dict:
             assert not isinstance(item, (str, list))
             return {k: cls.convert(v, _get_inner_type(target)) for k, v in item.items()}
-        assert not isinstance(
-            item, (list, dict)
-        ), "Can't convert list or dict to non-list/dict"
+        if isinstance(item, (list, dict)):
+            msg = f"Expected {target}, got {type(item).__name__}"
+            raise TypeError(msg)
         if raw_target is bool:
             result = item.strip().lower() not in {"0", "false", "off", "no", ""}
             return result
@@ -347,9 +349,17 @@ class TOMLSource:
     def convert(cls, item: Any, target: type[Any]) -> object:
         raw_target = _get_target_raw_type(target)
         if raw_target == list:
+            if not isinstance(item, list):
+                msg = f"Expected {target}, got {type(item).__name__}"
+                raise TypeError(msg)
             return [cls.convert(it, _get_inner_type(target)) for it in item]
         if raw_target == dict:
+            if not isinstance(item, dict):
+                msg = f"Expected {target}, got {type(item).__name__}"
+                raise TypeError(msg)
             return {k: cls.convert(v, _get_inner_type(target)) for k, v in item.items()}
+        if raw_target == Any:
+            return item
         if callable(raw_target):
             return raw_target(item)
         msg = f"Can't convert target {target}"
@@ -365,8 +375,14 @@ class TOMLSource:
 
 
 class SourceChain:
-    def __init__(self, *sources: Source) -> None:
+    def __init__(self, *sources: Source, prefixes: Sequence[str] = ()) -> None:
+        """
+        Combine a collection of sources into a single object that can run
+        ``convert_target(dataclass)``.  An optional list of prefixes can be
+        given that will be prepended (dot separated) to error messages.
+        """
         self.sources = sources
+        self.prefixes = prefixes
 
     def __getitem__(self, index: int) -> Source:
         return self.sources[index]
@@ -387,6 +403,11 @@ class SourceChain:
         raise NotImplementedError(msg)
 
     def convert_target(self, target: type[T], *prefixes: str) -> T:
+        """
+        Given a dataclass type, create an object of that dataclass filled
+        with the values in the sources.
+        """
+
         errors = []
         prep: dict[str, Any] = {}
         for field in dataclasses.fields(target):  # type: ignore[arg-type]
@@ -396,6 +417,8 @@ class SourceChain:
                         field.type, *prefixes, field.name
                     )
                 except Exception as e:
+                    name = ".".join([*self.prefixes, *prefixes, field.name])
+                    e.__notes__ = [*getattr(e, "__notes__", []), f"Field: {name}"]  # type: ignore[attr-defined]
                     errors.append(e)
                 continue
 
@@ -407,6 +430,8 @@ class SourceChain:
                     try:
                         tmp = source.convert(simple, field.type)
                     except Exception as e:
+                        name = ".".join([*self.prefixes, *prefixes, field.name])
+                        e.__notes__ = [*getattr(e, "__notes__", []), f"Field {name}"]  # type: ignore[attr-defined]
                         errors.append(e)
                         prep[field.name] = None
                         break
@@ -432,7 +457,7 @@ class SourceChain:
             errors.append(ValueError(f"Missing value for {field.name!r}"))
 
         if errors:
-            prefix_str = ".".join(prefixes)
+            prefix_str = ".".join([*self.prefixes, *prefixes])
             msg = f"Failed converting {prefix_str}"
             raise ExceptionGroup(msg, errors)
 


Test patch:
diff --git a/tests/packages/dynamic_metadata/dual_project.toml b/tests/packages/dynamic_metadata/dual_project.toml
index f977a0a..7cd743c 100644
--- a/tests/packages/dynamic_metadata/dual_project.toml
+++ b/tests/packages/dynamic_metadata/dual_project.toml
@@ -6,6 +6,7 @@ build-backend = "scikit_build_core.build"
 name = "fancy"
 dynamic = ["version", "license"]
 
-[tool.scikit-build.metadata]
-version = "test_dual"
-license = "test_dual"
+[tool.scikit-build]
+experimental = true
+metadata.version.provider = "test_dual"
+metadata.license.provider = "test_dual"
diff --git a/tests/packages/dynamic_metadata/faulty_dual_project.toml b/tests/packages/dynamic_metadata/faulty_dual_project.toml
index 87797e3..fa0ee1b 100644
--- a/tests/packages/dynamic_metadata/faulty_dual_project.toml
+++ b/tests/packages/dynamic_metadata/faulty_dual_project.toml
@@ -6,7 +6,8 @@ build-backend = "scikit_build_core.build"
 name = "fancy"
 dynamic = ["version", "readme", "license"]
 
-[tool.scikit-build.metadata]
-version = "test_dual"
-license = "test_dual"
-readme = "test_dual"
+[tool.scikit-build]
+experimental = true
+metadata.version.provider = "test_dual"
+metadata.license.provider = "test_dual"
+metadata.readme.provider = "test_dual"
diff --git a/tests/packages/dynamic_metadata/faulty_project.toml b/tests/packages/dynamic_metadata/faulty_project.toml
index dff81f0..7a02908 100644
--- a/tests/packages/dynamic_metadata/faulty_project.toml
+++ b/tests/packages/dynamic_metadata/faulty_project.toml
@@ -7,4 +7,4 @@ name = "fancy"
 version = "0.0.1"
 
 [tool.scikit-build.metadata]
-readme = "scikit_build_core.metadata.fancy_pypi_readme"
+readme.provider = "scikit_build_core.metadata.fancy_pypi_readme"
diff --git a/tests/packages/dynamic_metadata/local_pyproject.toml b/tests/packages/dynamic_metadata/local_pyproject.toml
new file mode 100644
index 0000000..5ddbf26
--- /dev/null
+++ b/tests/packages/dynamic_metadata/local_pyproject.toml
@@ -0,0 +1,14 @@
+[build-system]
+requires = ["scikit-build-core"]
+build-backend = "scikit_build_core.build"
+
+[project]
+name = "dynamic"
+dynamic = ["version"]
+
+[tool.scikit-build]
+experimental = true
+
+[tool.scikit-build.metadata.version]
+provider = "version.nested"
+provider-path = "plugins/local"
diff --git a/tests/packages/dynamic_metadata/plugin_project.toml b/tests/packages/dynamic_metadata/plugin_project.toml
index 2503908..d460feb 100644
--- a/tests/packages/dynamic_metadata/plugin_project.toml
+++ b/tests/packages/dynamic_metadata/plugin_project.toml
@@ -7,8 +7,8 @@ name = "fancy"
 dynamic = ["readme", "version"]
 
 [tool.scikit-build.metadata]
-version = "scikit_build_core.metadata.setuptools_scm"
-readme = "scikit_build_core.metadata.fancy_pypi_readme"
+version.provider = "scikit_build_core.metadata.setuptools_scm"
+readme.provider = "scikit_build_core.metadata.fancy_pypi_readme"
 
 [tool.setuptools_scm]
 
diff --git a/tests/packages/dynamic_metadata/plugins/local/version/__init__.py b/tests/packages/dynamic_metadata/plugins/local/version/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/packages/dynamic_metadata/plugins/local/version/nested/__init__.py b/tests/packages/dynamic_metadata/plugins/local/version/nested/__init__.py
new file mode 100644
index 0000000..f68ad03
--- /dev/null
+++ b/tests/packages/dynamic_metadata/plugins/local/version/nested/__init__.py
@@ -0,0 +1,22 @@
+from __future__ import annotations
+
+__all__ = ["dynamic_metadata"]
+
+
+def __dir__() -> list[str]:
+    return __all__
+
+
+def dynamic_metadata(
+    fields: frozenset[str],
+    settings: dict[str, object] | None = None,
+) -> dict[str, str | dict[str, str | None]]:
+    if fields != {"version"}:
+        msg = "Only the 'version' field is supported"
+        raise ValueError(msg)
+
+    if settings:
+        msg = "No inline configuration is supported"
+        raise ValueError(msg)
+
+    return {"version": "3.2.1"}
diff --git a/tests/packages/dynamic_metadata/pyproject.toml b/tests/packages/dynamic_metadata/pyproject.toml
index 4cf1654..a59cb33 100644
--- a/tests/packages/dynamic_metadata/pyproject.toml
+++ b/tests/packages/dynamic_metadata/pyproject.toml
@@ -6,7 +6,8 @@ build-backend = "scikit_build_core.build"
 name = "dynamic"
 dynamic = ["version", "readme", "license"]
 
-[tool.scikit-build.metadata]
-version = "test_version"
-readme = "test_readme"
-license = "test_license"
+[tool.scikit-build]
+experimental = true
+metadata.version.provider = "test_version"
+metadata.readme.provider = "test_readme"
+metadata.license.provider = "test_license"
diff --git a/tests/packages/dynamic_metadata/warn_project.toml b/tests/packages/dynamic_metadata/warn_project.toml
index 4074ccc..4b8f04a 100644
--- a/tests/packages/dynamic_metadata/warn_project.toml
+++ b/tests/packages/dynamic_metadata/warn_project.toml
@@ -7,5 +7,6 @@ name = "fancy"
 version = "0.0.1"
 dynamic = ["readme"]
 
-[tool.scikit-build.metadata]
-readme = "non_existent"
+[tool.scikit-build]
+experimental = true
+metadata.readme.provider = "non_existent"
diff --git a/tests/test_dynamic_metadata.py b/tests/test_dynamic_metadata.py
index 197d062..a408997 100644
--- a/tests/test_dynamic_metadata.py
+++ b/tests/test_dynamic_metadata.py
@@ -11,6 +11,7 @@ from typing import Any
 import git
 import pyproject_metadata
 import pytest
+from packaging.version import Version
 
 from scikit_build_core._compat import tomllib
 from scikit_build_core.build import build_wheel
@@ -26,16 +27,18 @@ DYNAMIC = DIR / "packages/dynamic_metadata"
 # it turns out to be easier to create EntryPoint objects pointing to real
 # functions than to mock them.
 def ep_version(
-    _pyproject_dict: dict[str, Any],
-    _config_settings: dict[str, list[str] | str] | None = None,
+    fields: frozenset[str],
+    _settings: dict[str, object] | None = None,
 ) -> dict[str, str | dict[str, str | None]]:
+    assert fields == {"version"}
     return {"version": "0.0.2"}
 
 
 def ep_readme(
-    _pyproject_dict: dict[str, Any],
-    _config_settings: dict[str, list[str] | str] | None = None,
+    fields: frozenset[str],
+    _settings: dict[str, object] | None = None,
 ) -> dict[str, str | dict[str, str | None]]:
+    assert fields == {"readme"}
     return {
         "readme": {
             "content-type": "text/x-rst",
@@ -45,16 +48,18 @@ def ep_readme(
 
 
 def ep_license(
-    _pyproject_dict: dict[str, Any],
-    _config_settings: dict[str, list[str] | str] | None = None,
+    fields: frozenset[str],
+    _settings: dict[str, object] | None = None,
 ) -> dict[str, str | dict[str, str | None]]:
+    assert fields == {"license"}
     return {"license": {"text": "MIT License"}}
 
 
 def ep_dual(
-    _pyproject_dict: dict[str, Any],
-    _config_settings: dict[str, list[str] | str] | None = None,
+    _fields: list[str],
+    _settings: dict[str, object] | None = None,
 ) -> dict[str, str | dict[str, str | None]]:
+    # Fields intentionally not checked to verify backend error thrown
     return {
         "version": "0.3",
         "license": {"text": "BSD License"},
@@ -162,6 +167,20 @@ def test_faulty_metadata(monkeypatch):
         get_standard_metadata(pyproject, settings)
 
 
+def test_local_plugin_metadata(monkeypatch):
+    monkeypatch.chdir(DYNAMIC)
+
+    with Path("local_pyproject.toml").open("rb") as ft:
+        pyproject = tomllib.load(ft)
+    settings_reader = SettingsReader(pyproject, {})
+    settings = settings_reader.settings
+
+    settings_reader.validate_may_exit()
+
+    metadata = get_standard_metadata(pyproject, settings)
+    assert metadata.version == Version("3.2.1")
+
+
 def test_warn_metadata(monkeypatch):
     monkeypatch.chdir(DYNAMIC)
     with Path("warn_project.toml").open("rb") as ft:
@@ -175,6 +194,18 @@ def test_warn_metadata(monkeypatch):
         get_standard_metadata(pyproject, settings)
 
 
+def test_fail_experimental_metadata(monkeypatch):
+    monkeypatch.chdir(DYNAMIC)
+    with Path("warn_project.toml").open("rb") as ft:
+        pyproject = tomllib.load(ft)
+    settings_reader = SettingsReader(pyproject, {"experimental": "false"})
+
+    with pytest.raises(SystemExit) as exc:
+        settings_reader.validate_may_exit()
+    (value,) = exc.value.args
+    assert value == 7
+
+
 @pytest.mark.usefixtures("mock_entry_points")
 def test_dual_metadata(monkeypatch):
     monkeypatch.chdir(DYNAMIC)
diff --git a/tests/test_settings.py b/tests/test_settings.py
index 6c9475e..179b8ce 100644
--- a/tests/test_settings.py
+++ b/tests/test_settings.py
@@ -1,6 +1,6 @@
 import dataclasses
 from pathlib import Path
-from typing import Dict, List, Optional, Union
+from typing import Any, Dict, List, Optional, Union
 
 import pytest
 
@@ -24,6 +24,8 @@ class SettingChecker:
     seven: Union[int, None] = None
     eight: Dict[str, str] = dataclasses.field(default_factory=dict)
     nine: Dict[str, int] = dataclasses.field(default_factory=dict)
+    # TOML only
+    ten: Dict[str, Any] = dataclasses.field(default_factory=dict)
 
 
 def test_empty(monkeypatch):
@@ -128,6 +130,7 @@ def test_toml():
         "seven": 7,
         "eight": {"one": "one", "two": "two"},
         "nine": {"thing": 8},
+        "ten": {"a": {"b": 3}},
     }
 
     sources = SourceChain(
@@ -147,6 +150,7 @@ def test_toml():
     assert settings.seven == 7
     assert settings.eight == {"one": "one", "two": "two"}
     assert settings.nine == {"thing": 8}
+    assert settings.ten == {"a": {"b": 3}}
 
 
 def test_all_names():
diff --git a/tests/test_setuptools_pep517.py b/tests/test_setuptools_pep517.py
index b112619..a38d971 100644
--- a/tests/test_setuptools_pep517.py
+++ b/tests/test_setuptools_pep517.py
@@ -75,6 +75,7 @@ def test_pep517_sdist(tmp_path, monkeypatch):
 
 @pytest.mark.compile()
 @pytest.mark.configure()
+@pytest.mark.broken_on_urct()
 @pytest.mark.skipif(
     sys.platform.startswith("cygwin"), reason="Cygwin fails here with ld errors"
 )
diff --git a/tests/test_setuptools_pep518.py b/tests/test_setuptools_pep518.py
index 30740da..74c5966 100644
--- a/tests/test_setuptools_pep518.py
+++ b/tests/test_setuptools_pep518.py
@@ -15,6 +15,7 @@ HELLO_PEP518 = DIR / "packages/simple_setuptools_ext"
 @pytest.mark.compile()
 @pytest.mark.configure()
 @pytest.mark.integration()
+@pytest.mark.broken_on_urct()
 @pytest.mark.skipif(
     sys.platform.startswith("cygwin"), reason="Cygwin fails here with ld errors"
 )
@@ -56,6 +57,7 @@ def test_pep518_wheel(tmp_path, monkeypatch, isolated):
 @pytest.mark.compile()
 @pytest.mark.configure()
 @pytest.mark.integration()
+@pytest.mark.broken_on_urct()
 @pytest.mark.skipif(
     sys.platform.startswith("cygwin"), reason="Cygwin fails here with ld errors"
 )
diff --git a/tests/test_skbuild_settings.py b/tests/test_skbuild_settings.py
index b972627..093d6c3 100644
--- a/tests/test_skbuild_settings.py
+++ b/tests/test_skbuild_settings.py
@@ -68,7 +68,6 @@ def test_skbuild_settings_envvar(tmp_path, monkeypatch):
     monkeypatch.setenv("SKBUILD_MINIMUM_VERSION", "0.1")
     monkeypatch.setenv("SKBUILD_CMAKE_VERBOSE", "TRUE")
     monkeypatch.setenv("SKBUILD_BUILD_DIR", "a/b/c")
-    monkeypatch.setenv("SKBUILD_METADATA", "a=b;c=d")
 
     pyproject_toml = tmp_path / "pyproject.toml"
     pyproject_toml.write_text("", encoding="utf-8")
@@ -98,7 +97,7 @@ def test_skbuild_settings_envvar(tmp_path, monkeypatch):
     assert settings.experimental
     assert settings.minimum_version == "0.1"
     assert settings.build_dir == "a/b/c"
-    assert settings.metadata == {"a": "b", "c": "d"}
+    assert settings.metadata == {}
 
 
 def test_skbuild_settings_config_settings(tmp_path, monkeypatch):
@@ -130,8 +129,6 @@ def test_skbuild_settings_config_settings(tmp_path, monkeypatch):
         "experimental": "1",
         "minimum-version": "0.1",
         "build-dir": "a/b/c",
-        "metadata.a": "b",
-        "metadata.c": "d",
     }
 
     settings_reader = SettingsReader.from_file(pyproject_toml, config_settings)
@@ -157,7 +154,7 @@ def test_skbuild_settings_config_settings(tmp_path, monkeypatch):
     assert settings.experimental
     assert settings.minimum_version == "0.1"
     assert settings.build_dir == "a/b/c"
-    assert settings.metadata == {"a": "b", "c": "d"}
+    assert settings.metadata == {}
 
 
 def test_skbuild_settings_pyproject_toml(tmp_path, monkeypatch):
@@ -188,7 +185,7 @@ def test_skbuild_settings_pyproject_toml(tmp_path, monkeypatch):
             experimental = true
             minimum-version = "0.1"
             build-dir = "a/b/c"
-            metadata = {a="b", c="d"}
+            metadata.version.provider = "a"
             """
         ),
         encoding="utf-8",
@@ -219,7 +216,7 @@ def test_skbuild_settings_pyproject_toml(tmp_path, monkeypatch):
     assert settings.experimental
     assert settings.minimum_version == "0.1"
     assert settings.build_dir == "a/b/c"
-    assert settings.metadata == {"a": "b", "c": "d"}
+    assert settings.metadata == {"version": {"provider": "a"}}
 
 
 def test_skbuild_settings_pyproject_toml_broken(tmp_path, capsys):