common-python-tasks 0.9.0__tar.gz → 0.10.1__tar.gz

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.
Files changed (36) hide show
  1. {common_python_tasks-0.9.0 → common_python_tasks-0.10.1}/PKG-INFO +8 -6
  2. {common_python_tasks-0.9.0 → common_python_tasks-0.10.1}/README.md +7 -5
  3. {common_python_tasks-0.9.0 → common_python_tasks-0.10.1}/pyproject.toml +0 -8
  4. {common_python_tasks-0.9.0 → common_python_tasks-0.10.1}/scripts/update_readme_tasks_table.py +0 -2
  5. {common_python_tasks-0.9.0 → common_python_tasks-0.10.1}/scripts/utils.py +2 -1
  6. {common_python_tasks-0.9.0 → common_python_tasks-0.10.1}/src/common_python_tasks/__init__.py +7 -3
  7. {common_python_tasks-0.9.0 → common_python_tasks-0.10.1}/src/common_python_tasks/data/generic/.dockerignore +0 -1
  8. {common_python_tasks-0.9.0 → common_python_tasks-0.10.1}/src/common_python_tasks/data/generic/Dockerfile.deps.j2 +6 -1
  9. {common_python_tasks-0.9.0 → common_python_tasks-0.10.1}/src/common_python_tasks/data/generic/Dockerfile.j2 +12 -5
  10. {common_python_tasks-0.9.0 → common_python_tasks-0.10.1}/src/common_python_tasks/docker.py +6 -7
  11. {common_python_tasks-0.9.0 → common_python_tasks-0.10.1}/src/common_python_tasks/docker_compose.py +13 -13
  12. {common_python_tasks-0.9.0 → common_python_tasks-0.10.1}/src/common_python_tasks/env.py +89 -2
  13. {common_python_tasks-0.9.0 → common_python_tasks-0.10.1}/src/common_python_tasks/git.py +2 -2
  14. {common_python_tasks-0.9.0 → common_python_tasks-0.10.1}/src/common_python_tasks/project.py +3 -2
  15. {common_python_tasks-0.9.0 → common_python_tasks-0.10.1}/src/common_python_tasks/tasks.py +35 -18
  16. {common_python_tasks-0.9.0 → common_python_tasks-0.10.1}/src/common_python_tasks/utils.py +14 -6
  17. {common_python_tasks-0.9.0 → common_python_tasks-0.10.1}/.gitignore +0 -0
  18. {common_python_tasks-0.9.0 → common_python_tasks-0.10.1}/.poetry/plugins/poetry_dynamic_versioning-1.10.0.dist-info/licenses/LICENSE +0 -0
  19. {common_python_tasks-0.9.0 → common_python_tasks-0.10.1}/.poetry/plugins/poetry_plugin_export-1.10.0.dist-info/licenses/LICENSE +0 -0
  20. {common_python_tasks-0.9.0 → common_python_tasks-0.10.1}/LICENSE +0 -0
  21. {common_python_tasks-0.9.0 → common_python_tasks-0.10.1}/scripts/__init__.py +0 -0
  22. {common_python_tasks-0.9.0 → common_python_tasks-0.10.1}/scripts/add_common_python_tasks.py +0 -0
  23. {common_python_tasks-0.9.0 → common_python_tasks-0.10.1}/scripts/readme_tasks_table.py +0 -0
  24. {common_python_tasks-0.9.0 → common_python_tasks-0.10.1}/scripts/release_script.py +0 -0
  25. {common_python_tasks-0.9.0 → common_python_tasks-0.10.1}/src/common_python_tasks/__main__.py +0 -0
  26. {common_python_tasks-0.9.0 → common_python_tasks-0.10.1}/src/common_python_tasks/data/dockerfile_extensions/.gitkeep +0 -0
  27. {common_python_tasks-0.9.0 → common_python_tasks-0.10.1}/src/common_python_tasks/data/fastapi/alembic.ini.j2 +0 -0
  28. {common_python_tasks-0.9.0 → common_python_tasks-0.10.1}/src/common_python_tasks/data/fastapi/compose-base.yml.j2 +0 -0
  29. {common_python_tasks-0.9.0 → common_python_tasks-0.10.1}/src/common_python_tasks/data/fastapi/compose-db-debug.yml.j2 +0 -0
  30. {common_python_tasks-0.9.0 → common_python_tasks-0.10.1}/src/common_python_tasks/data/fastapi/compose-db.yml.j2 +0 -0
  31. {common_python_tasks-0.9.0 → common_python_tasks-0.10.1}/src/common_python_tasks/data/fastapi/compose-debug.yml.j2 +0 -0
  32. {common_python_tasks-0.9.0 → common_python_tasks-0.10.1}/src/common_python_tasks/data/generic/.coveragerc +0 -0
  33. {common_python_tasks-0.9.0 → common_python_tasks-0.10.1}/src/common_python_tasks/data/generic/cliff.toml +0 -0
  34. {common_python_tasks-0.9.0 → common_python_tasks-0.10.1}/src/common_python_tasks/data/generic/pytest.ini +0 -0
  35. {common_python_tasks-0.9.0 → common_python_tasks-0.10.1}/src/common_python_tasks/data/generic/ruff.toml +0 -0
  36. {common_python_tasks-0.9.0 → common_python_tasks-0.10.1}/src/common_python_tasks/github.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: common-python-tasks
