Skip to content

Commit 54e351f

Browse files
miss-islingtonserhiy-storchakaclaude
authored
[3.13] gh-155207: Add --dry-run and --diff options to Argument Clinic (GH-155208) (GH-155215)
--dry-run lists the files which would be changed, and --diff writes a unified diff of the changes to the standard output. No file and no directory is created or modified in these modes. (cherry picked from commit 3874ad1) Co-authored-by: Serhiy Storchaka <storchaka@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e869134 commit 54e351f

6 files changed

Lines changed: 261 additions & 18 deletions

File tree

Lib/test/test_clinic.py

Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
from test import support, test_tools
77
from test.support import os_helper
88
from test.support.os_helper import TESTFN, unlink, rmtree
9+
from test.support.testcase import ExtraAssertions
910
from textwrap import dedent
1011
from unittest import TestCase
12+
import difflib
1113
import inspect
1214
import os.path
1315
import re
@@ -2608,7 +2610,7 @@ def test_disallow_defining_class_at_module_level(self):
26082610
self.expect_failure(block, err, lineno=2)
26092611

26102612

2611-
class ClinicExternalTest(TestCase):
2613+
class ClinicExternalTest(TestCase, ExtraAssertions):
26122614
maxDiff = None
26132615

26142616
def setUp(self):
@@ -2711,6 +2713,148 @@ def test_cli_force(self):
27112713
self.assertTrue(generated.endswith(checksum),
27122714
(generated, checksum))
27132715

