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 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,2 @@
1
+ # from .detail import get_detailed_version, get_version_tuple
2
+ from ._version import __version__, version_info # noqa: F401
@@ -0,0 +1,8 @@
1
+ """Entry point for ``python -m alver``."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
@@ -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)
@@ -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
+ ]