Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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 @@ -30,5 +30,6 @@ gazelle/examples/bzlmod_build_file_generation/bazel-bzlmod_build_file_generation
gazelle/examples/bzlmod_build_file_generation/bazel-out
gazelle/examples/bzlmod_build_file_generation/bazel-testlog
tests/integration/compile_pip_requirements/bazel-compile_pip_requirements
tests/integration/external_native_py_binary/bazel-external_native_py_binary
tests/integration/local_toolchains/bazel-local_toolchains
tests/integration/py_cc_toolchain_registered/bazel-py_cc_toolchain_registered
1 change: 1 addition & 0 deletions .bazelrc.deleted_packages
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ common --deleted_packages=gazelle/python/private
common --deleted_packages=tests/integration/compile_pip_requirements
common --deleted_packages=tests/integration/compile_pip_requirements_test_from_external_repo
common --deleted_packages=tests/integration/custom_commands
common --deleted_packages=tests/integration/external_native_py_binary
common --deleted_packages=tests/integration/local_toolchains
common --deleted_packages=tests/integration/pip_parse
common --deleted_packages=tests/integration/pip_parse/empty
Expand Down
32 changes: 29 additions & 3 deletions python/private/python_bootstrap_template.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,27 @@ import sys
import os
import subprocess
import uuid
import ast

# runfiles-relative path
# NOTE: The sentinel strings are split (e.g., "%stage2" + "_bootstrap%") so that
# the substitution logic won't replace them. This allows runtime detection of
# unsubstituted placeholders, which occurs when native py_binary is used in
# external repositories. In that case, we fall back to %main% which Bazel's
# native rule does substitute.
_STAGE2_BOOTSTRAP_SENTINEL = "%stage2" + "_bootstrap%"
STAGE2_BOOTSTRAP="%stage2_bootstrap%"
if STAGE2_BOOTSTRAP == _STAGE2_BOOTSTRAP_SENTINEL:
_MAIN_SENTINEL = "%main" + "%"
_main = "%main%"
if _main != _MAIN_SENTINEL and _main:
STAGE2_BOOTSTRAP = _main
else:
STAGE2_BOOTSTRAP = ""

if not STAGE2_BOOTSTRAP:
print("ERROR: %stage2_bootstrap% (or %main%) was not substituted.", file=sys.stderr)
sys.exit(1)

# runfiles-relative path to venv's python interpreter
# Empty string if a venv is not setup.
Expand All @@ -35,9 +53,17 @@ RECREATE_VENV_AT_RUNTIME="%recreate_venv_at_runtime%"
WORKSPACE_NAME = "%workspace_name%"

# Target-specific interpreter args.
INTERPRETER_ARGS = [
%interpreter_args%
]
# Sentinel split to detect unsubstituted placeholder (see STAGE2_BOOTSTRAP above).
_INTERPRETER_ARGS_SENTINEL = "%interpreter" + "_args%"
_INTERPRETER_ARGS_RAW = """%interpreter_args%""".strip()
if _INTERPRETER_ARGS_RAW and _INTERPRETER_ARGS_RAW != _INTERPRETER_ARGS_SENTINEL:
INTERPRETER_ARGS = [
ast.literal_eval(line.strip())
for line in _INTERPRETER_ARGS_RAW.splitlines()
if line.strip()
]
else:
INTERPRETER_ARGS = []

ADDITIONAL_INTERPRETER_ARGS = os.environ.get("RULES_PYTHON_ADDITIONAL_INTERPRETER_ARGS", "")

Expand Down
26 changes: 26 additions & 0 deletions python/private/stage1_bootstrap_template.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,22 @@ if [[ -n "${RULES_PYTHON_BOOTSTRAP_VERBOSE:-}" ]]; then
fi

# runfiles-relative path
# NOTE: The sentinel strings are split (e.g., "%stage2""_bootstrap%") so that
# the substitution logic won't replace them. This allows runtime detection of
# unsubstituted placeholders, which occurs when native py_binary is used in
# external repositories. In that case, we fall back to %main% which Bazel's
# native rule does substitute.
STAGE2_BOOTSTRAP_SENTINEL="%stage2""_bootstrap%"
MAIN_SENTINEL="%main""%"
Comment on lines +15 to +16
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The sentinel definitions for STAGE2_BOOTSTRAP and MAIN are quoted. When the placeholders are not substituted, the variables are assigned the unquoted placeholder string (e.g., STAGE2_BOOTSTRAP becomes %stage2_bootstrap%). This causes the comparison with the quoted sentinel (e.g., "%stage2_bootstrap%") to fail, as [[ "%stage2_bootstrap%" == "\"%stage2_bootstrap%\"" ]] evaluates to false. This breaks the intended fallback logic. To fix this, the sentinels should be defined without quotes.

Suggested change
STAGE2_BOOTSTRAP_SENTINEL="%stage2""_bootstrap%"
MAIN_SENTINEL="%main""%"
STAGE2_BOOTSTRAP_SENTINEL=%stage2""_bootstrap%
MAIN_SENTINEL=%main""%

Copy link
Author

@thomasdesr thomasdesr Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think Gemini is just wrong here?

$ echo "%stage2""_bootstrap%"
%stage2_bootstrap%

STAGE2_BOOTSTRAP="%stage2_bootstrap%"
MAIN="%main%"
if [[ "$STAGE2_BOOTSTRAP" == "$STAGE2_BOOTSTRAP_SENTINEL" ]]; then
if [[ "$MAIN" != "$MAIN_SENTINEL" && -n "$MAIN" ]]; then
STAGE2_BOOTSTRAP="$MAIN"
else
STAGE2_BOOTSTRAP=""
fi
fi

