Fix #130561: incorrect Python compatibility checks for wheels

Regression in [0] caused wheels with an older Python version
that used CPython's stable ABI to be considered incompatible.

Resolve using the stable ABI for version checks.

[0]: cfc10b0232
This commit is contained in:
Campbell Barton
2024-11-27 15:44:58 +11:00
committed by Thomas Dinges
parent ead4f5a898
commit cf8a569905
4 changed files with 159 additions and 11 deletions
+19 -2
View File
@@ -950,6 +950,7 @@ def _extensions_wheel_filter_for_this_system(wheels):
import sys
from .bl_extension_utils import (
python_versions_from_wheel_python_tag,
python_versions_from_wheel_abi_tag,
)
python_version_current = sys.version_info[:2]
@@ -974,8 +975,8 @@ def _extensions_wheel_filter_for_this_system(wheels):
if not (5 <= len(wheel_filename_split) <= 6):
print("Error: wheel doesn't follow naming spec \"{:s}\"".format(wheel_filename))
continue
# TODO: Match ABI tags.
python_tag, _abi_tag, platform_tag = wheel_filename_split[-3:]
python_tag, abi_tag, platform_tag = wheel_filename_split[-3:]
# Perform Platform Checks.
if platform_tag in {"any", platform_tag_current}:
@@ -1020,6 +1021,22 @@ def _extensions_wheel_filter_for_this_system(wheels):
if python_version_current == python_version:
python_version_is_compat = True
break
# When there is a stable ABI: Allow an older Python wheel to be compatible
# with a newer Python as long as the older wheel uses the stable ABI, see:
# https://packaging.python.org/en/latest/specifications/platform-compatibility-tags/#abi-tag
if isinstance(
python_versions_stable_abi := python_versions_from_wheel_abi_tag(abi_tag, stable_only=True),
str,
):
print("Error: wheel \"{:s}\" unable to parse Python ABI version {:s}".format(
wheel_filename, python_versions,
))
elif (python_version_current[0],) in python_versions_stable_abi:
if python_version_current >= python_version:
python_version_is_compat = True
break
if not python_version_is_compat:
# Useful to know, can quiet print in the future.
print(
@@ -838,6 +838,13 @@ def python_versions_from_wheel_python_tag(python_tag: str) -> set[tuple[int] | t
return result
def python_versions_from_wheel_abi_tag(abi_tag: str, *, stable_only: bool) -> set[tuple[int] | tuple[int, int]] | str:
from .cli.blender_ext import python_versions_from_wheel_abi_tag as fn
result = fn(abi_tag, stable_only=stable_only)
assert isinstance(result, (set, str))
return result
def python_versions_from_wheels(wheel_files: Sequence[str]) -> set[tuple[int] | tuple[int, int]] | str:
from .cli.blender_ext import python_versions_from_wheels as fn
result = fn(wheel_files)
+41 -8
View File
@@ -2209,11 +2209,9 @@ def python_versions_from_wheel_python_tag(python_tag: str) -> set[tuple[int] | t
"""
Return Python versions from a wheels ``python_tag``.
"""
# Index backwards to skip the optional build tag.
# The version may be:
# `cp312` for CPython 3.12
# `py2.py3` for both Python 2 & 3.
versions_string = python_tag.split(".")
# Based on the documentation as of 2024 and wheels used by existing extensions,
# these are the only valid prefix values.
@@ -2221,12 +2219,10 @@ def python_versions_from_wheel_python_tag(python_tag: str) -> set[tuple[int] | t
versions: set[tuple[int] | tuple[int, int]] = set()
for version_string in versions_string:
m = RE_PYTHON_WHEEL_VERSION_TAG.match(version_string)
for tag in python_tag.split("."):
m = RE_PYTHON_WHEEL_VERSION_TAG.match(tag)
if m is None:
return "wheel filename version could not be extracted from: \"{:s}\"".format(
version_string,
)
return "wheel filename version could not be extracted from: \"{:s}\"".format(tag)
version_prefix = m.group(1).lower()
version_number = m.group(2)
@@ -2252,6 +2248,25 @@ def python_versions_from_wheel_python_tag(python_tag: str) -> set[tuple[int] | t
return versions
def python_versions_from_wheel_abi_tag(
abi_tag: str,
*,
stable_only: bool,
) -> set[tuple[int] | tuple[int, int]] | str:
versions: set[tuple[int] | tuple[int, int]] = set()
# Not yet needed, add as needed.
if stable_only:
for tag in abi_tag.split("."):
if tag.startswith("abi") and tag[3:].isdigit():
versions.add((int(tag[3:]),))
else:
# Not a problem to support this, currently it's not needed.
raise NotImplementedError
return versions
def python_versions_from_wheel(wheel_filename: str) -> set[tuple[int] | tuple[int, int]] | str:
"""
Extract a set of Python versions from a list of wheels or return an error string.
@@ -2262,7 +2277,25 @@ def python_versions_from_wheel(wheel_filename: str) -> set[tuple[int] | tuple[in
if not (5 <= len(wheel_filename_split) <= 6):
return "wheel filename must follow the spec \"{:s}\", found {!r}".format(WHEEL_FILENAME_SPEC, wheel_filename)
return python_versions_from_wheel_python_tag(wheel_filename_split[-3])
python_tag = wheel_filename_split[-3]
abi_tag = wheel_filename_split[-2]
# NOTE(@ideasman42): when the ABI is set, simply return the major version,
# This is needed because older version of CPython (3.6) for e.g. are compatible with newer versions of CPython,
# but returning the old version causes it not to register as being compatible.
# So return the ABI version to allow any version of CPython 3.x.
#
# There is a logical problem here: which is that a future wheel from CPython *should* be detected
# as incompatible but wont be. To properly support this, extensions repository data would need to store
# either store a separate ABI version or a `>=` version. In practice this isn't as bad as it sounds
# because those packages typically won't support old versions of Blender known to use older Python versions,
# although it will incorrectly exclude old versions of Blender which were built against newer versions of
# CPython than the version used by official builds.
python_versions_from_abi = python_versions_from_wheel_abi_tag(abi_tag, stable_only=True)
if python_versions_from_abi:
return python_versions_from_abi
return python_versions_from_wheel_python_tag(python_tag)
def python_versions_from_wheels(wheel_files: Sequence[str]) -> set[tuple[int] | tuple[int, int]] | str:
@@ -218,7 +218,7 @@ def create_package(
fh.write('''version = "1.0.0"\n''')
fh.write('''tagline = "This is a tagline"\n''')
fh.write('''blender_version_min = "{:s}"\n'''.format(blender_version_min or "0.0.0"))
if blender_version_min is not None:
if blender_version_max is not None:
fh.write('''blender_version_max = "{:s}"\n'''.format(blender_version_max))
fh.write('''\n''')
@@ -915,6 +915,97 @@ class TestPythonVersions(TestWithTempBlenderUser_MixIn, unittest.TestCase):
self.assertEqual(stderr, "")
self.assertEqual(stdout, '''found 3 packages.\n''')
def test_server_generate_version_with_stable_abi(self) -> None:
# The different Python versions in the wheels cause the packages not to conflict.
repo_id = "test_repo_stable_abi_versions"
repo_name = "MyTestRepoVersionStableABI"
self.repo_add(repo_id=repo_id, repo_name=repo_name)
this_python_version_major, this_python_version_minor = sys.version_info[:2]
# TODO: this test doesn't make sense for the first Python major releases (4.0 for e.g.).
if this_python_version_minor == 0:
return
python_abi_current_stable = "abi{:d}".format(this_python_version_major)
python_abi_current = "cp{:d}{:d}".format(this_python_version_major, this_python_version_minor)
python_abi_outdated = "cp{:d}{:d}".format(this_python_version_major, this_python_version_minor - 1)
pkg_idnames = (
# Python incompatible because it's stable ABI implies gerater-or-equal-to.
("my_test_pkg_a", "my_test_a", [
"example_A-1.2.3-{0:s}-{0:s}-any.whl".format(
# Old ABI (making this extension incompatible).
python_abi_outdated,
),
"example_B-1.2.3-{0:s}-{0:s}-any.whl".format(python_abi_outdated),
]),
# Python compatible.
("my_test_pkg_b", "my_test_b", [
"example_A-1.2.3-{:s}-{:s}-any.whl".format(
python_abi_outdated,
# Stable ABI (making this extension compatible).
python_abi_current_stable,
),
"example_B-1.2.3-{0:s}-{0:s}-any.whl".format(python_abi_current),
]),
)
# Create a package contents.
for pkg_idname, pkg_filename, wheel_filenames in pkg_idnames:
wheel_params = []
for wheel_filename in wheel_filenames:
module_name, module_version = wheel_filename.split("-", 2)[0:2]
wheel_params.append(
WheelModuleParams(
module_name=module_name,
module_version=module_version,
filename=wheel_filename,
),
)
del module_name, module_version
self.build_package(
pkg_idname=pkg_idname,
pkg_filename=pkg_filename,
blender_version_min="4.2.0",
wheel_params=wheel_params,
)
# Generate the repository.
returncode, stdout, stderr = run_blender_extensions((
"server-generate",
"--repo-dir", TEMP_DIR_REMOTE,
))
self.assertEqual(returncode, 0)
self.assertEqual(stderr, "")
self.assertEqual(stdout, '''found 2 packages.\n''')
stdout = run_blender_extensions_no_errors((
"sync",
))
self.assertEqual(
stdout.rstrip("\n").split("\n")[-1],
"STATUS Extensions list for \"{:s}\" updated".format(repo_name),
)
# This should be filtered out.
# NOTE: ideally an error would show an error that the extension is known but not compatible.
returncode, stdout, stderr = run_blender_extensions(("install", "my_test_pkg_a", "--enable"))
self.assertEqual(stdout.rstrip(), "")
self.assertEqual(returncode, 1)
self.assertEqual(
"Package \"my_test_pkg_a\" not found in remote repositories!",
stderr.rstrip(),
)
stdout = run_blender_extensions_no_errors(("install", "my_test_pkg_b", "--enable"))
self.assertEqual(
[line for line in stdout.split("\n") if line.startswith("STATUS ")][0],
"STATUS Installed \"my_test_pkg_b\""
)
class TestModuleViolation(TestWithTempBlenderUser_MixIn, unittest.TestCase):