Source code for versionah

#
# coding=utf-8
"""versionah - Simple version specification management"""
# Copyright © 2011-2014  James Rowe <jnrowe@gmail.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#

from __future__ import print_function

from . import _version


__version__ = _version.dotted
__date__ = _version.date
__author__ = 'James Rowe <jnrowe@gmail.com>'
__copyright__ = 'Copyright (C) 2011-2014  James Rowe <jnrowe@gmail.com>'
__license__ = 'GNU General Public License Version 3'
__credits__ = ''
__history__ = 'See git repository'

from email.utils import parseaddr

# pylint: disable-msg=W0622
__doc__ += """.

versionah is a GPL v3 licensed module for maintaining version information files
for use in project management.

.. moduleauthor:: `%s <mailto:%s>`__
""" % parseaddr(__author__)
# pylint: enable-msg=W0622

# This is here to workaround UserWarning messages caused by path fiddling in
# dependencies
try:
    import pkg_resources  # NOQA
except ImportError:
    pass

import argparse
import datetime
import errno
import os
import re
import sys

try:
    # For Python 3
    from http.cookiejar import MONTHS
except ImportError:
    from cookielib import MONTHS  # NOQA

import aaargh
import jinja2

try:
    from blessings import Terminal
except ImportError:
    class Terminal:  # NOQA
        def __getattr__(self, attr):
            return lambda x: x

from .i18n import _


#: blessings Terminal object, used for fancy output.  No-op without blessings
T = Terminal()

#: Base string type, used for compatibility with Python 2 and 3
STR_TYPE = basestring if sys.version_info[0] == 2 else str

#: Command line interface object
APP = aaargh.App(description=_('A tool to manage project version files'),
                 epilog=_('Please report bugs to jnrowe@gmail.com'))

#: Regular expression to match a valid package name
VALID_PACKAGE = '[A-Za-z][A-Za-z0-9]+(?:[_\.-][A-Za-z0-9]+)*'
#: Regular expression to match a valid package version
VALID_VERSION = r'\d+\.\d+(?:\.\d+){,2}'
#: Regular expression to match a package date.  ISO-8601, and %d-%b-%Y
#: formatting for shtool compatibility
VALID_DATE = r'(?:\d{4}-\d{2}-\d{2}|\d{2}-(?:%s)-\d{4})' % '|'.join(MONTHS)

#: Supported version components
VERSION_COMPS = ('major', 'minor', 'micro', 'patch')


