Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
26 changes: 24 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
160 changes: 134 additions & 26 deletions src/blurb/_add.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import atexit
import os
import re
import shlex
import shutil
import subprocess
Expand All @@ -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.:
Expand All @@ -32,40 +53,67 @@ 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:

{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)
Expand Down Expand Up @@ -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)

Expand All @@ -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


Expand Down Expand Up @@ -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)

Expand Down
12 changes: 10 additions & 2 deletions src/blurb/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 = []
Expand Down
29 changes: 25 additions & 4 deletions tests/test_add.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,10 +156,6 @@ def test_empty_section_name(section):
@pytest.mark.parametrize(
'section',
[
# Wrong capitalisation
'C api',
'c API',
'LibrarY',
# Invalid
'_',
'-',
Expand All @@ -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