# -*- coding: utf-8 -*- # Copyright (c) The python-semanticversion project # This code is distributed under the two-clause BSD License. import functools import re def _to_int(value): try: return int(value), True except ValueError: return value, False def _has_leading_zero(value): return (value and value[0] == '0' and value.isdigit() and value != '0') def base_cmp(x, y): if x == y: return 0 elif x > y: return 1 elif x < y: return -1 else: return NotImplemented def identifier_cmp(a, b): """Compare two identifier (for pre-release/build components).""" a_cmp, a_is_int = _to_int(a) b_cmp, b_is_int = _to_int(b) if a_is_int and b_is_int: # Numeric identifiers are compared as integers return base_cmp(a_cmp, b_cmp) elif a_is_int: # Numeric identifiers have lower precedence return -1 elif b_is_int: return 1 else: # Non-numeric identifiers are compared lexicographically return base_cmp(a_cmp, b_cmp) def identifier_list_cmp(a, b): """Compare two identifier list (pre-release/build components). The rule is: - Identifiers are paired between lists - They are compared from left to right - If all first identifiers match, the longest list is greater. >>> identifier_list_cmp(['1', '2'], ['1', '2']) 0 >>> identifier_list_cmp(['1', '2a'], ['1', '2b']) -1 >>> identifier_list_cmp(['1'], ['1', '2']) -1 """ identifier_pairs = zip(a, b) for id_a, id_b in identifier_pairs: cmp_res = identifier_cmp(id_a, id_b) if cmp_res != 0: return cmp_res # alpha1.3 < alpha1.3.1 return base_cmp(len(a), len(b)) class Version: version_re = re.compile(r'^(\d+)\.(\d+)\.(\d+)(?:-([0-9a-zA-Z.-]+))?(?:\+([0-9a-zA-Z.-]+))?$') partial_version_re = re.compile(r'^(\d+)(?:\.(\d+)(?:\.(\d+))?)?(?:-([0-9a-zA-Z.-]*))?(?:\+([0-9a-zA-Z.-]*))?$') def __init__( self, version_string=None, *, major=None, minor=None, patch=None, prerelease=None, build=None, partial=False): has_text = version_string is not None has_parts = not (major is minor is patch is prerelease is build is None) if not has_text ^ has_parts: raise ValueError("Call either Version('1.2.3') or Version(major=1, ...).") if has_text: major, minor, patch, prerelease, build = self.parse(version_string, partial) else: # Convenience: allow to omit prerelease/build. if not partial: prerelease = prerelease or () build = build or () self._validate_kwargs(major, minor, patch, prerelease, build, partial) self.major = major self.minor = minor self.patch = patch self.prerelease = prerelease self.build = build self.partial = partial @classmethod def _coerce(cls, value, allow_none=False): if value is None and allow_none: return value return int(value) def next_major(self): if self.prerelease and self.minor == self.patch == 0: return Version( major=self.major, minor=0, patch=0, partial=self.partial, ) else: return Version( major=self.major + 1, minor=0, patch=0, partial=self.partial, ) def next_minor(self): if self.prerelease and self.patch == 0: return Version( major=self.major, minor=self.minor, patch=0, partial=self.partial, ) else: return Version( major=self.major, minor=self.minor + 1, patch=0, partial=self.partial, ) def next_patch(self): if self.prerelease: return Version( major=self.major, minor=self.minor, patch=self.patch, partial=self.partial, ) else: return Version( major=self.major, minor=self.minor, patch=self.patch + 1, partial=self.partial, ) @classmethod def coerce(cls, version_string, partial=False): """Coerce an arbitrary version string into a semver-compatible one. The rule is: - If not enough components, fill minor/patch with zeroes; unless partial=True - If more than 3 dot-separated components, extra components are "build" data. If some "build" data already appeared, append it to the extra components Examples: >>> Version.coerce('0.1') Version(0, 1, 0) >>> Version.coerce('0.1.2.3') Version(0, 1, 2, (), ('3',)) >>> Version.coerce('0.1.2.3+4') Version(0, 1, 2, (), ('3', '4')) >>> Version.coerce('0.1+2-3+4_5') Version(0, 1, 0, (), ('2-3', '4-5')) """ base_re = re.compile(r'^\d+(?:\.\d+(?:\.\d+)?)?') match = base_re.match(version_string) if not match: raise ValueError( "Version string lacks a numerical component: %r" % version_string ) version = version_string[:match.end()] if not partial: # We need a not-partial version. while version.count('.') < 2: version += '.0' if match.end() == len(version_string): return Version(version, partial=partial) rest = version_string[match.end():] # Cleanup the 'rest' rest = re.sub(r'[^a-zA-Z0-9+.-]', '-', rest) if rest[0] == '+': # A 'build' component prerelease = '' build = rest[1:] elif rest[0] == '.': # An extra version component, probably 'build' prerelease = '' build = rest[1:] elif rest[0] == '-': rest = rest[1:] if '+' in rest: prerelease, build = rest.split('+', 1) else: prerelease, build = rest, '' elif '+' in rest: prerelease, build = rest.split('+', 1) else: prerelease, build = rest, '' build = build.replace('+', '.') if prerelease: version = '%s-%s' % (version, prerelease) if build: version = '%s+%s' % (version, build) return cls(version, partial=partial) @classmethod def parse(cls, version_string, partial=False, coerce=False): """Parse a version string into a Version() object. Args: version_string (str), the version string to parse partial (bool), whether to accept incomplete input coerce (bool), whether to try to map the passed in string into a valid Version. """ if not version_string: raise ValueError('Invalid empty version string: %r' % version_string) if partial: version_re = cls.partial_version_re else: version_re = cls.version_re match = version_re.match(version_string) if not match: raise ValueError('Invalid version string: %r' % version_string) major, minor, patch, prerelease, build = match.groups() if _has_leading_zero(major): raise ValueError("Invalid leading zero in major: %r" % version_string) if _has_leading_zero(minor): raise ValueError("Invalid leading zero in minor: %r" % version_string) if _has_leading_zero(patch): raise ValueError("Invalid leading zero in patch: %r" % version_string) major = int(major) minor = cls._coerce(minor, partial) patch = cls._coerce(patch, partial) if prerelease is None: if partial and (build is None): # No build info, strip here return (major, minor, patch, None, None) else: prerelease = () elif prerelease == '': prerelease = () else: prerelease = tuple(prerelease.split('.')) cls._validate_identifiers(prerelease, allow_leading_zeroes=False) if build is None: if partial: build = None else: build = () elif build == '': build = () else: build = tuple(build.split('.')) cls._validate_identifiers(build, allow_leading_zeroes=True) return (major, minor, patch, prerelease, build) @classmethod def _validate_identifiers(cls, identifiers, allow_leading_zeroes=False): for item in identifiers: if not item: raise ValueError( "Invalid empty identifier %r in %r" % (item, '.'.join(identifiers)) ) if item[0] == '0' and item.isdigit() and item != '0' and not allow_leading_zeroes: raise ValueError("Invalid leading zero in identifier %r" % item) @classmethod def _validate_kwargs(cls, major, minor, patch, prerelease, build, partial): if ( major != int(major) or minor != cls._coerce(minor, partial) or patch != cls._coerce(patch, partial) or prerelease is None and not partial or build is None and not partial ): raise ValueError( "Invalid kwargs to Version(major=%r, minor=%r, patch=%r, " "prerelease=%r, build=%r, partial=%r" % ( major, minor, patch, prerelease, build, partial )) if prerelease is not None: cls._validate_identifiers(prerelease, allow_leading_zeroes=False) if build is not None: cls._validate_identifiers(build, allow_leading_zeroes=True) def __iter__(self): return iter((self.major, self.minor, self.patch, self.prerelease, self.build)) def __str__(self): version = '%d' % self.major if self.minor is not None: version = '%s.%d' % (version, self.minor) if self.patch is not None: version = '%s.%d' % (version, self.patch) if self.prerelease or (self.partial and self.prerelease == () and self.build is None): version = '%s-%s' % (version, '.'.join(self.prerelease)) if self.build or (self.partial and self.build == ()): version = '%s+%s' % (version, '.'.join(self.build)) return version def __repr__(self): return '%s(%r%s)' % ( self.__class__.__name__, str(self), ', partial=True' if self.partial else '', ) @classmethod def _comparison_functions(cls, partial=False): """Retrieve comparison methods to apply on version components. This is a private API. Args: partial (bool): whether to provide 'partial' or 'strict' matching. Returns: 5-tuple of cmp-like functions. """ def prerelease_cmp(a, b): """Compare prerelease components. Special rule: a version without prerelease component has higher precedence than one with a prerelease component. """ if a and b: return identifier_list_cmp(a, b) elif a: # Versions with prerelease field have lower precedence return -1 elif b: return 1 else: return 0 def build_cmp(a, b): """Compare build metadata. Special rule: there is no ordering on build metadata. """ if a == b: return 0 else: return NotImplemented def make_optional(orig_cmp_fun): """Convert a cmp-like function to consider 'None == *'.""" @functools.wraps(orig_cmp_fun) def alt_cmp_fun(a, b): if a is None or b is None: return 0 return orig_cmp_fun(a, b) return alt_cmp_fun if partial: return [ base_cmp, # Major is still mandatory make_optional(base_cmp), make_optional(base_cmp), make_optional(prerelease_cmp), make_optional(build_cmp), ] else: return [ base_cmp, base_cmp, base_cmp, prerelease_cmp, build_cmp, ] def __compare(self, other): comparison_functions = self._comparison_functions(partial=self.partial or other.partial) comparisons = zip(comparison_functions, self, other) for cmp_fun, self_field, other_field in comparisons: cmp_res = cmp_fun(self_field, other_field) if cmp_res != 0: return cmp_res return 0 def __hash__(self): # We don't include 'partial', since this is strictly equivalent to having # at least a field being `None`. return hash((self.major, self.minor, self.patch, self.prerelease, self.build)) def __cmp__(self, other): if not isinstance(other, self.__class__): return NotImplemented return self.__compare(other) def __compare_helper(self, other, condition, notimpl_target): """Helper for comparison. Allows the caller to provide: - The condition - The return value if the comparison is meaningless (ie versions with build metadata). """ if not isinstance(other, self.__class__): return NotImplemented cmp_res = self.__cmp__(other) if cmp_res is NotImplemented: return notimpl_target return condition(cmp_res) def __eq__(self, other): return self.__compare_helper(other, lambda x: x == 0, notimpl_target=False) def __ne__(self, other): return self.__compare_helper(other, lambda x: x != 0, notimpl_target=True) def __lt__(self, other): return self.__compare_helper(other, lambda x: x < 0, notimpl_target=False) def __le__(self, other): return self.__compare_helper(other, lambda x: x <= 0, notimpl_target=False) def __gt__(self, other): return self.__compare_helper(other, lambda x: x > 0, notimpl_target=False) def __ge__(self, other): return self.__compare_helper(other, lambda x: x >= 0, notimpl_target=False) class SpecItem: """A requirement specification.""" KIND_ANY = '*' KIND_LT = '<' KIND_LTE = '<=' KIND_EQUAL = '==' KIND_SHORTEQ = '=' KIND_EMPTY = '' KIND_GTE = '>=' KIND_GT = '>' KIND_NEQ = '!=' KIND_CARET = '^' KIND_TILDE = '~' KIND_COMPATIBLE = '~=' # Map a kind alias to its full version KIND_ALIASES = { KIND_SHORTEQ: KIND_EQUAL, KIND_EMPTY: KIND_EQUAL, } re_spec = re.compile(r'^(<|<=||=|==|>=|>|!=|\^|~|~=)(\d.*)$') def __init__(self, requirement_string): kind, spec = self.parse(requirement_string) self.kind = kind self.spec = spec @classmethod def parse(cls, requirement_string): if not requirement_string: raise ValueError("Invalid empty requirement specification: %r" % requirement_string) # Special case: the 'any' version spec. if requirement_string == '*': return (cls.KIND_ANY, '') match = cls.re_spec.match(requirement_string) if not match: raise ValueError("Invalid requirement specification: %r" % requirement_string) kind, version = match.groups() if kind in cls.KIND_ALIASES: kind = cls.KIND_ALIASES[kind] spec = Version(version, partial=True) if spec.build is not None and kind not in (cls.KIND_EQUAL, cls.KIND_NEQ): raise ValueError( "Invalid requirement specification %r: build numbers have no ordering." % requirement_string ) return (kind, spec) def match(self, version): if self.kind == self.KIND_ANY: return True elif self.kind == self.KIND_LT: return version < self.spec elif self.kind == self.KIND_LTE: return version <= self.spec elif self.kind == self.KIND_EQUAL: return version == self.spec elif self.kind == self.KIND_GTE: return version >= self.spec elif self.kind == self.KIND_GT: return version > self.spec elif self.kind == self.KIND_NEQ: return version != self.spec elif self.kind == self.KIND_CARET: if self.spec.major != 0: upper = self.spec.next_major() elif self.spec.minor != 0: upper = self.spec.next_minor() else: upper = self.spec.next_patch() return self.spec <= version < upper elif self.kind == self.KIND_TILDE: return self.spec <= version < self.spec.next_minor() elif self.kind == self.KIND_COMPATIBLE: if self.spec.patch is not None: upper = self.spec.next_minor() else: upper = self.spec.next_major() return self.spec <= version < upper else: # pragma: no cover raise ValueError('Unexpected match kind: %r' % self.kind) def __str__(self): return '%s%s' % (self.kind, self.spec) def __repr__(self): return '' % (self.kind, self.spec) def __eq__(self, other): if not isinstance(other, SpecItem): return NotImplemented return self.kind == other.kind and self.spec == other.spec def __hash__(self): return hash((self.kind, self.spec)) class Spec: def __init__(self, *specs_strings): subspecs = [self.parse(spec) for spec in specs_strings] self.specs = sum(subspecs, ()) @classmethod def parse(self, specs_string): spec_texts = specs_string.split(',') return tuple(SpecItem(spec_text) for spec_text in spec_texts) def match(self, version): """Check whether a Version satisfies the Spec.""" return all(spec.match(version) for spec in self.specs) def filter(self, versions): """Filter an iterable of versions satisfying the Spec.""" for version in versions: if self.match(version): yield version def select(self, versions): """Select the best compatible version among an iterable of options.""" options = list(self.filter(versions)) if options: return max(options) return None def __contains__(self, version): if isinstance(version, Version): return self.match(version) return False def __iter__(self): return iter(self.specs) def __str__(self): return ','.join(str(spec) for spec in self.specs) def __repr__(self): return '' % (self.specs,) def __eq__(self, other): if not isinstance(other, Spec): return NotImplemented return set(self.specs) == set(other.specs) def __hash__(self): return hash(self.specs) def compare(v1, v2): return base_cmp(Version(v1), Version(v2)) def match(spec, version): return Spec(spec).match(Version(version)) def validate(version_string): """Validates a version string againt the SemVer specification.""" try: Version.parse(version_string) return True except ValueError: return False