fix(pypi): support source-less wheels with dependencies (#4054)
Source-less wheels with dependency metadata fail analysis because the
generated wrapper `py_library` puts an empty `:srcs` target in its
`srcs` attribute.
This forwards `:srcs` through the wrapper only when it produces Python
sources, while retaining it in `deps` for `PyInfo` propagation. Before
this change, [`py-spy==0.4.1`](https://pypi.org/project/py-spy/0.4.1/)
dependency fails during analysis. Afterward, it can be used as a
dependency.
Adds regression coverage for source-less, sourceful, and generated
namespace-package source targets, along with a news entry.
Largely implemented with Opus.
Fixes #4053.
---------
Co-authored-by: Ignas Anikevicius <240938+aignas@users.noreply.github.com>
diff --git a/news/4054.fixed.md b/news/4054.fixed.md
new file mode 100644
index 0000000..e4b259b
--- /dev/null
+++ b/news/4054.fixed.md
@@ -0,0 +1,3 @@
+(pypi) Fixed analysis failures in {obj}`pip.parse` for source-less wheels with
+dependencies.
+([#4053](https://github.com/bazel-contrib/rules_python/issues/4053))
diff --git a/python/private/py_library.bzl b/python/private/py_library.bzl
index 3d8ba4a..db56f24 100644
--- a/python/private/py_library.bzl
+++ b/python/private/py_library.bzl
@@ -118,6 +118,34 @@
},
)
+def _validate_srcs(ctx):
+ """Validate that srcs targets provide Python sources or Python metadata."""
+ for target in ctx.attr.srcs:
+ files = target[DefaultInfo].files.to_list()
+ if not files and (
+ PyInfo in target or
+ (BuiltinPyInfo != None and BuiltinPyInfo in target)
+ ):
+ continue
+
+ found_match = False
+ for file in files:
+ if file.is_directory or file.extension in ("py", "py3"):
+ found_match = True
+ break
+
+ if found_match:
+ continue
+
+ fail(
+ ("{} does not produce any py_library srcs files " +
+ "(expected .py or .py3) and is not an empty target providing " +
+ "PyInfo").format(
+ target.label,
+ ),
+ attr = "srcs",
+ )
+
def py_library_impl(ctx):
"""Abstract implementation of py_library rule.
@@ -127,6 +155,7 @@
Returns:
A list of modern providers to propagate.
"""
+ _validate_srcs(ctx)
direct_sources = filter_to_py_srcs(ctx.files.srcs)
precompile_result = maybe_precompile(ctx, direct_sources)
@@ -297,4 +326,13 @@
ruleb.ToolchainType(EXEC_TOOLS_TOOLCHAIN_TYPE, mandatory = False),
],
)
+ srcs_attr = builder.attrs.get("srcs")
+ srcs_attr.set_allow_files(True)
+ srcs_attr.set_doc(srcs_attr.doc() + """
+
+:::{versionchanged} VERSION_NEXT_FEATURE
+As an exception, empty targets in `srcs` that provide {obj}`PyInfo` are
+allowed. Ordinary library dependencies should remain in `deps`.
+:::
+""")
return builder
diff --git a/tests/base_rules/py_library/py_library_tests.bzl b/tests/base_rules/py_library/py_library_tests.bzl
index c375e72..a3be7c4 100644
--- a/tests/base_rules/py_library/py_library_tests.bzl
+++ b/tests/base_rules/py_library/py_library_tests.bzl
@@ -12,6 +12,28 @@
_tests = []
+def _tree_artifact_impl(ctx):
+ out = ctx.actions.declare_directory(ctx.label.name + ".dir")
+ ctx.actions.run_shell(
+ outputs = [out],
+ command = "mkdir -p \"$1\"",
+ arguments = [out.path],
+ )
+ return [DefaultInfo(files = depset([out]))]
+
+_tree_artifact = rule(implementation = _tree_artifact_impl)
+
+def _non_py_py_info_impl(ctx):
+ return [
+ DefaultInfo(files = depset(ctx.files.srcs)),
+ PyInfo(transitive_sources = depset()),
+ ]
+
+_non_py_py_info = rule(
+ implementation = _non_py_py_info_impl,
+ attrs = {"srcs": attr.label_list(allow_files = True)},
+)
+
def _test_py_runtime_info_not_present(name, config):
rt_util.helper_target(
config.rule,
@@ -74,6 +96,117 @@
_tests.append(_test_srcs_can_contain_rule_generating_py_and_nonpy_files)
+def _test_srcs_can_contain_empty_py_library(name, config):
+ rt_util.helper_target(
+ config.rule,
+ name = name + "_empty",
+ )
+ rt_util.helper_target(
+ config.rule,
+ name = name + "_subject",
+ srcs = [name + "_empty"],
+ )
+ analysis_test(
+ name = name,
+ target = name + "_subject",
+ impl = _test_srcs_can_contain_empty_py_library_impl,
+ )
+
+def _test_srcs_can_contain_empty_py_library_impl(env, target):
+ env.expect.that_target(target).default_outputs().contains_exactly([])
+
+_tests.append(_test_srcs_can_contain_empty_py_library)
+
+def _test_srcs_can_contain_tree_artifact(name, config):
+ rt_util.helper_target(
+ _tree_artifact,
+ name = name + "_tree",
+ )
+ rt_util.helper_target(
+ config.rule,
+ name = name + "_subject",
+ srcs = [name + "_tree"],
+ )
+ analysis_test(
+ name = name,
+ target = name + "_subject",
+ impl = _test_srcs_can_contain_tree_artifact_impl,
+ )
+
+def _test_srcs_can_contain_tree_artifact_impl(env, target):
+ env.expect.that_target(target).default_outputs().contains_exactly([])
+
+_tests.append(_test_srcs_can_contain_tree_artifact)
+
+def _test_srcs_direct_non_py_file_is_error(name, config):
+ rt_util.helper_target(
+ config.rule,
+ name = name + "_subject",
+ srcs = [rt_util.empty_file(name + ".txt")],
+ )
+ analysis_test(
+ name = name,
+ target = name + "_subject",
+ impl = _test_srcs_direct_non_py_file_is_error_impl,
+ expect_failure = True,
+ )
+
+def _test_srcs_direct_non_py_file_is_error_impl(env, target):
+ env.expect.that_target(target).failures().contains_predicate(
+ matching.str_matches("does not produce*srcs files"),
+ )
+
+_tests.append(_test_srcs_direct_non_py_file_is_error)
+
+def _test_srcs_empty_filegroup_is_error(name, config):
+ rt_util.helper_target(
+ native.filegroup,
+ name = name + "_empty",
+ )
+ rt_util.helper_target(
+ config.rule,
+ name = name + "_subject",
+ srcs = [name + "_empty"],
+ )
+ analysis_test(
+ name = name,
+ target = name + "_subject",
+ impl = _test_srcs_empty_filegroup_is_error_impl,
+ expect_failure = True,
+ )
+
+def _test_srcs_empty_filegroup_is_error_impl(env, target):
+ env.expect.that_target(target).failures().contains_predicate(
+ matching.str_matches("does not produce*srcs files"),
+ )
+
+_tests.append(_test_srcs_empty_filegroup_is_error)
+
+def _test_srcs_py_info_with_only_non_py_files_is_error(name, config):
+ rt_util.helper_target(
+ _non_py_py_info,
+ name = name + "_non_py",
+ srcs = [rt_util.empty_file(name + ".txt")],
+ )
+ rt_util.helper_target(
+ config.rule,
+ name = name + "_subject",
+ srcs = [name + "_non_py"],
+ )
+ analysis_test(
+ name = name,
+ target = name + "_subject",
+ impl = _test_srcs_py_info_with_only_non_py_files_is_error_impl,
+ expect_failure = True,
+ )
+
+def _test_srcs_py_info_with_only_non_py_files_is_error_impl(env, target):
+ env.expect.that_target(target).failures().contains_predicate(
+ matching.str_matches("does not produce*srcs files"),
+ )
+
+_tests.append(_test_srcs_py_info_with_only_non_py_files_is_error)
+
def _test_srcs_generating_no_py_files_is_error(name, config):
rt_util.helper_target(
config.rule,