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>
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9448721..a6ba65e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -102,6 +102,8 @@
* (pypi) Starlark-based evaluation of environment markers (requirements.txt conditionals)
available (not enabled by default) for improved multi-platform build support.
Set the `RULES_PYTHON_ENABLE_PIPSTAR=1` environment variable to enable it.
+* (utils) Add a way to run a REPL for any `rules_python` target that returns
+ a `PyInfo` provider.
{#v0-0-0-removed}
### Removed
diff --git a/docs/index.md b/docs/index.md
index b10b445..285b1cd 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -101,6 +101,7 @@
coverage
precompiling
gazelle
+REPL <repl>
Extending <extending>
Contributing <contributing>
support
diff --git a/docs/repl.md b/docs/repl.md
new file mode 100644
index 0000000..edcf37e
--- /dev/null
+++ b/docs/repl.md
@@ -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
+```
diff --git a/docs/toolchains.md b/docs/toolchains.md
index c8305e8..121b398 100644
--- a/docs/toolchains.md
+++ b/docs/toolchains.md
@@ -757,3 +757,13 @@
The `python` target does not provide access to any modules from `py_*`
targets on its own. Please file a feature request if this is desired.
:::
+
+### Differences from `//python/bin:repl`
+
+The `//python/bin:python` target provides access to the underlying interpreter
+without any hermeticity guarantees.
+
+The [`//python/bin:repl` target](repl) provides an environment indentical to
+what `py_binary` provides. That means it handles things like the
+[`PYTHONSAFEPATH`](https://docs.python.org/3/using/cmdline.html#envvar-PYTHONSAFEPATH)
+environment variable automatically. The `//python/bin:python` target will not.
diff --git a/python/bin/BUILD.bazel b/python/bin/BUILD.bazel
index 57bee34..30af7d1 100644
--- a/python/bin/BUILD.bazel
+++ b/python/bin/BUILD.bazel
@@ -1,4 +1,5 @@
load("//python/private:interpreter.bzl", _interpreter_binary = "interpreter_binary")
+load("//python/private:repl.bzl", "py_repl_binary")
filegroup(
name = "distribution",
@@ -22,3 +23,35 @@
name = "python_src",
build_setting_default = "//python:none",
)
+
+py_repl_binary(
+ name = "repl",
+ stub = ":repl_stub",
+ visibility = ["//visibility:public"],
+ deps = [
+ ":repl_dep",
+ ":repl_stub_dep",
+ ],
+)
+
+# The user can replace this with their own stub. E.g. they can use this to
+# import ipython instead of the default shell.
+label_flag(
+ name = "repl_stub",
+ build_setting_default = "repl_stub.py",
+)
+
+# The user can modify this flag to make an interpreter shell library available
+# for the stub. E.g. if they switch the stub for an ipython-based one, then they
+# can point this at their version of ipython.
+label_flag(
+ name = "repl_stub_dep",
+ build_setting_default = "//python/private:empty",
+)
+
+# The user can modify this flag to make arbitrary PyInfo targets available for
+# import on the REPL.
+label_flag(
+ name = "repl_dep",
+ build_setting_default = "//python/private:empty",
+)
diff --git a/python/bin/repl_stub.py b/python/bin/repl_stub.py
new file mode 100644
index 0000000..86452aa
--- /dev/null
+++ b/python/bin/repl_stub.py
@@ -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.
+"""
+
+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)
diff --git a/python/private/BUILD.bazel b/python/private/BUILD.bazel
index 0b50ccf..ce22421 100644
--- a/python/private/BUILD.bazel
+++ b/python/private/BUILD.bazel
@@ -817,6 +817,10 @@
visibility = ["//visibility:public"],
)
+py_library(
+ name = "empty",
+)
+
sentinel(
name = "sentinel",
)
diff --git a/python/private/repl.bzl b/python/private/repl.bzl
new file mode 100644
index 0000000..838166a
--- /dev/null
+++ b/python/private/repl.bzl
@@ -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,
+ 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):
+ """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(
+ name = name,
+ srcs = [
+ ":%s_py" % name,
+ ],
+ main = "%s_py.py" % name,
+ data = data + [
+ stub,
+ ],
+ deps = deps + [
+ "//python/runfiles",
+ ],
+ **kwargs
+ )
diff --git a/python/private/repl_template.py b/python/private/repl_template.py
new file mode 100644
index 0000000..0e058b2
--- /dev/null
+++ b/python/private/repl_template.py
@@ -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()
diff --git a/tests/repl/BUILD.bazel b/tests/repl/BUILD.bazel
new file mode 100644
index 0000000..62c7377
--- /dev/null
+++ b/tests/repl/BUILD.bazel
@@ -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",
+)
diff --git a/tests/repl/helper/test_module.py b/tests/repl/helper/test_module.py
new file mode 100644
index 0000000..0c4a309
--- /dev/null
+++ b/tests/repl/helper/test_module.py
@@ -0,0 +1,5 @@
+"""This is a file purely intended for validating //python/bin:repl."""
+
+
+def print_hello():
+ print("Hello World")
diff --git a/tests/repl/repl_test.py b/tests/repl/repl_test.py
new file mode 100644
index 0000000..51ca951
--- /dev/null
+++ b/tests/repl/repl_test.py
@@ -0,0 +1,74 @@
+import os
+import subprocess
+import sys
+import unittest
+from typing import Iterable
+
+from python import runfiles
+
+rfiles = runfiles.Create()
+
+# Signals the tests below whether we should be expecting the import of
+# helpers/test_module.py on the REPL to work or not.
+EXPECT_TEST_MODULE_IMPORTABLE = os.environ["EXPECT_TEST_MODULE_IMPORTABLE"] == "1"
+
+
+class ReplTest(unittest.TestCase):
+ def setUp(self):
+ self.repl = rfiles.Rlocation("rules_python/python/bin/repl")
+ assert self.repl
+
+ def run_code_in_repl(self, lines: Iterable[str]) -> str:
+ """Runs the lines of code in the REPL and returns the text output."""
+ return subprocess.check_output(
+ [self.repl],
+ text=True,
+ stderr=subprocess.STDOUT,
+ input="\n".join(lines),
+ ).strip()
+
+ def test_repl_version(self):
+ """Validates that we can successfully execute arbitrary code on the REPL."""
+
+ result = self.run_code_in_repl(
+ [
+ "import sys",
+ "v = sys.version_info",
+ "print(f'version: {v.major}.{v.minor}')",
+ ]
+ )
+ self.assertIn("version: 3.12", result)
+
+ def test_cannot_import_test_module_directly(self):
+ """Validates that we cannot import helper/test_module.py since it's not a direct dep."""
+ with self.assertRaises(ModuleNotFoundError):
+ import test_module
+
+ @unittest.skipIf(
+ not EXPECT_TEST_MODULE_IMPORTABLE, "test only works without repl_dep set"
+ )
+ def test_import_test_module_success(self):
+ """Validates that we can import helper/test_module.py when repl_dep is set."""
+ result = self.run_code_in_repl(
+ [
+ "import test_module",
+ "test_module.print_hello()",
+ ]
+ )
+ self.assertIn("Hello World", result)
+
+ @unittest.skipIf(
+ EXPECT_TEST_MODULE_IMPORTABLE, "test only works without repl_dep set"
+ )
+ def test_import_test_module_failure(self):
+ """Validates that we cannot import helper/test_module.py when repl_dep isn't set."""
+ result = self.run_code_in_repl(
+ [
+ "import test_module",
+ ]
+ )
+ self.assertIn("ModuleNotFoundError: No module named 'test_module'", result)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/support/sh_py_run_test.bzl b/tests/support/sh_py_run_test.bzl
index 9c8134f..f6ebc50 100644
--- a/tests/support/sh_py_run_test.bzl
+++ b/tests/support/sh_py_run_test.bzl
@@ -38,6 +38,8 @@
settings["//command_line_option:extra_toolchains"] = attr.extra_toolchains
if attr.python_src:
settings["//python/bin:python_src"] = attr.python_src
+ if attr.repl_dep:
+ settings["//python/bin:repl_dep"] = attr.repl_dep
if attr.venvs_use_declare_symlink:
settings["//python/config_settings:venvs_use_declare_symlink"] = attr.venvs_use_declare_symlink
if attr.venvs_site_packages:
@@ -47,6 +49,7 @@
_RECONFIG_INPUTS = [
"//python/config_settings:bootstrap_impl",
"//python/bin:python_src",
+ "//python/bin:repl_dep",
"//command_line_option:extra_toolchains",
"//python/config_settings:venvs_use_declare_symlink",
"//python/config_settings:venvs_site_packages",
@@ -70,6 +73,7 @@
""",
),
"python_src": attrb.Label(),
+ "repl_dep": attrb.Label(),
"venvs_site_packages": attrb.String(),
"venvs_use_declare_symlink": attrb.String(),
}