bazel-contrib / bazel-contrib/rules_python
py_binary: ambient `PYTHONPATH` shadows runfiles packages under `bazel run`
- Dominant language
- Starlark
- Stars
- 688
- Forks
- 721
- Avg merge
- 14h 49m
- Merged PRs (30d)
- 81
Description
# 🐞 bug report
### Affected Rule
`py_binary` (via `bazel run`), for both `--bootstrap_impl=script` and `--bootstrap_impl=system_python`.
The fault lives in the shared `py_binary`/`py_test` stage1/stage2 launcher, so `py_test` is affected in principle too, but only when a hostile `PYTHONPATH` is explicitly forwarded with `--test_env`, and a default `bazel test` does not trip it (see Scope below).
### Is this a regression?
No.
This has been the behavior since `PYTHONSAFEPATH` was adopted as the launcher's only `sys.path` mitigation (the fix for #382).
Ambient `PYTHONPATH` shadowing was never addressed.
### Description
An ambient `PYTHONPATH` in the environment that invokes `bazel run` is honored by the launched interpreter and prepended to `sys.path` **ahead of the program's own runfiles**.
When a directory on that path shares a top-level package name with a runfiles package, it shadows it as a [PEP 420 namespace package](https://peps.python.org/pep-0420/), and the program fails to import its own declared dependencies.
A target thus resolves imports against the developer's shell state instead of its declared deps, which contradicts Bazel's "depend only on declared inputs" contract.
This is especially easy to trigger because a leading (or trailing, or doubled) `:` in `PYTHONPATH` is interpreted by CPython as the current working directory, and under `bazel run` the cwd is the workspace root, which routinely contains directories whose names collide with common top-level packages (`pkg`, `lib`, `src`, etc.).
Concretely, this breaks `rules_pkg`'s install script.
It is enough for a developer's `PYTHONPATH` to merely *begin* with a colon (e.g. `:/home/dev/another-project/src`, which is what happens when some unrelated tool prepends an entry): that leading empty entry resolves to the current working directory, which under `bazel run` is the workspace root.
The workspace root may contain a top-level `pkg/` directory, a very common monorepo layout (Go uses it by convention, but it is widespread beyond Go and has nothing to do with Python here).
Because the leading colon placed the cwd on `sys.path` ahead of the runfiles, `import pkg` binds to that `pkg/` as a namespace package, and `pkg.private` (shipped by `rules_pkg`) is no longer importable (see the traceback below).
#### Root cause
The stage-1 launcher sets `PYTHONSAFEPATH=1`, which removes the auto-prepended `sys.path[0]` (the script directory / cwd), the fix for #382, but `-P` / `PYTHONSAFEPATH` does **not** stop CPython from honoring `PYTHONPATH` entries:
https://github.com/bazel-contrib/rules_python/blob/e7d137877d4fba2cb58cd4188b7af480e211ef5b/python/private/stage1_bootstrap_template.sh#L263-L275
#### The asymmetry
`rules_python` already runs its **own** interpreter invocations in isolated mode (`-I`, which implies `-E -P -s`) precisely so that userspace variables such as `PYTHONPATH` cannot interfere:
https://github.com/bazel-contrib/rules_python/blob/e7d137877d4fba2cb58cd4188b7af480e211ef5b/python/private/toolchains_repo.bzl#L371-L374
https://github.com/bazel-contrib/rules_python/blob/e7d137877d4fba2cb58cd4188b7af480e211ef5b/python/private/pypi/whl_library.bzl#L121-L124
https://github.com/bazel-contrib/rules_python/blob/e7d137877d4fba2cb58cd4188b7af480e211ef5b/python/private/runtime_env_repo.bzl#L23-L30
User binaries get only `-P`, and the gap is not yet documented, so a user has no way to discover that `bazel run` may quietly resolve imports against their shell environment instead of the target's declared deps.
#### Scope: `bazel run` vs `bazel test`
`py_binary` and `py_test` share the same stage1/stage2 launcher, so the flaw is in principle common to both.
In practice it surfaces under `bazel run` but **not** under a default `bazel test`, for two independent reasons:
- `bazel run` inherits the full client environment, so the ambient `PYTHONPATH` reaches the interpreter as `bazel test` does **not** propagate it by default (the test process sees `PYTHONPATH=None` unless `--test_env=PYTHONPATH` is passed),
- `bazel run`'s cwd is the workspace root (so a leading-`:` empty entry resolves to it, where the stray `pkg/` lives), whereas a test's cwd is its runfiles tree, i.e. the correct layout.
A `py_test` can still be made to fail by explicitly forwarding a hostile `PYTHONPATH` via `--test_env=PYTHONPATH=/dir/containing/pkg` (the entry does land in `sys.path` ahead of the runfiles), so the launcher fix matters for both, even though only `bazel run` trips it implicitly.
#### Potential resolution
1. make runfiles win over ambient `PYTHONPATH`: order `sys.path` in the stage-2 bootstrap so the program's runfiles / venv site-packages precede any ambient `PYTHONPATH` entries.
This kills the shadowing of a target's declared dependencies without changing how much of the environment the interpreter otherwise honors, and without propagating anything to child processes, so it sidesteps the tension in #2060 / #2122 (where users wanted *less* stickiness, not more).
This would be a minimal correctness fix.
2. discoverable documentation, e.g. a note in the bootstrap / environment-variables docs explaining that `script` and `system_python` launchers honor ambient `PYTHON*` variables (notably `PYTHONPATH`), that this can shadow a target's runfiles, and that the interpreter can be hardened via `interpreter_args = ["-I"]` or `RULES_PYTHON_ADDITIONAL_INTERPRETER_ARGS=-I`.
The `stage2` header comment already documents the `sys.path[0]` / `PYTHONSAFEPATH` story but is silent on `PYTHONPATH`.
The strongest alternative would be to default the launcher to `-I` instead of `-P`, converging on Bazel's hermeticity expectation, but one has to keep in mind that `-I` implies `-E`, thus ignoring **all** `PYTHON*` variables, which conflicts with the direction of #2060 / #2122.
A default flip would also need a real opt-out:
- the existing `RULES_PYTHON_ADDITIONAL_INTERPRETER_ARGS` is *additive*, so an empty value cannot cancel a hardcoded `-I`,
- the variable would have to become an *override* of the default isolation args, which is itself a breaking change.
## 🔬 Minimal Reproduction
1. A `py_binary` whose runfiles contain a top-level package `foo` (with a submodule `foo.bar`).
2. A directory named `foo/` in the workspace root that is **not** that package (e.g. a non-Python directory, or a `foo/` without `bar.py`).
3. Run with an empty `PYTHONPATH` entry:
```
PYTHONPATH=: bazel run //path/to:app
```
4. Observe `ModuleNotFoundError: No module named 'foo.bar'`: the workspace-root `foo/` shadows the runfiles `foo/`.
Clearing the variable (`PYTHONPATH= bazel run ...`) or isolating the interpreter (`RULES_PYTHON_ADDITIONAL_INTERPRETER_ARGS=-I`) makes it pass.
## 🔥 Exception or Error
INFO: Running command line: bazel-bin/pkg/sth/install <args omitted>
Traceback (most recent call last):
...
File ".../install.runfiles/_main/pkg/sth/install_install_script.py", line 29, in <module>
from pkg.private import manifest
ModuleNotFoundError: No module named 'pkg.private'
## 🌍 Your Environment
**Operating System:**
Linux
macOS
Windows
**Output of `bazel version`:**
Bazelisk version: development
Build label: 9.1.1
Build target: @@//src/main/java/com/google/devtools/build/lib/bazel:BazelServer
Build time: Wed Jun 03 15:41:13 2026 (1780501273)
Build timestamp: 1780501273
Build timestamp as int: 1780501273
**Rules_python version:**
2.0.3
**Anything else relevant?**
Prior art:
- #382 (`sys.path[0]`, fixed via `PYTHONSAFEPATH`),
- #2060 (opt-out of `PYTHONSAFEPATH`),
- #2122 (closed PR moving env to `-P`),
- #3437 (subprocess module resolution under `system_python`).
Contributor guide
Assessment
This issue has not been assessed yet.