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
pull/68417/head
Matt Martz 4 years ago committed by GitHub
parent ed9de94ad9
commit d3ec31f8d5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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)

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

@ -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:

@ -0,0 +1,272 @@
# Copyright (c) 2020 Matt Martz <matt@sivel.net>
# 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'''
^
(?P<major>0|[1-9]\d*)
\.
(?P<minor>0|[1-9]\d*)
\.
(?P<patch>0|[1-9]\d*)
(?:
-
(?P<prerelease>
(?: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<buildmetadata>[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

@ -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:

@ -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({

@ -0,0 +1,285 @@
# -*- coding: utf-8 -*-
# (c) 2020 Matt Martz <matt@sivel.net>
# 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)
Loading…
Cancel
Save