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(),
 }