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
This commit is contained in:
parent
ed9de94ad9
commit
d3ec31f8d5
7 changed files with 653 additions and 30 deletions
4
changelogs/fragments/64905-semver.yml
Normal file
4
changelogs/fragments/64905-semver.yml
Normal file
|
@ -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:
|
||||
|
|
272
lib/ansible/utils/version.py
Normal file
272
lib/ansible/utils/version.py
Normal file
|
@ -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({
|
||||
|
|
285
test/units/utils/test_version.py
Normal file
285
test/units/utils/test_version.py
Normal file
|
@ -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…
Add table
Reference in a new issue