diff --git a/scripts/addons_core/bl_pkg/bl_extension_ops.py b/scripts/addons_core/bl_pkg/bl_extension_ops.py index 568336ebc19..6dcfea02b80 100644 --- a/scripts/addons_core/bl_pkg/bl_extension_ops.py +++ b/scripts/addons_core/bl_pkg/bl_extension_ops.py @@ -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( diff --git a/scripts/addons_core/bl_pkg/bl_extension_utils.py b/scripts/addons_core/bl_pkg/bl_extension_utils.py index 6700d58e803..2696a2d5713 100644 --- a/scripts/addons_core/bl_pkg/bl_extension_utils.py +++ b/scripts/addons_core/bl_pkg/bl_extension_utils.py @@ -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) diff --git a/scripts/addons_core/bl_pkg/cli/blender_ext.py b/scripts/addons_core/bl_pkg/cli/blender_ext.py index 4935c0a0da3..1690240767f 100755 --- a/scripts/addons_core/bl_pkg/cli/blender_ext.py +++ b/scripts/addons_core/bl_pkg/cli/blender_ext.py @@ -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: diff --git a/scripts/addons_core/bl_pkg/tests/test_cli_blender.py b/scripts/addons_core/bl_pkg/tests/test_cli_blender.py index b2275938b56..d4a257968ee 100644 --- a/scripts/addons_core/bl_pkg/tests/test_cli_blender.py +++ b/scripts/addons_core/bl_pkg/tests/test_cli_blender.py @@ -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):