Skip to content
Open
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 @@ -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
3 changes: 2 additions & 1 deletion .bazelrc.deleted_packages
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,12 @@ common --deleted_packages=gazelle/manifest/hasher
common --deleted_packages=gazelle/manifest/test
common --deleted_packages=gazelle/modules_mapping
common --deleted_packages=gazelle/python
common --deleted_packages=gazelle/pythonconfig
common --deleted_packages=gazelle/python/private
common --deleted_packages=gazelle/pythonconfig
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())
Copy link
Collaborator

Choose a reason for hiding this comment

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

Using ast to parse this is clever, but I'd rather not treat the written value as a valid parsable python expression.

Instead, change the %interpreter_args% value to encode as a newline-delimited list of values, then drop it into a string. Then just call split. e.g.

_INTERPRETER_ARGS = "%interpreter_args%".split("\n")

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""%"
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
12 changes: 12 additions & 0 deletions tests/integration/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,18 @@ rules_python_integration_test(
workspace_path = "compile_pip_requirements",
)

rules_python_integration_test(
name = "external_native_py_binary_workspace_test",
# Bazel 9+ removed native py_binary, so this test only works on older versions
bazel_versions = [
"7.4.1",
"8.0.0",
"self",
],
bzlmod = False,
workspace_path = "external_native_py_binary",
)

rules_python_integration_test(
name = "local_toolchains_test",
env = {
Expand Down
15 changes: 15 additions & 0 deletions tests/integration/external_native_py_binary/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
load("@rules_shell//shell:sh_test.bzl", "sh_test")

package(default_visibility = ["//visibility:public"])

# Test that a native py_binary from an external repo works.
# This exercises the bootstrap template's handling of unsubstituted placeholders.
sh_test(
name = "external_native_py_binary_test",
srcs = ["external_native_py_binary_test.sh"],
data = ["@native_py_binary_repo//:external_native_py_binary"],
env = {
"BIN_RLOCATION": "native_py_binary_repo/external_native_py_binary",
},
deps = ["@bazel_tools//tools/bash/runfiles"],
)
43 changes: 43 additions & 0 deletions tests/integration/external_native_py_binary/WORKSPACE
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
workspace(name = "external_native_py_binary")

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

http_archive(
name = "rules_shell",
sha256 = "3e114424a5c7e4fd43e0133cc6ecdfe54e45ae8affa14fadd839f29901424043",
strip_prefix = "rules_shell-0.4.0",
url = "https://github.com/bazelbuild/rules_shell/releases/download/v0.4.0/rules_shell-v0.4.0.tar.gz",
)

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",
build_file_content = """
package(default_visibility = ["//visibility:public"])

py_binary(
name = "external_native_py_binary",
srcs = ["main.py"],
main = "main.py",
)
""",
path = "external_repo",
)
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")