diff --git a/Lib/test/test_clinic.py b/Lib/test/test_clinic.py index cb4507dcac2336..653c3b1bf1476f 100644 --- a/Lib/test/test_clinic.py +++ b/Lib/test/test_clinic.py @@ -3076,6 +3076,64 @@ def test_no_change(self): # if the content does not change self.assertEqual(pre_mtime, post_mtime) + TOUCH_CODE = dedent(""" + /*[clinic input] + module m + [clinic start generated code]*/ + + /*[clinic input] + output everything file + m.func + a: int + / + + Docstring. + [clinic start generated code]*/ + """) + + def test_touch_source(self): + # gh-64595: The build system does not know that the source file + # depends on the file generated from it, so the modification + # times are updated to force the recompilation. + def mtimes(): + return os.stat(fn).st_mtime_ns, os.stat(dest).st_mtime_ns + + def set_mtimes(source, generated): + os.utime(fn, ns=(source, source)) + os.utime(dest, ns=(generated, generated)) + + 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(self.TOUCH_CODE) + dest = self.dest_file(fn) + self.expect_success(fn) + source_mtime, generated_mtime = mtimes() + self.assertGreaterEqual(generated_mtime, source_mtime) + + # The generated file is changed, so both files are touched. + os.unlink(dest) + old = source_mtime - 10**10 + os.utime(fn, ns=(old, old)) + self.expect_success(fn) + source_mtime, generated_mtime = mtimes() + self.assertGreater(source_mtime, old) + self.assertGreaterEqual(generated_mtime, source_mtime) + + # Nothing is changed, but the source file is newer, so only + # the generated file is touched. + set_mtimes(source_mtime - 10**10, source_mtime - 2 * 10**10) + old_source_mtime = os.stat(fn).st_mtime_ns + self.expect_success(fn) + source_mtime, generated_mtime = mtimes() + self.assertEqual(source_mtime, old_source_mtime) + self.assertGreaterEqual(generated_mtime, source_mtime) + + # Nothing is changed and the generated file is newer, + # so no file is touched. + self.expect_success(fn) + self.assertEqual(mtimes(), (source_mtime, generated_mtime)) + def test_cli_force(self): invalid_input = dedent(""" /*[clinic input] diff --git a/Misc/NEWS.d/next/Tools-Demos/2026-08-06-09-43-16.gh-issue-64595.W75XZK.rst b/Misc/NEWS.d/next/Tools-Demos/2026-08-06-09-43-16.gh-issue-64595.W75XZK.rst new file mode 100644 index 00000000000000..6c9393347d31ce --- /dev/null +++ b/Misc/NEWS.d/next/Tools-Demos/2026-08-06-09-43-16.gh-issue-64595.W75XZK.rst @@ -0,0 +1,5 @@ +Argument Clinic now touches the source file if a file generated from it was +changed, and keeps the generated files newer than the source file. +The build system does not know that the source file depends on the files +generated from it, so it did not recompile the source file if only the +generated file was changed. diff --git a/Tools/clinic/libclinic/cli.py b/Tools/clinic/libclinic/cli.py index c66084cf314482..290fc3a6e59408 100644 --- a/Tools/clinic/libclinic/cli.py +++ b/Tools/clinic/libclinic/cli.py @@ -89,10 +89,15 @@ def parse_file( filename=filename, limited_capi=limited_capi, writer=writer) + index = len(writer.files) cooked = clinic.parse(raw) - writer.write(output, cooked) + files = writer.files[index:] + writer.update_times(output, + [fn for fn, _ in files if fn != output], + any(changed for _, changed in files)) + def create_cli() -> argparse.ArgumentParser: cmdline = argparse.ArgumentParser( diff --git a/Tools/clinic/libclinic/utils.py b/Tools/clinic/libclinic/utils.py index 8fc8748f0f9ae1..01015ff1237656 100644 --- a/Tools/clinic/libclinic/utils.py +++ b/Tools/clinic/libclinic/utils.py @@ -5,6 +5,7 @@ import os import re import string +from collections.abc import Iterable from typing import Literal, Final @@ -17,11 +18,14 @@ def read_file(filename: str) -> str | None: return None -def write_file(filename: str, new_contents: str) -> None: - """Write new content to file, iff the content changed.""" +def write_file(filename: str, new_contents: str) -> bool: + """Write new content to file, iff the content changed. + + Return True if the file was written. + """ if read_file(filename) == new_contents: # no change: avoid modifying the file modification time - return + return False # Atomic write using a temporary file and os.replace() filename_new = f"{filename}.new" with open(filename_new, "w", encoding="utf-8") as fp: @@ -31,6 +35,7 @@ def write_file(filename: str, new_contents: str) -> None: except: os.unlink(filename_new) raise + return True @dc.dataclass(slots=True, frozen=True) @@ -50,6 +55,8 @@ class FileWriter: dry_run: bool = False changes: list[FileChange] = dc.field(default_factory=list) + # (filename, changed) for every file which was passed to write(). + files: list[tuple[str, bool]] = dc.field(default_factory=list) def makedirs(self, dirname: str) -> None: if not self.dry_run: @@ -61,12 +68,34 @@ def makedirs(self, dirname: str) -> None: def write(self, filename: str, new_contents: str) -> None: if not self.dry_run: - write_file(filename, new_contents) + changed = write_file(filename, new_contents) + else: + old_contents = read_file(filename) + changed = old_contents != new_contents + if changed: + self.changes.append( + FileChange(filename, old_contents, new_contents)) + self.files.append((filename, changed)) + + def update_times(self, source: str, generated: Iterable[str], + changed: bool) -> None: + """Keep the generated files newer than the source file. + + The build system does not know that the source file depends on + the files generated from it, so the source file is touched to + force its recompilation. + """ + if self.dry_run: return - old_contents = read_file(filename) - if old_contents != new_contents: - self.changes.append( - FileChange(filename, old_contents, new_contents)) + if changed: + os.utime(source) + for filename in generated: + os.utime(filename) + else: + mtime = os.stat(source).st_mtime_ns + for filename in generated: + if os.stat(filename).st_mtime_ns <= mtime: + os.utime(filename) def compute_checksum(input_: str, length: int | None = None) -> str: