From e082237010a97ef6b2e2fbf6c553fe2a9bd00b7c Mon Sep 17 00:00:00 2001 From: Stefan Felkel <16918854+tirolerstefan@users.noreply.github.com> Date: Wed, 27 May 2026 08:14:17 +0200 Subject: [PATCH 01/10] feature(create): added option 'index' which allows direct access to indexed snippets, e.g. issue-1234.feat.3.md --- src/towncrier/create.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/towncrier/create.py b/src/towncrier/create.py index e78fb658..812d0bec 100644 --- a/src/towncrier/create.py +++ b/src/towncrier/create.py @@ -54,6 +54,12 @@ type=str, help="The section to create the fragment for.", ) +@click.option( + "--index", + type=click.IntRange(min=0), + default=None, + help="Optional numeric index for the fragment filename.", +) @click.argument("filename", default="") def _main( ctx: click.Context, @@ -63,6 +69,7 @@ def _main( edit: bool | None, content: str, section: str | None, + index: int | None, ) -> None: """ Create a new news fragment. @@ -83,7 +90,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, section) + __main(ctx, directory, config, filename, edit, content, section, index) def __main( @@ -94,6 +101,7 @@ def __main( edit: bool | None, content: str, section: str | None, + index: int | None, ) -> None: """ The main entry point. @@ -187,6 +195,7 @@ def __main( "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 @@ -198,15 +207,22 @@ def __main( segment_file = os.path.join(fragments_directory, filename) - retry = 0 if filename.split(".")[-1] not in config.types: filename, extra_ext = os.path.splitext(filename) else: extra_ext = "" - while os.path.exists(segment_file): - retry += 1 + + if index is None: + retry = 0 + while os.path.exists(segment_file): + retry += 1 + segment_file = os.path.join( + fragments_directory, f"{filename}.{retry}{extra_ext}" + ) + else: segment_file = os.path.join( - fragments_directory, f"{filename}.{retry}{extra_ext}" + fragments_directory, + f"{filename}{f".{index}" if index > 0 else ""}{extra_ext}", ) if edit: From b2a8f85b4014aeefe18cd17da6dfe35c8026676c Mon Sep 17 00:00:00 2001 From: Stefan Felkel <16918854+tirolerstefan@users.noreply.github.com> Date: Wed, 27 May 2026 09:21:15 +0200 Subject: [PATCH 02/10] reformatted using black --- admin/check_tag_version_match.py | 1 - docs/conf.py | 1 - noxfile.py | 1 - src/towncrier/__main__.py | 1 - src/towncrier/_settings/__init__.py | 1 - src/towncrier/_settings/load.py | 1 - src/towncrier/_writer.py | 1 - src/towncrier/build.py | 1 - src/towncrier/click_default_group.py | 2 +- src/towncrier/create.py | 1 - src/towncrier/test/test_build.py | 314 +++++++++------------------ src/towncrier/test/test_builder.py | 12 +- src/towncrier/test/test_create.py | 54 ++--- src/towncrier/test/test_hg.py | 1 - src/towncrier/test/test_project.py | 13 +- src/towncrier/test/test_settings.py | 78 +++---- src/towncrier/test/test_write.py | 28 +-- 17 files changed, 161 insertions(+), 350 deletions(-) diff --git a/admin/check_tag_version_match.py b/admin/check_tag_version_match.py index 25ae1809..0c3d87df 100644 --- a/admin/check_tag_version_match.py +++ b/admin/check_tag_version_match.py @@ -12,7 +12,6 @@ from importlib import metadata - TAG_PREFIX = "refs/tags/" if len(sys.argv) < 2: diff --git a/docs/conf.py b/docs/conf.py index f98d50c8..d6335ad6 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -34,7 +34,6 @@ from datetime import date from importlib.metadata import version - towncrier_version = version("towncrier") diff --git a/noxfile.py b/noxfile.py index 494085c7..8df551d9 100644 --- a/noxfile.py +++ b/noxfile.py @@ -4,7 +4,6 @@ import nox - nox.options.sessions = ["pre_commit", "docs", "typecheck", "tests"] nox.options.reuse_existing_virtualenvs = True nox.options.error_on_external_run = True diff --git a/src/towncrier/__main__.py b/src/towncrier/__main__.py index cf8d9378..49481253 100644 --- a/src/towncrier/__main__.py +++ b/src/towncrier/__main__.py @@ -2,5 +2,4 @@ from towncrier._shell import cli - cli() diff --git a/src/towncrier/_settings/__init__.py b/src/towncrier/_settings/__init__.py index 0f25f2f4..645d820b 100644 --- a/src/towncrier/_settings/__init__.py +++ b/src/towncrier/_settings/__init__.py @@ -4,7 +4,6 @@ from towncrier._settings import load - load_config = load.load_config ConfigError = load.ConfigError load_config_from_options = load.load_config_from_options diff --git a/src/towncrier/_settings/load.py b/src/towncrier/_settings/load.py index 0170a024..a6177957 100644 --- a/src/towncrier/_settings/load.py +++ b/src/towncrier/_settings/load.py @@ -18,7 +18,6 @@ from .._settings import fragment_types as ft - if sys.version_info < (3, 10): import importlib_resources as resources else: diff --git a/src/towncrier/_writer.py b/src/towncrier/_writer.py index 634b5f19..661a45a9 100644 --- a/src/towncrier/_writer.py +++ b/src/towncrier/_writer.py @@ -13,7 +13,6 @@ 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: diff --git a/src/towncrier/build.py b/src/towncrier/build.py index df4798c2..29c7140a 100644 --- a/src/towncrier/build.py +++ b/src/towncrier/build.py @@ -25,7 +25,6 @@ from ._settings import ConfigError, config_option_help, load_config_from_options from ._writer import append_to_newsfile - if sys.version_info < (3, 10): import importlib_resources as resources else: diff --git a/src/towncrier/click_default_group.py b/src/towncrier/click_default_group.py index a50cce0d..7d0528b3 100644 --- a/src/towncrier/click_default_group.py +++ b/src/towncrier/click_default_group.py @@ -52,11 +52,11 @@ def bar(): bar """ + import warnings import click - __all__ = ["DefaultGroup"] __version__ = "1.2.2" diff --git a/src/towncrier/create.py b/src/towncrier/create.py index 812d0bec..6894d161 100644 --- a/src/towncrier/create.py +++ b/src/towncrier/create.py @@ -17,7 +17,6 @@ from ._builder import FragmentsPath from ._settings import config_option_help, load_config_from_options - DEFAULT_CONTENT = "Add your info here" diff --git a/src/towncrier/test/test_build.py b/src/towncrier/test/test_build.py index 7ff1ecab..b364fc3e 100644 --- a/src/towncrier/test/test_build.py +++ b/src/towncrier/test/test_build.py @@ -62,8 +62,7 @@ def _test_command(self, command, runner): self.assertEqual(0, result.exit_code, result.output) self.assertEqual( result.output, - dedent( - """\ + dedent("""\ Loading template... Finding news fragments... Rendering news fragments... @@ -89,8 +88,7 @@ def _test_command(self, command, runner): - """ - ), + """), ) def test_command(self): @@ -164,12 +162,10 @@ def test_in_different_dir_config_option(self, runner): self.assertEqual(0, result.exit_code) self.assertTrue((project_dir / "NEWS.rst").exists()) - @with_project( - config=""" + @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 @@ -253,43 +249,27 @@ def test_section_and_type_sorting(self): def run_order_scenario(sections, types): with runner.isolated_filesystem(): with open("pyproject.toml", "w") as f: - f.write( - dedent( - """ + f.write(dedent(""" [tool.towncrier] package = "foo" directory = "news" - """ - ) - ) + """)) for section in sections: - f.write( - dedent( - """ + f.write(dedent(""" [[tool.towncrier.section]] path = "{section}" name = "{section}" - """.format( - section=section - ) - ) - ) + """.format(section=section))) for type_ in types: - f.write( - dedent( - """ + f.write(dedent(""" [[tool.towncrier.type]] directory = "{type_}" name = "{type_}" showcontent = true - """.format( - type_=type_ - ) - ) - ) + """.format(type_=type_))) os.mkdir("foo") with open("foo/__init__.py", "w") as f: @@ -565,8 +545,7 @@ def test_projectless_changelog(self, runner): self.assertEqual(0, result.exit_code) self.assertEqual( result.output, - dedent( - """ + dedent(""" Loading template... Finding news fragments... Rendering news fragments... @@ -584,16 +563,13 @@ def test_projectless_changelog(self, runner): - """ - ).lstrip(), + """).lstrip(), ) - @with_project( - config=""" + @with_project(config=""" [tool.towncrier] version = "7.8.9" - """ - ) + """) def test_version_in_config(self, runner): """Calling towncrier with version defined in configfile. @@ -609,8 +585,7 @@ def test_version_in_config(self, runner): self.assertEqual(0, result.exit_code, result.output) self.assertEqual( result.output, - dedent( - """ + dedent(""" Loading template... Finding news fragments... Rendering news fragments... @@ -627,16 +602,13 @@ def test_version_in_config(self, runner): - """ - ).lstrip(), + """).lstrip(), ) - @with_project( - config=""" + @with_project(config=""" [tool.towncrier] name = "ImGoProject" - """ - ) + """) def test_project_name_in_config(self, runner): """The calling towncrier with project name defined in configfile. @@ -654,8 +626,7 @@ def test_project_name_in_config(self, runner): self.assertEqual(0, result.exit_code, result.output) self.assertEqual( result.output, - dedent( - """ + dedent(""" Loading template... Finding news fragments... Rendering news fragments... @@ -672,8 +643,7 @@ def test_project_name_in_config(self, runner): - """ - ).lstrip(), + """).lstrip(), ) @with_project(config="[tool.towncrier]") @@ -697,8 +667,7 @@ def test_no_package_changelog(self, runner): self.assertEqual(0, result.exit_code, result.output) self.assertEqual( result.output, - dedent( - """ + dedent(""" Loading template... Finding news fragments... Rendering news fragments... @@ -715,17 +684,14 @@ def test_no_package_changelog(self, runner): - """ - ).lstrip(), + """).lstrip(), ) - @with_project( - config=""" + @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 @@ -774,8 +740,7 @@ def do_build_once_with(version, fragment_file, fragment): self.assertEqual( outputs[0], - dedent( - """ + dedent(""" foo 7.8.9 (01-01-2001) ====================== @@ -783,13 +748,11 @@ def do_build_once_with(version, fragment_file, fragment): -------- - Adds levitation (#123) - """ - ).lstrip(), + """).lstrip(), ) self.assertEqual( outputs[1], - dedent( - """ + dedent(""" foo 7.9.0 (01-01-2001) ====================== @@ -797,16 +760,13 @@ def do_build_once_with(version, fragment_file, fragment): -------- - Adds catapult (#456) - """ - ).lstrip(), + """).lstrip(), ) - @with_project( - config=""" + @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 @@ -883,8 +843,7 @@ def do_build_once_with(version, fragment_file, fragment): self.assertEqual( output, - dedent( - """ + dedent(""" foo 7.9.0 (01-01-2001) ====================== @@ -901,17 +860,14 @@ def do_build_once_with(version, fragment_file, fragment): -------- - Adds levitation (#123) - """ - ).lstrip(), + """).lstrip(), ) - @with_project( - config=""" + @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. @@ -947,8 +903,7 @@ def test_bullet_points_false(self, runner): self.assertEqual( output, - dedent( - """ + dedent(""" foo 7.8.9 (01-01-2001) ====================== @@ -981,17 +936,14 @@ def test_bullet_points_false(self, runner): - Hyphen based bullet list. (#125) - """ - ).lstrip(), + """).lstrip(), ) - @with_project( - config=""" + @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. @@ -1015,8 +967,7 @@ def test_title_format_custom(self, runner): ], ) - expected_output = dedent( - """\ + expected_output = dedent("""\ Loading template... Finding news fragments... Rendering news fragments... @@ -1034,20 +985,17 @@ def test_title_format_custom(self, runner): - """ - ) + """) self.assertEqual(0, result.exit_code) self.assertEqual(expected_output, result.output) - @with_project( - config=""" + @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 @@ -1076,8 +1024,7 @@ def test_title_format_custom_markdown(self, runner): ], ) - expected_output = dedent( - """\ + expected_output = dedent("""\ Loading template... Finding news fragments... Rendering news fragments... @@ -1092,20 +1039,17 @@ def test_title_format_custom_markdown(self, runner): - """ - ) + """) self.assertEqual(0, result.exit_code) self.assertEqual(expected_output, result.output) - @with_project( - config=""" + @with_project(config=""" [tool.towncrier] package = "foo" filename = "NEWS.md" title_format = "### [{project_date}] CUSTOM RELEASE for {name} version {version}" - """ - ) + """) def test_markdown_injected_after_header(self, runner): """ Test that we can inject markdown after some fixed header @@ -1145,8 +1089,7 @@ def test_markdown_injected_after_header(self, runner): self.assertEqual(0, result.exit_code, result.output) output = read("NEWS.md") - expected_output = dedent( - """ + expected_output = dedent(""" # Top title ## Section title @@ -1167,28 +1110,23 @@ def test_markdown_injected_after_header(self, runner): a footer! - """ - ) + """) self.assertEqual(expected_output, output) - @with_project( - config=""" + @with_project(config=""" [tool.towncrier] package = "foo" title_format = false template = "template.rst" - """ - ) + """) def test_title_format_false(self, runner): """ Setting the title format to false disables the explicit title. This would be used, for example, when the template creates the title itself. """ with open("template.rst", "w") as f: - f.write( - dedent( - """\ + f.write(dedent("""\ Here's a hardcoded title added by the template ============================================== {% for section in sections %} @@ -1201,9 +1139,7 @@ def test_title_format_false(self, runner): {% endfor %} {% endfor %} {% endfor %} - """ - ) - ) + """)) result = runner.invoke( _main, @@ -1219,8 +1155,7 @@ def test_title_format_false(self, runner): catch_exceptions=False, ) - expected_output = dedent( - """\ + expected_output = dedent("""\ Loading template... Finding news fragments... Rendering news fragments... @@ -1230,18 +1165,15 @@ def test_title_format_false(self, runner): Here's a hardcoded title added by the template ============================================== - """ - ) + """) self.assertEqual(0, result.exit_code) self.assertEqual(expected_output, result.output) - @with_project( - config=""" + @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 @@ -1271,8 +1203,7 @@ def test_start_string(self, runner): self.assertTrue(os.path.exists("NEWS.rst"), os.listdir(".")) output = read("NEWS.rst") - expected_output = dedent( - """\ + expected_output = dedent("""\ a line another @@ -1288,8 +1219,7 @@ def test_start_string(self, runner): a footer! - """ - ) + """) self.assertEqual(expected_output, output) @@ -1319,8 +1249,7 @@ def test_default_start_string(self, runner): self.assertEqual(0, result.exit_code, result.output) output = read("NEWS.rst") - expected_output = dedent( - """ + expected_output = dedent(""" a line another @@ -1337,18 +1266,15 @@ def test_default_start_string(self, runner): a footer! - """ - ) + """) self.assertEqual(expected_output, output) - @with_project( - config=""" + @with_project(config=""" [tool.towncrier] package = "foo" filename = "NEWS.md" - """ - ) + """) def test_default_start_string_markdown(self, runner): """ The default start string is ```` for @@ -1375,8 +1301,7 @@ def test_default_start_string_markdown(self, runner): self.assertEqual(0, result.exit_code, result.output) output = read("NEWS.md") - expected_output = dedent( - """ + expected_output = dedent(""" a line another @@ -1391,20 +1316,17 @@ def test_default_start_string_markdown(self, runner): a footer! - """ - ) + """) self.assertEqual(expected_output, output) - @with_project( - config=""" + @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, @@ -1429,8 +1351,7 @@ def test_markdown_no_name_title(self, runner): self.assertEqual(0, result.exit_code, result.output) output = read("NEWS.md") - expected_output = dedent( - """ + expected_output = dedent(""" A line @@ -1440,13 +1361,11 @@ def test_markdown_no_name_title(self, runner): ## Features - Adds levitation (#123) - """ - ) + """) self.assertEqual(expected_output, output) - @with_project( - config=""" + @with_project(config=""" [tool.towncrier] title_format = "{version} - {project_date}" template = "template.rst" @@ -1455,8 +1374,7 @@ def test_markdown_no_name_title(self, runner): 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. @@ -1465,9 +1383,7 @@ def test_with_topline_and_template_and_draft(self, runner): with open("newsfragments/123.feature", "w") as f: f.write("Adds levitation") with open("template.rst", "w") as f: - f.write( - dedent( - """\ + f.write(dedent("""\ {% for section in sections %} {% set underline = "-" %} {% for category, val in definitions.items() if category in sections[section] %} @@ -1478,9 +1394,7 @@ def test_with_topline_and_template_and_draft(self, runner): {% endfor %} {% endfor %} {% endfor %} - """ - ) - ) + """)) result = runner.invoke( _main, @@ -1492,8 +1406,7 @@ def test_with_topline_and_template_and_draft(self, runner): ], ) - expected_output = dedent( - """\ + expected_output = dedent("""\ Loading template... Finding news fragments... Rendering news fragments... @@ -1506,17 +1419,14 @@ 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=""" + @with_project(config=""" [tool.towncrier] - """ - ) + """) def test_orphans_in_non_showcontent(self, runner): """ When ``showcontent`` is false (like in the ``misc`` category by default), @@ -1541,8 +1451,7 @@ def test_orphans_in_non_showcontent(self, runner): ], ) - expected_output = dedent( - """\ + expected_output = dedent("""\ Loading template... Finding news fragments... Rendering news fragments... @@ -1561,18 +1470,15 @@ def test_orphans_in_non_showcontent(self, runner): - """ - ) + """) self.assertEqual(0, result.exit_code, result.output) self.assertEqual(expected_output, result.output) - @with_project( - config=""" + @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), @@ -1597,8 +1503,7 @@ def test_orphans_in_non_showcontent_markdown(self, runner): ], ) - expected_output = dedent( - """\ + expected_output = dedent("""\ Loading template... Finding news fragments... Rendering news fragments... @@ -1615,8 +1520,7 @@ def test_orphans_in_non_showcontent_markdown(self, runner): - """ - ) + """) self.assertEqual(0, result.exit_code, result.output) self.assertEqual(expected_output, result.output) @@ -1657,8 +1561,7 @@ def test_uncommitted_files(self, runner, commit): news_contents = open(path).read() self.assertEqual( news_contents, - dedent( - """\ + dedent("""\ Foo 1.2.3 (01-01-2001) ====================== @@ -1669,17 +1572,14 @@ def test_uncommitted_files(self, runner, commit): - Extends levitation. File modified in Git. Extended for an hour. (#124) - Baz levitation. Staged file. (#125) - Fix (literal) crash. File unknown to Git. (#126) - """ - ), + """), ) - @with_project( - config=""" + @with_project(config=""" [tool.towncrier] package = "foo" ignore = ["template.jinja", "CAPYBARAS.md", "seq_wildcard_[ab]"] - """ - ) + """) def test_ignored_files(self, runner): """ When `ignore` is set in config, files with those names are ignored. @@ -1699,13 +1599,11 @@ def test_ignored_files(self, runner): result = runner.invoke(_main, ["--draft"]) self.assertEqual(0, result.exit_code, result.output) - @with_project( - config=""" + @with_project(config=""" [tool.towncrier] package = "foo" ignore = [] - """ - ) + """) def test_invalid_fragment_name(self, runner): """ When `ignore` is set in config, invalid filenames cause failure. @@ -1719,14 +1617,12 @@ def test_invalid_fragment_name(self, runner): self.assertEqual(1, result.exit_code, result.output) self.assertIn("Invalid news fragment name: feature.124", result.output) - @with_project( - config=""" + @with_project(config=""" [tool.towncrier] package = "foo" template = "foo/newsfragments/template.j2" ignore = ["placeholder-to-trigger-strict-checks.txt"] - """ - ) + """) def test_ignore_template_filename(self, runner): """ The `template` filename is automatically ignored when it @@ -1736,8 +1632,7 @@ def test_ignore_template_filename(self, runner): f.write("Brand new thing.") with open("foo/newsfragments/template.j2", "w") as f: # Just a simple template to check that the file is rendered. - f.write( - """ + f.write(""" {% for section, _ in sections.items() %} {% for category, val in definitions.items() if category in sections[section]%} {{ definitions[category]['name'] }} @@ -1748,8 +1643,7 @@ def test_ignore_template_filename(self, runner): {% endfor %} {% endfor %} -""" - ) +""") result = runner.invoke(_main, ["--draft"]) self.assertEqual(0, result.exit_code, result.output) @@ -1771,8 +1665,7 @@ def test_no_ignore_configured(self, runner): ) self.assertEqual(0, result.exit_code, result.output) - @with_project( - config=""" + @with_project(config=""" [tool.towncrier] package = "foo" title_format = "{version} - {project_date}" @@ -1781,8 +1674,7 @@ def test_no_ignore_configured(self, runner): directory = "feature" name = "Feature" # showcontent is not defined in TOML - """ - ) + """) def test_showcontent_default_toml_array(self, runner): """ When configuring custom fragment types with a TOML array @@ -1793,8 +1685,7 @@ def test_showcontent_default_toml_array(self, runner): _main, ["--date", "01-01-2001", "--version", "1.0.0", "--yes"] ) news = read("NEWS.rst") - expected = textwrap.dedent( - """\ + expected = textwrap.dedent("""\ 1.0.0 - 01-01-2001 ================== @@ -1802,13 +1693,11 @@ def test_showcontent_default_toml_array(self, runner): ------- - An exciting new feature! - """ - ) + """) self.assertEqual(0, result.exit_code, result.output) self.assertEqual(expected, news, news) - @with_project( - config=""" + @with_project(config=""" [tool.towncrier] package = "foo" title_format = "{version} - {project_date}" @@ -1821,8 +1710,7 @@ def test_showcontent_default_toml_array(self, runner): [[tool.towncrier.type]] directory = "deps" name = "Dependency" - """ - ) + """) def test_directory_default_toml_array(self, runner): """ When configuring custom fragment types with a TOML array @@ -1835,8 +1723,7 @@ def test_directory_default_toml_array(self, runner): _main, ["--date", "01-01-2001", "--version", "1.0.0", "--yes"] ) news = read("NEWS.rst") - expected = textwrap.dedent( - """\ + expected = textwrap.dedent("""\ 1.0.0 - 01-01-2001 ================== @@ -1850,7 +1737,6 @@ def test_directory_default_toml_array(self, runner): ---------- - We bumped our dependencies. - """ - ) + """) self.assertEqual(0, result.exit_code, result.output) self.assertEqual(expected, news, news) diff --git a/src/towncrier/test/test_builder.py b/src/towncrier/test/test_builder.py index 31108fdf..10334b47 100644 --- a/src/towncrier/test/test_builder.py +++ b/src/towncrier/test/test_builder.py @@ -148,8 +148,7 @@ class TestNewsFragmentsOrdering(TestCase): fragments within a section. """ - template = dedent( - """ + template = dedent(""" {% for section_name, category in sections.items() %} {% if section_name %}# {{ section_name }}{% endif %} {%- for category_name, issues in category.items() %} @@ -160,8 +159,7 @@ class TestNewsFragmentsOrdering(TestCase): {% endfor %} {% endfor -%} {% endfor -%} - """ - ) + """) def render(self, fragments): return render_fragments( @@ -198,13 +196,11 @@ def test_ordering(self): ) # "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( - """ + 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 -""" - ) +""") diff --git a/src/towncrier/test/test_create.py b/src/towncrier/test/test_create.py index dc6f6b9d..6ae72057 100644 --- a/src/towncrier/test/test_create.py +++ b/src/towncrier/test/test_create.py @@ -98,13 +98,11 @@ def test_edit_markdown_extension(self): mock_edit.return_value = "This is line 1" self._test_success( content=["This is line 1"], - config=dedent( - """\ + config=dedent("""\ [tool.towncrier] package = "foo" filename = "README.md" - """ - ), + """), additional_args=["--edit"], ) mock_edit.assert_called_once_with( @@ -123,13 +121,11 @@ def test_edit_unknown_extension(self): mock_edit.return_value = "This is line 1" self._test_success( content=["This is line 1"], - config=dedent( - """\ + config=dedent("""\ [tool.towncrier] package = "foo" filename = "README.FIRST" - """ - ), + """), additional_args=["--edit"], ) mock_edit.assert_called_once_with( @@ -153,13 +149,11 @@ def test_content_without_eof_newline(self): argument. The text editor is not invoked, and no eof newline is added if the config option is set. """ - config = dedent( - """\ + config = dedent("""\ [tool.towncrier] package = "foo" create_eof_newline = false - """ - ) + """) content_line = "This is a content" self._test_success( content=[content_line], @@ -190,12 +184,10 @@ def test_message_and_edit(self): def test_different_directory(self): """Ensure non-standard directories are used.""" runner = CliRunner() - config = dedent( - """\ + config = dedent("""\ [tool.towncrier] directory = "releasenotes" - """ - ) + """) with runner.isolated_filesystem(): setup_simple_project(config=config, mkdir_newsfragments=False) @@ -374,11 +366,9 @@ def test_without_filename_orphan(self, runner: CliRunner): mock_edit.assert_called_once() expected = os.path.join(os.getcwd(), "foo", "newsfragments", "+") self.assertTrue( - result.output.startswith( - f"""Issue number (`+` if none): + + result.output.startswith(f"""Issue number (`+` if none): + Fragment type (feature, bugfix, doc, removal, misc): feature -Created news fragment at {expected}""" - ), +Created news fragment at {expected}"""), result.output, ) # Check that the file was created with a random name @@ -424,16 +414,14 @@ def test_sections(self, runner: CliRunner): 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=""" + 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") @@ -459,8 +447,7 @@ 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=""" + setup_simple_project(extra_config=""" [[tool.towncrier.section]] name = "Backend" path = "" @@ -468,8 +455,7 @@ def test_sections_without_filename(self, runner: CliRunner): [[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") @@ -498,8 +484,7 @@ 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=""" + setup_simple_project(extra_config=""" [[tool.towncrier.section]] name = "Backend" path = "" @@ -507,8 +492,7 @@ def test_sections_without_filename_with_section_option(self, runner: CliRunner): [[tool.towncrier.section]] name = "Frontend" path = "frontend" -""" - ) +""") with mock.patch("click.edit") as mock_edit: mock_edit.return_value = "Edited content" result = runner.invoke( @@ -534,8 +518,7 @@ def test_sections_all_with_paths(self, runner: CliRunner): """ When all sections have paths, the first is the default. """ - setup_simple_project( - extra_config=""" + setup_simple_project(extra_config=""" [[tool.towncrier.section]] name = "Frontend" path = "frontend" @@ -543,8 +526,7 @@ def test_sections_all_with_paths(self, runner: CliRunner): [[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") diff --git a/src/towncrier/test/test_hg.py b/src/towncrier/test/test_hg.py index d1b95fdf..1c72c405 100644 --- a/src/towncrier/test/test_hg.py +++ b/src/towncrier/test/test_hg.py @@ -15,7 +15,6 @@ from .helpers import setup_simple_project, write - hg_available = shutil.which("hg") is not None diff --git a/src/towncrier/test/test_project.py b/src/towncrier/test/test_project.py index a93ef190..5b1c6a9e 100644 --- a/src/towncrier/test/test_project.py +++ b/src/towncrier/test/test_project.py @@ -13,7 +13,6 @@ from .._shell import cli as towncrier_cli from .helpers import write - towncrier_cli.name = "towncrier" @@ -53,8 +52,7 @@ def test_incremental(self): os.makedirs(os.path.join(temp, "mytestprojinc")) with open(os.path.join(temp, "mytestprojinc", "__init__.py"), "w") as f: - f.write( - """ + f.write(""" class Version: ''' This is emulating a Version object from incremental. @@ -68,8 +66,7 @@ def base(self): return '.'.join(map(str, self.version)) __version__ = Version(1, 3, 12, "rc1") - """ - ) + """) version = get_version(temp, "mytestprojinc") self.assertEqual(version, "1.3.12rc1") @@ -88,16 +85,14 @@ def test_not_incremental(self): os.makedirs(os.path.join(temp, "mytestprojnotinc")) with open(os.path.join(temp, "mytestprojnotinc", "__init__.py"), "w") as f: - f.write( - """ + f.write(""" class WeirdVersion: def base(self, some_arg): return "shouldn't get here" __version__ = WeirdVersion() -""" - ) +""") with self.assertRaises(Exception) as e: get_version(temp, "mytestprojnotinc") diff --git a/src/towncrier/test/test_settings.py b/src/towncrier/test/test_settings.py index f46db2b2..1532aa58 100644 --- a/src/towncrier/test/test_settings.py +++ b/src/towncrier/test/test_settings.py @@ -43,13 +43,11 @@ def test_base(self): """ Test a "base config". """ - project_dir = self.mktemp_project( - pyproject_toml=""" + project_dir = self.mktemp_project(pyproject_toml=""" [tool.towncrier] package = "foobar" orphan_prefix = "~" - """ - ) + """) config = load_config(project_dir) self.assertEqual(config.package, "foobar") @@ -63,13 +61,11 @@ def test_markdown(self): If the filename references an .md file and the builtin template doesn't have an extension, add .md rather than .rst. """ - project_dir = self.mktemp_project( - pyproject_toml=""" + project_dir = self.mktemp_project(pyproject_toml=""" [tool.towncrier] package = "foobar" filename = "NEWS.md" - """ - ) + """) config = load_config(project_dir) @@ -82,14 +78,12 @@ def test_explicit_template_extension(self): If the filename references an .md file and the builtin template has an extension, don't change it. """ - project_dir = self.mktemp_project( - pyproject_toml=""" + project_dir = self.mktemp_project(pyproject_toml=""" [tool.towncrier] package = "foobar" filename = "NEWS.md" template = "towncrier:default.rst" - """ - ) + """) config = load_config(project_dir) @@ -102,13 +96,11 @@ def test_template_extended(self): resource's 'templates' package, it could also be in the specified resource directly. """ - project_dir = self.mktemp_project( - pyproject_toml=""" + project_dir = self.mktemp_project(pyproject_toml=""" [tool.towncrier] package = "foobar" template = "towncrier.templates:default.rst" - """ - ) + """) config = load_config(project_dir) @@ -118,12 +110,10 @@ def test_incorrect_single_file(self): """ single_file must be a bool. """ - project_dir = self.mktemp_project( - pyproject_toml=""" + project_dir = self.mktemp_project(pyproject_toml=""" [tool.towncrier] single_file = "a" - """ - ) + """) with self.assertRaises(ConfigError) as e: load_config(project_dir) @@ -134,12 +124,10 @@ def test_incorrect_all_bullets(self): """ all_bullets must be a bool. """ - project_dir = self.mktemp_project( - pyproject_toml=""" + project_dir = self.mktemp_project(pyproject_toml=""" [tool.towncrier] all_bullets = "a" - """ - ) + """) with self.assertRaises(ConfigError) as e: load_config(project_dir) @@ -150,12 +138,10 @@ def test_mistype_singlefile(self): """ singlefile is not accepted, single_file is. """ - project_dir = self.mktemp_project( - pyproject_toml=""" + project_dir = self.mktemp_project(pyproject_toml=""" [tool.towncrier] singlefile = "a" - """ - ) + """) with self.assertRaises(ConfigError) as e: load_config(project_dir) @@ -211,18 +197,14 @@ def test_pyproject_assert_fallback(self): This both tests when things are *only* in the pyproject.toml and default usage of the data in the towncrier.toml file. """ - pyproject_toml = dedent( - """ + pyproject_toml = dedent(""" [project] name = "foo" [tool.towncrier] - """ - ) - towncrier_toml = dedent( - """ + """) + towncrier_toml = dedent(""" [tool.towncrier] - """ - ) + """) tests = [ "", "name = '{name}'", @@ -303,12 +285,10 @@ def test_missing_template(self): """ Towncrier will raise an exception saying when it can't find a template. """ - project_dir = self.mktemp_project( - towncrier_toml=""" + project_dir = self.mktemp_project(towncrier_toml=""" [tool.towncrier] template = "foo.rst" - """ - ) + """) with self.assertRaises(ConfigError) as e: load_config(project_dir) @@ -325,12 +305,10 @@ def test_missing_template_in_towncrier(self): Towncrier will raise an exception saying when it can't find a template from the Towncrier templates. """ - project_dir = self.mktemp_project( - towncrier_toml=""" + project_dir = self.mktemp_project(towncrier_toml=""" [tool.towncrier] template = "towncrier:foo" - """ - ) + """) with self.assertRaises(ConfigError) as e: load_config(project_dir) @@ -348,8 +326,7 @@ def test_custom_types_as_tables_array_deprecated(self): This functionality is considered deprecated, but we continue to support it to keep backward compatibility. """ - project_dir = self.mktemp_project( - pyproject_toml=""" + project_dir = self.mktemp_project(pyproject_toml=""" [tool.towncrier] package = "foobar" [[tool.towncrier.type]] @@ -367,8 +344,7 @@ def test_custom_types_as_tables_array_deprecated(self): name="Automatic" showcontent=true check=false - """ - ) + """) config = load_config(project_dir) expected = [ ( @@ -405,8 +381,7 @@ def test_custom_types_as_tables(self): Custom fragment categories can be defined inside the toml config file using tables. """ - project_dir = self.mktemp_project( - pyproject_toml=""" + project_dir = self.mktemp_project(pyproject_toml=""" [tool.towncrier] package = "foobar" [tool.towncrier.fragment.feat] @@ -418,8 +393,7 @@ def test_custom_types_as_tables(self): [tool.towncrier.fragment.auto] name = "Automatic" check = false - """ - ) + """) config = load_config(project_dir) expected = { "chore": { diff --git a/src/towncrier/test/test_write.py b/src/towncrier/test/test_write.py index 563fde6f..a3a72ba2 100644 --- a/src/towncrier/test/test_write.py +++ b/src/towncrier/test/test_write.py @@ -247,12 +247,10 @@ def test_multiple_file_no_start_string(self): with open(os.path.join(tempdir, "NEWS.rst")) as f: output = f.read() - expected_output = dedent( - """\ + expected_output = dedent("""\ MyProject 1.0 (never) ===================== - """ - ) + """) self.assertEqual(expected_output, output) @@ -285,15 +283,11 @@ def do_build_once(): # `single_file` default as true with runner.isolated_filesystem(): with open("pyproject.toml", "w") as f: - f.write( - dedent( - """ + f.write(dedent(""" [tool.towncrier] title_format="{name} {version} ({project_date})" filename="{version}-notes.rst" - """ - ).lstrip() - ) + """).lstrip()) with open("{version}-notes.rst", "w") as f: f.write("Release Notes\n\n.. towncrier release notes start\n") os.mkdir("newsfragments") @@ -336,16 +330,12 @@ def do_build_once(): # single_file = false with runner.isolated_filesystem(): with open("pyproject.toml", "w") as f: - f.write( - dedent( - """ + f.write(dedent(""" [tool.towncrier] single_file=false title_format="{name} {version} ({project_date})" filename="{version}-notes.rst" - """ - ).lstrip() - ) + """).lstrip()) os.mkdir("newsfragments") result = do_build_once() @@ -361,8 +351,7 @@ def do_build_once(): with open(notes[0]) as f: output = f.read() - expected_output = dedent( - """\ + expected_output = dedent("""\ foo 7.8.9 (01-01-2001) ====================== @@ -370,7 +359,6 @@ def do_build_once(): -------- - Adds levitation (#123) - """ - ) + """) self.assertEqual(expected_output, output) From 6d2e535830290b0f03ba2f88d6aea01aa23028b1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 07:21:29 +0000 Subject: [PATCH 03/10] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- admin/check_tag_version_match.py | 1 + docs/conf.py | 1 + noxfile.py | 1 + src/towncrier/__main__.py | 1 + src/towncrier/_settings/__init__.py | 1 + src/towncrier/_settings/load.py | 1 + src/towncrier/_writer.py | 1 + src/towncrier/build.py | 1 + src/towncrier/click_default_group.py | 1 + src/towncrier/create.py | 1 + src/towncrier/test/test_build.py | 314 ++++++++++++++++++--------- src/towncrier/test/test_builder.py | 12 +- src/towncrier/test/test_create.py | 54 +++-- src/towncrier/test/test_hg.py | 1 + src/towncrier/test/test_project.py | 13 +- src/towncrier/test/test_settings.py | 78 ++++--- src/towncrier/test/test_write.py | 28 ++- 17 files changed, 350 insertions(+), 160 deletions(-) diff --git a/admin/check_tag_version_match.py b/admin/check_tag_version_match.py index 0c3d87df..25ae1809 100644 --- a/admin/check_tag_version_match.py +++ b/admin/check_tag_version_match.py @@ -12,6 +12,7 @@ from importlib import metadata + TAG_PREFIX = "refs/tags/" if len(sys.argv) < 2: diff --git a/docs/conf.py b/docs/conf.py index d6335ad6..f98d50c8 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -34,6 +34,7 @@ from datetime import date from importlib.metadata import version + towncrier_version = version("towncrier") diff --git a/noxfile.py b/noxfile.py index 8df551d9..494085c7 100644 --- a/noxfile.py +++ b/noxfile.py @@ -4,6 +4,7 @@ import nox + nox.options.sessions = ["pre_commit", "docs", "typecheck", "tests"] nox.options.reuse_existing_virtualenvs = True nox.options.error_on_external_run = True diff --git a/src/towncrier/__main__.py b/src/towncrier/__main__.py index 49481253..cf8d9378 100644 --- a/src/towncrier/__main__.py +++ b/src/towncrier/__main__.py @@ -2,4 +2,5 @@ from towncrier._shell import cli + cli() diff --git a/src/towncrier/_settings/__init__.py b/src/towncrier/_settings/__init__.py index 645d820b..0f25f2f4 100644 --- a/src/towncrier/_settings/__init__.py +++ b/src/towncrier/_settings/__init__.py @@ -4,6 +4,7 @@ from towncrier._settings import load + load_config = load.load_config ConfigError = load.ConfigError load_config_from_options = load.load_config_from_options diff --git a/src/towncrier/_settings/load.py b/src/towncrier/_settings/load.py index a6177957..0170a024 100644 --- a/src/towncrier/_settings/load.py +++ b/src/towncrier/_settings/load.py @@ -18,6 +18,7 @@ from .._settings import fragment_types as ft + if sys.version_info < (3, 10): import importlib_resources as resources else: diff --git a/src/towncrier/_writer.py b/src/towncrier/_writer.py index 661a45a9..634b5f19 100644 --- a/src/towncrier/_writer.py +++ b/src/towncrier/_writer.py @@ -13,6 +13,7 @@ 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: diff --git a/src/towncrier/build.py b/src/towncrier/build.py index 29c7140a..df4798c2 100644 --- a/src/towncrier/build.py +++ b/src/towncrier/build.py @@ -25,6 +25,7 @@ from ._settings import ConfigError, config_option_help, load_config_from_options from ._writer import append_to_newsfile + if sys.version_info < (3, 10): import importlib_resources as resources else: diff --git a/src/towncrier/click_default_group.py b/src/towncrier/click_default_group.py index 7d0528b3..db52c541 100644 --- a/src/towncrier/click_default_group.py +++ b/src/towncrier/click_default_group.py @@ -57,6 +57,7 @@ def bar(): import click + __all__ = ["DefaultGroup"] __version__ = "1.2.2" diff --git a/src/towncrier/create.py b/src/towncrier/create.py index 6894d161..812d0bec 100644 --- a/src/towncrier/create.py +++ b/src/towncrier/create.py @@ -17,6 +17,7 @@ from ._builder import FragmentsPath from ._settings import config_option_help, load_config_from_options + DEFAULT_CONTENT = "Add your info here" diff --git a/src/towncrier/test/test_build.py b/src/towncrier/test/test_build.py index b364fc3e..7ff1ecab 100644 --- a/src/towncrier/test/test_build.py +++ b/src/towncrier/test/test_build.py @@ -62,7 +62,8 @@ def _test_command(self, command, runner): self.assertEqual(0, result.exit_code, result.output) self.assertEqual( result.output, - dedent("""\ + dedent( + """\ Loading template... Finding news fragments... Rendering news fragments... @@ -88,7 +89,8 @@ def _test_command(self, command, runner): - """), + """ + ), ) def test_command(self): @@ -162,10 +164,12 @@ def test_in_different_dir_config_option(self, runner): self.assertEqual(0, result.exit_code) self.assertTrue((project_dir / "NEWS.rst").exists()) - @with_project(config=""" + @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 @@ -249,27 +253,43 @@ def test_section_and_type_sorting(self): def run_order_scenario(sections, types): with runner.isolated_filesystem(): with open("pyproject.toml", "w") as f: - f.write(dedent(""" + f.write( + dedent( + """ [tool.towncrier] package = "foo" directory = "news" - """)) + """ + ) + ) for section in sections: - f.write(dedent(""" + f.write( + dedent( + """ [[tool.towncrier.section]] path = "{section}" name = "{section}" - """.format(section=section))) + """.format( + section=section + ) + ) + ) for type_ in types: - f.write(dedent(""" + f.write( + dedent( + """ [[tool.towncrier.type]] directory = "{type_}" name = "{type_}" showcontent = true - """.format(type_=type_))) + """.format( + type_=type_ + ) + ) + ) os.mkdir("foo") with open("foo/__init__.py", "w") as f: @@ -545,7 +565,8 @@ def test_projectless_changelog(self, runner): self.assertEqual(0, result.exit_code) self.assertEqual( result.output, - dedent(""" + dedent( + """ Loading template... Finding news fragments... Rendering news fragments... @@ -563,13 +584,16 @@ def test_projectless_changelog(self, runner): - """).lstrip(), + """ + ).lstrip(), ) - @with_project(config=""" + @with_project( + config=""" [tool.towncrier] version = "7.8.9" - """) + """ + ) def test_version_in_config(self, runner): """Calling towncrier with version defined in configfile. @@ -585,7 +609,8 @@ def test_version_in_config(self, runner): self.assertEqual(0, result.exit_code, result.output) self.assertEqual( result.output, - dedent(""" + dedent( + """ Loading template... Finding news fragments... Rendering news fragments... @@ -602,13 +627,16 @@ def test_version_in_config(self, runner): - """).lstrip(), + """ + ).lstrip(), ) - @with_project(config=""" + @with_project( + config=""" [tool.towncrier] name = "ImGoProject" - """) + """ + ) def test_project_name_in_config(self, runner): """The calling towncrier with project name defined in configfile. @@ -626,7 +654,8 @@ def test_project_name_in_config(self, runner): self.assertEqual(0, result.exit_code, result.output) self.assertEqual( result.output, - dedent(""" + dedent( + """ Loading template... Finding news fragments... Rendering news fragments... @@ -643,7 +672,8 @@ def test_project_name_in_config(self, runner): - """).lstrip(), + """ + ).lstrip(), ) @with_project(config="[tool.towncrier]") @@ -667,7 +697,8 @@ def test_no_package_changelog(self, runner): self.assertEqual(0, result.exit_code, result.output) self.assertEqual( result.output, - dedent(""" + dedent( + """ Loading template... Finding news fragments... Rendering news fragments... @@ -684,14 +715,17 @@ def test_no_package_changelog(self, runner): - """).lstrip(), + """ + ).lstrip(), ) - @with_project(config=""" + @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 @@ -740,7 +774,8 @@ def do_build_once_with(version, fragment_file, fragment): self.assertEqual( outputs[0], - dedent(""" + dedent( + """ foo 7.8.9 (01-01-2001) ====================== @@ -748,11 +783,13 @@ def do_build_once_with(version, fragment_file, fragment): -------- - Adds levitation (#123) - """).lstrip(), + """ + ).lstrip(), ) self.assertEqual( outputs[1], - dedent(""" + dedent( + """ foo 7.9.0 (01-01-2001) ====================== @@ -760,13 +797,16 @@ def do_build_once_with(version, fragment_file, fragment): -------- - Adds catapult (#456) - """).lstrip(), + """ + ).lstrip(), ) - @with_project(config=""" + @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 @@ -843,7 +883,8 @@ def do_build_once_with(version, fragment_file, fragment): self.assertEqual( output, - dedent(""" + dedent( + """ foo 7.9.0 (01-01-2001) ====================== @@ -860,14 +901,17 @@ def do_build_once_with(version, fragment_file, fragment): -------- - Adds levitation (#123) - """).lstrip(), + """ + ).lstrip(), ) - @with_project(config=""" + @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. @@ -903,7 +947,8 @@ def test_bullet_points_false(self, runner): self.assertEqual( output, - dedent(""" + dedent( + """ foo 7.8.9 (01-01-2001) ====================== @@ -936,14 +981,17 @@ def test_bullet_points_false(self, runner): - Hyphen based bullet list. (#125) - """).lstrip(), + """ + ).lstrip(), ) - @with_project(config=""" + @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. @@ -967,7 +1015,8 @@ def test_title_format_custom(self, runner): ], ) - expected_output = dedent("""\ + expected_output = dedent( + """\ Loading template... Finding news fragments... Rendering news fragments... @@ -985,17 +1034,20 @@ def test_title_format_custom(self, runner): - """) + """ + ) self.assertEqual(0, result.exit_code) self.assertEqual(expected_output, result.output) - @with_project(config=""" + @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 @@ -1024,7 +1076,8 @@ def test_title_format_custom_markdown(self, runner): ], ) - expected_output = dedent("""\ + expected_output = dedent( + """\ Loading template... Finding news fragments... Rendering news fragments... @@ -1039,17 +1092,20 @@ def test_title_format_custom_markdown(self, runner): - """) + """ + ) self.assertEqual(0, result.exit_code) self.assertEqual(expected_output, result.output) - @with_project(config=""" + @with_project( + config=""" [tool.towncrier] package = "foo" filename = "NEWS.md" title_format = "### [{project_date}] CUSTOM RELEASE for {name} version {version}" - """) + """ + ) def test_markdown_injected_after_header(self, runner): """ Test that we can inject markdown after some fixed header @@ -1089,7 +1145,8 @@ def test_markdown_injected_after_header(self, runner): self.assertEqual(0, result.exit_code, result.output) output = read("NEWS.md") - expected_output = dedent(""" + expected_output = dedent( + """ # Top title ## Section title @@ -1110,23 +1167,28 @@ def test_markdown_injected_after_header(self, runner): a footer! - """) + """ + ) self.assertEqual(expected_output, output) - @with_project(config=""" + @with_project( + config=""" [tool.towncrier] package = "foo" title_format = false template = "template.rst" - """) + """ + ) def test_title_format_false(self, runner): """ Setting the title format to false disables the explicit title. This would be used, for example, when the template creates the title itself. """ with open("template.rst", "w") as f: - f.write(dedent("""\ + f.write( + dedent( + """\ Here's a hardcoded title added by the template ============================================== {% for section in sections %} @@ -1139,7 +1201,9 @@ def test_title_format_false(self, runner): {% endfor %} {% endfor %} {% endfor %} - """)) + """ + ) + ) result = runner.invoke( _main, @@ -1155,7 +1219,8 @@ def test_title_format_false(self, runner): catch_exceptions=False, ) - expected_output = dedent("""\ + expected_output = dedent( + """\ Loading template... Finding news fragments... Rendering news fragments... @@ -1165,15 +1230,18 @@ def test_title_format_false(self, runner): Here's a hardcoded title added by the template ============================================== - """) + """ + ) self.assertEqual(0, result.exit_code) self.assertEqual(expected_output, result.output) - @with_project(config=""" + @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 @@ -1203,7 +1271,8 @@ def test_start_string(self, runner): self.assertTrue(os.path.exists("NEWS.rst"), os.listdir(".")) output = read("NEWS.rst") - expected_output = dedent("""\ + expected_output = dedent( + """\ a line another @@ -1219,7 +1288,8 @@ def test_start_string(self, runner): a footer! - """) + """ + ) self.assertEqual(expected_output, output) @@ -1249,7 +1319,8 @@ def test_default_start_string(self, runner): self.assertEqual(0, result.exit_code, result.output) output = read("NEWS.rst") - expected_output = dedent(""" + expected_output = dedent( + """ a line another @@ -1266,15 +1337,18 @@ def test_default_start_string(self, runner): a footer! - """) + """ + ) self.assertEqual(expected_output, output) - @with_project(config=""" + @with_project( + config=""" [tool.towncrier] package = "foo" filename = "NEWS.md" - """) + """ + ) def test_default_start_string_markdown(self, runner): """ The default start string is ```` for @@ -1301,7 +1375,8 @@ def test_default_start_string_markdown(self, runner): self.assertEqual(0, result.exit_code, result.output) output = read("NEWS.md") - expected_output = dedent(""" + expected_output = dedent( + """ a line another @@ -1316,17 +1391,20 @@ def test_default_start_string_markdown(self, runner): a footer! - """) + """ + ) self.assertEqual(expected_output, output) - @with_project(config=""" + @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, @@ -1351,7 +1429,8 @@ def test_markdown_no_name_title(self, runner): self.assertEqual(0, result.exit_code, result.output) output = read("NEWS.md") - expected_output = dedent(""" + expected_output = dedent( + """ A line @@ -1361,11 +1440,13 @@ def test_markdown_no_name_title(self, runner): ## Features - Adds levitation (#123) - """) + """ + ) self.assertEqual(expected_output, output) - @with_project(config=""" + @with_project( + config=""" [tool.towncrier] title_format = "{version} - {project_date}" template = "template.rst" @@ -1374,7 +1455,8 @@ def test_markdown_no_name_title(self, runner): 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. @@ -1383,7 +1465,9 @@ def test_with_topline_and_template_and_draft(self, runner): with open("newsfragments/123.feature", "w") as f: f.write("Adds levitation") with open("template.rst", "w") as f: - f.write(dedent("""\ + f.write( + dedent( + """\ {% for section in sections %} {% set underline = "-" %} {% for category, val in definitions.items() if category in sections[section] %} @@ -1394,7 +1478,9 @@ def test_with_topline_and_template_and_draft(self, runner): {% endfor %} {% endfor %} {% endfor %} - """)) + """ + ) + ) result = runner.invoke( _main, @@ -1406,7 +1492,8 @@ def test_with_topline_and_template_and_draft(self, runner): ], ) - expected_output = dedent("""\ + expected_output = dedent( + """\ Loading template... Finding news fragments... Rendering news fragments... @@ -1419,14 +1506,17 @@ 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=""" + @with_project( + config=""" [tool.towncrier] - """) + """ + ) def test_orphans_in_non_showcontent(self, runner): """ When ``showcontent`` is false (like in the ``misc`` category by default), @@ -1451,7 +1541,8 @@ def test_orphans_in_non_showcontent(self, runner): ], ) - expected_output = dedent("""\ + expected_output = dedent( + """\ Loading template... Finding news fragments... Rendering news fragments... @@ -1470,15 +1561,18 @@ def test_orphans_in_non_showcontent(self, runner): - """) + """ + ) self.assertEqual(0, result.exit_code, result.output) self.assertEqual(expected_output, result.output) - @with_project(config=""" + @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), @@ -1503,7 +1597,8 @@ def test_orphans_in_non_showcontent_markdown(self, runner): ], ) - expected_output = dedent("""\ + expected_output = dedent( + """\ Loading template... Finding news fragments... Rendering news fragments... @@ -1520,7 +1615,8 @@ def test_orphans_in_non_showcontent_markdown(self, runner): - """) + """ + ) self.assertEqual(0, result.exit_code, result.output) self.assertEqual(expected_output, result.output) @@ -1561,7 +1657,8 @@ def test_uncommitted_files(self, runner, commit): news_contents = open(path).read() self.assertEqual( news_contents, - dedent("""\ + dedent( + """\ Foo 1.2.3 (01-01-2001) ====================== @@ -1572,14 +1669,17 @@ def test_uncommitted_files(self, runner, commit): - Extends levitation. File modified in Git. Extended for an hour. (#124) - Baz levitation. Staged file. (#125) - Fix (literal) crash. File unknown to Git. (#126) - """), + """ + ), ) - @with_project(config=""" + @with_project( + config=""" [tool.towncrier] package = "foo" ignore = ["template.jinja", "CAPYBARAS.md", "seq_wildcard_[ab]"] - """) + """ + ) def test_ignored_files(self, runner): """ When `ignore` is set in config, files with those names are ignored. @@ -1599,11 +1699,13 @@ def test_ignored_files(self, runner): result = runner.invoke(_main, ["--draft"]) self.assertEqual(0, result.exit_code, result.output) - @with_project(config=""" + @with_project( + config=""" [tool.towncrier] package = "foo" ignore = [] - """) + """ + ) def test_invalid_fragment_name(self, runner): """ When `ignore` is set in config, invalid filenames cause failure. @@ -1617,12 +1719,14 @@ def test_invalid_fragment_name(self, runner): self.assertEqual(1, result.exit_code, result.output) self.assertIn("Invalid news fragment name: feature.124", result.output) - @with_project(config=""" + @with_project( + config=""" [tool.towncrier] package = "foo" template = "foo/newsfragments/template.j2" ignore = ["placeholder-to-trigger-strict-checks.txt"] - """) + """ + ) def test_ignore_template_filename(self, runner): """ The `template` filename is automatically ignored when it @@ -1632,7 +1736,8 @@ def test_ignore_template_filename(self, runner): f.write("Brand new thing.") with open("foo/newsfragments/template.j2", "w") as f: # Just a simple template to check that the file is rendered. - f.write(""" + f.write( + """ {% for section, _ in sections.items() %} {% for category, val in definitions.items() if category in sections[section]%} {{ definitions[category]['name'] }} @@ -1643,7 +1748,8 @@ def test_ignore_template_filename(self, runner): {% endfor %} {% endfor %} -""") +""" + ) result = runner.invoke(_main, ["--draft"]) self.assertEqual(0, result.exit_code, result.output) @@ -1665,7 +1771,8 @@ def test_no_ignore_configured(self, runner): ) self.assertEqual(0, result.exit_code, result.output) - @with_project(config=""" + @with_project( + config=""" [tool.towncrier] package = "foo" title_format = "{version} - {project_date}" @@ -1674,7 +1781,8 @@ def test_no_ignore_configured(self, runner): directory = "feature" name = "Feature" # showcontent is not defined in TOML - """) + """ + ) def test_showcontent_default_toml_array(self, runner): """ When configuring custom fragment types with a TOML array @@ -1685,7 +1793,8 @@ def test_showcontent_default_toml_array(self, runner): _main, ["--date", "01-01-2001", "--version", "1.0.0", "--yes"] ) news = read("NEWS.rst") - expected = textwrap.dedent("""\ + expected = textwrap.dedent( + """\ 1.0.0 - 01-01-2001 ================== @@ -1693,11 +1802,13 @@ def test_showcontent_default_toml_array(self, runner): ------- - An exciting new feature! - """) + """ + ) self.assertEqual(0, result.exit_code, result.output) self.assertEqual(expected, news, news) - @with_project(config=""" + @with_project( + config=""" [tool.towncrier] package = "foo" title_format = "{version} - {project_date}" @@ -1710,7 +1821,8 @@ def test_showcontent_default_toml_array(self, runner): [[tool.towncrier.type]] directory = "deps" name = "Dependency" - """) + """ + ) def test_directory_default_toml_array(self, runner): """ When configuring custom fragment types with a TOML array @@ -1723,7 +1835,8 @@ def test_directory_default_toml_array(self, runner): _main, ["--date", "01-01-2001", "--version", "1.0.0", "--yes"] ) news = read("NEWS.rst") - expected = textwrap.dedent("""\ + expected = textwrap.dedent( + """\ 1.0.0 - 01-01-2001 ================== @@ -1737,6 +1850,7 @@ def test_directory_default_toml_array(self, runner): ---------- - We bumped our dependencies. - """) + """ + ) self.assertEqual(0, result.exit_code, result.output) self.assertEqual(expected, news, news) diff --git a/src/towncrier/test/test_builder.py b/src/towncrier/test/test_builder.py index 10334b47..31108fdf 100644 --- a/src/towncrier/test/test_builder.py +++ b/src/towncrier/test/test_builder.py @@ -148,7 +148,8 @@ class TestNewsFragmentsOrdering(TestCase): fragments within a section. """ - template = dedent(""" + template = dedent( + """ {% for section_name, category in sections.items() %} {% if section_name %}# {{ section_name }}{% endif %} {%- for category_name, issues in category.items() %} @@ -159,7 +160,8 @@ class TestNewsFragmentsOrdering(TestCase): {% endfor %} {% endfor -%} {% endfor -%} - """) + """ + ) def render(self, fragments): return render_fragments( @@ -196,11 +198,13 @@ def test_ordering(self): ) # "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(""" + 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 -""") +""" + ) diff --git a/src/towncrier/test/test_create.py b/src/towncrier/test/test_create.py index 6ae72057..dc6f6b9d 100644 --- a/src/towncrier/test/test_create.py +++ b/src/towncrier/test/test_create.py @@ -98,11 +98,13 @@ def test_edit_markdown_extension(self): mock_edit.return_value = "This is line 1" self._test_success( content=["This is line 1"], - config=dedent("""\ + config=dedent( + """\ [tool.towncrier] package = "foo" filename = "README.md" - """), + """ + ), additional_args=["--edit"], ) mock_edit.assert_called_once_with( @@ -121,11 +123,13 @@ def test_edit_unknown_extension(self): mock_edit.return_value = "This is line 1" self._test_success( content=["This is line 1"], - config=dedent("""\ + config=dedent( + """\ [tool.towncrier] package = "foo" filename = "README.FIRST" - """), + """ + ), additional_args=["--edit"], ) mock_edit.assert_called_once_with( @@ -149,11 +153,13 @@ def test_content_without_eof_newline(self): argument. The text editor is not invoked, and no eof newline is added if the config option is set. """ - config = dedent("""\ + config = dedent( + """\ [tool.towncrier] package = "foo" create_eof_newline = false - """) + """ + ) content_line = "This is a content" self._test_success( content=[content_line], @@ -184,10 +190,12 @@ def test_message_and_edit(self): def test_different_directory(self): """Ensure non-standard directories are used.""" runner = CliRunner() - config = dedent("""\ + config = dedent( + """\ [tool.towncrier] directory = "releasenotes" - """) + """ + ) with runner.isolated_filesystem(): setup_simple_project(config=config, mkdir_newsfragments=False) @@ -366,9 +374,11 @@ def test_without_filename_orphan(self, runner: CliRunner): mock_edit.assert_called_once() expected = os.path.join(os.getcwd(), "foo", "newsfragments", "+") self.assertTrue( - result.output.startswith(f"""Issue number (`+` if none): + + result.output.startswith( + f"""Issue number (`+` if none): + Fragment type (feature, bugfix, doc, removal, misc): feature -Created news fragment at {expected}"""), +Created news fragment at {expected}""" + ), result.output, ) # Check that the file was created with a random name @@ -414,14 +424,16 @@ def test_sections(self, runner: CliRunner): 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=""" + 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") @@ -447,7 +459,8 @@ 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=""" + setup_simple_project( + extra_config=""" [[tool.towncrier.section]] name = "Backend" path = "" @@ -455,7 +468,8 @@ def test_sections_without_filename(self, runner: CliRunner): [[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") @@ -484,7 +498,8 @@ 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=""" + setup_simple_project( + extra_config=""" [[tool.towncrier.section]] name = "Backend" path = "" @@ -492,7 +507,8 @@ def test_sections_without_filename_with_section_option(self, runner: CliRunner): [[tool.towncrier.section]] name = "Frontend" path = "frontend" -""") +""" + ) with mock.patch("click.edit") as mock_edit: mock_edit.return_value = "Edited content" result = runner.invoke( @@ -518,7 +534,8 @@ def test_sections_all_with_paths(self, runner: CliRunner): """ When all sections have paths, the first is the default. """ - setup_simple_project(extra_config=""" + setup_simple_project( + extra_config=""" [[tool.towncrier.section]] name = "Frontend" path = "frontend" @@ -526,7 +543,8 @@ def test_sections_all_with_paths(self, runner: CliRunner): [[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") diff --git a/src/towncrier/test/test_hg.py b/src/towncrier/test/test_hg.py index 1c72c405..d1b95fdf 100644 --- a/src/towncrier/test/test_hg.py +++ b/src/towncrier/test/test_hg.py @@ -15,6 +15,7 @@ from .helpers import setup_simple_project, write + hg_available = shutil.which("hg") is not None diff --git a/src/towncrier/test/test_project.py b/src/towncrier/test/test_project.py index 5b1c6a9e..a93ef190 100644 --- a/src/towncrier/test/test_project.py +++ b/src/towncrier/test/test_project.py @@ -13,6 +13,7 @@ from .._shell import cli as towncrier_cli from .helpers import write + towncrier_cli.name = "towncrier" @@ -52,7 +53,8 @@ def test_incremental(self): os.makedirs(os.path.join(temp, "mytestprojinc")) with open(os.path.join(temp, "mytestprojinc", "__init__.py"), "w") as f: - f.write(""" + f.write( + """ class Version: ''' This is emulating a Version object from incremental. @@ -66,7 +68,8 @@ def base(self): return '.'.join(map(str, self.version)) __version__ = Version(1, 3, 12, "rc1") - """) + """ + ) version = get_version(temp, "mytestprojinc") self.assertEqual(version, "1.3.12rc1") @@ -85,14 +88,16 @@ def test_not_incremental(self): os.makedirs(os.path.join(temp, "mytestprojnotinc")) with open(os.path.join(temp, "mytestprojnotinc", "__init__.py"), "w") as f: - f.write(""" + f.write( + """ class WeirdVersion: def base(self, some_arg): return "shouldn't get here" __version__ = WeirdVersion() -""") +""" + ) with self.assertRaises(Exception) as e: get_version(temp, "mytestprojnotinc") diff --git a/src/towncrier/test/test_settings.py b/src/towncrier/test/test_settings.py index 1532aa58..f46db2b2 100644 --- a/src/towncrier/test/test_settings.py +++ b/src/towncrier/test/test_settings.py @@ -43,11 +43,13 @@ def test_base(self): """ Test a "base config". """ - project_dir = self.mktemp_project(pyproject_toml=""" + project_dir = self.mktemp_project( + pyproject_toml=""" [tool.towncrier] package = "foobar" orphan_prefix = "~" - """) + """ + ) config = load_config(project_dir) self.assertEqual(config.package, "foobar") @@ -61,11 +63,13 @@ def test_markdown(self): If the filename references an .md file and the builtin template doesn't have an extension, add .md rather than .rst. """ - project_dir = self.mktemp_project(pyproject_toml=""" + project_dir = self.mktemp_project( + pyproject_toml=""" [tool.towncrier] package = "foobar" filename = "NEWS.md" - """) + """ + ) config = load_config(project_dir) @@ -78,12 +82,14 @@ def test_explicit_template_extension(self): If the filename references an .md file and the builtin template has an extension, don't change it. """ - project_dir = self.mktemp_project(pyproject_toml=""" + project_dir = self.mktemp_project( + pyproject_toml=""" [tool.towncrier] package = "foobar" filename = "NEWS.md" template = "towncrier:default.rst" - """) + """ + ) config = load_config(project_dir) @@ -96,11 +102,13 @@ def test_template_extended(self): resource's 'templates' package, it could also be in the specified resource directly. """ - project_dir = self.mktemp_project(pyproject_toml=""" + project_dir = self.mktemp_project( + pyproject_toml=""" [tool.towncrier] package = "foobar" template = "towncrier.templates:default.rst" - """) + """ + ) config = load_config(project_dir) @@ -110,10 +118,12 @@ def test_incorrect_single_file(self): """ single_file must be a bool. """ - project_dir = self.mktemp_project(pyproject_toml=""" + project_dir = self.mktemp_project( + pyproject_toml=""" [tool.towncrier] single_file = "a" - """) + """ + ) with self.assertRaises(ConfigError) as e: load_config(project_dir) @@ -124,10 +134,12 @@ def test_incorrect_all_bullets(self): """ all_bullets must be a bool. """ - project_dir = self.mktemp_project(pyproject_toml=""" + project_dir = self.mktemp_project( + pyproject_toml=""" [tool.towncrier] all_bullets = "a" - """) + """ + ) with self.assertRaises(ConfigError) as e: load_config(project_dir) @@ -138,10 +150,12 @@ def test_mistype_singlefile(self): """ singlefile is not accepted, single_file is. """ - project_dir = self.mktemp_project(pyproject_toml=""" + project_dir = self.mktemp_project( + pyproject_toml=""" [tool.towncrier] singlefile = "a" - """) + """ + ) with self.assertRaises(ConfigError) as e: load_config(project_dir) @@ -197,14 +211,18 @@ def test_pyproject_assert_fallback(self): This both tests when things are *only* in the pyproject.toml and default usage of the data in the towncrier.toml file. """ - pyproject_toml = dedent(""" + pyproject_toml = dedent( + """ [project] name = "foo" [tool.towncrier] - """) - towncrier_toml = dedent(""" + """ + ) + towncrier_toml = dedent( + """ [tool.towncrier] - """) + """ + ) tests = [ "", "name = '{name}'", @@ -285,10 +303,12 @@ def test_missing_template(self): """ Towncrier will raise an exception saying when it can't find a template. """ - project_dir = self.mktemp_project(towncrier_toml=""" + project_dir = self.mktemp_project( + towncrier_toml=""" [tool.towncrier] template = "foo.rst" - """) + """ + ) with self.assertRaises(ConfigError) as e: load_config(project_dir) @@ -305,10 +325,12 @@ def test_missing_template_in_towncrier(self): Towncrier will raise an exception saying when it can't find a template from the Towncrier templates. """ - project_dir = self.mktemp_project(towncrier_toml=""" + project_dir = self.mktemp_project( + towncrier_toml=""" [tool.towncrier] template = "towncrier:foo" - """) + """ + ) with self.assertRaises(ConfigError) as e: load_config(project_dir) @@ -326,7 +348,8 @@ def test_custom_types_as_tables_array_deprecated(self): This functionality is considered deprecated, but we continue to support it to keep backward compatibility. """ - project_dir = self.mktemp_project(pyproject_toml=""" + project_dir = self.mktemp_project( + pyproject_toml=""" [tool.towncrier] package = "foobar" [[tool.towncrier.type]] @@ -344,7 +367,8 @@ def test_custom_types_as_tables_array_deprecated(self): name="Automatic" showcontent=true check=false - """) + """ + ) config = load_config(project_dir) expected = [ ( @@ -381,7 +405,8 @@ def test_custom_types_as_tables(self): Custom fragment categories can be defined inside the toml config file using tables. """ - project_dir = self.mktemp_project(pyproject_toml=""" + project_dir = self.mktemp_project( + pyproject_toml=""" [tool.towncrier] package = "foobar" [tool.towncrier.fragment.feat] @@ -393,7 +418,8 @@ def test_custom_types_as_tables(self): [tool.towncrier.fragment.auto] name = "Automatic" check = false - """) + """ + ) config = load_config(project_dir) expected = { "chore": { diff --git a/src/towncrier/test/test_write.py b/src/towncrier/test/test_write.py index a3a72ba2..563fde6f 100644 --- a/src/towncrier/test/test_write.py +++ b/src/towncrier/test/test_write.py @@ -247,10 +247,12 @@ def test_multiple_file_no_start_string(self): with open(os.path.join(tempdir, "NEWS.rst")) as f: output = f.read() - expected_output = dedent("""\ + expected_output = dedent( + """\ MyProject 1.0 (never) ===================== - """) + """ + ) self.assertEqual(expected_output, output) @@ -283,11 +285,15 @@ def do_build_once(): # `single_file` default as true with runner.isolated_filesystem(): with open("pyproject.toml", "w") as f: - f.write(dedent(""" + f.write( + dedent( + """ [tool.towncrier] title_format="{name} {version} ({project_date})" filename="{version}-notes.rst" - """).lstrip()) + """ + ).lstrip() + ) with open("{version}-notes.rst", "w") as f: f.write("Release Notes\n\n.. towncrier release notes start\n") os.mkdir("newsfragments") @@ -330,12 +336,16 @@ def do_build_once(): # single_file = false with runner.isolated_filesystem(): with open("pyproject.toml", "w") as f: - f.write(dedent(""" + f.write( + dedent( + """ [tool.towncrier] single_file=false title_format="{name} {version} ({project_date})" filename="{version}-notes.rst" - """).lstrip()) + """ + ).lstrip() + ) os.mkdir("newsfragments") result = do_build_once() @@ -351,7 +361,8 @@ def do_build_once(): with open(notes[0]) as f: output = f.read() - expected_output = dedent("""\ + expected_output = dedent( + """\ foo 7.8.9 (01-01-2001) ====================== @@ -359,6 +370,7 @@ def do_build_once(): -------- - Adds levitation (#123) - """) + """ + ) self.assertEqual(expected_output, output) From d1a1712ef7a69364a17c6dabcf5ef31caa7c7c8b Mon Sep 17 00:00:00 2001 From: Stefan Felkel <16918854+tirolerstefan@users.noreply.github.com> Date: Wed, 27 May 2026 09:26:27 +0200 Subject: [PATCH 04/10] simplified f-string in f-string for older python versions --- src/towncrier/create.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/towncrier/create.py b/src/towncrier/create.py index 6894d161..952e2541 100644 --- a/src/towncrier/create.py +++ b/src/towncrier/create.py @@ -17,6 +17,7 @@ from ._builder import FragmentsPath from ._settings import config_option_help, load_config_from_options + DEFAULT_CONTENT = "Add your info here" @@ -221,7 +222,7 @@ def __main( else: segment_file = os.path.join( fragments_directory, - f"{filename}{f".{index}" if index > 0 else ""}{extra_ext}", + f"{filename}{"."+str(index) if index > 0 else ""}{extra_ext}", ) if edit: From 6fe2f500275f2c1d0f876383326dedb5a6981f66 Mon Sep 17 00:00:00 2001 From: Stefan Felkel <16918854+tirolerstefan@users.noreply.github.com> Date: Wed, 27 May 2026 09:31:51 +0200 Subject: [PATCH 05/10] fix: f-strings and same inner quotes are not support with earlier python version --- src/towncrier/create.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/towncrier/create.py b/src/towncrier/create.py index 952e2541..7e76a523 100644 --- a/src/towncrier/create.py +++ b/src/towncrier/create.py @@ -222,7 +222,7 @@ def __main( else: segment_file = os.path.join( fragments_directory, - f"{filename}{"."+str(index) if index > 0 else ""}{extra_ext}", + f"{filename}{'.'+str(index) if index > 0 else ''}{extra_ext}", ) if edit: From 68042036a8827595de21963588f241d25d3cef6e Mon Sep 17 00:00:00 2001 From: Stefan Felkel <16918854+tirolerstefan@users.noreply.github.com> Date: Wed, 27 May 2026 09:55:57 +0200 Subject: [PATCH 06/10] chore: added news fragment --- src/towncrier/newsfragments/714.feature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/towncrier/newsfragments/714.feature.rst diff --git a/src/towncrier/newsfragments/714.feature.rst b/src/towncrier/newsfragments/714.feature.rst new file mode 100644 index 00000000..46222756 --- /dev/null +++ b/src/towncrier/newsfragments/714.feature.rst @@ -0,0 +1 @@ +A new option "--index" is added. From 1900c74d89ac7f5365d6bd43a72f45e30956cc1c Mon Sep 17 00:00:00 2001 From: Stefan Felkel <16918854+tirolerstefan@users.noreply.github.com> Date: Wed, 27 May 2026 10:11:29 +0200 Subject: [PATCH 07/10] added tests --- src/towncrier/test/test_create.py | 96 +++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/src/towncrier/test/test_create.py b/src/towncrier/test/test_create.py index dc6f6b9d..5f7eefd0 100644 --- a/src/towncrier/test/test_create.py +++ b/src/towncrier/test/test_create.py @@ -667,3 +667,99 @@ 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.rst").exists()) + + @with_isolated_runner + def test_index(self, runner): + """ + testing changing files with option --index + """ + 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) + result = runner.invoke( + _main, + ( + "--config", + "pyproject.toml", + "--dir", + "foo", + "--content", + "111", + "--index", + 0, + "123.feature", + ), + ) + + self.assertEqual(0, result.exit_code) + self.assertTrue(Path("foo/changelog.d/123.feature.rst").exists()) + + with open("foo/changelog.d/123.feature.rst") as fh: + self.assertEqual("111\n", fh.read()) + + result = runner.invoke( + _main, + ( + "--config", + "pyproject.toml", + "--dir", + "foo", + "--content", + "222", + "--index", + 1, + "123.feature", + ), + ) + + self.assertEqual(0, result.exit_code) + self.assertTrue(Path("foo/changelog.d/123.feature.1.rst").exists()) + + with open("foo/changelog.d/123.feature.1.rst") as fh: + self.assertEqual("222\n", fh.read()) + + result = runner.invoke( + _main, + ( + "--config", + "pyproject.toml", + "--dir", + "foo", + "--content", + "333", + "--index", + 0, + "123.feature", + ), + ) + + self.assertEqual(0, result.exit_code) + self.assertTrue(Path("foo/changelog.d/123.feature.rst").exists()) + + with open("foo/changelog.d/123.feature.rst") as fh: + self.assertEqual("333\n", fh.read()) + + result = runner.invoke( + _main, + ( + "--config", + "pyproject.toml", + "--dir", + "foo", + "--content", + "444", + "--index", + 1, + "123.feature", + ), + ) + + self.assertEqual(0, result.exit_code) + self.assertTrue(Path("foo/changelog.d/123.feature.1.rst").exists()) + + with open("foo/changelog.d/123.feature.1.rst") as fh: + self.assertEqual("444\n", fh.read()) From cd1e9303e12eca09159aef0bff0b43e83283c288 Mon Sep 17 00:00:00 2001 From: Stefan Felkel <16918854+tirolerstefan@users.noreply.github.com> Date: Thu, 28 May 2026 06:48:20 +0200 Subject: [PATCH 08/10] create: added better metavar and help text for option '--index' --- src/towncrier/create.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/towncrier/create.py b/src/towncrier/create.py index 7e76a523..14abd921 100644 --- a/src/towncrier/create.py +++ b/src/towncrier/create.py @@ -57,8 +57,10 @@ @click.option( "--index", type=click.IntRange(min=0), + metavar="x", default=None, - help="Optional numeric index for the fragment filename.", + help="Optional numeric index of the fragment " + "(e.g. x=1 -> 'issue.feat.1.md' is accessed and overwritten)", ) @click.argument("filename", default="") def _main( From 543388ea605b3627d7d910f48833e85ba95e6fd3 Mon Sep 17 00:00:00 2001 From: Stefan Felkel <16918854+tirolerstefan@users.noreply.github.com> Date: Tue, 9 Jun 2026 08:25:58 +0200 Subject: [PATCH 09/10] after review: added doc with examples, refined changelog and made usage more compact --- docs/cli.rst | 6 ++++++ src/towncrier/create.py | 5 ++--- src/towncrier/newsfragments/714.feature.rst | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index 12baf1d8..4535033c 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -104,6 +104,12 @@ If that is the entire fragment name, a random hash will be added for you:: 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. +.. option:: --index N + + Optional numeric index of the fragment, N>=0. + e.g. x=0 -> 'issue.feat.md' is accessed and overwritten + e.g. x=1 -> 'issue.feat.1.md' is accessed and overwritten + ``towncrier check`` ------------------- diff --git a/src/towncrier/create.py b/src/towncrier/create.py index 14abd921..9e6c96b0 100644 --- a/src/towncrier/create.py +++ b/src/towncrier/create.py @@ -57,10 +57,9 @@ @click.option( "--index", type=click.IntRange(min=0), - metavar="x", + metavar="N", default=None, - help="Optional numeric index of the fragment " - "(e.g. x=1 -> 'issue.feat.1.md' is accessed and overwritten)", + help="Optional numeric index of the fragment", ) @click.argument("filename", default="") def _main( diff --git a/src/towncrier/newsfragments/714.feature.rst b/src/towncrier/newsfragments/714.feature.rst index 46222756..50b65f70 100644 --- a/src/towncrier/newsfragments/714.feature.rst +++ b/src/towncrier/newsfragments/714.feature.rst @@ -1 +1 @@ -A new option "--index" is added. +The `towncrier create` command line tool now has the `--index` option that is used when creating multiple fragments for the same issue number. From c1bdf57b129e4456fe612eb64b148ca39008bc93 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 06:28:42 +0000 Subject: [PATCH 10/10] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/towncrier/create.py | 2 +- src/towncrier/test/test_create.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/towncrier/create.py b/src/towncrier/create.py index 9e6c96b0..96cb9705 100644 --- a/src/towncrier/create.py +++ b/src/towncrier/create.py @@ -223,7 +223,7 @@ def __main( else: segment_file = os.path.join( fragments_directory, - f"{filename}{'.'+str(index) if index > 0 else ''}{extra_ext}", + f"{filename}{'.' + str(index) if index > 0 else ''}{extra_ext}", ) if edit: diff --git a/src/towncrier/test/test_create.py b/src/towncrier/test/test_create.py index 5f7eefd0..4a208c78 100644 --- a/src/towncrier/test/test_create.py +++ b/src/towncrier/test/test_create.py @@ -676,8 +676,7 @@ def test_index(self, runner): Path("pyproject.toml").write_text( # Important to customize `config.directory` because the default # already supports this scenario. - "[tool.towncrier]\n" - + 'directory = "changelog.d"\n' + "[tool.towncrier]\n" + 'directory = "changelog.d"\n' ) Path("foo/foo").mkdir(parents=True) result = runner.invoke(