pax_global_header00006660000000000000000000000064143210461410014506gustar00rootroot0000000000000052 comment=8c81fd7984b5aae4ff288477a094963734ce0ab3 changelogd-0.1.7/000077500000000000000000000000001432104614100136065ustar00rootroot00000000000000changelogd-0.1.7/.editorconfig000066400000000000000000000004721432104614100162660ustar00rootroot00000000000000# http://editorconfig.org root = true [*] indent_style = space indent_size = 4 trim_trailing_whitespace = false insert_final_newline = true charset = utf-8 end_of_line = lf max_line_length = 89 [*.bat] indent_style = tab end_of_line = crlf [LICENSE] insert_final_newline = false [Makefile] indent_style = tab changelogd-0.1.7/.github/000077500000000000000000000000001432104614100151465ustar00rootroot00000000000000changelogd-0.1.7/.github/ISSUE_TEMPLATE.md000066400000000000000000000005011432104614100176470ustar00rootroot00000000000000* changelogd version: * Python version: * Operating System: ### Description Describe what you were trying to get done. Tell us what happened, what went wrong, and what you expected to happen. ### What I Did ``` Paste the command(s) you ran and the output. If there was a crash, please include the traceback here. ``` changelogd-0.1.7/.github/workflows/000077500000000000000000000000001432104614100172035ustar00rootroot00000000000000changelogd-0.1.7/.github/workflows/ci.yml000066400000000000000000000036741432104614100203330ustar00rootroot00000000000000# This workflow will install Python dependencies, run tests and lint with a variety of Python versions # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions name: changelogd-ci on: push: branches: - master pull_request: branches: - master jobs: test: runs-on: ${{ matrix.platform }} strategy: max-parallel: 4 matrix: platform: [ ubuntu-latest, macos-latest, windows-latest ] python-version: [ 3.6, 3.7, 3.8, 3.9, '3.10', '3.11-dev' ] steps: - uses: actions/checkout@v2 - name: Setup python uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} architecture: x64 - name: Setup Nox uses: aklajnert/setup-nox@v2.0.1 - name: Run tests if: matrix.python-version != '3.11-dev' run: | nox -s tests-${{ matrix.python-version }} --error-on-missing-interpreters env: PLATFORM: ${{ matrix.platform }} - name: Run tests 3.11 if: matrix.python-version == '3.11-dev' run: | nox -s tests-3.11 --error-on-missing-interpreters env: PLATFORM: ${{ matrix.platform }} - name: Update coverage uses: codecov/codecov-action@v1 mypy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Setup Nox uses: aklajnert/setup-nox@v2.0.1 - name: Run mypy run: | nox -s mypy flake8: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Setup Nox uses: aklajnert/setup-nox@v2.0.1 - name: Run flake8 run: | nox -s flake8 check-docs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Setup Nox uses: aklajnert/setup-nox@v2.0.1 - name: Build docs run: | nox -s docs changelogd-0.1.7/.gitignore000066400000000000000000000022411432104614100155750ustar00rootroot00000000000000.idea # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # dotenv .env # virtualenv .venv venv/ ENV/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ changelogd-0.1.7/.pre-commit-config.yaml000066400000000000000000000005601432104614100200700ustar00rootroot00000000000000repos: - repo: https://github.com/psf/black rev: 22.8.0 hooks: - id: black - repo: https://github.com/asottile/reorder_python_imports rev: v3.8.3 hooks: - id: reorder-python-imports - repo: https://github.com/asottile/blacken-docs rev: v1.12.1 hooks: - id: blacken-docs additional_dependencies: [black==19.10b0] changelogd-0.1.7/.readthedocs.yml000066400000000000000000000002361432104614100166750ustar00rootroot00000000000000version: 2 sphinx: configuration: docs/conf.py python: version: 3.7 install: - method: pip path: . extra_requirements: - docs changelogd-0.1.7/CONTRIBUTING.rst000066400000000000000000000067401432104614100162560ustar00rootroot00000000000000.. highlight:: shell ============ Contributing ============ Contributions are welcome, and they are greatly appreciated! Every little bit helps, and credit will always be given. You can contribute in many ways: Types of Contributions ---------------------- Report Bugs ~~~~~~~~~~~ Report bugs at https://github.com/aklajnert/changelogd/issues. If you are reporting a bug, please include: * Your operating system name and version. * Any details about your local setup that might be helpful in troubleshooting. * Detailed steps to reproduce the bug. Fix Bugs ~~~~~~~~ Look through the GitHub issues for bugs. Anything tagged with "bug" and "help wanted" is open to whoever wants to implement it. Implement Features ~~~~~~~~~~~~~~~~~~ Look through the GitHub issues for features. Anything tagged with "enhancement" and "help wanted" is open to whoever wants to implement it. Write Documentation ~~~~~~~~~~~~~~~~~~~ changelogd could always use more documentation, whether as part of the official changelogd docs, in docstrings, or even on the web in blog posts, articles, and such. Submit Feedback ~~~~~~~~~~~~~~~ The best way to send feedback is to file an issue at https://github.com/aklajnert/changelogd/issues. If you are proposing a feature: * Explain in detail how it would work. * Keep the scope as narrow as possible, to make it easier to implement. * Remember that this is a volunteer-driven project, and that contributions are welcome :) Get Started! ------------ Ready to contribute? Here's how to set up `changelogd` for local development. 1. Fork the `changelogd` repo on GitHub. 2. Clone your fork locally:: $ git clone git@github.com:your_name_here/changelogd.git 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: $ mkvirtualenv changelogd $ cd changelogd/ $ python setup.py develop 4. Create a branch for local development:: $ git checkout -b name-of-your-bugfix-or-feature Now you can make your changes locally. 5. When you're done making changes, check that your changes pass flake8 and the tests, including testing other Python versions with tox:: $ flake8 changelogd tests $ python setup.py test or pytest $ tox To get flake8 and tox, just pip install them into your virtualenv. 6. Commit your changes and push your branch to GitHub:: $ git add . $ git commit -m "Your detailed description of your changes." $ git push origin name-of-your-bugfix-or-feature 7. Submit a pull request through the GitHub website. Pull Request Guidelines ----------------------- Before you submit a pull request, check that it meets these guidelines: 1. The pull request should include tests. 2. If the pull request adds functionality, the docs should be updated. Put your new functionality into a function with a docstring, and add the feature to the list in README.rst. 3. The pull request should work for Python 2.7, 3.5, 3.6, 3.7 and 3.8, and for PyPy. Check https://travis-ci.org/aklajnert/changelogd/pull_requests and make sure that the tests pass for all supported Python versions. Tips ---- To run a subset of tests:: $ pytest tests.test_changelogd Deploying --------- A reminder for the maintainers on how to deploy. Make sure all your changes are committed (including an entry in HISTORY.rst). Then run:: $ bump2version patch # possible: major / minor / patch $ git push $ git push --tags Travis will then deploy to PyPI if tests pass. changelogd-0.1.7/HISTORY.rst000066400000000000000000000037701432104614100155100ustar00rootroot00000000000000History ======= 0.1.7 (2022-10-10) ------------------ Minor improvements ~~~~~~~~~~~~~~~~~~ * `#26 `_: Trim whitespace from multi-value fields. Other changes ~~~~~~~~~~~~~ * `#25 `_: Switch to GitHub Actions. 0.1.6 (2022-09-06) ------------------ Features ~~~~~~~~ * `#21 `_: Add support for computed values. Minor improvements ~~~~~~~~~~~~~~~~~~ * `#7 `_: Add a readme file that will be put into the changelogd config directory. Other changes ~~~~~~~~~~~~~ * `#19 `_: Remove invalid pytest option. * `#18 `_: Add support for python 3.9 and 3.10, fix tests. 0.1.5 (2020-01-30) ------------------ Minor improvements ~~~~~~~~~~~~~~~~~~ * `#6 `_: Add __main__.py file to allow invoking via `python -m changelogd`. 0.1.4 (2020-01-24) ------------------ Minor improvements ~~~~~~~~~~~~~~~~~~ * `#5 `_: Save timestamp with entry YAML, so the order won't be affected by simple file modification. * `#4 `_: Display entry title with `Select message type` question. 0.1.3 (2020-01-20) ------------------ Features ~~~~~~~~ * `#2 `_: Allow to control which user data will be saved in entries. * `#3 `_: Automatically add new entries and releases to git. Other changes ~~~~~~~~~~~~~ * `#1 `_: Switch from ``tox`` to ``nox`` for running tests and tasks. 0.1.2 (2020-01-17) ------------------ Bug fixes ~~~~~~~~~ * Fixed missing templates from the ``MANIFEST.in`` 0.1.1 (2020-01-16) ------------------ Initial release changelogd-0.1.7/LICENSE000066400000000000000000000020631432104614100146140ustar00rootroot00000000000000MIT License Copyright (c) 2019, Andrzej Klajnert Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. changelogd-0.1.7/MANIFEST.in000066400000000000000000000004331432104614100153440ustar00rootroot00000000000000include CONTRIBUTING.rst include LICENSE include README.rst include HISTORY.rst recursive-include changelogd/templates * recursive-include tests * recursive-exclude * __pycache__ recursive-exclude * *.py[co] recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif changelogd-0.1.7/README.rst000066400000000000000000000052321432104614100152770ustar00rootroot00000000000000changelogd ========== .. image:: https://img.shields.io/pypi/v/changelogd.svg :target: https://pypi.python.org/pypi/changelogd .. image:: https://dev.azure.com/aklajnert/changelogd/_apis/build/status/aklajnert.changelogd?branchName=master Changelogs without conflicts. * Free software: MIT license * Documentation: https://changelogd.readthedocs.io. Overview -------- Changelogd allows teams to avoid merge conflicts for the changelog files. The ``changelogd`` content is stored within multiple YAML files - one per each changelog entry. Then, during application release, all input files are combined into one release file. The script uses Jinja2 templates to generate one consistent text file out of all input YAML files. The default output format is Markdown, but by modifying the templates it can be changed into any text format you like. Installation ------------ You can install ``changelogd`` via `pip`_ from `PyPI`_:: $ pip install changelogd Quickstart ---------- First, initialize ``changelogd`` configuration. .. code-block:: bash $ changelogd init Created main configuration file: changelog.d\config.yaml Copied templates to changelog.d\templates Then, create changelog entries: .. code-block:: bash $ changelogd entry [1]: Features [feature] [2]: Bug fixes [bug] [3]: Documentation changes [doc] [4]: Deprecations [deprecation] [5]: Other changes [other] > Select message type [1]: 2 > Issue ID: 100 > Changelog message: Changelog message Created changelog entry at changelog.d\bug.a3f13823.entry.yaml Finally, generate changelog file. .. code-block:: bash $ changelogd release version-number > Release description (hit ENTER to omit): This is the initial release. Saved new release data into changelog.d\releases\0.release-name.yaml Generated changelog file to changelog.md Output file: .. code-block:: md # Changelog ## version-number (2020-01-11) This is the initial release. ### Bug fixes * [#100](http://repo/issues/100): Changelog message ([@user](user@example.com)) Documentation ------------- For full documentation, please see https://changelogd.readthedocs.io/en/latest/. License ------- Distributed under the terms of the `MIT`_ license, "changelogd" is free and open source software Issues ------ If you encounter any problems, please `file an issue`_ along with a detailed description. .. _`MIT`: http://opensource.org/licenses/MIT .. _`file an issue`: https://github.com/aklajnert/changelogd/issues .. _`pip`: https://pypi.org/project/pip/ .. _`PyPI`: https://pypi.org/project changelogd-0.1.7/changelog.d/000077500000000000000000000000001432104614100157575ustar00rootroot00000000000000changelogd-0.1.7/changelog.d/config.yaml000066400000000000000000000012731432104614100201130ustar00rootroot00000000000000context: # All variables defined here will be passed into templates pr_url: https://github.com/aklajnert/changelogd/pull message_types: # The order defined below will be preserved in the output changelog file - name: feature title: Features - name: bug title: Bug fixes - name: minor title: Minor improvements - name: doc title: Documentation changes - name: deprecation title: Deprecations - name: other title: Other changes entry_fields: - name: pr_ids verbose_name: PR number type: str required: false multiple: true - name: message verbose_name: Changelog message type: str required: true output_file: ../HISTORY.rst partial_release_name: unreleased user_data: null changelogd-0.1.7/changelog.d/releases/000077500000000000000000000000001432104614100175625ustar00rootroot00000000000000changelogd-0.1.7/changelog.d/releases/.gitkeep000066400000000000000000000000001432104614100212010ustar00rootroot00000000000000changelogd-0.1.7/changelog.d/releases/0.0.1.1.yaml000066400000000000000000000001721432104614100212410ustar00rootroot00000000000000entries: {} previous_release: null release_date: '2020-01-16' release_description: Initial release release_version: 0.1.1 changelogd-0.1.7/changelog.d/releases/1.0.1.2.yaml000066400000000000000000000003011432104614100212350ustar00rootroot00000000000000entries: bug: - message: Fixed missing templates from the ``MANIFEST.in`` pr_ids: null previous_release: 0.1.1 release_date: '2020-01-17' release_description: '' release_version: 0.1.2 changelogd-0.1.7/changelog.d/releases/2.0.1.3.yaml000066400000000000000000000006221432104614100212450ustar00rootroot00000000000000entries: feature: - message: Allow to control which user data will be saved in entries. pr_ids: - '2' - message: Automatically add new entries and releases to git. pr_ids: - '3' other: - message: Switch from ``tox`` to ``nox`` for running tests and tasks. pr_ids: - '1' previous_release: 0.1.2 release_date: '2020-01-20' release_description: '' release_version: 0.1.3 changelogd-0.1.7/changelog.d/releases/3.0.1.4.yaml000066400000000000000000000006111432104614100212450ustar00rootroot00000000000000entries: minor: - message: Save timestamp with entry YAML, so the order won't be affected by simple file modification. pr_ids: - '5' timestamp: 1579857306 - message: Display entry title with `Select message type` question. pr_ids: - '4' timestamp: 1579855508 previous_release: 0.1.3 release_date: '2020-01-24' release_description: '' release_version: 0.1.4 changelogd-0.1.7/changelog.d/releases/4.0.1.5.yaml000066400000000000000000000003641432104614100212540ustar00rootroot00000000000000entries: minor: - message: Add __main__.py file to allow invoking via `python -m changelogd`. pr_ids: - '6' timestamp: 1580362053 previous_release: 0.1.4 release_date: '2020-01-30' release_description: '' release_version: 0.1.5 changelogd-0.1.7/changelog.d/releases/5.0.1.6.yaml000066400000000000000000000010671432104614100212570ustar00rootroot00000000000000entries: feature: - message: Add support for computed values. pr_ids: - '21' timestamp: 1662456941 minor: - message: Add a readme file that will be put into the changelogd config directory. pr_ids: - '7' timestamp: 1582975986 other: - message: Remove invalid pytest option. pr_ids: - '19' timestamp: 1661314815 - message: Add support for python 3.9 and 3.10, fix tests. pr_ids: - '18' timestamp: 1661257022 previous_release: 0.1.5 release_date: '2022-09-06' release_description: '' release_version: 0.1.6 changelogd-0.1.7/changelog.d/releases/6.0.1.7.yaml000066400000000000000000000004741432104614100212620ustar00rootroot00000000000000entries: minor: - message: Trim whitespace from multi-value fields. pr_ids: - '26' timestamp: 1665243991 other: - message: Switch to GitHub Actions. pr_ids: - '25' timestamp: 1664691880 previous_release: 0.1.6 release_date: '2022-10-10' release_description: '' release_version: 0.1.7 changelogd-0.1.7/changelog.d/templates/000077500000000000000000000000001432104614100177555ustar00rootroot00000000000000changelogd-0.1.7/changelog.d/templates/entry.rst000066400000000000000000000002751432104614100216540ustar00rootroot00000000000000* {% if pr_ids is defined and pr_ids -%} {% for pr_id in pr_ids -%} `#{{ pr_id }} <{{ pr_url }}/{{ pr_id }}>`_{% if not loop.last %}, {% endif %} {%- endfor %}: {% endif -%} {{ message }} changelogd-0.1.7/changelog.d/templates/main.rst000066400000000000000000000001111432104614100214240ustar00rootroot00000000000000History ======= {% for release in releases %}{{ release }}{% endfor %} changelogd-0.1.7/changelog.d/templates/release.rst000066400000000000000000000005371432104614100221340ustar00rootroot00000000000000 {{ release_title or release_version }} ({{ release_date }}) {{ "-" * release_version|length }}---{{ "-" * release_date|length }} {% if release_description %} {{ release_description }} {% endif %}{% for group in entry_groups %} {{ group.title }} {{ "~" * group.title|length }} {% for entry in group.entries %}{{ entry }}{% endfor %}{% endfor %} changelogd-0.1.7/changelogd/000077500000000000000000000000001432104614100157015ustar00rootroot00000000000000changelogd-0.1.7/changelogd/__init__.py000066400000000000000000000001271432104614100200120ustar00rootroot00000000000000# -*- coding: utf-8 -*- """Top-level package for changelogd.""" __version__ = "0.1.7" changelogd-0.1.7/changelogd/__main__.py000066400000000000000000000001071432104614100177710ustar00rootroot00000000000000from changelogd.cli import main if __name__ == "__main__": main() changelogd-0.1.7/changelogd/changelogd.py000066400000000000000000000273131432104614100203540ustar00rootroot00000000000000# -*- coding: utf-8 -*- """Main module.""" import csv import datetime import getpass import glob import hashlib import io import json import logging import os import re import sys import typing from collections import defaultdict from pathlib import Path from ruamel.yaml import YAML # type: ignore from .computed_values import ComputedValueProcessor from .config import Config from .config import DEFAULT_USER_DATA from changelogd.resolver import Resolver from changelogd.utils import add_to_git from changelogd.utils import get_git_data yaml = YAML(typ="unsafe") yaml.default_flow_style = False class EntryField: name: str verbose_name: str required: bool multiple: bool def __init__(self, **data: typing.Dict[str, typing.Any]) -> None: self.name = str(data.get("name", "")) if not self.name: logging.error("Each 'entry_fields' element needs to have 'name'.") sys.exit(1) if " " in self.name: logging.error( "The 'name' argument of an 'entry_fields' element cannot contain spaces." ) sys.exit(1) self.verbose_name = str(data.get("verbose_name", "")) self.required = bool(data.get("required", True)) self.multiple = bool(data.get("multiple", False)) @property def value(self) -> typing.Any: value = None while value is None: modifiers = [] if self.required: modifiers.append("required") if self.multiple: modifiers.append("separate multiple values with comma") aux = f" ({', '.join(modifiers)})" if modifiers else "" value = input(f"{self.verbose_name or self.name}{aux}: ") or None if value is None and not self.required: break if value is not None and self.multiple: csv_string = io.StringIO(value) reader = csv.reader(csv_string, delimiter=",") value = [value.strip() for value in next(reader)] return value def _is_int(input: typing.Any) -> bool: try: int(input) return True except (ValueError, TypeError): return False def entry(config: Config, options: typing.Dict[str, typing.Optional[str]]) -> None: data = config.get_data() computed_value_processors = [ ComputedValueProcessor(item) for item in data.get("computed_values", []) ] entry_fields = [EntryField(**entry) for entry in data.get("entry_fields", [])] entry_type = _get_entry_type(data, options) entry = { entry_.name: options.get(entry_.name) or entry_.value for entry_ in entry_fields } entry["type"] = entry_type _add_user_data(entry, config.get_value("user_data", DEFAULT_USER_DATA)) if computed_value_processors: for processor in computed_value_processors: entry.update(processor.get_data()) hash = hashlib.md5() entries_flat = " ".join(f"{key}={value}" for key, value in entry.items()) hash.update(entries_flat.encode()) entry["timestamp"] = int(datetime.datetime.now().timestamp()) output_file = config.path / f"{entry_type}.{hash.hexdigest()[:8]}.entry.yaml" with output_file.open("w") as output_fh: yaml.dump(entry, output_fh) add_to_git(output_file) logging.warning(f"Created changelog entry at {output_file.absolute()}") def _add_user_data( entry: dict, user_data: typing.Union[typing.List[str], None] ) -> None: if not user_data: return data = {} data["os_user"] = getpass.getuser() git_data = get_git_data() if git_data: data["git_user"], data["git_email"] = git_data for key in user_data: source, destination, *_ = key.split(":", maxsplit=1) * 2 if source not in DEFAULT_USER_DATA: sys.exit( f"The '{source}' variable is not supported in 'user_data'. " f"Available choices are: '{', '.join(DEFAULT_USER_DATA)}'." ) entry[destination] = data[source] def _get_entry_type( data: typing.Dict[str, typing.Any], options: typing.Dict[str, typing.Any] ) -> str: message_types = data.get("message_types", []) if not message_types: logging.error("The 'message_types' field is missing from the configuration") sys.exit(1) provided_type: typing.Union[int, str, None] = options.get("type") if provided_type is not None: if _is_int(provided_type): if not _is_in_range(int(provided_type), message_types): sys.exit( f"Given --type has to be positive number, " f"lower than {len(message_types) + 1}" ) return _get_type_name(message_types, provided_type) elif isinstance(provided_type, str): type_names = {type_.get("name") for type_ in message_types} if provided_type not in type_names: sys.exit( f"No such type: '{provided_type}'. " f"Available types: {', '.join(type_names)}" ) return provided_type else: raise TypeError for i, message_type in enumerate(message_types): print(f"\t[{i + 1}]: {message_type.get('title')} [{message_type.get('name')}]") selection = None while not _is_int(selection) or not ( _is_in_range(selection, message_types) # type: ignore ): if selection is not None: print( f"Pick a positive number lower than {len(message_types) + 1}", file=sys.stderr, ) selection = input("Select message type [1]: ") or 1 entry_type = _get_type_name(message_types, selection) # type: ignore return entry_type def _get_type_name( message_types: typing.List[typing.Dict[str, typing.Any]], selection: typing.Union[int, str], ) -> str: return message_types[int(selection) - 1].get("name", "") def _is_in_range( index: int, message_types: typing.List[typing.Dict[str, typing.Any]] ) -> bool: return 0 < int(index) < len(message_types) + 1 def draft(config: Config, version: str) -> None: releases, _ = _read_input_files(config, version) resolver = Resolver(config) draft = resolver.full_resolve(releases) print(draft) def release( version: typing.Optional[str] = None, check: bool = False, partial: bool = False, output: str = "", config: typing.Union[Config, str, None] = None, ) -> None: if config is None: config = Config() elif not isinstance(config, Config): config = Config(config) config.settings["partial"] = partial if version is None: version = config.partial_name releases, entries = _read_input_files(config, version, check) if not config.get_bool_setting("partial"): _save_release_file(config, releases, version) logging.info("Removing old entry files") for entry in entries: os.remove(entry) resolver = Resolver(config) release = resolver.full_resolve(releases) output_path = Path(output) if output else config.output_path if check: with output_path.open("r") as output_fh: previous_content = output_fh.read() with output_path.open("w") as output_fh: output_fh.truncate(0) output_fh.write(release) logging.warning(f"Generated changelog file to {output_path}") if check and previous_content != release: logging.error("Output file content is different than before.") sys.exit(1) def _save_release_file( config: Config, releases: typing.List[typing.Dict[str, typing.Any]], version: str ) -> None: current_release = releases[0] release_id = releases[1]["id"] + 1 if len(releases) > 1 else 0 output_release_path = config.releases_dir / f"{release_id}.{version}.yaml" with output_release_path.open("w") as output_release_fh: yaml.dump(current_release, output_release_fh) logging.warning(f"Saved new release data into {output_release_path}") add_to_git(output_release_path) def _read_input_files( config: Config, version: str, is_checking: bool = False ) -> typing.Tuple[typing.List[typing.Dict[str, typing.Any]], typing.List[str]]: release, entries = _create_new_release(config, version, is_checking) releases = _prepare_releases(release, config.releases_dir) return releases, entries def _prepare_releases( release: typing.Dict, releases_dir: Path ) -> typing.List[typing.Dict]: versions: typing.Dict[int, Path] = dict() for item in os.listdir(releases_dir.as_posix()): match = re.match(r"(\d+).*\.ya?ml", item) if match: version = int(match.group(1)) if version in versions: sys.exit(f"The version {version} is duplicated.") versions[version] = releases_dir / match.group(0) previous_release = None releases = [] for version in sorted(versions.keys()): with versions[version].open() as release_fh: release_item = yaml.load(release_fh) if not release_item: logging.error( f"Release file {versions[version]} is corrupted and will be ignored." ) continue release_item["previous_release"] = previous_release release_item["id"] = version previous_release = release_item.get("release_version") releases.append(release_item) if release: release["previous_release"] = previous_release releases.append(release) return list(reversed(releases)) def _create_new_release( config: Config, version: str, is_checking: bool ) -> typing.Tuple[typing.Dict[str, typing.Any], typing.List[str]]: empty = config.get_bool_setting("empty") partial = config.get_bool_setting("partial") entries = glob.glob(str(config.path.absolute() / "*.entry.yaml")) if not entries and not partial and not empty: logging.error("Cannot create new release without any entries.") sys.exit(1) date = datetime.date.today() if partial and is_checking: date = _get_partial_timestamp(config, entries) release: typing.Dict[str, typing.Any] = { "entries": defaultdict(list), "release_version": version, "release_date": date.strftime("%Y-%m-%d"), "release_description": input("Release description (hit ENTER to omit): ") if not partial else None, } _grab_entries(entries, release) for group_name, items in release["entries"].items(): release["entries"][group_name] = list(_sort_entries(items)) # normalize release by dumping and loading it back via JSON release = json.loads(json.dumps(release)) if not entries and not empty: return {}, [] return release, entries def _grab_entries( entries: typing.List[str], release: typing.Dict[str, typing.Any] ) -> None: for entry_path in entries: with open(entry_path) as entry_file: entry_data = yaml.load(entry_file) timestamp = entry_data.get("timestamp") or os.path.getmtime(entry_path) entry_data["timestamp"] = timestamp release["entries"][entry_data.pop("type")].append(entry_data) def _sort_entries(items: typing.List[typing.Dict]) -> typing.Iterator[typing.Dict]: return reversed(sorted(items, key=lambda x: (x["timestamp"]))) # type: ignore def _get_partial_timestamp( config: Config, entries: typing.List[str] ) -> datetime.datetime: timestamps = [] if config.output_path.is_file(): timestamps.append(os.path.getmtime(config.output_path.as_posix())) for entry in entries: timestamps.append(os.path.getmtime(entry)) timestamps.sort() if not timestamps: return datetime.datetime.today() return datetime.datetime.fromtimestamp(timestamps[-1]) changelogd-0.1.7/changelogd/cli.py000066400000000000000000000010321432104614100170160ustar00rootroot00000000000000# -*- coding: utf-8 -*- """Console script for changelogd.""" import sys import click from . import __version__ from .commands import register_commands @click.group() @click.version_option(version=__version__) @click.pass_context def cli(_: click.core.Context) -> None: """Changelogs without conflicts.""" def main() -> int: """Entrypoint function.""" register_commands(cli) cli(prog_name="changelogd", obj={}, max_content_width=100) return 0 if __name__ == "__main__": sys.exit(main()) # pragma: no cover changelogd-0.1.7/changelogd/commands.py000066400000000000000000000060221432104614100200540ustar00rootroot00000000000000import typing import click from . import changelogd from .config import Config def command_decorator(func: typing.Callable) -> click.core.Command: pass_state = click.make_pass_decorator(Config, ensure=True) verbose = click.option( *("-v", "--verbose"), count=True, help="Increase verbosity.", callback=Config.set_verbosity, # type: ignore ) return click.command()(verbose(pass_state(click.pass_context(func)))) def dynamic_options(func: typing.Callable) -> typing.Callable: output = click.option("--type", help="Message type (as number or string).")(func) try: entry_fields = Config().get_value("entry_fields") except SystemExit: return output for entry_field in entry_fields: name = entry_field.get("name").replace("_", "-") if not name or " " in name: continue kwargs = dict() verbose_name = entry_field.get("verbose_name") if verbose_name: kwargs["help"] = verbose_name output = click.option(f"--{name}", **kwargs)(output) return output @command_decorator @click.option(*("-p", "--path"), help="Custom configuration directory") @click.option("--rst", is_flag=True, help="Use templates in RST format") def init( _: click.core.Context, config: Config, path: typing.Optional[str], rst: bool, **options: typing.Optional[str], ) -> None: """Initialize changelogd config.""" format = "rst" if rst else "md" config.init(path, format) @command_decorator @click.argument("version", required=False) def draft( _: click.core.Context, config: Config, version: str, **options: typing.Optional[str] ) -> None: """Generate draft changelog to stdout.""" if version is None: version = "draft" changelogd.draft(config, version) @command_decorator @click.argument("version") @click.option( "--empty", is_flag=True, help="Do not crash if there are no entry files.", ) def release( _: click.core.Context, config: Config, version: str, empty: bool = False, **options: typing.Optional[str], ) -> None: """Generate changelog, clear entries and make a new release.""" config.settings["empty"] = empty changelogd.release(config=config, version=version) @command_decorator @click.option( "--check", help="Return exit code 1 if output file is different.", is_flag=True ) def partial( _: click.core.Context, config: Config, check: bool, **options: typing.Optional[str] ) -> None: """ Generate changelog without clearing entries, release name is taken from config file. """ changelogd.release(config=config, check=check, partial=True) @command_decorator @dynamic_options def entry( _: click.core.Context, config: Config, **options: typing.Optional[str] ) -> None: """Create a new changelog entry.""" changelogd.entry(config, options) def register_commands(cli: click.core.Group) -> None: commands = (init, draft, partial, release, entry) for command in commands: cli.add_command(command) changelogd-0.1.7/changelogd/computed_values.py000066400000000000000000000054601432104614100214570ustar00rootroot00000000000000import logging import re import subprocess import sys import typing from typing import List from typing import Optional def remote_branch_name() -> Optional[str]: """Extract remote branch name""" return _value_from_process( ["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], "remote branch name", ) def local_branch_name() -> Optional[str]: """Extract local branch name""" return _value_from_process( ["git", "rev-parse", "--abbrev-ref", "HEAD"], "local branch name" ) def branch_name() -> Optional[str]: """Extract local AND remote branch name separated by space""" data = [] local = local_branch_name() if local: data.append(local) remote = remote_branch_name() if remote: data.append(remote) result = " - ".join(data) return result or None def _value_from_process( command: List[str], error_context: Optional[str] = None ) -> Optional[str]: process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) out, err = process.communicate() if process.returncode: if error_context: error_context = f" to get {error_context}" else: error_context = "" logging.error(f"Failed to run '{' '.join(command)}'{error_context}") logging.error(err.decode()) return None return out.decode() class ComputedValueProcessor: FUNCTIONS = (local_branch_name, remote_branch_name, branch_name) def __init__(self, data: dict): type_ = data.get("type", None) if not type_: sys.exit(f"Missing `type` for computed value: {dict(**data)}") function: typing.Optional[typing.Callable[[], Optional[str]]] = next( (function for function in self.FUNCTIONS if function.__name__ == type_), None, ) if not function: available_types = [function.__name__ for function in self.FUNCTIONS] sys.exit( f"Unavailable type: '{type_}'. " f"Available types: {' '.join(available_types)}" ) self.function: typing.Callable[[], Optional[str]] = function self.name = data.get("name", None) or type_ self.regex = data.get("regex", None) self.default = data.get("default", None) self._data = data def get_data(self) -> typing.Dict[str, typing.Any]: value = self.function() if self.regex: match = re.search(self.regex, value) if value is not None else None if match: value = match.group("value") else: logging.warning(f"The regex '{self.regex}' didn't match '{value}'.") value = None if self.default and not value: value = self.default return {self.name: value} changelogd-0.1.7/changelogd/config.py000066400000000000000000000212671432104614100175300ustar00rootroot00000000000000import configparser import logging import os import shutil import sys import typing from copy import deepcopy from pathlib import Path import click import toml from ruamel.yaml import YAML # type: ignore from ruamel.yaml.comments import CommentedMap # type: ignore yaml = YAML() DEFAULT_PATH = Path(os.getcwd()) / "changelog.d" DEFAULT_OUTPUT = "../changelog." PARTIAL_KEY_NAME = "partial_release_name" DEFAULT_PARTIAL_VALUE = "unreleased" DEFAULT_USER_DATA = ["os_user", "git_user", "git_email"] DEFAULT_CONFIG = CommentedMap( { "entry_fields": [ { "name": "issue_id", "verbose_name": "Issue ID", "type": "str", "required": False, "multiple": True, }, { "name": "message", "verbose_name": "Changelog message", "type": "str", "required": True, }, ], "output_file": DEFAULT_OUTPUT, PARTIAL_KEY_NAME: DEFAULT_PARTIAL_VALUE, "user_data": DEFAULT_USER_DATA, } ) DEFAULT_CONFIG.insert( 0, "context", {"issues_url": "http://repo/issues"}, comment="All variables defined here will be passed into templates", ) DEFAULT_CONFIG.insert( 1, "message_types", [ {"name": "feature", "title": "Features"}, {"name": "bug", "title": "Bug fixes"}, {"name": "doc", "title": "Documentation changes"}, {"name": "deprecation", "title": "Deprecations"}, {"name": "other", "title": "Other changes"}, ], comment="The order defined below will be preserved in the output changelog file", ) def load_toml(path: Path) -> typing.Optional[str]: if not path.is_file(): return None with path.open() as file_handle: config = toml.load(file_handle) return config.get("tool", {}).get("changelogd", {}).get("config") # type:ignore def load_ini(path: Path) -> typing.Optional[str]: if not path.is_file(): return None config = configparser.ConfigParser() with path.open() as file_handle: config.read_file(file_handle) try: return config.get("tool:changelogd", "config") except (configparser.NoSectionError, configparser.NoOptionError): return None CONFIG_SNIPPET = "[tool:changelogd]\nconfig={path}" CONFIG_SNIPPET_TOML = "[tool.changelogd]\nconfig = '{path}'" SUPPORTED_CONFIG_FILES: typing.List[typing.Tuple[Path, typing.Callable, str]] = [ ( Path("pyproject.toml"), load_toml, CONFIG_SNIPPET_TOML, ), (Path("setup.cfg"), load_ini, CONFIG_SNIPPET), (Path("tox.ini"), load_ini, CONFIG_SNIPPET), ] class Config: settings: typing.Dict[str, typing.Any] = dict() def __init__(self, path: typing.Union[Path, str, None] = None) -> None: self._path: typing.Optional[Path] if path: self._path = Path(path) if isinstance(path, str) else path if not self._path.exists(): sys.exit("The given configuration path doesn't exist.") if not self._path.is_dir(): sys.exit("The configuration path has to be a directory.") if not (self._path / "config.yaml").is_file(): sys.exit("The 'config.yaml' file doesn't exist in provided directory.") else: self._path = None self._data: typing.Optional[dict] = None def get_context(self) -> typing.Dict[str, typing.Any]: return self.get_value("context") or {} def get_bool_setting(self, name: str) -> bool: return bool(self.settings.get(name)) @property def path(self) -> Path: if self._path is None: self._path = self._get_path() return self._path @property def releases_dir(self) -> Path: return self.path / "releases" @property def output_path(self) -> Path: output_path = self.get_value("output_file", DEFAULT_OUTPUT) return Path((self.path / output_path).resolve()) @property def partial_name(self) -> str: return str(self.get_value(PARTIAL_KEY_NAME, DEFAULT_PARTIAL_VALUE)) def get_data(self) -> dict: if self._data is None: self._data = self._load_data() return deepcopy(self._data) def get_value(self, key: str, default: typing.Any = None) -> typing.Any: return self.get_data().get(key, default) def _get_path(self) -> Path: path = self._search_config() or DEFAULT_PATH if not path.is_dir(): sys.exit( f"The configuration directory does not exist: " f"{path.absolute().resolve()}.\n" f"Run `changelogd init` to create it." ) return path def _load_data(self) -> dict: config_file = self.path / "config.yaml" if not config_file.is_file(): sys.exit( f"The main configuration file does not exist: " f"{config_file.absolute().resolve()}.\n" f"Run `changelogd init` to create it." ) with config_file.open() as config: return yaml.load(config) or {} def _search_config(self) -> typing.Optional[Path]: for config_file, load_function, _ in SUPPORTED_CONFIG_FILES: config_path = load_function(config_file) # type: ignore if config_path: config_path = Path(config_path) if not config_path or not config_path.is_file(): continue if config_path: logging.info( f"Load configuration from file {config_file.absolute().resolve()}" ) logging.debug( f"Configuration directory: {config_file.absolute().resolve()}" ) return config_path # type: ignore return None @classmethod def set_verbosity( cls, ctx: click.core.Context, option: click.core.Option, verbose: int ) -> None: levels = {0: logging.WARNING, 1: logging.INFO, 2: logging.DEBUG} logging.basicConfig( level=levels.get(verbose, logging.WARNING), format="%(message)s", ) def init( self, path: typing.Union[str, Path, None] = None, format: str = "md" ) -> None: if isinstance(path, str): path = Path(path) output_directory = path or DEFAULT_PATH if output_directory.is_dir(): if not click.confirm( f"The config directory '{output_directory.absolute().resolve()}' " f"already exists. " f"Do you want to overwrite the configuration files?" ): sys.exit("Aborted") else: output_directory.mkdir() config_data = {**DEFAULT_CONFIG} config_data["output_file"] += format output_path = output_directory / "config.yaml" with output_path.open("w+") as output_stream: yaml.dump(config_data, output_stream) if output_path.is_file(): logging.warning( f"Created main configuration file: {output_path.absolute().resolve()}" ) shutil.copy( Path(__file__).parent / "templates" / "README.md", output_directory / "README.md", ) target = output_directory / "templates" if target.is_dir(): shutil.rmtree(target) dst = shutil.copytree(Path(__file__).parent / "templates" / format, target) if dst: logging.warning(f"Copied templates to {dst}") if path is not None and path != DEFAULT_PATH: config_file, default = next( ( (config_file, default) for config_file, _, default in SUPPORTED_CONFIG_FILES if config_file.is_file() ), (None, None), ) if config_file and default: snippet = default.format(path=output_path.absolute().resolve()) logging.warning( f"The configuration path is not standard, please add a " f"following snippet to the '{config_file.absolute().resolve()}' " f"file:\n\n{snippet}" ) if path and not config_file: logging.warning( "No configuration file found. Create a pyproject.toml " "file in root of your directory, with the following content:\n\n" f"{CONFIG_SNIPPET_TOML.format(path=path.absolute())}" ) releases_dir = output_directory / "releases" releases_dir.mkdir(exist_ok=True) with open(releases_dir / ".gitkeep", "w+"): pass changelogd-0.1.7/changelogd/resolver.py000066400000000000000000000055001432104614100201140ustar00rootroot00000000000000import os import sys import typing from pathlib import Path import jinja2 from .config import Config class Resolver: """Class responsible for resolving templates""" def __init__(self, config: Config): self._config: Config = config self._templates_dir: Path = config.path / "templates" def full_resolve(self, releases: typing.List[typing.Dict]) -> str: env = jinja2.Environment( loader=jinja2.FileSystemLoader(self._templates_dir.as_posix()), ) templates = self._get_template_file_names( self._templates_dir, ("entry", "main", "release"), env ) message_types = self._config.get_value("message_types", []) resolved_releases = [ self._resolve_release(message_types, release, templates) for release in releases ] template = templates["main"] return template.render(**self._config.get_context(), releases=resolved_releases) def _resolve_release( self, message_types: typing.List[typing.Dict], release: typing.Dict, templates: typing.Dict[str, jinja2.Template], ) -> str: groups = {} for group_name, group in release.pop("entries", {}).items(): groups[group_name] = [ self._resolve_entry(entry, templates["entry"]) for entry in group ] release["entry_groups"] = [] for message_type in message_types: name = message_type.get("name") title = message_type.get("title", name) if name in groups: release["entry_groups"].append( {"name": name, "title": title, "entries": groups.get(name)} ) template = templates["release"] return template.render(**self._config.get_context(), **release) def _get_template_file_names( self, templates_dir: Path, templates: typing.Tuple[str, ...], env: jinja2.Environment, ) -> typing.Dict[str, jinja2.Template]: template_files = os.listdir(templates_dir.as_posix()) try: return { entry: env.get_template( next( (item for item in template_files if item.startswith(entry)), entry, ) ) for entry in templates } except jinja2.exceptions.TemplateSyntaxError as exc: sys.exit(f"Syntax error in template '{exc.filename}':\n\t{exc.message}") except jinja2.exceptions.TemplateNotFound as exc: sys.exit(f"Template file for '{exc.name}' not found.") def _resolve_entry(self, entry: typing.Dict, template: jinja2.Template) -> str: return template.render(**self._config.get_context(), **entry) changelogd-0.1.7/changelogd/templates/000077500000000000000000000000001432104614100176775ustar00rootroot00000000000000changelogd-0.1.7/changelogd/templates/README.md000066400000000000000000000017311432104614100211600ustar00rootroot00000000000000# Introduction Working with changelogs in this project requires the [changelogd](https://github.com/aklajnert/changelogd) installed. To install it, run the following command (requires Python 3.4 or newer): ```shell pip install --upgrade changelogd ``` # Creating a new changelogd entry To create a new entry, use `changelogd entry` command. After replying to a few questions, the entry file will be created in the `changelog.d` directory. # Releasing a new version A new version can be released by running `changelogd release ` where `` is the new version's name, e.g. `1.1.3`. This command will remove all entry files, and create a new one with the release representation. # Partial releases Partial release is for a work-in-progress versions, that might not be released yet. The partial release doesn't remove entry files nor create a new release. To execute partial release run `changelogd partial`, which will regenerate the output changelog file. changelogd-0.1.7/changelogd/templates/md/000077500000000000000000000000001432104614100202775ustar00rootroot00000000000000changelogd-0.1.7/changelogd/templates/md/entry.md000066400000000000000000000004271432104614100217650ustar00rootroot00000000000000* {% if issue_id is defined and issue_id -%} {% for iid in issue_id -%} [#{{ iid }}]({{ issues_url }}/{{ iid }}){% if not loop.last %}, {% endif %} {%- endfor %}: {% endif -%} {{ message }} {%- if os_user and git_email %} ([@{{ os_user }}](mailto:{{ git_email }})){% endif %} changelogd-0.1.7/changelogd/templates/md/main.md000066400000000000000000000001051432104614100215410ustar00rootroot00000000000000# Changelog {% for release in releases %}{{ release }}{% endfor %} changelogd-0.1.7/changelogd/templates/md/release.md000066400000000000000000000003621432104614100222420ustar00rootroot00000000000000 ## {{ release_version }} ({{ release_date }}) {% if release_description %} {{ release_description }} {% endif %}{% for group in entry_groups %} ### {{ group.title }} {% for entry in group.entries %}{{ entry }}{% endfor %}{% endfor %} changelogd-0.1.7/changelogd/templates/rst/000077500000000000000000000000001432104614100205075ustar00rootroot00000000000000changelogd-0.1.7/changelogd/templates/rst/entry.rst000066400000000000000000000004241432104614100224020ustar00rootroot00000000000000* {% if issue_id is defined and issue_id -%} {% for iid in issue_id -%} `#{{ iid }} <{{ issues_url }}/{{ iid }}>`_{% if not loop.last %}, {% endif %} {%- endfor %}: {% endif -%} {{ message }} {%- if os_user and git_email %} (`@{{ os_user }} <{{ git_email }}>`_){% endif %} changelogd-0.1.7/changelogd/templates/rst/main.rst000066400000000000000000000001151432104614100221620ustar00rootroot00000000000000Changelog ========= {% for release in releases %}{{ release }}{% endfor %} changelogd-0.1.7/changelogd/templates/rst/release.rst000066400000000000000000000005161432104614100226630ustar00rootroot00000000000000 {{ release_version }} ({{ release_date }}) {{ "-" * release_version|length }}---{{ "-" * release_date|length }} {% if release_description %} {{ release_description }} {% endif %}{% for group in entry_groups %} {{ group.title }} {{ "~" * group.title|length }} {% for entry in group.entries %}{{ entry }}{% endfor %}{% endfor %} changelogd-0.1.7/changelogd/utils.py000066400000000000000000000016711432104614100174200ustar00rootroot00000000000000import logging import subprocess import typing from pathlib import Path def get_git_data() -> typing.Optional[typing.Tuple[str, str]]: try: git_data = subprocess.check_output(["git", "config", "--list"]) except subprocess.CalledProcessError: logging.info("Cannot read git data.") return None data = { key: value for key, value in ( line.split("=", maxsplit=1) for line in git_data.decode().splitlines() if "=" in line ) } return data.get("user.name", ""), data.get("user.email", "") def add_to_git(path: typing.Union[Path, str]) -> None: process = subprocess.Popen( ["git", "add", str(path)], stdout=subprocess.PIPE, stderr=subprocess.PIPE ) _, err = process.communicate() if process.returncode == 0: logging.info(f"Added to git: {path}") else: logging.error(f"Failed to add to git: {err.decode()}") changelogd-0.1.7/docs/000077500000000000000000000000001432104614100145365ustar00rootroot00000000000000changelogd-0.1.7/docs/commands.rst000066400000000000000000000071331432104614100170750ustar00rootroot00000000000000Commands ======== Changelogd consists of multiple independent subcommands to make the changelog management as easy as possible. init ---- This command initialized ``changelogd`` configuration including default templates. By default, it will create a new ``changelog.d`` directory in the current work directory. You can select different directory with ``--path`` argument. If you want to use RST format, use ``--rst`` argument to the ``changelogd init``. .. code-block:: bash $ changelogd init Created main configuration file: /workdir/changelog.d/config.yaml Copied templates to /workdir/changelog.d/templates entry ----- Creates a new changelog entry. By default, it asks for the entry type, issue id, and the changelog message. This can be changed by modifying the ``message_types`` in ``config.yaml``. Also, the ``entry`` subcommand will try to extract git username and e-mail and the system username. The entry file name will contain a md5 checksum of the file content, to avoid conflicts. The filename can be changed, as long as it follows the following pattern: ``..entry.yaml``. .. code-block:: bash $ changelogd entry [1]: Features [feature] [2]: Bug fixes [bug] [3]: Documentation changes [doc] [4]: Deprecations [deprecation] [5]: Other changes [other] > Select message type [1]: 1 > Issue ID (separate multiple values with comma): 100 > Changelog message (required): A new feature implementation. Created changelog entry at /workdir/changelog.d/feature.f155ee47.entry.yaml As a result, a following ``YAML`` file will be created: .. code-block:: yaml git_email: user@example.com git_user: Some User issue_id: - '100' message: A new feature implementation. os_user: user type: feature draft ----- Load all input files and resolve templates to generate a changelog. The changelog will be printed to the stdout stream. .. code-block:: bash $ changelogd draft > Release description (hit ENTER to omit): Just draft # Changelog ## draft (2020-01-13) Just draft ### Features * [#100](http://repo/issues/100): A new feature implementation. ([@user](user@example.com)) release ------- Generate a new release file, remove all entries and generate a changelog file. You need to specify the new release name. .. warning:: This command will fail if there are no entry files. .. code-block:: bash $ changelogd release 0.1.0 > Release description (hit ENTER to omit): Demo release Saved new release data into /workdir/changelog.d/releases/0.0.1.0.yaml Generated changelog file to /workdir/changelog.md The generated ``YAML`` file will have all entries combined. The release file name will always start with a number, which will indicate the order of releases within the generated changelog file. The default content of the ``0.0.1.0.yaml`` file: .. code-block:: yaml entries: feature: - git_email: user@example.com git_user: Some User issue_id: - '100' message: A new feature implementation. os_user: user previous_release: null release_date: '2020-01-13' release_description: Demo release release_version: 0.1.0 partial ------- Generate changelog without clearing entries, release name is taken from config file. This will overwrite the changelog file. Use ``--check`` argument to return exit code = 1 if the output file is different than the previously generated one (can be useful in CI/CD). .. code-block:: bash $ changelogd partial Generated changelog file to /workdir/changelog.md changelogd-0.1.7/docs/conf.py000066400000000000000000000043521432104614100160410ustar00rootroot00000000000000# Configuration file for the Sphinx documentation builder. # # This file only contains a selection of the most common options. For a full # list see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html # -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # # import os # import sys # sys.path.insert(0, os.path.abspath('.')) # -- Project information ----------------------------------------------------- from pathlib import Path import pkg_resources from changelogd import changelogd ROOT_PATH = Path(__file__).parents[1] changelogd.release(partial=True, output="history.rst", config=ROOT_PATH / "changelog.d") project = "Changelogd" copyright = "2020, Andrzej Klajnert" author = "Andrzej Klajnert" # The full version, including alpha/beta/rc tags release = pkg_resources.get_distribution("pip").version # -- General configuration --------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = "alabaster" html_sidebars = { "**": ["about.html", "navigation.html", "relations.html", "searchbox.html"] } # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". # html_static_path = ['_static'] changelogd-0.1.7/docs/configuration.rst000066400000000000000000000114361432104614100201440ustar00rootroot00000000000000Configuration ============= The configuration file is required to run all ``changelogd`` commands except fo ``init``. By default, the script looks for config file within the ``changelog.d`` directory in the current working directory. The configuration file path can be changed in one of the standard Python packages configuration files: **tox.ini** or **setup.cfg**: .. code-block:: ini [tool:changelogd] config=/config.yaml **pyproject.toml**: .. code-block:: toml [tool.changelogd] config = '/config.yaml' A default configuration file generated by the ``changelogd init`` command should look like below: .. code-block:: yaml context: # All variables defined here will be passed into templates issues_url: http://repo/issues message_types: # The order defined below will be preserved in the output changelog file - name: feature title: Features - name: bug title: Bug fixes - name: doc title: Documentation changes - name: deprecation title: Deprecations - name: other title: Other changes entry_fields: - name: issue_id verbose_name: Issue ID required: false multiple: true - name: message verbose_name: Changelog message required: true output_file: ..\changelog.md partial_release_name: unreleased user_data: - os_user - git_user - git_email context ------- This is a place for user-defined key-value pairs that will be passed into all templates. There is no limitation on how many variables will be passed here. message_types ------------- Define supported message types. The ``name`` argument defines the type name which will be saved in the entry file name, ``tittle`` is meant to be displayed as a header in the changelog file. The changelog entries are grouped by their message type. The order of the message types definitions within the configuration file will be preserved in the output changelog file. If a message type will be removed from the configuration, and they're still will be some entries that are using that type, they will not be generated into the output changelog file. entry_fields ------------ Define which fields will be asked with ``changelogd entry`` command. This is a list of objects with the following fields: | - **name** - the name under which the field will be available in the template. It cannot contain spaces or dashes. | - **verbose_name** - the name displayed when the program will ask for the field value. | - **required** (default: *true*) - the ``changelog entry`` won't allow to leave the field blank if ``required=True`` | - **multiple** (default: *false*) - the variable can be provided as comma-separated values. This will be converted into a list of strings (even if there is no comma in it). The defined ``entry_fields`` can be also provided as a *command-line* arguments, e.g. ``changelogd entry --message "Some message"``. The missing fields will be asked interactively. Use ``changelogd entry --help`` to see which fields are available. output_file ----------- Path to the output changelog file. By default, it is ``../changelogd.md``, which is relative to the ``config.yaml`` file. partial_release_name -------------------- Name of the current, not-yet-released version when using the ``changelogd partial`` command. Default: *unreleased*. user_data --------- Define fields will be captured with each entry. Available choices are: | - **os_user** - currently logged in system user username, | - **git_user** - full name of the current user from the git configuration, | - **git_email** - current user's e-mail from the git configuration. Each field's name can be changed, by defining new name after colon, e.g.: ``os_user:new_name``. Set the ``user_data`` value to ``null`` to avoid capturing the user data at all. computed_values --------------- Computed values is a feature, that allows to capture a dynamic value from environment. The ``computed_values`` variable is a list of objects that have to define a ``type`` value. The allowed types are: - ``local_branch_name`` - get the name of a local branch, - ``remote_branch_name`` - get the name of a remote branch, - ``branch_name`` - get the local and remote branch name separated by ``-`` (mostly suitable for running regex over it). Besides type, there are additional variables that can influence the output: - ``regex`` - regular expression that will be used to extract a value from the command output. The regex need to define a named group called ``value`` (e.g. ``(?Pexpression)``) which will be taken as a final value, - ``name`` - name of the variable in the entry file, if not provided, the ``type`` value will be taken, - ``default`` - the default value that will be used if the value (matched or returned from the dynamic command) will be empty. changelogd-0.1.7/docs/index.rst000066400000000000000000000045041432104614100164020ustar00rootroot00000000000000.. Changelogd documentation master file, created by sphinx-quickstart on Sat Jan 11 13:46:11 2020. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. Changelogd ========== .. toctree:: :maxdepth: 2 :caption: Contents: Changelogd allows teams to avoid merge conflicts for the changelog files. The ``changelogd`` content is stored within multiple YAML files - one per each changelog entry. Then, during application release, all input files are combined into one release file. The script uses Jinja2 templates to generate one consistent text file out of all input YAML files. The default output format is Markdown, but by modifying the templates it can be changed into any text format you like. Installation ------------ You can install ``changelogd`` via `pip`_ from `PyPI`_:: $ pip install changelogd Quickstart ---------- First, initialize ``changelogd`` configuration. .. code-block:: bash $ changelogd init Created main configuration file: changelog.d\config.yaml Copied templates to changelog.d\templates Then, create changelog entries: .. code-block:: bash $ changelogd entry [1]: Features [feature] [2]: Bug fixes [bug] [3]: Documentation changes [doc] [4]: Deprecations [deprecation] [5]: Other changes [other] > Select message type [1]: 2 > Issue ID: 100 > Changelog message: Changelog message Created changelog entry at changelog.d\bug.a3f13823.entry.yaml Finally, generate changelog file. .. code-block:: bash $ changelogd release version-number > Release description (hit ENTER to omit): This is the initial release. Saved new release data into changelog.d\releases\0.release-name.yaml Generated changelog file to changelog.md Output file: .. code-block:: md # Changelog ## version-number (2020-01-11) This is the initial release. ### Bug fixes * [#100](http://repo/issues/100): Changelog message ([@user](user@example.com)) Contents -------- .. toctree:: :maxdepth: 2 commands configuration templates history Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` .. _`pip`: https://pypi.org/project/pip/ .. _`PyPI`: https://pypi.org/project changelogd-0.1.7/docs/templates.rst000066400000000000000000000034141432104614100172700ustar00rootroot00000000000000Templates ========= The ``templates`` directory should be placed in the same directory where the ``config.yaml`` file. The ``changelogd init`` command will prepare the templates for you. By default, you can generate templates in ``Markdown`` format or change to ``ReStructuredText`` (with ``changelogd init --rst``). You can have your templates (and the output changelog) in any other text format you like, just make sure that the template file names start with ``main``, ``release`` and ``entry``. Templates are using `Jinja2 `_ for rendering the data. All templates have access to the fields defined in ``context`` within ``config.yaml``. main ---- This is the top-level template of the output changelog file. You can add some header and footer here. The list of releases is passed into the template via ``releases`` variable, which should be printed in a loop: .. code-block:: jinja {% for release in releases %}{{ release }}{% endfor %} release ------- This template is responsible for displaying single release instances. It has access to all variables within the data from ``YAML`` release representations. The release should iterate over entry groups, and display their content. Entry groups is stored as a list in ``entry_groups`` variable. Each group contain ``title`` which is the ``title`` from ``message_types`` defined in ``config.yaml``, and ``entries`` that is a single entry representation. .. code-block:: jinja {% for group in entry_groups %} ### {{ group.title }} {% for entry in group.entries %} {{ entry }} {% endfor %} {% endfor %} entry ----- Defines how the particular entry will be shown. It has access to all variables defined in entry's ``YAML`` representation. changelogd-0.1.7/noxfile.py000066400000000000000000000021651432104614100156300ustar00rootroot00000000000000import nox nox.options.sessions = ["tests", "flake8", "mypy", "docs"] @nox.session(python=["3.6", "3.7", "3.8", "3.9", "3.10", "3.11"]) def tests(session): session.install(".[test]") session.run("pytest") @nox.session def flake8(session): session.install("flake8") session.run("flake8", "changelogd") @nox.session def mypy(session): session.install("mypy", "types-toml", "types-click", "types-jinja2") session.run("mypy", "changelogd") @nox.session def docs(session): session.install(".[docs]") session.run("sphinx-build", "-b", "html", "docs", "docs/_build", "-v", "-W") @nox.session def create_dist(session): session.install("twine") session.run("python", "setup.py", "sdist", "bdist_wheel") session.run("twine", "check", "dist/*") @nox.session def publish(session): """Publish to pypi. Run `nox publish -- prod` to publish to the official repo.""" create_dist(session) twine_command = ["twine", "upload", "dist/*"] if "prod" not in session.posargs: twine_command.extend(["--repository-url", "https://test.pypi.org/legacy/"]) session.run(*twine_command) changelogd-0.1.7/requirements_dev.txt000066400000000000000000000002561432104614100177330ustar00rootroot00000000000000pip==21.1 bump2version==0.5.11 wheel==0.33.6 watchdog==0.9.0 flake8==3.7.8 tox==3.14.0 coverage==4.5.4 Sphinx==1.8.5 twine==1.14.0 Click==7.0 pytest==4.6.5 pytest-runner==5.1changelogd-0.1.7/setup.cfg000066400000000000000000000010631432104614100154270ustar00rootroot00000000000000[bumpversion] current_version = 0.1.0 commit = True tag = True [bumpversion:file:setup.py] search = version='{current_version}' replace = version='{new_version}' [bumpversion:file:changelogd/__init__.py] search = __version__ = '{current_version}' replace = __version__ = '{new_version}' [bdist_wheel] universal = 0 [flake8] exclude = docs ignore = E231,W503 max-line-length = 89 [mypy] python_version = 3.6 warn_return_any = True warn_unused_configs = True disallow_untyped_defs = True [mypy-setup] ignore_errors = True [build_sphinx] warning-is-error = 1 changelogd-0.1.7/setup.py000066400000000000000000000037021432104614100153220ustar00rootroot00000000000000#!/usr/bin/env python # -*- coding: utf-8 -*- """The setup script.""" from setuptools import find_packages from setuptools import setup from changelogd import __version__ with open("README.rst") as readme_file: readme = readme_file.read() with open("HISTORY.rst") as history_file: readme += "\n" + history_file.read() requirements = [ "Click>=7.0", "Jinja2>=2.10", "toml>=0.9.4", "ruamel.yaml>=0.16.0", ] test_requirements = ["pytest>=5", "pyfakefs==4.6.3", "pytest-subprocess"] dev_requirements = [ "bump2version==0.5.11", "wheel==0.33.6", "flake8==3.7.9", "nox==2019.11.9", "mypy==0.740", ] docs_requirements = [ "sphinx", ] setup( author="Andrzej Klajnert", author_email="python@aklajnert.pl", python_requires=">=3.6", classifiers=[ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Natural Language :: English", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", ], description="Changelogs without conflicts.", entry_points={ "console_scripts": [ "changelogd=changelogd.cli:main", ], }, install_requires=requirements, extras_require={ "test": test_requirements, "dev": dev_requirements, "docs": docs_requirements, }, license="MIT license", long_description=readme, include_package_data=True, keywords="changelogd", name="changelogd", packages=find_packages(include=["changelogd", "changelogd.*"]), test_suite="tests", url="https://github.com/aklajnert/changelogd", version=__version__, zip_safe=False, ) changelogd-0.1.7/tests/000077500000000000000000000000001432104614100147505ustar00rootroot00000000000000changelogd-0.1.7/tests/__init__.py000066400000000000000000000001001432104614100170500ustar00rootroot00000000000000# -*- coding: utf-8 -*- """Unit test package for changelogd.""" changelogd-0.1.7/tests/conftest.py000066400000000000000000000033721432104614100171540ustar00rootroot00000000000000import datetime import getpass import os import typing from pathlib import Path import pytest from click.testing import CliRunner from changelogd import config old_invoke = CliRunner.invoke def invoke(*args, **kwargs): result = old_invoke(*args, **kwargs) config.Config.settings = {} return result CliRunner.invoke = invoke @pytest.fixture def setup_env(fake_process, monkeypatch, tmpdir, fake_date): fake_process.allow_unregistered(True) fake_process.keep_last_process(True) fake_process.register_subprocess( ["git", "config", "--list"], stdout=("user.name=Some User\n" "user.email=user@example.com\n"), ) monkeypatch.setattr(getpass, "getuser", lambda: "test-user") monkeypatch.setattr(config, "DEFAULT_PATH", Path(tmpdir) / "changelog.d") monkeypatch.chdir(tmpdir) monkeypatch.setattr(datetime, "date", fake_date) monkeypatch.setattr(os.path, "getmtime", lambda _: fake_date.EPOCH_02_02_2020) fake_date.set_date(datetime.date(2020, 2, 2)) yield tmpdir class FakeDate(datetime.date): EPOCH_02_02_2020 = 1580608922 EPOCH_03_02_2020 = 1580695322 _date = None @classmethod def today(cls) -> datetime.date: if not cls._date: return super().today() return cls._date @classmethod def set_date(cls, date: datetime.date) -> None: cls._date = date class FakeNow: def __init__(self, timestamp): self._timestamp = timestamp def timestamp(self): return self._timestamp class FakeDateTime(datetime.datetime): timestamp = 0 @classmethod def now(cls, tz=None): cls.timestamp += 1 return FakeNow(cls.timestamp) @pytest.fixture() def fake_date() -> typing.Type[FakeDate]: return FakeDate changelogd-0.1.7/tests/test_changelogd.py000066400000000000000000000311011432104614100204500ustar00rootroot00000000000000#!/usr/bin/env python # -*- coding: utf-8 -*- """Tests for `changelogd` package.""" import datetime import glob import os from pathlib import Path from click.testing import CliRunner from ruamel.yaml import YAML from changelogd import cli from changelogd import commands from changelogd import config from tests.conftest import FakeDateTime yaml = YAML() BASE = """# Changelog """ INITIAL_RELEASE = """ ## initial-release (2020-02-02) This is the initial release. ### Features * [#101](http://repo/issues/101): Another test feature ([@test-user](mailto:user@example.com)) * [#100](http://repo/issues/100): Test feature ([@test-user](mailto:user@example.com)) ### Bug fixes * [#102](http://repo/issues/102): Bug fixes ([@test-user](mailto:user@example.com)) ### Documentation changes * Slight docs update ([@test-user](mailto:user@example.com)) """ PARTIAL_RELEASE_HEADER = """ ## unreleased (2020-02-03) """ SECOND_RELEASE_HEADER = """ ## second-release (2020-02-03) """ SECOND_RELEASE = """ ### Features * [#202](http://repo/issues/202), [#203](http://repo/issues/203), [#204](http://repo/issues/204): Something new ([@test-user](mailto:user@example.com)) * Great feature ([@test-user](mailto:user@example.com)) * [#201](http://repo/issues/201): Super cool feature ([@test-user](mailto:user@example.com)) ### Deprecations * [#200](http://repo/issues/200): Deprecated test feature ([@test-user](mailto:user@example.com)) ### Other changes * Refactor ([@test-user](mailto:user@example.com)) """ def test_command_line_interface(): """Test the CLI.""" runner = CliRunner() result = runner.invoke(cli.cli) assert result.exit_code == 0 assert "Changelogs without conflicts." in result.output help_result = runner.invoke(cli.cli, ["--help"]) assert help_result.exit_code == 0 assert "Show this message and exit." in help_result.output def test_full_flow(setup_env, monkeypatch, caplog, fake_date): """ This function tests full functionality from fresh start through few releases. """ monkeypatch.setattr(datetime, "datetime", FakeDateTime) runner = CliRunner() # start with init init = runner.invoke(commands.init) assert init.exit_code == 0 assert sorted(_list_directory(setup_env)) == sorted( [ "changelog.d/README.md", "changelog.d/config.yaml", "changelog.d/releases/.gitkeep", "changelog.d/templates/entry.md", "changelog.d/templates/main.md", "changelog.d/templates/release.md", ] ) # add some entries _create_entry(runner, "1", "100", "Test feature") _create_entry(runner, "1", "101", "Another test feature") _create_entry(runner, "2", "102", "Bug fixes") _create_entry(runner, "3", "", "Slight docs update") assert _count_entry_files(setup_env) == 4 # try draft release draft = runner.invoke( commands.draft, ["initial-release"], "This is the initial release." ) assert draft.exit_code == 0 output = draft.stdout[len("Release description (hit ENTER to omit): ") :] assert output == BASE + INITIAL_RELEASE + "\n" # now release first version release = runner.invoke( commands.release, ["initial-release"], "This is the initial release." ) assert release.exit_code == 0 assert sorted(_list_directory(setup_env)) == sorted( [ "changelog.d/README.md", "changelog.d/config.yaml", "changelog.d/releases/.gitkeep", "changelog.d/releases/0.initial-release.yaml", "changelog.d/templates/entry.md", "changelog.d/templates/main.md", "changelog.d/templates/release.md", "changelog.md", ] ) changelog = _read_changelog(setup_env) assert changelog == BASE + INITIAL_RELEASE # create some other entries _create_entry(runner, "4", "200", "Deprecated test feature") _create_entry(runner, "1", "201", "Super cool feature") _create_entry(runner, "5", "", "Refactor") _create_entry(runner, "1", "", "Great feature") _create_entry(runner, "1", "202,203,204", "Something new") assert _count_entry_files(setup_env) == 5 fake_date.set_date(datetime.date(2020, 2, 3)) monkeypatch.setattr(os.path, "getmtime", lambda _: fake_date.EPOCH_03_02_2020) # try a partial release partial = runner.invoke(commands.partial) assert partial.exit_code == 0 assert _count_entry_files(setup_env) == 5 changelog = _read_changelog(setup_env) assert changelog == BASE + PARTIAL_RELEASE_HEADER + SECOND_RELEASE + INITIAL_RELEASE # another partial release shall generate exactly the same output partial = runner.invoke(commands.partial) assert partial.exit_code == 0 assert changelog == _read_changelog(setup_env) # release a new version release = runner.invoke(commands.release, ["second-release"], "\n") assert release.exit_code == 0 assert sorted(_list_directory(setup_env)) == sorted( [ "changelog.d/README.md", "changelog.d/config.yaml", "changelog.d/releases/.gitkeep", "changelog.d/releases/0.initial-release.yaml", "changelog.d/releases/1.second-release.yaml", "changelog.d/templates/entry.md", "changelog.d/templates/main.md", "changelog.d/templates/release.md", "changelog.md", ] ) assert _count_entry_files(setup_env) == 0 changelog = _read_changelog(setup_env) assert changelog == BASE + SECOND_RELEASE_HEADER + SECOND_RELEASE + INITIAL_RELEASE caplog.clear() # another attempt to release shall raise an error due to no entries release = runner.invoke(commands.release, ["third-release"], "\n") assert release.exit_code == 1 assert "Cannot create new release without any entries." in caplog.messages caplog.clear() # same should happen for draft draft = runner.invoke(commands.draft, ["third-release"], "\n") assert draft.exit_code == 1 assert "Cannot create new release without any entries." in caplog.messages caplog.clear() # but another attempt to partial release should be fine partial = runner.invoke(commands.partial) assert partial.exit_code == 0 # however, changelog shouldn't be modified new_changelog = _read_changelog(setup_env) assert changelog == new_changelog # even with the --check argument partial = runner.invoke(commands.partial, ["--check"]) assert partial.exit_code == 0 # running release with --empty should also pass and generate a new release release = runner.invoke(commands.release, ["third-release", "--empty"], "\n") assert release.exit_code == 0 new_changelog = _read_changelog(setup_env) assert changelog != new_changelog def test_partial_releases(setup_env, caplog, fake_date): """Test more sophisticated scenarios with partial releases.""" runner = CliRunner() init = runner.invoke(commands.init) assert init.exit_code == 0 _create_entry(runner, "1", "1", "First entry") partial = runner.invoke(commands.partial) assert partial.exit_code == 0 _create_entry(runner, "1", "2", "Second entry") # now run partial with --check, which should fail caplog.clear() partial = runner.invoke(commands.partial, ["--check"]) assert partial.exit_code == 1 assert "Output file content is different than before." in caplog.messages # another partial should pass, since previous partial updated the changelog caplog.clear() partial = runner.invoke(commands.partial, ["--check"]) assert partial.exit_code == 0 assert len(caplog.messages) == 1 # even the next day shouldn't cause --check to fail caplog.clear() fake_date.set_date(datetime.date(2020, 2, 3)) partial = runner.invoke(commands.partial, ["--check"]) assert partial.exit_code == 0 assert len(caplog.messages) == 1 # but running partial without --check should update the timestamp caplog.clear() changelog_before = _read_changelog(setup_env) partial = runner.invoke(commands.partial) assert partial.exit_code == 0 assert len(caplog.messages) == 1 assert changelog_before != _read_changelog(setup_env) def test_empty_release(setup_env, caplog): """ This is also a regression. The program was crashing when there was no releases and no entries with --empty argument. """ HEADER = "# Changelog \n\n\n" CHANGELOG_0_1_0 = "## 0.1.0 (2020-02-02) \n\nInitial release \n" CHANGELOG_0_1_1 = ( "## 0.1.1 (2020-02-02) \n\nPatch release \n\n" "### Features \n" "* Sample entry ([@test-user](mailto:user@example.com)) \n\n\n" ) CHANGELOG_0_1_2 = "## 0.1.2 (2020-02-02) \n\nMaintenance release \n\n\n" runner = CliRunner() init = runner.invoke(commands.init) assert init.exit_code == 0 release = runner.invoke(commands.release, ["0.1.0", "--empty"], "Initial release\n") assert release.exit_code == 0 changelog = _read_changelog(setup_env) assert changelog == HEADER + CHANGELOG_0_1_0 _create_entry(runner, "1", "", "Sample entry") release = runner.invoke(commands.release, ["0.1.1"], "Patch release\n") assert release.exit_code == 0 assert _read_changelog(setup_env) == HEADER + CHANGELOG_0_1_1 + CHANGELOG_0_1_0 caplog.clear() release = runner.invoke( commands.release, ["0.1.2", "--empty"], "Maintenance release\n" ) assert release.exit_code == 0 assert ( _read_changelog(setup_env) == HEADER + CHANGELOG_0_1_2 + CHANGELOG_0_1_1 + CHANGELOG_0_1_0 ) def test_init(tmpdir, monkeypatch, caplog): monkeypatch.chdir(tmpdir) monkeypatch.setattr(config, "DEFAULT_PATH", Path(tmpdir) / "changelog.d") with open(tmpdir / "setup.cfg", "w+") as setup_file: setup_file.write("[tool:pytest]\ncollect_ignore = ['setup.py']") runner = CliRunner() init = runner.invoke(commands.init) assert init.exit_code == 0 config_yaml = tmpdir / "changelog.d" / "config.yaml" assert f"Created main configuration file: {config_yaml}" in caplog.messages assert ( f"Copied templates to {tmpdir / 'changelog.d' / 'templates'}" in caplog.messages ) assert ( f"The configuration path is not standard, please add a following snippet to " f"the '{tmpdir / 'setup.cfg'}' file:\n\n[tool:changelogd]\n" f"config={config_yaml}" not in caplog.messages ) def test_no_init(tmpdir, monkeypatch): """All commands, except `init` should crash if no configuration is found.""" monkeypatch.chdir(tmpdir) monkeypatch.setattr(config, "DEFAULT_PATH", Path(tmpdir) / "changelog.d") runner = CliRunner() error = ( f"The configuration directory does not exist: {tmpdir / 'changelog.d'}.\n" f"Run `changelogd init` to create it.\n" ) def check_command(command): result = runner.invoke(*command) assert result.exit_code == 1 assert result.stdout == error test_commands = ( (commands.entry,), (commands.draft, ["version"]), (commands.release, ["version"]), (commands.partial,), ) for command in test_commands: check_command(command) init = runner.invoke(commands.init) assert init.exit_code == 0 def test_init_rst(setup_env, monkeypatch, caplog, fake_date): runner = CliRunner() init = runner.invoke(commands.init, ["--rst"]) assert init.exit_code == 0 assert sorted(_list_directory(setup_env)) == sorted( [ "changelog.d/README.md", "changelog.d/config.yaml", "changelog.d/releases/.gitkeep", "changelog.d/templates/entry.rst", "changelog.d/templates/main.rst", "changelog.d/templates/release.rst", ] ) with Path(setup_env / "changelog.d" / "config.yaml").open() as config_fh: output_config = yaml.load(config_fh) assert output_config["output_file"] == "../changelog.rst" def _count_entry_files(tmpdir): return len(glob.glob((Path(tmpdir) / "changelog.d" / "*entry.yaml").as_posix())) def _read_changelog(tmpdir): with open(tmpdir / "changelog.md") as changelog_fh: changelog = changelog_fh.read() return changelog def _list_directory(directory): output = [] for root, dirs, files in os.walk(directory): for file_ in files: output.append((Path(root) / file_).relative_to(directory).as_posix()) return output def _create_entry(runner, type, issue_id, message): entry = runner.invoke( commands.entry, input=os.linesep.join([type, issue_id, message]) ) assert entry.exit_code == 0 changelogd-0.1.7/tests/test_config.py000066400000000000000000000067761432104614100176460ustar00rootroot00000000000000import os from pathlib import Path import pytest from click.testing import CliRunner from changelogd import commands from changelogd import config def test_load_toml(fs): fs.create_file( "pyproject.toml", contents=config.CONFIG_SNIPPET_TOML.format(path="/config/path"), ) assert config.load_toml(Path("pyproject.toml")) == "/config/path" fs.create_file( "pyproject2.toml", contents="[tool.other_tool]\nconfig = '/config/path'" ) assert config.load_toml(Path("pyproject2.toml")) is None fs.create_file( "pyproject3.toml", contents="[tool.changelogd]\nother_option = '/config/path'" ) assert config.load_toml(Path("pyproject3.toml")) is None def test_load_ini(fs): fs.create_file( "config.ini", contents=config.CONFIG_SNIPPET.format(path="/config/path") ) assert config.load_ini(Path("config.ini")) == "/config/path" fs.create_file("config2.ini", contents="[tool:other_tool]\nconfig=/config/path") assert config.load_ini(Path("config2.ini")) is None fs.create_file( "config3.ini", contents="[tool:changelogd]\nother_option=/config/path" ) assert config.load_ini(Path("config3.ini")) is None def test_init_config(fs, caplog, monkeypatch): # remove `setup.cfg` and `tox.ini` from supported files, # to make tests pass on azure pipelines monkeypatch.setattr( config, "SUPPORTED_CONFIG_FILES", (config.SUPPORTED_CONFIG_FILES[0],), ) fs.create_dir("/test") fs.add_real_directory((Path(__file__).parents[1] / "changelogd" / "templates")) runner = CliRunner() result = runner.invoke(commands.init, "--path=/test/changelog.d") assert result.exit_code == 0 assert os.listdir("/test") == ["changelog.d"] assert sorted(os.listdir("/test/changelog.d")) == [ "README.md", "config.yaml", "releases", "templates", ] assert sorted(os.listdir("/test/changelog.d/templates")) == [ "entry.md", "main.md", "release.md", ] assert os.listdir("/test/changelog.d/releases") == [".gitkeep"] assert all(record.levelname == "WARNING" for record in caplog.records) messages = [record.message for record in caplog.records] assert messages[0].startswith("Created main configuration file: ") assert messages[1].startswith("Copied templates to ") assert messages[2].startswith( "No configuration file found. Create a pyproject.toml file in root of your " "directory, with the following content:" ) result = runner.invoke(commands.init, "--path=/test/changelog.d", input="n") assert result.exit_code == 1 def test_custom_path(fs): # directory doesn't exist at all with pytest.raises(SystemExit) as exc: config.Config("/test") assert str(exc.value) == "The given configuration path doesn't exist." # the path is file, not a directory fs.create_file("/config.yaml") with pytest.raises(SystemExit) as exc: config.Config("/config.yaml") assert str(exc.value) == "The configuration path has to be a directory." # the config.yaml is missing from the directory fs.create_dir("/config_dir") with pytest.raises(SystemExit) as exc: config.Config("/config_dir") assert ( str(exc.value) == "The 'config.yaml' file doesn't exist in provided directory." ) # all good now fs.create_file("/config_dir/config.yaml") instance = config.Config("/config_dir") assert str(instance.path) == f"{os.sep}config_dir" changelogd-0.1.7/tests/test_entry.py000066400000000000000000000176241432104614100175340ustar00rootroot00000000000000import builtins import functools import getpass import glob import importlib from types import SimpleNamespace import pytest from click.testing import CliRunner from ruamel.yaml import YAML from changelogd import changelogd from changelogd import commands from changelogd.config import Config from changelogd.config import DEFAULT_CONFIG yaml = YAML() class FakeContext: def __enter__(self): pass def __exit__(self, *_): pass class FakePath: def __init__(self, *args, **kwargs): pass def __truediv__(self, other): return self def open(self, *args, **kwargs): return FakeContext() def absolute(self): return None def fake_yaml_dump(data, _, namespace): namespace.data = data def test_incorrect_input_entry(): runner = CliRunner() entry = runner.invoke(commands.entry) assert entry.exit_code == 1 def test_entry_help(setup_env): runner = CliRunner() runner.invoke(commands.init) # this is required to update the decorators which can be done # after initializing configuration importlib.reload(commands) entry = runner.invoke( commands.entry, ["--help"], ) assert entry.exit_code == 0 assert ( entry.stdout == """Usage: entry [OPTIONS] Create a new changelog entry. Options: -v, --verbose Increase verbosity. --message TEXT Changelog message --issue-id TEXT Issue ID --type TEXT Message type (as number or string). --help Show this message and exit. """ ) @pytest.mark.parametrize("type_input", ["1", "feature"]) def test_non_interactive_data(setup_env, type_input): runner = CliRunner() runner.invoke(commands.init) # this is required to update the decorators which can be done # after initializing configuration importlib.reload(commands) entry = runner.invoke( commands.entry, ["--type", type_input, "--message", "test message", "--issue-id", "100"], ) assert entry.exit_code == 0 entries = glob.glob(str(setup_env / "changelog.d" / "*entry.yaml")) assert len(entries) == 1 with open(entries[0]) as entry_fh: entry_content = yaml.load(entry_fh) assert entry_content.pop("timestamp") assert entry_content == { "git_email": "user@example.com", "git_user": "Some User", "issue_id": "100", "message": "test message", "os_user": "test-user", "type": "feature", } def test_multi_value_string(setup_env): runner = CliRunner() runner.invoke(commands.init) entry = runner.invoke( commands.entry, ["--type", "1", "--message", "test message"], input='a, b,"c,d", e,f', ) assert entry.exit_code == 0 entries = glob.glob(str(setup_env / "changelog.d" / "*entry.yaml")) assert len(entries) == 1 with open(entries[0]) as entry_fh: entry_content = yaml.load(entry_fh) assert entry_content.pop("timestamp") assert entry_content == { "git_email": "user@example.com", "git_user": "Some User", "issue_id": ["a", "b", "c,d", "e", "f"], "message": "test message", "os_user": "test-user", "type": "feature", } def test_entry_missing_message_types(setup_env, caplog): runner = CliRunner() runner.invoke(commands.init) with open(setup_env / "changelog.d" / "config.yaml") as config_fh: config_content = yaml.load(config_fh) config_content.pop("message_types") with open(setup_env / "changelog.d" / "config.yaml", "w+") as config_fh: yaml.dump(config_content, config_fh) caplog.clear() entry = runner.invoke(commands.entry) assert entry.exit_code == 1 assert ( "The 'message_types' field is missing from the configuration" in caplog.messages ) def test_entry_incorrect_entry_fields(setup_env, caplog): runner = CliRunner() runner.invoke(commands.init) with open(setup_env / "changelog.d" / "config.yaml") as config_fh: config_content = yaml.load(config_fh) # just name with correct value, this should be fine config_content["entry_fields"] = [{"name": "just_name", "required": False}] with open(setup_env / "changelog.d" / "config.yaml", "w+") as config_fh: yaml.dump(config_content, config_fh) caplog.clear() entry = runner.invoke(commands.entry, input="1\n\n") assert entry.exit_code == 0 importlib.reload(commands) # make sure that missing verbose_name doesn't cause a problem entry = runner.invoke( commands.entry, ["--help"], ) assert entry.exit_code == 0 # also the `--just-name` option won't have any help assert ( entry.stdout == """Usage: entry [OPTIONS] Create a new changelog entry. Options: -v, --verbose Increase verbosity. --just-name TEXT --type TEXT Message type (as number or string). --help Show this message and exit. """ ) # name contains space, not good config_content["entry_fields"] = [{"name": "just name", "required": False}] with open(setup_env / "changelog.d" / "config.yaml", "w+") as config_fh: yaml.dump(config_content, config_fh) caplog.clear() entry = runner.invoke(commands.entry, input="1\n\n") assert entry.exit_code == 1 assert ( "The 'name' argument of an 'entry_fields' element cannot contain spaces." in caplog.messages ) # no name at all, also bad config_content["entry_fields"] = [{"verbose_name": "just_name", "required": False}] with open(setup_env / "changelog.d" / "config.yaml", "w+") as config_fh: yaml.dump(config_content, config_fh) caplog.clear() entry = runner.invoke(commands.entry, input="1\n\n") assert entry.exit_code == 1 assert "Each 'entry_fields' element needs to have 'name'." in caplog.messages def test_user_data(monkeypatch, fake_process): namespace = SimpleNamespace() config = Config() config._data = {**DEFAULT_CONFIG} config._path = FakePath("/test") fake_process.register( ["git", "config", "--list"], stdout=("user.name=Some User\n" "user.email=user@example.com\n"), ) fake_process.register(["git", "add", fake_process.any()]) fake_process.keep_last_process(True) monkeypatch.setattr(getpass, "getuser", lambda: "test-user") monkeypatch.setattr( YAML, "dump", functools.partial(fake_yaml_dump, namespace=namespace) ) monkeypatch.setattr(builtins, "input", lambda _: "1") changelogd.entry(config, {}) assert namespace.data.pop("timestamp") assert namespace.data == { "git_email": "user@example.com", "git_user": "Some User", "issue_id": ["1"], "message": "1", "os_user": "test-user", "type": "feature", } config._data["user_data"] = ["os_user"] changelogd.entry(config, {}) assert namespace.data.pop("timestamp") assert namespace.data == { "issue_id": ["1"], "message": "1", "os_user": "test-user", "type": "feature", } config._data["user_data"] = [ "os_user:overridden_username", "git_user:overridden_git_user", ] changelogd.entry(config, {}) assert namespace.data.pop("timestamp") assert namespace.data == { "issue_id": ["1"], "message": "1", "type": "feature", "overridden_username": "test-user", "overridden_git_user": "Some User", } config._data["user_data"] = None changelogd.entry(config, {}) assert namespace.data.pop("timestamp") assert namespace.data == { "issue_id": ["1"], "message": "1", "type": "feature", } config._data["user_data"] = ["not_exist"] with pytest.raises(SystemExit) as exc: changelogd.entry(config, {}) assert str(exc.value) == ( "The 'not_exist' variable is not supported in 'user_data'. " "Available choices are: 'os_user, git_user, git_email'." ) changelogd-0.1.7/tests/test_entry_computed_values.py000066400000000000000000000216141432104614100230050ustar00rootroot00000000000000import builtins import functools import getpass from pathlib import Path from types import SimpleNamespace import pytest from pytest_subprocess import FakeProcess from ruamel.yaml import YAML from changelogd import changelogd from changelogd.config import Config yaml = YAML() def fake_yaml_dump(data, _, namespace): namespace.data = data def test_missing_type(monkeypatch, fp: FakeProcess, fs): config_path = Path("/fake/path/to/changelog.d") fs.create_file( config_path / "config.yaml", contents=( "message_types:\n" "- name: feature\n" " title: Features\n" "computed_values:\n" "- name: test\n" "user_data: null\n" ), ) config = Config(config_path) with pytest.raises( SystemExit, match="Missing `type` for computed value: {'name': 'test'}", ): changelogd.entry(config, {}) def test_invalid_type(monkeypatch, fp: FakeProcess, fs): config_path = Path("/fake/path/to/changelog.d") fs.create_file( config_path / "config.yaml", contents=( "message_types:\n" "- name: feature\n" " title: Features\n" "computed_values:\n" "- type: invalid\n" "user_data: null\n" ), ) config = Config(config_path) with pytest.raises( SystemExit, match=( "Unavailable type: 'invalid'. Available types: " "local_branch_name remote_branch_name branch_name" ), ): changelogd.entry(config, {}) def test_basic_data(monkeypatch, fp: FakeProcess, fs): namespace = SimpleNamespace() config_path = Path("/fake/path/to/changelog.d") fs.create_file( config_path / "config.yaml", contents=( "message_types:\n" "- name: feature\n" " title: Features\n" "computed_values:\n" "- type: branch_name\n" "user_data: null\n" ), ) config = Config(config_path) fp.register( ["git", "rev-parse", "--abbrev-ref", "HEAD"], stdout="local_branch_name" ) fp.register( ["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], stdout="remote_branch_name", ) fp.register(["git", "add", fp.any()]) fp.keep_last_process(True) monkeypatch.setattr( YAML, "dump", functools.partial(fake_yaml_dump, namespace=namespace) ) monkeypatch.setattr(builtins, "input", lambda _: "1") changelogd.entry(config, {}) assert namespace.data.pop("timestamp") assert namespace.data == { "type": "feature", "branch_name": "local_branch_name - remote_branch_name", } @pytest.mark.parametrize( "branches", [ ("fixing task JIRA-1234", "remote_branch_name"), ("local_branch_name", "fixing task JIRA-1234"), ], ) def test_matching_regex(monkeypatch, fp: FakeProcess, fs, branches): namespace = SimpleNamespace() config_path = Path("/fake/path/to/changelog.d") fs.create_file( config_path / "config.yaml", contents=( "message_types:\n" "- name: feature\n" " title: Features\n" "computed_values:\n" "- type: branch_name\n" " regex: '(?PJIRA-\d+)'\n" "user_data: null\n" ), ) config = Config(config_path) local_branch_name, remote_branch_name = branches fp.register(["git", "rev-parse", "--abbrev-ref", "HEAD"], stdout=local_branch_name) fp.register( ["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], stdout=remote_branch_name, ) fp.register(["git", "add", fp.any()]) fp.keep_last_process(True) monkeypatch.setattr( YAML, "dump", functools.partial(fake_yaml_dump, namespace=namespace) ) monkeypatch.setattr(builtins, "input", lambda _: "1") changelogd.entry(config, {}) assert namespace.data.pop("timestamp") assert namespace.data == { "type": "feature", "branch_name": "JIRA-1234", } def test_not_matching_regex(monkeypatch, fp: FakeProcess, fs, caplog): namespace = SimpleNamespace() config_path = Path("/fake/path/to/changelog.d") fs.create_file( config_path / "config.yaml", contents=( "message_types:\n" "- name: feature\n" " title: Features\n" "computed_values:\n" "- type: local_branch_name\n" " regex: '(?PJIRA-\d+)'\n" "user_data: null\n" ), ) config = Config(config_path) fp.register( ["git", "rev-parse", "--abbrev-ref", "HEAD"], stdout="local_branch_name" ) fp.register(["git", "add", fp.any()]) fp.keep_last_process(True) monkeypatch.setattr( YAML, "dump", functools.partial(fake_yaml_dump, namespace=namespace) ) monkeypatch.setattr(builtins, "input", lambda _: "1") changelogd.entry(config, {}) assert namespace.data.pop("timestamp") assert namespace.data == { "type": "feature", "local_branch_name": None, } assert ( caplog.messages[0] == "The regex '(?PJIRA-\\d+)' didn't match 'local_branch_name'." ) def test_subprocess_failure(monkeypatch, fp: FakeProcess, fs, caplog): namespace = SimpleNamespace() config_path = Path("/fake/path/to/changelog.d") fs.create_file( config_path / "config.yaml", contents=( "message_types:\n" "- name: feature\n" " title: Features\n" "computed_values:\n" "- type: branch_name\n" "user_data: null\n" ), ) config = Config(config_path) fp.register(["git", "rev-parse", "--abbrev-ref", "HEAD"], returncode=128) fp.register( ["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], returncode=128, ) fp.register(["git", "add", fp.any()]) fp.keep_last_process(True) monkeypatch.setattr( YAML, "dump", functools.partial(fake_yaml_dump, namespace=namespace) ) monkeypatch.setattr(builtins, "input", lambda _: "1") changelogd.entry(config, {}) assert namespace.data.pop("timestamp") assert namespace.data == { "type": "feature", "branch_name": None, } assert ( caplog.messages[0] == "Failed to run 'git rev-parse --abbrev-ref HEAD' to get local branch name" ) assert caplog.messages[2] == ( "Failed to run 'git rev-parse --abbrev-ref --symbolic-full-name @{u}' " "to get remote branch name" ) def test_default_value(monkeypatch, fp: FakeProcess, fs, caplog): namespace = SimpleNamespace() config_path = Path("/fake/path/to/changelog.d") fs.create_file( config_path / "config.yaml", contents=( "message_types:\n" "- name: feature\n" " title: Features\n" "computed_values:\n" "- type: branch_name\n" " default: default_name\n" "user_data: null\n" ), ) config = Config(config_path) fp.register(["git", "rev-parse", "--abbrev-ref", "HEAD"], returncode=128) fp.register( ["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], returncode=128, ) fp.register(["git", "add", fp.any()]) fp.keep_last_process(True) monkeypatch.setattr( YAML, "dump", functools.partial(fake_yaml_dump, namespace=namespace) ) monkeypatch.setattr(builtins, "input", lambda _: "1") changelogd.entry(config, {}) assert namespace.data.pop("timestamp") assert namespace.data == { "type": "feature", "branch_name": "default_name", } def test_default_not_matching_regex(monkeypatch, fp: FakeProcess, fs, caplog): namespace = SimpleNamespace() config_path = Path("/fake/path/to/changelog.d") fs.create_file( config_path / "config.yaml", contents=( "message_types:\n" "- name: feature\n" " title: Features\n" "computed_values:\n" "- type: local_branch_name\n" " default: default_name\n" " regex: '(?PJIRA-\d+)'\n" "user_data: null\n" ), ) config = Config(config_path) fp.register( ["git", "rev-parse", "--abbrev-ref", "HEAD"], stdout="local_branch_name" ) fp.register(["git", "add", fp.any()]) fp.keep_last_process(True) monkeypatch.setattr( YAML, "dump", functools.partial(fake_yaml_dump, namespace=namespace) ) monkeypatch.setattr(builtins, "input", lambda _: "1") changelogd.entry(config, {}) assert namespace.data.pop("timestamp") assert namespace.data == { "type": "feature", "local_branch_name": "default_name", } assert ( caplog.messages[0] == "The regex '(?PJIRA-\\d+)' didn't match 'local_branch_name'." ) changelogd-0.1.7/tests/test_utils.py000066400000000000000000000027161432104614100175270ustar00rootroot00000000000000import logging import os from changelogd.utils import add_to_git from changelogd.utils import get_git_data def test_get_git_data(fake_process): fake_process.register_subprocess( ["git", "config", "--list"], stdout=( "core.symlinks=false\n" "core.autocrlf=true\n" "core.fscache=true\n" "rebase.autosquash=true\n" "diff.astextplain.textconv=astextplain\n" "user.name=Some User\n" "user.email=user@example.com\n" "core.bare=false\n" "core.logallrefupdates=true\n" "core.symlinks=false\n" "core.ignorecase=true\n" "branch.master.remote=origin\n" "branch.master.merge=refs/heads/master\n" ), ) git_data = get_git_data() assert git_data == ("Some User", "user@example.com") def test_get_git_data_failed(fake_process): fake_process.register_subprocess(["git", "config", "--list"], returncode=1) assert get_git_data() is None def test_add_to_git(fake_process, caplog): caplog.set_level(logging.INFO) fake_process.register_subprocess(["git", "add", "/test"]) fake_process.register_subprocess( ["git", "add", "/other-test"], returncode=1, stderr="error message" ) add_to_git("/test") assert "Added to git: /test" in caplog.messages caplog.clear() add_to_git("/other-test") assert f"Failed to add to git: error message" in caplog.messages