feat(bzlmod): support bazel downloader when downloading wheels (#1827)

This introduces 3 attributes and the minimal code to be able to download
wheels
using the bazel downloader for the host platform. This is not yet adding
support for targeting a different platform but just allows us to get the
wheels
for the host platform instead of using `pip`.

All of this is achieved by calling the PyPI's SimpleAPI (Artifactory
should work
as well) and getting the all URLs for packages from there. Then we use
the `sha256`
information within the requirements files to match the entries found on
SimpleAPI
and then pass the `url`, `sha256` and the `filename` to `whl_library`,
which uses
`repository_ctx.download`.

If we cannot find any suitable artifact to use, we fallback to legacy
`pip` behaviour.

Testing notes:
* Most of the code has unit tests, but the `pypi_index.bzl` extension
could have more.
* You can see the lock file for what the output of all of this code
would be on your
  platform.
* Thanks to @dougthor42 for testing this using the credentials helper
against a private
  registry that needs authentication to be accessed.

Work towards #1357
diff --git a/.bazelrc b/.bazelrc
index 3f16396..61fd0e7 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -30,5 +30,4 @@
 # Some bzl files contain repos only available under bzlmod
 build:rtd --enable_bzlmod
 
-# Disabled due to https://github.com/bazelbuild/bazel/issues/20942
-build --lockfile_mode=off
+build --lockfile_mode=update
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 514aa9c..2f8e273 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,12 @@
 
 ### Changed
 
+* (bzlmod): The `MODULE.bazel.lock` `whl_library` rule attributes are now
+  sorted in the attributes section. We are also removing values that are not
+  default in order to reduce the size of the lock file.
+* (deps): Bumped bazel_features to 1.9.1 to detect optional support
+  non-blocking downloads.
+
 ### Fixed
 
 * (whl_library): Fix the experimental_target_platforms overriding for platform
@@ -48,12 +54,15 @@
 * (gazelle) Added a new `python_default_visibility` directive to control the
   _default_ visibility of generated targets. See the [docs][python_default_visibility]
   for details.
