From 7fbe38693a8a6fb63c47205ec3265508c8be74d2 Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith" Date: Sun, 7 Sep 2025 00:48:09 +0000 Subject: [PATCH] Add stdin input and smart section matching to blurb add MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add -D/--rst-on-stdin option to read blurb content from stdin - Requires both --issue and --section when using stdin input - Add smart section matching with case-insensitive and substring matching - Add common section aliases (api→C API, core→Core and Builtins, etc.) - Update tests to reflect new matching behavior - Update documentation with examples of new features --- CHANGELOG.md | 8 +++ README.md | 26 +++++++- src/blurb/_add.py | 160 ++++++++++++++++++++++++++++++++++++++-------- src/blurb/_cli.py | 12 +++- tests/test_add.py | 29 +++++++-- 5 files changed, 201 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04d4ee9..762bb1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 2.2.0 (unreleased) + +- Add the `-D` / `--rst-on-stdin` option to the 'blurb add' command. + This lets you provide the blurb content via stdin for automation. +- Enhanced section matching with smart matching and aliases. + You can now use shortcuts like 'api' for 'C API', 'core' for 'Core and Builtins', etc. +- Section matching now supports flexible patterns with word separators. + ## 2.1.0 - Add the `-i` / `--issue` option to the 'blurb add' command. diff --git a/README.md b/README.md index ff23efc..d66b609 100644 --- a/README.md +++ b/README.md @@ -120,18 +120,40 @@ Here's how you interact with the file: For example, if this should go in the `Library` section, uncomment the line reading `#.. section: Library`. To uncomment, just delete the `#` at the front of the line. - The section can also be specified via the ``-s`` / ``--section`` option: + The section can also be specified via the ``-s`` / ``--section`` option, + which supports case-insensitive matching and common aliases: ```shell $ blurb add -s Library - # or + # or using case-insensitive matching $ blurb add -s library + # or using an alias + $ blurb add -s lib + # More aliases: api→C API, core→Core and Builtins, docs→Documentation ``` * Finally, go to the end of the file, and enter your `NEWS` entry. This should be a single paragraph of English text using simple reST markup. +For automated tools and CI systems, you can provide the blurb content +via stdin using the `-D` / `--rst-on-stdin` option. This requires both +`--issue` and `--section` to be specified: + +```shell +# Provide content via stdin +$ echo "Fixed a bug in the parser" | blurb add -i 12345 -s core -D + +# Use with heredoc for multiline content +$ blurb add -i 12345 -s library -D << 'EOF' +Fixed an issue where :func:`example.function` would fail +when given invalid input. Patch by Jane Doe. +EOF + +# Use in scripts and CI pipelines +$ cat my-news-entry.txt | blurb add -i "$ISSUE" -s "$SECTION" -D +``` + When `blurb add` gets a valid entry, it writes it to a file with the following format: diff --git a/src/blurb/_add.py b/src/blurb/_add.py index c70fd42..061087f 100644 --- a/src/blurb/_add.py +++ b/src/blurb/_add.py @@ -2,6 +2,7 @@ import atexit import os +import re import shlex import shutil import subprocess @@ -22,8 +23,28 @@ else: FALLBACK_EDITORS = ('/etc/alternatives/editor', 'nano') - -def add(*, issue: str | None = None, section: str | None = None): +# Common section name aliases for convenience +SECTION_ALIASES = { + 'api': 'C API', + 'capi': 'C API', + 'c-api': 'C API', + 'builtin': 'Core and Builtins', + 'builtins': 'Core and Builtins', + 'core': 'Core and Builtins', + 'demo': 'Tools/Demos', + 'demos': 'Tools/Demos', + 'tool': 'Tools/Demos', + 'tools': 'Tools/Demos', + 'doc': 'Documentation', + 'docs': 'Documentation', + 'test': 'Tests', + 'lib': 'Library', +} + + +def add( + *, issue: str | None = None, section: str | None = None, rst_on_stdin: bool = False +): """Add a blurb (a Misc/NEWS.d/next entry) to the current CPython repo. Use -i/--issue to specify a GitHub issue number or link, e.g.: @@ -32,11 +53,18 @@ def add(*, issue: str | None = None, section: str | None = None): # or blurb add -i https://github.com/python/cpython/issues/12345 - Use -s/--section to specify the section name (case-insensitive), e.g.: + Use -s/--section to specify the section name (case-insensitive with + smart matching and aliases), e.g.: blurb add -s Library - # or - blurb add -s library + blurb add -s lib # alias for Library + blurb add -s core # alias for Core and Builtins + blurb add -s api # alias for C API + + Use -D/--rst-on-stdin to read the blurb content from stdin + (requires both -i and -s options): + + echo "Fixed a bug in the parser" | blurb add -i 12345 -s core -D The known sections names are defined as follows and spaces in names can be substituted for underscores: @@ -44,28 +72,48 @@ def add(*, issue: str | None = None, section: str | None = None): {sections} """ # fmt: skip + # Validate parameters for stdin mode + if rst_on_stdin: + if not issue or not section: + error('--issue and --section are required when using --rst-on-stdin') + rst_content = sys.stdin.read().strip() + if not rst_content: + error('No content provided on stdin') + else: + rst_content = None + handle, tmp_path = tempfile.mkstemp('.rst') os.close(handle) atexit.register(lambda: os.unlink(tmp_path)) - text = _blurb_template_text(issue=issue, section=section) + text = _blurb_template_text(issue=issue, section=section, rst_content=rst_content) with open(tmp_path, 'w', encoding='utf-8') as file: file.write(text) - args = _editor_args() - args.append(tmp_path) - - while True: - blurb = _add_blurb_from_template(args, tmp_path) - if blurb is None: - try: - prompt('Hit return to retry (or Ctrl-C to abort)') - except KeyboardInterrupt: + if rst_on_stdin: + # When reading from stdin, don't open editor + blurb = Blurbs() + try: + blurb.load(tmp_path) + except BlurbError as e: + error(str(e)) + if len(blurb) > 1: + error("Too many entries! Don't specify '..' on a line by itself.") + else: + args = _editor_args() + args.append(tmp_path) + + while True: + blurb = _add_blurb_from_template(args, tmp_path) + if blurb is None: + try: + prompt('Hit return to retry (or Ctrl-C to abort)') + except KeyboardInterrupt: + print() + return print() - return - print() - continue - break + continue + break path = blurb.save_next() git_add_files.append(path) @@ -108,7 +156,9 @@ def _find_editor() -> str: error('Could not find an editor! Set the EDITOR environment variable.') -def _blurb_template_text(*, issue: str | None, section: str | None) -> str: +def _blurb_template_text( + *, issue: str | None, section: str | None, rst_content: str | None = None +) -> str: issue_number = _extract_issue_number(issue) section_name = _extract_section_name(section) @@ -133,6 +183,11 @@ def _blurb_template_text(*, issue: str | None, section: str | None) -> str: pattern = f'.. section: {section_name}' text = text.replace(f'#{pattern}', pattern) + # If we have content from stdin, add it to the template + if rst_content is not None: + marker = '###########################################################################\n\n' + text = text.replace(marker + '\n', marker + '\n' + rst_content + '\n') + return text @@ -171,25 +226,78 @@ def _extract_section_name(section: str | None, /) -> str | None: if not section: raise SystemExit('Empty section name!') + raw_section = section matches = [] - # Try an exact or lowercase match + + # First, check aliases + section_lower = section.lower() + if section_lower in SECTION_ALIASES: + return SECTION_ALIASES[section_lower] + + # Try exact match (case-sensitive) + if section in sections: + return section + + # Try case-insensitive exact match for section_name in sections: - if section in {section_name, section_name.lower()}: - matches.append(section_name) + if section.lower() == section_name.lower(): + return section_name + + # Try case-insensitive substring match (but not for single special characters) + if len(section_lower) > 1: # Skip single character special searches + for section_name in sections: + if section_lower in section_name.lower(): + matches.append(section_name) + + # If no matches yet, try smart matching + if not matches: + matches = _find_smart_matches(section) if not matches: section_list = '\n'.join(f'* {s}' for s in sections) raise SystemExit( - f'Invalid section name: {section!r}\n\nValid names are:\n\n{section_list}' + f'Invalid section name: {raw_section!r}\n\nValid names are:\n\n{section_list}' ) if len(matches) > 1: - multiple_matches = ', '.join(f'* {m}' for m in sorted(matches)) - raise SystemExit(f'More than one match for {section!r}:\n\n{multiple_matches}') + multiple_matches = '\n'.join(f'* {m}' for m in sorted(matches)) + raise SystemExit( + f'More than one match for {raw_section!r}:\n\n{multiple_matches}' + ) return matches[0] +def _find_smart_matches(section: str, /) -> list[str]: + """Find matches using advanced pattern matching.""" + # Normalize separators and create regex pattern + sanitized = re.sub(r'[_\- /]', ' ', section).strip() + if not sanitized: + return [] + + matches = [] + section_words = re.split(r'\s+', sanitized) + + # Build pattern to match against known sections + # Allow any separators between words + section_pattern = r'[\s/]*'.join(re.escape(word) for word in section_words) + section_regex = re.compile(section_pattern, re.I) + + for section_name in sections: + if section_regex.search(section_name): + matches.append(section_name) + + # Try matching by removing all spaces/separators + if not matches: + normalized = ''.join(section_words).lower() + for section_name in sections: + section_normalized = re.sub(r'[^a-zA-Z0-9]', '', section_name).lower() + if section_normalized.startswith(normalized): + matches.append(section_name) + + return matches + + def _add_blurb_from_template(args: Sequence[str], tmp_path: str) -> Blurbs | None: subprocess.run(args) diff --git a/src/blurb/_cli.py b/src/blurb/_cli.py index 27ac9b3..86a0518 100644 --- a/src/blurb/_cli.py +++ b/src/blurb/_cli.py @@ -93,7 +93,11 @@ def help(subcommand: str | None = None) -> None: nesting = 0 for name, p in inspect.signature(fn).parameters.items(): if p.kind == inspect.Parameter.KEYWORD_ONLY: - short_option = name[0] + # Special case for rst_on_stdin which uses -D + if name == 'rst_on_stdin': + short_option = 'D' + else: + short_option = name[0] if isinstance(p.default, bool): options.append(f' [-{short_option}|--{name}]') else: @@ -195,7 +199,11 @@ def main() -> None: ) kwargs[name] = p.default - short_options[name[0]] = name + # Special case for rst_on_stdin which uses -D + if name == 'rst_on_stdin': + short_options['D'] = name + else: + short_options[name[0]] = name long_options[name] = name filtered_args = [] diff --git a/tests/test_add.py b/tests/test_add.py index 23eb404..a8bc991 100644 --- a/tests/test_add.py +++ b/tests/test_add.py @@ -156,10 +156,6 @@ def test_empty_section_name(section): @pytest.mark.parametrize( 'section', [ - # Wrong capitalisation - 'C api', - 'c API', - 'LibrarY', # Invalid '_', '-', @@ -183,3 +179,28 @@ def test_invalid_section_name(section): with pytest.raises(SystemExit, match=error_message): _blurb_template_text(issue=None, section=section) + + +@pytest.mark.parametrize( + 'section, expected', + [ + # Case variations now work + ('C api', 'C API'), + ('c API', 'C API'), + ('c api', 'C API'), + ('LibrarY', 'Library'), + ('LIBRARY', 'Library'), + # Substring matching + ('lib', 'Library'), + ('api', 'C API'), + ('core', 'Core and Builtins'), + ('builtin', 'Core and Builtins'), + ('doc', 'Documentation'), + ('test', 'Tests'), + ('tool', 'Tools/Demos'), + ('demo', 'Tools/Demos'), + ], +) +def test_smart_section_matching(section, expected): + """Test that smart section matching and aliases work correctly.""" + assert _extract_section_name(section) == expected