Skip to content

Commit ea4714d

Browse files
philscaignas
andauthored
feat: Add support for REPLs (#2723)
This patch adds a new target that lets users invoke a REPL for a given `PyInfo` target. For example, the following command will spawn a REPL for any target that provides `PyInfo`: ```console $ bazel run --//python/config_settings:bootstrap_impl=script //python/bin:repl --//python/bin:repl_dep=//tools:wheelmaker Python 3.11.1 (main, Jan 16 2023, 22:41:20) [Clang 15.0.7 ] on linux Type "help", "copyright", "credits" or "license" for more information. (InteractiveConsole) >>> import tools.wheelmaker >>> ``` If the user wants an IPython shell instead, they can create a file like this: ```python import IPython IPython.start_ipython() ``` Then they can set this up in their `.bazelrc` file: ``` # Allow the REPL stub to import ipython. In this case, @my_deps is the name # of the pip.parse() repository. 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 ``` --------- Co-authored-by: Ignas Anikevicius <240938+aignas@users.noreply.github.com>
1 parent ee8d7d6 commit ea4714d

File tree

13 files changed

+393
-0
lines changed

13 files changed

+393
-0
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ END_UNRELEASED_TEMPLATE
102102
* (pypi) Starlark-based evaluation of environment markers (requirements.txt conditionals)
103103
available (not enabled by default) for improved multi-platform build support.
104104
Set the `RULES_PYTHON_ENABLE_PIPSTAR=1` environment variable to enable it.
105+
* (utils) Add a way to run a REPL for any `rules_python` target that returns
106+
a `PyInfo` provider.
105107

106108
{#v0-0-0-removed}
107109
### Removed

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ pip
101101
coverage
102102
precompiling
103103
gazelle
104+
REPL <repl>
104105
Extending <extending>
105106
Contributing <contributing>
106107
support

docs/repl.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Getting a REPL or Interactive Shell
2+
3+
rules_python provides a REPL to help with debugging and developing. The goal of
4+
the REPL is to present an environment identical to what a {bzl:obj}`py_binary` creates
5+
for your code.
6+
7+
## Usage
8+
9+
Start the REPL with the following command:
10+
```console
11+
$ bazel run @rules_python//python/bin:repl
12+
Python 3.11.11 (main, Mar 17 2025, 21:02:09) [Clang 20.1.0 ] on linux
13+
Type "help", "copyright", "credits" or "license" for more information.
14+
>>>
15+
```
16+
17+
Settings like `//python/config_settings:python_version` will influence the exact
18+
behaviour.
19+
```console
20+
$ bazel run @rules_python//python/bin:repl --@rules_python//python/config_settings:python_version=3.13
21+
Python 3.13.2 (main, Mar 17 2025, 21:02:54) [Clang 20.1.0 ] on linux
22+
Type "help", "copyright", "credits" or "license" for more information.
23+
>>>
24+
```
25+
26+
See [//python/config_settings](api/rules_python/python/config_settings/index)
27+
and [Environment Variables](environment-variables) for more settings.
28+
29+
## Importing Python targets
30+
31+
The `//python/bin:repl_dep` command line flag gives the REPL access to a target
32+
that provides the {bzl:obj}`PyInfo` provider.
33+
34+
```console
35+
$ bazel run @rules_python//python/bin:repl --@rules_python//python/bin:repl_dep=@rules_python//tools:wheelmaker
36+
Python 3.11.11 (main, Mar 17 2025, 21:02:09) [Clang 20.1.0 ] on linux
37+
Type "help", "copyright", "credits" or "license" for more information.
38+
>>> import tools.wheelmaker
39+
>>>
40+
```
41+
42+
## Customizing the shell
43+
44+
By default, the `//python/bin:repl` target will invoke the shell from the `code`
45+
module. It's possible to switch to another shell by writing a custom "stub" and
46+
pointing the target at the necessary dependencies.
47+
48+
### IPython Example
49+
50+
For an IPython shell, create a file as follows.
51+
52+
```python
53+
import IPython
54+
IPython.start_ipython()
55+
```
56+
57+
Assuming the file is called `ipython_stub.py` and the `pip.parse` hub's name is
58+
`my_deps`, set this up in the .bazelrc file:
59+
```
60+
# Allow the REPL stub to import ipython. In this case, @my_deps is the hub name
61+
# of the pip.parse() call.
62+
build --@rules_python//python/bin:repl_stub_dep=@my_deps//ipython
63+
64+
# Point the REPL at the stub created above.
65+
build --@rules_python//python/bin:repl_stub=//path/to:ipython_stub.py
66+
```

docs/toolchains.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -757,3 +757,13 @@ a fixed version.
757757
The `python` target does not provide access to any modules from `py_*`
758758
targets on its own. Please file a feature request if this is desired.
759759
:::
760+
761+
### Differences from `//python/bin:repl`
762+
763+
The `//python/bin:python` target provides access to the underlying interpreter
764+
without any hermeticity guarantees.
765+
766+
The [`//python/bin:repl` target](repl) provides an environment indentical to
767+
what `py_binary` provides. That means it handles things like the
768+
[`PYTHONSAFEPATH`](https://docs.python.org/3/using/cmdline.html#envvar-PYTHONSAFEPATH)
769+
environment variable automatically. The `//python/bin:python` target will not.

python/bin/BUILD.bazel

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
load("//python/private:interpreter.bzl", _interpreter_binary = "interpreter_binary")
2+
load("//python/private:repl.bzl", "py_repl_binary")
23

34
filegroup(
45
name = "distribution",
@@ -22,3 +23,35 @@ label_flag(
2223
name = "python_src",
2324
build_setting_default = "//python:none",
2425
)
26+
27+
py_repl_binary(
28+
name = "repl",
29+
stub = ":repl_stub",
30+
visibility = ["//visibility:public"],
31+
deps = [
32+
":repl_dep",
33+
":repl_stub_dep",
34+
],
35+
)
36+
37+
# The user can replace this with their own stub. E.g. they can use this to
38+
# import ipython instead of the default shell.
39+
label_flag(
40+
name = "repl_stub",
41+
build_setting_default = "repl_stub.py",
42+
)
43+
44+
# The user can modify this flag to make an interpreter shell library available
45+
# for the stub. E.g. if they switch the stub for an ipython-based one, then they
46+
# can point this at their version of ipython.
47+
label_flag(
48+
name = "repl_stub_dep",
49+
build_setting_default = "//python/private:empty",
50+
)
51+
52+
# The user can modify this flag to make arbitrary PyInfo targets available for
53+
# import on the REPL.
54+
label_flag(
55+
name = "repl_dep",
56+
build_setting_default = "//python/private:empty",
57+
)

python/bin/repl_stub.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""Simulates the REPL that Python spawns when invoking the binary with no arguments.
2+
3+
The code module is responsible for the default shell.
4+
5+
The import and `ocde.interact()` call here his is equivalent to doing:
6+
7+
$ python3 -m code
8+
Python 3.11.2 (main, Mar 13 2023, 12:18:29) [GCC 12.2.0] on linux
9+
Type "help", "copyright", "credits" or "license" for more information.
10+
(InteractiveConsole)
11+
>>>
12+
13+
The logic for PYTHONSTARTUP is handled in python/private/repl_template.py.
14+
"""
15+
16+
import code
17+
import sys
18+
19+
if sys.stdin.isatty():
20+
# Use the default options.
21+
exitmsg = None
22+
else:
23+
# On a non-interactive console, we want to suppress the >>> and the exit message.
24+
exitmsg = ""
25+
sys.ps1 = ""
26+
sys.ps2 = ""
27+
28+
# We set the banner to an empty string because the repl_template.py file already prints the banner.
29+
code.interact(banner="", exitmsg=exitmsg)

python/private/BUILD.bazel

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -817,6 +817,10 @@ current_interpreter_executable(
817817
visibility = ["//visibility:public"],
818818
)
819819

820+
py_library(
821+
name = "empty",
822+
)
823+
820824
sentinel(
821825
name = "sentinel",
822826
)

python/private/repl.bzl

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""Implementation of the rules to expose a REPL."""
2+
3+
load("//python:py_binary.bzl", _py_binary = "py_binary")
4+
5+
def _generate_repl_main_impl(ctx):
6+
stub_repo = ctx.attr.stub.label.repo_name or ctx.workspace_name
7+
stub_path = "/".join([stub_repo, ctx.file.stub.short_path])
8+
9+
out = ctx.actions.declare_file(ctx.label.name + ".py")
10+
11+
# Point the generated main file at the stub.
12+
ctx.actions.expand_template(
13+
template = ctx.file._template,
14+
output = out,
15+
substitutions = {
16+
"%stub_path%": stub_path,
17+
},
18+
)
19+
20+
return [DefaultInfo(files = depset([out]))]
21+
22+
_generate_repl_main = rule(
23+
implementation = _generate_repl_main_impl,
24+
attrs = {
25+
"stub": attr.label(
26+
mandatory = True,
27+
allow_single_file = True,
28+
doc = ("The stub responsible for actually invoking the final shell. " +
29+
"See the \"Customizing the REPL\" docs for details."),
30+
),
31+
"_template": attr.label(
32+
default = "//python/private:repl_template.py",
33+
allow_single_file = True,
34+
doc = "The template to use for generating `out`.",
35+
),
36+
},
37+
doc = """\
38+
Generates a "main" script for a py_binary target that starts a Python REPL.
39+
40+
The template is designed to take care of the majority of the logic. The user
41+
customizes the exact shell that will be started via the stub. The stub is a
42+
simple shell script that imports the desired shell and then executes it.
43+
44+
The target's name is used for the output filename (with a .py extension).
45+
""",
46+
)
47+
48+
def py_repl_binary(name, stub, deps = [], data = [], **kwargs):
49+
"""A py_binary target that executes a REPL when run.
50+
51+
The stub is the script that ultimately decides which shell the REPL will run.
52+
It can be as simple as this:
53+
54+
import code
55+
code.interact()
56+
57+
Or it can load something like IPython instead.
58+
59+
Args:
60+
name: Name of the generated py_binary target.
61+
stub: The script that invokes the shell.
62+
deps: The dependencies of the py_binary.
63+
data: The runtime dependencies of the py_binary.
64+
**kwargs: Forwarded to the py_binary.
65+
"""
66+
_generate_repl_main(
67+
name = "%s_py" % name,
68+
stub = stub,
69+
)
70+
71+
_py_binary(
72+
name = name,
73+
srcs = [
74+
":%s_py" % name,
75+
],
76+
main = "%s_py.py" % name,
77+
data = data + [
78+
stub,
79+
],
80+
deps = deps + [
81+
"//python/runfiles",
82+
],
83+
**kwargs
84+
)

python/private/repl_template.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import os
2+
import runpy
3+
import sys
4+
from pathlib import Path
5+
6+
from python.runfiles import runfiles
7+
8+
STUB_PATH = "%stub_path%"
9+
10+
11+
def start_repl():
12+
if sys.stdin.isatty():
13+
# Print the banner similar to how python does it on startup when running interactively.
14+
cprt = 'Type "help", "copyright", "credits" or "license" for more information.'
15+
sys.stderr.write("Python %s on %s\n%s\n" % (sys.version, sys.platform, cprt))
16+
17+
# Simulate Python's behavior when a valid startup script is defined by the
18+
# PYTHONSTARTUP variable. If this file path fails to load, print the error
19+
# and revert to the default behavior.
20+
#
21+
# See upstream for more information:
22+
# https://docs.python.org/3/using/cmdline.html#envvar-PYTHONSTARTUP
23+
if startup_file := os.getenv("PYTHONSTARTUP"):
24+
try:
25+
source_code = Path(startup_file).read_text()
26+
except Exception as error:
27+
print(f"{type(error).__name__}: {error}")
28+
else:
29+
compiled_code = compile(source_code, filename=startup_file, mode="exec")
30+
eval(compiled_code, {})
31+
32+
bazel_runfiles = runfiles.Create()
33+
runpy.run_path(bazel_runfiles.Rlocation(STUB_PATH), run_name="__main__")
34+
35+
36+
if __name__ == "__main__":
37+
start_repl()

tests/repl/BUILD.bazel

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
load("//python:py_library.bzl", "py_library")
2+
load("//tests/support:sh_py_run_test.bzl", "py_reconfig_test")
3+
4+
# A library that adds a special import path only when this is specified as a
5+
# dependency. This makes it easy for a dependency to have this import path
6+
# available without the top-level target being able to import the module.
7+
py_library(
8+
name = "helper/test_module",
9+
srcs = [
10+
"helper/test_module.py",
11+
],
12+
imports = [
13+
"helper",
14+
],
15+
)
16+
17+
py_reconfig_test(
18+
name = "repl_without_dep_test",
19+
srcs = ["repl_test.py"],
20+
data = [
21+
"//python/bin:repl",
22+
],
23+
env = {
24+
# The helper/test_module should _not_ be importable for this test.
25+
"EXPECT_TEST_MODULE_IMPORTABLE": "0",
26+
},
27+
main = "repl_test.py",
28+
python_version = "3.12",
29+
)
30+
31+
py_reconfig_test(
32+
name = "repl_with_dep_test",
33+
srcs = ["repl_test.py"],
34+
data = [
35+
"//python/bin:repl",
36+
],
37+
env = {
38+
# The helper/test_module _should_ be importable for this test.
39+
"EXPECT_TEST_MODULE_IMPORTABLE": "1",
40+
},
41+
main = "repl_test.py",
42+
python_version = "3.12",
43+
repl_dep = ":helper/test_module",
44+
)

tests/repl/helper/test_module.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""This is a file purely intended for validating //python/bin:repl."""
2+
3+
4+
def print_hello():
5+
print("Hello World")

0 commit comments

Comments
 (0)