diff --git a/CHANGELOG.md b/CHANGELOG.md index aceccbbb85..e92b3c2737 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 \ 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 3092326d6f..d92e7d404f 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 @@ 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`. +* 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 0000000000..3f75712b0f --- /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 369989eb1e..7060d50b26 100644 --- a/python/config_settings/BUILD.bazel +++ b/python/config_settings/BUILD.bazel @@ -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(). diff --git a/python/private/BUILD.bazel b/python/private/BUILD.bazel index e92c45dad4..13cbfafade 100644 --- a/python/private/BUILD.bazel +++ b/python/private/BUILD.bazel @@ -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") @@ -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( diff --git a/python/private/bazel_config_mode.bzl b/python/private/bazel_config_mode.bzl new file mode 100644 index 0000000000..ec6be5c83b --- /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 19f2f39215..a593e97558 100644 --- a/python/private/common.bzl +++ b/python/private/common.bzl @@ -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): @@ -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 @@ -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, diff --git a/python/private/common_labels.bzl b/python/private/common_labels.bzl index e90679eb6f..9c21198a62 100644 --- a/python/private/common_labels.bzl +++ b/python/private/common_labels.bzl @@ -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")), diff --git a/python/private/py_executable.bzl b/python/private/py_executable.bzl index 2e167b99ab..ea00eed17b 100644 --- a/python/private/py_executable.bzl +++ b/python/private/py_executable.bzl @@ -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 @@ -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") @@ -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( @@ -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, diff --git a/python/private/transition_labels.bzl b/python/private/transition_labels.bzl index b2cf6d7d88..04fcecb5ec 100644 --- a/python/private/transition_labels.bzl +++ b/python/private/transition_labels.bzl @@ -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, diff --git a/python/py_binary.bzl b/python/py_binary.bzl index 80d371fd4c..d02c3e105b 100644 --- a/python/py_binary.bzl +++ b/python/py_binary.bzl @@ -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`. """ diff --git a/tests/base_rules/py_executable_base_tests.bzl b/tests/base_rules/py_executable_base_tests.bzl index ed1a55021d..58251c60a0 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: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") @@ -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,