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,