fix(stamping): skip build data spawn when --stamp=false (#4160)

## Summary

`PyWriteBuildData` is dirty on every build with a cold Skyframe when
outputs are not downloaded. That is, once per `py_binary` and `py_test`
in the build. When stamping is off, the output is a constant, so the
action spawn is pure overhead.

## Root Cause Analysis

The output is declared with
`_py_builtins.declare_constant_metadata_file`. Constant metadata always
reports itself as unchanged, so Bazel cannot validate it from the action
cache. It stats the output on disk instead.

Under `--remote_download_outputs=minimal` the output is not on disk. The
stat fails and the action is dirty. It re-executes, hits the remote
cache, is still not downloaded, and is dirty again on the next
cold-server build. Setting `--remote_download_regex` to match the file
avoids this, at the cost of downloading it, which confirms the stat is
the trigger.

The use of a constant metadata file is justified (as per the comment in
the code). With stamping on, the action reads `volatile-status.txt`,
which changes every build. The build data file is in the binary's
runfiles, so without it its consumers would invalidate on every build
due to timestamps.

However, with stamping off, there is no volatile input. There is nothing
to shield. Unfortunately, the current implementation keeps the
_additional_ cost (constant metadata being always unchanged plus failed
`stat` syscalls) for none of the benefits.

## Change

When `is_stamping_enabled(ctx)` is false, the content of the file is
known at analysis time:

```
TARGET <label>
STAMPED FALSE
```

So declare an ordinary output and write it with `ctx.actions.write`. No
spawn, and no constant metadata output.

Note this is broader than `--nostamp`. `is_stamping_enabled` is also
false for the exec configuration and for `stamp = 0`, so tool-config
binaries benefit from the fix even when `--stamp=true` is set at the top
level.

The stamped path is unchanged and keeps its constant metadata output.

## Compatibility

The output is byte-identical. The label is written with `str(ctx.label)`
to match `"TARGET": str(ctx.label)` in the spawn's env, which yields the
canonical `@@//pkg:target` form. `ctx.actions.args().add(ctx.label)`
renders the display form and drops the `@@`.

After the change, the tests in `tests/build_data/build_data_test.py`
pass without any modifications.

## Alternative considered

Keeping the spawn and declaring an ordinary output would also fix the
dirtiness. Writing at analysis time is lighter, since it needs no
subprocess.

## Environment

rules_python 2.2.0, Bazel 9.2.0, Linux. Reproduce with
`--remote_download_outputs=minimal` against a warm remote cache, then
`bazel shutdown` and rebuild.
diff --git a/news/4160.fixed.md b/news/4160.fixed.md
new file mode 100644
index 0000000..5f87118
--- /dev/null
+++ b/news/4160.fixed.md
@@ -0,0 +1,2 @@
+(stamping) The build data spawn action is now skipped when `--stamp=false` and,
+instead the metadata file is written at analysis time via `ctx.actions.write()`.
diff --git a/python/private/py_executable.bzl b/python/private/py_executable.bzl
index f7386ee..62742d3 100644
--- a/python/private/py_executable.bzl
+++ b/python/private/py_executable.bzl
@@ -1634,17 +1634,31 @@
     )
 
 def _write_build_data(ctx):
+    build_data_name = ctx.label.name + ".build_data.txt"
+
+    if not is_stamping_enabled(ctx):
+        # Without stamping there are no volatile inputs, so the content is known
+        # at analysis time. Write it directly. This skips a spawn per executable.
+        # It also skips the constant metadata output below, which never reports
+        # as up to date.
+        build_data = ctx.actions.declare_file(build_data_name)
+
+        ctx.actions.write(
+            output = build_data,
+            content = ctx.actions.args()
+                .set_param_file_format("multiline")
+                .add(str(ctx.label), format = "TARGET %s")
+                .add("STAMPED FALSE"),
+        )
+
+        return build_data
+
+    info_file = ctx.info_file
+    version_file = ctx.files._uncachable_version_file[0]
+
     inputs = builders.DepsetBuilder()
-    if is_stamping_enabled(ctx):
-        # NOTE: ctx.info_file is undocumented; see
-        # https://github.com/bazelbuild/bazel/issues/9363
-        info_file = ctx.info_file
-        version_file = ctx.files._uncachable_version_file[0]
-        inputs.add(info_file)
-        inputs.add(version_file)
-    else:
-        info_file = None
-        version_file = None
+    inputs.add(info_file)
+    inputs.add(version_file)
 
     # A "constant metadata" file is basically a special file that doesn't
     # support change detection logic and reports that it is unchanged. i.e., it
@@ -1670,9 +1684,15 @@
     #     mostly works. Google's RBE, unfortunately, rejects it.
     #   * A binary's transitive closure may be so large that it exceeds
     #     Google RBE limits for action inputs.
+    #
+    # The cost is that this action never reports as up to date. Constant
+    # metadata cannot be restored from the action cache, so ActionCacheChecker
+    # stats the output instead. That stat fails when outputs are not downloaded,
+    # and the action re-runs. The trade is worth it here, because the inputs do
+    # really change every build.
     build_data = _py_builtins.declare_constant_metadata_file(
         ctx = ctx,
-        name = ctx.label.name + ".build_data.txt",
+        name = build_data_name,
         root = ctx.bin_dir,
     )
 
@@ -1698,13 +1718,13 @@
         executable = action_exe,
         arguments = [action_args],
         env = {
-            "INFO_FILE": info_file.path if info_file else "",
+            "INFO_FILE": info_file.path,
             "OUTPUT": build_data.path,
             # Include this so it's explicit, otherwise, one has to detect
             # this by looking for the absense of info_file keys.
-            "STAMPED": "TRUE" if is_stamping_enabled(ctx) else "FALSE",
+            "STAMPED": "TRUE",
             "TARGET": str(ctx.label),
-            "VERSION_FILE": version_file.path if version_file else "",
+            "VERSION_FILE": version_file.path,
         },
         inputs = inputs.build(),
         outputs = [build_data],