Skip to content

feat: Add support for REPLs #2723

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
2 changes: 2 additions & 0 deletions .bazelrc
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,5 @@ build:rtd --enable_bzlmod
common --incompatible_python_disallow_native_rules

build --lockfile_mode=update

run:repl --//python/config_settings:bootstrap_impl=script //python/bin:repl
26 changes: 26 additions & 0 deletions python/bin/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
load("//python/private:interpreter.bzl", _interpreter_binary = "interpreter_binary")
load("//python:py_binary.bzl", "py_binary")

filegroup(
name = "distribution",
Expand All @@ -22,3 +23,28 @@ label_flag(
name = "python_src",
build_setting_default = "//python:none",
)

py_binary(
name = "repl",
srcs = ["repl.py"],
deps = [
":repl_dep",
":repl_lib_dep",
],
visibility = ["//visibility:public"],
)

# The user can modify this flag to make arbitrary libraries available for import
# on the REPL. Anything that exposes PyInfo can be used here.
label_flag(
name = "repl_dep",
build_setting_default = "//python:none",
)

# The user can modify this flag to make additional libraries available
# specifically for the purpose of interacting with the REPL. For example, point
# this at ipython in your .bazelrc file.
label_flag(
name = "repl_lib_dep",
build_setting_default = "//python:none",
)
28 changes: 28 additions & 0 deletions python/bin/repl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import os
from pathlib import Path

def start_repl():
# Simulate Python's behavior when a valid startup script is defined by the
# PYTHONSTARTUP variable. If this file path fails to load, print the error
# and revert to the default behavior.
if (startup_file := os.getenv("PYTHONSTARTUP")):
try:
source_code = Path(startup_file).read_text()
except Exception as error:
print(f"{type(error).__name__}: {error}")
else:
compiled_code = compile(source_code, filename=startup_file, mode="exec")
eval(compiled_code, {})

try:
# If the user has made ipython available somehow (e.g. via
# `repl_lib_dep`), then use it.
import IPython
IPython.start_ipython()
except ModuleNotFoundError:
# Fall back to the default shell.
import code
code.interact(local=dict(globals(), **locals()))

if __name__ == "__main__":
start_repl()
31 changes: 29 additions & 2 deletions python/private/py_library_macro.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,35 @@
"""Implementation of macro-half of py_library rule."""

load(":py_library_rule.bzl", py_library_rule = "py_library")
load(":py_binary_rule.bzl", py_binary_rule = "py_binary")

# The py_library's attributes we don't want to forward to auto-generated
# targets.
_LIBRARY_ONLY_ATTRS = [
"srcs",
"deps",
"data",
"imports",
]

# A wrapper macro is used to avoid any user-observable changes between a
# rule and macro. It also makes generator_function look as expected.
def py_library(**kwargs):
py_library_rule(**kwargs)
def py_library(name, **kwargs):
library_only_attrs = {
attr: kwargs.pop(attr, None)
for attr in _LIBRARY_ONLY_ATTRS
}
py_library_rule(
name = name,
**(library_only_attrs | kwargs)
)
py_binary_rule(
name = "%s.repl" % name,
srcs = [],
main_module = "python.bin.repl",
deps = [
":%s" % name,
"@rules_python//python/bin:repl",
],
**kwargs
)
3 changes: 3 additions & 0 deletions python/private/sentinel.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ Label attributes with defaults cannot accept None, otherwise they fall
back to using the default. A sentinel allows detecting an intended None value.
"""

load("//python:py_info.bzl", "PyInfo")

SentinelInfo = provider(
doc = "Indicates this was the sentinel target.",
fields = [],
Expand All @@ -29,6 +31,7 @@ def _sentinel_impl(ctx):
SentinelInfo(),
# Also output ToolchainInfo to allow it to be used for noop toolchains
platform_common.ToolchainInfo(),
PyInfo(transitive_sources=depset()),
]

sentinel = rule(implementation = _sentinel_impl)
4 changes: 4 additions & 0 deletions python/private/stage1_bootstrap_template.sh
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,10 @@ command=(
"$@"
)

# Point libedit/readline at the correct terminfo databases.
# https://github.com/astral-sh/python-build-standalone/blob/f0abfc9cb1f6a985fc5561cf5435f7f6e8a64e5b/docs/quirks.rst#backspace-key-doesnt-work-in-python-repl
export TERMINFO_DIRS=/etc/terminfo:/lib/terminfo:/usr/share/terminfo

# We use `exec` instead of a child process so that signals sent directly (e.g.
# using `kill`) to this process (the PID seen by the calling process) are
# received by the Python process. Otherwise, this process receives the signal
Expand Down
11 changes: 10 additions & 1 deletion python/private/stage2_bootstrap_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,16 @@ def main():
print_verbose("initial environ:", mapping=os.environ)
print_verbose("initial sys.path:", values=sys.path)

if bool(os.environ.get("RULES_PYTHON_BOOTSTRAP_REPL")):
global MAIN_PATH
global MAIN_MODULE
MAIN_PATH = ""
# TODO(philsc): Can we point at python.bin.repl instead? That would mean
# adding it as a dependency to all binaries.
MAIN_MODULE = "code"
# Prevent subprocesses from also entering the REPL.
del os.environ["RULES_PYTHON_BOOTSTRAP_REPL"]

main_rel_path = None
# todo: things happen to work because find_runfiles_root
# ends up using stage2_bootstrap, and ends up computing the proper
Expand Down Expand Up @@ -438,7 +448,6 @@ def main():
_run_py_path(main_filename, args=sys.argv[1:])
else:
_run_py_module(MAIN_MODULE)
sys.exit(0)


main()