pmbootstrap/pmb/parse/version.py

310 lines
9.0 KiB
Python

# Copyright 2023 Oliver Smith
# SPDX-License-Identifier: GPL-3.0-or-later
import collections
"""
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, invalid_suffix)
- rest: is the input "rest" string without the suffix
- value: is a signed integer (negative for pre-,
positive for post-suffixes).
- invalid_suffix: is true, when rest does not start
with anything from the suffixes variable.
C equivalent: get_token(), case TOKEN_SUFFIX
"""
suffixes = collections.OrderedDict([
("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, False)
return (rest, 0, True)
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"
invalid_suffix = False
# 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, invalid_suffix) = 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" and not invalid_suffix:
(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
"""
Convenience functions below are not modeled after apk's version.c.
"""
def check_string(a_version, rule):
"""
Compare a version against a check string. This is used in "pmbootstrap
kconfig check", to only require certain options if the pkgver is in a
specified range (#1795).
:param a_version: "3.4.1"
:param rule: ">=1.0.0"
:returns: True if a_version matches rule, false otherwise.
"""
# Operators and the expected returns of compare(a,b)
operator_results = {">=": [1, 0],
"<": [-1]}
# Find the operator
b_version = None
expected_results = None
for operator in operator_results:
if rule.startswith(operator):
b_version = rule[len(operator):]
expected_results = operator_results[operator]
break
# No operator found
if not b_version:
raise RuntimeError("Could not find operator in '" + rule + "'. You"
" probably need to adjust check_string() in"
" pmb/parse/version.py.")
# Compare
result = compare(a_version, b_version)
return result in expected_results