3
- Version: 0.9.0
3
+ Version: 0.10.1
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
@@ -43,7 +43,7 @@ It supplies sensible defaults for formatting, linting, testing, packaging, relea
43
43
  Add `common-python-tasks` as a development dependency from your project root.
44
44
 
45
45
  ```shell
46
- uv add --dev common-python-tasks==0.9.0
46
+ uv add --dev common-python-tasks==0.10.1
47
47
  ```
48
48
 
49
49
  Configure Poe the Poet to expose the default `common` task set.
@@ -68,7 +68,7 @@ The helper script performs the same development-dependency installation and Poe
68
68
  ```shell
69
69
  curl --fail --silent --show-error --location \
70
70
  --output /tmp/add_common_python_tasks.py \
71
- https://raw.githubusercontent.com/ci-sourcerer/common-python-tasks/v0.9.0/scripts/add_common_python_tasks.py
71
+ https://raw.githubusercontent.com/ci-sourcerer/common-python-tasks/v0.10.1/scripts/add_common_python_tasks.py
72
72
  ```
73
73
 
74
74
  Review the downloaded script before executing it.
@@ -80,10 +80,10 @@ less /tmp/add_common_python_tasks.py
80
80
  Run the reviewed script with the same pinned package version.
81
81
 
82
82
  ```shell
83
- COMMON_PYTHON_TASKS_VERSION=0.9.0 python3 /tmp/add_common_python_tasks.py
83
+ COMMON_PYTHON_TASKS_VERSION=0.10.1 python3 /tmp/add_common_python_tasks.py
84
84
  ```
85
85
 
86
- To install another release, replace both occurrences of `0.9.0` with that release's version.
86
+ To install another release, replace both occurrences of `0.10.1` with that release's version.
87
87
 
88
88
  ## Available tasks
89
89
 
@@ -203,7 +203,7 @@ After installing the package, a minimal project configuration looks like this.
203
203
  ```toml
204
204
  [project]
205
205
  name = "simple-cli-tool"
206
- version = "0.9.0"
206
+ version = "0.10.1"
207
207
 
208
208
  [tool.poe]
209
209
  include_script = "common_python_tasks:tasks()"
@@ -248,6 +248,7 @@ addopts = "-ra"
248
248
 
249
249
  - `CONTAINER_REGISTRY_USERNAME`: Container-registry username for image tagging; the default is the current local user
250
250
  - `CONTAINER_REGISTRY_URL`: Registry URL with a default of `docker.io/{username}`
251
+ - `CONTAINER_PYTHON_VARIANT`: Python base-image variant such as `slim`, `alpine`, etc. See <https://hub.docker.com/_/python> for available options. Defaults to `slim`; set to empty string for no variant (e.g., `FROM python:3.11`). The value is passed as the `PYTHON_VARIANT` Docker build argument and recorded in the `org.opencontainers.image.python.variant` image label.
251
252
  - `CONTAINER_DOCKER_BUILD_ARGS`: Additional arguments passed directly to `docker build`, parsed using shell quoting rules. Free arguments provided to the task after `--` take precedence.
252
253
  - `CONTAINER_DOCKERFILE_HOOK_PATH`: Optional host path to an executable hook script that receives the generated Dockerfile path and can modify the file before `docker build` runs.
253
254
  - `CONTAINER_APT_PACKAGES`: Space-delimited system packages installed in the generated image
@@ -263,6 +264,7 @@ addopts = "-ra"
263
264
  - `CONTAINER_DEPS_MAPPINGS`: Space-delimited `name:/target/path` entries for copying items from `/tmp/deps`. It is used only when no dependency move script is set.
264
265
  - `CONTAINER_DEPS_MOVE_SCRIPT`: Raw executable script to run after `/tmp/deps` is copied into the image
265
266
  - `CONTAINER_DEPS_MOVE_SCRIPT_PATH`: Host path to a dependency move script. This takes precedence over `CONTAINER_DEPS_MOVE_SCRIPT`.
267
+ - `UV_INDEX_{name}_USERNAME` and `UV_INDEX_{name}_PASSWORD`: Private Python index credentials consumed by uv during the Docker build. When any of these are set, the task automatically passes them as BuildKit secrets (`--secret id=uv_index_{name}_username,env=...`) and renders matching `--mount=type=secret` directives in the builder stage so uv can authenticate without baking credentials into the image. Multiple indices are supported; replace `{name}` with the uppercase index name (hyphens as underscores). These can be set in `tool.poe.env` for CI/CD or in the local environment for development.
266
268
 
267
269
  #### Release settings
268
270
 
@@ -11,7 +11,7 @@ It supplies sensible defaults for formatting, linting, testing, packaging, relea
11
11
  Add `common-python-tasks` as a development dependency from your project root.
12
12
 
13
13
  ```shell
