feat: allow registering arbitrary settings for py_binary transitions (#3248)

This implements the ability for users to add additional settings that
py_binary, py_test,
and py_wheel can transition on.

There were three main use cases motivating this feature:
1. Making it easier to have multiple pypi dependency closures and shared
dependencies.
2. Making it easier to override flags for `py_wheel`.
3. Making it easier to have per-target setting of things like
bootstrap_impl, venv
   site packages, etc.

It also adds most of our config settings to the the transition
inputs/outputs for those
rules, which allows users to per-target force particular settings
without having to
use e.g. `with_cfg` to wrap a target with the desired transition
settings. It also
lets use avoid adding dozens of attributes (one per setting); today
there are
about 17 flags.

Under the hood, this works by having a bzlmod api that users can pass
labels to. These
labels are put into a generated bzl file, which the rules load and add
to their
list of transition inputs/outputs. On the target level, the
`config_settings` attribute,
which is a `dict[label, str]`, can be set to change the particular flags
of interest.

Along the way...
* Create a common_labels.bzl file for the shared label strings
* Remove the defunct py_reconfig code in sh_py_run_test.

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
diff --git a/MODULE.bazel b/MODULE.bazel
index 1dca3e9..6251ed4 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -13,9 +13,9 @@
 # Use py_proto_library directly from protobuf repository
 bazel_dep(name = "protobuf", version = "29.0-rc2", repo_name = "com_google_protobuf")
 
-internal_deps = use_extension("//python/private:internal_deps.bzl", "internal_deps")
+rules_python_config = use_extension("//python/extensions:config.bzl", "config")
 use_repo(
-    internal_deps,
+    rules_python_config,
     "pypi__build",
     "pypi__click",
     "pypi__colorama",
@@ -218,6 +218,19 @@
     "whl_with_build_files",
 )
 
+dev_rules_python_config = use_extension(
+    "//python/extensions:config.bzl",
+    "config",
+    dev_dependency = True,
+)
+dev_rules_python_config.add_transition_setting(
+    # Intentionally add a setting already present for testing
+    setting = "//python/config_settings:python_version",
+)
+dev_rules_python_config.add_transition_setting(
+    setting = "//tests/multi_pypi:external_deps_name",
+)
+
 # Add gazelle plugin so that we can run the gazelle example as an e2e integration
 # test and include the distribution files.
 local_path_override(
@@ -291,7 +304,17 @@
     python_version = "3.11",
     requirements_lock = "//examples/wheel:requirements_server.txt",
 )
-use_repo(dev_pip, "dev_pip", "pypiserver")
+dev_pip.parse(
+    hub_name = "pypi_alpha",
+    python_version = "3.11",
+    requirements_lock = "//tests/multi_pypi/alpha:requirements.txt",
+)
+dev_pip.parse(
+    hub_name = "pypi_beta",
+    python_version = "3.11",
+    requirements_lock = "//tests/multi_pypi/beta:requirements.txt",
+)
+use_repo(dev_pip, "dev_pip", "pypi_alpha", "pypi_beta", "pypiserver")
 
 # Bazel integration test setup below
 
diff --git a/WORKSPACE b/WORKSPACE
index 5c21366..077ddb5 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -69,7 +69,9 @@
 rules_python_internal_setup()
 
 load("@pythons_hub//:versions.bzl", "PYTHON_VERSIONS")
-load("//python:repositories.bzl", "python_register_multi_toolchains")
+load("//python:repositories.bzl", "py_repositories", "python_register_multi_toolchains")
+
+py_repositories()
 
 python_register_multi_toolchains(
     name = "python",
@@ -155,3 +157,26 @@
 load("@dev_pip//:requirements.bzl", docs_install_deps = "install_deps")
 
 docs_install_deps()
+
+#####################
+# Pypi repos for //tests/multi_pypi
+
+pip_parse(
+    name = "pypi_alpha",
+    python_interpreter_target = interpreter,
+    requirements_lock = "//tests/multi_pypi/alpha:requirements.txt",
+)
+
+load("@pypi_alpha//:requirements.bzl", pypi_alpha_install_deps = "install_deps")
+
+pypi_alpha_install_deps()
+
+pip_parse(
+    name = "pypi_beta",
+    python_interpreter_target = interpreter,
+    requirements_lock = "//tests/multi_pypi/beta:requirements.txt",
+)
+
+load("@pypi_beta//:requirements.bzl", pypi_beta_install_deps = "install_deps")
+
+pypi_beta_install_deps()
diff --git a/docs/howto/common-deps-with-multipe-pypi-versions.md b/docs/howto/common-deps-with-multipe-pypi-versions.md
new file mode 100644
index 0000000..ba35686
--- /dev/null
+++ b/docs/howto/common-deps-with-multipe-pypi-versions.md
@@ -0,0 +1,91 @@
+# How to use a common set of dependencies with multiple PyPI versions
+
+In this guide, we show how to handle a situation common to monorepos
+that extensively share code: How does a common library refer to the correct
+`@pypi_<name>` hub when binaries may have their own requirements (and thus
+PyPI hub name)? Stated as code, this situation:
+
+```bzl
+
+py_binary(
+  name = "bin_alpha",
+  deps = ["@pypi_alpha//requests", ":common"],
+)
+py_binary(
+  name = "bin_beta",
+  deps = ["@pypi_beta//requests", ":common"],
+)
+
+py_library(
+  name = "common",
+  deps = ["@pypi_???//more_itertools"] # <-- Which @pypi repo?
+)
+```
+
+## Using flags to pick a hub
+
+The basic trick to make `:common` pick the appropriate `@pypi_<name>` is to use
+`select()` to choose one based on build flags. To help this process, `py_binary`
+et al allow forcing particular build flags to be used, and custom flags can be
+registered to allow `py_binary` et al to set them.
+
+In this example, we create a custom string flag named `//:pypi_hub`,
+register it to allow using it with `py_binary` directly, then use `select()`
+to pick different dependencies.
+
+```bzl
+# File: MODULE.bazel
+
+rules_python_config.add_transition_setting(
+    setting = "//:pypi_hub",
+)
+
+# File: BUILD.bazel
+
+```bzl
+
+load("@bazel_skylib//rules:common_settings.bzl", "string_flag")
+
+string_flag(
+    name = "pypi_hub",
+)
+
+config_setting(
+    name = "is_pypi_alpha",
+    flag_values = {"//:pypi_hub": "alpha"},
+)
+
+config_setting(
+    name = "is_pypi_beta",
+    flag_values = {"//:pypi_hub": "beta"}
+)
+
+py_binary(
+    name = "bin_alpha",
+    srcs = ["bin_alpha.py"],
+    config_settings = {
+        "//:pypi_hub": "alpha",
+    },
+    deps = ["@pypi_alpha//requests", ":common"],
+)
+py_binary(
+    name = "bin_beta",
+    srcs = ["bin_beta.py"],
+    config_settings = {
+        "//:pypi_hub": "beta",
+    },
+    deps = ["@pypi_beta//requests", ":common"],
+)
+py_library(
+    name = "common",
+    deps = select({
+        ":is_pypi_alpha": ["@pypi_alpha//more_itertools"],
+        ":is_pypi_beta": ["@pypi_beta//more_itertools"],
+    }),
+)
+```
+
+When `bin_alpha` and `bin_beta` are built, they will have the `pypi_hub`
+flag force to their respective value. When `:common` is evaluated, it sees
+the flag value of the binary that is consuming it, and the `select()` resolves
+appropriately.
diff --git a/internal_dev_deps.bzl b/internal_dev_deps.bzl
index e1a6562..91f5def 100644
--- a/internal_dev_deps.bzl
+++ b/internal_dev_deps.bzl
@@ -41,7 +41,12 @@
     For dependencies needed by *users* of rules_python, see
     python/private/py_repositories.bzl.
     """
-    internal_config_repo(name = "rules_python_internal")
+    internal_config_repo(
+        name = "rules_python_internal",
+        transition_settings = [
+            str(Label("//tests/multi_pypi:external_deps_name")),
+        ],
+    )
 
     local_repository(
         name = "other",
diff --git a/python/extensions/BUILD.bazel b/python/extensions/BUILD.bazel
index e8a63d6..e6c876c 100644
--- a/python/extensions/BUILD.bazel
+++ b/python/extensions/BUILD.bazel
@@ -39,3 +39,12 @@
         "//python/private:python_bzl",
     ],
 )
+
+bzl_library(
+    name = "config_bzl",
+    srcs = ["config.bzl"],
+    visibility = ["//:__subpackages__"],
+    deps = [
+        "//python/private:internal_config_repo_bzl",
+    ],
+)
diff --git a/python/extensions/config.bzl b/python/extensions/config.bzl
new file mode 100644
index 0000000..2667b2a
--- /dev/null
+++ b/python/extensions/config.bzl
@@ -0,0 +1,53 @@
+"""Extension for configuring global settings of rules_python."""
+
+load("//python/private:internal_config_repo.bzl", "internal_config_repo")
+load("//python/private/pypi:deps.bzl", "pypi_deps")
+
+_add_transition_setting = tag_class(
+    doc = """
+Specify a build setting that terminal rules transition on by default.
+
+Terminal rules are rules such as py_binary, py_test, py_wheel, or similar
+rules that represent some deployable unit. Settings added here can
+then be used a keys with the {obj}`config_settings` attribute.
+
+:::{note}
+This adds the label as a dependency of the Python rules. Take care to not refer
+to repositories that are expensive to create or invalidate frequently.
+:::
+""",
+    attrs = {
+        "setting": attr.label(doc = "The build setting to add."),
+    },
+)
+
+def _config_impl(mctx):
+    transition_setting_generators = {}
+    transition_settings = []
+    for mod in mctx.modules:
+        for tag in mod.tags.add_transition_setting:
+            setting = str(tag.setting)
+            if setting not in transition_setting_generators:
+                transition_setting_generators[setting] = []
+                transition_settings.append(setting)
+            transition_setting_generators[setting].append(mod.name)
+
+    internal_config_repo(
+        name = "rules_python_internal",
+        transition_setting_generators = transition_setting_generators,
+        transition_settings = transition_settings,
+    )
+
+    pypi_deps()
+
+config = module_extension(
+    doc = """Global settings for rules_python.
+
+:::{versionadded} VERSION_NEXT_FEATURE
+:::
+""",
+    implementation = _config_impl,
+    tag_classes = {
+        "add_transition_setting": _add_transition_setting,
+    },
+)
diff --git a/python/private/BUILD.bazel b/python/private/BUILD.bazel
index 6fc78ef..f31b56e 100644
--- a/python/private/BUILD.bazel
+++ b/python/private/BUILD.bazel
@@ -106,6 +106,7 @@
     name = "builders_util_bzl",
     srcs = ["builders_util.bzl"],
     deps = [
+        ":bzlmod_enabled_bzl",
         "@bazel_skylib//lib:types",
     ],
 )
@@ -136,6 +137,11 @@
 )
 
 bzl_library(
+    name = "common_labels_bzl",
+    srcs = ["common_labels.bzl"],
+)
+
+bzl_library(
     name = "config_settings_bzl",
     srcs = ["config_settings.bzl"],
     deps = [
@@ -408,6 +414,7 @@
         ":py_runtime_info_bzl",
         ":rules_cc_srcs_bzl",
         ":toolchain_types_bzl",
+        ":transition_labels_bzl",
         "@bazel_skylib//lib:dicts",
         "@bazel_skylib//lib:paths",
         "@bazel_skylib//lib:structs",
@@ -583,6 +590,7 @@
     deps = [
         ":py_package_bzl",
         ":stamp_bzl",
+        ":transition_labels_bzl",
     ],
 )
 
@@ -650,6 +658,16 @@
 )
 
 bzl_library(
+    name = "transition_labels_bzl",
+    srcs = ["transition_labels.bzl"],
+    deps = [
+        "common_labels_bzl",
+        "@bazel_skylib//lib:collections",
+        "@rules_python_internal//:extra_transition_settings_bzl",
+    ],
+)
+
+bzl_library(
     name = "util_bzl",
     srcs = ["util.bzl"],
     visibility = [
diff --git a/python/private/attr_builders.bzl b/python/private/attr_builders.bzl
index be9fa22..ecfc570 100644
--- a/python/private/attr_builders.bzl
+++ b/python/private/attr_builders.bzl
@@ -31,6 +31,7 @@
     "kwargs_setter",
     "kwargs_setter_doc",
     "kwargs_setter_mandatory",
+    "normalize_transition_in_out_values",
     "to_label_maybe",
 )
 
@@ -167,6 +168,8 @@
     }
     kwargs_set_default_list(state, _INPUTS)
     kwargs_set_default_list(state, _OUTPUTS)
+    normalize_transition_in_out_values("input", state[_INPUTS])
+    normalize_transition_in_out_values("output", state[_OUTPUTS])
 
     # buildifier: disable=uninitialized
     self = struct(
diff --git a/python/private/attributes.bzl b/python/private/attributes.bzl
index 641fa13..0ff92e3 100644
--- a/python/private/attributes.bzl
+++ b/python/private/attributes.bzl
@@ -405,8 +405,58 @@
 # Attributes specific to Python executable-equivalent rules. Such rules may not
 # accept Python sources (e.g. some packaged-version of a py_test/py_binary), but
 # still accept Python source-agnostic settings.
+CONFIG_SETTINGS_ATTR = {
+    "config_settings": lambda: attrb.LabelKeyedStringDict(
+        doc = """
+Config settings to change for this target.
+
+The keys are labels for settings, and the values are strings for the new value
+to use. Pass `Label` objects or canonical label strings for the keys to ensure
+they resolve as expected (canonical labels start with `@@` and can be
+obtained by calling `str(Label(...))`).
+
+Most `@rules_python//python/config_setting` settings can be used here, which
+allows, for example, making only a certain `py_binary` use
+{obj}`--boostrap_impl=script`.
+
+Additional or custom config settings can be registered using the
+{obj}`add_transition_setting` API. This allows, for example, forcing a
+particular CPU, or defining a custom setting that `select()` uses elsewhere
+to pick between `pip.parse` hubs. See the [How to guide on multiple
+versions of a library] for a more concrete example.
+
+:::{note}
+These values are transitioned on, so will affect the analysis graph and the
+associated memory overhead. The more unique configurations in your overall
+build, the more memory and (often unnecessary) re-analysis and re-building
+can occur. See
+https://bazel.build/extending/config#memory-performance-considerations for
+more information about risks and considerations.
+:::
+
+:::{versionadded} VERSION_NEXT_FEATURE
+:::
+""",
+    ),
+}
+
+def apply_config_settings_attr(settings, attr):
+    """Applies the config_settings attribute to the settings.
+
+    Args:
+        settings: The settings dict to modify in-place.
+        attr: The rule attributes struct.
+
+    Returns:
+        {type}`dict[str, object]` the input `settings` value.
+    """
+    for key, value in attr.config_settings.items():
+        settings[str(key)] = value
+    return settings
+
 AGNOSTIC_EXECUTABLE_ATTRS = dicts.add(
     DATA_ATTRS,
+    CONFIG_SETTINGS_ATTR,
     {
         "env": lambda: attrb.StringDict(
             doc = """\
diff --git a/python/private/builders_util.bzl b/python/private/builders_util.bzl
index 139084f..7710383 100644
--- a/python/private/builders_util.bzl
+++ b/python/private/builders_util.bzl
@@ -15,6 +15,41 @@
 """Utilities for builders."""
 
 load("@bazel_skylib//lib:types.bzl", "types")
+load(":bzlmod_enabled.bzl", "BZLMOD_ENABLED")
+
+def normalize_transition_in_out_values(arg_name, values):
+    """Normalize transition inputs/outputs to canonical label strings."""
+    for i, value in enumerate(values):
+        values[i] = normalize_transition_in_out_value(arg_name, value)
+
+def normalize_transition_in_out_value(arg_name, value):
+    """Normalize a transition input/output value to a canonical label string.
+
+    Args:
+        arg_name: {type}`str` the transition arg name, "input" or "output"
+        value: A label-like value to normalize.
+
+    Returns:
+        {type}`str` the canonical label string.
+    """
+    if is_label(value):
+        return str(value)
+    elif types.is_string(value):
+        if value.startswith("//command_line_option:"):
+            return value
+        if value.startswith("@@" if BZLMOD_ENABLED else "@"):
+            return value
+        else:
+            fail("transition {arg_name} invalid: non-canonical string '{value}'".format(
+                arg_name = arg_name,
+                value = value,
+            ))
+    else:
+        fail("transition {arg_name} invalid: ({type}) {value}".format(
+            arg_name = arg_name,
+            type = type(value),
+            value = repr(value),
+        ))
 
 def to_label_maybe(value):
     """Converts `value` to a `Label`, maybe.
@@ -100,7 +135,7 @@
     """Creates a `kwargs_setter` for the `mandatory` key."""
     return kwargs_setter(kwargs, "mandatory")
 
-def list_add_unique(add_to, others):
+def list_add_unique(add_to, others, convert = None):
     """Bulk add values to a list if not already present.
 
     Args:
@@ -108,9 +143,11 @@
             in-place.
         others: {type}`collection[collection[T]]` collection of collections of
             the values to add.
+        convert: {type}`callable | None` function to convert the values to add.
     """
     existing = {v: None for v in add_to}
     for values in others:
         for value in values:
+            value = convert(value) if convert else value
             if value not in existing:
                 add_to.append(value)
diff --git a/python/private/common_labels.bzl b/python/private/common_labels.bzl
new file mode 100644
index 0000000..a55b594
--- /dev/null
+++ b/python/private/common_labels.bzl
@@ -0,0 +1,27 @@
+"""Constants for common labels used in the codebase."""
+
+# NOTE: str() is called because some APIs don't accept Label objects
+# (e.g. transition inputs/outputs or the transition settings return dict)
+
+labels = struct(
+    # keep sorted
+    ADD_SRCS_TO_RUNFILES = str(Label("//python/config_settings:add_srcs_to_runfiles")),
+    BOOTSTRAP_IMPL = str(Label("//python/config_settings:bootstrap_impl")),
+    EXEC_TOOLS_TOOLCHAIN = str(Label("//python/config_settings:exec_tools_toolchain")),
+    PIP_ENV_MARKER_CONFIG = str(Label("//python/config_settings:pip_env_marker_config")),
+    PIP_WHL_MUSLC_VERSION = str(Label("//python/config_settings:pip_whl_muslc_version")),
+    PIP_WHL = str(Label("//python/config_settings:pip_whl")),
+    PIP_WHL_GLIBC_VERSION = str(Label("//python/config_settings:pip_whl_glibc_version")),
+    PIP_WHL_OSX_ARCH = str(Label("//python/config_settings:pip_whl_osx_arch")),
+    PIP_WHL_OSX_VERSION = str(Label("//python/config_settings:pip_whl_osx_version")),
+    PRECOMPILE = str(Label("//python/config_settings:precompile")),
+    PRECOMPILE_SOURCE_RETENTION = str(Label("//python/config_settings:precompile_source_retention")),
+    PYTHON_SRC = str(Label("//python/bin:python_src")),
+    PYTHON_VERSION = str(Label("//python/config_settings:python_version")),
+    PYTHON_VERSION_MAJOR_MINOR = str(Label("//python/config_settings:python_version_major_minor")),
+    PY_FREETHREADED = str(Label("//python/config_settings:py_freethreaded")),
+    PY_LINUX_LIBC = str(Label("//python/config_settings:py_linux_libc")),
+    REPL_DEP = str(Label("//python/bin:repl_dep")),
+    VENVS_SITE_PACKAGES = str(Label("//python/config_settings:venvs_site_packages")),
+    VENVS_USE_DECLARE_SYMLINK = str(Label("//python/config_settings:venvs_use_declare_symlink")),
+)
diff --git a/python/private/internal_config_repo.bzl b/python/private/internal_config_repo.bzl
index cfe2fdf..b57275b 100644
--- a/python/private/internal_config_repo.bzl
+++ b/python/private/internal_config_repo.bzl
@@ -18,6 +18,7 @@
 settings for rules to later use.
 """
 
+load("//python/private:text_util.bzl", "render")
 load(":repo_utils.bzl", "repo_utils")
 
 _ENABLE_PIPSTAR_ENVVAR_NAME = "RULES_PYTHON_ENABLE_PIPSTAR"
@@ -27,7 +28,7 @@
 _ENABLE_DEPRECATION_WARNINGS_ENVVAR_NAME = "RULES_PYTHON_DEPRECATION_WARNINGS"
 _ENABLE_DEPRECATION_WARNINGS_DEFAULT = "0"
 
-_CONFIG_TEMPLATE = """\
+_CONFIG_TEMPLATE = """
 config = struct(
   enable_pystar = {enable_pystar},
   enable_pipstar = {enable_pipstar},
@@ -40,12 +41,12 @@
 
 # The py_internal symbol is only accessible from within @rules_python, so we have to
 # load it from there and re-export it so that rules_python can later load it.
-_PY_INTERNAL_SHIM = """\
+_PY_INTERNAL_SHIM = """
 load("@rules_python//tools/build_defs/python/private:py_internal_renamed.bzl", "py_internal_renamed")
 py_internal_impl = py_internal_renamed
 """
 
-ROOT_BUILD_TEMPLATE = """\
+ROOT_BUILD_TEMPLATE = """
 load("@bazel_skylib//:bzl_library.bzl", "bzl_library")
 
 package(
@@ -64,6 +65,26 @@
     srcs = ["py_internal.bzl"],
     deps = [{py_internal_dep}],
 )
+
+bzl_library(
+    name = "extra_transition_settings_bzl",
+    srcs = ["extra_transition_settings.bzl"],
+)
+"""
+
+_EXTRA_TRANSITIONS_TEMPLATE = """
+# Generated by @rules_python//python/private:internal_config_repo.bzl
+#
+# For a list of what modules added what labels, see
+# transition_settings_debug.txt
+
+EXTRA_TRANSITION_SETTINGS = {labels}
+"""
+
+_TRANSITION_SETTINGS_DEBUG_TEMPLATE = """
+# Generated by @rules_python//python/private:internal_config_repo.bzl
+
+{lines}
 """
 
 def _internal_config_repo_impl(rctx):
@@ -113,12 +134,32 @@
         visibility = visibility,
     ))
     rctx.file("py_internal.bzl", shim_content)
+
+    rctx.file(
+        "extra_transition_settings.bzl",
+        _EXTRA_TRANSITIONS_TEMPLATE.format(
+            labels = render.list(rctx.attr.transition_settings),
+        ),
+    )
+    debug_lines = [
+        "{} added by modules: {}".format(setting, ", ".join(sorted(requesters)))
+        for setting, requesters in rctx.attr.transition_setting_generators.items()
+    ]
+    rctx.file(
+        "transition_settings_debug.txt",
+        _TRANSITION_SETTINGS_DEBUG_TEMPLATE.format(lines = "\n".join(debug_lines)),
+    )
+
     return None
 
 internal_config_repo = repository_rule(
     implementation = _internal_config_repo_impl,
     configure = True,
     environ = [_ENABLE_PYSTAR_ENVVAR_NAME],
+    attrs = {
+        "transition_setting_generators": attr.string_list_dict(),
+        "transition_settings": attr.string_list(),
+    },
 )
 
 def _bool_from_environ(rctx, key, default):
diff --git a/python/private/internal_deps.bzl b/python/private/internal_deps.bzl
deleted file mode 100644
index 6ea3fa4..0000000
--- a/python/private/internal_deps.bzl
+++ /dev/null
@@ -1,22 +0,0 @@
-#     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.
-
-"Python toolchain module extension for internal rule use"
-
-load("@bazel_skylib//lib:modules.bzl", "modules")
-load("//python/private/pypi:deps.bzl", "pypi_deps")
-load(":internal_config_repo.bzl", "internal_config_repo")
-
-def _internal_deps():
-    internal_config_repo(name = "rules_python_internal")
-    pypi_deps()
-
-internal_deps = modules.as_extension(
-    _internal_deps,
-    doc = "This extension registers internal rules_python dependencies.",
-)
diff --git a/python/private/py_executable.bzl b/python/private/py_executable.bzl
index 41938eb..98dbc7f 100644
--- a/python/private/py_executable.bzl
+++ b/python/private/py_executable.bzl
@@ -29,6 +29,7 @@
     "PrecompileAttr",
     "PycCollectionAttr",
     "REQUIRED_EXEC_GROUP_BUILDERS",
+    "apply_config_settings_attr",
 )
 load(":builders.bzl", "builders")
 load(":cc_helper.bzl", "cc_helper")
