release-creator 0.1.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.
@@ -0,0 +1 @@
1
+ """Tools for consistent project releases."""
@@ -0,0 +1,6 @@
1
+ """Run release-creator as a module."""
2
+
3
+ from release_creator.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
release_creator/cli.py ADDED
@@ -0,0 +1,38 @@
1
+ """Keep command registration separate from release work."""
2
+
3
+ import argparse
4
+
5
+ lazy from release_creator.fragments.cli import register as register_fragments
6
+ lazy from release_creator.notes.cli import register as register_notes
7
+ lazy from release_creator.tags.cli import register as register_tags
8
+
9
+
10
+ def main() -> None:
11
+ """Report command input errors through argparse's exit path."""
12
+ parser = build_parser()
13
+ args = parser.parse_args()
14
+ try:
15
+ args.handler(args)
16
+ except (OSError, ValueError) as error:
17
+ parser.error(str(error))
18
+
19
+
20
+ def build_parser() -> argparse.ArgumentParser:
21
+ """Expose the same parser to Sphinx and the executable.
22
+
23
+ Returns:
24
+ Parser shared by the documentation and command line.
25
+
26
+ """
27
+ parser = argparse.ArgumentParser(description="Release preparation tools")
28
+ commands = parser.add_subparsers(dest="command", required=True)
29
+ register_notes(commands)
30
+ register_fragments(commands)
31
+ register_tags(commands)
32
+ return parser
33
+
34
+
35
+ __all__ = [
36
+ "build_parser",
37
+ "main",
38
+ ]
@@ -0,0 +1 @@
1
+ """Named fragment handling for release preparation."""
@@ -0,0 +1,32 @@
1
+ """Keep fragment options beside PR link insertion."""
2
+
3
+ from pathlib import Path
4
+ lazy import argparse
5
+
6
+ lazy from release_creator.fragments.git import commit_subjects_from_git
7
+ lazy from release_creator.fragments.links import add_pull_request_links
8
+
9
+
10
+ def register(commands: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
11
+ """Keep fragment options with the fragment command."""
12
+ fragments = commands.add_parser("fragments", help="link named fragments to their squash pull requests")
13
+ fragments.add_argument("--directory", type=Path, default=Path("changelog.d"), help="Towncrier fragment directory")
14
+ fragments.add_argument("--repository", required=True, help="GitHub owner/repository")
15
+ source = fragments.add_mutually_exclusive_group(required=True)
16
+ source.add_argument("--commit-map", type=Path, help="TSV file of fragment names and squash subjects")
17
+ source.add_argument("--git", action="store_true", help="read each fragment's introducing squash commit from Git")
18
+ fragments.set_defaults(handler=_link)
19
+
20
+
21
+ def _link(args: argparse.Namespace) -> None:
22
+ commit_subjects = (
23
+ dict(line.split("\t", 1) for line in args.commit_map.read_text(encoding="utf-8").splitlines())
24
+ if args.commit_map is not None
25
+ else commit_subjects_from_git(args.directory)
26
+ )
27
+ add_pull_request_links(args.directory, args.repository, commit_subjects)
28
+
29
+
30
+ __all__ = [
31
+ "register",
32
+ ]
@@ -0,0 +1,39 @@
1
+ """Read introducing squash subjects from Git history."""
2
+
3
+ import shutil
4
+ import subprocess # ruff: ignore[suspicious-subprocess-import] # Fragment provenance comes from the local Git checkout.
5
+ lazy from pathlib import Path
6
+
7
+
8
+ def commit_subjects_from_git(directory: Path) -> dict[str, str]:
9
+ """Read the commit subject that first added each named fragment.
10
+
11
+ Returns:
12
+ Fragment filenames mapped to their introducing subjects.
13
+
14
+ Raises:
15
+ FileNotFoundError: Git is not installed.
16
+ ValueError: Git cannot read the checkout history.
17
+
18
+ """
19
+ if (git := shutil.which("git")) is None:
20
+ msg = "Git is required to read fragment history"
21
+ raise FileNotFoundError(msg)
22
+ subjects: dict[str, str] = {}
23
+ for fragment in sorted(directory.glob("+*.*")):
24
+ result = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] # Fixed Git command; the fragment path is a separate argument.
25
+ [git, "log", "-1", "--format=%s", "--diff-filter=A", "HEAD", "--", str(fragment)],
26
+ capture_output=True,
27
+ check=False,
28
+ text=True,
29
+ )
30
+ if result.returncode:
31
+ msg = result.stderr.strip() or f"could not read Git history for {fragment}"
32
+ raise ValueError(msg)
33
+ subjects[fragment.name] = result.stdout.strip()
34
+ return subjects
35
+
36
+
37
+ __all__ = [
38
+ "commit_subjects_from_git",
39
+ ]
@@ -0,0 +1,75 @@
1
+ """Associate named Towncrier fragments with merged pull requests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ lazy from pathlib import Path
7
+
8
+
9
+ def add_pull_request_links(directory: Path, repository: str, commit_subjects: dict[str, str]) -> None:
10
+ """Add pull request links to named fragments using squash commit subjects.
11
+
12
+ Raises:
13
+ ValueError: A fragment has no unambiguous squash commit or already names a pull request.
14
+
15
+ """
16
+ if not re.fullmatch(
17
+ r"""
18
+ (?P<owner>[A-Za-z0-9_.-]+) # GitHub owner
19
+ / # Repository separator
20
+ (?P<name>[A-Za-z0-9_.-]+) # GitHub repository
21
+ """,
22
+ repository,
23
+ re.VERBOSE,
24
+ ):
25
+ msg = f"invalid repository: {repository}"
26
+ raise ValueError(msg)
27
+ for fragment in sorted(directory.glob("+*.*")):
28
+ subject = commit_subjects.get(fragment.name, "")
29
+ if (
30
+ match := re.search(
31
+ r"""
32
+ \(\# # Squash merge PR marker
33
+ (?P<number>\d+) # Pull request number
34
+ \)$ # Marker ends the subject
35
+ """,
36
+ subject,
37
+ re.VERBOSE,
38
+ )
39
+ ) is None:
40
+ msg = f"no squash pull request number for {fragment}: {subject}"
41
+ raise ValueError(msg)
42
+ content = fragment.read_text(encoding="utf-8").rstrip()
43
+ if re.search(
44
+ rf"""
45
+ https://github\.com/ # GitHub host
46
+ {re.escape(repository)} # This repository
47
+ /pull/ # Pull request route
48
+ (?P<number>\d+) # Existing pull request link
49
+ """,
50
+ content,
51
+ re.VERBOSE,
52
+ ):
53
+ msg = f"pull request already linked in {fragment}"
54
+ raise ValueError(msg)
55
+ link = f"([#{match['number']}](https://github.com/{repository}/pull/{match['number']}))"
56
+ content, count = re.subn(
57
+ r"""
58
+ (?P<before>,?\s+) # Optional comma and space before author credit
59
+ by
60
+ (?P<after>\s+) # Space after credit marker
61
+ (?=\[@|:user:) # Markdown or RST author link
62
+ """,
63
+ f" {link} by ",
64
+ content,
65
+ count=1,
66
+ flags=re.VERBOSE,
67
+ )
68
+ if not count:
69
+ content = f"{content.removesuffix('.')} {link}."
70
+ fragment.write_text(f"{content}\n", encoding="utf-8")
71
+
72
+
73
+ __all__ = [
74
+ "add_pull_request_links",
75
+ ]
@@ -0,0 +1,20 @@
1
+ {% set release = "release-" + versiondata.version | replace(".", "-") %}
2
+ <a name="{{ release }}"></a>
3
+
4
+ ## {{ versiondata.version }} ({{ versiondata.date }})
5
+
6
+ {% for section, _ in sections.items() %}
7
+ {% for category, _ in definitions.items() if category in sections[section] %}
8
+ {% if sections[section][category] %}
9
+ {% set name = definitions[category]["name"] %}
10
+ <a name="{{ release }}-{{ name | lower | replace(" ", "-") }}"></a>
11
+
12
+ ### {{ name }}
13
+
14
+ {% for text, _ in sections[section][category].items() %}
15
+ - {{ text }}
16
+ {% endfor %}
17
+
18
+ {% endif %}
19
+ {% endfor %}
20
+ {% endfor %}
@@ -0,0 +1 @@
1
+ """Release note preparation and rendering."""
@@ -0,0 +1,42 @@
1
+ """Keep note options beside their renderer."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ lazy import argparse
6
+
7
+ lazy from release_creator.notes.render import ReleaseContext, render_markdown, render_rst
8
+
9
+
10
+ def register(commands: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
11
+ """Keep note options with the note command."""
12
+ notes = commands.add_parser("notes", help="render notes from a tagged changelog")
13
+ notes.add_argument("--changelog", type=Path, required=True, help="tagged Markdown or reStructuredText changelog")
14
+ notes.add_argument("--tag", required=True, help="release tag to render")
15
+ notes.add_argument("--repository", required=True, help="GitHub owner/repository")
16
+ notes.add_argument("--previous", help="previous tag for the comparison link")
17
+ notes.add_argument("--artifact-url", help="HTTPS link to the published package or artifact")
18
+ notes.add_argument("--artifact-label", default="PyPI release", help="text for the artifact link")
19
+ notes.add_argument("--issue-role", choices=("issue", "pull"), default="issue", help="target of RST issue roles")
20
+ notes.add_argument("--generated-notes", type=Path, help="GitHub generated notes for New Contributors credits")
21
+ notes.set_defaults(handler=_render)
22
+
23
+
24
+ def _render(args: argparse.Namespace) -> None:
25
+ context = ReleaseContext(
26
+ tag=args.tag,
27
+ repository=args.repository,
28
+ previous=args.previous,
29
+ artifact_url=args.artifact_url,
30
+ artifact_label=args.artifact_label,
31
+ )
32
+ changelog = args.changelog.read_text(encoding="utf-8")
33
+ generated = args.generated_notes.read_text(encoding="utf-8") if args.generated_notes is not None else None
34
+ if args.changelog.suffix == ".rst":
35
+ sys.stdout.write(render_rst(changelog, context, issue_role=args.issue_role, generated_notes=generated))
36
+ else:
37
+ sys.stdout.write(render_markdown(changelog, context, generated_notes=generated))
38
+
39
+
40
+ __all__ = [
41
+ "register",
42
+ ]
@@ -0,0 +1,341 @@
1
+ """Render release notes from tagged changelogs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from typing import Final
8
+
9
+ lazy import mdformat
10
+
11
+ _CATEGORIES: Final = {
12
+ "Added": "Features",
13
+ "Changes": "Changes",
14
+ "Fixed": "Bug fixes",
15
+ "Documentation": "Documentation",
16
+ "Breaking changes": "Breaking changes",
17
+ "Security": "Security",
18
+ "Performance": "Performance",
19
+ "Deprecated": "Deprecations",
20
+ "Removed": "Removals",
21
+ "Packaging": "Packaging",
22
+ }
23
+
24
+
25
+ @dataclass(frozen=True, kw_only=True)
26
+ class ReleaseContext:
27
+ """Links and identity for one GitHub Release."""
28
+
29
+ tag: str
30
+ repository: str
31
+ previous: str | None = None
32
+ artifact_url: str | None = None
33
+ artifact_label: str = "PyPI release"
34
+
35
+
36
+ def render_markdown(changelog: str, context: ReleaseContext, generated_notes: str | None = None) -> str:
37
+ """Render one Markdown release section for GitHub without wrapped list items.
38
+
39
+ Returns:
40
+ The complete GitHub Release body.
41
+
42
+
43
+ """
44
+ _validate_context(context)
45
+ notes = mdformat.text(_extract_body(changelog, context.tag), options={"wrap": "no"}, extensions={"gfm"}).strip()
46
+ return _finish(notes, context, generated_notes)
47
+
48
+
49
+ def render_rst(
50
+ changelog: str, context: ReleaseContext, issue_role: str = "issue", generated_notes: str | None = None
51
+ ) -> str:
52
+ """Render one reStructuredText release section for GitHub.
53
+
54
+ Returns:
55
+ The complete GitHub Release body.
56
+
57
+ Raises:
58
+ ValueError: The issue role must name a GitHub issue or pull request.
59
+
60
+ """
61
+ _validate_context(context)
62
+ if issue_role not in {"issue", "pull"}:
63
+ msg = f"invalid issue role: {issue_role}"
64
+ raise ValueError(msg)
65
+ notes = mdformat.text(
66
+ _extract_rst_body(changelog, context, issue_role), options={"wrap": "no"}, extensions={"gfm"}
67
+ ).strip()
68
+ return _finish(notes, context, generated_notes)
69
+
70
+
71
+ def _finish(notes: str, context: ReleaseContext, generated_notes: str | None) -> str:
72
+ if generated_notes is not None and (contributors := _new_contributors(notes, generated_notes, context.tag)):
73
+ notes = f"{notes}\n\n{contributors}" if notes else contributors
74
+ if context.artifact_url is not None:
75
+ link = f"[{context.artifact_label}]({context.artifact_url})"
76
+ notes = f"{notes}\n\n{link}" if notes else link
77
+ comparison = (
78
+ f"compare/{context.previous}...{context.tag}" if context.previous is not None else f"commits/{context.tag}"
79
+ )
80
+ if notes:
81
+ notes += "\n\n"
82
+ return f"{notes}**Full Changelog**: https://github.com/{context.repository}/{comparison}\n"
83
+
84
+
85
+ def _new_contributors(notes: str, generated_notes: str, tag: str) -> str:
86
+ if (
87
+ section := re.search(
88
+ r"""
89
+ ^\#\#\ New\ Contributors\s*\n # Generated contributor heading
90
+ (?P<entries>.*?) # Lines until the next heading
91
+ (?=^\#\#\ |\Z) # Next section or end of body
92
+ """,
93
+ generated_notes,
94
+ re.MULTILINE | re.DOTALL | re.VERBOSE,
95
+ )
96
+ ) is None:
97
+ return ""
98
+ credited = {
99
+ match["handle"]
100
+ for match in re.finditer(
101
+ r"""
102
+ @(?P<handle>[A-Za-z0-9-]+) # Credited GitHub handle
103
+ """,
104
+ notes,
105
+ re.VERBOSE,
106
+ )
107
+ }
108
+ entries = [
109
+ f"- @{match['handle']} made their first contribution in #{match['number']}"
110
+ for line in section["entries"].splitlines()
111
+ if (
112
+ match := re.fullmatch(
113
+ r"""
114
+ [-*]\ @(?P<handle>[A-Za-z0-9-]+) # Generated contributor bullet
115
+ \ made\ their\ first\ contribution\ in\ \#
116
+ (?P<number>\d+) # First PR number
117
+ """,
118
+ line.strip(),
119
+ re.VERBOSE,
120
+ )
121
+ )
122
+ and match["handle"] in credited
123
+ ]
124
+ if not entries:
125
+ return ""
126
+ anchor = f"release-{tag.replace('.', '-')}-new-contributors"
127
+ return f'<a name="{anchor}"></a>\n\n### New Contributors\n\n' + "\n".join(entries)
128
+
129
+
130
+ def _extract_rst_body(changelog: str, context: ReleaseContext, issue_role: str) -> str:
131
+ lines = changelog.splitlines()
132
+ version = re.compile(
133
+ r"""
134
+ ^v? # Optional release prefix
135
+ (?P<tag>\d+\.\d+\.\d+) # Numeric version
136
+ \ \((?P<date>[^)]+)\)$ # Parenthesized release date
137
+ """,
138
+ re.VERBOSE,
139
+ )
140
+ headings = [
141
+ index
142
+ for index, line in enumerate(lines)
143
+ if (match := version.fullmatch(line.strip())) and match["tag"] == context.tag.removeprefix("v")
144
+ ]
145
+ if len(headings) != 1:
146
+ msg = f"expected one changelog section for {context.tag}, found {len(headings)}"
147
+ raise ValueError(msg)
148
+ end = next(
149
+ (index for index in range(headings[0] + 1, len(lines)) if version.fullmatch(lines[index].strip())),
150
+ len(lines),
151
+ )
152
+ sections: dict[str, list[str]] = {}
153
+ category: str | None = None
154
+ index = headings[0] + 1
155
+ while index < end:
156
+ line = lines[index].strip()
157
+ if index + 1 < end and re.fullmatch(
158
+ r"""
159
+ (?P<underline>[-=~^]{3,}) # RST heading underline
160
+ """,
161
+ lines[index + 1].strip(),
162
+ re.VERBOSE,
163
+ ):
164
+ category = _require_category(line)
165
+ sections.setdefault(category, [])
166
+ index += 2
167
+ continue
168
+ if line.startswith("- "):
169
+ if category is None:
170
+ msg = f"release item outside a category: {line}"
171
+ raise ValueError(msg)
172
+ sections[category].append(_convert_rst(line, context.repository, issue_role))
173
+ elif line and line[0] not in "*=~^" and not line.startswith(".. _"):
174
+ if category is None or not sections[category]:
175
+ msg = f"unexpected release text: {line}"
176
+ raise ValueError(msg)
177
+ sections[category][-1] += f" {_convert_rst(line, context.repository, issue_role)}"
178
+ index += 1
179
+ body: list[str] = []
180
+ for name, entries in sections.items():
181
+ if entries:
182
+ anchor = f"release-{context.tag.replace('.', '-')}-{name.lower().replace(' ', '-')}"
183
+ body.extend((f'<a name="{anchor}"></a>', "", f"### {_CATEGORIES[name]}", "", *entries, ""))
184
+ return "\n".join(body).strip()
185
+
186
+
187
+ def _require_category(name: str) -> str:
188
+ if name not in _CATEGORIES:
189
+ msg = f"unknown changelog category: {name}"
190
+ raise ValueError(msg)
191
+ return name
192
+
193
+
194
+ def _convert_rst(line: str, repository: str, issue_role: str) -> str:
195
+ issue_target = "pull" if issue_role == "pull" else "issues"
196
+ line = re.sub(
197
+ r"""
198
+ :(?:pr|pull):` # Pull request role
199
+ (?P<number>\d+) # Pull request number
200
+ ` # Role terminator
201
+ """,
202
+ lambda match: f"[#{match['number']}](https://github.com/{repository}/pull/{match['number']})",
203
+ line,
204
+ flags=re.VERBOSE,
205
+ )
206
+ line = re.sub(
207
+ r"""
208
+ :issue:` # Issue role
209
+ (?P<number>\d+) # Issue number
210
+ ` # Role terminator
211
+ """,
212
+ lambda match: f"[#{match['number']}](https://github.com/{repository}/{issue_target}/{match['number']})",
213
+ line,
214
+ flags=re.VERBOSE,
215
+ )
216
+ line = re.sub(
217
+ r"""
218
+ :user:` # GitHub user role
219
+ (?P<handle>[A-Za-z0-9-]+) # GitHub handle
220
+ ` # Role terminator
221
+ """,
222
+ lambda match: f"@{match['handle']}",
223
+ line,
224
+ flags=re.VERBOSE,
225
+ )
226
+ line = re.sub(
227
+ r"""
228
+ `@(?P<handle>[A-Za-z0-9-]+) # Link text and GitHub handle
229
+ \ <https://github\.com/(?P=handle)>`_ # Matching profile URL
230
+ """,
231
+ lambda match: f"@{match['handle']}",
232
+ line,
233
+ flags=re.VERBOSE,
234
+ )
235
+ line = re.sub(
236
+ r"""
237
+ ``(?P<code>[^`]+)`` # RST inline code
238
+ """,
239
+ r"`\g<code>`",
240
+ line,
241
+ flags=re.VERBOSE,
242
+ )
243
+ line = re.sub(
244
+ r"""
245
+ :(?:[a-z]+:)?[a-z]+:` # RST role with optional domain
246
+ ~?(?P<name>[^`]+)` # Referenced name, with optional short display
247
+ """,
248
+ lambda match: f"`{match['name'].split('.')[-1]}`",
249
+ line,
250
+ flags=re.VERBOSE,
251
+ )
252
+ return line.replace(" - by ", " by ")
253
+
254
+
255
+ def _validate_context(context: ReleaseContext) -> None:
256
+ if not re.fullmatch(
257
+ r"""
258
+ (?P<tag>[A-Za-z0-9][A-Za-z0-9_.-]*) # URL-safe release tag
259
+ """,
260
+ context.tag,
261
+ re.VERBOSE,
262
+ ):
263
+ msg = f"invalid release tag: {context.tag}"
264
+ raise ValueError(msg)
265
+ if context.previous is not None and not re.fullmatch(
266
+ r"""
267
+ (?P<tag>[A-Za-z0-9][A-Za-z0-9_.-]*) # URL-safe previous tag
268
+ """,
269
+ context.previous,
270
+ re.VERBOSE,
271
+ ):
272
+ msg = f"invalid previous tag: {context.previous}"
273
+ raise ValueError(msg)
274
+ if not re.fullmatch(
275
+ r"""
276
+ (?P<owner>[A-Za-z0-9_.-]+) # GitHub owner
277
+ / # Repository separator
278
+ (?P<name>[A-Za-z0-9_.-]+) # GitHub repository
279
+ """,
280
+ context.repository,
281
+ re.VERBOSE,
282
+ ):
283
+ msg = f"invalid repository: {context.repository}"
284
+ raise ValueError(msg)
285
+ if context.artifact_url is not None and not context.artifact_url.startswith("https://"):
286
+ msg = "artifact URL must use HTTPS"
287
+ raise ValueError(msg)
288
+
289
+
290
+ def _extract_body(changelog: str, tag: str) -> str:
291
+ version = re.compile(
292
+ r"""
293
+ ^\#\#\ # Markdown version heading
294
+ (?P<tag>[^\s(]+) # Release tag
295
+ (?:\ \([^)]+\))? # Optional release date
296
+ $ # End of heading
297
+ """,
298
+ re.VERBOSE,
299
+ )
300
+ lines = changelog.splitlines()
301
+ headings = [index for index, line in enumerate(lines) if (match := version.fullmatch(line)) and match["tag"] == tag]
302
+ if len(headings) != 1:
303
+ msg = f"expected one changelog section for {tag}, found {len(headings)}"
304
+ raise ValueError(msg)
305
+
306
+ body: list[str] = []
307
+ version_anchor = re.compile(
308
+ r"""
309
+ ^<a\ name="release- # Version anchor prefix
310
+ (?P<version>v?\d+(?:-\d+){2}) # Dotted version encoded for an anchor
311
+ "></a>$ # Anchor terminator
312
+ """,
313
+ re.VERBOSE,
314
+ )
315
+ author = re.compile(
316
+ r"""
317
+ \[@(?P<handle>[A-Za-z0-9-]+)\] # GitHub handle in link text
318
+ \(https://github\.com/(?P=handle)\) # Matching profile URL
319
+ """,
320
+ re.VERBOSE,
321
+ )
322
+ for source_line in lines[headings[0] + 1 :]:
323
+ if version.fullmatch(source_line) or version_anchor.fullmatch(source_line):
324
+ break
325
+ line = source_line
326
+ if source_line.startswith("### "):
327
+ category = source_line[4:]
328
+ if category not in _CATEGORIES:
329
+ msg = f"unknown changelog category: {category}"
330
+ raise ValueError(msg)
331
+ line = f"### {_CATEGORIES[category]}"
332
+ body.append(author.sub(lambda match: f"@{match['handle']}", line))
333
+
334
+ return "\n".join(body).strip()
335
+
336
+
337
+ __all__ = [
338
+ "ReleaseContext",
339
+ "render_markdown",
340
+ "render_rst",
341
+ ]
File without changes
@@ -0,0 +1 @@
1
+ """Select numeric release tags."""
@@ -0,0 +1,57 @@
1
+ """Keep tag selection options beside the command."""
2
+
3
+ import sys
4
+ lazy import argparse
5
+ lazy from datetime import UTC, datetime
6
+ lazy from pathlib import Path
7
+
8
+ lazy from release_creator.tags.select import next_tag, parse_tag, previous_tag, require_newer_tag
9
+
10
+
11
+ def register(commands: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
12
+ """Register numeric tag validation and predecessor selection."""
13
+ tags = commands.add_parser("tags", help="calculate, validate, or compare numeric release tags")
14
+ tags.add_argument("tag", nargs="?", help="major.minor.patch release tag")
15
+ choice = tags.add_mutually_exclusive_group()
16
+ choice.add_argument(
17
+ "--next",
18
+ choices=("auto", "major", "minor", "patch", "calver"),
19
+ help="calculate a version from pending fragments",
20
+ )
21
+ choice.add_argument("--previous", action="store_true", help="print the preceding tag from standard input")
22
+ choice.add_argument("--require-newer", action="store_true", help="require a tag newer than those on standard input")
23
+ tags.add_argument(
24
+ "--directory", type=Path, default=Path("changelog.d"), help="pending fragment directory for --next"
25
+ )
26
+ tags.set_defaults(handler=_select)
27
+
28
+
29
+ def _select(args: argparse.Namespace) -> None:
30
+ if args.next is not None:
31
+ if args.tag is not None:
32
+ msg = "omit the tag when calculating the next version"
33
+ raise ValueError(msg)
34
+ version = next_tag(
35
+ args.next,
36
+ sys.stdin.read().splitlines(),
37
+ [path.name for path in args.directory.glob("*.*.md")],
38
+ datetime.now(UTC).date(),
39
+ )
40
+ sys.stdout.write(f"{version}\n")
41
+ return
42
+ if args.tag is None:
43
+ msg = "provide a tag or --next"
44
+ raise ValueError(msg)
45
+ parse_tag(args.tag)
46
+ if not args.previous and not args.require_newer:
47
+ return
48
+ tags = sys.stdin.read().splitlines()
49
+ if args.require_newer:
50
+ require_newer_tag(args.tag, tags)
51
+ elif (previous := previous_tag(args.tag, tags)) is not None:
52
+ sys.stdout.write(f"{previous}\n")
53
+
54
+
55
+ __all__ = [
56
+ "register",
57
+ ]
@@ -0,0 +1,119 @@
1
+ """Validate numeric tags and find their predecessors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from datetime import date
9
+
10
+
11
+ def next_tag(bump: str, tags: list[str], fragments: list[str], release_date: date) -> str:
12
+ """Choose a release version from existing tags and pending fragments.
13
+
14
+ Returns:
15
+ The next major.minor.patch or year.month.patch version.
16
+
17
+ Raises:
18
+ ValueError: The bump is unknown, no fragments exist, or the result is not newer.
19
+
20
+ """
21
+ if not fragments:
22
+ msg = "no changelog fragments to release"
23
+ raise ValueError(msg)
24
+ versions: list[tuple[int, int, int]] = []
25
+ for tag in tags:
26
+ try:
27
+ versions.append(parse_tag(tag))
28
+ except ValueError:
29
+ continue
30
+ current = max(versions, default=(0, 0, 0))
31
+ if bump == "auto":
32
+ bump = (
33
+ "major"
34
+ if any(fragment.endswith(".breaking.md") for fragment in fragments)
35
+ else "minor"
36
+ if any(fragment.endswith(".feature.md") for fragment in fragments)
37
+ else "patch"
38
+ )
39
+ match bump:
40
+ case "major":
41
+ version = (current[0] + 1, 0, 0)
42
+ case "minor":
43
+ version = (current[0], current[1] + 1, 0)
44
+ case "patch":
45
+ version = (current[0], current[1], current[2] + 1)
46
+ case "calver":
47
+ year_month = release_date.year, release_date.month
48
+ version = (*year_month, current[2] + 1 if current[:2] == year_month else 0)
49
+ case _:
50
+ msg = f"unknown version bump: {bump}"
51
+ raise ValueError(msg)
52
+ if version <= current:
53
+ msg = f"calculated version must be newer than {'.'.join(map(str, current))}"
54
+ raise ValueError(msg)
55
+ return ".".join(map(str, version))
56
+
57
+
58
+ def parse_tag(tag: str) -> tuple[int, int, int]:
59
+ """Parse a major.minor.patch release tag.
60
+
61
+ Returns:
62
+ The numeric major, minor, and patch components.
63
+
64
+ Raises:
65
+ ValueError: The tag is not a numeric release version.
66
+
67
+ """
68
+ match tag.split("."):
69
+ case [major, minor, patch] if all(part.isascii() and part.isdecimal() for part in (major, minor, patch)):
70
+ return int(major), int(minor), int(patch)
71
+ case _:
72
+ msg = f"expected a major.minor.patch version: {tag}"
73
+ raise ValueError(msg)
74
+
75
+
76
+ def previous_tag(tag: str, tags: list[str]) -> str | None:
77
+ """Select the greatest numeric release tag before ``tag``.
78
+
79
+ Returns:
80
+ The preceding tag, or ``None`` when this is the first release.
81
+
82
+ """
83
+ target = parse_tag(tag)
84
+ candidates: list[tuple[tuple[int, int, int], str]] = []
85
+ for candidate in tags:
86
+ try:
87
+ version = parse_tag(candidate)
88
+ except ValueError:
89
+ continue
90
+ if version < target:
91
+ candidates.append((version, candidate))
92
+ return max(candidates, default=((), ""))[1] or None
93
+
94
+
95
+ def require_newer_tag(tag: str, tags: list[str]) -> None:
96
+ """Require ``tag`` to follow every existing numeric release tag.
97
+
98
+ Raises:
99
+ ValueError: A matching or newer release tag already exists.
100
+
101
+ """
102
+ target = parse_tag(tag)
103
+ versions: list[tuple[int, int, int]] = []
104
+ for candidate in tags:
105
+ try:
106
+ versions.append(parse_tag(candidate))
107
+ except ValueError:
108
+ continue
109
+ if versions and target <= max(versions):
110
+ msg = f"{tag} must be newer than the latest release tag"
111
+ raise ValueError(msg)
112
+
113
+
114
+ __all__ = [
115
+ "next_tag",
116
+ "parse_tag",
117
+ "previous_tag",
118
+ "require_newer_tag",
119
+ ]
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.5
2
+ Name: release-creator
3
+ Version: 0.1.0
4
+ Summary: Release preparation tools for changelogs and GitHub
5
+ Project-URL: Documentation, https://release-creator.readthedocs.io/
6
+ Project-URL: Source, https://github.com/tox-dev/release-creator
7
+ Project-URL: Tracker, https://github.com/tox-dev/release-creator/issues
8
+ Author-email: Bernát Gábor <gaborjbernat@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: GitHub releases,changelog,release automation,release notes,towncrier
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.15
20
+ Classifier: Topic :: Documentation
21
+ Classifier: Topic :: Software Development :: Build Tools
22
+ Classifier: Topic :: Software Development :: Version Control :: Git
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.15
25
+ Requires-Dist: mdformat-gfm<2,>=1
26
+ Requires-Dist: mdformat<2,>=1
27
+ Description-Content-Type: text/markdown
28
+
29
+ # release-creator
30
+
31
+ ![release-creator logo](https://raw.githubusercontent.com/tox-dev/release-creator/main/docs/_static/logo-128.png)
32
+
33
+ [![PyPI](https://img.shields.io/pypi/v/release-creator?style=flat-square)](https://pypi.org/project/release-creator/)
34
+ [![Supported Python versions](https://img.shields.io/pypi/pyversions/release-creator.svg)](https://pypi.org/project/release-creator/)
35
+ [![check](https://github.com/tox-dev/release-creator/actions/workflows/check.yaml/badge.svg)](https://github.com/tox-dev/release-creator/actions/workflows/check.yaml)
36
+
37
+ Release preparation tools for Python projects. The `notes` command renders a tagged changelog section for a GitHub
38
+ Release. Other release tasks can use separate commands.
39
+
40
+ The [documentation](https://release-creator.readthedocs.io/) has a tutorial, how-to guides, a generated command
41
+ reference, and an explanation of the changelog format.
42
+
43
+ ## Render GitHub Release notes
44
+
45
+ Run the current release from PyPI:
46
+
47
+ ```bash
48
+ uvx --from 'release-creator' release-creator notes \
49
+ --changelog docs/changelog.md \
50
+ --tag 1.2.3 \
51
+ --repository tox-dev/example \
52
+ --previous 1.2.2 \
53
+ --artifact-url https://pypi.org/project/example/1.2.3/ > release-notes.md
54
+ ```
55
+
56
+ For automation, replace `release-creator` after `--from` with a tested `release-creator==VERSION`. The command reads the
57
+ tagged section, maps `Added` to `Features` and `Fixed` to `Bug fixes`, changes linked author handles to GitHub mentions,
58
+ and leaves bullets unwrapped. It keeps stable category anchors, omits empty categories when they are absent from the
59
+ source, and adds artifact and comparison links. Use `--artifact-label` for an ecosystem other than PyPI; omit
60
+ `--artifact-url` when there is no external artifact.
61
+
62
+ The command also accepts a `.rst` changelog with the same category names. For projects whose `:issue:` role points at
63
+ PRs, pass `--issue-role pull`:
64
+
65
+ ```bash
66
+ uvx --from 'release-creator' release-creator notes \
67
+ --changelog docs/changelog.rst \
68
+ --tag 1.2.3 \
69
+ --repository tox-dev/example \
70
+ --previous 1.2.2 \
71
+ --issue-role pull \
72
+ --artifact-url https://pypi.org/project/example/1.2.3/ > release-notes.md
73
+ ```
74
+
75
+ The changelog is the source of truth. The command does not infer changes from commits or generate an author list. Add
76
+ verified human credits to changelog entries before release; omit bot credits. The GitHub Release may add a
77
+ `New Contributors` section by passing `--generated-notes` with the body from GitHub's generate-notes API. Only handles
78
+ credited in the changelog are kept; the generated-notes banner and other generated sections are discarded.
79
+
80
+ Named Towncrier fragments need no issue or PR number. Before Towncrier builds the changelog, run
81
+ `release-creator fragments --repository owner/repo --git`. The command reads each fragment's introducing squash commit,
82
+ adds its PR link, and fails if the subject does not end in `(#number)`. For a checkout without Git history, pass a
83
+ tab-separated subject map with `--commit-map commits.tsv` instead.
84
+
85
+ The `tags` command calculates a version from pending fragments, validates numeric tags, and selects the preceding tag
86
+ for GitHub comparisons:
87
+
88
+ ```bash
89
+ git tag --list | uvx --from 'release-creator' release-creator tags --next auto
90
+ git tag --list | uvx --from 'release-creator' release-creator tags 1.2.3 --require-newer
91
+ git tag --list | uvx --from 'release-creator' release-creator tags 1.2.3 --previous
92
+ ```
93
+
94
+ ## Development and releases
95
+
96
+ See [contributing](https://github.com/tox-dev/release-creator/blob/main/CONTRIBUTING.md) for the fragment format and release process. The package uses the same `notes`
97
+ command for its own GitHub Releases. Run `tox -e 3.15,type,pkg_meta,docs` and `prek run --all-files` before a PR.
@@ -0,0 +1,20 @@
1
+ release_creator/__init__.py,sha256=s0siT-8PQHxn08gsUQha1Xp6xa5fCp9O3_Yy--i6amo,45
2
+ release_creator/__main__.py,sha256=JDDX8heZr8tpT9if1lgXHUcDQQNTcsnrOdYKvoE3b_I,116
3
+ release_creator/cli.py,sha256=L3X62d7rTqY9eTlObuGuBU-1ntA35LgknZnhvj0Albg,1050
4
+ release_creator/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ release_creator/fragments/__init__.py,sha256=DE2jxQF71lvRCWMLTkYG2qaE-z2Gn_QNkO9usStKT6c,55
6
+ release_creator/fragments/cli.py,sha256=A3qVjEzcnMHAbwEbTE6i_L5q2O-uzpRNvSY3Ecy8Rvg,1424
7
+ release_creator/fragments/git.py,sha256=Gwha7SihfAsy54ahJsIJwvIRBfjLDNmCu5HxMuNtKEs,1406
8
+ release_creator/fragments/links.py,sha256=E_c6CTsUHxv348f4CU42Ubt5qORTPIkEJ1dEJOkjpfg,2596
9
+ release_creator/fragments/template.jinja2,sha256=cxYtpokGJ6w9f_vgW-tEEK649jZeBEFAfIAt9Z9vzFM,568
10
+ release_creator/notes/__init__.py,sha256=sLz-XO9tCXqr0wu1xcdjyN5aDtaYeajMSChQukN8_ME,46
11
+ release_creator/notes/cli.py,sha256=i7dxMx73KZtLQf8H7-BBmdXua5_YjTKqukIB0ARzTis,1966
12
+ release_creator/notes/render.py,sha256=yeyDvImms0FwqDH4ZwZ7iZsv55KPIsCfL6NJfoZNskU,11464
13
+ release_creator/tags/__init__.py,sha256=H5Hrex281tq_T0ZnlZcq955jMDddBMhiBPBxyeQioK0,35
14
+ release_creator/tags/cli.py,sha256=-5BalQKXmE8871v4bbO5uCSX__YG5zRKAExkQFtTdR0,2131
15
+ release_creator/tags/select.py,sha256=2b6dC8sdiDPZ2n2rxqRJJyJCznA136Tt6UKnjS0i3kE,3630
16
+ release_creator-0.1.0.dist-info/METADATA,sha256=vEboXr-l1u9hIAkqHTGnZbqBjnEHAqSPHU29YmttMQQ,4912
17
+ release_creator-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
18
+ release_creator-0.1.0.dist-info/entry_points.txt,sha256=RP9cxwFYasRlvAa-xbO62lmLXlygMlGVbaNGE3lmHTY,61
19
+ release_creator-0.1.0.dist-info/licenses/LICENSE,sha256=kOJeH68qSq6o2V7o5_VziwUqGswMnFgGgQH5Mahr1Yg,1023
20
+ release_creator-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ release-creator = release_creator.cli:main
@@ -0,0 +1,18 @@
1
+ Permission is hereby granted, free of charge, to any person obtaining a
2
+ copy of this software and associated documentation files (the
3
+ "Software"), to deal in the Software without restriction, including
4
+ without limitation the rights to use, copy, modify, merge, publish,
5
+ distribute, sublicense, and/or sell copies of the Software, and to
6
+ permit persons to whom the Software is furnished to do so, subject to
7
+ the following conditions:
8
+
9
+ The above copyright notice and this permission notice shall be included
10
+ in all copies or substantial portions of the Software.
11
+
12
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
13
+ OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
14
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
15
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
16
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
17
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
18
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.