common-python-tasks 0.3.1__py3-none-any.whl → 0.4.1__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.
@@ -9,7 +9,8 @@ x-common-environment: &common-environment
9
9
  x-common-build-args: &common-build-args
10
10
  PACKAGE_NAME: ${PACKAGE_NAME}
11
11
  PYTHON_VERSION: ${PYTHON_VERSION}
12
- POETRY_VERSION: ${POETRY_VERSION}
12
+ PACKAGE_MANAGER: ${PACKAGE_MANAGER}
13
+ PACKAGE_MANAGER_VERSION: ${PACKAGE_MANAGER_VERSION}
13
14
 
14
15
  services:
15
16
  api:
@@ -8,7 +8,8 @@ x-common-environment: &common-environment
8
8
  x-common-build-args: &common-build-args
9
9
  PACKAGE_NAME: ${PACKAGE_NAME}
10
10
  PYTHON_VERSION: ${PYTHON_VERSION}
11
- POETRY_VERSION: ${POETRY_VERSION}
11
+ PACKAGE_MANAGER: ${PACKAGE_MANAGER}
12
+ PACKAGE_MANAGER_VERSION: ${PACKAGE_MANAGER_VERSION}
12
13
  WORKDIR_PATH: ${WORKDIR_PATH}
13
14
 
14
15
  services:
@@ -6,5 +6,3 @@
6
6
  !LICENSE
7
7
 
8
8
  !src
9
-
10
- !.git
@@ -7,20 +7,48 @@ ARG PYTHON_VERSION=3
7
7
  # See https://hub.docker.com/_/python for available variants
8
8
  ARG PYTHON_VARIANT=slim
9
9
  ARG WORKDIR_PATH=/workspace
10
+ {% set package_manager = PACKAGE_MANAGER|default('poetry') %}
10
11
 
11
12
  FROM python:${PYTHON_VERSION} AS builder
12
13
 
14
+ ARG PACKAGE_VERSION
15
+
16
+ {% if package_manager == 'poetry' %}
13
17
  ARG POETRY_VERSION
14
18
  ARG POETRY_DYNAMIC_VERSIONING_PLUGIN_VERSION
15
19
  ARG POETRY_PLUGIN_EXPORT_VERSION
16
- ARG TOMLKIT_VERSION
17
20
 
18
- # Install Poetry and required Poetry plugins for build
19
- RUN --mount=type=cache,target=/root/.cache/pip,id=pip-cache{{ CACHE_ID_SUFFIX }} pip install --root-user-action=ignore \
20
- "poetry==$POETRY_VERSION" \
21
- "poetry-dynamic-versioning[plugin]==$POETRY_DYNAMIC_VERSIONING_PLUGIN_VERSION" \
22
- "poetry-plugin-export==$POETRY_PLUGIN_EXPORT_VERSION" \
23
- "tomlkit==$TOMLKIT_VERSION"
21
+ # Bypass VCS-based version detection: use the host-computed version directly.
22
+ # This avoids requiring a .git directory in the build context and prevents
23
+ # false dirty-version detection from a partial working tree.
24
+ ENV POETRY_DYNAMIC_VERSIONING_BYPASS=${PACKAGE_VERSION}
25
+
26
+ # Install Poetry and required Poetry plugins for package build
27
+ RUN --mount=type=cache,target=/root/.cache/pip,id=pip-cache{{ CACHE_ID_SUFFIX }} \
28
+ sh -c 'set -eu; \
29
+ poetry_spec="poetry"; \
30
+ if [ -n "${POETRY_VERSION:-}" ]; then poetry_spec="poetry==$POETRY_VERSION"; fi; \
31
+ dynamic_spec="poetry-dynamic-versioning[plugin]"; \
32
+ if [ -n "${POETRY_DYNAMIC_VERSIONING_PLUGIN_VERSION:-}" ]; then dynamic_spec="poetry-dynamic-versioning[plugin]==$POETRY_DYNAMIC_VERSIONING_PLUGIN_VERSION"; fi; \
33
+ export_spec="poetry-plugin-export"; \
34
+ if [ -n "${POETRY_PLUGIN_EXPORT_VERSION:-}" ]; then export_spec="poetry-plugin-export==$POETRY_PLUGIN_EXPORT_VERSION"; fi; \
35
+ pip install --root-user-action=ignore "$poetry_spec" "$dynamic_spec" "$export_spec"'
36
+ {% else %}
37
+ ARG UV_VERSION
38
+
39
+ # Bypass VCS-based version detection: use the host-computed version directly.
40
+ # This avoids requiring a .git directory in the build context and prevents
41
+ # false dirty-version detection from a partial working tree.
42
+ ENV UV_DYNAMIC_VERSIONING_BYPASS=${PACKAGE_VERSION}
43
+
44
+ # Install uv for package build and dependency export steps
45
+ RUN --mount=type=cache,target=/root/.cache/pip,id=pip-cache{{ CACHE_ID_SUFFIX }} \
46
+ sh -c 'if [ -n "${UV_VERSION:-}" ]; then \
47
+ pip install --root-user-action=ignore "uv==$UV_VERSION"; \
48
+ else \
49
+ pip install --root-user-action=ignore uv; \
50
+ fi'
51
+ {% endif %}
24
52
 