@@ -65,6 +66,7 @@
     "TARGET_TOOLCHAIN_TYPE",
     TOOLCHAIN_TYPE = "TARGET_TOOLCHAIN_TYPE",
 )
+load(":transition_labels.bzl", "TRANSITION_LABELS")
 
 _py_builtins = py_internal
 _EXTERNAL_PATH_PREFIX = "external"
@@ -1902,10 +1904,10 @@
         inherited_environment = inherited_environment,
     )
 
-def _transition_executable_impl(input_settings, attr):
-    settings = {
-        _PYTHON_VERSION_FLAG: input_settings[_PYTHON_VERSION_FLAG],
-    }
+def _transition_executable_impl(settings, attr):
+    settings = dict(settings)
+    apply_config_settings_attr(settings, attr)
+
     if attr.python_version and attr.python_version not in ("PY2", "PY3"):
         settings[_PYTHON_VERSION_FLAG] = attr.python_version
     return settings
@@ -1958,8 +1960,8 @@
         ],
         cfg = dict(
             implementation = _transition_executable_impl,
-            inputs = [_PYTHON_VERSION_FLAG],
-            outputs = [_PYTHON_VERSION_FLAG],
+            inputs = TRANSITION_LABELS + [_PYTHON_VERSION_FLAG],
+            outputs = TRANSITION_LABELS + [_PYTHON_VERSION_FLAG],
         ),
         **kwargs
     )
