-
-
Notifications
You must be signed in to change notification settings - Fork 606
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
Changes from all commits
34cd838
d0dce4f
06cef67
6c2dd35
fc9cdeb
a2be313
f6dc039
b0b83ab
402faf5
66d216d
ac5d58c
9c9c8b0
1d3de79
d8b1125
e05f9af
9121abd
8635890
8a00d32
d48433b
2aa2a27
2c6f544
200e5e8
5f46437
98064dc
0ae8a81
32c436e
863e970
b769fb1
44dda2f
d9bed35
f1a68ab
520c980
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,66 @@ | ||
# Getting a REPL or Interactive Shell | ||
|
||
rules_python provides a REPL to help with debugging and developing. The goal of | ||
the REPL is to present an environment identical to what a {bzl:obj}`py_binary` creates | ||
for your code. | ||
|
||
## Usage | ||
|
||
Start the REPL with the following command: | ||
```console | ||
$ bazel run @rules_python//python/bin:repl | ||
Python 3.11.11 (main, Mar 17 2025, 21:02:09) [Clang 20.1.0 ] on linux | ||
Type "help", "copyright", "credits" or "license" for more information. | ||
>>> | ||
``` | ||
|
||
Settings like `//python/config_settings:python_version` will influence the exact | ||
behaviour. | ||
```console | ||
$ bazel run @rules_python//python/bin:repl --@rules_python//python/config_settings:python_version=3.13 | ||
Python 3.13.2 (main, Mar 17 2025, 21:02:54) [Clang 20.1.0 ] on linux | ||
Type "help", "copyright", "credits" or "license" for more information. | ||
>>> | ||
``` | ||
|
||
See [//python/config_settings](api/rules_python/python/config_settings/index) | ||
and [Environment Variables](environment-variables) for more settings. | ||
|
||
## Importing Python targets | ||
|
||
The `//python/bin:repl_dep` command line flag gives the REPL access to a target | ||
that provides the {bzl:obj}`PyInfo` provider. | ||
|
||
```console | ||
$ bazel run @rules_python//python/bin:repl --@rules_python//python/bin:repl_dep=@rules_python//tools:wheelmaker | ||
Python 3.11.11 (main, Mar 17 2025, 21:02:09) [Clang 20.1.0 ] on linux | ||
Type "help", "copyright", "credits" or "license" for more information. | ||
>>> import tools.wheelmaker | ||
>>> | ||
``` | ||
|
||
## Customizing the shell | ||
|
||
By default, the `//python/bin:repl` target will invoke the shell from the `code` | ||
module. It's possible to switch to another shell by writing a custom "stub" and | ||
pointing the target at the necessary dependencies. | ||
|
||
### IPython Example | ||
|
||
For an IPython shell, create a file as follows. | ||
|
||
```python | ||
import IPython | ||
IPython.start_ipython() | ||
``` | ||
|
||
Assuming the file is called `ipython_stub.py` and the `pip.parse` hub's name is | ||
`my_deps`, set this up in the .bazelrc file: | ||
``` | ||
# Allow the REPL stub to import ipython. In this case, @my_deps is the hub name | ||
# of the pip.parse() call. | ||
build --@rules_python//python/bin:repl_stub_dep=@my_deps//ipython | ||
|
||
# Point the REPL at the stub created above. | ||
build --@rules_python//python/bin:repl_stub=//path/to:ipython_stub.py | ||
``` |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
"""Simulates the REPL that Python spawns when invoking the binary with no arguments. | ||
|
||
The code module is responsible for the default shell. | ||
|
||
The import and `ocde.interact()` call here his is equivalent to doing: | ||
|
||
$ python3 -m code | ||
Python 3.11.2 (main, Mar 13 2023, 12:18:29) [GCC 12.2.0] on linux | ||
Type "help", "copyright", "credits" or "license" for more information. | ||
(InteractiveConsole) | ||
>>> | ||
|
||
The logic for PYTHONSTARTUP is handled in python/private/repl_template.py. | ||
""" | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we also copy the logic for the banner, so that users are greeted with the familiar Python version? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done. That is actually surprisingly awkward. That code is implemented a few times. At least once in the cpython startup code and once in the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry, did not realize it was a little awkward, but thanks for doing it! |
||
import code | ||
import sys | ||
|
||
if sys.stdin.isatty(): | ||
# Use the default options. | ||
exitmsg = None | ||
else: | ||
# On a non-interactive console, we want to suppress the >>> and the exit message. | ||
exitmsg = "" | ||
sys.ps1 = "" | ||
sys.ps2 = "" | ||
|
||
# We set the banner to an empty string because the repl_template.py file already prints the banner. | ||
code.interact(banner="", exitmsg=exitmsg) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,84 @@ | ||
"""Implementation of the rules to expose a REPL.""" | ||
|
||
load("//python:py_binary.bzl", _py_binary = "py_binary") | ||
|
||
def _generate_repl_main_impl(ctx): | ||
stub_repo = ctx.attr.stub.label.repo_name or ctx.workspace_name | ||
stub_path = "/".join([stub_repo, ctx.file.stub.short_path]) | ||
|
||
out = ctx.actions.declare_file(ctx.label.name + ".py") | ||
|
||
# Point the generated main file at the stub. | ||
ctx.actions.expand_template( | ||
template = ctx.file._template, | ||
output = out, | ||
substitutions = { | ||
"%stub_path%": stub_path, | ||
}, | ||
) | ||
|
||
return [DefaultInfo(files = depset([out]))] | ||
|
||
_generate_repl_main = rule( | ||
implementation = _generate_repl_main_impl, | ||
attrs = { | ||
"stub": attr.label( | ||
mandatory = True, | ||
allow_single_file = True, | ||
aignas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
doc = ("The stub responsible for actually invoking the final shell. " + | ||
"See the \"Customizing the REPL\" docs for details."), | ||
), | ||
"_template": attr.label( | ||
default = "//python/private:repl_template.py", | ||
allow_single_file = True, | ||
doc = "The template to use for generating `out`.", | ||
), | ||
}, | ||
doc = """\ | ||
Generates a "main" script for a py_binary target that starts a Python REPL. | ||
|
||
The template is designed to take care of the majority of the logic. The user | ||
customizes the exact shell that will be started via the stub. The stub is a | ||
simple shell script that imports the desired shell and then executes it. | ||
|
||
The target's name is used for the output filename (with a .py extension). | ||
""", | ||
) | ||
|
||
def py_repl_binary(name, stub, deps = [], data = [], **kwargs): | ||
philsc marked this conversation as resolved.
Show resolved
Hide resolved
|
||
"""A py_binary target that executes a REPL when run. | ||
|
||
The stub is the script that ultimately decides which shell the REPL will run. | ||
It can be as simple as this: | ||
|
||
import code | ||
code.interact() | ||
|
||
Or it can load something like IPython instead. | ||
|
||
Args: | ||
name: Name of the generated py_binary target. | ||
stub: The script that invokes the shell. | ||
deps: The dependencies of the py_binary. | ||
data: The runtime dependencies of the py_binary. | ||
**kwargs: Forwarded to the py_binary. | ||
""" | ||
_generate_repl_main( | ||
name = "%s_py" % name, | ||
stub = stub, | ||
) | ||
|
||
_py_binary( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: I remember @rickeylev mentioning to avoid There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done It did make the filename a bit weird though. Instead of |
||
name = name, | ||
srcs = [ | ||
":%s_py" % name, | ||
], | ||
main = "%s_py.py" % name, | ||
data = data + [ | ||
stub, | ||
], | ||
deps = deps + [ | ||
"//python/runfiles", | ||
], | ||
**kwargs | ||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
import os | ||
import runpy | ||
import sys | ||
from pathlib import Path | ||
|
||
from python.runfiles import runfiles | ||
|
||
STUB_PATH = "%stub_path%" | ||
|
||
|
||
def start_repl(): | ||
if sys.stdin.isatty(): | ||
# Print the banner similar to how python does it on startup when running interactively. | ||
cprt = 'Type "help", "copyright", "credits" or "license" for more information.' | ||
sys.stderr.write("Python %s on %s\n%s\n" % (sys.version, sys.platform, cprt)) | ||
|
||
# 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. | ||
# | ||
# See upstream for more information: | ||
# https://docs.python.org/3/using/cmdline.html#envvar-PYTHONSTARTUP | ||
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, {}) | ||
|
||
bazel_runfiles = runfiles.Create() | ||
runpy.run_path(bazel_runfiles.Rlocation(STUB_PATH), run_name="__main__") | ||
|
||
|
||
if __name__ == "__main__": | ||
start_repl() |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
load("//python:py_library.bzl", "py_library") | ||
load("//tests/support:sh_py_run_test.bzl", "py_reconfig_test") | ||
|
||
# A library that adds a special import path only when this is specified as a | ||
# dependency. This makes it easy for a dependency to have this import path | ||
# available without the top-level target being able to import the module. | ||
py_library( | ||
name = "helper/test_module", | ||
srcs = [ | ||
"helper/test_module.py", | ||
], | ||
imports = [ | ||
"helper", | ||
], | ||
) | ||
|
||
py_reconfig_test( | ||
name = "repl_without_dep_test", | ||
srcs = ["repl_test.py"], | ||
data = [ | ||
"//python/bin:repl", | ||
], | ||
env = { | ||
# The helper/test_module should _not_ be importable for this test. | ||
"EXPECT_TEST_MODULE_IMPORTABLE": "0", | ||
}, | ||
main = "repl_test.py", | ||
python_version = "3.12", | ||
) | ||
|
||
py_reconfig_test( | ||
name = "repl_with_dep_test", | ||
srcs = ["repl_test.py"], | ||
data = [ | ||
"//python/bin:repl", | ||
], | ||
env = { | ||
# The helper/test_module _should_ be importable for this test. | ||
"EXPECT_TEST_MODULE_IMPORTABLE": "1", | ||
}, | ||
main = "repl_test.py", | ||
python_version = "3.12", | ||
repl_dep = ":helper/test_module", | ||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
"""This is a file purely intended for validating //python/bin:repl.""" | ||
|
||
|
||
def print_hello(): | ||
print("Hello World") |
Uh oh!
There was an error while loading. Please reload this page.