diff --git a/docs/cli.rst b/docs/cli.rst index d2fa0d4b..478a88b9 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -72,6 +72,8 @@ Create a news fragment in the directory that ``towncrier`` is configured to look $ towncrier create 123.bugfix.rst +If you don't provide a file name, ``towncrier`` will prompt you for one. + ``towncrier create`` will enforce that the passed type (e.g. ``bugfix``) is valid. If the fragments directory does not exist, it will be created. @@ -91,9 +93,10 @@ If that is the entire fragment name, a random hash will be added for you:: A string to use for content. Default: an instructive placeholder. -.. option:: --edit +.. option:: --edit / --no-edit - Create file and start `$EDITOR` to edit it right away. + Whether to start ``$EDITOR`` to edit the news fragment right away. + Default: ``$EDITOR`` will be started unless you also provided content. ``towncrier check`` diff --git a/docs/configuration.rst b/docs/configuration.rst index d424e1c0..f0eaeda4 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -118,6 +118,16 @@ Top level keys ``"+"`` by default. +``create_eof_newline`` + Ensure the content of a news fragment file created with ``towncrier create`` ends with an empty line. + + ``true`` by default. + +``create_add_extension`` + Add the ``filename`` option extension to news fragment files created with ``towncrier create`` if an extension is not explicitly provided. + + ``true`` by default. + Extra top level keys for Python projects ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/towncrier/_settings/load.py b/src/towncrier/_settings/load.py index 0b845662..66a6c546 100644 --- a/src/towncrier/_settings/load.py +++ b/src/towncrier/_settings/load.py @@ -52,6 +52,8 @@ class Config: wrap: bool = False all_bullets: bool = True orphan_prefix: str = "+" + create_eof_newline: bool = True + create_add_extension: bool = True class ConfigError(ClickException): diff --git a/src/towncrier/create.py b/src/towncrier/create.py index de264121..362b2ba9 100644 --- a/src/towncrier/create.py +++ b/src/towncrier/create.py @@ -14,6 +14,9 @@ from ._settings import config_option_help, load_config_from_options +DEFAULT_CONTENT = "Add your info here" + + @click.command(name="create") @click.pass_context @click.option( @@ -32,30 +35,32 @@ ) @click.option( "--edit/--no-edit", - default=False, + default=None, help="Open an editor for writing the newsfragment content.", -) # TODO: default should be true +) @click.option( "-c", "--content", type=str, - default="Add your info here", + default=DEFAULT_CONTENT, help="Sets the content of the new fragment.", ) -@click.argument("filename") +@click.argument("filename", default="") def _main( ctx: click.Context, directory: str | None, config: str | None, filename: str, - edit: bool, + edit: bool | None, content: str, ) -> None: """ Create a new news fragment. - Create a new news fragment called FILENAME or pass the full path for a file. - Towncrier has a few standard types of news fragments, signified by the file extension. + If FILENAME is not provided, you'll be prompted to create it. + + Towncrier has a few standard types of news fragments, signified by the file + extension. \b These are: @@ -76,7 +81,7 @@ def __main( directory: str | None, config_path: str | None, filename: str, - edit: bool, + edit: bool | None, content: str, ) -> None: """ @@ -84,6 +89,26 @@ def __main( """ base_directory, config = load_config_from_options(directory, config_path) + filename_ext = "" + if config.create_add_extension: + ext = os.path.splitext(config.filename)[1] + if ext.lower() in (".rst", ".md"): + filename_ext = ext + + if not filename: + prompt = "Issue number" + # Add info about adding orphan if config is set. + if config.orphan_prefix: + prompt += f" (`{config.orphan_prefix}` if none)" + issue = click.prompt(prompt) + fragment_type = click.prompt( + "Fragment type", + type=click.Choice(list(config.types)), + ) + filename = f"{issue}.{fragment_type}" + if edit is None and content == DEFAULT_CONTENT: + edit = True + file_dir, file_basename = os.path.split(filename) if config.orphan_prefix and file_basename.startswith(f"{config.orphan_prefix}."): # Append a random hex string to the orphan news fragment base name. @@ -94,15 +119,18 @@ def __main( f"{file_basename[len(config.orphan_prefix):]}" ), ) - if len(filename.split(".")) < 2 or ( - filename.split(".")[-1] not in config.types - and filename.split(".")[-2] not in config.types + filename_parts = filename.split(".") + if len(filename_parts) < 2 or ( + filename_parts[-1] not in config.types + and filename_parts[-2] not in config.types ): raise click.BadParameter( "Expected filename '{}' to be of format '{{name}}.{{type}}', " "where '{{name}}' is an arbitrary slug and '{{type}}' is " "one of: {}".format(filename, ", ".join(config.types)) ) + if filename_parts[-1] in config.types and filename_ext: + filename += filename_ext if config.directory: fragments_directory = os.path.abspath( @@ -135,31 +163,34 @@ def __main( ) if edit: - edited_content = _get_news_content_from_user(content) - if edited_content is None: - click.echo("Abort creating news fragment.") + if content == DEFAULT_CONTENT: + content = "" + content = _get_news_content_from_user(content) + if not content: + click.echo("Aborted creating news fragment due to empty message.") ctx.exit(1) - content = edited_content with open(segment_file, "w") as f: f.write(content) + if config.create_eof_newline and content and not content.endswith("\n"): + f.write("\n") click.echo(f"Created news fragment at {segment_file}") -def _get_news_content_from_user(message: str) -> str | None: - initial_content = ( - "# Please write your news content. When finished, save the file.\n" - "# In order to abort, exit without saving.\n" - '# Lines starting with "#" are ignored.\n' - ) - initial_content += f"\n{message}\n" +def _get_news_content_from_user(message: str) -> str: + initial_content = """ +# Please write your news content. Lines starting with '#' will be ignored, and +# an empty message aborts. +""" + if message: + initial_content = f"{message}\n{initial_content}" content = click.edit(initial_content) if content is None: - return None + return message all_lines = content.split("\n") lines = [line.rstrip() for line in all_lines if not line.lstrip().startswith("#")] - return "\n".join(lines) + return "\n".join(lines).strip() if __name__ == "__main__": # pragma: no cover diff --git a/src/towncrier/newsfragments/482.feature.rst b/src/towncrier/newsfragments/482.feature.rst new file mode 100644 index 00000000..81b66604 --- /dev/null +++ b/src/towncrier/newsfragments/482.feature.rst @@ -0,0 +1,5 @@ +If no filename is given when doing ``towncrier`` create, interactively ask for the issue number and fragment type (and then launch an interactive editor for the fragment content). + +Now by default, when creating a fragment it will be appended with the ``filename`` option's extension (unless an extension is explicitly provided). For example, ``towncrier create 123.feature`` will create ``news/123.feature.rst``. This can be changed in configuration file by setting `add_extension = false`. + +A new line is now added by default to the end of the fragment contents. This can be reverted in the configuration file by setting `add_newline = false`. diff --git a/src/towncrier/test/test_create.py b/src/towncrier/test/test_create.py index 7f0e24b1..e946f201 100644 --- a/src/towncrier/test/test_create.py +++ b/src/towncrier/test/test_create.py @@ -11,7 +11,7 @@ from click.testing import CliRunner from twisted.trial.unittest import TestCase -from ..create import _main +from ..create import DEFAULT_CONTENT, _main from .helpers import setup_simple_project, with_isolated_runner @@ -19,7 +19,12 @@ class TestCli(TestCase): maxDiff = None def _test_success( - self, content=None, config=None, mkdir=True, additional_args=None + self, + content=None, + config=None, + mkdir=True, + additional_args=None, + eof_newline=True, ): runner = CliRunner() @@ -28,15 +33,17 @@ def _test_success( args = ["123.feature.rst"] if content is None: - content = ["Add your info here"] + content = [DEFAULT_CONTENT] if additional_args is not None: args.extend(additional_args) result = runner.invoke(_main, args) self.assertEqual(["123.feature.rst"], os.listdir("foo/newsfragments")) + if eof_newline: + content.append("") with open("foo/newsfragments/123.feature.rst") as fh: - self.assertEqual(content, fh.readlines()) + self.assertEqual("\n".join(content), fh.read()) self.assertEqual(0, result.exit_code) @@ -50,24 +57,21 @@ def test_directory_created(self): def test_edit_without_comments(self): """Create file with dynamic content.""" - content = ["This is line 1\n", "This is line 2"] + content = ["This is line 1", "This is line 2"] with mock.patch("click.edit") as mock_edit: - mock_edit.return_value = "".join(content) + mock_edit.return_value = "\n".join(content) self._test_success(content=content, additional_args=["--edit"]) mock_edit.assert_called_once_with( - "# Please write your news content. When finished, save the file.\n" - "# In order to abort, exit without saving.\n" - '# Lines starting with "#" are ignored.\n' - "\n" - "Add your info here\n" + "\n# Please write your news content. Lines starting " + "with '#' will be ignored, and\n# an empty message aborts.\n" ) def test_edit_with_comment(self): """Create file editly with ignored line.""" - content = ["This is line 1\n", "This is line 2"] - comment = "# I am ignored\n" + content = ["This is line 1", "This is line 2"] + comment = "# I am ignored" with mock.patch("click.edit") as mock_edit: - mock_edit.return_value = "".join(content[:1] + [comment] + content[1:]) + mock_edit.return_value = "\n".join(content[:1] + [comment] + content[1:]) self._test_success(content=content, additional_args=["--edit"]) def test_edit_abort(self): @@ -92,6 +96,27 @@ def test_content(self): content_line = "This is a content" self._test_success(content=[content_line], additional_args=["-c", content_line]) + def test_content_without_eof_newline(self): + """ + When creating a new fragment the content can be passed as a command line + argument. The text editor is not invoked, and no eof newline is added if the + config option is set. + """ + config = dedent( + """\ + [tool.towncrier] + package = "foo" + create_eof_newline = false + """ + ) + content_line = "This is a content" + self._test_success( + content=[content_line], + additional_args=["-c", content_line], + config=config, + eof_newline=False, + ) + def test_message_and_edit(self): """ When creating a new message, a initial content can be passed via @@ -99,18 +124,15 @@ def test_message_and_edit(self): text editor. """ content_line = "This is a content line" - edit_content = ["This is line 1\n", "This is line 2"] + edit_content = ["This is line 1", "This is line 2"] with mock.patch("click.edit") as mock_edit: - mock_edit.return_value = "".join(edit_content) + mock_edit.return_value = "\n".join(edit_content) self._test_success( content=edit_content, additional_args=["-c", content_line, "--edit"] ) mock_edit.assert_called_once_with( - "# Please write your news content. When finished, save the file.\n" - "# In order to abort, exit without saving.\n" - '# Lines starting with "#" are ignored.\n' - "\n" - "{content_line}\n".format(content_line=content_line) + f"{content_line}\n\n# Please write your news content. Lines starting " + "with '#' will be ignored, and\n# an empty message aborts.\n" ) def test_different_directory(self): @@ -151,12 +173,81 @@ def test_invalid_section(self): "Expected filename '123.foobar.rst' to be of format", result.output ) + @with_isolated_runner + def test_custom_extension(self, runner: CliRunner): + """Ensure we can still create fragments with custom extensions.""" + setup_simple_project() + frag_path = Path("foo", "newsfragments") + + result = runner.invoke(_main, ["123.feature.txt"]) + self.assertEqual(result.exit_code, 0, result.output) + + fragments = [f.name for f in frag_path.iterdir()] + # No '.rst' extension added. + self.assertEqual(fragments, ["123.feature.txt"]) + + @with_isolated_runner + def test_md_filename_extension(self, runner: CliRunner): + """Ensure changelog filename extension is used if .md""" + setup_simple_project(extra_config='filename = "changes.md"') + frag_path = Path("foo", "newsfragments") + + result = runner.invoke(_main, ["123.feature"]) + self.assertEqual(result.exit_code, 0, result.output) + + fragments = [f.name for f in frag_path.iterdir()] + # No '.rst' extension added. + self.assertEqual(fragments, ["123.feature.md"]) + + @with_isolated_runner + def test_no_filename_extension(self, runner: CliRunner): + """ + When the NEWS filename has no extension, new fragments are will not have an + extension added. + """ + # The name of the file where towncrier will generate + # the final release notes is named `RELEASE_NOTES` + # for this test (with no file extension). + setup_simple_project(extra_config='filename = "RELEASE_NOTES"') + frag_path = Path("foo", "newsfragments") + + result = runner.invoke(_main, ["123.feature"]) + self.assertEqual(result.exit_code, 0, result.output) + + fragments = [f.name for f in frag_path.iterdir()] + # No '.rst' extension added. + self.assertEqual(fragments, ["123.feature"]) + @with_isolated_runner def test_file_exists(self, runner: CliRunner): """Ensure we don't overwrite existing files.""" setup_simple_project() frag_path = Path("foo", "newsfragments") + for _ in range(3): + result = runner.invoke(_main, ["123.feature"]) + self.assertEqual(result.exit_code, 0, result.output) + + fragments = [f.name for f in frag_path.iterdir()] + self.assertEqual( + sorted(fragments), + [ + "123.feature.1.rst", + "123.feature.2.rst", + "123.feature.rst", + ], + ) + + @with_isolated_runner + def test_file_exists_no_ext(self, runner: CliRunner): + """ + Ensure we don't overwrite existing files with when not adding filename + extensions. + """ + + setup_simple_project(extra_config="create_add_extension = false") + frag_path = Path("foo", "newsfragments") + for _ in range(3): result = runner.invoke(_main, ["123.feature"]) self.assertEqual(result.exit_code, 0, result.output) @@ -194,6 +285,107 @@ def test_file_exists_with_ext(self, runner: CliRunner): ], ) + @with_isolated_runner + def test_without_filename(self, runner: CliRunner): + """ + When no filename is provided, the user is prompted for one. + """ + setup_simple_project() + + with mock.patch("click.edit") as mock_edit: + mock_edit.return_value = "Edited content" + result = runner.invoke(_main, input="123\nfeature\n") + self.assertFalse(result.exception, result.output) + mock_edit.assert_called_once() + expected = os.path.join(os.getcwd(), "foo", "newsfragments", "123.feature.rst") + self.assertEqual( + result.output, + f"""Issue number (`+` if none): 123 +Fragment type (feature, bugfix, doc, removal, misc): feature +Created news fragment at {expected} +""", + ) + with open(expected) as f: + self.assertEqual(f.read(), "Edited content\n") + + @with_isolated_runner + def test_without_filename_orphan(self, runner: CliRunner): + """ + The user can create an orphan fragment from the interactive prompt. + """ + setup_simple_project() + + with mock.patch("click.edit") as mock_edit: + mock_edit.return_value = "Orphan content" + result = runner.invoke(_main, input="+\nfeature\n") + self.assertFalse(result.exception, result.output) + mock_edit.assert_called_once() + expected = os.path.join(os.getcwd(), "foo", "newsfragments", "+") + self.assertTrue( + result.output.startswith( + f"""Issue number (`+` if none): + +Fragment type (feature, bugfix, doc, removal, misc): feature +Created news fragment at {expected}""" + ), + result.output, + ) + # Check that the file was created with a random name + created_line = result.output.strip().rsplit("\n", 1)[-1] + # Get file names in the newsfragments directory. + files = os.listdir(os.path.join(os.getcwd(), "foo", "newsfragments")) + # Check that the file name is in the created line. + created_fragment = created_line.split(" ")[-1] + self.assertIn(Path(created_fragment).name, files) + with open(created_fragment) as f: + self.assertEqual(f.read(), "Orphan content\n") + + @with_isolated_runner + def test_without_filename_no_orphan_config(self, runner: CliRunner): + """ + If an empty orphan prefix is set, orphan creation is turned off from interactive + prompt. + """ + setup_simple_project(extra_config='orphan_prefix = ""') + + with mock.patch("click.edit") as mock_edit: + mock_edit.return_value = "Edited content" + result = runner.invoke(_main, input="+\nfeature\n") + self.assertFalse(result.exception, result.output) + mock_edit.assert_called_once() + expected = os.path.join(os.getcwd(), "foo", "newsfragments", "+.feature.rst") + self.assertEqual( + result.output, + f"""Issue number: + +Fragment type (feature, bugfix, doc, removal, misc): feature +Created news fragment at {expected} +""", + ) + with open(expected) as f: + self.assertEqual(f.read(), "Edited content\n") + + @with_isolated_runner + def test_without_filename_with_message(self, runner: CliRunner): + """ + When no filename is provided, the user is prompted for one. If a message is + provided, the editor isn't opened and the message is used. + """ + setup_simple_project() + + with mock.patch("click.edit") as mock_edit: + result = runner.invoke(_main, ["-c", "Fixed this"], input="123\nfeature\n") + self.assertFalse(result.exception, result.output) + mock_edit.assert_not_called() + expected = os.path.join(os.getcwd(), "foo", "newsfragments", "123.feature.rst") + self.assertEqual( + result.output, + f"""Issue number (`+` if none): 123 +Fragment type (feature, bugfix, doc, removal, misc): feature +Created news fragment at {expected} +""", + ) + with open(expected) as f: + self.assertEqual(f.read(), "Fixed this\n") + @with_isolated_runner def test_create_orphan_fragment(self, runner: CliRunner): """ @@ -218,16 +410,18 @@ def test_create_orphan_fragment(self, runner: CliRunner): self.assertEqual(2, len(fragments)) change1, change2 = fragments - self.assertEqual(change1.suffix, ".feature") + self.assertEqual(change1.suffix, ".rst") self.assertTrue(change1.stem.startswith("+")) - # Length should be '+' character and 8 random hex characters. - self.assertEqual(len(change1.stem), 9) + self.assertTrue(change1.stem.endswith(".feature")) + # Length should be '+' character, 8 random hex characters, and ".feature". + self.assertEqual(len(change1.stem), 1 + 8 + len(".feature")) - self.assertEqual(change2.suffix, ".feature") + self.assertEqual(change2.suffix, ".rst") self.assertTrue(change2.stem.startswith("+")) + self.assertTrue(change2.stem.endswith(".feature")) self.assertEqual(change2.parent, sub_frag_path) - # Length should be '+' character and 8 random hex characters. - self.assertEqual(len(change2.stem), 9) + # Length should be '+' character, 8 random hex characters, and ".feature". + self.assertEqual(len(change2.stem), 1 + 8 + len(".feature")) @with_isolated_runner def test_create_orphan_fragment_custom_prefix(self, runner: CliRunner): @@ -245,10 +439,12 @@ def test_create_orphan_fragment_custom_prefix(self, runner: CliRunner): self.assertEqual(len(fragments), 1) change = fragments[0] self.assertTrue(change.stem.startswith("$$$")) - # Length should be '$$$' characters and 8 random hex characters. - self.assertEqual(len(change.stem), 11) + # Length should be '$$$' characters, 8 random hex characters, and ".feature". + self.assertEqual(len(change.stem), 3 + 8 + len(".feature")) # Check the remainder are all hex characters. - self.assertTrue(all(c in string.hexdigits for c in change.stem[3:])) + self.assertTrue( + all(c in string.hexdigits for c in change.stem[3 : -len(".feature")]) + ) @with_isolated_runner def test_in_different_dir_with_nondefault_newsfragments_directory(self, runner): @@ -282,4 +478,4 @@ def test_in_different_dir_with_nondefault_newsfragments_directory(self, runner): ) self.assertEqual(0, result.exit_code) - self.assertTrue(Path("foo/changelog.d/123.feature").exists()) + self.assertTrue(Path("foo/changelog.d/123.feature.rst").exists())