From 819638ee969a1aeb41eb4f11398bf7f33bd80552 Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Mon, 8 May 2023 09:36:48 +1200 Subject: [PATCH 1/6] Interactive create when no filename is passed --- docs/cli.rst | 4 +- docs/configuration.rst | 6 ++ src/towncrier/_settings/load.py | 1 + src/towncrier/create.py | 73 ++++++++++++------ src/towncrier/test/test_create.py | 124 ++++++++++++++++++++++-------- 5 files changed, 153 insertions(+), 55 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index 22697d01..b407993f 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -74,7 +74,9 @@ In the above example, it will generate ``123.bugfix.1.rst`` if ``123.bugfix.rst` .. option:: --edit - Create file and start `$EDITOR` to edit it right away.` + Create file and start ``$EDITOR`` to edit the news fragment right away. + +If you don't provide a file name, ``towncrier`` will prompt you for one, and unless you provided content, it'll also open an editor for you to write the news fragment. ``towncrier check`` diff --git a/docs/configuration.rst b/docs/configuration.rst index 7d73f508..5759f2f9 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -117,6 +117,12 @@ Top level keys ``"+"`` by default. +``create_add_extension`` + Add the ``filename`` option's extension to news fragment files created with ``towncrier create`` (if extension 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 92098f9d..c54cae43 100644 --- a/src/towncrier/_settings/load.py +++ b/src/towncrier/_settings/load.py @@ -58,6 +58,7 @@ class Config: wrap: bool = False all_bullets: bool = True orphan_prefix: str = "+" + create_add_extension: bool = True class ConfigError(Exception): diff --git a/src/towncrier/create.py b/src/towncrier/create.py index 5242e124..fcf3a3d7 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: @@ -73,7 +78,7 @@ def __main( directory: str | None, config_path: str | None, filename: str, - edit: bool, + edit: bool | None, content: str, ) -> None: """ @@ -81,6 +86,22 @@ 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: + issue = click.prompt("Issue number") + fragment_type = click.prompt( + "Fragment type", + type=click.Choice(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. @@ -91,15 +112,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( @@ -132,11 +156,12 @@ 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) @@ -144,19 +169,19 @@ def __main( 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/test/test_create.py b/src/towncrier/test/test_create.py index bb33da4c..8e01439c 100644 --- a/src/towncrier/test/test_create.py +++ b/src/towncrier/test/test_create.py @@ -3,7 +3,6 @@ import os import string - from pathlib import Path from textwrap import dedent from unittest import mock @@ -11,7 +10,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 @@ -28,7 +27,7 @@ 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) @@ -36,7 +35,7 @@ def _test_success( self.assertEqual(["123.feature.rst"], os.listdir("foo/newsfragments")) 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 +49,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): @@ -99,18 +95,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): @@ -157,6 +150,27 @@ def test_file_exists(self, runner: CliRunner): 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.""" + + 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 +208,52 @@ 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: 123 +Fragment type (feature, bugfix, doc, removal, misc): feature +Created news fragment at {expected} +""", + ) + with open(expected, "r") as f: + self.assertEqual(f.read(), "Edited content") + + @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: 123 +Fragment type (feature, bugfix, doc, removal, misc): feature +Created news fragment at {expected} +""", + ) + with open(expected, "r") as f: + self.assertEqual(f.read(), "Fixed this") + @with_isolated_runner def test_create_orphan_fragment(self, runner: CliRunner): """ @@ -218,16 +278,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,7 +307,9 @@ 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")]) + ) From 49fc0b489da7e04eb82c072d17cece80d73d5897 Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Mon, 8 May 2023 09:40:00 +1200 Subject: [PATCH 2/6] Configurable eof newline --- docs/configuration.rst | 5 +++ src/towncrier/_settings/load.py | 1 + src/towncrier/create.py | 13 +++++++- src/towncrier/test/test_create.py | 52 ++++++++++++++++++++++++++++--- 4 files changed, 65 insertions(+), 6 deletions(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index 5759f2f9..e46ee522 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -117,6 +117,11 @@ 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's extension to news fragment files created with ``towncrier create`` (if extension not explicitly provided). diff --git a/src/towncrier/_settings/load.py b/src/towncrier/_settings/load.py index c54cae43..8ebdea08 100644 --- a/src/towncrier/_settings/load.py +++ b/src/towncrier/_settings/load.py @@ -58,6 +58,7 @@ class Config: wrap: bool = False all_bullets: bool = True orphan_prefix: str = "+" + create_eof_newline: bool = True create_add_extension: bool = True diff --git a/src/towncrier/create.py b/src/towncrier/create.py index fcf3a3d7..7a6b2d1f 100644 --- a/src/towncrier/create.py +++ b/src/towncrier/create.py @@ -45,6 +45,11 @@ default=DEFAULT_CONTENT, help="Sets the content of the new fragment.", ) +@click.option( + "--eof-newline/--no-eof-newline", + default=None, + help="Ensure the content ends with an empty line.", +) @click.argument("filename", default="") def _main( ctx: click.Context, @@ -53,6 +58,7 @@ def _main( filename: str, edit: bool | None, content: str, + eof_newline: bool | None, ) -> None: """ Create a new news fragment. @@ -70,7 +76,7 @@ def _main( * .removal - a deprecation or removal of public API, * .misc - a ticket has been closed, but it is not of interest to users. """ - __main(ctx, directory, config, filename, edit, content) + __main(ctx, directory, config, filename, edit, content, eof_newline) def __main( @@ -80,11 +86,14 @@ def __main( filename: str, edit: bool | None, content: str, + eof_newline: bool | None, ) -> None: """ The main entry point. """ base_directory, config = load_config_from_options(directory, config_path) + if eof_newline is None: + eof_newline = config.create_eof_newline filename_ext = "" if config.create_add_extension: @@ -165,6 +174,8 @@ def __main( with open(segment_file, "w") as f: f.write(content) + if eof_newline and content and not content.endswith("\n"): + f.write("\n") click.echo(f"Created news fragment at {segment_file}") diff --git a/src/towncrier/test/test_create.py b/src/towncrier/test/test_create.py index 8e01439c..98c40b3a 100644 --- a/src/towncrier/test/test_create.py +++ b/src/towncrier/test/test_create.py @@ -3,6 +3,7 @@ import os import string + from pathlib import Path from textwrap import dedent from unittest import mock @@ -18,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() @@ -34,6 +40,8 @@ def _test_success( 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("\n".join(content), fh.read()) @@ -88,6 +96,40 @@ 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_content_no_eof_newline_arg(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 + --no-eof-newline argument is passed. + """ + content_line = "This is a content" + self._test_success( + content=[content_line], + additional_args=["-c", content_line, "--no-eof-newline"], + eof_newline=False, + ) + def test_message_and_edit(self): """ When creating a new message, a initial content can be passed via @@ -228,8 +270,8 @@ def test_without_filename(self, runner: CliRunner): Created news fragment at {expected} """, ) - with open(expected, "r") as f: - self.assertEqual(f.read(), "Edited content") + with open(expected) as f: + self.assertEqual(f.read(), "Edited content\n") @with_isolated_runner def test_without_filename_with_message(self, runner: CliRunner): @@ -251,8 +293,8 @@ def test_without_filename_with_message(self, runner: CliRunner): Created news fragment at {expected} """, ) - with open(expected, "r") as f: - self.assertEqual(f.read(), "Fixed this") + with open(expected) as f: + self.assertEqual(f.read(), "Fixed this\n") @with_isolated_runner def test_create_orphan_fragment(self, runner: CliRunner): From b86c6a0d883a32e4044d1908dcf7a798733877b4 Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Mon, 8 May 2023 09:41:29 +1200 Subject: [PATCH 3/6] Add changes --- src/towncrier/newsfragments/482.feature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/towncrier/newsfragments/482.feature.rst diff --git a/src/towncrier/newsfragments/482.feature.rst b/src/towncrier/newsfragments/482.feature.rst new file mode 100644 index 00000000..74c4f28c --- /dev/null +++ b/src/towncrier/newsfragments/482.feature.rst @@ -0,0 +1 @@ +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). From 24d7cb3b08aea54e3566714e080bbad6f2894e68 Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Mon, 8 May 2023 09:41:30 +1200 Subject: [PATCH 4/6] More tests for file extensions --- src/towncrier/test/test_create.py | 39 +++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/towncrier/test/test_create.py b/src/towncrier/test/test_create.py index 98c40b3a..dc4c9af1 100644 --- a/src/towncrier/test/test_create.py +++ b/src/towncrier/test/test_create.py @@ -186,6 +186,45 @@ 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_odd_filename_extension(self, runner: CliRunner): + """Ensure changelog filename extension is not used if not .rst or .md""" + setup_simple_project(extra_config='filename = "the.changes"') + 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.""" From c6b5f77ae0e391f09ac411c710545978319f6e27 Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Mon, 8 May 2023 09:41:30 +1200 Subject: [PATCH 5/6] mypy fix --- 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 7a6b2d1f..75faffa1 100644 --- a/src/towncrier/create.py +++ b/src/towncrier/create.py @@ -105,7 +105,7 @@ def __main( issue = click.prompt("Issue number") fragment_type = click.prompt( "Fragment type", - type=click.Choice(config.types), + type=click.Choice(list(config.types)), ) filename = f"{issue}.{fragment_type}" if edit is None and content == DEFAULT_CONTENT: From c742497002f3ead6ed9b884a5cd1f76446420acc Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Mon, 8 May 2023 09:46:02 +1200 Subject: [PATCH 6/6] Test interactive create for orphan news fragments --- src/towncrier/create.py | 6 +++- src/towncrier/test/test_create.py | 59 +++++++++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/towncrier/create.py b/src/towncrier/create.py index 75faffa1..a3d5a66d 100644 --- a/src/towncrier/create.py +++ b/src/towncrier/create.py @@ -102,7 +102,11 @@ def __main( filename_ext = ext if not filename: - issue = click.prompt("Issue number") + 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)), diff --git a/src/towncrier/test/test_create.py b/src/towncrier/test/test_create.py index dc4c9af1..ed0ed886 100644 --- a/src/towncrier/test/test_create.py +++ b/src/towncrier/test/test_create.py @@ -304,7 +304,62 @@ def test_without_filename(self, runner: CliRunner): expected = os.path.join(os.getcwd(), "foo", "newsfragments", "123.feature.rst") self.assertEqual( result.output, - f"""Issue number: 123 + 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} """, @@ -327,7 +382,7 @@ def test_without_filename_with_message(self, runner: CliRunner): expected = os.path.join(os.getcwd(), "foo", "newsfragments", "123.feature.rst") self.assertEqual( result.output, - f"""Issue number: 123 + f"""Issue number (`+` if none): 123 Fragment type (feature, bugfix, doc, removal, misc): feature Created news fragment at {expected} """,