Repository: pypa/cibuildwheel

Issue:
How do I build one specific wheel from the command line?
I'm running cibuildwheel from the command line on my local machine. I want to debug a build failure for the `cp39-musllinux_i686` wheel. But I can't figure out how to say "only build `cp39-musllinux_i686` not anything else; I don't have all day here".

I was expecting this to work:

`cibuildwheel --build-identifiers cp39-musllinux_i686`

but there is no such flag.

Discussion:
joerick: Hmm. I'm thinking more about this and I wonder if perhaps `--build` should override both CIBW_BUILD and CIBW_SKIP from environment/config. There seems something weird about the user doing explicitly `cibuildwheel --build 'pp38-manylinux_x86_64'` but that selects zero identifiers because there's a `CIBW_SKIP=pp*` in the environment. Perhaps `--build` should override both build & skip from environment and config. What do you think @henryiii ?
henryiii: `--target=<...>` or `--local=<...>` or `--only=<...>` might be options? Given it is merging several things, not just BUILD, and selector expressions are not valid. Personally I'd often do `CIBW_BUILD=cp310-*` since I'm lazy and don't want to look up the spelling of the rest of the os/arch tag, but being able to skip `--platform` (and maybe `--arch`) would likely make it more worth while.
joerick: `--only` makes sense to me!
joerick: I've just pushed a few commits. After removing the `intercepted_build_args` to `platform` coupling, I realised that CIBW_PLATFORM was causing an error when used with the `--only` command line option, which doesn't seem right to me (command line options should override env vars). So I wrote a test for that and got it passing.

Let me know if that works for you! 
joerick: The CI failures are the
> WARNING: Skipping page https://pypi.org/simple/xxx/ because the GET request got Content-Type: Unknown
ones.

Having seen more of this recently I investigated a little https://github.com/pypa/cibuildwheel/issues/1254
henryiii: That's fine with me - IMO it should be really rare to have `CIBW_PLATFORM` & `--only` together, and it's probably a mistake on the user's part if they are both there, so I'd be fine with the error, but don't have a strong feeling on it.
henryiii: Added `only:` to the Action, since I think it would solve https://github.com/pypa/cibuildwheel/pull/1262.

Patch:
diff --git a/action.yml b/action.yml
index a27269dd..111acc93 100644
--- a/action.yml
+++ b/action.yml
@@ -13,6 +13,10 @@ inputs:
     description: 'File containing the config, defaults to {package}/pyproject.toml'
     required: false
     default: ''
+  only:
+    description: 'Build a specific wheel only. No need for arch/platform if this is set'
+    required: false
+    default: ''
 branding:
   icon: package
   color: yellow
@@ -36,5 +40,6 @@ runs:
         ${{ inputs.package-dir }}
         --output-dir ${{ inputs.output-dir }}
         --config-file "${{ inputs.config-file }}"
+        --only "${{ inputs.only }}"
         2>&1
       shell: bash
diff --git a/cibuildwheel/__main__.py b/cibuildwheel/__main__.py
index f3b44261..4d1402d8 100644
--- a/cibuildwheel/__main__.py
+++ b/cibuildwheel/__main__.py
@@ -6,6 +6,7 @@
 import sys
 import tarfile
 import textwrap
+import typing
 from pathlib import Path
 from tempfile import mkdtemp
 
@@ -40,7 +41,7 @@ def main() -> None:
     parser.add_argument(
         "--platform",
         choices=["auto", "linux", "macos", "windows"],
-        default=os.environ.get("CIBW_PLATFORM", "auto"),
+        default=None,
         help="""
             Platform to build for. Use this option to override the
             auto-detected platform or to run cibuildwheel on your development
@@ -64,6 +65,16 @@ def main() -> None:
         """,
     )
 
