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 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
+
@@ -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"