jrl-cmakemodules-scripts 2.0.0__py3-none-any.whl

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.
jrl_release.py ADDED
@@ -0,0 +1,1433 @@
1
+ #!/usr/bin/env -S uv run --script
2
+ # /// script
3
+ # requires-python = ">=3.10"
4
+ # dependencies = [
5
+ # "tomlkit",
6
+ # "ruamel.yaml",
7
+ # "rich",
8
+ # "packaging",
9
+ # "cmake-parser",
10
+ # ]
11
+ # ///
12
+
13
+ """
14
+ # jrl_release.py
15
+
16
+ Version management script for multi-format projects. Keeps version strings in sync across all tracked files and automates the release process.
17
+
18
+ ## Usage
19
+
20
+ > Requires [`uv`](https://docs.astral.sh/uv/) — it auto-installs dependencies via PEP 723 inline metadata.
21
+
22
+ ```bash
23
+ uv run --no-project jrl_release.py [OPTIONS]
24
+ ```
25
+
26
+ ## Common Commands
27
+
28
+ ```bash
29
+ # Check that all files agree on the current version
30
+ uv run --no-project jrl_release.py --check-version
31
+
32
+ # Bump version
33
+ uv run --no-project jrl_release.py --bump patch # 1.0.0 -> 1.0.1
34
+ uv run --no-project jrl_release.py --bump minor # 1.0.0 -> 1.1.0
35
+ uv run --no-project jrl_release.py --bump major # 1.0.0 -> 2.0.0
36
+
37
+ # Set a specific version
38
+ uv run --no-project jrl_release.py --update-version 1.2.3
39
+
40
+ # Bump, commit and tag in one step
41
+ uv run --no-project jrl_release.py --bump patch --git-commit --git-tag
42
+ ```
43
+
44
+ ## Options
45
+
46
+ | Option | Description |
47
+ | :--- | :--- |
48
+ | `--root <PATH>` | Project root (default: cwd). |
49
+ | `--bump <major|minor|patch>` | Bump version component. |
50
+ | `--update-version <X.Y.Z>` | Set a specific version. |
51
+ | `--dry-run` | Show changes without writing files. |
52
+ | `--short` | Print only the version string. |
53
+ | `--output-format <text|json>` | Output format (default: text). |
54
+ | `--confirm` | Skip interactive prompts. |
55
+ | `--list-files` | List tracked files. |
56
+ | `--git-commit [MSG]` | Commit changes. Optional message (`{version}` placeholder). |
57
+ | `--git-tag [NAME]` | Create a tag. Optional name (`{version}` placeholder). |
58
+ | `--git-tag-message <MSG>` | Tag annotation (`{version}` placeholder). |
59
+
60
+ **Git defaults**: commit `chore: bump version to {version}`, tag `v{version}`, tag message `Release version {version}`.
61
+
62
+ ## Supported Files
63
+
64
+ | File | Key |
65
+ | :--- | :--- |
66
+ | `package.xml` | `<version>` tag |
67
+ | `pyproject.toml` | `project.version` |
68
+ | `CHANGELOG.md` | First `## [X.Y.Z]` section (not Unreleased) |
69
+ | `pixi.toml` | `[workspace] version` |
70
+ | `pixi.lock` | Regenerated via `pixi list` |
71
+ | `CITATION.cff` | `version` key |
72
+ | `CMakeLists.txt` | `project(... VERSION X.Y.Z ...)` |
73
+
74
+ > Requires `pixi` CLI if `pixi.lock` exists in the project root.
75
+ """
76
+
77
+ import sys
78
+ import re
79
+ import argparse
80
+ import datetime
81
+ import json
82
+ import subprocess
83
+ import shutil
84
+ import tempfile
85
+ from pathlib import Path
86
+ from abc import ABC, abstractmethod
87
+ from typing import List, Optional, Tuple, Dict
88
+
89
+ import tomlkit
90
+ import cmake_parser
91
+ from ruamel.yaml import YAML
92
+ from rich.console import Console
93
+ from rich.table import Table
94
+ from rich import box
95
+ from rich.prompt import Confirm
96
+ from rich.panel import Panel
97
+ from rich.markdown import Markdown
98
+ from rich.text import Text
99
+ from packaging.version import parse as parse_version, InvalidVersion
100
+
101
+ # Ensure UTF-8 output so rich box-drawing and ✓/✗ symbols render on Windows.
102
+ # Prevents UnicodeEncodeError: 'charmap' codec can't encode characters.
103
+ for _stream in (sys.stdout, sys.stderr):
104
+ try:
105
+ _stream.reconfigure(encoding="utf-8")
106
+ except (AttributeError, ValueError):
107
+ pass
108
+
109
+ console = Console()
110
+
111
+ STYLE_INFO = "bold blue"
112
+ STYLE_SUCCESS = "green"
113
+ STYLE_SUCCESS_STRONG = "bold green"
114
+ STYLE_WARNING = "yellow"
115
+ STYLE_WARNING_STRONG = "bold yellow"
116
+ STYLE_ERROR = "red"
117
+ STYLE_ERROR_STRONG = "bold red"
118
+ STYLE_MUTED = "dim"
119
+ STYLE_OLD_VALUE = "red"
120
+ STYLE_NEW_VALUE = "green"
121
+ STYLE_UNCHANGED_VALUE = "dim"
122
+ STYLE_HIGHLIGHT = "cyan"
123
+
124
+
125
+ class VersionNotPresent(Exception):
126
+ """Raised when a file exists but has no version field configured."""
127
+
128
+ pass
129
+
130
+
131
+ class VersionExtractor(ABC):
132
+ def __init__(self, file_path: Path):
133
+ self.file_path = file_path
134
+
135
+ @abstractmethod
136
+ def get_version(self) -> str:
137
+ pass
138
+
139
+ @abstractmethod
140
+ def update_version(self, new_version: str) -> None:
141
+ pass
142
+
143
+ def check_file_exists(self) -> bool:
144
+ return self.file_path.exists()
145
+
146
+ @property
147
+ def name(self) -> str:
148
+ return self.file_path.name
149
+
150
+ @property
151
+ def path(self) -> str:
152
+ return str(self.file_path)
153
+
154
+
155
+ class XmlVersionExtractor(VersionExtractor):
156
+ def get_version(self) -> str:
157
+ # Simple regex for package.xml to avoid parsing namespaces or losing comments
158
+ with open(self.file_path, "r", encoding="utf-8") as f:
159
+ content = f.read()
160
+ match = re.search(r"<version>(.*?)</version>", content)
161
+ if match:
162
+ return match.group(1).strip()
163
+ raise VersionNotPresent(f"No <version> tag found in {self.name}")
164
+
165
+ def update_version(self, new_version: str) -> None:
166
+ with open(self.file_path, "r", encoding="utf-8") as f:
167
+ content = f.read()
168
+
169
+ # Replace only the first occurrence which is standard for the package version
170
+ new_content = re.sub(
171
+ r"<version>(.*?)</version>",
172
+ f"<version>{new_version}</version>",
173
+ content,
174
+ count=1,
175
+ )
176
+
177
+ with open(self.file_path, "w", encoding="utf-8") as f:
178
+ f.write(new_content)
179
+
180
+
181
+ class TomlVersionExtractor(VersionExtractor):
182
+ def __init__(self, file_path: Path, keys: List[str]):
183
+ super().__init__(file_path)
184
+ self.keys = keys
185
+
186
+ def get_version(self) -> str:
187
+ with open(self.file_path, "r", encoding="utf-8") as f:
188
+ data = tomlkit.load(f)
189
+
190
+ value = data
191
+ for key in self.keys:
192
+ if key in value:
193
+ value = value[key]
194
+ else:
195
+ raise VersionNotPresent(
196
+ f"Key '{'.'.join(self.keys)}' not found in {self.name}"
197
+ )
198
+
199
+ return str(value)
200
+
201
+ def update_version(self, new_version: str) -> None:
202
+ with open(self.file_path, "r", encoding="utf-8") as f:
203
+ data = tomlkit.load(f)
204
+
205
+ # Navigate to the key
206
+ container = data
207
+ for key in self.keys[:-1]:
208
+ if key in container:
209
+ container = container[key]
210
+ else:
211
+ raise ValueError(f"Key '{key}' not found in {self.name}")
212
+
213
+ container[self.keys[-1]] = new_version
214
+
215
+ with open(self.file_path, "w", encoding="utf-8") as f:
216
+ tomlkit.dump(data, f)
217
+
218
+
219
+ class YamlVersionExtractor(VersionExtractor):
220
+ def __init__(self, file_path: Path, keys: List[str]):
221
+ super().__init__(file_path)
222
+ self.keys = keys
223
+ self.yaml = YAML()
224
+ self.yaml.preserve_quotes = True
225
+
226
+ def get_version(self) -> str:
227
+ with open(self.file_path, "r", encoding="utf-8") as f:
228
+ data = self.yaml.load(f)
229
+
230
+ value = data
231
+ for key in self.keys:
232
+ if key in value:
233
+ value = value[key]
234
+ else:
235
+ raise VersionNotPresent(
236
+ f"Key '{'.'.join(self.keys)}' not found in {self.name}"
237
+ )
238
+
239
+ return str(value)
240
+
241
+ def update_version(self, new_version: str) -> None:
242
+ with open(self.file_path, "r", encoding="utf-8") as f:
243
+ data = self.yaml.load(f)
244
+
245
+ container = data
246
+ for key in self.keys[:-1]:
247
+ container = container[key]
248
+
249
+ container[self.keys[-1]] = new_version
250
+
251
+ with open(self.file_path, "w", encoding="utf-8") as f:
252
+ self.yaml.dump(data, f)
253
+
254
+
255
+ class CMakeListsVersionExtractor(VersionExtractor):
256
+ """Specialized extractor for CMakeLists.txt that uses cmake-parser
257
+ and handles both direct VERSION and variables (e.g., from package.xml)."""
258
+
259
+ def get_version(self) -> str:
260
+ with open(self.file_path, "r", encoding="utf-8") as f:
261
+ content = f.read()
262
+
263
+ try:
264
+ # Parse the CMakeLists.txt file
265
+ tree = cmake_parser.parse(content)
266
+
267
+ fallback_version = None
268
+ project_version = None
269
+
270
+ # Walk through all commands
271
+ for node in tree:
272
+ if hasattr(node, "name"):
273
+ # Look for set(PROJECT_VERSION "...")
274
+ if node.name.lower() == "set":
275
+ args = self._get_command_args(node)
276
+ if len(args) >= 2 and args[0] == "PROJECT_VERSION":
277
+ # Remove quotes from version string
278
+ fallback_version = args[1].strip('"')
279
+
280
+ # Look for project(...VERSION ...)
281
+ elif node.name.lower() == "project":
282
+ args = self._get_command_args(node)
283
+ # Find VERSION keyword
284
+ try:
285
+ version_idx = args.index("VERSION")
286
+ if version_idx + 1 < len(args):
287
+ ver = args[version_idx + 1]
288
+ # Check if it's a variable reference
289
+ if not ver.startswith("${"):
290
+ project_version = ver
291
+ except ValueError:
292
+ pass
293
+
294
+ # If project() uses a variable, return fallback
295
+ if fallback_version and not project_version:
296
+ return fallback_version
297
+
298
+ # If project() has a literal version, use that
299
+ if project_version:
300
+ return project_version
301
+
302
+ except Exception:
303
+ pass # cmake-parser failed, fall back to regex
304
+
305
+ return self._get_version_regex(content)
306
+
307
+ def _get_command_args(self, node) -> List[str]:
308
+ """Extract arguments from a cmake command node."""
309
+ args = []
310
+ if hasattr(node, "body"):
311
+ for item in node.body:
312
+ if hasattr(item, "contents"):
313
+ args.append(item.contents)
314
+ return args
315
+
316
+ def _get_version_regex(self, content: str) -> str:
317
+ """Fallback regex-based version extraction."""
318
+ # First try to find set(PROJECT_VERSION "X.Y.Z")
319
+ fallback_pattern = re.compile(
320
+ r'set\s*\(\s*PROJECT_VERSION\s+"([0-9]+\.[0-9]+\.[0-9]+)"',
321
+ re.MULTILINE,
322
+ )
323
+ fallback_match = fallback_pattern.search(content)
324
+
325
+ # Also check if project() uses a literal version or variable
326
+ project_pattern = re.compile(
327
+ r"project\s*\([^)]*VERSION\s+([\d.]+|\$\{[^}]+\})", re.MULTILINE
328
+ )
329
+ project_match = project_pattern.search(content)
330
+
331
+ # If project() uses a variable, use the fallback version
332
+ if project_match and project_match.group(1).startswith("${"):
333
+ if fallback_match:
334
+ return fallback_match.group(1)
335
+ raise VersionNotPresent(
336
+ f"{self.name} reads version from variable {project_match.group(1)}, no fallback found"
337
+ )
338
+
339
+ # If project() uses a literal, return it
340
+ if project_match and not project_match.group(1).startswith("${"):
341
+ return project_match.group(1)
342
+
343
+ raise VersionNotPresent(f"No version found in {self.name}")
344
+
345
+ def update_version(self, new_version: str) -> None:
346
+ with open(self.file_path, "r", encoding="utf-8") as f:
347
+ content = f.read()
348
+
349
+ # Update the fallback version in set(PROJECT_VERSION "...")
350
+ fallback_pattern = re.compile(
351
+ r'(set\s*\(\s*PROJECT_VERSION\s+)"([0-9]+\.[0-9]+\.[0-9]+)"',
352
+ re.MULTILINE,
353
+ )
354
+
355
+ def repl_fallback(match):
356
+ return f'{match.group(1)}"{new_version}"'
357
+
358
+ content = fallback_pattern.sub(repl_fallback, content, count=1)
359
+
360
+ # Also update literal version in project() if present
361
+ project_pattern = re.compile(
362
+ r"(project\s*\([^)]*VERSION\s+)([\d.]+)", re.MULTILINE
363
+ )
364
+
365
+ def repl_project(match):
366
+ return f"{match.group(1)}{new_version}"
367
+
368
+ content = project_pattern.sub(repl_project, content, count=1)
369
+
370
+ with open(self.file_path, "w", encoding="utf-8") as f:
371
+ f.write(content)
372
+
373
+
374
+ class ChangelogVersionExtractor(VersionExtractor):
375
+ def __init__(self, file_path: Path, pattern: str = ""):
376
+ super().__init__(file_path)
377
+
378
+ def get_version(self) -> str:
379
+ with open(self.file_path, "r", encoding="utf-8") as f:
380
+ content = f.read()
381
+
382
+ # Look for ## [Version]
383
+ matches = re.findall(r"^## \[(.*?)\]", content, re.MULTILINE)
384
+ for version in matches:
385
+ if version.lower() != "unreleased":
386
+ return version
387
+ raise VersionNotPresent(f"No released version found in {self.name}")
388
+
389
+ def update_version(self, new_version: str) -> None:
390
+ with open(self.file_path, "r", encoding="utf-8") as f:
391
+ content = f.read()
392
+
393
+ today = datetime.date.today().isoformat()
394
+
395
+ pattern = r"^## \[Unreleased\]"
396
+ if not re.search(pattern, content, re.MULTILINE):
397
+ console.print(
398
+ f"[{STYLE_WARNING}]Warning: Could not find '## [Unreleased]' in CHANGELOG.md. Skipping update.[/{STYLE_WARNING}]"
399
+ )
400
+ return
401
+
402
+ replacement = f"## [Unreleased]\n\n## [{new_version}] - {today}"
403
+
404
+ new_content = re.sub(pattern, replacement, content, count=1, flags=re.MULTILINE)
405
+
406
+ with open(self.file_path, "w", encoding="utf-8") as f:
407
+ f.write(new_content)
408
+
409
+ console.print(
410
+ f"[{STYLE_INFO}]Updated CHANGELOG.md header. Note: Link definitions at the bottom were not updated automatically.[/{STYLE_INFO}]"
411
+ )
412
+
413
+
414
+ def validate_semver(version: str) -> str:
415
+ try:
416
+ parsed = parse_version(version)
417
+ return str(parsed)
418
+ except InvalidVersion:
419
+ raise argparse.ArgumentTypeError(
420
+ f"'{version}' is not a valid Semantic Version."
421
+ )
422
+
423
+
424
+ def parse_semver(version: str) -> Tuple[int, int, int]:
425
+ """Parse a semantic version string into major, minor, patch components."""
426
+ match = re.match(r"^(\d+)\.(\d+)\.(\d+)$", version.strip())
427
+ if not match:
428
+ raise ValueError(f"Invalid semver format: {version}")
429
+ return int(match.group(1)), int(match.group(2)), int(match.group(3))
430
+
431
+
432
+ def bump_version(version: str, bump_type: str) -> str:
433
+ """Bump a semantic version by major, minor, or patch."""
434
+ major, minor, patch = parse_semver(version)
435
+
436
+ if bump_type == "major":
437
+ return f"{major + 1}.0.0"
438
+ elif bump_type == "minor":
439
+ return f"{major}.{minor + 1}.0"
440
+ elif bump_type == "patch":
441
+ return f"{major}.{minor}.{patch + 1}"
442
+ else:
443
+ raise ValueError(f"Invalid bump type: {bump_type}")
444
+
445
+
446
+ def get_current_version(checks: List[VersionExtractor]) -> Optional[str]:
447
+ """Get the current consensus version from all files."""
448
+ versions_found = set()
449
+ errors = []
450
+
451
+ for check in checks:
452
+ if check.check_file_exists():
453
+ try:
454
+ version = check.get_version()
455
+ versions_found.add(version)
456
+ except VersionNotPresent:
457
+ pass # file exists but has no version configured; skip
458
+ except Exception as e:
459
+ errors.append(f"{check.name}: {e}")
460
+
461
+ # Report parsing errors
462
+ if errors:
463
+ console.print(
464
+ f"[{STYLE_WARNING}]Warning: Failed to parse version from some files:[/{STYLE_WARNING}]"
465
+ )
466
+ for error in errors:
467
+ console.print(f" [{STYLE_MUTED}]• {error}[/{STYLE_MUTED}]")
468
+
469
+ if len(versions_found) == 1:
470
+ return list(versions_found)[0]
471
+ elif len(versions_found) > 1:
472
+ console.print(
473
+ f"[{STYLE_ERROR}]Error: Multiple versions found: {', '.join(sorted(versions_found))}[/{STYLE_ERROR}]"
474
+ )
475
+ console.print(
476
+ f"[{STYLE_WARNING}]Please run --check-version first to resolve conflicts.[/{STYLE_WARNING}]"
477
+ )
478
+ return None
479
+ else:
480
+ console.print(
481
+ f"[{STYLE_ERROR}]Error: No version found in any files.[/{STYLE_ERROR}]"
482
+ )
483
+ return None
484
+
485
+
486
+ def infer_change_type(
487
+ old_version: str, new_version: str, bump_type: Optional[str] = None
488
+ ) -> str:
489
+ """Infer change type label (major/minor/patch/custom)."""
490
+ if bump_type in {"major", "minor", "patch"}:
491
+ return bump_type
492
+
493
+ try:
494
+ old_major, old_minor, old_patch = parse_semver(old_version)
495
+ new_major, new_minor, new_patch = parse_semver(new_version)
496
+ except ValueError:
497
+ return "custom"
498
+
499
+ if new_major != old_major:
500
+ return "major"
501
+ if new_minor != old_minor:
502
+ return "minor"
503
+ if new_patch != old_patch:
504
+ return "patch"
505
+ return "no-change"
506
+
507
+
508
+ def show_version_diff(
509
+ old_version: str, new_version: str, bump_type: Optional[str] = None
510
+ ) -> None:
511
+ """Display a visual diff between old and new versions."""
512
+ old_parts = old_version.split(".")
513
+ new_parts = new_version.split(".")
514
+
515
+ # Build colored versions with highlights on changed parts
516
+ old_colored_parts = []
517
+ new_colored_parts = []
518
+
519
+ for old, new in zip(old_parts, new_parts):
520
+ if old != new:
521
+ old_colored_parts.append(f"[{STYLE_OLD_VALUE}]{old}[/{STYLE_OLD_VALUE}]")
522
+ new_colored_parts.append(f"[{STYLE_NEW_VALUE}]{new}[/{STYLE_NEW_VALUE}]")
523
+ else:
524
+ old_colored_parts.append(
525
+ f"[{STYLE_UNCHANGED_VALUE}]{old}[/{STYLE_UNCHANGED_VALUE}]"
526
+ )
527
+ new_colored_parts.append(
528
+ f"[{STYLE_UNCHANGED_VALUE}]{new}[/{STYLE_UNCHANGED_VALUE}]"
529
+ )
530
+
531
+ old_colored = ".".join(old_colored_parts)
532
+ new_colored = ".".join(new_colored_parts)
533
+
534
+ change_type = infer_change_type(old_version, new_version, bump_type)
535
+
536
+ panel = Panel(
537
+ f"[bold]{old_colored} → {new_colored}[/bold]",
538
+ title=f"[{STYLE_WARNING_STRONG}]Version Change ({change_type})[/{STYLE_WARNING_STRONG}]",
539
+ border_style=STYLE_WARNING,
540
+ expand=False,
541
+ )
542
+ console.print(panel)
543
+
544
+
545
+ def validate_version_progression(
546
+ old_version: str, new_version: str, bump_type: str
547
+ ) -> None:
548
+ """Validate and warn about unusual version progressions."""
549
+ try:
550
+ old_major, old_minor, old_patch = parse_semver(old_version)
551
+ new_major, new_minor, new_patch = parse_semver(new_version)
552
+ except ValueError:
553
+ return # Can't validate non-semver
554
+
555
+ warnings = []
556
+
557
+ # Check for skipped versions
558
+ if bump_type == "major":
559
+ if new_major != old_major + 1:
560
+ warnings.append(
561
+ f"Major version jump: {old_major} → {new_major} (skipping versions)"
562
+ )
563
+ elif bump_type == "minor":
564
+ if new_major != old_major:
565
+ warnings.append(
566
+ f"Major version changed during minor bump: {old_major} → {new_major}"
567
+ )
568
+ elif new_minor != old_minor + 1:
569
+ warnings.append(
570
+ f"Minor version jump: {old_minor} → {new_minor} (skipping versions)"
571
+ )
572
+ elif bump_type == "patch":
573
+ if new_major != old_major:
574
+ warnings.append(
575
+ f"Major version changed during patch bump: {old_major} → {new_major}"
576
+ )
577
+ elif new_minor != old_minor:
578
+ warnings.append(
579
+ f"Minor version changed during patch bump: {old_minor} → {new_minor}"
580
+ )
581
+ elif new_patch != old_patch + 1:
582
+ warnings.append(
583
+ f"Patch version jump: {old_patch} → {new_patch} (skipping versions)"
584
+ )
585
+
586
+ # Check for backward version
587
+ if (new_major, new_minor, new_patch) <= (old_major, old_minor, old_patch):
588
+ warnings.append("New version is not greater than old version")
589
+
590
+ if warnings:
591
+ console.print(
592
+ f"[{STYLE_WARNING_STRONG}]⚠ Version Progression Warnings:[/{STYLE_WARNING_STRONG}]"
593
+ )
594
+ for warning in warnings:
595
+ console.print(f" [{STYLE_WARNING}]• {warning}[/{STYLE_WARNING}]")
596
+ console.print()
597
+
598
+
599
+ def run_git_command(args: List[str], cwd: Path) -> Tuple[bool, str]:
600
+ """Run a git command and return (success, output)."""
601
+ try:
602
+ result = subprocess.run(
603
+ ["git"] + args,
604
+ cwd=cwd,
605
+ capture_output=True,
606
+ text=True,
607
+ )
608
+ if result.returncode != 0:
609
+ return False, result.stderr.strip()
610
+ return True, result.stdout.strip()
611
+ except subprocess.CalledProcessError as e:
612
+ return False, e.stderr or ""
613
+ except FileNotFoundError:
614
+ return False, "git command not found"
615
+
616
+
617
+ def git_commit_version(
618
+ root_dir: Path,
619
+ version: str,
620
+ auto_confirm: bool,
621
+ custom_message: Optional[str] = None,
622
+ files_to_stage: Optional[List[str]] = None,
623
+ ) -> bool:
624
+ """Commit version changes to git."""
625
+ success, _ = run_git_command(["rev-parse", "--git-dir"], root_dir)
626
+ if not success:
627
+ console.print(
628
+ f"[{STYLE_WARNING}]Not a git repository, skipping git commit.[/{STYLE_WARNING}]"
629
+ )
630
+ return False
631
+
632
+ _, status_output = run_git_command(["status", "--porcelain"], root_dir)
633
+ if not status_output:
634
+ console.print(f"[{STYLE_WARNING}]No changes to commit.[/{STYLE_WARNING}]")
635
+ return False
636
+
637
+ commit_message = (
638
+ custom_message.format(version=version)
639
+ if custom_message
640
+ else f"chore: bump version to {version}"
641
+ )
642
+
643
+ if not auto_confirm:
644
+ confirmed = Confirm.ask(
645
+ f"[bold]Commit changes with message: '{commit_message}'?[/bold]",
646
+ default=True,
647
+ )
648
+ if not confirmed:
649
+ console.print(f"[{STYLE_WARNING}]Git commit skipped.[/{STYLE_WARNING}]")
650
+ return False
651
+
652
+ if files_to_stage:
653
+ rel_paths = [str(Path(p).relative_to(root_dir)) for p in files_to_stage]
654
+ console.print(f"[{STYLE_MUTED}]$ git add {' '.join(rel_paths)}[/{STYLE_MUTED}]")
655
+ run_git_command(["add"] + files_to_stage, root_dir)
656
+ else:
657
+ console.print(f"[{STYLE_MUTED}]$ git add -u[/{STYLE_MUTED}]")
658
+ run_git_command(["add", "-u"], root_dir)
659
+
660
+ console.print(f"[{STYLE_MUTED}]$ git commit -m '{commit_message}'[/{STYLE_MUTED}]")
661
+ success, output = run_git_command(["commit", "-m", commit_message], root_dir)
662
+ if success:
663
+ console.print(
664
+ f"[{STYLE_SUCCESS}]✓ Committed changes: {commit_message}[/{STYLE_SUCCESS}]"
665
+ )
666
+ return True
667
+ else:
668
+ console.print(f"[{STYLE_ERROR}]Failed to commit: {output}[/{STYLE_ERROR}]")
669
+ return False
670
+
671
+
672
+ def git_tag_version(
673
+ root_dir: Path,
674
+ version: str,
675
+ auto_confirm: bool,
676
+ custom_tag_name: Optional[str] = None,
677
+ custom_tag_message: Optional[str] = None,
678
+ ) -> bool:
679
+ """Create a git tag for the version."""
680
+ success, _ = run_git_command(["rev-parse", "--git-dir"], root_dir)
681
+ if not success:
682
+ console.print(
683
+ f"[{STYLE_WARNING}]Not a git repository, skipping git tag.[/{STYLE_WARNING}]"
684
+ )
685
+ return False
686
+
687
+ tag_name = (
688
+ custom_tag_name.format(version=version) if custom_tag_name else f"v{version}"
689
+ )
690
+ tag_message = (
691
+ custom_tag_message.format(version=version)
692
+ if custom_tag_message
693
+ else f"Release version {version}"
694
+ )
695
+
696
+ success, _ = run_git_command(["rev-parse", tag_name], root_dir)
697
+ if success:
698
+ console.print(
699
+ f"[{STYLE_WARNING}]Tag {tag_name} already exists.[/{STYLE_WARNING}]"
700
+ )
701
+ return False
702
+
703
+ if not auto_confirm:
704
+ confirmed = Confirm.ask(
705
+ f"[bold]Create git tag '{tag_name}'?[/bold]", default=True
706
+ )
707
+ if not confirmed:
708
+ console.print(f"[{STYLE_WARNING}]Git tag skipped.[/{STYLE_WARNING}]")
709
+ return False
710
+
711
+ console.print(
712
+ f"[{STYLE_MUTED}]$ git tag -a {tag_name} -m '{tag_message}'[/{STYLE_MUTED}]"
713
+ )
714
+ success, output = run_git_command(
715
+ ["tag", "-a", tag_name, "-m", tag_message], root_dir
716
+ )
717
+ if success:
718
+ console.print(f"[{STYLE_SUCCESS}]✓ Created tag: {tag_name}[/{STYLE_SUCCESS}]")
719
+ console.print(
720
+ f"[{STYLE_MUTED}] To push: git push origin {tag_name}[/{STYLE_MUTED}]"
721
+ )
722
+ return True
723
+ else:
724
+ console.print(f"[{STYLE_ERROR}]Failed to create tag: {output}[/{STYLE_ERROR}]")
725
+ return False
726
+
727
+
728
+ def update_pixi_lock(root_dir: Path, dry_run: bool = False) -> Optional[str]:
729
+ """Update pixi.lock file by running 'pixi list'.
730
+
731
+ Returns the path to pixi.lock if updated, None otherwise.
732
+ """
733
+ pixi_lock_path = root_dir / "pixi.lock"
734
+
735
+ if not pixi_lock_path.exists():
736
+ return None
737
+
738
+ if dry_run:
739
+ return None
740
+
741
+ console.print(
742
+ f"[{STYLE_INFO}]Running 'pixi list' to update pixi.lock...[/{STYLE_INFO}]"
743
+ )
744
+ try:
745
+ result = subprocess.run(
746
+ ["pixi", "list"],
747
+ cwd=root_dir,
748
+ timeout=60,
749
+ )
750
+
751
+ if result.returncode != 0:
752
+ console.print(
753
+ f"[{STYLE_ERROR}]Error: 'pixi list' returned non-zero exit code: {result.returncode}[/{STYLE_ERROR}]"
754
+ )
755
+ console.print(
756
+ f"[{STYLE_ERROR}]Failed to update pixi.lock. Please ensure 'pixi' is installed.[/{STYLE_ERROR}]"
757
+ )
758
+ raise RuntimeError(f"'pixi list' failed with exit code {result.returncode}")
759
+
760
+ except subprocess.TimeoutExpired:
761
+ console.print(
762
+ f"[{STYLE_ERROR}]Error: 'pixi list' command timed out[/{STYLE_ERROR}]"
763
+ )
764
+ raise RuntimeError("'pixi list' command timed out after 30 seconds")
765
+ except FileNotFoundError:
766
+ console.print(f"[{STYLE_ERROR}]Error: 'pixi' command not found[/{STYLE_ERROR}]")
767
+ console.print(
768
+ f"[{STYLE_ERROR}]pixi.lock exists but 'pixi' executable is not available.[/{STYLE_ERROR}]"
769
+ )
770
+ console.print(
771
+ f"[{STYLE_INFO}]Please install pixi: https://pixi.sh[/{STYLE_INFO}]"
772
+ )
773
+ raise RuntimeError("'pixi' executable not found. Install from https://pixi.sh")
774
+ except Exception as e:
775
+ console.print(
776
+ f"[{STYLE_ERROR}]Error: Failed to run 'pixi list': {e}[/{STYLE_ERROR}]"
777
+ )
778
+ raise RuntimeError(f"Failed to run 'pixi list': {e}") from e
779
+
780
+ console.print(
781
+ f"[{STYLE_SUCCESS}]✓ Updated pixi.lock via 'pixi list'[/{STYLE_SUCCESS}]"
782
+ )
783
+
784
+ return str(pixi_lock_path)
785
+
786
+
787
+ def create_backups(file_paths: List[Path]) -> Dict[Path, Path]:
788
+ """Create backup copies of files in a temporary directory.
789
+
790
+ Returns a mapping of original paths to backup paths.
791
+ """
792
+ backups = {}
793
+ temp_dir = Path(tempfile.mkdtemp(prefix="release_backup_"))
794
+
795
+ for file_path in file_paths:
796
+ if file_path.exists():
797
+ backup_path = temp_dir / file_path.name
798
+ shutil.copy2(file_path, backup_path)
799
+ backups[file_path] = backup_path
800
+
801
+ return backups
802
+
803
+
804
+ def restore_backups(backups: Dict[Path, Path]) -> None:
805
+ """Restore files from backups and cleanup temporary directory."""
806
+ if not backups:
807
+ return
808
+
809
+ # Get temp directory from first backup
810
+ temp_dir = None
811
+ for original_path, backup_path in backups.items():
812
+ if backup_path.exists():
813
+ shutil.copy2(backup_path, original_path)
814
+ temp_dir = backup_path.parent
815
+
816
+ # Clean up temp directory
817
+ if temp_dir and temp_dir.exists():
818
+ shutil.rmtree(temp_dir)
819
+
820
+
821
+ def cleanup_backups(backups: Dict[Path, Path]) -> None:
822
+ """Remove backup files without restoring."""
823
+ if not backups:
824
+ return
825
+
826
+ # Get temp directory from first backup
827
+ temp_dir = None
828
+ for backup_path in backups.values():
829
+ temp_dir = backup_path.parent
830
+ break
831
+
832
+ # Clean up temp directory
833
+ if temp_dir and temp_dir.exists():
834
+ shutil.rmtree(temp_dir)
835
+
836
+
837
+ def list_version_files(checks: List[VersionExtractor]) -> None:
838
+ """List all files that are checked for versions."""
839
+ table = Table(title="Version Files", box=box.ROUNDED)
840
+ table.add_column("File", style="cyan")
841
+ table.add_column("Path", style="dim")
842
+ table.add_column("Exists", justify="center")
843
+ table.add_column("Type", style="magenta")
844
+
845
+ for check in checks:
846
+ exists = (
847
+ f"[{STYLE_SUCCESS}]✓[/{STYLE_SUCCESS}]"
848
+ if check.check_file_exists()
849
+ else f"[{STYLE_ERROR}]✗[/{STYLE_ERROR}]"
850
+ )
851
+ file_type = check.__class__.__name__.replace("VersionExtractor", "")
852
+ table.add_row(check.name, str(check.file_path), exists, file_type)
853
+
854
+ console.print(table)
855
+ sys.exit(0)
856
+
857
+
858
+ def handle_check_version(checks: List[VersionExtractor], args) -> int:
859
+ """Handle the --check-version command.
860
+
861
+ Returns the exit code.
862
+ """
863
+ results = []
864
+ versions_found = set()
865
+ errors = False
866
+
867
+ if not args.short:
868
+ console.print(
869
+ f"[{STYLE_INFO}]Checking versions in {args.root}...[/{STYLE_INFO}]"
870
+ )
871
+
872
+ for check in checks:
873
+ result = {
874
+ "file": check.name,
875
+ "version": None,
876
+ "status": "Unknown",
877
+ "message": "",
878
+ }
879
+
880
+ if not check.check_file_exists():
881
+ result["status"] = "Missing"
882
+ result["message"] = "File not found"
883
+ else:
884
+ try:
885
+ version = check.get_version()
886
+ result["version"] = version
887
+ result["status"] = "Found"
888
+ versions_found.add(version)
889
+ except VersionNotPresent as e:
890
+ result["status"] = "Warning"
891
+ result["message"] = str(e)
892
+ except Exception as e:
893
+ result["status"] = "Error"
894
+ result["message"] = str(e)
895
+ errors = True
896
+
897
+ results.append(result)
898
+
899
+ consensus_version = None
900
+ if len(versions_found) == 1:
901
+ consensus_version = list(versions_found)[0]
902
+ elif len(versions_found) > 1:
903
+ errors = True
904
+ consensus_version = "MISMATCH"
905
+
906
+ if args.output_format == "json":
907
+ out_payload = {
908
+ "consensus_version": consensus_version,
909
+ "files": results,
910
+ "consistent": not errors and len(versions_found) == 1,
911
+ }
912
+ print(json.dumps(out_payload, indent=2))
913
+ return 1 if errors else 0
914
+
915
+ # Standard Rich table output
916
+ table = Table(title="Version Check Summary", box=box.ROUNDED)
917
+ table.add_column("File", style="cyan")
918
+ table.add_column("Version", style="magenta")
919
+ table.add_column("Status", justify="center")
920
+ table.add_column("Details")
921
+
922
+ for res in results:
923
+ status_style = res["status"]
924
+ if res["status"] == "Found":
925
+ status_style = f"[{STYLE_SUCCESS}]Found[/{STYLE_SUCCESS}]"
926
+ elif res["status"] == "Missing":
927
+ status_style = f"[{STYLE_WARNING}]Missing[/{STYLE_WARNING}]"
928
+ elif res["status"] == "Warning":
929
+ status_style = f"[{STYLE_WARNING}]Warning[/{STYLE_WARNING}]"
930
+ elif res["status"] == "Error":
931
+ status_style = f"[{STYLE_ERROR}]Error[/{STYLE_ERROR}]"
932
+
933
+ version_display = res["version"] if res["version"] else "-"
934
+ if res["version"]:
935
+ if (
936
+ consensus_version
937
+ and consensus_version != "MISMATCH"
938
+ and res["version"] == consensus_version
939
+ ):
940
+ version_display = f"[{STYLE_SUCCESS}]{res['version']}[/{STYLE_SUCCESS}]"
941
+ elif consensus_version == "MISMATCH":
942
+ version_display = f"[{STYLE_WARNING}]{res['version']}[/{STYLE_WARNING}]"
943
+
944
+ table.add_row(res["file"], version_display, status_style, res["message"])
945
+
946
+ if not args.short:
947
+ console.print(table)
948
+
949
+ if args.short and consensus_version and consensus_version != "MISMATCH":
950
+ print(consensus_version)
951
+
952
+ if errors:
953
+ if len(versions_found) > 1:
954
+ console.print(
955
+ f"\n[{STYLE_ERROR_STRONG}]FAILURE:[/{STYLE_ERROR_STRONG}] Found conflicting versions: {', '.join(sorted(versions_found))}"
956
+ )
957
+ else:
958
+ console.print(
959
+ f"\n[{STYLE_ERROR_STRONG}]FAILURE:[/{STYLE_ERROR_STRONG}] Errors encountered (parsing errors)."
960
+ )
961
+ return 1
962
+ elif not versions_found:
963
+ console.print(
964
+ f"\n[{STYLE_ERROR_STRONG}]FAILURE:[/{STYLE_ERROR_STRONG}] No version files found in {args.root}."
965
+ )
966
+ return 1
967
+ else:
968
+ if not args.short:
969
+ console.print(
970
+ f"\n[{STYLE_SUCCESS_STRONG}]SUCCESS:[/{STYLE_SUCCESS_STRONG}] All files match version [{STYLE_SUCCESS}]{consensus_version}[/{STYLE_SUCCESS}]."
971
+ )
972
+ return 0
973
+
974
+
975
+ def perform_version_updates(
976
+ checks: List[VersionExtractor],
977
+ target_version: str,
978
+ dry_run: bool = False,
979
+ ) -> Tuple[List[str], List[str], bool, List[Tuple[str, str, str]]]:
980
+ """Apply version updates to all files.
981
+
982
+ Returns: (updated_files, updated_file_paths, failed, dry_run_rows)
983
+ dry_run_rows contains (name, old_version, new_version) tuples when dry_run=True.
984
+ """
985
+ updated_files = []
986
+ updated_file_paths = []
987
+ failed = False
988
+ dry_run_rows: List[Tuple[str, str, str]] = []
989
+
990
+ for check in checks:
991
+ if check.check_file_exists():
992
+ try:
993
+ if dry_run:
994
+ curr = check.get_version()
995
+ dry_run_rows.append((check.name, curr, target_version))
996
+ else:
997
+ try:
998
+ old_version = check.get_version()
999
+ except VersionNotPresent:
1000
+ continue
1001
+ except Exception:
1002
+ old_version = "?"
1003
+ check.update_version(target_version)
1004
+ dry_run_rows.append((check.name, old_version, target_version))
1005
+ line = Text()
1006
+ line.append(f" {check.name:<22}", style="cyan")
1007
+ line.append(old_version, style=STYLE_OLD_VALUE)
1008
+ line.append(" → ", style="dim")
1009
+ line.append(target_version, style=STYLE_NEW_VALUE)
1010
+ console.print(line)
1011
+ updated_files.append(check.name)
1012
+ updated_file_paths.append(str(check.file_path))
1013
+ except VersionNotPresent:
1014
+ pass # file exists but has no version configured; skip
1015
+ except Exception as e:
1016
+ console.print(
1017
+ f"[{STYLE_ERROR}]Failed to update {check.name}: {e}[/{STYLE_ERROR}]"
1018
+ )
1019
+ if not dry_run:
1020
+ failed = True
1021
+
1022
+ return updated_files, updated_file_paths, failed, dry_run_rows
1023
+
1024
+
1025
+ def show_dry_run_panel(
1026
+ dry_run_rows: List[Tuple[str, str, str]],
1027
+ pixi_lock_would_update: bool,
1028
+ git_lines: List[str],
1029
+ ) -> None:
1030
+ """Display a unified dry-run preview."""
1031
+ console.print(
1032
+ f"\n[{STYLE_WARNING_STRONG}]Dry run — no files were modified[/{STYLE_WARNING_STRONG}]"
1033
+ )
1034
+ console.print()
1035
+
1036
+ console.print(" [bold cyan]Files[/bold cyan]")
1037
+ console.print(f" [dim]{'─' * 44}[/dim]")
1038
+ for name, old, new in dry_run_rows:
1039
+ line = Text()
1040
+ line.append(f" {name:<22}", style="cyan")
1041
+ line.append(old, style=STYLE_OLD_VALUE)
1042
+ line.append(" → ", style="dim")
1043
+ line.append(new, style=STYLE_NEW_VALUE)
1044
+ console.print(line)
1045
+ if pixi_lock_would_update:
1046
+ line = Text()
1047
+ line.append(f" {'pixi.lock':<22}", style="cyan")
1048
+ line.append("regenerated via pixi list", style="dim")
1049
+ console.print(line)
1050
+
1051
+ if git_lines:
1052
+ console.print()
1053
+ console.print(" [bold cyan]Git[/bold cyan]")
1054
+ console.print(f" [dim]{'─' * 44}[/dim]")
1055
+ for cmd in git_lines:
1056
+ console.print(f" [{STYLE_MUTED}]{cmd}[/{STYLE_MUTED}]", highlight=False)
1057
+ console.print()
1058
+
1059
+
1060
+ def show_result_panel(
1061
+ pixi_lock_updated: bool,
1062
+ ) -> None:
1063
+ """Display a polished summary of completed version updates."""
1064
+ if pixi_lock_updated:
1065
+ line = Text()
1066
+ line.append(f" {'pixi.lock':<22}", style="cyan")
1067
+ line.append("regenerated via pixi list", style="dim")
1068
+ console.print(line)
1069
+ console.print()
1070
+ console.print(
1071
+ f"[{STYLE_SUCCESS_STRONG}]✓ Version updated successfully[/{STYLE_SUCCESS_STRONG}]"
1072
+ )
1073
+
1074
+
1075
+ class RichHelpAction(argparse.Action):
1076
+ def __init__(
1077
+ self,
1078
+ option_strings,
1079
+ dest=argparse.SUPPRESS,
1080
+ default=argparse.SUPPRESS,
1081
+ help=None,
1082
+ ):
1083
+ super().__init__(
1084
+ option_strings=option_strings,
1085
+ dest=dest,
1086
+ default=default,
1087
+ nargs=0,
1088
+ help=help,
1089
+ )
1090
+
1091
+ def __call__(self, parser, namespace, values, option_string=None):
1092
+ if parser.description:
1093
+ console.print(Markdown(parser.description))
1094
+
1095
+ # Print the standard argparse usage and options
1096
+ # We clear the description to avoid printing the markdown source again
1097
+ original_description = parser.description
1098
+ parser.description = None
1099
+
1100
+ console.print(Text("\nCommand Reference:\n", style="bold"))
1101
+ console.print(Text(parser.format_help()))
1102
+
1103
+ parser.description = original_description
1104
+ parser.exit()
1105
+
1106
+
1107
+ def main():
1108
+ parser = argparse.ArgumentParser(
1109
+ description=__doc__,
1110
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1111
+ add_help=False,
1112
+ )
1113
+ parser.add_argument(
1114
+ "-h",
1115
+ "--help",
1116
+ action=RichHelpAction,
1117
+ help="Show this help message and exit",
1118
+ )
1119
+ parser.add_argument(
1120
+ "--root", type=Path, default=Path.cwd(), help="Project root directory"
1121
+ )
1122
+ parser.add_argument(
1123
+ "--confirm",
1124
+ action="store_true",
1125
+ help="Auto-confirm all actions without prompting.",
1126
+ )
1127
+ parser.add_argument(
1128
+ "--dry-run",
1129
+ action="store_true",
1130
+ help="Show what would change without modifying files.",
1131
+ )
1132
+ parser.add_argument(
1133
+ "--git-commit",
1134
+ nargs="?",
1135
+ const=True,
1136
+ default=None,
1137
+ metavar="MESSAGE",
1138
+ help="Commit version changes to git. Optionally provide a custom commit message. Use {version} as placeholder. Default: 'chore: bump version to {version}'",
1139
+ )
1140
+ parser.add_argument(
1141
+ "--git-tag",
1142
+ nargs="?",
1143
+ const=True,
1144
+ default=None,
1145
+ metavar="NAME",
1146
+ help="Create a git tag for the new version. Optionally provide a custom tag name. Use {version} as placeholder. Default: 'v{version}'",
1147
+ )
1148
+ parser.add_argument(
1149
+ "--git-tag-message",
1150
+ type=str,
1151
+ default=None,
1152
+ metavar="MESSAGE",
1153
+ help="Custom git tag message. Use {version} as placeholder for version number. Default: 'Release version {version}'",
1154
+ )
1155
+ parser.add_argument(
1156
+ "--short",
1157
+ action="store_true",
1158
+ help="Output only the final version string.",
1159
+ )
1160
+ parser.add_argument(
1161
+ "--output-format",
1162
+ choices=["text", "json"],
1163
+ default="text",
1164
+ help="Output format (default: text).",
1165
+ )
1166
+
1167
+ group = parser.add_mutually_exclusive_group(required=True)
1168
+ group.add_argument(
1169
+ "--check-version", action="store_true", help="Check versions across files."
1170
+ )
1171
+ group.add_argument(
1172
+ "--list-files",
1173
+ action="store_true",
1174
+ help="List all files that are checked for versions.",
1175
+ )
1176
+ group.add_argument(
1177
+ "--update-version",
1178
+ type=str,
1179
+ help="Update version in all files (enforces semver).",
1180
+ )
1181
+ group.add_argument(
1182
+ "--bump",
1183
+ choices=["major", "minor", "patch"],
1184
+ help="Bump the project version.",
1185
+ )
1186
+
1187
+ args = parser.parse_args()
1188
+ root_dir = args.root
1189
+
1190
+ # Redirect console output to stderr for clean stdout with json/short
1191
+ global console
1192
+ if args.short or args.output_format == "json":
1193
+ console = Console(file=sys.stderr)
1194
+ else:
1195
+ console = Console()
1196
+
1197
+ if args.update_version:
1198
+ try:
1199
+ if not re.match(r"^\d+\.\d+\.\d+$", args.update_version):
1200
+ console.print(
1201
+ f"[{STYLE_ERROR}]Invalid SemVer '{args.update_version}'. strict X.Y.Z required.[/{STYLE_ERROR}]"
1202
+ )
1203
+ sys.exit(1)
1204
+ except Exception as e:
1205
+ console.print(
1206
+ f"[{STYLE_ERROR}]Error validating version: {e}[/{STYLE_ERROR}]"
1207
+ )
1208
+ sys.exit(1)
1209
+
1210
+ checks: List[VersionExtractor] = [
1211
+ XmlVersionExtractor(root_dir / "package.xml"),
1212
+ TomlVersionExtractor(root_dir / "pyproject.toml", ["project", "version"]),
1213
+ ChangelogVersionExtractor(root_dir / "CHANGELOG.md", r""),
1214
+ TomlVersionExtractor(root_dir / "pixi.toml", ["workspace", "version"]),
1215
+ YamlVersionExtractor(root_dir / "CITATION.cff", ["version"]),
1216
+ CMakeListsVersionExtractor(root_dir / "CMakeLists.txt"),
1217
+ ]
1218
+
1219
+ if args.list_files:
1220
+ if args.output_format == "json":
1221
+ files_list = []
1222
+ for check in checks:
1223
+ files_list.append(
1224
+ {
1225
+ "name": check.name,
1226
+ "path": str(check.file_path),
1227
+ "exists": check.check_file_exists(),
1228
+ "type": check.__class__.__name__.replace(
1229
+ "VersionExtractor", ""
1230
+ ),
1231
+ }
1232
+ )
1233
+ print(json.dumps(files_list, indent=2))
1234
+ else:
1235
+ list_version_files(checks)
1236
+ sys.exit(0)
1237
+
1238
+ if args.check_version:
1239
+ sys.exit(handle_check_version(checks, args))
1240
+
1241
+ current_version = None
1242
+ new_version_str = None
1243
+
1244
+ if args.update_version:
1245
+ new_version_str = args.update_version
1246
+ current_version = get_current_version(checks)
1247
+ if not args.dry_run:
1248
+ console.print(
1249
+ f"[{STYLE_INFO}]Updating versions to {new_version_str} in {root_dir}...[/{STYLE_INFO}]"
1250
+ )
1251
+ elif args.bump:
1252
+ current_version = get_current_version(checks)
1253
+ if not current_version:
1254
+ sys.exit(1)
1255
+
1256
+ try:
1257
+ new_version_str = bump_version(current_version, args.bump)
1258
+ except ValueError as e:
1259
+ console.print(f"[{STYLE_ERROR}]Error: {e}[/{STYLE_ERROR}]")
1260
+ sys.exit(1)
1261
+
1262
+ show_version_diff(current_version, new_version_str, args.bump)
1263
+ validate_version_progression(current_version, new_version_str, args.bump)
1264
+
1265
+ if args.dry_run:
1266
+ confirmed = True
1267
+ elif args.confirm:
1268
+ confirmed = True
1269
+ else:
1270
+ confirmed = Confirm.ask(
1271
+ f"\n[bold]Do you want to upgrade from [{STYLE_INFO}]{current_version}[/{STYLE_INFO}] to [{STYLE_NEW_VALUE}]{new_version_str}[/{STYLE_NEW_VALUE}]?[/bold]",
1272
+ default=True,
1273
+ )
1274
+
1275
+ if not confirmed:
1276
+ console.print(f"[{STYLE_WARNING}]Upgrade cancelled.[/{STYLE_WARNING}]")
1277
+ sys.exit(0)
1278
+
1279
+ if new_version_str is None:
1280
+ console.print(
1281
+ f"[{STYLE_ERROR}]Internal error: target version is undefined.[/{STYLE_ERROR}]"
1282
+ )
1283
+ sys.exit(1)
1284
+ target_version: str = new_version_str
1285
+
1286
+ backups = {}
1287
+ if not args.dry_run:
1288
+ file_paths_to_backup = [
1289
+ check.file_path for check in checks if check.check_file_exists()
1290
+ ]
1291
+ backups = create_backups(file_paths_to_backup)
1292
+ console.print(
1293
+ f"[{STYLE_MUTED}]Created backups for {len(backups)} files[/{STYLE_MUTED}]"
1294
+ )
1295
+
1296
+ try:
1297
+ if not args.dry_run and args.output_format == "text":
1298
+ console.print()
1299
+ console.print(" [bold cyan]Files[/bold cyan]")
1300
+ console.print(f" [dim]{'─' * 44}[/dim]")
1301
+ updated_files, updated_file_paths, failed, dry_run_rows = (
1302
+ perform_version_updates(checks, target_version, args.dry_run)
1303
+ )
1304
+
1305
+ if failed:
1306
+ if backups:
1307
+ console.print(
1308
+ f"[{STYLE_WARNING}]Restoring files from backup due to failures...[/{STYLE_WARNING}]"
1309
+ )
1310
+ restore_backups(backups)
1311
+ console.print(
1312
+ f"[{STYLE_SUCCESS}]Files restored from backup[/{STYLE_SUCCESS}]"
1313
+ )
1314
+ sys.exit(1)
1315
+
1316
+ try:
1317
+ pixi_lock_path = update_pixi_lock(root_dir, args.dry_run)
1318
+ if pixi_lock_path:
1319
+ updated_files.append("pixi.lock")
1320
+ updated_file_paths.append(pixi_lock_path)
1321
+ except RuntimeError as e:
1322
+ console.print(
1323
+ f"[{STYLE_ERROR}]Pixi lock update failed: {e}[/{STYLE_ERROR}]"
1324
+ )
1325
+ if backups:
1326
+ console.print(
1327
+ f"[{STYLE_WARNING}]Restoring files from backup...[/{STYLE_WARNING}]"
1328
+ )
1329
+ restore_backups(backups)
1330
+ console.print(
1331
+ f"[{STYLE_SUCCESS}]Files restored from backup[/{STYLE_SUCCESS}]"
1332
+ )
1333
+ sys.exit(1)
1334
+
1335
+ if backups:
1336
+ cleanup_backups(backups)
1337
+ except Exception as e:
1338
+ console.print(f"[{STYLE_ERROR}]Unexpected error: {e}[/{STYLE_ERROR}]")
1339
+ if backups:
1340
+ console.print(
1341
+ f"[{STYLE_WARNING}]Restoring files from backup...[/{STYLE_WARNING}]"
1342
+ )
1343
+ restore_backups(backups)
1344
+ console.print(
1345
+ f"[{STYLE_SUCCESS}]Files restored from backup[/{STYLE_SUCCESS}]"
1346
+ )
1347
+ raise
1348
+
1349
+ if args.output_format == "json":
1350
+ res_json = {
1351
+ "previous_version": current_version,
1352
+ "new_version": target_version,
1353
+ "updated_files": updated_files,
1354
+ "dry_run": args.dry_run,
1355
+ }
1356
+ print(json.dumps(res_json, indent=2))
1357
+
1358
+ elif args.short:
1359
+ print(target_version)
1360
+
1361
+ if args.dry_run:
1362
+ pixi_lock_would_update = (root_dir / "pixi.lock").exists()
1363
+
1364
+ git_lines: List[str] = []
1365
+ if args.git_commit is not None:
1366
+ custom_message = None if args.git_commit is True else args.git_commit
1367
+ commit_message = (
1368
+ custom_message.format(version=target_version)
1369
+ if custom_message
1370
+ else f"chore: bump version to {target_version}"
1371
+ )
1372
+ rel_paths = (
1373
+ [str(Path(p).relative_to(root_dir)) for p in updated_file_paths]
1374
+ if updated_file_paths
1375
+ else None
1376
+ )
1377
+ git_lines.append(f"$ git add {' '.join(rel_paths) if rel_paths else '-u'}")
1378
+ git_lines.append(f"$ git commit -m '{commit_message}'")
1379
+ if args.git_tag is not None:
1380
+ custom_tag_name = None if args.git_tag is True else args.git_tag
1381
+ tag_name = (
1382
+ custom_tag_name.format(version=target_version)
1383
+ if custom_tag_name
1384
+ else f"v{target_version}"
1385
+ )
1386
+ tag_message = (
1387
+ args.git_tag_message.format(version=target_version)
1388
+ if args.git_tag_message
1389
+ else f"Release version {target_version}"
1390
+ )
1391
+ git_lines.append(f"$ git tag -a {tag_name} -m '{tag_message}'")
1392
+
1393
+ show_dry_run_panel(dry_run_rows, pixi_lock_would_update, git_lines)
1394
+ sys.exit(0)
1395
+ else:
1396
+ if not args.short and args.output_format == "text":
1397
+ show_result_panel(pixi_lock_path is not None)
1398
+
1399
+ # Git operations - only perform if explicitly requested
1400
+ if args.git_tag is not None and args.git_commit is None:
1401
+ console.print(
1402
+ f"[{STYLE_WARNING}]Warning: --git-tag used without --git-commit. The tag will point to the current HEAD, not the version bump commit.[/{STYLE_WARNING}]"
1403
+ )
1404
+
1405
+ if args.git_commit is not None:
1406
+ custom_message = None if args.git_commit is True else args.git_commit
1407
+ git_commit_version(
1408
+ root_dir,
1409
+ target_version,
1410
+ args.confirm,
1411
+ custom_message,
1412
+ updated_file_paths,
1413
+ )
1414
+
1415
+ if args.git_tag is not None:
1416
+ custom_tag_name = None if args.git_tag is True else args.git_tag
1417
+ git_tag_version(
1418
+ root_dir,
1419
+ target_version,
1420
+ args.confirm,
1421
+ custom_tag_name,
1422
+ args.git_tag_message,
1423
+ )
1424
+
1425
+
1426
+ if __name__ == "__main__":
1427
+ try:
1428
+ main()
1429
+ except KeyboardInterrupt:
1430
+ console.print(
1431
+ f"\n[{STYLE_WARNING}]Operation cancelled by user (Ctrl+C).[/{STYLE_WARNING}]"
1432
+ )
1433
+ sys.exit(130)