feat(pypi): support freethreaded in experimental_index_url (#2460)

With this we:
* Fix the previous behaviour where `abi3` wheels would be selected when
  freethreaded builds are selected. Whilst this may work in practise
  sometimes, I am not sure it has been supported by reading PEP703.
* Start selecting `cp313t` wheels when we scan what is available on
PyPI.
* Ensure that the `whl_library` repository rule handles `cp313t` wheel
  extraction.
* Generate `cp313t` config_settings so that we can use them in
  `pkg_aliases`.
* Generate `cp313t` references in `pkg_aliases` macro.
* Add the 3.13 deps to dev_pip for testing.

Also tested by manually running:
```
$ bazel cquery --//python/config_settings:python_version=3.13 --//python/config_settings:py_freethreaded=yes 'kind("py_library rule", deps(@dev_pip//markupsafe))'
INFO: Analyzed target @@_main~pip~dev_pip//markupsafe:markupsafe (3 packages loaded, 4091 targets configured).
INFO: Found 1 target...
@@_main~pip~dev_pip_313_markupsafe_cp313_cp313t_manylinux_2_17_x86_64_c0ef13ea//:pkg (008c5a5)
$bazel build --//python/config_settings:python_version=3.13 --//python/config_settings:py_freethreaded=yes @dev_pip//markupsafe
```

Fixes #2386
diff --git a/MODULE.bazel b/MODULE.bazel
index 2ae3173..e4b113e 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -124,6 +124,13 @@
 dev_pip.parse(
     download_only = True,
     experimental_index_url = "https://pypi.org/simple",
+    hub_name = "dev_pip",
+    python_version = "3.13.0",
+    requirements_lock = "//docs:requirements.txt",
+)
+dev_pip.parse(
+    download_only = True,
+    experimental_index_url = "https://pypi.org/simple",
     hub_name = "pypiserver",
     python_version = "3.11",
     requirements_lock = "//examples/wheel:requirements_server.txt",
diff --git a/python/config_settings/BUILD.bazel b/python/config_settings/BUILD.bazel
index aa26e6e..5455f5a 100644
--- a/python/config_settings/BUILD.bazel
+++ b/python/config_settings/BUILD.bazel
@@ -106,6 +106,12 @@
     visibility = ["//visibility:public"],
 )
 
+config_setting(
+    name = "is_py_non_freethreaded",
+    flag_values = {":py_freethreaded": FreeThreadedFlag.NO},
+    visibility = ["//visibility:public"],
+)
+
 # pip.parse related flags
 
 string_flag(
diff --git a/python/private/pypi/config_settings.bzl b/python/private/pypi/config_settings.bzl
index 6f927f2..620e50e 100644
--- a/python/private/pypi/config_settings.bzl
+++ b/python/private/pypi/config_settings.bzl
@@ -20,20 +20,21 @@
 most specialized wheels are used by default with the users being able to
 configure string_flag values to select the less specialized ones.
 
-The list of specialization of the dists goes like follows:
+The list of specialization of the dists goes like follows (cpxyt stands for freethreaded
+environments):
 * sdist
 * py*-none-any.whl
 * py*-abi3-any.whl
-* py*-cpxy-any.whl
+* py*-cpxy-any.whl or py*-cpxyt-any.whl
 * cp*-none-any.whl
 * cp*-abi3-any.whl
-* cp*-cpxy-plat.whl
+* cp*-cpxy-any.whl or cp*-cpxyt-any.whl
 * py*-none-plat.whl
 * py*-abi3-plat.whl
-* py*-cpxy-plat.whl
+* py*-cpxy-plat.whl or py*-cpxyt-plat.whl
 * cp*-none-plat.whl
 * cp*-abi3-plat.whl
-* cp*-cpxy-plat.whl
+* cp*-cpxy-plat.whl or cp*-cpxyt-plat.whl
 
 Note, that here the specialization of musl vs manylinux wheels is the same in
 order to ensure that the matching fails if the user requests for `musl` and we don't have it or vice versa.
@@ -46,19 +47,24 @@
     **{
         f: str(Label("//python/config_settings:" + f))
         for f in [
-            "python_version",
+            "is_pip_whl_auto",
+            "is_pip_whl_no",
+            "is_pip_whl_only",
+            "is_py_freethreaded",
+            "is_py_non_freethreaded",
             "pip_whl_glibc_version",
             "pip_whl_muslc_version",
             "pip_whl_osx_arch",
             "pip_whl_osx_version",
             "py_linux_libc",
-            "is_pip_whl_no",
-            "is_pip_whl_only",
-            "is_pip_whl_auto",
+            "python_version",
         ]
     }
 )
 
+_DEFAULT = "//conditions:default"
+_INCOMPATIBLE = "@platforms//:incompatible"
+
 # Here we create extra string flags that are just to work with the select
 # selecting the most specialized match. We don't allow the user to change
 # them.
@@ -170,52 +176,70 @@
         **kwargs
     )
 
