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,