Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .bazelignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ bazel/proto/
examples/large_bes
examples/slow_build
examples/cache-diff
examples/py_runnable
.claude/
63 changes: 51 additions & 12 deletions crates/aspect-cli/src/builtins/aspect/private/lib/runnable.axl
Original file line number Diff line number Diff line change
Expand Up @@ -583,12 +583,11 @@ def _spawn(ctx: TaskContext, state: struct, ep: struct, args: list[str], capture
wrappers can locate the workspace, and the four ASPECT_* variables for
tools that prefer aspect-native conventions.

When `ep.runfiles_dir` is set, also injects RUNFILES_DIR /
RUNFILES_MANIFEST_FILE / JAVA_RUNFILES so `rlocation` in sh_binary wrappers
and the bash runfiles library resolve data deps regardless of cwd, plus
RUNFILES_WORKSPACE_DIR (the cwd `bazel run` uses, `runfiles_dir/<workspace>`)
for tools that want the canonical workspace root inside runfiles without
re-deriving it from RUNFILES_DIR.
When `ep.runfiles_dir` is set, also applies the `runfiles_env(ep)` vars
(RUNFILES_DIR / JAVA_RUNFILES / RUNFILES_WORKSPACE_DIR) so `rlocation` in
sh_binary wrappers and the bash runfiles library resolve data deps regardless
of cwd, and unsets RUNFILES_MANIFEST_FILE — see `runfiles_env` for why the
manifest var must be actively removed from the spawned env.

Working directory defaults to `runfiles_dir/<workspace>` (matching `bazel run`)
when a runfiles tree is present, falling back to the aspect invocation directory
Expand All @@ -610,16 +609,56 @@ def _spawn(ctx: TaskContext, state: struct, ep: struct, args: list[str], capture
.env("ASPECT_BAZEL_ROOT_DIRECTORY", ctx.std.env.bazel_root_dir()) \
.env("ASPECT_GIT_ROOT_DIRECTORY", ctx.std.env.git_root_dir() or "") \
.args(getattr(ep, "args", []) + list(args))
if ep.runfiles_dir:
cmd = cmd \
.env("RUNFILES_DIR", ep.runfiles_dir) \
.env("JAVA_RUNFILES", ep.runfiles_dir) \
.env("RUNFILES_MANIFEST_FILE", ep.runfiles_dir + "_manifest") \
.env("RUNFILES_WORKSPACE_DIR", ep.runfiles_workspace_dir)
for k, v in runfiles_env(ep).items():
cmd = cmd.env(k, v)
Comment thread
thesayyn marked this conversation as resolved.
if capture:
cmd = cmd.stdout("piped").stderr("piped")
return cmd.spawn()

def runfiles_env(ep: struct) -> dict:
"""The RUNFILES_* environment `_spawn` exports for entrypoint `ep`.

Empty when `ep` has no materialized runfiles tree (`runfiles_dir` is None).
Otherwise RUNFILES_DIR / JAVA_RUNFILES (the runfiles root) plus
RUNFILES_WORKSPACE_DIR (the cwd `bazel run` uses, `runfiles_dir/<workspace>`,
for tools that want the canonical workspace root without re-deriving it), and
RUNFILES_MANIFEST_FILE mapped to `None` to REMOVE it from the spawned env.

RUNFILES_MANIFEST_FILE must be actively unset, not merely left unset by us.
The standard runfiles libraries resolve manifest-first, then directory:
whenever RUNFILES_MANIFEST_FILE is present they take manifest mode, which
resolves a runfile to its real (symlink-target) path rather than its in-tree
path. rules_py's `hermetic_launcher` binary then resolves the venv's
`bin/python` through the manifest to the underlying interpreter, so Python
computes `sys.prefix` from the interpreter repo, never reads the venv's
`pyvenv.cfg`, and the venv's site-packages (its third-party deps) never land
on `sys.path`. The spawned child inherits aspect-cli's own environment, so
when aspect-cli is itself launched from a Bazel binary/test (which exports
RUNFILES_MANIFEST_FILE pointing at aspect-cli's OWN manifest), simply not
adding the var isn't enough — the child would inherit the parent's manifest
and resolve the wrong tree. Mapping it to `None` (the local process wrapper
removes vars set to None) forces directory mode off RUNFILES_DIR, matching
how `bazel run` launches top-level binaries (no manifest; the launcher
self-discovers its tree from argv[0]). `--build_runfile_links` (always in
`_LOCAL_SPAWN_FLAGS`) guarantees the tree is materialized, so directory mode
always resolves.

TODO(windows): with `--noenable_runfiles` (Windows default) there is no
symlink tree, so `runfiles_dir` is None and this returns empty — a spawned
binary that doesn't self-discover its runfiles then can't resolve data deps.
The fix there is RUNFILES_MANIFEST_FILE, but setting it re-triggers the
hermetic_launcher manifest-mode breakage above, so the manifest path needs
its own (launcher-safe) handling rather than reusing this branch.
"""
if not ep.runfiles_dir:
return {}
return {
"RUNFILES_DIR": ep.runfiles_dir,
"JAVA_RUNFILES": ep.runfiles_dir,
"RUNFILES_WORKSPACE_DIR": ep.runfiles_workspace_dir,
"RUNFILES_MANIFEST_FILE": None,
}

def runnable(ctx, targets: list[str], rc = None) -> struct:
"""Build a BES event tracker that resolves entrypoints for `targets`.

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Unit tests for lib/runnable.axl: apparent_label normalization, BES matching,
entrypoint selection (passes 1–3), and runfiles directory detection."""

load("./runnable.axl", "apparent_label", "download_outputs_sufficient", "effective_download_outputs", "resolve_run_in", "runnable")
load("./runnable.axl", "apparent_label", "download_outputs_sufficient", "effective_download_outputs", "resolve_run_in", "runfiles_env", "runnable")

def _eq(label, got, want):
if got != want:
Expand Down Expand Up @@ -650,6 +650,42 @@ def _test_no_runinfo_yields_empty_args_env(ctx):
_eq("no-runinfo: env empty", ep.environment, {})
_eq("no-runinfo: inherited empty", ep.inherited_environment, [])

# runfiles_env — the RUNFILES_* env `_spawn` applies. RUNFILES_MANIFEST_FILE is
# mapped to None (actively removed): if present it forces manifest-mode
# resolution in rules_py's hermetic_launcher, which follows the venv's
# bin/python symlink to the real interpreter and drops the venv's site-packages
# (the venv's third-party deps). It must be removed, not merely left unset,
# because the spawned child inherits aspect-cli's env — an aspect-cli launched
# from a Bazel binary/test carries the parent's RUNFILES_MANIFEST_FILE.

def _spawn_ep(runfiles_dir):
"""An entrypoint struct shaped like `determine_entrypoint`'s return, with
only the fields `runfiles_env` reads populated."""
return struct(
runfiles_dir = runfiles_dir,
runfiles_workspace_dir = (runfiles_dir + "/_main") if runfiles_dir else None,
)

def _test_runfiles_env_unsets_manifest_file(ctx):
"""With a runfiles tree: RUNFILES_DIR / JAVA_RUNFILES / RUNFILES_WORKSPACE_DIR
are exported (directory mode), and RUNFILES_MANIFEST_FILE is mapped to None
so `_spawn` removes any inherited manifest — leaving it would force
hermetic_launcher's symlink-following manifest mode and break the venv."""
env = runfiles_env(_spawn_ep("/out/bin/pkg/hello.runfiles"))
if "RUNFILES_MANIFEST_FILE" not in env:
fail("runfiles_env: RUNFILES_MANIFEST_FILE must be present and mapped to None so an inherited manifest is removed")
if env["RUNFILES_MANIFEST_FILE"] != None:
fail("runfiles_env: RUNFILES_MANIFEST_FILE must be None (removes an inherited manifest); got %r" % env["RUNFILES_MANIFEST_FILE"])
_eq("runfiles_env: RUNFILES_DIR", env.get("RUNFILES_DIR"), "/out/bin/pkg/hello.runfiles")
_eq("runfiles_env: JAVA_RUNFILES", env.get("JAVA_RUNFILES"), "/out/bin/pkg/hello.runfiles")
_eq("runfiles_env: RUNFILES_WORKSPACE_DIR", env.get("RUNFILES_WORKSPACE_DIR"), "/out/bin/pkg/hello.runfiles/_main")

def _test_runfiles_env_empty_without_tree(ctx):
"""No runfiles tree (`runfiles_dir` is None) → empty dict: none of the
RUNFILES_* vars are set (and no manifest unset, since directory mode isn't
in play)."""
_eq("runfiles_env: empty without tree", runfiles_env(_spawn_ep(None)), {})

# flags — runinfo injection + the --remote_download_outputs floor.
# Exercised through `runnable(rc=...).flags`; the helpers are private.

Expand Down Expand Up @@ -876,6 +912,8 @@ _UNIT_TESTS = [
_test_runinfo_selects_executable_over_heuristic,
_test_runinfo_attaches_args_and_env,
_test_no_runinfo_yields_empty_args_env,
_test_runfiles_env_unsets_manifest_file,
_test_runfiles_env_empty_without_tree,
_test_download_floor_forced_when_unset,
_test_download_floor_forced_when_minimal,
_test_download_floor_forced_when_minimal_alias,
Expand Down
2 changes: 2 additions & 0 deletions examples/py_runnable/.bazelrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Activate this project's dependency group so @pypi//<pkg> targets are selected.
common --@pypi//dep_group=py-binary-example
1 change: 1 addition & 0 deletions examples/py_runnable/.bazelversion
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
9.0.1
13 changes: 13 additions & 0 deletions examples/py_runnable/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
load("@aspect_rules_py//py:defs.bzl", "py_binary")

# py_binary whose launcher is the hermetic_launcher native stub, with a
# third-party pypi dep in its venv — the fixture for `aspect run`
# executing a rules_py binary.
py_binary(
name = "hello",
srcs = ["hello.py"],
main = "hello.py",
deps = [
"@pypi//cowsay",
],
)
24 changes: 24 additions & 0 deletions examples/py_runnable/MODULE.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Standalone workspace: `aspect run` over a rules_py py_binary whose
launcher is the hermetic_launcher native stub."""

module(name = "py_runnable_example")

bazel_dep(name = "aspect_rules_py", version = "2.0.0-alpha.4")
bazel_dep(name = "bazel_lib", version = "3.0.0")
bazel_dep(name = "bazel_skylib", version = "1.4.2")
bazel_dep(name = "rules_python", version = "1.9.0")

interpreters = use_extension("@aspect_rules_py//py:extensions.bzl", "python_interpreters")
interpreters.toolchain(python_version = "3.11")
use_repo(interpreters, "python_interpreters")

register_toolchains("@python_interpreters//:all")

uv = use_extension("@aspect_rules_py//uv:extensions.bzl", "uv")
uv.declare_hub(hub_name = "pypi")
uv.project(
hub_name = "pypi",
lock = "//:uv.lock",
pyproject = "//:pyproject.toml",
)
use_repo(uv, "pypi")
Loading
Loading