2
2
3
3
import atexit
4
4
import os
5
+ import re
5
6
import shlex
6
7
import shutil
7
8
import subprocess
22
23
else :
23
24
FALLBACK_EDITORS = ('/etc/alternatives/editor' , 'nano' )
24
25
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
+ ):
27
48
"""Add a blurb (a Misc/NEWS.d/next entry) to the current CPython repo.
28
49
29
50
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):
32
53
# or
33
54
blurb add -i https://github.com/python/cpython/issues/12345
34
55
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.:
36
58
37
59
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
40
68
41
69
The known sections names are defined as follows and
42
70
spaces in names can be substituted for underscores:
43
71
44
72
{sections}
45
73
""" # fmt: skip
46
74
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
+
47
85
handle , tmp_path = tempfile .mkstemp ('.rst' )
48
86
os .close (handle )
49
87
atexit .register (lambda : os .unlink (tmp_path ))
50
88
51
- text = _blurb_template_text (issue = issue , section = section )
89
+ text = _blurb_template_text (issue = issue , section = section , rst_content = rst_content )
52
90
with open (tmp_path , 'w' , encoding = 'utf-8' ) as file :
53
91
file .write (text )
54
92
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
64
114
print ()
65
- return
66
- print ()
67
- continue
68
- break
115
+ continue
116
+ break
69
117
70
118
path = blurb .save_next ()
71
119
git_add_files .append (path )
@@ -108,7 +156,9 @@ def _find_editor() -> str:
108
156
error ('Could not find an editor! Set the EDITOR environment variable.' )
109
157
110
158
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 :
112
162
issue_number = _extract_issue_number (issue )
113
163
section_name = _extract_section_name (section )
114
164
@@ -133,6 +183,11 @@ def _blurb_template_text(*, issue: str | None, section: str | None) -> str:
133
183
pattern = f'.. section: { section_name } '
134
184
text = text .replace (f'#{ pattern } ' , pattern )
135
185
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
+
136
191
return text
137
192
138
193
@@ -171,25 +226,78 @@ def _extract_section_name(section: str | None, /) -> str | None:
171
226
if not section :
172
227
raise SystemExit ('Empty section name!' )
173
228
229
+ raw_section = section
174
230
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
176
242
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 )
179
255
180
256
if not matches :
181
257
section_list = '\n ' .join (f'* { s } ' for s in sections )
182
258
raise SystemExit (
183
- f'Invalid section name: { section !r} \n \n Valid names are:\n \n { section_list } '
259
+ f'Invalid section name: { raw_section !r} \n \n Valid names are:\n \n { section_list } '
184
260
)
185
261
186
262
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
+ )
189
267
190
268
return matches [0 ]
191
269
192
270
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
+
193
301
def _add_blurb_from_template (args : Sequence [str ], tmp_path : str ) -> Blurbs | None :
194
302
subprocess .run (args )
195
303
0 commit comments