Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allowed fallback usage of project.name for name and package if pyproject.toml exists #687

Merged
merged 6 commits into from
Feb 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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.
Expand Down Expand Up @@ -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.

Expand Down
40 changes: 31 additions & 9 deletions src/towncrier/_settings/load.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions src/towncrier/newsfragments/687.feature.rst
Original file line number Diff line number Diff line change
@@ -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].
105 changes: 89 additions & 16 deletions src/towncrier/test/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

import os

from textwrap import dedent

from click.testing import CliRunner
from twisted.trial.unittest import TestCase

Expand Down Expand Up @@ -112,22 +114,6 @@ def test_template_extended(self):

self.assertEqual(config.template, ("towncrier.templates", "default.rst"))

def test_missing(self):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why was this test removed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

because now towncrier does not need any tool.towncrier section for Python projects using pyproject.toml files.

A pyproject.toml file requires the name field, and hence everything can be used without problems.

The removed test checked that it broke when tool.towncrier was not present, simply because it is not required.

"""
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.
Expand Down Expand Up @@ -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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a small suggestion to help understand the scope of this test.

Suggested change
Towncrier will fallback to the [project.name] value in pyproject.toml.
To obtain the name of the project, when there is no dedicated [tool.towncrier] section in pyproject.toml, it will fallback to the `name` option from the [project] section.

Just asking. Is towncrier usable without a configuration section ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. When it is a Python project with a pyproject.toml file, then a configuration file / setting is not required.


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):
"""
Expand Down
Loading