Skip to content

Commit a883941

Browse files
committed
Add stdin input and smart section matching to blurb add
- 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
1 parent 4423aa9 commit a883941

File tree

5 files changed

+203
-34
lines changed

5 files changed

+203
-34
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
## 2.2.0 (unreleased)
4+
5+
- Add the `-D` / `--rst-on-stdin` option to the 'blurb add' command.
6+
This lets you provide the blurb content via stdin for automation.
7+
- Enhanced section matching with smart matching and aliases.
8+
You can now use shortcuts like 'api' for 'C API', 'core' for 'Core and Builtins', etc.
9+
- Section matching now supports flexible patterns with word separators.
10+
311
## 2.1.0
412

513
- Add the `-i` / `--issue` option to the 'blurb add' command.

README.md

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,12 +120,16 @@ Here's how you interact with the file:
120120
For example, if this should go in the `Library` section, uncomment
121121
the line reading `#.. section: Library`. To uncomment, just delete
122122
the `#` at the front of the line.
123-
The section can also be specified via the ``-s`` / ``--section`` option:
123+
The section can also be specified via the ``-s`` / ``--section`` option,
124+
which supports case-insensitive matching and common aliases:
124125

125126
```shell
126127
$ blurb add -s Library
127-
# or
128+
# or using case-insensitive matching
128129
$ blurb add -s library
130+
# or using an alias
131+
$ blurb add -s lib
132+
# More aliases: api→C API, core→Core and Builtins, docs→Documentation
129133
```
130134

131135
* Finally, go to the end of the file, and enter your `NEWS` entry.
@@ -137,6 +141,26 @@ with the following format:
137141

138142
Misc/NEWS.d/next/<section>/<datetime>.gh-issue-<issue_number>.<nonce>.rst
139143

144+
#### Automation support
145+
146+
For automated tools and CI systems, you can provide the blurb content
147+
via stdin using the `-D` / `--rst-on-stdin` option. This requires both
148+
`--issue` and `--section` to be specified:
149+
150+
```shell
151+
# Provide content via stdin
152+
$ echo "Fixed a bug in the parser" | blurb add -i 12345 -s core -D
153+
154+
# Use with heredoc for multiline content
155+
$ blurb add -i 12345 -s library -D << 'EOF'
156+
Fixed an issue where :func:`example.function` would fail
157+
when given invalid input. Patch by Jane Doe.
158+
EOF
159+
160+
# Use in scripts and CI pipelines
161+
$ cat my-news-entry.txt | blurb add -i "$ISSUE" -s "$SECTION" -D
162+
```
163+
140164
For example, a file added by `blurb add` might look like this::
141165

142166
Misc/NEWS.d/next/Library/2017-05-04-12-24-06.gh-issue-25458.Yl4gI2.rst

src/blurb/_add.py

Lines changed: 134 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import atexit
44
import os
5+
import re
56
import shlex
67
import shutil
78
import subprocess
@@ -22,8 +23,28 @@
2223
else:
2324
FALLBACK_EDITORS = ('/etc/alternatives/editor', 'nano')
2425

25-
26-
def add(*, issue: str | None = None, section: str | None = None):
26+
# Common section name aliases for convenience
27+
SECTION_ALIASES = {
28+
'api': 'C API',
29+
'capi': 'C API',
30+
'c-api': 'C API',
31+
'builtin': 'Core and Builtins',
32+
'builtins': 'Core and Builtins',
33+
'core': 'Core and Builtins',
34+
'demo': 'Tools/Demos',
35+
'demos': 'Tools/Demos',
36+
'tool': 'Tools/Demos',
37+
'tools': 'Tools/Demos',
38+
'doc': 'Documentation',
39+
'docs': 'Documentation',
40+
'test': 'Tests',
41+
'lib': 'Library',
42+
}
43+
44+
45+
def add(
46+
*, issue: str | None = None, section: str | None = None, rst_on_stdin: bool = False
47+
):
2748
"""Add a blurb (a Misc/NEWS.d/next entry) to the current CPython repo.
2849
2950
Use -i/--issue to specify a GitHub issue number or link, e.g.:
@@ -32,40 +53,67 @@ def add(*, issue: str | None = None, section: str | None = None):
3253
# or
3354
blurb add -i https://github.com/python/cpython/issues/12345
3455
35-
Use -s/--section to specify the section name (case-insensitive), e.g.:
56+
Use -s/--section to specify the section name (case-insensitive with
57+
smart matching and aliases), e.g.:
3658
3759
blurb add -s Library
38-
# or
39-
blurb add -s library
60+
blurb add -s lib # alias for Library
61+
blurb add -s core # alias for Core and Builtins
62+
blurb add -s api # alias for C API
63+
64+
Use -D/--rst-on-stdin to read the blurb content from stdin
65+
(requires both -i and -s options):
66+
67+
echo "Fixed a bug in the parser" | blurb add -i 12345 -s core -D
4068
4169
The known sections names are defined as follows and
4270
spaces in names can be substituted for underscores:
4371
4472
{sections}
4573
""" # fmt: skip
4674

