Skip to content

Commit a16fe3f

Browse files
pythongh-107467: Restructure Argument Clinic command-line interface
- Add and use CLIError exception for CLI usage errors - On CLI error, print to stderr instead of stdout - Put the entire CLI in main() - Rework ClinicExternalTest to call main() instead of using subprocesses
1 parent a24e25d commit a16fe3f

File tree

2 files changed

+58
-61
lines changed

2 files changed

+58
-61
lines changed

Lib/test/test_clinic.py

+29-37
Original file line numberDiff line numberDiff line change
@@ -1391,30 +1391,24 @@ def test_scaffolding(self):
13911391

13921392
class ClinicExternalTest(TestCase):
13931393
maxDiff = None
1394-
clinic_py = os.path.join(test_tools.toolsdir, "clinic", "clinic.py")
1395-
1396-
def _do_test(self, *args, expect_success=True):
1397-
with subprocess.Popen(
1398-
[sys.executable, "-Xutf8", self.clinic_py, *args],
1399-
encoding="utf-8",
1400-
bufsize=0,
1401-
stdout=subprocess.PIPE,
1402-
stderr=subprocess.PIPE,
1403-
) as proc:
1404-
proc.wait()
1405-
if expect_success and proc.returncode:
1406-
self.fail("".join([*proc.stdout, *proc.stderr]))
1407-
stdout = proc.stdout.read()
1408-
stderr = proc.stderr.read()
1409-
# Clinic never writes to stderr.
1410-
self.assertEqual(stderr, "")
1411-
return stdout
14121394

14131395
def expect_success(self, *args):
1414-
return self._do_test(*args)
1396+
with support.captured_stdout() as out, support.captured_stderr() as err:
1397+
try:
1398+
clinic.main(args)
1399+
except SystemExit as exc:
1400+
if exc.code != 0:
1401+
self.fail(f"unexpected failure: {args=}")
1402+
self.assertEqual(err.getvalue(), "")
1403+
return out.getvalue()
14151404

14161405
def expect_failure(self, *args):
1417-
return self._do_test(*args, expect_success=False)
1406+
with support.captured_stdout() as out, support.captured_stderr() as err:
1407+
with self.assertRaises(SystemExit) as cm:
1408+
clinic.main(args)
1409+
if cm.exception.code == 0:
1410+
self.fail(f"unexpected success: {args=}")
1411+
return out.getvalue(), err.getvalue()
14181412

14191413
def test_external(self):
14201414
CLINIC_TEST = 'clinic.test.c'
@@ -1478,8 +1472,9 @@ def test_cli_force(self):
14781472
# First, run the CLI without -f and expect failure.
14791473
# Note, we cannot check the entire fail msg, because the path to
14801474
# the tmp file will change for every run.
1481-
out = self.expect_failure(fn)
1482-
self.assertTrue(out.endswith(fail_msg))
1475+
out, _ = self.expect_failure(fn)
1476+
self.assertTrue(out.endswith(fail_msg),
1477+
f"{out!r} does not end with {fail_msg!r}")
14831478
# Then, force regeneration; success expected.
14841479
out = self.expect_success("-f", fn)
14851480
self.assertEqual(out, "")
@@ -1621,33 +1616,30 @@ def test_cli_converters(self):
16211616
)
16221617

16231618
def test_cli_fail_converters_and_filename(self):
1624-
out = self.expect_failure("--converters", "test.c")
1625-
msg = (
1626-
"Usage error: can't specify --converters "
1627-
"and a filename at the same time"
1628-
)
1629-
self.assertIn(msg, out)
1619+
_, err = self.expect_failure("--converters", "test.c")
1620+
msg = "can't specify --converters and a filename at the same time"
1621+
self.assertIn(msg, err)
16301622

16311623
def test_cli_fail_no_filename(self):
1632-
out = self.expect_failure()
1633-
self.assertIn("usage: clinic.py", out)
1624+
_, err = self.expect_failure()
1625+
self.assertIn("no input files", err)
16341626

16351627
def test_cli_fail_output_and_multiple_files(self):
1636-
out = self.expect_failure("-o", "out.c", "input.c", "moreinput.c")
1628+
_, err = self.expect_failure("-o", "out.c", "input.c", "moreinput.c")
16371629
msg = "Usage error: can't use -o with multiple filenames"
1638-
self.assertIn(msg, out)
1630+
self.assertIn(msg, err)
16391631

