Version sorter
Semantic Versioning: one list sorted as text, by a natural sort and by the SemVer rule, with the pairs they disagree on; two versions compared with the rule that decides; and the newest version matching a caret, tilde or comparator range.
Every screen below was recorded under CPython. When this page was built, the EML interpreter replayed each session from the same input and printed the same bytes.
About
Version numbers by Semantic Versioning 2.0.0: MAJOR.MINOR.PATCH, then an optional pre-release after - and build metadata after +. One list is sorted three ways, two versions are compared with the rule that decides, and the newest version in a range is picked from the list.
main.eml- the menu, the three sorts and their disagreements, comparing two, ranges, and a new listversions.eml- reading a version, the SemVer rule, a natural sort, sorting, and range bounds
How each part works:
- Reading: three whole numbers without leading zeros. A pre-release is dot-separated identifiers of letters, digits and hyphens, and a number among them has no leading zero. Build metadata is the same without that rule.
- The SemVer rule: - MAJOR, MINOR, then PATCH, as numbers; - a pre-release comes before its release; - pre-release identifiers one by one: numbers as numbers, words in ASCII order, a number before a word; - with every earlier identifier equal, more identifiers come later; - build metadata is ignored.
- Three sorts, as in the corpus case
version-string-ordering: - as text, which puts 1.10.0 before 1.9.1; - a natural sort that reads runs of digits as numbers, but puts 1.0.0 before 1.0.0-alpha, the shorter text first; - SemVer.
The program counts the pairs where text and the natural sort disagree with SemVer. - Ranges: - ^ keeps the first non-zero part: ^1.2.3 is >=1.2.3 <2.0.0, ^0.2.3 is >=0.2.3 <0.3.0, and ^0.0.3 is >=0.0.3 <0.0.4; - ~ keeps the minor: ~1.2.3 is >=1.2.3 <1.3.0; - comparators >=, >, <=, < and = can be combined with spaces.
Pre-releases never match, and a range takes releases only.
What is checked: menu choices 1 to 5; every version in the form above; ranges of releases only; a list of 2 to 20 versions. An empty answer cancels.
Sessions: sessions/basic.in gives: - the starting list of 13 sorted three ways: 17 and 14 of the 78 pairs disagree with SemVer; - six comparisons, each decided by a different rule; - the ranges ^1.0.0, ~1.9.0, >=1.2.0 <1.10.0 and ^2.0.0; - a list of 0.x versions, with ^0.2.3 and ^0.0.3, and its three sorts.
sessions/bad-input.in gives: - menu choices 0 and x; - versions with two parts, a leading zero in the core and in a pre-release, an empty pre-release and build, a stray #, and a v in front; two cancels; - a cancel, a range with two parts, a range with a pre-release, and a range nothing matches; - a cancel, a list of one, a list with a word in it; then the sorts again.
Built on the verified corpus case version-string-ordering (a version is a tuple of numbers; as text it goes wrong at the tenth release).
Recorded sessions
What the screen shows while someone uses the program. Each typed line appears after its prompt, the way a terminal shows it.
bad-input
interpreter: byte-equal== Version sorter ==
Semantic Versioning: MAJOR.MINOR.PATCH, then a pre-release after - and build metadata after +.
1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit
choice> 0
Pick a number from 1 to 5.
1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit
choice> x
Pick a number from 1 to 5.
1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit
choice> 2
first version> 1.2
Not a version: a version has three numbers, MAJOR.MINOR.PATCH.
1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit
choice> 2
first version> 01.2.3
Not a version: MAJOR, MINOR and PATCH have no leading zeros (01).
1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit
choice> 2
first version> 1.2.3-01
Not a version: a number in a pre-release has no leading zero (01).
1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit
choice> 2
first version> 1.2.3-
Not a version: a pre-release is dot-separated letters, digits and hyphens, none empty.
1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit
choice> 2
first version> 1.2.3+
Not a version: build metadata is dot-separated letters, digits and hyphens.
1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit
choice> 2
first version> 1.2.3-al#pha
Not a version: a pre-release is dot-separated letters, digits and hyphens, none empty.
1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit
choice> 2
first version> v1.2.3
Not a version: MAJOR, MINOR and PATCH are whole numbers.
1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit
choice> 2
first version>
Cancelled.
1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit
choice> 2
first version> 1.0.0
second version>
Cancelled.
1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit
choice> 3
A range: ^1.2.3 (same major), ~1.2.3 (same minor), or comparators such as >=1.2.0 <1.5.0. Pre-releases never match.
range>
Cancelled.
1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit
choice> 3
A range: ^1.2.3 (same major), ~1.2.3 (same minor), or comparators such as >=1.2.0 <1.5.0. Pre-releases never match.
range> ^1.2
Not a range: 1.2: a version has three numbers, MAJOR.MINOR.PATCH.
1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit
choice> 3
A range: ^1.2.3 (same major), ~1.2.3 (same minor), or comparators such as >=1.2.0 <1.5.0. Pre-releases never match.
range> ^1.2.3-beta
Not a range: a range here takes releases only, without a pre-release.
1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit
choice> 3
A range: ^1.2.3 (same major), ~1.2.3 (same minor), or comparators such as >=1.2.0 <1.5.0. Pre-releases never match.
range> >=3.0.0
>=3.0.0 means >=3.0.0.
No version in the list matches.
1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit
choice> 4
Versions separated by spaces, 2 to 20 of them.
versions>
Cancelled.
1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit
choice> 4
Versions separated by spaces, 2 to 20 of them.
versions> 1.0.0
Type 2 to 20 versions.
1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit
choice> 4
Versions separated by spaces, 2 to 20 of them.
versions> 1.0.0 banana
Not a version: banana - a version has three numbers, MAJOR.MINOR.PATCH.
1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit
choice> 1
as text 1.0.0 1.0.0+build.5 1.0.0-alpha 1.0.0-alpha.1 1.0.0-alpha.beta 1.0.0-beta 1.0.0-beta.11 1.0.0-beta.2 1.10.0 1.2.0 1.9.1 2.0.0 2.0.0-rc.1
natural sort 1.0.0 1.0.0+build.5 1.0.0-alpha 1.0.0-alpha.1 1.0.0-alpha.beta 1.0.0-beta 1.0.0-beta.2 1.0.0-beta.11 1.2.0 1.9.1 1.10.0 2.0.0 2.0.0-rc.1
SemVer 1.0.0-alpha 1.0.0-alpha.1 1.0.0-alpha.beta 1.0.0-beta 1.0.0-beta.2 1.0.0-beta.11 1.0.0 1.0.0+build.5 1.2.0 1.9.1 1.10.0 2.0.0-rc.1 2.0.0
Of the 78 pairs, text order disagrees with SemVer on 17 and the natural sort on 14.
First text disagreement: 1.10.0 and 1.2.0.
First natural-sort disagreement: 1.0.0-alpha and 1.0.0.
Text compares character by character, so 1.10.0 comes before 1.9.1. A natural sort reads numbers as numbers, but puts 1.0.0 before 1.0.0-alpha, the shorter text first. SemVer puts every pre-release before its release.
1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit
choice> 5
Bye.
What was typed (37 lines)
0
x
2
1.2
2
01.2.3
2
1.2.3-01
2
1.2.3-
2
1.2.3+
2
1.2.3-al#pha
2
v1.2.3
2
2
1.0.0
3
3
^1.2
3
^1.2.3-beta
3
>=3.0.0
4
4
1.0.0
4
1.0.0 banana
1
5
basic
interpreter: byte-equal== Version sorter ==
Semantic Versioning: MAJOR.MINOR.PATCH, then a pre-release after - and build metadata after +.
1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit
choice> 1
as text 1.0.0 1.0.0+build.5 1.0.0-alpha 1.0.0-alpha.1 1.0.0-alpha.beta 1.0.0-beta 1.0.0-beta.11 1.0.0-beta.2 1.10.0 1.2.0 1.9.1 2.0.0 2.0.0-rc.1
natural sort 1.0.0 1.0.0+build.5 1.0.0-alpha 1.0.0-alpha.1 1.0.0-alpha.beta 1.0.0-beta 1.0.0-beta.2 1.0.0-beta.11 1.2.0 1.9.1 1.10.0 2.0.0 2.0.0-rc.1
SemVer 1.0.0-alpha 1.0.0-alpha.1 1.0.0-alpha.beta 1.0.0-beta 1.0.0-beta.2 1.0.0-beta.11 1.0.0 1.0.0+build.5 1.2.0 1.9.1 1.10.0 2.0.0-rc.1 2.0.0
Of the 78 pairs, text order disagrees with SemVer on 17 and the natural sort on 14.
First text disagreement: 1.10.0 and 1.2.0.
First natural-sort disagreement: 1.0.0-alpha and 1.0.0.
Text compares character by character, so 1.10.0 comes before 1.9.1. A natural sort reads numbers as numbers, but puts 1.0.0 before 1.0.0-alpha, the shorter text first. SemVer puts every pre-release before its release.
1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit
choice> 2
first version> 1.9.1
second version> 1.10.0
1.9.1 comes before 1.10.0: minor 9 vs 10.
As text the order is the other way round.
1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit
choice> 2
first version> 1.0.0-beta.2
second version> 1.0.0-beta.11
1.0.0-beta.2 comes before 1.0.0-beta.11: pre-release part 2: 2 vs 11 as numbers.
As text the order is the other way round.
1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit
choice> 2
first version> 1.0.0-alpha
second version> 1.0.0
1.0.0-alpha comes before 1.0.0: a pre-release comes before its release.
As text the order is the other way round.
1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit
choice> 2
first version> 1.0.0-alpha
second version> 1.0.0-alpha.1
1.0.0-alpha comes before 1.0.0-alpha.1: every earlier pre-release part is equal, and more parts come later.
1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit
choice> 2
first version> 1.0.0-alpha.1
second version> 1.0.0-alpha.beta
1.0.0-alpha.1 comes before 1.0.0-alpha.beta: pre-release part 2: a number comes before a word.
1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit
choice> 2
first version> 1.0.0
second version> 1.0.0+build.5
1.0.0 and 1.0.0+build.5 rank the same: build metadata is ignored.
As text they differ, so a text sort still puts one first.
1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit
choice> 3
A range: ^1.2.3 (same major), ~1.2.3 (same minor), or comparators such as >=1.2.0 <1.5.0. Pre-releases never match.
range> ^1.0.0
^1.0.0 means >=1.0.0 <2.0.0.
Matching: 1.0.0 1.0.0+build.5 1.2.0 1.9.1 1.10.0. Newest: 1.10.0.
1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit
choice> 3
A range: ^1.2.3 (same major), ~1.2.3 (same minor), or comparators such as >=1.2.0 <1.5.0. Pre-releases never match.
range> ~1.9.0
~1.9.0 means >=1.9.0 <1.10.0.
Matching: 1.9.1. Newest: 1.9.1.
1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit
choice> 3
A range: ^1.2.3 (same major), ~1.2.3 (same minor), or comparators such as >=1.2.0 <1.5.0. Pre-releases never match.
range> >=1.2.0 <1.10.0
>=1.2.0 <1.10.0 means >=1.2.0 <1.10.0.
Matching: 1.2.0 1.9.1. Newest: 1.9.1.
1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit
choice> 3
A range: ^1.2.3 (same major), ~1.2.3 (same minor), or comparators such as >=1.2.0 <1.5.0. Pre-releases never match.
range> ^2.0.0
^2.0.0 means >=2.0.0 <3.0.0.
Matching: 2.0.0. Newest: 2.0.0.
1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit
choice> 4
Versions separated by spaces, 2 to 20 of them.
versions> 0.2.3 0.2.10 0.3.0 0.0.3 0.0.4 0.10.0
6 versions.
1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit
choice> 3
A range: ^1.2.3 (same major), ~1.2.3 (same minor), or comparators such as >=1.2.0 <1.5.0. Pre-releases never match.
range> ^0.2.3
^0.2.3 means >=0.2.3 <0.3.0.
Matching: 0.2.3 0.2.10. Newest: 0.2.10.
1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit
choice> 3
A range: ^1.2.3 (same major), ~1.2.3 (same minor), or comparators such as >=1.2.0 <1.5.0. Pre-releases never match.
range> ^0.0.3
^0.0.3 means >=0.0.3 <0.0.4.
Matching: 0.0.3. Newest: 0.0.3.
1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit
choice> 1
as text 0.0.3 0.0.4 0.10.0 0.2.10 0.2.3 0.3.0
natural sort 0.0.3 0.0.4 0.2.3 0.2.10 0.3.0 0.10.0
SemVer 0.0.3 0.0.4 0.2.3 0.2.10 0.3.0 0.10.0
Of the 15 pairs, text order disagrees with SemVer on 4 and the natural sort on 0.
First text disagreement: 0.2.3 and 0.2.10.
Text compares character by character, so 1.10.0 comes before 1.9.1. A natural sort reads numbers as numbers, but puts 1.0.0 before 1.0.0-alpha, the shorter text first. SemVer puts every pre-release before its release.
1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit
choice> 5
Bye.
What was typed (35 lines)
1
2
1.9.1
1.10.0
2
1.0.0-beta.2
1.0.0-beta.11
2
1.0.0-alpha
1.0.0
2
1.0.0-alpha
1.0.0-alpha.1
2
1.0.0-alpha.1
1.0.0-alpha.beta
2
1.0.0
1.0.0+build.5
3
^1.0.0
3
~1.9.0
3
>=1.2.0 <1.10.0
3
^2.0.0
4
0.2.3 0.2.10 0.3.0 0.0.3 0.0.4 0.10.0
3
^0.2.3
3
^0.0.3
1
5
Modules
The program as written, entry module first. Each module transpiles to its own Python file, which is what eml project run executes.
main.eml(entry)
eml# P103 version sorter: one list of versions sorted three ways, two versions
# compared with the rule that decides, and the newest version in a range.
import versions
def plain(s):
0 => i
len(s) => j
while i < j and s[i] == " ":
i + 1 => i
while j > i and s[j - 1] == " ":
j - 1 => j
return s[i:j]
def words(s):
[] => out
"" => w
for c in s + " ":
if c == " " or c == ",":
if w != "":
out + [w] => out
"" => w
else:
w + c => w
return out
def joined(xs):
"" => out
for x in xs:
if out != "":
out + " " => out
out + x => out
return out
def show_sorts(texts):
["text", "natural", "semver"] => rules
["as text ", "natural sort ", "SemVer "] => labels
for k in [0:2]:
(" " + labels[k] + joined(versions.order(texts, rules[k]))) ^0
0 => pairs
0 => text_off
0 => natural_off
"" => example_text
"" => example_natural
for i in [0:len(texts) - 1]:
for j in [i + 1:len(texts) - 1]:
pairs + 1 => pairs
versions.compare(texts[i], texts[j], "semver") => s
if versions.compare(texts[i], texts[j], "text") != s:
text_off + 1 => text_off
if example_text == "":
texts[i] + " and " + texts[j] => example_text
if versions.compare(texts[i], texts[j], "natural") != s:
natural_off + 1 => natural_off
if example_natural == "":
texts[i] + " and " + texts[j] => example_natural
("Of the " + str(pairs) + " pairs, text order disagrees with SemVer on " + str(text_off) + " and the natural sort on " + str(natural_off) + ".") ^0
if example_text != "":
(" First text disagreement: " + example_text + ".") ^0
if example_natural != "":
(" First natural-sort disagreement: " + example_natural + ".") ^0
"Text compares character by character, so 1.10.0 comes before 1.9.1. A natural sort reads numbers as numbers, but puts 1.0.0 before 1.0.0-alpha, the shorter text first. SemVer puts every pre-release before its release." ^0
def ask_version(prompt):
plain(input(prompt + "> ")) => t
if t == "":
return ["", []]
versions.parse(t) => p
if p[1] != "":
("Not a version: " + p[1] + ".") ^0
return ["!", []]
return [t, p[0]]
def compare_two():
ask_version("first version") => a
if a[0] == "":
"Cancelled." ^0
return
if a[0] == "!":
return
ask_version("second version") => b
if b[0] == "":
"Cancelled." ^0
return
if b[0] == "!":
return
versions.semver(a[1], b[1]) => r
if r[0] < 0:
(a[0] + " comes before " + b[0] + ": " + r[1] + ".") ^0
elif r[0] > 0:
(a[0] + " comes after " + b[0] + ": " + r[1] + ".") ^0
else:
(a[0] + " and " + b[0] + " rank the same: " + r[1] + ".") ^0
versions.sign(a[0], b[0]) => t
if r[0] != 0 and t == 0 - r[0]:
" As text the order is the other way round." ^0
elif r[0] == 0 and t != 0:
" As text they differ, so a text sort still puts one first." ^0
def bound_text(b):
"" => out
if len(b[0]) > 0:
">=" + str(b[0][0]) + "." + str(b[0][1]) + "." + str(b[0][2]) => out
if len(b[1]) > 0:
"<" => op
if b[2]:
"<=" => op
if out != "":
out + " " => out
out + op + str(b[1][0]) + "." + str(b[1][1]) + "." + str(b[1][2]) => out
return out
def pick(texts):
"A range: ^1.2.3 (same major), ~1.2.3 (same minor), or comparators such as >=1.2.0 <1.5.0. Pre-releases never match." ^0
plain(input("range> ")) => r
if r == "":
"Cancelled." ^0
return
[] => all_bounds
"" => means
for w in words(r):
versions.range_bounds(w) => b
if b[3] != "":
("Not a range: " + b[3] + ".") ^0
return
all_bounds + [b] => all_bounds
if means != "":
means + " " => means
means + bound_text(b) => means
(r + " means " + means + ".") ^0
[] => hits
for t in texts:
versions.parse(t)[0] => v
True => ok
for b in all_bounds:
if not versions.in_range(v, b):
False => ok
if ok:
hits + [t] => hits
if len(hits) == 0:
"No version in the list matches." ^0
return
versions.order(hits, "semver") => hits
("Matching: " + joined(hits) + ". Newest: " + hits[len(hits) - 1] + ".") ^0
def new_list():
"Versions separated by spaces, 2 to 20 of them." ^0
plain(input("versions> ")) => t
if t == "":
"Cancelled." ^0
return []
words(t) => out
if len(out) < 2 or len(out) > 20:
"Type 2 to 20 versions." ^0
return []
for w in out:
versions.parse(w) => p
if p[1] != "":
("Not a version: " + w + " - " + p[1] + ".") ^0
return []
(str(len(out)) + " versions.") ^0
return out
["1.10.0", "1.2.0", "1.9.1", "1.0.0-alpha", "1.0.0", "1.0.0-beta.11", "1.0.0-beta.2", "1.0.0-alpha.1", "2.0.0-rc.1", "1.0.0-beta", "1.0.0+build.5", "1.0.0-alpha.beta", "2.0.0"] => texts
"== Version sorter ==" ^0
"Semantic Versioning: MAJOR.MINOR.PATCH, then a pre-release after - and build metadata after +." ^0
True => running
while running:
"" ^0
"1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit" ^0
plain(input("choice> ")) => choice
if choice == "1":
show_sorts(texts)
elif choice == "2":
compare_two()
elif choice == "3":
pick(texts)
elif choice == "4":
new_list() => got
if len(got) > 0:
got => texts
elif choice == "5":
False => running
else:
"Pick a number from 1 to 5." ^0
"Bye." ^0
Python projection (main.py)
import versions
def plain(s):
i = 0
j = len(s)
while i < j and s[i] == " ":
i = i + 1
while j > i and s[j - 1] == " ":
j = j - 1
return s[i:j]
def words(s):
out = []
w = ""
for c in s + " ":
if c == " " or c == ",":
if w != "":
out = out + [w]
w = ""
else:
w = w + c
return out
def joined(xs):
out = ""
for x in xs:
if out != "":
out = out + " "
out = out + x
return out
def show_sorts(texts):
rules = ["text", "natural", "semver"]
labels = ["as text ", "natural sort ", "SemVer "]
for k in range(0, 3):
print(" " + labels[k] + joined(versions.order(texts, rules[k])))
pairs = 0
text_off = 0
natural_off = 0
example_text = ""
example_natural = ""
for i in range(0, len(texts)):
for j in range(i + 1, len(texts)):
pairs = pairs + 1
s = versions.compare(texts[i], texts[j], "semver")
if versions.compare(texts[i], texts[j], "text") != s:
text_off = text_off + 1
if example_text == "":
example_text = texts[i] + " and " + texts[j]
if versions.compare(texts[i], texts[j], "natural") != s:
natural_off = natural_off + 1
if example_natural == "":
example_natural = texts[i] + " and " + texts[j]
print("Of the " + str(pairs) + " pairs, text order disagrees with SemVer on " + str(text_off) + " and the natural sort on " + str(natural_off) + ".")
if example_text != "":
print(" First text disagreement: " + example_text + ".")
if example_natural != "":
print(" First natural-sort disagreement: " + example_natural + ".")
print("Text compares character by character, so 1.10.0 comes before 1.9.1. A natural sort reads numbers as numbers, but puts 1.0.0 before 1.0.0-alpha, the shorter text first. SemVer puts every pre-release before its release.")
def ask_version(prompt):
t = plain(input(prompt + "> "))
if t == "":
return ["", []]
p = versions.parse(t)
if p[1] != "":
print("Not a version: " + p[1] + ".")
return ["!", []]
return [t, p[0]]
def compare_two():
a = ask_version("first version")
if a[0] == "":
print("Cancelled.")
return
if a[0] == "!":
return
b = ask_version("second version")
if b[0] == "":
print("Cancelled.")
return
if b[0] == "!":
return
r = versions.semver(a[1], b[1])
if r[0] < 0:
print(a[0] + " comes before " + b[0] + ": " + r[1] + ".")
elif r[0] > 0:
print(a[0] + " comes after " + b[0] + ": " + r[1] + ".")
else:
print(a[0] + " and " + b[0] + " rank the same: " + r[1] + ".")
t = versions.sign(a[0], b[0])
if r[0] != 0 and t == 0 - r[0]:
print(" As text the order is the other way round.")
elif r[0] == 0 and t != 0:
print(" As text they differ, so a text sort still puts one first.")
def bound_text(b):
out = ""
if len(b[0]) > 0:
out = ">=" + str(b[0][0]) + "." + str(b[0][1]) + "." + str(b[0][2])
if len(b[1]) > 0:
op = "<"
if b[2]:
op = "<="
if out != "":
out = out + " "
out = out + op + str(b[1][0]) + "." + str(b[1][1]) + "." + str(b[1][2])
return out
def pick(texts):
print("A range: ^1.2.3 (same major), ~1.2.3 (same minor), or comparators such as >=1.2.0 <1.5.0. Pre-releases never match.")
r = plain(input("range> "))
if r == "":
print("Cancelled.")
return
all_bounds = []
means = ""
for w in words(r):
b = versions.range_bounds(w)
if b[3] != "":
print("Not a range: " + b[3] + ".")
return
all_bounds = all_bounds + [b]
if means != "":
means = means + " "
means = means + bound_text(b)
print(r + " means " + means + ".")
hits = []
for t in texts:
v = versions.parse(t)[0]
ok = True
for b in all_bounds:
if not versions.in_range(v, b):
ok = False
if ok:
hits = hits + [t]
if len(hits) == 0:
print("No version in the list matches.")
return
hits = versions.order(hits, "semver")
print("Matching: " + joined(hits) + ". Newest: " + hits[len(hits) - 1] + ".")
def new_list():
print("Versions separated by spaces, 2 to 20 of them.")
t = plain(input("versions> "))
if t == "":
print("Cancelled.")
return []
out = words(t)
if len(out) < 2 or len(out) > 20:
print("Type 2 to 20 versions.")
return []
for w in out:
p = versions.parse(w)
if p[1] != "":
print("Not a version: " + w + " - " + p[1] + ".")
return []
print(str(len(out)) + " versions.")
return out
texts = ["1.10.0", "1.2.0", "1.9.1", "1.0.0-alpha", "1.0.0", "1.0.0-beta.11", "1.0.0-beta.2", "1.0.0-alpha.1", "2.0.0-rc.1", "1.0.0-beta", "1.0.0+build.5", "1.0.0-alpha.beta", "2.0.0"]
print("== Version sorter ==")
print("Semantic Versioning: MAJOR.MINOR.PATCH, then a pre-release after - and build metadata after +.")
running = True
while running:
print("")
print("1) sort the list 2) compare two 3) newest in a range 4) new list 5) quit")
choice = plain(input("choice> "))
if choice == "1":
show_sorts(texts)
elif choice == "2":
compare_two()
elif choice == "3":
pick(texts)
elif choice == "4":
got = new_list()
if len(got) > 0:
texts = got
elif choice == "5":
running = False
else:
print("Pick a number from 1 to 5.")
print("Bye.")
versions.eml
eml# P103 version sorter - Semantic Versioning 2.0.0: MAJOR.MINOR.PATCH, an
# optional pre-release after "-" and build metadata after "+". As in the
# corpus case version-string-ordering, a version is a tuple of numbers
# written with dots, and comparing it as text goes wrong at the tenth
# release; here it is set against a natural sort and the SemVer rule.
#
# A parsed version is [major, minor, patch, pre, build]: pre is a list of
# identifiers (strings), build the text after "+" or "".
def is_digits(s):
if len(s) == 0:
return False
for c in s:
if not (c >= "0" and c <= "9"):
return False
return True
def ident_ok(s):
if len(s) == 0:
return False
for c in s:
if not ((c >= "0" and c <= "9") or (c >= "a" and c <= "z") or (c >= "A" and c <= "Z") or c == "-"):
return False
return True
def split(s, sep):
[] => out
"" => w
for c in s:
if c == sep:
out + [w] => out
"" => w
else:
w + c => w
out + [w] => out
return out
def parse(s):
# [version, ""] or [[], why]
"" => build
-1 => plus
for i in [0:len(s) - 1]:
if plus == -1 and s[i] == "+":
i => plus
if plus != -1:
s[plus + 1:len(s)] => build
s[0:plus] => s
for b in split(build, "."):
if not ident_ok(b):
return [[], "build metadata is dot-separated letters, digits and hyphens"]
[] => pre
-1 => dash
for i in [0:len(s) - 1]:
if dash == -1 and s[i] == "-":
i => dash
if dash != -1:
split(s[dash + 1:len(s)], ".") => pre
s[0:dash] => s
for p in pre:
if not ident_ok(p):
return [[], "a pre-release is dot-separated letters, digits and hyphens, none empty"]
if is_digits(p) and len(p) > 1 and p[0] == "0":
return [[], "a number in a pre-release has no leading zero (" + p + ")"]
split(s, ".") => core
if len(core) != 3:
return [[], "a version has three numbers, MAJOR.MINOR.PATCH"]
for c in core:
if not is_digits(c):
return [[], "MAJOR, MINOR and PATCH are whole numbers"]
if len(c) > 1 and c[0] == "0":
return [[], "MAJOR, MINOR and PATCH have no leading zeros (" + c + ")"]
return [[int(core[0]), int(core[1]), int(core[2]), pre, build], ""]
def text(v):
str(v[0]) + "." + str(v[1]) + "." + str(v[2]) => s
if len(v[3]) > 0:
"" => p
for x in v[3]:
if p != "":
p + "." => p
p + x => p
s + "-" + p => s
if v[4] != "":
s + "+" + v[4] => s
return s
def sign(a, b):
if a < b:
return -1
if a > b:
return 1
return 0
def semver(a, b):
# [-1, 0 or 1, the rule that decided]
["major", "minor", "patch"] => names
for i in [0:2]:
if a[i] != b[i]:
return [sign(a[i], b[i]), names[i] + " " + str(a[i]) + " vs " + str(b[i])]
if len(a[3]) == 0 and len(b[3]) == 0:
if a[4] != b[4]:
return [0, "build metadata is ignored"]
return [0, "the same version"]
if len(a[3]) == 0:
return [1, "a pre-release comes before its release"]
if len(b[3]) == 0:
return [-1, "a pre-release comes before its release"]
0 => i
while i < len(a[3]) and i < len(b[3]):
a[3][i] => x
b[3][i] => y
if x != y:
if is_digits(x) and is_digits(y):
return [sign(int(x), int(y)), "pre-release part " + str(i + 1) + ": " + x + " vs " + y + " as numbers"]
if is_digits(x):
return [-1, "pre-release part " + str(i + 1) + ": a number comes before a word"]
if is_digits(y):
return [1, "pre-release part " + str(i + 1) + ": a number comes before a word"]
return [sign(x, y), "pre-release part " + str(i + 1) + ": " + x + " vs " + y + " in ASCII order"]
i + 1 => i
if len(a[3]) != len(b[3]):
return [sign(len(a[3]), len(b[3])), "every earlier pre-release part is equal, and more parts come later"]
if a[4] != b[4]:
return [0, "build metadata is ignored"]
return [0, "the same version"]
def chunks(s):
# a natural sort's view: runs of digits as numbers, everything else as text
[] => out
"" => w
False => digit
for c in s:
(c >= "0" and c <= "9") => d
if w != "" and d != digit:
out + [w] => out
"" => w
w + c => w
d => digit
if w != "":
out + [w] => out
return out
def natural(a, b):
chunks(a) => x
chunks(b) => y
0 => i
while i < len(x) and i < len(y):
if x[i] != y[i]:
if is_digits(x[i]) and is_digits(y[i]):
if int(x[i]) != int(y[i]):
return sign(int(x[i]), int(y[i]))
return sign(x[i], y[i])
i + 1 => i
return sign(len(x), len(y))
def order(texts, rule):
# the texts sorted by rule ("text", "natural" or "semver"); stable
[] => out
for t in texts:
out + [t] => out
for i in [1:len(out) - 1]:
i => j
while j > 0 and compare(out[j - 1], out[j], rule) > 0:
out[j] => t
out[j - 1] => out[j]
t => out[j - 1]
j - 1 => j
return out
def compare(a, b, rule):
if rule == "text":
return sign(a, b)
if rule == "natural":
return natural(a, b)
return semver(parse(a)[0], parse(b)[0])[0]
def range_bounds(r):
# [low, high, inclusive high, why]: ^V and ~V, or one comparator
# >=V, >V, <=V, <V, =V or V; versions are [major, minor, patch];
# high [] for none, low [] for none
"" => op
for o in [">=", "<=", "^", "~", ">", "<", "="]:
if op == "" and r[0:len(o)] == o:
o => op
r[len(op):len(r)] => rest
parse(rest) => p
if p[1] != "":
return [[], [], False, rest + ": " + p[1]]
p[0] => v
if len(v[3]) > 0:
return [[], [], False, "a range here takes releases only, without a pre-release"]
[v[0], v[1], v[2]] => base
if op == "^":
if v[0] > 0:
return [base, [v[0] + 1, 0, 0], False, ""]
if v[1] > 0:
return [base, [0, v[1] + 1, 0], False, ""]
return [base, [0, 0, v[2] + 1], False, ""]
if op == "~":
return [base, [v[0], v[1] + 1, 0], False, ""]
if op == ">=":
return [base, [], False, ""]
if op == ">":
return [[v[0], v[1], v[2] + 1], [], False, ""]
if op == "<=":
return [[], base, True, ""]
if op == "<":
return [[], base, False, ""]
return [base, base, True, ""]
def triple_cmp(a, b):
for i in [0:2]:
if a[i] != b[i]:
return sign(a[i], b[i])
return 0
def in_range(v, bounds):
# releases only: a pre-release never matches
if len(v[3]) > 0:
return False
[v[0], v[1], v[2]] => t
if len(bounds[0]) > 0 and triple_cmp(t, bounds[0]) < 0:
return False
if len(bounds[1]) > 0:
triple_cmp(t, bounds[1]) => c
if c > 0 or (c == 0 and not bounds[2]):
return False
return True
Python projection (versions.py)
def is_digits(s):
if len(s) == 0:
return False
for c in s:
if not (c >= "0" and c <= "9"):
return False
return True
def ident_ok(s):
if len(s) == 0:
return False
for c in s:
if not (c >= "0" and c <= "9" or c >= "a" and c <= "z" or c >= "A" and c <= "Z" or c == "-"):
return False
return True
def split(s, sep):
out = []
w = ""
for c in s:
if c == sep:
out = out + [w]
w = ""
else:
w = w + c
out = out + [w]
return out
def parse(s):
build = ""
plus = -1
for i in range(0, len(s)):
if plus == -1 and s[i] == "+":
plus = i
if plus != -1:
build = s[plus + 1:len(s)]
s = s[0:plus]
for b in split(build, "."):
if not ident_ok(b):
return [[], "build metadata is dot-separated letters, digits and hyphens"]
pre = []
dash = -1
for i in range(0, len(s)):
if dash == -1 and s[i] == "-":
dash = i
if dash != -1:
pre = split(s[dash + 1:len(s)], ".")
s = s[0:dash]
for p in pre:
if not ident_ok(p):
return [[], "a pre-release is dot-separated letters, digits and hyphens, none empty"]
if is_digits(p) and len(p) > 1 and p[0] == "0":
return [[], "a number in a pre-release has no leading zero (" + p + ")"]
core = split(s, ".")
if len(core) != 3:
return [[], "a version has three numbers, MAJOR.MINOR.PATCH"]
for c in core:
if not is_digits(c):
return [[], "MAJOR, MINOR and PATCH are whole numbers"]
if len(c) > 1 and c[0] == "0":
return [[], "MAJOR, MINOR and PATCH have no leading zeros (" + c + ")"]
return [[int(core[0]), int(core[1]), int(core[2]), pre, build], ""]
def text(v):
s = str(v[0]) + "." + str(v[1]) + "." + str(v[2])
if len(v[3]) > 0:
p = ""
for x in v[3]:
if p != "":
p = p + "."
p = p + x
s = s + "-" + p
if v[4] != "":
s = s + "+" + v[4]
return s
def sign(a, b):
if a < b:
return -1
if a > b:
return 1
return 0
def semver(a, b):
names = ["major", "minor", "patch"]
for i in range(0, 3):
if a[i] != b[i]:
return [sign(a[i], b[i]), names[i] + " " + str(a[i]) + " vs " + str(b[i])]
if len(a[3]) == 0 and len(b[3]) == 0:
if a[4] != b[4]:
return [0, "build metadata is ignored"]
return [0, "the same version"]
if len(a[3]) == 0:
return [1, "a pre-release comes before its release"]
if len(b[3]) == 0:
return [-1, "a pre-release comes before its release"]
i = 0
while i < len(a[3]) and i < len(b[3]):
x = a[3][i]
y = b[3][i]
if x != y:
if is_digits(x) and is_digits(y):
return [sign(int(x), int(y)), "pre-release part " + str(i + 1) + ": " + x + " vs " + y + " as numbers"]
if is_digits(x):
return [-1, "pre-release part " + str(i + 1) + ": a number comes before a word"]
if is_digits(y):
return [1, "pre-release part " + str(i + 1) + ": a number comes before a word"]
return [sign(x, y), "pre-release part " + str(i + 1) + ": " + x + " vs " + y + " in ASCII order"]
i = i + 1
if len(a[3]) != len(b[3]):
return [sign(len(a[3]), len(b[3])), "every earlier pre-release part is equal, and more parts come later"]
if a[4] != b[4]:
return [0, "build metadata is ignored"]
return [0, "the same version"]
def chunks(s):
out = []
w = ""
digit = False
for c in s:
d = c >= "0" and c <= "9"
if w != "" and d != digit:
out = out + [w]
w = ""
w = w + c
digit = d
if w != "":
out = out + [w]
return out
def natural(a, b):
x = chunks(a)
y = chunks(b)
i = 0
while i < len(x) and i < len(y):
if x[i] != y[i]:
if is_digits(x[i]) and is_digits(y[i]):
if int(x[i]) != int(y[i]):
return sign(int(x[i]), int(y[i]))
return sign(x[i], y[i])
i = i + 1
return sign(len(x), len(y))
def order(texts, rule):
out = []
for t in texts:
out = out + [t]
for i in range(1, len(out)):
j = i
while j > 0 and compare(out[j - 1], out[j], rule) > 0:
t = out[j]
out[j] = out[j - 1]
out[j - 1] = t
j = j - 1
return out
def compare(a, b, rule):
if rule == "text":
return sign(a, b)
if rule == "natural":
return natural(a, b)
return semver(parse(a)[0], parse(b)[0])[0]
def range_bounds(r):
op = ""
for o in [">=", "<=", "^", "~", ">", "<", "="]:
if op == "" and r[0:len(o)] == o:
op = o
rest = r[len(op):len(r)]
p = parse(rest)
if p[1] != "":
return [[], [], False, rest + ": " + p[1]]
v = p[0]
if len(v[3]) > 0:
return [[], [], False, "a range here takes releases only, without a pre-release"]
base = [v[0], v[1], v[2]]
if op == "^":
if v[0] > 0:
return [base, [v[0] + 1, 0, 0], False, ""]
if v[1] > 0:
return [base, [0, v[1] + 1, 0], False, ""]
return [base, [0, 0, v[2] + 1], False, ""]
if op == "~":
return [base, [v[0], v[1] + 1, 0], False, ""]
if op == ">=":
return [base, [], False, ""]
if op == ">":
return [[v[0], v[1], v[2] + 1], [], False, ""]
if op == "<=":
return [[], base, True, ""]
if op == "<":
return [[], base, False, ""]
return [base, base, True, ""]
def triple_cmp(a, b):
for i in range(0, 3):
if a[i] != b[i]:
return sign(a[i], b[i])
return 0
def in_range(v, bounds):
if len(v[3]) > 0:
return False
t = [v[0], v[1], v[2]]
if len(bounds[0]) > 0 and triple_cmp(t, bounds[0]) < 0:
return False
if len(bounds[1]) > 0:
c = triple_cmp(t, bounds[1])
if c > 0 or c == 0 and not bounds[2]:
return False
return True