From 117b6cbd60ec6f10b217e9b272489312e30be483 Mon Sep 17 00:00:00 2001 From: Armando Montanez Date: Mon, 3 Aug 2026 10:08:03 -0700 Subject: [PATCH 1/6] 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 + ) From 00ae9923d17dcc60d532380463f1cbe3c6824a46 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Tue, 11 Aug 2026 08:10:04 +0000 Subject: [PATCH 2/6] fix: check mod.is_root in config extension and migrate tests to rules_testing Use mod.is_root directly instead of assuming module iteration order in the config extension to reliably detect the root module. Also migrate explicit_init_py_test from bazel_skylib analysistest to rules_testing. --- python/extensions/config.bzl | 4 +-- tests/modules/other/MODULE.bazel | 1 + tests/support/explicit_init_py_test.bzl | 46 +++++++++++-------------- 3 files changed, 22 insertions(+), 29 deletions(-) diff --git a/python/extensions/config.bzl b/python/extensions/config.bzl index ca5c462c21..f88a6219f9 100644 --- a/python/extensions/config.bzl +++ b/python/extensions/config.bzl @@ -56,7 +56,6 @@ 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) @@ -66,9 +65,8 @@ def _config_impl(module_ctx): 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: + if mod.is_root: explicit_init_py_modules[""] = str(tag.enabled) - is_root = False internal_config_repo( name = "rules_python_internal", diff --git a/tests/modules/other/MODULE.bazel b/tests/modules/other/MODULE.bazel index 3b5a769ec8..34e9a3c3f9 100644 --- a/tests/modules/other/MODULE.bazel +++ b/tests/modules/other/MODULE.bazel @@ -2,6 +2,7 @@ module(name = "other") bazel_dep(name = "rules_python", version = "0") bazel_dep(name = "bazel_skylib", version = "1.7.1") +bazel_dep(name = "rules_testing", version = "0.6.0") bazel_dep(name = "another_module", version = "0") # Validate behavior of use_explicit_init_py configuration extension when used by diff --git a/tests/support/explicit_init_py_test.bzl b/tests/support/explicit_init_py_test.bzl index 4765b735a4..abc32a9441 100644 --- a/tests/support/explicit_init_py_test.bzl +++ b/tests/support/explicit_init_py_test.bzl @@ -1,28 +1,21 @@ """Parameterized analysis test for __init__.py generation behavior.""" -load("@bazel_skylib//lib:unittest.bzl", "analysistest", "asserts") +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_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) +def _explicit_init_py_test_impl(env, target): 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") + collection = env.expect.that_collection( + empty_filenames, + container_name = "empty_filenames", + ) + if env.ctx.attr.expect_generated_init: + collection.contains_predicate(matching.str_endswith("__init__.py")) 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), - }, -) + collection.not_contains_predicate(matching.str_endswith("__init__.py")) 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. @@ -34,23 +27,24 @@ def explicit_init_py_test(*, name, main, expect_generated_init, legacy_create_in 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( + rt_util.helper_target( + 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( + analysis_test( name = name, - target_under_test = subject_name, - expect_generated_init = expect_generated_init, - **kwargs + target = subject_name, + impl = _explicit_init_py_test_impl, + attrs = { + "expect_generated_init": attr.bool(mandatory = True), + }, + attr_values = dict(kwargs, expect_generated_init = expect_generated_init), ) From d0be325aa552b82dfbecfa67a87348638715ce94 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Tue, 11 Aug 2026 08:16:34 +0000 Subject: [PATCH 3/6] refactor: rename use_explicit_init_py tag to explicit_init_py Rename the use_explicit_init_py tag class on the config extension to explicit_init_py and change its enabled attribute to default. Update all usages, docstrings, and warning messages accordingly. --- examples/bzlmod/MODULE.bazel | 2 +- examples/bzlmod/other_module/MODULE.bazel | 2 +- python/extensions/config.bzl | 14 +++++++------- python/private/py_executable.bzl | 4 ++-- tests/modules/other/MODULE.bazel | 4 ++-- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/examples/bzlmod/MODULE.bazel b/examples/bzlmod/MODULE.bazel index 849a3b8106..d6b066fd1f 100644 --- a/examples/bzlmod/MODULE.bazel +++ b/examples/bzlmod/MODULE.bazel @@ -24,7 +24,7 @@ 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) +rules_python_config.explicit_init_py(default = True) # We next initialize the python toolchain using the extension. # You can set different Python versions in this block. diff --git a/examples/bzlmod/other_module/MODULE.bazel b/examples/bzlmod/other_module/MODULE.bazel index 6f2f9e16fa..be18ff21fd 100644 --- a/examples/bzlmod/other_module/MODULE.bazel +++ b/examples/bzlmod/other_module/MODULE.bazel @@ -12,7 +12,7 @@ local_path_override( # 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) +rules_python_config.explicit_init_py(default = True) python = use_extension("@rules_python//python/extensions:python.bzl", "python") python.defaults( diff --git a/python/extensions/config.bzl b/python/extensions/config.bzl index f88a6219f9..2d427ceaf6 100644 --- a/python/extensions/config.bzl +++ b/python/extensions/config.bzl @@ -22,9 +22,9 @@ to repositories that are expensive to create or invalidate frequently. }, ) -_use_explicit_init_py = tag_class( +_explicit_init_py = tag_class( doc = """ -Require explicit `__init__.py` files in this module. +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 @@ -48,7 +48,7 @@ In the future, this will be enabled by default. ::: """, attrs = { - "enabled": attr.bool(doc = "Whether this feature is enabled.", mandatory = True), + "default": attr.bool(doc = "Whether explicit __init__.py files are required by default.", mandatory = True), }, ) @@ -63,10 +63,10 @@ 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) + for tag in mod.tags.explicit_init_py: + explicit_init_py_modules[mod.name] = str(tag.default) if mod.is_root: - explicit_init_py_modules[""] = str(tag.enabled) + explicit_init_py_modules[""] = str(tag.default) internal_config_repo( name = "rules_python_internal", @@ -91,6 +91,6 @@ config = module_extension( implementation = _config_impl, tag_classes = { "add_transition_setting": _add_transition_setting, - "use_explicit_init_py": _use_explicit_init_py, + "explicit_init_py": _explicit_init_py, }, ) diff --git a/python/private/py_executable.bzl b/python/private/py_executable.bzl index b2f0da3c5f..ff16899403 100644 --- a/python/private/py_executable.bzl +++ b/python/private/py_executable.bzl @@ -120,7 +120,7 @@ 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` or the `use_explicit_init_py` +`--incompatible_default_to_explicit_init_py` or the `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. @@ -1601,7 +1601,7 @@ WARNING: Target {} is using implicit __init__.py creation. 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) + rules_python_config.explicit_init_py(default = True) If this warning is coming from an external module, you can configure this globally with the following Bazel flag: diff --git a/tests/modules/other/MODULE.bazel b/tests/modules/other/MODULE.bazel index 34e9a3c3f9..12ee7672b6 100644 --- a/tests/modules/other/MODULE.bazel +++ b/tests/modules/other/MODULE.bazel @@ -5,10 +5,10 @@ bazel_dep(name = "bazel_skylib", version = "1.7.1") bazel_dep(name = "rules_testing", version = "0.6.0") bazel_dep(name = "another_module", version = "0") -# Validate behavior of use_explicit_init_py configuration extension when used by +# Validate behavior of 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) +rules_python_config.explicit_init_py(default = True) init_py_test_repo = use_repo_rule("//:ext.bzl", "init_py_test_repo") From ff95211cb25659e005d07ba43720f0df39adf60a Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Tue, 11 Aug 2026 08:23:14 +0000 Subject: [PATCH 4/6] docs: address code review audit findings and add news entry Add news entry fragment for module-wide explicit __init__.py configuration, version directives in Starlark docstrings, and clean up repository rule parameter naming. --- news/3997.added.md | 5 +++++ python/extensions/config.bzl | 3 +++ python/private/py_executable.bzl | 8 ++++++-- tests/modules/other/ext.bzl | 6 +++--- 4 files changed, 17 insertions(+), 5 deletions(-) create mode 100644 news/3997.added.md diff --git a/news/3997.added.md b/news/3997.added.md new file mode 100644 index 0000000000..34cbfc20e1 --- /dev/null +++ b/news/3997.added.md @@ -0,0 +1,5 @@ +(bzlmod) Added the `{obj}`explicit_init_py`` tag class to the +`{obj}`config`` module extension for configuring implicit `__init__.py` file +generation module-wide. +([#3997](https://github.com/bazel-contrib/rules_python/pull/3997), +[#2945](https://github.com/bazel-contrib/rules_python/issues/2945)) diff --git a/python/extensions/config.bzl b/python/extensions/config.bzl index 2d427ceaf6..a6d03c6f70 100644 --- a/python/extensions/config.bzl +++ b/python/extensions/config.bzl @@ -46,6 +46,9 @@ py_binary( :::{note} In the future, this will be enabled by default. ::: + +:::{versionadded} VERSION_NEXT_FEATURE +::: """, attrs = { "default": attr.bool(doc = "Whether explicit __init__.py files are required by default.", mandatory = True), diff --git a/python/private/py_executable.bzl b/python/private/py_executable.bzl index ff16899403..a0328d7df5 100644 --- a/python/private/py_executable.bzl +++ b/python/private/py_executable.bzl @@ -115,7 +115,7 @@ The {any}`RULES_PYTHON_ADDITIONAL_INTERPRETER_ARGS` environment variable "legacy_create_init": lambda: attrb.Int( default = -1, values = [-1, 0, 1], - doc = """\ + doc = """ 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 @@ -124,7 +124,11 @@ root directory. The default, `-1` (auto), means true unless 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. - """, + +:::{versionchanged} VERSION_NEXT_FEATURE +Now checks module-level `explicit_init_py` configuration before CLI flags. +::: +""", ), # TODO(b/203567235): In the Java impl, any file is allowed. While marked # label, it is more treated as a string, and doesn't have to refer to diff --git a/tests/modules/other/ext.bzl b/tests/modules/other/ext.bzl index 709ecd79a0..943909ee71 100644 --- a/tests/modules/other/ext.bzl +++ b/tests/modules/other/ext.bzl @@ -12,9 +12,9 @@ explicit_init_py_test( _MAIN_PY_CONTENT = "print('hello, world')" -def _repo_impl(ctx): - ctx.file("main.py", _MAIN_PY_CONTENT) - ctx.file("BUILD.bazel", _BUILD_FILE_CONTENT) +def _repo_impl(rctx): + rctx.file("main.py", _MAIN_PY_CONTENT) + rctx.file("BUILD.bazel", _BUILD_FILE_CONTENT) init_py_test_repo = repository_rule(implementation = _repo_impl) From f78623a50a06b7b577ed486cef215ef90098ecd2 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Tue, 11 Aug 2026 08:24:48 +0000 Subject: [PATCH 5/6] fix(py_executable): update deprecation warning to explain all explicit init options Update the implicit __init__.py deprecation warning message to clearly list module-wide configuration via config.explicit_init_py, per-target overrides, and the global flag. --- python/private/py_executable.bzl | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/python/private/py_executable.bzl b/python/private/py_executable.bzl index a0328d7df5..4ee484dcd0 100644 --- a/python/private/py_executable.bzl +++ b/python/private/py_executable.bzl @@ -1601,14 +1601,18 @@ 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, then enable this - setting in your MODULE.bazel: + added to the srcs or deps of your targets. + + Disable implicit creation for your module in MODULE.bazel: rules_python_config = use_extension("@rules_python//python/extensions:config.bzl", "config") rules_python_config.explicit_init_py(default = True) - If this warning is coming from an external module, you can configure this - globally with the following Bazel flag: + Or for a specific target by setting: + + legacy_create_init = 0 + + Or globally with the following Bazel flag: --incompatible_default_to_explicit_init_py ====================================================================== From dea603abcf78b6092f2ddd9e8bf157f2ac94b3eb Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Tue, 11 Aug 2026 08:31:21 +0000 Subject: [PATCH 6/6] fix(examples): remove internal test helper load from examples/bzlmod Remove cross-repository dependency on @rules_python//tests/support in examples/bzlmod/tests/BUILD.bazel to prevent missing dev-dependency errors when downstream BCR tests build examples. --- examples/bzlmod/tests/BUILD.bazel | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/examples/bzlmod/tests/BUILD.bazel b/examples/bzlmod/tests/BUILD.bazel index a4e3ee55be..4650fb8788 100644 --- a/examples/bzlmod/tests/BUILD.bazel +++ b/examples/bzlmod/tests/BUILD.bazel @@ -1,7 +1,6 @@ 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( @@ -193,16 +192,3 @@ 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", -)