gh-107467: Restructure Argument Clinic command-line interface (#107469)

- Use ArgumentParser.error() to handle CLI errors
- Put the entire CLI in main()
- Rework ClinicExternalTest to call main() instead of using subprocesses

Co-authored-by: AlexWaygood <alex.waygood@gmail.com>
This commit is contained in:
Erlend E. Aasland 2023-08-01 18:24:23 +02:00 committed by GitHub
parent 557b05c7a5
commit 49f238e78c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 56 additions and 65 deletions

View File

@ -4,14 +4,12 @@
from test import support, test_tools from test import support, test_tools
from test.support import os_helper from test.support import os_helper
from test.support import SHORT_TIMEOUT, requires_subprocess
from test.support.os_helper import TESTFN, unlink from test.support.os_helper import TESTFN, unlink
from textwrap import dedent from textwrap import dedent
from unittest import TestCase from unittest import TestCase
import collections import collections
import inspect import inspect
import os.path import os.path
import subprocess
import sys import sys
import unittest import unittest
@ -1411,30 +1409,26 @@ Couldn't find existing function 'fooooooooooooooooooooooo'!
class ClinicExternalTest(TestCase): class ClinicExternalTest(TestCase):
maxDiff = None maxDiff = None
clinic_py = os.path.join(test_tools.toolsdir, "clinic", "clinic.py")
def _do_test(self, *args, expect_success=True): def run_clinic(self, *args):
with subprocess.Popen( with (
[sys.executable, "-Xutf8", self.clinic_py, *args], support.captured_stdout() as out,
encoding="utf-8", support.captured_stderr() as err,
bufsize=0, self.assertRaises(SystemExit) as cm
stdout=subprocess.PIPE, ):
stderr=subprocess.PIPE, clinic.main(args)
) as proc: return out.getvalue(), err.getvalue(), cm.exception.code
proc.wait()
if expect_success and proc.returncode:
self.fail("".join([*proc.stdout, *proc.stderr]))
stdout = proc.stdout.read()
stderr = proc.stderr.read()
# Clinic never writes to stderr.
self.assertEqual(stderr, "")
return stdout
def expect_success(self, *args): def expect_success(self, *args):
return self._do_test(*args) out, err, code = self.run_clinic(*args)
self.assertEqual(code, 0, f"Unexpected failure: {args=}")
self.assertEqual(err, "")
return out
def expect_failure(self, *args): def expect_failure(self, *args):
return self._do_test(*args, expect_success=False) out, err, code = self.run_clinic(*args)
self.assertNotEqual(code, 0, f"Unexpected success: {args=}")
return out, err
def test_external(self): def test_external(self):
CLINIC_TEST = 'clinic.test.c' CLINIC_TEST = 'clinic.test.c'
@ -1498,8 +1492,9 @@ class ClinicExternalTest(TestCase):
# First, run the CLI without -f and expect failure. # First, run the CLI without -f and expect failure.
# Note, we cannot check the entire fail msg, because the path to # Note, we cannot check the entire fail msg, because the path to
# the tmp file will change for every run. # the tmp file will change for every run.
out = self.expect_failure(fn) out, _ = self.expect_failure(fn)
self.assertTrue(out.endswith(fail_msg)) self.assertTrue(out.endswith(fail_msg),
f"{out!r} does not end with {fail_msg!r}")
# Then, force regeneration; success expected. # Then, force regeneration; success expected.
out = self.expect_success("-f", fn) out = self.expect_success("-f", fn)
self.assertEqual(out, "") self.assertEqual(out, "")
@ -1641,33 +1636,30 @@ class ClinicExternalTest(TestCase):
) )
def test_cli_fail_converters_and_filename(self): def test_cli_fail_converters_and_filename(self):
out = self.expect_failure("--converters", "test.c") _, err = self.expect_failure("--converters", "test.c")
msg = ( msg = "can't specify --converters and a filename at the same time"
"Usage error: can't specify --converters " self.assertIn(msg, err)
"and a filename at the same time"
)
self.assertIn(msg, out)
def test_cli_fail_no_filename(self): def test_cli_fail_no_filename(self):
out = self.expect_failure() _, err = self.expect_failure()
self.assertIn("usage: clinic.py", out) self.assertIn("no input files", err)
def test_cli_fail_output_and_multiple_files(self): def test_cli_fail_output_and_multiple_files(self):
out = self.expect_failure("-o", "out.c", "input.c", "moreinput.c") _, err = self.expect_failure("-o", "out.c", "input.c", "moreinput.c")
msg = "Usage error: can't use -o with multiple filenames" msg = "error: can't use -o with multiple filenames"
self.assertIn(msg, out) self.assertIn(msg, err)
def test_cli_fail_filename_or_output_and_make(self): def test_cli_fail_filename_or_output_and_make(self):
msg = "can't use -o or filenames with --make"
for opts in ("-o", "out.c"), ("filename.c",): for opts in ("-o", "out.c"), ("filename.c",):
with self.subTest(opts=opts): with self.subTest(opts=opts):
out = self.expect_failure("--make", *opts) _, err = self.expect_failure("--make", *opts)
msg = "Usage error: can't use -o or filenames with --make" self.assertIn(msg, err)
self.assertIn(msg, out)
def test_cli_fail_make_without_srcdir(self): def test_cli_fail_make_without_srcdir(self):
out = self.expect_failure("--make", "--srcdir", "") _, err = self.expect_failure("--make", "--srcdir", "")
msg = "Usage error: --srcdir must not be empty with --make" msg = "error: --srcdir must not be empty with --make"
self.assertIn(msg, out) self.assertIn(msg, err)
try: try:

View File

@ -0,0 +1,2 @@
The Argument Clinic command-line tool now prints to stderr instead of stdout
on failure.

View File

@ -7,6 +7,7 @@
from __future__ import annotations from __future__ import annotations
import abc import abc
import argparse
import ast import ast
import builtins as bltns import builtins as bltns
import collections import collections
@ -5620,10 +5621,9 @@ parsers: dict[str, Callable[[Clinic], Parser]] = {
clinic = None clinic = None
def main(argv: list[str]) -> None: def create_cli() -> argparse.ArgumentParser:
import sys
import argparse
cmdline = argparse.ArgumentParser( cmdline = argparse.ArgumentParser(
prog="clinic.py",
description="""Preprocessor for CPython C files. description="""Preprocessor for CPython C files.
The purpose of the Argument Clinic is automating all the boilerplate involved The purpose of the Argument Clinic is automating all the boilerplate involved
@ -5646,14 +5646,15 @@ For more information see https://docs.python.org/3/howto/clinic.html""")
help="the directory tree to walk in --make mode") help="the directory tree to walk in --make mode")
cmdline.add_argument("filename", metavar="FILE", type=str, nargs="*", cmdline.add_argument("filename", metavar="FILE", type=str, nargs="*",
help="the list of files to process") help="the list of files to process")
ns = cmdline.parse_args(argv) return cmdline
def run_clinic(parser: argparse.ArgumentParser, ns: argparse.Namespace) -> None:
if ns.converters: if ns.converters:
if ns.filename: if ns.filename:
print("Usage error: can't specify --converters and a filename at the same time.") parser.error(
print() "can't specify --converters and a filename at the same time"
cmdline.print_usage() )
sys.exit(-1)
converters: list[tuple[str, str]] = [] converters: list[tuple[str, str]] = []
return_converters: list[tuple[str, str]] = [] return_converters: list[tuple[str, str]] = []
ignored = set(""" ignored = set("""
@ -5707,19 +5708,13 @@ For more information see https://docs.python.org/3/howto/clinic.html""")
print() print()
print("All converters also accept (c_default=None, py_default=None, annotation=None).") print("All converters also accept (c_default=None, py_default=None, annotation=None).")
print("All return converters also accept (py_default=None).") print("All return converters also accept (py_default=None).")
sys.exit(0) return
if ns.make: if ns.make:
if ns.output or ns.filename: if ns.output or ns.filename:
print("Usage error: can't use -o or filenames with --make.") parser.error("can't use -o or filenames with --make")
print()
cmdline.print_usage()
sys.exit(-1)
if not ns.srcdir: if not ns.srcdir:
print("Usage error: --srcdir must not be empty with --make.") parser.error("--srcdir must not be empty with --make")
print()
cmdline.print_usage()
sys.exit(-1)
for root, dirs, files in os.walk(ns.srcdir): for root, dirs, files in os.walk(ns.srcdir):
for rcs_dir in ('.svn', '.git', '.hg', 'build', 'externals'): for rcs_dir in ('.svn', '.git', '.hg', 'build', 'externals'):
if rcs_dir in dirs: if rcs_dir in dirs:
@ -5735,14 +5730,10 @@ For more information see https://docs.python.org/3/howto/clinic.html""")
return return
if not ns.filename: if not ns.filename:
cmdline.print_usage() parser.error("no input files")
sys.exit(-1)
if ns.output and len(ns.filename) > 1: if ns.output and len(ns.filename) > 1:
print("Usage error: can't use -o with multiple filenames.") parser.error("can't use -o with multiple filenames")
print()
cmdline.print_usage()
sys.exit(-1)
for filename in ns.filename: for filename in ns.filename:
if ns.verbose: if ns.verbose:
@ -5750,6 +5741,12 @@ For more information see https://docs.python.org/3/howto/clinic.html""")
parse_file(filename, output=ns.output, verify=not ns.force) parse_file(filename, output=ns.output, verify=not ns.force)
if __name__ == "__main__": def main(argv: list[str] | None = None) -> NoReturn:
main(sys.argv[1:]) parser = create_cli()
args = parser.parse_args(argv)
run_clinic(parser, args)
sys.exit(0) sys.exit(0)
if __name__ == "__main__":
main()