Skip to content

Commit bfb9db3

Browse files
miss-islingtonserhiy-storchakaclaude
authored
[3.14] gh-155207: Add --dry-run and --diff options to Argument Clinic (GH-155208) (GH-155214)
--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 51f6e01 commit bfb9db3

6 files changed

Lines changed: 259 additions & 17 deletions

File tree

Lib/test/test_clinic.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from test.support.os_helper import TESTFN, unlink, rmtree
99
from textwrap import dedent
1010
from unittest import TestCase
11+
import difflib
1112
import inspect
1213
import os.path
1314
import re
@@ -2893,6 +2894,148 @@ def test_cli_force(self):
28932894
generated = f.read()
28942895
self.assertEndsWith(generated, checksum)
28952896

2897+
DRY_RUN_CODE = dedent("""
2898+
/*[clinic input]
2899+
func
2900+
a: int
2901+
/
2902+
2903+
Docstring.
2904+
[clinic start generated code]*/
2905+
""")
2906+
2907+
def make_dry_run_file(self, tmp_dir):
2908+
fn = os.path.join(tmp_dir, "test.c")
2909+
with open(fn, "w", encoding="utf-8") as f:
2910+
f.write(self.DRY_RUN_CODE)
2911+
return fn
2912+
2913+
@staticmethod
2914+
def dest_file(fn):
2915+
# The default destination for the generated code. Its path is
2916+
# built from the "{dirname}/clinic/{basename}.h" template, so it
2917+
# always uses forward slashes, even on Windows.
2918+
dirname, basename = os.path.split(fn)
2919+
return f"{dirname}/clinic/{basename}.h"
2920+
2921+
def check_unchanged(self, tmp_dir, fn, pre_mtime):
2922+
# Neither the source file nor the destination file
2923+
# nor its directory is created or modified.
2924+
with open(fn, encoding="utf-8") as f:
2925+
self.assertEqual(f.read(), self.DRY_RUN_CODE)
2926+
self.assertEqual(os.stat(fn).st_mtime_ns, pre_mtime)
2927+
self.assertEqual(os.listdir(tmp_dir), ["test.c"])
2928+
2929+
def test_cli_dry_run(self):
2930+
with os_helper.temp_dir() as tmp_dir:
2931+
fn = self.make_dry_run_file(tmp_dir)
2932+
pre_mtime = os.stat(fn).st_mtime_ns
2933+
out = self.expect_success("--dry-run", fn)
2934+
self.assertEqual(out.splitlines(), [
2935+
f"would create {self.dest_file(fn)}",
2936+
f"would update {fn}",
2937+
])
2938+
self.check_unchanged(tmp_dir, fn, pre_mtime)
2939+
2940+
def test_cli_dry_run_no_change(self):
2941+
with os_helper.temp_dir() as tmp_dir:
2942+
fn = self.make_dry_run_file(tmp_dir)
2943+
self.expect_success(fn)
2944+
self.assertEqual(self.expect_success("--dry-run", fn), "")
2945+
self.assertEqual(self.expect_success("--diff", fn), "")
2946+
2947+
def test_cli_dry_run_no_clinic_block(self):
2948+
with os_helper.temp_dir() as tmp_dir:
2949+
fn = os.path.join(tmp_dir, "test.c")
2950+
with open(fn, "w", encoding="utf-8") as f:
2951+
f.write("int x;\n")
2952+
self.assertEqual(self.expect_success("--dry-run", fn), "")
2953+
2954+
def test_cli_dry_run_output(self):
2955+
with os_helper.temp_dir() as tmp_dir:
2956+
fn = self.make_dry_run_file(tmp_dir)
2957+
out_fn = os.path.join(tmp_dir, "output.c")
2958+
out = self.expect_success("--dry-run", "-o", out_fn, fn)
2959+
self.assertIn(f"would create {out_fn}", out)
2960+
self.assertNotIn(f"would update {fn}", out)
2961+
self.assertFalse(os.path.exists(out_fn))
2962+
2963+
def test_cli_dry_run_make(self):
2964+
with os_helper.temp_dir() as tmp_dir:
2965+
fn = self.make_dry_run_file(tmp_dir)
2966+
pre_mtime = os.stat(fn).st_mtime_ns
2967+
out = self.expect_success("--dry-run", "--make", "--srcdir", tmp_dir)
2968+
self.assertIn(f"would update {fn}", out)
2969+
self.check_unchanged(tmp_dir, fn, pre_mtime)
2970+
2971+
def test_cli_dry_run_verbose(self):
2972+
with os_helper.temp_dir() as tmp_dir:
2973+
fn = self.make_dry_run_file(tmp_dir)
2974+
out, err, code = self.run_clinic("-v", "--dry-run", fn)
2975+
self.assertEqual(code, 0)
2976+
# The progress goes to stderr, so that the standard output
2977+
# contains only the report.
2978+
self.assertEqual(err.splitlines(), [fn])
2979+
self.assertEqual(out.splitlines(), [
2980+
f"would create {self.dest_file(fn)}",
2981+
f"would update {fn}",
2982+
])
2983+
2984+
def test_cli_dry_run_checksum_mismatch(self):
2985+
invalid_input = dedent("""
2986+
/*[clinic input]
2987+
output preset block
2988+
module test
2989+
test.fn
2990+
a: int
2991+
[clinic start generated code]*/
2992+
/*[clinic end generated code: output=bogus input=bogus]*/
2993+
""")
2994+
with os_helper.temp_dir() as tmp_dir:
2995+
fn = os.path.join(tmp_dir, "test.c")
2996+
with open(fn, "w", encoding="utf-8") as f:
2997+
f.write(invalid_input)
2998+
pre_mtime = os.stat(fn).st_mtime_ns
2999+
# The dry run does not disable the checksum verification.
3000+
_, err = self.expect_failure("--dry-run", fn)
3001+
self.assertIn("Checksum mismatch!", err)
3002+
# With -f the change is reported, but still not written.
3003+
out = self.expect_success("--dry-run", "-f", fn)
3004+
self.assertIn(f"would update {fn}", out)
3005+
with open(fn, encoding="utf-8") as f:
3006+
self.assertEqual(f.read(), invalid_input)
3007+
self.assertEqual(os.stat(fn).st_mtime_ns, pre_mtime)
3008+
3009+
def test_cli_diff(self):
3010+
with os_helper.temp_dir() as tmp_dir:
3011+
fn = self.make_dry_run_file(tmp_dir)
3012+
pre_mtime = os.stat(fn).st_mtime_ns
3013+
out = self.expect_success("--diff", fn)
3014+
self.check_unchanged(tmp_dir, fn, pre_mtime)
3015+
3016+
# A new file is created by the patch.
3017+
dest_fn = self.dest_file(fn)
3018+
self.assertStartsWith(out, f"--- /dev/null\n+++ {dest_fn}\n@@ -0,0 +1,")
3019+
self.assertIn(f"--- {fn}\n+++ {fn}\n", out)
3020+
self.assertIn("+/*[clinic end generated code:", out)
3021+
3022+
# The patch is what clinic would have written.
3023+
self.expect_success(fn)
3024+
with open(fn, encoding="utf-8") as f:
3025+
new_contents = f.read()
3026+
expected = "".join(difflib.unified_diff(
3027+
self.DRY_RUN_CODE.splitlines(keepends=True),
3028+
new_contents.splitlines(keepends=True),
3029+
fromfile=fn, tofile=fn))
3030+
self.assertEndsWith(out, expected)
3031+
3032+
def test_cli_fail_converters_and_dry_run(self):
3033+
for opt in "--dry-run", "--diff":
3034+
with self.subTest(opt=opt):
3035+
_, err = self.expect_failure("--converters", opt)
3036+
msg = "can't use --dry-run or --diff with --converters"
3037+
self.assertIn(msg, err)
3038+
28963039
def test_cli_make(self):
28973040
c_code = dedent("""
28983041
/*[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
@@ -26,13 +26,16 @@
2626
is_legal_py_identifier,
2727
)
2828
from .utils import (
29+
FileChange,
30+
FileWriter,
2931
FormatCounterFormatter,
3032
NULL,
3133
NullType,
3234
Sentinels,
3335
VersionTuple,
3436
compute_checksum,
3537
create_regex,
38+
read_file,
3639
unknown,
3740
unspecified,
3841
write_file,
@@ -66,13 +69,16 @@
6669
"is_legal_py_identifier",
6770

6871
# Utility functions
72+
"FileChange",
73+
"FileWriter",
6974
"FormatCounterFormatter",
7075
"NULL",
7176
"NullType",
7277
"Sentinels",
7378
"VersionTuple",
7479
"compute_checksum",
7580
"create_regex",
81+
"read_file",
7682
"unknown",
7783
"unspecified",
7884
"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)