Skip to content

Commit 8bcad43

Browse files
committed
feat(script): parse version bump commits
Add helper to iterate over commits in a repository where the version (calculated by Strategy callback) has changed. Gemini used to help write the test cases.
1 parent 910d7cd commit 8bcad43

File tree

5 files changed

+538
-1
lines changed

5 files changed

+538
-1
lines changed
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import logging
2+
from collections.abc import Iterator
3+
from os import PathLike
4+
from pathlib import Path
5+
from typing import Protocol
6+
7+
import toml
8+
from git import Commit, Repo
9+
from pydantic import BaseModel, ConfigDict
10+
11+
12+
class VersionCommit(BaseModel):
13+
"""
14+
Model abstraction
15+
"""
16+
17+
model_config = ConfigDict(arbitrary_types_allowed=True)
18+
19+
commit: Commit
20+
version: str
21+
22+
23+
log = logging.getLogger("scottzach1").getChild("semantic_release").getChild("version_parser")
24+
25+
26+
class VersionStrategy(Protocol):
27+
watch_path: PathLike
28+
29+
@staticmethod
30+
def get_version(commit: Commit) -> str | None: ...
31+
32+
33+
class Pep261:
34+
"""
35+
Strategy to extract version from pyproject.toml following PEP 261 standard.
36+
37+
```toml
38+
[project]
39+
name = "project-name"
40+
version = "0.0.1"
41+
```
42+
43+
https://peps.python.org/pep-0621/
44+
"""
45+
46+
watch_path = "pyproject.toml"
47+
48+
@staticmethod
49+
def get_version(commit: Commit) -> str | None:
50+
try:
51+
toml_content = (commit.tree / "pyproject.toml").data_stream.read().decode("utf-8")
52+
except KeyError:
53+
return None # File missing for commit
54+
55+
try:
56+
toml_dict = toml.loads(toml_content)
57+
except toml.TomlDecodeError:
58+
return None # Malformed pyproject.toml file
59+
60+
try:
61+
version = toml_dict["project"]["version"]
62+
63+
if not isinstance(version, str):
64+
raise TypeError(f"Version must be a string (found {type(version)})")
65+
66+
return version
67+
except (KeyError, TypeError):
68+
return None # TOML data missing key(s) or malformed (e.g. is None or str)
69+
70+
71+
class Uv(Pep261):
72+
"""
73+
Callback strategy to extract version for an astral-sh/uv project (follows PEP 261).
74+
75+
https://github.com/astral-sh/uv
76+
"""
77+
78+
79+
class PoetryV2(Pep261):
80+
"""
81+
Callback strategy to extract version for a python-poetry/poetry (version 2) project (follows PEP 261).
82+
83+
https://github.com/python-poetry/poetry
84+
"""
85+
86+
87+
class PoetryV1(Pep261):
88+
"""
89+
Callback strategy to extract version for a python-poetry/poetry (version < 2) project.
90+
91+
https://github.com/python-poetry/poetry/tree/1.8 (latest minor version as of 25/05/2025).
92+
"""
93+
94+
watch_path = "pyproject.toml"
95+
96+
@staticmethod
97+
def get_version(commit: Commit) -> str | None:
98+
try:
99+
toml_content = (commit.tree / "pyproject.toml").data_stream.read().decode("utf-8")
100+
except KeyError:
101+
return None # File missing for commit
102+
103+
try:
104+
toml_dict = toml.loads(toml_content)
105+
except toml.TomlDecodeError:
106+
return None # Malformed pyproject.toml file
107+
108+
try:
109+
version = toml_dict["tool"]["poetry"]["version"]
110+
111+
if not isinstance(version, str):
112+
raise TypeError(f"Version must be a string (found {type(version)})")
113+
114+
return version
115+
except (KeyError, TypeError):
116+
return None # TOML data missing key(s) or malformed (e.g. is None or str)
117+
118+
119+
def iter_version_commits(repo: Repo, *, strategy: VersionStrategy) -> Iterator[VersionCommit]:
120+
"""
121+
Extract commits containing version bumps based on user provided strategy.
122+
123+
Args:
124+
repo: the repo to parse commits from.
125+
strategy: callback to extract version string from commit.
126+
127+
Returns:
128+
iterator of commits where the version string is present and has changed (newest to oldest).
129+
"""
130+
131+
nxt_version = None # version for previous iteration (we are recursing newest to oldest)
132+
nxt_commit = None
133+
134+
for cur_commit in repo.iter_commits(paths=Path(repo.working_dir, strategy.watch_path)):
135+
cur_version = None
136+
try:
137+
cur_version = strategy.get_version(cur_commit)
138+
except Exception as e:
139+
log.warning("unexpected %s while parsing commit %s: %s", type(e).__name__, repr(cur_commit), str(e))
140+
141+
if cur_version != nxt_version and nxt_commit is not None and nxt_version is not None:
142+
yield VersionCommit(commit=nxt_commit, version=nxt_version)
143+
144+
nxt_commit = cur_commit
145+
nxt_version = cur_version

