Skip to content

Commit 49f238e

Browse files
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 <[email protected]>
1 parent 557b05c commit 49f238e

File tree

3 files changed

+57
-66
lines changed

3 files changed

+57
-66
lines changed

Lib/test/test_clinic.py

+33-41
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,12 @@
44

55
from test import support, test_tools
66
from test.support import os_helper
7-
from test.support import SHORT_TIMEOUT, requires_subprocess
87
from test.support.os_helper import TESTFN, unlink
98
from textwrap import dedent
109
from unittest import TestCase
1110
import collections
1211
import inspect
1312
import os.path
14-
import subprocess
1513
import sys
1614
import unittest
1715

@@ -1411,30 +1409,26 @@ def test_scaffolding(self):
14111409

14121410
class ClinicExternalTest(TestCase):
14131411
maxDiff = None
1414-
clinic_py = os.path.join(test_tools.toolsdir, "clinic", "clinic.py")
1415-
1416-
def _do_test(self, *args, expect_success=True):
1417-
with subprocess.Popen(
1418-
[sys.executable, "-Xutf8", self.clinic_py, *args],
1419-
encoding="utf-8",
1420-
bufsize=0,
1421-
stdout=subprocess.PIPE,
1422-
stderr=subprocess.PIPE,
1423-
) as proc:
1424-
proc.wait()
1425-
if expect_success and proc.returncode:
1426-
self.fail("".join([*proc.stdout, *proc.stderr]))
1427-
stdout = proc.stdout.read()
1428-
stderr = proc.stderr.read()
1429-
# Clinic never writes to stderr.
1430-
self.assertEqual(stderr, "")
1431-
return stdout
1412+
1413+
def run_clinic(self, *args):
1414+
with (
1415+
support.captured_stdout() as out,
1416+
support.captured_stderr() as err,
1417+
self.assertRaises(SystemExit) as cm
1418+
):
1419+
clinic.main(args)
1420+
return out.getvalue(), err.getvalue(), cm.exception.code
14321421

14331422
def expect_success(self, *args):
1434-
return self._do_test(*args)
1423+
out, err, code = self.run_clinic(*args)
1424+
self.assertEqual(code, 0, f"Unexpected failure: {args=}")
1425+
self.assertEqual(err, "")
1426+
return out
14351427

14361428
def expect_failure(self, *args):
1437-
return self._do_test(*args, expect_success=False)
1429+
out, err, code = self.run_clinic(*args)
1430+
self.assertNotEqual(code, 0, f"Unexpected success: {args=}")
1431+
return out, err
14381432

14391433
def test_external(self):
14401434
CLINIC_TEST = 'clinic.test.c'
@@ -1498,8 +1492,9 @@ def test_cli_force(self):
14981492
# First, run the CLI without -f and expect failure.
14991493
# Note, we cannot check the entire fail msg, because the path to
15001494
# the tmp file will change for every run.
1501-
out = self.expect_failure(fn)
1502-
self.assertTrue(out.endswith(fail_msg))
1495+
out, _ = self.expect_failure(fn)
1496+
self.assertTrue(out.endswith(fail_msg),
1497+
f"{out!r} does not end with {fail_msg!r}")
15031498
# Then, force regeneration; success expected.
15041499
out = self.expect_success("-f", fn)
15051500
self.assertEqual(out, "")
@@ -1641,33 +1636,30 @@ def test_cli_converters(self):
16411636
)
16421637

16431638
def test_cli_fail_converters_and_filename(self):
1644-
out = self.expect_failure("--converters", "test.c")
1645-
msg = (
1646-
"Usage error: can't specify --converters "
1647-
"and a filename at the same time"
1648-
)
1649-
self.assertIn(msg, out)
1639+
_, err = self.expect_failure("--converters", "test.c")
1640+
msg = "can't specify --converters and a filename at the same time"
1641+
self.assertIn(msg, err)
16501642

16511643
def test_cli_fail_no_filename(self):
1652-
out = self.expect_failure()
1653-
self.assertIn("usage: clinic.py", out)
1644+
_, err = self.expect_failure()
1645+
self.assertIn("no input files", err)
16541646

