feat: add --debugger flag (#3478)
The --debugger flag is useful for injecting a user-specified dependency
without
having to modify the binary or test. Similarly, tests now implicitly
inherit the
`PYTHONBREAKPOINT` environment variable.
The dependency is only added for the target config because build tools
can't be
intercepted for debugging.
---------
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
diff --git a/CHANGELOG.md b/CHANGELOG.md
index aceccbb..e92b3c2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -47,6 +47,27 @@
END_UNRELEASED_TEMPLATE
-->
+{#v0-0-0}
+## Unreleased
+
+[0.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.0.0
+
+{#v0-0-0-removed}
+### Removed
+* Nothing removed.
+
+{#v0-0-0-changed}
+### Changed
+* (binaries/tests) The `PYTHONBREAKPOINT` environment variable is automatically inherited
+
+{#v0-0-0-fixed}
+### Fixed
+* Nothing fixed.
+
+{#v0-0-0-added}
+### Added
+* (binaries/tests) {obj}`--debugger`: allows specifying an extra dependency
+ to add to binaries/tests for custom debuggers.
{#v1-8-0}
## [1.8.0] - 2025-12-19
@@ -2065,4 +2086,4 @@
* (pip) Create all_data_requirements alias
* Expose Python C headers through the toolchain.
-[0.24.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.24.0
\ No newline at end of file
+[0.24.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.24.0
diff --git a/docs/api/rules_python/python/config_settings/index.md b/docs/api/rules_python/python/config_settings/index.md
index 3092326..d92e7d4 100644
--- a/docs/api/rules_python/python/config_settings/index.md
+++ b/docs/api/rules_python/python/config_settings/index.md
@@ -45,6 +45,27 @@
:::
::::
+::::{bzl:flag} debugger
+A target for providing a custom debugger dependency.
+
+This flag is roughly equivalent to putting a target in `deps`. It allows
+injecting a dependency into executables (`py_binary`, `py_test`) without having
+to modify their deps. The expectation is it points to a target that provides an
+alternative debugger (pudb, winpdb, debugpy, etc).
+
+* Must provide {obj}`PyInfo`.
+* This dependency is only used for the target config, i.e. build tools don't
+ have it added.
+
+:::{note}
+Setting this flag adds the debugger dependency, but doesn't automatically set
+`PYTHONBREAKPOINT` to change `breakpoint()` behavior.
+:::
+
+:::{versionadded} VERSION_NEXT_FEATURE
+:::
+::::
+
::::{bzl:flag} experimental_python_import_all_repositories
Controls whether repository directories are added to the import path.
diff --git a/docs/howto/debuggers.md b/docs/howto/debuggers.md
new file mode 100644
index 0000000..3f75712
--- /dev/null
+++ b/docs/howto/debuggers.md
@@ -0,0 +1,66 @@
+:::{default-domain} bzl
+:::
+
+# How to integrate a debugger
+
+This guide explains how to use the {obj}`--debugger` flag to integrate a debugger
+with your Python applications built with `rules_python`.
+
+## Basic Usage
+
+The {obj}`--debugger` flag allows you to inject an extra dependency into `py_test`
+and `py_binary` targets so that they have a custom debugger available at
+runtime. The flag is roughly equivalent to manually adding it to `deps` of
+the target under test.
+
+To use the debugger, you typically provide the `--debugger` flag to your `bazel run` command.
+
+Example command line:
+
+```bash
+bazel run --@rules_python//python/config_settings:debugger=@pypi//pudb \
+ //path/to:my_python_binary
+```
+
+This will launch the Python program with the `@pypi//pudb` dependency added.
+
+The exact behavior (e.g., waiting for attachment, breaking at the first line)
+depends on the specific debugger and its configuration.
+
+:::{note}
+The specified target must be in the requirements.txt file used with
+`pip.parse()` to make it available to Bazel.
+:::
+
+## Python `PYTHONBREAKPOINT` Environment Variable
+
+For more fine-grained control over debugging, especially for programmatic breakpoints,
+you can leverage the Python built-in `breakpoint()` function and the
+`PYTHONBREAKPOINT` environment variable.
+
+The `breakpoint()` built-in function, available since Python 3.7,
+can be called anywhere in your code to invoke a debugger. The `PYTHONBREAKPOINT`
+environment variable can be set to specify which debugger to use.
+
+For example, to use `pdb` (the Python Debugger) when `breakpoint()` is called:
+
+```bash
+PYTHONBREAKPOINT=pudb.set_trace bazel run \
+ --@rules_python//python/config_settings:debugger=@pypi//pudb \
+ //path/to:my_python_binary
+```
+
+For more details on `PYTHONBREAKPOINT`, refer to the [Python documentation](https://docs.python.org/3/library/functions.html#breakpoint).
+
+## Setting a default debugger
+
+By adding settings to your user or project `.bazelrc` files, you can have
+these settings automatically added to your bazel invocations. e.g.
+
+```
+common --@rules_python//python/config_settings:debugger=@pypi//pudb
+common --test_env=PYTHONBREAKPOINT=pudb.set_trace
+```
+
+Note that `--test_env` isn't strictly necessary. The `py_test` and `py_binary`
+rules will respect the `PYTHONBREAKPOINT` environment variable in your shell.
diff --git a/python/config_settings/BUILD.bazel b/python/config_settings/BUILD.bazel
index 369989e..7060d50 100644
--- a/python/config_settings/BUILD.bazel
+++ b/python/config_settings/BUILD.bazel
@@ -102,6 +102,12 @@
visibility = ["//visibility:public"],
)
+label_flag(
+ name = "debugger",
+ build_setting_default = "//python/private:empty",
+ visibility = ["//visibility:public"],
+)
+
# For some reason, @platforms//os:windows can't be directly used
# in the select() for the flag. But it can be used when put behind
# a config_setting().
diff --git a/python/private/BUILD.bazel b/python/private/BUILD.bazel
index e92c45d..13cbfaf 100644
--- a/python/private/BUILD.bazel
+++ b/python/private/BUILD.bazel
@@ -16,6 +16,7 @@
load("@bazel_skylib//rules:common_settings.bzl", "bool_setting")
load("//python:py_binary.bzl", "py_binary")
load("//python:py_library.bzl", "py_library")
+load(":bazel_config_mode.bzl", "bazel_config_mode")
load(":print_toolchain_checksums.bzl", "print_toolchains_checksums")
load(":py_exec_tools_toolchain.bzl", "current_interpreter_executable")
load(":sentinel.bzl", "sentinel")
@@ -810,6 +811,23 @@
},
)
+config_setting(
+ name = "is_bazel_config_mode_target",
+ flag_values = {
+ "//python/private:bazel_config_mode": "target",
+ },
+)
+
+alias(
+ name = "debugger_if_target_config",
+ actual = select({
+ ":is_bazel_config_mode_target": "//python/config_settings:debugger",
+ "//conditions:default": "//python/private:empty",
+ }),
+)
+
+bazel_config_mode(name = "bazel_config_mode")
+
# This should only be set by analysis tests to expose additional metadata to
# aid testing, so a setting instead of a flag.
bool_setting(
diff --git a/python/private/bazel_config_mode.bzl b/python/private/bazel_config_mode.bzl
new file mode 100644
index 0000000..ec6be5c
--- /dev/null
+++ b/python/private/bazel_config_mode.bzl
@@ -0,0 +1,12 @@
+"""Flag to tell if exec or target mode is active."""
+
+load(":py_internal.bzl", "py_internal")
+
+def _bazel_config_mode_impl(ctx):
+ return [config_common.FeatureFlagInfo(
+ value = "exec" if py_internal.is_tool_configuration(ctx) else "target",
+ )]
+
+bazel_config_mode = rule(
+ implementation = _bazel_config_mode_impl,
+)
diff --git a/python/private/common.bzl b/python/private/common.bzl
index 19f2f39..a593e97 100644
--- a/python/private/common.bzl
+++ b/python/private/common.bzl
@@ -44,7 +44,6 @@
def create_binary_semantics_struct(
*,
get_central_uncachable_version_file,
- get_debugger_deps,
get_native_deps_dso_name,
should_build_native_deps_dso,
should_include_build_data):
@@ -57,8 +56,6 @@
get_central_uncachable_version_file: Callable that returns an optional
Artifact; this artifact is special: it is never cached and is a copy
of `ctx.version_file`; see py_builtins.copy_without_caching
- get_debugger_deps: Callable that returns a list of Targets that provide
- custom debugger support; only called for target-configuration.
get_native_deps_dso_name: Callable that returns a string, which is the
basename (with extension) of the native deps DSO library.
should_build_native_deps_dso: Callable that returns bool; True if
@@ -71,7 +68,6 @@
return struct(
# keep-sorted
get_central_uncachable_version_file = get_central_uncachable_version_file,
- get_debugger_deps = get_debugger_deps,
get_native_deps_dso_name = get_native_deps_dso_name,
should_build_native_deps_dso = should_build_native_deps_dso,
should_include_build_data = should_include_build_data,
diff --git a/python/private/common_labels.bzl b/python/private/common_labels.bzl
index e90679e..9c21198 100644
--- a/python/private/common_labels.bzl
+++ b/python/private/common_labels.bzl
@@ -8,6 +8,7 @@
ADD_SRCS_TO_RUNFILES = str(Label("//python/config_settings:add_srcs_to_runfiles")),
BOOTSTRAP_IMPL = str(Label("//python/config_settings:bootstrap_impl")),
BUILD_PYTHON_ZIP = str(Label("//python/config_settings:build_python_zip")),
+ DEBUGGER = str(Label("//python/config_settings:debugger")),
EXEC_TOOLS_TOOLCHAIN = str(Label("//python/config_settings:exec_tools_toolchain")),
PIP_ENV_MARKER_CONFIG = str(Label("//python/config_settings:pip_env_marker_config")),
NONE = str(Label("//python:none")),
diff --git a/python/private/py_executable.bzl b/python/private/py_executable.bzl
index 2e167b9..ea00eed 100644
--- a/python/private/py_executable.bzl
+++ b/python/private/py_executable.bzl
@@ -205,6 +205,10 @@
allow_single_file = True,
default = "@bazel_tools//tools/python:python_bootstrap_template.txt",
),
+ "_debugger_flag": lambda: attrb.Label(
+ default = "//python/private:debugger_if_target_config",
+ providers = [PyInfo],
+ ),
"_launcher": lambda: attrb.Label(
cfg = "target",
# NOTE: This is an executable, but is only used for Windows. It
@@ -267,17 +271,12 @@
return create_binary_semantics_struct(
# keep-sorted start
get_central_uncachable_version_file = lambda ctx: None,
- get_debugger_deps = _get_debugger_deps,
get_native_deps_dso_name = _get_native_deps_dso_name,
should_build_native_deps_dso = lambda ctx: False,
should_include_build_data = lambda ctx: False,
# keep-sorted end
)
-def _get_debugger_deps(ctx, runtime_details):
- _ = ctx, runtime_details # @unused
- return []
-
def _should_create_init_files(ctx):
if ctx.attr.legacy_create_init == -1:
return not read_possibly_native_flag(ctx, "default_to_explicit_init_py")
@@ -1025,7 +1024,7 @@
# The debugger dependency should be prevented by select() config elsewhere,
# but just to be safe, also guard against adding it to the output here.
if not _is_tool_config(ctx):
- extra_deps.extend(semantics.get_debugger_deps(ctx, runtime_details))
+ extra_deps.append(ctx.attr._debugger_flag)
cc_details = _get_cc_details_for_binary(ctx, extra_deps = extra_deps)
native_deps_details = _get_native_deps_details(
@@ -1751,6 +1750,8 @@
expression = value,
targets = ctx.attr.data,
)
+ if "PYTHONBREAKPOINT" not in inherited_environment:
+ inherited_environment = inherited_environment + ["PYTHONBREAKPOINT"]
return RunEnvironmentInfo(
environment = expanded_env,
inherited_environment = inherited_environment,
diff --git a/python/private/transition_labels.bzl b/python/private/transition_labels.bzl
index b2cf6d7..04fcecb 100644
--- a/python/private/transition_labels.bzl
+++ b/python/private/transition_labels.bzl
@@ -10,6 +10,7 @@
_BASE_TRANSITION_LABELS = [
labels.ADD_SRCS_TO_RUNFILES,
labels.BOOTSTRAP_IMPL,
+ labels.DEBUGGER,
labels.EXEC_TOOLS_TOOLCHAIN,
labels.PIP_ENV_MARKER_CONFIG,
labels.PIP_WHL_MUSLC_VERSION,
diff --git a/python/py_binary.bzl b/python/py_binary.bzl
index 80d371f..d02c3e1 100644
--- a/python/py_binary.bzl
+++ b/python/py_binary.bzl
@@ -28,6 +28,11 @@
* `srcs_version`: cannot be `PY2` or `PY2ONLY`
* `tags`: May have special marker values added, if not already present.
+ :::{versionchanged} VERSION_NEXT_FEATURE
+ The `PYTHONBREAKPOINT` environment variable is inherited. Use in combination
+ with {obj}`--debugger` to customize the debugger available and used.
+ :::
+
Args:
**attrs: Rule attributes forwarded onto the underlying {rule}`py_binary`.
"""
diff --git a/tests/base_rules/py_executable_base_tests.bzl b/tests/base_rules/py_executable_base_tests.bzl
index ed1a550..58251c6 100644
--- a/tests/base_rules/py_executable_base_tests.bzl
+++ b/tests/base_rules/py_executable_base_tests.bzl
@@ -19,6 +19,7 @@
load("@rules_testing//lib:truth.bzl", "matching")
load("@rules_testing//lib:util.bzl", rt_util = "util")
load("//python:py_executable_info.bzl", "PyExecutableInfo")
+load("//python:py_library.bzl", "py_library")
load("//python/private:common_labels.bzl", "labels") # buildifier: disable=bzl-visibility
load("//python/private:reexports.bzl", "BuiltinPyRuntimeInfo") # buildifier: disable=bzl-visibility
load("//tests/base_rules:base_tests.bzl", "create_base_tests")
@@ -170,6 +171,44 @@
"{workspace}/{package}/{test_name}_subject",
])
+def _test_debugger(name, config):
+ rt_util.helper_target(
+ py_library,
+ name = name + "_debugger",
+ srcs = [rt_util.empty_file(name + "_debugger.py")],
+ )
+
+ rt_util.helper_target(
+ config.rule,
+ name = name + "_subject",
+ srcs = [rt_util.empty_file(name + "_subject.py")],
+ config_settings = {
+ # config_settings requires a fully qualified label
+ labels.DEBUGGER: "//{}:{}_debugger".format(native.package_name(), name),
+ },
+ )
+ analysis_test(
+ name = name,
+ impl = _test_debugger_impl,
+ targets = {
+ "exec_target": name + "_subject",
+ "target": name + "_subject",
+ },
+ attrs = {
+ "exec_target": attr.label(cfg = "exec"),
+ },
+ )
+
+_tests.append(_test_debugger)
+
+def _test_debugger_impl(env, targets):
+ env.expect.that_target(targets.target).runfiles().contains_at_least([
+ "{workspace}/{package}/{test_name}_debugger.py",
+ ])
+ env.expect.that_target(targets.exec_target).runfiles().not_contains(
+ "{workspace}/{package}/{test_name}_debugger.py",
+ )
+
def _test_default_main_can_be_generated(name, config):
rt_util.helper_target(
config.rule,