common-python-tasks 0.3.1__py3-none-any.whl → 0.4.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.
@@ -6,5 +6,3 @@
6
6
  !LICENSE
7
7
 
8
8
  !src
9
-
10
- !.git
@@ -13,14 +13,18 @@ FROM python:${PYTHON_VERSION} AS builder
13
13
  ARG POETRY_VERSION
14
14
  ARG POETRY_DYNAMIC_VERSIONING_PLUGIN_VERSION
15
15
  ARG POETRY_PLUGIN_EXPORT_VERSION
16
- ARG TOMLKIT_VERSION
16
+ ARG PACKAGE_VERSION
17
+
18
+ # Bypass VCS-based version detection: use the host-computed version directly.
19
+ # This avoids requiring a .git directory in the build context and prevents
20
+ # false dirty-version detection from a partial working tree.
21
+ ENV POETRY_DYNAMIC_VERSIONING_BYPASS=${PACKAGE_VERSION}
17
22
 
18
23
  # Install Poetry and required Poetry plugins for build
19
24
  RUN --mount=type=cache,target=/root/.cache/pip,id=pip-cache{{ CACHE_ID_SUFFIX }} pip install --root-user-action=ignore \
20
25
  "poetry==$POETRY_VERSION" \
21
26
  "poetry-dynamic-versioning[plugin]==$POETRY_DYNAMIC_VERSIONING_PLUGIN_VERSION" \
22
- "poetry-plugin-export==$POETRY_PLUGIN_EXPORT_VERSION" \
23
- "tomlkit==$TOMLKIT_VERSION"
27
+ "poetry-plugin-export==$POETRY_PLUGIN_EXPORT_VERSION"
24
28
 
25
29
  # Build package
26
30
  WORKDIR /tmp/build
@@ -152,35 +152,60 @@ def prune_images_keep(
152
152
 
153
153
  retain_count = int(keep) + 1
154
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},
155
+ def _list_images_for_reference(reference: str) -> list[str]:
156
+ res = utils.run_command(
157
+ [
158
+ "docker",
159
+ "image",
160
+ "ls",
161
+ "--format",
162
+ "{{.Repository}}:{{.Tag}}",
163
+ "--filter",
164
+ f"reference={reference}:*",
165
+ ],
166
+ capture_output=True,
167
+ acceptable_returncodes={0, 1},
168
+ )
169
+ if res.returncode != 0:
170
+ LOGGER.warning("Failed to list images for pruning: %s", reference)
171
+ return []
172
+
173
+ return [
174
+ line.strip()
175
+ for line in res.stdout.splitlines()
176
+ if line.strip() and not line.strip().startswith("<none>")
177
+ ]
178
+
179
+ parsed_full_name = utils.parse_image_reference(full_name)
180
+ short_reference = utils.compose_image_name(package_name, fully_qualified=False)
181
+ full_reference = utils.compose_image_name(
182
+ package_name,
183
+ registry=parsed_full_name["registry"],
184
+ namespace=parsed_full_name["namespace"],
167
185
  )
168
- if res.returncode != 0:
169
- LOGGER.exception("Failed to list images for pruning: %s", full_name)
170
- return
171
186
 
