jrl-cmakemodules-scripts 2.0.0__tar.gz → 2.2.4__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.
- {jrl_cmakemodules_scripts-2.0.0 → jrl_cmakemodules_scripts-2.2.4}/PKG-INFO +1 -1
- {jrl_cmakemodules_scripts-2.0.0 → jrl_cmakemodules_scripts-2.2.4}/jrl_cmakemodules_scripts.egg-info/PKG-INFO +1 -1
- {jrl_cmakemodules_scripts-2.0.0 → jrl_cmakemodules_scripts-2.2.4}/jrl_release.py +747 -76
- {jrl_cmakemodules_scripts-2.0.0 → jrl_cmakemodules_scripts-2.2.4}/pyproject.toml +1 -1
- {jrl_cmakemodules_scripts-2.0.0 → jrl_cmakemodules_scripts-2.2.4}/README.md +0 -0
- {jrl_cmakemodules_scripts-2.0.0 → jrl_cmakemodules_scripts-2.2.4}/jrl_cmakemodules_scripts.egg-info/SOURCES.txt +0 -0
- {jrl_cmakemodules_scripts-2.0.0 → jrl_cmakemodules_scripts-2.2.4}/jrl_cmakemodules_scripts.egg-info/dependency_links.txt +0 -0
- {jrl_cmakemodules_scripts-2.0.0 → jrl_cmakemodules_scripts-2.2.4}/jrl_cmakemodules_scripts.egg-info/entry_points.txt +0 -0
- {jrl_cmakemodules_scripts-2.0.0 → jrl_cmakemodules_scripts-2.2.4}/jrl_cmakemodules_scripts.egg-info/requires.txt +0 -0
- {jrl_cmakemodules_scripts-2.0.0 → jrl_cmakemodules_scripts-2.2.4}/jrl_cmakemodules_scripts.egg-info/top_level.txt +0 -0
- {jrl_cmakemodules_scripts-2.0.0 → jrl_cmakemodules_scripts-2.2.4}/setup.cfg +0 -0
|
@@ -56,6 +56,11 @@ uv run --no-project jrl_release.py --bump patch --git-commit --git-tag
|
|
|
56
56
|
| `--git-commit [MSG]` | Commit changes. Optional message (`{version}` placeholder). |
|
|
57
57
|
| `--git-tag [NAME]` | Create a tag. Optional name (`{version}` placeholder). |
|
|
58
58
|
| `--git-tag-message <MSG>` | Tag annotation (`{version}` placeholder). |
|
|
59
|
+
| `--sign-tag` | GPG-sign the tag (`git tag -s`). Needs a configured signing key. |
|
|
60
|
+
| `--push-tag` | Push the tag to the main repository. Only works with github for now. |
|
|
61
|
+
| `--git-archive` | Create a .tar.gz archive. |
|
|
62
|
+
| `--sign-archive` | GPG-sign the archive. Needs a configured signing key. |
|
|
63
|
+
| `--gh-release` | Create Github release. Needs a configured gh cli. |
|
|
59
64
|
|
|
60
65
|
**Git defaults**: commit `chore: bump version to {version}`, tag `v{version}`, tag message `Release version {version}`.
|
|
61
66
|
|
|
@@ -63,19 +68,34 @@ uv run --no-project jrl_release.py --bump patch --git-commit --git-tag
|
|
|
63
68
|
|
|
64
69
|
| File | Key |
|
|
65
70
|
| :--- | :--- |
|
|
66
|
-
| `package.xml` | `<version>` tag |
|
|
67
|
-
| `pyproject.toml` | `project.version` |
|
|
71
|
+
| `package.xml` | `<version> and <url>` tag |
|
|
72
|
+
| `pyproject.toml` | `project.version` and `project.urls` |
|
|
68
73
|
| `CHANGELOG.md` | First `## [X.Y.Z]` section (not Unreleased) |
|
|
69
74
|
| `pixi.toml` | `[workspace] version` |
|
|
70
75
|
| `pixi.lock` | Regenerated via `pixi list` |
|
|
71
76
|
| `CITATION.cff` | `version` key |
|
|
72
|
-
| `CMakeLists.txt` | `project(... VERSION X.Y.Z ...)` |
|
|
77
|
+
| `CMakeLists.txt` | `project(... VERSION X.Y.Z ... HOMEPAGE_URL ...)` |
|
|
78
|
+
| `debian/changelog` | `version = ...` |
|
|
79
|
+
| `conanfile.py` | `version = ...` |
|
|
73
80
|
|
|
74
81
|
> Requires `pixi` CLI if `pixi.lock` exists in the project root.
|
|
82
|
+
|
|
83
|
+
## Meta-packages
|
|
84
|
+
|
|
85
|
+
ROS meta-packages are handled automatically: every nested directory with a
|
|
86
|
+
`package.xml` is treated like the repository root — the full set of
|
|
87
|
+
supported files (`package.xml`, `pyproject.toml`, `CHANGELOG.md`, `pixi.toml`,
|
|
88
|
+
`CITATION.cff`, `CMakeLists.txt`, `debian/changelog`) is checked there too,
|
|
89
|
+
skipping any that don't exist in that nested directory.
|
|
90
|
+
Discovery skips hidden directories (`.git`, `.pixi`, `.venv`, …), git submodules,
|
|
91
|
+
and — honoring your `.gitignore` via `git check-ignore` — ignored dirs like
|
|
92
|
+
`build/` or `dist/`. All discovered files must agree on a single
|
|
93
|
+
version to bump version.
|
|
75
94
|
"""
|
|
76
95
|
|
|
77
96
|
import sys
|
|
78
97
|
import re
|
|
98
|
+
import os
|
|
79
99
|
import argparse
|
|
80
100
|
import datetime
|
|
81
101
|
import json
|
|
@@ -121,6 +141,8 @@ STYLE_NEW_VALUE = "green"
|
|
|
121
141
|
STYLE_UNCHANGED_VALUE = "dim"
|
|
122
142
|
STYLE_HIGHLIGHT = "cyan"
|
|
123
143
|
|
|
144
|
+
GITHUB_URL = "https://github.com/"
|
|
145
|
+
|
|
124
146
|
|
|
125
147
|
class VersionNotPresent(Exception):
|
|
126
148
|
"""Raised when a file exists but has no version field configured."""
|
|
@@ -131,6 +153,8 @@ class VersionNotPresent(Exception):
|
|
|
131
153
|
class VersionExtractor(ABC):
|
|
132
154
|
def __init__(self, file_path: Path):
|
|
133
155
|
self.file_path = file_path
|
|
156
|
+
# Display label; discovery overrides it with a root-relative path.
|
|
157
|
+
self.label = file_path.name
|
|
134
158
|
|
|
135
159
|
@abstractmethod
|
|
136
160
|
def get_version(self) -> str:
|
|
@@ -151,6 +175,17 @@ class VersionExtractor(ABC):
|
|
|
151
175
|
def path(self) -> str:
|
|
152
176
|
return str(self.file_path)
|
|
153
177
|
|
|
178
|
+
def get_url(self) -> str | None:
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
def validate_url(self, url: str) -> str | None:
|
|
182
|
+
if match := re.match(GITHUB_URL + r"[^/]+/[^/]+", url.strip("/").lower()):
|
|
183
|
+
valid_url = match.group()
|
|
184
|
+
console.print(
|
|
185
|
+
f"[{STYLE_INFO}]Found url {valid_url} in '{self.file_path}'[/{STYLE_INFO}]"
|
|
186
|
+
)
|
|
187
|
+
return valid_url
|
|
188
|
+
|
|
154
189
|
|
|
155
190
|
class XmlVersionExtractor(VersionExtractor):
|
|
156
191
|
def get_version(self) -> str:
|
|
@@ -177,6 +212,14 @@ class XmlVersionExtractor(VersionExtractor):
|
|
|
177
212
|
with open(self.file_path, "w", encoding="utf-8") as f:
|
|
178
213
|
f.write(new_content)
|
|
179
214
|
|
|
215
|
+
def get_url(self) -> str | None:
|
|
216
|
+
with open(self.file_path, "r", encoding="utf-8") as f:
|
|
217
|
+
content = f.read()
|
|
218
|
+
for url in re.findall(r"<url[^>]*>(.*?)</url>", content, re.I):
|
|
219
|
+
if valid_url := self.validate_url(url):
|
|
220
|
+
return valid_url
|
|
221
|
+
return None
|
|
222
|
+
|
|
180
223
|
|
|
181
224
|
class TomlVersionExtractor(VersionExtractor):
|
|
182
225
|
def __init__(self, file_path: Path, keys: List[str]):
|
|
@@ -215,6 +258,15 @@ class TomlVersionExtractor(VersionExtractor):
|
|
|
215
258
|
with open(self.file_path, "w", encoding="utf-8") as f:
|
|
216
259
|
tomlkit.dump(data, f)
|
|
217
260
|
|
|
261
|
+
def get_url(self) -> str | None:
|
|
262
|
+
with open(self.file_path, "r", encoding="utf-8") as f:
|
|
263
|
+
data = tomlkit.load(f)
|
|
264
|
+
if "project" in data and "urls" in data["project"]:
|
|
265
|
+
for url in data["project"]["urls"].values():
|
|
266
|
+
if valid_url := self.validate_url(url):
|
|
267
|
+
return valid_url
|
|
268
|
+
return None
|
|
269
|
+
|
|
218
270
|
|
|
219
271
|
class YamlVersionExtractor(VersionExtractor):
|
|
220
272
|
def __init__(self, file_path: Path, keys: List[str]):
|
|
@@ -252,6 +304,24 @@ class YamlVersionExtractor(VersionExtractor):
|
|
|
252
304
|
self.yaml.dump(data, f)
|
|
253
305
|
|
|
254
306
|
|
|
307
|
+
class ConanfileVersionExtractor(VersionExtractor):
|
|
308
|
+
def get_version(self) -> str:
|
|
309
|
+
content = self.file_path.read_text()
|
|
310
|
+
if match := re.search(r'\s*version\s*=\s*["\']([^"\']+)["\']', content):
|
|
311
|
+
return match.group(1)
|
|
312
|
+
raise VersionNotPresent(f"Version not found in {self.name}")
|
|
313
|
+
|
|
314
|
+
def update_version(self, new_version: str) -> None:
|
|
315
|
+
pattern = re.compile(r'(\s*version\s*=\s*["\'])([^"\']+)(["\'])')
|
|
316
|
+
content = self.file_path.read_text()
|
|
317
|
+
if not pattern.search(content):
|
|
318
|
+
raise VersionNotPresent(f"Version not found in {self.name}")
|
|
319
|
+
new_content, count = pattern.subn(
|
|
320
|
+
lambda m: f"{m.group(1)}{new_version}{m.group(3)}", content, count=1
|
|
321
|
+
)
|
|
322
|
+
self.file_path.write_text(new_content)
|
|
323
|
+
|
|
324
|
+
|
|
255
325
|
class CMakeListsVersionExtractor(VersionExtractor):
|
|
256
326
|
"""Specialized extractor for CMakeLists.txt that uses cmake-parser
|
|
257
327
|
and handles both direct VERSION and variables (e.g., from package.xml)."""
|
|
@@ -287,7 +357,7 @@ class CMakeListsVersionExtractor(VersionExtractor):
|
|
|
287
357
|
ver = args[version_idx + 1]
|
|
288
358
|
# Check if it's a variable reference
|
|
289
359
|
if not ver.startswith("${"):
|
|
290
|
-
project_version = ver
|
|
360
|
+
project_version = ver.strip('"')
|
|
291
361
|
except ValueError:
|
|
292
362
|
pass
|
|
293
363
|
|
|
@@ -304,6 +374,13 @@ class CMakeListsVersionExtractor(VersionExtractor):
|
|
|
304
374
|
|
|
305
375
|
return self._get_version_regex(content)
|
|
306
376
|
|
|
377
|
+
def get_url(self) -> str | None:
|
|
378
|
+
with open(self.file_path, "r", encoding="utf-8") as f:
|
|
379
|
+
content = f.read()
|
|
380
|
+
if match := re.search(r'HOMEPAGE_URL\s+"([^"]+)"', content):
|
|
381
|
+
return self.validate_url(match.group(1))
|
|
382
|
+
return None
|
|
383
|
+
|
|
307
384
|
def _get_command_args(self, node) -> List[str]:
|
|
308
385
|
"""Extract arguments from a cmake command node."""
|
|
309
386
|
args = []
|
|
@@ -315,16 +392,16 @@ class CMakeListsVersionExtractor(VersionExtractor):
|
|
|
315
392
|
|
|
316
393
|
def _get_version_regex(self, content: str) -> str:
|
|
317
394
|
"""Fallback regex-based version extraction."""
|
|
318
|
-
#
|
|
395
|
+
# Finds set(PROJECT_VERSION "X.Y.Z") or set(PROJECT_VERSION X.Y.Z)
|
|
319
396
|
fallback_pattern = re.compile(
|
|
320
|
-
r'set\s*\(\s*PROJECT_VERSION\s+"([0-9]+\.[0-9]+\.[0-9]+)"',
|
|
397
|
+
r'set\s*\(\s*PROJECT_VERSION\s+"?([0-9]+\.[0-9]+\.[0-9]+)"?\s*\)',
|
|
321
398
|
re.MULTILINE,
|
|
322
399
|
)
|
|
323
400
|
fallback_match = fallback_pattern.search(content)
|
|
324
401
|
|
|
325
402
|
# Also check if project() uses a literal version or variable
|
|
326
403
|
project_pattern = re.compile(
|
|
327
|
-
r
|
|
404
|
+
r'project\s*\([^)]*VERSION\s+"?([\d.]+|\$\{[^}]+\})"?', re.MULTILINE
|
|
328
405
|
)
|
|
329
406
|
project_match = project_pattern.search(content)
|
|
330
407
|
|
|
@@ -346,20 +423,22 @@ class CMakeListsVersionExtractor(VersionExtractor):
|
|
|
346
423
|
with open(self.file_path, "r", encoding="utf-8") as f:
|
|
347
424
|
content = f.read()
|
|
348
425
|
|
|
349
|
-
# Update
|
|
426
|
+
# 1. Update fallback version: matches set(PROJECT_VERSION 1.2.3) or set(PROJECT_VERSION "1.2.3")
|
|
427
|
+
# Always replaces it without quotes: set(PROJECT_VERSION 1.2.3)
|
|
350
428
|
fallback_pattern = re.compile(
|
|
351
|
-
r'(set\s*\(\s*PROJECT_VERSION\s+)"([0-9]+\.[0-9]+\.[0-9]+)"',
|
|
429
|
+
r'(set\s*\(\s*PROJECT_VERSION\s+)"?([0-9]+\.[0-9]+\.[0-9]+)"?\s*\)',
|
|
352
430
|
re.MULTILINE,
|
|
353
431
|
)
|
|
354
432
|
|
|
355
433
|
def repl_fallback(match):
|
|
356
|
-
return f
|
|
434
|
+
return f"{match.group(1)}{new_version})"
|
|
357
435
|
|
|
358
436
|
content = fallback_pattern.sub(repl_fallback, content, count=1)
|
|
359
437
|
|
|
360
|
-
#
|
|
438
|
+
# 2. Update literal version in project(): matches VERSION 1.2.3 or VERSION "1.2.3"
|
|
439
|
+
# Always replaces it without quotes: VERSION 1.2.3
|
|
361
440
|
project_pattern = re.compile(
|
|
362
|
-
r
|
|
441
|
+
r'(project\s*\([^)]*VERSION\s+)"?([\d.]+)"?', re.MULTILINE
|
|
363
442
|
)
|
|
364
443
|
|
|
365
444
|
def repl_project(match):
|
|
@@ -371,6 +450,114 @@ class CMakeListsVersionExtractor(VersionExtractor):
|
|
|
371
450
|
f.write(content)
|
|
372
451
|
|
|
373
452
|
|
|
453
|
+
class DebianChangelogVersionExtractor(VersionExtractor):
|
|
454
|
+
"""Specialized extractor for debian/changelog files.
|
|
455
|
+
|
|
456
|
+
Expects standard format: package (version) distribution; urgency=...
|
|
457
|
+
"""
|
|
458
|
+
|
|
459
|
+
@property
|
|
460
|
+
def name(self) -> str:
|
|
461
|
+
return "debian/changelog"
|
|
462
|
+
|
|
463
|
+
def get_version(self) -> str:
|
|
464
|
+
"""Extracts and returns the clean upstream version string (omitting the Debian suffix)."""
|
|
465
|
+
with open(self.file_path, "r", encoding="utf-8") as f:
|
|
466
|
+
first_line = f.readline()
|
|
467
|
+
|
|
468
|
+
if not first_line:
|
|
469
|
+
raise VersionNotPresent(f"Changelog file {self.name} is empty")
|
|
470
|
+
|
|
471
|
+
match = re.match(r"^[a-zA-Z0-9.+_-]+\s+\(([^)]+)\)", first_line)
|
|
472
|
+
if match:
|
|
473
|
+
full_version = match.group(1)
|
|
474
|
+
# Splits on '-' or '~' to strip the packaging revision (e.g., '1.3.3-1debian1' -> '1.3.3')
|
|
475
|
+
return re.split(r"[-~]", full_version)[0]
|
|
476
|
+
|
|
477
|
+
raise VersionNotPresent(
|
|
478
|
+
f"Could not parse Debian version from first line of {self.name}"
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
def _get_raw_full_version(self) -> str:
|
|
482
|
+
"""Internal helper to get the un-stripped full version string with its suffix."""
|
|
483
|
+
with open(self.file_path, "r", encoding="utf-8") as f:
|
|
484
|
+
first_line = f.readline()
|
|
485
|
+
match = re.match(r"^[a-zA-Z0-9.+_-]+\s+\(([^)]+)\)", first_line)
|
|
486
|
+
return match.group(1) if match else ""
|
|
487
|
+
|
|
488
|
+
def update_version(self, new_version: str) -> None:
|
|
489
|
+
# Compare core upstream versions safely to avoid duplicate entries
|
|
490
|
+
try:
|
|
491
|
+
if self.get_version() == re.split(r"[-~]", new_version)[0]:
|
|
492
|
+
return # Skip adding a duplicate entry
|
|
493
|
+
except VersionNotPresent:
|
|
494
|
+
pass
|
|
495
|
+
|
|
496
|
+
# Grab the raw full version string to parse its suffix before reading full file
|
|
497
|
+
try:
|
|
498
|
+
current_full_version = self._get_raw_full_version()
|
|
499
|
+
except Exception:
|
|
500
|
+
current_full_version = ""
|
|
501
|
+
|
|
502
|
+
with open(self.file_path, "r", encoding="utf-8") as f:
|
|
503
|
+
content = f.read()
|
|
504
|
+
|
|
505
|
+
# Parse the first line to reuse metadata
|
|
506
|
+
first_line = content.splitlines()[0] if content.strip() else ""
|
|
507
|
+
match = re.match(
|
|
508
|
+
r"^([a-zA-Z0-9.+_-]+)\s+\([^)]+\)\s+([^;]+);\s*(urgency=\w+)",
|
|
509
|
+
first_line,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
if match:
|
|
513
|
+
package_name = match.group(1)
|
|
514
|
+
distribution = match.group(2).strip()
|
|
515
|
+
urgency = match.group(3)
|
|
516
|
+
else:
|
|
517
|
+
package_name = "package"
|
|
518
|
+
distribution = "unstable"
|
|
519
|
+
urgency = "urgency=medium"
|
|
520
|
+
|
|
521
|
+
# Determine the final version string to write, preserving the suffix
|
|
522
|
+
new_upstream = re.split(r"[-~]", new_version)[0]
|
|
523
|
+
if "-" in new_version or "~" in new_version:
|
|
524
|
+
formatted_version = new_version
|
|
525
|
+
elif current_full_version and (
|
|
526
|
+
"-" in current_full_version or "~" in current_full_version
|
|
527
|
+
):
|
|
528
|
+
suffix_match = re.search(r"([-~].*)$", current_full_version)
|
|
529
|
+
suffix = suffix_match.group(1) if suffix_match else "-1"
|
|
530
|
+
formatted_version = f"{new_upstream}{suffix}"
|
|
531
|
+
else:
|
|
532
|
+
formatted_version = f"{new_upstream}-1"
|
|
533
|
+
|
|
534
|
+
# Fetch maintainer details
|
|
535
|
+
repo_dir = self.file_path.parent
|
|
536
|
+
name_ok, git_name = run_git_command(["config", "user.name"], cwd=repo_dir)
|
|
537
|
+
email_ok, git_email = run_git_command(["config", "user.email"], cwd=repo_dir)
|
|
538
|
+
|
|
539
|
+
if name_ok and email_ok:
|
|
540
|
+
maintainer = f"{git_name} <{git_email}>"
|
|
541
|
+
else:
|
|
542
|
+
maintainer = "Maintainer <maintainer@example.com>"
|
|
543
|
+
|
|
544
|
+
# Format RFC 5322 timestamp
|
|
545
|
+
now = datetime.datetime.now(datetime.timezone.utc).astimezone()
|
|
546
|
+
debian_date = now.strftime("%a, %d %b %Y %H:%M:%S %z")
|
|
547
|
+
|
|
548
|
+
# Write out using the compliant version with the preserved suffix
|
|
549
|
+
new_entry = (
|
|
550
|
+
f"{package_name} ({formatted_version}) {distribution}; {urgency}\n\n"
|
|
551
|
+
f" * New release: version {new_upstream}\n\n"
|
|
552
|
+
f" -- {maintainer} {debian_date}\n\n"
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
updated_content = new_entry + content
|
|
556
|
+
|
|
557
|
+
with open(self.file_path, "w", encoding="utf-8") as f:
|
|
558
|
+
f.write(updated_content)
|
|
559
|
+
|
|
560
|
+
|
|
374
561
|
class ChangelogVersionExtractor(VersionExtractor):
|
|
375
562
|
def __init__(self, file_path: Path, pattern: str = ""):
|
|
376
563
|
super().__init__(file_path)
|
|
@@ -392,6 +579,8 @@ class ChangelogVersionExtractor(VersionExtractor):
|
|
|
392
579
|
|
|
393
580
|
today = datetime.date.today().isoformat()
|
|
394
581
|
|
|
582
|
+
# Insert new release section
|
|
583
|
+
|
|
395
584
|
pattern = r"^## \[Unreleased\]"
|
|
396
585
|
if not re.search(pattern, content, re.MULTILINE):
|
|
397
586
|
console.print(
|
|
@@ -403,12 +592,221 @@ class ChangelogVersionExtractor(VersionExtractor):
|
|
|
403
592
|
|
|
404
593
|
new_content = re.sub(pattern, replacement, content, count=1, flags=re.MULTILINE)
|
|
405
594
|
|
|
595
|
+
# Add new release link and update unreleased link
|
|
596
|
+
|
|
597
|
+
pattern = r"^\[Unreleased\]: (https://.+)/(v.+)\.\.\.HEAD$"
|
|
598
|
+
|
|
599
|
+
if match := re.search(pattern, new_content, re.MULTILINE):
|
|
600
|
+
url, old_version = match.groups()
|
|
601
|
+
replacement = f"[Unreleased]: {url}/v{new_version}...HEAD\n[{new_version}]: {url}/{old_version}...v{new_version}"
|
|
602
|
+
new_content = re.sub(pattern, replacement, new_content, flags=re.MULTILINE)
|
|
603
|
+
else:
|
|
604
|
+
console.print(
|
|
605
|
+
f"[{STYLE_WARNING}]Warning: Link definitions in CHANGELOG.md can't be updated automatically.[/{STYLE_WARNING}]"
|
|
606
|
+
)
|
|
607
|
+
|
|
406
608
|
with open(self.file_path, "w", encoding="utf-8") as f:
|
|
407
609
|
f.write(new_content)
|
|
408
610
|
|
|
409
|
-
console.print(
|
|
410
|
-
|
|
611
|
+
console.print(f"[{STYLE_INFO}]Updated CHANGELOG.md.[/{STYLE_INFO}]")
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def git_ignored_abs_dirs(repo_dir: Path) -> set:
|
|
615
|
+
"""Absolute paths of git-ignored directories for the repo containing ``repo_dir``.
|
|
616
|
+
|
|
617
|
+
Runs ``git ls-files --directory`` from ``repo_dir`` so git resolves the
|
|
618
|
+
enclosing repository itself; paths are reported relative to ``repo_dir`` and
|
|
619
|
+
resolved to absolute Paths. Empty when ``repo_dir`` is not inside a repo.
|
|
620
|
+
|
|
621
|
+
Used to prune ignored dirs (e.g. build/, output/) during discovery instead
|
|
622
|
+
of walking into them.
|
|
623
|
+
"""
|
|
624
|
+
try:
|
|
625
|
+
result = subprocess.run(
|
|
626
|
+
[
|
|
627
|
+
"git",
|
|
628
|
+
"ls-files",
|
|
629
|
+
"--others",
|
|
630
|
+
"--ignored",
|
|
631
|
+
"--exclude-standard",
|
|
632
|
+
"--directory",
|
|
633
|
+
"-z",
|
|
634
|
+
],
|
|
635
|
+
cwd=repo_dir,
|
|
636
|
+
capture_output=True,
|
|
637
|
+
text=True,
|
|
411
638
|
)
|
|
639
|
+
except (FileNotFoundError, OSError):
|
|
640
|
+
return set()
|
|
641
|
+
|
|
642
|
+
if result.returncode != 0:
|
|
643
|
+
return set()
|
|
644
|
+
|
|
645
|
+
return {
|
|
646
|
+
(repo_dir / entry.rstrip("/")).resolve()
|
|
647
|
+
for entry in result.stdout.split("\0")
|
|
648
|
+
if entry
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def drop_git_ignored(root_dir: Path, paths: List[Path]) -> List[Path]:
|
|
653
|
+
"""Drop paths that git ignores, honoring each path's own enclosing repo.
|
|
654
|
+
|
|
655
|
+
Candidates may live in independent nested git repositories even when
|
|
656
|
+
``root_dir`` is not itself a repo (e.g. a plain folder holding several
|
|
657
|
+
checkouts). Anchoring a single ``git check-ignore`` at ``root_dir`` would
|
|
658
|
+
then bail out entirely and leak ignored build trees like ``output/`` into
|
|
659
|
+
the results. Instead, resolve the repository that actually contains each
|
|
660
|
+
candidate and run ``git check-ignore`` there. Paths outside any repo are
|
|
661
|
+
always kept.
|
|
662
|
+
"""
|
|
663
|
+
if not paths:
|
|
664
|
+
return paths
|
|
665
|
+
|
|
666
|
+
# Group candidates by the git repo top-level that contains them.
|
|
667
|
+
groups: Dict[Path, List[Path]] = {}
|
|
668
|
+
for p in paths:
|
|
669
|
+
ok, top = run_git_command(["rev-parse", "--show-toplevel"], p.parent)
|
|
670
|
+
if not ok or not top:
|
|
671
|
+
continue # not inside any repo -> always kept
|
|
672
|
+
groups.setdefault(Path(top).resolve(), []).append(p)
|
|
673
|
+
|
|
674
|
+
ignored: set = set()
|
|
675
|
+
for top_path, repo_paths in groups.items():
|
|
676
|
+
rel = [str(p.resolve().relative_to(top_path)) for p in repo_paths]
|
|
677
|
+
try:
|
|
678
|
+
result = subprocess.run(
|
|
679
|
+
["git", "check-ignore", "-z", "--stdin"],
|
|
680
|
+
cwd=top_path,
|
|
681
|
+
input="\0".join(rel) + "\0",
|
|
682
|
+
capture_output=True,
|
|
683
|
+
text=True,
|
|
684
|
+
)
|
|
685
|
+
except (FileNotFoundError, OSError):
|
|
686
|
+
continue # keep this repo's candidates on git failure
|
|
687
|
+
|
|
688
|
+
# 0 = some ignored, 1 = none ignored; anything else is an error, keep all.
|
|
689
|
+
if result.returncode not in (0, 1):
|
|
690
|
+
continue
|
|
691
|
+
|
|
692
|
+
ignored_rel = {chunk for chunk in result.stdout.split("\0") if chunk}
|
|
693
|
+
ignored.update(p for p, r in zip(repo_paths, rel) if r in ignored_rel)
|
|
694
|
+
|
|
695
|
+
return [p for p in paths if p not in ignored]
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def git_submodule_dirs(root_dir: Path) -> set:
|
|
699
|
+
"""Absolute paths of git submodule working trees (empty outside a git repo)."""
|
|
700
|
+
ok, top = run_git_command(["rev-parse", "--show-toplevel"], root_dir)
|
|
701
|
+
if not ok or not top:
|
|
702
|
+
return set()
|
|
703
|
+
top = Path(top)
|
|
704
|
+
|
|
705
|
+
ok, out = run_git_command(
|
|
706
|
+
["config", "--file", ".gitmodules", "-z", "--get-regexp", "path"], top
|
|
707
|
+
)
|
|
708
|
+
if not ok or not out:
|
|
709
|
+
return set()
|
|
710
|
+
|
|
711
|
+
dirs = set()
|
|
712
|
+
for record in out.split("\0"):
|
|
713
|
+
key, _, value = record.partition("\n")
|
|
714
|
+
if key.endswith(".path") and value:
|
|
715
|
+
dirs.add((top / value).resolve())
|
|
716
|
+
return dirs
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def drop_submodules(root_dir: Path, paths: List[Path]) -> List[Path]:
|
|
720
|
+
"""Drop paths inside git submodules (they are versioned independently)."""
|
|
721
|
+
if not paths:
|
|
722
|
+
return paths
|
|
723
|
+
subdirs = git_submodule_dirs(root_dir)
|
|
724
|
+
if not subdirs:
|
|
725
|
+
return paths
|
|
726
|
+
kept = []
|
|
727
|
+
for p in paths:
|
|
728
|
+
resolved = p.resolve()
|
|
729
|
+
if any(resolved == s or s in resolved.parents for s in subdirs):
|
|
730
|
+
continue
|
|
731
|
+
kept.append(p)
|
|
732
|
+
return kept
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def discover_package_roots(root_dir: Path) -> List[Path]:
|
|
736
|
+
"""Discover nested ROS package roots (dirs with a package.xml, root excluded).
|
|
737
|
+
|
|
738
|
+
Skips hidden directories (.git, .pixi, ...), git submodules, and — inside a
|
|
739
|
+
git repo — anything git ignores (.gitignore, global excludes), like `fd`.
|
|
740
|
+
|
|
741
|
+
Hidden and ignored directories are pruned during the walk so we never
|
|
742
|
+
descend into large trees like `.venv` or `build/`. Nested repositories are
|
|
743
|
+
handled too: each one's ignore rules are learned when its top-level is
|
|
744
|
+
reached, so an ignored `output/` build tree is pruned even when `root_dir`
|
|
745
|
+
itself is not a git repo.
|
|
746
|
+
"""
|
|
747
|
+
# Seed from root_dir's own repo (covers root being, or living inside, a repo).
|
|
748
|
+
ignored_abs = git_ignored_abs_dirs(root_dir)
|
|
749
|
+
candidates = []
|
|
750
|
+
for dirpath, dirnames, filenames in os.walk(root_dir):
|
|
751
|
+
current = Path(dirpath)
|
|
752
|
+
# A nested repo brings its own .gitignore; learn what it ignores before
|
|
753
|
+
# descending, so its build trees are pruned regardless of root_dir.
|
|
754
|
+
if current != root_dir and (current / ".git").exists():
|
|
755
|
+
ignored_abs |= git_ignored_abs_dirs(current)
|
|
756
|
+
kept = []
|
|
757
|
+
for d in dirnames:
|
|
758
|
+
if d.startswith("."):
|
|
759
|
+
continue
|
|
760
|
+
if ignored_abs and (current / d).resolve() in ignored_abs:
|
|
761
|
+
continue
|
|
762
|
+
kept.append(d)
|
|
763
|
+
dirnames[:] = kept
|
|
764
|
+
if current != root_dir and "package.xml" in filenames:
|
|
765
|
+
candidates.append(current / "package.xml")
|
|
766
|
+
candidates = drop_git_ignored(root_dir, candidates)
|
|
767
|
+
candidates = drop_submodules(root_dir, candidates)
|
|
768
|
+
return sorted({package_xml.parent for package_xml in candidates})
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def build_root_checks(root_dir: Path) -> List[VersionExtractor]:
|
|
772
|
+
"""Build the version extractors tracked at the repository root."""
|
|
773
|
+
return [
|
|
774
|
+
XmlVersionExtractor(root_dir / "package.xml"),
|
|
775
|
+
TomlVersionExtractor(root_dir / "pyproject.toml", ["project", "version"]),
|
|
776
|
+
ChangelogVersionExtractor(root_dir / "CHANGELOG.md", r""),
|
|
777
|
+
TomlVersionExtractor(root_dir / "pixi.toml", ["workspace", "version"]),
|
|
778
|
+
YamlVersionExtractor(root_dir / "CITATION.cff", ["version"]),
|
|
779
|
+
CMakeListsVersionExtractor(root_dir / "CMakeLists.txt"),
|
|
780
|
+
DebianChangelogVersionExtractor(root_dir / "debian/changelog"),
|
|
781
|
+
ConanfileVersionExtractor(root_dir / "conanfile.py"),
|
|
782
|
+
]
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def collect_version_checks(root_dir: Path) -> List[VersionExtractor]:
|
|
786
|
+
"""Root checks plus the same set of checks for each nested package.
|
|
787
|
+
|
|
788
|
+
Each nested package is treated exactly like the repository root: the same
|
|
789
|
+
file/key combinations are checked, and any that don't exist in the nested
|
|
790
|
+
package directory are simply skipped (VersionExtractor.check_file_exists()).
|
|
791
|
+
"""
|
|
792
|
+
checks = list(build_root_checks(root_dir))
|
|
793
|
+
seen_paths = {check.file_path for check in checks}
|
|
794
|
+
|
|
795
|
+
for package_root in discover_package_roots(root_dir):
|
|
796
|
+
for check in build_root_checks(package_root):
|
|
797
|
+
if check.file_path in seen_paths:
|
|
798
|
+
continue
|
|
799
|
+
checks.append(check)
|
|
800
|
+
seen_paths.add(check.file_path)
|
|
801
|
+
|
|
802
|
+
# Label nested files by their relative path so identical basenames stay distinct.
|
|
803
|
+
for check in checks:
|
|
804
|
+
try:
|
|
805
|
+
check.label = str(check.file_path.relative_to(root_dir))
|
|
806
|
+
except ValueError:
|
|
807
|
+
check.label = check.file_path.name
|
|
808
|
+
|
|
809
|
+
return checks
|
|
412
810
|
|
|
413
811
|
|
|
414
812
|
def validate_semver(version: str) -> str:
|
|
@@ -443,22 +841,25 @@ def bump_version(version: str, bump_type: str) -> str:
|
|
|
443
841
|
raise ValueError(f"Invalid bump type: {bump_type}")
|
|
444
842
|
|
|
445
843
|
|
|
446
|
-
def
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
errors
|
|
450
|
-
|
|
844
|
+
def collect_versions(
|
|
845
|
+
checks: List[VersionExtractor],
|
|
846
|
+
) -> Tuple[set, List[str]]:
|
|
847
|
+
"""Collect distinct versions and parse errors across all files. No console output."""
|
|
848
|
+
versions: set = set()
|
|
849
|
+
errors: List[str] = []
|
|
451
850
|
for check in checks:
|
|
452
851
|
if check.check_file_exists():
|
|
453
852
|
try:
|
|
454
|
-
|
|
455
|
-
versions_found.add(version)
|
|
853
|
+
versions.add(check.get_version())
|
|
456
854
|
except VersionNotPresent:
|
|
457
855
|
pass # file exists but has no version configured; skip
|
|
458
856
|
except Exception as e:
|
|
459
|
-
errors.append(f"{check.
|
|
857
|
+
errors.append(f"{check.label}: {e}")
|
|
858
|
+
return versions, errors
|
|
460
859
|
|
|
461
|
-
|
|
860
|
+
|
|
861
|
+
def report_version_problems(versions: set, errors: List[str]) -> None:
|
|
862
|
+
"""Print parse warnings and version-mismatch errors from collect_versions()."""
|
|
462
863
|
if errors:
|
|
463
864
|
console.print(
|
|
464
865
|
f"[{STYLE_WARNING}]Warning: Failed to parse version from some files:[/{STYLE_WARNING}]"
|
|
@@ -466,21 +867,25 @@ def get_current_version(checks: List[VersionExtractor]) -> Optional[str]:
|
|
|
466
867
|
for error in errors:
|
|
467
868
|
console.print(f" [{STYLE_MUTED}]• {error}[/{STYLE_MUTED}]")
|
|
468
869
|
|
|
469
|
-
if len(
|
|
470
|
-
return list(versions_found)[0]
|
|
471
|
-
elif len(versions_found) > 1:
|
|
870
|
+
if len(versions) > 1:
|
|
472
871
|
console.print(
|
|
473
|
-
f"[{
|
|
872
|
+
f"[{STYLE_INFO}]Tip:[/{STYLE_INFO}] use --update-version X.Y.Z to set a single version across all files."
|
|
474
873
|
)
|
|
475
874
|
console.print(
|
|
476
|
-
f"[{
|
|
875
|
+
f"[{STYLE_ERROR}]Error: version mismatch — multiple versions found: "
|
|
876
|
+
f"{', '.join(sorted(versions))}[/{STYLE_ERROR}]"
|
|
477
877
|
)
|
|
478
|
-
|
|
479
|
-
else:
|
|
878
|
+
elif not versions:
|
|
480
879
|
console.print(
|
|
481
880
|
f"[{STYLE_ERROR}]Error: No version found in any files.[/{STYLE_ERROR}]"
|
|
482
881
|
)
|
|
483
|
-
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def get_current_version(checks: List[VersionExtractor]) -> Optional[str]:
|
|
885
|
+
"""Consensus version across all files, reporting any problems."""
|
|
886
|
+
versions, errors = collect_versions(checks)
|
|
887
|
+
report_version_problems(versions, errors)
|
|
888
|
+
return next(iter(versions)) if len(versions) == 1 else None
|
|
484
889
|
|
|
485
890
|
|
|
486
891
|
def infer_change_type(
|
|
@@ -675,14 +1080,19 @@ def git_tag_version(
|
|
|
675
1080
|
auto_confirm: bool,
|
|
676
1081
|
custom_tag_name: Optional[str] = None,
|
|
677
1082
|
custom_tag_message: Optional[str] = None,
|
|
678
|
-
|
|
679
|
-
|
|
1083
|
+
sign: bool = False,
|
|
1084
|
+
) -> Tuple[bool, str]:
|
|
1085
|
+
"""Create a git tag for the version.
|
|
1086
|
+
|
|
1087
|
+
When ``sign`` is True the tag is GPG-signed (``git tag -s``); otherwise an
|
|
1088
|
+
annotated tag is created (``git tag -a``).
|
|
1089
|
+
"""
|
|
680
1090
|
success, _ = run_git_command(["rev-parse", "--git-dir"], root_dir)
|
|
681
1091
|
if not success:
|
|
682
1092
|
console.print(
|
|
683
1093
|
f"[{STYLE_WARNING}]Not a git repository, skipping git tag.[/{STYLE_WARNING}]"
|
|
684
1094
|
)
|
|
685
|
-
return False
|
|
1095
|
+
return False, ""
|
|
686
1096
|
|
|
687
1097
|
tag_name = (
|
|
688
1098
|
custom_tag_name.format(version=version) if custom_tag_name else f"v{version}"
|
|
@@ -698,7 +1108,7 @@ def git_tag_version(
|
|
|
698
1108
|
console.print(
|
|
699
1109
|
f"[{STYLE_WARNING}]Tag {tag_name} already exists.[/{STYLE_WARNING}]"
|
|
700
1110
|
)
|
|
701
|
-
return False
|
|
1111
|
+
return False, ""
|
|
702
1112
|
|
|
703
1113
|
if not auto_confirm:
|
|
704
1114
|
confirmed = Confirm.ask(
|
|
@@ -708,22 +1118,122 @@ def git_tag_version(
|
|
|
708
1118
|
console.print(f"[{STYLE_WARNING}]Git tag skipped.[/{STYLE_WARNING}]")
|
|
709
1119
|
return False
|
|
710
1120
|
|
|
1121
|
+
tag_flag = "-s" if sign else "-a"
|
|
711
1122
|
console.print(
|
|
712
|
-
f"[{STYLE_MUTED}]$ git tag
|
|
1123
|
+
f"[{STYLE_MUTED}]$ git tag {tag_flag} {tag_name} -m '{tag_message}'[/{STYLE_MUTED}]"
|
|
713
1124
|
)
|
|
714
1125
|
success, output = run_git_command(
|
|
715
|
-
["tag",
|
|
1126
|
+
["tag", tag_flag, tag_name, "-m", tag_message], root_dir
|
|
716
1127
|
)
|
|
717
1128
|
if success:
|
|
718
1129
|
console.print(f"[{STYLE_SUCCESS}]✓ Created tag: {tag_name}[/{STYLE_SUCCESS}]")
|
|
719
|
-
|
|
720
|
-
f"[{STYLE_MUTED}] To push: git push origin {tag_name}[/{STYLE_MUTED}]"
|
|
721
|
-
)
|
|
722
|
-
return True
|
|
1130
|
+
return True, tag_name
|
|
723
1131
|
else:
|
|
724
1132
|
console.print(f"[{STYLE_ERROR}]Failed to create tag: {output}[/{STYLE_ERROR}]")
|
|
1133
|
+
return False, ""
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
def push_tag(root_dir: Path, tag_name: str, project_url: str) -> bool:
|
|
1137
|
+
url = project_url.replace(GITHUB_URL, "git@github.com:")
|
|
1138
|
+
git_args = ["push", url, tag_name]
|
|
1139
|
+
console.print(f"[{STYLE_MUTED}]$ git {' '.join(git_args)}")
|
|
1140
|
+
success, output = run_git_command(git_args, cwd=root_dir)
|
|
1141
|
+
if success:
|
|
1142
|
+
console.print(f"[{STYLE_SUCCESS}]✓ Pushed tag: {tag_name}[/{STYLE_SUCCESS}]")
|
|
1143
|
+
else:
|
|
1144
|
+
console.print(f"[{STYLE_ERROR}]Failed to push tag: {output}[/{STYLE_ERROR}]")
|
|
1145
|
+
return success
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def git_archive(
|
|
1149
|
+
root_dir: Path, archive_name: str, tag_name: str, sign: bool = False
|
|
1150
|
+
) -> bool:
|
|
1151
|
+
"""Create a .tar.gz archive from git for the version.
|
|
1152
|
+
|
|
1153
|
+
When ``sign`` is True, a detached GPG .tar.gz.sig file is added.
|
|
1154
|
+
"""
|
|
1155
|
+
git_args = ["archive", "--format", "tgz", "--output", archive_name, tag_name]
|
|
1156
|
+
console.print(f"[{STYLE_MUTED}]$ git {' '.join(git_args)}")
|
|
1157
|
+
success, output = run_git_command(git_args, cwd=root_dir)
|
|
1158
|
+
if not success:
|
|
1159
|
+
console.print(
|
|
1160
|
+
f"[{STYLE_ERROR}]Failed to create archive: {output}[/{STYLE_ERROR}]"
|
|
1161
|
+
)
|
|
725
1162
|
return False
|
|
726
1163
|
|
|
1164
|
+
if sign:
|
|
1165
|
+
call = [
|
|
1166
|
+
"gpg",
|
|
1167
|
+
"--detach-sign",
|
|
1168
|
+
"--armor",
|
|
1169
|
+
"-o",
|
|
1170
|
+
f"{archive_name}.sig",
|
|
1171
|
+
archive_name,
|
|
1172
|
+
]
|
|
1173
|
+
console.print(f"[{STYLE_MUTED}]$ {' '.join(call)}")
|
|
1174
|
+
result = subprocess.run(call, cwd=root_dir, capture_output=True, text=True)
|
|
1175
|
+
if result.returncode != 0:
|
|
1176
|
+
console.print(
|
|
1177
|
+
f"[{STYLE_ERROR}]Failed to sign archive: {result.stderr}[/{STYLE_ERROR}]"
|
|
1178
|
+
)
|
|
1179
|
+
return False
|
|
1180
|
+
|
|
1181
|
+
return True
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def gh_release(
|
|
1185
|
+
root_dir: Path,
|
|
1186
|
+
target_version: str,
|
|
1187
|
+
project_url: str,
|
|
1188
|
+
archive_name: str | None = None,
|
|
1189
|
+
sign: bool = False,
|
|
1190
|
+
dry_run: bool = False,
|
|
1191
|
+
) -> Tuple[bool, str]:
|
|
1192
|
+
"""Create a github release and return (success, output)."""
|
|
1193
|
+
|
|
1194
|
+
title = f"Release v{target_version}"
|
|
1195
|
+
repo = project_url.removeprefix(GITHUB_URL)
|
|
1196
|
+
call = ["gh", "release", "create", "--title", title, "--repo", repo]
|
|
1197
|
+
|
|
1198
|
+
# include latest release notes
|
|
1199
|
+
changelog = root_dir / "CHANGELOG.md"
|
|
1200
|
+
if changelog.is_file():
|
|
1201
|
+
notes = changelog.read_text().split("\n## ")
|
|
1202
|
+
if len(notes) >= 3: # 0: header, 1: unreleased, 2: latest
|
|
1203
|
+
today = datetime.date.today().isoformat()
|
|
1204
|
+
call = [
|
|
1205
|
+
*call,
|
|
1206
|
+
"--notes",
|
|
1207
|
+
notes[1].replace("[Unreleased]", f"[{target_version}] - {today}")
|
|
1208
|
+
if dry_run
|
|
1209
|
+
else notes[2],
|
|
1210
|
+
]
|
|
1211
|
+
else:
|
|
1212
|
+
console.print(
|
|
1213
|
+
f"[{STYLE_WARNING}]Warning: No release notes available in CHANGELOG.md yet.[/{STYLE_WARNING}]"
|
|
1214
|
+
)
|
|
1215
|
+
|
|
1216
|
+
call = [*call, f"v{target_version}"]
|
|
1217
|
+
|
|
1218
|
+
# include .tar.gz archive and .tar.gz.sig signature
|
|
1219
|
+
if archive_name is not None:
|
|
1220
|
+
call = [*call, archive_name]
|
|
1221
|
+
if sign:
|
|
1222
|
+
call = [*call, f"{archive_name}.sig"]
|
|
1223
|
+
|
|
1224
|
+
print_call = "$ '" + "' '".join(c.replace("'", "\\'") for c in call) + "'"
|
|
1225
|
+
if dry_run:
|
|
1226
|
+
return True, print_call
|
|
1227
|
+
|
|
1228
|
+
console.print(f"[{STYLE_MUTED}]$ {print_call}")
|
|
1229
|
+
try:
|
|
1230
|
+
result = subprocess.run(call, cwd=root_dir, capture_output=True, text=True)
|
|
1231
|
+
if result.returncode != 0:
|
|
1232
|
+
return False, result.stderr.strip()
|
|
1233
|
+
return True, result.stdout.strip()
|
|
1234
|
+
except subprocess.CalledProcessError as e:
|
|
1235
|
+
return False, e.stderr or ""
|
|
1236
|
+
|
|
727
1237
|
|
|
728
1238
|
def update_pixi_lock(root_dir: Path, dry_run: bool = False) -> Optional[str]:
|
|
729
1239
|
"""Update pixi.lock file by running 'pixi list'.
|
|
@@ -792,9 +1302,10 @@ def create_backups(file_paths: List[Path]) -> Dict[Path, Path]:
|
|
|
792
1302
|
backups = {}
|
|
793
1303
|
temp_dir = Path(tempfile.mkdtemp(prefix="release_backup_"))
|
|
794
1304
|
|
|
795
|
-
|
|
1305
|
+
# Index-prefix the name so files sharing a basename don't collide.
|
|
1306
|
+
for index, file_path in enumerate(file_paths):
|
|
796
1307
|
if file_path.exists():
|
|
797
|
-
backup_path = temp_dir / file_path.name
|
|
1308
|
+
backup_path = temp_dir / f"{index:04d}_{file_path.name}"
|
|
798
1309
|
shutil.copy2(file_path, backup_path)
|
|
799
1310
|
backups[file_path] = backup_path
|
|
800
1311
|
|
|
@@ -849,16 +1360,16 @@ def list_version_files(checks: List[VersionExtractor]) -> None:
|
|
|
849
1360
|
else f"[{STYLE_ERROR}]✗[/{STYLE_ERROR}]"
|
|
850
1361
|
)
|
|
851
1362
|
file_type = check.__class__.__name__.replace("VersionExtractor", "")
|
|
852
|
-
table.add_row(check.
|
|
1363
|
+
table.add_row(check.label, str(check.file_path), exists, file_type)
|
|
853
1364
|
|
|
854
1365
|
console.print(table)
|
|
855
1366
|
sys.exit(0)
|
|
856
1367
|
|
|
857
1368
|
|
|
858
|
-
def handle_check_version(checks: List[VersionExtractor], args) ->
|
|
1369
|
+
def handle_check_version(checks: List[VersionExtractor], args) -> bool:
|
|
859
1370
|
"""Handle the --check-version command.
|
|
860
1371
|
|
|
861
|
-
Returns
|
|
1372
|
+
Returns True if all files agree on one version, False otherwise.
|
|
862
1373
|
"""
|
|
863
1374
|
results = []
|
|
864
1375
|
versions_found = set()
|
|
@@ -871,7 +1382,7 @@ def handle_check_version(checks: List[VersionExtractor], args) -> int:
|
|
|
871
1382
|
|
|
872
1383
|
for check in checks:
|
|
873
1384
|
result = {
|
|
874
|
-
"file": check.
|
|
1385
|
+
"file": check.label,
|
|
875
1386
|
"version": None,
|
|
876
1387
|
"status": "Unknown",
|
|
877
1388
|
"message": "",
|
|
@@ -904,13 +1415,14 @@ def handle_check_version(checks: List[VersionExtractor], args) -> int:
|
|
|
904
1415
|
consensus_version = "MISMATCH"
|
|
905
1416
|
|
|
906
1417
|
if args.output_format == "json":
|
|
1418
|
+
consistent = not errors and len(versions_found) == 1
|
|
907
1419
|
out_payload = {
|
|
908
1420
|
"consensus_version": consensus_version,
|
|
909
1421
|
"files": results,
|
|
910
|
-
"consistent":
|
|
1422
|
+
"consistent": consistent,
|
|
911
1423
|
}
|
|
912
1424
|
print(json.dumps(out_payload, indent=2))
|
|
913
|
-
return
|
|
1425
|
+
return consistent
|
|
914
1426
|
|
|
915
1427
|
# Standard Rich table output
|
|
916
1428
|
table = Table(title="Version Check Summary", box=box.ROUNDED)
|
|
@@ -939,7 +1451,7 @@ def handle_check_version(checks: List[VersionExtractor], args) -> int:
|
|
|
939
1451
|
):
|
|
940
1452
|
version_display = f"[{STYLE_SUCCESS}]{res['version']}[/{STYLE_SUCCESS}]"
|
|
941
1453
|
elif consensus_version == "MISMATCH":
|
|
942
|
-
version_display = f"[{
|
|
1454
|
+
version_display = f"[{STYLE_ERROR}]{res['version']}[/{STYLE_ERROR}]"
|
|
943
1455
|
|
|
944
1456
|
table.add_row(res["file"], version_display, status_style, res["message"])
|
|
945
1457
|
|
|
@@ -949,27 +1461,53 @@ def handle_check_version(checks: List[VersionExtractor], args) -> int:
|
|
|
949
1461
|
if args.short and consensus_version and consensus_version != "MISMATCH":
|
|
950
1462
|
print(consensus_version)
|
|
951
1463
|
|
|
1464
|
+
tag_check_failed = False
|
|
1465
|
+
tag_format_is_valid = False
|
|
1466
|
+
if hasattr(args, "check_tag") and args.check_tag:
|
|
1467
|
+
if not args.check_tag.startswith("v"):
|
|
1468
|
+
tag_check_failed = True
|
|
1469
|
+
errors = True
|
|
1470
|
+
else:
|
|
1471
|
+
tag_version = args.check_tag.lstrip("v")
|
|
1472
|
+
if consensus_version and consensus_version != "MISMATCH":
|
|
1473
|
+
if consensus_version != tag_version:
|
|
1474
|
+
tag_check_failed = True
|
|
1475
|
+
tag_format_is_valid = True
|
|
1476
|
+
errors = True
|
|
1477
|
+
|
|
952
1478
|
if errors:
|
|
953
1479
|
if len(versions_found) > 1:
|
|
954
1480
|
console.print(
|
|
955
|
-
f"\n[{
|
|
1481
|
+
f"\n[{STYLE_INFO}]Tip:[/{STYLE_INFO}] use --update-version X.Y.Z to set a single version across all files."
|
|
1482
|
+
)
|
|
1483
|
+
console.print(
|
|
1484
|
+
f"[{STYLE_ERROR_STRONG}]FAILURE:[/{STYLE_ERROR_STRONG}] version mismatch — conflicting versions: {', '.join(sorted(versions_found))}"
|
|
956
1485
|
)
|
|
1486
|
+
elif tag_check_failed:
|
|
1487
|
+
if not tag_format_is_valid:
|
|
1488
|
+
console.print(
|
|
1489
|
+
f"\n[{STYLE_ERROR_STRONG}]FAILURE:[/{STYLE_ERROR_STRONG}] Tag version ({args.check_tag}) format is invalid. It should start with 'v' and be a semantic version."
|
|
1490
|
+
)
|
|
1491
|
+
else:
|
|
1492
|
+
console.print(
|
|
1493
|
+
f"\n[{STYLE_ERROR_STRONG}]FAILURE:[/{STYLE_ERROR_STRONG}] Tag version ({args.check_tag.lstrip('v')}) does not match consensus version ({consensus_version})."
|
|
1494
|
+
)
|
|
957
1495
|
else:
|
|
958
1496
|
console.print(
|
|
959
|
-
f"\n[{STYLE_ERROR_STRONG}]FAILURE:[/{STYLE_ERROR_STRONG}]
|
|
1497
|
+
f"\n[{STYLE_ERROR_STRONG}]FAILURE:[/{STYLE_ERROR_STRONG}] parsing errors encountered."
|
|
960
1498
|
)
|
|
961
|
-
return
|
|
1499
|
+
return False
|
|
962
1500
|
elif not versions_found:
|
|
963
1501
|
console.print(
|
|
964
1502
|
f"\n[{STYLE_ERROR_STRONG}]FAILURE:[/{STYLE_ERROR_STRONG}] No version files found in {args.root}."
|
|
965
1503
|
)
|
|
966
|
-
return
|
|
1504
|
+
return False
|
|
967
1505
|
else:
|
|
968
1506
|
if not args.short:
|
|
969
1507
|
console.print(
|
|
970
1508
|
f"\n[{STYLE_SUCCESS_STRONG}]SUCCESS:[/{STYLE_SUCCESS_STRONG}] All files match version [{STYLE_SUCCESS}]{consensus_version}[/{STYLE_SUCCESS}]."
|
|
971
1509
|
)
|
|
972
|
-
return
|
|
1510
|
+
return True
|
|
973
1511
|
|
|
974
1512
|
|
|
975
1513
|
def perform_version_updates(
|
|
@@ -992,7 +1530,7 @@ def perform_version_updates(
|
|
|
992
1530
|
try:
|
|
993
1531
|
if dry_run:
|
|
994
1532
|
curr = check.get_version()
|
|
995
|
-
dry_run_rows.append((check.
|
|
1533
|
+
dry_run_rows.append((check.label, curr, target_version))
|
|
996
1534
|
else:
|
|
997
1535
|
try:
|
|
998
1536
|
old_version = check.get_version()
|
|
@@ -1001,20 +1539,20 @@ def perform_version_updates(
|
|
|
1001
1539
|
except Exception:
|
|
1002
1540
|
old_version = "?"
|
|
1003
1541
|
check.update_version(target_version)
|
|
1004
|
-
dry_run_rows.append((check.
|
|
1542
|
+
dry_run_rows.append((check.label, old_version, target_version))
|
|
1005
1543
|
line = Text()
|
|
1006
|
-
line.append(f" {check.
|
|
1544
|
+
line.append(f" {check.label:<28}", style="cyan")
|
|
1007
1545
|
line.append(old_version, style=STYLE_OLD_VALUE)
|
|
1008
1546
|
line.append(" → ", style="dim")
|
|
1009
1547
|
line.append(target_version, style=STYLE_NEW_VALUE)
|
|
1010
1548
|
console.print(line)
|
|
1011
|
-
updated_files.append(check.
|
|
1549
|
+
updated_files.append(check.label)
|
|
1012
1550
|
updated_file_paths.append(str(check.file_path))
|
|
1013
1551
|
except VersionNotPresent:
|
|
1014
1552
|
pass # file exists but has no version configured; skip
|
|
1015
1553
|
except Exception as e:
|
|
1016
1554
|
console.print(
|
|
1017
|
-
f"[{STYLE_ERROR}]Failed to update {check.
|
|
1555
|
+
f"[{STYLE_ERROR}]Failed to update {check.label}: {e}[/{STYLE_ERROR}]"
|
|
1018
1556
|
)
|
|
1019
1557
|
if not dry_run:
|
|
1020
1558
|
failed = True
|
|
@@ -1037,14 +1575,14 @@ def show_dry_run_panel(
|
|
|
1037
1575
|
console.print(f" [dim]{'─' * 44}[/dim]")
|
|
1038
1576
|
for name, old, new in dry_run_rows:
|
|
1039
1577
|
line = Text()
|
|
1040
|
-
line.append(f" {name:<
|
|
1578
|
+
line.append(f" {name:<28}", style="cyan")
|
|
1041
1579
|
line.append(old, style=STYLE_OLD_VALUE)
|
|
1042
1580
|
line.append(" → ", style="dim")
|
|
1043
1581
|
line.append(new, style=STYLE_NEW_VALUE)
|
|
1044
1582
|
console.print(line)
|
|
1045
1583
|
if pixi_lock_would_update:
|
|
1046
1584
|
line = Text()
|
|
1047
|
-
line.append(f" {'pixi.lock':<
|
|
1585
|
+
line.append(f" {'pixi.lock':<28}", style="cyan")
|
|
1048
1586
|
line.append("regenerated via pixi list", style="dim")
|
|
1049
1587
|
console.print(line)
|
|
1050
1588
|
|
|
@@ -1063,7 +1601,7 @@ def show_result_panel(
|
|
|
1063
1601
|
"""Display a polished summary of completed version updates."""
|
|
1064
1602
|
if pixi_lock_updated:
|
|
1065
1603
|
line = Text()
|
|
1066
|
-
line.append(f" {'pixi.lock':<
|
|
1604
|
+
line.append(f" {'pixi.lock':<28}", style="cyan")
|
|
1067
1605
|
line.append("regenerated via pixi list", style="dim")
|
|
1068
1606
|
console.print(line)
|
|
1069
1607
|
console.print()
|
|
@@ -1152,6 +1690,33 @@ def main():
|
|
|
1152
1690
|
metavar="MESSAGE",
|
|
1153
1691
|
help="Custom git tag message. Use {version} as placeholder for version number. Default: 'Release version {version}'",
|
|
1154
1692
|
)
|
|
1693
|
+
parser.add_argument(
|
|
1694
|
+
"--sign-tag",
|
|
1695
|
+
action="store_true",
|
|
1696
|
+
help="GPG-sign the git tag (uses 'git tag -s' instead of '-a'). Requires a configured signing key (git config user.signingkey). Only meaningful with --git-tag.",
|
|
1697
|
+
)
|
|
1698
|
+
parser.add_argument(
|
|
1699
|
+
"--push-tag",
|
|
1700
|
+
action="store_true",
|
|
1701
|
+
help="Push the tag to the main repository. Only works with github for now.",
|
|
1702
|
+
)
|
|
1703
|
+
|
|
1704
|
+
parser.add_argument(
|
|
1705
|
+
"--git-archive",
|
|
1706
|
+
action="store_true",
|
|
1707
|
+
help="Create a .tar.gz archive of the project with git",
|
|
1708
|
+
)
|
|
1709
|
+
parser.add_argument(
|
|
1710
|
+
"--sign-archive",
|
|
1711
|
+
action="store_true",
|
|
1712
|
+
help="GPG-sign the git archive. Requires a configured signing key. Only meaningful with --git-archive.",
|
|
1713
|
+
)
|
|
1714
|
+
parser.add_argument(
|
|
1715
|
+
"--gh-release",
|
|
1716
|
+
action="store_true",
|
|
1717
|
+
help="Create Github release. Requires a configured gh cli.",
|
|
1718
|
+
)
|
|
1719
|
+
|
|
1155
1720
|
parser.add_argument(
|
|
1156
1721
|
"--short",
|
|
1157
1722
|
action="store_true",
|
|
@@ -1184,6 +1749,12 @@ def main():
|
|
|
1184
1749
|
help="Bump the project version.",
|
|
1185
1750
|
)
|
|
1186
1751
|
|
|
1752
|
+
parser.add_argument(
|
|
1753
|
+
"--check-tag",
|
|
1754
|
+
type=str,
|
|
1755
|
+
help="Check that the consensus version matches the given tag (e.g., v2.15.0). Only meaningful with --check-version.",
|
|
1756
|
+
)
|
|
1757
|
+
|
|
1187
1758
|
args = parser.parse_args()
|
|
1188
1759
|
root_dir = args.root
|
|
1189
1760
|
|
|
@@ -1194,6 +1765,9 @@ def main():
|
|
|
1194
1765
|
else:
|
|
1195
1766
|
console = Console()
|
|
1196
1767
|
|
|
1768
|
+
if args.check_tag and not args.check_version:
|
|
1769
|
+
parser.error("--check-tag requires --check-version")
|
|
1770
|
+
|
|
1197
1771
|
if args.update_version:
|
|
1198
1772
|
try:
|
|
1199
1773
|
if not re.match(r"^\d+\.\d+\.\d+$", args.update_version):
|
|
@@ -1207,14 +1781,7 @@ def main():
|
|
|
1207
1781
|
)
|
|
1208
1782
|
sys.exit(1)
|
|
1209
1783
|
|
|
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
|
-
]
|
|
1784
|
+
checks: List[VersionExtractor] = collect_version_checks(root_dir)
|
|
1218
1785
|
|
|
1219
1786
|
if args.list_files:
|
|
1220
1787
|
if args.output_format == "json":
|
|
@@ -1222,7 +1789,7 @@ def main():
|
|
|
1222
1789
|
for check in checks:
|
|
1223
1790
|
files_list.append(
|
|
1224
1791
|
{
|
|
1225
|
-
"name": check.
|
|
1792
|
+
"name": check.label,
|
|
1226
1793
|
"path": str(check.file_path),
|
|
1227
1794
|
"exists": check.check_file_exists(),
|
|
1228
1795
|
"type": check.__class__.__name__.replace(
|
|
@@ -1236,14 +1803,15 @@ def main():
|
|
|
1236
1803
|
sys.exit(0)
|
|
1237
1804
|
|
|
1238
1805
|
if args.check_version:
|
|
1239
|
-
sys.exit(handle_check_version(checks, args))
|
|
1806
|
+
sys.exit(0 if handle_check_version(checks, args) else 1)
|
|
1240
1807
|
|
|
1241
1808
|
current_version = None
|
|
1242
1809
|
new_version_str = None
|
|
1243
1810
|
|
|
1244
1811
|
if args.update_version:
|
|
1245
1812
|
new_version_str = args.update_version
|
|
1246
|
-
|
|
1813
|
+
versions, _ = collect_versions(checks)
|
|
1814
|
+
current_version = next(iter(versions)) if len(versions) == 1 else None
|
|
1247
1815
|
if not args.dry_run:
|
|
1248
1816
|
console.print(
|
|
1249
1817
|
f"[{STYLE_INFO}]Updating versions to {new_version_str} in {root_dir}...[/{STYLE_INFO}]"
|
|
@@ -1358,6 +1926,22 @@ def main():
|
|
|
1358
1926
|
elif args.short:
|
|
1359
1927
|
print(target_version)
|
|
1360
1928
|
|
|
1929
|
+
project_url = ""
|
|
1930
|
+
archive_name = ""
|
|
1931
|
+
if args.push_tag:
|
|
1932
|
+
for check in checks:
|
|
1933
|
+
if url := check.get_url():
|
|
1934
|
+
project_url = url
|
|
1935
|
+
break
|
|
1936
|
+
else:
|
|
1937
|
+
where = "CMakeLists.txt, package.xml, or pyproject.toml"
|
|
1938
|
+
console.print(
|
|
1939
|
+
f"[{STYLE_ERROR_STRONG}]Error: can't push tag without a github homepage in {where}.[/{STYLE_ERROR_STRONG}]"
|
|
1940
|
+
)
|
|
1941
|
+
sys.exit(1)
|
|
1942
|
+
if args.git_archive:
|
|
1943
|
+
archive_name = f"{project_url.split('/')[-1]}-{target_version}.tar.gz"
|
|
1944
|
+
|
|
1361
1945
|
if args.dry_run:
|
|
1362
1946
|
pixi_lock_would_update = (root_dir / "pixi.lock").exists()
|
|
1363
1947
|
|
|
@@ -1376,6 +1960,7 @@ def main():
|
|
|
1376
1960
|
)
|
|
1377
1961
|
git_lines.append(f"$ git add {' '.join(rel_paths) if rel_paths else '-u'}")
|
|
1378
1962
|
git_lines.append(f"$ git commit -m '{commit_message}'")
|
|
1963
|
+
|
|
1379
1964
|
if args.git_tag is not None:
|
|
1380
1965
|
custom_tag_name = None if args.git_tag is True else args.git_tag
|
|
1381
1966
|
tag_name = (
|
|
@@ -1388,7 +1973,32 @@ def main():
|
|
|
1388
1973
|
if args.git_tag_message
|
|
1389
1974
|
else f"Release version {target_version}"
|
|
1390
1975
|
)
|
|
1391
|
-
|
|
1976
|
+
tag_flag = "-s" if args.sign_tag else "-a"
|
|
1977
|
+
git_lines.append(f"$ git tag {tag_flag} {tag_name} -m '{tag_message}'")
|
|
1978
|
+
if args.push_tag:
|
|
1979
|
+
url = project_url.replace(GITHUB_URL, "git@github.com:")
|
|
1980
|
+
git_lines.append(f"$ git push {url} {tag_name}")
|
|
1981
|
+
|
|
1982
|
+
if args.git_archive:
|
|
1983
|
+
git_lines.append(
|
|
1984
|
+
f"$ git archive --format tgz --output {archive_name} {tag_name}"
|
|
1985
|
+
)
|
|
1986
|
+
|
|
1987
|
+
if args.sign_archive:
|
|
1988
|
+
git_lines.append(
|
|
1989
|
+
f"$ gpg --detach-sign --armor -o {archive_name}.sig {archive_name}"
|
|
1990
|
+
)
|
|
1991
|
+
|
|
1992
|
+
if args.gh_release:
|
|
1993
|
+
_, call = gh_release(
|
|
1994
|
+
root_dir,
|
|
1995
|
+
target_version,
|
|
1996
|
+
project_url,
|
|
1997
|
+
archive_name,
|
|
1998
|
+
args.sign_archive,
|
|
1999
|
+
dry_run=True,
|
|
2000
|
+
)
|
|
2001
|
+
git_lines.append(call)
|
|
1392
2002
|
|
|
1393
2003
|
show_dry_run_panel(dry_run_rows, pixi_lock_would_update, git_lines)
|
|
1394
2004
|
sys.exit(0)
|
|
@@ -1402,6 +2012,36 @@ def main():
|
|
|
1402
2012
|
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
2013
|
)
|
|
1404
2014
|
|
|
2015
|
+
if args.sign_tag and args.git_tag is None:
|
|
2016
|
+
console.print(
|
|
2017
|
+
f"[{STYLE_WARNING}]Warning: --sign-tag has no effect without --git-tag.[/{STYLE_WARNING}]"
|
|
2018
|
+
)
|
|
2019
|
+
|
|
2020
|
+
if args.gh_release and not args.push_tag:
|
|
2021
|
+
console.print(
|
|
2022
|
+
f"[{STYLE_WARNING}]Warning: --gh-release has no effect without --push-tag.[/{STYLE_WARNING}]"
|
|
2023
|
+
)
|
|
2024
|
+
|
|
2025
|
+
if args.sign_archive and args.git_archive is None:
|
|
2026
|
+
console.print(
|
|
2027
|
+
f"[{STYLE_WARNING}]Warning: --sign-archive has no effect without --git-archive.[/{STYLE_WARNING}]"
|
|
2028
|
+
)
|
|
2029
|
+
|
|
2030
|
+
if args.git_archive and (root_dir / archive_name).is_file():
|
|
2031
|
+
console.print(
|
|
2032
|
+
f"[{STYLE_WARNING_STRONG}]Warning: {archive_name} already exists ![/{STYLE_WARNING_STRONG}]"
|
|
2033
|
+
)
|
|
2034
|
+
if Confirm.ask(
|
|
2035
|
+
"[bold]Remove this file (and the .sig if it is also there) and continue ?[/bold]",
|
|
2036
|
+
default=True,
|
|
2037
|
+
):
|
|
2038
|
+
(root_dir / archive_name).unlink()
|
|
2039
|
+
sig = root_dir / f"{archive_name}.sig"
|
|
2040
|
+
if sig.is_file():
|
|
2041
|
+
sig.unlink()
|
|
2042
|
+
else:
|
|
2043
|
+
sys.exit(1)
|
|
2044
|
+
|
|
1405
2045
|
if args.git_commit is not None:
|
|
1406
2046
|
custom_message = None if args.git_commit is True else args.git_commit
|
|
1407
2047
|
git_commit_version(
|
|
@@ -1414,14 +2054,45 @@ def main():
|
|
|
1414
2054
|
|
|
1415
2055
|
if args.git_tag is not None:
|
|
1416
2056
|
custom_tag_name = None if args.git_tag is True else args.git_tag
|
|
1417
|
-
git_tag_version(
|
|
2057
|
+
_success, tag_name = git_tag_version(
|
|
1418
2058
|
root_dir,
|
|
1419
2059
|
target_version,
|
|
1420
2060
|
args.confirm,
|
|
1421
2061
|
custom_tag_name,
|
|
1422
2062
|
args.git_tag_message,
|
|
2063
|
+
args.sign_tag,
|
|
1423
2064
|
)
|
|
1424
2065
|
|
|
2066
|
+
if args.push_tag:
|
|
2067
|
+
success = push_tag(root_dir, tag_name, project_url)
|
|
2068
|
+
if not success:
|
|
2069
|
+
sys.exit(1)
|
|
2070
|
+
|
|
2071
|
+
if args.git_archive and archive_name:
|
|
2072
|
+
success = git_archive(root_dir, archive_name, tag_name, args.sign_archive)
|
|
2073
|
+
if not success:
|
|
2074
|
+
sys.exit(1)
|
|
2075
|
+
|
|
2076
|
+
if args.gh_release:
|
|
2077
|
+
if args.git_tag is None:
|
|
2078
|
+
console.print(
|
|
2079
|
+
f"[{STYLE_WARNING}]Warning: --gh-release has no effect without --git-tag.[/{STYLE_WARNING}]"
|
|
2080
|
+
)
|
|
2081
|
+
elif not args.push_tag:
|
|
2082
|
+
console.print(
|
|
2083
|
+
f"[{STYLE_WARNING}]Warning: --gh-release has no effect without --push-tag.[/{STYLE_WARNING}]"
|
|
2084
|
+
)
|
|
2085
|
+
else:
|
|
2086
|
+
success, _ = gh_release(
|
|
2087
|
+
root_dir,
|
|
2088
|
+
target_version,
|
|
2089
|
+
project_url,
|
|
2090
|
+
archive_name,
|
|
2091
|
+
args.sign_archive,
|
|
2092
|
+
)
|
|
2093
|
+
if not success:
|
|
2094
|
+
sys.exit(1)
|
|
2095
|
+
|
|
1425
2096
|
|
|
1426
2097
|
if __name__ == "__main__":
|
|
1427
2098
|
try:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|