forked from Mirror/pmbootstrap
Hopefully makes for easier to read code, and potentially also faster once we have mypyc and the enum can get compiled into plain integers instead of the strings we previously were working with.
304 lines
8.9 KiB
Python
304 lines
8.9 KiB
Python
# Copyright 2023 Oliver Smith
|
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
import collections
|
|
from enum import IntEnum
|
|
|
|
"""
|
|
In order to stay as compatible to Alpine's apk as possible, this code
|
|
is heavily based on:
|
|
|
|
https://gitlab.alpinelinux.org/alpine/apk-tools/-/blob/5d796b567819ce91740fcdea7cbafecbda65d8f3/src/version.c
|
|
"""
|
|
|
|
|
|
class Token(IntEnum):
|
|
"""
|
|
C equivalent: enum PARTS
|
|
"""
|
|
|
|
INVALID = -1
|
|
DIGIT_OR_ZERO = 0
|
|
DIGIT = 1
|
|
LETTER = 2
|
|
SUFFIX = 3
|
|
SUFFIX_NO = 4
|
|
REVISION_NO = 5
|
|
END = 6
|
|
|
|
|
|
def next_token(previous: Token, rest: str) -> tuple[Token, str]:
|
|
"""
|
|
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 = Token.INVALID
|
|
char = rest[:1]
|
|
|
|
# Tokes, which do not change rest
|
|
if not len(rest):
|
|
next = Token.END
|
|
elif previous in [Token.DIGIT, Token.DIGIT_OR_ZERO] and char.islower():
|
|
next = Token.LETTER
|
|
elif previous == Token.LETTER and char.isdigit():
|
|
next = Token.DIGIT
|
|
elif previous == Token.SUFFIX and char.isdigit():
|
|
next = Token.SUFFIX_NO
|
|
|
|
# Tokens, which remove the first character of rest
|
|
else:
|
|
if char == ".":
|
|
next = Token.DIGIT_OR_ZERO
|
|
elif char == "_":
|
|
next = Token.SUFFIX
|
|
elif rest.startswith("-r"):
|
|
next = Token.REVISION_NO
|
|
rest = rest[1:]
|
|
elif char == "-":
|
|
next = Token.INVALID
|
|
rest = rest[1:]
|
|
|
|
# Validate current token
|
|
# Check if the transition from previous to current is valid
|
|
if next < previous:
|
|
if not (
|
|
(next == Token.DIGIT_OR_ZERO and previous == Token.DIGIT)
|
|
or (next == Token.SUFFIX and previous == Token.SUFFIX_NO)
|
|
or (next == Token.DIGIT and previous == Token.LETTER)
|
|
):
|
|
next = Token.INVALID
|
|
return (next, rest)
|
|
|
|
|
|
def parse_suffix(rest: str) -> tuple[str, int, bool]:
|
|
"""
|
|
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: Token, rest: str) -> tuple[Token, int, str]:
|
|
"""
|
|
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 = Token.INVALID
|
|
invalid_suffix = False
|
|
|
|
# Bail out if at the end
|
|
if not len(rest):
|
|
return (Token.END, 0, rest)
|
|
|
|
# Cut off leading zero digits
|
|
if previous == Token.DIGIT_OR_ZERO and rest.startswith("0"):
|
|
while rest.startswith("0"):
|
|
rest = rest[1:]
|
|
value -= 1
|
|
next = Token.DIGIT
|
|
|
|
# Add up numeric values
|
|
elif previous in [Token.DIGIT_OR_ZERO, Token.DIGIT, Token.SUFFIX_NO, Token.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 == Token.LETTER:
|
|
value = ord(rest[0])
|
|
rest = rest[1:]
|
|
elif previous == Token.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 = Token.END
|
|
elif next == Token.INVALID and not invalid_suffix:
|
|
(next, rest) = next_token(previous, rest)
|
|
|
|
return (next, value, rest)
|
|
|
|
|
|
def validate(version: str) -> bool:
|
|
"""
|
|
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 = Token.DIGIT
|
|
rest = version
|
|
while current != Token.END:
|
|
(current, value, rest) = get_token(current, rest)
|
|
if current == Token.INVALID:
|
|
return False
|
|
return True
|
|
|
|
|
|
def compare(a_version: str, b_version: str, fuzzy: bool = False) -> int:
|
|
"""
|
|
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 = Token.DIGIT
|
|
b_token = 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 [Token.END, Token.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 == Token.SUFFIX:
|
|
(a_token, a_value, a_rest) = get_token(a_token, a_rest)
|
|
if a_value < 0:
|
|
return -1
|
|
if b_token == 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 a_token > b_token:
|
|
return -1
|
|
if a_token < 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: str, rule: str) -> bool:
|
|
"""
|
|
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
|