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