diff --git a/examples/bzlmod/MODULE.bazel b/examples/bzlmod/MODULE.bazel index 106f25134e..d6b066fd1f 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.explicit_init_py(default = 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..be18ff21fd 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.explicit_init_py(default = True) + python = use_extension("@rules_python//python/extensions:python.bzl", "python") python.defaults( # In a submodule this is ignored 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 f19a07aaef..a6d03c6f70 100644 --- a/python/extensions/config.bzl +++ b/python/extensions/config.bzl @@ -22,9 +22,43 @@ to repositories that are expensive to create or invalidate frequently. }, ) +_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. +::: + +:::{versionadded} VERSION_NEXT_FEATURE +::: +""", + attrs = { + "default": attr.bool(doc = "Whether explicit __init__.py files are required by default.", mandatory = True), + }, +) + def _config_impl(module_ctx): transition_setting_generators = {} transition_settings = [] + explicit_init_py_modules = {} for mod in module_ctx.modules: for tag in mod.tags.add_transition_setting: setting = str(tag.setting) @@ -32,11 +66,16 @@ 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.explicit_init_py: + explicit_init_py_modules[mod.name] = str(tag.default) + if mod.is_root: + explicit_init_py_modules[""] = str(tag.default) 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 +94,6 @@ config = module_extension( implementation = _config_impl, tag_classes = { "add_transition_setting": _add_transition_setting, + "explicit_init_py": _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..4ee484dcd0 100644 --- a/python/private/py_executable.bzl +++ b/python/private/py_executable.bzl @@ -115,15 +115,20 @@ 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 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 `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. + +:::{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 @@ -284,11 +289,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, *, @@ -1586,9 +1603,17 @@ WARNING: Target {} is using implicit __init__.py creation. Ensure all __init__.py files are explicitly created and added to the srcs or deps of your targets. - Disable implicit creation by setting: + 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) + + Or for a specific target by setting: + legacy_create_init = 0 - on the target, or globally by setting: + + Or globally with the following Bazel flag: + --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..12ee7672b6 100644 --- a/tests/modules/other/MODULE.bazel +++ b/tests/modules/other/MODULE.bazel @@ -2,4 +2,18 @@ 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 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.explicit_init_py(default = 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..943909ee71 --- /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(rctx): + rctx.file("main.py", _MAIN_PY_CONTENT) + rctx.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..abc32a9441 --- /dev/null +++ b/tests/support/explicit_init_py_test.bzl @@ -0,0 +1,50 @@ +"""Parameterized analysis test for __init__.py generation behavior.""" + +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(env, target): + empty_filenames = target[DefaultInfo].default_runfiles.empty_filenames.to_list() + 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: + 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. + + 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" + rt_util.helper_target( + py_binary, + name = subject_name, + srcs = [main], + main = main, + legacy_create_init = legacy_create_init, + ) + analysis_test( + name = name, + 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), + )