save progress
This commit is contained in:
237
tools/slntools/lib/version_utils.py
Normal file
237
tools/slntools/lib/version_utils.py
Normal file
@@ -0,0 +1,237 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user