feat(pypi): support direct urls for wheels in bazel downloader (#2655)

This PR adds support for installing wheels via direct urls in the
requirements lock file:
```
foo==0.0.1 @ https://someurl.org/package.whl
bar==0.0.1 @ https://someurl.org/package.tar.gz
```
This is to improve parity between bazel downloader and pip behavior.
Before this change, direct urls used fallback to pip install.

Partially addresses #2363 as it does not add support for git urls.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 403dbaf..9029794 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -88,6 +88,9 @@
   GitHub releases page metadata published by the `uv` project.
 * (pypi) An extra argument to add the interpreter lib dir to `LDFLAGS` when
   building wheels from `sdist`.
+* (pypi) Direct HTTP urls for wheels and sdists are now supported when using
+  {obj}`experimental_index_url` (bazel downloader).
+  Partially fixes [#2363](https://github.com/bazelbuild/rules_python/issues/2363).
 
 {#v0-0-0-removed}
 ### Removed
diff --git a/python/private/pypi/index_sources.bzl b/python/private/pypi/index_sources.bzl
index 8b3c300..e3762d2 100644
--- a/python/private/pypi/index_sources.bzl
+++ b/python/private/pypi/index_sources.bzl
@@ -32,6 +32,7 @@
             * `marker` - str; the marker expression, as per PEP508 spec.
             * `requirement` - str; a requirement line without the marker. This can
                 be given to `pip` to install a package.
+            * `url` - str; URL if the requirement specifies a direct URL, empty string otherwise.
     """
     line = line.replace("\\", " ")
     head, _, maybe_hashes = line.partition(";")
@@ -55,9 +56,12 @@
         requirement,
         " ".join(["--hash=sha256:{}".format(sha) for sha in shas]),
     ).strip()
+
+    url = ""
     if "@" in head:
         requirement = requirement_line
-        shas = []
+        _, _, url_and_rest = requirement.partition("@")
+        url = url_and_rest.strip().partition(" ")[0].strip()
 
     return struct(
         requirement = requirement,
@@ -65,4 +69,5 @@
         version = version,
         shas = sorted(shas),
         marker = marker,
+        url = url,
     )
diff --git a/python/private/pypi/parse_requirements.bzl b/python/private/pypi/parse_requirements.bzl
index 2bca8d8..dbff44e 100644
--- a/python/private/pypi/parse_requirements.bzl
+++ b/python/private/pypi/parse_requirements.bzl
@@ -292,6 +292,23 @@
         index_urls: The result of simpleapi_download.
         logger: A logger for printing diagnostic info.
     """
+
+    # Handle direct URLs in requirements
+    if requirement.srcs.url:
+        url = requirement.srcs.url
+        _, _, filename = url.rpartition("/")
+        direct_url_dist = struct(
+            url = url,
+            filename = filename,
+            sha256 = requirement.srcs.shas[0] if requirement.srcs.shas else "",
+            yanked = False,
+        )
+
+        if filename.endswith(".whl"):
+            return [direct_url_dist], None
+        else:
+            return [], direct_url_dist
+
     if not index_urls:
         return [], None
 
diff --git a/tests/pypi/index_sources/index_sources_tests.bzl b/tests/pypi/index_sources/index_sources_tests.bzl
index 440957e..ffeed87 100644
--- a/tests/pypi/index_sources/index_sources_tests.bzl
+++ b/tests/pypi/index_sources/index_sources_tests.bzl
@@ -24,27 +24,39 @@
         "foo==0.0.1": struct(
             requirement = "foo==0.0.1",
             marker = "",
+            url = "",
         ),
         "foo==0.0.1 @ https://someurl.org": struct(
             requirement = "foo==0.0.1 @ https://someurl.org",
             marker = "",
+            url = "https://someurl.org",
         ),
-        "foo==0.0.1 @ https://someurl.org --hash=sha256:deadbeef": struct(
-            requirement = "foo==0.0.1 @ https://someurl.org --hash=sha256:deadbeef",
+        "foo==0.0.1 @ https://someurl.org/package.whl": struct(
+            requirement = "foo==0.0.1 @ https://someurl.org/package.whl",
             marker = "",
+            url = "https://someurl.org/package.whl",
         ),
-        "foo==0.0.1 @ https://someurl.org; python_version < \"2.7\"\\    --hash=sha256:deadbeef": struct(
-            requirement = "foo==0.0.1 @ https://someurl.org --hash=sha256:deadbeef",
+        "foo==0.0.1 @ https://someurl.org/package.whl --hash=sha256:deadbeef": struct(
+            requirement = "foo==0.0.1 @ https://someurl.org/package.whl --hash=sha256:deadbeef",
+            marker = "",
+            url = "https://someurl.org/package.whl",
+            shas = ["deadbeef"],
+        ),
+        "foo==0.0.1 @ https://someurl.org/package.whl; python_version < \"2.7\"\\    --hash=sha256:deadbeef": struct(
+            requirement = "foo==0.0.1 @ https://someurl.org/package.whl --hash=sha256:deadbeef",
             marker = "python_version < \"2.7\"",
+            url = "https://someurl.org/package.whl",
+            shas = ["deadbeef"],
         ),
     }
     for input, want in inputs.items():
         got = index_sources(input)
-        env.expect.that_collection(got.shas).contains_exactly([])
+        env.expect.that_collection(got.shas).contains_exactly(want.shas if hasattr(want, "shas") else [])
         env.expect.that_str(got.version).equals("0.0.1")
         env.expect.that_str(got.requirement).equals(want.requirement)
         env.expect.that_str(got.requirement_line).equals(got.requirement)
         env.expect.that_str(got.marker).equals(want.marker)
+        env.expect.that_str(got.url).equals(want.url)
 
 _tests.append(_test_no_simple_api_sources)
 
@@ -58,6 +70,7 @@
             marker = "",
             requirement = "foo==0.0.2",
             requirement_line = "foo==0.0.2 --hash=sha256:deafbeef --hash=sha256:deadbeef",
+            url = "",
         ),
         "foo[extra]==0.0.2; (python_version < 2.7 or extra == \"@\") --hash=sha256:deafbeef    --hash=sha256:deadbeef": struct(
             shas = [
@@ -67,6 +80,7 @@
             marker = "(python_version < 2.7 or extra == \"@\")",
             requirement = "foo[extra]==0.0.2",
             requirement_line = "foo[extra]==0.0.2 --hash=sha256:deafbeef --hash=sha256:deadbeef",
+            url = "",
         ),
     }
     for input, want in tests.items():
@@ -76,6 +90,7 @@
         env.expect.that_str(got.requirement).equals(want.requirement)
         env.expect.that_str(got.requirement_line).equals(want.requirement_line)
         env.expect.that_str(got.marker).equals(want.marker)
+        env.expect.that_str(got.url).equals(want.url)
 
 _tests.append(_test_simple_api_sources)
 
diff --git a/tests/pypi/parse_requirements/parse_requirements_tests.bzl b/tests/pypi/parse_requirements/parse_requirements_tests.bzl
index 77e22b8..8edc268 100644
--- a/tests/pypi/parse_requirements/parse_requirements_tests.bzl
+++ b/tests/pypi/parse_requirements/parse_requirements_tests.bzl
@@ -26,7 +26,10 @@
     --hash=sha256:deadb00f
 """,
         "requirements_direct": """\
-foo[extra] @ https://some-url
+foo[extra] @ https://some-url/package.whl
+bar @ https://example.org/bar-1.0.whl --hash=sha256:deadbeef
+baz @ https://test.com/baz-2.0.whl; python_version < "3.8" --hash=sha256:deadb00f
+qux @ https://example.org/qux-1.0.tar.gz --hash=sha256:deadbe0f
 """,
         "requirements_extra_args": """\
 --index-url=example.org
@@ -106,6 +109,7 @@
                     requirement_line = "foo[extra]==0.0.1 --hash=sha256:deadbeef",
                     shas = ["deadbeef"],
                     version = "0.0.1",
+                    url = "",
                 ),
                 target_platforms = [
                     "linux_x86_64",
@@ -124,6 +128,110 @@
 
 _tests.append(_test_simple)
 
+def _test_direct_urls(env):
+    got = parse_requirements(
+        ctx = _mock_ctx(),
+        requirements_by_platform = {
+            "requirements_direct": ["linux_x86_64"],
+        },
+    )
+    env.expect.that_dict(got).contains_exactly({
+        "bar": [
+            struct(
+                distribution = "bar",
+                extra_pip_args = [],
+                sdist = None,
+                is_exposed = True,
+                srcs = struct(
+                    marker = "",
+                    requirement = "bar @ https://example.org/bar-1.0.whl --hash=sha256:deadbeef",
+                    requirement_line = "bar @ https://example.org/bar-1.0.whl --hash=sha256:deadbeef",
+                    shas = ["deadbeef"],
+                    version = "",
+                    url = "https://example.org/bar-1.0.whl",
+                ),
+                target_platforms = ["linux_x86_64"],
+                whls = [struct(
+                    url = "https://example.org/bar-1.0.whl",
+                    filename = "bar-1.0.whl",
+                    sha256 = "deadbeef",
+                    yanked = False,
+                )],
+            ),
+        ],
+        "baz": [
+            struct(
+                distribution = "baz",
+                extra_pip_args = [],
+                sdist = None,
+                is_exposed = True,
+                srcs = struct(
+                    marker = "python_version < \"3.8\"",
+                    requirement = "baz @ https://test.com/baz-2.0.whl --hash=sha256:deadb00f",
+                    requirement_line = "baz @ https://test.com/baz-2.0.whl --hash=sha256:deadb00f",
+                    shas = ["deadb00f"],
+                    version = "",
+                    url = "https://test.com/baz-2.0.whl",
+                ),
+                target_platforms = ["linux_x86_64"],
+                whls = [struct(
+                    url = "https://test.com/baz-2.0.whl",
+                    filename = "baz-2.0.whl",
+                    sha256 = "deadb00f",
+                    yanked = False,
+                )],
+            ),
+        ],
+        "foo": [
+            struct(
+                distribution = "foo",
+                extra_pip_args = [],
+                sdist = None,
+                is_exposed = True,
+                srcs = struct(
+                    marker = "",
+                    requirement = "foo[extra] @ https://some-url/package.whl",
+                    requirement_line = "foo[extra] @ https://some-url/package.whl",
+                    shas = [],
+                    version = "",
+                    url = "https://some-url/package.whl",
+                ),
+                target_platforms = ["linux_x86_64"],
+                whls = [struct(
+                    url = "https://some-url/package.whl",
+                    filename = "package.whl",
+                    sha256 = "",
+                    yanked = False,
+                )],
+            ),
+        ],
+        "qux": [
+            struct(
+                distribution = "qux",
+                extra_pip_args = [],
+                sdist = struct(
+                    url = "https://example.org/qux-1.0.tar.gz",
+                    filename = "qux-1.0.tar.gz",
+                    sha256 = "deadbe0f",
+                    yanked = False,
+                ),
+                is_exposed = True,
+                srcs = struct(
+                    marker = "",
+                    requirement = "qux @ https://example.org/qux-1.0.tar.gz --hash=sha256:deadbe0f",
+                    requirement_line = "qux @ https://example.org/qux-1.0.tar.gz --hash=sha256:deadbe0f",
+                    shas = ["deadbe0f"],
+                    version = "",
+                    url = "https://example.org/qux-1.0.tar.gz",
+                ),
+                target_platforms = ["linux_x86_64"],
+                whls = [],
+            ),
+        ],
+    })
+
+_tests.append(_test_direct_urls)
+
 def _test_extra_pip_args(env):
     got = parse_requirements(
         ctx = _mock_ctx(),
@@ -145,6 +253,7 @@
                     requirement_line = "foo[extra]==0.0.1 --hash=sha256:deadbeef",
                     shas = ["deadbeef"],
                     version = "0.0.1",
+                    url = "",
                 ),
                 target_platforms = [
                     "linux_x86_64",
@@ -182,6 +291,7 @@
                     requirement_line = "foo[extra,extra_2]==0.0.1 --hash=sha256:deadbeef",
                     shas = ["deadbeef"],
                     version = "0.0.1",
+                    url = "",
                 ),
                 target_platforms = ["linux_x86_64"],
                 whls = [],
@@ -211,6 +321,7 @@
                     requirement_line = "bar==0.0.1 --hash=sha256:deadb00f",
                     shas = ["deadb00f"],
                     version = "0.0.1",
+                    url = "",
                 ),
                 target_platforms = ["windows_x86_64"],
                 whls = [],
@@ -228,6 +339,7 @@
                     requirement_line = "foo==0.0.3 --hash=sha256:deadbaaf",
                     shas = ["deadbaaf"],
                     version = "0.0.3",
+                    url = "",
                 ),
                 target_platforms = ["linux_x86_64"],
                 whls = [],
@@ -243,6 +355,7 @@
                     requirement_line = "foo[extra]==0.0.2 --hash=sha256:deadbeef",
                     shas = ["deadbeef"],
                     version = "0.0.2",
+                    url = "",
                 ),
                 target_platforms = ["windows_x86_64"],
                 whls = [],
