alver 1.0.0__tar.gz
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.
- alver-1.0.0/LICENSE +7 -0
- alver-1.0.0/PKG-INFO +43 -0
- alver-1.0.0/README.md +26 -0
- alver-1.0.0/alver/__init__.py +2 -0
- alver-1.0.0/alver/__main__.py +8 -0
- alver-1.0.0/alver/_version.py +11 -0
- alver-1.0.0/alver/changelog.py +225 -0
- alver-1.0.0/alver/cli.py +406 -0
- alver-1.0.0/alver/detail.py +171 -0
- alver-1.0.0/pyproject.toml +42 -0
alver-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright (c) 2026 - Almar Klein
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
alver-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: alver
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Version management made easy
|
|
5
|
+
Keywords: versioning,releaseing
|
|
6
|
+
Author: Almar Klein
|
|
7
|
+
Requires-Python: >= 3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: ruff ; extra == "dev"
|
|
11
|
+
Requires-Dist: pre-commit ; extra == "dev"
|
|
12
|
+
Requires-Dist: pytest ; extra == "dev"
|
|
13
|
+
Project-URL: Homepage, https://github.com/almarklein/alver
|
|
14
|
+
Project-URL: Repository, https://github.com/almarklein/alver
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
|
|
17
|
+
# alver
|
|
18
|
+
|
|
19
|
+
Easily manage versions, releases and changelog. Almar's version tool (went with "simpleversion" first but that was taken).
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
usage: alver [-h] {version,bump,notes,changelog-from-gh,changelog-to-gh} ...
|
|
23
|
+
|
|
24
|
+
Commands to easily manage releases and changelog
|
|
25
|
+
|
|
26
|
+
* Run `alver bump vx.y.z`.
|
|
27
|
+
* Edit the changelog as needed.
|
|
28
|
+
* Commit and create a PR for this version bump.
|
|
29
|
+
* When merged, push a tag to roll the release.
|
|
30
|
+
|
|
31
|
+
positional arguments:
|
|
32
|
+
{version,bump,notes,changelog-from-gh,changelog-to-gh}
|
|
33
|
+
version Print the current project version from _version.py.
|
|
34
|
+
bump Bump the version and add a changelog section with gh-generated release notes.
|
|
35
|
+
notes Print the release notes for a version from CHANGELOG.md.
|
|
36
|
+
changelog-from-gh Create/update CHANGELOG.md sections from the GitHub releases of this repo.
|
|
37
|
+
changelog-to-gh Update the release notes of all gh releases that have a section in CHANGELOG.md.
|
|
38
|
+
|
|
39
|
+
options:
|
|
40
|
+
-h, --help show this help message and exit
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
|
alver-1.0.0/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# alver
|
|
2
|
+
|
|
3
|
+
Easily manage versions, releases and changelog. Almar's version tool (went with "simpleversion" first but that was taken).
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
usage: alver [-h] {version,bump,notes,changelog-from-gh,changelog-to-gh} ...
|
|
7
|
+
|
|
8
|
+
Commands to easily manage releases and changelog
|
|
9
|
+
|
|
10
|
+
* Run `alver bump vx.y.z`.
|
|
11
|
+
* Edit the changelog as needed.
|
|
12
|
+
* Commit and create a PR for this version bump.
|
|
13
|
+
* When merged, push a tag to roll the release.
|
|
14
|
+
|
|
15
|
+
positional arguments:
|
|
16
|
+
{version,bump,notes,changelog-from-gh,changelog-to-gh}
|
|
17
|
+
version Print the current project version from _version.py.
|
|
18
|
+
bump Bump the version and add a changelog section with gh-generated release notes.
|
|
19
|
+
notes Print the release notes for a version from CHANGELOG.md.
|
|
20
|
+
changelog-from-gh Create/update CHANGELOG.md sections from the GitHub releases of this repo.
|
|
21
|
+
changelog-to-gh Update the release notes of all gh releases that have a section in CHANGELOG.md.
|
|
22
|
+
|
|
23
|
+
options:
|
|
24
|
+
-h, --help show this help message and exit
|
|
25
|
+
```
|
|
26
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
|
2
|
+
version_info = tuple(int(i) for i in __version__.split("."))
|
|
3
|
+
|
|
4
|
+
# base_version = __version__
|
|
5
|
+
# try:
|
|
6
|
+
# import alver
|
|
7
|
+
# except ImportError:
|
|
8
|
+
# pass
|
|
9
|
+
# else:
|
|
10
|
+
# __version__ = alver.get_detailed_version()
|
|
11
|
+
# version_info = alver.get_version_tuple(__version__)
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Read and update a Keep-a-Changelog style CHANGELOG.md.
|
|
2
|
+
|
|
3
|
+
Sections are headed by ``## [name]`` or ``## [name] - date`` lines, e.g.
|
|
4
|
+
``## [unreleased]`` or ``## [v1.2.3] - 23-06-2026``. Everything before the
|
|
5
|
+
first section header is the preamble and is preserved as-is.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import date, datetime
|
|
13
|
+
|
|
14
|
+
from .detail import get_version_tuple
|
|
15
|
+
|
|
16
|
+
# Matches a section header line, e.g. "## [v1.2.3] - 23-06-2026" or "## [unreleased]"
|
|
17
|
+
HEADER_RE = re.compile(r"^##\s*\[([^\]]*)\]\s*(?:-\s*(.*?))?\s*$", re.MULTILINE)
|
|
18
|
+
|
|
19
|
+
# Matches a section name that is a version, e.g. "v1.2.3" or "1.2.3rc1"
|
|
20
|
+
VERSION_NAME_RE = re.compile(r"v?\d+(\.\d+)*[0-9A-Za-z.\-+]*", re.IGNORECASE)
|
|
21
|
+
|
|
22
|
+
# Section names that hold not-yet-released changes.
|
|
23
|
+
UNRELEASED_NAMES = ("unreleased", "pending")
|
|
24
|
+
|
|
25
|
+
# Template for a CHANGELOG.md that does not exist yet.
|
|
26
|
+
DEFAULT_CHANGELOG = """# Changelog / release notes
|
|
27
|
+
|
|
28
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
29
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
30
|
+
|
|
31
|
+
Possible sections in each release:
|
|
32
|
+
|
|
33
|
+
* Added: for new features.
|
|
34
|
+
* Changed: for changes in existing functionality.
|
|
35
|
+
* Deprecated: for soon-to-be removed features.
|
|
36
|
+
* Removed: for now removed features.
|
|
37
|
+
* Fixed: for any bug fixes.
|
|
38
|
+
* Security: in case of vulnerabilities.
|
|
39
|
+
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Section:
|
|
45
|
+
"""A ``## [name] - date`` section of the changelog."""
|
|
46
|
+
|
|
47
|
+
name: str
|
|
48
|
+
date: str = ""
|
|
49
|
+
body: str = ""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def norm_version(v: str) -> str:
|
|
53
|
+
"""Normalize a version for comparison, so 'v1.2.3' == '1.2.3'."""
|
|
54
|
+
return v.strip().removeprefix("v").lower()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_version_name(name: str) -> bool:
|
|
58
|
+
"""Whether a section name looks like a version, e.g. 'v1.2.3'."""
|
|
59
|
+
return bool(VERSION_NAME_RE.fullmatch(name.strip()))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _version_sort_key(name: str) -> tuple:
|
|
63
|
+
"""Sort key for a version section name; numeric parts compare numerically."""
|
|
64
|
+
parts = get_version_tuple(norm_version(name))
|
|
65
|
+
# Wrap each part so ints and strings never compare against each other.
|
|
66
|
+
return tuple((0, p) if isinstance(p, int) else (1, str(p)) for p in parts)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def sort_sections(sections: list[Section]) -> list[Section]:
|
|
70
|
+
"""Order sections: [unreleased]/[pending] on top, then release sections
|
|
71
|
+
sorted newest version first, then any remaining sections in original order."""
|
|
72
|
+
head: list[Section] = []
|
|
73
|
+
vers: list[Section] = []
|
|
74
|
+
rest: list[Section] = []
|
|
75
|
+
for s in sections:
|
|
76
|
+
if s.name.lower() in UNRELEASED_NAMES:
|
|
77
|
+
head.append(s)
|
|
78
|
+
elif is_version_name(s.name):
|
|
79
|
+
vers.append(s)
|
|
80
|
+
else:
|
|
81
|
+
rest.append(s)
|
|
82
|
+
vers.sort(key=lambda s: _version_sort_key(s.name), reverse=True)
|
|
83
|
+
return head + vers + rest
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def parse_changelog(text: str) -> tuple[str, list[Section]]:
|
|
87
|
+
"""Split changelog text into the preamble and a list of sections."""
|
|
88
|
+
matches = list(HEADER_RE.finditer(text))
|
|
89
|
+
preamble = text[: matches[0].start()] if matches else text
|
|
90
|
+
sections = []
|
|
91
|
+
for i, m in enumerate(matches):
|
|
92
|
+
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
|
|
93
|
+
sections.append(
|
|
94
|
+
Section(
|
|
95
|
+
name=m.group(1).strip(),
|
|
96
|
+
date=(m.group(2) or "").strip(),
|
|
97
|
+
body=text[m.end() : end],
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
return preamble, sections
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def render_changelog(preamble: str, sections: list[Section]) -> str:
|
|
104
|
+
"""Compose changelog text from a preamble and sections."""
|
|
105
|
+
out = preamble
|
|
106
|
+
if out and not out.endswith("\n"):
|
|
107
|
+
out += "\n"
|
|
108
|
+
blocks = []
|
|
109
|
+
for s in sections:
|
|
110
|
+
header = f"## [{s.name}]"
|
|
111
|
+
if s.date:
|
|
112
|
+
header += f" - {s.date}"
|
|
113
|
+
body = s.body.strip("\n")
|
|
114
|
+
# End a non-empty body with a newline so the next header is
|
|
115
|
+
# separated by a blank line; keep empty bodies compact.
|
|
116
|
+
blocks.append(header + "\n\n" + body + ("\n" if body else ""))
|
|
117
|
+
if blocks:
|
|
118
|
+
out += "\n".join(blocks)
|
|
119
|
+
if out.strip():
|
|
120
|
+
out = out.rstrip("\n") + "\n"
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def bump_changelog(
|
|
125
|
+
text: str, version: str, release_notes: str, today: str | None = None
|
|
126
|
+
) -> str:
|
|
127
|
+
"""Return updated changelog text for releasing ``version``.
|
|
128
|
+
|
|
129
|
+
A new section ``## [version] - <today>`` is added, unless a section for
|
|
130
|
+
this version already exists, in which case that section is used. If an
|
|
131
|
+
``[unreleased]`` or ``[pending]`` section exists, its header is removed
|
|
132
|
+
and its content is moved into the target section, followed by the given
|
|
133
|
+
release notes. Sections are sorted so that newer versions are on top.
|
|
134
|
+
"""
|
|
135
|
+
if today is None:
|
|
136
|
+
today = date.today().strftime("%d-%m-%Y")
|
|
137
|
+
preamble, sections = parse_changelog(text)
|
|
138
|
+
v = norm_version(version)
|
|
139
|
+
|
|
140
|
+
unreleased = next((s for s in sections if s.name.lower() in UNRELEASED_NAMES), None)
|
|
141
|
+
target = next((s for s in sections if norm_version(s.name) == v), None)
|
|
142
|
+
|
|
143
|
+
if target is None:
|
|
144
|
+
target = Section(name=version, date=today)
|
|
145
|
+
sections.append(target)
|
|
146
|
+
if unreleased is not None:
|
|
147
|
+
sections.remove(unreleased)
|
|
148
|
+
|
|
149
|
+
# Compose the target body: existing content, then the unreleased content,
|
|
150
|
+
# then the generated release notes. The notes are skipped if already
|
|
151
|
+
# present (e.g. when bumping the same version twice).
|
|
152
|
+
parts = []
|
|
153
|
+
for chunk in (target.body, unreleased.body if unreleased else ""):
|
|
154
|
+
chunk = chunk.strip("\n")
|
|
155
|
+
if chunk:
|
|
156
|
+
parts.append(chunk)
|
|
157
|
+
notes_chunk = release_notes.strip("\n")
|
|
158
|
+
if notes_chunk and notes_chunk not in "\n\n".join(parts):
|
|
159
|
+
parts.append(notes_chunk)
|
|
160
|
+
target.body = "\n\n".join(parts)
|
|
161
|
+
|
|
162
|
+
return render_changelog(preamble, sort_sections(sections))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def changelog_from_releases(text: str, releases: list[dict]) -> str:
|
|
166
|
+
"""Create/update changelog sections from gh releases (expected newest first).
|
|
167
|
+
|
|
168
|
+
Each release is a dict with keys ``tag``, ``date`` (ISO string) and ``body``.
|
|
169
|
+
Existing ``[unreleased]``/``[pending]`` sections are kept on top. A section
|
|
170
|
+
whose version matches a release tag gets its date updated from gh, its name
|
|
171
|
+
set to the larger of the two version texts (so "v0.2.0" wins over "0.2.0"),
|
|
172
|
+
and its body set to the larger of the two notes texts (gh vs changelog);
|
|
173
|
+
a release without a section gets a new one. Sections are sorted so that
|
|
174
|
+
newer versions are on top.
|
|
175
|
+
"""
|
|
176
|
+
preamble, sections = parse_changelog(text)
|
|
177
|
+
by_version = {norm_version(s.name): s for s in sections}
|
|
178
|
+
|
|
179
|
+
merged: list[Section] = []
|
|
180
|
+
used: set[str] = set()
|
|
181
|
+
for rel in releases:
|
|
182
|
+
tag = str(rel.get("tag") or "").strip()
|
|
183
|
+
if not tag:
|
|
184
|
+
continue
|
|
185
|
+
key = norm_version(tag)
|
|
186
|
+
date_str = iso_to_changelog_date(rel.get("date") or "")
|
|
187
|
+
body = str(rel.get("body") or "").strip("\n")
|
|
188
|
+
existing = by_version.get(key)
|
|
189
|
+
if existing is not None and key not in used:
|
|
190
|
+
existing.date = date_str or existing.date
|
|
191
|
+
# Keep the larger notes text, whether it comes from gh or the changelog.
|
|
192
|
+
existing.body = max([existing.body.strip("\n"), body], key=len)
|
|
193
|
+
existing.name = max([tag, existing.name], key=len)
|
|
194
|
+
merged.append(existing)
|
|
195
|
+
used.add(key)
|
|
196
|
+
else:
|
|
197
|
+
merged.append(Section(name=tag, date=date_str, body=body))
|
|
198
|
+
|
|
199
|
+
# Keep all other existing sections (including [unreleased]/[pending]).
|
|
200
|
+
for s in sections:
|
|
201
|
+
if norm_version(s.name) not in used:
|
|
202
|
+
merged.append(s)
|
|
203
|
+
|
|
204
|
+
return render_changelog(preamble, sort_sections(merged))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def iso_to_changelog_date(s: str) -> str:
|
|
208
|
+
"""Convert an ISO datetime like '2026-11-09T12:34:56Z' to '09-11-2026'."""
|
|
209
|
+
try:
|
|
210
|
+
return datetime.fromisoformat(s.replace("Z", "+00:00")).strftime("%d-%m-%Y")
|
|
211
|
+
except ValueError:
|
|
212
|
+
return s
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def extract_changelog(text: str, version: str) -> str:
|
|
216
|
+
"""Return the body of the section for ``version``.
|
|
217
|
+
|
|
218
|
+
Raises KeyError if there is no section for this version.
|
|
219
|
+
"""
|
|
220
|
+
_preamble, sections = parse_changelog(text)
|
|
221
|
+
v = norm_version(version)
|
|
222
|
+
for s in sections:
|
|
223
|
+
if norm_version(s.name) == v:
|
|
224
|
+
return s.body.strip("\n")
|
|
225
|
+
raise KeyError(version)
|
alver-1.0.0/alver/cli.py
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""Commands to easily manage releases and changelog
|
|
2
|
+
|
|
3
|
+
* Run `alver bump vx.y.z`.
|
|
4
|
+
* Edit the changelog as needed.
|
|
5
|
+
* Commit and create a PR for this version bump.
|
|
6
|
+
* When merged, push a tag to roll the release.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import importlib.util
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from . import changelog
|
|
21
|
+
|
|
22
|
+
VERSION_RE = re.compile(r"v?\d+\.\d+\.\d+(?:[.\-][0-9A-Za-z]+)*$", re.IGNORECASE)
|
|
23
|
+
VERSION_LINE_RE = re.compile(r"^(__version__\s*=\s*)(['\"])([^'\"]*)\2", re.MULTILINE)
|
|
24
|
+
GITHUB_REMOTE_RE = re.compile(
|
|
25
|
+
r"github\.com[:/](?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?/?$"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Directories to skip when searching the repo for _version.py.
|
|
29
|
+
SKIP_DIRS = {
|
|
30
|
+
".git",
|
|
31
|
+
".hg",
|
|
32
|
+
".svn",
|
|
33
|
+
".venv",
|
|
34
|
+
"venv",
|
|
35
|
+
"env",
|
|
36
|
+
"node_modules",
|
|
37
|
+
"__pycache__",
|
|
38
|
+
".tox",
|
|
39
|
+
".nox",
|
|
40
|
+
".eggs",
|
|
41
|
+
"build",
|
|
42
|
+
"dist",
|
|
43
|
+
".mypy_cache",
|
|
44
|
+
".pytest_cache",
|
|
45
|
+
".ruff_cache",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main(argv: list[str] | None = None) -> int:
|
|
50
|
+
parser = argparse.ArgumentParser(
|
|
51
|
+
prog="alver",
|
|
52
|
+
description=__doc__,
|
|
53
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
54
|
+
)
|
|
55
|
+
sub = parser.add_subparsers(dest="command")
|
|
56
|
+
|
|
57
|
+
sub.add_parser(
|
|
58
|
+
"version", help="Print the current project version from _version.py."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
p_bump = sub.add_parser(
|
|
62
|
+
"bump",
|
|
63
|
+
help="Bump the version and add a changelog section with "
|
|
64
|
+
"gh-generated release notes.",
|
|
65
|
+
)
|
|
66
|
+
p_bump.add_argument("version", help="New version, e.g. v1.2.3")
|
|
67
|
+
|
|
68
|
+
p_notes = sub.add_parser(
|
|
69
|
+
"notes", help="Print the release notes for a version from CHANGELOG.md."
|
|
70
|
+
)
|
|
71
|
+
p_notes.add_argument("version", help="Version to show notes for, e.g. v1.2.3")
|
|
72
|
+
|
|
73
|
+
sub.add_parser(
|
|
74
|
+
"changelog-from-gh",
|
|
75
|
+
help="Create/update CHANGELOG.md sections from the GitHub releases "
|
|
76
|
+
"of this repo.",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
sub.add_parser(
|
|
80
|
+
"changelog-to-gh",
|
|
81
|
+
help="Update the release notes of all gh releases that have a "
|
|
82
|
+
"section in CHANGELOG.md.",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
args = parser.parse_args(argv)
|
|
86
|
+
if args.command is None:
|
|
87
|
+
parser.print_help()
|
|
88
|
+
return 2
|
|
89
|
+
if args.command == "bump":
|
|
90
|
+
return cmd_bump(args.version)
|
|
91
|
+
elif args.command == "notes":
|
|
92
|
+
return cmd_notes(args.version)
|
|
93
|
+
elif args.command == "version":
|
|
94
|
+
return cmd_version()
|
|
95
|
+
elif args.command == "changelog-from-gh":
|
|
96
|
+
return cmd_changelog_from_gh()
|
|
97
|
+
elif args.command == "changelog-to-gh":
|
|
98
|
+
return cmd_changelog_to_gh()
|
|
99
|
+
return 2 # pragma: no cover
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def cmd_changelog_to_gh() -> int:
|
|
103
|
+
repo_dir = get_repo_dir()
|
|
104
|
+
changelog_file = get_changelog_file(
|
|
105
|
+
repo_dir
|
|
106
|
+
) # must exist; nothing to push otherwise
|
|
107
|
+
owner, repo = get_owner_repo(repo_dir)
|
|
108
|
+
releases = get_gh_releases(owner, repo, repo_dir)
|
|
109
|
+
|
|
110
|
+
_preamble, sections = changelog.parse_changelog(
|
|
111
|
+
changelog_file.read_text(encoding="utf-8")
|
|
112
|
+
)
|
|
113
|
+
by_version = {changelog.norm_version(r["tag"]): r for r in releases}
|
|
114
|
+
|
|
115
|
+
updated = 0
|
|
116
|
+
skipped: list[str] = []
|
|
117
|
+
for s in sections:
|
|
118
|
+
if not changelog.is_version_name(s.name):
|
|
119
|
+
continue # e.g. [unreleased]
|
|
120
|
+
rel = by_version.get(changelog.norm_version(s.name))
|
|
121
|
+
if rel is None:
|
|
122
|
+
skipped.append(s.name)
|
|
123
|
+
continue
|
|
124
|
+
body = s.body.strip("\n")
|
|
125
|
+
if not body:
|
|
126
|
+
skipped.append(s.name)
|
|
127
|
+
continue
|
|
128
|
+
# Check both version texts (section name vs release tag) and use the
|
|
129
|
+
# larger one as the tag, so e.g. "v0.2.0" wins over "0.2.0".
|
|
130
|
+
tag = max([str(rel["tag"]), s.name], key=len)
|
|
131
|
+
update_gh_release(owner, repo, tag, rel.get("id"), body, repo_dir)
|
|
132
|
+
print(f"Updated release {tag} ({owner}/{repo}) with notes from CHANGELOG.md")
|
|
133
|
+
updated += 1
|
|
134
|
+
|
|
135
|
+
for name in skipped:
|
|
136
|
+
print(f"Skipped [{name}]: no matching gh release or empty section")
|
|
137
|
+
print(f"Done: {updated} release(s) updated, {len(skipped)} section(s) skipped.")
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def cmd_changelog_from_gh() -> int:
|
|
142
|
+
repo_dir = get_repo_dir()
|
|
143
|
+
changelog_file = get_changelog_file(repo_dir, create=True)
|
|
144
|
+
owner, repo = get_owner_repo(repo_dir)
|
|
145
|
+
releases = get_gh_releases(owner, repo, repo_dir)
|
|
146
|
+
new_text = changelog.changelog_from_releases(
|
|
147
|
+
changelog_file.read_text(encoding="utf-8"), releases
|
|
148
|
+
)
|
|
149
|
+
changelog_file.write_text(new_text, encoding="utf-8")
|
|
150
|
+
print(
|
|
151
|
+
f"Updated {changelog_file.relative_to(repo_dir)} from {len(releases)} "
|
|
152
|
+
f"gh releases ({owner}/{repo})"
|
|
153
|
+
)
|
|
154
|
+
return 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def cmd_version() -> int:
|
|
158
|
+
repo_dir = get_repo_dir()
|
|
159
|
+
version_file = find_version_file(repo_dir)
|
|
160
|
+
print(load_version(version_file))
|
|
161
|
+
return 0
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def load_version(path: Path) -> str:
|
|
165
|
+
"""Load a _version.py as a module and return its __version__ attribute."""
|
|
166
|
+
try:
|
|
167
|
+
spec = importlib.util.spec_from_file_location("_version", path)
|
|
168
|
+
mod = importlib.util.module_from_spec(spec)
|
|
169
|
+
spec.loader.exec_module(mod)
|
|
170
|
+
return str(mod.__version__)
|
|
171
|
+
except Exception:
|
|
172
|
+
# The module-level code failed (e.g. its package is not importable);
|
|
173
|
+
# fall back to reading the literal __version__ assignment.
|
|
174
|
+
m = VERSION_LINE_RE.search(path.read_text(encoding="utf-8"))
|
|
175
|
+
if m:
|
|
176
|
+
return m.group(3)
|
|
177
|
+
sys.exit(f"error: could not determine __version__ from {path}")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def cmd_bump(version: str) -> int:
|
|
181
|
+
if not VERSION_RE.fullmatch(version):
|
|
182
|
+
sys.exit(f"error: invalid version {version!r}, expected something like v1.2.3")
|
|
183
|
+
|
|
184
|
+
repo_dir = get_repo_dir()
|
|
185
|
+
version_file = find_version_file(repo_dir)
|
|
186
|
+
changelog_file = get_changelog_file(repo_dir, create=True)
|
|
187
|
+
|
|
188
|
+
owner, repo = get_owner_repo(repo_dir)
|
|
189
|
+
release_notes, source = get_release_notes(owner, repo, version, repo_dir)
|
|
190
|
+
|
|
191
|
+
# The changelog header and git tag keep the "v" prefix (e.g. v1.2.3),
|
|
192
|
+
# but __version__ itself must be a plain PEP 440 version (1.2.3).
|
|
193
|
+
plain_version = version[1:] if version[:1].lower() == "v" else version
|
|
194
|
+
old_version = set_version_in_file(version_file, plain_version)
|
|
195
|
+
new_text = changelog.bump_changelog(
|
|
196
|
+
changelog_file.read_text(encoding="utf-8"), version, release_notes
|
|
197
|
+
)
|
|
198
|
+
changelog_file.write_text(new_text, encoding="utf-8")
|
|
199
|
+
|
|
200
|
+
print(
|
|
201
|
+
f"Bumped {version_file.relative_to(repo_dir)}: {old_version} -> {plain_version}"
|
|
202
|
+
)
|
|
203
|
+
print(
|
|
204
|
+
f"Updated {changelog_file.relative_to(repo_dir)}: section [{version}] "
|
|
205
|
+
f"with release notes from {source} ({owner}/{repo})"
|
|
206
|
+
)
|
|
207
|
+
return 0
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def cmd_notes(version: str) -> int:
|
|
211
|
+
repo_dir = get_repo_dir()
|
|
212
|
+
changelog_file = get_changelog_file(repo_dir)
|
|
213
|
+
try:
|
|
214
|
+
body = changelog.extract_changelog(
|
|
215
|
+
changelog_file.read_text(encoding="utf-8"), version
|
|
216
|
+
)
|
|
217
|
+
except KeyError:
|
|
218
|
+
sys.exit(f"error: no section for version {version!r} in {changelog_file}")
|
|
219
|
+
print(body)
|
|
220
|
+
return 0
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# ===== Repo helpers
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def run_git(repo_dir: Path, *args: str) -> str:
|
|
227
|
+
p = subprocess.run(["git", *args], cwd=repo_dir, capture_output=True, text=True)
|
|
228
|
+
if p.returncode != 0:
|
|
229
|
+
raise RuntimeError(f"git {' '.join(args)}: {p.stderr.strip()}")
|
|
230
|
+
return p.stdout.strip()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def get_repo_dir() -> Path:
|
|
234
|
+
try:
|
|
235
|
+
return Path(run_git(Path.cwd(), "rev-parse", "--show-toplevel")).resolve()
|
|
236
|
+
except RuntimeError as e:
|
|
237
|
+
sys.exit(f"error: not inside a git repository ({e})")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def get_owner_repo(repo_dir: Path) -> tuple[str, str]:
|
|
241
|
+
"""Derive (owner, repo) from the git remote url, e.g. git@github.com:o/r.git."""
|
|
242
|
+
remotes = run_git(repo_dir, "remote").split()
|
|
243
|
+
if not remotes:
|
|
244
|
+
sys.exit(f"error: no git remote configured in {repo_dir}")
|
|
245
|
+
name = "origin" if "origin" in remotes else remotes[0]
|
|
246
|
+
url = run_git(repo_dir, "remote", "get-url", name)
|
|
247
|
+
m = GITHUB_REMOTE_RE.search(url)
|
|
248
|
+
if not m:
|
|
249
|
+
sys.exit(
|
|
250
|
+
f"error: could not derive org/repo from remote {name!r} url {url!r} "
|
|
251
|
+
"(only github.com remotes are supported)"
|
|
252
|
+
)
|
|
253
|
+
return m.group("owner"), m.group("repo")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def get_changelog_file(repo_dir: Path, create: bool = False) -> Path:
|
|
257
|
+
"""Return the path to CHANGELOG.md, optionally creating a default one."""
|
|
258
|
+
path = repo_dir / "CHANGELOG.md"
|
|
259
|
+
if not path.is_file():
|
|
260
|
+
if not create:
|
|
261
|
+
sys.exit(f"error: no CHANGELOG.md found at {path}")
|
|
262
|
+
path.write_text(changelog.DEFAULT_CHANGELOG, encoding="utf-8")
|
|
263
|
+
print(f"Created {path.relative_to(repo_dir)}")
|
|
264
|
+
return path
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def find_version_file(repo_dir: Path) -> Path:
|
|
268
|
+
"""Find the _version.py in the repo (shallowest match wins)."""
|
|
269
|
+
matches = []
|
|
270
|
+
for dirpath, dirnames, filenames in os.walk(repo_dir):
|
|
271
|
+
dirnames[:] = [
|
|
272
|
+
d for d in dirnames if d not in SKIP_DIRS and not d.startswith(".")
|
|
273
|
+
]
|
|
274
|
+
if "_version.py" in filenames:
|
|
275
|
+
matches.append(Path(dirpath) / "_version.py")
|
|
276
|
+
if not matches:
|
|
277
|
+
sys.exit(f"error: no _version.py found in {repo_dir}")
|
|
278
|
+
matches.sort(key=lambda p: (len(p.parts), str(p)))
|
|
279
|
+
return matches[0]
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def set_version_in_file(path: Path, version: str) -> str:
|
|
283
|
+
"""Set __version__ in the given _version.py. Returns the old version."""
|
|
284
|
+
text = path.read_text(encoding="utf-8")
|
|
285
|
+
m = VERSION_LINE_RE.search(text)
|
|
286
|
+
if not m:
|
|
287
|
+
sys.exit(f"error: no __version__ assignment found in {path}")
|
|
288
|
+
old = m.group(3)
|
|
289
|
+
new_text = (
|
|
290
|
+
text[: m.start()]
|
|
291
|
+
+ m.group(1)
|
|
292
|
+
+ m.group(2)
|
|
293
|
+
+ version
|
|
294
|
+
+ m.group(2)
|
|
295
|
+
+ text[m.end() :]
|
|
296
|
+
)
|
|
297
|
+
path.write_text(new_text, encoding="utf-8")
|
|
298
|
+
return old
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# ===== Release notes
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _run_gh(cmd: list[str], repo_dir: Path):
|
|
305
|
+
"""Run a gh command; return None if the gh executable is not installed."""
|
|
306
|
+
try:
|
|
307
|
+
return subprocess.run(cmd, cwd=repo_dir, capture_output=True, text=True)
|
|
308
|
+
except FileNotFoundError:
|
|
309
|
+
return None
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def get_release_notes(
|
|
313
|
+
owner: str, repo: str, tag: str, repo_dir: Path
|
|
314
|
+
) -> tuple[str, str]:
|
|
315
|
+
"""Get release notes for ``tag`` as generated by gh. Returns (notes, source)."""
|
|
316
|
+
base_cmd = [
|
|
317
|
+
"gh",
|
|
318
|
+
"api",
|
|
319
|
+
f"repos/{owner}/{repo}/releases/generate-notes",
|
|
320
|
+
"-f",
|
|
321
|
+
f"tag_name={tag}",
|
|
322
|
+
"--jq",
|
|
323
|
+
".body",
|
|
324
|
+
]
|
|
325
|
+
p = _run_gh(base_cmd, repo_dir)
|
|
326
|
+
if p is not None and p.returncode == 0 and p.stdout.strip():
|
|
327
|
+
return p.stdout.strip("\n"), "gh"
|
|
328
|
+
|
|
329
|
+
# The tag may not exist yet (bump happens before tagging); point gh at the
|
|
330
|
+
# current commit instead.
|
|
331
|
+
try:
|
|
332
|
+
head = run_git(repo_dir, "rev-parse", "HEAD")
|
|
333
|
+
except RuntimeError:
|
|
334
|
+
head = "HEAD"
|
|
335
|
+
p2 = _run_gh([*base_cmd, "-f", f"target_commitish={head}"], repo_dir)
|
|
336
|
+
if p2 is not None and p2.returncode == 0 and p2.stdout.strip():
|
|
337
|
+
return p2.stdout.strip("\n"), "gh"
|
|
338
|
+
|
|
339
|
+
err_src = (
|
|
340
|
+
(p2.stderr if p2 is not None else "")
|
|
341
|
+
or (p.stderr if p is not None else "")
|
|
342
|
+
or "gh executable not found"
|
|
343
|
+
)
|
|
344
|
+
err_lines = err_src.strip().splitlines()
|
|
345
|
+
err = err_lines[-1] if err_lines else "unknown error"
|
|
346
|
+
print(
|
|
347
|
+
f"warning: could not generate release notes with gh ({err}); "
|
|
348
|
+
"falling back to git log",
|
|
349
|
+
file=sys.stderr,
|
|
350
|
+
)
|
|
351
|
+
return git_log_notes(repo_dir), "git log"
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def get_gh_releases(owner: str, repo: str, repo_dir: Path) -> list[dict]:
|
|
355
|
+
"""List published gh releases (newest first) as dicts with tag/date/body."""
|
|
356
|
+
cmd = [
|
|
357
|
+
"gh",
|
|
358
|
+
"api",
|
|
359
|
+
"--paginate",
|
|
360
|
+
f"repos/{owner}/{repo}/releases?per_page=100",
|
|
361
|
+
"--jq",
|
|
362
|
+
'.[] | select(.draft | not) | {id: .id, tag: .tag_name, date: .published_at, body: (.body // "")}',
|
|
363
|
+
]
|
|
364
|
+
p = _run_gh(cmd, repo_dir)
|
|
365
|
+
if p is None:
|
|
366
|
+
sys.exit("error: gh executable not found; cannot list releases")
|
|
367
|
+
if p.returncode != 0:
|
|
368
|
+
sys.exit(
|
|
369
|
+
f"error: could not list gh releases for {owner}/{repo}: {p.stderr.strip()}"
|
|
370
|
+
)
|
|
371
|
+
# The jq filter emits one compact JSON object per line.
|
|
372
|
+
return [json.loads(line) for line in p.stdout.splitlines() if line.strip()]
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def update_gh_release(
|
|
376
|
+
owner: str,
|
|
377
|
+
repo: str,
|
|
378
|
+
tag: str,
|
|
379
|
+
release_id,
|
|
380
|
+
body: str,
|
|
381
|
+
repo_dir: Path,
|
|
382
|
+
) -> None:
|
|
383
|
+
"""PATCH the notes (body) of a gh release, by tag first, then by id."""
|
|
384
|
+
endpoints = [f"repos/{owner}/{repo}/releases/tags/{tag}"]
|
|
385
|
+
if release_id is not None:
|
|
386
|
+
endpoints.append(f"repos/{owner}/{repo}/releases/{release_id}")
|
|
387
|
+
err = "gh executable not found"
|
|
388
|
+
for endpoint in endpoints:
|
|
389
|
+
p = _run_gh(
|
|
390
|
+
["gh", "api", "-X", "PATCH", endpoint, "-f", f"body={body}"], repo_dir
|
|
391
|
+
)
|
|
392
|
+
if p is not None and p.returncode == 0:
|
|
393
|
+
return
|
|
394
|
+
if p is not None:
|
|
395
|
+
err = p.stderr.strip().splitlines()[-1] if p.stderr.strip() else err
|
|
396
|
+
sys.exit(f"error: could not update release {tag!r} ({owner}/{repo}): {err}")
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def git_log_notes(repo_dir: Path) -> str:
|
|
400
|
+
"""Fallback release notes from the git log since the previous tag."""
|
|
401
|
+
try:
|
|
402
|
+
prev = run_git(repo_dir, "describe", "--tags", "--abbrev=0")
|
|
403
|
+
rev_range = f"{prev}..HEAD"
|
|
404
|
+
except RuntimeError:
|
|
405
|
+
rev_range = "HEAD"
|
|
406
|
+
return run_git(repo_dir, "log", "--pretty=format:* %s (%h)", rev_range)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import subprocess
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_detailed_version(version: str, filename: str):
|
|
7
|
+
"""Get a more detailed version string, based on the git tags and history."""
|
|
8
|
+
|
|
9
|
+
# Get path to the library dir, assume that filename is the dir or a file directly in that dir
|
|
10
|
+
path = Path(filename)
|
|
11
|
+
project_name = "project"
|
|
12
|
+
if path.name.endswith(".py"):
|
|
13
|
+
path = path.parent
|
|
14
|
+
|
|
15
|
+
# Get repository dir from that, assume that the repo is exactly one directory up
|
|
16
|
+
repo_dir = path.parent
|
|
17
|
+
|
|
18
|
+
# Get sensible project name, only for reporting warnings and errors
|
|
19
|
+
project_name = path.name
|
|
20
|
+
if project_name == "src":
|
|
21
|
+
project_name = repo_dir.name
|
|
22
|
+
|
|
23
|
+
# If the repo_dir is indeed a git repo, do our thing, otherwise, just return given version
|
|
24
|
+
if repo_dir and repo_dir.joinpath(".git").is_dir():
|
|
25
|
+
base_version = version.partition("+")[0].partition(".post")[0] # Sanity
|
|
26
|
+
getter = DetailedVersionGetter(base_version, project_name, repo_dir)
|
|
27
|
+
return getter.get_version()
|
|
28
|
+
else:
|
|
29
|
+
return version
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_version_tuple(v: str) -> tuple:
|
|
33
|
+
"""Given a version string, produce a version tuple, where integer parts are actual integers."""
|
|
34
|
+
|
|
35
|
+
# Create raw parts
|
|
36
|
+
raw_parts = []
|
|
37
|
+
sortable, _, labels = v.partition("+")
|
|
38
|
+
raw_parts.extend(sortable.split("."))
|
|
39
|
+
if labels:
|
|
40
|
+
raw_parts.append("+")
|
|
41
|
+
raw_parts.extend(labels.split("."))
|
|
42
|
+
|
|
43
|
+
# Turn integer parts into actual integers
|
|
44
|
+
parts = []
|
|
45
|
+
for part in raw_parts:
|
|
46
|
+
if not part:
|
|
47
|
+
pass
|
|
48
|
+
elif part.startswith("post"):
|
|
49
|
+
try:
|
|
50
|
+
parts.extend(["post", int(part[4:])])
|
|
51
|
+
except ValueError:
|
|
52
|
+
parts.append(part)
|
|
53
|
+
else:
|
|
54
|
+
try:
|
|
55
|
+
parts.append(int(part))
|
|
56
|
+
except ValueError:
|
|
57
|
+
parts.append(part)
|
|
58
|
+
|
|
59
|
+
return tuple(parts)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class DetailedVersionGetter:
|
|
63
|
+
def __init__(self, base_version: str, project_name: str, repo_dir: Path):
|
|
64
|
+
self.base_version = base_version
|
|
65
|
+
self.project_name = project_name
|
|
66
|
+
self.repo_dir = repo_dir
|
|
67
|
+
self.logger = logging.getLogger(project_name)
|
|
68
|
+
|
|
69
|
+
def warning(self, m: str) -> None:
|
|
70
|
+
self.logger.warning(m)
|
|
71
|
+
|
|
72
|
+
def get_version(self) -> str:
|
|
73
|
+
try:
|
|
74
|
+
if self.repo_dir:
|
|
75
|
+
release, post, tag, dirty = self.get_version_info_from_git()
|
|
76
|
+
result = self.get_extended_version(release, post, tag, dirty)
|
|
77
|
+
|
|
78
|
+
# Warn if release does not match base_version.
|
|
79
|
+
# Can happen between bumping and tagging. And also when merging a
|
|
80
|
+
# version bump into a working branch, because we use --first-parent.
|
|
81
|
+
if release and release != self.base_version:
|
|
82
|
+
release2, _post, _tag, _dirty = self.get_version_info_from_git(
|
|
83
|
+
first_parent=False
|
|
84
|
+
)
|
|
85
|
+
if release2 != self.base_version:
|
|
86
|
+
self.warning(
|
|
87
|
+
f"{self.project_name} version from git ({release})"
|
|
88
|
+
f" and __version__ ({self.base_version}) don't match."
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
except Exception as err:
|
|
94
|
+
self.warning(f"Error getting refined version: {err}")
|
|
95
|
+
|
|
96
|
+
return self.base_version
|
|
97
|
+
|
|
98
|
+
def get_extended_version(
|
|
99
|
+
self, release: str, post: str, tag: str, dirty: str
|
|
100
|
+
) -> str:
|
|
101
|
+
# Start version string (__version__ string is leading).
|
|
102
|
+
version = self.base_version
|
|
103
|
+
labels = []
|
|
104
|
+
|
|
105
|
+
if release and release != self.base_version:
|
|
106
|
+
pre_label = "from_tag_" + release.replace(".", "_")
|
|
107
|
+
labels = [pre_label, f"post{post}", tag, dirty]
|
|
108
|
+
elif post and post != "0":
|
|
109
|
+
version += f".post{post}"
|
|
110
|
+
labels = [tag, dirty]
|
|
111
|
+
elif dirty:
|
|
112
|
+
labels = [tag, dirty]
|
|
113
|
+
else:
|
|
114
|
+
# If not post and not dirty, show 'clean' version without git tag.
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
# Compose final version (remove empty labels, e.g. when not dirty).
|
|
118
|
+
# Everything after the '+' is not sortable (does not get in version_info).
|
|
119
|
+
label_str = ".".join(label for label in labels if label)
|
|
120
|
+
if label_str:
|
|
121
|
+
version += "+" + label_str
|
|
122
|
+
return version
|
|
123
|
+
|
|
124
|
+
def get_version_info_from_git(
|
|
125
|
+
self, *, first_parent: bool = True
|
|
126
|
+
) -> tuple[str, str, str, str]:
|
|
127
|
+
"""
|
|
128
|
+
Get (release, post, tag, dirty) from Git.
|
|
129
|
+
|
|
130
|
+
With `release` the version number from the latest tag, `post` the
|
|
131
|
+
number of commits since that tag, `tag` the git hash, and `dirty` a string
|
|
132
|
+
that is either empty or says 'dirty'.
|
|
133
|
+
"""
|
|
134
|
+
# Call out to Git.
|
|
135
|
+
command = ["git", "describe", "--long", "--always", "--tags", "--dirty"]
|
|
136
|
+
if first_parent:
|
|
137
|
+
command.append("--first-parent")
|
|
138
|
+
try:
|
|
139
|
+
p = subprocess.run(
|
|
140
|
+
command, check=False, cwd=self.repo_dir, capture_output=True
|
|
141
|
+
)
|
|
142
|
+
except Exception as e:
|
|
143
|
+
self.warning(f"Could not get {self.project_name} version: {e}")
|
|
144
|
+
p = None
|
|
145
|
+
|
|
146
|
+
# Parse the result into parts.
|
|
147
|
+
if p is None:
|
|
148
|
+
parts = ("", "", "unknown")
|
|
149
|
+
else:
|
|
150
|
+
output = p.stdout.decode(errors="ignore")
|
|
151
|
+
if p.returncode:
|
|
152
|
+
stderr = p.stderr.decode(errors="ignore")
|
|
153
|
+
self.warning(
|
|
154
|
+
f"Could not get {self.project_name} version.\n\nstdout: "
|
|
155
|
+
+ output
|
|
156
|
+
+ "\n\nstderr: "
|
|
157
|
+
+ stderr
|
|
158
|
+
)
|
|
159
|
+
parts = ("", "", "unknown")
|
|
160
|
+
else:
|
|
161
|
+
parts = output.strip().lstrip("v").split("-")
|
|
162
|
+
if len(parts) <= 2:
|
|
163
|
+
# No tags (and thus no post). Only git hash and maybe 'dirty'.
|
|
164
|
+
parts = ("", "", *parts)
|
|
165
|
+
|
|
166
|
+
# Return unpacked parts.
|
|
167
|
+
release = parts[0]
|
|
168
|
+
post = parts[1]
|
|
169
|
+
tag = parts[2]
|
|
170
|
+
dirty = "dirty" if len(parts) > 3 else ""
|
|
171
|
+
return release, post, tag, dirty
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# ===== Project info
|
|
2
|
+
|
|
3
|
+
[project]
|
|
4
|
+
dynamic = ["version"]
|
|
5
|
+
name = "alver"
|
|
6
|
+
description = "Version management made easy"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
license = { file = "LICENSE" }
|
|
9
|
+
authors = [{ name = "Almar Klein" }]
|
|
10
|
+
keywords = [
|
|
11
|
+
"versioning",
|
|
12
|
+
"releaseing"
|
|
13
|
+
]
|
|
14
|
+
requires-python = ">= 3.10"
|
|
15
|
+
dependencies = []
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
dev = ["ruff", "pre-commit", "pytest"]
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
alver = "alver.cli:main"
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/almarklein/alver"
|
|
25
|
+
Repository = "https://github.com/almarklein/alver"
|
|
26
|
+
|
|
27
|
+
# ===== Building
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["flit_core >=3.2,<4"]
|
|
31
|
+
build-backend = "flit_core.buildapi"
|
|
32
|
+
|
|
33
|
+
# ===== Tooling
|
|
34
|
+
|
|
35
|
+
[tool.ruff]
|
|
36
|
+
line-length = 88
|
|
37
|
+
|
|
38
|
+
[tool.ruff.lint]
|
|
39
|
+
select = ["F", "E", "W", "N", "B", "RUF"]
|
|
40
|
+
ignore = [
|
|
41
|
+
"E501", # Line too long
|
|
42
|
+
]
|