Repository: twisted/towncrier
Pull request #370 (2022-02-15 21:25:24)

feat(settings): Add support to configure fragment types via tables
* Add support to toml tables for custom fragments,
  with default values.

Resolves: twisted/#369
Signed-off-by: danieljmv01 <danieljmv01@gmail.com>

codecov[bot]: # [Codecov](https://codecov.io/gh/twisted/towncrier/pull/370?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted) Report
> Merging [#370](https://codecov.io/gh/twisted/towncrier/pull/370?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted) (c4706a5) into [master](https://codecov.io/gh/twisted/towncrier/commit/27cd970aaa76167ecdc054bdb26cf6763ffa0ed8?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted) (27cd970) will **decrease** coverage by `3.56%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/twisted/towncrier/pull/370/graphs/tree.svg?width=650&height=150&src=pr&token=9npndnog2E&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted)](https://codecov.io/gh/twisted/towncrier/pull/370?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted)

```diff
@@            Coverage Diff             @@
##           master     #370      +/-   ##
==========================================
- Coverage   96.85%   93.29%   -3.57%     
==========================================
  Files          20       13       -7     
  Lines        1241      537     -704     
  Branches      120      105      -15     
==========================================
- Hits         1202      501     -701     
+ Misses         20       18       -2     
+ Partials       19       18       -1     
```


| [Impacted Files](https://codecov.io/gh/twisted/towncrier/pull/370?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted) | Coverage Δ | |
|---|---|---|
| [src/towncrier/\_settings/\_\_init\_\_.py](https://codecov.io/gh/twisted/towncrier/pull/370/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted#diff-c3JjL3Rvd25jcmllci9fc2V0dGluZ3MvX19pbml0X18ucHk=) | `100.00% <100.00%> (ø)` | |
| [src/towncrier/\_settings/fragment\_types.py](https://codecov.io/gh/twisted/towncrier/pull/370/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted#diff-c3JjL3Rvd25jcmllci9fc2V0dGluZ3MvZnJhZ21lbnRfdHlwZXMucHk=) | `100.00% <100.00%> (ø)` | |
| [src/towncrier/\_settings/load.py](https://codecov.io/gh/twisted/towncrier/pull/370/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted#diff-c3JjL3Rvd25jcmllci9fc2V0dGluZ3MvbG9hZC5weQ==) | `96.20% <100.00%> (ø)` | |
| [src/towncrier/test/test\_settings.py](https://codecov.io/gh/twisted/towncrier/pull/370/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted#diff-c3JjL3Rvd25jcmllci90ZXN0L3Rlc3Rfc2V0dGluZ3MucHk=) | | |
| [src/towncrier/\_\_main\_\_.py](https://codecov.io/gh/twisted/towncrier/pull/370/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted#diff-c3JjL3Rvd25jcmllci9fX21haW5fXy5weQ==) | | |
| [src/towncrier/test/test\_format.py](https://codecov.io/gh/twisted/towncrier/pull/370/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted#diff-c3JjL3Rvd25jcmllci90ZXN0L3Rlc3RfZm9ybWF0LnB5) | | |
| [src/towncrier/test/test\_builder.py](https://codecov.io/gh/twisted/towncrier/pull/370/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted#diff-c3JjL3Rvd25jcmllci90ZXN0L3Rlc3RfYnVpbGRlci5weQ==) | | |
| [src/towncrier/test/test\_write.py](https://codecov.io/gh/twisted/towncrier/pull/370/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted#diff-c3JjL3Rvd25jcmllci90ZXN0L3Rlc3Rfd3JpdGUucHk=) | | |
| [src/towncrier/test/test\_project.py](https://codecov.io/gh/twisted/towncrier/pull/370/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted#diff-c3JjL3Rvd25jcmllci90ZXN0L3Rlc3RfcHJvamVjdC5weQ==) | | |
| [src/towncrier/test/test\_check.py](https://codecov.io/gh/twisted/towncrier/pull/370/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted#diff-c3JjL3Rvd25jcmllci90ZXN0L3Rlc3RfY2hlY2sucHk=) | | |
| ... and [2 more](https://codecov.io/gh/twisted/towncrier/pull/370/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/twisted/towncrier/pull/370?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/twisted/towncrier/pull/370?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted). Last update [27cd970...c4706a5](https://codecov.io/gh/twisted/towncrier/pull/370?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted).

adiroiban: I am ok with any change that makes you happy :) as long as it has tests to prevent any future regressions.

My only concern is that we should try to get it right, so that in the future we will not have to also deprecate this method :)

So, maybe play it safe and go with `[tool.towncrier.fragment.chores]`
It's bit more verbose, but you only define this once.
And it should be more explicit.

Thanks again
adiroiban: So, maybe we should go with something like this

```
[tool.towncrier]
package="foo"
newsfile="news.rst"

[tool.towncrier.fragment.chores]
name= "foo"
```

It's more verbose, but we don't risk any colision.
I am not very happy about the extra `fragment_types` list.

I think that if we place everything inside `tool.towncrier.fragment.*` they can be automatically discovered.

danieljmv01: > So, maybe we should go with something like this
> 
> ```
> [tool.towncrier]
> package="foo"
> newsfile="news.rst"
> 
> [tool.towncrier.fragment.chores]
> name= "foo"
> ```
> 
> It's more verbose, but we don't risk any colision. I am not very happy about the extra `fragment_types` list.
> 
> I think that if we place everything inside `tool.towncrier.fragment.*` they can be automatically discovered.


Ok, cool. I will use the keys of "tool.towncrier.fragment" mapping to get the fragments instead of using   "fragment_types" array then. 

Patch:
diff --git a/README.rst b/README.rst
index 31acfd1..3f1bec6 100644
--- a/README.rst
+++ b/README.rst
@@ -156,15 +156,21 @@ If ``title_format`` is unspecified or an empty string, the default format will b
 If set to ``false``, no title will be created.
 This can be useful if the specified template creates the title itself.
 
-Furthermore, you can add your own fragment types using:
+Furthermore, you can customize each of your own fragment types using:
 
 .. code-block:: toml
 
     [tool.towncrier]
-    [[tool.towncrier.type]]
-    directory = "deprecation"
-    name = "Deprecations"
-    showcontent = true
+	# To add custom fragment types, with default setting, just add an empty section.
+    [tool.towncrier.feat]
+    [tool.towncrier.fix]
+
+	# Custom fragment types can have custom attributes
+	# that are used when rendering the result based on the template.
+    [tool.towncrier.chore]
+        name = "Other Tasks"
+        showcontent = false
+
 
 
 Automatic pull request checks
diff --git a/docs/configuration.rst b/docs/configuration.rst
index 985443a..144598c 100644
--- a/docs/configuration.rst
+++ b/docs/configuration.rst
@@ -25,13 +25,53 @@ Top level keys
 - ``issue_format`` -- A format string for rendering the issue/ticket number in newsfiles. ``#{issue}`` by default.
 - ``underlines`` -- The characters used for underlining headers. ``["=", "-", "~"]`` by default.
 
-
 Custom fragment types
 ---------------------
+``Towncrier`` allows defining custom fragment types. Custom fragment types
+will be used instead ``towncrier`` default ones, they are not combined.
+
+Users can configure each of their own custom fragment types by adding tables to
+the pyproject.toml named ``[tool.towncrier.type.<a custom fragment type>]``.
+
+These tables may include the following optional keys:
+
+ * ``name``: The description of the fragment type, as it must be included
+   in the news file. If omitted, it defaults to  its  fragment type,
+   but capitalized.
+ * ``showcontent``: Whether if the fragment contents should be included in the
+   news file. If omitted, it defaults to ``true``
+
+
+
+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
 
-Custom fragment types can be including in the
-pyproject.toml.
-Each custom type (``[[tool.towncrier.type]]``) has the following keys:
+
+    [tool.towncrier]
+
+
+    [tool.towncrier.fragment.feat]
+    [tool.towncrier.fragment.fix]
+
+    [tool.towncrier.fragment.chore]
+        name = "Other Tasks"
+        showcontent = false
+
+DEPRECATED: Defining custom fragment types with an array of toml tables
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+Users can create their own custom fragment types by adding an array of
+tables to the pyproject.toml named ``[[tool.towncrier.type]]``.
+
+If still using this way to configure custom fragment types,
+please notice that ``fragment_types`` must be empty or not provided.
+
+Each custom type (``[[tool.towncrier.type]]``) has the following
+mandatory keys:
  * ``directory``: The type of the fragment.
  * ``name``: The description of the fragment type, as it must be included
    in the news file.
@@ -39,6 +79,7 @@ Each custom type (``[[tool.towncrier.type]]``) has the following keys:
    news file.
 
 
+
 For example:
 
 .. code-block:: toml
@@ -47,4 +88,9 @@ For example:
     [[tool.towncrier.type]]
     directory = "deprecation"
     name = "Deprecations"
-    showcontent = true
\ No newline at end of file
+    showcontent = true
+
+    [[tool.towncrier.type]]
+    directory = "chore"
+    name = "Other Tasks"
+    showcontent = false
\ No newline at end of file
diff --git a/src/towncrier/_settings/__init__.py b/src/towncrier/_settings/__init__.py
new file mode 100644
index 0000000..f0cda51
--- /dev/null
+++ b/src/towncrier/_settings/__init__.py
@@ -0,0 +1,14 @@
+"""Subpackage to handle settings parsing."""
+
+from towncrier._settings import load
+
+load_config = load.load_config
+ConfigError = load.ConfigError
+load_config_from_options = load.load_config_from_options
+
+
+__all__ = [
+    "load_config",
+    "ConfigError",
+    "load_config_from_options",
+]
diff --git a/src/towncrier/_settings/fragment_types.py b/src/towncrier/_settings/fragment_types.py
new file mode 100644
index 0000000..5eb2860
--- /dev/null
+++ b/src/towncrier/_settings/fragment_types.py
@@ -0,0 +1,133 @@
+import abc
+import collections as clt
+
+
+class BaseFragmentTypesLoader:
+    """Base class to load fragment types."""
+
+    __metaclass__ = abc.ABCMeta
+
+    def __init__(self, config):
+        """Initialize."""
+        self.config = config
+
+    @classmethod
+    def factory(cls, config):
+        fragment_types_class = DefaultFragmentTypesLoader
+        fragment_types = config.get("fragment", {})
+        types_config = config.get("type", {})
+        if fragment_types:
+            fragment_types_class = TableFragmentTypesLoader
+        elif types_config:
+            fragment_types_class = ArrayFragmentTypesLoader
+
+        new = fragment_types_class(config)
+        return new
+
+    @abc.abstractmethod
+    def load(self,):
+        """Load fragment types."""
+
+
+class DefaultFragmentTypesLoader(BaseFragmentTypesLoader):
+    """Default towncrier's fragment types."""
+
+    _default_types = clt.OrderedDict(
+        [
+            (u"feature", {"name": u"Features", "showcontent": True}),
+            (u"bugfix", {"name": u"Bugfixes", "showcontent": True}),
+            (u"doc", {"name": u"Improved Documentation", "showcontent": True}),
+            (u"removal", {"name": u"Deprecations and Removals", "showcontent": True}),
+            (u"misc", {"name": u"Misc", "showcontent": False}),
+        ]
+    )
+
+    def load(self,):
+        """Load default types."""
+        return self._default_types
+
+
+class ArrayFragmentTypesLoader(BaseFragmentTypesLoader):
+    """Load fragment types from an toml array of tables.
+
+    This loader get the custom fragment types defined through a
+    toml array of tables, that ``toml`` parses as an array
+    of mappings.
+
+    For example::
+
+        [tool.towncrier]
+        [[tool.towncrier.type]]
+        directory = "deprecation"
+        name = "Deprecations"
+        showcontent = true
+
+    """
+
+    def load(self,):
+        """Load types from toml array of mappings."""
+
+        types = clt.OrderedDict()
+        types_config = self.config["type"]
+        for type_config in types_config:
+            directory = type_config["directory"]
+            fragment_type_name = type_config["name"]
+            is_content_required = type_config["showcontent"]
+            types[directory] = {
+                "name": fragment_type_name,
+                "showcontent": is_content_required,
+            }
+        return types
+
+
+class TableFragmentTypesLoader(BaseFragmentTypesLoader):
+    """Load fragment types from toml tables.
+
+    This loader get the custom fragment types defined through a
+    toml  tables, that ``toml`` parses as an nested mapping.
+
+    This loader allows omitting ``name`` and
+    ```showcontent`` fields.
+    ``name`` by default is the capitalized
+    fragment type.
+    ``showcontent`` is true by default.
+
+    For example::
+
+        [tool.towncrier]
+        [tool.towncrier.fragment.chore]
+        name = "Chores"
+        showcontent = False
+
+        [tool.towncrier.fragment.deprecations]
+        # name will be "Deprecations"
+        # The content will be shown.
+
+    """
+    def __init__(self, config):
+        """Initialize."""
+        self.config = config
+        self.fragment_options = config.get("fragment", {})
+
+    def load(self,):
+        """Load types from nested mapping."""
+        fragment_types = self.fragment_options.keys()
+        fragment_types = sorted(fragment_types)
+        custom_types_sequence = [
+            (fragment_type, self._load_options(fragment_type))
+            for fragment_type in fragment_types
+        ]
+        types = clt.OrderedDict(custom_types_sequence)
+        return types
+
+    def _load_options(self, fragment_type):
+        """Load fragment options."""
+        capitalized_fragment_type = fragment_type.capitalize()
+        options = self.fragment_options.get(fragment_type, {})
+        fragment_description = options.get("name", capitalized_fragment_type)
+        show_content = options.get("showcontent", True)
+        clean_fragment_options = {
+            "name": fragment_description,
+            "showcontent": show_content,
+        }
+        return clean_fragment_options
diff --git a/src/towncrier/_settings.py b/src/towncrier/_settings/load.py
similarity index 88%
rename from src/towncrier/_settings.py
rename to src/towncrier/_settings/load.py
index 7706010..fa9a7de 100644
--- a/src/towncrier/_settings.py
+++ b/src/towncrier/_settings/load.py
@@ -13,6 +13,7 @@ else:
     import toml
 
 from collections import OrderedDict
+from .._settings import fragment_types as ft
 
 
 class ConfigError(Exception):
@@ -24,15 +25,6 @@ class ConfigError(Exception):
 _start_string = u".. towncrier release notes start\n"
 _title_format = None
 _template_fname = "towncrier:default"
-_default_types = OrderedDict(
-    [
-        (u"feature", {"name": u"Features", "showcontent": True}),
-        (u"bugfix", {"name": u"Bugfixes", "showcontent": True}),
-        (u"doc", {"name": u"Improved Documentation", "showcontent": True}),
-        (u"removal", {"name": u"Deprecations and Removals", "showcontent": True}),
-        (u"misc", {"name": u"Misc", "showcontent": False}),
-    ]
-)
 _underlines = ["=", "-", "~"]
 
 
@@ -92,19 +84,15 @@ def parse_toml(base_path, config):
     config = config["tool"]["towncrier"]
 
     sections = OrderedDict()
-    types = OrderedDict()
-
     if "section" in config:
         for x in config["section"]:
             sections[x.get("name", "")] = x["path"]
     else:
         sections[""] = ""
-
-    if "type" in config:
-        for x in config["type"]:
-            types[x["directory"]] = {"name": x["name"], "showcontent": x["showcontent"]}
-    else:
-        types = _default_types
+    fragment_types_loader = ft.BaseFragmentTypesLoader.factory(
+        config
+    )
+    types = fragment_types_loader.load()
 
     wrap = config.get("wrap", False)
 
diff --git a/src/towncrier/newsfragments/369.feature.rst b/src/towncrier/newsfragments/369.feature.rst
new file mode 100644
index 0000000..2fa0986
--- /dev/null
+++ b/src/towncrier/newsfragments/369.feature.rst
@@ -0,0 +1,2 @@
+Added support to tables in toml settings, which provides a more intuitive
+way to configure custom types.
\ No newline at end of file


Test patch:
diff --git a/src/towncrier/test/test_settings.py b/src/towncrier/test/test_settings.py
index 6565ca0..2e0e0e2 100644
--- a/src/towncrier/test/test_settings.py
+++ b/src/towncrier/test/test_settings.py
@@ -1,10 +1,12 @@
 # Copyright (c) Amber Brown, 2015
 # See LICENSE for details.
 
-from twisted.trial.unittest import TestCase
-
+import collections as clt
 import os
 from textwrap import dedent
+import textwrap
+
+from twisted.trial.unittest import TestCase
 
 from .._settings import load_config, ConfigError
 
@@ -172,7 +174,7 @@ package = "foobar"
             str(e.exception),
             "The template file '{}' does not exist.".format(
                 os.path.normpath(os.path.join(temp, "foo.rst")),
-            )
+            ),
         )
 
     def test_missing_template_in_towncrier(self):
@@ -199,3 +201,78 @@ package = "foobar"
         self.assertEqual(
             str(e.exception), "Towncrier does not have a template named 'foo'."
         )
+
+    def test_custom_types_as_tables_array_deprecated(self):
+        """
+        Custom fragment categories can be defined inside
+        the toml config file using an array of tables
+        (a table name in double brackets).
+
+        This functionality is considered deprecated, but we continue
+        to support it to keep backward compatibility.
+        """
+        toml_content = """
+        [tool.towncrier]
+        package = "foobar"
+        [[tool.towncrier.type]]
+        directory="foo"
+        name="Foo"
+        showcontent=false
+
+        [[tool.towncrier.type]]
+        directory="spam"
+        name="Spam"
+        showcontent=true
+        """
+        toml_content = textwrap.dedent(toml_content)
+        expected = [
+            (u"foo", {"name": u"Foo", "showcontent": False, }),
+            (u"spam", {"name": u"Spam", "showcontent": True, },),
+        ]
+        expected = clt.OrderedDict(expected)
+        config = self.load_config_from_string(toml_content, )
+        actual = config["types"]
+        self.assertDictEqual(expected, actual)
+
+    def test_custom_types_as_tables(self):
+        """
+        Custom fragment categories can be defined inside
+        the toml config file using tables.
+        """
+        test_project_path = self.mktemp()
+        os.makedirs(test_project_path)
+        toml_content = """
+        [tool.towncrier]
+        package = "foobar"
+        [tool.towncrier.fragment.feat]
+        ignored_field="Bazz"
+        [tool.towncrier.fragment.fix]
+        [tool.towncrier.fragment.chore]
+        name = "Other Tasks"
+        showcontent = false
+        """
+        toml_content = textwrap.dedent(toml_content)
+        expected = [
+            (u"chore", {"name": u"Other Tasks", "showcontent": False, }),
+            (u"feat", {"name": u"Feat", "showcontent": True, }),
+            (u"fix", {"name": u"Fix", "showcontent": True, }),
+        ]
+
+        expected = clt.OrderedDict(expected)
+        config = self.load_config_from_string(toml_content, )
+        actual = config["types"]
+        self.assertDictEqual(expected, actual)
+
+    def load_config_from_string(self, toml_content):
+        """Load configuration from a string.
+
+        Given a string following toml syntax,
+        obtain the towncrier configuration.
+        """
+        test_project_path = self.mktemp()
+        os.makedirs(test_project_path)
+        toml_path = os.path.join(test_project_path, "pyproject.toml")
+        with open(toml_path, "w") as f:
+            f.write(toml_content)
+        config = load_config(test_project_path)
+        return config