summaryrefslogtreecommitdiff
path: root/tasks.py
diff options
context:
space:
mode:
Diffstat (limited to 'tasks.py')
-rw-r--r--tasks.py188
1 files changed, 188 insertions, 0 deletions
diff --git a/tasks.py b/tasks.py
new file mode 100644
index 0000000..e271c99
--- /dev/null
+++ b/tasks.py
@@ -0,0 +1,188 @@
+# -*- coding: utf-8 -*-
+import os
+import re
+import io
+from contextlib import contextmanager
+from datetime import datetime
+
+from invoke import task, run
+
+VERSION_FILE = 'pytest_catchlog.py'
+CHANGE_LOG_FILE = 'CHANGES.rst'
+
+
+def _path_abs_join(*nodes):
+ return os.path.abspath(os.path.join(os.path.dirname(__file__), *nodes))
+
+
+def _path_open(*nodes, **kwargs):
+ return io.open(_path_abs_join(*nodes), **kwargs)
+
+
+def _shell_quote(s):
+ """Quote given string to be suitable as input for bash as argument."""
+ if not s:
+ return "''"
+ if re.search(r'[^\w@%+=:,./-]', s) is None:
+ return s
+ return "'" + s.replace("'", "'\"'\"'") + "'"
+
+
+def _git_do(*commands, **kwargs):
+ """Execute arbitrary git commands."""
+ kwargs.setdefault('hide', 'out')
+ results = [run('git ' + command, **kwargs).stdout.strip('\n')
+ for command in commands]
+ return results if len(commands) > 1 else results[0]
+
+
+def _git_checkout(branch_name):
+ """Switches to the given branch name."""
+ return _git_do('checkout ' + _shell_quote(branch_name))
+
+
+@contextmanager
+def _git_work_on(branch_name):
+ """Work on given branch. Preserves current git branch."""
+ original_branch = _git_do('rev-parse --abbrev-ref HEAD')
+ try:
+ if original_branch != branch_name:
+ _git_checkout(branch_name)
+ yield
+ finally:
+ if original_branch and original_branch != branch_name:
+ _git_checkout(original_branch)
+
+
+def _version_find_existing():
+ """Returns set of existing versions in this repository.
+
+ This information is backed by previously used version tags
+ stored in the git repository.
+ """
+ git_tags = [y.strip() for y in _git_do('tag -l').split('\n')]
+
+ _version_re = re.compile(r'^v?(\d+)(?:\.(\d+)(?:\.(\d+))?)?$')
+ return {tuple(int(n) if n else 0 for n in m.groups())
+ for m in (_version_re.match(t) for t in git_tags if t) if m}
+
+
+def _version_find_latest():
+ """Returns the most recent used version number.
+
+ This information is backed by previously used version tags
+ stored in the git repository.
+ """
+ return max(_version_find_existing())
+
+
+def _version_guess_next(position='minor'):
+ """Guess next version.
+
+ A guess for the next version is determined by incrementing given
+ position or minor level position in latest existing version.
+ """
+ try:
+ latest_version = list(_version_find_latest())
+ except ValueError:
+ latest_version = [0, 0, 0]
+
+ position_index = {'major': 0, 'minor': 1, 'patch': 2}[position]
+ latest_version[position_index] += 1
+ latest_version[position_index + 1:] = [0] * (2 - position_index)
+ return tuple(latest_version)
+
+
+def _version_format(version):
+ """Return version in dotted string format."""
+ return '.'.join(str(x) for x in version)
+
+
+def _patch_file(file_path, line_callback):
+ """Patch given file with result from line callback.
+
+ Each line will be passed to the line callback.
+ The return value of the given callback will determine
+ the new content for the file.
+
+ :param str file_path:
+ The file to patch.
+ :param callable line_callback:
+ The patch function to run over each line.
+ :return:
+ Whenever the file has changed or not.
+ :rtype:
+ bool
+ """
+ new_file_content, file_changed = [], False
+ with _path_open(file_path) as in_stream:
+ for l in (x.strip('\n') for x in in_stream):
+ alt_lines = line_callback(l) or [l]
+ if alt_lines != [l]:
+ file_changed = True
+ new_file_content += (x + u'\n' for x in alt_lines)
+
+ new_file_name = file_path + '.new'
+ with _path_open(new_file_name, mode='w') as out_stream:
+ out_stream.writelines(new_file_content)
+ out_stream.flush()
+ os.fsync(out_stream.fileno())
+ os.rename(new_file_name, file_path)
+
+ return file_changed
+
+
+def _patch_version(new_version):
+ """Patch given version into version file."""
+ _patch_version_re = re.compile(r"""^(\s*__version__\s*=\s*(?:"|'))"""
+ r"""(?:[^'"]*)(?:("|')\s*)$""")
+
+ def __line_callback(line):
+ match = _patch_version_re.match(line)
+ if match:
+ line_head, line_tail = match.groups()
+ return [line_head + new_version + line_tail]
+ return _patch_file(VERSION_FILE, __line_callback)
+
+
+def _patch_change_log(new_version):
+ """Patch given version into change log file."""
+ def __line_callback(line):
+ if line == u'`Unreleased`_':
+ return [u'`{}`_'.format(new_version)]
+ elif line == u'Yet to be released.':
+ return [datetime.utcnow().strftime(u'Released on %F.')]
+ elif line == u'.. %UNRELEASED_SECTION%':
+ return [u'.. %UNRELEASED_SECTION%',
+ u'',
+ u'`Unreleased`_',
+ u'-------------',
+ u'',
+ u'Yet to be released.',
+ u'']
+ return _patch_file(CHANGE_LOG_FILE, __line_callback)
+
+
+@task()
+def mkrelease(position='minor'):
+ """Merge development state into Master Branch and tags a new Release."""
+ next_version = _version_format(_version_guess_next(position))
+ with _git_work_on('develop'):
+ patched_files = []
+ if _patch_version(next_version):
+ patched_files.append(VERSION_FILE)
+
+ if _patch_change_log(next_version):
+ patched_files.append(CHANGE_LOG_FILE)
+
+ if patched_files:
+ patched_files = ' '.join(_shell_quote(x) for x in patched_files)
+ _git_do('diff --color=always -- ' + patched_files,
+ ("commit -m 'Bump Version to {0}' -- {1}"
+ .format(next_version, patched_files)),
+ hide=None)
+
+ with _git_work_on('master'):
+ message = _shell_quote('Release {0}'.format(next_version))
+ _git_do('merge --no-ff --no-edit -m {0} develop'.format(message),
+ "tag -a -m {0} {1}".format(message, next_version))