Repository: pypa/cibuildwheel

Issue:
Build failed because docker is too old or is not working properly: json.decoder.JSONDecodeError
### Description

I am running cibuildwheel version 2.21.3 and I am trying to see if my project will compile for linux wheels, from windows, using the command:

```
cibuildwheel --platform linux --debug-traceback
```

Which fails with the error

```
Build failed because docker is too old or is not working properly.
```

Because of a json decode error:

```

     _ _       _ _   _       _           _
 ___|_| |_ _ _|_| |_| |_ _ _| |_ ___ ___| |
|  _| | . | | | | | . | | | |   | -_| -_| |
|___|_|___|___|_|_|___|_____|_|_|___|___|_|

cibuildwheel version 2.21.3

Build options:
  platform: linux
  allow_empty: False
  architectures: x86_64
  build_selector:
    build_config: cp*
    skip_config: cp*-musllinux* pp*
    requires_python: >=3.11
    prerelease_pythons: False
    free_threaded_support: False
  output_dir: C:\Users\tom.mclean\src\Nemo\wheelhouse
  package_dir: C:\Users\tom.mclean\src\Nemo
  test_selector:
    skip_config:
  before_all:
    rm -rf vcpkg
    rm -rf vcpkg_installed
    yum -y install curl zip unzip tar
    git clone https://github.com/microsoft/vcpkg.git
    ./vcpkg/bootstrap-vcpkg.sh
    ./vcpkg/vcpkg install
  before_build:
  before_test:
  build_frontend: None
  build_verbosity: 0
  config_settings:
  container_engine: docker
  dependency_constraints: pinned
  environment:
  manylinux_images:
    x86_64: quay.io/pypa/manylinux_2_28_x86_64:2024.10.07-1
    i686: manylinux_2_28
    pypy_x86_64: quay.io/pypa/manylinux_2_28_x86_64:2024.10.07-1
    aarch64: quay.io/pypa/manylinux_2_28_aarch64:2024.10.07-1
    ppc64le: quay.io/pypa/manylinux_2_28_ppc64le:2024.10.07-1
    s390x: quay.io/pypa/manylinux_2_28_s390x:2024.10.07-1
    pypy_aarch64: quay.io/pypa/manylinux_2_28_aarch64:2024.10.07-1
    pypy_i686: manylinux_2_28
  musllinux_images:
    x86_64: quay.io/pypa/musllinux_1_2_x86_64:2024.10.07-1
    i686: quay.io/pypa/musllinux_1_2_i686:2024.10.07-1
    aarch64: quay.io/pypa/musllinux_1_2_aarch64:2024.10.07-1
    ppc64le: quay.io/pypa/musllinux_1_2_ppc64le:2024.10.07-1
    s390x: quay.io/pypa/musllinux_1_2_s390x:2024.10.07-1
    armv7l: quay.io/pypa/musllinux_1_2_armv7l:2024.10.07-1
  repair_command: auditwheel repair -w {dest_dir} {wheel}
  test_command:
  test_extras:
  test_requires:

Cache folder: C:\Users\tom.mclean\AppData\Local\pypa\cibuildwheel\Cache

Here we go!

Starting container image quay.io/pypa/manylinux_2_28_x86_64:2024.10.07-1...

info: This container will host the build for cp311-manylinux_x86_64, cp312-manylinux_x86_64, cp313-manylinux_x86_64...
+ docker version -f '{{json .}}'

                                                                       ✕ 0.14s
Error: Build failed because docker is too old or is not working properly.

Traceback (most recent call last):
  File "C:\Users\tom.mclean\AppData\Local\miniconda3\envs\nemo\Lib\site-packages\cibuildwheel\oci_container.py", line 105, in _check_engine_version
    version_info = json.loads(version_string.strip())
                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\tom.mclean\AppData\Local\miniconda3\envs\nemo\Lib\json\__init__.py", line 346, in loads
    return _default_decoder.decode(s)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\tom.mclean\AppData\Local\miniconda3\envs\nemo\Lib\json\decoder.py", line 337, in decode
    obj, end = self.raw_decode(s, idx=_w(s, 0).end())
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\tom.mclean\AppData\Local\miniconda3\envs\nemo\Lib\json\decoder.py", line 355, in raw_decode
    raise JSONDecodeError("Expecting value", s, err.value) from None
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "C:\Users\tom.mclean\AppData\Local\miniconda3\envs\nemo\Lib\site-packages\cibuildwheel\__main__.py", line 49, in main
    main_inner(global_options)
  File "C:\Users\tom.mclean\AppData\Local\miniconda3\envs\nemo\Lib\site-packages\cibuildwheel\__main__.py", line 184, in main_inner
    build_in_directory(args)
  File "C:\Users\tom.mclean\AppData\Local\miniconda3\envs\nemo\Lib\site-packages\cibuildwheel\__main__.py", line 351, in build_in_directory
    platform_module.build(options, tmp_path)
  File "C:\Users\tom.mclean\AppData\Local\miniconda3\envs\nemo\Lib\site-packages\cibuildwheel\linux.py", line 460, in build
    with OCIContainer(
  File "C:\Users\tom.mclean\AppData\Local\miniconda3\envs\nemo\Lib\site-packages\cibuildwheel\oci_container.py", line 230, in __enter__
    _check_engine_version(self.engine)
  File "C:\Users\tom.mclean\AppData\Local\miniconda3\envs\nemo\Lib\site-packages\cibuildwheel\oci_container.py", line 145, in _check_engine_version
    raise OCIEngineTooOldError(msg) from e
cibuildwheel.errors.OCIEngineTooOldError: Build failed because docker is too old or is not working properly.
```

