common-python-tasks 0.0.3__py3-none-any.whl → 0.2.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/__init__.py +14 -3
- common_python_tasks/__main__.py +79 -6
- common_python_tasks/git.py +61 -22
- common_python_tasks/github.py +58 -1
- common_python_tasks/project.py +3 -3
- common_python_tasks/tasks.py +318 -128
- common_python_tasks/utils.py +43 -9
- {common_python_tasks-0.0.3.dist-info → common_python_tasks-0.2.0.dist-info}/METADATA +57 -44
- {common_python_tasks-0.0.3.dist-info → common_python_tasks-0.2.0.dist-info}/RECORD +11 -11
- {common_python_tasks-0.0.3.dist-info → common_python_tasks-0.2.0.dist-info}/WHEEL +1 -1
- {common_python_tasks-0.0.3.dist-info → common_python_tasks-0.2.0.dist-info}/licenses/LICENSE +0 -0
common_python_tasks/__init__.py
CHANGED
|
@@ -9,8 +9,19 @@ __all__ = ["TaskCollection"]
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
def tasks(
|
|
12
|
-
include_tags: "Sequence[str]
|
|
12
|
+
include_tags: "Sequence[str] | None" = None,
|
|
13
|
+
exclude_tags: "Sequence[str]" = tuple(),
|
|
13
14
|
) -> dict:
|
|
14
|
-
from .tasks import tasks
|
|
15
|
+
from .tasks import tasks as task_collection
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
if include_tags is None and not exclude_tags:
|
|
18
|
+
include_tags = ("common",)
|
|
19
|
+
elif include_tags is None:
|
|
20
|
+
include_tags = tuple()
|
|
21
|
+
|
|
22
|
+
result = task_collection(include_tags=include_tags, exclude_tags=exclude_tags)
|
|
23
|
+
globals()["tasks"] = _TASKS_ENTRYPOINT
|
|
24
|
+
return result
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_TASKS_ENTRYPOINT = tasks
|
common_python_tasks/__main__.py
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import inspect
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
1
5
|
import sys
|
|
2
6
|
|
|
3
|
-
|
|
7
|
+
from .tasks import tasks
|
|
8
|
+
|
|
9
|
+
__all__ = ["get_available_tasks", "print_available_tasks"]
|
|
4
10
|
|
|
5
11
|
|
|
6
12
|
def get_available_tasks(internal: bool = False) -> list[str]:
|
|
@@ -12,8 +18,6 @@ def get_available_tasks(internal: bool = False) -> list[str]:
|
|
|
12
18
|
Returns:
|
|
13
19
|
A list of task names.
|
|
14
20
|
"""
|
|
15
|
-
from .tasks import tasks
|
|
16
|
-
|
|
17
21
|
return [
|
|
18
22
|
task_name
|
|
19
23
|
for task_name in tasks()["tasks"]
|
|
@@ -21,6 +25,76 @@ def get_available_tasks(internal: bool = False) -> list[str]:
|
|
|
21
25
|
]
|
|
22
26
|
|
|
23
27
|
|
|
28
|
+
def _extract_docstring_excerpt(doc: str) -> str:
|
|
29
|
+
excerpt = []
|
|
30
|
+
for line in doc.strip().splitlines():
|
|
31
|
+
if re.match(r"^(Args?|Arguments?|Parameters?):\s*$", line):
|
|
32
|
+
break
|
|
33
|
+
excerpt.append(line)
|
|
34
|
+
|
|
35
|
+
while excerpt and excerpt[-1].strip() == "":
|
|
36
|
+
excerpt.pop()
|
|
37
|
+
|
|
38
|
+
return "\n".join(excerpt)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _get_task_docstring(task_name: str) -> str | None:
|
|
42
|
+
|
|
43
|
+
task_config = tasks()["tasks"].get(task_name)
|
|
44
|
+
if not isinstance(task_config, dict):
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
script = task_config.get("script")
|
|
48
|
+
if not isinstance(script, str) or ":" not in script:
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
module_name, func_name = script.split(":", 1)
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
module = importlib.import_module(module_name)
|
|
55
|
+
func = getattr(module, func_name)
|
|
56
|
+
except (ImportError, AttributeError):
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
doc = inspect.getdoc(func)
|
|
60
|
+
if not doc:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
return _extract_docstring_excerpt(doc)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _get_task_tags(task_name: str) -> list[str] | None:
|
|
67
|
+
task_variants = getattr(tasks, "_tasks", {}).get(task_name)
|
|
68
|
+
if not task_variants:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
tags = {
|
|
72
|
+
tag
|
|
73
|
+
for variant in task_variants
|
|
74
|
+
for tag in getattr(variant, "tags", ())
|
|
75
|
+
if isinstance(tag, str) and not tag.startswith("task-")
|
|
76
|
+
}
|
|
77
|
+
return sorted(tags) or None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def print_available_tasks(internal: bool = False, include_docs: bool = False) -> None:
|
|
81
|
+
"""Print available tasks.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
internal: Whether or not to include internal task names starting with '_'.
|
|
85
|
+
include_docs: Whether or not to include task docstrings.
|
|
86
|
+
"""
|
|
87
|
+
print("\nAvailable tasks in this release:\n")
|
|
88
|
+
for task_name in get_available_tasks(internal=internal):
|
|
89
|
+
print(f" - {task_name}")
|
|
90
|
+
if include_docs:
|
|
91
|
+
doc = _get_task_docstring(task_name)
|
|
92
|
+
if doc:
|
|
93
|
+
for line in doc.splitlines():
|
|
94
|
+
print(f" {line}")
|
|
95
|
+
print()
|
|
96
|
+
|
|
97
|
+
|
|
24
98
|
if __name__ == "__main__":
|
|
25
99
|
print(
|
|
26
100
|
"common_python_tasks is not intended to be run as a standalone script. Invoke a task via poethepoet.",
|
|
@@ -28,8 +102,7 @@ if __name__ == "__main__":
|
|
|
28
102
|
)
|
|
29
103
|
|
|
30
104
|
if len(sys.argv) == 1:
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
print(f" - {task_name}")
|
|
105
|
+
debug = os.getenv("COMMON_PYTHON_TASKS_LOG_LEVEL", "info").lower() == "debug"
|
|
106
|
+
print_available_tasks(include_docs=debug)
|
|
34
107
|
else:
|
|
35
108
|
sys.exit(1)
|
common_python_tasks/git.py
CHANGED
|
@@ -1,39 +1,44 @@
|
|
|
1
1
|
import re
|
|
2
2
|
import shutil
|
|
3
3
|
from pathlib import Path
|
|
4
|
-
from typing import Any
|
|
4
|
+
from typing import Any, NamedTuple
|
|
5
5
|
|
|
6
6
|
from dunamai import Style, Version
|
|
7
7
|
|
|
8
8
|
from . import project, utils
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
component
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
class NextReleaseVersion(NamedTuple):
|
|
12
|
+
"""The computed next release version and normalized component."""
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
component: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def compute_next_release_version(
|
|
19
|
+
component: str, stage: str | None
|
|
20
|
+
) -> NextReleaseVersion:
|
|
21
|
+
"""Compute the next release version based on the given component and stage.
|
|
17
22
|
|
|
18
23
|
Args:
|
|
19
|
-
component: The component to bump (major, minor, patch, or auto).
|
|
20
|
-
stage:
|
|
21
|
-
dry_run: Whether this is a dry run.
|
|
24
|
+
component: The version component to bump (major, minor, patch, or auto).
|
|
25
|
+
stage: Optional pre-release stage (alpha, beta, rc, or None).
|
|
22
26
|
|
|
23
27
|
Returns:
|
|
24
|
-
A
|
|
28
|
+
A `NextReleaseVersion` with the serialized new version and normalized component.
|
|
25
29
|
"""
|
|
26
30
|
component = component.lower()
|
|
27
31
|
normalized_stage = stage.lower() if stage is not None else None
|
|
28
32
|
|
|
33
|
+
current_version_text = project.get_project_version_from_poetry()
|
|
29
34
|
if component == "auto":
|
|
30
35
|
component = infer_bump_component_from_git_cliff()
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
if re.match(r"^0\.", str(Version.parse(current_version_text).base)):
|
|
37
|
+
utils.LOGGER.info(
|
|
38
|
+
"Current version %s is pre-production; using patch bump.",
|
|
39
|
+
current_version_text,
|
|
40
|
+
)
|
|
41
|
+
component = "patch"
|
|
37
42
|
|
|
38
43
|
valid_components = {"major": 0, "minor": 1, "patch": 2}
|
|
39
44
|
if component not in valid_components:
|
|
@@ -61,13 +66,47 @@ def build_release_hook_environment(
|
|
|
61
66
|
)
|
|
62
67
|
bumped.revision = 1
|
|
63
68
|
|
|
64
|
-
|
|
69
|
+
return NextReleaseVersion(version=bumped.serialize(), component=component)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_release_hook_environment(
|
|
73
|
+
component: str,
|
|
74
|
+
stage: str | None,
|
|
75
|
+
dry_run: bool,
|
|
76
|
+
) -> dict[str, str]:
|
|
77
|
+
"""Build the environment variables passed into release hook scripts.
|
|
78
|
+
|
|
79
|
+
The following variables are set and available to all hook scripts:
|
|
80
|
+
|
|
81
|
+
- `RELEASE_COMPONENT`: The version component being bumped.
|
|
82
|
+
- `RELEASE_STAGE`: The pre-release stage, or an empty string for a stable release.
|
|
83
|
+
- `RELEASE_VERSION`: The computed next version (e.g. `"1.2.3"`).
|
|
84
|
+
- `RELEASE_TAG`: The Git tag for the release (e.g. `"v1.2.3"`).
|
|
85
|
+
- `RELEASE_DRY_RUN`: `"1"` if this is a dry run, `"0"` otherwise.
|
|
86
|
+
- `RELEASE_SCRIPT_DRY_RUN`: `"1"` if this is a dry run, `"0"` otherwise.
|
|
87
|
+
Hook scripts should check this variable and, when set to `"1"`, print
|
|
88
|
+
what they would do without making any changes, then exit successfully.
|
|
89
|
+
This allows the release dry-run to show a complete preview of all
|
|
90
|
+
side effects, including those performed by hook scripts.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
component: The component to bump (major, minor, patch, or auto).
|
|
94
|
+
stage: The release stage (alpha, beta, rc, or None).
|
|
95
|
+
dry_run: Whether this is a dry run.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
A dictionary of environment variables for the release hook.
|
|
99
|
+
"""
|
|
100
|
+
normalized_stage = stage.lower() if stage is not None else None
|
|
101
|
+
result = compute_next_release_version(component, stage)
|
|
102
|
+
dry_run_flag = "1" if dry_run else "0"
|
|
65
103
|
return {
|
|
66
|
-
"RELEASE_COMPONENT": component,
|
|
104
|
+
"RELEASE_COMPONENT": result.component,
|
|
67
105
|
"RELEASE_STAGE": normalized_stage or "",
|
|
68
|
-
"RELEASE_VERSION":
|
|
69
|
-
"RELEASE_TAG": f"v{
|
|
70
|
-
"RELEASE_DRY_RUN":
|
|
106
|
+
"RELEASE_VERSION": result.version,
|
|
107
|
+
"RELEASE_TAG": f"v{result.version}",
|
|
108
|
+
"RELEASE_DRY_RUN": dry_run_flag,
|
|
109
|
+
"RELEASE_SCRIPT_DRY_RUN": dry_run_flag,
|
|
71
110
|
}
|
|
72
111
|
|
|
73
112
|
|
common_python_tasks/github.py
CHANGED
|
@@ -17,6 +17,26 @@ from .env import env_truthy
|
|
|
17
17
|
LOGGER = logging.getLogger(__name__)
|
|
18
18
|
|
|
19
19
|
|
|
20
|
+
def _is_unreleased_placeholder(notes: str) -> bool:
|
|
21
|
+
"""Return whether release notes are only an unreleased placeholder."""
|
|
22
|
+
normalized_lines = [line.strip() for line in notes.splitlines() if line.strip()]
|
|
23
|
+
if not normalized_lines:
|
|
24
|
+
return True
|
|
25
|
+
|
|
26
|
+
normalized = "\n".join(normalized_lines).lower()
|
|
27
|
+
return normalized in {"[unreleased]", "## [unreleased]", "## unreleased"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _latest_tagged_changelog() -> str | None:
|
|
31
|
+
"""Return release notes for the latest tagged release from git-cliff."""
|
|
32
|
+
result = utils.run_git_cliff(["--latest"], capture_output=True)
|
|
33
|
+
changelog = result.stdout.strip()
|
|
34
|
+
if not changelog:
|
|
35
|
+
LOGGER.warning("git-cliff produced no release notes for the latest tag")
|
|
36
|
+
return None
|
|
37
|
+
return changelog
|
|
38
|
+
|
|
39
|
+
|
|
20
40
|
def should_publish_github_release() -> bool:
|
|
21
41
|
"""Return whether GitHub Releases publication is enabled.
|
|
22
42
|
|
|
@@ -69,7 +89,39 @@ def get_github_token() -> str | None:
|
|
|
69
89
|
Returns:
|
|
70
90
|
The GitHub token from `GITHUB_TOKEN` or `GH_TOKEN`, or `None` if unset.
|
|
71
91
|
"""
|
|
72
|
-
|
|
92
|
+
token = (os.getenv("GITHUB_TOKEN") or os.getenv("GH_TOKEN") or "").strip()
|
|
93
|
+
if token:
|
|
94
|
+
return token
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
result = utils.run_command(
|
|
98
|
+
["gh", "auth", "token"],
|
|
99
|
+
capture_output=True,
|
|
100
|
+
acceptable_returncodes={0, 1},
|
|
101
|
+
)
|
|
102
|
+
except FileNotFoundError:
|
|
103
|
+
LOGGER.warning(
|
|
104
|
+
"GitHub CLI not installed; cannot retrieve token via `gh auth token`"
|
|
105
|
+
)
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
if result is None:
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
if result.returncode != 0:
|
|
112
|
+
stderr = (result.stderr or "").strip()
|
|
113
|
+
message = "Failed to retrieve GitHub token via `gh auth token`"
|
|
114
|
+
if stderr:
|
|
115
|
+
message += f": {stderr}"
|
|
116
|
+
LOGGER.warning(message)
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
token = (result.stdout or "").strip()
|
|
120
|
+
if not token:
|
|
121
|
+
LOGGER.warning("`gh auth token` returned an empty token")
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
return token
|
|
73
125
|
|
|
74
126
|
|
|
75
127
|
def get_github_api_base_url() -> str:
|
|
@@ -308,6 +360,11 @@ def publish_github_release(
|
|
|
308
360
|
|
|
309
361
|
release_name = release_name or tag_name
|
|
310
362
|
release_body = body if body is not None else _changelog()
|
|
363
|
+
if release_body is not None and _is_unreleased_placeholder(release_body):
|
|
364
|
+
LOGGER.info(
|
|
365
|
+
"git-cliff returned an unreleased placeholder; using latest tagged release notes instead"
|
|
366
|
+
)
|
|
367
|
+
release_body = _latest_tagged_changelog()
|
|
311
368
|
if release_body is None:
|
|
312
369
|
release_body = f"Release {tag_name}"
|
|
313
370
|
|
common_python_tasks/project.py
CHANGED
|
@@ -8,17 +8,17 @@ from typing import Any
|
|
|
8
8
|
from . import utils
|
|
9
9
|
|
|
10
10
|
|
|
11
|
+
@lru_cache
|
|
11
12
|
def get_authors() -> list[tuple[str, str]]:
|
|
12
13
|
"""Return a list of authors from the pyproject.toml file.
|
|
13
14
|
|
|
14
15
|
Returns:
|
|
15
16
|
A list of tuples containing the name and email of each author.
|
|
16
17
|
"""
|
|
18
|
+
authors = read_pyproject_toml().get("project", {}).get("authors", [])
|
|
17
19
|
return [
|
|
18
20
|
((a.get("name") or "").strip(), (a.get("email") or "").strip().strip("<>"))
|
|
19
|
-
for a in
|
|
20
|
-
.get("project", {})
|
|
21
|
-
.get("authors", [])
|
|
21
|
+
for a in authors
|
|
22
22
|
]
|
|
23
23
|
|
|
24
24
|
|