├── .flake8 ├── .git-blame-ignore-revs ├── .gitattributes ├── .github ├── CODEOWNERS ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── ci.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .pre-commit-hooks.yaml ├── .readthedocs.yaml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.rst ├── LICENSE ├── NEWS.rst ├── README.rst ├── RELEASE.rst ├── SECURITY.md ├── admin └── check_tag_version_match.py ├── bin └── towncrier ├── docs ├── .gitignore ├── Makefile ├── cli.rst ├── conf.py ├── configuration.rst ├── contributing.rst ├── customization │ ├── index.rst │ └── newsfile.rst ├── index.rst ├── markdown.rst ├── monorepo.rst ├── pre-commit.rst ├── release-notes.rst ├── release.rst └── tutorial.rst ├── noxfile.py ├── pyproject.toml └── src └── towncrier ├── __init__.py ├── __main__.py ├── _builder.py ├── _git.py ├── _hg.py ├── _novcs.py ├── _project.py ├── _settings ├── __init__.py ├── fragment_types.py └── load.py ├── _shell.py ├── _vcs.py ├── _writer.py ├── build.py ├── check.py ├── click_default_group.py ├── create.py ├── newsfragments ├── .gitignore ├── 394.feature.rst ├── 614.bugfix.rst ├── 663.removal ├── 667.misc.rst ├── 669.misc.rst ├── 672.doc ├── 676.feature.rst ├── 679.misc.rst ├── 680.misc ├── 682.misc.rst ├── 687.feature.rst ├── 691.feature.rst ├── 695.bugfix.rst ├── 700.feature.rst ├── 701.misc.rst ├── 702.misc.rst ├── 706.doc └── 713.misc.rst ├── templates ├── default.md ├── default.rst ├── hr-between-versions.rst └── single-file-no-bullets.rst └── test ├── __init__.py ├── helpers.py ├── test_build.py ├── test_builder.py ├── test_check.py ├── test_create.py ├── test_format.py ├── test_git.py ├── test_hg.py ├── test_novcs.py ├── test_project.py ├── test_settings.py ├── test_vcs.py └── test_write.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | # Allow for longer test strings. Code is formatted to 88 columns by Black. 3 | max-line-length = 99 4 | extend-ignore = 5 | # Conflict between flake8 & black about whitespace in slices. 6 | E203 7 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # This file lists commits that should be ignored by the `git blame` command. 2 | # You can configure your checkout to use it by default by running: 3 | # 4 | # git config blame.ignoreRevsFile .git-blame-ignore-revs 5 | # 6 | # Or pass it to the blame command manually: 7 | # 8 | # git blame --ignore-revs-file .git-blame-ignore-revs ... 9 | 10 | # black & isort 11 | 9a58911ea760b996b88355b6b18420b88b5a0ea9 12 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Hint Github.com about the language use in our template files. 2 | # The project templates indicate the target output type (.md/.rst) rather than the template type (jinja) 3 | # Reference: https://github.com/github-linguist/linguist/blob/main/docs/overrides.md#using-gitattributes 4 | src/towncrier/templates/* linguist-language=Jinja 5 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners 2 | * @twisted/twisted-contributors 3 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | Fixes # 6 | 7 | 8 | # Checklist 9 | 10 | * [ ] Make sure changes are covered by existing or new tests. 11 | * [ ] For at least one Python version, make sure test pass on your local environment. 12 | * [ ] Create a file in `src/towncrier/newsfragments/`. Briefly describe your 13 | changes, with information useful to end users. Your change will be included in the public release notes. 14 | * [ ] Make sure all GitHub Actions checks are green (they are automatically checking all of the above). 15 | * [ ] Ensure `docs/tutorial.rst` is still up-to-date. 16 | * [ ] If you add new **CLI arguments** (or change the meaning of existing ones), make sure `docs/cli.rst` reflects those changes. 17 | * [ ] If you add new **configuration options** (or change the meaning of existing ones), make sure `docs/configuration.rst` reflects those changes. 18 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ trunk ] 6 | tags: [ "**" ] 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | defaults: 11 | run: 12 | shell: bash 13 | 14 | env: 15 | PIP_DISABLE_PIP_VERSION_CHECK: "1" 16 | 17 | jobs: 18 | build: 19 | name: ${{ matrix.task.name}} - ${{ matrix.os.name }} ${{ matrix.python.name }} 20 | runs-on: ${{ matrix.os.runs-on }} 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | os: 25 | - name: Linux 26 | runs-on: ubuntu-latest 27 | python: 28 | - name: CPython 3.9 29 | action: 3.9 30 | task: 31 | - name: Build 32 | nox: build 33 | 34 | steps: 35 | - uses: actions/checkout@v3 36 | 37 | - uses: hynek/build-and-inspect-python-package@b5076c307dc91924a82ad150cdd1533b444d3310 # v2.12.0 38 | 39 | - name: Set up ${{ matrix.python.name }} 40 | uses: actions/setup-python@v4 41 | with: 42 | python-version: ${{ matrix.python.action }} 43 | 44 | - name: Install dependencies 45 | run: python -m pip install --upgrade pip nox 46 | 47 | - uses: twisted/python-info-action@v1 48 | 49 | - run: nox -e ${{ matrix.task.nox }} 50 | 51 | - name: Publish 52 | uses: actions/upload-artifact@v4 53 | with: 54 | name: dist 55 | path: dist/ 56 | 57 | test-linux: 58 | name: ${{ matrix.task.name}} - Linux ${{ matrix.python.name }} 59 | runs-on: ubuntu-latest 60 | needs: 61 | - build 62 | strategy: 63 | fail-fast: false 64 | matrix: 65 | # Keep list in-sync with noxfile/tests & pyproject.toml. 66 | python: 67 | - name: CPython 3.9 68 | action: 3.9 69 | - name: CPython 3.10 70 | action: '3.10' 71 | - name: CPython 3.11 72 | action: '3.11' 73 | - name: CPython 3.12 74 | action: '3.12' 75 | - name: CPython 3.13 76 | action: '3.13' 77 | - name: PyPy 3.10 78 | action: pypy3.10 79 | task: 80 | - name: Test 81 | nox: tests 82 | 83 | steps: 84 | - uses: actions/checkout@v3 85 | 86 | - name: Download package files 87 | uses: actions/download-artifact@v4 88 | with: 89 | name: dist 90 | path: dist/ 91 | 92 | - name: Set up ${{ matrix.python.name }} 93 | uses: actions/setup-python@v4 94 | with: 95 | python-version: ${{ matrix.python.action }} 96 | allow-prereleases: true 97 | cache: pip 98 | 99 | - name: Install dependencies 100 | run: python -m pip install --upgrade pip nox 101 | 102 | - uses: twisted/python-info-action@v1 103 | 104 | - run: nox --python ${{ matrix.python.action }} -e ${{ matrix.task.nox }} -- --use-wheel dist/*.whl 105 | 106 | - name: Upload coverage data 107 | uses: actions/upload-artifact@v4 108 | with: 109 | name: coverage-data-${{ matrix.python.action }} 110 | path: .coverage.* 111 | include-hidden-files: true 112 | if-no-files-found: ignore 113 | 114 | 115 | test-windows: 116 | name: ${{ matrix.task.name}} - Windows ${{ matrix.python.name }} 117 | runs-on: windows-latest 118 | needs: 119 | - build 120 | strategy: 121 | fail-fast: false 122 | matrix: 123 | python: 124 | - name: CPython 3.9 125 | action: '3.9' 126 | task: 127 | - name: Test 128 | nox: tests 129 | 130 | steps: 131 | - uses: actions/checkout@v3 132 | 133 | - name: Download package files 134 | uses: actions/download-artifact@v4 135 | with: 136 | name: dist 137 | path: dist/ 138 | 139 | - name: Set up ${{ matrix.python.name }} 140 | uses: actions/setup-python@v4 141 | with: 142 | python-version: ${{ matrix.python.action }} 143 | 144 | - name: Install dependencies 145 | run: python -m pip install --upgrade pip nox 146 | 147 | - uses: twisted/python-info-action@v1 148 | 149 | - run: nox --python ${{ matrix.python.action }} -e ${{ matrix.task.nox }} -- --use-wheel dist/*.whl 150 | 151 | 152 | check: 153 | name: ${{ matrix.task.name}} - ${{ matrix.python.name }} 154 | runs-on: ubuntu-latest 155 | needs: 156 | - build 157 | strategy: 158 | fail-fast: false 159 | matrix: 160 | python: 161 | # Use a recent version to avoid having common disconnects between 162 | # local development setups and CI. 163 | - name: CPython 3.12 164 | python-version: '3.12' 165 | task: 166 | - name: Check Newsfragment 167 | run: | 168 | nox -e check_newsfragment 169 | nox -e draft_newsfragment >> $GITHUB_STEP_SUMMARY 170 | run-if: ${{ github.head_ref != 'pre-commit-ci-update-config' }} 171 | - name: Check mypy 172 | run: nox -e typecheck 173 | run-if: true 174 | 175 | steps: 176 | - uses: actions/checkout@v3 177 | with: 178 | fetch-depth: 0 179 | 180 | - name: Download package files 181 | uses: actions/download-artifact@v4 182 | with: 183 | name: dist 184 | path: dist/ 185 | 186 | - name: Set up ${{ matrix.python.name }} 187 | uses: actions/setup-python@v4 188 | with: 189 | python-version: ${{ matrix.python.python-version }} 190 | 191 | - name: Install dependencies 192 | run: python -m pip install --upgrade pip nox 193 | 194 | - uses: twisted/python-info-action@v1 195 | 196 | - name: Check 197 | run: | 198 | ${{ matrix.task.run }} 199 | if: ${{ matrix.task.run-if }} 200 | 201 | 202 | pre-commit: 203 | name: Check pre-commit integration 204 | runs-on: ubuntu-latest 205 | 206 | steps: 207 | - uses: actions/checkout@v4 208 | with: 209 | fetch-depth: 0 210 | 211 | - name: Set up python 212 | uses: actions/setup-python@v5 213 | with: 214 | python-version: 3.12 215 | 216 | - name: Install dependencies 217 | run: python -m pip install pre-commit 218 | 219 | - name: Install pre-commit 220 | run: | 221 | pre-commit install 222 | 223 | - name: Update pre-commit 224 | run: | 225 | pre-commit autoupdate 226 | 227 | - name: Run pre-commit 228 | run: | 229 | pre-commit run -a 230 | 231 | 232 | pypi-publish: 233 | name: Check tag and publish 234 | # Only trigger this for tag changes. 235 | if: startsWith(github.ref, 'refs/tags/') 236 | runs-on: ubuntu-latest 237 | permissions: 238 | # IMPORTANT: this permission is mandatory for trusted publishing 239 | id-token: write 240 | 241 | needs: 242 | - build 243 | - test-linux 244 | - test-windows 245 | steps: 246 | - uses: actions/checkout@v3 247 | 248 | - name: Download package files 249 | uses: actions/download-artifact@v4 250 | with: 251 | name: dist 252 | path: dist/ 253 | 254 | - name: Set up Python 255 | uses: actions/setup-python@v4 256 | with: 257 | python-version: 3.12 258 | 259 | - name: Display structure of files to be pushed 260 | run: ls --recursive dist/ 261 | 262 | - name: Ensure tag and package versions match. 263 | run: | 264 | python -Im pip install dist/*.whl 265 | python -I admin/check_tag_version_match.py "${{ github.ref }}" 266 | 267 | - name: Publish to PyPI - on tag 268 | # This was tag 1.9.0 on 2024-07-30 269 | uses: pypa/gh-action-pypi-publish@ec4db0b4ddc65acdf4bff5fa45ac92d78b56bdf0 270 | 271 | 272 | coverage: 273 | name: Combine & check coverage. 274 | needs: test-linux 275 | runs-on: ubuntu-latest 276 | 277 | steps: 278 | - uses: actions/checkout@v3 279 | - uses: actions/setup-python@v4 280 | with: 281 | # Use latest Python, so it understands all syntax. 282 | python-version: 3.12 283 | 284 | - run: python -Im pip install --upgrade coverage[toml] 285 | 286 | - uses: actions/download-artifact@v4 287 | with: 288 | pattern: coverage-data-* 289 | merge-multiple: true 290 | path: . 291 | 292 | - name: Combine coverage & fail if it's <100%. 293 | run: | 294 | python -Im coverage combine 295 | python -Im coverage html --skip-covered --skip-empty 296 | 297 | # Report and write to summary. 298 | python -Im coverage report --format=markdown >> $GITHUB_STEP_SUMMARY 299 | 300 | # Report again and fail if under 100%. 301 | python -Im coverage report --fail-under=100 302 | 303 | - name: Upload HTML report if check failed. 304 | uses: actions/upload-artifact@v4 305 | with: 306 | name: html-report 307 | path: htmlcov 308 | include-hidden-files: true 309 | if: ${{ failure() }} 310 | 311 | # This is a meta-job to simplify PR CI enforcement configuration in GitHub. 312 | # Inside the GitHub config UI you only configure this job as required. 313 | # All the extra requirements are defined "as code" as part of the `needs` 314 | # list for this job. 315 | all: 316 | name: All success 317 | runs-on: ubuntu-latest 318 | # The always() part is very important. 319 | # If not set, the job will be skipped on failing dependencies. 320 | if: always() 321 | needs: 322 | # This is the list of CI job that we are interested to be green before 323 | # a merge. 324 | # pypi-publish is skipped since this is only executed for a tag. 325 | - build 326 | - test-linux 327 | - test-windows 328 | - coverage 329 | - check 330 | - pre-commit 331 | steps: 332 | - name: Require all successes 333 | uses: re-actors/alls-green@3a2de129f0713010a71314c74e33c0e3ef90e696 334 | with: 335 | jobs: ${{ toJSON(needs) }} 336 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.lock 2 | *.o 3 | *.py[co] 4 | *.pyproj 5 | *.so 6 | *~ 7 | .DS_Store 8 | .coverage 9 | .coverage.* 10 | .direnv 11 | .envrc 12 | .idea 13 | .mypy_cache 14 | .nox/ 15 | .pytest_cache 16 | .python-version 17 | .vs/ 18 | .vscode 19 | Justfile 20 | *egg-info/ 21 | _trial_temp*/ 22 | apidocs/ 23 | dist/ 24 | doc/ 25 | docs/_build/ 26 | dropin.cache 27 | htmlcov/ 28 | tmp/ 29 | venv/ 30 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | ci: 3 | autoupdate_schedule: monthly 4 | 5 | repos: 6 | - repo: https://github.com/psf/black 7 | rev: 25.1.0 8 | hooks: 9 | - id: black 10 | 11 | - repo: https://github.com/asottile/pyupgrade 12 | rev: v3.20.0 13 | hooks: 14 | - id: pyupgrade 15 | args: [--py39-plus] 16 | 17 | - repo: https://github.com/PyCQA/isort 18 | rev: 6.0.1 19 | hooks: 20 | - id: isort 21 | additional_dependencies: [toml] 22 | 23 | - repo: https://github.com/PyCQA/flake8 24 | rev: 7.2.0 25 | hooks: 26 | - id: flake8 27 | 28 | - repo: https://github.com/pre-commit/pre-commit-hooks 29 | rev: v5.0.0 30 | hooks: 31 | - id: trailing-whitespace 32 | - id: end-of-file-fixer 33 | - id: debug-statements 34 | - id: check-toml 35 | - id: check-yaml 36 | 37 | - repo: https://github.com/twisted/towncrier 38 | rev: 24.8.0 39 | hooks: 40 | - id: towncrier-check 41 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: towncrier-check 2 | name: towncrier-check 3 | description: Check towncrier changelog updates 4 | entry: towncrier --draft 5 | pass_filenames: false 6 | types: [text] 7 | files: newsfragments/ 8 | language: python 9 | - id: towncrier-update 10 | name: towncrier-update 11 | description: Update changelog with towncrier 12 | entry: towncrier 13 | pass_filenames: false 14 | args: ["--yes"] 15 | files: newsfragments/ 16 | language: python 17 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | sphinx: 4 | # We want to fail as this is also our CI check for the docs. 5 | fail_on_warning: True 6 | configuration: docs/conf.py 7 | 8 | # We don't need PDF and epub for the docs. 9 | formats: [] 10 | 11 | build: 12 | os: ubuntu-22.04 13 | tools: 14 | python: "3.11" 15 | 16 | python: 17 | install: 18 | - method: pip 19 | path: . 20 | extra_requirements: 21 | - dev 22 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | 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. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | 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. 28 | 29 | 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. 30 | 31 | ## Scope 32 | 33 | 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. 34 | 35 | ## Enforcement 36 | 37 | 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. 38 | 39 | 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. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contributing to Towncrier 2 | ========================= 3 | 4 | Want to contribute to this project? Great! We'd love to hear from you! 5 | 6 | As a developer and user, you probably have some questions about our project and how to contribute. 7 | In this article, we try to answer these and give you some recommendations. 8 | 9 | 10 | Ways to communicate and contribute 11 | ---------------------------------- 12 | 13 | There are several options to contribute to this project: 14 | 15 | * Open a new topic on our `GitHub Discussions`_ page. 16 | 17 | Tell us about your ideas or ask questions there. 18 | Discuss with us the next Towncrier release. 19 | 20 | * Help or comment on our GitHub `issues`_ tracker. 21 | 22 | There are certainly many issues where you can help with your expertise. 23 | Or you would like to share your ideas with us. 24 | 25 | * Open a new issue using GitHub `issues`_ tracker. 26 | 27 | If you found a bug or have a new cool feature, describe your findings. 28 | Try to be as descriptive as possible to help us understand your issue. 29 | 30 | * Check out the Libera ``#twisted`` IRC channel or `Twisted Gitter `_. 31 | 32 | If you prefer to discuss some topics personally, 33 | you may find the IRC or Gitter channels interesting. 34 | They are bridged. 35 | 36 | * Modify the code. 37 | 38 | If you would love to see the new feature in the next release, this is probably the best way. 39 | 40 | 41 | Modifying the code 42 | ------------------ 43 | 44 | The source code is managed using Git and is hosted on GitHub:: 45 | 46 | https://github.com/twisted/towncrier 47 | git@github.com:twisted/towncrier.git 48 | 49 | 50 | We recommend the following workflow: 51 | 52 | #. `Fork our project `_ on GitHub. 53 | 54 | #. Clone your forked Git repository (replace ``GITHUB_USER`` with your 55 | account name on GitHub):: 56 | 57 | $ git clone git@github.com:GITHUB_USER/towncrier.git 58 | 59 | #. Prepare a pull request: 60 | 61 | a. Create a new branch with:: 62 | 63 | $ git checkout -b 64 | 65 | b. Write your test cases and run the complete test suite, see the section 66 | *Running the test suite* for details. 67 | 68 | c. Document any user-facing changes in one of the ``/docs/`` files. 69 | Use `one sentence per line`_. 70 | 71 | d. Create a news fragment in ``src/towncrier/newsfragments/`` describing the changes and containing information that is of interest to end-users. 72 | Use `one sentence per line`_ here, too. 73 | You can use the ``towncrier`` CLI to create them; for example ``towncrier create 1234.bugfix`` 74 | 75 | Use one of the following types: 76 | 77 | - ``feature`` for new features 78 | - ``bugfix`` for bugfixes 79 | - ``doc`` for improvements to documentation 80 | - ``removal`` for deprecations and removals 81 | - ``misc`` for everything else that is linked but not shown in our ``NEWS.rst`` file. 82 | Use this for pull requests that don't affect end-users and leave them empty. 83 | 84 | e. Create a `pull request`_. 85 | Describe in the pull request what you did and why. 86 | If you have open questions, ask. 87 | (optional) Allow team members to edit the code on your PR. 88 | 89 | #. Wait for feedback. If you receive any comments, address these. 90 | 91 | #. After your pull request is merged, delete your branch. 92 | 93 | 94 | .. _testsuite: 95 | 96 | Running the test suite 97 | ---------------------- 98 | 99 | We use the `twisted.trial`_ module and `nox`_ to run tests against all supported 100 | Python versions and operating systems. 101 | 102 | The following list contains some ways how to run the test suite: 103 | 104 | * To install this project into a virtualenv along with the dependencies necessary 105 | to run the tests and build the documentation:: 106 | 107 | $ pip install -e .[dev] 108 | 109 | * To run the tests, use ``trial`` like so:: 110 | 111 | $ trial towncrier 112 | 113 | * To investigate and debug errors, use the ``trial`` command like this:: 114 | 115 | $ trial -b towncrier 116 | 117 | This will invoke a PDB session. If you press ``c`` it will continue running 118 | the test suite until it runs into an error. 119 | 120 | * To run all tests against all supported versions, install nox and use:: 121 | 122 | $ nox 123 | 124 | You may want to add the ``--no-error-on-missing-interpreters`` option to avoid errors 125 | when a specific Python interpreter version couldn't be found. 126 | 127 | * To get a complete list of the available targets, run:: 128 | 129 | $ nox -l 130 | 131 | * To run only a specific test only, use the ``towncrier.test.FILE.CLASS.METHOD`` syntax, 132 | for example:: 133 | 134 | $ nox -e tests -- towncrier.test.test_project.InvocationTests.test_version 135 | 136 | * To run some quality checks before you create the pull request, 137 | we recommend using this call:: 138 | 139 | $ nox -e pre_commit check_newsfragment 140 | 141 | * Or enable `pre-commit` as a git hook:: 142 | 143 | $ pip install pre-commit 144 | $ pre-commit install 145 | 146 | 147 | **Please note**: If the test suite works in nox, but doesn't by calling 148 | ``trial``, it could be that you've got GPG-signing active for git commits which 149 | fails with our dummy test commits. 150 | 151 | .. ### Links 152 | 153 | .. _flake8: https://flake8.pycqa.org/ 154 | .. _GitHub Discussions: https://github.com/twisted/towncrier/discussions 155 | .. _issues: https://github.com/twisted/towncrier/issues 156 | .. _pull request: https://github.com/twisted/towncrier/pulls 157 | .. _nox: https://nox.thea.codes/ 158 | .. _`one sentence per line`: https://rhodesmill.org/brandon/2012/one-sentence-per-line/ 159 | .. _twisted.trial: https://github.com/twisted/trac-wiki-archive/blob/trunk/TwistedTrial.mediawiki 160 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, Amber Brown and the towncrier contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Hear ye, hear ye, says the ``towncrier`` 2 | ======================================== 3 | 4 | .. image:: https://img.shields.io/badge/Docs-Read%20The%20Docs-black 5 | :alt: Documentation 6 | :target: https://towncrier.readthedocs.io/ 7 | 8 | .. image:: https://img.shields.io/badge/license-MIT-C06524 9 | :alt: License: MIT 10 | :target: https://github.com/twisted/towncrier/blob/trunk/LICENSE 11 | 12 | .. image:: https://img.shields.io/pypi/v/towncrier 13 | :alt: PyPI release 14 | :target: https://pypi.org/project/towncrier/ 15 | 16 | ``towncrier`` is a utility to produce useful, summarized news files (also known as changelogs) for your project. 17 | 18 | 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**. 19 | 20 | Used by `Twisted `_, `pytest `_, `pip `_, `BuildBot `_, and `attrs `_, among others. 21 | 22 | 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**. 23 | 24 | 25 | Philosophy 26 | ---------- 27 | 28 | ``towncrier`` delivers the news which is convenient to those that hear it, not those that write it. 29 | 30 | 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. 31 | These fragments are also commonly called "topfiles" or "newsfiles". 32 | 33 | ``towncrier`` works best in a development system where all merges involve closing an issue. 34 | 35 | To get started, check out our `tutorial `_! 36 | 37 | .. links 38 | 39 | Project Links 40 | ------------- 41 | 42 | - **PyPI**: https://pypi.org/project/towncrier/ 43 | - **Documentation**: https://towncrier.readthedocs.io/ 44 | - **Release Notes**: https://github.com/twisted/towncrier/blob/trunk/NEWS.rst 45 | - **License**: `MIT `_ 46 | -------------------------------------------------------------------------------- /RELEASE.rst: -------------------------------------------------------------------------------- 1 | Release Process 2 | =============== 3 | 4 | .. note:: 5 | Commands are written with Linux in mind and a ``venv`` located in ``venv/``. 6 | Adjust per your OS and virtual environment location. 7 | For example, on Windows with an environment in the directory ``myenv/`` the Python command would be ``myenv/scripts/python``. 8 | 9 | Towncrier uses `CalVer `_ of the form ``YY.MM.micro`` with the micro version just incrementing. 10 | 11 | Before the final release, a set of release candidates are released. 12 | 13 | 14 | Release candidate 15 | ----------------- 16 | 17 | Create a release branch with a name of the form ``release-19.9.0`` starting from the main branch. 18 | The same branch is used for the release candidate and the final release. 19 | In the end, the release branch is merged into the main branch. 20 | 21 | Update the version to the release candidate with the first being ``rc1`` (as opposed to 0). 22 | In ``pyproject.toml`` the version is set using a PEP440 compliant string: 23 | 24 | version = "19.9.0rc1" 25 | 26 | Use `towncrier` to generate the news release NEWS file, but first, 27 | make sure the new version is installed:: 28 | 29 | venv/bin/pip install -e . 30 | venv/bin/towncrier build --yes 31 | 32 | Commit and push to the primary repository, not a fork. 33 | It is important to not use a fork so that pushed tags end up in the primary repository, 34 | server provided secrets for publishing to PyPI are available, and maybe more. 35 | 36 | Create a PR named in the form ``Release 19.9.0``. 37 | The same PR will be used for the release candidates and the final release. 38 | 39 | Wait for the tests to be green. 40 | Start with the release candidates. 41 | Create a new release candidate using `GitHub New release UI `_. 42 | 43 | * *Choose a tag*: Type `19.9.0rc1` and select `Create new tag on publish.` 44 | * *Target*: Search for the release branch and select it. 45 | * *Title*: "Towncrier 19.9.0rc1". 46 | * Set the content based on the NEWS file (for now in RST format). 47 | * Make sure to check **This is a pre-release**. 48 | * Click `Publish release` 49 | 50 | This will trigger the PyPI release candidate. 51 | 52 | Wait for the PyPI version to be published and then request a review for the PR from the ``twisted/twisted-contributors`` team. 53 | 54 | In the PR request, you can give the link to the PyPI download and the documentation pages. 55 | The documentation link is also available as part of the standard Read The Docs PR checks. 56 | 57 | Notify the release candidate over IRC or Gitter to gain more attention. 58 | In the PR comments, you can also mention anyone who has asked for a release. 59 | 60 | We don't create discussion for pre-releases. 61 | Any discussions before the final release, can go on the PR itself. 62 | 63 | For now, the GitHub release text is reStructuredText as it's easy to copy and paste. 64 | In the future we might create a separate Markdown version. 65 | 66 | 67 | Release candidate publish failures 68 | ---------------------------------- 69 | 70 | The PyPI publish process is automatically triggered when a tag is created. 71 | 72 | The publish is skipped for PRs, so we can check that the automated process works only a release time. 73 | It can happen for the automated publish process to fail. 74 | 75 | As long as the package was not published to PyPI, do the followings: 76 | 77 | * Manually delete the candidate release from GitHub releases 78 | * Manually delete the tag for the release candidate 79 | 80 | Try to fix the issue and trigger the same release candidate again. 81 | 82 | Once the package is published on PyPI, do not delete the release or the tag. 83 | Proceed with create a new release candidate instead. 84 | 85 | 86 | Final release 87 | ------------- 88 | 89 | Once the PR is approved, you can trigger the final release. 90 | 91 | Update the version to the final version. 92 | In ``pyproject.toml`` the version is set using a PEP440 compliant string: 93 | 94 | version = "19.9.0" 95 | 96 | Manually update the `NEWS.rst` file to include the final release version and date. 97 | Usually it will look like this. 98 | This will replace the release candidate section:: 99 | 100 | towncrier 19.9.0 (2019-09-01) 101 | ============================= 102 | 103 | No significant changes since the previous release candidate. 104 | 105 | Commit and push the change. 106 | Wait for the tests to be green. 107 | 108 | Trigger the final release using GitHub Release GUI. 109 | 110 | Similar to the release candidate, with the difference: 111 | 112 | * tag will be named `19.9.0` 113 | * the target is the same branch 114 | * Title will be `towncrier 19.9.0` 115 | * Content can be the content of the final release (RST format). 116 | * Check **Set as the latest release**. 117 | * Check **Create a discussion for this release**. 118 | * Click `Publish release` 119 | 120 | No need for another review request. 121 | 122 | Update the version to the development version. 123 | In ``pyproject.toml`` the version is set using a PEP440 compliant string: 124 | 125 | version = "19.9.0.dev0" 126 | 127 | Commit and push the changes. 128 | 129 | Merge the commit in the main branch, **without using squash**. 130 | 131 | We tag the release based on a commit from the release branch. 132 | If we merge with squash, 133 | the release tag commit will no longer be found in the main branch history. 134 | With a squash merge, the whole branch history is lost. 135 | This causes the `pre-commit autoupdate` to fail. 136 | See `PR590 `_ for more details. 137 | 138 | You can announce the release over IRC, Gitter, or Twisted mailing list. 139 | 140 | Done. 141 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | The twisted/towncrier project uses the same security policy as [twisted/twisted](https://github.com/twisted/twisted). 4 | 5 | For more details, please check the [Twisted security process](https://github.com/twisted/twisted?tab=security-ov-file#readme). 6 | -------------------------------------------------------------------------------- /admin/check_tag_version_match.py: -------------------------------------------------------------------------------- 1 | # 2 | # Used during the release process to make sure that we release based on a 3 | # tag that has the same version as the current packaging metadata. 4 | # 5 | # Designed to be conditionally called inside GitHub Actions release job. 6 | # Tags should use PEP440 version scheme. 7 | # 8 | # To be called as: admin/check_tag_version_match.py refs/tags/20.3.0 9 | # 10 | 11 | import sys 12 | 13 | from importlib import metadata 14 | 15 | 16 | TAG_PREFIX = "refs/tags/" 17 | 18 | if len(sys.argv) < 2: 19 | print("No tag check requested.") 20 | sys.exit(0) 21 | 22 | pkg_version = metadata.version("towncrier") 23 | print(f"Package version is {pkg_version}.") 24 | run_version = sys.argv[1] 25 | 26 | if not run_version.startswith(TAG_PREFIX): 27 | print(f"Not a twisted release tag name '{run_version}.") 28 | sys.exit(1) 29 | 30 | run_version = run_version[len(TAG_PREFIX) :] # noqa: E203 31 | 32 | if run_version != pkg_version: 33 | print(f"Package is at '{pkg_version}' while tag is '{run_version}'") 34 | exit(1) 35 | 36 | print(f"All good. Package and tag versions match for '{pkg_version}'.") 37 | sys.exit(0) 38 | -------------------------------------------------------------------------------- /bin/towncrier: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # flake8: noqa 3 | 4 | import os.path 5 | import sys 6 | 7 | 8 | srcdir = os.path.join(os.path.dirname(__file__), "..", "src") 9 | sys.path.insert(0, srcdir) 10 | 11 | import towncrier 12 | 13 | 14 | towncrier._main() 15 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | _build 2 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python -msphinx 7 | SPHINXPROJ = Towncrier 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/cli.rst: -------------------------------------------------------------------------------- 1 | Command Line Reference 2 | ====================== 3 | 4 | The following options can be passed to all of the commands that explained below: 5 | 6 | .. option:: --config FILE_PATH 7 | 8 | Pass a custom config file at ``FILE_PATH``. 9 | 10 | Default: ``towncrier.toml`` or ``pyproject.toml`` file. 11 | If both files exist, the first will take precedence 12 | 13 | .. option:: --dir PATH 14 | 15 | The command is executed relative to ``PATH``. 16 | For instance with the default config news fragments are checked and added in ``PATH/newsfragments`` and the news file is built in ``PATH/NEWS.rst``. 17 | 18 | Default: current directory. 19 | 20 | 21 | ``towncrier build`` 22 | ------------------- 23 | 24 | Build the combined news file from news fragments. 25 | ``build`` is also assumed if no command is passed. 26 | 27 | If there are no news fragments (including an empty fragments directory or a 28 | non-existent directory), a notice of "no significant changes" will be added to 29 | the news file. 30 | 31 | By default, the processed news fragments are removed. For any fragments 32 | committed in your git repository, git rm will be used (which will also remove 33 | the fragments directory if now empty). 34 | 35 | .. option:: --draft 36 | 37 | Only render news fragments to standard output. 38 | Don't write to files, don't check versions. 39 | Only renders the news fragments **without** the surrounding template. 40 | 41 | .. option:: --name NAME 42 | 43 | Use `NAME` as project name in the news file. 44 | Can be configured. 45 | 46 | .. option:: --version VERSION 47 | 48 | Use ``VERSION`` in the rendered news file. 49 | Can be configured or guessed (default). 50 | 51 | This option requires the ``build`` command to be explicitly passed. 52 | 53 | .. option:: --date DATE 54 | 55 | The date in `ISO format `_ to use in the news file. 56 | 57 | Default: today's date 58 | 59 | .. option:: --yes 60 | 61 | Do not ask for confirmations. 62 | Useful for automated tasks. 63 | 64 | .. option:: --keep 65 | 66 | Don't delete news fragments after the build and don't ask for confirmation whether to delete or keep the fragments. 67 | 68 | 69 | ``towncrier create`` 70 | -------------------- 71 | 72 | Create a news fragment in the directory that ``towncrier`` is configured to look for fragments:: 73 | 74 | $ towncrier create 123.bugfix.rst 75 | 76 | If you don't provide a file name, ``towncrier`` will prompt you for one. 77 | 78 | ``towncrier create`` will enforce that the passed type (e.g. ``bugfix``) is valid. 79 | 80 | If the fragments directory does not exist, it will be created. 81 | 82 | If the filename exists already, ``towncrier create`` will add (and then increment) a number after the fragment type until it finds a filename that does not exist yet. 83 | In the above example, it will generate ``123.bugfix.1.rst`` if ``123.bugfix.rst`` already exists. 84 | 85 | To create a news fragment not tied to a specific issue (which towncrier calls an "orphan fragment"), start the fragment name with a ``+``. 86 | If that is the entire fragment name, a random hash will be added for you:: 87 | 88 | $ towncrier create +.feature.rst 89 | $ ls newsfragments/ 90 | +fcc4dc7b.feature.rst 91 | 92 | .. option:: --content, -c CONTENT 93 | 94 | A string to use for content. 95 | Default: an instructive placeholder. 96 | 97 | .. option:: --edit / --no-edit 98 | 99 | Whether to start ``$EDITOR`` to edit the news fragment right away. 100 | Default: ``$EDITOR`` will be started unless you also provided content. 101 | 102 | .. option:: --section SECTION 103 | 104 | The section to use for the news fragment. 105 | Default: the section with no path, or if all sections have a path then the first defined section. 106 | 107 | 108 | ``towncrier check`` 109 | ------------------- 110 | 111 | To check if a feature branch adds at least one news fragment, run:: 112 | 113 | $ towncrier check 114 | 115 | The check is automatically skipped when the main news file is modified inside the branch as this signals a release branch that is expected to not have news fragments. 116 | 117 | By default, ``towncrier`` compares the current branch against ``origin/main`` (and falls back to ``origin/master`` with a warning if it exists, *for now*). 118 | 119 | .. option:: --compare-with REMOTE-BRANCH 120 | 121 | Use ``REMOTE-BRANCH`` instead of ``origin/main``:: 122 | 123 | $ towncrier check --compare-with origin/trunk 124 | 125 | .. option:: --staged 126 | 127 | Include files that have been staged for commit when checking for news fragments:: 128 | 129 | $ towncrier check --staged 130 | $ towncrier check --staged --compare-with origin/trunk 131 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # 2 | # Towncrier documentation build configuration file, created by 3 | # sphinx-quickstart on Mon Aug 21 20:46:13 2017. 4 | # 5 | # This file is execfile()d with the current directory set to its 6 | # containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | # If extensions (or modules to document with autodoc) are in another directory, 15 | # add these directories to sys.path here. If the directory is relative to the 16 | # documentation root, use os.path.abspath to make it absolute, like shown here. 17 | # 18 | # import os 19 | # import sys 20 | # sys.path.insert(0, os.path.abspath('.')) 21 | 22 | 23 | # -- General configuration ------------------------------------------------ 24 | 25 | # If your documentation needs a minimal Sphinx version, state it here. 26 | # 27 | # needs_sphinx = '1.0' 28 | 29 | # Add any Sphinx extension module names here, as strings. They can be 30 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 31 | # ones. 32 | import os 33 | 34 | from datetime import date 35 | from importlib.metadata import version 36 | 37 | 38 | towncrier_version = version("towncrier") 39 | 40 | 41 | extensions = [] 42 | 43 | # Add any paths that contain templates here, relative to this directory. 44 | templates_path = ["_templates"] 45 | 46 | # The suffix(es) of source filenames. 47 | # You can specify multiple suffix as a list of string: 48 | # 49 | # source_suffix = ['.rst', '.md'] 50 | source_suffix = ".rst" 51 | 52 | # The master toctree document. 53 | master_doc = "index" 54 | 55 | # General information about the project. 56 | _today = date.today() 57 | project = "Towncrier" 58 | copyright = "{}, Towncrier contributors. Ver {}".format( 59 | _today.year, 60 | towncrier_version, 61 | ) 62 | author = "Amber Brown" 63 | 64 | # The version info for the project you're documenting, acts as replacement for 65 | # |version| and |release|, also used in various other places throughout the 66 | # built documents. 67 | # The short X.Y version. 68 | version = ".".join(towncrier_version.split(".")[:3]) 69 | # The full version, including alpha/beta/rc tags. 70 | release = towncrier_version 71 | 72 | # List of patterns, relative to source directory, that match files and 73 | # directories to ignore when looking for source files. 74 | # This patterns also effect to html_static_path and html_extra_path 75 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 76 | 77 | # If true, `todo` and `todoList` produce output, else they produce nothing. 78 | todo_include_todos = False 79 | 80 | 81 | # -- Options for HTML output ---------------------------------------------- 82 | 83 | # The theme to use for HTML and HTML Help pages. See the documentation for 84 | # a list of builtin themes. 85 | # 86 | html_theme = "furo" 87 | 88 | # Theme options are theme-specific and customize the look and feel of a theme 89 | # further. For a list of options available for each theme, see the 90 | # documentation. 91 | # 92 | if os.environ.get("READTHEDOCS_VERSION_NAME", "trunk") not in ("trunk", "latest"): 93 | # Remove the "Edit on GitHub" link for non-trunk versions of the docs 94 | html_theme_options = {"top_of_page_buttons": []} 95 | 96 | # Add any paths that contain custom static files (such as style sheets) here, 97 | # relative to this directory. They are copied after the builtin static files, 98 | # so a file named "default.css" will overwrite the builtin "default.css". 99 | html_static_path = [] 100 | 101 | 102 | # -- Options for HTMLHelp output ------------------------------------------ 103 | 104 | # Output file base name for HTML help builder. 105 | htmlhelp_basename = "Towncrierdoc" 106 | 107 | 108 | # -- Options for LaTeX output --------------------------------------------- 109 | 110 | latex_elements = { 111 | # The paper size ('letterpaper' or 'a4paper'). 112 | # 113 | # 'papersize': 'letterpaper', 114 | # The font size ('10pt', '11pt' or '12pt'). 115 | # 116 | # 'pointsize': '10pt', 117 | # Additional stuff for the LaTeX preamble. 118 | # 119 | # 'preamble': '', 120 | # Latex figure (float) alignment 121 | # 122 | # 'figure_align': 'htbp', 123 | } 124 | 125 | # Grouping the document tree into LaTeX files. List of tuples 126 | # (source start file, target name, title, 127 | # author, documentclass [howto, manual, or own class]). 128 | latex_documents = [ 129 | (master_doc, "Towncrier.tex", "Towncrier Documentation", "Amber Brown", "manual"), 130 | ] 131 | 132 | 133 | # -- Options for manual page output --------------------------------------- 134 | 135 | # One entry per manual page. List of tuples 136 | # (source start file, name, description, authors, manual section). 137 | man_pages = [(master_doc, "towncrier", "Towncrier Documentation", [author], 7)] 138 | 139 | 140 | # -- Options for Texinfo output ------------------------------------------- 141 | 142 | # Grouping the document tree into Texinfo files. List of tuples 143 | # (source start file, target name, title, author, 144 | # dir menu entry, description, category) 145 | texinfo_documents = [ 146 | ( 147 | master_doc, 148 | "Towncrier", 149 | "Towncrier Documentation", 150 | author, 151 | "Towncrier", 152 | "One line description of project.", 153 | "Miscellaneous", 154 | ), 155 | ] 156 | -------------------------------------------------------------------------------- /docs/configuration.rst: -------------------------------------------------------------------------------- 1 | Configuration Reference 2 | ======================= 3 | 4 | ``towncrier`` has many knobs and switches you can use, to customize it to your project's needs. 5 | The setup in the :doc:`tutorial` doesn't touch on many, but this document will detail each of these options for you! 6 | 7 | For how to perform common customization tasks, see :doc:`customization/index`. 8 | 9 | ``[tool.towncrier]`` 10 | -------------------- 11 | 12 | All configuration for ``towncrier`` sits inside ``towncrier.toml`` or ``pyproject.toml``, under the ``tool.towncrier`` namespace. 13 | Please see https://toml.io/ for how to write TOML. 14 | 15 | A minimal configuration for a Python project looks like this: 16 | 17 | .. code-block:: toml 18 | 19 | # pyproject.toml 20 | [project] 21 | name = "myproject" 22 | 23 | A minimal configuration for a non-Python project looks like this: 24 | 25 | .. code-block:: toml 26 | 27 | # towncrier.toml 28 | 29 | [tool.towncrier] 30 | name = "My Project" 31 | 32 | Top level keys 33 | ~~~~~~~~~~~~~~ 34 | 35 | ``name`` 36 | The name of your project. 37 | 38 | For Python projects that provide a ``package`` key, if left empty then the name will be automatically determined from the ``package`` key. 39 | 40 | Defaults to the key ``[project.name]`` in ``pyproject.toml`` (if present), otherwise defaults to the empty string ``""``. 41 | 42 | ``version`` 43 | The version of your project. 44 | 45 | Python projects that provide the ``package`` key, if left empty then the version will be automatically determined from the installed package's version metadata or a ``__version__`` variable in the package's module. 46 | 47 | If not provided or able to be determined, the version must be passed explicitly by the command line argument ``--version``. 48 | 49 | ``directory`` 50 | The directory storing your news fragments. 51 | 52 | For Python projects that provide a ``package`` key, the default is a ``newsfragments`` directory within the package. 53 | Otherwise the default is a ``newsfragments`` directory relative to either the directory passed as ``--dir`` or (by default) the configuration file. 54 | 55 | ``filename`` 56 | The filename of your news file. 57 | 58 | ``"NEWS.rst"`` by default. 59 | Its location is determined the same way as the location of the directory storing the news fragments. 60 | 61 | ``template`` 62 | Path to the template for generating the news file. 63 | 64 | If the path looks like ``:``, it is interpreted as a template bundled with an installed Python package. 65 | 66 | ``"towncrier:default.rst"`` by default unless ``filename`` ends with ``.md``, in which case the default is ``"towncrier:default.md"``. 67 | 68 | ``start_string`` 69 | The magic string that ``towncrier`` looks for when considering where the release notes should start. 70 | 71 | ``".. towncrier release notes start\n"`` by default unless ``filename`` ends with ``.md``, in which case the default is ``"\n"``. 72 | 73 | ``title_format`` 74 | A format string for the title of your project. 75 | 76 | The explicit value of ``False`` will disable the title entirely. 77 | Any other empty value means the template should render the title (the bundled templates use `` ()``). 78 | Strings should use the following keys to render the title dynamically: ``{name}``, ``{version}``, and ``{project_date}``. 79 | 80 | ``""`` by default. 81 | 82 | When using reStructuredText, formatted titles are underlined using the ``underlines`` configuration. 83 | For titles, the first value from ``underlines`` is used to create the underline (which is inserted on the line following the title). 84 | If the template has an ``.md`` suffix, we assume we are looking at markdown format and the title is applied as, i.e. full control over the title format is given to the user. 85 | The top header level is inferred from this, e.g. ``title_format = "(v{version})=\n### {version}"`` will render the title at level 3, categories at level 4, and so on. 86 | 87 | ``issue_format`` 88 | A format string for rendering the issue/ticket number in newsfiles. 89 | 90 | If none, the issues are rendered as ``#`` if for issues that are integers, or just ```` otherwise. 91 | Use the ``{issue}`` key in your string render the issue number, for example Markdown projects may want to use ``"[{issue}]: https:///{issue}"``. 92 | 93 | ``None`` by default. 94 | 95 | ``underlines`` 96 | The characters used for underlining headers. 97 | 98 | Not used in the bundled Markdown template. 99 | 100 | ``["=", "-", "~"]`` by default. 101 | 102 | ``wrap`` 103 | Boolean value indicating whether to wrap news fragments to a line length of 79. 104 | 105 | ``false`` by default. 106 | 107 | ``all_bullets`` 108 | Boolean value indicating whether the template uses bullets for each news fragment. 109 | 110 | ``true`` by default. 111 | 112 | ``single_file`` 113 | Boolean value indicating whether to write all news fragments to a single file. 114 | 115 | If ``false``, the ``filename`` should use the following keys to render the filenames dynamically: 116 | ``{name}``, ``{version}``, and ``{project_date}``. 117 | 118 | ``true`` by default. 119 | 120 | ``orphan_prefix`` 121 | The prefix used for orphaned news fragments. 122 | 123 | ``"+"`` by default. 124 | 125 | ``create_eof_newline`` 126 | Ensure the content of a news fragment file created with ``towncrier create`` ends with an empty line. 127 | 128 | ``true`` by default. 129 | 130 | ``create_add_extension`` 131 | Add the ``filename`` option extension to news fragment files created with ``towncrier create`` if an extension is not explicitly provided. 132 | 133 | ``true`` by default. 134 | 135 | ``ignore`` 136 | A case-insensitive list of filenames in the news fragments directory to ignore. 137 | Wildcard matching is supported via the `fnmatch `_ function. 138 | 139 | ``None`` by default. 140 | 141 | ``towncrier check`` will fail if there are any news fragment files that have invalid filenames, except for those in the list. ``towncrier build`` will likewise fail, but only if this list has been configured (set to an empty list if there are no files to ignore). 142 | 143 | The following filenames are automatically ignored, case insensitive. 144 | 145 | - ``.gitignore`` 146 | - ``.gitkeep`` 147 | - ``.keep`` 148 | - ``README`` 149 | - ``README.md`` 150 | - ``README.rst`` 151 | - the template file itself 152 | 153 | ``issue_pattern`` 154 | Ensure the issue name (file name excluding the category and suffix) matches a certain regex pattern. 155 | Make sure to use escape characters properly (e.g. "\\d+" for digit-only file names). 156 | When emptry (``""``), all issue names will be considered valid. 157 | 158 | ``""`` by default. 159 | 160 | Extra top level keys for Python projects 161 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 162 | 163 | ``package`` 164 | The Python package name of your project. 165 | 166 | Allows ``name`` and ``version`` to be automatically determined from the Python package. 167 | Changes the default ``directory`` to be a ``newsfragments`` directory within this package. 168 | 169 | Defaults to the key ``[project.name]`` in ``pyproject.toml`` (if present), otherwise defaults to the empty string ``""``. 170 | 171 | ``package_dir`` 172 | The folder your package lives. 173 | 174 | ``"."`` by default, some projects might need to use ``"src"``. 175 | 176 | 177 | Sections 178 | -------- 179 | 180 | ``towncrier`` supports splitting fragments into multiple sections, each with its own news of fragment types. 181 | 182 | Add an array of tables your ``.toml`` configuration file named ``[[tool.towncrier.section]]``. 183 | 184 | Each table within this array has the following mandatory keys: 185 | 186 | 187 | ``name`` 188 | The name of the section. 189 | 190 | ``path`` 191 | The path to the directory containing the news fragments for this section, relative to the configured ``directory``. 192 | Use ``""`` for the root directory. 193 | 194 | For example: 195 | 196 | .. code-block:: toml 197 | 198 | [[tool.towncrier.section]] 199 | name = "Main Platform" 200 | path = "" 201 | 202 | [[tool.towncrier.section]] 203 | name = "Secondary" 204 | path = "secondary" 205 | 206 | Section Path Behaviour 207 | ~~~~~~~~~~~~~~~~~~~~~~ 208 | 209 | The path behaviour is slightly different depending on whether ``directory`` is explicitly set. 210 | 211 | If ``directory`` is not set, "newsfragments" is added to the end of each path. For example, with the above sections, the paths would be: 212 | 213 | :Main Platform: ./newsfragments 214 | :Secondary: ./secondary/newsfragments 215 | 216 | If ``directory`` *is* set, the section paths are appended to this path. For example, with ``directory = "changes"`` and the above sections, the paths would be: 217 | 218 | :Main Platform: ./changes 219 | :Secondary: ./changes/secondary 220 | 221 | 222 | Custom fragment types 223 | --------------------- 224 | 225 | ``towncrier`` has the following default fragment types: ``feature``, ``bugfix``, ``doc``, ``removal``, and ``misc``. 226 | 227 | You can use either of the two following method to define custom types instead (you will need to redefine any of the default types you want to use). 228 | 229 | 230 | Use TOML tables (alphabetical order) 231 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 232 | 233 | Adding tables to your ``.toml`` configuration file named ``[tool.towncrier.fragment.]``. 234 | 235 | These may include the following optional keys: 236 | 237 | 238 | ``name`` 239 | The description of the fragment type, as it must be included in the news file. 240 | 241 | Defaults to its fragment type, but capitalized. 242 | 243 | ``showcontent`` 244 | A boolean value indicating whether the fragment contents should be included in the news file. 245 | 246 | ``true`` by default. 247 | 248 | .. note:: 249 | 250 | Orphan fragments (those without an issue number) always have their content included. 251 | If a fragment was created, it means that information is important for end users. 252 | 253 | ``check`` 254 | A boolean value indicating whether the fragment should be considered by the ``towncrier check`` command. 255 | 256 | ``true`` by default. 257 | 258 | For example, if you want your custom fragment types to be ``["feat", "fix", "chore",]`` and you want all of them to use the default configuration except ``"chore"`` you can do it as follows: 259 | 260 | .. code-block:: toml 261 | 262 | [tool.towncrier] 263 | 264 | [tool.towncrier.fragment.feat] 265 | [tool.towncrier.fragment.fix] 266 | 267 | [tool.towncrier.fragment.chore] 268 | name = "Other Tasks" 269 | showcontent = false 270 | 271 | [tool.towncrier.fragment.deps] 272 | name = "Dependency Changes" 273 | check = false 274 | 275 | 276 | .. warning:: 277 | 278 | Since TOML mappings aren't ordered, types defined using this method are always rendered alphabetically. 279 | 280 | 281 | Use a TOML Array (defined order) 282 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 283 | 284 | Add an array of tables to your ``.toml`` configuration file named ``[[tool.towncrier.type]]``. 285 | 286 | If you use this way to configure custom fragment types, ensure there is no ``tool.towncrier.fragment`` table. 287 | 288 | Each table within this array has the following mandatory keys: 289 | 290 | 291 | ``name`` (required) 292 | The description of the fragment type, as it must be included 293 | in the news file. 294 | 295 | ``directory`` 296 | The type / category of the fragment. 297 | 298 | Defaults to ``name.lower()``. 299 | 300 | ``showcontent`` 301 | A boolean value indicating whether the fragment contents should be included in the news file. 302 | 303 | ``true`` by default. 304 | 305 | .. note:: 306 | 307 | Orphan fragments (those without an issue number) always have their content included. 308 | If a fragment was created, it means that information is important for end users. 309 | 310 | ``check`` 311 | A boolean value indicating whether the fragment should be considered by the ``towncrier check`` command. 312 | 313 | ``true`` by default. 314 | 315 | For example: 316 | 317 | .. code-block:: toml 318 | 319 | [tool.towncrier] 320 | [[tool.towncrier.type]] 321 | name = "Deprecations" 322 | 323 | [[tool.towncrier.type]] 324 | directory = "chore" 325 | name = "Other Tasks" 326 | showcontent = false 327 | 328 | [[tool.towncrier.type]] 329 | directory = "deps" 330 | name = "Dependency Changes" 331 | showcontent = true 332 | check = false 333 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/customization/index.rst: -------------------------------------------------------------------------------- 1 | Customizing ``towncrier`` 2 | ========================= 3 | 4 | ``towncrier`` can be customized to suit your project's needs. 5 | These pages should describe common customization tasks, while if you want a reference, see `Configuration <../configuration.html>`_. 6 | 7 | .. toctree:: 8 | :maxdepth: 2 9 | 10 | newsfile 11 | -------------------------------------------------------------------------------- /docs/customization/newsfile.rst: -------------------------------------------------------------------------------- 1 | Customizing the News File Output 2 | ================================ 3 | 4 | Adding Content Above ``towncrier`` 5 | ---------------------------------- 6 | 7 | If you wish to have content at the top of the news file (for example, to say where you can find the issues), you can use a special rST comment to tell ``towncrier`` to only update after it. 8 | In your existing news file (e.g. ``NEWS.rst``), add the following line above where you want ``towncrier`` to put content: 9 | 10 | .. code-block:: restructuredtext 11 | 12 | .. towncrier release notes start 13 | 14 | In an existing news file, it'll look something like this: 15 | 16 | .. code-block:: restructuredtext 17 | 18 | This is the changelog of my project. You can find the 19 | issue tracker at http://blah. 20 | 21 | .. towncrier release notes start 22 | 23 | myproject 1.0.2 (2018-01-01) 24 | ============================ 25 | 26 | Bugfixes 27 | -------- 28 | 29 | - Fixed, etc... 30 | 31 | ``towncrier`` will not alter content above the comment. 32 | 33 | Markdown 34 | ~~~~~~~~ 35 | 36 | If your news file is in Markdown (e.g. ``NEWS.md``), use the following comment instead: 37 | 38 | .. code-block:: html 39 | 40 | 41 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | :end-before: To get started, 3 | 4 | 5 | Documentation 6 | ------------- 7 | 8 | Narrative 9 | ~~~~~~~~~ 10 | 11 | .. toctree:: 12 | :maxdepth: 1 13 | 14 | tutorial 15 | markdown 16 | monorepo 17 | 18 | 19 | Reference 20 | ~~~~~~~~~ 21 | 22 | .. toctree:: 23 | :maxdepth: 2 24 | 25 | cli 26 | configuration 27 | pre-commit 28 | customization/index 29 | 30 | 31 | Development 32 | ~~~~~~~~~~~ 33 | 34 | .. toctree:: 35 | :maxdepth: 1 36 | 37 | release-notes 38 | contributing 39 | release 40 | GitHub Repository 41 | 42 | .. include:: ../README.rst 43 | :start-after: .. links 44 | -------------------------------------------------------------------------------- /docs/markdown.rst: -------------------------------------------------------------------------------- 1 | How to Keep a Changelog in Markdown 2 | =================================== 3 | 4 | `Keep a Changelog `_ is a standardized way to format a news file in `Markdown `_. 5 | 6 | This guide shows you how to configure ``towncrier`` for keeping a Markdown-based news file of a project without using any Python-specific features. 7 | Everything used here can be used with any other language or platform. 8 | 9 | This guide makes the following assumptions: 10 | 11 | - The project lives at https://github.com/twisted/my-project/. 12 | - The news file name is ``CHANGELOG.md``. 13 | - You store the news fragments in the ``changelog.d`` directory at the root of the project. 14 | 15 | Put the following into your ``pyproject.toml`` or ``towncrier.toml``: 16 | 17 | .. code-block:: toml 18 | 19 | [tool.towncrier] 20 | directory = "changelog.d" 21 | filename = "CHANGELOG.md" 22 | start_string = "\n" 23 | underlines = ["", "", ""] 24 | title_format = "## [{version}](https://github.com/twisted/my-project/tree/{version}) - {project_date}" 25 | issue_format = "[#{issue}](https://github.com/twisted/my-project/issues/{issue})" 26 | 27 | [[tool.towncrier.type]] 28 | name = "Security" 29 | 30 | [[tool.towncrier.type]] 31 | name = "Removed" 32 | 33 | [[tool.towncrier.type]] 34 | name = "Deprecated" 35 | 36 | [[tool.towncrier.type]] 37 | name = "Added" 38 | 39 | [[tool.towncrier.type]] 40 | name = "Changed" 41 | 42 | [[tool.towncrier.type]] 43 | name = "Fixed" 44 | 45 | 46 | 47 | Next create the news fragment directory: 48 | 49 | .. code-block:: console 50 | 51 | $ mkdir changelog.d 52 | 53 | Next, create the news file with an explanatory header:: 54 | 55 | $ cat >CHANGELOG.md <. 63 | 64 | 65 | 66 | 67 | EOF 68 | 69 | .. note:: 70 | 71 | The two empty lines at the end are on purpose. 72 | 73 | That's it! 74 | You can start adding news fragments: 75 | 76 | .. code-block:: console 77 | 78 | towncrier create -c "Added a cool feature!" 1.added.md 79 | towncrier create -c "Changed a behavior!" 2.changed.md 80 | towncrier create -c "Deprecated a module!" 3.deprecated.md 81 | towncrier create -c "Removed a square feature!" 4.removed.md 82 | towncrier create -c "Fixed a bug!" 5.fixed.md 83 | towncrier create -c "Fixed a security issue!" 6.security.md 84 | towncrier create -c "Fixed a security issue!" 7.security.md 85 | towncrier create -c "A fix without an issue number!" +something-unique.fixed.md 86 | 87 | 88 | After running ``towncrier build --yes --version 1.0.0`` (you can ignore the Git error messages) your ``CHANGELOG.md`` looks like this: 89 | 90 | .. code-block:: markdown 91 | 92 | # Changelog 93 | 94 | All notable changes to this project will be documented in this file. 95 | 96 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 97 | 98 | This project uses [*towncrier*](https://towncrier.readthedocs.io/) and the changes for the upcoming release can be found in . 99 | 100 | 101 | 102 | ## [1.0.0](https://github.com/twisted/my-project/tree/1.0.0) - 2022-09-28 103 | 104 | 105 | ### Security 106 | 107 | - Fixed a security issue! ([#6](https://github.com/twisted/my-project/issues/6), [#7](https://github.com/twisted/my-project/issues/7)) 108 | 109 | 110 | ### Removed 111 | 112 | - Removed a square feature! ([#4](https://github.com/twisted/my-project/issues/4)) 113 | 114 | 115 | ### Deprecated 116 | 117 | - Deprecated a module! ([#3](https://github.com/twisted/my-project/issues/3)) 118 | 119 | 120 | ### Added 121 | 122 | - Added a cool feature! ([#1](https://github.com/twisted/my-project/issues/1)) 123 | 124 | 125 | ### Changed 126 | 127 | - Changed a behavior! ([#2](https://github.com/twisted/my-project/issues/2)) 128 | 129 | 130 | ### Fixed 131 | 132 | - Fixed a bug! ([#5](https://github.com/twisted/my-project/issues/5)) 133 | - A fix without an issue number! 134 | 135 | Pretty close, so this concludes this guide! 136 | 137 | .. note:: 138 | 139 | - The sections are rendered in the order the fragment types are defined. 140 | - Because ``towncrier`` doesn't have a concept of a "previous version" (yet), the version links will point to the release tags and not to the ``compare`` link like in *Keep a Changelog*. 141 | - *Keep a Changelog* doesn't have the concept of a uncategorized change, so the template doesn't expect any. 142 | -------------------------------------------------------------------------------- /docs/monorepo.rst: -------------------------------------------------------------------------------- 1 | Multiple Projects Share One Config (Monorepo) 2 | ============================================= 3 | 4 | Several projects may have independent release notes with the same format. 5 | For instance packages in a monorepo. 6 | Here's how you can use towncrier to set this up. 7 | 8 | Below is a minimal example: 9 | 10 | .. code-block:: text 11 | 12 | repo 13 | ├── project_a 14 | │ ├── newsfragments 15 | │ │ └── 123.added 16 | │ ├── project_a 17 | │ │ └── __init__.py 18 | │ └── NEWS.rst 19 | ├── project_b 20 | │ ├── newsfragments 21 | │ │ └── 120.bugfix 22 | │ ├── project_b 23 | │ │ └── __init__.py 24 | │ └── NEWS.rst 25 | └── towncrier.toml 26 | 27 | The ``towncrier.toml`` looks like this: 28 | 29 | .. code-block:: toml 30 | 31 | [tool.towncrier] 32 | # It's important to keep these config fields empty 33 | # because we have more than one package/name to manage. 34 | package = "" 35 | name = "" 36 | 37 | Now to add a fragment: 38 | 39 | .. code-block:: console 40 | 41 | towncrier create --config towncrier.toml --dir project_a 124.added 42 | 43 | This should create a file at ``project_a/newsfragments/124.added``. 44 | 45 | To build the news file for the same project: 46 | 47 | .. code-block:: console 48 | 49 | towncrier build --config towncrier.toml --dir project_a --version 1.5 50 | 51 | Note that we must explicitly pass ``--version``, there is no other way to get the version number. 52 | The ``towncrier.toml`` can only contain one version number and the ``package`` field is of no use for the same reason. 53 | -------------------------------------------------------------------------------- /docs/pre-commit.rst: -------------------------------------------------------------------------------- 1 | pre-commit 2 | ========== 3 | 4 | ``towncrier`` can also be used in your `pre-commit `_ configuration (``.pre-commit-config.yaml``) to check and/or update your news fragments on commit or during CI processes. 5 | 6 | No additional configuration is needed in your ``towncrier`` configuration; the hook will read from the appropriate configuration files in your project. 7 | 8 | 9 | Examples 10 | -------- 11 | 12 | Usage with the default configuration 13 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 14 | 15 | .. code-block:: yaml 16 | 17 | repos: 18 | - repo: https://github.com/twisted/towncrier 19 | rev: 23.11.0 # run 'pre-commit autoupdate' to update 20 | hooks: 21 | - id: towncrier-check 22 | 23 | 24 | Usage with custom configuration and directories 25 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 26 | 27 | News fragments are stored in ``changelog.d/`` in the root of the repository and we want to keep the news fragments when running ``update``: 28 | 29 | .. code-block:: yaml 30 | 31 | repos: 32 | - repo: https://github.com/twisted/towncrier 33 | rev: 23.11.0 # run 'pre-commit autoupdate' to update 34 | hooks: 35 | - id: towncrier-update 36 | files: $changelog\.d/ 37 | args: ['--keep'] 38 | -------------------------------------------------------------------------------- /docs/release-notes.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../NEWS.rst 2 | -------------------------------------------------------------------------------- /docs/release.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../RELEASE.rst 2 | -------------------------------------------------------------------------------- /docs/tutorial.rst: -------------------------------------------------------------------------------- 1 | Tutorial 2 | ======== 3 | 4 | This tutorial assumes you have a Python project with a *reStructuredText* (rst) or *Markdown* (md) news file (also known as changelog) that you wish to use ``towncrier`` on, to generate its news file. 5 | It will cover setting up your project with a basic configuration, which you can then feel free to `customize `_. 6 | 7 | Install from PyPI:: 8 | 9 | python3 -m pip install towncrier 10 | 11 | 12 | Configuration 13 | ------------- 14 | 15 | ``towncrier`` keeps its config in the `PEP-518 `_ ``pyproject.toml`` or a ``towncrier.toml`` file. 16 | If the latter exists, it takes precedence. 17 | 18 | The most basic configuration is just telling ``towncrier`` where to look for news fragments and what file to generate:: 19 | 20 | [tool.towncrier] 21 | directory = "changes" 22 | # Where you want your news files to come out, `NEWS.rst` is the default. 23 | # This can be .rst or .md, towncrier's default template works with both. 24 | # filename = "NEWS.rst" 25 | 26 | Which will look into "./changes" for news fragments and write them into "./NEWS.rst". 27 | 28 | If you're working on a Python project, you can also specify a package:: 29 | 30 | [tool.towncrier] 31 | # The name of your Python package 32 | package = "myproject" 33 | # The path to your Python package. 34 | # If your package lives in 'src/myproject/', it must be 'src', 35 | # but if you don't keep your code in a 'src' dir, remove the 36 | # config option 37 | package_dir = "src" 38 | 39 | By default, ``towncrier`` will look for news fragments inside your Python package, in a directory named ``newsfragments``. 40 | With this example project, it will look in ``src/myproject/newsfragments/`` for them. 41 | 42 | Create this folder:: 43 | 44 | $ mkdir src/myproject/newsfragments/ 45 | # This makes sure that Git will never delete the empty folder 46 | $ echo '!.gitignore' > src/myproject/newsfragments/.gitignore 47 | 48 | 49 | Detecting Version 50 | ----------------- 51 | 52 | ``towncrier`` needs to know what version your project is when generating news files. 53 | These are the ways you can provide it, in order of precedence (with the first taking precedence over the second, and so on): 54 | 55 | 1. Manually pass ``--version=`` when interacting with ``towncrier``. 56 | 2. Set a value for the ``version`` option in your configuration file. 57 | 3. For Python projects with a ``package`` key in the configuration file: 58 | 59 | - install the package to use its metadata version information 60 | - add a ``__version__`` in the top level package that is either a string literal, a tuple, or an `Incremental `_ version 61 | 62 | As an example, you can manually specify the version when calling ``towncrier`` on the command line with the ``--version`` flag:: 63 | 64 | $ towncrier build --version=1.2.3post4 65 | 66 | 67 | Setting Date 68 | ------------ 69 | 70 | ``towncrier`` will also include the current date (in ``YYYY-MM-DD`` format) when generating news files. 71 | You can change this with the ``--date`` flag:: 72 | 73 | $ towncrier build --date=2018-01-01 74 | 75 | 76 | Creating News Fragments 77 | ----------------------- 78 | 79 | ``towncrier`` news fragments are categorised according to their 'type'. 80 | There are five default types, but you can configure them freely (see `Configuration `_ for details). 81 | 82 | The five default types are: 83 | 84 | .. Keep in-sync with DefaultFragmentTypesLoader. 85 | 86 | - ``feature``: Signifying a new feature. 87 | - ``bugfix``: Signifying a bug fix. 88 | - ``doc``: Signifying a documentation improvement. 89 | - ``removal``: Signifying a deprecation or removal of public API. 90 | - ``misc``: An issue has been closed, but it is not of interest to users. 91 | 92 | When you create a news fragment, the filename consists of the issue/ticket ID (or some other unique identifier) as well as the 'type'. 93 | ``towncrier`` does not care about the fragment's suffix. 94 | 95 | You can create those fragments either by hand, or using the ``towncrier create`` command. 96 | Let's create some example news fragments to demonstrate:: 97 | 98 | $ echo 'Fixed a thing!' > src/myproject/newsfragments/1234.bugfix 99 | $ towncrier create --content 'Can also be ``rst`` as well!' 3456.doc.rst 100 | # You can associate multiple issue numbers with a news fragment by giving them the same contents. 101 | $ towncrier create --content 'Can also be ``rst`` as well!' 7890.doc.rst 102 | $ echo 'The final part is ignored, so set it to whatever you want.' > src/myproject/newsfragments/8765.removal.txt 103 | $ echo 'misc is special, and does not put the contents of the file in the newsfile.' > src/myproject/newsfragments/1.misc 104 | $ towncrier create --edit 2.misc.rst # starts an editor 105 | $ towncrier create -c "Orphan fragments have no issue ID." +random.bugfix.rst 106 | 107 | For orphan news fragments (those that don't need to be linked to any issue ID or other identifier), start the file name with ``+``. 108 | The content will still be included in the release notes, at the end of the category corresponding to the file extension:: 109 | 110 | $ echo 'Fixed an unreported thing!' > src/myproject/newsfragments/+anything.bugfix 111 | 112 | .. The --date is the date of towncrier's first release (15.0.0). 113 | 114 | We can then see our news fragments compiled by running ``towncrier`` in draft mode:: 115 | 116 | $ towncrier build --draft --name myproject --version 1.0.2 --date 2015-12-27 117 | 118 | You should get an output similar to this:: 119 | 120 | Loading template... 121 | Finding news fragments... 122 | Rendering news fragments... 123 | Draft only -- nothing has been written. 124 | What is seen below is what would be written. 125 | 126 | myproject 1.0.2 (2015-12-27) 127 | ============================ 128 | 129 | Bugfixes 130 | -------- 131 | 132 | - Fixed a thing! (#1234) 133 | - Orphan fragments have no issue ID. 134 | 135 | 136 | Improved Documentation 137 | ---------------------- 138 | 139 | - Can also be ``rst`` as well! (#3456, #7890) 140 | 141 | 142 | Deprecations and Removals 143 | ------------------------- 144 | 145 | - The final part is ignored, so set it to whatever you want. (#8765) 146 | 147 | 148 | Misc 149 | ---- 150 | 151 | - #1, #2 152 | 153 | Note: if you configure a Markdown file (for example, ``filename = "CHANGES.md"``) in your configuration file, the titles will be output in Markdown format instead. 154 | 155 | Note: all files (news fragments, the news file, the configuration file, and templates) are encoded and are expected to be encoded as UTF-8. 156 | 157 | 158 | Producing News Files In Production 159 | ---------------------------------- 160 | 161 | To produce the news file for real, run:: 162 | 163 | $ towncrier 164 | 165 | This command will remove the news files (with ``git rm``) and append the built news to the filename specified in ``pyproject.toml``, and then stage the news file changes (with ``git add``). 166 | It leaves committing the changes up to the user. 167 | 168 | If you wish to have content at the top of the news file (for example, to say where you can find the issues), put your text above a rST comment that says:: 169 | 170 | .. towncrier release notes start 171 | 172 | ``towncrier`` will then put the version notes after this comment, and leave your existing content that was above it where it is. 173 | 174 | Note: if you configure a Markdown file (for example, ``filename = "CHANGES.md"``) in your configuration file, the comment should be ```` instead. 175 | 176 | 177 | Finale 178 | ------ 179 | 180 | You should now have everything you need to get started with ``towncrier``! 181 | Please see `Customizing `_ for some common tasks, or `Configuration `_ for the full configuration specification. 182 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | 5 | import nox 6 | 7 | 8 | nox.options.sessions = ["pre_commit", "docs", "typecheck", "tests"] 9 | nox.options.reuse_existing_virtualenvs = True 10 | nox.options.error_on_external_run = True 11 | 12 | 13 | @nox.session 14 | def pre_commit(session: nox.Session) -> None: 15 | session.install("pre-commit") 16 | 17 | session.run("pre-commit", "run", "--all-files", "--show-diff-on-failure") 18 | 19 | 20 | # Keep list in-sync with ci.yml/test-linux & pyproject.toml 21 | @nox.session(python=["pypy3.10", "3.9", "3.10", "3.11", "3.12", "3.13"]) 22 | def tests(session: nox.Session) -> None: 23 | session.env["PYTHONWARNDEFAULTENCODING"] = "1" 24 | session.install("Twisted", "coverage[toml]") 25 | posargs = list(session.posargs) 26 | 27 | try: 28 | # Allow `--use-wheel path/to/wheel.whl` to be passed. 29 | i = session.posargs.index("--use-wheel") 30 | session.install(session.posargs[i + 1]) 31 | del posargs[i : i + 2] 32 | except ValueError: 33 | session.install(".") 34 | 35 | if not posargs: 36 | posargs = ["towncrier"] 37 | 38 | session.run("coverage", "run", "--module", "twisted.trial", *posargs) 39 | 40 | if os.environ.get("CI") != "true": 41 | session.notify("coverage_report") 42 | 43 | 44 | @nox.session 45 | def coverage_report(session: nox.Session) -> None: 46 | session.install("coverage[toml]") 47 | 48 | session.run("coverage", "combine") 49 | session.run("coverage", "report") 50 | 51 | 52 | @nox.session 53 | def check_newsfragment(session: nox.Session) -> None: 54 | session.install(".") 55 | session.run("python", "-m", "towncrier.check", "--compare-with", "origin/trunk") 56 | 57 | 58 | @nox.session 59 | def draft_newsfragment(session: nox.Session) -> None: 60 | session.install(".") 61 | session.run("python", "-m", "towncrier.build", "--draft") 62 | 63 | 64 | @nox.session 65 | def typecheck(session: nox.Session) -> None: 66 | # Click 8.1.4 is bad type hints -- lets not complicate packaging and only 67 | # pin here. 68 | session.install(".", "mypy", "click!=8.1.4") 69 | session.run("mypy", "src") 70 | 71 | 72 | @nox.session 73 | def docs(session: nox.Session) -> None: 74 | session.install(".[dev]") 75 | 76 | session.run( 77 | # fmt: off 78 | "python", "-m", "sphinx", 79 | "-T", "-E", 80 | "-W", "--keep-going", 81 | "-b", "html", 82 | "-d", "docs/_build/doctrees", 83 | "-D", "language=en", 84 | "docs", 85 | "docs/_build/html", 86 | # fmt: on 87 | ) 88 | 89 | 90 | @nox.session 91 | def build(session: nox.Session) -> None: 92 | session.install("build", "twine", "pkginfo>=1.12.0") 93 | 94 | # If no argument is passed, build builds an sdist and then a wheel from 95 | # that sdist. 96 | session.run("python", "-m", "build") 97 | 98 | session.run("twine", "check", "--strict", "dist/*") 99 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | 6 | [project] 7 | name = "towncrier" 8 | # For dev - 23.11.0.dev0 9 | # For RC - 23.11.0rc1 (release candidate starts at 1) 10 | # For final - 23.11.0 11 | # make sure to follow PEP440 12 | version = "24.8.0.dev0" 13 | description = "Building newsfiles for your project." 14 | readme = "README.rst" 15 | license = "MIT" 16 | # Keep version list in-sync with noxfile/tests & ci.yml/test-linux. 17 | classifiers = [ 18 | "Intended Audience :: Developers", 19 | "License :: OSI Approved :: MIT License", 20 | "Operating System :: POSIX :: Linux", 21 | "Operating System :: MacOS :: MacOS X", 22 | "Operating System :: Microsoft :: Windows", 23 | "Programming Language :: Python :: 3.9", 24 | "Programming Language :: Python :: 3.10", 25 | "Programming Language :: Python :: 3.11", 26 | "Programming Language :: Python :: 3.12", 27 | "Programming Language :: Python :: 3.13", 28 | "Programming Language :: Python :: Implementation :: CPython", 29 | "Programming Language :: Python :: Implementation :: PyPy", 30 | ] 31 | requires-python = ">=3.9" 32 | dependencies = [ 33 | "click", 34 | "importlib-resources>=5; python_version<'3.10'", 35 | "importlib-metadata>=4.6; python_version<'3.10'", 36 | "jinja2", 37 | "tomli; python_version<'3.11'", 38 | ] 39 | 40 | [project.optional-dependencies] 41 | dev = [ 42 | "packaging", 43 | "sphinx >= 5", 44 | "furo >= 2024.05.06", 45 | "twisted", 46 | "nox", 47 | ] 48 | 49 | [project.scripts] 50 | towncrier = "towncrier._shell:cli" 51 | 52 | [project.urls] 53 | Documentation = "https://towncrier.readthedocs.io/" 54 | Chat = "https://web.libera.chat/?channels=%23twisted" 55 | "Mailing list" = "https://mail.python.org/mailman3/lists/twisted.python.org/" 56 | Issues = "https://github.com/twisted/towncrier/issues" 57 | Repository = "https://github.com/twisted/towncrier" 58 | Tests = "https://github.com/twisted/towncrier/actions?query=branch%3Atrunk" 59 | Coverage = "https://codecov.io/gh/twisted/towncrier" 60 | Distribution = "https://pypi.org/project/towncrier" 61 | 62 | [tool.hatch.build] 63 | exclude = [ 64 | "admin", 65 | "bin", 66 | ".github", 67 | ".git-blame-ignore-revs", 68 | ".pre-commit-config.yaml", 69 | ".pre-commit-hooks.yaml", 70 | ".readthedocs.yaml", 71 | "src/towncrier/newsfragments", 72 | ] 73 | 74 | 75 | [tool.towncrier] 76 | package = "towncrier" 77 | package_dir = "src" 78 | filename = "NEWS.rst" 79 | issue_format = "`#{issue} `_" 80 | 81 | [[tool.towncrier.section]] 82 | path = "" 83 | 84 | [[tool.towncrier.type]] 85 | directory = "feature" 86 | name = "Features" 87 | showcontent = true 88 | 89 | [[tool.towncrier.type]] 90 | directory = "bugfix" 91 | name = "Bugfixes" 92 | showcontent = true 93 | 94 | [[tool.towncrier.type]] 95 | directory = "doc" 96 | name = "Improved Documentation" 97 | showcontent = true 98 | 99 | [[tool.towncrier.type]] 100 | directory = "removal" 101 | name = "Deprecations and Removals" 102 | showcontent = true 103 | 104 | [[tool.towncrier.type]] 105 | directory = "misc" 106 | name = "Misc" 107 | showcontent = false 108 | 109 | 110 | [tool.black] 111 | exclude = ''' 112 | 113 | ( 114 | /( 115 | \.eggs # exclude a few common directories in the 116 | | \.git # root of the project 117 | | \.nox 118 | | \.venv 119 | | \.env 120 | | env 121 | | _build 122 | | _trial_temp.* 123 | | build 124 | | dist 125 | | debian 126 | )/ 127 | ) 128 | ''' 129 | 130 | 131 | [tool.isort] 132 | profile = "attrs" 133 | line_length = 88 134 | 135 | 136 | [tool.ruff.isort] 137 | # Match isort's "attrs" profile 138 | lines-after-imports = 2 139 | lines-between-types = 1 140 | 141 | 142 | [tool.mypy] 143 | strict = true 144 | # 2022-09-04: Trial's API isn't annotated yet, which limits the usefulness of type-checking 145 | # the unit tests. Therefore they have not been annotated yet. 146 | exclude = '^src/towncrier/test/test_.*\.py$' 147 | 148 | [[tool.mypy.overrides]] 149 | module = 'towncrier.click_default_group' 150 | # Vendored module without type annotations. 151 | ignore_errors = true 152 | 153 | [tool.coverage.run] 154 | parallel = true 155 | branch = true 156 | source = ["towncrier"] 157 | 158 | [tool.coverage.paths] 159 | source = ["src", ".nox/tests-*/**/site-packages"] 160 | 161 | [tool.coverage.report] 162 | show_missing = true 163 | skip_covered = false 164 | exclude_lines = [ 165 | "pragma: no cover", 166 | "if TYPE_CHECKING:", 167 | # Empty functions of a Protocol definition (used in _vcs.py) can never be 168 | # executed. Ignoring them. 169 | ": \\.\\.\\.$", 170 | ] 171 | omit = [ 172 | "src/towncrier/__main__.py", 173 | "src/towncrier/test/*", 174 | "src/towncrier/click_default_group.py", 175 | ] 176 | -------------------------------------------------------------------------------- /src/towncrier/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Amber Brown, 2015 2 | # See LICENSE for details. 3 | 4 | """ 5 | towncrier, a builder for your news files. 6 | """ 7 | -------------------------------------------------------------------------------- /src/towncrier/__main__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from towncrier._shell import cli 4 | 5 | 6 | cli() 7 | -------------------------------------------------------------------------------- /src/towncrier/_git.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Amber Brown, 2015 2 | # See LICENSE for details. 3 | 4 | from __future__ import annotations 5 | 6 | import os 7 | 8 | from collections.abc import Container 9 | from subprocess import STDOUT, call, check_output 10 | from warnings import warn 11 | 12 | 13 | def get_default_compare_branch(branches: Container[str]) -> str | None: 14 | if "origin/main" in branches: 15 | return "origin/main" 16 | if "origin/master" in branches: 17 | warn( 18 | 'Using "origin/master" as default compare branch is deprecated ' 19 | "and will be removed in a future version.", 20 | DeprecationWarning, 21 | stacklevel=2, 22 | ) 23 | return "origin/master" 24 | return None 25 | 26 | 27 | def remove_files(fragment_filenames: list[str]) -> None: 28 | if not fragment_filenames: 29 | return 30 | 31 | # Filter out files that are unknown to git 32 | git_fragments = check_output( 33 | ["git", "ls-files"] + fragment_filenames, encoding="utf-8" 34 | ).split("\n") 35 | 36 | git_fragments = [os.path.abspath(f) for f in git_fragments if os.path.isfile(f)] 37 | call(["git", "rm", "--quiet", "--force"] + git_fragments) 38 | unknown_fragments = set(fragment_filenames) - set(git_fragments) 39 | for unknown_fragment in unknown_fragments: 40 | os.remove(unknown_fragment) 41 | 42 | 43 | def stage_newsfile(directory: str, filename: str) -> None: 44 | call(["git", "add", os.path.join(directory, filename)]) 45 | 46 | 47 | def get_remote_branches(base_directory: str) -> list[str]: 48 | output = check_output( 49 | ["git", "branch", "-r"], cwd=base_directory, encoding="utf-8", stderr=STDOUT 50 | ) 51 | 52 | return [branch.strip() for branch in output.strip().splitlines()] 53 | 54 | 55 | def list_changed_files_compared_to_branch( 56 | base_directory: str, compare_with: str, include_staged: bool 57 | ) -> list[str]: 58 | output = check_output( 59 | ["git", "diff", "--name-only", compare_with + "..."], 60 | cwd=base_directory, 61 | encoding="utf-8", 62 | stderr=STDOUT, 63 | ) 64 | filenames = output.strip().splitlines() 65 | if include_staged: 66 | output = check_output( 67 | ["git", "diff", "--name-only", "--cached"], 68 | cwd=base_directory, 69 | encoding="utf-8", 70 | stderr=STDOUT, 71 | ) 72 | filenames.extend(output.strip().splitlines()) 73 | 74 | return filenames 75 | -------------------------------------------------------------------------------- /src/towncrier/_hg.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) towncrier contributors, 2025 2 | # See LICENSE for details. 3 | 4 | from __future__ import annotations 5 | 6 | import os 7 | 8 | from collections.abc import Container 9 | from subprocess import STDOUT, call, check_output 10 | 11 | 12 | def get_default_compare_branch(branches: Container[str]) -> str | None: 13 | if "default" in branches: 14 | return "default" 15 | return None 16 | 17 | 18 | def remove_files(fragment_filenames: list[str]) -> None: 19 | if not fragment_filenames: 20 | return 21 | 22 | # Filter out files that are unknown to mercurial 23 | hg_fragments = ( 24 | check_output(["hg", "files"] + fragment_filenames, encoding="utf-8") 25 | .strip() 26 | .split("\n") 27 | ) 28 | 29 | hg_fragments = [os.path.abspath(f) for f in hg_fragments if os.path.isfile(f)] 30 | fragment_filenames = [ 31 | os.path.abspath(f) for f in fragment_filenames if os.path.isfile(f) 32 | ] 33 | call(["hg", "rm", "--force"] + hg_fragments, encoding="utf-8") 34 | unknown_fragments = set(fragment_filenames) - set(hg_fragments) 35 | for unknown_fragment in unknown_fragments: 36 | os.remove(unknown_fragment) 37 | 38 | 39 | def stage_newsfile(directory: str, filename: str) -> None: 40 | call(["hg", "add", os.path.join(directory, filename)]) 41 | 42 | 43 | def get_remote_branches(base_directory: str) -> list[str]: 44 | branches = check_output( 45 | ["hg", "branches", "--template", "{branch}\n"], 46 | cwd=base_directory, 47 | encoding="utf-8", 48 | ).splitlines() 49 | 50 | return branches 51 | 52 | 53 | def list_changed_files_compared_to_branch( 54 | base_directory: str, compare_with: str, include_staged: bool 55 | ) -> list[str]: 56 | output = check_output( 57 | ["hg", "diff", "--stat", "-r", compare_with], 58 | cwd=base_directory, 59 | encoding="utf-8", 60 | stderr=STDOUT, 61 | ).splitlines() 62 | 63 | return [line.split("|")[0].strip() for line in output if "|" in line] 64 | -------------------------------------------------------------------------------- /src/towncrier/_novcs.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) towncrier contributors, 2025 2 | # See LICENSE for details. 3 | 4 | from __future__ import annotations 5 | 6 | import os 7 | 8 | from collections.abc import Container 9 | 10 | 11 | def get_default_compare_branch(branches: Container[str]) -> str | None: 12 | return None 13 | 14 | 15 | def remove_files(fragment_filenames: list[str]) -> None: 16 | if not fragment_filenames: 17 | return 18 | 19 | for fragment in fragment_filenames: 20 | os.remove(fragment) 21 | 22 | 23 | def stage_newsfile(directory: str, filename: str) -> None: 24 | return 25 | 26 | 27 | def get_remote_branches(base_directory: str) -> list[str]: 28 | return [] 29 | 30 | 31 | def list_changed_files_compared_to_branch( 32 | base_directory: str, compare_with: str, include_staged: bool 33 | ) -> list[str]: 34 | return [] 35 | -------------------------------------------------------------------------------- /src/towncrier/_project.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Amber Brown, 2015 2 | # See LICENSE for details. 3 | 4 | """ 5 | Responsible for getting the version and name from a project. 6 | """ 7 | 8 | from __future__ import annotations 9 | 10 | import contextlib 11 | import importlib.metadata 12 | import sys 13 | 14 | from importlib import import_module 15 | from importlib.metadata import PackageNotFoundError 16 | from types import ModuleType 17 | 18 | 19 | def _get_package(package_dir: str, package: str) -> ModuleType: 20 | try: 21 | module = import_module(package) 22 | except ImportError: 23 | # Package is not already available / installed. 24 | # Force importing it based on the source files. 25 | sys.path.insert(0, package_dir) 26 | 27 | try: 28 | module = import_module(package) 29 | except ImportError as e: 30 | err = f"tried to import {package}, but ran into this error: {e}" 31 | # NOTE: this might be redirected via "towncrier --draft > …". 32 | print(f"ERROR: {err}") 33 | raise 34 | finally: 35 | sys.path.pop(0) 36 | 37 | return module 38 | 39 | 40 | def _get_metadata_version(package: str) -> str | None: 41 | """ 42 | Try to get the version from the package metadata. 43 | """ 44 | with contextlib.suppress(PackageNotFoundError): 45 | if version := importlib.metadata.version(package): 46 | return version 47 | 48 | return None 49 | 50 | 51 | def get_version(package_dir: str, package: str) -> str: 52 | """ 53 | Get the version of a package. 54 | 55 | Try to extract the version from the distribution version metadata that matches 56 | `package`, then fall back to looking for the package in `package_dir`. 57 | """ 58 | version: str | None 59 | 60 | # First try to get the version from the package metadata. 61 | if version := _get_metadata_version(package): 62 | return version 63 | 64 | # When no version if found, fall back to looking for the package in `package_dir`. 65 | module = _get_package(package_dir, package) 66 | version = getattr(module, "__version__", None) 67 | if not version: 68 | raise Exception( 69 | f"No __version__ or metadata version info for the '{package}' package." 70 | ) 71 | 72 | if isinstance(version, str): 73 | return version.strip() 74 | 75 | if isinstance(version, tuple): 76 | return ".".join(map(str, version)).strip() 77 | 78 | # Try duck-typing as an Incremental version. 79 | if hasattr(version, "base"): 80 | try: 81 | version = str(version.base()).strip() 82 | # Incremental uses `X.Y.rcN`. 83 | # Standardize on importlib (and PEP440) use of `X.YrcN`: 84 | return version.replace(".rc", "rc") 85 | except TypeError: 86 | pass 87 | 88 | raise Exception( 89 | "Version must be a string, tuple, or an Incremental Version." 90 | " If you can't provide that, use the --version argument and specify one." 91 | ) 92 | 93 | 94 | def get_project_name(package_dir: str, package: str) -> str: 95 | module = _get_package(package_dir, package) 96 | version = getattr(module, "__version__", None) 97 | # Incremental has support for package names, try duck-typing it. 98 | with contextlib.suppress(AttributeError): 99 | return str(version.package) # type: ignore 100 | 101 | return package.title() 102 | -------------------------------------------------------------------------------- /src/towncrier/_settings/__init__.py: -------------------------------------------------------------------------------- 1 | """Subpackage to handle settings parsing.""" 2 | 3 | from __future__ import annotations 4 | 5 | from towncrier._settings import load 6 | 7 | 8 | load_config = load.load_config 9 | ConfigError = load.ConfigError 10 | load_config_from_options = load.load_config_from_options 11 | 12 | # Help message for --config CLI option, shared by all sub-commands. 13 | config_option_help = ( 14 | "Pass a custom config file at FILE_PATH. " 15 | "Default: towncrier.toml or pyproject.toml file, " 16 | "if both files exist, the first will take precedence." 17 | ) 18 | 19 | __all__ = [ 20 | "config_option_help", 21 | "load_config", 22 | "ConfigError", 23 | "load_config_from_options", 24 | ] 25 | -------------------------------------------------------------------------------- /src/towncrier/_settings/fragment_types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | 5 | from collections.abc import Iterable, Mapping 6 | from typing import Any 7 | 8 | 9 | class BaseFragmentTypesLoader: 10 | """Base class to load fragment types.""" 11 | 12 | __metaclass__ = abc.ABCMeta 13 | 14 | def __init__(self, config: Mapping[str, Any]): 15 | """Initialize.""" 16 | self.config = config 17 | 18 | @classmethod 19 | def factory(cls, config: Mapping[str, Any]) -> BaseFragmentTypesLoader: 20 | fragment_types_class: type[BaseFragmentTypesLoader] = DefaultFragmentTypesLoader 21 | fragment_types = config.get("fragment", {}) 22 | types_config = config.get("type", {}) 23 | if fragment_types: 24 | fragment_types_class = TableFragmentTypesLoader 25 | elif types_config: 26 | fragment_types_class = ArrayFragmentTypesLoader 27 | 28 | new = fragment_types_class(config) 29 | return new 30 | 31 | @abc.abstractmethod 32 | def load(self) -> Mapping[str, Mapping[str, Any]]: 33 | """Load fragment types.""" 34 | 35 | 36 | class DefaultFragmentTypesLoader(BaseFragmentTypesLoader): 37 | """Default towncrier's fragment types.""" 38 | 39 | _default_types = { 40 | # Keep in-sync with docs/tutorial.rst. 41 | "feature": {"name": "Features", "showcontent": True, "check": True}, 42 | "bugfix": {"name": "Bugfixes", "showcontent": True, "check": True}, 43 | "doc": {"name": "Improved Documentation", "showcontent": True, "check": True}, 44 | "removal": { 45 | "name": "Deprecations and Removals", 46 | "showcontent": True, 47 | "check": True, 48 | }, 49 | "misc": {"name": "Misc", "showcontent": False, "check": True}, 50 | } 51 | 52 | def load(self) -> Mapping[str, Mapping[str, Any]]: 53 | """Load default types.""" 54 | return self._default_types 55 | 56 | 57 | class ArrayFragmentTypesLoader(BaseFragmentTypesLoader): 58 | """Load fragment types from an toml array of tables. 59 | 60 | This loader get the custom fragment types defined through a 61 | toml array of tables, that ``toml`` parses as an array 62 | of mappings. 63 | 64 | For example:: 65 | 66 | [tool.towncrier] 67 | [[tool.towncrier.type]] 68 | directory = "deprecation" 69 | name = "Deprecations" 70 | showcontent = true 71 | 72 | """ 73 | 74 | def load(self) -> Mapping[str, Mapping[str, Any]]: 75 | """Load types from toml array of mappings.""" 76 | 77 | types = {} 78 | types_config = self.config["type"] 79 | for type_config in types_config: 80 | fragment_type_name = type_config["name"] 81 | directory = type_config.get("directory", fragment_type_name.lower()) 82 | is_content_required = type_config.get("showcontent", True) 83 | check = type_config.get("check", True) 84 | types[directory] = { 85 | "name": fragment_type_name, 86 | "showcontent": is_content_required, 87 | "check": check, 88 | } 89 | return types 90 | 91 | 92 | class TableFragmentTypesLoader(BaseFragmentTypesLoader): 93 | """Load fragment types from toml tables. 94 | 95 | This loader get the custom fragment types defined through a 96 | toml tables, that ``toml`` parses as an nested mapping. 97 | 98 | This loader allows omitting ``name`` and 99 | ```showcontent`` fields. 100 | ``name`` by default is the capitalized 101 | fragment type. 102 | ``showcontent`` is true by default. 103 | 104 | For example:: 105 | 106 | [tool.towncrier] 107 | [tool.towncrier.fragment.chore] 108 | name = "Chores" 109 | showcontent = False 110 | 111 | [tool.towncrier.fragment.deprecations] 112 | # name will be "Deprecations" 113 | # The content will be shown. 114 | 115 | """ 116 | 117 | def __init__(self, config: Mapping[str, Mapping[str, Any]]): 118 | """Initialize.""" 119 | self.config = config 120 | self.fragment_options = config.get("fragment", {}) 121 | 122 | def load(self) -> Mapping[str, Mapping[str, Any]]: 123 | """Load types from nested mapping.""" 124 | fragment_types: Iterable[str] = self.fragment_options.keys() 125 | fragment_types = sorted(fragment_types) 126 | custom_types_sequence = [ 127 | (fragment_type, self._load_options(fragment_type)) 128 | for fragment_type in fragment_types 129 | ] 130 | types = dict(custom_types_sequence) 131 | return types 132 | 133 | def _load_options(self, fragment_type: str) -> Mapping[str, Any]: 134 | """Load fragment options.""" 135 | capitalized_fragment_type = fragment_type.capitalize() 136 | options = self.fragment_options.get(fragment_type, {}) 137 | fragment_description = options.get("name", capitalized_fragment_type) 138 | show_content = options.get("showcontent", True) 139 | check = options.get("check", True) 140 | clean_fragment_options = { 141 | "name": fragment_description, 142 | "showcontent": show_content, 143 | "check": check, 144 | } 145 | return clean_fragment_options 146 | -------------------------------------------------------------------------------- /src/towncrier/_settings/load.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Amber Brown, 2015 2 | # See LICENSE for details. 3 | 4 | from __future__ import annotations 5 | 6 | import atexit 7 | import dataclasses 8 | import os 9 | import re 10 | import sys 11 | 12 | from collections.abc import Mapping, Sequence 13 | from contextlib import ExitStack 14 | from pathlib import Path 15 | from typing import Any, Literal 16 | 17 | from click import ClickException 18 | 19 | from .._settings import fragment_types as ft 20 | 21 | 22 | if sys.version_info < (3, 10): 23 | import importlib_resources as resources 24 | else: 25 | from importlib import resources 26 | 27 | 28 | if sys.version_info < (3, 11): 29 | import tomli as tomllib 30 | else: 31 | import tomllib 32 | 33 | 34 | re_resource_template = re.compile(r"[-\w.]+:[-\w.]+$") 35 | 36 | 37 | @dataclasses.dataclass 38 | class Config: 39 | sections: Mapping[str, str] 40 | types: Mapping[str, Mapping[str, Any]] 41 | template: str | tuple[str, str] 42 | start_string: str 43 | package: str = "" 44 | package_dir: str = "." 45 | single_file: bool = True 46 | filename: str = "NEWS.rst" 47 | directory: str | None = None 48 | version: str | None = None 49 | name: str = "" 50 | title_format: str | Literal[False] = "" 51 | issue_format: str | None = None 52 | underlines: Sequence[str] = ("=", "-", "~") 53 | wrap: bool = False 54 | all_bullets: bool = True 55 | orphan_prefix: str = "+" 56 | create_eof_newline: bool = True 57 | create_add_extension: bool = True 58 | ignore: list[str] | None = None 59 | issue_pattern: str = "" 60 | 61 | 62 | class ConfigError(ClickException): 63 | def __init__(self, *args: str, **kwargs: str): 64 | self.failing_option = kwargs.get("failing_option") 65 | super().__init__(*args) 66 | 67 | 68 | def load_config_from_options( 69 | directory: str | None, config_path: str | None 70 | ) -> tuple[str, Config]: 71 | """ 72 | Load the configuration from a given directory or specific configuration file. 73 | 74 | Unless an explicit configuration file is given, traverse back from the given 75 | directory looking for a configuration file. 76 | 77 | Returns a tuple of the base directory and the parsed Config instance. 78 | """ 79 | if config_path is None: 80 | return traverse_for_config(directory) 81 | 82 | config_path = os.path.abspath(config_path) 83 | 84 | # When a directory is provided (in addition to the config file), use it as the base 85 | # directory. Otherwise use the directory containing the config file. 86 | if directory is not None: 87 | base_directory = os.path.abspath(directory) 88 | else: 89 | base_directory = os.path.dirname(config_path) 90 | 91 | if not os.path.isfile(config_path): 92 | raise ConfigError(f"Configuration file '{config_path}' not found.") 93 | config = load_config_from_file(base_directory, config_path) 94 | 95 | return base_directory, config 96 | 97 | 98 | def traverse_for_config(path: str | None) -> tuple[str, Config]: 99 | """ 100 | Search for a configuration file in the current directory and all parent directories. 101 | 102 | Returns the directory containing the configuration file and the parsed configuration. 103 | """ 104 | start_directory = directory = os.path.abspath(path or os.getcwd()) 105 | while True: 106 | config = load_config(directory) 107 | if config is not None: 108 | return directory, config 109 | 110 | parent = os.path.dirname(directory) 111 | if parent == directory: 112 | raise ConfigError( 113 | f"No configuration file found.\nLooked back from: {start_directory}" 114 | ) 115 | directory = parent 116 | 117 | 118 | def load_config(directory: str) -> Config | None: 119 | towncrier_toml = os.path.join(directory, "towncrier.toml") 120 | pyproject_toml = os.path.join(directory, "pyproject.toml") 121 | 122 | # In case the [tool.towncrier.name|package] is not specified 123 | # we'll read it from [project.name] 124 | 125 | if os.path.exists(pyproject_toml): 126 | pyproject_config = load_toml_from_file(pyproject_toml) 127 | else: 128 | # make it empty so it won't be used as a backup plan 129 | pyproject_config = {} 130 | 131 | if os.path.exists(towncrier_toml): 132 | config_toml = towncrier_toml 133 | elif os.path.exists(pyproject_toml): 134 | config_toml = pyproject_toml 135 | else: 136 | return None 137 | 138 | # Read the default configuration. Depending on which exists 139 | config = load_config_from_file(directory, config_toml) 140 | 141 | # Fallback certain values depending on the [project.name] 142 | if project_name := pyproject_config.get("project", {}).get("name", ""): 143 | # Fallback to the project name for the configuration name 144 | # and the configuration package entries. 145 | if not config.package: 146 | config.package = project_name 147 | if not config.name: 148 | config.name = config.package 149 | 150 | return config 151 | 152 | 153 | def load_toml_from_file(config_file: str) -> Mapping[str, Any]: 154 | with open(config_file, "rb") as conffile: 155 | return tomllib.load(conffile) 156 | 157 | 158 | def load_config_from_file(directory: str, config_file: str) -> Config: 159 | config = load_toml_from_file(config_file) 160 | 161 | return parse_toml(directory, config) 162 | 163 | 164 | # Clean up possible temporary files on exit. 165 | _file_manager = ExitStack() 166 | atexit.register(_file_manager.close) 167 | 168 | 169 | def parse_toml(base_path: str, config: Mapping[str, Any]) -> Config: 170 | config = config.get("tool", {}).get("towncrier", {}) 171 | parsed_data = {} 172 | 173 | # Check for misspelt options. 174 | for typo, correct in [ 175 | ("singlefile", "single_file"), 176 | ]: 177 | if config.get(typo): 178 | raise ConfigError( 179 | f"`{typo}` is not a valid option. Did you mean `{correct}`?", 180 | failing_option=typo, 181 | ) 182 | 183 | # Process options. 184 | for field in dataclasses.fields(Config): 185 | if field.name in ("sections", "types", "template"): 186 | # Skip these options, they are processed later. 187 | continue 188 | if field.name in config: 189 | # Interestingly, the __future__ annotation turns the type into a string. 190 | if field.type in ("bool", bool): 191 | if not isinstance(config[field.name], bool): 192 | raise ConfigError( 193 | f"`{field.name}` option must be boolean: false or true.", 194 | failing_option=field.name, 195 | ) 196 | parsed_data[field.name] = config[field.name] 197 | 198 | # Process 'section'. 199 | sections = {} 200 | if "section" in config: 201 | for x in config["section"]: 202 | sections[x.get("name", "")] = x["path"] 203 | else: 204 | sections[""] = "" 205 | parsed_data["sections"] = sections 206 | 207 | # Process 'types'. 208 | fragment_types_loader = ft.BaseFragmentTypesLoader.factory(config) 209 | parsed_data["types"] = fragment_types_loader.load() 210 | 211 | # Process 'template'. 212 | markdown_file = Path(config.get("filename", "")).suffix == ".md" 213 | template = config.get("template", "towncrier:default") 214 | if re_resource_template.match(template): 215 | package, resource = template.split(":", 1) 216 | if not Path(resource).suffix: 217 | resource += ".md" if markdown_file else ".rst" 218 | 219 | if not _pkg_file_exists(package, resource): 220 | if _pkg_file_exists(package + ".templates", resource): 221 | package += ".templates" 222 | else: 223 | raise ConfigError( 224 | f"'{package}' does not have a template named '{resource}'.", 225 | failing_option="template", 226 | ) 227 | template = (package, resource) 228 | else: 229 | template = os.path.join(base_path, template) 230 | if not os.path.isfile(template): 231 | raise ConfigError( 232 | f"The template file '{template}' does not exist.", 233 | failing_option="template", 234 | ) 235 | 236 | parsed_data["template"] = template 237 | 238 | # Process 'start_string'. 239 | 240 | start_string = config.get("start_string", "") 241 | if not start_string: 242 | start_string_template = "\n" if markdown_file else ".. {}\n" 243 | start_string = start_string_template.format("towncrier release notes start") 244 | parsed_data["start_string"] = start_string 245 | 246 | # Return the parsed config. 247 | return Config(**parsed_data) 248 | 249 | 250 | def _pkg_file_exists(pkg: str, file: str) -> bool: 251 | """ 252 | Check whether *file* exists within *pkg*. 253 | """ 254 | return resources.files(pkg).joinpath(file).is_file() 255 | -------------------------------------------------------------------------------- /src/towncrier/_shell.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Stephen Finucane, 2019 2 | # See LICENSE for details. 3 | 4 | """ 5 | Entry point of the command line interface. 6 | 7 | Each sub-command has its separate CLI definition andd help messages. 8 | """ 9 | 10 | from __future__ import annotations 11 | 12 | import click 13 | 14 | from .build import _main as _build_cmd 15 | from .check import _main as _check_cmd 16 | from .click_default_group import DefaultGroup 17 | from .create import _main as _create_cmd 18 | 19 | 20 | @click.group(cls=DefaultGroup, default="build", default_if_no_args=True) 21 | @click.version_option() 22 | def cli() -> None: 23 | """ 24 | Towncrier is a utility to produce useful, summarised news files for your project. 25 | Rather than reading the Git history as some newer tools to produce it, or having 26 | one single file which developers all write to, towncrier reads "news fragments" 27 | which contain information useful to end users. 28 | 29 | Towncrier delivers the news which is convenient to those that hear it, not those 30 | that write it. 31 | 32 | That is, a “news fragment” (a small file containing just enough information to 33 | be useful to end users) can be written that summarises what has changed from the 34 | “developer log” (which may contain complex information about the original issue, 35 | how it was fixed, who authored the fix, and who reviewed the fix). By compiling 36 | a collection of these fragments, towncrier can produce a digest of the changes 37 | which is valuable to those who may wish to use the software. 38 | """ 39 | pass 40 | 41 | 42 | cli.add_command(_build_cmd) 43 | cli.add_command(_check_cmd) 44 | cli.add_command(_create_cmd) 45 | -------------------------------------------------------------------------------- /src/towncrier/_vcs.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) towncrier contributors, 2025 2 | # See LICENSE for details. 3 | 4 | from __future__ import annotations 5 | 6 | import os 7 | 8 | from collections.abc import Container 9 | from typing import Protocol 10 | 11 | 12 | class VCSMod(Protocol): 13 | def get_default_compare_branch(self, branches: Container[str]) -> str | None: ... 14 | def remove_files(self, fragment_filenames: list[str]) -> None: ... 15 | def stage_newsfile(self, directory: str, filename: str) -> None: ... 16 | def get_remote_branches(self, base_directory: str) -> list[str]: ... 17 | 18 | def list_changed_files_compared_to_branch( 19 | self, base_directory: str, compare_with: str, include_staged: bool 20 | ) -> list[str]: ... 21 | 22 | 23 | def _get_mod(base_directory: str) -> VCSMod: 24 | base_directory = os.path.abspath(base_directory) 25 | if os.path.exists(os.path.join(base_directory, ".git")): 26 | from . import _git 27 | 28 | return _git 29 | elif os.path.exists(os.path.join(base_directory, ".hg")): 30 | from . import _hg 31 | 32 | hg: VCSMod = _hg 33 | 34 | return hg 35 | else: 36 | # No VCS was found in the current directory 37 | # We will try our luck in the parent directory. 38 | parent = os.path.dirname(base_directory) 39 | if parent == base_directory: 40 | # We reached the fs root, abandoning 41 | from . import _novcs 42 | 43 | return _novcs 44 | 45 | return _get_mod(parent) 46 | 47 | 48 | def get_default_compare_branch( 49 | base_directory: str, branches: Container[str] 50 | ) -> str | None: 51 | return _get_mod(base_directory).get_default_compare_branch(branches) 52 | 53 | 54 | def remove_files(base_directory: str, fragment_filenames: list[str]) -> None: 55 | return _get_mod(base_directory).remove_files(fragment_filenames) 56 | 57 | 58 | def stage_newsfile(directory: str, filename: str) -> None: 59 | return _get_mod(directory).stage_newsfile(directory, filename) 60 | 61 | 62 | def get_remote_branches(base_directory: str) -> list[str]: 63 | return _get_mod(base_directory).get_remote_branches(base_directory) 64 | 65 | 66 | def list_changed_files_compared_to_branch( 67 | base_directory: str, compare_with: str, include_staged: bool 68 | ) -> list[str]: 69 | return _get_mod(base_directory).list_changed_files_compared_to_branch( 70 | base_directory, 71 | compare_with, 72 | include_staged, 73 | ) 74 | -------------------------------------------------------------------------------- /src/towncrier/_writer.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Amber Brown, 2015 2 | # See LICENSE for details. 3 | 4 | """ 5 | Responsible for writing the built news fragments to a news file without 6 | affecting existing content. 7 | """ 8 | 9 | from __future__ import annotations 10 | 11 | import sys 12 | 13 | from pathlib import Path 14 | from typing import Any 15 | 16 | 17 | if sys.version_info < (3, 10): 18 | # Compatibility shim for newline parameter to write_text, added in 3.10 19 | def _newline_write_text(path: Path, content: str, **kwargs: Any) -> None: 20 | with path.open("w", **kwargs) as strm: # pragma: no branch 21 | strm.write(content) 22 | 23 | else: 24 | 25 | def _newline_write_text(path: Path, content: str, **kwargs: Any) -> None: 26 | path.write_text(content, **kwargs) 27 | 28 | 29 | def append_to_newsfile( 30 | directory: str, 31 | filename: str, 32 | start_string: str, 33 | top_line: str, 34 | content: str, 35 | single_file: bool, 36 | ) -> None: 37 | """ 38 | Write *content* to *directory*/*filename* behind *start_string*. 39 | 40 | Double-check *top_line* (i.e. the release header) is not already in the 41 | file. 42 | 43 | if *single_file* is True, add it to an existing file, otherwise create a 44 | fresh one. 45 | """ 46 | news_file = Path(directory) / filename 47 | 48 | header, prev_body = _figure_out_existing_content( 49 | news_file, start_string, single_file 50 | ) 51 | 52 | if top_line and top_line in prev_body: 53 | raise ValueError("It seems you've already produced newsfiles for this version?") 54 | 55 | _newline_write_text( 56 | news_file, 57 | # If there is no previous body that means we're writing a brand new news file. 58 | # We don't want extra whitespace at the end of this new file. 59 | header + (content + prev_body if prev_body else content.rstrip() + "\n"), 60 | encoding="utf-8", 61 | # Leave newlines alone. This probably leads to inconsistent newlines, 62 | # because we've loaded existing content with universal newlines, but that's 63 | # the original behavior. 64 | newline="", 65 | ) 66 | 67 | 68 | def _figure_out_existing_content( 69 | news_file: Path, start_string: str, single_file: bool 70 | ) -> tuple[str, str]: 71 | """ 72 | Try to read *news_file* and split it into header (everything before 73 | *start_string*) and the old body (everything after *start_string*). 74 | 75 | If there's no *start_string*, return empty header. 76 | 77 | Empty file and per-release files have neither. 78 | """ 79 | if not single_file or not news_file.exists(): 80 | # Per-release news files always start empty. 81 | # Non-existent files have no existing content. 82 | return "", "" 83 | 84 | content = Path(news_file).read_text(encoding="utf-8") 85 | 86 | t = content.split(start_string, 1) 87 | if len(t) == 2: 88 | return f"{t[0].rstrip()}\n\n{start_string}\n", t[1].lstrip() 89 | 90 | return "", content.lstrip() 91 | -------------------------------------------------------------------------------- /src/towncrier/build.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Amber Brown, 2015 2 | # See LICENSE for details. 3 | 4 | """ 5 | Build a combined news file from news fragments. 6 | """ 7 | 8 | from __future__ import annotations 9 | 10 | import os 11 | import re 12 | import sys 13 | 14 | from datetime import date 15 | from pathlib import Path 16 | 17 | import click 18 | 19 | from click import Context, Option, UsageError 20 | 21 | from towncrier import _vcs 22 | 23 | from ._builder import find_fragments, render_fragments, split_fragments 24 | from ._project import get_project_name, get_version 25 | from ._settings import ConfigError, config_option_help, load_config_from_options 26 | from ._writer import append_to_newsfile 27 | 28 | 29 | if sys.version_info < (3, 10): 30 | import importlib_resources as resources 31 | else: 32 | from importlib import resources 33 | 34 | 35 | def _get_date() -> str: 36 | return date.today().isoformat() 37 | 38 | 39 | def _validate_answer(ctx: Context, param: Option, value: bool) -> bool: 40 | value_check = ( 41 | ctx.params.get("answer_yes") 42 | if param.name == "answer_keep" 43 | else ctx.params.get("answer_keep") 44 | ) 45 | if value_check and value: 46 | click.echo("You can not choose both --yes and --keep at the same time") 47 | ctx.abort() 48 | return value 49 | 50 | 51 | @click.command(name="build") 52 | @click.option( 53 | "--draft", 54 | "draft", 55 | default=False, 56 | flag_value=True, 57 | help=( 58 | "Render the news fragments to standard output. " 59 | "Don't write to files, don't check versions." 60 | ), 61 | ) 62 | @click.option( 63 | "--config", 64 | "config_file", 65 | default=None, 66 | metavar="FILE_PATH", 67 | help=config_option_help, 68 | ) 69 | @click.option( 70 | "--dir", 71 | "directory", 72 | default=None, 73 | metavar="PATH", 74 | help="Build fragment in directory. Default to current directory.", 75 | ) 76 | @click.option( 77 | "--name", 78 | "project_name", 79 | default=None, 80 | help="Pass a custom project name.", 81 | ) 82 | @click.option( 83 | "--version", 84 | "project_version", 85 | default=None, 86 | help="Render the news fragments using given version.", 87 | ) 88 | @click.option( 89 | "--date", 90 | "project_date", 91 | default=None, 92 | help="Render the news fragments using the given date.", 93 | ) 94 | @click.option( 95 | "--yes", 96 | "answer_yes", 97 | default=None, 98 | flag_value=True, 99 | help="Do not ask for confirmation to remove news fragments.", 100 | callback=_validate_answer, 101 | ) 102 | @click.option( 103 | "--keep", 104 | "answer_keep", 105 | default=None, 106 | flag_value=True, 107 | help="Do not ask for confirmations. But keep news fragments.", 108 | callback=_validate_answer, 109 | ) 110 | def _main( 111 | draft: bool, 112 | directory: str | None, 113 | config_file: str | None, 114 | project_name: str | None, 115 | project_version: str | None, 116 | project_date: str | None, 117 | answer_yes: bool, 118 | answer_keep: bool, 119 | ) -> None: 120 | """ 121 | Build a combined news file from news fragment. 122 | """ 123 | try: 124 | return __main( 125 | draft, 126 | directory, 127 | config_file, 128 | project_name, 129 | project_version, 130 | project_date, 131 | answer_yes, 132 | answer_keep, 133 | ) 134 | except ConfigError as e: 135 | print(e, file=sys.stderr) 136 | sys.exit(1) 137 | 138 | 139 | def __main( 140 | draft: bool, 141 | directory: str | None, 142 | config_file: str | None, 143 | project_name: str | None, 144 | project_version: str | None, 145 | project_date: str | None, 146 | answer_yes: bool, 147 | answer_keep: bool, 148 | ) -> None: 149 | """ 150 | The main entry point. 151 | """ 152 | base_directory, config = load_config_from_options(directory, config_file) 153 | to_err = draft 154 | 155 | if project_version is None: 156 | project_version = config.version 157 | if project_version is None: 158 | if not config.package: 159 | raise UsageError( 160 | "'--version' is required since the config file does " 161 | "not contain 'version' or 'package'." 162 | ) 163 | project_version = get_version( 164 | os.path.join(base_directory, config.package_dir), config.package 165 | ).strip() 166 | 167 | click.echo("Loading template...", err=to_err) 168 | if isinstance(config.template, tuple): 169 | template = ( 170 | resources.files(config.template[0]) 171 | .joinpath(config.template[1]) 172 | .read_text(encoding="utf-8") 173 | ) 174 | template_extension = os.path.splitext(config.template[1])[1] 175 | else: 176 | template = Path(config.template).read_text(encoding="utf-8") 177 | template_extension = os.path.splitext(config.template)[1] 178 | is_markdown = template_extension.lower() == ".md" 179 | 180 | click.echo("Finding news fragments...", err=to_err) 181 | 182 | fragment_contents, fragment_files = find_fragments( 183 | base_directory, 184 | config, 185 | # Fail if any fragment filenames are invalid only if ignore list is set 186 | # (this maintains backward compatibility): 187 | strict=(config.ignore is not None), 188 | ) 189 | fragment_filenames = [filename for (filename, _category) in fragment_files] 190 | 191 | click.echo("Rendering news fragments...", err=to_err) 192 | fragments = split_fragments( 193 | fragment_contents, config.types, all_bullets=config.all_bullets 194 | ) 195 | 196 | if project_name is None: 197 | project_name = config.name 198 | if not project_name: 199 | package = config.package 200 | if package: 201 | project_name = get_project_name( 202 | os.path.abspath(os.path.join(base_directory, config.package_dir)), 203 | package, 204 | ) 205 | else: 206 | # Can't determine a project_name, but maybe it is not needed. 207 | project_name = "" 208 | 209 | if project_date is None: 210 | project_date = _get_date().strip() 211 | 212 | # Render the title in the template if the title format is set to "". It can 213 | # alternatively be set to False or a string, in either case it shouldn't be rendered 214 | # in the template. 215 | render_title = config.title_format == "" 216 | 217 | # Add format-specific context to the template 218 | md_header_level = 1 219 | if is_markdown: 220 | if config.title_format: 221 | m = re.search(r"^#+(?=\s)", config.title_format, re.MULTILINE) 222 | lvl = len(m[0]) if m else 0 223 | else: # TODO: derive from template or make configurable? 224 | lvl = 1 if render_title else 0 225 | md_header_level = lvl 226 | 227 | rendered = render_fragments( 228 | # The 0th underline is used for the top line 229 | template, 230 | config.issue_format, 231 | fragments, 232 | config.types, 233 | config.underlines[1:], 234 | config.wrap, 235 | {"name": project_name, "version": project_version, "date": project_date}, 236 | top_underline=config.underlines[0], 237 | all_bullets=config.all_bullets, 238 | render_title=render_title, 239 | md_header_level=md_header_level, 240 | ) 241 | 242 | if config.title_format: 243 | top_line = config.title_format.format( 244 | name=project_name, version=project_version, project_date=project_date 245 | ) 246 | if is_markdown: 247 | parts = [top_line] 248 | else: 249 | parts = [top_line, config.underlines[0] * len(top_line)] 250 | parts.append(rendered) 251 | content = "\n".join(parts) 252 | else: 253 | top_line = "" 254 | content = rendered 255 | 256 | if draft: 257 | click.echo( 258 | "Draft only -- nothing has been written.\n" 259 | "What is seen below is what would be written.\n", 260 | err=to_err, 261 | ) 262 | click.echo(content) 263 | return 264 | 265 | click.echo("Writing to newsfile...", err=to_err) 266 | news_file = config.filename 267 | 268 | if config.single_file is False: 269 | # The release notes for each version are stored in a separate file. 270 | # The name of that file is generated based on the current version and project. 271 | news_file = news_file.format( 272 | name=project_name, version=project_version, project_date=project_date 273 | ) 274 | 275 | append_to_newsfile( 276 | base_directory, 277 | news_file, 278 | config.start_string, 279 | top_line, 280 | content, 281 | single_file=config.single_file, 282 | ) 283 | 284 | click.echo("Staging newsfile...", err=to_err) 285 | _vcs.stage_newsfile(base_directory, news_file) 286 | 287 | if should_remove_fragment_files( 288 | fragment_filenames, 289 | answer_yes, 290 | answer_keep, 291 | ): 292 | click.echo("Removing news fragments...", err=to_err) 293 | _vcs.remove_files(base_directory, fragment_filenames) 294 | 295 | click.echo("Done!", err=to_err) 296 | 297 | 298 | def should_remove_fragment_files( 299 | fragment_filenames: list[str], 300 | answer_yes: bool, 301 | answer_keep: bool, 302 | ) -> bool: 303 | if not fragment_filenames: 304 | click.echo("No news fragments to remove. Skipping!") 305 | return False 306 | try: 307 | if answer_keep: 308 | click.echo("Keeping the following files:") 309 | # Not proceeding with the removal of the files. 310 | return False 311 | 312 | if answer_yes: 313 | click.echo("Removing the following files:") 314 | else: 315 | click.echo("I want to remove the following files:") 316 | finally: 317 | # Will always be printed, even for answer_keep to help with possible troubleshooting 318 | for filename in fragment_filenames: 319 | click.echo(filename) 320 | 321 | if answer_yes or click.confirm("Is it okay if I remove those files?", default=True): 322 | return True 323 | return False 324 | 325 | 326 | if __name__ == "__main__": # pragma: no cover 327 | _main() 328 | -------------------------------------------------------------------------------- /src/towncrier/check.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Amber Brown, 2018 2 | # See LICENSE for details. 3 | 4 | 5 | from __future__ import annotations 6 | 7 | import os 8 | import sys 9 | 10 | from subprocess import CalledProcessError 11 | 12 | import click 13 | 14 | from ._builder import find_fragments 15 | from ._settings import config_option_help, load_config_from_options 16 | from ._vcs import ( 17 | get_default_compare_branch, 18 | get_remote_branches, 19 | list_changed_files_compared_to_branch, 20 | ) 21 | 22 | 23 | @click.command(name="check") 24 | @click.option( 25 | "--compare-with", 26 | default=None, 27 | metavar="BRANCH", 28 | help=( 29 | "Checks files changed running git diff --name-only BRANCH... " 30 | "BRANCH is the branch to be compared with. " 31 | "Default to origin/main" 32 | ), 33 | ) 34 | @click.option( 35 | "--dir", 36 | "directory", 37 | default=None, 38 | metavar="PATH", 39 | help="Check fragment in directory. Default to current directory.", 40 | ) 41 | @click.option( 42 | "--config", 43 | "config", 44 | default=None, 45 | metavar="FILE_PATH", 46 | help=config_option_help, 47 | ) 48 | @click.option( 49 | "--staged", 50 | "staged", 51 | is_flag=True, 52 | default=False, 53 | help="Include staged files as part of the branch checked in the --compare-with", 54 | ) 55 | def _main( 56 | compare_with: str | None, directory: str | None, config: str | None, staged: bool 57 | ) -> None: 58 | """ 59 | Check for new fragments on a branch. 60 | """ 61 | __main(compare_with, directory, config, staged) 62 | 63 | 64 | def __main( 65 | comparewith: str | None, 66 | directory: str | None, 67 | config_path: str | None, 68 | staged: bool, 69 | ) -> None: 70 | base_directory, config = load_config_from_options(directory, config_path) 71 | 72 | if comparewith is None: 73 | comparewith = get_default_compare_branch( 74 | base_directory, get_remote_branches(base_directory=base_directory) 75 | ) 76 | 77 | if comparewith is None: 78 | click.echo("Could not detect default branch. Aborting.") 79 | sys.exit(1) 80 | 81 | try: 82 | files_changed = list_changed_files_compared_to_branch( 83 | base_directory, comparewith, staged 84 | ) 85 | except CalledProcessError as e: 86 | click.echo("git produced output while failing:") 87 | click.echo(e.output) 88 | raise 89 | 90 | if not files_changed: 91 | click.echo( 92 | f"On {comparewith} branch, or no diffs, so no newsfragment required." 93 | ) 94 | sys.exit(0) 95 | 96 | files = {os.path.abspath(path) for path in files_changed} 97 | 98 | click.echo("Looking at these files:") 99 | click.echo("----") 100 | for n, change in enumerate(files, start=1): 101 | click.echo(f"{n}. {change}") 102 | click.echo("----") 103 | 104 | # This will fail if any fragment files have an invalid name: 105 | all_fragment_files = find_fragments(base_directory, config, strict=True)[1] 106 | 107 | news_file = os.path.normpath(os.path.join(base_directory, config.filename)) 108 | if news_file in files: 109 | click.echo("Checks SKIPPED: news file changes detected.") 110 | sys.exit(0) 111 | 112 | fragments = set() # will only include fragments of types that are checked 113 | unchecked_fragments = set() # will include fragments of types that are not checked 114 | for fragment_filename, category in all_fragment_files: 115 | if config.types[category]["check"]: 116 | fragments.add(fragment_filename) 117 | else: 118 | unchecked_fragments.add(fragment_filename) 119 | fragments_in_branch = fragments & files 120 | 121 | if not fragments_in_branch: 122 | unchecked_fragments_in_branch = unchecked_fragments & files 123 | if unchecked_fragments: 124 | click.echo("Found newsfragments of unchecked types in the branch:") 125 | for n, fragment in enumerate(unchecked_fragments_in_branch, start=1): 126 | click.echo(f"{n}. {fragment}") 127 | else: 128 | click.echo("No new newsfragments found on this branch.") 129 | sys.exit(1) 130 | else: 131 | click.echo("Found:") 132 | for n, fragment in enumerate(fragments_in_branch, start=1): 133 | click.echo(f"{n}. {fragment}") 134 | sys.exit(0) 135 | 136 | 137 | if __name__ == "__main__": # pragma: no cover 138 | _main() 139 | -------------------------------------------------------------------------------- /src/towncrier/click_default_group.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2015 Heungsub Lee <19982+sublee@users.noreply.github.com> 2 | # 3 | # SPDX-License-Identifier: BSD-3-Clause 4 | 5 | # Vendored from 6 | # https://github.com/click-contrib/click-default-group/tree/b671ae5325d186fe5ea7abb584f15852a1e931aa 7 | # Because the PyPI package could not be installed on modern Pips anymore and 8 | # the project looks unmaintaintained. 9 | 10 | """ 11 | click_default_group 12 | ~~~~~~~~~~~~~~~~~~~ 13 | 14 | Define a default subcommand by `default=True`: 15 | 16 | .. sourcecode:: python 17 | 18 | import click 19 | from click_default_group import DefaultGroup 20 | 21 | @click.group(cls=DefaultGroup, default_if_no_args=True) 22 | def cli(): 23 | pass 24 | 25 | @cli.command(default=True) 26 | def foo(): 27 | click.echo('foo') 28 | 29 | @cli.command() 30 | def bar(): 31 | click.echo('bar') 32 | 33 | Then you can invoke that without explicit subcommand name: 34 | 35 | .. sourcecode:: console 36 | 37 | $ cli.py --help 38 | Usage: cli.py [OPTIONS] COMMAND [ARGS]... 39 | 40 | Options: 41 | --help Show this message and exit. 42 | 43 | Command: 44 | foo* 45 | bar 46 | 47 | $ cli.py 48 | foo 49 | $ cli.py foo 50 | foo 51 | $ cli.py bar 52 | bar 53 | 54 | """ 55 | import warnings 56 | 57 | import click 58 | 59 | 60 | __all__ = ["DefaultGroup"] 61 | __version__ = "1.2.2" 62 | 63 | 64 | class DefaultGroup(click.Group): 65 | """Invokes a subcommand marked with `default=True` if any subcommand not 66 | chosen. 67 | 68 | :param default_if_no_args: resolves to the default command if no arguments 69 | passed. 70 | 71 | """ 72 | 73 | def __init__(self, *args, **kwargs): 74 | # To resolve as the default command. 75 | if not kwargs.get("ignore_unknown_options", True): 76 | raise ValueError("Default group accepts unknown options") 77 | self.ignore_unknown_options = True 78 | self.default_cmd_name = kwargs.pop("default", None) 79 | self.default_if_no_args = kwargs.pop("default_if_no_args", False) 80 | super().__init__(*args, **kwargs) 81 | 82 | def set_default_command(self, command): 83 | """Sets a command function as the default command.""" 84 | cmd_name = command.name 85 | self.add_command(command) 86 | self.default_cmd_name = cmd_name 87 | 88 | def parse_args(self, ctx, args): 89 | if not args and self.default_if_no_args: 90 | args.insert(0, self.default_cmd_name) 91 | return super().parse_args(ctx, args) 92 | 93 | def get_command(self, ctx, cmd_name): 94 | if cmd_name not in self.commands: 95 | # No command name matched. 96 | ctx.arg0 = cmd_name 97 | cmd_name = self.default_cmd_name 98 | return super().get_command(ctx, cmd_name) 99 | 100 | def resolve_command(self, ctx, args): 101 | base = super() 102 | cmd_name, cmd, args = base.resolve_command(ctx, args) 103 | if hasattr(ctx, "arg0"): 104 | args.insert(0, ctx.arg0) 105 | cmd_name = cmd.name 106 | return cmd_name, cmd, args 107 | 108 | def format_commands(self, ctx, formatter): 109 | formatter = DefaultCommandFormatter(self, formatter, mark="*") 110 | return super().format_commands(ctx, formatter) 111 | 112 | def command(self, *args, **kwargs): 113 | default = kwargs.pop("default", False) 114 | decorator = super().command(*args, **kwargs) 115 | if not default: 116 | return decorator 117 | warnings.warn( 118 | "Use default param of DefaultGroup or " "set_default_command() instead", 119 | DeprecationWarning, 120 | ) 121 | 122 | def _decorator(f): 123 | cmd = decorator(f) 124 | self.set_default_command(cmd) 125 | return cmd 126 | 127 | return _decorator 128 | 129 | 130 | class DefaultCommandFormatter: 131 | """Wraps a formatter to mark a default command.""" 132 | 133 | def __init__(self, group, formatter, mark="*"): 134 | self.group = group 135 | self.formatter = formatter 136 | self.mark = mark 137 | 138 | def __getattr__(self, attr): 139 | return getattr(self.formatter, attr) 140 | 141 | def write_dl(self, rows, *args, **kwargs): 142 | rows_ = [] 143 | for cmd_name, help in rows: 144 | if cmd_name == self.group.default_cmd_name: 145 | rows_.insert(0, (cmd_name + self.mark, help)) 146 | else: 147 | rows_.append((cmd_name, help)) 148 | return self.formatter.write_dl(rows_, *args, **kwargs) 149 | -------------------------------------------------------------------------------- /src/towncrier/create.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Stephen Finucane, 2019 2 | # See LICENSE for details. 3 | 4 | """ 5 | Create a new fragment. 6 | """ 7 | 8 | from __future__ import annotations 9 | 10 | import os 11 | 12 | from pathlib import Path 13 | from typing import cast 14 | 15 | import click 16 | 17 | from ._builder import FragmentsPath 18 | from ._settings import config_option_help, load_config_from_options 19 | 20 | 21 | DEFAULT_CONTENT = "Add your info here" 22 | 23 | 24 | @click.command(name="create") 25 | @click.pass_context 26 | @click.option( 27 | "--dir", 28 | "directory", 29 | default=None, 30 | metavar="PATH", 31 | help="Create fragment in directory. Default to current directory.", 32 | ) 33 | @click.option( 34 | "--config", 35 | "config", 36 | default=None, 37 | metavar="FILE_PATH", 38 | help=config_option_help, 39 | ) 40 | @click.option( 41 | "--edit/--no-edit", 42 | default=None, 43 | help="Open an editor for writing the newsfragment content.", 44 | ) 45 | @click.option( 46 | "-c", 47 | "--content", 48 | type=str, 49 | default=DEFAULT_CONTENT, 50 | help="Sets the content of the new fragment.", 51 | ) 52 | @click.option( 53 | "--section", 54 | type=str, 55 | help="The section to create the fragment for.", 56 | ) 57 | @click.argument("filename", default="") 58 | def _main( 59 | ctx: click.Context, 60 | directory: str | None, 61 | config: str | None, 62 | filename: str, 63 | edit: bool | None, 64 | content: str, 65 | section: str | None, 66 | ) -> None: 67 | """ 68 | Create a new news fragment. 69 | 70 | If FILENAME is not provided, you'll be prompted to create it. 71 | 72 | Towncrier has a few standard types of news fragments, signified by the file 73 | extension. 74 | 75 | \b 76 | These are: 77 | * .feature - a new feature 78 | * .bugfix - a bug fix 79 | * .doc - a documentation improvement, 80 | * .removal - a deprecation or removal of public API, 81 | * .misc - an issue has been closed, but it is not of interest to users. 82 | 83 | If the FILENAME base is just '+' (to create a fragment not tied to an 84 | issue), it will be appended with a random hex string. 85 | """ 86 | __main(ctx, directory, config, filename, edit, content, section) 87 | 88 | 89 | def __main( 90 | ctx: click.Context, 91 | directory: str | None, 92 | config_path: str | None, 93 | filename: str, 94 | edit: bool | None, 95 | content: str, 96 | section: str | None, 97 | ) -> None: 98 | """ 99 | The main entry point. 100 | """ 101 | base_directory, config = load_config_from_options(directory, config_path) 102 | 103 | filename_ext = "" 104 | if config.create_add_extension: 105 | ext = os.path.splitext(config.filename)[1] 106 | if ext.lower() in (".rst", ".md"): 107 | filename_ext = ext 108 | 109 | section_provided = section is not None 110 | if not section_provided: 111 | # Get the default section. 112 | if len(config.sections) == 1: 113 | section = next(iter(config.sections)) 114 | else: 115 | # If there are multiple sections then the first without a path is the default 116 | # section, otherwise it's the first defined section. 117 | for ( 118 | section_name, 119 | section_dir, 120 | ) in config.sections.items(): # pragma: no branch 121 | if not section_dir: 122 | section = section_name 123 | break 124 | if section is None: 125 | section = list(config.sections.keys())[0] 126 | 127 | if section not in config.sections: 128 | # Raise a click exception with the correct parameter. 129 | section_param = None 130 | for p in ctx.command.params: # pragma: no branch 131 | if p.name == "section": 132 | section_param = p 133 | break 134 | expected_sections = ", ".join(f"'{s}'" for s in config.sections) 135 | raise click.BadParameter( 136 | f"expected one of {expected_sections}", 137 | param=section_param, 138 | ) 139 | section = cast(str, section) 140 | 141 | if not filename: 142 | if not section_provided: 143 | sections = list(config.sections) 144 | if len(sections) > 1: 145 | click.echo("Pick a section:") 146 | default_section_index = None 147 | for i, s in enumerate(sections): 148 | click.echo(f" {i+1}: {s or '(primary)'}") 149 | if not default_section_index and s == section: 150 | default_section_index = str(i + 1) 151 | section_index = click.prompt( 152 | "Section", 153 | type=click.Choice([str(i + 1) for i in range(len(sections))]), 154 | default=default_section_index, 155 | ) 156 | section = sections[int(section_index) - 1] 157 | prompt = "Issue number" 158 | # Add info about adding orphan if config is set. 159 | if config.orphan_prefix: 160 | prompt += f" (`{config.orphan_prefix}` if none)" 161 | issue = click.prompt(prompt) 162 | fragment_type = click.prompt( 163 | "Fragment type", 164 | type=click.Choice(list(config.types)), 165 | ) 166 | filename = f"{issue}.{fragment_type}" 167 | if edit is None and content == DEFAULT_CONTENT: 168 | edit = True 169 | 170 | file_dir, file_basename = os.path.split(filename) 171 | if config.orphan_prefix and file_basename.startswith(f"{config.orphan_prefix}."): 172 | # Append a random hex string to the orphan news fragment base name. 173 | filename = os.path.join( 174 | file_dir, 175 | ( 176 | f"{config.orphan_prefix}{os.urandom(4).hex()}" 177 | f"{file_basename[len(config.orphan_prefix):]}" 178 | ), 179 | ) 180 | filename_parts = filename.split(".") 181 | if len(filename_parts) < 2 or ( 182 | filename_parts[-1] not in config.types 183 | and filename_parts[-2] not in config.types 184 | ): 185 | raise click.BadParameter( 186 | "Expected filename '{}' to be of format '{{name}}.{{type}}', " 187 | "where '{{name}}' is an arbitrary slug and '{{type}}' is " 188 | "one of: {}".format(filename, ", ".join(config.types)) 189 | ) 190 | if filename_parts[-1] in config.types and filename_ext: 191 | filename += filename_ext 192 | 193 | get_fragments_path = FragmentsPath(base_directory, config) 194 | fragments_directory = get_fragments_path(section_directory=config.sections[section]) 195 | 196 | if not os.path.exists(fragments_directory): 197 | os.makedirs(fragments_directory) 198 | 199 | segment_file = os.path.join(fragments_directory, filename) 200 | 201 | retry = 0 202 | if filename.split(".")[-1] not in config.types: 203 | filename, extra_ext = os.path.splitext(filename) 204 | else: 205 | extra_ext = "" 206 | while os.path.exists(segment_file): 207 | retry += 1 208 | segment_file = os.path.join( 209 | fragments_directory, f"{filename}.{retry}{extra_ext}" 210 | ) 211 | 212 | if edit: 213 | if content == DEFAULT_CONTENT: 214 | content = "" 215 | content = _get_news_content_from_user(content, extension=filename_ext) 216 | if not content: 217 | click.echo("Aborted creating news fragment due to empty message.") 218 | ctx.exit(1) 219 | 220 | add_newline = bool( 221 | config.create_eof_newline and content and not content.endswith("\n") 222 | ) 223 | Path(segment_file).write_text(content + "\n" * add_newline, encoding="utf-8") 224 | 225 | click.echo(f"Created news fragment at {segment_file}") 226 | 227 | 228 | def _get_news_content_from_user(message: str, extension: str = "") -> str: 229 | initial_content = """ 230 | # Please write your news content. Lines starting with '#' will be ignored, and 231 | # an empty message aborts. 232 | """ 233 | if message: 234 | initial_content = f"{message}\n{initial_content}" 235 | content = click.edit(initial_content, extension=extension or ".txt") 236 | if content is None: 237 | return message 238 | all_lines = content.split("\n") 239 | lines = [line.rstrip() for line in all_lines if not line.lstrip().startswith("#")] 240 | return "\n".join(lines).strip() 241 | 242 | 243 | if __name__ == "__main__": # pragma: no cover 244 | _main() 245 | -------------------------------------------------------------------------------- /src/towncrier/newsfragments/.gitignore: -------------------------------------------------------------------------------- 1 | !.gitignore 2 | -------------------------------------------------------------------------------- /src/towncrier/newsfragments/394.feature.rst: -------------------------------------------------------------------------------- 1 | Support was added for Mercurial SCM. 2 | -------------------------------------------------------------------------------- /src/towncrier/newsfragments/614.bugfix.rst: -------------------------------------------------------------------------------- 1 | Multi-line newsfragments that ends with a code block will now have a newline inserted before appending the link to the issue, to avoid breaking formatting. 2 | -------------------------------------------------------------------------------- /src/towncrier/newsfragments/663.removal: -------------------------------------------------------------------------------- 1 | When no sections are present, 2 | the default Markdown template now renders the category headers as H2. 3 | In previous versions it was rendered as H3. 4 | -------------------------------------------------------------------------------- /src/towncrier/newsfragments/667.misc.rst: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twisted/towncrier/7b447ecabd3dc18e6ae368e05a7c87595cf1337b/src/towncrier/newsfragments/667.misc.rst -------------------------------------------------------------------------------- /src/towncrier/newsfragments/669.misc.rst: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twisted/towncrier/7b447ecabd3dc18e6ae368e05a7c87595cf1337b/src/towncrier/newsfragments/669.misc.rst -------------------------------------------------------------------------------- /src/towncrier/newsfragments/672.doc: -------------------------------------------------------------------------------- 1 | The documentation RST source files are now included in the sdist package. 2 | -------------------------------------------------------------------------------- /src/towncrier/newsfragments/676.feature.rst: -------------------------------------------------------------------------------- 1 | The `towncrier check` command now has a `--staged` flag to inspect the files staged for commit when checking for a news fragment: useful in a pre-commit hook 2 | -------------------------------------------------------------------------------- /src/towncrier/newsfragments/679.misc.rst: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twisted/towncrier/7b447ecabd3dc18e6ae368e05a7c87595cf1337b/src/towncrier/newsfragments/679.misc.rst -------------------------------------------------------------------------------- /src/towncrier/newsfragments/680.misc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twisted/towncrier/7b447ecabd3dc18e6ae368e05a7c87595cf1337b/src/towncrier/newsfragments/680.misc -------------------------------------------------------------------------------- /src/towncrier/newsfragments/682.misc.rst: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twisted/towncrier/7b447ecabd3dc18e6ae368e05a7c87595cf1337b/src/towncrier/newsfragments/682.misc.rst -------------------------------------------------------------------------------- /src/towncrier/newsfragments/687.feature.rst: -------------------------------------------------------------------------------- 1 | When used with an `pyproject.toml` file, when no explicit values are 2 | defined for [tool.towncrier.name|package] they will now fallback to 3 | the value of [project.name]. 4 | -------------------------------------------------------------------------------- /src/towncrier/newsfragments/691.feature.rst: -------------------------------------------------------------------------------- 1 | More simple configuration for Keep a Changelog style changelogs 2 | -------------------------------------------------------------------------------- /src/towncrier/newsfragments/695.bugfix.rst: -------------------------------------------------------------------------------- 1 | Markdown header level is correctly inferred from ``title_format``. 2 | -------------------------------------------------------------------------------- /src/towncrier/newsfragments/700.feature.rst: -------------------------------------------------------------------------------- 1 | Added support for Python 3.13 and removed support for Python 3.8. 2 | -------------------------------------------------------------------------------- /src/towncrier/newsfragments/701.misc.rst: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twisted/towncrier/7b447ecabd3dc18e6ae368e05a7c87595cf1337b/src/towncrier/newsfragments/701.misc.rst -------------------------------------------------------------------------------- /src/towncrier/newsfragments/702.misc.rst: -------------------------------------------------------------------------------- 1 | Skip the mercurial tests if not found on the local system. 2 | -------------------------------------------------------------------------------- /src/towncrier/newsfragments/706.doc: -------------------------------------------------------------------------------- 1 | Refactor the default markdown template to make it easier to understand, extend, and customize. 2 | -------------------------------------------------------------------------------- /src/towncrier/newsfragments/713.misc.rst: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twisted/towncrier/7b447ecabd3dc18e6ae368e05a7c87595cf1337b/src/towncrier/newsfragments/713.misc.rst -------------------------------------------------------------------------------- /src/towncrier/templates/default.md: -------------------------------------------------------------------------------- 1 | {#- 2 | ══════════════════════════════════════════════════════════════════════════════ 3 | TOWNCRIER MARKDOWN TEMPLATE 4 | ══════════════════════════════════════════════════════════════════════════════ 5 | 6 | ─── Macro: heading ───────────────────────────────────────────────────────── 7 | Purpose: 8 | Generates Markdown headings with the appropriate number of # characters. 9 | Based on header_prefix (default: "#") and the level argument. 10 | 11 | Arguments: 12 | level The relative heading level (1=#, 2=##, 3=###, etc.) 13 | -#} 14 | {%- macro heading(level) -%} 15 | {{- "#" * ( header_prefix | length + level -1 ) }} 16 | {%- endmacro -%} 17 | 18 | {#- 19 | ─── Variable: newline ────────────────────────────────────────────────────── 20 | Purpose: 21 | Consistent newline handling. -#} 22 | {%- set newline = "\n" -%} 23 | 24 | {#- ════════════════════════ TEMPLATE GENERATION ════════════════════════ -#} 25 | {#- ─── TITLE HEADING ─── #} 26 | {#- render_title is false when title_format is specified in the config #} 27 | {%- if render_title %} 28 | {%- if versiondata.name %} 29 | {{- heading(1) ~ " " ~ versiondata.name ~ " " ~ versiondata.version ~ " (" ~ versiondata.date ~ ")" ~ newline }} 30 | {%- else %} 31 | {{- heading(1) ~ " " ~ versiondata.version ~ " (" ~ versiondata.date ~ ")" ~ newline }} 32 | {%- endif %} 33 | {%- endif %} 34 | {#- If title_format is specified, we start with a new line #} 35 | {{- newline }} 36 | 37 | {%- for section, _ in sections.items() %} 38 | {#- ─── SECTION HEADING ─── #} 39 | {%- if section %} 40 | {{- newline }} 41 | {{- heading(2) ~ " " ~ section ~ newline }} 42 | {{- newline }} 43 | {%- endif %} 44 | 45 | {%- if sections[section] %} 46 | 47 | {%- for category, val in definitions.items() if category in sections[section] %} 48 | {%- set issue_pks = [] %} 49 | {#- ─── CATEGORY HEADING ─── #} 50 | {#- Increase heading level if section is not present #} 51 | {{- heading(3 if section else 2) ~" " ~ definitions[category]['name'] ~ newline }} 52 | {{- newline }} 53 | 54 | {#- ─── RENDER ENTRIES ─── #} 55 | {%- for text, values in sections[section][category].items() %} 56 | {#- Prepare the string of issue numbers (e.g., "#1, #9, #142") #} 57 | {%- set issue_pks = [] %} 58 | {%- for v_issue in values %} 59 | {%- set _= issue_pks.append(v_issue.split(": ", 1)[0]) %} 60 | {%- endfor %} 61 | {%- set issues_list = issue_pks | join(", ") %} 62 | 63 | {#- Check if text contains a sublist #} 64 | {%- set text_has_sublist = (("\n - " in text) or ("\n * " in text)) %} 65 | 66 | {#- CASE 1: No text, only issues #} 67 | {#- Output: - #1, #9, #142 #} 68 | {%- if not text and issues_list %} 69 | {{- "- " ~ issues_list ~ newline }} 70 | 71 | {#- Cases where both text and issues exist #} 72 | {%- elif text and issues_list %} 73 | {%- if text_has_sublist %} 74 | {#- CASE 3: Text with sublist #} 75 | {#- Output: - TEXT\n\n (#1, #9, #142) #} 76 | {{- "- " ~ text ~ newline ~ newline ~ " (" ~ issues_list ~ ")" ~ newline }} 77 | {%- else %} 78 | {#- CASE 2: Text, no sublist #} 79 | {#- Output: - TEXT (#1, #9, #142) #} 80 | {{- "- " ~ text ~ " (" ~ issues_list ~ ")" ~ newline }} 81 | {%- endif %} 82 | 83 | {%- elif text %} 84 | {#- Implicit Case: Text, but no issues #} 85 | {#- Output: - TEXT #} 86 | {{- "- " ~ text ~ newline }} 87 | {%- endif %} 88 | {%- endfor %} 89 | 90 | {#- New line between list and link references #} 91 | {{- newline }} 92 | 93 | {#- Link references #} 94 | {%- if issues_by_category[section][category] and "]: " in issues_by_category[section][category][0] %} 95 | {%- for issue in issues_by_category[section][category] %} 96 | {{- issue ~ newline }} 97 | {%- endfor %} 98 | {{- newline }} 99 | {%- endif %} 100 | 101 | {#- No changes in this category #} 102 | {%- if sections[section][category]|length == 0 %} 103 | {{- newline }} 104 | {{- "No significant changes." ~ newline * 2 }} 105 | {%- endif %} 106 | {%- endfor %} 107 | {%- else %} 108 | {#- No changes in this section #} 109 | {{- "No significant changes." ~ newline * 2 }} 110 | {%- endif %} 111 | {%- endfor %} 112 | {#- 113 | Newline at the end of the rendered newsfile content. 114 | In this way the there are 2 newlines between the latest release and the previous release content. 115 | -#} 116 | {{- newline -}} 117 | -------------------------------------------------------------------------------- /src/towncrier/templates/default.rst: -------------------------------------------------------------------------------- 1 | {% if render_title %} 2 | {% if versiondata.name %} 3 | {{ versiondata.name }} {{ versiondata.version }} ({{ versiondata.date }}) 4 | {{ top_underline * ((versiondata.name + versiondata.version + versiondata.date)|length + 4)}} 5 | {% else %} 6 | {{ versiondata.version }} ({{ versiondata.date }}) 7 | {{ top_underline * ((versiondata.version + versiondata.date)|length + 3)}} 8 | {% endif %} 9 | {% endif %} 10 | {% for section, _ in sections.items() %} 11 | {% set underline = underlines[0] %}{% if section %}{{section}} 12 | {{ underline * section|length }}{% set underline = underlines[1] %} 13 | 14 | {% endif %} 15 | 16 | {% if sections[section] %} 17 | {% for category, val in definitions.items() if category in sections[section]%} 18 | {{ definitions[category]['name'] }} 19 | {{ underline * definitions[category]['name']|length }} 20 | 21 | {% for text, values in sections[section][category].items() %} 22 | - {% if text %}{{ text }}{% if values %} ({{ values|join(', ') }}){% endif %}{% else %}{{ values|join(', ') }}{% endif %} 23 | 24 | {% endfor %} 25 | 26 | {% if sections[section][category]|length == 0 %} 27 | No significant changes. 28 | 29 | {% else %} 30 | {% endif %} 31 | 32 | {% endfor %} 33 | {% else %} 34 | No significant changes. 35 | 36 | 37 | {% endif %} 38 | {% endfor %} 39 | -------------------------------------------------------------------------------- /src/towncrier/templates/hr-between-versions.rst: -------------------------------------------------------------------------------- 1 | {% if render_title %} 2 | {% if versiondata.name %} 3 | {{ versiondata.name }} {{ versiondata.version }} ({{ versiondata.date }}) 4 | {{ top_underline * ((versiondata.name + versiondata.version + versiondata.date)|length + 4)}} 5 | {% else %} 6 | {{ versiondata.version }} ({{ versiondata.date }}) 7 | {{ top_underline * ((versiondata.version + versiondata.date)|length + 3)}} 8 | {% endif %} 9 | {% endif %} 10 | {% for section, _ in sections.items() %} 11 | {% set underline = underlines[0] %}{% if section %}{{section}} 12 | {{ underline * section|length }}{% set underline = underlines[1] %} 13 | 14 | {% endif %} 15 | 16 | {% if sections[section] %} 17 | {% for category, val in definitions.items() if category in sections[section]%} 18 | {{ definitions[category]['name'] }} 19 | {{ underline * definitions[category]['name']|length }} 20 | 21 | {% if definitions[category]['showcontent'] %} 22 | {% for text, values in sections[section][category].items() %} 23 | - {{ text }} 24 | {{ values|join(',\n ') }} 25 | {% endfor %} 26 | 27 | {% else %} 28 | - {{ sections[section][category]['']|join(', ') }} 29 | 30 | {% endif %} 31 | {% if sections[section][category]|length == 0 %} 32 | No significant changes. 33 | 34 | {% else %} 35 | {% endif %} 36 | 37 | {% endfor %} 38 | {% else %} 39 | No significant changes. 40 | 41 | 42 | {% endif %} 43 | {% endfor %} 44 | ---- 45 | -------------------------------------------------------------------------------- /src/towncrier/templates/single-file-no-bullets.rst: -------------------------------------------------------------------------------- 1 | {% if render_title %} 2 | {% if versiondata.name %} 3 | {{ versiondata.name }} {{ versiondata.version }} ({{ versiondata.date }}) 4 | {{ top_underline * ((versiondata.name + versiondata.version + versiondata.date)|length + 4)}} 5 | {% else %} 6 | {{ versiondata.version }} ({{ versiondata.date }}) 7 | {{ top_underline * ((versiondata.version + versiondata.date)|length + 3)}} 8 | {% endif %} 9 | {% endif %} 10 | {% for section, _ in sections.items() %} 11 | {% set underline = underlines[0] %}{% if section %}{{section}} 12 | {{ underline * section|length }}{% set underline = underlines[1] %} 13 | 14 | {% endif %} 15 | {% if sections[section] %} 16 | {% for category, val in definitions.items() if category in sections[section] %} 17 | 18 | {{ definitions[category]['name'] }} 19 | {{ underline * definitions[category]['name']|length }} 20 | 21 | {% if definitions[category]['showcontent'] %} 22 | {% for text, values in sections[section][category].items() %} 23 | {{ text }} 24 | {{ get_indent(text) }}({{values|join(', ') }}) 25 | 26 | {% endfor %} 27 | {% else %} 28 | - {{ sections[section][category]['']|join(', ') }} 29 | 30 | {% endif %} 31 | {% if sections[section][category]|length == 0 %} 32 | No significant changes. 33 | 34 | {% else %} 35 | {% endif %} 36 | {% endfor %} 37 | {% else %} 38 | No significant changes. 39 | 40 | 41 | {% endif %} 42 | {% endfor %} 43 | -------------------------------------------------------------------------------- /src/towncrier/test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twisted/towncrier/7b447ecabd3dc18e6ae368e05a7c87595cf1337b/src/towncrier/test/__init__.py -------------------------------------------------------------------------------- /src/towncrier/test/helpers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import textwrap 4 | 5 | from functools import wraps 6 | from importlib import resources 7 | from pathlib import Path 8 | from subprocess import call 9 | from typing import Any, Callable 10 | 11 | from click.testing import CliRunner 12 | 13 | 14 | def read(filename: str | Path) -> str: 15 | return Path(filename).read_text() 16 | 17 | 18 | def write(path: str | Path, contents: str, dedent: bool = False) -> None: 19 | """ 20 | Create a file with given contents including any missing parent directories 21 | """ 22 | p = Path(path) 23 | p.parent.mkdir(parents=True, exist_ok=True) 24 | if dedent: 25 | contents = textwrap.dedent(contents) 26 | p.write_text(contents) 27 | 28 | 29 | def read_pkg_resource(path: str) -> str: 30 | """ 31 | Read *path* from the towncrier package. 32 | """ 33 | return (resources.files("towncrier") / path).read_text("utf-8") 34 | 35 | 36 | def with_isolated_runner(fn: Callable[..., Any]) -> Callable[..., Any]: 37 | """ 38 | Run *fn* within an isolated filesystem and add the kwarg *runner* to its 39 | arguments. 40 | """ 41 | 42 | @wraps(fn) 43 | def test(*args: Any, **kw: Any) -> Any: 44 | runner = CliRunner() 45 | with runner.isolated_filesystem(): 46 | return fn(*args, runner=runner, **kw) 47 | 48 | return test 49 | 50 | 51 | def setup_simple_project( 52 | *, 53 | config: str | None = None, 54 | extra_config: str = "", 55 | pyproject_path: str = "pyproject.toml", 56 | mkdir_newsfragments: bool = True, 57 | ) -> None: 58 | if config is None: 59 | config = "[tool.towncrier]\n" 'package = "foo"\n' + extra_config 60 | else: 61 | config = textwrap.dedent(config) 62 | Path(pyproject_path).write_text(config) 63 | Path("foo").mkdir() 64 | Path("foo/__init__.py").write_text('__version__ = "1.2.3"\n') 65 | 66 | if mkdir_newsfragments: 67 | Path("foo/newsfragments").mkdir() 68 | 69 | 70 | def with_project( 71 | *, 72 | config: str | None = None, 73 | pyproject_path: str = "pyproject.toml", 74 | ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: 75 | """Decorator to run a test with an isolated directory containing a simple 76 | project. 77 | 78 | The files are not managed by git. 79 | 80 | `config` is the content of the config file. 81 | It will be automatically dedented. 82 | 83 | `pyproject_path` is the path where to store the config file. 84 | """ 85 | 86 | def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: 87 | @wraps(fn) 88 | def test(*args: Any, **kw: Any) -> Any: 89 | runner = CliRunner() 90 | with runner.isolated_filesystem(): 91 | setup_simple_project( 92 | config=config, 93 | pyproject_path=pyproject_path, 94 | ) 95 | 96 | return fn(*args, runner=runner, **kw) 97 | 98 | return test 99 | 100 | return decorator 101 | 102 | 103 | def with_git_project( 104 | *, 105 | config: str | None = None, 106 | pyproject_path: str = "pyproject.toml", 107 | ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: 108 | """Decorator to run a test with an isolated directory containing a simple 109 | project checked into git. 110 | Use `config` to tweak the content of the config file. 111 | Use `pyproject_path` to tweak the location of the config file. 112 | """ 113 | 114 | def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: 115 | def _commit() -> None: 116 | call(["git", "add", "."]) 117 | call(["git", "commit", "-m", "Second Commit"]) 118 | 119 | @wraps(fn) 120 | def test(*args: Any, **kw: Any) -> Any: 121 | runner = CliRunner() 122 | with runner.isolated_filesystem(): 123 | setup_simple_project( 124 | config=config, 125 | pyproject_path=pyproject_path, 126 | ) 127 | 128 | call(["git", "init"]) 129 | call(["git", "config", "user.name", "user"]) 130 | call(["git", "config", "user.email", "user@example.com"]) 131 | call(["git", "config", "commit.gpgSign", "false"]) 132 | call(["git", "add", "."]) 133 | call(["git", "commit", "-m", "Initial Commit"]) 134 | 135 | return fn(*args, runner=runner, commit=_commit, **kw) 136 | 137 | return test 138 | 139 | return decorator 140 | -------------------------------------------------------------------------------- /src/towncrier/test/test_builder.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Povilas Kanapickas, 2019 2 | # See LICENSE for details. 3 | 4 | from textwrap import dedent 5 | 6 | from twisted.trial.unittest import TestCase 7 | 8 | from .._builder import parse_newfragment_basename, render_fragments 9 | 10 | 11 | class TestParseNewsfragmentBasename(TestCase): 12 | def test_simple(self): 13 | """. generates a counter value of 0.""" 14 | self.assertEqual( 15 | parse_newfragment_basename("123.feature", ["feature"]), 16 | ("123", "feature", 0), 17 | ) 18 | 19 | def test_invalid_category(self): 20 | """Files without a valid category are rejected.""" 21 | self.assertEqual( 22 | parse_newfragment_basename("README.ext", ["feature"]), 23 | (None, None, None), 24 | ) 25 | self.assertEqual( 26 | parse_newfragment_basename("README", ["feature"]), 27 | (None, None, None), 28 | ) 29 | 30 | def test_counter(self): 31 | """.. generates a custom counter value.""" 32 | self.assertEqual( 33 | parse_newfragment_basename("123.feature.1", ["feature"]), 34 | ("123", "feature", 1), 35 | ) 36 | 37 | def test_counter_with_extension(self): 38 | """File extensions are ignored.""" 39 | self.assertEqual( 40 | parse_newfragment_basename("123.feature.1.ext", ["feature"]), 41 | ("123", "feature", 1), 42 | ) 43 | 44 | def test_ignores_extension(self): 45 | """File extensions are ignored.""" 46 | self.assertEqual( 47 | parse_newfragment_basename("123.feature.ext", ["feature"]), 48 | ("123", "feature", 0), 49 | ) 50 | 51 | def test_non_numeric_issue(self): 52 | """Non-numeric issue identifiers are preserved verbatim.""" 53 | self.assertEqual( 54 | parse_newfragment_basename("baz.feature", ["feature"]), 55 | ("baz", "feature", 0), 56 | ) 57 | 58 | def test_non_numeric_issue_with_extension(self): 59 | """File extensions are ignored.""" 60 | self.assertEqual( 61 | parse_newfragment_basename("baz.feature.ext", ["feature"]), 62 | ("baz", "feature", 0), 63 | ) 64 | 65 | def test_dots_in_issue_name(self): 66 | """Non-numeric issue identifiers are preserved verbatim.""" 67 | self.assertEqual( 68 | parse_newfragment_basename("baz.1.2.feature", ["feature"]), 69 | ("baz.1.2", "feature", 0), 70 | ) 71 | 72 | def test_dots_in_issue_name_invalid_category(self): 73 | """Files without a valid category are rejected.""" 74 | self.assertEqual( 75 | parse_newfragment_basename("baz.1.2.notfeature", ["feature"]), 76 | (None, None, None), 77 | ) 78 | 79 | def test_dots_in_issue_name_and_counter(self): 80 | """Non-numeric issue identifiers are preserved verbatim.""" 81 | self.assertEqual( 82 | parse_newfragment_basename("baz.1.2.feature.3", ["feature"]), 83 | ("baz.1.2", "feature", 3), 84 | ) 85 | 86 | def test_strip(self): 87 | """Leading spaces and subsequent leading zeros are stripped 88 | when parsing newsfragment names into issue numbers etc. 89 | """ 90 | self.assertEqual( 91 | parse_newfragment_basename(" 007.feature", ["feature"]), 92 | ("7", "feature", 0), 93 | ) 94 | 95 | def test_strip_with_counter(self): 96 | """Leading spaces and subsequent leading zeros are stripped 97 | when parsing newsfragment names into issue numbers etc. 98 | """ 99 | self.assertEqual( 100 | parse_newfragment_basename(" 007.feature.3", ["feature"]), 101 | ("7", "feature", 3), 102 | ) 103 | 104 | def test_orphan(self): 105 | """Orphaned snippets must remain the orphan marker in the issue 106 | identifier.""" 107 | self.assertEqual( 108 | parse_newfragment_basename("+orphan.feature", ["feature"]), 109 | ("+orphan", "feature", 0), 110 | ) 111 | 112 | def test_orphan_with_number(self): 113 | """Orphaned snippets can contain numbers in the identifier.""" 114 | self.assertEqual( 115 | parse_newfragment_basename("+123_orphan.feature", ["feature"]), 116 | ("+123_orphan", "feature", 0), 117 | ) 118 | self.assertEqual( 119 | parse_newfragment_basename("+orphan_123.feature", ["feature"]), 120 | ("+orphan_123", "feature", 0), 121 | ) 122 | 123 | def test_orphan_with_dotted_number(self): 124 | """Orphaned snippets can contain numbers with dots in the 125 | identifier.""" 126 | self.assertEqual( 127 | parse_newfragment_basename("+12.3_orphan.feature", ["feature"]), 128 | ("+12.3_orphan", "feature", 0), 129 | ) 130 | self.assertEqual( 131 | parse_newfragment_basename("+orphan_12.3.feature", ["feature"]), 132 | ("+orphan_12.3", "feature", 0), 133 | ) 134 | 135 | def test_orphan_all_digits(self): 136 | """Orphaned snippets can consist of only digits.""" 137 | self.assertEqual( 138 | parse_newfragment_basename("+123.feature", ["feature"]), 139 | ("+123", "feature", 0), 140 | ) 141 | 142 | 143 | class TestNewsFragmentsOrdering(TestCase): 144 | """ 145 | Tests to ensure that issues are ordered correctly in the output. 146 | 147 | This tests both ordering of issues within a fragment and ordering of 148 | fragments within a section. 149 | """ 150 | 151 | template = dedent( 152 | """ 153 | {% for section_name, category in sections.items() %} 154 | {% if section_name %}# {{ section_name }}{% endif %} 155 | {%- for category_name, issues in category.items() %} 156 | ## {{ category_name }} 157 | {% for issue, numbers in issues.items() %} 158 | - {{ issue }}{% if numbers %} ({{ numbers|join(', ') }}){% endif %} 159 | 160 | {% endfor %} 161 | {% endfor -%} 162 | {% endfor -%} 163 | """ 164 | ) 165 | 166 | def render(self, fragments): 167 | return render_fragments( 168 | template=self.template, 169 | issue_format=None, 170 | fragments=fragments, 171 | definitions={}, 172 | underlines=[], 173 | wrap=False, 174 | versiondata={}, 175 | ) 176 | 177 | def test_ordering(self): 178 | """ 179 | Issues are ordered first by the non-text component, then by their number. 180 | 181 | For backwards compatibility, issues with no number are grouped first and issues 182 | which are only a number are grouped last. 183 | 184 | Orphan news fragments are always last, sorted by their text. 185 | """ 186 | output = self.render( 187 | { 188 | "": { 189 | "feature": { 190 | "Added Cheese": ["10", "gh-25", "gh-3", "4"], 191 | "Added Fish": [], 192 | "Added Bread": [], 193 | "Added Milk": ["gh-1"], 194 | "Added Eggs": ["gh-2", "random"], 195 | } 196 | } 197 | }, 198 | ) 199 | # "Eggs" are first because they have an issue with no number, and the first 200 | # issue for each fragment is what is used for sorting the overall list. 201 | assert output == dedent( 202 | """ 203 | ## feature 204 | - Added Eggs (random, gh-2) 205 | - Added Milk (gh-1) 206 | - Added Cheese (gh-3, gh-25, #4, #10) 207 | - Added Bread 208 | - Added Fish 209 | """ 210 | ) 211 | -------------------------------------------------------------------------------- /src/towncrier/test/test_git.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Amber Brown, 2015 2 | # See LICENSE for details. 3 | 4 | 5 | import warnings 6 | 7 | from twisted.trial.unittest import TestCase 8 | 9 | from towncrier import _git 10 | 11 | 12 | class TestGit(TestCase): 13 | def test_empty_remove(self): 14 | """ 15 | If remove_files gets an empty list, it returns gracefully. 16 | """ 17 | _git.remove_files([]) 18 | 19 | def test_get_default_compare_branch_main(self): 20 | """ 21 | If there's a remote branch origin/main, prefer it over everything else. 22 | """ 23 | branch = _git.get_default_compare_branch(["origin/master", "origin/main"]) 24 | 25 | self.assertEqual("origin/main", branch) 26 | 27 | def test_get_default_compare_branch_fallback(self): 28 | """ 29 | If there's origin/master and no main, use it and warn about it. 30 | """ 31 | with warnings.catch_warnings(record=True) as w: 32 | branch = _git.get_default_compare_branch(["origin/master", "origin/foo"]) 33 | 34 | self.assertEqual("origin/master", branch) 35 | self.assertTrue(w[0].message.args[0].startswith('Using "origin/master')) 36 | -------------------------------------------------------------------------------- /src/towncrier/test/test_hg.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) towncrier contributors, 2025 2 | # See LICENSE for details. 3 | 4 | import os.path 5 | import shutil 6 | import unittest 7 | 8 | from pathlib import Path 9 | from subprocess import check_call 10 | 11 | from click.testing import CliRunner 12 | from twisted.trial.unittest import TestCase 13 | 14 | from towncrier import _hg, _vcs 15 | 16 | from .helpers import setup_simple_project, write 17 | 18 | 19 | hg_available = shutil.which("hg") is not None 20 | 21 | 22 | def create_project( 23 | pyproject_path="pyproject.toml", main_branch="main", extra_config="" 24 | ): 25 | setup_simple_project(pyproject_path=pyproject_path, extra_config=extra_config) 26 | Path("foo/newsfragments/123.feature").write_text("Adds levitation") 27 | initial_commit(branch=main_branch) 28 | check_call(["hg", "branch", "otherbranch"]) 29 | 30 | 31 | def initial_commit(branch="main"): 32 | """ 33 | Create a mercurial repo, configure it and make an initial commit 34 | 35 | There must be uncommitted changes 36 | """ 37 | check_call(["hg", "init", "."]) 38 | check_call(["hg", "branch", branch]) 39 | commit("Initial Commit") 40 | 41 | 42 | def commit(message): 43 | """Commit the repo in the current working directory 44 | 45 | There must be uncommitted changes 46 | """ 47 | check_call(["hg", "addremove", "."]) 48 | check_call(["hg", "commit", "--user", "Example ", "-m", message]) 49 | 50 | 51 | @unittest.skipUnless(hg_available, "requires 'mercurial' to be installed") 52 | class TestHg(TestCase): 53 | def test_get_default_compare_branch(self): 54 | """ 55 | Test the 'get_default_compare_branch' behavior. 56 | """ 57 | assert _hg.get_default_compare_branch(["main", "default"]) == "default" 58 | assert _hg.get_default_compare_branch(["main", "a_topic"]) is None 59 | 60 | def test_empty_remove(self): 61 | """ 62 | If remove_files gets an empty list, it returns gracefully. 63 | """ 64 | _hg.remove_files([]) 65 | 66 | def test_complete_scenario(self): 67 | """ 68 | Tests all the _hg functions that interact with an actual repository. 69 | 70 | Setting up a project is a little slow, hence the grouping of all the 71 | tests in one. 72 | """ 73 | runner = CliRunner() 74 | with runner.isolated_filesystem(): 75 | create_project("pyproject.toml") 76 | 77 | # make sure _vcs._get_mod properly detects a mercurial repo 78 | self.assertEqual(_hg, _vcs._get_mod(".")) 79 | 80 | write("changes/000.misc.rst", "some change") 81 | 82 | _hg.stage_newsfile(".", "changes/000.misc.rst") 83 | 84 | commit("commit 1") 85 | 86 | branches = sorted(_hg.get_remote_branches(".")) 87 | self.assertEqual(["main", "otherbranch"], branches) 88 | 89 | self.assertEqual( 90 | [os.path.join("changes", "000.misc.rst")], 91 | _hg.list_changed_files_compared_to_branch(".", "main", False), 92 | ) 93 | 94 | write("changes/001.misc.rst", "some change") 95 | _hg.remove_files(["changes/000.misc.rst", "changes/001.misc.rst"]) 96 | -------------------------------------------------------------------------------- /src/towncrier/test/test_novcs.py: -------------------------------------------------------------------------------- 1 | from twisted.trial.unittest import TestCase 2 | 3 | from towncrier import _novcs 4 | 5 | 6 | class TestNOVCS(TestCase): 7 | def test_get_default_compare_branch(self): 8 | """ 9 | Witout a version control system, there is no default branch. 10 | """ 11 | self.assertEqual(None, _novcs.get_default_compare_branch([])) 12 | 13 | def test_empty_remove(self): 14 | """ 15 | If remove_files gets an empty list, it returns gracefully. 16 | """ 17 | _novcs.remove_files([]) 18 | 19 | def test_get_remote_branches(self): 20 | """ 21 | There are no remote branches, when we don't have a VCS. 22 | """ 23 | self.assertEqual([], _novcs.get_remote_branches(".")) 24 | 25 | def test_list_changed_files_compared_to_branch(self): 26 | """ 27 | No changed files are detected without a VCS. 28 | """ 29 | self.assertEqual( 30 | [], _novcs.list_changed_files_compared_to_branch(".", "main", False) 31 | ) 32 | -------------------------------------------------------------------------------- /src/towncrier/test/test_project.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Amber Brown, 2015 2 | # See LICENSE for details. 3 | 4 | import os 5 | import sys 6 | 7 | from importlib.metadata import version as metadata_version 8 | 9 | from click.testing import CliRunner 10 | from twisted.trial.unittest import TestCase 11 | 12 | from .._project import get_project_name, get_version 13 | from .._shell import cli as towncrier_cli 14 | from .helpers import write 15 | 16 | 17 | towncrier_cli.name = "towncrier" 18 | 19 | 20 | class VersionFetchingTests(TestCase): 21 | def test_str(self): 22 | """ 23 | A str __version__ will be picked up. 24 | """ 25 | temp = self.mktemp() 26 | os.makedirs(os.path.join(temp, "mytestproj")) 27 | 28 | with open(os.path.join(temp, "mytestproj", "__init__.py"), "w") as f: 29 | f.write("__version__ = '1.2.3'") 30 | 31 | version = get_version(temp, "mytestproj") 32 | self.assertEqual(version, "1.2.3") 33 | 34 | def test_tuple(self): 35 | """ 36 | A tuple __version__ will be picked up. 37 | """ 38 | temp = self.mktemp() 39 | os.makedirs(os.path.join(temp, "mytestproja")) 40 | 41 | with open(os.path.join(temp, "mytestproja", "__init__.py"), "w") as f: 42 | f.write("__version__ = (1, 3, 12)") 43 | 44 | version = get_version(temp, "mytestproja") 45 | self.assertEqual(version, "1.3.12") 46 | 47 | def test_incremental(self): 48 | """ 49 | An incremental-like Version __version__ is picked up. 50 | """ 51 | temp = self.mktemp() 52 | os.makedirs(temp) 53 | os.makedirs(os.path.join(temp, "mytestprojinc")) 54 | 55 | with open(os.path.join(temp, "mytestprojinc", "__init__.py"), "w") as f: 56 | f.write( 57 | """ 58 | class Version: 59 | ''' 60 | This is emulating a Version object from incremental. 61 | ''' 62 | 63 | def __init__(self, *version_parts): 64 | self.version = version_parts 65 | self.package = "mytestprojinc" 66 | 67 | def base(self): 68 | return '.'.join(map(str, self.version)) 69 | 70 | __version__ = Version(1, 3, 12, "rc1") 71 | """ 72 | ) 73 | 74 | version = get_version(temp, "mytestprojinc") 75 | self.assertEqual(version, "1.3.12rc1") 76 | 77 | project = get_project_name(temp, "mytestprojinc") 78 | self.assertEqual(project, "mytestprojinc") 79 | 80 | def test_not_incremental(self): 81 | """ 82 | An exception is raised when the version could not be detected. 83 | For this test we use an incremental-like object, 84 | that has the `base` method, but that method 85 | does not match the return type for `incremental`. 86 | """ 87 | temp = self.mktemp() 88 | os.makedirs(os.path.join(temp, "mytestprojnotinc")) 89 | 90 | with open(os.path.join(temp, "mytestprojnotinc", "__init__.py"), "w") as f: 91 | f.write( 92 | """ 93 | class WeirdVersion: 94 | def base(self, some_arg): 95 | return "shouldn't get here" 96 | 97 | 98 | __version__ = WeirdVersion() 99 | """ 100 | ) 101 | with self.assertRaises(Exception) as e: 102 | get_version(temp, "mytestprojnotinc") 103 | 104 | self.assertEqual( 105 | ( 106 | "Version must be a string, tuple, or an Incremental Version. " 107 | "If you can't provide that, use the --version argument and " 108 | "specify one.", 109 | ), 110 | e.exception.args, 111 | ) 112 | 113 | def test_version_from_metadata(self): 114 | """ 115 | A version from package metadata is picked up. 116 | """ 117 | version = get_version(".", "towncrier") 118 | self.assertEqual(metadata_version("towncrier"), version) 119 | 120 | def _setup_missing(self): 121 | """ 122 | Create a minimalistic project with missing metadata in a temporary 123 | directory. 124 | """ 125 | tmp_dir = self.mktemp() 126 | pkg = os.path.join(tmp_dir, "missing") 127 | os.makedirs(pkg) 128 | init = os.path.join(tmp_dir, "__init__.py") 129 | 130 | write(init, "# nope\n") 131 | 132 | return tmp_dir 133 | 134 | def test_missing_version(self): 135 | """ 136 | Missing __version__ string leads to an exception. 137 | """ 138 | tmp_dir = self._setup_missing() 139 | 140 | with self.assertRaises(Exception) as e: 141 | # The 'missing' package has no __version__ string. 142 | get_version(tmp_dir, "missing") 143 | 144 | self.assertEqual( 145 | ("No __version__ or metadata version info for the 'missing' package.",), 146 | e.exception.args, 147 | ) 148 | 149 | def test_missing_version_project_name(self): 150 | """ 151 | Missing __version__ string leads to the package name becoming the 152 | project name. 153 | """ 154 | tmp_dir = self._setup_missing() 155 | 156 | self.assertEqual("Missing", get_project_name(tmp_dir, "missing")) 157 | 158 | def test_unknown_type(self): 159 | """ 160 | A __version__ of unknown type will lead to an exception. 161 | """ 162 | temp = self.mktemp() 163 | os.makedirs(os.path.join(temp, "mytestprojb")) 164 | 165 | with open(os.path.join(temp, "mytestprojb", "__init__.py"), "w") as f: 166 | f.write("__version__ = object()") 167 | 168 | self.assertRaises(Exception, get_version, temp, "mytestprojb") 169 | 170 | self.assertEqual("Mytestprojb", get_project_name(temp, "mytestprojb")) 171 | 172 | def test_import_fails(self): 173 | """ 174 | An exception is raised when getting the version failed due to missing Python package files. 175 | """ 176 | with self.assertRaises(ModuleNotFoundError): 177 | get_version(".", "projectname_without_any_files") 178 | 179 | def test_already_installed_import(self): 180 | """ 181 | An already installed package will be checked before cwd-found packages. 182 | """ 183 | project_name = "mytestproj_already_installed_import" 184 | 185 | temp = self.mktemp() 186 | os.makedirs(os.path.join(temp, project_name)) 187 | 188 | with open(os.path.join(temp, project_name, "__init__.py"), "w") as f: 189 | f.write("__version__ = (1, 3, 12)") 190 | 191 | sys_path_temp = self.mktemp() 192 | os.makedirs(os.path.join(sys_path_temp, project_name)) 193 | 194 | with open(os.path.join(sys_path_temp, project_name, "__init__.py"), "w") as f: 195 | f.write("__version__ = (2, 1, 5)") 196 | 197 | sys.path.insert(0, sys_path_temp) 198 | self.addCleanup(sys.path.pop, 0) 199 | 200 | version = get_version(temp, project_name) 201 | 202 | self.assertEqual(version, "2.1.5") 203 | 204 | def test_installed_package_found_when_no_source_present(self): 205 | """ 206 | The version from the installed package is returned when there is no 207 | package present at the provided source directory. 208 | """ 209 | project_name = "mytestproj_only_installed" 210 | 211 | sys_path_temp = self.mktemp() 212 | os.makedirs(os.path.join(sys_path_temp, project_name)) 213 | 214 | with open(os.path.join(sys_path_temp, project_name, "__init__.py"), "w") as f: 215 | f.write("__version__ = (3, 14)") 216 | 217 | sys.path.insert(0, sys_path_temp) 218 | self.addCleanup(sys.path.pop, 0) 219 | 220 | version = get_version("some non-existent directory", project_name) 221 | 222 | self.assertEqual(version, "3.14") 223 | 224 | 225 | class InvocationTests(TestCase): 226 | def test_dash_m(self): 227 | """ 228 | `python -m towncrier` invokes the main entrypoint. 229 | """ 230 | runner = CliRunner() 231 | temp = self.mktemp() 232 | new_dir = os.path.join(temp, "dashm") 233 | os.makedirs(new_dir) 234 | orig_dir = os.getcwd() 235 | try: 236 | os.chdir(new_dir) 237 | with open("pyproject.toml", "w") as f: 238 | f.write("[tool.towncrier]\n" 'directory = "news"\n') 239 | os.makedirs("news") 240 | result = runner.invoke(towncrier_cli, ["--help"]) 241 | self.assertIn("[OPTIONS] COMMAND [ARGS]...", result.stdout) 242 | self.assertRegex(result.stdout, r".*--help\s+Show this message and exit.") 243 | finally: 244 | os.chdir(orig_dir) 245 | 246 | def test_version(self): 247 | """ 248 | `--version` command line option is available to show the current production version. 249 | """ 250 | runner = CliRunner() 251 | result = runner.invoke(towncrier_cli, ["--version"]) 252 | self.assertTrue(result.output.startswith("towncrier, version 2")) 253 | -------------------------------------------------------------------------------- /src/towncrier/test/test_settings.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Amber Brown, 2015 2 | # See LICENSE for details. 3 | 4 | import os 5 | 6 | from textwrap import dedent 7 | 8 | from click.testing import CliRunner 9 | from twisted.trial.unittest import TestCase 10 | 11 | from .._settings import ConfigError, load_config 12 | from .._shell import cli 13 | from .helpers import with_isolated_runner, write 14 | 15 | 16 | class TomlSettingsTests(TestCase): 17 | def mktemp_project( 18 | self, *, pyproject_toml: str = "", towncrier_toml: str = "" 19 | ) -> str: 20 | """ 21 | Create a temporary directory with a pyproject.toml file in it. 22 | """ 23 | project_dir = self.mktemp() 24 | os.makedirs(project_dir) 25 | 26 | if pyproject_toml: 27 | write( 28 | os.path.join(project_dir, "pyproject.toml"), 29 | pyproject_toml, 30 | dedent=True, 31 | ) 32 | 33 | if towncrier_toml: 34 | write( 35 | os.path.join(project_dir, "towncrier.toml"), 36 | towncrier_toml, 37 | dedent=True, 38 | ) 39 | 40 | return project_dir 41 | 42 | def test_base(self): 43 | """ 44 | Test a "base config". 45 | """ 46 | project_dir = self.mktemp_project( 47 | pyproject_toml=""" 48 | [tool.towncrier] 49 | package = "foobar" 50 | orphan_prefix = "~" 51 | """ 52 | ) 53 | 54 | config = load_config(project_dir) 55 | self.assertEqual(config.package, "foobar") 56 | self.assertEqual(config.package_dir, ".") 57 | self.assertEqual(config.filename, "NEWS.rst") 58 | self.assertEqual(config.underlines, ("=", "-", "~")) 59 | self.assertEqual(config.orphan_prefix, "~") 60 | 61 | def test_markdown(self): 62 | """ 63 | If the filename references an .md file and the builtin template doesn't have an 64 | extension, add .md rather than .rst. 65 | """ 66 | project_dir = self.mktemp_project( 67 | pyproject_toml=""" 68 | [tool.towncrier] 69 | package = "foobar" 70 | filename = "NEWS.md" 71 | """ 72 | ) 73 | 74 | config = load_config(project_dir) 75 | 76 | self.assertEqual(config.filename, "NEWS.md") 77 | 78 | self.assertEqual(config.template, ("towncrier.templates", "default.md")) 79 | 80 | def test_explicit_template_extension(self): 81 | """ 82 | If the filename references an .md file and the builtin template has an 83 | extension, don't change it. 84 | """ 85 | project_dir = self.mktemp_project( 86 | pyproject_toml=""" 87 | [tool.towncrier] 88 | package = "foobar" 89 | filename = "NEWS.md" 90 | template = "towncrier:default.rst" 91 | """ 92 | ) 93 | 94 | config = load_config(project_dir) 95 | 96 | self.assertEqual(config.filename, "NEWS.md") 97 | self.assertEqual(config.template, ("towncrier.templates", "default.rst")) 98 | 99 | def test_template_extended(self): 100 | """ 101 | The template can be any package and resource, and although we look for a 102 | resource's 'templates' package, it could also be in the specified resource 103 | directly. 104 | """ 105 | project_dir = self.mktemp_project( 106 | pyproject_toml=""" 107 | [tool.towncrier] 108 | package = "foobar" 109 | template = "towncrier.templates:default.rst" 110 | """ 111 | ) 112 | 113 | config = load_config(project_dir) 114 | 115 | self.assertEqual(config.template, ("towncrier.templates", "default.rst")) 116 | 117 | def test_incorrect_single_file(self): 118 | """ 119 | single_file must be a bool. 120 | """ 121 | project_dir = self.mktemp_project( 122 | pyproject_toml=""" 123 | [tool.towncrier] 124 | single_file = "a" 125 | """ 126 | ) 127 | 128 | with self.assertRaises(ConfigError) as e: 129 | load_config(project_dir) 130 | 131 | self.assertEqual(e.exception.failing_option, "single_file") 132 | 133 | def test_incorrect_all_bullets(self): 134 | """ 135 | all_bullets must be a bool. 136 | """ 137 | project_dir = self.mktemp_project( 138 | pyproject_toml=""" 139 | [tool.towncrier] 140 | all_bullets = "a" 141 | """ 142 | ) 143 | 144 | with self.assertRaises(ConfigError) as e: 145 | load_config(project_dir) 146 | 147 | self.assertEqual(e.exception.failing_option, "all_bullets") 148 | 149 | def test_mistype_singlefile(self): 150 | """ 151 | singlefile is not accepted, single_file is. 152 | """ 153 | project_dir = self.mktemp_project( 154 | pyproject_toml=""" 155 | [tool.towncrier] 156 | singlefile = "a" 157 | """ 158 | ) 159 | 160 | with self.assertRaises(ConfigError) as e: 161 | load_config(project_dir) 162 | 163 | self.assertEqual(e.exception.failing_option, "singlefile") 164 | 165 | def test_towncrier_toml_preferred(self): 166 | """ 167 | Towncrier prefers the towncrier.toml for autodetect over pyproject.toml. 168 | """ 169 | project_dir = self.mktemp_project( 170 | towncrier_toml=""" 171 | [tool.towncrier] 172 | package = "a" 173 | """, 174 | pyproject_toml=""" 175 | [tool.towncrier] 176 | package = "b" 177 | """, 178 | ) 179 | 180 | config = load_config(project_dir) 181 | self.assertEqual(config.package, "a") 182 | 183 | def test_pyproject_only_pyproject_toml(self): 184 | """ 185 | Towncrier will fallback to the [project.name] value in pyproject.toml. 186 | 187 | This tests asserts that the minimal configuration is to do *nothing* 188 | when using a pyproject.toml file. 189 | """ 190 | project_dir = self.mktemp_project( 191 | pyproject_toml=""" 192 | [project] 193 | name = "a" 194 | """, 195 | ) 196 | 197 | config = load_config(project_dir) 198 | self.assertEqual(config.package, "a") 199 | self.assertEqual(config.name, "a") 200 | 201 | def test_pyproject_assert_fallback(self): 202 | """ 203 | This test is an extensive test of the fallback scenarios 204 | for the `package` and `name` keys in the towncrier section. 205 | 206 | It will fallback to pyproject.toml:name in any case. 207 | And as such it checks the various fallback mechanisms 208 | if the fields are not present in the towncrier.toml, nor 209 | in the pyproject.toml files. 210 | 211 | This both tests when things are *only* in the pyproject.toml 212 | and default usage of the data in the towncrier.toml file. 213 | """ 214 | pyproject_toml = dedent( 215 | """ 216 | [project] 217 | name = "foo" 218 | [tool.towncrier] 219 | """ 220 | ) 221 | towncrier_toml = dedent( 222 | """ 223 | [tool.towncrier] 224 | """ 225 | ) 226 | tests = [ 227 | "", 228 | "name = '{name}'", 229 | "package = '{package}'", 230 | "name = '{name}'", 231 | "package = '{package}'", 232 | ] 233 | 234 | def factory(name, package): 235 | def func(test): 236 | return dedent(test).format(name=name, package=package) 237 | 238 | return func 239 | 240 | for pp_fields in map(factory(name="a", package="b"), tests): 241 | pp_toml = pyproject_toml + pp_fields 242 | for tc_fields in map(factory(name="c", package="d"), tests): 243 | tc_toml = towncrier_toml + tc_fields 244 | 245 | # Create the temporary project 246 | project_dir = self.mktemp_project( 247 | pyproject_toml=pp_toml, 248 | towncrier_toml=tc_toml, 249 | ) 250 | 251 | # Read the configuration file. 252 | config = load_config(project_dir) 253 | 254 | # Now the values depend on where the fallback 255 | # is. 256 | # If something is in towncrier.toml, it will be preferred 257 | # name fallsback to package 258 | if "package" in tc_fields: 259 | package = "d" 260 | else: 261 | package = "foo" 262 | self.assertEqual(config.package, package) 263 | 264 | if "name" in tc_fields: 265 | self.assertEqual(config.name, "c") 266 | else: 267 | # fall-back to package name 268 | self.assertEqual(config.name, package) 269 | 270 | @with_isolated_runner 271 | def test_load_no_config(self, runner: CliRunner): 272 | """ 273 | Calling the root CLI without an existing configuration file in the base directory, 274 | will exit with code 1 and an informative message is sent to standard output. 275 | """ 276 | temp = self.mktemp() 277 | os.makedirs(temp) 278 | 279 | result = runner.invoke(cli, ("--dir", temp)) 280 | 281 | self.assertEqual( 282 | result.output, 283 | f"No configuration file found.\nLooked back from: {os.path.abspath(temp)}\n", 284 | ) 285 | self.assertEqual(result.exit_code, 1) 286 | 287 | @with_isolated_runner 288 | def test_load_explicit_missing_config(self, runner: CliRunner): 289 | """ 290 | Calling the CLI with an incorrect explicit configuration file will exit with 291 | code 1 and an informative message is sent to standard output. 292 | """ 293 | config = "not-there.toml" 294 | result = runner.invoke(cli, ("--config", config)) 295 | 296 | self.assertEqual(result.exit_code, 1) 297 | self.assertEqual( 298 | result.output, 299 | f"Configuration file '{os.path.abspath(config)}' not found.\n", 300 | ) 301 | 302 | def test_missing_template(self): 303 | """ 304 | Towncrier will raise an exception saying when it can't find a template. 305 | """ 306 | project_dir = self.mktemp_project( 307 | towncrier_toml=""" 308 | [tool.towncrier] 309 | template = "foo.rst" 310 | """ 311 | ) 312 | 313 | with self.assertRaises(ConfigError) as e: 314 | load_config(project_dir) 315 | 316 | self.assertEqual( 317 | str(e.exception), 318 | "The template file '{}' does not exist.".format( 319 | os.path.normpath(os.path.join(project_dir, "foo.rst")), 320 | ), 321 | ) 322 | 323 | def test_missing_template_in_towncrier(self): 324 | """ 325 | Towncrier will raise an exception saying when it can't find a template 326 | from the Towncrier templates. 327 | """ 328 | project_dir = self.mktemp_project( 329 | towncrier_toml=""" 330 | [tool.towncrier] 331 | template = "towncrier:foo" 332 | """ 333 | ) 334 | 335 | with self.assertRaises(ConfigError) as e: 336 | load_config(project_dir) 337 | 338 | self.assertEqual( 339 | str(e.exception), "'towncrier' does not have a template named 'foo.rst'." 340 | ) 341 | 342 | def test_custom_types_as_tables_array_deprecated(self): 343 | """ 344 | Custom fragment categories can be defined inside 345 | the toml config file using an array of tables 346 | (a table name in double brackets). 347 | 348 | This functionality is considered deprecated, but we continue 349 | to support it to keep backward compatibility. 350 | """ 351 | project_dir = self.mktemp_project( 352 | pyproject_toml=""" 353 | [tool.towncrier] 354 | package = "foobar" 355 | [[tool.towncrier.type]] 356 | directory="foo" 357 | name="Foo" 358 | showcontent=false 359 | 360 | [[tool.towncrier.type]] 361 | directory="spam" 362 | name="Spam" 363 | showcontent=true 364 | 365 | [[tool.towncrier.type]] 366 | directory="auto" 367 | name="Automatic" 368 | showcontent=true 369 | check=false 370 | """ 371 | ) 372 | config = load_config(project_dir) 373 | expected = [ 374 | ( 375 | "foo", 376 | { 377 | "name": "Foo", 378 | "showcontent": False, 379 | "check": True, 380 | }, 381 | ), 382 | ( 383 | "spam", 384 | { 385 | "name": "Spam", 386 | "showcontent": True, 387 | "check": True, 388 | }, 389 | ), 390 | ( 391 | "auto", 392 | { 393 | "name": "Automatic", 394 | "showcontent": True, 395 | "check": False, 396 | }, 397 | ), 398 | ] 399 | expected = dict(expected) 400 | actual = config.types 401 | self.assertDictEqual(expected, actual) 402 | 403 | def test_custom_types_as_tables(self): 404 | """ 405 | Custom fragment categories can be defined inside 406 | the toml config file using tables. 407 | """ 408 | project_dir = self.mktemp_project( 409 | pyproject_toml=""" 410 | [tool.towncrier] 411 | package = "foobar" 412 | [tool.towncrier.fragment.feat] 413 | ignored_field="Bazz" 414 | [tool.towncrier.fragment.fix] 415 | [tool.towncrier.fragment.chore] 416 | name = "Other Tasks" 417 | showcontent = false 418 | [tool.towncrier.fragment.auto] 419 | name = "Automatic" 420 | check = false 421 | """ 422 | ) 423 | config = load_config(project_dir) 424 | expected = { 425 | "chore": { 426 | "name": "Other Tasks", 427 | "showcontent": False, 428 | "check": True, 429 | }, 430 | "feat": { 431 | "name": "Feat", 432 | "showcontent": True, 433 | "check": True, 434 | }, 435 | "fix": { 436 | "name": "Fix", 437 | "showcontent": True, 438 | "check": True, 439 | }, 440 | "auto": { 441 | "name": "Automatic", 442 | "showcontent": True, 443 | "check": False, 444 | }, 445 | } 446 | actual = config.types 447 | self.assertDictEqual(expected, actual) 448 | -------------------------------------------------------------------------------- /src/towncrier/test/test_vcs.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) towncrier contributors, 2025 2 | # See LICENSE for details. 3 | 4 | import os.path 5 | import unittest 6 | 7 | from pathlib import Path 8 | 9 | from click.testing import CliRunner 10 | from twisted.trial.unittest import TestCase 11 | 12 | from towncrier import _vcs 13 | 14 | from . import test_check, test_hg 15 | from .helpers import setup_simple_project, write 16 | 17 | 18 | def novcs_create_project( 19 | pyproject_path="pyproject.toml", main_branch="main", extra_config="" 20 | ): 21 | setup_simple_project(pyproject_path=pyproject_path, extra_config=extra_config) 22 | Path("foo/newsfragments/123.feature").write_text("Adds levitation") 23 | 24 | 25 | def novcs_commit(message): 26 | pass 27 | 28 | 29 | class TestVCS(TestCase): 30 | def do_test_vcs(self, vcs): 31 | create_project = vcs["create_project"] 32 | commit = vcs["commit"] 33 | 34 | runner = CliRunner() 35 | with runner.isolated_filesystem(): 36 | create_project("pyproject.toml") 37 | 38 | write("changes/000.misc.rst", "some change") 39 | 40 | _vcs.stage_newsfile(os.getcwd(), "changes/000.misc.rst") 41 | 42 | commit("commit 1") 43 | 44 | branches = sorted(_vcs.get_remote_branches(".")) 45 | self.assertIn(branches, [[], ["main", "otherbranch"]]) 46 | 47 | if vcs["name"] != "novcs": 48 | self.assertIn( 49 | _vcs.list_changed_files_compared_to_branch(".", "main", False), 50 | [ 51 | ["changes/000.misc.rst"], 52 | [os.path.join("changes", "000.misc.rst")], 53 | ], 54 | ) 55 | 56 | write("changes/001.misc.rst", "some change") 57 | _vcs.remove_files( 58 | os.getcwd(), 59 | [ 60 | os.path.abspath(f) 61 | for f in ["changes/000.misc.rst", "changes/001.misc.rst"] 62 | ], 63 | ) 64 | 65 | def test_git(self): 66 | self.do_test_vcs( 67 | { 68 | "name": "git", 69 | "create_project": test_check.create_project, 70 | "commit": test_check.commit, 71 | }, 72 | ) 73 | 74 | @unittest.skipUnless(test_hg.hg_available, "requires 'mercurial' to be installed") 75 | def test_mercurial(self): 76 | self.do_test_vcs( 77 | { 78 | "name": "mercurial", 79 | "create_project": test_hg.create_project, 80 | "commit": test_hg.commit, 81 | }, 82 | ) 83 | 84 | def test_novcs(self): 85 | self.do_test_vcs( 86 | { 87 | "name": "novcs", 88 | "create_project": novcs_create_project, 89 | "commit": novcs_commit, 90 | }, 91 | ) 92 | -------------------------------------------------------------------------------- /src/towncrier/test/test_write.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Amber Brown, 2015 2 | # See LICENSE for details. 3 | 4 | import os 5 | 6 | from pathlib import Path 7 | from textwrap import dedent 8 | 9 | from click.testing import CliRunner 10 | from twisted.trial.unittest import TestCase 11 | 12 | from .._builder import render_fragments, split_fragments 13 | from .._writer import append_to_newsfile 14 | from ..build import _main 15 | from .helpers import read_pkg_resource, write 16 | 17 | 18 | class WritingTests(TestCase): 19 | maxDiff = None 20 | 21 | def test_append_at_top(self): 22 | fragments = { 23 | "": { 24 | ("142", "misc", 0): "", 25 | ("1", "misc", 0): "", 26 | ("4", "feature", 0): "Stuff!", 27 | ("4", "feature", 1): "Second Stuff!", 28 | ("2", "feature", 0): "Foo added.", 29 | ("72", "feature", 0): "Foo added.", 30 | }, 31 | "Names": {}, 32 | "Web": {("3", "bugfix", 0): "Web fixed."}, 33 | } 34 | 35 | definitions = { 36 | "feature": {"name": "Features", "showcontent": True}, 37 | "bugfix": {"name": "Bugfixes", "showcontent": True}, 38 | "misc": {"name": "Misc", "showcontent": False}, 39 | } 40 | 41 | expected_output = """MyProject 1.0 (never) 42 | ===================== 43 | 44 | Features 45 | -------- 46 | 47 | - Foo added. (#2, #72) 48 | - Stuff! (#4) 49 | - Second Stuff! (#4) 50 | 51 | 52 | Misc 53 | ---- 54 | 55 | - #1, #142 56 | 57 | 58 | Names 59 | ----- 60 | 61 | No significant changes. 62 | 63 | 64 | Web 65 | --- 66 | 67 | Bugfixes 68 | ~~~~~~~~ 69 | 70 | - Web fixed. (#3) 71 | 72 | 73 | Old text. 74 | """ 75 | 76 | tempdir = self.mktemp() 77 | os.makedirs(tempdir) 78 | 79 | with open(os.path.join(tempdir, "NEWS.rst"), "w") as f: 80 | f.write("Old text.\n") 81 | 82 | fragments = split_fragments(fragments, definitions) 83 | 84 | template = read_pkg_resource("templates/default.rst") 85 | 86 | append_to_newsfile( 87 | tempdir, 88 | "NEWS.rst", 89 | ".. towncrier release notes start\n", 90 | "", 91 | render_fragments( 92 | template, 93 | None, 94 | fragments, 95 | definitions, 96 | ["-", "~"], 97 | wrap=True, 98 | versiondata={"name": "MyProject", "version": "1.0", "date": "never"}, 99 | ), 100 | single_file=True, 101 | ) 102 | 103 | with open(os.path.join(tempdir, "NEWS.rst")) as f: 104 | output = f.read() 105 | 106 | self.assertEqual(expected_output, output) 107 | 108 | def test_append_at_top_with_hint(self): 109 | """ 110 | If there is a comment with C{.. towncrier release notes start}, 111 | towncrier will add the version notes after it. 112 | """ 113 | fragments = { 114 | "": { 115 | ("142", "misc", 0): "", 116 | ("1", "misc", 0): "", 117 | ("4", "feature", 0): "Stuff!", 118 | ("2", "feature", 0): "Foo added.", 119 | ("72", "feature", 0): "Foo added.", 120 | ("99", "feature", 0): "Foo! " * 100, 121 | }, 122 | "Names": {}, 123 | "Web": {("3", "bugfix", 0): "Web fixed."}, 124 | } 125 | 126 | definitions = { 127 | "feature": {"name": "Features", "showcontent": True}, 128 | "bugfix": {"name": "Bugfixes", "showcontent": True}, 129 | "misc": {"name": "Misc", "showcontent": False}, 130 | } 131 | 132 | expected_output = """Hello there! Here is some info. 133 | 134 | .. towncrier release notes start 135 | 136 | MyProject 1.0 (never) 137 | ===================== 138 | 139 | Features 140 | -------- 141 | 142 | - Foo added. (#2, #72) 143 | - Stuff! (#4) 144 | - Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! 145 | Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! 146 | Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! 147 | Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! 148 | Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! 149 | Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! 150 | Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! Foo! (#99) 151 | 152 | 153 | Misc 154 | ---- 155 | 156 | - #1, #142 157 | 158 | 159 | Names 160 | ----- 161 | 162 | No significant changes. 163 | 164 | 165 | Web 166 | --- 167 | 168 | Bugfixes 169 | ~~~~~~~~ 170 | 171 | - Web fixed. (#3) 172 | 173 | 174 | Old text. 175 | """ 176 | 177 | tempdir = self.mktemp() 178 | write( 179 | os.path.join(tempdir, "NEWS.rst"), 180 | contents="""\ 181 | Hello there! Here is some info. 182 | 183 | .. towncrier release notes start 184 | Old text. 185 | """, 186 | dedent=True, 187 | ) 188 | 189 | fragments = split_fragments(fragments, definitions) 190 | 191 | template = read_pkg_resource("templates/default.rst") 192 | 193 | append_to_newsfile( 194 | tempdir, 195 | "NEWS.rst", 196 | ".. towncrier release notes start\n", 197 | "", 198 | render_fragments( 199 | template, 200 | None, 201 | fragments, 202 | definitions, 203 | ["-", "~"], 204 | wrap=True, 205 | versiondata={"name": "MyProject", "version": "1.0", "date": "never"}, 206 | ), 207 | single_file=True, 208 | ) 209 | 210 | with open(os.path.join(tempdir, "NEWS.rst")) as f: 211 | output = f.read() 212 | 213 | self.assertEqual(expected_output, output) 214 | 215 | def test_multiple_file_no_start_string(self): 216 | """ 217 | When no `start_string` is defined, the generated content is added at 218 | the start of the file. 219 | """ 220 | tempdir = self.mktemp() 221 | os.makedirs(tempdir) 222 | 223 | definitions = {} 224 | fragments = split_fragments(fragments={}, definitions=definitions) 225 | 226 | template = read_pkg_resource("templates/default.rst") 227 | 228 | content = render_fragments( 229 | template=template, 230 | issue_format=None, 231 | fragments=fragments, 232 | definitions=definitions, 233 | underlines=["-", "~"], 234 | wrap=True, 235 | versiondata={"name": "MyProject", "version": "1.0", "date": "never"}, 236 | ) 237 | 238 | append_to_newsfile( 239 | directory=tempdir, 240 | filename="NEWS.rst", 241 | start_string=None, 242 | top_line="", 243 | content=content, 244 | single_file=True, 245 | ) 246 | 247 | with open(os.path.join(tempdir, "NEWS.rst")) as f: 248 | output = f.read() 249 | 250 | expected_output = dedent( 251 | """\ 252 | MyProject 1.0 (never) 253 | ===================== 254 | """ 255 | ) 256 | 257 | self.assertEqual(expected_output, output) 258 | 259 | def test_with_title_format_duplicate_version_raise(self): 260 | """ 261 | When `single_file` enabled as default, 262 | and fragments of `version` already produced in the newsfile, 263 | a duplicate `build` will throw a ValueError. 264 | """ 265 | runner = CliRunner() 266 | 267 | def do_build_once(): 268 | with open("newsfragments/123.feature", "w") as f: 269 | f.write("Adds levitation") 270 | 271 | result = runner.invoke( 272 | _main, 273 | [ 274 | "--version", 275 | "7.8.9", 276 | "--name", 277 | "foo", 278 | "--date", 279 | "01-01-2001", 280 | "--yes", 281 | ], 282 | ) 283 | return result 284 | 285 | # `single_file` default as true 286 | with runner.isolated_filesystem(): 287 | with open("pyproject.toml", "w") as f: 288 | f.write( 289 | dedent( 290 | """ 291 | [tool.towncrier] 292 | title_format="{name} {version} ({project_date})" 293 | filename="{version}-notes.rst" 294 | """ 295 | ).lstrip() 296 | ) 297 | with open("{version}-notes.rst", "w") as f: 298 | f.write("Release Notes\n\n.. towncrier release notes start\n") 299 | os.mkdir("newsfragments") 300 | 301 | result = do_build_once() 302 | self.assertEqual(0, result.exit_code) 303 | # build again with the same version 304 | result = do_build_once() 305 | self.assertNotEqual(0, result.exit_code) 306 | self.assertIsInstance(result.exception, ValueError) 307 | self.assertSubstring( 308 | "already produced newsfiles for this version", result.exception.args[0] 309 | ) 310 | 311 | def test_single_file_false_overwrite_duplicate_version(self): 312 | """ 313 | When `single_file` disabled, multiple newsfiles generated and 314 | the content of which get overwritten each time. 315 | """ 316 | runner = CliRunner() 317 | 318 | def do_build_once(): 319 | with open("newsfragments/123.feature", "w") as f: 320 | f.write("Adds levitation") 321 | 322 | result = runner.invoke( 323 | _main, 324 | [ 325 | "--version", 326 | "7.8.9", 327 | "--name", 328 | "foo", 329 | "--date", 330 | "01-01-2001", 331 | "--yes", 332 | ], 333 | ) 334 | return result 335 | 336 | # single_file = false 337 | with runner.isolated_filesystem(): 338 | with open("pyproject.toml", "w") as f: 339 | f.write( 340 | dedent( 341 | """ 342 | [tool.towncrier] 343 | single_file=false 344 | title_format="{name} {version} ({project_date})" 345 | filename="{version}-notes.rst" 346 | """ 347 | ).lstrip() 348 | ) 349 | os.mkdir("newsfragments") 350 | 351 | result = do_build_once() 352 | self.assertEqual(0, result.exit_code) 353 | # build again with the same version 354 | result = do_build_once() 355 | self.assertEqual(0, result.exit_code) 356 | 357 | notes = list(Path.cwd().glob("*-notes.rst")) 358 | self.assertEqual(1, len(notes)) 359 | self.assertEqual("7.8.9-notes.rst", notes[0].name) 360 | 361 | with open(notes[0]) as f: 362 | output = f.read() 363 | 364 | expected_output = dedent( 365 | """\ 366 | foo 7.8.9 (01-01-2001) 367 | ====================== 368 | 369 | Features 370 | -------- 371 | 372 | - Adds levitation (#123) 373 | """ 374 | ) 375 | 376 | self.assertEqual(expected_output, output) 377 | --------------------------------------------------------------------------------