feat(packaging): support Python toolchain make variables in py_wheel (#4257)

Setting version-specific wheel tags (`python_tag`, `abi`, `platform`)
and `python_requires` metadata in `py_wheel` previously required custom
`select()` expressions or `TemplateVariableInfo` rules. Additionally,
`platform` and `python_requires` did not expand make variables.

Populate `TemplateVariableInfo` on `py_runtime_pair` with Python
version, implementation, and wheel tag variables (such as
`PY_WHEEL_PYTHON_TAG`, `PY_WHEEL_ABI_TAG`, and `PY_MAJOR_DOT_MINOR`),
and forward them through `current_py_toolchain`. Attach the target
Python toolchain to `py_wheel` so these variables are available by
default, and expand make variables in `platform` and `python_requires`.
diff --git a/docs/howto/build-a-wheel.md b/docs/howto/build-a-wheel.md
index 0248ff8..d93155f 100644
--- a/docs/howto/build-a-wheel.md
+++ b/docs/howto/build-a-wheel.md
@@ -84,4 +84,87 @@
 ```
 # .bazelrc
 build --incompatible_default_to_explicit_init_py=true
-```
\ No newline at end of file
+```
+
+## Setting wheel filename tags from the Python version
+
+To automatically generate the wheel filename and `Requires-Python` metadata for
+the active Python version, use make variable syntax in `abi`, `platform`,
+`python_requires`, `python_tag`, and `version`:
+
+```starlark
+# BUILD.bazel
+load("@rules_python//python:packaging.bzl", "py_wheel")
+
+py_wheel(
+    name = "my_project_wheel",
+    abi = "$(PY_WHEEL_ABI_TAG)",
+    distribution = "my-project",
+    python_requires = ">=$(PY_MAJOR_DOT_MINOR)",
+    python_tag = "$(PY_WHEEL_PYTHON_TAG)",
+    version = "0.1.0",
+    deps = [":my_project_lib"],
+)
+```
+
+When built with CPython 3.12, this produces
+`my_project-0.1.0-cp312-cp312-any.whl` and sets `Requires-Python: >=3.12` in the
+wheel `METADATA`. With freethreaded CPython 3.13, `$(PY_WHEEL_ABI_TAG)` expands
+to `cp313t`.
+
+### Available Python version variables
+
+The Python toolchain provides the following make variables by default:
+
+| Variable | Description | Example |
+| :--- | :--- | :--- |
+| `$(PY_WHEEL_PYTHON_TAG)` | Wheel Python tag | `cp312` |
+| `$(PY_WHEEL_ABI_TAG)` | Wheel ABI tag | `cp312`, `cp313t` |
+| `$(PY_WHEEL_IMPLEMENTATION_TAG)` | Wheel implementation prefix | `cp` |
+| `$(PY_MAJOR_DOT_MINOR)` | Dotted `major.minor` version | `3.12` |
+| `$(PY_MAJOR_MINOR_NODOT)` | Dotless `majorminor` version | `312` |
+| `$(PY_MAJOR)` | Major version | `3` |
+| `$(PY_MINOR)` | Minor version | `12` |
+| `$(PY_ABI_FLAGS)` | Runtime ABI flags (`sys.abiflags`) | `""`, `"t"` |
+| `$(PY_IMPLEMENTATION_NAME)` | Implementation name | `cpython` |
+
+See {rule}`py_runtime_pair` for the full list of variables.
+
+### Adding custom make variables
+
+To define custom make variables (for example, to compute the package version or
+platform tag in Starlark), write a rule that returns
+`platform_common.TemplateVariableInfo` and pass it in the `toolchains` attribute
+of {obj}`py_wheel`:
+
+```starlark
+# wheel_vars.bzl
+def _wheel_vars_impl(ctx):
+    return [platform_common.TemplateVariableInfo({
+        "MY_VERSION": "1.2.3",
+    })]
+
+wheel_vars = rule(
+    implementation = _wheel_vars_impl,
+)
+```
+
+```starlark
+# BUILD.bazel
+load("@rules_python//python:packaging.bzl", "py_wheel")
+load(":wheel_vars.bzl", "wheel_vars")
+
+wheel_vars(
+    name = "vars",
+)
+
+py_wheel(
+    name = "my_project_wheel",
+    abi = "$(PY_WHEEL_ABI_TAG)",
+    distribution = "my-project",
+    python_tag = "$(PY_WHEEL_PYTHON_TAG)",
+    toolchains = [":vars"],
+    version = "$(MY_VERSION)",
+    deps = [":my_project_lib"],
+)
+```
diff --git a/news/py-wheel-makevars.added.md b/news/py-wheel-makevars.added.md
new file mode 100644
index 0000000..2ae604c
--- /dev/null
+++ b/news/py-wheel-makevars.added.md
@@ -0,0 +1,3 @@
+(packaging) {obj}`py_wheel` now expands Python toolchain make variables by
+default and in `platform` and `python_requires`. {obj}`py_runtime_pair` and
+{obj}`current_py_toolchain` now provide version and wheel tag make variables.
diff --git a/python/current_py_toolchain.bzl b/python/current_py_toolchain.bzl
index 4fb9eb9..0e7ab68 100644
--- a/python/current_py_toolchain.bzl
+++ b/python/current_py_toolchain.bzl
@@ -41,10 +41,15 @@
         vars["PYTHON2"] = toolchain.py2_runtime.interpreter_path
         vars["PYTHON2_ROOTPATH"] = toolchain.py2_runtime.interpreter_path
 
+    if getattr(toolchain, "template_vars", None):
+        template_vars = toolchain.template_vars
+    else:
+        template_vars = platform_common.TemplateVariableInfo(vars)
+
     files = depset(direct, transitive = transitive)
     return [
         toolchain,
-        platform_common.TemplateVariableInfo(vars),
+        template_vars,
         DefaultInfo(
             runfiles = ctx.runfiles(transitive_files = files),
             files = files,
@@ -53,14 +58,21 @@
 
 current_py_toolchain = rule(
     doc = """
-    This rule exists so that the current python toolchain can be used in the `toolchains` attribute of
-    other rules, such as genrule. It allows exposing a python toolchain after toolchain resolution has
-    happened, to a rule which expects a concrete implementation of a toolchain, rather than a
+    This rule exists so that the current python toolchain can be used in the
+    `toolchains` attribute of other rules, such as genrule. It allows exposing a
+    python toolchain after toolchain resolution has happened, to a rule which
+    expects a concrete implementation of a toolchain, rather than a
     toolchain_type which could be resolved to that toolchain.
 
     :::{versionchanged} 1.4.0
-    From now on, we also expose `$(PYTHON2_ROOTPATH)` and `$(PYTHON3_ROOTPATH)` which are runfiles
-    locations equivalents of `$(PYTHON2)` and `$(PYTHON3) respectively.
+    From now on, we also expose `$(PYTHON2_ROOTPATH)` and `$(PYTHON3_ROOTPATH)`
+    which are runfiles locations equivalents of `$(PYTHON2)` and `$(PYTHON3)`
+    respectively.
+    :::
+
+    :::{versionchanged} VERSION_NEXT_FEATURE
+    Exposes make variables from the underlying toolchain's `template_vars`
+    (see {rule}`py_runtime_pair`).
     :::
     """,
     implementation = _current_py_toolchain_impl,
diff --git a/python/packaging.bzl b/python/packaging.bzl
index fb333ca..1ec1d38 100644
--- a/python/packaging.bzl
+++ b/python/packaging.bzl
@@ -97,7 +97,8 @@
     Wheels are Python distribution format defined in {pep}`PEP 427`.
 
     This macro packages a set of targets into a single wheel.
-    It wraps the [py_wheel rule](#py_wheel_rule).
+    It wraps the [py_wheel rule](#py_wheel_rule). See
+    {doc}`/howto/build-a-wheel` for a usage guide.
 
     Currently only pure-python wheels are supported.
 
diff --git a/python/private/BUILD.bazel b/python/private/BUILD.bazel
index d13e13a..0f5c078 100644
--- a/python/private/BUILD.bazel
+++ b/python/private/BUILD.bazel
@@ -671,6 +671,7 @@
         ":common_labels",
         ":reexports",
         "//python:py_runtime_info",
+        "//python/private/pypi:python_tag",
         "@bazel_skylib//rules:common_settings",
     ],
 )
@@ -730,6 +731,7 @@
         ":py_package",
         ":rule_builders",
         ":stamp_impl",
+        ":toolchain_types",
         ":transition_labels",
         ":version",
     ],
diff --git a/python/private/py_runtime_pair_rule.bzl b/python/private/py_runtime_pair_rule.bzl
index c6c4c34..c339e0b 100644
--- a/python/private/py_runtime_pair_rule.bzl
+++ b/python/private/py_runtime_pair_rule.bzl
@@ -16,9 +16,53 @@
 
 load("@bazel_skylib//rules:common_settings.bzl", "BuildSettingInfo")
 load("//python:py_runtime_info.bzl", "PyRuntimeInfo")
+load("//python/private/pypi:python_tag.bzl", "python_tag")
 load(":common_labels.bzl", "labels")
 load(":reexports.bzl", "BuiltinPyRuntimeInfo")
 
+def _get_template_vars(py3_runtime):
+    vars = {}
+    if py3_runtime.interpreter:
+        vars["PYTHON3"] = py3_runtime.interpreter.path
+        vars["PYTHON3_ROOTPATH"] = py3_runtime.interpreter.short_path
+    elif py3_runtime.interpreter_path:
+        vars["PYTHON3"] = py3_runtime.interpreter_path
+        vars["PYTHON3_ROOTPATH"] = py3_runtime.interpreter_path
+
+    # BuiltinPyRuntimeInfo (used in Bazel 7) lacks rules_python-specific
+    # fields like abi_flags, implementation_name, and
+    # interpreter_version_info.
+    if hasattr(py3_runtime, "abi_flags"):
+        abi_flags = py3_runtime.abi_flags
+        vars["PY_ABI_FLAGS"] = abi_flags
+
+        impl_name = py3_runtime.implementation_name
+        impl_tag = None
+        if impl_name:
+            impl_tag = python_tag(impl_name)
+            vars["PY_IMPLEMENTATION_NAME"] = impl_name
+            vars["PY_WHEEL_IMPLEMENTATION_TAG"] = impl_tag
+
+        version_info = py3_runtime.interpreter_version_info
+        if (
+            version_info and
+            version_info.major != None and
+            version_info.minor != None
+        ):
+            major = str(version_info.major)
+            minor = str(version_info.minor)
+            major_minor_nodot = "{}{}".format(major, minor)
+            vars["PY_MAJOR"] = major
+            vars["PY_MINOR"] = minor
+            vars["PY_MAJOR_DOT_MINOR"] = "{}.{}".format(major, minor)
+            vars["PY_MAJOR_MINOR_NODOT"] = major_minor_nodot
+            if impl_tag:
+                py_tag = "{}{}".format(impl_tag, major_minor_nodot)
+                vars["PY_WHEEL_PYTHON_TAG"] = py_tag
+                vars["PY_WHEEL_ABI_TAG"] = "{}{}".format(py_tag, abi_flags)
+
+    return platform_common.TemplateVariableInfo(vars)
+
 def _py_runtime_pair_impl(ctx):
     if ctx.attr.py2_runtime != None:
         py2_runtime = _get_py_runtime_info(ctx.attr.py2_runtime)
@@ -44,11 +88,17 @@
     if ctx.attr._visible_for_testing[BuildSettingInfo].value:
         extra_kwargs["toolchain_label"] = ctx.label
 
-    return [platform_common.ToolchainInfo(
-        py2_runtime = py2_runtime,
-        py3_runtime = py3_runtime,
-        **extra_kwargs
-    )]
+    template_vars = _get_template_vars(py3_runtime)
+
+    return [
+        platform_common.ToolchainInfo(
+            py2_runtime = py2_runtime,
+            py3_runtime = py3_runtime,
+            template_vars = template_vars,
+            **extra_kwargs
+        ),
+        template_vars,
+    ]
 
 def _get_py_runtime_info(target):
     # Prior to Bazel 7, the builtin PyRuntimeInfo object must be used because
@@ -70,7 +120,7 @@
         "py2_runtime": attr.label(
             providers = [[PyRuntimeInfo]] + _MaybeBuiltinPyRuntimeInfo,
             cfg = "target",
-            doc = """\
+            doc = """
 The runtime to use for Python 2 targets. Must have `python_version` set to
 `PY2`.
 """,
@@ -78,7 +128,7 @@
         "py3_runtime": attr.label(
             providers = [[PyRuntimeInfo]] + _MaybeBuiltinPyRuntimeInfo,
             cfg = "target",
-            doc = """\
+            doc = """
 The runtime to use for Python 3 targets. Must have `python_version` set to
 `PY3`.
 """,
@@ -88,7 +138,7 @@
         ),
     },
     fragments = ["py"],
-    doc = """\
+    doc = """
 A toolchain rule for Python.
 
 This wraps up to two Python runtimes, one for Python 2 and one for Python 3.
@@ -103,8 +153,26 @@
 
 * `py2_runtime`: {type}`PyRuntimeInfo | None`, runtime information for a
   Python 2 runtime.
-* `py3_runtime`: {type}`PyRuntimeInfo | None`. runtime information for a
+* `py3_runtime`: {type}`PyRuntimeInfo | None`, runtime information for a
   Python 3 runtime.
+* `template_vars`: {type}`TemplateVariableInfo`, make variables exposed by this
+  toolchain.
+
+It also returns a `TemplateVariableInfo` provider with these make variables
+(when the corresponding runtime values are set):
+
+* `$(PYTHON3)`: Python 3 interpreter path.
+* `$(PYTHON3_ROOTPATH)`: Python 3 interpreter runfiles path.
+* `$(PY_ABI_FLAGS)`: Runtime ABI flags (`sys.abiflags`), e.g. `""` or `"t"`.
+* `$(PY_IMPLEMENTATION_NAME)`: Implementation name (`sys.implementation.name`),
+  e.g. `cpython`.
+* `$(PY_MAJOR)`: Major version, e.g. `3`.
+* `$(PY_MINOR)`: Minor version, e.g. `12`.
+* `$(PY_MAJOR_DOT_MINOR)`: Dotted major and minor version, e.g. `3.12`.
+* `$(PY_MAJOR_MINOR_NODOT)`: Dotless major and minor version, e.g. `312`.
+* `$(PY_WHEEL_ABI_TAG)`: Wheel ABI tag, e.g. `cp312` or `cp313t`.
+* `$(PY_WHEEL_IMPLEMENTATION_TAG)`: Wheel implementation tag, e.g. `cp`.
+* `$(PY_WHEEL_PYTHON_TAG)`: Wheel Python tag, e.g. `cp312`.
 
 Example usage:
 
@@ -145,5 +213,9 @@
 
 register_toolchains("//my_pkg:my_toolchain")
 ```
+
+:::{versionchanged} VERSION_NEXT_FEATURE
+Provides `TemplateVariableInfo` and `ToolchainInfo.template_vars`.
+:::
 """,
 )
diff --git a/python/private/py_wheel.bzl b/python/private/py_wheel.bzl
index c28aeff..c44623f 100644
--- a/python/private/py_wheel.bzl
+++ b/python/private/py_wheel.bzl
@@ -19,6 +19,7 @@
 load(":py_package.bzl", "py_package_lib")
 load(":rule_builders.bzl", "ruleb")
 load(":stamp_impl.bzl", "is_stamping_enabled")
+load(":toolchain_types.bzl", "TARGET_TOOLCHAIN_TYPE")
 load(":transition_labels.bzl", "TRANSITION_LABELS")
 load(":version.bzl", "version")
 
@@ -36,7 +37,17 @@
 _distribution_attrs = {
     "abi": attr.string(
         default = "none",
-        doc = "Python ABI tag. 'none' for pure-Python wheels.",
+        doc = """
+Python ABI tag. 'none' for pure-Python wheels.
+
+Supports make variables, e.g. `abi = "$(PY_WHEEL_ABI_TAG)"`. See
+{rule}`py_runtime_pair` for variables provided by the Python toolchain.
+
+:::{versionchanged} VERSION_NEXT_FEATURE
+Python toolchain make variables (such as `$(PY_WHEEL_ABI_TAG)`) are available by
+default.
+:::
+""",
     ),
     "compress": attr.bool(
         default = True,
@@ -59,7 +70,7 @@
     ),
     "platform": attr.string(
         default = "any",
-        doc = """\
+        doc = """
 Supported platform. Use 'any' for pure-Python wheel.
 
 If you have included platform-specific data, such as a .pyd or .so
@@ -75,11 +86,27 @@
     "//platforms:linux_x86_64": "manylinux2014_x86_64",
 })
 `
+
+Supports make variables.
+
+:::{versionchanged} VERSION_NEXT_FEATURE
+Supports make variables.
+:::
 """,
     ),
     "python_tag": attr.string(
         default = "py3",
-        doc = "Supported Python version(s), eg `py3`, `cp35.cp36`, etc",
+        doc = """
+Supported Python version(s), eg `py3`, `cp35.cp36`, etc.
+
+Supports make variables, e.g. `python_tag = "$(PY_WHEEL_PYTHON_TAG)"`. See
+{rule}`py_runtime_pair` for variables provided by the Python toolchain.
+
+:::{versionchanged} VERSION_NEXT_FEATURE
+Python toolchain make variables (such as `$(PY_WHEEL_PYTHON_TAG)`) are available
+by default.
+:::
+""",
     ),
     "stamp": attr.int(
         doc = """\
@@ -257,9 +284,16 @@
                'e.g `{{"Bug Tracker": "http://bitbucket.org/tarek/distribute/issues/"}}`'),
     ),
     "python_requires": attr.string(
-        doc = (
-            "Python versions required by this distribution, e.g. '>=3.5,<3.7'"
-        ),
+        doc = """
+Python versions required by this distribution, e.g. '>=3.5,<3.7'.
+
+Supports make variables, e.g. `python_requires = "==$(PY_MAJOR_DOT_MINOR).*"`.
+See {rule}`py_runtime_pair` for variables provided by the Python toolchain.
+
+:::{versionchanged} VERSION_NEXT_FEATURE
+Supports make variables.
+:::
+""",
         default = "",
     ),
     "strip_path_prefixes": attr.string_list(
@@ -360,6 +394,8 @@
 
 def _py_wheel_impl(ctx):
     abi = _replace_make_variables(ctx.attr.abi, ctx)
+    platform = _replace_make_variables(ctx.attr.platform, ctx)
+    python_requires = _replace_make_variables(ctx.attr.python_requires, ctx)
     python_tag = _replace_make_variables(ctx.attr.python_tag, ctx)
     version_str = _replace_make_variables(ctx.attr.version, ctx)
 
@@ -368,7 +404,7 @@
         version.normalize(version_str),
         _escape_filename_segment(python_tag),
         _escape_filename_segment(abi),
-        _escape_filename_segment(ctx.attr.platform),
+        _escape_filename_segment(platform),
     ]
 
     outfile = ctx.actions.declare_file("-".join(filename_segments) + ".whl")
@@ -402,7 +438,7 @@
     args.add("--version", version_str)
     args.add("--python_tag", python_tag)
     args.add("--abi", abi)
-    args.add("--platform", ctx.attr.platform)
+    args.add("--platform", platform)
     args.add("--out", outfile)
     args.add("--name_file", name_file)
     args.add_all(ctx.attr.strip_path_prefixes, format_each = "--strip_path_prefix=%s")
@@ -450,8 +486,8 @@
     for c in ctx.attr.classifiers:
         metadata_contents.append("Classifier: %s" % c)
 
-    if ctx.attr.python_requires:
-        metadata_contents.append("Requires-Python: %s" % ctx.attr.python_requires)
+    if python_requires:
+        metadata_contents.append("Requires-Python: %s" % python_requires)
 
     if ctx.attr.requires and ctx.attr.requires_file:
         fail("`requires` and `requires_file` are mutually exclusive. Please update {}".format(ctx.label))
@@ -640,7 +676,7 @@
 
 py_wheel = ruleb.Rule(
     implementation = py_wheel_lib.implementation,
-    doc = """\
+    doc = """
 Internal rule used by the [py_wheel macro](#py_wheel).
 
 These intentionally have the same name to avoid sharp edges with Bazel macros.
@@ -653,4 +689,7 @@
         inputs = TRANSITION_LABELS,
         outputs = TRANSITION_LABELS,
     ),
+    toolchains = [
+        ruleb.ToolchainType(TARGET_TOOLCHAIN_TYPE, mandatory = False),
+    ],
 ).build()
diff --git a/python/py_runtime_pair.bzl b/python/py_runtime_pair.bzl
index f44c722..497baf9 100644
--- a/python/py_runtime_pair.bzl
+++ b/python/py_runtime_pair.bzl
@@ -23,20 +23,21 @@
 
     This is a macro around the underlying {rule}`py_runtime_pair` rule.
 
-    This used to wrap up to two Python runtimes, one for Python 2 and one for Python 3.
-    However, Python 2 is no longer supported, so it now only wraps a single Python 3
-    runtime.
+    This used to wrap up to two Python runtimes, one for Python 2 and one for
+    Python 3. However, Python 2 is no longer supported, so it now only wraps a
+    single Python 3 runtime.
 
-    Usually the wrapped runtimes are declared using the `py_runtime` rule, but any
-    rule returning a `PyRuntimeInfo` provider may be used.
+    Usually the wrapped runtimes are declared using the `py_runtime` rule, but
+    any rule returning a `PyRuntimeInfo` provider may be used.
 
-    This rule returns a `platform_common.ToolchainInfo` provider with the following
-    schema:
+    This rule returns a `platform_common.ToolchainInfo` provider with the
+    following schema:
 
     ```python
     platform_common.ToolchainInfo(
         py2_runtime = None,
         py3_runtime = <PyRuntimeInfo or None>,
+        template_vars = <TemplateVariableInfo>,
     )
     ```
 
diff --git a/tests/py_runtime_pair/py_runtime_pair_tests.bzl b/tests/py_runtime_pair/py_runtime_pair_tests.bzl
index f865697..eeb847f 100644
--- a/tests/py_runtime_pair/py_runtime_pair_tests.bzl
+++ b/tests/py_runtime_pair/py_runtime_pair_tests.bzl
@@ -144,6 +144,60 @@
 
 _tests.append(_test_py_runtime_pair_and_binary)
 
+def _template_var_info_factory(value, meta):
+    return subjects.struct(
+        value,
+        meta = meta,
+        attrs = {
+            "variables": subjects.dict,
+        },
+    )
+
+def _test_template_variables(name):
+    rt_util.helper_target(
+        py_runtime,
+        name = name + "_runtime",
+        abi_flags = "t",
+        implementation_name = "cpython",
+        interpreter_path = "/fake_interpreter",
+        interpreter_version_info = {
+            "major": "3",
+            "minor": "13",
+        },
+        python_version = "PY3",
+    )
+    rt_util.helper_target(
+        py_runtime_pair,
+        name = name + "_subject",
+        py3_runtime = name + "_runtime",
+    )
+    analysis_test(
+        name = name,
+        target = name + "_subject",
+        impl = _test_template_variables_impl,
+    )
+
+def _test_template_variables_impl(env, target):
+    template_vars = env.expect.that_target(target).provider(
+        platform_common.TemplateVariableInfo,
+        factory = _template_var_info_factory,
+    )
+    template_vars.variables().contains_exactly({
+        "PYTHON3": "/fake_interpreter",
+        "PYTHON3_ROOTPATH": "/fake_interpreter",
+        "PY_ABI_FLAGS": "t",
+        "PY_IMPLEMENTATION_NAME": "cpython",
+        "PY_MAJOR": "3",
+        "PY_MAJOR_DOT_MINOR": "3.13",
+        "PY_MAJOR_MINOR_NODOT": "313",
+        "PY_MINOR": "13",
+        "PY_WHEEL_ABI_TAG": "cp313t",
+        "PY_WHEEL_IMPLEMENTATION_TAG": "cp",
+        "PY_WHEEL_PYTHON_TAG": "cp313",
+    })
+
+_tests.append(_test_template_variables)
+
 def py_runtime_pair_test_suite(name):
     test_suite(
         name = name,
diff --git a/tests/py_wheel/py_wheel_tests.bzl b/tests/py_wheel/py_wheel_tests.bzl
index f39397b..92fb73f 100644
--- a/tests/py_wheel/py_wheel_tests.bzl
+++ b/tests/py_wheel/py_wheel_tests.bzl
@@ -206,6 +206,113 @@
 
 _tests.append(_test_config_settings)
 
+def _test_default_make_variables(name):
+    rt_util.helper_target(
+        py_wheel,
+        name = name + "_subject",
+        abi = "$(PY_WHEEL_ABI_TAG)",
+        config_settings = {
+            labels.PY_FREETHREADED: "yes",
+            labels.PYTHON_VERSION: "3.13",
+        },
+        distribution = "mydist_" + name,
+        platform = "linux_$(PY_IMPLEMENTATION_NAME)_$(PY_ABI_FLAGS)",
+        python_requires = "==$(PY_MAJOR_DOT_MINOR).*",
+        python_tag = (
+            "$(PY_WHEEL_PYTHON_TAG)." +
+            "$(PY_WHEEL_IMPLEMENTATION_TAG)$(PY_MAJOR)$(PY_MINOR)." +
+            "py$(PY_MAJOR_MINOR_NODOT)"
+        ),
+        version = "1.0.0",
+    )
+    analysis_test(
+        name = name,
+        impl = _test_default_make_variables_impl,
+        target = name + "_subject",
+    )
+
+def _test_default_make_variables_impl(env, target):
+    env.expect.that_target(target).default_outputs().contains_exactly([
+        "{package}/mydist_{test_name}-1.0.0-" +
+        "cp313.cp313.py313-cp313t-linux_cpython_t.whl",
+    ])
+    action = env.expect.that_target(target).action_named("PyWheel")
+    action.contains_at_least_args([
+        "--version",
+        "1.0.0",
+        "--python_tag",
+        "cp313.cp313.py313",
+        "--abi",
+        "cp313t",
+        "--platform",
+        "linux_cpython_t",
+    ])
+    metadata_action = env.expect.that_target(target).action_generating(
+        "{package}/{name}.metadata.txt",
+    )
+    metadata_action.content().split("\n").contains(
+        "Requires-Python: ==3.13.*",
+    )
+
+_tests.append(_test_default_make_variables)
+
+def _make_vars_impl(ctx):
+    return [platform_common.TemplateVariableInfo(ctx.attr.vars)]
+
+_make_vars = rule(
+    implementation = _make_vars_impl,
+    attrs = {
+        "vars": attr.string_dict(),
+    },
+)
+
+def _test_custom_make_variables(name):
+    rt_util.helper_target(
+        _make_vars,
+        name = name + "_vars",
+        vars = {
+            "PLATFORM": "manylinux2014_x86_64",
+            "VERSION": "1.2.3",
+        },
+    )
+    rt_util.helper_target(
+        py_wheel,
+        name = name + "_subject",
+        abi = "$(PY_WHEEL_ABI_TAG)",
+        config_settings = {
+            labels.PYTHON_VERSION: "3.12",
+        },
+        distribution = "mydist_" + name,
+        platform = "$(PLATFORM)",
+        python_tag = "$(PY_WHEEL_PYTHON_TAG)",
+        toolchains = [name + "_vars"],
+        version = "$(VERSION)",
+    )
+    analysis_test(
+        name = name,
+        impl = _test_custom_make_variables_impl,
+        target = name + "_subject",
+    )
+
+def _test_custom_make_variables_impl(env, target):
+    env.expect.that_target(target).default_outputs().contains_exactly([
+        "{package}/mydist_{test_name}-1.2.3-cp312-cp312-" +
+        "manylinux2014_x86_64.whl",
+    ])
+    action = env.expect.that_target(target).action_named("PyWheel")
+    action.contains_at_least_args([
+        "--version",
+        "1.2.3",
+        "--python_tag",
+        "cp312",
+        "--abi",
+        "cp312",
+        "--platform",
+        "manylinux2014_x86_64",
+    ])
+
+_tests.append(_test_custom_make_variables)
+
 def py_wheel_test_suite(name):
     test_suite(
         name = name,