diff --git a/pmb/build/other.py b/pmb/build/other.py
index 7499d9c1..df168d29 100644
--- a/pmb/build/other.py
+++ b/pmb/build/other.py
@@ -26,6 +26,7 @@ import pmb.chroot
import pmb.helpers.run
import pmb.helpers.file
import pmb.parse.apkindex
+import pmb.parse.version
def find_aport(args, package, must_exist=True):
@@ -194,8 +195,7 @@ def is_necessary(args, arch, apkbuild, apkindex_path=None):
# a) Binary repo has a newer version
version_old = index_data["version"]
- if pmb.parse.apkindex.compare_version(version_old,
- version_new) == 1:
+ if pmb.parse.version.compare(version_old, version_new) == 1:
logging.warning("WARNING: Package '" + package + "' in your aports folder"
" has version " + version_new + ", but the binary package"
" repositories already have version " + version_old + "!")
diff --git a/pmb/chroot/apk.py b/pmb/chroot/apk.py
index 2e2ce309..174789ee 100644
--- a/pmb/chroot/apk.py
+++ b/pmb/chroot/apk.py
@@ -25,6 +25,7 @@ import pmb.config
import pmb.parse.apkindex
import pmb.parse.arch
import pmb.parse.depends
+import pmb.parse.version
def update_repository_list(args, suffix="native", check=False):
@@ -93,8 +94,8 @@ def check_min_version(args, suffix="native"):
# Compare
version_installed = installed(args, suffix)["apk-tools"]["version"]
version_min = pmb.config.apk_tools_static_min_version
- if pmb.parse.apkindex.compare_version(
- version_installed, version_min) == -1:
+ if pmb.parse.version.compare(version_installed,
+ version_min) == -1:
raise RuntimeError("You have an outdated version of the 'apk' package"
" manager installed (your version: " + version_installed +
", expected at least: " + version_min + "). Delete"
@@ -137,8 +138,8 @@ def install_is_necessary(args, build, arch, package, packages_installed):
# Compare the installed version vs. the version in the repos
data_installed = packages_installed[package]
- compare = pmb.parse.apkindex.compare_version(data_installed["version"],
- data_repo["version"])
+ compare = pmb.parse.version.compare(data_installed["version"],
+ data_repo["version"])
# a) Installed newer (should not happen normally)
if compare == 1:
logging.info("WARNING: " + arch + " package '" + package +
diff --git a/pmb/chroot/apk_static.py b/pmb/chroot/apk_static.py
index 62c18dee..9f978c78 100644
--- a/pmb/chroot/apk_static.py
+++ b/pmb/chroot/apk_static.py
@@ -28,6 +28,7 @@ import pmb.config
import pmb.config.load
import pmb.parse.apkindex
import pmb.helpers.http
+import pmb.parse.version
def read_signature_info(tar):
@@ -162,7 +163,7 @@ def init(args):
version = index_data["version"]
version_min = pmb.config.apk_tools_static_min_version
apk_name = "apk-tools-static-" + version + ".apk"
- if pmb.parse.apkindex.compare_version(version, version_min) == -1:
+ if pmb.parse.version.compare(version, version_min) == -1:
raise RuntimeError("You have an outdated version of apk-tools-static"
" (your version: " + version +
", expected at least:"
diff --git a/pmb/parse/apkindex.py b/pmb/parse/apkindex.py
index 9496fb8d..92eb9762 100644
--- a/pmb/parse/apkindex.py
+++ b/pmb/parse/apkindex.py
@@ -16,36 +16,12 @@ GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with pmbootstrap. If not, see .
"""
-import distutils.version
import logging
import os
import tarfile
import pmb.chroot.apk
import pmb.helpers.repo
-
-
-def compare_version(a_str, b_str):
- """
- http://stackoverflow.com/a/11887885
- LooseVersion behaves just like apk's version check, at least
- for all package versions, that have "-r".
-
- :returns:
- (a < b): -1
- (a == b): 0
- (a > b): 1
- """
- if a_str is None:
- a_str = "0"
- if b_str is None:
- b_str = "0"
- a = distutils.version.LooseVersion(a_str)
- b = distutils.version.LooseVersion(b_str)
- if a < b:
- return -1
- if a == b:
- return 0
- return 1
+import pmb.parse.version
def parse_next_block(args, path, lines, start):
@@ -160,7 +136,7 @@ def parse_add_block(path, strict, ret, block, pkgname=None):
# version
version_old = ret[pkgname]["version"]
version_new = block["version"]
- if compare_version(version_old, version_new) == 1:
+ if pmb.parse.version.compare(version_old, version_new) == 1:
return
# Add it to the result set
diff --git a/pmb/parse/version.py b/pmb/parse/version.py
new file mode 100644
index 00000000..1f5b0b49
--- /dev/null
+++ b/pmb/parse/version.py
@@ -0,0 +1,280 @@
+"""
+Copyright 2017 Oliver Smith
+
+This file is part of pmbootstrap.
+
+pmbootstrap is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+pmbootstrap is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with pmbootstrap. If not, see .
+"""
+
+"""
+In order to stay as compatible to Alpine's apk as possible, this code
+is heavily based on:
+
+https://git.alpinelinux.org/cgit/apk-tools/tree/src/version.c
+"""
+
+
+def token_value(string):
+ """
+ Return the associated value for a given token string (we parse
+ through the version string one token at a time).
+
+ :param string: a token string
+ :returns: integer associated to the token (so we can compare them in
+ functions further below, a digit (1) looses against a
+ letter (2), because "letter" has a higher value).
+
+ C equivalent: enum PARTS
+ """
+ order = {
+ "invalid": -1,
+ "digit_or_zero": 0,
+ "digit": 1,
+ "letter": 2,
+ "suffix": 3,
+ "suffix_no": 4,
+ "revision_no": 5,
+ "end": 6
+ }
+ return order[string]
+
+
+def next_token(previous, rest):
+ """
+ Parse the next token in the rest of the version string, we're
+ currently looking at.
+
+ We do *not* get the value of the token, or advance the rest string
+ beyond the whole token, that is what the get_token() function does
+ (see below).
+
+ :param previous: the token before
+ :param rest: of the version string
+ :returns: (next, rest) next is the upcoming token, rest is the
+ input "rest" string with one leading '.', '_' or '-'
+ character removed (if there was any).
+
+ C equivalent: next_token()
+ """
+ next = "invalid"
+ char = rest[:1]
+
+ # Tokes, which do not change rest
+ if not len(rest):
+ next = "end"
+ elif previous in ["digit", "digit_or_zero"] and char.islower():
+ next = "letter"
+ elif previous == "letter" and char.isdigit():
+ next = "digit"
+ elif previous == "suffix" and char.isdigit():
+ next = "suffix_no"
+
+ # Tokens, which remove the first character of rest
+ else:
+ if char == ".":
+ next = "digit_or_zero"
+ elif char == "_":
+ next = "suffix"
+ elif rest.startswith("-r"):
+ next = "revision_no"
+ rest = rest[1:]
+ elif char == "-":
+ next = "invalid"
+ rest = rest[1:]
+
+ # Validate current token
+ # Check if the transition from previous to current is valid
+ if token_value(next) < token_value(previous):
+ if not ((next == "digit_or_zero" and previous == "digit") or
+ (next == "suffix" and previous == "suffix_no") or
+ (next == "digit" and previous == "letter")):
+ next = "invalid"
+ return (next, rest)
+
+
+def parse_suffix(rest):
+ """
+ Cut off the suffix of rest (which is now at the beginning of the
+ rest variable, but regarding the whole version string, it is a
+ suffix), and return a value integer (so it can be compared later,
+ "beta" > "alpha" etc).
+
+ :param rest: what is left of the version string, that we are
+ currently parsing, starts with a "suffix" value
+ (see below for valid suffixes).
+ :returns: (rest, value) rest is the input "rest" string without the
+ suffix, value is a signed integer (negative for pre-,
+ positive for post-suffixes).
+
+ C equivalent: get_token(), case TOKEN_SUFFIX
+ """
+ suffixes = {
+ "pre": ["alpha", "beta", "pre", "rc"],
+ "post": ["cvs", "svn", "git", "hg", "p"]
+ }
+
+ for name, suffixes in suffixes.items():
+ for i, suffix in enumerate(suffixes):
+ if not rest.startswith(suffix):
+ continue
+ rest = rest[len(suffix):]
+ value = i
+ if name == "pre":
+ value = value - len(suffixes)
+ return (rest, value)
+ return (rest, 0)
+
+
+def get_token(previous, rest):
+ """
+ This function does three things:
+ * get the next token
+ * get the token value
+ * cut-off the whole token from rest
+
+ :param previous: the token before
+ :param rest: of the version string
+ :returns: (next, value, rest) next is the new token string,
+ value is an integer for comparing, rest is the rest of the
+ input string.
+
+ C equivalent: get_token()
+ """
+ # Set defaults
+ value = 0
+ next = "invalid"
+
+ # Bail out if at the end
+ if not len(rest):
+ return ("end", 0, rest)
+
+ # Cut off leading zero digits
+ if previous == "digit_or_zero" and rest.startswith("0"):
+ while rest.startswith("0"):
+ rest = rest[1:]
+ value -= 1
+ next = "digit"
+
+ # Add up numeric values
+ elif previous in ["digit_or_zero", "digit", "suffix_no",
+ "revision_no"]:
+ for i in range(len(rest)):
+ while len(rest) and rest[0].isdigit():
+ value *= 10
+ value += int(rest[i])
+ rest = rest[1:]
+
+ # Append chars or parse suffix
+ elif previous == "letter":
+ value = rest[0]
+ rest = rest[1:]
+ elif previous == "suffix":
+ (rest, value) = parse_suffix(rest)
+
+ # Invalid previous token
+ else:
+ value = -1
+
+ # Get the next token (for non-leading zeros)
+ if(not len(rest)):
+ next = "end"
+ elif(next == "invalid"):
+ (next, rest) = next_token(previous, rest)
+
+ return (next, value, rest)
+
+
+def validate(version):
+ """
+ Check whether one version string is valid.
+
+ :param version: full version string
+ :returns: True when the version string is valid
+
+ C equivalent: apk_version_validate()
+ """
+ current = "digit"
+ rest = version
+ while current != "end":
+ (current, value, rest) = get_token(current, rest)
+ if current == "invalid":
+ return False
+ return True
+
+
+def compare(a_version, b_version, fuzzy=False):
+ """
+ Compare two versions A and B to find out which one is higher, or if
+ both are equal.
+
+ :param a_version: full version string A
+ :param b_version: full version string B
+ :param fuzzy: treat version strings, which end in different token
+ types as equal
+
+ :returns:
+ (a < b): -1
+ (a == b): 0
+ (a > b): 1
+
+ C equivalent: apk_version_compare_blob_fuzzy()
+ """
+
+ # Defaults
+ a_token = "digit"
+ b_token = "digit"
+ a_value = 0
+ b_value = 0
+ a_rest = a_version
+ b_rest = b_version
+
+ # Parse A and B one token at a time, until one string ends, or the
+ # current token has a different type/value
+ while (a_token == b_token and a_token not in ["end", "invalid"] and
+ a_value == b_value):
+ (a_token, a_value, a_rest) = get_token(a_token, a_rest)
+ (b_token, b_value, b_rest) = get_token(b_token, b_rest)
+
+ # Compare the values inside the last tokens
+ if a_value < b_value:
+ return -1
+ if a_value > b_value:
+ return 1
+
+ # Equal: When tokens are the same strings, or when the value
+ # is the same and fuzzy compare is enabled
+ if a_token == b_token or fuzzy:
+ return 0
+
+ # Leading version components and their values are equal, now the
+ # non-terminating version is greater unless it's a suffix
+ # indicating pre-release
+ if a_token == "suffix":
+ (a_token, a_value, a_rest) = get_token(a_token, a_rest)
+ if a_value < 0:
+ return -1
+ if b_token == "suffix":
+ (b_token, b_value, b_rest) = get_token(b_token, b_rest)
+ if b_value < 0:
+ return 1
+
+ # Compare the token value (e.g. digit < letter)
+ if token_value(a_token) > token_value(b_token):
+ return -1
+ if token_value(a_token) < token_value(b_token):
+ return 1
+
+ # The tokens are not the same, but previous checks revealed that it
+ # is equal anyway (e.g. "1.0" == "1").
+ return 0
diff --git a/test/test_version.py b/test/test_version.py
index 80163234..51b24403 100644
--- a/test/test_version.py
+++ b/test/test_version.py
@@ -23,9 +23,9 @@ import pytest
# Import from parent directory
sys.path.append(os.path.abspath(
os.path.join(os.path.dirname(__file__) + "/..")))
-import pmb.parse.apkindex
import pmb.helpers.git
import pmb.helpers.logging
+import pmb.parse.version
@pytest.fixture
@@ -40,30 +40,39 @@ def args(request):
def test_version(args):
- # clone official test file from apk-tools
+ # Fail after the first error or print a grand total of failures
+ keep_going = False
+
+ # Clone official test file from apk-tools
pmb.helpers.git.clone(args, "apk-tools")
path = args.work + "/cache_git/apk-tools/test/version.data"
+ # Iterate over the cases from the list
mapping = {-1: "<", 0: "=", 1: ">"}
+ count = 0
+ errors = []
with open(path) as handle:
for line in handle:
split = line.split(" ")
a = split[0]
- b = split[2].rstrip()
+ b = split[2].split("#")[0].rstrip()
expected = split[1]
+ print("(#" + str(count) + ") " + line.rstrip())
+ result = pmb.parse.version.compare(a, b)
+ real = mapping[result]
- # Alpine packages nowadays always have '-r' in their version
- if "-r" not in a or "-r" not in b:
- continue
+ count += 1
+ if real != expected:
+ if keep_going:
+ errors.append(line.rstrip() + " (got: '" + real +
+ "')")
+ else:
+ assert real == expected
- print(line.rstrip())
- try:
- result = pmb.parse.apkindex.compare_version(a, b)
- real = mapping[result]
- except TypeError:
- # FIXME: Bug in Python:
- # https://bugs.python.org/issue14894
- # When this happens in pmbootstrap, it will also raise the
- # TypeError exception.
- continue
- assert(real == expected)
+ print("---")
+ print("total: " + str(count))
+ print("errors: " + str(len(errors)))
+ print("---")
+ for error in errors:
+ print(error)
+ assert errors == []