Repository: learningequality/kolibri

Issue:
make __init__.py's version a 3-tuple
### Observed behavior

Currently, `__init__.py` looks like this:

```python
#: This may not be the exact version as it's subject to modification with
#: get_version() - use ``kolibri.__version__`` for the exact version string.
VERSION = (0, 8, 0, 'alpha', 0)
```

The fact that the fourth value (`'alpha'`/`'beta'`/`'final'`) needs to be updated makes creating releases and managing release branches very frustrating:

* we need to do a whole PR+review+merge to set it to `'final'`, create a release tag, then do _another_ PR+review+merge to set it back to either `'alpha'` or `'beta'`
* this creates repeated merge conflicts when syncing old releases back to `develop`, or even worse across multiple release branches
* there doesn't seem to be any difference between `'beta'` and `'alpha'`
* the fifth value (`0`) is completely ignored
* need to create an `alpha0` tag on `develop` after cutting a new beta or we get the confusing error `You have added a final tag without bumping kolibri.VERISON`

### Expected behavior

Since the alpha/beta/final status is reflected in git tags and not in source code, we shouldn't need to update these values in source. Instead, we can simply do:

```python
#: This string does not reflect alpha/beta/final status and is modified by get_version()
#: Use ``kolibri.__version__`` for the exact version string.
VERSION = (0, 8, 0)
```

Then the final/beta/alpha status can be driven by git tags and the process for releasing will be greatly simplified

Final releases should be embedded in the code. Everything else can come from the git tag. Anything interim can be versioned with a git hash, because finding parents is unreliable.

If a tag is made (alpha, beta, rc, or final) that does not match the 3-tuple in `__init__.py`, then the code should throw an error, to prevent tags being made on the wrong branch or code.

### User-facing consequences

No user-facing consequences, but this would improve developer experience for creating new releases and managing release branches.

### Steps to reproduce

Go through the process of creating a patch release and then syncing those changes back to `develop`




