diff --git a/.gitignore b/.gitignore index 4d4f1755b..32fb9e559 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,4 @@ dist/ docs/build/ # Auto-generated by setup.py -traits/_version.py +traits/version.py diff --git a/docs/source/traits_api_reference/index.rst b/docs/source/traits_api_reference/index.rst index fe4f81095..3354f3f03 100644 --- a/docs/source/traits_api_reference/index.rst +++ b/docs/source/traits_api_reference/index.rst @@ -21,6 +21,7 @@ Traits core traits_listener trait_notifiers ustr_trait + version Subpackages ----------- diff --git a/docs/source/traits_api_reference/version.rst b/docs/source/traits_api_reference/version.rst new file mode 100644 index 000000000..3ebcce0cc --- /dev/null +++ b/docs/source/traits_api_reference/version.rst @@ -0,0 +1,12 @@ +:mod:`version` Module +===================== + +.. automodule:: traits.version + :no-members: + +Attributes +---------- + +.. autodata:: version + +.. autodata:: git_revision diff --git a/setup.py b/setup.py index 99c63ad45..e3a17969e 100644 --- a/setup.py +++ b/setup.py @@ -1,191 +1,248 @@ -# Copyright (c) 2008-2013 by Enthought, Inc. +# Copyright (c) 2008-2019 by Enthought, Inc. # All rights reserved. + +import io import os -import re +import runpy import subprocess -import sys -from setuptools import setup, Extension, find_packages +import setuptools +# Version information; update this by hand when making a new bugfix or feature +# release. The actual package version is autogenerated from this information +# together with information from the version control system, and then injected +# into the package source. MAJOR = 5 MINOR = 2 MICRO = 0 - IS_RELEASED = False -VERSION = '%d.%d.%d' % (MAJOR, MINOR, MICRO) +# Templates for version strings. +RELEASED_VERSION = u"{major}.{minor}.{micro}" +UNRELEASED_VERSION = u"{major}.{minor}.{micro}.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, "traits", "version.py") +GIT_DIRECTORY = os.path.join(HERE, ".git") -# Return the git revision as a string -def git_version(): - def _minimal_ext_cmd(cmd): - # construct minimal environment - env = {} - for k in ['SYSTEMROOT', 'PATH']: - v = os.environ.get(k) - if v is not None: - env[k] = v - # LANGUAGE is used on win32 - env['LANGUAGE'] = 'C' - env['LANG'] = 'C' - env['LC_ALL'] = 'C' - out = subprocess.Popen( - cmd, stdout=subprocess.PIPE, env=env, - ).communicate()[0] - return out - - try: - encoded_git_revision = _minimal_ext_cmd(['git', 'rev-parse', 'HEAD']) - except OSError: - git_revision = "Unknown" - else: - git_revision = encoded_git_revision.decode("ascii").strip() - - try: - out = _minimal_ext_cmd(['git', 'describe', '--tags']) - except OSError: - out = '' - - git_description = out.decode('ascii').strip() - expr = r'.*?\-(?P\d+)-g(?P[a-fA-F0-9]+)' - match = re.match(expr, git_description) - if match is None: - git_count = '0' - else: - git_count = match.group('count') +# Template for the autogenerated version file. +VERSION_FILE_TEMPLATE = u'''\ +# Copyright (c) 2008-2019 by Enthought, Inc. +# All rights reserved. - return git_revision, git_count +""" +Version information for this Traits distribution. +This file is autogenerated by the Traits setup.py script. +""" -def write_version_py(filename='traits/_version.py'): - template = """\ -# THIS FILE IS GENERATED FROM TRAITS SETUP.PY -version = '{version}' -full_version = '{full_version}' -git_revision = '{git_revision}' -is_released = {is_released} +from __future__ import unicode_literals -if not is_released: - version = full_version -""" - # Adding the git rev number needs to be done inside - # write_version_py(), otherwise the import of traits._version messes - # up the build under Python 3. - fullversion = VERSION - if os.path.exists('.git'): - git_rev, dev_num = git_version() - elif os.path.exists('traits/_version.py'): - # must be a source distribution, use existing version file - try: - from traits._version import git_revision as git_rev - from traits._version import full_version as full_v - except ImportError: - raise ImportError("Unable to import git_revision. Try removing " - "traits/_version.py and the build directory " - "before building.") - - match = re.match(r'.*?\.dev(?P\d+)', full_v) - if match is None: - dev_num = '0' - else: - dev_num = match.group('dev_num') - else: - git_rev = 'Unknown' - dev_num = '0' +#: The full version of the package, including a development suffix +#: for unreleased versions of the package. +version = "{version}" - if not IS_RELEASED: - fullversion += '.dev{0}'.format(dev_num) +#: The Git revision from which this release was made. +git_revision = "{git_revision}" +''' - with open(filename, "wt") as fp: - fp.write(template.format(version=VERSION, - full_version=fullversion, - git_revision=git_rev, - is_released=IS_RELEASED)) +# Git executable to use to get revision information. +GIT = "git" -def check_python_version(): +def _git_output(args): """ - Check that this version of Python is supported. + Call Git with the given arguments and return the output as (Unicode) text. + """ + return subprocess.check_output([GIT] + args).decode("utf-8") + - Raise SystemExit for unsupported Python versions. +def _git_info(commit="HEAD"): """ - supported_python_version = ( - (2, 7) <= sys.version_info < (3,) - or (3, 4) <= sys.version_info - ) - if not supported_python_version: - sys.exit( - ( - "Python version {0} is not supported by Traits. " - "Traits requires Python >= 2.7 or Python >= 3.4." - ).format(sys.version_info) - ) + Get information about the given commit from Git. + + Parameters + ---------- + commit : str, optional + Commit to provide information for. Defaults to "HEAD". + + Returns + ------- + git_count : int + Number of revisions from this commit to the initial commit. + git_revision : unicode + 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). + """ + count_args = ["rev-list", "--count", "--first-parent", commit] + git_count = int(_git_output(count_args)) + revision_args = ["rev-list", "--max-count", "1", commit] + git_revision = _git_output(revision_args).rstrip() + + return git_count, git_revision + + +def write_version_file(version, git_revision): + """ + Write version information to the version file. -if __name__ == "__main__": - check_python_version() - write_version_py() - from traits import __version__, __requires__ + Overwrites any existing version file. - ctraits = Extension( - 'traits.ctraits', - sources=['traits/ctraits.c'], - extra_compile_args=['-DNDEBUG=1', '-O3'], + Parameters + ---------- + version : unicode + Package version. + git_revision : unicode + The full commit hash for the current Git revision. + """ + with io.open(VERSION_FILE, "w", encoding="ascii") as version_file: + version_file.write( + VERSION_FILE_TEMPLATE.format( + version=version, git_revision=git_revision + ) ) - def additional_commands(): - # Pygments 2 isn't supported on Python 3 versions earlier than 3.3, so - # don't make the documentation command available there. - if (3,) <= sys.version_info < (3, 3): - return {} - - try: - from sphinx.setup_command import BuildDoc - except ImportError: - return {} - else: - return {'documentation': BuildDoc} - - setup( - name='traits', - version=__version__, - url='http://docs.enthought.com/traits', - author='David C. Morrill, et. al.', - author_email='info@enthought.com', - classifiers=[c.strip() for c in """\ - Development Status :: 5 - Production/Stable - Intended Audience :: Developers - Intended Audience :: Science/Research - License :: OSI Approved :: BSD License - Operating System :: MacOS - Operating System :: Microsoft :: Windows - Operating System :: OS Independent - Operating System :: POSIX - Operating System :: Unix - Programming Language :: C - Programming Language :: Python - Programming Language :: Python :: 2 - Programming Language :: Python :: 2.7 - Programming Language :: Python :: 3 - Programming Language :: Python :: 3.4 - Programming Language :: Python :: 3.5 - Programming Language :: Python :: 3.6 - Programming Language :: Python :: Implementation :: CPython - Topic :: Scientific/Engineering - Topic :: Software Development - Topic :: Software Development :: Libraries - """.splitlines() if len(c.strip()) > 0], - description='explicitly typed attributes for Python', - long_description=open('README.rst').read(), - download_url='https://github.com/enthought/traits', - install_requires=__requires__, - ext_modules=[ctraits], - license='BSD', - maintainer='ETS Developers', - maintainer_email='enthought-dev@enthought.com', - packages=find_packages(), - platforms=["Windows", "Linux", "Mac OS-X", "Unix", "Solaris"], - zip_safe=False, - use_2to3=False, - cmdclass=additional_commands(), + +def read_version_file(): + """ + Read version information from the version file, if it exists. + + Returns + ------- + version : unicode + The full version, including any development suffix. + git_revision : unicode + 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 git_version(): + """ + Construct version information from local variables and Git. + + Returns + ------- + version : unicode + Package version. + git_revision : unicode + The full commit hash for the current Git revision. + + 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, dev=git_count ) + return version, git_revision + + +def resolve_version(): + """ + Process version information and write a version file if necessary. + + Returns the current version information. + + Returns + ------- + version : unicode + Package version. + git_revision : unicode + 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(u"Computed package version: {}".format(version)) + print(u"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(u"Reading version file {}".format(VERSION_FILE)) + version = read_version_file() + print(u"Package version from version file: {}".format(version)) + else: + raise RuntimeError( + u"Unable to determine package version. No local Git clone " + u"detected, and no version file found at {}.".format(VERSION_FILE) + ) + + return version + + +def get_long_description(): + """ Read long description from README.txt. """ + with io.open("README.rst", "r", encoding="utf-8") as readme: + return readme.read() + + +version, git_revision = resolve_version() + +setuptools.setup( + name="traits", + version=version, + url="http://docs.enthought.com/traits", + author="Enthought", + author_email="info@enthought.com", + classifiers=[ + c.strip() + for c in """ + Development Status :: 5 - Production/Stable + Intended Audience :: Developers + Intended Audience :: Science/Research + License :: OSI Approved :: BSD License + Operating System :: MacOS :: MacOS X + Operating System :: Microsoft :: Windows + Operating System :: POSIX :: Linux + Programming Language :: C + Programming Language :: Python + Programming Language :: Python :: 2 + Programming Language :: Python :: 2.7 + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.5 + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: Implementation :: CPython + Topic :: Scientific/Engineering + Topic :: Software Development + Topic :: Software Development :: Libraries + Topic :: Software Development :: User Interfaces + """.splitlines() + if len(c.strip()) > 0 + ], + description="Explicitly typed attributes for Python", + long_description=get_long_description(), + long_description_content_type="text/x-rst", + download_url="https://github.com/enthought/traits", + install_requires=["six"], + ext_modules=[setuptools.Extension("traits.ctraits", ["traits/ctraits.c"])], + license="BSD", + maintainer="ETS Developers", + maintainer_email="enthought-dev@enthought.com", + packages=setuptools.find_packages(include=["traits", "traits.*"]), + python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*", + zip_safe=False, +) diff --git a/traits/__init__.py b/traits/__init__.py index 896112c19..819fcba15 100644 --- a/traits/__init__.py +++ b/traits/__init__.py @@ -1,14 +1,18 @@ from __future__ import absolute_import -from traits._version import full_version as __version__ +try: + from traits.version import version as __version__ +except ImportError: + # If we get here, we're using a source tree that hasn't been created via + # the setup script. That likely also means that the ctraits extension + # hasn't been built, so this isn't a viable Traits installation. OTOH, it + # can be useful if a simple "import traits" doesn't actually fail. + __version__ = "unknown" # Add a NullHandler so 'traits' loggers don't complain when they get used. import logging -__requires__ = ["six"] - - class NullHandler(logging.Handler): def handle(self, record): pass diff --git a/traits/tests/test_version.py b/traits/tests/test_version.py new file mode 100644 index 000000000..17534d6bf --- /dev/null +++ b/traits/tests/test_version.py @@ -0,0 +1,50 @@ +# Copyright (c) 2019 by Enthought, Inc. +# All rights reserved. + +""" +Tests for the traits.__version__ attribute and the traits.version +module contents. +""" + +from __future__ import absolute_import, print_function, unicode_literals + +import unittest + +import pkg_resources +import six + +import traits + + +class TestVersion(unittest.TestCase): + def test_dunder_version(self): + self.assertIsInstance(traits.__version__, six.text_type) + # Round-trip through parse_version; this verifies not only + # that the version is valid, but also that it's properly normalised + # according to the PEP 440 rules. + parsed_version = pkg_resources.parse_version(traits.__version__) + self.assertEqual(six.text_type(parsed_version), traits.__version__) + + def test_version_version(self): + # Importing inside the test to ensure that we get a test error + # in the case where the version module does not exist. + from traits.version import version + + self.assertIsInstance(version, six.text_type) + parsed_version = pkg_resources.parse_version(version) + self.assertEqual(six.text_type(parsed_version), version) + + def test_version_git_revision(self): + from traits.version import git_revision + + self.assertIsInstance(git_revision, six.text_type) + + # Check the form of the revision. Could use a regex, but that seems + # like overkill. + self.assertEqual(len(git_revision), 40) + self.assertLessEqual(set(git_revision), set("0123456789abcdef")) + + def test_versions_match(self): + import traits.version + + self.assertEqual(traits.version.version, traits.__version__)