refactor: consolidate version parsing (#2874)
This PR removes all of the custom version parsing functions where we try
to make sense about the version (e.g. extracting major/minor versions).
Whilst doing this I actually think that I made it easier to support
#2837.
diff --git a/python/private/BUILD.bazel b/python/private/BUILD.bazel
index e72a8fc..0b50ccf 100644
--- a/python/private/BUILD.bazel
+++ b/python/private/BUILD.bazel
@@ -139,7 +139,7 @@
name = "config_settings_bzl",
srcs = ["config_settings.bzl"],
deps = [
- ":semver_bzl",
+ ":version_bzl",
"@bazel_skylib//lib:selects",
"@bazel_skylib//rules:common_settings",
],
@@ -249,9 +249,9 @@
":python_register_toolchains_bzl",
":pythons_hub_bzl",
":repo_utils_bzl",
- ":semver_bzl",
":toolchains_repo_bzl",
":util_bzl",
+ ":version_bzl",
"@bazel_features//:features",
],
)
@@ -611,11 +611,6 @@
)
bzl_library(
- name = "semver_bzl",
- srcs = ["semver.bzl"],
-)
-
-bzl_library(
name = "sentinel_bzl",
srcs = ["sentinel.bzl"],
)
diff --git a/python/private/config_settings.bzl b/python/private/config_settings.bzl
index 5eb858e..aff5d01 100644
--- a/python/private/config_settings.bzl
+++ b/python/private/config_settings.bzl
@@ -18,7 +18,7 @@
load("@bazel_skylib//lib:selects.bzl", "selects")
load("@bazel_skylib//rules:common_settings.bzl", "BuildSettingInfo")
load("//python/private:text_util.bzl", "render")
-load(":semver.bzl", "semver")
+load(":version.bzl", "version")
_PYTHON_VERSION_FLAG = Label("//python/config_settings:python_version")
_PYTHON_VERSION_MAJOR_MINOR_FLAG = Label("//python/config_settings:python_version_major_minor")
@@ -181,8 +181,8 @@
def _python_version_major_minor_flag_impl(ctx):
input = _flag_value(ctx.attr._python_version_flag)
if input:
- version = semver(input)
- value = "{}.{}".format(version.major, version.minor)
+ ver = version.parse(input)
+ value = "{}.{}".format(ver.release[0], ver.release[1])
else:
value = ""
diff --git a/python/private/hermetic_runtime_repo_setup.bzl b/python/private/hermetic_runtime_repo_setup.bzl
index 64d721e..f944b0b 100644
--- a/python/private/hermetic_runtime_repo_setup.bzl
+++ b/python/private/hermetic_runtime_repo_setup.bzl
@@ -20,7 +20,7 @@
load("//python/cc:py_cc_toolchain.bzl", "py_cc_toolchain")
load(":glob_excludes.bzl", "glob_excludes")
load(":py_exec_tools_toolchain.bzl", "py_exec_tools_toolchain")
-load(":semver.bzl", "semver")
+load(":version.bzl", "version")
_IS_FREETHREADED = Label("//python/config_settings:is_py_freethreaded")
@@ -53,8 +53,11 @@
use.
"""
_ = name # @unused
- version_info = semver(python_version)
- version_dict = version_info.to_dict()
+ version_info = version.parse(python_version)
+ version_dict = {
+ "major": version_info.release[0],
+ "minor": version_info.release[1],
+ }
native.filegroup(
name = "files",
srcs = native.glob(
@@ -198,9 +201,9 @@
files = [":files"],
interpreter = python_bin,
interpreter_version_info = {
- "major": str(version_info.major),
- "micro": str(version_info.patch),
- "minor": str(version_info.minor),
+ "major": str(version_info.release[0]),
+ "micro": str(version_info.release[2]),
+ "minor": str(version_info.release[1]),
},
coverage_tool = select({
# Convert empty string to None
diff --git a/python/private/pypi/BUILD.bazel b/python/private/pypi/BUILD.bazel
index 06ca3a8..84e0535 100644
--- a/python/private/pypi/BUILD.bazel
+++ b/python/private/pypi/BUILD.bazel
@@ -116,7 +116,7 @@
":whl_target_platforms_bzl",
"//python/private:full_version_bzl",
"//python/private:normalize_name_bzl",
- "//python/private:semver_bzl",
+ "//python/private:version_bzl",
"//python/private:version_label_bzl",
"@bazel_features//:features",
"@pythons_hub//:interpreters_bzl",
@@ -256,7 +256,7 @@
srcs = ["pep508_evaluate.bzl"],
deps = [
"//python/private:enum_bzl",
- "//python/private:semver_bzl",
+ "//python/private:version_bzl",
],
)
diff --git a/python/private/pypi/extension.bzl b/python/private/pypi/extension.bzl
index 84caa0a..3896f29 100644
--- a/python/private/pypi/extension.bzl
+++ b/python/private/pypi/extension.bzl
@@ -22,7 +22,7 @@
load("//python/private:full_version.bzl", "full_version")
load("//python/private:normalize_name.bzl", "normalize_name")
load("//python/private:repo_utils.bzl", "repo_utils")
-load("//python/private:semver.bzl", "semver")
+load("//python/private:version.bzl", "version")
load("//python/private:version_label.bzl", "version_label")
load(":attrs.bzl", "use_isolated")
load(":evaluate_markers.bzl", "evaluate_markers_py", EVALUATE_MARKERS_SRCS = "SRCS")
@@ -36,9 +36,9 @@
load(":whl_library.bzl", "whl_library")
load(":whl_repo_name.bzl", "pypi_repo_name", "whl_repo_name")
-def _major_minor_version(version):
- version = semver(version)
- return "{}.{}".format(version.major, version.minor)
+def _major_minor_version(version_str):
+ ver = version.parse(version_str)
+ return "{}.{}".format(ver.release[0], ver.release[1])
def _whl_mods_impl(whl_mods_dict):
"""Implementation of the pip.whl_mods tag class.
diff --git a/python/private/python.bzl b/python/private/python.bzl
index 53cd5e9..c187904 100644
--- a/python/private/python.bzl
+++ b/python/private/python.bzl
@@ -21,9 +21,9 @@
load(":python_register_toolchains.bzl", "python_register_toolchains")
load(":pythons_hub.bzl", "hub_repo")
load(":repo_utils.bzl", "repo_utils")
-load(":semver.bzl", "semver")
load(":toolchains_repo.bzl", "multi_toolchain_aliases")
load(":util.bzl", "IS_BAZEL_6_4_OR_HIGHER")
+load(":version.bzl", "version")
def parse_modules(*, module_ctx, _fail = fail):
"""Parse the modules and return a struct for registrations.
@@ -458,16 +458,20 @@
second = second,
))
-def _validate_version(*, version, _fail = fail):
- parsed = semver(version)
- if parsed.patch == None or parsed.build or parsed.pre_release:
- _fail("The 'python_version' attribute needs to specify an 'X.Y.Z' semver-compatible version, got: '{}'".format(version))
+def _validate_version(version_str, *, _fail = fail):
+ v = version.parse(version_str, strict = True, _fail = _fail)
+ if v == None:
+ # Only reachable in tests
+ return False
+
+ if len(v.release) < 3:
+ _fail("The 'python_version' attribute needs to specify the full version in at least 'X.Y.Z' format, got: '{}'".format(v.string))
return False
return True
def _process_single_version_overrides(*, tag, _fail = fail, default):
- if not _validate_version(version = tag.python_version, _fail = _fail):
+ if not _validate_version(tag.python_version, _fail = _fail):
return
available_versions = default["tool_versions"]
@@ -517,7 +521,7 @@
kwargs.setdefault(tag.python_version, {})["distutils"] = tag.distutils
def _process_single_version_platform_overrides(*, tag, _fail = fail, default):
- if not _validate_version(version = tag.python_version, _fail = _fail):
+ if not _validate_version(tag.python_version, _fail = _fail):
return
available_versions = default["tool_versions"]
@@ -558,12 +562,12 @@
if tag.minor_mapping:
for minor_version, full_version in tag.minor_mapping.items():
- parsed = semver(minor_version)
- if parsed.patch != None or parsed.build or parsed.pre_release:
- fail("Expected the key to be of `X.Y` format but got `{}`".format(minor_version))
- parsed = semver(full_version)
- if parsed.patch == None:
- fail("Expected the value to at least be of `X.Y.Z` format but got `{}`".format(minor_version))
+ parsed = version.parse(minor_version, strict = True, _fail = _fail)
+ if len(parsed.release) > 2 or parsed.pre or parsed.post or parsed.dev or parsed.local:
+ fail("Expected the key to be of `X.Y` format but got `{}`".format(parsed.string))
+
+ # Ensure that the version is valid
+ version.parse(full_version, strict = True, _fail = _fail)
default["minor_mapping"] = tag.minor_mapping
@@ -651,8 +655,11 @@
versions = {}
for version_string in available_versions:
- v = semver(version_string)
- versions.setdefault("{}.{}".format(v.major, v.minor), []).append((int(v.patch), version_string))
+ v = version.parse(version_string, strict = True)
+ versions.setdefault(
+ "{}.{}".format(v.release[0], v.release[1]),
+ [],
+ ).append((version.key(v), v.string))
minor_mapping = {
major_minor: max(subset)[1]
diff --git a/python/private/semver.bzl b/python/private/semver.bzl
deleted file mode 100644
index 0cbd172..0000000
--- a/python/private/semver.bzl
+++ /dev/null
@@ -1,85 +0,0 @@
-# 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 semver version parser"
-
-def _key(version):
- return (
- version.major,
- version.minor or 0,
- version.patch or 0,
- # non pre-release versions are higher
- version.pre_release == "",
- # then we compare each element of the pre_release tag separately
- tuple([
- (
- i if not i.isdigit() else "",
- # digit values take precedence
- int(i) if i.isdigit() else 0,
- )
- for i in version.pre_release.split(".")
- ]) if version.pre_release else None,
- # And build info is just alphabetic
- version.build,
- )
-
-def _to_dict(self):
- return {
- "build": self.build,
- "major": self.major,
- "minor": self.minor,
- "patch": self.patch,
- "pre_release": self.pre_release,
- }
-
-def _new(*, major, minor, patch, pre_release, build, version = None):
- # buildifier: disable=uninitialized
- self = struct(
- major = int(major),
- minor = None if minor == None else int(minor),
- # NOTE: this is called `micro` in the Python interpreter versioning scheme
- patch = None if patch == None else int(patch),
- pre_release = pre_release,
- build = build,
- # buildifier: disable=uninitialized
- key = lambda: _key(self),
- str = lambda: version,
- to_dict = lambda: _to_dict(self),
- )
- return self
-
-def semver(version):
- """Parse the semver version and return the values as a struct.
-
- Args:
- version: {type}`str` the version string.
-
- Returns:
- A {type}`struct` with `major`, `minor`, `patch` and `build` attributes.
- """
-
- # Implement the https://semver.org/ spec
- major, _, tail = version.partition(".")
- minor, _, tail = tail.partition(".")
- patch, _, build = tail.partition("+")
- patch, _, pre_release = patch.partition("-")
-
- return _new(
- major = int(major),
- minor = int(minor) if minor.isdigit() else None,
- patch = int(patch) if patch.isdigit() else None,
- build = build,
- pre_release = pre_release,
- version = version,
- )
diff --git a/python/private/version.bzl b/python/private/version.bzl
index 4425cc7..f98165d 100644
--- a/python/private/version.bzl
+++ b/python/private/version.bzl
@@ -510,7 +510,7 @@
"""
return _parse(version, strict = True)["norm"]
-def _parse(version_str, strict = True):
+def _parse(version_str, strict = True, _fail = fail):
"""Escape the version component of a filename.
See https://packaging.python.org/en/latest/specifications/binary-distribution-format/#escaping-and-unicode
@@ -519,6 +519,7 @@
Args:
version_str: version string to be normalized according to PEP 440.
strict: fail if the version is invalid, defaults to True.
+ _fail: Used for tests
Returns:
string containing the normalized version.
@@ -544,7 +545,7 @@
parser_ctx = parser.context()
if parser.input[parser_ctx["start"]:]:
if strict:
- fail(
+ _fail(
"Failed to parse PEP 440 version identifier '%s'." % parser.input,
"Parse error at '%s'" % parser.input[parser_ctx["start"]:],
)
@@ -554,7 +555,7 @@
parser_ctx["is_prefix"] = is_prefix
return parser_ctx
-def parse(version_str, strict = False):
+def parse(version_str, strict = False, _fail = fail):
"""Parse a PEP4408 compliant version.
This is similar to `normalize_pep440`, but it parses individual components to
@@ -563,6 +564,7 @@
Args:
version_str: version string to be normalized according to PEP 440.
strict: fail if the version is invalid.
+ _fail: used for tests
Returns:
a struct with individual components of a version:
@@ -580,29 +582,29 @@
* `string` {type}`str` normalized value of the input.
"""
- parts = _parse(version_str, strict = strict)
+ parts = _parse(version_str, strict = strict, _fail = _fail)
if not parts:
return None
if parts["is_prefix"] and (parts["local"] or parts["post"] or parts["dev"] or parts["pre"]):
if strict:
- fail("local version part has been obtained, but only public segments can have prefix matches")
+ _fail("local version part has been obtained, but only public segments can have prefix matches")
# https://peps.python.org/pep-0440/#public-version-identifiers
return None
return struct(
- epoch = _parse_epoch(parts["epoch"]),
+ epoch = _parse_epoch(parts["epoch"], _fail),
release = _parse_release(parts["release"]),
pre = _parse_pre(parts["pre"]),
- post = _parse_post(parts["post"]),
- dev = _parse_dev(parts["dev"]),
- local = _parse_local(parts["local"]),
+ post = _parse_post(parts["post"], _fail),
+ dev = _parse_dev(parts["dev"], _fail),
+ local = _parse_local(parts["local"], _fail),
string = parts["norm"],
is_prefix = parts["is_prefix"],
)
-def _parse_epoch(value):
+def _parse_epoch(value, fail):
if not value:
return 0
@@ -614,7 +616,7 @@
def _parse_release(value):
return tuple([int(d) for d in value.split(".")])
-def _parse_local(value):
+def _parse_local(value, fail):
if not value:
return None
@@ -624,7 +626,7 @@
# If the part is numerical, handle it as a number
return tuple([int(part) if part.isdigit() else part for part in value[1:].split(".")])
-def _parse_dev(value):
+def _parse_dev(value, fail):
if not value:
return None
@@ -646,7 +648,7 @@
return (prefix, int(value[len(prefix):]))
-def _parse_post(value):
+def _parse_post(value, fail):
if not value:
return None
diff --git a/tests/python/python_tests.bzl b/tests/python/python_tests.bzl
index 97c47b5..443174c 100644
--- a/tests/python/python_tests.bzl
+++ b/tests/python/python_tests.bzl
@@ -746,12 +746,6 @@
],
want_error = "Only a single 'python.single_version_override' can be present for '3.12.4'",
),
- struct(
- overrides = [
- _single_version_override(python_version = "3.12.4+3", distutils_content = "foo"),
- ],
- want_error = "The 'python_version' attribute needs to specify an 'X.Y.Z' semver-compatible version, got: '3.12.4+3'",
- ),
]:
errors = []
parse_modules(
@@ -781,13 +775,13 @@
overrides = [
_single_version_platform_override(python_version = "3.12", platform = "foo"),
],
- want_error = "The 'python_version' attribute needs to specify an 'X.Y.Z' semver-compatible version, got: '3.12'",
+ want_error = "The 'python_version' attribute needs to specify the full version in at least 'X.Y.Z' format, got: '3.12'",
),
struct(
overrides = [
- _single_version_platform_override(python_version = "3.12.1+my_build", platform = "foo"),
+ _single_version_platform_override(python_version = "foo", platform = "foo"),
],
- want_error = "The 'python_version' attribute needs to specify an 'X.Y.Z' semver-compatible version, got: '3.12.1+my_build'",
+ want_error = "Failed to parse PEP 440 version identifier 'foo'. Parse error at 'foo'",
),
]:
errors = []
@@ -799,7 +793,7 @@
single_version_platform_override = test.overrides,
),
),
- _fail = errors.append,
+ _fail = lambda *a: errors.append(" ".join(a)),
)
env.expect.that_collection(errors).contains_exactly([test.want_error])
diff --git a/tests/semver/BUILD.bazel b/tests/semver/BUILD.bazel
deleted file mode 100644
index e12b1e5..0000000
--- a/tests/semver/BUILD.bazel
+++ /dev/null
@@ -1,17 +0,0 @@
-# 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(":semver_test.bzl", "semver_test_suite")
-
-semver_test_suite(name = "semver_tests")
diff --git a/tests/semver/semver_test.bzl b/tests/semver/semver_test.bzl
deleted file mode 100644
index 9d13402..0000000
--- a/tests/semver/semver_test.bzl
+++ /dev/null
@@ -1,113 +0,0 @@
-# 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:semver.bzl", "semver") # buildifier: disable=bzl-visibility
-
-_tests = []
-
-def _test_semver_from_major(env):
- actual = semver("3")
- env.expect.that_int(actual.major).equals(3)
- env.expect.that_int(actual.minor).equals(None)
- env.expect.that_int(actual.patch).equals(None)
- env.expect.that_str(actual.build).equals("")
-
-_tests.append(_test_semver_from_major)
-
-def _test_semver_from_major_minor_version(env):
- actual = semver("4.9")
- env.expect.that_int(actual.major).equals(4)
- env.expect.that_int(actual.minor).equals(9)
- env.expect.that_int(actual.patch).equals(None)
- env.expect.that_str(actual.build).equals("")
-
-_tests.append(_test_semver_from_major_minor_version)
-
-def _test_semver_with_build_info(env):
- actual = semver("1.2.3+mybuild")
- env.expect.that_int(actual.major).equals(1)
- env.expect.that_int(actual.minor).equals(2)
- env.expect.that_int(actual.patch).equals(3)
- env.expect.that_str(actual.build).equals("mybuild")
-
-_tests.append(_test_semver_with_build_info)
-
-def _test_semver_with_build_info_multiple_pluses(env):
- actual = semver("1.2.3-rc0+build+info")
- env.expect.that_int(actual.major).equals(1)
- env.expect.that_int(actual.minor).equals(2)
- env.expect.that_int(actual.patch).equals(3)
- env.expect.that_str(actual.pre_release).equals("rc0")
- env.expect.that_str(actual.build).equals("build+info")
-
-_tests.append(_test_semver_with_build_info_multiple_pluses)
-
-def _test_semver_alpha_beta(env):
- actual = semver("1.2.3-alpha.beta")
- env.expect.that_int(actual.major).equals(1)
- env.expect.that_int(actual.minor).equals(2)
- env.expect.that_int(actual.patch).equals(3)
- env.expect.that_str(actual.pre_release).equals("alpha.beta")
-
-_tests.append(_test_semver_alpha_beta)
-
-def _test_semver_sort(env):
- want = [
- semver(item)
- for item in [
- # The items are sorted from lowest to highest version
- "0.0.1",
- "0.1.0-rc",
- "0.1.0",
- "0.9.11",
- "0.9.12",
- "1.0.0-alpha",
- "1.0.0-alpha.1",
- "1.0.0-alpha.beta",
- "1.0.0-beta",
- "1.0.0-beta.2",
- "1.0.0-beta.11",
- "1.0.0-rc.1",
- "1.0.0-rc.2",
- "1.0.0",
- # Also handle missing minor and patch version strings
- "2.0",
- "3",
- # Alphabetic comparison for different builds
- "3.0.0+build0",
- "3.0.0+build1",
- ]
- ]
- actual = sorted(want, key = lambda x: x.key())
- env.expect.that_collection(actual).contains_exactly(want).in_order()
- for i, greater in enumerate(want[1:]):
- smaller = actual[i]
- if greater.key() <= smaller.key():
- env.fail("Expected '{}' to be smaller than '{}', but got otherwise".format(
- smaller.str(),
- greater.str(),
- ))
-
-_tests.append(_test_semver_sort)
-
-def semver_test_suite(name):
- """Create the test suite.
-
- Args:
- name: the name of the test suite
- """
- test_suite(name = name, basic_tests = _tests)