Repository: learningequality/kolibri

Issue:
tag name isn't reflected in version
### Observed behavior

This was seen with a tagged (beta4) release:

![image](https://user-images.githubusercontent.com/2367265/33458897-dccf52a6-d5dc-11e7-9cfc-62f33d5a2b55.png)

### Expected behavior

release version should reflect tags

### User-facing consequences

unknown what version is being run

### Steps to reproduce

* create a tag
* look at build




Discussion:
cc @benjaoming looks like another versioning regression
@indirectlylit this was a well-known behavior of `git describe` all along - it's actually the original issue from when everything was based off tags and `git describe`

The problem is that the distributed version still says `(0, 7, 0, 'alpha', 0)`:

https://github.com/learningequality/kolibri/blob/v0.7.0-beta4/kolibri/__init__.py

While I think @rtibbles's PR would fix some issues we might have seen with `git describe`, I'm not sure if the PR is correct.

I think you should edit the code to say `(0, 7, 0, 'beta', 5)` and make a new release.
Since the version has to be written to a `VERSION` file then, maybe the build in question didn't do that? @indirectlylit any chance this is the issue?

I think @rtibbles is fixing that a normal git checkout (with the pre-release tag) might not show a correct version, which is the old `git describe` bug.
agreed with @rtibbles here: https://github.com/learningequality/kolibri/pull/2794#discussion_r154397927

pre-releases should be tag-based
not necessary to fix for this release since the 0.7 'final' should work as expected ... right?
Yep
> not necessary to fix for this release since the 0.7 'final' should work as expected ... right?

Agreed, yet we will probably have pre-releases in the 0.7.1 face, so should keep the target branch nonetheless?
Oh, true, fair enough. I will retarget back after 0.7.0 is released.

Resolving patch:
diff --git a/kolibri/utils/version.py b/kolibri/utils/version.py
--- a/kolibri/utils/version.py
+++ b/kolibri/utils/version.py
@@ -110,10 +110,14 @@
 import re
 import subprocess
 
+from pkg_resources import parse_version
+
 from .lru_cache import lru_cache
 
 logger = logging.getLogger(__name__)
 
+ORDERED_VERSIONS = ('alpha', 'beta', 'rc', 'final')
+
 
 def get_major_version(version=None):
     """
@@ -133,7 +137,7 @@ def get_complete_version(version=None):
         from kolibri import VERSION as version
     else:
         assert len(version) == 5
-        assert version[3] in ('alpha', 'beta', 'rc', 'final')
+        assert version[3] in ORDERED_VERSIONS
 
     return version
 
@@ -157,8 +161,7 @@ def get_git_changeset():
     This value isn't guaranteed to be unique, but collisions are very unlikely,
     so it's sufficient for generating the development version numbers.
 
-    If there is no git data or git installed, it will gracefully return a
-    timestamp based on datetime.now()
+    If there is no git data or git installed, it will return None
     """
     repo_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
     try:
@@ -176,20 +179,15 @@ def get_git_changeset():
         timestamp = datetime.datetime.utcfromtimestamp(int(timestamp))
         return "{}-git".format(timestamp.strftime('%Y%m%d%H%M%S'))
     except (EnvironmentError, ValueError):
-        try:
-            # Check to see if we have a version file, if so, get the version from there.
-            # Do this by returning None, and then the get_prerelease_version code will read the VERSION file.
-            get_version_file()
-        except IOError:
-            return "{}-export".format(
-                datetime.datetime.now().strftime('%Y%m%d%H%M%S')
-            )
+        return None
 
 
 def get_git_describe():
     """
+    Detects a valid tag, 1.2.3-<alpha|beta|rc>(-123-sha123)
     :returns: None if no git tag available (no git, no tags, or not in a repo)
     """
+    valid_pattern = re.compile(r"^v[0-9\\-\\.]+(-(alpha|beta|rc)[0-9]+)?(-\d+-\w+)?$")
     repo_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
     try:
         p = subprocess.Popen(
@@ -203,7 +201,7 @@ def get_git_describe():
         # This does not fail if git is not available or current dir isn't a git
         # repo - it's safe.
         version_string = p.communicate()[0].rstrip()
-        return version_string
+        return version_string if valid_pattern.match(version_string) else None
     except EnvironmentError:
         return None
 
@@ -247,29 +245,6 @@ def get_version_from_git(get_git_describe_string):
     )), suffix
 
 
