Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions Lib/test/test_clinic.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from test.support.os_helper import TESTFN, unlink, rmtree
from textwrap import dedent
from unittest import TestCase
import difflib
import inspect
import os.path
import re
Expand Down Expand Up @@ -2893,6 +2894,148 @@ def test_cli_force(self):
generated = f.read()
self.assertEndsWith(generated, checksum)

DRY_RUN_CODE = dedent("""
/*[clinic input]
func
a: int
/

Docstring.
[clinic start generated code]*/
""")

def make_dry_run_file(self, tmp_dir):
fn = os.path.join(tmp_dir, "test.c")
with open(fn, "w", encoding="utf-8") as f:
f.write(self.DRY_RUN_CODE)
return fn

@staticmethod
def dest_file(fn):
# The default destination for the generated code. Its path is
# built from the "{dirname}/clinic/{basename}.h" template, so it
# always uses forward slashes, even on Windows.
dirname, basename = os.path.split(fn)
return f"{dirname}/clinic/{basename}.h"

def check_unchanged(self, tmp_dir, fn, pre_mtime):
# Neither the source file nor the destination file
# nor its directory is created or modified.
with open(fn, encoding="utf-8") as f:
self.assertEqual(f.read(), self.DRY_RUN_CODE)
self.assertEqual(os.stat(fn).st_mtime_ns, pre_mtime)
self.assertEqual(os.listdir(tmp_dir), ["test.c"])

def test_cli_dry_run(self):
with os_helper.temp_dir() as tmp_dir:
fn = self.make_dry_run_file(tmp_dir)
pre_mtime = os.stat(fn).st_mtime_ns
out = self.expect_success("--dry-run", fn)
self.assertEqual(out.splitlines(), [
f"would create {self.dest_file(fn)}",
f"would update {fn}",
])
self.check_unchanged(tmp_dir, fn, pre_mtime)

def test_cli_dry_run_no_change(self):
with os_helper.temp_dir() as tmp_dir:
fn = self.make_dry_run_file(tmp_dir)
self.expect_success(fn)
self.assertEqual(self.expect_success("--dry-run", fn), "")
self.assertEqual(self.expect_success("--diff", fn), "")

def test_cli_dry_run_no_clinic_block(self):
with os_helper.temp_dir() as tmp_dir:
fn = os.path.join(tmp_dir, "test.c")
with open(fn, "w", encoding="utf-8") as f:
f.write("int x;\n")
self.assertEqual(self.expect_success("--dry-run", fn), "")

def test_cli_dry_run_output(self):
with os_helper.temp_dir() as tmp_dir:
fn = self.make_dry_run_file(tmp_dir)
out_fn = os.path.join(tmp_dir, "output.c")
out = self.expect_success("--dry-run", "-o", out_fn, fn)
self.assertIn(f"would create {out_fn}", out)
self.assertNotIn(f"would update {fn}", out)
self.assertFalse(os.path.exists(out_fn))

def test_cli_dry_run_make(self):
with os_helper.temp_dir() as tmp_dir:
fn = self.make_dry_run_file(tmp_dir)
pre_mtime = os.stat(fn).st_mtime_ns
out = self.expect_success("--dry-run", "--make", "--srcdir", tmp_dir)
self.assertIn(f"would update {fn}", out)
self.check_unchanged(tmp_dir, fn, pre_mtime)

def test_cli_dry_run_verbose(self):
with os_helper.temp_dir() as tmp_dir:
fn = self.make_dry_run_file(tmp_dir)
out, err, code = self.run_clinic("-v", "--dry-run", fn)
self.assertEqual(code, 0)
# The progress goes to stderr, so that the standard output
# contains only the report.
self.assertEqual(err.splitlines(), [fn])
self.assertEqual(out.splitlines(), [
f"would create {self.dest_file(fn)}",
f"would update {fn}",
])