tests/test_git.py renamed to tests/test_message_parser.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import pytest
55
from pydantic import BaseModel
66

7-
from scottzach1.semantic_release.githelper import (
7+
from scottzach1.semantic_release.message_parser import (
88
LegacyMessage,
99
SemanticMessage,
1010
parse_commit_msg,

tests/test_version_parser.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import hashlib
2+
import logging
3+
from collections.abc import Iterator
4+
from pathlib import Path
5+
from unittest.mock import MagicMock, call
6+
7+
import git
8+
from git import Commit
9+
from gitdb.util import hex_to_bin
10+
11+
from scottzach1.semantic_release.version_parser import Pep261, VersionCommit, VersionStrategy, iter_version_commits
12+
13+
PROJECT_ROOT = Path(__file__).parent.parent
14+
15+
16+
def test_version_bumps():
17+
repo = git.Repo(PROJECT_ROOT)
18+
19+
for bump in iter_version_commits(repo, strategy=Pep261):
20+
print(bump.commit.hexsha, bump.version, bump.commit.message)
21+
22+
23+
class MockCommit(Commit):
24+
def __init__(self, repo, seed: str, **kwargs):
25+
hex_sha = hashlib.sha1(seed.encode("ascii")).hexdigest()
26+
bin_sha = hex_to_bin(hex_sha)
27+
28+
self.seed = seed
29+
super().__init__(repo, bin_sha, **kwargs)
30+
31+
def __repr__(self) -> str:
32+
return f"<MockCommit {self.seed}>"
33+
34+
35+
def test_version_commits_logic(caplog):
36+
"""
37+
Verity the toplevel behavior of iter_version_commits() with mocked repo and strategy.
38+
39+
Validates expected behavior for None, str, and Exception results.
40+
"""
41+
repo = MagicMock() # spec=git.Repo)
42+
43+
versions = [
44+
None,
45+
"1.1.1",
46+
"1.1.1",
47+
None,
48+
"1.1.0",
49+
"1.1.0",
50+
"1.1.0",
51+
"1.0.9",
52+
None,
53+
None,
54+
ValueError("MEEP"),
55+
"1.0.8",
56+
]
57+
commits = [MockCommit(repo, f"commits[{i}]") for i, _ in enumerate(versions)]
58+
59+
repo.working_dir = Path("/tmp/bogus")
60+
repo.iter_commits.return_value = commits
61+
62+
strategy = MagicMock(spec=VersionStrategy)
63+
strategy.watch_path = "meep.txt"
64+
strategy.get_version.side_effect = versions
65+
66+
# noinspection PyTypeChecker
67+
iterator = iter_version_commits(repo, strategy=strategy)
68+
assert isinstance(iterator, Iterator)
69+
70+
with caplog.at_level(logging.WARNING):
71+
result = list(iterator)
72+
73+
assert result == [
74+
VersionCommit(version="1.1.1", commit=commits[2]),
75+
VersionCommit(version="1.1.0", commit=commits[6]),
76+
VersionCommit(version="1.0.9", commit=commits[7]),
77+
]
78+
assert "unexpected ValueError while parsing commit <MockCommit commits[10]>: MEEP" in caplog.text
79+
assert repo.iter_commits.mock_calls == [call(paths=Path("/tmp/bogus/meep.txt"))]

0 commit comments

Comments
 (0)