-    for name, f in [
-        ("py_none", _flags.whl_py2_py3),
-        ("py3_none", _flags.whl_py3),
-        ("py3_abi3", _flags.whl_py3_abi3),
-        ("cp3x_none", _flags.whl_pycp3x),
-        ("cp3x_abi3", _flags.whl_pycp3x_abi3),
-        ("cp3x_cp", _flags.whl_pycp3x_abicp),
+    used_flags = {}
+
+    # NOTE @aignas 2024-12-01: the abi3 is not compatible with freethreaded
+    # builds as per PEP703 (https://peps.python.org/pep-0703/#backwards-compatibility)
+    #
+    # The discussion here also reinforces this notion:
+    # https://discuss.python.org/t/pep-703-making-the-global-interpreter-lock-optional-3-12-updates/26503/99
+
+    for name, f, abi in [
+        ("py_none", _flags.whl_py2_py3, None),
+        ("py3_none", _flags.whl_py3, None),
+        ("py3_abi3", _flags.whl_py3_abi3, (FLAGS.is_py_non_freethreaded,)),
+        ("cp3x_none", _flags.whl_pycp3x, None),
+        ("cp3x_abi3", _flags.whl_pycp3x_abi3, (FLAGS.is_py_non_freethreaded,)),
+        # The below are not specializations of one another, they are variants
+        ("cp3x_cp", _flags.whl_pycp3x_abicp, (FLAGS.is_py_non_freethreaded,)),
+        ("cp3x_cpt", _flags.whl_pycp3x_abicp, (FLAGS.is_py_freethreaded,)),
     ]:
-        if f in flag_values:
+        if (f, abi) in used_flags:
             # This should never happen as all of the different whls should have
-            # unique flag values.
+            # unique flag values
             fail("BUG: the flag {} is attempted to be added twice to the list".format(f))
         else:
             flag_values[f] = ""
+            used_flags[(f, abi)] = True
 
         _dist_config_setting(
             name = "{}_any{}".format(name, suffix),
             flag_values = flag_values,
             is_pip_whl = FLAGS.is_pip_whl_only,
+            abi = abi,
             **kwargs
         )
 
     generic_flag_values = flag_values
+    generic_used_flags = used_flags
 
     for (suffix, flag_values) in plat_flag_values:
+        used_flags = {(f, None): True for f in flag_values} | generic_used_flags
         flag_values = flag_values | generic_flag_values
 
-        for name, f in [
-            ("py_none", _flags.whl_plat),
-            ("py3_none", _flags.whl_plat_py3),
-            ("py3_abi3", _flags.whl_plat_py3_abi3),
-            ("cp3x_none", _flags.whl_plat_pycp3x),
-            ("cp3x_abi3", _flags.whl_plat_pycp3x_abi3),
-            ("cp3x_cp", _flags.whl_plat_pycp3x_abicp),
+        for name, f, abi in [
+            ("py_none", _flags.whl_plat, None),
+            ("py3_none", _flags.whl_plat_py3, None),
+            ("py3_abi3", _flags.whl_plat_py3_abi3, (FLAGS.is_py_non_freethreaded,)),
+            ("cp3x_none", _flags.whl_plat_pycp3x, None),
+            ("cp3x_abi3", _flags.whl_plat_pycp3x_abi3, (FLAGS.is_py_non_freethreaded,)),
+            # The below are not specializations of one another, they are variants
+            ("cp3x_cp", _flags.whl_plat_pycp3x_abicp, (FLAGS.is_py_non_freethreaded,)),
+            ("cp3x_cpt", _flags.whl_plat_pycp3x_abicp, (FLAGS.is_py_freethreaded,)),
         ]:
-            if f in flag_values:
+            if (f, abi) in used_flags:
                 # This should never happen as all of the different whls should have
                 # unique flag values.
                 fail("BUG: the flag {} is attempted to be added twice to the list".format(f))
             else:
                 flag_values[f] = ""
+                used_flags[(f, abi)] = True
 
             _dist_config_setting(
                 name = "{}_{}".format(name, suffix),
                 flag_values = flag_values,
                 is_pip_whl = FLAGS.is_pip_whl_only,
+                abi = abi,
                 **kwargs
             )
 
@@ -285,7 +309,7 @@
 
     return ret
 
-def _dist_config_setting(*, name, is_python, python_version, is_pip_whl = None, native = native, **kwargs):
+def _dist_config_setting(*, name, is_python, python_version, is_pip_whl = None, abi = None, native = native, **kwargs):
     """A macro to create a target that matches is_pip_whl_auto and one more value.
 
     Args:
@@ -294,6 +318,10 @@
             `is_pip_whl_auto` when evaluating the config setting.
         is_python: The python version config_setting to match.
         python_version: The python version name.
+        abi: {type}`tuple[Label]` A collection of ABI config settings that are
+            compatible with the given dist config setting. For example, if only
+            non-freethreaded python builds are allowed, add
+            FLAGS.is_py_non_freethreaded here.
         native (struct): The struct containing alias and config_setting rules
             to use for creating the objects. Can be overridden for unit tests
             reasons.
@@ -306,9 +334,9 @@
     native.alias(
         name = "is_cp{}_{}".format(python_version, name) if python_version else "is_{}".format(name),
         actual = select({
-            # First match by the python version
-            is_python: _name,
-            "//conditions:default": is_python,
+            # First match by the python version and then by ABI
+            is_python: _name + ("_abi" if abi else ""),
+            _DEFAULT: _INCOMPATIBLE,
         }),
         visibility = visibility,
     )
@@ -325,12 +353,23 @@
     config_setting_name = _name + "_setting"
     native.config_setting(name = config_setting_name, **kwargs)
 
+    if abi:
+        native.alias(
+            name = _name + "_abi",
+            actual = select(
+                {k: _name for k in abi} | {
+                    _DEFAULT: _INCOMPATIBLE,
+                },
+            ),
+            visibility = visibility,
+        )
+
     # Next match by the `pip_whl` flag value and then match by the flags that
     # are intrinsic to the distribution.
     native.alias(
         name = _name,
         actual = select({
-            "//conditions:default": FLAGS.is_pip_whl_auto,
+            _DEFAULT: _INCOMPATIBLE,
             FLAGS.is_pip_whl_auto: config_setting_name,
             is_pip_whl: config_setting_name,
         }),
diff --git a/python/private/pypi/pkg_aliases.bzl b/python/private/pypi/pkg_aliases.bzl
index 5a3f841..a6872fd 100644
--- a/python/private/pypi/pkg_aliases.bzl
+++ b/python/private/pypi/pkg_aliases.bzl
@@ -308,7 +308,9 @@
         else:
             py = "py3"
 
-        if parsed.abi_tag.startswith("cp"):
+        if parsed.abi_tag.startswith("cp") and parsed.abi_tag.endswith("t"):
+            abi = "cpt"
+        elif parsed.abi_tag.startswith("cp"):
             abi = "cp"
         else:
             abi = parsed.abi_tag
diff --git a/python/private/pypi/whl_library.bzl b/python/private/pypi/whl_library.bzl
index 612ca2c..79a58a8 100644
--- a/python/private/pypi/whl_library.bzl
+++ b/python/private/pypi/whl_library.bzl
@@ -287,7 +287,7 @@
                 p.target_platform
                 for p in whl_target_platforms(
                     platform_tag = parsed_whl.platform_tag,
-                    abi_tag = parsed_whl.abi_tag,
+                    abi_tag = parsed_whl.abi_tag.strip("tm"),
                 )
             ]
 
diff --git a/python/private/pypi/whl_target_platforms.bzl b/python/private/pypi/whl_target_platforms.bzl
index bdc44c6..6823199 100644
--- a/python/private/pypi/whl_target_platforms.bzl
+++ b/python/private/pypi/whl_target_platforms.bzl
@@ -89,6 +89,10 @@
         want_abis[abi] = None
         want_abis[abi + "m"] = None
 
+        # Also add freethreaded wheels if we find them since we started supporting them
+        _want_platforms["{}t_{}".format(abi, os_cpu)] = None
+        want_abis[abi + "t"] = None
+
     want_platforms = sorted(_want_platforms)
 
     candidates = {}
diff --git a/tests/pypi/config_settings/config_settings_tests.bzl b/tests/pypi/config_settings/config_settings_tests.bzl
index a77fa5b..049556a 100644
--- a/tests/pypi/config_settings/config_settings_tests.bzl
+++ b/tests/pypi/config_settings/config_settings_tests.bzl
@@ -39,6 +39,7 @@
     pip_whl_osx_arch = lambda x: (str(Label("//python/config_settings:pip_whl_osx_arch")), str(x)),
     py_linux_libc = lambda x: (str(Label("//python/config_settings:py_linux_libc")), str(x)),
     python_version = lambda x: (str(Label("//python/config_settings:python_version")), str(x)),
+    py_freethreaded = lambda x: (str(Label("//python/config_settings:py_freethreaded")), str(x)),
 )
 
 def _analysis_test(*, name, dist, want, config_settings = [_flag.platform("linux_aarch64")]):
@@ -286,6 +287,38 @@
 
 _tests.append(_test_py_none_any_versioned)
 
+def _test_cp_whl_is_not_prefered_over_py3_non_freethreaded(name):
+    _analysis_test(
+        name = name,
+        dist = {
+            "is_cp3.7_cp3x_abi3_any": "py3_abi3",
+            "is_cp3.7_cp3x_cpt_any": "cp",
+            "is_cp3.7_cp3x_none_any": "py3",
+        },
+        want = "py3_abi3",
+        config_settings = [
+            _flag.py_freethreaded("no"),
+        ],
+    )
+
+_tests.append(_test_cp_whl_is_not_prefered_over_py3_non_freethreaded)
+
+def _test_cp_whl_is_not_prefered_over_py3_freethreaded(name):
+    _analysis_test(
+        name = name,
+        dist = {
+            "is_cp3.7_cp3x_abi3_any": "py3_abi3",
+            "is_cp3.7_cp3x_cp_any": "cp",
+            "is_cp3.7_cp3x_none_any": "py3",
+        },
+        want = "py3",
+        config_settings = [
+            _flag.py_freethreaded("yes"),
+        ],
+    )
+
+_tests.append(_test_cp_whl_is_not_prefered_over_py3_freethreaded)
+
 def _test_cp_cp_whl(name):
     _analysis_test(
         name = name,
@@ -412,6 +445,7 @@
         name = name,
         dist = {
             "is_cp3.7_cp3x_cp_windows_x86_64": "whl",
+            "is_cp3.7_cp3x_cpt_windows_x86_64": "whl_freethreaded",
         },
         want = "whl",
         config_settings = [
@@ -421,6 +455,22 @@
 
 _tests.append(_test_windows)
 
+def _test_windows_freethreaded(name):
+    _analysis_test(
+        name = name,
+        dist = {
+            "is_cp3.7_cp3x_cp_windows_x86_64": "whl",
+            "is_cp3.7_cp3x_cpt_windows_x86_64": "whl_freethreaded",
+        },
+        want = "whl_freethreaded",
+        config_settings = [
+            _flag.platform("windows_x86_64"),
+            _flag.py_freethreaded("yes"),
+        ],
+    )
+
+_tests.append(_test_windows_freethreaded)
+
 def _test_osx(name):
     _analysis_test(
         name = name,
diff --git a/tests/pypi/pkg_aliases/pkg_aliases_test.bzl b/tests/pypi/pkg_aliases/pkg_aliases_test.bzl
index 0fa66d0..23a0f01 100644
--- a/tests/pypi/pkg_aliases/pkg_aliases_test.bzl
+++ b/tests/pypi/pkg_aliases/pkg_aliases_test.bzl
@@ -288,6 +288,14 @@
             version = "3.1",
         ): "foo-py3-0.0.1",
         whl_config_setting(
+            filename = "foo-0.0.1-cp313-cp313-any.whl",
+            version = "3.1",
+        ): "foo-cp-0.0.1",
+        whl_config_setting(
+            filename = "foo-0.0.1-cp313-cp313t-any.whl",
+            version = "3.1",
+        ): "foo-cpt-0.0.1",
+        whl_config_setting(
             filename = "foo-0.0.2-py3-none-any.whl",
             version = "3.1",
             target_platforms = [
@@ -303,6 +311,8 @@
         osx_versions = [],
     )
     want = {
+        "//_config:is_cp3.1_cp3x_cp_any": "foo-cp-0.0.1",
+        "//_config:is_cp3.1_cp3x_cpt_any": "foo-cpt-0.0.1",
         "//_config:is_cp3.1_py3_none_any": "foo-py3-0.0.1",
         "//_config:is_cp3.1_py3_none_any_linux_aarch64": "foo-0.0.2",
         "//_config:is_cp3.1_py3_none_any_linux_x86_64": "foo-0.0.2",
diff --git a/tests/pypi/whl_target_platforms/select_whl_tests.bzl b/tests/pypi/whl_target_platforms/select_whl_tests.bzl
index 2994bd5..8ab2413 100644
--- a/tests/pypi/whl_target_platforms/select_whl_tests.bzl
+++ b/tests/pypi/whl_target_platforms/select_whl_tests.bzl
@@ -27,6 +27,10 @@
     "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-cp313-cp313t-musllinux_1_1_x86_64.whl",
+    "pkg-0.0.1-cp313-cp313-musllinux_1_1_x86_64.whl",
+    "pkg-0.0.1-cp313-abi3-musllinux_1_1_x86_64.whl",
+    "pkg-0.0.1-cp313-none-musllinux_1_1_x86_64.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",
@@ -269,6 +273,22 @@
 
 _tests.append(_test_prefer_manylinux_wheels)
 
+def _test_freethreaded_wheels(env):
+    # Check we prefer platform specific wheels
+    got = _select_whls(whls = WHL_LIST, want_platforms = ["cp313_linux_x86_64"])
+    _match(
+        env,
+        got,
+        "pkg-0.0.1-cp313-cp313t-musllinux_1_1_x86_64.whl",
+        "pkg-0.0.1-cp313-cp313-musllinux_1_1_x86_64.whl",
+        "pkg-0.0.1-cp313-abi3-musllinux_1_1_x86_64.whl",
+        "pkg-0.0.1-cp313-none-musllinux_1_1_x86_64.whl",
+        "pkg-0.0.1-cp39-abi3-any.whl",
+        "pkg-0.0.1-py3-none-any.whl",
+    )
+
+_tests.append(_test_freethreaded_wheels)
+
 def select_whl_test_suite(name):
     """Create the test suite.