# -*- coding: utf-8 -*- """ Semantic Versioning (SemVer) Implementierung. Unterstützt das Parsen, Vergleichen und Validieren von semantischen Versionsnummern nach dem SemVer 2.0.0 Standard. """ from __future__ import annotations import re from dataclasses import dataclass from functools import total_ordering from typing import Any # SemVer Regex Pattern SEMVER_PATTERN = re.compile( r"^(?P0|[1-9]\d*)" r"\.(?P0|[1-9]\d*)" r"\.(?P0|[1-9]\d*)" r"(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)" r"(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?" r"(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$" ) # Einfacheres Pattern für grundlegende Versionen SIMPLE_VERSION_PATTERN = re.compile( r"^(?P\d+)(?:\.(?P\d+))?(?:\.(?P\d+))?" r"(?:-(?P[0-9a-zA-Z.-]+))?" r"(?:\+(?P[0-9a-zA-Z.-]+))?$" ) @total_ordering @dataclass class SemanticVersion: """ Semantische Versionsnummer. Implementiert SemVer 2.0.0 mit major.minor.patch[-prerelease][+build]. Attributes: major: Hauptversion (inkompatible API-Änderungen) minor: Nebenversion (abwärtskompatible Funktionalität) patch: Patch-Version (abwärtskompatible Bugfixes) prerelease: Pre-Release-Bezeichner (alpha, beta, rc) build: Build-Metadaten Example: v1 = SemanticVersion(1, 2, 3) v2 = parse_version("1.2.4") if v2 > v1: print("v2 ist neuer") v3 = SemanticVersion(2, 0, 0, prerelease="alpha.1") print(v3) # 2.0.0-alpha.1 """ major: int minor: int patch: int prerelease: str | None = None build: str | None = None def __post_init__(self) -> None: """Validiert die Version.""" if self.major < 0 or self.minor < 0 or self.patch < 0: raise ValueError("Version darf keine negativen Zahlen enthalten") def __str__(self) -> str: """Gibt die Version als String zurück.""" version = f"{self.major}.{self.minor}.{self.patch}" if self.prerelease: version += f"-{self.prerelease}" if self.build: version += f"+{self.build}" return version def __repr__(self) -> str: return f"SemanticVersion({self})" def __hash__(self) -> int: return hash((self.major, self.minor, self.patch, self.prerelease)) def __eq__(self, other: object) -> bool: if not isinstance(other, SemanticVersion): return NotImplemented return ( self.major == other.major and self.minor == other.minor and self.patch == other.patch and self.prerelease == other.prerelease ) def __lt__(self, other: SemanticVersion) -> bool: if not isinstance(other, SemanticVersion): return NotImplemented # Major, Minor, Patch vergleichen if (self.major, self.minor, self.patch) != (other.major, other.minor, other.patch): return (self.major, self.minor, self.patch) < (other.major, other.minor, other.patch) # Pre-release Vergleich # Eine Version ohne Pre-release ist größer als eine mit Pre-release if self.prerelease is None and other.prerelease is None: return False if self.prerelease is None: return False # Keine Pre-release > Pre-release if other.prerelease is None: return True # Pre-release < Keine Pre-release # Pre-release-Teile vergleichen return self._compare_prerelease(self.prerelease, other.prerelease) < 0 @staticmethod def _compare_prerelease(a: str, b: str) -> int: """ Vergleicht zwei Pre-release-Strings. Returns: -1 wenn a < b, 0 wenn gleich, 1 wenn a > b """ parts_a = a.split(".") parts_b = b.split(".") for part_a, part_b in zip(parts_a, parts_b): # Numerische Teile numerisch vergleichen is_num_a = part_a.isdigit() is_num_b = part_b.isdigit() if is_num_a and is_num_b: num_a, num_b = int(part_a), int(part_b) if num_a != num_b: return -1 if num_a < num_b else 1 elif is_num_a: return -1 # Numerisch < String elif is_num_b: return 1 # String > Numerisch else: if part_a != part_b: return -1 if part_a < part_b else 1 # Längerer Pre-release hat höhere Priorität if len(parts_a) != len(parts_b): return -1 if len(parts_a) < len(parts_b) else 1 return 0 @property def is_prerelease(self) -> bool: """Ist dies eine Pre-Release-Version?""" return self.prerelease is not None @property def is_stable(self) -> bool: """Ist dies eine stabile Version (>= 1.0.0)?""" return self.major >= 1 and not self.is_prerelease def bump_major(self) -> SemanticVersion: """Gibt eine neue Version mit erhöhter Major-Version zurück.""" return SemanticVersion(self.major + 1, 0, 0) def bump_minor(self) -> SemanticVersion: """Gibt eine neue Version mit erhöhter Minor-Version zurück.""" return SemanticVersion(self.major, self.minor + 1, 0) def bump_patch(self) -> SemanticVersion: """Gibt eine neue Version mit erhöhter Patch-Version zurück.""" return SemanticVersion(self.major, self.minor, self.patch + 1) def to_tuple(self) -> tuple[int, int, int]: """Gibt die Version als Tuple zurück.""" return (self.major, self.minor, self.patch) def to_dict(self) -> dict[str, Any]: """Gibt die Version als Dictionary zurück.""" return { "major": self.major, "minor": self.minor, "patch": self.patch, "prerelease": self.prerelease, "build": self.build, } class VersionRange: """ Versionsbereich für Abhängigkeitsspezifikationen. Unterstützt verschiedene Operatoren: - Exakt: "1.2.3" oder "=1.2.3" - Größer: ">1.0.0", ">=1.0.0" - Kleiner: "<2.0.0", "<=2.0.0" - Bereich: ">=1.0.0 <2.0.0" - Caret: "^1.2.3" (kompatibel mit 1.x.x) - Tilde: "~1.2.3" (kompatibel mit 1.2.x) - Wildcard: "1.2.*", "1.*" Example: range1 = VersionRange("^1.2.0") assert range1.satisfies(parse_version("1.5.0")) assert not range1.satisfies(parse_version("2.0.0")) range2 = VersionRange(">=1.0.0 <2.0.0") assert range2.satisfies(parse_version("1.9.9")) """ OPERATORS = [">=", "<=", "!=", ">", "<", "=", "^", "~"] def __init__(self, spec: str) -> None: """ Initialisiert den Versionsbereich. Args: spec: Versionsspezifikation """ self._spec = spec.strip() self._constraints = self._parse_spec(self._spec) def _parse_spec(self, spec: str) -> list[tuple[str, SemanticVersion]]: """Parst die Spezifikation in Constraints.""" constraints = [] # Wildcard-Handling if "*" in spec: return self._parse_wildcard(spec) # Mehrere Constraints (durch Leerzeichen oder Komma getrennt) parts = re.split(r"[,\s]+", spec) for part in parts: part = part.strip() if not part: continue # Operator extrahieren operator = "=" version_str = part for op in sorted(self.OPERATORS, key=len, reverse=True): if part.startswith(op): operator = op version_str = part[len(op):].strip() break # Caret und Tilde zu Range konvertieren if operator == "^": constraints.extend(self._expand_caret(version_str)) elif operator == "~": constraints.extend(self._expand_tilde(version_str)) else: version = parse_version(version_str) constraints.append((operator, version)) return constraints def _parse_wildcard(self, spec: str) -> list[tuple[str, SemanticVersion]]: """Parst Wildcard-Spezifikationen.""" parts = spec.replace("*", "0").split(".") if len(parts) == 1: # "*" -> Alles return [] major = int(parts[0]) if parts[0] != "0" or spec.startswith("0") else 0 if len(parts) == 2 or (len(parts) >= 2 and parts[1] == "0" and "*" in spec): # "1.*" -> >=1.0.0 <2.0.0 return [ (">=", SemanticVersion(major, 0, 0)), ("<", SemanticVersion(major + 1, 0, 0)), ] minor = int(parts[1]) # "1.2.*" -> >=1.2.0 <1.3.0 return [ (">=", SemanticVersion(major, minor, 0)), ("<", SemanticVersion(major, minor + 1, 0)), ] def _expand_caret(self, version_str: str) -> list[tuple[str, SemanticVersion]]: """Expandiert Caret-Range (^1.2.3 -> >=1.2.3 <2.0.0).""" version = parse_version(version_str) if version.major == 0: if version.minor == 0: # ^0.0.x -> =0.0.x upper = SemanticVersion(0, 0, version.patch + 1) else: # ^0.x.y -> >=0.x.y <0.(x+1).0 upper = SemanticVersion(0, version.minor + 1, 0) else: # ^x.y.z -> >=x.y.z <(x+1).0.0 upper = SemanticVersion(version.major + 1, 0, 0) return [(">=", version), ("<", upper)] def _expand_tilde(self, version_str: str) -> list[tuple[str, SemanticVersion]]: """Expandiert Tilde-Range (~1.2.3 -> >=1.2.3 <1.3.0).""" version = parse_version(version_str) upper = SemanticVersion(version.major, version.minor + 1, 0) return [(">=", version), ("<", upper)] def satisfies(self, version: SemanticVersion) -> bool: """ Prüft, ob eine Version den Bereich erfüllt. Args: version: Zu prüfende Version Returns: True wenn die Version im Bereich liegt """ if not self._constraints: return True # Leerer Bereich = Alles for operator, constraint in self._constraints: if not self._check_constraint(version, operator, constraint): return False return True def _check_constraint( self, version: SemanticVersion, operator: str, constraint: SemanticVersion ) -> bool: """Prüft einen einzelnen Constraint.""" if operator == "=": return version == constraint elif operator == "!=": return version != constraint elif operator == ">": return version > constraint elif operator == ">=": return version >= constraint elif operator == "<": return version < constraint elif operator == "<=": return version <= constraint return False @property def spec(self) -> str: """Gibt die ursprüngliche Spezifikation zurück.""" return self._spec def __str__(self) -> str: return self._spec def __repr__(self) -> str: return f"VersionRange({self._spec!r})" def __contains__(self, version: SemanticVersion) -> bool: return self.satisfies(version) def parse_version(version_str: str) -> SemanticVersion: """ Parst einen Versions-String zu einer SemanticVersion. Unterstützt vollständige und vereinfachte Versionen: - "1.2.3" - "1.2.3-alpha.1" - "1.2.3+build.123" - "1.2" -> "1.2.0" - "1" -> "1.0.0" Args: version_str: Versions-String Returns: SemanticVersion-Objekt Raises: ValueError: Bei ungültigem Format """ version_str = version_str.strip() if version_str.startswith("v") or version_str.startswith("V"): version_str = version_str[1:] # Erst vollständiges SemVer probieren match = SEMVER_PATTERN.match(version_str) if not match: # Einfacheres Pattern probieren match = SIMPLE_VERSION_PATTERN.match(version_str) if not match: raise ValueError(f"Ungültiges Versionsformat: {version_str}") groups = match.groupdict() major = int(groups["major"]) minor = int(groups.get("minor") or 0) patch = int(groups.get("patch") or 0) prerelease = groups.get("prerelease") build = groups.get("build") return SemanticVersion(major, minor, patch, prerelease, build) def parse_range(spec: str) -> VersionRange: """ Parst einen Range-String zu einem VersionRange. Args: spec: Range-Spezifikation Returns: VersionRange-Objekt """ return VersionRange(spec)