# Author: Mike Nerone <mike@nerone.org>

# This module, which should be placed in the root of a project package, automatically determines the version number
# for a python package distribution. With the help of "git describe", the basis for this version number is the most
# recent annotated git tags of the form "vX", where X ia a dotted version number. The automatic bit is that the
# number of commits since that tag is then appended as a final component, resulting in a unique version number for
# each commit in a branch (usually master).
#
# For example, if the project follows the common three-component version number format (this _does_ work fine with
# more than that), you would manually tag commits only when you wish to increment either of the first two components.
# At a given point, the most recent such commit might have the annotated tag "v2.3", while five more commits have
# occurred since. since that tag. The resulting calculated version would then be "2.3.5". As an aside, this lends
# itself particularly well to a semantic versioning scheme (http://semver.org/), which is something I'd recommend.
#
# Whenever calculated, the resulting version is snapshotted in "_version.py" in the same directory as this file. This
# snapshotted value will be used in the case that the codebase doesn't appear to be a git checkout of the project.
# This is important for distribution, as a typical use of this functionality is to feed a version number to setup.py.
# This would cause a failure upon attempt to install from sdists (.tar.gz archives) and bdists (eggs, wheels),
# because git metadata will be unavailable. Note that this snapshot is intentionally in a ".py" file so that setup.py
# will include it when building distributions without necessitating manual adjustment of the manifest (but that is
# should be added to .gitignore so that it does not get checked into your repository).
#
# There are two intended way to access the calculated version number:
#
# A. From inside the codebase (for display in banner messages or the like), simply treat this file as a module and
# import version from it. To follow modern Python standard practices, it's suggested to do import it as "__version__"
# in your package-root __init__.py:
#
#     from __future__ import absolute_import       # You should already be doing this...
#     from .version import version as __version__  # ...but if you don't, then leave off the dot.
#
# B. For use in setup.py, where it's needed as metadata for setup(), importing can have the undesired side effect of
# executing extraneous code from within the package. Avoiding this while remaining compatible with Python 2 *and* 3 and
# preserving the ability for this code to find itself (and thereby the snapshot) takes a bit of care. Unfortunately,
# the necessary incantation can't be provided as a function for you to import due to the same side-effect reasons, but
# the necessary incantation is just a few lines:
#
#     version = {}
#     with open(os.path.join(os.path.dirname(__file__), 'pkg_name', 'version.py')) as ver_file:
#         exec(compile(ver_file.read(), ver_file.name, 'exec'), version)  # The compile is important.
#     version = version['version']
#
# If you know your package is only targeting Python 2 (or if you're using 2to3), this can be simplified to:
#
#     version = {}
#     execfile(os.path.join(os.path.dirname(__file__), 'package_name', 'version.py'), version)
#     version = version['version']

from __future__ import unicode_literals


def _execfile(filename, global_vars):
    'execfile replacement sufficient for our needs that works with Python 2 & 3.'
    with open(filename, 'r') as f:
        exec(f.read(), global_vars)


def _determine_version():
    import os
    from re import match
    from subprocess import Popen, PIPE

    snapshot_file_template = '\n'.join([
        '# This file is generated by {this_file}.',
        '# Do not edit it, nor import (from) it (its existence is not guaranteed).',
        '',
        '_version = {version}\n',
    ])

    try:
        this_file = __file__
    except NameError:
        import inspect
        this_file = inspect.getframeinfo(inspect.currentframe()).filename
    this_file = os.path.abspath(this_file)

    this_dir = os.path.dirname(this_file)
    proj_dir = os.path.dirname(this_dir)
    snapshot_file = os.path.join(this_dir, '_version.py')

    def _derive_git_version():
        with open(os.devnull, 'w') as dev_null:
            git_described = Popen(
                ['git', 'describe', '--match', 'v[0-9]*'],
                stdout=PIPE, stderr=dev_null,
                cwd=this_dir or '.',
            ).stdout.readline().strip()
            git_described = git_described.decode('utf-8')
            match_result = match(
                r'^v(?P<tag>.+?)(?:-(?P<commits_since_tag>\d+)-g(?P<commit_hash>[\da-f]+))?$',
                git_described,
            )
            if not match_result:
                raise ValueError('Failed to parse "git describe" output. (No "vX.Y" tag?)')
            components = match_result.groupdict()
            if components['commits_since_tag'] is None:
                components['commits_since_tag'] = 0
            return '{tag}.{commits_since_tag}'.format(**components)

    def _load_snapshot_version():
        exec_vars = {}
        if os.path.isfile(snapshot_file):
            try:
                _execfile(snapshot_file, exec_vars)
            except Exception:
                pass
            else:
                if '_version' in exec_vars:
                    return exec_vars['_version']

    def _save_snapshot_version(version):
        version = repr(version).lstrip('u')
        with open(snapshot_file, 'w') as f:
            f.write(snapshot_file_template.format(this_file=os.path.basename(this_file), version=version))

    version = snapshot_version = _load_snapshot_version()
    if os.path.isdir(os.path.join(proj_dir, '.git')) and os.path.isfile(os.path.join(proj_dir, 'setup.py')):
        # Looks like this is a checkout of this project.
        version = _derive_git_version()
        if not snapshot_version or version != snapshot_version:
            _save_snapshot_version(version)
    if version is None:
        raise ValueError('Unable to determine the version.')

    return version

version = _determine_version()
del(_determine_version, _execfile)