75+
# Validate parameters for stdin mode
76+
if rst_on_stdin:
77+
if not issue or not section:
78+
error('--issue and --section are required when using --rst-on-stdin')
79+
rst_content = sys.stdin.read().strip()
80+
if not rst_content:
81+
error('No content provided on stdin')
82+
else:
83+
rst_content = None
84+
4785
handle, tmp_path = tempfile.mkstemp('.rst')
4886
os.close(handle)
4987
atexit.register(lambda: os.unlink(tmp_path))
5088

51-
text = _blurb_template_text(issue=issue, section=section)
89+
text = _blurb_template_text(issue=issue, section=section, rst_content=rst_content)
5290
with open(tmp_path, 'w', encoding='utf-8') as file:
5391
file.write(text)
5492

55-
args = _editor_args()
56-
args.append(tmp_path)
57-
58-
while True:
59-
blurb = _add_blurb_from_template(args, tmp_path)
60-
if blurb is None:
61-
try:
62-
prompt('Hit return to retry (or Ctrl-C to abort)')
63-
except KeyboardInterrupt:
93+
if rst_on_stdin:
94+
# When reading from stdin, don't open editor
95+
blurb = Blurbs()
96+
try:
97+
blurb.load(tmp_path)
98+
except BlurbError as e:
99+
error(str(e))
100+
if len(blurb) > 1:
101+
error("Too many entries! Don't specify '..' on a line by itself.")
102+
else:
103+
args = _editor_args()
104+
args.append(tmp_path)
105+
106+
while True:
107+
blurb = _add_blurb_from_template(args, tmp_path)
108+
if blurb is None:
109+
try:
110+
prompt('Hit return to retry (or Ctrl-C to abort)')
111+
except KeyboardInterrupt:
112+
print()
113+
return
64114
print()
65-
return
66-
print()
67-
continue
68-
break
115+
continue
116+
break
69117

70118
path = blurb.save_next()
71119
git_add_files.append(path)
@@ -108,7 +156,9 @@ def _find_editor() -> str:
108156
error('Could not find an editor! Set the EDITOR environment variable.')
109157

110158

111-
def _blurb_template_text(*, issue: str | None, section: str | None) -> str:
159+
def _blurb_template_text(
160+
*, issue: str | None, section: str | None, rst_content: str | None = None
161+
) -> str:
112162
issue_number = _extract_issue_number(issue)
113163
section_name = _extract_section_name(section)
114164

@@ -133,6 +183,11 @@ def _blurb_template_text(*, issue: str | None, section: str | None) -> str:
133183
pattern = f'.. section: {section_name}'
134184
text = text.replace(f'#{pattern}', pattern)
135185

186+
# If we have content from stdin, add it to the template
187+
if rst_content is not None:
188+
marker = '###########################################################################\n\n'
189+
text = text.replace(marker + '\n', marker + '\n' + rst_content + '\n')
190+
136191
return text
137192

138193

@@ -171,25 +226,78 @@ def _extract_section_name(section: str | None, /) -> str | None:
171226
if not section:
172227
raise SystemExit('Empty section name!')
173228

229+
raw_section = section
174230
matches = []
175-
# Try an exact or lowercase match
231+
232+
# First, check aliases
233+
section_lower = section.lower()
234+
if section_lower in SECTION_ALIASES:
235+
return SECTION_ALIASES[section_lower]
236+
237+
# Try exact match (case-sensitive)
238+
if section in sections:
239+
return section
240+
241+
# Try case-insensitive exact match
176242
for section_name in sections:
177-
if section in {section_name, section_name.lower()}:
178-
matches.append(section_name)
243+
if section.lower() == section_name.lower():
244+
return section_name
245+
246+
# Try case-insensitive substring match (but not for single special characters)
247+
if len(section_lower) > 1: # Skip single character special searches
248+
for section_name in sections:
249+
if section_lower in section_name.lower():
250+
matches.append(section_name)
251+
252+
# If no matches yet, try smart matching
253+
if not matches:
254+
matches = _find_smart_matches(section)
179255

