from __future__ import annotations
from collections.abc import Iterator
import difflib, operator as op, os
from packaging.version import ( # pylint:disable=ungrouped-imports
parse as _parse_version,
InvalidVersion,
Version
)
import termcolor
from .types import get_slots
__all__ = [
'SlotsEqualityMixin',
'unified_diff',
'VERSION_OPERATIONS',
'Version',
'compare_versions',
'satisfy_version_constraints',
'try_parse_version'
]
[docs]class SlotsEqualityMixin(object):
"""
Implement the comparison operators based on the slots.
Both the name of the slots retrieved with :func:`pytoolbox.types.get_slots`
and theirs values are tested for equality.
"""
def __eq__(self, other):
return get_slots(self) == get_slots(other) and \
all(getattr(self, a) == getattr(other, a) for a in get_slots(self))
def __ne__(self, other):
return not self.__eq__(other)
# Content ------------------------------------------------------------------------------------------
[docs]def unified_diff(before: str, after: str, *, colorize: bool = True, **kwargs) -> str:
"""
Colorization is not guaranteed (your environment may disable it).
Use `pytoolbox.console.toggle_colors` appropriately to ensure it.
"""
diff = difflib.unified_diff(before.splitlines(), after.splitlines(), **kwargs)
return os.linesep.join(_colorize(diff) if colorize else diff)
def _colorize(diff: Iterator[str]) -> Iterator[str]:
for line in diff:
if line.startswith('+'):
yield termcolor.colored(line, 'green')
elif line.startswith('-'):
yield termcolor.colored(line, 'red')
elif line.startswith('^'):
yield termcolor.colored(line, 'blue')
else:
yield line
# Versions -----------------------------------------------------------------------------------------
def _eqn(a, b) -> bool | None: # pylint:disable=invalid-name
return True if a == b else None
def _nen(a, b) -> bool | None: # pylint:disable=invalid-name
return False if a == b else None
VERSION_OPERATIONS: dict = { # pylint:disable=consider-using-namedtuple-or-dataclass
Version: {'<': op.lt, '<=': op.le, '==': op.eq, '!=': op.ne, '>=': op.ge, '>': op.gt},
str: {'<': _nen, '<=': _eqn, '==': op.eq, '!=': op.ne, '>=': _eqn, '>': _nen}
}
try:
from packaging.version import LegacyVersion
VERSION_OPERATIONS[LegacyVersion] = VERSION_OPERATIONS[str]
ParseVersionTypes = 'str | Version | LegacyVersion' # pylint:disable=invalid-name
except ImportError:
ParseVersionTypes = 'str | Version' # pylint:disable=invalid-name
[docs]def compare_versions(
a: str, # pylint:disable=invalid-name
b: str, # pylint:disable=invalid-name
operator: str
) -> bool | None:
version_a = try_parse_version(a)
version_b = try_parse_version(b)
if type(version_a) is type(version_b):
operation = VERSION_OPERATIONS[type(version_a)][operator]
return operation(version_a, version_b) if operation else None
return None # Will not try to compare Version to LegacyVersion
[docs]def satisfy_version_constraints(
version: str | None,
constraints: tuple[str, ...], *,
default='<undefined>',
) -> bool:
"""
Ensure given version fulfill the constraints (if any).
Constraints are given in the form '<operator> <version>', Exemple:
>>> satisfy_version_constraints('v1.5.2', ['>= v1.5', '< v2'])
True
>>> satisfy_version_constraints('v0.7', ['>= v1.5', '< v2'])
False
>>> satisfy_version_constraints(None, ['>= v1.5', '< v2'])
False
>>> satisfy_version_constraints('main', ['!= main'])
False
>>> satisfy_version_constraints(None, ['== <undefined>'])
True
>>> satisfy_version_constraints(None, ['!= master'], default='master')
False
"""
return all(compare_versions(version or default, *c.split(' ')[::-1]) for c in constraints or [])
[docs]def try_parse_version(version: str) -> ParseVersionTypes:
try:
return _parse_version(version)
except InvalidVersion:
return version