From c0af1f8940ed40d078d68564e9d0a8f7d66e6063 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Sun, 9 Jul 2023 14:00:38 +0200 Subject: [PATCH 01/51] Test CLI using click.testing.CliRunner (#530) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Test ‘build’ behaviour using a ‘click.testing.CliRunner’. This invokes the command behaviour consistently, without assuming any particular installed command name. * Use CliRunner for testing CLI standard output streams. * Document the improvement in a Towncrier news fragment. * Test ‘--version’ behaviour using a ‘click.testing.CliRunner’. This invokes the command behaviour consistently, without assuming any particular installed command name. * Test ‘--help’ behaviour using a ‘click.testing.CliRunner’. This invokes the command behaviour consistently, without assuming any particular installed command name. * Remove unused imports. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Semantic linefeeds * Markup --------- Co-authored-by: Ben Finney Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- src/towncrier/newsfragments/481.misc | 3 +++ src/towncrier/test/test_check.py | 22 +++++++++------------- src/towncrier/test/test_project.py | 18 ++++++++++++------ 3 files changed, 24 insertions(+), 19 deletions(-) create mode 100644 src/towncrier/newsfragments/481.misc diff --git a/src/towncrier/newsfragments/481.misc b/src/towncrier/newsfragments/481.misc new file mode 100644 index 00000000..720d4b83 --- /dev/null +++ b/src/towncrier/newsfragments/481.misc @@ -0,0 +1,3 @@ +``click.testing.CliRunner`` is now consistently used for testing CLI behaviour. + +This invokes the command behaviour consistently, without assuming any particular installed command name. diff --git a/src/towncrier/test/test_check.py b/src/towncrier/test/test_check.py index 9c7c7c81..102ab20a 100644 --- a/src/towncrier/test/test_check.py +++ b/src/towncrier/test/test_check.py @@ -3,16 +3,16 @@ import os import os.path -import sys import warnings from pathlib import Path -from subprocess import PIPE, Popen, call +from subprocess import call from click.testing import CliRunner from twisted.trial.unittest import TestCase from towncrier import check +from towncrier.build import _main as towncrier_build from towncrier.check import _main as towncrier_check from .helpers import setup_simple_project, with_isolated_runner, write @@ -182,15 +182,11 @@ def test_none_stdout_encoding_works(self): call(["git", "add", fragment_path]) call(["git", "commit", "-m", "add a newsfragment"]) - proc = Popen( - [sys.executable, "-m", "towncrier.check", "--compare-with", "master"], - stdout=PIPE, - stderr=PIPE, - ) - stdout, stderr = proc.communicate() + runner = CliRunner(mix_stderr=False) + result = runner.invoke(towncrier_check, ["--compare-with", "master"]) - self.assertEqual(0, proc.returncode) - self.assertEqual(b"", stderr) + self.assertEqual(0, result.exit_code) + self.assertEqual(0, len(result.stderr)) def test_first_release(self): """ @@ -207,7 +203,7 @@ def test_first_release(self): # Before any release, the NEWS file might no exist. self.assertNotIn("NEWS.rst", os.listdir(".")) - call(["towncrier", "build", "--yes", "--version", "1.0"]) + runner.invoke(towncrier_build, ["--yes", "--version", "1.0"]) commit("Prepare a release") # When missing, # the news file is automatically created with a new release. @@ -235,7 +231,7 @@ def test_release_branch(self): # Do a first release without any checks. # And merge the release branch back into the main branch. - call(["towncrier", "build", "--yes", "--version", "1.0"]) + runner.invoke(towncrier_build, ["--yes", "--version", "1.0"]) commit("First release") # The news file is now created. self.assertIn("NEWS.rst", os.listdir(".")) @@ -260,7 +256,7 @@ def test_release_branch(self): # We now have the new release branch. call(["git", "checkout", "-b", "next-release"]) - call(["towncrier", "build", "--yes", "--version", "2.0"]) + runner.invoke(towncrier_build, ["--yes", "--version", "2.0"]) commit("Second release") # Act diff --git a/src/towncrier/test/test_project.py b/src/towncrier/test/test_project.py index c41c3a55..45875683 100644 --- a/src/towncrier/test/test_project.py +++ b/src/towncrier/test/test_project.py @@ -4,12 +4,13 @@ import os import sys -from subprocess import check_output from unittest import skipIf +from click.testing import CliRunner from twisted.trial.unittest import TestCase from .._project import get_project_name, get_version +from .._shell import cli as towncrier_cli from .helpers import write @@ -19,6 +20,9 @@ metadata_version = None +towncrier_cli.name = "towncrier" + + class VersionFetchingTests(TestCase): def test_str(self): """ @@ -174,6 +178,7 @@ def test_dash_m(self): """ `python -m towncrier` invokes the main entrypoint. """ + runner = CliRunner() temp = self.mktemp() new_dir = os.path.join(temp, "dashm") os.makedirs(new_dir) @@ -183,9 +188,9 @@ def test_dash_m(self): with open("pyproject.toml", "w") as f: f.write("[tool.towncrier]\n" 'directory = "news"\n') os.makedirs("news") - out = check_output([sys.executable, "-m", "towncrier", "--help"]) - self.assertIn(b"[OPTIONS] COMMAND [ARGS]...", out) - self.assertRegex(out, rb".*--help\s+Show this message and exit.") + result = runner.invoke(towncrier_cli, ["--help"]) + self.assertIn("[OPTIONS] COMMAND [ARGS]...", result.stdout) + self.assertRegex(result.stdout, r".*--help\s+Show this message and exit.") finally: os.chdir(orig_dir) @@ -193,5 +198,6 @@ def test_version(self): """ `--version` command line option is available to show the current production version. """ - out = check_output(["towncrier", "--version"]) - self.assertTrue(out.startswith(b"towncrier, version 2")) + runner = CliRunner() + result = runner.invoke(towncrier_cli, ["--version"]) + self.assertTrue(result.output.startswith("towncrier, version 2")) From 121ebc3441b0128954f3f772dd0a38fcd409f287 Mon Sep 17 00:00:00 2001 From: Denis Laxalde Date: Thu, 20 Jul 2023 16:34:37 +0200 Subject: [PATCH 02/51] Remove a spurious ` in 'towncrier create' doc (#536) * Remove a spurious ` in 'towncrier create' doc * Add fragment to make CI happy --------- Co-authored-by: Adi Roiban --- docs/cli.rst | 2 +- src/towncrier/newsfragments/536.misc | 0 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 src/towncrier/newsfragments/536.misc diff --git a/docs/cli.rst b/docs/cli.rst index af03c784..25eb2cea 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -76,7 +76,7 @@ In the above example, it will generate ``123.bugfix.1.rst`` if ``123.bugfix.rst` .. option:: --edit - Create file and start `$EDITOR` to edit it right away.` + Create file and start `$EDITOR` to edit it right away. ``towncrier check`` diff --git a/src/towncrier/newsfragments/536.misc b/src/towncrier/newsfragments/536.misc new file mode 100644 index 00000000..e69de29b From 66134cc1266ba6543baa506f183d984cb28fb191 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 1 Aug 2023 08:30:57 +0200 Subject: [PATCH 03/51] [pre-commit.ci] pre-commit autoupdate (#539) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 23.3.0 → 23.7.0](https://github.com/psf/black/compare/23.3.0...23.7.0) - [github.com/asottile/pyupgrade: v3.8.0 → v3.10.1](https://github.com/asottile/pyupgrade/compare/v3.8.0...v3.10.1) - [github.com/PyCQA/flake8: 6.0.0 → 6.1.0](https://github.com/PyCQA/flake8/compare/6.0.0...6.1.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 93938b59..52b9da56 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,12 +4,12 @@ ci: repos: - repo: https://github.com/psf/black - rev: 23.3.0 + rev: 23.7.0 hooks: - id: black - repo: https://github.com/asottile/pyupgrade - rev: v3.8.0 + rev: v3.10.1 hooks: - id: pyupgrade args: [--py38-plus] @@ -21,7 +21,7 @@ repos: additional_dependencies: [toml] - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 + rev: 6.1.0 hooks: - id: flake8 From 3717dbf416bd7ac1580908bb23501096ae77df3b Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Fri, 4 Aug 2023 06:51:29 +0200 Subject: [PATCH 04/51] Vendor click_default_group to make us installable again (#540) * Vendor click_default_group to make us installable again Fixes #492 Co-authored-by: Heungsub Lee <19982+sublee@users.noreply.github.com> * Add news fragment --------- Co-authored-by: Heungsub Lee <19982+sublee@users.noreply.github.com> --- pyproject.toml | 8 +- src/towncrier/_shell.py | 3 +- src/towncrier/click_default_group.py | 148 +++++++++++++++++++++++++ src/towncrier/newsfragments/540.bugfix | 1 + 4 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 src/towncrier/click_default_group.py create mode 100644 src/towncrier/newsfragments/540.bugfix diff --git a/pyproject.toml b/pyproject.toml index 240da5d7..11481040 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,6 @@ classifiers = [ requires-python = ">=3.8" dependencies = [ "click", - "click-default-group", "importlib-resources>=5; python_version<'3.10'", "incremental", "jinja2", @@ -146,9 +145,9 @@ strict = true exclude = '^src/towncrier/test/test_.*\.py$' [[tool.mypy.overrides]] -module = 'click_default_group' -# 2022-09-04: This library has no type annotations. -ignore_missing_imports = true +module = 'towncrier.click_default_group' +# Vendored module without type annotations. +ignore_errors = true [[tool.mypy.overrides]] module = 'incremental' @@ -174,4 +173,5 @@ exclude_lines = [ omit = [ "src/towncrier/__main__.py", "src/towncrier/test/*", + "src/towncrier/click_default_group.py", ] diff --git a/src/towncrier/_shell.py b/src/towncrier/_shell.py index 0efe1b69..7aac1721 100644 --- a/src/towncrier/_shell.py +++ b/src/towncrier/_shell.py @@ -11,11 +11,10 @@ import click -from click_default_group import DefaultGroup - from ._version import __version__ from .build import _main as _build_cmd from .check import _main as _check_cmd +from .click_default_group import DefaultGroup from .create import _main as _create_cmd diff --git a/src/towncrier/click_default_group.py b/src/towncrier/click_default_group.py new file mode 100644 index 00000000..6d5e69bd --- /dev/null +++ b/src/towncrier/click_default_group.py @@ -0,0 +1,148 @@ +# SPDX-FileCopyrightText: 2015 Heungsub Lee <19982+sublee@users.noreply.github.com> +# +# SPDX-License-Identifier: BSD-3-Clause + +# Vendored from +# https://github.com/click-contrib/click-default-group/tree/b671ae5325d186fe5ea7abb584f15852a1e931aa +# Because the PyPI package could not be installed on modern Pips anymore and +# the project looks unmaintaintained. + +""" + click_default_group + ~~~~~~~~~~~~~~~~~~~ + + Define a default subcommand by `default=True`: + + .. sourcecode:: python + + import click + from click_default_group import DefaultGroup + + @click.group(cls=DefaultGroup, default_if_no_args=True) + def cli(): + pass + + @cli.command(default=True) + def foo(): + click.echo('foo') + + @cli.command() + def bar(): + click.echo('bar') + + Then you can invoke that without explicit subcommand name: + + .. sourcecode:: console + + $ cli.py --help + Usage: cli.py [OPTIONS] COMMAND [ARGS]... + + Options: + --help Show this message and exit. + + Command: + foo* + bar + + $ cli.py + foo + $ cli.py foo + foo + $ cli.py bar + bar + +""" +import warnings + +import click + + +__all__ = ["DefaultGroup"] +__version__ = "1.2.2" + + +class DefaultGroup(click.Group): + """Invokes a subcommand marked with `default=True` if any subcommand not + chosen. + + :param default_if_no_args: resolves to the default command if no arguments + passed. + + """ + + def __init__(self, *args, **kwargs): + # To resolve as the default command. + if not kwargs.get("ignore_unknown_options", True): + raise ValueError("Default group accepts unknown options") + self.ignore_unknown_options = True + self.default_cmd_name = kwargs.pop("default", None) + self.default_if_no_args = kwargs.pop("default_if_no_args", False) + super().__init__(*args, **kwargs) + + def set_default_command(self, command): + """Sets a command function as the default command.""" + cmd_name = command.name + self.add_command(command) + self.default_cmd_name = cmd_name + + def parse_args(self, ctx, args): + if not args and self.default_if_no_args: + args.insert(0, self.default_cmd_name) + return super().parse_args(ctx, args) + + def get_command(self, ctx, cmd_name): + if cmd_name not in self.commands: + # No command name matched. + ctx.arg0 = cmd_name + cmd_name = self.default_cmd_name + return super().get_command(ctx, cmd_name) + + def resolve_command(self, ctx, args): + base = super() + cmd_name, cmd, args = base.resolve_command(ctx, args) + if hasattr(ctx, "arg0"): + args.insert(0, ctx.arg0) + cmd_name = cmd.name + return cmd_name, cmd, args + + def format_commands(self, ctx, formatter): + formatter = DefaultCommandFormatter(self, formatter, mark="*") + return super().format_commands(ctx, formatter) + + def command(self, *args, **kwargs): + default = kwargs.pop("default", False) + decorator = super().command(*args, **kwargs) + if not default: + return decorator + warnings.warn( + "Use default param of DefaultGroup or " "set_default_command() instead", + DeprecationWarning, + ) + + def _decorator(f): + cmd = decorator(f) + self.set_default_command(cmd) + return cmd + + return _decorator + + +class DefaultCommandFormatter: + """Wraps a formatter to mark a default command.""" + + def __init__(self, group, formatter, mark="*"): + self.group = group + self.formatter = formatter + self.mark = mark + + def __getattr__(self, attr): + return getattr(self.formatter, attr) + + def write_dl(self, rows, *args, **kwargs): + rows_ = [] + for cmd_name, help in rows: + if cmd_name == self.group.default_cmd_name: + rows_.insert(0, (cmd_name + self.mark, help)) + else: + rows_.append((cmd_name, help)) + return self.formatter.write_dl(rows_, *args, **kwargs) diff --git a/src/towncrier/newsfragments/540.bugfix b/src/towncrier/newsfragments/540.bugfix new file mode 100644 index 00000000..7e2f12b0 --- /dev/null +++ b/src/towncrier/newsfragments/540.bugfix @@ -0,0 +1 @@ +Towncrier now vendors the click-default-group package that prevented installations on modern Pips. From 806bec1248b0ea8b86db21e0ce90b1849d3cd721 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Fri, 4 Aug 2023 14:39:08 +0200 Subject: [PATCH 05/51] Add 3.12 (#541) * Add 3.12 * Allow prereleases in CI * Update baipp to 1.5 while at it * Nicer summary, too * Make life simpler for next time --- .github/workflows/ci.yml | 9 +++++++-- noxfile.py | 3 ++- pyproject.toml | 2 ++ src/towncrier/newsfragments/541.feature | 1 + 4 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 src/towncrier/newsfragments/541.feature diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c2e8339..3941d138 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: hynek/build-and-inspect-python-package@f336040a31ea03a2551adcc994d3ddb2f3ea3f7f + - uses: hynek/build-and-inspect-python-package@6a687a6d3567bc184c1fc694ee7f0f328594ef25 - name: Set up ${{ matrix.python.name }} uses: actions/setup-python@v4 @@ -59,6 +59,7 @@ jobs: strategy: fail-fast: false matrix: + # Keep list in-sync with noxfile/tests & pyproject.toml. python: - name: CPython 3.8 action: 3.8 @@ -68,6 +69,8 @@ jobs: action: '3.10' - name: CPython 3.11 action: '3.11' + - name: CPython 3.12 + action: '3.12' - name: PyPy 3.8 action: pypy3.8 task: @@ -87,6 +90,8 @@ jobs: uses: actions/setup-python@v4 with: python-version: ${{ matrix.python.action }} + allow-prereleases: true + cache: pip - name: Install dependencies run: python -m pip install --upgrade pip nox @@ -252,7 +257,7 @@ jobs: python -Im coverage html --skip-covered --skip-empty # Report and write to summary. - python -Im coverage report | sed 's/^/ /' >> $GITHUB_STEP_SUMMARY + python -Im coverage report --format=markdown >> $GITHUB_STEP_SUMMARY # Report again and fail if under 100%. python -Im coverage report --fail-under=100 diff --git a/noxfile.py b/noxfile.py index 3f792708..bcd7f4ef 100644 --- a/noxfile.py +++ b/noxfile.py @@ -17,7 +17,8 @@ def pre_commit(session: nox.Session) -> None: session.run("pre-commit", "run", "--all-files", "--show-diff-on-failure") -@nox.session(python=["pypy3.8", "3.8", "3.9", "3.10", "3.11"]) +# Keep list in-sync with ci.yml/test-linux & pyproject.toml +@nox.session(python=["pypy3.8", "3.8", "3.9", "3.10", "3.11", "3.12"]) def tests(session: nox.Session) -> None: session.install("Twisted", "coverage[toml]") posargs = list(session.posargs) diff --git a/pyproject.toml b/pyproject.toml index 11481040..9f79487a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ name = "towncrier" description = "Building newsfiles for your project." readme = "README.rst" license = "MIT" +# Keep version list in-sync with noxfile/tests & ci.yml/test-linux. classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", @@ -22,6 +23,7 @@ classifiers = [ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ] diff --git a/src/towncrier/newsfragments/541.feature b/src/towncrier/newsfragments/541.feature new file mode 100644 index 00000000..22256fa1 --- /dev/null +++ b/src/towncrier/newsfragments/541.feature @@ -0,0 +1 @@ +Python 3.12 is now officially supported. From 239f84102c374e7395cdc0a50f7b4171cc8c3e83 Mon Sep 17 00:00:00 2001 From: Elliot Ford Date: Fri, 15 Sep 2023 12:00:15 +0100 Subject: [PATCH 06/51] use markdown default template in markdown docs (#546) * use markdown default template in markdown docs * Remove site_packages from .readthedocs.yaml https://blog.readthedocs.com/drop-support-system-packages/ --------- Co-authored-by: Adi Roiban --- .readthedocs.yaml | 1 - docs/markdown.rst | 36 ++++++----------------------- src/towncrier/newsfragments/545.doc | 1 + 3 files changed, 8 insertions(+), 30 deletions(-) create mode 100644 src/towncrier/newsfragments/545.doc diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 71513bbb..146090f5 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -13,7 +13,6 @@ build: python: "3.11" python: - system_packages: False install: - method: pip path: . diff --git a/docs/markdown.rst b/docs/markdown.rst index a81fbbcc..7c07aa5e 100644 --- a/docs/markdown.rst +++ b/docs/markdown.rst @@ -21,7 +21,6 @@ Put the following into your ``pyproject.toml`` or ``towncrier.toml``: filename = "CHANGELOG.md" start_string = "\n" underlines = ["", "", ""] - template = "changelog.d/changelog_template.jinja" title_format = "## [{version}](https://github.com/twisted/my-project/tree/{version}) - {project_date}" issue_format = "[#{issue}](https://github.com/twisted/my-project/issues/{issue})" @@ -57,33 +56,12 @@ Put the following into your ``pyproject.toml`` or ``towncrier.toml``: -Next create the news fragment directory and the news file template: +Next create the news fragment directory: .. code-block:: console $ mkdir changelog.d -And put the following into ``changelog.d/changelog_template.jinja``: - -.. code-block:: jinja - - {% if sections[""] %} - {% for category, val in definitions.items() if category in sections[""] %} - - ### {{ definitions[category]['name'] }} - - {% for text, values in sections[""][category].items() %} - - {{ text }} {{ values|join(', ') }} - {% endfor %} - - {% endfor %} - {% else %} - No significant changes. - - - {% endif %} - - Next, create the news file with an explanatory header:: $ cat >CHANGELOG.md < Date: Tue, 3 Oct 2023 09:15:01 +0200 Subject: [PATCH 07/51] [pre-commit.ci] pre-commit autoupdate (#549) --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 52b9da56..71e54206 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,12 +4,12 @@ ci: repos: - repo: https://github.com/psf/black - rev: 23.7.0 + rev: 23.9.1 hooks: - id: black - repo: https://github.com/asottile/pyupgrade - rev: v3.10.1 + rev: v3.14.0 hooks: - id: pyupgrade args: [--py38-plus] From 7f37ab5daa72f5a761db1bf14cba1e2d3d87422d Mon Sep 17 00:00:00 2001 From: Ross Patterson Date: Sat, 14 Oct 2023 13:45:18 -0700 Subject: [PATCH 08/51] docs(tutorial): Cleanup duplicate backtick typo (#551) * docs(tutorial): Cleanup duplicate backtick typo * Update and rename docs-tutorial-backtick.doc.rst to 551.doc Add standard news fragment file --------- Co-authored-by: Adi Roiban --- docs/tutorial.rst | 2 +- src/towncrier/newsfragments/551.doc | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 src/towncrier/newsfragments/551.doc diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 0a42ff35..875ed1f3 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -130,7 +130,7 @@ You should get an output similar to this:: Improved Documentation ---------------------- - - Can also be ``rst``` as well! (#3456, #7890) + - Can also be ``rst`` as well! (#3456, #7890) Deprecations and Removals diff --git a/src/towncrier/newsfragments/551.doc b/src/towncrier/newsfragments/551.doc new file mode 100644 index 00000000..37a8cdc1 --- /dev/null +++ b/src/towncrier/newsfragments/551.doc @@ -0,0 +1 @@ +Cleanup a duplicate backtick in the tutorial. From 3f24b6ef7e0c17f3e559b957526598c287774b70 Mon Sep 17 00:00:00 2001 From: Ilia Kurenkov Date: Sat, 21 Oct 2023 05:03:37 +0200 Subject: [PATCH 09/51] Whitespace between releases can be configured in the template. (#553) * Whitespace between releases can be configured in the template. It used to be baked into the rendering code. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix comment * Apply suggestions from code review Co-authored-by: Adi Roiban * Don't modify whitespace in draft mode * Comment that explains the whitespace changes --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Adi Roiban --- src/towncrier/_builder.py | 2 +- src/towncrier/_writer.py | 8 +++----- src/towncrier/newsfragments/552.feature | 4 ++++ src/towncrier/templates/default.md | 6 +++++- src/towncrier/test/test_build.py | 17 +++++++++++++++++ src/towncrier/test/test_format.py | 14 ++++++++++++++ 6 files changed, 44 insertions(+), 7 deletions(-) create mode 100644 src/towncrier/newsfragments/552.feature diff --git a/src/towncrier/_builder.py b/src/towncrier/_builder.py index d1b14dba..6f8f166b 100644 --- a/src/towncrier/_builder.py +++ b/src/towncrier/_builder.py @@ -335,4 +335,4 @@ def get_indent(text: str) -> str: else: done.append(line) - return "\n".join(done).rstrip() + "\n" + return "\n".join(done) diff --git a/src/towncrier/_writer.py b/src/towncrier/_writer.py index 92119114..4145e3d4 100644 --- a/src/towncrier/_writer.py +++ b/src/towncrier/_writer.py @@ -43,11 +43,9 @@ def append_to_newsfile( with news_file.open("w", encoding="utf8", newline="") as f: if header: f.write(header) - - f.write(content) - - if prev_body: - f.write(f"\n\n{prev_body}") + # If there is no previous body that means we're writing a brand new news file. + # We don't want extra whitespace at the end of this new file. + f.write(content + prev_body if prev_body else content.rstrip() + "\n") def _figure_out_existing_content( diff --git a/src/towncrier/newsfragments/552.feature b/src/towncrier/newsfragments/552.feature new file mode 100644 index 00000000..56b9f4a1 --- /dev/null +++ b/src/towncrier/newsfragments/552.feature @@ -0,0 +1,4 @@ +Two newlines are no longer always added between the current release notes and the previous content. +The newlines are now defined only inside the template. + +**Important! If you're using a custom template and want to keep the same whitespace between releases, you may have to modify your template.** diff --git a/src/towncrier/templates/default.md b/src/towncrier/templates/default.md index 45f7a395..9afb5c49 100644 --- a/src/towncrier/templates/default.md +++ b/src/towncrier/templates/default.md @@ -58,4 +58,8 @@ No significant changes. No significant changes. {% endif %} -{% endfor %} +{% endfor +%} +{# +This comment adds one more newline at the end of the rendered newsfile content. +In this way the there are 2 newlines between the latest release and the previous release content. +#} diff --git a/src/towncrier/test/test_build.py b/src/towncrier/test/test_build.py index d4561263..aa483ba9 100644 --- a/src/towncrier/test/test_build.py +++ b/src/towncrier/test/test_build.py @@ -76,6 +76,8 @@ def _test_command(self, command): - Another orphaned feature - Orphaned feature + + """ ), ) @@ -307,6 +309,8 @@ def run_order_scenario(sections, types): - section-b type-2 (#1) + + """ ), ) @@ -350,6 +354,8 @@ def run_order_scenario(sections, types): - section-a type-1 (#1) + + """ ), ) @@ -576,6 +582,8 @@ def test_projectless_changelog(self): - Adds levitation (#123) - Extends levitation (#124) + + """ ).lstrip(), ) @@ -616,6 +624,8 @@ def test_version_in_config(self): - Adds levitation (#123) + + """ ).lstrip(), ) @@ -658,6 +668,8 @@ def test_project_name_in_config(self): - Adds levitation (#123) + + """ ).lstrip(), ) @@ -703,6 +715,8 @@ def test_no_package_changelog(self): - Adds levitation (#123) + + """ ).lstrip(), ) @@ -1035,6 +1049,8 @@ def test_title_format_custom(self): - Adds levitation (#123) - Extends levitation (#124) + + """ ) @@ -1339,6 +1355,7 @@ def test_with_topline_and_template_and_draft(self): - Adds levitation + """ ) diff --git a/src/towncrier/test/test_format.py b/src/towncrier/test/test_format.py index 61e6d88c..a1efefb5 100644 --- a/src/towncrier/test/test_format.py +++ b/src/towncrier/test/test_format.py @@ -107,6 +107,8 @@ def test_basic(self): ~~~~~~~~ - Web fixed. (#3) + + """ template = read_pkg_resource("templates/default.rst") @@ -154,6 +156,8 @@ def test_basic(self): ^^^^^^^^ - Web fixed. (#3) + + """ output = render_fragments( @@ -229,6 +233,8 @@ def test_markdown(self): (#2) - Web fixed. (#3) + + """ template = read_pkg_resource("templates/default.md") @@ -291,6 +297,8 @@ def test_markdown(self): [2]: https://github.com/twisted/towncrier/issues/2 [3]: https://github.com/twisted/towncrier/issues/3 + + """ output = render_fragments( @@ -332,6 +340,8 @@ def test_issue_format(self): ---- - xxbar, xx1, xx9, xx142 + + """ template = read_pkg_resource("templates/default.rst") @@ -386,6 +396,8 @@ def test_line_wrapping(self): - a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a (#3) + + """ template = read_pkg_resource("templates/default.rst") @@ -433,6 +445,8 @@ def test_line_wrapping_disabled(self): - asdf asdf asdf asdf looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong newsfragment. (#1) - https://google.com/q=?---------------------------------------------------------------------------------------------------- (#2) - a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a (#3) + + """ # NOQA template = read_pkg_resource("templates/default.rst") From 0b023fa95926470482418d4e11f9ae9f0ada7d56 Mon Sep 17 00:00:00 2001 From: Ilia Kurenkov Date: Sun, 22 Oct 2023 21:30:52 +0200 Subject: [PATCH 10/51] Config location separate from directory containing news file and fragments (#548) * Build command supports multiple projects with one config The create command already supports this. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Full documented support for sharing config between multiple projects * Apply suggestions from code review Co-authored-by: Adi Roiban * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Adi Roiban Co-authored-by: Adi Roiban --- docs/cli.rst | 3 +- docs/configuration.rst | 3 +- docs/index.rst | 1 + docs/monorepo.rst | 52 +++++++++++++ src/towncrier/build.py | 4 +- src/towncrier/check.py | 10 +-- src/towncrier/newsfragments/548.feature | 2 + src/towncrier/test/test_build.py | 27 +++++++ src/towncrier/test/test_check.py | 98 +++++++++++++++++++++++++ src/towncrier/test/test_create.py | 34 +++++++++ 10 files changed, 226 insertions(+), 8 deletions(-) create mode 100644 docs/monorepo.rst create mode 100644 src/towncrier/newsfragments/548.feature diff --git a/docs/cli.rst b/docs/cli.rst index 25eb2cea..90b5c899 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -12,7 +12,8 @@ The following options can be passed to all of the commands that explained below: .. option:: --dir PATH - Build fragment in ``PATH``. + The command is executed relative to ``PATH``. + 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``. Default: current directory. diff --git a/docs/configuration.rst b/docs/configuration.rst index 7d73f508..d424e1c0 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -51,12 +51,13 @@ Top level keys The directory storing your news fragments. For Python projects that provide a ``package`` key, the default is a ``newsfragments`` directory within the package. - Otherwise the default is a ``newsfragments`` directory relative to the configuration file. + Otherwise the default is a ``newsfragments`` directory relative to either the directory passed as ``--dir`` or (by default) the configuration file. ``filename`` The filename of your news file. ``"NEWS.rst"`` by default. + Its location is determined the same way as the location of the directory storing the news fragments. ``template`` Path to the template for generating the news file. diff --git a/docs/index.rst b/docs/index.rst index 975373dd..f83e8ce4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -13,6 +13,7 @@ Narrative tutorial markdown + monorepo Reference diff --git a/docs/monorepo.rst b/docs/monorepo.rst new file mode 100644 index 00000000..0deb8392 --- /dev/null +++ b/docs/monorepo.rst @@ -0,0 +1,52 @@ +Multiple Projects Share One Config (Monorepo) +============================================= + +Several projects may have independent release notes with the same format. +For instance packages in a monorepo. +Here's how you can use towncrier to set this up. + +Below is a minimal example: + +.. code-block:: text + + repo + ├── project_a + │ ├── newsfragments + │ │ └── 123.added + │ ├── project_a + │ │ └── __init__.py + │ └── NEWS.rst + ├── project_b + │ ├── newsfragments + │ │ └── 120.bugfix + │ ├── project_b + │ │ └── __init__.py + │ └── NEWS.rst + └── towncrier.toml + +The ``towncrier.toml`` looks like this: + +.. code-block:: toml + + [tool.towncrier] + # It's important to keep these config fields empty + # because we have more than one package/name to manage. + package = "" + name = "" + +Now to add a fragment: + +.. code-block:: console + + towncrier create --config towncrier.toml --dir project_a 124.added + +This should create a file at ``project_a/newsfragments/124.added``. + +To build the news file for the same project: + +.. code-block:: console + + towncrier build --config towncrier.toml --dir project_a --version 1.5 + +Note that we must explicitly pass ``--version``, there is no other way to get the version number. +The ``towncrier.toml`` can only contain one version number and the ``package`` field is of no use for the same reason. diff --git a/src/towncrier/build.py b/src/towncrier/build.py index 60e0e861..3a07bae1 100644 --- a/src/towncrier/build.py +++ b/src/towncrier/build.py @@ -175,7 +175,9 @@ def __main( click.echo("Finding news fragments...", err=to_err) if config.directory is not None: - fragment_base_directory = os.path.abspath(config.directory) + fragment_base_directory = os.path.abspath( + os.path.join(base_directory, config.directory) + ) fragment_directory = None else: fragment_base_directory = os.path.abspath( diff --git a/src/towncrier/check.py b/src/towncrier/check.py index 2124590c..ee9b612e 100644 --- a/src/towncrier/check.py +++ b/src/towncrier/check.py @@ -93,9 +93,7 @@ def __main( ) sys.exit(0) - files = { - os.path.normpath(os.path.join(base_directory, path)) for path in files_changed - } + files = {os.path.abspath(path) for path in files_changed} click.echo("Looking at these files:") click.echo("----") @@ -109,7 +107,9 @@ def __main( sys.exit(0) if config.directory: - fragment_base_directory = os.path.abspath(config.directory) + fragment_base_directory = os.path.abspath( + os.path.join(base_directory, config.directory) + ) fragment_directory = None else: fragment_base_directory = os.path.abspath( @@ -118,7 +118,7 @@ def __main( fragment_directory = "newsfragments" fragments = { - os.path.normpath(path) + os.path.abspath(path) for path in find_fragments( fragment_base_directory, config.sections, diff --git a/src/towncrier/newsfragments/548.feature b/src/towncrier/newsfragments/548.feature new file mode 100644 index 00000000..c48f7670 --- /dev/null +++ b/src/towncrier/newsfragments/548.feature @@ -0,0 +1,2 @@ +Initial support was added for monorepo-style setup. +One project with multiple independent news files stored in separate sub-directories, that share the same towncrier config. diff --git a/src/towncrier/test/test_build.py b/src/towncrier/test/test_build.py index aa483ba9..f15cd9ab 100644 --- a/src/towncrier/test/test_build.py +++ b/src/towncrier/test/test_build.py @@ -145,6 +145,33 @@ def test_in_different_dir_config_option(self, runner): self.assertEqual(0, result.exit_code) self.assertTrue((project_dir / "NEWS.rst").exists()) + @with_isolated_runner + def test_in_different_dir_with_nondefault_newsfragments_directory(self, runner): + """ + Using the `--dir` CLI argument, the NEWS file can + be generated in a sub-directory from fragments + that are relatives to that sub-directory. + + The path passed to `--dir` becomes the + working directory. + """ + Path("pyproject.toml").write_text( + "[tool.towncrier]\n" + 'directory = "changelog.d"\n' + ) + Path("foo/foo").mkdir(parents=True) + Path("foo/foo/__init__.py").write_text("") + Path("foo/changelog.d").mkdir() + Path("foo/changelog.d/123.feature").write_text("Adds levitation") + self.assertFalse(Path("foo/NEWS.rst").exists()) + + result = runner.invoke( + cli, + ("--yes", "--config", "pyproject.toml", "--dir", "foo", "--version", "1.0"), + ) + + self.assertEqual(0, result.exit_code) + self.assertTrue(Path("foo/NEWS.rst").exists()) + @with_isolated_runner def test_no_newsfragment_directory(self, runner): """ diff --git a/src/towncrier/test/test_check.py b/src/towncrier/test/test_check.py index 102ab20a..81861db3 100644 --- a/src/towncrier/test/test_check.py +++ b/src/towncrier/test/test_check.py @@ -297,3 +297,101 @@ def test_get_default_compare_branch_fallback(self): self.assertEqual("origin/master", branch) self.assertTrue(w[0].message.args[0].startswith('Using "origin/master')) + + @with_isolated_runner + def test_in_different_dir_with_nondefault_newsfragments_directory(self, runner): + """ + It can check the fragments located in a sub-directory + that is specified using the `--dir` CLI argument. + """ + main_branch = "main" + Path("pyproject.toml").write_text( + # Important to customize `config.directory` because the default + # already supports this scenario. + "[tool.towncrier]\n" + + 'directory = "changelog.d"\n' + ) + subproject1 = Path("foo") + (subproject1 / "foo").mkdir(parents=True) + (subproject1 / "foo/__init__.py").write_text("") + (subproject1 / "changelog.d").mkdir(parents=True) + (subproject1 / "changelog.d/123.feature").write_text("Adds levitation") + initial_commit(branch=main_branch) + call(["git", "checkout", "-b", "otherbranch"]) + + # We add a code change but forget to add a news fragment. + write(subproject1 / "foo/somefile.py", "import os") + commit("add a file") + result = runner.invoke( + towncrier_check, + ( + "--config", + "pyproject.toml", + "--dir", + str(subproject1), + "--compare-with", + "main", + ), + ) + + self.assertEqual(1, result.exit_code) + self.assertTrue( + result.output.endswith("No new newsfragments found on this branch.\n") + ) + + # We add the news fragment. + fragment_path = (subproject1 / "changelog.d/124.feature").absolute() + write(fragment_path, "Adds gravity back") + commit("add a newsfragment") + result = runner.invoke( + towncrier_check, + ("--config", "pyproject.toml", "--dir", "foo", "--compare-with", "main"), + ) + + self.assertEqual(0, result.exit_code, result.output) + self.assertTrue( + result.output.endswith("Found:\n1. " + str(fragment_path) + "\n"), + (result.output, str(fragment_path)), + ) + + # We add a change in a different subproject without a news fragment. + # Checking subproject1 should pass. + subproject2 = Path("bar") + (subproject2 / "bar").mkdir(parents=True) + (subproject2 / "changelog.d").mkdir(parents=True) + write(subproject2 / "bar/somefile.py", "import os") + commit("add a file") + result = runner.invoke( + towncrier_check, + ( + "--config", + "pyproject.toml", + "--dir", + subproject1, + "--compare-with", + "main", + ), + ) + + self.assertEqual(0, result.exit_code, result.output) + self.assertTrue( + result.output.endswith("Found:\n1. " + str(fragment_path) + "\n"), + (result.output, str(fragment_path)), + ) + + # Checking subproject2 should result in an error. + result = runner.invoke( + towncrier_check, + ( + "--config", + "pyproject.toml", + "--dir", + subproject2, + "--compare-with", + "main", + ), + ) + self.assertEqual(1, result.exit_code) + self.assertTrue( + result.output.endswith("No new newsfragments found on this branch.\n") + ) diff --git a/src/towncrier/test/test_create.py b/src/towncrier/test/test_create.py index bb33da4c..7f0e24b1 100644 --- a/src/towncrier/test/test_create.py +++ b/src/towncrier/test/test_create.py @@ -249,3 +249,37 @@ def test_create_orphan_fragment_custom_prefix(self, runner: CliRunner): self.assertEqual(len(change.stem), 11) # Check the remainder are all hex characters. self.assertTrue(all(c in string.hexdigits for c in change.stem[3:])) + + @with_isolated_runner + def test_in_different_dir_with_nondefault_newsfragments_directory(self, runner): + """ + When the `--dir` CLI argument is passed, + it will create a new file in directory that is + created by combining the `--dir` value + with the `directory` option from the configuration + file. + """ + Path("pyproject.toml").write_text( + # Important to customize `config.directory` because the default + # already supports this scenario. + "[tool.towncrier]\n" + + 'directory = "changelog.d"\n' + ) + Path("foo/foo").mkdir(parents=True) + Path("foo/foo/__init__.py").write_text("") + + result = runner.invoke( + _main, + ( + "--config", + "pyproject.toml", + "--dir", + "foo", + "--content", + "Adds levitation.", + "123.feature", + ), + ) + + self.assertEqual(0, result.exit_code) + self.assertTrue(Path("foo/changelog.d/123.feature").exists()) From f6809f031fce9fe3568166247f8ccf8f7a6c4aaf Mon Sep 17 00:00:00 2001 From: Adi Roiban Date: Tue, 24 Oct 2023 10:48:13 +0100 Subject: [PATCH 11/51] Release 23.10.0 (#555) * Update version. * venv/bin/towncrier build --yes * Do RC1 instead of RC0. * Update for final release. * Add dev release. --- NEWS.rst | 43 +++++++++++++++++++++++++ src/towncrier/_version.py | 5 ++- src/towncrier/newsfragments/481.misc | 3 -- src/towncrier/newsfragments/520.misc | 0 src/towncrier/newsfragments/521.removal | 1 - src/towncrier/newsfragments/522.misc | 0 src/towncrier/newsfragments/523.misc | 0 src/towncrier/newsfragments/529.misc | 0 src/towncrier/newsfragments/536.misc | 0 src/towncrier/newsfragments/540.bugfix | 1 - src/towncrier/newsfragments/541.feature | 1 - src/towncrier/newsfragments/545.doc | 1 - src/towncrier/newsfragments/548.feature | 2 -- src/towncrier/newsfragments/551.doc | 1 - src/towncrier/newsfragments/552.feature | 4 --- 15 files changed, 47 insertions(+), 15 deletions(-) delete mode 100644 src/towncrier/newsfragments/481.misc delete mode 100644 src/towncrier/newsfragments/520.misc delete mode 100644 src/towncrier/newsfragments/521.removal delete mode 100644 src/towncrier/newsfragments/522.misc delete mode 100644 src/towncrier/newsfragments/523.misc delete mode 100644 src/towncrier/newsfragments/529.misc delete mode 100644 src/towncrier/newsfragments/536.misc delete mode 100644 src/towncrier/newsfragments/540.bugfix delete mode 100644 src/towncrier/newsfragments/541.feature delete mode 100644 src/towncrier/newsfragments/545.doc delete mode 100644 src/towncrier/newsfragments/548.feature delete mode 100644 src/towncrier/newsfragments/551.doc delete mode 100644 src/towncrier/newsfragments/552.feature diff --git a/NEWS.rst b/NEWS.rst index 26a6ad7c..9db34e74 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -5,6 +5,49 @@ Release notes .. towncrier release notes start +towncrier 23.10.0 (2023-10-24) +============================== + +No significant changes since the previous release candidate. + + +Features +-------- + +- Python 3.12 is now officially supported. (`#541 `_) +- Initial support was added for monorepo-style setup. + One project with multiple independent news files stored in separate sub-directories, that share the same towncrier config. (`#548 `_) +- Two newlines are no longer always added between the current release notes and the previous content. + The newlines are now defined only inside the template. + + **Important! If you're using a custom template and want to keep the same whitespace between releases, you may have to modify your template.** (`#552 `_) + + +Bugfixes +-------- + +- Towncrier now vendors the click-default-group package that prevented installations on modern Pips. (`#540 `_) + + +Improved Documentation +---------------------- + +- The markdown docs now use the default markdown template rather than a simpler custom one. (`#545 `_) +- Cleanup a duplicate backtick in the tutorial. (`#551 `_) + + +Deprecations and Removals +------------------------- + +- The support for Python 3.7 has been dropped. (`#521 `_) + + +Misc +---- + +- `#481 `_, `#520 `_, `#522 `_, `#523 `_, `#529 `_, `#536 `_ + + towncrier 23.6.0 (2023-06-06) ============================= diff --git a/src/towncrier/_version.py b/src/towncrier/_version.py index 7b50856e..1c086269 100644 --- a/src/towncrier/_version.py +++ b/src/towncrier/_version.py @@ -8,7 +8,10 @@ from incremental import Version -__version__ = Version("towncrier", 23, 6, 1, dev=0) +# For dev - Version('towncrier', 23, 8, 1, dev=0) +# For RC - Version('towncrier', 23, 9, 0, release_candidate=1) +# For final - Version('towncrier', 23, 9, 0) +__version__ = Version("towncrier", 23, 10, 1, dev=0) # The version is exposed in string format to be # available for the hatching build tools. _hatchling_version = __version__.short() diff --git a/src/towncrier/newsfragments/481.misc b/src/towncrier/newsfragments/481.misc deleted file mode 100644 index 720d4b83..00000000 --- a/src/towncrier/newsfragments/481.misc +++ /dev/null @@ -1,3 +0,0 @@ -``click.testing.CliRunner`` is now consistently used for testing CLI behaviour. - -This invokes the command behaviour consistently, without assuming any particular installed command name. diff --git a/src/towncrier/newsfragments/520.misc b/src/towncrier/newsfragments/520.misc deleted file mode 100644 index e69de29b..00000000 diff --git a/src/towncrier/newsfragments/521.removal b/src/towncrier/newsfragments/521.removal deleted file mode 100644 index c93bd0c6..00000000 --- a/src/towncrier/newsfragments/521.removal +++ /dev/null @@ -1 +0,0 @@ -The support for Python 3.7 has been dropped. diff --git a/src/towncrier/newsfragments/522.misc b/src/towncrier/newsfragments/522.misc deleted file mode 100644 index e69de29b..00000000 diff --git a/src/towncrier/newsfragments/523.misc b/src/towncrier/newsfragments/523.misc deleted file mode 100644 index e69de29b..00000000 diff --git a/src/towncrier/newsfragments/529.misc b/src/towncrier/newsfragments/529.misc deleted file mode 100644 index e69de29b..00000000 diff --git a/src/towncrier/newsfragments/536.misc b/src/towncrier/newsfragments/536.misc deleted file mode 100644 index e69de29b..00000000 diff --git a/src/towncrier/newsfragments/540.bugfix b/src/towncrier/newsfragments/540.bugfix deleted file mode 100644 index 7e2f12b0..00000000 --- a/src/towncrier/newsfragments/540.bugfix +++ /dev/null @@ -1 +0,0 @@ -Towncrier now vendors the click-default-group package that prevented installations on modern Pips. diff --git a/src/towncrier/newsfragments/541.feature b/src/towncrier/newsfragments/541.feature deleted file mode 100644 index 22256fa1..00000000 --- a/src/towncrier/newsfragments/541.feature +++ /dev/null @@ -1 +0,0 @@ -Python 3.12 is now officially supported. diff --git a/src/towncrier/newsfragments/545.doc b/src/towncrier/newsfragments/545.doc deleted file mode 100644 index b68b3d4c..00000000 --- a/src/towncrier/newsfragments/545.doc +++ /dev/null @@ -1 +0,0 @@ -The markdown docs now use the default markdown template rather than a simpler custom one. diff --git a/src/towncrier/newsfragments/548.feature b/src/towncrier/newsfragments/548.feature deleted file mode 100644 index c48f7670..00000000 --- a/src/towncrier/newsfragments/548.feature +++ /dev/null @@ -1,2 +0,0 @@ -Initial support was added for monorepo-style setup. -One project with multiple independent news files stored in separate sub-directories, that share the same towncrier config. diff --git a/src/towncrier/newsfragments/551.doc b/src/towncrier/newsfragments/551.doc deleted file mode 100644 index 37a8cdc1..00000000 --- a/src/towncrier/newsfragments/551.doc +++ /dev/null @@ -1 +0,0 @@ -Cleanup a duplicate backtick in the tutorial. diff --git a/src/towncrier/newsfragments/552.feature b/src/towncrier/newsfragments/552.feature deleted file mode 100644 index 56b9f4a1..00000000 --- a/src/towncrier/newsfragments/552.feature +++ /dev/null @@ -1,4 +0,0 @@ -Two newlines are no longer always added between the current release notes and the previous content. -The newlines are now defined only inside the template. - -**Important! If you're using a custom template and want to keep the same whitespace between releases, you may have to modify your template.** From e4b892fb21afce321a132780b7d4cf34814da7cb Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Tue, 24 Oct 2023 10:32:45 -0400 Subject: [PATCH 12/51] Make consistent the expectation around non-existent fragments directory (#557) * Add some documentation explaining the current behavior around the fragments directory. * Reflect expectation that a non-existent directory is honored and treated like an empty directory. Closes #538. * Add news fragment. --- docs/cli.rst | 9 +++++++++ src/towncrier/_builder.py | 10 ++-------- src/towncrier/newsfragments/538.bugfix | 1 + src/towncrier/test/test_build.py | 4 ++-- 4 files changed, 14 insertions(+), 10 deletions(-) create mode 100644 src/towncrier/newsfragments/538.bugfix diff --git a/docs/cli.rst b/docs/cli.rst index 90b5c899..7af5a3ac 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -24,6 +24,13 @@ The following options can be passed to all of the commands that explained below: Build the combined news file from news fragments. ``build`` is also assumed if no command is passed. +If there are no news fragments (including an empty fragments directory or a +non-existent directory), a notice of "no significant changes" will be added to +the news file. + +By default, the processed news fragments are removed using ``git``, which will +also remove the fragments directory if now empty. + .. option:: --draft Only render news fragments to standard output. @@ -67,6 +74,8 @@ Create a news fragment in the directory that ``towncrier`` is configured to look ``towncrier create`` will enforce that the passed type (e.g. ``bugfix``) is valid. +If the fragments directory does not exist, it will be created. + 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. In the above example, it will generate ``123.bugfix.1.rst`` if ``123.bugfix.rst`` already exists. diff --git a/src/towncrier/_builder.py b/src/towncrier/_builder.py index 6f8f166b..3f72f1fa 100644 --- a/src/towncrier/_builder.py +++ b/src/towncrier/_builder.py @@ -6,15 +6,12 @@ import os import textwrap -import traceback from collections import defaultdict from typing import Any, DefaultDict, Iterable, Iterator, Mapping, Sequence from jinja2 import Template -from ._settings import ConfigError - def strip_if_integer_string(s: str) -> str: try: @@ -102,11 +99,8 @@ def find_fragments( try: files = os.listdir(section_dir) - except FileNotFoundError as e: - message = "Failed to list the news fragment files.\n{}".format( - "".join(traceback.format_exception_only(type(e), e)), - ) - raise ConfigError(message) + except FileNotFoundError: + files = [] file_content = {} diff --git a/src/towncrier/newsfragments/538.bugfix b/src/towncrier/newsfragments/538.bugfix new file mode 100644 index 00000000..3a6fbf34 --- /dev/null +++ b/src/towncrier/newsfragments/538.bugfix @@ -0,0 +1 @@ +``build`` now treats a missing fragments directory the same as an empty one, consistent with other operations. diff --git a/src/towncrier/test/test_build.py b/src/towncrier/test/test_build.py index f15cd9ab..780a3c83 100644 --- a/src/towncrier/test/test_build.py +++ b/src/towncrier/test/test_build.py @@ -182,8 +182,8 @@ def test_no_newsfragment_directory(self, runner): result = runner.invoke(_main, ["--draft", "--date", "01-01-2001"]) - self.assertEqual(1, result.exit_code, result.output) - self.assertIn("Failed to list the news fragment files.\n", result.output) + self.assertEqual(0, result.exit_code) + self.assertIn("No significant changes.\n", result.output) def test_no_newsfragments_draft(self): """ From fba26a53022d6b5199a171018e0532a9ee59b596 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Fri, 27 Oct 2023 09:30:36 +0200 Subject: [PATCH 13/51] Update build-and-inspect-python-package to 1.5.2 (#559) * Update build-and-inspect-python-package to 1.5.2 1.5 is broken as GitHub Actions switched 3.x to 3.12 by default * Add news fragment --- .github/workflows/ci.yml | 2 +- src/towncrier/newsfragments/559.misc | 0 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 src/towncrier/newsfragments/559.misc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3941d138..6546298e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: hynek/build-and-inspect-python-package@6a687a6d3567bc184c1fc694ee7f0f328594ef25 + - uses: hynek/build-and-inspect-python-package@54d50a852d960c25f25f7f1874d3d969ecbe4eb0 - name: Set up ${{ matrix.python.name }} uses: actions/setup-python@v4 diff --git a/src/towncrier/newsfragments/559.misc b/src/towncrier/newsfragments/559.misc new file mode 100644 index 00000000..e69de29b From a794718b345cb332cd16ce9a807f4c02c68088d3 Mon Sep 17 00:00:00 2001 From: Adi Roiban Date: Fri, 3 Nov 2023 09:07:26 +0000 Subject: [PATCH 14/51] Update the documentation for the release process. (#558) * Update release notes. * Add newsfragment. --- RELEASE.rst | 19 +++++++++++++------ src/towncrier/newsfragments/558.misc | 0 2 files changed, 13 insertions(+), 6 deletions(-) create mode 100644 src/towncrier/newsfragments/558.misc diff --git a/RELEASE.rst b/RELEASE.rst index 6a4e9918..eaeca382 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -38,8 +38,8 @@ Create a new release candidate using `GitHub New release UI Date: Tue, 7 Nov 2023 05:47:35 +0100 Subject: [PATCH 15/51] [pre-commit.ci] pre-commit autoupdate (#563) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 23.9.1 → 23.10.1](https://github.com/psf/black/compare/23.9.1...23.10.1) - [github.com/asottile/pyupgrade: v3.14.0 → v3.15.0](https://github.com/asottile/pyupgrade/compare/v3.14.0...v3.15.0) - [github.com/pre-commit/pre-commit-hooks: v4.4.0 → v4.5.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.4.0...v4.5.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 71e54206..4243d584 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,12 +4,12 @@ ci: repos: - repo: https://github.com/psf/black - rev: 23.9.1 + rev: 23.10.1 hooks: - id: black - repo: https://github.com/asottile/pyupgrade - rev: v3.14.0 + rev: v3.15.0 hooks: - id: pyupgrade args: [--py38-plus] @@ -26,7 +26,7 @@ repos: - id: flake8 - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.5.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer From 3b27ce2e7ec2f219a8a655001bc21e5d4ba57412 Mon Sep 17 00:00:00 2001 From: Matthias Dellweg <2500@gmx.de> Date: Tue, 7 Nov 2023 16:54:53 +0100 Subject: [PATCH 16/51] Fix orphan fragments with numbers (#564) * Add test for orphan fragments with numbers * Fix fragment parsing with stray numbers and dots fixes #562 Co-authored-by: Adi Roiban --- .gitignore | 1 + src/towncrier/_builder.py | 14 +++---- src/towncrier/newsfragments/562.bugfix | 1 + src/towncrier/newsfragments/562.bugfix.1 | 2 + src/towncrier/test/test_build.py | 21 ++++++++++- src/towncrier/test/test_builder.py | 48 +++++++++++++++++++++++- 6 files changed, 74 insertions(+), 13 deletions(-) create mode 100644 src/towncrier/newsfragments/562.bugfix create mode 100644 src/towncrier/newsfragments/562.bugfix.1 diff --git a/.gitignore b/.gitignore index fa4a8cd0..e101cbb7 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ .vs/ .vscode Justfile +*egg-info/ _trial_temp*/ apidocs/ dist/ diff --git a/src/towncrier/_builder.py b/src/towncrier/_builder.py index 3f72f1fa..3d863548 100644 --- a/src/towncrier/_builder.py +++ b/src/towncrier/_builder.py @@ -32,24 +32,20 @@ def parse_newfragment_basename( if len(parts) == 1: return invalid - if len(parts) == 2: - ticket, category = parts - ticket = strip_if_integer_string(ticket) - return (ticket, category, 0) if category in frag_type_names else invalid - # There are at least 3 parts. Search for a valid category from the second - # part onwards. + # There are at least 2 parts. Search for a valid category from the second + # part onwards starting at the back. # The category is used as the reference point in the parts list to later # infer the issue number and counter value. - for i in range(1, len(parts)): + for i in reversed(range(1, len(parts))): if parts[i] in frag_type_names: # Current part is a valid category according to given definitions. category = parts[i] - # Use the previous part as the ticket number. + # Use all previous parts as the ticket number. # NOTE: This allows news fragment names like fix-1.2.3.feature or # something-cool.feature.ext for projects that don't use ticket # numbers in news fragment names. - ticket = strip_if_integer_string(parts[i - 1]) + ticket = strip_if_integer_string(".".join(parts[0:i])) counter = 0 # Use the following part as the counter if it exists and is a valid # digit. diff --git a/src/towncrier/newsfragments/562.bugfix b/src/towncrier/newsfragments/562.bugfix new file mode 100644 index 00000000..623ceab8 --- /dev/null +++ b/src/towncrier/newsfragments/562.bugfix @@ -0,0 +1 @@ +Orphan newsfragments containing numeric values are no longer accidentally associated to tickets. In previous versions the orphan marker was ignored and the newsfragment was associated to a ticket having the last numerical value from the filename. diff --git a/src/towncrier/newsfragments/562.bugfix.1 b/src/towncrier/newsfragments/562.bugfix.1 new file mode 100644 index 00000000..d751ebe8 --- /dev/null +++ b/src/towncrier/newsfragments/562.bugfix.1 @@ -0,0 +1,2 @@ +Fragments with filenames like `fix-1.2.3.feature` are now associated with the ticket `fix-1.2.3`. +In previous versions they were incorrectly associated to ticket `3`. diff --git a/src/towncrier/test/test_build.py b/src/towncrier/test/test_build.py index 780a3c83..938814cc 100644 --- a/src/towncrier/test/test_build.py +++ b/src/towncrier/test/test_build.py @@ -43,6 +43,14 @@ def _test_command(self, command): f.write("Orphaned feature") with open("foo/newsfragments/+xxx.feature", "w") as f: f.write("Another orphaned feature") + with open("foo/newsfragments/+123_orphaned.feature", "w") as f: + f.write("An orphaned feature starting with a number") + with open("foo/newsfragments/+12.3_orphaned.feature", "w") as f: + f.write("An orphaned feature starting with a dotted number") + with open("foo/newsfragments/+orphaned_123.feature", "w") as f: + f.write("An orphaned feature ending with a number") + with open("foo/newsfragments/+orphaned_12.3.feature", "w") as f: + f.write("An orphaned feature ending with a dotted number") # Towncrier ignores files that don't have a dot with open("foo/newsfragments/README", "w") as f: f.write("Blah blah") @@ -52,7 +60,7 @@ def _test_command(self, command): result = runner.invoke(command, ["--draft", "--date", "01-01-2001"]) - self.assertEqual(0, result.exit_code) + self.assertEqual(0, result.exit_code, result.output) self.assertEqual( result.output, dedent( @@ -70,9 +78,13 @@ def _test_command(self, command): -------- - Baz levitation (baz) - - Baz fix levitation (#2) + - Baz fix levitation (fix-1.2) - Adds levitation (#123) - Extends levitation (#124) + - An orphaned feature ending with a dotted number + - An orphaned feature ending with a number + - An orphaned feature starting with a dotted number + - An orphaned feature starting with a number - Another orphaned feature - Orphaned feature @@ -405,6 +417,7 @@ def test_draft_no_date(self): call(["git", "init"]) call(["git", "config", "user.name", "user"]) call(["git", "config", "user.email", "user@example.com"]) + call(["git", "config", "commit.gpgSign", "false"]) call(["git", "add", "."]) call(["git", "commit", "-m", "Initial Commit"]) @@ -429,6 +442,7 @@ def test_no_confirmation(self): call(["git", "init"]) call(["git", "config", "user.name", "user"]) call(["git", "config", "user.email", "user@example.com"]) + call(["git", "config", "commit.gpgSign", "false"]) call(["git", "add", "."]) call(["git", "commit", "-m", "Initial Commit"]) @@ -458,6 +472,7 @@ def test_keep_fragments(self, runner): call(["git", "init"]) call(["git", "config", "user.name", "user"]) call(["git", "config", "user.email", "user@example.com"]) + call(["git", "config", "commit.gpgSign", "false"]) call(["git", "add", "."]) call(["git", "commit", "-m", "Initial Commit"]) @@ -491,6 +506,7 @@ def test_yes_keep_error(self, runner): call(["git", "init"]) call(["git", "config", "user.name", "user"]) call(["git", "config", "user.email", "user@example.com"]) + call(["git", "config", "commit.gpgSign", "false"]) call(["git", "add", "."]) call(["git", "commit", "-m", "Initial Commit"]) @@ -519,6 +535,7 @@ def test_confirmation_says_no(self): call(["git", "init"]) call(["git", "config", "user.name", "user"]) call(["git", "config", "user.email", "user@example.com"]) + call(["git", "config", "commit.gpgSign", "false"]) call(["git", "add", "."]) call(["git", "commit", "-m", "Initial Commit"]) diff --git a/src/towncrier/test/test_builder.py b/src/towncrier/test/test_builder.py index 62630af0..b62033ac 100644 --- a/src/towncrier/test/test_builder.py +++ b/src/towncrier/test/test_builder.py @@ -8,63 +8,73 @@ class TestParseNewsfragmentBasename(TestCase): def test_simple(self): + """. generates a counter value of 0.""" self.assertEqual( parse_newfragment_basename("123.feature", ["feature"]), ("123", "feature", 0), ) def test_invalid_category(self): + """Files without a valid category are rejected.""" self.assertEqual( parse_newfragment_basename("README.ext", ["feature"]), (None, None, None), ) def test_counter(self): + """.. generates a custom counter value.""" self.assertEqual( parse_newfragment_basename("123.feature.1", ["feature"]), ("123", "feature", 1), ) def test_counter_with_extension(self): + """File extensions are ignored.""" self.assertEqual( parse_newfragment_basename("123.feature.1.ext", ["feature"]), ("123", "feature", 1), ) def test_ignores_extension(self): + """File extensions are ignored.""" self.assertEqual( parse_newfragment_basename("123.feature.ext", ["feature"]), ("123", "feature", 0), ) def test_non_numeric_ticket(self): + """Non-numeric issue identifiers are preserved verbatim.""" self.assertEqual( parse_newfragment_basename("baz.feature", ["feature"]), ("baz", "feature", 0), ) def test_non_numeric_ticket_with_extension(self): + """File extensions are ignored.""" self.assertEqual( parse_newfragment_basename("baz.feature.ext", ["feature"]), ("baz", "feature", 0), ) def test_dots_in_ticket_name(self): + """Non-numeric issue identifiers are preserved verbatim.""" self.assertEqual( parse_newfragment_basename("baz.1.2.feature", ["feature"]), - ("2", "feature", 0), + ("baz.1.2", "feature", 0), ) def test_dots_in_ticket_name_invalid_category(self): + """Files without a valid category are rejected.""" self.assertEqual( parse_newfragment_basename("baz.1.2.notfeature", ["feature"]), (None, None, None), ) def test_dots_in_ticket_name_and_counter(self): + """Non-numeric issue identifiers are preserved verbatim.""" self.assertEqual( parse_newfragment_basename("baz.1.2.feature.3", ["feature"]), - ("2", "feature", 3), + ("baz.1.2", "feature", 3), ) def test_strip(self): @@ -77,7 +87,41 @@ def test_strip(self): ) def test_strip_with_counter(self): + """Leading spaces and subsequent leading zeros are stripped + when parsing newsfragment names into ticket numbers etc. + """ self.assertEqual( parse_newfragment_basename(" 007.feature.3", ["feature"]), ("7", "feature", 3), ) + + def test_orphan(self): + """Orphaned snippets must remain the orphan marker in the issue + identifier.""" + self.assertEqual( + parse_newfragment_basename("+orphan.feature", ["feature"]), + ("+orphan", "feature", 0), + ) + + def test_orphan_with_number(self): + """Orphaned snippets can contain numbers in the identifier.""" + self.assertEqual( + parse_newfragment_basename("+123_orphan.feature", ["feature"]), + ("+123_orphan", "feature", 0), + ) + self.assertEqual( + parse_newfragment_basename("+orphan_123.feature", ["feature"]), + ("+orphan_123", "feature", 0), + ) + + def test_orphan_with_dotted_number(self): + """Orphaned snippets can contain numbers with dots in the + identifier.""" + self.assertEqual( + parse_newfragment_basename("+12.3_orphan.feature", ["feature"]), + ("+12.3_orphan", "feature", 0), + ) + self.assertEqual( + parse_newfragment_basename("+orphan_12.3.feature", ["feature"]), + ("+orphan_12.3", "feature", 0), + ) From 4d317bf138a2ce902334351be2176d5764a41479 Mon Sep 17 00:00:00 2001 From: Adi Roiban Date: Wed, 8 Nov 2023 14:35:13 +0000 Subject: [PATCH 17/51] Release 23.11.0 (#566) * venv/bin/towncrier build --yes * Show release draft as a step output. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Update final version. * Update dev version. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 10 +++++++--- NEWS.rst | 21 +++++++++++++++++++++ noxfile.py | 6 ++++++ src/towncrier/_version.py | 2 +- src/towncrier/newsfragments/538.bugfix | 1 - src/towncrier/newsfragments/558.misc | 0 src/towncrier/newsfragments/559.misc | 0 src/towncrier/newsfragments/562.bugfix | 1 - src/towncrier/newsfragments/562.bugfix.1 | 2 -- 9 files changed, 35 insertions(+), 8 deletions(-) delete mode 100644 src/towncrier/newsfragments/538.bugfix delete mode 100644 src/towncrier/newsfragments/558.misc delete mode 100644 src/towncrier/newsfragments/559.misc delete mode 100644 src/towncrier/newsfragments/562.bugfix delete mode 100644 src/towncrier/newsfragments/562.bugfix.1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6546298e..a5467106 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -160,10 +160,12 @@ jobs: python-version: '3.11' task: - name: Check Newsfragment - nox: check_newsfragment + run: | + nox -e check_newsfragment + nox -e draft_newsfragment >> $GITHUB_STEP_SUMMARY run-if: ${{ github.head_ref != 'pre-commit-ci-update-config' }} - name: Check mypy - nox: typecheck + run: nox -e typecheck run-if: true steps: @@ -187,7 +189,9 @@ jobs: - uses: twisted/python-info-action@v1 - - run: nox -e ${{ matrix.task.nox }} + - name: Check + run: | + ${{ matrix.task.run }} if: ${{ matrix.task.run-if }} diff --git a/NEWS.rst b/NEWS.rst index 9db34e74..70db8733 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -5,6 +5,27 @@ Release notes .. towncrier release notes start +towncrier 23.11.0 (2023-11-08) +============================== + +No significant changes since the previous release candidate. + + +Bugfixes +-------- + +- ``build`` now treats a missing fragments directory the same as an empty one, consistent with other operations. (`#538 `_) +- Fragments with filenames like `fix-1.2.3.feature` are now associated with the ticket `fix-1.2.3`. + In previous versions they were incorrectly associated to ticket `3`. (`#562 `_) +- Orphan newsfragments containing numeric values are no longer accidentally associated to tickets. In previous versions the orphan marker was ignored and the newsfragment was associated to a ticket having the last numerical value from the filename. (`#562 `_) + + +Misc +---- + +- `#558 `_, `#559 `_ + + towncrier 23.10.0 (2023-10-24) ============================== diff --git a/noxfile.py b/noxfile.py index bcd7f4ef..d3e7d025 100644 --- a/noxfile.py +++ b/noxfile.py @@ -54,6 +54,12 @@ def check_newsfragment(session: nox.Session) -> None: session.run("python", "-m", "towncrier.check", "--compare-with", "origin/trunk") +@nox.session +def draft_newsfragment(session: nox.Session) -> None: + session.install(".") + session.run("python", "-m", "towncrier.build", "--draft") + + @nox.session def typecheck(session: nox.Session) -> None: # Click 8.1.4 is bad type hints -- lets not complicate packaging and only diff --git a/src/towncrier/_version.py b/src/towncrier/_version.py index 1c086269..020bb62e 100644 --- a/src/towncrier/_version.py +++ b/src/towncrier/_version.py @@ -11,7 +11,7 @@ # For dev - Version('towncrier', 23, 8, 1, dev=0) # For RC - Version('towncrier', 23, 9, 0, release_candidate=1) # For final - Version('towncrier', 23, 9, 0) -__version__ = Version("towncrier", 23, 10, 1, dev=0) +__version__ = Version("towncrier", 23, 11, 1, dev=0) # The version is exposed in string format to be # available for the hatching build tools. _hatchling_version = __version__.short() diff --git a/src/towncrier/newsfragments/538.bugfix b/src/towncrier/newsfragments/538.bugfix deleted file mode 100644 index 3a6fbf34..00000000 --- a/src/towncrier/newsfragments/538.bugfix +++ /dev/null @@ -1 +0,0 @@ -``build`` now treats a missing fragments directory the same as an empty one, consistent with other operations. diff --git a/src/towncrier/newsfragments/558.misc b/src/towncrier/newsfragments/558.misc deleted file mode 100644 index e69de29b..00000000 diff --git a/src/towncrier/newsfragments/559.misc b/src/towncrier/newsfragments/559.misc deleted file mode 100644 index e69de29b..00000000 diff --git a/src/towncrier/newsfragments/562.bugfix b/src/towncrier/newsfragments/562.bugfix deleted file mode 100644 index 623ceab8..00000000 --- a/src/towncrier/newsfragments/562.bugfix +++ /dev/null @@ -1 +0,0 @@ -Orphan newsfragments containing numeric values are no longer accidentally associated to tickets. In previous versions the orphan marker was ignored and the newsfragment was associated to a ticket having the last numerical value from the filename. diff --git a/src/towncrier/newsfragments/562.bugfix.1 b/src/towncrier/newsfragments/562.bugfix.1 deleted file mode 100644 index d751ebe8..00000000 --- a/src/towncrier/newsfragments/562.bugfix.1 +++ /dev/null @@ -1,2 +0,0 @@ -Fragments with filenames like `fix-1.2.3.feature` are now associated with the ticket `fix-1.2.3`. -In previous versions they were incorrectly associated to ticket `3`. From d871ea39171fef55708c46c49f08db127b91f978 Mon Sep 17 00:00:00 2001 From: Matthias Dellweg Date: Thu, 9 Nov 2023 13:37:19 +0100 Subject: [PATCH 18/51] Refactored test_build.py (#565) * Refactored test_build.py Made use of more generic with_[git_]project decorators. Co-authored-by: Adi Roiban * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: Adi Roiban Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- src/towncrier/newsfragments/562.misc | 1 + src/towncrier/test/helpers.py | 76 ++- src/towncrier/test/test_build.py | 933 ++++++++++++--------------- 3 files changed, 497 insertions(+), 513 deletions(-) create mode 100644 src/towncrier/newsfragments/562.misc diff --git a/src/towncrier/newsfragments/562.misc b/src/towncrier/newsfragments/562.misc new file mode 100644 index 00000000..88d21020 --- /dev/null +++ b/src/towncrier/newsfragments/562.misc @@ -0,0 +1 @@ +Improved structure and readability of some tests for building the changelog. diff --git a/src/towncrier/test/helpers.py b/src/towncrier/test/helpers.py index ddc9ffc6..c3bd9604 100644 --- a/src/towncrier/test/helpers.py +++ b/src/towncrier/test/helpers.py @@ -5,6 +5,7 @@ from functools import wraps from pathlib import Path +from subprocess import call from typing import Any, Callable from click.testing import CliRunner @@ -62,10 +63,83 @@ def setup_simple_project( ) -> None: if config is None: config = "[tool.towncrier]\n" 'package = "foo"\n' + extra_config - + else: + config = textwrap.dedent(config) Path(pyproject_path).write_text(config) Path("foo").mkdir() Path("foo/__init__.py").write_text('__version__ = "1.2.3"\n') if mkdir_newsfragments: Path("foo/newsfragments").mkdir() + + +def with_project( + *, + config: str | None = None, + pyproject_path: str = "pyproject.toml", +) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + """Decorator to run a test with an isolated directory containing a simple + project. + + The files are not managed by git. + + `config` is the content of the config file. + It will be automatically dedented. + + `pyproject_path` is the path where to store the config file. + """ + + def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: + @wraps(fn) + def test(*args: Any, **kw: Any) -> Any: + runner = CliRunner() + with runner.isolated_filesystem(): + setup_simple_project( + config=config, + pyproject_path=pyproject_path, + ) + + return fn(*args, runner=runner, **kw) + + return test + + return decorator + + +def with_git_project( + *, + config: str | None = None, + pyproject_path: str = "pyproject.toml", +) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + """Decorator to run a test with an isolated directory containing a simple + project checked into git. + Use `config` to tweak the content of the config file. + Use `pyproject_path` to tweak the location of the config file. + """ + + def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: + def _commit() -> None: + call(["git", "add", "."]) + call(["git", "commit", "-m", "Second Commit"]) + + @wraps(fn) + def test(*args: Any, **kw: Any) -> Any: + runner = CliRunner() + with runner.isolated_filesystem(): + setup_simple_project( + config=config, + pyproject_path=pyproject_path, + ) + + call(["git", "init"]) + call(["git", "config", "user.name", "user"]) + call(["git", "config", "user.email", "user@example.com"]) + call(["git", "config", "commit.gpgSign", "false"]) + call(["git", "add", "."]) + call(["git", "commit", "-m", "Initial Commit"]) + + return fn(*args, runner=runner, commit=_commit, **kw) + + return test + + return decorator diff --git a/src/towncrier/test/test_build.py b/src/towncrier/test/test_build.py index 938814cc..76f6e8aa 100644 --- a/src/towncrier/test/test_build.py +++ b/src/towncrier/test/test_build.py @@ -6,7 +6,6 @@ from datetime import date from pathlib import Path -from subprocess import call from textwrap import dedent from unittest.mock import patch @@ -15,50 +14,48 @@ from .._shell import cli from ..build import _main -from .helpers import read, setup_simple_project, with_isolated_runner, write +from .helpers import read, with_git_project, with_project, write class TestCli(TestCase): maxDiff = None - def _test_command(self, command): - runner = CliRunner() - - with runner.isolated_filesystem(): - setup_simple_project() - with open("foo/newsfragments/123.feature", "w") as f: - f.write("Adds levitation") - # Towncrier treats this as 124.feature, ignoring .rst extension - with open("foo/newsfragments/124.feature.rst", "w") as f: - f.write("Extends levitation") - # Towncrier supports non-numeric newsfragment names. - with open("foo/newsfragments/baz.feature.rst", "w") as f: - f.write("Baz levitation") - # Towncrier supports files that have a dot in the name of the - # newsfragment - with open("foo/newsfragments/fix-1.2.feature", "w") as f: - f.write("Baz fix levitation") - # Towncrier supports fragments not linked to a feature - with open("foo/newsfragments/+anything.feature", "w") as f: - f.write("Orphaned feature") - with open("foo/newsfragments/+xxx.feature", "w") as f: - f.write("Another orphaned feature") - with open("foo/newsfragments/+123_orphaned.feature", "w") as f: - f.write("An orphaned feature starting with a number") - with open("foo/newsfragments/+12.3_orphaned.feature", "w") as f: - f.write("An orphaned feature starting with a dotted number") - with open("foo/newsfragments/+orphaned_123.feature", "w") as f: - f.write("An orphaned feature ending with a number") - with open("foo/newsfragments/+orphaned_12.3.feature", "w") as f: - f.write("An orphaned feature ending with a dotted number") - # Towncrier ignores files that don't have a dot - with open("foo/newsfragments/README", "w") as f: - f.write("Blah blah") - # And files that don't have a valid category - with open("foo/newsfragments/README.rst", "w") as f: - f.write("**Blah blah**") - - result = runner.invoke(command, ["--draft", "--date", "01-01-2001"]) + @with_project() + def _test_command(self, command, runner): + # Off the shelf newsfragment + with open("foo/newsfragments/123.feature", "w") as f: + f.write("Adds levitation") + # Towncrier treats this as 124.feature, ignoring .rst extension + with open("foo/newsfragments/124.feature.rst", "w") as f: + f.write("Extends levitation") + # Towncrier supports non-numeric newsfragment names. + with open("foo/newsfragments/baz.feature.rst", "w") as f: + f.write("Baz levitation") + # Towncrier supports files that have a dot in the name of the + # newsfragment + with open("foo/newsfragments/fix-1.2.feature", "w") as f: + f.write("Baz fix levitation") + # Towncrier supports fragments not linked to a feature + with open("foo/newsfragments/+anything.feature", "w") as f: + f.write("Orphaned feature") + with open("foo/newsfragments/+xxx.feature", "w") as f: + f.write("Another orphaned feature") + with open("foo/newsfragments/+123_orphaned.feature", "w") as f: + f.write("An orphaned feature starting with a number") + with open("foo/newsfragments/+12.3_orphaned.feature", "w") as f: + f.write("An orphaned feature starting with a dotted number") + with open("foo/newsfragments/+orphaned_123.feature", "w") as f: + f.write("An orphaned feature ending with a number") + with open("foo/newsfragments/+orphaned_12.3.feature", "w") as f: + f.write("An orphaned feature ending with a dotted number") + # Towncrier ignores files that don't have a dot + with open("foo/newsfragments/README", "w") as f: + f.write("Blah blah") + # And files that don't have a valid category + with open("foo/newsfragments/README.rst", "w") as f: + f.write("**Blah blah**") + + result = runner.invoke(command, ["--draft", "--date", "01-01-2001"]) self.assertEqual(0, result.exit_code, result.output) self.assertEqual( @@ -100,7 +97,7 @@ def test_command(self): def test_subcommand(self): self._test_command(_main) - @with_isolated_runner + @with_project() def test_in_different_dir_dir_option(self, runner): """ The current working directory doesn't matter as long as we pass @@ -108,7 +105,6 @@ def test_in_different_dir_dir_option(self, runner): """ project_dir = Path(".").resolve() - setup_simple_project() Path("foo/newsfragments/123.feature").write_text("Adds levitation") # Ensure our assetion below is meaningful. self.assertFalse((project_dir / "NEWS.rst").exists()) @@ -125,7 +121,7 @@ def test_in_different_dir_dir_option(self, runner): self.assertEqual(0, result.exit_code) self.assertTrue((project_dir / "NEWS.rst").exists()) - @with_isolated_runner + @with_project() def test_in_different_dir_config_option(self, runner): """ The current working directory and the location of the configuration @@ -134,7 +130,6 @@ def test_in_different_dir_config_option(self, runner): """ project_dir = Path(".").resolve() - setup_simple_project() Path("foo/newsfragments/123.feature").write_text("Adds levitation") # Ensure our assetion below is meaningful. self.assertFalse((project_dir / "NEWS.rst").exists()) @@ -157,7 +152,12 @@ def test_in_different_dir_config_option(self, runner): self.assertEqual(0, result.exit_code) self.assertTrue((project_dir / "NEWS.rst").exists()) - @with_isolated_runner + @with_project( + config=""" + [tool.towncrier] + directory = "changelog.d" + """ + ) def test_in_different_dir_with_nondefault_newsfragments_directory(self, runner): """ Using the `--dir` CLI argument, the NEWS file can @@ -167,9 +167,6 @@ def test_in_different_dir_with_nondefault_newsfragments_directory(self, runner): The path passed to `--dir` becomes the working directory. """ - Path("pyproject.toml").write_text( - "[tool.towncrier]\n" + 'directory = "changelog.d"\n' - ) Path("foo/foo").mkdir(parents=True) Path("foo/foo/__init__.py").write_text("") Path("foo/changelog.d").mkdir() @@ -184,12 +181,11 @@ def test_in_different_dir_with_nondefault_newsfragments_directory(self, runner): self.assertEqual(0, result.exit_code) self.assertTrue(Path("foo/NEWS.rst").exists()) - @with_isolated_runner + @with_project() def test_no_newsfragment_directory(self, runner): """ A missing newsfragment directory acts as if there are no changes. """ - setup_simple_project() os.rmdir("foo/newsfragments") result = runner.invoke(_main, ["--draft", "--date", "01-01-2001"]) @@ -197,49 +193,38 @@ def test_no_newsfragment_directory(self, runner): self.assertEqual(0, result.exit_code) self.assertIn("No significant changes.\n", result.output) - def test_no_newsfragments_draft(self): + @with_project() + def test_no_newsfragments_draft(self, runner): """ An empty newsfragment directory acts as if there are no changes. """ - runner = CliRunner() - - with runner.isolated_filesystem(): - setup_simple_project() - - result = runner.invoke(_main, ["--draft", "--date", "01-01-2001"]) + result = runner.invoke(_main, ["--draft", "--date", "01-01-2001"]) self.assertEqual(0, result.exit_code) self.assertIn("No significant changes.\n", result.output) - def test_no_newsfragments(self): + @with_project() + def test_no_newsfragments(self, runner): """ An empty newsfragment directory acts as if there are no changes and removing files handles it gracefully. """ - runner = CliRunner() - - with runner.isolated_filesystem(): - setup_simple_project() + result = runner.invoke(_main, ["--date", "01-01-2001"]) - result = runner.invoke(_main, ["--date", "01-01-2001"]) - - news = read("NEWS.rst") + news = read("NEWS.rst") self.assertEqual(0, result.exit_code) self.assertIn("No significant changes.\n", news) - def test_collision(self): - runner = CliRunner() - - with runner.isolated_filesystem(): - setup_simple_project() - # Note that both are 123.feature - with open("foo/newsfragments/123.feature", "w") as f: - f.write("Adds levitation") - with open("foo/newsfragments/123.feature.rst", "w") as f: - f.write("Extends levitation") + @with_project() + def test_collision(self, runner): + # Note that both are 123.feature + with open("foo/newsfragments/123.feature", "w") as f: + f.write("Adds levitation") + with open("foo/newsfragments/123.feature.rst", "w") as f: + f.write("Extends levitation") - result = runner.invoke(_main, ["--draft", "--date", "01-01-2001"]) + result = runner.invoke(_main, ["--draft", "--date", "01-01-2001"]) # This should fail self.assertEqual(type(result.exception), ValueError) @@ -399,69 +384,52 @@ def run_order_scenario(sections, types): ), ) - def test_draft_no_date(self): + @with_git_project() + def test_draft_no_date(self, runner, commit): """ If no date is passed, today's date is used. """ - runner = CliRunner() + fragment_path1 = "foo/newsfragments/123.feature" + fragment_path2 = "foo/newsfragments/124.feature.rst" + with open(fragment_path1, "w") as f: + f.write("Adds levitation") + with open(fragment_path2, "w") as f: + f.write("Extends levitation") - with runner.isolated_filesystem(): - setup_simple_project() - fragment_path1 = "foo/newsfragments/123.feature" - fragment_path2 = "foo/newsfragments/124.feature.rst" - with open(fragment_path1, "w") as f: - f.write("Adds levitation") - with open(fragment_path2, "w") as f: - f.write("Extends levitation") - - call(["git", "init"]) - call(["git", "config", "user.name", "user"]) - call(["git", "config", "user.email", "user@example.com"]) - call(["git", "config", "commit.gpgSign", "false"]) - call(["git", "add", "."]) - call(["git", "commit", "-m", "Initial Commit"]) - - today = date.today() - result = runner.invoke(_main, ["--draft"]) + commit() - self.assertEqual(0, result.exit_code) - self.assertIn(f"Foo 1.2.3 ({today.isoformat()})", result.output) + today = date.today() + result = runner.invoke(_main, ["--draft"]) - def test_no_confirmation(self): - runner = CliRunner() + self.assertEqual(0, result.exit_code) + self.assertIn(f"Foo 1.2.3 ({today.isoformat()})", result.output) - with runner.isolated_filesystem(): - setup_simple_project() - fragment_path1 = "foo/newsfragments/123.feature" - fragment_path2 = "foo/newsfragments/124.feature.rst" - with open(fragment_path1, "w") as f: - f.write("Adds levitation") - with open(fragment_path2, "w") as f: - f.write("Extends levitation") - - call(["git", "init"]) - call(["git", "config", "user.name", "user"]) - call(["git", "config", "user.email", "user@example.com"]) - call(["git", "config", "commit.gpgSign", "false"]) - call(["git", "add", "."]) - call(["git", "commit", "-m", "Initial Commit"]) - - result = runner.invoke(_main, ["--date", "01-01-2001", "--yes"]) - - self.assertEqual(0, result.exit_code) - path = "NEWS.rst" - self.assertTrue(os.path.isfile(path)) - self.assertFalse(os.path.isfile(fragment_path1)) - self.assertFalse(os.path.isfile(fragment_path2)) - - @with_isolated_runner - def test_keep_fragments(self, runner): + @with_git_project() + def test_no_confirmation(self, runner, commit): + fragment_path1 = "foo/newsfragments/123.feature" + fragment_path2 = "foo/newsfragments/124.feature.rst" + with open(fragment_path1, "w") as f: + f.write("Adds levitation") + with open(fragment_path2, "w") as f: + f.write("Extends levitation") + + commit() + + result = runner.invoke(_main, ["--date", "01-01-2001", "--yes"]) + + self.assertEqual(0, result.exit_code) + path = "NEWS.rst" + self.assertTrue(os.path.isfile(path)) + self.assertFalse(os.path.isfile(fragment_path1)) + self.assertFalse(os.path.isfile(fragment_path2)) + + @with_git_project() + def test_keep_fragments(self, runner, commit): """ The `--keep` option will build the full final news file without deleting the fragment files and without any extra CLI interaction or confirmation. """ - setup_simple_project() fragment_path1 = "foo/newsfragments/123.feature" fragment_path2 = "foo/newsfragments/124.feature.rst" with open(fragment_path1, "w") as f: @@ -469,12 +437,7 @@ def test_keep_fragments(self, runner): with open(fragment_path2, "w") as f: f.write("Extends levitation") - call(["git", "init"]) - call(["git", "config", "user.name", "user"]) - call(["git", "config", "user.email", "user@example.com"]) - call(["git", "config", "commit.gpgSign", "false"]) - call(["git", "add", "."]) - call(["git", "commit", "-m", "Initial Commit"]) + commit() result = runner.invoke(_main, ["--date", "01-01-2001", "--keep"]) @@ -485,8 +448,8 @@ def test_keep_fragments(self, runner): self.assertTrue(os.path.isfile(fragment_path1)) self.assertTrue(os.path.isfile(fragment_path2)) - @with_isolated_runner - def test_yes_keep_error(self, runner): + @with_git_project() + def test_yes_keep_error(self, runner, commit): """ It will fail to perform any action when the conflicting --keep and --yes options are provided. @@ -495,7 +458,6 @@ def test_yes_keep_error(self, runner): to make sure both orders are validated since click triggers the validator in the order it parses the command line. """ - setup_simple_project() fragment_path1 = "foo/newsfragments/123.feature" fragment_path2 = "foo/newsfragments/124.feature.rst" with open(fragment_path1, "w") as f: @@ -503,12 +465,7 @@ def test_yes_keep_error(self, runner): with open(fragment_path2, "w") as f: f.write("Extends levitation") - call(["git", "init"]) - call(["git", "config", "user.name", "user"]) - call(["git", "config", "user.email", "user@example.com"]) - call(["git", "config", "commit.gpgSign", "false"]) - call(["git", "add", "."]) - call(["git", "commit", "-m", "Initial Commit"]) + commit() result = runner.invoke(_main, ["--date", "01-01-2001", "--yes", "--keep"]) self.assertEqual(1, result.exit_code) @@ -516,38 +473,30 @@ def test_yes_keep_error(self, runner): result = runner.invoke(_main, ["--date", "01-01-2001", "--keep", "--yes"]) self.assertEqual(1, result.exit_code) - def test_confirmation_says_no(self): + @with_git_project() + def test_confirmation_says_no(self, runner, commit): """ If the user says "no" to removing the newsfragements, we end up with a NEWS.rst AND the newsfragments. """ - runner = CliRunner() + fragment_path1 = "foo/newsfragments/123.feature" + fragment_path2 = "foo/newsfragments/124.feature.rst" + with open(fragment_path1, "w") as f: + f.write("Adds levitation") + with open(fragment_path2, "w") as f: + f.write("Extends levitation") - with runner.isolated_filesystem(): - setup_simple_project() - fragment_path1 = "foo/newsfragments/123.feature" - fragment_path2 = "foo/newsfragments/124.feature.rst" - with open(fragment_path1, "w") as f: - f.write("Adds levitation") - with open(fragment_path2, "w") as f: - f.write("Extends levitation") - - call(["git", "init"]) - call(["git", "config", "user.name", "user"]) - call(["git", "config", "user.email", "user@example.com"]) - call(["git", "config", "commit.gpgSign", "false"]) - call(["git", "add", "."]) - call(["git", "commit", "-m", "Initial Commit"]) - - with patch("towncrier.build.click.confirm") as m: - m.return_value = False - result = runner.invoke(_main, []) - - self.assertEqual(0, result.exit_code) - path = "NEWS.rst" - self.assertTrue(os.path.isfile(path)) - self.assertTrue(os.path.isfile(fragment_path1)) - self.assertTrue(os.path.isfile(fragment_path2)) + commit() + + with patch("towncrier.build.click.confirm") as m: + m.return_value = False + result = runner.invoke(_main, []) + + self.assertEqual(0, result.exit_code) + path = "NEWS.rst" + self.assertTrue(os.path.isfile(path)) + self.assertTrue(os.path.isfile(fragment_path1)) + self.assertTrue(os.path.isfile(fragment_path2)) def test_needs_config(self): """ @@ -561,50 +510,45 @@ def test_needs_config(self): self.assertEqual(1, result.exit_code, result.output) self.assertTrue(result.output.startswith("No configuration file found.")) - @with_isolated_runner + @with_project(config="[tool.towncrier]") def test_needs_version(self, runner: CliRunner): """ If the configuration file doesn't specify a version or a package, the version option is required. """ - write("towncrier.toml", "[tool.towncrier]") - result = runner.invoke(_main, ["--draft"], catch_exceptions=False) self.assertEqual(2, result.exit_code) self.assertIn("Error: '--version' is required", result.output) - def test_projectless_changelog(self): + @with_project() + def test_projectless_changelog(self, runner): """In which a directory containing news files is built into a changelog - without a Python project or version number. We override the project title from the commandline. """ - runner = CliRunner() + # Remove the version from the project + Path("foo/__init__.py").unlink() - with runner.isolated_filesystem(): - with open("pyproject.toml", "w") as f: - f.write("[tool.towncrier]\n" 'package = "foo"\n') - os.mkdir("foo") - os.mkdir("foo/newsfragments") - with open("foo/newsfragments/123.feature", "w") as f: - f.write("Adds levitation") - # Towncrier ignores .rst extension - with open("foo/newsfragments/124.feature.rst", "w") as f: - f.write("Extends levitation") + with open("foo/newsfragments/123.feature", "w") as f: + f.write("Adds levitation") + # Towncrier ignores .rst extension + with open("foo/newsfragments/124.feature.rst", "w") as f: + f.write("Extends levitation") - result = runner.invoke( - _main, - [ - "--name", - "FooBarBaz", - "--version", - "7.8.9", - "--date", - "01-01-2001", - "--draft", - ], - ) + result = runner.invoke( + _main, + [ + "--name", + "FooBarBaz", + "--version", + "7.8.9", + "--date", + "01-01-2001", + "--draft", + ], + ) self.assertEqual(0, result.exit_code) self.assertEqual( @@ -632,22 +576,23 @@ def test_projectless_changelog(self): ).lstrip(), ) - def test_version_in_config(self): - """The calling towncrier with version defined in configfile. + @with_project( + config=""" + [tool.towncrier] + version = "7.8.9" + """ + ) + def test_version_in_config(self, runner): + """Calling towncrier with version defined in configfile. Specifying a version in toml file will be helpful if version is maintained by i.e. bumpversion and it's not a python project. """ - runner = CliRunner() - - with runner.isolated_filesystem(): - with open("pyproject.toml", "w") as f: - f.write("[tool.towncrier]\n" 'version = "7.8.9"\n') - os.mkdir("newsfragments") - with open("newsfragments/123.feature", "w") as f: - f.write("Adds levitation") + os.mkdir("newsfragments") + with open("newsfragments/123.feature", "w") as f: + f.write("Adds levitation") - result = runner.invoke(_main, ["--date", "01-01-2001", "--draft"]) + result = runner.invoke(_main, ["--date", "01-01-2001", "--draft"]) self.assertEqual(0, result.exit_code, result.output) self.assertEqual( @@ -674,24 +619,25 @@ def test_version_in_config(self): ).lstrip(), ) - def test_project_name_in_config(self): + @with_project( + config=""" + [tool.towncrier] + name = "ImGoProject" + """ + ) + def test_project_name_in_config(self, runner): """The calling towncrier with project name defined in configfile. Specifying a project name in toml file will be helpful to keep the project name consistent as part of the towncrier configuration, not call. """ - runner = CliRunner() - - with runner.isolated_filesystem(): - with open("pyproject.toml", "w") as f: - f.write("[tool.towncrier]\n" 'name = "ImGoProject"\n') - os.mkdir("newsfragments") - with open("newsfragments/123.feature", "w") as f: - f.write("Adds levitation") + os.mkdir("newsfragments") + with open("newsfragments/123.feature", "w") as f: + f.write("Adds levitation") - result = runner.invoke( - _main, ["--version", "7.8.9", "--date", "01-01-2001", "--draft"] - ) + result = runner.invoke( + _main, ["--version", "7.8.9", "--date", "01-01-2001", "--draft"] + ) self.assertEqual(0, result.exit_code, result.output) self.assertEqual( @@ -718,7 +664,8 @@ def test_project_name_in_config(self): ).lstrip(), ) - def test_no_package_changelog(self): + @with_project(config="[tool.towncrier]") + def test_no_package_changelog(self, runner): """The calling towncrier with any package argument. Specifying a package in the toml file or the command line @@ -727,18 +674,13 @@ def test_no_package_changelog(self): so we do not need the package for that. - we don't need to include the package in the changelog header. """ - runner = CliRunner() - - with runner.isolated_filesystem(): - with open("pyproject.toml", "w") as f: - f.write("[tool.towncrier]") - os.mkdir("newsfragments") - with open("newsfragments/123.feature", "w") as f: - f.write("Adds levitation") + os.mkdir("newsfragments") + with open("newsfragments/123.feature", "w") as f: + f.write("Adds levitation") - result = runner.invoke( - _main, ["--version", "7.8.9", "--date", "01-01-2001", "--draft"] - ) + result = runner.invoke( + _main, ["--version", "7.8.9", "--date", "01-01-2001", "--draft"] + ) self.assertEqual(0, result.exit_code, result.output) self.assertEqual( @@ -765,13 +707,19 @@ def test_no_package_changelog(self): ).lstrip(), ) - def test_release_notes_in_separate_files(self): + @with_project( + config=""" + [tool.towncrier] + single_file=false + filename="{version}-notes.rst" + """ + ) + def test_release_notes_in_separate_files(self, runner): """ When `single_file = false` the release notes for each version are stored in a separate file. The name of the file is defined by the `filename` configuration value. """ - runner = CliRunner() def do_build_once_with(version, fragment_file, fragment): with open(f"newsfragments/{fragment_file}", "w") as f: @@ -794,78 +742,65 @@ def do_build_once_with(version, fragment_file, fragment): return result results = [] - with runner.isolated_filesystem(): - with open("pyproject.toml", "w") as f: - f.write( - "\n".join( - [ - "[tool.towncrier]", - " single_file=false", - ' filename="{version}-notes.rst"', - ] - ) - ) - os.mkdir("newsfragments") - results.append( - do_build_once_with("7.8.9", "123.feature", "Adds levitation") - ) - results.append(do_build_once_with("7.9.0", "456.bugfix", "Adds catapult")) + os.mkdir("newsfragments") + results.append(do_build_once_with("7.8.9", "123.feature", "Adds levitation")) + results.append(do_build_once_with("7.9.0", "456.bugfix", "Adds catapult")) - self.assertEqual(0, results[0].exit_code, results[0].output) - self.assertEqual(0, results[1].exit_code, results[1].output) - self.assertEqual( - 2, - len(list(Path.cwd().glob("*-notes.rst"))), - "one newfile for each build", - ) - self.assertTrue(os.path.exists("7.8.9-notes.rst"), os.listdir(".")) - self.assertTrue(os.path.exists("7.9.0-notes.rst"), os.listdir(".")) + self.assertEqual(0, results[0].exit_code, results[0].output) + self.assertEqual(0, results[1].exit_code, results[1].output) + self.assertEqual( + 2, + len(list(Path.cwd().glob("*-notes.rst"))), + "one newfile for each build", + ) + self.assertTrue(os.path.exists("7.8.9-notes.rst"), os.listdir(".")) + self.assertTrue(os.path.exists("7.9.0-notes.rst"), os.listdir(".")) - outputs = [] - outputs.append(read("7.8.9-notes.rst")) - outputs.append(read("7.9.0-notes.rst")) + outputs = [] + outputs.append(read("7.8.9-notes.rst")) + outputs.append(read("7.9.0-notes.rst")) - self.assertEqual( - outputs[0], - dedent( - """ - foo 7.8.9 (01-01-2001) - ====================== + self.assertEqual( + outputs[0], + dedent( + """ + foo 7.8.9 (01-01-2001) + ====================== - Features - -------- + Features + -------- - - Adds levitation (#123) + - Adds levitation (#123) + """ + ).lstrip(), + ) + self.assertEqual( + outputs[1], + dedent( """ - ).lstrip(), - ) - self.assertEqual( - outputs[1], - dedent( - """ - foo 7.9.0 (01-01-2001) - ====================== + foo 7.9.0 (01-01-2001) + ====================== - Bugfixes - -------- + Bugfixes + -------- - - Adds catapult (#456) - """ - ).lstrip(), - ) + - Adds catapult (#456) + """ + ).lstrip(), + ) - def test_singlefile_errors_and_explains_cleanly(self): + @with_project( + config=""" + [tool.towncrier] + singlefile="fail!" + """ + ) + def test_singlefile_errors_and_explains_cleanly(self, runner): """ Failure to find the configuration file results in a clean explanation without a traceback. """ - runner = CliRunner() - - with runner.isolated_filesystem(): - with open("pyproject.toml", "w") as f: - f.write('[tool.towncrier]\n singlefile="fail!"\n') - - result = runner.invoke(_main) + result = runner.invoke(_main) self.assertEqual(1, result.exit_code) self.assertEqual( @@ -958,123 +893,115 @@ def do_build_once_with(version, fragment_file, fragment): ).lstrip(), ) - def test_bullet_points_false(self): + @with_project( + config=""" + [tool.towncrier] + template="towncrier:single-file-no-bullets" + all_bullets=false + """ + ) + def test_bullet_points_false(self, runner): """ When all_bullets is false, subsequent lines are not indented. The automatic ticket number inserted by towncrier will align with the manual bullet. """ - runner = CliRunner() + os.mkdir("newsfragments") + with open("newsfragments/123.feature", "w") as f: + f.write("wow!\n~~~~\n\nNo indentation at all.") + with open("newsfragments/124.bugfix", "w") as f: + f.write("#. Numbered bullet list.") + with open("newsfragments/125.removal", "w") as f: + f.write("- Hyphen based bullet list.") + with open("newsfragments/126.doc", "w") as f: + f.write("* Asterisk based bullet list.") - with runner.isolated_filesystem(): - with open("pyproject.toml", "w") as f: - f.write( - "[tool.towncrier]\n" - 'template="towncrier:single-file-no-bullets"\n' - "all_bullets=false" - ) - os.mkdir("newsfragments") - with open("newsfragments/123.feature", "w") as f: - f.write("wow!\n~~~~\n\nNo indentation at all.") - with open("newsfragments/124.bugfix", "w") as f: - f.write("#. Numbered bullet list.") - with open("newsfragments/125.removal", "w") as f: - f.write("- Hyphen based bullet list.") - with open("newsfragments/126.doc", "w") as f: - f.write("* Asterisk based bullet list.") - - result = runner.invoke( - _main, - [ - "--version", - "7.8.9", - "--name", - "foo", - "--date", - "01-01-2001", - "--yes", - ], - ) + result = runner.invoke( + _main, + [ + "--version", + "7.8.9", + "--name", + "foo", + "--date", + "01-01-2001", + "--yes", + ], + ) - self.assertEqual(0, result.exit_code, result.output) - output = read("NEWS.rst") + self.assertEqual(0, result.exit_code, result.output) + output = read("NEWS.rst") self.assertEqual( output, - """ -foo 7.8.9 (01-01-2001) -====================== + dedent( + """ + foo 7.8.9 (01-01-2001) + ====================== -Features --------- + Features + -------- -wow! -~~~~ + wow! + ~~~~ -No indentation at all. -(#123) + No indentation at all. + (#123) -Bugfixes --------- + Bugfixes + -------- -#. Numbered bullet list. - (#124) + #. Numbered bullet list. + (#124) -Improved Documentation ----------------------- + Improved Documentation + ---------------------- -* Asterisk based bullet list. - (#126) + * Asterisk based bullet list. + (#126) -Deprecations and Removals -------------------------- + Deprecations and Removals + ------------------------- -- Hyphen based bullet list. - (#125) -""".lstrip(), + - Hyphen based bullet list. + (#125) + """ + ).lstrip(), ) - def test_title_format_custom(self): + @with_project( + config=""" + [tool.towncrier] + package = "foo" + title_format = "[{project_date}] CUSTOM RELEASE for {name} version {version}" + """ + ) + def test_title_format_custom(self, runner): """ A non-empty title format adds the specified title. """ - runner = CliRunner() - - with runner.isolated_filesystem(): - with open("pyproject.toml", "w") as f: - f.write( - dedent( - """\ - [tool.towncrier] - package = "foo" - title_format = "[{project_date}] CUSTOM RELEASE for {name} version {version}" - """ - ) - ) - os.mkdir("foo") - os.mkdir("foo/newsfragments") - with open("foo/newsfragments/123.feature", "w") as f: - f.write("Adds levitation") - # Towncrier ignores .rst extension - with open("foo/newsfragments/124.feature.rst", "w") as f: - f.write("Extends levitation") + with open("foo/newsfragments/123.feature", "w") as f: + f.write("Adds levitation") + # Towncrier ignores .rst extension + with open("foo/newsfragments/124.feature.rst", "w") as f: + f.write("Extends levitation") - result = runner.invoke( - _main, - [ - "--name", - "FooBarBaz", - "--version", - "7.8.9", - "--date", - "20-01-2001", - "--draft", - ], - ) + result = runner.invoke( + _main, + [ + "--name", + "FooBarBaz", + "--version", + "7.8.9", + "--date", + "20-01-2001", + "--draft", + ], + ) expected_output = dedent( """\ @@ -1101,61 +1028,53 @@ def test_title_format_custom(self): self.assertEqual(0, result.exit_code) self.assertEqual(expected_output, result.output) - def test_title_format_false(self): + @with_project( + config=""" + [tool.towncrier] + package = "foo" + title_format = false + template = "template.rst" + """ + ) + def test_title_format_false(self, runner): """ Setting the title format to false disables the explicit title. This would be used, for example, when the template creates the title itself. """ - runner = CliRunner() - - with runner.isolated_filesystem(): - with open("pyproject.toml", "w") as f: - f.write( - dedent( - """\ - [tool.towncrier] - package = "foo" - title_format = false - template = "template.rst" - """ - ) - ) - os.mkdir("foo") - os.mkdir("foo/newsfragments") - with open("template.rst", "w") as f: - f.write( - dedent( - """\ - Here's a hardcoded title added by the template - ============================================== - {% for section in sections %} - {% set underline = "-" %} - {% for category, val in definitions.items() if category in sections[section] %} - - {% for text, values in sections[section][category]|dictsort(by='value') %} - - {{ text }} - - {% endfor %} - {% endfor %} - {% endfor %} - """ - ) + with open("template.rst", "w") as f: + f.write( + dedent( + """\ + Here's a hardcoded title added by the template + ============================================== + {% for section in sections %} + {% set underline = "-" %} + {% for category, val in definitions.items() if category in sections[section] %} + + {% for text, values in sections[section][category]|dictsort(by='value') %} + - {{ text }} + + {% endfor %} + {% endfor %} + {% endfor %} + """ ) - - result = runner.invoke( - _main, - [ - "--name", - "FooBarBaz", - "--version", - "7.8.9", - "--date", - "20-01-2001", - "--draft", - ], - catch_exceptions=False, ) + result = runner.invoke( + _main, + [ + "--name", + "FooBarBaz", + "--version", + "7.8.9", + "--date", + "20-01-2001", + "--draft", + ], + catch_exceptions=False, + ) + expected_output = dedent( """\ Loading template... @@ -1173,46 +1092,40 @@ def test_title_format_false(self): self.assertEqual(0, result.exit_code) self.assertEqual(expected_output, result.output) - def test_start_string(self): + @with_project( + config=""" + [tool.towncrier] + start_string="Release notes start marker" + """ + ) + def test_start_string(self, runner): """ The `start_string` configuration is used to detect the starting point for inserting the generated release notes. A newline is automatically added to the configured value. """ - runner = CliRunner() - - with runner.isolated_filesystem(): - with open("pyproject.toml", "w") as f: - f.write( - dedent( - """\ - [tool.towncrier] - start_string="Release notes start marker" - """ - ) - ) - os.mkdir("newsfragments") - with open("newsfragments/123.feature", "w") as f: - f.write("Adds levitation") - with open("NEWS.rst", "w") as f: - f.write("a line\n\nanother\n\nRelease notes start marker\na footer!\n") + os.mkdir("newsfragments") + with open("newsfragments/123.feature", "w") as f: + f.write("Adds levitation") + with open("NEWS.rst", "w") as f: + f.write("a line\n\nanother\n\nRelease notes start marker\na footer!\n") - result = runner.invoke( - _main, - [ - "--version", - "7.8.9", - "--name", - "foo", - "--date", - "01-01-2001", - "--yes", - ], - ) + result = runner.invoke( + _main, + [ + "--version", + "7.8.9", + "--name", + "foo", + "--date", + "01-01-2001", + "--yes", + ], + ) - self.assertEqual(0, result.exit_code, result.output) - self.assertTrue(os.path.exists("NEWS.rst"), os.listdir(".")) - output = read("NEWS.rst") + self.assertEqual(0, result.exit_code, result.output) + self.assertTrue(os.path.exists("NEWS.rst"), os.listdir(".")) + output = read("NEWS.rst") expected_output = dedent( """\ @@ -1236,13 +1149,11 @@ def test_start_string(self): self.assertEqual(expected_output, output) - @with_isolated_runner + @with_project() def test_default_start_string(self, runner): """ The default start string is ``.. towncrier release notes start``. """ - setup_simple_project() - write("foo/newsfragments/123.feature", "Adds levitation") write( "NEWS.rst", @@ -1285,14 +1196,18 @@ def test_default_start_string(self, runner): self.assertEqual(expected_output, output) - @with_isolated_runner + @with_project( + config=""" + [tool.towncrier] + package = "foo" + filename = "NEWS.md" + """ + ) def test_default_start_string_markdown(self, runner): """ The default start string is ```` for Markdown. """ - setup_simple_project(extra_config='filename = "NEWS.md"') - write("foo/newsfragments/123.feature", "Adds levitation") write( "NEWS.md", @@ -1333,59 +1248,53 @@ def test_default_start_string_markdown(self, runner): self.assertEqual(expected_output, output) - def test_with_topline_and_template_and_draft(self): + @with_project( + config=""" + [tool.towncrier] + title_format = "{version} - {project_date}" + template = "template.rst" + + [[tool.towncrier.type]] + directory = "feature" + name = "" + showcontent = true + """ + ) + def test_with_topline_and_template_and_draft(self, runner): """ Spacing is proper when drafting with a topline and a template. """ - runner = CliRunner() + os.mkdir("newsfragments") + with open("newsfragments/123.feature", "w") as f: + f.write("Adds levitation") + with open("template.rst", "w") as f: + f.write( + dedent( + """\ + {% for section in sections %} + {% set underline = "-" %} + {% for category, val in definitions.items() if category in sections[section] %} - with runner.isolated_filesystem(): - with open("pyproject.toml", "w") as f: - f.write( - dedent( - """\ - [tool.towncrier] - title_format = "{version} - {project_date}" - template = "template.rst" + {% for text, values in sections[section][category]|dictsort(by='value') %} + - {{ text }} - [[tool.towncrier.type]] - directory = "feature" - name = "" - showcontent = true - """ - ) - ) - os.mkdir("newsfragments") - with open("newsfragments/123.feature", "w") as f: - f.write("Adds levitation") - with open("template.rst", "w") as f: - f.write( - dedent( - """\ - {% for section in sections %} - {% set underline = "-" %} - {% for category, val in definitions.items() if category in sections[section] %} - - {% for text, values in sections[section][category]|dictsort(by='value') %} - - {{ text }} - - {% endfor %} - {% endfor %} - {% endfor %} - """ - ) + {% endfor %} + {% endfor %} + {% endfor %} + """ ) - - result = runner.invoke( - _main, - [ - "--version=7.8.9", - "--name=foo", - "--date=20-01-2001", - "--draft", - ], ) + result = runner.invoke( + _main, + [ + "--version=7.8.9", + "--name=foo", + "--date=20-01-2001", + "--draft", + ], + ) + expected_output = dedent( """\ Loading template... From 57f203651a080c2a91a3cf9b0e47eb6fca42128b Mon Sep 17 00:00:00 2001 From: Ilia Kurenkov Date: Sun, 12 Nov 2023 14:22:29 +0100 Subject: [PATCH 19/51] Add nox as development dependency (#568) * Add nox as development dependency * Add newsfragment * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: Adi Roiban Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- pyproject.toml | 1 + src/towncrier/newsfragments/568.misc | 0 2 files changed, 1 insertion(+) create mode 100644 src/towncrier/newsfragments/568.misc diff --git a/pyproject.toml b/pyproject.toml index 9f79487a..6856a5bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dev = [ "sphinx >= 5", "furo", "twisted", + "nox", ] [project.scripts] diff --git a/src/towncrier/newsfragments/568.misc b/src/towncrier/newsfragments/568.misc new file mode 100644 index 00000000..e69de29b From afee21d02f579cfa44f2fa0c0fc799f9fd224904 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 4 Dec 2023 19:47:54 +0000 Subject: [PATCH 20/51] [pre-commit.ci] pre-commit autoupdate (#570) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 23.10.1 → 23.11.0](https://github.com/psf/black/compare/23.10.1...23.11.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4243d584..b6c8c4d9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ ci: repos: - repo: https://github.com/psf/black - rev: 23.10.1 + rev: 23.11.0 hooks: - id: black From 381293a24d3f5f8c612fbebfafc296173ffb1fe5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 1 Jan 2024 20:55:09 +0100 Subject: [PATCH 21/51] [pre-commit.ci] pre-commit autoupdate (#573) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 23.11.0 → 23.12.1](https://github.com/psf/black/compare/23.11.0...23.12.1) - [github.com/PyCQA/isort: 5.12.0 → 5.13.2](https://github.com/PyCQA/isort/compare/5.12.0...5.13.2) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b6c8c4d9..757110fc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ ci: repos: - repo: https://github.com/psf/black - rev: 23.11.0 + rev: 23.12.1 hooks: - id: black @@ -15,7 +15,7 @@ repos: args: [--py38-plus] - repo: https://github.com/PyCQA/isort - rev: 5.12.0 + rev: 5.13.2 hooks: - id: isort additional_dependencies: [toml] From 28bb011cfc9aa644075f7b7bfcc9da6721421a01 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Mon, 5 Feb 2024 08:05:50 +0000 Subject: [PATCH 22/51] Unpin hatchling (#575) * Unpin hatchling It's breaking the build. * Add news fragment --- pyproject.toml | 2 +- src/towncrier/newsfragments/575.misc | 0 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 src/towncrier/newsfragments/575.misc diff --git a/pyproject.toml b/pyproject.toml index 6856a5bb..236e09cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [build-system] requires = [ - "hatchling ~= 1.17.1", + "hatchling", "incremental == 22.10.0", ] build-backend = "hatchling.build" diff --git a/src/towncrier/newsfragments/575.misc b/src/towncrier/newsfragments/575.misc new file mode 100644 index 00000000..e69de29b From 19399e607a59c469c0a3921af7f341df7e6e4e74 Mon Sep 17 00:00:00 2001 From: Adi Roiban Date: Mon, 5 Feb 2024 11:14:27 +0000 Subject: [PATCH 23/51] Create SECURITY.md (#574) * Create SECURITY.md * Add news fragment * Update SECURITY.md Co-authored-by: Hynek Schlawack --------- Co-authored-by: Hynek Schlawack --- SECURITY.md | 5 +++++ src/towncrier/newsfragments/574.misc | 0 2 files changed, 5 insertions(+) create mode 100644 SECURITY.md create mode 100644 src/towncrier/newsfragments/574.misc diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..a17a60d6 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,5 @@ +# Security Policy + +The twisted/towncrier project uses the same security policy as [twisted/twisted](https://github.com/twisted/twisted). + +For more details, please check the [Twisted security process](https://github.com/twisted/twisted?tab=security-ov-file#readme). diff --git a/src/towncrier/newsfragments/574.misc b/src/towncrier/newsfragments/574.misc new file mode 100644 index 00000000..e69de29b From 3c50423e9b23d3979d401e833e37e07557fa808c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 6 Feb 2024 06:28:19 +0000 Subject: [PATCH 24/51] [pre-commit.ci] pre-commit autoupdate (#576) --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 757110fc..0fd47514 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ ci: repos: - repo: https://github.com/psf/black - rev: 23.12.1 + rev: 24.1.1 hooks: - id: black @@ -21,7 +21,7 @@ repos: additional_dependencies: [toml] - repo: https://github.com/PyCQA/flake8 - rev: 6.1.0 + rev: 7.0.0 hooks: - id: flake8 From d48af0fdcb43b350845a5887733bc728db471fed Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Tue, 2 Apr 2024 06:41:29 +0200 Subject: [PATCH 25/51] Update build-and-inspect-python-package (#582) * Update build-and-inspect-python-package * Add news fragment --- .github/workflows/ci.yml | 2 +- src/towncrier/newsfragments/582.misc | 0 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 src/towncrier/newsfragments/582.misc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5467106..08871404 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: hynek/build-and-inspect-python-package@54d50a852d960c25f25f7f1874d3d969ecbe4eb0 + - uses: hynek/build-and-inspect-python-package@f51d0e79a34e62af977fcfe458b41fa8490e6e65 - name: Set up ${{ matrix.python.name }} uses: actions/setup-python@v4 diff --git a/src/towncrier/newsfragments/582.misc b/src/towncrier/newsfragments/582.misc new file mode 100644 index 00000000..e69de29b From 212a9385ec1b47476f30e889ff6d0c605a87a445 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 2 Apr 2024 06:49:48 +0200 Subject: [PATCH 26/51] [pre-commit.ci] pre-commit autoupdate (#581) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 24.1.1 → 24.3.0](https://github.com/psf/black/compare/24.1.1...24.3.0) - [github.com/asottile/pyupgrade: v3.15.0 → v3.15.2](https://github.com/asottile/pyupgrade/compare/v3.15.0...v3.15.2) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Hynek Schlawack --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0fd47514..5e07b6be 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,12 +4,12 @@ ci: repos: - repo: https://github.com/psf/black - rev: 24.1.1 + rev: 24.3.0 hooks: - id: black - repo: https://github.com/asottile/pyupgrade - rev: v3.15.0 + rev: v3.15.2 hooks: - id: pyupgrade args: [--py38-plus] From 914b446e64d62f3968a97931e490b1abe5c8c581 Mon Sep 17 00:00:00 2001 From: Silvan Mosberger Date: Fri, 19 Apr 2024 04:01:20 +0200 Subject: [PATCH 27/51] fix: Also render title in markdown template without name (#587) --- src/towncrier/newsfragments/587.bugfix | 1 + src/towncrier/templates/default.md | 2 +- src/towncrier/test/test_build.py | 47 ++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 src/towncrier/newsfragments/587.bugfix diff --git a/src/towncrier/newsfragments/587.bugfix b/src/towncrier/newsfragments/587.bugfix new file mode 100644 index 00000000..eeec305f --- /dev/null +++ b/src/towncrier/newsfragments/587.bugfix @@ -0,0 +1 @@ +The default Markdown template now renders a title containing the release version and date, even when the `name` configuration is left empty. diff --git a/src/towncrier/templates/default.md b/src/towncrier/templates/default.md index 9afb5c49..cc894f60 100644 --- a/src/towncrier/templates/default.md +++ b/src/towncrier/templates/default.md @@ -2,7 +2,7 @@ {% if versiondata.name %} # {{ versiondata.name }} {{ versiondata.version }} ({{ versiondata.date }}) {% else %} -{{ versiondata.version }} ({{ versiondata.date }}) +# {{ versiondata.version }} ({{ versiondata.date }}) {% endif %} {% endif %} {% for section, _ in sections.items() %} diff --git a/src/towncrier/test/test_build.py b/src/towncrier/test/test_build.py index 76f6e8aa..241425e9 100644 --- a/src/towncrier/test/test_build.py +++ b/src/towncrier/test/test_build.py @@ -1248,6 +1248,53 @@ def test_default_start_string_markdown(self, runner): self.assertEqual(expected_output, output) + @with_project( + config=""" + [tool.towncrier] + name = "" + directory = "changes" + filename = "NEWS.md" + version = "1.2.3" + """ + ) + def test_markdown_no_name_title(self, runner): + """ + When configured with an empty `name` option, + the default template used for Markdown + renders the title of the release note with just + the version number and release date. + """ + write("changes/123.feature", "Adds levitation") + write( + "NEWS.md", + contents=""" + A line + + + """, + dedent=True, + ) + + result = runner.invoke(_main, ["--date", "01-01-2001"], catch_exceptions=False) + self.assertEqual(0, result.exit_code, result.output) + output = read("NEWS.md") + + expected_output = dedent( + """ + A line + + + + # 1.2.3 (01-01-2001) + + ### Features + + - Adds levitation (#123) + """ + ) + + self.assertEqual(expected_output, output) + @with_project( config=""" [tool.towncrier] From 2908a62d15843597d6ab1dae9cb06ab39dafa3c3 Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Sat, 27 Apr 2024 00:30:36 +1200 Subject: [PATCH 28/51] Add docs for CLI orphan fragments (#589) * Add docs for CLI orphan fragments Refs #584 * Add newsfragment * Mention orphan fragments in doc * Add a note about '+' to the create command's docstring --- docs/cli.rst | 7 +++++++ src/towncrier/create.py | 3 +++ src/towncrier/newsfragments/589.doc | 1 + 3 files changed, 11 insertions(+) create mode 100644 src/towncrier/newsfragments/589.doc diff --git a/docs/cli.rst b/docs/cli.rst index 7af5a3ac..d2fa0d4b 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -79,6 +79,13 @@ If the fragments directory does not exist, it will be created. 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. In the above example, it will generate ``123.bugfix.1.rst`` if ``123.bugfix.rst`` already exists. +To create a news fragment not tied to a specific issue (which towncrier calls an "orphan fragment"), start the fragment name with a ``+``. +If that is the entire fragment name, a random hash will be added for you:: + + $ towncrier create +.feature.rst + $ ls newsfragments/ + +fcc4dc7b.feature.rst + .. option:: --content, -c CONTENT A string to use for content. diff --git a/src/towncrier/create.py b/src/towncrier/create.py index 5242e124..de264121 100644 --- a/src/towncrier/create.py +++ b/src/towncrier/create.py @@ -64,6 +64,9 @@ def _main( * .doc - a documentation improvement, * .removal - a deprecation or removal of public API, * .misc - a ticket has been closed, but it is not of interest to users. + + If the FILENAME base is just '+' (to create a fragment not tied to an + issue), it will be appended with a random hex string. """ __main(ctx, directory, config, filename, edit, content) diff --git a/src/towncrier/newsfragments/589.doc b/src/towncrier/newsfragments/589.doc new file mode 100644 index 00000000..3664626f --- /dev/null +++ b/src/towncrier/newsfragments/589.doc @@ -0,0 +1 @@ +Add docs to explain how ``towncrier create +.feature.rst`` (orphan fragments) works. From 50fc1e68af63a814f82bdc377399c7132d345b8a Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Sat, 27 Apr 2024 12:37:35 +1200 Subject: [PATCH 29/51] Fix incorrect handling of orphan fragment names consists of only digits (#588) * Don't lose the flag at the start of decimal only orphans Fixes #584 * Add news fragment * Reword news fragment to be less git-commenty * mention why we're removing leading zeros --------- Co-authored-by: Adi Roiban --- src/towncrier/_builder.py | 15 +++++---------- src/towncrier/newsfragments/588.bugfix | 1 + src/towncrier/test/test_builder.py | 7 +++++++ 3 files changed, 13 insertions(+), 10 deletions(-) create mode 100644 src/towncrier/newsfragments/588.bugfix diff --git a/src/towncrier/_builder.py b/src/towncrier/_builder.py index 3d863548..a72a3982 100644 --- a/src/towncrier/_builder.py +++ b/src/towncrier/_builder.py @@ -13,15 +13,6 @@ from jinja2 import Template -def strip_if_integer_string(s: str) -> str: - try: - i = int(s) - except ValueError: - return s - - return str(i) - - # Returns ticket, category and counter or (None, None, None) if the basename # could not be parsed or doesn't contain a valid category. def parse_newfragment_basename( @@ -45,7 +36,11 @@ def parse_newfragment_basename( # NOTE: This allows news fragment names like fix-1.2.3.feature or # something-cool.feature.ext for projects that don't use ticket # numbers in news fragment names. - ticket = strip_if_integer_string(".".join(parts[0:i])) + ticket = ".".join(parts[0:i]).strip() + # If the ticket is an integer, remove any leading zeros (to resolve + # issue #126). + if ticket.isdigit(): + ticket = str(int(ticket)) counter = 0 # Use the following part as the counter if it exists and is a valid # digit. diff --git a/src/towncrier/newsfragments/588.bugfix b/src/towncrier/newsfragments/588.bugfix new file mode 100644 index 00000000..f1c3e8ef --- /dev/null +++ b/src/towncrier/newsfragments/588.bugfix @@ -0,0 +1 @@ +Orphan news fragments, fragments not associated with an issue, consisting of only digits (e.g. '+12345678.feature') now retain their leading marker character. diff --git a/src/towncrier/test/test_builder.py b/src/towncrier/test/test_builder.py index b62033ac..9608e0a2 100644 --- a/src/towncrier/test/test_builder.py +++ b/src/towncrier/test/test_builder.py @@ -125,3 +125,10 @@ def test_orphan_with_dotted_number(self): parse_newfragment_basename("+orphan_12.3.feature", ["feature"]), ("+orphan_12.3", "feature", 0), ) + + def test_orphan_all_digits(self): + """Orphaned snippets can consist of only digits.""" + self.assertEqual( + parse_newfragment_basename("+123.feature", ["feature"]), + ("+123", "feature", 0), + ) From 90b75b4986d3ed92982ff944d4a1d2b74710ae52 Mon Sep 17 00:00:00 2001 From: Silvan Mosberger Date: Sun, 28 Apr 2024 14:14:22 +0200 Subject: [PATCH 30/51] docs(tutorial): Improve location of filename introduction (#586) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently, the tutorial contains this part: > The most basic configuration is just telling towncrier where to look for news fragments: > > ```toml > [tool.towncrier] > directory = "changes" > ``` > > Which will look into “./changes” for news fragments and write them into “./NEWS.rst”. Note the `and write them into “./NEWS.rst”.`. This confused me when reading, because while I clearly saw how `./changes` was specified, it's not clear why `./NEWS.rst` would be used. Only after reading the next paragraph the connection can be made, but that section is about Python specifically: > If you’re working on a Python project, you can also specify a package: > > ```toml > [tool.towncrier] > # The name of your Python package > package = "myproject" > # The path to your Python package. > # If your package lives in 'src/myproject/', it must be 'src', > # but if you don't keep your code in a 'src' dir, remove the > # config option > package_dir = "src" > # Where you want your news files to come out. This can be .rst > # or .md, towncrier's default template works with both. > filename = "NEWS.rst" > ``` But there it's very easy to miss. This commit moves the introduction of the filename option to the earlier section to avoid such confusion. Furthermore we indicate that there's no need to set the option because there's a default. Co-authored-by: Adi Roiban --- docs/tutorial.rst | 8 ++++---- src/towncrier/newsfragments/586.doc | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) create mode 100644 src/towncrier/newsfragments/586.doc diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 875ed1f3..75f99cf0 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -15,10 +15,13 @@ Configuration ``towncrier`` keeps its config in the `PEP-518 `_ ``pyproject.toml`` or a ``towncrier.toml`` file. If the latter exists, it takes precedence. -The most basic configuration is just telling ``towncrier`` where to look for news fragments:: +The most basic configuration is just telling ``towncrier`` where to look for news fragments and what file to generate:: [tool.towncrier] directory = "changes" + # Where you want your news files to come out, `NEWS.rst` is the default. + # This can be .rst or .md, towncrier's default template works with both. + # filename = "NEWS.rst" Which will look into "./changes" for news fragments and write them into "./NEWS.rst". @@ -32,9 +35,6 @@ If you're working on a Python project, you can also specify a package:: # but if you don't keep your code in a 'src' dir, remove the # config option package_dir = "src" - # Where you want your news files to come out. This can be .rst - # or .md, towncrier's default template works with both. - filename = "NEWS.rst" By default, ``towncrier`` will look for news fragments inside your Python package, in a directory named ``newsfragments``. With this example project, it will look in ``src/myproject/newsfragments/`` for them. diff --git a/src/towncrier/newsfragments/586.doc b/src/towncrier/newsfragments/586.doc new file mode 100644 index 00000000..cbaf3461 --- /dev/null +++ b/src/towncrier/newsfragments/586.doc @@ -0,0 +1 @@ +The tutorial now introduces the `filename` option in the appropriate paragraph and mentions its default value. From f6083bbb36ba01946535ced27b18f66b9e952ba7 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sun, 28 Apr 2024 08:19:17 -0400 Subject: [PATCH 31/51] Enable reporting of encoding warnings. (#578) * Enable reporting of encoding warnings. * Add changelog * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Adi Roiban --- noxfile.py | 1 + src/towncrier/newsfragments/561.misc.rst | 1 + 2 files changed, 2 insertions(+) create mode 100644 src/towncrier/newsfragments/561.misc.rst diff --git a/noxfile.py b/noxfile.py index d3e7d025..e7a4bafe 100644 --- a/noxfile.py +++ b/noxfile.py @@ -20,6 +20,7 @@ def pre_commit(session: nox.Session) -> None: # Keep list in-sync with ci.yml/test-linux & pyproject.toml @nox.session(python=["pypy3.8", "3.8", "3.9", "3.10", "3.11", "3.12"]) def tests(session: nox.Session) -> None: + session.env["PYTHONWARNDEFAULTENCODING"] = "1" session.install("Twisted", "coverage[toml]") posargs = list(session.posargs) diff --git a/src/towncrier/newsfragments/561.misc.rst b/src/towncrier/newsfragments/561.misc.rst new file mode 100644 index 00000000..bdb991a5 --- /dev/null +++ b/src/towncrier/newsfragments/561.misc.rst @@ -0,0 +1 @@ +Enable reporting of EncodingWarnings when running tests. From dd4186905893abe6559f8773369360546baa7b5c Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Mon, 29 Apr 2024 00:31:14 +1200 Subject: [PATCH 32/51] Interactive create (#482) * Interactive create when no filename is passed * Configurable eof newline * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add changes * More tests for file extensions * mypy fix * Test interactive create for orphan news fragments * Interactive create when no filename is passed * Configurable eof newline * Add changes * More tests for file extensions * mypy fix * Test interactive create for orphan news fragments * Document two other new features in the newsfragment * Test improvements * Fix test to use default extension * remove obscure --eof-newline/--no-eof-newline cli option. Have a config option is enough. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Glyph Co-authored-by: Adi Roiban Co-authored-by: Adi Roiban --- docs/cli.rst | 7 +- docs/configuration.rst | 10 + src/towncrier/_settings/load.py | 2 + src/towncrier/create.py | 79 ++++-- src/towncrier/newsfragments/482.feature.rst | 5 + src/towncrier/test/test_create.py | 258 +++++++++++++++++--- 6 files changed, 304 insertions(+), 57 deletions(-) create mode 100644 src/towncrier/newsfragments/482.feature.rst diff --git a/docs/cli.rst b/docs/cli.rst index d2fa0d4b..478a88b9 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -72,6 +72,8 @@ Create a news fragment in the directory that ``towncrier`` is configured to look $ towncrier create 123.bugfix.rst +If you don't provide a file name, ``towncrier`` will prompt you for one. + ``towncrier create`` will enforce that the passed type (e.g. ``bugfix``) is valid. If the fragments directory does not exist, it will be created. @@ -91,9 +93,10 @@ If that is the entire fragment name, a random hash will be added for you:: A string to use for content. Default: an instructive placeholder. -.. option:: --edit +.. option:: --edit / --no-edit - Create file and start `$EDITOR` to edit it right away. + Whether to start ``$EDITOR`` to edit the news fragment right away. + Default: ``$EDITOR`` will be started unless you also provided content. ``towncrier check`` diff --git a/docs/configuration.rst b/docs/configuration.rst index d424e1c0..f0eaeda4 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -118,6 +118,16 @@ Top level keys ``"+"`` by default. +``create_eof_newline`` + Ensure the content of a news fragment file created with ``towncrier create`` ends with an empty line. + + ``true`` by default. + +``create_add_extension`` + Add the ``filename`` option extension to news fragment files created with ``towncrier create`` if an extension is not explicitly provided. + + ``true`` by default. + Extra top level keys for Python projects ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/towncrier/_settings/load.py b/src/towncrier/_settings/load.py index 0b845662..66a6c546 100644 --- a/src/towncrier/_settings/load.py +++ b/src/towncrier/_settings/load.py @@ -52,6 +52,8 @@ class Config: wrap: bool = False all_bullets: bool = True orphan_prefix: str = "+" + create_eof_newline: bool = True + create_add_extension: bool = True class ConfigError(ClickException): diff --git a/src/towncrier/create.py b/src/towncrier/create.py index de264121..362b2ba9 100644 --- a/src/towncrier/create.py +++ b/src/towncrier/create.py @@ -14,6 +14,9 @@ from ._settings import config_option_help, load_config_from_options +DEFAULT_CONTENT = "Add your info here" + + @click.command(name="create") @click.pass_context @click.option( @@ -32,30 +35,32 @@ ) @click.option( "--edit/--no-edit", - default=False, + default=None, help="Open an editor for writing the newsfragment content.", -) # TODO: default should be true +) @click.option( "-c", "--content", type=str, - default="Add your info here", + default=DEFAULT_CONTENT, help="Sets the content of the new fragment.", ) -@click.argument("filename") +@click.argument("filename", default="") def _main( ctx: click.Context, directory: str | None, config: str | None, filename: str, - edit: bool, + edit: bool | None, content: str, ) -> None: """ Create a new news fragment. - Create a new news fragment called FILENAME or pass the full path for a file. - Towncrier has a few standard types of news fragments, signified by the file extension. + If FILENAME is not provided, you'll be prompted to create it. + + Towncrier has a few standard types of news fragments, signified by the file + extension. \b These are: @@ -76,7 +81,7 @@ def __main( directory: str | None, config_path: str | None, filename: str, - edit: bool, + edit: bool | None, content: str, ) -> None: """ @@ -84,6 +89,26 @@ def __main( """ base_directory, config = load_config_from_options(directory, config_path) + filename_ext = "" + if config.create_add_extension: + ext = os.path.splitext(config.filename)[1] + if ext.lower() in (".rst", ".md"): + filename_ext = ext + + if not filename: + prompt = "Issue number" + # Add info about adding orphan if config is set. + if config.orphan_prefix: + prompt += f" (`{config.orphan_prefix}` if none)" + issue = click.prompt(prompt) + fragment_type = click.prompt( + "Fragment type", + type=click.Choice(list(config.types)), + ) + filename = f"{issue}.{fragment_type}" + if edit is None and content == DEFAULT_CONTENT: + edit = True + file_dir, file_basename = os.path.split(filename) if config.orphan_prefix and file_basename.startswith(f"{config.orphan_prefix}."): # Append a random hex string to the orphan news fragment base name. @@ -94,15 +119,18 @@ def __main( f"{file_basename[len(config.orphan_prefix):]}" ), ) - if len(filename.split(".")) < 2 or ( - filename.split(".")[-1] not in config.types - and filename.split(".")[-2] not in config.types + filename_parts = filename.split(".") + if len(filename_parts) < 2 or ( + filename_parts[-1] not in config.types + and filename_parts[-2] not in config.types ): raise click.BadParameter( "Expected filename '{}' to be of format '{{name}}.{{type}}', " "where '{{name}}' is an arbitrary slug and '{{type}}' is " "one of: {}".format(filename, ", ".join(config.types)) ) + if filename_parts[-1] in config.types and filename_ext: + filename += filename_ext if config.directory: fragments_directory = os.path.abspath( @@ -135,31 +163,34 @@ def __main( ) if edit: - edited_content = _get_news_content_from_user(content) - if edited_content is None: - click.echo("Abort creating news fragment.") + if content == DEFAULT_CONTENT: + content = "" + content = _get_news_content_from_user(content) + if not content: + click.echo("Aborted creating news fragment due to empty message.") ctx.exit(1) - content = edited_content with open(segment_file, "w") as f: f.write(content) + if config.create_eof_newline and content and not content.endswith("\n"): + f.write("\n") click.echo(f"Created news fragment at {segment_file}") -def _get_news_content_from_user(message: str) -> str | None: - initial_content = ( - "# Please write your news content. When finished, save the file.\n" - "# In order to abort, exit without saving.\n" - '# Lines starting with "#" are ignored.\n' - ) - initial_content += f"\n{message}\n" +def _get_news_content_from_user(message: str) -> str: + initial_content = """ +# Please write your news content. Lines starting with '#' will be ignored, and +# an empty message aborts. +""" + if message: + initial_content = f"{message}\n{initial_content}" content = click.edit(initial_content) if content is None: - return None + return message all_lines = content.split("\n") lines = [line.rstrip() for line in all_lines if not line.lstrip().startswith("#")] - return "\n".join(lines) + return "\n".join(lines).strip() if __name__ == "__main__": # pragma: no cover diff --git a/src/towncrier/newsfragments/482.feature.rst b/src/towncrier/newsfragments/482.feature.rst new file mode 100644 index 00000000..81b66604 --- /dev/null +++ b/src/towncrier/newsfragments/482.feature.rst @@ -0,0 +1,5 @@ +If no filename is given when doing ``towncrier`` create, interactively ask for the issue number and fragment type (and then launch an interactive editor for the fragment content). + +Now by default, when creating a fragment it will be appended with the ``filename`` option's extension (unless an extension is explicitly provided). For example, ``towncrier create 123.feature`` will create ``news/123.feature.rst``. This can be changed in configuration file by setting `add_extension = false`. + +A new line is now added by default to the end of the fragment contents. This can be reverted in the configuration file by setting `add_newline = false`. diff --git a/src/towncrier/test/test_create.py b/src/towncrier/test/test_create.py index 7f0e24b1..e946f201 100644 --- a/src/towncrier/test/test_create.py +++ b/src/towncrier/test/test_create.py @@ -11,7 +11,7 @@ from click.testing import CliRunner from twisted.trial.unittest import TestCase -from ..create import _main +from ..create import DEFAULT_CONTENT, _main from .helpers import setup_simple_project, with_isolated_runner @@ -19,7 +19,12 @@ class TestCli(TestCase): maxDiff = None def _test_success( - self, content=None, config=None, mkdir=True, additional_args=None + self, + content=None, + config=None, + mkdir=True, + additional_args=None, + eof_newline=True, ): runner = CliRunner() @@ -28,15 +33,17 @@ def _test_success( args = ["123.feature.rst"] if content is None: - content = ["Add your info here"] + content = [DEFAULT_CONTENT] if additional_args is not None: args.extend(additional_args) result = runner.invoke(_main, args) self.assertEqual(["123.feature.rst"], os.listdir("foo/newsfragments")) + if eof_newline: + content.append("") with open("foo/newsfragments/123.feature.rst") as fh: - self.assertEqual(content, fh.readlines()) + self.assertEqual("\n".join(content), fh.read()) self.assertEqual(0, result.exit_code) @@ -50,24 +57,21 @@ def test_directory_created(self): def test_edit_without_comments(self): """Create file with dynamic content.""" - content = ["This is line 1\n", "This is line 2"] + content = ["This is line 1", "This is line 2"] with mock.patch("click.edit") as mock_edit: - mock_edit.return_value = "".join(content) + mock_edit.return_value = "\n".join(content) self._test_success(content=content, additional_args=["--edit"]) mock_edit.assert_called_once_with( - "# Please write your news content. When finished, save the file.\n" - "# In order to abort, exit without saving.\n" - '# Lines starting with "#" are ignored.\n' - "\n" - "Add your info here\n" + "\n# Please write your news content. Lines starting " + "with '#' will be ignored, and\n# an empty message aborts.\n" ) def test_edit_with_comment(self): """Create file editly with ignored line.""" - content = ["This is line 1\n", "This is line 2"] - comment = "# I am ignored\n" + content = ["This is line 1", "This is line 2"] + comment = "# I am ignored" with mock.patch("click.edit") as mock_edit: - mock_edit.return_value = "".join(content[:1] + [comment] + content[1:]) + mock_edit.return_value = "\n".join(content[:1] + [comment] + content[1:]) self._test_success(content=content, additional_args=["--edit"]) def test_edit_abort(self): @@ -92,6 +96,27 @@ def test_content(self): content_line = "This is a content" self._test_success(content=[content_line], additional_args=["-c", content_line]) + def test_content_without_eof_newline(self): + """ + When creating a new fragment the content can be passed as a command line + argument. The text editor is not invoked, and no eof newline is added if the + config option is set. + """ + config = dedent( + """\ + [tool.towncrier] + package = "foo" + create_eof_newline = false + """ + ) + content_line = "This is a content" + self._test_success( + content=[content_line], + additional_args=["-c", content_line], + config=config, + eof_newline=False, + ) + def test_message_and_edit(self): """ When creating a new message, a initial content can be passed via @@ -99,18 +124,15 @@ def test_message_and_edit(self): text editor. """ content_line = "This is a content line" - edit_content = ["This is line 1\n", "This is line 2"] + edit_content = ["This is line 1", "This is line 2"] with mock.patch("click.edit") as mock_edit: - mock_edit.return_value = "".join(edit_content) + mock_edit.return_value = "\n".join(edit_content) self._test_success( content=edit_content, additional_args=["-c", content_line, "--edit"] ) mock_edit.assert_called_once_with( - "# Please write your news content. When finished, save the file.\n" - "# In order to abort, exit without saving.\n" - '# Lines starting with "#" are ignored.\n' - "\n" - "{content_line}\n".format(content_line=content_line) + f"{content_line}\n\n# Please write your news content. Lines starting " + "with '#' will be ignored, and\n# an empty message aborts.\n" ) def test_different_directory(self): @@ -151,12 +173,81 @@ def test_invalid_section(self): "Expected filename '123.foobar.rst' to be of format", result.output ) + @with_isolated_runner + def test_custom_extension(self, runner: CliRunner): + """Ensure we can still create fragments with custom extensions.""" + setup_simple_project() + frag_path = Path("foo", "newsfragments") + + result = runner.invoke(_main, ["123.feature.txt"]) + self.assertEqual(result.exit_code, 0, result.output) + + fragments = [f.name for f in frag_path.iterdir()] + # No '.rst' extension added. + self.assertEqual(fragments, ["123.feature.txt"]) + + @with_isolated_runner + def test_md_filename_extension(self, runner: CliRunner): + """Ensure changelog filename extension is used if .md""" + setup_simple_project(extra_config='filename = "changes.md"') + frag_path = Path("foo", "newsfragments") + + result = runner.invoke(_main, ["123.feature"]) + self.assertEqual(result.exit_code, 0, result.output) + + fragments = [f.name for f in frag_path.iterdir()] + # No '.rst' extension added. + self.assertEqual(fragments, ["123.feature.md"]) + + @with_isolated_runner + def test_no_filename_extension(self, runner: CliRunner): + """ + When the NEWS filename has no extension, new fragments are will not have an + extension added. + """ + # The name of the file where towncrier will generate + # the final release notes is named `RELEASE_NOTES` + # for this test (with no file extension). + setup_simple_project(extra_config='filename = "RELEASE_NOTES"') + frag_path = Path("foo", "newsfragments") + + result = runner.invoke(_main, ["123.feature"]) + self.assertEqual(result.exit_code, 0, result.output) + + fragments = [f.name for f in frag_path.iterdir()] + # No '.rst' extension added. + self.assertEqual(fragments, ["123.feature"]) + @with_isolated_runner def test_file_exists(self, runner: CliRunner): """Ensure we don't overwrite existing files.""" setup_simple_project() frag_path = Path("foo", "newsfragments") + for _ in range(3): + result = runner.invoke(_main, ["123.feature"]) + self.assertEqual(result.exit_code, 0, result.output) + + fragments = [f.name for f in frag_path.iterdir()] + self.assertEqual( + sorted(fragments), + [ + "123.feature.1.rst", + "123.feature.2.rst", + "123.feature.rst", + ], + ) + + @with_isolated_runner + def test_file_exists_no_ext(self, runner: CliRunner): + """ + Ensure we don't overwrite existing files with when not adding filename + extensions. + """ + + setup_simple_project(extra_config="create_add_extension = false") + frag_path = Path("foo", "newsfragments") + for _ in range(3): result = runner.invoke(_main, ["123.feature"]) self.assertEqual(result.exit_code, 0, result.output) @@ -194,6 +285,107 @@ def test_file_exists_with_ext(self, runner: CliRunner): ], ) + @with_isolated_runner + def test_without_filename(self, runner: CliRunner): + """ + When no filename is provided, the user is prompted for one. + """ + setup_simple_project() + + with mock.patch("click.edit") as mock_edit: + mock_edit.return_value = "Edited content" + result = runner.invoke(_main, input="123\nfeature\n") + self.assertFalse(result.exception, result.output) + mock_edit.assert_called_once() + expected = os.path.join(os.getcwd(), "foo", "newsfragments", "123.feature.rst") + self.assertEqual( + result.output, + f"""Issue number (`+` if none): 123 +Fragment type (feature, bugfix, doc, removal, misc): feature +Created news fragment at {expected} +""", + ) + with open(expected) as f: + self.assertEqual(f.read(), "Edited content\n") + + @with_isolated_runner + def test_without_filename_orphan(self, runner: CliRunner): + """ + The user can create an orphan fragment from the interactive prompt. + """ + setup_simple_project() + + with mock.patch("click.edit") as mock_edit: + mock_edit.return_value = "Orphan content" + result = runner.invoke(_main, input="+\nfeature\n") + self.assertFalse(result.exception, result.output) + mock_edit.assert_called_once() + expected = os.path.join(os.getcwd(), "foo", "newsfragments", "+") + self.assertTrue( + result.output.startswith( + f"""Issue number (`+` if none): + +Fragment type (feature, bugfix, doc, removal, misc): feature +Created news fragment at {expected}""" + ), + result.output, + ) + # Check that the file was created with a random name + created_line = result.output.strip().rsplit("\n", 1)[-1] + # Get file names in the newsfragments directory. + files = os.listdir(os.path.join(os.getcwd(), "foo", "newsfragments")) + # Check that the file name is in the created line. + created_fragment = created_line.split(" ")[-1] + self.assertIn(Path(created_fragment).name, files) + with open(created_fragment) as f: + self.assertEqual(f.read(), "Orphan content\n") + + @with_isolated_runner + def test_without_filename_no_orphan_config(self, runner: CliRunner): + """ + If an empty orphan prefix is set, orphan creation is turned off from interactive + prompt. + """ + setup_simple_project(extra_config='orphan_prefix = ""') + + with mock.patch("click.edit") as mock_edit: + mock_edit.return_value = "Edited content" + result = runner.invoke(_main, input="+\nfeature\n") + self.assertFalse(result.exception, result.output) + mock_edit.assert_called_once() + expected = os.path.join(os.getcwd(), "foo", "newsfragments", "+.feature.rst") + self.assertEqual( + result.output, + f"""Issue number: + +Fragment type (feature, bugfix, doc, removal, misc): feature +Created news fragment at {expected} +""", + ) + with open(expected) as f: + self.assertEqual(f.read(), "Edited content\n") + + @with_isolated_runner + def test_without_filename_with_message(self, runner: CliRunner): + """ + When no filename is provided, the user is prompted for one. If a message is + provided, the editor isn't opened and the message is used. + """ + setup_simple_project() + + with mock.patch("click.edit") as mock_edit: + result = runner.invoke(_main, ["-c", "Fixed this"], input="123\nfeature\n") + self.assertFalse(result.exception, result.output) + mock_edit.assert_not_called() + expected = os.path.join(os.getcwd(), "foo", "newsfragments", "123.feature.rst") + self.assertEqual( + result.output, + f"""Issue number (`+` if none): 123 +Fragment type (feature, bugfix, doc, removal, misc): feature +Created news fragment at {expected} +""", + ) + with open(expected) as f: + self.assertEqual(f.read(), "Fixed this\n") + @with_isolated_runner def test_create_orphan_fragment(self, runner: CliRunner): """ @@ -218,16 +410,18 @@ def test_create_orphan_fragment(self, runner: CliRunner): self.assertEqual(2, len(fragments)) change1, change2 = fragments - self.assertEqual(change1.suffix, ".feature") + self.assertEqual(change1.suffix, ".rst") self.assertTrue(change1.stem.startswith("+")) - # Length should be '+' character and 8 random hex characters. - self.assertEqual(len(change1.stem), 9) + self.assertTrue(change1.stem.endswith(".feature")) + # Length should be '+' character, 8 random hex characters, and ".feature". + self.assertEqual(len(change1.stem), 1 + 8 + len(".feature")) - self.assertEqual(change2.suffix, ".feature") + self.assertEqual(change2.suffix, ".rst") self.assertTrue(change2.stem.startswith("+")) + self.assertTrue(change2.stem.endswith(".feature")) self.assertEqual(change2.parent, sub_frag_path) - # Length should be '+' character and 8 random hex characters. - self.assertEqual(len(change2.stem), 9) + # Length should be '+' character, 8 random hex characters, and ".feature". + self.assertEqual(len(change2.stem), 1 + 8 + len(".feature")) @with_isolated_runner def test_create_orphan_fragment_custom_prefix(self, runner: CliRunner): @@ -245,10 +439,12 @@ def test_create_orphan_fragment_custom_prefix(self, runner: CliRunner): self.assertEqual(len(fragments), 1) change = fragments[0] self.assertTrue(change.stem.startswith("$$$")) - # Length should be '$$$' characters and 8 random hex characters. - self.assertEqual(len(change.stem), 11) + # Length should be '$$$' characters, 8 random hex characters, and ".feature". + self.assertEqual(len(change.stem), 3 + 8 + len(".feature")) # Check the remainder are all hex characters. - self.assertTrue(all(c in string.hexdigits for c in change.stem[3:])) + self.assertTrue( + all(c in string.hexdigits for c in change.stem[3 : -len(".feature")]) + ) @with_isolated_runner def test_in_different_dir_with_nondefault_newsfragments_directory(self, runner): @@ -282,4 +478,4 @@ def test_in_different_dir_with_nondefault_newsfragments_directory(self, runner): ) self.assertEqual(0, result.exit_code) - self.assertTrue(Path("foo/changelog.d/123.feature").exists()) + self.assertTrue(Path("foo/changelog.d/123.feature.rst").exists()) From 7221d5d68335393ed2bf8c34be230c734c6d621b Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sun, 28 Apr 2024 12:50:10 -0400 Subject: [PATCH 33/51] Add explicit encoding to read_text. (#577) * Add explicit encoding to read_text. Closes #561 * Add explicit encoding in towncrier.create. * Add note about UTF-8 encoding to tutorial. * Expand the note to mention the fragments, the news file, the config file, and templates. * Use 'utf-8' throughout for consistency. --------- Co-authored-by: Adi Roiban --- docs/tutorial.rst | 2 ++ src/towncrier/_builder.py | 2 +- src/towncrier/_writer.py | 4 ++-- src/towncrier/build.py | 4 +++- src/towncrier/create.py | 2 +- src/towncrier/newsfragments/561.bugfix.rst | 1 + src/towncrier/test/helpers.py | 2 +- 7 files changed, 11 insertions(+), 6 deletions(-) create mode 100644 src/towncrier/newsfragments/561.bugfix.rst diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 75f99cf0..7138cd9e 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -146,6 +146,8 @@ You should get an output similar to this:: 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. +Note: all files (news fragments, the news file, the configuration file, and templates) are encoded and are expected to be encoded as UTF-8. + Producing News Files In Production ---------------------------------- diff --git a/src/towncrier/_builder.py b/src/towncrier/_builder.py index a72a3982..fefdf838 100644 --- a/src/towncrier/_builder.py +++ b/src/towncrier/_builder.py @@ -112,7 +112,7 @@ def find_fragments( full_filename = os.path.join(section_dir, basename) fragment_filenames.append(full_filename) with open(full_filename, "rb") as f: - data = f.read().decode("utf8", "replace") + data = f.read().decode("utf-8", "replace") if (ticket, category, counter) in file_content: raise ValueError( diff --git a/src/towncrier/_writer.py b/src/towncrier/_writer.py index 4145e3d4..6bbc5bfa 100644 --- a/src/towncrier/_writer.py +++ b/src/towncrier/_writer.py @@ -40,7 +40,7 @@ def append_to_newsfile( # Leave newlines alone. This probably leads to inconsistent newlines, # because we've loaded existing content with universal newlines, but that's # the original behavior. - with news_file.open("w", encoding="utf8", newline="") as f: + with news_file.open("w", encoding="utf-8", newline="") as f: if header: f.write(header) # If there is no previous body that means we're writing a brand new news file. @@ -66,7 +66,7 @@ def _figure_out_existing_content( # If we didn't use universal newlines here, we wouldn't find *start_string* # which usually contains a `\n`. - with news_file.open(encoding="utf8") as f: + with news_file.open(encoding="utf-8") as f: content = f.read() t = content.split(start_string, 1) diff --git a/src/towncrier/build.py b/src/towncrier/build.py index 3a07bae1..f8e4175e 100644 --- a/src/towncrier/build.py +++ b/src/towncrier/build.py @@ -166,7 +166,9 @@ def __main( click.echo("Loading template...", err=to_err) if isinstance(config.template, tuple): template = ( - resources.files(config.template[0]).joinpath(config.template[1]).read_text() + resources.files(config.template[0]) + .joinpath(config.template[1]) + .read_text(encoding="utf-8") ) else: with open(config.template, encoding="utf-8") as tmpl: diff --git a/src/towncrier/create.py b/src/towncrier/create.py index 362b2ba9..2d2ec65b 100644 --- a/src/towncrier/create.py +++ b/src/towncrier/create.py @@ -170,7 +170,7 @@ def __main( click.echo("Aborted creating news fragment due to empty message.") ctx.exit(1) - with open(segment_file, "w") as f: + with open(segment_file, "w", encoding="utf-8") as f: f.write(content) if config.create_eof_newline and content and not content.endswith("\n"): f.write("\n") diff --git a/src/towncrier/newsfragments/561.bugfix.rst b/src/towncrier/newsfragments/561.bugfix.rst new file mode 100644 index 00000000..9e0d382e --- /dev/null +++ b/src/towncrier/newsfragments/561.bugfix.rst @@ -0,0 +1 @@ +Add explicit encoding to read_text. diff --git a/src/towncrier/test/helpers.py b/src/towncrier/test/helpers.py index c3bd9604..77cea36d 100644 --- a/src/towncrier/test/helpers.py +++ b/src/towncrier/test/helpers.py @@ -36,7 +36,7 @@ def read_pkg_resource(path: str) -> str: """ Read *path* from the towncrier package. """ - return (resources.files("towncrier") / path).read_text("utf8") + return (resources.files("towncrier") / path).read_text("utf-8") def with_isolated_runner(fn: Callable[..., Any]) -> Callable[..., Any]: From db10a4d872450eac0441a8f873c2e0c0eba78889 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Mon, 29 Apr 2024 04:20:37 -0400 Subject: [PATCH 34/51] Prefer pathlib for read/write operations (#591) * Rely on pathlib when reading and writing simple text. * Universal newlines is the default, so remove the comment. * Add news fragment. * Add compatibility shim for Python 3.9 and earlier. * Suppress coverage check for line 20 as it emits a spurious failure. * Only exclude branch coverage (regular coverage is fine) Co-authored-by: Adi Roiban * Add comment explaining motivation for the compatibility shim. --------- Co-authored-by: Adi Roiban --- src/towncrier/_builder.py | 4 +-- src/towncrier/_writer.py | 36 ++++++++++++++++-------- src/towncrier/build.py | 5 ++-- src/towncrier/create.py | 10 ++++--- src/towncrier/newsfragments/591.misc.rst | 1 + 5 files changed, 36 insertions(+), 20 deletions(-) create mode 100644 src/towncrier/newsfragments/591.misc.rst diff --git a/src/towncrier/_builder.py b/src/towncrier/_builder.py index fefdf838..3a4591de 100644 --- a/src/towncrier/_builder.py +++ b/src/towncrier/_builder.py @@ -8,6 +8,7 @@ import textwrap from collections import defaultdict +from pathlib import Path from typing import Any, DefaultDict, Iterable, Iterator, Mapping, Sequence from jinja2 import Template @@ -111,8 +112,7 @@ def find_fragments( full_filename = os.path.join(section_dir, basename) fragment_filenames.append(full_filename) - with open(full_filename, "rb") as f: - data = f.read().decode("utf-8", "replace") + data = Path(full_filename).read_text(encoding="utf-8", errors="replace") if (ticket, category, counter) in file_content: raise ValueError( diff --git a/src/towncrier/_writer.py b/src/towncrier/_writer.py index 6bbc5bfa..634b5f19 100644 --- a/src/towncrier/_writer.py +++ b/src/towncrier/_writer.py @@ -8,7 +8,22 @@ from __future__ import annotations +import sys + from pathlib import Path +from typing import Any + + +if sys.version_info < (3, 10): + # Compatibility shim for newline parameter to write_text, added in 3.10 + def _newline_write_text(path: Path, content: str, **kwargs: Any) -> None: + with path.open("w", **kwargs) as strm: # pragma: no branch + strm.write(content) + +else: + + def _newline_write_text(path: Path, content: str, **kwargs: Any) -> None: + path.write_text(content, **kwargs) def append_to_newsfile( @@ -37,15 +52,17 @@ def append_to_newsfile( if top_line and top_line in prev_body: raise ValueError("It seems you've already produced newsfiles for this version?") - # Leave newlines alone. This probably leads to inconsistent newlines, - # because we've loaded existing content with universal newlines, but that's - # the original behavior. - with news_file.open("w", encoding="utf-8", newline="") as f: - if header: - f.write(header) + _newline_write_text( + news_file, # If there is no previous body that means we're writing a brand new news file. # We don't want extra whitespace at the end of this new file. - f.write(content + prev_body if prev_body else content.rstrip() + "\n") + header + (content + prev_body if prev_body else content.rstrip() + "\n"), + encoding="utf-8", + # Leave newlines alone. This probably leads to inconsistent newlines, + # because we've loaded existing content with universal newlines, but that's + # the original behavior. + newline="", + ) def _figure_out_existing_content( @@ -64,10 +81,7 @@ def _figure_out_existing_content( # Non-existent files have no existing content. return "", "" - # If we didn't use universal newlines here, we wouldn't find *start_string* - # which usually contains a `\n`. - with news_file.open(encoding="utf-8") as f: - content = f.read() + content = Path(news_file).read_text(encoding="utf-8") t = content.split(start_string, 1) if len(t) == 2: diff --git a/src/towncrier/build.py b/src/towncrier/build.py index f8e4175e..b28606c9 100644 --- a/src/towncrier/build.py +++ b/src/towncrier/build.py @@ -5,13 +5,13 @@ Build a combined news file from news fragments. """ - from __future__ import annotations import os import sys from datetime import date +from pathlib import Path import click @@ -171,8 +171,7 @@ def __main( .read_text(encoding="utf-8") ) else: - with open(config.template, encoding="utf-8") as tmpl: - template = tmpl.read() + template = Path(config.template).read_text(encoding="utf-8") click.echo("Finding news fragments...", err=to_err) diff --git a/src/towncrier/create.py b/src/towncrier/create.py index 2d2ec65b..28903f58 100644 --- a/src/towncrier/create.py +++ b/src/towncrier/create.py @@ -9,6 +9,8 @@ import os +from pathlib import Path + import click from ._settings import config_option_help, load_config_from_options @@ -170,10 +172,10 @@ def __main( click.echo("Aborted creating news fragment due to empty message.") ctx.exit(1) - with open(segment_file, "w", encoding="utf-8") as f: - f.write(content) - if config.create_eof_newline and content and not content.endswith("\n"): - f.write("\n") + add_newline = bool( + config.create_eof_newline and content and not content.endswith("\n") + ) + Path(segment_file).write_text(content + "\n" * add_newline, encoding="utf-8") click.echo(f"Created news fragment at {segment_file}") diff --git a/src/towncrier/newsfragments/591.misc.rst b/src/towncrier/newsfragments/591.misc.rst new file mode 100644 index 00000000..10f5e3a7 --- /dev/null +++ b/src/towncrier/newsfragments/591.misc.rst @@ -0,0 +1 @@ +Leveraged pathlib in most file operations. From 3427a1926ac79e138e0ca39fb300e0d18206bcb9 Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Tue, 7 May 2024 11:12:25 +1200 Subject: [PATCH 35/51] Remove github edit link in non-trunk versions of docs (#596) * Remove github edit link in non-trunk versions of docs * Rename newsfragment --- docs/conf.py | 6 +++++- pyproject.toml | 2 +- src/towncrier/newsfragments/596.misc.rst | 1 + 3 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 src/towncrier/newsfragments/596.misc.rst diff --git a/docs/conf.py b/docs/conf.py index ad06fee2..7196f550 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -29,6 +29,8 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. +import os + from datetime import date from towncrier import __version__ as towncrier_version @@ -90,7 +92,9 @@ # further. For a list of options available for each theme, see the # documentation. # -# html_theme_options = {} +if os.environ.get("READTHEDOCS_VERSION_NAME", "trunk") != "trunk": + # Remove the "Edit on GitHub" link for non-trunk versions of the docs + html_theme_options = {"top_of_page_buttons": []} # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, diff --git a/pyproject.toml b/pyproject.toml index 236e09cb..245e7dd4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ dependencies = [ dev = [ "packaging", "sphinx >= 5", - "furo", + "furo >= 2024.05.06", "twisted", "nox", ] diff --git a/src/towncrier/newsfragments/596.misc.rst b/src/towncrier/newsfragments/596.misc.rst new file mode 100644 index 00000000..d1b07d7b --- /dev/null +++ b/src/towncrier/newsfragments/596.misc.rst @@ -0,0 +1 @@ +Remove github edit link in non-trunk versions of docs From 9fe485d64297ba4fa3b59acfd1840db23a41e02a Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Tue, 7 May 2024 11:16:50 +1200 Subject: [PATCH 36/51] Edit-extension (#594) * Refactor _get_news_content_from_user to accept optional extension parameter * Document change * Update fragment name --------- Co-authored-by: Adi Roiban --- src/towncrier/create.py | 6 +-- src/towncrier/newsfragments/594.feature.rst | 1 + src/towncrier/test/test_create.py | 56 ++++++++++++++++++++- 3 files changed, 58 insertions(+), 5 deletions(-) create mode 100644 src/towncrier/newsfragments/594.feature.rst diff --git a/src/towncrier/create.py b/src/towncrier/create.py index 28903f58..77433fca 100644 --- a/src/towncrier/create.py +++ b/src/towncrier/create.py @@ -167,7 +167,7 @@ def __main( if edit: if content == DEFAULT_CONTENT: content = "" - content = _get_news_content_from_user(content) + content = _get_news_content_from_user(content, extension=filename_ext) if not content: click.echo("Aborted creating news fragment due to empty message.") ctx.exit(1) @@ -180,14 +180,14 @@ def __main( click.echo(f"Created news fragment at {segment_file}") -def _get_news_content_from_user(message: str) -> str: +def _get_news_content_from_user(message: str, extension: str = "") -> str: initial_content = """ # Please write your news content. Lines starting with '#' will be ignored, and # an empty message aborts. """ if message: initial_content = f"{message}\n{initial_content}" - content = click.edit(initial_content) + content = click.edit(initial_content, extension=extension or ".txt") if content is None: return message all_lines = content.split("\n") diff --git a/src/towncrier/newsfragments/594.feature.rst b/src/towncrier/newsfragments/594.feature.rst new file mode 100644 index 00000000..0f8d1296 --- /dev/null +++ b/src/towncrier/newsfragments/594.feature.rst @@ -0,0 +1 @@ +The temporary file ``towncrier create`` creates now uses the correct ``.rst`` or ``.md`` extension, which may help your editor with with syntax highlighting. diff --git a/src/towncrier/test/test_create.py b/src/towncrier/test/test_create.py index e946f201..2ba74af9 100644 --- a/src/towncrier/test/test_create.py +++ b/src/towncrier/test/test_create.py @@ -63,7 +63,8 @@ def test_edit_without_comments(self): self._test_success(content=content, additional_args=["--edit"]) mock_edit.assert_called_once_with( "\n# Please write your news content. Lines starting " - "with '#' will be ignored, and\n# an empty message aborts.\n" + "with '#' will be ignored, and\n# an empty message aborts.\n", + extension=".rst", ) def test_edit_with_comment(self): @@ -87,6 +88,56 @@ def test_edit_abort(self): self.assertEqual([], os.listdir("foo/newsfragments")) self.assertEqual(1, result.exit_code) + def test_edit_markdown_extension(self): + """ + The temporary file extension used when editing is ``.md`` if the main filename + also uses that extension. + """ + + with mock.patch("click.edit") as mock_edit: + mock_edit.return_value = "This is line 1" + self._test_success( + content=["This is line 1"], + config=dedent( + """\ + [tool.towncrier] + package = "foo" + filename = "README.md" + """ + ), + additional_args=["--edit"], + ) + mock_edit.assert_called_once_with( + "\n# Please write your news content. Lines starting " + "with '#' will be ignored, and\n# an empty message aborts.\n", + extension=".md", + ) + + def test_edit_unknown_extension(self): + """ + The temporary file extension used when editing is ``.txt`` if it the main + filename isn't ``.rst`` or ``.md``. + """ + + with mock.patch("click.edit") as mock_edit: + mock_edit.return_value = "This is line 1" + self._test_success( + content=["This is line 1"], + config=dedent( + """\ + [tool.towncrier] + package = "foo" + filename = "README.FIRST" + """ + ), + additional_args=["--edit"], + ) + mock_edit.assert_called_once_with( + "\n# Please write your news content. Lines starting " + "with '#' will be ignored, and\n# an empty message aborts.\n", + extension=".txt", + ) + def test_content(self): """ When creating a new fragment the content can be passed as a @@ -132,7 +183,8 @@ def test_message_and_edit(self): ) mock_edit.assert_called_once_with( f"{content_line}\n\n# Please write your news content. Lines starting " - "with '#' will be ignored, and\n# an empty message aborts.\n" + "with '#' will be ignored, and\n# an empty message aborts.\n", + extension=".rst", ) def test_different_directory(self): From 3d9b961bba685a941f60d41a2142cdcbcf781e79 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 7 May 2024 00:20:36 +0100 Subject: [PATCH 37/51] [pre-commit.ci] pre-commit autoupdate (#595) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 24.3.0 → 24.4.2](https://github.com/psf/black/compare/24.3.0...24.4.2) - [github.com/pre-commit/pre-commit-hooks: v4.5.0 → v4.6.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.5.0...v4.6.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Adi Roiban --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5e07b6be..5e6a6b43 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ ci: repos: - repo: https://github.com/psf/black - rev: 24.3.0 + rev: 24.4.2 hooks: - id: black @@ -26,7 +26,7 @@ repos: - id: flake8 - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v4.6.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer From 272993f1f4323db8f96ba67926781d753f207ba7 Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Tue, 7 May 2024 20:18:16 +1200 Subject: [PATCH 38/51] Fix readthedocs to keep the edit link for `latest` in addition to `trunk` (#597) * Fix readthedocs to keep the edit link for `latest` in addition to `trunk` * Rename news fragment --- docs/conf.py | 2 +- src/towncrier/newsfragments/597.misc.rst | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 src/towncrier/newsfragments/597.misc.rst diff --git a/docs/conf.py b/docs/conf.py index 7196f550..5f41b13d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -92,7 +92,7 @@ # further. For a list of options available for each theme, see the # documentation. # -if os.environ.get("READTHEDOCS_VERSION_NAME", "trunk") != "trunk": +if os.environ.get("READTHEDOCS_VERSION_NAME", "trunk") not in ("trunk", "latest"): # Remove the "Edit on GitHub" link for non-trunk versions of the docs html_theme_options = {"top_of_page_buttons": []} diff --git a/src/towncrier/newsfragments/597.misc.rst b/src/towncrier/newsfragments/597.misc.rst new file mode 100644 index 00000000..e7c7562c --- /dev/null +++ b/src/towncrier/newsfragments/597.misc.rst @@ -0,0 +1 @@ +Fix readthedocs to keep the edit link for ``latest`` in addition to ``trunk``. From 2bde0779c19d4b5e195e6c6702e26f7014458721 Mon Sep 17 00:00:00 2001 From: Adi Roiban Date: Tue, 21 May 2024 13:48:44 +0100 Subject: [PATCH 39/51] #571 Use pre-commit dog food and update release process for pre-commit autoupdate. (#572) * Use pre-commit dog food. * Run pre-commit autoupdate. * Update .github/workflows/ci.yml * Better name. Also run auto-update. * Add news fragment. * Update black. * Update pre-commit hooks. * Use latets towncrier. * Use latest pre-commit-hooks. * Use immutable versions * Add info about release process. * Apply fix from Sadik. * Update .github/workflows/ci.yml Co-authored-by: Chris Beaven --------- Co-authored-by: Hynek Schlawack Co-authored-by: Chris Beaven --- .github/workflows/ci.yml | 31 ++++++++++++++++++++++++++++ .pre-commit-config.yaml | 5 +++++ RELEASE.rst | 9 +++++++- docs/pre-commit.rst | 4 ++-- src/towncrier/newsfragments/571.misc | 0 5 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 src/towncrier/newsfragments/571.misc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08871404..850af7aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -195,6 +195,36 @@ jobs: if: ${{ matrix.task.run-if }} + pre-commit: + name: Check pre-commit integration + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up python + uses: actions/setup-python@v5 + with: + python-version: 3.12 + + - name: Install dependencies + run: python -m pip install pre-commit + + - name: Install pre-commit + run: | + pre-commit install + + - name: Update pre-commit + run: | + pre-commit autoupdate + + - name: Run pre-commit + run: | + pre-commit run -a + + pypi-publish: name: Check tag and publish # Only trigger this for tag changes. @@ -292,6 +322,7 @@ jobs: - test-windows - coverage - check + - pre-commit steps: - name: Require all successes uses: re-actors/alls-green@3a2de129f0713010a71314c74e33c0e3ef90e696 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5e6a6b43..a96ca41a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -33,3 +33,8 @@ repos: - id: debug-statements - id: check-toml - id: check-yaml + + - repo: https://github.com/twisted/towncrier + rev: 23.11.0 + hooks: + - id: towncrier-check diff --git a/RELEASE.rst b/RELEASE.rst index eaeca382..8b457aa7 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -102,7 +102,14 @@ In ``src/towncrier/_version.py`` the version is set using ``incremental`` such a Commit and push the changes. -Merge the commit in the main branch. +Merge the commit in the main branch, **without using squash**. + +We tag the release based on a commit from the release branch. +If we merge with squash, +the release tag commit will no longer be found in the main branch history. +With a squash merge, the whole branch history is lost. +This causes the `pre-commit autoupdate` to fail. +See `PR590 `_ for more details. You can announce the release over IRC or Gitter. diff --git a/docs/pre-commit.rst b/docs/pre-commit.rst index 452bd155..ebf04b54 100644 --- a/docs/pre-commit.rst +++ b/docs/pre-commit.rst @@ -16,7 +16,7 @@ Usage with the default configuration repos: - repo: https://github.com/twisted/towncrier - rev: 22.13.0 # run 'pre-commit autoupdate' to update + rev: 23.11.0 # run 'pre-commit autoupdate' to update hooks: - id: towncrier-check @@ -30,7 +30,7 @@ News fragments are stored in ``changelog.d/`` in the root of the repository and repos: - repo: https://github.com/twisted/towncrier - rev: 22.13.0 # run 'pre-commit autoupdate' to update + rev: 23.11.0 # run 'pre-commit autoupdate' to update hooks: - id: towncrier-update files: $changelog\.d/ diff --git a/src/towncrier/newsfragments/571.misc b/src/towncrier/newsfragments/571.misc new file mode 100644 index 00000000..e69de29b From 6d960106bfe35d156f4a52249693fad303da33ac Mon Sep 17 00:00:00 2001 From: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> Date: Thu, 23 May 2024 02:34:32 +0530 Subject: [PATCH 40/51] #432 Use `importlib.metadata.version` to get the version (#502) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Adi Roiban Co-authored-by: Adi Roiban Co-authored-by: Chris Beaven --- pyproject.toml | 1 + src/towncrier/_project.py | 38 +++++++++++++++++++-- src/towncrier/newsfragments/432.feature.rst | 1 + src/towncrier/test/test_project.py | 29 +++++++++++----- 4 files changed, 58 insertions(+), 11 deletions(-) create mode 100644 src/towncrier/newsfragments/432.feature.rst diff --git a/pyproject.toml b/pyproject.toml index 245e7dd4..d6f8fa6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ requires-python = ">=3.8" dependencies = [ "click", "importlib-resources>=5; python_version<'3.10'", + "importlib-metadata>=4.6; python_version<'3.10'", "incremental", "jinja2", "tomli; python_version<'3.11'", diff --git a/src/towncrier/_project.py b/src/towncrier/_project.py index cfaa11fa..53e72e95 100644 --- a/src/towncrier/_project.py +++ b/src/towncrier/_project.py @@ -5,17 +5,24 @@ Responsible for getting the version and name from a project. """ - from __future__ import annotations import sys from importlib import import_module +from importlib.metadata import version as metadata_version from types import ModuleType +from typing import Any from incremental import Version as IncrementalVersion +if sys.version_info >= (3, 10): + from importlib.metadata import packages_distributions +else: + from importlib_metadata import packages_distributions # type: ignore + + def _get_package(package_dir: str, package: str) -> ModuleType: try: module = import_module(package) @@ -37,13 +44,40 @@ def _get_package(package_dir: str, package: str) -> ModuleType: return module +def _get_metadata_version(package: str) -> str | None: + """ + Try to get the version from the package metadata. + """ + distributions = packages_distributions() + distribution_names = distributions.get(package) + if not distribution_names or len(distribution_names) != 1: + # We can only determine the version if there is exactly one matching distribution. + return None + return metadata_version(distribution_names[0]) + + def get_version(package_dir: str, package: str) -> str: + """ + Get the version of a package. + + Try to extract the version from the distribution version metadata that matches + `package`, then fall back to looking for the package in `package_dir`. + """ + version: Any + + # First try to get the version from the package metadata. + if version := _get_metadata_version(package): + return version + + # When no version if found, fall back to looking for the package in `package_dir`. module = _get_package(package_dir, package) version = getattr(module, "__version__", None) if not version: - raise Exception("No __version__, I don't know how else to look") + raise Exception( + f"No __version__ or metadata version info for the '{package}' package." + ) if isinstance(version, str): return version.strip() diff --git a/src/towncrier/newsfragments/432.feature.rst b/src/towncrier/newsfragments/432.feature.rst new file mode 100644 index 00000000..94f19ea4 --- /dev/null +++ b/src/towncrier/newsfragments/432.feature.rst @@ -0,0 +1 @@ +Inferring the version of a Python package now tries to use the metadata of the installed package before importing the package explicitly (which only looks for ``[package].__version__``). diff --git a/src/towncrier/test/test_project.py b/src/towncrier/test/test_project.py index 45875683..62ec3d77 100644 --- a/src/towncrier/test/test_project.py +++ b/src/towncrier/test/test_project.py @@ -4,7 +4,8 @@ import os import sys -from unittest import skipIf +from importlib.metadata import version as metadata_version +from unittest import mock from click.testing import CliRunner from twisted.trial.unittest import TestCase @@ -14,12 +15,6 @@ from .helpers import write -try: - from importlib.metadata import version as metadata_version -except ImportError: - metadata_version = None - - towncrier_cli.name = "towncrier" @@ -50,7 +45,6 @@ def test_tuple(self): version = get_version(temp, "mytestproja") self.assertEqual(version, "1.3.12") - @skipIf(metadata_version is None, "Needs importlib.metadata.") def test_incremental(self): """ An incremental Version __version__ is picked up. @@ -60,6 +54,14 @@ def test_incremental(self): with self.assertWarnsRegex( DeprecationWarning, "Accessing towncrier.__version__ is deprecated.*" ): + # Previously this triggered towncrier.__version__ but now the first version + # check is from the package metadata. Let's mock out that part to ensure we + # can get incremental versions from __version__ still. + with mock.patch( + "towncrier._project._get_metadata_version", return_value=None + ): + version = get_version(pkg, "towncrier") + version = get_version(pkg, "towncrier") with self.assertWarnsRegex( @@ -70,6 +72,13 @@ def test_incremental(self): self.assertEqual(metadata_version("towncrier"), version) self.assertEqual("towncrier", name) + def test_version_from_metadata(self): + """ + A version from package metadata is picked up. + """ + version = get_version(".", "towncrier") + self.assertEqual(metadata_version("towncrier"), version) + def _setup_missing(self): """ Create a minimalistic project with missing metadata in a temporary @@ -91,10 +100,12 @@ def test_missing_version(self): tmp_dir = self._setup_missing() with self.assertRaises(Exception) as e: + # The 'missing' package has no __version__ string. get_version(tmp_dir, "missing") self.assertEqual( - ("No __version__, I don't know how else to look",), e.exception.args + ("No __version__ or metadata version info for the 'missing' package.",), + e.exception.args, ) def test_missing_version_project_name(self): From 1c3b87bb44fc3fda3675d0f987ad613659a5d16d Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Thu, 23 May 2024 11:07:09 +1200 Subject: [PATCH 41/51] Allow `towncrier` to traverse back up directories looking for the configuration file (#601) --- src/towncrier/_settings/load.py | 49 ++++++++++++++++----- src/towncrier/newsfragments/601.feature.rst | 1 + src/towncrier/test/test_build.py | 12 ++++- src/towncrier/test/test_settings.py | 17 ++++++- 4 files changed, 66 insertions(+), 13 deletions(-) create mode 100644 src/towncrier/newsfragments/601.feature.rst diff --git a/src/towncrier/_settings/load.py b/src/towncrier/_settings/load.py index 66a6c546..724a7768 100644 --- a/src/towncrier/_settings/load.py +++ b/src/towncrier/_settings/load.py @@ -65,26 +65,53 @@ def __init__(self, *args: str, **kwargs: str): def load_config_from_options( directory: str | None, config_path: str | None ) -> tuple[str, Config]: + """ + Load the configuration from a given directory or specific configuration file. + + Unless an explicit configuration file is given, traverse back from the given + directory looking for a configuration file. + + Returns a tuple of the base directory and the parsed Config instance. + """ if config_path is None: - if directory is None: - directory = os.getcwd() + return traverse_for_config(directory) + + config_path = os.path.abspath(config_path) + # When a directory is provided (in addition to the config file), use it as the base + # directory. Otherwise use the directory containing the config file. + if directory is not None: base_directory = os.path.abspath(directory) - config = load_config(base_directory) else: - config_path = os.path.abspath(config_path) - if directory is None: - base_directory = os.path.dirname(config_path) - else: - base_directory = os.path.abspath(directory) - config = load_config_from_file(os.path.dirname(config_path), config_path) + base_directory = os.path.dirname(config_path) - if config is None: - raise ConfigError(f"No configuration file found.\nLooked in: {base_directory}") + if not os.path.isfile(config_path): + raise ConfigError(f"Configuration file '{config_path}' not found.") + config = load_config_from_file(base_directory, config_path) return base_directory, config +def traverse_for_config(path: str | None) -> tuple[str, Config]: + """ + Search for a configuration file in the current directory and all parent directories. + + Returns the directory containing the configuration file and the parsed configuration. + """ + start_directory = directory = os.path.abspath(path or os.getcwd()) + while True: + config = load_config(directory) + if config is not None: + return directory, config + + parent = os.path.dirname(directory) + if parent == directory: + raise ConfigError( + f"No configuration file found.\nLooked back from: {start_directory}" + ) + directory = parent + + def load_config(directory: str) -> Config | None: towncrier_toml = os.path.join(directory, "towncrier.toml") pyproject_toml = os.path.join(directory, "pyproject.toml") diff --git a/src/towncrier/newsfragments/601.feature.rst b/src/towncrier/newsfragments/601.feature.rst new file mode 100644 index 00000000..ca209ca5 --- /dev/null +++ b/src/towncrier/newsfragments/601.feature.rst @@ -0,0 +1 @@ +Running ``towncrier`` will now traverse back up directories looking for the configuration file. diff --git a/src/towncrier/test/test_build.py b/src/towncrier/test/test_build.py index 241425e9..17ffad49 100644 --- a/src/towncrier/test/test_build.py +++ b/src/towncrier/test/test_build.py @@ -121,11 +121,21 @@ def test_in_different_dir_dir_option(self, runner): self.assertEqual(0, result.exit_code) self.assertTrue((project_dir / "NEWS.rst").exists()) + @with_project() + def test_traverse_up_to_find_config(self, runner): + """ + When the current directory doesn't contain the configuration file, Towncrier + will traverse up the directory tree until it finds it. + """ + os.chdir("foo") + result = runner.invoke(_main, ["--draft", "--date", "01-01-2001"]) + self.assertEqual(0, result.exit_code, result.output) + @with_project() def test_in_different_dir_config_option(self, runner): """ The current working directory and the location of the configuration - don't matter as long as we pass corrct paths to the directory and the + don't matter as long as we pass correct paths to the directory and the config file. """ project_dir = Path(".").resolve() diff --git a/src/towncrier/test/test_settings.py b/src/towncrier/test/test_settings.py index 6d1f6041..b08384a5 100644 --- a/src/towncrier/test/test_settings.py +++ b/src/towncrier/test/test_settings.py @@ -207,10 +207,25 @@ def test_load_no_config(self, runner: CliRunner): self.assertEqual( result.output, - f"No configuration file found.\nLooked in: {os.path.abspath(temp)}\n", + f"No configuration file found.\nLooked back from: {os.path.abspath(temp)}\n", ) self.assertEqual(result.exit_code, 1) + @with_isolated_runner + def test_load_explicit_missing_config(self, runner: CliRunner): + """ + Calling the CLI with an incorrect explicit configuration file will exit with + code 1 and an informative message is sent to standard output. + """ + config = "not-there.toml" + result = runner.invoke(cli, ("--config", config)) + + self.assertEqual(result.exit_code, 1) + self.assertEqual( + result.output, + f"Configuration file '{os.path.abspath(config)}' not found.\n", + ) + def test_missing_template(self): """ Towncrier will raise an exception saying when it can't find a template. From 15d1e25d9dc676cd85fe1488341b272f6bb0e836 Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Fri, 24 May 2024 11:23:52 +1200 Subject: [PATCH 42/51] Update docs around version discovery (#602) --- docs/configuration.rst | 2 +- docs/tutorial.rst | 22 +++++++++++++++------- src/towncrier/newsfragments/432.doc.rst | 1 + src/towncrier/newsfragments/602.doc.rst | 1 + 4 files changed, 18 insertions(+), 8 deletions(-) create mode 100644 src/towncrier/newsfragments/432.doc.rst create mode 100644 src/towncrier/newsfragments/602.doc.rst diff --git a/docs/configuration.rst b/docs/configuration.rst index f0eaeda4..e7c8a99d 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -43,7 +43,7 @@ Top level keys ``version`` The version of your project. - Python projects that provide the ``package`` key can have the version to be automatically determined from a ``__version__`` variable in the package's module. + 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. If not provided or able to be determined, the version must be passed explicitly by the command line argument ``--version``. diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 7138cd9e..258a1323 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -48,19 +48,27 @@ Create this folder:: The ``.gitignore`` will remain and keep Git from not tracking the directory. -Detecting Dates & Versions --------------------------- +Detecting Version +----------------- -``towncrier`` needs to know what version your project is, and there are two ways you can give it: +``towncrier`` needs to know what version your project is when generating news files. +These are the ways you can provide it, in order of precedence (with the first taking precedence over the second, and so on): -- For Python 2/3-compatible projects, a ``__version__`` in the top level package. - This can be either a string literal, a tuple, or an `Incremental `_ version. -- Manually passing ``--version=`` when interacting with ``towncrier``. +1. Manually pass ``--version=`` when interacting with ``towncrier``. +2. Set a value for the ``version`` option in your configuration file. +3. For Python projects with a ``package`` key in the configuration file: -As an example, if your package doesn't have a ``__version__``, you can manually specify it when calling ``towncrier`` on the command line with the ``--version`` flag:: + - install the package to use its metadata version information + - add a ``__version__`` in the top level package that is either a string literal, a tuple, or an `Incremental `_ version + +As an example, you can manually specify the version when calling ``towncrier`` on the command line with the ``--version`` flag:: $ towncrier build --version=1.2.3post4 + +Setting Date +------------ + ``towncrier`` will also include the current date (in ``YYYY-MM-DD`` format) when generating news files. You can change this with the ``--date`` flag:: diff --git a/src/towncrier/newsfragments/432.doc.rst b/src/towncrier/newsfragments/432.doc.rst new file mode 100644 index 00000000..92e5247f --- /dev/null +++ b/src/towncrier/newsfragments/432.doc.rst @@ -0,0 +1 @@ +Clarify version discovery behavior. diff --git a/src/towncrier/newsfragments/602.doc.rst b/src/towncrier/newsfragments/602.doc.rst new file mode 100644 index 00000000..92e5247f --- /dev/null +++ b/src/towncrier/newsfragments/602.doc.rst @@ -0,0 +1 @@ +Clarify version discovery behavior. From 0b7f5424aaaeeba7498139041aa7f530854fb5a5 Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Thu, 13 Jun 2024 17:47:42 +1200 Subject: [PATCH 43/51] Refactor rendering of title via `config.title_format` (#610) * Refactor rendering of title via config.title_format * Add newsfragment * Config docs * Fix restructuredtext formatting error --- docs/configuration.rst | 2 + src/towncrier/build.py | 42 ++++++++--------- src/towncrier/newsfragments/610.feature.rst | 1 + src/towncrier/test/test_build.py | 50 +++++++++++++++++++++ 4 files changed, 71 insertions(+), 24 deletions(-) create mode 100644 src/towncrier/newsfragments/610.feature.rst diff --git a/docs/configuration.rst b/docs/configuration.rst index e7c8a99d..0a350691 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -80,6 +80,8 @@ Top level keys ``""`` by default. + Formatted titles are appended a line of ``=`` on the following line (reStructuredText title format) unless the template has an ``.md`` suffix, in which case the title will instead be prefixed with ``#`` (markdown title format). + ``issue_format`` A format string for rendering the issue/ticket number in newsfiles. diff --git a/src/towncrier/build.py b/src/towncrier/build.py index b28606c9..e0add371 100644 --- a/src/towncrier/build.py +++ b/src/towncrier/build.py @@ -170,8 +170,11 @@ def __main( .joinpath(config.template[1]) .read_text(encoding="utf-8") ) + template_extension = os.path.splitext(config.template[1])[1] else: template = Path(config.template).read_text(encoding="utf-8") + template_extension = os.path.splitext(config.template)[1] + is_markdown = template_extension.lower() == ".md" click.echo("Finding news fragments...", err=to_err) @@ -215,22 +218,10 @@ def __main( if project_date is None: project_date = _get_date().strip() - if config.title_format: - top_line = config.title_format.format( - name=project_name, version=project_version, project_date=project_date - ) - render_title_with_fragments = False - render_title_separately = True - elif config.title_format is False: - # This is an odd check but since we support both "" and False with - # different effects we have to do something a bit abnormal here. - top_line = "" - render_title_separately = False - render_title_with_fragments = False - else: - top_line = "" - render_title_separately = False - render_title_with_fragments = True + # Render the title in the template if the title format is set to "". It can + # alternatively be set to False or a string, in either case it shouldn't be rendered + # in the template. + render_title = config.title_format == "" rendered = render_fragments( # The 0th underline is used for the top line @@ -243,18 +234,21 @@ def __main( {"name": project_name, "version": project_version, "date": project_date}, top_underline=config.underlines[0], all_bullets=config.all_bullets, - render_title=render_title_with_fragments, + render_title=render_title, ) - if render_title_separately: - content = "\n".join( - [ - top_line, - config.underlines[0] * len(top_line), - rendered, - ] + if config.title_format: + top_line = config.title_format.format( + name=project_name, version=project_version, project_date=project_date ) + if is_markdown: + parts = [f"# {top_line}"] + else: + parts = [top_line, config.underlines[0] * len(top_line)] + parts.append(rendered) + content = "\n".join(parts) else: + top_line = "" content = rendered if draft: diff --git a/src/towncrier/newsfragments/610.feature.rst b/src/towncrier/newsfragments/610.feature.rst new file mode 100644 index 00000000..5b76ab2c --- /dev/null +++ b/src/towncrier/newsfragments/610.feature.rst @@ -0,0 +1 @@ +The ``title_format`` configuration option now uses a markdown format for markdown templates. diff --git a/src/towncrier/test/test_build.py b/src/towncrier/test/test_build.py index 17ffad49..23cfb860 100644 --- a/src/towncrier/test/test_build.py +++ b/src/towncrier/test/test_build.py @@ -1032,6 +1032,56 @@ def test_title_format_custom(self, runner): + """ + ) + + self.assertEqual(0, result.exit_code) + self.assertEqual(expected_output, result.output) + + @with_project( + config=""" + [tool.towncrier] + package = "foo" + filename = "NEWS.md" + title_format = "[{project_date}] CUSTOM RELEASE for {name} version {version}" + """ + ) + def test_title_format_custom_markdown(self, runner): + """ + A non-empty title format adds the specified title, and if the target filename is + markdown then the title is added as a markdown header. + """ + with open("foo/newsfragments/123.feature", "w") as f: + f.write("Adds levitation") + result = runner.invoke( + _main, + [ + "--name", + "FooBarBaz", + "--version", + "7.8.9", + "--date", + "20-01-2001", + "--draft", + ], + ) + + expected_output = dedent( + """\ + Loading template... + Finding news fragments... + Rendering news fragments... + Draft only -- nothing has been written. + What is seen below is what would be written. + + # [20-01-2001] CUSTOM RELEASE for FooBarBaz version 7.8.9 + + ### Features + + - Adds levitation (#123) + + + """ ) From be32e6bc99c6dcf6e009d60445d02d1128be3471 Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Fri, 14 Jun 2024 09:56:11 +1200 Subject: [PATCH 44/51] Refactor issue_key function to sort issues in a human-friendly way (#608) * Refactor issue_key function to sort issues in a human-friendly way * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Rename newsfragment * Small improvement to test to show how text with numeric issues are sorted * Update src/towncrier/_builder.py docstring grammar Co-authored-by: Adi Roiban * clarify new behaviour in newsfragment * Add some docstrings/comments to tests * linelength fix * Clarify news fragments vs tickets Co-authored-by: Adi Roiban * Consistent use of "issue" rather than "ticket" * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * typo --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Adi Roiban --- NEWS.rst | 8 +- README.rst | 2 +- docs/customization/newsfile.rst | 2 +- docs/tutorial.rst | 14 ++-- src/towncrier/_builder.py | 92 ++++++++++++++------- src/towncrier/create.py | 2 +- src/towncrier/newsfragments/608.feature.rst | 8 ++ src/towncrier/test/test_build.py | 2 +- src/towncrier/test/test_builder.py | 88 ++++++++++++++++++-- 9 files changed, 163 insertions(+), 55 deletions(-) create mode 100644 src/towncrier/newsfragments/608.feature.rst diff --git a/NEWS.rst b/NEWS.rst index 70db8733..23215d55 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -15,9 +15,9 @@ Bugfixes -------- - ``build`` now treats a missing fragments directory the same as an empty one, consistent with other operations. (`#538 `_) -- Fragments with filenames like `fix-1.2.3.feature` are now associated with the ticket `fix-1.2.3`. - In previous versions they were incorrectly associated to ticket `3`. (`#562 `_) -- Orphan newsfragments containing numeric values are no longer accidentally associated to tickets. In previous versions the orphan marker was ignored and the newsfragment was associated to a ticket having the last numerical value from the filename. (`#562 `_) +- Fragments with filenames like `fix-1.2.3.feature` are now associated with the issue `fix-1.2.3`. + In previous versions they were incorrectly associated to issue `3`. (`#562 `_) +- Orphan newsfragments containing numeric values are no longer accidentally associated to issues. In previous versions the orphan marker was ignored and the newsfragment was associated to an issue having the last numerical value from the filename. (`#562 `_) Misc @@ -248,7 +248,7 @@ towncrier 21.3.0.rc1 (2021-03-21) Features -------- -- Ticket number from file names will be stripped down to avoid ticket links such as ``#007``. (`#126 `_) +- Issue number from file names will be stripped down to avoid issue links such as ``#007``. (`#126 `_) - Allow definition of the project ``version`` and ``name`` in the configuration file. This allows use of towncrier seamlessly with non-Python projects. (`#165 `_) - Improve news fragment file name parsing to allow using file names like diff --git a/README.rst b/README.rst index da2250a8..f6cda3e3 100644 --- a/README.rst +++ b/README.rst @@ -30,7 +30,7 @@ Philosophy That is, by duplicating what has changed from the "developer log" (which may contain complex information about the original issue, how it was fixed, who authored the fix, and who reviewed the fix) into a "news fragment" (a small file containing just enough information to be useful to end users), ``towncrier`` can produce a digest of the changes which is valuable to those who may wish to use the software. These fragments are also commonly called "topfiles" or "newsfiles". -``towncrier`` works best in a development system where all merges involve closing a ticket. +``towncrier`` works best in a development system where all merges involve closing an issue. To get started, check out our `tutorial `_! diff --git a/docs/customization/newsfile.rst b/docs/customization/newsfile.rst index 67f3df4f..03b97a3a 100644 --- a/docs/customization/newsfile.rst +++ b/docs/customization/newsfile.rst @@ -4,7 +4,7 @@ Customizing the News File Output Adding Content Above ``towncrier`` ---------------------------------- -If you wish to have content at the top of the news file (for example, to say where you can find the tickets), you can use a special rST comment to tell ``towncrier`` to only update after it. +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. In your existing news file (e.g. ``NEWS.rst``), add the following line above where you want ``towncrier`` to put content: .. code-block:: restructuredtext diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 258a1323..70b9c410 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -89,9 +89,9 @@ The five default types are: - ``bugfix``: Signifying a bug fix. - ``doc``: Signifying a documentation improvement. - ``removal``: Signifying a deprecation or removal of public API. -- ``misc``: A ticket has been closed, but it is not of interest to users. +- ``misc``: An issue has been closed, but it is not of interest to users. -When you create a news fragment, the filename consists of the ticket ID (or some other unique identifier) as well as the 'type'. +When you create a news fragment, the filename consists of the issue/ticket ID (or some other unique identifier) as well as the 'type'. ``towncrier`` does not care about the fragment's suffix. You can create those fragments either by hand, or using the ``towncrier create`` command. @@ -99,14 +99,14 @@ Let's create some example news fragments to demonstrate:: $ echo 'Fixed a thing!' > src/myproject/newsfragments/1234.bugfix $ towncrier create --content 'Can also be ``rst`` as well!' 3456.doc.rst - # You can associate multiple ticket numbers with a news fragment by giving them the same contents. + # You can associate multiple issue numbers with a news fragment by giving them the same contents. $ towncrier create --content 'Can also be ``rst`` as well!' 7890.doc.rst $ echo 'The final part is ignored, so set it to whatever you want.' > src/myproject/newsfragments/8765.removal.txt $ echo 'misc is special, and does not put the contents of the file in the newsfile.' > src/myproject/newsfragments/1.misc $ towncrier create --edit 2.misc.rst # starts an editor - $ towncrier create -c "Orphan fragments have no ticket ID." +random.bugfix.rst + $ towncrier create -c "Orphan fragments have no issue ID." +random.bugfix.rst -For orphan news fragments (those that don't need to be linked to any ticket ID or other identifier), start the file name with ``+``. +For orphan news fragments (those that don't need to be linked to any issue ID or other identifier), start the file name with ``+``. The content will still be included in the release notes, at the end of the category corresponding to the file extension:: $ echo 'Fixed an unreported thing!' > src/myproject/newsfragments/+anything.bugfix @@ -132,7 +132,7 @@ You should get an output similar to this:: -------- - Fixed a thing! (#1234) - - Orphan fragments have no ticket ID. + - Orphan fragments have no issue ID. Improved Documentation @@ -167,7 +167,7 @@ To produce the news file for real, run:: This command will remove the news files (with ``git rm``) and append the built news to the filename specified in ``pyproject.toml``, and then stage the news file changes (with ``git add``). It leaves committing the changes up to the user. -If you wish to have content at the top of the news file (for example, to say where you can find the tickets), put your text above a rST comment that says:: +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:: .. towncrier release notes start diff --git a/src/towncrier/_builder.py b/src/towncrier/_builder.py index 3a4591de..751d4b14 100644 --- a/src/towncrier/_builder.py +++ b/src/towncrier/_builder.py @@ -5,16 +5,17 @@ from __future__ import annotations import os +import re import textwrap from collections import defaultdict from pathlib import Path -from typing import Any, DefaultDict, Iterable, Iterator, Mapping, Sequence +from typing import Any, DefaultDict, Iterable, Iterator, Mapping, NamedTuple, Sequence from jinja2 import Template -# Returns ticket, category and counter or (None, None, None) if the basename +# Returns issue, category and counter or (None, None, None) if the basename # could not be parsed or doesn't contain a valid category. def parse_newfragment_basename( basename: str, frag_type_names: Iterable[str] @@ -33,21 +34,21 @@ def parse_newfragment_basename( if parts[i] in frag_type_names: # Current part is a valid category according to given definitions. category = parts[i] - # Use all previous parts as the ticket number. + # Use all previous parts as the issue number. # NOTE: This allows news fragment names like fix-1.2.3.feature or - # something-cool.feature.ext for projects that don't use ticket + # something-cool.feature.ext for projects that don't use issue # numbers in news fragment names. - ticket = ".".join(parts[0:i]).strip() - # If the ticket is an integer, remove any leading zeros (to resolve + issue = ".".join(parts[0:i]).strip() + # If the issue is an integer, remove any leading zeros (to resolve # issue #126). - if ticket.isdigit(): - ticket = str(int(ticket)) + if issue.isdigit(): + issue = str(int(issue)) counter = 0 # Use the following part as the counter if it exists and is a valid # digit. if len(parts) > (i + 1) and parts[i + 1].isdigit(): counter = int(parts[i + 1]) - return ticket, category, counter + return issue, category, counter else: # No valid category found. return invalid @@ -97,15 +98,15 @@ def find_fragments( file_content = {} for basename in files: - ticket, category, counter = parse_newfragment_basename( + issue, category, counter = parse_newfragment_basename( basename, frag_type_names ) if category is None: continue - assert ticket is not None + assert issue is not None assert counter is not None - if orphan_prefix and ticket.startswith(orphan_prefix): - ticket = "" + if orphan_prefix and issue.startswith(orphan_prefix): + issue = "" # Use and increment the orphan news fragment counter. counter = orphan_fragment_counter[category] orphan_fragment_counter[category] += 1 @@ -114,13 +115,13 @@ def find_fragments( fragment_filenames.append(full_filename) data = Path(full_filename).read_text(encoding="utf-8", errors="replace") - if (ticket, category, counter) in file_content: + if (issue, category, counter) in file_content: raise ValueError( "multiple files for {}.{} in {}".format( - ticket, category, section_dir + issue, category, section_dir ) ) - file_content[ticket, category, counter] = data + file_content[issue, category, counter] = data content[key] = file_content @@ -153,7 +154,7 @@ def split_fragments( for section_name, section_fragments in fragments.items(): section: dict[str, dict[str, list[str]]] = {} - for (ticket, category, counter), content in section_fragments.items(): + for (issue, category, counter), content in section_fragments.items(): if all_bullets: # By default all fragmetns are append by "-" automatically, # and need to be indented because of that. @@ -168,30 +169,57 @@ def split_fragments( texts = section.setdefault(category, {}) - tickets = texts.setdefault(content, []) - if ticket: - # Only add the ticket if we have one (it can be blank for orphan news + issues = texts.setdefault(content, []) + if issue: + # Only add the issue if we have one (it can be blank for orphan news # fragments). - tickets.append(ticket) - tickets.sort() + issues.append(issue) + issues.sort() output[section_name] = section return output -def issue_key(issue: str) -> tuple[int, str]: - # We want integer issues to sort as integers, and we also want string - # issues to sort as strings. We arbitrarily put string issues before - # integer issues (hopefully no-one uses both at once). - try: - return (int(issue), "") - except Exception: - # Maybe we should sniff strings like "gh-10" -> (10, "gh-10")? - return (-1, issue) +class IssueParts(NamedTuple): + is_digit: bool + has_digit: bool + non_digit_part: str + number: int -def entry_key(entry: tuple[str, Sequence[str]]) -> tuple[str, list[tuple[int, str]]]: +def issue_key(issue: str) -> IssueParts: + """ + Used to sort the issue ID inside a news fragment in a human-friendly way. + + Issue IDs are grouped by their non-integer part, then sorted by their integer part. + + For backwards compatible consistency, issues without no number are sorted first and + digit only issues are sorted last. + + For example:: + + >>> sorted(["2", "#11", "#3", "gh-10", "gh-4", "omega", "alpha"], key=issue_key) + ['alpha', 'omega', '#3', '#11', 'gh-4', 'gh-10', '2'] + """ + if issue.isdigit(): + return IssueParts( + is_digit=True, has_digit=True, non_digit_part="", number=int(issue) + ) + match = re.search(r"\d+", issue) + if not match: + return IssueParts( + is_digit=False, has_digit=False, non_digit_part=issue, number=-1 + ) + return IssueParts( + is_digit=False, + has_digit=True, + non_digit_part=issue[: match.start()] + issue[match.end() :], + number=int(match.group()), + ) + + +def entry_key(entry: tuple[str, Sequence[str]]) -> tuple[str, list[IssueParts]]: content, issues = entry # Orphan news fragments (those without any issues) should sort last by content. return "" if issues else content, [issue_key(issue) for issue in issues] diff --git a/src/towncrier/create.py b/src/towncrier/create.py index 77433fca..39fddec0 100644 --- a/src/towncrier/create.py +++ b/src/towncrier/create.py @@ -70,7 +70,7 @@ def _main( * .bugfix - a bug fix * .doc - a documentation improvement, * .removal - a deprecation or removal of public API, - * .misc - a ticket has been closed, but it is not of interest to users. + * .misc - an issue has been closed, but it is not of interest to users. If the FILENAME base is just '+' (to create a fragment not tied to an issue), it will be appended with a random hex string. diff --git a/src/towncrier/newsfragments/608.feature.rst b/src/towncrier/newsfragments/608.feature.rst new file mode 100644 index 00000000..4cccc128 --- /dev/null +++ b/src/towncrier/newsfragments/608.feature.rst @@ -0,0 +1,8 @@ +News fragments are now sorted by issue number even if they have non-digit characters. +For example:: + + - some issue (gh-3, gh-10) + - another issue (gh-4) + - yet another issue (gh-11) + +The sorting algorithm groups the issues first by non-text characters and then by number. diff --git a/src/towncrier/test/test_build.py b/src/towncrier/test/test_build.py index 23cfb860..85a21bd7 100644 --- a/src/towncrier/test/test_build.py +++ b/src/towncrier/test/test_build.py @@ -914,7 +914,7 @@ def test_bullet_points_false(self, runner): """ When all_bullets is false, subsequent lines are not indented. - The automatic ticket number inserted by towncrier will align with the + The automatic issue number inserted by towncrier will align with the manual bullet. """ os.mkdir("newsfragments") diff --git a/src/towncrier/test/test_builder.py b/src/towncrier/test/test_builder.py index 9608e0a2..1213e1a3 100644 --- a/src/towncrier/test/test_builder.py +++ b/src/towncrier/test/test_builder.py @@ -1,9 +1,11 @@ # Copyright (c) Povilas Kanapickas, 2019 # See LICENSE for details. +from textwrap import dedent + from twisted.trial.unittest import TestCase -from .._builder import parse_newfragment_basename +from .._builder import parse_newfragment_basename, render_fragments class TestParseNewsfragmentBasename(TestCase): @@ -42,35 +44,35 @@ def test_ignores_extension(self): ("123", "feature", 0), ) - def test_non_numeric_ticket(self): + def test_non_numeric_issue(self): """Non-numeric issue identifiers are preserved verbatim.""" self.assertEqual( parse_newfragment_basename("baz.feature", ["feature"]), ("baz", "feature", 0), ) - def test_non_numeric_ticket_with_extension(self): + def test_non_numeric_issue_with_extension(self): """File extensions are ignored.""" self.assertEqual( parse_newfragment_basename("baz.feature.ext", ["feature"]), ("baz", "feature", 0), ) - def test_dots_in_ticket_name(self): + def test_dots_in_issue_name(self): """Non-numeric issue identifiers are preserved verbatim.""" self.assertEqual( parse_newfragment_basename("baz.1.2.feature", ["feature"]), ("baz.1.2", "feature", 0), ) - def test_dots_in_ticket_name_invalid_category(self): + def test_dots_in_issue_name_invalid_category(self): """Files without a valid category are rejected.""" self.assertEqual( parse_newfragment_basename("baz.1.2.notfeature", ["feature"]), (None, None, None), ) - def test_dots_in_ticket_name_and_counter(self): + def test_dots_in_issue_name_and_counter(self): """Non-numeric issue identifiers are preserved verbatim.""" self.assertEqual( parse_newfragment_basename("baz.1.2.feature.3", ["feature"]), @@ -79,7 +81,7 @@ def test_dots_in_ticket_name_and_counter(self): def test_strip(self): """Leading spaces and subsequent leading zeros are stripped - when parsing newsfragment names into ticket numbers etc. + when parsing newsfragment names into issue numbers etc. """ self.assertEqual( parse_newfragment_basename(" 007.feature", ["feature"]), @@ -88,7 +90,7 @@ def test_strip(self): def test_strip_with_counter(self): """Leading spaces and subsequent leading zeros are stripped - when parsing newsfragment names into ticket numbers etc. + when parsing newsfragment names into issue numbers etc. """ self.assertEqual( parse_newfragment_basename(" 007.feature.3", ["feature"]), @@ -132,3 +134,73 @@ def test_orphan_all_digits(self): parse_newfragment_basename("+123.feature", ["feature"]), ("+123", "feature", 0), ) + + +class TestNewsFragmentsOrdering(TestCase): + """ + Tests to ensure that issues are ordered correctly in the output. + + This tests both ordering of issues within a fragment and ordering of + fragments within a section. + """ + + template = dedent( + """ + {% for section_name, category in sections.items() %} + {% if section_name %}# {{ section_name }}{% endif %} + {%- for category_name, issues in category.items() %} + ## {{ category_name }} + {% for issue, numbers in issues.items() %} + - {{ issue }}{% if numbers %} ({{ numbers|join(', ') }}){% endif %} + + {% endfor %} + {% endfor -%} + {% endfor -%} + """ + ) + + def render(self, fragments): + return render_fragments( + template=self.template, + issue_format=None, + fragments=fragments, + definitions={}, + underlines=[], + wrap=False, + versiondata={}, + ) + + def test_ordering(self): + """ + Issues are ordered first by the non-text component, then by their number. + + For backwards compatibility, issues with no number are grouped first and issues + which are only a number are grouped last. + + Orphan news fragments are always last, sorted by their text. + """ + output = self.render( + { + "": { + "feature": { + "Added Cheese": ["10", "gh-25", "gh-3", "4"], + "Added Fish": [], + "Added Bread": [], + "Added Milk": ["gh-1"], + "Added Eggs": ["gh-2", "random"], + } + } + }, + ) + # "Eggs" are first because they have an issue with no number, and the first + # issue for each fragment is what is used for sorting the overall list. + assert output == dedent( + """ + ## feature + - Added Eggs (random, gh-2) + - Added Milk (gh-1) + - Added Cheese (gh-3, gh-25, #4, #10) + - Added Bread + - Added Fish +""" + ) From e3e73bd037ee214678d7cf7da4e7def40ac69d82 Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Fri, 14 Jun 2024 10:57:18 +1200 Subject: [PATCH 45/51] Section aware create (#603) * Abstract the fragments path generation * Update the create option to work with sections * Add fragment * Update newsfraghment * No need to mention new behaviour in news fragment, since that case wouldn't have worked previously anyway * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add some test docstrings * Default section * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Typing improvement * Skip an invalid branch to cover * Add test for multiple sections all with paths --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- docs/cli.rst | 5 + src/towncrier/_builder.py | 49 +++++-- src/towncrier/build.py | 19 +-- src/towncrier/check.py | 19 +-- src/towncrier/create.py | 73 +++++++++-- src/towncrier/newsfragments/603.feature.rst | 1 + src/towncrier/test/test_create.py | 136 ++++++++++++++++++++ 7 files changed, 241 insertions(+), 61 deletions(-) create mode 100644 src/towncrier/newsfragments/603.feature.rst diff --git a/docs/cli.rst b/docs/cli.rst index 478a88b9..6adbb94d 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -98,6 +98,11 @@ If that is the entire fragment name, a random hash will be added for you:: Whether to start ``$EDITOR`` to edit the news fragment right away. Default: ``$EDITOR`` will be started unless you also provided content. +.. option:: --section SECTION + + The section to use for the news fragment. + Default: the section with no path, or if all sections have a path then the first defined section. + ``towncrier check`` ------------------- diff --git a/src/towncrier/_builder.py b/src/towncrier/_builder.py index 751d4b14..bfb05227 100644 --- a/src/towncrier/_builder.py +++ b/src/towncrier/_builder.py @@ -14,6 +14,8 @@ from jinja2 import Template +from towncrier._settings.load import Config + # Returns issue, category and counter or (None, None, None) if the basename # could not be parsed or doesn't contain a valid category. @@ -54,6 +56,35 @@ def parse_newfragment_basename( return invalid +class FragmentsPath: + """ + A helper to get the full path to a fragments directory. + + This is a callable that optionally takes a section directory and returns the full + path to the fragments directory for that section (or the default if no section is + provided). + """ + + def __init__(self, base_directory: str, config: Config): + self.base_directory = base_directory + self.config = config + if config.directory is not None: + self.base_directory = os.path.abspath( + os.path.join(base_directory, config.directory) + ) + self.append_directory = "" + else: + self.base_directory = os.path.abspath( + os.path.join(base_directory, config.package_dir, config.package) + ) + self.append_directory = "newsfragments" + + def __call__(self, section_directory: str = "") -> str: + return os.path.join( + self.base_directory, section_directory, self.append_directory + ) + + # Returns a structure like: # # { @@ -70,25 +101,21 @@ def parse_newfragment_basename( # Also returns a list of the paths that the fragments were taken from. def find_fragments( base_directory: str, - sections: Mapping[str, str], - fragment_directory: str | None, - frag_type_names: Iterable[str], - orphan_prefix: str | None = None, + config: Config, ) -> tuple[Mapping[str, Mapping[tuple[str, str, int], str]], list[str]]: """ Sections are a dictonary of section names to paths. """ + get_section_path = FragmentsPath(base_directory, config) + content = {} fragment_filenames = [] # Multiple orphan news fragments are allowed per section, so initialize a counter # that can be incremented automatically. orphan_fragment_counter: DefaultDict[str | None, int] = defaultdict(int) - for key, val in sections.items(): - if fragment_directory is not None: - section_dir = os.path.join(base_directory, val, fragment_directory) - else: - section_dir = os.path.join(base_directory, val) + for key, section_dir in config.sections.items(): + section_dir = get_section_path(section_dir) try: files = os.listdir(section_dir) @@ -99,13 +126,13 @@ def find_fragments( for basename in files: issue, category, counter = parse_newfragment_basename( - basename, frag_type_names + basename, config.types ) if category is None: continue assert issue is not None assert counter is not None - if orphan_prefix and issue.startswith(orphan_prefix): + if config.orphan_prefix and issue.startswith(config.orphan_prefix): issue = "" # Use and increment the orphan news fragment counter. counter = orphan_fragment_counter[category] diff --git a/src/towncrier/build.py b/src/towncrier/build.py index e0add371..2d77bb09 100644 --- a/src/towncrier/build.py +++ b/src/towncrier/build.py @@ -178,24 +178,7 @@ def __main( click.echo("Finding news fragments...", err=to_err) - if config.directory is not None: - fragment_base_directory = os.path.abspath( - os.path.join(base_directory, config.directory) - ) - fragment_directory = None - else: - fragment_base_directory = os.path.abspath( - os.path.join(base_directory, config.package_dir, config.package) - ) - fragment_directory = "newsfragments" - - fragment_contents, fragment_filenames = find_fragments( - fragment_base_directory, - config.sections, - fragment_directory, - config.types, - config.orphan_prefix, - ) + fragment_contents, fragment_filenames = find_fragments(base_directory, config) click.echo("Rendering news fragments...", err=to_err) fragments = split_fragments( diff --git a/src/towncrier/check.py b/src/towncrier/check.py index ee9b612e..f0d45677 100644 --- a/src/towncrier/check.py +++ b/src/towncrier/check.py @@ -106,25 +106,8 @@ def __main( click.echo("Checks SKIPPED: news file changes detected.") sys.exit(0) - if config.directory: - fragment_base_directory = os.path.abspath( - os.path.join(base_directory, config.directory) - ) - fragment_directory = None - else: - fragment_base_directory = os.path.abspath( - os.path.join(base_directory, config.package_dir, config.package) - ) - fragment_directory = "newsfragments" - fragments = { - os.path.abspath(path) - for path in find_fragments( - fragment_base_directory, - config.sections, - fragment_directory, - config.types.keys(), - )[1] + os.path.abspath(path) for path in find_fragments(base_directory, config)[1] } fragments_in_branch = fragments & files diff --git a/src/towncrier/create.py b/src/towncrier/create.py index 39fddec0..e78fb658 100644 --- a/src/towncrier/create.py +++ b/src/towncrier/create.py @@ -10,9 +10,11 @@ import os from pathlib import Path +from typing import cast import click +from ._builder import FragmentsPath from ._settings import config_option_help, load_config_from_options @@ -47,6 +49,11 @@ default=DEFAULT_CONTENT, help="Sets the content of the new fragment.", ) +@click.option( + "--section", + type=str, + help="The section to create the fragment for.", +) @click.argument("filename", default="") def _main( ctx: click.Context, @@ -55,6 +62,7 @@ def _main( filename: str, edit: bool | None, content: str, + section: str | None, ) -> None: """ Create a new news fragment. @@ -75,7 +83,7 @@ def _main( If the FILENAME base is just '+' (to create a fragment not tied to an issue), it will be appended with a random hex string. """ - __main(ctx, directory, config, filename, edit, content) + __main(ctx, directory, config, filename, edit, content, section) def __main( @@ -85,6 +93,7 @@ def __main( filename: str, edit: bool | None, content: str, + section: str | None, ) -> None: """ The main entry point. @@ -97,7 +106,54 @@ def __main( if ext.lower() in (".rst", ".md"): filename_ext = ext + section_provided = section is not None + if not section_provided: + # Get the default section. + if len(config.sections) == 1: + section = next(iter(config.sections)) + else: + # If there are multiple sections then the first without a path is the default + # section, otherwise it's the first defined section. + for ( + section_name, + section_dir, + ) in config.sections.items(): # pragma: no branch + if not section_dir: + section = section_name + break + if section is None: + section = list(config.sections.keys())[0] + + if section not in config.sections: + # Raise a click exception with the correct parameter. + section_param = None + for p in ctx.command.params: # pragma: no branch + if p.name == "section": + section_param = p + break + expected_sections = ", ".join(f"'{s}'" for s in config.sections) + raise click.BadParameter( + f"expected one of {expected_sections}", + param=section_param, + ) + section = cast(str, section) + if not filename: + if not section_provided: + sections = list(config.sections) + if len(sections) > 1: + click.echo("Pick a section:") + default_section_index = None + for i, s in enumerate(sections): + click.echo(f" {i+1}: {s or '(primary)'}") + if not default_section_index and s == section: + default_section_index = str(i + 1) + section_index = click.prompt( + "Section", + type=click.Choice([str(i + 1) for i in range(len(sections))]), + default=default_section_index, + ) + section = sections[int(section_index) - 1] prompt = "Issue number" # Add info about adding orphan if config is set. if config.orphan_prefix: @@ -134,19 +190,8 @@ def __main( if filename_parts[-1] in config.types and filename_ext: filename += filename_ext - if config.directory: - fragments_directory = os.path.abspath( - os.path.join(base_directory, config.directory) - ) - else: - fragments_directory = os.path.abspath( - os.path.join( - base_directory, - config.package_dir, - config.package, - "newsfragments", - ) - ) + get_fragments_path = FragmentsPath(base_directory, config) + fragments_directory = get_fragments_path(section_directory=config.sections[section]) if not os.path.exists(fragments_directory): os.makedirs(fragments_directory) diff --git a/src/towncrier/newsfragments/603.feature.rst b/src/towncrier/newsfragments/603.feature.rst new file mode 100644 index 00000000..afe48164 --- /dev/null +++ b/src/towncrier/newsfragments/603.feature.rst @@ -0,0 +1 @@ +The ``towncrier create`` action now uses sections defined in your config (either interactively, or via the new ``--section`` option). diff --git a/src/towncrier/test/test_create.py b/src/towncrier/test/test_create.py index 2ba74af9..dc6f6b9d 100644 --- a/src/towncrier/test/test_create.py +++ b/src/towncrier/test/test_create.py @@ -415,6 +415,142 @@ def test_without_filename_no_orphan_config(self, runner: CliRunner): with open(expected) as f: self.assertEqual(f.read(), "Edited content\n") + @with_isolated_runner + def test_sections(self, runner: CliRunner): + """ + When creating a new fragment, the user can specify the section from the command + line (and if none is provided, the default section will be used). + + The default section is either the section with a blank path, or else the first + section defined in the configuration file. + """ + setup_simple_project( + extra_config=""" +[[tool.towncrier.section]] +name = "Backend" +path = "backend" +[[tool.towncrier.section]] +name = "Frontend" +path = "" +""" + ) + result = runner.invoke(_main, ["123.feature.rst"]) + self.assertFalse(result.exception, result.output) + frag_path = Path("foo", "newsfragments") + fragments = [f.name for f in frag_path.iterdir()] + self.assertEqual(fragments, ["123.feature.rst"]) + + result = runner.invoke(_main, ["123.feature.rst", "--section", "invalid"]) + self.assertTrue(result.exception, result.output) + self.assertIn( + "Invalid value for '--section': expected one of 'Backend', 'Frontend'", + result.output, + ) + + result = runner.invoke(_main, ["123.feature.rst", "--section", "Backend"]) + self.assertFalse(result.exception, result.output) + frag_path = Path("foo", "backend", "newsfragments") + fragments = [f.name for f in frag_path.iterdir()] + self.assertEqual(fragments, ["123.feature.rst"]) + + @with_isolated_runner + def test_sections_without_filename(self, runner: CliRunner): + """ + When multiple sections exist when the interactive prompt is used, the user is + prompted to select a section. + """ + setup_simple_project( + extra_config=""" +[[tool.towncrier.section]] +name = "Backend" +path = "" + +[[tool.towncrier.section]] +name = "Frontend" +path = "frontend" +""" + ) + with mock.patch("click.edit") as mock_edit: + mock_edit.return_value = "Edited content" + result = runner.invoke(_main, input="2\n123\nfeature\n") + self.assertFalse(result.exception, result.output) + mock_edit.assert_called_once() + expected = os.path.join( + os.getcwd(), "foo", "frontend", "newsfragments", "123.feature.rst" + ) + + self.assertEqual( + result.output, + f"""\ +Pick a section: + 1: Backend + 2: Frontend +Section (1, 2) [1]: 2 +Issue number (`+` if none): 123 +Fragment type (feature, bugfix, doc, removal, misc): feature +Created news fragment at {expected} +""", + ) + + @with_isolated_runner + def test_sections_without_filename_with_section_option(self, runner: CliRunner): + """ + When multiple sections exist and the section is provided via the command line, + the user isn't prompted to select a section. + """ + setup_simple_project( + extra_config=""" +[[tool.towncrier.section]] +name = "Backend" +path = "" + +[[tool.towncrier.section]] +name = "Frontend" +path = "frontend" +""" + ) + with mock.patch("click.edit") as mock_edit: + mock_edit.return_value = "Edited content" + result = runner.invoke( + _main, ["--section", "Frontend"], input="123\nfeature\n" + ) + self.assertFalse(result.exception, result.output) + mock_edit.assert_called_once() + expected = os.path.join( + os.getcwd(), "foo", "frontend", "newsfragments", "123.feature.rst" + ) + + self.assertEqual( + result.output, + f"""\ +Issue number (`+` if none): 123 +Fragment type (feature, bugfix, doc, removal, misc): feature +Created news fragment at {expected} +""", + ) + + @with_isolated_runner + def test_sections_all_with_paths(self, runner: CliRunner): + """ + When all sections have paths, the first is the default. + """ + setup_simple_project( + extra_config=""" +[[tool.towncrier.section]] +name = "Frontend" +path = "frontend" + +[[tool.towncrier.section]] +name = "Backend" +path = "backend" +""" + ) + result = runner.invoke(_main, ["123.feature.rst"]) + self.assertFalse(result.exception, result.output) + frag_path = Path("foo", "frontend", "newsfragments") + fragments = [f.name for f in frag_path.iterdir()] + self.assertEqual(fragments, ["123.feature.rst"]) + @with_isolated_runner def test_without_filename_with_message(self, runner: CliRunner): """ From 52412ac45bb11bf397019928b076e2ee1117d82d Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Fri, 14 Jun 2024 11:31:56 +1200 Subject: [PATCH 46/51] Require jinja2 v3.0. (#615) Fixes #569 --- pyproject.toml | 2 +- src/towncrier/newsfragments/569.misc.rst | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 src/towncrier/newsfragments/569.misc.rst diff --git a/pyproject.toml b/pyproject.toml index d6f8fa6d..a76f3c8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ dependencies = [ "importlib-resources>=5; python_version<'3.10'", "importlib-metadata>=4.6; python_version<'3.10'", "incremental", - "jinja2", + "jinja2>=3", "tomli; python_version<'3.11'", ] diff --git a/src/towncrier/newsfragments/569.misc.rst b/src/towncrier/newsfragments/569.misc.rst new file mode 100644 index 00000000..3dfd8638 --- /dev/null +++ b/src/towncrier/newsfragments/569.misc.rst @@ -0,0 +1 @@ +Constrain jinja2 to v3+ -- our templates require this and it was released over 3 years ago (May 2021) From 09819ac35f71aee8fa6f068228b4f28e6879f024 Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Fri, 14 Jun 2024 12:24:33 +1200 Subject: [PATCH 47/51] Orphans in non showcontent categories (#612) * Always content for orphans, since they don't have a concept of tickets * Add tests for orphans in non showcontent categories * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add newsfragment * Add a note in the configuration docs about the change to orphan fragments in non-showcontent categories * Use rst admonitions for the showcontent notes * Refactor rendering of title via `config.title_format` (#610) * Refactor rendering of title via config.title_format * Add newsfragment * Config docs * Fix restructuredtext formatting error * Refactor issue_key function to sort issues in a human-friendly way (#608) * Refactor issue_key function to sort issues in a human-friendly way * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Rename newsfragment * Small improvement to test to show how text with numeric issues are sorted * Update src/towncrier/_builder.py docstring grammar Co-authored-by: Adi Roiban * clarify new behaviour in newsfragment * Add some docstrings/comments to tests * linelength fix * Clarify news fragments vs tickets Co-authored-by: Adi Roiban * Consistent use of "issue" rather than "ticket" * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * typo --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Adi Roiban * variable now called issue instead of ticket --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Adi Roiban --- docs/configuration.rst | 10 ++ src/towncrier/_builder.py | 6 +- src/towncrier/newsfragments/612.bugfix.rst | 1 + src/towncrier/templates/default.md | 13 +-- src/towncrier/templates/default.rst | 7 +- src/towncrier/test/test_build.py | 109 +++++++++++++++++++++ 6 files changed, 129 insertions(+), 17 deletions(-) create mode 100644 src/towncrier/newsfragments/612.bugfix.rst diff --git a/docs/configuration.rst b/docs/configuration.rst index 0a350691..9841b1e4 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -216,6 +216,11 @@ These may include the following optional keys: ``true`` by default. + .. note:: + + Orphan fragments (those without an issue number) always have their content included. + If a fragment was created, it means that information is important for end users. + For example, if you want your custom fragment types to be ``["feat", "fix", "chore",]`` and you want all of them to use the default configuration except ``"chore"`` you can do it as follows: .. code-block:: toml @@ -257,6 +262,11 @@ Each table within this array has the following mandatory keys: ``true`` by default. + .. note:: + + Orphan fragments (those without an issue number) always have their content included. + If a fragment was created, it means that information is important for end users. + For example: .. code-block:: toml diff --git a/src/towncrier/_builder.py b/src/towncrier/_builder.py index bfb05227..91a63231 100644 --- a/src/towncrier/_builder.py +++ b/src/towncrier/_builder.py @@ -191,7 +191,11 @@ def split_fragments( # Assume the text is formatted correctly content = content.rstrip() - if definitions[category]["showcontent"] is False: + if definitions[category]["showcontent"] is False and issue: + # If this category is not supposed to show content (and we have an + # issue) then we should just add the issue to the section rather than + # the content. If there isn't an issue, still add the content so that + # it's recorded. content = "" texts = section.setdefault(category, {}) diff --git a/src/towncrier/newsfragments/612.bugfix.rst b/src/towncrier/newsfragments/612.bugfix.rst new file mode 100644 index 00000000..577a9c48 --- /dev/null +++ b/src/towncrier/newsfragments/612.bugfix.rst @@ -0,0 +1 @@ +Orphan news fragments, fragments not associated with an issue, will now still show in categories that are marked to not show content, since they do not have an issue number to show. diff --git a/src/towncrier/templates/default.md b/src/towncrier/templates/default.md index cc894f60..0aa7262c 100644 --- a/src/towncrier/templates/default.md +++ b/src/towncrier/templates/default.md @@ -15,7 +15,6 @@ {% for category, val in definitions.items() if category in sections[section] %} ### {{ definitions[category]['name'] }} -{% if definitions[category]['showcontent'] %} {% for text, values in sections[section][category].items() %} - {{ text }} {%- if values %} @@ -24,24 +23,18 @@ ( {%- else %} - ( +{% if text %} ({% endif %} {%- endif -%} {%- for issue in values %} {{ issue.split(": ", 1)[0] }}{% if not loop.last %}, {% endif %} {%- endfor %} -) +{% if text %}){% endif %} + {% else %} {% endif %} {% endfor %} -{% else %} -- {% for issue in sections[section][category][''] %} -{{ issue.split(": ", 1)[0] }}{% if not loop.last %}, {% endif %} -{% endfor %} - - -{% endif %} {% if issues_by_category[section][category] and "]: " in issues_by_category[section][category][0] %} {% for issue in issues_by_category[section][category] %} {{ issue }} diff --git a/src/towncrier/templates/default.rst b/src/towncrier/templates/default.rst index f494036d..bee15720 100644 --- a/src/towncrier/templates/default.rst +++ b/src/towncrier/templates/default.rst @@ -18,16 +18,11 @@ {{ definitions[category]['name'] }} {{ underline * definitions[category]['name']|length }} -{% if definitions[category]['showcontent'] %} {% for text, values in sections[section][category].items() %} -- {{ text }}{% if values %} ({{ values|join(', ') }}){% endif %} +- {% if text %}{{ text }}{% if values %} ({{ values|join(', ') }}){% endif %}{% else %}{{ values|join(', ') }}{% endif %} {% endfor %} -{% else %} -- {{ sections[section][category]['']|join(', ') }} - -{% endif %} {% if sections[section][category]|length == 0 %} No significant changes. diff --git a/src/towncrier/test/test_build.py b/src/towncrier/test/test_build.py index 85a21bd7..5da7d828 100644 --- a/src/towncrier/test/test_build.py +++ b/src/towncrier/test/test_build.py @@ -1416,6 +1416,115 @@ def test_with_topline_and_template_and_draft(self, runner): - Adds levitation + """ + ) + + self.assertEqual(0, result.exit_code, result.output) + self.assertEqual(expected_output, result.output) + + @with_project( + config=""" + [tool.towncrier] + """ + ) + def test_orphans_in_non_showcontent(self, runner): + """ + When ``showcontent`` is false (like in the ``misc`` category by default), + orphans are still rendered because they don't have an issue number to display. + """ + os.mkdir("newsfragments") + with open("newsfragments/123.misc", "w") as f: + f.write("Misc") + with open("newsfragments/345.misc", "w") as f: + f.write("Another misc") + with open("newsfragments/+.misc", "w") as f: + f.write("Orphan misc still displayed!") + with open("newsfragments/+2.misc", "w") as f: + f.write("Another orphan misc still displayed!") + + result = runner.invoke( + _main, + [ + "--version=7.8.9", + "--date=20-01-2001", + "--draft", + ], + ) + + expected_output = dedent( + """\ + Loading template... + Finding news fragments... + Rendering news fragments... + Draft only -- nothing has been written. + What is seen below is what would be written. + + 7.8.9 (20-01-2001) + ================== + + Misc + ---- + + - #123, #345 + - Another orphan misc still displayed! + - Orphan misc still displayed! + + + + """ + ) + + self.assertEqual(0, result.exit_code, result.output) + self.assertEqual(expected_output, result.output) + + @with_project( + config=""" + [tool.towncrier] + filename = "CHANGES.md" + """ + ) + def test_orphans_in_non_showcontent_markdown(self, runner): + """ + When ``showcontent`` is false (like in the ``misc`` category by default), + orphans are still rendered because they don't have an issue number to display. + """ + os.mkdir("newsfragments") + with open("newsfragments/123.misc", "w") as f: + f.write("Misc") + with open("newsfragments/345.misc", "w") as f: + f.write("Another misc") + with open("newsfragments/+.misc", "w") as f: + f.write("Orphan misc still displayed!") + with open("newsfragments/+2.misc", "w") as f: + f.write("Another orphan misc still displayed!") + + result = runner.invoke( + _main, + [ + "--version=7.8.9", + "--date=20-01-2001", + "--draft", + ], + ) + + expected_output = dedent( + """\ + Loading template... + Finding news fragments... + Rendering news fragments... + Draft only -- nothing has been written. + What is seen below is what would be written. + + # 7.8.9 (20-01-2001) + + ### Misc + + - #123, #345 + - Another orphan misc still displayed! + - Orphan misc still displayed! + + + """ ) From b49cca6ec28c941f15f58312992875c05fd1e382 Mon Sep 17 00:00:00 2001 From: Ben Avrahami Date: Wed, 26 Jun 2024 15:21:21 +0300 Subject: [PATCH 48/51] Ignore types on check (#618) * added check to newsfragment types * nox passes * renamed fragment for issue number * cr changes --------- Co-authored-by: Ben Avrahami --- docs/configuration.rst | 20 ++++++ src/towncrier/_builder.py | 22 +++--- src/towncrier/_settings/fragment_types.py | 18 +++-- src/towncrier/build.py | 3 +- src/towncrier/check.py | 19 ++++-- src/towncrier/newsfragments/617.feature.rst | 1 + src/towncrier/test/test_check.py | 75 +++++++++++++++++++++ src/towncrier/test/test_settings.py | 27 ++++++++ 8 files changed, 166 insertions(+), 19 deletions(-) create mode 100644 src/towncrier/newsfragments/617.feature.rst diff --git a/docs/configuration.rst b/docs/configuration.rst index 9841b1e4..aa4edfca 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -221,6 +221,11 @@ These may include the following optional keys: Orphan fragments (those without an issue number) always have their content included. If a fragment was created, it means that information is important for end users. +``check`` + A boolean value indicating whether the fragment should be considered by the ``towncrier check`` command. + + ``true`` by default. + For example, if you want your custom fragment types to be ``["feat", "fix", "chore",]`` and you want all of them to use the default configuration except ``"chore"`` you can do it as follows: .. code-block:: toml @@ -234,6 +239,10 @@ For example, if you want your custom fragment types to be ``["feat", "fix", "cho name = "Other Tasks" showcontent = false + [tool.towncrier.fragment.deps] + name = "Dependency Changes" + check = false + .. warning:: @@ -267,6 +276,11 @@ Each table within this array has the following mandatory keys: Orphan fragments (those without an issue number) always have their content included. If a fragment was created, it means that information is important for end users. +``check`` + A boolean value indicating whether the fragment should be considered by the ``towncrier check`` command. + + ``true`` by default. + For example: .. code-block:: toml @@ -281,3 +295,9 @@ For example: directory = "chore" name = "Other Tasks" showcontent = false + + [[tool.towncrier.type]] + directory = "deps" + name = "Dependency Changes" + showcontent = true + check = false diff --git a/src/towncrier/_builder.py b/src/towncrier/_builder.py index 91a63231..e7fbf12b 100644 --- a/src/towncrier/_builder.py +++ b/src/towncrier/_builder.py @@ -89,27 +89,31 @@ def __call__(self, section_directory: str = "") -> str: # # { # "": { -# ("142", "misc"): "", -# ("1", "feature"): "some cool description", +# ("142", "misc", 1): "", +# ("1", "feature", 1): "some cool description", # }, # "Names": {}, -# "Web": {("3", "bugfix"): "Fixed a thing"}, +# "Web": {("3", "bugfix", 1): "Fixed a thing"}, # } # -# We should really use attrs. +# and a list like: +# [ +# ("/path/to/fragments/142.misc.1", "misc"), +# ("/path/to/fragments/1.feature.1", "feature"), +# ] # -# Also returns a list of the paths that the fragments were taken from. +# We should really use attrs. def find_fragments( base_directory: str, config: Config, -) -> tuple[Mapping[str, Mapping[tuple[str, str, int], str]], list[str]]: +) -> tuple[Mapping[str, Mapping[tuple[str, str, int], str]], list[tuple[str, str]]]: """ Sections are a dictonary of section names to paths. """ get_section_path = FragmentsPath(base_directory, config) content = {} - fragment_filenames = [] + fragment_files = [] # Multiple orphan news fragments are allowed per section, so initialize a counter # that can be incremented automatically. orphan_fragment_counter: DefaultDict[str | None, int] = defaultdict(int) @@ -139,7 +143,7 @@ def find_fragments( orphan_fragment_counter[category] += 1 full_filename = os.path.join(section_dir, basename) - fragment_filenames.append(full_filename) + fragment_files.append((full_filename, category)) data = Path(full_filename).read_text(encoding="utf-8", errors="replace") if (issue, category, counter) in file_content: @@ -152,7 +156,7 @@ def find_fragments( content[key] = file_content - return content, fragment_filenames + return content, fragment_files def indent(text: str, prefix: str) -> str: diff --git a/src/towncrier/_settings/fragment_types.py b/src/towncrier/_settings/fragment_types.py index a9882c9a..bb1313be 100644 --- a/src/towncrier/_settings/fragment_types.py +++ b/src/towncrier/_settings/fragment_types.py @@ -37,11 +37,15 @@ class DefaultFragmentTypesLoader(BaseFragmentTypesLoader): _default_types = { # Keep in-sync with docs/tutorial.rst. - "feature": {"name": "Features", "showcontent": True}, - "bugfix": {"name": "Bugfixes", "showcontent": True}, - "doc": {"name": "Improved Documentation", "showcontent": True}, - "removal": {"name": "Deprecations and Removals", "showcontent": True}, - "misc": {"name": "Misc", "showcontent": False}, + "feature": {"name": "Features", "showcontent": True, "check": True}, + "bugfix": {"name": "Bugfixes", "showcontent": True, "check": True}, + "doc": {"name": "Improved Documentation", "showcontent": True, "check": True}, + "removal": { + "name": "Deprecations and Removals", + "showcontent": True, + "check": True, + }, + "misc": {"name": "Misc", "showcontent": False, "check": True}, } def load(self) -> Mapping[str, Mapping[str, Any]]: @@ -75,9 +79,11 @@ def load(self) -> Mapping[str, Mapping[str, Any]]: directory = type_config["directory"] fragment_type_name = type_config["name"] is_content_required = type_config["showcontent"] + check = type_config.get("check", True) types[directory] = { "name": fragment_type_name, "showcontent": is_content_required, + "check": check, } return types @@ -129,8 +135,10 @@ def _load_options(self, fragment_type: str) -> Mapping[str, Any]: options = self.fragment_options.get(fragment_type, {}) fragment_description = options.get("name", capitalized_fragment_type) show_content = options.get("showcontent", True) + check = options.get("check", True) clean_fragment_options = { "name": fragment_description, "showcontent": show_content, + "check": check, } return clean_fragment_options diff --git a/src/towncrier/build.py b/src/towncrier/build.py index 2d77bb09..bf7cd350 100644 --- a/src/towncrier/build.py +++ b/src/towncrier/build.py @@ -178,7 +178,8 @@ def __main( click.echo("Finding news fragments...", err=to_err) - fragment_contents, fragment_filenames = find_fragments(base_directory, config) + fragment_contents, fragment_files = find_fragments(base_directory, config) + fragment_filenames = [filename for (filename, _category) in fragment_files] click.echo("Rendering news fragments...", err=to_err) fragments = split_fragments( diff --git a/src/towncrier/check.py b/src/towncrier/check.py index f0d45677..a519f9ae 100644 --- a/src/towncrier/check.py +++ b/src/towncrier/check.py @@ -106,13 +106,24 @@ def __main( click.echo("Checks SKIPPED: news file changes detected.") sys.exit(0) - fragments = { - os.path.abspath(path) for path in find_fragments(base_directory, config)[1] - } + all_fragment_files = find_fragments(base_directory, config)[1] + fragments = set() # will only include fragments of types that are checked + unchecked_fragments = set() # will include fragments of types that are not checked + for fragment_filename, category in all_fragment_files: + if config.types[category]["check"]: + fragments.add(fragment_filename) + else: + unchecked_fragments.add(fragment_filename) fragments_in_branch = fragments & files if not fragments_in_branch: - click.echo("No new newsfragments found on this branch.") + unchecked_fragments_in_branch = unchecked_fragments & files + if unchecked_fragments: + click.echo("Found newsfragments of unchecked types in the branch:") + for n, fragment in enumerate(unchecked_fragments_in_branch, start=1): + click.echo(f"{n}. {fragment}") + else: + click.echo("No new newsfragments found on this branch.") sys.exit(1) else: click.echo("Found:") diff --git a/src/towncrier/newsfragments/617.feature.rst b/src/towncrier/newsfragments/617.feature.rst new file mode 100644 index 00000000..95799672 --- /dev/null +++ b/src/towncrier/newsfragments/617.feature.rst @@ -0,0 +1 @@ +newsfragment categories can now be marked with ``check = false``, causing them to be ignored in ``towncrier check`` diff --git a/src/towncrier/test/test_check.py b/src/towncrier/test/test_check.py index 81861db3..23db3264 100644 --- a/src/towncrier/test/test_check.py +++ b/src/towncrier/test/test_check.py @@ -166,6 +166,81 @@ def test_fragment_missing(self): result.output.endswith("No new newsfragments found on this branch.\n") ) + def test_fragment_exists_but_not_in_check(self): + """A fragment that exists but is marked as check=False is ignored by the check.""" + runner = CliRunner() + + with runner.isolated_filesystem(): + create_project( + "pyproject.toml", + main_branch="master", + extra_config="[[tool.towncrier.type]]\n" + 'directory = "feature"\n' + 'name = "Features"\n' + "showcontent = true\n" + "[[tool.towncrier.type]]\n" + 'directory = "sut"\n' + 'name = "System Under Test"\n' + "showcontent = true\n" + "check=false\n", + ) + + file_path = "foo/somefile.py" + write(file_path, "import os") + + fragment_path = Path("foo/newsfragments/1234.sut").absolute() + write(fragment_path, "Adds gravity back") + commit("add a file and a newsfragment") + + result = runner.invoke(towncrier_check, ["--compare-with", "master"]) + + self.assertEqual(1, result.exit_code) + self.assertTrue( + result.output.endswith( + "Found newsfragments of unchecked types in the branch:\n1. " + + str(fragment_path) + + "\n" + ), + (result.output, str(fragment_path)), + ) + + def test_fragment_exists_and_in_check(self): + """ + A fragment that exists but is not marked as check=False is + not ignored by the check, even if other categories are marked as check=False. + """ + runner = CliRunner() + + with runner.isolated_filesystem(): + create_project( + "pyproject.toml", + main_branch="master", + extra_config="[[tool.towncrier.type]]\n" + 'directory = "feature"\n' + 'name = "Features"\n' + "showcontent = true\n" + "[[tool.towncrier.type]]\n" + 'directory = "sut"\n' + 'name = "System Under Test"\n' + "showcontent = true\n" + "check=false\n", + ) + + file_path = "foo/somefile.py" + write(file_path, "import os") + + fragment_path = Path("foo/newsfragments/1234.feature").absolute() + write(fragment_path, "Adds gravity back") + commit("add a file and a newsfragment") + + result = runner.invoke(towncrier_check, ["--compare-with", "master"]) + + self.assertEqual(0, result.exit_code) + self.assertTrue( + result.output.endswith("Found:\n1. " + str(fragment_path) + "\n"), + (result.output, str(fragment_path)), + ) + def test_none_stdout_encoding_works(self): """ No failure when output is piped causing None encoding for stdout. diff --git a/src/towncrier/test/test_settings.py b/src/towncrier/test/test_settings.py index b08384a5..14554086 100644 --- a/src/towncrier/test/test_settings.py +++ b/src/towncrier/test/test_settings.py @@ -288,6 +288,12 @@ def test_custom_types_as_tables_array_deprecated(self): directory="spam" name="Spam" showcontent=true + + [[tool.towncrier.type]] + directory="auto" + name="Automatic" + showcontent=true + check=false """ ) config = load_config(project_dir) @@ -297,6 +303,7 @@ def test_custom_types_as_tables_array_deprecated(self): { "name": "Foo", "showcontent": False, + "check": True, }, ), ( @@ -304,6 +311,15 @@ def test_custom_types_as_tables_array_deprecated(self): { "name": "Spam", "showcontent": True, + "check": True, + }, + ), + ( + "auto", + { + "name": "Automatic", + "showcontent": True, + "check": False, }, ), ] @@ -326,6 +342,9 @@ def test_custom_types_as_tables(self): [tool.towncrier.fragment.chore] name = "Other Tasks" showcontent = false + [tool.towncrier.fragment.auto] + name = "Automatic" + check = false """ ) config = load_config(project_dir) @@ -333,14 +352,22 @@ def test_custom_types_as_tables(self): "chore": { "name": "Other Tasks", "showcontent": False, + "check": True, }, "feat": { "name": "Feat", "showcontent": True, + "check": True, }, "fix": { "name": "Fix", "showcontent": True, + "check": True, + }, + "auto": { + "name": "Automatic", + "showcontent": True, + "check": False, }, } actual = config.types From f60e750f1d40f1a8a9f8d1640d2cacb33939408c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 2 Jul 2024 06:43:45 +0200 Subject: [PATCH 49/51] [pre-commit.ci] pre-commit autoupdate (#620) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/asottile/pyupgrade: v3.15.2 → v3.16.0](https://github.com/asottile/pyupgrade/compare/v3.15.2...v3.16.0) - [github.com/PyCQA/flake8: 7.0.0 → 7.1.0](https://github.com/PyCQA/flake8/compare/7.0.0...7.1.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a96ca41a..fe7c2da2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ repos: - id: black - repo: https://github.com/asottile/pyupgrade - rev: v3.15.2 + rev: v3.16.0 hooks: - id: pyupgrade args: [--py38-plus] @@ -21,7 +21,7 @@ repos: additional_dependencies: [toml] - repo: https://github.com/PyCQA/flake8 - rev: 7.0.0 + rev: 7.1.0 hooks: - id: flake8 From 8e7e24071289f574a037dec6b035bf9e590bac8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Bompard?= Date: Sat, 13 Jul 2024 11:59:48 +0200 Subject: [PATCH 50/51] Handle deletion of uncommitted news fragments (#357) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before this commit, all the news fragments needed to be committed into git, or the fragments removal after building the news file would crash. In my workflow, I add missing fragments before building the news file because I'm extracting author names from the git log, and towncrier crashes at the end of the build process. Signed-off-by: Aurélien Bompard Co-authored-by: Adi Roiban --- docs/cli.rst | 5 +- src/towncrier/_git.py | 21 ++++++- src/towncrier/newsfragments/357.feature.rst | 1 + src/towncrier/test/test_build.py | 61 +++++++++++++++++++-- 4 files changed, 79 insertions(+), 9 deletions(-) create mode 100644 src/towncrier/newsfragments/357.feature.rst diff --git a/docs/cli.rst b/docs/cli.rst index 6adbb94d..c8040461 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -28,8 +28,9 @@ If there are no news fragments (including an empty fragments directory or a non-existent directory), a notice of "no significant changes" will be added to the news file. -By default, the processed news fragments are removed using ``git``, which will -also remove the fragments directory if now empty. +By default, the processed news fragments are removed. For any fragments +committed in your git repository, git rm will be used (which will also remove +the fragments directory if now empty). .. option:: --draft diff --git a/src/towncrier/_git.py b/src/towncrier/_git.py index 94f82d62..ff3d5448 100644 --- a/src/towncrier/_git.py +++ b/src/towncrier/_git.py @@ -5,12 +5,27 @@ import os -from subprocess import STDOUT, call, check_output +from subprocess import STDOUT, CalledProcessError, call, check_output def remove_files(fragment_filenames: list[str]) -> None: - if fragment_filenames: - call(["git", "rm", "--quiet"] + fragment_filenames) + if not fragment_filenames: + return + + # Filter out files that are unknown to git + try: + git_fragments = check_output( + ["git", "ls-files"] + fragment_filenames, encoding="utf-8" + ).split("\n") + except CalledProcessError: + # we may not be in a git repository + git_fragments = [] + + git_fragments = [os.path.abspath(f) for f in git_fragments if os.path.isfile(f)] + call(["git", "rm", "--quiet", "--force"] + git_fragments) + unknown_fragments = set(fragment_filenames) - set(git_fragments) + for unknown_fragment in unknown_fragments: + os.remove(unknown_fragment) def stage_newsfile(directory: str, filename: str) -> None: diff --git a/src/towncrier/newsfragments/357.feature.rst b/src/towncrier/newsfragments/357.feature.rst new file mode 100644 index 00000000..d459d5c3 --- /dev/null +++ b/src/towncrier/newsfragments/357.feature.rst @@ -0,0 +1 @@ +``towncrier build`` now handles removing news fragments which are not part of the git repository. For example, uncommitted or unstaged files. diff --git a/src/towncrier/test/test_build.py b/src/towncrier/test/test_build.py index 5da7d828..5141fe3d 100644 --- a/src/towncrier/test/test_build.py +++ b/src/towncrier/test/test_build.py @@ -6,6 +6,7 @@ from datetime import date from pathlib import Path +from subprocess import call from textwrap import dedent from unittest.mock import patch @@ -747,8 +748,8 @@ def do_build_once_with(version, fragment_file, fragment): "--yes", ], ) - # not git repository, manually remove fragment file - Path(f"newsfragments/{fragment_file}").unlink() + # Fragment files unknown to git are removed even without a git repo + assert not Path(f"newsfragments/{fragment_file}").exists() return result results = [] @@ -845,8 +846,8 @@ def do_build_once_with(version, fragment_file, fragment): ], catch_exceptions=False, ) - # not git repository, manually remove fragment file - Path(f"newsfragments/{fragment_file}").unlink() + # Fragment files unknown to git are removed even without a git repo + assert not Path(f"newsfragments/{fragment_file}").exists() return result results = [] @@ -1530,3 +1531,55 @@ def test_orphans_in_non_showcontent_markdown(self, runner): self.assertEqual(0, result.exit_code, result.output) self.assertEqual(expected_output, result.output) + + @with_git_project() + def test_uncommitted_files(self, runner, commit): + """ + At build time, it will delete any fragment file regardless of its stage, + included files that are not part of the git reporsitory, + or are just staged or modified. + """ + # 123 is committed, 124 is modified, 125 is just added, 126 is unknown + + with open("foo/newsfragments/123.feature", "w") as f: + f.write("Adds levitation. File committed.") + with open("foo/newsfragments/124.feature", "w") as f: + f.write("Extends levitation. File modified in Git.") + + commit() + + with open("foo/newsfragments/125.feature", "w") as f: + f.write("Baz levitation. Staged file.") + with open("foo/newsfragments/126.feature", "w") as f: + f.write("Fix (literal) crash. File unknown to Git.") + + with open("foo/newsfragments/124.feature", "a") as f: + f.write(" Extended for an hour.") + call(["git", "add", "foo/newsfragments/125.feature"]) + + result = runner.invoke(_main, ["--date", "01-01-2001", "--yes"]) + + self.assertEqual(0, result.exit_code) + for fragment in ("123", "124", "125", "126"): + self.assertFalse(os.path.isfile(f"foo/newsfragments/{fragment}.feature")) + + path = "NEWS.rst" + self.assertTrue(os.path.isfile(path)) + news_contents = open(path).read() + self.assertEqual( + news_contents, + dedent( + """\ + Foo 1.2.3 (01-01-2001) + ====================== + + Features + -------- + + - Adds levitation. File committed. (#123) + - Extends levitation. File modified in Git. Extended for an hour. (#124) + - Baz levitation. Staged file. (#125) + - Fix (literal) crash. File unknown to Git. (#126) + """ + ), + ) From a5a51b13d4c3ca60cb5d01ef56bd639071ff2f74 Mon Sep 17 00:00:00 2001 From: Pradyun Gedam Date: Tue, 23 Jul 2024 12:25:13 +0100 Subject: [PATCH 51/51] Remove the explicit setting of a pygments theme (#625) This allows the underlying theme being used to provide the pygments theme instead which looks better (subjectively). --- docs/conf.py | 3 --- src/towncrier/newsfragments/625.misc | 0 2 files changed, 3 deletions(-) create mode 100644 src/towncrier/newsfragments/625.misc diff --git a/docs/conf.py b/docs/conf.py index 5f41b13d..9f2098b4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -74,9 +74,6 @@ # This patterns also effect to html_static_path and html_extra_path exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = "sphinx" - # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = False diff --git a/src/towncrier/newsfragments/625.misc b/src/towncrier/newsfragments/625.misc new file mode 100644 index 00000000..e69de29b