16401632
def test_cli_fail_filename_or_output_and_make(self):
1633+
msg = "can't use -o or filenames with --make"
16411634
for opts in ("-o", "out.c"), ("filename.c",):
16421635
with self.subTest(opts=opts):
1643-
out = self.expect_failure("--make", *opts)
1644-
msg = "Usage error: can't use -o or filenames with --make"
1645-
self.assertIn(msg, out)
1636+
_, err = self.expect_failure("--make", *opts)
1637+
self.assertIn(msg, err)
16461638

16471639
def test_cli_fail_make_without_srcdir(self):
1648-
out = self.expect_failure("--make", "--srcdir", "")
1640+
_, err = self.expect_failure("--make", "--srcdir", "")
16491641
msg = "Usage error: --srcdir must not be empty with --make"
1650-
self.assertIn(msg, out)
1642+
self.assertIn(msg, err)
16511643

16521644

16531645
try:

Tools/clinic/clinic.py

+29-24
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from __future__ import annotations
88

99
import abc
10+
import argparse
1011
import ast
1112
import builtins as bltns
1213
import collections
@@ -5620,10 +5621,13 @@ def state_terminal(self, line: str | None) -> None:
56205621
clinic = None
56215622

56225623

5623-
def main(argv: list[str]) -> None:
5624-
import sys
5625-
import argparse
5624+
class CLIError(Exception):
5625+
pass
5626+
5627+
5628+
def create_cli() -> argparse.ArgumentParser:
56265629
cmdline = argparse.ArgumentParser(
5630+
prog="clinic.py",
56275631
description="""Preprocessor for CPython C files.
56285632
56295633
The purpose of the Argument Clinic is automating all the boilerplate involved
@@ -5646,14 +5650,15 @@ def main(argv: list[str]) -> None:
56465650
help="the directory tree to walk in --make mode")
56475651
cmdline.add_argument("filename", metavar="FILE", type=str, nargs="*",
56485652
help="the list of files to process")
5649-
ns = cmdline.parse_args(argv)
5653+
return cmdline
5654+
56505655

5656+
def run_clinic(ns: argparse.Namespace) -> None:
56515657
if ns.converters:
56525658
if ns.filename:
5653-
print("Usage error: can't specify --converters and a filename at the same time.")
5654-
print()
5655-
cmdline.print_usage()
5656-
sys.exit(-1)
5659+
raise CLIError(
5660+
"can't specify --converters and a filename at the same time"
5661+
)
56575662
converters: list[tuple[str, str]] = []
56585663
return_converters: list[tuple[str, str]] = []
56595664
ignored = set("""
@@ -5711,15 +5716,9 @@ def main(argv: list[str]) -> None:
57115716

57125717
if ns.make:
57135718
if ns.output or ns.filename:
5714-
print("Usage error: can't use -o or filenames with --make.")
5715-
print()
5716-
cmdline.print_usage()
5717-
sys.exit(-1)
5719+
raise CLIError("can't use -o or filenames with --make")
57185720
if not ns.srcdir:
5719-
print("Usage error: --srcdir must not be empty with --make.")
5720-
print()
5721-
cmdline.print_usage()
5722-
sys.exit(-1)
5721+
raise CLIError("--srcdir must not be empty with --make")
57235722
for root, dirs, files in os.walk(ns.srcdir):
57245723
for rcs_dir in ('.svn', '.git', '.hg', 'build', 'externals'):
57255724
if rcs_dir in dirs:
@@ -5735,21 +5734,27 @@ def main(argv: list[str]) -> None:
57355734
return
57365735

57375736
if not ns.filename:
5738-
cmdline.print_usage()
5739-
sys.exit(-1)
5737+
raise CLIError("no input files")
57405738

57415739
if ns.output and len(ns.filename) > 1:
5742-
print("Usage error: can't use -o with multiple filenames.")
5743-
print()
5744-
cmdline.print_usage()
5745-
sys.exit(-1)
5740+
raise CLIError("can't use -o with multiple filenames")
57465741

57475742
for filename in ns.filename:
57485743
if ns.verbose:
57495744
print(filename)
57505745
parse_file(filename, output=ns.output, verify=not ns.force)
57515746

57525747

5748+
def main(argv: list[str] | None = None) -> None:
5749+
cli = create_cli()
5750+
try:
5751+
args = cli.parse_args(argv)
5752+
run_clinic(args)
5753+
except CLIError as exc:
5754+
if msg := str(exc):
5755+
sys.stderr.write(f"Usage error: {msg}\n")
5756+
cli.print_usage()
5757+
sys.exit(1)
5758+
57535759
if __name__ == "__main__":
5754-
main(sys.argv[1:])
5755-
sys.exit(0)
5760+
main()

0 commit comments

Comments
 (0)