## @file
# Copyright (c) 2023, The OCE Build Authors. All rights reserved.
# SPDX-License-Identifier: BSD-3-Clause
##
"""Methods for sorting and handling versioning."""
from graphlib import TopologicalSorter
from itertools import chain
from typing import Dict, Generator, List, Tuple, Union
from packaging import version as vpkg
[docs]SEMVER_SYMBOLS = ('~', '^')
"""Semantic versioning range symbols."""
[docs]COMPARISON_SYMBOLS = ('>', '<', '>=', '<=', '==', '!=')
"""Version comparison symbols."""
[docs]def get_version_str(string: str) -> Union[str, None]:
"""Gets the version string from a version specifier.
Args:
string: The version specifier.
Returns:
The version string.
Example:
>>> get_version_string('^1.0.0')
# -> '1.0.0'
>>> get_version_string('1.0.0')
# ->'1.0.0'
>>> get_version_string('latest')
# -> None
"""
# Remove non-standard release tags
string = string\
.replace('-prerelease', '') \
.replace('-release', '') \
.replace('-debug', '') \
.replace('-stable', '')
# Remove semver versioning symbols
for symbol in reversed([*SEMVER_SYMBOLS, *COMPARISON_SYMBOLS]):
if symbol in string[:len(symbol)]:
return string[len(symbol):]
return string
[docs]def get_version(string: str) -> Union[vpkg.Version, None]:
"""Gets the version class from a version specifier.
Args:
string: The version class.
Returns:
The version string.
Example:
>>> get_version('^1.0.0')
# -> <Version('1.0.0')>
>>> get_version('1.0.0')
# -> <Version('1.0.0')>
>>> get_version('latest')
# -> None
"""
try:
return vpkg.parse(get_version_str(string))
except vpkg.InvalidVersion:
return None
[docs]def compare_version(v1: Union[str, vpkg.Version],
v2: Union[str, vpkg.Version],
operator: str
) -> bool:
"""Compares a version to the version specifier.
Args:
v1: The version.
v2: The version specifier.
operator: The operator.
Returns:
True if the version satisfies the specifier.
"""
v1_arr = get_version(v1) if isinstance(v1, str) else v1
v2_arr = get_version(v2) if isinstance(v2, str) else v2
if operator == '>': return v1_arr > v2_arr
if operator == '<': return v1_arr < v2_arr
if operator == '>=': return v1_arr >= v2_arr
if operator == '<=': return v1_arr <= v2_arr
if operator == '==': return v1_arr == v2_arr
if operator == '!=': return v1_arr != v2_arr
return False
[docs]def resolve_version_specifier(versions: List[str],
specifier: str
) -> Union[str, None]:
"""Resolves a version specifier.
Args:
versions: The versions.
specifier: The version specifier.
Returns:
The resolved version (if available).
Examples:
>>> resolve_version_specifier(['1.2.3', '1.2.4', '1.3.0'], '~1.2.3')
# -> '1.2.4'
>>> resolve_version_specifier(['1.2.3', '1.3.0', '2.0.0'], '^1.2.3')
# -> '1.3.0'
>>> resolve_version_specifier(['1.2.3', '1.2.4', '1.3.0'], '1.2.3')
# -> '1.2.3'
>>> resolve_version_specifier(['1.2.3', '1.2.4', '1.3.0'], '>1.2.3')
# -> '1.3.0'
>>> resolve_version_specifier(['1.2.2', '1.2.3', '1.3.0'], '<1.2.3')
# -> '1.2.2'
>>> resolve_version_specifier(['1.2.3', '1.2.4', '1.3.0'], '>=1.2.3')
# -> '1.3.0'
>>> resolve_version_specifier(['1.2.3', '1.2.4', '1.3.0'], '<=1.2.3')
# -> '1.2.3'
>>> resolve_version_specifier(['1.2.3', '1.2.4', '1.3.0'], '!=1.2.3')
# -> '1.3.0'
>>> resolve_version_specifier(['1.2.3', '1.2.4', '1.3.0'], '==1.2.3')
# -> '1.2.3'
>>> resolve_version_specifier(['1.2.3', '1.2.4', '1.3.0'], 'latest')
# -> '1.3.0'
>>> resolve_version_specifier(['1.2.3', '1.2.4', '1.3.0'], 'oldest')
# -> '1.2.3'
>>> resolve_version_specifier(['1.2.3', '1.2.4', '1.3.0'], '1.2.2')
# -> None
"""
# Sort available versions
sorted_versions = sorted(set(get_version(v) for v in versions if v))
# Handle named specifiers
if specifier == 'latest': return str(sorted_versions[-1])
if specifier == 'oldest': return str(sorted_versions[0])
# Find the version in the sorted list
version_str = get_version_str(specifier)
version = get_version(version_str)
if not version: return None
# Parse semver symbols
symbol = specifier[:specifier.index(version_str if version_str else '')]
# Up to next minor
# e.g. '~1.2.3' -> '>=1.2.3,<1.3.0'
if symbol == '~':
filtered = [v for v in sorted_versions
if compare_version(v, version, operator='>=')
and v.major == version.major
and v.minor < version.minor+1]
if len(filtered): return str(filtered[-1])
# Up to next major
# e.g. '^1.2.3' -> '>=1.2.3,<2.0.0'
elif symbol == '^':
filtered = [v for v in sorted_versions
if compare_version(v, version, operator='>=')
and v.major < version.major+1]
if len(filtered): return str(filtered[-1])
# Direct comparisons
elif symbol in COMPARISON_SYMBOLS:
filtered = [v for v in sorted_versions
if compare_version(v, version, operator=symbol)]
if len(filtered): return str(filtered[-1])
# Fallthrough
else:
# Exact match
# e.g. '1.2.3' -> '==1.2.3'
filtered = [v for v in sorted_versions
if compare_version(v, version, operator='==')]
if len(filtered): return str(filtered[-1])
# No match
return None
[docs]def get_minimum_version(dependencies: Dict[str, Tuple[str, str]],
library: str
) -> Tuple[str, Union[str, None]]:
"""Gets the minimum required version of a library.
Args:
dependencies: The dependency tree.
library: The library to get the minimum version of.
Returns:
A tuple of the library name and the minimum version.
Example:
>>> dependencies = {
... 'lib1': [('lib2', '2.0.0')],
... 'lib2': [('lib3', '3.0.0')],
... 'lib3': [],
... }
>>> get_minimum_version(dependencies, 'lib1')
# -> ('lib1', None)
>>> get_minimum_version(dependencies, 'lib2')
# -> ('lib2', '^2.0.0')
>>> get_minimum_version(dependencies, 'lib3')
# -> ('lib3', '^3.0.0')
"""
versions = set(k[1] for k in list(chain(*dependencies.values()))
if k[0] == library)
(versions := list(versions)).sort(key=vpkg.Version)
return (library, f'^{str(versions[-1])}' if versions else None)
[docs]def sort_dependencies(dependencies: Dict[str, Tuple[str, str]],
) -> Generator[Tuple[str, str], any, None]:
"""Sorts a dependency tree by topology and version.
Args:
dependencies: The dependency tree.
Yields:
A tuple of the library name and the minimum version.
Raises:
ValueError: If a cycle is detected in the dependency tree.
Example:
>>> dependencies = {
... 'lib1': [('lib2', '2.0.0')],
... 'lib2': [('lib3', '3.0.0')],
... 'lib3': [],
... }
>>> list(sort_dependencies(dependencies))
# -> [('lib3', '^3.0.0'), ('lib2', '^2.0.0'), ('lib1', None)]
"""
dependency_tree = { k: set(v[0] for v in t) for k,t in dependencies.items() }
for library in TopologicalSorter(dependency_tree).static_order():
yield get_minimum_version(dependencies, library)
__all__ = [
# Constants (2)
"SEMVER_SYMBOLS",
"COMPARISON_SYMBOLS",
# Functions (6)
"get_version_str",
"get_version",
"compare_version",
"resolve_version_specifier",
"get_minimum_version",
"sort_dependencies"
]