├── src └── gitchangelog │ ├── __init__.py │ ├── templates │ ├── mustache │ │ ├── markdown.tpl │ │ └── restructuredtext.tpl │ └── mako │ │ ├── octobercms-plugin.tpl │ │ ├── restructuredtext.tpl │ │ └── ChangeLog.tpl │ ├── gitchangelog.rc.reference.v2.1.3 │ ├── gitchangelog.rc.reference │ └── gitchangelog.py ├── MANIFEST.in ├── gitchangelog ├── test ├── __init__.py ├── test_pr23.py ├── test_log_encoding.py ├── test_issue54.py ├── test_issue76.py ├── test_broken_pipe.py ├── test_issue52.py ├── test_git_repos.py ├── test_tagger_date.py ├── test_template.py ├── common.py ├── test_publish.py ├── test_pr14.py ├── test_config_file.py ├── test_exc_handling.py ├── test_issue61.py └── test_base.py ├── INSTALL.rst ├── .package ├── .coveragerc ├── appveyor.yml ├── LICENSE ├── setup.cfg ├── setup.py ├── .travis.yml ├── autogen.sh ├── .gitchangelog.rc └── README.rst /src/gitchangelog/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft src 2 | include *.rst LICENSE -------------------------------------------------------------------------------- /gitchangelog: -------------------------------------------------------------------------------- 1 | src/gitchangelog/gitchangelog.py -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | -------------------------------------------------------------------------------- /INSTALL.rst: -------------------------------------------------------------------------------- 1 | INSTALL 2 | ======= 3 | 4 | If you downloaded the code from git: 5 | 6 | ./autogen.sh && 7 | python setup.py install 8 | 9 | 10 | -------------------------------------------------------------------------------- /.package: -------------------------------------------------------------------------------- 1 | NAME="gitchangelog" 2 | DESCRIPTION="gitchangelog generates a changelog thanks to git log." 3 | EMAIL="valentin.lab@kalysto.org" 4 | AUTHOR="Valentin LAB" 5 | AUTHOR_EMAIL="$AUTHOR <$EMAIL>" 6 | FILES="setup.cfg setup.py CHANGELOG.rst src/gitchangelog/gitchangelog.py" 7 | -------------------------------------------------------------------------------- /src/gitchangelog/templates/mustache/markdown.tpl: -------------------------------------------------------------------------------- 1 | {{#general_title}} 2 | # {{{title}}} 3 | 4 | 5 | {{/general_title}} 6 | {{#versions}} 7 | ## {{{label}}} 8 | 9 | {{#sections}} 10 | ### {{{label}}} 11 | 12 | {{#commits}} 13 | * {{{subject}}} [{{{author}}}] 14 | {{#body}} 15 | 16 | {{{body_indented}}} 17 | {{/body}} 18 | 19 | {{/commits}} 20 | {{/sections}} 21 | 22 | {{/versions}} 23 | -------------------------------------------------------------------------------- /src/gitchangelog/templates/mako/octobercms-plugin.tpl: -------------------------------------------------------------------------------- 1 | <% 2 | import re 3 | def quote(txt): 4 | if re.search(r"[\"`\[\]-]", txt): 5 | return "\"%s\"" % txt.replace('"', '\\"') 6 | return txt 7 | %> 8 | % for version in data["versions"]: 9 | ${version["tag"] or quote(opts["unreleased_version_label"])}: 10 | % for section in version["sections"]: 11 | % for commit in section["commits"]: 12 | - ${quote(commit["subject"])} 13 | % endfor 14 | % endfor 15 | % endfor 16 | -------------------------------------------------------------------------------- /src/gitchangelog/templates/mustache/restructuredtext.tpl: -------------------------------------------------------------------------------- 1 | {{#general_title}} 2 | {{{title}}} 3 | {{#title_chars}}={{/title_chars}} 4 | 5 | 6 | {{/general_title}} 7 | {{#versions}} 8 | {{{label}}} 9 | {{#label_chars}}-{{/label_chars}} 10 | {{#sections}} 11 | {{#display_label}} 12 | 13 | {{{label}}} 14 | {{#label_chars}}~{{/label_chars}} 15 | {{/display_label}} 16 | {{#commits}} 17 | - {{{subject}}} [{{{author_names_joined}}}] 18 | 19 | {{#body}} 20 | {{{body_indented}}} 21 | {{/body}} 22 | {{/commits}} 23 | {{/sections}} 24 | 25 | {{/versions}} 26 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | # .coveragerc to control coverage.py 2 | [run] 3 | branch = True 4 | 5 | [report] 6 | # Regexes for lines to exclude from consideration 7 | exclude_lines = 8 | # Have to re-enable the standard pragma 9 | pragma: no cover 10 | 11 | # Don't complain about missing debug-only code: 12 | def __repr__ 13 | if self\.debug 14 | 15 | # Don't complain if tests don't hit defensive assertion code: 16 | raise AssertionError 17 | raise NotImplementedError 18 | 19 | # Don't complain if non-runnable code isn't run: 20 | if 0: 21 | 22 | ignore_errors = True 23 | [html] 24 | directory = cover 25 | -------------------------------------------------------------------------------- /src/gitchangelog/templates/mako/restructuredtext.tpl: -------------------------------------------------------------------------------- 1 | % if data["title"]: 2 | ${data["title"]} 3 | ${"=" * len(data["title"])} 4 | 5 | 6 | % endif 7 | % for version in data["versions"]: 8 | <% 9 | title = "%s (%s)" % (version["tag"], version["date"]) if version["tag"] else opts["unreleased_version_label"] 10 | 11 | nb_sections = len(version["sections"]) 12 | %>${title} 13 | ${"-" * len(title)} 14 | % for section in version["sections"]: 15 | % if not (section["label"] == "Other" and nb_sections == 1): 16 | 17 | ${section["label"]} 18 | ${"~" * len(section["label"])} 19 | % endif 20 | % for commit in section["commits"]: 21 | <% 22 | subject = "%s [%s]" % (commit["subject"], ", ".join(commit["authors"])) 23 | entry = indent('\n'.join(textwrap.wrap(subject)), 24 | first="- ").strip() 25 | %>${entry} 26 | 27 | % if commit["body"]: 28 | ${indent(commit["body"])} 29 | % endif 30 | % endfor 31 | % endfor 32 | 33 | % endfor 34 | -------------------------------------------------------------------------------- /src/gitchangelog/templates/mako/ChangeLog.tpl: -------------------------------------------------------------------------------- 1 | % for version in data["versions"]: 2 | % if version["tag"]: 3 | ${version["date"]} Release 4 | 5 | * ${version["tag"] or opts["unreleased_version_label"]} released. 6 | %endif 7 | 8 | % for section in version["sections"]: 9 | % for commit in section["commits"]: 10 | 11 | 12 | -------------------------------------------------- 13 | ${version["date"]} Release 14 | 15 | <% 16 | title = "%s (%s)" % (version["tag"], version["date"]) if version["tag"] else opts["unreleased_version_label"] 17 | 18 | %>${title} 19 | ${"-" * len(title)} 20 | 21 | % for section in version["sections"]: 22 | % if not (section["label"] == "Other" and nb_sections == 1): 23 | ${section["label"]} 24 | ${"~" * len(section["label"])} 25 | 26 | % endif 27 | % for commit in section["commits"]: 28 | <% 29 | subject = "%s [%s]" % (commit["subject"], commit["author"]) 30 | entry = indent('\n'.join(textwrap.wrap(subject)), 31 | first="- ").strip() 32 | %>${entry} 33 | 34 | % if commit["body"]: 35 | ${indent(commit["body"])} 36 | 37 | % endif 38 | % endfor 39 | % endfor 40 | % endfor 41 | % endfor 42 | % endfor 43 | -------------------------------------------------------------------------------- /test/test_pr23.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | """ 3 | Tests issue #23 4 | (https://github.com/securactive/gitchangelog/issues/23) 5 | 6 | """ 7 | 8 | from __future__ import unicode_literals 9 | 10 | import textwrap 11 | 12 | from .common import BaseGitReposTest 13 | 14 | 15 | class TestCrossBranchTags(BaseGitReposTest): 16 | 17 | REFERENCE = textwrap.dedent("""\ 18 | None 19 | None: 20 | * c [The Committer] 21 | * b [The Committer] 22 | * a [The Committer] 23 | 24 | """) 25 | 26 | def setUp(self): 27 | super(TestCrossBranchTags, self).setUp() 28 | 29 | ## Target tree: 30 | ## 31 | ## (Pdb) print w("git log --all --pretty=tformat:%s\ %d --graph") 32 | ## * c (HEAD, master) 33 | ## * b 34 | ## * a 35 | 36 | self.git.commit(message='a', allow_empty=True) 37 | self.git.commit(message='b', allow_empty=True) 38 | self.git.commit(message='c', allow_empty=True) 39 | 40 | def test_matching_reference(self): 41 | """Test that all 3 commits are in the changelog""" 42 | 43 | changelog = self.simple_changelog() 44 | self.assertNoDiff( 45 | self.REFERENCE, changelog) 46 | 47 | 48 | -------------------------------------------------------------------------------- /test/test_log_encoding.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | """Testing unicode display""" 3 | 4 | from __future__ import unicode_literals 5 | 6 | import textwrap 7 | 8 | from .common import BaseGitReposTest, cmd 9 | 10 | 11 | class UnicodeCommitMessageTest(BaseGitReposTest): 12 | 13 | REFERENCE = textwrap.dedent("""\ 14 | Changelog 15 | ========= 16 | 17 | 18 | (unreleased) 19 | ------------ 20 | - Hć. [The Committer] 21 | 22 | 23 | 1.2 (2017-02-20) 24 | ---------------- 25 | - B. [The Committer] 26 | 27 | 28 | """) 29 | 30 | def setUp(self): 31 | super(UnicodeCommitMessageTest, self).setUp() 32 | 33 | self.git.commit(message="b", 34 | date="2017-02-20 11:00:00", 35 | allow_empty=True) 36 | self.git.tag("1.2") 37 | self.git.commit(message="Hć", 38 | date="2017-02-20 11:00:00", 39 | allow_empty=True) 40 | 41 | def test_checking_log_output(self): 42 | out, err, errlvl = cmd('$tprog') 43 | self.assertEqual( 44 | err, "", 45 | msg="There should be non error messages. " 46 | "Current stderr:\n%s" % err) 47 | self.assertEqual(errlvl, 0) 48 | self.assertNoDiff(self.REFERENCE, out) 49 | 50 | -------------------------------------------------------------------------------- /test/test_issue54.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | """Reference file is not required to be found 3 | 4 | Tests issue #54 5 | (https://github.com/vaab/gitchangelog/issues/54) 6 | 7 | """ 8 | 9 | from __future__ import unicode_literals 10 | 11 | from .common import BaseGitReposTest, cmd, gitchangelog 12 | 13 | 14 | class TestConfigComplains(BaseGitReposTest): 15 | 16 | def test_missing_option(self): 17 | super(TestConfigComplains, self).setUp() 18 | 19 | gitchangelog.file_put_contents( 20 | ".gitchangelog.rc", 21 | "del section_regexps" 22 | ) 23 | 24 | out, err, errlvl = cmd('$tprog --debug') 25 | self.assertContains( 26 | err.lower(), "missing value", 27 | msg="There should be an error message containing 'missing value'. " 28 | "Current stderr:\n%s" % err) 29 | self.assertContains( 30 | err.lower(), "config file", 31 | msg="There should be an error message containing 'config file'. " 32 | "Current stderr:\n%s" % err) 33 | self.assertContains( 34 | err.lower(), "section_regexps", 35 | msg="There should be an error msg containing 'section_regexps'. " 36 | "Current stderr:\n%s" % err) 37 | self.assertEqual( 38 | errlvl, 1, 39 | msg="Should faild.") 40 | self.assertEqual( 41 | out, "", 42 | msg="No output is expected.") 43 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | # What Python version is installed where: 2 | # http://www.appveyor.com/docs/installed-software#python 3 | 4 | environment: 5 | global: 6 | PYTHONIOENCODING: utf-8 7 | matrix: 8 | - PYTHON: "C:\\Python27" 9 | - PYTHON: "C:\\Python27-x64" 10 | - PYTHON: "C:\\Python35" 11 | - PYTHON: "C:\\Python35-x64" 12 | - PYTHON: "C:\\Python36" 13 | - PYTHON: "C:\\Python36-x64" 14 | - PYTHON: "C:\\Python37" 15 | - PYTHON: "C:\\Python37-x64" 16 | 17 | ## Before repo cloning 18 | init: 19 | ## without this, temporary directory could be created in current dir 20 | ## which will make some tests fail. 21 | - mkdir C:\TMP 22 | - set PATH=%PYTHON%;%PYTHON%\Scripts;%PATH% 23 | - python -V 24 | 25 | ## After repo cloning 26 | install: 27 | 28 | build: false 29 | 30 | ## Before tests 31 | before_test: 32 | - for /f %%i in ('bash .\autogen.sh --get-name') do set PACKAGE_NAME=%%i 33 | - python setup.py develop easy_install %PACKAGE_NAME%[test] 34 | - pip install coverage codecov 35 | 36 | ## Custom test script 37 | test_script: 38 | 39 | ## Fail early on big problems 40 | - "python src\\gitchangelog\\gitchangelog.py --debug" 41 | - "python src\\gitchangelog\\gitchangelog.py --debug HEAD^^..HEAD" 42 | 43 | ## real tests 44 | - nosetests -sx . 45 | 46 | ## installable 47 | - python setup.py install 48 | - gitchangelog --debug 49 | - "gitchangelog HEAD^^..HEAD" 50 | 51 | after_test: 52 | - "codecov & REM #dovis: ignore" 53 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018, Valentin Lab 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of the Securactive nor the 12 | names of its contributors may be used to endorse or promote products 13 | derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL SECURACTIVE BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | 26 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = %%name%% 3 | version = %%version%% 4 | summary = %%description%% 5 | description-file = 6 | README.rst 7 | CHANGELOG.rst 8 | license_file = LICENSE 9 | requires_dist = 10 | 11 | ## sdist info 12 | author = %%author%% 13 | author_email = %%email%% 14 | home_page = http://github.com/vaab/%%name%% 15 | license = BSD 3-Clause License 16 | classifier = 17 | Programming Language :: Python 18 | Environment :: Console 19 | Intended Audience :: Developers 20 | License :: OSI Approved :: BSD License 21 | Topic :: Software Development 22 | Development Status :: 5 - Production/Stable 23 | Topic :: Software Development :: Version Control 24 | Programming Language :: Python :: 2 25 | Programming Language :: Python :: 2.7 26 | Programming Language :: Python :: 3 27 | Programming Language :: Python :: 3.3 28 | Programming Language :: Python :: 3.4 29 | Programming Language :: Python :: 3.5 30 | Programming Language :: Python :: 3.6 31 | 32 | 33 | [backwards_compat] 34 | include_package_data = True 35 | 36 | 37 | [files] 38 | packages_root = src 39 | packages = 40 | %%name%% 41 | 42 | 43 | [entry_points] 44 | console_scripts = 45 | gitchangelog = gitchangelog.gitchangelog:main 46 | 47 | 48 | [bdist_wheel] 49 | universal = 1 50 | 51 | 52 | [nosetests] 53 | verbosity = 3 54 | with-doctest = 1 55 | doctest-extension = rst 56 | exe = 1 57 | with-coverage = 1 58 | cover-package = gitchangelog 59 | #cover-min-percentage = 90 60 | doctest-options = +ELLIPSIS,+NORMALIZE_WHITESPACE 61 | 62 | 63 | [flake8] 64 | ignore = E265,E266,W391,E262,E126,E127 65 | max-line-length = 80 66 | max-complexity = 10 67 | -------------------------------------------------------------------------------- /test/test_issue76.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | """Testing on empty message 3 | 4 | see: https://github.com/vaab/gitchangelog/issues/76 5 | 6 | """ 7 | 8 | from __future__ import unicode_literals 9 | 10 | import textwrap 11 | 12 | from .common import gitchangelog, BaseGitReposTest, cmd 13 | 14 | 15 | class EmptyMessageTest(BaseGitReposTest): 16 | 17 | def setUp(self): 18 | super(EmptyMessageTest, self).setUp() 19 | 20 | self.git.commit(message="", 21 | date="2017-02-20 11:00:00", 22 | allow_empty=True, 23 | allow_empty_message=True) 24 | 25 | def test_empty_message_accepted_commit(self): 26 | out = self.simple_changelog(ignore_regexps=[]) 27 | self.assertNoDiff(textwrap.dedent("""\ 28 | None 29 | None: 30 | * [The Committer] 31 | 32 | """), out) 33 | 34 | def test_empty_message_not_accepted(self): 35 | out = self.simple_changelog(ignore_regexps=[r'^$', ]) 36 | self.assertNoDiff("", out) 37 | 38 | def test_empty_message_default_changelog_accepted(self): 39 | gitchangelog.file_put_contents( 40 | ".gitchangelog.rc", 41 | "ignore_regexps = []") 42 | out, err, errlvl = cmd('$tprog --debug') 43 | self.assertEqual(err, "") 44 | self.assertEqual(errlvl, 0) 45 | self.assertNoDiff(textwrap.dedent("""\ 46 | Changelog 47 | ========= 48 | 49 | 50 | (unreleased) 51 | ------------ 52 | - No commit message. [The Committer] 53 | 54 | 55 | """), out) 56 | 57 | def test_empty_message_default_changelog(self): 58 | out, err, errlvl = cmd('$tprog --debug') 59 | self.assertEqual(errlvl, 0) 60 | self.assertNoDiff(textwrap.dedent("""\ 61 | Changelog 62 | ========= 63 | 64 | 65 | """), out) 66 | -------------------------------------------------------------------------------- /test/test_broken_pipe.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | """Testing that we do not output confusing python gibberish message 3 | upon SIGPIPE. 4 | 5 | 6 | Unfortunately this tests is not correctly testable with coverage due 7 | to a bug of coverage hitting python 2.7 (windows and linux). 8 | 9 | Indeed, the function ``isolate_module`` is used to make local 10 | copies of modules inside coverage and prevent nasty problems 11 | when some central modules gets mocked by monkeypatching. 12 | 13 | But on python 2.7, the copy of ``sys.__stdout__`` and ``sys.stdout`` 14 | will prevent the normal failing behavior upon SIGPIPE. 15 | 16 | def isolate_module(mod): 17 | if mod not in ISOLATED_MODULES: 18 | new_mod = types.ModuleType(mod.__name__) 19 | ISOLATED_MODULES[mod] = new_mod 20 | attributes = dir(mod) 21 | if mod.__name__ == "sys": 22 | attributes = set(attributes) - set(["__stdout__", "__stdin__", 23 | "stdout", "stdin"]) 24 | for name in attributes: 25 | value = getattr(mod, name) 26 | if isinstance(value, types.ModuleType): 27 | value = isolate_module(value) 28 | setattr(new_mod, name, value) 29 | return ISOLATED_MODULES[mod] 30 | 31 | 32 | """ 33 | 34 | 35 | from __future__ import unicode_literals 36 | 37 | from .common import BaseGitReposTest, cmd, WIN32 38 | 39 | 40 | class BrokenPipeTest(BaseGitReposTest): 41 | 42 | def setUp(self): 43 | super(BrokenPipeTest, self).setUp() 44 | 45 | self.git.commit( 46 | message='foo', 47 | author='Bob ', 48 | date='2000-01-01 10:00:00', 49 | allow_empty=True) 50 | 51 | def test_break_pipe(self): 52 | out, err, errlvl = cmd( 53 | '$tprog | %s' % ("REM" if WIN32 else ":")) 54 | self.assertEqual(errlvl, 0) 55 | self.assertNoDiff("", err) 56 | self.assertNoDiff("", out) 57 | -------------------------------------------------------------------------------- /test/test_issue52.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | """ 3 | Tests issue #52 4 | (https://github.com/vaab/gitchangelog/issues/52) 5 | 6 | """ 7 | 8 | from __future__ import unicode_literals 9 | 10 | from .common import BaseGitReposTest 11 | 12 | 13 | class TestNoTagButCommitNoWarn(BaseGitReposTest): 14 | 15 | def setUp(self): 16 | super(TestNoTagButCommitNoWarn, self).setUp() 17 | 18 | self.git.commit(message="a", allow_empty=True) 19 | 20 | def test_no_tag_no_revlist(self): 21 | """if no tags are detected it should throw a warning""" 22 | 23 | warnings = [] 24 | 25 | def warn(msg): 26 | warnings.append(msg) 27 | 28 | output = self.simple_changelog(warn=warn) 29 | self.assertEqual( 30 | output, 'None\n None:\n * a [The Committer]\n\n') 31 | self.assertTrue( 32 | len(warnings) == 0, 33 | msg="Should have outputed no warnings.") 34 | 35 | def test_no_tag_revlist(self): 36 | """if no tags are detected and revlist is provided, check warning""" 37 | 38 | warnings = [] 39 | 40 | def warn(msg): 41 | warnings.append(msg) 42 | 43 | output = self.simple_changelog(revlist=["HEAD", ], warn=warn) 44 | self.assertEqual( 45 | output, 'None\n None:\n * a [The Committer]\n\n') 46 | self.assertTrue( 47 | len(warnings) == 0, 48 | msg="Should have outputed no warnings.") 49 | 50 | 51 | class TestEmptyChangelogWarn(BaseGitReposTest): 52 | 53 | def setUp(self): 54 | super(TestEmptyChangelogWarn, self).setUp() 55 | self.git.commit(message='chg: dev: ignore !minor', allow_empty=True) 56 | 57 | def test_no_commit(self): 58 | """check warning about empty changelog""" 59 | 60 | warnings = [] 61 | 62 | def warn(msg): 63 | if "empty changelog" in msg.lower(): 64 | warnings.append(msg) 65 | 66 | self.simple_changelog(warn=warn, ignore_regexps=['!minor']) 67 | self.assertTrue( 68 | len(warnings) != 0, 69 | msg="Should have outputed at least one warning about " 70 | "'empty changelog'") 71 | 72 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | ## 4 | ## You can download latest version of this file: 5 | ## $ wget https://gist.github.com/vaab/e0eae9607ae806b662d4/raw -O setup.py 6 | ## $ chmod +x setup.py 7 | ## 8 | ## This setup.py is meant to be run along with ``./autogen.sh`` that 9 | ## you can also find here: https://gist.github.com/vaab/9118087/raw 10 | ## 11 | 12 | try: 13 | from setuptools import setup 14 | except ImportError: 15 | from distribute_setup import use_setuptools 16 | use_setuptools() 17 | from setuptools import setup 18 | 19 | ## 20 | ## Ensure that ``./autogen.sh`` is run prior to using ``setup.py`` 21 | ## 22 | 23 | if "%%short-version%%".startswith("%%"): 24 | import os.path 25 | import sys 26 | WIN32 = sys.platform == 'win32' 27 | autogen = os.path.join(".", "autogen.sh") 28 | if not os.path.exists(autogen): 29 | sys.stderr.write( 30 | "This source repository was not configured.\n" 31 | "Please ensure ``./autogen.sh`` exists and that you are running " 32 | "``setup.py`` from the project root directory.\n") 33 | sys.exit(1) 34 | if os.path.exists('.autogen.sh.output'): 35 | sys.stderr.write( 36 | "It seems that ``./autogen.sh`` couldn't do its job as expected.\n" 37 | "Please try to launch ``./autogen.sh`` manualy, and send the " 38 | "results to the\nmaintainer of this package.\n" 39 | "Package will not be installed !\n") 40 | sys.exit(1) 41 | sys.stderr.write("Missing version information: " 42 | "running './autogen.sh'...\n") 43 | import os 44 | import subprocess 45 | os.system('%s%s > .autogen.sh.output' 46 | % ("bash " if WIN32 else "", 47 | autogen)) 48 | cmdline = sys.argv[:] 49 | if cmdline[0] == "-c": 50 | ## for some reason, this is needed when launched from pip 51 | cmdline[0] = "setup.py" 52 | errlvl = subprocess.call(["python", ] + cmdline) 53 | os.unlink(".autogen.sh.output") 54 | sys.exit(errlvl) 55 | 56 | 57 | ## 58 | ## Normal d2to1 setup 59 | ## 60 | 61 | setup( 62 | setup_requires=['d2to1'], 63 | extras_require={ 64 | 'Mustache': ["pystache", ], 65 | 'Mako': ["mako", ], 66 | 'test': [ 67 | "nose", 68 | "minimock", 69 | "mako", 70 | "pystache", 71 | ], 72 | }, 73 | d2to1=True 74 | ) 75 | -------------------------------------------------------------------------------- /test/test_git_repos.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals 4 | 5 | import os 6 | import textwrap 7 | 8 | from .common import cmd, BaseGitReposTest, BaseTmpDirTest, gitchangelog 9 | 10 | 11 | class GitReposInstantiationTest(BaseTmpDirTest): 12 | 13 | def test_instanciate_on_non_git_repos_dir(self): 14 | os.mkdir("repos") 15 | with self.assertRaises(EnvironmentError): 16 | gitchangelog.GitRepos("repos") 17 | 18 | def test_gitchangelog_on_non_git_repos_dir(self): 19 | os.mkdir("repos") 20 | os.chdir("repos") 21 | out, err, errlvl = cmd('$tprog') 22 | self.assertEqual( 23 | err.strip(), 24 | "Not in a git repository. (calling ``git remote`` failed.)") 25 | self.assertEqual(errlvl, 1) 26 | 27 | 28 | class GitReposTest(BaseGitReposTest): 29 | 30 | def setUp(self): 31 | super(GitReposTest, self).setUp() 32 | 33 | self.git.commit( 34 | message='new: first commit', 35 | author='Bob ', 36 | date='2000-01-01 10:00:00', 37 | allow_empty=True) 38 | self.git.tag("0.0.1") 39 | self.git.commit( 40 | message=textwrap.dedent(""" 41 | add ``b`` with non-ascii chars éèàâ§µ and HTML chars ``&<`` 42 | 43 | Change-Id: Ic8aaa0728a43936cd4c6e1ed590e01ba8f0fbf5b"""), 44 | author='Alice ', 45 | date='2000-01-02 11:00:00', 46 | allow_empty=True) 47 | 48 | def test_get_commit(self): 49 | commit = self.repos.commit("0.0.1") 50 | self.assertEqual(commit.subject, 'new: first commit') 51 | commit = self.repos.commit("HEAD") 52 | self.assertEqual( 53 | commit.subject, 54 | 'add ``b`` with non-ascii chars éèàâ§µ and HTML chars ``&<``') 55 | 56 | def test_exception_when_requesting_unexistent_commit(self): 57 | commit = self.repos.commit("XXX") ## No exception yet. 58 | with self.assertRaises(ValueError): 59 | commit.subject 60 | 61 | def test_commit_less_or_equal(self): 62 | self.assertTrue(self.repos.commit("0.0.1") < self.repos.commit("HEAD")) 63 | self.assertTrue(self.repos.commit("0.0.1") < "HEAD") 64 | self.assertTrue(self.repos.commit("HEAD") == "HEAD") 65 | self.assertTrue(self.repos.commit("0.0.1") <= "HEAD") 66 | self.assertTrue(self.repos.commit("HEAD") <= "HEAD") 67 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | language: python 3 | python: 4 | - "3.7" 5 | - "3.6" 6 | - "3.5" 7 | - "2.7" 8 | install: 9 | - git fetch --unshallow || true ## required as we need it to make own gitchangelog 10 | - if [ -e requirements.txt ]; then pip install -r requirements.txt; fi 11 | - if [ -z "$DOVIS" -o "$PKG_COVERAGE" ]; then pip install coverage; fi 12 | ## getting test deps 13 | - python setup.py develop easy_install "$(./autogen.sh --get-name)[test]" 14 | script: 15 | ## real tests 16 | - nosetests -sx . 17 | after_success: 18 | - "bash <(curl -s https://codecov.io/bash) #dovis: ignore" 19 | 20 | 21 | ## Ignored by Travis, but used internally to check packaging 22 | dist_check: 23 | options: 24 | exclude: 25 | - ["v:3.6", "pkg:old"] ## old version is breaking python 3.6 pkg_resources 26 | tests: 27 | - label: install 28 | matrix: 29 | 'yes': 30 | - label: venv 31 | matrix: 32 | 'on': | 33 | pip install virtualenv 34 | virtualenv /tmp/virtualenv 35 | . /tmp/virtualenv/bin/activate 36 | 'off': | 37 | true 38 | - label: pkg 39 | matrix: 40 | old: | 41 | ## version 10 introduce a bug with d2to1 42 | pip install setuptools==9.1 43 | ## some ``python setup.py`` black magic do not work with d2to1 and pip ``6.0.7`` 44 | pip install pip==1.5.6 45 | docker: | 46 | ## Using the versions of python docker images 47 | true 48 | latest: | 49 | ## Using the last version of pip and setuptools 50 | pip install pip --upgrade 51 | pip install setuptools --upgrade 52 | - label: method 53 | matrix: 54 | setup: python setup.py install 55 | pip+git: pip install "git+file://$PWD" 56 | dist: 57 | dist_files: 58 | pip install "$DIST_FILE" 59 | - | 60 | pip show -f gitchangelog 61 | pip list 62 | 'no': 63 | - | 64 | ln -sf $PWD/gitchangelog /usr/local/bin/ 65 | touch /tmp/not-installed 66 | 67 | - | 68 | cd /tmp 69 | mkdir test_gitchangelog 70 | cd test_gitchangelog 71 | touch a 72 | git init . 73 | git add -A . 74 | git config --global user.email "you@example.com" 75 | git config --global user.name "Your Name" 76 | git commit -m "first commit" 77 | git tag 0.0.1 78 | echo 'a' > b 79 | git add b 80 | git commit -m "new: added b" 81 | git tag 0.0.2 82 | DEBUG_GITCHANGELOG=1 gitchangelog 83 | cat < .gitchangelog.rc 84 | output_engine = makotemplate("restructuredtext") 85 | EOF 86 | pip install mako 87 | DEBUG_GITCHANGELOG=1 gitchangelog 88 | - | 89 | [ -e /tmp/not-installed ] || pip uninstall -y gitchangelog 90 | -------------------------------------------------------------------------------- /test/test_tagger_date.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | """Testing tagger_date display 3 | 4 | https://github.com/vaab/gitchangelog/issues/60 5 | 6 | """ 7 | 8 | from __future__ import unicode_literals 9 | 10 | import textwrap 11 | 12 | from .common import BaseGitReposTest 13 | 14 | 15 | def simple_renderer_using_commit_internal(data, opts): 16 | s = "" 17 | for version in data["versions"]: 18 | s += "%s (%s)\n" % (version["tag"], version["commit"].tagger_date) 19 | for section in version["sections"]: 20 | s += " %s:\n" % section["label"] 21 | for commit in section["commits"]: 22 | s += " * %(subject)s [%(author)s]\n" % commit 23 | s += "\n" 24 | return s 25 | 26 | 27 | def simple_renderer(data, opts): 28 | s = "" 29 | for version in data["versions"]: 30 | s += "%s (%s)\n" % (version["tag"], version["tagger_date"]) 31 | for section in version["sections"]: 32 | s += " %s:\n" % section["label"] 33 | for commit in section["commits"]: 34 | s += " * %(subject)s [%(author)s]\n" % commit 35 | s += "\n" 36 | return s 37 | 38 | 39 | def simple_renderer_auto_date(data, opts): 40 | s = "" 41 | for version in data["versions"]: 42 | s += "%s (%s)\n" % (version["tag"], version["date"]) 43 | for section in version["sections"]: 44 | s += " %s:\n" % section["label"] 45 | for commit in section["commits"]: 46 | s += " * %(subject)s [%(author)s]\n" % commit 47 | s += "\n" 48 | return s 49 | 50 | 51 | class TaggerDateTest(BaseGitReposTest): 52 | 53 | REFERENCE = textwrap.dedent("""\ 54 | 1.2 (2017-03-17) 55 | None: 56 | * b [The Committer] 57 | 58 | """) 59 | 60 | def setUp(self): 61 | super(TaggerDateTest, self).setUp() 62 | 63 | self.git.commit(message="b", 64 | date="2017-02-20 11:00:00", 65 | allow_empty=True) 66 | self.git.tag(['-a', "1.2", '--message="tag message"'], 67 | env={'GIT_COMMITTER_DATE': "2017-03-17 11:00:00"}) 68 | 69 | def test_checking_tagger_date_in_commit_object(self): 70 | out = self.changelog( 71 | output_engine=simple_renderer_using_commit_internal) 72 | self.assertNoDiff(self.REFERENCE, out) 73 | 74 | def test_checking_tagger_date(self): 75 | out = self.changelog(output_engine=simple_renderer) 76 | self.assertNoDiff(self.REFERENCE, out) 77 | 78 | 79 | class TaggerDateNonAnnotatedTest(BaseGitReposTest): 80 | 81 | def setUp(self): 82 | super(TaggerDateNonAnnotatedTest, self).setUp() 83 | 84 | self.git.commit(message="b", 85 | date="2017-02-20 11:00:00", 86 | allow_empty=True) 87 | self.git.tag(["1.2"]) 88 | 89 | def test_checking_tagger_date_in_non_tag_with_commit_object(self): 90 | with self.assertRaises(ValueError): 91 | self.changelog(output_engine=simple_renderer_using_commit_internal) 92 | 93 | 94 | class TaggerDateAutoDateTest(BaseGitReposTest): 95 | 96 | REFERENCE = textwrap.dedent("""\ 97 | 1.3 (2017-02-20) 98 | None: 99 | * b [The Committer] 100 | 101 | 1.2 (2017-03-17) 102 | None: 103 | * b [The Committer] 104 | 105 | """) 106 | 107 | def setUp(self): 108 | super(TaggerDateAutoDateTest, self).setUp() 109 | 110 | self.git.commit(message="b", 111 | date="2017-02-20 11:00:00", 112 | allow_empty=True) 113 | self.git.tag(['-a', "1.2", '--message="tag message"'], 114 | env={'GIT_COMMITTER_DATE': "2017-03-17 11:00:00"}) 115 | self.git.commit(message="b", 116 | date="2017-02-20 11:00:00", 117 | allow_empty=True) 118 | self.git.tag(["1.3"]) 119 | 120 | def test_checking_tagger_date(self): 121 | out = self.changelog(output_engine=simple_renderer_auto_date) 122 | self.assertNoDiff(self.REFERENCE, out) 123 | -------------------------------------------------------------------------------- /test/test_template.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals 4 | 5 | import os 6 | import textwrap 7 | 8 | from .common import BaseGitReposTest, cmd, gitchangelog 9 | 10 | 11 | class TemplatingTest(BaseGitReposTest): 12 | """Base for all tests needing to start in a new git small repository""" 13 | 14 | def setUp(self): 15 | super(TemplatingTest, self).setUp() 16 | 17 | self.git.commit( 18 | message='new: begin', 19 | author='Bob ', 20 | date='2000-01-01 10:00:00', 21 | allow_empty=True) 22 | 23 | def test_unexistent_template_name(self): 24 | """Unexisting template should get a proper error message""" 25 | 26 | gitchangelog.file_put_contents( 27 | ".gitchangelog.rc", 28 | "output_engine = mustache('doesnotexist')") 29 | out, err, errlvl = cmd('$tprog') 30 | self.assertEqual( 31 | errlvl, 1, 32 | msg="Should fail as template does not exist") 33 | self.assertEqual( 34 | out, "", 35 | msg="No stdout was expected since there was an error. " 36 | "Current stdout:\n%r" % out) 37 | self.assertContains( 38 | err, "doesnotexist", 39 | msg="There should be an error message mentioning 'doesnotexist'. " 40 | "Current stderr:\n%s" % err) 41 | self.assertContains( 42 | err, "restructuredtext", 43 | msg="The error message should mention 'available'. " 44 | "Current stderr:\n%s" % err) 45 | self.assertContains( 46 | err, "mustache", 47 | msg="The error message should mention 'mustache'. " 48 | "Current stderr:\n%s" % err) 49 | self.assertContains( 50 | err, "restructuredtext", 51 | msg="The error message should mention 'restructuredtext'. " 52 | "Current stderr:\n%s" % err) 53 | 54 | def test_file_template_name(self): 55 | """Existing files should be accepted as valid templates""" 56 | 57 | gitchangelog.file_put_contents( 58 | "mytemplate.tpl", 59 | "check: {{{title}}}") 60 | gitchangelog.file_put_contents( 61 | ".gitchangelog.rc", 62 | "output_engine = mustache('mytemplate.tpl')") 63 | 64 | reference = """check: Changelog""" 65 | 66 | out, err, errlvl = cmd('$tprog --debug') 67 | self.assertEqual( 68 | err, "", 69 | msg="There should be no error messages. " 70 | "Current stderr:\n%s" % err) 71 | self.assertEqual( 72 | errlvl, 0, 73 | msg="Should succeed to find template") 74 | self.assertNoDiff( 75 | reference, out) 76 | 77 | def test_file_template_name_with_git_config_path(self): 78 | 79 | os.mkdir('XYZ') 80 | gitchangelog.file_put_contents( 81 | os.path.join('XYZ', "mytemplate.tpl"), 82 | "check: {{{title}}}") 83 | gitchangelog.file_put_contents( 84 | ".gitchangelog.rc", 85 | "output_engine = mustache('mytemplate.tpl')") 86 | self.git.config('gitchangelog.template-path', 'XYZ') 87 | 88 | reference = """check: Changelog""" 89 | 90 | out, err, errlvl = cmd('$tprog --debug') 91 | self.assertEqual( 92 | err, "", 93 | msg="There should be no error messages. " 94 | "Current stderr:\n%s" % err) 95 | self.assertEqual( 96 | errlvl, 0, 97 | msg="Should succeed to find template") 98 | self.assertNoDiff( 99 | reference, out) 100 | 101 | def test_template_has_access_to_full_commit(self): 102 | """Existing files should be accepted as valid templates""" 103 | 104 | gitchangelog.file_put_contents( 105 | "mytemplate.tpl", 106 | textwrap.dedent(""" 107 | % for version in data["versions"]: 108 | ${version["tag"]} 109 | % for section in version["sections"]: 110 | ${section["label"]}: 111 | % for commit in section["commits"]: 112 | - ${commit["commit"].subject} 113 | % endfor 114 | % endfor 115 | % endfor 116 | """)) 117 | gitchangelog.file_put_contents( 118 | ".gitchangelog.rc", 119 | "output_engine = makotemplate('mytemplate.tpl')") 120 | 121 | reference = textwrap.dedent(""" 122 | None 123 | New: 124 | - new: begin 125 | """) 126 | 127 | out, err, errlvl = cmd('$tprog') 128 | self.assertEqual( 129 | err, "", 130 | msg="There should be non error messages. " 131 | "Current stderr:\n%s" % err) 132 | self.assertEqual( 133 | errlvl, 0, 134 | msg="Should succeed to find template") 135 | self.assertNoDiff( 136 | reference, out) 137 | 138 | -------------------------------------------------------------------------------- /src/gitchangelog/gitchangelog.rc.reference.v2.1.3: -------------------------------------------------------------------------------- 1 | ## 2 | ## Format 3 | ## 4 | ## ACTION: [AUDIENCE:] COMMIT_MSG [@TAG ...] 5 | ## 6 | ## Description 7 | ## 8 | ## ACTION is one of 'chg', 'fix', 'new' 9 | ## 10 | ## Is WHAT the change is about. 11 | ## 12 | ## 'chg' is for refactor, small improvement, cosmetic changes... 13 | ## 'fix' is for bug fixes 14 | ## 'new' is for new features, big improvement 15 | ## 16 | ## SUBJECT is optional and one of 'dev', 'usr', 'pkg', 'test', 'doc' 17 | ## 18 | ## Is WHO is concerned by the change. 19 | ## 20 | ## 'dev' is for developpers (API changes, refactors...) 21 | ## 'usr' is for final users (UI changes) 22 | ## 'pkg' is for packagers (packaging changes) 23 | ## 'test' is for testers (test only related changes) 24 | ## 'doc' is for doc guys (doc only changes) 25 | ## 26 | ## COMMIT_MSG is ... well ... the commit message itself. 27 | ## 28 | ## TAGs are additionnal adjective as 'refactor' 'minor' 'cosmetic' 29 | ## 30 | ## 'refactor' is obviously for refactoring code only 31 | ## 'minor' is for a very meaningless change (a typo, adding a comment) 32 | ## 'cosmetic' is for cosmetic driven change (re-indentation, 80-col...) 33 | ## 'wip' is for partial functionality but complete subfunctionality. 34 | ## 35 | ## Example: 36 | ## 37 | ## new: usr: support of bazaar implemented 38 | ## chg: re-indentend some lines @cosmetic 39 | ## new: dev: updated code to be compatible with last version of killer lib. 40 | ## fix: pkg: updated year of licence coverage. 41 | ## new: test: added a bunch of test around user usability of feature X. 42 | ## fix: typo in spelling my name in comment. @minor 43 | ## 44 | ## Please note that multi-line commit message are supported, and only the 45 | ## first line will be considered as the "summary" of the commit message. So 46 | ## tags, and other rules only applies to the summary. The body of the commit 47 | ## message will be displayed in the changelog with minor reformating. 48 | 49 | 50 | ## 51 | ## ``ignore_regexps`` is a line of regexps 52 | ## 53 | ## Any commit having its full commit message matching any regexp listed here 54 | ## will be ignored and won't be reported in the changelog. 55 | ## 56 | ignore_regexps = [ 57 | r'@minor', r'!minor', 58 | r'@cosmetic', r'!cosmetic', 59 | r'@refactor', r'!refactor', 60 | r'@wip', r'!wip', 61 | r'^([cC]hg|[fF]ix|[nN]ew)\s*:\s*[p|P]kg:', 62 | r'^([cC]hg|[fF]ix|[nN]ew)\s*:\s*[d|D]ev:', 63 | r'^(.{3,3}\s*:)?\s*[fF]irst commit.?\s*$', 64 | ] 65 | 66 | 67 | ## 68 | ## ``replace_regexps`` is a dict associating a regexp pattern and its replacement 69 | ## 70 | ## It will be applied to get the summary line from the full commit message. 71 | ## 72 | ## Note that you can provide multiple replacement patterns, they will be all 73 | ## tried. If None matches, the summary line will be the full commit message. 74 | ## 75 | replace_regexps = { 76 | ## current format (ie: 'chg: dev: my commit msg @tag1 @tag2') 77 | 78 | r'^([cC]hg|[fF]ix|[nN]ew)\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n@]*)(@[a-z]+\s+)*$': 79 | r'\4', 80 | } 81 | 82 | 83 | ## ``section_regexps`` is a list of 2-tuples associating a string label and a 84 | ## list of regexp 85 | ## 86 | ## Commit messages will be classified in sections thanks to this. Section 87 | ## titles are the label, and a commit is classified under this section if any 88 | ## of the regexps associated is matching. 89 | ## 90 | section_regexps = [ 91 | ('New', [ 92 | r'^[nN]ew\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$', 93 | ]), 94 | ('Changes', [ 95 | r'^[cC]hg\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$', 96 | ]), 97 | ('Fix', [ 98 | r'^[fF]ix\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$', 99 | ]), 100 | 101 | ('Other', None ## Match all lines 102 | ), 103 | 104 | ] 105 | 106 | 107 | ## ``body_split_regexp`` is a regexp 108 | ## 109 | ## Commit message body (not the summary) if existing will be split 110 | ## (new line) on this regexp 111 | ## 112 | body_split_regexp = r'\n(?=\w+\s*:)' 113 | 114 | 115 | ## ``tag_filter_regexp`` is a regexp 116 | ## 117 | ## Tags that will be used for the changelog must match this regexp. 118 | ## 119 | tag_filter_regexp = r'^[0-9]+\.[0-9]+(\.[0-9]+)?$' 120 | 121 | 122 | ## ``unreleased_version_label`` is a string 123 | ## 124 | ## This label will be used as the changelog Title of the last set of changes 125 | ## between last valid tag and HEAD if any. 126 | unreleased_version_label = "(unreleased)" 127 | 128 | 129 | ## ``output_engine`` is a callable 130 | ## 131 | ## This will change the output format of the generated changelog file 132 | ## 133 | ## Available choices are: 134 | ## 135 | ## - rest_py 136 | ## 137 | ## Legacy pure python engine, outputs ReSTructured text. 138 | ## This is the default. 139 | ## 140 | ## - mustache() 141 | ## 142 | ## Template name could be any of the available templates in 143 | ## ``templates/mustache/*.tpl``. 144 | ## Requires python package ``pystache``. 145 | ## Examples: 146 | ## - mustache("markdown") 147 | ## - mustache("restructuredtext") 148 | ## 149 | ## - makotemplate() 150 | ## 151 | ## Template name could be any of the available templates in 152 | ## ``templates/mako/*.tpl``. 153 | ## Requires python package ``mako``. 154 | ## Examples: 155 | ## - makotemplate("restructuredtext") 156 | ## 157 | output_engine = rest_py 158 | #output_engine = mustache("restructuredtext") 159 | #output_engine = mustache("markdown") 160 | #output_engine = makotemplate("restructuredtext") 161 | 162 | 163 | ## ``include_merges`` is a boolean 164 | ## 165 | ## This option tells git-log whether to include merge commits in the log. 166 | ## The default is to include them. 167 | include_merges = True 168 | -------------------------------------------------------------------------------- /test/common.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | """ 3 | 4 | Each tests should start in an empty directory that will be destroyed at the end. 5 | 6 | 7 | 8 | """ 9 | 10 | from __future__ import unicode_literals 11 | 12 | import unittest 13 | import tempfile 14 | import os 15 | import os.path 16 | import shutil 17 | import re 18 | import sys 19 | 20 | from gitchangelog import gitchangelog 21 | 22 | WIN32 = gitchangelog.WIN32 23 | PY3 = gitchangelog.PY3 24 | 25 | 26 | def raw_renderer(data, opts): 27 | return data 28 | 29 | 30 | def simple_renderer(data, opts): 31 | """Provide a fixed template for tests. 32 | 33 | To use when checking what commits gets attributed to which 34 | versions/sections. 35 | 36 | Do not use if you want to check body contents as it is not printed. 37 | 38 | """ 39 | s = "" 40 | for version in data["versions"]: 41 | s += "%s\n" % version["tag"] 42 | for section in version["sections"]: 43 | s += " %s:\n" % section["label"] 44 | for commit in section["commits"]: 45 | s += " * %(subject)s [%(author)s]\n" % commit 46 | s += "\n" 47 | return s 48 | 49 | 50 | def replace_tprog(f): 51 | 52 | def _wrapped(*args, **kwargs): 53 | args = list(args) 54 | args[0] = args[0].replace('$tprog', tprog) 55 | return f(*args, **kwargs) 56 | return _wrapped 57 | 58 | 59 | def set_env(**se_kwargs): 60 | 61 | def decorator(f): 62 | 63 | def _wrapped(*args, **kwargs): 64 | env = dict(os.environ) 65 | for key, value in se_kwargs.items(): 66 | env[key] = value 67 | env.update(kwargs.get("env") or {}) 68 | kwargs["env"] = env 69 | return f(*args, **kwargs) 70 | return _wrapped 71 | return decorator 72 | 73 | BASE_PATH = os.path.normpath(os.path.join( 74 | os.path.dirname(os.path.realpath(__file__)), 75 | "..")) 76 | tprog = os.path.join(BASE_PATH, "src", "gitchangelog", "gitchangelog.py") 77 | 78 | WITH_COVERAGE = gitchangelog.cmd("coverage --version")[2] == 0 79 | if WITH_COVERAGE: 80 | source = os.path.join(BASE_PATH, 'src', 'gitchangelog') 81 | tprog = ('coverage run -a --source=%(source)s ' 82 | '--omit="%(omit)s" ' 83 | '--rcfile="%(rcfile)s" "%(tprog)s"' 84 | % {'base_path': BASE_PATH, 85 | 'python': sys.executable, 86 | 'tprog': tprog, 87 | 'source': source, 88 | 'omit': ",".join([ 89 | os.path.join(source, '__init__.py'), 90 | os.path.join(BASE_PATH, 'setup.py'), 91 | os.path.join(source, 'gitchangelog.rc.reference')]), 92 | 'rcfile': os.path.join(BASE_PATH, '.coveragerc'), 93 | }) 94 | tprog_set = set_env( 95 | COVERAGE_FILE=os.path.join(BASE_PATH, ".coverage.2"), 96 | PYTHONPATH=BASE_PATH, 97 | tprog=tprog) 98 | else: 99 | tprog = ('"%(python)s" "%(tprog)s"' 100 | % {'python': sys.executable, 101 | 'tprog': tprog}) 102 | if WIN32: 103 | ## For some reasons, even on 3.6, outputs in tests are in ``cp1252``. 104 | tprog_set = set_env( 105 | PYTHONIOENCODING="utf-8", 106 | tprog=tprog) 107 | else: 108 | tprog_set = set_env( 109 | tprog=tprog) 110 | 111 | 112 | w = replace_tprog(tprog_set(gitchangelog.wrap)) 113 | cmd = replace_tprog(tprog_set(gitchangelog.cmd)) 114 | 115 | 116 | class ExtendedTest(unittest.TestCase): 117 | 118 | def assertContains(self, haystack, needle, msg=None): 119 | if not msg: 120 | msg = "%r should contain %r." % (haystack, needle) 121 | self.assertTrue(needle in haystack, msg) 122 | 123 | def assertNotContains(self, haystack, needle, msg=None): 124 | if not msg: 125 | msg = "%r should not contain %r." % (haystack, needle) 126 | self.assertTrue(needle not in haystack, msg) 127 | 128 | def assertRegex(self, text, regex, msg=None): 129 | if not msg: 130 | msg = "%r should match regex %r." % (text, regex) 131 | self.assertTrue(re.search(regex, text, re.MULTILINE) is not None, msg) 132 | 133 | def assertNoDiff(self, t1, t2, msg=None): 134 | if WIN32: 135 | t1 = t1.replace('\r\n', '\n') 136 | t2 = t2.replace('\r\n', '\n') 137 | self.assertEqual(t1, t2, msg) 138 | 139 | 140 | class BaseTmpDirTest(ExtendedTest): 141 | 142 | def setUp(self): 143 | self.maxDiff = None 144 | ## put an empty tmp directory up 145 | self.old_cwd = os.getcwd() 146 | self.tmpdir = tempfile.mkdtemp() 147 | os.chdir(self.tmpdir) 148 | 149 | def tearDown(self): 150 | ## put an empty tmp directory up 151 | os.chdir(self.old_cwd) 152 | 153 | ## This is due to windows having loads of read-only files 154 | ## in unexpected places. 155 | def onerror(func, path, exc_info): 156 | 157 | import stat 158 | if not os.access(path, os.W_OK): 159 | # Is the error an access error ? 160 | os.chmod(path, stat.S_IWUSR) 161 | func(path) 162 | else: 163 | raise 164 | shutil.rmtree(self.tmpdir, onerror=onerror) 165 | 166 | 167 | class BaseGitReposTest(BaseTmpDirTest): 168 | 169 | def setUp(self): 170 | super(BaseGitReposTest, self).setUp() 171 | self.repos = gitchangelog.GitRepos.create( 172 | "repos", 173 | email="committer@example.com", 174 | user="The Committer") 175 | os.chdir("repos") 176 | 177 | @property 178 | def git(self): 179 | return self.repos.git 180 | 181 | @property 182 | def changelog(self): 183 | ## Currifyed main function 184 | return lambda **kw: gitchangelog.changelog( 185 | repository=self.repos, **kw) 186 | 187 | @property 188 | def raw_changelog(self): 189 | ## Currifyed main function 190 | return lambda **kw: gitchangelog.changelog( 191 | repository=self.repos, output_engine=raw_renderer, **kw) 192 | 193 | @property 194 | def simple_changelog(self): 195 | ## Currifyed main function 196 | return lambda **kw: gitchangelog.changelog( 197 | repository=self.repos, output_engine=simple_renderer, **kw) 198 | -------------------------------------------------------------------------------- /autogen.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | ## 4 | ## You can download latest version of this file: 5 | ## $ wget https://gist.github.com/vaab/9118087/raw -O autogen.sh 6 | ## $ chmod +x autogen.sh 7 | ## 8 | 9 | ## 10 | ## Functions 11 | ## 12 | 13 | exname="$(basename "$0")" 14 | long_tag="[0-9]+\.[0-9]+(\.[0-9]+)?-[0-9]+-[0-9a-f]+" 15 | short_tag="[0-9]+\.[0-9]+(\.[0-9]+)?" 16 | get_short_tag="s/^($short_tag).*\$/\1/g" 17 | 18 | get_path() { ( 19 | IFS=: 20 | for d in $PATH; do 21 | filename="$d/$1" 22 | [ -f "$filename" -a -x "$filename" ] && { 23 | echo "$d/$1" 24 | return 0 25 | } 26 | done 27 | return 1 28 | ) } 29 | 30 | print_exit() { 31 | echo "$@" 32 | exit 1 33 | } 34 | 35 | print_syntax_error() { 36 | [ "$*" ] || print_syntax_error "$FUNCNAME: no arguments" 37 | print_exit "${ERROR}script error:${NORMAL} $@" >&2 38 | } 39 | 40 | print_syntax_warning() { 41 | [ "$*" ] || print_syntax_error "$FUNCNAME: no arguments." 42 | [ "$exname" ] || print_syntax_error "$FUNCNAME: 'exname' var is null or not defined." 43 | echo "$exname: ${WARNING}script warning:${NORMAL} $@" >&2 44 | } 45 | 46 | print_error() { 47 | [ "$*" ] || print_syntax_warning "$FUNCNAME: no arguments." 48 | [ "$exname" ] || print_exit "$FUNCNAME: 'exname' var is null or not defined." >&2 49 | print_exit "$exname: ${ERROR}error:${NORMAL} $@" >&2 50 | } 51 | 52 | depends() { 53 | ## Avoid colliding with variables that are created with depends. 54 | local __i __tr __path __new_name 55 | __tr=$(get_path "tr") 56 | test "$__tr" || 57 | die "dependency check: couldn't find 'tr' command." 58 | 59 | for __i in "$@"; do 60 | if ! __path=$(get_path "$__i"); then 61 | __new_name=$(echo "$__i" | "$__tr" '_' '-') 62 | if [ "$__new_name" != "$__i" ]; then 63 | depends "$__new_name" 64 | else 65 | 66 | print_error "dependency check: couldn't find '$__i' required command." 67 | fi 68 | else 69 | if ! test -z "$__path" ; then 70 | export "$(echo "$__i" | "$__tr" -- '- ' '__')"="$__path" 71 | fi 72 | fi 73 | done 74 | } 75 | 76 | die() { 77 | [ "$*" ] || print_syntax_warning "$FUNCNAME: no arguments." 78 | [ "$exname" ] || print_exit "$FUNCNAME: 'exname' var is null or not defined." >&2 79 | print_exit "$exname: ${ERROR}error:${NORMAL}" "$@" >&2 80 | } 81 | 82 | matches() { 83 | echo "$1" | "$grep" -E "^$2\$" >/dev/null 2>&1 84 | } 85 | 86 | get_current_git_date_timestamp() { 87 | "$git" show -s --pretty=format:%ct 88 | } 89 | 90 | 91 | dev_version_tag() { 92 | compat_date "$(get_current_git_date_timestamp)" "+%Y%m%d%H%M" 93 | } 94 | 95 | 96 | get_current_version() { 97 | 98 | version=$("$git" describe --tags) 99 | if matches "$version" "$short_tag"; then 100 | echo "$version" 101 | else 102 | version=$(echo "$version" | compat_sed "$get_short_tag") 103 | echo "${version}.dev$(dev_version_tag)" 104 | fi 105 | 106 | } 107 | 108 | prepare_files() { 109 | 110 | version=$(get_current_version) 111 | short_version=$(echo "$version" | cut -f 1,2,3 -d ".") 112 | 113 | 114 | for file in $FILES; do 115 | if [ -e "$file" ]; then 116 | compat_sed_i "s#%%version%%#$version#g; 117 | s#%%short-version%%#${short_version}#g; 118 | s#%%name%%#${NAME}#g; 119 | s#%%author%%#${AUTHOR}#g; 120 | s#%%email%%#${EMAIL}#g; 121 | s#%%author-email%%#${AUTHOR_EMAIL}#g; 122 | s#%%description%%#${DESCRIPTION}#g" \ 123 | "$file" 124 | fi 125 | done 126 | 127 | echo "Version updated to $version." 128 | } 129 | 130 | ## 131 | ## LOAD CONFIG 132 | ## 133 | 134 | if [ -e ./.package ]; then 135 | . ./.package 136 | else 137 | echo "'./.package' file is missing." 138 | exit 1 139 | fi 140 | 141 | ## list of files where %%version*%% macros are to be replaced: 142 | [ -z "$FILES" ] && FILES="setup.cfg setup.py CHANGELOG.rst" 143 | 144 | [ -z "$NAME" ] && die "No \$NAME was defined in './package'." 145 | 146 | 147 | ## 148 | ## CHECK DEPS 149 | ## 150 | 151 | depends git grep 152 | 153 | ## BSD / GNU sed compatibility layer 154 | if get_path sed >/dev/null; then 155 | if sed --version >/dev/null 2>&1; then ## GNU 156 | compat_sed() { sed -r "$@"; } 157 | compat_sed_i() { sed -r -i "$@"; } 158 | else ## BSD 159 | compat_sed() { sed -E "$@"; } 160 | compat_sed_i() { sed -E -i "" "$@"; } 161 | fi 162 | else 163 | ## Look for ``gsed`` 164 | if (get_path gsed && gsed --version) >/dev/null 2>&1; then 165 | compat_sed() { gsed -r "$@"; } 166 | compat_sed_i() { gsed -r -i "$@"; } 167 | else 168 | print_error "$exname: required GNU or BSD sed not found" 169 | fi 170 | fi 171 | 172 | ## BSD / GNU date compatibility layer 173 | if get_path date >/dev/null; then 174 | if date --version >/dev/null 2>&1 ; then ## GNU 175 | compat_date() { date -d "@$1" "$2"; } 176 | else ## BSD 177 | compat_date() { date -j -f %s "$1" "$2"; } 178 | fi 179 | else 180 | if (get_path gdate && gdate --version) >/dev/null 2>&1; then 181 | compat_date() { gdate -d "@$1" "$2"; } 182 | else 183 | print_error "$exname: required GNU or BSD date not found" 184 | fi 185 | fi 186 | 187 | if ! "$git" describe --tags >/dev/null 2>&1; then 188 | die "Didn't find a git repository (or no tags found). " \ 189 | "\`\`./autogen.sh\`\` uses git to create changelog and version information." 190 | fi 191 | 192 | 193 | ## 194 | ## CODE 195 | ## 196 | 197 | if [ "$1" = "--get-version" ]; then 198 | get_current_version 199 | exit 0 200 | fi 201 | 202 | if [ "$1" = "--get-name" ]; then 203 | echo "$NAME" 204 | exit 0 205 | fi 206 | 207 | if get_path gitchangelog >/dev/null; then 208 | gitchangelog > CHANGELOG.rst 209 | if [ "$?" != 0 ]; then 210 | echo "Changelog NOT generated. An error occured while running \`\`gitchangelog\`\`." >&2 211 | else 212 | echo "Changelog generated." 213 | fi 214 | else 215 | echo "Changelog NOT generated because \`\`gitchangelog\`\` could not be found." 216 | touch CHANGELOG.rst ## create it anyway because it's required by setup.py current install 217 | fi 218 | 219 | prepare_files 220 | if [ "$?" != 0 ]; then 221 | print_error "Error while updating version information." 222 | fi 223 | -------------------------------------------------------------------------------- /test/test_publish.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | """Testing Incremental Functionality and Recipes 3 | 4 | 5 | """ 6 | 7 | from __future__ import unicode_literals 8 | 9 | import textwrap 10 | 11 | from .common import BaseGitReposTest, BaseTmpDirTest, cmd, \ 12 | gitchangelog 13 | 14 | 15 | class FullIncrementalRecipeTest(BaseGitReposTest): 16 | 17 | REFERENCE = textwrap.dedent("""\ 18 | Changelog 19 | ========= 20 | 21 | 22 | (unreleased) 23 | ------------ 24 | - C. [The Committer] 25 | 26 | 27 | 1.2 (2017-02-20) 28 | ---------------- 29 | - Previous content 30 | 31 | """) 32 | 33 | def setUp(self): 34 | super(FullIncrementalRecipeTest, self).setUp() 35 | 36 | self.git.commit(message="a", 37 | date="2017-02-20 11:00:00", 38 | allow_empty=True) 39 | self.git.commit(message="b", 40 | date="2017-02-20 11:00:00", 41 | allow_empty=True) 42 | self.git.tag("1.2") 43 | self.git.commit(message="c", 44 | date="2017-02-20 11:00:00", 45 | allow_empty=True) 46 | 47 | def test_insert_changelog_recipe(self): 48 | """Full incremental recipe""" 49 | 50 | gitchangelog.file_put_contents( 51 | ".gitchangelog.rc", 52 | textwrap.dedent( 53 | r""" 54 | OUTPUT_FILE = "CHANGELOG.rst" 55 | INSERT_POINT = r"\b(?P[0-9]+\.[0-9]+)\s+\([0-9]+-[0-9]{2}-[0-9]{2}\)\n--+\n" 56 | revs = [ 57 | Caret(FileFirstRegexMatch(OUTPUT_FILE, INSERT_POINT)), 58 | "HEAD" 59 | ] 60 | 61 | publish = FileInsertAtFirstRegexMatch( 62 | OUTPUT_FILE, INSERT_POINT, 63 | idx=lambda m: m.start(1) 64 | ) 65 | """)) 66 | gitchangelog.file_put_contents( 67 | "CHANGELOG.rst", 68 | textwrap.dedent("""\ 69 | Changelog 70 | ========= 71 | 72 | 73 | 1.2 (2017-02-20) 74 | ---------------- 75 | - Previous content 76 | 77 | """)) 78 | 79 | out, err, errlvl = cmd('$tprog') 80 | self.assertEqual( 81 | err, "", 82 | msg="There should be non error messages. " 83 | "Current stderr:\n%s" % err) 84 | self.assertEqual( 85 | errlvl, 0, 86 | msg="Should succeed") 87 | self.assertNoDiff(gitchangelog.file_get_contents("CHANGELOG.rst"), 88 | self.REFERENCE) 89 | 90 | def test_insert_changelog_recipe2(self): 91 | """Full incremental recipe with subst""" 92 | 93 | gitchangelog.file_put_contents( 94 | ".gitchangelog.rc", 95 | textwrap.dedent( 96 | r""" 97 | OUTPUT_FILE = "CHANGELOG.rst" 98 | REV_REGEX=r"[0-9]+\.[0-9]+(\.[0-9]+)?" 99 | INSERT_POINT_REGEX = r'''(?isxu) 100 | ^ 101 | ( 102 | \s*Changelog\s*(\n|\r\n|\r) ## ``Changelog`` line 103 | ==+\s*(\n|\r\n|\r){2} ## ``=========`` rest underline 104 | ) 105 | 106 | ( 107 | ( 108 | (?! 109 | (?<=(\n|\r)) ## look back for newline 110 | %(rev)s ## revision 111 | \s+ 112 | \([0-9]+-[0-9]{2}-[0-9]{2}\)(\n|\r\n|\r) ## date 113 | --+(\n|\r\n|\r) ## ``---`` underline 114 | ) 115 | . 116 | )* 117 | ) 118 | 119 | (?P%(rev)s) 120 | ''' % {'rev': REV_REGEX} 121 | 122 | revs = [ 123 | Caret(FileFirstRegexMatch(OUTPUT_FILE, INSERT_POINT_REGEX)), 124 | "HEAD" 125 | ] 126 | 127 | publish = FileRegexSubst( 128 | OUTPUT_FILE, INSERT_POINT_REGEX, r"\1\o\g" 129 | ) 130 | """)) 131 | gitchangelog.file_put_contents( 132 | "CHANGELOG.rst", 133 | textwrap.dedent("""\ 134 | Changelog 135 | ========= 136 | 137 | 138 | XXX Garbage 139 | 140 | 1.2 (2017-02-20) 141 | ---------------- 142 | - Previous content 143 | 144 | """)) 145 | 146 | out, err, errlvl = cmd('$tprog') 147 | self.assertEqual( 148 | err, "", 149 | msg="There should be non error messages. " 150 | "Current stderr:\n%s" % err) 151 | self.assertEqual( 152 | errlvl, 0, 153 | msg="Should succeed") 154 | self.assertNoDiff( 155 | self.REFERENCE, 156 | gitchangelog.file_get_contents("CHANGELOG.rst")) 157 | ## Re-applying will change nothing 158 | out, err, errlvl = cmd('$tprog') 159 | self.assertNoDiff( 160 | self.REFERENCE, 161 | gitchangelog.file_get_contents("CHANGELOG.rst")) 162 | 163 | 164 | class FileInsertAtFirstRegexMatchTest(BaseTmpDirTest): 165 | 166 | def test_insertions(self): 167 | def make_insertion(string, pattern, insert, **kw): 168 | FILE = "testing.txt" 169 | gitchangelog.file_put_contents(FILE, string) 170 | gitchangelog.FileInsertAtFirstRegexMatch(FILE, pattern, **kw)( 171 | insert.splitlines(True)) 172 | return gitchangelog.file_get_contents(FILE) 173 | 174 | self.assertEqual(make_insertion("", r"^", "B"), "B") 175 | self.assertEqual(make_insertion("AC", r"C", "B"), "ABC") 176 | self.assertEqual(make_insertion("BC", r"B", "A"), "ABC") 177 | self.assertEqual(make_insertion("AB", r"$", "C", idx=lambda m: m.end() + 1), "ABC") 178 | self.assertEqual(make_insertion("A\nC", r"C", "B\n"), "A\nB\nC") 179 | self.assertEqual(make_insertion("B\nC", r"B", "A\n"), "A\nB\nC") 180 | self.assertEqual(make_insertion("A\nB", r"$", "\nC", idx=lambda m: m.end() + 1), "A\nB\nC") 181 | self.assertEqual(make_insertion("A\nC\n", r"C", "B\n"), "A\nB\nC\n") 182 | self.assertEqual(make_insertion("B\nC\n", r"B", "A\n"), "A\nB\nC\n") 183 | self.assertEqual(make_insertion("A\nB\n", r"$", "C\n", idx=lambda m: m.end() + 1), "A\nB\nC\n") 184 | -------------------------------------------------------------------------------- /test/test_pr14.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | """ 3 | Tests pull request #14 Tags merged from other branches missing 4 | (https://github.com/securactive/gitchangelog/pull/14) 5 | 6 | Use cases intended to be covered by this test: 7 | - Tags located in branches that are not "first-parents" get included. 8 | eg. A release tag in develop and a release tag in master should both be 9 | analyzed for the changelog. 10 | - Changes committed to the repository before a tag on another branch should 11 | not be included in that tag, but the next descendant tag. 12 | 13 | Run test with: python -m unittest discover -fv -s test 14 | 15 | """ 16 | 17 | from __future__ import unicode_literals 18 | 19 | import textwrap 20 | 21 | from .common import BaseGitReposTest 22 | 23 | 24 | class TestCrossBranchTags(BaseGitReposTest): 25 | 26 | REFERENCE = textwrap.dedent("""\ 27 | 0.0.4 28 | None: 29 | * Merge branch 'master' into develop [The Committer] 30 | * new: some new commit [The Committer] 31 | * new: second commit on develop branch [The Committer] 32 | 33 | 0.0.3 34 | None: 35 | * fix: hotfix on master [The Committer] 36 | 37 | 0.0.2 38 | None: 39 | * new: first commit on develop branch [The Committer] 40 | 41 | 0.0.1 42 | None: 43 | * first commit [The Committer] 44 | 45 | """) 46 | 47 | def setUp(self): 48 | super(TestCrossBranchTags, self).setUp() 49 | 50 | ## Target tree: 51 | ## 52 | ## (Pdb) print w("git log --all --pretty=tformat:%s\ %d --graph") 53 | ## * Merge branch 'master' into develop (HEAD, tag: 0.0.4, develop) 54 | ## |\ 55 | ## | * new: some new commit (master) 56 | ## | * fix: hotfix on master (tag: 0.0.3) 57 | ## * | new: second commit on develop branch 58 | ## * | new: first commit on develop branch (tag: 0.0.2) 59 | ## |/ 60 | ## * first commit (tag: 0.0.1) 61 | 62 | self.git.commit(message='first commit', allow_empty=True) 63 | self.git.tag("0.0.1") 64 | 65 | ## We are on master branch by default... 66 | ## commit, tag 67 | self.git.checkout(b="develop") 68 | self.git.commit(message='new: first commit on develop branch', 69 | allow_empty=True) 70 | self.git.tag("0.0.2") 71 | 72 | self.git.commit(message='new: second commit on develop branch', 73 | allow_empty=True) 74 | 75 | ## Back on master, commit tag 76 | self.git.checkout("master") 77 | self.git.commit(message='fix: hotfix on master', allow_empty=True) 78 | self.git.tag("0.0.3") 79 | 80 | self.git.commit(message='new: some new commit', allow_empty=True) 81 | 82 | ## Merge and tag 83 | self.git.checkout("develop") 84 | self.git.merge("master", no_ff=True) 85 | self.git.tag("0.0.4") 86 | 87 | def test_nothing_missed_or_duplicate(self): 88 | """Test that all tags in branch history make it into changelog""" 89 | 90 | changelog = self.simple_changelog() 91 | self.assertNoDiff( 92 | self.REFERENCE, changelog) 93 | 94 | 95 | class TestLogLinearbility(BaseGitReposTest): 96 | """Test that commits are attributed to the proper release""" 97 | 98 | REFERENCE = textwrap.dedent("""\ 99 | 0.0.3 100 | None: 101 | * new: commit on develop branch [The Committer] 102 | 103 | 0.0.2 104 | None: 105 | * fix: something [The Committer] 106 | 107 | 0.0.1 108 | None: 109 | * first commit [The Committer] 110 | 111 | """) 112 | 113 | def setUp(self): 114 | super(TestLogLinearbility, self).setUp() 115 | 116 | ## Target tree: 117 | ## 118 | ## (Pdb) print w("git log --all --pretty=tformat:%s\ %d --graph") 119 | ## * new: commit on develop branch (HEAD, tag: 0.0.3, develop) 120 | ## * fix: something (tag: 0.0.2) 121 | ## * first commit (tag: 0.0.1, master) 122 | 123 | self.git.commit(message='first commit', allow_empty=True) 124 | self.git.tag("0.0.1") 125 | 126 | ## Branch 127 | self.git.checkout(b="develop") 128 | 129 | self.git.commit(message='fix: something', allow_empty=True) 130 | self.git.tag("0.0.2") 131 | 132 | self.git.commit(message='new: commit on develop branch', 133 | allow_empty=True) 134 | self.git.tag("0.0.3") 135 | 136 | def test_easy_release_attribution(self): 137 | """Test attribution when commits are already linear""" 138 | 139 | changelog = self.simple_changelog() 140 | self.assertNoDiff( 141 | self.REFERENCE, changelog) 142 | 143 | 144 | class TestLogHardLinearbility(BaseGitReposTest): 145 | 146 | REFERENCE = textwrap.dedent("""\ 147 | 0.2 148 | None: 149 | * new: something [The Committer] 150 | * Merge tag '0.1.1' into develop [The Committer] 151 | * chg: continued development [The Committer] 152 | 153 | 0.1.1 154 | None: 155 | * fix: out-of-band hotfix [The Committer] 156 | 157 | 0.1 158 | None: 159 | * fix: something [The Committer] 160 | 161 | 0.0.1 162 | None: 163 | * first commit [The Committer] 164 | 165 | """) 166 | 167 | def setUp(self): 168 | super(TestLogHardLinearbility, self).setUp() 169 | 170 | ## Target tree: 171 | ## 172 | ## (Pdb) print w("git log --all --pretty=tformat:%s\ %d --graph") 173 | ## * new: something (HEAD, tag: 0.2, develop) 174 | ## * Merge tag '0.1.1' into develop 175 | ## |\ 176 | ## | * fix: out-of-band hotfix (tag: 0.1.1) 177 | ## * | chg: continued development 178 | ## |/ 179 | ## * fix: something (tag: 0.1) 180 | ## * first commit (tag: 0.0.1, master) 181 | ## 182 | 183 | self.git.commit(message='first commit', allow_empty=True) 184 | self.git.tag("0.0.1") 185 | 186 | ## Branch 187 | self.git.checkout(b="develop") 188 | 189 | ## Build the tree 190 | self.git.commit(message='fix: something', allow_empty=True) 191 | self.git.tag("0.1") 192 | 193 | self.git.commit(message='chg: continued development', allow_empty=True) 194 | 195 | self.git.checkout("0.1") 196 | 197 | self.git.commit(message='fix: out-of-band hotfix', allow_empty=True) 198 | self.git.tag("0.1.1") 199 | 200 | self.git.checkout("develop") 201 | 202 | self.git.merge("0.1.1") 203 | 204 | self.git.commit(message='new: something', allow_empty=True) 205 | self.git.tag("0.2") 206 | 207 | def test_hard_release_attribution(self): 208 | """Test attribution for out-of-band releases""" 209 | 210 | changelog = self.simple_changelog() 211 | self.assertNoDiff( 212 | self.REFERENCE, changelog) 213 | -------------------------------------------------------------------------------- /test/test_config_file.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals 4 | 5 | import os 6 | import textwrap 7 | 8 | from .common import BaseGitReposTest, w, cmd, gitchangelog 9 | 10 | 11 | class BasicCallOnSimpleGit(BaseGitReposTest): 12 | 13 | def setUp(self): 14 | super(BasicCallOnSimpleGit, self).setUp() 15 | 16 | self.git.commit( 17 | message='new: begin', 18 | author='Bob ', 19 | date='2000-01-01 10:00:00', 20 | allow_empty=True) 21 | 22 | def test_overriding_options(self): 23 | """We must be able to define a small gitchangelog.rc that override only 24 | one variable of all the builtin defaults.""" 25 | 26 | self.git.commit( 27 | message='new: first commit', 28 | author='Bob ', 29 | date='2000-01-01 10:00:00', 30 | allow_empty=True) 31 | self.git.tag("v7.0") 32 | self.git.commit( 33 | message='new: second commit', 34 | author='Bob ', 35 | date='2000-01-01 10:00:00', 36 | allow_empty=True) 37 | self.git.tag("v8.0") 38 | 39 | gitchangelog.file_put_contents( 40 | ".gitchangelog.rc", 41 | "tag_filter_regexp = r'^v[0-9]+\\.[0.9]$'") 42 | 43 | changelog = w('$tprog') 44 | self.assertContains( 45 | changelog, "v8.0", 46 | msg="At least one of the tags should be displayed in changelog... " 47 | "content of changelog:\n%s" % changelog) 48 | 49 | def test_reuse_options(self): 50 | """We must be able to define a small gitchangelog.rc that reuse only 51 | one variable of all the builtin defaults.""" 52 | 53 | self.git.commit( 54 | message='new: XXX commit', 55 | author='Bob ', 56 | date='2000-01-01 10:00:00', 57 | allow_empty=True) 58 | self.git.commit( 59 | message='new: XYZ commit', 60 | author='Bob ', 61 | date='2000-01-01 10:00:00', 62 | allow_empty=True) 63 | self.git.commit( 64 | message='new: normal commit !minor', 65 | author='Bob ', 66 | date='2000-01-01 10:00:00', 67 | allow_empty=True) 68 | 69 | gitchangelog.file_put_contents( 70 | ".gitchangelog.rc", 71 | "ignore_regexps += [r'XXX', ]") 72 | 73 | changelog = w('$tprog') 74 | self.assertNotContains( 75 | changelog, "XXX", 76 | msg="Should not contain commit with XXX in it... " 77 | "content of changelog:\n%s" % changelog) 78 | self.assertContains( 79 | changelog, "XYZ", 80 | msg="Should contain commit with XYZ in it... " 81 | "content of changelog:\n%s" % changelog) 82 | self.assertNotContains( 83 | changelog, "!minor", 84 | msg="Shouldn't contain !minor tagged commit neither... " 85 | "content of changelog:\n%s" % changelog) 86 | 87 | def test_with_filename_same_as_tag(self): 88 | gitchangelog.file_put_contents("0.0.1", "") 89 | self.git.tag("0.0.1") 90 | out, err, errlvl = cmd('$tprog') 91 | self.assertEqual( 92 | errlvl, 0, 93 | msg="Should not fail even if filename same as tag name.") 94 | self.assertEqual( 95 | err, "", 96 | msg="No error message expected. " 97 | "Current stderr:\n%s" % err) 98 | 99 | def test_include_merge_options(self): 100 | """We must be able to define a small gitchangelog.rc that adjust only 101 | one variable of all the builtin defaults.""" 102 | 103 | gitchangelog.file_put_contents( 104 | ".gitchangelog.rc", 105 | "include_merge = False") 106 | self.git.checkout(b="develop") 107 | self.git.commit(message="made on develop branch", 108 | allow_empty=True) 109 | self.git.checkout("master") 110 | self.git.merge("develop", no_ff=True) 111 | 112 | changelog = w('$tprog') 113 | self.assertNotContains( 114 | changelog, "Merge", 115 | msg="Should not contain commit with 'Merge' in it... " 116 | "content of changelog:\n%s" % changelog) 117 | 118 | def test_config_file_is_not_a_file(self): 119 | 120 | os.mkdir(".gitchangelog.rc") 121 | out, err, errlvl = cmd('$tprog') 122 | self.assertEqual(errlvl, 1) 123 | self.assertContains(err, "is not a file") 124 | 125 | def test_config_file_syntax_error(self): 126 | 127 | gitchangelog.file_put_contents( 128 | ".gitchangelog.rc", 129 | "abc: ; test") 130 | out, err, errlvl = cmd('$tprog') 131 | self.assertEqual(errlvl, 1) 132 | self.assertContains(err.lower(), "syntax error") 133 | 134 | def test_subject_process_syntax_error(self): 135 | 136 | gitchangelog.file_put_contents( 137 | ".gitchangelog.rc", 138 | "subject_process = ucfirst | False") 139 | out, err, errlvl = cmd('$tprog') 140 | self.assertEqual(errlvl, 1) 141 | self.assertContains(err.lower(), "syntax error") 142 | 143 | 144 | class TestOnUnreleased(BaseGitReposTest): 145 | 146 | def setUp(self): 147 | super(TestOnUnreleased, self).setUp() 148 | 149 | self.git.commit( 150 | message='new: begin', 151 | author='Bob ', 152 | date='2000-01-01 10:00:00', 153 | allow_empty=True) 154 | self.git.tag("0.0.1") 155 | self.git.commit( 156 | message='new: begin', 157 | author='Bob ', 158 | date='2000-01-01 10:00:00', 159 | allow_empty=True) 160 | 161 | def test_unreleased_version_label_callable(self): 162 | """Using callable in unreleased_version_label should work""" 163 | 164 | gitchangelog.file_put_contents( 165 | ".gitchangelog.rc", 166 | "unreleased_version_label = lambda : 'foo'") 167 | changelog = w('$tprog "HEAD^..HEAD"') 168 | self.assertNoDiff( 169 | textwrap.dedent("""\ 170 | foo 171 | --- 172 | 173 | New 174 | ~~~ 175 | - Begin. [Bob] 176 | 177 | 178 | """), 179 | changelog) 180 | 181 | def test_unreleased_version_label_string(self): 182 | """Using string in unreleased_version_label should work""" 183 | 184 | gitchangelog.file_put_contents( 185 | ".gitchangelog.rc", 186 | "unreleased_version_label = 'bar'") 187 | changelog = w('$tprog "HEAD^..HEAD"') 188 | self.assertNoDiff( 189 | textwrap.dedent("""\ 190 | bar 191 | --- 192 | 193 | New 194 | ~~~ 195 | - Begin. [Bob] 196 | 197 | 198 | """), 199 | changelog) 200 | -------------------------------------------------------------------------------- /test/test_exc_handling.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals 4 | 5 | import textwrap 6 | 7 | from .common import BaseGitReposTest, cmd, gitchangelog 8 | 9 | 10 | class ExceptionHandlingTest(BaseGitReposTest): 11 | """Base for all tests needing to start in a new git small repository""" 12 | 13 | def setUp(self): 14 | super(ExceptionHandlingTest, self).setUp() 15 | 16 | self.git.commit( 17 | message='new: begin', 18 | author='Bob ', 19 | date='2000-01-01 10:00:00', 20 | allow_empty=True) 21 | gitchangelog.file_put_contents( 22 | ".gitchangelog.rc", 23 | textwrap.dedent(""" 24 | def raise_exc(data, opts): 25 | raise Exception('Test Exception XYZ') 26 | 27 | output_engine = raise_exc 28 | """)) 29 | 30 | def test_simple_with_changelog_python_exception(self): 31 | 32 | out, err, errlvl = cmd('$tprog') 33 | self.assertContains( 34 | err, "XYZ", 35 | msg="The exception message should be displayed and contain XYZ... " 36 | "Current stderr:\n%s" % err) 37 | self.assertEqual( 38 | errlvl, 255, 39 | msg="Should fail with errlvl 255 if exception in output_engine..." 40 | "Current errlvl: %s" % errlvl) 41 | self.assertContains( 42 | err, "--debug", 43 | msg="Message about ``--debug``... " 44 | "Current stderr:\n%s" % err) 45 | self.assertNotContains( 46 | err, "Traceback (most recent call last):", 47 | msg="The exception msg should NOT contain traceback information... " 48 | "Current stderr:\n%s" % err) 49 | self.assertEqual( 50 | out, "", 51 | msg="There should be no standard output. " 52 | "Current stdout:\n%r" % out) 53 | 54 | def test_simple_show_with_changelog_python_exception_deprecated(self): 55 | 56 | out, err, errlvl = cmd('$tprog show') 57 | self.assertContains( 58 | err, "XYZ", 59 | msg="The exception msg should be displayed and thus contain XYZ... " 60 | "Current stderr:\n%s" % err) 61 | self.assertEqual( 62 | errlvl, 255, 63 | msg="Should fail with errlvl 255 if exception in output_engine..." 64 | "Current errlvl: %s" % errlvl) 65 | self.assertContains( 66 | err, "--debug", 67 | msg="Message about ``--debug``... " 68 | "Current stderr:\n%s" % err) 69 | self.assertContains( 70 | err, "deprecated", 71 | msg="Message about show being deprecated... " 72 | "Current stderr:\n%s" % err) 73 | self.assertNotContains( 74 | err, "Traceback (most recent call last):", 75 | msg="The exception msg should NOT contain traceback information... " 76 | "Current stderr:\n%s" % err) 77 | self.assertEqual( 78 | out, "", 79 | msg="There should be no standard output. " 80 | "Current stdout:\n%r" % out) 81 | 82 | def test_with_changelog_python_exc_in_cli_debug_mode(self): 83 | 84 | out, err, errlvl = cmd('$tprog --debug') 85 | self.assertContains( 86 | err, "XYZ", 87 | msg="The exception msg should be displayed and thus contain XYZ... " 88 | "Current stderr:\n%s" % err) 89 | self.assertNotContains( 90 | err, "--debug", 91 | msg="Should not contain any message about ``--debug``... " 92 | "Current stderr:\n%s" % err) 93 | self.assertContains( 94 | err, "Traceback (most recent call last):", 95 | msg="The exception message should contain traceback information... " 96 | "Current stderr:\n%s" % err) 97 | self.assertEqual( 98 | errlvl, 255, 99 | msg="Should fail with errlvl 255 if exception in output_engine..." 100 | "Current errlvl: %s" % errlvl) 101 | self.assertEqual( 102 | out, "", 103 | msg="There should be no standard output. " 104 | "Current stdout:\n%r" % out) 105 | 106 | def test_show_with_changelog_python_exc_in_cli_debug_mode_deprecated(self): 107 | out, err, errlvl = cmd('$tprog --debug show') 108 | self.assertContains( 109 | err, "XYZ", 110 | msg="The exception msg should be displayed and thus contain XYZ... " 111 | "Current stderr:\n%s" % err) 112 | self.assertNotContains( 113 | err, "--debug", 114 | msg="Should not contain any message about ``--debug``... " 115 | "Current stderr:\n%s" % err) 116 | self.assertContains( 117 | err, "deprecated", 118 | msg="Should contain message about show being deprecated... " 119 | "Current stderr:\n%s" % err) 120 | self.assertContains( 121 | err, "Traceback (most recent call last):", 122 | msg="The exception message should contain traceback information... " 123 | "Current stderr:\n%s" % err) 124 | self.assertEqual( 125 | errlvl, 255, 126 | msg="Should fail with errlvl 255 if exception in output_engine..." 127 | "Current errlvl: %s" % errlvl) 128 | self.assertEqual( 129 | out, "", 130 | msg="There should be no standard output. " 131 | "Current stdout:\n%r" % out) 132 | 133 | def test_with_changelog_python_exc_in_cli_debug_mode_after(self): 134 | out, err, errlvl = cmd('$tprog HEAD --debug') 135 | self.assertContains( 136 | err, "XYZ", 137 | msg="The exception msg should be displayed and thus contain XYZ... " 138 | "Current stderr:\n%s" % err) 139 | self.assertNotContains( 140 | err, "--debug", 141 | msg="Should not contain any message about ``--debug``... " 142 | "Current stderr:\n%s" % err) 143 | self.assertContains( 144 | err, "Traceback (most recent call last):", 145 | msg="The exception message should contain traceback information... " 146 | "Current stderr:\n%s" % err) 147 | self.assertEqual( 148 | errlvl, 255, 149 | msg="Should fail with errlvl 255 if exception in output_engine..." 150 | "Current errlvl: %s" % errlvl) 151 | self.assertEqual( 152 | out, "", 153 | msg="There should be no standard output. " 154 | "Current stdout:\n%r" % out) 155 | 156 | def test_show_with_changelog_python_exc_in_cli_debug_mode_after_deprecated(self): 157 | out, err, errlvl = cmd('$tprog show --debug') 158 | self.assertContains( 159 | err, "XYZ", 160 | msg="The exception msg should be displayed and thus contain XYZ... " 161 | "Current stderr:\n%s" % err) 162 | self.assertNotContains( 163 | err, "--debug", 164 | msg="Should not contain any message about ``--debug``... " 165 | "Current stderr:\n%s" % err) 166 | self.assertContains( 167 | err, "deprecated", 168 | msg="Should contain message about show being deprecated... " 169 | "Current stderr:\n%s" % err) 170 | self.assertContains( 171 | err, "Traceback (most recent call last):", 172 | msg="The exception message should contain traceback information... " 173 | "Current stderr:\n%s" % err) 174 | self.assertEqual( 175 | errlvl, 255, 176 | msg="Should fail with errlvl 255 if exception in output_engine..." 177 | "Current errlvl: %s" % errlvl) 178 | self.assertEqual( 179 | out, "", 180 | msg="There should be no standard output. " 181 | "Current stdout:\n%r" % out) 182 | 183 | def test_with_changelog_python_exc_in_env_debug_mode(self): 184 | out, err, errlvl = cmd('$tprog', env={"DEBUG_GITCHANGELOG": "1"}) 185 | self.assertContains( 186 | err, "XYZ", 187 | msg="The exception msg should be displayed and thus contain XYZ... " 188 | "Current stderr:\n%s" % err) 189 | self.assertNotContains( 190 | err, "--debug", 191 | msg="Should not contain any message about ``--debug``... " 192 | "Current stderr:\n%s" % err) 193 | self.assertContains( 194 | err, "Traceback (most recent call last):", 195 | msg="The exception message should contain traceback information... " 196 | "Current stderr:\n%s" % err) 197 | self.assertEqual( 198 | errlvl, 255, 199 | msg="Should fail with errlvl 255 if exception in output_engine..." 200 | "Current errlvl: %s" % errlvl) 201 | self.assertEqual( 202 | out, "", 203 | msg="There should be no standard output. " 204 | "Current stdout:\n%r" % out) 205 | 206 | def test_show_with_changelog_python_exc_in_env_debug_mode_deprecated(self): 207 | out, err, errlvl = cmd('$tprog show', env={"DEBUG_GITCHANGELOG": "1"}) 208 | self.assertContains( 209 | err, "XYZ", 210 | msg="The exception msg should be displayed and thus contain XYZ... " 211 | "Current stderr:\n%s" % err) 212 | self.assertNotContains( 213 | err, "--debug", 214 | msg="Should not contain any message about ``--debug``... " 215 | "Current stderr:\n%s" % err) 216 | self.assertContains( 217 | err, "deprecated", 218 | msg="Should contain message about show being deprecated... " 219 | "Current stderr:\n%s" % err) 220 | self.assertContains( 221 | err, "Traceback (most recent call last):", 222 | msg="The exception message should contain traceback information... " 223 | "Current stderr:\n%s" % err) 224 | self.assertEqual( 225 | errlvl, 255, 226 | msg="Should fail with errlvl 255 if exception in output_engine..." 227 | "Current errlvl: %s" % errlvl) 228 | self.assertEqual( 229 | out, "", 230 | msg="There should be no standard output. " 231 | "Current stdout:\n%r" % out) 232 | -------------------------------------------------------------------------------- /src/gitchangelog/gitchangelog.rc.reference: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8; mode: python -*- 2 | ## 3 | ## Format 4 | ## 5 | ## ACTION: [AUDIENCE:] COMMIT_MSG [!TAG ...] 6 | ## 7 | ## Description 8 | ## 9 | ## ACTION is one of 'chg', 'fix', 'new' 10 | ## 11 | ## Is WHAT the change is about. 12 | ## 13 | ## 'chg' is for refactor, small improvement, cosmetic changes... 14 | ## 'fix' is for bug fixes 15 | ## 'new' is for new features, big improvement 16 | ## 17 | ## AUDIENCE is optional and one of 'dev', 'usr', 'pkg', 'test', 'doc' 18 | ## 19 | ## Is WHO is concerned by the change. 20 | ## 21 | ## 'dev' is for developpers (API changes, refactors...) 22 | ## 'usr' is for final users (UI changes) 23 | ## 'pkg' is for packagers (packaging changes) 24 | ## 'test' is for testers (test only related changes) 25 | ## 'doc' is for doc guys (doc only changes) 26 | ## 27 | ## COMMIT_MSG is ... well ... the commit message itself. 28 | ## 29 | ## TAGs are additionnal adjective as 'refactor' 'minor' 'cosmetic' 30 | ## 31 | ## They are preceded with a '!' or a '@' (prefer the former, as the 32 | ## latter is wrongly interpreted in github.) Commonly used tags are: 33 | ## 34 | ## 'refactor' is obviously for refactoring code only 35 | ## 'minor' is for a very meaningless change (a typo, adding a comment) 36 | ## 'cosmetic' is for cosmetic driven change (re-indentation, 80-col...) 37 | ## 'wip' is for partial functionality but complete subfunctionality. 38 | ## 39 | ## Example: 40 | ## 41 | ## new: usr: support of bazaar implemented 42 | ## chg: re-indentend some lines !cosmetic 43 | ## new: dev: updated code to be compatible with last version of killer lib. 44 | ## fix: pkg: updated year of licence coverage. 45 | ## new: test: added a bunch of test around user usability of feature X. 46 | ## fix: typo in spelling my name in comment. !minor 47 | ## 48 | ## Please note that multi-line commit message are supported, and only the 49 | ## first line will be considered as the "summary" of the commit message. So 50 | ## tags, and other rules only applies to the summary. The body of the commit 51 | ## message will be displayed in the changelog without reformatting. 52 | 53 | 54 | ## 55 | ## ``ignore_regexps`` is a line of regexps 56 | ## 57 | ## Any commit having its full commit message matching any regexp listed here 58 | ## will be ignored and won't be reported in the changelog. 59 | ## 60 | ignore_regexps = [ 61 | r'@minor', r'!minor', 62 | r'@cosmetic', r'!cosmetic', 63 | r'@refactor', r'!refactor', 64 | r'@wip', r'!wip', 65 | r'^([cC]hg|[fF]ix|[nN]ew)\s*:\s*[p|P]kg:', 66 | r'^([cC]hg|[fF]ix|[nN]ew)\s*:\s*[d|D]ev:', 67 | r'^(.{3,3}\s*:)?\s*[fF]irst commit.?\s*$', 68 | r'^$', ## ignore commits with empty messages 69 | ] 70 | 71 | 72 | ## ``section_regexps`` is a list of 2-tuples associating a string label and a 73 | ## list of regexp 74 | ## 75 | ## Commit messages will be classified in sections thanks to this. Section 76 | ## titles are the label, and a commit is classified under this section if any 77 | ## of the regexps associated is matching. 78 | ## 79 | ## Please note that ``section_regexps`` will only classify commits and won't 80 | ## make any changes to the contents. So you'll probably want to go check 81 | ## ``subject_process`` (or ``body_process``) to do some changes to the subject, 82 | ## whenever you are tweaking this variable. 83 | ## 84 | section_regexps = [ 85 | ('New', [ 86 | r'^[nN]ew\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$', 87 | ]), 88 | ('Changes', [ 89 | r'^[cC]hg\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$', 90 | ]), 91 | ('Fix', [ 92 | r'^[fF]ix\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$', 93 | ]), 94 | 95 | ('Other', None ## Match all lines 96 | ), 97 | 98 | ] 99 | 100 | 101 | ## ``body_process`` is a callable 102 | ## 103 | ## This callable will be given the original body and result will 104 | ## be used in the changelog. 105 | ## 106 | ## Available constructs are: 107 | ## 108 | ## - any python callable that take one txt argument and return txt argument. 109 | ## 110 | ## - ReSub(pattern, replacement): will apply regexp substitution. 111 | ## 112 | ## - Indent(chars=" "): will indent the text with the prefix 113 | ## Please remember that template engines gets also to modify the text and 114 | ## will usually indent themselves the text if needed. 115 | ## 116 | ## - Wrap(regexp=r"\n\n"): re-wrap text in separate paragraph to fill 80-Columns 117 | ## 118 | ## - noop: do nothing 119 | ## 120 | ## - ucfirst: ensure the first letter is uppercase. 121 | ## (usually used in the ``subject_process`` pipeline) 122 | ## 123 | ## - final_dot: ensure text finishes with a dot 124 | ## (usually used in the ``subject_process`` pipeline) 125 | ## 126 | ## - strip: remove any spaces before or after the content of the string 127 | ## 128 | ## - SetIfEmpty(msg="No commit message."): will set the text to 129 | ## whatever given ``msg`` if the current text is empty. 130 | ## 131 | ## Additionally, you can `pipe` the provided filters, for instance: 132 | #body_process = Wrap(regexp=r'\n(?=\w+\s*:)') | Indent(chars=" ") 133 | #body_process = Wrap(regexp=r'\n(?=\w+\s*:)') 134 | #body_process = noop 135 | body_process = ReSub(r'((^|\n)[A-Z]\w+(-\w+)*: .*(\n\s+.*)*)+$', r'') | strip 136 | 137 | 138 | ## ``subject_process`` is a callable 139 | ## 140 | ## This callable will be given the original subject and result will 141 | ## be used in the changelog. 142 | ## 143 | ## Available constructs are those listed in ``body_process`` doc. 144 | subject_process = (strip | 145 | ReSub(r'^([cC]hg|[fF]ix|[nN]ew)\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n@]*)(@[a-z]+\s+)*$', r'\4') | 146 | SetIfEmpty("No commit message.") | ucfirst | final_dot) 147 | 148 | 149 | ## ``tag_filter_regexp`` is a regexp 150 | ## 151 | ## Tags that will be used for the changelog must match this regexp. 152 | ## 153 | tag_filter_regexp = r'^[0-9]+\.[0-9]+(\.[0-9]+)?$' 154 | 155 | 156 | ## ``unreleased_version_label`` is a string or a callable that outputs a string 157 | ## 158 | ## This label will be used as the changelog Title of the last set of changes 159 | ## between last valid tag and HEAD if any. 160 | unreleased_version_label = "(unreleased)" 161 | 162 | 163 | ## ``output_engine`` is a callable 164 | ## 165 | ## This will change the output format of the generated changelog file 166 | ## 167 | ## Available choices are: 168 | ## 169 | ## - rest_py 170 | ## 171 | ## Legacy pure python engine, outputs ReSTructured text. 172 | ## This is the default. 173 | ## 174 | ## - mustache() 175 | ## 176 | ## Template name could be any of the available templates in 177 | ## ``templates/mustache/*.tpl``. 178 | ## Requires python package ``pystache``. 179 | ## Examples: 180 | ## - mustache("markdown") 181 | ## - mustache("restructuredtext") 182 | ## 183 | ## - makotemplate() 184 | ## 185 | ## Template name could be any of the available templates in 186 | ## ``templates/mako/*.tpl``. 187 | ## Requires python package ``mako``. 188 | ## Examples: 189 | ## - makotemplate("restructuredtext") 190 | ## 191 | output_engine = rest_py 192 | #output_engine = mustache("restructuredtext") 193 | #output_engine = mustache("markdown") 194 | #output_engine = makotemplate("restructuredtext") 195 | 196 | 197 | ## ``include_merge`` is a boolean 198 | ## 199 | ## This option tells git-log whether to include merge commits in the log. 200 | ## The default is to include them. 201 | include_merge = True 202 | 203 | 204 | ## ``log_encoding`` is a string identifier 205 | ## 206 | ## This option tells gitchangelog what encoding is outputed by ``git log``. 207 | ## The default is to be clever about it: it checks ``git config`` for 208 | ## ``i18n.logOutputEncoding``, and if not found will default to git's own 209 | ## default: ``utf-8``. 210 | #log_encoding = 'utf-8' 211 | 212 | 213 | ## ``publish`` is a callable 214 | ## 215 | ## Sets what ``gitchangelog`` should do with the output generated by 216 | ## the output engine. ``publish`` is a callable taking one argument 217 | ## that is an interator on lines from the output engine. 218 | ## 219 | ## Some helper callable are provided: 220 | ## 221 | ## Available choices are: 222 | ## 223 | ## - stdout 224 | ## 225 | ## Outputs directly to standard output 226 | ## (This is the default) 227 | ## 228 | ## - FileInsertAtFirstRegexMatch(file, pattern, idx=lamda m: m.start()) 229 | ## 230 | ## Creates a callable that will parse given file for the given 231 | ## regex pattern and will insert the output in the file. 232 | ## ``idx`` is a callable that receive the matching object and 233 | ## must return a integer index point where to insert the 234 | ## the output in the file. Default is to return the position of 235 | ## the start of the matched string. 236 | ## 237 | ## - FileRegexSubst(file, pattern, replace, flags) 238 | ## 239 | ## Apply a replace inplace in the given file. Your regex pattern must 240 | ## take care of everything and might be more complex. Check the README 241 | ## for a complete copy-pastable example. 242 | ## 243 | # publish = FileInsertIntoFirstRegexMatch( 244 | # "CHANGELOG.rst", 245 | # r'/(?P[0-9]+\.[0-9]+(\.[0-9]+)?)\s+\([0-9]+-[0-9]{2}-[0-9]{2}\)\n--+\n/', 246 | # idx=lambda m: m.start(1) 247 | # ) 248 | #publish = stdout 249 | 250 | 251 | ## ``revs`` is a list of callable or a list of string 252 | ## 253 | ## callable will be called to resolve as strings and allow dynamical 254 | ## computation of these. The result will be used as revisions for 255 | ## gitchangelog (as if directly stated on the command line). This allows 256 | ## to filter exaclty which commits will be read by gitchangelog. 257 | ## 258 | ## To get a full documentation on the format of these strings, please 259 | ## refer to the ``git rev-list`` arguments. There are many examples. 260 | ## 261 | ## Using callables is especially useful, for instance, if you 262 | ## are using gitchangelog to generate incrementally your changelog. 263 | ## 264 | ## Some helpers are provided, you can use them:: 265 | ## 266 | ## - FileFirstRegexMatch(file, pattern): will return a callable that will 267 | ## return the first string match for the given pattern in the given file. 268 | ## If you use named sub-patterns in your regex pattern, it'll output only 269 | ## the string matching the regex pattern named "rev". 270 | ## 271 | ## - Caret(rev): will return the rev prefixed by a "^", which is a 272 | ## way to remove the given revision and all its ancestor. 273 | ## 274 | ## Please note that if you provide a rev-list on the command line, it'll 275 | ## replace this value (which will then be ignored). 276 | ## 277 | ## If empty, then ``gitchangelog`` will act as it had to generate a full 278 | ## changelog. 279 | ## 280 | ## The default is to use all commits to make the changelog. 281 | #revs = ["^1.0.3", ] 282 | #revs = [ 283 | # Caret( 284 | # FileFirstRegexMatch( 285 | # "CHANGELOG.rst", 286 | # r"(?P[0-9]+\.[0-9]+(\.[0-9]+)?)\s+\([0-9]+-[0-9]{2}-[0-9]{2}\)\n--+\n")), 287 | # "HEAD" 288 | #] 289 | revs = [] 290 | -------------------------------------------------------------------------------- /.gitchangelog.rc: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8; mode: python -*- 2 | ## 3 | ## Format 4 | ## 5 | ## ACTION: [AUDIENCE:] COMMIT_MSG [!TAG ...] 6 | ## 7 | ## Description 8 | ## 9 | ## ACTION is one of 'chg', 'fix', 'new' 10 | ## 11 | ## Is WHAT the change is about. 12 | ## 13 | ## 'chg' is for refactor, small improvement, cosmetic changes... 14 | ## 'fix' is for bug fixes 15 | ## 'new' is for new features, big improvement 16 | ## 17 | ## AUDIENCE is optional and one of 'dev', 'usr', 'pkg', 'test', 'doc' 18 | ## 19 | ## Is WHO is concerned by the change. 20 | ## 21 | ## 'dev' is for developpers (API changes, refactors...) 22 | ## 'usr' is for final users (UI changes) 23 | ## 'pkg' is for packagers (packaging changes) 24 | ## 'test' is for testers (test only related changes) 25 | ## 'doc' is for doc guys (doc only changes) 26 | ## 27 | ## COMMIT_MSG is ... well ... the commit message itself. 28 | ## 29 | ## TAGs are additionnal adjective as 'refactor' 'minor' 'cosmetic' 30 | ## 31 | ## They are preceded with a '!' or a '@' (prefer the former, as the 32 | ## latter is wrongly interpreted in github.) Commonly used tags are: 33 | ## 34 | ## 'refactor' is obviously for refactoring code only 35 | ## 'minor' is for a very meaningless change (a typo, adding a comment) 36 | ## 'cosmetic' is for cosmetic driven change (re-indentation, 80-col...) 37 | ## 'wip' is for partial functionality but complete subfunctionality. 38 | ## 39 | ## Example: 40 | ## 41 | ## new: usr: support of bazaar implemented 42 | ## chg: re-indentend some lines !cosmetic 43 | ## new: dev: updated code to be compatible with last version of killer lib. 44 | ## fix: pkg: updated year of licence coverage. 45 | ## new: test: added a bunch of test around user usability of feature X. 46 | ## fix: typo in spelling my name in comment. !minor 47 | ## 48 | ## Please note that multi-line commit message are supported, and only the 49 | ## first line will be considered as the "summary" of the commit message. So 50 | ## tags, and other rules only applies to the summary. The body of the commit 51 | ## message will be displayed in the changelog without reformatting. 52 | 53 | 54 | ## 55 | ## ``ignore_regexps`` is a line of regexps 56 | ## 57 | ## Any commit having its full commit message matching any regexp listed here 58 | ## will be ignored and won't be reported in the changelog. 59 | ## 60 | ignore_regexps = [ 61 | r'@minor', r'!minor', 62 | r'@cosmetic', r'!cosmetic', 63 | r'@refactor', r'!refactor', 64 | r'@wip', r'!wip', 65 | r'^([cC]hg|[fF]ix|[nN]ew)\s*:\s*[p|P]kg:', 66 | r'^([cC]hg|[fF]ix|[nN]ew)\s*:\s*[d|D]ev:', 67 | r'^(.{3,3}\s*:)?\s*[fF]irst commit.?\s*$', 68 | r'^$', ## ignore commits with empty messages 69 | ] 70 | 71 | 72 | ## ``section_regexps`` is a list of 2-tuples associating a string label and a 73 | ## list of regexp 74 | ## 75 | ## Commit messages will be classified in sections thanks to this. Section 76 | ## titles are the label, and a commit is classified under this section if any 77 | ## of the regexps associated is matching. 78 | ## 79 | ## Please note that ``section_regexps`` will only classify commits and won't 80 | ## make any changes to the contents. So you'll probably want to go check 81 | ## ``subject_process`` (or ``body_process``) to do some changes to the subject, 82 | ## whenever you are tweaking this variable. 83 | ## 84 | section_regexps = [ 85 | ('New', [ 86 | r'^[nN]ew\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$', 87 | ]), 88 | ('Changes', [ 89 | r'^[cC]hg\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$', 90 | ]), 91 | ('Fix', [ 92 | r'^[fF]ix\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$', 93 | ]), 94 | 95 | ('Other', None ## Match all lines 96 | ), 97 | 98 | ] 99 | 100 | 101 | ## ``body_process`` is a callable 102 | ## 103 | ## This callable will be given the original body and result will 104 | ## be used in the changelog. 105 | ## 106 | ## Available constructs are: 107 | ## 108 | ## - any python callable that take one txt argument and return txt argument. 109 | ## 110 | ## - ReSub(pattern, replacement): will apply regexp substitution. 111 | ## 112 | ## - Indent(chars=" "): will indent the text with the prefix 113 | ## Please remember that template engines gets also to modify the text and 114 | ## will usually indent themselves the text if needed. 115 | ## 116 | ## - Wrap(regexp=r"\n\n"): re-wrap text in separate paragraph to fill 80-Columns 117 | ## 118 | ## - noop: do nothing 119 | ## 120 | ## - ucfirst: ensure the first letter is uppercase. 121 | ## (usually used in the ``subject_process`` pipeline) 122 | ## 123 | ## - final_dot: ensure text finishes with a dot 124 | ## (usually used in the ``subject_process`` pipeline) 125 | ## 126 | ## - strip: remove any spaces before or after the content of the string 127 | ## 128 | ## - SetIfEmpty(msg="No commit message."): will set the text to 129 | ## whatever given ``msg`` if the current text is empty. 130 | ## 131 | ## Additionally, you can `pipe` the provided filters, for instance: 132 | #body_process = Wrap(regexp=r'\n(?=\w+\s*:)') | Indent(chars=" ") 133 | #body_process = Wrap(regexp=r'\n(?=\w+\s*:)') 134 | #body_process = noop 135 | body_process = ReSub(r'((^|\n)[A-Z]\w+(-\w+)*: .*(\n\s+.*)*)+$', r'') | strip 136 | 137 | 138 | ## ``subject_process`` is a callable 139 | ## 140 | ## This callable will be given the original subject and result will 141 | ## be used in the changelog. 142 | ## 143 | ## Available constructs are those listed in ``body_process`` doc. 144 | subject_process = (strip | 145 | ReSub(r'^([cC]hg|[fF]ix|[nN]ew)\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n@]*)(@[a-z]+\s+)*$', r'\4') | 146 | SetIfEmpty("No commit message.") | ucfirst | final_dot) 147 | 148 | 149 | ## ``tag_filter_regexp`` is a regexp 150 | ## 151 | ## Tags that will be used for the changelog must match this regexp. 152 | ## 153 | tag_filter_regexp = r'^[0-9]+\.[0-9]+(\.[0-9]+)?$' 154 | 155 | 156 | ## ``unreleased_version_label`` is a string or a callable that outputs a string 157 | ## 158 | ## This label will be used as the changelog Title of the last set of changes 159 | ## between last valid tag and HEAD if any. 160 | import os.path 161 | unreleased_version_label = lambda: swrap( 162 | (["bash"] if WIN32 else []) + 163 | [os.path.join(".", "autogen.sh"), "--get-version"], 164 | shell=False) 165 | 166 | 167 | 168 | ## ``output_engine`` is a callable 169 | ## 170 | ## This will change the output format of the generated changelog file 171 | ## 172 | ## Available choices are: 173 | ## 174 | ## - rest_py 175 | ## 176 | ## Legacy pure python engine, outputs ReSTructured text. 177 | ## This is the default. 178 | ## 179 | ## - mustache() 180 | ## 181 | ## Template name could be any of the available templates in 182 | ## ``templates/mustache/*.tpl``. 183 | ## Requires python package ``pystache``. 184 | ## Examples: 185 | ## - mustache("markdown") 186 | ## - mustache("restructuredtext") 187 | ## 188 | ## - makotemplate() 189 | ## 190 | ## Template name could be any of the available templates in 191 | ## ``templates/mako/*.tpl``. 192 | ## Requires python package ``mako``. 193 | ## Examples: 194 | ## - makotemplate("restructuredtext") 195 | ## 196 | output_engine = rest_py 197 | #output_engine = mustache("restructuredtext") 198 | #output_engine = mustache("markdown") 199 | #output_engine = makotemplate("restructuredtext") 200 | 201 | 202 | ## ``include_merge`` is a boolean 203 | ## 204 | ## This option tells git-log whether to include merge commits in the log. 205 | ## The default is to include them. 206 | include_merge = True 207 | 208 | 209 | ## ``log_encoding`` is a string identifier 210 | ## 211 | ## This option tells gitchangelog what encoding is outputed by ``git log``. 212 | ## The default is to be clever about it: it checks ``git config`` for 213 | ## ``i18n.logOutputEncoding``, and if not found will default to git's own 214 | ## default: ``utf-8``. 215 | #log_encoding = 'utf-8' 216 | 217 | 218 | ## ``publish`` is a callable 219 | ## 220 | ## Sets what ``gitchangelog`` should do with the output generated by 221 | ## the output engine. ``publish`` is a callable taking one argument 222 | ## that is an interator on lines from the output engine. 223 | ## 224 | ## Some helper callable are provided: 225 | ## 226 | ## Available choices are: 227 | ## 228 | ## - stdout 229 | ## 230 | ## Outputs directly to standard output 231 | ## (This is the default) 232 | ## 233 | ## - FileInsertAtFirstRegexMatch(file, pattern, idx=lamda m: m.start(), flags) 234 | ## 235 | ## Creates a callable that will parse given file for the given 236 | ## regex pattern and will insert the output in the file. 237 | ## ``idx`` is a callable that receive the matching object and 238 | ## must return a integer index point where to insert the 239 | ## the output in the file. Default is to return the position of 240 | ## the start of the matched string. 241 | ## 242 | ## - FileRegexSubst(file, pattern, replace, flags) 243 | ## 244 | ## Apply a replace inplace in the given file. Your regex pattern must 245 | ## take care of everything and might be more complex. Check the README 246 | ## for a complete copy-pastable example. 247 | ## 248 | # publish = FileInsertIntoFirstRegexMatch( 249 | # "CHANGELOG.rst", 250 | # r'/(?P[0-9]+\.[0-9]+(\.[0-9]+)?)\s+\([0-9]+-[0-9]{2}-[0-9]{2}\)\n--+\n/', 251 | # idx=lambda m: m.start(1) 252 | # ) 253 | #publish = stdout 254 | 255 | 256 | ## ``revs`` is a list of callable or a list of string 257 | ## 258 | ## callable will be called to resolve as strings and allow dynamical 259 | ## computation of these. The result will be used as revisions for 260 | ## gitchangelog (as if directly stated on the command line). This allows 261 | ## to filter exaclty which commits will be read by gitchangelog. 262 | ## 263 | ## To get a full documentation on the format of these strings, please 264 | ## refer to the ``git rev-list`` arguments. There are many examples. 265 | ## 266 | ## Using callables is especially useful, for instance, if you 267 | ## are using gitchangelog to generate incrementally your changelog. 268 | ## 269 | ## Some helpers are provided, you can use them:: 270 | ## 271 | ## - FileFirstRegexMatch(file, pattern): will return a callable that will 272 | ## return the first string match for the given pattern in the given file. 273 | ## If you use named sub-patterns in your regex pattern, it'll output only 274 | ## the string matching the regex pattern named "rev". 275 | ## 276 | ## - Caret(rev): will return the rev prefixed by a "^", which is a 277 | ## way to remove the given revision and all its ancestor. 278 | ## 279 | ## Please note that if you provide a rev-list on the command line, it'll 280 | ## replace this value (which will then be ignored). 281 | ## 282 | ## If empty, then ``gitchangelog`` will act as it had to generate a full 283 | ## changelog. 284 | ## 285 | ## The default is to use all commits to make the changelog. 286 | #revs = ["^1.0.3", ] 287 | #revs = [ 288 | # Caret( 289 | # FileFirstRegexMatch( 290 | # "CHANGELOG.rst", 291 | # r"(?P[0-9]+\.[0-9]+(\.[0-9]+)?)\s+\([0-9]+-[0-9]{2}-[0-9]{2}\)\n--+\n")), 292 | # "HEAD" 293 | #] 294 | revs = [] 295 | -------------------------------------------------------------------------------- /test/test_issue61.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | """Implementation of ``revs`` option 3 | 4 | Tests issue #62 5 | (https://github.com/vaab/gitchangelog/issues/62) 6 | 7 | """ 8 | 9 | from __future__ import unicode_literals 10 | 11 | import textwrap 12 | 13 | from .common import BaseGitReposTest, cmd, gitchangelog 14 | 15 | 16 | class TestRevsBadFormat(BaseGitReposTest): 17 | 18 | def test_bad_revs_format(self): 19 | super(TestRevsBadFormat, self).setUp() 20 | 21 | gitchangelog.file_put_contents( 22 | ".gitchangelog.rc", 23 | "revs = '1.2'" 24 | ) 25 | 26 | out, err, errlvl = cmd('$tprog') 27 | self.assertContains( 28 | err, "'list' type is required", 29 | msg="There should be an error message containing " 30 | "\"'list' type is required\". " 31 | "Current stderr:\n%s" % err) 32 | self.assertEqual( 33 | errlvl, 1, 34 | msg="Should fail") 35 | self.assertEqual( 36 | out, "", 37 | msg="No standard output is expected") 38 | 39 | def test_bad_revs_format_callable(self): 40 | super(TestRevsBadFormat, self).setUp() 41 | 42 | gitchangelog.file_put_contents( 43 | ".gitchangelog.rc", 44 | "revs = lambda: '1.2'" 45 | ) 46 | 47 | out, err, errlvl = cmd('$tprog') 48 | self.assertContains( 49 | err, "'list' type is required", 50 | msg="There should be an error message containing " 51 | "\"'list' type is required\". " 52 | "Current stderr:\n%s" % err) 53 | self.assertEqual( 54 | errlvl, 1, 55 | msg="Should fail") 56 | self.assertEqual( 57 | out, "", 58 | msg="No output is expected since it errored... ") 59 | 60 | def test_bad_rev_in_revs_format(self): 61 | super(TestRevsBadFormat, self).setUp() 62 | 63 | gitchangelog.file_put_contents( 64 | ".gitchangelog.rc", 65 | "revs = [[]]" 66 | ) 67 | 68 | out, err, errlvl = cmd('$tprog') 69 | self.assertContains( 70 | err, "'str' type is required", 71 | msg="There should be an error message containing " 72 | "\"'str' type is required\". " 73 | "Current stderr:\n%s" % err) 74 | self.assertEqual( 75 | errlvl, 1, 76 | msg="Should fail") 77 | self.assertEqual( 78 | out, "", 79 | msg="No output is expected since it errored... ") 80 | 81 | 82 | class TestBasicRevs(BaseGitReposTest): 83 | 84 | REFERENCE = textwrap.dedent("""\ 85 | None 86 | None: 87 | * c [The Committer] 88 | 89 | """) 90 | 91 | REFERENCE2 = textwrap.dedent("""\ 92 | Changelog 93 | ========= 94 | 95 | 96 | (unreleased) 97 | ------------ 98 | - C. [The Committer] 99 | 100 | 101 | 1.2 (2017-02-20) 102 | ---------------- 103 | - B. [The Committer] 104 | - A. [The Committer] 105 | 106 | 107 | """) 108 | 109 | def setUp(self): 110 | super(TestBasicRevs, self).setUp() 111 | 112 | self.git.commit(message="a", 113 | date="2017-02-20 11:00:00", 114 | allow_empty=True) 115 | self.git.commit(message="b", 116 | date="2017-02-20 11:00:00", 117 | allow_empty=True) 118 | self.git.tag("1.2") 119 | self.git.commit(message="c", 120 | date="2017-02-20 11:00:00", 121 | allow_empty=True) 122 | 123 | def test_matching_reference(self): 124 | """Test that only last commit is in the changelog""" 125 | 126 | changelog = self.simple_changelog(revlist=['^1.2', 'HEAD']) 127 | self.assertNoDiff( 128 | self.REFERENCE, changelog) 129 | 130 | def test_cli_over_file_precedence(self): 131 | 132 | gitchangelog.file_put_contents( 133 | ".gitchangelog.rc", 134 | textwrap.dedent(r""" 135 | revs = [ 136 | Caret( 137 | FileFirstRegexMatch( 138 | "CHANGELOG.rst", 139 | r"(?P[0-9]+\.[0-9]+)\s+\([0-9]+-[0-9]{2}-[0-9]{2}\)\n--+\n")), 140 | "HEAD" 141 | ] 142 | """)) 143 | 144 | out, err, errlvl = cmd('$tprog HEAD') 145 | self.assertEqual( 146 | err, "", 147 | msg="There should be non error messages. " 148 | "Current stderr:\n%s" % err) 149 | self.assertEqual( 150 | errlvl, 0, 151 | msg="Should succeed") 152 | self.assertNoDiff(self.REFERENCE2, out) 153 | 154 | def test_callable_rev_file_first_regex_match_no_file(self): 155 | 156 | gitchangelog.file_put_contents( 157 | ".gitchangelog.rc", 158 | textwrap.dedent(r""" 159 | revs = [ 160 | Caret( 161 | FileFirstRegexMatch( 162 | "CHANGELOG.rst", 163 | r"(?P[0-9]+\.[0-9]+)\s+\([0-9]+-[0-9]{2}-[0-9]{2}\)\n--+\n\n")), 164 | "HEAD" 165 | ] 166 | """)) 167 | 168 | out, err, errlvl = cmd('$tprog') 169 | self.assertEqual(errlvl, 1) 170 | self.assertContains(err, "CHANGELOG.rst") 171 | self.assertEqual("", out) 172 | 173 | def test_callable_rev_file_first_regex_match_fails(self): 174 | 175 | gitchangelog.file_put_contents( 176 | ".gitchangelog.rc", 177 | textwrap.dedent(r""" 178 | revs = [ 179 | Caret( 180 | FileFirstRegexMatch( 181 | "CHANGELOG.rst", 182 | r"XXX(?P[0-9]+\.[0-9]+)\s+\([0-9]+-[0-9]{2}-[0-9]{2}\)\n--+\n")), 183 | "HEAD" 184 | ] 185 | """)) 186 | gitchangelog.file_put_contents( 187 | "CHANGELOG.rst", 188 | textwrap.dedent("""\ 189 | Changelog 190 | ========= 191 | 192 | 193 | 1.2 (2017-02-20) 194 | ---------------- 195 | - B. [The Committer] 196 | - A. [The Committer] 197 | 198 | """)) 199 | 200 | out, err, errlvl = cmd('$tprog') 201 | self.assertContains(err, "CHANGELOG.rst") 202 | self.assertEqual(errlvl, 1) 203 | self.assertContains(err, "match") 204 | self.assertEqual("", out) 205 | 206 | def test_callable_rev_file_first_regex_match_working(self): 207 | 208 | gitchangelog.file_put_contents( 209 | ".gitchangelog.rc", 210 | textwrap.dedent(r""" 211 | revs = [ 212 | Caret( 213 | FileFirstRegexMatch( 214 | "CHANGELOG.rst", 215 | r"(?P[0-9]+\.[0-9]+)\s+\([0-9]+-[0-9]{2}-[0-9]{2}\)\n--+\n")), 216 | "HEAD" 217 | ] 218 | """)) 219 | gitchangelog.file_put_contents( 220 | "CHANGELOG.rst", 221 | textwrap.dedent("""\ 222 | Changelog 223 | ========= 224 | 225 | 226 | 1.2 (2017-02-20) 227 | ---------------- 228 | - B. [The Committer] 229 | - A. [The Committer] 230 | 231 | """)) 232 | 233 | out, err, errlvl = cmd('$tprog') 234 | self.assertEqual( 235 | err, "", 236 | msg="There should be non error messages. " 237 | "Current stderr:\n%s" % err) 238 | self.assertEqual( 239 | errlvl, 0, 240 | msg="Should succeed") 241 | self.assertNoDiff(textwrap.dedent("""\ 242 | (unreleased) 243 | ------------ 244 | - C. [The Committer] 245 | 246 | 247 | """), out) 248 | 249 | def test_callable_rev_file_first_regex_reg_support(self): 250 | 251 | gitchangelog.file_put_contents( 252 | ".gitchangelog.rc", 253 | textwrap.dedent(r""" 254 | import re 255 | REGEX = re.compile(r"(?P[0-9]+\.[0-9]+)\s+\([0-9]+-[0-9]{2}-[0-9]{2}\)\n--+\n") 256 | revs = [ 257 | Caret( 258 | FileFirstRegexMatch( 259 | "CHANGELOG.rst", 260 | REGEX)), 261 | "HEAD" 262 | ] 263 | """)) 264 | gitchangelog.file_put_contents( 265 | "CHANGELOG.rst", 266 | textwrap.dedent("""\ 267 | Changelog 268 | ========= 269 | 270 | 271 | 1.2 (2017-02-20) 272 | ---------------- 273 | - B. [The Committer] 274 | - A. [The Committer] 275 | 276 | """)) 277 | 278 | out, err, errlvl = cmd('$tprog') 279 | self.assertEqual( 280 | err, "", 281 | msg="There should be non error messages. " 282 | "Current stderr:\n%s" % err) 283 | self.assertEqual( 284 | errlvl, 0, 285 | msg="Should succeed") 286 | self.assertNoDiff(textwrap.dedent("""\ 287 | (unreleased) 288 | ------------ 289 | - C. [The Committer] 290 | 291 | 292 | """), out) 293 | 294 | def test_callable_rev_file_first_regex_match_missing_pattern(self): 295 | 296 | gitchangelog.file_put_contents( 297 | ".gitchangelog.rc", 298 | textwrap.dedent(r""" 299 | revs = [ 300 | Caret( 301 | FileFirstRegexMatch( 302 | "CHANGELOG.rst", 303 | r"[0-9]+\.[0-9]+")), 304 | "HEAD" 305 | ] 306 | """)) 307 | gitchangelog.file_put_contents( 308 | "CHANGELOG.rst", 309 | textwrap.dedent("""\ 310 | Changelog 311 | ========= 312 | 313 | 314 | 1.2 (2017-02-20) 315 | ---------------- 316 | - B. [The Committer] 317 | - A. [The Committer] 318 | 319 | """)) 320 | 321 | out, err, errlvl = cmd('$tprog') 322 | self.assertEqual( 323 | err, "", 324 | msg="There should be non error messages. " 325 | "Current stderr:\n%s" % err) 326 | self.assertEqual( 327 | errlvl, 0, 328 | msg="Should succeed") 329 | self.assertNoDiff(textwrap.dedent("""\ 330 | (unreleased) 331 | ------------ 332 | - C. [The Committer] 333 | 334 | 335 | """), out) 336 | 337 | def test_command_line_overrights_config(self): 338 | """Test that all 3 commits are in the changelog""" 339 | 340 | out, err, errlvl = cmd('$tprog HEAD') 341 | 342 | self.assertEqual( 343 | err, "", 344 | msg="There should be non error messages. " 345 | "Current stderr:\n%s" % err) 346 | self.assertEqual( 347 | errlvl, 0, 348 | msg="Should succeed") 349 | self.assertNoDiff( 350 | self.REFERENCE2, out) 351 | -------------------------------------------------------------------------------- /test/test_base.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals 4 | 5 | import os.path 6 | import glob 7 | import textwrap 8 | 9 | from .common import BaseGitReposTest, w, cmd, gitchangelog 10 | from gitchangelog.gitchangelog import indent 11 | 12 | 13 | class GitChangelogTest(BaseGitReposTest): 14 | """Base for all tests needing to start in a new git small repository""" 15 | 16 | REFERENCE = textwrap.dedent("""\ 17 | Changelog 18 | ========= 19 | 20 | 21 | (unreleased) 22 | ------------ 23 | 24 | Changes 25 | ~~~~~~~ 26 | - Modified ``b`` XXX. [Alice, Charly, Juliet] 27 | 28 | 29 | 0.0.3 (2000-01-05) 30 | ------------------ 31 | 32 | New 33 | ~~~ 34 | - Add file ``e``, modified ``b`` [Bob] 35 | 36 | This is a message body. 37 | 38 | With multi-line content: 39 | - one 40 | - two 41 | - Add file ``c`` [Charly] 42 | 43 | 44 | 0.0.2 (2000-01-02) 45 | ------------------ 46 | - Add ``b`` with non-ascii chars éèàâ§µ and HTML chars ``&<`` [Alice] 47 | 48 | 49 | """) 50 | 51 | INCR_REFERENCE_002_003 = textwrap.dedent("""\ 52 | 0.0.3 (2000-01-05) 53 | ------------------ 54 | 55 | New 56 | ~~~ 57 | - Add file ``e``, modified ``b`` [Bob] 58 | 59 | This is a message body. 60 | 61 | With multi-line content: 62 | - one 63 | - two 64 | - Add file ``c`` [Charly] 65 | 66 | 67 | """) 68 | 69 | def setUp(self): 70 | super(GitChangelogTest, self).setUp() 71 | 72 | self.git.commit( 73 | message='new: first commit', 74 | author='Bob ', 75 | date='2000-01-01 10:00:00', 76 | allow_empty=True) 77 | self.git.tag("0.0.1") 78 | self.git.commit( 79 | message=textwrap.dedent(""" 80 | add ``b`` with non-ascii chars éèàâ§µ and HTML chars ``&<`` 81 | 82 | Change-Id: Ic8aaa0728a43936cd4c6e1ed590e01ba8f0fbf5b"""), 83 | author='Alice ', 84 | date='2000-01-02 11:00:00', 85 | allow_empty=True) 86 | self.git.tag("0.0.2") 87 | self.git.commit( 88 | message='new: add file ``c``', 89 | author='Charly ', 90 | date='2000-01-03 12:00:00', 91 | allow_empty=True) 92 | self.git.commit( 93 | message=textwrap.dedent(""" 94 | new: add file ``e``, modified ``b`` 95 | 96 | This is a message body. 97 | 98 | With multi-line content: 99 | - one 100 | - two 101 | 102 | Bug: #42 103 | Change-Id: Ic8aaa0728a43936cd4c6e1ed590e01ba8f0fbf5b 104 | Signed-off-by: A. U. Thor 105 | CC: R. E. Viewer 106 | Subject: This is a fake subject spanning to several lines 107 | as you can see 108 | """), 109 | author='Bob ', 110 | date='2000-01-04 13:00:00', 111 | allow_empty=True) 112 | self.git.commit( 113 | message='chg: modified ``b`` !minor', 114 | author='Bob ', 115 | date='2000-01-05 13:00:00', 116 | allow_empty=True) 117 | self.git.tag("0.0.3") 118 | self.git.commit( 119 | message=textwrap.dedent(""" 120 | chg: modified ``b`` XXX 121 | 122 | Co-Authored-By: Juliet 123 | Co-Authored-By: Charly 124 | """), 125 | author='Alice ', 126 | date='2000-01-06 11:00:00', 127 | allow_empty=True) 128 | 129 | def test_simple_run_no_args_legacy_call(self): 130 | out, err, errlvl = cmd('$tprog') 131 | self.assertEqual( 132 | err, "", 133 | msg="There should be no standard error outputed. " 134 | "Current stderr:\n%r" % err) 135 | self.assertEqual( 136 | errlvl, 0, 137 | msg="Should not fail on simple repo and without config file") 138 | self.assertContains( 139 | out, "0.0.2", 140 | msg="At least one of the tags should be displayed in stdout... " 141 | "Current stdout:\n%s" % out) 142 | self.assertNoDiff(self.REFERENCE, out) 143 | 144 | def test_simple_run_show_call_deprecated(self): 145 | out, err, errlvl = cmd('$tprog show') 146 | self.assertContains( 147 | err, "deprecated", 148 | msg="There should be a warning about deprecated calls. " 149 | "Current stderr:\n%r" % err) 150 | self.assertEqual( 151 | errlvl, 0, 152 | msg="Should not fail on simple repo and without config file") 153 | self.assertContains( 154 | out, "0.0.2", 155 | msg="At least one of the tags should be displayed in stdout... " 156 | "Current stdout:\n%s" % out) 157 | self.assertNoDiff(self.REFERENCE, out) 158 | 159 | def test_incremental_call(self): 160 | out, err, errlvl = cmd('$tprog 0.0.2..0.0.3') 161 | self.assertEqual( 162 | err, "", 163 | msg="There should be no standard error outputed. " 164 | "Current stderr:\n%r" % err) 165 | self.assertEqual( 166 | errlvl, 0, 167 | msg="Should not fail on simple repo and without config file") 168 | self.assertContains( 169 | out, "0.0.3", 170 | msg="The tag 0.0.3 should be displayed in stdout... " 171 | "Current stdout:\n%s" % out) 172 | self.assertNoDiff(self.INCR_REFERENCE_002_003, out) 173 | 174 | def test_incremental_call_multirev(self): 175 | out, err, errlvl = cmd('$tprog "^0.0.2" 0.0.3 0.0.3') 176 | self.assertEqual( 177 | errlvl, 0, 178 | msg="Should not fail on simple repo and without config file") 179 | self.assertEqual( 180 | err, "", 181 | msg="There should be no standard error outputed. " 182 | "Current stderr:\n%r" % err) 183 | self.assertContains( 184 | out, "0.0.3", 185 | msg="The tag 0.0.3 should be displayed in stdout... " 186 | "Current stdout:\n%s" % out) 187 | self.assertNoDiff(self.INCR_REFERENCE_002_003, out) 188 | 189 | def test_incremental_call_one_commit_unreleased(self): 190 | out, err, errlvl = cmd('$tprog "^HEAD^" HEAD') 191 | REFERENCE = textwrap.dedent("""\ 192 | (unreleased) 193 | ------------ 194 | 195 | Changes 196 | ~~~~~~~ 197 | - Modified ``b`` XXX. [Alice, Charly, Juliet] 198 | 199 | 200 | """) 201 | self.assertEqual( 202 | err, "", 203 | msg="There should be no standard error outputed. " 204 | "Current stderr:\n%s" % err) 205 | self.assertEqual( 206 | errlvl, 0, 207 | msg="Should not fail on simple repo and without config file") 208 | self.assertNoDiff( 209 | REFERENCE, out) 210 | 211 | def test_incremental_call_one_commit_released(self): 212 | out, err, errlvl = cmd('$tprog "0.0.3^^^..0.0.3^^"') 213 | REFERENCE = textwrap.dedent("""\ 214 | 0.0.3 (2000-01-05) 215 | ------------------ 216 | 217 | New 218 | ~~~ 219 | - Add file ``c`` [Charly] 220 | 221 | 222 | """) 223 | self.assertEqual( 224 | errlvl, 0, 225 | msg="Should not fail on simple repo and without config file") 226 | self.assertEqual( 227 | err, "", 228 | msg="There should be no standard error outputed. " 229 | "Current stderr:\n%r" % err) 230 | self.assertContains( 231 | out, "0.0.3", 232 | msg="The tag 0.0.3 should be displayed in stdout... " 233 | "Current stdout:\n%s" % out) 234 | self.assertNoDiff(REFERENCE, out) 235 | 236 | def test_incremental_show_call_deprecated(self): 237 | out, err, errlvl = cmd('$tprog show 0.0.2..0.0.3') 238 | self.assertEqual( 239 | errlvl, 0, 240 | msg="Should not fail on simple repo and without config file") 241 | self.assertContains( 242 | err, "deprecated", 243 | msg="There should be a deprecated warning. " 244 | "Current stderr:\n%r" % err) 245 | self.assertContains( 246 | out, "0.0.3", 247 | msg="The tag 0.0.3 should be displayed in stdout... " 248 | "Current stdout:\n%s" % out) 249 | self.assertNoDiff(self.INCR_REFERENCE_002_003, out) 250 | 251 | def test_provided_config_file(self): 252 | """Check provided reference with older name for perfect same result.""" 253 | 254 | config_dir = os.path.join( 255 | os.path.dirname(os.path.realpath(__file__)), 256 | "..", "src", "gitchangelog") 257 | configs = glob.glob(os.path.join(config_dir, 258 | "gitchangelog.rc.reference.v*")) 259 | self.assertNotEqual(len(configs), 0) 260 | for config in configs: 261 | out, err, errlvl = cmd( 262 | '$tprog', env={'GITCHANGELOG_CONFIG_FILENAME': config}) 263 | self.assertEqual(errlvl, 0) 264 | self.assertNoDiff(self.REFERENCE, out) 265 | 266 | def test_same_output_with_different_engine(self): 267 | """Reference implem should match mustache and mako implem""" 268 | 269 | gitchangelog.file_put_contents( 270 | ".gitchangelog.rc", 271 | "output_engine = mustache('restructuredtext')") 272 | changelog = w('$tprog') 273 | self.assertNoDiff( 274 | self.REFERENCE, changelog) 275 | 276 | gitchangelog.file_put_contents( 277 | ".gitchangelog.rc", 278 | "output_engine = makotemplate('restructuredtext')") 279 | changelog = w('$tprog') 280 | self.assertNoDiff(self.REFERENCE, changelog) 281 | 282 | def test_same_output_with_different_engine_incr(self): 283 | """Reference implem should match mustache and mako implem (incr)""" 284 | 285 | gitchangelog.file_put_contents(".gitchangelog.rc", 286 | "output_engine = mustache('restructuredtext')") 287 | changelog = w('$tprog 0.0.2..0.0.3') 288 | self.assertNoDiff(self.INCR_REFERENCE_002_003, changelog) 289 | 290 | gitchangelog.file_put_contents(".gitchangelog.rc", 291 | "output_engine = makotemplate('restructuredtext')") 292 | changelog = w('$tprog 0.0.2..0.0.3') 293 | self.assertNoDiff(self.INCR_REFERENCE_002_003, changelog) 294 | 295 | def test_provided_templates(self): 296 | """Run all provided templates at least once""" 297 | 298 | for label, directory in [("makotemplate", "mako"), 299 | ("mustache", "mustache")]: 300 | template_dir = os.path.join( 301 | os.path.dirname(os.path.realpath(__file__)), 302 | "..", "templates", directory) 303 | templates = glob.glob(os.path.join(template_dir, "*.tpl")) 304 | template_labels = [os.path.basename(f).split(".")[0] 305 | for f in templates] 306 | for tpl in template_labels: 307 | gitchangelog.file_put_contents(".gitchangelog.rc", 308 | "output_engine = %s(%r)" % (label, tpl)) 309 | out, err, errlvl = cmd('$tprog') 310 | self.assertEqual( 311 | errlvl, 0, 312 | msg="Should not fail on %s(%r) " % (label, tpl) + 313 | "Current stderr:\n%s" % indent(err)) 314 | 315 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | gitchangelog 3 | ============ 4 | 5 | .. image:: https://img.shields.io/pypi/v/gitchangelog.svg?style=flat 6 | :target: https://pypi.python.org/pypi/gitchangelog/ 7 | :alt: Latest PyPI version 8 | 9 | .. image:: https://img.shields.io/pypi/dm/gitchangelog.svg?style=flat 10 | :target: https://pypi.python.org/pypi/gitchangelog/ 11 | :alt: Number of PyPI downloads 12 | 13 | .. image:: https://img.shields.io/travis/vaab/gitchangelog/master.svg?style=flat 14 | :target: https://travis-ci.org/vaab/gitchangelog/ 15 | :alt: Travis CI build status 16 | 17 | .. image:: https://img.shields.io/appveyor/ci/vaab/gitchangelog.svg 18 | :target: https://ci.appveyor.com/project/vaab/gitchangelog/branch/master 19 | :alt: Appveyor CI build status 20 | 21 | .. image:: https://img.shields.io/codecov/c/github/vaab/gitchangelog.svg 22 | :target: https://codecov.io/gh/vaab/gitchangelog 23 | :alt: Test coverage 24 | 25 | 26 | Use your commit log to make beautifull and configurable changelog file. 27 | 28 | 29 | Feature 30 | ======= 31 | 32 | - fully driven by a config file that can be tailored with your changelog 33 | policies. (see for example the `reference configuration file`_) 34 | - filter out commits/tags based on regexp matching 35 | - refactor commit summary, or commit body on the fly with replace regexp 36 | - classify commit message into sections (ie: New, Fix, Changes...) 37 | - any output format supported thanks to templating, you can even choose 38 | your own preferred template engine (mako, mustache, full python ...). 39 | - support your merge or rebase workflows and complicated git histories 40 | - support full or incremental changelog generation to match your needs. 41 | - support easy access to `trailers key values`_ (if you use them) 42 | - support of multi-authors for one commit through ``Co-Authored-By`` `trailers key values`_ 43 | - support standard python installation or dep-free single executable. 44 | (this last feature is not yet completely pain free to use on Windows) 45 | 46 | .. _trailers key values: https://git.wiki.kernel.org/index.php/CommitMessageConventions 47 | 48 | 49 | Requirements 50 | ============ 51 | 52 | ``gitchangelog`` is compatible Python 2 and Python 3 on 53 | Linux/BSD/MacOSX and Windows. 54 | 55 | Please submit an issue if you encounter incompatibilities. 56 | 57 | 58 | Installation 59 | ============ 60 | 61 | 62 | full package 63 | ------------ 64 | 65 | Gitchangelog is published on PyPI, thus: 66 | 67 | pip install gitchangelog 68 | 69 | \.. is the way to go for install the full package on any platform. 70 | 71 | If you are installing from source, please note that the development tools 72 | are not working fully yet on Windows. 73 | 74 | The full package provides the ``gitchangelog.py`` executable as long as: 75 | 76 | - a `reference configuration file`_ that provides system wide defaults for 77 | all values. 78 | - some example templates in ``mustache`` and ``mako`` templating 79 | engine's language. Ideal to bootstrap your variations. 80 | 81 | 82 | from source 83 | ----------- 84 | 85 | If you'd rather work from the source repository, it supports the common 86 | idiom to install it on your system:: 87 | 88 | python setup.py install 89 | 90 | Note that for linux/BSD, there's a link to the executable in the root of the 91 | source. This can be a convenient way to work on the source version. 92 | 93 | 94 | single executable installation 95 | ------------------------------ 96 | 97 | The file ``gitchangelog.py`` is a full blown executable and can be used 98 | without any other files. This is easier to use naturally on Linux/BSD 99 | systems. For instance, you could type in:: 100 | 101 | curl -sSL https://raw.githubusercontent.com/vaab/gitchangelog/master/src/gitchangelog/gitchangelog.py > /usr/local/bin/gitchangelog && 102 | chmod +x /usr/local/bin/gitchangelog 103 | 104 | It'll install ``gitchangelog`` to be accessible for all users and will 105 | use the default python interpreter of your running session. 106 | 107 | Please note: if you choose to install it in this standalone mode, then 108 | you must make sure to value at least all the required configuration 109 | keys in your config file. As a good start you should probably copy the 110 | `reference configuration file`_ as you base configuration file. 111 | 112 | This is due to the fact that ``gitchangelog`` can not anymore reach 113 | the reference configuration file to get default values. 114 | 115 | 116 | Sample 117 | ====== 118 | 119 | The default output is ReSTructured text, so it should be readable in ASCII. 120 | 121 | Here is a small sample of the ``gitchangelog`` changelog at work. 122 | 123 | Current ``git log`` output so you can get an idea of the log history:: 124 | 125 | * 59f902a Valentin Lab new: dev: sections in changelog are now in the order given in ``gitchangelog.rc`` in the ``section_regexps`` option. (0.1.2) 126 | * c6f72cc Valentin Lab chg: dev: commented code to toggle doctest mode. 127 | * a9c38f3 Valentin Lab fix: dev: doctests were failing on this. 128 | * 59524e6 Valentin Lab new: usr: added ``body_split_regexp`` option to attempts to format correctly body of commit. 129 | * 5883f07 Valentin Lab new: usr: use a list of tuple instead of a dict for ``section_regexps`` to be able to manage order between section on find match. 130 | * 7c1d480 Valentin Lab new: dev: new ``unreleased_version_label`` option in ``gitchangelog.rc`` to change label of not yet released code. 131 | * cf29c9c Valentin Lab fix: dev: bad sorting of tags (alphanumerical). Changed to commit date sort. 132 | * 61d8f80 Valentin Lab fix: dev: support of empty commit message. 133 | * eeca31b Valentin Lab new: dev: use ``gitchangelog`` section in ``git config`` world appropriately. 134 | * 6142b71 Valentin Lab chg: dev: cosmetic removal of trailing whitespaces 135 | * 3c3edd5 Valentin Lab fix: usr: ``git`` in later versions seems to fail on ``git config `` with errlvl 255, that was not supported. 136 | * 3f9617d Valentin Lab fix: usr: removed Traceback when there were no tags at all in the current git repository. 137 | * e0db9ae Valentin Lab new: usr: added section classifiers (ie: New, Change, Bugs) and updated the sample rc file. (0.1.1) 138 | * 0c66d59 Valentin Lab fix: dev: Fixed case where exception was thrown if two tags are on the same commit. 139 | * d2fae0d Valentin Lab new: usr: added a succint ``--help`` support. 140 | 141 | And here is the ``gitchangelog`` output:: 142 | 143 | 0.1.2 (2011-05-17) 144 | ------------------ 145 | 146 | New 147 | ~~~ 148 | - Sections in changelog are now in the order given in ``git- 149 | changelog.rc`` in the ``section_regexps`` option. [Valentin Lab] 150 | - Added ``body_split_regexp`` option to attempts to format correctly 151 | body of commit. [Valentin Lab] 152 | - Use a list of tuple instead of a dict for ``section_regexps`` to be 153 | able to manage order between section on find match. [Valentin Lab] 154 | - New ``unreleased_version_label`` option in ``gitchangelog.rc`` to 155 | change label of not yet released code. [Valentin Lab] 156 | - Use ``gitchangelog`` section in ``git config`` world appropriately. 157 | [Valentin Lab] 158 | 159 | Changes 160 | ~~~~~~~ 161 | - Commented code to toggle doctest mode. [Valentin Lab] 162 | - Cosmetic removal of trailing whitespaces. [Valentin Lab] 163 | 164 | Fix 165 | ~~~ 166 | - Doctests were failing on this. [Valentin Lab] 167 | - Bad sorting of tags (alphanumerical). Changed to commit date sort. 168 | [Valentin Lab] 169 | - Support of empty commit message. [Valentin Lab] 170 | - ``git`` in later versions seems to fail on ``git config `` with 171 | errlvl 255, that was not supported. [Valentin Lab] 172 | - Removed Traceback when there were no tags at all in the current git 173 | repository. [Valentin Lab] 174 | 175 | 176 | 0.1.1 (2011-04-07) 177 | ------------------ 178 | 179 | New 180 | ~~~ 181 | - Added section classifiers (ie: New, Change, Bugs) and updated the 182 | sample rc file. [Valentin Lab] 183 | - Added a succint ``--help`` support. [Valentin Lab] 184 | 185 | Fix 186 | ~~~ 187 | - Fixed case where exception was thrown if two tags are on the same 188 | commit. [Valentin Lab] 189 | 190 | And the rendered full result is directly used to generate the HTML webpage of 191 | the `changelog of the PyPI page`_. 192 | 193 | 194 | Usage 195 | ===== 196 | 197 | The `reference configuration file`_ is delivered within 198 | ``gitchangelog`` package and is used to provides defaults to 199 | settings. If you didn't install the package and used the standalone 200 | file, then chances are that ``gitchangelog`` can't access these 201 | defaults values. This is not a problem as long as you provided all the 202 | required values in your config file. 203 | 204 | The recommended location for ``gitchangelog`` config file is the root 205 | of the current git repository with the name ``.gitchangelog.rc``. 206 | However you could put it elsewhere, and here are the locations checked 207 | (first match will prevail): 208 | 209 | - in the path given thanks to the environment variable 210 | ``GITCHANGELOG_CONFIG_FILENAME`` 211 | - in the path stored in git config's entry ``gitchangelog.rc-path`` (which 212 | could be stored in system location or per repository) 213 | - (RECOMMENDED) in the root of the current git repository with the name 214 | ``.gitchangelog.rc`` 215 | 216 | Then, you'll be able to call ``gitchangelog`` in a GIT repository and it'll 217 | print changelog on its standard output. 218 | 219 | 220 | Configuration file format 221 | ------------------------- 222 | 223 | The `reference configuration file`_ is quite heavily commented and is quite 224 | simple. You should be able to use it as required. 225 | 226 | .. _reference configuration file: https://github.com/vaab/gitchangelog/blob/master/src/gitchangelog/gitchangelog.rc.reference 227 | 228 | The changelog of gitchangelog is generated with himself and with the reference 229 | configuration file. You'll see the output in the `changelog of the PyPI page`_. 230 | 231 | .. _changelog of the PyPI page: http://pypi.python.org/pypi/gitchangelog 232 | 233 | 234 | Output Engines 235 | -------------- 236 | 237 | At the end of the configuration file, you'll notice a variable called 238 | ``output_engine``. By default, it's set to ``rest_py``, which is the 239 | legacy python engine to produce the `ReSTructured Text` output format 240 | that is shown in above samples. If this engine fits your needs, you 241 | won't need to fiddle with this option. 242 | 243 | To render the template, ``gitchangelog`` will generate a data structure that 244 | will then be rendered thanks to the output engine. This should help you get 245 | the exact output that you need. 246 | 247 | As people might have different needs and knowledge, a templating 248 | system using ``mustache`` is available. ``mustache`` templates are 249 | provided to render both `ReSTructured Text` or `markdown` formats. If 250 | you know ``mustache`` templating, then you could easily add or modify 251 | these existing templates. 252 | 253 | A ``mako`` templating engine is also provided. You'll find also a ``mako`` 254 | template producing the same `ReSTructured Text` output than the legacy one. 255 | It's provided for reference and/or further tweak if you would rather use `mako`_ 256 | templates. 257 | 258 | 259 | Mustache 260 | ~~~~~~~~ 261 | 262 | The ``mustache`` output engine uses `mustache templates`_. 263 | 264 | The `mustache`_ templates are powered via `pystache`_ the python 265 | implementation of the `mustache`_ specifications. So `mustache`_ output engine 266 | will only be available if you have `pystache`_ module available in your python 267 | environment. 268 | 269 | There are `mustache templates`_ bundled with the default installation 270 | of gitchangelog. These can be called by providing a simple label to the 271 | ``mustache(..)`` output_engine, for instance (in your ``.gitchangelog.rc``):: 272 | 273 | output_engine = mustache("markdown") 274 | 275 | Or you could provide your own mustache template by specifying an 276 | absolute path (or a relative one, starting from the git toplevel of 277 | your project by default, or if set, the 278 | ``git config gitchangelog.template-path`` 279 | location) to your template file, for instance:: 280 | 281 | output_engine = mustache(".gitchangelog.tpl") 282 | 283 | And feel free to copy the bundled templates to use them as bases for 284 | your own variations. In the source code, these are located in 285 | ``src/gitchangelog/templates/mustache`` directory, once installed they 286 | are in ``templates/mustache`` directory starting from where your 287 | ``gitchangelog.py`` was installed. 288 | 289 | 290 | .. _mustache: http://mustache.github.io 291 | .. _pystache: https://pypi.python.org/pypi/pystache 292 | .. _mustache templates: http://mustache.github.io/mustache.5.html 293 | 294 | 295 | Mako 296 | ~~~~ 297 | 298 | The ``makotemplate`` output engine templates for ``gitchangelog`` are 299 | powered via `mako`_ python templating system. So `mako`_ output engine 300 | will only be available if you have `mako`_ module available in your 301 | python environment. 302 | 303 | There are `mako`_ templates bundled with the default installation 304 | of gitchangelog. These can be called by providing a simple label to the 305 | ``makotemplate(..)`` output_engine, for instance (in your ``.gitchangelog.rc``):: 306 | 307 | output_engine = makotemplate("markdown") 308 | 309 | Or you could provide your own mustache template by specifying an 310 | absolute path (or a relative one, starting from the git toplevel of 311 | your project by default, or if set, the 312 | ``git config gitchangelog.template-path`` 313 | location) to your template file, for instance:: 314 | 315 | output_engine = makotemplate(".gitchangelog.tpl") 316 | 317 | And feel free to copy the bundled templates to use them as bases for 318 | your own variations. In the source code, these are located in 319 | ``src/gitchangelog/templates/mako`` directory, once installed they 320 | are in ``templates/mako`` directory starting from where your 321 | ``gitchangelog.py`` was installed. 322 | 323 | .. _mako: http://www.makotemplates.org 324 | 325 | 326 | Changelog data tree 327 | ~~~~~~~~~~~~~~~~~~~ 328 | 329 | This is a sample of the current data structure sent to output engines:: 330 | 331 | {'title': 'Changelog', 332 | 'versions': [{'label': '%%version%% (unreleased)', 333 | 'date': None, 334 | 'tag': None 335 | 'sections': [{'label': 'Changes', 336 | 'commits': [{'author': 'John doe', 337 | 'body': '', 338 | 'subject': 'Adding some extra values.'}, 339 | {'author': 'John Doe', 340 | 'body': '', 341 | 'subject': 'Some more changes'}]}, 342 | {'label': 'Other', 343 | 'commits': [{'author': 'Jim Foo', 344 | 'body': '', 345 | 'subject': 'classic modification'}, 346 | {'author': 'Jane Done', 347 | 'body': '', 348 | 'subject': 'Adding some stuff to do.'}]}]}, 349 | {'label': 'v0.2.5 (2013-08-06)', 350 | 'date': '2013-08-06', 351 | 'tag': 'v0.2.5' 352 | 'sections': [{'commits': [{'author': 'John Doe', 353 | 'body': '', 354 | 'subject': 'Updating Changelog installation.'}], 355 | 'label': 'Changes'}]}]} 356 | 357 | 358 | Merged branches history support 359 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 360 | 361 | Commit attribution to a specific version could be tricky. Suppose you have 362 | this typical merge tree (spot the tags!):: 363 | 364 | * new: something (HEAD, tag: 0.2, develop) 365 | * Merge tag '0.1.1' into develop 366 | |\ 367 | | * fix: out-of-band hotfix (tag: 0.1.1) 368 | * | chg: continued development 369 | |/ 370 | * fix: something (tag: 0.1) 371 | * first commit (tag: 0.0.1, master) 372 | 373 | Here's a minimal draft of gitchangelog to show how commit are 374 | attributed to versions:: 375 | 376 | 0.2 377 | * new: something. 378 | * Merge tag '0.1.1' into develop. 379 | * chg: continued development. 380 | 381 | 0.1.1 382 | * fix: out-of-band hotfix. 383 | 384 | 0.1 385 | * fix: something. 386 | 387 | 388 | .. note:: you can remove automatically all merge commit from 389 | gitchangelog output by using ``include_merge = False`` in the 390 | ``.gitchangelog.rc`` file. 391 | 392 | 393 | Use cases 394 | ========= 395 | 396 | 397 | No sectionning 398 | -------------- 399 | 400 | If you want to remove sectionning but keep anything else, you should 401 | probably use:: 402 | 403 | section_regexps = [ 404 | ('', None) 405 | ] 406 | 407 | subject_process = (strip | ucfirst | final_dot) 408 | 409 | This will disable sectionning and won't remove the prefixes 410 | used for sectionning from the commit's summary. 411 | 412 | 413 | Incremental changelog 414 | --------------------- 415 | 416 | Also known as partial changelog generation, this feature allows to 417 | generate only a subpart of your changelog, and combined with 418 | configurable publishing actions, you can insert the result inside 419 | an existing changelog. Usually this makes sense: 420 | 421 | - When wanting to switch to ``gitchangelog``, or change your 422 | conventions: 423 | 424 | - part of your history is not following conventions. 425 | - you have a previous CHANGELOG you want to blend in. 426 | 427 | - You'd rather commit changes to your changelog file for each release: 428 | 429 | - For performance reason, you can then generate changelog only for 430 | the new commit and save the result. 431 | - Because you want to be able to edit it to make some minor 432 | edition if needed. 433 | 434 | 435 | Generating partial changelog is as simple as ``gitchangelog 436 | REVLIST``. Examples follows:: 437 | 438 | ## will output only tags between 0.0.2 (excluded) and 0.0.3 (included) 439 | gitchangelog 0.0.2..0.0.3 440 | 441 | ## will output only tags since 0.0.3 (excluded) 442 | gitchangelog ^0.0.3 HEAD 443 | 444 | ## will output all tags up to 0.0.3 (included) 445 | gitchangelog 0.0.3 446 | 447 | 448 | Additionally, ``gitchangelog`` can figure out automatically which 449 | revision is the last for you (with some little help). This is done by 450 | specifying the ``revs`` config option. This config file option will be 451 | used as if specified on the command line. 452 | 453 | Here is an example that fits the current changelog format:: 454 | 455 | revs = [ 456 | Caret( 457 | FileFirstRegexMatch( 458 | "CHANGELOG.rst", 459 | r"(?P[0-9]+\.[0-9]+(\.[0-9]+))\s+\([0-9]+-[0-9]{2}-[0-9]{2}\)\n--+\n")), 460 | ] 461 | 462 | This will look into the file ``CHANGELOG.rst`` for the first match of 463 | the given regex and return the match of the ``rev`` regex sub-pattern 464 | it as a string. The ``Caret`` function will simply prefix the given 465 | string with a ``^``. As a consequence, this code will prevent 466 | recreating any previously generated changelog section (more information 467 | about the `REVLIST syntax`_ from ``git rev-list`` arguments.) 468 | 469 | .. _REVLIST syntax: https://git-scm.com/docs/git-rev-list#_description 470 | 471 | Note that the data structure provided to the template will set the 472 | ``title`` to ``None`` if you provided no REVLIST through command-line 473 | or the config file (or if the revlist was equivalently set to 474 | ``["HEAD", ]``). This a good way to make your template detect it is 475 | in "incremental mode". 476 | 477 | By default, this will only output to standard output the new sections 478 | of your changelog, you might want to insert it directly in your existing 479 | changelog. This is where ``publish`` parameters will help you. By default 480 | it is set to ``stdout``, and you might want to set it to:: 481 | 482 | publish = FileInsertIntoFirstRegexMatch( 483 | "CHANGELOG.rst", 484 | r'/(?P[0-9]+\.[0-9]+(\.[0-9]+)?)\s+\([0-9]+-[0-9]{2}-[0-9]{2}\)\n--+\n/', 485 | idx=lambda m: m.start(1) 486 | ) 487 | 488 | The full recipe could be:: 489 | 490 | OUTPUT_FILE = "CHANGELOG.rst" 491 | INSERT_POINT = r"\b(?P[0-9]+\.[0-9]+)\s+\([0-9]+-[0-9]{2}-[0-9]{2}\)\n--+\n" 492 | revs = [ 493 | Caret(FileFirstRegexMatch(OUTPUT_FILE, INSERT_POINT)), 494 | "HEAD" 495 | ] 496 | 497 | action = FileInsertAtFirstRegexMatch( 498 | OUTPUT_FILE, INSERT_POINT, 499 | idx=lambda m: m.start(1) 500 | ) 501 | 502 | 503 | Alternatively, you can use this other recipe, using ``FileRegexSubst``, that has 504 | the added advantage of being able to update the unreleased part if you had it already 505 | generated and need a re-fresh because you added new commits or amended some commits:: 506 | 507 | OUTPUT_FILE = "CHANGELOG.rst" 508 | INSERT_POINT_REGEX = r'''(?isxu) 509 | ^ 510 | ( 511 | \s*Changelog\s*(\n|\r\n|\r) ## ``Changelog`` line 512 | ==+\s*(\n|\r\n|\r){2} ## ``=========`` rest underline 513 | ) 514 | 515 | ( ## Match all between changelog and release rev 516 | ( 517 | (?! 518 | (?<=(\n|\r)) ## look back for newline 519 | %(rev)s ## revision 520 | \s+ 521 | \([0-9]+-[0-9]{2}-[0-9]{2}\)(\n|\r\n|\r) ## date 522 | --+(\n|\r\n|\r) ## ``---`` underline 523 | ) 524 | . 525 | )* 526 | ) 527 | 528 | (?P%(rev)s) 529 | ''' % {'rev': r"[0-9]+\.[0-9]+(\.[0-9]+)?"} 530 | 531 | revs = [ 532 | Caret(FileFirstRegexMatch(OUTPUT_FILE, INSERT_POINT_REGEX)), 533 | "HEAD" 534 | ] 535 | 536 | publish = FileRegexSubst(OUTPUT_FILE, INSERT_POINT_REGEX, r"\1\o\g") 537 | 538 | 539 | As a second example, here is the same recipe for mustache markdown format:: 540 | 541 | OUTPUT_FILE = "CHANGELOG.rst" 542 | INSERT_POINT_REGEX = r'''(?isxu) 543 | ^ 544 | ( 545 | \s*\#\s+Changelog\s*(\n|\r\n|\r) ## ``Changelog`` line 546 | ) 547 | 548 | ( ## Match all between changelog and release rev 549 | ( 550 | (?! 551 | (?<=(\n|\r)) ## look back for newline 552 | \#\#\s+%(rev)s ## revision 553 | \s+ 554 | \([0-9]+-[0-9]{2}-[0-9]{2}\)(\n|\r\n|\r) ## date 555 | ) 556 | . 557 | )* 558 | ) 559 | 560 | (?P\#\#\s+(?P%(rev)s)) 561 | ''' % {'rev': r"[0-9]+\.[0-9]+(\.[0-9]+)?"} 562 | 563 | revs = [ 564 | Caret(FileFirstRegexMatch(OUTPUT_FILE, INSERT_POINT_REGEX)), 565 | "HEAD" 566 | ] 567 | 568 | publish = FileRegexSubst(OUTPUT_FILE, INSERT_POINT_REGEX, r"\1\o\n\g") 569 | 570 | 571 | Contributing 572 | ============ 573 | 574 | Any suggestion or issue is welcome. Push request are very welcome, 575 | please check out the guidelines. 576 | 577 | 578 | Push Request Guidelines 579 | ----------------------- 580 | 581 | You can send any code. I'll look at it and will integrate it myself in 582 | the code base while leaving you as the commit(s) author. This process 583 | can take time and it'll take less time if you follow the following 584 | guidelines: 585 | 586 | - check your code with PEP8 or pylint. Try to stick to 80 columns wide. 587 | - separate your commits per smallest concern 588 | - each functionality/bugfix commit should contain the code, tests, 589 | and doc. 590 | - each commit should pass the tests (to allow easy bisect) 591 | - prior minor commit with typographic or code cosmetic changes are 592 | very welcome. These should be tagged in their commit summary with 593 | ``!minor``. 594 | - the commit message should follow gitchangelog rules (check the git 595 | log to get examples) 596 | - if the commit fixes an issue or finished the implementation of a 597 | feature, please mention it in the summary. 598 | 599 | If you have some questions about guidelines which is not answered here, 600 | please check the current ``git log``, you might find previous commit that 601 | would show you how to deal with your issue. Otherwise, just send your PR 602 | and ask your question. I won't bite. Promise. 603 | 604 | 605 | License 606 | ======= 607 | 608 | Copyright (c) 2012-2018 Valentin Lab. 609 | 610 | Licensed under the `BSD License`_. 611 | 612 | .. _BSD License: http://raw.github.com/vaab/gitchangelog/master/LICENSE 613 | -------------------------------------------------------------------------------- /src/gitchangelog/gitchangelog.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from __future__ import print_function 5 | from __future__ import absolute_import 6 | 7 | import locale 8 | import re 9 | import os 10 | import os.path 11 | import sys 12 | import glob 13 | import textwrap 14 | import datetime 15 | import collections 16 | import traceback 17 | import contextlib 18 | import itertools 19 | import errno 20 | 21 | from subprocess import Popen, PIPE 22 | 23 | try: 24 | import pystache 25 | except ImportError: ## pragma: no cover 26 | pystache = None 27 | 28 | try: 29 | import mako 30 | except ImportError: ## pragma: no cover 31 | mako = None 32 | 33 | 34 | __version__ = "%%version%%" ## replaced by autogen.sh 35 | 36 | DEBUG = None 37 | 38 | 39 | ## 40 | ## Platform and python compatibility 41 | ## 42 | 43 | PY_VERSION = float("%d.%d" % sys.version_info[0:2]) 44 | PY3 = PY_VERSION >= 3 45 | 46 | try: 47 | basestring 48 | except NameError: 49 | basestring = str ## pylint: disable=redefined-builtin 50 | 51 | WIN32 = sys.platform == 'win32' 52 | if WIN32: 53 | PLT_CFG = { 54 | 'close_fds': False, 55 | } 56 | else: 57 | PLT_CFG = { 58 | 'close_fds': True, 59 | } 60 | 61 | ## 62 | ## 63 | ## 64 | 65 | if WIN32 and not PY3: 66 | 67 | ## Sorry about the following, all this code is to ensure full 68 | ## compatibility with python 2.7 under windows about sending unicode 69 | ## command-line 70 | 71 | import ctypes 72 | import subprocess 73 | import _subprocess 74 | from ctypes import byref, windll, c_char_p, c_wchar_p, c_void_p, \ 75 | Structure, sizeof, c_wchar, WinError 76 | from ctypes.wintypes import BYTE, WORD, LPWSTR, BOOL, DWORD, LPVOID, \ 77 | HANDLE 78 | 79 | 80 | ## 81 | ## Types 82 | ## 83 | 84 | CREATE_UNICODE_ENVIRONMENT = 0x00000400 85 | LPCTSTR = c_char_p 86 | LPTSTR = c_wchar_p 87 | LPSECURITY_ATTRIBUTES = c_void_p 88 | LPBYTE = ctypes.POINTER(BYTE) 89 | 90 | class STARTUPINFOW(Structure): 91 | _fields_ = [ 92 | ("cb", DWORD), ("lpReserved", LPWSTR), 93 | ("lpDesktop", LPWSTR), ("lpTitle", LPWSTR), 94 | ("dwX", DWORD), ("dwY", DWORD), 95 | ("dwXSize", DWORD), ("dwYSize", DWORD), 96 | ("dwXCountChars", DWORD), ("dwYCountChars", DWORD), 97 | ("dwFillAtrribute", DWORD), ("dwFlags", DWORD), 98 | ("wShowWindow", WORD), ("cbReserved2", WORD), 99 | ("lpReserved2", LPBYTE), ("hStdInput", HANDLE), 100 | ("hStdOutput", HANDLE), ("hStdError", HANDLE), 101 | ] 102 | 103 | LPSTARTUPINFOW = ctypes.POINTER(STARTUPINFOW) 104 | 105 | 106 | class PROCESS_INFORMATION(Structure): 107 | _fields_ = [ 108 | ("hProcess", HANDLE), ("hThread", HANDLE), 109 | ("dwProcessId", DWORD), ("dwThreadId", DWORD), 110 | ] 111 | 112 | LPPROCESS_INFORMATION = ctypes.POINTER(PROCESS_INFORMATION) 113 | 114 | 115 | class DUMMY_HANDLE(ctypes.c_void_p): 116 | 117 | def __init__(self, *a, **kw): 118 | super(DUMMY_HANDLE, self).__init__(*a, **kw) 119 | self.closed = False 120 | 121 | def Close(self): 122 | if not self.closed: 123 | windll.kernel32.CloseHandle(self) 124 | self.closed = True 125 | 126 | def __int__(self): 127 | return self.value 128 | 129 | 130 | CreateProcessW = windll.kernel32.CreateProcessW 131 | CreateProcessW.argtypes = [ 132 | LPCTSTR, LPTSTR, LPSECURITY_ATTRIBUTES, 133 | LPSECURITY_ATTRIBUTES, BOOL, DWORD, LPVOID, LPCTSTR, 134 | LPSTARTUPINFOW, LPPROCESS_INFORMATION, 135 | ] 136 | CreateProcessW.restype = BOOL 137 | 138 | 139 | ## 140 | ## Patched functions/classes 141 | ## 142 | 143 | def CreateProcess(executable, args, _p_attr, _t_attr, 144 | inherit_handles, creation_flags, env, cwd, 145 | startup_info): 146 | """Create a process supporting unicode executable and args for win32 147 | 148 | Python implementation of CreateProcess using CreateProcessW for Win32 149 | 150 | """ 151 | 152 | si = STARTUPINFOW( 153 | dwFlags=startup_info.dwFlags, 154 | wShowWindow=startup_info.wShowWindow, 155 | cb=sizeof(STARTUPINFOW), 156 | ## XXXvlab: not sure of the casting here to ints. 157 | hStdInput=int(startup_info.hStdInput), 158 | hStdOutput=int(startup_info.hStdOutput), 159 | hStdError=int(startup_info.hStdError), 160 | ) 161 | 162 | wenv = None 163 | if env is not None: 164 | ## LPCWSTR seems to be c_wchar_p, so let's say CWSTR is c_wchar 165 | env = (unicode("").join([ 166 | unicode("%s=%s\0") % (k, v) 167 | for k, v in env.items()])) + unicode("\0") 168 | wenv = (c_wchar * len(env))() 169 | wenv.value = env 170 | 171 | pi = PROCESS_INFORMATION() 172 | creation_flags |= CREATE_UNICODE_ENVIRONMENT 173 | 174 | if CreateProcessW(executable, args, None, None, 175 | inherit_handles, creation_flags, 176 | wenv, cwd, byref(si), byref(pi)): 177 | return (DUMMY_HANDLE(pi.hProcess), DUMMY_HANDLE(pi.hThread), 178 | pi.dwProcessId, pi.dwThreadId) 179 | raise WinError() 180 | 181 | 182 | class Popen(subprocess.Popen): 183 | """This superseeds Popen and corrects a bug in cPython 2.7 implem""" 184 | 185 | def _execute_child(self, args, executable, preexec_fn, close_fds, 186 | cwd, env, universal_newlines, 187 | startupinfo, creationflags, shell, to_close, 188 | p2cread, p2cwrite, 189 | c2pread, c2pwrite, 190 | errread, errwrite): 191 | """Code from part of _execute_child from Python 2.7 (9fbb65e) 192 | 193 | There are only 2 little changes concerning the construction of 194 | the the final string in shell mode: we preempt the creation of 195 | the command string when shell is True, because original function 196 | will try to encode unicode args which we want to avoid to be able to 197 | sending it as-is to ``CreateProcess``. 198 | 199 | """ 200 | if not isinstance(args, subprocess.types.StringTypes): 201 | args = subprocess.list2cmdline(args) 202 | 203 | if startupinfo is None: 204 | startupinfo = subprocess.STARTUPINFO() 205 | if shell: 206 | startupinfo.dwFlags |= _subprocess.STARTF_USESHOWWINDOW 207 | startupinfo.wShowWindow = _subprocess.SW_HIDE 208 | comspec = os.environ.get("COMSPEC", unicode("cmd.exe")) 209 | args = unicode('{} /c "{}"').format(comspec, args) 210 | if (_subprocess.GetVersion() >= 0x80000000 or 211 | os.path.basename(comspec).lower() == "command.com"): 212 | w9xpopen = self._find_w9xpopen() 213 | args = unicode('"%s" %s') % (w9xpopen, args) 214 | creationflags |= _subprocess.CREATE_NEW_CONSOLE 215 | 216 | super(Popen, self)._execute_child(args, executable, 217 | preexec_fn, close_fds, cwd, env, universal_newlines, 218 | startupinfo, creationflags, False, to_close, p2cread, 219 | p2cwrite, c2pread, c2pwrite, errread, errwrite) 220 | 221 | _subprocess.CreateProcess = CreateProcess 222 | 223 | 224 | ## 225 | ## Help and usage strings 226 | ## 227 | 228 | usage_msg = """ 229 | %(exname)s {-h|--help} 230 | %(exname)s {-v|--version} 231 | %(exname)s [--debug|-d] [REVLIST]""" 232 | 233 | description_msg = """\ 234 | Run this command in a git repository to output a formatted changelog 235 | """ 236 | 237 | epilog_msg = """\ 238 | %(exname)s uses a config file to filter meaningful commit or do some 239 | formatting in commit messages thanks to a config file. 240 | 241 | Config file location will be resolved in this order: 242 | - in shell environment variable GITCHANGELOG_CONFIG_FILENAME 243 | - in git configuration: ``git config gitchangelog.rc-path`` 244 | - as '.%(exname)s.rc' in the root of the current git repository 245 | 246 | """ 247 | 248 | 249 | ## 250 | ## Shell command helper functions 251 | ## 252 | 253 | def stderr(msg): 254 | print(msg, file=sys.stderr) 255 | 256 | 257 | def err(msg): 258 | stderr("Error: " + msg) 259 | 260 | 261 | def warn(msg): 262 | stderr("Warning: " + msg) 263 | 264 | 265 | def die(msg=None, errlvl=1): 266 | if msg: 267 | stderr(msg) 268 | sys.exit(errlvl) 269 | 270 | 271 | class ShellError(Exception): 272 | 273 | def __init__(self, msg, errlvl=None, command=None, out=None, err=None): 274 | self.errlvl = errlvl 275 | self.command = command 276 | self.out = out 277 | self.err = err 278 | super(ShellError, self).__init__(msg) 279 | 280 | 281 | @contextlib.contextmanager 282 | def set_cwd(directory): 283 | curdir = os.getcwd() 284 | os.chdir(directory) 285 | try: 286 | yield 287 | finally: 288 | os.chdir(curdir) 289 | 290 | 291 | def format_last_exception(prefix=" | "): 292 | """Format the last exception for display it in tests. 293 | 294 | This allows to raise custom exception, without loosing the context of what 295 | caused the problem in the first place: 296 | 297 | >>> def f(): 298 | ... raise Exception("Something terrible happened") 299 | >>> try: ## doctest: +ELLIPSIS 300 | ... f() 301 | ... except Exception: 302 | ... formated_exception = format_last_exception() 303 | ... raise ValueError('Oups, an error occured:\\n%s' 304 | ... % formated_exception) 305 | Traceback (most recent call last): 306 | ... 307 | ValueError: Oups, an error occured: 308 | | Traceback (most recent call last): 309 | ... 310 | | Exception: Something terrible happened 311 | 312 | """ 313 | 314 | return '\n'.join( 315 | str(prefix + line) 316 | for line in traceback.format_exc().strip().split('\n')) 317 | 318 | 319 | ## 320 | ## config file functions 321 | ## 322 | 323 | _config_env = { 324 | 'WIN32': WIN32, 325 | 'PY3': PY3, 326 | } 327 | 328 | 329 | def available_in_config(f): 330 | _config_env[f.__name__] = f 331 | return f 332 | 333 | 334 | def load_config_file(filename, default_filename=None, 335 | fail_if_not_present=True): 336 | """Loads data from a config file.""" 337 | 338 | config = _config_env.copy() 339 | for fname in [default_filename, filename]: 340 | if fname and os.path.exists(fname): 341 | if not os.path.isfile(fname): 342 | die("config file path '%s' exists but is not a file !" 343 | % (fname, )) 344 | content = file_get_contents(fname) 345 | try: 346 | code = compile(content, fname, 'exec') 347 | exec(code, config) ## pylint: disable=exec-used 348 | except SyntaxError as e: 349 | die('Syntax error in config file: %s\n%s' 350 | 'File %s, line %i' 351 | % (str(e), 352 | (indent(e.text.rstrip(), " | ") + "\n") if e.text else "", 353 | e.filename, e.lineno)) 354 | else: 355 | if fail_if_not_present: 356 | die('%s config file is not found and is required.' % (fname, )) 357 | 358 | return config 359 | 360 | 361 | ## 362 | ## Text functions 363 | ## 364 | 365 | @available_in_config 366 | class TextProc(object): 367 | 368 | def __init__(self, fun): 369 | self.fun = fun 370 | if hasattr(fun, "__name__"): 371 | self.__name__ = fun.__name__ 372 | 373 | def __call__(self, text): 374 | return self.fun(text) 375 | 376 | def __or__(self, value): 377 | if isinstance(value, TextProc): 378 | return TextProc(lambda text: value.fun(self.fun(text))) 379 | import inspect 380 | (_frame, filename, lineno, _function_name, lines, _index) = \ 381 | inspect.stack()[1] 382 | raise SyntaxError("Invalid syntax in config file", 383 | (filename, lineno, 0, 384 | "Invalid chain with a non TextProc element %r:\n%s" 385 | % (value, indent("".join(lines).strip(), " | ")))) 386 | 387 | 388 | def set_if_empty(text, msg="No commit message."): 389 | if len(text): 390 | return text 391 | return msg 392 | 393 | 394 | @TextProc 395 | def ucfirst(msg): 396 | if len(msg) == 0: 397 | return msg 398 | return msg[0].upper() + msg[1:] 399 | 400 | 401 | @TextProc 402 | def final_dot(msg): 403 | if len(msg) and msg[-1].isalnum(): 404 | return msg + "." 405 | return msg 406 | 407 | 408 | def indent(text, chars=" ", first=None): 409 | """Return text string indented with the given chars 410 | 411 | >>> string = 'This is first line.\\nThis is second line\\n' 412 | 413 | >>> print(indent(string, chars="| ")) # doctest: +NORMALIZE_WHITESPACE 414 | | This is first line. 415 | | This is second line 416 | | 417 | 418 | >>> print(indent(string, first="- ")) # doctest: +NORMALIZE_WHITESPACE 419 | - This is first line. 420 | This is second line 421 | 422 | 423 | >>> string = 'This is first line.\\n\\nThis is second line' 424 | >>> print(indent(string, first="- ")) # doctest: +NORMALIZE_WHITESPACE 425 | - This is first line. 426 | 427 | This is second line 428 | 429 | """ 430 | if first: 431 | first_line = text.split("\n")[0] 432 | rest = '\n'.join(text.split("\n")[1:]) 433 | return '\n'.join([(first + first_line).rstrip(), 434 | indent(rest, chars=chars)]) 435 | return '\n'.join([(chars + line).rstrip() 436 | for line in text.split('\n')]) 437 | 438 | 439 | def paragraph_wrap(text, regexp="\n\n"): 440 | r"""Wrap text by making sure that paragraph are separated correctly 441 | 442 | >>> string = 'This is first paragraph which is quite long don\'t you \ 443 | ... think ? Well, I think so.\n\nThis is second paragraph\n' 444 | 445 | >>> print(paragraph_wrap(string)) # doctest: +NORMALIZE_WHITESPACE 446 | This is first paragraph which is quite long don't you think ? Well, I 447 | think so. 448 | This is second paragraph 449 | 450 | Notice that that each paragraph has been wrapped separately. 451 | 452 | """ 453 | regexp = re.compile(regexp, re.MULTILINE) 454 | return "\n".join("\n".join(textwrap.wrap(paragraph.strip())) 455 | for paragraph in regexp.split(text)).strip() 456 | 457 | 458 | def curryfy(f): 459 | return lambda *a, **kw: TextProc(lambda txt: f(txt, *a, **kw)) 460 | 461 | ## these are curryfied version of their lower case definition 462 | 463 | Indent = curryfy(indent) 464 | Wrap = curryfy(paragraph_wrap) 465 | ReSub = lambda p, r, **k: TextProc(lambda txt: re.sub(p, r, txt, **k)) 466 | noop = TextProc(lambda txt: txt) 467 | strip = TextProc(lambda txt: txt.strip()) 468 | SetIfEmpty = curryfy(set_if_empty) 469 | 470 | for _label in ("Indent", "Wrap", "ReSub", "noop", "final_dot", 471 | "ucfirst", "strip", "SetIfEmpty"): 472 | _config_env[_label] = locals()[_label] 473 | 474 | ## 475 | ## File 476 | ## 477 | 478 | def file_get_contents(filename): 479 | with open(filename) as f: 480 | out = f.read() 481 | if not PY3: 482 | if not isinstance(out, unicode): 483 | out = out.decode(_preferred_encoding) 484 | ## remove encoding declaration (for some reason, python 2.7 485 | ## don't like it). 486 | out = re.sub(r"^(\s*#.*\s*)coding[:=]\s*([-\w.]+\s*;?\s*)", 487 | r"\1", out, re.DOTALL) 488 | 489 | return out 490 | 491 | 492 | def file_put_contents(filename, string): 493 | """Write string to filename.""" 494 | if PY3: 495 | fopen = open(filename, 'w', newline='') 496 | else: 497 | fopen = open(filename, 'wb') 498 | 499 | with fopen as f: 500 | f.write(string) 501 | 502 | 503 | ## 504 | ## Inferring revision 505 | ## 506 | 507 | def _file_regex_match(filename, pattern, **kw): 508 | if not os.path.isfile(filename): 509 | raise IOError("Can't open file '%s'." % filename) 510 | file_content = file_get_contents(filename) 511 | match = re.search(pattern, file_content, **kw) 512 | if match is None: 513 | stderr("file content: %r" % file_content) 514 | if isinstance(pattern, type(re.compile(''))): 515 | pattern = pattern.pattern 516 | raise ValueError( 517 | "Regex %s did not match any substring in '%s'." 518 | % (pattern, filename)) 519 | return match 520 | 521 | 522 | @available_in_config 523 | def FileFirstRegexMatch(filename, pattern): 524 | def _call(): 525 | match = _file_regex_match(filename, pattern) 526 | dct = match.groupdict() 527 | if dct: 528 | if "rev" not in dct: 529 | warn("Named pattern used, but no one are named 'rev'. " 530 | "Using full match.") 531 | return match.group(0) 532 | if dct['rev'] is None: 533 | die("Named pattern used, but it was not valued.") 534 | return dct['rev'] 535 | return match.group(0) 536 | return _call 537 | 538 | 539 | @available_in_config 540 | def Caret(l): 541 | def _call(): 542 | return "^%s" % eval_if_callable(l) 543 | return _call 544 | ## 545 | ## System functions 546 | ## 547 | 548 | ## Note that locale.getpreferredencoding() does NOT follow 549 | ## PYTHONIOENCODING by default, but ``sys.stdout.encoding`` does. In 550 | ## PY2, ``sys.stdout.encoding`` without PYTHONIOENCODING set does not 551 | ## get any values set in subshells. However, if _preferred_encoding 552 | ## is not set to utf-8, it leads to encoding errors. 553 | _preferred_encoding = os.environ.get("PYTHONIOENCODING") or \ 554 | locale.getpreferredencoding() 555 | DEFAULT_GIT_LOG_ENCODING = 'utf-8' 556 | 557 | 558 | class Phile(object): 559 | """File like API to read fields separated by any delimiters 560 | 561 | It'll take care of file decoding to unicode. 562 | 563 | This is an adaptor on a file object. 564 | 565 | >>> if PY3: 566 | ... from io import BytesIO 567 | ... def File(s): 568 | ... _obj = BytesIO() 569 | ... _obj.write(s.encode(_preferred_encoding)) 570 | ... _obj.seek(0) 571 | ... return _obj 572 | ... else: 573 | ... from cStringIO import StringIO as File 574 | 575 | >>> f = Phile(File("a-b-c-d")) 576 | 577 | Read provides an iterator: 578 | 579 | >>> def show(l): 580 | ... print(", ".join(l)) 581 | >>> show(f.read(delimiter="-")) 582 | a, b, c, d 583 | 584 | You can change the buffersize loaded into memory before outputing 585 | your changes. It should not change the iterator output: 586 | 587 | >>> f = Phile(File("é-à-ü-d"), buffersize=3) 588 | >>> len(list(f.read(delimiter="-"))) 589 | 4 590 | 591 | >>> f = Phile(File("foo-bang-yummy"), buffersize=3) 592 | >>> show(f.read(delimiter="-")) 593 | foo, bang, yummy 594 | 595 | >>> f = Phile(File("foo-bang-yummy"), buffersize=1) 596 | >>> show(f.read(delimiter="-")) 597 | foo, bang, yummy 598 | 599 | """ 600 | 601 | def __init__(self, filename, buffersize=4096, encoding=_preferred_encoding): 602 | self._file = filename 603 | self._buffersize = buffersize 604 | self._encoding = encoding 605 | 606 | def read(self, delimiter="\n"): 607 | buf = "" 608 | if PY3: 609 | delimiter = delimiter.encode(_preferred_encoding) 610 | buf = buf.encode(_preferred_encoding) 611 | while True: 612 | chunk = self._file.read(self._buffersize) 613 | if not chunk: 614 | yield buf.decode(self._encoding) 615 | return 616 | records = chunk.split(delimiter) 617 | records[0] = buf + records[0] 618 | for record in records[:-1]: 619 | yield record.decode(self._encoding) 620 | buf = records[-1] 621 | 622 | def write(self, buf): 623 | if PY3: 624 | buf = buf.encode(self._encoding) 625 | return self._file.write(buf) 626 | 627 | def close(self): 628 | return self._file.close() 629 | 630 | 631 | class Proc(Popen): 632 | 633 | def __init__(self, command, env=None, encoding=_preferred_encoding): 634 | super(Proc, self).__init__( 635 | command, shell=True, 636 | stdin=PIPE, stdout=PIPE, stderr=PIPE, 637 | close_fds=PLT_CFG['close_fds'], env=env, 638 | universal_newlines=False) 639 | 640 | self.stdin = Phile(self.stdin, encoding=encoding) 641 | self.stdout = Phile(self.stdout, encoding=encoding) 642 | self.stderr = Phile(self.stderr, encoding=encoding) 643 | 644 | 645 | def cmd(command, env=None, shell=True): 646 | 647 | p = Popen(command, shell=shell, 648 | stdin=PIPE, stdout=PIPE, stderr=PIPE, 649 | close_fds=PLT_CFG['close_fds'], env=env, 650 | universal_newlines=False) 651 | out, err = p.communicate() 652 | return ( 653 | out.decode(getattr(sys.stdout, "encoding", None) or 654 | _preferred_encoding), 655 | err.decode(getattr(sys.stderr, "encoding", None) or 656 | _preferred_encoding), 657 | p.returncode) 658 | 659 | 660 | @available_in_config 661 | def wrap(command, ignore_errlvls=[0], env=None, shell=True): 662 | """Wraps a shell command and casts an exception on unexpected errlvl 663 | 664 | >>> wrap('/tmp/lsdjflkjf') # doctest: +ELLIPSIS +IGNORE_EXCEPTION_DETAIL 665 | Traceback (most recent call last): 666 | ... 667 | ShellError: Wrapped command '/tmp/lsdjflkjf' exited with errorlevel 127. 668 | stderr: 669 | | /bin/sh: .../tmp/lsdjflkjf: not found 670 | 671 | >>> print(wrap('echo hello'), end='') 672 | hello 673 | 674 | >>> print(wrap('echo hello && false'), 675 | ... end='') # doctest: +ELLIPSIS +IGNORE_EXCEPTION_DETAIL 676 | Traceback (most recent call last): 677 | ... 678 | ShellError: Wrapped command 'echo hello && false' exited with errorlevel 1. 679 | stdout: 680 | | hello 681 | 682 | """ 683 | 684 | out, err, errlvl = cmd(command, env=env, shell=shell) 685 | 686 | if errlvl not in ignore_errlvls: 687 | 688 | formatted = [] 689 | if out: 690 | if out.endswith('\n'): 691 | out = out[:-1] 692 | formatted.append("stdout:\n%s" % indent(out, "| ")) 693 | if err: 694 | if err.endswith('\n'): 695 | err = err[:-1] 696 | formatted.append("stderr:\n%s" % indent(err, "| ")) 697 | msg = '\n'.join(formatted) 698 | 699 | raise ShellError("Wrapped command %r exited with errorlevel %d.\n%s" 700 | % (command, errlvl, indent(msg, chars=" ")), 701 | errlvl=errlvl, command=command, out=out, err=err) 702 | return out 703 | 704 | 705 | @available_in_config 706 | def swrap(command, **kwargs): 707 | """Same as ``wrap(...)`` but strips the output.""" 708 | 709 | return wrap(command, **kwargs).strip() 710 | 711 | 712 | ## 713 | ## git information access 714 | ## 715 | 716 | class SubGitObjectMixin(object): 717 | 718 | def __init__(self, repos): 719 | self._repos = repos 720 | 721 | @property 722 | def git(self): 723 | """Simple delegation to ``repos`` original method.""" 724 | return self._repos.git 725 | 726 | 727 | GIT_FORMAT_KEYS = { 728 | 'sha1': "%H", 729 | 'sha1_short': "%h", 730 | 'subject': "%s", 731 | 'author_name': "%an", 732 | 'author_email': "%ae", 733 | 'author_date': "%ad", 734 | 'author_date_timestamp': "%at", 735 | 'committer_name': "%cn", 736 | 'committer_date_timestamp': "%ct", 737 | 'raw_body': "%B", 738 | 'body': "%b", 739 | } 740 | 741 | GIT_FULL_FORMAT_STRING = "%x00".join(GIT_FORMAT_KEYS.values()) 742 | 743 | REGEX_RFC822_KEY_VALUE = \ 744 | r'(^|\n)(?P[A-Z]\w+(-\w+)*): (?P[^\n]*(\n\s+[^\n]*)*)' 745 | REGEX_RFC822_POSTFIX = \ 746 | r'(%s)+$' % REGEX_RFC822_KEY_VALUE 747 | 748 | 749 | class GitCommit(SubGitObjectMixin): 750 | r"""Represent a Git Commit and expose through its attribute many information 751 | 752 | Let's create a fake GitRepos: 753 | 754 | >>> from minimock import Mock 755 | >>> repos = Mock("gitRepos") 756 | 757 | Initialization: 758 | 759 | >>> repos.git = Mock("gitRepos.git") 760 | >>> repos.git.log.mock_returns_func = \ 761 | ... lambda *a, **kwargs: "\x00".join([{ 762 | ... 'sha1': "000000", 763 | ... 'sha1_short': "000", 764 | ... 'subject': SUBJECT, 765 | ... 'author_name': "John Smith", 766 | ... 'author_date': "Tue Feb 14 20:31:22 2017 +0700", 767 | ... 'author_email': "john.smith@example.com", 768 | ... 'author_date_timestamp': "0", ## epoch 769 | ... 'committer_name': "Alice Wang", 770 | ... 'committer_date_timestamp': "0", ## epoch 771 | ... 'raw_body': "my subject\n\n%s" % BODY, 772 | ... 'body': BODY, 773 | ... }[key] for key in GIT_FORMAT_KEYS.keys()]) 774 | >>> repos.git.rev_list.mock_returns = "123456" 775 | 776 | Query, by attributes or items: 777 | 778 | >>> SUBJECT = "fee fie foh" 779 | >>> BODY = "foo foo foo" 780 | 781 | >>> head = GitCommit(repos, "HEAD") 782 | >>> head.subject 783 | Called gitRepos.git.log(...'HEAD'...) 784 | 'fee fie foh' 785 | >>> head.author_name 786 | 'John Smith' 787 | 788 | Notice that on the second call, there's no need to call again git log as 789 | all the values have already been computed. 790 | 791 | Trailer 792 | ======= 793 | 794 | ``GitCommit`` offers a simple direct API to trailer values. These 795 | are like RFC822's header value but are at the end of body: 796 | 797 | >>> BODY = '''\ 798 | ... Stuff in the body 799 | ... Change-id: 1234 800 | ... Value-X: Supports multi 801 | ... line values''' 802 | 803 | >>> head = GitCommit(repos, "HEAD") 804 | >>> head.trailer_change_id 805 | Called gitRepos.git.log(...'HEAD'...) 806 | '1234' 807 | >>> head.trailer_value_x 808 | 'Supports multi\nline values' 809 | 810 | Notice how the multi-line value was unindented. 811 | In case of multiple values, these are concatened in lists: 812 | 813 | >>> BODY = '''\ 814 | ... Stuff in the body 815 | ... Co-Authored-By: Bob 816 | ... Co-Authored-By: Alice 817 | ... Co-Authored-By: Jack 818 | ... ''' 819 | 820 | >>> head = GitCommit(repos, "HEAD") 821 | >>> head.trailer_co_authored_by 822 | Called gitRepos.git.log(...'HEAD'...) 823 | ['Bob', 'Alice', 'Jack'] 824 | 825 | 826 | Special values 827 | ============== 828 | 829 | Authors 830 | ------- 831 | 832 | >>> BODY = '''\ 833 | ... Stuff in the body 834 | ... Co-Authored-By: Bob 835 | ... Co-Authored-By: Alice 836 | ... Co-Authored-By: Jack 837 | ... ''' 838 | 839 | >>> head = GitCommit(repos, "HEAD") 840 | >>> head.author_names 841 | Called gitRepos.git.log(...'HEAD'...) 842 | ['Alice', 'Bob', 'Jack', 'John Smith'] 843 | 844 | Notice that they are printed in alphabetical order. 845 | 846 | """ 847 | 848 | def __init__(self, repos, identifier): 849 | super(GitCommit, self).__init__(repos) 850 | self.identifier = identifier 851 | self._trailer_parsed = False 852 | 853 | def __getattr__(self, label): 854 | """Completes commits attributes upon request.""" 855 | attrs = GIT_FORMAT_KEYS.keys() 856 | if label not in attrs: 857 | try: 858 | return self.__dict__[label] 859 | except KeyError: 860 | if self._trailer_parsed: 861 | raise AttributeError(label) 862 | 863 | identifier = self.identifier 864 | 865 | ## Compute only missing information 866 | missing_attrs = [l for l in attrs if l not in self.__dict__] 867 | ## some commit can be already fully specified (see ``mk_commit``) 868 | if missing_attrs: 869 | aformat = "%x00".join(GIT_FORMAT_KEYS[l] 870 | for l in missing_attrs) 871 | try: 872 | ret = self.git.log([identifier, "--max-count=1", 873 | "--pretty=format:%s" % aformat, "--"]) 874 | except ShellError: 875 | if DEBUG: 876 | raise 877 | raise ValueError("Given commit identifier %r doesn't exists" 878 | % self.identifier) 879 | attr_values = ret.split("\x00") 880 | for attr, value in zip(missing_attrs, attr_values): 881 | setattr(self, attr, value.strip()) 882 | 883 | ## Let's interpret RFC822-like header keys that could be in the body 884 | match = re.search(REGEX_RFC822_POSTFIX, self.body) 885 | if match is not None: 886 | pos = match.start() 887 | postfix = self.body[pos:] 888 | self.body = self.body[:pos] 889 | for match in re.finditer(REGEX_RFC822_KEY_VALUE, postfix): 890 | dct = match.groupdict() 891 | key = dct["key"].replace("-", "_").lower() 892 | if "\n" in dct["value"]: 893 | first_line, remaining = dct["value"].split('\n', 1) 894 | value = "%s\n%s" % (first_line, 895 | textwrap.dedent(remaining)) 896 | else: 897 | value = dct["value"] 898 | try: 899 | prev_value = self.__dict__["trailer_%s" % key] 900 | except KeyError: 901 | setattr(self, "trailer_%s" % key, value) 902 | else: 903 | setattr(self, "trailer_%s" % key, 904 | prev_value + [value, ] 905 | if isinstance(prev_value, list) 906 | else [prev_value, value, ]) 907 | self._trailer_parsed = True 908 | return getattr(self, label) 909 | 910 | @property 911 | def author_names(self): 912 | return [re.sub(r'^([^<]+)<[^>]+>\s*$', r'\1', author).strip() 913 | for author in self.authors] 914 | 915 | @property 916 | def authors(self): 917 | co_authors = getattr(self, 'trailer_co_authored_by', []) 918 | co_authors = co_authors if isinstance(co_authors, list) \ 919 | else [co_authors] 920 | return sorted(co_authors + 921 | ["%s <%s>" % (self.author_name, self.author_email)]) 922 | 923 | @property 924 | def date(self): 925 | d = datetime.datetime.utcfromtimestamp( 926 | float(self.author_date_timestamp)) 927 | return d.strftime('%Y-%m-%d') 928 | 929 | @property 930 | def has_annotated_tag(self): 931 | try: 932 | self.git.rev_parse(['%s^{tag}' % self.identifier, "--"]) 933 | return True 934 | except ShellError as e: 935 | if e.errlvl != 128: 936 | raise 937 | return False 938 | 939 | @property 940 | def tagger_date_timestamp(self): 941 | if not self.has_annotated_tag: 942 | raise ValueError("Can't access 'tagger_date_timestamp' on commit without annotated tag.") 943 | tagger_date_utc = self.git.for_each_ref( 944 | 'refs/tags/%s' % self.identifier, format='%(taggerdate:raw)') 945 | return tagger_date_utc.split(" ", 1)[0] 946 | 947 | @property 948 | def tagger_date(self): 949 | d = datetime.datetime.utcfromtimestamp( 950 | float(self.tagger_date_timestamp)) 951 | return d.strftime('%Y-%m-%d') 952 | 953 | def __le__(self, value): 954 | if not isinstance(value, GitCommit): 955 | value = self._repos.commit(value) 956 | try: 957 | self.git.merge_base(value.sha1, is_ancestor=self.sha1) 958 | return True 959 | except ShellError as e: 960 | if e.errlvl != 1: 961 | raise 962 | return False 963 | 964 | def __lt__(self, value): 965 | if not isinstance(value, GitCommit): 966 | value = self._repos.commit(value) 967 | return self <= value and self != value 968 | 969 | def __eq__(self, value): 970 | if not isinstance(value, GitCommit): 971 | value = self._repos.commit(value) 972 | return self.sha1 == value.sha1 973 | 974 | def __hash__(self): 975 | return hash(self.sha1) 976 | 977 | def __repr__(self): 978 | return "<%s %r>" % (self.__class__.__name__, self.identifier) 979 | 980 | 981 | def normpath(path, cwd=None): 982 | """path can be absolute or relative, if relative it uses the cwd given as 983 | param. 984 | 985 | """ 986 | if os.path.isabs(path): 987 | return path 988 | cwd = cwd if cwd else os.getcwd() 989 | return os.path.normpath(os.path.join(cwd, path)) 990 | 991 | 992 | class GitConfig(SubGitObjectMixin): 993 | """Interface to config values of git 994 | 995 | Let's create a fake GitRepos: 996 | 997 | >>> from minimock import Mock 998 | >>> repos = Mock("gitRepos") 999 | 1000 | Initialization: 1001 | 1002 | >>> cfg = GitConfig(repos) 1003 | 1004 | Query, by attributes or items: 1005 | 1006 | >>> repos.git.config.mock_returns = "bar" 1007 | >>> cfg.foo 1008 | Called gitRepos.git.config('foo') 1009 | 'bar' 1010 | >>> cfg["foo"] 1011 | Called gitRepos.git.config('foo') 1012 | 'bar' 1013 | >>> cfg.get("foo") 1014 | Called gitRepos.git.config('foo') 1015 | 'bar' 1016 | >>> cfg["foo.wiz"] 1017 | Called gitRepos.git.config('foo.wiz') 1018 | 'bar' 1019 | 1020 | Notice that you can't use attribute search in subsection as ``cfg.foo.wiz`` 1021 | That's because in git config files, you can have a value attached to 1022 | an element, and this element can also be a section. 1023 | 1024 | Nevertheless, you can do: 1025 | 1026 | >>> getattr(cfg, "foo.wiz") 1027 | Called gitRepos.git.config('foo.wiz') 1028 | 'bar' 1029 | 1030 | Default values 1031 | -------------- 1032 | 1033 | get item, and getattr default values can be used: 1034 | 1035 | >>> del repos.git.config.mock_returns 1036 | >>> repos.git.config.mock_raises = ShellError('Key not found', 1037 | ... errlvl=1, out="", err="") 1038 | 1039 | >>> getattr(cfg, "foo", "default") 1040 | Called gitRepos.git.config('foo') 1041 | 'default' 1042 | 1043 | >>> cfg["foo"] ## doctest: +ELLIPSIS 1044 | Traceback (most recent call last): 1045 | ... 1046 | KeyError: 'foo' 1047 | 1048 | >>> getattr(cfg, "foo") ## doctest: +ELLIPSIS 1049 | Traceback (most recent call last): 1050 | ... 1051 | AttributeError... 1052 | 1053 | >>> cfg.get("foo", "default") 1054 | Called gitRepos.git.config('foo') 1055 | 'default' 1056 | 1057 | >>> print("%r" % cfg.get("foo")) 1058 | Called gitRepos.git.config('foo') 1059 | None 1060 | 1061 | """ 1062 | 1063 | def __init__(self, repos): 1064 | super(GitConfig, self).__init__(repos) 1065 | 1066 | def __getattr__(self, label): 1067 | try: 1068 | res = self.git.config(label) 1069 | except ShellError as e: 1070 | if e.errlvl == 1 and e.out == "": 1071 | raise AttributeError("key %r is not found in git config." 1072 | % label) 1073 | raise 1074 | return res 1075 | 1076 | def get(self, label, default=None): 1077 | return getattr(self, label, default) 1078 | 1079 | def __getitem__(self, label): 1080 | try: 1081 | return getattr(self, label) 1082 | except AttributeError: 1083 | raise KeyError(label) 1084 | 1085 | 1086 | class GitCmd(SubGitObjectMixin): 1087 | 1088 | def __getattr__(self, label): 1089 | label = label.replace("_", "-") 1090 | 1091 | def dir_swrap(command, **kwargs): 1092 | with set_cwd(self._repos._orig_path): 1093 | return swrap(command, **kwargs) 1094 | 1095 | def method(*args, **kwargs): 1096 | if (len(args) == 1 and not isinstance(args[0], basestring)): 1097 | return dir_swrap( 1098 | ['git', label, ] + args[0], 1099 | shell=False, 1100 | env=kwargs.get("env", None)) 1101 | cli_args = [] 1102 | for key, value in kwargs.items(): 1103 | cli_key = (("-%s" if len(key) == 1 else "--%s") 1104 | % key.replace("_", "-")) 1105 | if isinstance(value, bool): 1106 | cli_args.append(cli_key) 1107 | else: 1108 | cli_args.append(cli_key) 1109 | cli_args.append(value) 1110 | 1111 | cli_args.extend(args) 1112 | 1113 | return dir_swrap(['git', label, ] + cli_args, shell=False) 1114 | return method 1115 | 1116 | 1117 | class GitRepos(object): 1118 | 1119 | def __init__(self, path): 1120 | 1121 | ## Saving this original path to ensure all future git commands 1122 | ## will be done from this location. 1123 | self._orig_path = os.path.abspath(path) 1124 | 1125 | ## verify ``git`` command is accessible: 1126 | try: 1127 | self._git_version = self.git.version() 1128 | except ShellError: 1129 | if DEBUG: 1130 | raise 1131 | raise EnvironmentError( 1132 | "Required ``git`` command not found or broken in $PATH. " 1133 | "(calling ``git version`` failed.)") 1134 | 1135 | ## verify that we are in a git repository 1136 | try: 1137 | self.git.remote() 1138 | except ShellError: 1139 | if DEBUG: 1140 | raise 1141 | raise EnvironmentError( 1142 | "Not in a git repository. (calling ``git remote`` failed.)") 1143 | 1144 | self.bare = self.git.rev_parse(is_bare_repository=True) == "true" 1145 | self.toplevel = (None if self.bare else 1146 | self.git.rev_parse(show_toplevel=True)) 1147 | self.gitdir = normpath(self.git.rev_parse(git_dir=True), 1148 | cwd=self._orig_path) 1149 | 1150 | @classmethod 1151 | def create(cls, directory, *args, **kwargs): 1152 | os.mkdir(directory) 1153 | return cls.init(directory, *args, **kwargs) 1154 | 1155 | @classmethod 1156 | def init(cls, directory, user=None, email=None): 1157 | with set_cwd(directory): 1158 | wrap("git init .") 1159 | self = cls(directory) 1160 | if user: 1161 | self.git.config("user.name", user) 1162 | if email: 1163 | self.git.config("user.email", email) 1164 | return self 1165 | 1166 | def commit(self, identifier): 1167 | return GitCommit(self, identifier) 1168 | 1169 | @property 1170 | def git(self): 1171 | return GitCmd(self) 1172 | 1173 | @property 1174 | def config(self): 1175 | return GitConfig(self) 1176 | 1177 | def tags(self, contains=None): 1178 | """String list of repository's tag names 1179 | 1180 | Current tag order is committer date timestamp of tagged commit. 1181 | No firm reason for that, and it could change in future version. 1182 | 1183 | """ 1184 | if contains: 1185 | tags = self.git.tag(contains=contains).split("\n") 1186 | else: 1187 | tags = self.git.tag().split("\n") 1188 | ## Should we use new version name sorting ? refering to : 1189 | ## ``git tags --sort -v:refname`` in git version >2.0. 1190 | ## Sorting and reversing with command line is not available on 1191 | ## git version <2.0 1192 | return sorted([self.commit(tag) for tag in tags if tag != ''], 1193 | key=lambda x: int(x.committer_date_timestamp)) 1194 | 1195 | def log(self, includes=["HEAD", ], excludes=[], include_merge=True, 1196 | encoding=_preferred_encoding): 1197 | """Reverse chronological list of git repository's commits 1198 | 1199 | Note: rev lists can be GitCommit instance list or identifier list. 1200 | 1201 | """ 1202 | 1203 | refs = {'includes': includes, 1204 | 'excludes': excludes} 1205 | for ref_type in ('includes', 'excludes'): 1206 | for idx, ref in enumerate(refs[ref_type]): 1207 | if not isinstance(ref, GitCommit): 1208 | refs[ref_type][idx] = self.commit(ref) 1209 | 1210 | ## --topo-order: don't mix commits from separate branches. 1211 | plog = Proc("git log --stdin -z --topo-order --pretty=format:%s %s --" 1212 | % (GIT_FULL_FORMAT_STRING, 1213 | '--no-merges' if not include_merge else ''), 1214 | encoding=encoding) 1215 | for ref in refs["includes"]: 1216 | plog.stdin.write("%s\n" % ref.sha1) 1217 | 1218 | for ref in refs["excludes"]: 1219 | plog.stdin.write("^%s\n" % ref.sha1) 1220 | plog.stdin.close() 1221 | 1222 | def mk_commit(dct): 1223 | """Creates an already set commit from a dct""" 1224 | c = self.commit(dct["sha1"]) 1225 | for k, v in dct.items(): 1226 | setattr(c, k, v) 1227 | return c 1228 | 1229 | values = plog.stdout.read("\x00") 1230 | 1231 | try: 1232 | while True: ## next(values) will eventualy raise a StopIteration 1233 | yield mk_commit(dict([(key, next(values)) 1234 | for key in GIT_FORMAT_KEYS])) 1235 | except StopIteration: 1236 | pass ## since 3.7, we are not allowed anymore to trickle down 1237 | ## StopIteration. 1238 | finally: 1239 | plog.stdout.close() 1240 | plog.stderr.close() 1241 | 1242 | 1243 | def first_matching(section_regexps, string): 1244 | for section, regexps in section_regexps: 1245 | if regexps is None: 1246 | return section 1247 | for regexp in regexps: 1248 | if re.search(regexp, string) is not None: 1249 | return section 1250 | 1251 | 1252 | def ensure_template_file_exists(label, template_name): 1253 | """Return template file path given a label hint and the template name 1254 | 1255 | Template name can be either a filename with full path, 1256 | if this is the case, the label is of no use. 1257 | 1258 | If ``template_name`` does not refer to an existing file, 1259 | then ``label`` is used to find a template file in the 1260 | the bundled ones. 1261 | 1262 | """ 1263 | 1264 | try: 1265 | template_path = GitRepos(os.getcwd()).config.get( 1266 | "gitchangelog.template-path") 1267 | except ShellError as e: 1268 | stderr( 1269 | "Error parsing git config: %s." 1270 | " Won't be able to read 'template-path' if defined." 1271 | % (str(e))) 1272 | template_path = None 1273 | 1274 | if template_path: 1275 | path_file = path_label = template_path 1276 | else: 1277 | path_file = os.getcwd() 1278 | path_label = os.path.join(os.path.dirname(os.path.realpath(__file__)), 1279 | "templates", label) 1280 | 1281 | for ftn in [os.path.join(path_file, template_name), 1282 | os.path.join(path_label, "%s.tpl" % template_name)]: 1283 | if os.path.isfile(ftn): 1284 | return ftn 1285 | 1286 | templates = glob.glob(os.path.join(path_label, "*.tpl")) 1287 | if len(templates) > 0: 1288 | msg = ("These are the available %s templates:" % label) 1289 | msg += "\n - " + \ 1290 | "\n - ".join(os.path.basename(f).split(".")[0] 1291 | for f in templates) 1292 | msg += "\nTemplates are located in %r" % path_label 1293 | else: 1294 | msg = "No available %s templates found in %r." \ 1295 | % (label, path_label) 1296 | die("Error: Invalid %s template name %r.\n" % (label, template_name) + 1297 | "%s" % msg) 1298 | 1299 | 1300 | ## 1301 | ## Output Engines 1302 | ## 1303 | 1304 | @available_in_config 1305 | def rest_py(data, opts={}): 1306 | """Returns ReStructured Text changelog content from data""" 1307 | 1308 | def rest_title(label, char="="): 1309 | return (label.strip() + "\n") + (char * len(label) + "\n") 1310 | 1311 | def render_version(version): 1312 | title = "%s (%s)" % (version["tag"], version["date"]) \ 1313 | if version["tag"] else \ 1314 | opts["unreleased_version_label"] 1315 | s = rest_title(title, char="-") 1316 | 1317 | sections = version["sections"] 1318 | nb_sections = len(sections) 1319 | for section in sections: 1320 | 1321 | section_label = section["label"] if section.get("label", None) \ 1322 | else "Other" 1323 | 1324 | if not (section_label == "Other" and nb_sections == 1): 1325 | s += "\n" + rest_title(section_label, "~") 1326 | 1327 | for commit in section["commits"]: 1328 | s += render_commit(commit) 1329 | return s 1330 | 1331 | def render_commit(commit, opts=opts): 1332 | subject = commit["subject"] 1333 | subject += " [%s]" % (", ".join(commit["authors"]), ) 1334 | 1335 | entry = indent('\n'.join(textwrap.wrap(subject)), 1336 | first="- ").strip() + "\n" 1337 | 1338 | if commit["body"]: 1339 | entry += "\n" + indent(commit["body"]) 1340 | entry += "\n" 1341 | 1342 | return entry 1343 | 1344 | if data["title"]: 1345 | yield rest_title(data["title"], char="=") + "\n\n" 1346 | 1347 | for version in data["versions"]: 1348 | if len(version["sections"]) > 0: 1349 | yield render_version(version) + "\n\n" 1350 | 1351 | 1352 | ## formatter engines 1353 | 1354 | if pystache: 1355 | 1356 | @available_in_config 1357 | def mustache(template_name): 1358 | """Return a callable that will render a changelog data structure 1359 | 1360 | returned callable must take 2 arguments ``data`` and ``opts``. 1361 | 1362 | """ 1363 | template_path = ensure_template_file_exists("mustache", template_name) 1364 | 1365 | template = file_get_contents(template_path) 1366 | 1367 | def stuffed_versions(versions, opts): 1368 | for version in versions: 1369 | title = "%s (%s)" % (version["tag"], version["date"]) \ 1370 | if version["tag"] else \ 1371 | opts["unreleased_version_label"] 1372 | version["label"] = title 1373 | version["label_chars"] = list(version["label"]) 1374 | for section in version["sections"]: 1375 | section["label_chars"] = list(section["label"]) 1376 | section["display_label"] = \ 1377 | not (section["label"] == "Other" and 1378 | len(version["sections"]) == 1) 1379 | for commit in section["commits"]: 1380 | commit["author_names_joined"] = ", ".join( 1381 | commit["authors"]) 1382 | commit["body_indented"] = indent(commit["body"]) 1383 | yield version 1384 | 1385 | def renderer(data, opts): 1386 | 1387 | ## mustache is very simple so we need to add some intermediate 1388 | ## values 1389 | data["general_title"] = True if data["title"] else False 1390 | data["title_chars"] = list(data["title"]) if data["title"] else [] 1391 | 1392 | data["versions"] = stuffed_versions(data["versions"], opts) 1393 | 1394 | return pystache.render(template, data) 1395 | 1396 | return renderer 1397 | 1398 | else: 1399 | 1400 | @available_in_config 1401 | def mustache(template_name): ## pylint: disable=unused-argument 1402 | die("Required 'pystache' python module not found.") 1403 | 1404 | 1405 | if mako: 1406 | 1407 | import mako.template ## pylint: disable=wrong-import-position 1408 | 1409 | mako_env = dict((f.__name__, f) for f in (ucfirst, indent, textwrap, 1410 | paragraph_wrap)) 1411 | 1412 | @available_in_config 1413 | def makotemplate(template_name): 1414 | """Return a callable that will render a changelog data structure 1415 | 1416 | returned callable must take 2 arguments ``data`` and ``opts``. 1417 | 1418 | """ 1419 | template_path = ensure_template_file_exists("mako", template_name) 1420 | 1421 | template = mako.template.Template(filename=template_path) 1422 | 1423 | def renderer(data, opts): 1424 | kwargs = mako_env.copy() 1425 | kwargs.update({"data": data, 1426 | "opts": opts}) 1427 | return template.render(**kwargs) 1428 | 1429 | return renderer 1430 | 1431 | else: 1432 | 1433 | @available_in_config 1434 | def makotemplate(template_name): ## pylint: disable=unused-argument 1435 | die("Required 'mako' python module not found.") 1436 | 1437 | 1438 | ## 1439 | ## Publish action 1440 | ## 1441 | 1442 | @available_in_config 1443 | def stdout(content): 1444 | for chunk in content: 1445 | safe_print(chunk) 1446 | @available_in_config 1447 | def FileInsertAtFirstRegexMatch(filename, pattern, flags=0, 1448 | idx=lambda m: m.start()): 1449 | 1450 | def write_content(f, content): 1451 | for content_line in content: 1452 | f.write(content_line) 1453 | 1454 | def _wrapped(content): 1455 | index = idx(_file_regex_match(filename, pattern, flags=flags)) 1456 | offset = 0 1457 | new_offset = 0 1458 | postfix = False 1459 | 1460 | with open(filename + "~", "w") as dst: 1461 | with open(filename, "r") as src: 1462 | for line in src: 1463 | if postfix: 1464 | dst.write(line) 1465 | continue 1466 | new_offset = offset + len(line) 1467 | if new_offset < index: 1468 | offset = new_offset 1469 | dst.write(line) 1470 | continue 1471 | dst.write(line[0:index - offset]) 1472 | write_content(dst, content) 1473 | dst.write(line[index - offset:]) 1474 | postfix = True 1475 | if not postfix: 1476 | write_content(dst, content) 1477 | if WIN32: 1478 | os.remove(filename) 1479 | os.rename(filename + "~", filename) 1480 | 1481 | return _wrapped 1482 | 1483 | 1484 | @available_in_config 1485 | def FileRegexSubst(filename, pattern, replace, flags=0): 1486 | 1487 | replace = re.sub(r'\\([0-9+])', r'\\g<\1>', replace) 1488 | 1489 | def _wrapped(content): 1490 | src = file_get_contents(filename) 1491 | ## Protect replacement pattern against the following expansion of '\o' 1492 | src = re.sub( 1493 | pattern, 1494 | replace.replace(r'\o', "".join(content).replace('\\', '\\\\')), 1495 | src, flags=flags) 1496 | if not PY3: 1497 | src = src.encode(_preferred_encoding) 1498 | file_put_contents(filename, src) 1499 | 1500 | return _wrapped 1501 | 1502 | 1503 | ## 1504 | ## Data Structure 1505 | ## 1506 | 1507 | def versions_data_iter(repository, revlist=None, 1508 | ignore_regexps=[], 1509 | section_regexps=[(None, '')], 1510 | tag_filter_regexp=r"\d+\.\d+(\.\d+)?", 1511 | include_merge=True, 1512 | body_process=lambda x: x, 1513 | subject_process=lambda x: x, 1514 | log_encoding=DEFAULT_GIT_LOG_ENCODING, 1515 | warn=warn, ## Mostly used for test 1516 | ): 1517 | """Returns an iterator through versions data structures 1518 | 1519 | (see ``gitchangelog.rc.reference`` file for more info) 1520 | 1521 | :param repository: target ``GitRepos`` object 1522 | :param revlist: list of strings that git log understands as revlist 1523 | :param ignore_regexps: list of regexp identifying ignored commit messages 1524 | :param section_regexps: regexps identifying sections 1525 | :param tag_filter_regexp: regexp to match tags used as version 1526 | :param include_merge: whether to include merge commits in the log or not 1527 | :param body_process: text processing object to apply to body 1528 | :param subject_process: text processing object to apply to subject 1529 | :param log_encoding: the encoding used in git logs 1530 | :param warn: callable to output warnings, mocked by tests 1531 | 1532 | :returns: iterator of versions data_structures 1533 | 1534 | """ 1535 | 1536 | revlist = revlist or [] 1537 | 1538 | ## Hash to speedup lookups 1539 | versions_done = {} 1540 | excludes = [rev[1:] 1541 | for rev in repository.git.rev_parse([ 1542 | "--rev-only", ] + revlist + ["--", ]).split("\n") 1543 | if rev.startswith("^")] if revlist else [] 1544 | 1545 | revs = repository.git.rev_list(*revlist).split("\n") if revlist else [] 1546 | revs = [rev for rev in revs if rev != ""] 1547 | 1548 | if revlist and not revs: 1549 | die("No commits matching given revlist: %s" % (" ".join(revlist), )) 1550 | 1551 | tags = [tag 1552 | for tag in repository.tags(contains=revs[-1] if revs else None) 1553 | if re.match(tag_filter_regexp, tag.identifier)] 1554 | 1555 | tags.append(repository.commit("HEAD")) 1556 | 1557 | if revlist: 1558 | max_rev = repository.commit(revs[0]) 1559 | new_tags = [] 1560 | for tag in tags: 1561 | new_tags.append(tag) 1562 | if max_rev <= tag: 1563 | break 1564 | tags = new_tags 1565 | else: 1566 | max_rev = tags[-1] 1567 | 1568 | section_order = [k for k, _v in section_regexps] 1569 | 1570 | tags = list(reversed(tags)) 1571 | 1572 | ## Get the changes between tags (releases) 1573 | for idx, tag in enumerate(tags): 1574 | 1575 | ## New version 1576 | current_version = { 1577 | "date": tag.tagger_date if tag.has_annotated_tag else tag.date, 1578 | "commit_date": tag.date, 1579 | "tagger_date": tag.tagger_date if tag.has_annotated_tag else None, 1580 | "tag": tag.identifier if tag.identifier != "HEAD" else None, 1581 | "commit": tag, 1582 | } 1583 | 1584 | sections = collections.defaultdict(list) 1585 | commits = repository.log( 1586 | includes=[min(tag, max_rev)], 1587 | excludes=tags[idx + 1:] + excludes, 1588 | include_merge=include_merge, 1589 | encoding=log_encoding) 1590 | 1591 | for commit in commits: 1592 | if any(re.search(pattern, commit.subject) is not None 1593 | for pattern in ignore_regexps): 1594 | continue 1595 | 1596 | matched_section = first_matching(section_regexps, commit.subject) 1597 | 1598 | ## Finally storing the commit in the matching section 1599 | 1600 | sections[matched_section].append({ 1601 | "author": commit.author_name, 1602 | "authors": commit.author_names, 1603 | "subject": subject_process(commit.subject), 1604 | "body": body_process(commit.body), 1605 | "commit": commit, 1606 | }) 1607 | 1608 | ## Flush current version 1609 | current_version["sections"] = [{"label": k, "commits": sections[k]} 1610 | for k in section_order 1611 | if k in sections] 1612 | if len(current_version["sections"]) != 0: 1613 | yield current_version 1614 | versions_done[tag] = current_version 1615 | 1616 | 1617 | def changelog(output_engine=rest_py, 1618 | unreleased_version_label="unreleased", 1619 | warn=warn, ## Mostly used for test 1620 | **kwargs): 1621 | """Returns a string containing the changelog of given repository 1622 | 1623 | This function returns a string corresponding to the template rendered with 1624 | the changelog data tree. 1625 | 1626 | (see ``gitchangelog.rc.sample`` file for more info) 1627 | 1628 | For an exact list of arguments, see the arguments of 1629 | ``versions_data_iter(..)``. 1630 | 1631 | :param unreleased_version_label: version label for untagged commits 1632 | :param output_engine: callable to render the changelog data 1633 | :param warn: callable to output warnings, mocked by tests 1634 | 1635 | :returns: content of changelog 1636 | 1637 | """ 1638 | 1639 | opts = { 1640 | 'unreleased_version_label': unreleased_version_label, 1641 | } 1642 | 1643 | ## Setting main container of changelog elements 1644 | title = None if kwargs.get("revlist") else "Changelog" 1645 | data = {"title": title, 1646 | "versions": []} 1647 | 1648 | versions = versions_data_iter(warn=warn, **kwargs) 1649 | 1650 | ## poke once in versions to know if there's at least one: 1651 | try: 1652 | first_version = next(versions) 1653 | except StopIteration: 1654 | warn("Empty changelog. No commits were elected to be used as entry.") 1655 | data["versions"] = [] 1656 | else: 1657 | data["versions"] = itertools.chain([first_version], versions) 1658 | 1659 | return output_engine(data=data, opts=opts) 1660 | 1661 | ## 1662 | ## Manage obsolete options 1663 | ## 1664 | 1665 | _obsolete_options_managers = [] 1666 | 1667 | 1668 | def obsolete_option_manager(fun): 1669 | _obsolete_options_managers.append(fun) 1670 | 1671 | 1672 | @obsolete_option_manager 1673 | def obsolete_replace_regexps(config): 1674 | """This option was superseeded by the ``subject_process`` option. 1675 | 1676 | Each regex replacement you had could be translated in a 1677 | ``ReSub(pattern, replace)`` in the ``subject_process`` pipeline. 1678 | 1679 | """ 1680 | if "replace_regexps" in config: 1681 | for pattern, replace in config["replace_regexps"].items(): 1682 | config["subject_process"] = \ 1683 | ReSub(pattern, replace) | \ 1684 | config.get("subject_process", ucfirst | final_dot) 1685 | 1686 | 1687 | @obsolete_option_manager 1688 | def obsolete_body_split_regexp(config): 1689 | """This option was superseeded by the ``body_process`` option. 1690 | 1691 | The split regex can now be sent as a ``Wrap(regex)`` text process 1692 | instruction in the ``body_process`` pipeline. 1693 | 1694 | """ 1695 | if "body_split_regex" in config: 1696 | config["body_process"] = Wrap(config["body_split_regex"]) | \ 1697 | config.get("body_process", noop) 1698 | 1699 | 1700 | def manage_obsolete_options(config): 1701 | for man in _obsolete_options_managers: 1702 | man(config) 1703 | 1704 | 1705 | ## 1706 | ## Command line parsing 1707 | ## 1708 | 1709 | def parse_cmd_line(usage, description, epilog, exname, version): 1710 | 1711 | import argparse 1712 | kwargs = dict(usage=usage, 1713 | description=description, 1714 | epilog="\n" + epilog, 1715 | prog=exname, 1716 | formatter_class=argparse.RawTextHelpFormatter) 1717 | 1718 | try: 1719 | parser = argparse.ArgumentParser(version=version, **kwargs) 1720 | except TypeError: ## compat with argparse from python 3.4 1721 | parser = argparse.ArgumentParser(**kwargs) 1722 | parser.add_argument('-v', '--version', 1723 | help="show program's version number and exit", 1724 | action="version", version=version) 1725 | 1726 | parser.add_argument('-d', '--debug', 1727 | help="Enable debug mode (show full tracebacks).", 1728 | action="store_true", dest="debug") 1729 | parser.add_argument('revlist', nargs='*', action="store", default=[]) 1730 | 1731 | ## Remove "show" as first argument for compatibility reason. 1732 | 1733 | argv = [] 1734 | for i, arg in enumerate(sys.argv[1:]): 1735 | if arg.startswith("-"): 1736 | argv.append(arg) 1737 | continue 1738 | if arg == "show": 1739 | warn("'show' positional argument is deprecated.") 1740 | argv += sys.argv[i + 2:] 1741 | break 1742 | else: 1743 | argv += sys.argv[i + 1:] 1744 | break 1745 | 1746 | return parser.parse_args(argv) 1747 | 1748 | 1749 | eval_if_callable = lambda v: v() if callable(v) else v 1750 | 1751 | 1752 | def get_revision(repository, config, opts): 1753 | if opts.revlist: 1754 | revs = opts.revlist 1755 | else: 1756 | revs = config.get("revs") 1757 | if revs: 1758 | revs = eval_if_callable(revs) 1759 | if not isinstance(revs, list): 1760 | die("Invalid type for 'revs' in config file. " 1761 | "A 'list' type is required, and a %r was given." 1762 | % type(revs).__name__) 1763 | revs = [eval_if_callable(rev) 1764 | for rev in revs] 1765 | else: 1766 | revs = [] 1767 | 1768 | for rev in revs: 1769 | if not isinstance(rev, basestring): 1770 | die("Invalid type for revision in revs list from config file. " 1771 | "'str' type is required, and a %r was given." 1772 | % type(rev).__name__) 1773 | try: 1774 | repository.git.rev_parse([rev, "--rev_only", "--"]) 1775 | except ShellError: 1776 | if DEBUG: 1777 | raise 1778 | die("Revision %r is not valid." % rev) 1779 | 1780 | if revs == ["HEAD", ]: 1781 | return [] 1782 | return revs 1783 | 1784 | 1785 | def get_log_encoding(repository, config): 1786 | 1787 | log_encoding = config.get("log_encoding", None) 1788 | if log_encoding is None: 1789 | try: 1790 | log_encoding = repository.config.get("i18n.logOuputEncoding") 1791 | except ShellError as e: 1792 | warn( 1793 | "Error parsing git config: %s." 1794 | " Couldn't check if 'i18n.logOuputEncoding' was set." 1795 | % (str(e))) 1796 | 1797 | ## Final defaults coming from git defaults 1798 | return log_encoding or DEFAULT_GIT_LOG_ENCODING 1799 | 1800 | 1801 | ## 1802 | ## Config Manager 1803 | ## 1804 | 1805 | class Config(dict): 1806 | 1807 | def __getitem__(self, label): 1808 | if label not in self.keys(): 1809 | die("Missing value in config file for key '%s'." % label) 1810 | return super(Config, self).__getitem__(label) 1811 | 1812 | 1813 | ## 1814 | ## Safe print 1815 | ## 1816 | 1817 | def safe_print(content): 1818 | if not PY3: 1819 | if isinstance(content, unicode): 1820 | content = content.encode(_preferred_encoding) 1821 | 1822 | try: 1823 | print(content, end='') 1824 | sys.stdout.flush() 1825 | except UnicodeEncodeError: 1826 | if DEBUG: 1827 | raise 1828 | ## XXXvlab: should use $COLUMNS in bash and for windows: 1829 | ## http://stackoverflow.com/questions/14978548 1830 | stderr(paragraph_wrap(textwrap.dedent("""\ 1831 | UnicodeEncodeError: 1832 | There was a problem outputing the resulting changelog to 1833 | your console. 1834 | 1835 | This probably means that the changelog contains characters 1836 | that can't be translated to characters in your current charset 1837 | (%s). 1838 | """) % sys.stdout.encoding)) 1839 | if WIN32 and PY_VERSION < 3.6 and sys.stdout.encoding != 'utf-8': 1840 | ## As of PY 3.6, encoding is now ``utf-8`` regardless of 1841 | ## PYTHONIOENCODING 1842 | ## https://www.python.org/dev/peps/pep-0528/ 1843 | stderr(" You might want to try to fix that by setting " 1844 | "PYTHONIOENCODING to 'utf-8'.") 1845 | exit(1) 1846 | except IOError as e: 1847 | if e.errno == 0 and not PY3 and WIN32: 1848 | ## Yes, had a strange IOError Errno 0 after outputing string 1849 | ## that contained UTF-8 chars on Windows and PY2.7 1850 | pass ## Ignoring exception 1851 | elif ((WIN32 and e.errno == 22) or ## Invalid argument 1852 | (not WIN32 and e.errno == errno.EPIPE)): ## Broken Pipe 1853 | ## Nobody is listening anymore to stdout it seems. Let's bailout. 1854 | if PY3: 1855 | try: 1856 | ## Called only to generate exception and have a chance at 1857 | ## ignoring it. Otherwise this happens upon exit, and gets 1858 | ## some error message printed on stderr. 1859 | sys.stdout.close() 1860 | except BrokenPipeError: ## expected outcome on linux 1861 | pass 1862 | except OSError as e2: 1863 | if e2.errno != 22: ## expected outcome on WIN32 1864 | raise 1865 | ## Yay ! stdout is closed we can now exit safely. 1866 | exit(0) 1867 | else: 1868 | raise 1869 | 1870 | 1871 | ## 1872 | ## Main 1873 | ## 1874 | 1875 | def main(): 1876 | 1877 | global DEBUG 1878 | ## Basic environment infos 1879 | 1880 | reference_config = os.path.join( 1881 | os.path.dirname(os.path.realpath(__file__)), 1882 | "gitchangelog.rc.reference") 1883 | 1884 | basename = os.path.basename(sys.argv[0]) 1885 | if basename.endswith(".py"): 1886 | basename = basename[:-3] 1887 | 1888 | debug_varname = "DEBUG_%s" % basename.upper() 1889 | DEBUG = os.environ.get(debug_varname, False) 1890 | 1891 | i = lambda x: x % {'exname': basename} 1892 | 1893 | opts = parse_cmd_line(usage=i(usage_msg), 1894 | description=i(description_msg), 1895 | epilog=i(epilog_msg), 1896 | exname=basename, 1897 | version=__version__) 1898 | DEBUG = DEBUG or opts.debug 1899 | 1900 | try: 1901 | repository = GitRepos(".") 1902 | except EnvironmentError as e: 1903 | if DEBUG: 1904 | raise 1905 | try: 1906 | die(str(e)) 1907 | except Exception as e2: 1908 | die(repr(e2)) 1909 | 1910 | try: 1911 | gc_rc = repository.config.get("gitchangelog.rc-path") 1912 | except ShellError as e: 1913 | stderr( 1914 | "Error parsing git config: %s." 1915 | " Won't be able to read 'rc-path' if defined." 1916 | % (str(e))) 1917 | gc_rc = None 1918 | 1919 | gc_rc = normpath(gc_rc, cwd=repository.toplevel) if gc_rc else None 1920 | 1921 | ## config file lookup resolution 1922 | for enforce_file_existence, fun in [ 1923 | (True, lambda: os.environ.get('GITCHANGELOG_CONFIG_FILENAME')), 1924 | (True, lambda: gc_rc), 1925 | (False, 1926 | lambda: (os.path.join(repository.toplevel, ".%s.rc" % basename)) 1927 | if not repository.bare else None)]: 1928 | changelogrc = fun() 1929 | if changelogrc: 1930 | if not os.path.exists(changelogrc): 1931 | if enforce_file_existence: 1932 | die("File %r does not exists." % changelogrc) 1933 | else: 1934 | continue ## changelogrc valued, but file does not exists 1935 | else: 1936 | break 1937 | 1938 | ## config file may lookup for templates relative to the toplevel 1939 | ## of git repository 1940 | os.chdir(repository.toplevel) 1941 | 1942 | config = load_config_file( 1943 | os.path.expanduser(changelogrc), 1944 | default_filename=reference_config, 1945 | fail_if_not_present=False) 1946 | 1947 | config = Config(config) 1948 | 1949 | log_encoding = get_log_encoding(repository, config) 1950 | revlist = get_revision(repository, config, opts) 1951 | config['unreleased_version_label'] = eval_if_callable( 1952 | config['unreleased_version_label']) 1953 | manage_obsolete_options(config) 1954 | 1955 | try: 1956 | content = changelog( 1957 | repository=repository, revlist=revlist, 1958 | ignore_regexps=config['ignore_regexps'], 1959 | section_regexps=config['section_regexps'], 1960 | unreleased_version_label=config['unreleased_version_label'], 1961 | tag_filter_regexp=config['tag_filter_regexp'], 1962 | output_engine=config.get("output_engine", rest_py), 1963 | include_merge=config.get("include_merge", True), 1964 | body_process=config.get("body_process", noop), 1965 | subject_process=config.get("subject_process", noop), 1966 | log_encoding=log_encoding, 1967 | ) 1968 | 1969 | if isinstance(content, basestring): 1970 | content = content.splitlines(True) 1971 | 1972 | config.get("publish", stdout)(content) 1973 | 1974 | except KeyboardInterrupt: 1975 | if DEBUG: 1976 | err("Keyboard interrupt received while running '%s':" 1977 | % (basename, )) 1978 | stderr(format_last_exception()) 1979 | else: 1980 | err("Keyboard Interrupt. Bailing out.") 1981 | exit(130) ## Actual SIGINT as bash process convention. 1982 | except Exception as e: ## pylint: disable=broad-except 1983 | if DEBUG: 1984 | err("Exception while running '%s':" 1985 | % (basename, )) 1986 | stderr(format_last_exception()) 1987 | else: 1988 | message = "%s" % e 1989 | err(message) 1990 | stderr(" (set %s environment variable, " 1991 | "or use ``--debug`` to see full traceback)" % 1992 | (debug_varname, )) 1993 | exit(255) 1994 | 1995 | 1996 | ## 1997 | ## Launch program 1998 | ## 1999 | 2000 | if __name__ == "__main__": 2001 | main() 2002 | --------------------------------------------------------------------------------