@@ -282,6 +395,7 @@
                     requirement_line = "bar==0.0.1 --hash=sha256:deadb00f",
                     shas = ["deadb00f"],
                     version = "0.0.1",
+                    url = "",
                 ),
                 target_platforms = ["cp39_linux_x86_64"],
                 whls = [],
@@ -299,6 +413,7 @@
                     requirement_line = "foo==0.0.1 --hash=sha256:deadbeef",
                     shas = ["deadbeef"],
                     version = "0.0.1",
+                    url = "",
                 ),
                 target_platforms = ["cp39_linux_x86_64"],
                 whls = [],
@@ -314,6 +429,7 @@
                     requirement = "foo==0.0.3",
                     shas = ["deadbaaf"],
                     version = "0.0.3",
+                    url = "",
                 ),
                 target_platforms = ["cp39_osx_aarch64"],
                 whls = [],
@@ -367,6 +483,7 @@
                     requirement_line = "bar==0.0.1 --hash=sha256:deadbeef",
                     shas = ["deadbeef"],
                     version = "0.0.1",
+                    url = "",
                 ),
                 target_platforms = ["cp311_linux_super_exotic", "cp311_windows_x86_64"],
                 whls = [],
@@ -384,6 +501,7 @@
                     requirement_line = "foo[extra]==0.0.1 --hash=sha256:deadbeef",
                     shas = ["deadbeef"],
                     version = "0.0.1",
+                    url = "",
                 ),
                 target_platforms = ["cp311_windows_x86_64"],
                 whls = [],
@@ -419,6 +537,7 @@
                     requirement_line = "foo==0.0.1 --hash=sha256:deadb00f",
                     shas = ["deadb00f"],
                     version = "0.0.1",
+                    url = "",
                 ),
                 target_platforms = ["linux_x86_64"],
                 whls = [],
@@ -434,6 +553,7 @@
                     requirement_line = "foo==0.0.1+local --hash=sha256:deadbeef",
                     shas = ["deadbeef"],
                     version = "0.0.1+local",
+                    url = "",
                 ),
                 target_platforms = ["linux_x86_64"],
                 whls = [],