14
- uv add --dev common-python-tasks==0.9.0
14
+ uv add --dev common-python-tasks==0.10.1
15
15
  ```
16
16
 
17
17
  Configure Poe the Poet to expose the default `common` task set.
@@ -36,7 +36,7 @@ The helper script performs the same development-dependency installation and Poe
36
36
  ```shell
37
37
  curl --fail --silent --show-error --location \
38
38
  --output /tmp/add_common_python_tasks.py \
39
- https://raw.githubusercontent.com/ci-sourcerer/common-python-tasks/v0.9.0/scripts/add_common_python_tasks.py
39
+ https://raw.githubusercontent.com/ci-sourcerer/common-python-tasks/v0.10.1/scripts/add_common_python_tasks.py
40
40
  ```
41
41
 
42
42
  Review the downloaded script before executing it.
@@ -48,10 +48,10 @@ less /tmp/add_common_python_tasks.py
48
48
  Run the reviewed script with the same pinned package version.
49
49
 
50
50
  ```shell
51
- COMMON_PYTHON_TASKS_VERSION=0.9.0 python3 /tmp/add_common_python_tasks.py
51
+ COMMON_PYTHON_TASKS_VERSION=0.10.1 python3 /tmp/add_common_python_tasks.py
52
52
  ```
53
53
 
54
- To install another release, replace both occurrences of `0.9.0` with that release's version.
54
+ To install another release, replace both occurrences of `0.10.1` with that release's version.
55
55
 
56
56
  ## Available tasks
57
57
 
@@ -171,7 +171,7 @@ After installing the package, a minimal project configuration looks like this.
171
171
  ```toml
172
172
  [project]
173
173
  name = "simple-cli-tool"
174
- version = "0.9.0"
174
+ version = "0.10.1"
175
175
 
176
176
  [tool.poe]
177
177
  include_script = "common_python_tasks:tasks()"
@@ -216,6 +216,7 @@ addopts = "-ra"
216
216
 
217
217
  - `CONTAINER_REGISTRY_USERNAME`: Container-registry username for image tagging; the default is the current local user
218
218
  - `CONTAINER_REGISTRY_URL`: Registry URL with a default of `docker.io/{username}`
219
+ - `CONTAINER_PYTHON_VARIANT`: Python base-image variant such as `slim`, `alpine`, etc. See <https://hub.docker.com/_/python> for available options. Defaults to `slim`; set to empty string for no variant (e.g., `FROM python:3.11`). The value is passed as the `PYTHON_VARIANT` Docker build argument and recorded in the `org.opencontainers.image.python.variant` image label.
219
220
  - `CONTAINER_DOCKER_BUILD_ARGS`: Additional arguments passed directly to `docker build`, parsed using shell quoting rules. Free arguments provided to the task after `--` take precedence.
220
221
  - `CONTAINER_DOCKERFILE_HOOK_PATH`: Optional host path to an executable hook script that receives the generated Dockerfile path and can modify the file before `docker build` runs.
221
222
  - `CONTAINER_APT_PACKAGES`: Space-delimited system packages installed in the generated image
@@ -231,6 +232,7 @@ addopts = "-ra"
231
232
  - `CONTAINER_DEPS_MAPPINGS`: Space-delimited `name:/target/path` entries for copying items from `/tmp/deps`. It is used only when no dependency move script is set.
232
233
  - `CONTAINER_DEPS_MOVE_SCRIPT`: Raw executable script to run after `/tmp/deps` is copied into the image
233
234
  - `CONTAINER_DEPS_MOVE_SCRIPT_PATH`: Host path to a dependency move script. This takes precedence over `CONTAINER_DEPS_MOVE_SCRIPT`.
235
+ - `UV_INDEX_{name}_USERNAME` and `UV_INDEX_{name}_PASSWORD`: Private Python index credentials consumed by uv during the Docker build. When any of these are set, the task automatically passes them as BuildKit secrets (`--secret id=uv_index_{name}_username,env=...`) and renders matching `--mount=type=secret` directives in the builder stage so uv can authenticate without baking credentials into the image. Multiple indices are supported; replace `{name}` with the uppercase index name (hyphens as underscores). These can be set in `tool.poe.env` for CI/CD or in the local environment for development.
234
236
 
235
237
  #### Release settings
236
238
 
@@ -70,11 +70,3 @@ dirty = true
70
70
 
71
71
  [tool.uv]
72
72
  package = true
73
-
74
- [tool.ruff]
75
- line-length = 88
76
- extend-exclude = [".dist", "build"]
77
-
78
- [tool.ruff.lint]
79
- select = ["E4", "E7", "E9", "F", "I", "W"]
80
- ignore = ["W191"]
@@ -1,5 +1,3 @@
1
- #!/usr/bin/env python3
2
-
3
1
  import os
4
2
  import sys
5
3
  from pathlib import Path
@@ -1,11 +1,12 @@
1
1
  import logging
2
2
  import subprocess
