pax_global_header00006660000000000000000000000064143506110340014507gustar00rootroot0000000000000052 comment=b0e201f8d84819f0e5604e0cb029fb3b8b4cf2df twisted-towncrier-b0e201f/000077500000000000000000000000001435061103400156055ustar00rootroot00000000000000twisted-towncrier-b0e201f/.flake8000066400000000000000000000003041435061103400167550ustar00rootroot00000000000000[flake8] # Allow for longer test strings. Code is formatted to 88 columns by Black. max-line-length = 99 extend-ignore = # Conflict between flake8 & black about whitespace in slices. E203 twisted-towncrier-b0e201f/.git-blame-ignore-revs000066400000000000000000000005631435061103400217110ustar00rootroot00000000000000# This file lists commits that should be ignored by the `git blame` command. # You can configure your checkout to use it by default by running: # # git config blame.ignoreRevsFile .git-blame-ignore-revs # # Or pass it to the blame command manually: # # git blame --ignore-revs-file .git-blame-ignore-revs ... # black & isort 9a58911ea760b996b88355b6b18420b88b5a0ea9 twisted-towncrier-b0e201f/.github/000077500000000000000000000000001435061103400171455ustar00rootroot00000000000000twisted-towncrier-b0e201f/.github/CODEOWNERS000066400000000000000000000002541435061103400205410ustar00rootroot00000000000000# See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners * @twisted/twisted-contributors twisted-towncrier-b0e201f/.github/PULL_REQUEST_TEMPLATE.md000066400000000000000000000015651435061103400227550ustar00rootroot00000000000000# Description # Checklist * [ ] Make sure changes are covered by existing or new tests. * [ ] For at least one Python version, make sure local test run is green. * [ ] Create a file in `src/towncrier/newsfragments/`. Describe your change and include important information. Your change will be included in the public release notes. * [ ] Make sure all GitHub Actions checks are green (they are automatically checking all of the above). * [ ] Ensure `docs/tutorial.rst` is still up-to-date. * [ ] If you add new **CLI arguments** (or change the meaning of existing ones), make sure `docs/cli.rst` reflects those changes. * [ ] If you add new **configuration options** (or change the meaning of existing ones), make sure `docs/configuration.rst` reflects those changes. twisted-towncrier-b0e201f/.github/workflows/000077500000000000000000000000001435061103400212025ustar00rootroot00000000000000twisted-towncrier-b0e201f/.github/workflows/ci.yml000066400000000000000000000150711435061103400223240ustar00rootroot00000000000000name: CI on: push: branches: [ trunk ] tags: [ "**" ] pull_request: defaults: run: shell: bash jobs: build: name: ${{ matrix.task.name}} - ${{ matrix.os.name }} ${{ matrix.python.name }} runs-on: ${{ matrix.os.runs-on }} strategy: fail-fast: false matrix: os: - name: Linux runs-on: ubuntu-latest python: - name: CPython 3.9 action: 3.9 task: - name: Build nox: build steps: - uses: actions/checkout@v3 - name: Set up ${{ matrix.python.name }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python.action }} - name: Install dependencies run: python -m pip install --upgrade pip nox - uses: twisted/python-info-action@v1 - run: nox -e ${{ matrix.task.nox }} - name: Publish uses: actions/upload-artifact@v3 with: name: dist path: dist/ test-linux: name: ${{ matrix.task.name}} - Linux ${{ matrix.python.name }} runs-on: ubuntu-latest needs: - build strategy: fail-fast: false matrix: python: - name: CPython 3.7 action: 3.7 - name: CPython 3.8 action: 3.8 - name: CPython 3.9 action: 3.9 - name: CPython 3.10 action: '3.10' - name: CPython 3.11 action: '3.11.0-beta - 3.11' - name: PyPy 3.7 action: pypy3.7 - name: PyPy 3.8 action: pypy3.8 task: - name: Test nox: tests steps: - uses: actions/checkout@v3 - name: Download package files uses: actions/download-artifact@v3 with: name: dist path: dist/ - name: Set up ${{ matrix.python.name }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python.action }} - name: Install dependencies run: python -m pip install --upgrade pip nox codecov coverage[toml] - uses: twisted/python-info-action@v1 - run: nox --python ${{ matrix.python.action }} -e ${{ matrix.task.nox }} -- --use-wheel dist/*.whl - name: Codecov run: | codecov -n "GitHub Actions - ${{ matrix.task.name}} - ${{ matrix.os.name }} ${{ matrix.python.name }}" test-windows: name: ${{ matrix.task.name}} - Windows ${{ matrix.python.name }} runs-on: windows-latest needs: - build strategy: fail-fast: false matrix: python: - name: CPython 3.9 action: '3.9' task: - name: Test nox: tests steps: - uses: actions/checkout@v3 - name: Download package files uses: actions/download-artifact@v3 with: name: dist path: dist/ - name: Set up ${{ matrix.python.name }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python.action }} - name: Install dependencies run: python -m pip install --upgrade pip nox codecov coverage[toml] - uses: twisted/python-info-action@v1 - run: nox --python ${{ matrix.python.action }} -e ${{ matrix.task.nox }} -- --use-wheel dist/*.whl - name: Codecov run: | codecov -n "GitHub Actions - ${{ matrix.task.name}} - ${{ matrix.os.name }} ${{ matrix.python.name }}" check: name: ${{ matrix.task.name}} - ${{ matrix.python.name }} runs-on: ubuntu-latest needs: - build strategy: fail-fast: false matrix: python: # Using second most recent minor release for whatever little # increase in stability over using the latest minor. - name: CPython 3.9 python-version: '3.9' task: - name: Check Newsfragment nox: check_newsfragment run-if: ${{ github.head_ref != 'pre-commit-ci-update-config' }} - name: Check package manifest nox: check_manifest run-if: true - name: Check mypy nox: typecheck run-if: true steps: - uses: actions/checkout@v3 with: fetch-depth: 0 - name: Download package files uses: actions/download-artifact@v3 with: name: dist path: dist/ - name: Set up ${{ matrix.python.name }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python.python-version }} - name: Install dependencies run: python -m pip install --upgrade pip nox - uses: twisted/python-info-action@v1 - run: nox -e ${{ matrix.task.nox }} if: ${{ matrix.task.run-if }} pypi-publish: # https://github.community/t/is-it-possible-to-require-all-github-actions-tasks-to-pass-without-enumerating-them/117957/4?u=graingert name: Check tag and publish runs-on: ubuntu-latest needs: - build - test-linux - test-windows steps: - uses: actions/checkout@v3 - name: Download package files uses: actions/download-artifact@v3 with: name: dist path: dist/ - name: Set up Python uses: actions/setup-python@v4 with: python-version: 3.9 - name: Install dependencies run: | python -m pip install --upgrade pip setuptools wheel python -m pip install pep517 - name: Display structure of files to be pushed run: ls --recursive dist/ - name: Check matched tag version and branch version - on tag if: startsWith(github.ref, 'refs/tags/') run: python admin/check_tag_version_match.py "${{ github.ref }}" - name: Publish to PyPI - on tag if: startsWith(github.ref, 'refs/tags/') uses: pypa/gh-action-pypi-publish@37f50c210e3d2f9450da2cd423303d6a14a6e29f with: password: ${{ secrets.PYPI_TOKEN }} verbose: true # This is a meta-job to simplify PR CI enforcement configuration in GitHub. # Inside the GitHub config UI you only configure this job as required. # All the extra requirements are defined "as code" as part of the `needs` # list for this job. all: name: All success runs-on: ubuntu-latest # The always() part is very important. # If not set, the job will be skipped on failing dependencies. if: always() needs: # This is the list of CI job that we are interested to be green before # a merge. - build - test-linux - test-windows - check - pypi-publish steps: - name: Require all successes uses: re-actors/alls-green@3a2de129f0713010a71314c74e33c0e3ef90e696 with: jobs: ${{ toJSON(needs) }} twisted-towncrier-b0e201f/.gitignore000066400000000000000000000003221435061103400175720ustar00rootroot00000000000000*.egg-info/ *.o *.py[co] *.so _trial_temp*/ build/ dropin.cache doc/ docs/_build/ dist/ venv/ htmlcov/ .coverage *~ *.lock apidocs/ .vs/ *.pyproj .DS_Store .eggs .nox/ .coverage.* .vscode .idea .python-version twisted-towncrier-b0e201f/.pre-commit-config.yaml000066400000000000000000000012651435061103400220720ustar00rootroot00000000000000--- ci: autoupdate_schedule: monthly repos: - repo: https://github.com/psf/black rev: 22.10.0 hooks: - id: black - repo: https://github.com/asottile/pyupgrade rev: v3.3.0 hooks: - id: pyupgrade args: [--py37-plus] - repo: https://github.com/PyCQA/isort rev: 5.10.1 hooks: - id: isort additional_dependencies: [toml] - repo: https://github.com/PyCQA/flake8 rev: 6.0.0 hooks: - id: flake8 - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: debug-statements - id: check-toml - id: check-yaml twisted-towncrier-b0e201f/.readthedocs.yml000066400000000000000000000004511435061103400206730ustar00rootroot00000000000000version: 2 sphinx: # We want to fail as this is also our CI check for the docs. fail_on_warning: True # We don't need PDF and epub for the docs. formats: [] python: version: "3.8" system_packages: False install: - method: pip path: . extra_requirements: - dev twisted-towncrier-b0e201f/CODE_OF_CONDUCT.md000066400000000000000000000062261435061103400204120ustar00rootroot00000000000000# Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at hawkowl@atleastfornow.net. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] [homepage]: http://contributor-covenant.org [version]: http://contributor-covenant.org/version/1/4/ twisted-towncrier-b0e201f/CONTRIBUTING.rst000066400000000000000000000107201435061103400202460ustar00rootroot00000000000000Contributing to Towncrier ========================= Want to contribute to this project? Great! We'd love to hear from you! As a developer and user, you probably have some questions about our project and how to contribute. In this article, we try to answer these and give you some recommendations. Ways to communicate and contribute ---------------------------------- There are several options to contribute to this project: * Open a new topic on our `GitHub Discussions`_ page. Tell us about your ideas or ask questions there. Discuss with us the next Towncrier release. * Help or comment on our GitHub `issues`_ tracker. There are certainly many issues where you can help with your expertise. Or you would like to share your ideas with us. * Open a new issue using GitHub `issues`_ tracker. If you found a bug or have a new cool feature, describe your findings. Try to be as descriptive as possible to help us understand your issue. * Check out the Libera ``#twisted`` IRC channel or `Twisted Gitter `_. If you prefer to discuss some topics personally, you may find the IRC or Gitter channels interesting. They are bridged. * Modify the code. If you would love to see the new feature in the next release, this is probably the best way. Modifying the code ------------------ The source code is managed using Git and is hosted on GitHub:: https://github.com/twisted/towncrier git@github.com:twisted/towncrier.git We recommend the following workflow: #. `Fork our project `_ on GitHub. #. Clone your forked Git repository (replace ``GITHUB_USER`` with your account name on GitHub):: $ git clone git@github.com:GITHUB_USER/towncrier.git #. Prepare a pull request: a. Create a new branch with:: $ git checkout -b b. Write your test cases and run the complete test suite, see the section *Running the test suite* for details. c. Document any user-facing changes in one of the ``/docs/`` files. d. Create a newsfragment in ``src/towncrier/newsfragments/`` describing the changes and containing information that is of interest to end-users. e. Create a `pull request`_. Describe in the pull request what you did and why. If you have open questions, ask. (optional) Allow team members to edit the code on your PR. #. Wait for feedback. If you receive any comments, address these. #. After your pull request is merged, delete your branch. .. _testsuite: Running the test suite ---------------------- We use the `twisted.trial`_ module and `nox`_ to run tests against all supported Python versions and operating systems. The following list contains some ways how to run the test suite: * To install this project into a virtualenv along with the dependencies necessary to run the tests and build the documentation:: $ pip install -e .[dev] * To run the tests, use ``trial`` like so:: $ trial towncrier * To investigate and debug errors, use the ``trial`` command like this:: $ trial -b towncrier This will invoke a PDB session. If you press ``c`` it will continue running the test suite until it runs into an error. * To run all tests against all supported versions, install nox and use:: $ nox You may want to add the ``--no-error-on-missing-interpreters`` option to avoid errors when a specific Python interpreter version couldn't be found. * To get a complete list of the available targets, run:: $ nox -l * To run only a specific test only, use the ``towncrier.test.FILE.CLASS.METHOD`` syntax, for example:: $ nox -e tests -- towncrier.test.test_project.InvocationTests.test_version * To run some quality checks before you create the pull request, we recommend using this call:: $ nox -e pre_commit check_manifest check_newsfragment * Or enable `pre-commit` as a git hook:: $ pip install pre-commit $ pre-commit install **Please note**: If the test suite works in nox, but doesn't by calling ``trial``, it could be that you've got GPG-signing active for git commits which fails with our dummy test commits. .. ### Links .. _flake8: https://flake8.pycqa.org/ .. _GitHub Discussions: https://github.com/twisted/towncrier/discussions .. _issues: https://github.com/twisted/towncrier/issues .. _pull request: https://github.com/twisted/towncrier/pulls .. _nox: https://nox.thea.codes/ .. _twisted.trial: https://github.com/twisted/trac-wiki-archive/blob/trunk/TwistedTrial.mediawiki twisted-towncrier-b0e201f/LICENSE000066400000000000000000000020771435061103400166200ustar00rootroot00000000000000Copyright (c) 2015, Amber Brown and the towncrier contributors 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. twisted-towncrier-b0e201f/MANIFEST.in000066400000000000000000000006451435061103400173500ustar00rootroot00000000000000include *.rst include .coveragerc include LICENSE include CODE_OF_CONDUCT.md include pyproject.toml include noxfile.py include *.yaml include .git-blame-ignore-revs include .flake8 recursive-include src *.rst exclude bin exclude admin exclude src/towncrier/newsfragments exclude .readthedocs.yml recursive-exclude bin * recursive-exclude admin * recursive-exclude src/towncrier/newsfragments * recursive-exclude docs * twisted-towncrier-b0e201f/NEWS.rst000066400000000000000000000443601435061103400171220ustar00rootroot00000000000000``towncrier`` issues are filed on `GitHub `_, and each ticket number here corresponds to a closed GitHub issue. .. towncrier release notes start towncrier 22.12.0 (2022-12-21) ============================== No changes since the previous release candidate. towncrier 22.12.0rc1 (2022-12-20) ================================= Features -------- - Added ``--keep`` option to the ``build`` command that allows generating a newsfile, but keeps the newsfragments in place. This option can not be used together with ``--yes``. (`#129 `_) - Python 3.11 is now officially supported. (`#427 `_) - You can now create fragments that are not associated with issues. Start the name of the fragment with ``+`` (e.g. ``+anything.feature``). The content of these orphan news fragments will be included in the release notes, at the end of the category corresponding to the file extension. To help quickly create a unique orphan news fragment, ``towncrier create +.feature`` will append a random string to the base name of the file, to avoid name collisions. (`#428 `_) Improved Documentation ---------------------- - Improved contribution documentation. (`#415 `_) - Correct a typo in the readme that incorrectly documented custom fragments in a format that does not work. (`#424 `_) - The documentation has been restructured and (hopefully) improved. (`#435 `_) - Added a Markdown-based how-to guide. (`#436 `_) - Defining custom fragments using a TOML array is not deprecated anymore. (`#438 `_) Deprecations and Removals ------------------------- - Default branch for `towncrier check` is now "origin/main" instead of "origin/master". If "origin/main" does not exist, fallback to "origin/master" with a deprecation warning. (`#400 `_) Misc ---- - `#406 `_, `#408 `_, `#411 `_, `#412 `_, `#413 `_, `#414 `_, `#416 `_, `#418 `_, `#419 `_, `#421 `_, `#429 `_, `#430 `_, `#431 `_, `#434 `_, `#446 `_, `#447 `_ towncrier 22.8.0 (2022-08-29) ============================= No significant changes since the previous release candidate. towncrier 22.8.0.rc1 (2022-08-28) ================================= Features -------- - Make the check subcommand succeed for branches that change the news file This should enable the ``check`` subcommand to be used as a CI lint step and not fail when a pull request only modifies the configured news file (i.e. when the news file is being assembled for the next release). (`#337 `_) - Added support to tables in toml settings, which provides a more intuitive way to configure custom types. (`#369 `_) - The `towncrier create` command line now has a new `-m TEXT` argument that is used to define the content of the newly created fragment. (`#374 `_) Bugfixes -------- - The extra newline between the title and rendered content when using ``--draft`` is no longer inserted. (`#105 `_) - The detection of duplicate release notes was fixed and recording changes of same version is no longer triggered. Support for having the release notes for each version in a separate file is working again. This is a regression introduced in VERSION 19.9.0rc1. (`#391 `_) Improved Documentation ---------------------- - Improve ``CONTRIBUTING.rst`` and add PR template. (`#342 `_) - Move docs too the main branch and document custom fragment types. (`#367 `_) - The CLI help messages were updated to contain more information. (`#384 `_) Deprecations and Removals ------------------------- - Support for all Python versions older than 3.7 has been dropped. (`#378 `_) Misc ---- - `#292 `_, `#330 `_, `#366 `_, `#376 `_, `#377 `_, `#380 `_, `#381 `_, `#382 `_, `#383 `_, `#393 `_, `#399 `_, `#402 `_ towncrier 21.9.0 (2022-02-04) ============================= Features -------- - towncrier --version` was added to the command line interface to show the product version. (`#339 `_) - Support Toml v1 syntax with tomli on Python 3.6+ (`#354 `_) Bugfixes -------- - Stop writing title twice when ``title_format`` is specified. (`#346 `_) - Disable universal newlines when reading TOML (`#359 `_) Misc ---- - `#332 `_, `#333 `_, `#334 `_, `#338 `_ towncrier 21.3.0 (2021-04-02) ============================= No significant changes since the previous release candidate. towncrier 21.3.0.rc1 (2021-03-21) ================================= Features -------- - Ticket number from file names will be stripped down to avoid ticket links such as ``#007``. (`#126 `_) - Allow definition of the project ``version`` and ``name`` in the configuration file. This allows use of towncrier seamlessly with non-Python projects. (`#165 `_) - Improve news fragment file name parsing to allow using file names like ``123.feature.1.ext`` which are convenient when one wants to use an appropriate extension (e.g. ``rst``, ``md``) to enable syntax highlighting. (`#173 `_) - The new ``--edit`` option of the ``create`` subcommand launches an editor for entering the contents of the newsfragment. (`#275 `_) - CPython 3.8 and 3.9 are now part of our automated test matrix and are officially supported. (`#291 `_) - When searching for the project, first check for an existing importable instance. This helps if the version is only available in the installed version and not the source. (`#297 `_) - Support building with PEP 517. (`#314 `_) Bugfixes -------- - Configuration errors found during command line execution now trigger a message to stderr and no longer show a traceback. (`#84 `_) - A configuration error is triggered when the newsfragment files couldn't be discovered. (`#85 `_) - Invoking towncrier as `python -m towncrier` works. (`#163 `_) - ``check`` subcommand defaults to UTF-8 encoding when ``sys.stdout.encoding`` is ``None``. This happens, for example, with Python 2 on GitHub Actions or when the output is piped. (`#175 `_) - Specifying ``title_format`` disables default top line creation to avoid duplication. (`#180 `_) Improved Documentation ---------------------- - The README now mentions the possibility to name the configuration file ``towncrier.toml`` (in addition to ``pyproject.toml``). (`#172 `_) - ``start_line`` corrected to ``start_string`` in the readme to match the long standing implementation. (`#277 `_) towncrier 19.9.0 (2021-03-20) ============================= No significant changes. towncrier 19.9.0rc1 (2019-09-16) ================================ Features -------- - Add ``create`` subcommand, which can be used to quickly create a news fragment command in the location defined by config. (`#4 `_) - Add support for subcommands, meaning the functionality of the ``towncrier`` executable is now replaced by the ``build`` subcommand:: $ towncrier build --draft A new ``check`` subcommand is exposed. This is an alternative to calling the ``towncrier.check`` module manually:: $ towncrier check Calling ``towncrier`` without a subcommand will result in a call to the ``build`` subcommand to ensure backwards compatibility. This may be removed in a future release. (`#144 `_) - Towncrier's templating now allows configuration of the version header. *CUSTOM TEMPLATE USERS PLEASE NOTE: You will need to add the version header information to your template!* (`#147 `_) - towncrier now accepts the --config argument to specify a custom configuration file (`#157 `_) - There is now the option for ``all_bullets = false`` in the configuration. Setting ``all_bullets`` to false means that news fragments have to include the bullet point if they should be rendered as enumerations, otherwise they are rendered directly (this means fragments can include a header.). It is necessary to set this option to avoid (incorrect) automatic indentation of multiline fragments that do not include bullet points. The ``single-file-no-bullets.rst`` template gives an example of using these options. (`#158 `_) - The ``single_file`` option can now be added to the configuration file. When set to ``true``, the filename key can now be formattable with the ``name``, ``version``, and ``project_date`` format variables. This allows subsequent versions to be written out to new files instead of appended to an existing one. (`#161 `_) - You can now specify Towncrier-bundled templates in your configuration file. Available templates are `default`, `hr-between-versions` (as used in attrs), and `single-file-no-bullets`. (`#162 `_) Bugfixes -------- - Accept newsfragment filenames with multiple dots, like `fix-1.2.3.bugfix`. (`#142 `_) Deprecations and Removals ------------------------- - The `--pyproject` option for `towncrier check` is now replaced with `--config`, for consistency with other commands. (`#162 `_) towncrier 19.2.0 (2019-02-15) ============================= Features -------- - Add support for multiple fragements per issue/type pair. This extends the naming pattern of the fragments to `issuenumber.type(.counter)` where counter is an optional integer. (`#119 `_) - Python 2.7 is now supported. (`#121 `_) - `python -m towncrier.check` now accepts an option to give the configuration file location. (`#123 `_) - towncrier.check now reports git output when it encounters a git failure. (`#124 `_) towncrier 18.6.0 (2018-07-05) ============================= Features -------- - ``python -m towncrier.check``, which will check a Git branch for the presence of added newsfiles, to be used in a CI system. (`#75 `_) - wrap is now an optional configuration option (which is False by default) which controls line wrapping of news files. Towncrier will now also not attempt to normalise (wiping newlines) from the input, but will strip leading and ending whitespace. (`#80 `_) - Towncrier can now be invoked by ``python -m towncrier``. (`#115 `_) Deprecations and Removals ------------------------- - Towncrier now supports Python 3.5+ as a script runtime. Python 2.7 will not function. (`#80 `_) towncrier 18.5.0 (2018-05-16) ============================= Features -------- - Python 3.3 is no longer supported. (`#103 `_) - Made ``package`` optional. When the version is passed on the command line, and the ``title_format`` does not use the package name, and it is not used for the path to the news fragments, then no package name is needed, so we should not enforce it. (`#111 `_) Bugfixes -------- - When cleaning up old newsfragments, if a newsfragment is named "123.feature.rst", then remove that file instead of trying to remove the non-existent "123.feature". (`#99 `_) - If there are two newsfragments with the same name (example: "123.bugfix.rst" and "123.bugfix.rst~"), then raise an error instead of silently picking one at random. (`#101 `_) towncrier 17.8.0 (2017-08-19) ============================= Features -------- - Added new option ``issue_format``. For example, this can be used to make issue text in the NEWS file be formatted as ReST links to the issue tracker. (`#52 `_) - Add ``--yes`` option to run non-interactively. (`#56 `_) - You can now name newsfragments like 123.feature.rst, or 123.feature.txt, or 123.feature.whatever.you.want, and towncrier will ignore the extension. (`#62 `_) - New option in ``pyproject.toml``: ``underlines = ["=", "-", "~"]`` to specify the ReST underline hierarchy in towncrier's generated text. (`#63 `_) - Instead of sorting sections/types alphabetically (e.g. "bugfix" before "feature" because "b" < "f"), sections/types will now have the same order in the output as they have in your config file. (`#70 `_) Bugfixes -------- - When rewrapping text, don't break words or at hyphens -- they might be inside a URL (`#68 `_) Deprecations and Removals ------------------------- - `towncrier.ini` config file support has been removed in preference to `pyproject.toml` configuration. (`#71 `_) towncrier 17.4.0 (2017-04-15) ============================= Misc ---- - #46 towncrier 17.1.0 ========== Bugfixes -------- - fix --date being ignored (#43) towncrier 16.12.0 ========== Bugfixes -------- - Towncrier will now import the local version of the package and not the global one. (#38) Features -------- - Allow configration of the template file, title text and "magic comment" (#35) - Towncrier now uses pyproject.toml, as defined in PEP-518. (#40) towncrier 16.1.0 (2016-03-25) ============================= Features -------- - Ported to Python 2.7. (#27) - towncrier now supports non-numerical news fragment names. (#32) Bugfixes -------- - towncrier would spew an unhelpful exception if it failed importing your project when autodiscovering, now it does not. (#22) - incremental is now added as a runtime dependency for towncrier. (#25) Misc ---- - #33 towncrier 16.0.0 (2016-01-06) ============================= Features -------- - towncrier now automatically puts a date beside the version as it is generated, using today's date. For repeatable builds, use the ``--date`` switch and provide a date. For no date, use ``--date=``. (#11) - towncrier will now add the version logs after ``.. towncrier release notes start``, if it is in the file, allowing you to preserve text at the top of the file. (#15) Improved Documentation ---------------------- - The README now mentions how to manually provide the version number, for non-Py3 compatible projects. (#19) towncrier 15.1.0 ================ Features -------- - towncrier now supports reading ``__version__`` attributes that are tuples of numbers (e.g. (15, 4, 0)). (#3) - towncrier now has support for testing via Tox and each commit is now ran on Travis CI. (#6) Bugfixes -------- - towncrier now defaults to the current working directory for the package_dir settings variable. (#2) towncrier 15.0.0 ================ Features -------- - Basic functionality has been implemented. This includes configuring towncrier to find your project, having a set of preconfigured news fragment categories, and assembling a newsfile from them. (#1) twisted-towncrier-b0e201f/README.rst000066400000000000000000000045511435061103400173010ustar00rootroot00000000000000Hear ye, hear ye, says the ``towncrier`` ======================================== .. image:: https://img.shields.io/badge/Docs-Read%20The%20Docs-black :alt: Documentation :target: https://towncrier.readthedocs.io/ .. image:: https://img.shields.io/badge/license-MIT-C06524 :alt: License: MIT :target: https://github.com/twisted/towncrier/blob/trunk/LICENSE .. image:: https://img.shields.io/pypi/v/towncrier :alt: PyPI release :target: https://pypi.org/project/towncrier/ ``towncrier`` is a utility to produce useful, summarized news files (also known as changelogs) for your project. Rather than reading the Git history, or having one single file which developers all write to and produce merge conflicts, ``towncrier`` reads "news fragments" which contain information useful to **end users**. Used by `Twisted `_, `pytest `_, `pip `_, `BuildBot `_, and `attrs `_, among others. While the command line tool ``towncrier`` works on Python 3.7+ only, as long as you don't use any Python-specific affordances (like auto-detection of the project version), it is usable with **any project type** on **any platform**. Philosophy ---------- ``towncrier`` delivers the news which is convenient to those that hear it, not those that write it. That is, by duplicating what has changed from the "developer log" (which may contain complex information about the original issue, how it was fixed, who authored the fix, and who reviewed the fix) into a "news fragment" (a small file containing just enough information to be useful to end users), ``towncrier`` can produce a digest of the changes which is valuable to those who may wish to use the software. These fragments are also commonly called "topfiles" or "newsfiles". ``towncrier`` works best in a development system where all merges involve closing a ticket. To get started, check out our `tutorial `_! .. links Project Links ------------- - **PyPI**: https://pypi.org/project/towncrier/ - **Documentation**: https://towncrier.readthedocs.io/ - **News**: https://github.com/twisted/towncrier/blob/trunk/NEWS.rst - **License**: `MIT `_ twisted-towncrier-b0e201f/RELEASE.rst000066400000000000000000000071671435061103400174320ustar00rootroot00000000000000Release Process =============== .. note:: Commands are written with Linux in mind and a ``venv`` located in ``venv/``. Adjust per your OS and virtual environment location. For example, on Windows with an environment in the directory ``myenv/`` the Python command would be ``myenv/scripts/python``. Towncrier uses `CalVer `_ of the form ``YY.MM.micro`` with the micro version just incrementing. Before the final release, a set of release candidates are released. Release candidate ----------------- Create a release branch with a name of the form ``release-19.9.0`` starting from the main branch. The same branch is used for the release candidated and the final release. In the end, the release branch is merged into the main branch. Update the version to the release candidate with the first being ``rc1`` (as opposed to 0). In ``src/towncrier/_version.py`` the version is set using ``incremental`` such as:: __version__ = Version('towncrier', 19, 9, 0, release_candidate=1) Run ``venv/bin/towncrier build --yes`` to generate the news release NEWS file. Commit and push to the primary repository, not a fork. It is important to not use a fork so that pushed tags end up in the primary repository, server provided secrets for publishing to PyPI are available, and maybe more. Create a PR named in the form ``Release 19.9.0``. The same PR will be used for the release candidates and the final release. Wait for the tests to be green. Start with the release candidates. Create a new release candidate using `GitHub New release UI `_. * *Choose a tag*: Type `19.9.0rc1` and select `Create new tag on publish.` * *Target*: Search for the release branch and select it. * *Title*: "Towncrier 19.9.0rc1". * Set the content based on the NEWS file. * Make sure to mark **This is a pre-release**. * Click `Publish release` This will trigger the PyPI release candidate. Wait for the PyPI version to be published and then request a review for the PR from the ``twisted/twisted-contributors`` team. In the PR request, you can give the link to the PyPI download and the documentation pages. The documentation link is also available as part of the standard Read The Docs PR checks. Notify the release candidate over IRC or Gitter to gain more attention. In the PR comments, you can also mention anyone who has asked for a release. Final release -------------- Once the PR is approved, you can trigger the final release. Update the version to the final version. In ``src/towncrier/_version.py`` the version is set using ``incremental`` such as:: __version__ = Version('towncrier', 19, 9, 0) Manually update the `NEWS.rst` file to include the final release version and date. Usually it will look like this:: towncrier 19.9.0 (2019-09-29) ============================= No significant changes since the previous release candidate. Commit and push the change. Wait for the tests to be green. Trigger the final release using GitHub Release GUI. Similar to the release candidate, with the difference: * tag will be named `19.9.0` * the target is the same branch * Title will be `towncrier 19.0.0` * Content can be the content of the final release and the release candidates. * Don't mark **This is a pre-release**. * Click `Publish release` No need for another review request. Update the version to the development version. In ``src/towncrier/_version.py`` the version is set using ``incremental`` such as:: __version__ = Version('towncrier', 19, 9, 1, dev=0) Commit and push the changes. Merge the commit in the main branch. You can announce the release over IRC or Gitter. Done. twisted-towncrier-b0e201f/admin/000077500000000000000000000000001435061103400166755ustar00rootroot00000000000000twisted-towncrier-b0e201f/admin/canonicalize_version.py000066400000000000000000000014221435061103400234520ustar00rootroot00000000000000import click import incremental import packaging.utils @click.command() @click.argument("version") def cli(version): """Canonicalizes the passed version according to incremental.""" parsed_version = packaging.utils.Version(version) release_candidate = None if parsed_version.pre is not None: if parsed_version.pre[0] == "rc": release_candidate = parsed_version.pre[1] incremental_version = incremental.Version( package="", major=parsed_version.major, minor=parsed_version.minor, micro=parsed_version.micro, release_candidate=release_candidate, post=parsed_version.post, dev=parsed_version.dev, ) click.echo(incremental_version.public()) if __name__ == "__main__": cli() twisted-towncrier-b0e201f/admin/check_tag_version_match.py000066400000000000000000000016521435061103400241040ustar00rootroot00000000000000# # Used during the release process to make sure that we release based on a # tag that has the same version as the current twisted.__version. # # Designed to be conditionally called inside GitHub Actions release job. # Tags should use PEP440 version scheme. # # To be called as: admin/check_tag_version_match.py refs/tags/twisted-20.3.0 # import sys import pep517.meta TAG_PREFIX = "refs/tags/" if len(sys.argv) < 2: print("No tag check requested.") sys.exit(0) branch_version = pep517.meta.load(".").version run_version = sys.argv[1] if not run_version.startswith(TAG_PREFIX): print(f"Not a twisted release tag name '{run_version}.") sys.exit(1) run_version = run_version[len(TAG_PREFIX) :] # noqa: E203 if run_version != branch_version: print(f"Branch is at '{branch_version}' while tag is '{run_version}'") exit(1) print(f"All good. Branch and tag versions match for '{branch_version}'.") sys.exit(0) twisted-towncrier-b0e201f/bin/000077500000000000000000000000001435061103400163555ustar00rootroot00000000000000twisted-towncrier-b0e201f/bin/towncrier000077500000000000000000000003031435061103400203130ustar00rootroot00000000000000#! /usr/bin/env python3 # flake8: noqa import os.path import sys srcdir = os.path.join(os.path.dirname(__file__), "..", "src") sys.path.insert(0, srcdir) import towncrier towncrier._main() twisted-towncrier-b0e201f/docs/000077500000000000000000000000001435061103400165355ustar00rootroot00000000000000twisted-towncrier-b0e201f/docs/.gitignore000066400000000000000000000000071435061103400205220ustar00rootroot00000000000000_build twisted-towncrier-b0e201f/docs/Makefile000066400000000000000000000011421435061103400201730ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = python -msphinx SPHINXPROJ = Towncrier SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) twisted-towncrier-b0e201f/docs/cli.rst000066400000000000000000000045131435061103400200410ustar00rootroot00000000000000Command Line Reference ====================== The following options can be passed to all of the commands that explained below: .. option:: --config FILE_PATH Pass a custom config file at ``FILE_PATH``. Default: ``towncrier.toml`` or ``pyproject.toml`` file. If both files exist, the first will take precedence .. option:: --dir PATH Build fragment in ``PATH``. Default: current directory. ``towncrier build`` ------------------- Build the combined news file from news fragments. ``build`` is also assumed if no command is passed. .. option:: --draft Only render news fragments to standard output. Don't write to files, don't check versions. Only renders the news fragments **without** the surrounding template. .. option:: --name NAME Use `NAME` as project name in the news file. Can be configured. .. option:: --version VERSION Use ``VERSION`` in the rendered news file. Can be configured or guessed (default). .. option:: --date DATE The date in `ISO format `_ to use in the news file. Default: today's date .. option:: --yes Do not ask for confirmations. Useful for automated tasks. .. option:: --keep Don't delete news fragments after the build and don't ask for confirmation whether to delete or keep the fragments. ``towncrier create`` -------------------- Create a news fragment in the directory that ``towncrier`` is configured to look for fragments:: $ towncrier create 123.bugfix.rst ``towncrier create`` will enforce that the passed type (e.g. ``bugfix``) is valid. .. option:: --content, -c CONTENT A string to use for content. Default: an instructive placeholder. .. option:: --edit Create file and start `$EDITOR` to edit it right away.` ``towncrier check`` ------------------- To check if a feature branch adds at least one news fragment, run:: $ towncrier check The check is automatically skipped when the main news file is modified inside the branch as this signals a release branch that is expected to not have news fragments. By default, ``towncrier`` compares the current branch against ``origin/main`` (and falls back to ``origin/master`` with a warning if it exists, *for now*). .. option:: --compare-with REMOTE-BRANCH Use ``REMOTE-BRANCH`` instead of ``origin/main``:: $ towncrier check --compare-with origin/trunk twisted-towncrier-b0e201f/docs/conf.py000066400000000000000000000111661435061103400200410ustar00rootroot00000000000000# # Towncrier documentation build configuration file, created by # sphinx-quickstart on Mon Aug 21 20:46:13 2017. # # This file is execfile()d with the current directory set to its # containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. # 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('.')) # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. # # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. from datetime import date from towncrier import __version__ as towncrier_version extensions = [] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] source_suffix = ".rst" # The master toctree document. master_doc = "index" # General information about the project. _today = date.today() project = "Towncrier" copyright = "{}, Towncrier contributors. Ver {}".format( _today.year, towncrier_version.public(), ) author = "Amber Brown" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # The short X.Y version. version = "{}.{}.{}".format( towncrier_version.major, towncrier_version.minor, towncrier_version.micro ) # The full version, including alpha/beta/rc tags. release = towncrier_version.public() # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This patterns also effect to html_static_path and html_extra_path exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # The name of the Pygments (syntax highlighting) style to use. pygments_style = "sphinx" # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = False # -- 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 = "furo" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # # html_theme_options = {} # 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 = [] # -- Options for HTMLHelp output ------------------------------------------ # Output file base name for HTML help builder. htmlhelp_basename = "Towncrierdoc" # -- Options for LaTeX output --------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. # # 'preamble': '', # Latex figure (float) alignment # # 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ (master_doc, "Towncrier.tex", "Towncrier Documentation", "Amber Brown", "manual"), ] # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [(master_doc, "towncrier", "Towncrier Documentation", [author], 1)] # -- Options for Texinfo output ------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ( master_doc, "Towncrier", "Towncrier Documentation", author, "Towncrier", "One line description of project.", "Miscellaneous", ), ] twisted-towncrier-b0e201f/docs/configuration.rst000066400000000000000000000141351435061103400221420ustar00rootroot00000000000000Configuration Reference ======================= ``towncrier`` has many knobs and switches you can use, to customize it to your project's needs. The setup in the `Quick Start `_ doesn't touch on many, but this document will detail each of these options for you! For how to perform common customization tasks, see `Customization `_. ``[tool.towncrier]`` -------------------- All configuration for ``towncrier`` sits inside ``pyproject.toml``, under the ``tool.towncrier`` namespace. Please see https://toml.io/ for how to write TOML. Top level keys ~~~~~~~~~~~~~~ - ``directory`` -- If you are not storing your news fragments in your Python package, or aren't using Python, this is the path to where your newsfragments will be put. - ``filename`` -- The filename of your news file. ``NEWS.rst`` by default. - ``package`` -- The package name of your project. (Python projects only) - ``package_dir`` -- The folder your package lives. ``./`` by default, some projects might need to use ``src``. (Python projects only) - ``template`` -- Path to an alternate template for generating the news file, if you have one. - ``start_string`` -- The magic string that ``towncrier`` looks for when considering where the release notes should start. ``.. towncrier release notes start`` by default. - ``title_format`` -- A format string for the title of your project. ``{name} {version} ({project_date})`` by default. - ``issue_format`` -- A format string for rendering the issue/ticket number in newsfiles. ``#{issue}`` by default. - ``underlines`` -- The characters used for underlining headers. ``["=", "-", "~"]`` by default. Custom fragment types --------------------- ``towncrier`` allows defining custom fragment types. Custom fragment types will be used instead ``towncrier`` default ones, they are not combined. There are two ways to add custom fragment types. Defining Custom Fragment Types With a TOML Mapping ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Users can configure each of their own custom fragment types by adding tables to the pyproject.toml named ``[tool.towncrier.fragment.]``. These tables may include the following optional keys: * ``name``: The description of the fragment type, as it must be included in the news file. If omitted, it defaults to its fragment type, but capitalized. * ``showcontent``: Whether if the fragment contents should be included in the news file. If omitted, it defaults to ``true`` For example, if you want your custom fragment types to be ``["feat", "fix", "chore",]`` and you want all of them to use the default configuration except ``"chore"`` you can do it as follows: .. code-block:: toml [tool.towncrier] [tool.towncrier.fragment.feat] [tool.towncrier.fragment.fix] [tool.towncrier.fragment.chore] name = "Other Tasks" showcontent = false .. warning:: Since TOML mappings aren't ordered, the sections are always rendered alphabetically. Defining Custom Fragment Types With an Array of TOML Tables ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Users can create their own custom fragment types by adding an array of tables to the pyproject.toml named ``[[tool.towncrier.type]]``. If you use this way to configure custom fragment types, please note that ``fragment_types`` must be empty or not provided. Each custom type (``[[tool.towncrier.type]]``) has the following mandatory keys: * ``directory``: The type / category of the fragment. * ``name``: The description of the fragment type, as it must be included in the news file. * ``showcontent``: Whether if the fragment contents should be included in the news file. For example: .. code-block:: toml [tool.towncrier] [[tool.towncrier.type]] directory = "deprecation" name = "Deprecations" showcontent = true [[tool.towncrier.type]] directory = "chore" name = "Other Tasks" showcontent = false All Options ----------- ``towncrier`` has the following global options, which can be specified in the toml file: .. code-block:: toml [tool.towncrier] package = "" package_dir = "." single_file = true # if false, filename is formatted like `title_format`. filename = "NEWS.rst" directory = "directory/of/news/fragments" version = "1.2.3" # project version if maintained separately name = "arbitrary project name" template = "path/to/template.rst" start_string = "Text used to detect where to add the generated content in the middle of a file. Generated content added after this text. Newline auto added." title_format = "{name} {version} ({project_date})" # or false if template includes title issue_format = "format string for {issue} (issue is the first part of fragment name)" underlines = "=-~" wrap = false # Wrap text to 79 characters all_bullets = true # make all fragments bullet points orphan_prefix = "+" # Prefix for orphan news fragment files, set to "" to disable. If ``single_file`` is set to ``true`` or unspecified, all changes will be written to a single fixed newsfile, whose name is literally fixed as the ``filename`` option. In each run of ``towncrier build``, content of new changes will append at the top of old content, and after ``start_string`` if the ``start_string`` already appears in the newsfile. If the corresponding ``top_line``, which is formatted as the option 'title_format', already exists in newsfile, ``ValueError`` will be raised to remind you "already produced newsfiles for this version". If ``single_file`` is set to ``false`` instead, each versioned ``towncrier build`` will generate a separate newsfile, whose name is formatted as the pattern given by option ``filename``. For example, if ``filename="{version}-notes.rst"``, then the release note with version "7.8.9" will be written to the file "7.8.9-notes.rst". If the newsfile already exists, its content will be overwritten with new release note, without throwing a ``ValueError`` warning. If ``title_format`` is unspecified or an empty string, the default format will be used. If set to ``false``, no title will be created. This can be useful if the specified template creates the title itself. twisted-towncrier-b0e201f/docs/contributing.rst000066400000000000000000000000411435061103400217710ustar00rootroot00000000000000.. include:: ../CONTRIBUTING.rst twisted-towncrier-b0e201f/docs/customization/000077500000000000000000000000001435061103400214455ustar00rootroot00000000000000twisted-towncrier-b0e201f/docs/customization/index.rst000066400000000000000000000004431435061103400233070ustar00rootroot00000000000000Customizing ``towncrier`` ========================= ``towncrier`` can be customized to suit your project's needs. These pages should describe common customization tasks, while if you want a reference, see `Configuration <../configuration.html>`_. .. toctree:: :maxdepth: 2 newsfile twisted-towncrier-b0e201f/docs/customization/newsfile.rst000066400000000000000000000015031435061103400240120ustar00rootroot00000000000000Customizing the News File Output ================================ Adding Content Above ``towncrier`` ---------------------------------- If you wish to have content at the top of the news file (for example, to say where you can find the tickets), you can use a special rST comment to tell ``towncrier`` to only update after it. In your existing news file (e.g. ``NEWS.rst``), add the following line above where you want ``towncrier`` to put content:: .. towncrier release notes start In an existing news file, it'll look something like this:: This is the changelog of my project. You can find the issue tracker at http://blah. .. towncrier release notes start myproject 1.0.2 (2018-01-01) ============================ Bugfixes -------- - Fixed, etc... ``towncrier`` will not alter content above the comment. twisted-towncrier-b0e201f/docs/index.rst000066400000000000000000000006231435061103400203770ustar00rootroot00000000000000.. include:: ../README.rst :end-before: To get started, Documentation ------------- Narrative ~~~~~~~~~ .. toctree:: :maxdepth: 1 tutorial markdown Reference ~~~~~~~~~ .. toctree:: :maxdepth: 2 cli configuration customization/index Development ~~~~~~~~~~~ .. toctree:: :maxdepth: 1 contributing release .. include:: ../README.rst :start-after: .. links twisted-towncrier-b0e201f/docs/markdown.rst000066400000000000000000000124151435061103400211140ustar00rootroot00000000000000How to Keep a Changelog in Markdown =================================== `Keep a Changelog `_ is a standardized way to format a news file in `Markdown `_. This guide shows you how to configure ``towncrier`` for keeping a Markdown-based news file of a project without using any Python-specific features. Everything used here can be use with any other language or platform. This guide makes the following assumptions: - The project lives at https://github.com/twisted/my-project/. - The news file name is ``CHANGELOG.md``. - You store the news fragments in the ``changelog.d`` directory at the root of the project. Put the following into your ``pyproject.toml`` or ``towncrier.toml``: .. code-block:: toml [tool.towncrier] directory = "changelog.d" filename = "CHANGELOG.md" start_string = "\n" underlines = ["", "", ""] template = "changelog.d/changelog_template.jinja" title_format = "## [{version}](https://github.com/twisted/my-project/tree/{version}) - {project_date}" issue_format = "[#{issue}](https://github.com/twisted/my-project/issues/{issue})" [[tool.towncrier.type]] directory = "security" name = "Security" showcontent = true [[tool.towncrier.type]] directory = "removed" name = "Removed" showcontent = true [[tool.towncrier.type]] directory = "deprecated" name = "Deprecated" showcontent = true [[tool.towncrier.type]] directory = "added" name = "Added" showcontent = true [[tool.towncrier.type]] directory = "changed" name = "Changed" showcontent = true [[tool.towncrier.type]] directory = "fixed" name = "Fixed" showcontent = true Next create the news fragment directory and the news file template: .. code-block:: console $ mkdir changelog.d And put the following into ``changelog.d/changelog_template.jinja``: .. code-block:: jinja {% if sections[""] %} {% for category, val in definitions.items() if category in sections[""] %} ### {{ definitions[category]['name'] }} {% for text, values in sections[""][category].items() %} - {{ text }} {{ values|join(', ') }} {% endfor %} {% endfor %} {% else %} No significant changes. {% endif %} Next, create the news file with an explanatory header:: $ cat >CHANGELOG.md <. EOF .. note:: The two empty lines at the end are on purpose. That's it! You can start adding news fragments: .. code-block:: console towncrier create -c "Added a cool feature!" 1.added.md towncrier create -c "Changed a behavior!" 2.changed.md towncrier create -c "Deprecated a module!" 3.deprecated.md towncrier create -c "Removed a square feature!" 4.removed.md towncrier create -c "Fixed a bug!" 5.fixed.md towncrier create -c "Fixed a security issue!" 6.security.md towncrier create -c "Fixed a security issue!" 7.security.md towncrier create -c "A fix without an issue number!" +something-unique.fixed.md After running ``towncrier build --yes --version 1.0.0`` (you can ignore the Git error messages) your ``CHANGELOG.md`` looks like this: .. code-block:: markdown # Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). This project uses [*towncrier*](https://towncrier.readthedocs.io/) and the changes for the upcoming release can be found in . ## [1.0.0](https://github.com/twisted/my-project/tree/1.0.0) - 2022-09-28 ### Security - Fixed a security issue! [#6](https://github.com/twisted/my-project/issues/6), [#7](https://github.com/twisted/my-project/issues/7) ### Removed - Removed a square feature! [#4](https://github.com/twisted/my-project/issues/4) ### Deprecated - Deprecated a module! [#3](https://github.com/twisted/my-project/issues/3) ### Added - Added a cool feature! [#1](https://github.com/twisted/my-project/issues/1) ### Changed - Changed a behavior! [#2](https://github.com/twisted/my-project/issues/2) ### Fixed - Fixed a bug! [#5](https://github.com/twisted/my-project/issues/5) - A fix without an issue number! Pretty close, so this concludes this guide! .. note:: - The sections are rendered in the order the fragment types are defined. - Because ``towncrier`` doesn't have a concept of a "previous version" (yet), the version links will point to the release tags and not to the ``compare`` link like in *Keep a Changelog*. - *Keep a Changelog* doesn't have the concept of a uncategorized change, so the template doesn't expect any. twisted-towncrier-b0e201f/docs/release.rst000066400000000000000000000000341435061103400207040ustar00rootroot00000000000000.. include:: ../RELEASE.rst twisted-towncrier-b0e201f/docs/tutorial.rst000066400000000000000000000145431435061103400211410ustar00rootroot00000000000000Tutorial ======== This tutorial assumes you have a Python project with a *reStructuredText* (rst) news file (also known as changelog) file that you wish to use ``towncrier`` on, to generate its news file. It will cover setting up your project with a basic configuration, which you can then feel free to `customize `_. Install from PyPI:: python3 -m pip install towncrier Configuration ------------- ``towncrier`` keeps its config in the `PEP-518 `_ ``pyproject.toml`` or a ``towncrier.toml`` file. If the latter exists, it takes precedence. The most basic configuration is just telling ``towncrier`` where to look for news fragments:: [tool.towncrier] directory = "changes" Which will look into "./changes" for news fragments and write them into "./NEWS.rst". If you're working on a Python project, you can also specify a package:: [tool.towncrier] # The name of your Python package package = "myproject" # The path to your Python package. # If your package lives in 'src/myproject/', it must be 'src', # but if you don't keep your code in a 'src' dir, remove the # config option package_dir = "src" # Where you want your news files to come out. This can be .rst # or .md, towncrier's default template works with both. filename = "NEWS.rst" By default, ``towncrier`` will look for news fragments inside your Python package, in a directory named ``newsfragments``. With this example project, it will look in ``src/myproject/newsfragments/`` for them. Create this folder:: $ mkdir src/myproject/newsfragments/ # This makes sure that Git will never delete the empty folder $ echo '!.gitignore' > src/myproject/newsfragments/.gitignore The ``.gitignore`` will remain and keep Git from not tracking the directory. Detecting Dates & Versions -------------------------- ``towncrier`` needs to know what version your project is, and there are two ways you can give it: - For Python 2/3-compatible projects, a ``__version__`` in the top level package. This can be either a string literal, a tuple, or an `Incremental `_ version. - Manually passing ``--version=`` when interacting with ``towncrier``. As an example, if your package doesn't have a ``__version__``, you can manually specify it when calling ``towncrier`` on the command line with the ``--version`` flag:: $ towncrier build --version=1.2.3post4 ``towncrier`` will also include the current date (in ``YYYY-MM-DD`` format) when generating news files. You can change this with the ``--date`` flag:: $ towncrier build --date=2018-01-01 Creating News Fragments ----------------------- ``towncrier`` news fragments are categorised according to their 'type'. There are five default types, but you can configure them freely (see `Configuration `_ for details). The five default types are: .. Keep in-sync with DefaultFragmentTypesLoader. - ``feature``: Signifying a new feature. - ``bugfix``: Signifying a bug fix. - ``doc``: Signifying a documentation improvement. - ``removal``: Signifying a deprecation or removal of public API. - ``misc``: A ticket has been closed, but it is not of interest to users. When you create a news fragment, the filename consists of the ticket ID (or some other unique identifier) as well as the 'type'. ``towncrier`` does not care about the fragment's suffix. You can create those fragments either by hand, or using the ``towncrier create`` command. Let's create some example news fragments to demonstrate:: $ echo 'Fixed a thing!' > src/myproject/newsfragments/1234.bugfix $ towncrier create --content 'Can also be ``rst`` as well!' 3456.doc.rst # You can associate multiple ticket numbers with a news fragment by giving them the same contents. $ towncrier create --content 'Can also be ``rst`` as well!' 7890.doc.rst $ echo 'The final part is ignored, so set it to whatever you want.' > src/myproject/newsfragments/8765.removal.txt $ echo 'misc is special, and does not put the contents of the file in the newsfile.' > src/myproject/newsfragments/1.misc $ towncrier create --edit 2.misc.rst # starts an editor $ towncrier create -c "Orphan fragments have no ticket ID." +random.bugfix.rst For orphan news fragments (those that don't need to be linked to any ticket ID or other identifier), start the file name with ``+``. The content will still be included in the release notes, at the end of the category corresponding to the file extension:: $ echo 'Fixed an unreported thing!' > src/myproject/newsfragments/+anything.bugfix .. The --date is the date of towncrier's first release (15.0.0). We can then see our news fragments compiled by running ``towncrier`` in draft mode:: $ towncrier build --draft --name myproject --version 1.0.2 --date 2015-12-27 You should get an output similar to this:: Loading template... Finding news fragments... Rendering news fragments... Draft only -- nothing has been written. What is seen below is what would be written. myproject 1.0.2 (2015-12-27) ============================ Bugfixes -------- - Fixed a thing! (#1234) - Orphan fragments have no ticket ID. Improved Documentation ---------------------- - Can also be ``rst``` as well! (#3456, #7890) Deprecations and Removals ------------------------- - The final part is ignored, so set it to whatever you want. (#8765) Misc ---- - #1, #2 Producing News Files In Production ---------------------------------- To produce the news file for real, run:: $ towncrier This command will remove the news files (with ``git rm``) and append the built news to the filename specified in ``pyproject.toml``, and then stage the news file changes (with ``git add``). It leaves committing the changes up to the user. If you wish to have content at the top of the news file (for example, to say where you can find the tickets), put your text above a rST comment that says:: .. towncrier release notes start ``towncrier`` will then put the version notes after this comment, and leave your existing content that was above it where it is. Finale ------ You should now have everything you need to get started with ``towncrier``! Please see `Customizing `_ for some common c tasks, or `Configuration `_ for the full configuration specification. twisted-towncrier-b0e201f/noxfile.py000066400000000000000000000046401435061103400176270ustar00rootroot00000000000000from __future__ import annotations import os import nox nox.options.sessions = ["pre_commit", "docs", "typecheck", "tests"] nox.options.reuse_existing_virtualenvs = True nox.options.error_on_external_run = True @nox.session def pre_commit(session: nox.Session) -> None: session.install("pre-commit") session.run("pre-commit", "run", "--all-files", "--show-diff-on-failure") @nox.session(python=["pypy3.7", "pypy3.8", "3.7", "3.8", "3.9", "3.10", "3.11"]) def tests(session: nox.Session) -> None: session.install("Twisted", "coverage[toml]") posargs = list(session.posargs) try: # Allow `--use-wheel path/to/wheel.whl` to be passed. i = session.posargs.index("--use-wheel") session.install(session.posargs[i + 1]) del posargs[i : i + 2] except ValueError: session.install(".") if not posargs: posargs = ["towncrier"] session.run("coverage", "run", "--module", "twisted.trial", *posargs) if os.environ.get("CI") != "true": session.notify("coverage_report") else: session.run("coverage", "combine") @nox.session def coverage_report(session: nox.Session) -> None: session.install("coverage[toml]") session.run("coverage", "combine") session.run("coverage", "report") @nox.session def check_newsfragment(session: nox.Session) -> None: session.install(".") session.run("python", "-m", "towncrier.check", "--compare-with", "origin/trunk") @nox.session def check_manifest(session: nox.Session) -> None: session.install("check-manifest") session.run("check-manifest") @nox.session def typecheck(session: nox.Session) -> None: session.install(".", "mypy", "types-setuptools") session.run("mypy", "src") @nox.session def docs(session: nox.Session) -> None: session.install(".[dev]") session.run( # fmt: off "python", "-m", "sphinx", "-T", "-E", "-W", "--keep-going", "-b", "html", "-d", "docs/_build/doctrees", "-D", "language=en", "docs", "docs/_build/html", # fmt: on ) @nox.session def build(session: nox.Session) -> None: session.install("build", "check-manifest>=0.44", "twine") session.run("check-manifest", "--verbose") # If no argument is passed, build builds an sdist and then a wheel from # that sdist. session.run("python", "-m", "build") session.run("twine", "check", "dist/*") twisted-towncrier-b0e201f/pyproject.toml000066400000000000000000000041421435061103400205220ustar00rootroot00000000000000[tool.towncrier] package = "towncrier" package_dir = "src" filename = "NEWS.rst" issue_format = "`#{issue} `_" [[tool.towncrier.section]] path = "" [[tool.towncrier.type]] directory = "feature" name = "Features" showcontent = true [[tool.towncrier.type]] directory = "bugfix" name = "Bugfixes" showcontent = true [[tool.towncrier.type]] directory = "doc" name = "Improved Documentation" showcontent = true [[tool.towncrier.type]] directory = "removal" name = "Deprecations and Removals" showcontent = true [[tool.towncrier.type]] directory = "misc" name = "Misc" showcontent = false [tool.black] target-version = ['py37'] exclude = ''' ( /( \.eggs # exclude a few common directories in the | \.git # root of the project | \.nox | \.venv | \.env | env | _build | _trial_temp.* | build | dist | debian )/ ) ''' [tool.isort] profile = "attrs" line_length = 88 [tool.mypy] strict = true # 2022-09-04: Trial's API isn't annotated yet, which limits the usefulness of type-checking # the unit tests. Therefore they have not been annotated yet. exclude = '^src/towncrier/test/test_.*\.py$' [[tool.mypy.overrides]] module = 'click_default_group' # 2022-09-04: This library has no type annotations. ignore_missing_imports = true [[tool.mypy.overrides]] module = 'incremental' # No released version with type hints. ignore_missing_imports = true [build-system] requires = [ "setuptools ~= 44.1.1", "wheel ~= 0.36.2", "incremental == 22.10.0", ] build-backend = "setuptools.build_meta" [tool.coverage.run] parallel = true branch = true source = ["towncrier"] [tool.coverage.paths] source = ["src", ".nox/*/site-packages"] [tool.coverage.report] show_missing = true skip_covered = true exclude_lines = [ "pragma: no cover", "if TYPE_CHECKING:", ] omit = [ "src/towncrier/__main__.py", "src/towncrier/test/*", ] twisted-towncrier-b0e201f/setup.py000066400000000000000000000043301435061103400173170ustar00rootroot00000000000000#!/usr/bin/env python # If incremental is not present then setuptools just silently uses v0.0.0 so # let's import it and fail instead. import incremental # noqa from setuptools import find_packages, setup setup( name="towncrier", url="https://github.com/twisted/towncrier", project_urls={ "Documentation": "https://towncrier.readthedocs.io/", "Chat": "https://web.libera.chat/?channels=%23twisted", "Mailing list": "https://mail.python.org/mailman3/lists/twisted.python.org/", "Issues": "https://github.com/twisted/towncrier/issues", "Repository": "https://github.com/twisted/towncrier", "Tests": "https://github.com/twisted/towncrier/actions?query=branch%3Atrunk", "Coverage": "https://codecov.io/gh/twisted/towncrier", "Distribution": "https://pypi.org/project/towncrier", }, classifiers=[ "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: POSIX :: Linux", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Programming Language :: Python :: 3", "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", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ], use_incremental=True, python_requires=">=3.7", install_requires=[ "click", "click-default-group", "incremental", "jinja2", "setuptools", "tomli; python_version<'3.11'", ], extras_require={ "dev": [ "packaging", "sphinx >= 5", "furo", "twisted", ], }, package_dir={"": "src"}, packages=find_packages("src"), license="MIT", zip_safe=False, include_package_data=True, description="Building newsfiles for your project.", long_description=open("README.rst").read(), entry_points={"console_scripts": ["towncrier = towncrier._shell:cli"]}, ) twisted-towncrier-b0e201f/src/000077500000000000000000000000001435061103400163745ustar00rootroot00000000000000twisted-towncrier-b0e201f/src/towncrier/000077500000000000000000000000001435061103400204105ustar00rootroot00000000000000twisted-towncrier-b0e201f/src/towncrier/__init__.py000066400000000000000000000003231435061103400225170ustar00rootroot00000000000000# Copyright (c) Amber Brown, 2015 # See LICENSE for details. """ towncrier, a builder for your news files. """ from __future__ import annotations from ._version import __version__ __all__ = ["__version__"] twisted-towncrier-b0e201f/src/towncrier/__main__.py000066400000000000000000000001151435061103400224770ustar00rootroot00000000000000from __future__ import annotations from towncrier._shell import cli cli() twisted-towncrier-b0e201f/src/towncrier/_builder.py000066400000000000000000000252341435061103400225550ustar00rootroot00000000000000# Copyright (c) Amber Brown, 2015 # See LICENSE for details. from __future__ import annotations import os import textwrap import traceback from collections import OrderedDict, defaultdict from typing import Any, DefaultDict, Iterable, Iterator, Mapping, Sequence from jinja2 import Template from ._settings import ConfigError def strip_if_integer_string(s: str) -> str: try: i = int(s) except ValueError: return s return str(i) # Returns ticket, category and counter or (None, None, None) if the basename # could not be parsed or doesn't contain a valid category. def parse_newfragment_basename( basename: str, frag_type_names: Iterable[str] ) -> tuple[str, str, int] | tuple[None, None, None]: invalid = (None, None, None) parts = basename.split(".") if len(parts) == 1: return invalid if len(parts) == 2: ticket, category = parts ticket = strip_if_integer_string(ticket) return (ticket, category, 0) if category in frag_type_names else invalid # There are at least 3 parts. Search for a valid category from the second # part onwards. # The category is used as the reference point in the parts list to later # infer the issue number and counter value. for i in range(1, len(parts)): if parts[i] in frag_type_names: # Current part is a valid category according to given definitions. category = parts[i] # Use the previous part as the ticket number. # NOTE: This allows news fragment names like fix-1.2.3.feature or # something-cool.feature.ext for projects that don't use ticket # numbers in news fragment names. ticket = strip_if_integer_string(parts[i - 1]) counter = 0 # Use the following part as the counter if it exists and is a valid # digit. if len(parts) > (i + 1) and parts[i + 1].isdigit(): counter = int(parts[i + 1]) return ticket, category, counter else: # No valid category found. return invalid # Returns a structure like: # # OrderedDict([ # ("", # { # ("142", "misc"): u"", # ("1", "feature"): u"some cool description", # }), # ("Names", {}), # ("Web", {("3", "bugfix"): u"Fixed a thing"}), # ]) # # We should really use attrs. # # Also returns a list of the paths that the fragments were taken from. def find_fragments( base_directory: str, sections: Mapping[str, str], fragment_directory: str | None, frag_type_names: Iterable[str], orphan_prefix: str | None = None, ) -> tuple[Mapping[str, Mapping[tuple[str, str, int], str]], list[str]]: """ Sections are a dictonary of section names to paths. """ content = OrderedDict() fragment_filenames = [] # Multiple orphan news fragments are allowed per section, so initialize a counter # that can be incremented automatically. orphan_fragment_counter: DefaultDict[str | None, int] = defaultdict(int) for key, val in sections.items(): if fragment_directory is not None: section_dir = os.path.join(base_directory, val, fragment_directory) else: section_dir = os.path.join(base_directory, val) try: files = os.listdir(section_dir) except FileNotFoundError as e: message = "Failed to list the news fragment files.\n{}".format( "".join(traceback.format_exception_only(type(e), e)), ) raise ConfigError(message) file_content = {} for basename in files: ticket, category, counter = parse_newfragment_basename( basename, frag_type_names ) if category is None: continue assert ticket is not None assert counter is not None if orphan_prefix and ticket.startswith(orphan_prefix): ticket = "" # Use and increment the orphan news fragment counter. counter = orphan_fragment_counter[category] orphan_fragment_counter[category] += 1 full_filename = os.path.join(section_dir, basename) fragment_filenames.append(full_filename) with open(full_filename, "rb") as f: data = f.read().decode("utf8", "replace") if (ticket, category, counter) in file_content: raise ValueError( "multiple files for {}.{} in {}".format( ticket, category, section_dir ) ) file_content[ticket, category, counter] = data content[key] = file_content return content, fragment_filenames def indent(text: str, prefix: str) -> str: """ Adds `prefix` to the beginning of non-empty lines in `text`. """ # Based on Python 3's textwrap.indent def prefixed_lines() -> Iterator[str]: for line in text.splitlines(True): yield (prefix + line if line.strip() else line) return "".join(prefixed_lines()) # Takes the output from find_fragments above. Probably it would be useful to # add an example output here. Next time someone digs deep enough to figure it # out, please do so... def split_fragments( fragments: Mapping[str, Mapping[tuple[str, str, int], str]], definitions: Mapping[str, Mapping[str, Any]], all_bullets: bool = True, ) -> Mapping[str, Mapping[str, Mapping[str, Sequence[str]]]]: output = OrderedDict() for section_name, section_fragments in fragments.items(): section: dict[str, dict[str, list[str]]] = {} for (ticket, category, counter), content in section_fragments.items(): if all_bullets: # By default all fragmetns are append by "-" automatically, # and need to be indented because of that. # (otherwise, assume they are formatted correctly) content = indent(content.strip(), " ")[2:] else: # Assume the text is formatted correctly content = content.rstrip() if definitions[category]["showcontent"] is False: content = "" texts = section.setdefault(category, OrderedDict()) tickets = texts.setdefault(content, []) if ticket: # Only add the ticket if we have one (it can be blank for orphan news # fragments). tickets.append(ticket) tickets.sort() output[section_name] = section return output def issue_key(issue: str) -> tuple[int, str]: # We want integer issues to sort as integers, and we also want string # issues to sort as strings. We arbitrarily put string issues before # integer issues (hopefully no-one uses both at once). try: return (int(issue), "") except Exception: # Maybe we should sniff strings like "gh-10" -> (10, "gh-10")? return (-1, issue) def entry_key(entry: tuple[str, Sequence[str]]) -> tuple[str, list[tuple[int, str]]]: content, issues = entry # Orphan news fragments (those without any issues) should sort last by content. return "" if issues else content, [issue_key(issue) for issue in issues] def bullet_key(entry: tuple[str, Sequence[str]]) -> int: text, _ = entry if not text: return -1 if text[:2] == "- ": return 0 elif text[:2] == "* ": return 1 elif text[:3] == "#. ": return 2 return 3 def render_issue(issue_format: str | None, issue: str) -> str: if issue_format is None: try: int(issue) return "#" + issue except Exception: return issue else: return issue_format.format(issue=issue) def render_fragments( template: str, issue_format: str | None, fragments: Mapping[str, Mapping[str, Mapping[str, Sequence[str]]]], definitions: Mapping[str, Mapping[str, Any]], underlines: Sequence[str], wrap: bool, versiondata: Mapping[str, str], top_underline: str = "=", all_bullets: bool = False, render_title: bool = True, ) -> str: """ Render the fragments into a news file. """ jinja_template = Template(template, trim_blocks=True) data: dict[str, dict[str, dict[str, list[str]]]] = OrderedDict() for section_name, section_value in fragments.items(): data[section_name] = OrderedDict() for category_name, category_value in section_value.items(): # Suppose we start with an ordering like this: # # - Fix the thing (#7, #123, #2) # - Fix the other thing (#1) # First we sort the issues inside each line: # # - Fix the thing (#2, #7, #123) # - Fix the other thing (#1) entries = [] for text, issues in category_value.items(): entries.append((text, sorted(issues, key=issue_key))) # Then we sort the lines: # # - Fix the other thing (#1) # - Fix the thing (#2, #7, #123) entries.sort(key=entry_key) if not all_bullets: entries.sort(key=bullet_key) # Then we put these nicely sorted entries back in an ordered dict # for the template, after formatting each issue number categories = OrderedDict() for text, issues in entries: rendered = [render_issue(issue_format, i) for i in issues] categories[text] = rendered data[section_name][category_name] = categories done = [] def get_indent(text: str) -> str: # If bullets are not assumed and we wrap, the subsequent # indentation depends on whether or not this is a bullet point. # (it is probably usually best to disable wrapping in that case) if all_bullets or text[:2] == "- " or text[:2] == "* ": return " " elif text[:3] == "#. ": return " " return "" res = jinja_template.render( render_title=render_title, sections=data, definitions=definitions, underlines=underlines, versiondata=versiondata, top_underline=top_underline, get_indent=get_indent, # simplify indentation in the jinja template. ) for line in res.split("\n"): if wrap: done.append( textwrap.fill( line, width=79, subsequent_indent=get_indent(line), break_long_words=False, break_on_hyphens=False, ) ) else: done.append(line) return "\n".join(done).rstrip() + "\n" twisted-towncrier-b0e201f/src/towncrier/_git.py000066400000000000000000000017411435061103400217070ustar00rootroot00000000000000# Copyright (c) Amber Brown, 2015 # See LICENSE for details. from __future__ import annotations import os from subprocess import STDOUT, call, check_output def remove_files(fragment_filenames: list[str]) -> None: if fragment_filenames: call(["git", "rm", "--quiet"] + fragment_filenames) def stage_newsfile(directory: str, filename: str) -> None: call(["git", "add", os.path.join(directory, filename)]) def get_remote_branches(base_directory: str) -> list[str]: output = check_output( ["git", "branch", "-r"], cwd=base_directory, encoding="utf-8", stderr=STDOUT ) return [branch.strip() for branch in output.strip().splitlines()] def list_changed_files_compared_to_branch( base_directory: str, compare_with: str ) -> list[str]: output = check_output( ["git", "diff", "--name-only", compare_with + "..."], cwd=base_directory, encoding="utf-8", stderr=STDOUT, ) return output.strip().splitlines() twisted-towncrier-b0e201f/src/towncrier/_project.py000066400000000000000000000045521435061103400225750ustar00rootroot00000000000000# Copyright (c) Amber Brown, 2015 # See LICENSE for details. """ Responsible for getting the version and name from a project. """ from __future__ import annotations import sys from importlib import import_module from types import ModuleType from incremental import Version as IncrementalVersion def _get_package(package_dir: str, package: str) -> ModuleType: try: module = import_module(package) except ImportError: # Package is not already available / installed. # Force importing it based on the source files. sys.path.insert(0, package_dir) try: module = import_module(package) except ImportError as e: err = f"tried to import {package}, but ran into this error: {e}" # NOTE: this might be redirected via "towncrier --draft > …". print(f"ERROR: {err}") raise finally: sys.path.pop(0) return module def get_version(package_dir: str, package: str) -> str: module = _get_package(package_dir, package) version = getattr(module, "__version__", None) if not version: raise Exception("No __version__, I don't know how else to look") if isinstance(version, str): return version.strip() if isinstance(version, IncrementalVersion): # FIXME:https://github.com/twisted/incremental/issues/81 # Incremental uses `.rcN`. # importlib uses `rcN` (without a dot separation). # Here we make incremental work like importlib. return version.base().strip().replace(".rc", "rc") if isinstance(version, tuple): return ".".join(map(str, version)).strip() raise Exception( "I only know how to look at a __version__ that is a str, " "an Increment Version, or a tuple. If you can't provide " "that, use the --version argument and specify one." ) def get_project_name(package_dir: str, package: str) -> str: module = _get_package(package_dir, package) version = getattr(module, "__version__", None) if not version: # welp idk return package.title() if isinstance(version, str): return package.title() if isinstance(version, IncrementalVersion): # Incremental has support for package names return version.package raise TypeError(f"Unsupported type for __version__: {type(version)}") twisted-towncrier-b0e201f/src/towncrier/_settings/000077500000000000000000000000001435061103400224075ustar00rootroot00000000000000twisted-towncrier-b0e201f/src/towncrier/_settings/__init__.py000066400000000000000000000011361435061103400245210ustar00rootroot00000000000000"""Subpackage to handle settings parsing.""" from __future__ import annotations from towncrier._settings import load load_config = load.load_config ConfigError = load.ConfigError load_config_from_options = load.load_config_from_options # Help message for --config CLI option, shared by all sub-commands. config_option_help = ( "Pass a custom config file at FILE_PATH. " "Default: towncrier.toml or pyproject.toml file, " "if both files exist, the first will take precedence." ) __all__ = [ "config_option_help", "load_config", "ConfigError", "load_config_from_options", ] twisted-towncrier-b0e201f/src/towncrier/_settings/fragment_types.py000066400000000000000000000106311435061103400260110ustar00rootroot00000000000000from __future__ import annotations import abc import collections as clt from typing import Any, Iterable, Mapping class BaseFragmentTypesLoader: """Base class to load fragment types.""" __metaclass__ = abc.ABCMeta def __init__(self, config: Mapping[str, Any]): """Initialize.""" self.config = config @classmethod def factory(cls, config: Mapping[str, Any]) -> BaseFragmentTypesLoader: fragment_types_class: type[BaseFragmentTypesLoader] = DefaultFragmentTypesLoader fragment_types = config.get("fragment", {}) types_config = config.get("type", {}) if fragment_types: fragment_types_class = TableFragmentTypesLoader elif types_config: fragment_types_class = ArrayFragmentTypesLoader new = fragment_types_class(config) return new @abc.abstractmethod def load(self) -> Mapping[str, Mapping[str, Any]]: """Load fragment types.""" class DefaultFragmentTypesLoader(BaseFragmentTypesLoader): """Default towncrier's fragment types.""" _default_types = clt.OrderedDict( [ # Keep in-sync with docs/tutorial.rst. ("feature", {"name": "Features", "showcontent": True}), ("bugfix", {"name": "Bugfixes", "showcontent": True}), ("doc", {"name": "Improved Documentation", "showcontent": True}), ("removal", {"name": "Deprecations and Removals", "showcontent": True}), ("misc", {"name": "Misc", "showcontent": False}), ] ) def load(self) -> Mapping[str, Mapping[str, Any]]: """Load default types.""" return self._default_types class ArrayFragmentTypesLoader(BaseFragmentTypesLoader): """Load fragment types from an toml array of tables. This loader get the custom fragment types defined through a toml array of tables, that ``toml`` parses as an array of mappings. For example:: [tool.towncrier] [[tool.towncrier.type]] directory = "deprecation" name = "Deprecations" showcontent = true """ def load(self) -> Mapping[str, Mapping[str, Any]]: """Load types from toml array of mappings.""" types = clt.OrderedDict() types_config = self.config["type"] for type_config in types_config: directory = type_config["directory"] fragment_type_name = type_config["name"] is_content_required = type_config["showcontent"] types[directory] = { "name": fragment_type_name, "showcontent": is_content_required, } return types class TableFragmentTypesLoader(BaseFragmentTypesLoader): """Load fragment types from toml tables. This loader get the custom fragment types defined through a toml tables, that ``toml`` parses as an nested mapping. This loader allows omitting ``name`` and ```showcontent`` fields. ``name`` by default is the capitalized fragment type. ``showcontent`` is true by default. For example:: [tool.towncrier] [tool.towncrier.fragment.chore] name = "Chores" showcontent = False [tool.towncrier.fragment.deprecations] # name will be "Deprecations" # The content will be shown. """ def __init__(self, config: Mapping[str, Mapping[str, Any]]): """Initialize.""" self.config = config self.fragment_options = config.get("fragment", {}) def load(self) -> Mapping[str, Mapping[str, Any]]: """Load types from nested mapping.""" fragment_types: Iterable[str] = self.fragment_options.keys() fragment_types = sorted(fragment_types) custom_types_sequence = [ (fragment_type, self._load_options(fragment_type)) for fragment_type in fragment_types ] types = clt.OrderedDict(custom_types_sequence) return types def _load_options(self, fragment_type: str) -> Mapping[str, Any]: """Load fragment options.""" capitalized_fragment_type = fragment_type.capitalize() options = self.fragment_options.get(fragment_type, {}) fragment_description = options.get("name", capitalized_fragment_type) show_content = options.get("showcontent", True) clean_fragment_options = { "name": fragment_description, "showcontent": show_content, } return clean_fragment_options twisted-towncrier-b0e201f/src/towncrier/_settings/load.py000066400000000000000000000126741435061103400237120ustar00rootroot00000000000000# Copyright (c) Amber Brown, 2015 # See LICENSE for details. from __future__ import annotations import os import sys from collections import OrderedDict from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Mapping import pkg_resources from .._settings import fragment_types as ft if TYPE_CHECKING: # We only use Literal for type-checking and Mypy always brings its own # typing_extensions so this is safe without further dependencies. if sys.version_info < (3, 8): from typing_extensions import Literal else: from typing import Literal if sys.version_info < (3, 11): import tomli as tomllib else: import tomllib @dataclass class Config: package: str package_dir: str single_file: bool filename: str directory: str | None version: str | None name: str | None sections: Mapping[str, str] types: Mapping[str, Mapping[str, Any]] template: str start_string: str title_format: str | Literal[False] issue_format: str | None underlines: list[str] wrap: bool all_bullets: bool orphan_prefix: str class ConfigError(Exception): def __init__(self, *args: str, **kwargs: str): self.failing_option = kwargs.get("failing_option") super().__init__(*args) _start_string = ".. towncrier release notes start\n" _title_format = None _template_fname = "towncrier:default" _underlines = ["=", "-", "~"] def load_config_from_options( directory: str | None, config_path: str | None ) -> tuple[str, Config]: if config_path is None: if directory is None: directory = os.getcwd() base_directory = os.path.abspath(directory) config = load_config(base_directory) else: config_path = os.path.abspath(config_path) if directory is None: base_directory = os.path.dirname(config_path) else: base_directory = os.path.abspath(directory) config = load_config_from_file(os.path.dirname(config_path), config_path) if config is None: raise ConfigError(f"No configuration file found.\nLooked in: {base_directory}") return base_directory, config def load_config(directory: str) -> Config | None: towncrier_toml = os.path.join(directory, "towncrier.toml") pyproject_toml = os.path.join(directory, "pyproject.toml") if os.path.exists(towncrier_toml): config_file = towncrier_toml elif os.path.exists(pyproject_toml): config_file = pyproject_toml else: return None return load_config_from_file(directory, config_file) def load_config_from_file(directory: str, config_file: str) -> Config: with open(config_file, "rb") as conffile: config = tomllib.load(conffile) return parse_toml(directory, config) def parse_toml(base_path: str, config: Mapping[str, Any]) -> Config: if "tool" not in config: raise ConfigError("No [tool.towncrier] section.", failing_option="all") config = config["tool"]["towncrier"] sections = OrderedDict() if "section" in config: for x in config["section"]: sections[x.get("name", "")] = x["path"] else: sections[""] = "" fragment_types_loader = ft.BaseFragmentTypesLoader.factory(config) types = fragment_types_loader.load() wrap = config.get("wrap", False) single_file_wrong = config.get("singlefile") if single_file_wrong: raise ConfigError( "`singlefile` is not a valid option. Did you mean `single_file`?", failing_option="singlefile", ) single_file = config.get("single_file", True) if not isinstance(single_file, bool): raise ConfigError( "`single_file` option must be a boolean: false or true.", failing_option="single_file", ) all_bullets = config.get("all_bullets", True) if not isinstance(all_bullets, bool): raise ConfigError( "`all_bullets` option must be boolean: false or true.", failing_option="all_bullets", ) template = config.get("template", _template_fname) if template.startswith("towncrier:"): resource_name = "templates/" + template.split("towncrier:", 1)[1] + ".rst" if not pkg_resources.resource_exists("towncrier", resource_name): raise ConfigError( "Towncrier does not have a template named '%s'." % (template.split("towncrier:", 1)[1],) ) template = pkg_resources.resource_filename("towncrier", resource_name) else: template = os.path.join(base_path, template) if not os.path.exists(template): raise ConfigError( f"The template file '{template}' does not exist.", failing_option="template", ) return Config( package=config.get("package", ""), package_dir=config.get("package_dir", "."), single_file=single_file, filename=config.get("filename", "NEWS.rst"), directory=config.get("directory"), version=config.get("version"), name=config.get("name"), sections=sections, types=types, template=template, start_string=config.get("start_string", _start_string), title_format=config.get("title_format", _title_format), issue_format=config.get("issue_format"), underlines=config.get("underlines", _underlines), wrap=wrap, all_bullets=all_bullets, orphan_prefix=config.get("orphan_prefix", "+"), ) twisted-towncrier-b0e201f/src/towncrier/_shell.py000066400000000000000000000030711435061103400222310ustar00rootroot00000000000000# Copyright (c) Stephen Finucane, 2019 # See LICENSE for details. """ Entry point of the command line interface. Each sub-command has its separate CLI definition andd help messages. """ from __future__ import annotations import click from click_default_group import DefaultGroup from ._version import __version__ from .build import _main as _build_cmd from .check import _main as _check_cmd from .create import _main as _create_cmd @click.group(cls=DefaultGroup, default="build", default_if_no_args=True) @click.version_option(__version__.public()) def cli() -> None: """ Towncrier is a utility to produce useful, summarised news files for your project. Rather than reading the Git history as some newer tools to produce it, or having one single file which developers all write to, towncrier reads "news fragments" which contain information useful to end users. Towncrier delivers the news which is convenient to those that hear it, not those that write it. That is, a “news fragment” (a small file containing just enough information to be useful to end users) can be written that summarises what has changed from the “developer log” (which may contain complex information about the original issue, how it was fixed, who authored the fix, and who reviewed the fix). By compiling a collection of these fragments, towncrier can produce a digest of the changes which is valuable to those who may wish to use the software. """ pass cli.add_command(_build_cmd) cli.add_command(_check_cmd) cli.add_command(_create_cmd) twisted-towncrier-b0e201f/src/towncrier/_version.py000066400000000000000000000004141435061103400226050ustar00rootroot00000000000000""" Provides towncrier version information. """ # This file is auto-generated! Do not edit! # Use `python -m incremental.update towncrier` to change this file. from incremental import Version __version__ = Version("towncrier", 22, 12, 0) __all__ = ["__version__"] twisted-towncrier-b0e201f/src/towncrier/_writer.py000066400000000000000000000043001435061103400224320ustar00rootroot00000000000000# Copyright (c) Amber Brown, 2015 # See LICENSE for details. """ Responsible for writing the built news fragments to a news file without affecting existing content. """ from __future__ import annotations from pathlib import Path def append_to_newsfile( directory: str, filename: str, start_string: str, top_line: str, content: str, single_file: bool, ) -> None: """ Write *content* to *directory*/*filename* behind *start_string*. Double-check *top_line* (i.e. the release header) is not already in the file. if *single_file* is True, add it to an existing file, otherwise create a fresh one. """ news_file = Path(directory) / filename header, prev_body = _figure_out_existing_content( news_file, start_string, single_file ) if top_line and top_line in prev_body: raise ValueError("It seems you've already produced newsfiles for this version?") # Leave newlines alone. This probably leads to inconsistent newlines, # because we've loaded existing content with universal newlines, but that's # the original behavior. with news_file.open("w", encoding="utf8", newline="") as f: if header: f.write(header) f.write(content) if prev_body: f.write(f"\n\n{prev_body}") def _figure_out_existing_content( news_file: Path, start_string: str, single_file: bool ) -> tuple[str, str]: """ Try to read *news_file* and split it into header (everything before *start_string*) and the old body (everything after *start_string*). If there's no *start_string*, return empty header. Empty file and per-release files have neither. """ if not single_file or not news_file.exists(): # Per-release news files always start empty. # Non-existent files have no existing content. return "", "" # If we didn't use universal newlines here, we wouldn't find *start_string* # which usually contains a `\n`. with news_file.open(encoding="utf8") as f: content = f.read() t = content.split(start_string, 1) if len(t) == 2: return f"{t[0].rstrip()}\n\n{start_string}\n", t[1].lstrip() return "", content.lstrip() twisted-towncrier-b0e201f/src/towncrier/build.py000066400000000000000000000201641435061103400220640ustar00rootroot00000000000000# Copyright (c) Amber Brown, 2015 # See LICENSE for details. """ Build a combined news file from news fragments. """ from __future__ import annotations import os import sys from datetime import date import click from click import Context, Option from towncrier import _git from ._builder import find_fragments, render_fragments, split_fragments from ._project import get_project_name, get_version from ._settings import ConfigError, config_option_help, load_config_from_options from ._writer import append_to_newsfile def _get_date() -> str: return date.today().isoformat() def _validate_answer(ctx: Context, param: Option, value: bool) -> bool: value_check = ( ctx.params.get("answer_yes") if param.name == "answer_keep" else ctx.params.get("answer_keep") ) if value_check and value: click.echo("You can not choose both --yes and --keep at the same time") ctx.abort() return value @click.command(name="build") @click.option( "--draft", "draft", default=False, flag_value=True, help=( "Render the news fragments to standard output. " "Don't write to files, don't check versions." ), ) @click.option( "--config", "config_file", default=None, metavar="FILE_PATH", help=config_option_help, ) @click.option( "--dir", "directory", default=None, metavar="PATH", help="Build fragment in directory. Default to current directory.", ) @click.option( "--name", "project_name", default=None, help="Pass a custom project name.", ) @click.option( "--version", "project_version", default=None, help="Render the news fragments using given version.", ) @click.option("--date", "project_date", default=None) @click.option( "--yes", "answer_yes", default=None, flag_value=True, help="Do not ask for confirmation to remove news fragments.", callback=_validate_answer, ) @click.option( "--keep", "answer_keep", default=None, flag_value=True, help="Do not ask for confirmations. But keep news fragments.", callback=_validate_answer, ) def _main( draft: bool, directory: str | None, config_file: str | None, project_name: str | None, project_version: str | None, project_date: str | None, answer_yes: bool, answer_keep: bool, ) -> None: """ Build a combined news file from news fragment. """ try: return __main( draft, directory, config_file, project_name, project_version, project_date, answer_yes, answer_keep, ) except ConfigError as e: print(e, file=sys.stderr) sys.exit(1) def __main( draft: bool, directory: str | None, config_file: str | None, project_name: str | None, project_version: str | None, project_date: str | None, answer_yes: bool, answer_keep: bool, ) -> None: """ The main entry point. """ base_directory, config = load_config_from_options(directory, config_file) to_err = draft click.echo("Loading template...", err=to_err) with open(config.template, "rb") as tmpl: template = tmpl.read().decode("utf8") click.echo("Finding news fragments...", err=to_err) if config.directory is not None: fragment_base_directory = os.path.abspath(config.directory) fragment_directory = None else: fragment_base_directory = os.path.abspath( os.path.join(base_directory, config.package_dir, config.package) ) fragment_directory = "newsfragments" fragment_contents, fragment_filenames = find_fragments( fragment_base_directory, config.sections, fragment_directory, config.types, config.orphan_prefix, ) click.echo("Rendering news fragments...", err=to_err) fragments = split_fragments( fragment_contents, config.types, all_bullets=config.all_bullets ) if project_version is None: project_version = config.version if project_version is None: project_version = get_version( os.path.join(base_directory, config.package_dir), config.package ).strip() if project_name is None: project_name = config.name if not project_name: package = config.package if package: project_name = get_project_name( os.path.abspath(os.path.join(base_directory, config.package_dir)), package, ) else: # Can't determine a project_name, but maybe it is not needed. project_name = "" if project_date is None: project_date = _get_date().strip() if config.title_format: top_line = config.title_format.format( name=project_name, version=project_version, project_date=project_date ) render_title_with_fragments = False render_title_separately = True elif config.title_format is False: # This is an odd check but since we support both "" and False with # different effects we have to do something a bit abnormal here. top_line = "" render_title_separately = False render_title_with_fragments = False else: top_line = "" render_title_separately = False render_title_with_fragments = True rendered = render_fragments( # The 0th underline is used for the top line template, config.issue_format, fragments, config.types, config.underlines[1:], config.wrap, {"name": project_name, "version": project_version, "date": project_date}, top_underline=config.underlines[0], all_bullets=config.all_bullets, render_title=render_title_with_fragments, ) if render_title_separately: content = "\n".join( [ top_line, config.underlines[0] * len(top_line), rendered, ] ) else: content = rendered if draft: click.echo( "Draft only -- nothing has been written.\n" "What is seen below is what would be written.\n", err=to_err, ) click.echo(content) else: click.echo("Writing to newsfile...", err=to_err) news_file = config.filename if config.single_file is False: # The release notes for each version are stored in a separate file. # The name of that file is generated based on the current version and project. news_file = news_file.format( name=project_name, version=project_version, project_date=project_date ) append_to_newsfile( base_directory, news_file, config.start_string, top_line, content, single_file=config.single_file, ) click.echo("Staging newsfile...", err=to_err) _git.stage_newsfile(base_directory, news_file) click.echo("Removing news fragments...", err=to_err) if should_remove_fragment_files( fragment_filenames, answer_yes, answer_keep, ): _git.remove_files(fragment_filenames) click.echo("Done!", err=to_err) def should_remove_fragment_files( fragment_filenames: list[str], answer_yes: bool, answer_keep: bool, ) -> bool: try: if answer_keep: click.echo("Keeping the following files:") # Not proceeding with the removal of the files. return False if answer_yes: click.echo("Removing the following files:") else: click.echo("I want to remove the following files:") finally: # Will always be printed, even for answer_keep to help with possible troubleshooting for filename in fragment_filenames: click.echo(filename) if answer_yes or click.confirm("Is it okay if I remove those files?", default=True): return True return False if __name__ == "__main__": # pragma: no cover _main() twisted-towncrier-b0e201f/src/towncrier/check.py000066400000000000000000000074621435061103400220500ustar00rootroot00000000000000# Copyright (c) Amber Brown, 2018 # See LICENSE for details. from __future__ import annotations import os import sys from subprocess import CalledProcessError from typing import Container from warnings import warn import click from ._builder import find_fragments from ._git import get_remote_branches, list_changed_files_compared_to_branch from ._settings import config_option_help, load_config_from_options def _get_default_compare_branch(branches: Container[str]) -> str | None: if "origin/main" in branches: return "origin/main" if "origin/master" in branches: warn( 'Using "origin/master" as default compare branch is deprecated ' "and will be removed in a future version.", DeprecationWarning, stacklevel=2, ) return "origin/master" return None @click.command(name="check") @click.option( "--compare-with", default=None, metavar="BRANCH", help=( "Checks files changed running git diff --name-ony BRANCH... " "BRANCH is the branch to be compared with. " "Default to origin/main" ), ) @click.option( "--dir", "directory", default=None, metavar="PATH", help="Check fragment in directory. Default to current directory.", ) @click.option( "--config", "config", default=None, metavar="FILE_PATH", help=config_option_help, ) def _main(compare_with: str | None, directory: str | None, config: str | None) -> None: """ Check for new fragments on a branch. """ __main(compare_with, directory, config) def __main( comparewith: str | None, directory: str | None, config_path: str | None ) -> None: base_directory, config = load_config_from_options(directory, config_path) if comparewith is None: comparewith = _get_default_compare_branch( get_remote_branches(base_directory=base_directory) ) if comparewith is None: click.echo("Could not detect default branch. Aborting.") sys.exit(1) try: files_changed = list_changed_files_compared_to_branch( base_directory, comparewith ) except CalledProcessError as e: click.echo("git produced output while failing:") click.echo(e.output) raise if not files_changed: click.echo( f"On {comparewith} branch, or no diffs, so no newsfragment required." ) sys.exit(0) files = { os.path.normpath(os.path.join(base_directory, path)) for path in files_changed } click.echo("Looking at these files:") click.echo("----") for n, change in enumerate(files, start=1): click.echo(f"{n}. {change}") click.echo("----") news_file = os.path.normpath(os.path.join(base_directory, config.filename)) if news_file in files: click.echo("Checks SKIPPED: news file changes detected.") sys.exit(0) if config.directory: fragment_base_directory = os.path.abspath(config.directory) fragment_directory = None else: fragment_base_directory = os.path.abspath( os.path.join(base_directory, config.package_dir, config.package) ) fragment_directory = "newsfragments" fragments = { os.path.normpath(path) for path in find_fragments( fragment_base_directory, config.sections, fragment_directory, config.types.keys(), )[1] } fragments_in_branch = fragments & files if not fragments_in_branch: click.echo("No new newsfragments found on this branch.") sys.exit(1) else: click.echo("Found:") for n, fragment in enumerate(fragments_in_branch, start=1): click.echo(f"{n}. {fragment}") sys.exit(0) if __name__ == "__main__": # pragma: no cover _main() twisted-towncrier-b0e201f/src/towncrier/create.py000066400000000000000000000100051435061103400222210ustar00rootroot00000000000000# Copyright (c) Stephen Finucane, 2019 # See LICENSE for details. """ Create a new fragment. """ from __future__ import annotations import os import click from ._settings import config_option_help, load_config_from_options @click.command(name="create") @click.pass_context @click.option( "--dir", "directory", default=None, metavar="PATH", help="Create fragment in directory. Default to current directory.", ) @click.option( "--config", "config", default=None, metavar="FILE_PATH", help=config_option_help, ) @click.option( "--edit/--no-edit", default=False, help="Open an editor for writing the newsfragment content.", ) # TODO: default should be true @click.option( "-c", "--content", type=str, default="Add your info here", help="Sets the content of the new fragment.", ) @click.argument("filename") def _main( ctx: click.Context, directory: str | None, config: str | None, filename: str, edit: bool, content: str, ) -> None: """ Create a new news fragment. Create a new news fragment called FILENAME or pass the full path for a file. Towncrier has a few standard types of news fragments, signified by the file extension. \b These are: * .feature - a new feature * .bugfix - a bug fix * .doc - a documentation improvement, * .removal - a deprecation or removal of public API, * .misc - a ticket has been closed, but it is not of interest to users. """ __main(ctx, directory, config, filename, edit, content) def __main( ctx: click.Context, directory: str | None, config_path: str | None, filename: str, edit: bool, content: str, ) -> None: """ The main entry point. """ base_directory, config = load_config_from_options(directory, config_path) if config.orphan_prefix and filename.startswith(f"{config.orphan_prefix}."): # Append a random hex string to the orphan news fragment base name. filename = f"{config.orphan_prefix}{os.urandom(4).hex()}{filename[1:]}" if len(filename.split(".")) < 2 or ( filename.split(".")[-1] not in config.types and filename.split(".")[-2] not in config.types ): raise click.BadParameter( "Expected filename '{}' to be of format '{{name}}.{{type}}', " "where '{{name}}' is an arbitrary slug and '{{type}}' is " "one of: {}".format(filename, ", ".join(config.types)) ) if config.directory: fragments_directory = os.path.abspath( os.path.join(base_directory, config.directory) ) else: fragments_directory = os.path.abspath( os.path.join( base_directory, config.package_dir, config.package, "newsfragments", ) ) if not os.path.exists(fragments_directory): os.makedirs(fragments_directory) segment_file = os.path.join(fragments_directory, filename) if os.path.exists(segment_file): raise click.ClickException(f"{segment_file} already exists") if edit: edited_content = _get_news_content_from_user(content) if edited_content is None: click.echo("Abort creating news fragment.") ctx.exit(1) content = edited_content with open(segment_file, "w") as f: f.write(content) click.echo(f"Created news fragment at {segment_file}") def _get_news_content_from_user(message: str) -> str | None: initial_content = ( "# Please write your news content. When finished, save the file.\n" "# In order to abort, exit without saving.\n" '# Lines starting with "#" are ignored.\n' ) initial_content += f"\n{message}\n" content = click.edit(initial_content) if content is None: return None all_lines = content.split("\n") lines = [line.rstrip() for line in all_lines if not line.lstrip().startswith("#")] return "\n".join(lines) if __name__ == "__main__": # pragma: no cover _main() twisted-towncrier-b0e201f/src/towncrier/newsfragments/000077500000000000000000000000001435061103400232735ustar00rootroot00000000000000twisted-towncrier-b0e201f/src/towncrier/newsfragments/.gitignore000066400000000000000000000000141435061103400252560ustar00rootroot00000000000000!.gitignore twisted-towncrier-b0e201f/src/towncrier/templates/000077500000000000000000000000001435061103400224065ustar00rootroot00000000000000twisted-towncrier-b0e201f/src/towncrier/templates/default.rst000066400000000000000000000022551435061103400245700ustar00rootroot00000000000000{% if render_title %} {% if versiondata.name %} {{ versiondata.name }} {{ versiondata.version }} ({{ versiondata.date }}) {{ top_underline * ((versiondata.name + versiondata.version + versiondata.date)|length + 4)}} {% else %} {{ versiondata.version }} ({{ versiondata.date }}) {{ top_underline * ((versiondata.version + versiondata.date)|length + 3)}} {% endif %} {% endif %} {% for section, _ in sections.items() %} {% set underline = underlines[0] %}{% if section %}{{section}} {{ underline * section|length }}{% set underline = underlines[1] %} {% endif %} {% if sections[section] %} {% for category, val in definitions.items() if category in sections[section]%} {{ definitions[category]['name'] }} {{ underline * definitions[category]['name']|length }} {% if definitions[category]['showcontent'] %} {% for text, values in sections[section][category].items() %} - {{ text }}{% if values %} ({{ values|join(', ') }}){% endif %} {% endfor %} {% else %} - {{ sections[section][category]['']|join(', ') }} {% endif %} {% if sections[section][category]|length == 0 %} No significant changes. {% else %} {% endif %} {% endfor %} {% else %} No significant changes. {% endif %} {% endfor %} twisted-towncrier-b0e201f/src/towncrier/templates/hr-between-versions.rst000066400000000000000000000022321435061103400270450ustar00rootroot00000000000000{% if render_title %} {% if versiondata.name %} {{ versiondata.name }} {{ versiondata.version }} ({{ versiondata.date }}) {{ top_underline * ((versiondata.name + versiondata.version + versiondata.date)|length + 4)}} {% else %} {{ versiondata.version }} ({{ versiondata.date }}) {{ top_underline * ((versiondata.version + versiondata.date)|length + 3)}} {% endif %} {% endif %} {% for section, _ in sections.items() %} {% set underline = underlines[0] %}{% if section %}{{section}} {{ underline * section|length }}{% set underline = underlines[1] %} {% endif %} {% if sections[section] %} {% for category, val in definitions.items() if category in sections[section]%} {{ definitions[category]['name'] }} {{ underline * definitions[category]['name']|length }} {% if definitions[category]['showcontent'] %} {% for text, values in sections[section][category].items() %} - {{ text }} {{ values|join(',\n ') }} {% endfor %} {% else %} - {{ sections[section][category]['']|join(', ') }} {% endif %} {% if sections[section][category]|length == 0 %} No significant changes. {% else %} {% endif %} {% endfor %} {% else %} No significant changes. {% endif %} {% endfor %} ---- twisted-towncrier-b0e201f/src/towncrier/templates/single-file-no-bullets.rst000066400000000000000000000022451435061103400274230ustar00rootroot00000000000000{% if render_title %} {% if versiondata.name %} {{ versiondata.name }} {{ versiondata.version }} ({{ versiondata.date }}) {{ top_underline * ((versiondata.name + versiondata.version + versiondata.date)|length + 4)}} {% else %} {{ versiondata.version }} ({{ versiondata.date }}) {{ top_underline * ((versiondata.version + versiondata.date)|length + 3)}} {% endif %} {% endif %} {% for section, _ in sections.items() %} {% set underline = underlines[0] %}{% if section %}{{section}} {{ underline * section|length }}{% set underline = underlines[1] %} {% endif %} {% if sections[section] %} {% for category, val in definitions.items() if category in sections[section] %} {{ definitions[category]['name'] }} {{ underline * definitions[category]['name']|length }} {% if definitions[category]['showcontent'] %} {% for text, values in sections[section][category].items() %} {{ text }} {{ get_indent(text) }}({{values|join(', ') }}) {% endfor %} {% else %} - {{ sections[section][category]['']|join(', ') }} {% endif %} {% if sections[section][category]|length == 0 %} No significant changes. {% else %} {% endif %} {% endfor %} {% else %} No significant changes. {% endif %} {% endfor %} twisted-towncrier-b0e201f/src/towncrier/test/000077500000000000000000000000001435061103400213675ustar00rootroot00000000000000twisted-towncrier-b0e201f/src/towncrier/test/__init__.py000066400000000000000000000000001435061103400234660ustar00rootroot00000000000000twisted-towncrier-b0e201f/src/towncrier/test/helpers.py000066400000000000000000000025071435061103400234070ustar00rootroot00000000000000from __future__ import annotations from functools import wraps from pathlib import Path from typing import Any, Callable from click.testing import CliRunner def read(filename: str | Path) -> str: return Path(filename).read_text() def write(path: str | Path, contents: str) -> None: """ Create a file with given contents including any missing parent directories """ p = Path(path) p.parent.mkdir(parents=True, exist_ok=True) p.write_text(contents) def with_isolated_runner(fn: Callable[..., Any]) -> Callable[..., Any]: """ Run *fn* within an isolated filesystem and add the kwarg *runner* to its arguments. """ @wraps(fn) def test(*args: Any, **kw: Any) -> Any: runner = CliRunner() with runner.isolated_filesystem(): return fn(*args, runner=runner, **kw) return test def setup_simple_project( *, config: str | None = None, extra_config: str = "", pyproject_path: str = "pyproject.toml", mkdir_newsfragments: bool = True, ) -> None: if config is None: config = "[tool.towncrier]\n" 'package = "foo"\n' + extra_config Path(pyproject_path).write_text(config) Path("foo").mkdir() Path("foo/__init__.py").write_text('__version__ = "1.2.3"\n') if mkdir_newsfragments: Path("foo/newsfragments").mkdir() twisted-towncrier-b0e201f/src/towncrier/test/test_build.py000066400000000000000000001156211435061103400241050ustar00rootroot00000000000000# Copyright (c) Amber Brown, 2015 # See LICENSE for details. import os import tempfile from datetime import date from pathlib import Path from subprocess import call from textwrap import dedent from unittest.mock import patch from click.testing import CliRunner from twisted.trial.unittest import TestCase from .._shell import cli from ..build import _main from .helpers import read, setup_simple_project, with_isolated_runner class TestCli(TestCase): maxDiff = None def _test_command(self, command): runner = CliRunner() with runner.isolated_filesystem(): setup_simple_project() with open("foo/newsfragments/123.feature", "w") as f: f.write("Adds levitation") # Towncrier treats this as 124.feature, ignoring .rst extension with open("foo/newsfragments/124.feature.rst", "w") as f: f.write("Extends levitation") # Towncrier supports non-numeric newsfragment names. with open("foo/newsfragments/baz.feature.rst", "w") as f: f.write("Baz levitation") # Towncrier supports files that have a dot in the name of the # newsfragment with open("foo/newsfragments/fix-1.2.feature", "w") as f: f.write("Baz fix levitation") # Towncrier supports fragments not linked to a feature with open("foo/newsfragments/+anything.feature", "w") as f: f.write("Orphaned feature") with open("foo/newsfragments/+xxx.feature", "w") as f: f.write("Another orphaned feature") # Towncrier ignores files that don't have a dot with open("foo/newsfragments/README", "w") as f: f.write("Blah blah") # And files that don't have a valid category with open("foo/newsfragments/README.rst", "w") as f: f.write("**Blah blah**") result = runner.invoke(command, ["--draft", "--date", "01-01-2001"]) self.assertEqual(0, result.exit_code) self.assertEqual( result.output, dedent( """\ Loading template... Finding news fragments... Rendering news fragments... Draft only -- nothing has been written. What is seen below is what would be written. Foo 1.2.3 (01-01-2001) ====================== Features -------- - Baz levitation (baz) - Baz fix levitation (#2) - Adds levitation (#123) - Extends levitation (#124) - Another orphaned feature - Orphaned feature """ ), ) def test_command(self): self._test_command(cli) def test_subcommand(self): self._test_command(_main) @with_isolated_runner def test_in_different_dir_dir_option(self, runner): """ The current working directory doesn't matter as long as we pass the correct one. """ project_dir = Path(".").resolve() setup_simple_project() Path("foo/newsfragments/123.feature").write_text("Adds levitation") # Ensure our assetion below is meaningful. self.assertFalse((project_dir / "NEWS.rst").exists()) # Create a temporary directory, run Towncrier from there and assert # it didn't litter into it. td = tempfile.TemporaryDirectory() self.addCleanup(td.cleanup) os.chdir(td.name) result = runner.invoke(cli, ("--yes", "--dir", str(project_dir))) self.assertEqual([], list(Path(td.name).glob("*"))) self.assertEqual(0, result.exit_code) self.assertTrue((project_dir / "NEWS.rst").exists()) @with_isolated_runner def test_in_different_dir_config_option(self, runner): """ The current working directory and the location of the configuration don't matter as long as we pass corrct paths to the directory and the config file. """ project_dir = Path(".").resolve() setup_simple_project() Path("foo/newsfragments/123.feature").write_text("Adds levitation") # Ensure our assetion below is meaningful. self.assertFalse((project_dir / "NEWS.rst").exists()) # Create a temporary directory, move the config file there, run # Towncrier from there, and assert it didn't litter into it. td = tempfile.TemporaryDirectory() self.addCleanup(td.cleanup) os.chdir(td.name) (project_dir / "pyproject.toml").rename("pyproject.toml") result = runner.invoke( cli, ("--yes", "--config", "pyproject.toml", "--dir", str(project_dir)) ) # There's only pyproject.toml in this directory. self.assertEqual( [Path(td.name) / "pyproject.toml"], list(Path(td.name).glob("*")) ) self.assertEqual(0, result.exit_code) self.assertTrue((project_dir / "NEWS.rst").exists()) @with_isolated_runner def test_no_newsfragment_directory(self, runner): """ A missing newsfragment directory acts as if there are no changes. """ setup_simple_project() os.rmdir("foo/newsfragments") result = runner.invoke(_main, ["--draft", "--date", "01-01-2001"]) self.assertEqual(1, result.exit_code, result.output) self.assertIn("Failed to list the news fragment files.\n", result.output) def test_no_newsfragments_draft(self): """ An empty newsfragment directory acts as if there are no changes. """ runner = CliRunner() with runner.isolated_filesystem(): setup_simple_project() result = runner.invoke(_main, ["--draft", "--date", "01-01-2001"]) self.assertEqual(0, result.exit_code) self.assertIn("No significant changes.\n", result.output) def test_no_newsfragments(self): """ An empty newsfragment directory acts as if there are no changes and removing files handles it gracefully. """ runner = CliRunner() with runner.isolated_filesystem(): setup_simple_project() result = runner.invoke(_main, ["--date", "01-01-2001"]) news = read("NEWS.rst") self.assertEqual(0, result.exit_code) self.assertIn("No significant changes.\n", news) def test_collision(self): runner = CliRunner() with runner.isolated_filesystem(): setup_simple_project() # Note that both are 123.feature with open("foo/newsfragments/123.feature", "w") as f: f.write("Adds levitation") with open("foo/newsfragments/123.feature.rst", "w") as f: f.write("Extends levitation") result = runner.invoke(_main, ["--draft", "--date", "01-01-2001"]) # This should fail self.assertEqual(type(result.exception), ValueError) self.assertIn("multiple files for 123.feature", str(result.exception)) def test_section_and_type_sorting(self): """ Sections and types should be output in the same order that they're defined in the config file. """ runner = CliRunner() def run_order_scenario(sections, types): with runner.isolated_filesystem(): with open("pyproject.toml", "w") as f: f.write( dedent( """ [tool.towncrier] package = "foo" directory = "news" """ ) ) for section in sections: f.write( dedent( """ [[tool.towncrier.section]] path = "{section}" name = "{section}" """.format( section=section ) ) ) for type_ in types: f.write( dedent( """ [[tool.towncrier.type]] directory = "{type_}" name = "{type_}" showcontent = true """.format( type_=type_ ) ) ) os.mkdir("foo") with open("foo/__init__.py", "w") as f: f.write('__version__ = "1.2.3"\n') os.mkdir("news") for section in sections: sectdir = "news/" + section os.mkdir(sectdir) for type_ in types: with open(f"{sectdir}/1.{type_}", "w") as f: f.write(f"{section} {type_}") return runner.invoke( _main, ["--draft", "--date", "01-01-2001"], catch_exceptions=False ) result = run_order_scenario(["section-a", "section-b"], ["type-1", "type-2"]) self.assertEqual(0, result.exit_code) self.assertEqual( result.output, "Loading template...\nFinding news fragments...\nRendering news " "fragments...\nDraft only -- nothing has been written.\nWhat is " "seen below is what would be written.\n\nFoo 1.2.3 (01-01-2001)" "\n======================" + dedent( """ section-a --------- type-1 ~~~~~~ - section-a type-1 (#1) type-2 ~~~~~~ - section-a type-2 (#1) section-b --------- type-1 ~~~~~~ - section-b type-1 (#1) type-2 ~~~~~~ - section-b type-2 (#1) """ ), ) result = run_order_scenario(["section-b", "section-a"], ["type-2", "type-1"]) self.assertEqual(0, result.exit_code) self.assertEqual( result.output, "Loading template...\nFinding news fragments...\nRendering news " "fragments...\nDraft only -- nothing has been written.\nWhat is " "seen below is what would be written.\n\nFoo 1.2.3 (01-01-2001)" "\n======================" + dedent( """ section-b --------- type-2 ~~~~~~ - section-b type-2 (#1) type-1 ~~~~~~ - section-b type-1 (#1) section-a --------- type-2 ~~~~~~ - section-a type-2 (#1) type-1 ~~~~~~ - section-a type-1 (#1) """ ), ) def test_draft_no_date(self): """ If no date is passed, today's date is used. """ runner = CliRunner() with runner.isolated_filesystem(): setup_simple_project() fragment_path1 = "foo/newsfragments/123.feature" fragment_path2 = "foo/newsfragments/124.feature.rst" with open(fragment_path1, "w") as f: f.write("Adds levitation") with open(fragment_path2, "w") as f: f.write("Extends levitation") call(["git", "init"]) call(["git", "config", "user.name", "user"]) call(["git", "config", "user.email", "user@example.com"]) call(["git", "add", "."]) call(["git", "commit", "-m", "Initial Commit"]) today = date.today() result = runner.invoke(_main, ["--draft"]) self.assertEqual(0, result.exit_code) self.assertIn(f"Foo 1.2.3 ({today.isoformat()})", result.output) def test_no_confirmation(self): runner = CliRunner() with runner.isolated_filesystem(): setup_simple_project() fragment_path1 = "foo/newsfragments/123.feature" fragment_path2 = "foo/newsfragments/124.feature.rst" with open(fragment_path1, "w") as f: f.write("Adds levitation") with open(fragment_path2, "w") as f: f.write("Extends levitation") call(["git", "init"]) call(["git", "config", "user.name", "user"]) call(["git", "config", "user.email", "user@example.com"]) call(["git", "add", "."]) call(["git", "commit", "-m", "Initial Commit"]) result = runner.invoke(_main, ["--date", "01-01-2001", "--yes"]) self.assertEqual(0, result.exit_code) path = "NEWS.rst" self.assertTrue(os.path.isfile(path)) self.assertFalse(os.path.isfile(fragment_path1)) self.assertFalse(os.path.isfile(fragment_path2)) @with_isolated_runner def test_keep_fragments(self, runner): """ The `--keep` option will build the full final news file without deleting the fragment files and without any extra CLI interaction or confirmation. """ setup_simple_project() fragment_path1 = "foo/newsfragments/123.feature" fragment_path2 = "foo/newsfragments/124.feature.rst" with open(fragment_path1, "w") as f: f.write("Adds levitation") with open(fragment_path2, "w") as f: f.write("Extends levitation") call(["git", "init"]) call(["git", "config", "user.name", "user"]) call(["git", "config", "user.email", "user@example.com"]) call(["git", "add", "."]) call(["git", "commit", "-m", "Initial Commit"]) result = runner.invoke(_main, ["--date", "01-01-2001", "--keep"]) self.assertEqual(0, result.exit_code) # The NEWS file is created. # So this is not just `--draft`. self.assertTrue(os.path.isfile("NEWS.rst")) self.assertTrue(os.path.isfile(fragment_path1)) self.assertTrue(os.path.isfile(fragment_path2)) @with_isolated_runner def test_yes_keep_error(self, runner): """ It will fail to perform any action when the conflicting --keep and --yes options are provided. Called twice with the different order of --keep and --yes options to make sure both orders are validated since click triggers the validator in the order it parses the command line. """ setup_simple_project() fragment_path1 = "foo/newsfragments/123.feature" fragment_path2 = "foo/newsfragments/124.feature.rst" with open(fragment_path1, "w") as f: f.write("Adds levitation") with open(fragment_path2, "w") as f: f.write("Extends levitation") call(["git", "init"]) call(["git", "config", "user.name", "user"]) call(["git", "config", "user.email", "user@example.com"]) call(["git", "add", "."]) call(["git", "commit", "-m", "Initial Commit"]) result = runner.invoke(_main, ["--date", "01-01-2001", "--yes", "--keep"]) self.assertEqual(1, result.exit_code) result = runner.invoke(_main, ["--date", "01-01-2001", "--keep", "--yes"]) self.assertEqual(1, result.exit_code) def test_confirmation_says_no(self): """ If the user says "no" to removing the newsfragements, we end up with a NEWS.rst AND the newsfragments. """ runner = CliRunner() with runner.isolated_filesystem(): setup_simple_project() fragment_path1 = "foo/newsfragments/123.feature" fragment_path2 = "foo/newsfragments/124.feature.rst" with open(fragment_path1, "w") as f: f.write("Adds levitation") with open(fragment_path2, "w") as f: f.write("Extends levitation") call(["git", "init"]) call(["git", "config", "user.name", "user"]) call(["git", "config", "user.email", "user@example.com"]) call(["git", "add", "."]) call(["git", "commit", "-m", "Initial Commit"]) with patch("towncrier.build.click.confirm") as m: m.return_value = False result = runner.invoke(_main, []) self.assertEqual(0, result.exit_code) path = "NEWS.rst" self.assertTrue(os.path.isfile(path)) self.assertTrue(os.path.isfile(fragment_path1)) self.assertTrue(os.path.isfile(fragment_path2)) def test_needs_config(self): """ Towncrier needs a configuration file. """ runner = CliRunner() with runner.isolated_filesystem(): result = runner.invoke(_main, ["--draft"]) self.assertEqual(1, result.exit_code, result.output) self.assertTrue(result.output.startswith("No configuration file found.")) def test_projectless_changelog(self): """In which a directory containing news files is built into a changelog - without a Python project or version number. We override the project title from the commandline. """ runner = CliRunner() with runner.isolated_filesystem(): with open("pyproject.toml", "w") as f: f.write("[tool.towncrier]\n" 'package = "foo"\n') os.mkdir("foo") os.mkdir("foo/newsfragments") with open("foo/newsfragments/123.feature", "w") as f: f.write("Adds levitation") # Towncrier ignores .rst extension with open("foo/newsfragments/124.feature.rst", "w") as f: f.write("Extends levitation") result = runner.invoke( _main, [ "--name", "FooBarBaz", "--version", "7.8.9", "--date", "01-01-2001", "--draft", ], ) self.assertEqual(0, result.exit_code) self.assertEqual( result.output, dedent( """ Loading template... Finding news fragments... Rendering news fragments... Draft only -- nothing has been written. What is seen below is what would be written. FooBarBaz 7.8.9 (01-01-2001) ============================ Features -------- - Adds levitation (#123) - Extends levitation (#124) """ ).lstrip(), ) def test_version_in_config(self): """The calling towncrier with version defined in configfile. Specifying a version in toml file will be helpful if version is maintained by i.e. bumpversion and it's not a python project. """ runner = CliRunner() with runner.isolated_filesystem(): with open("pyproject.toml", "w") as f: f.write("[tool.towncrier]\n" 'version = "7.8.9"\n') os.mkdir("newsfragments") with open("newsfragments/123.feature", "w") as f: f.write("Adds levitation") result = runner.invoke(_main, ["--date", "01-01-2001", "--draft"]) self.assertEqual(0, result.exit_code, result.output) self.assertEqual( result.output, dedent( """ Loading template... Finding news fragments... Rendering news fragments... Draft only -- nothing has been written. What is seen below is what would be written. 7.8.9 (01-01-2001) ================== Features -------- - Adds levitation (#123) """ ).lstrip(), ) def test_project_name_in_config(self): """The calling towncrier with project name defined in configfile. Specifying a project name in toml file will be helpful to keep the project name consistent as part of the towncrier configuration, not call. """ runner = CliRunner() with runner.isolated_filesystem(): with open("pyproject.toml", "w") as f: f.write("[tool.towncrier]\n" 'name = "ImGoProject"\n') os.mkdir("newsfragments") with open("newsfragments/123.feature", "w") as f: f.write("Adds levitation") result = runner.invoke( _main, ["--version", "7.8.9", "--date", "01-01-2001", "--draft"] ) self.assertEqual(0, result.exit_code, result.output) self.assertEqual( result.output, dedent( """ Loading template... Finding news fragments... Rendering news fragments... Draft only -- nothing has been written. What is seen below is what would be written. ImGoProject 7.8.9 (01-01-2001) ============================== Features -------- - Adds levitation (#123) """ ).lstrip(), ) def test_no_package_changelog(self): """The calling towncrier with any package argument. Specifying a package in the toml file or the command line should not always be needed: - we can set the version number on the command line, so we do not need the package for that. - we don't need to include the package in the changelog header. """ runner = CliRunner() with runner.isolated_filesystem(): with open("pyproject.toml", "w") as f: f.write("[tool.towncrier]") os.mkdir("newsfragments") with open("newsfragments/123.feature", "w") as f: f.write("Adds levitation") result = runner.invoke( _main, ["--version", "7.8.9", "--date", "01-01-2001", "--draft"] ) self.assertEqual(0, result.exit_code, result.output) self.assertEqual( result.output, dedent( """ Loading template... Finding news fragments... Rendering news fragments... Draft only -- nothing has been written. What is seen below is what would be written. 7.8.9 (01-01-2001) ================== Features -------- - Adds levitation (#123) """ ).lstrip(), ) def test_release_notes_in_separate_files(self): """ When `single_file = false` the release notes for each version are stored in a separate file. The name of the file is defined by the `filename` configuration value. """ runner = CliRunner() def do_build_once_with(version, fragment_file, fragment): with open(f"newsfragments/{fragment_file}", "w") as f: f.write(fragment) result = runner.invoke( _main, [ "--version", version, "--name", "foo", "--date", "01-01-2001", "--yes", ], ) # not git repository, manually remove fragment file Path(f"newsfragments/{fragment_file}").unlink() return result results = [] with runner.isolated_filesystem(): with open("pyproject.toml", "w") as f: f.write( "\n".join( [ "[tool.towncrier]", " single_file=false", ' filename="{version}-notes.rst"', ] ) ) os.mkdir("newsfragments") results.append( do_build_once_with("7.8.9", "123.feature", "Adds levitation") ) results.append(do_build_once_with("7.9.0", "456.bugfix", "Adds catapult")) self.assertEqual(0, results[0].exit_code, results[0].output) self.assertEqual(0, results[1].exit_code, results[1].output) self.assertEqual( 2, len(list(Path.cwd().glob("*-notes.rst"))), "one newfile for each build", ) self.assertTrue(os.path.exists("7.8.9-notes.rst"), os.listdir(".")) self.assertTrue(os.path.exists("7.9.0-notes.rst"), os.listdir(".")) outputs = [] outputs.append(read("7.8.9-notes.rst")) outputs.append(read("7.9.0-notes.rst")) self.assertEqual( outputs[0], dedent( """ foo 7.8.9 (01-01-2001) ====================== Features -------- - Adds levitation (#123) """ ).lstrip(), ) self.assertEqual( outputs[1], dedent( """ foo 7.9.0 (01-01-2001) ====================== Bugfixes -------- - Adds catapult (#456) """ ).lstrip(), ) def test_singlefile_errors_and_explains_cleanly(self): """ Failure to find the configuration file results in a clean explanation without a traceback. """ runner = CliRunner() with runner.isolated_filesystem(): with open("pyproject.toml", "w") as f: f.write('[tool.towncrier]\n singlefile="fail!"\n') result = runner.invoke(_main) self.assertEqual(1, result.exit_code) self.assertEqual( "`singlefile` is not a valid option. Did you mean `single_file`?\n", result.output, ) def test_all_version_notes_in_a_single_file(self): """ When `single_file = true` the single file is used to store the notes for multiple versions. The name of the file is fixed as the literal option `filename` option in the configuration file, instead of extrapolated with variables. """ runner = CliRunner() def do_build_once_with(version, fragment_file, fragment): with open(f"newsfragments/{fragment_file}", "w") as f: f.write(fragment) result = runner.invoke( _main, [ "--version", version, "--name", "foo", "--date", "01-01-2001", "--yes", ], catch_exceptions=False, ) # not git repository, manually remove fragment file Path(f"newsfragments/{fragment_file}").unlink() return result results = [] with runner.isolated_filesystem(): with open("pyproject.toml", "w") as f: f.write( "\n".join( [ "[tool.towncrier]", " single_file=true", " # The `filename` variable is fixed and not formated in any way.", ' filename="{version}-notes.rst"', ] ) ) os.mkdir("newsfragments") results.append( do_build_once_with("7.8.9", "123.feature", "Adds levitation") ) results.append(do_build_once_with("7.9.0", "456.bugfix", "Adds catapult")) self.assertEqual(0, results[0].exit_code, results[0].output) self.assertEqual(0, results[1].exit_code, results[1].output) self.assertEqual( 1, len(list(Path.cwd().glob("*-notes.rst"))), "single newfile for multiple builds", ) self.assertTrue(os.path.exists("{version}-notes.rst"), os.listdir(".")) output = read("{version}-notes.rst") self.assertEqual( output, dedent( """ foo 7.9.0 (01-01-2001) ====================== Bugfixes -------- - Adds catapult (#456) foo 7.8.9 (01-01-2001) ====================== Features -------- - Adds levitation (#123) """ ).lstrip(), ) def test_bullet_points_false(self): """ When all_bullets is false, subsequent lines are not indented. The automatic ticket number inserted by towncrier will align with the manual bullet. """ runner = CliRunner() with runner.isolated_filesystem(): with open("pyproject.toml", "w") as f: f.write( "[tool.towncrier]\n" 'template="towncrier:single-file-no-bullets"\n' "all_bullets=false" ) os.mkdir("newsfragments") with open("newsfragments/123.feature", "w") as f: f.write("wow!\n~~~~\n\nNo indentation at all.") with open("newsfragments/124.bugfix", "w") as f: f.write("#. Numbered bullet list.") with open("newsfragments/125.removal", "w") as f: f.write("- Hyphen based bullet list.") with open("newsfragments/126.doc", "w") as f: f.write("* Asterisk based bullet list.") result = runner.invoke( _main, [ "--version", "7.8.9", "--name", "foo", "--date", "01-01-2001", "--yes", ], ) self.assertEqual(0, result.exit_code, result.output) output = read("NEWS.rst") self.assertEqual( output, """ foo 7.8.9 (01-01-2001) ====================== Features -------- wow! ~~~~ No indentation at all. (#123) Bugfixes -------- #. Numbered bullet list. (#124) Improved Documentation ---------------------- * Asterisk based bullet list. (#126) Deprecations and Removals ------------------------- - Hyphen based bullet list. (#125) """.lstrip(), ) def test_title_format_custom(self): """ A non-empty title format adds the specified title. """ runner = CliRunner() with runner.isolated_filesystem(): with open("pyproject.toml", "w") as f: f.write( dedent( """\ [tool.towncrier] package = "foo" title_format = "[{project_date}] CUSTOM RELEASE for {name} version {version}" """ ) ) os.mkdir("foo") os.mkdir("foo/newsfragments") with open("foo/newsfragments/123.feature", "w") as f: f.write("Adds levitation") # Towncrier ignores .rst extension with open("foo/newsfragments/124.feature.rst", "w") as f: f.write("Extends levitation") result = runner.invoke( _main, [ "--name", "FooBarBaz", "--version", "7.8.9", "--date", "20-01-2001", "--draft", ], ) expected_output = dedent( """\ Loading template... Finding news fragments... Rendering news fragments... Draft only -- nothing has been written. What is seen below is what would be written. [20-01-2001] CUSTOM RELEASE for FooBarBaz version 7.8.9 ======================================================= Features -------- - Adds levitation (#123) - Extends levitation (#124) """ ) self.assertEqual(0, result.exit_code) self.assertEqual(expected_output, result.output) def test_title_format_false(self): """ Setting the title format to false disables the explicit title. This would be used, for example, when the template creates the title itself. """ runner = CliRunner() with runner.isolated_filesystem(): with open("pyproject.toml", "w") as f: f.write( dedent( """\ [tool.towncrier] package = "foo" title_format = false template = "template.rst" """ ) ) os.mkdir("foo") os.mkdir("foo/newsfragments") with open("template.rst", "w") as f: f.write( dedent( """\ Here's a hardcoded title added by the template ============================================== {% for section in sections %} {% set underline = "-" %} {% for category, val in definitions.items() if category in sections[section] %} {% for text, values in sections[section][category]|dictsort(by='value') %} - {{ text }} {% endfor %} {% endfor %} {% endfor %} """ ) ) result = runner.invoke( _main, [ "--name", "FooBarBaz", "--version", "7.8.9", "--date", "20-01-2001", "--draft", ], ) expected_output = dedent( """\ Loading template... Finding news fragments... Rendering news fragments... Draft only -- nothing has been written. What is seen below is what would be written. Here's a hardcoded title added by the template ============================================== """ ) self.assertEqual(0, result.exit_code) self.assertEqual(expected_output, result.output) def test_start_string(self): """ The `start_string` configuration is used to detect the starting point for inserting the generated release notes. A newline is automatically added to the configured value. """ runner = CliRunner() with runner.isolated_filesystem(): with open("pyproject.toml", "w") as f: f.write( dedent( """\ [tool.towncrier] start_string="Release notes start marker" """ ) ) os.mkdir("newsfragments") with open("newsfragments/123.feature", "w") as f: f.write("Adds levitation") with open("NEWS.rst", "w") as f: f.write("a line\n\nanother\n\nRelease notes start marker\na footer!\n") result = runner.invoke( _main, [ "--version", "7.8.9", "--name", "foo", "--date", "01-01-2001", "--yes", ], ) self.assertEqual(0, result.exit_code, result.output) self.assertTrue(os.path.exists("NEWS.rst"), os.listdir(".")) output = read("NEWS.rst") expected_output = dedent( """\ a line another Release notes start marker foo 7.8.9 (01-01-2001) ====================== Features -------- - Adds levitation (#123) a footer! """ ) self.assertEqual(expected_output, output) def test_with_topline_and_template_and_draft(self): """ Spacing is proper when drafting with a topline and a template. """ runner = CliRunner() with runner.isolated_filesystem(): with open("pyproject.toml", "w") as f: f.write( dedent( """\ [tool.towncrier] title_format = "{version} - {project_date}" template = "template.rst" [[tool.towncrier.type]] directory = "feature" name = "" showcontent = true """ ) ) os.mkdir("newsfragments") with open("newsfragments/123.feature", "w") as f: f.write("Adds levitation") with open("template.rst", "w") as f: f.write( dedent( """\ {% for section in sections %} {% set underline = "-" %} {% for category, val in definitions.items() if category in sections[section] %} {% for text, values in sections[section][category]|dictsort(by='value') %} - {{ text }} {% endfor %} {% endfor %} {% endfor %} """ ) ) result = runner.invoke( _main, [ "--version=7.8.9", "--name=foo", "--date=20-01-2001", "--draft", ], ) expected_output = dedent( """\ Loading template... Finding news fragments... Rendering news fragments... Draft only -- nothing has been written. What is seen below is what would be written. 7.8.9 - 20-01-2001 ================== - Adds levitation """ ) self.assertEqual(0, result.exit_code, result.output) self.assertEqual(expected_output, result.output) twisted-towncrier-b0e201f/src/towncrier/test/test_builder.py000066400000000000000000000047551435061103400244410ustar00rootroot00000000000000# Copyright (c) Povilas Kanapickas, 2019 # See LICENSE for details. from twisted.trial.unittest import TestCase from .._builder import parse_newfragment_basename class TestParseNewsfragmentBasename(TestCase): def test_simple(self): self.assertEqual( parse_newfragment_basename("123.feature", ["feature"]), ("123", "feature", 0), ) def test_invalid_category(self): self.assertEqual( parse_newfragment_basename("README.ext", ["feature"]), (None, None, None), ) def test_counter(self): self.assertEqual( parse_newfragment_basename("123.feature.1", ["feature"]), ("123", "feature", 1), ) def test_counter_with_extension(self): self.assertEqual( parse_newfragment_basename("123.feature.1.ext", ["feature"]), ("123", "feature", 1), ) def test_ignores_extension(self): self.assertEqual( parse_newfragment_basename("123.feature.ext", ["feature"]), ("123", "feature", 0), ) def test_non_numeric_ticket(self): self.assertEqual( parse_newfragment_basename("baz.feature", ["feature"]), ("baz", "feature", 0), ) def test_non_numeric_ticket_with_extension(self): self.assertEqual( parse_newfragment_basename("baz.feature.ext", ["feature"]), ("baz", "feature", 0), ) def test_dots_in_ticket_name(self): self.assertEqual( parse_newfragment_basename("baz.1.2.feature", ["feature"]), ("2", "feature", 0), ) def test_dots_in_ticket_name_invalid_category(self): self.assertEqual( parse_newfragment_basename("baz.1.2.notfeature", ["feature"]), (None, None, None), ) def test_dots_in_ticket_name_and_counter(self): self.assertEqual( parse_newfragment_basename("baz.1.2.feature.3", ["feature"]), ("2", "feature", 3), ) def test_strip(self): """Leading spaces and subsequent leading zeros are stripped when parsing newsfragment names into ticket numbers etc. """ self.assertEqual( parse_newfragment_basename(" 007.feature", ["feature"]), ("7", "feature", 0), ) def test_strip_with_counter(self): self.assertEqual( parse_newfragment_basename(" 007.feature.3", ["feature"]), ("7", "feature", 3), ) twisted-towncrier-b0e201f/src/towncrier/test/test_check.py000066400000000000000000000246141435061103400240640ustar00rootroot00000000000000# Copyright (c) Amber Brown, 2017 # See LICENSE for details. import os import os.path import sys import warnings from pathlib import Path from subprocess import PIPE, Popen, call from click.testing import CliRunner from twisted.trial.unittest import TestCase from towncrier import check from towncrier.check import _main as towncrier_check from .helpers import setup_simple_project, with_isolated_runner, write def create_project( pyproject_path="pyproject.toml", main_branch="main", extra_config="" ): """ Create the project files in the main branch that already has a news-fragment and then switch to a new in-work branch. """ setup_simple_project(pyproject_path=pyproject_path, extra_config=extra_config) Path("foo/newsfragments/123.feature").write_text("Adds levitation") initial_commit(branch=main_branch) call(["git", "checkout", "-b", "otherbranch"]) def commit(message): """Stage and commit the repo in the current working directory There must be uncommitted changes otherwise git will complain: "nothing to commit, working tree clean" """ call(["git", "add", "."]) call(["git", "commit", "-m", message]) def initial_commit(branch="main"): """ Create a git repo, configure it and make an initial commit There must be uncommitted changes otherwise git will complain: "nothing to commit, working tree clean" """ # --initial-branch is explicitly set to `main` because # git has deprecated the default branch name. call(["git", "init", f"--initial-branch={branch}"]) # Without ``git config` user.name and user.email `git commit` fails # unless the settings are set globally call(["git", "config", "user.name", "user"]) call(["git", "config", "user.email", "user@example.com"]) commit("Initial Commit") class TestChecker(TestCase): maxDiff = None def test_git_fails(self): """ If git fails to report a comparison, git's output is reported to aid in debugging the situation. """ runner = CliRunner() with runner.isolated_filesystem(): create_project("pyproject.toml") result = runner.invoke(towncrier_check, ["--compare-with", "hblaugh"]) self.assertIn("git produced output while failing", result.output) self.assertIn("hblaugh", result.output) def test_no_changes_made(self): self._test_no_changes_made( "pyproject.toml", lambda runner, main, argv: runner.invoke(main, argv) ) def test_no_changes_made_config_path(self): pyproject = "not-pyproject.toml" self._test_no_changes_made( pyproject, lambda runner, main, argv: runner.invoke( main, argv + ["--config", pyproject] ), ) def _test_no_changes_made(self, pyproject_path, invoke): """ When no changes are made on a new branch, no checks are performed. """ runner = CliRunner() with runner.isolated_filesystem(): create_project(pyproject_path, main_branch="master") result = invoke(runner, towncrier_check, ["--compare-with", "master"]) self.assertEqual(0, result.exit_code, result.output) self.assertEqual( "On master branch, or no diffs, so no newsfragment required.\n", result.output, ) @with_isolated_runner def test_fragment_exists(self, runner): create_project("pyproject.toml") write("foo/somefile.py", "import os") commit("add a file") fragment_path = Path("foo/newsfragments/1234.feature").absolute() write(fragment_path, "Adds gravity back") commit("add a newsfragment") result = runner.invoke(towncrier_check, ["--compare-with", "main"]) self.assertTrue( result.output.endswith("Found:\n1. " + str(fragment_path) + "\n"), (result.output, str(fragment_path)), ) self.assertEqual(0, result.exit_code, result.output) @with_isolated_runner def test_fragment_exists_hidden(self, runner): """ Location of fragments can be configured using tool.towncrier.directory. """ create_project("pyproject.toml", extra_config="directory = 'deep/fragz'\n") write("foo/bar/somefile.py", "import os") commit("add a file") fragment_path = Path("deep/fragz/1234.feature").absolute() write(fragment_path, "Adds gravity back") commit("add a newsfragment") result = runner.invoke(towncrier_check, ["--compare-with", "main"]) self.assertTrue( result.output.endswith("Found:\n1. " + str(fragment_path) + "\n"), (result.output, str(fragment_path)), ) self.assertEqual(0, result.exit_code, result.output) def test_fragment_missing(self): runner = CliRunner() with runner.isolated_filesystem(): create_project("pyproject.toml", main_branch="master") file_path = "foo/somefile.py" with open(file_path, "w") as f: f.write("import os") call(["git", "add", "foo/somefile.py"]) call(["git", "commit", "-m", "add a file"]) result = runner.invoke(towncrier_check, ["--compare-with", "master"]) self.assertEqual(1, result.exit_code) self.assertTrue( result.output.endswith("No new newsfragments found on this branch.\n") ) def test_none_stdout_encoding_works(self): """ No failure when output is piped causing None encoding for stdout. """ runner = CliRunner() with runner.isolated_filesystem(): create_project("pyproject.toml", main_branch="master") fragment_path = "foo/newsfragments/1234.feature" with open(fragment_path, "w") as f: f.write("Adds gravity back") call(["git", "add", fragment_path]) call(["git", "commit", "-m", "add a newsfragment"]) proc = Popen( [sys.executable, "-m", "towncrier.check", "--compare-with", "master"], stdout=PIPE, stderr=PIPE, ) stdout, stderr = proc.communicate() self.assertEqual(0, proc.returncode) self.assertEqual(b"", stderr) def test_first_release(self): """ The checks should be skipped on a branch that creates the news file. If the checks are not skipped in this case, towncrier check would fail for the first release that has a changelog. """ runner = CliRunner() with runner.isolated_filesystem(): # Arrange create_project() # Before any release, the NEWS file might no exist. self.assertNotIn("NEWS.rst", os.listdir(".")) call(["towncrier", "build", "--yes", "--version", "1.0"]) commit("Prepare a release") # When missing, # the news file is automatically created with a new release. self.assertIn("NEWS.rst", os.listdir(".")) # Act result = runner.invoke(towncrier_check, ["--compare-with", "main"]) # Assert self.assertEqual(0, result.exit_code, (result, result.output)) self.assertIn("Checks SKIPPED: news file changes detected", result.output) def test_release_branch(self): """ The checks for missing news fragments are skipped on a branch that modifies the news file. This is a hint that we are on a release branch and at release time is expected no not have news-fragment files. """ runner = CliRunner() with runner.isolated_filesystem(): # Arrange create_project() # Do a first release without any checks. # And merge the release branch back into the main branch. call(["towncrier", "build", "--yes", "--version", "1.0"]) commit("First release") # The news file is now created. self.assertIn("NEWS.rst", os.listdir(".")) call(["git", "checkout", "main"]) call(["git", "merge", "otherbranch", "-m", "Sync release in main branch."]) # We have a new feature branch that has a news fragment that # will be merged to the main branch. call(["git", "checkout", "-b", "new-feature-branch"]) write("foo/newsfragments/456.feature", "Foo the bar") commit("A feature in the second release.") call(["git", "checkout", "main"]) call( [ "git", "merge", "new-feature-branch", "-m", "Merge new-feature-branch.", ] ) # We now have the new release branch. call(["git", "checkout", "-b", "next-release"]) call(["towncrier", "build", "--yes", "--version", "2.0"]) commit("Second release") # Act result = runner.invoke(towncrier_check, ["--compare-with", "main"]) # Assert self.assertEqual(0, result.exit_code, (result, result.output)) self.assertIn("Checks SKIPPED: news file changes detected", result.output) def test_get_default_compare_branch_missing(self): """ If there's no recognized remote origin, exit with an error. """ runner = CliRunner() with runner.isolated_filesystem(): create_project() result = runner.invoke(towncrier_check) self.assertEqual(1, result.exit_code) self.assertEqual("Could not detect default branch. Aborting.\n", result.output) def test_get_default_compare_branch_main(self): """ If there's a remote branch origin/main, prefer it over everything else. """ branch = check._get_default_compare_branch(["origin/master", "origin/main"]) self.assertEqual("origin/main", branch) def test_get_default_compare_branch_fallback(self): """ If there's origin/master and no main, use it and warn about it. """ with warnings.catch_warnings(record=True) as w: branch = check._get_default_compare_branch(["origin/master", "origin/foo"]) self.assertEqual("origin/master", branch) self.assertTrue(w[0].message.args[0].startswith('Using "origin/master')) twisted-towncrier-b0e201f/src/towncrier/test/test_create.py000066400000000000000000000152621435061103400242510ustar00rootroot00000000000000# Copyright (c) Amber Brown, 2015 # See LICENSE for details. import os from textwrap import dedent from unittest import mock from click.testing import CliRunner from twisted.trial.unittest import TestCase from ..create import _main from .helpers import setup_simple_project class TestCli(TestCase): maxDiff = None def _test_success( self, content=None, config=None, mkdir=True, additional_args=None ): runner = CliRunner() with runner.isolated_filesystem(): setup_simple_project(config=config, mkdir_newsfragments=mkdir) args = ["123.feature.rst"] if content is None: content = ["Add your info here"] if additional_args is not None: args.extend(additional_args) result = runner.invoke(_main, args) self.assertEqual(["123.feature.rst"], os.listdir("foo/newsfragments")) with open("foo/newsfragments/123.feature.rst") as fh: self.assertEqual(content, fh.readlines()) self.assertEqual(0, result.exit_code) def test_basics(self): """Ensure file created where output directory already exists.""" self._test_success(mkdir=True) def test_directory_created(self): """Ensure both file and output directory created if necessary.""" self._test_success(mkdir=False) def test_edit_without_comments(self): """Create file with dynamic content.""" content = ["This is line 1\n", "This is line 2"] with mock.patch("click.edit") as mock_edit: mock_edit.return_value = "".join(content) self._test_success(content=content, additional_args=["--edit"]) mock_edit.assert_called_once_with( "# Please write your news content. When finished, save the file.\n" "# In order to abort, exit without saving.\n" '# Lines starting with "#" are ignored.\n' "\n" "Add your info here\n" ) def test_edit_with_comment(self): """Create file editly with ignored line.""" content = ["This is line 1\n", "This is line 2"] comment = "# I am ignored\n" with mock.patch("click.edit") as mock_edit: mock_edit.return_value = "".join(content[:1] + [comment] + content[1:]) self._test_success(content=content, additional_args=["--edit"]) def test_edit_abort(self): """Create file editly and abort.""" with mock.patch("click.edit") as mock_edit: mock_edit.return_value = None runner = CliRunner() with runner.isolated_filesystem(): setup_simple_project(config=None, mkdir_newsfragments=True) result = runner.invoke(_main, ["123.feature.rst", "--edit"]) self.assertEqual([], os.listdir("foo/newsfragments")) self.assertEqual(1, result.exit_code) def test_content(self): """ When creating a new fragment the content can be passed as a command line argument. The text editor is not invoked. """ content_line = "This is a content" self._test_success(content=[content_line], additional_args=["-c", content_line]) def test_message_and_edit(self): """ When creating a new message, a initial content can be passed via the command line and continue modifying the content by invoking the text editor. """ content_line = "This is a content line" edit_content = ["This is line 1\n", "This is line 2"] with mock.patch("click.edit") as mock_edit: mock_edit.return_value = "".join(edit_content) self._test_success( content=edit_content, additional_args=["-c", content_line, "--edit"] ) mock_edit.assert_called_once_with( "# Please write your news content. When finished, save the file.\n" "# In order to abort, exit without saving.\n" '# Lines starting with "#" are ignored.\n' "\n" "{content_line}\n".format(content_line=content_line) ) def test_different_directory(self): """Ensure non-standard directories are used.""" runner = CliRunner() config = dedent( """\ [tool.towncrier] directory = "releasenotes" """ ) with runner.isolated_filesystem(): setup_simple_project(config=config, mkdir_newsfragments=False) os.mkdir("releasenotes") result = runner.invoke(_main, ["123.feature.rst"]) self.assertEqual(["123.feature.rst"], os.listdir("releasenotes")) self.assertEqual(0, result.exit_code) def test_invalid_section(self): """Ensure creating a path without a valid section is rejected.""" runner = CliRunner() with runner.isolated_filesystem(): setup_simple_project() self.assertEqual([], os.listdir("foo/newsfragments")) result = runner.invoke(_main, ["123.foobar.rst"]) self.assertEqual([], os.listdir("foo/newsfragments")) self.assertEqual(type(result.exception), SystemExit, result.exception) self.assertIn( "Expected filename '123.foobar.rst' to be of format", result.output ) def test_file_exists(self): """Ensure we don't overwrite existing files.""" runner = CliRunner() with runner.isolated_filesystem(): setup_simple_project() self.assertEqual([], os.listdir("foo/newsfragments")) runner.invoke(_main, ["123.feature.rst"]) result = runner.invoke(_main, ["123.feature.rst"]) self.assertEqual(type(result.exception), SystemExit) self.assertIn("123.feature.rst already exists", result.output) def test_create_orphan_fragment(self): """ When a fragment starts with the only the orphan prefix (``+`` by default), the create CLI automatically extends the new file's base name to contain a random value to avoid commit collisions. """ runner = CliRunner() with runner.isolated_filesystem(): setup_simple_project() self.assertEqual([], os.listdir("foo/newsfragments")) runner.invoke(_main, ["+.feature"]) fragments = os.listdir("foo/newsfragments") self.assertEqual(1, len(fragments)) filename = fragments[0] self.assertTrue(filename.endswith(".feature")) self.assertTrue(filename.startswith("+")) # Length should be '+' character and 8 random hex characters. self.assertEqual(len(filename.split(".")[0]), 9) twisted-towncrier-b0e201f/src/towncrier/test/test_format.py000066400000000000000000000216301435061103400242720ustar00rootroot00000000000000# Copyright (c) Amber Brown, 2015 # See LICENSE for details. from collections import OrderedDict import pkg_resources from twisted.trial.unittest import TestCase from .._builder import render_fragments, split_fragments class FormatterTests(TestCase): def test_split(self): fragments = { "": { ("1", "misc", 0): "", ("baz", "misc", 0): "", ("2", "feature", 0): "Foo added.", ("5", "feature", 0): "Foo added. \n", ("6", "bugfix", 0): "Foo added.", }, "Web": { ("3", "bugfix", 0): "Web fixed. ", ("4", "feature", 0): "Foo added.", }, } expected_output = { "": { "misc": {"": ["1", "baz"]}, "feature": {"Foo added.": ["2", "5"]}, "bugfix": {"Foo added.": ["6"]}, }, "Web": { "bugfix": {"Web fixed.": ["3"]}, "feature": {"Foo added.": ["4"]}, }, } definitions = OrderedDict( [ ("feature", {"name": "Features", "showcontent": True}), ("bugfix", {"name": "Bugfixes", "showcontent": True}), ("misc", {"name": "Misc", "showcontent": False}), ] ) output = split_fragments(fragments, definitions) self.assertEqual(expected_output, output) def test_basic(self): """ Basic functionality -- getting a bunch of news fragments and formatting them into a rST file -- works. """ fragments = OrderedDict( [ ( "", { # asciibetical sorting will do 1, 142, 9 # we want 1, 9, 142 instead ("142", "misc", 0): "", ("1", "misc", 0): "", ("9", "misc", 0): "", ("bar", "misc", 0): "", ("4", "feature", 0): "Stuff!", ("2", "feature", 0): "Foo added.", ("72", "feature", 0): "Foo added.", ("9", "feature", 0): "Foo added.", ("baz", "feature", 0): "Fun!", }, ), ("Names", {}), ("Web", {("3", "bugfix", 0): "Web fixed."}), ] ) definitions = OrderedDict( [ ("feature", {"name": "Features", "showcontent": True}), ("bugfix", {"name": "Bugfixes", "showcontent": True}), ("misc", {"name": "Misc", "showcontent": False}), ] ) expected_output = """MyProject 1.0 (never) ===================== Features -------- - Fun! (baz) - Foo added. (#2, #9, #72) - Stuff! (#4) Misc ---- - bar, #1, #9, #142 Names ----- No significant changes. Web --- Bugfixes ~~~~~~~~ - Web fixed. (#3) """ template = pkg_resources.resource_string( "towncrier", "templates/default.rst" ).decode("utf8") fragments = split_fragments(fragments, definitions) output = render_fragments( template, None, fragments, definitions, ["-", "~"], wrap=True, versiondata={"name": "MyProject", "version": "1.0", "date": "never"}, ) self.assertEqual(output, expected_output) # Check again with non-default underlines expected_output_weird_underlines = """MyProject 1.0 (never) ===================== Features ******** - Fun! (baz) - Foo added. (#2, #9, #72) - Stuff! (#4) Misc **** - bar, #1, #9, #142 Names ***** No significant changes. Web *** Bugfixes ^^^^^^^^ - Web fixed. (#3) """ output = render_fragments( template, None, fragments, definitions, ["*", "^"], wrap=True, versiondata={"name": "MyProject", "version": "1.0", "date": "never"}, ) self.assertEqual(output, expected_output_weird_underlines) def test_issue_format(self): """ issue_format option can be used to format issue text. And sorting happens before formatting, so numerical issues are still ordered numerically even if that doesn't match asciibetical order on the final text. """ fragments = { "": { # asciibetical sorting will do 1, 142, 9 # we want 1, 9, 142 instead ("142", "misc", 0): "", ("1", "misc", 0): "", ("9", "misc", 0): "", ("bar", "misc", 0): "", } } definitions = OrderedDict([("misc", {"name": "Misc", "showcontent": False})]) expected_output = """MyProject 1.0 (never) ===================== Misc ---- - xxbar, xx1, xx9, xx142 """ template = pkg_resources.resource_string( "towncrier", "templates/default.rst" ).decode("utf8") fragments = split_fragments(fragments, definitions) output = render_fragments( template, "xx{issue}", fragments, definitions, ["-", "~"], wrap=True, versiondata={"name": "MyProject", "version": "1.0", "date": "never"}, ) self.assertEqual(output, expected_output) def test_line_wrapping(self): """ Output is nicely wrapped, but doesn't break up words (which can mess up URLs) """ self.maxDiff = None fragments = { "": { ( "1", "feature", 0, ): """ asdf asdf asdf asdf looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong newsfragment. """, # NOQA ("2", "feature", 0): "https://google.com/q=?" + "-" * 100, ("3", "feature", 0): "a " * 80, } } definitions = OrderedDict( [("feature", {"name": "Features", "showcontent": True})] ) expected_output = """MyProject 1.0 (never) ===================== Features -------- - asdf asdf asdf asdf looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong newsfragment. (#1) - https://google.com/q=?---------------------------------------------------------------------------------------------------- (#2) - a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a (#3) """ template = pkg_resources.resource_string( "towncrier", "templates/default.rst" ).decode("utf8") fragments = split_fragments(fragments, definitions) output = render_fragments( template, None, fragments, definitions, ["-", "~"], wrap=True, versiondata={"name": "MyProject", "version": "1.0", "date": "never"}, ) self.assertEqual(output, expected_output) def test_line_wrapping_disabled(self): """ Output is not wrapped if it's disabled. """ self.maxDiff = None fragments = { "": { ( "1", "feature", 0, ): """ asdf asdf asdf asdf looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong newsfragment. """, # NOQA ("2", "feature", 0): "https://google.com/q=?" + "-" * 100, ("3", "feature", 0): "a " * 80, } } definitions = OrderedDict( [("feature", {"name": "Features", "showcontent": True})] ) expected_output = """MyProject 1.0 (never) ===================== Features -------- - asdf asdf asdf asdf looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong newsfragment. (#1) - https://google.com/q=?---------------------------------------------------------------------------------------------------- (#2) - a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a (#3) """ # NOQA template = pkg_resources.resource_string( "towncrier", "templates/default.rst" ).decode("utf8") fragments = split_fragments(fragments, definitions) output = render_fragments( template, None, fragments, definitions, ["-", "~"], wrap=False, versiondata={"name": "MyProject", "version": "1.0", "date": "never"}, ) self.assertEqual(output, expected_output) twisted-towncrier-b0e201f/src/towncrier/test/test_project.py000066400000000000000000000137321435061103400244540ustar00rootroot00000000000000# Copyright (c) Amber Brown, 2015 # See LICENSE for details. import os import sys from subprocess import check_output from unittest import skipIf from twisted.trial.unittest import TestCase from .._project import get_project_name, get_version from .helpers import write try: from importlib.metadata import version as metadata_version except ImportError: metadata_version = None class VersionFetchingTests(TestCase): def test_str(self): """ A str __version__ will be picked up. """ temp = self.mktemp() os.makedirs(temp) os.makedirs(os.path.join(temp, "mytestproj")) with open(os.path.join(temp, "mytestproj", "__init__.py"), "w") as f: f.write("__version__ = '1.2.3'") version = get_version(temp, "mytestproj") self.assertEqual(version, "1.2.3") def test_tuple(self): """ A tuple __version__ will be picked up. """ temp = self.mktemp() os.makedirs(temp) os.makedirs(os.path.join(temp, "mytestproja")) with open(os.path.join(temp, "mytestproja", "__init__.py"), "w") as f: f.write("__version__ = (1, 3, 12)") version = get_version(temp, "mytestproja") self.assertEqual(version, "1.3.12") @skipIf(metadata_version is None, "Needs importlib.metadata.") def test_incremental(self): """ An incremental Version __version__ is picked up. """ pkg = "../src" self.assertEqual(metadata_version("towncrier"), get_version(pkg, "towncrier")) self.assertEqual("towncrier", get_project_name(pkg, "towncrier")) def _setup_missing(self): """ Create a minimalistic project with missing metadata in a temporary directory. """ tmp_dir = self.mktemp() pkg = os.path.join(tmp_dir, "missing") os.makedirs(pkg) init = os.path.join(tmp_dir, "__init__.py") write(init, "# nope\n") return tmp_dir def test_missing_version(self): """ Missing __version__ string leads to an exception. """ tmp_dir = self._setup_missing() with self.assertRaises(Exception) as e: get_version(tmp_dir, "missing") self.assertEqual( ("No __version__, I don't know how else to look",), e.exception.args ) def test_missing_version_project_name(self): """ Missing __version__ string leads to the package name becoming the project name. """ tmp_dir = self._setup_missing() self.assertEqual("Missing", get_project_name(tmp_dir, "missing")) def test_unknown_type(self): """ A __version__ of unknown type will lead to an exception. """ temp = self.mktemp() os.makedirs(temp) os.makedirs(os.path.join(temp, "mytestprojb")) with open(os.path.join(temp, "mytestprojb", "__init__.py"), "w") as f: f.write("__version__ = object()") self.assertRaises(Exception, get_version, temp, "mytestprojb") self.assertRaises(TypeError, get_project_name, temp, "mytestprojb") def test_import_fails(self): """ An exception is raised when getting the version failed due to missing Python package files. """ with self.assertRaises(ModuleNotFoundError): get_version(".", "projectname_without_any_files") def test_already_installed_import(self): """ An already installed package will be checked before cwd-found packages. """ project_name = "mytestproj_already_installed_import" temp = self.mktemp() os.makedirs(temp) os.makedirs(os.path.join(temp, project_name)) with open(os.path.join(temp, project_name, "__init__.py"), "w") as f: f.write("__version__ = (1, 3, 12)") sys_path_temp = self.mktemp() os.makedirs(sys_path_temp) os.makedirs(os.path.join(sys_path_temp, project_name)) with open(os.path.join(sys_path_temp, project_name, "__init__.py"), "w") as f: f.write("__version__ = (2, 1, 5)") sys.path.insert(0, sys_path_temp) self.addCleanup(sys.path.pop, 0) version = get_version(temp, project_name) self.assertEqual(version, "2.1.5") def test_installed_package_found_when_no_source_present(self): """ The version from the installed package is returned when there is no package present at the provided source directory. """ project_name = "mytestproj_only_installed" sys_path_temp = self.mktemp() os.makedirs(sys_path_temp) os.makedirs(os.path.join(sys_path_temp, project_name)) with open(os.path.join(sys_path_temp, project_name, "__init__.py"), "w") as f: f.write("__version__ = (3, 14)") sys.path.insert(0, sys_path_temp) self.addCleanup(sys.path.pop, 0) version = get_version("some non-existent directory", project_name) self.assertEqual(version, "3.14") class InvocationTests(TestCase): def test_dash_m(self): """ `python -m towncrier` invokes the main entrypoint. """ temp = self.mktemp() new_dir = os.path.join(temp, "dashm") os.makedirs(new_dir) orig_dir = os.getcwd() try: os.chdir(new_dir) with open("pyproject.toml", "w") as f: f.write("[tool.towncrier]\n" 'directory = "news"\n') os.makedirs("news") out = check_output([sys.executable, "-m", "towncrier", "--help"]) self.assertIn(b"[OPTIONS] COMMAND [ARGS]...", out) self.assertRegex(out, rb".*--help\s+Show this message and exit.") finally: os.chdir(orig_dir) def test_version(self): """ `--version` command line option is available to show the current production version. """ out = check_output(["towncrier", "--version"]) self.assertTrue(out.startswith(b"towncrier, version 2")) twisted-towncrier-b0e201f/src/towncrier/test/test_settings.py000066400000000000000000000205261435061103400246450ustar00rootroot00000000000000# Copyright (c) Amber Brown, 2015 # See LICENSE for details. import collections as clt import os import textwrap from textwrap import dedent from twisted.trial.unittest import TestCase from .._settings import ConfigError, load_config class TomlSettingsTests(TestCase): def test_base(self): """ Test a "base config". """ temp = self.mktemp() os.makedirs(temp) with open(os.path.join(temp, "pyproject.toml"), "w") as f: f.write( """[tool.towncrier] package = "foobar" orphan_prefix = "~" """ ) config = load_config(temp) self.assertEqual(config.package, "foobar") self.assertEqual(config.package_dir, ".") self.assertEqual(config.filename, "NEWS.rst") self.assertEqual(config.underlines, ["=", "-", "~"]) self.assertEqual(config.orphan_prefix, "~") def test_missing(self): """ If the config file doesn't have the correct toml key, we error. """ temp = self.mktemp() os.makedirs(temp) with open(os.path.join(temp, "pyproject.toml"), "w") as f: f.write( dedent( """ [something.else] blah='baz' """ ) ) with self.assertRaises(ConfigError) as e: load_config(temp) self.assertEqual(e.exception.failing_option, "all") def test_incorrect_single_file(self): """ single_file must be a bool. """ temp = self.mktemp() os.makedirs(temp) with open(os.path.join(temp, "pyproject.toml"), "w") as f: f.write( dedent( """ [tool.towncrier] single_file = "a" """ ) ) with self.assertRaises(ConfigError) as e: load_config(temp) self.assertEqual(e.exception.failing_option, "single_file") def test_incorrect_all_bullets(self): """ all_bullets must be a bool. """ temp = self.mktemp() os.makedirs(temp) with open(os.path.join(temp, "pyproject.toml"), "w") as f: f.write( dedent( """ [tool.towncrier] all_bullets = "a" """ ) ) with self.assertRaises(ConfigError) as e: load_config(temp) self.assertEqual(e.exception.failing_option, "all_bullets") def test_mistype_singlefile(self): """ singlefile is not accepted, single_file is. """ temp = self.mktemp() os.makedirs(temp) with open(os.path.join(temp, "pyproject.toml"), "w") as f: f.write( dedent( """ [tool.towncrier] singlefile = "a" """ ) ) with self.assertRaises(ConfigError) as e: load_config(temp) self.assertEqual(e.exception.failing_option, "singlefile") def test_towncrier_toml_preferred(self): """ Towncrier prefers the towncrier.toml for autodetect over pyproject.toml. """ temp = self.mktemp() os.makedirs(temp) with open(os.path.join(temp, "towncrier.toml"), "w") as f: f.write( dedent( """ [tool.towncrier] package = "a" """ ) ) with open(os.path.join(temp, "pyproject.toml"), "w") as f: f.write( dedent( """ [tool.towncrier] package = "b" """ ) ) config = load_config(temp) self.assertEqual(config.package, "a") def test_missing_template(self): """ Towncrier will raise an exception saying when it can't find a template. """ temp = self.mktemp() os.makedirs(temp) with open(os.path.join(temp, "towncrier.toml"), "w") as f: f.write( dedent( """ [tool.towncrier] template = "foo.rst" """ ) ) with self.assertRaises(ConfigError) as e: load_config(temp) self.assertEqual( str(e.exception), "The template file '{}' does not exist.".format( os.path.normpath(os.path.join(temp, "foo.rst")), ), ) def test_missing_template_in_towncrier(self): """ Towncrier will raise an exception saying when it can't find a template from the Towncrier templates. """ temp = self.mktemp() os.makedirs(temp) with open(os.path.join(temp, "towncrier.toml"), "w") as f: f.write( dedent( """ [tool.towncrier] template = "towncrier:foo" """ ) ) with self.assertRaises(ConfigError) as e: load_config(temp) self.assertEqual( str(e.exception), "Towncrier does not have a template named 'foo'." ) def test_custom_types_as_tables_array_deprecated(self): """ Custom fragment categories can be defined inside the toml config file using an array of tables (a table name in double brackets). This functionality is considered deprecated, but we continue to support it to keep backward compatibility. """ toml_content = """ [tool.towncrier] package = "foobar" [[tool.towncrier.type]] directory="foo" name="Foo" showcontent=false [[tool.towncrier.type]] directory="spam" name="Spam" showcontent=true """ toml_content = textwrap.dedent(toml_content) expected = [ ( "foo", { "name": "Foo", "showcontent": False, }, ), ( "spam", { "name": "Spam", "showcontent": True, }, ), ] expected = clt.OrderedDict(expected) config = self.load_config_from_string( toml_content, ) actual = config.types self.assertDictEqual(expected, actual) def test_custom_types_as_tables(self): """ Custom fragment categories can be defined inside the toml config file using tables. """ test_project_path = self.mktemp() os.makedirs(test_project_path) toml_content = """ [tool.towncrier] package = "foobar" [tool.towncrier.fragment.feat] ignored_field="Bazz" [tool.towncrier.fragment.fix] [tool.towncrier.fragment.chore] name = "Other Tasks" showcontent = false """ toml_content = textwrap.dedent(toml_content) expected = [ ( "chore", { "name": "Other Tasks", "showcontent": False, }, ), ( "feat", { "name": "Feat", "showcontent": True, }, ), ( "fix", { "name": "Fix", "showcontent": True, }, ), ] expected = clt.OrderedDict(expected) config = self.load_config_from_string( toml_content, ) actual = config.types self.assertDictEqual(expected, actual) def load_config_from_string(self, toml_content): """Load configuration from a string. Given a string following toml syntax, obtain the towncrier configuration. """ test_project_path = self.mktemp() os.makedirs(test_project_path) toml_path = os.path.join(test_project_path, "pyproject.toml") with open(toml_path, "w") as f: f.write(toml_content) config = load_config(test_project_path) return config twisted-towncrier-b0e201f/src/towncrier/test/test_write.py000066400000000000000000000250251435061103400241360ustar00rootroot00000000000000# Copyright (c) Amber Brown, 2015 # See LICENSE for details. import os from collections import OrderedDict from pathlib import Path from textwrap import dedent import pkg_resources from click.testing import CliRunner from twisted.trial.unittest import TestCase from .._builder import render_fragments, split_fragments from .._writer import append_to_newsfile from ..build import _main class WritingTests(TestCase): maxDiff = None def test_append_at_top(self): fragments = OrderedDict( [ ( "", OrderedDict( [ (("142", "misc", 0), ""), (("1", "misc", 0), ""), (("4", "feature", 0), "Stuff!"), (("4", "feature", 1), "Second Stuff!"), (("2", "feature", 0), "Foo added."), (("72", "feature", 0), "Foo added."), ] ), ), ("Names", {}), ("Web", {("3", "bugfix", 0): "Web fixed."}), ] ) definitions = OrderedDict( [ ("feature", {"name": "Features", "showcontent": True}), ("bugfix", {"name": "Bugfixes", "showcontent": True}), ("misc", {"name": "Misc", "showcontent": False}), ] ) expected_output = """MyProject 1.0 (never) ===================== Features -------- - Foo added. (#2, #72) - Stuff! (#4) - Second Stuff! (#4) Misc ---- - #1, #142 Names ----- No significant changes. Web --- Bugfixes ~~~~~~~~ - Web fixed. (#3) Old text. """ tempdir = self.mktemp() os.mkdir(tempdir) with open(os.path.join(tempdir, "NEWS.rst"), "w") as f: f.write("Old text.\n") fragments = split_fragments(fragments, definitions) template = pkg_resources.resource_string( "towncrier", "templates/default.rst" ).decode("utf8") append_to_newsfile( tempdir, "NEWS.rst", ".. towncrier release notes start\n", "", render_fragments( template, None, fragments, definitions, ["-", "~"], wrap=True, versiondata={"name": "MyProject", "version": "1.0", "date": "never"}, ), single_file=True, ) with open(os.path.join(tempdir, "NEWS.rst")) as f: output = f.read() self.assertEqual(expected_output, output) def test_append_at_top_with_hint(self): """ If there is a comment with C{.. towncrier release notes start}, towncrier will add the version notes after it. """ fragments = OrderedDict( [ ( "", { ("142", "misc", 0): "", ("1", "misc", 0): "", ("4", "feature", 0): "Stuff!", ("2", "feature", 0): "Foo added.", ("72", "feature", 0): "Foo added.", ("99", "feature", 0): "Foo! " * 100, }, ), ("Names", {}), ("Web", {("3", "bugfix", 0): "Web fixed."}), ] ) definitions = OrderedDict( [ ("feature", {"name": "Features", "showcontent": True}), ("bugfix", {"name": "Bugfixes", "showcontent": True}), ("misc", {"name": "Misc", "showcontent": False}), ] ) expected_output = """Hello there! Here is some info. .. towncrier release notes start MyProject 1.0 (never) ===================== Features -------- - Foo added. (#2, #72) - Stuff! (#4) - Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! (#99) Misc ---- - #1, #142 Names ----- No significant changes. Web --- Bugfixes ~~~~~~~~ - Web fixed. (#3) Old text. """ tempdir = self.mktemp() os.mkdir(tempdir) with open(os.path.join(tempdir, "NEWS.rst"), "w") as f: f.write( "Hello there! Here is some info.\n\n" ".. towncrier release notes start\nOld text.\n" ) fragments = split_fragments(fragments, definitions) template = pkg_resources.resource_string( "towncrier", "templates/default.rst" ).decode("utf8") append_to_newsfile( tempdir, "NEWS.rst", ".. towncrier release notes start\n", "", render_fragments( template, None, fragments, definitions, ["-", "~"], wrap=True, versiondata={"name": "MyProject", "version": "1.0", "date": "never"}, ), single_file=True, ) with open(os.path.join(tempdir, "NEWS.rst")) as f: output = f.read() self.assertEqual(expected_output, output) def test_multiple_file_no_start_string(self): """ When no `start_string` is defined, the generated content is added at the start of the file. """ tempdir = self.mktemp() os.mkdir(tempdir) definitions = {} fragments = split_fragments(fragments={}, definitions=definitions) template = pkg_resources.resource_string( "towncrier", "templates/default.rst" ).decode("utf8") content = render_fragments( template=template, issue_format=None, fragments=fragments, definitions=definitions, underlines=["-", "~"], wrap=True, versiondata={"name": "MyProject", "version": "1.0", "date": "never"}, ) append_to_newsfile( directory=tempdir, filename="NEWS.rst", start_string=None, top_line="", content=content, single_file=True, ) with open(os.path.join(tempdir, "NEWS.rst")) as f: output = f.read() expected_output = dedent( """\ MyProject 1.0 (never) ===================== """ ) self.assertEqual(expected_output, output) def test_with_title_format_duplicate_version_raise(self): """ When `single_file` enabled as default, and fragments of `version` already produced in the newsfile, a duplicate `build` will throw a ValueError. """ runner = CliRunner() def do_build_once(): with open("newsfragments/123.feature", "w") as f: f.write("Adds levitation") result = runner.invoke( _main, [ "--version", "7.8.9", "--name", "foo", "--date", "01-01-2001", "--yes", ], ) return result # `single_file` default as true with runner.isolated_filesystem(): with open("pyproject.toml", "w") as f: f.write( dedent( """ [tool.towncrier] title_format="{name} {version} ({project_date})" filename="{version}-notes.rst" """ ).lstrip() ) with open("{version}-notes.rst", "w") as f: f.write("Release Notes\n\n.. towncrier release notes start\n") os.mkdir("newsfragments") result = do_build_once() self.assertEqual(0, result.exit_code) # build again with the same version result = do_build_once() self.assertNotEqual(0, result.exit_code) self.assertIsInstance(result.exception, ValueError) self.assertSubstring( "already produced newsfiles for this version", result.exception.args[0] ) def test_single_file_false_overwrite_duplicate_version(self): """ When `single_file` disabled, multiple newsfiles generated and the content of which get overwritten each time. """ runner = CliRunner() def do_build_once(): with open("newsfragments/123.feature", "w") as f: f.write("Adds levitation") result = runner.invoke( _main, [ "--version", "7.8.9", "--name", "foo", "--date", "01-01-2001", "--yes", ], ) return result # single_file = false with runner.isolated_filesystem(): with open("pyproject.toml", "w") as f: f.write( dedent( """ [tool.towncrier] single_file=false title_format="{name} {version} ({project_date})" filename="{version}-notes.rst" """ ).lstrip() ) os.mkdir("newsfragments") result = do_build_once() self.assertEqual(0, result.exit_code) # build again with the same version result = do_build_once() self.assertEqual(0, result.exit_code) notes = list(Path.cwd().glob("*-notes.rst")) self.assertEqual(1, len(notes)) self.assertEqual("7.8.9-notes.rst", notes[0].name) with open(notes[0]) as f: output = f.read() expected_output = dedent( """\ foo 7.8.9 (01-01-2001) ====================== Features -------- - Adds levitation (#123) """ ) self.assertEqual(expected_output, output)