From 83ef57c6ac82ee8578988a1196ed3bef4b16ab26 Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Wed, 13 Aug 2025 00:49:43 +0100 Subject: [PATCH 1/3] Move 'blurb add' to ``blurb._add`` --- src/blurb/_add.py | 211 +++++++++++++++++++++++++++++++++++++++++++++ src/blurb/_cli.py | 8 +- src/blurb/blurb.py | 185 --------------------------------------- 3 files changed, 217 insertions(+), 187 deletions(-) create mode 100644 src/blurb/_add.py diff --git a/src/blurb/_add.py b/src/blurb/_add.py new file mode 100644 index 0000000..cad6efd --- /dev/null +++ b/src/blurb/_add.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import atexit +import os +import shlex +import shutil +import subprocess +import sys +import tempfile + +from blurb._cli import subcommand,error,prompt +from blurb._template import sections, template +from blurb.blurb import Blurbs, BlurbError, flush_git_add_files, git_add_files + +TYPE_CHECKING = False +if TYPE_CHECKING: + from collections.abc import Sequence + +if sys.platform == 'win32': + FALLBACK_EDITORS = ('notepad.exe',) +else: + FALLBACK_EDITORS = ('/etc/alternatives/editor', 'nano') + + +@subcommand +def add(*, issue: str | None = None, section: str | None = None): + """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.: + + blurb add -i 12345 + # or + blurb add -i https://github.com/python/cpython/issues/12345 + + Use -s/--section to specify the section name (case-insensitive), e.g.: + + blurb add -s Library + # or + blurb add -s library + + The known sections names are defined as follows and + spaces in names can be substituted for underscores: + +{sections} + """ + + handle, tmp_path = tempfile.mkstemp('.rst') + os.close(handle) + atexit.register(lambda : os.unlink(tmp_path)) + + text = _blurb_template_text(issue=issue, section=section) + 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: + print() + return + print() + continue + break + + path = blurb.save_next() + git_add_files.append(path) + flush_git_add_files() + print('Ready for commit.') +add.__doc__ = add.__doc__.format(sections='\n'.join(f'* {s}' for s in sections)) + + +def _editor_args() -> list[str]: + editor = _find_editor() + + # We need to be clever about EDITOR. + # On the one hand, it might be a legitimate path to an + # executable containing spaces. + # On the other hand, it might be a partial command-line + # with options. + if shutil.which(editor): + args = [editor] + else: + args = list(shlex.split(editor)) + if not shutil.which(args[0]): + raise SystemExit(f'Invalid GIT_EDITOR / EDITOR value: {editor}') + return args + + +def _find_editor() -> str: + for var in 'GIT_EDITOR', 'EDITOR': + editor = os.environ.get(var) + if editor is not None: + return editor + for fallback in FALLBACK_EDITORS: + if os.path.isabs(fallback): + found_path = fallback + else: + found_path = shutil.which(fallback) + if found_path and os.path.exists(found_path): + return found_path + error('Could not find an editor! Set the EDITOR environment variable.') + + +def _blurb_template_text(*, issue: str | None, section: str | None) -> str: + issue_number = _extract_issue_number(issue) + section_name = _extract_section_name(section) + + text = template + + # Ensure that there is a trailing space after '.. gh-issue:' to make + # filling in the template easier, unless an issue number was given + # through the --issue command-line flag. + issue_line = '.. gh-issue:' + without_space = f'\n{issue_line}\n' + if without_space not in text: + raise SystemExit("Can't find gh-issue line in the template!") + if issue_number is None: + with_space = f'\n{issue_line} \n' + text = text.replace(without_space, with_space) + else: + with_issue_number = f'\n{issue_line} {issue_number}\n' + text = text.replace(without_space, with_issue_number) + + # Uncomment the section if needed. + if section_name is not None: + pattern = f'.. section: {section_name}' + text = text.replace(f'#{pattern}', pattern) + + return text + + +def _extract_issue_number(issue: str | None, /) -> int | None: + if issue is None: + return None + issue = issue.strip() + + if issue.startswith(('GH-', 'gh-')): + stripped = issue[3:] + else: + stripped = issue.removeprefix('#') + try: + if stripped.isdecimal(): + return int(stripped) + except ValueError: + pass + + # Allow GitHub URL with or without the scheme + stripped = issue.removeprefix('https://') + stripped = stripped.removeprefix('github.com/python/cpython/issues/') + try: + if stripped.isdecimal(): + return int(stripped) + except ValueError: + pass + + raise SystemExit(f'Invalid GitHub issue number: {issue}') + + +def _extract_section_name(section: str | None, /) -> str | None: + if section is None: + return None + + section = section.strip() + if not section: + raise SystemExit('Empty section name!') + + matches = [] + # Try an exact or lowercase match + for section_name in sections: + if section in {section_name, section_name.lower()}: + matches.append(section_name) + + if not matches: + section_list = '\n'.join(f'* {s}' for s in sections) + raise SystemExit(f'Invalid section name: {section!r}\n\n' + f'Valid 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' + f'{multiple_matches}') + + return matches[0] + + +def _add_blurb_from_template(args: Sequence[str], tmp_path: str) -> Blurbs | None: + subprocess.run(args) + + failure = '' + blurb = Blurbs() + try: + blurb.load(tmp_path) + except BlurbError as e: + failure = str(e) + + if not failure: + assert len(blurb) # if parse_blurb succeeds, we should always have a body + if len(blurb) > 1: + failure = "Too many entries! Don't specify '..' on a line by itself." + + if failure: + print() + print(f'Error: {failure}') + print() + return None + return blurb diff --git a/src/blurb/_cli.py b/src/blurb/_cli.py index 6729e9d..b61c0a8 100644 --- a/src/blurb/_cli.py +++ b/src/blurb/_cli.py @@ -10,7 +10,7 @@ TYPE_CHECKING = False if TYPE_CHECKING: from collections.abc import Callable - from typing import TypeAlias + from typing import NoReturn, TypeAlias CommandFunc: TypeAlias = Callable[..., None] @@ -19,10 +19,14 @@ readme_re = re.compile(r'This is \w+ version \d+\.\d+').match -def error(msg: str, /): +def error(msg: str, /) -> NoReturn: raise SystemExit(f'Error: {msg}') +def prompt(prompt: str, /) -> str: + return input(f'[{prompt}> ') + + def subcommand(fn: CommandFunc): global subcommands subcommands[fn.__name__] = fn diff --git a/src/blurb/blurb.py b/src/blurb/blurb.py index 13c7345..dbbdb64 100755 --- a/src/blurb/blurb.py +++ b/src/blurb/blurb.py @@ -164,9 +164,6 @@ def sortable_datetime(): return time.strftime("%Y-%m-%d-%H-%M-%S", time.localtime()) -def prompt(prompt): - return input(f"[{prompt}> ") - def require_ok(prompt): prompt = f"[{prompt}> " while True: @@ -569,188 +566,6 @@ def _find_blurb_dir(): return None -def find_editor(): - for var in 'GIT_EDITOR', 'EDITOR': - editor = os.environ.get(var) - if editor is not None: - return editor - if sys.platform == 'win32': - fallbacks = ['notepad.exe'] - else: - fallbacks = ['/etc/alternatives/editor', 'nano'] - for fallback in fallbacks: - if os.path.isabs(fallback): - found_path = fallback - else: - found_path = shutil.which(fallback) - if found_path and os.path.exists(found_path): - return found_path - error('Could not find an editor! Set the EDITOR environment variable.') - - -def _extract_issue_number(issue, /): - if issue is None: - return None - issue = issue.strip() - - if issue.startswith(('GH-', 'gh-')): - stripped = issue[3:] - else: - stripped = issue.removeprefix('#') - try: - if stripped.isdecimal(): - return int(stripped) - except ValueError: - pass - - # Allow GitHub URL with or without the scheme - stripped = issue.removeprefix('https://') - stripped = stripped.removeprefix('github.com/python/cpython/issues/') - try: - if stripped.isdecimal(): - return int(stripped) - except ValueError: - pass - - sys.exit(f"Invalid GitHub issue number: {issue}") - - -def _extract_section_name(section, /): - if section is None: - return None - - section = section.strip() - if not section: - sys.exit("Empty section name!") - - matches = [] - # Try an exact or lowercase match - for section_name in sections: - if section in {section_name, section_name.lower()}: - matches.append(section_name) - - if not matches: - section_list = '\n'.join(f'* {s}' for s in sections) - sys.exit(f"Invalid section name: {section!r}\n\n" - f"Valid names are:\n\n{section_list}") - - if len(matches) > 1: - multiple_matches = ', '.join(f'* {m}' for m in sorted(matches)) - sys.exit(f"More than one match for {section!r}:\n\n" - f"{multiple_matches}") - - return matches[0] - - -def _blurb_template_text(*, issue, section): - issue_number = _extract_issue_number(issue) - section_name = _extract_section_name(section) - - text = template - - # Ensure that there is a trailing space after '.. gh-issue:' to make - # filling in the template easier, unless an issue number was given - # through the --issue command-line flag. - issue_line = ".. gh-issue:" - without_space = "\n" + issue_line + "\n" - if without_space not in text: - sys.exit("Can't find gh-issue line in the template!") - if issue_number is None: - with_space = "\n" + issue_line + " \n" - text = text.replace(without_space, with_space) - else: - with_issue_number = f"\n{issue_line} {issue_number}\n" - text = text.replace(without_space, with_issue_number) - - # Uncomment the section if needed. - if section_name is not None: - pattern = f'.. section: {section_name}' - text = text.replace(f'#{pattern}', pattern) - - return text - - -@subcommand -def add(*, issue=None, section=None): - """ -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.: - - blurb add -i 12345 - # or - blurb add -i https://github.com/python/cpython/issues/12345 - -Use -s/--section to specify the section name (case-insensitive), e.g.: - - blurb add -s Library - # or - blurb add -s library - -The known sections names are defined as follows and -spaces in names can be substituted for underscores: - -{sections} - """ - - editor = find_editor() - - handle, tmp_path = tempfile.mkstemp(".rst") - os.close(handle) - atexit.register(lambda : os.unlink(tmp_path)) - - text = _blurb_template_text(issue=issue, section=section) - with open(tmp_path, "w", encoding="utf-8") as file: - file.write(text) - - # We need to be clever about EDITOR. - # On the one hand, it might be a legitimate path to an - # executable containing spaces. - # On the other hand, it might be a partial command-line - # with options. - if shutil.which(editor): - args = [editor] - else: - args = list(shlex.split(editor)) - if not shutil.which(args[0]): - sys.exit(f"Invalid GIT_EDITOR / EDITOR value: {editor}") - args.append(tmp_path) - - while True: - subprocess.run(args) - - failure = None - blurb = Blurbs() - try: - blurb.load(tmp_path) - except BlurbError as e: - failure = str(e) - - if not failure: - assert len(blurb) # if parse_blurb succeeds, we should always have a body - if len(blurb) > 1: - failure = "Too many entries! Don't specify '..' on a line by itself." - - if failure: - print() - print(f"Error: {failure}") - print() - try: - prompt("Hit return to retry (or Ctrl-C to abort)") - except KeyboardInterrupt: - print() - return - print() - continue - break - - path = blurb.save_next() - git_add_files.append(path) - flush_git_add_files() - print("Ready for commit.") -add.__doc__ = add.__doc__.format(sections='\n'.join(f'* {s}' for s in sections)) - - @subcommand def release(version): """ From 38eaec21d3c4826f0165f9b149de4ce42f6c59a4 Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Wed, 13 Aug 2025 00:52:44 +0100 Subject: [PATCH 2/3] fixup! Move 'blurb add' to ``blurb._add`` --- tests/test_blurb_add.py | 40 +++++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/tests/test_blurb_add.py b/tests/test_blurb_add.py index d6d8287..f828bb2 100644 --- a/tests/test_blurb_add.py +++ b/tests/test_blurb_add.py @@ -2,12 +2,14 @@ import pytest -from blurb import blurb - +import blurb._add +import blurb._template +from blurb._add import (_blurb_template_text, _extract_issue_number, + _extract_section_name) def test_valid_no_issue_number(): - assert blurb._extract_issue_number(None) is None - res = blurb._blurb_template_text(issue=None, section=None) + assert _extract_issue_number(None) is None + res = _blurb_template_text(issue=None, section=None) lines = frozenset(res.splitlines()) assert '.. gh-issue:' not in lines assert '.. gh-issue: ' in lines @@ -34,10 +36,10 @@ def test_valid_no_issue_number(): ' https://github.com/python/cpython/issues/12345 ', )) def test_valid_issue_number_12345(issue): - actual = blurb._extract_issue_number(issue) + actual = _extract_issue_number(issue) assert actual == 12345 - res = blurb._blurb_template_text(issue=issue, section=None) + res = _blurb_template_text(issue=issue, section=None) lines = frozenset(res.splitlines()) assert '.. gh-issue:' not in lines assert '.. gh-issue: ' not in lines @@ -70,7 +72,7 @@ def test_valid_issue_number_12345(issue): def test_invalid_issue_number(issue): error_message = re.escape(f'Invalid GitHub issue number: {issue}') with pytest.raises(SystemExit, match=error_message): - blurb._blurb_template_text(issue=issue, section=None) + _blurb_template_text(issue=issue, section=None) @pytest.mark.parametrize('invalid', ( @@ -79,21 +81,21 @@ def test_invalid_issue_number(issue): 'gh-issue', )) def test_malformed_gh_issue_line(invalid, monkeypatch): - template = blurb.template.replace('.. gh-issue:', invalid) + template = blurb._add.template.replace('.. gh-issue:', invalid) error_message = re.escape("Can't find gh-issue line in the template!") with monkeypatch.context() as cm: - cm.setattr(blurb, 'template', template) + cm.setattr(blurb._add, 'template', template) with pytest.raises(SystemExit, match=error_message): - blurb._blurb_template_text(issue='1234', section=None) + _blurb_template_text(issue='1234', section=None) def _check_section_name(section_name, expected): - actual = blurb._extract_section_name(section_name) + actual = _extract_section_name(section_name) assert actual == expected - res = blurb._blurb_template_text(issue=None, section=section_name) + res = _blurb_template_text(issue=None, section=section_name) res = res.splitlines() - for section_name in blurb.sections: + for section_name in blurb._template.sections: if section_name == expected: assert f'.. section: {section_name}' in res else: @@ -103,7 +105,7 @@ def _check_section_name(section_name, expected): @pytest.mark.parametrize( ('section_name', 'expected'), - [(name, name) for name in blurb.sections], + [(name, name) for name in blurb._template.sections], ) def test_exact_names(section_name, expected): _check_section_name(section_name, expected) @@ -111,7 +113,7 @@ def test_exact_names(section_name, expected): @pytest.mark.parametrize( ('section_name', 'expected'), - [(name.lower(), name) for name in blurb.sections], + [(name.lower(), name) for name in blurb._template.sections], ) def test_exact_names_lowercase(section_name, expected): _check_section_name(section_name, expected) @@ -128,10 +130,10 @@ def test_exact_names_lowercase(section_name, expected): def test_empty_section_name(section): error_message = re.escape('Empty section name!') with pytest.raises(SystemExit, match=error_message): - blurb._extract_section_name(section) + _extract_section_name(section) with pytest.raises(SystemExit, match=error_message): - blurb._blurb_template_text(issue=None, section=section) + _blurb_template_text(issue=None, section=section) @pytest.mark.parametrize('section', [ @@ -157,7 +159,7 @@ def test_empty_section_name(section): def test_invalid_section_name(section): error_message = rf"(?m)Invalid section name: '{re.escape(section)}'\n\n.+" with pytest.raises(SystemExit, match=error_message): - blurb._extract_section_name(section) + _extract_section_name(section) with pytest.raises(SystemExit, match=error_message): - blurb._blurb_template_text(issue=None, section=section) + _blurb_template_text(issue=None, section=section) From 6184d3a8683cb9bab2c20891c0fcaa8f342ef59c Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Wed, 13 Aug 2025 00:54:56 +0100 Subject: [PATCH 3/3] fixup! Move 'blurb add' to ``blurb._add`` --- tests/test_blurb_add.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/test_blurb_add.py b/tests/test_blurb_add.py index f828bb2..3f1bf64 100644 --- a/tests/test_blurb_add.py +++ b/tests/test_blurb_add.py @@ -3,9 +3,10 @@ import pytest import blurb._add -import blurb._template from blurb._add import (_blurb_template_text, _extract_issue_number, _extract_section_name) +from blurb._template import sections as SECTIONS, template as blurb_template + def test_valid_no_issue_number(): assert _extract_issue_number(None) is None @@ -81,7 +82,7 @@ def test_invalid_issue_number(issue): 'gh-issue', )) def test_malformed_gh_issue_line(invalid, monkeypatch): - template = blurb._add.template.replace('.. gh-issue:', invalid) + template = blurb_template.replace('.. gh-issue:', invalid) error_message = re.escape("Can't find gh-issue line in the template!") with monkeypatch.context() as cm: cm.setattr(blurb._add, 'template', template) @@ -95,7 +96,7 @@ def _check_section_name(section_name, expected): res = _blurb_template_text(issue=None, section=section_name) res = res.splitlines() - for section_name in blurb._template.sections: + for section_name in SECTIONS: if section_name == expected: assert f'.. section: {section_name}' in res else: @@ -105,7 +106,7 @@ def _check_section_name(section_name, expected): @pytest.mark.parametrize( ('section_name', 'expected'), - [(name, name) for name in blurb._template.sections], + [(name, name) for name in SECTIONS], ) def test_exact_names(section_name, expected): _check_section_name(section_name, expected) @@ -113,7 +114,7 @@ def test_exact_names(section_name, expected): @pytest.mark.parametrize( ('section_name', 'expected'), - [(name.lower(), name) for name in blurb._template.sections], + [(name.lower(), name) for name in SECTIONS], ) def test_exact_names_lowercase(section_name, expected): _check_section_name(section_name, expected)