feat(toolchain): drop 3.8 and print info level messages about it (#3387)

Before this PR we had to have at least one 3.8 toolchain to not break
things.
With this we should be good to drop it.

Any python_version 3.8 registrations will be dropped if there are no
actual
URLs configured, which means that 3.8 will not be selected. The same
with
pip.parse, we will just ignore it and won't add it to the hub. 

In order to ensure that `is_python_3.x` flags continue working, we
just alias them to `@platforms//:incompatible`. No deprecation message
is
printed.

Work towards #2704 

Next step for anyone interested and who has more time than me these
days:
- [ ] Remove the 3.9 URLs and add them individually to our examples to
show
      that one can do that.
- [ ] Update the examples to no longer use 3.9, because it is a
maintenance burden.
diff --git a/python/private/config_settings.bzl b/python/private/config_settings.bzl
index 3089b9c..91fbbba 100644
--- a/python/private/config_settings.bzl
+++ b/python/private/config_settings.bzl
@@ -35,7 +35,14 @@
 # access it, but it's not intended for general public usage.
 _NOT_ACTUALLY_PUBLIC = ["//visibility:public"]
 
-def construct_config_settings(*, name, default_version, versions, minor_mapping, documented_flags):  # buildifier: disable=function-docstring
+def construct_config_settings(
+        *,
+        name,
+        default_version,
+        versions,
+        minor_mapping,
+        compat_lowest_version = "3.8",
+        documented_flags):  # buildifier: disable=function-docstring
     """Create a 'python_version' config flag and construct all config settings used in rules_python.
 
     This mainly includes the targets that are used in the toolchain and pip hub
@@ -46,6 +53,8 @@
         default_version: {type}`str` the default value for the `python_version` flag.
         versions: {type}`list[str]` A list of versions to build constraint settings for.
         minor_mapping: {type}`dict[str, str]` A mapping from `X.Y` to `X.Y.Z` python versions.
+        compat_lowest_version: {type}`str` The version that we should use as the lowest available
+            version for `is_python_3.X` flags.
         documented_flags: {type}`list[str]` The labels of the documented settings
             that affect build configuration.
     """
@@ -69,21 +78,21 @@
     )
 
     _reverse_minor_mapping = {full: minor for minor, full in minor_mapping.items()}
-    for version in versions:
-        minor_version = _reverse_minor_mapping.get(version)
+    for ver in versions:
+        minor_version = _reverse_minor_mapping.get(ver)
         if not minor_version:
             native.config_setting(
-                name = "is_python_{}".format(version),
-                flag_values = {":python_version": version},
+                name = "is_python_{}".format(ver),
+                flag_values = {":python_version": ver},
                 visibility = ["//visibility:public"],
             )
             continue
 
         # Also need to match the minor version when using
-        name = "is_python_{}".format(version)
+        name = "is_python_{}".format(ver)
         native.config_setting(
             name = "_" + name,
-            flag_values = {":python_version": version},
+            flag_values = {":python_version": ver},
             visibility = ["//visibility:public"],
         )
 
