pmbootstrap-meow/pmb/parse/version.py
Caleb Connolly 31cc898dd5
treewide: adopt pathlib.Path and type hinting (MR 2252)
With the new chroot type, we can now write fancy paths in the pythonic
way. Convert most of the codebase over, as well as adding various other
type hints.

Signed-off-by: Caleb Connolly <caleb@postmarketos.org>
2024-06-23 12:38:37 +02:00

310 lines
9.1 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
"""
name_suffixes = collections.OrderedDict([
("pre", ["alpha", "beta", "pre", "rc"]),
("post", ["cvs", "svn", "git", "hg", "p"]),
])
for name, suffixes in name_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: str, b_version: str, 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 not expected_results or result in expected_results