Skip to content

Commit af14133

Browse files
authored
fix(py_venv): link complete runfiles tree (#1147)
`py_venv` contains relative `.pth` entries and symlinks that leave the venv for source files and wheels in the target's runfiles tree. A workspace symlink to only the venv changes their lexical base, so both CPython and IDEs can resolve those paths outside the declared runfiles layout. Make `py_venv_link` link the complete directory-based runfiles tree and print the nested venv path for IDE and shell configuration. This preserves the generated venv layout, keeps `.pth` entries declarative, and avoids either rewriting startup paths or adding a recursive runfiles backedge. Manifest-only launches cannot expose a directory and fail explicitly. Windows therefore requires Bazel runfiles trees. The launcher must also prefer a valid runfiles directory when Bazel supplies both sources; that behavior is implemented by tamird/hermetic-launcher@45d9b79.
1 parent 2266b52 commit af14133

8 files changed

Lines changed: 112 additions & 46 deletions

File tree

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -335,13 +335,13 @@ py_binary(
335335
with the venv activated. Useful for ad-hoc Python sessions matching your
336336
binary's deps.
337337
- `bazel run //:my_app.venv_link` — materialises a workspace-local symlink
338-
pointing at the venv tree under `bazel-bin`. **This is what your IDE should
339-
point at.**
338+
pointing at the target's complete runfiles tree and prints the venv's nested
339+
path below that link. **Point your IDE at the printed venv path.**
340340

341-
Then point your IDE to the symlinked virtualenv:
341+
Then point your IDE to the virtualenv path printed by the command:
342342

343-
- **VSCode**: Set `python.defaultInterpreterPath` to the symlinked path
344-
- **PyCharm**: Add the symlinked venv as a Python interpreter
343+
- **VSCode**: Set `python.defaultInterpreterPath` to the printed path
344+
- **PyCharm**: Add the printed venv path as a Python interpreter
345345
- **Neovim/LSP**: Configure `python-lsp-server` or `pyright` to use the virtualenv
346346

347347
### Underlying mechanism

e2e/cases/uv-include-group/BUILD.bazel

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
load("@aspect_rules_py//py:defs.bzl", "py_test", "py_unpacked_wheel")
2+
load("@rules_shell//shell:sh_test.bzl", "sh_test")
23
load("//tools:pth_snapshot.bzl", "extract_venv_pth")
34

45
# setuptools vendors `packaging` (among others), which collides at
@@ -54,12 +55,18 @@ py_test(
5455
name = "wheel_root_pth_test",
5556
srcs = ["wheel_root_pth_test.py"],
5657
dep_group = "build",
57-
expose_venv = True,
58+
expose_venv_link = True,
5859
isolated = False,
5960
main = "wheel_root_pth_test.py",
6061
deps = [":setuptools_no_top_levels"],
6162
)
6263

64+
sh_test(
65+
name = "wheel_root_pth_link_test",
66+
srcs = ["wheel_root_pth_link_test.sh"],
67+
data = [":wheel_root_pth_test.venv_link"],
68+
)
69+
6370
# Snapshot fixture: pins the `site.addsitedir(...)` line shape that
6471
# the non-keyed branch must emit. If anyone collapses it back to a
6572
# plain path entry, this diff surfaces alongside the runtime test
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
#!/usr/bin/env sh
2+
3+
set -ex
4+
5+
ROOT=$(CDPATH= cd "${0%/*}" && pwd -P)
6+
LINK_DIR=${TEST_TMPDIR}/linked
7+
mkdir "${LINK_DIR}"
8+
BAZEL_TARGET=//cases/uv-include-group:wheel_root_pth_test.venv_link \
9+
BAZEL_WORKSPACE=_main \
10+
BUILD_WORKING_DIRECTORY=${LINK_DIR} \
11+
VIRTUAL_ENV=cases/uv-include-group/.wheel_root_pth_test.venv \
12+
"${ROOT}/wheel_root_pth_test.venv_link" --name=wheel-root-pth.venv
13+
LINKED_VENV=${LINK_DIR}/wheel-root-pth.venv/_main/cases/uv-include-group/.wheel_root_pth_test.venv
14+
test -L "${LINK_DIR}/wheel-root-pth.venv"
15+
test -d "${LINKED_VENV}"
16+
"${LINKED_VENV}/bin/python" \
17+
"${ROOT}/wheel_root_pth_test.py"

py/defs.bzl

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -113,12 +113,13 @@ def py_binary(name, srcs = [], main = None, **kwargs):
113113
the hermetic interpreter).
114114
* `expose_venv_link` (bool, default `False`) — when `True`,
115115
additionally emit a `:{name}.venv_link` py_venv_link.
116-
`bazel run :{name}.venv_link` materialises a
117-
workspace-local symlink to the venv tree, suitable for an
118-
IDE's interpreter setting. Implies `expose_venv = True`;
119-
passing `expose_venv = False, expose_venv_link = True`
120-
explicitly is rejected with a clear error. Equivalent to
121-
declaring an explicit
116+
`bazel run :{name}.venv_link` links the target's runfiles
117+
tree into the workspace and prints the nested venv path
118+
suitable for an IDE's interpreter setting. Implies
119+
`expose_venv = True`; passing
120+
`expose_venv = False, expose_venv_link = True` explicitly
121+
is rejected with a clear error. Equivalent to declaring an
122+
explicit
122123
`py_venv_link(name = "{name}.venv_link", venv = ":{name}.venv")`
123124
alongside the binary.
124125
"""

py/private/py_venv/link.py

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
"""%(prog)s [options]
44
5-
Helper to create a symlink to a virtualenv in the source tree.
5+
Helper to link a target's runfiles tree into the source tree.
66
"""
77

88
import argparse
@@ -21,9 +21,28 @@ def munge_venv_name(target_package, virtualenv_name):
2121

2222

2323
if __name__ == "__main__":
24-
virtualenv_home = os.path.normpath(os.environ["VIRTUAL_ENV"])
25-
virtualenv_name = os.path.basename(virtualenv_home)
26-
runfiles_dir = os.path.normpath(os.environ["RUNFILES_DIR"])
24+
try:
25+
runfiles_dir = Path(os.path.abspath(os.environ["RUNFILES_DIR"]))
26+
except KeyError:
27+
raise SystemExit(
28+
"py_venv_link requires directory-based runfiles (RUNFILES_DIR)"
29+
) from None
30+
if not runfiles_dir.is_dir():
31+
raise SystemExit(
32+
"py_venv_link RUNFILES_DIR is not a directory: {}".format(runfiles_dir)
33+
)
34+
35+
virtualenv_relative = Path(
36+
os.environ["BAZEL_WORKSPACE"],
37+
os.environ["VIRTUAL_ENV"],
38+
)
39+
virtualenv_home = runfiles_dir / virtualenv_relative
40+
if not virtualenv_home.is_dir():
41+
raise SystemExit(
42+
"py_venv_link virtualenv is not a directory: {}".format(virtualenv_home)
43+
)
44+
45+
virtualenv_name = virtualenv_home.name
2746
builddir = os.path.normpath(os.environ["BUILD_WORKING_DIRECTORY"])
2847
target_package, target_name = os.environ["BAZEL_TARGET"].split("//", 1)[1].split(":")
2948

@@ -37,38 +56,45 @@ def munge_venv_name(target_package, virtualenv_name):
3756
"--dest",
3857
dest="dest",
3958
default=builddir,
40-
help="Dir to link the virtualenv into. Default is $BUILD_WORKING_DIRECTORY.",
59+
help="Directory in which to create the runfiles link. Default is $BUILD_WORKING_DIRECTORY.",
4160
)
4261

4362
PARSER.add_argument(
4463
"--name",
4564
dest="name",
4665
default=munge_venv_name(target_package, virtualenv_name),
47-
help="Name to link the virtualenv as.",
66+
help="Name of the workspace-local runfiles link.",
4867
)
4968

5069
opts = PARSER.parse_args()
51-
dest = Path(os.path.join(opts.dest, opts.name))
70+
runfiles_link = Path(opts.dest, opts.name)
71+
virtualenv_path = runfiles_link / virtualenv_relative
5272
print("""
5373
54-
Linking: {venv_home} -> {venv_path}
74+
Linking runfiles: {runfiles_dir} -> {runfiles_link}
75+
Virtualenv: {venv_path}
5576
""".format(
56-
venv_home = virtualenv_home,
57-
venv_path = dest,
77+
runfiles_dir = runfiles_dir,
78+
runfiles_link = runfiles_link,
79+
venv_path = virtualenv_path,
5880
))
5981

60-
if dest.exists() and dest.is_symlink() and dest.readlink() == Path(virtualenv_home):
82+
if (
83+
runfiles_link.exists()
84+
and runfiles_link.is_symlink()
85+
and runfiles_link.readlink() == runfiles_dir
86+
):
6187
print("Link is up to date!")
6288

6389
else:
6490
try:
65-
dest.lstat()
66-
dest.unlink()
91+
runfiles_link.lstat()
92+
runfiles_link.unlink()
6793
except FileNotFoundError:
6894
pass
6995

7096
# From -> to
71-
dest.symlink_to(virtualenv_home, target_is_directory=True)
97+
runfiles_link.symlink_to(runfiles_dir, target_is_directory=True)
7298
print("Link created!")
7399

74100
print("""
@@ -85,7 +111,6 @@ def munge_venv_name(target_package, virtualenv_name):
85111
virtualenvwrapper users may further want to
86112
$ ln -s {venv_path} $WORKON_HOME/{venv_name}
87113
""".format(
88-
venv_home = virtualenv_home,
89114
venv_name = opts.name,
90-
venv_path = dest,
115+
venv_path = virtualenv_path,
91116
))

py/private/py_venv/py_venv.bzl

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@
1515
interpreter.
1616
1717
- `py_venv_link` — opt-in macro that emits a runnable target whose
18-
`bazel run` materialises a workspace-local symlink to an existing
19-
`py_venv`'s tree. Pair with `py_binary(expose_venv = True)` to hand
20-
your IDE a stable `.venv` symlink to point at.
18+
`bazel run` links the target's complete runfiles tree into the
19+
workspace and prints the nested `py_venv` path. Pair with
20+
`py_binary(expose_venv = True)` to give your IDE a stable interpreter
21+
path.
2122
2223
Shared venv-assembly logic lives in
2324
`//py/private/py_venv:venv.bzl::assemble_venv`. See that file's header for the
@@ -341,10 +342,10 @@ def py_binary_with_venv(py_rule, name, main, srcs = [], deps = [], data = [], im
341342
342343
`expose_venv_link = True` additionally emits a public
343344
`:{name}.venv_link` py_venv_link. `bazel run :{name}.venv_link`
344-
materialises a workspace-local symlink at the venv's tree under
345-
`bazel-bin`, suitable for pointing an IDE at. Implies
346-
`expose_venv = True` — the link target needs a public venv to point
347-
at. Passing `expose_venv = False, expose_venv_link = True`
345+
materialises a workspace-local symlink to the target's runfiles tree and
346+
prints the venv path below that link, suitable for pointing an IDE at.
347+
Implies `expose_venv = True` — the link target needs a public venv to
348+
point at. Passing `expose_venv = False, expose_venv_link = True`
348349
explicitly is contradictory and fails.
349350
350351
All venv-shaping attrs (`deps`, `imports`, `package_collisions`,
@@ -415,21 +416,21 @@ def py_venv_link(name, venv, link_name = None, **kwargs):
415416
"""Emit a runnable target that materialises `venv` into the workspace.
416417
417418
`bazel run :<name>` creates a symlink in `$BUILD_WORKING_DIRECTORY`
418-
(typically the workspace root) that points at `venv`'s materialised
419-
venv tree in bazel-bin, so IDEs / LSPs can resolve interpreter and
420-
site-packages via a stable workspace-local path.
419+
(typically the workspace root) that points at the target's complete
420+
runfiles tree. The command prints `venv`'s nested path below that link;
421+
preserving the runfiles directory layout keeps the venv's relative paths
422+
valid for Python and IDEs. This requires directory-based runfiles; a
423+
manifest alone cannot expose a runfiles tree.
421424
422425
Args:
423426
name: Runnable target name. `bazel run :<name>` materialises
424-
the symlink; pick something like `<something>.venv` by
425-
convention so `python.defaultInterpreterPath` readers
426-
recognise it, but any label works.
427+
the runfiles symlink.
427428
venv: Label of a `py_venv` target to link. Typically the
428429
`:<binary_name>.venv` target auto-emitted by
429430
`py_binary(expose_venv = True, ...)`, or a standalone
430431
`py_venv` shared across many binaries.
431432
link_name: Workspace-relative basename for the created
432-
symlink. Defaults to a safely-escaped version of the
433+
runfiles symlink. Defaults to a safely-escaped version of the
433434
target's package + venv name.
434435
**kwargs: Forwarded to the underlying `py_binary`.
435436
"""

py/tests/py-venv-standalone-interpreter/BUILD.bazel

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
load("@rules_shell//shell:sh_test.bzl", "sh_test")
22
load("//py:defs.bzl", "py_binary")
33

4-
# Regression test: `py_binary(expose_venv = True, isolated = False)` —
4+
# Regression test: `py_binary(expose_venv_link = True, isolated = False)` —
55
# the opt-in split shape — produces a runnable venv whose interpreter
66
# honors `-I`-less semantics (PYTHONPATH, script-dir-on-sys.path,
77
# optionally user-site). This is the shape users land on when they
@@ -11,7 +11,7 @@ py_binary(
1111
srcs = [
1212
"ex.py",
1313
],
14-
expose_venv = True,
14+
expose_venv_link = True,
1515
imports = [
1616
".",
1717
],
@@ -29,5 +29,6 @@ sh_test(
2929
],
3030
data = [
3131
":ex",
32+
":ex.venv_link",
3233
],
3334
)

py/tests/py-venv-standalone-interpreter/test.sh

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,24 @@
22

33
set -ex
44

5-
ROOT="$(dirname $0)"
5+
ROOT=$(CDPATH= cd "${0%/*}" && pwd -P)
66

77
"$ROOT"/.ex.venv/bin/python --help >/dev/null 2>&1
88

9-
if [ "Hello, world!" != "$($ROOT/.ex.venv/bin/python -c 'from ex import hello; print(hello())')" ]; then
9+
if [ "Hello, world!" != "$("${ROOT}/.ex.venv/bin/python" -c 'from ex import hello; print(hello())')" ]; then
1010
exit 1
1111
fi
12+
13+
LINK_DIR=${TEST_TMPDIR}/linked
14+
mkdir "${LINK_DIR}"
15+
BAZEL_TARGET=//py/tests/py-venv-standalone-interpreter:ex.venv_link \
16+
BAZEL_WORKSPACE=_main \
17+
BUILD_WORKING_DIRECTORY=${LINK_DIR} \
18+
VIRTUAL_ENV=py/tests/py-venv-standalone-interpreter/.ex.venv \
19+
"${ROOT}/ex.venv_link" --name=ex.venv
20+
cd "${LINK_DIR}"
21+
LINKED_VENV=${LINK_DIR}/ex.venv/_main/py/tests/py-venv-standalone-interpreter/.ex.venv
22+
test -L "${LINK_DIR}/ex.venv"
23+
test -d "${LINKED_VENV}"
24+
"${LINKED_VENV}/bin/python" -c \
25+
'import cowsay, sys; from ex import hello; assert hello() == "Hello, world!"; assert sys.prefix != sys.base_prefix'

0 commit comments

Comments
 (0)