25
53
  # Build package
26
54
  WORKDIR /tmp/build
@@ -28,15 +56,24 @@ COPY . /tmp/build/
28
56
 
29
57
  {% if HAS_DEBUG_DEPS %}
30
58
  # Export debug requirements when the project defines a debug dependency group
59
+ {% if package_manager == 'poetry' %}
31
60
  RUN --mount=type=cache,target=/root/.cache/pip,id=pip-cache{{ CACHE_ID_SUFFIX }} \
32
61
  poetry export --only debug --without-hashes -f requirements.txt --output requirements-debug.txt
62
+ {% else %}
63
+ RUN --mount=type=cache,target=/root/.cache/uv,id=uv-cache{{ CACHE_ID_SUFFIX }} \
64
+ uv export --group debug --no-hashes --format requirements-txt --output-file requirements-debug.txt
65
+ {% endif %}
33
66
  {% endif %}
34
67
 
35
- # Build the wheel, caching Poetry's cache directory to speed up subsequent builds.
68
+ # Build the wheel, caching the package-manager cache directory to speed up subsequent builds.
36
69
  # If running this in a CI system, you must be using a persistent builder to take
37
70
  # advantage of the cache mount, and you should configure your CI to persist the
38
71
  # cache directory between builds
72
+ {% if package_manager == 'poetry' %}
39
73
  RUN --mount=type=cache,target=/root/.cache/pypoetry,id=pypoetry-cache{{ CACHE_ID_SUFFIX }} poetry build --format=wheel
74
+ {% else %}
75
+ RUN --mount=type=cache,target=/root/.cache/uv,id=uv-cache{{ CACHE_ID_SUFFIX }} uv build --wheel
76
+ {% endif %}
40
77
 
41
78
  FROM python:${PYTHON_VERSION}-${PYTHON_VARIANT} AS runtime
42
79
  ARG WORKDIR_PATH=/workspace
@@ -93,7 +93,6 @@ def _safe_unlink(path: Path | str | None) -> None:
93
93
 
94
94
 
