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