Repository: enthought/chaco

Issue:
Update setup.py so we can make release candidates
See similar issue https://github.com/enthought/envisage/issues/316

At the moment, the way `setup.py` is written and version computation in the setup script works, we cannot make release candidates for `chaco` e.g. `5.0.0rc1`. It would be useful to bring over the changes from traits/traitsui/pyface/envisage to be able to make release candidates for chaco.


Discussion:

Resolving patch:
diff --git a/chaco/__init__.py b/chaco/__init__.py
--- a/chaco/__init__.py
+++ b/chaco/__init__.py
@@ -3,6 +3,9 @@
 """ Two-dimensional plotting application toolkit.
     Part of the Chaco project of the Enthought Tool Suite.
 """
-from ._version import full_version as __version__  # noqa
+try:
+    from chaco._version import full_version as __version__  # noqa
+except ImportError:
+    __version__ = "not-built"
 
 __requires__ = ["traits", "traitsui", "pyface", "numpy", "enable"]
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -2,6 +2,7 @@
 # All rights reserved.
 import os
 import re
+import runpy
 import subprocess
 
 from numpy import get_include
@@ -11,38 +12,96 @@
 MAJOR = 4
 MINOR = 8
 MICRO = 1
-
+PRERELEASE = ""
 IS_RELEASED = False
 
-VERSION = '%d.%d.%d' % (MAJOR, MINOR, MICRO)
+# If this file is part of a Git export (for example created with "git archive",
+# or downloaded from GitHub), ARCHIVE_COMMIT_HASH gives the full hash of the
+# commit that was exported.
+ARCHIVE_COMMIT_HASH = "$Format:%H$"
+
+# Templates for version strings.
+RELEASED_VERSION = "{major}.{minor}.{micro}{prerelease}"
+UNRELEASED_VERSION = "{major}.{minor}.{micro}{prerelease}.dev{dev}"
+
+# Paths to the autogenerated version file and the Git directory.
+HERE = os.path.abspath(os.path.dirname(__file__))
+VERSION_FILE = os.path.join(HERE, "chaco", "_version.py")
+GIT_DIRECTORY = os.path.join(HERE, ".git")
+
+# Template for the autogenerated version file.
+VERSION_FILE_TEMPLATE = """\
+# (C) Copyright 2005-2021 Enthought, Inc., Austin, TX
+# All rights reserved.
+#
+# This software is provided without warranty under the terms of the BSD
+# license included in LICENSE.txt and may be redistributed only under
+# the conditions described in the aforementioned license. The license
+# is also available online at http://www.enthought.com/licenses/BSD.txt
+#
+# Thanks for using Enthought open source!
+
+# THIS FILE IS GENERATED FROM SETUP.PY
+
+#: The full version of the package, including a development suffix
+#: for unreleased versions of the package.
+version = '{version}'
+
+#: The full version of the package, same as 'version'
+#: Kept for backward compatibility
+full_version = version
+
+#: The Git revision from which this release was made.
+git_revision = '{git_revision}'
+
+#: Flag whether this is a final release
+is_released = {is_released}
+"""
 
-# Name of the directory containing the package.
-PKG_PATHNAME = 'chaco'
 
-# Name of the file containing the version information.
-_VERSION_FILENAME = os.path.join(PKG_PATHNAME, '_version.py')
+def read_module(module, package='chaco'):
+    """ Read a simple .py file from chaco in a safe way.
 
+    It would be simpler to import the file, but that can be problematic in an
+    unknown system, so we exec the file instead and extract the variables.
 
-def read_version_py(path):
-    """ Read a _version.py file in a safe way. """
-    with open(path, 'r') as fp:
-        code = compile(fp.read(), 'chaco._version', 'exec')
+    This will fail if things get too complex in the file being read, but is
+    sufficient to get version and requirements information.
+    """
+    base_dir = os.path.dirname(__file__)
+    module_name = package + '.' + module
+    path = os.path.join(base_dir, package, module+'.py')
+    with open(path, 'r', encoding='utf-8') as fp:
+        code = compile(fp.read(), module_name, 'exec')
     context = {}
     exec(code, context)
