From 17f62d5054852fea410719b995160739627a6830 Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith" Date: Sun, 29 Jun 2025 00:13:35 +0000 Subject: [PATCH 1/8] Add automation features to blurb add command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 8 + README.md | 14 ++ src/blurb/blurb.py | 272 ++++++++++++++++++++++------------ tests/test_add_command.py | 123 +++++++++++++++ tests/test_cli_integration.py | 161 ++++++++++++++++++++ 5 files changed, 486 insertions(+), 92 deletions(-) create mode 100644 tests/test_add_command.py create mode 100644 tests/test_cli_integration.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b810a9..133dc5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 2.1.0 (unreleased) + +* Add automation support to `blurb add` command: + * New `--gh_issue` option to specify GitHub issue number + * New `--section` option to specify NEWS section + * New `--rst_on_stdin` option to read entry content from stdin + * Useful for CI systems and automated tools + ## 2.0.0 * Move 'blurb test' subcommand into test suite by @hugovk in https://github.com/python/blurb/pull/37 diff --git a/README.md b/README.md index c5e6abc..0e61129 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,20 @@ It opens a text editor on a template; you edit the file, save, and exit. **blurb** then stores the file in the correct place, and stages it in Git for you. +#### Automation support + +For automated tools and CI systems, `blurb add` supports non-interactive operation: + +```bash +# Add a blurb entry from stdin +echo "Added beans to the :mod:`spam` module." | blurb add \ + --gh_issue 123456 \ + --section Library \ + --rst_on_stdin +``` + +When using `--rst_on_stdin`, both `--gh_issue` and `--section` are required. + The template for the `blurb add` message looks like this: # diff --git a/src/blurb/blurb.py b/src/blurb/blurb.py index b3998b5..54eea52 100755 --- a/src/blurb/blurb.py +++ b/src/blurb/blurb.py @@ -63,7 +63,8 @@ # # This template is the canonical list of acceptable section names! -# It's parsed internally into the "sections" set. +# It is parsed internally into SECTIONS. String replacement on the +# formatted comments is done later. Beware when editing. # template = """ @@ -98,13 +99,14 @@ root = None original_dir = None -sections = [] +SECTIONS = [] for line in template.split('\n'): line = line.strip() prefix, found, section = line.partition("#.. section: ") if found and not prefix: - sections.append(section.strip()) + SECTIONS.append(section.strip()) + SECTIONS = sorted(SECTIONS) _sanitize_section = { @@ -319,8 +321,8 @@ def glob_blurbs(version): filenames.extend(glob.glob(wildcard)) else: sanitized_sections = ( - {sanitize_section(section) for section in sections} | - {sanitize_section_legacy(section) for section in sections} + {sanitize_section(section) for section in SECTIONS} | + {sanitize_section_legacy(section) for section in SECTIONS} ) for section in sanitized_sections: wildcard = os.path.join(base, section, "*.rst") @@ -487,7 +489,7 @@ def finish_entry(): if key == "section": if no_changes: continue - if value not in sections: + if value not in SECTIONS: throw(f"Invalid section {value!r}! You must use one of the predefined sections.") if "gh-issue" not in metadata and "bpo" not in metadata: @@ -568,7 +570,7 @@ def _parse_next_filename(filename): components = filename.split(os.sep) section, filename = components[-2:] section = unsanitize_section(section) - assert section in sections, f"Unknown section {section}" + assert section in SECTIONS, f"Unknown section {section}" fields = [x.strip() for x in filename.split(".")] assert len(fields) >= 4, f"Can't parse 'next' filename! filename {filename!r} fields {fields}" @@ -817,42 +819,43 @@ def find_editor(): error('Could not find an editor! Set the EDITOR environment variable.') -@subcommand -def add(): - """ -Add a blurb (a Misc/NEWS.d/next entry) to the current CPython repo. - """ +def validate_add_parameters(section, gh_issue, rst_on_stdin): + """Validate parameters for the add command.""" + if section and section not in SECTIONS: + error(f"--section must be one of {SECTIONS} not {section!r}") - editor = find_editor() + if gh_issue < 0: + error(f"--gh_issue must be a positive integer not {gh_issue!r}") - handle, tmp_path = tempfile.mkstemp(".rst") - os.close(handle) - atexit.register(lambda : os.unlink(tmp_path)) - - def init_tmp_with_template(): - with open(tmp_path, "wt", encoding="utf-8") as file: - # hack: - # my editor likes to strip trailing whitespace from lines. - # normally this is a good idea. but in the case of the template - # it's unhelpful. - # so, manually ensure there's a space at the end of the gh-issue line. - text = template - - issue_line = ".. gh-issue:" - without_space = "\n" + issue_line + "\n" - with_space = "\n" + issue_line + " \n" - if without_space not in text: - sys.exit("Can't find gh-issue line to ensure there's a space on the end!") - text = text.replace(without_space, with_space) - file.write(text) + if rst_on_stdin and (gh_issue <= 0 or not section): + error("--gh_issue and --section required with --rst_on_stdin") - init_tmp_with_template() + return True - # 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. + +def prepare_template(tmp_path, gh_issue, section, rst_content): + """Write the template file with substitutions.""" + text = template + + # Ensure gh-issue line ends with space + issue_line = ".. gh-issue:" + text = text.replace(f"\n{issue_line}\n", f"\n{issue_line} \n") + + # Apply substitutions + if gh_issue > 0: + text = text.replace(".. gh-issue: \n", f".. gh-issue: {gh_issue}\n") + if section: + text = text.replace(f"#.. section: {section}\n", f".. section: {section}\n") + if rst_content: + marker = "#################\n\n" + text = text.replace(marker, f"{marker}{rst_content}\n") + + with open(tmp_path, "wt", encoding="utf-8") as file: + file.write(text) + + +def get_editor_args(editor, tmp_path): + """Prepare editor command arguments.""" if shutil.which(editor): args = [editor] else: @@ -860,39 +863,91 @@ def init_tmp_with_template(): if not shutil.which(args[0]): sys.exit(f"Invalid GIT_EDITOR / EDITOR value: {editor}") args.append(tmp_path) + return args + + +def edit_until_valid(editor, tmp_path): + """Run editor until we get a valid blurb.""" + args = get_editor_args(editor, 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() + raise BlurbError("Too many entries! Don't specify '..' on a line by itself.") + return blurb + except BlurbError as e: + print(f"\nError: {e}\n") try: prompt("Hit return to retry (or Ctrl-C to abort)") except KeyboardInterrupt: print() - return + return None print() - continue - break + +@subcommand +def add(*, help=False, gh_issue: int = 0, section: str = "", rst_on_stdin=False): + """ +Add a blurb (a Misc/NEWS.d/next entry) to the current CPython repo. + +Optional arguments, useful for automation: + --gh_issue - The GitHub issue number to associate the NEWS entry with. + --section - The NEWS section name. One of {SECTIONS} + --rst_on_stdin - Pipe your ReStructured Text news entry via stdin instead of opening an editor. + +When using --rst_on_stdin, both --gh_issue and --section are required. + """ + if help: + print(add.__doc__) + sys.exit(0) + + # Validate parameters + if not validate_add_parameters(section, gh_issue, rst_on_stdin): + return 1 + + # Prepare content source + if rst_on_stdin: + rst_content = sys.stdin.read().strip() + if not rst_content: + error("No content provided on stdin") + editor = None + else: + rst_content = None + editor = find_editor() + + # Create temp file + handle, tmp_path = tempfile.mkstemp(".rst") + os.close(handle) + atexit.register(lambda: os.path.exists(tmp_path) and os.unlink(tmp_path)) + + # Prepare template + prepare_template(tmp_path, gh_issue, section, rst_content) + + # Get blurb content + if editor: + blurb = edit_until_valid(editor, tmp_path) + if not blurb: + return 1 + else: + blurb = Blurbs() + try: + blurb.load(tmp_path) + except BlurbError as e: + error(str(e)) + + # Save and commit path = blurb.save_next() git_add_files.append(path) flush_git_add_files() - print("Ready for commit.") + print(f"Ready for commit. {path!r} created and git added.") + + +# Format the docstring with the actual SECTIONS list +add.__doc__ = add.__doc__.format(SECTIONS=SECTIONS) @@ -1107,7 +1162,7 @@ def populate(): os.chdir("Misc") os.makedirs("NEWS.d/next", exist_ok=True) - for section in sections: + for section in SECTIONS: dir_name = sanitize_section(section) dir_path = f"NEWS.d/next/{dir_name}" os.makedirs(dir_path, exist_ok=True) @@ -1161,43 +1216,76 @@ def main(): original_dir = os.getcwd() chdir_to_repo_root() - # map keyword arguments to options - # we only handle boolean options - # and they must have default values - short_options = {} - long_options = {} - kwargs = {} - for name, p in inspect.signature(fn).parameters.items(): - if p.kind == inspect.Parameter.KEYWORD_ONLY: - assert isinstance(p.default, bool), "blurb command-line processing only handles boolean options" - kwargs[name] = p.default - short_options[name[0]] = name - long_options[name] = name - - filtered_args = [] - done_with_options = False - - def handle_option(s, dict): - name = dict.get(s, None) - if not name: - sys.exit(f'blurb: Unknown option for {subcommand}: "{s}"') - kwargs[name] = not kwargs[name] - - # print(f"short_options {short_options} long_options {long_options}") - for a in args: - if done_with_options: - filtered_args.append(a) - continue - if a.startswith('-'): - if a == "--": - done_with_options = True - elif a.startswith("--"): - handle_option(a[2:], long_options) + # Special handling for 'add' command with non-boolean options + if subcommand == "add": + kwargs = {"help": False, "gh_issue": 0, "section": "", "rst_on_stdin": False} + filtered_args = [] + i = 0 + while i < len(args): + arg = args[i] + if arg in ("-h", "--help"): + kwargs["help"] = True + elif arg == "--gh_issue": + if i + 1 < len(args): + try: + kwargs["gh_issue"] = int(args[i + 1]) + i += 1 + except ValueError: + sys.exit(f"blurb: --gh_issue requires an integer value, got '{args[i + 1]}'") + else: + sys.exit("blurb: --gh_issue requires a value") + elif arg == "--section": + if i + 1 < len(args): + kwargs["section"] = args[i + 1] + i += 1 + else: + sys.exit("blurb: --section requires a value") + elif arg in ("-r", "--rst_on_stdin"): + kwargs["rst_on_stdin"] = True + elif arg.startswith("-"): + sys.exit(f'blurb: Unknown option for add: "{arg}"') else: - for s in a[1:]: - handle_option(s, short_options) - continue - filtered_args.append(a) + filtered_args.append(arg) + i += 1 + else: + # Original code for other commands + # map keyword arguments to options + # we only handle boolean options + # and they must have default values + short_options = {} + long_options = {} + kwargs = {} + for name, p in inspect.signature(fn).parameters.items(): + if p.kind == inspect.Parameter.KEYWORD_ONLY: + assert isinstance(p.default, bool), "blurb command-line processing only handles boolean options" + kwargs[name] = p.default + short_options[name[0]] = name + long_options[name] = name + + filtered_args = [] + done_with_options = False + + def handle_option(s, dict): + name = dict.get(s, None) + if not name: + sys.exit(f'blurb: Unknown option for {subcommand}: "{s}"') + kwargs[name] = not kwargs[name] + + # print(f"short_options {short_options} long_options {long_options}") + for a in args: + if done_with_options: + filtered_args.append(a) + continue + if a.startswith('-'): + if a == "--": + done_with_options = True + elif a.startswith("--"): + handle_option(a[2:], long_options) + else: + for s in a[1:]: + handle_option(s, short_options) + continue + filtered_args.append(a) sys.exit(fn(*filtered_args, **kwargs)) diff --git a/tests/test_add_command.py b/tests/test_add_command.py new file mode 100644 index 0000000..2913a29 --- /dev/null +++ b/tests/test_add_command.py @@ -0,0 +1,123 @@ +"""Tests for the blurb add command with automation features.""" + +import io +import os +import tempfile +from unittest import mock +import pytest +from pyfakefs.fake_filesystem import FakeFilesystem + +from blurb import blurb + + +class TestAddCommand: + """Test cases for the add command's automation features.""" + + def setup_method(self): + """Set up test environment before each test.""" + # Save original values + self.original_dir = os.getcwd() + self.original_root = blurb.root + + def teardown_method(self): + """Clean up after each test.""" + # Restore original values + os.chdir(self.original_dir) + blurb.root = self.original_root + + def test_add_help_parameter(self, capsys): + """Test that --help displays the help text.""" + with pytest.raises(SystemExit) as exc_info: + blurb.add(help=True) + + # help exits with code 0 + assert exc_info.value.code == 0 + captured = capsys.readouterr() + assert "Add a blurb" in captured.out + assert "--gh_issue" in captured.out + assert "--section" in captured.out + assert "--rst_on_stdin" in captured.out + + def test_invalid_section_parameter(self, capsys): + """Test that invalid section names are rejected.""" + with pytest.raises(SystemExit) as exc_info: + blurb.add(section="InvalidSection") + + # error() function exits with string message, not code + assert "--section must be one of" in str(exc_info.value) + assert "InvalidSection" in str(exc_info.value) + + def test_negative_gh_issue(self, capsys): + """Test that negative GitHub issue numbers are rejected.""" + with pytest.raises(SystemExit) as exc_info: + blurb.add(gh_issue=-123) + + # error() function exits with string message, not code + assert "--gh_issue must be a positive integer" in str(exc_info.value) + + def test_rst_on_stdin_requires_other_params(self, capsys): + """Test that --rst_on_stdin requires --gh_issue and --section.""" + with pytest.raises(SystemExit) as exc_info: + blurb.add(rst_on_stdin=True) + + # error() function exits with string message, not code + assert "--gh_issue and --section required with --rst_on_stdin" in str(exc_info.value) + + def test_rst_on_stdin_missing_section(self, capsys): + """Test that --rst_on_stdin fails without --section.""" + with pytest.raises(SystemExit) as exc_info: + blurb.add(rst_on_stdin=True, gh_issue=12345) + + # error() function exits with string message, not code + assert "--gh_issue and --section required with --rst_on_stdin" in str(exc_info.value) + + def test_rst_on_stdin_missing_gh_issue(self, capsys): + """Test that --rst_on_stdin fails without --gh_issue.""" + with pytest.raises(SystemExit) as exc_info: + blurb.add(rst_on_stdin=True, section="Library") + + # error() function exits with string message, not code + assert "--gh_issue and --section required with --rst_on_stdin" in str(exc_info.value) + + @mock.patch('blurb.blurb.chdir_to_repo_root') + @mock.patch('blurb.blurb.flush_git_add_files') + @mock.patch('sys.stdin', new_callable=io.StringIO) + def test_add_with_all_automation_params(self, mock_stdin, mock_flush_git, mock_chdir, fs: FakeFilesystem): + """Test successful add with all automation parameters.""" + # Set up fake filesystem + fs.create_dir("/fake_repo") + fs.create_dir("/fake_repo/Misc/NEWS.d/next/Library") + os.chdir("/fake_repo") + blurb.root = "/fake_repo" + + # Mock stdin content with a Monty Python reference + mock_stdin.write("Fixed spam module to properly handle eggs, bacon, and spam repetition counts.") + mock_stdin.seek(0) + + # Mock chdir_to_repo_root to do nothing since we're in fake fs + mock_chdir.return_value = None + + # Call add with automation parameters + with mock.patch('blurb.blurb.sortable_datetime', return_value='2024-01-01-12-00-00'): + with mock.patch('blurb.blurb.nonceify', return_value='abc123'): + result = blurb.add( + gh_issue=123456, + section="Library", + rst_on_stdin=True + ) + + # Verify the file was created + expected_path = "/fake_repo/Misc/NEWS.d/next/Library/2024-01-01-12-00-00.gh-issue-123456.abc123.rst" + assert os.path.exists(expected_path) + + # Verify file contents - the metadata is in the filename, not the file content + with open(expected_path) as f: + content = f.read() + + # The file should only contain the body text (which may be wrapped) + assert "Fixed spam module to properly handle eggs, bacon, and spam repetition" in content + assert "counts." in content + + # Verify git add was called + assert expected_path in blurb.git_add_files + mock_flush_git.assert_called_once() diff --git a/tests/test_cli_integration.py b/tests/test_cli_integration.py new file mode 100644 index 0000000..7c1eced --- /dev/null +++ b/tests/test_cli_integration.py @@ -0,0 +1,161 @@ +"""Integration tests for the blurb CLI tool.""" + +import os +import subprocess +import sys +import tempfile +from pathlib import Path +import pytest + + +@pytest.fixture +def mock_cpython_repo(tmp_path): + """Create a minimal mock CPython repository structure.""" + # Create necessary directories + (tmp_path / "Include").mkdir() + (tmp_path / "Python").mkdir() + (tmp_path / "Misc" / "NEWS.d" / "next").mkdir(parents=True) + + # Create section directories + sections = ["Library", "Tests", "Documentation", "Core_and_Builtins", + "Build", "Windows", "macOS", "IDLE", "Tools-Demos", "C_API", "Security"] + for section in sections: + (tmp_path / "Misc" / "NEWS.d" / "next" / section).mkdir() + + # Create required files that identify a CPython repo + (tmp_path / "README").write_text("This is Python version 3.12.0\n") + (tmp_path / "LICENSE").write_text("A. HISTORY OF THE SOFTWARE\n==========================\n") + (tmp_path / "Include" / "Python.h").touch() + (tmp_path / "Python" / "ceval.c").touch() + + # Initialize as a git repository + subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True) + subprocess.run(["git", "config", "user.email", "brian@pythons.invalid"], cwd=tmp_path, capture_output=True) + subprocess.run(["git", "config", "user.name", "Brian of Nazareth"], cwd=tmp_path, capture_output=True) + + yield tmp_path + # Cleanup happens automatically when tmp_path fixture is torn down + + +@pytest.fixture +def blurb_executable(): + """Get the path to the blurb executable.""" + # Try to find blurb in the virtual environment + venv_blurb = Path(__file__).parent.parent / "venv" / "bin" / "blurb" + if venv_blurb.exists(): + return str(venv_blurb) + + # Fall back to using Python module + return [sys.executable, "-m", "blurb"] + + +def run_blurb(blurb_executable, args, cwd=None, input_text=None): + """Run blurb with the given arguments.""" + if isinstance(blurb_executable, str): + cmd = [blurb_executable] + args + else: + cmd = blurb_executable + args + + result = subprocess.run( + cmd, + cwd=cwd, + input=input_text, + capture_output=True, + text=True + ) + return result + + +class TestCLIIntegration: + """Test the blurb command line interface with a mock CPython repo.""" + + def test_blurb_add_help_in_mock_repo(self, mock_cpython_repo, blurb_executable): + """Test that 'blurb add --help' works in a mock CPython repo.""" + # Run blurb add --help + result = run_blurb(blurb_executable, ["add", "--help"], cwd=mock_cpython_repo) + + # Check it succeeded + assert result.returncode == 0 + + # Check the help output contains expected content + output = result.stdout + result.stderr # Help might go to either + assert "Add a blurb" in output + assert "--gh_issue" in output + assert "--section" in output + assert "--rst_on_stdin" in output + assert "Library" in output # Should show available sections + + def test_blurb_version(self, blurb_executable): + """Test that 'blurb version' works without a CPython repo.""" + result = run_blurb(blurb_executable, ["version"]) + + assert result.returncode == 0 + assert "blurb version" in result.stdout + + def test_blurb_help(self, blurb_executable): + """Test that 'blurb help' works without a CPython repo.""" + result = run_blurb(blurb_executable, ["help"]) + + assert result.returncode == 0 + assert "Available subcommands:" in result.stdout + assert "add" in result.stdout + assert "merge" in result.stdout + assert "release" in result.stdout + + def test_blurb_add_automation_params_validation(self, mock_cpython_repo, blurb_executable): + """Test validation of automation parameters via CLI.""" + # Test invalid section + result = run_blurb(blurb_executable, ["add", "--section", "InvalidSection"], cwd=mock_cpython_repo) + assert result.returncode != 0 + assert "must be one of" in result.stderr + + # Test negative gh_issue + result = run_blurb(blurb_executable, ["add", "--gh_issue", "-123"], cwd=mock_cpython_repo) + assert result.returncode != 0 + assert "must be a positive integer" in result.stderr + + # Test rst_on_stdin without required params + result = run_blurb(blurb_executable, ["add", "--rst_on_stdin"], cwd=mock_cpython_repo) + assert result.returncode != 0 + assert "--gh_issue and --section required" in result.stderr + + @pytest.mark.parametrize("section", ["Library", "Tests", "Documentation"]) + def test_blurb_add_with_stdin_integration(self, mock_cpython_repo, blurb_executable, section): + """Test the full automation flow with stdin input.""" + # Create the blurb content + blurb_text = f"Fixed a bug in the {section.lower()} that improves spam handling." + + # Run blurb with all automation parameters + result = run_blurb( + blurb_executable, + ["add", "--gh_issue", "123456", "--section", section, "--rst_on_stdin"], + cwd=mock_cpython_repo, + input_text=blurb_text + ) + + # Check it succeeded + assert result.returncode == 0 + assert "Ready for commit" in result.stdout + assert "created and git added" in result.stdout + + # Verify the file was created + news_dir = mock_cpython_repo / "Misc" / "NEWS.d" / "next" / section + rst_files = list(news_dir.glob("*.gh-issue-123456.*.rst")) + assert len(rst_files) == 1 + + # Verify the content + created_file = rst_files[0] + content = created_file.read_text() + assert blurb_text in content + + def test_blurb_outside_cpython_repo(self, blurb_executable, tmp_path): + """Test that blurb gives appropriate error outside a CPython repo.""" + # Create a non-CPython directory + non_cpython_dir = tmp_path / "not_cpython" + non_cpython_dir.mkdir() + + # Try to run blurb add + result = run_blurb(blurb_executable, ["add"], cwd=non_cpython_dir) + + assert result.returncode != 0 + assert "not inside a CPython repo" in result.stderr From f88da1da0ab66a32ee10df72397883e6acad19ba Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith" Date: Sun, 29 Jun 2025 01:34:29 +0000 Subject: [PATCH 2/8] Refactor CLI to use cyclopts decorators directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This holy grail of refactoring reduces the code by >150 lines! We've eliminated the bureaucracy of wrapper functions and now apply `@app.command` decorators directly to the original functions. Just like the Knights of Ni demanded a shrubbery, cyclopts demands clear, typed parameters - and we have delivered! The code is now cleaner than the Holy Grail's cup after being washed by the French Taunter. Key improvements: - Moved cyclopts imports to top of file (no more silly walks) - Applied `@app.command` directly to functions (no indirection!) - Removed 'help' parameter from add() - cyclopts handles it - Simplified main() from ~200 lines to ~40 lines - Updated tests for new option naming (--gh-issue not --gh_issue) - All 28 tests still pass - it's not dead yet! 'Tis but a scratch compared to the original implementation! 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude (with a few human touchups by Greg) --- .gitignore | 6 + pyproject.toml | 3 + src/blurb/blurb.py | 393 +++++++++------------------------- tests/test_add_command.py | 51 ++--- tests/test_cli_integration.py | 301 ++++++++++++++++++-------- 5 files changed, 347 insertions(+), 407 deletions(-) diff --git a/.gitignore b/.gitignore index 353ced5..bfeae78 100644 --- a/.gitignore +++ b/.gitignore @@ -93,3 +93,9 @@ ENV/ # hatch-vcs src/*/_version.py + +*.bak +*.swp + +CLAUDE.local.md +.claude/* diff --git a/pyproject.toml b/pyproject.toml index d6f0669..f3fe7a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,9 @@ classifiers = [ dynamic = [ "version", ] +dependencies = [ + "cyclopts>=3", +] optional-dependencies.tests = [ "pyfakefs", "pytest", diff --git a/src/blurb/blurb.py b/src/blurb/blurb.py index 54eea52..1472cfa 100755 --- a/src/blurb/blurb.py +++ b/src/blurb/blurb.py @@ -35,9 +35,6 @@ ## Licensed to the Python Software Foundation under a contributor agreement. ## -# TODO -# -# automatic git adds and removes import atexit import base64 @@ -45,7 +42,6 @@ import glob import hashlib import io -import inspect import itertools import os from pathlib import Path @@ -57,10 +53,15 @@ import tempfile import textwrap import time +from typing import Optional, Annotated + +from cyclopts import App, Parameter from . import __version__ +LOWEST_POSSIBLE_GH_ISSUE_NUMBER = 32426 + # # This template is the canonical list of acceptable section names! # It is parsed internally into SECTIONS. String replacement on the @@ -97,16 +98,13 @@ """.lstrip() -root = None -original_dir = None -SECTIONS = [] - -for line in template.split('\n'): - line = line.strip() - prefix, found, section = line.partition("#.. section: ") - if found and not prefix: - SECTIONS.append(section.strip()) - SECTIONS = sorted(SECTIONS) +root = None # Set by chdir_to_repo_root() +original_dir = None # Set by main() +SECTIONS = sorted([ + line.partition("#.. section: ")[2].strip() + for line in template.split('\n') + if line.strip().startswith("#.. section: ") +]) _sanitize_section = { @@ -462,8 +460,6 @@ def finish_entry(): no_changes = metadata.get('no changes') - lowest_possible_gh_issue_number = 32426 - issue_keys = { 'gh-issue': 'GitHub', 'bpo': 'bpo', @@ -483,8 +479,8 @@ def finish_entry(): except (TypeError, ValueError): throw(f"Invalid {issue_keys[key]} number: {value!r}") - if key == "gh-issue" and int(value) < lowest_possible_gh_issue_number: - throw(f"Invalid gh-issue number: {value!r} (must be >= {lowest_possible_gh_issue_number})") + if key == "gh-issue" and int(value) < LOWEST_POSSIBLE_GH_ISSUE_NUMBER: + throw(f"Invalid gh-issue number: {value!r} (must be >= {LOWEST_POSSIBLE_GH_ISSUE_NUMBER})") if key == "section": if no_changes: @@ -688,107 +684,39 @@ def error(*a): sys.exit("Error: " + s) -subcommands = {} - -def subcommand(fn): - global subcommands - name = fn.__name__ - subcommands[name] = fn - return fn +app = App( + help="Management tool for CPython Misc/NEWS and Misc/NEWS.d entries.", + version_flags=["--version", "-V"], + version=f"blurb version {__version__}", +) -def get_subcommand(subcommand): - fn = subcommands.get(subcommand) - if not fn: - error(f"Unknown subcommand: {subcommand}\nRun 'blurb help' for help.") - return fn -@subcommand +@app.command(name="version") def version(): """Print blurb version.""" print("blurb version", __version__) -@subcommand -def help(subcommand=None): - """ -Print help for subcommands. +@app.command(name="help") +def help(subcommand: Optional[str] = None): + """Print help for subcommands. -Prints the help text for the specified subcommand. -If subcommand is not specified, prints one-line summaries for every command. + Parameters + ---------- + subcommand : str, optional + Subcommand to get help for. If not specified, shows all commands. """ - if not subcommand: - print("blurb version", __version__) - print() - print("Management tool for CPython Misc/NEWS and Misc/NEWS.d entries.") - print() - print("Usage:") - print(" blurb [subcommand] [options...]") - print() - - # print list of subcommands - summaries = [] - longest_name_len = -1 - for name, fn in subcommands.items(): - if name.startswith('-'): - continue - longest_name_len = max(longest_name_len, len(name)) - if not fn.__doc__: - error("help is broken, no docstring for " + fn.__name__) - fields = fn.__doc__.lstrip().split("\n") - if not fields: - first_line = "(no help available)" - else: - first_line = fields[0] - summaries.append((name, first_line)) - summaries.sort() - - print("Available subcommands:") - print() - for name, summary in summaries: - print(" ", name.ljust(longest_name_len), " ", summary) - - print() - print("If blurb is run without any arguments, this is equivalent to 'blurb add'.") - - sys.exit(0) - - fn = get_subcommand(subcommand) - doc = fn.__doc__.strip() - if not doc: - error("help is broken, no docstring for " + subcommand) - - options = [] - positionals = [] - - nesting = 0 - for name, p in inspect.signature(fn).parameters.items(): - if p.kind == inspect.Parameter.KEYWORD_ONLY: - short_option = name[0] - options.append(f" [-{short_option}|--{name}]") - elif p.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD: - positionals.append(" ") - has_default = (p.default != inspect._empty) - if has_default: - positionals.append("[") - nesting += 1 - positionals.append(f"<{name}>") - positionals.append("]" * nesting) - - - parameters = "".join(options + positionals) - print(f"blurb {subcommand}{parameters}") - print() - print(doc) - sys.exit(0) + if subcommand: + # Show help for specific subcommand + app([subcommand, "--help"]) + else: + # Show overall help + app(["--help"]) -# Make "blurb --help/--version/-V" work. -subcommands["--help"] = help -subcommands["--version"] = version -subcommands["-V"] = version def _find_blurb_dir(): @@ -825,12 +753,10 @@ def validate_add_parameters(section, gh_issue, rst_on_stdin): error(f"--section must be one of {SECTIONS} not {section!r}") if gh_issue < 0: - error(f"--gh_issue must be a positive integer not {gh_issue!r}") + error(f"--gh-issue must be a positive integer not {gh_issue!r}") if rst_on_stdin and (gh_issue <= 0 or not section): - error("--gh_issue and --section required with --rst_on_stdin") - - return True + error("--gh-issue and --section required with --rst-on-stdin") def prepare_template(tmp_path, gh_issue, section, rst_content): @@ -842,7 +768,7 @@ def prepare_template(tmp_path, gh_issue, section, rst_content): text = text.replace(f"\n{issue_line}\n", f"\n{issue_line} \n") # Apply substitutions - if gh_issue > 0: + if gh_issue: text = text.replace(".. gh-issue: \n", f".. gh-issue: {gh_issue}\n") if section: text = text.replace(f"#.. section: {section}\n", f".. section: {section}\n") @@ -889,25 +815,26 @@ def edit_until_valid(editor, tmp_path): print() -@subcommand -def add(*, help=False, gh_issue: int = 0, section: str = "", rst_on_stdin=False): - """ -Add a blurb (a Misc/NEWS.d/next entry) to the current CPython repo. +@app.command(name="add") +def add(*, gh_issue: int = 0, section: str = "", rst_on_stdin: bool = False): + # This docstring template is formatted after the function definition. + """Add a new Misc/NEWS entry. -Optional arguments, useful for automation: - --gh_issue - The GitHub issue number to associate the NEWS entry with. - --section - The NEWS section name. One of {SECTIONS} - --rst_on_stdin - Pipe your ReStructured Text news entry via stdin instead of opening an editor. + Opens an editor to create a new entry for Misc/NEWS unless all + automation parameters are provided. -When using --rst_on_stdin, both --gh_issue and --section are required. + Parameters + ---------- + gh_issue : int, optional + GitHub issue number (optional, must be >= {lowest_possible_gh_issue_number}). + section : str, optional + NEWS section. One of {sections_csv}. + rst_on_stdin : bool + Read restructured text entry from stdin (requires gh issue and section). """ - if help: - print(add.__doc__) - sys.exit(0) - # Validate parameters - if not validate_add_parameters(section, gh_issue, rst_on_stdin): - return 1 + validate_add_parameters(section, gh_issue, rst_on_stdin) + chdir_to_repo_root() # Prepare content source if rst_on_stdin: @@ -946,21 +873,26 @@ def add(*, help=False, gh_issue: int = 0, section: str = "", rst_on_stdin=False) print(f"Ready for commit. {path!r} created and git added.") -# Format the docstring with the actual SECTIONS list -add.__doc__ = add.__doc__.format(SECTIONS=SECTIONS) +add.__doc__ = add.__doc__.format( + lowest_possible_gh_issue_number=LOWEST_POSSIBLE_GH_ISSUE_NUMBER, + sections_csv=", ".join(repr(s) for s in SECTIONS) +) +@app.command(name="release") +def release(version: str): + """Move all new blurbs to a single blurb file for the release. -@subcommand -def release(version): - """ -Move all new blurbs to a single blurb file for the release. + This is used by the release manager when cutting a new release. -This is used by the release manager when cutting a new release. + Parameters + ---------- + version : str + Version to release (use '.' for current directory name) """ + chdir_to_repo_root() + if version == ".": - # harvest version number from dirname of repo - # I remind you, we're in the Misc subdir right now version = os.path.basename(root) existing_filenames = glob_blurbs(version) @@ -1010,17 +942,23 @@ def release(version): -@subcommand -def merge(output=None, *, forced=False): - """ -Merge all blurbs together into a single Misc/NEWS file. - -Optional output argument specifies where to write to. -Default is /Misc/NEWS. +@app.command(name="merge") +def merge( + output: Optional[str] = None, + *, + forced: Annotated[bool, Parameter(alias=["-f"])] = False +): + """Merge all blurbs together into a single Misc/NEWS file. -If overwriting, blurb merge will prompt you to make sure it's okay. -To force it to overwrite, use -f. + Parameters + ---------- + output : str, optional + Output file path (default: Misc/NEWS) + forced : bool, optional + Force overwrite without prompting (-f) """ + chdir_to_repo_root() + if output: output = os.path.join(original_dir, output) else: @@ -1148,17 +1086,11 @@ def flush_git_rm_files(): git_rm_files.clear() -# @subcommand -# def noop(): -# "Do-nothing command. Used for blurb smoke-testing." -# pass - - -@subcommand +@app.command(name="populate") def populate(): - """ -Creates and populates the Misc/NEWS.d directory tree. - """ + """Creates and populates the Misc/NEWS.d directory tree.""" + chdir_to_repo_root() + os.chdir("Misc") os.makedirs("NEWS.d/next", exist_ok=True) @@ -1174,157 +1106,34 @@ def populate(): flush_git_add_files() -@subcommand +@app.command(name="export") def export(): - """ -Removes blurb data files, for building release tarballs/installers. - """ + """Removes blurb data files, for building release tarballs/installers.""" + chdir_to_repo_root() + os.chdir("Misc") shutil.rmtree("NEWS.d", ignore_errors=True) - -# @subcommand -# def arg(*, boolean=False, option=True): -# """ -# Test function for blurb command-line processing. -# """ -# print(f"arg: boolean {boolean} option {option}") +@app.default +def default_command(): + """Default to 'add' command when no subcommand specified.""" + add() def main(): + """Main entry point for the CLI using cyclopts.""" global original_dir - args = sys.argv[1:] - - if not args: - args = ["add"] - elif args[0] == "-h": - # slight hack - args[0] = "help" - - subcommand = args[0] - args = args[1:] - - fn = get_subcommand(subcommand) - - # hack - if fn in (help, version): - sys.exit(fn(*args)) - try: original_dir = os.getcwd() - chdir_to_repo_root() - - # Special handling for 'add' command with non-boolean options - if subcommand == "add": - kwargs = {"help": False, "gh_issue": 0, "section": "", "rst_on_stdin": False} - filtered_args = [] - i = 0 - while i < len(args): - arg = args[i] - if arg in ("-h", "--help"): - kwargs["help"] = True - elif arg == "--gh_issue": - if i + 1 < len(args): - try: - kwargs["gh_issue"] = int(args[i + 1]) - i += 1 - except ValueError: - sys.exit(f"blurb: --gh_issue requires an integer value, got '{args[i + 1]}'") - else: - sys.exit("blurb: --gh_issue requires a value") - elif arg == "--section": - if i + 1 < len(args): - kwargs["section"] = args[i + 1] - i += 1 - else: - sys.exit("blurb: --section requires a value") - elif arg in ("-r", "--rst_on_stdin"): - kwargs["rst_on_stdin"] = True - elif arg.startswith("-"): - sys.exit(f'blurb: Unknown option for add: "{arg}"') - else: - filtered_args.append(arg) - i += 1 - else: - # Original code for other commands - # map keyword arguments to options - # we only handle boolean options - # and they must have default values - short_options = {} - long_options = {} - kwargs = {} - for name, p in inspect.signature(fn).parameters.items(): - if p.kind == inspect.Parameter.KEYWORD_ONLY: - assert isinstance(p.default, bool), "blurb command-line processing only handles boolean options" - kwargs[name] = p.default - short_options[name[0]] = name - long_options[name] = name - - filtered_args = [] - done_with_options = False - - def handle_option(s, dict): - name = dict.get(s, None) - if not name: - sys.exit(f'blurb: Unknown option for {subcommand}: "{s}"') - kwargs[name] = not kwargs[name] - - # print(f"short_options {short_options} long_options {long_options}") - for a in args: - if done_with_options: - filtered_args.append(a) - continue - if a.startswith('-'): - if a == "--": - done_with_options = True - elif a.startswith("--"): - handle_option(a[2:], long_options) - else: - for s in a[1:]: - handle_option(s, short_options) - continue - filtered_args.append(a) - - - sys.exit(fn(*filtered_args, **kwargs)) - except TypeError as e: - # almost certainly wrong number of arguments. - # count arguments of function and print appropriate error message. - specified = len(args) - required = optional = 0 - for p in inspect.signature(fn).parameters.values(): - if p.default == inspect._empty: - required += 1 - else: - optional += 1 - total = required + optional - - if required <= specified <= total: - # whoops, must be a real type error, reraise - raise e - - how_many = f"{specified} argument" - if specified != 1: - how_many += "s" - - if total == 0: - middle = "accepts no arguments" - else: - if total == required: - middle = "requires" - else: - plural = "" if required == 1 else "s" - middle = f"requires at least {required} argument{plural} and at most" - middle += f" {total} argument" - if total != 1: - middle += "s" - - print(f'Error: Wrong number of arguments!\n\nblurb {subcommand} {middle},\nand you specified {how_many}.') - print() - print("usage: ", end="") - help(subcommand) + app() + except SystemExit: + raise + except KeyboardInterrupt: + sys.exit("Interrupted") + except Exception as e: + error(str(e)) if __name__ == '__main__': diff --git a/tests/test_add_command.py b/tests/test_add_command.py index 2913a29..a3ee2d0 100644 --- a/tests/test_add_command.py +++ b/tests/test_add_command.py @@ -26,19 +26,18 @@ def teardown_method(self): blurb.root = self.original_root def test_add_help_parameter(self, capsys): - """Test that --help displays the help text.""" - with pytest.raises(SystemExit) as exc_info: - blurb.add(help=True) - - # help exits with code 0 - assert exc_info.value.code == 0 - captured = capsys.readouterr() - assert "Add a blurb" in captured.out - assert "--gh_issue" in captured.out - assert "--section" in captured.out - assert "--rst_on_stdin" in captured.out - - def test_invalid_section_parameter(self, capsys): + """Test that add command has proper help text.""" + # With cyclopts, help is handled by the framework, not a parameter + # We'll test the docstring is properly formatted instead + assert blurb.add.__doc__ is not None + assert "Add a new Misc/NEWS entry" in blurb.add.__doc__ + assert "gh_issue" in blurb.add.__doc__ + assert "section" in blurb.add.__doc__ + assert "rst_on_stdin" in blurb.add.__doc__ + assert str(blurb.LOWEST_POSSIBLE_GH_ISSUE_NUMBER) in blurb.add.__doc__ + + @mock.patch.object(blurb, 'chdir_to_repo_root') + def test_invalid_section_parameter(self, mock_chdir, capsys): """Test that invalid section names are rejected.""" with pytest.raises(SystemExit) as exc_info: blurb.add(section="InvalidSection") @@ -47,37 +46,41 @@ def test_invalid_section_parameter(self, capsys): assert "--section must be one of" in str(exc_info.value) assert "InvalidSection" in str(exc_info.value) - def test_negative_gh_issue(self, capsys): + @mock.patch.object(blurb, 'chdir_to_repo_root') + def test_negative_gh_issue(self, mock_chdir, capsys): """Test that negative GitHub issue numbers are rejected.""" with pytest.raises(SystemExit) as exc_info: blurb.add(gh_issue=-123) # error() function exits with string message, not code - assert "--gh_issue must be a positive integer" in str(exc_info.value) + assert "--gh-issue must be a positive integer" in str(exc_info.value) - def test_rst_on_stdin_requires_other_params(self, capsys): - """Test that --rst_on_stdin requires --gh_issue and --section.""" + @mock.patch.object(blurb, 'chdir_to_repo_root') + def test_rst_on_stdin_requires_other_params(self, mock_chdir, capsys): + """Test that --rst-on-stdin requires --gh-issue and --section.""" with pytest.raises(SystemExit) as exc_info: blurb.add(rst_on_stdin=True) # error() function exits with string message, not code - assert "--gh_issue and --section required with --rst_on_stdin" in str(exc_info.value) + assert "--gh-issue and --section required with --rst-on-stdin" in str(exc_info.value) - def test_rst_on_stdin_missing_section(self, capsys): - """Test that --rst_on_stdin fails without --section.""" + @mock.patch.object(blurb, 'chdir_to_repo_root') + def test_rst_on_stdin_missing_section(self, mock_chdir, capsys): + """Test that --rst-on-stdin fails without --section.""" with pytest.raises(SystemExit) as exc_info: blurb.add(rst_on_stdin=True, gh_issue=12345) # error() function exits with string message, not code - assert "--gh_issue and --section required with --rst_on_stdin" in str(exc_info.value) + assert "--gh-issue and --section required with --rst-on-stdin" in str(exc_info.value) - def test_rst_on_stdin_missing_gh_issue(self, capsys): - """Test that --rst_on_stdin fails without --gh_issue.""" + @mock.patch.object(blurb, 'chdir_to_repo_root') + def test_rst_on_stdin_missing_gh_issue(self, mock_chdir, capsys): + """Test that --rst-on-stdin fails without --gh-issue.""" with pytest.raises(SystemExit) as exc_info: blurb.add(rst_on_stdin=True, section="Library") # error() function exits with string message, not code - assert "--gh_issue and --section required with --rst_on_stdin" in str(exc_info.value) + assert "--gh-issue and --section required with --rst-on-stdin" in str(exc_info.value) @mock.patch('blurb.blurb.chdir_to_repo_root') @mock.patch('blurb.blurb.flush_git_add_files') diff --git a/tests/test_cli_integration.py b/tests/test_cli_integration.py index 7c1eced..dc8146c 100644 --- a/tests/test_cli_integration.py +++ b/tests/test_cli_integration.py @@ -3,159 +3,278 @@ import os import subprocess import sys -import tempfile from pathlib import Path import pytest +import shutil @pytest.fixture def mock_cpython_repo(tmp_path): """Create a minimal mock CPython repository structure.""" - # Create necessary directories + # Core directories (tmp_path / "Include").mkdir() (tmp_path / "Python").mkdir() (tmp_path / "Misc" / "NEWS.d" / "next").mkdir(parents=True) - # Create section directories + # Section directories sections = ["Library", "Tests", "Documentation", "Core_and_Builtins", "Build", "Windows", "macOS", "IDLE", "Tools-Demos", "C_API", "Security"] for section in sections: (tmp_path / "Misc" / "NEWS.d" / "next" / section).mkdir() - # Create required files that identify a CPython repo + # Required files for CPython repo identification (tmp_path / "README").write_text("This is Python version 3.12.0\n") (tmp_path / "LICENSE").write_text("A. HISTORY OF THE SOFTWARE\n==========================\n") (tmp_path / "Include" / "Python.h").touch() (tmp_path / "Python" / "ceval.c").touch() - # Initialize as a git repository + # Git setup subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True) subprocess.run(["git", "config", "user.email", "brian@pythons.invalid"], cwd=tmp_path, capture_output=True) subprocess.run(["git", "config", "user.name", "Brian of Nazareth"], cwd=tmp_path, capture_output=True) yield tmp_path - # Cleanup happens automatically when tmp_path fixture is torn down @pytest.fixture -def blurb_executable(): - """Get the path to the blurb executable.""" - # Try to find blurb in the virtual environment - venv_blurb = Path(__file__).parent.parent / "venv" / "bin" / "blurb" - if venv_blurb.exists(): - return str(venv_blurb) +def mock_cpython_with_blurbs(mock_cpython_repo): + """Mock CPython repo with existing blurb files.""" + library_blurb = mock_cpython_repo / "Misc/NEWS.d/next/Library/2024-01-01-12-00-00.gh-issue-100000.abc123.rst" + library_blurb.write_text("Fixed spam module to handle eggs properly.") + + tests_blurb = mock_cpython_repo / "Misc/NEWS.d/next/Tests/2024-01-02-13-00-00.gh-issue-100001.def456.rst" + tests_blurb.write_text("Added tests for the spam module.") + + version_file = mock_cpython_repo / "Misc/NEWS.d/3.12.0.rst" + version_file.write_text(""".. date: 2024-01-01 +.. gh-issue: 100002 +.. nonce: xyz789 +.. release date: 2024-01-01 +.. section: Library + +Previous release notes. +""") + return mock_cpython_repo + - # Fall back to using Python module +@pytest.fixture +def blurb_executable(): + """Get the command line to run the blurb executable.""" return [sys.executable, "-m", "blurb"] def run_blurb(blurb_executable, args, cwd=None, input_text=None): """Run blurb with the given arguments.""" - if isinstance(blurb_executable, str): - cmd = [blurb_executable] + args - else: - cmd = blurb_executable + args - - result = subprocess.run( - cmd, - cwd=cwd, - input=input_text, - capture_output=True, - text=True - ) - return result - - -class TestCLIIntegration: - """Test the blurb command line interface with a mock CPython repo.""" - - def test_blurb_add_help_in_mock_repo(self, mock_cpython_repo, blurb_executable): - """Test that 'blurb add --help' works in a mock CPython repo.""" - # Run blurb add --help - result = run_blurb(blurb_executable, ["add", "--help"], cwd=mock_cpython_repo) - - # Check it succeeded - assert result.returncode == 0 + cmd = [blurb_executable] + args if isinstance(blurb_executable, str) else blurb_executable + args + return subprocess.run(cmd, cwd=cwd, input=input_text, capture_output=True, text=True) - # Check the help output contains expected content - output = result.stdout + result.stderr # Help might go to either - assert "Add a blurb" in output - assert "--gh_issue" in output - assert "--section" in output - assert "--rst_on_stdin" in output - assert "Library" in output # Should show available sections - def test_blurb_version(self, blurb_executable): - """Test that 'blurb version' works without a CPython repo.""" - result = run_blurb(blurb_executable, ["version"]) +class TestBasicCommands: + """Test basic CLI functionality and help.""" + @pytest.mark.parametrize("cmd,expected", [ + (["version"], "blurb version"), + (["help"], "Commands"), + (["-h"], "Commands"), + ]) + def test_info_commands(self, blurb_executable, cmd, expected): + result = run_blurb(blurb_executable, cmd) assert result.returncode == 0 - assert "blurb version" in result.stdout - - def test_blurb_help(self, blurb_executable): - """Test that 'blurb help' works without a CPython repo.""" - result = run_blurb(blurb_executable, ["help"]) + output = result.stdout + result.stderr + assert expected in output + @pytest.mark.parametrize("subcommand", ["add", "merge", "release", "populate", "export"]) + def test_help_subcommands(self, blurb_executable, subcommand): + result = run_blurb(blurb_executable, ["help", subcommand]) assert result.returncode == 0 - assert "Available subcommands:" in result.stdout - assert "add" in result.stdout - assert "merge" in result.stdout - assert "release" in result.stdout - - def test_blurb_add_automation_params_validation(self, mock_cpython_repo, blurb_executable): - """Test validation of automation parameters via CLI.""" - # Test invalid section - result = run_blurb(blurb_executable, ["add", "--section", "InvalidSection"], cwd=mock_cpython_repo) + output = result.stdout + result.stderr + assert subcommand in output.lower() + + def test_invalid_subcommand(self, blurb_executable): + result = run_blurb(blurb_executable, ["invalid_command"]) assert result.returncode != 0 - assert "must be one of" in result.stderr + # With cyclopts, invalid commands show "Unused Tokens" error + output = result.stdout + result.stderr + assert "Unused Tokens" in output - # Test negative gh_issue - result = run_blurb(blurb_executable, ["add", "--gh_issue", "-123"], cwd=mock_cpython_repo) + def test_outside_cpython_repo(self, blurb_executable, tmp_path): + non_cpython_dir = tmp_path / "not_cpython" + non_cpython_dir.mkdir() + result = run_blurb(blurb_executable, ["add"], cwd=non_cpython_dir) assert result.returncode != 0 - assert "must be a positive integer" in result.stderr + assert "not inside a CPython repo" in result.stderr + + +class TestAddCommand: + """Test the add command functionality.""" - # Test rst_on_stdin without required params - result = run_blurb(blurb_executable, ["add", "--rst_on_stdin"], cwd=mock_cpython_repo) + def test_help_content(self, mock_cpython_repo, blurb_executable): + result = run_blurb(blurb_executable, ["add", "--help"], cwd=mock_cpython_repo) + assert result.returncode == 0 + output = result.stdout + result.stderr + required_content = ["Add a new Misc/NEWS entry", "--gh-issue", "--section", "--rst-on-stdin", "Library"] + assert all(content in output for content in required_content) + + @pytest.mark.parametrize("args,error_text", [ + (["--section", "InvalidSection"], "must be one of"), + (["--gh-issue", "-123"], "must be a positive integer"), + (["--rst-on-stdin"], "--gh-issue and --section required"), + ]) + def test_validation_errors(self, mock_cpython_repo, blurb_executable, args, error_text): + result = run_blurb(blurb_executable, ["add"] + args, cwd=mock_cpython_repo) assert result.returncode != 0 - assert "--gh_issue and --section required" in result.stderr + assert error_text in result.stderr @pytest.mark.parametrize("section", ["Library", "Tests", "Documentation"]) - def test_blurb_add_with_stdin_integration(self, mock_cpython_repo, blurb_executable, section): - """Test the full automation flow with stdin input.""" - # Create the blurb content + def test_stdin_automation(self, mock_cpython_repo, blurb_executable, section): blurb_text = f"Fixed a bug in the {section.lower()} that improves spam handling." - - # Run blurb with all automation parameters result = run_blurb( blurb_executable, - ["add", "--gh_issue", "123456", "--section", section, "--rst_on_stdin"], + ["add", "--gh-issue", "123456", "--section", section, "--rst-on-stdin"], cwd=mock_cpython_repo, input_text=blurb_text ) - - # Check it succeeded assert result.returncode == 0 assert "Ready for commit" in result.stdout - assert "created and git added" in result.stdout - # Verify the file was created news_dir = mock_cpython_repo / "Misc" / "NEWS.d" / "next" / section rst_files = list(news_dir.glob("*.gh-issue-123456.*.rst")) assert len(rst_files) == 1 + assert blurb_text in rst_files[0].read_text() - # Verify the content - created_file = rst_files[0] - content = created_file.read_text() - assert blurb_text in content + def test_default_behavior(self, mock_cpython_repo, blurb_executable, monkeypatch): + monkeypatch.setenv("EDITOR", "true") + result = run_blurb(blurb_executable, [], cwd=mock_cpython_repo) + assert result.returncode != 0 + assert "Blurb 'body' text must not be empty!" in result.stdout or "EOFError" in result.stderr - def test_blurb_outside_cpython_repo(self, blurb_executable, tmp_path): - """Test that blurb gives appropriate error outside a CPython repo.""" - # Create a non-CPython directory - non_cpython_dir = tmp_path / "not_cpython" - non_cpython_dir.mkdir() - # Try to run blurb add - result = run_blurb(blurb_executable, ["add"], cwd=non_cpython_dir) +class TestMergeCommand: + """Test merge functionality and options.""" + + def test_basic_merge(self, mock_cpython_with_blurbs, blurb_executable): + result = run_blurb(blurb_executable, ["merge"], cwd=mock_cpython_with_blurbs) + assert result.returncode == 0 + + news_file = mock_cpython_with_blurbs / "Misc/NEWS" + assert news_file.exists() + content = news_file.read_text() + assert "Fixed spam module" in content + assert "Added tests for the spam module" in content + + def test_custom_output(self, mock_cpython_with_blurbs, blurb_executable): + result = run_blurb(blurb_executable, ["merge", "custom_news.txt"], cwd=mock_cpython_with_blurbs) + assert result.returncode == 0 + + custom_file = mock_cpython_with_blurbs / "custom_news.txt" + assert custom_file.exists() + assert "Fixed spam module" in custom_file.read_text() + + @pytest.mark.parametrize("force_flag", ["--forced", "-f"]) + def test_forced_overwrite(self, mock_cpython_with_blurbs, blurb_executable, force_flag): + news_file = mock_cpython_with_blurbs / "Misc/NEWS" + news_file.write_text("Old content") + + result = run_blurb(blurb_executable, ["merge", force_flag], cwd=mock_cpython_with_blurbs) + assert result.returncode == 0 + content = news_file.read_text() + assert "Old content" not in content + assert "Fixed spam module" in content + + def test_no_blurbs_error(self, tmp_path, blurb_executable): + # Create a minimal CPython repo with empty NEWS.d (no next directories) + (tmp_path / "Include").mkdir() + (tmp_path / "Python").mkdir() + (tmp_path / "Misc" / "NEWS.d").mkdir(parents=True) + + # Required files for CPython repo identification + (tmp_path / "README").write_text("This is Python version 3.12.0\n") + (tmp_path / "LICENSE").write_text("A. HISTORY OF THE SOFTWARE\n==========================\n") + (tmp_path / "Include" / "Python.h").touch() + (tmp_path / "Python" / "ceval.c").touch() + + result = run_blurb(blurb_executable, ["merge"], cwd=tmp_path) assert result.returncode != 0 - assert "not inside a CPython repo" in result.stderr + assert "don't have ANY blurbs" in result.stderr + + +class TestReleaseCommand: + """Test release management functionality.""" + + def test_version_release(self, mock_cpython_with_blurbs, blurb_executable): + result = run_blurb(blurb_executable, ["release", "3.12.1"], cwd=mock_cpython_with_blurbs) + assert result.returncode == 0 + + version_file = mock_cpython_with_blurbs / "Misc/NEWS.d/3.12.1.rst" + assert version_file.exists() + + # Verify blurbs were moved + library_dir = mock_cpython_with_blurbs / "Misc/NEWS.d/next/Library" + tests_dir = mock_cpython_with_blurbs / "Misc/NEWS.d/next/Tests" + assert not list(library_dir.glob("*.rst")) + assert not list(tests_dir.glob("*.rst")) + + def test_dot_version(self, mock_cpython_with_blurbs, blurb_executable): + versioned_dir = mock_cpython_with_blurbs.parent / "3.12.2" + shutil.move(str(mock_cpython_with_blurbs), str(versioned_dir)) + + result = run_blurb(blurb_executable, ["release", "."], cwd=versioned_dir) + assert result.returncode == 0 + + version_file = versioned_dir / "Misc/NEWS.d/3.12.2.rst" + assert version_file.exists() + + def test_missing_version_error(self, mock_cpython_repo, blurb_executable): + result = run_blurb(blurb_executable, ["release"], cwd=mock_cpython_repo) + assert result.returncode != 0 + assert any(word in result.stdout.lower() for word in ["requires", "missing", "expected"]) + + def test_too_many_args_error(self, mock_cpython_repo, blurb_executable): + result = run_blurb(blurb_executable, ["release", "arg1", "arg2"], cwd=mock_cpython_repo) + assert result.returncode != 0 + assert any(word in result.stdout.lower() for word in ["unused", "too many"]) + + +class TestMaintenanceCommands: + """Test populate and export commands.""" + + def test_populate_structure(self, mock_cpython_repo, blurb_executable): + shutil.rmtree(mock_cpython_repo / "Misc/NEWS.d") + + result = run_blurb(blurb_executable, ["populate"], cwd=mock_cpython_repo) + assert result.returncode == 0 + + news_d = mock_cpython_repo / "Misc/NEWS.d" + assert news_d.exists() + assert (news_d / "next").exists() + + for section in ["Library", "Tests", "Documentation"]: + section_dir = news_d / "next" / section + assert section_dir.exists() + readme = section_dir / "README.rst" + assert readme.exists() + assert section in readme.read_text() + + def test_populate_idempotent(self, mock_cpython_with_blurbs, blurb_executable): + library_blurb = mock_cpython_with_blurbs / "Misc/NEWS.d/next/Library/2024-01-01-12-00-00.gh-issue-100000.abc123.rst" + initial_content = library_blurb.read_text() + + result = run_blurb(blurb_executable, ["populate"], cwd=mock_cpython_with_blurbs) + assert result.returncode == 0 + assert library_blurb.read_text() == initial_content + + def test_export_removes_directory(self, mock_cpython_with_blurbs, blurb_executable): + news_d = mock_cpython_with_blurbs / "Misc/NEWS.d" + assert news_d.exists() + + result = run_blurb(blurb_executable, ["export"], cwd=mock_cpython_with_blurbs) + assert result.returncode == 0 + assert not news_d.exists() + + def test_export_missing_directory_ok(self, mock_cpython_repo, blurb_executable): + news_d = mock_cpython_repo / "Misc/NEWS.d" + shutil.rmtree(news_d) + + result = run_blurb(blurb_executable, ["export"], cwd=mock_cpython_repo) + assert result.returncode == 0 From 67a6c0cc8f466cd4117365729b2060a5b8311d57 Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith" Date: Sun, 29 Jun 2025 05:34:58 +0000 Subject: [PATCH 3/8] fixup changelog --- CHANGELOG.md | 5 +++-- README.md | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 133dc5c..21d2f7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,11 @@ ## 2.1.0 (unreleased) * Add automation support to `blurb add` command: - * New `--gh_issue` option to specify GitHub issue number + * New `--gh-issue` option to specify GitHub issue number * New `--section` option to specify NEWS section - * New `--rst_on_stdin` option to read entry content from stdin + * New `--rst-on-stdin` option to read entry content from stdin * Useful for CI systems and automated tools +* Uses `cyclopts` for command line parsing instead of rolling our own to reduce our code size, this changes the help format and brings in a dependency. ## 2.0.0 diff --git a/README.md b/README.md index 0e61129..6040d6a 100644 --- a/README.md +++ b/README.md @@ -83,10 +83,10 @@ For automated tools and CI systems, `blurb add` supports non-interactive operati ```bash # Add a blurb entry from stdin -echo "Added beans to the :mod:`spam` module." | blurb add \ - --gh_issue 123456 \ +echo 'Added beans to the :mod:`spam` module.' | blurb add \ + --gh-issue 123456 \ --section Library \ - --rst_on_stdin + --rst-on-stdin ``` When using `--rst_on_stdin`, both `--gh_issue` and `--section` are required. From e79dd63aab18f8d1af75bebb7381a5eaf5085a0c Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith" Date: Sun, 29 Jun 2025 05:47:43 +0000 Subject: [PATCH 4/8] Add ruff check to lint, address errors. --- .github/workflows/lint.yml | 4 ++++ src/blurb/__init__.py | 2 +- tests/test_add_command.py | 3 +-- tests/test_cli_integration.py | 2 -- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e535eb6..8c56ccd 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -20,3 +20,7 @@ jobs: with: python-version: "3.x" - uses: tox-dev/action-pre-commit-uv@v1 + - uses: astral-sh/ruff-action@v3 + with: + args: check + src: "." diff --git a/src/blurb/__init__.py b/src/blurb/__init__.py index 8dee4bf..aab79a8 100644 --- a/src/blurb/__init__.py +++ b/src/blurb/__init__.py @@ -1 +1 @@ -from ._version import __version__ +from ._version import __version__ as __version__ diff --git a/tests/test_add_command.py b/tests/test_add_command.py index a3ee2d0..01a8e67 100644 --- a/tests/test_add_command.py +++ b/tests/test_add_command.py @@ -2,7 +2,6 @@ import io import os -import tempfile from unittest import mock import pytest from pyfakefs.fake_filesystem import FakeFilesystem @@ -103,7 +102,7 @@ def test_add_with_all_automation_params(self, mock_stdin, mock_flush_git, mock_c # Call add with automation parameters with mock.patch('blurb.blurb.sortable_datetime', return_value='2024-01-01-12-00-00'): with mock.patch('blurb.blurb.nonceify', return_value='abc123'): - result = blurb.add( + blurb.add( gh_issue=123456, section="Library", rst_on_stdin=True diff --git a/tests/test_cli_integration.py b/tests/test_cli_integration.py index dc8146c..f987992 100644 --- a/tests/test_cli_integration.py +++ b/tests/test_cli_integration.py @@ -1,9 +1,7 @@ """Integration tests for the blurb CLI tool.""" -import os import subprocess import sys -from pathlib import Path import pytest import shutil From 2106ae6d08413e2afdbf7b1347f6b10b9c5f34e0 Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith" Date: Thu, 3 Jul 2025 21:14:51 -0700 Subject: [PATCH 5/8] Add user-friendly interface improvements to blurb add command Integrate the user-friendly features from PR #16 by @picnixz into the automation support from PR #45, making the CLI more intuitive: - Change --gh-issue to --issue, accepting multiple formats: * Plain numbers: --issue 12345 * With gh- prefix: --issue gh-12345 * GitHub URLs: --issue https://github.com/python/cpython/issues/12345 - Add smart section matching with: * Case-insensitive matching: --section lib matches "Library" * Partial matching: --section doc matches "Documentation" * Common aliases: --section api matches "C API" * Separator normalization: --section core-and-builtins - Improve error messages for invalid sections This combines the automation features from PR #45 with the interface improvements suggested by @picnixz in PR #16, as reviewed by @hugovk and @larryhastings. Co-authored-by: picnixz --- CHANGELOG.md | 4 +- README.md | 14 +- src/blurb/blurb.py | 159 ++++++++++++--- tests/conftest.py | 11 + tests/test_add_command.py | 125 ------------ tests/test_blurb_add.py | 373 ++++++++++++++++++++++++++++++++++ tests/test_cli_integration.py | 10 +- 7 files changed, 536 insertions(+), 160 deletions(-) mode change 100755 => 100644 src/blurb/blurb.py create mode 100644 tests/conftest.py delete mode 100644 tests/test_add_command.py create mode 100644 tests/test_blurb_add.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 21d2f7d..7f8044c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,8 @@ ## 2.1.0 (unreleased) * Add automation support to `blurb add` command: - * New `--gh-issue` option to specify GitHub issue number - * New `--section` option to specify NEWS section + * New `--issue` option to specify GitHub issue number (supports URLs and various formats) + * New `--section` option to specify NEWS section (with smart case-insensitive matching) * New `--rst-on-stdin` option to read entry content from stdin * Useful for CI systems and automated tools * Uses `cyclopts` for command line parsing instead of rolling our own to reduce our code size, this changes the help format and brings in a dependency. diff --git a/README.md b/README.md index 6040d6a..db6afc0 100644 --- a/README.md +++ b/README.md @@ -84,12 +84,22 @@ For automated tools and CI systems, `blurb add` supports non-interactive operati ```bash # Add a blurb entry from stdin echo 'Added beans to the :mod:`spam` module.' | blurb add \ - --gh-issue 123456 \ + --issue 123456 \ --section Library \ --rst-on-stdin ``` -When using `--rst_on_stdin`, both `--gh_issue` and `--section` are required. +When using `--rst-on-stdin`, both `--issue` and `--section` are required. + +The `--issue` parameter accepts various formats: +- Issue number: `--issue 12345` +- With gh- prefix: `--issue gh-12345` +- GitHub URL: `--issue https://github.com/python/cpython/issues/12345` + +The `--section` parameter supports smart matching: +- Case insensitive: `--section library` or `--section LIBRARY` +- Partial matching: `--section lib` (matches "Library") +- Common aliases: `--section api` (matches "C API"), `--section builtin` (matches "Core and Builtins") The template for the `blurb add` message looks like this: diff --git a/src/blurb/blurb.py b/src/blurb/blurb.py old mode 100755 new mode 100644 index 1472cfa..b49584f --- a/src/blurb/blurb.py +++ b/src/blurb/blurb.py @@ -747,31 +747,26 @@ def find_editor(): error('Could not find an editor! Set the EDITOR environment variable.') -def validate_add_parameters(section, gh_issue, rst_on_stdin): - """Validate parameters for the add command.""" - if section and section not in SECTIONS: - error(f"--section must be one of {SECTIONS} not {section!r}") - if gh_issue < 0: - error(f"--gh-issue must be a positive integer not {gh_issue!r}") - if rst_on_stdin and (gh_issue <= 0 or not section): - error("--gh-issue and --section required with --rst-on-stdin") - - -def prepare_template(tmp_path, gh_issue, section, rst_content): +def prepare_template(tmp_path, issue_number, section_name, rst_content): """Write the template file with substitutions.""" text = template - # Ensure gh-issue line ends with space + # Ensure gh-issue line ends with space (or fill in issue number) issue_line = ".. gh-issue:" - text = text.replace(f"\n{issue_line}\n", f"\n{issue_line} \n") + pattern = f"\n{issue_line}\n" + if issue_number: + replacement = f"\n{issue_line} {issue_number}\n" + else: + replacement = f"\n{issue_line} \n" + text = text.replace(pattern, replacement) + + # Apply section substitution + if section_name: + text = text.replace(f"#.. section: {section_name}\n", f".. section: {section_name}\n") - # Apply substitutions - if gh_issue: - text = text.replace(".. gh-issue: \n", f".. gh-issue: {gh_issue}\n") - if section: - text = text.replace(f"#.. section: {section}\n", f".. section: {section}\n") + # Apply content substitution if rst_content: marker = "#################\n\n" text = text.replace(marker, f"{marker}{rst_content}\n") @@ -815,25 +810,138 @@ def edit_until_valid(editor, tmp_path): print() +def _extract_issue_number(issue): + """Extract issue number from various formats like '12345', 'gh-12345', or GitHub URLs.""" + if issue is None: + return None + + issue = raw_issue = str(issue).strip() + if issue.startswith('gh-'): + issue = issue[3:] + if issue.isdigit(): + return issue + + match = re.match(r'^(?:https://)?github\.com/python/cpython/issues/(\d+)$', issue) + if match is None: + error(f"Invalid GitHub issue: {raw_issue}") + return match.group(1) + + +def _extract_section_name(section): + """Extract section name with smart matching.""" + if section is None: + return None + + section = raw_section = section.strip() + if not section: + error("Empty section name!") + + matches = [] + # Try simple case-insensitive substring matching + section_lower = section.lower() + for section_name in SECTIONS: + if section_lower in section_name.lower(): + matches.append(section_name) + + # If no matches, try more complex matching + if not matches: + matches = _find_smart_matches(section) + + if not matches: + sections_list = '\n'.join(f' - {s}' for s in SECTIONS) + error(f"Invalid section name: {raw_section!r}\n\nValid sections are:\n{sections_list}") + + if len(matches) > 1: + multiple_matches = ', '.join(map(repr, sorted(matches))) + error(f"More than one match for: {raw_section!r}\nMatches: {multiple_matches}") + + return matches[0] + + +def _find_smart_matches(section): + """Find matches using advanced pattern matching.""" + # Normalize separators + sanitized = re.sub(r'[_\- /]', ' ', section).strip() + if not sanitized: + return [] + + matches = [] + section_words = re.split(r'\s+', sanitized) + + # Build pattern to match against known sections + section_pattern = r'[\s/]*'.join(map(re.escape, section_words)) + section_pattern = re.compile(section_pattern, re.I) + + for section_name in SECTIONS: + if section_pattern.search(section_name): + matches.append(section_name) + + # Special cases and aliases + normalized = ''.join(section_words).lower() + + # Check special aliases + aliases = { + 'api': 'C API', + 'capi': 'C API', + 'builtin': 'Core and Builtins', + 'builtins': 'Core and Builtins', + 'core': 'Core and Builtins', + 'demo': 'Tools/Demos', + 'demos': 'Tools/Demos', + 'tool': 'Tools/Demos', + 'tools': 'Tools/Demos', + } + + for alias, section_name in aliases.items(): + if normalized.startswith(alias): + if section_name not in matches: + matches.append(section_name) + + # Try matching by removing spaces/separators + if not matches: + for section_name in SECTIONS: + section_normalized = re.sub(r'[^a-zA-Z0-9]', '', section_name).lower() + if section_normalized.startswith(normalized): + matches.append(section_name) + + return matches + + @app.command(name="add") -def add(*, gh_issue: int = 0, section: str = "", rst_on_stdin: bool = False): +def add(*, issue: Annotated[Optional[str], Parameter(alias=["-i"])] = None, + section: Annotated[Optional[str], Parameter(alias=["-s"])] = None, + rst_on_stdin: bool = False): # This docstring template is formatted after the function definition. """Add a new Misc/NEWS entry. Opens an editor to create a new entry for Misc/NEWS unless all automation parameters are provided. + Use -i/--issue to specify a GitHub issue number or link. + Use -s/--section to specify the NEWS section (case insensitive with partial matching). + Parameters ---------- - gh_issue : int, optional - GitHub issue number (optional, must be >= {lowest_possible_gh_issue_number}). + issue : str, optional + GitHub issue number or URL (e.g. '12345', 'gh-12345', or 'https://github.com/python/cpython/issues/12345'). section : str, optional - NEWS section. One of {sections_csv}. + NEWS section. Can use partial matching (e.g. 'lib' for 'Library'). One of {sections_csv}. rst_on_stdin : bool - Read restructured text entry from stdin (requires gh issue and section). + Read restructured text entry from stdin (requires issue and section). """ - validate_add_parameters(section, gh_issue, rst_on_stdin) + # Extract and validate issue number + issue_number = _extract_issue_number(issue) if issue else None + if issue_number and int(issue_number) < LOWEST_POSSIBLE_GH_ISSUE_NUMBER: + error(f"Invalid issue number: {issue_number} (must be >= {LOWEST_POSSIBLE_GH_ISSUE_NUMBER})") + + # Extract and validate section + section_name = _extract_section_name(section) if section else None + + # Validate parameters for stdin mode + if rst_on_stdin and (not issue_number or not section_name): + error("--issue and --section required with --rst-on-stdin") + chdir_to_repo_root() # Prepare content source @@ -852,7 +960,7 @@ def add(*, gh_issue: int = 0, section: str = "", rst_on_stdin: bool = False): atexit.register(lambda: os.path.exists(tmp_path) and os.unlink(tmp_path)) # Prepare template - prepare_template(tmp_path, gh_issue, section, rst_content) + prepare_template(tmp_path, issue_number, section_name, rst_content) # Get blurb content if editor: @@ -874,7 +982,6 @@ def add(*, gh_issue: int = 0, section: str = "", rst_on_stdin: bool = False): add.__doc__ = add.__doc__.format( - lowest_possible_gh_issue_number=LOWEST_POSSIBLE_GH_ISSUE_NUMBER, sections_csv=", ".join(repr(s) for s in SECTIONS) ) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e45e7ed --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,11 @@ +"""pytest configuration and fixtures.""" + +import pytest +from pyfakefs.fake_filesystem_unittest import Patcher + + +@pytest.fixture +def fs(): + """Pyfakefs fixture compatible with pytest.""" + with Patcher() as patcher: + yield patcher.fs diff --git a/tests/test_add_command.py b/tests/test_add_command.py deleted file mode 100644 index 01a8e67..0000000 --- a/tests/test_add_command.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Tests for the blurb add command with automation features.""" - -import io -import os -from unittest import mock -import pytest -from pyfakefs.fake_filesystem import FakeFilesystem - -from blurb import blurb - - -class TestAddCommand: - """Test cases for the add command's automation features.""" - - def setup_method(self): - """Set up test environment before each test.""" - # Save original values - self.original_dir = os.getcwd() - self.original_root = blurb.root - - def teardown_method(self): - """Clean up after each test.""" - # Restore original values - os.chdir(self.original_dir) - blurb.root = self.original_root - - def test_add_help_parameter(self, capsys): - """Test that add command has proper help text.""" - # With cyclopts, help is handled by the framework, not a parameter - # We'll test the docstring is properly formatted instead - assert blurb.add.__doc__ is not None - assert "Add a new Misc/NEWS entry" in blurb.add.__doc__ - assert "gh_issue" in blurb.add.__doc__ - assert "section" in blurb.add.__doc__ - assert "rst_on_stdin" in blurb.add.__doc__ - assert str(blurb.LOWEST_POSSIBLE_GH_ISSUE_NUMBER) in blurb.add.__doc__ - - @mock.patch.object(blurb, 'chdir_to_repo_root') - def test_invalid_section_parameter(self, mock_chdir, capsys): - """Test that invalid section names are rejected.""" - with pytest.raises(SystemExit) as exc_info: - blurb.add(section="InvalidSection") - - # error() function exits with string message, not code - assert "--section must be one of" in str(exc_info.value) - assert "InvalidSection" in str(exc_info.value) - - @mock.patch.object(blurb, 'chdir_to_repo_root') - def test_negative_gh_issue(self, mock_chdir, capsys): - """Test that negative GitHub issue numbers are rejected.""" - with pytest.raises(SystemExit) as exc_info: - blurb.add(gh_issue=-123) - - # error() function exits with string message, not code - assert "--gh-issue must be a positive integer" in str(exc_info.value) - - @mock.patch.object(blurb, 'chdir_to_repo_root') - def test_rst_on_stdin_requires_other_params(self, mock_chdir, capsys): - """Test that --rst-on-stdin requires --gh-issue and --section.""" - with pytest.raises(SystemExit) as exc_info: - blurb.add(rst_on_stdin=True) - - # error() function exits with string message, not code - assert "--gh-issue and --section required with --rst-on-stdin" in str(exc_info.value) - - @mock.patch.object(blurb, 'chdir_to_repo_root') - def test_rst_on_stdin_missing_section(self, mock_chdir, capsys): - """Test that --rst-on-stdin fails without --section.""" - with pytest.raises(SystemExit) as exc_info: - blurb.add(rst_on_stdin=True, gh_issue=12345) - - # error() function exits with string message, not code - assert "--gh-issue and --section required with --rst-on-stdin" in str(exc_info.value) - - @mock.patch.object(blurb, 'chdir_to_repo_root') - def test_rst_on_stdin_missing_gh_issue(self, mock_chdir, capsys): - """Test that --rst-on-stdin fails without --gh-issue.""" - with pytest.raises(SystemExit) as exc_info: - blurb.add(rst_on_stdin=True, section="Library") - - # error() function exits with string message, not code - assert "--gh-issue and --section required with --rst-on-stdin" in str(exc_info.value) - - @mock.patch('blurb.blurb.chdir_to_repo_root') - @mock.patch('blurb.blurb.flush_git_add_files') - @mock.patch('sys.stdin', new_callable=io.StringIO) - def test_add_with_all_automation_params(self, mock_stdin, mock_flush_git, mock_chdir, fs: FakeFilesystem): - """Test successful add with all automation parameters.""" - # Set up fake filesystem - fs.create_dir("/fake_repo") - fs.create_dir("/fake_repo/Misc/NEWS.d/next/Library") - os.chdir("/fake_repo") - blurb.root = "/fake_repo" - - # Mock stdin content with a Monty Python reference - mock_stdin.write("Fixed spam module to properly handle eggs, bacon, and spam repetition counts.") - mock_stdin.seek(0) - - # Mock chdir_to_repo_root to do nothing since we're in fake fs - mock_chdir.return_value = None - - # Call add with automation parameters - with mock.patch('blurb.blurb.sortable_datetime', return_value='2024-01-01-12-00-00'): - with mock.patch('blurb.blurb.nonceify', return_value='abc123'): - blurb.add( - gh_issue=123456, - section="Library", - rst_on_stdin=True - ) - - # Verify the file was created - expected_path = "/fake_repo/Misc/NEWS.d/next/Library/2024-01-01-12-00-00.gh-issue-123456.abc123.rst" - assert os.path.exists(expected_path) - - # Verify file contents - the metadata is in the filename, not the file content - with open(expected_path) as f: - content = f.read() - - # The file should only contain the body text (which may be wrapped) - assert "Fixed spam module to properly handle eggs, bacon, and spam repetition" in content - assert "counts." in content - - # Verify git add was called - assert expected_path in blurb.git_add_files - mock_flush_git.assert_called_once() diff --git a/tests/test_blurb_add.py b/tests/test_blurb_add.py new file mode 100644 index 0000000..b9713ab --- /dev/null +++ b/tests/test_blurb_add.py @@ -0,0 +1,373 @@ +import io +import os +import re +import sys +import tempfile +from unittest import mock + +import pytest + +from blurb import blurb + + +def test_valid_no_issue_number(): + assert blurb._extract_issue_number(None) is None + + +@pytest.mark.parametrize('issue', [ + # issue given by their number + '12345', + '12345 ', + ' 12345', + ' 12345 ', + # issue given by their number and a 'gh-' prefix + 'gh-12345', + 'gh-12345 ', + ' gh-12345', + ' gh-12345 ', + # issue given by their URL (no protocol) + 'github.com/python/cpython/issues/12345', + 'github.com/python/cpython/issues/12345 ', + ' github.com/python/cpython/issues/12345', + ' github.com/python/cpython/issues/12345 ', + # issue given by their URL (with protocol) + 'https://github.com/python/cpython/issues/12345', + 'https://github.com/python/cpython/issues/12345 ', + ' https://github.com/python/cpython/issues/12345', + ' https://github.com/python/cpython/issues/12345 ', +]) +def test_valid_issue_number_12345(issue): + actual = blurb._extract_issue_number(issue) + assert actual == '12345' + + +@pytest.mark.parametrize('issue', [ + '', + 'abc', + 'gh-abc', + 'gh-', + 'bpo-', + 'bpo-12345', + 'github.com/python/cpython/issues', + 'github.com/python/cpython/issues/', + 'github.com/python/cpython/issues/abc', + 'github.com/python/cpython/issues/gh-abc', + 'github.com/python/cpython/issues/gh-123', + 'github.com/python/cpython/issues/1234?param=1', + 'https://github.com/python/cpython/issues', + 'https://github.com/python/cpython/issues/', + 'https://github.com/python/cpython/issues/abc', + 'https://github.com/python/cpython/issues/gh-abc', + 'https://github.com/python/cpython/issues/gh-123', + 'https://github.com/python/cpython/issues/1234?param=1', +]) +def test_invalid_issue_number(issue): + error_message = re.escape(f'Invalid GitHub issue: {issue}') + with pytest.raises(SystemExit, match=error_message): + blurb._extract_issue_number(issue) + + +class TestValidSectionNames: + @staticmethod + def check(section, expect): + actual = blurb._extract_section_name(section) + assert actual == expect + + @pytest.mark.parametrize( + ('section', 'expect'), + tuple(zip(blurb.SECTIONS, blurb.SECTIONS)) + ) + def test_exact_names(self, section, expect): + self.check(section, expect) + + @pytest.mark.parametrize( + ('section', 'expect'), [ + ('Sec', 'Security'), + ('sec', 'Security'), + ('security', 'Security'), + ('Core And', 'Core and Builtins'), + ('Core And Built', 'Core and Builtins'), + ('Core And Builtins', 'Core and Builtins'), + ('Lib', 'Library'), + ('doc', 'Documentation'), + ('document', 'Documentation'), + ('Tes', 'Tests'), + ('tes', 'Tests'), + ('Test', 'Tests'), + ('Tests', 'Tests'), + # 'Buil' and 'bui' are ambiguous with 'Core and Builtins' + ('build', 'Build'), + ('Tool', 'Tools/Demos'), + ('Tools', 'Tools/Demos'), + ('Tools/', 'Tools/Demos'), + ('core', 'Core and Builtins'), + ] + ) + def test_partial_words(self, section, expect): + self.check(section, expect) + + @pytest.mark.parametrize( + ('section', 'expect'), [ + ('builtin', 'Core and Builtins'), + ('builtins', 'Core and Builtins'), + ('api', 'C API'), + ('c-api', 'C API'), + ('c/api', 'C API'), + ('c api', 'C API'), + ('dem', 'Tools/Demos'), + ('demo', 'Tools/Demos'), + ('demos', 'Tools/Demos'), + ] + ) + def test_partial_special_names(self, section, expect): + self.check(section, expect) + + @pytest.mark.parametrize( + ('section', 'expect'), [ + ('Core-and-Builtins', 'Core and Builtins'), + ('Core_and_Builtins', 'Core and Builtins'), + ('Core_and-Builtins', 'Core and Builtins'), + ('Core and', 'Core and Builtins'), + ('Core_and', 'Core and Builtins'), + ('core_and', 'Core and Builtins'), + ('core-and', 'Core and Builtins'), + ('Core and Builtins', 'Core and Builtins'), + ('cOre _ and - bUILtins', 'Core and Builtins'), + ('Tools/demo', 'Tools/Demos'), + ('Tools-demo', 'Tools/Demos'), + ('Tools demo', 'Tools/Demos'), + ] + ) + def test_partial_separators(self, section, expect): + # normalize the separtors '_', '-', ' ' and '/' + self.check(section, expect) + + @pytest.mark.parametrize( + ('prefix', 'expect'), [ + ('corean', 'Core and Builtins'), + ('coreand', 'Core and Builtins'), + ('coreandbuilt', 'Core and Builtins'), + ('coreand Builtins', 'Core and Builtins'), + ('coreand Builtins', 'Core and Builtins'), + ('coreAnd Builtins', 'Core and Builtins'), + ('CoreAnd Builtins', 'Core and Builtins'), + ('Coreand', 'Core and Builtins'), + ('Coreand Builtins', 'Core and Builtins'), + ('Coreand builtin', 'Core and Builtins'), + ('Coreand buil', 'Core and Builtins'), + ] + ) + def test_partial_prefix_words(self, prefix, expect): + # try to find a match using prefixes (without separators and lowercase) + self.check(prefix, expect) + + @pytest.mark.parametrize( + ('section', 'expect'), + [(name.lower(), name) for name in blurb.SECTIONS], + ) + def test_exact_names_lowercase(self, section, expect): + self.check(section, expect) + + @pytest.mark.parametrize( + ('section', 'expect'), + [(name.upper(), name) for name in blurb.SECTIONS], + ) + def test_exact_names_uppercase(self, section, expect): + self.check(section, expect) + + +@pytest.mark.parametrize('section', ['', ' ', ' ']) +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) + + +@pytest.mark.parametrize('section', [ + # invalid + '_', + '-', + 'invalid', + 'Not a section', + # non-special names + 'c?api', + 'cXapi', + 'C+API', + # super-strings + 'Library and more', + 'library3', + 'librari', +]) +def test_invalid_section_name(section): + error_message = re.escape(f'Invalid section name: {section!r}') + error_message = re.compile(rf'{error_message}\n\n.+', re.MULTILINE) + with pytest.raises(SystemExit, match=error_message): + blurb._extract_section_name(section) + + +@pytest.mark.parametrize(('section', 'matches'), [ + # 'matches' must be a sorted sequence of matching section names + ('c', ['C API', 'Core and Builtins']), + ('C', ['C API', 'Core and Builtins']), + ('buil', ['Build', 'Core and Builtins']), + ('BUIL', ['Build', 'Core and Builtins']), +]) +def test_ambiguous_section_name(section, matches): + matching_list = ', '.join(map(repr, matches)) + error_message = re.escape(f'More than one match for: {section!r}\n' + f'Matches: {matching_list}') + error_message = re.compile(rf'{error_message}', re.MULTILINE) + with pytest.raises(SystemExit, match=error_message): + blurb._extract_section_name(section) + + +def test_prepare_template_with_issue(): + """Test that prepare_template correctly fills in issue number.""" + with tempfile.NamedTemporaryFile(mode='w+', suffix='.rst', delete=False) as tmp: + blurb.prepare_template(tmp.name, "12345", None, None) + tmp.seek(0) + content = tmp.read() + + assert ".. gh-issue: 12345" in content + assert ".. gh-issue: \n" not in content + + +def test_prepare_template_with_section(): + """Test that prepare_template correctly uncomments section.""" + with tempfile.NamedTemporaryFile(mode='w+', suffix='.rst', delete=False) as tmp: + blurb.prepare_template(tmp.name, None, "Library", None) + tmp.seek(0) + content = tmp.read() + + assert ".. section: Library" in content + assert "#.. section: Library" not in content + # Other sections should still be commented + assert "#.. section: Tests" in content + + +def test_prepare_template_with_content(): + """Test that prepare_template correctly adds content.""" + with tempfile.NamedTemporaryFile(mode='w+', suffix='.rst', delete=False) as tmp: + test_content = "Fixed spam module to handle eggs." + blurb.prepare_template(tmp.name, None, None, test_content) + tmp.seek(0) + content = tmp.read() + + assert test_content in content + # The marker is followed by content, so check that the content appears after it + assert "#################\n\n" + test_content in content + + +class TestAddCommandAutomation: + """Test cases for the add command's automation features.""" + + def setup_method(self): + """Set up test environment before each test.""" + # Save original values + self.original_dir = os.getcwd() + self.original_root = blurb.root + + def teardown_method(self): + """Clean up after each test.""" + # Restore original values + os.chdir(self.original_dir) + blurb.root = self.original_root + + def test_add_help_parameter(self, capsys): + """Test that add command has proper help text.""" + # With cyclopts, help is handled by the framework, not a parameter + # We'll test the docstring is properly formatted instead + assert blurb.add.__doc__ is not None + assert "Add a new Misc/NEWS entry" in blurb.add.__doc__ + assert "issue" in blurb.add.__doc__ + assert "section" in blurb.add.__doc__ + assert "rst_on_stdin" in blurb.add.__doc__ + + @pytest.fixture + def mock_chdir(self): + """Mock chdir_to_repo_root for tests.""" + with mock.patch.object(blurb, 'chdir_to_repo_root') as m: + yield m + + def test_invalid_section_parameter(self, mock_chdir, capsys): + """Test that invalid section names are rejected.""" + with pytest.raises(SystemExit) as exc_info: + blurb.add(section="InvalidSection") + + # error() function exits with string message, not code + assert "Invalid section name: 'InvalidSection'" in str(exc_info.value) + + def test_invalid_gh_issue_number(self, mock_chdir, capsys): + """Test that invalid GitHub issue numbers are rejected.""" + # Test issue number below threshold + with pytest.raises(SystemExit) as exc_info: + blurb.add(issue="123") + assert "Invalid issue number: 123" in str(exc_info.value) + + # Test invalid formats + with pytest.raises(SystemExit) as exc_info: + blurb.add(issue="not-a-number") + assert "Invalid GitHub issue: not-a-number" in str(exc_info.value) + + def test_rst_on_stdin_requires_other_params(self, mock_chdir, capsys): + """Test that --rst-on-stdin requires --issue and --section.""" + with pytest.raises(SystemExit) as exc_info: + blurb.add(rst_on_stdin=True) + + # error() function exits with string message, not code + assert "--issue and --section required with --rst-on-stdin" in str(exc_info.value) + + def test_rst_on_stdin_missing_section(self, mock_chdir, capsys): + """Test that --rst-on-stdin fails without --section.""" + with pytest.raises(SystemExit) as exc_info: + blurb.add(rst_on_stdin=True, issue="123456") + + # error() function exits with string message, not code + assert "--issue and --section required with --rst-on-stdin" in str(exc_info.value) + + def test_rst_on_stdin_missing_issue(self, mock_chdir, capsys): + """Test that --rst-on-stdin fails without --issue.""" + with pytest.raises(SystemExit) as exc_info: + blurb.add(rst_on_stdin=True, section="Library") + + # error() function exits with string message, not code + assert "--issue and --section required with --rst-on-stdin" in str(exc_info.value) + + def test_add_with_all_automation_params(self, tmp_path): + """Test successful add with all automation parameters.""" + # Set up filesystem + (tmp_path / "Misc" / "NEWS.d" / "next" / "Library").mkdir(parents=True) + os.chdir(tmp_path) + blurb.root = str(tmp_path) + + with mock.patch.object(blurb, 'chdir_to_repo_root'): + with mock.patch.object(blurb, 'flush_git_add_files') as mock_flush_git: + with mock.patch.object(sys, 'stdin', new_callable=io.StringIO) as mock_stdin: + # Mock stdin content with a Monty Python reference + mock_stdin.write("Fixed spam module to properly handle eggs, bacon, and spam repetition counts.") + mock_stdin.seek(0) + + # Call add with automation parameters + with mock.patch.object(blurb, 'sortable_datetime', return_value='2024-01-01-12-00-00'): + with mock.patch.object(blurb, 'nonceify', return_value='abc123'): + blurb.add( + issue="123456", + section="Library", + rst_on_stdin=True + ) + + # Verify the file was created + expected_filename = "2024-01-01-12-00-00.gh-issue-123456.abc123.rst" + expected_path = tmp_path / "Misc" / "NEWS.d" / "next" / "Library" / expected_filename + assert expected_path.exists() + + # Verify file contents - the metadata is in the filename, not the file content + content = expected_path.read_text() + + # The file should only contain the body text (which may be wrapped) + assert "Fixed spam module to properly handle eggs, bacon, and spam repetition" in content + assert "counts." in content + + # Verify git add was called + assert str(expected_path) in blurb.git_add_files + mock_flush_git.assert_called_once() diff --git a/tests/test_cli_integration.py b/tests/test_cli_integration.py index f987992..d8ff02d 100644 --- a/tests/test_cli_integration.py +++ b/tests/test_cli_integration.py @@ -110,13 +110,13 @@ def test_help_content(self, mock_cpython_repo, blurb_executable): result = run_blurb(blurb_executable, ["add", "--help"], cwd=mock_cpython_repo) assert result.returncode == 0 output = result.stdout + result.stderr - required_content = ["Add a new Misc/NEWS entry", "--gh-issue", "--section", "--rst-on-stdin", "Library"] + required_content = ["Add a new Misc/NEWS entry", "--issue", "--section", "--rst-on-stdin"] assert all(content in output for content in required_content) @pytest.mark.parametrize("args,error_text", [ - (["--section", "InvalidSection"], "must be one of"), - (["--gh-issue", "-123"], "must be a positive integer"), - (["--rst-on-stdin"], "--gh-issue and --section required"), + (["--section", "InvalidSection"], "Invalid section name"), + (["--issue", "not-a-number"], "Invalid GitHub issue"), + (["--rst-on-stdin"], "--issue and --section required"), ]) def test_validation_errors(self, mock_cpython_repo, blurb_executable, args, error_text): result = run_blurb(blurb_executable, ["add"] + args, cwd=mock_cpython_repo) @@ -128,7 +128,7 @@ def test_stdin_automation(self, mock_cpython_repo, blurb_executable, section): blurb_text = f"Fixed a bug in the {section.lower()} that improves spam handling." result = run_blurb( blurb_executable, - ["add", "--gh-issue", "123456", "--section", section, "--rst-on-stdin"], + ["add", "--issue", "123456", "--section", section, "--rst-on-stdin"], cwd=mock_cpython_repo, input_text=blurb_text ) From 4ff5066d18ca73b6525a6794e89b851c7289a180 Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith" Date: Thu, 3 Jul 2025 21:30:45 -0700 Subject: [PATCH 6/8] Make default command accept same args as 'add' command Now 'blurb -i 123' works the same as 'blurb add -i 123', because nobody expects the Spanish Inquisition... or having to type 'add' every time. - Forward all add command parameters to the default command - Update help text to indicate 'add' is the default command - Parameters like --issue, --section, and --rst-on-stdin now work directly with 'blurb' without specifying 'add' --- src/blurb/blurb.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/blurb/blurb.py b/src/blurb/blurb.py index b49584f..d99911e 100644 --- a/src/blurb/blurb.py +++ b/src/blurb/blurb.py @@ -912,10 +912,11 @@ def add(*, issue: Annotated[Optional[str], Parameter(alias=["-i"])] = None, section: Annotated[Optional[str], Parameter(alias=["-s"])] = None, rst_on_stdin: bool = False): # This docstring template is formatted after the function definition. - """Add a new Misc/NEWS entry. + """Add a new Misc/NEWS entry (default command). Opens an editor to create a new entry for Misc/NEWS unless all - automation parameters are provided. + automation parameters are provided. This is the default command when + no subcommand is specified. Use -i/--issue to specify a GitHub issue number or link. Use -s/--section to specify the NEWS section (case insensitive with partial matching). @@ -1223,9 +1224,11 @@ def export(): @app.default -def default_command(): +def default_command(*, issue: Annotated[Optional[str], Parameter(alias=["-i"])] = None, + section: Annotated[Optional[str], Parameter(alias=["-s"])] = None, + rst_on_stdin: bool = False): """Default to 'add' command when no subcommand specified.""" - add() + add(issue=issue, section=section, rst_on_stdin=rst_on_stdin) def main(): From f49b7fbbe6334de31e5a93c51b5d1f3670b54849 Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith" Date: Fri, 4 Jul 2025 09:02:35 -0700 Subject: [PATCH 7/8] remove unneeded conftest.py --- tests/conftest.py | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index e45e7ed..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,11 +0,0 @@ -"""pytest configuration and fixtures.""" - -import pytest -from pyfakefs.fake_filesystem_unittest import Patcher - - -@pytest.fixture -def fs(): - """Pyfakefs fixture compatible with pytest.""" - with Patcher() as patcher: - yield patcher.fs From c4cc262a6496e7b816b77e46c028aaaccaf6b246 Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith" Date: Fri, 4 Jul 2025 09:36:00 -0700 Subject: [PATCH 8/8] =?UTF-8?q?Remove=20redundant=20test=20spam=20-=20we?= =?UTF-8?q?=20don't=20need=20it\!=20=F0=9F=A5=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminated 4 redundant test methods from TestAddCommandAutomation that duplicated validation logic already covered by existing parametrized tests. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- tests/test_blurb_add.py | 36 ------------------------------------ 1 file changed, 36 deletions(-) diff --git a/tests/test_blurb_add.py b/tests/test_blurb_add.py index b9713ab..3315939 100644 --- a/tests/test_blurb_add.py +++ b/tests/test_blurb_add.py @@ -289,26 +289,6 @@ def mock_chdir(self): with mock.patch.object(blurb, 'chdir_to_repo_root') as m: yield m - def test_invalid_section_parameter(self, mock_chdir, capsys): - """Test that invalid section names are rejected.""" - with pytest.raises(SystemExit) as exc_info: - blurb.add(section="InvalidSection") - - # error() function exits with string message, not code - assert "Invalid section name: 'InvalidSection'" in str(exc_info.value) - - def test_invalid_gh_issue_number(self, mock_chdir, capsys): - """Test that invalid GitHub issue numbers are rejected.""" - # Test issue number below threshold - with pytest.raises(SystemExit) as exc_info: - blurb.add(issue="123") - assert "Invalid issue number: 123" in str(exc_info.value) - - # Test invalid formats - with pytest.raises(SystemExit) as exc_info: - blurb.add(issue="not-a-number") - assert "Invalid GitHub issue: not-a-number" in str(exc_info.value) - def test_rst_on_stdin_requires_other_params(self, mock_chdir, capsys): """Test that --rst-on-stdin requires --issue and --section.""" with pytest.raises(SystemExit) as exc_info: @@ -317,22 +297,6 @@ def test_rst_on_stdin_requires_other_params(self, mock_chdir, capsys): # error() function exits with string message, not code assert "--issue and --section required with --rst-on-stdin" in str(exc_info.value) - def test_rst_on_stdin_missing_section(self, mock_chdir, capsys): - """Test that --rst-on-stdin fails without --section.""" - with pytest.raises(SystemExit) as exc_info: - blurb.add(rst_on_stdin=True, issue="123456") - - # error() function exits with string message, not code - assert "--issue and --section required with --rst-on-stdin" in str(exc_info.value) - - def test_rst_on_stdin_missing_issue(self, mock_chdir, capsys): - """Test that --rst-on-stdin fails without --issue.""" - with pytest.raises(SystemExit) as exc_info: - blurb.add(rst_on_stdin=True, section="Library") - - # error() function exits with string message, not code - assert "--issue and --section required with --rst-on-stdin" in str(exc_info.value) - def test_add_with_all_automation_params(self, tmp_path): """Test successful add with all automation parameters.""" # Set up filesystem