common-python-tasks 0.4.0__py3-none-any.whl → 0.5.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.
@@ -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:
@@ -7,24 +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 PACKAGE_VERSION
17
20
 
18
21
  # Bypass VCS-based version detection: use the host-computed version directly.
19
22
  # This avoids requiring a .git directory in the build context and prevents
20
23
  # false dirty-version detection from a partial working tree.
21
24
  ENV POETRY_DYNAMIC_VERSIONING_BYPASS=${PACKAGE_VERSION}
22
25
 
23
- # Install Poetry and required Poetry plugins for build
24
- RUN --mount=type=cache,target=/root/.cache/pip,id=pip-cache{{ CACHE_ID_SUFFIX }} pip install --root-user-action=ignore \
25
- "poetry==$POETRY_VERSION" \
26
- "poetry-dynamic-versioning[plugin]==$POETRY_DYNAMIC_VERSIONING_PLUGIN_VERSION" \
27
- "poetry-plugin-export==$POETRY_PLUGIN_EXPORT_VERSION"
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 %}
28
52
 
29
53
  # Build package
30
54
  WORKDIR /tmp/build
@@ -32,15 +56,24 @@ COPY . /tmp/build/
32
56
 
33
57
  {% if HAS_DEBUG_DEPS %}
34
58
  # Export debug requirements when the project defines a debug dependency group
59
+ {% if package_manager == 'poetry' %}
35
60
  RUN --mount=type=cache,target=/root/.cache/pip,id=pip-cache{{ CACHE_ID_SUFFIX }} \
36
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 %}
37
66
  {% endif %}
38
67
 
39
- # 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.
40
69
  # If running this in a CI system, you must be using a persistent builder to take
41
70
  # advantage of the cache mount, and you should configure your CI to persist the
42
71
  # cache directory between builds
72
+ {% if package_manager == 'poetry' %}
43
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 %}
44
77
 
45
78
  FROM python:${PYTHON_VERSION}-${PYTHON_VARIANT} AS runtime
