Repository: twisted/towncrier
Pull request #173 (2020-01-07 14:51:21)

Improve news fragment file name parsing
The new version uses category as the reference point to later infer the issue number and counter value.
As a consequence, it will return `(None, None, None)` if the base name doesn't contain a valid category.

The new parser allows using file names like `123.feature.1.ext` which are convenient when one wants to use an appropriate extension (e.g. `rst`, `md`) to enable syntax highlighting.


Patch:
diff --git a/src/towncrier/_builder.py b/src/towncrier/_builder.py
index e8dc8be..6135df8 100644
--- a/src/towncrier/_builder.py
+++ b/src/towncrier/_builder.py
@@ -12,35 +12,39 @@ from jinja2 import Template
 
 
 # Returns ticket, category and counter or (None, None, None) if the basename
-# could not be parsed
+# could not be parsed or doesn't contain a valid category.
 def parse_newfragment_basename(basename, definitions):
-    parts = basename.split(u".")
+    invalid = (None, None, None)
+    parts = basename.split(".")
 
     if len(parts) == 1:
-        return (None, None, None)
+        return invalid
     if len(parts) == 2:
         ticket, category = parts
-        return ticket, category, 0
-
-    # fix-1.2.3.feature and fix.1.feature.2 are valid formats. The former is
-    # used in projects which don't put ticket numbers to newfragment names.
-    if parts[-1] in definitions:
-        category = parts[-1]
-        ticket = parts[-2]
-        return ticket, category, 0
-
-    # If there is a number after the category then use it as a counter,
-    # otherwise ignore it.
-    # This means 1.feature.1 and 1.feature do not conflict but
-    # 1.feature.rst and 1.feature do.
-    counter = 0
-    try:
-        counter = int(parts[-1])
-    except ValueError:
-        pass
-    category = parts[-2]
-    ticket = parts[-3]
-    return ticket, category, counter
+        return (ticket, category, 0) if category in definitions else invalid
+
+    # There are at least 3 parts. Search for a valid category from the second
+    # part onwards.
+    # The category is used as the reference point in the parts list to later
+    # infer the issue number and counter value.
+    for i in range(1, len(parts)):
+        if parts[i] in definitions:
+            # Current part is a valid category according to given definitions.
+            category = parts[i]
+            # Use the previous part as the ticket number.
+            # NOTE: This allows news fragment names like fix-1.2.3.feature or
+            # something-cool.feature.ext for projects that don't use ticket
+            # numbers in news fragment names.
+            ticket = parts[i-1]
+            counter = 0
+            # Use the following part as the counter if it exists and is a valid
+            # digit.
+            if len(parts) > (i + 1) and parts[i+1].isdigit():
+                counter = int(parts[i+1])
+            return ticket, category, counter
+    else:
+        # No valid category found.
+        return invalid
 
 
 # Returns a structure like:
@@ -81,7 +85,7 @@ def find_fragments(base_directory, sections, fragment_directory, definitions):
             ticket, category, counter = parse_newfragment_basename(
                 basename, definitions
             )
-            if category is None or category not in definitions:
+            if category is None:
                 continue
 
             full_filename = os.path.join(section_dir, basename)
diff --git a/src/towncrier/newsfragments/173.feature.rst b/src/towncrier/newsfragments/173.feature.rst
new file mode 100644
index 0000000..feaa1d7
--- /dev/null
+++ b/src/towncrier/newsfragments/173.feature.rst
@@ -0,0 +1,3 @@
+ Improve news fragment file name parsing to allow using file names like
+ ``123.feature.1.ext`` which are convenient when one wants to use an appropriate
+ extension (e.g. ``rst``, ``md``) to enable syntax highlighting.


Test patch:
diff --git a/src/towncrier/test/test_builder.py b/src/towncrier/test/test_builder.py
index d5db5ab..e6d0ed0 100644
--- a/src/towncrier/test/test_builder.py
+++ b/src/towncrier/test/test_builder.py
@@ -13,12 +13,24 @@ class TestParseNewsfragmentBasename(TestCase):
             ("123", "feature", 0),
         )
 
+    def test_invalid_category(self):
+        self.assertEqual(
+            parse_newfragment_basename("README.ext", ["feature"]),
+            (None, None, None),
+        )
+
     def test_counter(self):
         self.assertEqual(
             parse_newfragment_basename("123.feature.1", ["feature"]),
             ("123", "feature", 1),
         )
 
+    def test_counter_with_extension(self):
+        self.assertEqual(
+            parse_newfragment_basename("123.feature.1.ext", ["feature"]),
+            ("123", "feature", 1),
+        )
+
     def test_ignores_extension(self):
         self.assertEqual(
             parse_newfragment_basename("123.feature.ext", ["feature"]),
@@ -31,15 +43,22 @@ class TestParseNewsfragmentBasename(TestCase):
             ("baz", "feature", 0),
         )
 
+    def test_non_numeric_ticket_with_extension(self):
+        self.assertEqual(
+            parse_newfragment_basename("baz.feature.ext", ["feature"]),
+            ("baz", "feature", 0),
+        )
+
     def test_dots_in_ticket_name(self):
         self.assertEqual(
             parse_newfragment_basename("baz.1.2.feature", ["feature"]),
             ("2", "feature", 0),
         )
 
-    def test_dots_in_ticket_name_unknown_category(self):
+    def test_dots_in_ticket_name_invalid_category(self):
         self.assertEqual(
-            parse_newfragment_basename("baz.1.2.notfeature", ["feature"]), ("1", "2", 0)
+            parse_newfragment_basename("baz.1.2.notfeature", ["feature"]),
+            (None, None, None),
         )
 
     def test_dots_in_ticket_name_and_counter(self):