@@ -94,7 +103,7 @@
         selects.config_setting_group(
             name = "_{}_group".format(name),
             match_any = [
-                ":_is_python_{}".format(version),
+                ":_is_python_{}".format(ver),
                 ":is_python_{}".format(minor_version),
             ],
             visibility = ["//visibility:private"],
@@ -109,13 +118,28 @@
     # It's private because matching the concept of e.g. "3.8" value is done
     # using the `is_python_X.Y` config setting group, which is aware of the
     # minor versions that could match instead.
+    first_minor = None
     for minor in minor_mapping.keys():
+        ver = version.parse(minor)
+        if first_minor == None or version.is_lt(ver, first_minor):
+            first_minor = ver
+
         native.config_setting(
             name = "is_python_{}".format(minor),
             flag_values = {_PYTHON_VERSION_MAJOR_MINOR_FLAG: minor},
             visibility = ["//visibility:public"],
         )
 
+    # This is a compatibility layer to ensure that `select` statements don't break out right
+    # when the toolchains for EOL minor versions are no longer registered.
+    compat_lowest_version = version.parse(compat_lowest_version)
+    for minor in range(compat_lowest_version.release[-1], first_minor.release[-1]):
+        native.alias(
+            name = "is_python_3.{}".format(minor),
+            actual = "@platforms//:incompatible",
+            visibility = ["//visibility:public"],
+        )
+
     _current_config(
         name = "current_config",
         build_setting_default = "",
diff --git a/python/private/full_version.bzl b/python/private/full_version.bzl
index 0292d6c..0be5b44 100644
--- a/python/private/full_version.bzl
+++ b/python/private/full_version.bzl
@@ -14,12 +14,13 @@
 
 """A small helper to ensure that we are working with full versions."""
 
-def full_version(*, version, minor_mapping):
+def full_version(*, version, minor_mapping, fail_on_err = True):
     """Return a full version.
 
     Args:
         version: {type}`str` the version in `X.Y` or `X.Y.Z` format.
         minor_mapping: {type}`dict[str, str]` mapping between `X.Y` to `X.Y.Z` format.
+        fail_on_err: {type}`bool` whether to fail on error or return `None` instead.
 
     Returns:
         a full version given the version string. If the string is already a
@@ -31,6 +32,8 @@
     parts = version.split(".")
     if len(parts) == 3:
         return version
+    elif not fail_on_err:
+        return None
     elif len(parts) == 2:
         fail(
             "Unknown Python version '{}', available values are: {}".format(
diff --git a/python/private/pypi/hub_builder.bzl b/python/private/pypi/hub_builder.bzl
index 7cf60ff..bd60081 100644
--- a/python/private/pypi/hub_builder.bzl
+++ b/python/private/pypi/hub_builder.bzl
@@ -114,9 +114,29 @@
             version = python_version,
         ))
 
-    self._platforms[python_version] = _platforms(
-        python_version = python_version,
+    full_python_version = full_version(
+        version = python_version,
         minor_mapping = self._minor_mapping,
+        fail_on_err = False,
+    )
+    if not full_python_version:
+        # NOTE @aignas 2025-11-18: If the python version is not present in our
+        # minor_mapping, then we will not register any packages and then the
+        # select in the hub repository will fail, which will prompt the user to
+        # configure the toolchain correctly and move forward.
+        self._logger.info(lambda: (
+            "Ignoring pip python version '{version}' for hub " +
+            "'{hub}' in module '{module}' because there is no registered " +
+            "toolchain for it."
+        ).format(
+            hub = self.name,
+            module = self.module_name,
+            version = python_version,
+        ))
+        return
+
+    self._platforms[python_version] = _platforms(
+        python_version = full_python_version,
         config = self._config,
     )
     _set_get_index_urls(self, pip_attr)
@@ -280,13 +300,10 @@
         path = pip_attr.python_interpreter,
     )
 
-def _platforms(*, python_version, minor_mapping, config):
+def _platforms(*, python_version, config):
     platforms = {}
     python_version = version.parse(
-        full_version(
-            version = python_version,
-            minor_mapping = minor_mapping,
-        ),
+        python_version,
         strict = True,
     )
 
diff --git a/python/private/python.bzl b/python/private/python.bzl
index a1fe80e..22f4753 100644
--- a/python/private/python.bzl
+++ b/python/private/python.bzl
@@ -268,7 +268,18 @@
         full_python_version = full_version(
             version = toolchain_info.python_version,
             minor_mapping = py.config.minor_mapping,
+            fail_on_err = False,
         )
+        if not full_python_version:
+            logger.info(lambda: (
+                "The actual toolchain for python_version '{version}' " +
+                "has not been registered, but was requested, please configure a toolchain " +
+                "to be actually downloaded and setup"
+            ).format(
+                version = toolchain_info.python_version,
+            ))
+            continue
+
         kwargs = {
             "python_version": full_python_version,
             "register_coverage_tool": toolchain_info.register_coverage_tool,
diff --git a/python/versions.bzl b/python/versions.bzl
index 7e1b36b..842fb39 100644
--- a/python/versions.bzl
+++ b/python/versions.bzl
@@ -54,17 +54,6 @@
 #
 # buildifier: disable=unsorted-dict-items
 TOOL_VERSIONS = {
-    "3.8.20": {
-        "url": "20241002/cpython-{python_version}+20241002-{platform}-{build}.tar.gz",
-        "sha256": {
-            "aarch64-apple-darwin": "2ddfc04bdb3e240f30fb782fa1deec6323799d0e857e0b63fa299218658fd3d4",
-            "aarch64-unknown-linux-gnu": "9d8798f9e79e0fc0f36fcb95bfa28a1023407d51a8ea5944b4da711f1f75f1ed",
-            "x86_64-apple-darwin": "68d060cd373255d2ca5b8b3441363d5aa7cc45b0c11bbccf52b1717c2b5aa8bb",
-            "x86_64-pc-windows-msvc": "41b6709fec9c56419b7de1940d1f87fa62045aff81734480672dcb807eedc47e",
-            "x86_64-unknown-linux-gnu": "285e141c36f88b2e9357654c5f77d1f8fb29cc25132698fe35bb30d787f38e87",
-        },
-        "strip_prefix": "python",
-    },
     "3.9.25": {
         "url": "20251031/cpython-{python_version}+20251031-{platform}-{build}.tar.gz",
         "sha256": {
@@ -872,7 +861,6 @@
 
 # buildifier: disable=unsorted-dict-items
 MINOR_MAPPING = {
-    "3.8": "3.8.20",
     "3.9": "3.9.25",
     "3.10": "3.10.19",
     "3.11": "3.11.14",
diff --git a/tests/python/python_tests.bzl b/tests/python/python_tests.bzl
index f2e8727..ff02cc8 100644
--- a/tests/python/python_tests.bzl
+++ b/tests/python/python_tests.bzl
@@ -707,6 +707,77 @@
 
 _tests.append(_test_register_all_versions)
 
+def _test_ignore_unsupported_versions(env):
+    py = parse_modules(
+        module_ctx = _mock_mctx(
+            _mod(
+                name = "my_module",
+                is_root = True,
+                toolchain = [
+                    _toolchain("3.11"),
+                    _toolchain("3.12"),
+                    _toolchain("3.13", is_default = True),
+                ],
+                single_version_override = [
+                    _single_version_override(
+                        python_version = "3.13.0",
+                        sha256 = {
+                            "aarch64-unknown-linux-gnu": "deadbeef",
+                        },
+                        urls = ["example.org"],
+                    ),
+                ],
+                single_version_platform_override = [
+                    _single_version_platform_override(
+                        sha256 = "deadb00f",
+                        urls = ["something.org"],
+                        platform = "aarch64-unknown-linux-gnu",
+                        python_version = "3.13.99",
+                    ),
+                ],
+                override = [
+                    _override(
+                        base_url = "",
+                        available_python_versions = ["3.12.4", "3.13.0", "3.13.1"],
+                        minor_mapping = {
+                            "3.12": "3.12.4",
+                            "3.13": "3.13.1",
+                        },
+                    ),
+                ],
+            ),
+        ),
+        logger = repo_utils.logger(verbosity_level = 0, name = "python"),
+    )
+
+    env.expect.that_str(py.default_python_version).equals("3.13")
+    env.expect.that_collection(py.config.default["tool_versions"].keys()).contains_exactly([
+        "3.12.4",
+        "3.13.0",
+        "3.13.1",
+    ])
+    env.expect.that_dict(py.config.minor_mapping).contains_exactly({
+        # The mapping is calculated automatically
+        "3.12": "3.12.4",
+        "3.13": "3.13.1",
+    })
+    env.expect.that_collection(py.toolchains).contains_exactly([
+        struct(
+            name = name,
+            python_version = version,
+            register_coverage_tool = False,
+        )
+        for name, version in {
+            # NOTE: that '3.11' wont be actually registered and present in the
+            # `tool_versions` above.
+            "python_3_11": "3.11",
+            "python_3_12": "3.12",
+            "python_3_13": "3.13",
+        }.items()
+    ])
+
+_tests.append(_test_ignore_unsupported_versions)
+
 def _test_add_patches(env):
     py = parse_modules(
         module_ctx = _mock_mctx(