Repository: pypa/cibuildwheel

Issue:
feat: add formatted indentifier printout
See #1261 - this would enable easier production of dynamic matrix outputs from `cibuildwheel`.

Example:

```console
$ cibuildwheel --print-build-identifiers '{{"CIBW_BUILD": "{identifier}", "CIBW_ARCHS":"{arch}", "os": "ubuntu-latest"}}' --platform linux | jq -s
[
  {
    "CIBW_BUILD": "cp36-manylinux_x86_64",
    "CIBW_ARCHS": "x86_64",
    "os": "ubuntu-latest"
  },
  {
    "CIBW_BUILD": "cp37-manylinux_x86_64",
    "CIBW_ARCHS": "x86_64",
    "os": "ubuntu-latest"
  },
  ...
```

You should be able to concat multiple platforms as well.

Todo:

- [ ] Verify this is a good idea / makes using this easier in #1261.
- [ ] Tests
- [ ] Any other values useful in the format string?
- [ ] Is there any way to use this from the action, so the version stays locked with the cibuildwheel invocation that runs the builds?
- [ ] This does not have a way to group smaller chunks of runs, sadly, but maybe that could be done via jq? I did include `version` if that's useful - so we have `version`, `identifier`, and `arch`. You'd have to combine the contents of the build field. But you could write a processor to do this at least much easier than now.

I could have done this by just processing the build identifier, and not touching `get_build_identifiers` - version, pp/cp, os, and arch are all obtainable by processing the identifier. Maybe that would be better. Edit: trying that in a new commit. Also added `impl` and `os`.


Discussion:

Resolving patch:
diff --git a/cibuildwheel/__main__.py b/cibuildwheel/__main__.py
--- a/cibuildwheel/__main__.py
+++ b/cibuildwheel/__main__.py
@@ -7,6 +7,7 @@
 import tarfile
 import tempfile
 import textwrap
+import typing
 from pathlib import Path
 from tempfile import mkdtemp
 
@@ -41,7 +42,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
@@ -65,6 +66,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,
@@ -149,10 +160,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:
@@ -182,10 +223,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
--- a/cibuildwheel/options.py
+++ b/cibuildwheel/options.py
@@ -41,9 +41,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
@@ -388,6 +389,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,
@@ -396,9 +406,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"]:
@@ -569,6 +576,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":
@@ -578,7 +588,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
--- a/cibuildwheel/util.py
+++ b/cibuildwheel/util.py
@@ -144,7 +144,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.
@@ -172,11 +172,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,
         )
 


Test patch:
diff --git a/unit_test/main_tests/conftest.py b/unit_test/main_tests/conftest.py
--- 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
--- 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
--- 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("."),