fix(venv_shebang_rewriter): avoid depending on host coreutils (#4125)

Two scripts used as build actions in the pip integration ran directly
(`ctx.actions.run`, no shell) with no declared `PATH`. On sandboxed
environments without an FHS-style `/bin:/usr/bin` (e.g. NixOS), these
fail with `<tool>: command not found`, even though the action's own
executable resolves fine.

- `venv_shebang_rewriter.sh` resolved `head`/`tail`/`chmod` from `PATH`.
- `wheel_record_rewriter.sh` resolved `awk` from `PATH`.

## Fix

Both are ported to plain Python scripts exposed as `py_binary` targets,
per this project's own documented guidance (`PyExecToolsInfo`'s
`exec_interpreter` docs recommend a `py_binary` + `cfg=exec` over manual
interpreter wiring). This sidesteps host `PATH` concerns entirely.

---------

Co-authored-by: Richard Levasseur <richardlev@gmail.com>
diff --git a/news/4125.fixed.md b/news/4125.fixed.md
new file mode 100644
index 0000000..54c2096
--- /dev/null
+++ b/news/4125.fixed.md
@@ -0,0 +1,3 @@
+(pypi) Venv mode now works on NixOS and no longer requires coreutils to process
+wheel scripts and RECORD files
+([#4125](https://github.com/bazel-contrib/rules_python/pull/4125)).
diff --git a/python/private/pypi/BUILD.bazel b/python/private/pypi/BUILD.bazel
index 38ecabc..35286c5 100644
--- a/python/private/pypi/BUILD.bazel
+++ b/python/private/pypi/BUILD.bazel
@@ -13,6 +13,7 @@
 # limitations under the License.
 
 load("@bazel_skylib//:bzl_library.bzl", "bzl_library")
+load("//python:py_binary.bzl", "py_binary")
 
 package(default_visibility = ["//:__subpackages__"])
 
@@ -32,20 +33,34 @@
     visibility = ["//visibility:public"],
 )
 
+py_binary(
+    name = "venv_shebang_rewriter_py",
+    srcs = ["venv_shebang_rewriter.py"],
+    legacy_create_init = False,
+    main = "venv_shebang_rewriter.py",
+)
+
 alias(
     name = "venv_shebang_rewriter",
     actual = select({
         "@platforms//os:windows": "venv_shebang_rewriter.ps1",
-        "//conditions:default": "venv_shebang_rewriter.sh",
+        "//conditions:default": "venv_shebang_rewriter_py",
     }),
     visibility = ["//visibility:public"],
 )
 
+py_binary(
+    name = "wheel_record_rewriter_py",
+    srcs = ["wheel_record_rewriter.py"],
+    legacy_create_init = False,
+    main = "wheel_record_rewriter.py",
+)
+
 alias(
     name = "wheel_record_rewriter",
     actual = select({
         "@platforms//os:windows": "wheel_record_rewriter.ps1",
-        "//conditions:default": "wheel_record_rewriter.sh",
+        "//conditions:default": "wheel_record_rewriter_py",
     }),
     visibility = ["//visibility:public"],
 )
diff --git a/python/private/pypi/venv_shebang_rewriter.py b/python/private/pypi/venv_shebang_rewriter.py
new file mode 100644
index 0000000..48e615b
--- /dev/null
+++ b/python/private/pypi/venv_shebang_rewriter.py
@@ -0,0 +1,42 @@
+"""Rewrites a console_script wrapper's shebang into a batch/shell-Python polyglot."""
+
+import os
+import sys
+
+
+def main(argv):
+    in_path, out_path, target_os = argv[1:4]
+
+    with open(in_path, "rb") as in_file:
+        first_line = in_file.readline()
+        rest = in_file.read()
+
+    with open(out_path, "wb") as out_file:
+        if target_os == "windows":
+            python_exe = (
+                b"pythonw.exe" if first_line.startswith(b"#!pythonw") else b"python.exe"
+            )
+            # A Batch-Python polyglot. Batch executes the first line and exits,
+            # while Python (via -x) ignores the first line and executes the rest.
+            out_file.write(
+                b'@setlocal enabledelayedexpansion & "%~dp0'
+                + python_exe
+                + b'" -x "%~f0" %* & exit /b !ERRORLEVEL!\r\n',
+            )
+        else:
+            out_file.write(b"#!/bin/sh\n")
+            # A Shell-Python polyglot. The shell executes the triple-quoted 'exec'
+            # command, re-running the script with python3 from the scripts directory.
+            # Python ignores the triple-quoted string and continues.
+            out_file.write(
+                b"'''exec' \"$(dirname \"$0\")/python3\" \"$0\" \"$@\"\n' '''\n"
+            )
+
+        out_file.write(rest)
+
+    mode = os.stat(out_path).st_mode
+    os.chmod(out_path, mode | 0o111)
+
+
+if __name__ == "__main__":
+    main(sys.argv)
diff --git a/python/private/pypi/venv_shebang_rewriter.sh b/python/private/pypi/venv_shebang_rewriter.sh
deleted file mode 100755
index d4391d3..0000000
--- a/python/private/pypi/venv_shebang_rewriter.sh
+++ /dev/null
@@ -1,27 +0,0 @@
-#!/bin/sh
-set -eu
-
-IN="$1"
-OUT="$2"
-TARGET_OS="$3"
-
-FIRST_LINE=$(head -n 1 "$IN")
-
-if [ "$TARGET_OS" = "windows" ]; then
-  case "$FIRST_LINE" in
-    "#!pythonw"*) PYTHON_EXE="pythonw.exe" ;;
-    *)            PYTHON_EXE="python.exe" ;;
-  esac
-  # A Batch-Python polyglot. Batch executes the first line and exits,
-  # while Python (via -x) ignores the first line and executes the rest.
-  printf "@setlocal enabledelayedexpansion & \"%%~dp0$PYTHON_EXE\" -x \"%%~f0\" %%* & exit /b !ERRORLEVEL!\r\n" > "$OUT"
-else
-  printf "#!/bin/sh\n" > "$OUT"
-  # A Shell-Python polyglot. The shell executes the triple-quoted 'exec'
-  # command, re-running the script with python3 from the scripts directory.
-  # Python ignores the triple-quoted string and continues.
-  printf "'''exec' \"\$(dirname \"\$0\")/python3\" \"\$0\" \"\$@\"\n' '''\n" >> "$OUT"
-fi
-
-tail -n +2 "$IN" >> "$OUT"
-chmod +x "$OUT"
diff --git a/python/private/pypi/wheel_record_rewriter.py b/python/private/pypi/wheel_record_rewriter.py
new file mode 100644
index 0000000..8931b30
--- /dev/null
+++ b/python/private/pypi/wheel_record_rewriter.py
@@ -0,0 +1,75 @@
+"""Rewrites a wheel's RECORD file to reflect its final installed layout."""
+
+import sys
+
+
+def _rewrite(in_path, out_path, target_os, data_dir_basename, rewritten_scripts):
+    data_prefix = data_dir_basename + "/"
+    quoted_data_prefix = '"' + data_prefix
+
+    if target_os == "windows":
+        data_repl = "../../"
+        headers_repl = "../../Include/"
+        platlib_repl = ""
+        purelib_repl = ""
+        scripts_repl = "../../Scripts/"
+    else:
+        data_repl = "../../../"
+        headers_repl = "../../../include/"
+        platlib_repl = ""
+        purelib_repl = ""
+        scripts_repl = "../../../bin/"
+
+    # PEP 427 specifies archive filenames are UTF-8, and while the encoding of
+    # dist-info files isn't formally standardized in PEP 376, packaging tools
+    # like pip and importlib.metadata treat them as UTF-8 in practice.
+    # See: https://discuss.python.org/t/encoding-of-files-in-the-dist-info-directory/6734/4
+    with open(in_path, encoding="utf-8") as in_file, open(
+        out_path, "w", encoding="utf-8", newline="\n"
+    ) as out_file:
+        for raw_line in in_file:
+            line = raw_line.rstrip("\r\n")
+
+            if line.startswith(quoted_data_prefix):
+                quote = '"'
+                rest = line[len(quoted_data_prefix) :]
+            elif line.startswith(data_prefix):
+                quote = ""
+                rest = line[len(data_prefix) :]
+            else:
+                out_file.write(line + "\n")
+                continue
+
+            if rest.startswith("purelib/"):
+                out_file.write(quote + purelib_repl + rest[len("purelib/") :] + "\n")
+            elif rest.startswith("platlib/"):
+                out_file.write(quote + platlib_repl + rest[len("platlib/") :] + "\n")
+            elif rest.startswith("scripts/"):
+                entry = rest[len("scripts/") :]
+                if target_os == "windows":
+                    if quote == '"':
+                        idx = entry.index('"')
+                    else:
+                        idx = entry.index(",")
+                    spath, suffix = entry[:idx], entry[idx:]
+                    if spath in rewritten_scripts:
+                        spath += ".bat"
+                    out_file.write(quote + scripts_repl + spath + suffix + "\n")
+                else:
+                    out_file.write(quote + scripts_repl + entry + "\n")
+            elif rest.startswith("headers/"):
+                out_file.write(quote + headers_repl + rest[len("headers/") :] + "\n")
+            elif rest.startswith("data/"):
+                out_file.write(quote + data_repl + rest[len("data/") :] + "\n")
+            else:
+                out_file.write(line + "\n")
+
+
+def main(argv):
+    in_path, out_path, target_os, data_dir_basename = argv[1:5]
+    rewritten_scripts = set(argv[5:])
+    _rewrite(in_path, out_path, target_os, data_dir_basename, rewritten_scripts)
+
+
+if __name__ == "__main__":
+    main(sys.argv)
diff --git a/python/private/pypi/wheel_record_rewriter.sh b/python/private/pypi/wheel_record_rewriter.sh
deleted file mode 100755
index 4d93f2a..0000000
--- a/python/private/pypi/wheel_record_rewriter.sh
+++ /dev/null
@@ -1,85 +0,0 @@
-#!/bin/sh
-set -eu
-
-IN="$1"
-OUT="$2"
-TARGET_OS="$3"
-DATA_DIR_BASENAME="$4"
-shift 4
-
-DATA_PREFIX="${DATA_DIR_BASENAME}/"
-QUOTED_DATA_PREFIX="\"${DATA_DIR_BASENAME}/"
-
-if [ "$TARGET_OS" = "windows" ]; then
-  DATA_REPL="../../"
-  HEADERS_REPL="../../Include/"
-  PLATLIB_REPL=""
-  PURELIB_REPL=""
-  SCRIPTS_REPL="../../Scripts/"
-else
-  DATA_REPL="../../../"
-  HEADERS_REPL="../../../include/"
-  PLATLIB_REPL=""
-  PURELIB_REPL=""
-  SCRIPTS_REPL="../../../bin/"
-fi
-
-awk -v data_prefix="$DATA_PREFIX" \
-    -v quoted_data_prefix="$QUOTED_DATA_PREFIX" \
-    -v data_repl="$DATA_REPL" \
-    -v headers_repl="$HEADERS_REPL" \
-    -v platlib_repl="$PLATLIB_REPL" \
-    -v purelib_repl="$PURELIB_REPL" \
-    -v scripts_repl="$SCRIPTS_REPL" \
-    -v target_os="$TARGET_OS" '
-BEGIN {
-  for (i = 2; i < ARGC; i++) {
-    rewritten[ARGV[i]] = 1
-  }
-  ARGC = 2
-}
-{
-  line = $0
-  quote = ""
-  if (substr(line, 1, length(quoted_data_prefix)) == quoted_data_prefix) {
-    quote = "\""
-    rest = substr(line, length(quoted_data_prefix) + 1)
-  } else if (substr(line, 1, length(data_prefix)) == data_prefix) {
-    rest = substr(line, length(data_prefix) + 1)
-  } else {
-    print line
-    next
-  }
-
-  if (substr(rest, 1, 8) == "purelib/") {
-    print quote purelib_repl substr(rest, 9)
-  } else if (substr(rest, 1, 8) == "platlib/") {
-    print quote platlib_repl substr(rest, 9)
-  } else if (substr(rest, 1, 8) == "scripts/") {
-    entry = substr(rest, 9)
-    if (target_os == "windows") {
-      if (quote == "\"") {
-        idx = index(entry, "\"")
-        spath = substr(entry, 1, idx - 1)
-        suffix = substr(entry, idx)
-      } else {
-        idx = index(entry, ",")
-        spath = substr(entry, 1, idx - 1)
-        suffix = substr(entry, idx)
-      }
-      if (spath in rewritten) {
-        spath = spath ".bat"
-      }
-      print quote scripts_repl spath suffix
-    } else {
-      print quote scripts_repl entry
-    }
-  } else if (substr(rest, 1, 8) == "headers/") {
-    print quote headers_repl substr(rest, 9)
-  } else if (substr(rest, 1, 5) == "data/") {
-    print quote data_repl substr(rest, 6)
-  } else {
-    print line
-  }
-}
-' "$IN" "$@" > "$OUT"