diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7c7344d8..3f38dfab 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -496,7 +496,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/looseversion.py b/fdroidserver/looseversion.py new file mode 100644 index 00000000..47779ad5 --- /dev/null +++ b/fdroidserver/looseversion.py @@ -0,0 +1,243 @@ +"""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 + +# 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.