feat: Expose Python C headers through the toolchain. (#1287)

This allows getting a build's `cc_library` of Python headers through
toolchain resolution instead of having to use the underlying toolchain's
repository `:python_headers` target directly.

Without this feature, it's not possible to reliably and correctly get
the C information about the runtime a build is going to use. Existing
solutions require carefully setting up repo names, external state,
and/or using specific build rules. In comparison, with this feature,
consumers are able to simply ask for the current headers via a helper
target or manually lookup the toolchain and pull the relevant
information; toolchain resolution handles finding the correct headers.

The basic way this works is by registering a second toolchain to carry
C/C++ related information; as such, it is named `py_cc_toolchain`. The
py cc toolchain has the same constraint settings as the regular py
toolchain; an expected invariant is that there is a 1:1 correspondence
between the two. This base functionality allows a consuming rule
implementation to use toolchain resolution to find the Python C
toolchain information.

Usually what downstream consumers need are the headers to feed into
another `cc_library` (or equivalent) target, so, rather than have every
project re-implement the same "lookup and forward cc_library info"
logic,
this is provided by the `//python/cc:current_py_cc_headers` target.
Targets that need the headers can then depend on that target as if it
was a `cc_library` target.

Work towards https://github.com/bazelbuild/rules_python/issues/824
diff --git a/.bazelci/presubmit.yml b/.bazelci/presubmit.yml
index ac24113..1da4d9f 100644
--- a/.bazelci/presubmit.yml
+++ b/.bazelci/presubmit.yml
@@ -113,8 +113,19 @@
     <<: *reusable_config
     name: Test on RBE using minimum supported Bazel version
     platform: rbe_ubuntu1604
+    build_flags:
+      # BazelCI sets --action_env=BAZEL_DO_NOT_DETECT_CPP_TOOLCHAIN=1,
+      # which prevents cc toolchain autodetection from working correctly
+      # on Bazel 5.4 and earlier. To workaround this, manually specify the
+      # build kite cc toolchain.
+      - "--extra_toolchains=@buildkite_config//config:cc-toolchain"
     test_flags:
       - "--test_tag_filters=-integration-test,-acceptance-test"
+      # BazelCI sets --action_env=BAZEL_DO_NOT_DETECT_CPP_TOOLCHAIN=1,
+      # which prevents cc toolchain autodetection from working correctly
+      # on Bazel 5.4 and earlier. To workaround this, manually specify the
+      # build kite cc toolchain.
+      - "--extra_toolchains=@buildkite_config//config:cc-toolchain"
   rbe:
     <<: *reusable_config
     name: Test on RBE
diff --git a/.bazelrc b/.bazelrc
index d5b0566..3611999 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -3,8 +3,8 @@
 # This lets us glob() up all the files inside the examples to make them inputs to tests
 # (Note, we cannot use `common --deleted_packages` because the bazel version command doesn't support it)
 # To update these lines, run tools/bazel_integration_test/update_deleted_packages.sh
-build --deleted_packages=examples/build_file_generation,examples/build_file_generation/random_number_generator,examples/bzlmod,examples/bzlmod/entry_point,examples/bzlmod/libs/my_lib,examples/bzlmod/other_module/other_module/pkg,examples/bzlmod/runfiles,examples/bzlmod/tests,examples/bzlmod_build_file_generation,examples/bzlmod_build_file_generation/other_module/other_module/pkg,examples/bzlmod_build_file_generation/runfiles,examples/multi_python_versions/libs/my_lib,examples/multi_python_versions/requirements,examples/multi_python_versions/tests,examples/pip_install,examples/pip_parse,examples/pip_parse_vendored,examples/pip_repository_annotations,examples/py_proto_library,tests/compile_pip_requirements,tests/compile_pip_requirements_test_from_external_workspace,tests/ignore_root_user_error,tests/pip_repository_entry_points
-query --deleted_packages=examples/build_file_generation,examples/build_file_generation/random_number_generator,examples/bzlmod,examples/bzlmod/entry_point,examples/bzlmod/libs/my_lib,examples/bzlmod/other_module/other_module/pkg,examples/bzlmod/runfiles,examples/bzlmod/tests,examples/bzlmod_build_file_generation,examples/bzlmod_build_file_generation/other_module/other_module/pkg,examples/bzlmod_build_file_generation/runfiles,examples/multi_python_versions/libs/my_lib,examples/multi_python_versions/requirements,examples/multi_python_versions/tests,examples/pip_install,examples/pip_parse,examples/pip_parse_vendored,examples/pip_repository_annotations,examples/py_proto_library,tests/compile_pip_requirements,tests/compile_pip_requirements_test_from_external_workspace,tests/ignore_root_user_error,tests/pip_repository_entry_points
+build --deleted_packages=examples/build_file_generation,examples/build_file_generation/random_number_generator,examples/bzlmod,examples/bzlmod_build_file_generation,examples/bzlmod_build_file_generation/other_module/other_module/pkg,examples/bzlmod_build_file_generation/runfiles,examples/bzlmod/entry_point,examples/bzlmod/libs/my_lib,examples/bzlmod/other_module/other_module/pkg,examples/bzlmod/runfiles,examples/bzlmod/tests,examples/multi_python_versions/libs/my_lib,examples/multi_python_versions/requirements,examples/multi_python_versions/tests,examples/pip_install,examples/pip_parse,examples/pip_parse_vendored,examples/pip_repository_annotations,examples/py_proto_library,tests/compile_pip_requirements,tests/compile_pip_requirements_test_from_external_workspace,tests/ignore_root_user_error,tests/pip_repository_entry_points
+query --deleted_packages=examples/build_file_generation,examples/build_file_generation/random_number_generator,examples/bzlmod,examples/bzlmod_build_file_generation,examples/bzlmod_build_file_generation/other_module/other_module/pkg,examples/bzlmod_build_file_generation/runfiles,examples/bzlmod/entry_point,examples/bzlmod/libs/my_lib,examples/bzlmod/other_module/other_module/pkg,examples/bzlmod/runfiles,examples/bzlmod/tests,examples/multi_python_versions/libs/my_lib,examples/multi_python_versions/requirements,examples/multi_python_versions/tests,examples/pip_install,examples/pip_parse,examples/pip_parse_vendored,examples/pip_repository_annotations,examples/py_proto_library,tests/compile_pip_requirements,tests/compile_pip_requirements_test_from_external_workspace,tests/ignore_root_user_error,tests/pip_repository_entry_points
 
 test --test_output=errors
 
diff --git a/docs/BUILD.bazel b/docs/BUILD.bazel
index 938ba85..1fb4f81 100644
--- a/docs/BUILD.bazel
+++ b/docs/BUILD.bazel
@@ -25,6 +25,8 @@
     "packaging": "//docs:packaging-docs",
     "pip": "//docs:pip-docs",
     "pip_repository": "//docs:pip-repository",
+    "py_cc_toolchain": "//docs:py_cc_toolchain-docs",
+    "py_cc_toolchain_info": "//docs:py_cc_toolchain_info-docs",
     "python": "//docs:core-docs",
 }
 
