From b2604317980b9cb26f1445dfb6ff82c84f9cbe65 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Mon, 30 Mar 2020 12:46:22 +0530 Subject: add version range checks --- semver.py | 1420 +++++++++++++++++++++++++++++++++++++++++++++++++++ talerbuildconfig.py | 19 +- testconfigure.py | 2 +- 3 files changed, 1438 insertions(+), 3 deletions(-) create mode 100644 semver.py diff --git a/semver.py b/semver.py new file mode 100644 index 0000000..7fd871e --- /dev/null +++ b/semver.py @@ -0,0 +1,1420 @@ +# -*- coding: utf-8 -*- +# Copyright (c) The python-semanticversion project +# This code is distributed under the two-clause BSD License. + +import functools +import re +import warnings + + +def _has_leading_zero(value): + return (value + and value[0] == '0' + and value.isdigit() + and value != '0') + + +class MaxIdentifier(object): + __slots__ = [] + + def __repr__(self): + return 'MaxIdentifier()' + + def __eq__(self, other): + return isinstance(other, self.__class__) + + +@functools.total_ordering +class NumericIdentifier(object): + __slots__ = ['value'] + + def __init__(self, value): + self.value = int(value) + + def __repr__(self): + return 'NumericIdentifier(%r)' % self.value + + def __eq__(self, other): + if isinstance(other, NumericIdentifier): + return self.value == other.value + return NotImplemented + + def __lt__(self, other): + if isinstance(other, MaxIdentifier): + return True + elif isinstance(other, AlphaIdentifier): + return True + elif isinstance(other, NumericIdentifier): + return self.value < other.value + else: + return NotImplemented + + +@functools.total_ordering +class AlphaIdentifier(object): + __slots__ = ['value'] + + def __init__(self, value): + self.value = value.encode('ascii') + + def __repr__(self): + return 'AlphaIdentifier(%r)' % self.value + + def __eq__(self, other): + if isinstance(other, AlphaIdentifier): + return self.value == other.value + return NotImplemented + + def __lt__(self, other): + if isinstance(other, MaxIdentifier): + return True + elif isinstance(other, NumericIdentifier): + return False + elif isinstance(other, AlphaIdentifier): + return self.value < other.value + else: + return NotImplemented + + +class Version(object): + + 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): + if partial: + warnings.warn( + "Partial versions will be removed in 3.0; use SimpleSpec('1.x.x') instead.", + DeprecationWarning, + stacklevel=2, + ) + 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. + prerelease = tuple(prerelease or ()) + if not partial: + build = tuple(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, + ) + + def truncate(self, level='patch'): + """Return a new Version object, truncated up to the selected level.""" + if level == 'build': + return self + elif level == 'prerelease': + return Version( + major=self.major, + minor=self.minor, + patch=self.patch, + prerelease=self.prerelease, + partial=self.partial, + ) + elif level == 'patch': + return Version( + major=self.major, + minor=self.minor, + patch=self.patch, + partial=self.partial, + ) + elif level == 'minor': + return Version( + major=self.major, + minor=self.minor, + patch=None if self.partial else 0, + partial=self.partial, + ) + elif level == 'major': + return Version( + major=self.major, + minor=None if self.partial else 0, + patch=None if self.partial else 0, + partial=self.partial, + ) + else: + raise ValueError("Invalid truncation level `%s`." % level) + + @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' + + # Strip leading zeros in components + # Version is of the form nn, nn.pp or nn.pp.qq + version = '.'.join( + # If the part was '0', we end up with an empty string. + part.lstrip('0') or '0' + for part in version.split('.') + ) + + 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 '', + ) + + 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)) + + @property + def precedence_key(self): + if self.prerelease: + prerelease_key = tuple( + NumericIdentifier(part) if re.match(r'^[0-9]+$', part) else AlphaIdentifier(part) + for part in self.prerelease + ) + else: + prerelease_key = ( + MaxIdentifier(), + ) + + return ( + self.major, + self.minor, + self.patch, + prerelease_key, + ) + + def __cmp__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + if self < other: + return -1 + elif self > other: + return 1 + elif self == other: + return 0 + else: + return NotImplemented + + def __eq__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + return ( + self.major == other.major + and self.minor == other.minor + and self.patch == other.patch + and (self.prerelease or ()) == (other.prerelease or ()) + and (self.build or ()) == (other.build or ()) + ) + + def __ne__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + return tuple(self) != tuple(other) + + def __lt__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + return self.precedence_key < other.precedence_key + + def __le__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + return self.precedence_key <= other.precedence_key + + def __gt__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + return self.precedence_key > other.precedence_key + + def __ge__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + return self.precedence_key >= other.precedence_key + + +class SpecItem(object): + """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, _warn=True): + if _warn: + warnings.warn( + "The `SpecItem` class will be removed in 3.0.", + DeprecationWarning, + stacklevel=2, + ) + kind, spec = self.parse(requirement_string) + self.kind = kind + self.spec = spec + self._clause = Spec(requirement_string).clause + + @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) + + @classmethod + def from_matcher(cls, matcher): + if matcher == Always(): + return cls('*', _warn=False) + elif matcher == Never(): + return cls('<0.0.0-', _warn=False) + elif isinstance(matcher, Range): + return cls('%s%s' % (matcher.operator, matcher.target), _warn=False) + + def match(self, version): + return self._clause.match(version) + + 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)) + + +def compare(v1, v2): + return Version(v1).__cmp__(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 + + +DEFAULT_SYNTAX = 'simple' + + +class BaseSpec(object): + """A specification of compatible versions. + + Usage: + >>> Spec('>=1.0.0', syntax='npm') + + A version matches a specification if it matches any + of the clauses of that specification. + + Internally, a Spec is AnyOf( + AllOf(Matcher, Matcher, Matcher), + AllOf(...), + ) + """ + SYNTAXES = {} + + @classmethod + def register_syntax(cls, subclass): + syntax = subclass.SYNTAX + if syntax is None: + raise ValueError("A Spec needs its SYNTAX field to be set.") + elif syntax in cls.SYNTAXES: + raise ValueError( + "Duplicate syntax for %s: %r, %r" + % (syntax, cls.SYNTAXES[syntax], subclass) + ) + cls.SYNTAXES[syntax] = subclass + return subclass + + def __init__(self, expression): + super(BaseSpec, self).__init__() + self.expression = expression + self.clause = self._parse_to_clause(expression) + + @classmethod + def parse(cls, expression, syntax=DEFAULT_SYNTAX): + """Convert a syntax-specific expression into a BaseSpec instance.""" + return cls.SYNTAXES[syntax](expression) + + @classmethod + def _parse_to_clause(cls, expression): + """Converts an expression to a clause.""" + raise NotImplementedError() + + def filter(self, versions): + """Filter an iterable of versions satisfying the Spec.""" + for version in versions: + if self.match(version): + yield version + + def match(self, version): + """Check whether a Version satisfies the Spec.""" + return self.clause.match(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): + """Whether `version in self`.""" + if isinstance(version, Version): + return self.match(version) + return False + + def __eq__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + + return self.clause == other.clause + + def __hash__(self): + return hash(self.clause) + + def __str__(self): + return self.expression + + def __repr__(self): + return '<%s: %r>' % (self.__class__.__name__, self.expression) + + +class Clause(object): + __slots__ = [] + + def match(self, version): + raise NotImplementedError() + + def __and__(self, other): + raise NotImplementedError() + + def __or__(self, other): + raise NotImplementedError() + + def __eq__(self, other): + raise NotImplementedError() + + def prettyprint(self, indent='\t'): + """Pretty-print the clause. + """ + return '\n'.join(self._pretty()).replace('\t', indent) + + def _pretty(self): + """Actual pretty-printing logic. + + Yields: + A list of string. Indentation is performed with \t. + """ + yield repr(self) + + def __ne__(self, other): + return not self == other + + def simplify(self): + return self + + +class AnyOf(Clause): + __slots__ = ['clauses'] + + def __init__(self, *clauses): + super(AnyOf, self).__init__() + self.clauses = frozenset(clauses) + + def match(self, version): + return any(c.match(version) for c in self.clauses) + + def simplify(self): + subclauses = set() + for clause in self.clauses: + simplified = clause.simplify() + if isinstance(simplified, AnyOf): + subclauses |= simplified.clauses + elif simplified == Never(): + continue + else: + subclauses.add(simplified) + if len(subclauses) == 1: + return subclauses.pop() + return AnyOf(*subclauses) + + def __hash__(self): + return hash((AnyOf, self.clauses)) + + def __iter__(self): + return iter(self.clauses) + + def __eq__(self, other): + return isinstance(other, self.__class__) and self.clauses == other.clauses + + def __and__(self, other): + if isinstance(other, AllOf): + return other & self + elif isinstance(other, Matcher) or isinstance(other, AnyOf): + return AllOf(self, other) + else: + return NotImplemented + + def __or__(self, other): + if isinstance(other, AnyOf): + clauses = list(self.clauses | other.clauses) + elif isinstance(other, Matcher) or isinstance(other, AllOf): + clauses = list(self.clauses | set([other])) + else: + return NotImplemented + return AnyOf(*clauses) + + def __repr__(self): + return 'AnyOf(%s)' % ', '.join(sorted(repr(c) for c in self.clauses)) + + def _pretty(self): + yield 'AnyOF(' + for clause in self.clauses: + lines = list(clause._pretty()) + for line in lines[:-1]: + yield '\t' + line + yield '\t' + lines[-1] + ',' + yield ')' + + +class AllOf(Clause): + __slots__ = ['clauses'] + + def __init__(self, *clauses): + super(AllOf, self).__init__() + self.clauses = frozenset(clauses) + + def match(self, version): + return all(clause.match(version) for clause in self.clauses) + + def simplify(self): + subclauses = set() + for clause in self.clauses: + simplified = clause.simplify() + if isinstance(simplified, AllOf): + subclauses |= simplified.clauses + elif simplified == Always(): + continue + else: + subclauses.add(simplified) + if len(subclauses) == 1: + return subclauses.pop() + return AllOf(*subclauses) + + def __hash__(self): + return hash((AllOf, self.clauses)) + + def __iter__(self): + return iter(self.clauses) + + def __eq__(self, other): + return isinstance(other, self.__class__) and self.clauses == other.clauses + + def __and__(self, other): + if isinstance(other, Matcher) or isinstance(other, AnyOf): + clauses = list(self.clauses | set([other])) + elif isinstance(other, AllOf): + clauses = list(self.clauses | other.clauses) + else: + return NotImplemented + return AllOf(*clauses) + + def __or__(self, other): + if isinstance(other, AnyOf): + return other | self + elif isinstance(other, Matcher): + return AnyOf(self, AllOf(other)) + elif isinstance(other, AllOf): + return AnyOf(self, other) + else: + return NotImplemented + + def __repr__(self): + return 'AllOf(%s)' % ', '.join(sorted(repr(c) for c in self.clauses)) + + def _pretty(self): + yield 'AllOF(' + for clause in self.clauses: + lines = list(clause._pretty()) + for line in lines[:-1]: + yield '\t' + line + yield '\t' + lines[-1] + ',' + yield ')' + + +class Matcher(Clause): + __slots__ = [] + + def __and__(self, other): + if isinstance(other, AllOf): + return other & self + elif isinstance(other, Matcher) or isinstance(other, AnyOf): + return AllOf(self, other) + else: + return NotImplemented + + def __or__(self, other): + if isinstance(other, AnyOf): + return other | self + elif isinstance(other, Matcher) or isinstance(other, AllOf): + return AnyOf(self, other) + else: + return NotImplemented + + +class Never(Matcher): + __slots__ = [] + + def match(self, version): + return False + + def __hash__(self): + return hash((Never,)) + + def __eq__(self, other): + return isinstance(other, self.__class__) + + def __and__(self, other): + return self + + def __or__(self, other): + return other + + def __repr__(self): + return 'Never()' + + +class Always(Matcher): + __slots__ = [] + + def match(self, version): + return True + + def __hash__(self): + return hash((Always,)) + + def __eq__(self, other): + return isinstance(other, self.__class__) + + def __and__(self, other): + return other + + def __or__(self, other): + return self + + def __repr__(self): + return 'Always()' + + +class Range(Matcher): + OP_EQ = '==' + OP_GT = '>' + OP_GTE = '>=' + OP_LT = '<' + OP_LTE = '<=' + OP_NEQ = '!=' + + # <1.2.3 matches 1.2.3-a1 + PRERELEASE_ALWAYS = 'always' + # <1.2.3 does not match 1.2.3-a1 + PRERELEASE_NATURAL = 'natural' + # 1.2.3-a1 is only considered if target == 1.2.3-xxx + PRERELEASE_SAMEPATCH = 'same-patch' + + # 1.2.3 matches 1.2.3+* + BUILD_IMPLICIT = 'implicit' + # 1.2.3 matches only 1.2.3, not 1.2.3+4 + BUILD_STRICT = 'strict' + + __slots__ = ['operator', 'target', 'prerelease_policy', 'build_policy'] + + def __init__(self, operator, target, prerelease_policy=PRERELEASE_NATURAL, build_policy=BUILD_IMPLICIT): + super(Range, self).__init__() + if target.build and operator not in (self.OP_EQ, self.OP_NEQ): + raise ValueError( + "Invalid range %s%s: build numbers have no ordering." + % (operator, target)) + self.operator = operator + self.target = target + self.prerelease_policy = prerelease_policy + self.build_policy = self.BUILD_STRICT if target.build else build_policy + + def match(self, version): + if self.build_policy != self.BUILD_STRICT: + version = version.truncate('prerelease') + + if version.prerelease: + same_patch = self.target.truncate() == version.truncate() + + if self.prerelease_policy == self.PRERELEASE_SAMEPATCH and not same_patch: + return False + + if self.operator == self.OP_EQ: + if self.build_policy == self.BUILD_STRICT: + return ( + self.target.truncate('prerelease') == version.truncate('prerelease') + and version.build == self.target.build + ) + return version == self.target + elif self.operator == self.OP_GT: + return version > self.target + elif self.operator == self.OP_GTE: + return version >= self.target + elif self.operator == self.OP_LT: + if ( + version.prerelease + and self.prerelease_policy == self.PRERELEASE_NATURAL + and version.truncate() == self.target.truncate() + and not self.target.prerelease + ): + return False + return version < self.target + elif self.operator == self.OP_LTE: + return version <= self.target + else: + assert self.operator == self.OP_NEQ + if self.build_policy == self.BUILD_STRICT: + return not ( + self.target.truncate('prerelease') == version.truncate('prerelease') + and version.build == self.target.build + ) + + if ( + version.prerelease + and self.prerelease_policy == self.PRERELEASE_NATURAL + and version.truncate() == self.target.truncate() + and not self.target.prerelease + ): + return False + return version != self.target + + def __hash__(self): + return hash((Range, self.operator, self.target, self.prerelease_policy)) + + def __eq__(self, other): + return ( + isinstance(other, self.__class__) + and self.operator == other.operator + and self.target == other.target + and self.prerelease_policy == other.prerelease_policy + ) + + def __str__(self): + return '%s%s' % (self.operator, self.target) + + def __repr__(self): + policy_part = ( + '' if self.prerelease_policy == self.PRERELEASE_NATURAL + else ', prerelease_policy=%r' % self.prerelease_policy + ) + ( + '' if self.build_policy == self.BUILD_IMPLICIT + else ', build_policy=%r' % self.build_policy + ) + return 'Range(%r, %r%s)' % ( + self.operator, + self.target, + policy_part, + ) + + +@BaseSpec.register_syntax +class SimpleSpec(BaseSpec): + + SYNTAX = 'simple' + + @classmethod + def _parse_to_clause(cls, expression): + return cls.Parser.parse(expression) + + class Parser: + NUMBER = r'\*|0|[1-9][0-9]*' + NAIVE_SPEC = re.compile(r"""^ + (?P<|<=||=|==|>=|>|!=|\^|~|~=) + (?P{nb})(?:\.(?P{nb})(?:\.(?P{nb}))?)? + (?:-(?P[a-z0-9A-Z.-]*))? + (?:\+(?P[a-z0-9A-Z.-]*))? + $ + """.format(nb=NUMBER), + re.VERBOSE, + ) + + @classmethod + def parse(cls, expression): + blocks = expression.split(',') + clause = Always() + for block in blocks: + if not cls.NAIVE_SPEC.match(block): + raise ValueError("Invalid simple block %r" % block) + clause &= cls.parse_block(block) + + return clause + + PREFIX_CARET = '^' + PREFIX_TILDE = '~' + PREFIX_COMPATIBLE = '~=' + PREFIX_EQ = '==' + PREFIX_NEQ = '!=' + PREFIX_GT = '>' + PREFIX_GTE = '>=' + PREFIX_LT = '<' + PREFIX_LTE = '<=' + + PREFIX_ALIASES = { + '=': PREFIX_EQ, + '': PREFIX_EQ, + } + + EMPTY_VALUES = ['*', 'x', 'X', None] + + @classmethod + def parse_block(cls, expr): + if not cls.NAIVE_SPEC.match(expr): + raise ValueError("Invalid simple spec component: %r" % expr) + prefix, major_t, minor_t, patch_t, prerel, build = cls.NAIVE_SPEC.match(expr).groups() + prefix = cls.PREFIX_ALIASES.get(prefix, prefix) + + major = None if major_t in cls.EMPTY_VALUES else int(major_t) + minor = None if minor_t in cls.EMPTY_VALUES else int(minor_t) + patch = None if patch_t in cls.EMPTY_VALUES else int(patch_t) + + if major is None: # '*' + target = Version(major=0, minor=0, patch=0) + if prefix not in (cls.PREFIX_EQ, cls.PREFIX_GTE): + raise ValueError("Invalid simple spec: %r" % expr) + elif minor is None: + target = Version(major=major, minor=0, patch=0) + elif patch is None: + target = Version(major=major, minor=minor, patch=0) + else: + target = Version( + major=major, + minor=minor, + patch=patch, + prerelease=prerel.split('.') if prerel else (), + build=build.split('.') if build else (), + ) + + if (major is None or minor is None or patch is None) and (prerel or build): + raise ValueError("Invalid simple spec: %r" % expr) + + if build is not None and prefix not in (cls.PREFIX_EQ, cls.PREFIX_NEQ): + raise ValueError("Invalid simple spec: %r" % expr) + + if prefix == cls.PREFIX_CARET: + # Accept anything with the same most-significant digit + if target.major: + high = target.next_major() + elif target.minor: + high = target.next_minor() + else: + high = target.next_patch() + return Range(Range.OP_GTE, target) & Range(Range.OP_LT, high) + + elif prefix == cls.PREFIX_TILDE: + assert major is not None + # Accept any higher patch in the same minor + # Might go higher if the initial version was a partial + if minor is None: + high = target.next_major() + else: + high = target.next_minor() + return Range(Range.OP_GTE, target) & Range(Range.OP_LT, high) + + elif prefix == cls.PREFIX_COMPATIBLE: + assert major is not None + # ~1 is 1.0.0..2.0.0; ~=2.2 is 2.2.0..3.0.0; ~=1.4.5 is 1.4.5..1.5.0 + if minor is None or patch is None: + # We got a partial version + high = target.next_major() + else: + high = target.next_minor() + return Range(Range.OP_GTE, target) & Range(Range.OP_LT, high) + + elif prefix == cls.PREFIX_EQ: + if major is None: + return Range(Range.OP_GTE, target) + elif minor is None: + return Range(Range.OP_GTE, target) & Range(Range.OP_LT, target.next_major()) + elif patch is None: + return Range(Range.OP_GTE, target) & Range(Range.OP_LT, target.next_patch()) + elif build == '': + return Range(Range.OP_EQ, target, build_policy=Range.BUILD_STRICT) + else: + return Range(Range.OP_EQ, target) + + elif prefix == cls.PREFIX_NEQ: + assert major is not None + if minor is None: + # !=1.x => <1.0.0 || >=2.0.0 + return Range(Range.OP_LT, target) | Range(Range.OP_GTE, target.next_major()) + elif patch is None: + # !=1.2.x => <1.2.0 || >=1.3.0 + return Range(Range.OP_LT, target) | Range(Range.OP_GTE, target.next_minor()) + elif prerel == '': + # !=1.2.3- + return Range(Range.OP_NEQ, target, prerelease_policy=Range.PRERELEASE_ALWAYS) + elif build == '': + # !=1.2.3+ or !=1.2.3-a2+ + return Range(Range.OP_NEQ, target, build_policy=Range.BUILD_STRICT) + else: + return Range(Range.OP_NEQ, target) + + elif prefix == cls.PREFIX_GT: + assert major is not None + if minor is None: + # >1.x => >=2.0 + return Range(Range.OP_GTE, target.next_major()) + elif patch is None: + return Range(Range.OP_GTE, target.next_minor()) + else: + return Range(Range.OP_GT, target) + + elif prefix == cls.PREFIX_GTE: + return Range(Range.OP_GTE, target) + + elif prefix == cls.PREFIX_LT: + assert major is not None + if prerel == '': + # <1.2.3- + return Range(Range.OP_LT, target, prerelease_policy=Range.PRERELEASE_ALWAYS) + return Range(Range.OP_LT, target) + + else: + assert prefix == cls.PREFIX_LTE + assert major is not None + if minor is None: + # <=1.x => <2.0 + return Range(Range.OP_LT, target.next_major()) + elif patch is None: + return Range(Range.OP_LT, target.next_minor()) + else: + return Range(Range.OP_LTE, target) + + +class LegacySpec(SimpleSpec): + def __init__(self, *expressions): + warnings.warn( + "The Spec() class will be removed in 3.1; use SimpleSpec() instead.", + PendingDeprecationWarning, + stacklevel=2, + ) + + if len(expressions) > 1: + warnings.warn( + "Passing 2+ arguments to SimpleSpec will be removed in 3.0; concatenate them with ',' instead.", + DeprecationWarning, + stacklevel=2, + ) + expression = ','.join(expressions) + super(LegacySpec, self).__init__(expression) + + @property + def specs(self): + return list(self) + + def __iter__(self): + warnings.warn( + "Iterating over the components of a SimpleSpec object will be removed in 3.0.", + DeprecationWarning, + stacklevel=2, + ) + try: + clauses = list(self.clause) + except TypeError: # Not an iterable + clauses = [self.clause] + for clause in clauses: + yield SpecItem.from_matcher(clause) + + +Spec = LegacySpec + + +@BaseSpec.register_syntax +class NpmSpec(BaseSpec): + SYNTAX = 'npm' + + @classmethod + def _parse_to_clause(cls, expression): + return cls.Parser.parse(expression) + + class Parser: + JOINER = '||' + HYPHEN = ' - ' + + NUMBER = r'x|X|\*|0|[1-9][0-9]*' + PART = r'[a-zA-Z0-9.-]*' + NPM_SPEC_BLOCK = re.compile(r""" + ^(?:v)? # Strip optional initial v + (?P<|<=|>=|>|=|\^|~|) # Operator, can be empty + (?P{nb})(?:\.(?P{nb})(?:\.(?P{nb}))?)? + (?:-(?P{part}))? # Optional re-release + (?:\+(?P{part}))? # Optional build + $""".format(nb=NUMBER, part=PART), + re.VERBOSE, + ) + + @classmethod + def range(cls, operator, target): + return Range(operator, target, prerelease_policy=Range.PRERELEASE_SAMEPATCH) + + @classmethod + def parse(cls, expression): + result = Never() + groups = expression.split(cls.JOINER) + for group in groups: + group = group.strip() + if not group: + group = '>=0.0.0' + + subclauses = [] + if cls.HYPHEN in group: + low, high = group.split(cls.HYPHEN, 2) + subclauses = cls.parse_simple('>=' + low) + cls.parse_simple('<=' + high) + + else: + blocks = group.split(' ') + for block in blocks: + if not cls.NPM_SPEC_BLOCK.match(block): + raise ValueError("Invalid NPM block in %r: %r" % (expression, block)) + + subclauses.extend(cls.parse_simple(block)) + + prerelease_clauses = [] + non_prerel_clauses = [] + for clause in subclauses: + if clause.target.prerelease: + if clause.operator in (Range.OP_GT, Range.OP_GTE): + prerelease_clauses.append(Range( + operator=Range.OP_LT, + target=Version( + major=clause.target.major, + minor=clause.target.minor, + patch=clause.target.patch + 1, + ), + prerelease_policy=Range.PRERELEASE_ALWAYS, + )) + elif clause.operator in (Range.OP_LT, Range.OP_LTE): + prerelease_clauses.append(Range( + operator=Range.OP_GTE, + target=Version( + major=clause.target.major, + minor=clause.target.minor, + patch=0, + prerelease=(), + ), + prerelease_policy=Range.PRERELEASE_ALWAYS, + )) + prerelease_clauses.append(clause) + non_prerel_clauses.append(cls.range( + operator=clause.operator, + target=clause.target.truncate(), + )) + else: + non_prerel_clauses.append(clause) + if prerelease_clauses: + result |= AllOf(*prerelease_clauses) + result |= AllOf(*non_prerel_clauses) + + return result + + PREFIX_CARET = '^' + PREFIX_TILDE = '~' + PREFIX_EQ = '=' + PREFIX_GT = '>' + PREFIX_GTE = '>=' + PREFIX_LT = '<' + PREFIX_LTE = '<=' + + PREFIX_ALIASES = { + '': PREFIX_EQ, + } + + PREFIX_TO_OPERATOR = { + PREFIX_EQ: Range.OP_EQ, + PREFIX_LT: Range.OP_LT, + PREFIX_LTE: Range.OP_LTE, + PREFIX_GTE: Range.OP_GTE, + PREFIX_GT: Range.OP_GT, + } + + EMPTY_VALUES = ['*', 'x', 'X', None] + + @classmethod + def parse_simple(cls, simple): + match = cls.NPM_SPEC_BLOCK.match(simple) + + prefix, major_t, minor_t, patch_t, prerel, build = match.groups() + + prefix = cls.PREFIX_ALIASES.get(prefix, prefix) + major = None if major_t in cls.EMPTY_VALUES else int(major_t) + minor = None if minor_t in cls.EMPTY_VALUES else int(minor_t) + patch = None if patch_t in cls.EMPTY_VALUES else int(patch_t) + + if build is not None and prefix not in [cls.PREFIX_EQ]: + # Ignore the 'build' part when not comparing to a specific part. + build = None + + if major is None: # '*', 'x', 'X' + target = Version(major=0, minor=0, patch=0) + if prefix not in [cls.PREFIX_EQ, cls.PREFIX_GTE]: + raise ValueError("Invalid expression %r" % simple) + prefix = cls.PREFIX_GTE + elif minor is None: + target = Version(major=major, minor=0, patch=0) + elif patch is None: + target = Version(major=major, minor=minor, patch=0) + else: + target = Version( + major=major, + minor=minor, + patch=patch, + prerelease=prerel.split('.') if prerel else (), + build=build.split('.') if build else (), + ) + + if (major is None or minor is None or patch is None) and (prerel or build): + raise ValueError("Invalid NPM spec: %r" % simple) + + if prefix == cls.PREFIX_CARET: + if target.major: # ^1.2.4 => >=1.2.4 <2.0.0 ; ^1.x => >=1.0.0 <2.0.0 + high = target.truncate().next_major() + elif target.minor: # ^0.1.2 => >=0.1.2 <0.2.0 + high = target.truncate().next_minor() + elif minor is None: # ^0.x => >=0.0.0 <1.0.0 + high = target.truncate().next_major() + elif patch is None: # ^0.2.x => >=0.2.0 <0.3.0 + high = target.truncate().next_minor() + else: # ^0.0.1 => >=0.0.1 <0.0.2 + high = target.truncate().next_patch() + return [cls.range(Range.OP_GTE, target), cls.range(Range.OP_LT, high)] + + elif prefix == cls.PREFIX_TILDE: + assert major is not None + if minor is None: # ~1.x => >=1.0.0 <2.0.0 + high = target.next_major() + else: # ~1.2.x => >=1.2.0 <1.3.0; ~1.2.3 => >=1.2.3 <1.3.0 + high = target.next_minor() + return [cls.range(Range.OP_GTE, target), cls.range(Range.OP_LT, high)] + + elif prefix == cls.PREFIX_EQ: + if major is None: + return [cls.range(Range.OP_GTE, target)] + elif minor is None: + return [cls.range(Range.OP_GTE, target), cls.range(Range.OP_LT, target.next_major())] + elif patch is None: + return [cls.range(Range.OP_GTE, target), cls.range(Range.OP_LT, target.next_minor())] + else: + return [cls.range(Range.OP_EQ, target)] + + elif prefix == cls.PREFIX_GT: + assert major is not None + if minor is None: # >1.x + return [cls.range(Range.OP_GTE, target.next_major())] + elif patch is None: # >1.2.x => >=1.3.0 + return [cls.range(Range.OP_GTE, target.next_minor())] + else: + return [cls.range(Range.OP_GT, target)] + + elif prefix == cls.PREFIX_GTE: + return [cls.range(Range.OP_GTE, target)] + + elif prefix == cls.PREFIX_LT: + assert major is not None + return [cls.range(Range.OP_LT, target)] + + else: + assert prefix == cls.PREFIX_LTE + assert major is not None + if minor is None: # <=1.x => <2.0.0 + return [cls.range(Range.OP_LT, target.next_major())] + elif patch is None: # <=1.2.x => <1.3.0 + return [cls.range(Range.OP_LT, target.next_minor())] + else: + return [cls.range(Range.OP_LTE, target)] diff --git a/talerbuildconfig.py b/talerbuildconfig.py index e243a19..f28e1c2 100644 --- a/talerbuildconfig.py +++ b/talerbuildconfig.py @@ -35,6 +35,7 @@ import logging from distutils.spawn import find_executable import subprocess from dataclasses import dataclass +import semver """ This module aims to replicate a small GNU Coding Standards @@ -50,6 +51,9 @@ Makefile fragement, which is the processed by a Makefile (usually) in GNU Make format. """ +# Should be incremented each time we add some functionality +serialversion = 2 + # TODO: We need a smallest version argument. @@ -116,9 +120,17 @@ class BuildConfig: for tool in self.tools: res = tool.check(self) if not res: - print(f"Error: tool {tool.name} not available") + print(f"Error: tool '{tool.name}' not available") if hasattr(tool, "hint"): print(f"Hint: {tool.hint}") + sys.exit(1) + if hasattr(tool, "version_spec"): + sv = semver.SimpleSpec(tool.version_spec) + path, version = self.tool_results[tool.name] + if not sv.match(semver.Version(version)): + print(f"Error: Tool '{tool.name}' has version '{version}', but we require '{tool.version_spec}'") + sys.exit(1) + for tool in self.tools: path, version = self.tool_results[tool.name] @@ -413,6 +425,9 @@ class NodeJsTool(Tool): name = "node" hint = "If you are using Ubuntu Linux or Debian Linux, try installing the\nnode-legacy package or symlink node to nodejs." + def __init__(self, version_spec): + self.version_spec = version_spec + def args(self, parser): pass @@ -427,7 +442,7 @@ class NodeJsTool(Tool): ): buildconfig._warn("your node version is too old, use Node 4.x or newer") return False - node_version = tool_version("node --version") + node_version = tool_version("node --version").lstrip("v") buildconfig._set_tool("node", "node", version=node_version) return True diff --git a/testconfigure.py b/testconfigure.py index 29d82a1..3db0aa4 100644 --- a/testconfigure.py +++ b/testconfigure.py @@ -6,7 +6,7 @@ b.enable_configmk() b.add_tool(YarnTool()) b.add_tool(BrowserTool()) b.add_tool(PyBabelTool()) -b.add_tool(NodeJsTool()) +b.add_tool(NodeJsTool(version_spec=">=12.0.0")) b.add_tool(PythonTool()) b.add_tool(PosixTool("find")) b.add_tool(PosixTool("xargs")) -- cgit v1.2.3