95
95
  def _build_docker_build_command(
96
- *,
97
96
  context_path: Path,
98
97
  dockerfile_path: Path,
99
98
  build_args: dict[str, str],
@@ -152,35 +151,60 @@ def prune_images_keep(
152
151
 
153
152
  retain_count = int(keep) + 1
154
153
 
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},
154
+ def _list_images_for_reference(reference: str) -> list[str]:
155
+ res = utils.run_command(
156
+ [
157
+ "docker",
158
+ "image",
159
+ "ls",
160
+ "--format",
161
+ "{{.Repository}}:{{.Tag}}",
162
+ "--filter",
163
+ f"reference={reference}:*",
164
+ ],
165
+ capture_output=True,
166
+ acceptable_returncodes={0, 1},
167
+ )
168
+ if res.returncode != 0:
169
+ LOGGER.warning("Failed to list images for pruning: %s", reference)
170
+ return []
171
+
172
+ return [
173
+ line.strip()
174
+ for line in res.stdout.splitlines()
175
+ if line.strip() and not line.strip().startswith("<none>")
176
+ ]
177
+
178
+ parsed_full_name = utils.parse_image_reference(full_name)
179
+ short_reference = utils.compose_image_name(package_name, fully_qualified=False)
180
+ full_reference = utils.compose_image_name(
181
+ package_name,
182
+ registry=parsed_full_name["registry"],
183
+ namespace=parsed_full_name["namespace"],
167
184
  )
168
- if res.returncode != 0:
169
- LOGGER.exception("Failed to list images for pruning: %s", full_name)
170
- return
171
185
 
172
- lines = [
173
- line.strip()
174
- for line in res.stdout.splitlines()
175
- if line.strip() and not line.strip().startswith("<none>")
186
+ lines = _list_images_for_reference(full_reference)
187
+ lines += [
188
+ line
189
+ for line in _list_images_for_reference(short_reference)
190
+ if line not in lines
176
191
  ]
177
- tags_in_order = []
192
+
193
+ tags_in_order: list[str] = []
194
+ seen_tags: set[str] = set()
178
195
  for entry in lines:
179
- if ":" not in entry:
196
+ try:
197
+ parsed_entry = utils.parse_image_reference(entry)
198
+ except ValueError:
180
199
  continue
181
- repo, tag = entry.rsplit(":", 1)
182
- if entry.startswith(full_name) or repo.endswith(package_name):
183
- tags_in_order.append(tag)
200
+
201
+ if (
202
+ parsed_entry["repository"] == package_name
203
+ and parsed_entry["tag"]
204
+ and parsed_entry["tag"] not in seen_tags
205
+ ):
206
+ tags_in_order.append(parsed_entry["tag"])
207
+ seen_tags.add(parsed_entry["tag"])
184
208
 
185
209
  candidates = [t for t in tags_in_order if t not in protect_tags]
186
210
  if len(candidates) <= retain_count:
@@ -188,7 +212,18 @@ def prune_images_keep(
188
212
 
189
213
  to_delete = candidates[retain_count:]
190
214
  for tag in to_delete:
191
- for img in (f"{package_name}:{tag}", f"{full_name}:{tag}"):
215
+ short_image = utils.compose_image_name(
216
+ package_name,
217
+ tag=tag,
218
+ fully_qualified=False,
219
+ )
220
+ full_image = utils.compose_image_name(
221
+ package_name,
222
+ tag=tag,
223
+ registry=parsed_full_name["registry"],
224
+ namespace=parsed_full_name["namespace"],
225
+ )
226
+ for img in (short_image, full_image):
192
227
  try:
193
228
  LOGGER.info("Pruning image %s", img)
194
229
  utils.run_command(["docker", "rmi", img], acceptable_returncodes={0, 1})
@@ -295,12 +330,12 @@ def build_deps_image(
295
330
 
296
331
  full_tag = f"{deps_image_name}:{deps_tag}"
297
332
  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,
333
+ context_path,
334
+ dockerfile_path,
335
+ build_args,
336
+ archs,
337
+ no_cache,
338
+ plain,
304
339
  tags=[full_tag],
305
340
  )
306
341
 
@@ -369,6 +404,7 @@ def build_image(
369
404
  try:
370
405
  archs = _resolve_archs(single_arch)
371
406
  files_to_ignore = [Path(".dockerignore")] if temp_dockerignore_created else []
407
+ package_version = git.get_version(ignore=files_to_ignore)
372
408
  version_string = git.get_image_tag(ignore=files_to_ignore)
373
409
 
374
410
  dockerfile_source = (
@@ -400,26 +436,51 @@ def build_image(
400
436
  tag = BuildTag.LATEST if not git.has_tags_later_in_history() else None
401
437
 
402
438
  version_tag = f"{version_string}{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}"
439
+ if git.git_available():
440
+ commit_hash = utils.run_command(
441
+ ["git", "rev-parse", "--short", "HEAD"],
442
+ capture_output=True,
443
+ acceptable_returncodes={0},
444
+ ).stdout.strip()
445
+ dirty_suffix = (
446
+ "-dirty" if git.get_dirty_files(ignore=files_to_ignore) else ""
447
+ )
448
+ commit_tag = f"{commit_hash}{dirty_suffix}{suffix}"
449
+ else:
450
+ commit_tag = version_tag
451
+
404
452
  python_version = platform.python_version()
405
- poetry_version = project.get_poetry_version()
453
+ package_manager = project.get_package_manager()
454
+ package_manager_version = project.get_package_manager_version(package_manager)
406
455
 
407
- build_args = _merge_build_args(
408
- {
409
- "PYTHON_VERSION": python_version,
410
- "POETRY_VERSION": poetry_version,
456
+ manager_build_args: dict[str, str | None]
457
+ if package_manager == project.PackageManager.POETRY:
458
+ manager_build_args = {
459
+ "POETRY_VERSION": package_manager_version,
411
460
  "POETRY_DYNAMIC_VERSIONING_PLUGIN_VERSION": project.get_installed_requirement_version(
412
461
  "poetry-dynamic-versioning[plugin]"
413
462
  ),
414
463
  "POETRY_PLUGIN_EXPORT_VERSION": project.get_installed_requirement_version(
415
464
  "poetry-plugin-export"
416
465
  ),
417
- "TOMLKIT_VERSION": project.get_installed_requirement_version("tomlkit"),
466
+ }
467
+ else:
468
+ manager_build_args = {
469
+ "UV_VERSION": package_manager_version,
470
+ }
471
+
472
+ build_args = _merge_build_args(
473
+ {
474
+ "PYTHON_VERSION": python_version,
475
+ "PACKAGE_MANAGER": package_manager,
476
+ "PACKAGE_MANAGER_VERSION": package_manager_version,
477
+ "PACKAGE_VERSION": package_version,
418
478
  "PACKAGE_NAME": utils.get_package_name(use_underscores=True),
419
479
  "AUTHORS": ",".join(
420
480
  [f"{name} <{email}>" for name, email in project.get_authors()]
421
481
  ),
422
482
  "GIT_COMMIT": commit_tag,
483
+ **manager_build_args,
423
484
  },
424
485
  extra_build_args,
425
486
  )
@@ -432,26 +493,36 @@ def build_image(
432
493
  image_name if image_name is not None else utils.get_package_name()
433
494
  )
434
495
  orig_full_name = utils.get_full_image_name()
435
- if image_name is None:
436
- image_full_name = orig_full_name
437
- else:
438
- if "/" in orig_full_name:
439
- prefix = orig_full_name.rsplit("/", 1)[0]
440
- image_full_name = f"{prefix}/{image_name}"
441
- else:
442
- image_full_name = image_name
496
+
497
+ parsed_full_name = utils.parse_image_reference(orig_full_name)
498
+ short_tags = [
499
+ utils.compose_image_name(image_short_name, tag=t, fully_qualified=False)
500
+ for t in tags_to_use
501
+ ]
502
+ full_tags = [
503
+ utils.compose_image_name(
504
+ image_short_name,
505
+ tag=t,
506
+ registry=parsed_full_name["registry"],
507
+ namespace=parsed_full_name["namespace"],
508
+ )
509
+ for t in tags_to_use
510
+ ]
511
+
512
+ all_tags: list[str] = []
513
+ for resolved_tag in [*short_tags, *full_tags]:
514
+ if resolved_tag not in all_tags:
515
+ all_tags.append(resolved_tag)
516
+
443
517
  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,
518
+ context_path,
519
+ dockerfile_path,
520
+ build_args,
521
+ archs,
522
+ no_cache,
523
+ plain,
450
524
  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],
454
- ],
525
+ tags=all_tags,
455
526
  )
456
527
  utils.run_command(build_cmd)
457
528
  delete_temp_file = True
@@ -14,7 +14,7 @@ from poethepoet_tasks import TaskCollection
14
14
 
15
15
  from . import utils
16
16
  from .env import get_workdir_path
17
- from .project import get_poetry_version
17
+ from .project import get_package_manager, get_package_manager_version
18
18
 
19
19
  LOGGER = logging.getLogger(__name__)
20
20
 
@@ -232,7 +232,8 @@ _COMPOSE_VAR_REQUIREMENTS: dict[str, dict[str, set[str]]] = {
232
232
  "ENVIRONMENT",
233
233
  "IMAGE_TAG",
234
234
  "PYTHON_VERSION",
235
- "POETRY_VERSION",
235
+ "PACKAGE_MANAGER",
236
+ "PACKAGE_MANAGER_VERSION",
236
237
  },
237
238
  "compose-db": {
238
239
  "PACKAGE_NAME",
@@ -242,7 +243,8 @@ _COMPOSE_VAR_REQUIREMENTS: dict[str, dict[str, set[str]]] = {
242
243
  "DB_PORT",
243
244
  "IMAGE_TAG",
244
245
  "PYTHON_VERSION",
245
- "POETRY_VERSION",
246
+ "PACKAGE_MANAGER",
247
+ "PACKAGE_MANAGER_VERSION",
246
248
  "POSTGRES_VERSION",
247
249
  "WORKDIR_PATH",
248
250
  },
@@ -328,9 +330,10 @@ def get_compose_env(
328
330
  "ENVIRONMENT": os.getenv("ENVIRONMENT", "production"),
329
331
  "IMAGE_TAG": image_tag or "latest",
330
332
  "PACKAGE_NAME": package_name,
333
+ "PACKAGE_MANAGER": get_package_manager(),
334
+ "PACKAGE_MANAGER_VERSION": get_package_manager_version(),
331
335
  "PACKAGE_UNDERSCORE_NAME": utils.package_name_to_underscore(package_name),
332
336
  "WORKDIR_PATH": get_workdir_path(),
333
- "POETRY_VERSION": get_poetry_version(),
334
337
  "POSTGRES_VERSION": os.getenv("POSTGRES_VERSION", "17"),
335
338
  "PYTHON_VERSION": platform.python_version(),
336
339
  "SECRET_KEY": os.getenv("SECRET_KEY", ""),
@@ -375,6 +378,8 @@ def load_compose_files(
375
378
  cleaned up **after** `docker compose` down`.
376
379
  """
377
380
 
381
+ from .env import split_colon_delimited_values
382
+
378
383
  compose_files_env = os.getenv("COMPOSE_FILE")
379
384
  if compose_files_env:
380
385
  LOGGER.debug(
@@ -382,14 +387,14 @@ def load_compose_files(
382
387
  compose_files_env,
383
388
  )
384
389
  return (
385
- [Path(f.strip()) for f in compose_files_env.split(":") if f.strip()],
390
+ [Path(path) for path in split_colon_delimited_values(compose_files_env)],
386
391
  [],
387
392
  [],
388
393
  )
389
394
 
390
- compose_type = os.environ["COMPOSE_TYPE"]
395
+ compose_type = get_compose_type()
391
396
  compose_addons_str = os.getenv("COMPOSE_ADDONS", "")
392
- compose_addons = [a.strip() for a in compose_addons_str.split(":") if a.strip()]
397
+ compose_addons = split_colon_delimited_values(compose_addons_str)
393
398
 
394
399
  LOGGER.debug(
395
400
  "Loading compose files for type '%s' with addons: %s%s",
@@ -459,7 +464,7 @@ def load_compose_files(
459
464
 
460
465
  overlay_files_str = os.getenv("COMPOSE_OVERLAY_FILES", "")
461
466
  if overlay_files_str:
462
- overlay_files = [f.strip() for f in overlay_files_str.split(":") if f.strip()]
467
+ overlay_files = split_colon_delimited_values(overlay_files_str)
463
468
  LOGGER.debug("Adding overlay compose files: %s", overlay_files)
464
469
  for overlay_file in overlay_files:
465
470
  files_and_cleanups.append((Path(overlay_file), False))
@@ -497,6 +502,7 @@ def run_docker_compose_command(
497
502
  *args: str | None,
498
503
  compose_files: list[Path],
499
504
  compose_env: dict[str, str],
505
+ tasks: TaskCollection,
500
506
  ) -> None:
501
507
  """Run `docker compose` with standard file and env-file arguments.
502
508
 
@@ -512,7 +518,7 @@ def run_docker_compose_command(
512
518
 
513
519
  utils.run_command(
514
520
  [
515
- *compose_cmd_prefix(compose_files),
521
+ *compose_cmd_prefix(compose_files, tasks),
516
522
  *[arg for arg in args if arg is not None],
517
523
  ],
518
524
  env=compose_env,
@@ -1,8 +1,8 @@
1
1
  import logging
2
2
  import os
3
3
  import secrets
4
+ import shlex
4
5
  from pathlib import Path
5
- from shlex import split as shlex_split
6
6
 
7
7
  from . import utils
8
8
 
@@ -14,6 +14,7 @@ TRUTHY_VALUES = {
14
14
  "yes",
15
15
  "on",
16
16
  "enabled",
17
+ "enable",
17
18
  "y",
18
19
  "t",
19
20
  }
@@ -75,7 +76,7 @@ def parse_container_extensions() -> list[dict]:
75
76
  exts: list[dict] = []
76
77
  files_raw = os.getenv("CONTAINER_EXTENSION_FILES")
77
78
  if files_raw:
78
- for part in [p.strip() for p in files_raw.split(":") if p.strip()]:
79
+ for part in split_colon_delimited_values(files_raw):
79
80
  pth = Path(part)
80
81
  exts.append(
81
82
  {
@@ -91,7 +92,7 @@ def parse_container_extensions() -> list[dict]:
91
92
  )
92
93
  bundles_raw = os.getenv("CONTAINER_EXTENSIONS")
93
94
  if bundles_raw:
94
- for part in (p.strip() for p in bundles_raw.split(":")):
95
+ for part in split_colon_delimited_values(bundles_raw):
95
96
  if not part:
96
97
  continue
97
98
  if "=" in part:
@@ -180,24 +181,64 @@ def load_container_env_file(path: str | None = None) -> str | None:
180
181
  return container_env if container_env else None
181
182
 
182
183
 
183
- def split_colon_delimited_values(value: str) -> list[str]:
184
- """Split a colon-delimited string while preserving quoted substrings.
184
+ def split_delimited_values(
185
+ value: str,
186
+ separators: str = ":",
187
+ allow_whitespace: bool = False,
188
+ ) -> list[str]:
189
+ """Split a delimited string while preserving quoted substrings.
185
190
 
186
191
  Args:
187
- value: The colon-delimited string to split.
192
+ value: The string to split.
193
+ separators: Explicit separator characters to use.
194
+ allow_whitespace: Treat whitespace as additional separators.
188
195
 
189
196
  Returns:
190
- A list of strings representing the split values.
197
+ A list of strings representing the parsed tokens.
191
198
  """
192
199
  if not value or not value.strip():
193
200
  return []
194
201
 
195
- try:
196
- tokens = shlex_split(value.replace(":", " "), comments=True)
197
- except ValueError as exc:
198
- raise ValueError(f"Unable to parse colon-delimited values: {exc}") from exc
202
+ lexer = shlex.shlex(value, posix=True)
203
+ lexer.whitespace = separators + (" \t\r\n" if allow_whitespace else "")
204
+ lexer.whitespace_split = True
205
+ lexer.commenters = "#"
206
+ return [token for token in lexer if token.strip()]
199
207
 
200
- return [token for token in tokens if token.strip()]
208
+
209
+ def split_colon_delimited_values(value: str) -> list[str]:
210
+ """Split a colon- or whitespace-delimited string while preserving quoted substrings."""
211
+ return split_delimited_values(value, separators=":", allow_whitespace=True)
212
+
213
+
214
+ def split_colon_or_whitespace_delimited_values(value: str) -> list[str]:
215
+ """Split a colon- or whitespace-delimited string while preserving quoted substrings."""
216
+ return split_colon_delimited_values(value)
217
+
218
+
219
+ def parse_container_build_args(
220
+ cli_build_args: list[str] | None,
221
+ env_build_args: str | None,
222
+ ) -> dict[str, str] | None:
223
+ """Parse container build args from CLI or environment input."""
224
+ if cli_build_args is not None:
225
+ build_arg_tokens = [
226
+ token.strip() for token in cli_build_args if token and token.strip()
227
+ ]
228
+ elif env_build_args:
229
+ build_arg_tokens = split_colon_delimited_values(env_build_args)
230
+ else:
231
+ return None
232
+
233
+ parsed_build_args: dict[str, str] = {}
234
+ for token in build_arg_tokens:
235
+ if "=" not in token:
236
+ LOGGER.warning("Ignoring invalid build-arg token: %s", token)
237
+ continue
238
+ key, value = token.split("=", 1)
239
+ parsed_build_args[key.strip()] = value.strip()
240
+
241
+ return parsed_build_args or None
201
242
 
202
243
 
203
244
  def parse_container_env_tokens(value: str | list[str] | None) -> list[str]:
@@ -96,7 +96,7 @@ def compute_next_release_version(
96
96
  Returns:
97
97
  A `NextReleaseVersion` with the serialized new version and normalized component.
98
98
  """
99
- current_version_text = project.get_project_version_from_poetry()
99
+ current_version_text = project.get_project_version()
100
100
  release_component = (
101
101
  infer_bump_component_from_git_cliff() if component is None else component
102
102
  )
@@ -181,14 +181,11 @@ def infer_bump_component_from_git_cliff() -> ReleaseComponent:
181
181
  except Exception:
182
182
  utils.fatal(f"Unable to parse bumped version from git-cliff: {version_text!r}")
183
183
 
184
- current_version = project.get_project_version_from_poetry()
184
+ current_version = project.get_project_version()
185
185
  try:
186
186
  current = Version.parse(current_version)
187
187
  except Exception:
188
- utils.fatal(
189
- "Unable to parse current version from Poetry output: "
190
- f"{current_version!r}"
191
- )
188
+ utils.fatal("Unable to parse current project version: " f"{current_version!r}")
192
189
 
193
190
  def _parse_semver(version_value: str | Any) -> tuple[int, int, int]:
194
191
  if not isinstance(version_value, str):
@@ -220,6 +217,20 @@ def infer_bump_component_from_git_cliff() -> ReleaseComponent:
220
217
  )
221
218
 
222
219
 
220
+ def git_available() -> bool:
221
+ """Return `True` if Git is installed and the current directory is a repository."""
222
+
223
+ if shutil.which("git") is None:
224
+ return False
225
+
226
+ result = utils.run_command(
227
+ ["git", "rev-parse", "--is-inside-work-tree"],
228
+ capture_output=True,
229
+ acceptable_returncodes={0, 1, 128},
230
+ )
231
+ return result.returncode == 0
232
+
233
+
223
234
  def get_dirty_files(ignore: list[Path] | None = None) -> list[Path]:
224
235
  """Get dirty git files excluding any specified paths.
225
236
 
@@ -233,6 +244,9 @@ def get_dirty_files(ignore: list[Path] | None = None) -> list[Path]:
233
244
  if ignore is None:
234
245
  ignore = []
235
246
 
247
+ if not git_available():
248
+ return []
249
+
236
250
  ignore_set = {Path(p) for p in ignore}
237
251
 
238
252
  return [
@@ -260,6 +274,10 @@ def get_version(ignore: list[Path] | None = None) -> str:
260
274
  if ignore is None:
261
275
  ignore = []
262
276
 
277
+ if not git_available():
278
+ LOGGER.warning("Git is unavailable. Falling back to placeholder version 0.0.0.")
279
+ return "0.0.0"
280
+
263
281
  dirty_files = get_dirty_files(ignore=ignore)
264
282
  LOGGER.debug("Dirty files: %s", dirty_files)
265
283
 
@@ -296,6 +314,9 @@ def has_tags_later_in_history() -> bool:
296
314
  `True` if tags are later in history, else `False`.
297
315
  """
298
316
 
317
+ if not git_available():
318
+ return False
319
+
299
320
  result = utils.run_command(
300
321
  ["git", "tag"],
301
322
  capture_output=True,