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.fromtimestamp(
   1083             float(self.author_date_timestamp), datetime.timezone.utc
   1084         )
   1085         return d.strftime("%Y-%m-%d")
   1086 
   1087     @property
   1088     def has_annotated_tag(self):
   1089         try:
   1090             self.git.rev_parse(["%s^{tag}" % self.identifier, "--"])
   1091             return True
   1092         except ShellError as e:
   1093             if e.errlvl != 128:
   1094                 raise
   1095             return False
   1096 
   1097     @property
   1098     def tagger_date_timestamp(self):
   1099         if not self.has_annotated_tag:
   1100             raise ValueError(
   1101                 "Can't access 'tagger_date_timestamp' on commit without annotated tag."
   1102             )
   1103         tagger_date_utc = self.git.for_each_ref(
   1104             "refs/tags/%s" % self.identifier, format="%(taggerdate:raw)"
   1105         )
   1106         return tagger_date_utc.split(" ", 1)[0]
   1107 
   1108     @property
   1109     def tagger_date(self):
   1110         d = datetime.datetime.fromtimestamp(
   1111             float(self.tagger_date_timestamp), datetime.UTC
   1112         )
   1113         return d.strftime("%Y-%m-%d")
   1114 
   1115     def __le__(self, value):
   1116         if not isinstance(value, GitCommit):
   1117             value = self._repos.commit(value)
   1118         try:
   1119             self.git.merge_base(value.sha1, is_ancestor=self.sha1)
   1120             return True
   1121         except ShellError as e:
   1122             if e.errlvl != 1:
   1123                 raise
   1124             return False
   1125 
   1126     def __lt__(self, value):
   1127         if not isinstance(value, GitCommit):
   1128             value = self._repos.commit(value)
   1129         return self <= value and self != value
   1130 
   1131     def __eq__(self, value):
   1132         if not isinstance(value, GitCommit):
   1133             value = self._repos.commit(value)
   1134         return self.sha1 == value.sha1
   1135 
   1136     def __hash__(self):
   1137         return hash(self.sha1)
   1138 
   1139     def __repr__(self):
   1140         return "<%s %r>" % (self.__class__.__name__, self.identifier)
   1141 
   1142 
   1143 def normpath(path, cwd=None):
   1144     """path can be absolute or relative, if relative it uses the cwd given as
   1145     param.
   1146 
   1147     """
   1148     if os.path.isabs(path):
   1149         return path
   1150     cwd = cwd if cwd else os.getcwd()
   1151     return os.path.normpath(os.path.join(cwd, path))
   1152 
   1153 
   1154 class GitConfig(SubGitObjectMixin):
   1155     """Interface to config values of git
   1156 
   1157     Let's create a fake GitRepos:
   1158 
   1159         >>> from minimock import Mock
   1160         >>> repos = Mock("gitRepos")
   1161 
   1162     Initialization:
   1163 
   1164         >>> cfg = GitConfig(repos)
   1165 
   1166     Query, by attributes or items:
   1167 
   1168         >>> repos.git.config.mock_returns = "bar"
   1169         >>> cfg.foo
   1170         Called gitRepos.git.config('foo')
   1171         'bar'
   1172         >>> cfg["foo"]
   1173         Called gitRepos.git.config('foo')
   1174         'bar'
   1175         >>> cfg.get("foo")
   1176         Called gitRepos.git.config('foo')
   1177         'bar'
   1178         >>> cfg["foo.wiz"]
   1179         Called gitRepos.git.config('foo.wiz')
   1180         'bar'
   1181 
   1182     Notice that you can't use attribute search in subsection as ``cfg.foo.wiz``
   1183     That's because in git config files, you can have a value attached to
   1184     an element, and this element can also be a section.
   1185 
   1186     Nevertheless, you can do:
   1187 
   1188         >>> getattr(cfg, "foo.wiz")
   1189         Called gitRepos.git.config('foo.wiz')
   1190         'bar'
   1191 
   1192     Default values
   1193     --------------
   1194 
   1195     get item, and getattr default values can be used:
   1196 
   1197         >>> del repos.git.config.mock_returns
   1198         >>> repos.git.config.mock_raises = ShellError('Key not found',
   1199         ...                                           errlvl=1, out="", err="")
   1200 
   1201         >>> getattr(cfg, "foo", "default")
   1202         Called gitRepos.git.config('foo')
   1203         'default'
   1204 
   1205         >>> cfg["foo"]  ## doctest: +ELLIPSIS
   1206         Traceback (most recent call last):
   1207         ...
   1208         KeyError: 'foo'
   1209 
   1210         >>> getattr(cfg, "foo")  ## doctest: +ELLIPSIS
   1211         Traceback (most recent call last):
   1212         ...
   1213         AttributeError...
   1214 
   1215         >>> cfg.get("foo", "default")
   1216         Called gitRepos.git.config('foo')
   1217         'default'
   1218 
   1219         >>> print("%r" % cfg.get("foo"))
   1220         Called gitRepos.git.config('foo')
   1221         None
   1222 
   1223     """
   1224 
   1225     def __init__(self, repos):
   1226         super(GitConfig, self).__init__(repos)
   1227 
   1228     def __getattr__(self, label):
   1229         try:
   1230             res = self.git.config(label)
   1231         except ShellError as e:
   1232             if e.errlvl == 1 and e.out == "":
   1233                 raise AttributeError("key %r is not found in git config." % label)
   1234             raise
   1235         return res
   1236 
   1237     def get(self, label, default=None):
   1238         return getattr(self, label, default)
   1239 
   1240     def __getitem__(self, label):
   1241         try:
   1242             return getattr(self, label)
   1243         except AttributeError:
   1244             raise KeyError(label)
   1245 
   1246 
   1247 class GitCmd(SubGitObjectMixin):
   1248 
   1249     def __getattr__(self, label):
   1250         label = label.replace("_", "-")
   1251 
   1252         def dir_swrap(command, **kwargs):
   1253             with set_cwd(self._repos._orig_path):
   1254                 return swrap(command, **kwargs)
   1255 
   1256         def method(*args, **kwargs):
   1257             if len(args) == 1 and not isinstance(args[0], basestring):
   1258                 return dir_swrap(
   1259                     [
   1260                         "git",
   1261                         label,
   1262                     ]
   1263                     + args[0],
   1264                     shell=False,
   1265                     env=kwargs.get("env", None),
   1266                 )
   1267             cli_args = []
   1268             for key, value in kwargs.items():
   1269                 cli_key = ("-%s" if len(key) == 1 else "--%s") % key.replace("_", "-")
   1270                 if isinstance(value, bool):
   1271                     cli_args.append(cli_key)
   1272                 else:
   1273                     cli_args.append(cli_key)
   1274                     cli_args.append(value)
   1275 
   1276             cli_args.extend(args)
   1277 
   1278             return dir_swrap(
   1279                 [
   1280                     "git",
   1281                     label,
   1282                 ]
   1283                 + cli_args,
   1284                 shell=False,
   1285             )
   1286 
   1287         return method
   1288 
   1289 
   1290 class GitRepos(object):
   1291 
   1292     def __init__(self, path):
   1293 
   1294         ## Saving this original path to ensure all future git commands
   1295         ## will be done from this location.
   1296         self._orig_path = os.path.abspath(path)
   1297 
   1298         ## verify ``git`` command is accessible:
   1299         try:
   1300             self._git_version = self.git.version()
   1301         except ShellError:
   1302             if DEBUG:
   1303                 raise
   1304             raise EnvironmentError(
   1305                 "Required ``git`` command not found or broken in $PATH. "
   1306                 "(calling ``git version`` failed.)"
   1307             )
   1308 
   1309         ## verify that we are in a git repository
   1310         try:
   1311             self.git.remote()
   1312         except ShellError:
   1313             if DEBUG:
   1314                 raise
   1315             raise EnvironmentError(
   1316                 "Not in a git repository. (calling ``git remote`` failed.)"
   1317             )
   1318 
   1319         self.bare = self.git.rev_parse(is_bare_repository=True) == "true"
   1320         self.toplevel = None if self.bare else self.git.rev_parse(show_toplevel=True)
   1321         self.gitdir = normpath(self.git.rev_parse(git_dir=True), cwd=self._orig_path)
   1322 
   1323     @classmethod
   1324     def create(cls, directory, *args, **kwargs):
   1325         os.mkdir(directory)
   1326         return cls.init(directory, *args, **kwargs)
   1327 
   1328     @classmethod
   1329     def init(cls, directory, user=None, email=None):
   1330         with set_cwd(directory):
   1331             wrap("git init .")
   1332         self = cls(directory)
   1333         if user:
   1334             self.git.config("user.name", user)
   1335         if email:
   1336             self.git.config("user.email", email)
   1337         return self
   1338 
   1339     def commit(self, identifier):
   1340         return GitCommit(self, identifier)
   1341 
   1342     @property
   1343     def git(self):
   1344         return GitCmd(self)
   1345 
   1346     @property
   1347     def config(self):
   1348         return GitConfig(self)
   1349 
   1350     def tags(self, contains=None):
   1351         """String list of repository's tag names
   1352 
   1353         Current tag order is committer date timestamp of tagged commit.
   1354         No firm reason for that, and it could change in future version.
   1355 
   1356         """
   1357         if contains:
   1358             tags = self.git.tag(contains=contains).split("\n")
   1359         else:
   1360             tags = self.git.tag().split("\n")
   1361         ## Should we use new version name sorting ?  refering to :
   1362         ## ``git tags --sort -v:refname`` in git version >2.0.
   1363         ## Sorting and reversing with command line is not available on
   1364         ## git version <2.0
   1365         return sorted(
   1366             [self.commit(tag) for tag in tags if tag != ""],
   1367             key=lambda x: int(x.committer_date_timestamp),
   1368         )
   1369 
   1370     def log(
   1371         self,
   1372         includes=[
   1373             "HEAD",
   1374         ],
   1375         excludes=[],
   1376         include_merge=True,
   1377         encoding=_preferred_encoding,
   1378     ):
   1379         """Reverse chronological list of git repository's commits
   1380 
   1381         Note: rev lists can be GitCommit instance list or identifier list.
   1382 
   1383         """
   1384 
   1385         refs = {"includes": includes, "excludes": excludes}
   1386         for ref_type in ("includes", "excludes"):
   1387             for idx, ref in enumerate(refs[ref_type]):
   1388                 if not isinstance(ref, GitCommit):
   1389                     refs[ref_type][idx] = self.commit(ref)
   1390 
   1391         ## --topo-order: don't mix commits from separate branches.
   1392         plog = Proc(
   1393             "git log --stdin -z --topo-order --pretty=format:%s %s --"
   1394             % (GIT_FULL_FORMAT_STRING, "--no-merges" if not include_merge else ""),
   1395             encoding=encoding,
   1396         )
   1397         for ref in refs["includes"]:
   1398             plog.stdin.write("%s\n" % ref.sha1)
   1399 
   1400         for ref in refs["excludes"]:
   1401             plog.stdin.write("^%s\n" % ref.sha1)
   1402         plog.stdin.close()
   1403 
   1404         def mk_commit(dct):
   1405             """Creates an already set commit from a dct"""
   1406             c = self.commit(dct["sha1"])
   1407             for k, v in dct.items():
   1408                 setattr(c, k, v)
   1409             return c
   1410 
   1411         values = plog.stdout.read("\x00")
   1412 
   1413         try:
   1414             while True:  ## next(values) will eventualy raise a StopIteration
   1415                 yield mk_commit(dict([(key, next(values)) for key in GIT_FORMAT_KEYS]))
   1416         except StopIteration:
   1417             pass  ## since 3.7, we are not allowed anymore to trickle down
   1418             ## StopIteration.
   1419         finally:
   1420             plog.stdout.close()
   1421             plog.stderr.close()
   1422 
   1423 
   1424 def first_matching(section_regexps, string):
   1425     for section, regexps in section_regexps:
   1426         if regexps is None:
   1427             return section
   1428         for regexp in regexps:
   1429             if re.search(regexp, string) is not None:
   1430                 return section
   1431 
   1432 
   1433 def ensure_template_file_exists(label, template_name):
   1434     """Return template file path given a label hint and the template name
   1435 
   1436     Template name can be either a filename with full path,
   1437     if this is the case, the label is of no use.
   1438 
   1439     If ``template_name`` does not refer to an existing file,
   1440     then ``label`` is used to find a template file in the
   1441     the bundled ones.
   1442 
   1443     """
   1444 
   1445     try:
   1446         template_path = GitRepos(os.getcwd()).config.get("gitchangelog.template-path")
   1447     except ShellError as e:
   1448         stderr(
   1449             "Error parsing git config: %s."
   1450             " Won't be able to read 'template-path' if defined." % (str(e))
   1451         )
   1452         template_path = None
   1453 
   1454     if template_path:
   1455         path_file = path_label = template_path
   1456     else:
   1457         path_file = os.getcwd()
   1458         path_label = os.path.join(
   1459             os.path.dirname(os.path.realpath(__file__)), "templates", label
   1460         )
   1461 
   1462     for ftn in [
   1463         os.path.join(path_file, template_name),
   1464         os.path.join(path_label, "%s.tpl" % template_name),
   1465     ]:
   1466         if os.path.isfile(ftn):
   1467             return ftn
   1468 
   1469     templates = glob.glob(os.path.join(path_label, "*.tpl"))
   1470     if len(templates) > 0:
   1471         msg = "These are the available %s templates:" % label
   1472         msg += "\n - " + "\n - ".join(
   1473             os.path.basename(f).split(".")[0] for f in templates
   1474         )
   1475         msg += "\nTemplates are located in %r" % path_label
   1476     else:
   1477         msg = "No available %s templates found in %r." % (label, path_label)
   1478     die("Error: Invalid %s template name %r.\n" % (label, template_name) + "%s" % msg)
   1479 
   1480 
   1481 ##
   1482 ## Output Engines
   1483 ##
   1484 
   1485 
   1486 @available_in_config
   1487 def rest_py(data, opts={}):
   1488     """Returns ReStructured Text changelog content from data"""
   1489 
   1490     def rest_title(label, char="="):
   1491         return (label.strip() + "\n") + (char * len(label) + "\n\n")
   1492 
   1493     def render_version(version):
   1494         title = (
   1495             "%s (%s)" % (version["tag"], version["date"])
   1496             if version["tag"]
   1497             else opts["unreleased_version_label"]
   1498         )
   1499         s = rest_title(title, char="-")
   1500 
   1501         sections = version["sections"]
   1502         nb_sections = len(sections)
   1503         for section in sections:
   1504 
   1505             section_label = section["label"] if section.get("label", None) else "Other"
   1506 
   1507             if not (section_label == "Other" and nb_sections == 1):
   1508                 s += rest_title(section_label, "~")
   1509 
   1510             for commit in section["commits"]:
   1511                 s += render_commit(commit, opts)
   1512         return s
   1513 
   1514     def render_commit(commit, opts=opts):
   1515         subject = commit["subject"]
   1516 
   1517         if opts["include_commit_sha"]:
   1518             subject += " ``%s``" % commit["commit"].sha1_short
   1519 
   1520         entry = (
   1521             indent(
   1522                 "\n".join(textwrap.wrap(subject, break_on_hyphens=False)), first="- "
   1523             ).strip()
   1524             + "\n"
   1525         )
   1526 
   1527         if commit["body"]:
   1528             entry += "\n" + indent(commit["body"])
   1529             entry += "\n"
   1530 
   1531         entry += "\n"
   1532 
   1533         return entry
   1534 
   1535     if data["title"]:
   1536         yield rest_title(data["title"], char="=") + "\n"
   1537 
   1538     for version in data["versions"]:
   1539         if len(version["sections"]) > 0:
   1540             yield render_version(version) + "\n"
   1541 
   1542 
   1543 ## formatter engines
   1544 
   1545 if pystache:
   1546 
   1547     @available_in_config
   1548     def mustache(template_name):
   1549         """Return a callable that will render a changelog data structure
   1550 
   1551         returned callable must take 2 arguments ``data`` and ``opts``.
   1552 
   1553         """
   1554         template_path = ensure_template_file_exists("mustache", template_name)
   1555 
   1556         template = file_get_contents(template_path)
   1557 
   1558         def stuffed_versions(versions, opts):
   1559             for version in versions:
   1560                 title = (
   1561                     "%s (%s)" % (version["tag"], version["date"])
   1562                     if version["tag"]
   1563                     else opts["unreleased_version_label"]
   1564                 )
   1565                 version["label"] = title
   1566                 version["label_chars"] = list(version["label"])
   1567                 for section in version["sections"]:
   1568                     section["label_chars"] = list(section["label"])
   1569                     section["display_label"] = not (
   1570                         section["label"] == "Other" and len(version["sections"]) == 1
   1571                     )
   1572                     for commit in section["commits"]:
   1573                         commit["author_names_joined"] = ", ".join(commit["authors"])
   1574                         commit["body_indented"] = indent(commit["body"])
   1575                 yield version
   1576 
   1577         def renderer(data, opts):
   1578 
   1579             ## mustache is very simple so we need to add some intermediate
   1580             ## values
   1581             data["general_title"] = True if data["title"] else False
   1582             data["title_chars"] = list(data["title"]) if data["title"] else []
   1583 
   1584             data["versions"] = stuffed_versions(data["versions"], opts)
   1585 
   1586             return pystache.render(template, data)
   1587 
   1588         return renderer
   1589 
   1590 else:
   1591 
   1592     @available_in_config
   1593     def mustache(template_name):  ## pylint: disable=unused-argument
   1594         die("Required 'pystache' python module not found.")
   1595 
   1596 
   1597 if mako:
   1598 
   1599     import mako.template  ## pylint: disable=wrong-import-position
   1600 
   1601     mako_env = dict(
   1602         (f.__name__, f) for f in (ucfirst, indent, textwrap, paragraph_wrap)
   1603     )
   1604 
   1605     @available_in_config
   1606     def makotemplate(template_name):
   1607         """Return a callable that will render a changelog data structure
   1608 
   1609         returned callable must take 2 arguments ``data`` and ``opts``.
   1610 
   1611         """
   1612         template_path = ensure_template_file_exists("mako", template_name)
   1613 
   1614         template = mako.template.Template(filename=template_path)
   1615 
   1616         def renderer(data, opts):
   1617             kwargs = mako_env.copy()
   1618             kwargs.update({"data": data, "opts": opts})
   1619             return template.render(**kwargs)
   1620 
   1621         return renderer
   1622 
   1623 else:
   1624 
   1625     @available_in_config
   1626     def makotemplate(template_name):  ## pylint: disable=unused-argument
   1627         die("Required 'mako' python module not found.")
   1628 
   1629 
   1630 ##
   1631 ## Publish action
   1632 ##
   1633 
   1634 
   1635 @available_in_config
   1636 def stdout(content):
   1637     for chunk in content:
   1638         safe_print(chunk)
   1639 
   1640 
   1641 @available_in_config
   1642 def FileInsertAtFirstRegexMatch(filename, pattern, flags=0, idx=lambda m: m.start()):
   1643 
   1644     def write_content(f, content):
   1645         for content_line in content:
   1646             f.write(content_line)
   1647 
   1648     def _wrapped(content):
   1649         index = idx(_file_regex_match(filename, pattern, flags=flags))
   1650         offset = 0
   1651         new_offset = 0
   1652         postfix = False
   1653 
   1654         with open(filename + "~", "w") as dst:
   1655             with open(filename, "r") as src:
   1656                 for line in src:
   1657                     if postfix:
   1658                         dst.write(line)
   1659                         continue
   1660                     new_offset = offset + len(line)
   1661                     if new_offset < index:
   1662                         offset = new_offset
   1663                         dst.write(line)
   1664                         continue
   1665                     dst.write(line[0 : index - offset])
   1666                     write_content(dst, content)
   1667                     dst.write(line[index - offset :])
   1668                     postfix = True
   1669             if not postfix:
   1670                 write_content(dst, content)
   1671         if WIN32:
   1672             os.remove(filename)
   1673         os.rename(filename + "~", filename)
   1674 
   1675     return _wrapped
   1676 
   1677 
   1678 @available_in_config
   1679 def FileRegexSubst(filename, pattern, replace, flags=0):
   1680 
   1681     replace = re.sub(r"\\([0-9+])", r"\\g<\1>", replace)
   1682 
   1683     def _wrapped(content):
   1684         src = file_get_contents(filename)
   1685         ## Protect replacement pattern against the following expansion of '\o'
   1686         src = re.sub(
   1687             pattern,
   1688             replace.replace(r"\o", "".join(content).replace("\\", "\\\\")),
   1689             src,
   1690             flags=flags,
   1691         )
   1692         if not PY3:
   1693             src = src.encode(_preferred_encoding)
   1694         file_put_contents(filename, src)
   1695 
   1696     return _wrapped
   1697 
   1698 
   1699 ##
   1700 ## Data Structure
   1701 ##
   1702 
   1703 
   1704 def versions_data_iter(
   1705     repository,
   1706     revlist=None,
   1707     ignore_regexps=[],
   1708     section_regexps=[(None, "")],
   1709     tag_filter_regexp=r"\d+\.\d+(\.\d+)?",
   1710     include_merge=True,
   1711     body_process=lambda x: x,
   1712     subject_process=lambda x: x,
   1713     log_encoding=DEFAULT_GIT_LOG_ENCODING,
   1714     warn=warn,  ## Mostly used for test
   1715 ):
   1716     """Returns an iterator through versions data structures
   1717 
   1718     (see ``gitchangelog.rc.reference`` file for more info)
   1719 
   1720     :param repository: target ``GitRepos`` object
   1721     :param revlist: list of strings that git log understands as revlist
   1722     :param ignore_regexps: list of regexp identifying ignored commit messages
   1723     :param section_regexps: regexps identifying sections
   1724     :param tag_filter_regexp: regexp to match tags used as version
   1725     :param include_merge: whether to include merge commits in the log or not
   1726     :param body_process: text processing object to apply to body
   1727     :param subject_process: text processing object to apply to subject
   1728     :param log_encoding: the encoding used in git logs
   1729     :param warn: callable to output warnings, mocked by tests
   1730 
   1731     :returns: iterator of versions data_structures
   1732 
   1733     """
   1734 
   1735     revlist = revlist or []
   1736 
   1737     ## Hash to speedup lookups
   1738     versions_done = {}
   1739     excludes = (
   1740         [
   1741             rev[1:]
   1742             for rev in repository.git.rev_parse(
   1743                 [
   1744                     "--rev-only",
   1745                 ]
   1746                 + revlist
   1747                 + [
   1748                     "--",
   1749                 ]
   1750             ).split("\n")
   1751             if rev.startswith("^")
   1752         ]
   1753         if revlist
   1754         else []
   1755     )
   1756 
   1757     revs = repository.git.rev_list(*revlist).split("\n") if revlist else []
   1758     revs = [rev for rev in revs if rev != ""]
   1759 
   1760     if revlist and not revs:
   1761         die("No commits matching given revlist: %s" % (" ".join(revlist),))
   1762 
   1763     tags = [
   1764         tag
   1765         for tag in repository.tags(contains=revs[-1] if revs else None)
   1766         if re.match(tag_filter_regexp, tag.identifier)
   1767     ]
   1768 
   1769     tags.append(repository.commit("HEAD"))
   1770 
   1771     if revlist:
   1772         max_rev = repository.commit(revs[0])
   1773         new_tags = []
   1774         for tag in tags:
   1775             new_tags.append(tag)
   1776             if max_rev <= tag:
   1777                 break
   1778         tags = new_tags
   1779     else:
   1780         max_rev = tags[-1]
   1781 
   1782     section_order = [k for k, _v in section_regexps]
   1783 
   1784     tags = list(reversed(tags))
   1785 
   1786     ## Get the changes between tags (releases)
   1787     for idx, tag in enumerate(tags):
   1788 
   1789         ## New version
   1790         current_version = {
   1791             "date": tag.tagger_date if tag.has_annotated_tag else tag.date,
   1792             "commit_date": tag.date,
   1793             "tagger_date": tag.tagger_date if tag.has_annotated_tag else None,
   1794             "tag": tag.identifier if tag.identifier != "HEAD" else None,
   1795             "commit": tag,
   1796         }
   1797 
   1798         sections = collections.defaultdict(list)
   1799         commits = repository.log(
   1800             includes=[min(tag, max_rev)],
   1801             excludes=tags[idx + 1 :] + excludes,
   1802             include_merge=include_merge,
   1803             encoding=log_encoding,
   1804         )
   1805 
   1806         for commit in commits:
   1807             if any(
   1808                 re.search(pattern, commit.subject) is not None
   1809                 for pattern in ignore_regexps
   1810             ):
   1811                 continue
   1812 
   1813             body = body_process(commit.body)
   1814 
   1815             ## Extract gitlab issue number
   1816             issue = None
   1817             if match := re.search(r".*:gl:`#([0-9]+)`", body):
   1818                 issue = int(match.group(1))
   1819 
   1820             matched_section = first_matching(section_regexps, commit.subject)
   1821 
   1822             ## Finally storing the commit in the matching section
   1823 
   1824             sections[matched_section].append(
   1825                 {
   1826                     "author": commit.author_name,
   1827                     "authors": commit.author_names,
   1828                     "subject": subject_process(commit.subject),
   1829                     "body": body,
   1830                     "commit": commit,
   1831                     "issue": issue,
   1832                 }
   1833             )
   1834 
   1835         ## Sort sections by issue number or title
   1836         for section_key in sections.keys():
   1837             sections[section_key].sort(
   1838                 key=lambda c: (
   1839                     c["issue"] if c["issue"] is not None else sys.maxsize,
   1840                     c["subject"],
   1841                 )
   1842             )
   1843 
   1844         ## Flush current version
   1845         current_version["sections"] = [
   1846             {"label": k, "commits": sections[k]} for k in section_order if k in sections
   1847         ]
   1848         if len(current_version["sections"]) != 0:
   1849             yield current_version
   1850         versions_done[tag] = current_version
   1851 
   1852 
   1853 def changelog(
   1854     output_engine=rest_py,
   1855     unreleased_version_label="unreleased",
   1856     include_commit_sha=False,
   1857     warn=warn,  ## Mostly used for test
   1858     **kwargs,
   1859 ):
   1860     """Returns a string containing the changelog of given repository
   1861 
   1862     This function returns a string corresponding to the template rendered with
   1863     the changelog data tree.
   1864 
   1865     (see ``gitchangelog.rc.sample`` file for more info)
   1866 
   1867     For an exact list of arguments, see the arguments of
   1868     ``versions_data_iter(..)``.
   1869 
   1870     :param unreleased_version_label: version label for untagged commits
   1871     :param include_commit_sha: whether message should contain commit sha
   1872     :param output_engine: callable to render the changelog data
   1873     :param warn: callable to output warnings, mocked by tests
   1874 
   1875     :returns: content of changelog
   1876 
   1877     """
   1878 
   1879     opts = {
   1880         "unreleased_version_label": unreleased_version_label,
   1881         "include_commit_sha": include_commit_sha,
   1882     }
   1883 
   1884     ## Setting main container of changelog elements
   1885     title = None if kwargs.get("revlist") else "Changelog"
   1886     data = {"title": title, "versions": []}
   1887 
   1888     versions = versions_data_iter(warn=warn, **kwargs)
   1889 
   1890     ## poke once in versions to know if there's at least one:
   1891     try:
   1892         first_version = next(versions)
   1893     except StopIteration:
   1894         die("Empty changelog. No commits were elected to be used as entry.")
   1895     else:
   1896         data["versions"] = itertools.chain([first_version], versions)
   1897 
   1898     return output_engine(data=data, opts=opts)
   1899 
   1900 
   1901 ##
   1902 ## Manage obsolete options
   1903 ##
   1904 
   1905 _obsolete_options_managers = []
   1906 
   1907 
   1908 def obsolete_option_manager(fun):
   1909     _obsolete_options_managers.append(fun)
   1910 
   1911 
   1912 @obsolete_option_manager
   1913 def obsolete_replace_regexps(config):
   1914     """This option was superseeded by the ``subject_process`` option.
   1915 
   1916     Each regex replacement you had could be translated in a
   1917     ``ReSub(pattern, replace)`` in the ``subject_process`` pipeline.
   1918 
   1919     """
   1920     if "replace_regexps" in config:
   1921         for pattern, replace in config["replace_regexps"].items():
   1922             config["subject_process"] = ReSub(pattern, replace) | config.get(
   1923                 "subject_process", ucfirst | final_dot
   1924             )
   1925 
   1926 
   1927 @obsolete_option_manager
   1928 def obsolete_body_split_regexp(config):
   1929     """This option was superseeded by the ``body_process`` option.
   1930 
   1931     The split regex can now be sent as a ``Wrap(regex)`` text process
   1932     instruction in the ``body_process`` pipeline.
   1933 
   1934     """
   1935     if "body_split_regex" in config:
   1936         config["body_process"] = Wrap(config["body_split_regex"]) | config.get(
   1937             "body_process", noop
   1938         )
   1939 
   1940 
   1941 def manage_obsolete_options(config):
   1942     for man in _obsolete_options_managers:
   1943         man(config)
   1944 
   1945 
   1946 ##
   1947 ## Command line parsing
   1948 ##
   1949 
   1950 
   1951 def parse_cmd_line(usage, description, epilog, exname, version):
   1952 
   1953     import argparse
   1954 
   1955     kwargs = dict(
   1956         usage=usage,
   1957         description=description,
   1958         epilog="\n" + epilog,
   1959         prog=exname,
   1960         formatter_class=argparse.RawTextHelpFormatter,
   1961     )
   1962 
   1963     try:
   1964         parser = argparse.ArgumentParser(version=version, **kwargs)
   1965     except TypeError:  ## compat with argparse from python 3.4
   1966         parser = argparse.ArgumentParser(**kwargs)
   1967         parser.add_argument(
   1968             "-v",
   1969             "--version",
   1970             help="show program's version number and exit",
   1971             action="version",
   1972             version=version,
   1973         )
   1974 
   1975     parser.add_argument(
   1976         "-d",
   1977         "--debug",
   1978         help="Enable debug mode (show full tracebacks).",
   1979         action="store_true",
   1980         dest="debug",
   1981     )
   1982     parser.add_argument("revlist", nargs="*", action="store", default=[])
   1983 
   1984     ## Remove "show" as first argument for compatibility reason.
   1985 
   1986     argv = []
   1987     for i, arg in enumerate(sys.argv[1:]):
   1988         if arg.startswith("-"):
   1989             argv.append(arg)
   1990             continue
   1991         if arg == "show":
   1992             warn("'show' positional argument is deprecated.")
   1993             argv += sys.argv[i + 2 :]
   1994             break
   1995         else:
   1996             argv += sys.argv[i + 1 :]
   1997             break
   1998 
   1999     return parser.parse_args(argv)
   2000 
   2001 
   2002 eval_if_callable = lambda v: v() if callable(v) else v
   2003 
   2004 
   2005 def get_revision(repository, config, opts):
   2006     if opts.revlist:
   2007         revs = opts.revlist
   2008     else:
   2009         revs = config.get("revs")
   2010         if revs:
   2011             revs = eval_if_callable(revs)
   2012             if not isinstance(revs, list):
   2013                 die(
   2014                     "Invalid type for 'revs' in config file. "
   2015                     "A 'list' type is required, and a %r was given."
   2016                     % type(revs).__name__
   2017                 )
   2018             revs = [eval_if_callable(rev) for rev in revs]
   2019         else:
   2020             revs = []
   2021 
   2022     for rev in revs:
   2023         if not isinstance(rev, basestring):
   2024             die(
   2025                 "Invalid type for revision in revs list from config file. "
   2026                 "'str' type is required, and a %r was given." % type(rev).__name__
   2027             )
   2028         try:
   2029             repository.git.rev_parse([rev, "--rev_only", "--"])
   2030         except ShellError:
   2031             if DEBUG:
   2032                 raise
   2033             die("Revision %r is not valid." % rev)
   2034 
   2035     if revs == [
   2036         "HEAD",
   2037     ]:
   2038         return []
   2039     return revs
   2040 
   2041 
   2042 def get_log_encoding(repository, config):
   2043 
   2044     log_encoding = config.get("log_encoding", None)
   2045     if log_encoding is None:
   2046         try:
   2047             log_encoding = repository.config.get("i18n.logOuputEncoding")
   2048         except ShellError as e:
   2049             warn(
   2050                 "Error parsing git config: %s."
   2051                 " Couldn't check if 'i18n.logOuputEncoding' was set." % (str(e))
   2052             )
   2053 
   2054     ## Final defaults coming from git defaults
   2055     return log_encoding or DEFAULT_GIT_LOG_ENCODING
   2056 
   2057 
   2058 ##
   2059 ## Config Manager
   2060 ##
   2061 
   2062 
   2063 class Config(dict):
   2064 
   2065     def __getitem__(self, label):
   2066         if label not in self.keys():
   2067             die("Missing value in config file for key '%s'." % label)
   2068         return super(Config, self).__getitem__(label)
   2069 
   2070 
   2071 ##
   2072 ## Safe print
   2073 ##
   2074 
   2075 
   2076 def safe_print(content):
   2077     if not PY3:
   2078         if isinstance(content, unicode):
   2079             content = content.encode(_preferred_encoding)
   2080 
   2081     try:
   2082         print(content, end="")
   2083         sys.stdout.flush()
   2084     except UnicodeEncodeError:
   2085         if DEBUG:
   2086             raise
   2087         ## XXXvlab: should use $COLUMNS in bash and for windows:
   2088         ## http://stackoverflow.com/questions/14978548
   2089         stderr(paragraph_wrap(textwrap.dedent("""\
   2090             UnicodeEncodeError:
   2091               There was a problem outputing the resulting changelog to
   2092               your console.
   2093 
   2094               This probably means that the changelog contains characters
   2095               that can't be translated to characters in your current charset
   2096               (%s).
   2097             """) % sys.stdout.encoding))
   2098         if WIN32 and PY_VERSION < 3.6 and sys.stdout.encoding != "utf-8":
   2099             ## As of PY 3.6, encoding is now ``utf-8`` regardless of
   2100             ## PYTHONIOENCODING
   2101             ## https://www.python.org/dev/peps/pep-0528/
   2102             stderr(
   2103                 "  You might want to try to fix that by setting "
   2104                 "PYTHONIOENCODING to 'utf-8'."
   2105             )
   2106         exit(1)
   2107     except IOError as e:
   2108         if e.errno == 0 and not PY3 and WIN32:
   2109             ## Yes, had a strange IOError Errno 0 after outputing string
   2110             ## that contained UTF-8 chars on Windows and PY2.7
   2111             pass  ## Ignoring exception
   2112         elif (WIN32 and e.errno == 22) or (  ## Invalid argument
   2113             not WIN32 and e.errno == errno.EPIPE
   2114         ):  ## Broken Pipe
   2115             ## Nobody is listening anymore to stdout it seems. Let's bailout.
   2116             if PY3:
   2117                 try:
   2118                     ## Called only to generate exception and have a chance at
   2119                     ## ignoring it. Otherwise this happens upon exit, and gets
   2120                     ## some error message printed on stderr.
   2121                     sys.stdout.close()
   2122                 except BrokenPipeError:  ## expected outcome on linux
   2123                     pass
   2124                 except OSError as e2:
   2125                     if e2.errno != 22:  ## expected outcome on WIN32
   2126                         raise
   2127             ## Yay ! stdout is closed we can now exit safely.
   2128             exit(0)
   2129         else:
   2130             raise
   2131 
   2132 
   2133 ##
   2134 ## Main
   2135 ##
   2136 
   2137 
   2138 def main():
   2139 
   2140     global DEBUG
   2141     ## Basic environment infos
   2142 
   2143     reference_config = os.path.join(
   2144         os.path.dirname(os.path.realpath(__file__)), "gitchangelog.rc.reference"
   2145     )
   2146 
   2147     basename = os.path.basename(sys.argv[0])
   2148     if basename.endswith(".py"):
   2149         basename = basename[:-3]
   2150 
   2151     debug_varname = "DEBUG_%s" % basename.upper()
   2152     DEBUG = os.environ.get(debug_varname, False)
   2153 
   2154     i = lambda x: x % {"exname": basename}
   2155 
   2156     opts = parse_cmd_line(
   2157         usage=i(usage_msg),
   2158         description=i(description_msg),
   2159         epilog=i(epilog_msg),
   2160         exname=basename,
   2161         version=__version__,
   2162     )
   2163     DEBUG = DEBUG or opts.debug
   2164 
   2165     try:
   2166         repository = GitRepos(".")
   2167     except EnvironmentError as e:
   2168         if DEBUG:
   2169             raise
   2170         try:
   2171             die(str(e))
   2172         except Exception as e2:
   2173             die(repr(e2))
   2174 
   2175     try:
   2176         gc_rc = repository.config.get("gitchangelog.rc-path")
   2177     except ShellError as e:
   2178         stderr(
   2179             "Error parsing git config: %s."
   2180             " Won't be able to read 'rc-path' if defined." % (str(e))
   2181         )
   2182         gc_rc = None
   2183 
   2184     gc_rc = normpath(gc_rc, cwd=repository.toplevel) if gc_rc else None
   2185 
   2186     ## config file lookup resolution
   2187     for enforce_file_existence, fun in [
   2188         (True, lambda: os.environ.get("GITCHANGELOG_CONFIG_FILENAME")),
   2189         (True, lambda: gc_rc),
   2190         (
   2191             False,
   2192             lambda: (
   2193                 (os.path.join(repository.toplevel, ".%s.rc" % basename))
   2194                 if not repository.bare
   2195                 else None
   2196             ),
   2197         ),
   2198     ]:
   2199         changelogrc = fun()
   2200         if changelogrc:
   2201             if not os.path.exists(changelogrc):
   2202                 if enforce_file_existence:
   2203                     die("File %r does not exists." % changelogrc)
   2204                 else:
   2205                     continue  ## changelogrc valued, but file does not exists
   2206             else:
   2207                 break
   2208 
   2209     ## config file may lookup for templates relative to the toplevel
   2210     ## of git repository
   2211     os.chdir(repository.toplevel)
   2212 
   2213     config = load_config_file(
   2214         os.path.expanduser(changelogrc),
   2215         default_filename=reference_config,
   2216         fail_if_not_present=False,
   2217     )
   2218 
   2219     config = Config(config)
   2220 
   2221     log_encoding = get_log_encoding(repository, config)
   2222     revlist = get_revision(repository, config, opts)
   2223     config["unreleased_version_label"] = eval_if_callable(
   2224         config["unreleased_version_label"]
   2225     )
   2226     manage_obsolete_options(config)
   2227 
   2228     try:
   2229         content = changelog(
   2230             repository=repository,
   2231             revlist=revlist,
   2232             ignore_regexps=config["ignore_regexps"],
   2233             section_regexps=config["section_regexps"],
   2234             unreleased_version_label=config["unreleased_version_label"],
   2235             include_commit_sha=config["include_commit_sha"],
   2236             tag_filter_regexp=config["tag_filter_regexp"],
   2237             output_engine=config.get("output_engine", rest_py),
   2238             include_merge=config.get("include_merge", True),
   2239             body_process=config.get("body_process", noop),
   2240             subject_process=config.get("subject_process", noop),
   2241             log_encoding=log_encoding,
   2242         )
   2243 
   2244         if isinstance(content, basestring):
   2245             content = content.splitlines(True)
   2246 
   2247         config.get("publish", stdout)(content)
   2248 
   2249     except KeyboardInterrupt:
   2250         if DEBUG:
   2251             err("Keyboard interrupt received while running '%s':" % (basename,))
   2252             stderr(format_last_exception())
   2253         else:
   2254             err("Keyboard Interrupt. Bailing out.")
   2255         exit(130)  ## Actual SIGINT as bash process convention.
   2256     except Exception as e:  ## pylint: disable=broad-except
   2257         if DEBUG:
   2258             err("Exception while running '%s':" % (basename,))
   2259             stderr(format_last_exception())
   2260         else:
   2261             message = "%s" % e
   2262             err(message)
   2263             stderr(
   2264                 "  (set %s environment variable, "
   2265                 "or use ``--debug`` to see full traceback)" % (debug_varname,)
   2266             )
   2267         exit(255)
   2268 
   2269 
   2270 ##
   2271 ## Launch program
   2272 ##
   2273 
   2274 if __name__ == "__main__":
   2275     main()
   2276