Repository: twisted/towncrier
Pull request #618 (2024-06-20 10:57:17)

Ignore types on check
# Description
<!-- add a short summary if necessary; mention issue numbers -->
closes  #617 
Adds a new optional key to newsfragment categories called `check` (boolean, defaults to true). If set to false, towncrier check will not consider newsfragments of this category as enough to pass

# Checklist
<!-- add a "X" inside the brackets to confirm -->
* [X] Make sure changes are covered by existing or new tests.
* [X] For at least one Python version, make sure local test run is green.
* [X] Create a file in `src/towncrier/newsfragments/`. Describe your
  change and include important information. Your change will be included in the public release notes.
* [x] Make sure all GitHub Actions checks are green (they are automatically checking all of the above).
* [X] Ensure `docs/tutorial.rst` is still up-to-date.
* [X] If you add new **CLI arguments** (or change the meaning of existing ones), make sure `docs/cli.rst` reflects those changes.
* [X] If you add new **configuration options** (or change the meaning of existing ones), make sure `docs/configuration.rst` reflects those changes.


bentheiii: Hi @adiroiban, can merge this?

Patch:
diff --git a/docs/configuration.rst b/docs/configuration.rst
index 9841b1e..aa4edfc 100644
--- a/docs/configuration.rst
+++ b/docs/configuration.rst
@@ -221,6 +221,11 @@ These may include the following optional keys:
         Orphan fragments (those without an issue number) always have their content included.
         If a fragment was created, it means that information is important for end users.
 
+``check``
+    A boolean value indicating whether the fragment should be considered by the ``towncrier check`` command.
+
+    ``true`` by default.
+
 For example, if you want your custom fragment types to be ``["feat", "fix", "chore",]`` and you want all of them to use the default configuration except ``"chore"`` you can do it as follows:
 
 .. code-block:: toml
@@ -234,6 +239,10 @@ For example, if you want your custom fragment types to be ``["feat", "fix", "cho
    name = "Other Tasks"
    showcontent = false
 
+   [tool.towncrier.fragment.deps]
+   name = "Dependency Changes"
+   check = false
+
 
 .. warning::
 
@@ -267,6 +276,11 @@ Each table within this array has the following mandatory keys:
         Orphan fragments (those without an issue number) always have their content included.
         If a fragment was created, it means that information is important for end users.
 
+``check``
+    A boolean value indicating whether the fragment should be considered by the ``towncrier check`` command.
+
+    ``true`` by default.
+
 For example:
 
 .. code-block:: toml
@@ -281,3 +295,9 @@ For example:
    directory = "chore"
    name = "Other Tasks"
    showcontent = false
+
+   [[tool.towncrier.type]]
+   directory = "deps"
+   name = "Dependency Changes"
+   showcontent = true
+   check = false
diff --git a/src/towncrier/_builder.py b/src/towncrier/_builder.py
index 91a6323..e7fbf12 100644
--- a/src/towncrier/_builder.py
+++ b/src/towncrier/_builder.py
@@ -89,27 +89,31 @@ class FragmentsPath:
 #
 # {
 #     "": {
-#         ("142", "misc"): "",
-#         ("1", "feature"): "some cool description",
+#         ("142", "misc", 1): "",
+#         ("1", "feature", 1): "some cool description",
 #     },
 #     "Names": {},
-#     "Web": {("3", "bugfix"): "Fixed a thing"},
+#     "Web": {("3", "bugfix", 1): "Fixed a thing"},
 # }
 #
-# We should really use attrs.
+# and a list like:
+# [
+#    ("/path/to/fragments/142.misc.1", "misc"),
+#    ("/path/to/fragments/1.feature.1", "feature"),
+# ]
 #
-# Also returns a list of the paths that the fragments were taken from.
+# We should really use attrs.
 def find_fragments(
     base_directory: str,
     config: Config,
-) -> tuple[Mapping[str, Mapping[tuple[str, str, int], str]], list[str]]:
+) -> tuple[Mapping[str, Mapping[tuple[str, str, int], str]], list[tuple[str, str]]]:
     """
     Sections are a dictonary of section names to paths.
     """
     get_section_path = FragmentsPath(base_directory, config)
 
     content = {}
-    fragment_filenames = []
+    fragment_files = []
     # Multiple orphan news fragments are allowed per section, so initialize a counter
     # that can be incremented automatically.
     orphan_fragment_counter: DefaultDict[str | None, int] = defaultdict(int)