+    parser.add_argument(
+        "--only",
+        default=None,
+        help="""
+            Force a single wheel build when given an identifier. Overrides
+            CIBW_BUILD/CIBW_SKIP. --platform and --arch cannot be specified
+            if this is given.
+        """,
+    )
+
     parser.add_argument(
         "--output-dir",
         type=Path,
@@ -154,10 +165,40 @@ def main() -> None:
 
 
 def build_in_directory(args: CommandLineArguments) -> None:
+    platform_option_value = args.platform or os.environ.get("CIBW_PLATFORM", "auto")
     platform: PlatformName
 
-    if args.platform != "auto":
-        platform = args.platform
+    if args.only:
+        if "linux_" in args.only:
+            platform = "linux"
+        elif "macosx_" in args.only:
+            platform = "macos"
+        elif "win_" in args.only:
+            platform = "windows"
+        else:
+            print(
+                f"Invalid --only='{args.only}', must be a build selector with a known platform",
+                file=sys.stderr,
+            )
+            sys.exit(2)
+        if args.platform is not None:
+            print(
+                "--platform cannot be specified with --only, it is computed from --only",
+                file=sys.stderr,
+            )
+            sys.exit(2)
+        if args.archs is not None:
+            print(
+                "--arch cannot be specified with --only, it is computed from --only",
+                file=sys.stderr,
+            )
+            sys.exit(2)
+    elif platform_option_value != "auto":
+        if platform_option_value not in PLATFORMS:
+            print(f"cibuildwheel: Unsupported platform: {platform_option_value}", file=sys.stderr)
+            sys.exit(2)
+
+        platform = typing.cast(PlatformName, platform_option_value)
     else:
         ci_provider = detect_ci_provider()
         if ci_provider is None:
@@ -187,10 +228,6 @@ def build_in_directory(args: CommandLineArguments) -> None:
             )
             sys.exit(2)
 
-    if platform not in PLATFORMS:
-        print(f"cibuildwheel: Unsupported platform: {platform}", file=sys.stderr)
-        sys.exit(2)
-
     options = compute_options(platform=platform, command_line_arguments=args)
 
     package_dir = options.globals.package_dir
diff --git a/cibuildwheel/options.py b/cibuildwheel/options.py
index 241f0e0e..75e367c6 100644
--- a/cibuildwheel/options.py
+++ b/cibuildwheel/options.py
@@ -42,9 +42,10 @@
 
 @dataclass
 class CommandLineArguments:
-    platform: Literal["auto", "linux", "macos", "windows"]
+    platform: Literal["auto", "linux", "macos", "windows"] | None
     archs: str | None
     output_dir: Path
+    only: str | None
     config_file: str
     package_dir: Path
     print_build_identifiers: bool
@@ -403,6 +404,15 @@ def globals(self) -> GlobalOptions:
         )
         requires_python = None if requires_python_str is None else SpecifierSet(requires_python_str)
 
+        archs_config_str = args.archs or self.reader.get("archs", sep=" ")
+        architectures = Architecture.parse_config(archs_config_str, platform=self.platform)
+
+        # Process `--only`
+        if args.only:
+            build_config = args.only
+            skip_config = ""
+            architectures = Architecture.all_archs(self.platform)
+
         build_selector = BuildSelector(
             build_config=build_config,
             skip_config=skip_config,
@@ -411,9 +421,6 @@ def globals(self) -> GlobalOptions:
         )
         test_selector = TestSelector(skip_config=test_skip)
 
-        archs_config_str = args.archs or self.reader.get("archs", sep=" ")
-        architectures = Architecture.parse_config(archs_config_str, platform=self.platform)
-
         container_engine_str = self.reader.get("container-engine")
 
         if container_engine_str not in ["docker", "podman"]:
@@ -588,6 +595,9 @@ def summary(self, identifiers: list[str]) -> str:
         ]
 
         build_option_defaults = self.build_options(identifier=None)
+        build_options_for_identifier = {
+            identifier: self.build_options(identifier) for identifier in identifiers
+        }
 
         for option_name, default_value in sorted(asdict(build_option_defaults).items()):
             if option_name == "globals":