Discussion:
rtibbles: Looking at the built asset in buildkite (https://buildkite.com/learningequality/kolibri-python-package/builds/6409#0183adc3-aa6b-4b79-a9d7-59b2ffdc1951) it seems that we're not giving a very informative version in this case: `0.15.8.dev0` - it's not wrong, but would be good to retain some of the previous behaviour.

I'm not entirely sure where the change would need to be applied though. I'll try to write a test case to cover what is happening here so we can fix it.

Patch:
diff --git a/kolibri/__init__.py b/kolibri/__init__.py
index 0055c6f923..60e3ff9eda 100755
--- a/kolibri/__init__.py
+++ b/kolibri/__init__.py
@@ -15,13 +15,12 @@ env.set_env()
 
 #: This may not be the exact version as it's subject to modification with
 #: get_version() - use ``kolibri.__version__`` for the exact version string.
-VERSION = (0, 15, 8, "final", 0)
+VERSION = (0, 15, 8)
 
 __author__ = "Learning Equality"
 __email__ = "info@learningequality.org"
 __version__ = str(get_version(VERSION))
 
-
 #: A list of all available plugins defined within the Kolibri repo
 #: Define it here to avoid introspection malarkey, and to allow for
 #: import in setup.py for creating a list of plugin entry points.
diff --git a/kolibri/utils/version.py b/kolibri/utils/version.py
index 1872ba0bc5..f95f07ccd6 100644
--- a/kolibri/utils/version.py
+++ b/kolibri/utils/version.py
@@ -13,73 +13,64 @@ Here's how version numbers are generated:
    to decide the version of Kolibri as a string. This is especially something
    that PyPi and setuptools use.
 
- * ``kolibri.VERSION`` is a tuple containing version information, it's set in
-   ``kolibri/__init__.py`` is automatically suffixed in pre-releases by a
-   number of rules defined below. For a final release (not a pre-release),
-   it will be used exactly as it appears.
+ * ``kolibri.VERSION`` is a tuple containing major, minor, and patch version information,
+   it's set in ``kolibri/__init__.py``
 
  * ``kolibri/VERSION`` is a file containing the exact version of Kolibri for a
-   distributed environment (pre-releases only!)
+   distributed environment - when it exists, as long as its major, minor, and patch
+   versions are compatible with ``kolibri.VERSION`` then it is used as the version.
+   If these versions do not match, an AssertionError will be thrown.
 
  * ``git describe --tags`` is a command run to fetch tag information from a git
    checkout with the Kolibri code. The information is used to validate the
-   major components of ``kolibri.VERSION`` and to suffix the final version of
-   prereleases. This information is stored permanently in ``kolibri/VERSION``
-   before shipping a pre-release by calling ``make writeversion`` during
-   ``make dist`` etc.
-
-
-Confused? Here's a table:
-
-
-+--------------+---------------------+---------------------+---------------------------+-------------------------------------+
-| Release type | ``kolibri.VERSION`` | ``kolibri/VERSION`` | Git data                  | Examples                            |
-+==============+=====================+=====================+===========================+=====================================+
-| Final        | Canonical, only     | N/A                 | N/A                       | 0.1.0, 0.2.2,                       |
-|              | information used    |                     |                           | 0.2.post1                           |
-+--------------+---------------------+---------------------+---------------------------+-------------------------------------+
-| dev release  | (1, 2, 3, 'alpha',  | Fallback            | timestamp of latest       | 1.2.3.dev0+git.123.f1234567         |
-| (alpha0)     | 0), 0th alpha = a   |                     | commit + hash             |                                     |
-|              | dev release! Never  |                     |                           |                                     |
-|              | used as a canonical |                     |                           |                                     |
-|              |                     |                     |                           |                                     |
-+--------------+---------------------+---------------------+---------------------------+-------------------------------------+
-| alpha1+      | (1, 2, 3, 'alpha',  | Fallback            | ``git describe --tags``   | Clean head:                         |
-|              | 1)                  |                     |                           | 1.2.3a1,                            |
-|              |                     |                     |                           | 4 changes                           |
-|              |                     |                     |                           | since tag:                          |
-|              |                     |                     |                           | 1.2.3a1.dev0+git.4.f1234567         |
-+--------------+---------------------+---------------------+---------------------------+-------------------------------------+
-| beta1+       | (1, 2, 3, 'alpha',  | Fallback            | ``git describe --tags``   | Clean head:                         |
-|              | 1)                  |                     |                           | 1.2.3b1,                            |
-|              |                     |                     |                           | 5 changes                           |
-|              |                     |                     |                           | since tag:                          |
-|              |                     |                     |                           | 1.2.3b1.dev0+git.5.f1234567         |
-+--------------+---------------------+---------------------+---------------------------+-------------------------------------+
-| rc1+         | (1, 2, 3, 'alpha',  | Fallback            | ``git describe --tags``   | Clean head:                         |
-| (release     | 1)                  |                     |                           | 1.2.3rc1,                           |
-| candidate)   |                     |                     |                           | Changes                             |
-|              |                     |                     |                           | since tag:                          |
-|              |                     |                     |                           | 1.2.3rc1.dev0+git.f1234567          |
-+--------------+---------------------+---------------------+---------------------------+-------------------------------------+
-| beta0, rc0,  | DO NOT USE          | Fallback            | timestamp of latest       | 1.2.3b0.dev0+git.123.f1234567       |
-| post0, x.y.0 |                     |                     | commit + hash             |                                     |
-|              |                     |                     |                           |                                     |
-+--------------+---------------------+---------------------+---------------------------+-------------------------------------+
-
-
-**Fallback**: ``kolibri/VERSION`` is auto-generated with ``make writeversion``
-during the build process. The file is read as a fallback when there's no git
-data available in a pre-release (which is the case in an installed
-environment).
+   major components of ``kolibri.VERSION`` and to add a suffix (if needed).
+   This information is stored permanently in ``kolibri/VERSION`` before shipping
+   any built asset by calling ``make writeversion`` during ``make dist`` etc.
+
+
+This table shows examples of kolibri.VERSION and git data used to generate a specific version:
+
+
++--------------+---------------------+---------------------------+-------------------------------------+
+| Release type | ``kolibri.VERSION`` | Git data                  | Examples                            |
++==============+=====================+===========================+=====================================+
+| Final        | (1, 2, 3)           | Final tag: e.g. v1.2.3    | 1.2.3                               |
++--------------+---------------------+---------------------------+-------------------------------------+
+| dev release  | (1, 2, 3)           | timestamp of latest       | 1.2.3.dev0+git.123.f1234567         |
+| (alpha0)     |                     | commit + hash             |                                     |
++--------------+---------------------+---------------------------+-------------------------------------+
+| alpha1+      | (1, 2, 3)           | Alpha tag: e.g. v1.2.3a1  | Clean head:                         |
+|              |                     |                           | 1.2.3a1,                            |
+|              |                     |                           | 4 changes                           |
+|              |                     |                           | since tag:                          |
+|              |                     |                           | 1.2.3a1.dev0+git.4.f1234567         |
++--------------+---------------------+---------------------------+-------------------------------------+
+| beta1+       | (1, 2, 3)           | Beta tag: e.g. v1.2.3b1   | Clean head:                         |
+|              |                     |                           | 1.2.3b1,                            |
+|              |                     |                           | 5 changes                           |
+|              |                     |                           | since tag:                          |
+|              |                     |                           | 1.2.3b1.dev0+git.5.f1234567         |
++--------------+---------------------+---------------------------+-------------------------------------+
+| rc1+         | (1, 2, 3)           | RC tag: e.g. v1.2.3rc1    | Clean head:                         |
+| (release     |                     |                           | 1.2.3rc1,                           |
+| candidate)   |                     |                           | Changes                             |
+|              |                     |                           | since tag:                          |
+|              |                     |                           | 1.2.3rc1.dev0+git.f1234567          |
++--------------+---------------------+---------------------------+-------------------------------------+
+
+
+**Built assets**: ``kolibri/VERSION`` is auto-generated with ``make writeversion``
+during the build process. The file is read in preference to git
+data in order to prioritize swift version resolution in an installed
+environment.
 
 
 Release order example 1.2.3 release:
 
- * ``VERSION = (1, 2, 3, 'alpha', 0)`` throughout the development phase, this
+ * ``VERSION = (1, 2, 3)`` throughout the development phase, this
    results in a lot of ``1.2.3.dev0+git1234abcd`` with no need for
    git tags.
- * ``VERSION = (1, 2, 3, 'alpha', 1)`` for the first alpha release.
+ * ``VERSION = (1, 2, 3)`` for the first alpha release, a git tag v1.2.3a0 is made.
 
 .. warning::
     Do not import anything from the rest of Kolibri in this module, it's
@@ -117,41 +108,15 @@ PRERELEASE_VERSION = "prerelease"
 BUILD_VERSION = "build"
 
 
-def get_major_version(version=None):
+def get_major_version(version):
     """
     :returns: String w/ first digit part of version tuple x.y.z
     """
-    version = get_complete_version(version)
+
     major = ".".join(str(x) for x in version[:3])
     return major
 
 
-def get_complete_version(version=None):
-    """
-    :returns: A tuple of the version. If version argument is non-empty, then
-              checks for correctness of the tuple provided.
-    """
-    if version is None:
-        from kolibri import VERSION as version
-    else:
-        if len(version) != 5:
-            raise AssertionError
-        if version[3] not in ORDERED_VERSIONS:
-            raise AssertionError
-
-    return version
-
-
-def get_docs_version(version=None):
-    """
-    :returns: Version string for use in Sphinx docs
-    """
-    version = get_complete_version(version)
-    if version[3] != "final":
-        return "dev"
-    return "%d.%d" % version[:2]
-
-
 def get_git_changeset():
     """
     Returns a numeric identifier of the latest git changeset.
@@ -262,14 +227,12 @@ def get_version_from_git(get_git_describe_string):
     suffix = ".dev0+git" + suffix if suffix else ""
 
     return (
-        get_complete_version(
-            (
-                int(major),
-                int(minor),
-                int(patch),
-                m.group("release") or "final",
-                int(m.group("release_number") or 0),
-            )
+        (
+            int(major),
+            int(minor),
+            int(patch),
+            m.group("release") or "final",
+            int(m.group("release_number") or 0),
         ),
         suffix,
     )
@@ -294,31 +257,22 @@ def get_prerelease_version(version):
     \\*, \\*, \\*, "alpha", 0: Maps to latest commit timestamp
     \\*, \\*, \\*, "alpha", >0: Uses latest git tag, asserting that there is such.
     """
-
     mapping = {"alpha": "a", "beta": "b", "rc": "rc"}
-
     major = get_major_version(version)
-    major_and_release = major + mapping[version[3]] + str(version[4])
 
     # Calculate suffix...
     tag_describe = get_git_describe(version)
 
     # 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:
 
         git_version, suffix = get_version_from_git(tag_describe)
-
-        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 the tag was not of a final version, we will fail.
-            elif not git_version[4] == "final" and git_version[:3] > version[:3]:
+        # We will check if the git_tag and version strings are the same length,
+        # and compare it the first three characters of each string to see if they matches.
+        # if not, we then raise an AssertionError.
+        if not suffix:
+            if not git_version[:3] == version[:3]:
                 raise AssertionError(
                     (
                         "Version detected from git describe --tags, but it's "
@@ -326,108 +280,80 @@ def get_prerelease_version(version):
                         "__version__ is: {}, tag says: {}."
                     ).format(str(version), git_version)
                 )
-
-        if git_version[3] == "final" and version[3] != "final":
+        # checks if the version number in git_version is greater than the version number in version.
+        # If it is, the code raises an AssertionError
+        if git_version[:3] > version[:3]:
             raise AssertionError(
-                "You have added a final tag without bumping kolibri.VERSION, "
-                + "OR you need to make a new alpha0 tag. Current tag: {}".format(
-                    git_version
-                )
-                + "\n\n"
-                "Often, this is because of missing tag information, try "
-                "running:\n"
-                "\n"
-                "   git fetch <upstream>"
+                (
+                    "Version detected from git describe --tags, but it's "
+                    "inconsistent with kolibri.__version__."
+                    "__version__ is: {}, tag says: {}."
+                ).format(str(version), git_version)
+            )
+        # checks if the tag in git_version is the same to the final version number in version.
+        # If it is, we return the major version number.
+        # And If the tag was of a final version, we will use it.
+
+        if git_version[:3] == version[:3]:
+            if git_version[3] == "final":
+                if not suffix:
+                    return major
+                else:
+                    # If there's a suffix, we're post the final tag for the release
+                    # so set it to an alpha to give a more meaningful version number
+                    # although it is slighly incorrect as we have already released.
+                    git_version = (
+                        git_version[0],
+                        git_version[1],
+                        git_version[2],
+                        "alpha",
+                        git_version[4],
+                    )
+
+            return (
+                get_major_version(git_version)
+                + mapping[git_version[3]]
+                + str(git_version[4])
+                + suffix
             )
 
-        return (
-            get_major_version(git_version)
-            + mapping[git_version[3]]
-            + str(git_version[4])
-            + suffix
-        )
+    return major + ".dev0" + (get_git_changeset() or "")
+
 
+def get_version_from_file(version):
     # 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:
+        version_major_minor_patch = parse_version(version_file).base_version
+        split_version = version_major_minor_patch.split(".")
+        major = int(split_version[0])
+        minor = int(split_version[1])
+        patch = int(split_version[2])
+        # If the major, minor, and patch of the parsed version number
+        # We will raise an error
+        if (major, minor, patch) != version[:3]:
             raise AssertionError(
                 (
-                    "kolibri/VERSION file specified as final release but "
-                    "kolibri.__version__. is not a final release."
-                    "__version__ is: {}, file says: {}."
+                    "Version detected from VERSION file, but it's "
+                    "inconsistent with kolibri.__version__."
+                    "__version__ is: {}, VERSION 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
-                )
-            )
         return version_file
 
-    # Finally, if there was no git data or VERSION file, map the alpha-0 to
-    # .dev
-    if version[4] == 0 and version[3] == "alpha":
-        mapping["alpha"] = ".dev"
-        major_and_release = major + mapping[version[3]] + str(version[4])
-
-    # In all circumstances, return the initial findings
-    return major_and_release
-
 
 @lru_cache()
-def get_version(version=None):
-    """
-    Returns a PEP 440-compliant version number from VERSION.
-    Derives additional information from git repository if code is contained
-    in such.
-
-    This is important to read from PEP-404 (which this function is compliant
-    with):
+def get_version(version):
+    version_str = get_version_from_file(version)
+    if version_str:
+        return version_str
 
-    Within a numeric release ( 1.0.0 , 2.7.3 ), the following suffixes are
-    permitted and MUST be ordered as shown:
-
-    .devN, aN, bN, rcN, <no suffix>, .postN
-    """
-    version = get_complete_version(version)
-
-    # Prerelease versions are special, we parse git data and look for special
-    # VERSION files in package data to fetch auto-generated data.
-    if version[3] != "final":
-        return get_prerelease_version(version)
-
-    major = get_major_version(version)
-
-    sub = ""
-    if version[4] > 0:
-        sub = ".post{}".format(version[4])
-
-    return str(major + sub)
+    return get_prerelease_version(version)
 
 
 def get_version_and_operator_from_range(version_range):
@@ -472,6 +398,7 @@ def normalize_version_to_semver(version):
     dev = dev_match.group(2)
 
     # extract the numeric semver component and the stuff that comes after
+
     numeric, after = re.match(
         r"(^\d+\.\d+[0-9]*\.?[0-9]*)([a-z0-9.+]*)", version
     ).groups()


Test patch:
diff --git a/kolibri/utils/tests/test_version.py b/kolibri/utils/tests/test_version.py
index c0800a5253..adcbcb277d 100755
--- a/kolibri/utils/tests/test_version.py
+++ b/kolibri/utils/tests/test_version.py
@@ -18,10 +18,6 @@ from kolibri.utils import version
 get_version = version.get_version.__wrapped__  # @UndefinedVariable
 
 
-def dont_call_me_maybe(msg):
-    raise AssertionError(msg)
-
-
 class TestKolibriVersion(unittest.TestCase):
     def test_version(self):
         """
@@ -32,46 +28,61 @@ class TestKolibriVersion(unittest.TestCase):
 
     @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):
+    @mock.patch("kolibri.utils.version.get_git_changeset", return_value=None)
+    def test_no_tag_no_file_version_no_git_changeset(
+        self, get_git_changeset_mock, file_mock, describe_mock
+    ):
         """
         Test that when doing something with a 0th alpha doesn't provoke any
-        hickups with ``git describe --tag``.
+        hiccups with ``git describe --tag``.
+        If the version file returns nothing, and get_git_describe returns nothing,
+        and get_git_changeset returns nothing,
+        then get_prerelease_version should return x.y.z.dev0
+        as we are not on a final release, but we can't determine any more information
+        beyond that, so we can only say this is a dev0 release and nothing more.
         """
-        v = get_version((0, 1, 0, "alpha", 0))
-        self.assertIn("0.1.0.dev0", v)
+        v = get_version((0, 1, 0))
+        self.assertEqual("0.1.0.dev0", v)
 
     @mock.patch("kolibri.utils.version.get_git_describe", return_value=None)
     @mock.patch("kolibri.utils.version.get_version_file", return_value=None)
+    @mock.patch(
+        "kolibri.utils.version.get_git_changeset", return_value="+git.1234567890"
+    )
+    def test_no_tag_no_file_version_git_changeset(
+        self, get_git_changeset_mock, file_mock, describe_mock
+    ):
+        """
+        Test that when doing something with a 0th alpha doesn't provoke any
+        hiccups with ``git describe --tag``.
+        If the version file returns nothing, and get_git_describe returns nothing,
+        then get_prerelease_version should return x.y.z.dev0 and the output of
+        get_git_changeset to give an incrementing version number in absence of any
+        other relevant tag information.
+        """
+        v = get_version((0, 1, 0))
+        self.assertEqual("0.1.0.dev0+git.1234567890", v)
+
+    @mock.patch("kolibri.utils.version.get_git_describe", return_value="v0.1.0-alpha1")
+    @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)
         """
-        v = get_version((0, 1, 0, "alpha", 1))
+        v = get_version((0, 1, 0))
         self.assertIn("0.1.0a1", v)
 
-    @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.
-        """
-        # Simple mocking
-        get_version_file = version.get_version_file
-        version.get_version_file = lambda: None
-        try:
-            v = get_version((0, 1, 0, "alpha", 1))
-            self.assertIn("0.1.0a1", v)
-        finally:
-            version.get_version_file = get_version_file
-
     @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
+        If the version file returns a version that matches the
+        major, minor, patch of the version tuple, return that version string
         """
-        v = get_version((0, 1, 0, "alpha", 1))
+        v = get_version((0, 1, 0))
         self.assertIn("0.1.0a1", v)
 
     @mock.patch("kolibri.utils.version.get_version_file", return_value="0.1.0a1\n")
@@ -82,7 +93,7 @@ class TestKolibriVersion(unittest.TestCase):
 
         See: https://github.com/learningequality/kolibri/issues/2464
         """
-        v = get_version((0, 1, 0, "alpha", 1))
+        v = get_version((0, 1, 0))
         self.assertIn("0.1.0a1", v)
 
     @mock.patch(
@@ -94,44 +105,24 @@ class TestKolibriVersion(unittest.TestCase):
         """
         Test that a version file with git describe output is correctly parsed
         """
-        v = get_version((0, 7, 1, "beta", 1))
+        v = get_version((0, 7, 1))
         self.assertIn("0.7.1b1.dev0+git.2.gfd48a7a", v)
 
+    @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)
     @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
+        self, get_git_changeset_mock, describe_mock, version_file_mock
     ):
         """
-        Test that inconsistent file data also just fails
+        Test that inconsistent version file data also just fails
+        If the version file returns a version that doesn't match the
+        major, minor, patch of the version tuple, throw an assertion error
         """
-        # Simple mocking
-        get_version_file = version.get_version_file
-        inconsistent_versions = ("0.2.0a1", "0.1.1a1", "0.1.0")
+        inconsistent_versions = ("0.2.0a1", "0.1.1a1")
         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.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
-        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
+            version_file_mock.return_value = v
+            self.assertRaises(AssertionError, get_version, (0, 1, 0))
 
     @mock.patch("kolibri.utils.version.get_version_file", return_value="0.1.0b1")
     @mock.patch("kolibri.utils.version.get_git_describe", return_value=None)
@@ -143,69 +134,45 @@ class TestKolibriVersion(unittest.TestCase):
         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"
+        assert get_version((0, 1, 0)) == "0.1.0b1"
 
     @mock.patch("kolibri.utils.version.get_version_file", return_value=None)
     @mock.patch(
         "kolibri.utils.version.get_git_describe",
         return_value="v0.1.0-alpha1-123-abcdfe12",
     )
-    def test_alpha_0_consistent_git(self, describe_mock, file_mock):
+    def test_alpha_1_consistent_git(self, describe_mock, file_mock):
         """
         Tests that git describe data for an alpha-1 tag generates an a1 version
         string.
         """
-        assert get_version((0, 1, 0, "alpha", 0)) == "0.1.0a1.dev0+git.123.abcdfe12"
+        assert get_version((0, 1, 0)) == "0.1.0a1.dev0+git.123.abcdfe12"
 
     @mock.patch("kolibri.utils.version.get_version_file", return_value=None)
     @mock.patch(
         "kolibri.utils.version.get_git_describe",
-        return_value="v0.1.0-alpha1-123-abcdfe12",
+        return_value="v0.1.0-alpha1",
     )
-    def test_alpha_1_consistent_git(self, describe_mock, file_mock):
+    def test_alpha_1_consistent_git_tag(self, describe_mock, file_mock):
         """
         Tests that git describe data for an alpha-1 tag generates an a1 version
         string.
+        If the version file returns nothing, and get_git_describe returns a version
+        tag like v0.1.0-alpha1 and it matches the major, minor, patch of the version tuple,
+        return the version (without the v) (because that means we are on a tagged commit)
         """
-        assert get_version((0, 1, 0, "alpha", 1)) == "0.1.0a1.dev0+git.123.abcdfe12"
-
-    @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.7.1b1.dev0+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.dev0+git.12.g2a8fe31"
+        assert get_version((0, 1, 0)) == "0.1.0a1"
 
     @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(
+    def test_version_file_overrides(
         self, get_git_changeset_mock, describe_mock, file_mock
     ):
         """
-        Test that the VERSION file is NOT used where git data is available
+        Test that the VERSION file is used when git data is available
         """
-        assert get_version((0, 1, 0, "alpha", 0)) == "0.1.0.dev0+git123"
+        assert get_version((0, 1, 0)) == "0.1.0b1"
 
     @mock.patch("kolibri.utils.version.get_version_file", return_value="0.1.0")
     @mock.patch("kolibri.utils.version.get_git_describe", return_value=None)
@@ -215,37 +182,43 @@ class TestKolibriVersion(unittest.TestCase):
         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"
+        assert get_version((0, 1, 0)) == "0.1.0"
 
-    def test_alpha_1_inconsistent_git(self):
+    @mock.patch(
+        "kolibri.utils.version.get_version_file",
+        return_value=None,
+    )
+    @mock.patch("kolibri.utils.version.get_git_describe")
+    def test_alpha_1_inconsistent_git_tag(self, describe_mock, file_mock):
         """
         Test that we fail when git returns inconsistent data
+        Only when the returned tag is a greater major, minor, patch
+        version than what we have encoded in the version tuple.
+        This should ensure that we notice if we apply a tag on the wrong
+        repository branch.
         """
-        # Simple mocking
-        git_describe = version.get_git_describe
-        try:
-            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"
-            self.assertRaises(AssertionError, get_version, (0, 1, 0, "beta", 0))
-            version.get_git_describe = lambda *x: "v0.1.0"
-            self.assertRaises(AssertionError, get_version, (0, 1, 0, "alpha", 0))
-        finally:
-            version.get_git_describe = git_describe
+        describe_mock.return_value = "v0.2.0-beta1"
+        self.assertRaises(AssertionError, get_version, (0, 1, 0))
+        describe_mock.return_value = "v0.2.0"
+        self.assertRaises(AssertionError, get_version, (0, 1, 0))
 
+    @mock.patch("kolibri.utils.version.get_version_file", return_value=None)
     @mock.patch(
         "kolibri.utils.version.get_git_describe",
-        return_value="v0.1.0-beta1-123-abcdfe12",
     )
-    def test_alpha_1_beta_1_consistent_git(self, describe_mock):
+    def test_alpha_1_inconsistent_git(self, describe_mock, file_mock):
         """
-        Test that a beta1 git tag can override kolibri.__version__ reading
-        alpha0.
+        Tests that git describe data for an alpha-1 tag generates an a1 version
+        string.
+        Only when the returned version is a greater major, minor, patch
+        version than what we have encoded in the version tuple.
         """
-        assert get_version((0, 1, 0, "alpha", 1)) == "0.1.0b1.dev0+git.123.abcdfe12"
+        describe_mock.return_value = "v0.2.0-alpha1-123-abcdfe12"
+        self.assertRaises(AssertionError, get_version, (0, 1, 0))
 
     @mock.patch("subprocess.Popen")
-    def test_git_describe_parser(self, popen_mock):
+    @mock.patch("kolibri.utils.version.get_version_file", return_value=None)
+    def test_git_describe_parser(self, file_mock, popen_mock):
         """
         Test that we get the git describe data when it's there
         """
@@ -253,19 +226,23 @@ class TestKolibriVersion(unittest.TestCase):
         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.dev0+git.123.abcdfe12"
+        assert get_version((0, 1, 0)) == "0.1.0b1.dev0+git.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
+        Always fallback to .dev0 to give some indication that
+        this is not a final release, even if we can discern nothing else.
+        Noting that the subprocess.Popen mock also causes get_git_changeset
+        to return nothing meaningful either, so we don't even get that additional information.
         """
         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"
+        assert get_version((0, 1, 0)) == "0.1.0.dev0"
 
     @mock.patch("subprocess.Popen", side_effect=EnvironmentError())
     @mock.patch("kolibri.utils.version.get_version_file", return_value="0.1.0a2")
@@ -273,45 +250,39 @@ class TestKolibriVersion(unittest.TestCase):
         """
         Test that we don't fail and that the version file is used
         """
-        assert get_version((0, 1, 0, "alpha", 1)) == "0.1.0a2"
+        assert get_version((0, 1, 0)) == "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):
+    @mock.patch("kolibri.utils.version.get_git_describe")
+    @mock.patch("kolibri.utils.version.get_version_file", return_value="0.1.1")
+    def test_final_patch(self, file_mock, 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")
+        v = get_version((0, 1, 1))
+        self.assertEqual(v, "0.1.1")
         assert describe_mock.call_count == 0
 
-    @mock.patch("kolibri.utils.version.get_git_describe")
-    def test_final_patch(self, describe_mock):
+    @mock.patch("kolibri.utils.version.get_git_describe", return_value="v0.1.1")
+    @mock.patch("kolibri.utils.version.get_version_file", return_value=None)
+    def test_final_tag(self, file_mock, describe_mock):
         """
-        Test that the major version is set as expected on a final release
+        Test that the major version is set as expected on a final release tag
         """
-        v = get_version((0, 1, 1, "final", 0))
+        v = get_version((0, 1, 1))
         self.assertEqual(v, "0.1.1")
-        assert describe_mock.call_count == 0
+        assert describe_mock.call_count == 1
 
-    @mock.patch("kolibri.utils.version.get_git_describe")
-    def test_final_post(self, describe_mock):
+    @mock.patch(
+        "kolibri.utils.version.get_git_describe", return_value="v0.1.1-6-gdef09150"
+    )
+    @mock.patch("kolibri.utils.version.get_version_file", return_value=None)
+    def test_after_final_tag(self, file_mock, describe_mock):
         """
-        Test that the major version is set as expected on a final release
+        Test that the version is set as the next patch alpha when we are beyond the final release tag
         """
-        v = get_version((0, 1, 1, "final", 1))
-        self.assertEqual(v, "0.1.1.post1")
-        assert describe_mock.call_count == 0
+        v = get_version((0, 1, 1))
+        self.assertEqual(v, "0.1.1a0.dev0+git.6.gdef09150")
+        assert describe_mock.call_count == 1
 
     def test_version_compat(self):
         """
@@ -470,3 +441,20 @@ class TestKolibriVersion(unittest.TestCase):
             ),
             "0.16-b.1.c",
         )
+
+    @mock.patch("kolibri.utils.version.get_git_describe", return_value="v0.15.8")
+    @mock.patch("kolibri.utils.version.get_version_file", return_value=None)
+    def test_get_version(self, file_mock, describe_mock):
+        self.assertEqual(
+            get_version((0, 15, 8)),
+            "0.15.8",
+        )
+        assert describe_mock.call_count == 1
+
+    @mock.patch("kolibri.utils.version.get_version_file", return_value="0.15.8")
+    def test_get_version_from_file(self, describe_mock):
+        self.assertEqual(
+            get_version((0, 15, 8)),
+            "0.15.8",
+        )
+        assert describe_mock.call_count == 1