""" 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