fix(pypi): fixes to the marker evaluation and utils (#2767)
These are just bugfixes to already merged code:
* Fix nested bracket parsing in PEP508 marker parser.
* Fix the sys_platform constants, which I noticed in #2629 but they got
also pointed out in #2766.
* Port some of python tests for requirement parsing and improve the
implementation. Those tests will be removed in #2629.
* Move the platform related code to a separate file.
* Rename `pep508_req.bzl` to `pep508_requirement.bzl` to follow the
convention.
All of the bug fixes have added tests.
Work towards #2423.
diff --git a/python/private/pypi/BUILD.bazel b/python/private/pypi/BUILD.bazel
index 21e05f2..e0a2f20 100644
--- a/python/private/pypi/BUILD.bazel
+++ b/python/private/pypi/BUILD.bazel
@@ -77,7 +77,8 @@
deps = [
":pep508_env_bzl",
":pep508_evaluate_bzl",
- ":pep508_req_bzl",
+ ":pep508_platform_bzl",
+ ":pep508_requirement_bzl",
],
)
@@ -223,6 +224,9 @@
bzl_library(
name = "pep508_env_bzl",
srcs = ["pep508_env.bzl"],
+ deps = [
+ ":pep508_platform_bzl",
+ ],
)
bzl_library(
@@ -235,8 +239,13 @@
)
bzl_library(
- name = "pep508_req_bzl",
- srcs = ["pep508_req.bzl"],
+ name = "pep508_platform_bzl",
+ srcs = ["pep508_platform.bzl"],
+)
+
+bzl_library(
+ name = "pep508_requirement_bzl",
+ srcs = ["pep508_requirement.bzl"],
deps = [
"//python/private:normalize_name_bzl",
],
diff --git a/python/private/pypi/evaluate_markers.bzl b/python/private/pypi/evaluate_markers.bzl
index 1d4c307..a0223ab 100644
--- a/python/private/pypi/evaluate_markers.bzl
+++ b/python/private/pypi/evaluate_markers.bzl
@@ -14,9 +14,10 @@
"""A simple function that evaluates markers using a python interpreter."""
-load(":pep508_env.bzl", "env", _platform_from_str = "platform_from_str")
+load(":pep508_env.bzl", "env")
load(":pep508_evaluate.bzl", "evaluate")
-load(":pep508_req.bzl", _req = "requirement")
+load(":pep508_platform.bzl", "platform_from_str")
+load(":pep508_requirement.bzl", "requirement")
def evaluate_markers(requirements):
"""Return the list of supported platforms per requirements line.
@@ -29,9 +30,9 @@
"""
ret = {}
for req_string, platforms in requirements.items():
- req = _req(req_string)
+ req = requirement(req_string)
for platform in platforms:
- if evaluate(req.marker, env = env(_platform_from_str(platform, None))):
+ if evaluate(req.marker, env = env(platform_from_str(platform, None))):
ret.setdefault(req_string, []).append(platform)
return ret
diff --git a/python/private/pypi/pep508_env.bzl b/python/private/pypi/pep508_env.bzl
index 17d4187..265a8e9 100644
--- a/python/private/pypi/pep508_env.bzl
+++ b/python/private/pypi/pep508_env.bzl
@@ -15,7 +15,9 @@
"""This module is for implementing PEP508 environment definition.
"""
-# See https://stackoverflow.com/questions/45125516/possible-values-for-uname-m
+load(":pep508_platform.bzl", "platform_from_str")
+
+# See https://stackoverflow.com/a/45125525
_platform_machine_aliases = {
# These pairs mean the same hardware, but different values may be used
# on different host platforms.
@@ -24,13 +26,41 @@
"i386": "x86_32",
"i686": "x86_32",
}
+
+# Platform system returns results from the `uname` call.
_platform_system_values = {
"linux": "Linux",
"osx": "Darwin",
"windows": "Windows",
}
+
+# The copy of SO [answer](https://stackoverflow.com/a/13874620) containing
+# all of the platforms:
+# ┍━━━━━━━━━━━━━━━━━━━━━┯━━━━━━━━━━━━━━━━━━━━━┑
+# │ System │ Value │
+# ┝━━━━━━━━━━━━━━━━━━━━━┿━━━━━━━━━━━━━━━━━━━━━┥
+# │ Linux │ linux or linux2 (*) │
+# │ Windows │ win32 │
+# │ Windows/Cygwin │ cygwin │
+# │ Windows/MSYS2 │ msys │
+# │ Mac OS X │ darwin │
+# │ OS/2 │ os2 │
+# │ OS/2 EMX │ os2emx │
+# │ RiscOS │ riscos │
+# │ AtheOS │ atheos │
+# │ FreeBSD 7 │ freebsd7 │
+# │ FreeBSD 8 │ freebsd8 │
+# │ FreeBSD N │ freebsdN │
+# │ OpenBSD 6 │ openbsd6 │
+# │ AIX │ aix (**) │
+# ┕━━━━━━━━━━━━━━━━━━━━━┷━━━━━━━━━━━━━━━━━━━━━┙
+#
+# (*) Prior to Python 3.3, the value for any Linux version is always linux2; after, it is linux.
+# (**) Prior Python 3.8 could also be aix5 or aix7; use sys.platform.startswith()
+#
+# We are using only the subset that we actually support.
_sys_platform_values = {
- "linux": "posix",
+ "linux": "linux",
"osx": "darwin",
"windows": "win32",
}
@@ -61,6 +91,7 @@
"platform_release": "",
"platform_version": "",
}
+
if type(target_platform) == type(""):
target_platform = platform_from_str(target_platform, python_version = "")
@@ -87,31 +118,3 @@
"platform_machine": _platform_machine_aliases,
},
}
-
-def _platform(*, abi = None, os = None, arch = None):
- return struct(
- abi = abi,
- os = os,
- arch = arch,
- )
-
-def platform_from_str(p, python_version):
- """Return a platform from a string.
-
- Args:
- p: {type}`str` the actual string.
- python_version: {type}`str` the python version to add to platform if needed.
-
- Returns:
- A struct that is returned by the `_platform` function.
- """
- if p.startswith("cp"):
- abi, _, p = p.partition("_")
- elif python_version:
- major, _, tail = python_version.partition(".")
- abi = "cp{}{}".format(major, tail)
- else:
- abi = None
-
- os, _, arch = p.partition("_")
- return _platform(abi = abi, os = os or None, arch = arch or None)
diff --git a/python/private/pypi/pep508_platform.bzl b/python/private/pypi/pep508_platform.bzl
new file mode 100644
index 0000000..381a8d7
--- /dev/null
+++ b/python/private/pypi/pep508_platform.bzl
@@ -0,0 +1,57 @@
+# Copyright 2025 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.
+
+"""The platform abstraction
+"""
+
+def platform(*, abi = None, os = None, arch = None):
+ """platform returns a struct for the platform.
+
+ Args:
+ abi: {type}`str | None` the target ABI, e.g. `"cp39"`.
+ os: {type}`str | None` the target os, e.g. `"linux"`.
+ arch: {type}`str | None` the target CPU, e.g. `"aarch64"`.
+
+ Returns:
+ A struct.
+ """
+
+ # Note, this is used a lot as a key in dictionaries, so it cannot contain
+ # methods.
+ return struct(
+ abi = abi,
+ os = os,
+ arch = arch,
+ )
+
+def platform_from_str(p, python_version):
+ """Return a platform from a string.
+
+ Args:
+ p: {type}`str` the actual string.
+ python_version: {type}`str` the python version to add to platform if needed.
+
+ Returns:
+ A struct that is returned by the `_platform` function.
+ """
+ if p.startswith("cp"):
+ abi, _, p = p.partition("_")
+ elif python_version:
+ major, _, tail = python_version.partition(".")
+ abi = "cp{}{}".format(major, tail)
+ else:
+ abi = None
+
+ os, _, arch = p.partition("_")
+ return platform(abi = abi, os = os or None, arch = arch or None)
diff --git a/python/private/pypi/pep508_req.bzl b/python/private/pypi/pep508_requirement.bzl
similarity index 81%
rename from python/private/pypi/pep508_req.bzl
rename to python/private/pypi/pep508_requirement.bzl
index 618ffaf..11f2b3e 100644
--- a/python/private/pypi/pep508_req.bzl
+++ b/python/private/pypi/pep508_requirement.bzl
@@ -17,7 +17,7 @@
load("//python/private:normalize_name.bzl", "normalize_name")
-_STRIP = ["(", " ", ">", "=", "<", "~", "!"]
+_STRIP = ["(", " ", ">", "=", "<", "~", "!", "@"]
def requirement(spec):
"""Parse a PEP508 requirement line
@@ -28,15 +28,18 @@
Returns:
A struct with the information.
"""
+ spec = spec.strip()
requires, _, maybe_hashes = spec.partition(";")
marker, _, _ = maybe_hashes.partition("--hash")
requires, _, extras_unparsed = requires.partition("[")
+ extras_unparsed, _, _ = extras_unparsed.partition("]")
for char in _STRIP:
requires, _, _ = requires.partition(char)
- extras = extras_unparsed.strip("]").split(",")
+ extras = extras_unparsed.replace(" ", "").split(",")
+ name = requires.strip(" ")
return struct(
- name = normalize_name(requires.strip(" ")),
+ name = normalize_name(name).replace("_", "-"),
marker = marker.strip(" "),
extras = extras,
)
diff --git a/tests/pypi/pep508/BUILD.bazel b/tests/pypi/pep508/BUILD.bazel
index b795db0..575f28a 100644
--- a/tests/pypi/pep508/BUILD.bazel
+++ b/tests/pypi/pep508/BUILD.bazel
@@ -1,5 +1,10 @@
load(":evaluate_tests.bzl", "evaluate_test_suite")
+load(":requirement_tests.bzl", "requirement_test_suite")
evaluate_test_suite(
name = "evaluate_tests",
)
+
+requirement_test_suite(
+ name = "requirement_tests",
+)
diff --git a/tests/pypi/pep508/requirement_tests.bzl b/tests/pypi/pep508/requirement_tests.bzl
new file mode 100644
index 0000000..7c81ea5
--- /dev/null
+++ b/tests/pypi/pep508/requirement_tests.bzl
@@ -0,0 +1,47 @@
+# Copyright 2025 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.
+"""Tests for parsing the requirement specifier."""
+
+load("@rules_testing//lib:test_suite.bzl", "test_suite")
+load("//python/private/pypi:pep508_requirement.bzl", "requirement") # buildifier: disable=bzl-visibility
+
+_tests = []
+
+def _test_requirement_line_parsing(env):
+ want = {
+ " name1[ foo ] ": ("name1", ["foo"]),
+ "Name[foo]": ("name", ["foo"]),
+ "name [fred,bar] @ http://foo.com ; python_version=='2.7'": ("name", ["fred", "bar"]),
+ "name; (os_name=='a' or os_name=='b') and os_name=='c'": ("name", [""]),
+ "name@http://foo.com": ("name", [""]),
+ "name[ Foo123 ]": ("name", ["Foo123"]),
+ "name[extra]@http://foo.com": ("name", ["extra"]),
+ "name[foo]": ("name", ["foo"]),
+ "name[quux, strange];python_version<'2.7' and platform_version=='2'": ("name", ["quux", "strange"]),
+ "name_foo[bar]": ("name-foo", ["bar"]),
+ }
+
+ got = {
+ i: (parsed.name, parsed.extras)
+ for i, parsed in {case: requirement(case) for case in want}.items()
+ }
+ env.expect.that_dict(got).contains_exactly(want)
+
+_tests.append(_test_requirement_line_parsing)
+
+def requirement_test_suite(name): # buildifier: disable=function-docstring
+ test_suite(
+ name = name,
+ basic_tests = _tests,
+ )