scikit-build-core 1.0

Scikit-build-core 1.0, the culmination of a four year project to build the best compiled code backend for Python, is out. It’s a huge release, with over 90 bugs fixed, over 40 new features, accounting for over a hundred closed issues. This is the completion of my original vision, and I’m really happy with it. I’d like to show off some of the new features, and also discuss some of the last four months of development that made this possible. This is all in time for SIMPLE-Py, the workshop I’m hosting next week over Python packaging alongside SciPy 2026.

New Features

I’ll lead with new features, as that’s probably most exciting, but a quick precap of the key development processes: I got an OSS subscription to Claude (thanks to Greg Smith at Anthropic!), and this combined with my experience working with Open Source models thanks to the National Research Platform (NRP) enabled a variety of procedures I’ll talk about in the second half of this post. One I’ll be referring to a few times is a categorization issue, part of the work in getting 140 or so open issues down to 20.

Editable installs

A lot of work went into editable installs. I had about 10 issues related to editable installs identified in my categorization issue, and I ended up being able to solve basically all of them. AI really helped here; writing tests for editable installs is really annoying by hand, and it works really well with the machinery gymnastics required to make it work everywhere - it even solved the weird 3.9-only failure that we’ve had to live with (3.8 and 3.10+ were fine). The fixes include:

  • importlib.resources.files() works properly now!
  • __path__ supported properly
  • Namespace packages now handled
  • Subpackages work properly
  • Versioned so-names don’t break things

I won’t go over the fixes for other areas in detail, but wanted to give an idea of the things that now work that didn’t before. We also support PEP 829 .start files when using Python 3.15+

Rebuilds

Rebuilds are still considered experimental, but several things help make them better than before. We now support rebuilds in inplace mode too, we make sure we don’t rebuild multiple times across multiple imports in the same process, and we have a new editable.rebuild-dir setting that allows you to set a persistent rebuild directory, which fixes several caveats that a normal rebuild deals with, like changing absolute install paths.

And then there’s also support for manual rebuilds, regardless of if you have auto rebuilds turned on! It looks like this:

import my_package

my_package.__loader__.rebuild()

CLI - new project init

We now have a new CLI command for quickly starting up a project! It looks like this:

$ uvx scikit-build-core init my_package
Select a binding backend:
  1) pybind11
  2) nanobind
  3) c
  4) cython
  5) swig
  6) fortran
  7) abi3
  8) abi3t
Enter a number [1-8]:

If that list looks a lot like our examples, it is - that’s now powered by our init templates! If you want a fancier package with more setup, you can always use the scientific-python cookie:

uvx cookiecutter gh:scientific-python/cookie

Scikit-build (core) is one of the backend choices there.

Env table

A really powerful new feature is the env table (similar to the define table), which lets you set environment variables. It looks like this:

[tool.scikit-build.env]
CMAKE_BUILD_PARALLEL_LEVEL = { env = "MAX_JOBS" }