diff --git a/python/private/py_repositories.bzl b/python/private/py_repositories.bzl
index c09ba68..3ad2a97 100644
--- a/python/private/py_repositories.bzl
+++ b/python/private/py_repositories.bzl
@@ -24,15 +24,24 @@
 def http_archive(**kwargs):
     maybe(_http_archive, **kwargs)
 
-def py_repositories():
+def py_repositories(transition_settings = []):
     """Runtime dependencies that users must install.
 
     This function should be loaded and called in the user's `WORKSPACE`.
     With `bzlmod` enabled, this function is not needed since `MODULE.bazel` handles transitive deps.
+
+    Args:
+        transition_settings: A list of labels that terminal rules transition on
+            by default.
     """
+
+    # NOTE: The @rules_python_internal repo is special cased by Bazel: it
+    # has autoloading disabled. This allows the rules to load from it
+    # without triggering recursion.
     maybe(
         internal_config_repo,
         name = "rules_python_internal",
+        transition_settings = transition_settings,
     )
     maybe(
         hub_repo,
diff --git a/python/private/py_wheel.bzl b/python/private/py_wheel.bzl
index e6352ef..8202fa0 100644
--- a/python/private/py_wheel.bzl
+++ b/python/private/py_wheel.bzl
@@ -14,9 +14,12 @@
 
 "Implementation of py_wheel rule"
 
+load(":attributes.bzl", "CONFIG_SETTINGS_ATTR", "apply_config_settings_attr")
 load(":py_info.bzl", "PyInfo")
 load(":py_package.bzl", "py_package_lib")
+load(":rule_builders.bzl", "ruleb")
 load(":stamp.bzl", "is_stamping_enabled")
+load(":transition_labels.bzl", "TRANSITION_LABELS")
 load(":version.bzl", "version")
 
 PyWheelInfo = provider(
@@ -577,10 +580,15 @@
         _requirement_attrs,
         _entrypoint_attrs,
         _other_attrs,
+        CONFIG_SETTINGS_ATTR,
     ),
 )
 
