refactor: add a version label function for consistent labels (#1328)

Before this PR there would be at least a few places where we would be
converting a `X.Y.Z` version string to a shortened `X_Y` or `XY` string
segment
to be used in repository rule labels. This PR adds a small utility
function
that helps making things consistent.

Work towards #1262, split from #1294.
diff --git a/python/extensions/pip.bzl b/python/extensions/pip.bzl
index 2534dea..add69a4 100644
--- a/python/extensions/pip.bzl
+++ b/python/extensions/pip.bzl
@@ -27,6 +27,7 @@
 )
 load("@rules_python//python/pip_install:requirements_parser.bzl", parse_requirements = "parse")
 load("//python/private:normalize_name.bzl", "normalize_name")
+load("//python/private:version_label.bzl", "version_label")
 
 def _whl_mods_impl(mctx):
     """Implementation of the pip.whl_mods tag class.
@@ -84,7 +85,7 @@
     # we programtically find it.
     hub_name = pip_attr.hub_name
     if python_interpreter_target == None:
-        python_name = "python_{}".format(pip_attr.python_version.replace(".", "_"))
+        python_name = "python_" + version_label(pip_attr.python_version, sep = "_")
         if python_name not in INTERPRETER_LABELS.keys():
             fail((
                 "Unable to find interpreter for pip hub '{hub_name}' for " +
@@ -96,7 +97,10 @@
             ))
         python_interpreter_target = INTERPRETER_LABELS[python_name]
 
-    pip_name = hub_name + "_{}".format(pip_attr.python_version.replace(".", ""))
+    pip_name = "{}_{}".format(
+        hub_name,
+        version_label(pip_attr.python_version),
+    )
     requrements_lock = locked_requirements_label(module_ctx, pip_attr)
 
     # Parse the requirements file directly in starlark to get the information
diff --git a/python/private/coverage_deps.bzl b/python/private/coverage_deps.bzl
index 8d1e5f4..93938e9 100644
--- a/python/private/coverage_deps.bzl
+++ b/python/private/coverage_deps.bzl
@@ -17,6 +17,7 @@
 
 load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
 load("@bazel_tools//tools/build_defs/repo:utils.bzl", "maybe")
+load("//python/private:version_label.bzl", "version_label")
 
 # Update with './tools/update_coverage_deps.py <version>'
 #START: managed by update_coverage_deps.py script
@@ -116,8 +117,7 @@
         # for now as it is not actionable.
         return None
 
-    python_short_version = python_version.rpartition(".")[0]
-    abi = python_short_version.replace("3.", "cp3")
+    abi = "cp" + version_label(python_version)
     url, sha256 = _coverage_deps.get(abi, {}).get(platform, (None, ""))
 
     if url == None:
diff --git a/python/private/version_label.bzl b/python/private/version_label.bzl
new file mode 100644
index 0000000..1bca92c
--- /dev/null
+++ b/python/private/version_label.bzl
@@ -0,0 +1,36 @@
+# 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.
+
+""
+
+def version_label(version, *, sep = ""):
+    """A version fragment derived from python minor version
+
+    Examples:
+        version_label("3.9") == "39"
+        version_label("3.9.12", sep="_") == "3_9"
+        version_label("3.11") == "311"
+
+    Args:
+        version: Python version.
+        sep: The separator between major and minor version numbers, defaults
+            to an empty string.
+
+    Returns:
+        The fragment of the version.
+    """
+    major, _, version = version.partition(".")
+    minor, _, _ = version.partition(".")
+
+    return major + sep + minor
diff --git a/tests/version_label/BUILD.bazel b/tests/version_label/BUILD.bazel
new file mode 100644
index 0000000..1dcfece
--- /dev/null
+++ b/tests/version_label/BUILD.bazel
@@ -0,0 +1,17 @@
+# 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(":version_label_test.bzl", "version_label_test_suite")
+
+version_label_test_suite(name = "version_label_tests")
diff --git a/tests/version_label/version_label_test.bzl b/tests/version_label/version_label_test.bzl
new file mode 100644
index 0000000..b4ed6f9
--- /dev/null
+++ b/tests/version_label/version_label_test.bzl
@@ -0,0 +1,52 @@
+# 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("//python/private:version_label.bzl", "version_label")  # buildifier: disable=bzl-visibility
+
+_tests = []
+
+def _test_version_label_from_major_minor_version(env):
+    actual = version_label("3.9")
+    env.expect.that_str(actual).equals("39")
+
+_tests.append(_test_version_label_from_major_minor_version)
+
+def _test_version_label_from_major_minor_patch_version(env):
+    actual = version_label("3.9.3")
+    env.expect.that_str(actual).equals("39")
+
+_tests.append(_test_version_label_from_major_minor_patch_version)
+
+def _test_version_label_from_major_minor_version_custom_sep(env):
+    actual = version_label("3.9", sep = "_")
+    env.expect.that_str(actual).equals("3_9")
+
+_tests.append(_test_version_label_from_major_minor_version_custom_sep)
+
+def _test_version_label_from_complex_version(env):
+    actual = version_label("3.9.3-rc.0")
+    env.expect.that_str(actual).equals("39")
+
+_tests.append(_test_version_label_from_complex_version)
+
+def version_label_test_suite(name):
+    """Create the test suite.
+
+    Args:
+        name: the name of the test suite
+    """
+    test_suite(name = name, basic_tests = _tests)