3
+ from typing import ClassVar
3
4
 
4
5
 
5
6
  class ColoredFormatter(logging.Formatter):
6
7
  """Format warning and error log levels with ANSI color codes."""
7
8
 
8
- COLORS = {
9
+ COLORS: ClassVar = {
9
10
  "WARNING": "\033[93m",
10
11
  "ERROR": "\033[91m",
11
12
  "CRITICAL": "\033[91m",
@@ -1,7 +1,7 @@
1
1
  from typing import TYPE_CHECKING
2
2
 
3
3
  if TYPE_CHECKING:
4
- from typing import Sequence
4
+ from collections.abc import Sequence
5
5
 
6
6
  from poethepoet_tasks import TaskCollection
7
7
 
@@ -9,7 +9,8 @@ __all__ = ["TaskCollection"]
9
9
 
10
10
 
11
11
  def tasks(
12
- include_tags: "Sequence[str] | None" = None, exclude_tags: "Sequence[str]" = tuple()
12
+ include_tags: "Sequence[str] | None" = None,
13
+ exclude_tags: "Sequence[str] | None" = None,
13
14
  ) -> dict:
14
15
  """Return the task collection filtered by the requested tags.
15
16
 
@@ -25,6 +26,9 @@ def tasks(
25
26
  if include_tags is None and not exclude_tags:
26
27
  include_tags = ("common",)
27
28
  elif include_tags is None:
28
- include_tags = tuple()
29
+ include_tags = ()
30
+
31
+ if exclude_tags is None:
32
+ exclude_tags = ()
29
33
 
30
34
  return task_collection(include_tags=include_tags, exclude_tags=exclude_tags)
@@ -1,5 +1,4 @@
1
1
  *
2
- !dist/*.whl
3
2
  !pyproject.toml
4
3
  !uv.lock
5
4
  !README.md
@@ -5,7 +5,12 @@
5
5
  ARG PYTHON_VERSION=3
6
6
  ARG PYTHON_VARIANT=slim
7
7
 
8
- FROM python:${PYTHON_VERSION}-${PYTHON_VARIANT} AS deps
8
+ FROM python:${PYTHON_VERSION}${PYTHON_VARIANT:+-${PYTHON_VARIANT}} AS deps
9
+ ARG PYTHON_VERSION
10
+ ARG PYTHON_VARIANT
11
+
12
+ LABEL org.opencontainers.image.base.name="python:${PYTHON_VERSION}${PYTHON_VARIANT:+-${PYTHON_VARIANT}}" \
13
+ org.opencontainers.image.python.variant="${PYTHON_VARIANT}"
9
14
 
10
15
  ARG CACHE_ID_SUFFIX
11
16
  RUN printf '%s' "$CACHE_ID_SUFFIX" >/tmp/.cache_id_suffix
@@ -4,9 +4,8 @@
4
4
  # the latest. The `build-image` task passes the version properly here
5
5
  ARG PYTHON_VERSION=3
6
6
  # Variant for the runtime image, e.g. slim, alpine, etc.
7
- # See https://hub.docker.com/_/python for available variants
7
+ # See https://hub.docker.com/_/python for available variants. Leave empty for no variant.
8
8
  ARG PYTHON_VARIANT=slim
9
- ARG WORKDIR_PATH=/workspace
10
9
 
11
10
  FROM python:${PYTHON_VERSION} AS builder
12
11
 
@@ -32,7 +31,8 @@ COPY . /tmp/build/
32
31
 
33
32
  {% if HAS_DEBUG_DEPS %}
34
33
  # Export debug requirements when the project defines a debug dependency group
35
- RUN --mount=type=cache,target=/root/.cache/uv,id=uv-cache{{ CACHE_ID_SUFFIX }} \
34
+ RUN --mount=type=cache,target=/root/.cache/uv,id=uv-cache{{ CACHE_ID_SUFFIX }}{% for mount in UV_INDEX_SECRET_MOUNTS %} \
35
+ --mount={{ mount }}{% endfor %} \
36
36
  uv export --group debug --no-hashes --format requirements-txt --output-file requirements-debug.txt
37
37
  {% endif %}
38
38
 
@@ -40,11 +40,18 @@ RUN --mount=type=cache,target=/root/.cache/uv,id=uv-cache{{ CACHE_ID_SUFFIX }} \
40
40
  # If running this in a CI system, you must be using a persistent builder to take
41
41
  # advantage of the cache mount, and you should configure your CI to persist the
42
42
  # cache directory between builds
43
- RUN --mount=type=cache,target=/root/.cache/uv,id=uv-cache{{ CACHE_ID_SUFFIX }} uv build --wheel
43
+ RUN --mount=type=cache,target=/root/.cache/uv,id=uv-cache{{ CACHE_ID_SUFFIX }}{% for mount in UV_INDEX_SECRET_MOUNTS %} \
44
+ --mount={{ mount }}{% endfor %} \
45
+ uv build --wheel
44
46
 
45
- FROM python:${PYTHON_VERSION}-${PYTHON_VARIANT} AS runtime
47
+ FROM python:${PYTHON_VERSION}${PYTHON_VARIANT:+-${PYTHON_VARIANT}} AS runtime
48
+ ARG PYTHON_VERSION
49
+ ARG PYTHON_VARIANT
46
50
  ARG WORKDIR_PATH=/workspace
47
51
 
52
+ LABEL org.opencontainers.image.base.name="python:${PYTHON_VERSION}${PYTHON_VARIANT:+-${PYTHON_VARIANT}}" \
53
+ org.opencontainers.image.python.variant="${PYTHON_VARIANT}"
54
+
48
55
  ENV PYTHONUNBUFFERED=1
49
56
  ENV PYTHONDONTWRITEBYTECODE=1
50
57
 
@@ -3,14 +3,14 @@ import logging
3
3
  import platform
4
4
  import re
5
5
  import tempfile
6
+ from collections.abc import Sequence
6
7
  from dataclasses import dataclass
7
8
  from enum import IntEnum, StrEnum
8
9
  from pathlib import Path
9
10
  from shlex import quote
10
- from typing import Sequence
11
11
 
12
12
  from . import git, project, utils
13
- from .env import get_cache_id_suffix, get_workdir_path
13
+ from .env import get_cache_id_suffix, get_python_variant, get_workdir_path
14
14
 
15
15
  LOGGER = logging.getLogger(__name__)
16
16
 
@@ -205,10 +205,6 @@ def render_build_image(
205
205
  Returns:
206
206
  The rendered build plan that contains the command and generated artifacts.
207
207
  """
208
- dist_path = Path("dist")
209
- if utils.directory_has_contents(dist_path):
210
- utils.remove_path(dist_path)
211
-
212
208
  context_path = _resolve_context_path(context_path)
213
209
 
214
210
  temp_file_path = None
@@ -560,7 +556,10 @@ def build_deps_image(
560
556
  archs = _resolve_archs(single_arch)
561
557
 
562
558
  build_args = _merge_build_args(
563
- {"PYTHON_VERSION": python_version},
559
+ {
560
+ "PYTHON_VERSION": python_version,
561
+ "PYTHON_VARIANT": get_python_variant(),
562
+ },
564
563
  extra_build_args,
565
564
  )
566
565
  if cache_id_suffix:
@@ -5,9 +5,10 @@ import logging
5
5
  import os
6
6
  import platform
7
7
  import tempfile
8
+ from collections.abc import Sequence
8
9
  from pathlib import Path
9
10
  from shlex import quote
10
- from typing import NoReturn, Sequence
11
+ from typing import NoReturn
11
12
 
12
13
  from jinja2 import Template
13
14
  from poethepoet_tasks import TaskCollection
@@ -113,16 +114,15 @@ def render_file(
113
114
  if extra_template_vars:
114
115
  template_vars.update(extra_template_vars)
115
116
  rendered = Template(path_obj.read_text()).render(**template_vars)
116
- tf = tempfile.NamedTemporaryFile(
117
+ with tempfile.NamedTemporaryFile(
117
118
  mode="w",
118
119
  encoding="utf-8",
119
120
  delete=False,
120
121
  prefix=path_obj.stem + ".",
121
122
  suffix=suffix,
122
- )
123
- temp_path = Path(tf.name)
124
- tf.write(rendered)
125
- tf.close()
123
+ ) as temp_file:
124
+ temp_path = Path(temp_file.name)
125
+ temp_file.write(rendered)
126
126
  return temp_path, True
127
127
 
128
128
  return path_obj, False
@@ -154,7 +154,7 @@ def read_dotenv(path: Path, error_on_fail: bool = False) -> dict[str, str]:
154
154
  value = value.strip().strip('"').strip("'")
155
155
  if key:
156
156
  env[key] = value
157
- except Exception as exc:
157
+ except (OSError, UnicodeError) as exc:
158
158
  if error_on_fail:
159
159
  utils.fatal(f"Failed to parse .env file {path}: {exc}")
160
160
  else:
@@ -169,9 +169,9 @@ def append_dotenv(path: Path, items: dict[str, str]) -> None:
169
169
  path: Path to the `.env` file.
170
170
  items: Environment variables to append to the file.
171
171
  """
172
- from datetime import datetime
172
+ from datetime import UTC, datetime
173
173
 
174
- header = f"\n# Auto-generated by common_python_tasks on {datetime.now().isoformat(timespec='seconds')}\n"
174
+ header = f"\n# Auto-generated by common_python_tasks on {datetime.now(UTC).astimezone().isoformat(timespec='seconds')}\n"
175
175
  with path.open("a", encoding="utf-8") as f:
176
176
  f.write(header)
177
177
  for k, v in items.items():
@@ -214,7 +214,7 @@ def get_or_generate_secret(
214
214
  try:
215
215
  append_dotenv(dotenv_path, {key_name: token})
216
216
  LOGGER.info("Generated %s and stored it in .env", key_name)
217
- except Exception as exc:
217
+ except OSError as exc:
218
218
  LOGGER.warning(
219
219
  "Failed to persist %s to .env (%s); using in-memory only", key_name, exc
220
220
  )
@@ -590,14 +590,14 @@ def build_exec_script(
590
590
  Returns:
591
591
  Absolute path to the temporary script file.
592
592
  """
593
- script_fd = tempfile.NamedTemporaryFile(
593
+ with tempfile.NamedTemporaryFile(
594
594
  mode="w",
595
595
  encoding="utf-8",
596
596
  delete=False,
597
597
  prefix="compose-exec.",
598
598
  suffix=".sh",
599
- )
600
- script_path = Path(script_fd.name)
599
+ ) as temp_file:
600
+ script_path = Path(temp_file.name)
601
601
  script_path_str = str(script_path.resolve())
602
602
 
603
603
  lines = [
@@ -1,5 +1,6 @@
1
1
  import logging
2
2
  import os
3
+ import re
3
4
  import secrets
4
5
  import shlex
5
6
  from pathlib import Path
@@ -21,6 +22,82 @@ TRUTHY_VALUES = {
21
22
  WORKDIR_PATH_ENV_VAR = "WORKDIR_PATH"
22
23
  WORKDIR_PATH_DEFAULT = "/workspace"
23
24
  _AUTO_INJECTED_BUILD_ARG_ENV_VARS = (WORKDIR_PATH_ENV_VAR,)
25
+ _UV_INDEX_CREDENTIAL_PATTERN = re.compile(r"^UV_INDEX_(.+)_(USERNAME|PASSWORD)$")
26
+
27
+
28
+ def collect_uv_index_credentials() -> list[dict[str, str | None]]:
29
+ """Collect present `UV_INDEX_{name}_{USERNAME,PASSWORD}` host env vars.
30
+
31
+ Returns:
32
+ A list of dicts with keys `index_name`, `username_env`, and
33
+ `password_env`. Only indices with at least one credential set are
34
+ included, sorted by index name.
35
+ """
36
+ found = {}
37
+ for key in os.environ:
38
+ match = _UV_INDEX_CREDENTIAL_PATTERN.match(key)
39
+ if match:
40
+ found.setdefault(match.group(1), set()).add(match.group(2))
41
+
42
+ return [
43
+ {
44
+ "index_name": name,
45
+ "username_env": f"UV_INDEX_{name}_USERNAME"
46
+ if "USERNAME" in found[name]
47
+ else None,
48
+ "password_env": f"UV_INDEX_{name}_PASSWORD"
49
+ if "PASSWORD" in found[name]
50
+ else None,
51
+ }
52
+ for name in sorted(found)
53
+ ]
54
+
55
+
56
+ def uv_index_secret_mounts(credentials: list[dict[str, str | None]]) -> list[str]:
57
+ """Return Dockerfile ``--mount`` strings for UV index credentials.
58
+
59
+ Args:
60
+ credentials: Output of `collect_uv_index_credentials`.
61
+
62
+ Returns:
63
+ A list of ``type=secret,...`` strings for Dockerfile ``RUN --mount=``
64
+ directives.
65
+ """
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
+
79
+
80
+ 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.
82
+
83
+ Args:
84
+ credentials: Output of `collect_uv_index_credentials`.
85
+
86
+ Returns:
87
+ A flat list of ``--secret`` flag-value pairs for the docker build command.
88
+ """
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
24
101
 
25
102
 
26
103
  def env_truthy(env_var: str) -> bool:
@@ -44,6 +121,16 @@ def get_workdir_path() -> str:
44
121
  return os.getenv(WORKDIR_PATH_ENV_VAR, WORKDIR_PATH_DEFAULT)
45
122
 
46
123
 
124
+ def get_python_variant() -> str:
125
+ """Return the Python image variant for container builds.
126
+
127
+ Returns:
128
+ The variant from `CONTAINER_PYTHON_VARIANT`, or 'slim' by default.
129
+ An empty string disables the variant (e.g., `FROM python:3.11`).
130
+ """
131
+ return os.getenv("CONTAINER_PYTHON_VARIANT", "slim").strip()
132
+
133
+
47
134
  def inject_auto_build_args_from_env(
48
135
  build_args: dict[str, str] | None,
49
136
  ) -> dict[str, str]:
@@ -454,7 +541,7 @@ def render_container_deps_move_script(mappings: dict[str, str]) -> str:
454
541
  "import shutil",
455
542
  "import sys",
456
543
  "",
457
- f"mappings = {repr(mappings)}",
544
+ f"mappings = {mappings!r}",
458
545
  'source_root = pathlib.Path("/tmp/deps")',
459
546
  "",
460
547
  "for name, dest in mappings.items():",
@@ -523,7 +610,7 @@ def get_prune_keep() -> int:
523
610
  return -1
524
611
  try:
525
612
  return int(raw)
526
- except Exception:
613
+ except ValueError:
527
614
  LOGGER.warning(
528
615
  "Invalid CONTAINER_PRUNE_KEEP value '%s' - defaulting to -1 (no prune)",
529
616
  raw,
@@ -178,13 +178,13 @@ def infer_bump_component_from_git_cliff() -> ReleaseComponent:
178
178
  normalized_version = version_text.lstrip("v")
179
179
  try:
180
180
  bumped = Version.parse(normalized_version)
181
- except Exception:
181
+ except ValueError:
182
182
  utils.fatal(f"Unable to parse bumped version from git-cliff: {version_text!r}")
183
183
 
184
184
  current_version = project.get_project_version()
185
185
  try:
186
186
  current = Version.parse(current_version)
187
- except Exception:
187
+ except ValueError:
188
188
  utils.fatal(f"Unable to parse current project version: {current_version!r}")
189
189
 
190
190
  def _parse_semver(version_value: str | Any) -> tuple[int, int, int]:
@@ -3,10 +3,11 @@ import logging
3
3
  import os
4
4
  import re
5
5
  import tomllib
6
+ from collections.abc import Sequence
6
7
  from functools import lru_cache
7
8
  from importlib import metadata
8
9
  from pathlib import Path
9
- from typing import Any, Sequence
10
+ from typing import Any
10
11
 
11
12
  from packaging.specifiers import InvalidSpecifier, SpecifierSet
12
13
  from packaging.version import Version
@@ -513,7 +514,7 @@ def get_project_version_from_vcs() -> str | None:
513
514
  from dunamai import Style, Version
514
515
 
515
516
  return Version.from_git().serialize(style=Style.Pep440, dirty=False)
516
- except Exception:
517
+ except (OSError, RuntimeError, ValueError):
517
518
  return None
518
519
 
519
520
 
@@ -4,9 +4,10 @@ import logging
4
4
  import os
5
5
  import re
6
6
  import shlex
7
+ from collections.abc import Callable
7
8
  from functools import partial, wraps
8
9
  from pathlib import Path
9
- from typing import Callable, NamedTuple
10
+ from typing import ClassVar, NamedTuple
10
11
 
11
12
  from poethepoet_tasks import TaskCollection
12
13
 
@@ -14,7 +15,7 @@ from common_python_tasks.utils import confirm
14
15
 
15
16
 
16
17
  class _ColoredFormatter(logging.Formatter):
17
- COLORS = {
18
+ COLORS: ClassVar = {
18
19
  "WARNING": "\033[93m",
19
20
  "ERROR": "\033[91m",
20
21
  "CRITICAL": "\033[91m",
@@ -445,9 +446,11 @@ def build_image(
445
446
  prune_images_keep,
446
447
  )
447
448
  from .env import (
449
+ collect_uv_index_credentials,
448
450
  get_cache_id_suffix,
449
451
  get_container_deps_move_script,
450
452
  get_prune_keep,
453
+ get_python_variant,
451
454
  inject_auto_build_args_from_env,
452
455
  load_container_env_tokens,
453
456
  parse_container_deps_mappings,
@@ -457,6 +460,8 @@ def build_image(
457
460
  resolve_container_docker_build_args,
458
461
  resolve_container_dockerfile_hook_path,
459
462
  resolve_extension_content,
463
+ uv_index_secret_build_args,
464
+ uv_index_secret_mounts,
460
465
  )
461
466
  from .project import (
462
467
  has_debug_dependency_group,
@@ -486,6 +491,15 @@ def build_image(
486
491
  docker_build_args,
487
492
  os.getenv("CONTAINER_DOCKER_BUILD_ARGS"),
488
493
  )
494
+ uv_credentials = collect_uv_index_credentials()
495
+ if uv_credentials:
496
+ LOGGER.debug(
497
+ "Passing UV index credentials for: %s",
498
+ ", ".join(c["index_name"] for c in uv_credentials),
499
+ )
500
+ resolved_docker_build_args = uv_index_secret_build_args(uv_credentials) + list(
501
+ resolved_docker_build_args
502
+ )
489
503
  resolved_dockerfile_hook_path = resolve_container_dockerfile_hook_path(
490
504
  dockerfile_hook_path,
491
505
  os.getenv("CONTAINER_DOCKERFILE_HOOK_PATH"),
@@ -513,7 +527,9 @@ def build_image(
513
527
  "Injecting builder-stage env vars: %s",
514
528
  ", ".join(container_env_vars),
515
529
  )
516
- top_level_build_args = inject_auto_build_args_from_env({})
530
+ top_level_build_args = inject_auto_build_args_from_env(
531
+ {"PYTHON_VARIANT": get_python_variant()}
532
+ )
517
533
  external_deps_image = _get_container_build_setting("CONTAINER_DEPS_IMAGE")
518
534
  if external_deps_image:
519
535
  top_level_build_args["CONTAINER_DEPS_IMAGE"] = external_deps_image
@@ -534,7 +550,7 @@ def build_image(
534
550
  continue
535
551
 
536
552
  arg_names = re.findall(
537
- r"^\s*ARG\s+([A-Za-z_][A-Za-z0-9_]*)", fragment, re.M
553
+ r"^\s*ARG\s+([A-Za-z_][A-Za-z0-9_]*)", fragment, re.MULTILINE
538
554
  )
539
555
  if not arg_names:
540
556
  LOGGER.warning(
@@ -606,6 +622,7 @@ def build_image(
606
622
  "ENTRYPOINT_COMMAND": entrypoint_command,
607
623
  "CONTAINER_ENV_VARS": container_env_vars,
608
624
  "CACHE_ID_SUFFIX": cache_id_suffix,
625
+ "UV_INDEX_SECRET_MOUNTS": uv_index_secret_mounts(uv_credentials),
609
626
  },
610
627
  )
611
628
 
@@ -1313,6 +1330,9 @@ def _run_release_flow(
1313
1330
  repository,
1314
1331
  repository_url,
1315
1332
  )
1333
+ log_dry_run(
1334
+ "Would publish GitHub Release %s with built assets", hook_env["RELEASE_TAG"]
1335
+ )
1316
1336
  if include_containers:
1317
1337
  log_dry_run(
1318
1338
  "Would build container image with debug=%s no_cache=%s plain=%s single_arch=%s",
@@ -1322,9 +1342,6 @@ def _run_release_flow(
1322
1342
  single_arch,
1323
1343
  )
1324
1344
  log_dry_run("Would push container image with debug=%s", debug)
1325
- log_dry_run(
1326
- "Would publish GitHub Release %s with built assets", hook_env["RELEASE_TAG"]
1327
- )
1328
1345
  if post_script:
1329
1346
  run_command(["sh", "-lc", post_script], env=hook_env, dry_run=False)
1330
1347
  return
@@ -1333,17 +1350,6 @@ def _run_release_flow(
1333
1350
  hook_env["RELEASE_TAG"], repository=repository, repository_url=repository_url
1334
1351
  )
1335
1352
 
1336
- if include_containers:
1337
- build_image(
1338
- debug=debug,
1339
- no_cache=no_cache,
1340
- plain=plain,
1341
- single_arch=single_arch,
1342
- container_env=container_env,
1343
- container_envfile=container_envfile,
1344
- )
1345
- push_image(debug=debug)
1346
-
1347
1353
  run_command(["git", "push", "origin", hook_env["RELEASE_TAG"]])
1348
1354
  try:
1349
1355
  publish_github_release(
@@ -1362,6 +1368,17 @@ def _run_release_flow(
1362
1368
  )
1363
1369
  raise
1364
1370
 
1371
+ if include_containers:
1372
+ build_image(
1373
+ debug=debug,
1374
+ no_cache=no_cache,
1375
+ plain=plain,
1376
+ single_arch=single_arch,
1377
+ container_env=container_env,
1378
+ container_envfile=container_envfile,
1379
+ )
1380
+ push_image(debug=debug)
1381
+
1365
1382
  if post_script:
1366
1383
  run_command(["sh", "-lc", post_script], env=hook_env, dry_run=False)
1367
1384
 
@@ -6,12 +6,12 @@ import shutil
6
6
  import subprocess
7
7
  import sys
8
8
  import tomllib
9
+ from collections.abc import Collection, Sequence
9
10
  from functools import lru_cache
10
11
  from getpass import getuser
11
12
  from importlib.resources import files
12
13
  from pathlib import Path
13
14
  from shlex import quote
14
- from typing import Collection, Sequence
15
15
 
16
16
  from jinja2 import Template
17
17
 
@@ -154,7 +154,11 @@ def run_command(
154
154
  merged_env = {**os.environ, **env} if env is not None else None
155
155
 
156
156
  out = subprocess.run(
157
- command, capture_output=capture_output, text=True, env=merged_env
157
+ command,
158
+ capture_output=capture_output,
159
+ check=False,
160
+ text=True,
161
+ env=merged_env,
158
162
  )
159
163
  if out.returncode not in acceptable_returncodes:
160
164
  if capture_output:
@@ -234,8 +238,7 @@ def normalize_registry(registry: str | None) -> str | None:
234
238
  return None
235
239
 
236
240
  for prefix in ("https://", "http://"):
237
- if registry.startswith(prefix):
238
- registry = registry[len(prefix) :]
241
+ registry = registry.removeprefix(prefix)
239
242
 
240
243
  return registry.rstrip("/")
241
244
 
@@ -407,8 +410,13 @@ def get_package_name(use_underscores: bool = False) -> str | None:
407
410
  name = (
408
411
  tomllib.loads(pyproject.read_text()).get("project", {}).get("name")
409
412
  )
410
- except Exception as e: # pragma: no cover - defensive logging
411
- LOGGER.debug("Unable to read pyproject.toml: %s", e)
413
+ except (
414
+ AttributeError,
415
+ OSError,
416
+ UnicodeDecodeError,
417
+ tomllib.TOMLDecodeError,
418
+ ) as error: # pragma: no cover - defensive logging
419
+ LOGGER.debug("Unable to read pyproject.toml: %s", error)
412
420
 
413
421
  if use_underscores and name:
414
422
  name = package_name_to_underscore(name)