diff --git a/NEWS.rst b/NEWS.rst index 58b9c88e..98a30eb6 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -5,6 +5,7 @@ Release notes .. towncrier release notes start + towncrier 25.8.0 (2025-08-30) ============================= diff --git a/pyproject.toml b/pyproject.toml index f39dd740..2164a188 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] diff --git a/src/towncrier/_builder.py b/src/towncrier/_builder.py index 61eeb60d..620667c1 100644 --- a/src/towncrier/_builder.py +++ b/src/towncrier/_builder.py @@ -178,15 +178,13 @@ 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) - ): + 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 0170a024..133da540 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,8 @@ 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 unittest.mock import file_spec from click import ClickException @@ -57,6 +59,119 @@ 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: + parts = self.filename.split(".") + if len(parts) >= 2: + return parts[-1].lower() + else: + return "" + + @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 + 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_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] + elif len(elements) == 3 or len(elements) == 2: + issue_id = elements[0] + type_name = elements[1] + increment_nr = "0" + else: + 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 + prompt = f"must match to {self.issue_pattern}" + 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 + else: + return prompt class ConfigError(ClickException): @@ -82,8 +197,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..02cd7a3d 100644 --- a/src/towncrier/create.py +++ b/src/towncrier/create.py @@ -10,17 +10,28 @@ 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 +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 != "" + ): + file_name = f"{file_name}.{config.file_extension}" + return file_name + + @click.command(name="create") @click.pass_context @click.option( @@ -54,6 +65,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 +84,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 +106,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,75 +119,58 @@ def __main( edit: bool | None, content: str, section: str | None, + issue: str | None, + fragment_type: str | None, ) -> None: """ The main entry point. """ 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 - - section_provided = section is not None - if not section_provided: - # Get the default section. - if len(config.sections) == 1: - section = next(iter(config.sections)) - 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] - - 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) - raise click.BadParameter( - f"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, + 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 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 (`+` if none):", 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}", ) - 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)), - ) + 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 @@ -177,21 +185,24 @@ 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) - fragments_directory = get_fragments_path(section_directory=config.sections[section]) + + 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( + section_directory=config.sections[section] # type: ignore + ) if not os.path.exists(fragments_directory): os.makedirs(fragments_directory) @@ -212,7 +223,9 @@ 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) @@ -225,14 +238,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_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 diff --git a/src/towncrier/test/test_create.py b/src/towncrier/test/test_create.py index dc6f6b9d..a7eee00d 100644 --- a/src/towncrier/test/test_create.py +++ b/src/towncrier/test/test_create.py @@ -25,24 +25,27 @@ 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)