Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,27 @@ BEGIN_UNRELEASED_TEMPLATE
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
Expand Down Expand Up @@ -2065,4 +2086,4 @@ Breaking changes:
* (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
[0.24.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.24.0
21 changes: 21 additions & 0 deletions docs/api/rules_python/python/config_settings/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,27 @@ This flag replaces the Bazel builtin `--build_python_zip` flag.
:::
::::

::::{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`.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see that the debugpy can be used either as a CLI via python -m debugpy which could work well with our main_module or via import debugpy that would automatically work with this as well. To be truly useful, it would be nice to give at least one example how to setup the debugger. That can be done in a separate PR though.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does python -m debugpy do? Just start a debugpy server?

debugpy is particularly interesting because its a client/server design, so it can can actually Just Work with bazel test (where stdin is gone).

* 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.

Expand Down
66 changes: 66 additions & 0 deletions docs/howto/debuggers.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions python/config_settings/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ rp_string_flag(
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().
Expand Down
18 changes: 18 additions & 0 deletions python/private/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ load("@bazel_skylib//:bzl_library.bzl", "bzl_library")
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")
Expand Down Expand Up @@ -810,6 +811,23 @@ config_setting(
},
)

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(
Expand Down
12 changes: 12 additions & 0 deletions python/private/bazel_config_mode.bzl
Original file line number Diff line number Diff line change
@@ -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,
)
4 changes: 0 additions & 4 deletions python/private/common.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ PYTHON_FILE_EXTENSIONS = [
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):
Expand All @@ -57,8 +56,6 @@ def create_binary_semantics_struct(
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
Expand All @@ -71,7 +68,6 @@ def create_binary_semantics_struct(
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,
Expand Down
1 change: 1 addition & 0 deletions python/private/common_labels.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ labels = struct(
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")),
Expand Down
13 changes: 7 additions & 6 deletions python/private/py_executable.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,10 @@ accepting arbitrary Python versions.
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
Expand Down Expand Up @@ -267,17 +271,12 @@ def create_binary_semantics():
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")
Expand Down Expand Up @@ -1025,7 +1024,7 @@ def py_executable_base_impl(ctx, *, semantics, is_test, inherited_environment =
# 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(
Expand Down Expand Up @@ -1751,6 +1750,8 @@ def _create_run_environment_info(ctx, inherited_environment):
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,
Expand Down
1 change: 1 addition & 0 deletions python/private/transition_labels.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ load(":common_labels.bzl", "labels")
_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,
Expand Down
5 changes: 5 additions & 0 deletions python/py_binary.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ def py_binary(**attrs):
* `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`.
"""
Expand Down
39 changes: 39 additions & 0 deletions tests/base_rules/py_executable_base_tests.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ load("@rules_testing//lib:analysis_test.bzl", "analysis_test")
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")
Expand Down Expand Up @@ -170,6 +171,44 @@ def _test_executable_in_runfiles_impl(env, target):
"{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,
Expand Down
Loading