180256
if not matches:
181257
section_list = '\n'.join(f'* {s}' for s in sections)
182258
raise SystemExit(
183-
f'Invalid section name: {section!r}\n\nValid names are:\n\n{section_list}'
259+
f'Invalid section name: {raw_section!r}\n\nValid names are:\n\n{section_list}'
184260
)
185261

186262
if len(matches) > 1:
187-
multiple_matches = ', '.join(f'* {m}' for m in sorted(matches))
188-
raise SystemExit(f'More than one match for {section!r}:\n\n{multiple_matches}')
263+
multiple_matches = '\n'.join(f'* {m}' for m in sorted(matches))
264+
raise SystemExit(
265+
f'More than one match for {raw_section!r}:\n\n{multiple_matches}'
266+
)
189267

190268
return matches[0]
191269

192270

271+
def _find_smart_matches(section: str, /) -> list[str]:
272+
"""Find matches using advanced pattern matching."""
273+
# Normalize separators and create regex pattern
274+
sanitized = re.sub(r'[_\- /]', ' ', section).strip()
275+
if not sanitized:
276+
return []
277+
278+
matches = []
279+
section_words = re.split(r'\s+', sanitized)
280+
281+
# Build pattern to match against known sections
282+
# Allow any separators between words
283+
section_pattern = r'[\s/]*'.join(re.escape(word) for word in section_words)
284+
section_regex = re.compile(section_pattern, re.I)
285+
286+
for section_name in sections:
287+
if section_regex.search(section_name):
288+
matches.append(section_name)
289+
290+
# Try matching by removing all spaces/separators
291+
if not matches:
292+
normalized = ''.join(section_words).lower()
293+
for section_name in sections:
294+
section_normalized = re.sub(r'[^a-zA-Z0-9]', '', section_name).lower()
295+
if section_normalized.startswith(normalized):
296+
matches.append(section_name)
297+
298+
return matches
299+
300+
193301
def _add_blurb_from_template(args: Sequence[str], tmp_path: str) -> Blurbs | None:
194302
subprocess.run(args)
195303

src/blurb/_cli.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,11 @@ def help(subcommand: str | None = None) -> None:
9393
nesting = 0
9494
for name, p in inspect.signature(fn).parameters.items():
9595
if p.kind == inspect.Parameter.KEYWORD_ONLY:
96-
short_option = name[0]
96+
# Special case for rst_on_stdin which uses -D
97+
if name == 'rst_on_stdin':
98+
short_option = 'D'
99+
else:
100+
short_option = name[0]
97101
if isinstance(p.default, bool):
98102
options.append(f' [-{short_option}|--{name}]')
99103
else:
@@ -195,7 +199,11 @@ def main() -> None:
195199
)
196200

197201
kwargs[name] = p.default
198-
short_options[name[0]] = name
202+
# Special case for rst_on_stdin which uses -D
203+
if name == 'rst_on_stdin':
204+
short_options['D'] = name
205+
else:
206+
short_options[name[0]] = name
199207
long_options[name] = name
200208

201209
filtered_args = []

tests/test_add.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -156,10 +156,6 @@ def test_empty_section_name(section):
156156
@pytest.mark.parametrize(
157157
'section',
158158
[
159-
# Wrong capitalisation
160-
'C api',
161-
'c API',
162-
'LibrarY',
163159
# Invalid
164160
'_',
165161
'-',
@@ -183,3 +179,28 @@ def test_invalid_section_name(section):
183179

184180
with pytest.raises(SystemExit, match=error_message):
185181
_blurb_template_text(issue=None, section=section)
182+
183+
184+
@pytest.mark.parametrize(
185+
'section, expected',
186+
[
187+
# Case variations now work
188+
('C api', 'C API'),
189+
('c API', 'C API'),
190+
('c api', 'C API'),
191+
('LibrarY', 'Library'),
192+
('LIBRARY', 'Library'),
193+
# Substring matching
194+
('lib', 'Library'),
195+
('api', 'C API'),
196+
('core', 'Core and Builtins'),
197+
('builtin', 'Core and Builtins'),
198+
('doc', 'Documentation'),
199+
('test', 'Tests'),
200+
('tool', 'Tools/Demos'),
201+
('demo', 'Tools/Demos'),
202+
],
203+
)
204+
def test_smart_section_matching(section, expected):
205+
"""Test that smart section matching and aliases work correctly."""
206+
assert _extract_section_name(section) == expected

0 commit comments

Comments
 (0)