Home | History | Annotate | Line # | Download | only in gitchangelog
      1 #!/usr/bin/env python3
      2 ############################################################################
      3 # Copyright (c) 2018, Valentin Lab
      4 # All rights reserved.
      5 #
      6 # Redistribution and use in source and binary forms, with or without
      7 # modification, are permitted provided that the following conditions are met:
      8 #     * Redistributions of source code must retain the above copyright
      9 #       notice, this list of conditions and the following disclaimer.
     10 #     * Redistributions in binary form must reproduce the above copyright
     11 #       notice, this list of conditions and the following disclaimer in the
     12 #       documentation and/or other materials provided with the distribution.
     13 #     * Neither the name of the Securactive nor the
     14 #       names of its contributors may be used to endorse or promote products
     15 #       derived from this software without specific prior written permission.
     16 #
     17 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
     18 # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
     19 # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
     20 # DISCLAIMED. IN NO EVENT SHALL SECURACTIVE BE LIABLE FOR ANY
     21 # DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
     22 # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
     23 # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
     24 # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
     25 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
     26 # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
     27 #
     28 # SPDX-License-Identifier: BSD-3-Clause
     29 #
     30 ############################################################################
     31 
     32 from __future__ import print_function
     33 from __future__ import absolute_import
     34 
     35 import locale
     36 import re
     37 import os
     38 import os.path
     39 import sys
     40 import glob
     41 import textwrap
     42 import datetime
     43 import collections
     44 import traceback
     45 import contextlib
     46 import itertools
     47 import errno
     48 
     49 from subprocess import Popen, PIPE
     50 
     51 try:
     52     import pystache
     53 except ImportError:  ## pragma: no cover
     54     pystache = None
     55 
     56 try:
     57     import mako
     58 except ImportError:  ## pragma: no cover
     59     mako = None
     60 
     61 
     62 __version__ = "%%version%%"  ## replaced by autogen.sh
     63 
     64 EBUG = None
     65 
     66 
     67 ##
     68 ## Platform and python compatibility
     69 ##
     70 
     71 PY_VERSION = float("%d.%d" % sys.version_info[0:2])
     72 PY3 = PY_VERSION >= 3
     73 
     74 try:
     75     basestring
     76 except NameError:
     77     basestring = str  ## pylint: disable=redefined-builtin
     78 
     79 WIN32 = sys.platform == "win32"
     80 if WIN32:
     81     PLT_CFG = {
     82         "close_fds": False,
     83     }
     84 else:
     85     PLT_CFG = {
     86         "close_fds": True,
     87     }
     88 
     89 ##
     90 ##
     91 ##
     92 
     93 if WIN32 and not PY3:
     94 
     95     ## Sorry about the following, all this code is to ensure full
     96     ## compatibility with python 2.7 under windows about sending unicode
     97     ## command-line
     98 
     99     import ctypes
    100     import subprocess
    101     import _subprocess
    102     from ctypes import (
    103         byref,
    104         windll,
    105         c_char_p,
    106         c_wchar_p,
    107         c_void_p,
    108         Structure,
    109         sizeof,
    110         c_wchar,
    111         WinError,
    112     )
    113     from ctypes.wintypes import BYTE, WORD, LPWSTR, BOOL, DWORD, LPVOID, HANDLE
    114 
    115     ##
    116     ## Types
    117     ##
    118 
    119     CREATE_UNICODE_ENVIRONMENT = 0x00000400
    120     LPCTSTR = c_char_p
    121     LPTSTR = c_wchar_p
    122     LPSECURITY_ATTRIBUTES = c_void_p
    123     LPBYTE = ctypes.POINTER(BYTE)
    124 
    125     class STARTUPINFOW(Structure):
    126         _fields_ = [
    127             ("cb", DWORD),
    128             ("lpReserved", LPWSTR),
    129             ("lpDesktop", LPWSTR),
    130             ("lpTitle", LPWSTR),
    131             ("dwX", DWORD),
    132             ("dwY", DWORD),
    133             ("dwXSize", DWORD),
    134             ("dwYSize", DWORD),
    135             ("dwXCountChars", DWORD),
    136             ("dwYCountChars", DWORD),
    137             ("dwFillAtrribute", DWORD),
    138             ("dwFlags", DWORD),
    139             ("wShowWindow", WORD),
    140             ("cbReserved2", WORD),
    141             ("lpReserved2", LPBYTE),
    142             ("hStdInput", HANDLE),
    143             ("hStdOutput", HANDLE),
    144             ("hStdError", HANDLE),
    145         ]
    146 
    147     LPSTARTUPINFOW = ctypes.POINTER(STARTUPINFOW)
    148 
    149     class PROCESS_INFORMATION(Structure):
    150         _fields_ = [
    151             ("hProcess", HANDLE),
    152             ("hThread", HANDLE),
    153             ("dwProcessId", DWORD),
    154             ("dwThreadId", DWORD),
    155         ]
    156 
    157     LPPROCESS_INFORMATION = ctypes.POINTER(PROCESS_INFORMATION)
    158 
    159     class DUMMY_HANDLE(ctypes.c_void_p):
    160 
    161         def __init__(self, *a, **kw):
    162             super(DUMMY_HANDLE, self).__init__(*a, **kw)
    163             self.closed = False
    164 
    165         def Close(self):
    166             if not self.closed:
    167                 windll.kernel32.CloseHandle(self)
    168                 self.closed = True
    169 
    170         def __int__(self):
    171             return self.value
    172 
    173     CreateProcessW = windll.kernel32.CreateProcessW
    174     CreateProcessW.argtypes = [
    175         LPCTSTR,
    176         LPTSTR,
    177         LPSECURITY_ATTRIBUTES,
    178         LPSECURITY_ATTRIBUTES,
    179         BOOL,
    180         DWORD,
    181         LPVOID,
    182         LPCTSTR,
    183         LPSTARTUPINFOW,
    184         LPPROCESS_INFORMATION,
    185     ]
    186     CreateProcessW.restype = BOOL
    187 
    188     ##
    189     ## Patched functions/classes
    190     ##
    191 
    192     def CreateProcess(
    193         executable,
    194         args,
    195         _p_attr,
    196         _t_attr,
    197         inherit_handles,
    198         creation_flags,
    199         env,
    200         cwd,
    201         startup_info,
    202     ):
    203         """Create a process supporting unicode executable and args for win32
    204 
    205         Python implementation of CreateProcess using CreateProcessW for Win32
    206 
    207         """
    208 
    209         si = STARTUPINFOW(
    210             dwFlags=startup_info.dwFlags,
    211             wShowWindow=startup_info.wShowWindow,
    212             cb=sizeof(STARTUPINFOW),
    213             ## XXXvlab: not sure of the casting here to ints.
    214             hStdInput=int(startup_info.hStdInput),
    215             hStdOutput=int(startup_info.hStdOutput),
    216             hStdError=int(startup_info.hStdError),
    217         )
    218 
    219         wenv = None
    220         if env is not None:
    221             ## LPCWSTR seems to be c_wchar_p, so let's say CWSTR is c_wchar
    222             env = (
    223                 unicode("").join([unicode("%s=%s\0") % (k, v) for k, v in env.items()])
    224             ) + unicode("\0")
    225             wenv = (c_wchar * len(env))()
    226             wenv.value = env
    227 
    228         pi = PROCESS_INFORMATION()
    229         creation_flags |= CREATE_UNICODE_ENVIRONMENT
    230 
    231         if CreateProcessW(
    232             executable,
    233             args,
    234             None,
    235             None,
    236             inherit_handles,
    237             creation_flags,
    238             wenv,
    239             cwd,
    240             byref(si),
    241             byref(pi),
    242         ):
    243             return (
    244                 DUMMY_HANDLE(pi.hProcess),
    245                 DUMMY_HANDLE(pi.hThread),
    246                 pi.dwProcessId,
    247                 pi.dwThreadId,
    248             )
    249         raise WinError()
    250 
    251     class Popen(subprocess.Popen):
    252         """This superseeds Popen and corrects a bug in cPython 2.7 implem"""
    253 
    254         def _execute_child(
    255             self,
    256             args,
    257             executable,
    258             preexec_fn,
    259             close_fds,
    260             cwd,
    261             env,
    262             universal_newlines,
    263             startupinfo,
    264             creationflags,
    265             shell,
    266             to_close,
    267             p2cread,
    268             p2cwrite,
    269             c2pread,
    270             c2pwrite,
    271             errread,
    272             errwrite,
    273         ):
    274             """Code from part of _execute_child from Python 2.7 (9fbb65e)
    275 
    276             There are only 2 little changes concerning the construction of
    277             the the final string in shell mode: we preempt the creation of
    278             the command string when shell is True, because original function
    279             will try to encode unicode args which we want to avoid to be able to
    280             sending it as-is to ``CreateProcess``.
    281 
    282             """
    283             if not isinstance(args, subprocess.types.StringTypes):
    284                 args = subprocess.list2cmdline(args)
    285 
    286             if startupinfo is None:
    287                 startupinfo = subprocess.STARTUPINFO()
    288             if shell:
    289                 startupinfo.dwFlags |= _subprocess.STARTF_USESHOWWINDOW
    290                 startupinfo.wShowWindow = _subprocess.SW_HIDE
    291                 comspec = os.environ.get("COMSPEC", unicode("cmd.exe"))
    292                 args = unicode('{} /c "{}"').format(comspec, args)
    293                 if (
    294                     _subprocess.GetVersion() >= 0x80000000
    295                     or os.path.basename(comspec).lower() == "command.com"
    296                 ):
    297                     w9xpopen = self._find_w9xpopen()
    298                     args = unicode('"%s" %s') % (w9xpopen, args)
    299                     creationflags |= _subprocess.CREATE_NEW_CONSOLE
    300 
    301             super(Popen, self)._execute_child(
    302                 args,
    303                 executable,
    304                 preexec_fn,
    305                 close_fds,
    306                 cwd,
    307                 env,
    308                 universal_newlines,
    309                 startupinfo,
    310                 creationflags,
    311                 False,
    312                 to_close,
    313                 p2cread,
    314                 p2cwrite,
    315                 c2pread,
    316                 c2pwrite,
    317                 errread,
    318                 errwrite,
    319             )
    320 
    321     _subprocess.CreateProcess = CreateProcess
    322 
    323 
    324 ##
    325 ## Help and usage strings
    326 ##
    327 
    328 usage_msg = """
    329   %(exname)s {-h|--help}
    330   %(exname)s {-v|--version}
    331   %(exname)s [--debug|-d] [REVLIST]"""
    332 
    333 description_msg = """\
    334 Run this command in a git repository to output a formatted changelog
    335 """
    336 
    337 epilog_msg = """\
    338 %(exname)s uses a config file to filter meaningful commit or do some
    339 formatting in commit messages thanks to a config file.
    340 
    341 Config file location will be resolved in this order:
    342   - in shell environment variable GITCHANGELOG_CONFIG_FILENAME
    343   - in git configuration: ``git config gitchangelog.rc-path``
    344   - as '.%(exname)s.rc' in the root of the current git repository
    345 
    346 """
    347 
    348 
    349 ##
    350 ## Shell command helper functions
    351 ##
    352 
    353 
    354 def stderr(msg):
    355     print(msg, file=sys.stderr)
    356 
    357 
    358 def err(msg):
    359     stderr("Error: " + msg)
    360 
    361 
    362 def warn(msg):
    363     stderr("Warning: " + msg)
    364 
    365 
    366 def die(msg=None, errlvl=1):
    367     if msg:
    368         stderr(msg)
    369     sys.exit(errlvl)
    370 
    371 
    372 class ShellError(Exception):
    373 
    374     def __init__(self, msg, errlvl=None, command=None, out=None, err=None):
    375         self.errlvl = errlvl
    376         self.command = command
    377         self.out = out
    378         self.err = err
    379         super(ShellError, self).__init__(msg)
    380 
    381 
    382 @contextlib.contextmanager
    383 def set_cwd(directory):
    384     curdir = os.getcwd()
    385     os.chdir(directory)
    386     try:
    387         yield
    388     finally:
    389         os.chdir(curdir)
    390 
    391 
    392 def format_last_exception(prefix="  | "):
    393     """Format the last exception for display it in tests.
    394 
    395     This allows to raise custom exception, without loosing the context of what
    396     caused the problem in the first place:
    397 
    398     >>> def f():
    399     ...     raise Exception("Something terrible happened")
    400     >>> try:  ## doctest: +ELLIPSIS
    401     ...     f()
    402     ... except Exception:
    403     ...     formated_exception = format_last_exception()
    404     ...     raise ValueError('Oups, an error occured:\\n%s'
    405     ...         % formated_exception)
    406     Traceback (most recent call last):
    407     ...
    408     ValueError: Oups, an error occured:
    409       | Traceback (most recent call last):
    410     ...
    411       | Exception: Something terrible happened
    412 
    413     """
    414 
    415     return "\n".join(
    416         str(prefix + line) for line in traceback.format_exc().strip().split("\n")
    417     )
    418 
    419 
    420 ##
    421 ## config file functions
    422 ##
    423 
    424 _config_env = {
    425     "WIN32": WIN32,
    426     "PY3": PY3,
    427 }
    428 
    429 
    430 def available_in_config(f):
    431     _config_env[f.__name__] = f
    432     return f
    433 
    434 
    435 def load_config_file(filename, default_filename=None, fail_if_not_present=True):
    436     """Loads data from a config file."""
    437 
    438     config = _config_env.copy()
    439     for fname in [default_filename, filename]:
    440         if fname and os.path.exists(fname):
    441             if not os.path.isfile(fname):
    442                 die("config file path '%s' exists but is not a file !" % (fname,))
    443             content = file_get_contents(fname)
    444             try:
    445                 code = compile(content, fname, "exec")
    446                 exec(code, config)  ## pylint: disable=exec-used
    447             except SyntaxError as e:
    448                 die(
    449                     "Syntax error in config file: %s\n%s"
    450                     "File %s, line %i"
    451                     % (
    452                         str(e),
    453                         (indent(e.text.rstrip(), "  | ") + "\n") if e.text else "",
    454                         e.filename,
    455                         e.lineno,
    456                     )
    457                 )
    458         else:
    459             if fail_if_not_present:
    460                 die("%s config file is not found and is required." % (fname,))
    461 
    462     return config
    463 
    464 
    465 ##
    466 ## Text functions
    467 ##
    468 
    469 
    470 @available_in_config
    471 class TextProc(object):
    472 
    473     def __init__(self, fun):
    474         self.fun = fun
    475         if hasattr(fun, "__name__"):
    476             self.__name__ = fun.__name__
    477 
    478     def __call__(self, text):
    479         return self.fun(text)
    480 
    481     def __or__(self, value):
    482         if isinstance(value, TextProc):
    483             return TextProc(lambda text: value.fun(self.fun(text)))
    484         import inspect
    485 
    486         (_frame, filename, lineno, _function_name, lines, _index) = inspect.stack()[1]
    487         raise SyntaxError(
    488             "Invalid syntax in config file",
    489             (
    490                 filename,
    491                 lineno,
    492                 0,
    493                 "Invalid chain with a non TextProc element %r:\n%s"
    494                 % (value, indent("".join(lines).strip(), "  | ")),
    495             ),
    496         )
    497 
    498 
    499 def set_if_empty(text, msg="No commit message."):
    500     if len(text):
    501         return text
    502     return msg
    503 
    504 
    505 @TextProc
    506 def ucfirst(msg):
    507     if len(msg) == 0:
    508         return msg
    509     return msg[0].upper() + msg[1:]
    510 
    511 
    512 @TextProc
    513 def final_dot(msg):
    514     if len(msg) and msg[-1].isalnum():
    515         return msg + "."
    516     return msg
    517 
    518 
    519 def indent(text, chars="  ", first=None):
    520     """Return text string indented with the given chars
    521 
    522     >>> string = 'This is first line.\\nThis is second line\\n'
    523 
    524     >>> print(indent(string, chars="| "))  # doctest: +NORMALIZE_WHITESPACE
    525     | This is first line.
    526     | This is second line
    527     |
    528 
    529     >>> print(indent(string, first="- "))  # doctest: +NORMALIZE_WHITESPACE
    530     - This is first line.
    531       This is second line
    532 
    533 
    534     >>> string = 'This is first line.\\n\\nThis is second line'
    535     >>> print(indent(string, first="- "))  # doctest: +NORMALIZE_WHITESPACE
    536     - This is first line.
    537     <BLANKLINE>
    538       This is second line
    539 
    540     """
    541     if first:
    542         first_line = text.split("\n")[0]
    543         rest = "\n".join(text.split("\n")[1:])
    544         return "\n".join([(first + first_line).rstrip(), indent(rest, chars=chars)])
    545     return "\n".join([(chars + line).rstrip() for line in text.split("\n")])
    546 
    547 
    548 def paragraph_wrap(text, regexp="\n\n", separator="\n"):
    549     r"""Wrap text by making sure that paragraph are separated correctly
    550 
    551         >>> string = 'This is first paragraph which is quite long don\'t you \
    552         ... think ? Well, I think so.\n\nThis is second paragraph\n'
    553 
    554         >>> print(paragraph_wrap(string)) # doctest: +NORMALIZE_WHITESPACE
    555         This is first paragraph which is quite long don't you think ? Well, I
    556         think so.
    557         This is second paragraph
    558 
    559     Notice that that each paragraph has been wrapped separately.
    560 
    561     """
    562     regexp = re.compile(regexp, re.MULTILINE)
    563     return separator.join(
    564         "\n".join(textwrap.wrap(paragraph.strip(), break_on_hyphens=False))
    565         for paragraph in regexp.split(text)
    566     ).strip()
    567 
    568 
    569 def curryfy(f):
    570     return lambda *a, **kw: TextProc(lambda txt: f(txt, *a, **kw))
    571 
    572 
    573 ## these are curryfied version of their lower case definition
    574 
    575 Indent = curryfy(indent)
    576 Wrap = curryfy(paragraph_wrap)
    577 ReSub = lambda p, r, **k: TextProc(lambda txt: re.sub(p, r, txt, **k))
    578 noop = TextProc(lambda txt: txt)
    579 strip = TextProc(lambda txt: txt.strip())
    580 SetIfEmpty = curryfy(set_if_empty)
    581 
    582 for _label in (
    583     "Indent",
    584     "Wrap",
    585     "ReSub",
    586     "noop",
    587     "final_dot",
    588     "ucfirst",
    589     "strip",
    590     "SetIfEmpty",
    591 ):
    592     _config_env[_label] = locals()[_label]
    593 
    594 ##
    595 ## File
    596 ##
    597 
    598 
    599 def file_get_contents(filename):
    600     with open(filename) as f:
    601         out = f.read()
    602     if not PY3:
    603         if not isinstance(out, unicode):
    604             out = out.decode(_preferred_encoding)
    605         ## remove encoding declaration (for some reason, python 2.7
    606         ## don't like it).
    607         out = re.sub(
    608             r"^(\s*#.*\s*)coding[:=]\s*([-\w.]+\s*;?\s*)", r"\1", out, re.DOTALL
    609         )
    610 
    611     return out
    612 
    613 
    614 def file_put_contents(filename, string):
    615     """Write string to filename."""
    616     if PY3:
    617         fopen = open(filename, "w", newline="")
    618     else:
    619         fopen = open(filename, "wb")
    620 
    621     with fopen as f:
    622         f.write(string)
    623 
    624 
    625 ##
    626 ## Inferring revision
    627 ##
    628 
    629 
    630 def _file_regex_match(filename, pattern, **kw):
    631     if not os.path.isfile(filename):
    632         raise IOError("Can't open file '%s'." % filename)
    633     file_content = file_get_contents(filename)
    634     match = re.search(pattern, file_content, **kw)
    635     if match is None:
    636         stderr("file content: %r" % file_content)
    637         if isinstance(pattern, type(re.compile(""))):
    638             pattern = pattern.pattern
    639         raise ValueError(
    640             "Regex %s did not match any substring in '%s'." % (pattern, filename)
    641         )
    642     return match
    643 
    644 
    645 @available_in_config
    646 def FileFirstRegexMatch(filename, pattern):
    647     def _call():
    648         match = _file_regex_match(filename, pattern)
    649         dct = match.groupdict()
    650         if dct:
    651             if "rev" not in dct:
    652                 warn(
    653                     "Named pattern used, but no one are named 'rev'. "
    654                     "Using full match."
    655                 )
    656                 return match.group(0)
    657             if dct["rev"] is None:
    658                 die("Named pattern used, but it was not valued.")
    659             return dct["rev"]
    660         return match.group(0)
    661 
    662     return _call
    663 
    664 
    665 @available_in_config
    666 def Caret(l):
    667     def _call():
    668         return "^%s" % eval_if_callable(l)
    669 
    670     return _call
    671 
    672 
    673 ##
    674 ## System functions
    675 ##
    676 
    677 ## Note that locale.getpreferredencoding() does NOT follow
    678 ## PYTHONIOENCODING by default, but ``sys.stdout.encoding`` does. In
    679 ## PY2, ``sys.stdout.encoding`` without PYTHONIOENCODING set does not
    680 ## get any values set in subshells.  However, if _preferred_encoding
    681 ## is not set to utf-8, it leads to encoding errors.
    682 _preferred_encoding = (
    683     os.environ.get("PYTHONIOENCODING") or locale.getpreferredencoding()
    684 )
    685 DEFAULT_GIT_LOG_ENCODING = "utf-8"
    686 
    687 
    688 class Phile(object):
    689     """File like API to read fields separated by any delimiters
    690 
    691     It'll take care of file decoding to unicode.
    692 
    693     This is an adaptor on a file object.
    694 
    695         >>> if PY3:
    696         ...     from io import BytesIO
    697         ...     def File(s):
    698         ...         _obj = BytesIO()
    699         ...         _obj.write(s.encode(_preferred_encoding))
    700         ...         _obj.seek(0)
    701         ...         return _obj
    702         ... else:
    703         ...     from cStringIO import StringIO as File
    704 
    705         >>> f = Phile(File("a-b-c-d"))
    706 
    707     Read provides an iterator:
    708 
    709         >>> def show(l):
    710         ...     print(", ".join(l))
    711         >>> show(f.read(delimiter="-"))
    712         a, b, c, d
    713 
    714     You can change the buffersize loaded into memory before outputing
    715     your changes. It should not change the iterator output:
    716 
    717         >>> f = Phile(File("---d"), buffersize=3)
    718         >>> len(list(f.read(delimiter="-")))
    719         4
    720 
    721         >>> f = Phile(File("foo-bang-yummy"), buffersize=3)
    722         >>> show(f.read(delimiter="-"))
    723         foo, bang, yummy
    724 
    725         >>> f = Phile(File("foo-bang-yummy"), buffersize=1)
    726         >>> show(f.read(delimiter="-"))
    727         foo, bang, yummy
    728 
    729     """
    730 
    731     def __init__(self, filename, buffersize=4096, encoding=_preferred_encoding):
    732         self._file = filename
    733         self._buffersize = buffersize
    734         self._encoding = encoding
    735 
    736     def read(self, delimiter="\n"):
    737         buf = ""
    738         if PY3:
    739             delimiter = delimiter.encode(_preferred_encoding)
    740             buf = buf.encode(_preferred_encoding)
    741         while True:
    742             chunk = self._file.read(self._buffersize)
    743             if not chunk:
    744                 yield buf.decode(self._encoding)
    745                 return
    746             records = chunk.split(delimiter)
    747             records[0] = buf + records[0]
    748             for record in records[:-1]:
    749                 yield record.decode(self._encoding)
    750             buf = records[-1]
    751 
    752     def write(self, buf):
    753         if PY3:
    754             buf = buf.encode(self._encoding)
    755         return self._file.write(buf)
    756 
    757     def close(self):
    758         return self._file.close()
    759 
    760 
    761 class Proc(Popen):
    762 
    763     def __init__(self, command, env=None, encoding=_preferred_encoding):
    764         super(Proc, self).__init__(
    765             command,
    766             shell=True,
    767             stdin=PIPE,
    768             stdout=PIPE,
    769             stderr=PIPE,
    770             close_fds=PLT_CFG["close_fds"],
    771             env=env,
    772             universal_newlines=False,
    773         )
    774 
    775         self.stdin = Phile(self.stdin, encoding=encoding)
    776         self.stdout = Phile(self.stdout, encoding=encoding)
    777         self.stderr = Phile(self.stderr, encoding=encoding)
    778 
    779 
    780 def cmd(command, env=None, shell=True):
    781 
    782     p = Popen(
    783         command,
    784         shell=shell,
    785         stdin=PIPE,
    786         stdout=PIPE,
    787         stderr=PIPE,
    788         close_fds=PLT_CFG["close_fds"],
    789         env=env,
    790         universal_newlines=False,
    791     )
    792     out, err = p.communicate()
    793     return (
    794         out.decode(getattr(sys.stdout, "encoding", None) or _preferred_encoding),
    795         err.decode(getattr(sys.stderr, "encoding", None) or _preferred_encoding),
    796         p.returncode,
    797     )
    798 
    799 
    800 @available_in_config
    801 def wrap(command, ignore_errlvls=[0], env=None, shell=True):
    802     """Wraps a shell command and casts an exception on unexpected errlvl
    803 
    804     >>> wrap('/tmp/lsdjflkjf') # doctest: +ELLIPSIS +IGNORE_EXCEPTION_DETAIL
    805     Traceback (most recent call last):
    806     ...
    807     ShellError: Wrapped command '/tmp/lsdjflkjf' exited with errorlevel 127.
    808       stderr:
    809       | /bin/sh: .../tmp/lsdjflkjf: not found
    810 
    811     >>> print(wrap('echo hello'),  end='')
    812     hello
    813 
    814     >>> print(wrap('echo hello && false'),
    815     ...       end='')  # doctest: +ELLIPSIS +IGNORE_EXCEPTION_DETAIL
    816     Traceback (most recent call last):
    817     ...
    818     ShellError: Wrapped command 'echo hello && false' exited with errorlevel 1.
    819       stdout:
    820       | hello
    821 
    822     """
    823 
    824     out, err, errlvl = cmd(command, env=env, shell=shell)
    825 
    826     if errlvl not in ignore_errlvls:
    827 
    828         formatted = []
    829         if out:
    830             if out.endswith("\n"):
    831                 out = out[:-1]
    832             formatted.append("stdout:\n%s" % indent(out, "| "))
    833         if err:
    834             if err.endswith("\n"):
    835                 err = err[:-1]
    836             formatted.append("stderr:\n%s" % indent(err, "| "))
    837         msg = "\n".join(formatted)
    838 
    839         raise ShellError(
    840             "Wrapped command %r exited with errorlevel %d.\n%s"
    841             % (command, errlvl, indent(msg, chars="  ")),
    842             errlvl=errlvl,
    843             command=command,
    844             out=out,
    845             err=err,
    846         )
    847     return out
    848 
    849 
    850 @available_in_config
    851 def swrap(command, **kwargs):
    852     """Same as ``wrap(...)`` but strips the output."""
    853 
    854     return wrap(command, **kwargs).strip()
    855 
    856 
    857 ##
    858 ## git information access
    859 ##
    860 
    861 
    862 class SubGitObjectMixin(object):
    863 
    864     def __init__(self, repos):
    865         self._repos = repos
    866 
    867     @property
    868     def git(self):
    869         """Simple delegation to ``repos`` original method."""
    870         return self._repos.git
    871 
    872 
    873 GIT_FORMAT_KEYS = {
    874     "sha1": "%H",
    875     "sha1_short": "%h",
    876     "subject": "%s",
    877     "author_name": "%an",
    878     "author_email": "%ae",
    879     "author_date": "%ad",
    880     "author_date_timestamp": "%at",
    881     "committer_name": "%cn",
    882     "committer_date_timestamp": "%ct",
    883     "raw_body": "%B",
    884     "body": "%b",
    885 }
    886 
    887 GIT_FULL_FORMAT_STRING = "%x00".join(GIT_FORMAT_KEYS.values())
    888 
    889 REGEX_RFC822_KEY_VALUE = (
    890     r"(^|\n)(?P<key>[A-Z]\w+(-\w+)*): (?P<value>[^\n]*(\n\s+[^\n]*)*)"
    891 )
    892 REGEX_RFC822_POSTFIX = r"(%s)+$" % REGEX_RFC822_KEY_VALUE
    893 
    894 
    895 class GitCommit(SubGitObjectMixin):
    896     r"""Represent a Git Commit and expose through its attribute many information
    897 
    898     Let's create a fake GitRepos:
    899 
    900         >>> from minimock import Mock
    901         >>> repos = Mock("gitRepos")
    902 
    903     Initialization:
    904 
    905         >>> repos.git = Mock("gitRepos.git")
    906         >>> repos.git.log.mock_returns_func = \
    907         ...     lambda *a, **kwargs: "\x00".join([{
    908         ...             'sha1': "000000",
    909         ...             'sha1_short': "000",
    910         ...             'subject': SUBJECT,
    911         ...             'author_name': "John Smith",
    912         ...             'author_date': "Tue Feb 14 20:31:22 2017 +0700",
    913         ...             'author_email': "john.smith@example.com",
    914         ...             'author_date_timestamp': "0",   ## epoch
    915         ...             'committer_name': "Alice Wang",
    916         ...             'committer_date_timestamp': "0", ## epoch
    917         ...             'raw_body': "my subject\n\n%s" % BODY,
    918         ...             'body': BODY,
    919         ...         }[key] for key in GIT_FORMAT_KEYS.keys()])
    920         >>> repos.git.rev_list.mock_returns = "123456"
    921 
    922     Query, by attributes or items:
    923 
    924         >>> SUBJECT = "fee fie foh"
    925         >>> BODY = "foo foo foo"
    926 
    927         >>> head = GitCommit(repos, "HEAD")
    928         >>> head.subject
    929         Called gitRepos.git.log(...'HEAD'...)
    930         'fee fie foh'
    931         >>> head.author_name
    932         'John Smith'
    933 
    934     Notice that on the second call, there's no need to call again git log as
    935     all the values have already been computed.
    936 
    937     Trailer
    938     =======
    939 
    940     ``GitCommit`` offers a simple direct API to trailer values. These
    941     are like RFC822's header value but are at the end of body:
    942 
    943         >>> BODY = '''\
    944         ... Stuff in the body
    945         ... Change-id: 1234
    946         ... Value-X: Supports multi
    947         ...   line values'''
    948 
    949         >>> head = GitCommit(repos, "HEAD")
    950         >>> head.trailer_change_id
    951         Called gitRepos.git.log(...'HEAD'...)
    952         '1234'
    953         >>> head.trailer_value_x
    954         'Supports multi\nline values'
    955 
    956     Notice how the multi-line value was unindented.
    957     In case of multiple values, these are concatened in lists:
    958 
    959         >>> BODY = '''\
    960         ... Stuff in the body
    961         ... Co-Authored-By: Bob
    962         ... Co-Authored-By: Alice
    963         ... Co-Authored-By: Jack
    964         ... '''
    965 
    966         >>> head = GitCommit(repos, "HEAD")
    967         >>> head.trailer_co_authored_by
    968         Called gitRepos.git.log(...'HEAD'...)
    969         ['Bob', 'Alice', 'Jack']
    970 
    971 
    972     Special values
    973     ==============
    974 
    975     Authors
    976     -------
    977 
    978         >>> BODY = '''\
    979         ... Stuff in the body
    980         ... Co-Authored-By: Bob
    981         ... Co-Authored-By: Alice
    982         ... Co-Authored-By: Jack
    983         ... '''
    984 
    985         >>> head = GitCommit(repos, "HEAD")
    986         >>> head.author_names
    987         Called gitRepos.git.log(...'HEAD'...)
    988         ['Alice', 'Bob', 'Jack', 'John Smith']
    989 
    990     Notice that they are printed in alphabetical order.
    991 
    992     """
    993 
    994     def __init__(self, repos, identifier):
    995         super(GitCommit, self).__init__(repos)
    996         self.identifier = identifier
    997         self._trailer_parsed = False
    998 
    999     def __getattr__(self, label):
   1000         """Completes commits attributes upon request."""
   1001         attrs = GIT_FORMAT_KEYS.keys()
   1002         if label not in attrs:
   1003             try:
   1004                 return self.__dict__[label]
   1005             except KeyError:
   1006                 if self._trailer_parsed:
   1007                     raise AttributeError(label)
   1008 
   1009         identifier = self.identifier
   1010 
   1011         ## Compute only missing information
   1012         missing_attrs = [l for l in attrs if l not in self.__dict__]
   1013         ## some commit can be already fully specified (see ``mk_commit``)
   1014         if missing_attrs:
   1015             aformat = "%x00".join(GIT_FORMAT_KEYS[l] for l in missing_attrs)
   1016             try:
   1017                 ret = self.git.log(
   1018                     [identifier, "--max-count=1", "--pretty=format:%s" % aformat, "--"]
   1019                 )
   1020             except ShellError:
   1021                 if DEBUG:
   1022                     raise
   1023                 raise ValueError(
   1024                     "Given commit identifier %r doesn't exists" % self.identifier
   1025                 )
   1026             attr_values = ret.split("\x00")
   1027             for attr, value in zip(missing_attrs, attr_values):
   1028                 setattr(self, attr, value.strip())
   1029 
   1030         ## Let's interpret RFC822-like header keys that could be in the body
   1031         match = re.search(REGEX_RFC822_POSTFIX, self.body)
   1032         if match is not None:
   1033             pos = match.start()
   1034             postfix = self.body[pos:]
   1035             self.body = self.body[:pos]
   1036             for match in re.finditer(REGEX_RFC822_KEY_VALUE, postfix):
   1037                 dct = match.groupdict()
   1038                 key = dct["key"].replace("-", "_").lower()
   1039                 if "\n" in dct["value"]:
   1040                     first_line, remaining = dct["value"].split("\n", 1)
   1041                     value = "%s\n%s" % (first_line, textwrap.dedent(remaining))
   1042                 else:
   1043                     value = dct["value"]
   1044                 try:
   1045                     prev_value = self.__dict__["trailer_%s" % key]
   1046                 except KeyError:
   1047                     setattr(self, "trailer_%s" % key, value)
   1048                 else:
   1049                     setattr(
   1050                         self,
   1051                         "trailer_%s" % key,
   1052                         (
   1053                             prev_value
   1054                             + [
   1055                                 value,
   1056                             ]
   1057                             if isinstance(prev_value, list)
   1058                             else [
   1059                                 prev_value,
   1060                                 value,
   1061                             ]
   1062                         ),
   1063                     )
   1064         self._trailer_parsed = True
   1065         return getattr(self, label)
   1066 
   1067     @property
   1068     def author_names(self):
   1069         return [
   1070             re.sub(r"^([^<]+)<[^>]+>\s*$", r"\1", author).strip()
   1071             for author in self.authors
   1072         ]
   1073 
   1074     @property
   1075     def authors(self):
   1076         co_authors = getattr(self, "trailer_co_authored_by", [])
   1077         co_authors = co_authors if isinstance(co_authors, list) else [co_authors]
   1078         return sorted(co_authors + ["%s <%s>" % (self.author_name, self.author_email)])
   1079 
   1080     @property
   1081     def date(self):
   1082         d = datetime.datetime.utcfromtimestamp(float(self.author_date_timestamp))
   1083         return d.strftime("%Y-%m-%d")
   1084 
   1085     @property
   1086     def has_annotated_tag(self):
   1087         try:
   1088             self.git.rev_parse(["%s^{tag}" % self.identifier, "--"])
   1089             return True
   1090         except ShellError as e:
   1091             if e.errlvl != 128:
   1092                 raise
   1093             return False
   1094 
   1095     @property
   1096     def tagger_date_timestamp(self):
   1097         if not self.has_annotated_tag:
   1098             raise ValueError(
   1099                 "Can't access 'tagger_date_timestamp' on commit without annotated tag."
   1100             )
   1101         tagger_date_utc = self.git.for_each_ref(
   1102             "refs/tags/%s" % self.identifier, format="%(taggerdate:raw)"
   1103         )
   1104         return tagger_date_utc.split(" ", 1)[0]
   1105 
   1106     @property
   1107     def tagger_date(self):
   1108         d = datetime.datetime.fromtimestamp(
   1109             float(self.tagger_date_timestamp), datetime.UTC
   1110         )
   1111         return d.strftime("%Y-%m-%d")
   1112 
   1113     def __le__(self, value):
   1114         if not isinstance(value, GitCommit):
   1115             value = self._repos.commit(value)
   1116         try:
   1117             self.git.merge_base(value.sha1, is_ancestor=self.sha1)
   1118             return True
   1119         except ShellError as e:
   1120             if e.errlvl != 1:
   1121                 raise
   1122             return False
   1123 
   1124     def __lt__(self, value):
   1125         if not isinstance(value, GitCommit):
   1126             value = self._repos.commit(value)
   1127         return self <= value and self != value
   1128 
   1129     def __eq__(self, value):
   1130         if not isinstance(value, GitCommit):
   1131             value = self._repos.commit(value)
   1132         return self.sha1 == value.sha1
   1133 
   1134     def __hash__(self):
   1135         return hash(self.sha1)
   1136 
   1137     def __repr__(self):
   1138         return "<%s %r>" % (self.__class__.__name__, self.identifier)
   1139 
   1140 
   1141 def normpath(path, cwd=None):
   1142     """path can be absolute or relative, if relative it uses the cwd given as
   1143     param.
   1144 
   1145     """
   1146     if os.path.isabs(path):
   1147         return path
   1148     cwd = cwd if cwd else os.getcwd()
   1149     return os.path.normpath(os.path.join(cwd, path))
   1150 
   1151 
   1152 class GitConfig(SubGitObjectMixin):
   1153     """Interface to config values of git
   1154 
   1155     Let's create a fake GitRepos:
   1156 
   1157         >>> from minimock import Mock
   1158         >>> repos = Mock("gitRepos")
   1159 
   1160     Initialization:
   1161 
   1162         >>> cfg = GitConfig(repos)
   1163 
   1164     Query, by attributes or items:
   1165 
   1166         >>> repos.git.config.mock_returns = "bar"
   1167         >>> cfg.foo
   1168         Called gitRepos.git.config('foo')
   1169         'bar'
   1170         >>> cfg["foo"]
   1171         Called gitRepos.git.config('foo')
   1172         'bar'
   1173         >>> cfg.get("foo")
   1174         Called gitRepos.git.config('foo')
   1175         'bar'
   1176         >>> cfg["foo.wiz"]
   1177         Called gitRepos.git.config('foo.wiz')
   1178         'bar'
   1179 
   1180     Notice that you can't use attribute search in subsection as ``cfg.foo.wiz``
   1181     That's because in git config files, you can have a value attached to
   1182     an element, and this element can also be a section.
   1183 
   1184     Nevertheless, you can do:
   1185 
   1186         >>> getattr(cfg, "foo.wiz")
   1187         Called gitRepos.git.config('foo.wiz')
   1188         'bar'
   1189 
   1190     Default values
   1191     --------------
   1192 
   1193     get item, and getattr default values can be used:
   1194 
   1195         >>> del repos.git.config.mock_returns
   1196         >>> repos.git.config.mock_raises = ShellError('Key not found',
   1197         ...                                           errlvl=1, out="", err="")
   1198 
   1199         >>> getattr(cfg, "foo", "default")
   1200         Called gitRepos.git.config('foo')
   1201         'default'
   1202 
   1203         >>> cfg["foo"]  ## doctest: +ELLIPSIS
   1204         Traceback (most recent call last):
   1205         ...
   1206         KeyError: 'foo'
   1207 
   1208         >>> getattr(cfg, "foo")  ## doctest: +ELLIPSIS
   1209         Traceback (most recent call last):
   1210         ...
   1211         AttributeError...
   1212 
   1213         >>> cfg.get("foo", "default")
   1214         Called gitRepos.git.config('foo')
   1215         'default'
   1216 
   1217         >>> print("%r" % cfg.get("foo"))
   1218         Called gitRepos.git.config('foo')
   1219         None
   1220 
   1221     """
   1222 
   1223     def __init__(self, repos):
   1224         super(GitConfig, self).__init__(repos)
   1225 
   1226     def __getattr__(self, label):
   1227         try:
   1228             res = self.git.config(label)
   1229         except ShellError as e:
   1230             if e.errlvl == 1 and e.out == "":
   1231                 raise AttributeError("key %r is not found in git config." % label)
   1232             raise
   1233         return res
   1234 
   1235     def get(self, label, default=None):
   1236         return getattr(self, label, default)
   1237 
   1238     def __getitem__(self, label):
   1239         try:
   1240             return getattr(self, label)
   1241         except AttributeError:
   1242             raise KeyError(label)
   1243 
   1244 
   1245 class GitCmd(SubGitObjectMixin):
   1246 
   1247     def __getattr__(self, label):
   1248         label = label.replace("_", "-")
   1249 
   1250         def dir_swrap(command, **kwargs):
   1251             with set_cwd(self._repos._orig_path):
   1252                 return swrap(command, **kwargs)
   1253 
   1254         def method(*args, **kwargs):
   1255             if len(args) == 1 and not isinstance(args[0], basestring):
   1256                 return dir_swrap(
   1257                     [
   1258                         "git",
   1259                         label,
   1260                     ]
   1261                     + args[0],
   1262                     shell=False,
   1263                     env=kwargs.get("env", None),
   1264                 )
   1265             cli_args = []
   1266             for key, value in kwargs.items():
   1267                 cli_key = ("-%s" if len(key) == 1 else "--%s") % key.replace("_", "-")
   1268                 if isinstance(value, bool):
   1269                     cli_args.append(cli_key)
   1270                 else:
   1271                     cli_args.append(cli_key)
   1272                     cli_args.append(value)
   1273 
   1274             cli_args.extend(args)
   1275 
   1276             return dir_swrap(
   1277                 [
   1278                     "git",
   1279                     label,
   1280                 ]
   1281                 + cli_args,
   1282                 shell=False,
   1283             )
   1284 
   1285         return method
   1286 
   1287 
   1288 class GitRepos(object):
   1289 
   1290     def __init__(self, path):
   1291 
   1292         ## Saving this original path to ensure all future git commands
   1293         ## will be done from this location.
   1294         self._orig_path = os.path.abspath(path)
   1295 
   1296         ## verify ``git`` command is accessible:
   1297         try:
   1298             self._git_version = self.git.version()
   1299         except ShellError:
   1300             if DEBUG:
   1301                 raise
   1302             raise EnvironmentError(
   1303                 "Required ``git`` command not found or broken in $PATH. "
   1304                 "(calling ``git version`` failed.)"
   1305             )
   1306 
   1307         ## verify that we are in a git repository
   1308         try:
   1309             self.git.remote()
   1310         except ShellError:
   1311             if DEBUG:
   1312                 raise
   1313             raise EnvironmentError(
   1314                 "Not in a git repository. (calling ``git remote`` failed.)"
   1315             )
   1316 
   1317         self.bare = self.git.rev_parse(is_bare_repository=True) == "true"
   1318         self.toplevel = None if self.bare else self.git.rev_parse(show_toplevel=True)
   1319         self.gitdir = normpath(self.git.rev_parse(git_dir=True), cwd=self._orig_path)
   1320 
   1321     @classmethod
   1322     def create(cls, directory, *args, **kwargs):
   1323         os.mkdir(directory)
   1324         return cls.init(directory, *args, **kwargs)
   1325 
   1326     @classmethod
   1327     def init(cls, directory, user=None, email=None):
   1328         with set_cwd(directory):
   1329             wrap("git init .")
   1330         self = cls(directory)
   1331         if user:
   1332             self.git.config("user.name", user)
   1333         if email:
   1334             self.git.config("user.email", email)
   1335         return self
   1336 
   1337     def commit(self, identifier):
   1338         return GitCommit(self, identifier)
   1339 
   1340     @property
   1341     def git(self):
   1342         return GitCmd(self)
   1343 
   1344     @property
   1345     def config(self):
   1346         return GitConfig(self)
   1347 
   1348     def tags(self, contains=None):
   1349         """String list of repository's tag names
   1350 
   1351         Current tag order is committer date timestamp of tagged commit.
   1352         No firm reason for that, and it could change in future version.
   1353 
   1354         """
   1355         if contains:
   1356             tags = self.git.tag(contains=contains).split("\n")
   1357         else:
   1358             tags = self.git.tag().split("\n")
   1359         ## Should we use new version name sorting ?  refering to :
   1360         ## ``git tags --sort -v:refname`` in git version >2.0.
   1361         ## Sorting and reversing with command line is not available on
   1362         ## git version <2.0
   1363         return sorted(
   1364             [self.commit(tag) for tag in tags if tag != ""],
   1365             key=lambda x: int(x.committer_date_timestamp),
   1366         )
   1367 
   1368     def log(
   1369         self,
   1370         includes=[
   1371             "HEAD",
   1372         ],
   1373         excludes=[],
   1374         include_merge=True,
   1375         encoding=_preferred_encoding,
   1376     ):
   1377         """Reverse chronological list of git repository's commits
   1378 
   1379         Note: rev lists can be GitCommit instance list or identifier list.
   1380 
   1381         """
   1382 
   1383         refs = {"includes": includes, "excludes": excludes}
   1384         for ref_type in ("includes", "excludes"):
   1385             for idx, ref in enumerate(refs[ref_type]):
   1386                 if not isinstance(ref, GitCommit):
   1387                     refs[ref_type][idx] = self.commit(ref)
   1388 
   1389         ## --topo-order: don't mix commits from separate branches.
   1390         plog = Proc(
   1391             "git log --stdin -z --topo-order --pretty=format:%s %s --"
   1392             % (GIT_FULL_FORMAT_STRING, "--no-merges" if not include_merge else ""),
   1393             encoding=encoding,
   1394         )
   1395         for ref in refs["includes"]:
   1396             plog.stdin.write("%s\n" % ref.sha1)
   1397 
   1398         for ref in refs["excludes"]:
   1399             plog.stdin.write("^%s\n" % ref.sha1)
   1400         plog.stdin.close()
   1401 
   1402         def mk_commit(dct):
   1403             """Creates an already set commit from a dct"""
   1404             c = self.commit(dct["sha1"])
   1405             for k, v in dct.items():
   1406                 setattr(c, k, v)
   1407             return c
   1408 
   1409         values = plog.stdout.read("\x00")
   1410 
   1411         try:
   1412             while True:  ## next(values) will eventualy raise a StopIteration
   1413                 yield mk_commit(dict([(key, next(values)) for key in GIT_FORMAT_KEYS]))
   1414         except StopIteration:
   1415             pass  ## since 3.7, we are not allowed anymore to trickle down
   1416             ## StopIteration.
   1417         finally:
   1418             plog.stdout.close()
   1419             plog.stderr.close()
   1420 
   1421 
   1422 def first_matching(section_regexps, string):
   1423     for section, regexps in section_regexps:
   1424         if regexps is None:
   1425             return section
   1426         for regexp in regexps:
   1427             if re.search(regexp, string) is not None:
   1428                 return section
   1429 
   1430 
   1431 def ensure_template_file_exists(label, template_name):
   1432     """Return template file path given a label hint and the template name
   1433 
   1434     Template name can be either a filename with full path,
   1435     if this is the case, the label is of no use.
   1436 
   1437     If ``template_name`` does not refer to an existing file,
   1438     then ``label`` is used to find a template file in the
   1439     the bundled ones.
   1440 
   1441     """
   1442 
   1443     try:
   1444         template_path = GitRepos(os.getcwd()).config.get("gitchangelog.template-path")
   1445     except ShellError as e:
   1446         stderr(
   1447             "Error parsing git config: %s."
   1448             " Won't be able to read 'template-path' if defined." % (str(e))
   1449         )
   1450         template_path = None
   1451 
   1452     if template_path:
   1453         path_file = path_label = template_path
   1454     else:
   1455         path_file = os.getcwd()
   1456         path_label = os.path.join(
   1457             os.path.dirname(os.path.realpath(__file__)), "templates", label
   1458         )
   1459 
   1460     for ftn in [
   1461         os.path.join(path_file, template_name),
   1462         os.path.join(path_label, "%s.tpl" % template_name),
   1463     ]:
   1464         if os.path.isfile(ftn):
   1465             return ftn
   1466 
   1467     templates = glob.glob(os.path.join(path_label, "*.tpl"))
   1468     if len(templates) > 0:
   1469         msg = "These are the available %s templates:" % label
   1470         msg += "\n - " + "\n - ".join(
   1471             os.path.basename(f).split(".")[0] for f in templates
   1472         )
   1473         msg += "\nTemplates are located in %r" % path_label
   1474     else:
   1475         msg = "No available %s templates found in %r." % (label, path_label)
   1476     die("Error: Invalid %s template name %r.\n" % (label, template_name) + "%s" % msg)
   1477 
   1478 
   1479 ##
   1480 ## Output Engines
   1481 ##
   1482 
   1483 
   1484 @available_in_config
   1485 def rest_py(data, opts={}):
   1486     """Returns ReStructured Text changelog content from data"""
   1487 
   1488     def rest_title(label, char="="):
   1489         return (label.strip() + "\n") + (char * len(label) + "\n\n")
   1490 
   1491     def render_version(version):
   1492         title = (
   1493             "%s (%s)" % (version["tag"], version["date"])
   1494             if version["tag"]
   1495             else opts["unreleased_version_label"]
   1496         )
   1497         s = rest_title(title, char="-")
   1498 
   1499         sections = version["sections"]
   1500         nb_sections = len(sections)
   1501         for section in sections:
   1502 
   1503             section_label = section["label"] if section.get("label", None) else "Other"
   1504 
   1505             if not (section_label == "Other" and nb_sections == 1):
   1506                 s += rest_title(section_label, "~")
   1507 
   1508             for commit in section["commits"]:
   1509                 s += render_commit(commit, opts)
   1510         return s
   1511 
   1512     def render_commit(commit, opts=opts):
   1513         subject = commit["subject"]
   1514 
   1515         if opts["include_commit_sha"]:
   1516             subject += " ``%s``" % commit["commit"].sha1_short
   1517 
   1518         entry = (
   1519             indent(
   1520                 "\n".join(textwrap.wrap(subject, break_on_hyphens=False)), first="- "
   1521             ).strip()
   1522             + "\n"
   1523         )
   1524 
   1525         if commit["body"]:
   1526             entry += "\n" + indent(commit["body"])
   1527             entry += "\n"
   1528 
   1529         entry += "\n"
   1530 
   1531         return entry
   1532 
   1533     if data["title"]:
   1534         yield rest_title(data["title"], char="=") + "\n"
   1535 
   1536     for version in data["versions"]:
   1537         if len(version["sections"]) > 0:
   1538             yield render_version(version) + "\n"
   1539 
   1540 
   1541 ## formatter engines
   1542 
   1543 if pystache:
   1544 
   1545     @available_in_config
   1546     def mustache(template_name):
   1547         """Return a callable that will render a changelog data structure
   1548 
   1549         returned callable must take 2 arguments ``data`` and ``opts``.
   1550 
   1551         """
   1552         template_path = ensure_template_file_exists("mustache", template_name)
   1553 
   1554         template = file_get_contents(template_path)
   1555 
   1556         def stuffed_versions(versions, opts):
   1557             for version in versions:
   1558                 title = (
   1559                     "%s (%s)" % (version["tag"], version["date"])
   1560                     if version["tag"]
   1561                     else opts["unreleased_version_label"]
   1562                 )
   1563                 version["label"] = title
   1564                 version["label_chars"] = list(version["label"])
   1565                 for section in version["sections"]:
   1566                     section["label_chars"] = list(section["label"])
   1567                     section["display_label"] = not (
   1568                         section["label"] == "Other" and len(version["sections"]) == 1
   1569                     )
   1570                     for commit in section["commits"]:
   1571                         commit["author_names_joined"] = ", ".join(commit["authors"])
   1572                         commit["body_indented"] = indent(commit["body"])
   1573                 yield version
   1574 
   1575         def renderer(data, opts):
   1576 
   1577             ## mustache is very simple so we need to add some intermediate
   1578             ## values
   1579             data["general_title"] = True if data["title"] else False
   1580             data["title_chars"] = list(data["title"]) if data["title"] else []
   1581 
   1582             data["versions"] = stuffed_versions(data["versions"], opts)
   1583 
   1584             return pystache.render(template, data)
   1585 
   1586         return renderer
   1587 
   1588 else:
   1589 
   1590     @available_in_config
   1591     def mustache(template_name):  ## pylint: disable=unused-argument
   1592         die("Required 'pystache' python module not found.")
   1593 
   1594 
   1595 if mako:
   1596 
   1597     import mako.template  ## pylint: disable=wrong-import-position
   1598 
   1599     mako_env = dict(
   1600         (f.__name__, f) for f in (ucfirst, indent, textwrap, paragraph_wrap)
   1601     )
   1602 
   1603     @available_in_config
   1604     def makotemplate(template_name):
   1605         """Return a callable that will render a changelog data structure
   1606 
   1607         returned callable must take 2 arguments ``data`` and ``opts``.
   1608 
   1609         """
   1610         template_path = ensure_template_file_exists("mako", template_name)
   1611 
   1612         template = mako.template.Template(filename=template_path)
   1613 
   1614         def renderer(data, opts):
   1615             kwargs = mako_env.copy()
   1616             kwargs.update({"data": data, "opts": opts})
   1617             return template.render(**kwargs)
   1618 
   1619         return renderer
   1620 
   1621 else:
   1622 
   1623     @available_in_config
   1624     def makotemplate(template_name):  ## pylint: disable=unused-argument
   1625         die("Required 'mako' python module not found.")
   1626 
   1627 
   1628 ##
   1629 ## Publish action
   1630 ##
   1631 
   1632 
   1633 @available_in_config
   1634 def stdout(content):
   1635     for chunk in content:
   1636         safe_print(chunk)
   1637 
   1638 
   1639 @available_in_config
   1640 def FileInsertAtFirstRegexMatch(filename, pattern, flags=0, idx=lambda m: m.start()):
   1641 
   1642     def write_content(f, content):
   1643         for content_line in content:
   1644             f.write(content_line)
   1645 
   1646     def _wrapped(content):
   1647         index = idx(_file_regex_match(filename, pattern, flags=flags))
   1648         offset = 0
   1649         new_offset = 0
   1650         postfix = False
   1651 
   1652         with open(filename + "~", "w") as dst:
   1653             with open(filename, "r") as src:
   1654                 for line in src:
   1655                     if postfix:
   1656                         dst.write(line)
   1657                         continue
   1658                     new_offset = offset + len(line)
   1659                     if new_offset < index:
   1660                         offset = new_offset
   1661                         dst.write(line)
   1662                         continue
   1663                     dst.write(line[0 : index - offset])
   1664                     write_content(dst, content)
   1665                     dst.write(line[index - offset :])
   1666                     postfix = True
   1667             if not postfix:
   1668                 write_content(dst, content)
   1669         if WIN32:
   1670             os.remove(filename)
   1671         os.rename(filename + "~", filename)
   1672 
   1673     return _wrapped
   1674 
   1675 
   1676 @available_in_config
   1677 def FileRegexSubst(filename, pattern, replace, flags=0):
   1678 
   1679     replace = re.sub(r"\\([0-9+])", r"\\g<\1>", replace)
   1680 
   1681     def _wrapped(content):
   1682         src = file_get_contents(filename)
   1683         ## Protect replacement pattern against the following expansion of '\o'
   1684         src = re.sub(
   1685             pattern,
   1686             replace.replace(r"\o", "".join(content).replace("\\", "\\\\")),
   1687             src,
   1688             flags=flags,
   1689         )
   1690         if not PY3:
   1691             src = src.encode(_preferred_encoding)
   1692         file_put_contents(filename, src)
   1693 
   1694     return _wrapped
   1695 
   1696 
   1697 ##
   1698 ## Data Structure
   1699 ##
   1700 
   1701 
   1702 def versions_data_iter(
   1703     repository,
   1704     revlist=None,
   1705     ignore_regexps=[],
   1706     section_regexps=[(None, "")],
   1707     tag_filter_regexp=r"\d+\.\d+(\.\d+)?",
   1708     include_merge=True,
   1709     body_process=lambda x: x,
   1710     subject_process=lambda x: x,
   1711     log_encoding=DEFAULT_GIT_LOG_ENCODING,
   1712     warn=warn,  ## Mostly used for test
   1713 ):
   1714     """Returns an iterator through versions data structures
   1715 
   1716     (see ``gitchangelog.rc.reference`` file for more info)
   1717 
   1718     :param repository: target ``GitRepos`` object
   1719     :param revlist: list of strings that git log understands as revlist
   1720     :param ignore_regexps: list of regexp identifying ignored commit messages
   1721     :param section_regexps: regexps identifying sections
   1722     :param tag_filter_regexp: regexp to match tags used as version
   1723     :param include_merge: whether to include merge commits in the log or not
   1724     :param body_process: text processing object to apply to body
   1725     :param subject_process: text processing object to apply to subject
   1726     :param log_encoding: the encoding used in git logs
   1727     :param warn: callable to output warnings, mocked by tests
   1728 
   1729     :returns: iterator of versions data_structures
   1730 
   1731     """
   1732 
   1733     revlist = revlist or []
   1734 
   1735     ## Hash to speedup lookups
   1736     versions_done = {}
   1737     excludes = (
   1738         [
   1739             rev[1:]
   1740             for rev in repository.git.rev_parse(
   1741                 [
   1742                     "--rev-only",
   1743                 ]
   1744                 + revlist
   1745                 + [
   1746                     "--",
   1747                 ]
   1748             ).split("\n")
   1749             if rev.startswith("^")
   1750         ]
   1751         if revlist
   1752         else []
   1753     )
   1754 
   1755     revs = repository.git.rev_list(*revlist).split("\n") if revlist else []
   1756     revs = [rev for rev in revs if rev != ""]
   1757 
   1758     if revlist and not revs:
   1759         die("No commits matching given revlist: %s" % (" ".join(revlist),))
   1760 
   1761     tags = [
   1762         tag
   1763         for tag in repository.tags(contains=revs[-1] if revs else None)
   1764         if re.match(tag_filter_regexp, tag.identifier)
   1765     ]
   1766 
   1767     tags.append(repository.commit("HEAD"))
   1768 
   1769     if revlist:
   1770         max_rev = repository.commit(revs[0])
   1771         new_tags = []
   1772         for tag in tags:
   1773             new_tags.append(tag)
   1774             if max_rev <= tag:
   1775                 break
   1776         tags = new_tags
   1777     else:
   1778         max_rev = tags[-1]
   1779 
   1780     section_order = [k for k, _v in section_regexps]
   1781 
   1782     tags = list(reversed(tags))
   1783 
   1784     ## Get the changes between tags (releases)
   1785     for idx, tag in enumerate(tags):
   1786 
   1787         ## New version
   1788         current_version = {
   1789             "date": tag.tagger_date if tag.has_annotated_tag else tag.date,
   1790             "commit_date": tag.date,
   1791             "tagger_date": tag.tagger_date if tag.has_annotated_tag else None,
   1792             "tag": tag.identifier if tag.identifier != "HEAD" else None,
   1793             "commit": tag,
   1794         }
   1795 
   1796         sections = collections.defaultdict(list)
   1797         commits = repository.log(
   1798             includes=[min(tag, max_rev)],
   1799             excludes=tags[idx + 1 :] + excludes,
   1800             include_merge=include_merge,
   1801             encoding=log_encoding,
   1802         )
   1803 
   1804         for commit in commits:
   1805             if any(
   1806                 re.search(pattern, commit.subject) is not None
   1807                 for pattern in ignore_regexps
   1808             ):
   1809                 continue
   1810 
   1811             body = body_process(commit.body)
   1812 
   1813             ## Extract gitlab issue number
   1814             issue = None
   1815             if match := re.search(r".*:gl:`#([0-9]+)`", body):
   1816                 issue = int(match.group(1))
   1817 
   1818             matched_section = first_matching(section_regexps, commit.subject)
   1819 
   1820             ## Finally storing the commit in the matching section
   1821 
   1822             sections[matched_section].append(
   1823                 {
   1824                     "author": commit.author_name,
   1825                     "authors": commit.author_names,
   1826                     "subject": subject_process(commit.subject),
   1827                     "body": body,
   1828                     "commit": commit,
   1829                     "issue": issue,
   1830                 }
   1831             )
   1832 
   1833         ## Sort sections by issue number or title
   1834         for section_key in sections.keys():
   1835             sections[section_key].sort(
   1836                 key=lambda c: (
   1837                     c["issue"] if c["issue"] is not None else sys.maxsize,
   1838                     c["subject"],
   1839                 )
   1840             )
   1841 
   1842         ## Flush current version
   1843         current_version["sections"] = [
   1844             {"label": k, "commits": sections[k]} for k in section_order if k in sections
   1845         ]
   1846         if len(current_version["sections"]) != 0:
   1847             yield current_version
   1848         versions_done[tag] = current_version
   1849 
   1850 
   1851 def changelog(
   1852     output_engine=rest_py,
   1853     unreleased_version_label="unreleased",
   1854     include_commit_sha=False,
   1855     warn=warn,  ## Mostly used for test
   1856     **kwargs,
   1857 ):
   1858     """Returns a string containing the changelog of given repository
   1859 
   1860     This function returns a string corresponding to the template rendered with
   1861     the changelog data tree.
   1862 
   1863     (see ``gitchangelog.rc.sample`` file for more info)
   1864 
   1865     For an exact list of arguments, see the arguments of
   1866     ``versions_data_iter(..)``.
   1867 
   1868     :param unreleased_version_label: version label for untagged commits
   1869     :param include_commit_sha: whether message should contain commit sha
   1870     :param output_engine: callable to render the changelog data
   1871     :param warn: callable to output warnings, mocked by tests
   1872 
   1873     :returns: content of changelog
   1874 
   1875     """
   1876 
   1877     opts = {
   1878         "unreleased_version_label": unreleased_version_label,
   1879         "include_commit_sha": include_commit_sha,
   1880     }
   1881 
   1882     ## Setting main container of changelog elements
   1883     title = None if kwargs.get("revlist") else "Changelog"
   1884     data = {"title": title, "versions": []}
   1885 
   1886     versions = versions_data_iter(warn=warn, **kwargs)
   1887 
   1888     ## poke once in versions to know if there's at least one:
   1889     try:
   1890         first_version = next(versions)
   1891     except StopIteration:
   1892         die("Empty changelog. No commits were elected to be used as entry.")
   1893     else:
   1894         data["versions"] = itertools.chain([first_version], versions)
   1895 
   1896     return output_engine(data=data, opts=opts)
   1897 
   1898 
   1899 ##
   1900 ## Manage obsolete options
   1901 ##
   1902 
   1903 _obsolete_options_managers = []
   1904 
   1905 
   1906 def obsolete_option_manager(fun):
   1907     _obsolete_options_managers.append(fun)
   1908 
   1909 
   1910 @obsolete_option_manager
   1911 def obsolete_replace_regexps(config):
   1912     """This option was superseeded by the ``subject_process`` option.
   1913 
   1914     Each regex replacement you had could be translated in a
   1915     ``ReSub(pattern, replace)`` in the ``subject_process`` pipeline.
   1916 
   1917     """
   1918     if "replace_regexps" in config:
   1919         for pattern, replace in config["replace_regexps"].items():
   1920             config["subject_process"] = ReSub(pattern, replace) | config.get(
   1921                 "subject_process", ucfirst | final_dot
   1922             )
   1923 
   1924 
   1925 @obsolete_option_manager
   1926 def obsolete_body_split_regexp(config):
   1927     """This option was superseeded by the ``body_process`` option.
   1928 
   1929     The split regex can now be sent as a ``Wrap(regex)`` text process
   1930     instruction in the ``body_process`` pipeline.
   1931 
   1932     """
   1933     if "body_split_regex" in config:
   1934         config["body_process"] = Wrap(config["body_split_regex"]) | config.get(
   1935             "body_process", noop
   1936         )
   1937 
   1938 
   1939 def manage_obsolete_options(config):
   1940     for man in _obsolete_options_managers:
   1941         man(config)
   1942 
   1943 
   1944 ##
   1945 ## Command line parsing
   1946 ##
   1947 
   1948 
   1949 def parse_cmd_line(usage, description, epilog, exname, version):
   1950 
   1951     import argparse
   1952 
   1953     kwargs = dict(
   1954         usage=usage,
   1955         description=description,
   1956         epilog="\n" + epilog,
   1957         prog=exname,
   1958         formatter_class=argparse.RawTextHelpFormatter,
   1959     )
   1960 
   1961     try:
   1962         parser = argparse.ArgumentParser(version=version, **kwargs)
   1963     except TypeError:  ## compat with argparse from python 3.4
   1964         parser = argparse.ArgumentParser(**kwargs)
   1965         parser.add_argument(
   1966             "-v",
   1967             "--version",
   1968             help="show program's version number and exit",
   1969             action="version",
   1970             version=version,
   1971         )
   1972 
   1973     parser.add_argument(
   1974         "-d",
   1975         "--debug",
   1976         help="Enable debug mode (show full tracebacks).",
   1977         action="store_true",
   1978         dest="debug",
   1979     )
   1980     parser.add_argument("revlist", nargs="*", action="store", default=[])
   1981 
   1982     ## Remove "show" as first argument for compatibility reason.
   1983 
   1984     argv = []
   1985     for i, arg in enumerate(sys.argv[1:]):
   1986         if arg.startswith("-"):
   1987             argv.append(arg)
   1988             continue
   1989         if arg == "show":
   1990             warn("'show' positional argument is deprecated.")
   1991             argv += sys.argv[i + 2 :]
   1992             break
   1993         else:
   1994             argv += sys.argv[i + 1 :]
   1995             break
   1996 
   1997     return parser.parse_args(argv)
   1998 
   1999 
   2000 eval_if_callable = lambda v: v() if callable(v) else v
   2001 
   2002 
   2003 def get_revision(repository, config, opts):
   2004     if opts.revlist:
   2005         revs = opts.revlist
   2006     else:
   2007         revs = config.get("revs")
   2008         if revs:
   2009             revs = eval_if_callable(revs)
   2010             if not isinstance(revs, list):
   2011                 die(
   2012                     "Invalid type for 'revs' in config file. "
   2013                     "A 'list' type is required, and a %r was given."
   2014                     % type(revs).__name__
   2015                 )
   2016             revs = [eval_if_callable(rev) for rev in revs]
   2017         else:
   2018             revs = []
   2019 
   2020     for rev in revs:
   2021         if not isinstance(rev, basestring):
   2022             die(
   2023                 "Invalid type for revision in revs list from config file. "
   2024                 "'str' type is required, and a %r was given." % type(rev).__name__
   2025             )
   2026         try:
   2027             repository.git.rev_parse([rev, "--rev_only", "--"])
   2028         except ShellError:
   2029             if DEBUG:
   2030                 raise
   2031             die("Revision %r is not valid." % rev)
   2032 
   2033     if revs == [
   2034         "HEAD",
   2035     ]:
   2036         return []
   2037     return revs
   2038 
   2039 
   2040 def get_log_encoding(repository, config):
   2041 
   2042     log_encoding = config.get("log_encoding", None)
   2043     if log_encoding is None:
   2044         try:
   2045             log_encoding = repository.config.get("i18n.logOuputEncoding")
   2046         except ShellError as e:
   2047             warn(
   2048                 "Error parsing git config: %s."
   2049                 " Couldn't check if 'i18n.logOuputEncoding' was set." % (str(e))
   2050             )
   2051 
   2052     ## Final defaults coming from git defaults
   2053     return log_encoding or DEFAULT_GIT_LOG_ENCODING
   2054 
   2055 
   2056 ##
   2057 ## Config Manager
   2058 ##
   2059 
   2060 
   2061 class Config(dict):
   2062 
   2063     def __getitem__(self, label):
   2064         if label not in self.keys():
   2065             die("Missing value in config file for key '%s'." % label)
   2066         return super(Config, self).__getitem__(label)
   2067 
   2068 
   2069 ##
   2070 ## Safe print
   2071 ##
   2072 
   2073 
   2074 def safe_print(content):
   2075     if not PY3:
   2076         if isinstance(content, unicode):
   2077             content = content.encode(_preferred_encoding)
   2078 
   2079     try:
   2080         print(content, end="")
   2081         sys.stdout.flush()
   2082     except UnicodeEncodeError:
   2083         if DEBUG:
   2084             raise
   2085         ## XXXvlab: should use $COLUMNS in bash and for windows:
   2086         ## http://stackoverflow.com/questions/14978548
   2087         stderr(
   2088             paragraph_wrap(
   2089                 textwrap.dedent(
   2090                     """\
   2091             UnicodeEncodeError:
   2092               There was a problem outputing the resulting changelog to
   2093               your console.
   2094 
   2095               This probably means that the changelog contains characters
   2096               that can't be translated to characters in your current charset
   2097               (%s).
   2098             """
   2099                 )
   2100                 % sys.stdout.encoding
   2101             )
   2102         )
   2103         if WIN32 and PY_VERSION < 3.6 and sys.stdout.encoding != "utf-8":
   2104             ## As of PY 3.6, encoding is now ``utf-8`` regardless of
   2105             ## PYTHONIOENCODING
   2106             ## https://www.python.org/dev/peps/pep-0528/
   2107             stderr(
   2108                 "  You might want to try to fix that by setting "
   2109                 "PYTHONIOENCODING to 'utf-8'."
   2110             )
   2111         exit(1)
   2112     except IOError as e:
   2113         if e.errno == 0 and not PY3 and WIN32:
   2114             ## Yes, had a strange IOError Errno 0 after outputing string
   2115             ## that contained UTF-8 chars on Windows and PY2.7
   2116             pass  ## Ignoring exception
   2117         elif (WIN32 and e.errno == 22) or (  ## Invalid argument
   2118             not WIN32 and e.errno == errno.EPIPE
   2119         ):  ## Broken Pipe
   2120             ## Nobody is listening anymore to stdout it seems. Let's bailout.
   2121             if PY3:
   2122                 try:
   2123                     ## Called only to generate exception and have a chance at
   2124                     ## ignoring it. Otherwise this happens upon exit, and gets
   2125                     ## some error message printed on stderr.
   2126                     sys.stdout.close()
   2127                 except BrokenPipeError:  ## expected outcome on linux
   2128                     pass
   2129                 except OSError as e2:
   2130                     if e2.errno != 22:  ## expected outcome on WIN32
   2131                         raise
   2132             ## Yay ! stdout is closed we can now exit safely.
   2133             exit(0)
   2134         else:
   2135             raise
   2136 
   2137 
   2138 ##
   2139 ## Main
   2140 ##
   2141 
   2142 
   2143 def main():
   2144 
   2145     global DEBUG
   2146     ## Basic environment infos
   2147 
   2148     reference_config = os.path.join(
   2149         os.path.dirname(os.path.realpath(__file__)), "gitchangelog.rc.reference"
   2150     )
   2151 
   2152     basename = os.path.basename(sys.argv[0])
   2153     if basename.endswith(".py"):
   2154         basename = basename[:-3]
   2155 
   2156     debug_varname = "DEBUG_%s" % basename.upper()
   2157     DEBUG = os.environ.get(debug_varname, False)
   2158 
   2159     i = lambda x: x % {"exname": basename}
   2160 
   2161     opts = parse_cmd_line(
   2162         usage=i(usage_msg),
   2163         description=i(description_msg),
   2164         epilog=i(epilog_msg),
   2165         exname=basename,
   2166         version=__version__,
   2167     )
   2168     DEBUG = DEBUG or opts.debug
   2169 
   2170     try:
   2171         repository = GitRepos(".")
   2172     except EnvironmentError as e:
   2173         if DEBUG:
   2174             raise
   2175         try:
   2176             die(str(e))
   2177         except Exception as e2:
   2178             die(repr(e2))
   2179 
   2180     try:
   2181         gc_rc = repository.config.get("gitchangelog.rc-path")
   2182     except ShellError as e:
   2183         stderr(
   2184             "Error parsing git config: %s."
   2185             " Won't be able to read 'rc-path' if defined." % (str(e))
   2186         )
   2187         gc_rc = None
   2188 
   2189     gc_rc = normpath(gc_rc, cwd=repository.toplevel) if gc_rc else None
   2190 
   2191     ## config file lookup resolution
   2192     for enforce_file_existence, fun in [
   2193         (True, lambda: os.environ.get("GITCHANGELOG_CONFIG_FILENAME")),
   2194         (True, lambda: gc_rc),
   2195         (
   2196             False,
   2197             lambda: (
   2198                 (os.path.join(repository.toplevel, ".%s.rc" % basename))
   2199                 if not repository.bare
   2200                 else None
   2201             ),
   2202         ),
   2203     ]:
   2204         changelogrc = fun()
   2205         if changelogrc:
   2206             if not os.path.exists(changelogrc):
   2207                 if enforce_file_existence:
   2208                     die("File %r does not exists." % changelogrc)
   2209                 else:
   2210                     continue  ## changelogrc valued, but file does not exists
   2211             else:
   2212                 break
   2213 
   2214     ## config file may lookup for templates relative to the toplevel
   2215     ## of git repository
   2216     os.chdir(repository.toplevel)
   2217 
   2218     config = load_config_file(
   2219         os.path.expanduser(changelogrc),
   2220         default_filename=reference_config,
   2221         fail_if_not_present=False,
   2222     )
   2223 
   2224     config = Config(config)
   2225 
   2226     log_encoding = get_log_encoding(repository, config)
   2227     revlist = get_revision(repository, config, opts)
   2228     config["unreleased_version_label"] = eval_if_callable(
   2229         config["unreleased_version_label"]
   2230     )
   2231     manage_obsolete_options(config)
   2232 
   2233     try:
   2234         content = changelog(
   2235             repository=repository,
   2236             revlist=revlist,
   2237             ignore_regexps=config["ignore_regexps"],
   2238             section_regexps=config["section_regexps"],
   2239             unreleased_version_label=config["unreleased_version_label"],
   2240             include_commit_sha=config["include_commit_sha"],
   2241             tag_filter_regexp=config["tag_filter_regexp"],
   2242             output_engine=config.get("output_engine", rest_py),
   2243             include_merge=config.get("include_merge", True),
   2244             body_process=config.get("body_process", noop),
   2245             subject_process=config.get("subject_process", noop),
   2246             log_encoding=log_encoding,
   2247         )
   2248 
   2249         if isinstance(content, basestring):
   2250             content = content.splitlines(True)
   2251 
   2252         config.get("publish", stdout)(content)
   2253 
   2254     except KeyboardInterrupt:
   2255         if DEBUG:
   2256             err("Keyboard interrupt received while running '%s':" % (basename,))
   2257             stderr(format_last_exception())
   2258         else:
   2259             err("Keyboard Interrupt. Bailing out.")
   2260         exit(130)  ## Actual SIGINT as bash process convention.
   2261     except Exception as e:  ## pylint: disable=broad-except
   2262         if DEBUG:
   2263             err("Exception while running '%s':" % (basename,))
   2264             stderr(format_last_exception())
   2265         else:
   2266             message = "%s" % e
   2267             err(message)
   2268             stderr(
   2269                 "  (set %s environment variable, "
   2270                 "or use ``--debug`` to see full traceback)" % (debug_varname,)
   2271             )
   2272         exit(255)
   2273 
   2274 
   2275 ##
   2276 ## Launch program
   2277 ##
   2278 
   2279 if __name__ == "__main__":
   2280     main()
   2281