Skip to content

Commit 105d195

Browse files
authored
Better typing for docker build options (#4262)
1 parent 3389cac commit 105d195

File tree

7 files changed

+174
-74
lines changed

7 files changed

+174
-74
lines changed

src/zenml/config/docker_settings.py

Lines changed: 115 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,20 +55,129 @@ class PythonPackageInstaller(Enum):
5555
UV = "uv"
5656

5757

58+
# (docker_sdk_argument, attribute_name)
59+
BUILD_OPTION_CONVERSIONS = [
60+
("buildargs", "build_args"),
61+
("cachefrom", "cache_from"),
62+
("nocache", "no_cache"),
63+
("shmsize", "shm_size"),
64+
]
65+
66+
67+
class DockerBuildOptions(BaseModel):
68+
"""Docker build options.
69+
70+
This class only specifies a subset of the options which require explicit
71+
conversion as they require different names in the Docker CLI and Python SDK.
72+
However, you can still specify any other options as extra model attributes
73+
which will be passed unmodified to the build method of the image builder.
74+
"""
75+
76+
pull: Optional[bool] = None
77+
rm: Optional[bool] = None
78+
no_cache: Optional[bool] = None
79+
shm_size: Optional[int] = None
80+
labels: Optional[Dict[str, Any]] = None
81+
build_args: Optional[Dict[str, Any]] = None
82+
cache_from: Optional[List[str]] = None
83+
84+
model_config = ConfigDict(extra="allow")
85+
86+
@model_validator(mode="before")
87+
@classmethod
88+
@before_validator_handler
89+
def _migrate_sdk_arguments(cls, data: Dict[str, Any]) -> Dict[str, Any]:
90+
"""Migrate Docker SDK arguments to attributes.
91+
92+
Args:
93+
data: The model data.
94+
95+
Returns:
96+
The migrated data.
97+
"""
98+
for sdk_argument, attribute_name in BUILD_OPTION_CONVERSIONS:
99+
if sdk_argument in data and attribute_name not in data:
100+
data[attribute_name] = data.pop(sdk_argument)
101+
102+
return data
103+
104+
def to_docker_cli_options(self) -> List[str]:
105+
"""Convert the build options to a list of Docker CLI options.
106+
107+
https://docs.docker.com/reference/cli/docker/buildx/build/#options
108+
109+
Returns:
110+
A list of Docker CLI options.
111+
"""
112+
options = []
113+
if self.pull:
114+
options.append("--pull")
115+
if self.rm:
116+
options.append("--rm")
117+
if self.no_cache:
118+
options.append("--no-cache")
119+
if self.shm_size:
120+
options.extend(["--shm-size", str(self.shm_size)])
121+
if self.labels:
122+
for key, value in self.labels.items():
123+
options.extend(["--label", f"{key}={value}"])
124+
if self.build_args:
125+
for key, value in self.build_args.items():
126+
options.extend(["--build-arg", f"{key}={value}"])
127+
if self.cache_from:
128+
for value in self.cache_from:
129+
options.extend(["--cache-from", value])
130+
131+
if self.model_extra:
132+
for key, value in self.model_extra.items():
133+
option = f"--{key.replace('_', '-')}"
134+
if isinstance(value, Dict):
135+
for key, value in value.items():
136+
options.extend([option, f"{key}={value}"])
137+
elif isinstance(value, List):
138+
for val in value:
139+
options.extend([option, str(val)])
140+
elif value in (True, None):
141+
options.extend([option])
142+
elif value is False:
143+
pass
144+
else:
145+
options.extend([option, str(value)])
146+
147+
return options
148+
149+
def to_docker_python_sdk_options(self) -> Dict[str, Any]:
150+
"""Get the build options as a dictionary of Docker Python SDK options.
151+
152+
https://docker-py.readthedocs.io/en/stable/images.html#docker.models.images.ImageCollection.build
153+
154+
Returns:
155+
A dictionary of Docker Python SDK options.
156+
"""
157+
options = self.model_dump(exclude_unset=True)
158+
for sdk_argument, attribute_name in BUILD_OPTION_CONVERSIONS:
159+
if attribute_name in options:
160+
options[sdk_argument] = options.pop(attribute_name)
161+
162+
return options
163+
164+
58165
class DockerBuildConfig(BaseModel):
59166
"""Configuration for a Docker build.
60167
61168
Attributes:
62-
build_options: Additional options that will be passed unmodified to the
63-
Docker build call when building an image. You can use this to for
64-
example specify build args or a target stage. See
65-
https://docker-py.readthedocs.io/en/stable/images.html#docker.models.images.ImageCollection.build
66-
for a full list of available options.
169+
build_options: Additional options that will be passed when building an
170+
image. Depending on the image builder that is used, different
171+
options are available.
172+
For image builders that use the Docker CLI:
173+
- https://docs.docker.com/reference/cli/docker/buildx/build/#options
174+
For image builders that use the Docker Python SDK:
175+
- https://docker-py.readthedocs.io/en/stable/images.html#docker.models.images.ImageCollection.build
67176
dockerignore: Path to a dockerignore file to use when building the
68177
Docker image.
69178
"""
70179

71-
build_options: Dict[str, Any] = {}
180+
build_options: Optional[DockerBuildOptions] = None
72181
dockerignore: Optional[str] = None
73182

74183

src/zenml/image_builders/base_image_builder.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import os
1818
import tempfile
1919
from abc import ABC, abstractmethod
20-
from typing import TYPE_CHECKING, Any, Dict, Optional, Type, cast
20+
from typing import TYPE_CHECKING, Optional, Type, cast
2121

2222
from zenml.client import Client
2323
from zenml.enums import StackComponentType
@@ -28,6 +28,7 @@
2828
from zenml.utils.archivable import ArchiveType
2929

3030
if TYPE_CHECKING:
31+
from zenml.config.docker_settings import DockerBuildOptions
3132
from zenml.container_registries import BaseContainerRegistry
3233
from zenml.image_builders import BuildContext
3334

@@ -79,7 +80,7 @@ def build(
7980
self,
8081
image_name: str,
8182
build_context: "BuildContext",
82-
docker_build_options: Dict[str, Any],
83+
docker_build_options: Optional["DockerBuildOptions"] = None,
8384
container_registry: Optional["BaseContainerRegistry"] = None,
8485
) -> str:
8586
"""Builds a Docker image.

src/zenml/image_builders/local_image_builder.py

Lines changed: 14 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import shutil
1717
import subprocess
1818
import tempfile
19-
from typing import TYPE_CHECKING, Any, Dict, Optional, Type, cast
19+
from typing import TYPE_CHECKING, Optional, Type, cast
2020

2121
from pydantic import Field
2222

@@ -28,6 +28,7 @@
2828
from zenml.utils import docker_utils
2929

3030
if TYPE_CHECKING:
31+
from zenml.config.docker_settings import DockerBuildOptions
3132
from zenml.container_registries import BaseContainerRegistry
3233
from zenml.image_builders import BuildContext
3334

@@ -102,7 +103,7 @@ def build(
102103
self,
103104
image_name: str,
104105
build_context: "BuildContext",
105-
docker_build_options: Optional[Dict[str, Any]] = None,
106+
docker_build_options: Optional["DockerBuildOptions"] = None,
106107
container_registry: Optional["BaseContainerRegistry"] = None,
107108
) -> str:
108109
"""Builds and optionally pushes an image using the local Docker client.
@@ -141,7 +142,7 @@ def _build_with_python_sdk(
141142
self,
142143
image_name: str,
143144
build_context: "BuildContext",
144-
docker_build_options: Optional[Dict[str, Any]] = None,
145+
docker_build_options: Optional["DockerBuildOptions"] = None,
145146
container_registry: Optional["BaseContainerRegistry"] = None,
146147
) -> None:
147148
"""Builds an image using the Python Docker SDK.
@@ -159,6 +160,12 @@ def _build_with_python_sdk(
159160
else:
160161
docker_client = docker_utils._try_get_docker_client_from_env()
161162

163+
build_options = (
164+
docker_build_options.to_docker_python_sdk_options()
165+
if docker_build_options
166+
else {}
167+
)
168+
162169
with tempfile.TemporaryFile(mode="w+b") as f:
163170
build_context.write_archive(f)
164171

@@ -167,15 +174,15 @@ def _build_with_python_sdk(
167174
fileobj=f,
168175
custom_context=True,
169176
tag=image_name,
170-
**(docker_build_options or {}),
177+
**build_options,
171178
)
172179
docker_utils._process_stream(output_stream)
173180

174181
def _build_with_subprocess_call(
175182
self,
176183
image_name: str,
177184
build_context: "BuildContext",
178-
docker_build_options: Optional[Dict[str, Any]] = None,
185+
docker_build_options: Optional["DockerBuildOptions"] = None,
179186
) -> None:
180187
"""Builds an image using a subprocess `docker build` call.
181188
@@ -194,31 +201,8 @@ def _build_with_subprocess_call(
194201
# file to stdin
195202
command = ["docker", "build", "-", "-t", image_name]
196203

197-
docker_build_options = docker_build_options or {}
198-
if docker_build_options.pop("pull", False):
199-
command.append("--pull")
200-
201-
build_args = docker_build_options.pop("buildargs", None) or {}
202-
for key, value in build_args.items():
203-
command.extend(["--build-arg", f"{key}={value}"])
204-
205-
labels = docker_build_options.pop("labels", None) or {}
206-
for key, value in labels.items():
207-
command.extend(["--label", f"{key}={value}"])
208-
209-
cache_from = docker_build_options.pop("cache_from", None) or []
210-
for value in cache_from:
211-
command.extend(["--cache-from", value])
212-
213-
for key, value in docker_build_options.items():
214-
if isinstance(value, list):
215-
for val in value:
216-
command.extend([f"--{key}", str(val)])
217-
elif isinstance(value, bool):
218-
if value:
219-
command.append(f"--{key}")
220-
else:
221-
command.extend([f"--{key}", str(value)])
204+
if docker_build_options:
205+
command.extend(docker_build_options.to_docker_cli_options())
222206

223207
process = subprocess.Popen(command, stdin=f)
224208

src/zenml/integrations/aws/image_builders/aws_image_builder.py

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from zenml.utils.archivable import ArchiveType
3232

3333
if TYPE_CHECKING:
34+
from zenml.config.docker_settings import DockerBuildOptions
3435
from zenml.container_registries import BaseContainerRegistry
3536
from zenml.image_builders import BuildContext
3637
from zenml.stack import Stack
@@ -126,7 +127,7 @@ def build(
126127
self,
127128
image_name: str,
128129
build_context: "BuildContext",
129-
docker_build_options: Dict[str, Any],
130+
docker_build_options: Optional["DockerBuildOptions"] = None,
130131
container_registry: Optional["BaseContainerRegistry"] = None,
131132
) -> str:
132133
"""Builds and pushes a Docker image.
@@ -189,17 +190,11 @@ def build(
189190
f"aws ecr get-login-password --region {self.code_build_client._client_config.region_name} | docker login --username AWS --password-stdin {container_registry.config.uri}",
190191
]
191192

192-
# Convert the docker_build_options dictionary to a list of strings
193193
docker_build_args = ""
194-
for key, value in docker_build_options.items():
195-
option = f"--{key}"
196-
if isinstance(value, list):
197-
for val in value:
198-
docker_build_args += f"{option} {val} "
199-
elif value is not None and not isinstance(value, bool):
200-
docker_build_args += f"{option} {value} "
201-
elif value is not False:
202-
docker_build_args += f"{option} "
194+
if docker_build_options:
195+
docker_build_args = " ".join(
196+
docker_build_options.to_docker_cli_options()
197+
)
203198

204199
pre_build_commands_str = "\n".join(
205200
[f" - {command}" for command in pre_build_commands]

src/zenml/integrations/gcp/image_builders/gcp_image_builder.py

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
# permissions and limitations under the License.
1414
"""Google Cloud Builder image builder implementation."""
1515

16-
from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, cast
16+
from typing import TYPE_CHECKING, Optional, Tuple, cast
1717
from urllib.parse import urlparse
1818

1919
from google.cloud.devtools import cloudbuild_v1
@@ -29,6 +29,7 @@
2929
from zenml.stack import StackValidator
3030

3131
if TYPE_CHECKING:
32+
from zenml.config.docker_settings import DockerBuildOptions
3233
from zenml.container_registries import BaseContainerRegistry
3334
from zenml.image_builders import BuildContext
3435
from zenml.stack import Stack
@@ -98,7 +99,7 @@ def build(
9899
self,
99100
image_name: str,
100101
build_context: "BuildContext",
101-
docker_build_options: Dict[str, Any],
102+
docker_build_options: Optional["DockerBuildOptions"] = None,
102103
container_registry: Optional["BaseContainerRegistry"] = None,
103104
) -> str:
104105
"""Builds and pushes a Docker image.
@@ -141,7 +142,7 @@ def _configure_cloud_build(
141142
self,
142143
image_name: str,
143144
cloud_build_context: str,
144-
build_options: Dict[str, Any],
145+
build_options: Optional["DockerBuildOptions"] = None,
145146
) -> cloudbuild_v1.Build:
146147
"""Configures the build to be run to generate the Docker image.
147148
@@ -171,17 +172,9 @@ def _configure_cloud_build(
171172
cloud_builder_network_option,
172173
)
173174

174-
# Convert the docker_build_options dictionary to a list of strings
175-
docker_build_args = []
176-
for key, value in build_options.items():
177-
option = f"--{key}"
178-
if isinstance(value, list):
179-
for val in value:
180-
docker_build_args.extend([option, val])
181-
elif value is not None and not isinstance(value, bool):
182-
docker_build_args.extend([option, value])
183-
elif value is not False:
184-
docker_build_args.extend([option])
175+
docker_build_args = (
176+
build_options.to_docker_cli_options() if build_options else []
177+
)
185178

186179
return cloudbuild_v1.Build(
187180
source=cloudbuild_v1.Source(

0 commit comments

Comments
 (0)