diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7c7344d8..21be7ba4 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -319,6 +319,7 @@ gradle: python3-colorama python3-git python3-gitlab + python3-packaging python3-requests # if this is a merge request fork, then only check if relevant files changed - if [ "$CI_PROJECT_NAMESPACE" != "fdroid" ]; then @@ -496,7 +497,8 @@ Build documentation: script: - apt-get install make python3-sphinx python3-numpydoc python3-pydata-sphinx-theme pydocstyle fdroidserver - apt purge fdroidserver - - pydocstyle fdroidserver + # ignore vendored files + - pydocstyle --verbose --match='(?!apksigcopier|looseversion|setup|test_).*\.py' fdroidserver - cd docs - sphinx-apidoc -o ./source ../fdroidserver -M -e - PYTHONPATH=.. sphinx-autogen -o generated source/*.rst diff --git a/fdroidserver/common.py b/fdroidserver/common.py index 8fe48874..c9d3f59d 100644 --- a/fdroidserver/common.py +++ b/fdroidserver/common.py @@ -57,7 +57,6 @@ import defusedxml.ElementTree as XMLElementTree from base64 import urlsafe_b64encode from binascii import hexlify from datetime import datetime, timedelta, timezone -from packaging.version import Version from queue import Queue from zipfile import ZipFile @@ -71,6 +70,7 @@ from fdroidserver import _ from fdroidserver.exception import FDroidException, VCSException, NoSubmodulesException,\ BuildException, VerificationException, MetaDataException from .asynchronousfilereader import AsynchronousFileReader +from .looseversion import LooseVersion from . import apksigcopier, common @@ -656,7 +656,7 @@ def find_apksigner(config): if not os.path.isdir(os.path.join(build_tools_path, f)): continue try: - if Version(f) < Version(MINIMUM_APKSIGNER_BUILD_TOOLS_VERSION): + if LooseVersion(f) < LooseVersion(MINIMUM_APKSIGNER_BUILD_TOOLS_VERSION): logging.debug("Local Android SDK only has outdated apksigner versions") return except TypeError: @@ -717,9 +717,9 @@ def test_aapt_version(aapt): # the Debian package has the version string like "v0.2-23.0.2" too_old = False if '.' in bugfix: - if Version(bugfix) < Version(MINIMUM_AAPT_BUILD_TOOLS_VERSION): + if LooseVersion(bugfix) < LooseVersion(MINIMUM_AAPT_BUILD_TOOLS_VERSION): too_old = True - elif Version('.'.join((major, minor, bugfix))) < Version('0.2.4062713'): + elif LooseVersion('.'.join((major, minor, bugfix))) < LooseVersion('0.2.4062713'): too_old = True if too_old: logging.warning(_("'{aapt}' is too old, fdroid requires build-tools-{version} or newer!") diff --git a/fdroidserver/looseversion.py b/fdroidserver/looseversion.py new file mode 100644 index 00000000..0c785d69 --- /dev/null +++ b/fdroidserver/looseversion.py @@ -0,0 +1,300 @@ +# PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 +# -------------------------------------------- +# +# 1. This LICENSE AGREEMENT is between the Python Software Foundation +# ("PSF"), and the Individual or Organization ("Licensee") accessing and +# otherwise using this software ("Python") in source or binary form and +# its associated documentation. +# +# 2. Subject to the terms and conditions of this License Agreement, PSF hereby +# grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, +# analyze, test, perform and/or display publicly, prepare derivative works, +# distribute, and otherwise use Python alone or in any derivative version, +# provided, however, that PSF's License Agreement and PSF's notice of copyright, +# i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, +# 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022 Python Software Foundation; +# All Rights Reserved" are retained in Python alone or in any derivative version +# prepared by Licensee. +# +# 3. In the event Licensee prepares a derivative work that is based on +# or incorporates Python or any part thereof, and wants to make +# the derivative work available to others as provided herein, then +# Licensee hereby agrees to include in any such work a brief summary of +# the changes made to Python. +# +# 4. PSF is making Python available to Licensee on an "AS IS" +# basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +# IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND +# DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +# FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT +# INFRINGE ANY THIRD PARTY RIGHTS. +# +# 5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +# FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +# A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, +# OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. +# +# 6. This License Agreement will automatically terminate upon a material +# breach of its terms and conditions. +# +# 7. Nothing in this License Agreement shall be deemed to create any +# relationship of agency, partnership, or joint venture between PSF and +# Licensee. This License Agreement does not grant permission to use PSF +# trademarks or trade name in a trademark sense to endorse or promote +# products or services of Licensee, or any third party. +# +# 8. By copying, installing or otherwise using Python, Licensee +# agrees to be bound by the terms and conditions of this License +# Agreement. +# +# SPDX-License-Identifier: Python-2.0 +# +# downloaded from: +# https://github.com/effigies/looseversion/blob/e1a5a176a92dc6825deda4205c1be6d05e9ed352/src/looseversion/__init__.py + +"""Provides classes to represent module version numbers (one class for +each style of version numbering). There are currently two such classes +implemented: StrictVersion and LooseVersion. + +Every version number class implements the following interface: + * the 'parse' method takes a string and parses it to some internal + representation; if the string is an invalid version number, + 'parse' raises a ValueError exception + * the class constructor takes an optional string argument which, + if supplied, is passed to 'parse' + * __str__ reconstructs the string that was passed to 'parse' (or + an equivalent string -- ie. one that will generate an equivalent + version number instance) + * __repr__ generates Python code to recreate the version number instance + * _cmp compares the current instance with either another instance + of the same class or a string (which will be parsed to an instance + of the same class, thus must follow the same rules) +""" +import re +import sys + +__license__ = "Python License 2.0" + +# The rules according to Greg Stein: +# 1) a version number has 1 or more numbers separated by a period or by +# sequences of letters. If only periods, then these are compared +# left-to-right to determine an ordering. +# 2) sequences of letters are part of the tuple for comparison and are +# compared lexicographically +# 3) recognize the numeric components may have leading zeroes +# +# The LooseVersion class below implements these rules: a version number +# string is split up into a tuple of integer and string components, and +# comparison is a simple tuple comparison. This means that version +# numbers behave in a predictable and obvious way, but a way that might +# not necessarily be how people *want* version numbers to behave. There +# wouldn't be a problem if people could stick to purely numeric version +# numbers: just split on period and compare the numbers as tuples. +# However, people insist on putting letters into their version numbers; +# the most common purpose seems to be: +# - indicating a "pre-release" version +# ('alpha', 'beta', 'a', 'b', 'pre', 'p') +# - indicating a post-release patch ('p', 'pl', 'patch') +# but of course this can't cover all version number schemes, and there's +# no way to know what a programmer means without asking him. +# +# The problem is what to do with letters (and other non-numeric +# characters) in a version number. The current implementation does the +# obvious and predictable thing: keep them as strings and compare +# lexically within a tuple comparison. This has the desired effect if +# an appended letter sequence implies something "post-release": +# eg. "0.99" < "0.99pl14" < "1.0", and "5.001" < "5.001m" < "5.002". +# +# However, if letters in a version number imply a pre-release version, +# the "obvious" thing isn't correct. Eg. you would expect that +# "1.5.1" < "1.5.2a2" < "1.5.2", but under the tuple/lexical comparison +# implemented here, this just isn't so. +# +# Two possible solutions come to mind. The first is to tie the +# comparison algorithm to a particular set of semantic rules, as has +# been done in the StrictVersion class above. This works great as long +# as everyone can go along with bondage and discipline. Hopefully a +# (large) subset of Python module programmers will agree that the +# particular flavour of bondage and discipline provided by StrictVersion +# provides enough benefit to be worth using, and will submit their +# version numbering scheme to its domination. The free-thinking +# anarchists in the lot will never give in, though, and something needs +# to be done to accommodate them. +# +# Perhaps a "moderately strict" version class could be implemented that +# lets almost anything slide (syntactically), and makes some heuristic +# assumptions about non-digits in version number strings. This could +# sink into special-case-hell, though; if I was as talented and +# idiosyncratic as Larry Wall, I'd go ahead and implement a class that +# somehow knows that "1.2.1" < "1.2.2a2" < "1.2.2" < "1.2.2pl3", and is +# just as happy dealing with things like "2g6" and "1.13++". I don't +# think I'm smart enough to do it right though. +# +# In any case, I've coded the test suite for this module (see +# ../test/test_version.py) specifically to fail on things like comparing +# "1.2a2" and "1.2". That's not because the *code* is doing anything +# wrong, it's because the simple, obvious design doesn't match my +# complicated, hairy expectations for real-world version numbers. It +# would be a snap to fix the test suite to say, "Yep, LooseVersion does +# the Right Thing" (ie. the code matches the conception). But I'd rather +# have a conception that matches common notions about version numbers. + + +if sys.version_info >= (3,): + + class _Py2Int(int): + """Integer object that compares < any string""" + + def __gt__(self, other): + if isinstance(other, str): + return False + return super().__gt__(other) + + def __lt__(self, other): + if isinstance(other, str): + return True + return super().__lt__(other) + +else: + _Py2Int = int + + +class LooseVersion(object): + """Version numbering for anarchists and software realists. + Implements the standard interface for version number classes as + described above. A version number consists of a series of numbers, + separated by either periods or strings of letters. When comparing + version numbers, the numeric components will be compared + numerically, and the alphabetic components lexically. The following + are all valid version numbers, in no particular order: + + 1.5.1 + 1.5.2b2 + 161 + 3.10a + 8.02 + 3.4j + 1996.07.12 + 3.2.pl0 + 3.1.1.6 + 2g6 + 11g + 0.960923 + 2.2beta29 + 1.13++ + 5.5.kw + 2.0b1pl0 + + In fact, there is no such thing as an invalid version number under + this scheme; the rules for comparison are simple and predictable, + but may not always give the results you want (for some definition + of "want"). + """ + + component_re = re.compile(r"(\d+ | [a-z]+ | \.)", re.VERBOSE) + + def __init__(self, vstring=None): + if vstring: + self.parse(vstring) + + def __eq__(self, other): + c = self._cmp(other) + if c is NotImplemented: + return NotImplemented + return c == 0 + + def __lt__(self, other): + c = self._cmp(other) + if c is NotImplemented: + return NotImplemented + return c < 0 + + def __le__(self, other): + c = self._cmp(other) + if c is NotImplemented: + return NotImplemented + return c <= 0 + + def __gt__(self, other): + c = self._cmp(other) + if c is NotImplemented: + return NotImplemented + return c > 0 + + def __ge__(self, other): + c = self._cmp(other) + if c is NotImplemented: + return NotImplemented + return c >= 0 + + def parse(self, vstring): + # I've given up on thinking I can reconstruct the version string + # from the parsed tuple -- so I just store the string here for + # use by __str__ + self.vstring = vstring + components = [x for x in self.component_re.split(vstring) if x and x != "."] + for i, obj in enumerate(components): + try: + components[i] = int(obj) + except ValueError: + pass + + self.version = components + + def __str__(self): + return self.vstring + + def __repr__(self): + return "LooseVersion ('%s')" % str(self) + + def _cmp(self, other): + other = self._coerce(other) + if other is NotImplemented: + return NotImplemented + + if self.version == other.version: + return 0 + if self.version < other.version: + return -1 + if self.version > other.version: + return 1 + return NotImplemented + + @classmethod + def _coerce(cls, other): + if isinstance(other, cls): + return other + elif isinstance(other, str): + return cls(other) + elif "distutils" in sys.modules: + # Using this check to avoid importing distutils and suppressing the warning + try: + from distutils.version import LooseVersion as deprecated + except ImportError: + return NotImplemented + if isinstance(other, deprecated): + return cls(str(other)) + return NotImplemented + + +class LooseVersion2(LooseVersion): + """LooseVersion variant that restores Python 2 semantics + + In Python 2, comparing LooseVersions where paired components could be string + and int always resulted in the string being "greater". In Python 3, this produced + a TypeError. + """ + + def parse(self, vstring): + # I've given up on thinking I can reconstruct the version string + # from the parsed tuple -- so I just store the string here for + # use by __str__ + self.vstring = vstring + components = [x for x in self.component_re.split(vstring) if x and x != "."] + for i, obj in enumerate(components): + try: + components[i] = _Py2Int(obj) + except ValueError: + pass + + self.version = components diff --git a/hooks/pre-commit b/hooks/pre-commit index 0a96b808..c1761aa8 100755 --- a/hooks/pre-commit +++ b/hooks/pre-commit @@ -90,7 +90,8 @@ if [ "$PY_FILES $PY_TEST_FILES" != " " ]; then if ! $PYFLAKES $PY_FILES $PY_TEST_FILES; then err "pyflakes tests failed!" fi - if ! $PYDOCSTYLE $PY_FILES $PY_TEST_FILES; then + # ignore vendored files + if ! $PYDOCSTYLE --match='(?!apksigcopier|looseversion).*\.py' $PY_FILES $PY_TEST_FILES; then err "pydocstyle tests failed!" fi fi diff --git a/pyproject.toml b/pyproject.toml index dcfe865a..9694d7be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ force-exclude = '''( | fdroidserver/__init__\.py | fdroidserver/__main__\.py | fdroidserver/apksigcopier\.py + | fdroidserver/looseversion\.py | fdroidserver/build\.py | fdroidserver/checkupdates\.py | fdroidserver/common\.py @@ -67,8 +68,8 @@ python_version = "3.9" files = "fdroidserver" -# exclude vendored file -exclude = "fdroidserver/apksigcopier.py" +# exclude vendored files +exclude = "fdroidserver/(apksigcopier|looseversion).py" # this is de-facto the linter setting for this file warn_unused_configs = true @@ -95,7 +96,7 @@ jobs = 4 py-version = "3.9" # Files or directories to be skipped. They should be base names, not paths. -ignore = ["apksigcopier.py"] +ignore = ["apksigcopier.py", "looseversion.py"] [tool.pylint.basic] # Good variable names which should always be accepted, separated by a comma. diff --git a/setup.py b/setup.py index 19eac953..f89ddf94 100755 --- a/setup.py +++ b/setup.py @@ -98,7 +98,6 @@ setup( 'paramiko', 'Pillow', 'apache-libcloud >= 0.14.1', - 'packaging', 'pyasn1 >=0.4.1, < 0.5.0', 'pyasn1-modules >= 0.2.1, < 0.3', 'python-vagrant', diff --git a/tests/common.TestCase b/tests/common.TestCase index dc9717db..266406bc 100755 --- a/tests/common.TestCase +++ b/tests/common.TestCase @@ -21,7 +21,6 @@ import unittest import textwrap import yaml import gzip -from packaging.version import Version from zipfile import BadZipFile, ZipFile from unittest import mock from pathlib import Path @@ -42,6 +41,7 @@ from testcommon import TmpCwd, mkdtemp from fdroidserver.common import ANTIFEATURES_CONFIG_NAME, CATEGORIES_CONFIG_NAME from fdroidserver.exception import FDroidException, VCSException,\ MetaDataException, VerificationException +from fdroidserver.looseversion import LooseVersion class CommonTest(unittest.TestCase): @@ -852,9 +852,9 @@ class CommonTest(unittest.TestCase): v = bt.split('/')[-2] if v == 'debian': continue - if Version(version) < Version(v): + if LooseVersion(version) < LooseVersion(v): version = v - if Version(version) < Version(min_version): + if LooseVersion(version) < LooseVersion(min_version): self.skipTest('SKIPPING since build-tools %s or higher is required!' % min_version) fdroidserver.common.config = {'sdk_path': android_home} with mock.patch.dict(os.environ, clear=True): @@ -2374,10 +2374,10 @@ class CommonTest(unittest.TestCase): import sdkmanager import pkg_resources - sdkmanager_version = Version( + sdkmanager_version = LooseVersion( pkg_resources.get_distribution('sdkmanager').version ) - if sdkmanager_version < Version('0.6.4'): + if sdkmanager_version < LooseVersion('0.6.4'): raise unittest.SkipTest('needs fdroid sdkmanager >= 0.6.4') fdroidserver.common.config = {'sdk_path': 'placeholder'} diff --git a/tests/update.TestCase b/tests/update.TestCase index 97829ff8..abce3a30 100755 --- a/tests/update.TestCase +++ b/tests/update.TestCase @@ -21,7 +21,6 @@ import yaml import zipfile import textwrap from datetime import datetime -from packaging.version import Version from pathlib import Path from testcommon import TmpCwd, mkdtemp from unittest import mock @@ -54,6 +53,7 @@ import fdroidserver.exception import fdroidserver.metadata import fdroidserver.update from fdroidserver.common import CATEGORIES_CONFIG_NAME +from fdroidserver.looseversion import LooseVersion DONATION_FIELDS = ('Donate', 'Liberapay', 'OpenCollective') @@ -1041,7 +1041,7 @@ class UpdateTest(unittest.TestCase): javac = config['jarsigner'].replace('jarsigner', 'javac') v = subprocess.check_output([javac, '-version'], stderr=subprocess.STDOUT)[6:-1].decode('utf-8') - if Version(v) < Version('1.8.0_132'): + if LooseVersion(v) < LooseVersion('1.8.0_132'): print('SKIPPING: running tests with old Java (' + v + ')') return