[docs]class ValidatingAction(argparse.Action): """argparse action to validate versionah input.""" def __call__(self, parser, namespace, values, option_string=None): if option_string in ('-n', '--name'): matcher = '%s$' % VALID_PACKAGE else: matcher = '%s$' % VALID_VERSION if not re.match(matcher, values): parser.error('Invalid string for %s: %r' % (option_string, values)) setattr(namespace, self.dest, values)
[docs]def success(text): """Format a success message with colour, if possible. :rtype: `str` """ return T.bright_green(text)
[docs]def fail(text): """Format a failure message with colour, if possible. :rtype: `str` """ return T.bright_red(text)
[docs]def warn(text): """Format a warning message with colour, if possible. :rtype: `str` """ return T.bright_yellow(text)
#: Custom filters for Jinja FILTERS = {}
[docs]def filter_regexp(string, pattern, repl, count=0, flags=0): """Jinja filter for regexp replacements. See :func:`re.sub` for documentation. :rtype: `str` :return: Text with substitutions applied """ if sys.version_info[:2] >= (2, 7): return re.sub(pattern, repl, string, count, flags) else: # regexps are cached, so this uglier path is no better than the 2.7 # one. Once 2.6 support disappears, so can this match = re.compile(pattern, flags=flags) return match.sub(repl, string, count)
FILTERS['regexp'] = filter_regexp
[docs]class Version(object): """Main version identifier representation.""" if sys.platform == 'darwin': fallback_dir = os.path.expanduser('~/Library/Application Support') else: fallback_dir = os.path.join(os.environ.get('HOME', '/'), '.local') user_dir = os.environ.get('XDG_DATA_HOME', fallback_dir) system_dirs = os.environ.get('XDG_DATA_DIRS', '/usr/local/share/:/usr/share/').split(':') mk_data_dir = lambda s: os.path.join(s, 'versionah', 'templates') pkg_data_dirs = [mk_data_dir(user_dir), ] for directory in system_dirs: pkg_data_dirs.append(mk_data_dir(directory)) env = jinja2.Environment(loader=jinja2.ChoiceLoader( list(jinja2.FileSystemLoader(s) for s in pkg_data_dirs))) env.loader.loaders.append(jinja2.PackageLoader('versionah', 'templates')) env.filters.update(FILTERS) filetypes = [s.split('.')[0] for s in env.list_templates()] def __init__(self, components=(0, 1, 0), name='unknown', date=datetime.date.today()): """Initialise a new `Version` object. :type components: `int` or `tuple` of `int` :param components: Version components :param str name: Package name :param datetime.date date: Date associated with version """ if isinstance(components, STR_TYPE): components = split_version(components) if not 2 <= len(components) <= 4: raise ValueError('Invalid number of components in %r' % (components, )) if not all((isinstance(n, int) and n >= 0) for n in components): raise ValueError('Invalid component values in %r' % (components, )) # Stub attributes set via Version.set method self.major = self.minor = self.micro = self.patch = 0 self._resolution = 0 self.set(components) self.name = name self.date = date def __repr__(self): """Self-documenting string representation. :rtype: `str` :return: String representation of object """ return '%s(%r, %r, %r)' % (self.__class__.__name__, self.components, self.name, self.date) def __str__(self): """Return default string representation. We return a dotted version string, as that is the most common format. :rtype: `str` :return: Default strings representation of object """ return '%s v%s' % (self.name, self.as_dotted()) @staticmethod def __prepare_cmp_object(other): """Prepare object for comparison with Version. This presents a tuple for comparison with Version.components_full. :type other: `Version`, `list`, `tuple` or `int` :param other: Object to munge :rtype: `tuple` :return: Full version component tuple for object :raise NotImplementedError: Incomparable other """ if isinstance(other, Version): return other.components_full elif isinstance(other, (tuple, list)): return (tuple(other) + (0, 0, 0))[:4] elif isinstance(other, str): return (split_version(other) + (0, 0, 0))[:4] else: raise NotImplementedError('Unable to compare Version and %r' % type(other)) def __eq__(self, other): """Test `Version` objects for equality. Importantly, padded version components are checked so that 0.1 is considered equal to 0.1.0.0. :rtype: `bool` """ return self.components_full == self.__prepare_cmp_object(other) __ne__ = lambda self, other: not self == (other) def __lt__(self, other): """Strict less-than test against comparable object. See `~Version.__prepare_cmp_object`. :rtype: `bool` :return: True if ``self`` is strictly less-than ``other`` """ return self.components < self.__prepare_cmp_object(other) def __gt__(self, other): """Strict greater-than test against comparable object. See `~Version.__prepare_cmp_object`. :rtype: `bool` :return: True if ``self`` is strictly greater-than ``other`` """ return self.components_full > self.__prepare_cmp_object(other) def __le__(self, other): """Less-than or equal to test against comparable object. See `~Version.__prepare_cmp_object`. :rtype: `bool` :return: True if ``self`` is less-than or equal to ``other`` """ return self < other or self == other def __ge__(self, other): """Greater-than or equal to test against comparable object. See `~Version.__prepare_cmp_object`. :rtype: `bool` :return: True if ``self`` is greater-than or equal to ``other`` """ return self > other or self == other def __hash__(self): """Create object-unique hash value. :rtype: `str` :return: Object-unique hash value """ return hash(repr(self))
[docs] def set(self, components): """Set version components. :type components: `tuple` of `int` :param components: Version components """ if isinstance(components, STR_TYPE): components = split_version(components) elif isinstance(components, list): components = tuple(components) padded = (components + (0, 0, 0))[:4] self.major, self.minor, self.micro, self.patch = padded self._resolution = len(components)
@property def components_full(self): """Generate full length component tuple for version. :rtype: `tuple` of `int` """ return self.major, self.minor, self.micro, self.patch @property def components(self): """Generate component tuple to initial resolution. :rtype: `tuple` of `int` """ return self.components_full[:self._resolution]
[docs] def bump(self, bump_type): """Bump a version string. :param str bump_type: Component to bump """ if bump_type == 'micro' and self._resolution < 3 \ or bump_type == 'patch' and self._resolution < 4: raise ValueError('Invalid bump_type %r for version %r' % (bump_type, self.components)) if bump_type == 'major': self.major += 1 self.micro = self.minor = self.patch = 0 elif bump_type == 'minor': self.minor += 1 self.micro = self.patch = 0 elif bump_type == 'micro': self.micro += 1 self.patch = 0 elif bump_type == 'patch': self.patch += 1 else: raise ValueError('Unknown bump_type %r' % bump_type) self.date = datetime.date.today()
[docs] def bump_major(self): """Bump major version component.""" self.bump('major')
[docs] def bump_minor(self): """Bump minor version component.""" self.bump('minor')
[docs] def bump_micro(self): """Bump micro version component.""" self.bump('micro')
[docs] def bump_patch(self): """Bump patch version component.""" self.bump('patch')
[docs] def as_dict(self): """Generate a dictionary of version components. :rtype: `dict` :return: Version as dictionary """ return dict(zip(VERSION_COMPS, self.components))
[docs] def as_dotted(self): """Generate a dotted version string. :rtype: `str` :return: Standard dotted version string """ return '.'.join(str(s) for s in self.components)
[docs] def as_hex(self): """Generate a hex version string. :rtype: `str` :return: Version as hex string """ return '0x' + ''.join('%02x' % n for n in self.components)
[docs] def as_libtool(self): """Generate a libtool version string. :rtype: `str` :return: Version as libtool string """ return '%i:%i' % (self.major * 10 + self.minor, 20 + self.micro)
[docs] def as_date(self): """Generate a ISO-8601 date string for release. :rtype: `str` :return: Version's release date as ISO-8601 date stamp """ return self.date.isoformat()
[docs] def as_tuple(self): """Generate a tuple of version components. :rtype: `int` :return: Version components as tuple """ return self.components
[docs] def as_web(self): """Generate a web UA-style string for release. :rtype: `str` :return: Version's string in web UA-style """ return '%s/%s' % (self.name, self.as_dotted())
@staticmethod
[docs] def display_types(): """Supported representation types. :rtype: `list` of `str` :return: Method names for representation types """ return [s[3:] for s in dir(Version) if s.startswith('as_')]
[docs] def display(self, display_format): """Display a version string. :param str display_format: Format to display version string in :rtype: `str` :return: Formatted version string """ return getattr(self, 'as_%s' % display_format)()
@staticmethod
[docs] def read(filename): """Read a version file. :param str filename: Version file to read :rtype: `Version` :return: New `Version` object representing file :raise exceptions.OSError: When ``filename`` doesn't exist :raise exceptions.ValueError: Unparsable version data """ with open(filename) as f: data = f.read().strip() match = re.search(r'This is (%s),? [vV]ersion (%s) \((%s)\)' % (VALID_PACKAGE, VALID_VERSION, VALID_DATE), data) if not match: raise ValueError('No valid version identifier in %r' % filename) name, version_str, date_str = match.groups() components = split_version(version_str) try: parsed = datetime.datetime.strptime(date_str, '%Y-%m-%d') except ValueError: parsed = datetime.datetime.strptime(date_str, '%d-%b-%Y') return Version(components, name, parsed.date())
[docs] def write(self, filename, file_type, shtool=False): """Write a version file. :param str filename: Version file to write :param str file_type: File type to write :param bool shtool: Write shtool_ compatible files :rtype: `bool` :return: `True` on write success .. _shtool: http://www.gnu.org/software/shtool/shtool.html """ data = vars(self) data.update({ 'now': datetime.datetime.now(), 'utcnow': datetime.datetime.utcnow(), 'filename': filename, 'dateobj': self.date, 'resolution': self._resolution, }) if shtool: # %d-%b-%Y, if %b wasn't locale dependent shtool_date = "%s-%s-%s" % (self.date.day, MONTHS[self.date.month - 1], self.date.year) data['magic'] = 'This is %s, Version %s (%s)' % (self.name, self.as_dotted(), shtool_date) else: data['magic'] = 'This is %s version %s (%s)' % (self.name, self.as_dotted(), self.as_date()) data.update(dict(zip(VERSION_COMPS, self.components))) data.update(dict([(k[3:], getattr(self, k)()) for k in dir(self) if k.startswith('as_')])) template = self.env.get_template('%s.jinja' % file_type) with open(filename, 'w') as f: f.write(template.render(data))
[docs]def split_version(version): """Split version string to components. :param str version: Version string :rtype: `tuple` of `int` :return: Components of version string :raise exceptions.ValueError: Invalid version string """ if not re.match('%s$' % VALID_VERSION, version): raise ValueError('Invalid version string %r' % version) return tuple(int(s) for s in version.split('.'))
[docs]def guess_type(filename): """Guess output type from filename. :param str filename: File to operate on """ suffix = os.path.splitext(filename)[1][1:] if suffix in Version.filetypes: file_type = suffix else: file_type = 'text' return file_type
OPTIONS = argparse.ArgumentParser(add_help=False) OPTIONS.add_argument('-d', '--display', default='dotted', choices=Version.display_types(), dest='display_format', metavar='dotted', help=_('display format for output')) OPTIONS.add_argument('filename', help=_('version file to operate on')) @APP.cmd(name='bump', help=_('bump version in given file'), parents=[OPTIONS, ]) @APP.cmd_arg('-t', '--type', choices=Version.filetypes, dest='file_type', metavar='text', help=_('define the file type used for version file')) @APP.cmd_arg('--shtool', action='store_true', help=_('write shtool compatible output')) @APP.cmd_arg('bump', default=None, nargs='?', choices=('major', 'minor', 'micro', 'patch'), help=_('bump type'))
[docs]def bump_version(display_format, filename, file_type, shtool, bump): """Bump version in existing file. :param str display_format: Format to display output in :param str filename: File to operate on :param str file_type: File type to produce :param bool shtool: Write shtool_ compatible files :param str bump: Component to bump .. _shtool: http://www.gnu.org/software/shtool/shtool.html """ if not file_type: file_type = guess_type(filename) try: version = Version.read(filename) except IOError as error: print(fail(error.args[1])) return errno.EEXIST if not os.path.exists(filename): print(fail(_('File not found'))) return errno.ENOENT if not bump: bump = VERSION_COMPS[len(version.components) - 1] version.bump(bump) version.write(filename, file_type, shtool) print(success(version.display(display_format)))
@APP.cmd(name='set', help=_('set version in given file'), parents=[OPTIONS, ]) @APP.cmd_arg('-t', '--type', choices=Version.filetypes, dest='file_type', metavar='text', help=_('define the file type used for version file')) @APP.cmd_arg('-n', '--name', default=os.path.basename(os.getenv('PWD')), metavar=os.path.basename(os.getenv('PWD')), action=ValidatingAction, help=_('package name for version(default from $PWD)')) @APP.cmd_arg('version_str', metavar='version', action=ValidatingAction, help='set to a specific version')
[docs]def set_version(display_format, filename, file_type, name, version_str): """Set version in new or existing file. :param str display_format: Format to display output in :param str filename: File to operate on :param str file_type: File type to produce :param str name: Project name used in output :param str version_str: Initial version string """ if not file_type: file_type = guess_type(filename) try: version = Version.read(filename) except IOError: version = Version() except ValueError as error: print(fail(error.args[0])) return errno.EEXIST if name: version.name = name version.set(version_str) version.write(filename, file_type) print(success(version.display(display_format)))
@APP.cmd(help=_('display version in given file'), parents=[OPTIONS, ])
[docs]def display(display_format, filename): """Display version in existing file. :param str display_format: Format to display output in :param str filename: File to operate on """ try: version = Version.read(filename) except IOError as error: print(fail(error.args[1])) return errno.EEXIST except ValueError as error: print(fail(error.args[0])) return errno.EIO print(success(version.display(display_format)))
[docs]def main(): """Main script entry point.""" APP.arg('--version', action='version', version='%%(prog)s %s' % _version.dotted) return APP.run()