Skip to content

Commit 17f62d5

Browse files
gpsheadclaude
andcommitted
Add automation features to blurb add command
This commit adds three new options to 'blurb add' for automation: - --gh_issue: Specify GitHub issue number - --section: Specify NEWS section (must be from the sacred list) - --rst_on_stdin: Read the news entry from stdin (no editor needed) When using --rst_on_stdin, one must provide both --gh_issue and --section, lest they be turned into a newt (they'll get better). Added comprehensive test coverage including: - Unit tests for parameter validation - Integration tests with mock CPython repo (ruled by Brian of Nazareth) - CLI tests that actually run the blurb command Also fixed the command-line parser to handle non-boolean options specifically for the add command, and improved error handling for temporary file cleanup. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent e7ea718 commit 17f62d5

File tree

5 files changed

+486
-92
lines changed

5 files changed

+486
-92
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.1.0 (unreleased)
4+
5+
* Add automation support to `blurb add` command:
6+
* New `--gh_issue` option to specify GitHub issue number
7+
* New `--section` option to specify NEWS section
8+
* New `--rst_on_stdin` option to read entry content from stdin
9+
* Useful for CI systems and automated tools
10+
311
## 2.0.0
412

513
* Move 'blurb test' subcommand into test suite by @hugovk in https://github.com/python/blurb/pull/37

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,20 @@ It opens a text editor on a template; you edit the
7777
file, save, and exit. **blurb** then stores the file
7878
in the correct place, and stages it in Git for you.
7979

80+
#### Automation support
81+
82+
For automated tools and CI systems, `blurb add` supports non-interactive operation:
83+
84+
```bash
85+
# Add a blurb entry from stdin
86+
echo "Added beans to the :mod:`spam` module." | blurb add \
87+
--gh_issue 123456 \
88+
--section Library \
89+
--rst_on_stdin
90+
```
91+
92+
When using `--rst_on_stdin`, both `--gh_issue` and `--section` are required.
93+
8094
The template for the `blurb add` message looks like this:
8195

8296
#

src/blurb/blurb.py

Lines changed: 180 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@
6363

6464
#
6565
# This template is the canonical list of acceptable section names!
66-
# It's parsed internally into the "sections" set.
66+
# It is parsed internally into SECTIONS. String replacement on the
67+
# formatted comments is done later. Beware when editing.
6768
#
6869

6970
template = """
@@ -98,13 +99,14 @@
9899

99100
root = None
100101
original_dir = None
101-
sections = []
102+
SECTIONS = []
102103

103104
for line in template.split('\n'):
104105
line = line.strip()
105106
prefix, found, section = line.partition("#.. section: ")
106107
if found and not prefix:
107-
sections.append(section.strip())
108+
SECTIONS.append(section.strip())
109+
SECTIONS = sorted(SECTIONS)
108110

109111

110112
_sanitize_section = {
@@ -319,8 +321,8 @@ def glob_blurbs(version):
319321
filenames.extend(glob.glob(wildcard))
320322
else:
321323
sanitized_sections = (
322-
{sanitize_section(section) for section in sections} |
323-
{sanitize_section_legacy(section) for section in sections}
324+
{sanitize_section(section) for section in SECTIONS} |
325+
{sanitize_section_legacy(section) for section in SECTIONS}
324326
)
325327
for section in sanitized_sections:
326328
wildcard = os.path.join(base, section, "*.rst")
@@ -487,7 +489,7 @@ def finish_entry():
487489
if key == "section":
488490
if no_changes:
489491
continue
490-
if value not in sections:
492+
if value not in SECTIONS:
491493
throw(f"Invalid section {value!r}! You must use one of the predefined sections.")
492494

493495
if "gh-issue" not in metadata and "bpo" not in metadata:
@@ -568,7 +570,7 @@ def _parse_next_filename(filename):
568570
components = filename.split(os.sep)
569571
section, filename = components[-2:]
570572
section = unsanitize_section(section)
571-
assert section in sections, f"Unknown section {section}"
573+
assert section in SECTIONS, f"Unknown section {section}"
572574

573575
fields = [x.strip() for x in filename.split(".")]
574576
assert len(fields) >= 4, f"Can't parse 'next' filename! filename {filename!r} fields {fields}"
@@ -817,82 +819,135 @@ def find_editor():
817819
error('Could not find an editor! Set the EDITOR environment variable.')
818820

819821

820-
@subcommand
821-
def add():
822-
"""
823-
Add a blurb (a Misc/NEWS.d/next entry) to the current CPython repo.
824-
"""
822+
def validate_add_parameters(section, gh_issue, rst_on_stdin):
823+
"""Validate parameters for the add command."""
824+
if section and section not in SECTIONS:
825+
error(f"--section must be one of {SECTIONS} not {section!r}")
825826

826-
editor = find_editor()
827+
if gh_issue < 0:
828+
error(f"--gh_issue must be a positive integer not {gh_issue!r}")
827829

828-
handle, tmp_path = tempfile.mkstemp(".rst")
829-
os.close(handle)
830-
atexit.register(lambda : os.unlink(tmp_path))
831-
832-
def init_tmp_with_template():
833-
with open(tmp_path, "wt", encoding="utf-8") as file:
834-
# hack:
835-
# my editor likes to strip trailing whitespace from lines.
836-
# normally this is a good idea. but in the case of the template
837-
# it's unhelpful.
838-
# so, manually ensure there's a space at the end of the gh-issue line.
839-
text = template
840-
841-
issue_line = ".. gh-issue:"
842-
without_space = "\n" + issue_line + "\n"
843-
with_space = "\n" + issue_line + " \n"
844-
if without_space not in text:
845-
sys.exit("Can't find gh-issue line to ensure there's a space on the end!")
846-
text = text.replace(without_space, with_space)
847-
file.write(text)
830+
if rst_on_stdin and (gh_issue <= 0 or not section):
831+
error("--gh_issue and --section required with --rst_on_stdin")
848832

849-
init_tmp_with_template()
833+
return True
850834

851-
# We need to be clever about EDITOR.
852-
# On the one hand, it might be a legitimate path to an
853-
# executable containing spaces.
854-
# On the other hand, it might be a partial command-line
855-
# with options.
835+
836+
def prepare_template(tmp_path, gh_issue, section, rst_content):
837+
"""Write the template file with substitutions."""
838+
text = template
839+
840+
# Ensure gh-issue line ends with space
841+
issue_line = ".. gh-issue:"
842+
text = text.replace(f"\n{issue_line}\n", f"\n{issue_line} \n")
843+
844+
# Apply substitutions
845+
if gh_issue > 0:
846+
text = text.replace(".. gh-issue: \n", f".. gh-issue: {gh_issue}\n")
847+
if section:
848+
text = text.replace(f"#.. section: {section}\n", f".. section: {section}\n")
849+
if rst_content:
850+
marker = "#################\n\n"
851+
text = text.replace(marker, f"{marker}{rst_content}\n")
852+
853+
with open(tmp_path, "wt", encoding="utf-8") as file:
854+
file.write(text)
855+
856+
857+
def get_editor_args(editor, tmp_path):
858+
"""Prepare editor command arguments."""
856859
if shutil.which(editor):
857860
args = [editor]
858861
else:
859862
args = list(shlex.split(editor))
860863
if not shutil.which(args[0]):
861864
sys.exit(f"Invalid GIT_EDITOR / EDITOR value: {editor}")
862865
args.append(tmp_path)
866+
return args
867+
868+
869+
def edit_until_valid(editor, tmp_path):
870+
"""Run editor until we get a valid blurb."""
871+
args = get_editor_args(editor, tmp_path)
863872

864873
while True:
865874
subprocess.run(args)
866875

867-
failure = None
868876
blurb = Blurbs()
869877
try:
870878
blurb.load(tmp_path)
871-
except BlurbError as e:
872-
failure = str(e)
873-
874-
if not failure:
875-
assert len(blurb) # if parse_blurb succeeds, we should always have a body
876879
if len(blurb) > 1:
877-
failure = "Too many entries! Don't specify '..' on a line by itself."
878-
879-
if failure:
880-
print()
881-
print(f"Error: {failure}")
882-
print()
880+
raise BlurbError("Too many entries! Don't specify '..' on a line by itself.")
881+
return blurb
882+
except BlurbError as e:
883+
print(f"\nError: {e}\n")
883884
try:
884885
prompt("Hit return to retry (or Ctrl-C to abort)")
885886
except KeyboardInterrupt:
886887
print()
887-
return
888+
return None
888889
print()
889-
continue
890-
break
891890

891+
892+
@subcommand
893+
def add(*, help=False, gh_issue: int = 0, section: str = "", rst_on_stdin=False):
894+
"""
895+
Add a blurb (a Misc/NEWS.d/next entry) to the current CPython repo.
896+
897+
Optional arguments, useful for automation:
898+
--gh_issue - The GitHub issue number to associate the NEWS entry with.
899+
--section - The NEWS section name. One of {SECTIONS}
900+
--rst_on_stdin - Pipe your ReStructured Text news entry via stdin instead of opening an editor.
901+
902+
When using --rst_on_stdin, both --gh_issue and --section are required.
903+
"""
904+
if help:
905+
print(add.__doc__)
906+
sys.exit(0)
907+
908+
# Validate parameters
909+
if not validate_add_parameters(section, gh_issue, rst_on_stdin):
910+
return 1
911+
912+
# Prepare content source
913+
if rst_on_stdin:
914+
rst_content = sys.stdin.read().strip()
915+
if not rst_content:
916+
error("No content provided on stdin")
917+
editor = None
918+
else:
919+
rst_content = None
920+
editor = find_editor()
921+
922+
# Create temp file
923+
handle, tmp_path = tempfile.mkstemp(".rst")
924+
os.close(handle)
925+
atexit.register(lambda: os.path.exists(tmp_path) and os.unlink(tmp_path))
926+
927+
# Prepare template
928+
prepare_template(tmp_path, gh_issue, section, rst_content)
929+
930+
# Get blurb content
931+
if editor:
932+
blurb = edit_until_valid(editor, tmp_path)
933+
if not blurb:
934+
return 1
935+
else:
936+
blurb = Blurbs()
937+
try:
938+
blurb.load(tmp_path)
939+
except BlurbError as e:
940+
error(str(e))
941+
942+
# Save and commit
892943
path = blurb.save_next()
893944
git_add_files.append(path)
894945
flush_git_add_files()
895-
print("Ready for commit.")
946+
print(f"Ready for commit. {path!r} created and git added.")
947+
948+
949+
# Format the docstring with the actual SECTIONS list
950+
add.__doc__ = add.__doc__.format(SECTIONS=SECTIONS)
896951

897952

898953

@@ -1107,7 +1162,7 @@ def populate():
11071162
os.chdir("Misc")
11081163
os.makedirs("NEWS.d/next", exist_ok=True)
11091164

1110-
for section in sections:
1165+
for section in SECTIONS:
11111166
dir_name = sanitize_section(section)
11121167
dir_path = f"NEWS.d/next/{dir_name}"
11131168
os.makedirs(dir_path, exist_ok=True)
@@ -1161,43 +1216,76 @@ def main():
11611216
original_dir = os.getcwd()
11621217
chdir_to_repo_root()
11631218

1164-
# map keyword arguments to options
1165-
# we only handle boolean options
1166-
# and they must have default values
1167-
short_options = {}
1168-
long_options = {}
1169-
kwargs = {}
1170-
for name, p in inspect.signature(fn).parameters.items():
1171-
if p.kind == inspect.Parameter.KEYWORD_ONLY:
1172-
assert isinstance(p.default, bool), "blurb command-line processing only handles boolean options"
1173-
kwargs[name] = p.default
1174-
short_options[name[0]] = name
1175-
long_options[name] = name
1176-
1177-
filtered_args = []
1178-
done_with_options = False
1179-
1180-
def handle_option(s, dict):
1181-
name = dict.get(s, None)
1182-
if not name:
1183-
sys.exit(f'blurb: Unknown option for {subcommand}: "{s}"')
1184-
kwargs[name] = not kwargs[name]
1185-
1186-
# print(f"short_options {short_options} long_options {long_options}")
1187-
for a in args:
1188-
if done_with_options:
1189-
filtered_args.append(a)
1190-
continue
1191-
if a.startswith('-'):
1192-
if a == "--":
1193-
done_with_options = True
1194-
elif a.startswith("--"):
1195-
handle_option(a[2:], long_options)
1219+
# Special handling for 'add' command with non-boolean options
1220+
if subcommand == "add":
1221+
kwargs = {"help": False, "gh_issue": 0, "section": "", "rst_on_stdin": False}
1222+
filtered_args = []
1223+
i = 0
1224+
while i < len(args):
1225+
arg = args[i]
1226+
if arg in ("-h", "--help"):
1227+
kwargs["help"] = True
1228+
elif arg == "--gh_issue":
1229+
if i + 1 < len(args):
1230+
try:
1231+
kwargs["gh_issue"] = int(args[i + 1])
1232+
i += 1
1233+
except ValueError:
1234+
sys.exit(f"blurb: --gh_issue requires an integer value, got '{args[i + 1]}'")
1235+
else:
1236+
sys.exit("blurb: --gh_issue requires a value")
1237+
elif arg == "--section":
1238+
if i + 1 < len(args):
1239+
kwargs["section"] = args[i + 1]
1240+
i += 1
1241+
else:
1242+
sys.exit("blurb: --section requires a value")
1243+
elif arg in ("-r", "--rst_on_stdin"):
1244+
kwargs["rst_on_stdin"] = True
1245+
elif arg.startswith("-"):
1246+
sys.exit(f'blurb: Unknown option for add: "{arg}"')
11961247
else:
1197-
for s in a[1:]:
1198-
handle_option(s, short_options)
1199-
continue
1200-
filtered_args.append(a)
1248+
filtered_args.append(arg)
1249+
i += 1
1250+
else:
1251+
# Original code for other commands
1252+
# map keyword arguments to options
1253+
# we only handle boolean options
1254+
# and they must have default values
1255+
short_options = {}
1256+
long_options = {}
1257+
kwargs = {}
1258+
for name, p in inspect.signature(fn).parameters.items():
1259+
if p.kind == inspect.Parameter.KEYWORD_ONLY:
1260+
assert isinstance(p.default, bool), "blurb command-line processing only handles boolean options"
1261+
kwargs[name] = p.default
1262+
short_options[name[0]] = name
1263+
long_options[name] = name
1264+
1265+
filtered_args = []
1266+
done_with_options = False
1267+
1268+
def handle_option(s, dict):
1269+
name = dict.get(s, None)
1270+
if not name:
1271+
sys.exit(f'blurb: Unknown option for {subcommand}: "{s}"')
1272+
kwargs[name] = not kwargs[name]
1273+
1274+
# print(f"short_options {short_options} long_options {long_options}")
1275+
for a in args:
1276+
if done_with_options:
1277+
filtered_args.append(a)
1278+
continue
1279+
if a.startswith('-'):
1280+
if a == "--":
1281+
done_with_options = True
1282+
elif a.startswith("--"):
1283+
handle_option(a[2:], long_options)
1284+
else:
1285+
for s in a[1:]:
1286+
handle_option(s, short_options)
1287+
continue
1288+
filtered_args.append(a)
12011289

12021290

12031291
sys.exit(fn(*filtered_args, **kwargs))

0 commit comments

Comments
 (0)