238 lines
6.3 KiB
Python
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
|