diff --git a/README.rst b/README.rst index 59cd6da7..1a7c2b29 100644 --- a/README.rst +++ b/README.rst @@ -150,7 +150,18 @@ Towncrier has the following global options, which can be specified in the toml f wrap = false # Wrap text to 79 characters all_bullets = true # make all fragments bullet points -If a single file is used, the content of that file gets overwritten each time. +If ``single_file`` is set to ``true`` or unspecified, all changes will be written to a single +fixed newsfile, whose name is literally fixed as the ``filename`` option. In each run of ``towncrier build``, +content of new changes will append at the top of old content, and after ``start_string`` if the +``start_string`` already appears in the newsfile. If the corresponding ``top_line``, which is formatted +as the option 'title_format', already exists in newsfile, ``ValueError`` will be raised to remind +you "already produced newsfiles for this version". + +If ``single_file`` is set to ``false`` instead, each versioned ``towncrier build`` will generate a +separate newsfile, whose name is formatted as the patten given by option ``filename``. +For example, if ``filename="{version}-notes.rst"``, then the release note with version "7.8.9" will +be written to the file "7.8.9-notes.rst". If the newsfile already exists, its content +will be overwriten with new release note, without throwing a ``ValueError`` warning. If ``title_format`` is unspecified or an empty string, the default format will be used. If set to ``false``, no title will be created. diff --git a/src/towncrier/_writer.py b/src/towncrier/_writer.py index 4b58b20a..f167391d 100644 --- a/src/towncrier/_writer.py +++ b/src/towncrier/_writer.py @@ -26,7 +26,7 @@ def append_to_newsfile( else: existing_content = [""] - if top_line and top_line in existing_content: + if top_line and top_line in existing_content[-1]: raise ValueError("It seems you've already produced newsfiles for this version?") with open(os.path.join(directory, filename), "wb") as f: diff --git a/src/towncrier/build.py b/src/towncrier/build.py index 1f7aab66..61848498 100644 --- a/src/towncrier/build.py +++ b/src/towncrier/build.py @@ -193,8 +193,9 @@ def __main( start_string = config["start_string"] news_file = config["filename"] - if config["single_file"]: - # When single_file is enabled, the news file name changes based on the version. + if config["single_file"] is False: + # The release notes for each version are stored in a separate file. + # The name of that file is generated based on the current version and project. news_file = news_file.format( name=project_name, version=project_version, project_date=project_date ) diff --git a/src/towncrier/newsfragments/391.bugfix b/src/towncrier/newsfragments/391.bugfix new file mode 100644 index 00000000..d55ec2de --- /dev/null +++ b/src/towncrier/newsfragments/391.bugfix @@ -0,0 +1,3 @@ +The detection of duplicate release notes was fixed and recording changes of same version is no longer triggered. + +Support for having the release notes for each version in a separate file is working again. This is regression introduced in VERSION 19.9.0rc1. diff --git a/src/towncrier/test/test_build.py b/src/towncrier/test/test_build.py index bed40131..04817373 100644 --- a/src/towncrier/test/test_build.py +++ b/src/towncrier/test/test_build.py @@ -3,6 +3,7 @@ import os +from pathlib import Path from subprocess import call from textwrap import dedent @@ -500,27 +501,23 @@ def test_no_package_changelog(self): ).lstrip(), ) - def test_single_file(self): + def test_release_notes_in_separate_files(self): """ - Enabling the single file mode will write the changelog to a filename - that is formatted from the filename args. + 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() - with runner.isolated_filesystem(): - with open("pyproject.toml", "w") as f: - f.write( - '[tool.towncrier]\n single_file=true\n filename="{version}-notes.rst"' - ) - os.mkdir("newsfragments") - with open("newsfragments/123.feature", "w") as f: - f.write("Adds levitation") + def do_build_once_with(version, fragment_file, fragment): + with open(f"newsfragments/{fragment_file}", "w") as f: + f.write(fragment) result = runner.invoke( _main, [ "--version", - "7.8.9", + version, "--name", "foo", "--date", @@ -528,26 +525,72 @@ def test_single_file(self): "--yes", ], ) + # not git repository, manually remove fragment file + Path(f"newsfragments/{fragment_file}").unlink() + return result - self.assertEqual(0, result.exit_code, result.output) + results = [] + with runner.isolated_filesystem(): + with open("pyproject.toml", "w") as f: + f.write( + "\n".join( + [ + "[tool.towncrier]", + " single_file=false", + ' filename="{version}-notes.rst"', + ] + ) + ) + os.mkdir("newsfragments") + results.append( + do_build_once_with("7.8.9", "123.feature", "Adds levitation") + ) + results.append(do_build_once_with("7.9.0", "456.bugfix", "Adds catapult")) + + self.assertEqual(0, results[0].exit_code, results[0].output) + self.assertEqual(0, results[1].exit_code, results[1].output) + self.assertEqual( + 2, + len(list(Path.cwd().glob("*-notes.rst"))), + "one newfile for each build", + ) self.assertTrue(os.path.exists("7.8.9-notes.rst"), os.listdir(".")) + self.assertTrue(os.path.exists("7.9.0-notes.rst"), os.listdir(".")) + + outputs = [] with open("7.8.9-notes.rst") as f: - output = f.read() + outputs.append(f.read()) + with open("7.9.0-notes.rst") as f: + outputs.append(f.read()) - self.assertEqual( - output, - dedent( + self.assertEqual( + outputs[0], + dedent( + """ + foo 7.8.9 (01-01-2001) + ====================== + + Features + -------- + + - Adds levitation (#123) """ - foo 7.8.9 (01-01-2001) - ====================== + ).lstrip(), + ) + self.assertEqual( + outputs[1], + dedent( + """ + foo 7.9.0 (01-01-2001) + ====================== - Features - -------- + Bugfixes + -------- - - Adds levitation (#123) - """ - ).lstrip(), - ) + - Adds catapult (#456) + """ + ).lstrip(), + ) def test_singlefile_errors_and_explains_cleanly(self): """ @@ -568,27 +611,25 @@ def test_singlefile_errors_and_explains_cleanly(self): result.output, ) - def test_single_file_false(self): + def test_all_version_notes_in_a_single_file(self): """ - If formatting arguments are given in the filename arg and single_file is - false, the filename will not be formatted. + When `single_file = true` the single file is used to store the notes + for multiple versions. + + The name of the file is fixed as the literal option `filename` option + in the configuration file, instead of extrapolated with variables. """ runner = CliRunner() - with runner.isolated_filesystem(): - with open("pyproject.toml", "w") as f: - f.write( - '[tool.towncrier]\n single_file=false\n filename="{version}-notes.rst"' - ) - os.mkdir("newsfragments") - with open("newsfragments/123.feature", "w") as f: - f.write("Adds levitation") + def do_build_once_with(version, fragment_file, fragment): + with open(f"newsfragments/{fragment_file}", "w") as f: + f.write(fragment) result = runner.invoke( _main, [ "--version", - "7.8.9", + version, "--name", "foo", "--date", @@ -596,27 +637,64 @@ def test_single_file_false(self): "--yes", ], ) + # not git repository, manually remove fragment file + Path(f"newsfragments/{fragment_file}").unlink() + return result - self.assertEqual(0, result.exit_code, result.output) + results = [] + with runner.isolated_filesystem(): + with open("pyproject.toml", "w") as f: + f.write( + "\n".join( + [ + "[tool.towncrier]", + " single_file=true", + " # The `filename` variable is fixed and not formated in any way.", + ' filename="{version}-notes.rst"', + ] + ) + ) + os.mkdir("newsfragments") + results.append( + do_build_once_with("7.8.9", "123.feature", "Adds levitation") + ) + results.append(do_build_once_with("7.9.0", "456.bugfix", "Adds catapult")) + + self.assertEqual(0, results[0].exit_code, results[0].output) + self.assertEqual(0, results[1].exit_code, results[1].output) + self.assertEqual( + 1, + len(list(Path.cwd().glob("*-notes.rst"))), + "single newfile for multiple builds", + ) self.assertTrue(os.path.exists("{version}-notes.rst"), os.listdir(".")) - self.assertFalse(os.path.exists("7.8.9-notes.rst"), os.listdir(".")) + with open("{version}-notes.rst") as f: output = f.read() - self.assertEqual( - output, - dedent( - """ - foo 7.8.9 (01-01-2001) - ====================== + self.assertEqual( + output, + dedent( + """ + foo 7.9.0 (01-01-2001) + ====================== - Features - -------- + Bugfixes + -------- - - Adds levitation (#123) - """ - ).lstrip(), - ) + - Adds catapult (#456) + + + foo 7.8.9 (01-01-2001) + ====================== + + Features + -------- + + - Adds levitation (#123) + """ + ).lstrip(), + ) def test_bullet_points_false(self): """ diff --git a/src/towncrier/test/test_write.py b/src/towncrier/test/test_write.py index 5cb82e7a..628b0dec 100644 --- a/src/towncrier/test/test_write.py +++ b/src/towncrier/test/test_write.py @@ -4,14 +4,17 @@ import os from collections import OrderedDict +from pathlib import Path from textwrap import dedent import pkg_resources +from click.testing import CliRunner from twisted.trial.unittest import TestCase from .._builder import render_fragments, split_fragments from .._writer import append_to_newsfile +from ..build import _main class WritingTests(TestCase): @@ -269,3 +272,122 @@ def test_multiple_file_no_start_string(self): ) self.assertEqual(expected_output, output) + + def test_with_title_format_duplicate_version_raise(self): + """ + When `single_file` enabled as default, + and fragments of `version` already produced in the newsfile, + a duplicate `build` will throw a ValueError. + """ + runner = CliRunner() + + def do_build_once(): + with open("newsfragments/123.feature", "w") as f: + f.write("Adds levitation") + + result = runner.invoke( + _main, + [ + "--version", + "7.8.9", + "--name", + "foo", + "--date", + "01-01-2001", + "--yes", + ], + ) + return result + + # `single_file` default as true + with runner.isolated_filesystem(): + with open("pyproject.toml", "w") as f: + f.write( + dedent( + """ + [tool.towncrier] + title_format="{name} {version} ({project_date})" + filename="{version}-notes.rst" + """ + ).lstrip() + ) + with open("{version}-notes.rst", "w") as f: + f.write("Release Notes\n\n.. towncrier release notes start\n") + os.mkdir("newsfragments") + + result = do_build_once() + self.assertEqual(0, result.exit_code) + # build again with the same version + result = do_build_once() + self.assertNotEqual(0, result.exit_code) + self.assertIsInstance(result.exception, ValueError) + self.assertSubstring( + "already produced newsfiles for this version", result.exception.args[0] + ) + + def test_single_file_false_overwrite_duplicate_version(self): + """ + When `single_file` disabled, multiple newsfiles generated and + the content of which get overwritten each time. + """ + runner = CliRunner() + + def do_build_once(): + with open("newsfragments/123.feature", "w") as f: + f.write("Adds levitation") + + result = runner.invoke( + _main, + [ + "--version", + "7.8.9", + "--name", + "foo", + "--date", + "01-01-2001", + "--yes", + ], + ) + return result + + # single_file = false + with runner.isolated_filesystem(): + with open("pyproject.toml", "w") as f: + f.write( + dedent( + """ + [tool.towncrier] + single_file=false + title_format="{name} {version} ({project_date})" + filename="{version}-notes.rst" + """ + ).lstrip() + ) + os.mkdir("newsfragments") + + result = do_build_once() + self.assertEqual(0, result.exit_code) + # build again with the same version + result = do_build_once() + self.assertEqual(0, result.exit_code) + + notes = list(Path.cwd().glob("*-notes.rst")) + self.assertEqual(1, len(notes)) + self.assertEqual("7.8.9-notes.rst", notes[0].name) + + with open(notes[0]) as f: + output = f.read() + + expected_output = dedent( + """\ + foo 7.8.9 (01-01-2001) + ====================== + + Features + -------- + + - Adds levitation (#123) + """ + ) + + self.assertEqual(expected_output, output)