Repository: learningequality/kolibri

Issue:
Version parsing on old setuptools platforms is broken
### Observed behavior

Builds do not run on Launchpad. This is consistent for any older Debian + Ubuntu 14.04. Example:

```
test@test-VirtualBox:~$ python 
Python 2.7.6 (default, Jun 22 2015, 17:58:13) 
[GCC 4.8.2] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> from pkg_resources import parse_version
>>> parse_version("1.2.3")
('00000001', '00000002', '00000003', '*final')
>>> parse_version("1.2.3.dev0")
('00000001', '00000002', '00000003', '*@', '*final')
>>> parse_version("1.2.3a1")
('00000001', '00000002', '00000003', '*a', '00000001', '*final')
>>> parse_version("1.2.3a0")
('00000001', '00000002', '00000003', '*a', '*final')
>>> parse_version("1.2.3b1")
('00000001', '00000002', '00000003', '*b', '00000001', '*final')
>>> parse_version("1.2.3b1+git-123")
('00000001', '00000002', '00000003', '*b', '00000001', '*+', '*git', '*final-', '00000123', '*final')
>>> 
```

### Expected behavior

Currently, we have an `AttributeError: 'tuple' object has no attribute 'base_version'` in the build when running on an older platform.

It doesn't happen on Buildkite or Ubuntu 16.04.

### User-facing consequences

None at the moment, but we won't have an automated release on Launchpad with the current implementation.

### Errors and logs

https://launchpadlibrarian.net/355336713/buildlog_ubuntu-trusty-i386.kolibri-source_0.7.1~b1.dev0+git.10.gb0f4ff4-0ubuntu1_BUILDING.txt.gz

### Steps to reproduce

See above

### Context

See above


Discussion:

Resolving patch:
diff --git a/kolibri/utils/compat.py b/kolibri/utils/compat.py
--- a/kolibri/utils/compat.py
+++ b/kolibri/utils/compat.py
@@ -5,6 +5,8 @@
 
 import sys
 
+from pkg_resources import parse_version as _parse_version
+
 
 def module_exists(module_path):
     """
@@ -29,3 +31,48 @@ def module_exists(module_path):
             return False
     else:
         raise NotImplementedError("No compatibility with Python 3.0 and 3.2")
+
+
+class VersionCompat:
+    """
+    This is an exactly-what-we-need version of the newer
+    packaging.version.Version object.
+
+    It is made to exclusively filter out properties of the later
+    ``packaging.version.Version`` that we should not access because they are
+    unsupported.
+
+    So please avoid using anything directly from ``packaging.version.Version``
+    """
+
+    def __init__(self, tpl_or_version):
+        self.tpl_or_version = tpl_or_version
+
+    @property
+    def base_version(self):
+        # if it's a real Version object...
+        if hasattr(self.tpl_or_version, 'base_version'):
+            return self.tpl_or_version.base_version
+
+        # Otherwise assume we have the old tuple with strings.
+
+        # Remove leading 0's
+        self.tpl_or_version = map(lambda s: s.lstrip("0"), self.tpl_or_version)
+        self.tpl_or_version = map(lambda s: s or "0", self.tpl_or_version)
+        # When map returns a map object in Python 3...
+        self.tpl_or_version = tuple(self.tpl_or_version)
+
+        # Always just assume the first 3 members of the tuple... because this
+        # is Kolibri's version scheme (For instance, version 1 is always
+        # 1.0.0)
+        return ".".join(self.tpl_or_version[:3])
+
+
+def parse_version(v):
+    """
+    In old versions of Python (for instance on Ubuntu 14.04),
+    pkg_resources.parse_version returns a tuple and not a version object.
+    """
+    parsed = _parse_version(v)
+
+    return VersionCompat(parsed)
diff --git a/kolibri/utils/version.py b/kolibri/utils/version.py
--- a/kolibri/utils/version.py
+++ b/kolibri/utils/version.py
@@ -110,8 +110,7 @@
 import re
 import subprocess
 
-from pkg_resources import parse_version
-
+from .compat import parse_version
 from .lru_cache import lru_cache
 
 logger = logging.getLogger(__name__)


Test patch:
diff --git a/kolibri/utils/tests/test_version.py b/kolibri/utils/tests/test_version.py
--- a/kolibri/utils/tests/test_version.py
+++ b/kolibri/utils/tests/test_version.py
@@ -152,6 +152,16 @@ def test_beta_1_consistent_version_file(self, get_git_changeset_mock, describe_m
         """
         assert get_version((0, 1, 0, "beta", 1)) == "0.1.0b2"
 
