From 5a382d88a00db4883d00d5122a35d7ba07ec6e05 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Thu, 6 Aug 2026 09:43:30 +0300 Subject: [PATCH] gh-64595: Touch the source file if the generated file was changed 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. The generated files are also kept newer than the source file. --- Lib/test/test_clinic.py | 58 +++++++++++++++++++ ...6-08-06-09-43-16.gh-issue-64595.W75XZK.rst | 5 ++ Tools/clinic/libclinic/cli.py | 7 ++- Tools/clinic/libclinic/utils.py | 45 +++++++++++--- 4 files changed, 106 insertions(+), 9 deletions(-) create mode 100644 Misc/NEWS.d/next/Tools-Demos/2026-08-06-09-43-16.gh-issue-64595.W75XZK.rst 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: