feat: inherit PYTHONSAFEPATH env var from outer process (#2076)

By default, PYTHONSAFEPATH is enabled to help prevent imports being
found where they
shouldn't be. However, this behavior can't be disabled, which makes it
harder to use
a py_binary when the non-safe path behavior is explicitly desired.

To fix, the bootstrap now respects the caller environment's
PYTHONSAFEPATH environment variable, if set. This allows the callers to
set `PYTHONSAFEPATH=` (empty string) to
override the default behavior that enables it.

Fixes https://github.com/bazelbuild/rules_python/issues/2060
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 560c04e..9ccff79 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -39,6 +39,9 @@
   containing ">" sign
 
 ### Added
+* (rules) `PYTHONSAFEPATH` is inherited from the calling environment to allow
+  disabling it (Requires {obj}`--bootstrap_impl=script`)
+  ([#2060](https://github.com/bazelbuild/rules_python/issues/2060)).
 * (gazelle) Added `python_generation_mode_per_package_require_test_entry_point`
   in order to better accommodate users who use a custom macro,
   [`pytest-bazel`][pytest_bazel], [rules_python_pytest] or `rules_py`
diff --git a/python/private/stage1_bootstrap_template.sh b/python/private/stage1_bootstrap_template.sh
index 46e33b4..895793b 100644
--- a/python/private/stage1_bootstrap_template.sh
+++ b/python/private/stage1_bootstrap_template.sh
@@ -105,7 +105,13 @@
 # Don't prepend a potentially unsafe path to sys.path
 # See: https://docs.python.org/3.11/using/cmdline.html#envvar-PYTHONSAFEPATH
 # NOTE: Only works for 3.11+
-interpreter_env+=("PYTHONSAFEPATH=1")
+# We inherit the value from the outer environment in case the user wants to
+# opt-out of using PYTHONSAFEPATH.
+# Because empty means false and non-empty means true, we have to distinguish
+# between "defined and empty" and "not defined at all".
+if [[ -z "${PYTHONSAFEPATH+x}" ]]; then
+  interpreter_env+=("PYTHONSAFEPATH=${PYTHONSAFEPATH+1}")
+fi
 
 if [[ "$IS_ZIPFILE" == "1" ]]; then
   interpreter_args+=("-XRULES_PYTHON_ZIP_DIR=$zip_dir")
diff --git a/tests/base_rules/BUILD.bazel b/tests/base_rules/BUILD.bazel
index 62d73ac..e04d314 100644
--- a/tests/base_rules/BUILD.bazel
+++ b/tests/base_rules/BUILD.bazel
@@ -51,3 +51,11 @@
     sh_src = "run_binary_zip_no_test.sh",
     target_compatible_with = _SUPPORTS_BOOTSTRAP_SCRIPT,
 )
+
+sh_py_run_test(
+    name = "inherit_pythonsafepath_env_test",
+    bootstrap_impl = "script",
+    py_src = "bin.py",
+    sh_src = "inherit_pythonsafepath_env_test.sh",
+    target_compatible_with = _SUPPORTS_BOOTSTRAP_SCRIPT,
+)
diff --git a/tests/base_rules/bin.py b/tests/base_rules/bin.py
index cffb79b..c46e43a 100644
--- a/tests/base_rules/bin.py
+++ b/tests/base_rules/bin.py
@@ -11,11 +11,14 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
-#
+
+import os
 import sys
 
 print("Hello")
 print(
     "RULES_PYTHON_ZIP_DIR:{}".format(sys._xoptions.get("RULES_PYTHON_ZIP_DIR", "UNSET"))
 )
+print("PYTHONSAFEPATH:", os.environ.get("PYTHONSAFEPATH", "UNSET") or "EMPTY")
+print("sys.flags.safe_path:", sys.flags.safe_path)
 print("file:", __file__)
diff --git a/tests/base_rules/inherit_pythonsafepath_env_test.sh b/tests/base_rules/inherit_pythonsafepath_env_test.sh
new file mode 100755
index 0000000..bf85d26
--- /dev/null
+++ b/tests/base_rules/inherit_pythonsafepath_env_test.sh
@@ -0,0 +1,51 @@
+# Copyright 2024 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# --- begin runfiles.bash initialization v3 ---
+# Copy-pasted from the Bazel Bash runfiles library v3.
+set -uo pipefail; set +e; f=bazel_tools/tools/bash/runfiles/runfiles.bash
+source "${RUNFILES_DIR:-/dev/null}/$f" 2>/dev/null || \
+  source "$(grep -sm1 "^$f " "${RUNFILES_MANIFEST_FILE:-/dev/null}" | cut -f2- -d' ')" 2>/dev/null || \
+  source "$0.runfiles/$f" 2>/dev/null || \
+  source "$(grep -sm1 "^$f " "$0.runfiles_manifest" | cut -f2- -d' ')" 2>/dev/null || \
+  source "$(grep -sm1 "^$f " "$0.exe.runfiles_manifest" | cut -f2- -d' ')" 2>/dev/null || \
+  { echo>&2 "ERROR: cannot find $f"; exit 1; }; f=; set -e
+# --- end runfiles.bash initialization v3 ---
+set +e
+
+bin=$(rlocation $BIN_RLOCATION)
+if [[ -z "$bin" ]]; then
+  echo "Unable to locate test binary: $BIN_RLOCATION"
+  exit 1
+fi
+
+
+function expect_match() {
+  local expected_pattern=$1
+  local actual=$2
+  if ! (echo "$actual" | grep "$expected_pattern" ) >/dev/null; then
+    echo "expected output to match: $expected_pattern"
+    echo "but got:\n$actual"
+    return 1
+  fi
+}
+
+
+actual=$(PYTHONSAFEPATH= $bin 2>&1)
+expect_match "sys.flags.safe_path: False" "$actual"
+expect_match "PYTHONSAFEPATH: EMPTY" "$actual"
+
+actual=$(PYTHONSAFEPATH=OUTER $bin 2>&1)
+expect_match "sys.flags.safe_path: True" "$actual"
+expect_match "PYTHONSAFEPATH: OUTER" "$actual"