172
- lines = [
173
- line.strip()
174
- for line in res.stdout.splitlines()
175
- if line.strip() and not line.strip().startswith("<none>")
187
+ lines = _list_images_for_reference(full_reference)
188
+ lines += [
189
+ line
190
+ for line in _list_images_for_reference(short_reference)
191
+ if line not in lines
176
192
  ]
177
- tags_in_order = []
193
+
194
+ tags_in_order: list[str] = []
195
+ seen_tags: set[str] = set()
178
196
  for entry in lines:
179
- if ":" not in entry:
197
+ try:
198
+ parsed_entry = utils.parse_image_reference(entry)
199
+ except ValueError:
180
200
  continue
181
- repo, tag = entry.rsplit(":", 1)
182
- if entry.startswith(full_name) or repo.endswith(package_name):
183
- tags_in_order.append(tag)
201
+
202
+ if (
203
+ parsed_entry["repository"] == package_name
204
+ and parsed_entry["tag"]
205
+ and parsed_entry["tag"] not in seen_tags
206
+ ):
207
+ tags_in_order.append(parsed_entry["tag"])
208
+ seen_tags.add(parsed_entry["tag"])
184
209
 
185
210
  candidates = [t for t in tags_in_order if t not in protect_tags]
186
211
  if len(candidates) <= retain_count:
@@ -188,7 +213,18 @@ def prune_images_keep(
188
213
 
189
214
  to_delete = candidates[retain_count:]
190
215
  for tag in to_delete:
191
- for img in (f"{package_name}:{tag}", f"{full_name}:{tag}"):
216
+ short_image = utils.compose_image_name(
217
+ package_name,
218
+ tag=tag,
219
+ fully_qualified=False,
220
+ )
221
+ full_image = utils.compose_image_name(
222
+ package_name,
223
+ tag=tag,
224
+ registry=parsed_full_name["registry"],
225
+ namespace=parsed_full_name["namespace"],
226
+ )
227
+ for img in (short_image, full_image):
192
228
  try:
193
229
  LOGGER.info("Pruning image %s", img)
194
230
  utils.run_command(["docker", "rmi", img], acceptable_returncodes={0, 1})
@@ -369,6 +405,7 @@ def build_image(
369
405
  try:
370
406
  archs = _resolve_archs(single_arch)
371
407
  files_to_ignore = [Path(".dockerignore")] if temp_dockerignore_created else []
408
+ package_version = git.get_version(ignore=files_to_ignore)
372
409
  version_string = git.get_image_tag(ignore=files_to_ignore)
373
410
 
374
411
  dockerfile_source = (
@@ -400,7 +437,19 @@ def build_image(
400
437
  tag = BuildTag.LATEST if not git.has_tags_later_in_history() else None
401
438
 
402
439
  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}"
440
+ if git.git_available():
441
+ commit_hash = utils.run_command(
442
+ ["git", "rev-parse", "--short", "HEAD"],
443
+ capture_output=True,
444
+ acceptable_returncodes={0},
445
+ ).stdout.strip()
446
+ dirty_suffix = (
447
+ "-dirty" if git.get_dirty_files(ignore=files_to_ignore) else ""
448
+ )
449
+ commit_tag = f"{commit_hash}{dirty_suffix}{suffix}"
450
+ else:
451
+ commit_tag = version_tag
452
+
404
453
  python_version = platform.python_version()
405
454
  poetry_version = project.get_poetry_version()
406
455
 
@@ -414,7 +463,7 @@ def build_image(
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
+ "PACKAGE_VERSION": package_version,
418
467
  "PACKAGE_NAME": utils.get_package_name(use_underscores=True),
419
468
  "AUTHORS": ",".join(
420
469
  [f"{name} <{email}>" for name, email in project.get_authors()]
@@ -432,14 +481,27 @@ def build_image(
432
481
  image_name if image_name is not None else utils.get_package_name()
433
482
  )
434
483
  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
484
+
485
+ parsed_full_name = utils.parse_image_reference(orig_full_name)
486
+ short_tags = [
487
+ utils.compose_image_name(image_short_name, tag=t, fully_qualified=False)
488
+ for t in tags_to_use
489
+ ]
490
+ full_tags = [
491
+ utils.compose_image_name(
492
+ image_short_name,
493
+ tag=t,
494
+ registry=parsed_full_name["registry"],
495
+ namespace=parsed_full_name["namespace"],
496
+ )
497
+ for t in tags_to_use
498
+ ]
499
+
500
+ all_tags: list[str] = []
501
+ for resolved_tag in [*short_tags, *full_tags]:
502
+ if resolved_tag not in all_tags:
503
+ all_tags.append(resolved_tag)
504
+
443
505
  build_cmd = _build_docker_build_command(
444
506
  context_path=context_path,
445
507
  dockerfile_path=dockerfile_path,
@@ -448,10 +510,7 @@ def build_image(
448
510
  no_cache=no_cache,
449
511
  plain=plain,
450
512
  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
- ],
513
+ tags=all_tags,
455
514
  )
456
515
  utils.run_command(build_cmd)
457
516
  delete_temp_file = True
@@ -375,6 +375,8 @@ def load_compose_files(
375
375
  cleaned up **after** `docker compose` down`.
376
376
  """
377
377
 
378
+ from .env import split_colon_delimited_values
379
+
378
380
  compose_files_env = os.getenv("COMPOSE_FILE")
379
381
  if compose_files_env:
380
382
  LOGGER.debug(
@@ -382,14 +384,14 @@ def load_compose_files(
382
384
  compose_files_env,
383
385
  )
384
386
  return (
385
- [Path(f.strip()) for f in compose_files_env.split(":") if f.strip()],
387
+ [Path(path) for path in split_colon_delimited_values(compose_files_env)],
386
388
  [],
387
389
  [],
388
390
  )
389
391
 
390
- compose_type = os.environ["COMPOSE_TYPE"]
392
+ compose_type = get_compose_type()
391
393
  compose_addons_str = os.getenv("COMPOSE_ADDONS", "")
392
- compose_addons = [a.strip() for a in compose_addons_str.split(":") if a.strip()]
394
+ compose_addons = split_colon_delimited_values(compose_addons_str)
393
395
 
394
396
  LOGGER.debug(
395
397
  "Loading compose files for type '%s' with addons: %s%s",
@@ -459,7 +461,7 @@ def load_compose_files(
459
461
 
460
462
  overlay_files_str = os.getenv("COMPOSE_OVERLAY_FILES", "")
461
463
  if overlay_files_str:
462
- overlay_files = [f.strip() for f in overlay_files_str.split(":") if f.strip()]
464
+ overlay_files = split_colon_delimited_values(overlay_files_str)
463
465
  LOGGER.debug("Adding overlay compose files: %s", overlay_files)
464
466
  for overlay_file in overlay_files:
465
467
  files_and_cleanups.append((Path(overlay_file), False))
@@ -497,6 +499,7 @@ def run_docker_compose_command(
497
499
  *args: str | None,
498
500
  compose_files: list[Path],
499
501
  compose_env: dict[str, str],
502
+ tasks: TaskCollection,
500
503
  ) -> None:
501
504
  """Run `docker compose` with standard file and env-file arguments.
502
505
 
@@ -512,7 +515,7 @@ def run_docker_compose_command(
512
515
 
513
516
  utils.run_command(
514
517
  [
515
- *compose_cmd_prefix(compose_files),
518
+ *compose_cmd_prefix(compose_files, tasks),
516
519
  *[arg for arg in args if arg is not None],
517
520
  ],
518
521
  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]:
@@ -220,6 +220,20 @@ def infer_bump_component_from_git_cliff() -> ReleaseComponent:
220
220
  )
221
221
 
222
222
 
223
+ def git_available() -> bool:
224
+ """Return `True` if Git is installed and the current directory is a repository."""
225
+
226
+ if shutil.which("git") is None:
227
+ return False
228
+
229
+ result = utils.run_command(
230
+ ["git", "rev-parse", "--is-inside-work-tree"],
231
+ capture_output=True,
232
+ acceptable_returncodes={0, 1, 128},
233
+ )
234
+ return result.returncode == 0
235
+
236
+
223
237
  def get_dirty_files(ignore: list[Path] | None = None) -> list[Path]:
224
238
  """Get dirty git files excluding any specified paths.
225
239
 
@@ -233,6 +247,9 @@ def get_dirty_files(ignore: list[Path] | None = None) -> list[Path]:
233
247
  if ignore is None:
234
248
  ignore = []
235
249
 
250
+ if not git_available():
251
+ return []
252
+
236
253
  ignore_set = {Path(p) for p in ignore}
237
254
 
238
255
  return [
@@ -260,6 +277,10 @@ def get_version(ignore: list[Path] | None = None) -> str:
260
277
  if ignore is None:
261
278
  ignore = []
262
279
 
280
+ if not git_available():
281
+ LOGGER.warning("Git is unavailable. Falling back to placeholder version 0.0.0.")
282
+ return "0.0.0"
283
+
263
284
  dirty_files = get_dirty_files(ignore=ignore)
264
285
  LOGGER.debug("Dirty files: %s", dirty_files)
265
286
 
@@ -296,6 +317,9 @@ def has_tags_later_in_history() -> bool:
296
317
  `True` if tags are later in history, else `False`.
297
318
  """
298
319
 
320
+ if not git_available():
321
+ return False
322
+
299
323
  result = utils.run_command(
300
324
  ["git", "tag"],
301
325
  capture_output=True,
@@ -3,12 +3,11 @@ import logging
3
3
  import mimetypes
4
4
  import os
5
5
  import re
6
- import urllib.error
7
- import urllib.parse
8
- import urllib.request
9
6
  from pathlib import Path
10
7
  from typing import Any, Literal
11
8
 
9
+ import requests
10
+
12
11
  from . import utils
13
12
  from .env import env_truthy
14
13
  from .utils import changelog as _changelog
@@ -210,21 +209,6 @@ class GitHubClient:
210
209
  def _build_url(self, path: str, endpoint: GitHubEndpoint = "api") -> str:
211
210
  return f"{self._get_base_url(endpoint)}/repos/{self.repository}{path}"
212
211
 
213
- def _build_request(
214
- self,
215
- method: str,
216
- path: str,
217
- payload: bytes | None = None,
218
- endpoint: GitHubEndpoint = "api",
219
- content_type: str | None = None,
220
- ) -> urllib.request.Request:
221
- return urllib.request.Request(
222
- self._build_url(path, endpoint=endpoint),
223
- data=payload,
224
- method=method,
225
- headers=self._build_headers(content_type),
226
- )
227
-
228
212
  def _request(
229
213
  self,
230
214
  method: str,
@@ -244,25 +228,22 @@ class GitHubClient:
244
228
  )
245
229
  return None
246
230
 
247
- request = self._build_request(
248
- method,
249
- path,
250
- payload=payload,
251
- endpoint=endpoint,
252
- content_type=content_type,
231
+ response = requests.request(
232
+ method=method,
233
+ url=self._build_url(path, endpoint=endpoint),
234
+ data=payload,
235
+ headers=self._build_headers(content_type),
236
+ timeout=30,
253
237
  )
254
238
 
255
- try:
256
- with urllib.request.urlopen(request) as response:
257
- body = response.read().decode("utf-8").strip()
258
- except urllib.error.HTTPError:
259
- if allow_404:
260
- return None
261
- raise
239
+ if allow_404 and response.status_code == 404:
240
+ return None
241
+ response.raise_for_status()
262
242
 
243
+ body = response.text.strip()
263
244
  if not body:
264
245
  return None
265
- return json.loads(body)
246
+ return response.json()
266
247
 
267
248
  def api_request(
268
249
  self,
@@ -395,7 +376,7 @@ def publish_github_release(
395
376
  }
396
377
 
397
378
  client = GitHubClient(repository=repository, token=token)
398
- encoded_tag_name = urllib.parse.quote(tag_name, safe="")
379
+ encoded_tag_name = requests.utils.quote(tag_name, safe="")
399
380
  existing_release = client.api_request(
400
381
  "GET",
401
382
  f"/releases/tags/{encoded_tag_name}",
@@ -471,21 +452,19 @@ def get_github_release_asset_paths(
471
452
  Returns:
472
453
  A sorted list of existing asset paths.
473
454
  """
455
+ from .env import split_colon_delimited_values
456
+
474
457
  if asset_sources is not None:
475
458
  explicit_assets = True
476
459
  if isinstance(asset_sources, list):
477
460
  source_items = [item.strip() for item in asset_sources if item.strip()]
478
461
  else:
479
- source_items = [
480
- item.strip() for item in asset_sources.split(":") if item.strip()
481
- ]
462
+ source_items = split_colon_delimited_values(asset_sources)
482
463
  else:
483
464
  env_asset_sources = os.getenv("GITHUB_RELEASE_ASSETS")
484
465
  explicit_assets = bool(env_asset_sources)
485
466
  if env_asset_sources:
486
- source_items = [
487
- item.strip() for item in env_asset_sources.split(":") if item.strip()
488
- ]
467
+ source_items = split_colon_delimited_values(env_asset_sources)
489
468
  else:
490
469
  source_items = ["dist/*"]
491
470
 
@@ -563,7 +542,7 @@ def upload_github_release_asset(
563
542
  content_type = content_type or "application/octet-stream"
564
543
  return github_upload_request(
565
544
  "POST",
566
- f"/releases/{release_id}/assets?name={urllib.parse.quote(asset_path.name, safe='')}",
545
+ f"/releases/{release_id}/assets?name={requests.utils.quote(asset_path.name, safe='')}",
567
546
  payload=asset_path.read_bytes(),
568
547
  content_type=content_type,
569
548
  )
@@ -7,6 +7,9 @@ from importlib import metadata
7
7
  from pathlib import Path
8
8
  from typing import Any
9
9
 
10
+ from packaging.specifiers import SpecifierSet
11
+ from packaging.version import Version
12
+
10
13
  from . import utils
11
14
 
12
15
  LOGGER = logging.getLogger(__name__)
@@ -180,6 +183,32 @@ def read_pyproject_toml() -> dict[str, Any]:
180
183
  return tomllib.loads(Path("pyproject.toml").read_text())
181
184
 
182
185
 
186
+ BLACK_TARGET_PYTHON_CANDIDATES = [Version(f"3.{minor}") for minor in range(7, 17)]
187
+
188
+
189
+ def get_black_target_version() -> str | None:
190
+ """Return the Black target-version token based on `project.requires-python`.
191
+
192
+ Returns:
193
+ The lowest supported Python version in Black format, such as
194
+ `py311`, or `None` when the project metadata is missing or invalid.
195
+ """
196
+ requires_python = read_pyproject_toml().get("project", {}).get("requires-python")
197
+ if not isinstance(requires_python, str):
198
+ return None
199
+
200
+ try:
201
+ specifier_set = SpecifierSet(requires_python)
202
+ except Exception:
203
+ LOGGER.warning("Unable to parse requires-python specifier: %s", requires_python)
204
+ return None
205
+
206
+ for candidate in BLACK_TARGET_PYTHON_CANDIDATES:
207
+ if specifier_set.contains(candidate):
208
+ return f"py{candidate.major}{candidate.minor:02d}"
209
+ return None
210
+
211
+
183
212
  @lru_cache
184
213
  def has_debug_dependency_group() -> bool:
185
214
  """Return whether `pyproject.toml` declares a non-empty debug dependency group.