From d3ec31f8d5683926aa6a05bb573d9929a6266fac Mon Sep 17 00:00:00 2001 From: Matt Martz Date: Mon, 23 Mar 2020 16:04:07 -0500 Subject: [PATCH] Support pre-releases via new SemanticVersion (#68258) * Support pre-releases via new SemanticVersion. Fixes #64905 * Don't treat buildmeta as prerelease * Don't inherit from str and int * Add helper method to try and construct a SemanticVersion from a LooseVersion * Don't count major 0 as pre-release, it's different * Guard against invalid or no version in LooseVersion * return a bool * Add integration tests for pre-release * Fix up lingering issues with comparisons * typo fix * Always allow pre-releases in verify * Move pre-release filtering into CollectionRequirement, add messaging when a collection only contains pre-releases * Update changelog * If explicit requirement allow pre releases * Enable pre-releases for tar installs, and collections already installed when they are pre-releases * Drop --pre-release alias, make arg name more clear * Simplify code into a single line * Remove build metadata precedence, add some comments, and is_stable helper * Improve from_loose_version * Increase test coverage * linting fix * Update changelog --- changelogs/fragments/64905-semver.yml | 4 + lib/ansible/cli/galaxy.py | 7 +- lib/ansible/galaxy/collection.py | 93 ++++-- lib/ansible/utils/version.py | 272 +++++++++++++++++ .../tasks/install.yml | 20 ++ test/units/galaxy/test_collection_install.py | 2 +- test/units/utils/test_version.py | 285 ++++++++++++++++++ 7 files changed, 653 insertions(+), 30 deletions(-) create mode 100644 changelogs/fragments/64905-semver.yml create mode 100644 lib/ansible/utils/version.py create mode 100644 test/units/utils/test_version.py diff --git a/changelogs/fragments/64905-semver.yml b/changelogs/fragments/64905-semver.yml new file mode 100644 index 00000000000..913cb487ce2 --- /dev/null +++ b/changelogs/fragments/64905-semver.yml @@ -0,0 +1,4 @@ +minor_changes: +- Add ``--pre`` flag to ``ansible-galaxy collection install`` + to allow pulling in the most recent pre-release version of a collection + (https://github.com/ansible/ansible/issues/64905) diff --git a/lib/ansible/cli/galaxy.py b/lib/ansible/cli/galaxy.py index a5558e27845..96422b22fac 100644 --- a/lib/ansible/cli/galaxy.py +++ b/lib/ansible/cli/galaxy.py @@ -339,6 +339,8 @@ class GalaxyCLI(CLI): help='The path to the directory containing your collections.') install_parser.add_argument('-r', '--requirements-file', dest='requirements', help='A file containing a list of collections to be installed.') + install_parser.add_argument('--pre', dest='allow_pre_release', action='store_true', + help='Include pre-release versions. Semantic versioning pre-releases are ignored by default') else: install_parser.add_argument('-r', '--role-file', dest='role_file', help='A file containing a list of roles to be imported.') @@ -897,7 +899,8 @@ class GalaxyCLI(CLI): resolved_paths = [validate_collection_path(GalaxyCLI._resolve_path(path)) for path in search_paths] - verify_collections(requirements, resolved_paths, self.api_servers, (not ignore_certs), ignore_errors) + verify_collections(requirements, resolved_paths, self.api_servers, (not ignore_certs), ignore_errors, + allow_pre_release=True) return 0 @@ -941,7 +944,7 @@ class GalaxyCLI(CLI): os.makedirs(b_output_path) install_collections(requirements, output_path, self.api_servers, (not ignore_certs), ignore_errors, - no_deps, force, force_deps) + no_deps, force, force_deps, context.CLIARGS['allow_pre_release']) return 0 diff --git a/lib/ansible/galaxy/collection.py b/lib/ansible/galaxy/collection.py index 76312c1fb67..f0077384873 100644 --- a/lib/ansible/galaxy/collection.py +++ b/lib/ansible/galaxy/collection.py @@ -18,7 +18,7 @@ import yaml from collections import namedtuple from contextlib import contextmanager -from distutils.version import LooseVersion, StrictVersion +from distutils.version import LooseVersion from hashlib import sha256 from io import BytesIO from yaml.error import YAMLError @@ -38,6 +38,7 @@ from ansible.module_utils._text import to_bytes, to_native, to_text from ansible.utils.collection_loader import AnsibleCollectionRef from ansible.utils.display import Display from ansible.utils.hashing import secure_hash, secure_hash_s +from ansible.utils.version import SemanticVersion from ansible.module_utils.urls import open_url urlparse = six.moves.urllib.parse.urlparse @@ -56,7 +57,7 @@ class CollectionRequirement: _FILE_MAPPING = [(b'MANIFEST.json', 'manifest_file'), (b'FILES.json', 'files_file')] def __init__(self, namespace, name, b_path, api, versions, requirement, force, parent=None, metadata=None, - files=None, skip=False): + files=None, skip=False, allow_pre_releases=False): """ Represents a collection requirement, the versions that are available to be installed as well as any dependencies the collection has. @@ -75,15 +76,17 @@ class CollectionRequirement: collection artifact. :param skip: Whether to skip installing the collection. Should be set if the collection is already installed and force is not set. + :param allow_pre_releases: Whether to skip pre-release versions of collections. """ self.namespace = namespace self.name = name self.b_path = b_path self.api = api - self.versions = set(versions) + self._versions = set(versions) self.force = force self.skip = skip self.required_by = [] + self.allow_pre_releases = allow_pre_releases self._metadata = metadata self._files = files @@ -101,10 +104,24 @@ class CollectionRequirement: self._get_metadata() return self._metadata + @property + def versions(self): + if self.allow_pre_releases: + return self._versions + return set(v for v in self._versions if v == '*' or not SemanticVersion(v).is_prerelease) + + @versions.setter + def versions(self, value): + self._versions = set(value) + + @property + def pre_releases(self): + return set(v for v in self._versions if SemanticVersion(v).is_prerelease) + @property def latest_version(self): try: - return max([v for v in self.versions if v != '*'], key=LooseVersion) + return max([v for v in self.versions if v != '*'], key=SemanticVersion) except ValueError: # ValueError: max() arg is an empty sequence return '*' @@ -144,10 +161,18 @@ class CollectionRequirement: for p, r in self.required_by ) - versions = ", ".join(sorted(self.versions, key=LooseVersion)) + versions = ", ".join(sorted(self.versions, key=SemanticVersion)) + if not self.versions and self.pre_releases: + pre_release_msg = ( + '\nThis collection only contains pre-releases. Utilize `--pre` to install pre-releases, or ' + 'explicitly provide the pre-release version.' + ) + else: + pre_release_msg = '' + raise AnsibleError( - "%s from source '%s'. Available versions before last requirement added: %s\nRequirements from:\n%s" - % (msg, collection_source, versions, req_by) + "%s from source '%s'. Available versions before last requirement added: %s\nRequirements from:\n%s%s" + % (msg, collection_source, versions, req_by, pre_release_msg) ) self.versions = new_versions @@ -298,7 +323,7 @@ class CollectionRequirement: elif requirement == '*' or version == '*': continue - if not op(LooseVersion(version), LooseVersion(requirement)): + if not op(SemanticVersion(version), SemanticVersion.from_loose_version(LooseVersion(requirement))): break else: return True @@ -336,8 +361,13 @@ class CollectionRequirement: version = meta['version'] meta = CollectionVersionMetadata(namespace, name, version, None, None, meta['dependencies']) + if SemanticVersion(version).is_prerelease: + allow_pre_release = True + else: + allow_pre_release = False + return CollectionRequirement(namespace, name, b_path, None, [version], version, force, parent=parent, - metadata=meta, files=files) + metadata=meta, files=files, allow_pre_releases=allow_pre_release) @staticmethod def from_path(b_path, force, parent=None): @@ -354,13 +384,19 @@ class CollectionRequirement: raise AnsibleError("Collection file at '%s' does not contain a valid json string." % to_native(b_file_path)) + allow_pre_release = False if 'manifest_file' in info: manifest = info['manifest_file']['collection_info'] namespace = manifest['namespace'] name = manifest['name'] version = to_text(manifest['version'], errors='surrogate_or_strict') - if not hasattr(LooseVersion(version), 'version'): + try: + _v = SemanticVersion() + _v.parse(version) + if _v.is_prerelease: + allow_pre_release = True + except ValueError: display.warning("Collection at '%s' does not have a valid version set, falling back to '*'. Found " "version: '%s'" % (to_text(b_path), version)) version = '*' @@ -380,10 +416,10 @@ class CollectionRequirement: files = info.get('files_file', {}).get('files', {}) return CollectionRequirement(namespace, name, b_path, None, [version], version, force, parent=parent, - metadata=meta, files=files, skip=True) + metadata=meta, files=files, skip=True, allow_pre_releases=allow_pre_release) @staticmethod - def from_name(collection, apis, requirement, force, parent=None): + def from_name(collection, apis, requirement, force, parent=None, allow_pre_release=False): namespace, name = collection.split('.', 1) galaxy_meta = None @@ -391,6 +427,9 @@ class CollectionRequirement: try: if not (requirement == '*' or requirement.startswith('<') or requirement.startswith('>') or requirement.startswith('!=')): + # Exact requirement + allow_pre_release = True + if requirement.startswith('='): requirement = requirement.lstrip('=') @@ -399,11 +438,7 @@ class CollectionRequirement: galaxy_meta = resp versions = [resp.version] else: - resp = api.get_collection_versions(namespace, name) - - # Galaxy supports semver but ansible-galaxy does not. We ignore any versions that don't match - # StrictVersion (x.y.z) and only support pre-releases if an explicit version was set (done above). - versions = [v for v in resp if StrictVersion.version_re.match(v)] + versions = api.get_collection_versions(namespace, name) except GalaxyError as err: if err.http_code == 404: display.vvv("Collection '%s' is not available from server %s %s" @@ -417,7 +452,7 @@ class CollectionRequirement: raise AnsibleError("Failed to find collection %s:%s" % (collection, requirement)) req = CollectionRequirement(namespace, name, None, api, versions, requirement, force, parent=parent, - metadata=galaxy_meta) + metadata=galaxy_meta, allow_pre_releases=allow_pre_release) return req @@ -493,7 +528,8 @@ def publish_collection(collection_path, api, wait, timeout): % (api.name, api.api_server, import_uri)) -def install_collections(collections, output_path, apis, validate_certs, ignore_errors, no_deps, force, force_deps): +def install_collections(collections, output_path, apis, validate_certs, ignore_errors, no_deps, force, force_deps, + allow_pre_release=False): """ Install Ansible collections to the path specified. @@ -512,7 +548,8 @@ def install_collections(collections, output_path, apis, validate_certs, ignore_e display.display("Process install dependency map") with _display_progress(): dependency_map = _build_dependency_map(collections, existing_collections, b_temp_path, apis, - validate_certs, force, force_deps, no_deps) + validate_certs, force, force_deps, no_deps, + allow_pre_release=allow_pre_release) display.display("Starting collection install process") with _display_progress(): @@ -557,7 +594,7 @@ def validate_collection_path(collection_path): return collection_path -def verify_collections(collections, search_paths, apis, validate_certs, ignore_errors): +def verify_collections(collections, search_paths, apis, validate_certs, ignore_errors, allow_pre_release=False): with _display_progress(): with _tempdir() as b_temp_path: @@ -585,7 +622,8 @@ def verify_collections(collections, search_paths, apis, validate_certs, ignore_e # Download collection on a galaxy server for comparison try: - remote_collection = CollectionRequirement.from_name(collection_name, apis, collection_version, False, parent=None) + remote_collection = CollectionRequirement.from_name(collection_name, apis, collection_version, False, parent=None, + allow_pre_release=allow_pre_release) except AnsibleError as e: if e.message == 'Failed to find collection %s:%s' % (collection[0], collection[1]): raise AnsibleError('Failed to find remote collection %s:%s on any of the galaxy servers' % (collection[0], collection[1])) @@ -921,13 +959,13 @@ def find_existing_collections(path): def _build_dependency_map(collections, existing_collections, b_temp_path, apis, validate_certs, force, force_deps, - no_deps): + no_deps, allow_pre_release=False): dependency_map = {} # First build the dependency map on the actual requirements for name, version, source in collections: _get_collection_info(dependency_map, existing_collections, name, version, source, b_temp_path, apis, - validate_certs, (force or force_deps)) + validate_certs, (force or force_deps), allow_pre_release=allow_pre_release) checked_parents = set([to_text(c) for c in dependency_map.values() if c.skip]) while len(dependency_map) != len(checked_parents): @@ -943,7 +981,7 @@ def _build_dependency_map(collections, existing_collections, b_temp_path, apis, for dep_name, dep_requirement in parent_info.dependencies.items(): _get_collection_info(dependency_map, existing_collections, dep_name, dep_requirement, parent_info.api, b_temp_path, apis, validate_certs, force_deps, - parent=parent) + parent=parent, allow_pre_release=allow_pre_release) checked_parents.add(parent) @@ -963,7 +1001,7 @@ def _build_dependency_map(collections, existing_collections, b_temp_path, apis, def _get_collection_info(dep_map, existing_collections, collection, requirement, source, b_temp_path, apis, - validate_certs, force, parent=None): + validate_certs, force, parent=None, allow_pre_release=False): dep_msg = "" if parent: dep_msg = " - as dependency of %s" % parent @@ -999,7 +1037,8 @@ def _get_collection_info(dep_map, existing_collections, collection, requirement, collection_info.add_requirement(parent, requirement) else: apis = [source] if source else apis - collection_info = CollectionRequirement.from_name(collection, apis, requirement, force, parent=parent) + collection_info = CollectionRequirement.from_name(collection, apis, requirement, force, parent=parent, + allow_pre_release=allow_pre_release) existing = [c for c in existing_collections if to_text(c) == to_text(collection_info)] if existing and not collection_info.force: diff --git a/lib/ansible/utils/version.py b/lib/ansible/utils/version.py new file mode 100644 index 00000000000..2dd38c2a795 --- /dev/null +++ b/lib/ansible/utils/version.py @@ -0,0 +1,272 @@ +# Copyright (c) 2020 Matt Martz +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# Make coding more python3-ish +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import re + +from distutils.version import LooseVersion, Version + +from ansible.module_utils.six import text_type + + +# Regular expression taken from +# https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string +SEMVER_RE = re.compile( + r''' + ^ + (?P0|[1-9]\d*) + \. + (?P0|[1-9]\d*) + \. + (?P0|[1-9]\d*) + (?: + - + (?P + (?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*) + (?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))* + ) + )? + (?: + \+ + (?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*) + )? + $ + ''', + flags=re.X +) + + +class _Alpha: + """Class to easily allow comparing strings + + Largely this exists to make comparing an integer and a string on py3 + so that it works like py2. + """ + def __init__(self, specifier): + self.specifier = specifier + + def __repr__(self): + return repr(self.specifier) + + def __eq__(self, other): + if isinstance(other, _Alpha): + return self.specifier == other.specifier + elif isinstance(other, str): + return self.specifier == other + + return False + + def __ne__(self, other): + return not self.__eq__(other) + + def __lt__(self, other): + if isinstance(other, _Alpha): + return self.specifier < other.specifier + elif isinstance(other, str): + return self.specifier < other + elif isinstance(other, _Numeric): + return False + + raise ValueError + + def __gt__(self, other): + return not self.__lt__(other) + + def __le__(self, other): + return self.__lt__(other) or self.__eq__(other) + + def __ge__(self, other): + return self.__gt__(other) or self.__eq__(other) + + +class _Numeric: + """Class to easily allow comparing numbers + + Largely this exists to make comparing an integer and a string on py3 + so that it works like py2. + """ + def __init__(self, specifier): + self.specifier = int(specifier) + + def __repr__(self): + return repr(self.specifier) + + def __eq__(self, other): + if isinstance(other, _Numeric): + return self.specifier == other.specifier + elif isinstance(other, int): + return self.specifier == other + + return False + + def __ne__(self, other): + return not self.__eq__(other) + + def __lt__(self, other): + if isinstance(other, _Numeric): + return self.specifier < other.specifier + elif isinstance(other, int): + return self.specifier < other + elif isinstance(other, _Alpha): + return True + + raise ValueError + + def __gt__(self, other): + return not self.__lt__(other) + + def __le__(self, other): + return self.__lt__(other) or self.__eq__(other) + + def __ge__(self, other): + return self.__gt__(other) or self.__eq__(other) + + +class SemanticVersion(Version): + """Version comparison class that implements Semantic Versioning 2.0.0 + + Based off of ``distutils.version.Version`` + """ + + version_re = SEMVER_RE + + def __init__(self, vstring=None): + self.vstring = vstring + self.major = None + self.minor = None + self.patch = None + self.prerelease = () + self.buildmetadata = () + + if vstring: + self.parse(vstring) + + def __repr__(self): + return 'SemanticVersion(%r)' % self.vstring + + @staticmethod + def from_loose_version(loose_version): + """This method is designed to take a ``LooseVersion`` + and attempt to construct a ``SemanticVersion`` from it + + This is useful where you want to do simple version math + without requiring users to provide a compliant semver. + """ + if not isinstance(loose_version, LooseVersion): + raise ValueError("%r is not a LooseVersion" % loose_version) + + try: + version = loose_version.version[:] + except AttributeError: + raise ValueError("%r is not a LooseVersion" % loose_version) + + extra_idx = 3 + for marker in ('-', '+'): + try: + idx = version.index(marker) + except ValueError: + continue + else: + if idx < extra_idx: + extra_idx = idx + version[:] = version[:extra_idx] + + if version and set(type(v) for v in version) != set((int,)): + raise ValueError("Non integer values in %r" % loose_version) + + # Extra is everything to the right of the core version + extra = re.search('[+-].+$', loose_version.vstring) + + version[:] = version + [0] * (3 - len(version)) + return SemanticVersion( + '%s%s' % ( + '.'.join(str(v) for v in version), + extra.group(0) if extra else '' + ) + ) + + def parse(self, vstring): + match = SEMVER_RE.match(vstring) + if not match: + raise ValueError("invalid semantic version '%s'" % vstring) + + (major, minor, patch, prerelease, buildmetadata) = match.group(1, 2, 3, 4, 5) + self.major = int(major) + self.minor = int(minor) + self.patch = int(patch) + + if prerelease: + self.prerelease = tuple(_Numeric(x) if x.isdigit() else _Alpha(x) for x in prerelease.split('.')) + if buildmetadata: + self.buildmetadata = tuple(_Numeric(x) if x.isdigit() else _Alpha(x) for x in buildmetadata.split('.')) + + @property + def core(self): + return self.major, self.minor, self.patch + + @property + def is_prerelease(self): + return bool(self.prerelease) + + @property + def is_stable(self): + # Major version zero (0.y.z) is for initial development. Anything MAY change at any time. + # The public API SHOULD NOT be considered stable. + # https://semver.org/#spec-item-4 + return not (self.major == 0 or self.is_prerelease) + + def _cmp(self, other): + if isinstance(other, str): + other = SemanticVersion(other) + + if self.core != other.core: + # if the core version doesn't match + # prerelease and buildmetadata doesn't matter + if self.core < other.core: + return -1 + else: + return 1 + + if not any((self.prerelease, other.prerelease)): + return 0 + + if self.prerelease and not other.prerelease: + return -1 + elif not self.prerelease and other.prerelease: + return 1 + else: + if self.prerelease < other.prerelease: + return -1 + elif self.prerelease > other.prerelease: + return 1 + + # Build metadata MUST be ignored when determining version precedence + # https://semver.org/#spec-item-10 + # With the above in mind it is ignored here + + # If we have made it here, things should be equal + return 0 + + # The Py2 and Py3 implementations of distutils.version.Version + # are quite different, this makes the Py2 and Py3 implementations + # the same + def __eq__(self, other): + return self._cmp(other) == 0 + + def __ne__(self, other): + return not self.__eq__(other) + + def __lt__(self, other): + return self._cmp(other) < 0 + + def __le__(self, other): + return self._cmp(other) <= 0 + + def __gt__(self, other): + return self._cmp(other) > 0 + + def __ge__(self, other): + return self._cmp(other) >= 0 diff --git a/test/integration/targets/ansible-galaxy-collection/tasks/install.yml b/test/integration/targets/ansible-galaxy-collection/tasks/install.yml index 649fa8faa58..0d557630792 100644 --- a/test/integration/targets/ansible-galaxy-collection/tasks/install.yml +++ b/test/integration/targets/ansible-galaxy-collection/tasks/install.yml @@ -73,6 +73,26 @@ - '"Installing ''namespace1.name1:1.1.0-beta.1'' to" in install_prerelease.stdout' - (install_prerelease_actual.content | b64decode | from_json).collection_info.version == '1.1.0-beta.1' +- name: Remove beta + file: + path: '{{ galaxy_dir }}/ansible_collections/namespace1/name1' + state: absent + +- name: install pre-release version with --pre to custom dir - {{ test_name }} + command: ansible-galaxy collection install --pre 'namespace1.name1' -s '{{ test_server }}' -p '{{ galaxy_dir }}/ansible_collections' + register: install_prerelease + +- name: get result of install pre-release version with --pre to custom dir - {{ test_name }} + slurp: + path: '{{ galaxy_dir }}/ansible_collections/namespace1/name1/MANIFEST.json' + register: install_prerelease_actual + +- name: assert install pre-release version with --pre to custom dir - {{ test_name }} + assert: + that: + - '"Installing ''namespace1.name1:1.1.0-beta.1'' to" in install_prerelease.stdout' + - (install_prerelease_actual.content | b64decode | from_json).collection_info.version == '1.1.0-beta.1' + - name: install multiple collections with dependencies - {{ test_name }} command: ansible-galaxy collection install parent_dep.parent_collection namespace2.name -s {{ test_name }} args: diff --git a/test/units/galaxy/test_collection_install.py b/test/units/galaxy/test_collection_install.py index 092cf1ca3a2..9318d31dc7c 100644 --- a/test/units/galaxy/test_collection_install.py +++ b/test/units/galaxy/test_collection_install.py @@ -165,7 +165,7 @@ def test_build_requirement_from_path(collection_artifact): assert actual.dependencies == {} -@pytest.mark.parametrize('version', ['1.1.1', 1.1, 1]) +@pytest.mark.parametrize('version', ['1.1.1', '1.1.0', '1.0.0']) def test_build_requirement_from_path_with_manifest(version, collection_artifact): manifest_path = os.path.join(collection_artifact[0], b'MANIFEST.json') manifest_value = json.dumps({ diff --git a/test/units/utils/test_version.py b/test/units/utils/test_version.py new file mode 100644 index 00000000000..a46282876f2 --- /dev/null +++ b/test/units/utils/test_version.py @@ -0,0 +1,285 @@ +# -*- coding: utf-8 -*- +# (c) 2020 Matt Martz +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +from distutils.version import LooseVersion, StrictVersion + +import pytest + +from ansible.utils.version import _Alpha, _Numeric, SemanticVersion + + +EQ = [ + ('1.0.0', '1.0.0', True), + ('1.0.0', '1.0.0-beta', False), + ('1.0.0-beta2+build1', '1.0.0-beta.2+build.1', False), + ('1.0.0-beta+build', '1.0.0-beta+build', True), + ('1.0.0-beta+build1', '1.0.0-beta+build2', True), + ('1.0.0-beta+a', '1.0.0-alpha+bar', False), +] + +NE = [ + ('1.0.0', '1.0.0', False), + ('1.0.0', '1.0.0-beta', True), + ('1.0.0-beta2+build1', '1.0.0-beta.2+build.1', True), + ('1.0.0-beta+build', '1.0.0-beta+build', False), + ('1.0.0-beta+a', '1.0.0-alpha+bar', True), +] + +LT = [ + ('1.0.0', '2.0.0', True), + ('1.0.0-beta', '2.0.0-alpha', True), + ('1.0.0-alpha', '2.0.0-beta', True), + ('1.0.0-alpha', '1.0.0', True), + ('1.0.0-beta', '1.0.0-alpha3', False), + ('1.0.0+foo', '1.0.0-alpha', False), + ('1.0.0-beta.1', '1.0.0-beta.a', True), + ('1.0.0-beta+a', '1.0.0-alpha+bar', False), +] + +GT = [ + ('1.0.0', '2.0.0', False), + ('1.0.0-beta', '2.0.0-alpha', False), + ('1.0.0-alpha', '2.0.0-beta', False), + ('1.0.0-alpha', '1.0.0', False), + ('1.0.0-beta', '1.0.0-alpha3', True), + ('1.0.0+foo', '1.0.0-alpha', True), + ('1.0.0-beta.1', '1.0.0-beta.a', False), + ('1.0.0-beta+a', '1.0.0-alpha+bar', True), +] + +LE = [ + ('1.0.0', '1.0.0', True), + ('1.0.0', '2.0.0', True), + ('1.0.0-alpha', '1.0.0-beta', True), + ('1.0.0-beta', '1.0.0-alpha', False), +] + +GE = [ + ('1.0.0', '1.0.0', True), + ('1.0.0', '2.0.0', False), + ('1.0.0-alpha', '1.0.0-beta', False), + ('1.0.0-beta', '1.0.0-alpha', True), +] + +VALID = [ + "0.0.4", + "1.2.3", + "10.20.30", + "1.1.2-prerelease+meta", + "1.1.2+meta", + "1.1.2+meta-valid", + "1.0.0-alpha", + "1.0.0-beta", + "1.0.0-alpha.beta", + "1.0.0-alpha.beta.1", + "1.0.0-alpha.1", + "1.0.0-alpha0.valid", + "1.0.0-alpha.0valid", + "1.0.0-alpha-a.b-c-somethinglong+build.1-aef.1-its-okay", + "1.0.0-rc.1+build.1", + "2.0.0-rc.1+build.123", + "1.2.3-beta", + "10.2.3-DEV-SNAPSHOT", + "1.2.3-SNAPSHOT-123", + "1.0.0", + "2.0.0", + "1.1.7", + "2.0.0+build.1848", + "2.0.1-alpha.1227", + "1.0.0-alpha+beta", + "1.2.3----RC-SNAPSHOT.12.9.1--.12+788", + "1.2.3----R-S.12.9.1--.12+meta", + "1.2.3----RC-SNAPSHOT.12.9.1--.12", + "1.0.0+0.build.1-rc.10000aaa-kk-0.1", + "99999999999999999999999.999999999999999999.99999999999999999", + "1.0.0-0A.is.legal", +] + +INVALID = [ + "1", + "1.2", + "1.2.3-0123", + "1.2.3-0123.0123", + "1.1.2+.123", + "+invalid", + "-invalid", + "-invalid+invalid", + "-invalid.01", + "alpha", + "alpha.beta", + "alpha.beta.1", + "alpha.1", + "alpha+beta", + "alpha_beta", + "alpha.", + "alpha..", + "beta", + "1.0.0-alpha_beta", + "-alpha.", + "1.0.0-alpha..", + "1.0.0-alpha..1", + "1.0.0-alpha...1", + "1.0.0-alpha....1", + "1.0.0-alpha.....1", + "1.0.0-alpha......1", + "1.0.0-alpha.......1", + "01.1.1", + "1.01.1", + "1.1.01", + "1.2", + "1.2.3.DEV", + "1.2-SNAPSHOT", + "1.2.31.2.3----RC-SNAPSHOT.12.09.1--..12+788", + "1.2-RC-SNAPSHOT", + "-1.0.3-gamma+b7718", + "+justmeta", + "9.8.7+meta+meta", + "9.8.7-whatever+meta+meta", +] + +PRERELEASE = [ + ('1.0.0-alpha', True), + ('1.0.0-alpha.1', True), + ('1.0.0-0.3.7', True), + ('1.0.0-x.7.z.92', True), + ('0.1.2', False), + ('0.1.2+bob', False), + ('1.0.0', False), +] + +STABLE = [ + ('1.0.0-alpha', False), + ('1.0.0-alpha.1', False), + ('1.0.0-0.3.7', False), + ('1.0.0-x.7.z.92', False), + ('0.1.2', False), + ('0.1.2+bob', False), + ('1.0.0', True), + ('1.0.0+bob', True), +] + +LOOSE_VERSION = [ + (LooseVersion('1'), SemanticVersion('1.0.0')), + (LooseVersion('1-alpha'), SemanticVersion('1.0.0-alpha')), + (LooseVersion('1.0.0-alpha+build'), SemanticVersion('1.0.0-alpha+build')), +] + +LOOSE_VERSION_INVALID = [ + LooseVersion('1.a.3'), + LooseVersion(), + 'bar', + StrictVersion('1.2.3'), +] + + +def test_semanticversion_none(): + assert SemanticVersion().major is None + + +@pytest.mark.parametrize('left,right,expected', EQ) +def test_eq(left, right, expected): + assert (SemanticVersion(left) == SemanticVersion(right)) is expected + + +@pytest.mark.parametrize('left,right,expected', NE) +def test_ne(left, right, expected): + assert (SemanticVersion(left) != SemanticVersion(right)) is expected + + +@pytest.mark.parametrize('left,right,expected', LT) +def test_lt(left, right, expected): + assert (SemanticVersion(left) < SemanticVersion(right)) is expected + + +@pytest.mark.parametrize('left,right,expected', LE) +def test_le(left, right, expected): + assert (SemanticVersion(left) <= SemanticVersion(right)) is expected + + +@pytest.mark.parametrize('left,right,expected', GT) +def test_gt(left, right, expected): + assert (SemanticVersion(left) > SemanticVersion(right)) is expected + + +@pytest.mark.parametrize('left,right,expected', GE) +def test_ge(left, right, expected): + assert (SemanticVersion(left) >= SemanticVersion(right)) is expected + + +@pytest.mark.parametrize('value', VALID) +def test_valid(value): + SemanticVersion(value) + + +@pytest.mark.parametrize('value', INVALID) +def test_invalid(value): + pytest.raises(ValueError, SemanticVersion, value) + + +def test_example_precedence(): + # https://semver.org/#spec-item-11 + sv = SemanticVersion + assert sv('1.0.0') < sv('2.0.0') < sv('2.1.0') < sv('2.1.1') + assert sv('1.0.0-alpha') < sv('1.0.0') + assert sv('1.0.0-alpha') < sv('1.0.0-alpha.1') < sv('1.0.0-alpha.beta') + assert sv('1.0.0-beta') < sv('1.0.0-beta.2') < sv('1.0.0-beta.11') < sv('1.0.0-rc.1') < sv('1.0.0') + + +@pytest.mark.parametrize('value,expected', PRERELEASE) +def test_prerelease(value, expected): + assert SemanticVersion(value).is_prerelease is expected + + +@pytest.mark.parametrize('value,expected', STABLE) +def test_stable(value, expected): + assert SemanticVersion(value).is_stable is expected + + +@pytest.mark.parametrize('value,expected', LOOSE_VERSION) +def test_from_loose_version(value, expected): + assert SemanticVersion.from_loose_version(value) == expected + + +@pytest.mark.parametrize('value', LOOSE_VERSION_INVALID) +def test_from_loose_version_invalid(value): + pytest.raises((AttributeError, ValueError), SemanticVersion.from_loose_version, value) + + +def test_comparison_with_string(): + assert SemanticVersion('1.0.0') > '0.1.0' + + +def test_alpha(): + assert _Alpha('a') == _Alpha('a') + assert _Alpha('a') == 'a' + assert _Alpha('a') != _Alpha('b') + assert _Alpha('a') != 1 + assert _Alpha('a') < _Alpha('b') + assert _Alpha('a') < 'c' + assert _Alpha('a') > _Numeric(1) + with pytest.raises(ValueError): + _Alpha('a') < None + assert _Alpha('a') <= _Alpha('a') + assert _Alpha('a') <= _Alpha('b') + assert _Alpha('b') >= _Alpha('a') + assert _Alpha('b') >= _Alpha('b') + + +def test_numeric(): + assert _Numeric(1) == _Numeric(1) + assert _Numeric(1) == 1 + assert _Numeric(1) != _Numeric(2) + assert _Numeric(1) != 'a' + assert _Numeric(1) < _Numeric(2) + assert _Numeric(1) < 3 + assert _Numeric(1) < _Alpha('b') + with pytest.raises(ValueError): + _Numeric(1) < None + assert _Numeric(1) <= _Numeric(1) + assert _Numeric(1) <= _Numeric(2) + assert _Numeric(2) >= _Numeric(1) + assert _Numeric(2) >= _Numeric(2)