Repository: borgbackup/borg

Issue:
version parsing code blows up with a dirty workdir



Discussion:
My workdir is very dirty (always is), but it doesn't blow up
I meant dirty in the sense of modified, revision controlled files.

@enkore do you have the latest setuptools_scm?
```
> Finished processing dependencies for borgbackup==1.1.0b6.dev119+ng2dcbe02e.d20170525
> pip freeze | grep scm
setuptools-scm==1.15.0
> git status           
On branch issue/2557
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

        modified:   src/borg/repository.py

Untracked files:
  (use "git add <file>..." to include in what will be committed)

---snip---
```

Note quite the newest-est version (which would be 1.15.5).
please get me a SETUPTOOLS_SCM_DEBUG output of the breakage in its bugtracker
I'll try to reproduce it...

Usually, a version looks like this (working case):
```
borg 1.1.0b6.dev110+g892ebd7.d20170525
```

Our `version.py` depends on that `+`:
```
    parts = version.split('+')[0].split('.')
```

IIRC, the version that blew up everything did not have a plus, but a minus at that place.

That sometimes happens in ancient setuptools
It happens with setuptools < 8.0 (setuptools 7.0 is of Oct 2014):
```
Successfully installed setuptools-7.0
(borg-env)tw@tux:~/w/borg$ pip install -e .
Obtaining file:///home/tw/w/borg
Requirement already satisfied: msgpack-python>=0.4.6 in /home/tw/w/borg-env/lib/python3.5/site-packages (from borgbackup==1.0.10)
Installing collected packages: borgbackup
  Found existing installation: borgbackup 1.1.0b6.dev204+g8866961.d20170605
    Uninstalling borgbackup-1.1.0b6.dev204+g8866961.d20170605:
      Successfully uninstalled borgbackup-1.1.0b6.dev204+g8866961.d20170605
  Running setup.py develop for borgbackup
Successfully installed borgbackup
(borg-env)tw@tux:~/w/borg$ pip install -e .
Obtaining file:///home/tw/w/borg
Requirement already satisfied: msgpack-python>=0.4.6 in /home/tw/w/borg-env/lib/python3.5/site-packages (from borgbackup==1.0.10)
Installing collected packages: borgbackup
  Found existing installation: borgbackup 1.1.0b6.dev204-g8866961.d20170605
    Uninstalling borgbackup-1.1.0b6.dev204-g8866961.d20170605:
      Successfully uninstalled borgbackup-1.1.0b6.dev204-g8866961.d20170605
  Running setup.py develop for borgbackup
Successfully installed borgbackup
```
Note the switch from `+` to `-` as separator in the version string.

Resolving patch:
diff --git a/src/borg/testsuite/version.py b/src/borg/testsuite/version.py
new file mode 100644
--- /dev/null
+++ b/src/borg/testsuite/version.py
@@ -0,0 +1,38 @@
+import pytest
+
+from ..version import parse_version
+
+
+@pytest.mark.parametrize("version_str, version_tuple", [
+    # setuptools < 8.0 uses "-"
+    ('1.0.0a1.dev204-g8866961.d20170606', (1, 0, 0, -4, 1)),
+    ('1.0.0a1.dev204-g8866961', (1, 0, 0, -4, 1)),
+    ('1.0.0-d20170606', (1, 0, 0, -1)),
+    # setuptools >= 8.0 uses "+"
+    ('1.0.0a1.dev204+g8866961.d20170606', (1, 0, 0, -4, 1)),
+    ('1.0.0a1.dev204+g8866961', (1, 0, 0, -4, 1)),
+    ('1.0.0+d20170606', (1, 0, 0, -1)),
+    # pre-release versions:
+    ('1.0.0a1', (1, 0, 0, -4, 1)),
+    ('1.0.0a2', (1, 0, 0, -4, 2)),
+    ('1.0.0b3', (1, 0, 0, -3, 3)),
+    ('1.0.0rc4', (1, 0, 0, -2, 4)),
+    # release versions:
+    ('0.0.0', (0, 0, 0, -1)),
+    ('0.0.11', (0, 0, 11, -1)),
+    ('0.11.0', (0, 11, 0, -1)),
+    ('11.0.0', (11, 0, 0, -1)),
+])
+def test_parse_version(version_str, version_tuple):
+    assert parse_version(version_str) == version_tuple
+
+
+def test_parse_version_invalid():
+    with pytest.raises(ValueError):
+        assert parse_version('')  # we require x.y.z versions
+    with pytest.raises(ValueError):
+        assert parse_version('1')  # we require x.y.z versions
+    with pytest.raises(ValueError):
+        assert parse_version('1.2')  # we require x.y.z versions
+    with pytest.raises(ValueError):
+        assert parse_version('crap')
diff --git a/src/borg/version.py b/src/borg/version.py
--- a/src/borg/version.py
+++ b/src/borg/version.py
@@ -3,33 +3,33 @@
 
 def parse_version(version):
     """
-    simplistic parser for setuptools_scm versions
+    Simplistic parser for setuptools_scm versions.
 
-    supports final versions and alpha ('a'), beta ('b') and rc versions. It just discards commits since last tag
-    and git revision hash.
+    Supports final versions and alpha ('a'), beta ('b') and release candidate ('rc') versions.
+    It does not try to parse anything else than that, even if there is more in the version string.
 
     Output is a version tuple containing integers. It ends with one or two elements that ensure that relational
-    operators yield correct relations for alpha, beta and rc versions too. For final versions the last element
-    is a -1, for prerelease versions the last two elements are a smaller negative number and the number of e.g.
-    the beta.
-
-    Note, this sorts version 1.0 before 1.0.0.
+    operators yield correct relations for alpha, beta and rc versions, too.
+    For final versions the last element is a -1.
+    For prerelease versions the last two elements are a smaller negative number and the number of e.g. the beta.
 
     This version format is part of the remote protocol, don‘t change in breaking ways.
     """
-
-    parts = version.split('+')[0].split('.')
-    if parts[-1].startswith('dev'):
-        del parts[-1]
-    version = [int(segment) for segment in parts[:-1]]
-
-    prerelease = re.fullmatch('([0-9]+)(a|b|rc)([0-9]+)', parts[-1])
-    if prerelease:
-        version_type = {'a': -4, 'b': -3, 'rc': -2}[prerelease.group(2)]
-        version += [int(prerelease.group(1)), version_type, int(prerelease.group(3))]
+    version_re = r"""
+        (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)   # version, e.g. 1.2.33
+        (?P<prerelease>(?P<ptype>a|b|rc)(?P<pnum>\d+))?  # optional prerelease, e.g. a1 or b2 or rc33
+    """
+    m = re.match(version_re, version, re.VERBOSE)
+    if m is None:
+        raise ValueError('Invalid version string %s' % version)
+    gd = m.groupdict()
+    version = [int(gd['major']), int(gd['minor']), int(gd['patch'])]
+    if m.lastgroup == 'prerelease':
+        p_type = {'a': -4, 'b': -3, 'rc': -2}[gd['ptype']]
+        p_num = int(gd['pnum'])
+        version += [p_type, p_num]
     else:
-        version += [int(parts[-1]), -1]
-
+        version += [-1]
     return tuple(version)
 
 


Test patch: