22
33import atexit
44import os
5+ import re
56import shlex
67import shutil
78import subprocess
2223else :
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 \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 } '
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+
193301def _add_blurb_from_template (args : Sequence [str ], tmp_path : str ) -> Blurbs | None :
194302 subprocess .run (args )
195303
0 commit comments