@@ -134,6 +136,25 @@
     deps = [":packaging_bzl"],
 )
 
+stardoc(
+    name = "py_cc_toolchain-docs",
+    out = "py_cc_toolchain.md_",
+    # NOTE: The public file isn't used as the input because it would document
+    # the macro, which doesn't have the attribute documentation. The macro
+    # doesn't do anything interesting to users, so bypass it to avoid having to
+    # copy/paste all the rule's doc in the macro.
+    input = "//python/private:py_cc_toolchain_rule.bzl",
+    target_compatible_with = _NOT_WINDOWS,
+    deps = ["//python/private:py_cc_toolchain_bzl"],
+)
+
+stardoc(
+    name = "py_cc_toolchain_info-docs",
+    out = "py_cc_toolchain_info.md_",
+    input = "//python/cc:py_cc_toolchain_info.bzl",
+    deps = ["//python/cc:py_cc_toolchain_info_bzl"],
+)
+
 [
     diff_test(
         name = "check_" + k,
diff --git a/docs/py_cc_toolchain.md b/docs/py_cc_toolchain.md
new file mode 100644
index 0000000..3a59ea9
--- /dev/null
+++ b/docs/py_cc_toolchain.md
@@ -0,0 +1,32 @@
+<!-- Generated with Stardoc: http://skydoc.bazel.build -->
+
+Implementation of py_cc_toolchain rule.
+
+NOTE: This is a beta-quality feature. APIs subject to change until
+https://github.com/bazelbuild/rules_python/issues/824 is considered done.
+
+
+<a id="py_cc_toolchain"></a>
+
+## py_cc_toolchain
+
+<pre>
+py_cc_toolchain(<a href="#py_cc_toolchain-name">name</a>, <a href="#py_cc_toolchain-headers">headers</a>, <a href="#py_cc_toolchain-python_version">python_version</a>)
+</pre>
+
+A toolchain for a Python runtime's C/C++ information (e.g. headers)
+
+This rule carries information about the C/C++ side of a Python runtime, e.g.
+headers, shared libraries, etc.
+
+
+**ATTRIBUTES**
+
+
+| Name  | Description | Type | Mandatory | Default |
+| :------------- | :------------- | :------------- | :------------- | :------------- |
+| <a id="py_cc_toolchain-name"></a>name |  A unique name for this target.   | <a href="https://bazel.build/concepts/labels#target-names">Name</a> | required |  |
+| <a id="py_cc_toolchain-headers"></a>headers |  Target that provides the Python headers. Typically this is a cc_library target.   | <a href="https://bazel.build/concepts/labels">Label</a> | required |  |
+| <a id="py_cc_toolchain-python_version"></a>python_version |  The Major.minor Python version, e.g. 3.11   | String | required |  |
+
+
diff --git a/docs/py_cc_toolchain_info.md b/docs/py_cc_toolchain_info.md
new file mode 100644
index 0000000..4e59a78
--- /dev/null
+++ b/docs/py_cc_toolchain_info.md
@@ -0,0 +1,27 @@
+<!-- Generated with Stardoc: http://skydoc.bazel.build -->
+
+Provider for C/C++ information about the Python runtime.
+
+NOTE: This is a beta-quality feature. APIs subject to change until
+https://github.com/bazelbuild/rules_python/issues/824 is considered done.
+
+
+<a id="PyCcToolchainInfo"></a>
+
+## PyCcToolchainInfo
+
+<pre>
+PyCcToolchainInfo(<a href="#PyCcToolchainInfo-headers">headers</a>, <a href="#PyCcToolchainInfo-python_version">python_version</a>)
+</pre>
+
+C/C++ information about the Python runtime.
+
+**FIELDS**
+
+
+| Name  | Description |
+| :------------- | :------------- |
+| <a id="PyCcToolchainInfo-headers"></a>headers |  (struct) Information about the header files, with fields:   * providers_map: a dict of string to provider instances. The key should be     a fully qualified name (e.g. <code>@rules_foo//bar:baz.bzl#MyInfo</code>) of the     provider to uniquely identify its type.<br><br>    The following keys are always present:       * CcInfo: the CcInfo provider instance for the headers.       * DefaultInfo: the DefaultInfo provider instance for the headers.<br><br>    A map is used to allow additional providers from the originating headers     target (typically a <code>cc_library</code>) to be propagated to consumers (directly     exposing a Target object can cause memory issues and is an anti-pattern).<br><br>    When consuming this map, it's suggested to use <code>providers_map.values()</code> to     return all providers; or copy the map and filter out or replace keys as     appropriate. Note that any keys begining with <code>_</code> (underscore) are     considered private and should be forward along as-is (this better allows     e.g. <code>:current_py_cc_headers</code> to act as the underlying headers target it     represents).    |
+| <a id="PyCcToolchainInfo-python_version"></a>python_version |  (str) The Python Major.Minor version.    |
+
+
diff --git a/python/BUILD.bazel b/python/BUILD.bazel
index d75889d..c5f2580 100644
--- a/python/BUILD.bazel
+++ b/python/BUILD.bazel
@@ -33,6 +33,7 @@
 filegroup(
     name = "distribution",
     srcs = glob(["**"]) + [
+        "//python/cc:distribution",
         "//python/config_settings:distribution",
         "//python/constraints:distribution",
         "//python/private:distribution",
diff --git a/python/cc/BUILD.bazel b/python/cc/BUILD.bazel
new file mode 100644
index 0000000..d4a6bb8
--- /dev/null
+++ b/python/cc/BUILD.bazel
@@ -0,0 +1,44 @@
+# Package for C/C++ specific functionality of the Python rules.
+
+load("@bazel_skylib//:bzl_library.bzl", "bzl_library")
+load("//python/private:current_py_cc_headers.bzl", "current_py_cc_headers")
+load("//python/private:util.bzl", "BZLMOD_ENABLED")
+
+package(
+    default_visibility = ["//:__subpackages__"],
+)
+
+# This target provides the C headers 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_headers(
+    name = "current_py_cc_headers",
+    # 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"],
+)
+
+bzl_library(
+    name = "py_cc_toolchain_bzl",
+    srcs = ["py_cc_toolchain.bzl"],
+    visibility = ["//visibility:public"],
+    deps = ["//python/private:py_cc_toolchain_bzl"],
+)
+
+bzl_library(
+    name = "py_cc_toolchain_info_bzl",
+    srcs = ["py_cc_toolchain_info.bzl"],
+    visibility = ["//visibility:public"],
+    deps = ["//python/private:py_cc_toolchain_info_bzl"],
+)
+
+filegroup(
+    name = "distribution",
+    srcs = glob(["**"]),
+)
diff --git a/python/cc/py_cc_toolchain.bzl b/python/cc/py_cc_toolchain.bzl
new file mode 100644
index 0000000..2e782ef
--- /dev/null
+++ b/python/cc/py_cc_toolchain.bzl
@@ -0,0 +1,19 @@
+# Copyright 2023 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.
+
+"""Public entry point for py_cc_toolchain rule."""
+
+load("//python/private:py_cc_toolchain_macro.bzl", _py_cc_toolchain = "py_cc_toolchain")
+
+py_cc_toolchain = _py_cc_toolchain
diff --git a/python/cc/py_cc_toolchain_info.bzl b/python/cc/py_cc_toolchain_info.bzl
new file mode 100644
index 0000000..9ea394a
--- /dev/null
+++ b/python/cc/py_cc_toolchain_info.bzl
@@ -0,0 +1,23 @@
+# Copyright 2023 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.
+
+"""Provider for C/C++ information about the Python runtime.
+
+NOTE: This is a beta-quality feature. APIs subject to change until
+https://github.com/bazelbuild/rules_python/issues/824 is considered done.
+"""
+
+load("//python/private:py_cc_toolchain_info.bzl", _PyCcToolchainInfo = "PyCcToolchainInfo")
+
+PyCcToolchainInfo = _PyCcToolchainInfo
diff --git a/python/private/BUILD.bazel b/python/private/BUILD.bazel
index f454f42..10af17e 100644
--- a/python/private/BUILD.bazel
+++ b/python/private/BUILD.bazel
@@ -51,6 +51,28 @@
     deps = ["@bazel_skylib//lib:types"],
 )
 
+bzl_library(
+    name = "py_cc_toolchain_bzl",
+    srcs = [
+        "py_cc_toolchain_macro.bzl",
+        "py_cc_toolchain_rule.bzl",
+    ],
+    visibility = [
+        "//docs:__subpackages__",
+        "//python/cc:__pkg__",
+    ],
+    deps = [
+        ":py_cc_toolchain_info_bzl",
+        ":util_bzl",
+    ],
+)
+
+bzl_library(
+    name = "py_cc_toolchain_info_bzl",
+    srcs = ["py_cc_toolchain_info.bzl"],
+    visibility = ["//python/cc:__pkg__"],
+)
+
 # @bazel_tools can't define bzl_library itself, so we just put a wrapper around it.
 bzl_library(
     name = "bazel_tools_bzl",
@@ -73,6 +95,7 @@
         "reexports.bzl",
         "stamp.bzl",
         "util.bzl",
+        "py_cc_toolchain_rule.bzl",
     ],
     visibility = ["//docs:__pkg__"],
 )
diff --git a/python/private/current_py_cc_headers.bzl b/python/private/current_py_cc_headers.bzl
new file mode 100644
index 0000000..be7f8f8
--- /dev/null
+++ b/python/private/current_py_cc_headers.bzl
@@ -0,0 +1,41 @@
+# Copyright 2023 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_headers rule."""
+
+def _current_py_cc_headers_impl(ctx):
+    py_cc_toolchain = ctx.toolchains["//python/cc:toolchain_type"].py_cc_toolchain
+    return py_cc_toolchain.headers.providers_map.values()
+
+current_py_cc_headers = rule(
+    implementation = _current_py_cc_headers_impl,
+    toolchains = ["//python/cc:toolchain_type"],
+    provides = [CcInfo],
+    doc = """\
+Provides the currently active Python toolchain's C headers.
+
+This is a wrapper around the underlying `cc_library()` for the
+C headers 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 `:python_headers` target:
+
+```starlark
+cc_library(
+    name = "foo",
+    deps = ["@rules_python//python/cc:current_py_cc_headers"]
+)
+```
+""",
+)
diff --git a/python/private/py_cc_toolchain_info.bzl b/python/private/py_cc_toolchain_info.bzl
new file mode 100644
index 0000000..e7afc10
--- /dev/null
+++ b/python/private/py_cc_toolchain_info.bzl
@@ -0,0 +1,43 @@
+# Copyright 2023 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 PyCcToolchainInfo."""
+
+PyCcToolchainInfo = provider(
+    doc = "C/C++ information about the Python runtime.",
+    fields = {
+        "headers": """\
+(struct) Information about the header files, 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 headers.
+      * DefaultInfo: the DefaultInfo provider instance for the headers.
+
+    A map is used to allow additional providers from the originating headers
+    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 begining 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_macro.bzl b/python/private/py_cc_toolchain_macro.bzl
new file mode 100644
index 0000000..35276f7
--- /dev/null
+++ b/python/private/py_cc_toolchain_macro.bzl
@@ -0,0 +1,31 @@
+# Copyright 2023 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.
+
+"""Fronting macro for the py_cc_toolchain rule."""
+
+load(":py_cc_toolchain_rule.bzl", _py_cc_toolchain = "py_cc_toolchain")
+load(":util.bzl", "add_tag")
+
+# A fronting macro is used because macros have user-observable behavior;
+# using one from the onset avoids introducing those changes in the future.
+def py_cc_toolchain(**kwargs):
+    """Creates a py_cc_toolchain target.
+
+    Args:
+        **kwargs: Keyword args to pass onto underlying rule.
+    """
+
+    #  This tag is added to easily identify usages through other macros.
+    add_tag(kwargs, "@rules_python//python:py_cc_toolchain")
+    _py_cc_toolchain(**kwargs)
diff --git a/python/private/py_cc_toolchain_rule.bzl b/python/private/py_cc_toolchain_rule.bzl
new file mode 100644
index 0000000..c80f845
--- /dev/null
+++ b/python/private/py_cc_toolchain_rule.bzl
@@ -0,0 +1,57 @@
+# Copyright 2023 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 py_cc_toolchain rule.
+
+NOTE: This is a beta-quality feature. APIs subject to change until
+https://github.com/bazelbuild/rules_python/issues/824 is considered done.
+"""
+
+load(":py_cc_toolchain_info.bzl", "PyCcToolchainInfo")
+
+def _py_cc_toolchain_impl(ctx):
+    py_cc_toolchain = PyCcToolchainInfo(
+        headers = struct(
+            providers_map = {
+                "CcInfo": ctx.attr.headers[CcInfo],
+                "DefaultInfo": ctx.attr.headers[DefaultInfo],
+            },
+        ),
+        python_version = ctx.attr.python_version,
+    )
+    return [platform_common.ToolchainInfo(
+        py_cc_toolchain = py_cc_toolchain,
+    )]
+
+py_cc_toolchain = rule(
+    implementation = _py_cc_toolchain_impl,
+    attrs = {
+        "headers": attr.label(
+            doc = ("Target that provides the Python headers. Typically this " +
+                   "is a cc_library target."),
+            providers = [CcInfo],
+            mandatory = True,
+        ),
+        "python_version": attr.string(
+            doc = "The Major.minor Python version, e.g. 3.11",
+            mandatory = True,
+        ),
+    },
+    doc = """\
+A toolchain for a Python runtime's C/C++ information (e.g. headers)
+
+This rule carries information about the C/C++ side of a Python runtime, e.g.
+headers, shared libraries, etc.
+""",
+)
diff --git a/python/private/toolchains_repo.bzl b/python/private/toolchains_repo.bzl
index f47ea8f..5923787 100644
--- a/python/private/toolchains_repo.bzl
+++ b/python/private/toolchains_repo.bzl
@@ -83,6 +83,15 @@
     toolchain = "@{user_repository_name}_{platform}//:python_runtimes",
     toolchain_type = "@bazel_tools//tools/python:toolchain_type",
 )
+
+toolchain(
+    name = "{prefix}{platform}_py_cc_toolchain",
+    target_compatible_with = {compatible_with},
+    target_settings = {target_settings},
+    toolchain = "@{user_repository_name}_{platform}//:py_cc_toolchain",
+    toolchain_type = "@rules_python//python/cc:toolchain_type",
+
+)
 """.format(
             compatible_with = meta.compatible_with,
             platform = platform,
diff --git a/python/private/util.bzl b/python/private/util.bzl
index f0d4373..4c4b8fc 100644
--- a/python/private/util.bzl
+++ b/python/private/util.bzl
@@ -1,7 +1,25 @@
+# Copyright 2023 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.
+
 """Functionality shared by multiple pieces of code."""
 
 load("@bazel_skylib//lib:types.bzl", "types")
 
+# When bzlmod is enabled, canonical repos names have @@ in them, while under
+# workspace builds, there is never a @@ in labels.
+BZLMOD_ENABLED = "@@" in str(Label("//:unused"))
+
 def copy_propagating_kwargs(from_kwargs, into_kwargs = None):
     """Copies args that must be compatible between two targets with a dependency relationship.
 
@@ -46,15 +64,26 @@
     Returns:
         The same `attrs` object, but modified.
     """
+    add_tag(attrs, _MIGRATION_TAG)
+    return attrs
+
+def add_tag(attrs, tag):
+    """Adds `tag` to `attrs["tags"]`.
+
+    Args:
+        attrs: dict of keyword args. It is modified in place.
+        tag: str, the tag to add.
+    """
     if "tags" in attrs and attrs["tags"] != None:
         tags = attrs["tags"]
 
         # Preserve the input type: this allows a test verifying the underlying
         # rule can accept the tuple for the tags argument.
         if types.is_tuple(tags):
-            attrs["tags"] = tags + (_MIGRATION_TAG,)
+            attrs["tags"] = tags + (tag,)
         else:
-            attrs["tags"] = tags + [_MIGRATION_TAG]
+            # List concatenation is necessary because the original value
+            # may be a frozen list.
+            attrs["tags"] = tags + [tag]
     else:
-        attrs["tags"] = [_MIGRATION_TAG]
-    return attrs
+        attrs["tags"] = [tag]
diff --git a/python/repositories.bzl b/python/repositories.bzl
index 04de657..38a580e 100644
--- a/python/repositories.bzl
+++ b/python/repositories.bzl
@@ -265,6 +265,7 @@
 # Generated by python/repositories.bzl
 
 load("@bazel_tools//tools/python:toolchain.bzl", "py_runtime_pair")
+load("@rules_python//python/cc:py_cc_toolchain.bzl", "py_cc_toolchain")
 
 package(default_visibility = ["//visibility:public"])
 
@@ -336,6 +337,12 @@
     py2_runtime = None,
     py3_runtime = ":py3_runtime",
 )
+
+py_cc_toolchain(
+    name = "py_cc_toolchain",
+    headers = ":python_headers",
+    python_version = "{python_version}",
+)
 """.format(
         glob_exclude = repr(glob_exclude),
         glob_include = repr(glob_include),
diff --git a/tests/BUILD.bazel b/tests/BUILD.bazel
index abbe62d..2dd2282 100644
--- a/tests/BUILD.bazel
+++ b/tests/BUILD.bazel
@@ -28,5 +28,7 @@
         "//python:py_runtime_info_bzl",
         "//python:py_runtime_pair_bzl",
         "//python:py_test_bzl",
+        "//python/cc:py_cc_toolchain_bzl",
+        "//python/cc:py_cc_toolchain_info_bzl",
     ],
 )
diff --git a/tests/cc/BUILD.bazel b/tests/cc/BUILD.bazel
new file mode 100644
index 0000000..1339557
--- /dev/null
+++ b/tests/cc/BUILD.bazel
@@ -0,0 +1,108 @@
+# Copyright 2023 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("@rules_cc//cc:defs.bzl", "cc_toolchain", "cc_toolchain_suite")
+load("@rules_testing//lib:util.bzl", "PREVENT_IMPLICIT_BUILDING_TAGS")
+load("//python/cc:py_cc_toolchain.bzl", "py_cc_toolchain")
+load(":fake_cc_toolchain_config.bzl", "fake_cc_toolchain_config")
+
+package(default_visibility = ["//:__subpackages__"])
+
+toolchain(
+    name = "fake_py_cc_toolchain",
+    tags = PREVENT_IMPLICIT_BUILDING_TAGS,
+    toolchain = ":fake_py_cc_toolchain_impl",
+    toolchain_type = "@rules_python//python/cc:toolchain_type",
+)
+
+py_cc_toolchain(
+    name = "fake_py_cc_toolchain_impl",
+    testonly = True,
+    headers = ":fake_headers",
+    python_version = "3.999",
+    tags = PREVENT_IMPLICIT_BUILDING_TAGS,
+)
+
+# buildifier: disable=native-cc
+cc_library(
+    name = "fake_headers",
+    testonly = True,
+    hdrs = ["fake_header.h"],
+    data = ["data.txt"],
+    includes = ["fake_include"],
+    tags = PREVENT_IMPLICIT_BUILDING_TAGS,
+)
+
+cc_toolchain_suite(
+    name = "cc_toolchain_suite",
+    tags = ["manual"],
+    toolchains = {
+        "darwin_x86_64": ":mac_toolchain",
+        "k8": ":linux_toolchain",
+    },
+)
+
+filegroup(name = "empty")
+
+cc_toolchain(
+    name = "mac_toolchain",
+    all_files = ":empty",
+    compiler_files = ":empty",
+    dwp_files = ":empty",
+    linker_files = ":empty",
+    objcopy_files = ":empty",
+    strip_files = ":empty",
+    supports_param_files = 0,
+    toolchain_config = ":mac_toolchain_config",
+    toolchain_identifier = "mac-toolchain",
+)
+
+toolchain(
+    name = "mac_toolchain_definition",
+    target_compatible_with = ["@platforms//os:macos"],
+    toolchain = ":mac_toolchain",
+    toolchain_type = "@bazel_tools//tools/cpp:toolchain_type",
+)
+
+fake_cc_toolchain_config(
+    name = "mac_toolchain_config",
+    target_cpu = "darwin_x86_64",
+    toolchain_identifier = "mac-toolchain",
+)
+
+cc_toolchain(
+    name = "linux_toolchain",
+    all_files = ":empty",
+    compiler_files = ":empty",
+    dwp_files = ":empty",
+    linker_files = ":empty",
+    objcopy_files = ":empty",
+    strip_files = ":empty",
+    supports_param_files = 0,
+    toolchain_config = ":linux_toolchain_config",
+    toolchain_identifier = "linux-toolchain",
+)
+
+toolchain(
+    name = "linux_toolchain_definition",
+    target_compatible_with = ["@platforms//os:linux"],
+    toolchain = ":linux_toolchain",
+    toolchain_type = "@bazel_tools//tools/cpp:toolchain_type",
+)
+
+fake_cc_toolchain_config(
+    name = "linux_toolchain_config",
+    target_cpu = "k8",
+    toolchain_identifier = "linux-toolchain",
+)
diff --git a/tests/cc/current_py_cc_headers/BUILD.bazel b/tests/cc/current_py_cc_headers/BUILD.bazel
new file mode 100644
index 0000000..e2d6a1b
--- /dev/null
+++ b/tests/cc/current_py_cc_headers/BUILD.bazel
@@ -0,0 +1,17 @@
+# Copyright 2023 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_headers_tests.bzl", "current_py_cc_headers_test_suite")
+
+current_py_cc_headers_test_suite(name = "current_py_cc_headers_tests")
diff --git a/tests/cc/current_py_cc_headers/current_py_cc_headers_tests.bzl b/tests/cc/current_py_cc_headers/current_py_cc_headers_tests.bzl
new file mode 100644
index 0000000..2b8b2ee
--- /dev/null
+++ b/tests/cc/current_py_cc_headers/current_py_cc_headers_tests.bzl
@@ -0,0 +1,69 @@
+# Copyright 2023 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_headers."""
+
+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_headers(name):
+    analysis_test(
+        name = name,
+        impl = _test_current_toolchain_headers_impl,
+        target = "//python/cc:current_py_cc_headers",
+        config_settings = {
+            "//command_line_option:extra_toolchains": [str(Label("//tests/cc:all"))],
+        },
+        attrs = {
+            "header": attr.label(
+                default = "//tests/cc:fake_header.h",
+                allow_single_file = True,
+            ),
+        },
+    )
+
+def _test_current_toolchain_headers_impl(env, target):
+    # Check that the forwarded CcInfo looks vaguely correct.
+    compilation_context = env.expect.that_target(target).provider(
+        CcInfo,
+        factory = cc_info_subject,
+    ).compilation_context()
+    compilation_context.direct_headers().contains_exactly([
+        env.ctx.file.header,
+    ])
+    compilation_context.direct_public_headers().contains_exactly([
+        env.ctx.file.header,
+    ])
+
+    # NOTE: The include dir gets added twice, once for the source path,
+    # and once for the config-specific path.
+    compilation_context.system_includes().contains_at_least_predicates([
+        matching.str_matches("*/fake_include"),
+    ])
+
+    # Check that the forward DefaultInfo looks correct
+    env.expect.that_target(target).runfiles().contains_predicate(
+        matching.str_matches("*/cc/data.txt"),
+    )
+
+_tests.append(_test_current_toolchain_headers)
+
+def current_py_cc_headers_test_suite(name):
+    test_suite(
+        name = name,
+        tests = _tests,
+    )
diff --git a/tools/build_defs/python/tests/fake_cc_toolchain_config.bzl b/tests/cc/fake_cc_toolchain_config.bzl
similarity index 100%
rename from tools/build_defs/python/tests/fake_cc_toolchain_config.bzl
rename to tests/cc/fake_cc_toolchain_config.bzl
diff --git a/tests/cc/py_cc_toolchain/BUILD.bazel b/tests/cc/py_cc_toolchain/BUILD.bazel
new file mode 100644
index 0000000..57d030c
--- /dev/null
+++ b/tests/cc/py_cc_toolchain/BUILD.bazel
@@ -0,0 +1,3 @@
+load(":py_cc_toolchain_tests.bzl", "py_cc_toolchain_test_suite")
+
+py_cc_toolchain_test_suite(name = "py_cc_toolchain_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
new file mode 100644
index 0000000..09bd646
--- /dev/null
+++ b/tests/cc/py_cc_toolchain/py_cc_toolchain_tests.bzl
@@ -0,0 +1,85 @@
+# Copyright 2023 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 py_cc_toolchain."""
+
+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")
+load("//tests:default_info_subject.bzl", "default_info_subject")
+load("//tests:py_cc_toolchain_info_subject.bzl", "PyCcToolchainInfoSubject")
+
+_tests = []
+
+def _py_cc_toolchain_test(name):
+    analysis_test(
+        name = name,
+        impl = _py_cc_toolchain_test_impl,
+        target = "//tests/cc:fake_py_cc_toolchain_impl",
+        attrs = {
+            "header": attr.label(
+                default = "//tests/cc:fake_header.h",
+                allow_single_file = True,
+            ),
+        },
+    )
+
+def _py_cc_toolchain_test_impl(env, target):
+    env.expect.that_target(target).has_provider(platform_common.ToolchainInfo)
+
+    toolchain = PyCcToolchainInfoSubject.new(
+        target[platform_common.ToolchainInfo].py_cc_toolchain,
+        meta = env.expect.meta.derive(expr = "py_cc_toolchain_info"),
+    )
+    toolchain.python_version().equals("3.999")
+
+    toolchain_headers = toolchain.headers()
+    toolchain_headers.providers_map().keys().contains_exactly(["CcInfo", "DefaultInfo"])
+
+    cc_info = cc_info_subject(
+        # TODO: Use DictSubject.get once available,
+        # https://github.com/bazelbuild/rules_testing/issues/51
+        toolchain_headers.actual.providers_map["CcInfo"],
+        meta = env.expect.meta.derive(expr = "cc_info"),
+    )
+
+    compilation_context = cc_info.compilation_context()
+    compilation_context.direct_headers().contains_exactly([
+        env.ctx.file.header,
+    ])
+    compilation_context.direct_public_headers().contains_exactly([
+        env.ctx.file.header,
+    ])
+
+    # NOTE: The include dir gets added twice, once for the source path,
+    # and once for the config-specific path, but we don't care about that.
+    compilation_context.system_includes().contains_at_least_predicates([
+        matching.str_matches("*/fake_include"),
+    ])
+
+    default_info = default_info_subject(
+        toolchain_headers.actual.providers_map["DefaultInfo"],
+        meta = env.expect.meta.derive(expr = "default_info"),
+    )
+    default_info.runfiles().contains_predicate(
+        matching.str_matches("*/cc/data.txt"),
+    )
+
+_tests.append(_py_cc_toolchain_test)
+
+def py_cc_toolchain_test_suite(name):
+    test_suite(
+        name = name,
+        tests = _tests,
+    )
diff --git a/tests/cc_info_subject.bzl b/tests/cc_info_subject.bzl
new file mode 100644
index 0000000..31ac03a
--- /dev/null
+++ b/tests/cc_info_subject.bzl
@@ -0,0 +1,128 @@
+# Copyright 2023 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.
+"""CcInfo testing subject."""
+
+load("@rules_testing//lib:truth.bzl", "subjects")
+
+def cc_info_subject(info, *, meta):
+    """Creates a new `CcInfoSubject` for a CcInfo provider instance.
+
+    Args:
+        info: The CcInfo object.
+        meta: ExpectMeta object.
+
+    Returns:
+        A `CcInfoSubject` struct.
+    """
+
+    # buildifier: disable=uninitialized
+    public = struct(
+        # go/keep-sorted start
+        compilation_context = lambda *a, **k: _cc_info_subject_compilation_context(self, *a, **k),
+        # go/keep-sorted end
+    )
+    self = struct(
+        actual = info,
+        meta = meta,
+    )
+    return public
+
+def _cc_info_subject_compilation_context(self):
+    """Returns the CcInfo.compilation_context as a subject.
+
+    Args:
+        self: implicitly added.
+
+    Returns:
+        [`CompilationContext`] instance.
+    """
+    return _compilation_context_subject_new(
+        self.actual.compilation_context,
+        meta = self.meta.derive("compilation_context()"),
+    )
+
+def _compilation_context_subject_new(info, *, meta):
+    """Creates a CompilationContextSubject.
+
+    Args:
+        info: ([`CompilationContext`]) object instance.
+        meta: rules_testing `ExpectMeta` instance.
+
+    Returns:
+        [`CompilationContextSubject`] object.
+    """
+
+    # buildifier: disable=uninitialized
+    public = struct(
+        # go/keep-sorted start
+        direct_headers = lambda *a, **k: _compilation_context_subject_direct_headers(self, *a, **k),
+        direct_public_headers = lambda *a, **k: _compilation_context_subject_direct_public_headers(self, *a, **k),
+        system_includes = lambda *a, **k: _compilation_context_subject_system_includes(self, *a, **k),
+        # go/keep-sorted end
+    )
+    self = struct(
+        actual = info,
+        meta = meta,
+    )
+    return public
+
+def _compilation_context_subject_direct_headers(self):
+    """Returns the direct headers as a subjecct.
+
+    Args:
+        self: implicitly added
+
+    Returns:
+        [`CollectionSubject`] of `File` objects of the direct headers.
+    """
+    return subjects.collection(
+        self.actual.direct_headers,
+        meta = self.meta.derive("direct_headers()"),
+        container_name = "direct_headers",
+        element_plural_name = "header files",
+    )
+
+def _compilation_context_subject_direct_public_headers(self):
+    """Returns the direct public headers as a subjecct.
+
+    Args:
+        self: implicitly added
+
+    Returns:
+        [`CollectionSubject`] of `File` objects of the direct headers.
+    """
+    return subjects.collection(
+        self.actual.direct_public_headers,
+        meta = self.meta.derive("direct_public_headers()"),
+        container_name = "direct_public_headers",
+        element_plural_name = "public header files",
+    )
+
+def _compilation_context_subject_system_includes(self):
+    """Returns the system include directories as a subject.
+
+    NOTE: The system includes are the `cc_library.includes` attribute.
+
+    Args:
+        self: implicitly added
+
+    Returns:
+        [`CollectionSubject`] of [`str`]
+    """
+    return subjects.collection(
+        self.actual.system_includes.to_list(),
+        meta = self.meta.derive("includes()"),
+        container_name = "includes",
+        element_plural_name = "include paths",
+    )
diff --git a/tests/default_info_subject.bzl b/tests/default_info_subject.bzl
new file mode 100644
index 0000000..205dc1e
--- /dev/null
+++ b/tests/default_info_subject.bzl
@@ -0,0 +1,34 @@
+# Copyright 2023 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.
+"""DefaultInfo testing subject."""
+
+# TODO: Load this through truth.bzl#subjects when made available
+# https://github.com/bazelbuild/rules_testing/issues/54
+load("@rules_testing//lib/private:runfiles_subject.bzl", "RunfilesSubject")  # buildifier: disable=bzl-visibility
+
+# TODO: Use rules_testing's DefaultInfoSubject once it's available
+# https://github.com/bazelbuild/rules_testing/issues/52
+def default_info_subject(info, *, meta):
+    # buildifier: disable=uninitialized
+    public = struct(
+        runfiles = lambda *a, **k: _default_info_subject_runfiles(self, *a, **k),
+    )
+    self = struct(actual = info, meta = meta)
+    return public
+
+def _default_info_subject_runfiles(self):
+    return RunfilesSubject.new(
+        self.actual.default_runfiles,
+        meta = self.meta.derive("runfiles()"),
+    )
diff --git a/tests/py_cc_toolchain_info_subject.bzl b/tests/py_cc_toolchain_info_subject.bzl
new file mode 100644
index 0000000..20585e9
--- /dev/null
+++ b/tests/py_cc_toolchain_info_subject.bzl
@@ -0,0 +1,52 @@
+# Copyright 2023 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.
+"""PyCcToolchainInfo testing subject."""
+
+# TODO: Load this through truth.bzl#subjects when made available
+# https://github.com/bazelbuild/rules_testing/issues/54
+load("@rules_testing//lib/private:dict_subject.bzl", "DictSubject")  # buildifier: disable=bzl-visibility
+
+# TODO: Load this through truth.bzl#subjects when made available
+# https://github.com/bazelbuild/rules_testing/issues/54
+load("@rules_testing//lib/private:str_subject.bzl", "StrSubject")  # buildifier: disable=bzl-visibility
+load(":struct_subject.bzl", "struct_subject")
+
+def _py_cc_toolchain_info_subject_new(info, *, meta):
+    # buildifier: disable=uninitialized
+    public = struct(
+        headers = lambda *a, **k: _py_cc_toolchain_info_subject_headers(self, *a, **k),
+        python_version = lambda *a, **k: _py_cc_toolchain_info_subject_python_version(self, *a, **k),
+        actual = info,
+    )
+    self = struct(actual = info, meta = meta)
+    return public
+
+def _py_cc_toolchain_info_subject_headers(self):
+    return struct_subject(
+        self.actual.headers,
+        meta = self.meta.derive("headers()"),
+        providers_map = DictSubject.new,
+    )
+
+def _py_cc_toolchain_info_subject_python_version(self):
+    return StrSubject.new(
+        self.actual.python_version,
+        meta = self.meta.derive("python_version()"),
+    )
+
+# Disable this to aid doc generation
+# buildifier: disable=name-conventions
+PyCcToolchainInfoSubject = struct(
+    new = _py_cc_toolchain_info_subject_new,
+)
diff --git a/tests/struct_subject.bzl b/tests/struct_subject.bzl
new file mode 100644
index 0000000..9d18980
--- /dev/null
+++ b/tests/struct_subject.bzl
@@ -0,0 +1,50 @@
+# Copyright 2023 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.
+
+# TODO: Replace this with rules_testing StructSubject
+# https://github.com/bazelbuild/rules_testing/issues/53
+"""Subject for an arbitrary struct."""
+
+def struct_subject(actual, *, meta, **attr_factories):
+    """Creates a struct subject.
+
+    Args:
+        actual: struct, the struct to wrap.
+        meta: rules_testing ExpectMeta object.
+        **attr_factories: dict of attribute names to factory functions. Each
+            attribute must exist on the `actual` value. The factory functions
+            have the signature `def factory(value, *, meta)`, where `value`
+            is the actual attribute value of the struct, and `meta` is
+            a rules_testing ExpectMeta object.
+
+    Returns:
+        StructSubject object.
+    """
+    public_attrs = {}
+    for name, factory in attr_factories.items():
+        if not hasattr(actual, name):
+            fail("Struct missing attribute: '{}'".format(name))
+
+        def attr_accessor(*, __name = name, __factory = factory):
+            return __factory(
+                getattr(actual, __name),
+                meta = meta.derive(__name + "()"),
+            )
+
+        public_attrs[name] = attr_accessor
+    public = struct(
+        actual = actual,
+        **public_attrs
+    )
+    return public
diff --git a/tools/build_defs/python/tests/BUILD.bazel b/tools/build_defs/python/tests/BUILD.bazel
index b5694e2..e271850 100644
--- a/tools/build_defs/python/tests/BUILD.bazel
+++ b/tools/build_defs/python/tests/BUILD.bazel
@@ -12,9 +12,6 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-load("@rules_cc//cc:defs.bzl", "cc_toolchain", "cc_toolchain_suite")
-load(":fake_cc_toolchain_config.bzl", "fake_cc_toolchain_config")
-
 platform(
     name = "mac",
     constraint_values = [
@@ -28,66 +25,3 @@
         "@platforms//os:linux",
     ],
 )
-
-cc_toolchain_suite(
-    name = "cc_toolchain_suite",
-    tags = ["manual"],
-    toolchains = {
-        "darwin_x86_64": ":mac_toolchain",
-        "k8": ":linux_toolchain",
-    },
-)
-
-filegroup(name = "empty")
-
-cc_toolchain(
-    name = "mac_toolchain",
-    all_files = ":empty",
-    compiler_files = ":empty",
-    dwp_files = ":empty",
-    linker_files = ":empty",
-    objcopy_files = ":empty",
-    strip_files = ":empty",
-    supports_param_files = 0,
-    toolchain_config = ":mac_toolchain_config",
-    toolchain_identifier = "mac-toolchain",
-)
-
-toolchain(
-    name = "mac_toolchain_definition",
-    target_compatible_with = ["@platforms//os:macos"],
-    toolchain = ":mac_toolchain",
-    toolchain_type = "@bazel_tools//tools/cpp:toolchain_type",
-)
-
-fake_cc_toolchain_config(
-    name = "mac_toolchain_config",
-    target_cpu = "darwin_x86_64",
-    toolchain_identifier = "mac-toolchain",
-)
-
-cc_toolchain(
-    name = "linux_toolchain",
-    all_files = ":empty",
-    compiler_files = ":empty",
-    dwp_files = ":empty",
-    linker_files = ":empty",
-    objcopy_files = ":empty",
-    strip_files = ":empty",
-    supports_param_files = 0,
-    toolchain_config = ":linux_toolchain_config",
-    toolchain_identifier = "linux-toolchain",
-)
-
-toolchain(
-    name = "linux_toolchain_definition",
-    target_compatible_with = ["@platforms//os:linux"],
-    toolchain = ":linux_toolchain",
-    toolchain_type = "@bazel_tools//tools/cpp:toolchain_type",
-)
-
-fake_cc_toolchain_config(
-    name = "linux_toolchain_config",
-    target_cpu = "k8",
-    toolchain_identifier = "linux-toolchain",
-)
diff --git a/tools/build_defs/python/tests/py_test/py_test_tests.bzl b/tools/build_defs/python/tests/py_test/py_test_tests.bzl
index 5983581..1ecb252 100644
--- a/tools/build_defs/python/tests/py_test/py_test_tests.bzl
+++ b/tools/build_defs/python/tests/py_test/py_test_tests.bzl
@@ -24,8 +24,8 @@
 
 # Explicit Label() calls are required so that it resolves in @rules_python context instead of
 # @rules_testing context.
-_FAKE_CC_TOOLCHAIN = Label("//tools/build_defs/python/tests:cc_toolchain_suite")
-_FAKE_CC_TOOLCHAINS = [str(Label("//tools/build_defs/python/tests:all"))]
+_FAKE_CC_TOOLCHAIN = Label("//tests/cc:cc_toolchain_suite")
+_FAKE_CC_TOOLCHAINS = [str(Label("//tests/cc:all"))]
 _PLATFORM_MAC = Label("//tools/build_defs/python/tests:mac")
 _PLATFORM_LINUX = Label("//tools/build_defs/python/tests:linux")