Skip to content

Commit

Permalink
Merge pull request #428 from SmileyChris/unlinked-fragments
Browse files Browse the repository at this point in the history
Orphan news fragments
  • Loading branch information
adiroiban committed Sep 16, 2022
2 parents d27bcdf + 48cd347 commit 9e02440
Show file tree
Hide file tree
Showing 11 changed files with 81 additions and 14 deletions.
5 changes: 5 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ For example::

towncrier create 123.feature

If a news fragment is not tied to an issue, use `+` as the basename (a random hash will be added to the filename to keep it unique):

towncrier create +.feature

To produce a draft of the news file, run::

towncrier build --draft
Expand Down Expand Up @@ -149,6 +153,7 @@ Towncrier has the following global options, which can be specified in the toml f
underlines = "=-~"
wrap = false # Wrap text to 79 characters
all_bullets = true # make all fragments bullet points
orphan_prefix = "+" # Prefix for orphan news fragment files, set to "" to disable.
If ``single_file`` is set to ``true`` or unspecified, all changes will be written to a single
fixed newsfile, whose name is literally fixed as the ``filename`` option. In each run of ``towncrier build``,
Expand Down
6 changes: 6 additions & 0 deletions docs/quickstart.rst
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ We can create some example news files to demonstrate::
$ echo 'The final part is ignored, so set it to whatever you want.' > src/myproject/newsfragments/8765.removal.txt
$ echo 'misc is special, and does not put the contents of the file in the newsfile.' > src/myproject/newsfragments/1.misc