-
 * (wheel) Add support for `data_files` attributes in py_wheel rule
   ([#1777](https://github.com/bazelbuild/rules_python/issues/1777))
-
 * (py_wheel) `bzlmod` installations now provide a `twine` setup for the default
   Python toolchain in `rules_python` for version 3.11.
+* (bzlmod) New `experimental_index_url`, `experimental_extra_index_urls` and
+  `experimental_index_url_overrides` to `pip.parse` for using the bazel
+  downloader. If you see any issues, report in
+  [#1357](https://github.com/bazelbuild/rules_python/issues/1357). The URLs for
+  the whl and sdist files will be written to the lock file.
 
 [0.XX.0]: https://github.com/bazelbuild/rules_python/releases/tag/0.XX.0
 [python_default_visibility]: gazelle/README.md#directive-python_default_visibility
diff --git a/MODULE.bazel b/MODULE.bazel
index a165a94..fc32a3e 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -4,7 +4,7 @@
     compatibility_level = 1,
 )
 
-bazel_dep(name = "bazel_features", version = "1.1.1")
+bazel_dep(name = "bazel_features", version = "1.9.1")
 bazel_dep(name = "bazel_skylib", version = "1.3.0")
 bazel_dep(name = "platforms", version = "0.0.4")
 
@@ -58,6 +58,7 @@
 
 pip = use_extension("//python/extensions:pip.bzl", "pip")
 pip.parse(
+    experimental_index_url = "https://pypi.org/simple",
     hub_name = "rules_python_publish_deps",
     python_version = "3.11",
     requirements_darwin = "//tools/publish:requirements_darwin.txt",
@@ -69,7 +70,7 @@
 # ===== DEV ONLY DEPS AND SETUP BELOW HERE =====
 bazel_dep(name = "stardoc", version = "0.6.2", dev_dependency = True, repo_name = "io_bazel_stardoc")
 bazel_dep(name = "rules_bazel_integration_test", version = "0.20.0", dev_dependency = True)
-bazel_dep(name = "rules_testing", version = "0.5.0", dev_dependency = True)
+bazel_dep(name = "rules_testing", version = "0.6.0", dev_dependency = True)
 bazel_dep(name = "rules_cc", version = "0.0.9", dev_dependency = True)
 
 # Extra gazelle plugin deps so that WORKSPACE.bzlmod can continue including it for e2e tests.
@@ -83,6 +84,8 @@
     dev_dependency = True,
 )
 dev_pip.parse(
+    envsubst = ["PIP_INDEX_URL"],
+    experimental_index_url = "${PIP_INDEX_URL:-https://pypi.org/simple}",
     experimental_requirement_cycles = {
         "sphinx": [
             "sphinx",
@@ -98,6 +101,8 @@
     requirements_lock = "//docs/sphinx:requirements.txt",
 )
 dev_pip.parse(
+    envsubst = ["PIP_INDEX_URL"],
+    experimental_index_url = "${PIP_INDEX_URL:-https://pypi.org/simple}",
     hub_name = "pypiserver",
     python_version = "3.11",
     requirements_lock = "//examples/wheel:requirements_server.txt",
diff --git a/examples/bzlmod/MODULE.bazel b/examples/bzlmod/MODULE.bazel
index ceb0010..1134487 100644
--- a/examples/bzlmod/MODULE.bazel
+++ b/examples/bzlmod/MODULE.bazel
@@ -94,6 +94,20 @@
 # Alternatively, `python_interpreter_target` can be used to directly specify
 # the Python interpreter to run to resolve dependencies.
 pip.parse(
+    # We can use `envsubst in the above
+    envsubst = ["PIP_INDEX_URL"],
+    # Use the bazel downloader to query the simple API for downloading the sources
+    # Note, that we can use envsubst for this value.
+    experimental_index_url = "${PIP_INDEX_URL:-https://pypi.org/simple}",
+    # One can also select a particular index for a particular package.
+    # This ensures that the setup is resistant against confusion attacks.
+    # experimental_index_url_overrides = {
+    #    "my_package": "https://different-index-url.com",
+    # },
+    # Or you can specify extra indexes like with `pip`:
+    # experimental_extra_index_urls = [
+    #    "https://different-index-url.com",
+    # ],
     experimental_requirement_cycles = {
         "sphinx": [
             "sphinx",
diff --git a/internal_deps.bzl b/internal_deps.bzl
index 9931933..2ef0dc5 100644
--- a/internal_deps.bzl
+++ b/internal_deps.bzl
@@ -57,18 +57,9 @@
 
     http_archive(
         name = "rules_testing",
-        sha256 = "b84ed8546f1969d700ead4546de9f7637e0f058d835e47e865dcbb13c4210aed",
-        strip_prefix = "rules_testing-0.5.0",
-        url = "https://github.com/bazelbuild/rules_testing/releases/download/v0.5.0/rules_testing-v0.5.0.tar.gz",
-    )
-
-    http_archive(
-        name = "rules_license",
-        urls = [
-            "https://mirror.bazel.build/github.com/bazelbuild/rules_license/releases/download/0.0.7/rules_license-0.0.7.tar.gz",
-            "https://github.com/bazelbuild/rules_license/releases/download/0.0.7/rules_license-0.0.7.tar.gz",
-        ],
-        sha256 = "4531deccb913639c30e5c7512a054d5d875698daeb75d8cf90f284375fe7c360",
+        sha256 = "02c62574631876a4e3b02a1820cb51167bb9cdcdea2381b2fa9d9b8b11c407c4",
+        strip_prefix = "rules_testing-0.6.0",
+        url = "https://github.com/bazelbuild/rules_testing/releases/download/v0.6.0/rules_testing-v0.6.0.tar.gz",
     )
 
     http_archive(
@@ -221,3 +212,10 @@
         ],
         sha256 = "4531deccb913639c30e5c7512a054d5d875698daeb75d8cf90f284375fe7c360",
     )
+
+    http_archive(
+        name = "bazel_features",
+        sha256 = "d7787da289a7fb497352211ad200ec9f698822a9e0757a4976fd9f713ff372b3",
+        strip_prefix = "bazel_features-1.9.1",
+        url = "https://github.com/bazel-contrib/bazel_features/releases/download/v1.9.1/bazel_features-v1.9.1.tar.gz",
+    )
diff --git a/internal_setup.bzl b/internal_setup.bzl
index a80099f..bb62611 100644
--- a/internal_setup.bzl
+++ b/internal_setup.bzl
@@ -14,6 +14,7 @@
 
 """Setup for rules_python tests and tools."""
 
+load("@bazel_features//:deps.bzl", "bazel_features_deps")
 load("@bazel_skylib//:workspace.bzl", "bazel_skylib_workspace")
 load("@cgrindel_bazel_starlib//:deps.bzl", "bazel_starlib_dependencies")
 load("@com_google_protobuf//:protobuf_deps.bzl", "protobuf_deps")
@@ -42,3 +43,4 @@
     bazel_integration_test_rules_dependencies()
     bazel_starlib_dependencies()
     bazel_binaries(versions = SUPPORTED_BAZEL_VERSIONS)
+    bazel_features_deps()
diff --git a/python/pip_install/pip_repository.bzl b/python/pip_install/pip_repository.bzl
index 3d5f3c1..55d61fc 100644
--- a/python/pip_install/pip_repository.bzl
+++ b/python/pip_install/pip_repository.bzl
@@ -22,6 +22,7 @@
 load("//python/pip_install/private:generate_group_library_build_bazel.bzl", "generate_group_library_build_bazel")
 load("//python/pip_install/private:generate_whl_library_build_bazel.bzl", "generate_whl_library_build_bazel")
 load("//python/pip_install/private:srcs.bzl", "PIP_INSTALL_PY_SRCS")
+load("//python/private:auth.bzl", "AUTH_ATTRS", "get_auth")
 load("//python/private:envsubst.bzl", "envsubst")
 load("//python/private:normalize_name.bzl", "normalize_name")
 load("//python/private:parse_whl_name.bzl", "parse_whl_name")
@@ -187,7 +188,7 @@
 
     return use_isolated
 
-def _parse_optional_attrs(rctx, args):
+def _parse_optional_attrs(rctx, args, extra_pip_args = None):
     """Helper function to parse common attributes of pip_repository and whl_library repository rules.
 
     This function also serializes the structured arguments as JSON
@@ -196,6 +197,7 @@
     Args:
         rctx: Handle to the rule repository context.
         args: A list of parsed args for the rule.
+        extra_pip_args: The pip args to pass.
     Returns: Augmented args list.
     """
 
@@ -212,7 +214,7 @@
 
     # Check for None so we use empty default types from our attrs.
     # Some args want to be list, and some want to be dict.
-    if rctx.attr.extra_pip_args != None:
+    if extra_pip_args != None:
         args += [
             "--extra_pip_args",
             json.encode(struct(arg = [
@@ -759,24 +761,64 @@
         "--requirement",
         rctx.attr.requirement,
     ]
-
-    args = _parse_optional_attrs(rctx, args)
+    extra_pip_args = []
+    extra_pip_args.extend(rctx.attr.extra_pip_args)
 
     # Manually construct the PYTHONPATH since we cannot use the toolchain here
     environment = _create_repository_execution_environment(rctx, python_interpreter)
 
-    repo_utils.execute_checked(
-        rctx,
-        op = "whl_library.ResolveRequirement({}, {})".format(rctx.attr.name, rctx.attr.requirement),
-        arguments = args,
-        environment = environment,
-        quiet = rctx.attr.quiet,
-        timeout = rctx.attr.timeout,
-    )
+    whl_path = None
+    if rctx.attr.whl_file:
+        whl_path = rctx.path(rctx.attr.whl_file)
 
-    whl_path = rctx.path(json.decode(rctx.read("whl_file.json"))["whl_file"])
-    if not rctx.delete("whl_file.json"):
-        fail("failed to delete the whl_file.json file")
+        # Simulate the behaviour where the whl is present in the current directory.
+        rctx.symlink(whl_path, whl_path.basename)
+        whl_path = rctx.path(whl_path.basename)
+    elif rctx.attr.urls:
+        filename = rctx.attr.filename
+        urls = rctx.attr.urls
+        if not filename:
+            _, _, filename = urls[0].rpartition("/")
+
+        if not (filename.endswith(".whl") or filename.endswith("tar.gz") or filename.endswith(".zip")):
+            if rctx.attr.filename:
+                msg = "got '{}'".format(filename)
+            else:
+                msg = "detected '{}' from url:\n{}".format(filename, urls[0])
+            fail("Only '.whl', '.tar.gz' or '.zip' files are supported, {}".format(msg))
+
+        result = rctx.download(
+            url = urls,
+            output = filename,
+            sha256 = rctx.attr.sha256,
+            auth = get_auth(rctx, urls),
+        )
+
+        if not result.success:
+            fail("could not download the '{}' from {}:\n{}".format(filename, urls, result))
+
+        if filename.endswith(".whl"):
+            whl_path = rctx.path(rctx.attr.filename)
+        else:
+            # It is an sdist and we need to tell PyPI to use a file in this directory
+            # and not use any indexes.
+            extra_pip_args.extend(["--no-index", "--find-links", "."])
+
+    args = _parse_optional_attrs(rctx, args, extra_pip_args)
+
+    if not whl_path:
+        repo_utils.execute_checked(
+            rctx,
+            op = "whl_library.ResolveRequirement({}, {})".format(rctx.attr.name, rctx.attr.requirement),
+            arguments = args,
+            environment = environment,
+            quiet = rctx.attr.quiet,
+            timeout = rctx.attr.timeout,
+        )
+
+        whl_path = rctx.path(json.decode(rctx.read("whl_file.json"))["whl_file"])
+        if not rctx.delete("whl_file.json"):
+            fail("failed to delete the whl_file.json file")
 
     if rctx.attr.whl_patches:
         patches = {}
@@ -890,7 +932,8 @@
     )
     return contents
 
-whl_library_attrs = {
+# NOTE @aignas 2024-03-21: The usage of dict({}, **common) ensures that all args to `dict` are unique
+whl_library_attrs = dict({
     "annotation": attr.label(
         doc = (
             "Optional json encoded file containing annotation to apply to the extracted wheel. " +
@@ -898,6 +941,9 @@
         ),
         allow_files = True,
     ),
+    "filename": attr.string(
+        doc = "Download the whl file to this filename. Only used when the `urls` is passed. If not specified, will be auto-detected from the `urls`.",
+    ),
     "group_deps": attr.string_list(
         doc = "List of dependencies to skip in order to break the cycles within a dependency group.",
         default = [],
@@ -911,7 +957,18 @@
     ),
     "requirement": attr.string(
         mandatory = True,
-        doc = "Python requirement string describing the package to make available",
+        doc = "Python requirement string describing the package to make available, if 'urls' or 'whl_file' is given, then this only needs to include foo[any_extras] as a bare minimum.",
+    ),
+    "sha256": attr.string(
+        doc = "The sha256 of the downloaded whl. Only used when the `urls` is passed.",
+    ),
+    "urls": attr.string_list(
+        doc = """\
+The list of urls of the whl to be downloaded using bazel downloader. Using this
+attr makes `extra_pip_args` and `download_only` ignored.""",
+    ),
+    "whl_file": attr.label(
+        doc = "The whl file that should be used instead of downloading or building the whl.",
     ),
     "whl_patches": attr.label_keyed_string_dict(
         doc = """a label-keyed-string dict that has
@@ -933,9 +990,8 @@
             for repo in all_requirements
         ],
     ),
-}
-
-whl_library_attrs.update(**common_attrs)
+}, **common_attrs)
+whl_library_attrs.update(AUTH_ATTRS)
 
 whl_library = repository_rule(
     attrs = whl_library_attrs,
diff --git a/python/private/BUILD.bazel b/python/private/BUILD.bazel
index d3d6e76..b105c47 100644
--- a/python/private/BUILD.bazel
+++ b/python/private/BUILD.bazel
@@ -120,6 +120,18 @@
 )
 
 bzl_library(
+    name = "pypi_index_bzl",
+    srcs = ["pypi_index.bzl"],
+    deps = [
+        ":auth_bzl",
+        ":normalize_name_bzl",
+        ":text_util_bzl",
+        "//python/pip_install:requirements_parser_bzl",
+        "//python/private/bzlmod:bazel_features_bzl",
+    ],
+)
+
+bzl_library(
     name = "py_cc_toolchain_bzl",
     srcs = [
         "py_cc_toolchain_macro.bzl",
@@ -260,6 +272,9 @@
     name = "whl_target_platforms_bzl",
     srcs = ["whl_target_platforms.bzl"],
     visibility = ["//:__subpackages__"],
+    deps = [
+        "parse_whl_name_bzl",
+    ],
 )
 
 bzl_library(
diff --git a/python/private/auth.bzl b/python/private/auth.bzl
index 39ada37..6b61267 100644
--- a/python/private/auth.bzl
+++ b/python/private/auth.bzl
@@ -17,26 +17,90 @@
 The implementation below is copied directly from Bazel's implementation of `http_archive`.
 Accordingly, the return value of this function should be used identically as the `auth` parameter of `http_archive`.
 Reference: https://github.com/bazelbuild/bazel/blob/6.3.2/tools/build_defs/repo/http.bzl#L109
+
+The helpers were further modified to support module_ctx.
 """
 
-# TODO @aignas 2023-12-18: use the following instead when available.
-# load("@bazel_tools//tools/build_defs/repo:utils.bzl", "get_auth")
 load("@bazel_tools//tools/build_defs/repo:utils.bzl", "read_netrc", "read_user_netrc", "use_netrc")
 
-def get_auth(rctx, urls):
+# Copied from https://sourcegraph.com/github.com/bazelbuild/bazel@26c6add3f9809611ad3795bce1e5c0fb37902902/-/blob/tools/build_defs/repo/http.bzl
+_AUTH_PATTERN_DOC = """An optional dict mapping host names to custom authorization patterns.
+
+If a URL's host name is present in this dict the value will be used as a pattern when
+generating the authorization header for the http request. This enables the use of custom
+authorization schemes used in a lot of common cloud storage providers.
+
+The pattern currently supports 2 tokens: <code>&lt;login&gt;</code> and
+<code>&lt;password&gt;</code>, which are replaced with their equivalent value
+in the netrc file for the same host name. After formatting, the result is set
+as the value for the <code>Authorization</code> field of the HTTP request.
+
+Example attribute and netrc for a http download to an oauth2 enabled API using a bearer token:
+
+<pre>
+auth_patterns = {
+    "storage.cloudprovider.com": "Bearer &lt;password&gt;"
+}
+</pre>
+
+netrc:
+
+<pre>
+machine storage.cloudprovider.com
+        password RANDOM-TOKEN
+</pre>
+
+The final HTTP request would have the following header:
+
+<pre>
+Authorization: Bearer RANDOM-TOKEN
+</pre>
+"""
+
+# AUTH_ATTRS are used within whl_library and pip bzlmod extension.
+AUTH_ATTRS = {
+    "auth_patterns": attr.string_dict(
+        doc = _AUTH_PATTERN_DOC,
+    ),
+    "netrc": attr.string(
+        doc = "Location of the .netrc file to use for authentication",
+    ),
+}
+
+def get_auth(ctx, urls, ctx_attr = None):
     """Utility for retrieving netrc-based authentication parameters for repository download rules used in python_repository.
 
     Args:
-        rctx (repository_ctx): The repository rule's context object.
+        ctx(repository_ctx or module_ctx): The extension module_ctx or
+            repository rule's repository_ctx object.
         urls: A list of URLs from which assets will be downloaded.
+        ctx_attr(struct): The attributes to get the netrc from. When ctx is
+            repository_ctx, then we will attempt to use repository_ctx.attr
+            if this is not specified, otherwise we will use the specified
+            field. The module_ctx attributes are located in the tag classes
+            so it cannot be retrieved from the context.
 
     Returns:
         dict: A map of authentication parameters by URL.
     """
-    if rctx.attr.netrc:
-        netrc = read_netrc(rctx, rctx.attr.netrc)
-    elif "NETRC" in rctx.os.environ:
-        netrc = read_netrc(rctx, rctx.os.environ["NETRC"])
+
+    # module_ctx does not have attributes, as they are stored in tag classes. Whilst
+    # the correct behaviour should be to pass the `attr` to the
+    ctx_attr = ctx_attr or getattr(ctx, "attr", None)
+    ctx_attr = struct(
+        netrc = getattr(ctx_attr, "netrc", None),
+        auth_patterns = getattr(ctx_attr, "auth_patterns", ""),
+    )
+
+    if ctx_attr.netrc:
+        netrc = read_netrc(ctx, ctx_attr.netrc)
+    elif "NETRC" in ctx.os.environ:
+        # This can be used on newer bazel versions
+        if hasattr(ctx, "getenv"):
+            netrc = read_netrc(ctx, ctx.getenv("NETRC"))
+        else:
+            netrc = read_netrc(ctx, ctx.os.environ["NETRC"])
     else:
-        netrc = read_user_netrc(rctx)
-    return use_netrc(netrc, urls, rctx.attr.auth_patterns)
+        netrc = read_user_netrc(ctx)
+
+    return use_netrc(netrc, urls, ctx_attr.auth_patterns)
diff --git a/python/private/bzlmod/BUILD.bazel b/python/private/bzlmod/BUILD.bazel
index b636cca..0ec95e4 100644
--- a/python/private/bzlmod/BUILD.bazel
+++ b/python/private/bzlmod/BUILD.bazel
@@ -32,6 +32,7 @@
         ":pip_repository_bzl",
         "//python/pip_install:pip_repository_bzl",
         "//python/pip_install:requirements_parser_bzl",
+        "//python/private:pypi_index_bzl",
         "//python/private:full_version_bzl",
         "//python/private:normalize_name_bzl",
         "//python/private:parse_whl_name_bzl",
@@ -44,7 +45,7 @@
 
 bzl_library(
     name = "bazel_features_bzl",
-    srcs = ["@bazel_features//:bzl_files"] if BZLMOD_ENABLED else [],
+    srcs = ["@bazel_features//:bzl_files"],
 )
 
 bzl_library(
diff --git a/python/private/bzlmod/pip.bzl b/python/private/bzlmod/pip.bzl
index 57167bb..2ba275b 100644
--- a/python/private/bzlmod/pip.bzl
+++ b/python/private/bzlmod/pip.bzl
@@ -25,10 +25,13 @@
     "whl_library",
 )
 load("//python/pip_install:requirements_parser.bzl", parse_requirements = "parse")
+load("//python/private:auth.bzl", "AUTH_ATTRS")
 load("//python/private:normalize_name.bzl", "normalize_name")
 load("//python/private:parse_whl_name.bzl", "parse_whl_name")
+load("//python/private:pypi_index.bzl", "get_simpleapi_sources", "simpleapi_download")
 load("//python/private:render_pkg_aliases.bzl", "whl_alias")
 load("//python/private:version_label.bzl", "version_label")
+load("//python/private:whl_target_platforms.bzl", "select_whl")
 load(":pip_repository.bzl", "pip_repository")
 
 def _parse_version(version):
@@ -98,7 +101,7 @@
             whl_mods = whl_mods,
         )
 
-def _create_whl_repos(module_ctx, pip_attr, whl_map, whl_overrides):
+def _create_whl_repos(module_ctx, pip_attr, whl_map, whl_overrides, simpleapi_cache):
     python_interpreter_target = pip_attr.python_interpreter_target
 
     # if we do not have the python_interpreter set in the attributes
@@ -126,11 +129,12 @@
         hub_name,
         version_label(pip_attr.python_version),
     )
-    requrements_lock = locked_requirements_label(module_ctx, pip_attr)
+
+    requirements_lock = locked_requirements_label(module_ctx, pip_attr)
 
     # Parse the requirements file directly in starlark to get the information
     # needed for the whl_libary declarations below.
-    requirements_lock_content = module_ctx.read(requrements_lock)
+    requirements_lock_content = module_ctx.read(requirements_lock)
     parse_result = parse_requirements(requirements_lock_content)
 
     # Replicate a surprising behavior that WORKSPACE builds allowed:
@@ -177,6 +181,28 @@
         whl_group_mapping = {}
         requirement_cycles = {}
 
+    index_urls = {}
+    if pip_attr.experimental_index_url:
+        if pip_attr.download_only:
+            fail("Currently unsupported to use `download_only` and `experimental_index_url`")
+
+        index_urls = simpleapi_download(
+            module_ctx,
+            attr = struct(
+                index_url = pip_attr.experimental_index_url,
+                extra_index_urls = pip_attr.experimental_extra_index_urls or [],
+                index_url_overrides = pip_attr.experimental_index_url_overrides or {},
+                sources = [requirements_lock_content],
+                envsubst = pip_attr.envsubst,
+                # Auth related info
+                netrc = pip_attr.netrc,
+                auth_patterns = pip_attr.auth_patterns,
+            ),
+            cache = simpleapi_cache,
+        )
+
+    major_minor = _major_minor_version(pip_attr.python_version)
+
     # Create a new wheel library for each of the different whls
     for whl_name, requirement_line in requirements:
         # We are not using the "sanitized name" because the user
@@ -188,34 +214,98 @@
         group_name = whl_group_mapping.get(whl_name)
         group_deps = requirement_cycles.get(group_name, [])
 
+        # Construct args separately so that the lock file can be smaller and does not include unused
+        # attrs.
         repo_name = "{}_{}".format(pip_name, whl_name)
-        whl_library(
-            name = repo_name,
-            requirement = requirement_line,
+        whl_library_args = dict(
             repo = pip_name,
             repo_prefix = pip_name + "_",
+            requirement = requirement_line,
+        )
+        maybe_args = dict(
+            # The following values are safe to omit if they have false like values
             annotation = annotation,
+            download_only = pip_attr.download_only,
+            enable_implicit_namespace_pkgs = pip_attr.enable_implicit_namespace_pkgs,
+            environment = pip_attr.environment,
+            envsubst = pip_attr.envsubst,
+            experimental_target_platforms = pip_attr.experimental_target_platforms,
+            extra_pip_args = extra_pip_args,
+            group_deps = group_deps,
+            group_name = group_name,
+            pip_data_exclude = pip_attr.pip_data_exclude,
+            python_interpreter = pip_attr.python_interpreter,
+            python_interpreter_target = python_interpreter_target,
             whl_patches = {
                 p: json.encode(args)
                 for p, args in whl_overrides.get(whl_name, {}).items()
             },
-            experimental_target_platforms = pip_attr.experimental_target_platforms,
-            python_interpreter = pip_attr.python_interpreter,
-            python_interpreter_target = python_interpreter_target,
-            quiet = pip_attr.quiet,
-            timeout = pip_attr.timeout,
-            isolated = use_isolated(module_ctx, pip_attr),
-            extra_pip_args = extra_pip_args,
-            download_only = pip_attr.download_only,
-            pip_data_exclude = pip_attr.pip_data_exclude,
-            enable_implicit_namespace_pkgs = pip_attr.enable_implicit_namespace_pkgs,
-            environment = pip_attr.environment,
-            envsubst = pip_attr.envsubst,
-            group_name = group_name,
-            group_deps = group_deps,
         )
+        whl_library_args.update({k: v for k, v in maybe_args.items() if v})
+        maybe_args_with_default = dict(
+            # The following values have defaults next to them
+            isolated = (use_isolated(module_ctx, pip_attr), True),
+            quiet = (pip_attr.quiet, True),
+            timeout = (pip_attr.timeout, 600),
+        )
+        whl_library_args.update({k: v for k, (v, default) in maybe_args_with_default.items() if v == default})
 
-        major_minor = _major_minor_version(pip_attr.python_version)
+        if index_urls:
+            srcs = get_simpleapi_sources(requirement_line)
+
+            whls = []
+            sdist = None
+            for sha256 in srcs.shas:
+                # For now if the artifact is marked as yanked we just ignore it.
+                #
+                # See https://packaging.python.org/en/latest/specifications/simple-repository-api/#adding-yank-support-to-the-simple-api
+
+                maybe_whl = index_urls[whl_name].whls.get(sha256)
+                if maybe_whl and not maybe_whl.yanked:
+                    whls.append(maybe_whl)
+                    continue
+
+                maybe_sdist = index_urls[whl_name].sdists.get(sha256)
+                if maybe_sdist and not maybe_sdist.yanked:
+                    sdist = maybe_sdist
+                    continue
+
+                print("WARNING: Could not find a whl or an sdist with sha256={}".format(sha256))  # buildifier: disable=print
+
+            distribution = select_whl(
+                whls = whls,
+                want_abis = [
+                    "none",
+                    "abi3",
+                    "cp" + major_minor.replace(".", ""),
+                    # Older python versions have wheels for the `*m` ABI.
+                    "cp" + major_minor.replace(".", "") + "m",
+                ],
+                want_os = module_ctx.os.name,
+                want_cpu = module_ctx.os.arch,
+            ) or sdist
+
+            if distribution:
+                whl_library_args["requirement"] = srcs.requirement
+                whl_library_args["urls"] = [distribution.url]
+                whl_library_args["sha256"] = distribution.sha256
+                whl_library_args["filename"] = distribution.filename
+                if pip_attr.netrc:
+                    whl_library_args["netrc"] = pip_attr.netrc
+                if pip_attr.auth_patterns:
+                    whl_library_args["auth_patterns"] = pip_attr.auth_patterns
+
+                # pip is not used to download wheels and the python `whl_library` helpers are only extracting things
+                whl_library_args.pop("extra_pip_args", None)
+
+                # This is no-op because pip is not used to download the wheel.
+                whl_library_args.pop("download_only", None)
+            else:
+                print("WARNING: falling back to pip for installing the right file for {}".format(requirement_line))  # buildifier: disable=print
+
+        # We sort so that the lock-file remains the same no matter the order of how the
+        # args are manipulated in the code going before.
+        whl_library(name = repo_name, **dict(sorted(whl_library_args.items())))
         whl_map[hub_name].setdefault(whl_name, []).append(
             whl_alias(
                 repo = repo_name,
@@ -332,6 +422,8 @@
     # Where hub, whl, and pip are the repo names
     hub_whl_map = {}
 
+    simpleapi_cache = {}
+
     for mod in module_ctx.modules:
         for pip_attr in mod.tags.parse:
             hub_name = pip_attr.hub_name
@@ -367,7 +459,7 @@
             else:
                 pip_hub_map[pip_attr.hub_name].python_versions.append(pip_attr.python_version)
 
-            _create_whl_repos(module_ctx, pip_attr, hub_whl_map, whl_overrides)
+            _create_whl_repos(module_ctx, pip_attr, hub_whl_map, whl_overrides, simpleapi_cache)
 
     for hub_name, whl_map in hub_whl_map.items():
         pip_repository(
@@ -382,6 +474,49 @@
 
 def _pip_parse_ext_attrs():
     attrs = dict({
+        "experimental_extra_index_urls": attr.string_list(
+            doc = """\
+The extra index URLs to use for downloading wheels using bazel downloader.
+Each value is going to be subject to `envsubst` substitutions if necessary.
+
+The indexes must support Simple API as described here:
+https://packaging.python.org/en/latest/specifications/simple-repository-api/
+
+This is equivalent to `--extra-index-urls` `pip` option.
+""",
+            default = [],
+        ),
+        "experimental_index_url": attr.string(
+            doc = """\
+The index URL to use for downloading wheels using bazel downloader. This value is going
+to be subject to `envsubst` substitutions if necessary.
+
+The indexes must support Simple API as described here:
+https://packaging.python.org/en/latest/specifications/simple-repository-api/
+
+In the future this could be defaulted to `https://pypi.org` when this feature becomes
+stable.
+
+This is equivalent to `--index-url` `pip` option.
+""",
+        ),
+        "experimental_index_url_overrides": attr.string_dict(
+            doc = """\
+The index URL overrides for each package to use for downloading wheels using
+bazel downloader. This value is going to be subject to `envsubst` substitutions
+if necessary.
+
+The key is the package name (will be normalized before usage) and the value is the
+index URL.
+
+This design pattern has been chosen in order to be fully deterministic about which
+packages come from which source. We want to avoid issues similar to what happened in
+https://pytorch.org/blog/compromised-nightly-dependency/.
+
+The indexes must support Simple API as described here:
+https://packaging.python.org/en/latest/specifications/simple-repository-api/
+""",
+        ),
         "hub_name": attr.string(
             mandatory = True,
             doc = """
@@ -423,6 +558,7 @@
 """,
         ),
     }, **pip_repository_attrs)
+    attrs.update(AUTH_ATTRS)
 
     # Like the pip_repository rule, we end up setting this manually so
     # don't allow users to override it.
diff --git a/python/private/pypi_index.bzl b/python/private/pypi_index.bzl
new file mode 100644
index 0000000..e716831
--- /dev/null
+++ b/python/private/pypi_index.bzl
@@ -0,0 +1,358 @@
+# Copyright 2024 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+A file that houses private functions used in the `bzlmod` extension with the same name.
+"""
+
+load("@bazel_features//:features.bzl", "bazel_features")
+load("@bazel_skylib//lib:sets.bzl", "sets")
+load("//python/pip_install:requirements_parser.bzl", parse_requirements = "parse")
+load(":auth.bzl", "get_auth")
+load(":envsubst.bzl", "envsubst")
+load(":normalize_name.bzl", "normalize_name")
+
+def simpleapi_download(ctx, *, attr, cache):
+    """Download Simple API HTML.
+
+    Args:
+        ctx: The module_ctx or repository_ctx.
+        attr: Contains the parameters for the download. They are grouped into a
+          struct for better clarity. It must have attributes:
+           * index_url: str, the index.
+           * index_url_overrides: dict[str, str], the index overrides for
+             separate packages.
+           * extra_index_urls: Extra index URLs that will be looked up after
+             the main is looked up.
+           * sources: list[str], the sources to download things for. Each value is
+             the contents of requirements files.
+           * envsubst: list[str], the envsubst vars for performing substitution in index url.
+           * netrc: The netrc parameter for ctx.download, see http_file for docs.
+           * auth_patterns: The auth_patterns parameter for ctx.download, see
+               http_file for docs.
+        cache: A dictionary that can be used as a cache between calls during a
+            single evaluation of the extension. We use a dictionary as a cache
+            so that we can reuse calls to the simple API when evaluating the
+            extension. Using the canonical_id parameter of the module_ctx would
+            deposit the simple API responses to the bazel cache and that is
+            undesirable because additions to the PyPI index would not be
+            reflected when re-evaluating the extension unless we do
+            `bazel clean --expunge`.
+
+    Returns:
+        dict of pkg name to the parsed HTML contents - a list of structs.
+    """
+    index_url_overrides = {
+        normalize_name(p): i
+        for p, i in (attr.index_url_overrides or {}).items()
+    }
+
+    download_kwargs = {}
+    if bazel_features.external_deps.download_has_block_param:
+        download_kwargs["block"] = False
+
+    # Download in parallel if possible. This will download (potentially
+    # duplicate) data for multiple packages if there is more than one index
+    # available, but that is the price of convenience. However, that price
+    # should be mostly negligible because the simple API calls are very cheap
+    # and the user should not notice any extra overhead.
+    #
+    # If we are in synchronous mode, then we will use the first result that we
+    # find.
+    #
+    # NOTE @aignas 2024-03-31: we are not merging results from multiple indexes
+    # to replicate how `pip` would handle this case.
+    async_downloads = {}
+    contents = {}
+    index_urls = [attr.index_url] + attr.extra_index_urls
+    for pkg in get_packages_from_requirements(attr.sources):
+        pkg_normalized = normalize_name(pkg)
+
+        success = False
+        for index_url in index_urls:
+            result = read_simple_api(
+                ctx = ctx,
+                url = "{}/{}/".format(
+                    index_url_overrides.get(pkg_normalized, index_url).rstrip("/"),
+                    pkg,
+                ),
+                attr = attr,
+                cache = cache,
+                **download_kwargs
+            )
+            if hasattr(result, "wait"):
+                # We will process it in a separate loop:
+                async_downloads.setdefault(pkg_normalized, []).append(
+                    struct(
+                        pkg_normalized = pkg_normalized,
+                        wait = result.wait,
+                    ),
+                )
+                continue
+
+            if result.success:
+                contents[pkg_normalized] = result.output
+                success = True
+                break
+
+        if not async_downloads and not success:
+            fail("Failed to download metadata from urls: {}".format(
+                ", ".join(index_urls),
+            ))
+
+    if not async_downloads:
+        return contents
+
+    # If we use `block` == False, then we need to have a second loop that is
+    # collecting all of the results as they were being downloaded in parallel.
+    for pkg, downloads in async_downloads.items():
+        success = False
+        for download in downloads:
+            result = download.wait()
+
+            if result.success and download.pkg_normalized not in contents:
+                contents[download.pkg_normalized] = result.output
+                success = True
+
+        if not success:
+            fail("Failed to download metadata from urls: {}".format(
+                ", ".join(index_urls),
+            ))
+
+    return contents
+
+def read_simple_api(ctx, url, attr, cache, **download_kwargs):
+    """Read SimpleAPI.
+
+    Args:
+        ctx: The module_ctx or repository_ctx.
+        url: str, the url parameter that can be passed to ctx.download.
+        attr: The attribute that contains necessary info for downloading. The
+          following attributes must be present:
+           * envsubst: The envsubst values for performing substitutions in the URL.
+           * netrc: The netrc parameter for ctx.download, see http_file for docs.
+           * auth_patterns: The auth_patterns parameter for ctx.download, see
+               http_file for docs.
+        cache: A dict for storing the results.
+        **download_kwargs: Any extra params to ctx.download.
+            Note that output and auth will be passed for you.
+
+    Returns:
+        A similar object to what `download` would return except that in result.out
+        will be the parsed simple api contents.
+    """
+    # NOTE @aignas 2024-03-31: some of the simple APIs use relative URLs for
+    # the whl location and we cannot handle multiple URLs at once by passing
+    # them to ctx.download if we want to correctly handle the relative URLs.
+    # TODO: Add a test that env subbed index urls do not leak into the lock file.
+
+    real_url = envsubst(
+        url,
+        attr.envsubst,
+        ctx.getenv if hasattr(ctx, "getenv") else ctx.os.environ.get,
+    )
+
+    cache_key = real_url
+    if cache_key in cache:
+        return struct(success = True, output = cache[cache_key])
+
+    output_str = envsubst(
+        url,
+        attr.envsubst,
+        # Use env names in the subst values - this will be unique over
+        # the lifetime of the execution of this function and we also use
+        # `~` as the separator to ensure that we don't get clashes.
+        {e: "~{}~".format(e) for e in attr.envsubst}.get,
+    )
+
+    # Transform the URL into a valid filename
+    for char in [".", ":", "/", "\\", "-"]:
+        output_str = output_str.replace(char, "_")
+
+    output = ctx.path(output_str.strip("_").lower() + ".html")
+
+    # NOTE: this may have block = True or block = False in the download_kwargs
+    download = ctx.download(
+        url = [real_url],
+        output = output,
+        auth = get_auth(ctx, [real_url], ctx_attr = attr),
+        allow_fail = True,
+        **download_kwargs
+    )
+
+    if download_kwargs.get("block") == False:
+        # Simulate the same API as ctx.download has
+        return struct(
+            wait = lambda: _read_index_result(ctx, download.wait(), output, url, cache, cache_key),
+        )
+
+    return _read_index_result(ctx, download, output, url, cache, cache_key)
+
+def _read_index_result(ctx, result, output, url, cache, cache_key):
+    if not result.success:
+        return struct(success = False)
+
+    content = ctx.read(output)
+
+    output = parse_simple_api_html(url = url, content = content)
+    if output:
+        cache.setdefault(cache_key, output)
+        return struct(success = True, output = output, cache_key = cache_key)
+    else:
+        return struct(success = False)
+
+def get_packages_from_requirements(requirements_files):
+    """Get Simple API sources from a list of requirements files and merge them.
+
+    Args:
+        requirements_files(list[str]): A list of requirements files contents.
+
+    Returns:
+        A list.
+    """
+    want_packages = sets.make()
+    for contents in requirements_files:
+        parse_result = parse_requirements(contents)
+        for distribution, _ in parse_result.requirements:
+            # NOTE: we'll be querying the PyPI servers multiple times if the
+            # requirements contains non-normalized names, but this is what user
+            # is specifying to us.
+            sets.insert(want_packages, distribution)
+
+    return sets.to_list(want_packages)
+
+def get_simpleapi_sources(line):
+    """Get PyPI sources from a requirements.txt line.
+
+    We interpret the spec described in
+    https://pip.pypa.io/en/stable/reference/requirement-specifiers/#requirement-specifiers
+
+    Args:
+        line(str): The requirements.txt entry.
+
+    Returns:
+        A struct with shas attribute containing a list of shas to download from pypi_index.
+    """
+    head, _, maybe_hashes = line.partition(";")
+    _, _, version = head.partition("==")
+    version = version.partition(" ")[0].strip()
+
+    if "@" in head:
+        shas = []
+    else:
+        maybe_hashes = maybe_hashes or line
+        shas = [
+            sha.strip()
+            for sha in maybe_hashes.split("--hash=sha256:")[1:]
+        ]
+
+    if head == line:
+        head = line.partition("--hash=")[0].strip()
+    else:
+        head = head + ";" + maybe_hashes.partition("--hash=")[0].strip()
+
+    return struct(
+        requirement = line if not shas else head,
+        version = version,
+        shas = sorted(shas),
+    )
+
+def parse_simple_api_html(*, url, content):
+    """Get the package URLs for given shas by parsing the Simple API HTML.
+
+    Args:
+        url(str): The URL that the HTML content can be downloaded from.
+        content(str): The Simple API HTML content.
+
+    Returns:
+        A list of structs with:
+        * filename: The filename of the artifact.
+        * url: The URL to download the artifact.
+        * sha256: The sha256 of the artifact.
+        * metadata_sha256: The whl METADATA sha256 if we can download it. If this is
+          present, then the 'metadata_url' is also present. Defaults to "".
+        * metadata_url: The URL for the METADATA if we can download it. Defaults to "".
+    """
+    sdists = {}
+    whls = {}
+    lines = content.split("<a href=\"")
+
+    _, _, api_version = lines[0].partition("name=\"pypi:repository-version\" content=\"")
+    api_version, _, _ = api_version.partition("\"")
+
+    # We must assume the 1.0 if it is not present
+    # See https://packaging.python.org/en/latest/specifications/simple-repository-api/#clients
+    api_version = api_version or "1.0"
+    api_version = tuple([int(i) for i in api_version.split(".")])
+
+    if api_version >= (2, 0):
+        # We don't expect to have version 2.0 here, but have this check in place just in case.
+        # https://packaging.python.org/en/latest/specifications/simple-repository-api/#versioning-pypi-s-simple-api
+        fail("Unsupported API version: {}".format(api_version))
+
+    for line in lines[1:]:
+        dist_url, _, tail = line.partition("#sha256=")
+        sha256, _, tail = tail.partition("\"")
+
+        # See https://packaging.python.org/en/latest/specifications/simple-repository-api/#adding-yank-support-to-the-simple-api
+        yanked = "data-yanked" in line
+
+        maybe_metadata, _, tail = tail.partition(">")
+        filename, _, tail = tail.partition("<")
+
+        metadata_sha256 = ""
+        metadata_url = ""
+        for metadata_marker in ["data-core-metadata", "data-dist-info-metadata"]:
+            metadata_marker = metadata_marker + "=\"sha256="
+            if metadata_marker in maybe_metadata:
+                # Implement https://peps.python.org/pep-0714/
+                _, _, tail = maybe_metadata.partition(metadata_marker)
+                metadata_sha256, _, _ = tail.partition("\"")
+                metadata_url = dist_url + ".metadata"
+                break
+
+        if filename.endswith(".whl"):
+            whls[sha256] = struct(
+                filename = filename,
+                url = _absolute_url(url, dist_url),
+                sha256 = sha256,
+                metadata_sha256 = metadata_sha256,
+                metadata_url = _absolute_url(url, metadata_url),
+                yanked = yanked,
+            )
+        else:
+            sdists[sha256] = struct(
+                filename = filename,
+                url = _absolute_url(url, dist_url),
+                sha256 = sha256,
+                metadata_sha256 = "",
+                metadata_url = "",
+                yanked = yanked,
+            )
+
+    return struct(
+        sdists = sdists,
+        whls = whls,
+    )
+
+def _absolute_url(index_url, candidate):
+    if not candidate.startswith(".."):
+        return candidate
+
+    candidate_parts = candidate.split("..")
+    last = candidate_parts[-1]
+    for _ in range(len(candidate_parts) - 1):
+        index_url, _, _ = index_url.rstrip("/").rpartition("/")
+
+    return "{}/{}".format(index_url, last.strip("/"))
diff --git a/python/private/whl_target_platforms.bzl b/python/private/whl_target_platforms.bzl
index 30e4dd4..4e17f2b 100644
--- a/python/private/whl_target_platforms.bzl
+++ b/python/private/whl_target_platforms.bzl
@@ -16,6 +16,56 @@
 A starlark implementation of the wheel platform tag parsing to get the target platform.
 """
 
+load(":parse_whl_name.bzl", "parse_whl_name")
+
+# Taken from https://peps.python.org/pep-0600/
+_LEGACY_ALIASES = {
+    "manylinux1_i686": "manylinux_2_5_i686",
+    "manylinux1_x86_64": "manylinux_2_5_x86_64",
+    "manylinux2010_i686": "manylinux_2_12_i686",
+    "manylinux2010_x86_64": "manylinux_2_12_x86_64",
+    "manylinux2014_aarch64": "manylinux_2_17_aarch64",
+    "manylinux2014_armv7l": "manylinux_2_17_armv7l",
+    "manylinux2014_i686": "manylinux_2_17_i686",
+    "manylinux2014_ppc64": "manylinux_2_17_ppc64",
+    "manylinux2014_ppc64le": "manylinux_2_17_ppc64le",
+    "manylinux2014_s390x": "manylinux_2_17_s390x",
+    "manylinux2014_x86_64": "manylinux_2_17_x86_64",
+}
+
+# _translate_cpu and _translate_os from @platforms//host:extension.bzl
+def _translate_cpu(arch):
+    if arch in ["i386", "i486", "i586", "i686", "i786", "x86"]:
+        return "x86_32"
+    if arch in ["amd64", "x86_64", "x64"]:
+        return "x86_64"
+    if arch in ["ppc", "ppc64", "ppc64le"]:
+        return "ppc"
+    if arch in ["arm", "armv7l"]:
+        return "arm"
+    if arch in ["aarch64"]:
+        return "aarch64"
+    if arch in ["s390x", "s390"]:
+        return "s390x"
+    if arch in ["mips64el", "mips64"]:
+        return "mips64"
+    if arch in ["riscv64"]:
+        return "riscv64"
+    return None
+
+def _translate_os(os):
+    if os.startswith("mac os"):
+        return "osx"
+    if os.startswith("freebsd"):
+        return "freebsd"
+    if os.startswith("openbsd"):
+        return "openbsd"
+    if os.startswith("linux"):
+        return "linux"
+    if os.startswith("windows"):
+        return "windows"
+    return None
+
 # The order of the dictionaries is to keep definitions with their aliases next to each
 # other
 _CPU_ALIASES = {
@@ -28,8 +78,11 @@
     "aarch64": "aarch64",
     "arm64": "aarch64",
     "ppc": "ppc",
+    "ppc64": "ppc",
     "ppc64le": "ppc",
     "s390x": "s390x",
+    "armv6l": "arm",
+    "armv7l": "arm",
 }  # buildifier: disable=unsorted-dict-items
 
 _OS_PREFIXES = {
@@ -40,6 +93,131 @@
     "win": "windows",
 }  # buildifier: disable=unsorted-dict-items
 
+def _whl_priority(value):
+    """Return a value for sorting whl lists.
+
+    TODO @aignas 2024-03-29: In the future we should create a repo for each
+    repo that matches the abi and then we could have config flags for the
+    preference of `any` wheels or `sdist` or `manylinux` vs `musllinux` or
+    `universal2`. Ideally we use `select` statements in the hub repo to do
+    the selection based on the config, but for now this is the best way to
+    get this working for the host platform.
+
+    In the future the right thing would be to have `bool_flag` or something
+    similar to be able to have select statements that does the right thing:
+    * select whls vs sdists.
+    * select manylinux vs musllinux
+    * select universal2 vs arch-specific whls
+
+    All of these can be expressed as configuration settings and included in the
+    select statements in the `whl` repo. This means that the user can configure
+    for a particular target what they need.
+
+    Returns a 4-tuple where the items are:
+        * bool - is it an 'any' wheel? True if it is.
+        * bool - is it an 'universal' wheel? True if it is. (e.g. macos universal2 wheels)
+        * int - the minor plaform version (e.g. osx os version, libc version)
+        * int - the major plaform version (e.g. osx os version, libc version)
+    """
+    if "." in value:
+        value, _, _ = value.partition(".")
+
+    if "any" == value:
+        # This is just a big value that should be larger than any other value returned by this function
+        return (True, False, 0, 0)
+
+    if "linux" in value:
+        os, _, tail = value.partition("_")
+        if os == "linux":
+            # If the platform tag starts with 'linux', then return something less than what 'any' returns
+            minor = 0
+            major = 0
+        else:
+            major, _, tail = tail.partition("_")  # We don't need to use that because it's the same for all candidates now
+            minor, _, _ = tail.partition("_")
+
+        return (False, os == "linux", int(minor), int(major))
+
+    if "mac" in value or "osx" in value:
+        _, _, tail = value.partition("_")
+        major, _, tail = tail.partition("_")
+        minor, _, _ = tail.partition("_")
+
+        return (False, "universal2" in value, int(minor), int(major))
+
+    if not "win" in value:
+        fail("BUG: only windows, linux and mac platforms are supported, but got: {}".format(value))
+
+    # Windows does not have multiple wheels for the same target platform
+    return (False, False, 0, 0)
+
+def select_whl(*, whls, want_abis, want_os, want_cpu):
+    """Select a suitable wheel from a list.
+
+    Args:
+        whls(list[struct]): A list of candidates.
+        want_abis(list[str]): A list of ABIs that are supported.
+        want_os(str): The module_ctx.os.name.
+        want_cpu(str): The module_ctx.os.arch.
+
+    Returns:
+        None or a struct with `url`, `sha256` and `filename` attributes for the
+        selected whl. If no match is found, None is returned.
+    """
+    if not whls:
+        return None
+
+    candidates = {}
+    for whl in whls:
+        parsed = parse_whl_name(whl.filename)
+        if parsed.abi_tag not in want_abis:
+            # Filter out incompatible ABIs
+            continue
+
+        platform_tags = list({_LEGACY_ALIASES.get(p, p): True for p in parsed.platform_tag.split(".")})
+
+        for tag in platform_tags:
+            candidates[tag] = whl
+
+    # For most packages - if they supply 'any' wheel and there are no other
+    # compatible wheels with the selected abis, we can just return the value.
+    if len(candidates) == 1 and "any" in candidates:
+        return struct(
+            url = candidates["any"].url,
+            sha256 = candidates["any"].sha256,
+            filename = candidates["any"].filename,
+        )
+
+    target_plats = {}
+    has_any = "any" in candidates
+    for platform_tag, whl in candidates.items():
+        if platform_tag == "any":
+            continue
+
+        if "musl" in platform_tag:
+            # Ignore musl wheels for now
+            continue
+
+        platform_tag = ".".join({_LEGACY_ALIASES.get(p, p): True for p in platform_tag.split(".")})
+        platforms = whl_target_platforms(platform_tag)
+        for p in platforms:
+            target_plats.setdefault("{}_{}".format(p.os, p.cpu), []).append(platform_tag)
+
+    for p, platform_tags in target_plats.items():
+        if has_any:
+            platform_tags.append("any")
+
+        target_plats[p] = sorted(platform_tags, key = _whl_priority)
+
+    want = target_plats.get("{}_{}".format(
+        _translate_os(want_os),
+        _translate_cpu(want_cpu),
+    ))
+    if not want:
+        return want
+
+    return candidates[want[0]]
+
 def whl_target_platforms(platform_tag, abi_tag = ""):
     """Parse the wheel abi and platform tags and return (os, cpu) tuples.
 
@@ -74,7 +252,8 @@
                 for cpu in cpus
             ]
 
-    fail("unknown platform_tag os: {}".format(platform_tag))
+    print("WARNING: ignoring unknown platform_tag os: {}".format(platform_tag))  # buildifier: disable=print
+    return []
 
 def _cpu_from_tag(tag):
     candidate = [
@@ -87,7 +266,14 @@
 
     if tag == "win32":
         return ["x86_32"]
-    elif tag.endswith("universal2") and tag.startswith("macosx"):
-        return ["x86_64", "aarch64"]
-    else:
-        fail("Unrecognized tag: '{}': cannot determine CPU".format(tag))
+    elif tag == "win_ia64":
+        return []
+    elif tag.startswith("macosx"):
+        if tag.endswith("universal2"):
+            return ["x86_64", "aarch64"]
+        elif tag.endswith("universal"):
+            return ["x86_64", "aarch64"]
+        elif tag.endswith("intel"):
+            return ["x86_32"]
+
+    return []
diff --git a/tests/private/pypi_index/BUILD.bazel b/tests/private/pypi_index/BUILD.bazel
new file mode 100644
index 0000000..d365896
--- /dev/null
+++ b/tests/private/pypi_index/BUILD.bazel
@@ -0,0 +1,3 @@
+load(":pypi_index_tests.bzl", "pypi_index_test_suite")
+
+pypi_index_test_suite(name = "pypi_index_tests")
diff --git a/tests/private/pypi_index/pypi_index_tests.bzl b/tests/private/pypi_index/pypi_index_tests.bzl
new file mode 100644
index 0000000..e2122b5
--- /dev/null
+++ b/tests/private/pypi_index/pypi_index_tests.bzl
@@ -0,0 +1,256 @@
+# Copyright 2023 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+""
+
+load("@rules_testing//lib:test_suite.bzl", "test_suite")
+load("@rules_testing//lib:truth.bzl", "subjects")
+load("//python/private:pypi_index.bzl", "get_simpleapi_sources", "parse_simple_api_html")  # buildifier: disable=bzl-visibility
+
+_tests = []
+
+def _test_no_simple_api_sources(env):
+    inputs = [
+        "foo==0.0.1",
+        "foo==0.0.1 @ https://someurl.org",
+        "foo==0.0.1 @ https://someurl.org --hash=sha256:deadbeef",
+        "foo==0.0.1 @ https://someurl.org; python_version < 2.7 --hash=sha256:deadbeef",
+    ]
+    for input in inputs:
+        got = get_simpleapi_sources(input)
+        env.expect.that_collection(got.shas).contains_exactly([])
+        env.expect.that_str(got.version).equals("0.0.1")
+
+_tests.append(_test_no_simple_api_sources)
+
+def _test_simple_api_sources(env):
+    tests = {
+        "foo==0.0.2 --hash=sha256:deafbeef    --hash=sha256:deadbeef": [
+            "deadbeef",
+            "deafbeef",
+        ],
+        "foo[extra]==0.0.2; (python_version < 2.7 or something_else == \"@\") --hash=sha256:deafbeef    --hash=sha256:deadbeef": [
+            "deadbeef",
+            "deafbeef",
+        ],
+    }
+    for input, want_shas in tests.items():
+        got = get_simpleapi_sources(input)
+        env.expect.that_collection(got.shas).contains_exactly(want_shas)
+        env.expect.that_str(got.version).equals("0.0.2")
+
+_tests.append(_test_simple_api_sources)
+
+def _generate_html(*items):
+    return """\
+<html>
+  <head>
+    <meta name="pypi:repository-version" content="1.1">
+    <title>Links for foo</title>
+  </head>
+  <body>
+    <h1>Links for cengal</h1>
+{}
+</body>
+</html>
+""".format(
+        "\n".join([
+            "<a {}>{}</a><br />".format(
+                " ".join(item.attrs),
+                item.filename,
+            )
+            for item in items
+        ]),
+    )
+
+def _test_parse_simple_api_html(env):
+    # buildifier: disable=unsorted-dict-items
+    tests = [
+        (
+            struct(
+                attrs = [
+                    'href="https://example.org/full-url/foo-0.0.1.tar.gz#sha256=deadbeefasource"',
+                    'data-requires-python="&gt;=3.7"',
+                ],
+                filename = "foo-0.0.1.tar.gz",
+                url = "ignored",
+            ),
+            struct(
+                filename = "foo-0.0.1.tar.gz",
+                sha256 = "deadbeefasource",
+                url = "https://example.org/full-url/foo-0.0.1.tar.gz",
+                yanked = False,
+            ),
+        ),
+    ]
+
+    for (input, want) in tests:
+        html = _generate_html(input)
+        got = parse_simple_api_html(url = input.url, content = html)
+        env.expect.that_collection(got.sdists).has_size(1)
+        env.expect.that_collection(got.whls).has_size(0)
+        if not got:
+            fail("expected at least one element, but did not get anything from:\n{}".format(html))
+
+        actual = env.expect.that_struct(
+            got.sdists[want.sha256],
+            attrs = dict(
+                filename = subjects.str,
+                sha256 = subjects.str,
+                url = subjects.str,
+                yanked = subjects.bool,
+            ),
+        )
+        actual.filename().equals(want.filename)
+        actual.sha256().equals(want.sha256)
+        actual.url().equals(want.url)
+        actual.yanked().equals(want.yanked)
+
+_tests.append(_test_parse_simple_api_html)
+
+def _test_parse_simple_api_html_whls(env):
+    # buildifier: disable=unsorted-dict-items
+    tests = [
+        (
+            struct(
+                attrs = [
+                    'href="https://example.org/full-url/foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl#sha256=deadbeef"',
+                    'data-requires-python="&gt;=3.7"',
+                    'data-dist-info-metadata="sha256=deadb00f"',
+                    'data-core-metadata="sha256=deadb00f"',
+                ],
+                filename = "foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
+                url = "ignored",
+            ),
+            struct(
+                filename = "foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
+                metadata_sha256 = "deadb00f",
+                metadata_url = "https://example.org/full-url/foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata",
+                sha256 = "deadbeef",
+                url = "https://example.org/full-url/foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
+                yanked = False,
+            ),
+        ),
+        (
+            struct(
+                attrs = [
+                    'href="https://example.org/full-url/foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl#sha256=deadbeef"',
+                    'data-requires-python="&gt;=3.7"',
+                    'data-core-metadata="sha256=deadb00f"',
+                ],
+                filename = "foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
+                url = "ignored",
+            ),
+            struct(
+                filename = "foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
+                metadata_sha256 = "deadb00f",
+                metadata_url = "https://example.org/full-url/foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata",
+                sha256 = "deadbeef",
+                url = "https://example.org/full-url/foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
+                yanked = False,
+            ),
+        ),
+        (
+            struct(
+                attrs = [
+                    'href="https://example.org/full-url/foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl#sha256=deadbeef"',
+                    'data-requires-python="&gt;=3.7"',
+                    'data-dist-info-metadata="sha256=deadb00f"',
+                ],
+                filename = "foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
+                url = "ignored",
+            ),
+            struct(
+                filename = "foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
+                metadata_sha256 = "deadb00f",
+                metadata_url = "https://example.org/full-url/foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata",
+                sha256 = "deadbeef",
+                url = "https://example.org/full-url/foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
+                yanked = False,
+            ),
+        ),
+        (
+            struct(
+                attrs = [
+                    'href="https://example.org/full-url/foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl#sha256=deadbeef"',
+                    'data-requires-python="&gt;=3.7"',
+                ],
+                filename = "foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
+                url = "ignored",
+            ),
+            struct(
+                filename = "foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
+                metadata_sha256 = "",
+                metadata_url = "",
+                sha256 = "deadbeef",
+                url = "https://example.org/full-url/foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
+                yanked = False,
+            ),
+        ),
+        (
+            struct(
+                attrs = [
+                    'href="../../foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl#sha256=deadbeef"',
+                    'data-requires-python="&gt;=3.7"',
+                    'data-dist-info-metadata="sha256=deadb00f"',
+                ],
+                filename = "foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
+                url = "https://example.org/python-wheels/bar/foo/",
+            ),
+            struct(
+                filename = "foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
+                metadata_sha256 = "deadb00f",
+                metadata_url = "https://example.org/python-wheels/foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata",
+                sha256 = "deadbeef",
+                url = "https://example.org/python-wheels/foo-0.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
+                yanked = False,
+            ),
+        ),
+    ]
+
+    for (input, want) in tests:
+        html = _generate_html(input)
+        got = parse_simple_api_html(url = input.url, content = html)
+        env.expect.that_collection(got.sdists).has_size(0)
+        env.expect.that_collection(got.whls).has_size(1)
+        if not got:
+            fail("expected at least one element, but did not get anything from:\n{}".format(html))
+
+        actual = env.expect.that_struct(
+            got.whls[want.sha256],
+            attrs = dict(
+                filename = subjects.str,
+                metadata_sha256 = subjects.str,
+                metadata_url = subjects.str,
+                sha256 = subjects.str,
+                url = subjects.str,
+                yanked = subjects.bool,
+            ),
+        )
+        actual.filename().equals(want.filename)
+        actual.metadata_sha256().equals(want.metadata_sha256)
+        actual.metadata_url().equals(want.metadata_url)
+        actual.sha256().equals(want.sha256)
+        actual.url().equals(want.url)
+        actual.yanked().equals(want.yanked)
+
+_tests.append(_test_parse_simple_api_html_whls)
+
+def pypi_index_test_suite(name):
+    """Create the test suite.
+
+    Args:
+        name: the name of the test suite
+    """
+    test_suite(name = name, basic_tests = _tests)
diff --git a/tests/private/whl_target_platforms/BUILD.bazel b/tests/private/whl_target_platforms/BUILD.bazel
index fec25af..6c35b08 100644
--- a/tests/private/whl_target_platforms/BUILD.bazel
+++ b/tests/private/whl_target_platforms/BUILD.bazel
@@ -12,6 +12,9 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+load(":select_whl_tests.bzl", "select_whl_test_suite")
 load(":whl_target_platforms_tests.bzl", "whl_target_platforms_test_suite")
 
+select_whl_test_suite(name = "select_whl_tests")
+
 whl_target_platforms_test_suite(name = "whl_target_platforms_tests")
diff --git a/tests/private/whl_target_platforms/select_whl_tests.bzl b/tests/private/whl_target_platforms/select_whl_tests.bzl
new file mode 100644
index 0000000..0d6f97d
--- /dev/null
+++ b/tests/private/whl_target_platforms/select_whl_tests.bzl
@@ -0,0 +1,127 @@
+# Copyright 2024 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+""
+
+load("@rules_testing//lib:test_suite.bzl", "test_suite")
+load("//python/private:whl_target_platforms.bzl", "select_whl")  # buildifier: disable=bzl-visibility
+
+WHL_LIST = [
+    struct(
+        filename = f,
+        url = "https://" + f,
+        sha256 = "sha256://" + f,
+    )
+    for f in [
+        "pkg-0.0.1-cp311-cp311-macosx_10_9_universal2.whl",
+        "pkg-0.0.1-cp311-cp311-macosx_10_9_x86_64.whl",
+        "pkg-0.0.1-cp311-cp311-macosx_11_0_arm64.whl",
+        "pkg-0.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl",
+        "pkg-0.0.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl",
+        "pkg-0.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl",
+        "pkg-0.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
+        "pkg-0.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl",
+        "pkg-0.0.1-cp311-cp311-musllinux_1_1_aarch64.whl",
+        "pkg-0.0.1-cp311-cp311-musllinux_1_1_i686.whl",
+        "pkg-0.0.1-cp311-cp311-musllinux_1_1_ppc64le.whl",
+        "pkg-0.0.1-cp311-cp311-musllinux_1_1_s390x.whl",
+        "pkg-0.0.1-cp311-cp311-musllinux_1_1_x86_64.whl",
+        "pkg-0.0.1-cp311-cp311-win32.whl",
+        "pkg-0.0.1-cp311-cp311-win_amd64.whl",
+        "pkg-0.0.1-cp37-cp37m-macosx_10_9_x86_64.whl",
+        "pkg-0.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl",
+        "pkg-0.0.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl",
+        "pkg-0.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl",
+        "pkg-0.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
+        "pkg-0.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl",
+        "pkg-0.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl",
+        "pkg-0.0.1-cp37-cp37m-musllinux_1_1_i686.whl",
+        "pkg-0.0.1-cp37-cp37m-musllinux_1_1_ppc64le.whl",
+        "pkg-0.0.1-cp37-cp37m-musllinux_1_1_s390x.whl",
+        "pkg-0.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl",
+        "pkg-0.0.1-cp37-cp37m-win32.whl",
+        "pkg-0.0.1-cp37-cp37m-win_amd64.whl",
+        "pkg-0.0.1-cp39-cp39-macosx_10_9_universal2.whl",
+        "pkg-0.0.1-cp39-cp39-macosx_10_9_x86_64.whl",
+        "pkg-0.0.1-cp39-cp39-macosx_11_0_arm64.whl",
+        "pkg-0.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl",
+        "pkg-0.0.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl",
+        "pkg-0.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl",
+        "pkg-0.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
+        "pkg-0.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl",
+        "pkg-0.0.1-cp39-cp39-musllinux_1_1_aarch64.whl",
+        "pkg-0.0.1-cp39-cp39-musllinux_1_1_i686.whl",
+        "pkg-0.0.1-cp39-cp39-musllinux_1_1_ppc64le.whl",
+        "pkg-0.0.1-cp39-cp39-musllinux_1_1_s390x.whl",
+        "pkg-0.0.1-cp39-cp39-musllinux_1_1_x86_64.whl",
+        "pkg-0.0.1-cp39-cp39-win32.whl",
+        "pkg-0.0.1-cp39-cp39-win_amd64.whl",
+        "pkg-0.0.1-py3-abi3-any.whl",
+        "pkg-0.0.1-py3-none-any.whl",
+    ]
+]
+
+def _match(env, got, want_filename):
+    if want_filename:
+        env.expect.that_str(got.filename).equals(want_filename)
+        env.expect.that_str(got.sha256).equals("sha256://" + want_filename)
+        env.expect.that_str(got.url).equals("https://" + want_filename)
+    else:
+        env.expect.that_int(got).equals(None)
+
+_tests = []
+
+def _test_selecting(env):
+    got = select_whl(whls = WHL_LIST, want_abis = ["none"], want_os = "ignored", want_cpu = "ignored")
+    _match(env, got, "pkg-0.0.1-py3-none-any.whl")
+
+    got = select_whl(whls = WHL_LIST, want_abis = ["abi3"], want_os = "ignored", want_cpu = "ignored")
+    _match(env, got, "pkg-0.0.1-py3-abi3-any.whl")
+
+    # Check the selection failure
+    got = select_whl(whls = WHL_LIST, want_abis = ["cp39"], want_os = "fancy", want_cpu = "exotic")
+    _match(env, got, None)
+
+    # Check we match the ABI and not the py version
+    got = select_whl(whls = WHL_LIST, want_abis = ["cp37m"], want_os = "linux", want_cpu = "amd64")
+    _match(env, got, "pkg-0.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl")
+
+    # Check we can select a filename with many platform tags
+    got = select_whl(whls = WHL_LIST, want_abis = ["cp39"], want_os = "linux", want_cpu = "i686")
+    _match(env, got, "pkg-0.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl")
+
+    # Check that we prefer the specific wheel
+    got = select_whl(whls = WHL_LIST, want_abis = ["cp311"], want_os = "mac os", want_cpu = "x86_64")
+    _match(env, got, "pkg-0.0.1-cp311-cp311-macosx_10_9_x86_64.whl")
+
+    got = select_whl(whls = WHL_LIST, want_abis = ["cp311"], want_os = "mac os", want_cpu = "aarch64")
+    _match(env, got, "pkg-0.0.1-cp311-cp311-macosx_11_0_arm64.whl")
+
+    # Check that we can use the universal2 if the arm wheel is not available
+    got = select_whl(whls = [w for w in WHL_LIST if "arm64" not in w.filename], want_abis = ["cp311"], want_os = "mac os", want_cpu = "aarch64")
+    _match(env, got, "pkg-0.0.1-cp311-cp311-macosx_10_9_universal2.whl")
+
+    # Check we prefer platform specific wheels
+    got = select_whl(whls = WHL_LIST, want_abis = ["none", "abi3", "cp39"], want_os = "linux", want_cpu = "x86_64")
+    _match(env, got, "pkg-0.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl")
+
+_tests.append(_test_selecting)
+
+def select_whl_test_suite(name):
+    """Create the test suite.
+
+    Args:
+        name: the name of the test suite
+    """
+    test_suite(name = name, basic_tests = _tests)
diff --git a/tests/private/whl_target_platforms/whl_target_platforms_tests.bzl b/tests/private/whl_target_platforms/whl_target_platforms_tests.bzl
index f52437f..a06147b 100644
--- a/tests/private/whl_target_platforms/whl_target_platforms_tests.bzl
+++ b/tests/private/whl_target_platforms/whl_target_platforms_tests.bzl
@@ -72,10 +72,62 @@
 
 _tests.append(_test_with_abi)
 
-def whl_target_platforms_test_suite(name):
-    """Create the test suite.
+def _can_parse_existing_tags(env):
+    examples = {
+        "linux_armv6l": 1,
+        "linux_armv7l": 1,
+        "macosx_11_12_arm64": 1,
+        "macosx_11_12_i386": 1,
+        "macosx_11_12_intel": 1,
+        "macosx_11_12_universal": 2,
+        "macosx_11_12_universal2": 2,
+        "macosx_11_12_x86_64": 1,
+        "manylinux1_i686": 1,
+        "manylinux1_x86_64": 1,
+        "manylinux2010_i686": 1,
+        "manylinux2010_x86_64": 1,
+        "manylinux2014_aarch64": 1,
+        "manylinux2014_armv7l": 1,
+        "manylinux2014_i686": 1,
+        "manylinux2014_ppc64": 1,
+        "manylinux2014_ppc64le": 1,
+        "manylinux2014_s390x": 1,
+        "manylinux2014_x86_64": 1,
+        "manylinux_11_12_aarch64": 1,
+        "manylinux_11_12_armv7l": 1,
+        "manylinux_11_12_i686": 1,
+        "manylinux_11_12_ppc64": 1,
+        "manylinux_11_12_ppc64le": 1,
+        "manylinux_11_12_s390x": 1,
+        "manylinux_11_12_x86_64": 1,
+        "manylinux_1_2_aarch64": 1,
+        "manylinux_1_2_x86_64": 1,
+        "musllinux_11_12_aarch64": 1,
+        "musllinux_11_12_armv7l": 1,
+        "musllinux_11_12_i686": 1,
+        "musllinux_11_12_ppc64le": 1,
+        "musllinux_11_12_s390x": 1,
+        "musllinux_11_12_x86_64": 1,
+        "win32": 1,
+        "win_amd64": 1,
+        "win_arm64": 1,
+        "win_ia64": 0,
+    }
 
-    Args:
+    for major_version in [2, 10, 13]:
+        for minor_version in [0, 1, 2, 10, 45]:
+            for give, want_size in examples.items():
+                give = give.replace("_11_", "_{}_".format(major_version))
+                give = give.replace("_12_", "_{}_".format(minor_version))
+                got = whl_target_platforms(give)
+                env.expect.that_str("{}: {}".format(give, len(got))).equals("{}: {}".format(give, want_size))
+
+_tests.append(_can_parse_existing_tags)
+
+def whl_target_platforms_test_suite(name):
+    """create the test suite.
+
+    args:
         name: the name of the test suite
     """
     test_suite(name = name, basic_tests = _tests)