test(pytest): support python_versions in pytest_test macro (#4064)

test(pytest): support python_versions in pytest_test macro

Testing pytest-based suites across multiple Python versions previously
required manually defining individual targets for each Python version.

Update the \`pytest_test\` macro in test support helpers to accept an
optional \`python_versions\` list. When specified, version-specific
\`pytest_test\` targets with formatted names are generated and grouped
under a root \`test_suite\`.
diff --git a/tests/pytest_test/BUILD.bazel b/tests/pytest_test/BUILD.bazel
index b15094a..204671c 100644
--- a/tests/pytest_test/BUILD.bazel
+++ b/tests/pytest_test/BUILD.bazel
@@ -1,5 +1,11 @@
 load("//tests/support:support.bzl", "SUPPORTS_BZLMOD")
-load("//tests/support/pytest_test:pytest_test.bzl", "pytest_test")
+load(
+    "//tests/support/pytest_test:pytest_test.bzl",
+    "pytest_test",
+)
+load(":pytest_test_tests.bzl", "pytest_test_test_suite")
+
+pytest_test_test_suite(name = "pytest_test_tests")
 
 pytest_test(
     name = "pytest_script_venv_test",
@@ -20,3 +26,15 @@
     ],
     target_compatible_with = SUPPORTS_BZLMOD,
 )
+
+pytest_test(
+    name = "pytest_multipy_default_test",
+    srcs = [
+        "basic_test.py",
+    ],
+    python_versions = [
+        "3.14",
+        "3.13",
+    ],
+    target_compatible_with = SUPPORTS_BZLMOD,
+)
diff --git a/tests/pytest_test/pytest_test_tests.bzl b/tests/pytest_test/pytest_test_tests.bzl
new file mode 100644
index 0000000..8699ac2
--- /dev/null
+++ b/tests/pytest_test/pytest_test_tests.bzl
@@ -0,0 +1,37 @@
+"""Tests for pytest_test."""
+
+load("@rules_testing//lib:test_suite.bzl", "test_suite")
+load(
+    "//tests/support/pytest_test:pytest_test.bzl",
+    "get_version_test_name",
+)
+
+_tests = []
+
+def _test_get_version_test_name(env):
+    want = {
+        ("foo_test", "3.14"): "foo_py3.14_test",
+        ("foo_test", "3.10"): "foo_py3.10_test",
+        ("foo_test", "py3.14"): "foo_py3.14_test",
+        ("foo_tests", "3.14"): "foo_py3.14_tests",
+        ("foo", "3.14"): "foo_py3.14",
+        ("test_foo", "3.14"): "test_foo_py3.14",
+        ("basic_test", "3.11"): "basic_py3.11_test",
+        ("pytest_default_test", "3.12"): "pytest_default_py3.12_test",
+    }
+
+    actual = {
+        (name, ver): get_version_test_name(name, ver)
+        for (name, ver) in want.keys()
+    }
+    env.expect.that_dict(actual).contains_exactly(want)
+
+_tests.append(_test_get_version_test_name)
+
+def pytest_test_test_suite(name):
+    """Create the test suite.
+
+    Args:
+        name: The name of the test suite.
+    """
+    test_suite(name = name, basic_tests = _tests)
diff --git a/tests/support/pytest_test/pytest_test.bzl b/tests/support/pytest_test/pytest_test.bzl
index 893b22e..bbdfffb 100644
--- a/tests/support/pytest_test/pytest_test.bzl
+++ b/tests/support/pytest_test/pytest_test.bzl
@@ -11,6 +11,7 @@
         srcs,
         pytest = None,
         pytest_bazel = None,
+        python_versions = None,
         **kwargs):
     """Run pytest tests.
 
@@ -21,9 +22,76 @@
         pytest: The pytest target to use. Defaults to @pypi//pytest.
         pytest_bazel: The pytest-bazel target to use. Defaults to
             @pypi//pytest_bazel.
+        python_versions: List of Python versions to test against. If specified,
+            a test is created for each version and grouped under a test_suite
+            named `name`.
         **kwargs: Additional arguments passed to py_test. Note that `main` is
             not a supported argument.
     """
+    if python_versions != None:
+        _multi_pytest_test(
+            name = name,
+            srcs = srcs,
+            pytest = pytest,
+            pytest_bazel = pytest_bazel,
+            python_versions = python_versions,
+            **kwargs
+        )
+    else:
+        _single_pytest_test(
+            name = name,
+            srcs = srcs,
+            pytest = pytest,
+            pytest_bazel = pytest_bazel,
+            **kwargs
+        )
+
+def _multi_pytest_test(
+        *,
+        name,
+        srcs,
+        pytest = None,
+        pytest_bazel = None,
+        python_versions,
+        **kwargs):
+    if "python_version" in kwargs:
+        fail(
+            "Cannot specify both python_version and python_versions in " +
+            "pytest_test; use one or the other.",
+        )
+    if not python_versions:
+        fail("python_versions must not be empty for {}".format(name))
+
+    tests = []
+    for python_version in python_versions:
+        test_name = _get_version_test_name(name, python_version)
+        _single_pytest_test(
+            name = test_name,
+            srcs = srcs,
+            pytest = pytest,
+            pytest_bazel = pytest_bazel,
+            python_version = python_version,
+            **kwargs
+        )
+        tests.append(":" + test_name)
+
+    test_suite_kwargs = {}
+    if "visibility" in kwargs:
+        test_suite_kwargs["visibility"] = kwargs["visibility"]
+
+    native.test_suite(
+        name = name,
+        tests = tests,
+        **test_suite_kwargs
+    )
+
+def _single_pytest_test(
+        *,
+        name,
+        srcs,
+        pytest = None,
+        pytest_bazel = None,
+        **kwargs):
     if pytest == None:
         pytest = _DEFAULT_PYTEST
     if pytest_bazel == None:
@@ -37,17 +105,32 @@
         output_name = main_file,
     )
 
+    kwargs = dict(kwargs)
+    deps = kwargs.pop("deps", [])
     py_test(
         name = name,
         main = main_file,
         srcs = [bootstrap_target] + srcs,
-        deps = kwargs.pop("deps", []) + [
+        deps = deps + [
             pytest,
             pytest_bazel,
         ],
         **kwargs
     )
 
+def _get_version_test_name(name, python_version):
+    version_str = str(python_version)
+    if not version_str.startswith("py"):
+        version_str = "py" + version_str
+
+    if name.endswith("_test"):
+        return "{}_{}_test".format(name[:-len("_test")], version_str)
+    elif name.endswith("_tests"):
+        return "{}_{}_tests".format(name[:-len("_tests")], version_str)
+    return "{}_{}".format(name, version_str)
+
+get_version_test_name = _get_version_test_name
+
 def _write_pytest_bootstrap_impl(ctx):
     output = ctx.actions.declare_file(ctx.attr.output_name)
     test_files = "\n".join([f.short_path for f in ctx.files.srcs])