towncrier-24.8.0/.flake80000644000000000000000000000030413615410400011711 0ustar00[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 towncrier-24.8.0/.git-blame-ignore-revs0000644000000000000000000000056313615410400014645 0ustar00# 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 towncrier-24.8.0/.pre-commit-config.yaml0000644000000000000000000000143413615410400015024 0ustar00--- ci: autoupdate_schedule: monthly repos: - repo: https://github.com/psf/black rev: 24.8.0 hooks: - id: black - repo: https://github.com/asottile/pyupgrade rev: v3.17.0 hooks: - id: pyupgrade args: [--py38-plus] - repo: https://github.com/PyCQA/isort rev: 5.13.2 hooks: - id: isort additional_dependencies: [toml] - repo: https://github.com/PyCQA/flake8 rev: 7.1.1 hooks: - id: flake8 - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: debug-statements - id: check-toml - id: check-yaml - repo: https://github.com/twisted/towncrier rev: 24.7.1 hooks: - id: towncrier-check towncrier-24.8.0/.pre-commit-hooks.yaml0000644000000000000000000000062413615410400014702 0ustar00- id: towncrier-check name: towncrier-check description: Check towncrier changelog updates entry: towncrier --draft pass_filenames: false types: [text] files: newsfragments/ language: python - id: towncrier-update name: towncrier-update description: Update changelog with towncrier entry: towncrier pass_filenames: false args: ["--yes"] files: newsfragments/ language: python towncrier-24.8.0/CODE_OF_CONDUCT.md0000644000000000000000000000622613615410400013346 0ustar00# 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/ towncrier-24.8.0/CONTRIBUTING.rst0000644000000000000000000001211713615410400013204 0ustar00Contributing 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. Use `one sentence per line`_. d. Create a news fragment in ``src/towncrier/newsfragments/`` describing the changes and containing information that is of interest to end-users. Use `one sentence per line`_ here, too. You can use the ``towncrier`` CLI to create them; for example ``towncrier create 1234.bugfix`` Use one of the following types: - ``feature`` for new features - ``bugfix`` for bugfixes - ``doc`` for improvements to documentation - ``removal`` for deprecations and removals - ``misc`` for everything else that is linked but not shown in our ``NEWS.rst`` file. Use this for pull requests that don't affect end-users and leave them empty. 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_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/ .. _`one sentence per line`: https://rhodesmill.org/brandon/2012/one-sentence-per-line/ .. _twisted.trial: https://github.com/twisted/trac-wiki-archive/blob/trunk/TwistedTrial.mediawiki towncrier-24.8.0/NEWS.rst0000644000000000000000000007624013615410400012060 0ustar00Release notes ############# ``towncrier`` issues are filed on `GitHub `_, and each ticket number here corresponds to a closed GitHub issue. .. towncrier release notes start Towncrier 24.8.0 (2024-08-23) ============================= No changes since the previous release candidate. Features -------- - Add ``.gitkeep`` as an ignored filename. (`#643 `_) - Config `ignore` option now supports wildcard matching via `fnmatch `_. (`#644 `_) - Add a config for enforcing issue names using regex. (`#649 `_) Bugfixes -------- - The template file is now ignored based only on the file name. (`#638 `_) - Control of the header formatting is once again completely up to the user when they are writing markdown files (fixes a regression introduced in [#610](https://github.com/twisted/towncrier/pull/610)). (`#651 `_) - Fixed an issue where `issue_template` failed recognizing the issue name of files with a non-category suffix (`.md`) (`#654 `_) - Fixed a bug where orphan news fragments (e.g. +abc1234.feature) would fail when an `issue_pattern` is configured. Orphan news fragments are now excempt from `issue_pattern` checks. (`#655 `_) Deprecations and Removals ------------------------- - Moved towncrier version definition from src/towncrier/_version.py to pyproject.toml towncrier.__version__ was removed, after being deprecated in 23.6.0. (`#640 `_) Misc ---- - `#640 `_, `#657 `_ Towncrier 24.7.1 (2024-07-31) ============================= No significant changes since the previous release candidate. Bugfixes -------- - When the template file is stored in the same directory with the news fragments, it is automatically ignored when checking for valid fragment file names. (`#632 `_) Misc ---- - `#629 `_, `#630 `_ Towncrier 24.7.0 (2024-07-31) ============================= No changes since the previous release candidate. Features -------- - ``towncrier build`` now handles removing news fragments which are not part of the git repository. For example, uncommitted or unstaged files. (`#357 `_) - Inferring the version of a Python package now tries to use the metadata of the installed package before importing the package explicitly (which only looks for ``[package].__version__``). (`#432 `_) - If no filename is given when doing ``towncrier`` create, interactively ask for the issue number and fragment type (and then launch an interactive editor for the fragment content). Now by default, when creating a fragment it will be appended with the ``filename`` option's extension (unless an extension is explicitly provided). For example, ``towncrier create 123.feature`` will create ``news/123.feature.rst``. This can be changed in configuration file by setting `add_extension = false`. A new line is now added by default to the end of the fragment contents. This can be reverted in the configuration file by setting `add_newline = false`. (`#482 `_) - The temporary file ``towncrier create`` creates now uses the correct ``.rst`` or ``.md`` extension, which may help your editor with with syntax highlighting. (`#594 `_) - Running ``towncrier`` will now traverse back up directories looking for the configuration file. (`#601 `_) - The ``towncrier create`` action now uses sections defined in your config (either interactively, or via the new ``--section`` option). (`#603 `_) - News fragments are now sorted by issue number even if they have non-digit characters. For example:: - some issue (gh-3, gh-10) - another issue (gh-4) - yet another issue (gh-11) The sorting algorithm groups the issues first by non-text characters and then by number. (`#608 `_) - The ``title_format`` configuration option now uses a markdown format for markdown templates. (`#610 `_) - newsfragment categories can now be marked with ``check = false``, causing them to be ignored in ``towncrier check`` (`#617 `_) - ``towncrier check`` will now fail if any news fragments have invalid filenames. Added a new configuration option called ``ignore`` that allows you to specify a list of filenames that should be ignored. If this is set, ``towncrier build`` will also fail if any filenames are invalid, except for those in the list. (`#622 `_) Bugfixes -------- - Add explicit encoding to read_text. (`#561 `_) - The default Markdown template now renders a title containing the release version and date, even when the `name` configuration is left empty. (`#587 `_) - Orphan news fragments, fragments not associated with an issue, consisting of only digits (e.g. '+12345678.feature') now retain their leading marker character. (`#588 `_) - Orphan news fragments, fragments not associated with an issue, will now still show in categories that are marked to not show content, since they do not have an issue number to show. (`#612 `_) Improved Documentation ---------------------- - Clarify version discovery behavior. (`#432 `_, `#602 `_) - The tutorial now introduces the `filename` option in the appropriate paragraph and mentions its default value. (`#586 `_) - Add docs to explain how ``towncrier create +.feature.rst`` (orphan fragments) works. (`#589 `_) Misc ---- - `#491 `_, `#561 `_, `#562 `_, `#568 `_, `#569 `_, `#571 `_, `#574 `_, `#575 `_, `#582 `_, `#591 `_, `#596 `_, `#597 `_, `#625 `_ towncrier 23.11.0 (2023-11-08) ============================== No significant changes since the previous release candidate. Bugfixes -------- - ``build`` now treats a missing fragments directory the same as an empty one, consistent with other operations. (`#538 `_) - Fragments with filenames like `fix-1.2.3.feature` are now associated with the issue `fix-1.2.3`. In previous versions they were incorrectly associated to issue `3`. (`#562 `_) - Orphan newsfragments containing numeric values are no longer accidentally associated to issues. In previous versions the orphan marker was ignored and the newsfragment was associated to an issue having the last numerical value from the filename. (`#562 `_) Misc ---- - `#558 `_, `#559 `_ towncrier 23.10.0 (2023-10-24) ============================== No significant changes since the previous release candidate. Features -------- - Python 3.12 is now officially supported. (`#541 `_) - Initial support was added for monorepo-style setup. One project with multiple independent news files stored in separate sub-directories, that share the same towncrier config. (`#548 `_) - Two newlines are no longer always added between the current release notes and the previous content. The newlines are now defined only inside the template. **Important! If you're using a custom template and want to keep the same whitespace between releases, you may have to modify your template.** (`#552 `_) Bugfixes -------- - Towncrier now vendors the click-default-group package that prevented installations on modern Pips. (`#540 `_) Improved Documentation ---------------------- - The markdown docs now use the default markdown template rather than a simpler custom one. (`#545 `_) - Cleanup a duplicate backtick in the tutorial. (`#551 `_) Deprecations and Removals ------------------------- - The support for Python 3.7 has been dropped. (`#521 `_) Misc ---- - `#481 `_, `#520 `_, `#522 `_, `#523 `_, `#529 `_, `#536 `_ towncrier 23.6.0 (2023-06-06) ============================= This is the last release to support Python 3.7. Features -------- - Make ``towncrier create`` use the fragment counter rather than failing on existing fragment names. For example, if there is an existing fragment named ``123.feature``, then ``towncrier create 123.feature`` will now create a fragment named ``123.feature.1``. (`#475 `_) - Provide a default Markdown template if the configured filename ends with ``.md``. The Markdown template uses the same rendered format as the default *reStructuredText* template, but with a Markdown syntax. (`#483 `_) - Towncrier no longer depends on setuptools & uses importlib.resources (or its backport) instead. (`#496 `_) - Added pre-commit hooks for checking and updating news in projects using pre-commit. (`#498 `_) - Calling ``towncrier check`` without an existing configuration, will just show only an error message. In previous versions, a traceback was generated instead of the error message. (`#501 `_) Bugfixes -------- - Fix creating fragment in a section not adding random characters. For example, ``towncrier create some_section/+.feature`` should end up as a fragment named something like ``news/some_section/+a4e22da1.feature``. (`#468 `_) - Fix the ReadTheDocs build for ``towncrier`` which was broken due to the python version in use being 3.8. Upgrade to 3.11. (`#509 `_) Improved Documentation ---------------------- - Moved man page to correct section (`#470 `_) - Update link to Quick Start in configuration.html to point to Tutorial instead. (`#504 `_) - Add a note about the build command's ``--version`` requiring the command to be explicitly passed. (`#511 `_) - Fix typos in the Pre-Commit docs. (`#512 `_) Misc ---- - `#459 `_, `#462 `_, `#472 `_, `#485 `_, `#486 `_, `#487 `_, `#488 `_, `#495 `_, `#497 `_, `#507 `_, `#1117 `_, `#513 `_ 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 -------- - Issue number from file names will be stripped down to avoid issue 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) towncrier-24.8.0/RELEASE.rst0000644000000000000000000001200113615410400012345 0ustar00Release 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 candidate 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 ``pyproject.toml`` the version is set using a PEP440 compliant string: version = "19.9.0rc1" Use `towncrier` to generate the news release NEWS file, but first, make sure the new version is installed:: venv/bin/pip install -e . venv/bin/towncrier build --yes 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 (for now in RST format). * Make sure to check **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. We don't create discussion for pre-releases. Any discussions before the final release, can go on the PR itself. For now, the GitHub release text is reStructuredText as it's easy to copy and paste. In the future we might create a separate Markdown version. Release candidate publish failures ---------------------------------- The PyPI publish process is automatically triggered when a tag is created. The publish is skipped for PRs, so we can check that the automated process works only a release time. It can happen for the automated publish process to fail. As long as the package was not published to PyPI, do the followings: * Manually delete the candidate release from GitHub releases * Manually delete the tag for the release candidate Try to fix the issue and trigger the same release candidate again. Once the package is published on PyPI, do not delete the release or the tag. Proceed with create a new release candidate instead. Final release ------------- Once the PR is approved, you can trigger the final release. Update the version to the final version. In ``pyproject.toml`` the version is set using a PEP440 compliant string: version = "19.9.0" Manually update the `NEWS.rst` file to include the final release version and date. Usually it will look like this. This will replace the release candidate section:: towncrier 19.9.0 (2019-09-01) ============================= 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.9.0` * Content can be the content of the final release (RST format). * Check **Set as the latest release**. * Check **Create a discussion for this release**. * Click `Publish release` No need for another review request. Update the version to the development version. In ``pyproject.toml`` the version is set using a PEP440 compliant string: version = "19.9.0.dev0" Commit and push the changes. Merge the commit in the main branch, **without using squash**. We tag the release based on a commit from the release branch. If we merge with squash, the release tag commit will no longer be found in the main branch history. With a squash merge, the whole branch history is lost. This causes the `pre-commit autoupdate` to fail. See `PR590 `_ for more details. You can announce the release over IRC, Gitter, or Twisted mailing list. Done. towncrier-24.8.0/SECURITY.md0000644000000000000000000000041113615410400012326 0ustar00# Security Policy The twisted/towncrier project uses the same security policy as [twisted/twisted](https://github.com/twisted/twisted). For more details, please check the [Twisted security process](https://github.com/twisted/twisted?tab=security-ov-file#readme). towncrier-24.8.0/noxfile.py0000644000000000000000000000500213615410400012554 0ustar00from __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") # Keep list in-sync with ci.yml/test-linux & pyproject.toml @nox.session(python=["pypy3.8", "3.8", "3.9", "3.10", "3.11", "3.12"]) def tests(session: nox.Session) -> None: session.env["PYTHONWARNDEFAULTENCODING"] = "1" 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") @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 draft_newsfragment(session: nox.Session) -> None: session.install(".") session.run("python", "-m", "towncrier.build", "--draft") @nox.session def typecheck(session: nox.Session) -> None: # Click 8.1.4 is bad type hints -- lets not complicate packaging and only # pin here. session.install(".", "mypy", "click!=8.1.4") 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", "twine") # 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", "--strict", "dist/*") towncrier-24.8.0/.github/CODEOWNERS0000644000000000000000000000025413615410400013475 0ustar00# See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners * @twisted/twisted-contributors towncrier-24.8.0/.github/PULL_REQUEST_TEMPLATE.md0000644000000000000000000000170113615410400015701 0ustar00# Description Fixes # # Checklist * [ ] Make sure changes are covered by existing or new tests. * [ ] For at least one Python version, make sure test pass on your local environment. * [ ] Create a file in `src/towncrier/newsfragments/`. Briefly describe your changes, with information useful to end users. 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. towncrier-24.8.0/.github/workflows/ci.yml0000644000000000000000000002001513615410400015252 0ustar00name: CI on: push: branches: [ trunk ] tags: [ "**" ] pull_request: workflow_dispatch: defaults: run: shell: bash env: PIP_DISABLE_PIP_VERSION_CHECK: "1" 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 - uses: hynek/build-and-inspect-python-package@f51d0e79a34e62af977fcfe458b41fa8490e6e65 - 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: # Keep list in-sync with noxfile/tests & pyproject.toml. python: - 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' - name: CPython 3.12 action: '3.12' - 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 }} allow-prereleases: true cache: pip - name: Install dependencies run: python -m pip install --upgrade pip nox - uses: twisted/python-info-action@v1 - run: nox --python ${{ matrix.python.action }} -e ${{ matrix.task.nox }} -- --use-wheel dist/*.whl - name: Upload coverage data uses: actions/upload-artifact@v3 with: name: coverage-data path: .coverage.* if-no-files-found: ignore 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 - uses: twisted/python-info-action@v1 - run: nox --python ${{ matrix.python.action }} -e ${{ matrix.task.nox }} -- --use-wheel dist/*.whl check: name: ${{ matrix.task.name}} - ${{ matrix.python.name }} runs-on: ubuntu-latest needs: - build strategy: fail-fast: false matrix: python: # Use a recent version to avoid having common disconnects between # local development setups and CI. - name: CPython 3.12 python-version: '3.12' task: - name: Check Newsfragment run: | nox -e check_newsfragment nox -e draft_newsfragment >> $GITHUB_STEP_SUMMARY run-if: ${{ github.head_ref != 'pre-commit-ci-update-config' }} - name: Check mypy run: nox -e 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 - name: Check run: | ${{ matrix.task.run }} if: ${{ matrix.task.run-if }} pre-commit: name: Check pre-commit integration runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up python uses: actions/setup-python@v5 with: python-version: 3.12 - name: Install dependencies run: python -m pip install pre-commit - name: Install pre-commit run: | pre-commit install - name: Update pre-commit run: | pre-commit autoupdate - name: Run pre-commit run: | pre-commit run -a pypi-publish: name: Check tag and publish # Only trigger this for tag changes. if: startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest permissions: # IMPORTANT: this permission is mandatory for trusted publishing id-token: write 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.12 - name: Display structure of files to be pushed run: ls --recursive dist/ - name: Ensure tag and package versions match. run: | python -Im pip install dist/*.whl python -I admin/check_tag_version_match.py "${{ github.ref }}" - name: Publish to PyPI - on tag # This was tag 1.9.0 on 2024-07-30 uses: pypa/gh-action-pypi-publish@ec4db0b4ddc65acdf4bff5fa45ac92d78b56bdf0 coverage: name: Combine & check coverage. needs: test-linux runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: # Use latest Python, so it understands all syntax. python-version: 3.12 - run: python -Im pip install --upgrade coverage[toml] - uses: actions/download-artifact@v3 with: name: coverage-data - name: Combine coverage & fail if it's <100%. run: | python -Im coverage combine python -Im coverage html --skip-covered --skip-empty # Report and write to summary. python -Im coverage report --format=markdown >> $GITHUB_STEP_SUMMARY # Report again and fail if under 100%. python -Im coverage report --fail-under=100 - name: Upload HTML report if check failed. uses: actions/upload-artifact@v3 with: name: html-report path: htmlcov if: ${{ failure() }} # 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. # pypi-publish is skipped since this is only executed for a tag. - build - test-linux - test-windows - coverage - check - pre-commit steps: - name: Require all successes uses: re-actors/alls-green@3a2de129f0713010a71314c74e33c0e3ef90e696 with: jobs: ${{ toJSON(needs) }} towncrier-24.8.0/src/towncrier/__init__.py0000644000000000000000000000016013615410400015452 0ustar00# Copyright (c) Amber Brown, 2015 # See LICENSE for details. """ towncrier, a builder for your news files. """ towncrier-24.8.0/src/towncrier/__main__.py0000644000000000000000000000011513615410400015433 0ustar00from __future__ import annotations from towncrier._shell import cli cli() towncrier-24.8.0/src/towncrier/_builder.py0000644000000000000000000003403713615410400015512 0ustar00# Copyright (c) Amber Brown, 2015 # See LICENSE for details. from __future__ import annotations import os import re import textwrap from collections import defaultdict from fnmatch import fnmatch from pathlib import Path from typing import Any, DefaultDict, Iterable, Iterator, Mapping, NamedTuple, Sequence from click import ClickException from jinja2 import Template from towncrier._settings.load import Config # Returns issue, 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 # There are at least 2 parts. Search for a valid category from the second # part onwards starting at the back. # The category is used as the reference point in the parts list to later # infer the issue number and counter value. for i in reversed(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 all previous parts as the issue number. # NOTE: This allows news fragment names like fix-1.2.3.feature or # something-cool.feature.ext for projects that don't use issue # numbers in news fragment names. issue = ".".join(parts[0:i]).strip() # If the issue is an integer, remove any leading zeros (to resolve # issue #126). if issue.isdigit(): issue = str(int(issue)) 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 issue, category, counter else: # No valid category found. return invalid class FragmentsPath: """ A helper to get the full path to a fragments directory. This is a callable that optionally takes a section directory and returns the full path to the fragments directory for that section (or the default if no section is provided). """ def __init__(self, base_directory: str, config: Config): self.base_directory = base_directory self.config = config if config.directory is not None: self.base_directory = os.path.abspath( os.path.join(base_directory, config.directory) ) self.append_directory = "" else: self.base_directory = os.path.abspath( os.path.join(base_directory, config.package_dir, config.package) ) self.append_directory = "newsfragments" def __call__(self, section_directory: str = "") -> str: return os.path.join( self.base_directory, section_directory, self.append_directory ) # Returns a structure like: # # { # "": { # ("142", "misc", 1): "", # ("1", "feature", 1): "some cool description", # }, # "Names": {}, # "Web": {("3", "bugfix", 1): "Fixed a thing"}, # } # # and a list like: # [ # ("/path/to/fragments/142.misc.1", "misc"), # ("/path/to/fragments/1.feature.1", "feature"), # ] # # We should really use attrs. def find_fragments( base_directory: str, config: Config, strict: bool, ) -> tuple[Mapping[str, Mapping[tuple[str, str, int], str]], list[tuple[str, str]]]: """ Sections are a dictonary of section names to paths. If strict, raise ClickException if any fragments have an invalid name. """ ignored_files = { ".gitignore", ".gitkeep", ".keep", "readme", "readme.md", "readme.rst", } if isinstance(config.template, str): # Template can be a tuple of (package_name, resource_name). # # See https://github.com/twisted/towncrier/issues/634 ignored_files.add(os.path.basename(config.template)) if config.ignore: ignored_files.update(filename.lower() for filename in config.ignore) get_section_path = FragmentsPath(base_directory, config) content = {} fragment_files = [] # 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, section_dir in config.sections.items(): section_dir = get_section_path(section_dir) try: files = os.listdir(section_dir) except FileNotFoundError: files = [] file_content = {} for basename in files: if any( [ fnmatch(basename.lower(), ignore_pattern) for ignore_pattern in ignored_files ] ): continue issue, category, counter = parse_newfragment_basename( basename, config.types ) if category is None: if strict and issue is None: raise ClickException( f"Invalid news fragment name: {basename}\n" "If this filename is deliberate, add it to " "'ignore' in your configuration." ) continue assert issue is not None assert counter is not None if config.orphan_prefix and issue.startswith(config.orphan_prefix): issue = "" # Use and increment the orphan news fragment counter. counter = orphan_fragment_counter[category] orphan_fragment_counter[category] += 1 if ( config.issue_pattern and issue # not orphan and not re.fullmatch(config.issue_pattern, issue) ): raise ClickException( f"Issue name '{issue}' does not match the " f"configured pattern, '{config.issue_pattern}'" ) full_filename = os.path.join(section_dir, basename) fragment_files.append((full_filename, category)) data = Path(full_filename).read_text(encoding="utf-8", errors="replace") if (issue, category, counter) in file_content: raise ValueError( "multiple files for {}.{} in {}".format( issue, category, section_dir ) ) file_content[issue, category, counter] = data content[key] = file_content return content, fragment_files 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 = {} for section_name, section_fragments in fragments.items(): section: dict[str, dict[str, list[str]]] = {} for (issue, 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 and issue: # If this category is not supposed to show content (and we have an # issue) then we should just add the issue to the section rather than # the content. If there isn't an issue, still add the content so that # it's recorded. content = "" texts = section.setdefault(category, {}) issues = texts.setdefault(content, []) if issue: # Only add the issue if we have one (it can be blank for orphan news # fragments). issues.append(issue) issues.sort() output[section_name] = section return output class IssueParts(NamedTuple): is_digit: bool has_digit: bool non_digit_part: str number: int def issue_key(issue: str) -> IssueParts: """ Used to sort the issue ID inside a news fragment in a human-friendly way. Issue IDs are grouped by their non-integer part, then sorted by their integer part. For backwards compatible consistency, issues without no number are sorted first and digit only issues are sorted last. For example:: >>> sorted(["2", "#11", "#3", "gh-10", "gh-4", "omega", "alpha"], key=issue_key) ['alpha', 'omega', '#3', '#11', 'gh-4', 'gh-10', '2'] """ if issue.isdigit(): return IssueParts( is_digit=True, has_digit=True, non_digit_part="", number=int(issue) ) match = re.search(r"\d+", issue) if not match: return IssueParts( is_digit=False, has_digit=False, non_digit_part=issue, number=-1 ) return IssueParts( is_digit=False, has_digit=True, non_digit_part=issue[: match.start()] + issue[match.end() :], number=int(match.group()), ) def entry_key(entry: tuple[str, Sequence[str]]) -> tuple[str, list[IssueParts]]: 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]]]] = {} issues_by_category: dict[str, dict[str, list[str]]] = {} for section_name, section_value in fragments.items(): data[section_name] = {} issues_by_category[section_name] = {} for category_name, category_value in section_value.items(): category_issues: set[str] = set() # 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))) category_issues.update(issues) # 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 = {} for text, issues in entries: rendered = [render_issue(issue_format, i) for i in issues] categories[text] = rendered data[section_name][category_name] = categories issues_by_category[section_name][category_name] = [ render_issue(issue_format, i) for i in sorted(category_issues, key=issue_key) ] 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. issues_by_category=issues_by_category, ) 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) towncrier-24.8.0/src/towncrier/_git.py0000644000000000000000000000303113615410400014635 0ustar00# Copyright (c) Amber Brown, 2015 # See LICENSE for details. from __future__ import annotations import os from subprocess import STDOUT, CalledProcessError, call, check_output def remove_files(fragment_filenames: list[str]) -> None: if not fragment_filenames: return # Filter out files that are unknown to git try: git_fragments = check_output( ["git", "ls-files"] + fragment_filenames, encoding="utf-8" ).split("\n") except CalledProcessError: # we may not be in a git repository git_fragments = [] git_fragments = [os.path.abspath(f) for f in git_fragments if os.path.isfile(f)] call(["git", "rm", "--quiet", "--force"] + git_fragments) unknown_fragments = set(fragment_filenames) - set(git_fragments) for unknown_fragment in unknown_fragments: os.remove(unknown_fragment) 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() towncrier-24.8.0/src/towncrier/_project.py0000644000000000000000000000601113615410400015521 0ustar00# Copyright (c) Amber Brown, 2015 # See LICENSE for details. """ Responsible for getting the version and name from a project. """ from __future__ import annotations import contextlib import importlib.metadata import sys from importlib import import_module from importlib.metadata import PackageNotFoundError from types import ModuleType 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_metadata_version(package: str) -> str | None: """ Try to get the version from the package metadata. """ with contextlib.suppress(PackageNotFoundError): if version := importlib.metadata.version(package): return version return None def get_version(package_dir: str, package: str) -> str: """ Get the version of a package. Try to extract the version from the distribution version metadata that matches `package`, then fall back to looking for the package in `package_dir`. """ version: str | None # First try to get the version from the package metadata. if version := _get_metadata_version(package): return version # When no version if found, fall back to looking for the package in `package_dir`. module = _get_package(package_dir, package) version = getattr(module, "__version__", None) if not version: raise Exception( f"No __version__ or metadata version info for the '{package}' package." ) if isinstance(version, str): return version.strip() if isinstance(version, tuple): return ".".join(map(str, version)).strip() # Try duck-typing as an Incremental version. if hasattr(version, "base"): try: version = str(version.base()).strip() # Incremental uses `X.Y.rcN`. # Standardize on importlib (and PEP440) use of `X.YrcN`: return version.replace(".rc", "rc") # type: ignore except TypeError: pass raise Exception( "Version must be a string, tuple, or an Incremental Version." " 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) # Incremental has support for package names, try duck-typing it. with contextlib.suppress(AttributeError): return str(version.package) # type: ignore return package.title() towncrier-24.8.0/src/towncrier/_shell.py0000644000000000000000000000300313615410400015160 0ustar00# 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 .build import _main as _build_cmd from .check import _main as _check_cmd from .click_default_group import DefaultGroup from .create import _main as _create_cmd @click.group(cls=DefaultGroup, default="build", default_if_no_args=True) @click.version_option() 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) towncrier-24.8.0/src/towncrier/_writer.py0000644000000000000000000000522113615410400015371 0ustar00# 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 import sys from pathlib import Path from typing import Any if sys.version_info < (3, 10): # Compatibility shim for newline parameter to write_text, added in 3.10 def _newline_write_text(path: Path, content: str, **kwargs: Any) -> None: with path.open("w", **kwargs) as strm: # pragma: no branch strm.write(content) else: def _newline_write_text(path: Path, content: str, **kwargs: Any) -> None: path.write_text(content, **kwargs) 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?") _newline_write_text( news_file, # If there is no previous body that means we're writing a brand new news file. # We don't want extra whitespace at the end of this new file. header + (content + prev_body if prev_body else content.rstrip() + "\n"), encoding="utf-8", # Leave newlines alone. This probably leads to inconsistent newlines, # because we've loaded existing content with universal newlines, but that's # the original behavior. newline="", ) 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 "", "" content = Path(news_file).read_text(encoding="utf-8") 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() towncrier-24.8.0/src/towncrier/build.py0000644000000000000000000002076513615410400015027 0ustar00# 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 from pathlib import Path import click from click import Context, Option, UsageError 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 if sys.version_info < (3, 10): import importlib_resources as resources else: from importlib import resources 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, help="Render the news fragments using the given date.", ) @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 if project_version is None: project_version = config.version if project_version is None: if not config.package: raise UsageError( "'--version' is required since the config file does " "not contain 'version' or 'package'." ) project_version = get_version( os.path.join(base_directory, config.package_dir), config.package ).strip() click.echo("Loading template...", err=to_err) if isinstance(config.template, tuple): template = ( resources.files(config.template[0]) .joinpath(config.template[1]) .read_text(encoding="utf-8") ) template_extension = os.path.splitext(config.template[1])[1] else: template = Path(config.template).read_text(encoding="utf-8") template_extension = os.path.splitext(config.template)[1] is_markdown = template_extension.lower() == ".md" click.echo("Finding news fragments...", err=to_err) fragment_contents, fragment_files = find_fragments( base_directory, config, # Fail if any fragment filenames are invalid only if ignore list is set # (this maintains backward compatibility): strict=(config.ignore is not None), ) fragment_filenames = [filename for (filename, _category) in fragment_files] click.echo("Rendering news fragments...", err=to_err) fragments = split_fragments( fragment_contents, config.types, all_bullets=config.all_bullets ) 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() # Render the title in the template if the title format is set to "". It can # alternatively be set to False or a string, in either case it shouldn't be rendered # in the template. render_title = config.title_format == "" 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, ) if config.title_format: top_line = config.title_format.format( name=project_name, version=project_version, project_date=project_date ) if is_markdown: parts = [top_line] else: parts = [top_line, config.underlines[0] * len(top_line)] parts.append(rendered) content = "\n".join(parts) else: top_line = "" 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) return 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) if should_remove_fragment_files( fragment_filenames, answer_yes, answer_keep, ): click.echo("Removing news fragments...", err=to_err) _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: if not fragment_filenames: click.echo("No news fragments to remove. Skipping!") return False 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() towncrier-24.8.0/src/towncrier/check.py0000644000000000000000000001006213615410400014772 0ustar00# 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-only 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.abspath(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("----") # This will fail if any fragment files have an invalid name: all_fragment_files = find_fragments(base_directory, config, strict=True)[1] 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) fragments = set() # will only include fragments of types that are checked unchecked_fragments = set() # will include fragments of types that are not checked for fragment_filename, category in all_fragment_files: if config.types[category]["check"]: fragments.add(fragment_filename) else: unchecked_fragments.add(fragment_filename) fragments_in_branch = fragments & files if not fragments_in_branch: unchecked_fragments_in_branch = unchecked_fragments & files if unchecked_fragments: click.echo("Found newsfragments of unchecked types in the branch:") for n, fragment in enumerate(unchecked_fragments_in_branch, start=1): click.echo(f"{n}. {fragment}") else: 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() towncrier-24.8.0/src/towncrier/click_default_group.py0000644000000000000000000001007713615410400017730 0ustar00# SPDX-FileCopyrightText: 2015 Heungsub Lee <19982+sublee@users.noreply.github.com> # # SPDX-License-Identifier: BSD-3-Clause # Vendored from # https://github.com/click-contrib/click-default-group/tree/b671ae5325d186fe5ea7abb584f15852a1e931aa # Because the PyPI package could not be installed on modern Pips anymore and # the project looks unmaintaintained. """ click_default_group ~~~~~~~~~~~~~~~~~~~ Define a default subcommand by `default=True`: .. sourcecode:: python import click from click_default_group import DefaultGroup @click.group(cls=DefaultGroup, default_if_no_args=True) def cli(): pass @cli.command(default=True) def foo(): click.echo('foo') @cli.command() def bar(): click.echo('bar') Then you can invoke that without explicit subcommand name: .. sourcecode:: console $ cli.py --help Usage: cli.py [OPTIONS] COMMAND [ARGS]... Options: --help Show this message and exit. Command: foo* bar $ cli.py foo $ cli.py foo foo $ cli.py bar bar """ import warnings import click __all__ = ["DefaultGroup"] __version__ = "1.2.2" class DefaultGroup(click.Group): """Invokes a subcommand marked with `default=True` if any subcommand not chosen. :param default_if_no_args: resolves to the default command if no arguments passed. """ def __init__(self, *args, **kwargs): # To resolve as the default command. if not kwargs.get("ignore_unknown_options", True): raise ValueError("Default group accepts unknown options") self.ignore_unknown_options = True self.default_cmd_name = kwargs.pop("default", None) self.default_if_no_args = kwargs.pop("default_if_no_args", False) super().__init__(*args, **kwargs) def set_default_command(self, command): """Sets a command function as the default command.""" cmd_name = command.name self.add_command(command) self.default_cmd_name = cmd_name def parse_args(self, ctx, args): if not args and self.default_if_no_args: args.insert(0, self.default_cmd_name) return super().parse_args(ctx, args) def get_command(self, ctx, cmd_name): if cmd_name not in self.commands: # No command name matched. ctx.arg0 = cmd_name cmd_name = self.default_cmd_name return super().get_command(ctx, cmd_name) def resolve_command(self, ctx, args): base = super() cmd_name, cmd, args = base.resolve_command(ctx, args) if hasattr(ctx, "arg0"): args.insert(0, ctx.arg0) cmd_name = cmd.name return cmd_name, cmd, args def format_commands(self, ctx, formatter): formatter = DefaultCommandFormatter(self, formatter, mark="*") return super().format_commands(ctx, formatter) def command(self, *args, **kwargs): default = kwargs.pop("default", False) decorator = super().command(*args, **kwargs) if not default: return decorator warnings.warn( "Use default param of DefaultGroup or " "set_default_command() instead", DeprecationWarning, ) def _decorator(f): cmd = decorator(f) self.set_default_command(cmd) return cmd return _decorator class DefaultCommandFormatter: """Wraps a formatter to mark a default command.""" def __init__(self, group, formatter, mark="*"): self.group = group self.formatter = formatter self.mark = mark def __getattr__(self, attr): return getattr(self.formatter, attr) def write_dl(self, rows, *args, **kwargs): rows_ = [] for cmd_name, help in rows: if cmd_name == self.group.default_cmd_name: rows_.insert(0, (cmd_name + self.mark, help)) else: rows_.append((cmd_name, help)) return self.formatter.write_dl(rows_, *args, **kwargs) towncrier-24.8.0/src/towncrier/create.py0000644000000000000000000001672213615410400015171 0ustar00# Copyright (c) Stephen Finucane, 2019 # See LICENSE for details. """ Create a new fragment. """ from __future__ import annotations import os from pathlib import Path from typing import cast import click from ._builder import FragmentsPath from ._settings import config_option_help, load_config_from_options DEFAULT_CONTENT = "Add your info here" @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=None, help="Open an editor for writing the newsfragment content.", ) @click.option( "-c", "--content", type=str, default=DEFAULT_CONTENT, help="Sets the content of the new fragment.", ) @click.option( "--section", type=str, help="The section to create the fragment for.", ) @click.argument("filename", default="") def _main( ctx: click.Context, directory: str | None, config: str | None, filename: str, edit: bool | None, content: str, section: str | None, ) -> None: """ Create a new news fragment. If FILENAME is not provided, you'll be prompted to create it. 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 - an issue has been closed, but it is not of interest to users. If the FILENAME base is just '+' (to create a fragment not tied to an issue), it will be appended with a random hex string. """ __main(ctx, directory, config, filename, edit, content, section) def __main( ctx: click.Context, directory: str | None, config_path: str | None, filename: str, edit: bool | None, content: str, section: str | None, ) -> None: """ The main entry point. """ base_directory, config = load_config_from_options(directory, config_path) filename_ext = "" if config.create_add_extension: ext = os.path.splitext(config.filename)[1] if ext.lower() in (".rst", ".md"): filename_ext = ext section_provided = section is not None if not section_provided: # Get the default section. if len(config.sections) == 1: section = next(iter(config.sections)) else: # If there are multiple sections then the first without a path is the default # section, otherwise it's the first defined section. for ( section_name, section_dir, ) in config.sections.items(): # pragma: no branch if not section_dir: section = section_name break if section is None: section = list(config.sections.keys())[0] if section not in config.sections: # Raise a click exception with the correct parameter. section_param = None for p in ctx.command.params: # pragma: no branch if p.name == "section": section_param = p break expected_sections = ", ".join(f"'{s}'" for s in config.sections) raise click.BadParameter( f"expected one of {expected_sections}", param=section_param, ) section = cast(str, section) if not filename: if not section_provided: sections = list(config.sections) if len(sections) > 1: click.echo("Pick a section:") default_section_index = None for i, s in enumerate(sections): click.echo(f" {i+1}: {s or '(primary)'}") if not default_section_index and s == section: default_section_index = str(i + 1) section_index = click.prompt( "Section", type=click.Choice([str(i + 1) for i in range(len(sections))]), default=default_section_index, ) section = sections[int(section_index) - 1] prompt = "Issue number" # Add info about adding orphan if config is set. if config.orphan_prefix: prompt += f" (`{config.orphan_prefix}` if none)" issue = click.prompt(prompt) fragment_type = click.prompt( "Fragment type", type=click.Choice(list(config.types)), ) filename = f"{issue}.{fragment_type}" if edit is None and content == DEFAULT_CONTENT: edit = True file_dir, file_basename = os.path.split(filename) if config.orphan_prefix and file_basename.startswith(f"{config.orphan_prefix}."): # Append a random hex string to the orphan news fragment base name. filename = os.path.join( file_dir, ( f"{config.orphan_prefix}{os.urandom(4).hex()}" f"{file_basename[len(config.orphan_prefix):]}" ), ) filename_parts = filename.split(".") if len(filename_parts) < 2 or ( filename_parts[-1] not in config.types and filename_parts[-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 filename_parts[-1] in config.types and filename_ext: filename += filename_ext get_fragments_path = FragmentsPath(base_directory, config) fragments_directory = get_fragments_path(section_directory=config.sections[section]) if not os.path.exists(fragments_directory): os.makedirs(fragments_directory) segment_file = os.path.join(fragments_directory, filename) retry = 0 if filename.split(".")[-1] not in config.types: filename, extra_ext = os.path.splitext(filename) else: extra_ext = "" while os.path.exists(segment_file): retry += 1 segment_file = os.path.join( fragments_directory, f"{filename}.{retry}{extra_ext}" ) if edit: if content == DEFAULT_CONTENT: content = "" content = _get_news_content_from_user(content, extension=filename_ext) if not content: click.echo("Aborted creating news fragment due to empty message.") ctx.exit(1) add_newline = bool( config.create_eof_newline and content and not content.endswith("\n") ) Path(segment_file).write_text(content + "\n" * add_newline, encoding="utf-8") click.echo(f"Created news fragment at {segment_file}") def _get_news_content_from_user(message: str, extension: str = "") -> str: initial_content = """ # Please write your news content. Lines starting with '#' will be ignored, and # an empty message aborts. """ if message: initial_content = f"{message}\n{initial_content}" content = click.edit(initial_content, extension=extension or ".txt") if content is None: return message all_lines = content.split("\n") lines = [line.rstrip() for line in all_lines if not line.lstrip().startswith("#")] return "\n".join(lines).strip() if __name__ == "__main__": # pragma: no cover _main() towncrier-24.8.0/src/towncrier/_settings/__init__.py0000644000000000000000000000113613615410400017455 0ustar00"""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", ] towncrier-24.8.0/src/towncrier/_settings/fragment_types.py0000644000000000000000000001106413615410400020746 0ustar00from __future__ import annotations import abc 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 = { # Keep in-sync with docs/tutorial.rst. "feature": {"name": "Features", "showcontent": True, "check": True}, "bugfix": {"name": "Bugfixes", "showcontent": True, "check": True}, "doc": {"name": "Improved Documentation", "showcontent": True, "check": True}, "removal": { "name": "Deprecations and Removals", "showcontent": True, "check": True, }, "misc": {"name": "Misc", "showcontent": False, "check": True}, } 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 = {} 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"] check = type_config.get("check", True) types[directory] = { "name": fragment_type_name, "showcontent": is_content_required, "check": check, } 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 = dict(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) check = options.get("check", True) clean_fragment_options = { "name": fragment_description, "showcontent": show_content, "check": check, } return clean_fragment_options towncrier-24.8.0/src/towncrier/_settings/load.py0000644000000000000000000001607213615410400016642 0ustar00# Copyright (c) Amber Brown, 2015 # See LICENSE for details. from __future__ import annotations import atexit import dataclasses import os import re import sys from contextlib import ExitStack from pathlib import Path from typing import Any, Literal, Mapping, Sequence from click import ClickException from .._settings import fragment_types as ft if sys.version_info < (3, 10): import importlib_resources as resources else: from importlib import resources if sys.version_info < (3, 11): import tomli as tomllib else: import tomllib re_resource_template = re.compile(r"[-\w.]+:[-\w.]+$") @dataclasses.dataclass class Config: sections: Mapping[str, str] types: Mapping[str, Mapping[str, Any]] template: str | tuple[str, str] start_string: str package: str = "" package_dir: str = "." single_file: bool = True filename: str = "NEWS.rst" directory: str | None = None version: str | None = None name: str = "" title_format: str | Literal[False] = "" issue_format: str | None = None underlines: Sequence[str] = ("=", "-", "~") wrap: bool = False all_bullets: bool = True orphan_prefix: str = "+" create_eof_newline: bool = True create_add_extension: bool = True ignore: list[str] | None = None issue_pattern: str = "" class ConfigError(ClickException): def __init__(self, *args: str, **kwargs: str): self.failing_option = kwargs.get("failing_option") super().__init__(*args) def load_config_from_options( directory: str | None, config_path: str | None ) -> tuple[str, Config]: """ Load the configuration from a given directory or specific configuration file. Unless an explicit configuration file is given, traverse back from the given directory looking for a configuration file. Returns a tuple of the base directory and the parsed Config instance. """ if config_path is None: return traverse_for_config(directory) config_path = os.path.abspath(config_path) # When a directory is provided (in addition to the config file), use it as the base # directory. Otherwise use the directory containing the config file. if directory is not None: base_directory = os.path.abspath(directory) else: base_directory = os.path.dirname(config_path) if not os.path.isfile(config_path): raise ConfigError(f"Configuration file '{config_path}' not found.") config = load_config_from_file(base_directory, config_path) return base_directory, config def traverse_for_config(path: str | None) -> tuple[str, Config]: """ Search for a configuration file in the current directory and all parent directories. Returns the directory containing the configuration file and the parsed configuration. """ start_directory = directory = os.path.abspath(path or os.getcwd()) while True: config = load_config(directory) if config is not None: return directory, config parent = os.path.dirname(directory) if parent == directory: raise ConfigError( f"No configuration file found.\nLooked back from: {start_directory}" ) directory = parent 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) # Clean up possible temporary files on exit. _file_manager = ExitStack() atexit.register(_file_manager.close) def parse_toml(base_path: str, config: Mapping[str, Any]) -> Config: if "towncrier" not in (config.get("tool") or {}): raise ConfigError("No [tool.towncrier] section.", failing_option="all") config = config["tool"]["towncrier"] parsed_data = {} # Check for misspelt options. for typo, correct in [ ("singlefile", "single_file"), ]: if config.get(typo): raise ConfigError( f"`{typo}` is not a valid option. Did you mean `{correct}`?", failing_option=typo, ) # Process options. for field in dataclasses.fields(Config): if field.name in ("sections", "types", "template"): # Skip these options, they are processed later. continue if field.name in config: # Interestingly, the __future__ annotation turns the type into a string. if field.type in ("bool", bool): if not isinstance(config[field.name], bool): raise ConfigError( f"`{field.name}` option must be boolean: false or true.", failing_option=field.name, ) parsed_data[field.name] = config[field.name] # Process 'section'. sections = {} if "section" in config: for x in config["section"]: sections[x.get("name", "")] = x["path"] else: sections[""] = "" parsed_data["sections"] = sections # Process 'types'. fragment_types_loader = ft.BaseFragmentTypesLoader.factory(config) parsed_data["types"] = fragment_types_loader.load() # Process 'template'. markdown_file = Path(config.get("filename", "")).suffix == ".md" template = config.get("template", "towncrier:default") if re_resource_template.match(template): package, resource = template.split(":", 1) if not Path(resource).suffix: resource += ".md" if markdown_file else ".rst" if not _pkg_file_exists(package, resource): if _pkg_file_exists(package + ".templates", resource): package += ".templates" else: raise ConfigError( f"'{package}' does not have a template named '{resource}'.", failing_option="template", ) template = (package, resource) else: template = os.path.join(base_path, template) if not os.path.isfile(template): raise ConfigError( f"The template file '{template}' does not exist.", failing_option="template", ) parsed_data["template"] = template # Process 'start_string'. start_string = config.get("start_string", "") if not start_string: start_string_template = "\n" if markdown_file else ".. {}\n" start_string = start_string_template.format("towncrier release notes start") parsed_data["start_string"] = start_string # Return the parsed config. return Config(**parsed_data) def _pkg_file_exists(pkg: str, file: str) -> bool: """ Check whether *file* exists within *pkg*. """ return resources.files(pkg).joinpath(file).is_file() towncrier-24.8.0/src/towncrier/templates/default.md0000644000000000000000000000252713615410400017316 0ustar00{% if render_title %} {% if versiondata.name %} # {{ versiondata.name }} {{ versiondata.version }} ({{ versiondata.date }}) {% else %} # {{ versiondata.version }} ({{ versiondata.date }}) {% endif %} {% endif %} {% for section, _ in sections.items() %} {% if section %} ## {{section}} {% endif %} {% if sections[section] %} {% for category, val in definitions.items() if category in sections[section] %} ### {{ definitions[category]['name'] }} {% for text, values in sections[section][category].items() %} - {{ text }} {%- if values %} {% if "\n - " in text or '\n * ' in text %} ( {%- else %} {% if text %} ({% endif %} {%- endif -%} {%- for issue in values %} {{ issue.split(": ", 1)[0] }}{% if not loop.last %}, {% endif %} {%- endfor %} {% if text %}){% endif %} {% else %} {% endif %} {% endfor %} {% if issues_by_category[section][category] and "]: " in issues_by_category[section][category][0] %} {% for issue in issues_by_category[section][category] %} {{ issue }} {% endfor %} {% endif %} {% if sections[section][category]|length == 0 %} No significant changes. {% else %} {% endif %} {% endfor %} {% else %} No significant changes. {% endif %} {% endfor +%} {# This comment adds one more newline at the end of the rendered newsfile content. In this way the there are 2 newlines between the latest release and the previous release content. #} towncrier-24.8.0/src/towncrier/templates/default.rst0000644000000000000000000000215613615410400017524 0ustar00{% 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 }} {% for text, values in sections[section][category].items() %} - {% if text %}{{ text }}{% if values %} ({{ values|join(', ') }}){% endif %}{% else %}{{ values|join(', ') }}{% endif %} {% endfor %} {% if sections[section][category]|length == 0 %} No significant changes. {% else %} {% endif %} {% endfor %} {% else %} No significant changes. {% endif %} {% endfor %} towncrier-24.8.0/src/towncrier/templates/hr-between-versions.rst0000644000000000000000000000223213615410400022001 0ustar00{% 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 %} ---- towncrier-24.8.0/src/towncrier/templates/single-file-no-bullets.rst0000644000000000000000000000224513615410400022357 0ustar00{% 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 %} towncrier-24.8.0/src/towncrier/test/__init__.py0000644000000000000000000000000013615410400016422 0ustar00towncrier-24.8.0/src/towncrier/test/helpers.py0000644000000000000000000000776113615410400016352 0ustar00from __future__ import annotations import sys import textwrap from functools import wraps from pathlib import Path from subprocess import call from typing import Any, Callable from click.testing import CliRunner if sys.version_info < (3, 9): import importlib_resources as resources else: from importlib import resources def read(filename: str | Path) -> str: return Path(filename).read_text() def write(path: str | Path, contents: str, dedent: bool = False) -> None: """ Create a file with given contents including any missing parent directories """ p = Path(path) p.parent.mkdir(parents=True, exist_ok=True) if dedent: contents = textwrap.dedent(contents) p.write_text(contents) def read_pkg_resource(path: str) -> str: """ Read *path* from the towncrier package. """ return (resources.files("towncrier") / path).read_text("utf-8") 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 else: config = textwrap.dedent(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() def with_project( *, config: str | None = None, pyproject_path: str = "pyproject.toml", ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: """Decorator to run a test with an isolated directory containing a simple project. The files are not managed by git. `config` is the content of the config file. It will be automatically dedented. `pyproject_path` is the path where to store the config file. """ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: @wraps(fn) def test(*args: Any, **kw: Any) -> Any: runner = CliRunner() with runner.isolated_filesystem(): setup_simple_project( config=config, pyproject_path=pyproject_path, ) return fn(*args, runner=runner, **kw) return test return decorator def with_git_project( *, config: str | None = None, pyproject_path: str = "pyproject.toml", ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: """Decorator to run a test with an isolated directory containing a simple project checked into git. Use `config` to tweak the content of the config file. Use `pyproject_path` to tweak the location of the config file. """ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: def _commit() -> None: call(["git", "add", "."]) call(["git", "commit", "-m", "Second Commit"]) @wraps(fn) def test(*args: Any, **kw: Any) -> Any: runner = CliRunner() with runner.isolated_filesystem(): setup_simple_project( config=config, pyproject_path=pyproject_path, ) call(["git", "init"]) call(["git", "config", "user.name", "user"]) call(["git", "config", "user.email", "user@example.com"]) call(["git", "config", "commit.gpgSign", "false"]) call(["git", "add", "."]) call(["git", "commit", "-m", "Initial Commit"]) return fn(*args, runner=runner, commit=_commit, **kw) return test return decorator towncrier-24.8.0/src/towncrier/test/test_build.py0000644000000000000000000015176413615410400017051 0ustar00# 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, read_pkg_resource, with_git_project, with_project, write class TestCli(TestCase): maxDiff = None @with_project() def _test_command(self, command, runner): # Off the shelf newsfragment 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") with open("foo/newsfragments/+123_orphaned.feature", "w") as f: f.write("An orphaned feature starting with a number") with open("foo/newsfragments/+12.3_orphaned.feature", "w") as f: f.write("An orphaned feature starting with a dotted number") with open("foo/newsfragments/+orphaned_123.feature", "w") as f: f.write("An orphaned feature ending with a number") with open("foo/newsfragments/+orphaned_12.3.feature", "w") as f: f.write("An orphaned feature ending with a dotted number") # 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, 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. Foo 1.2.3 (01-01-2001) ====================== Features -------- - Baz levitation (baz) - Baz fix levitation (fix-1.2) - Adds levitation (#123) - Extends levitation (#124) - An orphaned feature ending with a dotted number - An orphaned feature ending with a number - An orphaned feature starting with a dotted number - An orphaned feature starting with a number - Another orphaned feature - Orphaned feature """ ), ) def test_command(self): self._test_command(cli) def test_subcommand(self): self._test_command(_main) @with_project() 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() 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_project() def test_traverse_up_to_find_config(self, runner): """ When the current directory doesn't contain the configuration file, Towncrier will traverse up the directory tree until it finds it. """ os.chdir("foo") result = runner.invoke(_main, ["--draft", "--date", "01-01-2001"]) self.assertEqual(0, result.exit_code, result.output) @with_project() 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 correct paths to the directory and the config file. """ project_dir = Path(".").resolve() 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_project( config=""" [tool.towncrier] directory = "changelog.d" """ ) def test_in_different_dir_with_nondefault_newsfragments_directory(self, runner): """ Using the `--dir` CLI argument, the NEWS file can be generated in a sub-directory from fragments that are relatives to that sub-directory. The path passed to `--dir` becomes the working directory. """ Path("foo/foo").mkdir(parents=True) Path("foo/foo/__init__.py").write_text("") Path("foo/changelog.d").mkdir() Path("foo/changelog.d/123.feature").write_text("Adds levitation") self.assertFalse(Path("foo/NEWS.rst").exists()) result = runner.invoke( cli, ("--yes", "--config", "pyproject.toml", "--dir", "foo", "--version", "1.0"), ) self.assertEqual(0, result.exit_code) self.assertTrue(Path("foo/NEWS.rst").exists()) @with_project() def test_no_newsfragment_directory(self, runner): """ A missing newsfragment directory acts as if there are no changes. """ os.rmdir("foo/newsfragments") result = runner.invoke(_main, ["--draft", "--date", "01-01-2001"]) self.assertEqual(0, result.exit_code) self.assertIn("No significant changes.\n", result.output) @with_project() def test_no_newsfragments_draft(self, runner): """ An empty newsfragment directory acts as if there are no changes. """ result = runner.invoke(_main, ["--draft", "--date", "01-01-2001"]) self.assertEqual(0, result.exit_code) self.assertIn("No significant changes.\n", result.output) @with_project() def test_no_newsfragments(self, runner): """ An empty newsfragment directory acts as if there are no changes and removing files handles it gracefully. """ 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) @with_project() def test_collision(self, runner): # 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) """ ), ) @with_git_project() def test_draft_no_date(self, runner, commit): """ If no date is passed, today's date is used. """ 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") 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) @with_git_project() def test_no_confirmation(self, runner, commit): 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") 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_git_project() def test_keep_fragments(self, runner, commit): """ The `--keep` option will build the full final news file without deleting the fragment files and without any extra CLI interaction or confirmation. """ 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") 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_git_project() def test_yes_keep_error(self, runner, commit): """ 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. """ 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") 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) @with_git_project() def test_confirmation_says_no(self, runner, commit): """ If the user says "no" to removing the newsfragements, we end up with a NEWS.rst AND the newsfragments. """ 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") 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.")) @with_project(config="[tool.towncrier]") def test_needs_version(self, runner: CliRunner): """ If the configuration file doesn't specify a version or a package, the version option is required. """ result = runner.invoke(_main, ["--draft"], catch_exceptions=False) self.assertEqual(2, result.exit_code) self.assertIn("Error: '--version' is required", result.output) @with_project() def test_projectless_changelog(self, runner): """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. """ # Remove the version from the project Path("foo/__init__.py").unlink() 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(), ) @with_project( config=""" [tool.towncrier] version = "7.8.9" """ ) def test_version_in_config(self, runner): """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. """ 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(), ) @with_project( config=""" [tool.towncrier] name = "ImGoProject" """ ) def test_project_name_in_config(self, runner): """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. """ 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(), ) @with_project(config="[tool.towncrier]") def test_no_package_changelog(self, runner): """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. """ 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(), ) @with_project( config=""" [tool.towncrier] single_file=false filename="{version}-notes.rst" """ ) def test_release_notes_in_separate_files(self, runner): """ 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. """ 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", ], ) # Fragment files unknown to git are removed even without a git repo assert not Path(f"newsfragments/{fragment_file}").exists() return result results = [] 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(), ) @with_project( config=""" [tool.towncrier] singlefile="fail!" """ ) def test_singlefile_errors_and_explains_cleanly(self, runner): """ Failure to find the configuration file results in a clean explanation without a traceback. """ 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, ) # Fragment files unknown to git are removed even without a git repo assert not Path(f"newsfragments/{fragment_file}").exists() 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(), ) @with_project( config=""" [tool.towncrier] template="towncrier:single-file-no-bullets" all_bullets=false """ ) def test_bullet_points_false(self, runner): """ When all_bullets is false, subsequent lines are not indented. The automatic issue number inserted by towncrier will align with the manual bullet. """ 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, dedent( """ 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(), ) @with_project( config=""" [tool.towncrier] package = "foo" title_format = "[{project_date}] CUSTOM RELEASE for {name} version {version}" """ ) def test_title_format_custom(self, runner): """ A non-empty title format adds the specified title. """ 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) @with_project( config=""" [tool.towncrier] package = "foo" filename = "NEWS.md" title_format = "[{project_date}] CUSTOM RELEASE for {name} version {version}" """ ) def test_title_format_custom_markdown(self, runner): """ A non-empty title format adds the specified title, and if the target filename is markdown then the title is added as given by the config. In this way, full control is given to the user. We make this choice for markdown files because markdown header levels depend on where in the file the section is being written and require modifications in the same line, hence there is no easy way to know what to do to get the header to be at the right level. This avoids a repeat of the regression introduced in [#610](https://github.com/twisted/towncrier/pull/610), which mistakenly assumed that starting the line with '# ' would work in all use cases. """ with open("foo/newsfragments/123.feature", "w") as f: f.write("Adds 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) """ ) self.assertEqual(0, result.exit_code) self.assertEqual(expected_output, result.output) @with_project( config=""" [tool.towncrier] package = "foo" filename = "NEWS.md" title_format = "### [{project_date}] CUSTOM RELEASE for {name} version {version}" template = "custom_template.md" """ ) def test_markdown_injected_after_header(self, runner): """ Test that we can inject markdown after some fixed header and have the injected markdown header levels set at the desired level. This avoids a repeat of the regression introduced in [#610](https://github.com/twisted/towncrier/pull/610), which mistakenly assumed that starting the line with '# ' would work in all use cases. """ write("foo/newsfragments/123.feature", "Adds levitation") write( "NEWS.md", contents=""" # Top title ## Section title Some text explaining something another line ## Release notes a footer! """, dedent=True, ) default_template = read_pkg_resource("templates/default.md") write( "custom_template.md", contents=default_template.replace( "### {{ definitions", "#### {{ definitions" ), ) result = runner.invoke(_main, ["--date", "01-01-2001"], catch_exceptions=False) with open("foo/newsfragments/123.feature", "w") as f: f.write("Adds levitation") self.assertEqual(0, result.exit_code, result.output) output = read("NEWS.md") expected_output = dedent( """ # Top title ## Section title Some text explaining something another line ## Release notes ### [01-01-2001] CUSTOM RELEASE for Foo version 1.2.3 #### Features - Adds levitation (#123) a footer! """ ) self.assertEqual(expected_output, output) @with_project( config=""" [tool.towncrier] package = "foo" title_format = false template = "template.rst" """ ) def test_title_format_false(self, runner): """ Setting the title format to false disables the explicit title. This would be used, for example, when the template creates the title itself. """ 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", ], catch_exceptions=False, ) 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) @with_project( config=""" [tool.towncrier] start_string="Release notes start marker" """ ) def test_start_string(self, runner): """ 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. """ 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) @with_project() def test_default_start_string(self, runner): """ The default start string is ``.. towncrier release notes start``. """ write("foo/newsfragments/123.feature", "Adds levitation") write( "NEWS.rst", contents=""" a line another .. towncrier release notes start a footer! """, dedent=True, ) result = runner.invoke(_main, ["--date", "01-01-2001"], catch_exceptions=False) self.assertEqual(0, result.exit_code, result.output) output = read("NEWS.rst") expected_output = dedent( """ a line another .. towncrier release notes start Foo 1.2.3 (01-01-2001) ====================== Features -------- - Adds levitation (#123) a footer! """ ) self.assertEqual(expected_output, output) @with_project( config=""" [tool.towncrier] package = "foo" filename = "NEWS.md" """ ) def test_default_start_string_markdown(self, runner): """ The default start string is ```` for Markdown. """ write("foo/newsfragments/123.feature", "Adds levitation") write( "NEWS.md", contents=""" a line another a footer! """, dedent=True, ) result = runner.invoke(_main, ["--date", "01-01-2001"], catch_exceptions=False) self.assertEqual(0, result.exit_code, result.output) output = read("NEWS.md") expected_output = dedent( """ a line another # Foo 1.2.3 (01-01-2001) ### Features - Adds levitation (#123) a footer! """ ) self.assertEqual(expected_output, output) @with_project( config=""" [tool.towncrier] name = "" directory = "changes" filename = "NEWS.md" version = "1.2.3" """ ) def test_markdown_no_name_title(self, runner): """ When configured with an empty `name` option, the default template used for Markdown renders the title of the release note with just the version number and release date. """ write("changes/123.feature", "Adds levitation") write( "NEWS.md", contents=""" A line """, dedent=True, ) result = runner.invoke(_main, ["--date", "01-01-2001"], catch_exceptions=False) self.assertEqual(0, result.exit_code, result.output) output = read("NEWS.md") expected_output = dedent( """ A line # 1.2.3 (01-01-2001) ### Features - Adds levitation (#123) """ ) self.assertEqual(expected_output, output) @with_project( config=""" [tool.towncrier] title_format = "{version} - {project_date}" template = "template.rst" [[tool.towncrier.type]] directory = "feature" name = "" showcontent = true """ ) def test_with_topline_and_template_and_draft(self, runner): """ Spacing is proper when drafting with a topline and a template. """ 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) @with_project( config=""" [tool.towncrier] """ ) def test_orphans_in_non_showcontent(self, runner): """ When ``showcontent`` is false (like in the ``misc`` category by default), orphans are still rendered because they don't have an issue number to display. """ os.mkdir("newsfragments") with open("newsfragments/123.misc", "w") as f: f.write("Misc") with open("newsfragments/345.misc", "w") as f: f.write("Another misc") with open("newsfragments/+.misc", "w") as f: f.write("Orphan misc still displayed!") with open("newsfragments/+2.misc", "w") as f: f.write("Another orphan misc still displayed!") result = runner.invoke( _main, [ "--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. 7.8.9 (20-01-2001) ================== Misc ---- - #123, #345 - Another orphan misc still displayed! - Orphan misc still displayed! """ ) self.assertEqual(0, result.exit_code, result.output) self.assertEqual(expected_output, result.output) @with_project( config=""" [tool.towncrier] filename = "CHANGES.md" """ ) def test_orphans_in_non_showcontent_markdown(self, runner): """ When ``showcontent`` is false (like in the ``misc`` category by default), orphans are still rendered because they don't have an issue number to display. """ os.mkdir("newsfragments") with open("newsfragments/123.misc", "w") as f: f.write("Misc") with open("newsfragments/345.misc", "w") as f: f.write("Another misc") with open("newsfragments/+.misc", "w") as f: f.write("Orphan misc still displayed!") with open("newsfragments/+2.misc", "w") as f: f.write("Another orphan misc still displayed!") result = runner.invoke( _main, [ "--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. # 7.8.9 (20-01-2001) ### Misc - #123, #345 - Another orphan misc still displayed! - Orphan misc still displayed! """ ) self.assertEqual(0, result.exit_code, result.output) self.assertEqual(expected_output, result.output) @with_git_project() def test_uncommitted_files(self, runner, commit): """ At build time, it will delete any fragment file regardless of its stage, included files that are not part of the git reporsitory, or are just staged or modified. """ # 123 is committed, 124 is modified, 125 is just added, 126 is unknown with open("foo/newsfragments/123.feature", "w") as f: f.write("Adds levitation. File committed.") with open("foo/newsfragments/124.feature", "w") as f: f.write("Extends levitation. File modified in Git.") commit() with open("foo/newsfragments/125.feature", "w") as f: f.write("Baz levitation. Staged file.") with open("foo/newsfragments/126.feature", "w") as f: f.write("Fix (literal) crash. File unknown to Git.") with open("foo/newsfragments/124.feature", "a") as f: f.write(" Extended for an hour.") call(["git", "add", "foo/newsfragments/125.feature"]) result = runner.invoke(_main, ["--date", "01-01-2001", "--yes"]) self.assertEqual(0, result.exit_code) for fragment in ("123", "124", "125", "126"): self.assertFalse(os.path.isfile(f"foo/newsfragments/{fragment}.feature")) path = "NEWS.rst" self.assertTrue(os.path.isfile(path)) news_contents = open(path).read() self.assertEqual( news_contents, dedent( """\ Foo 1.2.3 (01-01-2001) ====================== Features -------- - Adds levitation. File committed. (#123) - Extends levitation. File modified in Git. Extended for an hour. (#124) - Baz levitation. Staged file. (#125) - Fix (literal) crash. File unknown to Git. (#126) """ ), ) @with_project( config=""" [tool.towncrier] package = "foo" ignore = ["template.jinja", "CAPYBARAS.md", "seq_wildcard_[ab]"] """ ) def test_ignored_files(self, runner): """ When `ignore` is set in config, files with those names are ignored. Configuration supports wildcard matching with `fnmatch`. """ with open("foo/newsfragments/123.feature", "w") as f: f.write("This has valid filename (control case)") with open("foo/newsfragments/template.jinja", "w") as f: f.write("This template has been manually ignored") with open("foo/newsfragments/CAPYBARAS.md", "w") as f: f.write("This markdown file has been manually ignored") with open("foo/newsfragments/.gitignore", "w") as f: f.write("gitignore is automatically ignored") with open("foo/newsfragments/seq_wildcard_a", "w") as f: f.write("Manually ignored with [] wildcard") result = runner.invoke(_main, ["--draft"]) self.assertEqual(0, result.exit_code, result.output) @with_project( config=""" [tool.towncrier] package = "foo" ignore = [] """ ) def test_invalid_fragment_name(self, runner): """ When `ignore` is set in config, invalid filenames cause failure. """ with open("foo/newsfragments/123.feature", "w") as f: f.write("This has valid filename (control case)") with open("foo/newsfragments/feature.124", "w") as f: f.write("This has the issue and category the wrong way round") result = runner.invoke(_main, ["--draft"]) self.assertEqual(1, result.exit_code, result.output) self.assertIn("Invalid news fragment name: feature.124", result.output) @with_project( config=""" [tool.towncrier] package = "foo" template = "foo/newsfragments/template.j2" ignore = ["placeholder-to-trigger-strict-checks.txt"] """ ) def test_ignore_template_filename(self, runner): """ The `template` filename is automatically ignored when it is stored in the same path as the newsfragment files. """ with open("foo/newsfragments/123.feature", "w") as f: f.write("Brand new thing.") with open("foo/newsfragments/template.j2", "w") as f: # Just a simple template to check that the file is rendered. f.write( """ {% for section, _ in sections.items() %} {% for category, val in definitions.items() if category in sections[section]%} {{ definitions[category]['name'] }} {% for text, values in sections[section][category].items() %} - TEST {{ text }} {% endfor %} {% endfor %} {% endfor %} """ ) result = runner.invoke(_main, ["--draft"]) self.assertEqual(0, result.exit_code, result.output) self.assertIn("- TEST Brand new thing.\n", result.output) @with_project() def test_no_ignore_configured(self, runner): """ When `ignore` is not set in config, invalid filenames are skipped. This maintains backward compatibility with before we added `ignore` to the configuration spec. """ with open("foo/newsfragments/feature.124", "w") as f: f.write("This has the issue and category the wrong way round") result = runner.invoke( _main, ["--draft", "--date", "01-01-2001", "--version", "1.0.0"] ) self.assertEqual(0, result.exit_code, result.output) towncrier-24.8.0/src/towncrier/test/test_builder.py0000644000000000000000000001550313615410400017366 0ustar00# Copyright (c) Povilas Kanapickas, 2019 # See LICENSE for details. from textwrap import dedent from twisted.trial.unittest import TestCase from .._builder import parse_newfragment_basename, render_fragments class TestParseNewsfragmentBasename(TestCase): def test_simple(self): """. generates a counter value of 0.""" self.assertEqual( parse_newfragment_basename("123.feature", ["feature"]), ("123", "feature", 0), ) def test_invalid_category(self): """Files without a valid category are rejected.""" self.assertEqual( parse_newfragment_basename("README.ext", ["feature"]), (None, None, None), ) self.assertEqual( parse_newfragment_basename("README", ["feature"]), (None, None, None), ) def test_counter(self): """.. generates a custom counter value.""" self.assertEqual( parse_newfragment_basename("123.feature.1", ["feature"]), ("123", "feature", 1), ) def test_counter_with_extension(self): """File extensions are ignored.""" self.assertEqual( parse_newfragment_basename("123.feature.1.ext", ["feature"]), ("123", "feature", 1), ) def test_ignores_extension(self): """File extensions are ignored.""" self.assertEqual( parse_newfragment_basename("123.feature.ext", ["feature"]), ("123", "feature", 0), ) def test_non_numeric_issue(self): """Non-numeric issue identifiers are preserved verbatim.""" self.assertEqual( parse_newfragment_basename("baz.feature", ["feature"]), ("baz", "feature", 0), ) def test_non_numeric_issue_with_extension(self): """File extensions are ignored.""" self.assertEqual( parse_newfragment_basename("baz.feature.ext", ["feature"]), ("baz", "feature", 0), ) def test_dots_in_issue_name(self): """Non-numeric issue identifiers are preserved verbatim.""" self.assertEqual( parse_newfragment_basename("baz.1.2.feature", ["feature"]), ("baz.1.2", "feature", 0), ) def test_dots_in_issue_name_invalid_category(self): """Files without a valid category are rejected.""" self.assertEqual( parse_newfragment_basename("baz.1.2.notfeature", ["feature"]), (None, None, None), ) def test_dots_in_issue_name_and_counter(self): """Non-numeric issue identifiers are preserved verbatim.""" self.assertEqual( parse_newfragment_basename("baz.1.2.feature.3", ["feature"]), ("baz.1.2", "feature", 3), ) def test_strip(self): """Leading spaces and subsequent leading zeros are stripped when parsing newsfragment names into issue numbers etc. """ self.assertEqual( parse_newfragment_basename(" 007.feature", ["feature"]), ("7", "feature", 0), ) def test_strip_with_counter(self): """Leading spaces and subsequent leading zeros are stripped when parsing newsfragment names into issue numbers etc. """ self.assertEqual( parse_newfragment_basename(" 007.feature.3", ["feature"]), ("7", "feature", 3), ) def test_orphan(self): """Orphaned snippets must remain the orphan marker in the issue identifier.""" self.assertEqual( parse_newfragment_basename("+orphan.feature", ["feature"]), ("+orphan", "feature", 0), ) def test_orphan_with_number(self): """Orphaned snippets can contain numbers in the identifier.""" self.assertEqual( parse_newfragment_basename("+123_orphan.feature", ["feature"]), ("+123_orphan", "feature", 0), ) self.assertEqual( parse_newfragment_basename("+orphan_123.feature", ["feature"]), ("+orphan_123", "feature", 0), ) def test_orphan_with_dotted_number(self): """Orphaned snippets can contain numbers with dots in the identifier.""" self.assertEqual( parse_newfragment_basename("+12.3_orphan.feature", ["feature"]), ("+12.3_orphan", "feature", 0), ) self.assertEqual( parse_newfragment_basename("+orphan_12.3.feature", ["feature"]), ("+orphan_12.3", "feature", 0), ) def test_orphan_all_digits(self): """Orphaned snippets can consist of only digits.""" self.assertEqual( parse_newfragment_basename("+123.feature", ["feature"]), ("+123", "feature", 0), ) class TestNewsFragmentsOrdering(TestCase): """ Tests to ensure that issues are ordered correctly in the output. This tests both ordering of issues within a fragment and ordering of fragments within a section. """ template = dedent( """ {% for section_name, category in sections.items() %} {% if section_name %}# {{ section_name }}{% endif %} {%- for category_name, issues in category.items() %} ## {{ category_name }} {% for issue, numbers in issues.items() %} - {{ issue }}{% if numbers %} ({{ numbers|join(', ') }}){% endif %} {% endfor %} {% endfor -%} {% endfor -%} """ ) def render(self, fragments): return render_fragments( template=self.template, issue_format=None, fragments=fragments, definitions={}, underlines=[], wrap=False, versiondata={}, ) def test_ordering(self): """ Issues are ordered first by the non-text component, then by their number. For backwards compatibility, issues with no number are grouped first and issues which are only a number are grouped last. Orphan news fragments are always last, sorted by their text. """ output = self.render( { "": { "feature": { "Added Cheese": ["10", "gh-25", "gh-3", "4"], "Added Fish": [], "Added Bread": [], "Added Milk": ["gh-1"], "Added Eggs": ["gh-2", "random"], } } }, ) # "Eggs" are first because they have an issue with no number, and the first # issue for each fragment is what is used for sorting the overall list. assert output == dedent( """ ## feature - Added Eggs (random, gh-2) - Added Milk (gh-1) - Added Cheese (gh-3, gh-25, #4, #10) - Added Bread - Added Fish """ ) towncrier-24.8.0/src/towncrier/test/test_check.py0000644000000000000000000005053513615410400017021 0ustar00# Copyright (c) Amber Brown, 2017 # See LICENSE for details. import os import os.path import warnings from pathlib import Path from subprocess import call from click.testing import CliRunner from twisted.trial.unittest import TestCase from towncrier import check from towncrier.build import _main as towncrier_build 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_fragment_exists_but_not_in_check(self): """A fragment that exists but is marked as check=False is ignored by the check.""" runner = CliRunner() with runner.isolated_filesystem(): create_project( "pyproject.toml", main_branch="master", extra_config="[[tool.towncrier.type]]\n" 'directory = "feature"\n' 'name = "Features"\n' "showcontent = true\n" "[[tool.towncrier.type]]\n" 'directory = "sut"\n' 'name = "System Under Test"\n' "showcontent = true\n" "check=false\n", ) file_path = "foo/somefile.py" write(file_path, "import os") fragment_path = Path("foo/newsfragments/1234.sut").absolute() write(fragment_path, "Adds gravity back") commit("add a file and a newsfragment") result = runner.invoke(towncrier_check, ["--compare-with", "master"]) self.assertEqual(1, result.exit_code) self.assertTrue( result.output.endswith( "Found newsfragments of unchecked types in the branch:\n1. " + str(fragment_path) + "\n" ), (result.output, str(fragment_path)), ) def test_fragment_exists_and_in_check(self): """ A fragment that exists but is not marked as check=False is not ignored by the check, even if other categories are marked as check=False. """ runner = CliRunner() with runner.isolated_filesystem(): create_project( "pyproject.toml", main_branch="master", extra_config="[[tool.towncrier.type]]\n" 'directory = "feature"\n' 'name = "Features"\n' "showcontent = true\n" "[[tool.towncrier.type]]\n" 'directory = "sut"\n' 'name = "System Under Test"\n' "showcontent = true\n" "check=false\n", ) file_path = "foo/somefile.py" write(file_path, "import os") fragment_path = Path("foo/newsfragments/1234.feature").absolute() write(fragment_path, "Adds gravity back") commit("add a file and a newsfragment") result = runner.invoke(towncrier_check, ["--compare-with", "master"]) self.assertEqual(0, result.exit_code) self.assertTrue( result.output.endswith("Found:\n1. " + str(fragment_path) + "\n"), (result.output, str(fragment_path)), ) 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"]) runner = CliRunner(mix_stderr=False) result = runner.invoke(towncrier_check, ["--compare-with", "master"]) self.assertEqual(0, result.exit_code) self.assertEqual(0, len(result.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(".")) runner.invoke(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. runner.invoke(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"]) runner.invoke(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')) @with_isolated_runner def test_in_different_dir_with_nondefault_newsfragments_directory(self, runner): """ It can check the fragments located in a sub-directory that is specified using the `--dir` CLI argument. """ main_branch = "main" Path("pyproject.toml").write_text( # Important to customize `config.directory` because the default # already supports this scenario. "[tool.towncrier]\n" + 'directory = "changelog.d"\n' ) subproject1 = Path("foo") (subproject1 / "foo").mkdir(parents=True) (subproject1 / "foo/__init__.py").write_text("") (subproject1 / "changelog.d").mkdir(parents=True) (subproject1 / "changelog.d/123.feature").write_text("Adds levitation") initial_commit(branch=main_branch) call(["git", "checkout", "-b", "otherbranch"]) # We add a code change but forget to add a news fragment. write(subproject1 / "foo/somefile.py", "import os") commit("add a file") result = runner.invoke( towncrier_check, ( "--config", "pyproject.toml", "--dir", str(subproject1), "--compare-with", "main", ), ) self.assertEqual(1, result.exit_code) self.assertTrue( result.output.endswith("No new newsfragments found on this branch.\n") ) # We add the news fragment. fragment_path = (subproject1 / "changelog.d/124.feature").absolute() write(fragment_path, "Adds gravity back") commit("add a newsfragment") result = runner.invoke( towncrier_check, ("--config", "pyproject.toml", "--dir", "foo", "--compare-with", "main"), ) self.assertEqual(0, result.exit_code, result.output) self.assertTrue( result.output.endswith("Found:\n1. " + str(fragment_path) + "\n"), (result.output, str(fragment_path)), ) # We add a change in a different subproject without a news fragment. # Checking subproject1 should pass. subproject2 = Path("bar") (subproject2 / "bar").mkdir(parents=True) (subproject2 / "changelog.d").mkdir(parents=True) write(subproject2 / "bar/somefile.py", "import os") commit("add a file") result = runner.invoke( towncrier_check, ( "--config", "pyproject.toml", "--dir", subproject1, "--compare-with", "main", ), ) self.assertEqual(0, result.exit_code, result.output) self.assertTrue( result.output.endswith("Found:\n1. " + str(fragment_path) + "\n"), (result.output, str(fragment_path)), ) # Checking subproject2 should result in an error. result = runner.invoke( towncrier_check, ( "--config", "pyproject.toml", "--dir", subproject2, "--compare-with", "main", ), ) self.assertEqual(1, result.exit_code) self.assertTrue( result.output.endswith("No new newsfragments found on this branch.\n") ) @with_isolated_runner def test_ignored_files(self, runner): """ When `ignore` is set in config, files with those names are ignored. Configuration supports wildcard matching with `fnmatch`. """ create_project( "pyproject.toml", extra_config='ignore = ["template.jinja", "star_wildcard*"]', ) write( "foo/newsfragments/124.feature", "This fragment has valid name (control case)", ) write("foo/newsfragments/template.jinja", "This is manually ignored") write("foo/newsfragments/.gitignore", "gitignore is automatically ignored") write("foo/newsfragments/star_wildcard_foo", "Manually ignored with * wildcard") commit("add stuff") result = runner.invoke(towncrier_check, ["--compare-with", "main"]) self.assertEqual(0, result.exit_code, result.output) @with_isolated_runner def test_invalid_fragment_name(self, runner): """ Fails if a news fragment has an invalid name, even if `ignore` is not set in the config. """ create_project("pyproject.toml") write( "foo/newsfragments/124.feature", "This fragment has valid name (control case)", ) write( "foo/newsfragments/feature.125", "This has issue and category wrong way round", ) write( "NEWS.rst", "Modification of news file should not skip check of invalid names", ) commit("add stuff") result = runner.invoke(towncrier_check, ["--compare-with", "main"]) self.assertEqual(1, result.exit_code, result.output) self.assertIn("Invalid news fragment name: feature.125", result.output) @with_isolated_runner def test_issue_pattern(self, runner): """ Fails if an issue name goes against the configured pattern. """ create_project( "pyproject.toml", extra_config='issue_pattern = "\\\\d+"', ) write( "foo/newsfragments/123.feature", "This fragment has a valid name", ) write( "foo/newsfragments/+abcdefg.feature", "This fragment has a valid name (orphan fragment)", ) commit("add stuff") result = runner.invoke(towncrier_check, ["--compare-with", "main"]) self.assertEqual(0, result.exit_code, result.output) @with_isolated_runner def test_issue_pattern_invalid_with_suffix(self, runner): """ Fails if an issue name goes against the configured pattern. """ create_project( "pyproject.toml", extra_config='issue_pattern = "\\\\d+"', ) write( "foo/newsfragments/AAA.BBB.feature.md", "This fragment has an invalid name (should be digits only)", ) commit("add stuff") result = runner.invoke(towncrier_check, ["--compare-with", "main"]) self.assertEqual(1, result.exit_code, result.output) self.assertIn( "Error: Issue name 'AAA.BBB' does not match the configured pattern, '\\d+'", result.output, ) @with_isolated_runner def test_issue_pattern_invalid(self, runner): """ Fails if an issue name goes against the configured pattern. """ create_project( "pyproject.toml", extra_config='issue_pattern = "\\\\d+"', ) write( "foo/newsfragments/AAA.BBB.feature", "This fragment has an invalid name (should be digits only)", ) commit("add stuff") result = runner.invoke(towncrier_check, ["--compare-with", "main"]) self.assertEqual(1, result.exit_code, result.output) self.assertIn( "Error: Issue name 'AAA.BBB' does not match the configured pattern, '\\d+'", result.output, ) towncrier-24.8.0/src/towncrier/test/test_create.py0000644000000000000000000005670213615410400017211 0ustar00# Copyright (c) Amber Brown, 2015 # See LICENSE for details. import os import string from pathlib import Path from textwrap import dedent from unittest import mock from click.testing import CliRunner from twisted.trial.unittest import TestCase from ..create import DEFAULT_CONTENT, _main from .helpers import setup_simple_project, with_isolated_runner class TestCli(TestCase): maxDiff = None def _test_success( self, content=None, config=None, mkdir=True, additional_args=None, eof_newline=True, ): runner = CliRunner() with runner.isolated_filesystem(): setup_simple_project(config=config, mkdir_newsfragments=mkdir) args = ["123.feature.rst"] if content is None: content = [DEFAULT_CONTENT] if additional_args is not None: args.extend(additional_args) result = runner.invoke(_main, args) self.assertEqual(["123.feature.rst"], os.listdir("foo/newsfragments")) if eof_newline: content.append("") with open("foo/newsfragments/123.feature.rst") as fh: self.assertEqual("\n".join(content), fh.read()) 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", "This is line 2"] with mock.patch("click.edit") as mock_edit: mock_edit.return_value = "\n".join(content) self._test_success(content=content, additional_args=["--edit"]) mock_edit.assert_called_once_with( "\n# Please write your news content. Lines starting " "with '#' will be ignored, and\n# an empty message aborts.\n", extension=".rst", ) def test_edit_with_comment(self): """Create file editly with ignored line.""" content = ["This is line 1", "This is line 2"] comment = "# I am ignored" with mock.patch("click.edit") as mock_edit: mock_edit.return_value = "\n".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_edit_markdown_extension(self): """ The temporary file extension used when editing is ``.md`` if the main filename also uses that extension. """ with mock.patch("click.edit") as mock_edit: mock_edit.return_value = "This is line 1" self._test_success( content=["This is line 1"], config=dedent( """\ [tool.towncrier] package = "foo" filename = "README.md" """ ), additional_args=["--edit"], ) mock_edit.assert_called_once_with( "\n# Please write your news content. Lines starting " "with '#' will be ignored, and\n# an empty message aborts.\n", extension=".md", ) def test_edit_unknown_extension(self): """ The temporary file extension used when editing is ``.txt`` if it the main filename isn't ``.rst`` or ``.md``. """ with mock.patch("click.edit") as mock_edit: mock_edit.return_value = "This is line 1" self._test_success( content=["This is line 1"], config=dedent( """\ [tool.towncrier] package = "foo" filename = "README.FIRST" """ ), additional_args=["--edit"], ) mock_edit.assert_called_once_with( "\n# Please write your news content. Lines starting " "with '#' will be ignored, and\n# an empty message aborts.\n", extension=".txt", ) 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_content_without_eof_newline(self): """ When creating a new fragment the content can be passed as a command line argument. The text editor is not invoked, and no eof newline is added if the config option is set. """ config = dedent( """\ [tool.towncrier] package = "foo" create_eof_newline = false """ ) content_line = "This is a content" self._test_success( content=[content_line], additional_args=["-c", content_line], config=config, eof_newline=False, ) 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", "This is line 2"] with mock.patch("click.edit") as mock_edit: mock_edit.return_value = "\n".join(edit_content) self._test_success( content=edit_content, additional_args=["-c", content_line, "--edit"] ) mock_edit.assert_called_once_with( f"{content_line}\n\n# Please write your news content. Lines starting " "with '#' will be ignored, and\n# an empty message aborts.\n", extension=".rst", ) 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 ) @with_isolated_runner def test_custom_extension(self, runner: CliRunner): """Ensure we can still create fragments with custom extensions.""" setup_simple_project() frag_path = Path("foo", "newsfragments") result = runner.invoke(_main, ["123.feature.txt"]) self.assertEqual(result.exit_code, 0, result.output) fragments = [f.name for f in frag_path.iterdir()] # No '.rst' extension added. self.assertEqual(fragments, ["123.feature.txt"]) @with_isolated_runner def test_md_filename_extension(self, runner: CliRunner): """Ensure changelog filename extension is used if .md""" setup_simple_project(extra_config='filename = "changes.md"') frag_path = Path("foo", "newsfragments") result = runner.invoke(_main, ["123.feature"]) self.assertEqual(result.exit_code, 0, result.output) fragments = [f.name for f in frag_path.iterdir()] # No '.rst' extension added. self.assertEqual(fragments, ["123.feature.md"]) @with_isolated_runner def test_no_filename_extension(self, runner: CliRunner): """ When the NEWS filename has no extension, new fragments are will not have an extension added. """ # The name of the file where towncrier will generate # the final release notes is named `RELEASE_NOTES` # for this test (with no file extension). setup_simple_project(extra_config='filename = "RELEASE_NOTES"') frag_path = Path("foo", "newsfragments") result = runner.invoke(_main, ["123.feature"]) self.assertEqual(result.exit_code, 0, result.output) fragments = [f.name for f in frag_path.iterdir()] # No '.rst' extension added. self.assertEqual(fragments, ["123.feature"]) @with_isolated_runner def test_file_exists(self, runner: CliRunner): """Ensure we don't overwrite existing files.""" setup_simple_project() frag_path = Path("foo", "newsfragments") for _ in range(3): result = runner.invoke(_main, ["123.feature"]) self.assertEqual(result.exit_code, 0, result.output) fragments = [f.name for f in frag_path.iterdir()] self.assertEqual( sorted(fragments), [ "123.feature.1.rst", "123.feature.2.rst", "123.feature.rst", ], ) @with_isolated_runner def test_file_exists_no_ext(self, runner: CliRunner): """ Ensure we don't overwrite existing files with when not adding filename extensions. """ setup_simple_project(extra_config="create_add_extension = false") frag_path = Path("foo", "newsfragments") for _ in range(3): result = runner.invoke(_main, ["123.feature"]) self.assertEqual(result.exit_code, 0, result.output) fragments = [f.name for f in frag_path.iterdir()] self.assertEqual( sorted(fragments), [ "123.feature", "123.feature.1", "123.feature.2", ], ) @with_isolated_runner def test_file_exists_with_ext(self, runner: CliRunner): """ Ensure we don't overwrite existing files when using an extension after the fragment type. """ setup_simple_project() frag_path = Path("foo", "newsfragments") for _ in range(3): result = runner.invoke(_main, ["123.feature.rst"]) self.assertEqual(result.exit_code, 0, result.output) fragments = [f.name for f in frag_path.iterdir()] self.assertEqual( sorted(fragments), [ "123.feature.1.rst", "123.feature.2.rst", "123.feature.rst", ], ) @with_isolated_runner def test_without_filename(self, runner: CliRunner): """ When no filename is provided, the user is prompted for one. """ setup_simple_project() with mock.patch("click.edit") as mock_edit: mock_edit.return_value = "Edited content" result = runner.invoke(_main, input="123\nfeature\n") self.assertFalse(result.exception, result.output) mock_edit.assert_called_once() expected = os.path.join(os.getcwd(), "foo", "newsfragments", "123.feature.rst") self.assertEqual( result.output, f"""Issue number (`+` if none): 123 Fragment type (feature, bugfix, doc, removal, misc): feature Created news fragment at {expected} """, ) with open(expected) as f: self.assertEqual(f.read(), "Edited content\n") @with_isolated_runner def test_without_filename_orphan(self, runner: CliRunner): """ The user can create an orphan fragment from the interactive prompt. """ setup_simple_project() with mock.patch("click.edit") as mock_edit: mock_edit.return_value = "Orphan content" result = runner.invoke(_main, input="+\nfeature\n") self.assertFalse(result.exception, result.output) mock_edit.assert_called_once() expected = os.path.join(os.getcwd(), "foo", "newsfragments", "+") self.assertTrue( result.output.startswith( f"""Issue number (`+` if none): + Fragment type (feature, bugfix, doc, removal, misc): feature Created news fragment at {expected}""" ), result.output, ) # Check that the file was created with a random name created_line = result.output.strip().rsplit("\n", 1)[-1] # Get file names in the newsfragments directory. files = os.listdir(os.path.join(os.getcwd(), "foo", "newsfragments")) # Check that the file name is in the created line. created_fragment = created_line.split(" ")[-1] self.assertIn(Path(created_fragment).name, files) with open(created_fragment) as f: self.assertEqual(f.read(), "Orphan content\n") @with_isolated_runner def test_without_filename_no_orphan_config(self, runner: CliRunner): """ If an empty orphan prefix is set, orphan creation is turned off from interactive prompt. """ setup_simple_project(extra_config='orphan_prefix = ""') with mock.patch("click.edit") as mock_edit: mock_edit.return_value = "Edited content" result = runner.invoke(_main, input="+\nfeature\n") self.assertFalse(result.exception, result.output) mock_edit.assert_called_once() expected = os.path.join(os.getcwd(), "foo", "newsfragments", "+.feature.rst") self.assertEqual( result.output, f"""Issue number: + Fragment type (feature, bugfix, doc, removal, misc): feature Created news fragment at {expected} """, ) with open(expected) as f: self.assertEqual(f.read(), "Edited content\n") @with_isolated_runner def test_sections(self, runner: CliRunner): """ When creating a new fragment, the user can specify the section from the command line (and if none is provided, the default section will be used). The default section is either the section with a blank path, or else the first section defined in the configuration file. """ setup_simple_project( extra_config=""" [[tool.towncrier.section]] name = "Backend" path = "backend" [[tool.towncrier.section]] name = "Frontend" path = "" """ ) result = runner.invoke(_main, ["123.feature.rst"]) self.assertFalse(result.exception, result.output) frag_path = Path("foo", "newsfragments") fragments = [f.name for f in frag_path.iterdir()] self.assertEqual(fragments, ["123.feature.rst"]) result = runner.invoke(_main, ["123.feature.rst", "--section", "invalid"]) self.assertTrue(result.exception, result.output) self.assertIn( "Invalid value for '--section': expected one of 'Backend', 'Frontend'", result.output, ) result = runner.invoke(_main, ["123.feature.rst", "--section", "Backend"]) self.assertFalse(result.exception, result.output) frag_path = Path("foo", "backend", "newsfragments") fragments = [f.name for f in frag_path.iterdir()] self.assertEqual(fragments, ["123.feature.rst"]) @with_isolated_runner def test_sections_without_filename(self, runner: CliRunner): """ When multiple sections exist when the interactive prompt is used, the user is prompted to select a section. """ setup_simple_project( extra_config=""" [[tool.towncrier.section]] name = "Backend" path = "" [[tool.towncrier.section]] name = "Frontend" path = "frontend" """ ) with mock.patch("click.edit") as mock_edit: mock_edit.return_value = "Edited content" result = runner.invoke(_main, input="2\n123\nfeature\n") self.assertFalse(result.exception, result.output) mock_edit.assert_called_once() expected = os.path.join( os.getcwd(), "foo", "frontend", "newsfragments", "123.feature.rst" ) self.assertEqual( result.output, f"""\ Pick a section: 1: Backend 2: Frontend Section (1, 2) [1]: 2 Issue number (`+` if none): 123 Fragment type (feature, bugfix, doc, removal, misc): feature Created news fragment at {expected} """, ) @with_isolated_runner def test_sections_without_filename_with_section_option(self, runner: CliRunner): """ When multiple sections exist and the section is provided via the command line, the user isn't prompted to select a section. """ setup_simple_project( extra_config=""" [[tool.towncrier.section]] name = "Backend" path = "" [[tool.towncrier.section]] name = "Frontend" path = "frontend" """ ) with mock.patch("click.edit") as mock_edit: mock_edit.return_value = "Edited content" result = runner.invoke( _main, ["--section", "Frontend"], input="123\nfeature\n" ) self.assertFalse(result.exception, result.output) mock_edit.assert_called_once() expected = os.path.join( os.getcwd(), "foo", "frontend", "newsfragments", "123.feature.rst" ) self.assertEqual( result.output, f"""\ Issue number (`+` if none): 123 Fragment type (feature, bugfix, doc, removal, misc): feature Created news fragment at {expected} """, ) @with_isolated_runner def test_sections_all_with_paths(self, runner: CliRunner): """ When all sections have paths, the first is the default. """ setup_simple_project( extra_config=""" [[tool.towncrier.section]] name = "Frontend" path = "frontend" [[tool.towncrier.section]] name = "Backend" path = "backend" """ ) result = runner.invoke(_main, ["123.feature.rst"]) self.assertFalse(result.exception, result.output) frag_path = Path("foo", "frontend", "newsfragments") fragments = [f.name for f in frag_path.iterdir()] self.assertEqual(fragments, ["123.feature.rst"]) @with_isolated_runner def test_without_filename_with_message(self, runner: CliRunner): """ When no filename is provided, the user is prompted for one. If a message is provided, the editor isn't opened and the message is used. """ setup_simple_project() with mock.patch("click.edit") as mock_edit: result = runner.invoke(_main, ["-c", "Fixed this"], input="123\nfeature\n") self.assertFalse(result.exception, result.output) mock_edit.assert_not_called() expected = os.path.join(os.getcwd(), "foo", "newsfragments", "123.feature.rst") self.assertEqual( result.output, f"""Issue number (`+` if none): 123 Fragment type (feature, bugfix, doc, removal, misc): feature Created news fragment at {expected} """, ) with open(expected) as f: self.assertEqual(f.read(), "Fixed this\n") @with_isolated_runner def test_create_orphan_fragment(self, runner: CliRunner): """ 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. """ setup_simple_project() frag_path = Path("foo", "newsfragments") sub_frag_path = frag_path / "subsection" sub_frag_path.mkdir() result = runner.invoke(_main, ["+.feature"]) self.assertEqual(0, result.exit_code) result = runner.invoke( _main, [str(Path("subsection", "+.feature"))], catch_exceptions=False ) self.assertEqual(0, result.exit_code, result.output) fragments = [p for p in frag_path.rglob("*") if p.is_file()] self.assertEqual(2, len(fragments)) change1, change2 = fragments self.assertEqual(change1.suffix, ".rst") self.assertTrue(change1.stem.startswith("+")) self.assertTrue(change1.stem.endswith(".feature")) # Length should be '+' character, 8 random hex characters, and ".feature". self.assertEqual(len(change1.stem), 1 + 8 + len(".feature")) self.assertEqual(change2.suffix, ".rst") self.assertTrue(change2.stem.startswith("+")) self.assertTrue(change2.stem.endswith(".feature")) self.assertEqual(change2.parent, sub_frag_path) # Length should be '+' character, 8 random hex characters, and ".feature". self.assertEqual(len(change2.stem), 1 + 8 + len(".feature")) @with_isolated_runner def test_create_orphan_fragment_custom_prefix(self, runner: CliRunner): """ Check that the orphan prefix can be customized. """ setup_simple_project(extra_config='orphan_prefix = "$$$"') frag_path = Path("foo", "newsfragments") result = runner.invoke(_main, ["$$$.feature"]) self.assertEqual(0, result.exit_code, result.output) fragments = list(frag_path.rglob("*")) self.assertEqual(len(fragments), 1) change = fragments[0] self.assertTrue(change.stem.startswith("$$$")) # Length should be '$$$' characters, 8 random hex characters, and ".feature". self.assertEqual(len(change.stem), 3 + 8 + len(".feature")) # Check the remainder are all hex characters. self.assertTrue( all(c in string.hexdigits for c in change.stem[3 : -len(".feature")]) ) @with_isolated_runner def test_in_different_dir_with_nondefault_newsfragments_directory(self, runner): """ When the `--dir` CLI argument is passed, it will create a new file in directory that is created by combining the `--dir` value with the `directory` option from the configuration file. """ Path("pyproject.toml").write_text( # Important to customize `config.directory` because the default # already supports this scenario. "[tool.towncrier]\n" + 'directory = "changelog.d"\n' ) Path("foo/foo").mkdir(parents=True) Path("foo/foo/__init__.py").write_text("") result = runner.invoke( _main, ( "--config", "pyproject.toml", "--dir", "foo", "--content", "Adds levitation.", "123.feature", ), ) self.assertEqual(0, result.exit_code) self.assertTrue(Path("foo/changelog.d/123.feature.rst").exists()) towncrier-24.8.0/src/towncrier/test/test_format.py0000644000000000000000000002671113615410400017233 0ustar00# Copyright (c) Amber Brown, 2015 # See LICENSE for details. from twisted.trial.unittest import TestCase from .._builder import render_fragments, split_fragments from .helpers import read_pkg_resource class FormatterTests(TestCase): maxDiff = None 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 = { "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 = { "": { # 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 = { "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 = read_pkg_resource("templates/default.rst") 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_markdown(self): """ Check formating of default markdown template. """ 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): "", ("4", "feature", 0): "Stuff!", ("2", "feature", 0): "Foo added.", ("72", "feature", 0): "Foo added.", ("9", "feature", 0): "Foo added.", ("3", "feature", 0): "Multi-line\nhere", ("baz", "feature", 0): "Fun!", }, "Names": {}, "Web": { ("3", "bugfix", 0): "Web fixed.", ("2", "bugfix", 0): "Multi-line bulleted\n- fix\n- here", }, } definitions = { "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) - Multi-line here (#3) - Stuff! (#4) ### Misc - bar, #1, #9, #142 ## Names No significant changes. ## Web ### Bugfixes - Multi-line bulleted - fix - here (#2) - Web fixed. (#3) """ template = read_pkg_resource("templates/default.md") 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) # Also test with custom issue format expected_output = """# MyProject 1.0 (never) ### Features - Fun! ([baz]) - Foo added. ([2], [9], [72]) - Multi-line here ([3]) - Stuff! ([4]) [baz]: https://github.com/twisted/towncrier/issues/baz [2]: https://github.com/twisted/towncrier/issues/2 [3]: https://github.com/twisted/towncrier/issues/3 [4]: https://github.com/twisted/towncrier/issues/4 [9]: https://github.com/twisted/towncrier/issues/9 [72]: https://github.com/twisted/towncrier/issues/72 ### Misc - [bar], [1], [9], [142] [bar]: https://github.com/twisted/towncrier/issues/bar [1]: https://github.com/twisted/towncrier/issues/1 [9]: https://github.com/twisted/towncrier/issues/9 [142]: https://github.com/twisted/towncrier/issues/142 ## Names No significant changes. ## Web ### Bugfixes - Multi-line bulleted - fix - here ([2]) - Web fixed. ([3]) [2]: https://github.com/twisted/towncrier/issues/2 [3]: https://github.com/twisted/towncrier/issues/3 """ output = render_fragments( template, "[{issue}]: https://github.com/twisted/towncrier/issues/{issue}", fragments, definitions, ["-", "~"], wrap=True, versiondata={"name": "MyProject", "version": "1.0", "date": "never"}, ) self.assertEqual(output, expected_output) 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 = {"misc": {"name": "Misc", "showcontent": False}} expected_output = """MyProject 1.0 (never) ===================== Misc ---- - xxbar, xx1, xx9, xx142 """ template = read_pkg_resource("templates/default.rst") 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 = {"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 = read_pkg_resource("templates/default.rst") 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 = {"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 = read_pkg_resource("templates/default.rst") 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) towncrier-24.8.0/src/towncrier/test/test_git.py0000644000000000000000000000047413615410400016524 0ustar00# Copyright (c) Amber Brown, 2015 # See LICENSE for details. from twisted.trial.unittest import TestCase from towncrier import _git class TestGit(TestCase): def test_empty_remove(self): """ If remove_files gets an empty list, it returns gracefully. """ _git.remove_files([]) towncrier-24.8.0/src/towncrier/test/test_project.py0000644000000000000000000001734513615410400017414 0ustar00# Copyright (c) Amber Brown, 2015 # See LICENSE for details. import os import sys from importlib.metadata import version as metadata_version from click.testing import CliRunner from twisted.trial.unittest import TestCase from .._project import get_project_name, get_version from .._shell import cli as towncrier_cli from .helpers import write towncrier_cli.name = "towncrier" class VersionFetchingTests(TestCase): def test_str(self): """ A str __version__ will be picked up. """ temp = self.mktemp() 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(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") def test_incremental(self): """ An incremental-like Version __version__ is picked up. """ temp = self.mktemp() os.makedirs(temp) os.makedirs(os.path.join(temp, "mytestprojinc")) with open(os.path.join(temp, "mytestprojinc", "__init__.py"), "w") as f: f.write( """ class Version: ''' This is emulating a Version object from incremental. ''' def __init__(self, *version_parts): self.version = version_parts self.package = "mytestprojinc" def base(self): return '.'.join(map(str, self.version)) __version__ = Version(1, 3, 12, "rc1") """ ) version = get_version(temp, "mytestprojinc") self.assertEqual(version, "1.3.12rc1") project = get_project_name(temp, "mytestprojinc") self.assertEqual(project, "mytestprojinc") def test_not_incremental(self): """ An exception is raised when the version could not be detected. For this test we use an incremental-like object, that has the `base` method, but that method does not match the return type for `incremental`. """ temp = self.mktemp() os.makedirs(os.path.join(temp, "mytestprojnotinc")) with open(os.path.join(temp, "mytestprojnotinc", "__init__.py"), "w") as f: f.write( """ class WeirdVersion: def base(self, some_arg): return "shouldn't get here" __version__ = WeirdVersion() """ ) with self.assertRaises(Exception) as e: get_version(temp, "mytestprojnotinc") self.assertEqual( ( "Version must be a string, tuple, or an Incremental Version. " "If you can't provide that, use the --version argument and " "specify one.", ), e.exception.args, ) def test_version_from_metadata(self): """ A version from package metadata is picked up. """ version = get_version(".", "towncrier") self.assertEqual(metadata_version("towncrier"), version) 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: # The 'missing' package has no __version__ string. get_version(tmp_dir, "missing") self.assertEqual( ("No __version__ or metadata version info for the 'missing' package.",), 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(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.assertEqual("Mytestprojb", 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(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(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(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. """ runner = CliRunner() 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") result = runner.invoke(towncrier_cli, ["--help"]) self.assertIn("[OPTIONS] COMMAND [ARGS]...", result.stdout) self.assertRegex(result.stdout, r".*--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. """ runner = CliRunner() result = runner.invoke(towncrier_cli, ["--version"]) self.assertTrue(result.output.startswith("towncrier, version 2")) towncrier-24.8.0/src/towncrier/test/test_settings.py0000644000000000000000000002555413615410400017607 0ustar00# Copyright (c) Amber Brown, 2015 # See LICENSE for details. import os from click.testing import CliRunner from twisted.trial.unittest import TestCase from .._settings import ConfigError, load_config from .._shell import cli from .helpers import with_isolated_runner, write class TomlSettingsTests(TestCase): def mktemp_project( self, *, pyproject_toml: str = "", towncrier_toml: str = "" ) -> str: """ Create a temporary directory with a pyproject.toml file in it. """ project_dir = self.mktemp() os.makedirs(project_dir) if pyproject_toml: write( os.path.join(project_dir, "pyproject.toml"), pyproject_toml, dedent=True, ) if towncrier_toml: write( os.path.join(project_dir, "towncrier.toml"), towncrier_toml, dedent=True, ) return project_dir def test_base(self): """ Test a "base config". """ project_dir = self.mktemp_project( pyproject_toml=""" [tool.towncrier] package = "foobar" orphan_prefix = "~" """ ) config = load_config(project_dir) 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_markdown(self): """ If the filename references an .md file and the builtin template doesn't have an extension, add .md rather than .rst. """ project_dir = self.mktemp_project( pyproject_toml=""" [tool.towncrier] package = "foobar" filename = "NEWS.md" """ ) config = load_config(project_dir) self.assertEqual(config.filename, "NEWS.md") self.assertEqual(config.template, ("towncrier.templates", "default.md")) def test_explicit_template_extension(self): """ If the filename references an .md file and the builtin template has an extension, don't change it. """ project_dir = self.mktemp_project( pyproject_toml=""" [tool.towncrier] package = "foobar" filename = "NEWS.md" template = "towncrier:default.rst" """ ) config = load_config(project_dir) self.assertEqual(config.filename, "NEWS.md") self.assertEqual(config.template, ("towncrier.templates", "default.rst")) def test_template_extended(self): """ The template can be any package and resource, and although we look for a resource's 'templates' package, it could also be in the specified resource directly. """ project_dir = self.mktemp_project( pyproject_toml=""" [tool.towncrier] package = "foobar" template = "towncrier.templates:default.rst" """ ) config = load_config(project_dir) self.assertEqual(config.template, ("towncrier.templates", "default.rst")) def test_missing(self): """ If the config file doesn't have the correct toml key, we error. """ project_dir = self.mktemp_project( pyproject_toml=""" [something.else] blah='baz' """ ) with self.assertRaises(ConfigError) as e: load_config(project_dir) self.assertEqual(e.exception.failing_option, "all") def test_incorrect_single_file(self): """ single_file must be a bool. """ project_dir = self.mktemp_project( pyproject_toml=""" [tool.towncrier] single_file = "a" """ ) with self.assertRaises(ConfigError) as e: load_config(project_dir) self.assertEqual(e.exception.failing_option, "single_file") def test_incorrect_all_bullets(self): """ all_bullets must be a bool. """ project_dir = self.mktemp_project( pyproject_toml=""" [tool.towncrier] all_bullets = "a" """ ) with self.assertRaises(ConfigError) as e: load_config(project_dir) self.assertEqual(e.exception.failing_option, "all_bullets") def test_mistype_singlefile(self): """ singlefile is not accepted, single_file is. """ project_dir = self.mktemp_project( pyproject_toml=""" [tool.towncrier] singlefile = "a" """ ) with self.assertRaises(ConfigError) as e: load_config(project_dir) self.assertEqual(e.exception.failing_option, "singlefile") def test_towncrier_toml_preferred(self): """ Towncrier prefers the towncrier.toml for autodetect over pyproject.toml. """ project_dir = self.mktemp_project( towncrier_toml=""" [tool.towncrier] package = "a" """, pyproject_toml=""" [tool.towncrier] package = "b" """, ) config = load_config(project_dir) self.assertEqual(config.package, "a") @with_isolated_runner def test_load_no_config(self, runner: CliRunner): """ Calling the root CLI without an existing configuration file in the base directory, will exit with code 1 and an informative message is sent to standard output. """ temp = self.mktemp() os.makedirs(temp) result = runner.invoke(cli, ("--dir", temp)) self.assertEqual( result.output, f"No configuration file found.\nLooked back from: {os.path.abspath(temp)}\n", ) self.assertEqual(result.exit_code, 1) @with_isolated_runner def test_load_explicit_missing_config(self, runner: CliRunner): """ Calling the CLI with an incorrect explicit configuration file will exit with code 1 and an informative message is sent to standard output. """ config = "not-there.toml" result = runner.invoke(cli, ("--config", config)) self.assertEqual(result.exit_code, 1) self.assertEqual( result.output, f"Configuration file '{os.path.abspath(config)}' not found.\n", ) def test_missing_template(self): """ Towncrier will raise an exception saying when it can't find a template. """ project_dir = self.mktemp_project( towncrier_toml=""" [tool.towncrier] template = "foo.rst" """ ) with self.assertRaises(ConfigError) as e: load_config(project_dir) self.assertEqual( str(e.exception), "The template file '{}' does not exist.".format( os.path.normpath(os.path.join(project_dir, "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. """ project_dir = self.mktemp_project( towncrier_toml=""" [tool.towncrier] template = "towncrier:foo" """ ) with self.assertRaises(ConfigError) as e: load_config(project_dir) self.assertEqual( str(e.exception), "'towncrier' does not have a template named 'foo.rst'." ) 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. """ project_dir = self.mktemp_project( pyproject_toml=""" [tool.towncrier] package = "foobar" [[tool.towncrier.type]] directory="foo" name="Foo" showcontent=false [[tool.towncrier.type]] directory="spam" name="Spam" showcontent=true [[tool.towncrier.type]] directory="auto" name="Automatic" showcontent=true check=false """ ) config = load_config(project_dir) expected = [ ( "foo", { "name": "Foo", "showcontent": False, "check": True, }, ), ( "spam", { "name": "Spam", "showcontent": True, "check": True, }, ), ( "auto", { "name": "Automatic", "showcontent": True, "check": False, }, ), ] expected = dict(expected) 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. """ project_dir = self.mktemp_project( pyproject_toml=""" [tool.towncrier] package = "foobar" [tool.towncrier.fragment.feat] ignored_field="Bazz" [tool.towncrier.fragment.fix] [tool.towncrier.fragment.chore] name = "Other Tasks" showcontent = false [tool.towncrier.fragment.auto] name = "Automatic" check = false """ ) config = load_config(project_dir) expected = { "chore": { "name": "Other Tasks", "showcontent": False, "check": True, }, "feat": { "name": "Feat", "showcontent": True, "check": True, }, "fix": { "name": "Fix", "showcontent": True, "check": True, }, "auto": { "name": "Automatic", "showcontent": True, "check": False, }, } actual = config.types self.assertDictEqual(expected, actual) towncrier-24.8.0/src/towncrier/test/test_write.py0000644000000000000000000002347313615410400017077 0ustar00# Copyright (c) Amber Brown, 2015 # See LICENSE for details. import os from pathlib import Path from textwrap import dedent 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 from .helpers import read_pkg_resource, write class WritingTests(TestCase): maxDiff = None def test_append_at_top(self): fragments = { "": { ("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 = { "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.makedirs(tempdir) with open(os.path.join(tempdir, "NEWS.rst"), "w") as f: f.write("Old text.\n") fragments = split_fragments(fragments, definitions) template = read_pkg_resource("templates/default.rst") 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 = { "": { ("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 = { "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() write( os.path.join(tempdir, "NEWS.rst"), contents="""\ Hello there! Here is some info. .. towncrier release notes start Old text. """, dedent=True, ) fragments = split_fragments(fragments, definitions) template = read_pkg_resource("templates/default.rst") 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.makedirs(tempdir) definitions = {} fragments = split_fragments(fragments={}, definitions=definitions) template = read_pkg_resource("templates/default.rst") 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) towncrier-24.8.0/.gitignore0000644000000000000000000000037313615410400012534 0ustar00*.lock *.o *.py[co] *.pyproj *.so *~ .DS_Store .coverage .coverage.* .direnv .envrc .idea .mypy_cache .nox/ .pytest_cache .python-version .vs/ .vscode Justfile *egg-info/ _trial_temp*/ apidocs/ dist/ doc/ docs/_build/ dropin.cache htmlcov/ tmp/ venv/ towncrier-24.8.0/LICENSE0000644000000000000000000000207713615410400011554 0ustar00Copyright (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. towncrier-24.8.0/README.rst0000644000000000000000000000455713615410400012243 0ustar00Hear 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`` requires Python to run, 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 an issue. To get started, check out our `tutorial `_! .. links Project Links ------------- - **PyPI**: https://pypi.org/project/towncrier/ - **Documentation**: https://towncrier.readthedocs.io/ - **Release Notes**: https://github.com/twisted/towncrier/blob/trunk/NEWS.rst - **License**: `MIT `_ towncrier-24.8.0/pyproject.toml0000644000000000000000000000767713615410400013476 0ustar00[build-system] requires = ["hatchling"] build-backend = "hatchling.build" [project] name = "towncrier" # For dev - 23.11.0.dev0 # For RC - 23.11.0rc1 (release candidate starts at 1) # For final - 23.11.0 # make sure to follow PEP440 version = "24.8.0" description = "Building newsfiles for your project." readme = "README.rst" license = "MIT" # Keep version list in-sync with noxfile/tests & ci.yml/test-linux. 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.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ] requires-python = ">=3.8" dependencies = [ "click", "importlib-resources>=5; python_version<'3.10'", "importlib-metadata>=4.6; python_version<'3.10'", "jinja2", "tomli; python_version<'3.11'", ] [project.optional-dependencies] dev = [ "packaging", "sphinx >= 5", "furo >= 2024.05.06", "twisted", "nox", ] [project.scripts] towncrier = "towncrier._shell:cli" [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" [tool.hatch.build] exclude = [ "admin", "bin", "docs", ".readthedocs.yaml", "src/towncrier/newsfragments", ] [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] 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.ruff.isort] # Match isort's "attrs" profile lines-after-imports = 2 lines-between-types = 1 [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 = 'towncrier.click_default_group' # Vendored module without type annotations. ignore_errors = true [tool.coverage.run] parallel = true branch = true source = ["towncrier"] [tool.coverage.paths] source = ["src", ".nox/tests-*/**/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/*", "src/towncrier/click_default_group.py", ] towncrier-24.8.0/PKG-INFO0000644000000000000000000001023213615410400011634 0ustar00Metadata-Version: 2.3 Name: towncrier Version: 24.8.0 Summary: Building newsfiles for your project. Project-URL: Documentation, https://towncrier.readthedocs.io/ Project-URL: Chat, https://web.libera.chat/?channels=%23twisted Project-URL: Mailing list, https://mail.python.org/mailman3/lists/twisted.python.org/ Project-URL: Issues, https://github.com/twisted/towncrier/issues Project-URL: Repository, https://github.com/twisted/towncrier Project-URL: Tests, https://github.com/twisted/towncrier/actions?query=branch%3Atrunk Project-URL: Coverage, https://codecov.io/gh/twisted/towncrier Project-URL: Distribution, https://pypi.org/project/towncrier License-Expression: MIT License-File: LICENSE Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: MIT License Classifier: Operating System :: MacOS :: MacOS X Classifier: Operating System :: Microsoft :: Windows Classifier: Operating System :: POSIX :: Linux Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: Implementation :: PyPy Requires-Python: >=3.8 Requires-Dist: click Requires-Dist: importlib-metadata>=4.6; python_version < '3.10' Requires-Dist: importlib-resources>=5; python_version < '3.10' Requires-Dist: jinja2 Requires-Dist: tomli; python_version < '3.11' Provides-Extra: dev Requires-Dist: furo>=2024.05.06; extra == 'dev' Requires-Dist: nox; extra == 'dev' Requires-Dist: packaging; extra == 'dev' Requires-Dist: sphinx>=5; extra == 'dev' Requires-Dist: twisted; extra == 'dev' Description-Content-Type: text/x-rst Hear 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`` requires Python to run, 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 an issue. To get started, check out our `tutorial `_! .. links Project Links ------------- - **PyPI**: https://pypi.org/project/towncrier/ - **Documentation**: https://towncrier.readthedocs.io/ - **Release Notes**: https://github.com/twisted/towncrier/blob/trunk/NEWS.rst - **License**: `MIT `_