fix(toolchains): write regular __pycache__ files when pyc caching is disabled (#4264)

When `RULES_PYTHON_PYCACHE_DIR` is `/dev/null` (`NUL` on Windows) or a
pycache directory cannot be created, `python_repository` previously
symlinked `__pycache__` to `/dev/null`. Those character-device symlinks
can behave unexpectedly when read or archived.

Instead, return `None` from `_get_pycache_root` when `/dev/null` or
`NUL` is specified and create empty regular `__pycache__` files when no
pycache root is available, preventing Python from writing local `.pyc`
files in the runtime repository.

Fixes #4198
diff --git a/docs/environment-variables.md b/docs/environment-variables.md
index 9efa48c..b818ade 100644
--- a/docs/environment-variables.md
+++ b/docs/environment-variables.md
@@ -112,8 +112,8 @@
 2. `$HOME/.cache` (non-Windows), the XDG default when `XDG_CACHE_HOME` is
    unset, or `LOCALAPPDATA` (Windows).
 
-If such a diretory cannot be found, or created, then `/dev/null` will be used,
-which will effectively disable pyc caching.
+If `/dev/null` (`NUL` on Windows) is specified, or if a directory cannot be
+found or created, then pyc caching will be disabled.
 
 :::{versionchanged} VERSION_NEXT_PATCH
 Temporary directories (`TMP`, `TEMP`, `/tmp` and `C:\Temp`) are no longer used.
diff --git a/python/private/python_repository.bzl b/python/private/python_repository.bzl
index 8517a3f..d5ec92f 100644
--- a/python/private/python_repository.bzl
+++ b/python/private/python_repository.bzl
@@ -60,6 +60,10 @@
         {type}`path | None` The path to the pycache root, or None if it couldn't
         be created.
     """
+    res = rctx.getenv("RULES_PYTHON_PYCACHE_DIR")
+    if res and res.lower() in ("/dev/null", "nul"):
+        return None
+
     os_name = repo_utils.get_platforms_os_name(rctx)
     is_windows = os_name == "windows"
 
@@ -74,7 +78,6 @@
         hash("{}:{}".format(rctx.workspace_root, runtime_hash)),
     )
 
-    res = rctx.getenv("RULES_PYTHON_PYCACHE_DIR")
     if res:
         return repo_utils.mkdir(rctx, rctx.path(res).get_child(suffix))
 
@@ -100,7 +103,7 @@
     return repo_utils.mkdir(rctx, path)
 
 def _create_pycache_symlinks(rctx, logger):
-    """Finds all directories with a .py file and creates __pycache__ symlinks.
+    """Finds all directories with a .py file and creates __pycache__ entries.
 
     Args:
         rctx: {type}`repository_ctx` The repository rule's context object.
@@ -110,9 +113,6 @@
     logger.info(lambda: "pycache root: {}".format(pycache_root))
     pycache_root_str = str(pycache_root) if pycache_root else None
 
-    os_name = repo_utils.get_platforms_os_name(rctx)
-    null_device = "NUL" if os_name == "windows" else "/dev/null"
-
     queue = [rctx.path(".")]
 
     # Starlark doesn't support recursion, use a loop with a queue.
@@ -146,7 +146,13 @@
                 rctx.symlink(target_dir, pycache_dir)
             else:
                 rctx.delete(pycache_dir)
-                rctx.symlink(null_device, pycache_dir)
+
+                # The intent is to disable pyc generation, which we can't
+                # entirely do directly. Symlinking to /dev/null makes it point
+                # to a character device, which can act weird when read or put
+                # into a zip. A regular file seems to make python skip local pyc
+                # generation, so use that.
+                rctx.file(pycache_dir, content = "", executable = False)
 
 def _python_repository_impl(rctx):
     if rctx.attr.distutils and rctx.attr.distutils_content:
diff --git a/tests/integration/custom_commands_test.py b/tests/integration/custom_commands_test.py
index c186d57..a707d0d 100644
--- a/tests/integration/custom_commands_test.py
+++ b/tests/integration/custom_commands_test.py
@@ -12,6 +12,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import pathlib
 import unittest
 
 from tests.integration import runner
@@ -30,6 +31,7 @@
 
     # Regression test for https://github.com/bazel-contrib/rules_python/issues/4198
     def test_runtime_files_exclude_pycache_symlinks(self):
+        output_base = pathlib.Path(self.run_bazel("info", "output_base").stdout.strip())
         result = self.run_bazel(
             "cquery",
             "--repo_env=RULES_PYTHON_PYCACHE_DIR=/dev/null",
@@ -41,6 +43,12 @@
         pycache = [f for f in files if "__pycache__" in f]
         self.assertEqual(pycache, [])
 
+        os_py = next(f for f in files if f.endswith("/os.py") or f.endswith("\\os.py"))
+        stdlib_pycache = output_base / pathlib.Path(os_py).parent / "__pycache__"
+        self.assertFalse(stdlib_pycache.is_symlink())
+        self.assertFalse(stdlib_pycache.is_dir())
+        self.assertTrue(stdlib_pycache.is_file())
+
 
 if __name__ == "__main__":
     # Enabling this makes the runner log subprocesses as the test goes along.