-py_wheel = rule(
+def _transition_wheel_impl(settings, attr):
+    """Transition for py_wheel."""
+    return apply_config_settings_attr(dict(settings), attr)
+
+py_wheel = ruleb.Rule(
     implementation = py_wheel_lib.implementation,
     doc = """\
 Internal rule used by the [py_wheel macro](#py_wheel).
@@ -590,4 +598,9 @@
 in the way they expect.
 """,
     attrs = py_wheel_lib.attrs,
-)
+    cfg = transition(
+        implementation = _transition_wheel_impl,
+        inputs = TRANSITION_LABELS,
+        outputs = TRANSITION_LABELS,
+    ),
+).build()
diff --git a/python/private/rule_builders.bzl b/python/private/rule_builders.bzl
index 360503b..876ca2b 100644
--- a/python/private/rule_builders.bzl
+++ b/python/private/rule_builders.bzl
@@ -108,6 +108,8 @@
     "kwargs_setter",
     "kwargs_setter_doc",
     "list_add_unique",
+    "normalize_transition_in_out_value",
+    "normalize_transition_in_out_values",
 )
 
 # Various string constants for kwarg key names used across two or more
@@ -314,6 +316,9 @@
     kwargs_set_default_list(state, _INPUTS)
     kwargs_set_default_list(state, _OUTPUTS)
 
