Files
git.stella-ops.org/tools/slntools/lib/version_utils.py
StellaOps Bot cec4265a40 save progress
2025-12-28 01:40:52 +02:00

238 lines
6.3 KiB
Python

"""
Version parsing and comparison utilities for NuGet packages.
Handles SemVer versions with prerelease suffixes.
"""
import re
from dataclasses import dataclass
from typing import Optional
@dataclass(frozen=True)
class ParsedVersion:
"""Parsed semantic version."""
major: int
minor: int
patch: int
prerelease: Optional[str] = None
build_metadata: Optional[str] = None
original: str = ""
def is_stable(self) -> bool:
"""Returns True if this is a stable (non-prerelease) version."""
return self.prerelease is None
def __lt__(self, other: "ParsedVersion") -> bool:
"""Compare versions following SemVer rules."""
# Compare major.minor.patch first
self_tuple = (self.major, self.minor, self.patch)
other_tuple = (other.major, other.minor, other.patch)
if self_tuple != other_tuple:
return self_tuple < other_tuple
# If equal, prerelease versions are less than stable
if self.prerelease is None and other.prerelease is None:
return False
if self.prerelease is None:
return False # stable > prerelease
if other.prerelease is None:
return True # prerelease < stable
# Both have prerelease - compare alphanumerically
return self._compare_prerelease(self.prerelease, other.prerelease) < 0
def __le__(self, other: "ParsedVersion") -> bool:
return self == other or self < other
def __gt__(self, other: "ParsedVersion") -> bool:
return other < self
def __ge__(self, other: "ParsedVersion") -> bool:
return self == other or self > other
@staticmethod
def _compare_prerelease(a: str, b: str) -> int:
"""Compare prerelease strings according to SemVer."""
a_parts = a.split(".")
b_parts = b.split(".")
for i in range(max(len(a_parts), len(b_parts))):
if i >= len(a_parts):
return -1
if i >= len(b_parts):
return 1
a_part = a_parts[i]
b_part = b_parts[i]
# Try numeric comparison first
a_is_num = a_part.isdigit()
b_is_num = b_part.isdigit()
if a_is_num and b_is_num:
diff = int(a_part) - int(b_part)
if diff != 0:
return diff
elif a_is_num:
return -1 # Numeric < string
elif b_is_num:
return 1 # String > numeric
else:
# Both strings - compare lexically
if a_part < b_part:
return -1
if a_part > b_part:
return 1
return 0
# Regex for parsing NuGet versions
# Matches: 1.2.3, 1.2.3-beta, 1.2.3-beta.1, 1.2.3-rc.1+build, [1.2.3]
VERSION_PATTERN = re.compile(
r"^\[?" # Optional opening bracket
r"(\d+)" # Major (required)
r"(?:\.(\d+))?" # Minor (optional)
r"(?:\.(\d+))?" # Patch (optional)
r"(?:-([a-zA-Z0-9][a-zA-Z0-9.-]*))?" # Prerelease (optional)
r"(?:\+([a-zA-Z0-9][a-zA-Z0-9.-]*))?" # Build metadata (optional)
r"\]?$" # Optional closing bracket
)
# Pattern for wildcard versions (e.g., 1.0.*)
WILDCARD_PATTERN = re.compile(r"\*")
def parse_version(version_str: str) -> Optional[ParsedVersion]:
"""
Parse a NuGet version string.
Args:
version_str: Version string like "1.2.3", "1.2.3-beta.1", "[1.2.3]"
Returns:
ParsedVersion if valid, None if invalid or wildcard
"""
if not version_str:
return None
version_str = version_str.strip()
# Skip wildcard versions
if WILDCARD_PATTERN.search(version_str):
return None
match = VERSION_PATTERN.match(version_str)
if not match:
return None
major = int(match.group(1))
minor = int(match.group(2)) if match.group(2) else 0
patch = int(match.group(3)) if match.group(3) else 0
prerelease = match.group(4)
build_metadata = match.group(5)
return ParsedVersion(
major=major,
minor=minor,
patch=patch,
prerelease=prerelease,
build_metadata=build_metadata,
original=version_str,
)
def is_stable(version_str: str) -> bool:
"""
Check if a version string represents a stable release.
Args:
version_str: Version string to check
Returns:
True if stable (no prerelease suffix), False otherwise
"""
parsed = parse_version(version_str)
if parsed is None:
return False
return parsed.is_stable()
def compare_versions(v1: str, v2: str) -> int:
"""
Compare two version strings.
Args:
v1: First version string
v2: Second version string
Returns:
-1 if v1 < v2, 0 if equal, 1 if v1 > v2
Returns 0 if either version is unparseable
"""
parsed_v1 = parse_version(v1)
parsed_v2 = parse_version(v2)
if parsed_v1 is None or parsed_v2 is None:
return 0
if parsed_v1 < parsed_v2:
return -1
if parsed_v1 > parsed_v2:
return 1
return 0
def select_latest_stable(versions: list[str]) -> Optional[str]:
"""
Select the latest stable version from a list.
Args:
versions: List of version strings
Returns:
Latest stable version string, or None if no stable versions exist
"""
stable_versions: list[tuple[ParsedVersion, str]] = []
for v in versions:
parsed = parse_version(v)
if parsed is not None and parsed.is_stable():
stable_versions.append((parsed, v))
if not stable_versions:
return None
# Sort by parsed version and return the original string of the max
stable_versions.sort(key=lambda x: x[0], reverse=True)
return stable_versions[0][1]
def normalize_version_string(version_str: str) -> str:
"""
Normalize a version string to a canonical form.
Strips brackets, whitespace, and normalizes format.
Args:
version_str: Version string to normalize
Returns:
Normalized version string
"""
parsed = parse_version(version_str)
if parsed is None:
return version_str.strip()
# Rebuild canonical form
result = f"{parsed.major}.{parsed.minor}.{parsed.patch}"
if parsed.prerelease:
result += f"-{parsed.prerelease}"
if parsed.build_metadata:
result += f"+{parsed.build_metadata}"
return result