common-python-tasks 0.11.0__py3-none-any.whl → 0.12.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.
@@ -0,0 +1,27 @@
1
+ # Docker-in-Docker currently supports Debian Bookworm on amd64 and arm64.
2
+ USER root
3
+
4
+ # Optional exact APT version, including the epoch and distribution suffix.
5
+ ARG DIND_DOCKER_VERSION
6
+
7
+ COPY --from=cpt-extension-docker-in-docker --chmod=0755 install.sh \
8
+ /usr/local/share/cpt-dind-install.sh
9
+ RUN /usr/local/share/cpt-dind-install.sh \
10
+ && rm /usr/local/share/cpt-dind-install.sh
11
+
12
+ COPY --from=cpt-extension-docker-in-docker --chmod=0755 supervisor.sh \
13
+ /usr/local/bin/cpt-dind-supervisor
14
+
15
+ RUN mv /pkg/entrypoint.sh /pkg/application-entrypoint.sh \
16
+ && printf '%s\n' '#!/bin/sh' \
17
+ 'exec /usr/bin/tini -- /usr/local/bin/cpt-dind-supervisor "$@"' \
18
+ >/pkg/entrypoint.sh \
19
+ && chmod 0755 /pkg/entrypoint.sh
20
+
21
+ ENV DOCKER_HOST=unix:///var/run/docker.sock
22
+ VOLUME ["/var/lib/docker", "/var/lib/containerd"]
23
+
24
+ # The generic final and debug stages inherit this startup-user selection.
25
+ # The wrapper drops to py only for the application, after Docker is ready.
26
+ ARG RUNTIME_USER=root
27
+ USER ${RUNTIME_USER}
@@ -0,0 +1,39 @@
1
+ #!/bin/sh
2
+ set -eu
3
+
4
+ # shellcheck source=/dev/null
5
+ . /etc/os-release
6
+ if [ "$ID" != debian ] || [ "${VERSION_CODENAME:-}" != bookworm ]; then
7
+ echo >&2 'docker-in-docker requires Debian Bookworm; use slim-bookworm.'
8
+ exit 1
9
+ fi
10
+ case "$(dpkg --print-architecture)" in
11
+ amd64 | arm64) ;;
12
+ *)
13
+ echo >&2 'docker-in-docker supports amd64 and arm64 only.'
14
+ exit 1
15
+ ;;
16
+ esac
17
+
18
+ apt-get update
19
+ apt-get install -y --no-install-recommends \
20
+ bash ca-certificates curl iptables tini util-linux
21
+ install -m 0755 -d /etc/apt/keyrings
22
+ curl -fsSL https://download.docker.com/linux/debian/gpg \
23
+ -o /etc/apt/keyrings/docker.asc
24
+ chmod 0644 /etc/apt/keyrings/docker.asc
25
+ cat >/etc/apt/sources.list.d/docker.sources <<SOURCES
26
+ Types: deb
27
+ URIs: https://download.docker.com/linux/debian
28
+ Suites: bookworm
29
+ Components: stable
30
+ Architectures: $(dpkg --print-architecture)
31
+ Signed-By: /etc/apt/keyrings/docker.asc
32
+ SOURCES
33
+ apt-get update
34
+ apt-get install -y --no-install-recommends \
35
+ "docker-ce${DIND_DOCKER_VERSION:+=$DIND_DOCKER_VERSION}" \
36
+ "docker-ce-cli${DIND_DOCKER_VERSION:+=$DIND_DOCKER_VERSION}" \
37
+ containerd.io docker-buildx-plugin docker-compose-plugin
38
+ usermod -aG docker py
39
+ rm -rf /var/lib/apt/lists/*
@@ -0,0 +1,116 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ _prepare_host() {
5
+ if [[ $(id -u) != 0 ]]; then
6
+ echo >&2 'docker-in-docker must start as root with --privileged.'
7
+ return 1
8
+ fi
9
+ if [[ ! -w /sys/fs/cgroup ]]; then
10
+ echo >&2 'docker-in-docker needs writable cgroups; use --privileged.'
11
+ return 1
12
+ fi
13
+ if [[ -d /sys/kernel/security ]] &&
14
+ ! mountpoint -q /sys/kernel/security; then
15
+ mount -t securityfs none /sys/kernel/security
16
+ fi
17
+ _prepare_cgroups
18
+ if ! iptables -nL >/dev/null 2>&1 &&
19
+ iptables-legacy -nL >/dev/null 2>&1; then
20
+ update-alternatives --set iptables /usr/sbin/iptables-legacy
21
+ update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy
22
+ fi
23
+ # Only remove our daemon's PID file, never unrelated processes' files.
24
+ rm -f /var/run/docker.pid
25
+ }
26
+
27
+ _prepare_cgroups() {
28
+ [[ -f /sys/fs/cgroup/cgroup.controllers ]] || return 0
29
+ mkdir -p /sys/fs/cgroup/cpt-init
30
+ local attempt pid controllers
31
+ for ((attempt = 0; attempt < 5; attempt++)); do
32
+ while read -r pid; do
33
+ # Processes can disappear or become immovable while we move them.
34
+ printf '%s\n' "$pid" | tee \
35
+ /sys/fs/cgroup/cpt-init/cgroup.procs >/dev/null 2>&1 || true
36
+ done </sys/fs/cgroup/cgroup.procs
37
+ controllers=$(sed 's/[^ ]\+/+&/g' \
38
+ /sys/fs/cgroup/cgroup.controllers)
39
+ if printf '%s\n' "$controllers" | tee \
40
+ /sys/fs/cgroup/cgroup.subtree_control >/dev/null 2>&1; then
41
+ return 0
42
+ fi
43
+ sleep 0.1
44
+ done
45
+ echo >&2 'docker-in-docker could not enable nested cgroups.'
46
+ return 1
47
+ }
48
+
49
+ _stop_group() {
50
+ local pid=$1
51
+ [[ -n "$pid" ]] || return 0
52
+ kill -TERM -- "-$pid" 2>/dev/null || true
53
+ local attempt
54
+ for ((attempt = 0; attempt < 50; attempt++)); do
55
+ kill -0 -- "-$pid" 2>/dev/null || break
56
+ sleep 0.1
57
+ done
58
+ kill -KILL -- "-$pid" 2>/dev/null || true
59
+ wait "$pid" 2>/dev/null || true
60
+ }
61
+
62
+ _shutdown() {
63
+ trap '' TERM INT
64
+ _stop_group "$app_pid"
65
+ _stop_group "$daemon_pid"
66
+ }
67
+
68
+ _wait_for_docker() {
69
+ local deadline=$((SECONDS + DIND_STARTUP_TIMEOUT))
70
+ while ((SECONDS < deadline)); do
71
+ if ! kill -0 "$daemon_pid" 2>/dev/null; then
72
+ echo >&2 'docker-in-docker daemon exited during startup.'
73
+ return 1
74
+ fi
75
+ if timeout 1 docker --host "$DOCKER_HOST" info >/dev/null 2>&1; then
76
+ return 0
77
+ fi
78
+ sleep 0.2
79
+ done
80
+ echo >&2 'docker-in-docker timed out waiting for the daemon.'
81
+ return 1
82
+ }
83
+
84
+ _main() {
85
+ if [[ ! ${DIND_STARTUP_TIMEOUT:-60} =~ ^[1-9][0-9]{0,3}$ ]]; then
86
+ echo >&2 'DIND_STARTUP_TIMEOUT must be an integer from 1 to 9999.'
87
+ return 1
88
+ fi
89
+ export DIND_STARTUP_TIMEOUT=${DIND_STARTUP_TIMEOUT:-60}
90
+ export DOCKER_HOST=unix:///var/run/docker.sock
91
+ export container=docker
92
+ unset DOCKER_CONTEXT DOCKER_TLS_VERIFY DOCKER_CERT_PATH
93
+ _prepare_host
94
+ daemon_pid=''
95
+ app_pid=''
96
+ trap _shutdown EXIT
97
+ trap 'exit 143' TERM
98
+ trap 'exit 130' INT
99
+ # Keep daemon logs on stderr and expose only the local Unix socket.
100
+ setsid dockerd --host "$DOCKER_HOST" --group docker >&2 &
101
+ daemon_pid=$!
102
+ _wait_for_docker
103
+ setsid setpriv --reuid py --regid py --init-groups \
104
+ env HOME="$(getent passwd py | cut -d: -f6)" USER=py LOGNAME=py \
105
+ /pkg/application-entrypoint.sh "$@" &
106
+ app_pid=$!
107
+ local finished status=0
108
+ wait -n -p finished "$daemon_pid" "$app_pid" || status=$?
109
+ if [[ "$finished" == "$daemon_pid" ]]; then
110
+ echo >&2 'docker-in-docker daemon exited while the app was running.'
111
+ return 1
112
+ fi
113
+ return "$status"
114
+ }
115
+
116
+ _main "$@"
@@ -40,7 +40,7 @@ COPY . /tmp/build/
40
40
  # Export debug requirements when the project defines a debug dependency group
41
41
  RUN --mount=type=cache,target=/root/.cache/uv,id=uv-cache{{ CACHE_ID_SUFFIX }}{% for mount in UV_INDEX_SECRET_MOUNTS %} \
42
42
  --mount={{ mount }}{% endfor %} \
43
- uv export --group debug --no-hashes --format requirements-txt --output-file requirements-debug.txt
43
+ uv export --frozen --only-group debug --no-hashes --format requirements-txt --output-file requirements-debug.txt
44
44
  {% endif %}
45
45
 
46
46
  # Build the wheel, caching the uv cache directory to speed up subsequent builds.
@@ -133,10 +133,11 @@ LABEL git.commit=${GIT_COMMIT}
133
133
  # This entrypoint is deliberately not configurable via environment variables in order to
134
134
  # ensure that the container always uses the entrypoint selected at build time. If the
135
135
  # current package does not provide a console script, the entrypoint will default to `python`
136
- RUN echo "#!/bin/sh\n\n{{ ENTRYPOINT_COMMAND|default('python') }} \"\$@\"" >/pkg/entrypoint.sh \
136
+ RUN echo "#!/bin/sh\n\nexec {{ ENTRYPOINT_COMMAND|default('python') }} \"\$@\"" >/pkg/entrypoint.sh \
137
137
  && chmod +x /pkg/entrypoint.sh
138
138
 
139
- USER py
139
+ ARG RUNTIME_USER=py
140
+ USER ${RUNTIME_USER}
140
141
 
141
142
  {% if EXTENSION_CONTENT %}
142
143
  {{ EXTENSION_CONTENT }}
@@ -154,10 +155,10 @@ COPY --from=builder /tmp/build /tmp/build
154
155
 
155
156
  RUN --mount=type=cache,target=/root/.cache/pip,id=pip-cache{{ CACHE_ID_SUFFIX }} pip install --root-user-action=ignore -r /tmp/build/requirements-debug.txt
156
157
 
157
- USER py
158
+ USER ${RUNTIME_USER}
158
159
  {% endif %}
159
160
 
160
161
  # Final (default) image: explicitly use runtime as the final target so debug is not used unless requested
161
162
  FROM runtime AS final
162
163
 
163
- USER py
164
+ USER ${RUNTIME_USER}
@@ -3,6 +3,7 @@ import os
3
3
  import re
4
4
  import secrets
5
5
  import shlex
6
+ from collections.abc import Iterator
6
7
  from pathlib import Path
7
8
 
8
9
  from . import utils
@@ -53,51 +54,47 @@ def collect_uv_index_credentials() -> list[dict[str, str | None]]:
53
54
  ]
54
55
 
55
56
 
57
+ def _iter_uv_index_secrets(
58
+ credentials: list[dict[str, str | None]],
59
+ ) -> Iterator[tuple[str, str]]:
60
+ for credential in credentials:
61
+ index_name = credential["index_name"].lower()
62
+ for credential_type in ("username", "password"):
63
+ environment_name = credential[f"{credential_type}_env"]
64
+ if environment_name:
65
+ yield f"uv_index_{index_name}_{credential_type}", environment_name
66
+
67
+
56
68
  def uv_index_secret_mounts(credentials: list[dict[str, str | None]]) -> list[str]:
57
- """Return Dockerfile ``--mount`` strings for UV index credentials.
69
+ """Return Dockerfile `--mount` strings for UV index credentials.
58
70
 
59
71
  Args:
60
72
  credentials: Output of `collect_uv_index_credentials`.
61
73
 
62
74
  Returns:
63
- A list of ``type=secret,...`` strings for Dockerfile ``RUN --mount=``
75
+ A list of `type=secret,...` strings for Dockerfile `RUN --mount=`
64
76
  directives.
65
77
  """
66
- mounts = []
67
- for cred in credentials:
68
- name = cred["index_name"].lower()
69
- if cred["username_env"]:
70
- mounts.append(
71
- f"type=secret,id=uv_index_{name}_username,env={cred['username_env']}"
72
- )
73
- if cred["password_env"]:
74
- mounts.append(
75
- f"type=secret,id=uv_index_{name}_password,env={cred['password_env']}"
76
- )
77
- return mounts
78
+ return [
79
+ f"type=secret,id={secret_id},env={environment_name}"
80
+ for secret_id, environment_name in _iter_uv_index_secrets(credentials)
81
+ ]
78
82
 
79
83
 
80
84
  def uv_index_secret_build_args(credentials: list[dict[str, str | None]]) -> list[str]:
81
- """Return ``docker build --secret`` arg pairs for UV index credentials.
85
+ """Return `docker build --secret` arg pairs for UV index credentials.
82
86
 
83
87
  Args:
84
88
  credentials: Output of `collect_uv_index_credentials`.
85
89
 
86
90
  Returns:
87
- A flat list of ``--secret`` flag-value pairs for the docker build command.
91
+ A flat list of `--secret` flag-value pairs for the docker build command.
88
92
  """
89
- args = []
90
- for cred in credentials:
91
- name = cred["index_name"].lower()
92
- if cred["username_env"]:
93
- args.extend(
94
- ["--secret", f"id=uv_index_{name}_username,env={cred['username_env']}"]
95
- )
96
- if cred["password_env"]:
97
- args.extend(
98
- ["--secret", f"id=uv_index_{name}_password,env={cred['password_env']}"]
99
- )
100
- return args
93
+ return [
94
+ item
95
+ for secret_id, environment_name in _iter_uv_index_secrets(credentials)
96
+ for item in ("--secret", f"id={secret_id},env={environment_name}")
97
+ ]
101
98
 
102
99
 
103
100
  def env_truthy(env_var: str) -> bool:
@@ -230,6 +227,40 @@ def resolve_extension_content(descriptor: dict[str, str | None]) -> str:
230
227
  utils.fatal(f"Unknown extension descriptor source: {descriptor['source']}")
231
228
 
232
229
 
230
+ def resolve_extension_build_context(
231
+ descriptor: dict[str, str | None],
232
+ ) -> tuple[str, Path] | None:
233
+ """Return the named build context for a bundled extension's asset directory.
234
+
235
+ Args:
236
+ descriptor: Extension descriptor dictionary containing `source` and
237
+ `bundle_name` values.
238
+
239
+ Returns:
240
+ A Docker build-context name and its directory, or `None` when the
241
+ extension has no packaged assets.
242
+ """
243
+ if descriptor["source"] != "bundle":
244
+ return None
245
+ bundle_name = descriptor["bundle_name"]
246
+ out = utils.load_data_file(
247
+ f"{bundle_name}/Dockerfile",
248
+ type_identifier="dockerfile_extensions",
249
+ fatal_on_missing=False,
250
+ )
251
+ if out is None:
252
+ utils.fatal(f"Extension bundle not found: {bundle_name}")
253
+ bundle_directory = out[0].parent
254
+ if not bundle_directory.is_dir() or not any(
255
+ path.name != "Dockerfile" for path in bundle_directory.iterdir()
256
+ ):
257
+ return None
258
+ return (
259
+ f"cpt-extension-{re.sub(r'[^a-z0-9]+', '-', bundle_name.lower()).strip('-')}",
260
+ bundle_directory,
261
+ )
262
+
263
+
233
264
  def get_cache_id_suffix(no_cache: bool) -> str:
234
265
  """Return a cache-break suffix for Docker cache mount IDs.
235
266
  Args:
@@ -330,6 +361,28 @@ def resolve_container_docker_build_args(
330
361
  utils.fatal(f"Invalid CONTAINER_DOCKER_BUILD_ARGS: {error}")
331
362
 
332
363
 
364
+ def _resolve_optional_file_path(
365
+ cli_path: str | None,
366
+ environment_path: str | None,
367
+ description: str,
368
+ ) -> Path | None:
369
+ resolved_path = next(
370
+ (
371
+ Path(value.strip())
372
+ for value in (cli_path, environment_path)
373
+ if value and value.strip()
374
+ ),
375
+ None,
376
+ )
377
+ if resolved_path is None:
378
+ return None
379
+ if not resolved_path.exists():
380
+ utils.fatal(f"{description} not found: {resolved_path}")
381
+ if not resolved_path.is_file():
382
+ utils.fatal(f"{description} is not a file: {resolved_path}")
383
+ return resolved_path
384
+
385
+
333
386
  def resolve_container_dockerfile_hook_path(
334
387
  cli_hook_path: str | None, env_hook_path: str | None
335
388
  ) -> Path | None:
@@ -342,17 +395,13 @@ def resolve_container_dockerfile_hook_path(
342
395
  Returns:
343
396
  The validated hook path, or `None` when unset.
344
397
  """
345
- raw_value = (
346
- cli_hook_path if cli_hook_path and cli_hook_path.strip() else env_hook_path
398
+ hook_path = _resolve_optional_file_path(
399
+ cli_hook_path,
400
+ env_hook_path,
401
+ "Dockerfile hook script",
347
402
  )
348
- if raw_value is None or not raw_value.strip():
403
+ if hook_path is None:
349
404
  return None
350
-
351
- hook_path = Path(raw_value.strip())
352
- if not hook_path.exists():
353
- utils.fatal(f"Dockerfile hook script not found: {hook_path}")
354
- if not hook_path.is_file():
355
- utils.fatal(f"Dockerfile hook path is not a file: {hook_path}")
356
405
  if not os.access(hook_path, os.X_OK):
357
406
  utils.fatal(f"Dockerfile hook script must be executable: {hook_path}")
358
407
 
@@ -594,6 +643,28 @@ def get_container_deps_move_script() -> str | None:
594
643
  return inline_script if inline_script else None
595
644
 
596
645
 
646
+ def resolve_container_dockerfile_path(
647
+ dockerfile_path: str | None,
648
+ environment_dockerfile_path: str | None,
649
+ ) -> Path | None:
650
+ """Resolve and validate a project-owned application Dockerfile.
651
+
652
+ Args:
653
+ dockerfile_path: Dockerfile path supplied directly to a task.
654
+ environment_dockerfile_path: Dockerfile path supplied through the
655
+ environment.
656
+
657
+ Returns:
658
+ The configured Dockerfile path, or `None` when the bundled template
659
+ should be used.
660
+ """
661
+ return _resolve_optional_file_path(
662
+ dockerfile_path,
663
+ environment_dockerfile_path,
664
+ "Container Dockerfile",
665
+ )
666
+
667
+
597
668
  def get_prune_keep() -> int:
598
669
  """Return the integer value of CONTAINER_PRUNE_KEEP.
599
670
 
@@ -70,6 +70,28 @@ class _ContainerBuild(NamedTuple):
70
70
  commit_tag: str
71
71
 
72
72
 
73
+ def _prune_container_images(version_tag: str, commit_tag: str) -> None:
74
+ from .docker import prune_images_keep
75
+ from .env import get_prune_keep
76
+ from .utils import get_full_image_name, get_package_name
77
+
78
+ keep = get_prune_keep()
79
+ if keep < 0:
80
+ return
81
+
82
+ LOGGER.debug(
83
+ "Pruning old images; keeping %d and protecting tags: %s",
84
+ keep,
85
+ [version_tag, commit_tag],
86
+ )
87
+ prune_images_keep(
88
+ get_full_image_name(),
89
+ get_package_name(),
90
+ keep,
91
+ protect_tags=[version_tag, commit_tag],
92
+ )
93
+
94
+
73
95
  def _make_var_positional_task_args_optional(
74
96
  func: callable, resolved_task_name: str
75
97
  ) -> None:
@@ -367,11 +389,19 @@ def clean(dist_only: bool = False) -> None:
367
389
  return
368
390
  else:
369
391
  to_clean = [
370
- *[Path(p) for p in [".pytest_cache", "dist", ".mypy_cache"]],
392
+ *[
393
+ Path(p)
394
+ for p in [
395
+ ".pytest_cache",
396
+ "dist",
397
+ ".mypy_cache",
398
+ ".coverage",
399
+ "coverage.xml",
400
+ ".ruff_cache",
401
+ ]
402
+ ],
371
403
  *Path(".").rglob("__pycache__"),
372
404
  *Path(".").rglob("*.pyc"),
373
- Path(".coverage"),
374
- Path("coverage.xml"),
375
405
  ]
376
406
 
377
407
  for item in to_clean:
@@ -465,11 +495,12 @@ def build_image(
465
495
  no_cache: bool = False,
466
496
  plain: bool = False,
467
497
  single_arch: bool = False,
498
+ dockerfile_path: str | None = None,
468
499
  dockerfile_hook_path: str | None = None,
469
500
  container_env: list[str] | None = None,
470
501
  container_envfile: list[str] | None = None,
471
502
  ) -> _ContainerBuild:
472
- """Build the container image for this project using the Dockerfile template.
503
+ """Build the container image using a project-owned or bundled Dockerfile.
473
504
 
474
505
  Args:
475
506
  *docker_build_args: Additional arguments passed directly to `docker build`.
@@ -479,8 +510,10 @@ def build_image(
479
510
  no_cache: Do not use cache when building the image.
480
511
  plain: Do not pretty-print output.
481
512
  single_arch: Build images for a single architecture.
513
+ dockerfile_path: Optional project-owned Dockerfile. Overrides
514
+ CONTAINER_DOCKERFILE_PATH if provided.
482
515
  dockerfile_hook_path: Optional executable script path that can mutate
483
- the generated Dockerfile before build. Overrides
516
+ the selected Dockerfile before build. Overrides
484
517
  CONTAINER_DOCKERFILE_HOOK_PATH if provided.
485
518
  container_env: Builder and runtime environment declarations as repeated
486
519
  KEY=VALUE values.
@@ -494,14 +527,10 @@ def build_image(
494
527
  The generated Dockerfile text and image tags.
495
528
  """
496
529
  from .docker import build_image as _build_image
497
- from .docker import (
498
- prune_images_keep,
499
- )
500
530
  from .env import (
501
531
  collect_uv_index_credentials,
502
532
  get_cache_id_suffix,
503
533
  get_container_deps_move_script,
504
- get_prune_keep,
505
534
  get_python_variant,
506
535
  inject_auto_build_args_from_env,
507
536
  load_container_env_tokens,
@@ -511,6 +540,8 @@ def build_image(
511
540
  render_container_deps_move_script,
512
541
  resolve_container_docker_build_args,
513
542
  resolve_container_dockerfile_hook_path,
543
+ resolve_container_dockerfile_path,
544
+ resolve_extension_build_context,
514
545
  resolve_extension_content,
515
546
  uv_index_secret_build_args,
516
547
  uv_index_secret_mounts,
@@ -521,14 +552,17 @@ def build_image(
521
552
  )
522
553
  from .utils import (
523
554
  fatal,
524
- get_full_image_name,
525
- get_package_name,
526
555
  load_data_file,
527
556
  render_template_text,
528
557
  )
529
558
 
559
+ resolved_dockerfile_path = resolve_container_dockerfile_path(
560
+ dockerfile_path,
561
+ os.getenv("CONTAINER_DOCKERFILE_PATH"),
562
+ )
563
+
530
564
  # Determine extensions up-front so we can log a single, accurate message
531
- extensions = parse_container_extensions()
565
+ extensions = [] if resolved_dockerfile_path else parse_container_extensions()
532
566
  extension_ids = [desc.get("id") for desc in extensions if desc.get("id")]
533
567
  if extension_ids:
534
568
  LOGGER.info("Building image (with extensions: %s)", ", ".join(extension_ids))
@@ -538,6 +572,11 @@ def build_image(
538
572
  # Resolve all extension fragments up-front so we fail fast on missing
539
573
  # bundles or files and avoid calling resolution logic multiple times.
540
574
  resolved_fragments = [resolve_extension_content(desc) for desc in extensions]
575
+ extension_build_contexts = [
576
+ context
577
+ for desc in extensions
578
+ if (context := resolve_extension_build_context(desc)) is not None
579
+ ]
541
580
 
542
581
  resolved_docker_build_args = resolve_container_docker_build_args(
543
582
  docker_build_args,
@@ -556,6 +595,29 @@ def build_image(
556
595
  dockerfile_hook_path,
557
596
  os.getenv("CONTAINER_DOCKERFILE_HOOK_PATH"),
558
597
  )
598
+ top_level_build_args = inject_auto_build_args_from_env(
599
+ {"PYTHON_VARIANT": get_python_variant()}
600
+ )
601
+
602
+ if resolved_dockerfile_path:
603
+ version_tag, commit_tag = _build_image(
604
+ dockerfile_path=resolved_dockerfile_path,
605
+ context_path=Path("."),
606
+ debug=debug,
607
+ no_cache=no_cache,
608
+ plain=plain,
609
+ single_arch=single_arch,
610
+ omit_target=not debug,
611
+ extra_build_args=top_level_build_args or None,
612
+ docker_build_args=resolved_docker_build_args,
613
+ dockerfile_hook_path=resolved_dockerfile_hook_path,
614
+ )
615
+ _prune_container_images(version_tag, commit_tag)
616
+ return _ContainerBuild(
617
+ dockerfile_text=resolved_dockerfile_path.read_text(encoding="utf-8"),
618
+ version_tag=version_tag,
619
+ commit_tag=commit_tag,
620
+ )
559
621
 
560
622
  has_debug_deps = has_debug_dependency_group()
561
623
  if debug and not has_debug_deps:
@@ -579,9 +641,6 @@ def build_image(
579
641
  "Injecting builder and runtime env vars: %s",
580
642
  ", ".join(container_env_vars),
581
643
  )
582
- top_level_build_args = inject_auto_build_args_from_env(
583
- {"PYTHON_VARIANT": get_python_variant()}
584
- )
585
644
  external_deps_image = _get_container_build_setting("CONTAINER_DEPS_IMAGE")
586
645
  if external_deps_image:
587
646
  top_level_build_args["CONTAINER_DEPS_IMAGE"] = external_deps_image
@@ -687,20 +746,18 @@ def build_image(
687
746
  plain=plain,
688
747
  single_arch=single_arch,
689
748
  extra_build_args=merged_build_args or None,
690
- docker_build_args=resolved_docker_build_args,
749
+ docker_build_args=[
750
+ *resolved_docker_build_args,
751
+ *(
752
+ item
753
+ for name, path in extension_build_contexts
754
+ for item in ("--build-context", f"{name}={path}")
755
+ ),
756
+ ],
691
757
  dockerfile_hook_path=resolved_dockerfile_hook_path,
692
758
  )
693
759
 
694
- keep = get_prune_keep()
695
- if keep >= 0:
696
- # Protect the tags created by this build
697
- protect = [t for t in (version_tag, commit_tag) if t is not None]
698
- LOGGER.debug(
699
- "Pruning old images; keeping %d and protecting tags: %s", keep, protect
700
- )
701
- prune_images_keep(
702
- get_full_image_name(), get_package_name(), keep, protect_tags=protect
703
- )
760
+ _prune_container_images(version_tag, commit_tag)
704
761
 
705
762
  return _ContainerBuild(
706
763
  dockerfile_text=dockerfile_text,
@@ -1320,6 +1377,7 @@ def _run_release_flow(
1320
1377
  no_cache: bool = False,
1321
1378
  plain: bool = False,
1322
1379
  single_arch: bool = False,
1380
+ dockerfile_path: str | None = None,
1323
1381
  container_env: list[str] | None = None,
1324
1382
  container_envfile: list[str] | None = None,
1325
1383
  assets: list[str] | None = None,
@@ -1426,6 +1484,7 @@ def _run_release_flow(
1426
1484
  no_cache=no_cache,
1427
1485
  plain=plain,
1428
1486
  single_arch=single_arch,
1487
+ dockerfile_path=dockerfile_path,
1429
1488
  container_env=container_env,
1430
1489
  container_envfile=container_envfile,
1431
1490
  )
@@ -1444,6 +1503,7 @@ def release(
1444
1503
  no_cache: bool = False,
1445
1504
  plain: bool = False,
1446
1505
  single_arch: bool = False,
1506
+ dockerfile_path: str | None = None,
1447
1507
  container_env: list[str] | None = None,
1448
1508
  container_envfile: list[str] | None = None,
1449
1509
  assets: list[str] | None = None,
@@ -1462,6 +1522,8 @@ def release(
1462
1522
  no_cache: Do not use cache when building container images.
1463
1523
  plain: Do not pretty-print container build output.
1464
1524
  single_arch: Build container image for a single architecture.
1525
+ dockerfile_path: Optional project-owned Dockerfile used for the image
1526
+ build. Overrides CONTAINER_DOCKERFILE_PATH if provided.
1465
1527
  container_env: Inline container environment variables as repeated
1466
1528
  KEY=VALUE values.
1467
1529
  container_envfile: Repeated list of container environment files.
@@ -1480,6 +1542,7 @@ def release(
1480
1542
  no_cache=no_cache,
1481
1543
  plain=plain,
1482
1544
  single_arch=single_arch,
1545
+ dockerfile_path=dockerfile_path,
1483
1546
  container_env=container_env,
1484
1547
  container_envfile=container_envfile,
1485
1548
  assets=assets,
@@ -1532,6 +1595,7 @@ def build(
1532
1595
  no_cache: bool = False,
1533
1596
  plain: bool = False,
1534
1597
  single_arch: bool = False,
1598
+ dockerfile_path: str | None = None,
1535
1599
  container_env: list[str] | None = None,
1536
1600
  container_envfile: list[str] | None = None,
1537
1601
  ) -> None:
@@ -1544,6 +1608,8 @@ def build(
1544
1608
  no_cache: Pass `--no-cache` to the Docker build command.
1545
1609
  plain: Pass `--progress plain` to the Docker build command.
1546
1610
  single_arch: Build the container for the current host architecture only.
1611
+ dockerfile_path: Optional project-owned Dockerfile used for the image
1612
+ build. Overrides CONTAINER_DOCKERFILE_PATH if provided.
1547
1613
  container_env: Inline container environment variables as repeated
1548
1614
  `KEY=VALUE` values.
1549
1615
  container_envfile: Repeated list of container environment files.
@@ -1558,6 +1624,7 @@ def build(
1558
1624
  no_cache=no_cache,
1559
1625
  plain=plain,
1560
1626
  single_arch=single_arch,
1627
+ dockerfile_path=dockerfile_path,
1561
1628
  container_env=container_env,
1562
1629
  container_envfile=container_envfile,
1563
1630
  )
@@ -1572,6 +1639,7 @@ def build_with_containers(
1572
1639
  no_cache: bool = False,
1573
1640
  plain: bool = False,
1574
1641
  single_arch: bool = False,
1642
+ dockerfile_path: str | None = None,
1575
1643
  container_env: list[str] | None = None,
1576
1644
  container_envfile: list[str] | None = None,
1577
1645
  ) -> None:
@@ -1582,6 +1650,8 @@ def build_with_containers(
1582
1650
  no_cache: Do not use cache when building the image.
1583
1651
  plain: Do not pretty-print output.
1584
1652
  single_arch: Build images for a single architecture.
1653
+ dockerfile_path: Optional project-owned Dockerfile used for the image
1654
+ build. Overrides CONTAINER_DOCKERFILE_PATH if provided.
1585
1655
  container_env: Inline container environment variables as repeated KEY=VALUE
1586
1656
  values.
1587
1657
  container_envfile: Repeated list of container environment files.
@@ -1593,6 +1663,7 @@ def build_with_containers(
1593
1663
  no_cache=no_cache,
1594
1664
  plain=plain,
1595
1665
  single_arch=single_arch,
1666
+ dockerfile_path=dockerfile_path,
1596
1667
  container_env=container_env,
1597
1668
  container_envfile=container_envfile,
1598
1669
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: common-python-tasks
3
- Version: 0.11.0
3
+ Version: 0.12.0
4
4
  Summary: Opinionated Poe the Poet tasks for Python package development.
5
5
  Project-URL: Homepage, http://github.com/ci-sourcerer/common-python-tasks
6
6
  Project-URL: Source, http://github.com/ci-sourcerer/common-python-tasks.git
@@ -42,7 +42,7 @@ Read the [complete documentation](https://ci-sourcerer.github.io/common-python-t
42
42
  Add the package as a development dependency.
43
43
 
44
44
  ```shell
45
- uv add --dev common-python-tasks==0.11.0
45
+ uv add --dev common-python-tasks==0.12.0
46
46
  ```
47
47
 
48
48
  Expose the default task set in `pyproject.toml`.
@@ -2,13 +2,16 @@ common_python_tasks/__init__.py,sha256=tqRLgOwmMQHDb1A84XbML9sWfzMymkSPhJjoF6aCH
2
2
  common_python_tasks/__main__.py,sha256=GpgSd_toMbEaWA_cZvgsZE7jo4XpzuI-awBVm2uhVyw,3512
3
3
  common_python_tasks/docker.py,sha256=_YW-hyHWlUK-kMCvI6QTgxSfmXQQKhYLOKRBPfUmtHA,24176
4
4
  common_python_tasks/docker_compose.py,sha256=NGgMei3oCYS0RbNaQXE-2veXFylO3XtZ7lU-PTtocik,21338
5
- common_python_tasks/env.py,sha256=1YLSxsUP9lPn51_zraM3nAiN1J7aKdUHzjzDzxXLo34,19696
5
+ common_python_tasks/env.py,sha256=hSjWe8L1RXguLqIMh1IsgbkHa_64b1U8lBHqDIS3zko,21779
6
6
  common_python_tasks/git.py,sha256=0X472w_vHdtbrHHxCmJ85NoQjjogEEDs_0SRXfxbWi0,11719
7
7
  common_python_tasks/github.py,sha256=_LVanl-z4PsdLM94L8y7tkX-AZO2n3Qy39FRY7reDhA,21589
8
8
  common_python_tasks/project.py,sha256=PQ_GQicTrSQgVDB9cJ9E6pU0UhLPom2xou1sGFscr3E,17377
9
- common_python_tasks/tasks.py,sha256=Ajqw7mnfMwfiOdTuVGvGJQAp_DQxEnqDc9e8y3VFcT8,61636
9
+ common_python_tasks/tasks.py,sha256=vMCFXZKo-0Dj1TLmZ3lo6oFCAhJSEp637qZWhaWOmpA,64199
10
10
  common_python_tasks/utils.py,sha256=hR7w0Cb7uQ7ILyIegTQ4znWHVoWANlCgxrOnO-Rh5oE,20798
11
11
  common_python_tasks/data/dockerfile_extensions/.gitkeep,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ common_python_tasks/data/dockerfile_extensions/docker-in-docker/Dockerfile,sha256=m7jeQyFe6mAv8AVGpM89NHljzG21ctcSyllbvoEL0Do,1021
13
+ common_python_tasks/data/dockerfile_extensions/docker-in-docker/install.sh,sha256=F87oojGFYT5F1mdcMchRNlUeI_viAO4ip04UHBC_8bQ,1224
14
+ common_python_tasks/data/dockerfile_extensions/docker-in-docker/supervisor.sh,sha256=cqslPv9QPU4vtClvNSl2wyhEL0HubxO5YqAhwNfTEXA,3726
12
15
  common_python_tasks/data/fastapi/alembic.ini.j2,sha256=dOiAmZngzX6eyVXeM6BhzgQmes2zwUEdKwOgm8mxjMs,985
13
16
  common_python_tasks/data/fastapi/compose-base.yml.j2,sha256=vDLPAdRJ66lsWZdsKbYqEvY_liXaHZ0ly4ljzqmJsyY,910
14
17
  common_python_tasks/data/fastapi/compose-db-debug.yml.j2,sha256=iREdSy4F8tJ5m9rlB6CROBkj9OtDFVuGphqIuDvHhww,1193
@@ -17,11 +20,11 @@ common_python_tasks/data/fastapi/compose-debug.yml.j2,sha256=sIH9IFvmRAszN6wSejp
17
20
  common_python_tasks/data/generic/.coveragerc,sha256=8zXwDq18wIKQV4NtW2NJYpo3nxCO4LsAChyluYTUoJc,85
18
21
  common_python_tasks/data/generic/.dockerignore,sha256=8kHwNa6Ct-6y3EvRjNuhOKfquuGEywq3P2BHIbG4uXo,53
19
22
  common_python_tasks/data/generic/Dockerfile.deps.j2,sha256=fBmcytyUd4LsC26y0IgZ09QPxrFp0gx0d3OlKNqZ-KM,663
20
- common_python_tasks/data/generic/Dockerfile.j2,sha256=hgLrIRU5xvS2xqw9Xm4kfkcBGHH-mghFPlWv3KSNaz4,6223
23
+ common_python_tasks/data/generic/Dockerfile.j2,sha256=UrR9T794V2ESNIkSVE7K5vEbx43NQiddfsWKG_bY6b4,6301
21
24
  common_python_tasks/data/generic/cliff.toml,sha256=po1MnEGEEgrpyBz3XLWr0h7emXb6ASX63lrU2r6o240,77
22
25
  common_python_tasks/data/generic/pytest.ini,sha256=RHak5QrLzKHvUBjFmaaiOYFrd_8YBRQubXEOT0NJfco,134
23
26
  common_python_tasks/data/generic/ruff.toml,sha256=njvd-5inSGmgY865SWCT4SR9IrKlzS7JQFZmAG2UT_E,53
24
- common_python_tasks-0.11.0.dist-info/METADATA,sha256=00ugE5R39fK6NpEH2LDIaUm87vNhxC2jVTGoa_FI4aI,3307
25
- common_python_tasks-0.11.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
26
- common_python_tasks-0.11.0.dist-info/licenses/LICENSE,sha256=RsSweM-DyzxFPx38fQOJqHrKyeT9Fiz8rO7yox-fOsw,1070
27
- common_python_tasks-0.11.0.dist-info/RECORD,,
27
+ common_python_tasks-0.12.0.dist-info/METADATA,sha256=iAVWGja53RQz1tWWMtn5szBCGveET5EDvwI_ltczz2A,3307
28
+ common_python_tasks-0.12.0.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
29
+ common_python_tasks-0.12.0.dist-info/licenses/LICENSE,sha256=RsSweM-DyzxFPx38fQOJqHrKyeT9Fiz8rO7yox-fOsw,1070
30
+ common_python_tasks-0.12.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.32.0
2
+ Generator: hatchling 1.32.3
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any