+    normalize_transition_in_out_values("input", state[_INPUTS])
+    normalize_transition_in_out_values("output", state[_OUTPUTS])
+
     # buildifier: disable=uninitialized
     self = struct(
         add_inputs = lambda *a, **k: _RuleCfg_add_inputs(self, *a, **k),
@@ -398,7 +403,11 @@
             `Label`, not `str`, should be passed to ensure different apparent
             labels can be properly de-duplicated.
     """
-    list_add_unique(self._state[_INPUTS], others)
+    list_add_unique(
+        self._state[_INPUTS],
+        others,
+        convert = lambda v: normalize_transition_in_out_value("input", v),
+    )
 
 def _RuleCfg_update_outputs(self, *others):
     """Add a collection of values to outputs.
@@ -410,7 +419,11 @@
             `Label`, not `str`, should be passed to ensure different apparent
             labels can be properly de-duplicated.
     """
-    list_add_unique(self._state[_OUTPUTS], others)
+    list_add_unique(
+        self._state[_OUTPUTS],
+        others,
+        convert = lambda v: normalize_transition_in_out_value("output", v),
+    )
 
 # buildifier: disable=name-conventions
 RuleCfg = struct(
diff --git a/python/private/transition_labels.bzl b/python/private/transition_labels.bzl
new file mode 100644
index 0000000..b2cf6d7
--- /dev/null
+++ b/python/private/transition_labels.bzl
@@ -0,0 +1,32 @@
+"""Flags that terminal rules should allow transitioning on by default.
+
+Terminal rules are e.g. py_binary, py_test, or packaging rules.
+"""
+
+load("@bazel_skylib//lib:collections.bzl", "collections")
+load("@rules_python_internal//:extra_transition_settings.bzl", "EXTRA_TRANSITION_SETTINGS")
+load(":common_labels.bzl", "labels")
+
+_BASE_TRANSITION_LABELS = [
+    labels.ADD_SRCS_TO_RUNFILES,
+    labels.BOOTSTRAP_IMPL,
+    labels.EXEC_TOOLS_TOOLCHAIN,
+    labels.PIP_ENV_MARKER_CONFIG,
+    labels.PIP_WHL_MUSLC_VERSION,
+    labels.PIP_WHL,
+    labels.PIP_WHL_GLIBC_VERSION,
+    labels.PIP_WHL_OSX_ARCH,
+    labels.PIP_WHL_OSX_VERSION,
+    labels.PRECOMPILE,
+    labels.PRECOMPILE_SOURCE_RETENTION,
+    labels.PYTHON_SRC,
+    labels.PYTHON_VERSION,
+    labels.PY_FREETHREADED,
+    labels.PY_LINUX_LIBC,
+    labels.VENVS_SITE_PACKAGES,
+    labels.VENVS_USE_DECLARE_SYMLINK,
+]
+
+TRANSITION_LABELS = collections.uniq(
+    _BASE_TRANSITION_LABELS + EXTRA_TRANSITION_SETTINGS,
+)
diff --git a/tests/builders/rule_builders_tests.bzl b/tests/builders/rule_builders_tests.bzl
index 9a91ceb..3f14832 100644
--- a/tests/builders/rule_builders_tests.bzl
+++ b/tests/builders/rule_builders_tests.bzl
@@ -153,11 +153,11 @@
     expect.that_bool(subject.cfg.implementation()).equals(impl)
     subject.cfg.add_inputs(Label("//some:input"))
     expect.that_collection(subject.cfg.inputs()).contains_exactly([
-        Label("//some:input"),
+        str(Label("//some:input")),
     ])
     subject.cfg.add_outputs(Label("//some:output"))
     expect.that_collection(subject.cfg.outputs()).contains_exactly([
-        Label("//some:output"),
+        str(Label("//some:output")),
     ])
 
 _basic_tests.append(_test_rule_api)
diff --git a/tests/multi_pypi/BUILD.bazel b/tests/multi_pypi/BUILD.bazel
new file mode 100644
index 0000000..a119ebe
--- /dev/null
+++ b/tests/multi_pypi/BUILD.bazel
@@ -0,0 +1,29 @@
+load("@bazel_skylib//rules:common_settings.bzl", "string_flag")
+load("//python:defs.bzl", "py_library")
+
+string_flag(
+    name = "external_deps_name",
+    build_setting_default = "",
+    visibility = ["//visibility:public"],
+)
+
+py_library(
+    name = "common",
+    srcs = [],
+    visibility = ["//visibility:public"],
+    deps = select({
+        ":is_external_alpha": ["@pypi_alpha//more_itertools"],
+        ":is_external_beta": ["@pypi_beta//more_itertools"],
+        "//conditions:default": [],
+    }),
+)
+
+config_setting(
+    name = "is_external_alpha",
+    flag_values = {"//tests/multi_pypi:external_deps_name": "alpha"},
+)
+
+config_setting(
+    name = "is_external_beta",
+    flag_values = {"//tests/multi_pypi:external_deps_name": "beta"},
+)
diff --git a/tests/multi_pypi/alpha/BUILD.bazel b/tests/multi_pypi/alpha/BUILD.bazel
new file mode 100644
index 0000000..7b56e0a
--- /dev/null
+++ b/tests/multi_pypi/alpha/BUILD.bazel
@@ -0,0 +1,7 @@
+load("//python/uv:lock.bzl", "lock")
+
+lock(
+    name = "requirements",
+    srcs = ["pyproject.toml"],
+    out = "requirements.txt",
+)
diff --git a/tests/multi_pypi/alpha/pyproject.toml b/tests/multi_pypi/alpha/pyproject.toml
new file mode 100644
index 0000000..8f99cd0
--- /dev/null
+++ b/tests/multi_pypi/alpha/pyproject.toml
@@ -0,0 +1,6 @@
+[project]
+name = "multi-pypi-test-alpha"
+version = "0.1.0"
+dependencies = [
+    "more-itertools==9.1.0"
+]
diff --git a/tests/multi_pypi/alpha/requirements.txt b/tests/multi_pypi/alpha/requirements.txt
new file mode 100644
index 0000000..febb6b7
--- /dev/null
+++ b/tests/multi_pypi/alpha/requirements.txt
@@ -0,0 +1,6 @@
+# This file was autogenerated by uv via the following command:
+#    bazel run //tests/multi_pypi/alpha:requirements.update
+more-itertools==9.1.0 \
+    --hash=sha256:cabaa341ad0389ea83c17a94566a53ae4c9d07349861ecb14dc6d0345cf9ac5d \
+    --hash=sha256:d2bc7f02446e86a68911e58ded76d6561eea00cddfb2a91e7019bbb586c799f3
+    # via multi-pypi-test-alpha (tests/multi_pypi/alpha/pyproject.toml)
diff --git a/tests/multi_pypi/beta/BUILD.bazel b/tests/multi_pypi/beta/BUILD.bazel
new file mode 100644
index 0000000..7b56e0a
--- /dev/null
+++ b/tests/multi_pypi/beta/BUILD.bazel
@@ -0,0 +1,7 @@
+load("//python/uv:lock.bzl", "lock")
+
+lock(
+    name = "requirements",
+    srcs = ["pyproject.toml"],
+    out = "requirements.txt",
+)
diff --git a/tests/multi_pypi/beta/pyproject.toml b/tests/multi_pypi/beta/pyproject.toml
new file mode 100644
index 0000000..02a510f
--- /dev/null
+++ b/tests/multi_pypi/beta/pyproject.toml
@@ -0,0 +1,6 @@
+[project]
+name = "multi-pypi-test-beta"
+version = "0.1.0"
+dependencies = [
+    "more-itertools==9.0.0"
+]
diff --git a/tests/multi_pypi/beta/requirements.txt b/tests/multi_pypi/beta/requirements.txt
new file mode 100644
index 0000000..de05f6d
--- /dev/null
+++ b/tests/multi_pypi/beta/requirements.txt
@@ -0,0 +1,6 @@
+# This file was autogenerated by uv via the following command:
+#    bazel run //tests/multi_pypi/beta:requirements.update
+more-itertools==9.0.0 \
+    --hash=sha256:250e83d7e81d0c87ca6bd942e6aeab8cc9daa6096d12c5308f3f92fa5e5c1f41 \
+    --hash=sha256:5a6257e40878ef0520b1803990e3e22303a41b5714006c32a3fd8304b26ea1ab
+    # via multi-pypi-test-beta (tests/multi_pypi/beta/pyproject.toml)
diff --git a/tests/multi_pypi/pypi_alpha/BUILD.bazel b/tests/multi_pypi/pypi_alpha/BUILD.bazel
new file mode 100644
index 0000000..47e3b2f
--- /dev/null
+++ b/tests/multi_pypi/pypi_alpha/BUILD.bazel
@@ -0,0 +1,11 @@
+load("//tests/support:py_reconfig.bzl", "py_reconfig_test")
+
+py_reconfig_test(
+    name = "pypi_alpha_test",
+    srcs = ["pypi_alpha_test.py"],
+    config_settings = {
+        "//tests/multi_pypi:external_deps_name": "alpha",
+    },
+    main = "pypi_alpha_test.py",
+    deps = ["//tests/multi_pypi:common"],
+)
diff --git a/tests/multi_pypi/pypi_alpha/pypi_alpha_test.py b/tests/multi_pypi/pypi_alpha/pypi_alpha_test.py
new file mode 100644
index 0000000..0521327
--- /dev/null
+++ b/tests/multi_pypi/pypi_alpha/pypi_alpha_test.py
@@ -0,0 +1,8 @@
+import sys
+
+from more_itertools import __version__
+
+if __name__ == "__main__":
+    expected_version = "9.1.0"
+    if __version__ != expected_version:
+        sys.exit(f"Expected version {expected_version}, got {__version__}")
diff --git a/tests/multi_pypi/pypi_beta/BUILD.bazel b/tests/multi_pypi/pypi_beta/BUILD.bazel
new file mode 100644
index 0000000..077d87b
--- /dev/null
+++ b/tests/multi_pypi/pypi_beta/BUILD.bazel
@@ -0,0 +1,11 @@
+load("//tests/support:py_reconfig.bzl", "py_reconfig_test")
+
+py_reconfig_test(
+    name = "pypi_beta_test",
+    srcs = ["pypi_beta_test.py"],
+    config_settings = {
+        "//tests/multi_pypi:external_deps_name": "beta",
+    },
+    main = "pypi_beta_test.py",
+    deps = ["//tests/multi_pypi:common"],
+)
diff --git a/tests/multi_pypi/pypi_beta/pypi_beta_test.py b/tests/multi_pypi/pypi_beta/pypi_beta_test.py
new file mode 100644
index 0000000..8c34de0
--- /dev/null
+++ b/tests/multi_pypi/pypi_beta/pypi_beta_test.py
@@ -0,0 +1,8 @@
+import sys
+
+from more_itertools import __version__
+
+if __name__ == "__main__":
+    expected_version = "9.0.0"
+    if __version__ != expected_version:
+        sys.exit(f"Expected version {expected_version}, got {__version__}")
diff --git a/tests/py_wheel/py_wheel_tests.bzl b/tests/py_wheel/py_wheel_tests.bzl
index 43c068e..75fef3a 100644
--- a/tests/py_wheel/py_wheel_tests.bzl
+++ b/tests/py_wheel/py_wheel_tests.bzl
@@ -14,9 +14,10 @@
 """Test for py_wheel."""
 
 load("@rules_testing//lib:analysis_test.bzl", "analysis_test", "test_suite")
-load("@rules_testing//lib:truth.bzl", "matching")
+load("@rules_testing//lib:truth.bzl", "matching", "subjects")
 load("@rules_testing//lib:util.bzl", rt_util = "util")
 load("//python:packaging.bzl", "py_wheel")
+load("//python/private:common_labels.bzl", "labels")  # buildifier: disable=bzl-visibility
 
 _basic_tests = []
 _tests = []
@@ -167,6 +168,44 @@
 
 _tests.append(_test_content_type_from_description)
 
+def _test_config_settings(name):
+    rt_util.helper_target(
+        native.config_setting,
+        name = "is_py_39",
+        flag_values = {
+            labels.PYTHON_VERSION_MAJOR_MINOR: "3.9",
+        },
+    )
+    rt_util.helper_target(
+        py_wheel,
+        name = name + "_subject",
+        distribution = "mydist_" + name,
+        version = select({
+            ":is_py_39": "3.9",
+            "//conditions:default": "not-3.9",
+        }),
+        config_settings = {
+            labels.PYTHON_VERSION: "3.9",
+        },
+    )
+    analysis_test(
+        name = name,
+        impl = _test_config_settings_impl,
+        target = name + "_subject",
+        config_settings = {
+            # Ensure a different value than the target under test.
+            labels.PYTHON_VERSION: "3.11",
+        },
+    )
+
+def _test_config_settings_impl(env, target):
+    env.expect.that_target(target).attr(
+        "version",
+        factory = subjects.str,
+    ).equals("3.9")
+
+_tests.append(_test_config_settings)
+
 def py_wheel_test_suite(name):
     test_suite(
         name = name,
diff --git a/tests/support/py_reconfig.bzl b/tests/support/py_reconfig.bzl
index b33f679..38d5366 100644
--- a/tests/support/py_reconfig.bzl
+++ b/tests/support/py_reconfig.bzl
@@ -18,11 +18,12 @@
 """
 
 load("//python/private:attr_builders.bzl", "attrb")  # buildifier: disable=bzl-visibility
+load("//python/private:common_labels.bzl", "labels")  # buildifier: disable=bzl-visibility
 load("//python/private:py_binary_macro.bzl", "py_binary_macro")  # buildifier: disable=bzl-visibility
 load("//python/private:py_binary_rule.bzl", "create_py_binary_rule_builder")  # buildifier: disable=bzl-visibility
 load("//python/private:py_test_macro.bzl", "py_test_macro")  # buildifier: disable=bzl-visibility
 load("//python/private:py_test_rule.bzl", "create_py_test_rule_builder")  # buildifier: disable=bzl-visibility
-load("//tests/support:support.bzl", "VISIBLE_FOR_TESTING")
+load("//tests/support:support.bzl", "CUSTOM_RUNTIME", "VISIBLE_FOR_TESTING")
 
 def _perform_transition_impl(input_settings, attr, base_impl):
     settings = {k: input_settings[k] for k in _RECONFIG_INHERITED_OUTPUTS if k in input_settings}
@@ -31,26 +32,29 @@
     settings[VISIBLE_FOR_TESTING] = True
     settings["//command_line_option:build_python_zip"] = attr.build_python_zip
     if attr.bootstrap_impl:
-        settings["//python/config_settings:bootstrap_impl"] = attr.bootstrap_impl
+        settings[labels.BOOTSTRAP_IMPL] = attr.bootstrap_impl
     if attr.extra_toolchains:
         settings["//command_line_option:extra_toolchains"] = attr.extra_toolchains
     if attr.python_src:
-        settings["//python/bin:python_src"] = attr.python_src
+        settings[labels.PYTHON_SRC] = attr.python_src
     if attr.repl_dep:
-        settings["//python/bin:repl_dep"] = attr.repl_dep
+        settings[labels.REPL_DEP] = attr.repl_dep
     if attr.venvs_use_declare_symlink:
-        settings["//python/config_settings:venvs_use_declare_symlink"] = attr.venvs_use_declare_symlink
+        settings[labels.VENVS_USE_DECLARE_SYMLINK] = attr.venvs_use_declare_symlink
     if attr.venvs_site_packages:
-        settings["//python/config_settings:venvs_site_packages"] = attr.venvs_site_packages
+        settings[labels.VENVS_SITE_PACKAGES] = attr.venvs_site_packages
+    for key, value in attr.config_settings.items():
+        settings[str(key)] = value
     return settings
 
 _RECONFIG_INPUTS = [
-    "//python/config_settings:bootstrap_impl",
-    "//python/bin:python_src",
-    "//python/bin:repl_dep",
     "//command_line_option:extra_toolchains",
-    "//python/config_settings:venvs_use_declare_symlink",
-    "//python/config_settings:venvs_site_packages",
+    CUSTOM_RUNTIME,
+    labels.BOOTSTRAP_IMPL,
+    labels.PYTHON_SRC,
+    labels.REPL_DEP,
+    labels.VENVS_SITE_PACKAGES,
+    labels.VENVS_USE_DECLARE_SYMLINK,
 ]
 _RECONFIG_OUTPUTS = _RECONFIG_INPUTS + [
     "//command_line_option:build_python_zip",
@@ -61,6 +65,7 @@
 _RECONFIG_ATTRS = {
     "bootstrap_impl": attrb.String(),
     "build_python_zip": attrb.String(default = "auto"),
+    "config_settings": attrb.LabelKeyedStringDict(),
     "extra_toolchains": attrb.StringList(
         doc = """
 Value for the --extra_toolchains flag.
diff --git a/tests/support/sh_py_run_test.bzl b/tests/support/sh_py_run_test.bzl
index 49445ed..83ac2c8 100644
--- a/tests/support/sh_py_run_test.bzl
+++ b/tests/support/sh_py_run_test.bzl
@@ -18,85 +18,8 @@
 """
 
 load("@rules_shell//shell:sh_test.bzl", "sh_test")
-load("//python/private:attr_builders.bzl", "attrb")  # buildifier: disable=bzl-visibility
-load("//python/private:py_binary_macro.bzl", "py_binary_macro")  # buildifier: disable=bzl-visibility
-load("//python/private:py_binary_rule.bzl", "create_py_binary_rule_builder")  # buildifier: disable=bzl-visibility
-load("//python/private:py_test_macro.bzl", "py_test_macro")  # buildifier: disable=bzl-visibility
-load("//python/private:py_test_rule.bzl", "create_py_test_rule_builder")  # buildifier: disable=bzl-visibility
 load("//python/private:toolchain_types.bzl", "TARGET_TOOLCHAIN_TYPE")  # buildifier: disable=bzl-visibility
-load("//tests/support:support.bzl", "VISIBLE_FOR_TESTING")
-
-def _perform_transition_impl(input_settings, attr, base_impl):
-    settings = {k: input_settings[k] for k in _RECONFIG_INHERITED_OUTPUTS if k in input_settings}
-    settings.update(base_impl(input_settings, attr))
-
-    settings[VISIBLE_FOR_TESTING] = True
-    settings["//command_line_option:build_python_zip"] = attr.build_python_zip
-
-    for attr_name, setting_label in _RECONFIG_ATTR_SETTING_MAP.items():
-        if getattr(attr, attr_name):
-            settings[setting_label] = getattr(attr, attr_name)
-    return settings
-
-# Attributes that, if non-falsey (`if attr.<name>`), will copy their
-# value into the output settings
-_RECONFIG_ATTR_SETTING_MAP = {
-    "bootstrap_impl": "//python/config_settings:bootstrap_impl",
-    "custom_runtime": "//tests/support:custom_runtime",
-    "extra_toolchains": "//command_line_option:extra_toolchains",
-    "python_src": "//python/bin:python_src",
-    "venvs_site_packages": "//python/config_settings:venvs_site_packages",
-    "venvs_use_declare_symlink": "//python/config_settings:venvs_use_declare_symlink",
-}
-
-_RECONFIG_INPUTS = _RECONFIG_ATTR_SETTING_MAP.values()
-_RECONFIG_OUTPUTS = _RECONFIG_INPUTS + [
-    "//command_line_option:build_python_zip",
-    VISIBLE_FOR_TESTING,
-]
-_RECONFIG_INHERITED_OUTPUTS = [v for v in _RECONFIG_OUTPUTS if v in _RECONFIG_INPUTS]
-
-_RECONFIG_ATTRS = {
-    "bootstrap_impl": attrb.String(),
-    "build_python_zip": attrb.String(default = "auto"),
-    "custom_runtime": attrb.String(),
-    "extra_toolchains": attrb.StringList(
-        doc = """
-Value for the --extra_toolchains flag.
-
-NOTE: You'll likely have to also specify //tests/support/cc_toolchains:all (or some CC toolchain)
-to make the RBE presubmits happy, which disable auto-detection of a CC
-toolchain.
-""",
-    ),
-    "python_src": attrb.Label(),
-    "venvs_site_packages": attrb.String(),
-    "venvs_use_declare_symlink": attrb.String(),
-}
-
-def _create_reconfig_rule(builder):
-    builder.attrs.update(_RECONFIG_ATTRS)
-
-    base_cfg_impl = builder.cfg.implementation()
-    builder.cfg.set_implementation(lambda *args: _perform_transition_impl(base_impl = base_cfg_impl, *args))
-    builder.cfg.update_inputs(_RECONFIG_INPUTS)
-    builder.cfg.update_outputs(_RECONFIG_OUTPUTS)
-    return builder.build()
-
-_py_reconfig_binary = _create_reconfig_rule(create_py_binary_rule_builder())
-
-_py_reconfig_test = _create_reconfig_rule(create_py_test_rule_builder())
-
-def py_reconfig_test(**kwargs):
-    """Create a py_test with customized build settings for testing.
-
-    Args:
-        **kwargs: kwargs to pass along to _py_reconfig_test.
-    """
-    py_test_macro(_py_reconfig_test, **kwargs)
-
-def py_reconfig_binary(**kwargs):
-    py_binary_macro(_py_reconfig_binary, **kwargs)
+load(":py_reconfig.bzl", "py_reconfig_binary")
 
 def sh_py_run_test(*, name, sh_src, py_src, **kwargs):
     """Run a py_binary within a sh_test.
diff --git a/tests/support/support.bzl b/tests/support/support.bzl
index f869462..28cab0d 100644
--- a/tests/support/support.bzl
+++ b/tests/support/support.bzl
@@ -44,6 +44,7 @@
 PYC_COLLECTION = str(Label("//python/config_settings:pyc_collection"))
 PYTHON_VERSION = str(Label("//python/config_settings:python_version"))
 VISIBLE_FOR_TESTING = str(Label("//python/private:visible_for_testing"))
+CUSTOM_RUNTIME = str(Label("//tests/support:custom_runtime"))
 
 SUPPORTS_BOOTSTRAP_SCRIPT = select({
     "@platforms//os:windows": ["@platforms//:incompatible"],
diff --git a/tests/toolchains/BUILD.bazel b/tests/toolchains/BUILD.bazel
index b995286..f32ab6f 100644
--- a/tests/toolchains/BUILD.bazel
+++ b/tests/toolchains/BUILD.bazel
@@ -14,7 +14,7 @@
 
 load("@bazel_skylib//rules:build_test.bzl", "build_test")
 load("//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED")  # buildifier: disable=bzl-visibility
-load("//tests/support:sh_py_run_test.bzl", "py_reconfig_test")
+load("//tests/support:py_reconfig.bzl", "py_reconfig_test")
 load(":defs.bzl", "define_toolchain_tests")
 
 define_toolchain_tests(
@@ -24,7 +24,7 @@
 py_reconfig_test(
     name = "custom_platform_toolchain_test",
     srcs = ["custom_platform_toolchain_test.py"],
-    custom_runtime = "linux-x86-install-only-stripped",
+    config_settings = {"//tests/support:custom_runtime": "linux-x86-install-only-stripped"},
     python_version = "3.13.1",
     target_compatible_with = [
         "@platforms//os:linux",