The output of my docker version command is:

```
{
   "Client":{
      "CloudIntegration":"v1.0.35+desktop.10",
      "Version":"25.0.2",
      "ApiVersion":"1.44",
      "DefaultAPIVersion":"1.44",
      "GitCommit":"29cf629",
      "GoVersion":"go1.21.6",
      "Os":"windows",
      "Arch":"amd64",
      "BuildTime":"Thu Feb  1 00:24:09 2024",
      "Context":"default"
   },
   "Server":{
      "Platform":{
         "Name":"Docker Desktop 4.27.1 (136059)"
      },
      "Components":[
         {
            "Name":"Engine",
            "Version":"25.0.2",
            "Details":{
               "ApiVersion":"1.44",
               "Arch":"amd64",
               "BuildTime":"Thu Feb  1 00:23:17 2024",
               "Experimental":"false",
               "GitCommit":"fce6e0c",
               "GoVersion":"go1.21.6",
               "KernelVersion":"5.15.133.1-microsoft-standard-WSL2",
               "MinAPIVersion":"1.24",
               "Os":"linux"
            }
         },
         {
            "Name":"containerd",
            "Version":"1.6.28",
            "Details":{
               "GitCommit":"ae07eda36dd25f8a1b98dfbf587313b99c0190bb"
            }
         },
         {
            "Name":"runc",
            "Version":"1.1.12",
            "Details":{
               "GitCommit":"v1.1.12-0-g51d5e94"
            }
         },
         {
            "Name":"docker-init",
            "Version":"0.19.0",
            "Details":{
               "GitCommit":"de40ad0"
            }
         }
      ],
      "Version":"25.0.2",
      "ApiVersion":"1.44",
      "MinAPIVersion":"1.24",
      "GitCommit":"fce6e0c",
      "GoVersion":"go1.21.6",
      "Os":"linux",
      "Arch":"amd64",
      "KernelVersion":"5.15.133.1-microsoft-standard-WSL2",
      "BuildTime":"2024-02-01T00:23:17.000000000+00:00"
   }
}
```

So I am not sure why it is failing.

### Build log

_No response_

### CI config

_No response_

Discussion:
mayeut: I don't understand why PyPy fails on Windows. Will try reverting the shell part.
mayeut: So it works if we keep using shell on Windows which means there are other side effects that I don't understand.
Let's wait at least for a confirmation that it solves the docker issue in #2074 
henryiii: Should we rename this to capture_output? Maybe not right now, but when we are refactoring the utils for 3.0?
mayeut: > Should we rename this to capture_output?

That's a good idea for the refactor in 3.0

Patch:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 2a220607..8f97b1c9 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -33,7 +33,7 @@ jobs:
         path: dist
 
     - name: Generate artifact attestation for sdist and wheel
-      uses: actions/attest-build-provenance@1c608d11d69870c2092266b3f9a6f3abbf17002c # v1.4.3
+      uses: actions/attest-build-provenance@ef244123eb79f2f7a7e75d99086184180e6d0018 # v1.4.4
       with:
         subject-path: "dist/cibuildwheel-*"
 
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index ae8d90eb..57e86593 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -14,7 +14,7 @@ repos:
   - id: trailing-whitespace
 
 - repo: https://github.com/astral-sh/ruff-pre-commit
-  rev: v0.7.2
+  rev: v0.7.3
   hooks:
   - id: ruff
     args: ["--fix", "--show-fixes"]
diff --git a/bin/generate_schema.py b/bin/generate_schema.py
index fa03284a..1e33cf1f 100755
--- a/bin/generate_schema.py
+++ b/bin/generate_schema.py
@@ -26,6 +26,12 @@
       - append
     default: none
     description: How to inherit the parent's value.
+  enable:
+    enum:
+      - cpython-freethreading
+      - cpython-prerelease
+      - pypy
+  description: A Python version or flavor to enable.
 additionalProperties: false
 description: cibuildwheel's settings.
 type: object
@@ -99,6 +105,13 @@
     default: pinned
     description: Specify how cibuildwheel controls the versions of the tools it uses
     type: string
+  enable:
+    description: Enable or disable certain builds.
+    oneOf:
+      - $ref: "#/$defs/enable"
+      - type: array
+        items:
+          $ref: "#/$defs/enable"
   environment:
     description: Set environment variables needed during the build.
     type: string_table
@@ -110,6 +123,7 @@
     type: boolean
     default: false
     description: The project supports free-threaded builds of Python (PEP703)
