From 4ebde344d3c9a0a74556d504510a39e8c638a91b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 2 Sep 2025 06:08:05 +0200 Subject: [PATCH 1/6] Implement #724 --- .pre-commit-config.yaml | 4 +- pyproject.toml | 2 + src/towncrier/_builder.py | 10 +-- src/towncrier/_settings/load.py | 64 ++++++++++++++++- src/towncrier/create.py | 118 +++++++++++++++++--------------- 5 files changed, 127 insertions(+), 71 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2de8b011..2b868ddb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,7 +26,7 @@ repos: - id: flake8 - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -35,6 +35,6 @@ repos: - id: check-yaml - repo: https://github.com/twisted/towncrier - rev: 24.8.0 + rev: 25.8.0 hooks: - id: towncrier-check diff --git a/pyproject.toml b/pyproject.toml index f39dd740..456b5ca7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dependencies = [ "importlib-metadata>=4.6; python_version<'3.10'", "jinja2", "tomli; python_version<'3.11'", + "questionary", ] [project.optional-dependencies] @@ -77,6 +78,7 @@ exclude = [ package_dir = "src" filename = "NEWS.rst" issue_format = "`#{issue} `_" + issue_pattern = "\\d+" [[tool.towncrier.section]] path = "" diff --git a/src/towncrier/_builder.py b/src/towncrier/_builder.py index 61eeb60d..e67ae5b3 100644 --- a/src/towncrier/_builder.py +++ b/src/towncrier/_builder.py @@ -178,15 +178,7 @@ def find_fragments( counter = orphan_fragment_counter[category] orphan_fragment_counter[category] += 1 - if ( - config.issue_pattern - and issue # not orphan - and not re.fullmatch(config.issue_pattern, issue) - ): - raise ClickException( - f"Issue name '{issue}' does not match the " - f"configured pattern, '{config.issue_pattern}'" - ) + config.check_issue_pattern(issue) full_filename = os.path.join(section_dir, basename) fragment_files.append((full_filename, category)) data = Path(full_filename).read_text(encoding="utf-8", errors="replace") diff --git a/src/towncrier/_settings/load.py b/src/towncrier/_settings/load.py index 0170a024..155d2d28 100644 --- a/src/towncrier/_settings/load.py +++ b/src/towncrier/_settings/load.py @@ -4,6 +4,7 @@ from __future__ import annotations import atexit +import collections import dataclasses import os import re @@ -12,7 +13,7 @@ from collections.abc import Mapping, Sequence from contextlib import ExitStack from pathlib import Path -from typing import Any, Literal +from typing import Any, Dict, Literal # noqa: F401 from click import ClickException @@ -58,6 +59,63 @@ class Config: ignore: list[str] | None = None issue_pattern: str = "" + @property + def section_display_names(self) -> list[str]: + return [x["display_name"] for x in self._section_data().values()] + + def _section_data(self) -> dict[str, dict[str, Any]]: + primary_addition = "(primary)" + primary_exists = False + selections_sections = collections.OrderedDict() + nr_sections = len(self.sections) + paths_seen = set() + for section, path in self.sections.items(): + display_name = section + if path in paths_seen: + raise ConfigError(f"Duplicate path '{path}' for section '{section}'") + paths_seen.add(path) + data = { + "display_name": display_name, + "path": path, + } + + if section == "": + display_name = "None" + + if nr_sections == 1: + display_name = f"{display_name} {primary_addition}" + primary_exists = True + + if data["path"] == "" and not primary_exists: + display_name = f"{display_name} {primary_addition}" + primary_exists = True + + data["display_name"] = display_name + selections_sections[section] = data + + if not primary_exists and nr_sections > 0: + _, first_item = selections_sections.popitem(last=False) + display_name = first_item["display_name"] + first_item.update({"display_name": f"{display_name} {primary_addition}"}) + + return selections_sections + + def get_section_for_display_name(self, section_display_name: str) -> str | None: + for section, section_data in self._section_data().items(): + if section_display_name == section_data["display_name"]: + return section + return None + + def check_issue_pattern(self, issue: str) -> bool | str: + prompt = f"must match to {self.issue_pattern}" + if self.orphan_prefix: + prompt += f" (`{self.orphan_prefix}` if none)" + pattern = re.compile(self.issue_pattern) + if pattern.fullmatch(issue): + return True + else: + return prompt + class ConfigError(ClickException): def __init__(self, *args: str, **kwargs: str): @@ -82,8 +140,8 @@ def load_config_from_options( config_path = os.path.abspath(config_path) # When a directory is provided (in addition to the config file), use it as the base - # directory. Otherwise use the directory containing the config file. - if directory is not None: + # directory. Otherwise, use the directory containing the config file. + if directory and directory != "": base_directory = os.path.abspath(directory) else: base_directory = os.path.dirname(config_path) diff --git a/src/towncrier/create.py b/src/towncrier/create.py index e78fb658..4884f9db 100644 --- a/src/towncrier/create.py +++ b/src/towncrier/create.py @@ -10,9 +10,9 @@ import os from pathlib import Path -from typing import cast import click +import questionary from ._builder import FragmentsPath from ._settings import config_option_help, load_config_from_options @@ -54,6 +54,16 @@ type=str, help="The section to create the fragment for.", ) +@click.option( + "--issue", + type=str, + help="The issue id of the new fragment.", +) +@click.option( + "--fragment-type", + type=str, + help="The type of the new fragment.", +) @click.argument("filename", default="") def _main( ctx: click.Context, @@ -63,6 +73,8 @@ def _main( edit: bool | None, content: str, section: str | None, + issue: str | None, + fragment_type: str | None, ) -> None: """ Create a new news fragment. @@ -83,7 +95,9 @@ def _main( If the FILENAME base is just '+' (to create a fragment not tied to an issue), it will be appended with a random hex string. """ - __main(ctx, directory, config, filename, edit, content, section) + __main( + ctx, directory, config, filename, edit, content, section, issue, fragment_type + ) def __main( @@ -94,6 +108,8 @@ def __main( edit: bool | None, content: str, section: str | None, + issue: str | None, + fragment_type: str | None, ) -> None: """ The main entry point. @@ -106,66 +122,49 @@ def __main( if ext.lower() in (".rst", ".md"): filename_ext = ext - section_provided = section is not None - if not section_provided: - # Get the default section. - if len(config.sections) == 1: - section = next(iter(config.sections)) + if section is None: + if len(config.section_display_names) == 1: + section_display_name = config.section_display_names[0] else: - # If there are multiple sections then the first without a path is the default - # section, otherwise it's the first defined section. - for ( - section_name, - section_dir, - ) in config.sections.items(): # pragma: no branch - if not section_dir: - section = section_name - break - if section is None: - section = list(config.sections.keys())[0] + section_display_name = questionary.select( + "Pick a section:", choices=config.section_display_names + ).ask() + section = config.get_section_for_display_name(section_display_name) + + if section and section.lower() == "none": + section = "" if section not in config.sections: - # Raise a click exception with the correct parameter. - section_param = None - for p in ctx.command.params: # pragma: no branch - if p.name == "section": - section_param = p - break - expected_sections = ", ".join(f"'{s}'" for s in config.sections) + section_param = [x for x in ctx.command.params if x.name == "section"][0] + expected_sections = ", ".join(f"'{s}'" for s in config.section_display_names) raise click.BadParameter( - f"expected one of {expected_sections}", + f"'{section}' is not a valid section name, expected one of {expected_sections}", param=section_param, ) - section = cast(str, section) - - if not filename: - if not section_provided: - sections = list(config.sections) - if len(sections) > 1: - click.echo("Pick a section:") - default_section_index = None - for i, s in enumerate(sections): - click.echo(f" {i+1}: {s or '(primary)'}") - if not default_section_index and s == section: - default_section_index = str(i + 1) - section_index = click.prompt( - "Section", - type=click.Choice([str(i + 1) for i in range(len(sections))]), - default=default_section_index, - ) - section = sections[int(section_index) - 1] - prompt = "Issue number" - # Add info about adding orphan if config is set. - if config.orphan_prefix: - prompt += f" (`{config.orphan_prefix}` if none)" - issue = click.prompt(prompt) - fragment_type = click.prompt( - "Fragment type", - type=click.Choice(list(config.types)), - ) - filename = f"{issue}.{fragment_type}" - if edit is None and content == DEFAULT_CONTENT: - edit = True + + if issue: + check_issue = config.check_issue_pattern(issue) + if isinstance(check_issue, str): + raise click.BadParameter(check_issue) + else: + issue = questionary.text( + "Issue number:", validate=config.check_issue_pattern + ).ask() + + if fragment_type: + expected_types = ", ".join(f"'{s}'" for s in config.types) + if fragment_type not in config.types: + raise click.BadParameter( + f"'{fragment_type}' is not a valid type, expected one of {expected_types}", + ) + else: + fragment_type = questionary.select( + "Fragment type:", choices=[type_name for type_name in config.types.keys()] + ).ask() + + filename = f"{issue}.{fragment_type}" + if edit is None and content == DEFAULT_CONTENT: + edit = True file_dir, file_basename = os.path.split(filename) if config.orphan_prefix and file_basename.startswith(f"{config.orphan_prefix}."): @@ -191,7 +190,12 @@ def __main( filename += filename_ext get_fragments_path = FragmentsPath(base_directory, config) - fragments_directory = get_fragments_path(section_directory=config.sections[section]) + if not section and section not in config.sections: + raise click.BadParameter(f"No such section {section}") + + fragments_directory = get_fragments_path( + section_directory=config.sections[section] # type: ignore + ) if not os.path.exists(fragments_directory): os.makedirs(fragments_directory) From cfa8841e1df66f01ddc1913d3604eae326e63d51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Sch=C3=B6chlin?= Date: Sat, 6 Dec 2025 16:18:38 +0100 Subject: [PATCH 2/6] Fix tests --- pyproject.toml | 1 - src/towncrier/_builder.py | 9 ++++++++- src/towncrier/_settings/load.py | 8 +++++++- src/towncrier/create.py | 2 +- src/towncrier/test/test_build.py | 1 + 5 files changed, 17 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 456b5ca7..2164a188 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,7 +78,6 @@ exclude = [ package_dir = "src" filename = "NEWS.rst" issue_format = "`#{issue} `_" - issue_pattern = "\\d+" [[tool.towncrier.section]] path = "" diff --git a/src/towncrier/_builder.py b/src/towncrier/_builder.py index e67ae5b3..73e464d2 100644 --- a/src/towncrier/_builder.py +++ b/src/towncrier/_builder.py @@ -178,7 +178,14 @@ def find_fragments( counter = orphan_fragment_counter[category] orphan_fragment_counter[category] += 1 - config.check_issue_pattern(issue) + check_pattern_result = config.check_issue_pattern(issue) + if check_pattern_result is not True: + raise ClickException( + f"Issue name '{issue}' does not match the " + f"configured pattern, '{config.issue_pattern}'" + ) + + full_filename = os.path.join(section_dir, basename) fragment_files.append((full_filename, category)) data = Path(full_filename).read_text(encoding="utf-8", errors="replace") diff --git a/src/towncrier/_settings/load.py b/src/towncrier/_settings/load.py index 155d2d28..09191412 100644 --- a/src/towncrier/_settings/load.py +++ b/src/towncrier/_settings/load.py @@ -107,9 +107,15 @@ def get_section_for_display_name(self, section_display_name: str) -> str | None: return None def check_issue_pattern(self, issue: str) -> bool | str: + if issue == "": + return True prompt = f"must match to {self.issue_pattern}" - if self.orphan_prefix: + if issue.startswith(self.orphan_prefix): prompt += f" (`{self.orphan_prefix}` if none)" + + if not self.issue_pattern: + return True + pattern = re.compile(self.issue_pattern) if pattern.fullmatch(issue): return True diff --git a/src/towncrier/create.py b/src/towncrier/create.py index 4884f9db..e9605b3c 100644 --- a/src/towncrier/create.py +++ b/src/towncrier/create.py @@ -144,7 +144,7 @@ def __main( if issue: check_issue = config.check_issue_pattern(issue) - if isinstance(check_issue, str): + if check_issue is not True: raise click.BadParameter(check_issue) else: issue = questionary.text( diff --git a/src/towncrier/test/test_build.py b/src/towncrier/test/test_build.py index 7ff1ecab..339a9b38 100644 --- a/src/towncrier/test/test_build.py +++ b/src/towncrier/test/test_build.py @@ -847,6 +847,7 @@ def do_build_once_with(version, fragment_file, fragment): ], catch_exceptions=False, ) + # Fragment files unknown to git are removed even without a git repo assert not Path(f"newsfragments/{fragment_file}").exists() return result From 3f64773ea5d613061eaa0ef9c2ecd5c53408114d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Sch=C3=B6chlin?= Date: Sun, 7 Dec 2025 15:01:43 +0100 Subject: [PATCH 3/6] fix tests --- NEWS.rst | 15 ++++ src/towncrier/_settings/load.py | 50 +++++++++++++ src/towncrier/create.py | 120 +++++++++++++++--------------- src/towncrier/test/test_create.py | 7 +- 4 files changed, 129 insertions(+), 63 deletions(-) diff --git a/NEWS.rst b/NEWS.rst index 58b9c88e..ef7a8649 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -5,6 +5,21 @@ Release notes .. towncrier release notes start +towncrier 25.8.0.dev0 (2025-12-06) +================================== + +Features +-------- + +- sdfsdfsdf (`#234 `_) + + +Misc +---- + +- `#719 `_, `#729 `_ + + towncrier 25.8.0 (2025-08-30) ============================= diff --git a/src/towncrier/_settings/load.py b/src/towncrier/_settings/load.py index 09191412..ce91ba61 100644 --- a/src/towncrier/_settings/load.py +++ b/src/towncrier/_settings/load.py @@ -14,6 +14,7 @@ from contextlib import ExitStack from pathlib import Path from typing import Any, Dict, Literal # noqa: F401 +from unittest.mock import file_spec from click import ClickException @@ -58,11 +59,24 @@ class Config: create_add_extension: bool = True ignore: list[str] | None = None issue_pattern: str = "" + regular_file_extensions: list[str] = dataclasses.field(default_factory=lambda: ["md", "rst"]) @property def section_display_names(self) -> list[str]: return [x["display_name"] for x in self._section_data().values()] + @property + def file_extension(self) -> str: + return self.filename.split(".")[-1].lower() + + @property + def file_extension_for_edit(self) -> str: + if self.file_extension in self.regular_file_extensions: + return self.file_extension + else: + return "txt" + + def _section_data(self) -> dict[str, dict[str, Any]]: primary_addition = "(primary)" primary_exists = False @@ -106,6 +120,42 @@ def get_section_for_display_name(self, section_display_name: str) -> str | None: return section return None + def check_filename(self, filename: str) -> bool | str: + message = "Expected filename '{}' to be of format '{{name}}.{{type}}.{{extension}}', " + \ + "where '{{name}}' is an arbitrary slug and '{{type}}' is " + \ + "one of: {}".format(filename, ", ".join(self.types)) + + elements = filename.split(".") + if len(elements) == 4: + issue_id = elements[0] + type_name = elements[1] + increment_nr = elements[2] + file_extension = elements[3] + elif len(elements) == 3: + issue_id = elements[0] + type_name = elements[1] + increment_nr = "0" + file_extension = elements[2] + else: + return message + + # TODO + #if file_extension != self.file_extension: + # return message + + if not increment_nr.isdigit(): + return message + + if type_name not in self.types.keys(): + return message + + issue_check_result = self.check_issue_pattern(issue_id) + + if issue_check_result is not True: + return message + + return True + def check_issue_pattern(self, issue: str) -> bool | str: if issue == "": return True diff --git a/src/towncrier/create.py b/src/towncrier/create.py index e9605b3c..198e7321 100644 --- a/src/towncrier/create.py +++ b/src/towncrier/create.py @@ -14,12 +14,17 @@ import click import questionary +from ._settings.load import Config from ._builder import FragmentsPath from ._settings import config_option_help, load_config_from_options DEFAULT_CONTENT = "Add your info here" +def add_file_extension(file_name: str, config: Config) -> str: + if config.create_add_extension and len(file_name.split(".")) == 2: + file_name = f"{file_name}.{config.file_extension}" + return file_name @click.command(name="create") @click.pass_context @@ -116,55 +121,51 @@ def __main( """ base_directory, config = load_config_from_options(directory, config_path) - filename_ext = "" - if config.create_add_extension: - ext = os.path.splitext(config.filename)[1] - if ext.lower() in (".rst", ".md"): - filename_ext = ext + if not filename: + if section is None: + if len(config.section_display_names) == 1: + section_display_name = config.section_display_names[0] + else: + section_display_name = questionary.select( + "Pick a section:", choices=config.section_display_names + ).ask() + section = config.get_section_for_display_name(section_display_name) + + if section and section.lower() == "none": + section = "" + + if section not in config.sections: + section_param = [x for x in ctx.command.params if x.name == "section"][0] + expected_sections = ", ".join(f"'{s}'" for s in config.section_display_names) + raise click.BadParameter( + f"'{section}' is not a valid section name, expected one of {expected_sections}", + param=section_param, + ) - if section is None: - if len(config.section_display_names) == 1: - section_display_name = config.section_display_names[0] + + if issue: + check_issue = config.check_issue_pattern(issue) + if check_issue is not True: + raise click.BadParameter(check_issue) else: - section_display_name = questionary.select( - "Pick a section:", choices=config.section_display_names + issue = questionary.text( + "Issue number (`+` if none):", validate=config.check_issue_pattern ).ask() - section = config.get_section_for_display_name(section_display_name) - - if section and section.lower() == "none": - section = "" - if section not in config.sections: - section_param = [x for x in ctx.command.params if x.name == "section"][0] - expected_sections = ", ".join(f"'{s}'" for s in config.section_display_names) - raise click.BadParameter( - f"'{section}' is not a valid section name, expected one of {expected_sections}", - param=section_param, - ) - - if issue: - check_issue = config.check_issue_pattern(issue) - if check_issue is not True: - raise click.BadParameter(check_issue) - else: - issue = questionary.text( - "Issue number:", validate=config.check_issue_pattern - ).ask() - - if fragment_type: - expected_types = ", ".join(f"'{s}'" for s in config.types) - if fragment_type not in config.types: - raise click.BadParameter( - f"'{fragment_type}' is not a valid type, expected one of {expected_types}", - ) - else: - fragment_type = questionary.select( - "Fragment type:", choices=[type_name for type_name in config.types.keys()] - ).ask() + if fragment_type: + expected_types = ", ".join(f"'{s}'" for s in config.types) + if fragment_type not in config.types: + raise click.BadParameter( + f"'{fragment_type}' is not a valid type, expected one of {expected_types}", + ) + else: + fragment_type = questionary.select( + "Fragment type:", choices=[type_name for type_name in config.types.keys()] + ).ask() - filename = f"{issue}.{fragment_type}" - if edit is None and content == DEFAULT_CONTENT: - edit = True + filename = f"{issue}.{fragment_type}" + if edit is None and content == DEFAULT_CONTENT: + edit = True file_dir, file_basename = os.path.split(filename) if config.orphan_prefix and file_basename.startswith(f"{config.orphan_prefix}."): @@ -176,21 +177,20 @@ def __main( f"{file_basename[len(config.orphan_prefix):]}" ), ) - filename_parts = filename.split(".") - if len(filename_parts) < 2 or ( - filename_parts[-1] not in config.types - and filename_parts[-2] not in config.types - ): - raise click.BadParameter( - "Expected filename '{}' to be of format '{{name}}.{{type}}', " - "where '{{name}}' is an arbitrary slug and '{{type}}' is " - "one of: {}".format(filename, ", ".join(config.types)) - ) - if filename_parts[-1] in config.types and filename_ext: - filename += filename_ext + + + filename = add_file_extension(filename, config) + + check_filename_result = config.check_filename(filename) + if check_filename_result is not True: + raise click.BadParameter(check_filename_result) get_fragments_path = FragmentsPath(base_directory, config) - if not section and section not in config.sections: + + if section is None: + section = "" + + if section and section not in config.sections: raise click.BadParameter(f"No such section {section}") fragments_directory = get_fragments_path( @@ -216,7 +216,7 @@ def __main( if edit: if content == DEFAULT_CONTENT: content = "" - content = _get_news_content_from_user(content, extension=filename_ext) + content = _get_news_content_from_user(content, extension=config.file_extension_for_edit) if not content: click.echo("Aborted creating news fragment due to empty message.") ctx.exit(1) @@ -229,14 +229,14 @@ def __main( click.echo(f"Created news fragment at {segment_file}") -def _get_news_content_from_user(message: str, extension: str = "") -> str: +def _get_news_content_from_user(message: str, extension: str) -> str: initial_content = """ # Please write your news content. Lines starting with '#' will be ignored, and # an empty message aborts. """ if message: initial_content = f"{message}\n{initial_content}" - content = click.edit(initial_content, extension=extension or ".txt") + content = click.edit(initial_content, extension=f".{extension}") if content is None: return message all_lines = content.split("\n") diff --git a/src/towncrier/test/test_create.py b/src/towncrier/test/test_create.py index dc6f6b9d..04c7d1f1 100644 --- a/src/towncrier/test/test_create.py +++ b/src/towncrier/test/test_create.py @@ -25,24 +25,25 @@ def _test_success( mkdir=True, additional_args=None, eof_newline=True, + file_extension="rst", ): runner = CliRunner() with runner.isolated_filesystem(): setup_simple_project(config=config, mkdir_newsfragments=mkdir) - args = ["123.feature.rst"] + args = [f"123.feature.{file_extension}"] if content is None: content = [DEFAULT_CONTENT] if additional_args is not None: args.extend(additional_args) result = runner.invoke(_main, args) - self.assertEqual(["123.feature.rst"], os.listdir("foo/newsfragments")) + self.assertEqual([f"123.feature.{file_extension}"], os.listdir("foo/newsfragments")) if eof_newline: content.append("") - with open("foo/newsfragments/123.feature.rst") as fh: + with open(f"foo/newsfragments/123.feature.{file_extension}") as fh: self.assertEqual("\n".join(content), fh.read()) self.assertEqual(0, result.exit_code) From 522771875aec19df967cf243020fa6e5683f0eb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Sch=C3=B6chlin?= Date: Sun, 7 Dec 2025 15:12:07 +0100 Subject: [PATCH 4/6] add --- src/towncrier/_settings/load.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/towncrier/_settings/load.py b/src/towncrier/_settings/load.py index ce91ba61..47b03b90 100644 --- a/src/towncrier/_settings/load.py +++ b/src/towncrier/_settings/load.py @@ -130,18 +130,13 @@ def check_filename(self, filename: str) -> bool | str: issue_id = elements[0] type_name = elements[1] increment_nr = elements[2] - file_extension = elements[3] - elif len(elements) == 3: + elif len(elements) == 3 or len(elements) == 2: issue_id = elements[0] type_name = elements[1] increment_nr = "0" - file_extension = elements[2] else: return message - # TODO - #if file_extension != self.file_extension: - # return message if not increment_nr.isdigit(): return message From 8dd99eca3a154ecc60755d87c4f31cce2e9eddf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Sch=C3=B6chlin?= Date: Sun, 7 Dec 2025 15:55:42 +0100 Subject: [PATCH 5/6] fixing tests --- NEWS.rst | 14 -------------- src/towncrier/_settings/load.py | 12 ++++++++---- src/towncrier/create.py | 2 +- 3 files changed, 9 insertions(+), 19 deletions(-) diff --git a/NEWS.rst b/NEWS.rst index ef7a8649..98a30eb6 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -5,20 +5,6 @@ Release notes .. towncrier release notes start -towncrier 25.8.0.dev0 (2025-12-06) -================================== - -Features --------- - -- sdfsdfsdf (`#234 `_) - - -Misc ----- - -- `#719 `_, `#729 `_ - towncrier 25.8.0 (2025-08-30) ============================= diff --git a/src/towncrier/_settings/load.py b/src/towncrier/_settings/load.py index 47b03b90..7a73bad5 100644 --- a/src/towncrier/_settings/load.py +++ b/src/towncrier/_settings/load.py @@ -67,7 +67,11 @@ def section_display_names(self) -> list[str]: @property def file_extension(self) -> str: - return self.filename.split(".")[-1].lower() + parts = self.filename.split(".") + if len(parts) >= 2: + return parts[-1].lower() + else: + return "" @property def file_extension_for_edit(self) -> str: @@ -121,9 +125,9 @@ def get_section_for_display_name(self, section_display_name: str) -> str | None: return None def check_filename(self, filename: str) -> bool | str: - message = "Expected filename '{}' to be of format '{{name}}.{{type}}.{{extension}}', " + \ - "where '{{name}}' is an arbitrary slug and '{{type}}' is " + \ - "one of: {}".format(filename, ", ".join(self.types)) + message = ("Expected filename '{}' to be of format '{{name}}.{{type}}.{{extension}}', " + "where '{{name}}' is an arbitrary slug and '{{type}}' is " + "one of: {}".format(filename, ", ".join(self.types))) elements = filename.split(".") if len(elements) == 4: diff --git a/src/towncrier/create.py b/src/towncrier/create.py index 198e7321..96e0c2a3 100644 --- a/src/towncrier/create.py +++ b/src/towncrier/create.py @@ -22,7 +22,7 @@ DEFAULT_CONTENT = "Add your info here" def add_file_extension(file_name: str, config: Config) -> str: - if config.create_add_extension and len(file_name.split(".")) == 2: + if config.create_add_extension and len(file_name.split(".")) == 2 and config.file_extension != "": file_name = f"{file_name}.{config.file_extension}" return file_name From dfd1e8b737579dffb610a2b2eb74b11a0515b19b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 7 Dec 2025 14:55:55 +0000 Subject: [PATCH 6/6] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/towncrier/_builder.py | 1 - src/towncrier/_settings/load.py | 16 +++++++++------- src/towncrier/create.py | 23 ++++++++++++++++------- src/towncrier/test/test_create.py | 4 +++- 4 files changed, 28 insertions(+), 16 deletions(-) diff --git a/src/towncrier/_builder.py b/src/towncrier/_builder.py index 73e464d2..620667c1 100644 --- a/src/towncrier/_builder.py +++ b/src/towncrier/_builder.py @@ -185,7 +185,6 @@ def find_fragments( f"configured pattern, '{config.issue_pattern}'" ) - full_filename = os.path.join(section_dir, basename) fragment_files.append((full_filename, category)) data = Path(full_filename).read_text(encoding="utf-8", errors="replace") diff --git a/src/towncrier/_settings/load.py b/src/towncrier/_settings/load.py index 7a73bad5..133da540 100644 --- a/src/towncrier/_settings/load.py +++ b/src/towncrier/_settings/load.py @@ -59,7 +59,9 @@ class Config: create_add_extension: bool = True ignore: list[str] | None = None issue_pattern: str = "" - regular_file_extensions: list[str] = dataclasses.field(default_factory=lambda: ["md", "rst"]) + regular_file_extensions: list[str] = dataclasses.field( + default_factory=lambda: ["md", "rst"] + ) @property def section_display_names(self) -> list[str]: @@ -75,12 +77,11 @@ def file_extension(self) -> str: @property def file_extension_for_edit(self) -> str: - if self.file_extension in self.regular_file_extensions: + if self.file_extension in self.regular_file_extensions: return self.file_extension else: return "txt" - def _section_data(self) -> dict[str, dict[str, Any]]: primary_addition = "(primary)" primary_exists = False @@ -125,9 +126,11 @@ def get_section_for_display_name(self, section_display_name: str) -> str | None: return None def check_filename(self, filename: str) -> bool | str: - message = ("Expected filename '{}' to be of format '{{name}}.{{type}}.{{extension}}', " - "where '{{name}}' is an arbitrary slug and '{{type}}' is " - "one of: {}".format(filename, ", ".join(self.types))) + message = ( + "Expected filename '{}' to be of format '{{name}}.{{type}}.{{extension}}', " + "where '{{name}}' is an arbitrary slug and '{{type}}' is " + "one of: {}".format(filename, ", ".join(self.types)) + ) elements = filename.split(".") if len(elements) == 4: @@ -141,7 +144,6 @@ def check_filename(self, filename: str) -> bool | str: else: return message - if not increment_nr.isdigit(): return message diff --git a/src/towncrier/create.py b/src/towncrier/create.py index 96e0c2a3..02cd7a3d 100644 --- a/src/towncrier/create.py +++ b/src/towncrier/create.py @@ -14,18 +14,24 @@ import click import questionary -from ._settings.load import Config from ._builder import FragmentsPath from ._settings import config_option_help, load_config_from_options +from ._settings.load import Config DEFAULT_CONTENT = "Add your info here" + def add_file_extension(file_name: str, config: Config) -> str: - if config.create_add_extension and len(file_name.split(".")) == 2 and config.file_extension != "": + if ( + config.create_add_extension + and len(file_name.split(".")) == 2 + and config.file_extension != "" + ): file_name = f"{file_name}.{config.file_extension}" return file_name + @click.command(name="create") @click.pass_context @click.option( @@ -136,13 +142,14 @@ def __main( if section not in config.sections: section_param = [x for x in ctx.command.params if x.name == "section"][0] - expected_sections = ", ".join(f"'{s}'" for s in config.section_display_names) + expected_sections = ", ".join( + f"'{s}'" for s in config.section_display_names + ) raise click.BadParameter( f"'{section}' is not a valid section name, expected one of {expected_sections}", param=section_param, ) - if issue: check_issue = config.check_issue_pattern(issue) if check_issue is not True: @@ -160,7 +167,8 @@ def __main( ) else: fragment_type = questionary.select( - "Fragment type:", choices=[type_name for type_name in config.types.keys()] + "Fragment type:", + choices=[type_name for type_name in config.types.keys()], ).ask() filename = f"{issue}.{fragment_type}" @@ -178,7 +186,6 @@ def __main( ), ) - filename = add_file_extension(filename, config) check_filename_result = config.check_filename(filename) @@ -216,7 +223,9 @@ def __main( if edit: if content == DEFAULT_CONTENT: content = "" - content = _get_news_content_from_user(content, extension=config.file_extension_for_edit) + content = _get_news_content_from_user( + content, extension=config.file_extension_for_edit + ) if not content: click.echo("Aborted creating news fragment due to empty message.") ctx.exit(1) diff --git a/src/towncrier/test/test_create.py b/src/towncrier/test/test_create.py index 04c7d1f1..a7eee00d 100644 --- a/src/towncrier/test/test_create.py +++ b/src/towncrier/test/test_create.py @@ -39,7 +39,9 @@ def _test_success( args.extend(additional_args) result = runner.invoke(_main, args) - self.assertEqual([f"123.feature.{file_extension}"], os.listdir("foo/newsfragments")) + self.assertEqual( + [f"123.feature.{file_extension}"], os.listdir("foo/newsfragments") + ) if eof_newline: content.append("")