+    @mock.patch('kolibri.utils.version.get_version_file', return_value="0.7.1b1.dev+git-12-g2a8fe31")
+    @mock.patch('kolibri.utils.version.get_git_describe', return_value=None)
+    @mock.patch('kolibri.utils.version.get_git_changeset', return_value=None)
+    def test_beta_1_consistent_dev_release_version_file(self, get_git_changeset_mock, describe_mock, file_mock):
+        """
+        Test that a VERSION file can overwrite an beta-1 state in case the
+        version was bumped in ``kolibri.VERSION``.
+        """
+        assert get_version((0, 7, 1, "alpha", 0)) == "0.7.1b1.dev+git-12-g2a8fe31"
+
     @mock.patch('kolibri.utils.version.get_version_file', return_value="0.1.0b1")
     @mock.patch('kolibri.utils.version.get_git_describe', return_value="v0.0.1")
     @mock.patch('kolibri.utils.version.get_git_changeset', return_value="+git123")
@@ -161,6 +171,16 @@ def test_version_file_ignored(self, get_git_changeset_mock, describe_mock, file_
         """
         assert get_version((0, 1, 0, "alpha", 0)) == "0.1.0.dev+git123"
 
+    @mock.patch('kolibri.utils.version.get_version_file', return_value="0.1.0")
+    @mock.patch('kolibri.utils.version.get_git_describe', return_value=None)
+    @mock.patch('kolibri.utils.version.get_git_changeset', return_value=None)
+    def test_version_file_final(self, get_git_changeset_mock, describe_mock, file_mock):
+        """
+        Test that a VERSION specifying a final version will work when the
+        kolibri.VERSION tuple is consistent.
+        """
+        assert get_version((0, 1, 0, "final", 0)) == "0.1.0"
+
     def test_alpha_1_inconsistent_git(self):
         """
         Test that we fail when git returns inconsistent data
@@ -264,3 +284,55 @@ def test_final_post(self, describe_mock):
         v = get_version((0, 1, 1, "final", 1))
         self.assertEqual(v, "0.1.1.post1")
         assert describe_mock.call_count == 0
+
+    def test_version_compat(self):
+        """
+        Test that our version glue works for some really old releases of
+        setuptools, like the one in Ubuntu 14.04.
+
+        We don't have a reference implementation, but parse_version will return
+        a tuple, and this is from a live system::
+
+            test@test-VirtualBox:~$ python
+            Python 2.7.6 (default, Jun 22 2015, 17:58:13)
+            [GCC 4.8.2] on linux2
+            Type "help", "copyright", "credits" or "license" for more information.
+            >>> from pkg_resources import parse_version
+            >>> parse_version("1.2.3")
+            ('00000001', '00000002', '00000003', '*final')
+            >>> parse_version("1.2.3.dev0")
+            ('00000001', '00000002', '00000003', '*@', '*final')
+            >>> parse_version("1.2.3a1")
+            ('00000001', '00000002', '00000003', '*a', '00000001', '*final')
+            >>> parse_version("1.2.3a0")
+            ('00000001', '00000002', '00000003', '*a', '*final')
+            >>> parse_version("1.2.3b1")
+            ('00000001', '00000002', '00000003', '*b', '00000001', '*final')
+            >>> parse_version("1.2.3b1+git-123")
+            ('00000001', '00000002', '00000003', '*b', '00000001', '*+', '*git', '*final-', '00000123', '*final')
+
+        """
+        from kolibri.utils.compat import VersionCompat
+        assert VersionCompat(
+            ('00000001', '00000002', '00000003', '*final')
+        ).base_version == "1.2.3"
+
+        assert VersionCompat(
+            ('00000001', '00000002', '00000003', '*@', '*final')
+        ).base_version == "1.2.3"
+
+        assert VersionCompat(
+            ('00000001', '00000002', '00000003', '*a', '00000001', '*final')
+        ).base_version == "1.2.3"
+
+        assert VersionCompat(
+            ('00000001', '00000002', '00000003', '*b', '00000001', '*final')
+        ).base_version == "1.2.3"
+
+        assert VersionCompat(
+            ('00000001', '00000002', '00000003', '*b', '00000001', '*+', '*git', '*final-', '00000123', '*final')
+        ).base_version == "1.2.3"
+
+        assert VersionCompat(
+            ('00000000', '00000002', '00000003', '*b', '00000001', '*+', '*git', '*final-', '00000123', '*final')
+        ).base_version == "0.2.3"