File: version-compare

package info (click to toggle)
snapd 2.71-3
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 79,536 kB
  • sloc: ansic: 16,114; sh: 16,105; python: 9,941; makefile: 1,890; exp: 190; awk: 40; xml: 22
file content (308 lines) | stat: -rwxr-xr-x 10,589 bytes parent folder | download | duplicates (3)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
#!/usr/bin/env python3
from __future__ import print_function, absolute_import, unicode_literals

from argparse import Action, ArgumentParser, RawTextHelpFormatter, SUPPRESS
import itertools
import re
import sys
import sys
import unittest

# PY2 is true when we're running under Python 2.x It is used for appropriate
# return value selection of __str__ and __repr_ methods, which must both
# return str, not unicode (in Python 2) and str (in Python 3). In both cases
# the return type annotation is exactly the same, but due to unicode_literals
# being in effect, and the fact we often use a format string (which is an
# unicode string in Python 2), we must encode the it to byte string when
# running under Python 2.
PY2 = sys.version_info[0] == 2

# Define MYPY as False and use it as a conditional for typing import. Despite
# this declaration mypy will really treat MYPY as True when type-checking.
# This is required so that we can import typing on Python 2.x without the
# typing module installed. For more details see:
# https://mypy.readthedocs.io/en/latest/common_issues.html#import-cycles
MYPY = False
if MYPY:
    from typing import Any, Text, Tuple, Optional, Union, Sequence
    from argparse import Namespace


class _UnitTestAction(Action):
    def __init__(
        self,
        option_strings,
        dest=SUPPRESS,
        default=SUPPRESS,
        help="run program's unit test suite and exit",
    ):
        # type: (Text, Text, Text, Text) -> None
        super(_UnitTestAction, self).__init__(
            option_strings=option_strings,
            dest=dest,
            default=default,
            nargs="...",
            help=help,
        )

    def __call__(self, parser, ns, values, option_string=None):
        # type: (ArgumentParser, Namespace, Union[str, Sequence[Any], None], Optional[Text]) -> None
        # We allow the caller to provide the test to invoke by giving
        # --run-unit-tests a set of arguments.
        argv = [sys.argv[0]]
        if isinstance(values, list):
            argv += values
        unittest.main(argv=argv)
        parser.exit()


def consistent_relation(rel_op, delta):
    # type: (Text, int) -> bool
    """
    consistent_relation returns true if the relation is consistent with delta.

    The relation operator is one of ==, !=, <, <=, > or >=.
    Delta is either 0, a positive or a negative number.
    """
    if rel_op == "==":
        return delta == 0
    elif rel_op == "!=":
        return delta != 0
    elif rel_op == ">":
        return delta > 0
    elif rel_op == ">=":
        return delta >= 0
    elif rel_op == "<":
        return delta < 0
    elif rel_op == "<=":
        return delta <= 0
    raise ValueError("unexpected relational operator " + rel_op)


class ConsistentRelationTests(unittest.TestCase):
    def test_eq(self):
        # type: () -> None
        self.assertFalse(consistent_relation("==", -1))
        self.assertTrue(consistent_relation("==", 0))
        self.assertFalse(consistent_relation("==", +1))

    def test_ne(self):
        # type: () -> None
        self.assertTrue(consistent_relation("!=", -1))
        self.assertFalse(consistent_relation("!=", 0))
        self.assertTrue(consistent_relation("!=", +1))

    def test_gt(self):
        # type: () -> None
        self.assertFalse(consistent_relation(">", -1))
        self.assertFalse(consistent_relation(">", 0))
        self.assertTrue(consistent_relation(">", +1))

    def test_ge(self):
        # type: () -> None
        self.assertFalse(consistent_relation(">=", -1))
        self.assertTrue(consistent_relation(">=", 0))
        self.assertTrue(consistent_relation(">=", +1))

    def test_lt(self):
        # type: () -> None
        self.assertTrue(consistent_relation("<", -1))
        self.assertFalse(consistent_relation("<", 0))
        self.assertFalse(consistent_relation("<", +1))

    def test_le(self):
        # type: () -> None
        self.assertTrue(consistent_relation("<=", -1))
        self.assertTrue(consistent_relation("<=", 0))
        self.assertFalse(consistent_relation("<=", +1))

    def test_unknown(self):
        # type: () -> None
        with self.assertRaises(ValueError):
            consistent_relation("???", 0)


def strict_version_cmp(a, b):
    # type: (Text, Text) -> int
    """
    strictly_version_cmp compares two version numbers without leeway.

    The algorithm considers each version to be a tuple of integers. Non,
    integer elements or element fragments are regarded as an error and raised
    as ValueError.

    Comparison is performed on by considering the leftmost element in each
    tuple. First pair of numbers that are not equal determine the result of the
    comparison. Tuples have unequal length then missing elements are
    substituted with zero.

    The return value is 0 if the version strings are equal, -1 if version a is
    smaller or +1 if version b is smaller.
    """
    try:
        a_items = [int(item, 10) for item in a.split(".")]
    except ValueError:
        raise ValueError("version {} is not purely numeric".format(a))
    try:
        b_items = [int(item, 10) for item in b.split(".")]
    except ValueError:
        raise ValueError("version {} is not purely numeric".format(b))
    if PY2:
        zip_longest_fn = itertools.izip_longest
    else:
        zip_longest_fn = itertools.zip_longest
    for a_val, b_val in zip_longest_fn(a_items, b_items, fillvalue=0):
        delta = a_val - b_val
        if delta != 0:
            return 1 if delta > 0 else -1
    return 0