46
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],
@@ -331,12 +330,12 @@ def build_deps_image(
331
330
 
332
331
  full_tag = f"{deps_image_name}:{deps_tag}"
333
332
  build_cmd = _build_docker_build_command(
334
- context_path=context_path,
335
- dockerfile_path=dockerfile_path,
336
- build_args=build_args,
337
- archs=archs,
338
- no_cache=no_cache,
339
- plain=plain,
333
+ context_path,
334
+ dockerfile_path,
335
+ build_args,
336
+ archs,
337
+ no_cache,
338
+ plain,
340
339
  tags=[full_tag],
341
340
  )
342
341
 
@@ -451,24 +450,37 @@ def build_image(
451
450
  commit_tag = version_tag
452
451
 
453
452
  python_version = platform.python_version()
454
- poetry_version = project.get_poetry_version()
453
+ package_manager = project.get_package_manager()
454
+ package_manager_version = project.get_package_manager_version(package_manager)
455
455
 
456
- build_args = _merge_build_args(
457
- {
458
- "PYTHON_VERSION": python_version,
459
- "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,
460
460
  "POETRY_DYNAMIC_VERSIONING_PLUGIN_VERSION": project.get_installed_requirement_version(
461
461
  "poetry-dynamic-versioning[plugin]"
462
462
  ),
463
463
  "POETRY_PLUGIN_EXPORT_VERSION": project.get_installed_requirement_version(
464
464
  "poetry-plugin-export"
465
465
  ),
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,
466
477
  "PACKAGE_VERSION": package_version,
467
478
  "PACKAGE_NAME": utils.get_package_name(use_underscores=True),
468
479
  "AUTHORS": ",".join(
469
480
  [f"{name} <{email}>" for name, email in project.get_authors()]
470
481
  ),
471
482
  "GIT_COMMIT": commit_tag,
483
+ **manager_build_args,
472
484
  },
473
485
  extra_build_args,
474
486
  )
@@ -503,12 +515,12 @@ def build_image(
503
515
  all_tags.append(resolved_tag)
504
516
 
505
517
  build_cmd = _build_docker_build_command(
506
- context_path=context_path,
507
- dockerfile_path=dockerfile_path,
508
- build_args=build_args,
509
- archs=archs,
510
- no_cache=no_cache,
511
- plain=plain,
518
+ context_path,
519
+ dockerfile_path,
520
+ build_args,
521
+ archs,
522
+ no_cache,
523
+ plain,
512
524
  target=None if omit_target else target,
513
525
  tags=all_tags,
514
526
  )
@@ -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", ""),
@@ -186,11 +186,12 @@ def split_delimited_values(
186
186
  separators: str = ":",
187
187
  allow_whitespace: bool = False,
188
188
  ) -> list[str]:
189
- """Split a delimited string while preserving quoted substrings.
189
+ """Split a delimited string while preserving quoted or escaped separators.
190
190
 
191
191
  Args:
192
192
  value: The string to split.
193
- separators: Explicit separator characters to use.
193
+ separators: Explicit separator characters to use. Prefix a separator with
194
+ a backslash to include it literally in a token.
194
195
  allow_whitespace: Treat whitespace as additional separators.
195
196
 
196
197
  Returns:
@@ -207,7 +208,7 @@ def split_delimited_values(
207
208
 
208
209
 
209
210
  def split_colon_delimited_values(value: str) -> list[str]:
210
- """Split a colon- or whitespace-delimited string while preserving quoted substrings."""
211
+ """Split colon-delimited values while preserving quoted or escaped colons."""
211
212
  return split_delimited_values(value, separators=":", allow_whitespace=True)
212
213
 
213
214
 
@@ -221,7 +222,7 @@ def parse_container_build_args(
221
222
  env_build_args: str | None,
222
223
  ) -> dict[str, str] | None:
223
224
  """Parse container build args from CLI or environment input."""
224
- if cli_build_args is not None:
225
+ if cli_build_args and any(token.strip() for token in cli_build_args):
225
226
  build_arg_tokens = [
226
227
  token.strip() for token in cli_build_args if token and token.strip()
227
228
  ]
@@ -233,10 +234,15 @@ def parse_container_build_args(
233
234
  parsed_build_args: dict[str, str] = {}
234
235
  for token in build_arg_tokens:
235
236
  if "=" not in token:
236
- LOGGER.warning("Ignoring invalid build-arg token: %s", token)
237
- continue
237
+ utils.fatal(
238
+ f"Invalid build-arg token {token!r}; expected KEY=VALUE. "
239
+ "Escape literal colons in values as \\: or quote the value."
240
+ )
238
241
  key, value = token.split("=", 1)
239
- parsed_build_args[key.strip()] = value.strip()
242
+ key = key.strip()
243
+ if not key:
244
+ utils.fatal(f"Invalid build-arg token {token!r}; the key must not be empty")
245
+ parsed_build_args[key] = value.strip()
240
246
 
241
247
  return parsed_build_args or None
242
248
 
@@ -254,13 +260,20 @@ def parse_container_env_tokens(value: str | list[str] | None) -> list[str]:
254
260
  return []
255
261
 
256
262
  if isinstance(value, list):
257
- return [token.strip() for token in value if token and token.strip()]
258
-
259
- if not value.strip():
263
+ tokens = [token.strip() for token in value if token and token.strip()]
264
+ elif not value.strip():
260
265
  return []
266
+ else:
267
+ tokens = split_colon_delimited_values(value)
261
268
 
262
- tokens = split_colon_delimited_values(value)
263
- return [token for token in tokens if token.strip()]
269
+ for token in tokens:
270
+ if "=" not in token or not token.split("=", 1)[0].strip():
271
+ utils.fatal(
272
+ f"Invalid container env token {token!r}; expected KEY=VALUE. "
273
+ "Escape literal colons in values as \\: or quote the value."
274
+ )
275
+
276
+ return tokens
264
277
 
265
278
 
266
279
  def _parse_container_envfile_paths(
@@ -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):
@@ -3,6 +3,8 @@ import logging
3
3
  import mimetypes
4
4
  import os
5
5
  import re
6
+ from collections.abc import Callable
7
+ from functools import lru_cache
6
8
  from pathlib import Path
7
9
  from typing import Any, Literal
8
10
 
@@ -39,7 +41,7 @@ def should_publish_github_release() -> bool:
39
41
  """Return whether GitHub Releases publication is enabled.
40
42
 
41
43
  Returns:
42
- True if GitHub Release publishing is enabled, False if it is skipped.
44
+ `True` if GitHub Release publishing is enabled, `False` if it is skipped.
43
45
  """
44
46
  return not env_truthy("SKIP_GITHUB_RELEASE")
45
47
 
@@ -51,13 +53,25 @@ def get_github_repository() -> str | None:
51
53
  The GitHub repository slug in the form `owner/repo`, or `None` if it
52
54
  cannot be determined.
53
55
  """
54
- repository = os.getenv("GITHUB_REPOSITORY", "").strip()
56
+ return _get_github_repository(
57
+ os.getenv("GITHUB_REPOSITORY", "").strip(),
58
+ Path.cwd(),
59
+ utils.run_command,
60
+ )
61
+
62
+
63
+ @lru_cache(maxsize=32)
64
+ def _get_github_repository(
65
+ repository: str,
66
+ _working_directory: Path,
67
+ run_command: Callable[..., Any],
68
+ ) -> str | None:
55
69
  if repository:
56
70
  if re.fullmatch(r"[^/]+/[^/]+", repository):
57
71
  return repository
58
72
  LOGGER.warning("Ignoring invalid GITHUB_REPOSITORY value: %s", repository)
59
73
 
60
- result = utils.run_command(
74
+ result = run_command(
61
75
  ["git", "remote", "get-url", "origin"],
62
76
  capture_output=True,
63
77
  acceptable_returncodes={0, 128},
@@ -94,8 +108,13 @@ def get_github_token() -> str | None:
94
108
  if token:
95
109
  return token
96
110
 
111
+ return _get_github_cli_token(utils.run_command)
112
+
113
+
114
+ @lru_cache(maxsize=8)
115
+ def _get_github_cli_token(run_command: Callable[..., Any]) -> str | None:
97
116
  try:
98
- result = utils.run_command(
117
+ result = run_command(
99
118
  ["gh", "auth", "token"],
100
119
  capture_output=True,
101
120
  acceptable_returncodes={0, 1},
@@ -132,13 +151,18 @@ def get_github_api_base_url() -> str:
132
151
  The base URL for GitHub API requests, defaulting to GitHub.com API or
133
152
  GitHub Enterprise API if configured.
134
153
  """
135
- api_url = os.getenv("GITHUB_API_URL", "").strip()
154
+ return _get_github_api_base_url(
155
+ os.getenv("GITHUB_API_URL", "").strip(),
156
+ os.getenv("GITHUB_SERVER_URL", "https://github.com").strip(),
157
+ )
158
+
159
+
160
+ @lru_cache(maxsize=32)
161
+ def _get_github_api_base_url(api_url: str, server_url: str) -> str:
136
162
  if api_url:
137
163
  return api_url.rstrip("/")
138
164
 
139
- server_url = (
140
- os.getenv("GITHUB_SERVER_URL", "https://github.com").strip().rstrip("/")
141
- )
165
+ server_url = server_url.rstrip("/")
142
166
  if server_url.endswith("github.com"):
143
167
  return "https://api.github.com"
144
168
  return f"{server_url}/api/v3"
@@ -156,7 +180,11 @@ def get_github_upload_api_base_url(api_base_url: str | None = None) -> str:
156
180
  Returns:
157
181
  The upload base URL for GitHub API requests.
158
182
  """
159
- api_base = api_base_url or get_github_api_base_url()
183
+ return _get_github_upload_api_base_url(api_base_url or get_github_api_base_url())
184
+
185
+
186
+ @lru_cache(maxsize=32)
187
+ def _get_github_upload_api_base_url(api_base: str) -> str:
160
188
  if api_base == "https://api.github.com":
161
189
  return "https://uploads.github.com"
162
190
  return api_base