The above example maps one environment variable (such as a classic "MAX_JOBS" variable to another. It can take a default value too. This is quite powerful when combined with overrides too, allowing you to customize for different platforms or build situations (note on overlap: overrides’ env key is based on the environment without this set). You can also disable CC/CXX setting from sysconfig by setting CC = { env = "CC" } and CXX = { env = "CXX" }; the auto setting will respect that you are trying to set it, and the table will only actually be set if CC / CXX are set.

Force-include

One of the most common needs is to map arbitrary files into places in the SDist or wheel. Before, you had to resort to using CMake; now you can use a force-include system inspired by hatchling:

[tool.scikit-build.sdist.force-include]
"../shared/data.json" = "mypackage/data.json"

[tool.scikit-build.wheel.force-include]
"tools/run.sh"  = "${SKBUILD_SCRIPTS_DIR}/run.sh"

You can pull values from outside the source, such as in a workspace. You can move files around, including to the special dirs, like the scripts dir in the wheel. The syntax, matching CMake’s variable names, is new and works for install-dir as well.

Of course, this also integrates really well with editable installs; force-include entries are served live in redirect editable installs. We also track through direct wheel installs - so if the wheel includes an SDist item that’s included elsewhere, building directly from source will map the item through the SDist mapping without making an SDist automatically for consistent file inclusion.

Dynamic metadata

The mechanism for dynamic metadata has finally been set up as a generic system other tools can use. This is what the new system looks like:

[[tool.dynamic-metadata]]
provider = "dynamic_metadata.regex"
field = "version"
input = "src/mypackage/__init__.py"
[[tool.dynamic-metadata]]
provider = "scikit_build_core.metadata.regex"
field = "version"
input = "src/mypackage/__init__.py"
[tool.scikit-build.metadata.version]
provider = "scikit_build_core.metadata.regex"
input = "src/mypackage/__init__.py"

This is based on the standardized [[tool.dynamic-metadata]] array-of-tables, which any backend can read; the provider is a name registered in the dynamic_metadata.provider entry-point group. The “Upstream” form uses a provider from the standalone dynamic-metadata package (dynamic_metadata.*), which you need to add to your requirements if using it, while the “Built-in” form uses one of scikit-build-core’s own providers (scikit_build_core.metadata.*). Other plugins will likely add support over time. Before 1.0, dynamic metadata lived in the scikit-build-core-specific [tool.scikit-build.metadata] table, which is now deprecated (and warns unless minimum-version is set below 1.0).

We also support PEP 808 partially-dynamic metadata: a list or table field (like dependencies or optional-dependencies) can have a static value in [project] and also be listed in dynamic, in which case a provider only adds to it. This is emitted as METADATA 2.6 if there are “Dynamic” entries in the SDist (a separate meaning of dynamic, and handled with the wheel_dynamic() hook. Note that METADATA 2.6 is very new, and not supported everywhere yet.

Updated plugins

Both the setuptools and hatchling plugins are improved. The setuptools plugin exists primarily so we can release scikit-build (classic) using scikit-build-core as a backend, but we’ve added inplace editable support, cmake_install_dir and cmake_install_target kwargs, and cmake_process_manifest_hook support. We have adapted the wrapper (which exactly mimics the old scikit-build) with some legacy behaviors that the standard plugin doesn’t need, like support for SKBUILD_CONFIGURE_OPTIONS.

For the hatchling plugin, it is no longer experimental, support editable installs, and fixed several issues. Both plugins expect an extra, such as scikit-build-core[hatchling], and will warn if scikit-build-core is detected but not specified.

Helpers

I also did a lot of work on both f2py-cmake and cython-cmake; those should be much better, more flexible, and faster than before. I’ve moved the examples over to using them.

Here’s what the getting-started examples look like now compared to before (the “1.0” tabs use the new helpers):

Cython

[build-system]
requires = ["scikit-build-core", "cython", "cython-cmake"]
build-backend = "scikit_build_core.build"

[project]
name = "example"
version = "0.0.1"
cmake_minimum_required(VERSION 3.15...4.3)
project(${SKBUILD_PROJECT_NAME} LANGUAGES C)

find_package(
  Python
  COMPONENTS Interpreter Development.Module
  REQUIRED)
find_package(Cython MODULE REQUIRED VERSION 3.0)
include(UseCython)

cython_transpile(src/${SKBUILD_PROJECT_NAME}/_core.pyx LANGUAGE C
                 OUTPUT_VARIABLE _core_c)

python_add_library(_core MODULE "${_core_c}" WITH_SOABI)

install(TARGETS _core DESTINATION ${SKBUILD_PROJECT_NAME})
[build-system]
requires = ["scikit-build-core", "cython"]
build-backend = "scikit_build_core.build"

[project]
name = "example"
version = "0.0.1"
cmake_minimum_required(VERSION 3.15...3.26)
project(${SKBUILD_PROJECT_NAME} LANGUAGES C)

find_package(
  Python
  COMPONENTS Interpreter Development.Module
  REQUIRED)

add_custom_command(
  OUTPUT example.c
  COMMENT
    "Making ${CMAKE_CURRENT_BINARY_DIR}/example.c from ${CMAKE_CURRENT_SOURCE_DIR}/example.pyx"
  COMMAND Python::Interpreter -m cython
          "${CMAKE_CURRENT_SOURCE_DIR}/example.pyx" --output-file example.c
  DEPENDS example.pyx
  VERBATIM)

python_add_library(example MODULE example.c WITH_SOABI)

install(TARGETS example DESTINATION .)

Fortran (f2py)

[build-system]
requires = ["scikit-build-core", "numpy", "f2py-cmake"]
build-backend = "scikit_build_core.build"

[project]
name = "example"
version = "0.0.1"
dependencies = ["numpy"]
cmake_minimum_required(VERSION 3.17...4.3)
project(${SKBUILD_PROJECT_NAME} LANGUAGES C Fortran)

find_package(
  Python
  COMPONENTS Interpreter Development.Module NumPy
  REQUIRED)

include(UseF2Py)

f2py_add_module(_core src/${SKBUILD_PROJECT_NAME}/_core.f)

install(TARGETS _core DESTINATION ${SKBUILD_PROJECT_NAME})
[build-system]
requires = ["scikit-build-core", "numpy"]
build-backend = "scikit_build_core.build"

[project]
name = "example"
version = "0.0.1"
dependencies = ["numpy"]

[tool.scikit-build]
ninja.version = ">=1.10"
cmake.version = ">=3.17.2"
cmake_minimum_required(VERSION 3.17.2...3.29)
project(${SKBUILD_PROJECT_NAME} LANGUAGES C Fortran)

find_package(
  Python
  COMPONENTS Interpreter Development.Module NumPy
  REQUIRED)

# F2PY headers
execute_process(
  COMMAND "${PYTHON_EXECUTABLE}" -c
          "import numpy.f2py; print(numpy.f2py.get_include())"
  OUTPUT_VARIABLE F2PY_INCLUDE_DIR
  OUTPUT_STRIP_TRAILING_WHITESPACE)

add_library(fortranobject OBJECT "${F2PY_INCLUDE_DIR}/fortranobject.c")
target_link_libraries(fortranobject PUBLIC Python::NumPy)
target_include_directories(fortranobject PUBLIC "${F2PY_INCLUDE_DIR}")
set_property(TARGET fortranobject PROPERTY POSITION_INDEPENDENT_CODE ON)

add_custom_command(
  OUTPUT examplemodule.c example-f2pywrappers.f
  DEPENDS example.f
  VERBATIM
  COMMAND "${Python_EXECUTABLE}" -m numpy.f2py
          "${CMAKE_CURRENT_SOURCE_DIR}/example.f" -m example --lower)

python_add_library(
  example MODULE "${CMAKE_CURRENT_BINARY_DIR}/examplemodule.c"
  "${CMAKE_CURRENT_BINARY_DIR}/example-f2pywrappers.f"
  "${CMAKE_CURRENT_SOURCE_DIR}/example.f" WITH_SOABI)
target_link_libraries(example PRIVATE fortranobject)

install(TARGETS example DESTINATION .)

Docs

We’ve add and reorganized our docs. We added some nesting and have dedicated pages for things like monorepos/workspaces, debugging and IDE integration, and building wheel variants (experimental). Many of these are based on requests in our issue tracker.

Other smaller things

We now support several modes for handling symlinks in SDists, and the default has changed to sdist.resolve-symlinks = "all" (if minimum-version is unset or set to 1 or higher). Other options include "none", "external", and "classic".

On Python 3.15+, imports are lazy (PEP 810), so CLI and backend startup are noticeably faster.

There’s a new sdist.inclusion-mode = "explicit" for an opt-in include model, if you’d rather list exactly what goes into the SDist instead of relying on the default heuristics.

We support configuration providers via entry-points, so distributions can add custom defaults or overrides for scikit-build-core settings, like symbol stripping or build type.

We support custom install.targets, in case you want to install something other than install. You can also list multiple build types, and we’ll build multiple times (a feature inspired by py-build-cmake, and useful for debugging). wheel.packages can now list single files.

We now support PEP 803, the free-threaded stable ABI, so you can ship a single wheel that works on both the free-threaded and GIL-enabled builds of a Python version. That includes the combined abi3.abi3t wheel tag, and the installer-compatible abi3t tag is emitted correctly. You’ll even see abi3 and abi3t show up as binding choices in the new init command. You either need CMake 4.4 (which is about to be released), or you can manually make it work (as in the examples), and there are limitations creating these from GIL Python, at least on Windows; please use the free-threaded interpreter for 3.15 if making these for now.

With a little setup, you can now build a bit-for-bit reproducible wheel via wheel.reproducible. We set SOURCE_DATE_EPOCH and normalize the archive so repeated builds from the same source produce identical wheels, which is handy for build verification and caching. You do still need to make sure you use a recent enough compiler to respect SOURCE_DATE_EPOCH, and you need to make sure paths and things are not hardcoded into the binary (see your compiler docs).

Many other smaller fixes and minor things are there, see the full release notes for more!

Development Process

Let’s look at the development process for this release a bit. I’ve been learning Agentic AI since March 2026, and this became a big part of the push to 1.0.

I will try to mention if I used Claude Fable, otherwise most work was Opus or an Open Source model - with Opus often used here because I could run far more agents in parallel with it than NRP’s 2 agent limit and heavy usage, and I had SIMPLE-Py as a deadline.

Sweeping review

I had Claude Fable do a sweeping review looking for bugs and cleanups; it found a big set of things that needed fixing or could be improved. I would highly recommend every project try something like this; the false positive rate was really low from Fable, and it caught things hard to find any other way. (And as hackers get access to these models that can string together minor bugs, keeping bugs to a minimum is becoming more important than ever!)

I also did a sweeping review of the documentation, and a pre-release investigation, both of which were useful as well.

Working on issues

To manage the roughly 140 open issues that had built up over the last four years, I had AI first find all issues that might be a bug and label them triage. Then any bug it could reproduce locally got labeled with bug. Then I had it try to fix all the reproducible bugs, in 10 subagents; of the 10 PRs, about 6-7 were correct fixes out of the box, 2 or so needed a bit of work but were usable as a base, and 1 was an instant close. Not bad!

Then I asked for a categorisation of all the feature issues in preparation for 1.0. I put that into a discussion (as issues expand mentions in lists, while discussions do not). This was a really useful overview, and guided my work (such as on the editable install issues) for much of the development cycle

  • I was constantly going back to this issue to decide what to work on next, and what needed to be postponed past 1.0.

Difficult projects

Some of these were things I’d put off because they were difficult to do by hand. Editable installs were hard due to how much boilerplate there was to set up each case, like nested namespace packages, for example. (I was also worried about test time before, but the faster tests PR discussed later and using non-compiled projects fixed this). There’s also a lot of plumbing with specs and loaders that’s hard to get right (and sparsely documented). All this was much easier with AI helping write tests and proposing solutions with worked-out plumbing. Some of them took some iteration, but it moved along, and the weird “only broken on Python 3.9” tests suddely started working as the bugs were fixed.

Other examples included setuptools, reproducible wheels, and force-include; all of these have some element that makes them take quite a bit of manual effort (like setting up tests), but was much easier with AI assistance.

The “slow” point in software development is becoming design—I had a lot of planned features there were fully designed, and just needed implementations, often blocked mostly on testing; this was a perfect use case for AI assistance. For features not fully designed yet (like custom config-settings arguments), AI still is useful for prototyping, but I can’t push forward on those without time, user input, and studying the tradeoffs.

Scikit-build (classic)

The single hardest task was the one that I required for 1.0 from the beginning; that’s the scikit-build classic conversion to scikit-build-core. It was terrifying for good reason: I’d have to select the unsupported parts (like most setup.py commands), and strip the tests for those. It would require iteration between both projects (also the sample projects repo, which I wanted to use a baseline regression test). Huge chunks of scikit-build (classic) would just go away. Tests for the MSVC selector would all change due to scikit-build-core not requiring a manual search for each anymore. It was going to be hard.

And it was pretty much the first thing I tried Claude Fable on. Fable not only did all the test adjustments, the replacements, and tested the samples repo, but it also found and fixed seven bugs in scikit-build-core’s setuptools plugin, found my local checkout, fixed them there, and then asked me if I wanted a PR for that too—the PR it was making would depend on that one. I was really impressed, especially at how it was happy (proactive, even) to work in multiple repos at the same time.

Faster tests

I also asked Fable to reduce the time we spent in testing. I did give it some guidance in how I thought it would work; switching to C-API for most tests (C++/pybind11 is slower than C, but I like writing the former better), combining ideas into a smaller number of compiled tests, not compiling when not needed, etc. It was able to reduce the test time by 40% with the same coverage.

PyTorch PR analysis

Another thing I did was ask for an analysis of the PyTorch PR converting from setuptools to scikit-build-core 0.12 and report the pain points of the conversion. I used this to focus work on features that would help them: force-include, dynamic-metadata, the env table. When I was working on these features, I asked to see what that PR would look like with my proposed addition; this caught a problem that I was able to resolve to make it work better; it was like having someone trying out my feature in real time. I was able to make sure the PR worked with the pre-release state of scikit-build-core, and I was able to rewrite the PR using all the new features and make sure that also worked too!

Downstream testing SKILL

I’ve supported testing scikit-build-core against downstream projects manually with nox -s downstream -- ... for a while, and I have a list of some known scikit-build-core downstream project, but running them was time consuming. I had Claude write a skill to run this, then launched batches of agents working with this skill on projects from the known projects.

The summary of the skill is this: run once with the last released version as a baseline, then run again with the latest main branch, and report on the difference between the produced files.

I ran this on dozens of projects, and found a regression that I was able to fix.

What’s next

I’m very happy with the state of scikit-build-core, and with the NSF grant ending this month, I won’t have as much time to work on it. Though AI has really helped me in jumping around between my projects, so I do think I’ll be able to move it forward a bit more than just maintenance and managing contributions (and we do have a second core maintainer with some open PRs). A few ideas that might make it in eventually:

  • Drop old Pythons as soon as 1.0 seems fine
  • Extract data from CMake in a dynamic-metadata plugin
  • Work on a CLI for building locally without going through a wheel (pip/uv/build frontend)
  • Maybe work on some repair helpers or integration
  • Mechanism for custom config-settings args (design is harder than implementation)
  • Maybe have custom Python hooks (so far CMake + our config has been enough!)

AI usage disclaimer: Text was written primarily by me. AI was used to review, compare with changelog for gaps, and help add links.

Categories: Python