Skip to content

Commit 0993534

Browse files
committed
Check for invalid fragment names
1 parent f60e750 commit 0993534

10 files changed

Lines changed: 139 additions & 2 deletions

File tree

docs/cli.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ also remove the fragments directory if now empty.
6464

6565
Don't delete news fragments after the build and don't ask for confirmation whether to delete or keep the fragments.
6666

67+
.. option:: --strict
68+
69+
Fail if there are any news fragments that have invalid filenames.
70+
This is automatically turned on if ``build_ignore_filenames`` has been set in the configuration.
71+
6772

6873
``towncrier create``
6974
--------------------

docs/configuration.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,13 @@ Top level keys
130130

131131
``true`` by default.
132132

133+
``build_ignore_filenames``
134+
A list of filenames in the news fragments directory to ignore when building the news file.
135+
136+
If this is configured, it turns on the ``--strict`` build mode which will fail if there are any news fragment files not in this list that have invalid filenames. Note that if a template is used, it should also be included here.
137+
138+
``None`` by default.
139+
133140
Extra top level keys for Python projects
134141
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
135142

docs/tutorial.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ Create this folder::
4747

4848
The ``.gitignore`` will remain and keep Git from not tracking the directory.
4949

50+
If needed, you can also specify a list of filenames for ``towncrier`` to ignore in the news fragments directory::
51+
52+
[tool.towncrier]
53+
build_ignore_filenames = ["README.rst"]
54+
5055

5156
Detecting Version
5257
-----------------

src/towncrier/_builder.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from pathlib import Path
1313
from typing import Any, DefaultDict, Iterable, Iterator, Mapping, NamedTuple, Sequence
1414

15+
from click import ClickException
1516
from jinja2 import Template
1617

1718
from towncrier._settings.load import Config
@@ -106,10 +107,22 @@ def __call__(self, section_directory: str = "") -> str:
106107
def find_fragments(
107108
base_directory: str,
108109
config: Config,
110+
strict: bool | None,
109111
) -> tuple[Mapping[str, Mapping[tuple[str, str, int], str]], list[tuple[str, str]]]:
110112
"""
111113
Sections are a dictonary of section names to paths.
114+
115+
In strict mode, raise ClickException if any fragments have an invalid name.
112116
"""
117+
if strict is None:
118+
# If strict mode is not set, turn it on only if build_ignore_filenames is set
119+
# (this maintains backward compatibility).
120+
strict = config.build_ignore_filenames is not None
121+
122+
ignored_files = {".gitignore"}
123+
if config.build_ignore_filenames:
124+
ignored_files.update(config.build_ignore_filenames)
125+
113126
get_section_path = FragmentsPath(base_directory, config)
114127

