From 1c3b87bb44fc3fda3675d0f987ad613659a5d16d Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Thu, 23 May 2024 11:07:09 +1200 Subject: [PATCH] Allow `towncrier` to traverse back up directories looking for the configuration file (#601) --- src/towncrier/_settings/load.py | 49 ++++++++++++++++----- src/towncrier/newsfragments/601.feature.rst | 1 + src/towncrier/test/test_build.py | 12 ++++- src/towncrier/test/test_settings.py | 17 ++++++- 4 files changed, 66 insertions(+), 13 deletions(-) create mode 100644 src/towncrier/newsfragments/601.feature.rst diff --git a/src/towncrier/_settings/load.py b/src/towncrier/_settings/load.py index 66a6c546..724a7768 100644 --- a/src/towncrier/_settings/load.py +++ b/src/towncrier/_settings/load.py @@ -65,26 +65,53 @@ 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 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: - directory = os.getcwd() + return traverse_for_config(directory) + + config_path = os.path.abspath(config_path) + # 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: - config_path = os.path.abspath(config_path) - 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) + base_directory = os.path.dirname(config_path) - if config is None: - raise ConfigError(f"No configuration file found.\nLooked in: {base_directory}") + 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 +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") diff --git a/src/towncrier/newsfragments/601.feature.rst b/src/towncrier/newsfragments/601.feature.rst new file mode 100644 index 00000000..ca209ca5 --- /dev/null +++ b/src/towncrier/newsfragments/601.feature.rst @@ -0,0 +1 @@ +Running ``towncrier`` will now traverse back up directories looking for the configuration file. diff --git a/src/towncrier/test/test_build.py b/src/towncrier/test/test_build.py index 241425e9..17ffad49 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): + """ + 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"]) + 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() 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.