1# Licensed under a 3-clause BSD style license - see LICENSE.rst
2
3"""
4Utilities for generating the version string for Astropy (or an affiliated
5package) and the version.py module, which contains version info for the
6package.
7
8Within the generated astropy.version module, the `major`, `minor`, and `bugfix`
9variables hold the respective parts of the version number (bugfix is '0' if
10absent). The `release` variable is True if this is a release, and False if this
11is a development version of astropy. For the actual version string, use::
12
13    from astropy.version import version
14
15or::
16
17    from astropy import __version__
18
19"""
20
21import datetime
22import os
23import pkgutil
24import sys
25import time
26import warnings
27
28from distutils import log
29from configparser import ConfigParser
30
31import pkg_resources
32
33from . import git_helpers
34from .distutils_helpers import is_distutils_display_option
35from .git_helpers import get_git_devstr
36from .utils import AstropyDeprecationWarning, import_file
37
38__all__ = ['generate_version_py']
39
40
41def _version_split(version):
42    """
43    Split a version string into major, minor, and bugfix numbers.  If any of
44    those numbers are missing the default is zero.  Any pre/post release
45    modifiers are ignored.
46
47    Examples
48    ========
49    >>> _version_split('1.2.3')
50    (1, 2, 3)
51    >>> _version_split('1.2')
52    (1, 2, 0)
53    >>> _version_split('1.2rc1')
54    (1, 2, 0)
55    >>> _version_split('1')
56    (1, 0, 0)
57    >>> _version_split('')
58    (0, 0, 0)
59    """
60
61    parsed_version = pkg_resources.parse_version(version)
62
63    if hasattr(parsed_version, 'base_version'):
64        # New version parsing for setuptools >= 8.0
65        if parsed_version.base_version:
66            parts = [int(part)
67                     for part in parsed_version.base_version.split('.')]
68        else:
69            parts = []
70    else:
71        parts = []
72        for part in parsed_version:
73            if part.startswith('*'):
74                # Ignore any .dev, a, b, rc, etc.
75                break
76            parts.append(int(part))
77
78    if len(parts) < 3:
79        parts += [0] * (3 - len(parts))
80
81    # In principle a version could have more parts (like 1.2.3.4) but we only
82    # support <major>.<minor>.<micro>
83    return tuple(parts[:3])
84
85
86# This is used by setup.py to create a new version.py - see that file for
87# details. Note that the imports have to be absolute, since this is also used
88# by affiliated packages.
89_FROZEN_VERSION_PY_TEMPLATE = """
90# Autogenerated by {packagetitle}'s setup.py on {timestamp!s} UTC
91import datetime
92
93{header}
94
95major = {major}
96minor = {minor}
97bugfix = {bugfix}
98
99version_info = (major, minor, bugfix)
100
101release = {rel}
102timestamp = {timestamp!r}
103debug = {debug}
104
105astropy_helpers_version = "{ahver}"
106"""[1:]
107
108
109_FROZEN_VERSION_PY_WITH_GIT_HEADER = """
110{git_helpers}
111
112
113_packagename = "{packagename}"
114_last_generated_version = "{verstr}"
115_last_githash = "{githash}"
116
117# Determine where the source code for this module
118# lives.  If __file__ is not a filesystem path then
119# it is assumed not to live in a git repo at all.
120if _get_repo_path(__file__, levels=len(_packagename.split('.'))):
121    version = update_git_devstr(_last_generated_version, path=__file__)
122    githash = get_git_devstr(sha=True, show_warning=False,
123                             path=__file__) or _last_githash
124else:
125    # The file does not appear to live in a git repo so don't bother
126    # invoking git
127    version = _last_generated_version
128    githash = _last_githash
129"""[1:]
130
131
132_FROZEN_VERSION_PY_STATIC_HEADER = """
133version = "{verstr}"
134githash = "{githash}"
135"""[1:]
136
137
138def _get_version_py_str(packagename, version, githash, release, debug,
139                        uses_git=True):
140    try:
141        from astropy_helpers import __version__ as ahver
142    except ImportError:
143        ahver = "unknown"
144
145    epoch = int(os.environ.get('SOURCE_DATE_EPOCH', time.time()))
146    timestamp = datetime.datetime.utcfromtimestamp(epoch)
147    major, minor, bugfix = _version_split(version)
148
149    if packagename.lower() == 'astropy':
150        packagetitle = 'Astropy'
151    else:
152        packagetitle = 'Astropy-affiliated package ' + packagename
153
154    header = ''
155
156    if uses_git:
157        header = _generate_git_header(packagename, version, githash)
158    elif not githash:
159        # _generate_git_header will already generate a new git has for us, but
160        # for creating a new version.py for a release (even if uses_git=False)
161        # we still need to get the githash to include in the version.py
162        # See https://github.com/astropy/astropy-helpers/issues/141
163        githash = git_helpers.get_git_devstr(sha=True, show_warning=True)
164
165    if not header:  # If _generate_git_header fails it returns an empty string
166        header = _FROZEN_VERSION_PY_STATIC_HEADER.format(verstr=version,
167                                                         githash=githash)
168
169    return _FROZEN_VERSION_PY_TEMPLATE.format(packagetitle=packagetitle,
170                                              timestamp=timestamp,
171                                              header=header,
172                                              major=major,
173                                              minor=minor,
174                                              bugfix=bugfix,
175                                              ahver=ahver,
176                                              rel=release, debug=debug)
177
178
179def _generate_git_header(packagename, version, githash):
180    """
181    Generates a header to the version.py module that includes utilities for
182    probing the git repository for updates (to the current git hash, etc.)
183    These utilities should only be available in development versions, and not
184    in release builds.
185
186    If this fails for any reason an empty string is returned.
187    """
188
189    loader = pkgutil.get_loader(git_helpers)
190    source = loader.get_source(git_helpers.__name__) or ''
191    source_lines = source.splitlines()
192    if not source_lines:
193        log.warn('Cannot get source code for astropy_helpers.git_helpers; '
194                 'git support disabled.')
195        return ''
196
197    idx = 0
198    for idx, line in enumerate(source_lines):
199        if line.startswith('# BEGIN'):
200            break
201    git_helpers_py = '\n'.join(source_lines[idx + 1:])
202
203    verstr = version
204
205    new_githash = git_helpers.get_git_devstr(sha=True, show_warning=False)
206
207    if new_githash:
208        githash = new_githash
209
210    return _FROZEN_VERSION_PY_WITH_GIT_HEADER.format(
211                git_helpers=git_helpers_py, packagename=packagename,
212                verstr=verstr, githash=githash)
213
214
215def generate_version_py(packagename=None, version=None, release=None, debug=None,
216                        uses_git=None, srcdir='.'):
217    """
218    Generate a version.py file in the package with version information, and
219    update developer version strings.
220
221    This function should normally be called without any arguments. In this case
222    the package name and version is read in from the ``setup.cfg`` file (from
223    the ``name`` or ``package_name`` entry and the ``version`` entry in the
224    ``[metadata]`` section).
225
226    If the version is a developer version (of the form ``3.2.dev``), the
227    version string will automatically be expanded to include a sequential
228    number as a suffix (e.g. ``3.2.dev13312``), and the updated version string
229    will be returned by this function.
230
231    Based on this updated version string, a ``version.py`` file will be
232    generated inside the package, containing the version string as well as more
233    detailed information (for example the major, minor, and bugfix version
234    numbers, a ``release`` flag indicating whether the current version is a
235    stable or developer version, and so on.
236    """
237
238    if packagename is not None:
239        warnings.warn('The packagename argument to generate_version_py has '
240                      'been deprecated and will be removed in future. Specify '
241                      'the package name in setup.cfg instead', AstropyDeprecationWarning)
242
243    if version is not None:
244        warnings.warn('The version argument to generate_version_py has '
245                      'been deprecated and will be removed in future. Specify '
246                      'the version number in setup.cfg instead', AstropyDeprecationWarning)
247
248    if release is not None:
249        warnings.warn('The release argument to generate_version_py has '
250                      'been deprecated and will be removed in future. We now '
251                      'use the presence of the "dev" string in the version to '
252                      'determine whether this is a release', AstropyDeprecationWarning)
253
254    # We use ConfigParser instead of read_configuration here because the latter
255    # only reads in keys recognized by setuptools, but we need to access
256    # package_name below.
257    conf = ConfigParser()
258    conf.read('setup.cfg')
259
260    if conf.has_option('metadata', 'name'):
261        packagename = conf.get('metadata', 'name')
262    elif conf.has_option('metadata', 'package_name'):
263        # The package-template used package_name instead of name for a while
264        warnings.warn('Specifying the package name using the "package_name" '
265                      'option in setup.cfg is deprecated - use the "name" '
266                      'option instead.', AstropyDeprecationWarning)
267        packagename = conf.get('metadata', 'package_name')
268    elif packagename is not None:  # deprecated
269        pass
270    else:
271        sys.stderr.write('ERROR: Could not read package name from setup.cfg\n')
272        sys.exit(1)
273
274    if conf.has_option('metadata', 'version'):
275        version = conf.get('metadata', 'version')
276        add_git_devstr = True
277    elif version is not None:  # deprecated
278        add_git_devstr = False
279    else:
280        sys.stderr.write('ERROR: Could not read package version from setup.cfg\n')
281        sys.exit(1)
282
283    if release is None:
284        release = 'dev' not in version
285
286    if not release and add_git_devstr:
287        version += get_git_devstr(False)
288
289    if uses_git is None:
290        uses_git = not release
291
292    # In some cases, packages have a - but this is a _ in the module. Since we
293    # are only interested in the module here, we replace - by _
294    packagename = packagename.replace('-', '_')
295
296    try:
297        version_module = get_pkg_version_module(packagename)
298
299        try:
300            last_generated_version = version_module._last_generated_version
301        except AttributeError:
302            last_generated_version = version_module.version
303
304        try:
305            last_githash = version_module._last_githash
306        except AttributeError:
307            last_githash = version_module.githash
308
309        current_release = version_module.release
310        current_debug = version_module.debug
311    except ImportError:
312        version_module = None
313        last_generated_version = None
314        last_githash = None
315        current_release = None
316        current_debug = None
317
318    if release is None:
319        # Keep whatever the current value is, if it exists
320        release = bool(current_release)
321
322    if debug is None:
323        # Likewise, keep whatever the current value is, if it exists
324        debug = bool(current_debug)
325
326    package_srcdir = os.path.join(srcdir, *packagename.split('.'))
327    version_py = os.path.join(package_srcdir, 'version.py')
328
329    if (last_generated_version != version or current_release != release or
330            current_debug != debug):
331        if '-q' not in sys.argv and '--quiet' not in sys.argv:
332            log.set_threshold(log.INFO)
333
334        if is_distutils_display_option():
335            # Always silence unnecessary log messages when display options are
336            # being used
337            log.set_threshold(log.WARN)
338
339        log.info('Freezing version number to {0}'.format(version_py))
340
341        with open(version_py, 'w') as f:
342            # This overwrites the actual version.py
343            f.write(_get_version_py_str(packagename, version, last_githash,
344                                        release, debug, uses_git=uses_git))
345
346    return version
347
348
349def get_pkg_version_module(packagename, fromlist=None):
350    """Returns the package's .version module generated by
351    `astropy_helpers.version_helpers.generate_version_py`.  Raises an
352    ImportError if the version module is not found.
353
354    If ``fromlist`` is an iterable, return a tuple of the members of the
355    version module corresponding to the member names given in ``fromlist``.
356    Raises an `AttributeError` if any of these module members are not found.
357    """
358
359    version = import_file(os.path.join(packagename, 'version.py'), name='version')
360
361    if fromlist:
362        return tuple(getattr(version, member) for member in fromlist)
363    else:
364        return version
365