gh-64595: Touch the source file if the generated file was changed (GH-155265)

serhiy-storchaka <[email protected]>
Newsgroups gmane.comp.python.cvs
Message-ID <[email protected]>
https://github.com/python/cpython/commit/c5168eadf15665310b22ff44e23738034d3e5036
commit: c5168eadf15665310b22ff44e23738034d3e5036
branch: main
author: Serhiy Storchaka <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-08-18T19:55:02+03:00
summary:

gh-64595: Touch the source file if the generated file was changed (GH-155265)

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.

files:
A Misc/NEWS.d/next/Tools-Demos/2026-08-06-09-43-16.gh-issue-64595.W75XZK.rst
M Lib/test/test_clinic.py
M Tools/clinic/libclinic/cli.py
M Tools/clinic/libclinic/utils.py

diff --git a/Lib/test/test_clinic.py b/Lib/test/test_clinic.py
index f0dc62967f6a776..94a69b6d7309df8 100644
--- a/Lib/test/test_clinic.py
+++ b/Lib/test/test_clinic.py
@@ -3214,6 +3214,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 000000000000000..6c9393347d31ce5
--- /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 c66084cf3144826..290fc3a6e59408a 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 8fc8748f0f9ae10..01015ff1237656b 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:

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.