Skip to content

Commit b2bc6fd

Browse files
authored
Merge pull request #1499 from pypa/docker-flags
Add create_args suboption to CIBW_CONTAINER_ENGINE
2 parents 754a473 + 9b90477 commit b2bc6fd

File tree

9 files changed

+293
-36
lines changed

9 files changed

+293
-36
lines changed

cibuildwheel/linux.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,9 @@ def build(options: Options, tmp_path: Path) -> None: # noqa: ARG001
379379
try:
380380
# check the container engine is installed
381381
subprocess.run(
382-
[options.globals.container_engine, "--version"], check=True, stdout=subprocess.DEVNULL
382+
[options.globals.container_engine.name, "--version"],
383+
check=True,
384+
stdout=subprocess.DEVNULL,
383385
)
384386
except subprocess.CalledProcessError:
385387
print(

cibuildwheel/oci_container.py

+47-17
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,44 @@
1111
import typing
1212
import uuid
1313
from collections.abc import Mapping, Sequence
14+
from dataclasses import dataclass
1415
from pathlib import Path, PurePath, PurePosixPath
1516
from types import TracebackType
1617
from typing import IO, Dict
1718

1819
from ._compat.typing import Literal
1920
from .typing import PathOrStr, PopenBytes
20-
from .util import CIProvider, detect_ci_provider
21+
from .util import CIProvider, detect_ci_provider, parse_key_value_string
2122

22-
ContainerEngine = Literal["docker", "podman"]
23+
ContainerEngineName = Literal["docker", "podman"]
24+
25+
26+
@dataclass(frozen=True)
27+
class OCIContainerEngineConfig:
28+
name: ContainerEngineName
29+
create_args: Sequence[str] = ()
30+
31+
@staticmethod
32+
def from_config_string(config_string: str) -> OCIContainerEngineConfig:
33+
config_dict = parse_key_value_string(config_string, ["name"])
34+
name = " ".join(config_dict["name"])
35+
if name not in {"docker", "podman"}:
36+
msg = f"unknown container engine {name}"
37+
raise ValueError(msg)
38+
39+
name = typing.cast(ContainerEngineName, name)
40+
# some flexibility in the option name to cope with TOML conventions
41+
create_args = config_dict.get("create_args") or config_dict.get("create-args") or []
42+
return OCIContainerEngineConfig(name=name, create_args=create_args)
43+
44+
def options_summary(self) -> str | dict[str, str]:
45+
if not self.create_args:
46+
return self.name
47+
else:
48+
return {"name": self.name, "create_args": repr(self.create_args)}
49+
50+
51+
DEFAULT_ENGINE = OCIContainerEngineConfig("docker")
2352

2453

2554
class OCIContainer:
@@ -57,7 +86,7 @@ def __init__(
5786
image: str,
5887
simulate_32_bit: bool = False,
5988
cwd: PathOrStr | None = None,
60-
engine: ContainerEngine = "docker",
89+
engine: OCIContainerEngineConfig = DEFAULT_ENGINE,
6190
):
6291
if not image:
6392
msg = "Must have a non-empty image to run."
@@ -84,13 +113,14 @@ def __enter__(self) -> OCIContainer:
84113

85114
subprocess.run(
86115
[
87-
self.engine,
116+
self.engine.name,
88117
"create",
89118
"--env=CIBUILDWHEEL",
90119
f"--name={self.name}",
91120
"--interactive",
92121
"--volume=/:/host", # ignored on CircleCI
93122
*network_args,
123+
*self.engine.create_args,
94124
self.image,
95125
*shell_args,
96126
],
@@ -99,7 +129,7 @@ def __enter__(self) -> OCIContainer:
99129

100130
self.process = subprocess.Popen(
101131
[
102-
self.engine,
132+
self.engine.name,
103133
"start",
104134
"--attach",
105135
"--interactive",
@@ -137,7 +167,7 @@ def __exit__(
137167
self.bash_stdin.close()
138168
self.bash_stdout.close()
139169

140-
if self.engine == "podman":
170+
if self.engine.name == "podman":
141171
# This works around what seems to be a race condition in the podman
142172
# backend. The full reason is not understood. See PR #966 for a
143173
# discussion on possible causes and attempts to remove this line.
@@ -147,7 +177,7 @@ def __exit__(
147177
assert isinstance(self.name, str)
148178

149179
subprocess.run(
150-
[self.engine, "rm", "--force", "-v", self.name],
180+
[self.engine.name, "rm", "--force", "-v", self.name],
151181
stdout=subprocess.DEVNULL,
152182
check=False,
153183
)
@@ -162,7 +192,7 @@ def copy_into(self, from_path: Path, to_path: PurePath) -> None:
162192
if from_path.is_dir():
163193
self.call(["mkdir", "-p", to_path])
164194
subprocess.run(
165-
f"tar cf - . | {self.engine} exec -i {self.name} tar --no-same-owner -xC {shell_quote(to_path)} -f -",
195+
f"tar cf - . | {self.engine.name} exec -i {self.name} tar --no-same-owner -xC {shell_quote(to_path)} -f -",
166196
shell=True,
167197
check=True,
168198
cwd=from_path,
@@ -171,7 +201,7 @@ def copy_into(self, from_path: Path, to_path: PurePath) -> None:
171201
exec_process: subprocess.Popen[bytes]
172202
with subprocess.Popen(
173203
[
174-
self.engine,
204+
self.engine.name,
175205
"exec",
176206
"-i",
177207
str(self.name),
@@ -198,29 +228,29 @@ def copy_out(self, from_path: PurePath, to_path: Path) -> None:
198228
# note: we assume from_path is a dir
199229
to_path.mkdir(parents=True, exist_ok=True)
200230

201-
if self.engine == "podman":
231+
if self.engine.name == "podman":
202232
subprocess.run(
203233
[
204-
self.engine,
234+
self.engine.name,
205235
"cp",
206236
f"{self.name}:{from_path}/.",
207237
str(to_path),
208238
],
209239
check=True,
210240
cwd=to_path,
211241
)
212-
elif self.engine == "docker":
242+
elif self.engine.name == "docker":
213243
# There is a bug in docker that prevents a simple 'cp' invocation
214244
# from working https://github.com/moby/moby/issues/38995
215-
command = f"{self.engine} exec -i {self.name} tar -cC {shell_quote(from_path)} -f - . | tar -xf -"
245+
command = f"{self.engine.name} exec -i {self.name} tar -cC {shell_quote(from_path)} -f - . | tar -xf -"
216246
subprocess.run(
217247
command,
218248
shell=True,
219249
check=True,
220250
cwd=to_path,
221251
)
222252
else:
223-
raise KeyError(self.engine)
253+
raise KeyError(self.engine.name)
224254

225255
def glob(self, path: PurePosixPath, pattern: str) -> list[PurePosixPath]:
226256
glob_pattern = path.joinpath(pattern)
@@ -338,10 +368,10 @@ def environment_executor(self, command: Sequence[str], environment: dict[str, st
338368
return self.call(command, env=environment, capture_output=True)
339369

340370
def debug_info(self) -> str:
341-
if self.engine == "podman":
342-
command = f"{self.engine} info --debug"
371+
if self.engine.name == "podman":
372+
command = f"{self.engine.name} info --debug"
343373
else:
344-
command = f"{self.engine} info"
374+
command = f"{self.engine.name} info"
345375
completed = subprocess.run(
346376
command,
347377
shell=True,

cibuildwheel/options.py

+15-8
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
import sys
1111
import textwrap
1212
import traceback
13-
import typing
1413
from collections.abc import Callable, Generator, Iterable, Iterator, Mapping, Set
1514
from pathlib import Path
1615
from typing import Any, Dict, List, Union
@@ -22,7 +21,7 @@
2221
from .architecture import Architecture
2322
from .environment import EnvironmentParseError, ParsedEnvironment, parse_environment
2423
from .logger import log
25-
from .oci_container import ContainerEngine
24+
from .oci_container import OCIContainerEngineConfig
2625
from .projectfiles import get_requires_python_str
2726
from .typing import PLATFORMS, PlatformName
2827
from .util import (
@@ -75,7 +74,7 @@ class GlobalOptions:
7574
build_selector: BuildSelector
7675
test_selector: TestSelector
7776
architectures: set[Architecture]
78-
container_engine: ContainerEngine
77+
container_engine: OCIContainerEngineConfig
7978

8079

8180
@dataclasses.dataclass(frozen=True)
@@ -136,8 +135,14 @@ class Override:
136135

137136

138137
class TableFmt(TypedDict):
138+
# a format string, used with '.format', with `k` and `v` parameters
139+
# e.g. "{k}={v}"
139140
item: str
141+
# the string that is inserted between items
142+
# e.g. " "
140143
sep: str
144+
# a quoting function that, if supplied, is called to quote each value
145+
# e.g. shlex.quote
141146
quote: NotRequired[Callable[[str], str]]
142147

143148

@@ -454,15 +459,17 @@ def globals(self) -> GlobalOptions:
454459
)
455460
test_selector = TestSelector(skip_config=test_skip)
456461

457-
container_engine_str = self.reader.get("container-engine")
462+
container_engine_str = self.reader.get(
463+
"container-engine", table={"item": "{k}:{v}", "sep": "; ", "quote": shlex.quote}
464+
)
458465

459-
if container_engine_str not in ["docker", "podman"]:
460-
msg = f"cibuildwheel: Unrecognised container_engine {container_engine_str!r}, only 'docker' and 'podman' are supported"
466+
try:
467+
container_engine = OCIContainerEngineConfig.from_config_string(container_engine_str)
468+
except ValueError as e:
469+
msg = f"cibuildwheel: Failed to parse container config. {e}"
461470
print(msg, file=sys.stderr)
462471
sys.exit(2)
463472

464-
container_engine = typing.cast(ContainerEngine, container_engine_str)
465-
466473
return GlobalOptions(
467474
package_dir=package_dir,
468475
output_dir=output_dir,

cibuildwheel/util.py

+38
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import time
1414
import typing
1515
import urllib.request
16+
from collections import defaultdict
1617
from collections.abc import Generator, Iterable, Mapping, Sequence
1718
from dataclasses import dataclass
1819
from enum import Enum
@@ -697,3 +698,40 @@ def fix_ansi_codes_for_github_actions(text: str) -> str:
697698
ansi_codes.append(code)
698699

699700
return output
701+
702+
703+
def parse_key_value_string(
704+
key_value_string: str, positional_arg_names: list[str] | None = None
705+
) -> dict[str, list[str]]:
706+
"""
707+
Parses a string like "docker; create_args: --some-option=value another-option"
708+
"""
709+
if positional_arg_names is None:
710+
positional_arg_names = []
711+
712+
shlexer = shlex.shlex(key_value_string, posix=True, punctuation_chars=";:")
713+
shlexer.commenters = ""
714+
parts = list(shlexer)
715+
# parts now looks like
716+
# ['docker', ';', 'create_args',':', '--some-option=value', 'another-option']
717+
718+
# split by semicolon
719+
fields = [list(group) for k, group in itertools.groupby(parts, lambda x: x == ";") if not k]
720+
721+
result: dict[str, list[str]] = defaultdict(list)
722+
for field_i, field in enumerate(fields):
723+
if len(field) > 1 and field[1] == ":":
724+
field_name = field[0]
725+
values = field[2:]
726+
else:
727+
try:
728+
field_name = positional_arg_names[field_i]
729+
except IndexError:
730+
msg = f"Failed to parse {key_value_string!r}. Too many positional arguments - expected a maximum of {len(positional_arg_names)}"
731+
raise ValueError(msg) from None
732+
733+
values = field
734+
735+
result[field_name] += values
736+
737+
return result

docs/options.md

+19-2
Original file line numberDiff line numberDiff line change
@@ -1048,9 +1048,12 @@ Auditwheel detects the version of the manylinux / musllinux standard in the imag
10481048

10491049

10501050
### `CIBW_CONTAINER_ENGINE` {: #container-engine}
1051-
> Specify which container engine to use when building Linux wheels
1051+
> Specify the container engine to use when building Linux wheels
10521052
1053-
Options: `docker` `podman`
1053+
Options:
1054+
1055+
- `docker[;create_args: ...]`
1056+
- `podman[;create_args: ...]`
10541057

10551058
Default: `docker`
10561059

@@ -1059,6 +1062,12 @@ Set the container engine to use. Docker is the default, or you can switch to
10591062
running and `docker` available on PATH. To use Podman, it needs to be
10601063
installed and `podman` available on PATH.
10611064

1065+
Arguments can be supplied to the container engine. Currently, the only option
1066+
that's customisable is 'create_args'. Parameters to create_args are
1067+
space-separated strings, which are passed to the container engine on the
1068+
command line when it's creating the container. If you want to include spaces
1069+
inside a parameter, use shell-style quoting.
1070+
10621071
!!! tip
10631072

10641073
While most users will stick with Docker, Podman is available in different
@@ -1073,14 +1082,22 @@ installed and `podman` available on PATH.
10731082
!!! tab examples "Environment variables"
10741083

10751084
```yaml
1085+
# use podman instead of docker
10761086
CIBW_CONTAINER_ENGINE: podman
1087+
1088+
# pass command line options to 'docker create'
1089+
CIBW_CONTAINER_ENGINE: "docker; create_args: --gpus all"
10771090
```
10781091

10791092
!!! tab examples "pyproject.toml"
10801093

10811094
```toml
10821095
[tool.cibuildwheel]
1096+
# use podman instead of docker
10831097
container-engine = "podman"
1098+
1099+
# pass command line options to 'docker create'
1100+
container-engine = { name = "docker", create-args = ["--gpus", "all"]}
10841101
```
10851102

10861103

test/test_podman.py renamed to test/test_container_engine.py

+27-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
basic_project = test_projects.new_c_project()
88

99

10-
def test(tmp_path, capfd, request):
10+
def test_podman(tmp_path, capfd, request):
1111
if utils.platform != "linux":
1212
pytest.skip("the test is only relevant to the linux build")
1313

@@ -38,3 +38,29 @@ def test(tmp_path, capfd, request):
3838
# check that stdout is bring passed-though from container correctly
3939
captured = capfd.readouterr()
4040
assert "test log statement from before-all" in captured.out
41+
42+
43+
def test_create_args(tmp_path, capfd):
44+
if utils.platform != "linux":
45+
pytest.skip("the test is only relevant to the linux build")
46+
47+
project_dir = tmp_path / "project"
48+
basic_project.generate(project_dir)
49+
50+
# build a manylinux wheel, using create_args to set an environment variable
51+
actual_wheels = utils.cibuildwheel_run(
52+
project_dir,
53+
add_env={
54+
"CIBW_BUILD": "cp310-manylinux_*",
55+
"CIBW_BEFORE_ALL": "echo TEST_CREATE_ARGS is set to $TEST_CREATE_ARGS",
56+
"CIBW_CONTAINER_ENGINE": "docker; create_args: --env=TEST_CREATE_ARGS=itworks",
57+
},
58+
)
59+
60+
expected_wheels = [
61+
w for w in utils.expected_wheels("spam", "0.1.0") if ("cp310-manylinux" in w)
62+
]
63+
assert set(actual_wheels) == set(expected_wheels)
64+
65+
captured = capfd.readouterr()
66+
assert "TEST_CREATE_ARGS is set to itworks" in captured.out

0 commit comments

Comments
 (0)