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