Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 717
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
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
34cd838d0dce4f06cef676c2dd35fc9cdeba2be313f6dc039b0b83ab402faf566d216dac5d58c9c9c8b01d3de79d8b1125e05f9af9121abd86358908a00d32d48433b2aa2a272c6f544200e5e85f4643798064dc0ae8a8132c436e863e970b769fb144dda2fd9bed35f1a68ab520c980File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -101,6 +101,7 @@ pip | ||
| coverage | ||
| precompiling | ||
| gazelle | ||
| REPL <repl> | ||
| Extending <extending> | ||
| Contributing <contributing> | ||
| support | ||
| 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 |
|---|---|---|
| @@ -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 @@ label_flag( | ||
| 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", | ||
philsc marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| ) | ||
| # The user can modify this flag to make arbitrary PyInfo targets available for | ||
| # import on the REPL. | ||
| label_flag( | ||
| name = "repl_dep", | ||
aignas marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| build_setting_default = "//python/private:empty", | ||
| ) | ||
| 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. | ||
| """ | ||
Collaborator 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? ContributorAuthor 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 Collaborator 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.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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.
Uh oh!There was an error while loading. Please reload this page. | ||
| """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( | ||
Collaborator 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 ContributorAuthor 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.
Uh oh!
There was an error while loading. Please reload this page.