tests(pystar): py_runtime_pair and py_runtime analysis tests (#1441)

These analysis tests verify that `py_runtime` and `py_runtime_pair` are
working as intended
for both the native Bazel and Starlark implementations.

Work towards #1069
diff --git a/python/BUILD.bazel b/python/BUILD.bazel
index f9c93e5..3884349 100644
--- a/python/BUILD.bazel
+++ b/python/BUILD.bazel
@@ -147,7 +147,11 @@
 bzl_library(
     name = "py_runtime_pair_bzl",
     srcs = ["py_runtime_pair.bzl"],
-    deps = ["//python/private:bazel_tools_bzl"],
+    deps = [
+        "//python/private:bazel_tools_bzl",
+        "//python/private:py_runtime_pair_macro_bzl",
+        "@rules_python_internal//:rules_python_config_bzl",
+    ],
 )
 
 bzl_library(
diff --git a/python/private/BUILD.bazel b/python/private/BUILD.bazel
index a67183e..f0eddad 100644
--- a/python/private/BUILD.bazel
+++ b/python/private/BUILD.bazel
@@ -104,7 +104,8 @@
 
 bzl_library(
     name = "py_runtime_pair_macro_bzl",
-    srcs = ["py_runtime_pair_rule.bzl"],
+    srcs = ["py_runtime_pair_macro.bzl"],
+    visibility = ["//:__subpackages__"],
     deps = [":py_runtime_pair_rule_bzl"],
 )
 
diff --git a/python/py_runtime_pair.bzl b/python/py_runtime_pair.bzl
index 951c606..c80994c 100644
--- a/python/py_runtime_pair.bzl
+++ b/python/py_runtime_pair.bzl
@@ -14,7 +14,11 @@
 
 """Public entry point for py_runtime_pair."""
 
-load("@bazel_tools//tools/python:toolchain.bzl", _py_runtime_pair = "py_runtime_pair")
+load("@bazel_tools//tools/python:toolchain.bzl", _bazel_tools_impl = "py_runtime_pair")
+load("@rules_python_internal//:rules_python_config.bzl", "config")
+load("//python/private:py_runtime_pair_macro.bzl", _starlark_impl = "py_runtime_pair")
+
+_py_runtime_pair = _bazel_tools_impl if not config.enable_pystar else _starlark_impl
 
 # NOTE: This doc is copy/pasted from the builtin py_runtime_pair rule so our
 # doc generator gives useful API docs.
diff --git a/tests/py_runtime/BUILD.bazel b/tests/py_runtime/BUILD.bazel
new file mode 100644
index 0000000..e097f0d
--- /dev/null
+++ b/tests/py_runtime/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(":py_runtime_tests.bzl", "py_runtime_test_suite")
+
+py_runtime_test_suite(name = "py_runtime_tests")
diff --git a/tests/py_runtime/py_runtime_tests.bzl b/tests/py_runtime/py_runtime_tests.bzl
new file mode 100644
index 0000000..662909c
--- /dev/null
+++ b/tests/py_runtime/py_runtime_tests.bzl
@@ -0,0 +1,262 @@
+# 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.
+"""Starlark tests for py_runtime rule."""
+
+load("@rules_python_internal//:rules_python_config.bzl", "config")
+load("@rules_testing//lib:analysis_test.bzl", "analysis_test")
+load("@rules_testing//lib:test_suite.bzl", "test_suite")
+load("@rules_testing//lib:truth.bzl", "matching")
+load("@rules_testing//lib:util.bzl", rt_util = "util")
+load("//python:py_runtime.bzl", "py_runtime")
+load("//python:py_runtime_info.bzl", "PyRuntimeInfo")
+load("//tests:py_runtime_info_subject.bzl", "py_runtime_info_subject")
+load("//tests/base_rules:util.bzl", br_util = "util")
+
+_tests = []
+
+_SKIP_TEST = {
+    "target_compatible_with": ["@platforms//:incompatible"],
+}
+
+def _test_bootstrap_template(name):
+    # The bootstrap_template arg isn't present in older Bazel versions, so
+    # we have to conditionally pass the arg and mark the test incompatible.
+    if config.enable_pystar:
+        py_runtime_kwargs = {"bootstrap_template": "bootstrap.txt"}
+        attr_values = {}
+    else:
+        py_runtime_kwargs = {}
+        attr_values = _SKIP_TEST
+
+    rt_util.helper_target(
+        py_runtime,
+        name = name + "_subject",
+        interpreter_path = "/py",
+        python_version = "PY3",
+        **py_runtime_kwargs
+    )
+    analysis_test(
+        name = name,
+        target = name + "_subject",
+        impl = _test_bootstrap_template_impl,
+        attr_values = attr_values,
+    )
+
+def _test_bootstrap_template_impl(env, target):
+    env.expect.that_target(target).provider(
+        PyRuntimeInfo,
+        factory = py_runtime_info_subject,
+    ).bootstrap_template().path().contains("bootstrap.txt")
+
+_tests.append(_test_bootstrap_template)
+
+def _test_cannot_have_both_inbuild_and_system_interpreter(name):
+    if br_util.is_bazel_6_or_higher():
+        py_runtime_kwargs = {
+            "interpreter": "fake_interpreter",
+            "interpreter_path": "/some/path",
+        }
+        attr_values = {}
+    else:
+        py_runtime_kwargs = {
+            "interpreter_path": "/some/path",
+        }
+        attr_values = _SKIP_TEST
+    rt_util.helper_target(
+        py_runtime,
+        name = name + "_subject",
+        python_version = "PY3",
+        **py_runtime_kwargs
+    )
+    analysis_test(
+        name = name,
+        target = name + "_subject",
+        impl = _test_cannot_have_both_inbuild_and_system_interpreter_impl,
+        expect_failure = True,
+        attr_values = attr_values,
+    )
+
+def _test_cannot_have_both_inbuild_and_system_interpreter_impl(env, target):
+    env.expect.that_target(target).failures().contains_predicate(
+        matching.str_matches("one of*interpreter*interpreter_path"),
+    )
+
+_tests.append(_test_cannot_have_both_inbuild_and_system_interpreter)
+
+def _test_cannot_specify_files_for_system_interpreter(name):
+    if br_util.is_bazel_6_or_higher():
+        py_runtime_kwargs = {"files": ["foo.txt"]}
+        attr_values = {}
+    else:
+        py_runtime_kwargs = {}
+        attr_values = _SKIP_TEST
+    rt_util.helper_target(
+        py_runtime,
+        name = name + "_subject",
+        interpreter_path = "/foo",
+        python_version = "PY3",
+        **py_runtime_kwargs
+    )
+    analysis_test(
+        name = name,
+        target = name + "_subject",
+        impl = _test_cannot_specify_files_for_system_interpreter_impl,
+        expect_failure = True,
+        attr_values = attr_values,
+    )
+
+def _test_cannot_specify_files_for_system_interpreter_impl(env, target):
+    env.expect.that_target(target).failures().contains_predicate(
+        matching.str_matches("files*must be empty"),
+    )
+
+_tests.append(_test_cannot_specify_files_for_system_interpreter)
+
+def _test_in_build_interpreter(name):
+    rt_util.helper_target(
+        py_runtime,
+        name = name + "_subject",
+        interpreter = "fake_interpreter",
+        python_version = "PY3",
+        files = ["file1.txt"],
+    )
+    analysis_test(
+        name = name,
+        target = name + "_subject",
+        impl = _test_in_build_interpreter_impl,
+    )
+
+def _test_in_build_interpreter_impl(env, target):
+    info = env.expect.that_target(target).provider(PyRuntimeInfo, factory = py_runtime_info_subject)
+    info.python_version().equals("PY3")
+    info.files().contains_predicate(matching.file_basename_equals("file1.txt"))
+    info.interpreter().path().contains("fake_interpreter")
+
+_tests.append(_test_in_build_interpreter)
+
+def _test_must_have_either_inbuild_or_system_interpreter(name):
+    if br_util.is_bazel_6_or_higher():
+        py_runtime_kwargs = {}
+        attr_values = {}
+    else:
+        py_runtime_kwargs = {
+            "interpreter_path": "/some/path",
+        }
+        attr_values = _SKIP_TEST
+    rt_util.helper_target(
+        py_runtime,
+        name = name + "_subject",
+        python_version = "PY3",
+        **py_runtime_kwargs
+    )
+    analysis_test(
+        name = name,
+        target = name + "_subject",
+        impl = _test_must_have_either_inbuild_or_system_interpreter_impl,
+        expect_failure = True,
+        attr_values = attr_values,
+    )
+
+def _test_must_have_either_inbuild_or_system_interpreter_impl(env, target):
+    env.expect.that_target(target).failures().contains_predicate(
+        matching.str_matches("one of*interpreter*interpreter_path"),
+    )
+
+_tests.append(_test_must_have_either_inbuild_or_system_interpreter)
+
+def _test_python_version_required(name):
+    # Bazel 5.4 will entirely crash when python_version is missing.
+    if br_util.is_bazel_6_or_higher():
+        py_runtime_kwargs = {}
+        attr_values = {}
+    else:
+        py_runtime_kwargs = {"python_version": "PY3"}
+        attr_values = _SKIP_TEST
+    rt_util.helper_target(
+        py_runtime,
+        name = name + "_subject",
+        interpreter_path = "/math/pi",
+        **py_runtime_kwargs
+    )
+    analysis_test(
+        name = name,
+        target = name + "_subject",
+        impl = _test_python_version_required_impl,
+        expect_failure = True,
+        attr_values = attr_values,
+    )
+
+def _test_python_version_required_impl(env, target):
+    env.expect.that_target(target).failures().contains_predicate(
+        matching.str_matches("must be set*PY2*PY3"),
+    )
+
+_tests.append(_test_python_version_required)
+
+def _test_system_interpreter(name):
+    rt_util.helper_target(
+        py_runtime,
+        name = name + "_subject",
+        interpreter_path = "/system/python",
+        python_version = "PY3",
+    )
+    analysis_test(
+        name = name,
+        target = name + "_subject",
+        impl = _test_system_interpreter_impl,
+    )
+
+def _test_system_interpreter_impl(env, target):
+    env.expect.that_target(target).provider(
+        PyRuntimeInfo,
+        factory = py_runtime_info_subject,
+    ).interpreter_path().equals("/system/python")
+
+_tests.append(_test_system_interpreter)
+
+def _test_system_interpreter_must_be_absolute(name):
+    # Bazel 5.4 will entirely crash when an invalid interpreter_path
+    # is given.
+    if br_util.is_bazel_6_or_higher():
+        py_runtime_kwargs = {"interpreter_path": "relative/path"}
+        attr_values = {}
+    else:
+        py_runtime_kwargs = {"interpreter_path": "/junk/value/for/bazel5.4"}
+        attr_values = _SKIP_TEST
+    rt_util.helper_target(
+        py_runtime,
+        name = name + "_subject",
+        python_version = "PY3",
+        **py_runtime_kwargs
+    )
+    analysis_test(
+        name = name,
+        target = name + "_subject",
+        impl = _test_system_interpreter_must_be_absolute_impl,
+        expect_failure = True,
+        attr_values = attr_values,
+    )
+
+def _test_system_interpreter_must_be_absolute_impl(env, target):
+    env.expect.that_target(target).failures().contains_predicate(
+        matching.str_matches("must be*absolute"),
+    )
+
+_tests.append(_test_system_interpreter_must_be_absolute)
+
+def py_runtime_test_suite(name):
+    test_suite(
+        name = name,
+        tests = _tests,
+    )
diff --git a/tests/py_runtime_info_subject.bzl b/tests/py_runtime_info_subject.bzl
new file mode 100644
index 0000000..9f42d3a
--- /dev/null
+++ b/tests/py_runtime_info_subject.bzl
@@ -0,0 +1,101 @@
+# 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.
+"""PyRuntimeInfo testing subject."""
+
+load("@rules_testing//lib:truth.bzl", "subjects")
+
+def py_runtime_info_subject(info, *, meta):
+    """Creates a new `PyRuntimeInfoSubject` for a PyRuntimeInfo provider instance.
+
+    Method: PyRuntimeInfoSubject.new
+
+    Args:
+        info: The PyRuntimeInfo object
+        meta: ExpectMeta object.
+
+    Returns:
+        A `PyRuntimeInfoSubject` struct
+    """
+
+    # buildifier: disable=uninitialized
+    public = struct(
+        # go/keep-sorted start
+        bootstrap_template = lambda *a, **k: _py_runtime_info_subject_bootstrap_template(self, *a, **k),
+        coverage_files = lambda *a, **k: _py_runtime_info_subject_coverage_files(self, *a, **k),
+        coverage_tool = lambda *a, **k: _py_runtime_info_subject_coverage_tool(self, *a, **k),
+        files = lambda *a, **k: _py_runtime_info_subject_files(self, *a, **k),
+        interpreter = lambda *a, **k: _py_runtime_info_subject_interpreter(self, *a, **k),
+        interpreter_path = lambda *a, **k: _py_runtime_info_subject_interpreter_path(self, *a, **k),
+        python_version = lambda *a, **k: _py_runtime_info_subject_python_version(self, *a, **k),
+        stub_shebang = lambda *a, **k: _py_runtime_info_subject_stub_shebang(self, *a, **k),
+        # go/keep-sorted end
+    )
+    self = struct(
+        actual = info,
+        meta = meta,
+    )
+    return public
+
+def _py_runtime_info_subject_bootstrap_template(self):
+    return subjects.file(
+        self.actual.bootstrap_template,
+        meta = self.meta.derive("bootstrap_template()"),
+    )
+
+def _py_runtime_info_subject_coverage_files(self):
+    """Returns a `DepsetFileSubject` for the `coverage_files` attribute.
+
+    Args:
+        self: implicitly added.
+    """
+    return subjects.depset_file(
+        self.actual.coverage_files,
+        meta = self.meta.derive("coverage_files()"),
+    )
+
+def _py_runtime_info_subject_coverage_tool(self):
+    return subjects.file(
+        self.actual.coverage_tool,
+        meta = self.meta.derive("coverage_tool()"),
+    )
+
+def _py_runtime_info_subject_files(self):
+    return subjects.depset_file(
+        self.actual.files,
+        meta = self.meta.derive("files()"),
+    )
+
+def _py_runtime_info_subject_interpreter(self):
+    return subjects.file(
+        self.actual.interpreter,
+        meta = self.meta.derive("interpreter()"),
+    )
+
+def _py_runtime_info_subject_interpreter_path(self):
+    return subjects.str(
+        self.actual.interpreter_path,
+        meta = self.meta.derive("interpreter_path()"),
+    )
+
+def _py_runtime_info_subject_python_version(self):
+    return subjects.str(
+        self.actual.python_version,
+        meta = self.meta.derive("python_version()"),
+    )
+
+def _py_runtime_info_subject_stub_shebang(self):
+    return subjects.str(
+        self.actual.stub_shebang,
+        meta = self.meta.derive("stub_shebang()"),
+    )
diff --git a/tests/py_runtime_pair/BUILD.bazel b/tests/py_runtime_pair/BUILD.bazel
new file mode 100644
index 0000000..6a6a4b9
--- /dev/null
+++ b/tests/py_runtime_pair/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(":py_runtime_pair_tests.bzl", "py_runtime_pair_test_suite")
+
+py_runtime_pair_test_suite(name = "py_runtime_pair_tests")
diff --git a/tests/py_runtime_pair/py_runtime_pair_tests.bzl b/tests/py_runtime_pair/py_runtime_pair_tests.bzl
new file mode 100644
index 0000000..e1ff19e
--- /dev/null
+++ b/tests/py_runtime_pair/py_runtime_pair_tests.bzl
@@ -0,0 +1,66 @@
+# 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.
+"""Starlark tests for py_runtime_pair rule."""
+
+load("@rules_testing//lib:analysis_test.bzl", "analysis_test")
+load("@rules_testing//lib:test_suite.bzl", "test_suite")
+load("@rules_testing//lib:truth.bzl", "matching", "subjects")
+load("@rules_testing//lib:util.bzl", rt_util = "util")
+load("//python:py_runtime.bzl", "py_runtime")
+load("//python:py_runtime_pair.bzl", "py_runtime_pair")
+load("//tests:py_runtime_info_subject.bzl", "py_runtime_info_subject")
+
+_tests = []
+
+def _test_basic(name):
+    rt_util.helper_target(
+        py_runtime,
+        name = name + "_runtime",
+        interpreter = "fake_interpreter",
+        python_version = "PY3",
+        files = ["file1.txt"],
+    )
+    rt_util.helper_target(
+        py_runtime_pair,
+        name = name + "_subject",
+        py3_runtime = name + "_runtime",
+    )
+    analysis_test(
+        name = name,
+        target = name + "_subject",
+        impl = _test_basic_impl,
+    )
+
+def _test_basic_impl(env, target):
+    toolchain = env.expect.that_target(target).provider(
+        platform_common.ToolchainInfo,
+        factory = lambda value, meta: subjects.struct(
+            value,
+            meta = meta,
+            attrs = {
+                "py3_runtime": py_runtime_info_subject,
+            },
+        ),
+    )
+    toolchain.py3_runtime().python_version().equals("PY3")
+    toolchain.py3_runtime().files().contains_predicate(matching.file_basename_equals("file1.txt"))
+    toolchain.py3_runtime().interpreter().path().contains("fake_interpreter")
+
+_tests.append(_test_basic)
+
+def py_runtime_pair_test_suite(name):
+    test_suite(
+        name = name,
+        tests = _tests,
+    )