feat: expose runtime's shared libraries through toolchain (#1717)

This exposes the runtime's C libraries through the py cc toolchain. This
allows tools to embed the Python runtime or otherwise link against it.

It follows the same pattern as with the headers: the toolchain consumes
the cc_library,
exports CcInfo, and a `:current_py_cc_libs` target makes it easily
accessible to users.

Work towards https://github.com/bazelbuild/rules_python/issues/824

* Also upgrades to rules_testing 0.5.0 to make use of rules_testing's
DefaultInfoSubject
diff --git a/internal_deps.bzl b/internal_deps.bzl
index 3835cd6..9931933 100644
--- a/internal_deps.bzl
+++ b/internal_deps.bzl
@@ -57,9 +57,9 @@
 
     http_archive(
         name = "rules_testing",
-        sha256 = "8df0a8eb21739ea4b0a03f5dc79e68e245a45c076cfab404b940cc205cb62162",
-        strip_prefix = "rules_testing-0.4.0",
-        url = "https://github.com/bazelbuild/rules_testing/releases/download/v0.4.0/rules_testing-v0.4.0.tar.gz",
+        sha256 = "b84ed8546f1969d700ead4546de9f7637e0f058d835e47e865dcbb13c4210aed",
+        strip_prefix = "rules_testing-0.5.0",
+        url = "https://github.com/bazelbuild/rules_testing/releases/download/v0.5.0/rules_testing-v0.5.0.tar.gz",
     )
 
     http_archive(
diff --git a/python/cc/BUILD.bazel b/python/cc/BUILD.bazel
index 0d90e15..d384d05 100644
--- a/python/cc/BUILD.bazel
+++ b/python/cc/BUILD.bazel
@@ -3,6 +3,7 @@
 load("@bazel_skylib//:bzl_library.bzl", "bzl_library")
 load("//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED")
 load("//python/private:current_py_cc_headers.bzl", "current_py_cc_headers")
+load("//python/private:current_py_cc_libs.bzl", "current_py_cc_libs")
 
 package(
     default_visibility = ["//:__subpackages__"],
@@ -19,6 +20,17 @@
     visibility = ["//visibility:public"],
 )
 
+# This target provides the C libraries for whatever the current toolchain is for
+# the consuming rule. It basically acts like a cc_library by forwarding on the
+# providers for the underlying cc_library that the toolchain is using.
+current_py_cc_libs(
+    name = "current_py_cc_libs",
+    # Building this directly will fail unless a py cc toolchain is registered,
+    # and it's only under bzlmod that one is registered by default.
+    tags = [] if BZLMOD_ENABLED else ["manual"],
+    visibility = ["//visibility:public"],
+)
+
 toolchain_type(
     name = "toolchain_type",
     visibility = ["//visibility:public"],
diff --git a/python/private/current_py_cc_libs.bzl b/python/private/current_py_cc_libs.bzl
new file mode 100644
index 0000000..863e59a
--- /dev/null
+++ b/python/private/current_py_cc_libs.bzl
@@ -0,0 +1,41 @@
+# Copyright 2024 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Implementation of current_py_cc_libs rule."""
+
+def _current_py_cc_libs_impl(ctx):
+    py_cc_toolchain = ctx.toolchains["//python/cc:toolchain_type"].py_cc_toolchain
+    return py_cc_toolchain.libs.providers_map.values()
+
+current_py_cc_libs = rule(
+    implementation = _current_py_cc_libs_impl,
+    toolchains = ["//python/cc:toolchain_type"],
+    provides = [CcInfo],
+    doc = """\
+Provides the currently active Python toolchain's C libraries.
+
+This is a wrapper around the underlying `cc_library()` for the
+C libraries for the consuming target's currently active Python toolchain.
+
+To use, simply depend on this target where you would have wanted the
+toolchain's underlying `:libpython` target:
+
+```starlark
+cc_library(
+    name = "foo",
+    deps = ["@rules_python//python/cc:current_py_cc_libs"]
+)
+```
+""",
+)
diff --git a/python/private/py_cc_toolchain_info.bzl b/python/private/py_cc_toolchain_info.bzl
index a2e62a8..a47a6a5 100644
--- a/python/private/py_cc_toolchain_info.bzl
+++ b/python/private/py_cc_toolchain_info.bzl
@@ -38,6 +38,27 @@
     e.g. `:current_py_cc_headers` to act as the underlying headers target it
     represents).
 """,
+        "libs": """\
+(struct) Information about C libraries, with fields:
+  * providers_map: A dict of string to provider instances. The key should be
+    a fully qualified name (e.g. `@rules_foo//bar:baz.bzl#MyInfo`) of the
+    provider to uniquely identify its type.
+
+    The following keys are always present:
+      * CcInfo: the CcInfo provider instance for the libraries.
+      * DefaultInfo: the DefaultInfo provider instance for the headers.
+
+    A map is used to allow additional providers from the originating libraries
+    target (typically a `cc_library`) to be propagated to consumers (directly
+    exposing a Target object can cause memory issues and is an anti-pattern).
+
+    When consuming this map, it's suggested to use `providers_map.values()` to
+    return all providers; or copy the map and filter out or replace keys as
+    appropriate. Note that any keys beginning with `_` (underscore) are
+    considered private and should be forward along as-is (this better allows
+    e.g. `:current_py_cc_headers` to act as the underlying headers target it
+    represents).
+""",
         "python_version": "(str) The Python Major.Minor version.",
     },
 )
diff --git a/python/private/py_cc_toolchain_rule.bzl b/python/private/py_cc_toolchain_rule.bzl
index c80f845..abb3fb6 100644
--- a/python/private/py_cc_toolchain_rule.bzl
+++ b/python/private/py_cc_toolchain_rule.bzl
@@ -28,6 +28,12 @@
                 "DefaultInfo": ctx.attr.headers[DefaultInfo],
             },
         ),
+        libs = struct(
+            providers_map = {
+                "CcInfo": ctx.attr.libs[CcInfo],
+                "DefaultInfo": ctx.attr.libs[DefaultInfo],
+            },
+        ),
         python_version = ctx.attr.python_version,
     )
     return [platform_common.ToolchainInfo(
@@ -43,6 +49,12 @@
             providers = [CcInfo],
             mandatory = True,
         ),
+        "libs": attr.label(
+            doc = ("Target that provides the Python runtime libraries for linking. " +
+                   "Typically this is a cc_library target of `.so` files."),
+            providers = [CcInfo],
+            mandatory = True,
+        ),
         "python_version": attr.string(
             doc = "The Major.minor Python version, e.g. 3.11",
             mandatory = True,
diff --git a/python/repositories.bzl b/python/repositories.bzl
index e991511..1a6c0e5 100644
--- a/python/repositories.bzl
+++ b/python/repositories.bzl
@@ -353,6 +353,7 @@
 py_cc_toolchain(
     name = "py_cc_toolchain",
     headers = ":python_headers",
+    libs = ":libpython",
     python_version = "{python_version}",
 )
 """.format(
diff --git a/tests/cc/BUILD.bazel b/tests/cc/BUILD.bazel
index ef64d6d..889f9e0 100644
--- a/tests/cc/BUILD.bazel
+++ b/tests/cc/BUILD.bazel
@@ -21,6 +21,12 @@
 
 exports_files(["fake_header.h"])
 
+filegroup(
+    name = "libpython",
+    srcs = ["libpython-fake.so"],
+    tags = PREVENT_IMPLICIT_BUILDING_TAGS,
+)
+
 toolchain(
     name = "fake_py_cc_toolchain",
     tags = PREVENT_IMPLICIT_BUILDING_TAGS,
@@ -31,6 +37,7 @@
 py_cc_toolchain(
     name = "fake_py_cc_toolchain_impl",
     headers = ":fake_headers",
+    libs = ":fake_libs",
     python_version = "3.999",
     tags = PREVENT_IMPLICIT_BUILDING_TAGS,
 )
@@ -44,6 +51,14 @@
     tags = PREVENT_IMPLICIT_BUILDING_TAGS,
 )
 
+# buildifier: disable=native-cc
+cc_library(
+    name = "fake_libs",
+    srcs = ["libpython3.so"],
+    data = ["libdata.txt"],
+    tags = PREVENT_IMPLICIT_BUILDING_TAGS,
+)
+
 cc_toolchain_suite(
     name = "cc_toolchain_suite",
     tags = ["manual"],
diff --git a/tests/cc/current_py_cc_libs/BUILD.bazel b/tests/cc/current_py_cc_libs/BUILD.bazel
new file mode 100644
index 0000000..1e108c3
--- /dev/null
+++ b/tests/cc/current_py_cc_libs/BUILD.bazel
@@ -0,0 +1,17 @@
+# Copyright 2024 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+load(":current_py_cc_libs_tests.bzl", "current_py_cc_libs_test_suite")
+
+current_py_cc_libs_test_suite(name = "current_py_cc_libs_tests")
diff --git a/tests/cc/current_py_cc_libs/current_py_cc_libs_tests.bzl b/tests/cc/current_py_cc_libs/current_py_cc_libs_tests.bzl
new file mode 100644
index 0000000..5699b75
--- /dev/null
+++ b/tests/cc/current_py_cc_libs/current_py_cc_libs_tests.bzl
@@ -0,0 +1,77 @@
+# Copyright 2024 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Tests for current_py_cc_libs."""
+
+load("@rules_testing//lib:analysis_test.bzl", "analysis_test", "test_suite")
+load("@rules_testing//lib:truth.bzl", "matching")
+load("//tests:cc_info_subject.bzl", "cc_info_subject")
+
+_tests = []
+
+def _test_current_toolchain_libs(name):
+    analysis_test(
+        name = name,
+        impl = _test_current_toolchain_libs_impl,
+        target = "//python/cc:current_py_cc_libs",
+        config_settings = {
+            "//command_line_option:extra_toolchains": [str(Label("//tests/cc:all"))],
+        },
+        attrs = {
+            "lib": attr.label(
+                default = "//tests/cc:libpython",
+                allow_single_file = True,
+            ),
+        },
+    )
+
+def _test_current_toolchain_libs_impl(env, target):
+    # Check that the forwarded CcInfo looks vaguely correct.
+    cc_info = env.expect.that_target(target).provider(
+        CcInfo,
+        factory = cc_info_subject,
+    )
+    cc_info.linking_context().linker_inputs().has_size(2)
+
+    # Check that the forward DefaultInfo looks correct
+    env.expect.that_target(target).runfiles().contains_predicate(
+        matching.str_matches("*/libdata.txt"),
+    )
+
+    # The shared library should also end up in runfiles
+    # The `_solib` directory is a special directory CC rules put
+    # libraries into.
+    env.expect.that_target(target).runfiles().contains_predicate(
+        matching.str_matches("*_solib*/libpython3.so"),
+    )
+
+_tests.append(_test_current_toolchain_libs)
+
+def _test_toolchain_is_registered_by_default(name):
+    analysis_test(
+        name = name,
+        impl = _test_toolchain_is_registered_by_default_impl,
+        target = "//python/cc:current_py_cc_libs",
+    )
+
+def _test_toolchain_is_registered_by_default_impl(env, target):
+    env.expect.that_target(target).has_provider(CcInfo)
+
+_tests.append(_test_toolchain_is_registered_by_default)
+
+def current_py_cc_libs_test_suite(name):
+    test_suite(
+        name = name,
+        tests = _tests,
+    )
diff --git a/tests/cc/py_cc_toolchain/py_cc_toolchain_tests.bzl b/tests/cc/py_cc_toolchain/py_cc_toolchain_tests.bzl
index 609518d..fe83bf2 100644
--- a/tests/cc/py_cc_toolchain/py_cc_toolchain_tests.bzl
+++ b/tests/cc/py_cc_toolchain/py_cc_toolchain_tests.bzl
@@ -15,7 +15,7 @@
 """Tests for py_cc_toolchain."""
 
 load("@rules_testing//lib:analysis_test.bzl", "analysis_test", "test_suite")
-load("@rules_testing//lib:truth.bzl", "matching")
+load("@rules_testing//lib:truth.bzl", "matching", "subjects")
 load("//tests:cc_info_subject.bzl", "cc_info_subject")
 load("//tests:default_info_subject.bzl", "default_info_subject")
 load("//tests:py_cc_toolchain_info_subject.bzl", "PyCcToolchainInfoSubject")
@@ -74,6 +74,19 @@
         matching.str_matches("*/cc/data.txt"),
     )
 
+    libs_providers = toolchain.libs().providers_map()
+    libs_providers.keys().contains_exactly(["CcInfo", "DefaultInfo"])
+
+    cc_info = libs_providers.get("CcInfo", factory = cc_info_subject)
+
+    cc_info.linking_context().linker_inputs().has_size(2)
+
+    default_info = libs_providers.get("DefaultInfo", factory = subjects.default_info)
+    default_info.runfiles().contains("{workspace}/tests/cc/libdata.txt")
+    default_info.runfiles().contains_predicate(
+        matching.str_matches("/libpython3."),
+    )
+
 _tests.append(_py_cc_toolchain_test)
 
 def py_cc_toolchain_test_suite(name):
diff --git a/tests/cc_info_subject.bzl b/tests/cc_info_subject.bzl
index 31ac03a..e33ccb8 100644
--- a/tests/cc_info_subject.bzl
+++ b/tests/cc_info_subject.bzl
@@ -29,7 +29,9 @@
     # buildifier: disable=uninitialized
     public = struct(
         # go/keep-sorted start
+        actual = info,
         compilation_context = lambda *a, **k: _cc_info_subject_compilation_context(self, *a, **k),
+        linking_context = lambda *a, **k: _cc_info_subject_linking_context(self, *a, **k),
         # go/keep-sorted end
     )
     self = struct(
@@ -52,6 +54,20 @@
         meta = self.meta.derive("compilation_context()"),
     )
 
+def _cc_info_subject_linking_context(self):
+    """Returns the CcInfo.linking_context as a subject.
+
+    Args:
+        self: implicitly added.
+
+    Returns:
+        [`LinkingContextSubject`] instance.
+    """
+    return _linking_context_subject_new(
+        self.actual.linking_context,
+        meta = self.meta.derive("linking_context()"),
+    )
+
 def _compilation_context_subject_new(info, *, meta):
     """Creates a CompilationContextSubject.
 
@@ -126,3 +142,42 @@
         container_name = "includes",
         element_plural_name = "include paths",
     )
+
+def _linking_context_subject_new(info, meta):
+    """Creates a LinkingContextSubject.
+
+    Args:
+        info: ([`LinkingContext`]) object instance.
+        meta: rules_testing `ExpectMeta` instance.
+
+    Returns:
+        [`LinkingContextSubject`] object.
+    """
+
+    # buildifier: disable=uninitialized
+    public = struct(
+        # go/keep-sorted start
+        linker_inputs = lambda *a, **k: _linking_context_subject_linker_inputs(self, *a, **k),
+        # go/keep-sorted end
+    )
+    self = struct(
+        actual = info,
+        meta = meta,
+    )
+    return public
+
+def _linking_context_subject_linker_inputs(self):
+    """Returns the linker inputs.
+
+    Args:
+        self: implicitly added
+
+    Returns:
+        [`CollectionSubject`] of the linker inputs.
+    """
+    return subjects.collection(
+        self.actual.linker_inputs.to_list(),
+        meta = self.meta.derive("linker_inputs()"),
+        container_name = "linker_inputs",
+        element_plural_name = "linker input values",
+    )
diff --git a/tests/py_cc_toolchain_info_subject.bzl b/tests/py_cc_toolchain_info_subject.bzl
index ab9d1b8..4d3647c 100644
--- a/tests/py_cc_toolchain_info_subject.bzl
+++ b/tests/py_cc_toolchain_info_subject.bzl
@@ -19,6 +19,7 @@
     # buildifier: disable=uninitialized
     public = struct(
         headers = lambda *a, **k: _py_cc_toolchain_info_subject_headers(self, *a, **k),
+        libs = lambda *a, **k: _py_cc_toolchain_info_subject_libs(self, *a, **k),
         python_version = lambda *a, **k: _py_cc_toolchain_info_subject_python_version(self, *a, **k),
         actual = info,
     )
@@ -34,6 +35,15 @@
         ),
     )
 
+def _py_cc_toolchain_info_subject_libs(self):
+    return subjects.struct(
+        self.actual.libs,
+        meta = self.meta.derive("libs()"),
+        attrs = dict(
+            providers_map = subjects.dict,
+        ),
+    )
+
 def _py_cc_toolchain_info_subject_python_version(self):
     return subjects.str(
         self.actual.python_version,