-def assert_git_version(version, git_version):
-    """
-    This is the policy that expresses what git version is allowed together with
-    the current VERSION tuple
-
-    :version: current VERSION tuple
-    :git_version: the git version derived from git describe
-    """
-    if not (
-        version[0] == git_version[0] and
-        version[1] == git_version[1] and
-        version[2] == git_version[2] and
-        (
-            version[3] > git_version[3] or
-            version[3] == git_version[3] and
-            version[4] >= git_version[4]
-        )
-    ):
-        raise AssertionError(
-            "Inconsistent git tagging: {} <= {}".format(version, git_version)
-        )
-
-
 def get_version_file():
     """
     Looks for a file VERSION in the package data and returns the contents in
@@ -287,67 +262,105 @@ def get_prerelease_version(version):
     \*, \*, \*, "alpha", >0: Uses latest git tag, asserting that there is such.
     """
 
-    major = get_major_version(version)
-
     mapping = {'alpha': 'a', 'beta': 'b', 'rc': 'rc'}
-    suffix = ''
-
-    # Support item assignment instead of tuple
-    version = list(version)
-
-    tag_describe = get_git_describe()
-
-    # If it's the 0th alpha, load suffix info from git changeset
     if version[4] == 0 and version[3] == 'alpha':
         mapping['alpha'] = '.dev'
-        suffix = get_git_changeset()
 
+    major = get_major_version(version)
     major_and_release = major + mapping[version[3]] + str(version[4])
 
     # Calculate suffix...
+    tag_describe = get_git_describe()
 
-    # If a description from git is available and we haven't already
-    # found a suffix
-    if not suffix and tag_describe:
-        git_version, git_suffix = get_version_from_git(tag_describe)
+    # If the detected git describe data is not valid, then either respect
+    # that we are in alpha-0 mode or raise an error
+    if tag_describe:
 
-        # Fail in case the VERSION tuple and version derived from Git are
-        # not tolerable.
-        assert_git_version(version, git_version)
+        git_version, suffix = get_version_from_git(tag_describe)
 
-        # We allow a git version that specifies the same
-        # (major, minor, patch, release) - for instance 1.2.3a2 - as
-        # the current tag to be filled in with a suffix from git.
-        if version[3] == git_version[3] and version[4] == git_version[4]:
-            suffix = git_suffix
+        if not git_version[:3] == version[:3]:
+            # If it's the 0th alpha, load suffix info from git changeset
+            if version[4] == 0 and version[3] == 'alpha':
+                # Throw away the description from git
+                suffix = get_git_changeset()
+                # Replace 'alpha' with .dev
+                return major + ".dev0" + suffix
 
-    # If no git info, *fallback* to VERSION file info
-    elif not suffix:
-        # No git data, will look for a VERSION file
-        version_file = get_version_file()
+            # If the tag was not of a final version, we will fail.
+            elif not git_version[4] == 'final' and git_version[:3] > version[:3]:
+                raise AssertionError(
+                    (
+                        "Version detected from git describe --tags, but it's "
+                        "inconsistent with kolibri.__version__."
+                        "__version__ is: {}, tag says: {}."
+                    ).format(
+                        str(version),
+                        git_version,
+                    )
+                )
 
-        # Check that the version file is consistent
-        if version_file:
+        if git_version[3] == 'final' and version[3] != 'final':
+            raise AssertionError(
+                "You have added a final tag without bumping kolibri.VERISON"
+            )
 
-            # Because \n may have been appended
-            version_file = version_file.strip()
+        return (
+            get_major_version(git_version) +
+            mapping[git_version[3]] +
+            str(git_version[4]) +
+            suffix
+        )
 
-            # If there is a '.dev', we can remove it, otherwise we check it
-            # for consistency and fail if inconsistent
-            before_dev = version_file.split(".dev")[0]
+    # No git data, will look for a VERSION file
+    version_file = get_version_file()
+
+    # Check that the version file is consistent
+    if version_file:
+
+        # Because \n may have been appended
+        version_file = version_file.strip()
+
+        # If there is a '.dev', we can remove it, otherwise we check it
+        # for consistency and fail if inconsistent
+        version_file_base = parse_version(version_file).base_version
+
+        # If a final release is specified in the VERSION file, then it
+        # has to be a final release in the VERSION tuple as well.
+        # A final release specified in a VERSION file (pep 440) is
+        # something that doesn't end like a1, b1, post1, and rc1
+        pep440_is_final = re.compile(r"^\d+(\.\d)+(\.post\d+)?$")
+        version_file_is_final = pep440_is_final.match(version_file)
+
+        if version_file_is_final and version_file != major_and_release:
+            raise AssertionError(
+                (
+                    "kolibri/VERSION file specified as final release but "
+                    "kolibri.__version__. is not a final release."
+                    "__version__ is: {}, file says: {}."
+                ).format(
+                    str(version),
+                    version_file,
+                )
+            )
 