2716+
DRY_RUN_CODE = dedent("""
2717+
/*[clinic input]
2718+
func
2719+
a: int
2720+
/
2721+
2722+
Docstring.
2723+
[clinic start generated code]*/
2724+
""")
2725+
2726+
def make_dry_run_file(self, tmp_dir):
2727+
fn = os.path.join(tmp_dir, "test.c")
2728+
with open(fn, "w", encoding="utf-8") as f:
2729+
f.write(self.DRY_RUN_CODE)
2730+
return fn
2731+
2732+
@staticmethod
2733+
def dest_file(fn):
2734+
# The default destination for the generated code. Its path is
2735+
# built from the "{dirname}/clinic/{basename}.h" template, so it
2736+
# always uses forward slashes, even on Windows.
2737+
dirname, basename = os.path.split(fn)
2738+
return f"{dirname}/clinic/{basename}.h"
2739+
2740+
def check_unchanged(self, tmp_dir, fn, pre_mtime):
2741+
# Neither the source file nor the destination file
2742+
# nor its directory is created or modified.
2743+
with open(fn, encoding="utf-8") as f:
2744+
self.assertEqual(f.read(), self.DRY_RUN_CODE)
2745+
self.assertEqual(os.stat(fn).st_mtime_ns, pre_mtime)
2746+
self.assertEqual(os.listdir(tmp_dir), ["test.c"])
2747+
2748+
def test_cli_dry_run(self):
2749+
with os_helper.temp_dir() as tmp_dir:
2750+
fn = self.make_dry_run_file(tmp_dir)
2751+
pre_mtime = os.stat(fn).st_mtime_ns
2752+
out = self.expect_success("--dry-run", fn)
2753+
self.assertEqual(out.splitlines(), [
2754+
f"would create {self.dest_file(fn)}",
2755+
f"would update {fn}",
2756+
])
2757+
self.check_unchanged(tmp_dir, fn, pre_mtime)
2758+
2759+
def test_cli_dry_run_no_change(self):
2760+
with os_helper.temp_dir() as tmp_dir:
2761+
fn = self.make_dry_run_file(tmp_dir)
2762+
self.expect_success(fn)
2763+
self.assertEqual(self.expect_success("--dry-run", fn), "")
2764+
self.assertEqual(self.expect_success("--diff", fn), "")
2765+
2766+
def test_cli_dry_run_no_clinic_block(self):
2767+
with os_helper.temp_dir() as tmp_dir:
2768+
fn = os.path.join(tmp_dir, "test.c")
2769+
with open(fn, "w", encoding="utf-8") as f:
2770+
f.write("int x;\n")
2771+
self.assertEqual(self.expect_success("--dry-run", fn), "")
2772+
2773+
def test_cli_dry_run_output(self):
2774+
with os_helper.temp_dir() as tmp_dir:
2775+
fn = self.make_dry_run_file(tmp_dir)
2776+
out_fn = os.path.join(tmp_dir, "output.c")
2777+
out = self.expect_success("--dry-run", "-o", out_fn, fn)
2778+
self.assertIn(f"would create {out_fn}", out)
2779+
self.assertNotIn(f"would update {fn}", out)
2780+
self.assertFalse(os.path.exists(out_fn))
2781+
2782+
def test_cli_dry_run_make(self):
2783+
with os_helper.temp_dir() as tmp_dir:
2784+
fn = self.make_dry_run_file(tmp_dir)
2785+
pre_mtime = os.stat(fn).st_mtime_ns
2786+
out = self.expect_success("--dry-run", "--make", "--srcdir", tmp_dir)
2787+
self.assertIn(f"would update {fn}", out)
2788+
self.check_unchanged(tmp_dir, fn, pre_mtime)
2789+
2790+
def test_cli_dry_run_verbose(self):
2791+
with os_helper.temp_dir() as tmp_dir:
2792+
fn = self.make_dry_run_file(tmp_dir)
2793+
out, err, code = self.run_clinic("-v", "--dry-run", fn)
2794+
self.assertEqual(code, 0)
2795+
# The progress goes to stderr, so that the standard output
2796+
# contains only the report.
2797+
self.assertEqual(err.splitlines(), [fn])
2798+
self.assertEqual(out.splitlines(), [
2799+
f"would create {self.dest_file(fn)}",
2800+
f"would update {fn}",
2801+
])
2802+
2803+
def test_cli_dry_run_checksum_mismatch(self):
2804+
invalid_input = dedent("""
2805+
/*[clinic input]
2806+
output preset block
2807+
module test
2808+
test.fn
2809+
a: int
2810+
[clinic start generated code]*/
2811+
/*[clinic end generated code: output=bogus input=bogus]*/
2812+
""")
2813+
with os_helper.temp_dir() as tmp_dir:
2814+
fn = os.path.join(tmp_dir, "test.c")
2815+
with open(fn, "w", encoding="utf-8") as f:
2816+
f.write(invalid_input)
2817+
pre_mtime = os.stat(fn).st_mtime_ns
2818+
# The dry run does not disable the checksum verification.
2819+
_, err = self.expect_failure("--dry-run", fn)
2820+
self.assertIn("Checksum mismatch!", err)
2821+
# With -f the change is reported, but still not written.
2822+
out = self.expect_success("--dry-run", "-f", fn)
2823+
self.assertIn(f"would update {fn}", out)
2824+
with open(fn, encoding="utf-8") as f:
2825+
self.assertEqual(f.read(), invalid_input)
2826+
self.assertEqual(os.stat(fn).st_mtime_ns, pre_mtime)
2827+
2828+
def test_cli_diff(self):
2829+
with os_helper.temp_dir() as tmp_dir:
2830+
fn = self.make_dry_run_file(tmp_dir)
2831+
pre_mtime = os.stat(fn).st_mtime_ns
2832+
out = self.expect_success("--diff", fn)
2833+
self.check_unchanged(tmp_dir, fn, pre_mtime)
2834+
2835+
# A new file is created by the patch.
2836+
dest_fn = self.dest_file(fn)
2837+
self.assertStartsWith(out, f"--- /dev/null\n+++ {dest_fn}\n@@ -0,0 +1,")
2838+
self.assertIn(f"--- {fn}\n+++ {fn}\n", out)
2839+
self.assertIn("+/*[clinic end generated code:", out)
2840+
2841+
# The patch is what clinic would have written.
2842+
self.expect_success(fn)
2843+
with open(fn, encoding="utf-8") as f:
2844+
new_contents = f.read()
2845+
expected = "".join(difflib.unified_diff(
2846+
self.DRY_RUN_CODE.splitlines(keepends=True),
2847+
new_contents.splitlines(keepends=True),
2848+
fromfile=fn, tofile=fn))
2849+
self.assertEndsWith(out, expected)
2850+
2851+
def test_cli_fail_converters_and_dry_run(self):
2852+
for opt in "--dry-run", "--diff":
2853+
with self.subTest(opt=opt):
2854+
_, err = self.expect_failure("--converters", opt)
2855+
msg = "can't use --dry-run or --diff with --converters"
2856+
self.assertIn(msg, err)
2857+
27142858
def test_cli_make(self):
27152859
c_code = dedent("""
27162860
/*[clinic input]
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Argument Clinic now supports the ``--dry-run`` and ``--diff`` options.
2+
They list the files which would be changed, or write a unified diff of the
3+
changes to the standard output, without modifying any file.

Tools/clinic/libclinic/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,16 @@
2424
is_legal_py_identifier,
2525
)
2626
from .utils import (
27+
FileChange,
28+
FileWriter,
2729
FormatCounterFormatter,
2830
NULL,
2931
Null,
3032
Sentinels,
3133
VersionTuple,
3234
compute_checksum,
3335
create_regex,
36+
read_file,
3437
unknown,
3538
unspecified,
3639
write_file,
@@ -62,13 +65,16 @@
6265
"is_legal_py_identifier",
6366

6467
# Utility functions
68+
"FileChange",
69+
"FileWriter",
6570
"FormatCounterFormatter",
6671
"NULL",
6772
"Null",
6873
"Sentinels",
6974
"VersionTuple",
7075
"compute_checksum",
7176
"create_regex",
77+
"read_file",
7278
"unknown",
7379
"unspecified",
7480
"write_file",

Tools/clinic/libclinic/app.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ def __init__(
8787
filename: str,
8888
limited_capi: bool,
8989
verify: bool = True,
90+
writer: libclinic.FileWriter | None = None,
9091
) -> None:
9192
# maps strings to Parser objects.
9293
# (instantiated from the "parsers" global.)
@@ -95,6 +96,7 @@ def __init__(
9596
if printer:
9697
fail("Custom printers are broken right now")
9798
self.printer = printer or BlockPrinter(language)
99+
self.writer = writer or libclinic.FileWriter()
98100
self.verify = verify
99101
self.limited_capi = limited_capi
100102
self.filename = filename
@@ -213,7 +215,7 @@ def parse(self, input: str) -> str:
213215
try:
214216
dirname = os.path.dirname(destination.filename)
215217
try:
216-
os.makedirs(dirname)
218+
self.writer.makedirs(dirname)
217219
except FileExistsError:
218220
if not os.path.isdir(dirname):
219221
fail(f"Can't write to destination "
@@ -234,8 +236,8 @@ def parse(self, input: str) -> str:
234236

235237
printer_2 = BlockPrinter(self.language)
236238
printer_2.print_block(block, header_includes=includes)
237-
libclinic.write_file(destination.filename,
238-
printer_2.f.getvalue())
239+
self.writer.write(destination.filename,
240+
printer_2.f.getvalue())
239241
continue
240242

241243
return printer.f.getvalue()

Tools/clinic/libclinic/cli.py

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import argparse
4+
import difflib
45
import inspect
56
import os
67
import re
@@ -52,9 +53,12 @@ def parse_file(
5253
limited_capi: bool,
5354
output: str | None = None,
5455
verify: bool = True,
56+
writer: libclinic.FileWriter | None = None,
5557
) -> None:
5658
if not output:
5759
output = filename
60+
if writer is None:
61+
writer = libclinic.FileWriter()
5862

5963
extension = os.path.splitext(filename)[1][1:]
6064
if not extension:
@@ -80,10 +84,11 @@ def parse_file(
8084
clinic = Clinic(language,
8185
verify=verify,
8286
filename=filename,
83-
limited_capi=limited_capi)
87+
limited_capi=limited_capi,
88+
writer=writer)
8489
cooked = clinic.parse(raw)
8590

86-
libclinic.write_file(output, cooked)
91+
writer.write(output, cooked)
8792

8893

8994
def create_cli() -> argparse.ArgumentParser:
@@ -102,6 +107,12 @@ def create_cli() -> argparse.ArgumentParser:
102107
help="redirect file output to OUTPUT")
103108
cmdline.add_argument("-v", "--verbose", action='store_true',
104109
help="enable verbose mode")
110+
cmdline.add_argument("--dry-run", action='store_true',
111+
help=("don't write any file, only list the files "
112+
"which would be changed"))
113+
cmdline.add_argument("--diff", action='store_true',
114+
help=("don't write any file, write a unified diff "
115+
"of the changes to the standard output"))
105116
cmdline.add_argument("--converters", action='store_true',
106117
help=("print a list of all supported converters "
107118
"and return converters"))
@@ -119,12 +130,43 @@ def create_cli() -> argparse.ArgumentParser:
119130
return cmdline
120131

121132

133+
def print_diff(change: libclinic.FileChange) -> None:
134+
if change.old_contents is None:
135+
fromfile = "/dev/null"
136+
old_lines: list[str] = []
137+
else:
138+
fromfile = change.filename
139+
old_lines = change.old_contents.splitlines(keepends=True)
140+
sys.stdout.writelines(difflib.unified_diff(
141+
old_lines,
142+
change.new_contents.splitlines(keepends=True),
143+
fromfile=fromfile,
144+
tofile=change.filename,
145+
))
146+
147+
148+
def report_changes(writer: libclinic.FileWriter, *, diff: bool) -> None:
149+
for change in sorted(writer.changes, key=lambda change: change.filename):
150+
if diff:
151+
print_diff(change)
152+
else:
153+
action = "create" if change.old_contents is None else "update"
154+
print(f"would {action} {change.filename}")
155+
156+
122157
def run_clinic(parser: argparse.ArgumentParser, ns: argparse.Namespace) -> None:
158+
dry_run = ns.dry_run or ns.diff
159+
# The report is written to the standard output, so the progress
160+
# is written to the standard error stream to not mix them.
161+
verbose_file = sys.stderr if dry_run else sys.stdout
162+
123163
if ns.converters:
124164
if ns.filename:
125165
parser.error(
126166
"can't specify --converters and a filename at the same time"
127167
)
168+
if dry_run:
169+
parser.error("can't use --dry-run or --diff with --converters")
128170
AnyConverterType = ConverterType | ReturnConverterType
129171
converter_list: list[tuple[str, AnyConverterType]] = []
130172
return_converter_list: list[tuple[str, AnyConverterType]] = []
@@ -188,6 +230,7 @@ def run_clinic(parser: argparse.ArgumentParser, ns: argparse.Namespace) -> None:
188230
excludes = [os.path.normpath(f) for f in excludes]
189231
else:
190232
excludes = []
233+
writer = libclinic.FileWriter(dry_run=dry_run)
191234
for root, dirs, files in os.walk(ns.srcdir):
192235
for rcs_dir in ('.svn', '.git', '.hg', 'build', 'externals'):
193236
if rcs_dir in dirs:
@@ -201,9 +244,11 @@ def run_clinic(parser: argparse.ArgumentParser, ns: argparse.Namespace) -> None:
201244
if path in excludes:
202245
continue
203246
if ns.verbose:
204-
print(path)
247+
print(path, file=verbose_file)
205248
parse_file(path,
206-
verify=not ns.force, limited_capi=ns.limited_capi)
249+
verify=not ns.force, limited_capi=ns.limited_capi,
250+
writer=writer)
251+
report_changes(writer, diff=ns.diff)
207252
return
208253

209254
if not ns.filename:
@@ -212,11 +257,14 @@ def run_clinic(parser: argparse.ArgumentParser, ns: argparse.Namespace) -> None:
212257
if ns.output and len(ns.filename) > 1:
213258
parser.error("can't use -o with multiple filenames")
214259

260+
writer = libclinic.FileWriter(dry_run=dry_run)
215261
for filename in ns.filename:
216262
if ns.verbose:
217-
print(filename)
263+
print(filename, file=verbose_file)
218264
parse_file(filename, output=ns.output,
219-
verify=not ns.force, limited_capi=ns.limited_capi)
265+
verify=not ns.force, limited_capi=ns.limited_capi,
266+
writer=writer)
267+
report_changes(writer, diff=ns.diff)
220268

221269

222270
def main(argv: list[str] | None = None) -> NoReturn:

0 commit comments

Comments
 (0)