@@ -139,7 +143,7 @@ def find_fragments(
                 orphan_fragment_counter[category] += 1
 
             full_filename = os.path.join(section_dir, basename)
-            fragment_filenames.append(full_filename)
+            fragment_files.append((full_filename, category))
             data = Path(full_filename).read_text(encoding="utf-8", errors="replace")
 
             if (issue, category, counter) in file_content:
@@ -152,7 +156,7 @@ def find_fragments(
 
         content[key] = file_content
 
-    return content, fragment_filenames
+    return content, fragment_files
 
 
 def indent(text: str, prefix: str) -> str:
diff --git a/src/towncrier/_settings/fragment_types.py b/src/towncrier/_settings/fragment_types.py
index a9882c9..bb1313b 100644
--- a/src/towncrier/_settings/fragment_types.py
+++ b/src/towncrier/_settings/fragment_types.py
@@ -37,11 +37,15 @@ class DefaultFragmentTypesLoader(BaseFragmentTypesLoader):
 
     _default_types = {
         # Keep in-sync with docs/tutorial.rst.
-        "feature": {"name": "Features", "showcontent": True},
-        "bugfix": {"name": "Bugfixes", "showcontent": True},
-        "doc": {"name": "Improved Documentation", "showcontent": True},
-        "removal": {"name": "Deprecations and Removals", "showcontent": True},
-        "misc": {"name": "Misc", "showcontent": False},
+        "feature": {"name": "Features", "showcontent": True, "check": True},
+        "bugfix": {"name": "Bugfixes", "showcontent": True, "check": True},
+        "doc": {"name": "Improved Documentation", "showcontent": True, "check": True},
+        "removal": {
+            "name": "Deprecations and Removals",
+            "showcontent": True,
+            "check": True,
+        },
+        "misc": {"name": "Misc", "showcontent": False, "check": True},
     }
 
     def load(self) -> Mapping[str, Mapping[str, Any]]:
@@ -75,9 +79,11 @@ class ArrayFragmentTypesLoader(BaseFragmentTypesLoader):
             directory = type_config["directory"]
             fragment_type_name = type_config["name"]
             is_content_required = type_config["showcontent"]
+            check = type_config.get("check", True)
             types[directory] = {
                 "name": fragment_type_name,
                 "showcontent": is_content_required,
+                "check": check,
             }
         return types
 
@@ -129,8 +135,10 @@ class TableFragmentTypesLoader(BaseFragmentTypesLoader):
         options = self.fragment_options.get(fragment_type, {})
         fragment_description = options.get("name", capitalized_fragment_type)
         show_content = options.get("showcontent", True)
+        check = options.get("check", True)
         clean_fragment_options = {
             "name": fragment_description,
             "showcontent": show_content,
+            "check": check,
         }
         return clean_fragment_options
diff --git a/src/towncrier/build.py b/src/towncrier/build.py
index 2d77bb0..bf7cd35 100644
--- a/src/towncrier/build.py
+++ b/src/towncrier/build.py
@@ -178,7 +178,8 @@ def __main(
 
     click.echo("Finding news fragments...", err=to_err)
 
-    fragment_contents, fragment_filenames = find_fragments(base_directory, config)
+    fragment_contents, fragment_files = find_fragments(base_directory, config)
+    fragment_filenames = [filename for (filename, _category) in fragment_files]
 
     click.echo("Rendering news fragments...", err=to_err)
     fragments = split_fragments(
diff --git a/src/towncrier/check.py b/src/towncrier/check.py
index f0d4567..a519f9a 100644
--- a/src/towncrier/check.py
+++ b/src/towncrier/check.py
@@ -106,13 +106,24 @@ def __main(
         click.echo("Checks SKIPPED: news file changes detected.")
         sys.exit(0)
 
-    fragments = {
-        os.path.abspath(path) for path in find_fragments(base_directory, config)[1]
-    }
+    all_fragment_files = find_fragments(base_directory, config)[1]
+    fragments = set()  # will only include fragments of types that are checked
+    unchecked_fragments = set()  # will include fragments of types that are not checked
+    for fragment_filename, category in all_fragment_files:
+        if config.types[category]["check"]:
+            fragments.add(fragment_filename)
+        else:
+            unchecked_fragments.add(fragment_filename)
     fragments_in_branch = fragments & files
 
     if not fragments_in_branch:
-        click.echo("No new newsfragments found on this branch.")
+        unchecked_fragments_in_branch = unchecked_fragments & files
+        if unchecked_fragments:
+            click.echo("Found newsfragments of unchecked types in the branch:")
+            for n, fragment in enumerate(unchecked_fragments_in_branch, start=1):
+                click.echo(f"{n}. {fragment}")
+        else:
+            click.echo("No new newsfragments found on this branch.")
         sys.exit(1)
     else:
         click.echo("Found:")
diff --git a/src/towncrier/newsfragments/617.feature.rst b/src/towncrier/newsfragments/617.feature.rst
new file mode 100644
index 0000000..9579967
--- /dev/null
+++ b/src/towncrier/newsfragments/617.feature.rst
@@ -0,0 +1,1 @@
+newsfragment categories can now be marked with ``check = false``, causing them to be ignored in ``towncrier check``


Test patch:
diff --git a/src/towncrier/test/test_check.py b/src/towncrier/test/test_check.py
index 81861db..23db326 100644
--- a/src/towncrier/test/test_check.py
+++ b/src/towncrier/test/test_check.py
@@ -166,6 +166,81 @@ class TestChecker(TestCase):
                 result.output.endswith("No new newsfragments found on this branch.\n")
             )
 
+    def test_fragment_exists_but_not_in_check(self):
+        """A fragment that exists but is marked as check=False is ignored by the check."""
+        runner = CliRunner()
+
+        with runner.isolated_filesystem():
+            create_project(
+                "pyproject.toml",
+                main_branch="master",
+                extra_config="[[tool.towncrier.type]]\n"
+                'directory = "feature"\n'
+                'name = "Features"\n'
+                "showcontent = true\n"
+                "[[tool.towncrier.type]]\n"
+                'directory = "sut"\n'
+                'name = "System Under Test"\n'
+                "showcontent = true\n"
+                "check=false\n",
+            )
+
+            file_path = "foo/somefile.py"
+            write(file_path, "import os")
+
+            fragment_path = Path("foo/newsfragments/1234.sut").absolute()
+            write(fragment_path, "Adds gravity back")
+            commit("add a file and a newsfragment")
+
+            result = runner.invoke(towncrier_check, ["--compare-with", "master"])
+
+            self.assertEqual(1, result.exit_code)
+            self.assertTrue(
+                result.output.endswith(
+                    "Found newsfragments of unchecked types in the branch:\n1. "
+                    + str(fragment_path)
+                    + "\n"
+                ),
+                (result.output, str(fragment_path)),
+            )
+
+    def test_fragment_exists_and_in_check(self):
+        """
+        A fragment that exists but is not marked as check=False is
+        not ignored by the check, even if other categories are marked as check=False.
+        """
+        runner = CliRunner()
+
+        with runner.isolated_filesystem():
+            create_project(
+                "pyproject.toml",
+                main_branch="master",
+                extra_config="[[tool.towncrier.type]]\n"
+                'directory = "feature"\n'
+                'name = "Features"\n'
+                "showcontent = true\n"
+                "[[tool.towncrier.type]]\n"
+                'directory = "sut"\n'
+                'name = "System Under Test"\n'
+                "showcontent = true\n"
+                "check=false\n",
+            )
+
+            file_path = "foo/somefile.py"
+            write(file_path, "import os")
+
+            fragment_path = Path("foo/newsfragments/1234.feature").absolute()
+            write(fragment_path, "Adds gravity back")
+            commit("add a file and a newsfragment")
+
+            result = runner.invoke(towncrier_check, ["--compare-with", "master"])
+
+            self.assertEqual(0, result.exit_code)
+            self.assertTrue(
+                result.output.endswith("Found:\n1. " + str(fragment_path) + "\n"),
+                (result.output, str(fragment_path)),
+            )
+
     def test_none_stdout_encoding_works(self):
         """
         No failure when output is piped causing None encoding for stdout.
diff --git a/src/towncrier/test/test_settings.py b/src/towncrier/test/test_settings.py
index b08384a..1455408 100644
--- a/src/towncrier/test/test_settings.py
+++ b/src/towncrier/test/test_settings.py
@@ -288,6 +288,12 @@ class TomlSettingsTests(TestCase):
                 directory="spam"
                 name="Spam"
                 showcontent=true
+
+                [[tool.towncrier.type]]
+                directory="auto"
+                name="Automatic"
+                showcontent=true
+                check=false
             """
         )
         config = load_config(project_dir)
@@ -297,6 +303,7 @@ class TomlSettingsTests(TestCase):
                 {
                     "name": "Foo",
                     "showcontent": False,
+                    "check": True,
                 },
             ),
             (
@@ -304,6 +311,15 @@ class TomlSettingsTests(TestCase):
                 {
                     "name": "Spam",
                     "showcontent": True,
+                    "check": True,
+                },
+            ),
+            (
+                "auto",
+                {
+                    "name": "Automatic",
+                    "showcontent": True,
+                    "check": False,
                 },
             ),
         ]
@@ -326,6 +342,9 @@ class TomlSettingsTests(TestCase):
                 [tool.towncrier.fragment.chore]
                 name = "Other Tasks"
                 showcontent = false
+                [tool.towncrier.fragment.auto]
+                name = "Automatic"
+                check = false
             """
         )
         config = load_config(project_dir)
@@ -333,14 +352,22 @@ class TomlSettingsTests(TestCase):
             "chore": {
                 "name": "Other Tasks",
                 "showcontent": False,
+                "check": True,
             },
             "feat": {
                 "name": "Feat",
                 "showcontent": True,
+                "check": True,
             },
             "fix": {
                 "name": "Fix",
                 "showcontent": True,
+                "check": True,
+            },
+            "auto": {
+                "name": "Automatic",
+                "showcontent": True,
+                "check": False,
             },
         }
         actual = config.types