-            if not major_and_release.startswith(before_dev):
-                raise AssertionError(
-                    "VERSION file inconsistent with kolibri.__version__. "
-                    "__version__ is: {}, File says: {}".format(
-                        str(version),
-                        version_file
-                    )
+        if not major_and_release.startswith(version_file_base):
+            raise AssertionError(
+                (
+                    "kolibri/VERSION file inconsistent with "
+                    "kolibri.__version__.\n"
+                    "__version__ is: {}, file says: {}\n\n{} should start "
+                    "with {}"
+                ).format(
+                    str(version),
+                    version_file,
+                    major_and_release,
+                    version_file_base
                 )
-            else:
-                return version_file
+            )
+        return version_file
 
-    return major_and_release + suffix
+    # In all circumstances, return the initial findings
+    return major_and_release
 
 
 @lru_cache()


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
@@ -4,9 +4,9 @@
 from __future__ import absolute_import, print_function, unicode_literals
 
 import unittest
-from functools import wraps
 
 import kolibri
+import mock
 from kolibri.utils import version
 
 #: Because we don't want to call the original (decorated function), it uses
@@ -19,23 +19,6 @@ def dont_call_me_maybe(msg):
     raise AssertionError(msg)
 
 
-def mock_get_git_describe(func):
-
-    @wraps(func)
-    def wrapper(*args, **kwargs):
-
-        # Simple mocking
-        git_describe = version.get_git_describe
-        version.get_git_describe = lambda *x: None
-        try:
-            ret_val = func(*args, **kwargs)
-        finally:
-            version.get_git_describe = git_describe
-        return ret_val
-
-    return wrapper
-
-
 class TestKolibriVersion(unittest.TestCase):
 
     def test_version(self):
@@ -45,8 +28,9 @@ def test_version(self):
         major_version_tuple = "{}.{}".format(*kolibri.VERSION[0:2])
         self.assertIn(major_version_tuple, kolibri.__version__)
 
-    @mock_get_git_describe
-    def test_alpha_0_version(self):
+    @mock.patch('kolibri.utils.version.get_git_describe', return_value=None)
+    @mock.patch('kolibri.utils.version.get_version_file', return_value=None)
+    def test_alpha_0_version(self, file_mock, describe_mock):
         """
         Test that when doing something with a 0th alpha doesn't provoke any
         hickups with ``git describe --tag``.
@@ -54,23 +38,19 @@ def test_alpha_0_version(self):
         v = get_version((0, 1, 0, "alpha", 0))
         self.assertIn("0.1.0.dev", v)
 
-    def test_alpha_1_version(self):
+    @mock.patch('kolibri.utils.version.get_git_describe', return_value=None)
+    @mock.patch('kolibri.utils.version.get_version_file', return_value=None)
+    def test_alpha_1_version(self, file_mock, describe_mock):
         """
         Test some normal alpha version, but don't assert that the
         ``git describe --tag`` is consistent (it will change in future test
         runs)
         """
-        # Simple mocking
-        assert_version = version.assert_git_version
-        version.assert_git_version = lambda *x: True
-        try:
-            v = get_version((0, 1, 0, "alpha", 1))
-            self.assertIn("0.1.0a1", v)
-        finally:
-            version.assert_git_version = assert_version
+        v = get_version((0, 1, 0, "alpha", 1))
+        self.assertIn("0.1.0a1", v)
 
-    @mock_get_git_describe
-    def test_alpha_1_version_no_git(self):
+    @mock.patch('kolibri.utils.version.get_git_describe', return_value=None)
+    def test_alpha_1_version_no_git(self, describe_mock):
         """
         Not running from git and no VERSION file.
         """
@@ -83,52 +63,94 @@ def test_alpha_1_version_no_git(self):
         finally:
             version.get_version_file = get_version_file
 
-    @mock_get_git_describe
-    def test_alpha_1_version_file(self):
+    @mock.patch('kolibri.utils.version.get_version_file', return_value="0.1.0a1")
+    @mock.patch('kolibri.utils.version.get_git_describe', return_value=None)
+    def test_alpha_1_version_file(self, describe_mock, file_mock):
         """
         Test that a simple 0.1a1 works when loaded from a VERSION file
         """
-        # Simple mocking
-        get_version_file = version.get_version_file
-        version.get_version_file = lambda: "0.1.0a1"
-        try:
-            v = get_version((0, 1, 0, "alpha", 1))
-            self.assertIn("0.1.0a1", v)
-        finally:
-            version.get_version_file = get_version_file
+        v = get_version((0, 1, 0, "alpha", 1))
+        self.assertIn("0.1.0a1", v)
 
-    @mock_get_git_describe
-    def test_version_file_linebreaks(self):
+    @mock.patch('kolibri.utils.version.get_version_file', return_value="0.1.0a1\n")
+    @mock.patch('kolibri.utils.version.get_git_describe', return_value=None)
+    def test_version_file_linebreaks(self, describe_mock, file_mock):
         """
         Test that line breaks don't get included in the final version
 
         See: https://github.com/learningequality/kolibri/issues/2464
         """
+        v = get_version((0, 1, 0, "alpha", 1))
+        self.assertIn("0.1.0a1", v)
+
+    @mock.patch('kolibri.utils.version.get_git_describe', return_value=None)
+    @mock.patch('kolibri.utils.version.get_git_changeset', return_value=None)
+    def test_alpha_0_inconsistent_version_file(self, get_git_changeset_mock, describe_mock):
+        """
+        Test that inconsistent file data also just fails
+        """
         # Simple mocking
         get_version_file = version.get_version_file
-        version.get_version_file = lambda: "0.1.0a1\n"
-        try:
-            v = get_version((0, 1, 0, "alpha", 1))
-            self.assertIn("0.1.0a1", v)
-        finally:
-            version.get_version_file = get_version_file
+        inconsistent_versions = ("0.2.0a1", "0.1.1a1", "0.1.0")
+        for v in inconsistent_versions:
+            version.get_version_file = lambda: v
+            try:
+                self.assertRaises(
+                    AssertionError,
+                    get_version,
+                    (0, 1, 0, "alpha", 0)
+                )
+            finally:
+                version.get_version_file = get_version_file
 
-    @mock_get_git_describe
-    def test_alpha_1_inconsistent_version_file(self):
+    @mock.patch('kolibri.utils.version.get_git_describe', return_value=None)
+    @mock.patch('kolibri.utils.version.get_git_changeset', return_value=None)
+    def test_alpha_1_inconsistent_version_file(self, get_git_changeset_mock, describe_mock):
         """
         Test that inconsistent file data also just fails
         """
         # Simple mocking
         get_version_file = version.get_version_file
-        version.get_version_file = lambda: "0.2.0a1"
-        try:
-            self.assertRaises(
-                AssertionError,
-                get_version,
-                (0, 1, 0, "alpha", 1)
-            )
-        finally:
-            version.get_version_file = get_version_file
+        inconsistent_versions = ("0.2.0a1", "0.1.1a1", "0.1.0")
+        for v in inconsistent_versions:
+            version.get_version_file = lambda: v
+            try:
+                self.assertRaises(
+                    AssertionError,
+                    get_version,
+                    (0, 1, 0, "alpha", 1)
+                )
+            finally:
+                version.get_version_file = get_version_file
+
+    @mock.patch('kolibri.utils.version.get_version_file', return_value="0.1.0b1")
+    @mock.patch('kolibri.utils.version.get_git_describe', return_value=None)
+    @mock.patch('kolibri.utils.version.get_git_changeset', return_value=None)
+    def test_alpha_0_consistent_version_file(self, get_git_changeset_mock, describe_mock, file_mock):
+        """
+        Test that a VERSION file can overwrite an alpha-0 (dev) state.
+        Because a prerelease can be made with a version file.
+        """
+        assert get_version((0, 1, 0, "alpha", 0)) == "0.1.0b1"
+
+    @mock.patch('kolibri.utils.version.get_version_file', return_value="0.1.0b2")
+    @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_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, 1, 0, "beta", 1)) == "0.1.0b2"
+
+    @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")
+    def test_version_file_ignored(self, get_git_changeset_mock, describe_mock, file_mock):
+        """
+        Test that the VERSION file is NOT used where git data is available
+        """
+        assert get_version((0, 1, 0, "alpha", 0)) == "0.1.0.dev0-git123"
 
     def test_alpha_1_inconsistent_git(self):
         """
@@ -137,57 +159,99 @@ def test_alpha_1_inconsistent_git(self):
         # Simple mocking
         git_describe = version.get_git_describe
         try:
-            version.get_git_describe = lambda *x: 'v0.2.0-beta1-29-gc61d5f4'
+            version.get_git_describe = lambda *x: 'v0.2.0-beta1'
             self.assertRaises(
                 AssertionError,
                 get_version,
                 (0, 1, 0, "alpha", 1)
             )
-            version.get_git_describe = lambda *x: 'v0.2.0-beta2-29-gc61d5f4'
+            version.get_git_describe = lambda *x: 'v0.2.0-beta2'
             self.assertRaises(
                 AssertionError,
                 get_version,
                 (0, 1, 0, "beta", 0)
             )
-            version.get_git_describe = lambda *x: 'v0.1.0-beta2-29-gc61d5f4'
+            version.get_git_describe = lambda *x: 'v0.1.0'
             self.assertRaises(
                 AssertionError,
                 get_version,
-                (0, 1, 0, "beta", 1)
+                (0, 1, 0, "alpha", 0)
             )
         finally:
             version.get_git_describe = git_describe
 
-    @mock_get_git_describe
-    def test_final(self):
+    @mock.patch('kolibri.utils.version.get_git_describe', return_value="v0.1.0-beta1-123-abcdfe12")
+    def test_alpha_1_consistent_git(self, describe_mock):
+        """
+        Test that we get the git describe data when it's there
+        """
+        assert get_version((0, 1, 0, "alpha", 1)) == "0.1.0b1.dev-123-abcdfe12"
+
+    @mock.patch('subprocess.Popen')
+    def test_git_describe_parser(self, popen_mock):
+        """
+        Test that we get the git describe data when it's there
+        """
+        process_mock = mock.Mock()
+        attrs = {'communicate.return_value': ('v0.1.0-beta1-123-abcdfe12', '')}
+        process_mock.configure_mock(**attrs)
+        popen_mock.return_value = process_mock
+        assert get_version((0, 1, 0, "alpha", 1)) == "0.1.0b1.dev-123-abcdfe12"
+
+    @mock.patch('subprocess.Popen')
+    @mock.patch('kolibri.utils.version.get_version_file', return_value=None)
+    def test_git_random_tag(self, file_mock, popen_mock):
+        """
+        Test that we don't fail if some random tag appears
+        """
+        process_mock = mock.Mock()
+        attrs = {'communicate.return_value': ('foobar', '')}
+        process_mock.configure_mock(**attrs)
+        popen_mock.return_value = process_mock
+        assert get_version((0, 1, 0, "alpha", 1)) == "0.1.0a1"
+
+    @mock.patch('subprocess.Popen', side_effect=EnvironmentError())
+    @mock.patch('kolibri.utils.version.get_version_file', return_value="0.1.0a2")
+    def test_prerelease_no_git(self, file_mock, popen_mock):
+        """
+        Test that we don't fail and that the version file is used
+        """
+        assert get_version((0, 1, 0, "alpha", 1)) == "0.1.0a2"
+
+    @mock.patch('kolibri.utils.version.get_complete_version', side_effect=lambda x: x if x else (0, 2, 0, 'alpha', 2))
+    @mock.patch('kolibri.utils.version.get_git_describe', return_value="v0.2.0-beta1")
+    def test_beta_1_git(self, describe_mock, complete_mock):
+        """
+        Test that we use git tag data when our version is alpha
+        """
+        self.assertEqual(
+            get_version(),
+            '0.2.0b1'
+        )
+
+    @mock.patch('kolibri.utils.version.get_git_describe', return_value=None)
+    def test_final(self, describe_mock):
         """
         Test that the major version is set as expected on a final release
         """
         v = get_version((0, 1, 0, "final", 0))
         self.assertEqual(v, "0.1.0")
+        assert describe_mock.call_count == 0
 
-    def test_final_patch(self):
+    @mock.patch('kolibri.utils.version.get_git_describe')
+    def test_final_patch(self, describe_mock):
         """
         Test that the major version is set as expected on a final release
         """
-        # Simple mocking
-        git_describe = version.get_git_describe
-        version.get_git_describe = lambda *x: dont_call_me_maybe("get_git_describe called")
-        try:
-            v = get_version((0, 1, 1, "final", 0))
-            self.assertEqual(v, "0.1.1")
-        finally:
-            version.get_git_describe = git_describe
+        v = get_version((0, 1, 1, "final", 0))
+        self.assertEqual(v, "0.1.1")
+        assert describe_mock.call_count == 0
 
-    def test_final_post(self):
+    @mock.patch('kolibri.utils.version.get_git_describe')
+    def test_final_post(self, describe_mock):
         """
         Test that the major version is set as expected on a final release
         """
-        # Simple mocking
-        git_describe = version.get_git_describe
-        version.get_git_describe = lambda *x: dont_call_me_maybe("get_git_describe called")
-        try:
-            v = get_version((0, 1, 1, "final", 1))
-            self.assertEqual(v, "0.1.1.post1")
-        finally:
-            version.get_git_describe = git_describe
+        v = get_version((0, 1, 1, "final", 1))
+        self.assertEqual(v, "0.1.1.post1")
+        assert describe_mock.call_count == 0