115128
content = {}
@@ -129,10 +142,20 @@ def find_fragments(
129142
file_content = {}
130143

131144
for basename in files:
145+
# Skip files that are deliberately ignored
146+
if basename in ignored_files:
147+
continue
148+
132149
issue, category, counter = parse_newfragment_basename(
133150
basename, config.types
134151
)
135152
if category is None:
153+
if strict and issue is None:
154+
raise ClickException(
155+
f"Invalid news fragment name: {basename}\n"
156+
"If this filename is deliberate, add it to "
157+
"'build_ignore_filenames' in your configuration."
158+
)
136159
continue
137160
assert issue is not None
138161
assert counter is not None

src/towncrier/_settings/load.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ class Config:
5454
orphan_prefix: str = "+"
5555
create_eof_newline: bool = True
5656
create_add_extension: bool = True
57+
build_ignore_filenames: list[str] | None = None
5758

5859

5960
class ConfigError(ClickException):

src/towncrier/build.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,17 @@ def _validate_answer(ctx: Context, param: Option, value: bool) -> bool:
106106
help="Do not ask for confirmations. But keep news fragments.",
107107
callback=_validate_answer,
108108
)
109+
@click.option(
110+
"--strict",
111+
"strict",
112+
default=None,
113+
flag_value=True,
114+
help=(
115+
"Fail if there are any news fragments that have invalid filenames (this is "
116+
"automatically turned on if 'build_ignore_filenames' has been set in the "
117+
"configuration)."
118+
),
119+
)
109120
def _main(
110121
draft: bool,
111122
directory: str | None,
@@ -115,6 +126,7 @@ def _main(
115126
project_date: str | None,
116127
answer_yes: bool,
117128
answer_keep: bool,
129+
strict: bool | None,
118130
) -> None:
119131
"""
120132
Build a combined news file from news fragment.
@@ -129,6 +141,7 @@ def _main(
129141
project_date,
130142
answer_yes,
131143
answer_keep,
144+
strict,
132145
)
133146
except ConfigError as e:
134147
print(e, file=sys.stderr)
@@ -144,6 +157,7 @@ def __main(
144157
project_date: str | None,
145158
answer_yes: bool,
146159
answer_keep: bool,
160+
strict: bool | None,
147161
) -> None:
148162
"""
149163
The main entry point.
@@ -178,7 +192,7 @@ def __main(
178192

179193
click.echo("Finding news fragments...", err=to_err)
180194

181-
fragment_contents, fragment_files = find_fragments(base_directory, config)
195+
fragment_contents, fragment_files = find_fragments(base_directory, config, strict)
182196
fragment_filenames = [filename for (filename, _category) in fragment_files]
183197

184198
click.echo("Rendering news fragments...", err=to_err)

src/towncrier/check.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,12 +101,14 @@ def __main(
101101
click.echo(f"{n}. {change}")
102102
click.echo("----")
103103

104+
# This will fail if any fragment files have an invalid name:
105+
all_fragment_files = find_fragments(base_directory, config, strict=True)[1]
106+
104107
news_file = os.path.normpath(os.path.join(base_directory, config.filename))
105108
if news_file in files:
106109
click.echo("Checks SKIPPED: news file changes detected.")
107110
sys.exit(0)
108111

109-
all_fragment_files = find_fragments(base_directory, config)[1]
110112
fragments = set() # will only include fragments of types that are checked
111113
unchecked_fragments = set() # will include fragments of types that are not checked
112114
for fragment_filename, category in all_fragment_files:
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
``towncrier check`` will now fail if any news fragments have invalid filenames.
2+
3+
Added a new configuration option called ``build_ignore_filenames`` that allows you to specify a list of filenames that should be ignored. If this is set, ``towncrier build`` will also fail if any filenames are invalid, except for those in the list.

src/towncrier/test/test_build.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1530,3 +1530,60 @@ def test_orphans_in_non_showcontent_markdown(self, runner):
15301530

15311531
self.assertEqual(0, result.exit_code, result.output)
15321532
self.assertEqual(expected_output, result.output)
1533+
1534+
@with_project(
1535+
config="""
1536+
[tool.towncrier]
1537+
package = "foo"
1538+
build_ignore_filenames = ["template.jinja", "CAPYBARAS.md"]
1539+
"""
1540+
)
1541+
def test_invalid_fragment_names(self, runner):
1542+
"""
1543+
When build_ignore_filenames is set, files with those names are ignored.
1544+
"""
1545+
opts = ["--draft", "--date", "01-01-2001", "--version", "1.0.0"]
1546+
# Valid filename:
1547+
with open("foo/newsfragments/123.feature", "w") as f:
1548+
f.write("Adds levitation")
1549+
# Files that should be ignored:
1550+
with open("foo/newsfragments/template.jinja", "w") as f:
1551+
f.write("Jinja template")
1552+
with open("foo/newsfragments/CAPYBARAS.md", "w") as f:
1553+
f.write("Peanut butter")
1554+
# Automatically ignored:
1555+
with open("foo/newsfragments/.gitignore", "w") as f:
1556+
f.write("!.gitignore")
1557+
1558+
result = runner.invoke(_main, opts)
1559+
# Should succeed
1560+
self.assertEqual(0, result.exit_code, result.output)
1561+
1562+
# Invalid filename:
1563+
with open("foo/newsfragments/feature.124", "w") as f:
1564+
f.write("Extends levitation")
1565+
1566+
result = runner.invoke(_main, opts)
1567+
# Should now fail
1568+
self.assertEqual(1, result.exit_code, result.output)
1569+
self.assertIn("Invalid news fragment name: feature.124", result.output)
1570+
1571+
@with_project()
1572+
def test_invalid_fragment_names_strict(self, runner):
1573+
"""
1574+
When using --strict, any invalid filenames will cause an error even if
1575+
build_ignore_filenames is NOT set.
1576+
"""
1577+
opts = ["--draft", "--date", "01-01-2001", "--version", "1.0.0"]
1578+
# Invalid filename:
1579+
with open("foo/newsfragments/feature.124", "w") as f:
1580+
f.write("Extends levitation")
1581+
1582+
result = runner.invoke(_main, opts)
1583+
# Should succeed in normal mode
1584+
self.assertEqual(0, result.exit_code, result.output)
1585+
1586+
result = runner.invoke(_main, [*opts, "--strict"])
1587+
# Should now fail
1588+
self.assertEqual(1, result.exit_code, result.output)
1589+
self.assertIn("Invalid news fragment name: feature.124", result.output)

src/towncrier/test/test_check.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,3 +470,23 @@ def test_in_different_dir_with_nondefault_newsfragments_directory(self, runner):
470470
self.assertTrue(
471471
result.output.endswith("No new newsfragments found on this branch.\n")
472472
)
473+
474+
@with_isolated_runner
475+
def test_invalid_fragment_name(self, runner):
476+
create_project("pyproject.toml")
477+
opts = ["--compare-with", "main"]
478+
479+
write("foo/bar.py", "# Scorpions!")
480+
write("foo/newsfragments/123.feature", "Adds scorpions")
481+
write("foo/newsfragments/.gitignore", "!.gitignore")
482+
commit("add stuff")
483+
484+
result = runner.invoke(towncrier_check, opts)
485+
self.assertEqual(0, result.exit_code, result.output)
486+
487+
# Make invalid filename:
488+
os.rename("foo/newsfragments/123.feature", "foo/newsfragments/feature.123")
489+
490+
result = runner.invoke(towncrier_check, opts)
491+
self.assertEqual(1, result.exit_code, result.output)
492+
self.assertIn("Invalid news fragment name: feature.123", result.output)

0 commit comments

Comments
 (0)