feat: multi-toolchain support (#846)

* feat: multi-toolchain support

This adds support for multiple Python versions on the same Bazel
workspace.

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* feat: cross-version testing

A py_test using 3.10 runs a py_binary using 3.9.

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* fix: error message

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* doc: add link to bazelbuild/bazel PR fixing expand_location

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* fix: set environment variables for py_binary too

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* test: extra case for default version taking another version

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* fix: fail if args attribute is set

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* fix: remove confusing output with same target name

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* fix: buildifier

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* revert: use testing.TestEnvironment

See comment in code for the reasons.

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* fix: linting issues

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* fix: black linter

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* refactor: move tests to a sub-dir

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* feat: add multi_pip_parse

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* fix: add missing aliases

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* fix: use requirement function in example

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* fix: deleted packages

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* fix: update generated docs

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* refactor: version checking of the rule is already done by other tests

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* fix: add python_interpreter_target to multi_pip_parse

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* fix: windows

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* refactor: unify py_test and py_binary transition impls

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* fix: test compatible with all platforms

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* rebase: adjust multi_python_versions on ci

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* refactor: use usr flags instead of platforms in transition

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* refactor: rename rule -> rule_impl

This avoids confusion with the global `rule`
https://bazel.build/rules/lib/globals#rule.

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* refactor: reduce repetition of args

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* fix: missing test and binary-specific attributes

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* fix: add srcs and deps attrs for path expansion

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* fix: missing bazel_skylib on integration tests

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* refactor: use ctx.target_platform_has_constraint over select

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* doc: why symlink <name>.zip under Windows

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* fix: apply suggestions from code review

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* fix: use incoming edge transitions

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* fix: use RunEnvironmentInfo when available

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* fix: cfg should be target not exec

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>
diff --git a/.bazelci/presubmit.yml b/.bazelci/presubmit.yml
index d7ca8ef..56516e9 100644
--- a/.bazelci/presubmit.yml
+++ b/.bazelci/presubmit.yml
@@ -85,6 +85,22 @@
     working_directory: examples/bzlmod
     platform: windows
 
+  integration_test_multi_python_versions_linux:
+    <<: *reusable_build_test_all
+    name: multi_python_versions integration tests on Linux
+    working_directory: examples/multi_python_versions
+    platform: ubuntu2004
+  integration_test_multi_python_versions_macos:
+    <<: *reusable_build_test_all
+    name: multi_python_versions integration tests on macOS
+    working_directory: examples/multi_python_versions
+    platform: macos
+  integration_test_multi_python_versions_windows:
+    <<: *reusable_build_test_all
+    name: multi_python_versions integration tests on Windows
+    working_directory: examples/multi_python_versions
+    platform: windows
+
   integration_test_pip_install_linux:
     <<: *reusable_build_test_all
     name: pip_install integration tests on Linux
diff --git a/.bazelrc b/.bazelrc
index a4bcccf..510191b 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -3,8 +3,8 @@
 # This lets us glob() up all the files inside the examples to make them inputs to tests
 # (Note, we cannot use `common --deleted_packages` because the bazel version command doesn't support it)
 # To update these lines, run tools/bazel_integration_test/update_deleted_packages.sh
-build --deleted_packages=examples/build_file_generation,examples/bzlmod,examples/pip_install,examples/pip_parse,examples/pip_parse_vendored,examples/pip_repository_annotations,examples/py_import,examples/relative_requirements,tests/pip_repository_entry_points,tests/pip_deps
-query --deleted_packages=examples/build_file_generation,examples/bzlmod,examples/pip_install,examples/pip_parse,examples/pip_parse_vendored,examples/pip_repository_annotations,examples/py_import,examples/relative_requirements,tests/pip_repository_entry_points,tests/pip_deps
+build --deleted_packages=examples/build_file_generation,examples/bzlmod,examples/multi_python_versions,examples/multi_python_versions/libs/my_lib,examples/multi_python_versions/requirements,examples/multi_python_versions/tests,examples/pip_install,examples/pip_parse,examples/pip_parse_vendored,examples/pip_repository_annotations,examples/py_import,examples/relative_requirements,tests/pip_repository_entry_points,tests/pip_deps
+query --deleted_packages=examples/build_file_generation,examples/bzlmod,examples/multi_python_versions,examples/multi_python_versions/libs/my_lib,examples/multi_python_versions/requirements,examples/multi_python_versions/tests,examples/pip_install,examples/pip_parse,examples/pip_parse_vendored,examples/pip_repository_annotations,examples/py_import,examples/relative_requirements,tests/pip_repository_entry_points,tests/pip_deps
 
 test --test_output=errors
 
diff --git a/WORKSPACE b/WORKSPACE
index ff1b956..1d9d5e4 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -25,13 +25,13 @@
 
 rules_python_internal_setup()
 
-load("//python:repositories.bzl", "python_register_toolchains")
+load("//python:repositories.bzl", "python_register_multi_toolchains")
 load("//python:versions.bzl", "MINOR_MAPPING")
 
-python_register_toolchains(
+python_register_multi_toolchains(
     name = "python",
-    # We always use the latest Python internally.
-    python_version = MINOR_MAPPING.values()[-1],
+    default_version = MINOR_MAPPING.values()[-1],
+    python_versions = MINOR_MAPPING.values(),
 )
 
 load("//gazelle:deps.bzl", "gazelle_deps")
diff --git a/docs/pip.md b/docs/pip.md
index fc38f0f..5f6c1d8 100644
--- a/docs/pip.md
+++ b/docs/pip.md
@@ -2,13 +2,36 @@
 
 Import pip requirements into Bazel.
 
+<a id="whl_library_alias"></a>
+
+## whl_library_alias
+
+<pre>
+whl_library_alias(<a href="#whl_library_alias-name">name</a>, <a href="#whl_library_alias-default_version">default_version</a>, <a href="#whl_library_alias-repo_mapping">repo_mapping</a>, <a href="#whl_library_alias-version_map">version_map</a>, <a href="#whl_library_alias-wheel_name">wheel_name</a>)
+</pre>
+
+
+
+**ATTRIBUTES**
+
+
+| Name  | Description | Type | Mandatory | Default |
+| :------------- | :------------- | :------------- | :------------- | :------------- |
+| <a id="whl_library_alias-name"></a>name |  A unique name for this repository.   | <a href="https://bazel.build/concepts/labels#target-names">Name</a> | required |  |
+| <a id="whl_library_alias-default_version"></a>default_version |  -   | String | required |  |
+| <a id="whl_library_alias-repo_mapping"></a>repo_mapping |  A dictionary from local repository name to global repository name. This allows controls over workspace dependency resolution for dependencies of this repository.&lt;p&gt;For example, an entry <code>"@foo": "@bar"</code> declares that, for any time this repository depends on <code>@foo</code> (such as a dependency on <code>@foo//some:target</code>, it should actually resolve that dependency within globally-declared <code>@bar</code> (<code>@bar//some:target</code>).   | <a href="https://bazel.build/rules/lib/dict">Dictionary: String -> String</a> | required |  |
+| <a id="whl_library_alias-version_map"></a>version_map |  -   | <a href="https://bazel.build/rules/lib/dict">Dictionary: String -> String</a> | required |  |
+| <a id="whl_library_alias-wheel_name"></a>wheel_name |  -   | String | required |  |
+
+
 <a id="compile_pip_requirements"></a>
 
 ## compile_pip_requirements
 
 <pre>
-compile_pip_requirements(<a href="#compile_pip_requirements-name">name</a>, <a href="#compile_pip_requirements-extra_args">extra_args</a>, <a href="#compile_pip_requirements-visibility">visibility</a>, <a href="#compile_pip_requirements-requirements_in">requirements_in</a>, <a href="#compile_pip_requirements-requirements_txt">requirements_txt</a>,
-                         <a href="#compile_pip_requirements-requirements_linux">requirements_linux</a>, <a href="#compile_pip_requirements-requirements_darwin">requirements_darwin</a>, <a href="#compile_pip_requirements-requirements_windows">requirements_windows</a>, <a href="#compile_pip_requirements-tags">tags</a>, <a href="#compile_pip_requirements-kwargs">kwargs</a>)
+compile_pip_requirements(<a href="#compile_pip_requirements-name">name</a>, <a href="#compile_pip_requirements-extra_args">extra_args</a>, <a href="#compile_pip_requirements-py_binary">py_binary</a>, <a href="#compile_pip_requirements-py_test">py_test</a>, <a href="#compile_pip_requirements-requirements_in">requirements_in</a>, <a href="#compile_pip_requirements-requirements_txt">requirements_txt</a>,
+                         <a href="#compile_pip_requirements-requirements_darwin">requirements_darwin</a>, <a href="#compile_pip_requirements-requirements_linux">requirements_linux</a>, <a href="#compile_pip_requirements-requirements_windows">requirements_windows</a>, <a href="#compile_pip_requirements-visibility">visibility</a>,
+                         <a href="#compile_pip_requirements-tags">tags</a>, <a href="#compile_pip_requirements-kwargs">kwargs</a>)
 </pre>
 
 Generates targets for managing pip dependencies with pip-compile.
@@ -28,16 +51,50 @@
 
 | Name  | Description | Default Value |
 | :------------- | :------------- | :------------- |
-| <a id="compile_pip_requirements-name"></a>name |  base name for generated targets, typically "requirements"   |  none |
-| <a id="compile_pip_requirements-extra_args"></a>extra_args |  passed to pip-compile   |  <code>[]</code> |
-| <a id="compile_pip_requirements-visibility"></a>visibility |  passed to both the _test and .update rules   |  <code>["//visibility:private"]</code> |
-| <a id="compile_pip_requirements-requirements_in"></a>requirements_in |  file expressing desired dependencies   |  <code>None</code> |
-| <a id="compile_pip_requirements-requirements_txt"></a>requirements_txt |  result of "compiling" the requirements.in file   |  <code>None</code> |
-| <a id="compile_pip_requirements-requirements_linux"></a>requirements_linux |  File of linux specific resolve output to check validate if requirement.in has changes.   |  <code>None</code> |
+| <a id="compile_pip_requirements-name"></a>name |  base name for generated targets, typically "requirements".   |  none |
+| <a id="compile_pip_requirements-extra_args"></a>extra_args |  passed to pip-compile.   |  <code>[]</code> |
+| <a id="compile_pip_requirements-py_binary"></a>py_binary |  the py_binary rule to be used.   |  <code>&lt;function py_binary&gt;</code> |
+| <a id="compile_pip_requirements-py_test"></a>py_test |  the py_test rule to be used.   |  <code>&lt;function py_test&gt;</code> |
+| <a id="compile_pip_requirements-requirements_in"></a>requirements_in |  file expressing desired dependencies.   |  <code>None</code> |
+| <a id="compile_pip_requirements-requirements_txt"></a>requirements_txt |  result of "compiling" the requirements.in file.   |  <code>None</code> |
 | <a id="compile_pip_requirements-requirements_darwin"></a>requirements_darwin |  File of darwin specific resolve output to check validate if requirement.in has changes.   |  <code>None</code> |
+| <a id="compile_pip_requirements-requirements_linux"></a>requirements_linux |  File of linux specific resolve output to check validate if requirement.in has changes.   |  <code>None</code> |
 | <a id="compile_pip_requirements-requirements_windows"></a>requirements_windows |  File of windows specific resolve output to check validate if requirement.in has changes.   |  <code>None</code> |
-| <a id="compile_pip_requirements-tags"></a>tags |  tagging attribute common to all build rules, passed to both the _test and .update rules   |  <code>None</code> |
-| <a id="compile_pip_requirements-kwargs"></a>kwargs |  other bazel attributes passed to the "_test" rule   |  none |
+| <a id="compile_pip_requirements-visibility"></a>visibility |  passed to both the _test and .update rules.   |  <code>["//visibility:private"]</code> |
+| <a id="compile_pip_requirements-tags"></a>tags |  tagging attribute common to all build rules, passed to both the _test and .update rules.   |  <code>None</code> |
+| <a id="compile_pip_requirements-kwargs"></a>kwargs |  other bazel attributes passed to the "_test" rule.   |  none |
+
+
+<a id="multi_pip_parse"></a>
+
+## multi_pip_parse
+
+<pre>
+multi_pip_parse(<a href="#multi_pip_parse-name">name</a>, <a href="#multi_pip_parse-default_version">default_version</a>, <a href="#multi_pip_parse-python_versions">python_versions</a>, <a href="#multi_pip_parse-python_interpreter_target">python_interpreter_target</a>,
+                <a href="#multi_pip_parse-requirements_lock">requirements_lock</a>, <a href="#multi_pip_parse-kwargs">kwargs</a>)
+</pre>
+
+NOT INTENDED FOR DIRECT USE!
+
+This is intended to be used by the multi_pip_parse implementation in the template of the
+multi_toolchain_aliases repository rule.
+
+
+**PARAMETERS**
+
+
+| Name  | Description | Default Value |
+| :------------- | :------------- | :------------- |
+| <a id="multi_pip_parse-name"></a>name |  the name of the multi_pip_parse repository.   |  none |
+| <a id="multi_pip_parse-default_version"></a>default_version |  the default Python version.   |  none |
+| <a id="multi_pip_parse-python_versions"></a>python_versions |  all Python toolchain versions currently registered.   |  none |
+| <a id="multi_pip_parse-python_interpreter_target"></a>python_interpreter_target |  a dictionary which keys are Python versions and values are resolved host interpreters.   |  none |
+| <a id="multi_pip_parse-requirements_lock"></a>requirements_lock |  a dictionary which keys are Python versions and values are locked requirements files.   |  none |
+| <a id="multi_pip_parse-kwargs"></a>kwargs |  extra arguments passed to all wrapped pip_parse.   |  none |
+
+**RETURNS**
+
+The internal implementation of multi_pip_parse repository rule.
 
 
 <a id="package_annotation"></a>
diff --git a/examples/BUILD b/examples/BUILD
index fcdbdb1..39e4fce 100644
--- a/examples/BUILD
+++ b/examples/BUILD
@@ -39,6 +39,11 @@
 )
 
 bazel_integration_test(
+    name = "multi_python_versions_example",
+    timeout = "long",
+)
+
+bazel_integration_test(
     name = "bzlmod_example",
     bzlmod = True,
     override_bazel_version = "6.0.0rc1",
diff --git a/examples/multi_python_versions/.bazelrc b/examples/multi_python_versions/.bazelrc
new file mode 100644
index 0000000..f23315a
--- /dev/null
+++ b/examples/multi_python_versions/.bazelrc
@@ -0,0 +1,5 @@
+test --test_output=errors
+
+# Windows requires these for multi-python support:
+build --enable_runfiles
+startup --windows_enable_symlinks
diff --git a/examples/multi_python_versions/.gitignore b/examples/multi_python_versions/.gitignore
new file mode 100644
index 0000000..ac51a05
--- /dev/null
+++ b/examples/multi_python_versions/.gitignore
@@ -0,0 +1 @@
+bazel-*
diff --git a/examples/multi_python_versions/WORKSPACE b/examples/multi_python_versions/WORKSPACE
new file mode 100644
index 0000000..9a6676e
--- /dev/null
+++ b/examples/multi_python_versions/WORKSPACE
@@ -0,0 +1,59 @@
+workspace(name = "rules_python_multi_python_versions")
+
+load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
+
+http_archive(
+    name = "bazel_skylib",
+    sha256 = "c6966ec828da198c5d9adbaa94c05e3a1c7f21bd012a0b29ba8ddbccb2c93b0d",
+    urls = [
+        "https://github.com/bazelbuild/bazel-skylib/releases/download/1.1.1/bazel-skylib-1.1.1.tar.gz",
+        "https://mirror.bazel.build/github.com/bazelbuild/bazel-skylib/releases/download/1.1.1/bazel-skylib-1.1.1.tar.gz",
+    ],
+)
+
+local_repository(
+    name = "rules_python",
+    path = "../..",
+)
+
+load("@rules_python//python/pip_install:repositories.bzl", "pip_install_dependencies")
+
+pip_install_dependencies()
+
+load("@rules_python//python:repositories.bzl", "python_register_multi_toolchains")
+
+default_python_version = "3.9"
+
+python_register_multi_toolchains(
+    name = "python",
+    default_version = default_python_version,
+    python_versions = [
+        "3.8",
+        "3.9",
+        "3.10",
+    ],
+)
+
+load("@python//:pip.bzl", "multi_pip_parse")
+load("@python//3.10:defs.bzl", interpreter_3_10 = "interpreter")
+load("@python//3.8:defs.bzl", interpreter_3_8 = "interpreter")
+load("@python//3.9:defs.bzl", interpreter_3_9 = "interpreter")
+
+multi_pip_parse(
+    name = "pypi",
+    default_version = default_python_version,
+    python_interpreter_target = {
+        "3.10": interpreter_3_10,
+        "3.8": interpreter_3_8,
+        "3.9": interpreter_3_9,
+    },
+    requirements_lock = {
+        "3.10": "//requirements:requirements_lock_3_10.txt",
+        "3.8": "//requirements:requirements_lock_3_8.txt",
+        "3.9": "//requirements:requirements_lock_3_9.txt",
+    },
+)
+
+load("@pypi//:requirements.bzl", "install_deps")
+
+install_deps()
diff --git a/examples/multi_python_versions/libs/my_lib/BUILD.bazel b/examples/multi_python_versions/libs/my_lib/BUILD.bazel
new file mode 100644
index 0000000..8c29f60
--- /dev/null
+++ b/examples/multi_python_versions/libs/my_lib/BUILD.bazel
@@ -0,0 +1,9 @@
+load("@pypi//:requirements.bzl", "requirement")
+load("@rules_python//python:defs.bzl", "py_library")
+
+py_library(
+    name = "my_lib",
+    srcs = ["__init__.py"],
+    visibility = ["@//tests:__pkg__"],
+    deps = [requirement("websockets")],
+)
diff --git a/examples/multi_python_versions/libs/my_lib/__init__.py b/examples/multi_python_versions/libs/my_lib/__init__.py
new file mode 100644
index 0000000..3f02b96
--- /dev/null
+++ b/examples/multi_python_versions/libs/my_lib/__init__.py
@@ -0,0 +1,5 @@
+import websockets
+
+
+def websockets_is_for_python_version(sanitized_version_check):
+    return f"pypi_{sanitized_version_check}_websockets" in websockets.__file__
diff --git a/examples/multi_python_versions/requirements/BUILD.bazel b/examples/multi_python_versions/requirements/BUILD.bazel
new file mode 100644
index 0000000..4848fab
--- /dev/null
+++ b/examples/multi_python_versions/requirements/BUILD.bazel
@@ -0,0 +1,24 @@
+load("@python//3.10:defs.bzl", compile_pip_requirements_3_10 = "compile_pip_requirements")
+load("@python//3.8:defs.bzl", compile_pip_requirements_3_8 = "compile_pip_requirements")
+load("@python//3.9:defs.bzl", compile_pip_requirements_3_9 = "compile_pip_requirements")
+
+compile_pip_requirements_3_8(
+    name = "requirements_3_8",
+    extra_args = ["--allow-unsafe"],
+    requirements_in = "requirements.in",
+    requirements_txt = "requirements_lock_3_8.txt",
+)
+
+compile_pip_requirements_3_9(
+    name = "requirements_3_9",
+    extra_args = ["--allow-unsafe"],
+    requirements_in = "requirements.in",
+    requirements_txt = "requirements_lock_3_9.txt",
+)
+
+compile_pip_requirements_3_10(
+    name = "requirements_3_10",
+    extra_args = ["--allow-unsafe"],
+    requirements_in = "requirements.in",
+    requirements_txt = "requirements_lock_3_10.txt",
+)
diff --git a/examples/multi_python_versions/requirements/requirements.in b/examples/multi_python_versions/requirements/requirements.in
new file mode 100644
index 0000000..14774b4
--- /dev/null
+++ b/examples/multi_python_versions/requirements/requirements.in
@@ -0,0 +1 @@
+websockets
diff --git a/examples/multi_python_versions/requirements/requirements_lock_3_10.txt b/examples/multi_python_versions/requirements/requirements_lock_3_10.txt
new file mode 100644
index 0000000..0e332bf
--- /dev/null
+++ b/examples/multi_python_versions/requirements/requirements_lock_3_10.txt
@@ -0,0 +1,56 @@
+#
+# This file is autogenerated by pip-compile with python 3.10
+# To update, run:
+#
+#    bazel run //requirements:requirements_3_10.update
+#
+websockets==10.3 \
+    --hash=sha256:07cdc0a5b2549bcfbadb585ad8471ebdc7bdf91e32e34ae3889001c1c106a6af \
+    --hash=sha256:210aad7fdd381c52e58777560860c7e6110b6174488ef1d4b681c08b68bf7f8c \
+    --hash=sha256:28dd20b938a57c3124028680dc1600c197294da5db4292c76a0b48efb3ed7f76 \
+    --hash=sha256:2f94fa3ae454a63ea3a19f73b95deeebc9f02ba2d5617ca16f0bbdae375cda47 \
+    --hash=sha256:31564a67c3e4005f27815634343df688b25705cccb22bc1db621c781ddc64c69 \
+    --hash=sha256:347974105bbd4ea068106ec65e8e8ebd86f28c19e529d115d89bd8cc5cda3079 \
+    --hash=sha256:379e03422178436af4f3abe0aa8f401aa77ae2487843738542a75faf44a31f0c \
+    --hash=sha256:3eda1cb7e9da1b22588cefff09f0951771d6ee9fa8dbe66f5ae04cc5f26b2b55 \
+    --hash=sha256:51695d3b199cd03098ae5b42833006a0f43dc5418d3102972addc593a783bc02 \
+    --hash=sha256:54c000abeaff6d8771a4e2cef40900919908ea7b6b6a30eae72752607c6db559 \
+    --hash=sha256:5b936bf552e4f6357f5727579072ff1e1324717902127ffe60c92d29b67b7be3 \
+    --hash=sha256:6075fd24df23133c1b078e08a9b04a3bc40b31a8def4ee0b9f2c8865acce913e \
+    --hash=sha256:661f641b44ed315556a2fa630239adfd77bd1b11cb0b9d96ed8ad90b0b1e4978 \
+    --hash=sha256:6ea6b300a6bdd782e49922d690e11c3669828fe36fc2471408c58b93b5535a98 \
+    --hash=sha256:6ed1d6f791eabfd9808afea1e068f5e59418e55721db8b7f3bfc39dc831c42ae \
+    --hash=sha256:7934e055fd5cd9dee60f11d16c8d79c4567315824bacb1246d0208a47eca9755 \
+    --hash=sha256:7ab36e17af592eec5747c68ef2722a74c1a4a70f3772bc661079baf4ae30e40d \
+    --hash=sha256:7f6d96fdb0975044fdd7953b35d003b03f9e2bcf85f2d2cf86285ece53e9f991 \
+    --hash=sha256:83e5ca0d5b743cde3d29fda74ccab37bdd0911f25bd4cdf09ff8b51b7b4f2fa1 \
+    --hash=sha256:85506b3328a9e083cc0a0fb3ba27e33c8db78341b3eb12eb72e8afd166c36680 \
+    --hash=sha256:8af75085b4bc0b5c40c4a3c0e113fa95e84c60f4ed6786cbb675aeb1ee128247 \
+    --hash=sha256:8b1359aba0ff810d5830d5ab8e2c4a02bebf98a60aa0124fb29aa78cfdb8031f \
+    --hash=sha256:8fbd7d77f8aba46d43245e86dd91a8970eac4fb74c473f8e30e9c07581f852b2 \
+    --hash=sha256:907e8247480f287aa9bbc9391bd6de23c906d48af54c8c421df84655eef66af7 \
+    --hash=sha256:93d5ea0b5da8d66d868b32c614d2b52d14304444e39e13a59566d4acb8d6e2e4 \
+    --hash=sha256:97bc9d41e69a7521a358f9b8e44871f6cdeb42af31815c17aed36372d4eec667 \
+    --hash=sha256:994cdb1942a7a4c2e10098d9162948c9e7b235df755de91ca33f6e0481366fdb \
+    --hash=sha256:a141de3d5a92188234afa61653ed0bbd2dde46ad47b15c3042ffb89548e77094 \
+    --hash=sha256:a1e15b230c3613e8ea82c9fc6941b2093e8eb939dd794c02754d33980ba81e36 \
+    --hash=sha256:aad5e300ab32036eb3fdc350ad30877210e2f51bceaca83fb7fef4d2b6c72b79 \
+    --hash=sha256:b529fdfa881b69fe563dbd98acce84f3e5a67df13de415e143ef053ff006d500 \
+    --hash=sha256:b9c77f0d1436ea4b4dc089ed8335fa141e6a251a92f75f675056dac4ab47a71e \
+    --hash=sha256:bb621ec2dbbbe8df78a27dbd9dd7919f9b7d32a73fafcb4d9252fc4637343582 \
+    --hash=sha256:c7250848ce69559756ad0086a37b82c986cd33c2d344ab87fea596c5ac6d9442 \
+    --hash=sha256:c8d1d14aa0f600b5be363077b621b1b4d1eb3fbf90af83f9281cda668e6ff7fd \
+    --hash=sha256:d1655a6fc7aecd333b079d00fb3c8132d18988e47f19740c69303bf02e9883c6 \
+    --hash=sha256:d6353ba89cfc657a3f5beabb3b69be226adbb5c6c7a66398e17809b0ce3c4731 \
+    --hash=sha256:da4377904a3379f0c1b75a965fff23b28315bcd516d27f99a803720dfebd94d4 \
+    --hash=sha256:e49ea4c1a9543d2bd8a747ff24411509c29e4bdcde05b5b0895e2120cb1a761d \
+    --hash=sha256:e4e08305bfd76ba8edab08dcc6496f40674f44eb9d5e23153efa0a35750337e8 \
+    --hash=sha256:e6fa05a680e35d0fcc1470cb070b10e6fe247af54768f488ed93542e71339d6f \
+    --hash=sha256:e7e6f2d6fd48422071cc8a6f8542016f350b79cc782752de531577d35e9bd677 \
+    --hash=sha256:e904c0381c014b914136c492c8fa711ca4cced4e9b3d110e5e7d436d0fc289e8 \
+    --hash=sha256:ec2b0ab7edc8cd4b0eb428b38ed89079bdc20c6bdb5f889d353011038caac2f9 \
+    --hash=sha256:ef5ce841e102278c1c2e98f043db99d6755b1c58bde475516aef3a008ed7f28e \
+    --hash=sha256:f351c7d7d92f67c0609329ab2735eee0426a03022771b00102816a72715bb00b \
+    --hash=sha256:fab7c640815812ed5f10fbee7abbf58788d602046b7bb3af9b1ac753a6d5e916 \
+    --hash=sha256:fc06cc8073c8e87072138ba1e431300e2d408f054b27047d047b549455066ff4
+    # via -r requirements/requirements.in
diff --git a/examples/multi_python_versions/requirements/requirements_lock_3_8.txt b/examples/multi_python_versions/requirements/requirements_lock_3_8.txt
new file mode 100644
index 0000000..30419da
--- /dev/null
+++ b/examples/multi_python_versions/requirements/requirements_lock_3_8.txt
@@ -0,0 +1,56 @@
+#
+# This file is autogenerated by pip-compile with python 3.8
+# To update, run:
+#
+#    bazel run //requirements:requirements_3_8.update
+#
+websockets==10.3 \
+    --hash=sha256:07cdc0a5b2549bcfbadb585ad8471ebdc7bdf91e32e34ae3889001c1c106a6af \
+    --hash=sha256:210aad7fdd381c52e58777560860c7e6110b6174488ef1d4b681c08b68bf7f8c \
+    --hash=sha256:28dd20b938a57c3124028680dc1600c197294da5db4292c76a0b48efb3ed7f76 \
+    --hash=sha256:2f94fa3ae454a63ea3a19f73b95deeebc9f02ba2d5617ca16f0bbdae375cda47 \
+    --hash=sha256:31564a67c3e4005f27815634343df688b25705cccb22bc1db621c781ddc64c69 \
+    --hash=sha256:347974105bbd4ea068106ec65e8e8ebd86f28c19e529d115d89bd8cc5cda3079 \
+    --hash=sha256:379e03422178436af4f3abe0aa8f401aa77ae2487843738542a75faf44a31f0c \
+    --hash=sha256:3eda1cb7e9da1b22588cefff09f0951771d6ee9fa8dbe66f5ae04cc5f26b2b55 \
+    --hash=sha256:51695d3b199cd03098ae5b42833006a0f43dc5418d3102972addc593a783bc02 \
+    --hash=sha256:54c000abeaff6d8771a4e2cef40900919908ea7b6b6a30eae72752607c6db559 \
+    --hash=sha256:5b936bf552e4f6357f5727579072ff1e1324717902127ffe60c92d29b67b7be3 \
+    --hash=sha256:6075fd24df23133c1b078e08a9b04a3bc40b31a8def4ee0b9f2c8865acce913e \
+    --hash=sha256:661f641b44ed315556a2fa630239adfd77bd1b11cb0b9d96ed8ad90b0b1e4978 \
+    --hash=sha256:6ea6b300a6bdd782e49922d690e11c3669828fe36fc2471408c58b93b5535a98 \
+    --hash=sha256:6ed1d6f791eabfd9808afea1e068f5e59418e55721db8b7f3bfc39dc831c42ae \
+    --hash=sha256:7934e055fd5cd9dee60f11d16c8d79c4567315824bacb1246d0208a47eca9755 \
+    --hash=sha256:7ab36e17af592eec5747c68ef2722a74c1a4a70f3772bc661079baf4ae30e40d \
+    --hash=sha256:7f6d96fdb0975044fdd7953b35d003b03f9e2bcf85f2d2cf86285ece53e9f991 \
+    --hash=sha256:83e5ca0d5b743cde3d29fda74ccab37bdd0911f25bd4cdf09ff8b51b7b4f2fa1 \
+    --hash=sha256:85506b3328a9e083cc0a0fb3ba27e33c8db78341b3eb12eb72e8afd166c36680 \
+    --hash=sha256:8af75085b4bc0b5c40c4a3c0e113fa95e84c60f4ed6786cbb675aeb1ee128247 \
+    --hash=sha256:8b1359aba0ff810d5830d5ab8e2c4a02bebf98a60aa0124fb29aa78cfdb8031f \
+    --hash=sha256:8fbd7d77f8aba46d43245e86dd91a8970eac4fb74c473f8e30e9c07581f852b2 \
+    --hash=sha256:907e8247480f287aa9bbc9391bd6de23c906d48af54c8c421df84655eef66af7 \
+    --hash=sha256:93d5ea0b5da8d66d868b32c614d2b52d14304444e39e13a59566d4acb8d6e2e4 \
+    --hash=sha256:97bc9d41e69a7521a358f9b8e44871f6cdeb42af31815c17aed36372d4eec667 \
+    --hash=sha256:994cdb1942a7a4c2e10098d9162948c9e7b235df755de91ca33f6e0481366fdb \
+    --hash=sha256:a141de3d5a92188234afa61653ed0bbd2dde46ad47b15c3042ffb89548e77094 \
+    --hash=sha256:a1e15b230c3613e8ea82c9fc6941b2093e8eb939dd794c02754d33980ba81e36 \
+    --hash=sha256:aad5e300ab32036eb3fdc350ad30877210e2f51bceaca83fb7fef4d2b6c72b79 \
+    --hash=sha256:b529fdfa881b69fe563dbd98acce84f3e5a67df13de415e143ef053ff006d500 \
+    --hash=sha256:b9c77f0d1436ea4b4dc089ed8335fa141e6a251a92f75f675056dac4ab47a71e \
+    --hash=sha256:bb621ec2dbbbe8df78a27dbd9dd7919f9b7d32a73fafcb4d9252fc4637343582 \
+    --hash=sha256:c7250848ce69559756ad0086a37b82c986cd33c2d344ab87fea596c5ac6d9442 \
+    --hash=sha256:c8d1d14aa0f600b5be363077b621b1b4d1eb3fbf90af83f9281cda668e6ff7fd \
+    --hash=sha256:d1655a6fc7aecd333b079d00fb3c8132d18988e47f19740c69303bf02e9883c6 \
+    --hash=sha256:d6353ba89cfc657a3f5beabb3b69be226adbb5c6c7a66398e17809b0ce3c4731 \
+    --hash=sha256:da4377904a3379f0c1b75a965fff23b28315bcd516d27f99a803720dfebd94d4 \
+    --hash=sha256:e49ea4c1a9543d2bd8a747ff24411509c29e4bdcde05b5b0895e2120cb1a761d \
+    --hash=sha256:e4e08305bfd76ba8edab08dcc6496f40674f44eb9d5e23153efa0a35750337e8 \
+    --hash=sha256:e6fa05a680e35d0fcc1470cb070b10e6fe247af54768f488ed93542e71339d6f \
+    --hash=sha256:e7e6f2d6fd48422071cc8a6f8542016f350b79cc782752de531577d35e9bd677 \
+    --hash=sha256:e904c0381c014b914136c492c8fa711ca4cced4e9b3d110e5e7d436d0fc289e8 \
+    --hash=sha256:ec2b0ab7edc8cd4b0eb428b38ed89079bdc20c6bdb5f889d353011038caac2f9 \
+    --hash=sha256:ef5ce841e102278c1c2e98f043db99d6755b1c58bde475516aef3a008ed7f28e \
+    --hash=sha256:f351c7d7d92f67c0609329ab2735eee0426a03022771b00102816a72715bb00b \
+    --hash=sha256:fab7c640815812ed5f10fbee7abbf58788d602046b7bb3af9b1ac753a6d5e916 \
+    --hash=sha256:fc06cc8073c8e87072138ba1e431300e2d408f054b27047d047b549455066ff4
+    # via -r requirements/requirements.in
diff --git a/examples/multi_python_versions/requirements/requirements_lock_3_9.txt b/examples/multi_python_versions/requirements/requirements_lock_3_9.txt
new file mode 100644
index 0000000..124355e
--- /dev/null
+++ b/examples/multi_python_versions/requirements/requirements_lock_3_9.txt
@@ -0,0 +1,56 @@
+#
+# This file is autogenerated by pip-compile with python 3.9
+# To update, run:
+#
+#    bazel run //requirements:requirements_3_9.update
+#
+websockets==10.3 \
+    --hash=sha256:07cdc0a5b2549bcfbadb585ad8471ebdc7bdf91e32e34ae3889001c1c106a6af \
+    --hash=sha256:210aad7fdd381c52e58777560860c7e6110b6174488ef1d4b681c08b68bf7f8c \
+    --hash=sha256:28dd20b938a57c3124028680dc1600c197294da5db4292c76a0b48efb3ed7f76 \
+    --hash=sha256:2f94fa3ae454a63ea3a19f73b95deeebc9f02ba2d5617ca16f0bbdae375cda47 \
+    --hash=sha256:31564a67c3e4005f27815634343df688b25705cccb22bc1db621c781ddc64c69 \
+    --hash=sha256:347974105bbd4ea068106ec65e8e8ebd86f28c19e529d115d89bd8cc5cda3079 \
+    --hash=sha256:379e03422178436af4f3abe0aa8f401aa77ae2487843738542a75faf44a31f0c \
+    --hash=sha256:3eda1cb7e9da1b22588cefff09f0951771d6ee9fa8dbe66f5ae04cc5f26b2b55 \
+    --hash=sha256:51695d3b199cd03098ae5b42833006a0f43dc5418d3102972addc593a783bc02 \
+    --hash=sha256:54c000abeaff6d8771a4e2cef40900919908ea7b6b6a30eae72752607c6db559 \
+    --hash=sha256:5b936bf552e4f6357f5727579072ff1e1324717902127ffe60c92d29b67b7be3 \
+    --hash=sha256:6075fd24df23133c1b078e08a9b04a3bc40b31a8def4ee0b9f2c8865acce913e \
+    --hash=sha256:661f641b44ed315556a2fa630239adfd77bd1b11cb0b9d96ed8ad90b0b1e4978 \
+    --hash=sha256:6ea6b300a6bdd782e49922d690e11c3669828fe36fc2471408c58b93b5535a98 \
+    --hash=sha256:6ed1d6f791eabfd9808afea1e068f5e59418e55721db8b7f3bfc39dc831c42ae \
+    --hash=sha256:7934e055fd5cd9dee60f11d16c8d79c4567315824bacb1246d0208a47eca9755 \
+    --hash=sha256:7ab36e17af592eec5747c68ef2722a74c1a4a70f3772bc661079baf4ae30e40d \
+    --hash=sha256:7f6d96fdb0975044fdd7953b35d003b03f9e2bcf85f2d2cf86285ece53e9f991 \
+    --hash=sha256:83e5ca0d5b743cde3d29fda74ccab37bdd0911f25bd4cdf09ff8b51b7b4f2fa1 \
+    --hash=sha256:85506b3328a9e083cc0a0fb3ba27e33c8db78341b3eb12eb72e8afd166c36680 \
+    --hash=sha256:8af75085b4bc0b5c40c4a3c0e113fa95e84c60f4ed6786cbb675aeb1ee128247 \
+    --hash=sha256:8b1359aba0ff810d5830d5ab8e2c4a02bebf98a60aa0124fb29aa78cfdb8031f \
+    --hash=sha256:8fbd7d77f8aba46d43245e86dd91a8970eac4fb74c473f8e30e9c07581f852b2 \
+    --hash=sha256:907e8247480f287aa9bbc9391bd6de23c906d48af54c8c421df84655eef66af7 \
+    --hash=sha256:93d5ea0b5da8d66d868b32c614d2b52d14304444e39e13a59566d4acb8d6e2e4 \
+    --hash=sha256:97bc9d41e69a7521a358f9b8e44871f6cdeb42af31815c17aed36372d4eec667 \
+    --hash=sha256:994cdb1942a7a4c2e10098d9162948c9e7b235df755de91ca33f6e0481366fdb \
+    --hash=sha256:a141de3d5a92188234afa61653ed0bbd2dde46ad47b15c3042ffb89548e77094 \
+    --hash=sha256:a1e15b230c3613e8ea82c9fc6941b2093e8eb939dd794c02754d33980ba81e36 \
+    --hash=sha256:aad5e300ab32036eb3fdc350ad30877210e2f51bceaca83fb7fef4d2b6c72b79 \
+    --hash=sha256:b529fdfa881b69fe563dbd98acce84f3e5a67df13de415e143ef053ff006d500 \
+    --hash=sha256:b9c77f0d1436ea4b4dc089ed8335fa141e6a251a92f75f675056dac4ab47a71e \
+    --hash=sha256:bb621ec2dbbbe8df78a27dbd9dd7919f9b7d32a73fafcb4d9252fc4637343582 \
+    --hash=sha256:c7250848ce69559756ad0086a37b82c986cd33c2d344ab87fea596c5ac6d9442 \
+    --hash=sha256:c8d1d14aa0f600b5be363077b621b1b4d1eb3fbf90af83f9281cda668e6ff7fd \
+    --hash=sha256:d1655a6fc7aecd333b079d00fb3c8132d18988e47f19740c69303bf02e9883c6 \
+    --hash=sha256:d6353ba89cfc657a3f5beabb3b69be226adbb5c6c7a66398e17809b0ce3c4731 \
+    --hash=sha256:da4377904a3379f0c1b75a965fff23b28315bcd516d27f99a803720dfebd94d4 \
+    --hash=sha256:e49ea4c1a9543d2bd8a747ff24411509c29e4bdcde05b5b0895e2120cb1a761d \
+    --hash=sha256:e4e08305bfd76ba8edab08dcc6496f40674f44eb9d5e23153efa0a35750337e8 \
+    --hash=sha256:e6fa05a680e35d0fcc1470cb070b10e6fe247af54768f488ed93542e71339d6f \
+    --hash=sha256:e7e6f2d6fd48422071cc8a6f8542016f350b79cc782752de531577d35e9bd677 \
+    --hash=sha256:e904c0381c014b914136c492c8fa711ca4cced4e9b3d110e5e7d436d0fc289e8 \
+    --hash=sha256:ec2b0ab7edc8cd4b0eb428b38ed89079bdc20c6bdb5f889d353011038caac2f9 \
+    --hash=sha256:ef5ce841e102278c1c2e98f043db99d6755b1c58bde475516aef3a008ed7f28e \
+    --hash=sha256:f351c7d7d92f67c0609329ab2735eee0426a03022771b00102816a72715bb00b \
+    --hash=sha256:fab7c640815812ed5f10fbee7abbf58788d602046b7bb3af9b1ac753a6d5e916 \
+    --hash=sha256:fc06cc8073c8e87072138ba1e431300e2d408f054b27047d047b549455066ff4
+    # via -r requirements/requirements.in
diff --git a/examples/multi_python_versions/tests/BUILD.bazel b/examples/multi_python_versions/tests/BUILD.bazel
new file mode 100644
index 0000000..7219ca5
--- /dev/null
+++ b/examples/multi_python_versions/tests/BUILD.bazel
@@ -0,0 +1,148 @@
+load("@python//3.10:defs.bzl", py_binary_3_10 = "py_binary", py_test_3_10 = "py_test")
+load("@python//3.8:defs.bzl", py_binary_3_8 = "py_binary", py_test_3_8 = "py_test")
+load("@python//3.9:defs.bzl", py_binary_3_9 = "py_binary", py_test_3_9 = "py_test")
+load("@rules_python//python:defs.bzl", "py_binary", "py_test")
+
+py_binary(
+    name = "version_default",
+    srcs = ["version.py"],
+    main = "version.py",
+)
+
+py_binary_3_8(
+    name = "version_3_8",
+    srcs = ["version.py"],
+    main = "version.py",
+)
+
+py_binary_3_9(
+    name = "version_3_9",
+    srcs = ["version.py"],
+    main = "version.py",
+)
+
+py_binary_3_10(
+    name = "version_3_10",
+    srcs = ["version.py"],
+    main = "version.py",
+)
+
+py_test(
+    name = "my_lib_default_test",
+    srcs = ["my_lib_test.py"],
+    main = "my_lib_test.py",
+    deps = ["//libs/my_lib"],
+)
+
+py_test_3_8(
+    name = "my_lib_3_8_test",
+    srcs = ["my_lib_test.py"],
+    main = "my_lib_test.py",
+    deps = ["//libs/my_lib"],
+)
+
+py_test_3_9(
+    name = "my_lib_3_9_test",
+    srcs = ["my_lib_test.py"],
+    main = "my_lib_test.py",
+    deps = ["//libs/my_lib"],
+)
+
+py_test_3_10(
+    name = "my_lib_3_10_test",
+    srcs = ["my_lib_test.py"],
+    main = "my_lib_test.py",
+    deps = ["//libs/my_lib"],
+)
+
+py_test(
+    name = "version_default_test",
+    srcs = ["version_test.py"],
+    env = {"VERSION_CHECK": "3.9"},  # The default defined in the WORKSPACE.
+    main = "version_test.py",
+)
+
+py_test_3_8(
+    name = "version_3_8_test",
+    srcs = ["version_test.py"],
+    env = {"VERSION_CHECK": "3.8"},
+    main = "version_test.py",
+)
+
+py_test_3_9(
+    name = "version_3_9_test",
+    srcs = ["version_test.py"],
+    env = {"VERSION_CHECK": "3.9"},
+    main = "version_test.py",
+)
+
+py_test_3_10(
+    name = "version_3_10_test",
+    srcs = ["version_test.py"],
+    env = {"VERSION_CHECK": "3.10"},
+    main = "version_test.py",
+)
+
+py_test(
+    name = "version_default_takes_3_10_subprocess_test",
+    srcs = ["cross_version_test.py"],
+    data = [":version_3_10"],
+    env = {
+        "SUBPROCESS_VERSION_CHECK": "3.10",
+        "SUBPROCESS_VERSION_PY_BINARY": "$(rootpath :version_3_10)",
+        "VERSION_CHECK": "3.9",
+    },
+    main = "cross_version_test.py",
+)
+
+py_test_3_10(
+    name = "version_3_10_takes_3_9_subprocess_test",
+    srcs = ["cross_version_test.py"],
+    data = [":version_3_9"],
+    env = {
+        "SUBPROCESS_VERSION_CHECK": "3.9",
+        "SUBPROCESS_VERSION_PY_BINARY": "$(rootpath :version_3_9)",
+        "VERSION_CHECK": "3.10",
+    },
+    main = "cross_version_test.py",
+)
+
+sh_test(
+    name = "version_test_binary_default",
+    srcs = ["version_test.sh"],
+    data = [":version_default"],
+    env = {
+        "VERSION_CHECK": "3.9",  # The default defined in the WORKSPACE.
+        "VERSION_PY_BINARY": "$(rootpath :version_default)",
+    },
+)
+
+sh_test(
+    name = "version_test_binary_3_8",
+    srcs = ["version_test.sh"],
+    data = [":version_3_8"],
+    env = {
+        "VERSION_CHECK": "3.8",
+        "VERSION_PY_BINARY": "$(rootpath :version_3_8)",
+    },
+)
+
+sh_test(
+    name = "version_test_binary_3_9",
+    srcs = ["version_test.sh"],
+    data = [":version_3_9"],
+    env = {
+        "VERSION_CHECK": "3.9",
+        "VERSION_PY_BINARY": "$(rootpath :version_3_9)",
+    },
+)
+
+sh_test(
+    name = "version_test_binary_3_10",
+    srcs = ["version_test.sh"],
+    data = [":version_3_10"],
+    env = {
+        "VERSION_CHECK": "3.10",
+        "VERSION_PY_BINARY": "$(rootpath :version_3_10)",
+    },
+)
diff --git a/examples/multi_python_versions/tests/cross_version_test.py b/examples/multi_python_versions/tests/cross_version_test.py
new file mode 100644
index 0000000..f933ed6
--- /dev/null
+++ b/examples/multi_python_versions/tests/cross_version_test.py
@@ -0,0 +1,25 @@
+import os
+import subprocess
+import sys
+
+process = subprocess.run(
+    [os.getenv("SUBPROCESS_VERSION_PY_BINARY")],
+    stdout=subprocess.PIPE,
+    universal_newlines=True,
+)
+
+subprocess_current = process.stdout.strip()
+subprocess_expected = os.getenv("SUBPROCESS_VERSION_CHECK")
+
+if subprocess_current != subprocess_expected:
+    print(
+        f"expected subprocess version '{subprocess_expected}' is different than returned '{subprocess_current}'"
+    )
+    sys.exit(1)
+
+expected = os.getenv("VERSION_CHECK")
+current = f"{sys.version_info.major}.{sys.version_info.minor}"
+
+if current != expected:
+    print(f"expected version '{expected}' is different than returned '{current}'")
+    sys.exit(1)
diff --git a/examples/multi_python_versions/tests/my_lib_test.py b/examples/multi_python_versions/tests/my_lib_test.py
new file mode 100644
index 0000000..4fc7095
--- /dev/null
+++ b/examples/multi_python_versions/tests/my_lib_test.py
@@ -0,0 +1,10 @@
+import os
+import sys
+
+import libs.my_lib as my_lib
+
+sanitized_version_check = f"{sys.version_info.major}_{sys.version_info.minor}"
+
+if not my_lib.websockets_is_for_python_version(sanitized_version_check):
+    print("expected package for Python version is different than returned")
+    sys.exit(1)
diff --git a/examples/multi_python_versions/tests/version.py b/examples/multi_python_versions/tests/version.py
new file mode 100644
index 0000000..1007a14
--- /dev/null
+++ b/examples/multi_python_versions/tests/version.py
@@ -0,0 +1,3 @@
+import sys
+
+print(f"{sys.version_info.major}.{sys.version_info.minor}")
diff --git a/examples/multi_python_versions/tests/version_test.py b/examples/multi_python_versions/tests/version_test.py
new file mode 100644
index 0000000..305773c
--- /dev/null
+++ b/examples/multi_python_versions/tests/version_test.py
@@ -0,0 +1,9 @@
+import os
+import sys
+
+expected = os.getenv("VERSION_CHECK")
+current = f"{sys.version_info.major}.{sys.version_info.minor}"
+
+if current != expected:
+    print(f"expected version '{expected}' is different than returned '{current}'")
+    sys.exit(1)
diff --git a/examples/multi_python_versions/tests/version_test.sh b/examples/multi_python_versions/tests/version_test.sh
new file mode 100755
index 0000000..b8f510d
--- /dev/null
+++ b/examples/multi_python_versions/tests/version_test.sh
@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+
+set -o errexit -o nounset -o pipefail
+
+version_py_binary=$("${VERSION_PY_BINARY}")
+
+if [[ "${version_py_binary}" != "${VERSION_CHECK}" ]]; then
+    echo >&2 "expected version '${VERSION_CHECK}' is different than returned '${version_py_binary}'"
+    exit 1
+fi
diff --git a/examples/pip_parse/WORKSPACE b/examples/pip_parse/WORKSPACE
index e96db9f..cd557a3 100644
--- a/examples/pip_parse/WORKSPACE
+++ b/examples/pip_parse/WORKSPACE
@@ -1,5 +1,16 @@
 workspace(name = "rules_python_pip_parse_example")
 
+load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
+
+http_archive(
+    name = "bazel_skylib",
+    sha256 = "c6966ec828da198c5d9adbaa94c05e3a1c7f21bd012a0b29ba8ddbccb2c93b0d",
+    urls = [
+        "https://github.com/bazelbuild/bazel-skylib/releases/download/1.1.1/bazel-skylib-1.1.1.tar.gz",
+        "https://mirror.bazel.build/github.com/bazelbuild/bazel-skylib/releases/download/1.1.1/bazel-skylib-1.1.1.tar.gz",
+    ],
+)
+
 local_repository(
     name = "rules_python",
     path = "../..",
diff --git a/python/BUILD b/python/BUILD
index ce19653..dcdbee1 100644
--- a/python/BUILD
+++ b/python/BUILD
@@ -34,8 +34,9 @@
     name = "distribution",
     srcs = glob(["**"]) + [
         "//python/constraints:distribution",
-        "//python/runfiles:distribution",
+        "//python/config_settings:distribution",
         "//python/private:distribution",
+        "//python/runfiles:distribution",
     ],
     visibility = ["//:__pkg__"],
 )
diff --git a/python/config_settings/BUILD.bazel b/python/config_settings/BUILD.bazel
new file mode 100644
index 0000000..272ba78
--- /dev/null
+++ b/python/config_settings/BUILD.bazel
@@ -0,0 +1,12 @@
+load("//python:versions.bzl", "TOOL_VERSIONS")
+load(":config_settings.bzl", "construct_config_settings")
+
+filegroup(
+    name = "distribution",
+    srcs = glob(["*.bzl"]) + [
+        "BUILD.bazel",
+    ],
+    visibility = ["//python:__pkg__"],
+)
+
+construct_config_settings(python_versions = TOOL_VERSIONS.keys())
diff --git a/python/config_settings/config_settings.bzl b/python/config_settings/config_settings.bzl
new file mode 100644
index 0000000..977d023
--- /dev/null
+++ b/python/config_settings/config_settings.bzl
@@ -0,0 +1,26 @@
+"""This module is used to construct the config settings in the BUILD file in this same package.
+"""
+
+load("@bazel_skylib//rules:common_settings.bzl", "string_flag")
+
+# buildifier: disable=unnamed-macro
+def construct_config_settings(python_versions):
+    """Constructs a set of configs for all Python versions.
+
+    Args:
+        python_versions: The Python versions supported by rules_python.
+    """
+    string_flag(
+        name = "python_version",
+        build_setting_default = python_versions[0],
+        values = python_versions,
+        visibility = ["//visibility:public"],
+    )
+
+    for python_version in python_versions:
+        python_version_constraint_setting = "is_python_" + python_version
+        native.config_setting(
+            name = python_version_constraint_setting,
+            flag_values = {":python_version": python_version},
+            visibility = ["//visibility:public"],
+        )
diff --git a/python/config_settings/transition.bzl b/python/config_settings/transition.bzl
new file mode 100644
index 0000000..2fd3384
--- /dev/null
+++ b/python/config_settings/transition.bzl
@@ -0,0 +1,208 @@
+"""The transition module contains the rule definitions to wrap py_binary and py_test and transition
+them to the desired target platform.
+"""
+
+load("@bazel_skylib//lib:dicts.bzl", "dicts")
+load("//python:defs.bzl", _py_binary = "py_binary", _py_test = "py_test")
+
+def _transition_python_version_impl(_, attr):
+    return {"//python/config_settings:python_version": str(attr.python_version)}
+
+_transition_python_version = transition(
+    implementation = _transition_python_version_impl,
+    inputs = [],
+    outputs = ["//python/config_settings:python_version"],
+)
+
+def _transition_py_impl(ctx):
+    target = ctx.attr.target
+    windows_constraint = ctx.attr._windows_constraint[platform_common.ConstraintValueInfo]
+    target_is_windows = ctx.target_platform_has_constraint(windows_constraint)
+    executable = ctx.actions.declare_file(ctx.attr.name + (".exe" if target_is_windows else ""))
+    ctx.actions.symlink(
+        is_executable = True,
+        output = executable,
+        target_file = target[DefaultInfo].files_to_run.executable,
+    )
+    zipfile_symlink = None
+    if target_is_windows:
+        # Under Windows, the expected "<name>.zip" does not exist, so we have to
+        # create the symlink ourselves to achieve the same behaviour as in macOS
+        # and Linux.
+        zipfile = None
+        expected_target_path = target[DefaultInfo].files_to_run.executable.short_path[:-4] + ".zip"
+        for file in target[DefaultInfo].default_runfiles.files.to_list():
+            if file.short_path == expected_target_path:
+                zipfile = file
+        zipfile_symlink = ctx.actions.declare_file(ctx.attr.name + ".zip")
+        ctx.actions.symlink(
+            is_executable = True,
+            output = zipfile_symlink,
+            target_file = zipfile,
+        )
+    env = {}
+    for k, v in ctx.attr.env.items():
+        env[k] = ctx.expand_location(v)
+
+    providers = [
+        DefaultInfo(
+            executable = executable,
+            files = depset([zipfile_symlink] if zipfile_symlink else [], transitive = [target[DefaultInfo].files]),
+            runfiles = ctx.runfiles([zipfile_symlink] if zipfile_symlink else []).merge(target[DefaultInfo].default_runfiles),
+        ),
+        target[PyInfo],
+        target[PyRuntimeInfo],
+        # Ensure that the binary we're wrapping is included in code coverage.
+        coverage_common.instrumented_files_info(
+            ctx,
+            dependency_attributes = ["target"],
+        ),
+        target[OutputGroupInfo],
+        # testing.TestEnvironment is deprecated in favour of RunEnvironmentInfo but
+        # RunEnvironmentInfo is not exposed in Bazel < 5.3.
+        # https://github.com/bazelbuild/bazel/commit/dbdfa07e92f99497be9c14265611ad2920161483
+        (RunEnvironmentInfo if hasattr(native, "RunEnvironmentInfo") else testing.TestEnvironment)(environment = env),
+    ]
+    return providers
+
+_COMMON_ATTRS = {
+    "deps": attr.label_list(
+        mandatory = False,
+    ),
+    "env": attr.string_dict(
+        mandatory = False,
+    ),
+    "python_version": attr.string(
+        mandatory = True,
+    ),
+    "srcs": attr.label_list(
+        allow_files = True,
+        mandatory = False,
+    ),
+    "target": attr.label(
+        executable = True,
+        cfg = "target",
+        mandatory = True,
+        providers = [PyInfo],
+    ),
+    # "tools" is a hack here. It should be "data" but "data" is not included by default in the
+    # location expansion in the same way it is in the native Python rules. The difference on how
+    # the Bazel deals with those special attributes differ on the LocationExpander, e.g.:
+    # https://github.com/bazelbuild/bazel/blob/ce611646/src/main/java/com/google/devtools/build/lib/analysis/LocationExpander.java#L415-L429
+    #
+    # Since the default LocationExpander used by ctx.expand_location is not the same as the native
+    # rules (it doesn't set "allowDataAttributeEntriesInLabel"), we use "tools" temporarily while a
+    # proper fix in Bazel happens.
+    #
+    # A fix for this was proposed in https://github.com/bazelbuild/bazel/pull/16381.
+    "tools": attr.label_list(
+        allow_files = True,
+        mandatory = False,
+    ),
+    # Required to Opt-in to the transitions feature.
+    "_allowlist_function_transition": attr.label(
+        default = "@bazel_tools//tools/allowlists/function_transition_allowlist",
+    ),
+    "_windows_constraint": attr.label(
+        default = "@platforms//os:windows",
+    ),
+}
+
+_transition_py_binary = rule(
+    _transition_py_impl,
+    attrs = _COMMON_ATTRS,
+    cfg = _transition_python_version,
+    executable = True,
+)
+
+_transition_py_test = rule(
+    _transition_py_impl,
+    attrs = _COMMON_ATTRS,
+    cfg = _transition_python_version,
+    test = True,
+)
+
+def _py_rule(rule_impl, transition_rule, name, python_version, **kwargs):
+    args = kwargs.pop("args", None)
+    data = kwargs.pop("data", None)
+    env = kwargs.pop("env", None)
+    srcs = kwargs.pop("srcs", None)
+    deps = kwargs.pop("deps", None)
+
+    # Attributes common to all build rules.
+    # https://bazel.build/reference/be/common-definitions#common-attributes
+    compatible_with = kwargs.pop("compatible_with", None)
+    deprecation = kwargs.pop("deprecation", None)
+    distribs = kwargs.pop("distribs", None)
+    exec_compatible_with = kwargs.pop("exec_compatible_with", None)
+    exec_properties = kwargs.pop("exec_properties", None)
+    features = kwargs.pop("features", None)
+    restricted_to = kwargs.pop("restricted_to", None)
+    tags = kwargs.pop("tags", None)
+    target_compatible_with = kwargs.pop("target_compatible_with", None)
+    testonly = kwargs.pop("testonly", None)
+    toolchains = kwargs.pop("toolchains", None)
+    visibility = kwargs.pop("visibility", None)
+
+    common_attrs = {
+        "compatible_with": compatible_with,
+        "deprecation": deprecation,
+        "distribs": distribs,
+        "exec_compatible_with": exec_compatible_with,
+        "exec_properties": exec_properties,
+        "features": features,
+        "restricted_to": restricted_to,
+        "target_compatible_with": target_compatible_with,
+        "testonly": testonly,
+        "toolchains": toolchains,
+    }
+
+    # Test-specific extra attributes.
+    if "env_inherit" in kwargs:
+        common_attrs["env_inherit"] = kwargs.pop("env_inherit")
+    if "size" in kwargs:
+        common_attrs["size"] = kwargs.pop("size")
+    if "timeout" in kwargs:
+        common_attrs["timeout"] = kwargs.pop("timeout")
+    if "flaky" in kwargs:
+        common_attrs["flaky"] = kwargs.pop("flaky")
+    if "shard_count" in kwargs:
+        common_attrs["shard_count"] = kwargs.pop("shard_count")
+    if "local" in kwargs:
+        common_attrs["local"] = kwargs.pop("local")
+
+    # Binary-specific extra attributes.
+    if "output_licenses" in kwargs:
+        common_attrs["output_licenses"] = kwargs.pop("output_licenses")
+
+    rule_impl(
+        name = "_" + name,
+        args = args,
+        data = data,
+        deps = deps,
+        env = env,
+        srcs = srcs,
+        tags = ["manual"] + (tags if tags else []),
+        visibility = ["//visibility:private"],
+        **dicts.add(common_attrs, kwargs)
+    )
+
+    return transition_rule(
+        name = name,
+        args = args,
+        deps = deps,
+        env = env,
+        python_version = python_version,
+        srcs = srcs,
+        tags = tags,
+        target = ":_" + name,
+        tools = data,
+        visibility = visibility,
+        **common_attrs
+    )
+
+def py_binary(name, python_version, **kwargs):
+    return _py_rule(_py_binary, _transition_py_binary, name, python_version, **kwargs)
+
+def py_test(name, python_version, **kwargs):
+    return _py_rule(_py_test, _transition_py_test, name, python_version, **kwargs)
diff --git a/python/pip.bzl b/python/pip.bzl
index 02ea538..a3c9b69 100644
--- a/python/pip.bzl
+++ b/python/pip.bzl
@@ -16,6 +16,7 @@
 load("//python/pip_install:pip_repository.bzl", "pip_repository", _package_annotation = "package_annotation")
 load("//python/pip_install:repositories.bzl", "pip_install_dependencies")
 load("//python/pip_install:requirements.bzl", _compile_pip_requirements = "compile_pip_requirements")
+load(":versions.bzl", "MINOR_MAPPING")
 
 compile_pip_requirements = _compile_pip_requirements
 package_annotation = _package_annotation
@@ -163,3 +164,200 @@
         bzlmod = bzlmod,
         **kwargs
     )
+
+def _multi_pip_parse_impl(rctx):
+    rules_python = rctx.attr._rules_python_workspace.workspace_name
+    load_statements = []
+    install_deps_calls = []
+    process_requirements_calls = []
+    for python_version, pypi_repository in rctx.attr.pip_parses.items():
+        sanitized_python_version = python_version.replace(".", "_")
+        load_statement = """\
+load(
+    "@{pypi_repository}//:requirements.bzl",
+    _{sanitized_python_version}_install_deps = "install_deps",
+    _{sanitized_python_version}_all_requirements = "all_requirements",
+)""".format(
+            pypi_repository = pypi_repository,
+            sanitized_python_version = sanitized_python_version,
+        )
+        load_statements.append(load_statement)
+        process_requirements_call = """\
+_process_requirements(
+    pkg_labels = _{sanitized_python_version}_all_requirements,
+    python_version = "{python_version}",
+    repo_prefix = "{pypi_repository}_",
+)""".format(
+            pypi_repository = pypi_repository,
+            python_version = python_version,
+            sanitized_python_version = sanitized_python_version,
+        )
+        process_requirements_calls.append(process_requirements_call)
+        install_deps_call = """    _{sanitized_python_version}_install_deps(**whl_library_kwargs)""".format(
+            sanitized_python_version = sanitized_python_version,
+        )
+        install_deps_calls.append(install_deps_call)
+
+    requirements_bzl = """\
+# Generated by python/pip.bzl
+
+load("@{rules_python}//python:pip.bzl", "whl_library_alias")
+{load_statements}
+
+_wheel_names = []
+_version_map = dict()
+def _process_requirements(pkg_labels, python_version, repo_prefix):
+    for pkg_label in pkg_labels:
+        workspace_name = Label(pkg_label).workspace_name
+        wheel_name = workspace_name[len(repo_prefix):]
+        _wheel_names.append(wheel_name)
+        if not wheel_name in _version_map:
+            _version_map[wheel_name] = dict()
+        _version_map[wheel_name][python_version] = repo_prefix
+
+{process_requirements_calls}
+
+def _clean_name(name):
+    return name.replace("-", "_").replace(".", "_").lower()
+
+def requirement(name):
+    return "@{name}_" + _clean_name(name) + "//:pkg"
+
+def whl_requirement(name):
+    return "@{name}_" + _clean_name(name) + "//:whl"
+
+def data_requirement(name):
+    return "@{name}_" + _clean_name(name) + "//:data"
+
+def dist_info_requirement(name):
+    return "@{name}_" + _clean_name(name) + "//:dist_info"
+
+def entry_point(pkg, script = None):
+    fail("Not implemented yet")
+
+def install_deps(**whl_library_kwargs):
+{install_deps_calls}
+    for wheel_name in _wheel_names:
+        whl_library_alias(
+            name = "{name}_" + wheel_name,
+            wheel_name = wheel_name,
+            default_version = "{default_version}",
+            version_map = _version_map[wheel_name],
+        )
+""".format(
+        name = rctx.attr.name,
+        install_deps_calls = "\n".join(install_deps_calls),
+        load_statements = "\n".join(load_statements),
+        process_requirements_calls = "\n".join(process_requirements_calls),
+        rules_python = rules_python,
+        default_version = rctx.attr.default_version,
+    )
+    rctx.file("requirements.bzl", requirements_bzl)
+    rctx.file("BUILD.bazel", "exports_files(['requirements.bzl'])")
+
+_multi_pip_parse = repository_rule(
+    _multi_pip_parse_impl,
+    attrs = {
+        "default_version": attr.string(),
+        "pip_parses": attr.string_dict(),
+        "_rules_python_workspace": attr.label(default = Label("//:WORKSPACE")),
+    },
+)
+
+def _whl_library_alias_impl(rctx):
+    rules_python = rctx.attr._rules_python_workspace.workspace_name
+    default_repo_prefix = rctx.attr.version_map[rctx.attr.default_version]
+    version_map = rctx.attr.version_map.items()
+    build_content = ["# Generated by python/pip.bzl"]
+    for alias_name in ["pkg", "whl", "data", "dist_info"]:
+        build_content.append(_whl_library_render_alias_target(
+            alias_name = alias_name,
+            default_repo_prefix = default_repo_prefix,
+            rules_python = rules_python,
+            version_map = version_map,
+            wheel_name = rctx.attr.wheel_name,
+        ))
+    rctx.file("BUILD.bazel", "\n".join(build_content))
+
+def _whl_library_render_alias_target(
+        alias_name,
+        default_repo_prefix,
+        rules_python,
+        version_map,
+        wheel_name):
+    alias = ["""\
+alias(
+    name = "{alias_name}",
+    actual = select({{""".format(alias_name = alias_name)]
+    for [python_version, repo_prefix] in version_map:
+        alias.append("""\
+        "@{rules_python}//python/config_settings:is_python_{full_python_version}": "{actual}",""".format(
+            full_python_version = MINOR_MAPPING[python_version] if python_version in MINOR_MAPPING else python_version,
+            actual = "@{repo_prefix}{wheel_name}//:{alias_name}".format(
+                repo_prefix = repo_prefix,
+                wheel_name = wheel_name,
+                alias_name = alias_name,
+            ),
+            rules_python = rules_python,
+        ))
+    alias.append("""\
+        "//conditions:default": "{default_actual}",
+    }}),
+    visibility = ["//visibility:public"],
+)""".format(
+        default_actual = "@{repo_prefix}{wheel_name}//:{alias_name}".format(
+            repo_prefix = default_repo_prefix,
+            wheel_name = wheel_name,
+            alias_name = alias_name,
+        ),
+    ))
+    return "\n".join(alias)
+
+whl_library_alias = repository_rule(
+    _whl_library_alias_impl,
+    attrs = {
+        "default_version": attr.string(mandatory = True),
+        "version_map": attr.string_dict(mandatory = True),
+        "wheel_name": attr.string(mandatory = True),
+        "_rules_python_workspace": attr.label(default = Label("//:WORKSPACE")),
+    },
+)
+
+def multi_pip_parse(name, default_version, python_versions, python_interpreter_target, requirements_lock, **kwargs):
+    """NOT INTENDED FOR DIRECT USE!
+
+    This is intended to be used by the multi_pip_parse implementation in the template of the
+    multi_toolchain_aliases repository rule.
+
+    Args:
+        name: the name of the multi_pip_parse repository.
+        default_version: the default Python version.
+        python_versions: all Python toolchain versions currently registered.
+        python_interpreter_target: a dictionary which keys are Python versions and values are resolved host interpreters.
+        requirements_lock: a dictionary which keys are Python versions and values are locked requirements files.
+        **kwargs: extra arguments passed to all wrapped pip_parse.
+
+    Returns:
+        The internal implementation of multi_pip_parse repository rule.
+    """
+    pip_parses = {}
+    for python_version in python_versions:
+        if not python_version in python_interpreter_target:
+            fail("Missing python_interpreter_target for Python version %s in '%s'" % (python_version, name))
+        if not python_version in requirements_lock:
+            fail("Missing requirements_lock for Python version %s in '%s'" % (python_version, name))
+
+        pip_parse_name = name + "_" + python_version.replace(".", "_")
+        pip_parse(
+            name = pip_parse_name,
+            python_interpreter_target = python_interpreter_target[python_version],
+            requirements_lock = requirements_lock[python_version],
+            **kwargs
+        )
+        pip_parses[python_version] = pip_parse_name
+
+    return _multi_pip_parse(
+        name = name,
+        default_version = default_version,
+        pip_parses = pip_parses,
+    )
diff --git a/python/pip_install/requirements.bzl b/python/pip_install/requirements.bzl
index cca9213..7e248f6 100644
--- a/python/pip_install/requirements.bzl
+++ b/python/pip_install/requirements.bzl
@@ -1,17 +1,19 @@
 """Rules to verify and update pip-compile locked requirements.txt"""
 
-load("//python:defs.bzl", "py_binary", "py_test")
+load("//python:defs.bzl", _py_binary = "py_binary", _py_test = "py_test")
 load("//python/pip_install:repositories.bzl", "requirement")
 
 def compile_pip_requirements(
         name,
         extra_args = [],
-        visibility = ["//visibility:private"],
+        py_binary = _py_binary,
+        py_test = _py_test,
         requirements_in = None,
         requirements_txt = None,
-        requirements_linux = None,
         requirements_darwin = None,
+        requirements_linux = None,
         requirements_windows = None,
+        visibility = ["//visibility:private"],
         tags = None,
         **kwargs):
     """Generates targets for managing pip dependencies with pip-compile.
@@ -26,16 +28,18 @@
     - update with   `bazel run <name>.update`
 
     Args:
-        name: base name for generated targets, typically "requirements"
-        extra_args: passed to pip-compile
-        visibility: passed to both the _test and .update rules
-        requirements_in: file expressing desired dependencies
-        requirements_txt: result of "compiling" the requirements.in file
+        name: base name for generated targets, typically "requirements".
+        extra_args: passed to pip-compile.
+        py_binary: the py_binary rule to be used.
+        py_test: the py_test rule to be used.
+        requirements_in: file expressing desired dependencies.
+        requirements_txt: result of "compiling" the requirements.in file.
         requirements_linux: File of linux specific resolve output to check validate if requirement.in has changes.
         requirements_darwin: File of darwin specific resolve output to check validate if requirement.in has changes.
         requirements_windows: File of windows specific resolve output to check validate if requirement.in has changes.
-        tags: tagging attribute common to all build rules, passed to both the _test and .update rules
-        **kwargs: other bazel attributes passed to the "_test" rule
+        tags: tagging attribute common to all build rules, passed to both the _test and .update rules.
+        visibility: passed to both the _test and .update rules.
+        **kwargs: other bazel attributes passed to the "_test" rule.
     """
     requirements_in = name + ".in" if requirements_in == None else requirements_in
     requirements_txt = name + ".txt" if requirements_txt == None else requirements_txt
diff --git a/python/private/toolchains_repo.bzl b/python/private/toolchains_repo.bzl
index 282859a..93cf31a 100644
--- a/python/private/toolchains_repo.bzl
+++ b/python/private/toolchains_repo.bzl
@@ -32,8 +32,14 @@
 )
 
 def _toolchains_repo_impl(rctx):
+    rules_python_repository_name = rctx.attr._rules_python_workspace.workspace_name
+    python_version_constraint = "@{rules_python}//python/config_settings:is_python_{python_version}".format(
+        rules_python = rules_python_repository_name,
+        python_version = rctx.attr.python_version,
+    )
+
     build_content = """\
-# Generated by toolchains_repo.bzl
+# Generated by python/private/toolchains_repo.bzl
 #
 # These can be registered in the workspace file or passed to --extra_toolchains
 # flag. By default all these toolchains are registered by the
@@ -49,14 +55,17 @@
 toolchain(
     name = "{platform}_toolchain",
     target_compatible_with = {compatible_with},
+    target_settings = ["{python_version_constraint}"] if {set_python_version_constraint} else [],
     toolchain = "@{user_repository_name}_{platform}//:python_runtimes",
     toolchain_type = "@bazel_tools//tools/python:toolchain_type",
 )
 """.format(
-            platform = platform,
-            name = rctx.attr.name,
-            user_repository_name = rctx.attr.user_repository_name,
             compatible_with = meta.compatible_with,
+            name = rctx.attr.name,
+            platform = platform,
+            python_version_constraint = python_version_constraint,
+            set_python_version_constraint = rctx.attr.set_python_version_constraint,
+            user_repository_name = rctx.attr.user_repository_name,
         )
 
     rctx.file("BUILD.bazel", build_content)
@@ -66,26 +75,26 @@
     doc = "Creates a repository with toolchain definitions for all known platforms " +
           "which can be registered or selected.",
     attrs = {
+        "python_version": attr.string(doc = "The Python version."),
+        "set_python_version_constraint": attr.bool(doc = "if target_compatible_with for the toolchain should set the version constraint"),
         "user_repository_name": attr.string(doc = "what the user chose for the base name"),
+        "_rules_python_workspace": attr.label(default = Label("//:WORKSPACE")),
     },
 )
 
-def _resolved_interpreter_os_alias_impl(rctx):
-    (os_name, arch) = _host_os_arch(rctx)
+def _toolchain_aliases_impl(rctx):
+    (os_name, arch) = get_host_os_arch(rctx)
 
-    host_platform = None
-    for platform, meta in PLATFORMS.items():
-        if meta.os_name == os_name and meta.arch == arch:
-            host_platform = platform
-    if not host_platform:
-        fail("No platform declared for host OS {} on arch {}".format(os_name, arch))
+    host_platform = get_host_platform(os_name, arch)
 
     is_windows = (os_name == WINDOWS_NAME)
     python3_binary_path = "python.exe" if is_windows else "bin/python3"
 
+    rules_python_repository_name = rctx.attr._rules_python_workspace.workspace_name
+
     # Base BUILD file for this repository.
     build_contents = """\
-# Generated by python/repositories.bzl
+# Generated by python/private/toolchains_repo.bzl
 package(default_visibility = ["//visibility:public"])
 exports_files(["defs.bzl"])
 alias(name = "files",           actual = "@{py_repository}_{host_platform}//:files")
@@ -112,29 +121,135 @@
     # Expose a Starlark file so rules can know what host platform we used and where to find an interpreter
     # when using repository_ctx.path, which doesn't understand aliases.
     rctx.file("defs.bzl", content = """\