For orphan news fragments (those that don't need to be linked to any ticket ID or other identifier), start the file name with ``+``.
The content will still be included in the release notes, at the end of the category corresponding to the file extension::

$ echo 'Fixed an unreported thing!' > src/myproject/newsfragments/+anything.bugfix

We can then see our news fragments compiled by running ``towncrier`` in draft mode::

$ towncrier --draft
Expand All @@ -97,6 +102,7 @@ You should get an output similar to this::
--------

- Fixed a thing! (#1234)
- Fixed an unreported thing!


Improved Documentation
Expand Down
34 changes: 22 additions & 12 deletions src/towncrier/_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
import textwrap
import traceback

from collections import OrderedDict
from typing import Any, Iterator, Mapping, Sequence
from collections import OrderedDict, defaultdict
from typing import Any, DefaultDict, Iterator, Mapping, Sequence

from jinja2 import Template

Expand Down Expand Up @@ -84,12 +84,16 @@ def find_fragments(
sections: Mapping[str, str],
fragment_directory: str | None,
definitions: Sequence[str],
orphan_prefix: str | None = None,
) -> tuple[Mapping[str, Mapping[tuple[str, str, int], str]], list[str]]:
"""
Sections are a dictonary of section names to paths.
"""
content = OrderedDict()
fragment_filenames = []
# 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)

for key, val in sections.items():

Expand Down Expand Up @@ -117,6 +121,11 @@ def find_fragments(
continue
assert ticket is not None
assert counter is not None
if orphan_prefix and ticket.startswith(orphan_prefix):
ticket = ""
# Use and increment the orphan news fragment counter.
counter = orphan_fragment_counter[category]
orphan_fragment_counter[category] += 1

full_filename = os.path.join(section_dir, basename)
fragment_filenames.append(full_filename)
Expand Down Expand Up @@ -176,14 +185,14 @@ def split_fragments(
if definitions[category]["showcontent"] is False:
content = ""

texts = section.get(category, OrderedDict())
texts = section.setdefault(category, OrderedDict())

if texts.get(content):
texts[content] = sorted(texts[content] + [ticket])
else:
texts[content] = [ticket]

section[category] = texts
tickets = texts.setdefault(content, [])
if ticket:
# Only add the ticket if we have one (it can be blank for orphan news
# fragments).
tickets.append(ticket)
tickets.sort()

output[section_name] = section

Expand All @@ -201,9 +210,10 @@ def issue_key(issue: str) -> tuple[int, str]:
return (-1, issue)


def entry_key(entry: tuple[str, Sequence[str]]) -> list[tuple[int, str]]:
_, issues = entry
return [issue_key(issue) for issue in issues]
def entry_key(entry: tuple[str, Sequence[str]]) -> tuple[str, list[tuple[int, str]]]:
content, issues = entry
# Orphan news fragments (those without any issues) should sort last by content.
return "" if issues else content, [issue_key(issue) for issue in issues]


def bullet_key(entry: tuple[str, Sequence[str]]) -> int:
Expand Down
1 change: 1 addition & 0 deletions src/towncrier/_settings/load.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,4 +151,5 @@ def parse_toml(base_path: str, config: Mapping[str, Any]) -> Mapping[str, Any]:
"underlines": config.get("underlines", _underlines),
"wrap": wrap,
"all_bullets": all_bullets,
"orphan_prefix": config.get("orphan_prefix", "+"),
}
6 changes: 5 additions & 1 deletion src/towncrier/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,11 @@ def __main(
fragment_directory = "newsfragments"

fragment_contents, fragment_filenames = find_fragments(
fragment_base_directory, config["sections"], fragment_directory, definitions
fragment_base_directory,
config["sections"],
fragment_directory,
definitions,
config["orphan_prefix"],
)

click.echo("Rendering news fragments...", err=to_err)
Expand Down
4 changes: 4 additions & 0 deletions src/towncrier/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ def __main(
base_directory, config = load_config_from_options(directory, config_path)

definitions = config["types"] or []
orphan_prefix = config["orphan_prefix"]
if orphan_prefix and filename.startswith(f"{orphan_prefix}."):
# Append a random hex string to the orphan news fragment base name.
filename = f"{orphan_prefix}{os.urandom(4).hex()}{filename[1:]}"
if len(filename.split(".")) < 2 or (
filename.split(".")[-1] not in definitions
and filename.split(".")[-2] not in definitions
Expand Down
4 changes: 4 additions & 0 deletions src/towncrier/newsfragments/428.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
You can now create fragments that are not associated with issues. Start the name of the fragment with ``+`` (e.g. ``+anything.feature``).
The content of these orphan news fragments will be included in the release notes, at the end of the category corresponding to the file extension.

To help quickly create a unique orphan news fragment, ``towncrier create +.feature`` will append a random string to the base name of the file, to avoid name collisions.
3 changes: 2 additions & 1 deletion src/towncrier/templates/default.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@

{% if definitions[category]['showcontent'] %}
{% for text, values in sections[section][category].items() %}
- {{ text }} ({{ values|join(', ') }})
- {{ text }}{% if values %} ({{ values|join(', ') }}){% endif %}

{% endfor %}

{% else %}
Expand Down
7 changes: 7 additions & 0 deletions src/towncrier/test/test_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ def _test_command(self, command):
# newsfragment
with open("foo/newsfragments/fix-1.2.feature", "w") as f:
f.write("Baz fix levitation")
# Towncrier supports fragments not linked to a feature
with open("foo/newsfragments/+anything.feature", "w") as f:
f.write("Orphaned feature")
with open("foo/newsfragments/+xxx.feature", "w") as f:
f.write("Another orphaned feature")
# Towncrier ignores files that don't have a dot
with open("foo/newsfragments/README", "w") as f:
f.write("Blah blah")
Expand Down Expand Up @@ -80,6 +85,8 @@ def _test_command(self, command):
- Baz fix levitation (#2)
- Adds levitation (#123)
- Extends levitation (#124)
- Another orphaned feature
- Orphaned feature
"""
),
Expand Down
23 changes: 23 additions & 0 deletions src/towncrier/test/test_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,3 +182,26 @@ def test_file_exists(self):

self.assertEqual(type(result.exception), SystemExit)
self.assertIn("123.feature.rst already exists", result.output)

def test_create_orphan_fragment(self):
"""
When a fragment starts with the only the orphan prefix (``+`` by default), the
create CLI automatically extends the new file's base name to contain a random
value to avoid commit collisions.
"""
runner = CliRunner()

with runner.isolated_filesystem():
setup_simple_project()

self.assertEqual([], os.listdir("foo/newsfragments"))

runner.invoke(_main, ["+.feature"])
fragments = os.listdir("foo/newsfragments")

self.assertEqual(1, len(fragments))
filename = fragments[0]
self.assertTrue(filename.endswith(".feature"))
self.assertTrue(filename.startswith("+"))
# Length should be '+' character and 8 random hex characters.
self.assertEqual(len(filename.split(".")[0]), 9)
2 changes: 2 additions & 0 deletions src/towncrier/test/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ def test_base(self):
f.write(
"""[tool.towncrier]
package = "foobar"
orphan_prefix = "~"
"""
)

Expand All @@ -32,6 +33,7 @@ def test_base(self):
self.assertEqual(config["package_dir"], ".")
self.assertEqual(config["filename"], "NEWS.rst")
self.assertEqual(config["underlines"], ["=", "-", "~"])
self.assertEqual(config["orphan_prefix"], "~")

def test_missing(self):
"""
Expand Down

0 comments on commit 9e02440

Please sign in to comment.