def test_cli_dry_run_checksum_mismatch(self):
invalid_input = dedent("""
/*[clinic input]
output preset block
module test
test.fn
a: int
[clinic start generated code]*/
/*[clinic end generated code: output=bogus input=bogus]*/
""")
with os_helper.temp_dir() as tmp_dir:
fn = os.path.join(tmp_dir, "test.c")
with open(fn, "w", encoding="utf-8") as f:
f.write(invalid_input)
pre_mtime = os.stat(fn).st_mtime_ns
# The dry run does not disable the checksum verification.
_, err = self.expect_failure("--dry-run", fn)
self.assertIn("Checksum mismatch!", err)
# With -f the change is reported, but still not written.
out = self.expect_success("--dry-run", "-f", fn)
self.assertIn(f"would update {fn}", out)
with open(fn, encoding="utf-8") as f:
self.assertEqual(f.read(), invalid_input)
self.assertEqual(os.stat(fn).st_mtime_ns, pre_mtime)

def test_cli_diff(self):
with os_helper.temp_dir() as tmp_dir:
fn = self.make_dry_run_file(tmp_dir)
pre_mtime = os.stat(fn).st_mtime_ns
out = self.expect_success("--diff", fn)
self.check_unchanged(tmp_dir, fn, pre_mtime)

# A new file is created by the patch.
dest_fn = self.dest_file(fn)
self.assertStartsWith(out, f"--- /dev/null\n+++ {dest_fn}\n@@ -0,0 +1,")
self.assertIn(f"--- {fn}\n+++ {fn}\n", out)
self.assertIn("+/*[clinic end generated code:", out)

# The patch is what clinic would have written.
self.expect_success(fn)
with open(fn, encoding="utf-8") as f:
new_contents = f.read()
expected = "".join(difflib.unified_diff(
self.DRY_RUN_CODE.splitlines(keepends=True),
new_contents.splitlines(keepends=True),
fromfile=fn, tofile=fn))
self.assertEndsWith(out, expected)

def test_cli_fail_converters_and_dry_run(self):
for opt in "--dry-run", "--diff":
with self.subTest(opt=opt):
_, err = self.expect_failure("--converters", opt)
msg = "can't use --dry-run or --diff with --converters"
self.assertIn(msg, err)

