diff --git a/docs/configuration.rst b/docs/configuration.rst index 771b3b12..6022cd9d 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -17,9 +17,8 @@ A minimal configuration for a Python project looks like this: .. code-block:: toml # pyproject.toml - - [tool.towncrier] - package = "myproject" + [project] + name = "myproject" A minimal configuration for a non-Python project looks like this: @@ -36,9 +35,9 @@ Top level keys ``name`` The name of your project. - For Python projects that provide a ``package`` key, if left empty then the name will be automatically determined. + For Python projects that provide a ``package`` key, if left empty then the name will be automatically determined from the ``package`` key. - ``""`` by default. + Defaults to the key ``[project.name]`` in ``pyproject.toml`` (if present), otherwise defaults to the empty string ``""``. ``version`` The version of your project. @@ -167,6 +166,8 @@ Extra top level keys for Python projects Allows ``name`` and ``version`` to be automatically determined from the Python package. Changes the default ``directory`` to be a ``newsfragments`` directory within this package. + Defaults to the key ``[project.name]`` in ``pyproject.toml`` (if present), otherwise defaults to the empty string ``""``. + ``package_dir`` The folder your package lives. diff --git a/src/towncrier/_settings/load.py b/src/towncrier/_settings/load.py index c54f9798..3a70a67a 100644 --- a/src/towncrier/_settings/load.py +++ b/src/towncrier/_settings/load.py @@ -118,19 +118,44 @@ def load_config(directory: str) -> Config | None: towncrier_toml = os.path.join(directory, "towncrier.toml") pyproject_toml = os.path.join(directory, "pyproject.toml") + # In case the [tool.towncrier.name|package] is not specified + # we'll read it from [project.name] + + if os.path.exists(pyproject_toml): + pyproject_config = load_toml_from_file(pyproject_toml) + else: + # make it empty so it won't be used as a backup plan + pyproject_config = {} + if os.path.exists(towncrier_toml): - config_file = towncrier_toml + config_toml = towncrier_toml elif os.path.exists(pyproject_toml): - config_file = pyproject_toml + config_toml = pyproject_toml else: return None - return load_config_from_file(directory, config_file) + # Read the default configuration. Depending on which exists + config = load_config_from_file(directory, config_toml) + # Fallback certain values depending on the [project.name] + if project_name := pyproject_config.get("project", {}).get("name", ""): + # Fallback to the project name for the configuration name + # and the configuration package entries. + if not config.package: + config.package = project_name + if not config.name: + config.name = config.package -def load_config_from_file(directory: str, config_file: str) -> Config: + return config + + +def load_toml_from_file(config_file: str) -> Mapping[str, Any]: with open(config_file, "rb") as conffile: - config = tomllib.load(conffile) + return tomllib.load(conffile) + + +def load_config_from_file(directory: str, config_file: str) -> Config: + config = load_toml_from_file(config_file) return parse_toml(directory, config) @@ -141,10 +166,7 @@ def load_config_from_file(directory: str, config_file: str) -> Config: def parse_toml(base_path: str, config: Mapping[str, Any]) -> Config: - if "towncrier" not in (config.get("tool") or {}): - raise ConfigError("No [tool.towncrier] section.", failing_option="all") - - config = config["tool"]["towncrier"] + config = config.get("tool", {}).get("towncrier", {}) parsed_data = {} # Check for misspelt options. diff --git a/src/towncrier/newsfragments/687.feature.rst b/src/towncrier/newsfragments/687.feature.rst new file mode 100644 index 00000000..42d67ec8 --- /dev/null +++ b/src/towncrier/newsfragments/687.feature.rst @@ -0,0 +1,3 @@ +When used with an `pyproject.toml` file, when no explicit values are +defined for [tool.towncrier.name|package] they will now fallback to +the value of [project.name]. diff --git a/src/towncrier/test/test_settings.py b/src/towncrier/test/test_settings.py index 14554086..f46db2b2 100644 --- a/src/towncrier/test/test_settings.py +++ b/src/towncrier/test/test_settings.py @@ -3,6 +3,8 @@ import os +from textwrap import dedent + from click.testing import CliRunner from twisted.trial.unittest import TestCase @@ -112,22 +114,6 @@ def test_template_extended(self): self.assertEqual(config.template, ("towncrier.templates", "default.rst")) - def test_missing(self): - """ - If the config file doesn't have the correct toml key, we error. - """ - project_dir = self.mktemp_project( - pyproject_toml=""" - [something.else] - blah='baz' - """ - ) - - with self.assertRaises(ConfigError) as e: - load_config(project_dir) - - self.assertEqual(e.exception.failing_option, "all") - def test_incorrect_single_file(self): """ single_file must be a bool. @@ -194,6 +180,93 @@ def test_towncrier_toml_preferred(self): config = load_config(project_dir) self.assertEqual(config.package, "a") + def test_pyproject_only_pyproject_toml(self): + """ + Towncrier will fallback to the [project.name] value in pyproject.toml. + + This tests asserts that the minimal configuration is to do *nothing* + when using a pyproject.toml file. + """ + project_dir = self.mktemp_project( + pyproject_toml=""" + [project] + name = "a" + """, + ) + + config = load_config(project_dir) + self.assertEqual(config.package, "a") + self.assertEqual(config.name, "a") + + def test_pyproject_assert_fallback(self): + """ + This test is an extensive test of the fallback scenarios + for the `package` and `name` keys in the towncrier section. + + It will fallback to pyproject.toml:name in any case. + And as such it checks the various fallback mechanisms + if the fields are not present in the towncrier.toml, nor + in the pyproject.toml files. + + This both tests when things are *only* in the pyproject.toml + and default usage of the data in the towncrier.toml file. + """ + pyproject_toml = dedent( + """ + [project] + name = "foo" + [tool.towncrier] + """ + ) + towncrier_toml = dedent( + """ + [tool.towncrier] + """ + ) + tests = [ + "", + "name = '{name}'", + "package = '{package}'", + "name = '{name}'", + "package = '{package}'", + ] + + def factory(name, package): + def func(test): + return dedent(test).format(name=name, package=package) + + return func + + for pp_fields in map(factory(name="a", package="b"), tests): + pp_toml = pyproject_toml + pp_fields + for tc_fields in map(factory(name="c", package="d"), tests): + tc_toml = towncrier_toml + tc_fields + + # Create the temporary project + project_dir = self.mktemp_project( + pyproject_toml=pp_toml, + towncrier_toml=tc_toml, + ) + + # Read the configuration file. + config = load_config(project_dir) + + # Now the values depend on where the fallback + # is. + # If something is in towncrier.toml, it will be preferred + # name fallsback to package + if "package" in tc_fields: + package = "d" + else: + package = "foo" + self.assertEqual(config.package, package) + + if "name" in tc_fields: + self.assertEqual(config.name, "c") + else: + # fall-back to package name + self.assertEqual(config.name, package) + @with_isolated_runner def test_load_no_config(self, runner: CliRunner): """