diff --git a/README.md b/README.md index ff23efc..bb4c214 100644 --- a/README.md +++ b/README.md @@ -62,14 +62,6 @@ managers. -### blurb help - -**blurb** is self-documenting through the `blurb help` subcommand. -Run without any further arguments, it prints a list of all subcommands, -with a one-line summary of the functionality of each. Run with a -third argument, it prints help on that subcommand (e.g. `blurb help release`). - - ### blurb add `blurb add` adds a new `Misc/NEWS` entry for you. diff --git a/RELEASING.md b/RELEASING.md index a30a573..e126bd2 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -25,5 +25,5 @@ - [ ] Check installation: ```bash - python -m pip uninstall -y blurb && python -m pip install -U blurb && blurb help + python -m pip uninstall -y blurb && python -m pip install -U blurb && blurb --help ``` diff --git a/src/blurb/_add.py b/src/blurb/_add.py index c70fd42..1911456 100644 --- a/src/blurb/_add.py +++ b/src/blurb/_add.py @@ -24,15 +24,15 @@ def add(*, issue: str | None = None, section: str | None = None): - """Add a blurb (a Misc/NEWS.d/next entry) to the current CPython repo. + """Add a blurb (a `Misc/NEWS.d/next` entry) to the current CPython repo. - Use -i/--issue to specify a GitHub issue number or link, e.g.: + Use `-i`/`--issue` to specify a GitHub issue number or link, e.g.: blurb add -i 12345 # or blurb add -i https://github.com/python/cpython/issues/12345 - Use -s/--section to specify the section name (case-insensitive), e.g.: + Use `-s`/`--section` to specify the section name (case-insensitive), e.g.: blurb add -s Library # or diff --git a/src/blurb/_cli.py b/src/blurb/_cli.py index 27ac9b3..333b7fc 100644 --- a/src/blurb/_cli.py +++ b/src/blurb/_cli.py @@ -1,6 +1,6 @@ from __future__ import annotations -import inspect +import argparse import os import re import sys @@ -9,40 +9,11 @@ TYPE_CHECKING = False if TYPE_CHECKING: - from collections.abc import Callable - from typing import NoReturn, TypeAlias + from typing import NoReturn - CommandFunc: TypeAlias = Callable[..., None] - - -subcommands: dict[str, CommandFunc] = {} readme_re = re.compile(r'This is \w+ version \d+\.\d+').match -def initialise_subcommands() -> None: - global subcommands - - from blurb._add import add - from blurb._export import export - from blurb._merge import merge - from blurb._populate import populate - from blurb._release import release - - subcommands = { - 'version': version, - 'help': help, - 'add': add, - 'export': export, - 'merge': merge, - 'populate': populate, - 'release': release, - # Make 'blurb --help/--version/-V' work. - '--help': help, - '--version': version, - '-V': version, - } - - def error(msg: str, /) -> NoReturn: raise SystemExit(f'Error: {msg}') @@ -59,225 +30,102 @@ def require_ok(prompt: str, /) -> str: return s -def get_subcommand(subcommand: str, /) -> CommandFunc: - fn = subcommands.get(subcommand) - if not fn: - error(f"Unknown subcommand: {subcommand}\nRun 'blurb help' for help.") - return fn - - -def version() -> None: - """Print blurb version.""" - print('blurb version', blurb.__version__) - - -def help(subcommand: str | None = None) -> 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. - """ - - if not subcommand: - _blurb_help() - raise SystemExit(0) - - fn = get_subcommand(subcommand) - doc = fn.__doc__.strip() - if not doc: - error(f'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] - if isinstance(p.default, bool): - options.append(f' [-{short_option}|--{name}]') - else: - if p.default is None: - metavar = f'{name.upper()}' - else: - metavar = f'{name.upper()}[={p.default}]' - options.append(f' [-{short_option}|--{name} {metavar}]') - 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) - raise SystemExit(0) - - -def _blurb_help() -> None: - """Print default help for blurb.""" - - print('blurb version', blurb.__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(f'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'.") +def build_parser() -> argparse.ArgumentParser: + from blurb._add import add + from blurb._export import export + from blurb._merge import merge + from blurb._populate import populate + from blurb._release import release + parser = argparse.ArgumentParser( + prog='blurb', + description='Management tool for CPython `Misc/NEWS` and `Misc/NEWS.d` entries.', + epilog='If blurb is run without any arguments, this is equivalent to `blurb add`.', + ) + parser.add_argument( + '-V', + '--version', + action='version', + version=f'blurb version {blurb.__version__}', + ) + + subparsers = parser.add_subparsers( + dest='subcommand', metavar='subcommand', required=True + ) + + def add_subcommand(name: str, doc: str) -> argparse.ArgumentParser: + doc = doc.strip() + return subparsers.add_parser( + name, + description=doc, + help=doc.split('\n')[0], + formatter_class=argparse.RawDescriptionHelpFormatter, + ) -def main() -> None: - args = sys.argv[1:] + parser_add = add_subcommand('add', add.__doc__) + parser_add.add_argument( + '-i', '--issue', metavar='ISSUE', help='GitHub issue number or link' + ) + parser_add.add_argument( + '-s', '--section', metavar='SECTION', help='section name (case-insensitive)' + ) + parser_add.set_defaults(func=lambda ns: add(issue=ns.issue, section=ns.section)) + + parser_export = add_subcommand('export', export.__doc__) + parser_export.set_defaults(func=lambda ns: export()) + + parser_merge = add_subcommand('merge', merge.__doc__) + parser_merge.add_argument( + 'output', nargs='?', help='where to write the NEWS file (default: Misc/NEWS)' + ) + parser_merge.add_argument( + '-f', + '--forced', + action='store_true', + help='overwrite an existing file without prompting', + ) + parser_merge.set_defaults(func=lambda ns: merge(ns.output, forced=ns.forced)) + + parser_populate = add_subcommand('populate', populate.__doc__) + parser_populate.set_defaults(func=lambda ns: populate()) + + parser_release = add_subcommand('release', release.__doc__) + parser_release.add_argument( + 'version', help="version number, or '.' to use the repo directory name" + ) + parser_release.set_defaults(func=lambda ns: release(ns.version)) + + return parser + + +def main(argv: list[str] | None = None) -> None: + args = sys.argv[1:] if argv is None else argv if not args: args = ['add'] - elif args[0] == '-h': - # slight hack - args[0] = 'help' - - subcommand = args[0] - args = args[1:] - - initialise_subcommands() - fn = get_subcommand(subcommand) + # Keep the legacy 'help' and 'version' subcommands working as aliases. + elif args[0] == 'help': + print( + "Warning: 'blurb help' is deprecated, use 'blurb --help' instead", + file=sys.stderr, + ) + args = [*args[1:2], '--help'] + elif args[0] == 'version': + print( + "Warning: 'blurb version' is deprecated, use 'blurb --version' instead", + file=sys.stderr, + ) + args = ['--version'] - # hack - if fn in (help, version): - raise SystemExit(fn(*args)) + parser = build_parser() + ns = parser.parse_args(args) import blurb._merge blurb._merge.original_dir = os.getcwd() - try: - 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: - if p.default is not None and not isinstance(p.default, (bool, str)): - raise SystemExit( - 'blurb command-line processing cannot handle ' - f'options of type {type(p.default).__qualname__}' - ) - - kwargs[name] = p.default - short_options[name[0]] = name - long_options[name] = name - - filtered_args = [] - done_with_options = False - consume_after = None - - def handle_option(s, dict): - nonlocal consume_after - name = dict.get(s, None) - if not name: - raise SystemExit(f'blurb: Unknown option for {subcommand}: "{s}"') - - value = kwargs[name] - if isinstance(value, bool): - kwargs[name] = not value - else: - consume_after = name - - for a in args: - if consume_after: - kwargs[consume_after] = a - consume_after = None - continue - 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) - - if consume_after: - raise SystemExit( - f'Error: blurb: {subcommand} {consume_after} ' - 'must be followed by an option argument' - ) - - raise SystemExit(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' + chdir_to_repo_root() - print( - f'Error: Wrong number of arguments!\n\nblurb {subcommand} {middle},\nand you specified {how_many}.' - ) - print() - print('usage: ', end='') - help(subcommand) + ns.func(ns) def chdir_to_repo_root() -> str: @@ -285,7 +133,7 @@ def chdir_to_repo_root() -> str: # note that we can't ask git, because we might # be in an exported directory tree! - # we intentionally start in a (probably nonexistant) subtree + # we intentionally start in a (probably nonexistent) subtree # the first thing the while loop does is .., basically path = os.path.abspath('garglemox') while True: diff --git a/src/blurb/_merge.py b/src/blurb/_merge.py index b18f483..1d7f308 100644 --- a/src/blurb/_merge.py +++ b/src/blurb/_merge.py @@ -14,13 +14,13 @@ def merge(output: str | None = None, *, forced: bool = False) -> None: - """Merge all blurbs together into a single Misc/NEWS file. + """Merge all blurbs together into a single `Misc/NEWS` file. Optional output argument specifies where to write to. - Default is /Misc/NEWS. + Default is `/Misc/NEWS`. If overwriting, blurb merge will prompt you to make sure it's okay. - To force it to overwrite, use -f. + To force it to overwrite, use `-f`. """ if output: output = os.path.join(original_dir, output) diff --git a/src/blurb/_populate.py b/src/blurb/_populate.py index 10fde1e..edc4f35 100644 --- a/src/blurb/_populate.py +++ b/src/blurb/_populate.py @@ -7,7 +7,7 @@ def populate() -> None: - """Creates and populates the Misc/NEWS.d directory tree.""" + """Creates and populates the `Misc/NEWS.d` directory tree.""" os.chdir('Misc') os.makedirs('NEWS.d/next', exist_ok=True) diff --git a/tests/test_cli.py b/tests/test_cli.py index d70b615..fa5a16a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,10 +1,175 @@ -import blurb._cli +import pytest +from blurb._cli import build_parser, main -def test_version(capfd): - # Act - blurb._cli.version() - # Assert - captured = capfd.readouterr() - assert captured.out.startswith('blurb version ') +class TestParser: + """Pin the invocation forms used by external automation. + + Known consumers: python/release-tools, python/blurb_it, + CPython's Doc/Makefile and Tools/patchcheck/patchcheck.py. + """ + + def test_merge_bare(self): + args = build_parser().parse_args(['merge']) + assert args.output is None + assert args.forced is False + + def test_merge_forced_with_output(self): + # Doc/Makefile: blurb merge -f build/NEWS + args = build_parser().parse_args(['merge', '-f', 'build/NEWS']) + assert args.output == 'build/NEWS' + assert args.forced is True + + def test_merge_forced_long_option(self): + args = build_parser().parse_args(['merge', '--forced']) + assert args.forced is True + + def test_release_version(self): + # release-tools: blurb release + args = build_parser().parse_args(['release', '3.14.0']) + assert args.version == '3.14.0' + + def test_release_dot(self): + args = build_parser().parse_args(['release', '.']) + assert args.version == '.' + + def test_release_requires_version(self): + with pytest.raises(SystemExit) as excinfo: + build_parser().parse_args(['release']) + assert excinfo.value.code != 0 + + def test_add_bare(self): + args = build_parser().parse_args(['add']) + assert args.issue is None + assert args.section is None + + def test_add_short_options(self): + args = build_parser().parse_args(['add', '-i', '12345', '-s', 'Library']) + assert args.issue == '12345' + assert args.section == 'Library' + + def test_add_long_options(self): + args = build_parser().parse_args([ + 'add', + '--issue', + '12345', + '--section', + 'Library', + ]) + assert args.issue == '12345' + assert args.section == 'Library' + + def test_add_long_options_with_equals(self): + args = build_parser().parse_args(['add', '--issue=12345', '--section=C API']) + assert args.issue == '12345' + assert args.section == 'C API' + + def test_export(self): + args = build_parser().parse_args(['export']) + assert args.subcommand == 'export' + + def test_populate(self): + args = build_parser().parse_args(['populate']) + assert args.subcommand == 'populate' + + def test_unknown_subcommand(self): + with pytest.raises(SystemExit) as excinfo: + build_parser().parse_args(['garglemox']) + assert excinfo.value.code != 0 + + def test_unknown_option(self): + with pytest.raises(SystemExit) as excinfo: + build_parser().parse_args(['merge', '--garglemox']) + assert excinfo.value.code != 0 + + +class TestMain: + @pytest.mark.parametrize('argv', [['--version'], ['-V']]) + def test_version_option(self, argv, capfd): + with pytest.raises(SystemExit) as excinfo: + main(argv) + assert excinfo.value.code in (None, 0) + captured = capfd.readouterr() + assert captured.out.startswith('blurb version ') + + @pytest.mark.parametrize('argv', [['--help'], ['-h']]) + def test_help_lists_subcommands(self, argv, capfd): + with pytest.raises(SystemExit) as excinfo: + main(argv) + assert excinfo.value.code in (None, 0) + captured = capfd.readouterr() + for name in ('add', 'export', 'merge', 'populate', 'release'): + assert name in captured.out + + def test_help_for_subcommand(self, capfd): + with pytest.raises(SystemExit) as excinfo: + main(['merge', '--help']) + assert excinfo.value.code in (None, 0) + captured = capfd.readouterr() + assert 'blurb merge' in captured.out + assert '--forced' in captured.out + + def test_legacy_help_subcommand(self, capfd): + # python-docs-* translation Makefiles probe with 'blurb help' + with pytest.raises(SystemExit) as excinfo: + main(['help']) + assert excinfo.value.code in (None, 0) + captured = capfd.readouterr() + for name in ('add', 'export', 'merge', 'populate', 'release'): + assert name in captured.out + + def test_legacy_help_for_subcommand(self, capfd): + with pytest.raises(SystemExit) as excinfo: + main(['help', 'merge']) + assert excinfo.value.code in (None, 0) + captured = capfd.readouterr() + assert 'blurb merge' in captured.out + assert '--forced' in captured.out + + def test_legacy_help_unknown_subcommand(self): + with pytest.raises(SystemExit) as excinfo: + main(['help', 'garglemox']) + assert excinfo.value.code != 0 + + def test_legacy_version_subcommand(self, capfd): + with pytest.raises(SystemExit) as excinfo: + main(['version']) + assert excinfo.value.code in (None, 0) + captured = capfd.readouterr() + assert captured.out.startswith('blurb version ') + + def test_no_arguments_runs_add(self, monkeypatch): + # 'blurb' with no arguments is equivalent to 'blurb add' + calls = [] + + def fake_add(*, issue, section): + """Add a blurb.""" + calls.append((issue, section)) + + monkeypatch.setattr('blurb._add.add', fake_add) + monkeypatch.setattr('blurb._cli.chdir_to_repo_root', lambda: '/') + main([]) + assert calls == [(None, None)] + + def test_subcommand_gets_repo_root(self, monkeypatch): + chdir_calls = [] + + def fake_release(version): + """Move blurbs to a release file.""" + + monkeypatch.setattr('blurb._release.release', fake_release) + monkeypatch.setattr( + 'blurb._cli.chdir_to_repo_root', lambda: chdir_calls.append(True) + ) + main(['release', '3.14.0']) + assert chdir_calls == [True] + + def test_version_does_not_need_repo(self, monkeypatch): + def explode(): + raise AssertionError('--version must not require a CPython repo') + + monkeypatch.setattr('blurb._cli.chdir_to_repo_root', explode) + with pytest.raises(SystemExit) as excinfo: + main(['--version']) + assert excinfo.value.code in (None, 0) diff --git a/tox.ini b/tox.ini index 17f70d6..4868997 100644 --- a/tox.ini +++ b/tox.ini @@ -17,7 +17,7 @@ commands = --cov-report term \ --cov-report xml \ {posargs} - blurb help + blurb --help blurb --version - {envpython} -I -m blurb help - {envpython} -I -m blurb version + {envpython} -I -m blurb --help + {envpython} -I -m blurb --version