From cc4dc54ed3299ac1455f2f6e7c73c8f847947ba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Lebleu?= Date: Tue, 8 Mar 2016 21:52:30 +0100 Subject: [PATCH] [enh] Implement package version specifier and use it for min_version --- src/yunohost/app.py | 8 +- src/yunohost/utils/packages.py | 203 ++++++++++++++++++++++++++++++++- 2 files changed, 202 insertions(+), 9 deletions(-) diff --git a/src/yunohost/app.py b/src/yunohost/app.py index 4ee7883a0..6d688009f 100644 --- a/src/yunohost/app.py +++ b/src/yunohost/app.py @@ -40,7 +40,7 @@ from moulinette.core import MoulinetteError from moulinette.utils.log import getActionLogger from yunohost.service import service_log -from yunohost.utils.packages import has_min_version +from yunohost.utils.packages import meets_version_specifier logger = getActionLogger('yunohost.app') @@ -360,7 +360,8 @@ def app_upgrade(auth, app=[], url=None, file=None): # Check min version if 'min_version' in manifest \ - and not has_min_version(manifest['min_version']): + and not meets_version_specifier( + 'yunohost', '>> {0}'.format(manifest['min_version'])): raise MoulinetteError(errno.EPERM, m18n.n('app_recent_version_required', app=app_id)) @@ -452,7 +453,8 @@ def app_install(auth, app, label=None, args=None): # Check min version if 'min_version' in manifest \ - and not has_min_version(manifest['min_version']): + and not meets_version_specifier( + 'yunohost', '>> {0}'.format(manifest['min_version'])): raise MoulinetteError(errno.EPERM, m18n.n('app_recent_version_required', app=app_id)) diff --git a/src/yunohost/utils/packages.py b/src/yunohost/utils/packages.py index c4f4fe76f..a2c447dc3 100644 --- a/src/yunohost/utils/packages.py +++ b/src/yunohost/utils/packages.py @@ -18,11 +18,15 @@ along with this program; if not, see http://www.gnu.org/licenses """ +import re +import logging from collections import OrderedDict import apt from apt_pkg import version_compare +logger = logging.getLogger('yunohost.utils.packages') + # Exceptions ----------------------------------------------------------------- @@ -44,13 +48,202 @@ class PackageException(Exception): class UnknownPackage(PackageException): + """The package is not found in the cache.""" message_key = 'package_unknown' class UninstalledPackage(PackageException): + """The package is not installed.""" message_key = 'package_not_installed' +class InvalidSpecifier(ValueError): + """An invalid specifier was found.""" + + +# Version specifier ---------------------------------------------------------- +# The packaging package has been a nice inspiration for the following classes. +# See: https://github.com/pypa/packaging + +class Specifier(object): + """Unique package version specifier + + Restrict a package version according to the `spec`. It must be a string + containing a relation from the list below followed by a version number + value. The relations allowed are, as defined by the Debian Policy Manual: + + - `<<` for strictly lower + - `<=` for lower or equal + - `=` for exactly equal + - `>=` for greater or equal + - `>>` for strictly greater + + """ + _regex_str = ( + r""" + (?P(<<|<=|=|>=|>>)) + \s* + (?P[^,;\s)]*) + """ + ) + _regex = re.compile( + r"^\s*" + _regex_str + r"\s*$", re.VERBOSE | re.IGNORECASE) + + _relations = { + "<<": "lower_than", + "<=": "lower_or_equal_than", + "=": "equal", + ">=": "greater_or_equal_than", + ">>": "greater_than", + } + + def __init__(self, spec): + match = self._regex.search(spec) + if not match: + raise InvalidSpecifier("Invalid specifier: '{0}'".format(spec)) + + self._spec = ( + match.group("relation").strip(), + match.group("version").strip(), + ) + + def __repr__(self): + return "".format(str(self)) + + def __str__(self): + return "{0}{1}".format(*self._spec) + + def __hash__(self): + return hash(self._spec) + + def __eq__(self, other): + if isinstance(other, basestring): + try: + other = self.__class__(other) + except InvalidSpecifier: + return NotImplemented + elif not isinstance(other, self.__class__): + return NotImplemented + + return self._spec == other._spec + + def __ne__(self, other): + if isinstance(other, basestring): + try: + other = self.__class__(other) + except InvalidSpecifier: + return NotImplemented + elif not isinstance(other, self.__class__): + return NotImplemented + + return self._spec != other._spec + + def _get_relation(self, op): + return getattr(self, "_compare_{0}".format(self._relations[op])) + + def _compare_lower_than(self, version, spec): + return version_compare(version, spec) < 0 + + def _compare_lower_or_equal_than(self, version, spec): + return version_compare(version, spec) <= 0 + + def _compare_equal(self, version, spec): + return version_compare(version, spec) == 0 + + def _compare_greater_or_equal_than(self, version, spec): + return version_compare(version, spec) >= 0 + + def _compare_greater_than(self, version, spec): + return version_compare(version, spec) > 0 + + @property + def relation(self): + return self._spec[0] + + @property + def version(self): + return self._spec[1] + + def __contains__(self, item): + return self.contains(item) + + def contains(self, item): + return self._get_relation(self.relation)(item, self.version) + + +class SpecifierSet(object): + """A set of package version specifiers + + Combine several Specifier separated by a comma. It allows to restrict + more precisely a package version. Each package version specifier must be + meet. Note than an empty set of specifiers will always be meet. + + """ + + def __init__(self, specifiers): + specifiers = [s.strip() for s in specifiers.split(",") if s.strip()] + + parsed = set() + for specifier in specifiers: + parsed.add(Specifier(specifier)) + + self._specs = frozenset(parsed) + + def __repr__(self): + return "".format(str(self)) + + def __str__(self): + return ",".join(sorted(str(s) for s in self._specs)) + + def __hash__(self): + return hash(self._specs) + + def __and__(self, other): + if isinstance(other, basestring): + other = SpecifierSet(other) + elif not isinstance(other, SpecifierSet): + return NotImplemented + + specifier = SpecifierSet() + specifier._specs = frozenset(self._specs | other._specs) + return specifiers + + def __eq__(self, other): + if isinstance(other, basestring): + other = SpecifierSet(other) + elif isinstance(other, Specifier): + other = SpecifierSet(str(other)) + elif not isinstance(other, SpecifierSet): + return NotImplemented + + return self._specs == other._specs + + def __ne__(self, other): + if isinstance(other, basestring): + other = SpecifierSet(other) + elif isinstance(other, Specifier): + other = SpecifierSet(str(other)) + elif not isinstance(other, SpecifierSet): + return NotImplemented + + return self._specs != other._specs + + def __len__(self): + return len(self._specs) + + def __iter__(self): + return iter(self._specs) + + def __contains__(self, item): + return self.contains(item) + + def contains(self, item): + return all( + s.contains(item) + for s in self._specs + ) + + # Packages and cache helpers ------------------------------------------------- def get_installed_version(*pkgnames, **kwargs): @@ -83,12 +276,10 @@ def get_installed_version(*pkgnames, **kwargs): return versions[pkgnames[0]] return versions -def has_min_version(min_version, package='yunohost'): - """Check if a package has a minimum installed version""" - version = get_installed_version(package) - if version_compare(version, min_version) > 0: - return True - return False +def meets_version_specifier(pkgname, specifier): + """Check if a package installed version meets specifier""" + spec = SpecifierSet(specifier) + return get_installed_version(pkgname) in spec # YunoHost related methods ---------------------------------------------------