├── md_changelog ├── __init__.py ├── utils │ ├── __init__.py │ └── git.py ├── exceptions.py ├── assets │ └── commit_msg_hook.py ├── tokens.py ├── entry.py └── main.py ├── .gitignore ├── MANIFEST.in ├── tox.ini ├── setup.cfg ├── .bumpversion.cfg ├── requirements-test.txt ├── .travis.yml ├── tests ├── test_utils.py ├── fixtures │ └── Changelog.md ├── test_tokens.py ├── test_entry.py └── test_main.py ├── setup.py ├── .coveragerc ├── Changelog.md ├── LICENSE ├── Makefile └── README.md /md_changelog/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .idea 3 | .env 4 | .vagrant 5 | .coverage 6 | .tox 7 | release.txt 8 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include requirements-test.txt 3 | include *.py 4 | include *.md 5 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py34 3 | 4 | [testenv] 5 | passenv = * 6 | deps = -rrequirements-test.txt 7 | commands = py.test tests 8 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 0 3 | 4 | [wheel] 5 | python-tag = py3 6 | 7 | [aliases] 8 | test = pytest 9 | 10 | [pytest] 11 | addopts = -xs -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.1.4 3 | tag = False 4 | tag_name = {new_version} 5 | commit = True 6 | 7 | [bumpversion:file:setup.py] 8 | 9 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | mock 2 | pytest 3 | coverage 4 | 5 | # For setuptools integration 6 | # More details on http://pytest.org/latest/goodpractices.html#integrating-with-setuptools-python-setup-py-test-pytest-runner 7 | pytest-runner 8 | pytest-pythonpath -------------------------------------------------------------------------------- /md_changelog/utils/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | class VcsBackend(object): 5 | """VCS utils backend interface""" 6 | 7 | def get_user_email(self): 8 | raise NotImplementedError() 9 | 10 | def get_user_name(self): 11 | raise NotImplementedError() 12 | -------------------------------------------------------------------------------- /md_changelog/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | class ChangelogError(Exception): 5 | """ Common module exception class""" 6 | pass 7 | 8 | 9 | class WrongMessageTypeError(ChangelogError): 10 | pass 11 | 12 | 13 | class ConfigNotFoundError(ChangelogError): 14 | pass 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | cache: pip 3 | python: 4 | - 3.4 5 | env: 6 | - TOXENV=py34 7 | install: 8 | - pip install --quiet -r requirements-test.txt 9 | - pip install --quiet tox coveralls 10 | 11 | script: 12 | - tox -r 13 | 14 | after_success: 15 | - coverage run -m py.test tests 16 | - coverage report 17 | - coveralls 18 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import pytest 3 | 4 | 5 | @pytest.mark.skip('only for local tests') 6 | def test_git_backend(): 7 | from md_changelog.utils.git import GitBackend 8 | 9 | git = GitBackend() 10 | email = git.get_user_email() 11 | name = git.get_user_name() 12 | assert email == 'ekimovsky.maksim@gmail.com' 13 | assert name == 'Maksim Ekimovskii' 14 | 15 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | 4 | setup( 5 | name='md-changelog', 6 | version='0.1.4', 7 | packages=['md_changelog'], 8 | url='', 9 | license='MIT', 10 | author='Maksim Ekimovskii', 11 | author_email='ekimovsky.maksim@gmail.com', 12 | description='Changelog command-line tool manager', 13 | setup_requires=['pytest-runner'], 14 | tests_require=['pytest'], 15 | entry_points={ 16 | 'console_scripts': [ 17 | 'md-changelog = md_changelog.main:main' 18 | ] 19 | } 20 | ) 21 | -------------------------------------------------------------------------------- /md_changelog/utils/git.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import subprocess 3 | 4 | from md_changelog.utils import VcsBackend 5 | 6 | 7 | class GitBackend(VcsBackend): 8 | """Git utils backend""" 9 | 10 | def get_user_email(self): 11 | return self.call_cmd('git', 'config', 'user.email') 12 | 13 | def get_user_name(self): 14 | return self.call_cmd('git', 'config', 'user.name') 15 | 16 | @classmethod 17 | def call_cmd(cls, *args): 18 | output = subprocess.check_output(args) 19 | return output.strip().decode('utf-8') 20 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source=md_changelog 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 | def __unicode__ 14 | if self\.debug 15 | 16 | # Don't complain if tests don't hit defensive assertion code: 17 | raise AssertionError 18 | raise NotImplementedError 19 | 20 | # Don't complain if non-runnable code isn't run: 21 | if 0: 22 | if __name__ == .__main__.: 23 | 24 | omit = 25 | md-changelog/assets/* 26 | 27 | ignore_errors = True 28 | 29 | [html] 30 | directory = coverage_html_report 31 | -------------------------------------------------------------------------------- /md_changelog/assets/commit_msg_hook.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | #!/usr/bin/env python 3 | 4 | import sys 5 | import re 6 | import subprocess 7 | 8 | # Collect the parameters 9 | commit_msg_filepath = sys.argv[1] 10 | 11 | # Figure out which branch we're on 12 | branch = subprocess.check_output( 13 | ['git', 'symbolic-ref', '--short', 'HEAD']).strip() 14 | print("commit-msg: On branch '%s'" % branch) 15 | 16 | # Check the commit message if we're on an issue branch 17 | if branch.startswith('issue-'): 18 | print("commit-msg: Oh hey, it's an issue branch.") 19 | result = re.match('issue-(.*)', branch) 20 | issue_number = result.group(1) 21 | required_message = "ISSUE-%s" % issue_number 22 | 23 | with open(commit_msg_filepath, 'r') as f: 24 | content = f.read() 25 | if not content.startswith(required_message): 26 | print("commit-msg: ERROR! The commit message must start with '%s'" % required_message) 27 | sys.exit(1) 28 | -------------------------------------------------------------------------------- /Changelog.md: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 0.1.4+1 (UNRELEASED) 5 | -------------------- 6 | 7 | 8 | 0.1.4 (2017-06-04) 9 | ------------------ 10 | * [Feature] New [Breaking] message type 11 | 12 | 0.1.3 (2016-07-10) 13 | ------------------ 14 | * Remove py27 support, keep only py3, too hassle to maintain it and time to move to be in the new era of python 15 | * [Feature] Added confirmation dialog for 'release' with support of rollback, added --force-yes key to disable it 16 | 17 | 0.1.2 (2016-07-08) 18 | ------------------ 19 | * [Improvement] py2/3 compatibility 20 | * [Improvement] implemented changelog.undo() 21 | 22 | 0.1.1 (2016-07-07) 23 | ------------------ 24 | * [Improvement] Rename cli command current -> last 25 | * [Improvement] updated append command, added --no-edit key 26 | * [Improvement] improve add_message feature, new --split-by key to add multiple entries at once 27 | 28 | 0.1.0 (2016-07-06) 29 | ------------------ 30 | * [Feature] Implemented basic functionality: init, messages, release, append, current, edit 31 | 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Maksim Ekimovskii 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/fixtures/Changelog.md: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 0.1.0+1 (UNRELEASED) 5 | -------------------- 6 | ## TODO: 7 | 8 | * [Feature] DELETE functionality. 9 | * [Feature] Sub-queries, for example: SELECT * FROM (SELECT * FROM MyTable) 10 | * [Feature] JOIN ON functionality. Currently it supports JOIN USING syntax only 11 | * [Improvement] Add optional validation for operators (e.g validate IN value args) 12 | * [Improvement] Documentation: Q objects, GROUP BY syntax 13 | 14 | ### END OF TODO 15 | 16 | * [Feature] UPDATE functionality. 17 | * [Feature] Add "table_name.key" evaluation via table_name__key syntax for WHERE and SET clauses 18 | * [Improvement] Experimental support of pattern matching 'LIKE', 'ILIKE', 'SIMILAR TO' operator 19 | * [Feature] Support multiple .filter() clauses, concatenate it with AND operator 20 | * [Feature] SELECT {AggFunction} support: COUNT(*), AVG(*) 21 | * [Feature] SelectQuery: Basic join support with USING keyword 22 | * [Feature] SelectQuery: Offset option 23 | * [Feature] User-function calls: SELECT * FROM my_custom_function(%s, %s, %s) 24 | 25 | 26 | 0.1.0 (2016-03-11) 27 | -------------------- 28 | * Initial release 29 | * [Feature] very basic SelectQuery and InsertQuery functionality -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # System variables 2 | VIRTUAL_ENV=$(CURDIR)/.env 3 | PYTHON=$(VIRTUAL_ENV)/bin/python 4 | PY_VERSION=python3 5 | 6 | help: 7 | # target: help - Display callable targets 8 | @grep -e "^# target:" [Mm]akefile | sed -e 's/^# target: //g' 9 | 10 | 11 | .PHONY: clean 12 | # target: clean - Display callable targets 13 | clean: 14 | rm -rf build/ dist/ docs/_build *.egg-info \.coverage 15 | find $(CURDIR) -name "*.py[co]" -delete 16 | find $(CURDIR) -name "*.orig" -delete 17 | find $(CURDIR)/$(MODULE) -name "__pycache__" | xargs rm -rf 18 | 19 | 20 | .PHONY: env 21 | env: 22 | # target: env - create virtualenv and install packages 23 | @virtualenv --python=$(PY_VERSION) $(VIRTUAL_ENV) 24 | @$(VIRTUAL_ENV)/bin/pip install -r $(CURDIR)/requirements-test.txt 25 | 26 | 27 | # =============== 28 | # Test commands 29 | # =============== 30 | 31 | .PHONY: test 32 | test: env 33 | # target: test - Run tests 34 | tox 35 | 36 | 37 | # =============== 38 | # Build package 39 | # =============== 40 | 41 | .PHONY: register 42 | # target: register - Register package on PyPi 43 | register: 44 | @$(VIRTUAL_ENV)/bin/python setup.py register 45 | 46 | 47 | .PHONY: upload 48 | upload: clean 49 | # target: upload - Upload package on PyPi 50 | @$(VIRTUAL_ENV)/bin/pip install wheel 51 | @$(PYTHON) setup.py bdist_wheel upload -r pypi 52 | -------------------------------------------------------------------------------- /tests/test_tokens.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import pytest 3 | from datetime import datetime 4 | 5 | from md_changelog import tokens 6 | 7 | 8 | @pytest.fixture 9 | def header_line(): 10 | return '0.1.0 [2016-03-11]' 11 | 12 | 13 | def test_version_token(header_line): 14 | """Test header tokens. It includes Version, Date tokens 15 | 16 | """ 17 | v = tokens.Version.parse(raw_text=header_line) 18 | 19 | assert isinstance(v, tokens.Version) 20 | assert v.eval() == '0.1.0' 21 | 22 | 23 | def test_date_token(header_line): 24 | d = tokens.Date.parse(raw_text=header_line) 25 | 26 | assert isinstance(d, tokens.Date) 27 | assert isinstance(d.dt, datetime) 28 | assert d.eval() == '2016-03-11' 29 | 30 | # Test init options 31 | 32 | # Expect that datetime.now() 33 | d = tokens.Date() 34 | assert isinstance(d.dt, datetime) 35 | 36 | # '' value indicates 'unreleased' 37 | d = tokens.Date(dt='') 38 | assert d.dt is None 39 | assert str(d) == tokens.Date.UNRELEASED 40 | 41 | 42 | def test_message(): 43 | message = tokens.Message(text='Test commit') 44 | assert message.eval() == 'Test commit' 45 | 46 | message = tokens.Message(text='Test commit', 47 | message_type=tokens.TYPES.bugfix) 48 | assert message.eval() == '[Bugfix] Test commit' 49 | 50 | message = '* Test commit' 51 | message = tokens.Message.parse(message) 52 | assert isinstance(message, tokens.Message) 53 | assert message._type == tokens.TYPES.message 54 | assert message._text == 'Test commit' 55 | 56 | message = '* [Bugfix] Test commit' 57 | message = tokens.Message.parse(message) 58 | assert isinstance(message, tokens.Message) 59 | assert message._type == tokens.TYPES.bugfix 60 | assert message._text == 'Test commit' -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | md-changelog 2 | ============ 3 | [![Build Status](https://travis-ci.org/prawn-cake/md-changelog.svg?branch=master)](https://travis-ci.org/prawn-cake/md-changelog) 4 | [![Coverage Status](https://coveralls.io/repos/github/prawn-cake/md-changelog/badge.svg?branch=master)](https://coveralls.io/github/prawn-cake/md-changelog?branch=master) 5 | ![PythonVersions](https://img.shields.io/badge/python-3.4-blue.svg) 6 | 7 | Handy command-line tool for managing changelog for your open source projects. 8 | 9 | 10 | ### TODO: 11 | 12 | * bash auto-complete with https://github.com/kislyuk/argcomplete 13 | * support multiple modes: list (just list of entries) and group (entries grouped by entry types: Features, Bugfixes, Improvements, etc) 14 | * Git post-commit hook integration 15 | * Git tag integration 16 | 17 | 18 | ## Install 19 | 20 | pip3 install md-changelog 21 | 22 | 23 | ## Quickstart 24 | 25 | cd 26 | 27 | md-changelog init # it creates .md-changelog.cfg and Changelog.md in the current folder 28 | 29 | 30 | ### Open with editor 31 | 32 | md-changelog edit 33 | 34 | 35 | ### Add message entry 36 | 37 | md-changelog [--split-by=''] 38 | 39 | # Examples 40 | md-changelog message "My message" 41 | md-changelog improvement "Code cleanup" 42 | md-changelog bugfix "Fixed main loop" 43 | md-changelog feature "Implemented new feature" 44 | md-changelog breaking "Some breaking change" 45 | 46 | # Add multiple entries the same type at once 47 | md-changelog improvement --split-by=';' "Code cleanup; New command-line --split-by key; Improved feature X" 48 | 49 | 50 | Changelog may look like 51 | 52 | Changelog 53 | ========= 54 | 55 | 0.1.0+1 (UNRELEASED) 56 | -------------------- 57 | * My message 58 | * [Improvement] Code cleanup 59 | * [Bugfix] Fixed main loop 60 | * [Feature] Implemented new feature 61 | 62 | 63 | ### Auto-message (not implemented) 64 | 65 | md-changelog auto-message "${some_var_from_git_commit_post_hook}" 66 | 67 | 68 | ### Show last changelog entry 69 | 70 | md-changelog last 71 | 72 | 73 | ### New release 74 | 75 | Release currently unreleased version. 76 | Release assumes to set a release date to the current and update the last version *(basically 0.1.0+1 -> 0.1.1 or 0.2.0, etc)* 77 | 78 | # Open in editor to update version manually 79 | md-changelog release 80 | 81 | # Set the version explicitly 82 | md-changelog release -v 1.0.0 83 | 84 | # Without confirmation dialog 85 | md-changelog release -v 1.0.0 --force-yes 86 | 87 | ### Append new unreleased entry 88 | 89 | md-changelog append 90 | md-changelog append --no-edit # just add a new entry without calling editor 91 | 92 | -------------------------------------------------------------------------------- /tests/test_entry.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os.path as op 3 | import tempfile 4 | 5 | import pytest 6 | 7 | from md_changelog import tokens 8 | from md_changelog.entry import Changelog, LogEntry 9 | from md_changelog.tokens import Message 10 | 11 | FIXTURES_DIR = op.abspath(op.dirname(__file__)) + '/fixtures' 12 | 13 | 14 | def get_fixtures_path(filename): 15 | return '/'.join([FIXTURES_DIR, filename]) 16 | 17 | 18 | def get_fixtures(filename): 19 | with open(get_fixtures_path(filename)) as fd: 20 | content = fd.read() 21 | return content 22 | 23 | 24 | @pytest.fixture 25 | def raw_changelog(): 26 | return get_fixtures('Changelog.md') 27 | 28 | 29 | def test_changelog_parsing(raw_changelog): 30 | entries = Changelog.parse_entries(text=raw_changelog) 31 | assert len(entries) == 2 32 | 33 | assert entries[0].version.released is False 34 | assert str(entries[0].version) == '0.1.0+1' 35 | 36 | assert entries[1].version.released is True 37 | assert str(entries[1].version) == '0.1.0' 38 | 39 | 40 | def test_new_changelog_save(): 41 | with tempfile.NamedTemporaryFile() as tmp_file: 42 | changelog = Changelog(path=tmp_file.name) 43 | 44 | # Create and add log entry manually 45 | entry = LogEntry(version=tokens.Version('0.1.0'), date=tokens.Date()) 46 | entry.add_message(Message(text='Test message')) 47 | entry.add_message(Message(text='Updated tests', 48 | message_type=tokens.TYPES.improvement)) 49 | changelog.add_entry(entry) 50 | 51 | # Add new entry 52 | new_entry = changelog.new_entry() 53 | new_entry.add_message(Message(text='Code refactoring message')) 54 | new_entry.add_message(Message(text='New super-cool feature', 55 | message_type=tokens.TYPES.feature)) 56 | changelog.save() 57 | 58 | # Check idempotency 59 | changelog_2 = Changelog.parse(path=tmp_file.name) 60 | assert len(changelog.entries) == len(changelog_2.entries) 61 | for e1, e2 in zip(changelog.entries, changelog_2.entries): 62 | assert e1.eval() == e2.eval() 63 | 64 | 65 | def test_changelog_backup(): 66 | with tempfile.NamedTemporaryFile() as tmp_file: 67 | changelog = Changelog(path=tmp_file.name) 68 | assert len(changelog.entries) == 0 69 | 70 | # Test undo new entry 71 | changelog.new_entry() 72 | assert len(changelog.entries) == 1 73 | 74 | assert changelog.undo() is True 75 | assert len(changelog.entries) == 0 76 | 77 | # Add new entry + one entry manually, then undo the last one 78 | new_entry = changelog.new_entry() 79 | entry = LogEntry(version=tokens.Version('0.1.0'), date=tokens.Date()) 80 | entry.add_message(Message(text='Test message')) 81 | changelog.add_entry(entry) 82 | assert len(changelog.entries) == 2 83 | 84 | assert changelog.undo() is True 85 | assert len(changelog.entries) == 1 86 | assert changelog.entries[0] == new_entry 87 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os.path as op 3 | import tempfile 4 | from contextlib import contextmanager 5 | 6 | import mock 7 | import pytest 8 | 9 | from md_changelog import main 10 | from md_changelog.entry import Changelog 11 | from md_changelog.exceptions import ConfigNotFoundError 12 | 13 | 14 | @pytest.fixture 15 | def parser(): 16 | return main.create_parser() 17 | 18 | 19 | @contextmanager 20 | def get_test_config(): 21 | parser = main.create_parser() 22 | with tempfile.TemporaryDirectory() as tmp_dir: 23 | args = parser.parse_args(['init', '--path', tmp_dir]) 24 | args.func(args) 25 | cfg_path = op.join(tmp_dir, main.CONFIG_NAME) 26 | yield cfg_path 27 | 28 | 29 | def test_get_config(): 30 | with pytest.raises(ConfigNotFoundError) as err: 31 | main.get_config('/tmp/not_exists.cfg') 32 | assert 'Config is not found' in str(err) 33 | 34 | 35 | def test_init(parser): 36 | with tempfile.TemporaryDirectory() as tmp_dir: 37 | args = parser.parse_args(['init', '--path', tmp_dir]) 38 | args.func(args) 39 | 40 | # Check config and default parameters 41 | cfg_path = op.join(tmp_dir, main.CONFIG_NAME) 42 | changelog_path = op.join(tmp_dir, main.CHANGELOG_NAME) 43 | op.isfile(cfg_path) 44 | config = main.get_config(cfg_path) 45 | assert len(config.keys()) == 2 46 | assert config['md-changelog']['changelog'] == changelog_path 47 | assert config['md-changelog']['vcs'] == 'git' 48 | 49 | # Check changelog and default content 50 | assert op.isfile(changelog_path) 51 | with open(changelog_path) as fd: 52 | content = fd.read() 53 | assert content == main.INIT_TEMPLATE 54 | 55 | 56 | def test_add_message(parser): 57 | with get_test_config() as cfg_path: 58 | config = main.get_config(cfg_path) 59 | args = parser.parse_args(['-c', cfg_path, 'message', 'test message']) 60 | args.func(args) 61 | 62 | args = parser.parse_args( 63 | ['-c', cfg_path, 'improvement', 'test improvement']) 64 | args.func(args) 65 | 66 | args = parser.parse_args(['-c', cfg_path, 'feature', 'test feature']) 67 | args.func(args) 68 | 69 | # check unicode message 70 | args = parser.parse_args(['-c', cfg_path, 'bugfix', 'багфикс']) 71 | args.func(args) 72 | 73 | changelog = Changelog.parse(path=config['md-changelog']['changelog']) 74 | assert len(changelog.entries) == 1 75 | assert len(changelog.entries[0]._messages) == 4 76 | 77 | 78 | def test_release(parser): 79 | with mock.patch('subprocess.call') as call_mock: 80 | # Without version specification 81 | with get_test_config() as cfg_path: 82 | args = parser.parse_args( 83 | ['-c', cfg_path, 'release']) 84 | with mock.patch('md_changelog.main.get_input', return_value='Y') \ 85 | as input_mock: 86 | args.func(args) 87 | assert input_mock.called 88 | assert call_mock.called is True 89 | args = call_mock.call_args_list[0][0][0] 90 | assert main.default_editor() in args 91 | assert main.CHANGELOG_NAME in args[1] 92 | 93 | # With version '-v' 94 | with get_test_config() as cfg_path: 95 | args = parser.parse_args( 96 | ['-c', cfg_path, 'release', '-v', '1.0.0']) 97 | with mock.patch('md_changelog.main.get_input', return_value='Y') \ 98 | as input_mock: 99 | args.func(args) 100 | assert input_mock.called 101 | assert call_mock.called is True 102 | args = call_mock.call_args_list[0][0][0] 103 | assert main.default_editor() in args 104 | assert main.CHANGELOG_NAME in args[1] 105 | 106 | 107 | def test_show_last(parser): 108 | # Just expect no errors 109 | with get_test_config() as cfg_path: 110 | args = parser.parse_args(['-c', cfg_path, 'last']) 111 | args.func(args) 112 | -------------------------------------------------------------------------------- /md_changelog/tokens.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import abc 3 | import re 4 | from collections import namedtuple 5 | 6 | from datetime import datetime 7 | 8 | from md_changelog.exceptions import WrongMessageTypeError 9 | 10 | 11 | class Evaluable(object): 12 | def eval(self): 13 | """This method should generally evaluate the value of it's holder. 14 | In most cases it should return some markdown string 15 | """ 16 | raise NotImplementedError() 17 | 18 | 19 | class Token(Evaluable): 20 | """Token interface""" 21 | 22 | def parse(self, raw_text): 23 | raise NotImplementedError() 24 | 25 | def __str__(self): 26 | return str(self.eval()) 27 | 28 | 29 | class Version(Token): 30 | """Version token""" 31 | 32 | VERSION_RE = re.compile( 33 | r'(?P\d+)\.(?P\d+)\.(?P\d+)(?P\+\d+)?') 34 | 35 | def __init__(self, version_str, matcher=None): 36 | self.version_str = version_str 37 | self._version_dict = {} 38 | if not matcher: 39 | matcher = self.VERSION_RE.search(version_str) 40 | self._version_dict = matcher.groupdict() 41 | self._version_tuple = (self._version_dict['major'], 42 | self._version_dict['minor'], 43 | self._version_dict['patch']) 44 | 45 | @classmethod 46 | def parse(cls, raw_text): 47 | matcher = cls.VERSION_RE.search(raw_text) 48 | if not matcher: 49 | return None 50 | return cls(matcher.group(), matcher=matcher) 51 | 52 | @property 53 | def released(self): 54 | return not self._version_dict.get('suffix') 55 | 56 | def eval(self): 57 | return self.version_str 58 | 59 | def __repr__(self): 60 | return '%s(version_str=%s, released=%s)' % ( 61 | self.__class__.__name__, self.version_str, self.released) 62 | 63 | def __gt__(self, other): 64 | return self._version_tuple > other._version_tuple 65 | 66 | def __lt__(self, other): 67 | return self._version_tuple < other._version_tuple 68 | 69 | def __ge__(self, other): 70 | return self._version_tuple >= other._version_tuple 71 | 72 | def __le__(self, other): 73 | return self._version_tuple <= other._version_tuple 74 | 75 | def __eq__(self, other): 76 | return self._version_tuple == other._version_tuple 77 | 78 | 79 | class Date(Token): 80 | """Date token""" 81 | 82 | UNRELEASED = 'UNRELEASED' 83 | DATE_RE = re.compile(r'((\d{4})\-(\d{2})\-(\d{2})|%s)' % UNRELEASED) 84 | DATE_FMT = '%Y-%m-%d' 85 | 86 | def __init__(self, dt=None): 87 | if dt is None: 88 | self.dt = datetime.now() 89 | elif dt in ('', self.UNRELEASED): # UNRELEASED can come from parse 90 | self.dt = None 91 | elif isinstance(dt, str): 92 | self.dt = datetime.strptime(dt, self.DATE_FMT) 93 | elif isinstance(dt, datetime): 94 | self.dt = dt 95 | else: 96 | raise ValueError('Wrong datetime value %r for Date token' % dt) 97 | 98 | def is_set(self): 99 | return isinstance(self.dt, datetime) 100 | 101 | @classmethod 102 | def parse(cls, raw_text): 103 | matcher = cls.DATE_RE.search(raw_text) 104 | if not matcher: 105 | return None 106 | return cls(dt=matcher.group()) 107 | 108 | def eval(self): 109 | if self.dt: 110 | return self.dt.strftime(self.DATE_FMT) 111 | return self.UNRELEASED 112 | 113 | def __repr__(self): 114 | return '%s(%s)' % (self.__class__.__name__, self.dt) 115 | 116 | def __eq__(self, other): 117 | return self.dt == other.dt 118 | 119 | def __gt__(self, other): 120 | return self.dt > other.dt 121 | 122 | def __lt__(self, other): 123 | return self.dt < other.dt 124 | 125 | def __ge__(self, other): 126 | return self.dt >= other.dt 127 | 128 | def __le__(self, other): 129 | return self.dt <= other.dt 130 | 131 | 132 | # Declare message type namedtuple 133 | message_t = namedtuple('MESSAGE_TYPES', ['message', 134 | 'feature', 135 | 'bugfix', 136 | 'improvement', 137 | 'breaking']) 138 | TYPES = message_t(message='', 139 | feature='Feature', 140 | bugfix='Bugfix', 141 | improvement='Improvement', 142 | breaking='Breaking') 143 | 144 | 145 | class Message(Token): 146 | """Changelog entry message""" 147 | 148 | MD_TEMPLATE = '{type} {text}' 149 | MESSAGE_RE = re.compile(r'^\* (?P\[\w+\])? ?(?P.*$)') 150 | 151 | def __init__(self, text, message_type=None): 152 | if not message_type: 153 | self._type = TYPES.message 154 | else: 155 | self._type = message_type 156 | self._text = text 157 | 158 | def eval(self): 159 | if self._type == TYPES.message: 160 | return self._text 161 | return self.MD_TEMPLATE.format(type=self.format_type(self._type), 162 | text=self._text) 163 | 164 | @staticmethod 165 | def format_type(val): 166 | return '[{}]'.format(val) 167 | 168 | @staticmethod 169 | def deformat_type(val): 170 | if val: 171 | val = str(val).replace('[', '').replace(']', '').lower() 172 | return val 173 | 174 | @classmethod 175 | def parse(cls, raw_text): 176 | matcher = cls.MESSAGE_RE.search(raw_text) 177 | if not matcher: 178 | return None 179 | 180 | values_dict = matcher.groupdict() 181 | if values_dict['type'] is None: 182 | values_dict['type'] = 'message' 183 | else: 184 | values_dict['type'] = cls.deformat_type(values_dict['type']) 185 | 186 | try: 187 | message_type = getattr(TYPES, str(values_dict['type'])) 188 | except AttributeError: 189 | raise WrongMessageTypeError( 190 | 'Wrong message type: %s' % values_dict['type']) 191 | instance = cls(text=str(values_dict['message']), 192 | message_type=message_type) 193 | return instance 194 | 195 | def __repr__(self): 196 | return '%s(type=%s, text=%s)' % ( 197 | self.__class__.__name__, self._type, self._text) 198 | -------------------------------------------------------------------------------- /md_changelog/entry.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import abc 3 | import copy 4 | import re 5 | 6 | from md_changelog import tokens 7 | from md_changelog.exceptions import ChangelogError 8 | from md_changelog.tokens import Version, Date, Message 9 | 10 | 11 | class Evaluable(object): 12 | """Evaluable interface class. Just indicate that class has .eval() method 13 | """ 14 | 15 | MD_TEMPLATE = '' 16 | 17 | @abc.abstractmethod 18 | def eval(self): 19 | """This method should generally evaluate the value of it's holder. 20 | In most cases it should return some markdown string 21 | """ 22 | pass 23 | 24 | 25 | class LogEntry(Evaluable): 26 | """Changelog log entry representation""" 27 | 28 | def __init__(self, version=None, date=None): 29 | self._version = version 30 | self._date = date 31 | self._messages = [] 32 | 33 | @property 34 | def declared(self): 35 | """Log entry is declared only in case of version and date is defined 36 | 37 | :return: 38 | """ 39 | if all([self._version, self._date]): 40 | return True 41 | else: 42 | if self._version and not self._version.released: 43 | return True 44 | return False 45 | 46 | @property 47 | def version(self): 48 | return self._version 49 | 50 | def eval(self): 51 | header = self.header 52 | text_tokens = (header, 53 | '-' * len(header), 54 | '\n'.join(['* {}'.format(entry.eval()) 55 | for entry in self._messages])) 56 | return '\n'.join(text_tokens) 57 | 58 | def add_message(self, message): 59 | if not isinstance(message, tokens.Message): 60 | raise ValueError('Wrong message type %r, must be %s' 61 | % (message, tokens.Message)) 62 | self._messages.append(message) 63 | 64 | def set_version(self, version): 65 | cond = (self._version is None, 66 | self.version and not self.version.released) 67 | if any(cond): 68 | self._version = version 69 | else: 70 | raise ChangelogError( 71 | "Can't add version because it's already exists") 72 | 73 | def set_date(self, date): 74 | cond = (self._date is None, 75 | self._date and not self._date.is_set(), 76 | not self.version.released) 77 | if any(cond): 78 | self._date = date 79 | else: 80 | raise ChangelogError( 81 | "Can't add date because it's already exists") 82 | 83 | @property 84 | def header(self): 85 | return '{version} ({date})'.format(version=self._version.eval(), 86 | date=self._date.eval()) 87 | 88 | @staticmethod 89 | def is_header(line): 90 | v = Version.parse(line) 91 | dt = Date.parse(line) 92 | if all([v, dt]): 93 | return True 94 | elif any([v, dt]): 95 | # Accept unreleased header 96 | if v and not v.released: 97 | return True 98 | raise ChangelogError( 99 | 'Broken header %s. Version and date must be presented' % line) 100 | return False 101 | 102 | @staticmethod 103 | def is_message(line): 104 | return bool(Message.parse(line)) 105 | 106 | def __repr__(self): 107 | return "%s(version=%s, date=%s, declared=%s, messages=%d)" % \ 108 | (self.__class__.__name__, str(self.version), str(self._date), 109 | self.declared, len(self._messages)) 110 | 111 | def __eq__(self, other): 112 | return all([self.version == other.version, 113 | self._date == other._date, 114 | self._messages == other._messages]) 115 | 116 | 117 | class Changelog(object): 118 | """Changelog representation""" 119 | 120 | IGNORE_LINES_RE = re.compile(r'([-=]{3,})') # ----, === 121 | INIT_VERSION = '0.1.0' 122 | 123 | def __init__(self, path, entries=None): 124 | self.header = 'Changelog' 125 | self.path = path 126 | self.entries = entries or [] 127 | self._backup = None 128 | 129 | @property 130 | def last_entry(self): 131 | if not self.entries: 132 | return None 133 | return self.entries[-1] 134 | 135 | @property 136 | def versions(self): 137 | if not self.entries: 138 | return None 139 | return [entry.version for entry in self.entries] 140 | 141 | @classmethod 142 | def parse(cls, path): 143 | """Parse changelog 144 | 145 | :param path: str 146 | :return: Changelog instance 147 | """ 148 | with open(path) as fd: 149 | content = fd.read() 150 | entries = cls.parse_entries(text=content) 151 | instance = Changelog(path=path, entries=entries[::-1]) 152 | return instance 153 | 154 | @classmethod 155 | def parse_entries(cls, text): 156 | """Parse text into log entries 157 | 158 | :param text: str: raw changelog text 159 | :rtype: list 160 | """ 161 | entries = [] 162 | stack = [] 163 | log_entry = None 164 | for line in text.splitlines(): 165 | # Skip comments or empty lines 166 | if line.startswith('#') or not line: 167 | continue 168 | if cls.IGNORE_LINES_RE.search(line): 169 | continue 170 | 171 | if LogEntry.is_header(line): 172 | log_entry = LogEntry() 173 | stack.append(log_entry) 174 | if not log_entry.declared: 175 | # New log entry 176 | log_entry.set_version(Version.parse(line)) 177 | log_entry.set_date(Date.parse(line)) 178 | else: 179 | entries.append(log_entry) 180 | stack.pop() 181 | 182 | log_entry = LogEntry() 183 | stack.append(log_entry) 184 | elif log_entry and log_entry.declared and LogEntry.is_message(line): 185 | # parse messages only after log header is declared 186 | log_entry.add_message(Message.parse(line)) 187 | else: 188 | continue 189 | if stack: 190 | entries.extend(stack) 191 | return entries 192 | 193 | def new_entry(self): 194 | """Create and add new unreleased log entry 195 | 196 | :return: LogEntry instance 197 | """ 198 | self.make_backup() 199 | log_entry = LogEntry() 200 | if len(self.entries) == 0: 201 | last_version = self.INIT_VERSION 202 | else: 203 | last_version = str(self.last_entry.version) 204 | 205 | v = Version(version_str='%s+1' % last_version) 206 | log_entry.set_version(version=v) 207 | log_entry.set_date(date=Date(dt='')) # set Date as unreleased 208 | self.entries.append(log_entry) 209 | return log_entry 210 | 211 | def add_entry(self, entry): 212 | if not isinstance(entry, LogEntry): 213 | raise ValueError('Wrong entry type %r, must be %s' 214 | % (entry, LogEntry)) 215 | self.make_backup() 216 | self.entries.append(entry) 217 | 218 | def save(self): 219 | """Save and sync changes 220 | """ 221 | with open(self.path, 'w') as fd: 222 | fd.write(self.eval()) 223 | 224 | def reload(self): 225 | """Reload changelog within the same instance 226 | 227 | """ 228 | reloaded = self.parse(self.path) 229 | self.__dict__ = copy.deepcopy(reloaded.__dict__) 230 | 231 | def undo(self): 232 | if self._backup: 233 | self.__dict__ = copy.deepcopy(self._backup.__dict__) 234 | return True 235 | return False 236 | 237 | def make_backup(self): 238 | """Make deep copy of itself 239 | """ 240 | self._backup = copy.deepcopy(self) 241 | 242 | def __repr__(self): 243 | return "%s(entries=%d)" % (self.__class__.__name__, len(self.entries)) 244 | 245 | def eval(self): 246 | lines = ['Changelog\n=========\n\n'] 247 | lines.append( 248 | '\n\n'.join([entry.eval() for entry in reversed(self.entries)])) 249 | lines.append('\n\n') 250 | return ''.join(lines) 251 | 252 | def __eq__(self, other): 253 | return self.eval() == other.eval() 254 | -------------------------------------------------------------------------------- /md_changelog/main.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import argparse 4 | import configparser 5 | import functools 6 | import logging 7 | import os 8 | import os.path as op 9 | import subprocess 10 | 11 | import sys 12 | 13 | from md_changelog import tokens 14 | from md_changelog.entry import Changelog 15 | from md_changelog.exceptions import ConfigNotFoundError 16 | 17 | logging.basicConfig( 18 | level=logging.DEBUG, 19 | # format='%(asctime)s %(levelname)s [%(name)s] %(message)s', 20 | format='--> %(message)s', 21 | ) 22 | 23 | logger = logging.getLogger('md-changelog') 24 | CHANGELOG_NAME = 'Changelog.md' 25 | INIT_TEMPLATE = 'Changelog\n' \ 26 | '=========\n\n' \ 27 | '%s+1 (UNRELEASED)\n' \ 28 | '--------------------' % Changelog.INIT_VERSION 29 | CONFIG_NAME = '.md-changelog.cfg' 30 | DEFAULT_VCS = 'git' 31 | 32 | 33 | def handler(fn): 34 | """Helper decorator for command-line handler functions 35 | 36 | :param fn: decorated function 37 | :return: 38 | """ 39 | 40 | @functools.wraps 41 | def wrapper(args): 42 | config = get_config(path=args.config) 43 | res = fn(args, config) 44 | return res 45 | return wrapper 46 | 47 | 48 | def default_editor(): 49 | return os.getenv('EDITOR', 'vi') 50 | 51 | 52 | def init(args): 53 | """Init new changelog and config 54 | 55 | """ 56 | 57 | def init_changelog(path): 58 | file_path = op.join(op.abspath(path), CHANGELOG_NAME) 59 | if op.exists(file_path): 60 | logger.info('Changelog %s already exist. Skip', file_path) 61 | return False 62 | logger.debug('Init changelog: %s', file_path) 63 | with open(file_path, 'w') as fd: 64 | fd.write(INIT_TEMPLATE) 65 | logger.debug('See `md-changelog -h` for more info') 66 | return True 67 | 68 | def init_config(path): 69 | cfg_path = op.join(op.abspath(path), CONFIG_NAME) 70 | changelog_path = op.join(op.abspath(path), CHANGELOG_NAME) 71 | if op.exists(cfg_path): 72 | logger.info('Config %s already exist. Skip', path) 73 | return False 74 | # Write config 75 | config = configparser.ConfigParser() 76 | config['md-changelog'] = { 77 | 'changelog': changelog_path, 78 | 'vcs': DEFAULT_VCS 79 | } 80 | 81 | logger.info('Writing config %s', cfg_path) 82 | with open(cfg_path, 'w') as fd: 83 | config.write(fd) 84 | return True 85 | 86 | if args.path: 87 | if op.isdir(args.path): 88 | path = args.path 89 | init_changelog(path=path) 90 | else: 91 | raise ValueError('Wrong project path %s' % args.path) 92 | else: 93 | path = './' 94 | init_changelog(path=path) 95 | 96 | init_config(path) 97 | 98 | 99 | def get_config(path=None): 100 | if path is not None: 101 | cfg_path = path 102 | else: 103 | cfg_path = op.join(op.abspath('.'), CONFIG_NAME) 104 | if not op.exists(cfg_path): 105 | raise ConfigNotFoundError('Config is not found: %s' % path) 106 | 107 | config = configparser.ConfigParser() 108 | config.read(cfg_path) 109 | return config 110 | 111 | 112 | def get_changelog(config_path): 113 | """Changelog getter 114 | 115 | :param config_path: str: path to config 116 | :return: md_changelog.entry.Changelog instance 117 | """ 118 | config = get_config(path=config_path) 119 | changelog_path = config['md-changelog']['changelog'] 120 | return Changelog.parse(path=changelog_path) 121 | 122 | 123 | def get_input(text): 124 | """Get input wrapper. It basically needs for unittests 125 | 126 | :param text: str 127 | :return: input result 128 | """ 129 | return input(text) 130 | 131 | 132 | def release(args): 133 | """Make a new release 134 | 135 | :param args: command-line args 136 | """ 137 | 138 | changelog = get_changelog(args.config) 139 | last_entry = changelog.last_entry 140 | if not last_entry: 141 | logger.info('Empty changelog. Nothing to release') 142 | sys.exit(99) 143 | 144 | if last_entry.version.released: 145 | logger.info("No UNRELEASED entries. Run 'md-changelog append'") 146 | sys.exit(99) 147 | 148 | if args.version: 149 | # Set up specific version 150 | v = tokens.Version(args.version) 151 | last_v = last_entry.version 152 | if v <= last_v: 153 | logger.info('Version must be greater than the last one: ' 154 | '%s <= %s (last one)', v, last_v) 155 | sys.exit(99) 156 | else: 157 | last_entry.set_version(v) 158 | 159 | # Backup before release 160 | changelog.make_backup() 161 | 162 | last_entry.set_date(tokens.Date()) 163 | changelog.save() 164 | 165 | # Skip this step if --force-yes is passed 166 | if not args.force_yes: 167 | subprocess.call([default_editor(), changelog.path]) 168 | confirm = get_input('Confirm changes? [Y/n]') 169 | if confirm == 'n': 170 | res = changelog.undo() 171 | logger.info('Undo changes: %s', 'OK' if res else 'Fail') 172 | changelog.save() 173 | sys.exit(0) 174 | 175 | changelog.reload() 176 | if not changelog.last_entry.version.released: 177 | logger.warning( 178 | "WARNING: version still contains dev suffix: %s. " 179 | "Run 'md-changelog edit' to fix it", 180 | changelog.last_entry.version) 181 | 182 | if len(changelog.entries) > 1: 183 | v_cur = changelog.last_entry.version 184 | v_prev = changelog.entries[-2].version 185 | if v_cur <= v_prev: 186 | logger.warning( 187 | "WARNING: wrong release version, less or equal " 188 | "the previous one: %s (current) <= %s (previous). " 189 | "Run 'md-changelog edit' to fix it", 190 | v_cur, v_prev) 191 | 192 | 193 | def append_entry(args): 194 | """Append new changelog entry 195 | 196 | :param args: command-line args 197 | """ 198 | changelog = get_changelog(args.config) 199 | last_entry = changelog.last_entry 200 | if last_entry and not last_entry.version.released: 201 | logger.info('Changelog has contained UNRELEASED entry. ' 202 | 'Make a release before appending a new one') 203 | sys.exit(99) 204 | changelog.new_entry() 205 | changelog.save() 206 | if not args.no_edit: 207 | subprocess.call([default_editor(), changelog.path]) 208 | logger.info("Added new '%s' entry", changelog.last_entry.header) 209 | 210 | 211 | def edit(args): 212 | """Open changelog in the editor""" 213 | config = get_config(path=args.config) 214 | changelog_path = config['md-changelog']['changelog'] 215 | editor = default_editor() 216 | logger.info('Call: %s %s', editor, changelog_path) 217 | subprocess.call([editor, changelog_path]) 218 | 219 | 220 | def add_message(args): 221 | """Add message to unreleased 222 | 223 | :param args: command-line args 224 | """ 225 | changelog = get_changelog(args.config) 226 | m_type = getattr(tokens.TYPES, args.message_type) 227 | messages = [] 228 | if args.split_by: 229 | messages = [tokens.Message(text=msg.strip(), message_type=m_type) 230 | for msg in args.message.split(args.split_by)] 231 | else: 232 | messages.append(tokens.Message(text=args.message, message_type=m_type)) 233 | 234 | if not changelog.last_entry or changelog.last_entry.version.released: 235 | new_entry = changelog.new_entry() 236 | for msg in messages: 237 | new_entry.add_message(msg) 238 | else: 239 | for msg in messages: 240 | changelog.last_entry.add_message(msg) 241 | changelog.save() 242 | 243 | logger.info('Added new %d %s entry to the %s (%s)', 244 | len(messages), 245 | args.message_type, 246 | op.relpath(changelog.path), 247 | str(changelog.last_entry.version)) 248 | 249 | 250 | def show_last(args): 251 | """Show the last changelog log entry 252 | 253 | :param args: command-line args 254 | """ 255 | changelog = get_changelog(args.config) 256 | print('\n%s\n' % changelog.last_entry.eval()) 257 | 258 | 259 | def create_parser(): 260 | parser = argparse.ArgumentParser( 261 | description='md-changelog command-line tool') 262 | parser.add_argument('-c', '--config', help='Path to config file') 263 | 264 | subparsers = parser.add_subparsers(help='Sub-commands') 265 | 266 | # Add clone command parser and parameters 267 | init_p = subparsers.add_parser('init', help='Init new changelog') 268 | init_p.add_argument('--path', help='Path to project directory') 269 | init_p.set_defaults(func=init) 270 | 271 | release_p = subparsers.add_parser( 272 | 'release', help='Release current version') 273 | release_p.add_argument('-v', '--version', help='New release version') 274 | release_p.add_argument('-y', '--force-yes', action='store_true', 275 | help="Don't ask changes confirmation") 276 | release_p.set_defaults(func=release) 277 | 278 | append_p = subparsers.add_parser( 279 | 'append', help='Append a new changelog entry') 280 | append_p.add_argument('--no-edit', help="Don't call text editor after run", 281 | action='store_true') 282 | append_p.set_defaults(func=append_entry) 283 | 284 | # Message parsers 285 | for m_type in list(tokens.TYPES._asdict().keys()): 286 | msg_p = subparsers.add_parser( 287 | m_type, help='Add new %s entry to the current release' % m_type) 288 | msg_p.add_argument('message', help='Enter text message here') 289 | msg_p.add_argument('--split-by', type=str, 290 | help='Split message into several and add it as ' 291 | 'multiple entries') 292 | msg_p.set_defaults(func=add_message, message_type=m_type) 293 | 294 | # Open in an editor command 295 | edit_p = subparsers.add_parser('edit', help='Open changelog in the editor') 296 | edit_p.set_defaults(func=edit) 297 | 298 | last_p = subparsers.add_parser('last', help='Show last log entry') 299 | last_p.set_defaults(func=show_last) 300 | 301 | return parser 302 | 303 | 304 | def main(): 305 | parser = create_parser() 306 | args = parser.parse_args() 307 | args.func(args) 308 | 309 | 310 | if __name__ == '__main__': 311 | main() 312 | --------------------------------------------------------------------------------