#!/usr/bin/env python3 ############################################################################ # 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. 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", separator="\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 separator.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[A-Z]\w+(-\w+)*): (?P[^\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.fromtimestamp( float(self.tagger_date_timestamp), datetime.UTC ) 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\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 += rest_title(section_label, "~") for commit in section["commits"]: s += render_commit(commit, opts) return s def render_commit(commit, opts=opts): subject = commit["subject"] if opts["include_commit_sha"]: subject += " ``%s``" % commit["commit"].sha1_short entry = indent("\n".join(textwrap.wrap(subject)), first="- ").strip() + "\n" if commit["body"]: entry += "\n" + indent(commit["body"]) entry += "\n" entry += "\n" return entry if data["title"]: yield rest_title(data["title"], char="=") + "\n" for version in data["versions"]: if len(version["sections"]) > 0: yield render_version(version) + "\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 body = body_process(commit.body) ## Extract gitlab issue number issue = None if match := re.search(r".*:gl:`#([0-9]+)`", body): issue = int(match.group(1)) 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, "commit": commit, "issue": issue, } ) ## Sort sections by issue number or title for section_key in sections.keys(): sections[section_key].sort( key=lambda c: ( c["issue"] if c["issue"] is not None else sys.maxsize, c["subject"], ) ) ## 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", include_commit_sha=False, 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 include_commit_sha: whether message should contain commit sha :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, "include_commit_sha": include_commit_sha, } ## 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"], include_commit_sha=config["include_commit_sha"], 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()