-# Generated by python/repositories.bzl
+# Generated by python/private/toolchains_repo.bzl
+
+load("@{rules_python}//python/config_settings:transition.bzl", _py_binary = "py_binary", _py_test = "py_test")
+load("@{rules_python}//python:pip.bzl", _compile_pip_requirements = "compile_pip_requirements")
+
 host_platform = "{host_platform}"
 interpreter = "@{py_repository}_{host_platform}//:{python3_binary_path}"
+
+def py_binary(name, **kwargs):
+    return _py_binary(
+        name = name,
+        python_version = "{python_version}",
+        **kwargs
+    )
+
+def py_test(name, **kwargs):
+    return _py_test(
+        name = name,
+        python_version = "{python_version}",
+        **kwargs
+    )
+
+def compile_pip_requirements(name, **kwargs):
+    return _compile_pip_requirements(
+        name = name,
+        py_binary = py_binary,
+        py_test = py_test,
+        **kwargs
+    )
+
 """.format(
-        py_repository = rctx.attr.user_repository_name,
         host_platform = host_platform,
+        py_repository = rctx.attr.user_repository_name,
+        python_version = rctx.attr.python_version,
         python3_binary_path = python3_binary_path,
+        rules_python = rules_python_repository_name,
     ))
 
-resolved_interpreter_os_alias = repository_rule(
-    _resolved_interpreter_os_alias_impl,
+toolchain_aliases = repository_rule(
+    _toolchain_aliases_impl,
     doc = """Creates a repository with a shorter name meant for the host platform, which contains
     a BUILD.bazel file declaring aliases to the host platform's targets.
     """,
     attrs = {
+        "python_version": attr.string(doc = "The Python version."),
         "user_repository_name": attr.string(
             mandatory = True,
             doc = "The base name for all created repositories, like 'python38'.",
         ),
+        "_rules_python_workspace": attr.label(default = Label("//:WORKSPACE")),
     },
 )
 
-def _host_os_arch(rctx):
+def _multi_toolchain_aliases_impl(rctx):
+    rules_python = rctx.attr._rules_python_workspace.workspace_name
+
+    for python_version, repository_name in rctx.attr.python_versions.items():
+        file = "{}/defs.bzl".format(python_version)
+        rctx.file(file, content = """\
+# Generated by python/private/toolchains_repo.bzl
+
+load(
+    "@{repository_name}//:defs.bzl",
+    _compile_pip_requirements = "compile_pip_requirements",
+    _host_platform = "host_platform",
+    _interpreter = "interpreter",
+    _py_binary = "py_binary",
+    _py_test = "py_test",
+)
+
+compile_pip_requirements = _compile_pip_requirements
+host_platform = _host_platform
+interpreter = _interpreter
+py_binary = _py_binary
+py_test = _py_test
+""".format(
+            repository_name = repository_name,
+        ))
+        rctx.file("{}/BUILD.bazel".format(python_version), "")
+
+    pip_bzl = """\
+# Generated by python/private/toolchains_repo.bzl
+
+load("@{rules_python}//python:pip.bzl", "pip_parse", _multi_pip_parse = "multi_pip_parse")
+
+def multi_pip_parse(name, requirements_lock, **kwargs):
+    return _multi_pip_parse(
+        name = name,
+        python_versions = {python_versions},
+        requirements_lock = requirements_lock,
+        **kwargs
+    )
+
+""".format(
+        python_versions = rctx.attr.python_versions.keys(),
+        rules_python = rules_python,
+    )
+    rctx.file("pip.bzl", content = pip_bzl)
+    rctx.file("BUILD.bazel", "")
+
+multi_toolchain_aliases = repository_rule(
+    _multi_toolchain_aliases_impl,
+    attrs = {
+        "python_versions": attr.string_dict(doc = "The Python versions."),
+        "_rules_python_workspace": attr.label(default = Label("//:WORKSPACE")),
+    },
+)
+
+def sanitize_platform_name(platform):
+    return platform.replace("-", "_")
+
+def get_host_platform(os_name, arch):
+    """Gets the host platform.
+
+    Args:
+        os_name: the host OS name.
+        arch: the host arch.
+    Returns:
+        The host platform.
+    """
+    host_platform = None
+    for platform, meta in PLATFORMS.items():
+        if meta.os_name == os_name and meta.arch == arch:
+            host_platform = platform
+    if not host_platform:
+        fail("No platform declared for host OS {} on arch {}".format(os_name, arch))
+    return host_platform
+
+def get_host_os_arch(rctx):
     """Infer the host OS name and arch from a repository context.
 
     Args:
diff --git a/python/repositories.bzl b/python/repositories.bzl
index 6965bcd..e0c9b06 100644
--- a/python/repositories.bzl
+++ b/python/repositories.bzl
@@ -17,7 +17,12 @@
 For historic reasons, pip_repositories() is defined in //python:pip.bzl.
 """
 
-load("//python/private:toolchains_repo.bzl", "resolved_interpreter_os_alias", "toolchains_repo")
+load(
+    "//python/private:toolchains_repo.bzl",
+    "multi_toolchain_aliases",
+    "toolchain_aliases",
+    "toolchains_repo",
+)
 load(
     ":versions.bzl",
     "DEFAULT_RELEASE_BASE_URL",
@@ -333,6 +338,7 @@
         distutils = None,
         distutils_content = None,
         register_toolchains = True,
+        set_python_version_constraint = False,
         tool_versions = TOOL_VERSIONS,
         **kwargs):
     """Convenience macro for users which does typical setup.
@@ -350,6 +356,7 @@
         distutils: see the distutils attribute in the python_repository repository rule.
         distutils_content: see the distutils_content attribute in the python_repository repository rule.
         register_toolchains: Whether or not to register the downloaded toolchains.
+        set_python_version_constraint: When set to true, target_compatible_with for the toolchains will include a version constraint.
         tool_versions: a dict containing a mapping of version with SHASUM and platform info. If not supplied, the defaults
         in python/versions.bzl will be used
         **kwargs: passed to each python_repositories call.
