Skip to content

Commit

Permalink
Merge pull request #1499 from pypa/docker-flags
Browse files Browse the repository at this point in the history
Add create_args suboption to CIBW_CONTAINER_ENGINE
  • Loading branch information
joerick committed May 26, 2023
2 parents 754a473 + 9b90477 commit b2bc6fd
Show file tree
Hide file tree
Showing 9 changed files with 293 additions and 36 deletions.
4 changes: 3 additions & 1 deletion cibuildwheel/linux.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,9 @@ def build(options: Options, tmp_path: Path) -> None: # noqa: ARG001
try:
# check the container engine is installed
subprocess.run(
[options.globals.container_engine, "--version"], check=True, stdout=subprocess.DEVNULL
[options.globals.container_engine.name, "--version"],
check=True,
stdout=subprocess.DEVNULL,
)
except subprocess.CalledProcessError:
print(
Expand Down
64 changes: 47 additions & 17 deletions cibuildwheel/oci_container.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,44 @@
import typing
import uuid
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path, PurePath, PurePosixPath
from types import TracebackType
from typing import IO, Dict

from ._compat.typing import Literal
from .typing import PathOrStr, PopenBytes
from .util import CIProvider, detect_ci_provider
from .util import CIProvider, detect_ci_provider, parse_key_value_string

ContainerEngine = Literal["docker", "podman"]
ContainerEngineName = Literal["docker", "podman"]


@dataclass(frozen=True)
class OCIContainerEngineConfig:
name: ContainerEngineName
create_args: Sequence[str] = ()

@staticmethod
def from_config_string(config_string: str) -> OCIContainerEngineConfig:
config_dict = parse_key_value_string(config_string, ["name"])
name = " ".join(config_dict["name"])
if name not in {"docker", "podman"}:
msg = f"unknown container engine {name}"
raise ValueError(msg)

name = typing.cast(ContainerEngineName, name)
# some flexibility in the option name to cope with TOML conventions
create_args = config_dict.get("create_args") or config_dict.get("create-args") or []
return OCIContainerEngineConfig(name=name, create_args=create_args)

def options_summary(self) -> str | dict[str, str]:
if not self.create_args:
return self.name
else:
return {"name": self.name, "create_args": repr(self.create_args)}


DEFAULT_ENGINE = OCIContainerEngineConfig("docker")


class OCIContainer:
Expand Down Expand Up @@ -57,7 +86,7 @@ def __init__(
image: str,
simulate_32_bit: bool = False,
cwd: PathOrStr | None = None,
engine: ContainerEngine = "docker",
engine: OCIContainerEngineConfig = DEFAULT_ENGINE,
):
if not image:
msg = "Must have a non-empty image to run."
Expand All @@ -84,13 +113,14 @@ def __enter__(self) -> OCIContainer:

subprocess.run(
[
self.engine,
self.engine.name,
"create",
"--env=CIBUILDWHEEL",
f"--name={self.name}",
"--interactive",
"--volume=/:/host", # ignored on CircleCI
*network_args,
*self.engine.create_args,
self.image,
*shell_args,
],
Expand All @@ -99,7 +129,7 @@ def __enter__(self) -> OCIContainer:

self.process = subprocess.Popen(
[
self.engine,
self.engine.name,
"start",
"--attach",
"--interactive",
Expand Down Expand Up @@ -137,7 +167,7 @@ def __exit__(
self.bash_stdin.close()
self.bash_stdout.close()

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

subprocess.run(
[self.engine, "rm", "--force", "-v", self.name],
[self.engine.name, "rm", "--force", "-v", self.name],
stdout=subprocess.DEVNULL,
check=False,
)
Expand All @@ -162,7 +192,7 @@ def copy_into(self, from_path: Path, to_path: PurePath) -> None:
if from_path.is_dir():
self.call(["mkdir", "-p", to_path])
subprocess.run(
f"tar cf - . | {self.engine} exec -i {self.name} tar --no-same-owner -xC {shell_quote(to_path)} -f -",
f"tar cf - . | {self.engine.name} exec -i {self.name} tar --no-same-owner -xC {shell_quote(to_path)} -f -",
shell=True,
check=True,
cwd=from_path,
Expand All @@ -171,7 +201,7 @@ def copy_into(self, from_path: Path, to_path: PurePath) -> None:
exec_process: subprocess.Popen[bytes]
with subprocess.Popen(
[
self.engine,
self.engine.name,
"exec",
"-i",
str(self.name),
Expand All @@ -198,29 +228,29 @@ def copy_out(self, from_path: PurePath, to_path: Path) -> None:
# note: we assume from_path is a dir
to_path.mkdir(parents=True, exist_ok=True)

if self.engine == "podman":
if self.engine.name == "podman":
subprocess.run(
[
self.engine,
self.engine.name,
"cp",
f"{self.name}:{from_path}/.",
str(to_path),
],
check=True,
cwd=to_path,
)
elif self.engine == "docker":
elif self.engine.name == "docker":
# There is a bug in docker that prevents a simple 'cp' invocation
# from working https://github.com/moby/moby/issues/38995
command = f"{self.engine} exec -i {self.name} tar -cC {shell_quote(from_path)} -f - . | tar -xf -"
command = f"{self.engine.name} exec -i {self.name} tar -cC {shell_quote(from_path)} -f - . | tar -xf -"
subprocess.run(
command,
shell=True,
check=True,
cwd=to_path,
)
else:
raise KeyError(self.engine)
raise KeyError(self.engine.name)

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

def debug_info(self) -> str:
if self.engine == "podman":
command = f"{self.engine} info --debug"
if self.engine.name == "podman":
command = f"{self.engine.name} info --debug"
else:
command = f"{self.engine} info"
command = f"{self.engine.name} info"
completed = subprocess.run(
command,
shell=True,
Expand Down
23 changes: 15 additions & 8 deletions cibuildwheel/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
import sys
import textwrap
import traceback
import typing
from collections.abc import Callable, Generator, Iterable, Iterator, Mapping, Set
from pathlib import Path
from typing import Any, Dict, List, Union
Expand All @@ -22,7 +21,7 @@
from .architecture import Architecture
from .environment import EnvironmentParseError, ParsedEnvironment, parse_environment
from .logger import log
from .oci_container import ContainerEngine
from .oci_container import OCIContainerEngineConfig
from .projectfiles import get_requires_python_str
from .typing import PLATFORMS, PlatformName
from .util import (
Expand Down Expand Up @@ -75,7 +74,7 @@ class GlobalOptions:
build_selector: BuildSelector
test_selector: TestSelector
architectures: set[Architecture]
container_engine: ContainerEngine
container_engine: OCIContainerEngineConfig


@dataclasses.dataclass(frozen=True)
Expand Down Expand Up @@ -136,8 +135,14 @@ class Override:


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


Expand Down Expand Up @@ -454,15 +459,17 @@ def globals(self) -> GlobalOptions:
)
test_selector = TestSelector(skip_config=test_skip)

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

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

container_engine = typing.cast(ContainerEngine, container_engine_str)

return GlobalOptions(
package_dir=package_dir,
output_dir=output_dir,
Expand Down
38 changes: 38 additions & 0 deletions cibuildwheel/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import time
import typing
import urllib.request
from collections import defaultdict
from collections.abc import Generator, Iterable, Mapping, Sequence
from dataclasses import dataclass
from enum import Enum
Expand Down Expand Up @@ -697,3 +698,40 @@ def fix_ansi_codes_for_github_actions(text: str) -> str:
ansi_codes.append(code)

return output


def parse_key_value_string(
key_value_string: str, positional_arg_names: list[str] | None = None
) -> dict[str, list[str]]:
"""
Parses a string like "docker; create_args: --some-option=value another-option"
"""
if positional_arg_names is None:
positional_arg_names = []

shlexer = shlex.shlex(key_value_string, posix=True, punctuation_chars=";:")
shlexer.commenters = ""
parts = list(shlexer)
# parts now looks like
# ['docker', ';', 'create_args',':', '--some-option=value', 'another-option']

# split by semicolon
fields = [list(group) for k, group in itertools.groupby(parts, lambda x: x == ";") if not k]

result: dict[str, list[str]] = defaultdict(list)
for field_i, field in enumerate(fields):
if len(field) > 1 and field[1] == ":":
field_name = field[0]
values = field[2:]
else:
try:
field_name = positional_arg_names[field_i]
except IndexError:
msg = f"Failed to parse {key_value_string!r}. Too many positional arguments - expected a maximum of {len(positional_arg_names)}"
raise ValueError(msg) from None

values = field

result[field_name] += values

return result
21 changes: 19 additions & 2 deletions docs/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -1048,9 +1048,12 @@ Auditwheel detects the version of the manylinux / musllinux standard in the imag


### `CIBW_CONTAINER_ENGINE` {: #container-engine}
> Specify which container engine to use when building Linux wheels
> Specify the container engine to use when building Linux wheels
Options: `docker` `podman`
Options:

- `docker[;create_args: ...]`
- `podman[;create_args: ...]`

Default: `docker`

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

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

!!! tip

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

```yaml
# use podman instead of docker
CIBW_CONTAINER_ENGINE: podman

# pass command line options to 'docker create'
CIBW_CONTAINER_ENGINE: "docker; create_args: --gpus all"
```

!!! tab examples "pyproject.toml"

```toml
[tool.cibuildwheel]
# use podman instead of docker
container-engine = "podman"

# pass command line options to 'docker create'
container-engine = { name = "docker", create-args = ["--gpus", "all"]}
```


Expand Down
28 changes: 27 additions & 1 deletion test/test_podman.py → test/test_container_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
basic_project = test_projects.new_c_project()


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

Expand Down Expand Up @@ -38,3 +38,29 @@ def test(tmp_path, capfd, request):
# check that stdout is bring passed-though from container correctly
captured = capfd.readouterr()
assert "test log statement from before-all" in captured.out


def test_create_args(tmp_path, capfd):
if utils.platform != "linux":
pytest.skip("the test is only relevant to the linux build")

project_dir = tmp_path / "project"
basic_project.generate(project_dir)

# build a manylinux wheel, using create_args to set an environment variable
actual_wheels = utils.cibuildwheel_run(
project_dir,
add_env={
"CIBW_BUILD": "cp310-manylinux_*",
"CIBW_BEFORE_ALL": "echo TEST_CREATE_ARGS is set to $TEST_CREATE_ARGS",
"CIBW_CONTAINER_ENGINE": "docker; create_args: --env=TEST_CREATE_ARGS=itworks",
},
)

expected_wheels = [
w for w in utils.expected_wheels("spam", "0.1.0") if ("cp310-manylinux" in w)
]
assert set(actual_wheels) == set(expected_wheels)

captured = capfd.readouterr()
assert "TEST_CREATE_ARGS is set to itworks" in captured.out
Loading

0 comments on commit b2bc6fd

Please sign in to comment.