--- /dev/null
+#!/usr/bin/env python
+############################################################################
+# Copyright (c) 2018, Valentin Lab
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution.
+# * Neither the name of the Securactive nor the
+# names of its contributors may be used to endorse or promote products
+# derived from this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL SECURACTIVE BE LIABLE FOR ANY
+# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+#
+# SPDX-License-Identifier: BSD-3-Clause
+#
+############################################################################
+
+from __future__ import print_function
+from __future__ import absolute_import
+
+import locale
+import re
+import os
+import os.path
+import sys
+import glob
+import textwrap
+import datetime
+import collections
+import traceback
+import contextlib
+import itertools
+import errno
+
+from subprocess import Popen, PIPE
+
+try:
+ import pystache
+except ImportError: ## pragma: no cover
+ pystache = None
+
+try:
+ import mako
+except ImportError: ## pragma: no cover
+ mako = None
+
+
+__version__ = "%%version%%" ## replaced by autogen.sh
+
+EBUG = None
+
+
+##
+## Platform and python compatibility
+##
+
+PY_VERSION = float("%d.%d" % sys.version_info[0:2])
+PY3 = PY_VERSION >= 3
+
+try:
+ basestring
+except NameError:
+ basestring = str ## pylint: disable=redefined-builtin
+
+WIN32 = sys.platform == 'win32'
+if WIN32:
+ PLT_CFG = {
+ 'close_fds': False,
+ }
+else:
+ PLT_CFG = {
+ 'close_fds': True,
+ }
+
+##
+##
+##
+
+if WIN32 and not PY3:
+
+ ## Sorry about the following, all this code is to ensure full
+ ## compatibility with python 2.7 under windows about sending unicode
+ ## command-line
+
+ import ctypes
+ import subprocess
+ import _subprocess
+ from ctypes import byref, windll, c_char_p, c_wchar_p, c_void_p, \
+ Structure, sizeof, c_wchar, WinError
+ from ctypes.wintypes import BYTE, WORD, LPWSTR, BOOL, DWORD, LPVOID, \
+ HANDLE
+
+
+ ##
+ ## Types
+ ##
+
+ CREATE_UNICODE_ENVIRONMENT = 0x00000400
+ LPCTSTR = c_char_p
+ LPTSTR = c_wchar_p
+ LPSECURITY_ATTRIBUTES = c_void_p
+ LPBYTE = ctypes.POINTER(BYTE)
+
+ class STARTUPINFOW(Structure):
+ _fields_ = [
+ ("cb", DWORD), ("lpReserved", LPWSTR),
+ ("lpDesktop", LPWSTR), ("lpTitle", LPWSTR),
+ ("dwX", DWORD), ("dwY", DWORD),
+ ("dwXSize", DWORD), ("dwYSize", DWORD),
+ ("dwXCountChars", DWORD), ("dwYCountChars", DWORD),
+ ("dwFillAtrribute", DWORD), ("dwFlags", DWORD),
+ ("wShowWindow", WORD), ("cbReserved2", WORD),
+ ("lpReserved2", LPBYTE), ("hStdInput", HANDLE),
+ ("hStdOutput", HANDLE), ("hStdError", HANDLE),
+ ]
+
+ LPSTARTUPINFOW = ctypes.POINTER(STARTUPINFOW)
+
+
+ class PROCESS_INFORMATION(Structure):
+ _fields_ = [
+ ("hProcess", HANDLE), ("hThread", HANDLE),
+ ("dwProcessId", DWORD), ("dwThreadId", DWORD),
+ ]
+
+ LPPROCESS_INFORMATION = ctypes.POINTER(PROCESS_INFORMATION)
+
+
+ class DUMMY_HANDLE(ctypes.c_void_p):
+
+ def __init__(self, *a, **kw):
+ super(DUMMY_HANDLE, self).__init__(*a, **kw)
+ self.closed = False
+
+ def Close(self):
+ if not self.closed:
+ windll.kernel32.CloseHandle(self)
+ self.closed = True
+
+ def __int__(self):
+ return self.value
+
+
+ CreateProcessW = windll.kernel32.CreateProcessW
+ CreateProcessW.argtypes = [
+ LPCTSTR, LPTSTR, LPSECURITY_ATTRIBUTES,
+ LPSECURITY_ATTRIBUTES, BOOL, DWORD, LPVOID, LPCTSTR,
+ LPSTARTUPINFOW, LPPROCESS_INFORMATION,
+ ]
+ CreateProcessW.restype = BOOL
+
+
+ ##
+ ## Patched functions/classes
+ ##
+
+ def CreateProcess(executable, args, _p_attr, _t_attr,
+ inherit_handles, creation_flags, env, cwd,
+ startup_info):
+ """Create a process supporting unicode executable and args for win32
+
+ Python implementation of CreateProcess using CreateProcessW for Win32
+
+ """
+
+ si = STARTUPINFOW(
+ dwFlags=startup_info.dwFlags,
+ wShowWindow=startup_info.wShowWindow,
+ cb=sizeof(STARTUPINFOW),
+ ## XXXvlab: not sure of the casting here to ints.
+ hStdInput=int(startup_info.hStdInput),
+ hStdOutput=int(startup_info.hStdOutput),
+ hStdError=int(startup_info.hStdError),
+ )
+
+ wenv = None
+ if env is not None:
+ ## LPCWSTR seems to be c_wchar_p, so let's say CWSTR is c_wchar
+ env = (unicode("").join([
+ unicode("%s=%s\0") % (k, v)
+ for k, v in env.items()])) + unicode("\0")
+ wenv = (c_wchar * len(env))()
+ wenv.value = env
+
+ pi = PROCESS_INFORMATION()
+ creation_flags |= CREATE_UNICODE_ENVIRONMENT
+
+ if CreateProcessW(executable, args, None, None,
+ inherit_handles, creation_flags,
+ wenv, cwd, byref(si), byref(pi)):
+ return (DUMMY_HANDLE(pi.hProcess), DUMMY_HANDLE(pi.hThread),
+ pi.dwProcessId, pi.dwThreadId)
+ raise WinError()
+
+
+ class Popen(subprocess.Popen):
+ """This superseeds Popen and corrects a bug in cPython 2.7 implem"""
+
+ def _execute_child(self, args, executable, preexec_fn, close_fds,
+ cwd, env, universal_newlines,
+ startupinfo, creationflags, shell, to_close,
+ p2cread, p2cwrite,
+ c2pread, c2pwrite,
+ errread, errwrite):
+ """Code from part of _execute_child from Python 2.7 (9fbb65e)
+
+ There are only 2 little changes concerning the construction of
+ the the final string in shell mode: we preempt the creation of
+ the command string when shell is True, because original function
+ will try to encode unicode args which we want to avoid to be able to
+ sending it as-is to ``CreateProcess``.
+
+ """
+ if not isinstance(args, subprocess.types.StringTypes):
+ args = subprocess.list2cmdline(args)
+
+ if startupinfo is None:
+ startupinfo = subprocess.STARTUPINFO()
+ if shell:
+ startupinfo.dwFlags |= _subprocess.STARTF_USESHOWWINDOW
+ startupinfo.wShowWindow = _subprocess.SW_HIDE
+ comspec = os.environ.get("COMSPEC", unicode("cmd.exe"))
+ args = unicode('{} /c "{}"').format(comspec, args)
+ if (_subprocess.GetVersion() >= 0x80000000 or
+ os.path.basename(comspec).lower() == "command.com"):
+ w9xpopen = self._find_w9xpopen()
+ args = unicode('"%s" %s') % (w9xpopen, args)
+ creationflags |= _subprocess.CREATE_NEW_CONSOLE
+
+ super(Popen, self)._execute_child(args, executable,
+ preexec_fn, close_fds, cwd, env, universal_newlines,
+ startupinfo, creationflags, False, to_close, p2cread,
+ p2cwrite, c2pread, c2pwrite, errread, errwrite)
+
+ _subprocess.CreateProcess = CreateProcess
+
+
+##
+## Help and usage strings
+##
+
+usage_msg = """
+ %(exname)s {-h|--help}
+ %(exname)s {-v|--version}
+ %(exname)s [--debug|-d] [REVLIST]"""
+
+description_msg = """\
+Run this command in a git repository to output a formatted changelog
+"""
+
+epilog_msg = """\
+%(exname)s uses a config file to filter meaningful commit or do some
+formatting in commit messages thanks to a config file.
+
+Config file location will be resolved in this order:
+ - in shell environment variable GITCHANGELOG_CONFIG_FILENAME
+ - in git configuration: ``git config gitchangelog.rc-path``
+ - as '.%(exname)s.rc' in the root of the current git repository
+
+"""
+
+
+##
+## Shell command helper functions
+##
+
+def stderr(msg):
+ print(msg, file=sys.stderr)
+
+
+def err(msg):
+ stderr("Error: " + msg)
+
+
+def warn(msg):
+ stderr("Warning: " + msg)
+
+
+def die(msg=None, errlvl=1):
+ if msg:
+ stderr(msg)
+ sys.exit(errlvl)
+
+
+class ShellError(Exception):
+
+ def __init__(self, msg, errlvl=None, command=None, out=None, err=None):
+ self.errlvl = errlvl
+ self.command = command
+ self.out = out
+ self.err = err
+ super(ShellError, self).__init__(msg)
+
+
+@contextlib.contextmanager
+def set_cwd(directory):
+ curdir = os.getcwd()
+ os.chdir(directory)
+ try:
+ yield
+ finally:
+ os.chdir(curdir)
+
+
+def format_last_exception(prefix=" | "):
+ """Format the last exception for display it in tests.
+
+ This allows to raise custom exception, without loosing the context of what
+ caused the problem in the first place:
+
+ >>> def f():
+ ... raise Exception("Something terrible happened")
+ >>> try: ## doctest: +ELLIPSIS
+ ... f()
+ ... except Exception:
+ ... formated_exception = format_last_exception()
+ ... raise ValueError('Oups, an error occured:\\n%s'
+ ... % formated_exception)
+ Traceback (most recent call last):
+ ...
+ ValueError: Oups, an error occured:
+ | Traceback (most recent call last):
+ ...
+ | Exception: Something terrible happened
+
+ """
+
+ return '\n'.join(
+ str(prefix + line)
+ for line in traceback.format_exc().strip().split('\n'))
+
+
+##
+## config file functions
+##
+
+_config_env = {
+ 'WIN32': WIN32,
+ 'PY3': PY3,
+}
+
+
+def available_in_config(f):
+ _config_env[f.__name__] = f
+ return f
+
+
+def load_config_file(filename, default_filename=None,
+ fail_if_not_present=True):
+ """Loads data from a config file."""
+
+ config = _config_env.copy()
+ for fname in [default_filename, filename]:
+ if fname and os.path.exists(fname):
+ if not os.path.isfile(fname):
+ die("config file path '%s' exists but is not a file !"
+ % (fname, ))
+ content = file_get_contents(fname)
+ try:
+ code = compile(content, fname, 'exec')
+ exec(code, config) ## pylint: disable=exec-used
+ except SyntaxError as e:
+ die('Syntax error in config file: %s\n%s'
+ 'File %s, line %i'
+ % (str(e),
+ (indent(e.text.rstrip(), " | ") + "\n") if e.text else "",
+ e.filename, e.lineno))
+ else:
+ if fail_if_not_present:
+ die('%s config file is not found and is required.' % (fname, ))
+
+ return config
+
+
+##
+## Text functions
+##
+
+@available_in_config
+class TextProc(object):
+
+ def __init__(self, fun):
+ self.fun = fun
+ if hasattr(fun, "__name__"):
+ self.__name__ = fun.__name__
+
+ def __call__(self, text):
+ return self.fun(text)
+
+ def __or__(self, value):
+ if isinstance(value, TextProc):
+ return TextProc(lambda text: value.fun(self.fun(text)))
+ import inspect
+ (_frame, filename, lineno, _function_name, lines, _index) = \
+ inspect.stack()[1]
+ raise SyntaxError("Invalid syntax in config file",
+ (filename, lineno, 0,
+ "Invalid chain with a non TextProc element %r:\n%s"
+ % (value, indent("".join(lines).strip(), " | "))))
+
+
+def set_if_empty(text, msg="No commit message."):
+ if len(text):
+ return text
+ return msg
+
+
+@TextProc
+def ucfirst(msg):
+ if len(msg) == 0:
+ return msg
+ return msg[0].upper() + msg[1:]
+
+
+@TextProc
+def final_dot(msg):
+ if len(msg) and msg[-1].isalnum():
+ return msg + "."
+ return msg
+
+
+def indent(text, chars=" ", first=None):
+ """Return text string indented with the given chars
+
+ >>> string = 'This is first line.\\nThis is second line\\n'
+
+ >>> print(indent(string, chars="| ")) # doctest: +NORMALIZE_WHITESPACE
+ | This is first line.
+ | This is second line
+ |
+
+ >>> print(indent(string, first="- ")) # doctest: +NORMALIZE_WHITESPACE
+ - This is first line.
+ This is second line
+
+
+ >>> string = 'This is first line.\\n\\nThis is second line'
+ >>> print(indent(string, first="- ")) # doctest: +NORMALIZE_WHITESPACE
+ - This is first line.
+ <BLANKLINE>
+ This is second line
+
+ """
+ if first:
+ first_line = text.split("\n")[0]
+ rest = '\n'.join(text.split("\n")[1:])
+ return '\n'.join([(first + first_line).rstrip(),
+ indent(rest, chars=chars)])
+ return '\n'.join([(chars + line).rstrip()
+ for line in text.split('\n')])
+
+
+def paragraph_wrap(text, regexp="\n\n"):
+ r"""Wrap text by making sure that paragraph are separated correctly
+
+ >>> string = 'This is first paragraph which is quite long don\'t you \
+ ... think ? Well, I think so.\n\nThis is second paragraph\n'
+
+ >>> print(paragraph_wrap(string)) # doctest: +NORMALIZE_WHITESPACE
+ This is first paragraph which is quite long don't you think ? Well, I
+ think so.
+ This is second paragraph
+
+ Notice that that each paragraph has been wrapped separately.
+
+ """
+ regexp = re.compile(regexp, re.MULTILINE)
+ return "\n".join("\n".join(textwrap.wrap(paragraph.strip()))
+ for paragraph in regexp.split(text)).strip()
+
+
+def curryfy(f):
+ return lambda *a, **kw: TextProc(lambda txt: f(txt, *a, **kw))
+
+## these are curryfied version of their lower case definition
+
+Indent = curryfy(indent)
+Wrap = curryfy(paragraph_wrap)
+ReSub = lambda p, r, **k: TextProc(lambda txt: re.sub(p, r, txt, **k))
+noop = TextProc(lambda txt: txt)
+strip = TextProc(lambda txt: txt.strip())
+SetIfEmpty = curryfy(set_if_empty)
+
+for _label in ("Indent", "Wrap", "ReSub", "noop", "final_dot",
+ "ucfirst", "strip", "SetIfEmpty"):
+ _config_env[_label] = locals()[_label]
+
+##
+## File
+##
+
+def file_get_contents(filename):
+ with open(filename) as f:
+ out = f.read()
+ if not PY3:
+ if not isinstance(out, unicode):
+ out = out.decode(_preferred_encoding)
+ ## remove encoding declaration (for some reason, python 2.7
+ ## don't like it).
+ out = re.sub(r"^(\s*#.*\s*)coding[:=]\s*([-\w.]+\s*;?\s*)",
+ r"\1", out, re.DOTALL)
+
+ return out
+
+
+def file_put_contents(filename, string):
+ """Write string to filename."""
+ if PY3:
+ fopen = open(filename, 'w', newline='')
+ else:
+ fopen = open(filename, 'wb')
+
+ with fopen as f:
+ f.write(string)
+
+
+##
+## Inferring revision
+##
+
+def _file_regex_match(filename, pattern, **kw):
+ if not os.path.isfile(filename):
+ raise IOError("Can't open file '%s'." % filename)
+ file_content = file_get_contents(filename)
+ match = re.search(pattern, file_content, **kw)
+ if match is None:
+ stderr("file content: %r" % file_content)
+ if isinstance(pattern, type(re.compile(''))):
+ pattern = pattern.pattern
+ raise ValueError(
+ "Regex %s did not match any substring in '%s'."
+ % (pattern, filename))
+ return match
+
+
+@available_in_config
+def FileFirstRegexMatch(filename, pattern):
+ def _call():
+ match = _file_regex_match(filename, pattern)
+ dct = match.groupdict()
+ if dct:
+ if "rev" not in dct:
+ warn("Named pattern used, but no one are named 'rev'. "
+ "Using full match.")
+ return match.group(0)
+ if dct['rev'] is None:
+ die("Named pattern used, but it was not valued.")
+ return dct['rev']
+ return match.group(0)
+ return _call
+
+
+@available_in_config
+def Caret(l):
+ def _call():
+ return "^%s" % eval_if_callable(l)
+ return _call
+##
+## System functions
+##
+
+## Note that locale.getpreferredencoding() does NOT follow
+## PYTHONIOENCODING by default, but ``sys.stdout.encoding`` does. In
+## PY2, ``sys.stdout.encoding`` without PYTHONIOENCODING set does not
+## get any values set in subshells. However, if _preferred_encoding
+## is not set to utf-8, it leads to encoding errors.
+_preferred_encoding = os.environ.get("PYTHONIOENCODING") or \
+ locale.getpreferredencoding()
+DEFAULT_GIT_LOG_ENCODING = 'utf-8'
+
+
+class Phile(object):
+ """File like API to read fields separated by any delimiters
+
+ It'll take care of file decoding to unicode.
+
+ This is an adaptor on a file object.
+
+ >>> if PY3:
+ ... from io import BytesIO
+ ... def File(s):
+ ... _obj = BytesIO()
+ ... _obj.write(s.encode(_preferred_encoding))
+ ... _obj.seek(0)
+ ... return _obj
+ ... else:
+ ... from cStringIO import StringIO as File
+
+ >>> f = Phile(File("a-b-c-d"))
+
+ Read provides an iterator:
+
+ >>> def show(l):
+ ... print(", ".join(l))
+ >>> show(f.read(delimiter="-"))
+ a, b, c, d
+
+ You can change the buffersize loaded into memory before outputing
+ your changes. It should not change the iterator output:
+
+ >>> f = Phile(File("é-à-ü-d"), buffersize=3)
+ >>> len(list(f.read(delimiter="-")))
+ 4
+
+ >>> f = Phile(File("foo-bang-yummy"), buffersize=3)
+ >>> show(f.read(delimiter="-"))
+ foo, bang, yummy
+
+ >>> f = Phile(File("foo-bang-yummy"), buffersize=1)
+ >>> show(f.read(delimiter="-"))
+ foo, bang, yummy
+
+ """
+
+ def __init__(self, filename, buffersize=4096, encoding=_preferred_encoding):
+ self._file = filename
+ self._buffersize = buffersize
+ self._encoding = encoding
+
+ def read(self, delimiter="\n"):
+ buf = ""
+ if PY3:
+ delimiter = delimiter.encode(_preferred_encoding)
+ buf = buf.encode(_preferred_encoding)
+ while True:
+ chunk = self._file.read(self._buffersize)
+ if not chunk:
+ yield buf.decode(self._encoding)
+ return
+ records = chunk.split(delimiter)
+ records[0] = buf + records[0]
+ for record in records[:-1]:
+ yield record.decode(self._encoding)
+ buf = records[-1]
+
+ def write(self, buf):
+ if PY3:
+ buf = buf.encode(self._encoding)
+ return self._file.write(buf)
+
+ def close(self):
+ return self._file.close()
+
+
+class Proc(Popen):
+
+ def __init__(self, command, env=None, encoding=_preferred_encoding):
+ super(Proc, self).__init__(
+ command, shell=True,
+ stdin=PIPE, stdout=PIPE, stderr=PIPE,
+ close_fds=PLT_CFG['close_fds'], env=env,
+ universal_newlines=False)
+
+ self.stdin = Phile(self.stdin, encoding=encoding)
+ self.stdout = Phile(self.stdout, encoding=encoding)
+ self.stderr = Phile(self.stderr, encoding=encoding)
+
+
+def cmd(command, env=None, shell=True):
+
+ p = Popen(command, shell=shell,
+ stdin=PIPE, stdout=PIPE, stderr=PIPE,
+ close_fds=PLT_CFG['close_fds'], env=env,
+ universal_newlines=False)
+ out, err = p.communicate()
+ return (
+ out.decode(getattr(sys.stdout, "encoding", None) or
+ _preferred_encoding),
+ err.decode(getattr(sys.stderr, "encoding", None) or
+ _preferred_encoding),
+ p.returncode)
+
+
+@available_in_config
+def wrap(command, ignore_errlvls=[0], env=None, shell=True):
+ """Wraps a shell command and casts an exception on unexpected errlvl
+
+ >>> wrap('/tmp/lsdjflkjf') # doctest: +ELLIPSIS +IGNORE_EXCEPTION_DETAIL
+ Traceback (most recent call last):
+ ...
+ ShellError: Wrapped command '/tmp/lsdjflkjf' exited with errorlevel 127.
+ stderr:
+ | /bin/sh: .../tmp/lsdjflkjf: not found
+
+ >>> print(wrap('echo hello'), end='')
+ hello
+
+ >>> print(wrap('echo hello && false'),
+ ... end='') # doctest: +ELLIPSIS +IGNORE_EXCEPTION_DETAIL
+ Traceback (most recent call last):
+ ...
+ ShellError: Wrapped command 'echo hello && false' exited with errorlevel 1.
+ stdout:
+ | hello
+
+ """
+
+ out, err, errlvl = cmd(command, env=env, shell=shell)
+
+ if errlvl not in ignore_errlvls:
+
+ formatted = []
+ if out:
+ if out.endswith('\n'):
+ out = out[:-1]
+ formatted.append("stdout:\n%s" % indent(out, "| "))
+ if err:
+ if err.endswith('\n'):
+ err = err[:-1]
+ formatted.append("stderr:\n%s" % indent(err, "| "))
+ msg = '\n'.join(formatted)
+
+ raise ShellError("Wrapped command %r exited with errorlevel %d.\n%s"
+ % (command, errlvl, indent(msg, chars=" ")),
+ errlvl=errlvl, command=command, out=out, err=err)
+ return out
+
+
+@available_in_config
+def swrap(command, **kwargs):
+ """Same as ``wrap(...)`` but strips the output."""
+
+ return wrap(command, **kwargs).strip()
+
+
+##
+## git information access
+##
+
+class SubGitObjectMixin(object):
+
+ def __init__(self, repos):
+ self._repos = repos
+
+ @property
+ def git(self):
+ """Simple delegation to ``repos`` original method."""
+ return self._repos.git
+
+
+GIT_FORMAT_KEYS = {
+ 'sha1': "%H",
+ 'sha1_short': "%h",
+ 'subject': "%s",
+ 'author_name': "%an",
+ 'author_email': "%ae",
+ 'author_date': "%ad",
+ 'author_date_timestamp': "%at",
+ 'committer_name': "%cn",
+ 'committer_date_timestamp': "%ct",
+ 'raw_body': "%B",
+ 'body': "%b",
+}
+
+GIT_FULL_FORMAT_STRING = "%x00".join(GIT_FORMAT_KEYS.values())
+
+REGEX_RFC822_KEY_VALUE = \
+ r'(^|\n)(?P<key>[A-Z]\w+(-\w+)*): (?P<value>[^\n]*(\n\s+[^\n]*)*)'
+REGEX_RFC822_POSTFIX = \
+ r'(%s)+$' % REGEX_RFC822_KEY_VALUE
+
+
+class GitCommit(SubGitObjectMixin):
+ r"""Represent a Git Commit and expose through its attribute many information
+
+ Let's create a fake GitRepos:
+
+ >>> from minimock import Mock
+ >>> repos = Mock("gitRepos")
+
+ Initialization:
+
+ >>> repos.git = Mock("gitRepos.git")
+ >>> repos.git.log.mock_returns_func = \
+ ... lambda *a, **kwargs: "\x00".join([{
+ ... 'sha1': "000000",
+ ... 'sha1_short': "000",
+ ... 'subject': SUBJECT,
+ ... 'author_name': "John Smith",
+ ... 'author_date': "Tue Feb 14 20:31:22 2017 +0700",
+ ... 'author_email': "john.smith@example.com",
+ ... 'author_date_timestamp': "0", ## epoch
+ ... 'committer_name': "Alice Wang",
+ ... 'committer_date_timestamp': "0", ## epoch
+ ... 'raw_body': "my subject\n\n%s" % BODY,
+ ... 'body': BODY,
+ ... }[key] for key in GIT_FORMAT_KEYS.keys()])
+ >>> repos.git.rev_list.mock_returns = "123456"
+
+ Query, by attributes or items:
+
+ >>> SUBJECT = "fee fie foh"
+ >>> BODY = "foo foo foo"
+
+ >>> head = GitCommit(repos, "HEAD")
+ >>> head.subject
+ Called gitRepos.git.log(...'HEAD'...)
+ 'fee fie foh'
+ >>> head.author_name
+ 'John Smith'
+
+ Notice that on the second call, there's no need to call again git log as
+ all the values have already been computed.
+
+ Trailer
+ =======
+
+ ``GitCommit`` offers a simple direct API to trailer values. These
+ are like RFC822's header value but are at the end of body:
+
+ >>> BODY = '''\
+ ... Stuff in the body
+ ... Change-id: 1234
+ ... Value-X: Supports multi
+ ... line values'''
+
+ >>> head = GitCommit(repos, "HEAD")
+ >>> head.trailer_change_id
+ Called gitRepos.git.log(...'HEAD'...)
+ '1234'
+ >>> head.trailer_value_x
+ 'Supports multi\nline values'
+
+ Notice how the multi-line value was unindented.
+ In case of multiple values, these are concatened in lists:
+
+ >>> BODY = '''\
+ ... Stuff in the body
+ ... Co-Authored-By: Bob
+ ... Co-Authored-By: Alice
+ ... Co-Authored-By: Jack
+ ... '''
+
+ >>> head = GitCommit(repos, "HEAD")
+ >>> head.trailer_co_authored_by
+ Called gitRepos.git.log(...'HEAD'...)
+ ['Bob', 'Alice', 'Jack']
+
+
+ Special values
+ ==============
+
+ Authors
+ -------
+
+ >>> BODY = '''\
+ ... Stuff in the body
+ ... Co-Authored-By: Bob
+ ... Co-Authored-By: Alice
+ ... Co-Authored-By: Jack
+ ... '''
+
+ >>> head = GitCommit(repos, "HEAD")
+ >>> head.author_names
+ Called gitRepos.git.log(...'HEAD'...)
+ ['Alice', 'Bob', 'Jack', 'John Smith']
+
+ Notice that they are printed in alphabetical order.
+
+ """
+
+ def __init__(self, repos, identifier):
+ super(GitCommit, self).__init__(repos)
+ self.identifier = identifier
+ self._trailer_parsed = False
+
+ def __getattr__(self, label):
+ """Completes commits attributes upon request."""
+ attrs = GIT_FORMAT_KEYS.keys()
+ if label not in attrs:
+ try:
+ return self.__dict__[label]
+ except KeyError:
+ if self._trailer_parsed:
+ raise AttributeError(label)
+
+ identifier = self.identifier
+
+ ## Compute only missing information
+ missing_attrs = [l for l in attrs if l not in self.__dict__]
+ ## some commit can be already fully specified (see ``mk_commit``)
+ if missing_attrs:
+ aformat = "%x00".join(GIT_FORMAT_KEYS[l]
+ for l in missing_attrs)
+ try:
+ ret = self.git.log([identifier, "--max-count=1",
+ "--pretty=format:%s" % aformat, "--"])
+ except ShellError:
+ if DEBUG:
+ raise
+ raise ValueError("Given commit identifier %r doesn't exists"
+ % self.identifier)
+ attr_values = ret.split("\x00")
+ for attr, value in zip(missing_attrs, attr_values):
+ setattr(self, attr, value.strip())
+
+ ## Let's interpret RFC822-like header keys that could be in the body
+ match = re.search(REGEX_RFC822_POSTFIX, self.body)
+ if match is not None:
+ pos = match.start()
+ postfix = self.body[pos:]
+ self.body = self.body[:pos]
+ for match in re.finditer(REGEX_RFC822_KEY_VALUE, postfix):
+ dct = match.groupdict()
+ key = dct["key"].replace("-", "_").lower()
+ if "\n" in dct["value"]:
+ first_line, remaining = dct["value"].split('\n', 1)
+ value = "%s\n%s" % (first_line,
+ textwrap.dedent(remaining))
+ else:
+ value = dct["value"]
+ try:
+ prev_value = self.__dict__["trailer_%s" % key]
+ except KeyError:
+ setattr(self, "trailer_%s" % key, value)
+ else:
+ setattr(self, "trailer_%s" % key,
+ prev_value + [value, ]
+ if isinstance(prev_value, list)
+ else [prev_value, value, ])
+ self._trailer_parsed = True
+ return getattr(self, label)
+
+ @property
+ def author_names(self):
+ return [re.sub(r'^([^<]+)<[^>]+>\s*$', r'\1', author).strip()
+ for author in self.authors]
+
+ @property
+ def authors(self):
+ co_authors = getattr(self, 'trailer_co_authored_by', [])
+ co_authors = co_authors if isinstance(co_authors, list) \
+ else [co_authors]
+ return sorted(co_authors +
+ ["%s <%s>" % (self.author_name, self.author_email)])
+
+ @property
+ def date(self):
+ d = datetime.datetime.utcfromtimestamp(
+ float(self.author_date_timestamp))
+ return d.strftime('%Y-%m-%d')
+
+ @property
+ def has_annotated_tag(self):
+ try:
+ self.git.rev_parse(['%s^{tag}' % self.identifier, "--"])
+ return True
+ except ShellError as e:
+ if e.errlvl != 128:
+ raise
+ return False
+
+ @property
+ def tagger_date_timestamp(self):
+ if not self.has_annotated_tag:
+ raise ValueError("Can't access 'tagger_date_timestamp' on commit without annotated tag.")
+ tagger_date_utc = self.git.for_each_ref(
+ 'refs/tags/%s' % self.identifier, format='%(taggerdate:raw)')
+ return tagger_date_utc.split(" ", 1)[0]
+
+ @property
+ def tagger_date(self):
+ d = datetime.datetime.utcfromtimestamp(
+ float(self.tagger_date_timestamp))
+ return d.strftime('%Y-%m-%d')
+
+ def __le__(self, value):
+ if not isinstance(value, GitCommit):
+ value = self._repos.commit(value)
+ try:
+ self.git.merge_base(value.sha1, is_ancestor=self.sha1)
+ return True
+ except ShellError as e:
+ if e.errlvl != 1:
+ raise
+ return False
+
+ def __lt__(self, value):
+ if not isinstance(value, GitCommit):
+ value = self._repos.commit(value)
+ return self <= value and self != value
+
+ def __eq__(self, value):
+ if not isinstance(value, GitCommit):
+ value = self._repos.commit(value)
+ return self.sha1 == value.sha1
+
+ def __hash__(self):
+ return hash(self.sha1)
+
+ def __repr__(self):
+ return "<%s %r>" % (self.__class__.__name__, self.identifier)
+
+
+def normpath(path, cwd=None):
+ """path can be absolute or relative, if relative it uses the cwd given as
+ param.
+
+ """
+ if os.path.isabs(path):
+ return path
+ cwd = cwd if cwd else os.getcwd()
+ return os.path.normpath(os.path.join(cwd, path))
+
+
+class GitConfig(SubGitObjectMixin):
+ """Interface to config values of git
+
+ Let's create a fake GitRepos:
+
+ >>> from minimock import Mock
+ >>> repos = Mock("gitRepos")
+
+ Initialization:
+
+ >>> cfg = GitConfig(repos)
+
+ Query, by attributes or items:
+
+ >>> repos.git.config.mock_returns = "bar"
+ >>> cfg.foo
+ Called gitRepos.git.config('foo')
+ 'bar'
+ >>> cfg["foo"]
+ Called gitRepos.git.config('foo')
+ 'bar'
+ >>> cfg.get("foo")
+ Called gitRepos.git.config('foo')
+ 'bar'
+ >>> cfg["foo.wiz"]
+ Called gitRepos.git.config('foo.wiz')
+ 'bar'
+
+ Notice that you can't use attribute search in subsection as ``cfg.foo.wiz``
+ That's because in git config files, you can have a value attached to
+ an element, and this element can also be a section.
+
+ Nevertheless, you can do:
+
+ >>> getattr(cfg, "foo.wiz")
+ Called gitRepos.git.config('foo.wiz')
+ 'bar'
+
+ Default values
+ --------------
+
+ get item, and getattr default values can be used:
+
+ >>> del repos.git.config.mock_returns
+ >>> repos.git.config.mock_raises = ShellError('Key not found',
+ ... errlvl=1, out="", err="")
+
+ >>> getattr(cfg, "foo", "default")
+ Called gitRepos.git.config('foo')
+ 'default'
+
+ >>> cfg["foo"] ## doctest: +ELLIPSIS
+ Traceback (most recent call last):
+ ...
+ KeyError: 'foo'
+
+ >>> getattr(cfg, "foo") ## doctest: +ELLIPSIS
+ Traceback (most recent call last):
+ ...
+ AttributeError...
+
+ >>> cfg.get("foo", "default")
+ Called gitRepos.git.config('foo')
+ 'default'
+
+ >>> print("%r" % cfg.get("foo"))
+ Called gitRepos.git.config('foo')
+ None
+
+ """
+
+ def __init__(self, repos):
+ super(GitConfig, self).__init__(repos)
+
+ def __getattr__(self, label):
+ try:
+ res = self.git.config(label)
+ except ShellError as e:
+ if e.errlvl == 1 and e.out == "":
+ raise AttributeError("key %r is not found in git config."
+ % label)
+ raise
+ return res
+
+ def get(self, label, default=None):
+ return getattr(self, label, default)
+
+ def __getitem__(self, label):
+ try:
+ return getattr(self, label)
+ except AttributeError:
+ raise KeyError(label)
+
+
+class GitCmd(SubGitObjectMixin):
+
+ def __getattr__(self, label):
+ label = label.replace("_", "-")
+
+ def dir_swrap(command, **kwargs):
+ with set_cwd(self._repos._orig_path):
+ return swrap(command, **kwargs)
+
+ def method(*args, **kwargs):
+ if (len(args) == 1 and not isinstance(args[0], basestring)):
+ return dir_swrap(
+ ['git', label, ] + args[0],
+ shell=False,
+ env=kwargs.get("env", None))
+ cli_args = []
+ for key, value in kwargs.items():
+ cli_key = (("-%s" if len(key) == 1 else "--%s")
+ % key.replace("_", "-"))
+ if isinstance(value, bool):
+ cli_args.append(cli_key)
+ else:
+ cli_args.append(cli_key)
+ cli_args.append(value)
+
+ cli_args.extend(args)
+
+ return dir_swrap(['git', label, ] + cli_args, shell=False)
+ return method
+
+
+class GitRepos(object):
+
+ def __init__(self, path):
+
+ ## Saving this original path to ensure all future git commands
+ ## will be done from this location.
+ self._orig_path = os.path.abspath(path)
+
+ ## verify ``git`` command is accessible:
+ try:
+ self._git_version = self.git.version()
+ except ShellError:
+ if DEBUG:
+ raise
+ raise EnvironmentError(
+ "Required ``git`` command not found or broken in $PATH. "
+ "(calling ``git version`` failed.)")
+
+ ## verify that we are in a git repository
+ try:
+ self.git.remote()
+ except ShellError:
+ if DEBUG:
+ raise
+ raise EnvironmentError(
+ "Not in a git repository. (calling ``git remote`` failed.)")
+
+ self.bare = self.git.rev_parse(is_bare_repository=True) == "true"
+ self.toplevel = (None if self.bare else
+ self.git.rev_parse(show_toplevel=True))
+ self.gitdir = normpath(self.git.rev_parse(git_dir=True),
+ cwd=self._orig_path)
+
+ @classmethod
+ def create(cls, directory, *args, **kwargs):
+ os.mkdir(directory)
+ return cls.init(directory, *args, **kwargs)
+
+ @classmethod
+ def init(cls, directory, user=None, email=None):
+ with set_cwd(directory):
+ wrap("git init .")
+ self = cls(directory)
+ if user:
+ self.git.config("user.name", user)
+ if email:
+ self.git.config("user.email", email)
+ return self
+
+ def commit(self, identifier):
+ return GitCommit(self, identifier)
+
+ @property
+ def git(self):
+ return GitCmd(self)
+
+ @property
+ def config(self):
+ return GitConfig(self)
+
+ def tags(self, contains=None):
+ """String list of repository's tag names
+
+ Current tag order is committer date timestamp of tagged commit.
+ No firm reason for that, and it could change in future version.
+
+ """
+ if contains:
+ tags = self.git.tag(contains=contains).split("\n")
+ else:
+ tags = self.git.tag().split("\n")
+ ## Should we use new version name sorting ? refering to :
+ ## ``git tags --sort -v:refname`` in git version >2.0.
+ ## Sorting and reversing with command line is not available on
+ ## git version <2.0
+ return sorted([self.commit(tag) for tag in tags if tag != ''],
+ key=lambda x: int(x.committer_date_timestamp))
+
+ def log(self, includes=["HEAD", ], excludes=[], include_merge=True,
+ encoding=_preferred_encoding):
+ """Reverse chronological list of git repository's commits
+
+ Note: rev lists can be GitCommit instance list or identifier list.
+
+ """
+
+ refs = {'includes': includes,
+ 'excludes': excludes}
+ for ref_type in ('includes', 'excludes'):
+ for idx, ref in enumerate(refs[ref_type]):
+ if not isinstance(ref, GitCommit):
+ refs[ref_type][idx] = self.commit(ref)
+
+ ## --topo-order: don't mix commits from separate branches.
+ plog = Proc("git log --stdin -z --topo-order --pretty=format:%s %s --"
+ % (GIT_FULL_FORMAT_STRING,
+ '--no-merges' if not include_merge else ''),
+ encoding=encoding)
+ for ref in refs["includes"]:
+ plog.stdin.write("%s\n" % ref.sha1)
+
+ for ref in refs["excludes"]:
+ plog.stdin.write("^%s\n" % ref.sha1)
+ plog.stdin.close()
+
+ def mk_commit(dct):
+ """Creates an already set commit from a dct"""
+ c = self.commit(dct["sha1"])
+ for k, v in dct.items():
+ setattr(c, k, v)
+ return c
+
+ values = plog.stdout.read("\x00")
+
+ try:
+ while True: ## next(values) will eventualy raise a StopIteration
+ yield mk_commit(dict([(key, next(values))
+ for key in GIT_FORMAT_KEYS]))
+ except StopIteration:
+ pass ## since 3.7, we are not allowed anymore to trickle down
+ ## StopIteration.
+ finally:
+ plog.stdout.close()
+ plog.stderr.close()
+
+
+def first_matching(section_regexps, string):
+ for section, regexps in section_regexps:
+ if regexps is None:
+ return section
+ for regexp in regexps:
+ if re.search(regexp, string) is not None:
+ return section
+
+
+def ensure_template_file_exists(label, template_name):
+ """Return template file path given a label hint and the template name
+
+ Template name can be either a filename with full path,
+ if this is the case, the label is of no use.
+
+ If ``template_name`` does not refer to an existing file,
+ then ``label`` is used to find a template file in the
+ the bundled ones.
+
+ """
+
+ try:
+ template_path = GitRepos(os.getcwd()).config.get(
+ "gitchangelog.template-path")
+ except ShellError as e:
+ stderr(
+ "Error parsing git config: %s."
+ " Won't be able to read 'template-path' if defined."
+ % (str(e)))
+ template_path = None
+
+ if template_path:
+ path_file = path_label = template_path
+ else:
+ path_file = os.getcwd()
+ path_label = os.path.join(os.path.dirname(os.path.realpath(__file__)),
+ "templates", label)
+
+ for ftn in [os.path.join(path_file, template_name),
+ os.path.join(path_label, "%s.tpl" % template_name)]:
+ if os.path.isfile(ftn):
+ return ftn
+
+ templates = glob.glob(os.path.join(path_label, "*.tpl"))
+ if len(templates) > 0:
+ msg = ("These are the available %s templates:" % label)
+ msg += "\n - " + \
+ "\n - ".join(os.path.basename(f).split(".")[0]
+ for f in templates)
+ msg += "\nTemplates are located in %r" % path_label
+ else:
+ msg = "No available %s templates found in %r." \
+ % (label, path_label)
+ die("Error: Invalid %s template name %r.\n" % (label, template_name) +
+ "%s" % msg)
+
+
+##
+## Output Engines
+##
+
+@available_in_config
+def rest_py(data, opts={}):
+ """Returns ReStructured Text changelog content from data"""
+
+ def rest_title(label, char="="):
+ return (label.strip() + "\n") + (char * len(label) + "\n")
+
+ def render_version(version):
+ title = "%s (%s)" % (version["tag"], version["date"]) \
+ if version["tag"] else \
+ opts["unreleased_version_label"]
+ s = rest_title(title, char="-")
+
+ sections = version["sections"]
+ nb_sections = len(sections)
+ for section in sections:
+
+ section_label = section["label"] if section.get("label", None) \
+ else "Other"
+
+ if not (section_label == "Other" and nb_sections == 1):
+ s += "\n" + rest_title(section_label, "~")
+
+ for commit in section["commits"]:
+ s += render_commit(commit)
+ return s
+
+ def render_commit(commit, opts=opts):
+ subject = commit["subject"]
+ subject += " [%s]" % (", ".join(commit["authors"]), )
+
+ entry = indent('\n'.join(textwrap.wrap(subject)),
+ first="- ").strip() + "\n"
+
+ if commit["body"]:
+ entry += "\n" + indent(commit["body"])
+ entry += "\n"
+
+ return entry
+
+ if data["title"]:
+ yield rest_title(data["title"], char="=") + "\n\n"
+
+ for version in data["versions"]:
+ if len(version["sections"]) > 0:
+ yield render_version(version) + "\n\n"
+
+
+## formatter engines
+
+if pystache:
+
+ @available_in_config
+ def mustache(template_name):
+ """Return a callable that will render a changelog data structure
+
+ returned callable must take 2 arguments ``data`` and ``opts``.
+
+ """
+ template_path = ensure_template_file_exists("mustache", template_name)
+
+ template = file_get_contents(template_path)
+
+ def stuffed_versions(versions, opts):
+ for version in versions:
+ title = "%s (%s)" % (version["tag"], version["date"]) \
+ if version["tag"] else \
+ opts["unreleased_version_label"]
+ version["label"] = title
+ version["label_chars"] = list(version["label"])
+ for section in version["sections"]:
+ section["label_chars"] = list(section["label"])
+ section["display_label"] = \
+ not (section["label"] == "Other" and
+ len(version["sections"]) == 1)
+ for commit in section["commits"]:
+ commit["author_names_joined"] = ", ".join(
+ commit["authors"])
+ commit["body_indented"] = indent(commit["body"])
+ yield version
+
+ def renderer(data, opts):
+
+ ## mustache is very simple so we need to add some intermediate
+ ## values
+ data["general_title"] = True if data["title"] else False
+ data["title_chars"] = list(data["title"]) if data["title"] else []
+
+ data["versions"] = stuffed_versions(data["versions"], opts)
+
+ return pystache.render(template, data)
+
+ return renderer
+
+else:
+
+ @available_in_config
+ def mustache(template_name): ## pylint: disable=unused-argument
+ die("Required 'pystache' python module not found.")
+
+
+if mako:
+
+ import mako.template ## pylint: disable=wrong-import-position
+
+ mako_env = dict((f.__name__, f) for f in (ucfirst, indent, textwrap,
+ paragraph_wrap))
+
+ @available_in_config
+ def makotemplate(template_name):
+ """Return a callable that will render a changelog data structure
+
+ returned callable must take 2 arguments ``data`` and ``opts``.
+
+ """
+ template_path = ensure_template_file_exists("mako", template_name)
+
+ template = mako.template.Template(filename=template_path)
+
+ def renderer(data, opts):
+ kwargs = mako_env.copy()
+ kwargs.update({"data": data,
+ "opts": opts})
+ return template.render(**kwargs)
+
+ return renderer
+
+else:
+
+ @available_in_config
+ def makotemplate(template_name): ## pylint: disable=unused-argument
+ die("Required 'mako' python module not found.")
+
+
+##
+## Publish action
+##
+
+@available_in_config
+def stdout(content):
+ for chunk in content:
+ safe_print(chunk)
+@available_in_config
+def FileInsertAtFirstRegexMatch(filename, pattern, flags=0,
+ idx=lambda m: m.start()):
+
+ def write_content(f, content):
+ for content_line in content:
+ f.write(content_line)
+
+ def _wrapped(content):
+ index = idx(_file_regex_match(filename, pattern, flags=flags))
+ offset = 0
+ new_offset = 0
+ postfix = False
+
+ with open(filename + "~", "w") as dst:
+ with open(filename, "r") as src:
+ for line in src:
+ if postfix:
+ dst.write(line)
+ continue
+ new_offset = offset + len(line)
+ if new_offset < index:
+ offset = new_offset
+ dst.write(line)
+ continue
+ dst.write(line[0:index - offset])
+ write_content(dst, content)
+ dst.write(line[index - offset:])
+ postfix = True
+ if not postfix:
+ write_content(dst, content)
+ if WIN32:
+ os.remove(filename)
+ os.rename(filename + "~", filename)
+
+ return _wrapped
+
+
+@available_in_config
+def FileRegexSubst(filename, pattern, replace, flags=0):
+
+ replace = re.sub(r'\\([0-9+])', r'\\g<\1>', replace)
+
+ def _wrapped(content):
+ src = file_get_contents(filename)
+ ## Protect replacement pattern against the following expansion of '\o'
+ src = re.sub(
+ pattern,
+ replace.replace(r'\o', "".join(content).replace('\\', '\\\\')),
+ src, flags=flags)
+ if not PY3:
+ src = src.encode(_preferred_encoding)
+ file_put_contents(filename, src)
+
+ return _wrapped
+
+
+##
+## Data Structure
+##
+
+def versions_data_iter(repository, revlist=None,
+ ignore_regexps=[],
+ section_regexps=[(None, '')],
+ tag_filter_regexp=r"\d+\.\d+(\.\d+)?",
+ include_merge=True,
+ body_process=lambda x: x,
+ subject_process=lambda x: x,
+ log_encoding=DEFAULT_GIT_LOG_ENCODING,
+ warn=warn, ## Mostly used for test
+ ):
+ """Returns an iterator through versions data structures
+
+ (see ``gitchangelog.rc.reference`` file for more info)
+
+ :param repository: target ``GitRepos`` object
+ :param revlist: list of strings that git log understands as revlist
+ :param ignore_regexps: list of regexp identifying ignored commit messages
+ :param section_regexps: regexps identifying sections
+ :param tag_filter_regexp: regexp to match tags used as version
+ :param include_merge: whether to include merge commits in the log or not
+ :param body_process: text processing object to apply to body
+ :param subject_process: text processing object to apply to subject
+ :param log_encoding: the encoding used in git logs
+ :param warn: callable to output warnings, mocked by tests
+
+ :returns: iterator of versions data_structures
+
+ """
+
+ revlist = revlist or []
+
+ ## Hash to speedup lookups
+ versions_done = {}
+ excludes = [rev[1:]
+ for rev in repository.git.rev_parse([
+ "--rev-only", ] + revlist + ["--", ]).split("\n")
+ if rev.startswith("^")] if revlist else []
+
+ revs = repository.git.rev_list(*revlist).split("\n") if revlist else []
+ revs = [rev for rev in revs if rev != ""]
+
+ if revlist and not revs:
+ die("No commits matching given revlist: %s" % (" ".join(revlist), ))
+
+ tags = [tag
+ for tag in repository.tags(contains=revs[-1] if revs else None)
+ if re.match(tag_filter_regexp, tag.identifier)]
+
+ tags.append(repository.commit("HEAD"))
+
+ if revlist:
+ max_rev = repository.commit(revs[0])
+ new_tags = []
+ for tag in tags:
+ new_tags.append(tag)
+ if max_rev <= tag:
+ break
+ tags = new_tags
+ else:
+ max_rev = tags[-1]
+
+ section_order = [k for k, _v in section_regexps]
+
+ tags = list(reversed(tags))
+
+ ## Get the changes between tags (releases)
+ for idx, tag in enumerate(tags):
+
+ ## New version
+ current_version = {
+ "date": tag.tagger_date if tag.has_annotated_tag else tag.date,
+ "commit_date": tag.date,
+ "tagger_date": tag.tagger_date if tag.has_annotated_tag else None,
+ "tag": tag.identifier if tag.identifier != "HEAD" else None,
+ "commit": tag,
+ }
+
+ sections = collections.defaultdict(list)
+ commits = repository.log(
+ includes=[min(tag, max_rev)],
+ excludes=tags[idx + 1:] + excludes,
+ include_merge=include_merge,
+ encoding=log_encoding)
+
+ for commit in commits:
+ if any(re.search(pattern, commit.subject) is not None
+ for pattern in ignore_regexps):
+ continue
+
+ matched_section = first_matching(section_regexps, commit.subject)
+
+ ## Finally storing the commit in the matching section
+
+ sections[matched_section].append({
+ "author": commit.author_name,
+ "authors": commit.author_names,
+ "subject": subject_process(commit.subject),
+ "body": body_process(commit.body),
+ "commit": commit,
+ })
+
+ ## Flush current version
+ current_version["sections"] = [{"label": k, "commits": sections[k]}
+ for k in section_order
+ if k in sections]
+ if len(current_version["sections"]) != 0:
+ yield current_version
+ versions_done[tag] = current_version
+
+
+def changelog(output_engine=rest_py,
+ unreleased_version_label="unreleased",
+ warn=warn, ## Mostly used for test
+ **kwargs):
+ """Returns a string containing the changelog of given repository
+
+ This function returns a string corresponding to the template rendered with
+ the changelog data tree.
+
+ (see ``gitchangelog.rc.sample`` file for more info)
+
+ For an exact list of arguments, see the arguments of
+ ``versions_data_iter(..)``.
+
+ :param unreleased_version_label: version label for untagged commits
+ :param output_engine: callable to render the changelog data
+ :param warn: callable to output warnings, mocked by tests
+
+ :returns: content of changelog
+
+ """
+
+ opts = {
+ 'unreleased_version_label': unreleased_version_label,
+ }
+
+ ## Setting main container of changelog elements
+ title = None if kwargs.get("revlist") else "Changelog"
+ data = {"title": title,
+ "versions": []}
+
+ versions = versions_data_iter(warn=warn, **kwargs)
+
+ ## poke once in versions to know if there's at least one:
+ try:
+ first_version = next(versions)
+ except StopIteration:
+ warn("Empty changelog. No commits were elected to be used as entry.")
+ data["versions"] = []
+ else:
+ data["versions"] = itertools.chain([first_version], versions)
+
+ return output_engine(data=data, opts=opts)
+
+##
+## Manage obsolete options
+##
+
+_obsolete_options_managers = []
+
+
+def obsolete_option_manager(fun):
+ _obsolete_options_managers.append(fun)
+
+
+@obsolete_option_manager
+def obsolete_replace_regexps(config):
+ """This option was superseeded by the ``subject_process`` option.
+
+ Each regex replacement you had could be translated in a
+ ``ReSub(pattern, replace)`` in the ``subject_process`` pipeline.
+
+ """
+ if "replace_regexps" in config:
+ for pattern, replace in config["replace_regexps"].items():
+ config["subject_process"] = \
+ ReSub(pattern, replace) | \
+ config.get("subject_process", ucfirst | final_dot)
+
+
+@obsolete_option_manager
+def obsolete_body_split_regexp(config):
+ """This option was superseeded by the ``body_process`` option.
+
+ The split regex can now be sent as a ``Wrap(regex)`` text process
+ instruction in the ``body_process`` pipeline.
+
+ """
+ if "body_split_regex" in config:
+ config["body_process"] = Wrap(config["body_split_regex"]) | \
+ config.get("body_process", noop)
+
+
+def manage_obsolete_options(config):
+ for man in _obsolete_options_managers:
+ man(config)
+
+
+##
+## Command line parsing
+##
+
+def parse_cmd_line(usage, description, epilog, exname, version):
+
+ import argparse
+ kwargs = dict(usage=usage,
+ description=description,
+ epilog="\n" + epilog,
+ prog=exname,
+ formatter_class=argparse.RawTextHelpFormatter)
+
+ try:
+ parser = argparse.ArgumentParser(version=version, **kwargs)
+ except TypeError: ## compat with argparse from python 3.4
+ parser = argparse.ArgumentParser(**kwargs)
+ parser.add_argument('-v', '--version',
+ help="show program's version number and exit",
+ action="version", version=version)
+
+ parser.add_argument('-d', '--debug',
+ help="Enable debug mode (show full tracebacks).",
+ action="store_true", dest="debug")
+ parser.add_argument('revlist', nargs='*', action="store", default=[])
+
+ ## Remove "show" as first argument for compatibility reason.
+
+ argv = []
+ for i, arg in enumerate(sys.argv[1:]):
+ if arg.startswith("-"):
+ argv.append(arg)
+ continue
+ if arg == "show":
+ warn("'show' positional argument is deprecated.")
+ argv += sys.argv[i + 2:]
+ break
+ else:
+ argv += sys.argv[i + 1:]
+ break
+
+ return parser.parse_args(argv)
+
+
+eval_if_callable = lambda v: v() if callable(v) else v
+
+
+def get_revision(repository, config, opts):
+ if opts.revlist:
+ revs = opts.revlist
+ else:
+ revs = config.get("revs")
+ if revs:
+ revs = eval_if_callable(revs)
+ if not isinstance(revs, list):
+ die("Invalid type for 'revs' in config file. "
+ "A 'list' type is required, and a %r was given."
+ % type(revs).__name__)
+ revs = [eval_if_callable(rev)
+ for rev in revs]
+ else:
+ revs = []
+
+ for rev in revs:
+ if not isinstance(rev, basestring):
+ die("Invalid type for revision in revs list from config file. "
+ "'str' type is required, and a %r was given."
+ % type(rev).__name__)
+ try:
+ repository.git.rev_parse([rev, "--rev_only", "--"])
+ except ShellError:
+ if DEBUG:
+ raise
+ die("Revision %r is not valid." % rev)
+
+ if revs == ["HEAD", ]:
+ return []
+ return revs
+
+
+def get_log_encoding(repository, config):
+
+ log_encoding = config.get("log_encoding", None)
+ if log_encoding is None:
+ try:
+ log_encoding = repository.config.get("i18n.logOuputEncoding")
+ except ShellError as e:
+ warn(
+ "Error parsing git config: %s."
+ " Couldn't check if 'i18n.logOuputEncoding' was set."
+ % (str(e)))
+
+ ## Final defaults coming from git defaults
+ return log_encoding or DEFAULT_GIT_LOG_ENCODING
+
+
+##
+## Config Manager
+##
+
+class Config(dict):
+
+ def __getitem__(self, label):
+ if label not in self.keys():
+ die("Missing value in config file for key '%s'." % label)
+ return super(Config, self).__getitem__(label)
+
+
+##
+## Safe print
+##
+
+def safe_print(content):
+ if not PY3:
+ if isinstance(content, unicode):
+ content = content.encode(_preferred_encoding)
+
+ try:
+ print(content, end='')
+ sys.stdout.flush()
+ except UnicodeEncodeError:
+ if DEBUG:
+ raise
+ ## XXXvlab: should use $COLUMNS in bash and for windows:
+ ## http://stackoverflow.com/questions/14978548
+ stderr(paragraph_wrap(textwrap.dedent("""\
+ UnicodeEncodeError:
+ There was a problem outputing the resulting changelog to
+ your console.
+
+ This probably means that the changelog contains characters
+ that can't be translated to characters in your current charset
+ (%s).
+ """) % sys.stdout.encoding))
+ if WIN32 and PY_VERSION < 3.6 and sys.stdout.encoding != 'utf-8':
+ ## As of PY 3.6, encoding is now ``utf-8`` regardless of
+ ## PYTHONIOENCODING
+ ## https://www.python.org/dev/peps/pep-0528/
+ stderr(" You might want to try to fix that by setting "
+ "PYTHONIOENCODING to 'utf-8'.")
+ exit(1)
+ except IOError as e:
+ if e.errno == 0 and not PY3 and WIN32:
+ ## Yes, had a strange IOError Errno 0 after outputing string
+ ## that contained UTF-8 chars on Windows and PY2.7
+ pass ## Ignoring exception
+ elif ((WIN32 and e.errno == 22) or ## Invalid argument
+ (not WIN32 and e.errno == errno.EPIPE)): ## Broken Pipe
+ ## Nobody is listening anymore to stdout it seems. Let's bailout.
+ if PY3:
+ try:
+ ## Called only to generate exception and have a chance at
+ ## ignoring it. Otherwise this happens upon exit, and gets
+ ## some error message printed on stderr.
+ sys.stdout.close()
+ except BrokenPipeError: ## expected outcome on linux
+ pass
+ except OSError as e2:
+ if e2.errno != 22: ## expected outcome on WIN32
+ raise
+ ## Yay ! stdout is closed we can now exit safely.
+ exit(0)
+ else:
+ raise
+
+
+##
+## Main
+##
+
+def main():
+
+ global DEBUG
+ ## Basic environment infos
+
+ reference_config = os.path.join(
+ os.path.dirname(os.path.realpath(__file__)),
+ "gitchangelog.rc.reference")
+
+ basename = os.path.basename(sys.argv[0])
+ if basename.endswith(".py"):
+ basename = basename[:-3]
+
+ debug_varname = "DEBUG_%s" % basename.upper()
+ DEBUG = os.environ.get(debug_varname, False)
+
+ i = lambda x: x % {'exname': basename}
+
+ opts = parse_cmd_line(usage=i(usage_msg),
+ description=i(description_msg),
+ epilog=i(epilog_msg),
+ exname=basename,
+ version=__version__)
+ DEBUG = DEBUG or opts.debug
+
+ try:
+ repository = GitRepos(".")
+ except EnvironmentError as e:
+ if DEBUG:
+ raise
+ try:
+ die(str(e))
+ except Exception as e2:
+ die(repr(e2))
+
+ try:
+ gc_rc = repository.config.get("gitchangelog.rc-path")
+ except ShellError as e:
+ stderr(
+ "Error parsing git config: %s."
+ " Won't be able to read 'rc-path' if defined."
+ % (str(e)))
+ gc_rc = None
+
+ gc_rc = normpath(gc_rc, cwd=repository.toplevel) if gc_rc else None
+
+ ## config file lookup resolution
+ for enforce_file_existence, fun in [
+ (True, lambda: os.environ.get('GITCHANGELOG_CONFIG_FILENAME')),
+ (True, lambda: gc_rc),
+ (False,
+ lambda: (os.path.join(repository.toplevel, ".%s.rc" % basename))
+ if not repository.bare else None)]:
+ changelogrc = fun()
+ if changelogrc:
+ if not os.path.exists(changelogrc):
+ if enforce_file_existence:
+ die("File %r does not exists." % changelogrc)
+ else:
+ continue ## changelogrc valued, but file does not exists
+ else:
+ break
+
+ ## config file may lookup for templates relative to the toplevel
+ ## of git repository
+ os.chdir(repository.toplevel)
+
+ config = load_config_file(
+ os.path.expanduser(changelogrc),
+ default_filename=reference_config,
+ fail_if_not_present=False)
+
+ config = Config(config)
+
+ log_encoding = get_log_encoding(repository, config)
+ revlist = get_revision(repository, config, opts)
+ config['unreleased_version_label'] = eval_if_callable(
+ config['unreleased_version_label'])
+ manage_obsolete_options(config)
+
+ try:
+ content = changelog(
+ repository=repository, revlist=revlist,
+ ignore_regexps=config['ignore_regexps'],
+ section_regexps=config['section_regexps'],
+ unreleased_version_label=config['unreleased_version_label'],
+ tag_filter_regexp=config['tag_filter_regexp'],
+ output_engine=config.get("output_engine", rest_py),
+ include_merge=config.get("include_merge", True),
+ body_process=config.get("body_process", noop),
+ subject_process=config.get("subject_process", noop),
+ log_encoding=log_encoding,
+ )
+
+ if isinstance(content, basestring):
+ content = content.splitlines(True)
+
+ config.get("publish", stdout)(content)
+
+ except KeyboardInterrupt:
+ if DEBUG:
+ err("Keyboard interrupt received while running '%s':"
+ % (basename, ))
+ stderr(format_last_exception())
+ else:
+ err("Keyboard Interrupt. Bailing out.")
+ exit(130) ## Actual SIGINT as bash process convention.
+ except Exception as e: ## pylint: disable=broad-except
+ if DEBUG:
+ err("Exception while running '%s':"
+ % (basename, ))
+ stderr(format_last_exception())
+ else:
+ message = "%s" % e
+ err(message)
+ stderr(" (set %s environment variable, "
+ "or use ``--debug`` to see full traceback)" %
+ (debug_varname, ))
+ exit(255)
+
+
+##
+## Launch program
+##
+
+if __name__ == "__main__":
+ main()