common-python-tasks 0.2.0__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,54 +1,201 @@
1
1
  import hashlib
2
+ import logging
2
3
  import platform
3
4
  import re
5
+ import tempfile
6
+ from enum import IntEnum, StrEnum
4
7
  from pathlib import Path
5
8
  from typing import Sequence
6
9
 
7
- from . import git as _git
8
- from . import project as _project
9
- from . import utils
10
+ from . import git, project, utils
10
11
  from .env import get_cache_id_suffix, get_workdir_path
11
12
 
13
+ LOGGER = logging.getLogger(__name__)
12
14
 
13
- def build(
14
- has_containers: bool,
15
- debug: bool = False,
16
- no_cache: bool = False,
17
- plain: bool = False,
18
- single_arch: bool = False,
19
- build_args: list[str] | None = None,
20
- container_env: list[str] | None = None,
21
- container_envfile: list[str] | None = None,
15
+
16
+ class PruneKeep(IntEnum):
17
+ """Special keep values for image pruning behavior."""
18
+
19
+ DISABLED = -1
20
+
21
+
22
+ class DockerPlatform(StrEnum):
23
+ """Supported Docker target platforms for multi-arch builds."""
24
+
25
+ AMD64 = "linux/amd64"
26
+ ARM64 = "linux/arm64"
27
+
28
+
29
+ class BuildStage(StrEnum):
30
+ """Named Docker build stages used by image builders."""
31
+
32
+ RUNTIME = "runtime"
33
+ DEBUG = "debug"
34
+
35
+
36
+ class BuildTag(StrEnum):
37
+ """Reserved tags managed by image build helpers."""
38
+
39
+ LATEST = "latest"
40
+ DEBUG = "debug"
41
+
42
+
43
+ class BuildSuffix(StrEnum):
44
+ """Suffixes appended to version and commit image tags."""
45
+
46
+ NONE = ""
47
+ DEBUG = "-debug"
48
+
49
+
50
+ def _resolve_context_path(context_path: Path | None) -> Path:
51
+ return context_path if context_path is not None else Path(".")
52
+
53
+
54
+ def _resolve_archs(single_arch: bool) -> list[DockerPlatform] | None:
55
+ return None if single_arch else [DockerPlatform.AMD64, DockerPlatform.ARM64]
56
+
57
+
58
+ def _merge_build_args(
59
+ base_build_args: dict[str, str | None],
60
+ extra_build_args: dict[str, str] | None = None,
61
+ ) -> dict[str, str]:
62
+ build_args = {k: v for k, v in base_build_args.items() if v is not None}
63
+ if extra_build_args is not None:
64
+ build_args.update({k: v for k, v in extra_build_args.items() if v is not None})
65
+ return build_args
66
+
67
+
68
+ def _create_temp_dockerfile(
69
+ dockerfile_text: str,
70
+ prefix: str,
71
+ ) -> tuple[Path, str]:
72
+ with tempfile.NamedTemporaryFile(
73
+ mode="w",
74
+ encoding="utf-8",
75
+ delete=False,
76
+ prefix=prefix,
77
+ suffix=".generated",
78
+ ) as temp_file:
79
+ temp_file.write(dockerfile_text)
80
+ temp_file_path = temp_file.name
81
+
82
+ return Path(temp_file_path), temp_file_path
83
+
84
+
85
+ def _safe_unlink(path: Path | str | None) -> None:
86
+ if path is None:
87
+ return
88
+
89
+ try:
90
+ Path(path).unlink()
91
+ except FileNotFoundError:
92
+ pass
93
+
94
+
95
+ def _build_docker_build_command(
96
+ *,
97
+ context_path: Path,
98
+ dockerfile_path: Path,
99
+ build_args: dict[str, str],
100
+ archs: list[DockerPlatform] | None,
101
+ no_cache: bool,
102
+ plain: bool,
103
+ target: BuildStage | None = None,
104
+ tags: Sequence[str] | None = None,
105
+ ) -> list[str]:
106
+ build_cmd = [
107
+ "docker",
108
+ "build",
109
+ str(context_path),
110
+ "-f",
111
+ str(dockerfile_path),
112
+ ]
113
+ if target is not None:
114
+ build_cmd += ["--target", target]
115
+
116
+ build_cmd += [
117
+ item for k, v in build_args.items() for item in ("--build-arg", f"{k}={v}")
118
+ ]
119
+ if archs:
120
+ build_cmd += ["--platform", ",".join(archs)]
121
+ if no_cache:
122
+ build_cmd += ["--no-cache", "--pull"]
123
+ if tags is not None:
124
+ for tag in tags:
125
+ build_cmd += ["-t", tag]
126
+ if plain:
127
+ build_cmd += ["--progress", "plain"]
128
+
129
+ return build_cmd
130
+
131
+
132
+ def prune_images_keep(
133
+ full_name: str,
134
+ package_name: str,
135
+ keep: PruneKeep,
136
+ protect_tags: Sequence[str] | None = None,
22
137
  ) -> None:
23
- """Build the package and optionally the container image.
138
+ """Prune images for `full_name` keeping the most-recent `keep + 1` images.
24
139
 
25
140
  Args:
26
- has_containers: When `True`, also build the Docker image after the
27
- package.
28
- debug: Build the debug container image stage.
29
- no_cache: Pass `--no-cache` to the Docker build command.
30
- plain: Pass `--progress plain` to the Docker build command.
31
- single_arch: Build the container for the current host architecture only.
32
- build_args: Additional build arguments for the Docker build as repeated
33
- `KEY=VALUE` values.
34
- container_env: Inline container environment variables as repeated
35
- `KEY=VALUE` values.
36
- container_envfile: Repeated list of container environment files.
141
+ full_name: The full image name (including registry and repository) to prune, e.g. `ghcr.io/myorg/myimage`.
142
+ package_name: The name of the package (repository) to prune, e.g. `myimage`.
143
+ keep: The number of most-recent images to keep. Use
144
+ `PruneKeep.DISABLED` to skip pruning.
145
+ protect_tags: A list of tags to protect from pruning.
37
146
  """
38
- from common_python_tasks.tasks import build_image as build_container_image
39
- from common_python_tasks.tasks import build_package
40
147
 
41
- build_package()
42
- if has_containers:
43
- build_container_image(
44
- debug=debug,
45
- no_cache=no_cache,
46
- plain=plain,
47
- single_arch=single_arch,
48
- build_args=build_args,
49
- container_env=container_env,
50
- container_envfile=container_envfile,
51
- )
148
+ if keep <= PruneKeep.DISABLED:
149
+ return
150
+ if protect_tags is None:
151
+ protect_tags = []
152
+
153
+ retain_count = int(keep) + 1
154
+
155
+ res = utils.run_command(
156
+ [
157
+ "docker",
158
+ "image",
159
+ "ls",
160
+ "--format",
161
+ "{{.Repository}}:{{.Tag}}",
162
+ "--filter",
163
+ f"reference={full_name}:*",
164
+ ],
165
+ capture_output=True,
166
+ acceptable_returncodes={0, 1},
167
+ )
168
+ if res.returncode != 0:
169
+ LOGGER.exception("Failed to list images for pruning: %s", full_name)
170
+ return
171
+
172
+ lines = [
173
+ line.strip()
174
+ for line in res.stdout.splitlines()
175
+ if line.strip() and not line.strip().startswith("<none>")
176
+ ]
177
+ tags_in_order = []
178
+ for entry in lines:
179
+ if ":" not in entry:
180
+ continue
181
+ repo, tag = entry.rsplit(":", 1)
182
+ if entry.startswith(full_name) or repo.endswith(package_name):
183
+ tags_in_order.append(tag)
184
+
185
+ candidates = [t for t in tags_in_order if t not in protect_tags]
186
+ if len(candidates) <= retain_count:
187
+ return
188
+
189
+ to_delete = candidates[retain_count:]
190
+ for tag in to_delete:
191
+ for img in (f"{package_name}:{tag}", f"{full_name}:{tag}"):
192
+ try:
193
+ LOGGER.info("Pruning image %s", img)
194
+ utils.run_command(["docker", "rmi", img], acceptable_returncodes={0, 1})
195
+ except SystemExit:
196
+ LOGGER.warning(
197
+ "Failed to remove image %s during pruning; continuing", img
198
+ )
52
199
 
53
200
 
54
201
  def build_deps_image(
@@ -61,10 +208,23 @@ def build_deps_image(
61
208
  extra_build_args: dict[str, str] | None = None,
62
209
  cache_id_suffix: str = "",
63
210
  ) -> str:
64
- """Build the dependency collector image and return its full tag."""
211
+ """Build the dependency collector image and return its full tag.
212
+
213
+ Args:
214
+ deps_content: The content to use for the dependencies Dockerfile. If provided, this will be rendered into a template Dockerfile.
215
+ deps_dockerfile_path: A path or list of paths to Dockerfile(s) to use for the dependencies image. If multiple paths are provided, their contents will be concatenated. If provided, this takes precedence over `deps_content`.
216
+ context_path: The build context path for the Docker build. Defaults to the current directory.
217
+ no_cache: If `True`, build the image without using cache.
218
+ plain: If `True`, use plain output mode for the Docker build.
219
+ single_arch: If `True`, build the image for the current host architecture only.
220
+ extra_build_args: Additional build arguments for the Docker build as a dictionary of `KEY: VALUE` pairs.
221
+ cache_id_suffix: A suffix to append to the cache ID.
222
+
223
+ Returns:
224
+ The full tag of the built dependency image.
225
+ """
65
226
 
66
- if context_path is None:
67
- context_path = Path(".")
227
+ context_path = _resolve_context_path(context_path)
68
228
 
69
229
  deps_image_name = f"{utils.get_package_name()}-deps"
70
230
  if deps_content is not None and deps_dockerfile_path is not None:
@@ -103,21 +263,10 @@ def build_deps_image(
103
263
  utils.load_data_file("Dockerfile.deps.j2")[1],
104
264
  {"DEPS_CONTENT": deps_content},
105
265
  )
106
-
107
- import tempfile
108
-
109
- tf = tempfile.NamedTemporaryFile(
110
- mode="w",
111
- encoding="utf-8",
112
- delete=False,
266
+ dockerfile_path, temp_file_path = _create_temp_dockerfile(
267
+ dockerfile_text,
113
268
  prefix="Dockerfile.deps.",
114
- suffix=".generated",
115
269
  )
116
- temp_file_path = tf.name
117
- with open(temp_file_path, "w", encoding="utf-8") as f:
118
- f.write(dockerfile_text)
119
-
120
- dockerfile_path = Path(temp_file_path)
121
270
  else:
122
271
  if isinstance(deps_dockerfile_path, list):
123
272
  if len(deps_dockerfile_path) == 1:
@@ -127,124 +276,43 @@ def build_deps_image(
127
276
  path.read_text(encoding="utf-8").strip()
128
277
  for path in deps_dockerfile_path
129
278
  )
130
- import tempfile
131
-
132
- tf = tempfile.NamedTemporaryFile(
133
- mode="w",
134
- encoding="utf-8",
135
- delete=False,
279
+ dockerfile_path, temp_file_path = _create_temp_dockerfile(
280
+ dockerfile_text,
136
281
  prefix="Dockerfile.deps.",
137
- suffix=".generated",
138
282
  )
139
- temp_file_path = tf.name
140
- with open(temp_file_path, "w", encoding="utf-8") as f:
141
- f.write(dockerfile_text)
142
-
143
- dockerfile_path = Path(temp_file_path)
144
283
  else:
145
284
  dockerfile_path = deps_dockerfile_path
146
285
 
147
286
  python_version = platform.python_version()
148
- archs = ["linux/amd64", "linux/arm64"] if not single_arch else None
287
+ archs = _resolve_archs(single_arch)
149
288
 
150
- build_args: dict[str, str] = {"PYTHON_VERSION": python_version}
151
- if extra_build_args:
152
- build_args.update({k: v for k, v in extra_build_args.items() if v is not None})
289
+ build_args = _merge_build_args(
290
+ {"PYTHON_VERSION": python_version},
291
+ extra_build_args,
292
+ )
153
293
  if cache_id_suffix:
154
294
  build_args["CACHE_ID_SUFFIX"] = cache_id_suffix
155
295
 
156
296
  full_tag = f"{deps_image_name}:{deps_tag}"
157
- build_cmd = [
158
- "docker",
159
- "build",
160
- str(context_path),
161
- "-f",
162
- str(dockerfile_path),
163
- *[item for k, v in build_args.items() for item in ("--build-arg", f"{k}={v}")],
164
- "--platform" if archs else None,
165
- ",".join(archs) if archs else None,
166
- "--no-cache" if no_cache else None,
167
- "--pull" if no_cache else None,
168
- "-t",
169
- full_tag,
170
- ]
171
- if plain:
172
- build_cmd += ["--progress", "plain"]
297
+ build_cmd = _build_docker_build_command(
298
+ context_path=context_path,
299
+ dockerfile_path=dockerfile_path,
300
+ build_args=build_args,
301
+ archs=archs,
302
+ no_cache=no_cache,
303
+ plain=plain,
304
+ tags=[full_tag],
305
+ )
173
306
 
174
- utils.LOGGER.info("Building deps image: %s", full_tag)
307
+ LOGGER.info("Building deps image: %s", full_tag)
175
308
  try:
176
309
  utils.run_command(build_cmd)
177
310
  finally:
178
- if temp_file_path is not None:
179
- try:
180
- Path(temp_file_path).unlink()
181
- except FileNotFoundError:
182
- pass
311
+ _safe_unlink(temp_file_path)
183
312
 
184
313
  return full_tag
185
314
 
186
315
 
187
- def prune_images_keep(
188
- full_name: str,
189
- package_name: str,
190
- keep: int,
191
- protect_tags: Sequence[str] | None = None,
192
- ) -> None:
193
- """Prune images for `full_name` keeping the most-recent `keep + 1` images."""
194
-
195
- if keep < 0:
196
- return
197
- if protect_tags is None:
198
- protect_tags = []
199
-
200
- retain_count = keep + 1
201
-
202
- res = utils.run_command(
203
- [
204
- "docker",
205
- "image",
206
- "ls",
207
- "--format",
208
- "{{.Repository}}:{{.Tag}}",
209
- "--filter",
210
- f"reference={full_name}:*",
211
- ],
212
- capture_output=True,
213
- acceptable_returncodes={0, 1},
214
- )
215
- if res.returncode != 0:
216
- utils.LOGGER.exception("Failed to list images for pruning: %s", full_name)
217
- return
218
-
219
- lines = [
220
- line.strip()
221
- for line in res.stdout.splitlines()
222
- if line.strip() and not line.strip().startswith("<none>")
223
- ]
224
- tags_in_order = []
225
- for entry in lines:
226
- if ":" not in entry:
227
- continue
228
- repo, tag = entry.rsplit(":", 1)
229
- if entry.startswith(full_name) or repo.endswith(package_name):
230
- tags_in_order.append(tag)
231
-
232
- candidates = [t for t in tags_in_order if t not in protect_tags]
233
- if len(candidates) <= retain_count:
234
- return
235
-
236
- to_delete = candidates[retain_count:]
237
- for tag in to_delete:
238
- for img in (f"{package_name}:{tag}", f"{full_name}:{tag}"):
239
- try:
240
- utils.LOGGER.info("Pruning image %s", img)
241
- utils.run_command(["docker", "rmi", img], acceptable_returncodes={0, 1})
242
- except SystemExit:
243
- utils.LOGGER.warning(
244
- "Failed to remove image %s during pruning; continuing", img
245
- )
246
-
247
-
248
316
  def build_image(
249
317
  dockerfile_path: Path | None = None,
250
318
  dockerfile_text: str | None = None,
@@ -257,47 +325,51 @@ def build_image(
257
325
  image_name: str | None = None,
258
326
  extra_build_args: dict[str, str] | None = None,
259
327
  ) -> tuple[str, str]:
260
- """Build a Docker image and return its `(version_tag, commit_tag)`."""
261
- from common_python_tasks.tasks import clean
328
+ """Build the primary image and return its version and commit tags.
262
329
 
330
+ Args:
331
+ dockerfile_path: Path to the Dockerfile.
332
+ dockerfile_text: Dockerfile content as a string.
333
+ context_path: Path to the build context.
334
+ debug: If `True`, build the debug image.
335
+ no_cache: If `True`, do not use cache when building the image.
336
+ plain: If `True`, use plain output mode for the Docker build.
337
+ single_arch: If `True`, build the image for the current host architecture only.
338
+ omit_target: If `True`, omit the target stage.
339
+ image_name: Name of the image to build.
340
+ extra_build_args: Additional build arguments for the Docker build as a dictionary of `KEY: VALUE` pairs.
341
+
342
+ Returns:
343
+ A tuple containing the version tag and commit tag of the built image.
344
+ """
263
345
  dist_path = Path("dist")
264
- if dist_path.exists() and any(dist_path.iterdir()):
265
- clean(dist_only=True)
346
+ if utils.directory_has_contents(dist_path):
347
+ utils.remove_path(dist_path)
266
348
 
267
- if context_path is None:
268
- context_path = Path(".")
349
+ context_path = _resolve_context_path(context_path)
269
350
 
270
351
  temp_file_path = None
271
352
  if dockerfile_path is None:
272
353
  if dockerfile_text is None:
273
354
  utils.fatal("Either dockerfile_path or dockerfile_text must be provided.")
274
- import tempfile
275
-
276
- tf = tempfile.NamedTemporaryFile(
277
- mode="w",
278
- encoding="utf-8",
279
- delete=False,
355
+ dockerfile_path, temp_file_path = _create_temp_dockerfile(
356
+ dockerfile_text,
280
357
  prefix="Dockerfile.",
281
- suffix=".generated",
282
358
  )
283
- temp_file_path = tf.name
284
- with open(temp_file_path, "w", encoding="utf-8") as f:
285
- f.write(dockerfile_text)
286
- dockerfile_path = Path(temp_file_path)
287
359
 
288
360
  dockerignore_path = context_path / ".dockerignore"
289
361
  temp_dockerignore_created = False
290
362
  if not dockerignore_path.exists():
291
- utils.LOGGER.debug("No .dockerignore found; using built-in .dockerignore")
363
+ LOGGER.debug("No .dockerignore found; using built-in .dockerignore")
292
364
  builtin_dockerignore_content = utils.load_data_file(".dockerignore")[1]
293
365
  dockerignore_path.write_text(builtin_dockerignore_content, encoding="utf-8")
294
366
  temp_dockerignore_created = True
295
367
 
296
368
  delete_temp_file = False
297
369
  try:
298
- archs = ["linux/amd64", "linux/arm64"] if not single_arch else None
370
+ archs = _resolve_archs(single_arch)
299
371
  files_to_ignore = [Path(".dockerignore")] if temp_dockerignore_created else []
300
- version_string = _git.get_image_tag(ignore=files_to_ignore)
372
+ version_string = git.get_image_tag(ignore=files_to_ignore)
301
373
 
302
374
  dockerfile_source = (
303
375
  dockerfile_text
@@ -308,7 +380,7 @@ def build_image(
308
380
  debug
309
381
  and not omit_target
310
382
  and not re.search(
311
- r"^\s*FROM\s+\S+\s+AS\s+debug\s*$",
383
+ rf"^\s*FROM\s+\S+\s+AS\s+{BuildStage.DEBUG}\s*$",
312
384
  dockerfile_source,
313
385
  re.IGNORECASE | re.MULTILINE,
314
386
  )
@@ -319,50 +391,43 @@ def build_image(
319
391
  )
320
392
 
321
393
  if debug:
322
- suffix = "-debug"
323
- target = "debug"
324
- tag = "debug"
394
+ suffix = BuildSuffix.DEBUG
395
+ target = BuildStage.DEBUG
396
+ tag = BuildTag.DEBUG
325
397
  else:
326
- suffix = ""
327
- target = "runtime"
328
- tag = "latest" if not _git.has_tags_later_in_history() else None
398
+ suffix = BuildSuffix.NONE
399
+ target = BuildStage.RUNTIME
400
+ tag = BuildTag.LATEST if not git.has_tags_later_in_history() else None
329
401
 
330
402
  version_tag = f"{version_string}{suffix}"
331
- commit_tag = f"{utils.run_command(['git', 'rev-parse', '--short', 'HEAD'], capture_output=True).stdout.strip()}{'-dirty' if _git.get_dirty_files(ignore=files_to_ignore) else ''}{suffix}"
403
+ commit_tag = f"{utils.run_command(['git', 'rev-parse', '--short', 'HEAD'], capture_output=True).stdout.strip()}{'-dirty' if git.get_dirty_files(ignore=files_to_ignore) else ''}{suffix}"
332
404
  python_version = platform.python_version()
333
- poetry_version = _project.get_poetry_version()
405
+ poetry_version = project.get_poetry_version()
334
406
 
335
- build_args = {
336
- k: v
337
- for k, v in {
407
+ build_args = _merge_build_args(
408
+ {
338
409
  "PYTHON_VERSION": python_version,
339
410
  "POETRY_VERSION": poetry_version,
340
- "POETRY_DYNAMIC_VERSIONING_PLUGIN_VERSION": _project.get_installed_requirement_version(
411
+ "POETRY_DYNAMIC_VERSIONING_PLUGIN_VERSION": project.get_installed_requirement_version(
341
412
  "poetry-dynamic-versioning[plugin]"
342
413
  ),
343
- "POETRY_PLUGIN_EXPORT_VERSION": _project.get_installed_requirement_version(
414
+ "POETRY_PLUGIN_EXPORT_VERSION": project.get_installed_requirement_version(
344
415
  "poetry-plugin-export"
345
416
  ),
346
- "TOMLKIT_VERSION": _project.get_installed_requirement_version(
347
- "tomlkit"
348
- ),
417
+ "TOMLKIT_VERSION": project.get_installed_requirement_version("tomlkit"),
349
418
  "PACKAGE_NAME": utils.get_package_name(use_underscores=True),
350
419
  "AUTHORS": ",".join(
351
- [f"{name} <{email}>" for name, email in _project.get_authors()]
420
+ [f"{name} <{email}>" for name, email in project.get_authors()]
352
421
  ),
353
422
  "GIT_COMMIT": commit_tag,
354
- }.items()
355
- if v is not None
356
- }
357
- if extra_build_args:
358
- for k, v in extra_build_args.items():
359
- if v is not None:
360
- build_args[k] = v
423
+ },
424
+ extra_build_args,
425
+ )
361
426
 
362
427
  build_args.setdefault("WORKDIR_PATH", get_workdir_path())
363
428
 
364
429
  tags_to_use = [t for t in (tag, version_tag, commit_tag) if t is not None]
365
- utils.LOGGER.debug("Building image with tags: %s", ", ".join(tags_to_use))
430
+ LOGGER.debug("Building image with tags: %s", ", ".join(tags_to_use))
366
431
  image_short_name = (
367
432
  image_name if image_name is not None else utils.get_package_name()
368
433
  )
@@ -375,43 +440,26 @@ def build_image(
375
440
  image_full_name = f"{prefix}/{image_name}"
376
441
  else:
377
442
  image_full_name = image_name
378
- build_cmd = [
379
- "docker",
380
- "build",
381
- str(context_path),
382
- "-f",
383
- str(dockerfile_path),
384
- "--target" if not omit_target else None,
385
- target if not omit_target else None,
386
- *[
387
- item
388
- for k, v in build_args.items()
389
- for item in ("--build-arg", f"{k}={v if v is not None else ''}")
443
+ build_cmd = _build_docker_build_command(
444
+ context_path=context_path,
445
+ dockerfile_path=dockerfile_path,
446
+ build_args=build_args,
447
+ archs=archs,
448
+ no_cache=no_cache,
449
+ plain=plain,
450
+ target=None if omit_target else target,
451
+ tags=[
452
+ *[f"{image_short_name}:{t}" for t in tags_to_use],
453
+ *[f"{image_full_name}:{t}" for t in tags_to_use],
390
454
  ],
391
- "--platform" if archs else None,
392
- ",".join(archs) if archs else None,
393
- "--no-cache" if no_cache else None,
394
- "--pull" if no_cache else None,
395
- *[item for t in tags_to_use for item in ("-t", f"{image_short_name}:{t}")],
396
- ]
397
- for t in tags_to_use:
398
- build_cmd += ["-t", f"{image_full_name}:{t}"]
399
-
400
- if plain:
401
- build_cmd += ["--progress", "plain"]
455
+ )
402
456
  utils.run_command(build_cmd)
403
457
  delete_temp_file = True
404
458
  finally:
405
459
  if temp_file_path is not None and delete_temp_file:
406
- try:
407
- dockerfile_path.unlink()
408
- except FileNotFoundError:
409
- pass
460
+ _safe_unlink(dockerfile_path)
410
461
  if temp_dockerignore_created:
411
- try:
412
- dockerignore_path.unlink()
413
- except FileNotFoundError:
414
- pass
462
+ _safe_unlink(dockerignore_path)
415
463
  return version_tag, commit_tag
416
464
 
417
465
 
@@ -427,12 +475,27 @@ def build_extension_image(
427
475
  single_arch: bool = False,
428
476
  extra_build_args: dict[str, str] | None = None,
429
477
  ) -> tuple[str, str]:
430
- """Build an image that starts `FROM` the primary image and returns its tags."""
478
+ """Build an image that starts `FROM` the primary image and returns its version and commit tags.
479
+
480
+ Args:
481
+ base_full_name: The full name of the base image.
482
+ base_version_tag: The version tag of the base image.
483
+ extension_content: The content to append to the Dockerfile.
484
+ context_path: Path to the build context.
485
+ image_name_override: Override for the image name.
486
+ debug: If `True`, build the debug image.
487
+ no_cache: If `True`, do not use cache when building the image.
488
+ plain: If `True`, use plain output mode for the Docker build.
489
+ single_arch: If `True`, build the image for the current host architecture only.
490
+ extra_build_args: Additional build arguments for the Docker build as a dictionary of `KEY: VALUE` pairs.
491
+
492
+ Returns:
493
+ A tuple containing the version tag and commit tag of the built image.
494
+ """
431
495
 
432
- if context_path is None:
433
- context_path = Path(".")
496
+ context_path = _resolve_context_path(context_path)
434
497
 
435
- utils.LOGGER.debug(
498
+ LOGGER.debug(
436
499
  "Building extension image based on %s:%s with override name '%s'",
437
500
  base_full_name,
438
501
  base_version_tag,