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/.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/CHANGELOG.md b/CHANGELOG.md index 9b810a9..7f8044c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 2.1.0 (unreleased) + +* Add automation support to `blurb add` command: + * 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. + ## 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..db6afc0 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,30 @@ 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 \ + --issue 123456 \ + --section Library \ + --rst-on-stdin +``` + +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/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/__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/src/blurb/blurb.py b/src/blurb/blurb.py old mode 100755 new mode 100644 index b3998b5..d99911e --- 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,13 +53,19 @@ 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'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 = """ @@ -96,15 +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()) +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 = { @@ -319,8 +319,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") @@ -460,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', @@ -481,13 +479,13 @@ 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: 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 +566,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}" @@ -686,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(): @@ -817,42 +747,36 @@ 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. - """ - editor = find_editor() - 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) +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 (or fill in issue number) + issue_line = ".. gh-issue:" + 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 content substitution + 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) - init_tmp_with_template() - # 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 get_editor_args(editor, tmp_path): + """Prepare editor command arguments.""" if shutil.which(editor): args = [editor] else: @@ -860,52 +784,223 @@ 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 + +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(*, 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 (default command). + + Opens an editor to create a new entry for Misc/NEWS unless all + 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). + + Parameters + ---------- + 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. Can use partial matching (e.g. 'lib' for 'Library'). One of {sections_csv}. + rst_on_stdin : bool + Read restructured text entry from stdin (requires issue and section). + """ + + # 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 + 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, issue_number, section_name, 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.") +add.__doc__ = add.__doc__.format( + sections_csv=", ".join(repr(s) for s in SECTIONS) +) -@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. +@app.command(name="release") +def release(version: str): + """Move all new blurbs to a single blurb file for the 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) @@ -955,17 +1050,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: @@ -1093,21 +1194,15 @@ 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) - 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) @@ -1119,124 +1214,36 @@ 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(*, 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(issue=issue, section=section, rst_on_stdin=rst_on_stdin) 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() - - # 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_blurb_add.py b/tests/test_blurb_add.py new file mode 100644 index 0000000..3315939 --- /dev/null +++ b/tests/test_blurb_add.py @@ -0,0 +1,337 @@ +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_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_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 new file mode 100644 index 0000000..d8ff02d --- /dev/null +++ b/tests/test_cli_integration.py @@ -0,0 +1,278 @@ +"""Integration tests for the blurb CLI tool.""" + +import subprocess +import sys +import pytest +import shutil + + +@pytest.fixture +def mock_cpython_repo(tmp_path): + """Create a minimal mock CPython repository structure.""" + # Core directories + (tmp_path / "Include").mkdir() + (tmp_path / "Python").mkdir() + (tmp_path / "Misc" / "NEWS.d" / "next").mkdir(parents=True) + + # 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() + + # 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() + + # 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 + + +@pytest.fixture +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 + + +@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.""" + 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) + + +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 + 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 + 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 + # With cyclopts, invalid commands show "Unused Tokens" error + output = result.stdout + result.stderr + assert "Unused Tokens" in output + + 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 "not inside a CPython repo" in result.stderr + + +class TestAddCommand: + """Test the add command functionality.""" + + 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", "--issue", "--section", "--rst-on-stdin"] + assert all(content in output for content in required_content) + + @pytest.mark.parametrize("args,error_text", [ + (["--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) + assert result.returncode != 0 + assert error_text in result.stderr + + @pytest.mark.parametrize("section", ["Library", "Tests", "Documentation"]) + 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", "--issue", "123456", "--section", section, "--rst-on-stdin"], + cwd=mock_cpython_repo, + input_text=blurb_text + ) + assert result.returncode == 0 + assert "Ready for commit" in result.stdout + + 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() + + 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 + + +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 "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