@@ -359,6 +366,8 @@
     if python_version in MINOR_MAPPING:
         python_version = MINOR_MAPPING[python_version]
 
+    toolchain_repo_name = "{name}_toolchains".format(name = name)
+
     for platform in PLATFORMS.keys():
         sha256 = tool_versions[python_version]["sha256"].get(platform, None)
         if not sha256:
@@ -382,17 +391,67 @@
             **kwargs
         )
         if register_toolchains:
-            native.register_toolchains("@{name}_toolchains//:{platform}_toolchain".format(
-                name = name,
+            native.register_toolchains("@{toolchain_repo_name}//:{platform}_toolchain".format(
+                toolchain_repo_name = toolchain_repo_name,
                 platform = platform,
             ))
 
-    resolved_interpreter_os_alias(
-        name = name,
+    toolchains_repo(
+        name = toolchain_repo_name,
+        python_version = python_version,
+        set_python_version_constraint = set_python_version_constraint,
         user_repository_name = name,
     )
 
-    toolchains_repo(
-        name = "{name}_toolchains".format(name = name),
+    toolchain_aliases(
+        name = name,
+        python_version = python_version,
         user_repository_name = name,
     )
+
+def python_register_multi_toolchains(
+        name,
+        python_versions,
+        default_version = None,
+        **kwargs):
+    """Convenience macro for registering multiple Python toolchains.
+
+    Args:
+        name: base name for each name in python_register_toolchains call.
+        python_versions: the Python version.
+        default_version: the default Python version. If not set, the first version in
+            python_versions is used.
+        **kwargs: passed to each python_register_toolchains call.
+    """
+    if len(python_versions) == 0:
+        fail("python_versions must not be empty")
+
+    if not default_version:
+        default_version = python_versions.pop(0)
+    for python_version in python_versions:
+        if python_version == default_version:
+            # We register the default version lastly so that it's not picked first when --platforms
+            # is set with a constraint during toolchain resolution. This is due to the fact that
+            # Bazel will match the unconstrained toolchain if we register it before the constrained
+            # ones.
+            continue
+        python_register_toolchains(
+            name = name + "_" + python_version.replace(".", "_"),
+            python_version = python_version,
+            set_python_version_constraint = True,
+            **kwargs
+        )
+    python_register_toolchains(
+        name = name + "_" + default_version.replace(".", "_"),
+        python_version = default_version,
+        set_python_version_constraint = False,
+        **kwargs
+    )
+
+    multi_toolchain_aliases(
+        name = name,
+        python_versions = {
+            python_version: name + "_" + python_version.replace(".", "_")
+            for python_version in (python_versions + [default_version])
+        },
+    )
diff --git a/tests/pip_repository_entry_points/WORKSPACE b/tests/pip_repository_entry_points/WORKSPACE
index dd80db4..f9f3543 100644
--- a/tests/pip_repository_entry_points/WORKSPACE
+++ b/tests/pip_repository_entry_points/WORKSPACE
@@ -1,5 +1,16 @@
 workspace(name = "pip_repository_annotations_example")
 
+load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
+
+http_archive(
+    name = "bazel_skylib",
+    sha256 = "c6966ec828da198c5d9adbaa94c05e3a1c7f21bd012a0b29ba8ddbccb2c93b0d",
+    urls = [
+        "https://github.com/bazelbuild/bazel-skylib/releases/download/1.1.1/bazel-skylib-1.1.1.tar.gz",
+        "https://mirror.bazel.build/github.com/bazelbuild/bazel-skylib/releases/download/1.1.1/bazel-skylib-1.1.1.tar.gz",
+    ],
+)
+
 local_repository(
     name = "rules_python",
     path = "../..",