16551647
def test_cli_fail_output_and_multiple_files(self):
1656-
out = self.expect_failure("-o", "out.c", "input.c", "moreinput.c")
1657-
msg = "Usage error: can't use -o with multiple filenames"
1658-
self.assertIn(msg, out)
1648+
_, err = self.expect_failure("-o", "out.c", "input.c", "moreinput.c")
1649+
msg = "error: can't use -o with multiple filenames"
1650+
self.assertIn(msg, err)
16591651

16601652
def test_cli_fail_filename_or_output_and_make(self):
1653+
msg = "can't use -o or filenames with --make"
16611654
for opts in ("-o", "out.c"), ("filename.c",):
16621655
with self.subTest(opts=opts):
1663-
out = self.expect_failure("--make", *opts)
1664-
msg = "Usage error: can't use -o or filenames with --make"
1665-
self.assertIn(msg, out)
1656+
_, err = self.expect_failure("--make", *opts)
1657+
self.assertIn(msg, err)
16661658

16671659
def test_cli_fail_make_without_srcdir(self):
1668-
out = self.expect_failure("--make", "--srcdir", "")
1669-
msg = "Usage error: --srcdir must not be empty with --make"
1670-
self.assertIn(msg, out)
1660+
_, err = self.expect_failure("--make", "--srcdir", "")
1661+
msg = "error: --srcdir must not be empty with --make"
1662+
self.assertIn(msg, err)
16711663

16721664

16731665
try:
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
The Argument Clinic command-line tool now prints to stderr instead of stdout
2+
on failure.

Tools/clinic/clinic.py

+22-25
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,9 @@ 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+
def create_cli() -> argparse.ArgumentParser:
56265625
cmdline = argparse.ArgumentParser(
5626+
prog="clinic.py",
56275627
description="""Preprocessor for CPython C files.
56285628
56295629
The purpose of the Argument Clinic is automating all the boilerplate involved
@@ -5646,14 +5646,15 @@ def main(argv: list[str]) -> None:
56465646
help="the directory tree to walk in --make mode")
56475647
cmdline.add_argument("filename", metavar="FILE", type=str, nargs="*",
56485648
help="the list of files to process")
5649-
ns = cmdline.parse_args(argv)
5649+
return cmdline
56505650

5651+
5652+
def run_clinic(parser: argparse.ArgumentParser, ns: argparse.Namespace) -> None:
56515653
if ns.converters:
56525654
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)
5655+
parser.error(
5656+
"can't specify --converters and a filename at the same time"
5657+
)
56575658
converters: list[tuple[str, str]] = []
56585659
return_converters: list[tuple[str, str]] = []
56595660
ignored = set("""
@@ -5707,19 +5708,13 @@ def main(argv: list[str]) -> None:
57075708
print()
57085709
print("All converters also accept (c_default=None, py_default=None, annotation=None).")
57095710
print("All return converters also accept (py_default=None).")
5710-
sys.exit(0)
5711+
return
57115712

57125713
if ns.make:
57135714
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)
5715+
parser.error("can't use -o or filenames with --make")
57185716
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)
5717+
parser.error("--srcdir must not be empty with --make")
57235718
for root, dirs, files in os.walk(ns.srcdir):
57245719
for rcs_dir in ('.svn', '.git', '.hg', 'build', 'externals'):
57255720
if rcs_dir in dirs:
@@ -5735,21 +5730,23 @@ def main(argv: list[str]) -> None:
57355730
return
57365731

57375732
if not ns.filename:
5738-
cmdline.print_usage()
5739-
sys.exit(-1)
5733+
parser.error("no input files")
57405734

57415735
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)
5736+
parser.error("can't use -o with multiple filenames")
57465737

57475738
for filename in ns.filename:
57485739
if ns.verbose:
57495740
print(filename)
57505741
parse_file(filename, output=ns.output, verify=not ns.force)
57515742

57525743

5753-
if __name__ == "__main__":
5754-
main(sys.argv[1:])
5744+
def main(argv: list[str] | None = None) -> NoReturn:
5745+
parser = create_cli()
5746+
args = parser.parse_args(argv)
5747+
run_clinic(parser, args)
57555748
sys.exit(0)
5749+
5750+
5751+
if __name__ == "__main__":
5752+
main()

0 commit comments

Comments
 (0)