@@ -597,7 +607,7 @@ def summary(self, identifiers: list[str]) -> str:
 
             # if any identifiers have an overridden value, print that too
             for identifier in identifiers:
-                option_value = getattr(self.build_options(identifier=identifier), option_name)
+                option_value = getattr(build_options_for_identifier[identifier], option_name)
                 if option_value != default_value:
                     lines.append(f"  {identifier}: {option_value!r}")
 
diff --git a/cibuildwheel/util.py b/cibuildwheel/util.py
index 4b3d4fb9..e494929d 100644
--- a/cibuildwheel/util.py
+++ b/cibuildwheel/util.py
@@ -145,7 +145,7 @@ def shell(*commands: str, env: dict[str, str] | None = None, cwd: PathOrStr | No
     subprocess.run(command, env=env, cwd=cwd, shell=True, check=True)
 
 
-def format_safe(template: str, **kwargs: Any) -> str:
+def format_safe(template: str, **kwargs: str | os.PathLike[str]) -> str:
     """
     Works similarly to `template.format(**kwargs)`, except that unmatched
     fields in `template` are passed through untouched.
@@ -173,11 +173,9 @@ def format_safe(template: str, **kwargs: Any) -> str:
             re.VERBOSE,
         )
 
-        # we use a function for repl to prevent re.sub interpreting backslashes
-        # in repl as escape sequences.
         result = re.sub(
             pattern=find_pattern,
-            repl=lambda _: str(value),  # pylint: disable=cell-var-from-loop
+            repl=str(value).replace("\\", r"\\"),
             string=result,
         )
 
diff --git a/docs/options.md b/docs/options.md
index cc093079..498b15d0 100644
--- a/docs/options.md
+++ b/docs/options.md
@@ -113,7 +113,7 @@ ### Configuration file {: #configuration-file}
 
 !!! tip
     Static configuration works across all CI systems, and can be used locally if
-    you run `cibuildwheel --plat linux`. This is preferred, but environment
+    you run `cibuildwheel --platform linux`. This is preferred, but environment
     variables are better if you need to change per-matrix element
     (`CIBW_BUILD` is often in this category, for example), or if you cannot or do
     not want to change a `pyproject.toml` file. You can specify a different file to
@@ -202,6 +202,10 @@ ### `CIBW_PLATFORM` {: #platform}
 
     This is even more convenient if you store your cibuildwheel config in [`pyproject.toml`](#configuration-file).
 
+    You can also run a single identifier with `--only <identifier>`. This will
+    not require `--platform` or `--arch`, and will override any build/skip
+    configuration.
+
 ### `CIBW_BUILD`, `CIBW_SKIP` {: #build-skip}
 
 > Choose the Python versions to build


Test patch:
diff --git a/unit_test/main_tests/conftest.py b/unit_test/main_tests/conftest.py
index 25732187..c90fb555 100644
--- a/unit_test/main_tests/conftest.py
+++ b/unit_test/main_tests/conftest.py
@@ -12,7 +12,13 @@
 
 
 class ArgsInterceptor:
+    def __init__(self):
+        self.call_count = 0
+        self.args = None
+        self.kwargs = None
+
     def __call__(self, *args, **kwargs):
+        self.call_count += 1
         self.args = args
         self.kwargs = kwargs
 
@@ -75,16 +81,14 @@ def platform(request, monkeypatch):
 
 
 @pytest.fixture
-def intercepted_build_args(platform, monkeypatch):
+def intercepted_build_args(monkeypatch):
     intercepted = ArgsInterceptor()
 
-    if platform == "linux":
-        monkeypatch.setattr(linux, "build", intercepted)
-    elif platform == "macos":
-        monkeypatch.setattr(macos, "build", intercepted)
-    elif platform == "windows":
-        monkeypatch.setattr(windows, "build", intercepted)
-    else:
-        raise ValueError(f"unknown platform value: {platform}")
+    monkeypatch.setattr(linux, "build", intercepted)
+    monkeypatch.setattr(macos, "build", intercepted)
+    monkeypatch.setattr(windows, "build", intercepted)
+
+    yield intercepted
 
-    return intercepted
+    # check that intercepted_build_args only ever had one set of args
+    assert intercepted.call_count <= 1
diff --git a/unit_test/main_tests/main_platform_test.py b/unit_test/main_tests/main_platform_test.py
index 96fd4412..9136802c 100644
--- a/unit_test/main_tests/main_platform_test.py
+++ b/unit_test/main_tests/main_platform_test.py
@@ -192,3 +192,73 @@ def test_archs_platform_all(platform, intercepted_build_args, monkeypatch):
             Architecture.arm64,
             Architecture.universal2,
         }
+
+
+@pytest.mark.parametrize(
+    "only,plat",
+    (
+        ("cp311-manylinux_x86_64", "linux"),
+        ("cp310-win_amd64", "windows"),
+        ("cp311-macosx_x86_64", "macos"),
+    ),
+)
+def test_only_argument(intercepted_build_args, monkeypatch, only, plat):
+    monkeypatch.setenv("CIBW_BUILD", "unused")
+    monkeypatch.setenv("CIBW_SKIP", "unused")
+    monkeypatch.setattr(sys, "argv", sys.argv + ["--only", only])
+
+    main()
+
+    options = intercepted_build_args.args[0]
+    assert options.globals.build_selector.build_config == only
+    assert options.globals.build_selector.skip_config == ""
+    assert options.platform == plat
+    assert options.globals.architectures == Architecture.all_archs(plat)
+
+
+@pytest.mark.parametrize("only", ("cp311-manylxinux_x86_64", "some_linux_thing"))
+def test_only_failed(monkeypatch, only):
+    monkeypatch.setattr(sys, "argv", sys.argv + ["--only", only])
+
+    with pytest.raises(SystemExit):
+        main()
+
+
+def test_only_no_platform(monkeypatch):
+    monkeypatch.setattr(
+        sys, "argv", sys.argv + ["--only", "cp311-manylinux_x86_64", "--platform", "macos"]
+    )
+
+    with pytest.raises(SystemExit):
+        main()
+
+
+def test_only_no_archs(monkeypatch):
+    monkeypatch.setattr(
+        sys, "argv", sys.argv + ["--only", "cp311-manylinux_x86_64", "--archs", "x86_64"]
+    )
+
+    with pytest.raises(SystemExit):
+        main()
+
+
+@pytest.mark.parametrize(
+    "envvar_name,envvar_value",
+    (
+        ("CIBW_BUILD", "cp310-*"),
+        ("CIBW_SKIP", "cp311-*"),
+        ("CIBW_ARCHS", "auto32"),
+        ("CIBW_PLATFORM", "macos"),
+    ),
+)
+def test_only_overrides_env_vars(monkeypatch, intercepted_build_args, envvar_name, envvar_value):
+    monkeypatch.setattr(sys, "argv", sys.argv + ["--only", "cp311-manylinux_x86_64"])
+    monkeypatch.setenv(envvar_name, envvar_value)
+
+    main()
+
+    options = intercepted_build_args.args[0]
+    assert options.globals.build_selector.build_config == "cp311-manylinux_x86_64"
+    assert options.globals.build_selector.skip_config == ""
+    assert options.platform == "linux"
+    assert options.globals.architectures == Architecture.all_archs("linux")
diff --git a/unit_test/utils.py b/unit_test/utils.py
index 0cd2fefb..bf44382a 100644
--- a/unit_test/utils.py
+++ b/unit_test/utils.py
@@ -10,6 +10,7 @@ def get_default_command_line_arguments() -> CommandLineArguments:
         platform="auto",
         allow_empty=False,
         archs=None,
+        only=None,
         config_file="",
         output_dir=Path("wheelhouse"),
         package_dir=Path("."),