# runfiles-relative path to python interpreter to use.
# This is the `bin/python3` path in the binary's venv.
Expand Down Expand Up @@ -35,6 +50,17 @@ VENV_REL_SITE_PACKAGES="%venv_rel_site_packages%"
declare -a INTERPRETER_ARGS_FROM_TARGET=(
%interpreter_args%
)
# Sentinel split to detect unsubstituted placeholder (see STAGE2_BOOTSTRAP above).
INTERPRETER_ARGS_SENTINEL="%interpreter""_args%"
if [[ "${#INTERPRETER_ARGS_FROM_TARGET[@]}" -eq 1 &&
"${INTERPRETER_ARGS_FROM_TARGET[0]}" == "$INTERPRETER_ARGS_SENTINEL" ]]; then
INTERPRETER_ARGS_FROM_TARGET=()
fi
Comment on lines +54 to +58
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The logic to detect an unsubstituted %interpreter_args% placeholder has an edge-case bug. If a user provides a single interpreter argument that is the exact string "%interpreter_args%", it will be incorrectly identified as an unsubstituted placeholder, and the argument will be dropped. This happens because declare -a arr=(foo) and declare -a arr=("foo") are treated identically by the shell for simple strings without special characters.

A more robust approach is to use a unique sentinel for the case of empty arguments, which requires a small change in python/private/py_executable.bzl. This makes a collision with a user-provided argument much less likely.

Here is the recommended change for python/private/py_executable.bzl (in the _create_stage1_bootstrap function):

# python/private/py_executable.bzl
    _INTERPRETER_ARGS_SENTINEL_EMPTY = "__py_interpreter_args_empty_sentinel__"
    subs = {
        "%interpreter_args%": "\n".join([
            '"{}"'.format(v)
            for v in ctx.attr.interpreter_args
        ]) if ctx.attr.interpreter_args else '"{}"'.format(_INTERPRETER_ARGS_SENTINEL_EMPTY),
        # ... other substitutions
    }

With that change, the logic in this file can be updated to be more robust. Note that the sentinel for the unsubstituted case also needs to be unquoted to work correctly.

Suggested change
INTERPRETER_ARGS_SENTINEL="%interpreter""_args%"
if [[ "${#INTERPRETER_ARGS_FROM_TARGET[@]}" -eq 1 &&
"${INTERPRETER_ARGS_FROM_TARGET[0]}" == "$INTERPRETER_ARGS_SENTINEL" ]]; then
INTERPRETER_ARGS_FROM_TARGET=()
fi
INTERPRETER_ARGS_SENTINEL_UNSUBSTITUTED=%interpreter""_args%
INTERPRETER_ARGS_SENTINEL_EMPTY="__py_interpreter_args_empty_sentinel__"
if [[ "${#INTERPRETER_ARGS_FROM_TARGET[@]}" -eq 1 ]] && \
([[ "${INTERPRETER_ARGS_FROM_TARGET[0]}" == "$INTERPRETER_ARGS_SENTINEL_UNSUBSTITUTED" ]] || \
[[ "${INTERPRETER_ARGS_FROM_TARGET[0]}" == "$INTERPRETER_ARGS_SENTINEL_EMPTY" ]]); then
INTERPRETER_ARGS_FROM_TARGET=()
fi

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also seems off, if a user is intentionally passing the exact sentinel string that would be unfortunate, but probably not worth complicating the fix for?

Defer to maintainers though


if [[ -z "$STAGE2_BOOTSTRAP" ]]; then
echo >&2 "ERROR: %stage2_bootstrap% (or %main%) was not substituted."
exit 1
fi

if [[ "$IS_ZIPFILE" == "1" ]]; then
# NOTE: Macs have an old version of mktemp, so we must use only the
Expand Down
6 changes: 6 additions & 0 deletions tests/integration/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ rules_python_integration_test(
workspace_path = "compile_pip_requirements",
)

rules_python_integration_test(
name = "external_native_py_binary_workspace_test",
bzlmod = False,
workspace_path = "external_native_py_binary",
)

rules_python_integration_test(
name = "local_toolchains_test",
env = {
Expand Down
13 changes: 13 additions & 0 deletions tests/integration/external_native_py_binary/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package(default_visibility = ["//visibility:public"])

sh_test(
name = "external_native_py_binary_test",
srcs = ["external_native_py_binary_test.sh"],
data = [
"@bazel_tools//tools/bash/runfiles",
"@native_py_binary_repo//:external_native_py_binary",
],
env = {
"BIN_RLOCATION": "native_py_binary_repo/external_native_py_binary",
},
)
35 changes: 35 additions & 0 deletions tests/integration/external_native_py_binary/WORKSPACE
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
workspace(name = "external_native_py_binary")

load(
"@bazel_tools//tools/build_defs/repo:local.bzl",
"local_repository",
"new_local_repository",
)

local_repository(
name = "rules_python",
path = "../../..",
)

load("@rules_python//python:repositories.bzl", "py_repositories", "python_register_toolchains")

py_repositories()

python_register_toolchains(
name = "python_3_11",
python_version = "3.11",
)

new_local_repository(
name = "native_py_binary_repo",
path = "external_repo",
build_file_content = """
package(default_visibility = ["//visibility:public"])

py_binary(
name = "external_native_py_binary",
srcs = ["main.py"],
main = "main.py",
)
""",
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail

# --- begin runfiles.bash initialization 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; }
set -euo pipefail
# --- end runfiles.bash initialization v3 ---

bin=$(rlocation "$BIN_RLOCATION")
output="$("$bin")"
if [[ "$output" != "external-native-ok" ]]; then
echo >&2 "Expected 'external-native-ok' but got: $output"
exit 1
fi
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
print("external-native-ok")