|
| 1 | +# Copyright (C) 2023 Intel Corporation |
| 2 | +# SPDX-License-Identifier: GPL-3.0-or-later |
| 3 | + |
| 4 | +import re |
| 5 | + |
| 6 | +""" |
| 7 | +A class for comparing arbitrary versions of products. |
| 8 | +
|
| 9 | +Splits versions up using common whitespace delimiters and also splits out letters |
| 10 | +so that things like openSSL's 1.1.1y type of version will work too. |
| 11 | +
|
| 12 | +This may need some additional smarts for stuff like "rc" or "beta" and potentially for |
| 13 | +things like distro versioning. I don't know yet. |
| 14 | +""" |
| 15 | + |
| 16 | + |
| 17 | +class CannotParseVersionException(Exception): |
| 18 | + """ |
| 19 | + Thrown if the version doesn't comply with our expectations |
| 20 | + """ |
| 21 | + |
| 22 | + |
| 23 | +class UnknownVersion(Exception): |
| 24 | + """ |
| 25 | + Thrown if version is null or "unknown". |
| 26 | + """ |
| 27 | + |
| 28 | + |
| 29 | +def parse_version(version_string: str): |
| 30 | + """ |
| 31 | + Splits a version string into an array for comparison. |
| 32 | + This includes dealing with some letters. |
| 33 | +
|
| 34 | + e.g. 1.1.1a would become [1, 1, 1, a] |
| 35 | + """ |
| 36 | + |
| 37 | + if not version_string or version_string.lower() == "unknown": |
| 38 | + raise UnknownVersion(f"version string = {version_string}") |
| 39 | + |
| 40 | + versionString = version_string.strip() |
| 41 | + versionArray = [] |
| 42 | + |
| 43 | + # convert - and _ to be treated like . below |
| 44 | + # we could switch to a re split but it seems to leave blanks so this is less hassle |
| 45 | + versionString = versionString.replace("-", ".") |
| 46 | + versionString = versionString.replace("_", ".") |
| 47 | + # Note: there may be other non-alphanumeric characters we want to add here in the |
| 48 | + # future, but we'd like to look at those cases before adding them in case the version |
| 49 | + # logic is very different. |
| 50 | + |
| 51 | + # Attempt a split |
| 52 | + split_version = versionString.split(".") |
| 53 | + |
| 54 | + # if the whole string was numeric then we're done and you can move on |
| 55 | + if versionString.isnumeric(): |
| 56 | + versionArray = split_version |
| 57 | + return versionArray |
| 58 | + |
| 59 | + # Go through and split up anything like 6a in to 6 and a |
| 60 | + number_letter = re.compile("([0-9]+)([a-zA-Z]+)") |
| 61 | + letter_number = re.compile("([a-zA-Z]+)([0-9]+)") |
| 62 | + for section in split_version: |
| 63 | + # if it's all letters or all nubmers, just add it to the array |
| 64 | + if section.isnumeric() or section.isalpha(): |
| 65 | + versionArray.append(section) |
| 66 | + |
| 67 | + # if it looks like 42a split out the letters and numbers |
| 68 | + # We will treat 42a as coming *after* version 42. |
| 69 | + elif re.match(number_letter, section): |
| 70 | + result = re.findall(number_letter, section) |
| 71 | + |
| 72 | + # We're expecting a result that looks like [("42", "a")] but let's verify |
| 73 | + # and then add it to the array |
| 74 | + if len(result) == 1 and len(result[0]) == 2: |
| 75 | + versionArray.append(result[0][0]) |
| 76 | + versionArray.append(result[0][1]) |
| 77 | + else: |
| 78 | + raise CannotParseVersionException(f"version string = {versionString}") |
| 79 | + |
| 80 | + # if it looks like rc1 or dev7 we'll leave it together as it may be some kind of pre-release |
| 81 | + # and we'll probably want to handle it specially in the compare. |
| 82 | + # We need to threat 42dev7 as coming *before* version 42. |
| 83 | + elif re.match(letter_number, section): |
| 84 | + versionArray.append(section) |
| 85 | + |
| 86 | + # If all else fails, complain |
| 87 | + else: |
| 88 | + if versionString != ".": |
| 89 | + raise CannotParseVersionException(f"version string = {versionString}") |
| 90 | + |
| 91 | + return versionArray |
| 92 | + |
| 93 | + |
| 94 | +def version_compare(v1: str, v2: str): |
| 95 | + """ |
| 96 | + Compare two versions by converting them to arrays |
| 97 | +
|
| 98 | + returns 0 if they're the same. |
| 99 | + returns 1 if v1 > v2 |
| 100 | + returns -1 if v1 < v2findall |
| 101 | + n |
| 102 | + """ |
| 103 | + v1_array = parse_version(v1) |
| 104 | + v2_array = parse_version(v2) |
| 105 | + |
| 106 | + for i in range(len(v1_array)): |
| 107 | + if len(v2_array) > i: |
| 108 | + # If it's all numbers, cast to int and compare |
| 109 | + if v1_array[i].isnumeric() and v2_array[i].isnumeric(): |
| 110 | + if int(v1_array[i]) > int(v2_array[i]): |
| 111 | + return 1 |
| 112 | + if int(v1_array[i]) < int(v2_array[i]): |
| 113 | + return -1 |
| 114 | + |
| 115 | + # If they're letters just do a string compare, I don't have a better idea |
| 116 | + # This might be a bad choice in some cases: Do we want ag < z? |
| 117 | + # I suspect projects using letters in version names may not use ranges in nvd |
| 118 | + # for this reason (e.g. openssl) |
| 119 | + # Converting to lower() so that 3.14a == 3.14A |
| 120 | + # but this may not be ideal in all cases |
| 121 | + elif v1_array[i].isalpha() and v2_array[i].isalpha(): |
| 122 | + if v1_array[i].lower() > v2_array[i].lower(): |
| 123 | + return 1 |
| 124 | + if v1_array[i].lower() < v2_array[i].lower(): |
| 125 | + return -1 |
| 126 | + |
| 127 | + else: |
| 128 | + # They are not the same type, and we're comparing mixed letters and numbers. |
| 129 | + # We'll treat letters as less than numbers. |
| 130 | + # This will result in things like rc1, dev9, b2 getting treated like pre-releases |
| 131 | + # as in https://peps.python.org/pep-0440/ |
| 132 | + # So 1.2.pre4 would be less than 1.2.1 and (so would 1.2.post1) |
| 133 | + if v1_array[i].isalnum() and v2_array[i].isnumeric(): |
| 134 | + return -1 |
| 135 | + elif v1_array[i].isnumeric() and v2_array[i].isalnum(): |
| 136 | + return 1 |
| 137 | + |
| 138 | + # They're both of type letter567 and we'll convert them to be letter.567 and |
| 139 | + # run them through the compare function again |
| 140 | + # Honestly it's hard to guess if .dev3 is going to be more or less than .rc4 |
| 141 | + # unless you know the project, so hopefully people don't expect that kind of range |
| 142 | + # matching |
| 143 | + v1_newstring = re.sub("([a-zA-Z]+)([0-9]+)", r"\1.\2", v1_array[i]) |
| 144 | + v2_newstring = re.sub("([a-zA-Z]+)([0-9]+)", r"\1.\2", v2_array[i]) |
| 145 | + print(f"`{v1_newstring}` and `{v2_newstring}`") |
| 146 | + return version_compare(v1_newstring, v2_newstring) |
| 147 | + |
| 148 | + # And if all else fails, just compare the strings |
| 149 | + if v1_array[i] > v2_array[i]: |
| 150 | + return 1 |
| 151 | + if v1_array[i] < v2_array[i]: |
| 152 | + return -1 |
| 153 | + |
| 154 | + else: |
| 155 | + # v1 has more digits than v2 |
| 156 | + # Check to see if v1's something that looks like a pre-release (a2, dev8, rc4) |
| 157 | + # e.g. 4.5.a1 would be less than 4.5 |
| 158 | + if re.match("([a-zA-Z]+)([0-9]+)", v1_array[i]): |
| 159 | + return -1 |
| 160 | + |
| 161 | + # Otherwise, v1 has more digits than v2 and the previous ones matched, |
| 162 | + # so it's probably later. e.g. 1.2.3 amd 1.2.q are both > 1.2 |
| 163 | + return 1 |
| 164 | + |
| 165 | + # if we made it this far and they've matched, see if there's more stuff in v2 |
| 166 | + # e.g. 1.2.3 or 1.2a comes after 1.2 |
| 167 | + if len(v2_array) > len(v1_array): |
| 168 | + # special case: if v2 declares itself a post-release, we'll say it's bigger than v1 |
| 169 | + if v2_array[len(v1_array)].startswith("post"): |
| 170 | + return -1 |
| 171 | + |
| 172 | + # if what's in v2 next looks like a pre-release number (e.g. a2, dev8, rc4) then we'll |
| 173 | + # claim v1 is still bigger, otherwise we'll say v2 is. |
| 174 | + if re.match("([0-9]+)([a-zA-Z]+)", v2_array[len(v1_array)]): |
| 175 | + return 1 |
| 176 | + |
| 177 | + return -1 |
| 178 | + |
| 179 | + return 0 |
| 180 | + |
| 181 | + |
| 182 | +class Version(str): |
| 183 | + """ |
| 184 | + A class to make version comparisons look more pretty: |
| 185 | +
|
| 186 | + Version("1.2") > Version("1.1") |
| 187 | + """ |
| 188 | + |
| 189 | + def __cmp__(self, other): |
| 190 | + """compare""" |
| 191 | + return version_compare(self, other) |
| 192 | + |
| 193 | + def __lt__(self, other): |
| 194 | + """<""" |
| 195 | + return bool(version_compare(self, other) < 0) |
| 196 | + |
| 197 | + def __le__(self, other): |
| 198 | + """<=""" |
| 199 | + return bool(version_compare(self, other) <= 0) |
| 200 | + |
| 201 | + def __gt__(self, other): |
| 202 | + """>""" |
| 203 | + return bool(version_compare(self, other) > 0) |
| 204 | + |
| 205 | + def __ge__(self, other): |
| 206 | + """>=""" |
| 207 | + return bool(version_compare(self, other) >= 0) |
| 208 | + |
| 209 | + def __eq__(self, other): |
| 210 | + """==""" |
| 211 | + return bool(version_compare(self, other) == 0) |
| 212 | + |
| 213 | + def __ne__(self, other): |
| 214 | + """!=""" |
| 215 | + return bool(version_compare(self, other) != 0) |
| 216 | + |
| 217 | + def __repr__(self): |
| 218 | + """print the version string""" |
| 219 | + return f"Version: {self}" |
0 commit comments