vbumper 0.1.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.
- vbumper-0.1.0/PKG-INFO +41 -0
- vbumper-0.1.0/README.md +26 -0
- vbumper-0.1.0/pyproject.toml +22 -0
- vbumper-0.1.0/src/vbumper/__init__.py +27 -0
- vbumper-0.1.0/src/vbumper/bump_type.py +90 -0
- vbumper-0.1.0/src/vbumper/git.py +337 -0
- vbumper-0.1.0/src/vbumper/main.py +59 -0
- vbumper-0.1.0/src/vbumper/vbumper.py +196 -0
- vbumper-0.1.0/src/vbumper/versions.py +353 -0
vbumper-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: vbumper
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Comprehensive version bumper for Python projects
|
|
5
|
+
Author: Danny Stewart
|
|
6
|
+
Author-email: danny@stewart.cc
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
11
|
+
Requires-Dist: dsbase (>=0.2.2,<0.3.0)
|
|
12
|
+
Project-URL: Repository, https://github.com/dannystewart/dsbin
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# vbumper
|
|
16
|
+
|
|
17
|
+
A comprehensive version management tool for Python projects.
|
|
18
|
+
|
|
19
|
+
It handles version bumping in a wide variety of formats, including pre-releases, development versions, and Git operations following PEP 440. It supports major.minor.patch versioning, with pre-release and post-release versions including dev, alpha, beta, rc, and post.
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
# Regular version bumping
|
|
25
|
+
vbumper # 1.2.3 -> 1.2.4
|
|
26
|
+
vbumper minor # 1.2.3 -> 1.3.0
|
|
27
|
+
vbumper major # 1.2.3 -> 2.0.0
|
|
28
|
+
|
|
29
|
+
# Pre-release versions
|
|
30
|
+
vbumper dev # 1.2.3 -> 1.2.4.dev0
|
|
31
|
+
vbumper alpha # 1.2.3 -> 1.2.4a1
|
|
32
|
+
vbumper beta # 1.2.4a1 -> 1.2.4b1
|
|
33
|
+
vbumper rc # 1.2.4b1 -> 1.2.4rc1
|
|
34
|
+
vbumper patch # 1.2.4rc1 -> 1.2.4
|
|
35
|
+
|
|
36
|
+
# Post-release version
|
|
37
|
+
vbumper post # 1.2.4 -> 1.2.4.post1
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
All operations also include Git tagging and pushing changes to remote repository.
|
|
41
|
+
|
vbumper-0.1.0/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# vbumper
|
|
2
|
+
|
|
3
|
+
A comprehensive version management tool for Python projects.
|
|
4
|
+
|
|
5
|
+
It handles version bumping in a wide variety of formats, including pre-releases, development versions, and Git operations following PEP 440. It supports major.minor.patch versioning, with pre-release and post-release versions including dev, alpha, beta, rc, and post.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
# Regular version bumping
|
|
11
|
+
vbumper # 1.2.3 -> 1.2.4
|
|
12
|
+
vbumper minor # 1.2.3 -> 1.3.0
|
|
13
|
+
vbumper major # 1.2.3 -> 2.0.0
|
|
14
|
+
|
|
15
|
+
# Pre-release versions
|
|
16
|
+
vbumper dev # 1.2.3 -> 1.2.4.dev0
|
|
17
|
+
vbumper alpha # 1.2.3 -> 1.2.4a1
|
|
18
|
+
vbumper beta # 1.2.4a1 -> 1.2.4b1
|
|
19
|
+
vbumper rc # 1.2.4b1 -> 1.2.4rc1
|
|
20
|
+
vbumper patch # 1.2.4rc1 -> 1.2.4
|
|
21
|
+
|
|
22
|
+
# Post-release version
|
|
23
|
+
vbumper post # 1.2.4 -> 1.2.4.post1
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
All operations also include Git tagging and pushing changes to remote repository.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "vbumper"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Comprehensive version bumper for Python projects"
|
|
5
|
+
authors = [{ name = "Danny Stewart", email = "danny@stewart.cc" }]
|
|
6
|
+
urls = { repository = "https://github.com/dannystewart/dsbin" }
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"dsbase (>=0.2.2,<0.3.0)",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[tool.poetry.group.dev.dependencies]
|
|
14
|
+
mypy = ">=1.15.0"
|
|
15
|
+
ruff = ">=0.11.2"
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["poetry-core>=2.0"]
|
|
19
|
+
build-backend = "poetry.core.masonry.api"
|
|
20
|
+
|
|
21
|
+
[project.scripts]
|
|
22
|
+
vbumper = "vbumper.main:main"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Version management tool for Python projects.
|
|
2
|
+
|
|
3
|
+
Handles version bumping, pre-releases, development versions, and git operations following PEP 440.
|
|
4
|
+
Supports major.minor.patch versioning with dev/alpha/beta/rc prerelease (and post-release) versions.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
# Regular version bumping
|
|
8
|
+
dsbump # 1.2.3 -> 1.2.4
|
|
9
|
+
dsbump minor # 1.2.3 -> 1.3.0
|
|
10
|
+
dsbump major # 1.2.3 -> 2.0.0
|
|
11
|
+
|
|
12
|
+
# Pre-release versions
|
|
13
|
+
dsbump dev # 1.2.3 -> 1.2.4.dev0
|
|
14
|
+
dsbump alpha # 1.2.3 -> 1.2.4a1
|
|
15
|
+
dsbump beta # 1.2.4a1 -> 1.2.4b1
|
|
16
|
+
dsbump rc # 1.2.4b1 -> 1.2.4rc1
|
|
17
|
+
dsbump patch # 1.2.4rc1 -> 1.2.4
|
|
18
|
+
|
|
19
|
+
# Post-release version
|
|
20
|
+
dsbump post # 1.2.4 -> 1.2.4.post1
|
|
21
|
+
|
|
22
|
+
All operations include git tagging and pushing changes to remote repository.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from .vbumper import VersionBumper
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
from functools import total_ordering
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@total_ordering
|
|
8
|
+
class BumpType(StrEnum):
|
|
9
|
+
"""Version bump types following PEP 440.
|
|
10
|
+
|
|
11
|
+
Progression:
|
|
12
|
+
- Pre-release: dev -> alpha -> beta -> rc
|
|
13
|
+
- Release: patch -> minor -> major
|
|
14
|
+
- Post-release: post (only after final release)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
DEV = "dev"
|
|
18
|
+
ALPHA = "alpha"
|
|
19
|
+
BETA = "beta"
|
|
20
|
+
RC = "rc"
|
|
21
|
+
POST = "post"
|
|
22
|
+
PATCH = "patch"
|
|
23
|
+
MINOR = "minor"
|
|
24
|
+
MAJOR = "major"
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def is_prerelease(self) -> bool:
|
|
28
|
+
"""Whether this is a pre-release version type."""
|
|
29
|
+
return self in {self.DEV, self.ALPHA, self.BETA, self.RC}
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def is_release(self) -> bool:
|
|
33
|
+
"""Whether this is a regular release version type."""
|
|
34
|
+
return self in {self.PATCH, self.MINOR, self.MAJOR}
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def version_suffix(self) -> str:
|
|
38
|
+
"""Get the suffix used in version strings."""
|
|
39
|
+
match self:
|
|
40
|
+
case self.DEV:
|
|
41
|
+
return ".dev"
|
|
42
|
+
case self.ALPHA:
|
|
43
|
+
return "a"
|
|
44
|
+
case self.BETA:
|
|
45
|
+
return "b"
|
|
46
|
+
case self.RC:
|
|
47
|
+
return "rc"
|
|
48
|
+
case self.POST:
|
|
49
|
+
return ".post"
|
|
50
|
+
case _:
|
|
51
|
+
return ""
|
|
52
|
+
|
|
53
|
+
def sort_value(self) -> int:
|
|
54
|
+
"""Get numeric sort value for comparison."""
|
|
55
|
+
order = {
|
|
56
|
+
self.DEV: -1,
|
|
57
|
+
self.ALPHA: 0,
|
|
58
|
+
self.BETA: 1,
|
|
59
|
+
self.RC: 2,
|
|
60
|
+
self.POST: 10,
|
|
61
|
+
self.PATCH: 3,
|
|
62
|
+
self.MINOR: 4,
|
|
63
|
+
self.MAJOR: 5,
|
|
64
|
+
}
|
|
65
|
+
return order[self]
|
|
66
|
+
|
|
67
|
+
def __lt__(self, other: BumpType | str) -> bool:
|
|
68
|
+
"""Compare bump types for ordering."""
|
|
69
|
+
try:
|
|
70
|
+
other = BumpType(other)
|
|
71
|
+
except ValueError:
|
|
72
|
+
return NotImplemented
|
|
73
|
+
return self.sort_value() < other.sort_value()
|
|
74
|
+
|
|
75
|
+
def can_progress_to(self, other: BumpType) -> bool:
|
|
76
|
+
"""Check if this version type can progress to another."""
|
|
77
|
+
# Can't go backwards in pre-release chain
|
|
78
|
+
if self.is_prerelease and other.is_prerelease:
|
|
79
|
+
return self.sort_value() < other.sort_value()
|
|
80
|
+
|
|
81
|
+
# Can't add post-release to pre-release
|
|
82
|
+
if self.is_prerelease and other == self.POST:
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
# Can always go to a release version
|
|
86
|
+
if other.is_release:
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
# Can add post-release to release versions
|
|
90
|
+
return bool(self.is_release and other == self.POST)
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from dsbase.util import handle_interrupt
|
|
10
|
+
|
|
11
|
+
from vbumper.bump_type import BumpType
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from logging import Logger
|
|
15
|
+
|
|
16
|
+
from vbumper.versions import VersionHelper
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class GitHelper:
|
|
21
|
+
"""Helper class for git operations."""
|
|
22
|
+
|
|
23
|
+
version_helper: VersionHelper
|
|
24
|
+
logger: Logger
|
|
25
|
+
commit_message: str | None = None
|
|
26
|
+
cleanup_tags: bool = False
|
|
27
|
+
push_to_remote: bool = True
|
|
28
|
+
|
|
29
|
+
@handle_interrupt()
|
|
30
|
+
def check_git_state(self) -> None:
|
|
31
|
+
"""Check if we're in a git repository and on a valid branch."""
|
|
32
|
+
try: # Check if we're in a git repo
|
|
33
|
+
subprocess.run(["git", "rev-parse", "--git-dir"], check=True, capture_output=True)
|
|
34
|
+
except subprocess.CalledProcessError:
|
|
35
|
+
self.logger.error("Not a git repository.")
|
|
36
|
+
sys.exit(1)
|
|
37
|
+
|
|
38
|
+
# Check if we're on a branch (not in detached HEAD state)
|
|
39
|
+
result = subprocess.run(
|
|
40
|
+
["git", "symbolic-ref", "--short", "HEAD"], capture_output=True, text=True, check=False
|
|
41
|
+
)
|
|
42
|
+
if result.returncode != 0:
|
|
43
|
+
self.logger.error("Not on a git branch (detached HEAD state).")
|
|
44
|
+
sys.exit(1)
|
|
45
|
+
|
|
46
|
+
@handle_interrupt()
|
|
47
|
+
def detect_version_prefix(self) -> str:
|
|
48
|
+
"""Detect whether versions are tagged with 'v' prefix based on existing tags.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
"v" if versions use v-prefix, "" if they use bare numbers
|
|
52
|
+
"""
|
|
53
|
+
try:
|
|
54
|
+
# Get all tags sorted by version
|
|
55
|
+
result = subprocess.run(
|
|
56
|
+
["git", "tag", "--sort=v:refname"], capture_output=True, text=True, check=True
|
|
57
|
+
)
|
|
58
|
+
tags = result.stdout.strip().split("\n")
|
|
59
|
+
|
|
60
|
+
# Filter out empty results
|
|
61
|
+
tags = [tag for tag in tags if tag]
|
|
62
|
+
if not tags:
|
|
63
|
+
# Default to "v" prefix for new projects
|
|
64
|
+
return "v"
|
|
65
|
+
|
|
66
|
+
# Look at the most recent tag that starts with either v or a number
|
|
67
|
+
for tag in reversed(tags):
|
|
68
|
+
if tag.startswith("v") or tag[0].isdigit():
|
|
69
|
+
return "v" if tag.startswith("v") else ""
|
|
70
|
+
|
|
71
|
+
# If no matching tags found, default to "v" prefix
|
|
72
|
+
return "v"
|
|
73
|
+
|
|
74
|
+
except subprocess.CalledProcessError:
|
|
75
|
+
# If git commands fail, default to "v" prefix
|
|
76
|
+
return "v"
|
|
77
|
+
|
|
78
|
+
@handle_interrupt()
|
|
79
|
+
def tag_current_version(self) -> None:
|
|
80
|
+
"""Tag and push the current version without incrementing.
|
|
81
|
+
|
|
82
|
+
Creates a new commit with the current version number, then tags and pushes it.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
commit_message: Custom commit message (if None, default is used).
|
|
86
|
+
"""
|
|
87
|
+
pyproject = Path("pyproject.toml")
|
|
88
|
+
if not pyproject.exists():
|
|
89
|
+
self.logger.error("No pyproject.toml found in current directory.")
|
|
90
|
+
sys.exit(1)
|
|
91
|
+
|
|
92
|
+
self.check_git_state()
|
|
93
|
+
current_version = self.version_helper.get_version()
|
|
94
|
+
version_prefix = self.detect_version_prefix()
|
|
95
|
+
tag_name = f"{version_prefix}{current_version}"
|
|
96
|
+
|
|
97
|
+
# Check if tag already exists
|
|
98
|
+
if (
|
|
99
|
+
subprocess.run(
|
|
100
|
+
["git", "rev-parse", tag_name], capture_output=True, check=False
|
|
101
|
+
).returncode
|
|
102
|
+
== 0
|
|
103
|
+
):
|
|
104
|
+
self.logger.error("Tag %s already exists.", tag_name)
|
|
105
|
+
sys.exit(1)
|
|
106
|
+
|
|
107
|
+
# Create a new commit with the current version number
|
|
108
|
+
has_other_changes = self.commit_version_change(current_version)
|
|
109
|
+
if has_other_changes:
|
|
110
|
+
self.logger.info(
|
|
111
|
+
"Committed pyproject.toml without version change. "
|
|
112
|
+
"Other changes in the working directory were skipped and preserved."
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Create tag
|
|
116
|
+
subprocess.run(["git", "tag", tag_name], check=True)
|
|
117
|
+
|
|
118
|
+
# Push changes and tags
|
|
119
|
+
subprocess.run(["git", "push"], check=True)
|
|
120
|
+
subprocess.run(["git", "push", "--tags"], check=True)
|
|
121
|
+
|
|
122
|
+
self.logger.info("Successfully tagged and pushed version %s!", current_version)
|
|
123
|
+
|
|
124
|
+
@handle_interrupt()
|
|
125
|
+
def perform_tag_cleanup(self, old_ver: str, new_ver: str) -> None:
|
|
126
|
+
"""Remove all pre-release tags for relevant versions.
|
|
127
|
+
|
|
128
|
+
Removes tags based on version bump type:
|
|
129
|
+
- Major bump (1.x -> 2.x): Removes all 1.x pre-release tags
|
|
130
|
+
- Minor bump (1.1 -> 1.2): Removes all 1.1.x pre-release tags
|
|
131
|
+
- Patch bump (1.1.1 -> 1.1.2): Removes only 1.1.2 pre-release tags
|
|
132
|
+
"""
|
|
133
|
+
self.logger.debug(
|
|
134
|
+
"Checking for pre-release tags to clean up when moving from %s to %s.",
|
|
135
|
+
old_ver,
|
|
136
|
+
new_ver,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
patterns = self._identify_tag_patterns(old_ver, new_ver)
|
|
140
|
+
|
|
141
|
+
all_tags = set()
|
|
142
|
+
for pattern in patterns:
|
|
143
|
+
result = subprocess.run(
|
|
144
|
+
["git", "tag", "-l", pattern], capture_output=True, text=True, check=True
|
|
145
|
+
)
|
|
146
|
+
tags = result.stdout.strip().split("\n")
|
|
147
|
+
if tags and tags[0]: # Check if we actually found any tags
|
|
148
|
+
all_tags.update(tags)
|
|
149
|
+
|
|
150
|
+
if all_tags:
|
|
151
|
+
self.logger.info("Cleaning up %d pre-release tags.", len(all_tags))
|
|
152
|
+
self._remove_found_tags(all_tags)
|
|
153
|
+
|
|
154
|
+
def _identify_tag_patterns(self, old_ver: str, new_ver: str) -> list[str]:
|
|
155
|
+
"""Find tag patterns to clean up based on version bump.
|
|
156
|
+
|
|
157
|
+
Determines which pre-release tags should be cleaned up based on the type of version bump
|
|
158
|
+
(major/minor/patch).
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
List of glob patterns matching tags to be removed.
|
|
162
|
+
"""
|
|
163
|
+
old = self.version_helper.parse_version(old_ver)
|
|
164
|
+
new = self.version_helper.parse_version(new_ver)
|
|
165
|
+
|
|
166
|
+
version_prefix = self.detect_version_prefix()
|
|
167
|
+
|
|
168
|
+
prerelease_patterns = [
|
|
169
|
+
t.version_suffix + "*"
|
|
170
|
+
for t in [BumpType.DEV, BumpType.ALPHA, BumpType.BETA, BumpType.RC]
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
patterns = []
|
|
174
|
+
if new.major > old.major:
|
|
175
|
+
patterns.extend(
|
|
176
|
+
f"{version_prefix}{old.major}.*{pattern}" for pattern in prerelease_patterns
|
|
177
|
+
)
|
|
178
|
+
elif new.minor > old.minor:
|
|
179
|
+
patterns.extend(
|
|
180
|
+
f"{version_prefix}{old.major}.{old.minor}.*{pattern}"
|
|
181
|
+
for pattern in prerelease_patterns
|
|
182
|
+
)
|
|
183
|
+
else:
|
|
184
|
+
patterns.extend(
|
|
185
|
+
f"{version_prefix}{new.major}.{new.minor}.{new.patch}{pattern}"
|
|
186
|
+
for pattern in prerelease_patterns
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
return patterns
|
|
190
|
+
|
|
191
|
+
@handle_interrupt()
|
|
192
|
+
def _remove_found_tags(self, found_tags: set[str]) -> None:
|
|
193
|
+
"""Remove identified pre-release tags.
|
|
194
|
+
|
|
195
|
+
Removes tags both locally and from remote if it exists. Remote tag deletion failures are
|
|
196
|
+
ignored as tags might not exist remotely.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
found_tags: Set of tag names to remove.
|
|
200
|
+
"""
|
|
201
|
+
# Remove local tags
|
|
202
|
+
for tag in found_tags:
|
|
203
|
+
self.logger.info("Removing tag: %s", tag)
|
|
204
|
+
subprocess.run(["git", "tag", "-d", tag], check=True)
|
|
205
|
+
|
|
206
|
+
# Remove remote tags if remote exists
|
|
207
|
+
remote_check = subprocess.run(
|
|
208
|
+
["git", "remote"], capture_output=True, text=True, check=False
|
|
209
|
+
)
|
|
210
|
+
if remote_check.stdout.strip():
|
|
211
|
+
self.logger.debug("Removing remote tags...")
|
|
212
|
+
subprocess.run(
|
|
213
|
+
["git", "push", "--delete", "origin", *list(found_tags)],
|
|
214
|
+
capture_output=True,
|
|
215
|
+
check=False,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def commit_version_change(self, new_version: str) -> bool:
|
|
219
|
+
"""Commit version change to git.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
new_version: The new version string.
|
|
223
|
+
commit_message: Optional custom commit message.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
True if there were other uncommitted changes, False otherwise.
|
|
227
|
+
"""
|
|
228
|
+
# Check for uncommitted changes
|
|
229
|
+
result = subprocess.run(
|
|
230
|
+
["git", "status", "--porcelain"], capture_output=True, text=True, check=True
|
|
231
|
+
)
|
|
232
|
+
has_other_changes = any(
|
|
233
|
+
not line.endswith("pyproject.toml") for line in result.stdout.splitlines()
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# Stage only pyproject.toml
|
|
237
|
+
subprocess.run(["git", "add", "pyproject.toml"], check=True)
|
|
238
|
+
|
|
239
|
+
# Use custom message if provided, otherwise use default
|
|
240
|
+
message = self.commit_message or f"Bump version to {new_version}"
|
|
241
|
+
subprocess.run(["git", "commit", "-m", message], check=True)
|
|
242
|
+
|
|
243
|
+
return has_other_changes
|
|
244
|
+
|
|
245
|
+
def should_perform_cleanup(
|
|
246
|
+
self, bump_type: BumpType | str | list[BumpType] | None, new_ver: str
|
|
247
|
+
) -> bool:
|
|
248
|
+
"""Determine if tag cleanup should be performed.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
bump_type: The type of version bump performed.
|
|
252
|
+
new_ver: The new version string.
|
|
253
|
+
"""
|
|
254
|
+
if isinstance(bump_type, list):
|
|
255
|
+
# If the last bump type in the list isn't a pre-release, clean up tags
|
|
256
|
+
if not bump_type:
|
|
257
|
+
return False
|
|
258
|
+
return not bump_type[-1].is_prerelease
|
|
259
|
+
|
|
260
|
+
if isinstance(bump_type, BumpType):
|
|
261
|
+
version = self.version_helper.parse_version(new_ver)
|
|
262
|
+
# If new version is a release version
|
|
263
|
+
return version.pre_type is None
|
|
264
|
+
|
|
265
|
+
if isinstance(bump_type, str) and bump_type.count(".") >= 2: # Explicit version
|
|
266
|
+
return not any(
|
|
267
|
+
t.version_suffix in bump_type
|
|
268
|
+
for t in [BumpType.DEV, BumpType.ALPHA, BumpType.BETA, BumpType.RC]
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
return False
|
|
272
|
+
|
|
273
|
+
def create_and_push_tag(self, tag_name: str) -> None:
|
|
274
|
+
"""Create and push a git tag."""
|
|
275
|
+
if ( # Check if tag already exists
|
|
276
|
+
subprocess.run(
|
|
277
|
+
["git", "rev-parse", tag_name], capture_output=True, check=False
|
|
278
|
+
).returncode
|
|
279
|
+
== 0
|
|
280
|
+
):
|
|
281
|
+
self.logger.error("Tag %s already exists.", tag_name)
|
|
282
|
+
sys.exit(1)
|
|
283
|
+
|
|
284
|
+
# Create tag and push
|
|
285
|
+
subprocess.run(["git", "tag", tag_name], check=True)
|
|
286
|
+
subprocess.run(["git", "push"], check=True)
|
|
287
|
+
subprocess.run(["git", "push", "--tags"], check=True)
|
|
288
|
+
|
|
289
|
+
@handle_interrupt()
|
|
290
|
+
def handle_git_operations(
|
|
291
|
+
self,
|
|
292
|
+
new_version: str,
|
|
293
|
+
bump_type: BumpType | str | list[BumpType] | None,
|
|
294
|
+
current_version: str,
|
|
295
|
+
) -> None:
|
|
296
|
+
"""Handle git commit, tag, and push operations.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
new_version: The version string to tag with.
|
|
300
|
+
bump_type: The type of version bump performed.
|
|
301
|
+
current_version: The previous version string.
|
|
302
|
+
"""
|
|
303
|
+
version_prefix = self.detect_version_prefix()
|
|
304
|
+
tag_name = f"{version_prefix}{new_version}"
|
|
305
|
+
|
|
306
|
+
# Handle version bump commit if needed
|
|
307
|
+
if bump_type is not None:
|
|
308
|
+
has_other_changes = self.commit_version_change(new_version)
|
|
309
|
+
if has_other_changes:
|
|
310
|
+
self.logger.info(
|
|
311
|
+
"Committed pyproject.toml with the version bump. "
|
|
312
|
+
"Other changes in the working directory were skipped and preserved."
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# Clean up pre-release tags when moving to a release version (if cleanup=True)
|
|
316
|
+
if self.cleanup_tags and self.should_perform_cleanup(bump_type, new_version):
|
|
317
|
+
self.perform_tag_cleanup(current_version, new_version)
|
|
318
|
+
|
|
319
|
+
# Create tag
|
|
320
|
+
if ( # Check if tag already exists
|
|
321
|
+
subprocess.run(
|
|
322
|
+
["git", "rev-parse", tag_name], capture_output=True, check=False
|
|
323
|
+
).returncode
|
|
324
|
+
== 0
|
|
325
|
+
):
|
|
326
|
+
self.logger.error("Tag %s already exists.", tag_name)
|
|
327
|
+
sys.exit(1)
|
|
328
|
+
|
|
329
|
+
subprocess.run(["git", "tag", tag_name], check=True)
|
|
330
|
+
|
|
331
|
+
if self.push_to_remote: # Push changes and tags
|
|
332
|
+
subprocess.run(["git", "push"], check=True)
|
|
333
|
+
subprocess.run(["git", "push", "--tags"], check=True)
|
|
334
|
+
else:
|
|
335
|
+
self.logger.info(
|
|
336
|
+
"Changes committed and tagged locally. Use 'git push && git push --tags' to push to remote."
|
|
337
|
+
)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Version management tool for Python projects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from dsbase.util import dsbase_setup
|
|
8
|
+
|
|
9
|
+
from vbumper import VersionBumper
|
|
10
|
+
from vbumper.bump_type import BumpType
|
|
11
|
+
|
|
12
|
+
dsbase_setup()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_args() -> argparse.Namespace:
|
|
16
|
+
"""Parse command-line arguments."""
|
|
17
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
18
|
+
parser.add_argument(
|
|
19
|
+
"type",
|
|
20
|
+
nargs="*",
|
|
21
|
+
default=[BumpType.PATCH],
|
|
22
|
+
help="version bump type(s): major, minor, patch, dev, alpha, beta, rc, post, or x.y.z",
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument("-f", "--force", action="store_true", help="skip confirmation prompt")
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--cleanup",
|
|
27
|
+
action="store_true",
|
|
28
|
+
help="clean up pre-release tags when finalizing a version",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"-m",
|
|
32
|
+
"--message",
|
|
33
|
+
help="custom commit message (default: 'Bump version to x.y.z')",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Mutually exclusive group for push options
|
|
37
|
+
push_group = parser.add_mutually_exclusive_group()
|
|
38
|
+
push_group.add_argument(
|
|
39
|
+
"--keep-version",
|
|
40
|
+
action="store_true",
|
|
41
|
+
help="tag and push the current version without incrementing",
|
|
42
|
+
)
|
|
43
|
+
push_group.add_argument(
|
|
44
|
+
"--no-push",
|
|
45
|
+
action="store_true",
|
|
46
|
+
help="commit and tag changes but don't push to remote",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
return parser.parse_args()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def main() -> None:
|
|
53
|
+
"""Perform version bump."""
|
|
54
|
+
args = parse_args()
|
|
55
|
+
VersionBumper(args).perform_bump()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
if __name__ == "__main__":
|
|
59
|
+
main()
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Version management tool for Python projects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from dsbase.env import DSEnv
|
|
10
|
+
from dsbase.log import LocalLogger
|
|
11
|
+
from dsbase.shell import confirm_action
|
|
12
|
+
from dsbase.util import dsbase_setup, handle_interrupt
|
|
13
|
+
|
|
14
|
+
from vbumper.bump_type import BumpType
|
|
15
|
+
from vbumper.git import GitHelper
|
|
16
|
+
from vbumper.versions import VersionHelper
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
import argparse
|
|
20
|
+
|
|
21
|
+
dsbase_setup()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class VersionBumper:
|
|
25
|
+
"""Version management tool for Python projects."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, args: argparse.Namespace) -> None:
|
|
28
|
+
# Create DSEnv to manage debug flag
|
|
29
|
+
env = DSEnv()
|
|
30
|
+
env.add_debug_var()
|
|
31
|
+
|
|
32
|
+
# Create logger with debug flag; use simple logger if debug is off
|
|
33
|
+
self.logger = LocalLogger().get_logger(
|
|
34
|
+
level="debug" if env.debug else "info", simple=not env.debug
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Parse command-line arguments into instance variables
|
|
38
|
+
self.keep_version = args.keep_version
|
|
39
|
+
self.force = args.force
|
|
40
|
+
self.type = args.type
|
|
41
|
+
|
|
42
|
+
# Whether to push changes to remote (default is True unless --no-push is specified)
|
|
43
|
+
self.push_to_remote = not args.no_push
|
|
44
|
+
|
|
45
|
+
# Verify and load pyproject.toml
|
|
46
|
+
self.pyproject_path = Path("pyproject.toml")
|
|
47
|
+
if not self.pyproject_path.exists():
|
|
48
|
+
self.logger.error("No pyproject.toml found in current directory.")
|
|
49
|
+
sys.exit(1)
|
|
50
|
+
|
|
51
|
+
# Initialize helpers
|
|
52
|
+
self.version_helper = VersionHelper(self.pyproject_path, self.logger)
|
|
53
|
+
self.git = GitHelper(
|
|
54
|
+
self.version_helper, self.logger, args.message, args.cleanup, self.push_to_remote
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Get current version as a Version object
|
|
58
|
+
self.current_version = self.version_helper.get_version_object()
|
|
59
|
+
self.current_ver_str = str(self.current_version)
|
|
60
|
+
|
|
61
|
+
def perform_bump(self) -> None:
|
|
62
|
+
"""Perform version bump."""
|
|
63
|
+
try:
|
|
64
|
+
# Handle --keep-version flag (tag current version without incrementing)
|
|
65
|
+
if self.keep_version:
|
|
66
|
+
if self.type and self.type != [BumpType.PATCH.value]:
|
|
67
|
+
self.logger.error("--keep-version cannot be used with version bump arguments")
|
|
68
|
+
sys.exit(1)
|
|
69
|
+
self.git.tag_current_version()
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
# Default to patch if no types specified
|
|
73
|
+
type_args = self.type or [BumpType.PATCH.value]
|
|
74
|
+
bump_type = self.version_helper.parse_bump_types(type_args)
|
|
75
|
+
|
|
76
|
+
# Calculate new version
|
|
77
|
+
new_version_obj = self.current_version
|
|
78
|
+
|
|
79
|
+
# If we have multiple bump types, sort them in a consistent order
|
|
80
|
+
if isinstance(bump_type, list):
|
|
81
|
+
# Sort and apply bumps in logical order
|
|
82
|
+
sorted_bumps = self._sort_bump_types(bump_type)
|
|
83
|
+
for bt in sorted_bumps:
|
|
84
|
+
new_version_obj = self.version_helper.bump_version(bt, new_version_obj)
|
|
85
|
+
else:
|
|
86
|
+
new_version_obj = self.version_helper.bump_version(bump_type, self.current_version)
|
|
87
|
+
|
|
88
|
+
new_version_str = str(new_version_obj)
|
|
89
|
+
|
|
90
|
+
# Show version info
|
|
91
|
+
self.logger.info("Current version: %s", self.current_ver_str)
|
|
92
|
+
self.logger.info("Will bump to: %s", new_version_str)
|
|
93
|
+
|
|
94
|
+
# Prompt for confirmation unless --force is used
|
|
95
|
+
if not self.force:
|
|
96
|
+
if not confirm_action("Proceed with version bump?"):
|
|
97
|
+
self.logger.info("Version bump cancelled.")
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
self.update_version(bump_type, new_version_str)
|
|
101
|
+
except Exception as e:
|
|
102
|
+
self.logger.error(str(e))
|
|
103
|
+
sys.exit(1)
|
|
104
|
+
|
|
105
|
+
def _sort_bump_types(self, bump_types: list[BumpType]) -> list[BumpType]:
|
|
106
|
+
"""Sort bump types in logical order: major/minor/patch, then pre-release, then post."""
|
|
107
|
+
# First apply all regular version bumps (major, minor, patch) in that order
|
|
108
|
+
regular_bumps = [bt for bt in bump_types if bt.is_release]
|
|
109
|
+
# Sort by priority (major > minor > patch)
|
|
110
|
+
regular_bumps.sort(reverse=True)
|
|
111
|
+
|
|
112
|
+
# Then apply all pre-release bumps in order (dev, alpha, beta, rc)
|
|
113
|
+
prerelease_bumps = [bt for bt in bump_types if bt.is_prerelease]
|
|
114
|
+
prerelease_bumps.sort()
|
|
115
|
+
|
|
116
|
+
# Finally apply post if present
|
|
117
|
+
post_bumps = [bt for bt in bump_types if bt == BumpType.POST]
|
|
118
|
+
|
|
119
|
+
# Combine in the right order
|
|
120
|
+
return regular_bumps + prerelease_bumps + post_bumps
|
|
121
|
+
|
|
122
|
+
@handle_interrupt()
|
|
123
|
+
def update_version(self, bump_type: BumpType | str | list[BumpType], new_version: str) -> None:
|
|
124
|
+
"""Update version, create git tag, and push changes.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
bump_type: The version's BumpType or list of BumpTypes, or a specific version string.
|
|
128
|
+
new_version: The calculated new version string.
|
|
129
|
+
"""
|
|
130
|
+
try:
|
|
131
|
+
self.git.check_git_state()
|
|
132
|
+
|
|
133
|
+
# Update version in pyproject.toml
|
|
134
|
+
if bump_type is not None:
|
|
135
|
+
self._update_version_in_pyproject(self.pyproject_path, new_version)
|
|
136
|
+
|
|
137
|
+
# Handle git operations
|
|
138
|
+
self.git.handle_git_operations(new_version, bump_type, self.current_ver_str)
|
|
139
|
+
|
|
140
|
+
# Log success
|
|
141
|
+
action = "tagged" if bump_type is None else "updated to"
|
|
142
|
+
push_status = "" if self.push_to_remote else " (not pushed)"
|
|
143
|
+
self.logger.info("Successfully %s v%s%s!", action, new_version, push_status)
|
|
144
|
+
|
|
145
|
+
except Exception as e:
|
|
146
|
+
self.logger.error("Version update failed: %s", str(e))
|
|
147
|
+
raise
|
|
148
|
+
|
|
149
|
+
def _update_version_in_pyproject(self, pyproject: Path, new_version: str) -> None:
|
|
150
|
+
"""Update version in pyproject.toml while preserving formatting."""
|
|
151
|
+
content = pyproject.read_text()
|
|
152
|
+
lines = content.splitlines()
|
|
153
|
+
|
|
154
|
+
# Find the version line
|
|
155
|
+
version_line_idx = None
|
|
156
|
+
in_project = False
|
|
157
|
+
|
|
158
|
+
for i, line in enumerate(lines):
|
|
159
|
+
stripped = line.strip()
|
|
160
|
+
if stripped.startswith("[project]"):
|
|
161
|
+
in_project = True
|
|
162
|
+
elif stripped.startswith("["): # Any other section
|
|
163
|
+
in_project = False
|
|
164
|
+
|
|
165
|
+
if in_project and stripped.startswith("version"):
|
|
166
|
+
version_line_idx = i
|
|
167
|
+
break
|
|
168
|
+
|
|
169
|
+
if version_line_idx is None:
|
|
170
|
+
self.logger.error("Could not find version field in project section.")
|
|
171
|
+
sys.exit(1)
|
|
172
|
+
|
|
173
|
+
# Update the version line while preserving indentation
|
|
174
|
+
current_line = lines[version_line_idx]
|
|
175
|
+
if "=" in current_line:
|
|
176
|
+
before_version = current_line.split("=")[0]
|
|
177
|
+
quote_char = '"' if '"' in current_line else "'"
|
|
178
|
+
lines[version_line_idx] = f"{before_version}= {quote_char}{new_version}{quote_char}"
|
|
179
|
+
|
|
180
|
+
# Verify the new content is valid TOML before writing
|
|
181
|
+
new_content = "\n".join(lines) + "\n"
|
|
182
|
+
try:
|
|
183
|
+
import tomllib
|
|
184
|
+
|
|
185
|
+
tomllib.loads(new_content)
|
|
186
|
+
except tomllib.TOMLDecodeError:
|
|
187
|
+
self.logger.error("Version update would create invalid TOML. Aborting.")
|
|
188
|
+
sys.exit(1)
|
|
189
|
+
|
|
190
|
+
# Write back the file
|
|
191
|
+
pyproject.write_text(new_content)
|
|
192
|
+
|
|
193
|
+
# Verify the changes
|
|
194
|
+
if self.version_helper.get_version() != new_version:
|
|
195
|
+
self.logger.error("Version update failed verification.")
|
|
196
|
+
sys.exit(1)
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from vbumper.bump_type import BumpType
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from logging import Logger
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Version:
|
|
17
|
+
"""Represents a parsed version."""
|
|
18
|
+
|
|
19
|
+
version_string: str
|
|
20
|
+
major: int
|
|
21
|
+
minor: int
|
|
22
|
+
patch: int
|
|
23
|
+
pre_type: BumpType | None
|
|
24
|
+
pre_num: int | None
|
|
25
|
+
|
|
26
|
+
def __str__(self) -> str:
|
|
27
|
+
return self.version_string
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class VersionHelper:
|
|
32
|
+
"""Helper class for version-related operations."""
|
|
33
|
+
|
|
34
|
+
pyproject_path: Path
|
|
35
|
+
logger: Logger
|
|
36
|
+
|
|
37
|
+
def get_version(self) -> str:
|
|
38
|
+
"""Get current version from pyproject.toml."""
|
|
39
|
+
content = self.pyproject_path.read_text()
|
|
40
|
+
try:
|
|
41
|
+
import tomllib
|
|
42
|
+
|
|
43
|
+
data = tomllib.loads(content)
|
|
44
|
+
if "project" in data and "version" in data["project"]:
|
|
45
|
+
return data["project"]["version"]
|
|
46
|
+
self.logger.error("Could not find version in pyproject.toml (project.version).")
|
|
47
|
+
sys.exit(1)
|
|
48
|
+
except tomllib.TOMLDecodeError:
|
|
49
|
+
self.logger.error(
|
|
50
|
+
"Invalid TOML format in pyproject.toml. Do you even know how to use a text editor?"
|
|
51
|
+
)
|
|
52
|
+
sys.exit(1)
|
|
53
|
+
|
|
54
|
+
def get_version_object(self) -> Version:
|
|
55
|
+
"""Get current version from pyproject.toml as a Version object."""
|
|
56
|
+
version_str = self.get_version()
|
|
57
|
+
return self.parse_version(version_str)
|
|
58
|
+
|
|
59
|
+
def parse_version(self, version_str: str) -> Version:
|
|
60
|
+
"""Parse version string into a Version object."""
|
|
61
|
+
major, minor, patch, pre_type, pre_num = self._parse_version_components(version_str)
|
|
62
|
+
return Version(version_str, major, minor, patch, pre_type, pre_num)
|
|
63
|
+
|
|
64
|
+
def _parse_version_components(
|
|
65
|
+
self, version: str
|
|
66
|
+
) -> tuple[int, int, int, BumpType | None, int | None]:
|
|
67
|
+
"""Parse version string into components.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
version: Version string (e.g., '1.2.3', '1.2.3a1', '1.2.3.post1').
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Tuple of (major, minor, patch, pre-release type, pre-release number).
|
|
74
|
+
Pre-release type is BumpType.DEV/ALPHA/BETA/RC/POST or None.
|
|
75
|
+
Pre-release number can be None if no pre-release.
|
|
76
|
+
"""
|
|
77
|
+
# Handle post suffix (.postN)
|
|
78
|
+
if ".post" in version:
|
|
79
|
+
version_part, post_num = version.rsplit(".post", 1)
|
|
80
|
+
try:
|
|
81
|
+
pre_num = int(post_num)
|
|
82
|
+
except ValueError:
|
|
83
|
+
self.logger.error("Invalid post-release number: %s", post_num)
|
|
84
|
+
sys.exit(1)
|
|
85
|
+
major, minor, patch = map(int, version_part.split("."))
|
|
86
|
+
return major, minor, patch, BumpType.POST, pre_num
|
|
87
|
+
|
|
88
|
+
# Handle dev suffix (.devN)
|
|
89
|
+
if ".dev" in version:
|
|
90
|
+
version_part, dev_num = version.rsplit(".dev", 1)
|
|
91
|
+
try:
|
|
92
|
+
pre_num = int(dev_num)
|
|
93
|
+
except ValueError:
|
|
94
|
+
self.logger.error("Invalid dev number: %s", dev_num)
|
|
95
|
+
sys.exit(1)
|
|
96
|
+
major, minor, patch = map(int, version_part.split("."))
|
|
97
|
+
return major, minor, patch, BumpType.DEV, pre_num
|
|
98
|
+
|
|
99
|
+
# Handle pre-release suffixes (aN, bN, rcN)
|
|
100
|
+
suffix_map = {"a": BumpType.ALPHA, "b": BumpType.BETA, "rc": BumpType.RC}
|
|
101
|
+
for suffix, bump_type in suffix_map.items():
|
|
102
|
+
if suffix in version:
|
|
103
|
+
version_part, pre_num = version.rsplit(suffix, 1)
|
|
104
|
+
try:
|
|
105
|
+
major, minor, patch = map(int, version_part.split("."))
|
|
106
|
+
return major, minor, patch, bump_type, int(pre_num)
|
|
107
|
+
except ValueError:
|
|
108
|
+
self.logger.error("Invalid pre-release number: %s", pre_num)
|
|
109
|
+
sys.exit(1)
|
|
110
|
+
|
|
111
|
+
try: # Parse version numbers
|
|
112
|
+
major, minor, patch = map(int, version.split("."))
|
|
113
|
+
return major, minor, patch, None, None
|
|
114
|
+
except ValueError:
|
|
115
|
+
self.logger.error(
|
|
116
|
+
"Invalid version format: %s. Numbers go left to right, champ.", version
|
|
117
|
+
)
|
|
118
|
+
sys.exit(1)
|
|
119
|
+
|
|
120
|
+
def bump_version(self, bump_type: BumpType | str, version: Version) -> Version:
|
|
121
|
+
"""Calculate new version based on bump type and current version.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
bump_type: Version bump type (major/minor/patch/alpha/beta/rc) or specific version.
|
|
125
|
+
version: Current version object.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
New version object.
|
|
129
|
+
"""
|
|
130
|
+
# Handle explicit version numbers
|
|
131
|
+
if bump_type.count(".") >= 2:
|
|
132
|
+
self._handle_explicit_version(bump_type)
|
|
133
|
+
new_version_str = bump_type
|
|
134
|
+
return self.parse_version(new_version_str)
|
|
135
|
+
|
|
136
|
+
# Convert to enum if it's a string
|
|
137
|
+
bump_type_enum = BumpType(bump_type) if not isinstance(bump_type, BumpType) else bump_type
|
|
138
|
+
|
|
139
|
+
# Handle different bump types
|
|
140
|
+
if bump_type_enum == BumpType.MAJOR:
|
|
141
|
+
# Major bump resets minor and patch to 0
|
|
142
|
+
new_version_str = f"{version.major + 1}.0.0"
|
|
143
|
+
return self.parse_version(new_version_str)
|
|
144
|
+
|
|
145
|
+
if bump_type_enum == BumpType.MINOR:
|
|
146
|
+
# Minor bump resets patch to 0
|
|
147
|
+
new_version_str = f"{version.major}.{version.minor + 1}.0"
|
|
148
|
+
return self.parse_version(new_version_str)
|
|
149
|
+
|
|
150
|
+
if bump_type_enum == BumpType.PATCH:
|
|
151
|
+
# For a patch bump, we need to check if we're finalizing a pre-release
|
|
152
|
+
if version.pre_type and version.pre_type.is_prerelease:
|
|
153
|
+
# Finalizing a pre-release - keep same version but drop pre-release suffix
|
|
154
|
+
new_version_str = f"{version.major}.{version.minor}.{version.patch}"
|
|
155
|
+
else:
|
|
156
|
+
# Regular patch bump
|
|
157
|
+
new_version_str = f"{version.major}.{version.minor}.{version.patch + 1}"
|
|
158
|
+
return self.parse_version(new_version_str)
|
|
159
|
+
|
|
160
|
+
# Handle pre-release and post-release bumps
|
|
161
|
+
if bump_type_enum.is_prerelease or bump_type_enum == BumpType.POST:
|
|
162
|
+
# For dev, alpha, beta, rc, post
|
|
163
|
+
return self._handle_version_modifier(bump_type_enum, version)
|
|
164
|
+
|
|
165
|
+
self.logger.error("Invalid bump type: %s", bump_type)
|
|
166
|
+
sys.exit(1)
|
|
167
|
+
|
|
168
|
+
def _handle_special_bump(self, bump_type: BumpType | str, version: Version) -> Version:
|
|
169
|
+
"""Handle special bump types like dev, alpha, beta, rc, post."""
|
|
170
|
+
bump_type_enum = BumpType(bump_type) if not isinstance(bump_type, BumpType) else bump_type
|
|
171
|
+
|
|
172
|
+
# Determine what the next version should be based on the bump type
|
|
173
|
+
if bump_type_enum == BumpType.DEV:
|
|
174
|
+
# For dev versions, we want to start with .dev0
|
|
175
|
+
new_version_str = f"{version.major}.{version.minor}.{version.patch}.dev0"
|
|
176
|
+
elif bump_type_enum.is_prerelease:
|
|
177
|
+
# For other pre-releases, we increment to the next version
|
|
178
|
+
new_version_str = (
|
|
179
|
+
f"{version.major}.{version.minor}.{version.patch}{bump_type_enum.version_suffix}1"
|
|
180
|
+
)
|
|
181
|
+
elif bump_type_enum == BumpType.POST:
|
|
182
|
+
# For post-releases, we add .post1
|
|
183
|
+
new_version_str = f"{version.major}.{version.minor}.{version.patch}.post1"
|
|
184
|
+
else:
|
|
185
|
+
self.logger.error("Invalid bump type for post-release: %s", bump_type)
|
|
186
|
+
sys.exit(1)
|
|
187
|
+
|
|
188
|
+
return self.parse_version(new_version_str)
|
|
189
|
+
|
|
190
|
+
def _get_base_version(self, bump_type: BumpType | str, version: Version) -> Version:
|
|
191
|
+
"""Calculate base version based on bump type."""
|
|
192
|
+
if bump_type.count(".") >= 2:
|
|
193
|
+
return self.parse_version(bump_type)
|
|
194
|
+
|
|
195
|
+
# Now we know it's a BumpType
|
|
196
|
+
bump_type_enum = BumpType(bump_type) if not isinstance(bump_type, BumpType) else bump_type
|
|
197
|
+
|
|
198
|
+
# Handle pre-release bumping
|
|
199
|
+
if bump_type_enum.is_prerelease or bump_type_enum == BumpType.POST:
|
|
200
|
+
return self._handle_version_modifier(bump_type_enum, version)
|
|
201
|
+
|
|
202
|
+
# When moving from pre-release to release
|
|
203
|
+
if version.pre_type and bump_type_enum == BumpType.PATCH:
|
|
204
|
+
new_version_str = f"{version.major}.{version.minor}.{version.patch}"
|
|
205
|
+
return self.parse_version(new_version_str)
|
|
206
|
+
|
|
207
|
+
# Handle regular version bumping
|
|
208
|
+
match bump_type_enum:
|
|
209
|
+
case BumpType.MAJOR:
|
|
210
|
+
new_version_str = f"{version.major + 1}.0.0"
|
|
211
|
+
case BumpType.MINOR:
|
|
212
|
+
new_version_str = f"{version.major}.{version.minor + 1}.0"
|
|
213
|
+
case BumpType.PATCH:
|
|
214
|
+
new_version_str = f"{version.major}.{version.minor}.{version.patch + 1}"
|
|
215
|
+
case _:
|
|
216
|
+
self.logger.error("Invalid bump type: %s", bump_type)
|
|
217
|
+
sys.exit(1)
|
|
218
|
+
|
|
219
|
+
return self.parse_version(new_version_str)
|
|
220
|
+
|
|
221
|
+
def _handle_explicit_version(self, version: str) -> None:
|
|
222
|
+
"""Validate explicit version number format."""
|
|
223
|
+
# Parse version to extract the base part (major.minor.patch)
|
|
224
|
+
major, minor, patch, _, _ = self._parse_version_components(version)
|
|
225
|
+
|
|
226
|
+
# Validate the numbers
|
|
227
|
+
if any(n < 0 for n in (major, minor, patch)):
|
|
228
|
+
self.logger.error("Invalid version number: %s. Numbers cannot be negative.", version)
|
|
229
|
+
sys.exit(1)
|
|
230
|
+
|
|
231
|
+
def _handle_version_modifier(self, bump_type: BumpType, version: Version) -> Version:
|
|
232
|
+
"""Calculate pre-release version bump."""
|
|
233
|
+
if bump_type == BumpType.POST:
|
|
234
|
+
return self._handle_post_release(version)
|
|
235
|
+
|
|
236
|
+
if bump_type == BumpType.DEV:
|
|
237
|
+
return self._handle_dev_release(version)
|
|
238
|
+
|
|
239
|
+
# Handle alpha, beta, rc
|
|
240
|
+
return self._handle_prerelease(bump_type, version)
|
|
241
|
+
|
|
242
|
+
def _handle_post_release(self, version: Version) -> Version:
|
|
243
|
+
"""Handle post-release version bump."""
|
|
244
|
+
if version.pre_type == BumpType.POST and version.pre_num:
|
|
245
|
+
# Increment existing post-release
|
|
246
|
+
new_version_str = (
|
|
247
|
+
f"{version.major}.{version.minor}.{version.patch}.post{version.pre_num + 1}"
|
|
248
|
+
)
|
|
249
|
+
elif version.pre_type and version.pre_type.is_prerelease:
|
|
250
|
+
# Can't add post-release to pre-release
|
|
251
|
+
self.logger.error(
|
|
252
|
+
"Can't add post-release to %s%s, genius. "
|
|
253
|
+
"How can you post-release something that isn't released? "
|
|
254
|
+
"Finalize the version first.",
|
|
255
|
+
version.pre_type,
|
|
256
|
+
version.pre_num,
|
|
257
|
+
)
|
|
258
|
+
sys.exit(1)
|
|
259
|
+
else:
|
|
260
|
+
# Add post-release to regular version
|
|
261
|
+
new_version_str = f"{version.major}.{version.minor}.{version.patch}.post1"
|
|
262
|
+
|
|
263
|
+
return self.parse_version(new_version_str)
|
|
264
|
+
|
|
265
|
+
def _handle_dev_release(self, version: Version) -> Version:
|
|
266
|
+
"""Handle dev version bump."""
|
|
267
|
+
if version.pre_type == BumpType.DEV and version.pre_num is not None:
|
|
268
|
+
# Increment existing dev version
|
|
269
|
+
new_version_str = (
|
|
270
|
+
f"{version.major}.{version.minor}.{version.patch}.dev{version.pre_num + 1}"
|
|
271
|
+
)
|
|
272
|
+
else:
|
|
273
|
+
# Start new dev series
|
|
274
|
+
new_version_str = f"{version.major}.{version.minor}.{version.patch}.dev0"
|
|
275
|
+
|
|
276
|
+
return self.parse_version(new_version_str)
|
|
277
|
+
|
|
278
|
+
def _handle_prerelease(self, bump_type: BumpType, version: Version) -> Version:
|
|
279
|
+
"""Handle alpha, beta, rc version bumps."""
|
|
280
|
+
new_suffix = bump_type.version_suffix
|
|
281
|
+
|
|
282
|
+
# Check for invalid progression
|
|
283
|
+
if version.pre_type and version.pre_type.sort_value() > bump_type.sort_value():
|
|
284
|
+
self.logger.error(
|
|
285
|
+
"Can't go backwards from %s to %s, idiot. Version progression is: dev -> alpha -> beta -> rc",
|
|
286
|
+
version.pre_type.version_suffix,
|
|
287
|
+
new_suffix,
|
|
288
|
+
)
|
|
289
|
+
sys.exit(1)
|
|
290
|
+
|
|
291
|
+
# Determine the new version string
|
|
292
|
+
if version.pre_type == bump_type and version.pre_num is not None:
|
|
293
|
+
# Increment same pre-release type (e.g., beta1 -> beta2)
|
|
294
|
+
new_version_str = (
|
|
295
|
+
f"{version.major}.{version.minor}.{version.patch}{new_suffix}{version.pre_num + 1}"
|
|
296
|
+
)
|
|
297
|
+
elif version.pre_type and version.pre_type.is_prerelease and version.pre_type != bump_type:
|
|
298
|
+
# Progress to next pre-release stage (e.g., alpha1 -> beta1)
|
|
299
|
+
new_version_str = f"{version.major}.{version.minor}.{version.patch}{new_suffix}1"
|
|
300
|
+
else:
|
|
301
|
+
# Start new pre-release series
|
|
302
|
+
new_version_str = f"{version.major}.{version.minor}.{version.patch}{new_suffix}1"
|
|
303
|
+
|
|
304
|
+
return self.parse_version(new_version_str)
|
|
305
|
+
|
|
306
|
+
def parse_bump_types(self, type_args: list[str]) -> BumpType | list[BumpType] | str:
|
|
307
|
+
"""Parse bump type arguments into appropriate format."""
|
|
308
|
+
# Handle explicit version case
|
|
309
|
+
if len(type_args) == 1 and type_args[0].count(".") >= 2:
|
|
310
|
+
return type_args[0]
|
|
311
|
+
|
|
312
|
+
# Handle multiple bump types
|
|
313
|
+
bump_types = []
|
|
314
|
+
for t in type_args:
|
|
315
|
+
if hasattr(BumpType, t.upper()):
|
|
316
|
+
bump_types.append(BumpType(t))
|
|
317
|
+
else:
|
|
318
|
+
self.logger.error(
|
|
319
|
+
"Invalid argument: %s. Must be a bump type (%s) or version number (x.y.z)",
|
|
320
|
+
t,
|
|
321
|
+
", ".join(item.value for item in BumpType),
|
|
322
|
+
)
|
|
323
|
+
sys.exit(1)
|
|
324
|
+
|
|
325
|
+
return bump_types if len(bump_types) > 1 else bump_types[0]
|
|
326
|
+
|
|
327
|
+
@staticmethod
|
|
328
|
+
def detect_version_prefix() -> str:
|
|
329
|
+
"""Detect whether versions are tagged with 'v' prefix based on existing tags."""
|
|
330
|
+
try:
|
|
331
|
+
# Get all tags sorted by version
|
|
332
|
+
result = subprocess.run(
|
|
333
|
+
["git", "tag", "--sort=v:refname"], capture_output=True, text=True, check=True
|
|
334
|
+
)
|
|
335
|
+
tags = result.stdout.strip().split("\n")
|
|
336
|
+
|
|
337
|
+
# Filter out empty results
|
|
338
|
+
tags = [tag for tag in tags if tag]
|
|
339
|
+
if not tags:
|
|
340
|
+
# Default to "v" prefix for new projects
|
|
341
|
+
return "v"
|
|
342
|
+
|
|
343
|
+
# Look at the most recent tag that starts with either v or a number
|
|
344
|
+
for tag in reversed(tags):
|
|
345
|
+
if tag.startswith("v") or tag[0].isdigit():
|
|
346
|
+
return "v" if tag.startswith("v") else ""
|
|
347
|
+
|
|
348
|
+
# If no matching tags found, default to 'v' prefix
|
|
349
|
+
return "v"
|
|
350
|
+
|
|
351
|
+
except subprocess.CalledProcessError:
|
|
352
|
+
# If git commands fail, default to 'v' prefix
|
|
353
|
+
return "v"
|