From e9840b38aeb4c5274202a49fc083c2ac804abef7 Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Wed, 22 May 2024 09:31:01 +1200 Subject: [PATCH 1/8] Traverse filesystem for config --- src/towncrier/_settings/load.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/towncrier/_settings/load.py b/src/towncrier/_settings/load.py index 66a6c546..82cac554 100644 --- a/src/towncrier/_settings/load.py +++ b/src/towncrier/_settings/load.py @@ -67,7 +67,7 @@ def load_config_from_options( ) -> tuple[str, Config]: if config_path is None: if directory is None: - directory = os.getcwd() + return traverse_for_config(None) base_directory = os.path.abspath(directory) config = load_config(base_directory) @@ -85,6 +85,26 @@ def load_config_from_options( return base_directory, config +def traverse_for_config(path: str | None) -> tuple[str, Config]: + """ + Search for a configuration file in the current directory and all parent directories. + + Returns the directory containing the configuration file and the parsed configuration. + """ + start_directory = directory = os.path.abspath(path or os.getcwd()) + while True: + config = load_config(directory) + if config is not None: + return directory, config + + parent = os.path.dirname(directory) + if parent == directory: + raise ConfigError( + f"No configuration file found.\nLooked back from: {start_directory}" + ) + directory = parent + + def load_config(directory: str) -> Config | None: towncrier_toml = os.path.join(directory, "towncrier.toml") pyproject_toml = os.path.join(directory, "pyproject.toml") From 3d32f9d8c71091ef8e0a0850eb8a24cbef93f53f Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Wed, 22 May 2024 11:16:39 +1200 Subject: [PATCH 2/8] Add traversal test --- src/towncrier/test/test_build.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/towncrier/test/test_build.py b/src/towncrier/test/test_build.py index 241425e9..e23ffa61 100644 --- a/src/towncrier/test/test_build.py +++ b/src/towncrier/test/test_build.py @@ -121,11 +121,21 @@ def test_in_different_dir_dir_option(self, runner): self.assertEqual(0, result.exit_code) self.assertTrue((project_dir / "NEWS.rst").exists()) + @with_project() + def test_traverse_up_to_find_config(self, runner): + """ + If the current directory doesn't contain the configuration file, Towncrier + should traverse up the directory tree until it finds it. + """ + os.chdir("foo") + result = runner.invoke(_main, ["--draft", "--date", "01-01-2001"]) + self.assertEqual(0, result.exit_code, result.output) + @with_project() def test_in_different_dir_config_option(self, runner): """ The current working directory and the location of the configuration - don't matter as long as we pass corrct paths to the directory and the + don't matter as long as we pass correct paths to the directory and the config file. """ project_dir = Path(".").resolve() From f4230f57cda48692f752c49f350a7e2fa66cf6b3 Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Wed, 22 May 2024 11:18:29 +1200 Subject: [PATCH 3/8] Add news fragment --- src/towncrier/newsfragments/+2ac366d5.feature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/towncrier/newsfragments/+2ac366d5.feature.rst diff --git a/src/towncrier/newsfragments/+2ac366d5.feature.rst b/src/towncrier/newsfragments/+2ac366d5.feature.rst new file mode 100644 index 00000000..ca209ca5 --- /dev/null +++ b/src/towncrier/newsfragments/+2ac366d5.feature.rst @@ -0,0 +1 @@ +Running ``towncrier`` will now traverse back up directories looking for the configuration file. From 99a253eed52b7df1bee2f3032349861973cb2e9a Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Wed, 22 May 2024 12:09:22 +1200 Subject: [PATCH 4/8] Update newsfragment --- .../newsfragments/{+2ac366d5.feature.rst => 601.feature.rst} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/towncrier/newsfragments/{+2ac366d5.feature.rst => 601.feature.rst} (100%) diff --git a/src/towncrier/newsfragments/+2ac366d5.feature.rst b/src/towncrier/newsfragments/601.feature.rst similarity index 100% rename from src/towncrier/newsfragments/+2ac366d5.feature.rst rename to src/towncrier/newsfragments/601.feature.rst From 80be0150d5dee6165cf6407e73575705784e6c2d Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Thu, 23 May 2024 10:07:39 +1200 Subject: [PATCH 5/8] Add docstring to load_config_from_options and some explanatory comments of the different branch behaviour. --- src/towncrier/_settings/load.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/towncrier/_settings/load.py b/src/towncrier/_settings/load.py index 82cac554..02fc3ff3 100644 --- a/src/towncrier/_settings/load.py +++ b/src/towncrier/_settings/load.py @@ -65,19 +65,32 @@ def __init__(self, *args: str, **kwargs: str): def load_config_from_options( directory: str | None, config_path: str | None ) -> tuple[str, Config]: + """ + Load the configuration from the given directory or specific configuration file. + + Returns a tuple of the base directory and the parsed Config instance. + """ if config_path is None: if directory is None: + # Neither a directory or explicit config path was given so traverse for a + # config. return traverse_for_config(None) + # Since no explicit config path was provided, we'll look for a config in the + # given directory. base_directory = os.path.abspath(directory) config = load_config(base_directory) else: + # If an explicit config path was provided, we'll use that. config_path = os.path.abspath(config_path) + config = load_config_from_file(os.path.dirname(config_path), config_path) + + # If a directory was also provided, we'll use that as the base directory + # otherwise we'll use the directory containing the config file. if directory is None: base_directory = os.path.dirname(config_path) else: base_directory = os.path.abspath(directory) - config = load_config_from_file(os.path.dirname(config_path), config_path) if config is None: raise ConfigError(f"No configuration file found.\nLooked in: {base_directory}") From 7321151f5e378a3d7b1136cecd1cedbdb8334383 Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Thu, 23 May 2024 10:56:03 +1200 Subject: [PATCH 6/8] Simplify branching in load_config_from_options --- src/towncrier/_settings/load.py | 36 ++++++++++++----------------- src/towncrier/test/test_settings.py | 17 +++++++++++++- 2 files changed, 31 insertions(+), 22 deletions(-) diff --git a/src/towncrier/_settings/load.py b/src/towncrier/_settings/load.py index 02fc3ff3..724a7768 100644 --- a/src/towncrier/_settings/load.py +++ b/src/towncrier/_settings/load.py @@ -66,34 +66,28 @@ def load_config_from_options( directory: str | None, config_path: str | None ) -> tuple[str, Config]: """ - Load the configuration from the given directory or specific configuration file. + Load the configuration from a given directory or specific configuration file. + + Unless an explicit configuration file is given, traverse back from the given + directory looking for a configuration file. Returns a tuple of the base directory and the parsed Config instance. """ if config_path is None: - if directory is None: - # Neither a directory or explicit config path was given so traverse for a - # config. - return traverse_for_config(None) + return traverse_for_config(directory) + + config_path = os.path.abspath(config_path) - # Since no explicit config path was provided, we'll look for a config in the - # given directory. + # When a directory is provided (in addition to the config file), use it as the base + # directory. Otherwise use the directory containing the config file. + if directory is not None: base_directory = os.path.abspath(directory) - config = load_config(base_directory) else: - # If an explicit config path was provided, we'll use that. - config_path = os.path.abspath(config_path) - config = load_config_from_file(os.path.dirname(config_path), config_path) - - # If a directory was also provided, we'll use that as the base directory - # otherwise we'll use the directory containing the config file. - if directory is None: - base_directory = os.path.dirname(config_path) - else: - base_directory = os.path.abspath(directory) - - if config is None: - raise ConfigError(f"No configuration file found.\nLooked in: {base_directory}") + base_directory = os.path.dirname(config_path) + + if not os.path.isfile(config_path): + raise ConfigError(f"Configuration file '{config_path}' not found.") + config = load_config_from_file(base_directory, config_path) return base_directory, config diff --git a/src/towncrier/test/test_settings.py b/src/towncrier/test/test_settings.py index 6d1f6041..b08384a5 100644 --- a/src/towncrier/test/test_settings.py +++ b/src/towncrier/test/test_settings.py @@ -207,10 +207,25 @@ def test_load_no_config(self, runner: CliRunner): self.assertEqual( result.output, - f"No configuration file found.\nLooked in: {os.path.abspath(temp)}\n", + f"No configuration file found.\nLooked back from: {os.path.abspath(temp)}\n", ) self.assertEqual(result.exit_code, 1) + @with_isolated_runner + def test_load_explicit_missing_config(self, runner: CliRunner): + """ + Calling the CLI with an incorrect explicit configuration file will exit with + code 1 and an informative message is sent to standard output. + """ + config = "not-there.toml" + result = runner.invoke(cli, ("--config", config)) + + self.assertEqual(result.exit_code, 1) + self.assertEqual( + result.output, + f"Configuration file '{os.path.abspath(config)}' not found.\n", + ) + def test_missing_template(self): """ Towncrier will raise an exception saying when it can't find a template. From 9c42c6959f585333a250f5a4c6e0dceeae8d116b Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Thu, 23 May 2024 11:01:55 +1200 Subject: [PATCH 7/8] Docstring nitpick --- src/towncrier/test/test_build.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/towncrier/test/test_build.py b/src/towncrier/test/test_build.py index e23ffa61..52bf5851 100644 --- a/src/towncrier/test/test_build.py +++ b/src/towncrier/test/test_build.py @@ -124,8 +124,8 @@ def test_in_different_dir_dir_option(self, runner): @with_project() def test_traverse_up_to_find_config(self, runner): """ - If the current directory doesn't contain the configuration file, Towncrier - should traverse up the directory tree until it finds it. + When the current directory doesn't contain the configuration file, Towncrier + will traverse up the directory tree until it finds it. """ os.chdir("foo") result = runner.invoke(_main, ["--draft", "--date", "01-01-2001"]) @@ -269,9 +269,7 @@ def run_order_scenario(sections, types): [[tool.towncrier.section]] path = "{section}" name = "{section}" - """.format( - section=section - ) + """.format(section=section) ) ) @@ -283,9 +281,7 @@ def run_order_scenario(sections, types): directory = "{type_}" name = "{type_}" showcontent = true - """.format( - type_=type_ - ) + """.format(type_=type_) ) ) From f83780498fa12995e3bb218a0b08f91638012878 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 22 May 2024 23:02:07 +0000 Subject: [PATCH 8/8] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/towncrier/test/test_build.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/towncrier/test/test_build.py b/src/towncrier/test/test_build.py index 52bf5851..17ffad49 100644 --- a/src/towncrier/test/test_build.py +++ b/src/towncrier/test/test_build.py @@ -269,7 +269,9 @@ def run_order_scenario(sections, types): [[tool.towncrier.section]] path = "{section}" name = "{section}" - """.format(section=section) + """.format( + section=section + ) ) ) @@ -281,7 +283,9 @@ def run_order_scenario(sections, types): directory = "{type_}" name = "{type_}" showcontent = true - """.format(type_=type_) + """.format( + type_=type_ + ) ) )