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.
@@ -9,8 +9,19 @@ __all__ = ["TaskCollection"]
9
9
 
10
10
 
11
11
  def tasks(
12
- include_tags: "Sequence[str]" = tuple(), exclude_tags: "Sequence[str]" = tuple()
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
- return tasks(include_tags=include_tags, exclude_tags=exclude_tags)
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
@@ -1,6 +1,12 @@
1
+ import importlib
2
+ import inspect
3
+ import os
4
+ import re
1
5
  import sys
2
6
 
3
- __all__ = ["get_available_tasks"]
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
- print("\nAvailable tasks in this release:\n")
32
- for task_name in get_available_tasks():
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)
@@ -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
- def build_release_hook_environment(
12
- component: str,
13
- stage: str | None,
14
- dry_run: bool,
15
- ) -> dict[str, str]:
16
- """Build the environment variables passed into release hook scripts.
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: The release stage (alpha, beta, rc, or None).
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 dictionary of environment variables for the release hook.
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
- current_version_text = project.get_project_version_from_poetry()
33
- current_version = Version.parse(current_version_text)
34
-
35
- if component == "auto" and re.match(r"^0\.", str(current_version.base)):
36
- component = "patch"
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
- release_version = bumped.serialize()
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": release_version,
69
- "RELEASE_TAG": f"v{release_version}",
70
- "RELEASE_DRY_RUN": "1" if dry_run else "0",
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
 
@@ -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
- return (os.getenv("GITHUB_TOKEN") or os.getenv("GH_TOKEN") or "").strip() or None
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
 
@@ -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 tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
20
- .get("project", {})
21
- .get("authors", [])
21
+ for a in authors
22
22
  ]
23
23
 
24
24