+    deprecated: Use the `enable` option instead.
   manylinux-aarch64-image:
     type: string
     description: Specify alternative manylinux / musllinux container images
@@ -261,6 +275,7 @@
 del non_global_options["skip"]
 del non_global_options["test-skip"]
 del non_global_options["free-threaded-support"]
+del non_global_options["enable"]
 
 overrides["items"]["properties"]["select"]["oneOf"] = string_array
 overrides["items"]["properties"] |= non_global_options.copy()
diff --git a/cibuildwheel/macos.py b/cibuildwheel/macos.py
index f830dbcc..fcc88dac 100644
--- a/cibuildwheel/macos.py
+++ b/cibuildwheel/macos.py
@@ -278,23 +278,23 @@ def setup_python(
     # Apply our environment after pip is ready
     env = environment.as_dictionary(prev_environment=env)
 
+    # check what Python version we're on
+    which_python = call("which", "python", env=env, capture_stdout=True).strip()
+    print(which_python)
+    if which_python != str(venv_bin_path / "python"):
+        msg = "cibuildwheel: python available on PATH doesn't match our installed instance. If you have modified PATH, ensure that you don't overwrite cibuildwheel's entry or insert python above it."
+        raise errors.FatalError(msg)
+    call("python", "--version", env=env)
+
     # check what pip version we're on
     if not use_uv:
         assert (venv_bin_path / "pip").exists()
-        call("which", "pip", env=env)
-        call("pip", "--version", env=env)
         which_pip = call("which", "pip", env=env, capture_stdout=True).strip()
+        print(which_pip)
         if which_pip != str(venv_bin_path / "pip"):
             msg = "cibuildwheel: pip available on PATH doesn't match our installed instance. If you have modified PATH, ensure that you don't overwrite cibuildwheel's entry or insert pip above it."
             raise errors.FatalError(msg)
-
-    # check what Python version we're on
-    call("which", "python", env=env)
-    call("python", "--version", env=env)
-    which_python = call("which", "python", env=env, capture_stdout=True).strip()
-    if which_python != str(venv_bin_path / "python"):
-        msg = "cibuildwheel: python available on PATH doesn't match our installed instance. If you have modified PATH, ensure that you don't overwrite cibuildwheel's entry or insert python above it."
-        raise errors.FatalError(msg)
+        call("pip", "--version", env=env)
 
     config_is_arm64 = python_configuration.identifier.endswith("arm64")
     config_is_universal2 = python_configuration.identifier.endswith("universal2")
diff --git a/cibuildwheel/options.py b/cibuildwheel/options.py
index 455e1ee7..3fb9ebb7 100644
--- a/cibuildwheel/options.py
+++ b/cibuildwheel/options.py
@@ -30,8 +30,10 @@
     BuildFrontendConfig,
     BuildSelector,
     DependencyConstraints,
+    EnableGroups,
     TestSelector,
     format_safe,
+    read_python_configs,
     resources_dir,
     selector_matches,
     strtobool,
@@ -512,6 +514,7 @@ def get(
         env_plat: bool = True,
         option_format: OptionFormat | None = None,
         ignore_empty: bool = False,
+        env_rule: InheritRule = InheritRule.NONE,
     ) -> str:
         """
         Get and return the value for the named option from environment,
@@ -543,8 +546,8 @@ def get(
                 (o.options.get(name), o.inherit.get(name, InheritRule.NONE))
                 for o in self.active_config_overrides
             ],
-            (self.env.get(envvar), InheritRule.NONE),
-            (self.env.get(plat_envvar) if env_plat else None, InheritRule.NONE),
+            (self.env.get(envvar), env_rule),
+            (self.env.get(plat_envvar) if env_plat else None, env_rule),
             ignore_empty=ignore_empty,
             option_format=option_format,
         )
@@ -558,14 +561,15 @@ def __init__(
         platform: PlatformName,
         command_line_arguments: CommandLineArguments,
         env: Mapping[str, str],
-        read_config_file: bool = True,
+        defaults: bool = False,
     ):
         self.platform = platform
         self.command_line_arguments = command_line_arguments
         self.env = env
+        self._defaults = defaults
 
         self.reader = OptionsReader(
-            self.config_file_path if read_config_file else None,
+            None if defaults else self.config_file_path,
             platform=platform,
             env=env,
             disallow=DISALLOWED_OPTIONS,
@@ -578,7 +582,7 @@ def __init__(
         except FileNotFoundError:
             self.pyproject_toml = None
 
-    @property
+    @functools.cached_property
     def config_file_path(self) -> Path | None:
         args = self.command_line_arguments
 
@@ -596,7 +600,7 @@ def config_file_path(self) -> Path | None:
     def package_requires_python_str(self) -> str | None:
         return get_requires_python_str(self.package_dir, self.pyproject_toml)
 
-    @property
+    @functools.cached_property
     def globals(self) -> GlobalOptions:
         args = self.command_line_arguments
         package_dir = args.package_dir
@@ -608,16 +612,34 @@ def globals(self) -> GlobalOptions:
         skip_config = self.reader.get("skip", env_plat=False, option_format=ListFormat(sep=" "))
         test_skip = self.reader.get("test-skip", env_plat=False, option_format=ListFormat(sep=" "))
 
+        allow_empty = args.allow_empty or strtobool(self.env.get("CIBW_ALLOW_EMPTY", "0"))
+
+        enable_groups = self.reader.get(
+            "enable", env_plat=False, option_format=ListFormat(sep=" "), env_rule=InheritRule.APPEND
+        )
+        enable = {EnableGroups(group) for group in enable_groups.split()}
+
         free_threaded_support = strtobool(
             self.reader.get("free-threaded-support", env_plat=False, ignore_empty=True)
         )
 
-        allow_empty = args.allow_empty or strtobool(self.env.get("CIBW_ALLOW_EMPTY", "0"))
-
         prerelease_pythons = args.prerelease_pythons or strtobool(
             self.env.get("CIBW_PRERELEASE_PYTHONS", "0")
         )
 
+        if free_threaded_support or prerelease_pythons:
+            msg = (
+                "free-threaded-support and prerelease-pythons should be specified by enable instead"
+            )
+            if enable:
+                raise OptionsReaderError(msg)
+            log.warning(msg)
+
+        if free_threaded_support:
+            enable.add(EnableGroups.CPythonFreeThreading)
+        if prerelease_pythons:
+            enable.add(EnableGroups.CPythonPrerelease)
+
         # This is not supported in tool.cibuildwheel, as it comes from a standard location.
         # Passing this in as an environment variable will override pyproject.toml, setup.cfg, or setup.py
         requires_python_str: str | None = (
@@ -633,18 +655,30 @@ def globals(self) -> GlobalOptions:
             build_config = args.only
             skip_config = ""
             architectures = Architecture.all_archs(self.platform)
-            prerelease_pythons = True
-            free_threaded_support = True
+            enable = set(EnableGroups)
 
         build_selector = BuildSelector(
             build_config=build_config,
             skip_config=skip_config,
             requires_python=requires_python,
-            prerelease_pythons=prerelease_pythons,
-            free_threaded_support=free_threaded_support,
+            enable=frozenset(
+                enable | {EnableGroups.PyPy}
+            ),  # For backwards compatibility, we are adding PyPy for now
         )
         test_selector = TestSelector(skip_config=test_skip)
 
+        all_configs = read_python_configs(self.platform)
+        all_pypy_ids = {
+            config["identifier"] for config in all_configs if config["identifier"].startswith("pp")
+        }
+        if (
+            not self._defaults
+            and EnableGroups.PyPy not in enable
+            and any(build_selector(build_id) for build_id in all_pypy_ids)
+        ):
+            msg = "PyPy builds will be disabled by default in version 3. Enabling PyPy builds should be specified by enable"
+            log.warning(msg)
+
         return GlobalOptions(
             package_dir=package_dir,
             output_dir=output_dir,
@@ -831,7 +865,7 @@ def defaults(self) -> Options:
             platform=self.platform,
             command_line_arguments=CommandLineArguments.defaults(),
             env={},
-            read_config_file=False,
+            defaults=True,
         )
 
     def summary(self, identifiers: Iterable[str]) -> str:
diff --git a/cibuildwheel/pyodide.py b/cibuildwheel/pyodide.py
index 3b721a10..147203cf 100644
--- a/cibuildwheel/pyodide.py
+++ b/cibuildwheel/pyodide.py
@@ -136,22 +136,22 @@ def setup_python(
 
     env = environment.as_dictionary(prev_environment=env)
 
+    # check what Python version we're on
+    which_python = call("which", "python", env=env, capture_stdout=True).strip()
+    print(which_python)
+    if which_python != str(venv_bin_path / "python"):
+        msg = "python available on PATH doesn't match our venv instance. If you have modified PATH, ensure that you don't overwrite cibuildwheel's entry or insert python above it."
+        raise errors.FatalError(msg)
+    call("python", "--version", env=env)
+
     # check what pip version we're on
     assert (venv_bin_path / "pip").exists()
-    call("which", "pip", env=env)
-    call("pip", "--version", env=env)
     which_pip = call("which", "pip", env=env, capture_stdout=True).strip()
+    print(which_pip)
     if which_pip != str(venv_bin_path / "pip"):
         msg = "pip available on PATH doesn't match our venv instance. If you have modified PATH, ensure that you don't overwrite cibuildwheel's entry or insert pip above it."
         raise errors.FatalError(msg)
-
-    # check what Python version we're on
-    call("which", "python", env=env)
-    call("python", "--version", env=env)
-    which_python = call("which", "python", env=env, capture_stdout=True).strip()
-    if which_python != str(venv_bin_path / "python"):
-        msg = "python available on PATH doesn't match our venv instance. If you have modified PATH, ensure that you don't overwrite cibuildwheel's entry or insert python above it."
-        raise errors.FatalError(msg)
+    call("pip", "--version", env=env)
 
     log.step("Installing build tools...")
     call(
diff --git a/cibuildwheel/resources/cibuildwheel.schema.json b/cibuildwheel/resources/cibuildwheel.schema.json
index 92f368b1..1b824d89 100644
--- a/cibuildwheel/resources/cibuildwheel.schema.json
+++ b/cibuildwheel/resources/cibuildwheel.schema.json
@@ -10,7 +10,16 @@
       ],
       "default": "none",
       "description": "How to inherit the parent's value."
-    }
+    },
+    "enable": {
+      "enum": [
+        "cpython-eol",
+        "cpython-freethreading",
+        "cpython-prerelease",
+        "pypy-eol"
+      ]
+    },
+    "description": "A Python version or flavor to enable."
   },
   "additionalProperties": false,
   "description": "cibuildwheel's settings.",
@@ -228,6 +237,21 @@
       "type": "string",
       "title": "CIBW_DEPENDENCY_VERSIONS"
     },
+    "enable": {
+      "description": "Enable or disable certain builds.",
+      "oneOf": [
+        {
+          "$ref": "#/$defs/enable"
+        },
+        {
+          "type": "array",
+          "items": {
+            "$ref": "#/$defs/enable"
+          }
+        }
+      ],
+      "title": "CIBW_ENABLE"
+    },
     "environment": {
       "description": "Set environment variables needed during the build.",
       "oneOf": [
diff --git a/cibuildwheel/resources/defaults.toml b/cibuildwheel/resources/defaults.toml
index f7839643..456b8dcd 100644
--- a/cibuildwheel/resources/defaults.toml
+++ b/cibuildwheel/resources/defaults.toml
@@ -3,6 +3,7 @@ build = "*"
 skip = ""
 test-skip = ""
 free-threaded-support = false
+enable = []
 
 archs = ["auto"]
 build-frontend = "default"
diff --git a/cibuildwheel/util.py b/cibuildwheel/util.py
index bfbe50c7..3e68ea73 100644
--- a/cibuildwheel/util.py
+++ b/cibuildwheel/util.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import contextlib
+import enum
 import fnmatch
 import itertools
 import os
@@ -23,7 +24,7 @@
 from pathlib import Path, PurePath
 from tempfile import TemporaryDirectory
 from time import sleep
-from typing import Any, ClassVar, Final, Literal, TextIO, TypeVar
+from typing import Any, Final, Literal, TextIO, TypeVar
 from zipfile import ZipFile
 
 import bracex
@@ -37,10 +38,12 @@
 
 from ._compat import tomllib
 from .architecture import Architecture
+from .errors import FatalError
 from .typing import PathOrStr, PlatformName
 
 __all__ = [
     "MANYLINUX_ARCHS",
+    "EnableGroups",
     "call",
     "chdir",
     "combine_constraints",
@@ -66,6 +69,16 @@
 test_fail_cwd_file: Final[Path] = resources_dir / "testing_temp_dir_file.py"
 
 
+class EnableGroups(enum.Enum):
+    """
+    Groups of build selectors that are not enabled by default.
+    """
+
+    CPythonFreeThreading = "cpython-freethreading"
+    CPythonPrerelease = "cpython-prerelease"
+    PyPy = "pypy"
+
+
 MANYLINUX_ARCHS: Final[tuple[str, ...]] = (
     "x86_64",
     "i686",
@@ -127,13 +140,32 @@ def call(
     args_ = [str(arg) for arg in args]
     # print the command executing for the logs
     print("+ " + " ".join(shlex.quote(a) for a in args_))
-    kwargs: dict[str, Any] = {}
-    if capture_stdout:
-        kwargs["universal_newlines"] = True
-        kwargs["stdout"] = subprocess.PIPE
-    result = subprocess.run(args_, check=True, shell=IS_WIN, env=env, cwd=cwd, **kwargs)
+    # workaround platform behaviour differences outlined
+    # in https://github.com/python/cpython/issues/52803
+    path_env = env if env is not None else os.environ
+    path = path_env.get("PATH", None)
+    executable = shutil.which(args_[0], path=path)
+    if executable is None:
+        msg = f"Couldn't find {args_[0]!r} in PATH {path!r}"
+        raise FatalError(msg)
+    args_[0] = executable
+    try:
+        result = subprocess.run(
+            args_,
+            check=True,
+            shell=IS_WIN,
+            env=env,
+            cwd=cwd,
+            capture_output=capture_stdout,
+            text=capture_stdout,
+        )
+    except subprocess.CalledProcessError as e:
+        if capture_stdout:
+            sys.stderr.write(e.stderr)
+        raise
     if not capture_stdout:
         return None
+    sys.stderr.write(result.stderr)
     return typing.cast(str, result.stdout)
 
 
@@ -247,12 +279,7 @@ class BuildSelector:
     build_config: str
     skip_config: str
     requires_python: SpecifierSet | None = None
-
-    # a pattern that skips prerelease versions, when include_prereleases is False.
-    PRERELEASE_SKIP: ClassVar[str] = ""
-    prerelease_pythons: bool = False
-
-    free_threaded_support: bool = False
+    enable: frozenset[EnableGroups] = frozenset()
 
     def __call__(self, build_id: str) -> bool:
         # Filter build selectors by python_requires if set
@@ -266,12 +293,16 @@ def __call__(self, build_id: str) -> bool:
             if not self.requires_python.contains(version):
                 return False
 
-        # filter out the prerelease pythons if self.prerelease_pythons is False
-        if not self.prerelease_pythons and selector_matches(self.PRERELEASE_SKIP, build_id):
+        # filter out groups that are not enabled
+        if EnableGroups.CPythonFreeThreading not in self.enable and selector_matches(
+            "cp3??t-*", build_id
+        ):
             return False
-
-        # filter out free threaded pythons if self.free_threaded_support is False
-        if not self.free_threaded_support and selector_matches("*t-*", build_id):
+        if EnableGroups.CPythonPrerelease not in self.enable and selector_matches(
+            "cp314*", build_id
+        ):
+            return False
+        if EnableGroups.PyPy not in self.enable and selector_matches("pp*", build_id):
             return False
 
         should_build = selector_matches(self.build_config, build_id)
@@ -284,8 +315,7 @@ def options_summary(self) -> Any:
             "build_config": self.build_config,
             "skip_config": self.skip_config,
             "requires_python": str(self.requires_python),
-            "prerelease_pythons": self.prerelease_pythons,
-            "free_threaded_support": self.free_threaded_support,
+            "enable": sorted(group.value for group in self.enable),
         }
 
 
diff --git a/cibuildwheel/windows.py b/cibuildwheel/windows.py
index 449be810..8f5633d6 100644
--- a/cibuildwheel/windows.py
+++ b/cibuildwheel/windows.py
@@ -302,22 +302,22 @@ def setup_python(
     env = environment.as_dictionary(prev_environment=env)
 
     # check what Python version we're on
-    call("where", "python", env=env)
-    call("python", "--version", env=env)
-    call("python", "-c", "\"import struct; print(struct.calcsize('P') * 8)\"", env=env)
     where_python = call("where", "python", env=env, capture_stdout=True).splitlines()[0].strip()
+    print(where_python)
     if where_python != str(venv_path / "Scripts" / "python.exe"):
         msg = "python available on PATH doesn't match our installed instance. If you have modified PATH, ensure that you don't overwrite cibuildwheel's entry or insert python above it."
         raise errors.FatalError(msg)
+    call("python", "--version", env=env)
+    call("python", "-c", "\"import struct; print(struct.calcsize('P') * 8)\"", env=env)
 
     # check what pip version we're on
     if not use_uv:
         assert (venv_path / "Scripts" / "pip.exe").exists()
         where_pip = call("where", "pip", env=env, capture_stdout=True).splitlines()[0].strip()
+        print(where_pip)
         if where_pip.strip() != str(venv_path / "Scripts" / "pip.exe"):
             msg = "pip available on PATH doesn't match our installed instance. If you have modified PATH, ensure that you don't overwrite cibuildwheel's entry or insert pip above it."
             raise errors.FatalError(msg)
-
         call("pip", "--version", env=env)
 
     log.step("Installing build tools...")
diff --git a/docs/options.md b/docs/options.md
index 0225c8dd..39304e37 100644
--- a/docs/options.md
+++ b/docs/options.md
@@ -422,52 +422,6 @@ #### Examples
   }
 </style>
 
-### `CIBW_FREE_THREADED_SUPPORT` {: #free-threaded-support}
-
-> Choose whether free-threaded variants should be built
-
-[PEP 703](https://www.python.org/dev/peps/pep-0703) introduced variants of CPython that can be built without the Global Interpreter Lock (GIL).
-Those variants are also known as free-threaded / no-gil.
-
-Building for free-threaded variants is disabled by default.
-
-Building can be enabled by setting this option to `true`. The free-threaded compatible wheels will be built in addition to the standard wheels.
-
-This option doesn't support overrides.
-If you need to enable/disable it per platform or python version, set this option to `true` and use [`CIBW_BUILD`](#build-skip)/[`CIBW_SKIP`](#build-skip) options to filter the builds.
-
-The build identifiers for those variants have a `t` suffix in their `python_tag` (e.g. `cp313t-manylinux_x86_64`)
-
-!!! note
-    This feature is experimental: [What’s New In Python 3.13](https://docs.python.org/3.13/whatsnew/3.13.html#free-threaded-cpython)
-
-#### Examples
-
-!!! tab examples "Environment variables"
-
-    ```yaml
-    # Enable free-threaded support
-    CIBW_FREE_THREADED_SUPPORT: 1
-
-    # Skip building free-threaded compatible wheels on Windows
-    CIBW_FREE_THREADED_SUPPORT: 1
-    CIBW_SKIP: *t-win*
-    ```
-
-    It is generally recommended to use `free-threaded-support` in a config file as you can statically declare that you
-    support free-threaded builds.
-
-!!! tab examples "pyproject.toml"
-
-    ```toml
-    [tool.cibuildwheel]
-    # Enable free-threaded support
-    free-threaded-support = true
-
-    # Skip building free-threaded compatible wheels on Windows
-    free-threaded-support = true
-    skip = "*t-win*"
-    ```
 
 ### `CIBW_ARCHS` {: #archs}
 > Change the architectures built on your machine by default.
@@ -603,24 +557,53 @@ #### Examples
     CIBW_PROJECT_REQUIRES_PYTHON: ">=3.6"
     ```
 
-###  `CIBW_PRERELEASE_PYTHONS` {: #prerelease-pythons}
-> Enable building with pre-release versions of Python if available
+###  `CIBW_ENABLE` {: #enable}
+> Enable building with extra categories of selectors present.
+
+This option lets you opt-in to non-default builds, like pre-releases and
+free-threaded Python. These are not included by default to give a nice default
+for new users, but can be added to the selectors available here. The allowed
+values are:
+
+
+- `cypython-prerelease`: Enables beta versions of Pythons if any are available
+  (May-July, approximately). For backward compatibility, `CIBW_PRERELEASE_PYTHONS`
+  is also supported until cibuildwheel 3.
+- `cpython-freethreading`: [PEP 703](https://www.python.org/dev/peps/pep-0703)
+  introduced variants of CPython that can be built without the Global
+  Interpreter Lock (GIL).  Those variants are also known as free-threaded /
+  no-gil. This will enable building these wheels while they are experimental.
+  The build identifiers for those variants have a `t` suffix in their
+  `python_tag` (e.g. `cp313t-manylinux_x86_64`).  For backward compatibility,
+  `CIBW_FREE_THREADED_SUPPORT` is also supported until cibuildwheel 3.
+- `pypy`: Enable PyPy. For backward compatibility, this is always enabled until
+  cibuildwheel 3 is released.
 
-During the beta period, when new versions of Python are being tested,
-cibuildwheel will often gain early support for beta releases. If you would
-like to test wheel building with these versions, you can enable this flag.
 
 !!! caution
-    This option is provided for testing purposes only. It is not
-    recommended to distribute wheels built when `CIBW_PRERELEASE_PYTHONS` is
-    set, such as uploading to PyPI.  Please _do not_ upload these wheels to
-    PyPI, as they are not guaranteed to work with the final Python release.
-    Once Python is ABI stable and enters the release candidate phase, that
-    version of Python will become available without this flag.
+    `cpython-prerelease` is provided for testing purposes only. It is not
+    recommended to distribute wheels built with beta releases, such as
+    uploading to PyPI.  Please _do not_ upload these wheels to PyPI, as they are
+    not guaranteed to work with the final Python release.  Once Python is ABI
+    stable and enters the release candidate phase, that version of Python will
+    become available without this flag.
+
+!!! note
+    Free threading is experimental: [What’s New In Python 3.13](https://docs.python.org/3.13/whatsnew/3.13.html#free-threaded-cpython)
 
-Default: Off (0) if Python is available in beta phase. No effect otherwise.
+Default: empty (`pypy` is always injected).
+
+This option doesn't support overrides or platform specific variants; it is
+intended as a way to acknowledge that a project is aware that these extra
+selectors exist.  If you need to enable/disable it per platform or python
+version, set this option to `true` and use
+[`CIBW_BUILD`](#build-skip)/[`CIBW_SKIP`](#build-skip) options to filter the
+builds.
+
+Unlike all other cibuildwheel options, the environment variable setting will
+only add to the TOML config; you can't remove an enable by setting an empty or
+partial list in environment variables; use `CIBW_SKIP` instead.
 
-This option can also be set using the [command-line option](#command-line) `--prerelease-pythons`. This option is not available in the `pyproject.toml` config.
 
 #### Examples
 
@@ -628,9 +611,38 @@ #### Examples
 
     ```yaml
     # Include latest Python beta
-    CIBW_PRERELEASE_PYTHONS: True
+    CIBW_ENABLE: cpython-prerelease
+
+    # Include free-threaded support
+    CIBW_ENABLE: cpython-freethreading
+
+    # Include both
+    CIBW_ENABLE: cpython-prerelease cpython-freethreading
+
+    # Skip building free-threaded compatible wheels on Windows
+    CIBW_ENABLE: cpython-freethreading
+    CIBW_SKIP: *t-win*
     ```
 
+    It is generally recommended to use `cpython-freethreading` in a config
+    file as you can statically declare that you support free-threaded builds.
+
+!!! tab examples "pyproject.toml"
+
+    ```toml
+    [tool.cibuildwheel]
+    # Enable free-threaded support
+    enable = ["cpython-freethreading"]
+
+    # Skip building free-threaded compatible wheels on Windows
+    enable = ["cpython-freethreading"]
+    skip = "*t-win*"
+    ```
+
+    It is generally not recommended to use `cpython-prerelease` in a config file,
+    as it's intended for testing pre-releases for a 2-3 month period only.
+
+
 ### `CIBW_ALLOW_EMPTY` {: #allow-empty}
 > Suppress the error code if no wheels match the specified build identifiers
 


Test patch:
diff --git a/unit_test/build_selector_test.py b/unit_test/build_selector_test.py
index f8922246..88620200 100644
--- a/unit_test/build_selector_test.py
+++ b/unit_test/build_selector_test.py
@@ -2,11 +2,13 @@
 
 from packaging.specifiers import SpecifierSet
 
-from cibuildwheel.util import BuildSelector
+from cibuildwheel.util import BuildSelector, EnableGroups
 
 
 def test_build():
-    build_selector = BuildSelector(build_config="cp3*-* *-manylinux*", skip_config="")
+    build_selector = BuildSelector(
+        build_config="cp3*-* *-manylinux*", skip_config="", enable=frozenset([EnableGroups.PyPy])
+    )
 
     assert build_selector("cp36-manylinux_x86_64")
     assert build_selector("cp37-manylinux_x86_64")
@@ -43,7 +45,7 @@ def test_build_filter_pre():
     build_selector = BuildSelector(
         build_config="cp3*-* *-manylinux*",
         skip_config="",
-        prerelease_pythons=True,
+        enable=frozenset([EnableGroups.CPythonPrerelease, EnableGroups.PyPy]),
     )
 
     assert build_selector("cp37-manylinux_x86_64")
@@ -55,7 +57,9 @@ def test_build_filter_pre():
 
 def test_skip():
     build_selector = BuildSelector(
-        build_config="*", skip_config="pp36-* cp3?-manylinux_i686 cp36-win* *-win32"
+        build_config="*",
+        skip_config="pp36-* cp3?-manylinux_i686 cp36-win* *-win32",
+        enable=frozenset([EnableGroups.PyPy]),
     )
 
     assert not build_selector("pp36-manylinux_x86_64")
@@ -79,7 +83,9 @@ def test_skip():
 
 def test_build_and_skip():
     build_selector = BuildSelector(
-        build_config="cp36-* cp37-macosx* *-manylinux*", skip_config="pp37-* cp37-manylinux_i686"
+        build_config="cp36-* cp37-macosx* *-manylinux*",
+        skip_config="pp37-* cp37-manylinux_i686",
+        enable=frozenset([EnableGroups.PyPy]),
     )
 
     assert not build_selector("pp37-manylinux_x86_64")
@@ -110,7 +116,10 @@ def test_build_braces():
 
 def test_build_limited_python():
     build_selector = BuildSelector(
-        build_config="*", skip_config="", requires_python=SpecifierSet(">=3.7")
+        build_config="*",
+        skip_config="",
+        requires_python=SpecifierSet(">=3.7"),
+        enable=frozenset([EnableGroups.PyPy]),
     )
 
     assert not build_selector("cp36-manylinux_x86_64")
@@ -146,9 +155,7 @@ def test_build_limited_python_patch():
 
 
 def test_build_free_threaded_python():
-    build_selector = BuildSelector(
-        build_config="*", skip_config="", prerelease_pythons=True, free_threaded_support=True
-    )
+    build_selector = BuildSelector(build_config="*", skip_config="", enable=frozenset(EnableGroups))
 
     assert build_selector("cp313t-manylinux_x86_64")
 
diff --git a/unit_test/main_tests/main_platform_test.py b/unit_test/main_tests/main_platform_test.py
index cdc2bebc..fbc86159 100644
--- a/unit_test/main_tests/main_platform_test.py
+++ b/unit_test/main_tests/main_platform_test.py
@@ -6,6 +6,7 @@
 
 from cibuildwheel.__main__ import main
 from cibuildwheel.architecture import Architecture
+from cibuildwheel.util import EnableGroups
 
 from ..conftest import MOCK_PACKAGE_DIR
 
@@ -216,7 +217,7 @@ def test_only_argument(intercepted_build_args, monkeypatch, only, plat):
     assert options.globals.build_selector.skip_config == ""
     assert options.platform == plat
     assert options.globals.architectures == Architecture.all_archs(plat)
-    assert options.globals.build_selector.prerelease_pythons is True
+    assert EnableGroups.PyPy in options.globals.build_selector.enable
 
 
 @pytest.mark.parametrize("only", ("cp311-manylxinux_x86_64", "some_linux_thing"))
diff --git a/unit_test/options_test.py b/unit_test/options_test.py
index 06139655..fb6727b0 100644
--- a/unit_test/options_test.py
+++ b/unit_test/options_test.py
@@ -15,6 +15,7 @@
     Options,
     _get_pinned_container_images,
 )
+from cibuildwheel.util import EnableGroups
 
 PYPROJECT_1 = """
 [tool.cibuildwheel]
@@ -454,4 +455,7 @@ def test_free_threaded_support(
         )
     )
     options = Options(platform="linux", command_line_arguments=args, env=env)
-    assert options.globals.build_selector.free_threaded_support is expected_result
+    if expected_result:
+        assert EnableGroups.CPythonFreeThreading in options.globals.build_selector.enable
+    else:
+        assert EnableGroups.CPythonFreeThreading not in options.globals.build_selector.enable