pmbootstrap-meow/pmb/parse/version.py
Newbyte 415893fb74
pmb.parse.version: Replace token_value() with Token enum (MR 2518)
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.
2025-01-02 15:42:22 +01:00

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