Skip to content

Commit b7219ec

Browse files
Merge remote-tracking branch 'upstream/main' into clinic-sibling-groups
# Conflicts: # Tools/clinic/libclinic/clanguage.py
2 parents 0ba2932 + 7aec160 commit b7219ec

8 files changed

Lines changed: 263 additions & 20 deletions

File tree

Lib/test/test_clinic.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from test.support.os_helper import TESTFN, unlink, rmtree
1010
from textwrap import dedent
1111
from unittest import TestCase
12+
import difflib
1213
import inspect
1314
import os.path
1415
import re
@@ -3113,6 +3114,148 @@ def test_cli_force(self):
31133114
generated = f.read()
31143115
self.assertEndsWith(generated, checksum)
31153116

3117+
DRY_RUN_CODE = dedent("""
3118+
/*[clinic input]
3119+
func
3120+
a: int
3121+
/
3122+
3123+
Docstring.
3124+
[clinic start generated code]*/
3125+
""")
3126+
3127+
def make_dry_run_file(self, tmp_dir):
3128+
fn = os.path.join(tmp_dir, "test.c")
3129+
with open(fn, "w", encoding="utf-8") as f:
3130+
f.write(self.DRY_RUN_CODE)
3131+
return fn
3132+
3133+
@staticmethod
3134+
def dest_file(fn):
3135+
# The default destination for the generated code. Its path is
3136+
# built from the "{dirname}/clinic/{basename}.h" template, so it
3137+
# always uses forward slashes, even on Windows.
3138+
dirname, basename = os.path.split(fn)
3139+
return f"{dirname}/clinic/{basename}.h"
3140+
3141+
def check_unchanged(self, tmp_dir, fn, pre_mtime):
3142+
# Neither the source file nor the destination file
3143+
# nor its directory is created or modified.
3144+
with open(fn, encoding="utf-8") as f:
3145+
self.assertEqual(f.read(), self.DRY_RUN_CODE)
3146+
self.assertEqual(os.stat(fn).st_mtime_ns, pre_mtime)
3147+
self.assertEqual(os.listdir(tmp_dir), ["test.c"])
3148+
3149+
def test_cli_dry_run(self):
3150+
with os_helper.temp_dir() as tmp_dir:
3151+
fn = self.make_dry_run_file(tmp_dir)
3152+
pre_mtime = os.stat(fn).st_mtime_ns
3153+
out = self.expect_success("--dry-run", fn)
3154+
self.assertEqual(out.splitlines(), [
3155+
f"would create {self.dest_file(fn)}",
3156+
f"would update {fn}",
3157+
])
3158+
self.check_unchanged(tmp_dir, fn, pre_mtime)
3159+
3160+
def test_cli_dry_run_no_change(self):
3161+
with os_helper.temp_dir() as tmp_dir:
3162+
fn = self.make_dry_run_file(tmp_dir)
3163+
self.expect_success(fn)
3164+
self.assertEqual(self.expect_success("--dry-run", fn), "")
3165+
self.assertEqual(self.expect_success("--diff", fn), "")
3166+
3167+
def test_cli_dry_run_no_clinic_block(self):
3168+
with os_helper.temp_dir() as tmp_dir:
3169+
fn = os.path.join(tmp_dir, "test.c")
3170+
with open(fn, "w", encoding="utf-8") as f:
3171+
f.write("int x;\n")
3172+
self.assertEqual(self.expect_success("--dry-run", fn), "")
3173+
3174+
def test_cli_dry_run_output(self):
3175+
with os_helper.temp_dir() as tmp_dir:
3176+
fn = self.make_dry_run_file(tmp_dir)
3177+
out_fn = os.path.join(tmp_dir, "output.c")
3178+
out = self.expect_success("--dry-run", "-o", out_fn, fn)
3179+
self.assertIn(f"would create {out_fn}", out)
3180+
self.assertNotIn(f"would update {fn}", out)
3181+
self.assertFalse(os.path.exists(out_fn))
3182+
3183+
def test_cli_dry_run_make(self):
3184+
with os_helper.temp_dir() as tmp_dir:
3185+
fn = self.make_dry_run_file(tmp_dir)
3186+
pre_mtime = os.stat(fn).st_mtime_ns
3187+
out = self.expect_success("--dry-run", "--make", "--srcdir", tmp_dir)
3188+
self.assertIn(f"would update {fn}", out)
3189+
self.check_unchanged(tmp_dir, fn, pre_mtime)
3190+
3191+
def test_cli_dry_run_verbose(self):
3192+
with os_helper.temp_dir() as tmp_dir:
3193+
fn = self.make_dry_run_file(tmp_dir)
3194+
out, err, code = self.run_clinic("-v", "--dry-run", fn)
3195+
self.assertEqual(code, 0)
3196+
# The progress goes to stderr, so that the standard output
3197+
# contains only the report.
3198+
self.assertEqual(err.splitlines(), [fn])
3199+
self.assertEqual(out.splitlines(), [
3200+
f"would create {self.dest_file(fn)}",
3201+
f"would update {fn}",
3202+
])
3203+
3204+
def test_cli_dry_run_checksum_mismatch(self):
3205+
invalid_input = dedent("""
3206+
/*[clinic input]
3207+
output preset block
3208+
module test
3209+
test.fn
3210+
a: int
3211+
[clinic start generated code]*/
3212+
/*[clinic end generated code: output=bogus input=bogus]*/
3213+
""")
3214+
with os_helper.temp_dir() as tmp_dir:
3215+
fn = os.path.join(tmp_dir, "test.c")
3216+
with open(fn, "w", encoding="utf-8") as f:
3217+
f.write(invalid_input)
3218+
pre_mtime = os.stat(fn).st_mtime_ns
3219+
# The dry run does not disable the checksum verification.
3220+
_, err = self.expect_failure("--dry-run", fn)
3221+
self.assertIn("Checksum mismatch!", err)
3222+
# With -f the change is reported, but still not written.
3223+
out = self.expect_success("--dry-run", "-f", fn)
3224+
self.assertIn(f"would update {fn}", out)
3225+
with open(fn, encoding="utf-8") as f:
3226+
self.assertEqual(f.read(), invalid_input)
3227+
self.assertEqual(os.stat(fn).st_mtime_ns, pre_mtime)
3228+
3229+
def test_cli_diff(self):
3230+
with os_helper.temp_dir() as tmp_dir:
3231+
fn = self.make_dry_run_file(tmp_dir)
3232+
pre_mtime = os.stat(fn).st_mtime_ns
3233+
out = self.expect_success("--diff", fn)
3234+
self.check_unchanged(tmp_dir, fn, pre_mtime)
3235+
3236+
# A new file is created by the patch.
3237+
dest_fn = self.dest_file(fn)
3238+
self.assertStartsWith(out, f"--- /dev/null\n+++ {dest_fn}\n@@ -0,0 +1,")
3239+
self.assertIn(f"--- {fn}\n+++ {fn}\n", out)
3240+
self.assertIn("+/*[clinic end generated code:", out)
3241+
3242+
# The patch is what clinic would have written.
3243+
self.expect_success(fn)
3244+
with open(fn, encoding="utf-8") as f:
3245+
new_contents = f.read()
3246+
expected = "".join(difflib.unified_diff(
3247+
self.DRY_RUN_CODE.splitlines(keepends=True),
3248+
new_contents.splitlines(keepends=True),
3249+
fromfile=fn, tofile=fn))
3250+
self.assertEndsWith(out, expected)
3251+
3252+
def test_cli_fail_converters_and_dry_run(self):
3253+
for opt in "--dry-run", "--diff":
3254+
with self.subTest(opt=opt):
3255+
_, err = self.expect_failure("--converters", opt)
3256+
msg = "can't use --dry-run or --diff with --converters"
3257+
self.assertIn(msg, err)
3258+
31163259
def test_cli_make(self):
31173260
c_code = dedent("""
31183261
/*[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.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Fix Argument Clinic generating the flags of the optional groups in
2+
different order on 32-bit and 64-bit platforms.

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/clanguage.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -356,9 +356,8 @@ def render_option_group_parsing(
356356
""")
357357
continue
358358

359-
# Deduplicate the groups, but keep the order of parameters:
360-
# the iteration order of a set of small negative integers
361-
# depends on the platform.
359+
# A set would eliminate duplicates too, but the iteration
360+
# order of small negative integers depends on the platform.
362361
group_ids = dict.fromkeys(p.group for p in subset)
363362
d: dict[str, str | int] = {}
364363
d['count'] = count

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)