From 117b6cbd60ec6f10b217e9b272489312e30be483 Mon Sep 17 00:00:00 2001 From: Armando Montanez Date: Mon, 3 Aug 2026 10:08:03 -0700 Subject: [PATCH] Make __init__.py generation configurable module-wide In #3841, a warning pushing users to migrate away from implicit `__init__.py` generation was added. While this is very nice to have, it forces users to either explicitly configure this option on every `py_binary` and `py_test` target, or configure the option globally in their `.bazelrc`. To better facilitate a migration, this change introduces a mechanism for modules to configure this option module-wide. This has multiple benefits: 1. Everyone working in the module doesn't need to remember to explicitly set `legacy_create_init` on every target. 2. Everyone that depends on the module receives the correct behavior as configured by the module. 3. It becomes possible to tell BCR-wide which modules have adopted this migration. Work towards #2945 --- examples/bzlmod/MODULE.bazel | 4 ++ examples/bzlmod/other_module/MODULE.bazel | 4 ++ examples/bzlmod/tests/BUILD.bazel | 14 ++++++ python/extensions/config.bzl | 39 ++++++++++++++++ python/private/internal_config_repo.bzl | 4 ++ python/private/py_executable.bzl | 37 +++++++++++---- tests/explicit_init_py/BUILD.bazel | 6 +++ tests/modules/other/BUILD.bazel | 26 +++++++++++ tests/modules/other/MODULE.bazel | 13 ++++++ tests/modules/other/ext.bzl | 37 +++++++++++++++ tests/support/explicit_init_py_test.bzl | 56 +++++++++++++++++++++++ 11 files changed, 230 insertions(+), 10 deletions(-) create mode 100644 tests/explicit_init_py/BUILD.bazel create mode 100644 tests/modules/other/ext.bzl create mode 100644 tests/support/explicit_init_py_test.bzl diff --git a/examples/bzlmod/MODULE.bazel b/examples/bzlmod/MODULE.bazel index 106f25134e..849a3b8106 100644 --- a/examples/bzlmod/MODULE.bazel +++ b/examples/bzlmod/MODULE.bazel @@ -22,6 +22,10 @@ bazel_dep(name = "rules_java", version = "8.16.1") # were fixed. bazel_dep(name = "rules_rust", version = "0.67.0") +# Adopt migration away from legacy __init__.py generation. +rules_python_config = use_extension("@rules_python//python/extensions:config.bzl", "config") +rules_python_config.use_explicit_init_py(enabled = True) + # We next initialize the python toolchain using the extension. # You can set different Python versions in this block. python = use_extension("@rules_python//python/extensions:python.bzl", "python") diff --git a/examples/bzlmod/other_module/MODULE.bazel b/examples/bzlmod/other_module/MODULE.bazel index a128c39ca0..6f2f9e16fa 100644 --- a/examples/bzlmod/other_module/MODULE.bazel +++ b/examples/bzlmod/other_module/MODULE.bazel @@ -10,6 +10,10 @@ local_path_override( path = "../../..", ) +# Adopt migration away from legacy __init__.py generation. +rules_python_config = use_extension("@rules_python//python/extensions:config.bzl", "config") +rules_python_config.use_explicit_init_py(enabled = True) + python = use_extension("@rules_python//python/extensions:python.bzl", "python") python.defaults( # In a submodule this is ignored diff --git a/examples/bzlmod/tests/BUILD.bazel b/examples/bzlmod/tests/BUILD.bazel index 4650fb8788..a4e3ee55be 100644 --- a/examples/bzlmod/tests/BUILD.bazel +++ b/examples/bzlmod/tests/BUILD.bazel @@ -1,6 +1,7 @@ load("@pythons_hub//:versions.bzl", "MINOR_MAPPING") load("@rules_python//python:py_binary.bzl", "py_binary") load("@rules_python//python:py_test.bzl", "py_test") +load("@rules_python//tests/support:explicit_init_py_test.bzl", "explicit_init_py_test") load("@rules_shell//shell:sh_test.bzl", "sh_test") py_binary( @@ -192,3 +193,16 @@ sh_test( "VERSION_PY_BINARY": "$(rootpaths :version_3_10)", }, ) + +explicit_init_py_test( + name = "explicit_init_py_no_generation_test", + expect_generated_init = False, + main = "version.py", +) + +explicit_init_py_test( + name = "legacy_create_init_override_test", + expect_generated_init = True, + legacy_create_init = 1, + main = "version.py", +) diff --git a/python/extensions/config.bzl b/python/extensions/config.bzl index f19a07aaef..ca5c462c21 100644 --- a/python/extensions/config.bzl +++ b/python/extensions/config.bzl @@ -22,9 +22,41 @@ to repositories that are expensive to create or invalidate frequently. }, ) +_use_explicit_init_py = tag_class( + doc = """ +Require explicit `__init__.py` files in this module. + +Disables the legacy `__init__.py` generation for all `py_*` targets in this +module, requiring all Python targets to explicitly provide `__init__.py` files +when they're needed. + +To override this at a per-target level, set `legacy_create_init` on applicable +`py_binary` or `py_test` targets: + +```starlark +py_binary( + name = "hello_python", + # ... + # This Binary still relies on legacy behavior, so + # enable the legacy behavior as an exceptional case. + legacy_create_init = 1, +) +``` + +:::{note} +In the future, this will be enabled by default. +::: +""", + attrs = { + "enabled": attr.bool(doc = "Whether this feature is enabled.", mandatory = True), + }, +) + def _config_impl(module_ctx): transition_setting_generators = {} transition_settings = [] + explicit_init_py_modules = {} + is_root = True for mod in module_ctx.modules: for tag in mod.tags.add_transition_setting: setting = str(tag.setting) @@ -32,11 +64,17 @@ def _config_impl(module_ctx): transition_setting_generators[setting] = [] transition_settings.append(setting) transition_setting_generators[setting].append(mod.name) + for tag in mod.tags.use_explicit_init_py: + explicit_init_py_modules[mod.name] = str(tag.enabled) + if is_root: + explicit_init_py_modules[""] = str(tag.enabled) + is_root = False internal_config_repo( name = "rules_python_internal", transition_setting_generators = transition_setting_generators, transition_settings = transition_settings, + explicit_init_py_modules = explicit_init_py_modules, ) pypi_deps() @@ -55,5 +93,6 @@ config = module_extension( implementation = _config_impl, tag_classes = { "add_transition_setting": _add_transition_setting, + "use_explicit_init_py": _use_explicit_init_py, }, ) diff --git a/python/private/internal_config_repo.bzl b/python/private/internal_config_repo.bzl index 8ee6a2d017..0630eb5c2f 100644 --- a/python/private/internal_config_repo.bzl +++ b/python/private/internal_config_repo.bzl @@ -37,6 +37,7 @@ config = struct( BuiltinPyInfo = getattr(getattr(native, "legacy_globals", None), "PyInfo", {builtin_py_info_symbol}), BuiltinPyRuntimeInfo = getattr(getattr(native, "legacy_globals", None), "PyRuntimeInfo", {builtin_py_runtime_info_symbol}), BuiltinPyCcLinkParamsProvider = getattr(getattr(native, "legacy_globals", None), "PyCcLinkParamsProvider", {builtin_py_cc_link_params_provider}), + modules_using_explicit_initpy = {modules_using_explicit_initpy}, ) """ @@ -100,6 +101,7 @@ def _internal_config_repo_impl(rctx): builtin_py_info_symbol = "PyInfo" builtin_py_runtime_info_symbol = "PyRuntimeInfo" builtin_py_cc_link_params_provider = "PyCcLinkParamsProvider" + explicit_init_py_modules = {k: str(v) == "True" for k, v in rctx.attr.explicit_init_py_modules.items()} rctx.file("rules_python_config.bzl", _CONFIG_TEMPLATE.format( build_python_zip_default = repo_utils.get_platforms_os_name(rctx) == "windows", @@ -109,6 +111,7 @@ def _internal_config_repo_impl(rctx): supports_whl_extraction = str(supports_whl_extraction), extract_needs_chmod = str(extract_needs_chmod), builtin_py_cc_link_params_provider = builtin_py_cc_link_params_provider, + modules_using_explicit_initpy = str(explicit_init_py_modules), bazel_8_or_later = str(bazel_major_version >= 8), bazel_9_or_later = str(bazel_major_version >= 9), bazel_10_or_later = str(bazel_major_version > 9), @@ -140,6 +143,7 @@ internal_config_repo = repository_rule( configure = True, environ = [], attrs = { + "explicit_init_py_modules": attr.string_dict(), "transition_setting_generators": attr.string_list_dict(), "transition_settings": attr.string_list(), }, diff --git a/python/private/py_executable.bzl b/python/private/py_executable.bzl index 2ac2423b86..b2f0da3c5f 100644 --- a/python/private/py_executable.bzl +++ b/python/private/py_executable.bzl @@ -120,9 +120,10 @@ Whether to implicitly create empty `__init__.py` files in the runfiles tree. These are created in every directory containing Python source code or shared libraries, and every parent directory of those directories, excluding the repo root directory. The default, `-1` (auto), means true unless -`--incompatible_default_to_explicit_init_py` is used. If false, the user is -responsible for creating (possibly empty) `__init__.py` files and adding them to -the `srcs` of Python targets as required. +`--incompatible_default_to_explicit_init_py` or the `use_explicit_init_py` +module configuration option are used. If false, the user is responsible for +creating (possibly empty) `__init__.py` files and adding them to the `srcs` of +Python targets as required. """, ), # TODO(b/203567235): In the Java impl, any file is allowed. While marked @@ -284,11 +285,23 @@ def create_binary_semantics(): ) 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") - else: + # Each target has the first say in this setting. + if ctx.attr.legacy_create_init != -1: return bool(ctx.attr.legacy_create_init) + # Check if it's configured by a module extension. + canonical_name = ctx.label.repo_name + for sep in ("+", "~"): + if canonical_name.startswith(sep): + canonical_name = "" + module_name = canonical_name.rstrip(sep) if sep not in canonical_name else canonical_name.split(sep)[0] + module_configured_explicit_initpy = rp_config.modules_using_explicit_initpy.get(module_name, None) + if module_configured_explicit_initpy != None: + return not module_configured_explicit_initpy + + # Fall back to CLI setting. + return not read_possibly_native_flag(ctx, "default_to_explicit_init_py") + def _create_executable( ctx, *, @@ -1584,11 +1597,15 @@ WARNING: Target {} is using implicit __init__.py creation. See https://github.com/bazel-contrib/rules_python/issues/2945 Ensure all __init__.py files are explicitly created and - added to the srcs or deps of your targets. + added to the srcs or deps of your targets, then enable this + setting in your MODULE.bazel: + + rules_python_config = use_extension("@rules_python//python/extensions:config.bzl", "config") + rules_python_config.use_explicit_init_py(enabled = True) + + If this warning is coming from an external module, you can configure this + globally with the following Bazel flag: - Disable implicit creation by setting: - legacy_create_init = 0 - on the target, or globally by setting: --incompatible_default_to_explicit_init_py ====================================================================== """.rstrip().format(ctx.label), diff --git a/tests/explicit_init_py/BUILD.bazel b/tests/explicit_init_py/BUILD.bazel new file mode 100644 index 0000000000..9ea71dc09e --- /dev/null +++ b/tests/explicit_init_py/BUILD.bazel @@ -0,0 +1,6 @@ +load("//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED") # buildifier: disable=bzl-visibility + +test_suite( + name = "explicit_init_py", + tests = ["@other//:explicit_init_py_tests"] if BZLMOD_ENABLED else [], +) diff --git a/tests/modules/other/BUILD.bazel b/tests/modules/other/BUILD.bazel index 665049b9f5..2ce7c88325 100644 --- a/tests/modules/other/BUILD.bazel +++ b/tests/modules/other/BUILD.bazel @@ -1,4 +1,5 @@ load("@rules_python//python:py_binary.bzl", "py_binary") +load("@rules_python//tests/support:explicit_init_py_test.bzl", "explicit_init_py_test") load("@rules_python//tests/support:py_reconfig.bzl", "py_reconfig_binary") package( @@ -28,3 +29,28 @@ py_binary( "//nspkg_gamma", ], ) + +explicit_init_py_test( + name = "test_module_dep_no_init", + expect_generated_init = False, + main = "external_main.py", +) + +explicit_init_py_test( + name = "test_legacy_create_init_override", + expect_generated_init = True, + legacy_create_init = 1, + main = "external_main.py", +) + +# These tests are run by @rules_python//tests/explicit_init_py to ensure a +# module's configuration is respected when it's a dependency. +test_suite( + name = "explicit_init_py_tests", + tests = [ + ":test_legacy_create_init_override", + ":test_module_dep_no_init", + "@init_py_test_extension_repo//:test", + "@init_py_test_repo//:test", + ], +) diff --git a/tests/modules/other/MODULE.bazel b/tests/modules/other/MODULE.bazel index 11a633d56b..3b5a769ec8 100644 --- a/tests/modules/other/MODULE.bazel +++ b/tests/modules/other/MODULE.bazel @@ -3,3 +3,16 @@ module(name = "other") bazel_dep(name = "rules_python", version = "0") bazel_dep(name = "bazel_skylib", version = "1.7.1") bazel_dep(name = "another_module", version = "0") + +# Validate behavior of use_explicit_init_py configuration extension when used by +# a module that's a dependency. +rules_python_config = use_extension("@rules_python//python/extensions:config.bzl", "config") +rules_python_config.use_explicit_init_py(enabled = True) + +init_py_test_repo = use_repo_rule("//:ext.bzl", "init_py_test_repo") + +init_py_test_repo(name = "init_py_test_repo") + +other_ext = use_extension("//:ext.bzl", "other_init_py_test_ext") +other_ext.repo(name = "init_py_test_extension_repo") +use_repo(other_ext, "init_py_test_extension_repo") diff --git a/tests/modules/other/ext.bzl b/tests/modules/other/ext.bzl new file mode 100644 index 0000000000..709ecd79a0 --- /dev/null +++ b/tests/modules/other/ext.bzl @@ -0,0 +1,37 @@ +"""Module extension declared by 'other' module for testing __init__.py generation.""" + +_BUILD_FILE_CONTENT = """\ +load("@rules_python//tests/support:explicit_init_py_test.bzl", "explicit_init_py_test") + +explicit_init_py_test( + name = "test", + main = "main.py", + expect_generated_init = False, +) +""" + +_MAIN_PY_CONTENT = "print('hello, world')" + +def _repo_impl(ctx): + ctx.file("main.py", _MAIN_PY_CONTENT) + ctx.file("BUILD.bazel", _BUILD_FILE_CONTENT) + +init_py_test_repo = repository_rule(implementation = _repo_impl) + +def _other_init_py_test_ext_impl(module_ctx): + for mod in module_ctx.modules: + for tag in mod.tags.repo: + init_py_test_repo(name = tag.name) + +_repo_tag = tag_class( + attrs = { + "name": attr.string(mandatory = True), + }, +) + +other_init_py_test_ext = module_extension( + implementation = _other_init_py_test_ext_impl, + tag_classes = { + "repo": _repo_tag, + }, +) diff --git a/tests/support/explicit_init_py_test.bzl b/tests/support/explicit_init_py_test.bzl new file mode 100644 index 0000000000..4765b735a4 --- /dev/null +++ b/tests/support/explicit_init_py_test.bzl @@ -0,0 +1,56 @@ +"""Parameterized analysis test for __init__.py generation behavior.""" + +load("@bazel_skylib//lib:unittest.bzl", "analysistest", "asserts") +load("//python:py_binary.bzl", "py_binary") +load("//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED") # buildifier: disable=bzl-visibility + +def _explicit_init_py_test_impl(ctx): + env = analysistest.begin(ctx) + target = analysistest.target_under_test(env) + empty_filenames = target[DefaultInfo].default_runfiles.empty_filenames.to_list() + init_pys = [f for f in empty_filenames if f.endswith("__init__.py")] + + if ctx.attr.expect_generated_init: + asserts.true(env, len(init_pys) > 0, "Expected __init__.py to be generated") + else: + asserts.true(env, len(init_pys) == 0, "Expected __init__.py to NOT be generated") + + return analysistest.end(env) + +_explicit_init_py_test = analysistest.make( + _explicit_init_py_test_impl, + attrs = { + "expect_generated_init": attr.bool(mandatory = True), + }, +) + +def explicit_init_py_test(*, name, main, expect_generated_init, legacy_create_init = -1, **kwargs): + """Test that verifies whether __init__.py is generated for a py_binary. + + Args: + name: Test name. + main: Source file for the py_binary subject. + expect_generated_init: Whether __init__.py generation is expected. + legacy_create_init: Value for the legacy_create_init attribute (-1, 0, or 1). + **kwargs: Additional args forwarded to the test rule (e.g. tags). + """ + + if not BZLMOD_ENABLED: + native.test_suite(name = name, tests = []) + return + + subject_name = name + "_subject" + py_binary( + name = subject_name, + srcs = [main], + main = main, + legacy_create_init = legacy_create_init, + tags = kwargs.pop("tags", ["manual"]), + **kwargs + ) + _explicit_init_py_test( + name = name, + target_under_test = subject_name, + expect_generated_init = expect_generated_init, + **kwargs + )