class StrictVersionCmpTests(unittest.TestCase):
    def test_simple(self):
        # type: () -> None
        self.assertEqual(strict_version_cmp("10", "10"), 0)
        self.assertEqual(strict_version_cmp("10", "20"), -1)
        self.assertEqual(strict_version_cmp("20", "10"), +1)

    def test_many_segments(self):
        # type: () -> None
        self.assertEqual(strict_version_cmp("1.2.3", "1.2.3"), 0)
        self.assertEqual(strict_version_cmp("1.2.3", "1.3.4"), -1)
        self.assertEqual(strict_version_cmp("1.4.3", "1.2.3"), +1)
        self.assertEqual(strict_version_cmp("1.0.0", "1.1.0"), -1)
        self.assertEqual(strict_version_cmp("0.1.2", "1.1.2"), -1)

    def test_unequal_length(self):
        # type: () -> None
        self.assertEqual(strict_version_cmp("1", "1.0"), 0)
        self.assertEqual(strict_version_cmp("1", "1.2"), -1)
        self.assertEqual(strict_version_cmp("1.2", "1"), +1)
        self.assertEqual(strict_version_cmp("1", "1.0.1"), -1)
        self.assertEqual(strict_version_cmp("1.1", "1.0.1"), +1)

    def test_version_with_text(self):
        # type: () -> None
        with self.assertRaises(ValueError) as cm:
            strict_version_cmp("1-foo", "1")
        self.assertEqual(cm.exception.args, ("version 1-foo is not purely numeric",))
        with self.assertRaises(ValueError) as cm:
            strict_version_cmp("1.2-foo", "1.2")
        self.assertEqual(cm.exception.args, ("version 1.2-foo is not purely numeric",))


def _make_parser():
    # type: () -> ArgumentParser
    parser = ArgumentParser(
        epilog="""
Relational operator is one of:

    -eq -ne -gt -ge -lt -le

Version comparison is performed using the selected algorithm.

strict:
    The algorithm considers each version to be a tuple of integers.
    Non-integer elements are considered to be invalid version.

    Comparison is performed on by considering the leftmost element in each
    tuple. First pair of numbers that are not equal determine the result of the
    comparison. Tuples have unequal length then missing elements are
    substituted with zero.
    """,
        formatter_class=RawTextHelpFormatter,
    )
    parser.register("action", "unit-test", _UnitTestAction)
    parser.add_argument("-v", "--version", action="version", version="1.0")
    parser.add_argument(
        "--verbose", action="store_true", help="describe comparison process"
    )

    parser.add_argument("version_a", metavar="VERSION-A")
    parser.add_argument("version_b", metavar="VERSION-B")

    # algorithm selection
    alg_grp = parser.add_mutually_exclusive_group(required=True)
    alg_grp.add_argument(
        "--strict",
        dest="algorithm",
        action="store_const",
        const=strict_version_cmp,
        help="select the strict version comparison",
    )
    # relation selection
    rel_op_grp = parser.add_mutually_exclusive_group(required=True)
    rel_op_grp.add_argument(
        "-eq",
        action="store_const",
        const="==",
        dest="rel_op",
        help="test that versions are equal",
    )
    rel_op_grp.add_argument(
        "-ne",
        action="store_const",
        const="!=",
        dest="rel_op",
        help="test that versions are not equal",
    )
    rel_op_grp.add_argument(
        "-gt",
        action="store_const",
        const=">",
        dest="rel_op",
        help="test that version-a is greater than version-b",
    )
    rel_op_grp.add_argument(
        "-ge",
        action="store_const",
        const=">=",
        dest="rel_op",
        help="test that version-a is greater than or equal to version-b",
    )
    rel_op_grp.add_argument(
        "-lt",
        action="store_const",
        const="<",
        dest="rel_op",
        help="test that version-a is less than version-b",
    )
    rel_op_grp.add_argument(
        "-le",
        action="store_const",
        const="<=",
        dest="rel_op",
        help="test that version-a is less than or equal to version-b",
    )

    # maintenance commands
    maint_grp = parser.add_argument_group("maintenance commands")
    maint_grp.add_argument("--run-unit-tests", action="unit-test", help=SUPPRESS)
    return parser


def main():
    # type: () -> None
    opts = _make_parser().parse_args()
    try:
        delta = opts.algorithm(opts.version_a, opts.version_b)
    except ValueError as exc:
        print("error: {}".format(exc), file=sys.stderr)
        raise SystemExit(2)
    else:
        is_consistent = consistent_relation(opts.rel_op, delta)
        if opts.verbose:
            print(
                "delta between {} and {} is: {}".format(
                    opts.version_a, opts.version_b, delta
                )
            )
            if is_consistent:
                print("delta {} is consistent with {}".format(delta, opts.rel_op))
            else:
                print("delta {} is inconsistent with {}".format(delta, opts.rel_op))
        raise SystemExit(0 if is_consistent else 1)


if __name__ == "__main__":
    main()