def test_cli_make(self):
c_code = dedent("""
/*[clinic input]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Argument Clinic now supports the ``--dry-run`` and ``--diff`` options.
They list the files which would be changed, or write a unified diff of the
changes to the standard output, without modifying any file.
6 changes: 6 additions & 0 deletions Tools/clinic/libclinic/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,16 @@
is_legal_py_identifier,
)
from .utils import (
FileChange,
FileWriter,
FormatCounterFormatter,
NULL,
NullType,
Sentinels,
VersionTuple,
compute_checksum,
create_regex,
read_file,
unknown,
unspecified,
write_file,
Expand Down Expand Up @@ -66,13 +69,16 @@
"is_legal_py_identifier",

# Utility functions
"FileChange",
"FileWriter",
"FormatCounterFormatter",
"NULL",
"NullType",
"Sentinels",
"VersionTuple",
"compute_checksum",
"create_regex",
"read_file",
"unknown",
"unspecified",
"write_file",
Expand Down
8 changes: 5 additions & 3 deletions Tools/clinic/libclinic/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ def __init__(
filename: str,
limited_capi: bool,
verify: bool = True,
writer: libclinic.FileWriter | None = None,
) -> None:
# maps strings to Parser objects.
# (instantiated from the "parsers" global.)
Expand All @@ -95,6 +96,7 @@ def __init__(
if printer:
fail("Custom printers are broken right now")
self.printer = printer or BlockPrinter(language)
self.writer = writer or libclinic.FileWriter()
self.verify = verify
self.limited_capi = limited_capi
self.filename = filename
Expand Down Expand Up @@ -213,7 +215,7 @@ def parse(self, input: str) -> str:
try:
dirname = os.path.dirname(destination.filename)
try:
os.makedirs(dirname)
self.writer.makedirs(dirname)
except FileExistsError:
if not os.path.isdir(dirname):
fail(f"Can't write to destination "
Expand All @@ -234,8 +236,8 @@ def parse(self, input: str) -> str:

printer_2 = BlockPrinter(self.language)
printer_2.print_block(block, header_includes=includes)
libclinic.write_file(destination.filename,
printer_2.f.getvalue())
self.writer.write(destination.filename,
printer_2.f.getvalue())
continue

return printer.f.getvalue()
Expand Down
60 changes: 54 additions & 6 deletions Tools/clinic/libclinic/cli.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import argparse
import difflib
import inspect
import os
import re
Expand Down Expand Up @@ -52,9 +53,12 @@ def parse_file(
limited_capi: bool,
output: str | None = None,
verify: bool = True,
writer: libclinic.FileWriter | None = None,
) -> None:
if not output:
output = filename
if writer is None:
writer = libclinic.FileWriter()

extension = os.path.splitext(filename)[1][1:]
if not extension:
Expand All @@ -80,10 +84,11 @@ def parse_file(
clinic = Clinic(language,
verify=verify,
filename=filename,
limited_capi=limited_capi)
limited_capi=limited_capi,
writer=writer)
cooked = clinic.parse(raw)

libclinic.write_file(output, cooked)
writer.write(output, cooked)


def create_cli() -> argparse.ArgumentParser:
Expand All @@ -102,6 +107,12 @@ def create_cli() -> argparse.ArgumentParser:
help="redirect file output to OUTPUT")
cmdline.add_argument("-v", "--verbose", action='store_true',
help="enable verbose mode")
cmdline.add_argument("--dry-run", action='store_true',
help=("don't write any file, only list the files "
"which would be changed"))
cmdline.add_argument("--diff", action='store_true',
help=("don't write any file, write a unified diff "
"of the changes to the standard output"))
cmdline.add_argument("--converters", action='store_true',
help=("print a list of all supported converters "
"and return converters"))
Expand All @@ -119,12 +130,43 @@ def create_cli() -> argparse.ArgumentParser:
return cmdline


def print_diff(change: libclinic.FileChange) -> None:
if change.old_contents is None:
fromfile = "/dev/null"
old_lines: list[str] = []
else:
fromfile = change.filename
old_lines = change.old_contents.splitlines(keepends=True)
sys.stdout.writelines(difflib.unified_diff(
old_lines,
change.new_contents.splitlines(keepends=True),
fromfile=fromfile,
tofile=change.filename,
))


def report_changes(writer: libclinic.FileWriter, *, diff: bool) -> None:
for change in sorted(writer.changes, key=lambda change: change.filename):
if diff:
print_diff(change)
else:
action = "create" if change.old_contents is None else "update"
print(f"would {action} {change.filename}")


def run_clinic(parser: argparse.ArgumentParser, ns: argparse.Namespace) -> None:
dry_run = ns.dry_run or ns.diff
# The report is written to the standard output, so the progress
# is written to the standard error stream to not mix them.
verbose_file = sys.stderr if dry_run else sys.stdout

if ns.converters:
if ns.filename:
parser.error(
"can't specify --converters and a filename at the same time"
)
if dry_run:
parser.error("can't use --dry-run or --diff with --converters")
AnyConverterType = ConverterType | ReturnConverterType
converter_list: list[tuple[str, AnyConverterType]] = []
return_converter_list: list[tuple[str, AnyConverterType]] = []
Expand Down Expand Up @@ -188,6 +230,7 @@ def run_clinic(parser: argparse.ArgumentParser, ns: argparse.Namespace) -> None:
excludes = [os.path.normpath(f) for f in excludes]
else:
excludes = []
writer = libclinic.FileWriter(dry_run=dry_run)
for root, dirs, files in os.walk(ns.srcdir):
for rcs_dir in ('.svn', '.git', '.hg', 'build', 'externals'):
if rcs_dir in dirs:
Expand All @@ -201,9 +244,11 @@ def run_clinic(parser: argparse.ArgumentParser, ns: argparse.Namespace) -> None:
if path in excludes:
continue
if ns.verbose:
print(path)
print(path, file=verbose_file)
parse_file(path,
verify=not ns.force, limited_capi=ns.limited_capi)
verify=not ns.force, limited_capi=ns.limited_capi,
writer=writer)
report_changes(writer, diff=ns.diff)
return

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

writer = libclinic.FileWriter(dry_run=dry_run)
for filename in ns.filename:
if ns.verbose:
print(filename)
print(filename, file=verbose_file)
parse_file(filename, output=ns.output,
verify=not ns.force, limited_capi=ns.limited_capi)
verify=not ns.force, limited_capi=ns.limited_capi,
writer=writer)
report_changes(writer, diff=ns.diff)


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