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.
- release_creator/__init__.py +1 -0
- release_creator/__main__.py +6 -0
- release_creator/cli.py +38 -0
- release_creator/fragments/__init__.py +1 -0
- release_creator/fragments/cli.py +32 -0
- release_creator/fragments/git.py +39 -0
- release_creator/fragments/links.py +75 -0
- release_creator/fragments/template.jinja2 +20 -0
- release_creator/notes/__init__.py +1 -0
- release_creator/notes/cli.py +42 -0
- release_creator/notes/render.py +341 -0
- release_creator/py.typed +0 -0
- release_creator/tags/__init__.py +1 -0
- release_creator/tags/cli.py +57 -0
- release_creator/tags/select.py +119 -0
- release_creator-0.1.0.dist-info/METADATA +97 -0
- release_creator-0.1.0.dist-info/RECORD +20 -0
- release_creator-0.1.0.dist-info/WHEEL +4 -0
- release_creator-0.1.0.dist-info/entry_points.txt +2 -0
- release_creator-0.1.0.dist-info/licenses/LICENSE +18 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Tools for consistent project releases."""
|
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
|
+
]
|
release_creator/py.typed
ADDED
|
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
|
+

|
|
32
|
+
|
|
33
|
+
[](https://pypi.org/project/release-creator/)
|
|
34
|
+
[](https://pypi.org/project/release-creator/)
|
|
35
|
+
[](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,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.
|