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.
- common_python_tasks/data/generic/.dockerignore +0 -2
- common_python_tasks/data/generic/Dockerfile.j2 +7 -3
- common_python_tasks/docker.py +98 -39
- common_python_tasks/docker_compose.py +8 -5
- common_python_tasks/env.py +53 -12
- common_python_tasks/git.py +24 -0
- common_python_tasks/github.py +19 -40
- common_python_tasks/project.py +29 -0
- common_python_tasks/tasks.py +172 -62
- common_python_tasks/utils.py +166 -13
- {common_python_tasks-0.3.1.dist-info → common_python_tasks-0.4.0.dist-info}/METADATA +92 -101
- {common_python_tasks-0.3.1.dist-info → common_python_tasks-0.4.0.dist-info}/RECORD +14 -14
- {common_python_tasks-0.3.1.dist-info → common_python_tasks-0.4.0.dist-info}/WHEEL +0 -0
- {common_python_tasks-0.3.1.dist-info → common_python_tasks-0.4.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
|
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
|
common_python_tasks/docker.py
CHANGED
|
@@ -152,35 +152,60 @@ def prune_images_keep(
|
|
|
152
152
|
|
|
153
153
|
retain_count = int(keep) + 1
|
|
154
154
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
193
|
+
|
|
194
|
+
tags_in_order: list[str] = []
|
|
195
|
+
seen_tags: set[str] = set()
|
|
178
196
|
for entry in lines:
|
|
179
|
-
|
|
197
|
+
try:
|
|
198
|
+
parsed_entry = utils.parse_image_reference(entry)
|
|
199
|
+
except ValueError:
|
|
180
200
|
continue
|
|
181
|
-
|
|
182
|
-
if
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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(
|
|
387
|
+
[Path(path) for path in split_colon_delimited_values(compose_files_env)],
|
|
386
388
|
[],
|
|
387
389
|
[],
|
|
388
390
|
)
|
|
389
391
|
|
|
390
|
-
compose_type =
|
|
392
|
+
compose_type = get_compose_type()
|
|
391
393
|
compose_addons_str = os.getenv("COMPOSE_ADDONS", "")
|
|
392
|
-
compose_addons =
|
|
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 =
|
|
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,
|
common_python_tasks/env.py
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
|
184
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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]:
|
common_python_tasks/git.py
CHANGED
|
@@ -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,
|
common_python_tasks/github.py
CHANGED
|
@@ -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
|
-
|
|
248
|
-
method,
|
|
249
|
-
path,
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
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 =
|
|
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={
|
|
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
|
)
|
common_python_tasks/project.py
CHANGED
|
@@ -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.
|