-    return context['git_revision'], context['full_version']
-
+    return context
 
-def git_version():
-    """ Parse version information from the current git commit.
 
-    Parse the output of `git describe` and return the git hash and the number
-    of commits since the last version tag.
+# Return the git revision as a string
+def _git_info():
     """
+    Get information about the given commit from Git.
+
+    Returns
+    -------
+    git_count : int
+        Number of revisions from this commit to the initial commit.
+    git_revision : str
+        Commit hash for HEAD.
 
+    Raises
+    ------
+    EnvironmentError
+        If Git is not available.
+    subprocess.CalledProcessError
+        If Git is available, but the version command fails (most likely
+        because there's no Git repository here).
+    """
     def _minimal_ext_cmd(cmd):
         # construct minimal environment
         env = {}
-        for k in ['SYSTEMROOT', 'PATH', 'HOME']:
+        for k in ['SYSTEMROOT', 'PATH']:
             v = os.environ.get(k)
             if v is not None:
                 env[k] = v
@@ -56,14 +115,7 @@ def _minimal_ext_cmd(cmd):
         return out
 
     try:
-        # We ask git to find the latest tag matching a glob expression. The
-        # intention is to find a release tag of the form '4.50.2'. Strictly
-        # speaking, the glob expression also matches tags of the form
-        # '4abc.5xyz.2gtluuu', but it's very difficult with glob expressions
-        # to distinguish between the two cases, and the likelihood of a
-        # problem is minimal.
-        out = _minimal_ext_cmd(
-            ['git', 'describe', '--match', '[0-9]*.[0-9]*.[0-9]*', '--tags'])
+        out = _minimal_ext_cmd(['git', 'describe', '--tags'])
     except OSError:
         out = ''
 
@@ -75,61 +127,159 @@ def _minimal_ext_cmd(cmd):
     else:
         git_revision, git_count = match.group('hash'), match.group('count')
 
-    return git_revision, git_count
+    return git_count, git_revision
 
 
-def write_version_py(filename=_VERSION_FILENAME):
-    """ Create a file containing the version information. """
+def git_version():
+    """
+    Construct version information from local variables and Git.
 
-    template = """\
-# This file was automatically generated from the `setup.py` script.
-version = '{version}'
-full_version = '{full_version}'
-git_revision = '{git_revision}'
-is_released = {is_released}
+    Returns
+    -------
+    version : str
+        Package version.
+    git_revision : str
+        The full commit hash for the current Git revision.
 
-if not is_released:
-    version = full_version
-"""
-    # Adding the git rev number needs to be done inside
-    # write_version_py(), otherwise the import of _version messes
-    # up the build under Python 3.
-    fullversion = VERSION
-    chaco_version_path = os.path.join(
-        os.path.dirname(__file__), 'chaco', '_version.py')
-    if os.path.exists('.git'):
-        git_rev, dev_num = git_version()
-    elif os.path.exists(filename):
-        # must be a source distribution, use existing version file
-        try:
-            git_rev, fullversion = read_version_py(chaco_version_path)
-        except (SyntaxError, KeyError):
-            raise RuntimeError("Unable to read git_revision. Try removing "
-                               "chaco/_version.py and the build directory "
-                               "before building.")
-
-        match = re.match(r'.*?\.dev(?P<dev_num>\d+)', fullversion)
-        if match is None:
-            dev_num = '0'
-        else:
-            dev_num = match.group('dev_num')
-    else:
-        git_rev = 'Unknown'
-        dev_num = '0'
+    Raises
+    ------
+    EnvironmentError
+        If Git is not available.
+    subprocess.CalledProcessError
+        If Git is available, but the version command fails (most likely
+        because there's no Git repository here).
+    """
+    git_count, git_revision = _git_info()
+    version_template = RELEASED_VERSION if IS_RELEASED else UNRELEASED_VERSION
+    version = version_template.format(
+        major=MAJOR,
+        minor=MINOR,
+        micro=MICRO,
+        prerelease=PRERELEASE,
+        dev=git_count,
+    )
+    return version, git_revision
+
+
+def archive_version():
+    """
+    Construct version information for an archive.
+
+    Returns
+    -------
+    version : str
+        Package version.
+    git_revision : str
+        The full commit hash for the current Git revision.
+
+    Raises
+    ------
+    ValueError
+        If this does not appear to be an archive.
+    """
+    if "$" in ARCHIVE_COMMIT_HASH:
+        raise ValueError("This does not appear to be an archive.")
 
-    if not IS_RELEASED:
-        fullversion += '.dev{0}'.format(dev_num)
+    version_template = RELEASED_VERSION if IS_RELEASED else UNRELEASED_VERSION
+    version = version_template.format(
+        major=MAJOR,
+        minor=MINOR,
+        micro=MICRO,
+        prerelease=PRERELEASE,
+        dev="-unknown",
+    )
+    return version, ARCHIVE_COMMIT_HASH
+
+
+def write_version_file(version, git_revision, filename=VERSION_FILE):
+    """
+    Write version information to the version file.
+
+    Overwrites any existing version file.
+
+    Parameters
+    ----------
+    version : str
+        Package version.
+    git_revision : str
+        The full commit hash for the current Git revision.
+    filename : str
+        Path to the version file.
+    """
+    with open(filename, "w", encoding="utf-8") as version_file:
+        version_file.write(
+            VERSION_FILE_TEMPLATE.format(
+                version=version,
+                git_revision=git_revision,
+                is_released=IS_RELEASED,
+            )
+        )
+
+
+def read_version_file():
+    """
+    Read version information from the version file, if it exists.
+
+    Returns
+    -------
+    version : str
+        The full version, including any development suffix.
+    git_revision : str
+        The full commit hash for the current Git revision.
+
+    Raises
+    ------
+    EnvironmentError
+        If the version file does not exist.
+    """
+    version_info = runpy.run_path(VERSION_FILE)
+    return (version_info["version"], version_info["git_revision"])
+
+
+def resolve_version():
+    """
+    Process version information and write a version file if necessary.
+
+    Returns the current version information.
+
+    Returns
+    -------
+    version : str
+        Package version.
+    git_revision : str
+        The full commit hash for the current Git revision.
+    """
+    if os.path.isdir(GIT_DIRECTORY):
+        # This is a local clone; compute version information and write
+        # it to the version file, overwriting any existing information.
+        version = git_version()
+        print("Computed package version: {}".format(version))
+        print("Writing version to version file {}.".format(VERSION_FILE))
+        write_version_file(*version)
+    elif "$" not in ARCHIVE_COMMIT_HASH:
+        # This is a source archive.
+        version = archive_version()
+        print("Archive package version: {}".format(version))
+        print("Writing version to version file {}.".format(VERSION_FILE))
+        write_version_file(*version)
+    elif os.path.isfile(VERSION_FILE):
+        # This is a source distribution. Read the version information.
+        print("Reading version file {}".format(VERSION_FILE))
+        version = read_version_file()
+        print("Package version from version file: {}".format(version))
+    else:
+        raise RuntimeError(
+            "Unable to determine package version. No local Git clone "
+            "detected, and no version file found at {}.".format(VERSION_FILE)
+        )
 
-    with open(filename, "wt") as fp:
-        fp.write(template.format(version=VERSION,
-                                 full_version=fullversion,
-                                 git_revision=git_rev,
-                                 is_released=IS_RELEASED))
+    return version
 
 
 if __name__ == "__main__":
-    write_version_py()
-    from chaco import __requires__, __version__
+    __version__, _ = resolve_version()
+    data = read_module('__init__')
+    __requires__ = data['__requires__']
 
     numpy_include_dir = get_include()
 


Test patch: