diff --git a/scripts/addons_core/bl_pkg/Makefile b/scripts/addons_core/bl_pkg/Makefile index 0e1b6257e7b..2cb99a6d427 100644 --- a/scripts/addons_core/bl_pkg/Makefile +++ b/scripts/addons_core/bl_pkg/Makefile @@ -94,7 +94,7 @@ test: FORCE watch_test: FORCE @cd "$(BASE_DIR)" && \ while true; do \ - $(MAKE) test_cli; \ + $(MAKE) test; \ inotifywait -q -e close_write $(EXTRA_WATCH_FILES) $(PY_FILES) ; \ tput clear; \ done diff --git a/scripts/addons_core/bl_pkg/__init__.py b/scripts/addons_core/bl_pkg/__init__.py index c4cdce30150..e28e75450b0 100644 --- a/scripts/addons_core/bl_pkg/__init__.py +++ b/scripts/addons_core/bl_pkg/__init__.py @@ -102,6 +102,7 @@ def manifest_compatible_with_wheel_data_or_error( from bl_pkg.bl_extension_utils import ( pkg_manifest_dict_is_valid_or_error, toml_from_filepath, + python_versions_from_wheels, ) from bl_pkg.bl_extension_ops import ( pkg_manifest_params_compatible_or_error_for_this_system, @@ -115,10 +116,24 @@ def manifest_compatible_with_wheel_data_or_error( if (error := pkg_manifest_dict_is_valid_or_error(manifest_dict, from_repo=False, strict=False)): return error + python_versions = [] + if (wheel_files := manifest_dict.get("wheels", None)) is not None: + if isinstance(python_versions_test := python_versions_from_wheels(wheel_files), str): + print("Error parsing wheel versions: {:s} from \"{:s}\"".format( + python_versions_test, + pkg_manifest_filepath, + )) + else: + python_versions = [ + ".".join(str(i) for i in v) + for v in python_versions_test + ] + if isinstance(error := pkg_manifest_params_compatible_or_error_for_this_system( blender_version_min=manifest_dict.get("blender_version_min", ""), blender_version_max=manifest_dict.get("blender_version_max", ""), platforms=manifest_dict.get("platforms", ""), + python_versions=python_versions, ), str): return error @@ -569,6 +584,7 @@ _repo_cache_store = None def repo_cache_store_ensure(): # pylint: disable-next=global-statement global _repo_cache_store + import sys if _repo_cache_store is not None: return _repo_cache_store @@ -577,7 +593,10 @@ def repo_cache_store_ensure(): bl_extension_ops, bl_extension_utils, ) - _repo_cache_store = bl_extension_utils.RepoCacheStore(bpy.app.version) + _repo_cache_store = bl_extension_utils.RepoCacheStore( + blender_version=bpy.app.version, + python_version=sys.version_info[:3], + ) bl_extension_ops.repo_cache_store_refresh_from_prefs(_repo_cache_store) return _repo_cache_store diff --git a/scripts/addons_core/bl_pkg/bl_extension_ops.py b/scripts/addons_core/bl_pkg/bl_extension_ops.py index f359d4835a1..7287100acf3 100644 --- a/scripts/addons_core/bl_pkg/bl_extension_ops.py +++ b/scripts/addons_core/bl_pkg/bl_extension_ops.py @@ -70,8 +70,13 @@ _ext_base_pkg_idname_with_dot = _ext_base_pkg_idname + "." def url_append_defaults(url): + import sys from .bl_extension_utils import url_append_query_for_blender - return url_append_query_for_blender(url, blender_version=bpy.app.version) + return url_append_query_for_blender( + url=url, + blender_version=bpy.app.version, + python_version=sys.version_info[:3], + ) def url_normalize(url): @@ -546,8 +551,10 @@ def pkg_manifest_params_compatible_or_error_for_this_system( blender_version_min, # `str` blender_version_max, # `str` platforms, # `list[str]` + python_versions, # `list[str]` ): # `str | None` # Return true if the parameters are compatible with this system. + import sys from .bl_extension_utils import ( pkg_manifest_params_compatible_or_error, platform_from_this_system, @@ -557,8 +564,10 @@ def pkg_manifest_params_compatible_or_error_for_this_system( blender_version_min=blender_version_min, blender_version_max=blender_version_max, platforms=platforms, + python_versions=python_versions, # This system. this_platform=platform_from_this_system(), + this_python_version=sys.version_info, this_blender_version=bpy.app.version, error_fn=print, ) @@ -913,7 +922,7 @@ def _pkg_marked_by_repo(repo_cache_store, pkg_manifest_all): # Wheel Handling # -def _extensions_wheel_filter_for_platform(wheels): +def _extensions_wheel_filter_for_this_system(wheels): # Copied from `wheel.bwheel_dist.get_platform(..)` which isn't part of Python. # This misses some additional checks which aren't supported by official Blender builds, @@ -922,6 +931,13 @@ def _extensions_wheel_filter_for_platform(wheels): import sysconfig platform_tag_current = sysconfig.get_platform().replace("-", "_") + import sys + from .bl_extension_utils import ( + python_versions_from_wheel_python_tag, + ) + + python_version_current = sys.version_info[:2] + # https://packaging.python.org/en/latest/specifications/binary-distribution-format/#file-name-convention # This also defines the name spec: # `{distribution}-{version}(-{build tag})?-{python tag}-{abi tag}-{platform tag}.whl` @@ -942,9 +958,10 @@ def _extensions_wheel_filter_for_platform(wheels): if not (5 <= len(wheel_filename_split) <= 6): print("Error: wheel doesn't follow naming spec \"{:s}\"".format(wheel_filename)) continue - # TODO: Match Python & ABI tags. - _python_tag, _abi_tag, platform_tag = wheel_filename_split[-3:] + # TODO: Match ABI tags. + python_tag, _abi_tag, platform_tag = wheel_filename_split[-3:] + # Perform Platform Checks. if platform_tag in {"any", platform_tag_current}: pass elif platform_tag_current.startswith("macosx_") and ( @@ -973,7 +990,35 @@ def _extensions_wheel_filter_for_platform(wheels): ) continue + # Perform Python Version Checks. + if isinstance(python_versions := python_versions_from_wheel_python_tag(python_tag), str): + print("Error: wheel \"{:s}\" unable to parse Python version {:s}".format(wheel_filename, python_versions)) + else: + python_version_is_compat = False + for python_version in python_versions: + if len(python_version) == 1: + if python_version_current[0] == python_version[0]: + python_version_is_compat = True + break + 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( + "Skipping wheel for other Python version", + "({:s}=>({:s}) not in {:d}.{:d}):".format( + python_tag, + ", ".join([".".join(str(i) for i in v) for v in python_versions]), + python_version_current[0], + python_version_current[1], + ), + wheel_filename, + ) + continue + wheels_compatible.append(wheel) + return wheels_compatible @@ -984,7 +1029,7 @@ def pkg_wheel_filter( wheels_rel, # `list[str]` ): # `-> tuple[str, list[str]]` # Filter only the wheels for this platform. - wheels_rel = _extensions_wheel_filter_for_platform(wheels_rel) + wheels_rel = _extensions_wheel_filter_for_this_system(wheels_rel) if not wheels_rel: return None @@ -1884,6 +1929,7 @@ class EXTENSIONS_OT_package_upgrade_all(Operator, _ExtCmdMixIn): return "" # Default. def exec_command_iter(self, is_modal): + import sys from . import bl_extension_utils # pylint: disable-next=attribute-defined-outside-init self._repo_directories = set() @@ -1974,6 +2020,7 @@ class EXTENSIONS_OT_package_upgrade_all(Operator, _ExtCmdMixIn): pkg_id_sequence=pkg_id_sequence_iter, online_user_agent=online_user_agent_from_blender(), blender_version=bpy.app.version, + python_version=sys.version_info[:3], access_token=repo_item.access_token, timeout=prefs.system.network_timeout, use_cache=repo_item.use_cache, @@ -2055,6 +2102,7 @@ class EXTENSIONS_OT_package_install_marked(Operator, _ExtCmdMixIn): enable_on_install: rna_prop_enable_on_install def exec_command_iter(self, is_modal): + import sys from . import bl_extension_utils repos_all = extension_repos_read() @@ -2096,6 +2144,7 @@ class EXTENSIONS_OT_package_install_marked(Operator, _ExtCmdMixIn): pkg_id_sequence=pkg_id_sequence_iter, online_user_agent=online_user_agent_from_blender(), blender_version=bpy.app.version, + python_version=sys.version_info[:3], access_token=repo_item.access_token, timeout=prefs.system.network_timeout, use_cache=repo_item.use_cache, @@ -2383,6 +2432,7 @@ class EXTENSIONS_OT_package_install_files(Operator, _ExtCmdMixIn): url: rna_prop_url def exec_command_iter(self, is_modal): + import sys from . import bl_extension_utils from .bl_extension_utils import ( pkg_manifest_dict_from_archive_or_error, @@ -2497,6 +2547,7 @@ class EXTENSIONS_OT_package_install_files(Operator, _ExtCmdMixIn): directory=directory, files=pkg_files, blender_version=bpy.app.version, + python_version=sys.version_info[:3], use_idle=is_modal, python_args=bpy.app.python_args, ) @@ -2825,6 +2876,7 @@ class EXTENSIONS_OT_package_install(Operator, _ExtCmdMixIn): return True def exec_command_iter(self, is_modal): + import sys from . import bl_extension_utils if not self._is_ready_to_execute(): @@ -2879,6 +2931,7 @@ class EXTENSIONS_OT_package_install(Operator, _ExtCmdMixIn): pkg_id_sequence=(pkg_id,), online_user_agent=online_user_agent_from_blender(), blender_version=bpy.app.version, + python_version=sys.version_info[:3], access_token=repo_item.access_token, timeout=prefs.system.network_timeout, use_cache=repo_item.use_cache, @@ -3006,6 +3059,10 @@ class EXTENSIONS_OT_package_install(Operator, _ExtCmdMixIn): blender_version_min=url_params.get("blender_version_min", ""), blender_version_max=url_params.get("blender_version_max", ""), platforms=[platform for platform in url_params.get("platforms", "").split(",") if platform], + python_versions=[ + python_version for python_version in url_params.get("python_versions", "").split(",") + if python_version + ], ), str): self.report({'ERROR'}, iface_("The extension is incompatible with this system:\n{:s}").format(error)) return {'CANCELLED'} diff --git a/scripts/addons_core/bl_pkg/bl_extension_utils.py b/scripts/addons_core/bl_pkg/bl_extension_utils.py index 58b4b50473c..6700d58e803 100644 --- a/scripts/addons_core/bl_pkg/bl_extension_utils.py +++ b/scripts/addons_core/bl_pkg/bl_extension_utils.py @@ -43,6 +43,8 @@ __all__ = ( "pkg_manifest_dict_from_archive_or_error", "pkg_manifest_archive_url_abs_from_remote_url", + "python_versions_from_wheel_python_tag", + "CommandBatch", "RepoCacheStore", @@ -431,7 +433,12 @@ def _url_append_query(url: str, query: dict[str, str]) -> str: return new_url -def url_append_query_for_blender(url: str, blender_version: tuple[int, int, int]) -> str: +def url_append_query_for_blender( + *, + url: str, + blender_version: tuple[int, int, int], + python_version: tuple[int, int, int], +) -> str: # `blender_version` is typically `bpy.app.version`. # While this won't cause errors, it's redundant to add this information to file URL's. @@ -441,6 +448,7 @@ def url_append_query_for_blender(url: str, blender_version: tuple[int, int, int] query = { "platform": platform_from_this_system(), "blender_version": "{:d}.{:d}.{:d}".format(*blender_version), + "python_version": "{:d}.{:d}.{:d}".format(*python_version), } return _url_append_query(url, query) @@ -471,7 +479,7 @@ def url_parse_for_blender(url: str) -> tuple[str, dict[str, str]]: for key, value in query: value_xform = None match key: - case "blender_version_min" | "blender_version_max" | "platforms": + case "blender_version_min" | "blender_version_max" | "python_versions" | "platforms": if value: value_xform = value case "repository": @@ -620,6 +628,7 @@ def pkg_install_files( directory: str, files: Sequence[str], blender_version: tuple[int, int, int], + python_version: tuple[int, int, int], use_idle: bool, python_args: Sequence[str], ) -> Iterator[InfoItemSeq]: @@ -631,6 +640,7 @@ def pkg_install_files( "install-files", *files, "--local-dir", directory, "--blender-version", "{:d}.{:d}.{:d}".format(*blender_version), + "--python-version", "{:d}.{:d}.{:d}".format(*python_version), "--temp-prefix-and-suffix", "/".join(PKG_TEMP_PREFIX_AND_SUFFIX), ], use_idle=use_idle, python_args=python_args) yield [COMPLETE_ITEM] @@ -642,6 +652,7 @@ def pkg_install( remote_url: str, pkg_id_sequence: Sequence[str], blender_version: tuple[int, int, int], + python_version: tuple[int, int, int], online_user_agent: str, access_token: str, timeout: float, @@ -658,6 +669,7 @@ def pkg_install( "--local-dir", directory, "--remote-url", remote_url, "--blender-version", "{:d}.{:d}.{:d}".format(*blender_version), + "--python-version", "{:d}.{:d}.{:d}".format(*python_version), "--online-user-agent", online_user_agent, "--access-token", access_token, "--local-cache", str(int(use_cache)), @@ -819,6 +831,20 @@ def pkg_repo_cache_clear(local_dir: str) -> None: print("Error: unlink", ex) +def python_versions_from_wheel_python_tag(python_tag: str) -> set[tuple[int] | tuple[int, int]] | str: + from .cli.blender_ext import python_versions_from_wheel_python_tag as fn + result = fn(python_tag) + 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) + assert isinstance(result, (set, str)) + return result + + # ----------------------------------------------------------------------------- # Public Command Pool (non-command-line wrapper) # @@ -1335,6 +1361,7 @@ def repository_id_with_error_fn( class PkgManifest_FilterParams(NamedTuple): platform: str blender_version: tuple[int, int, int] + python_version: tuple[int, int, int] def repository_filter_skip( @@ -1346,6 +1373,7 @@ def repository_filter_skip( result = repository_filter_skip_impl( item, filter_blender_version=filter_params.blender_version, + filter_python_version=filter_params.python_version, filter_platform=filter_params.platform, skip_message_fn=None, error_fn=error_fn, @@ -1359,8 +1387,10 @@ def pkg_manifest_params_compatible_or_error( blender_version_min: str, blender_version_max: str, platforms: list[str], + python_versions: list[str], this_platform: tuple[int, int, int], this_blender_version: tuple[int, int, int], + this_python_version: tuple[int, int, int], error_fn: Callable[[Exception], None], ) -> str | None: from .cli.blender_ext import repository_filter_skip as fn @@ -1373,11 +1403,14 @@ def pkg_manifest_params_compatible_or_error( item["blender_version_max"] = blender_version_max if platforms: item["platforms"] = platforms + if python_versions: + item["python_versions"] = python_versions result_report = [] result = fn( item=item, filter_blender_version=this_blender_version, + filter_python_version=this_python_version, filter_platform=this_platform, # pylint: disable-next=unnecessary-lambda skip_message_fn=lambda msg: result_report.append(msg), @@ -1987,11 +2020,17 @@ class RepoCacheStore: "_is_init", ) - def __init__(self, blender_version: tuple[int, int, int]) -> None: + def __init__( + self, + *, + blender_version: tuple[int, int, int], + python_version: tuple[int, int, int], + ) -> None: self._repos: list[_RepoCacheEntry] = [] self._filter_params = PkgManifest_FilterParams( platform=platform_from_this_system(), blender_version=blender_version, + python_version=python_version, ) self._is_init = False diff --git a/scripts/addons_core/bl_pkg/cli/blender_ext.py b/scripts/addons_core/bl_pkg/cli/blender_ext.py index 616dacd7c95..617c3c7f638 100755 --- a/scripts/addons_core/bl_pkg/cli/blender_ext.py +++ b/scripts/addons_core/bl_pkg/cli/blender_ext.py @@ -104,6 +104,9 @@ RE_MANIFEST_SEMVER = re.compile( # Ensure names (for example), don't contain control characters. RE_CONTROL_CHARS = re.compile(r'[\x00-\x1f\x7f-\x9f]') +# Use to extract a Python version tag: `py3`, `cp311` etc, from a wheel's filename. +RE_PYTHON_WHEEL_VERSION_TAG = re.compile("([a-zA-Z]+)([0-9]+)") + # Progress updates are displayed after each chunk of this size is downloaded. # Small values add unnecessary overhead showing progress, large values will make # progress not update often enough. @@ -117,6 +120,11 @@ CHUNK_SIZE_DEFAULT = 1 << 14 # Used for project tag-line & permissions values. TERSE_DESCRIPTION_MAX_LENGTH = 64 +# Enforce naming spec: +# https://packaging.python.org/en/latest/specifications/binary-distribution-format/#file-name-convention +# This also defines the name spec: +WHEEL_FILENAME_SPEC = "{distribution}-{version}(-{build tag})?-{python tag}-{abi tag}-{platform tag}.whl" + # Default HTML for `server-generate`. # Intentionally very basic, users may define their own `--html-template`. HTML_TEMPLATE = '''\ @@ -1871,10 +1879,6 @@ def pkg_manifest_validate_field_wheels( ) -> str | None: if (error := pkg_manifest_validate_field_any_list_of_non_empty_strings(value, strict)) is not None: return error - # Enforce naming spec: - # https://packaging.python.org/en/latest/specifications/binary-distribution-format/#file-name-convention - # This also defines the name spec: - filename_spec = "{distribution}-{version}(-{build tag})?-{python tag}-{abi tag}-{platform tag}.whl" for wheel in value: if "\"" in wheel: @@ -1894,7 +1898,10 @@ def pkg_manifest_validate_field_wheels( wheel_filename_split = wheel_filename.split("-") # pylint: disable-next=superfluous-parens if not (5 <= len(wheel_filename_split) <= 6): - return "wheel filename must follow the spec \"{:s}\", found {!r}".format(filename_spec, wheel_filename) + return "wheel filename must follow the spec \"{:s}\", found {!r}".format( + WHEEL_FILENAME_SPEC, + wheel_filename, + ) return None @@ -2191,6 +2198,99 @@ def blender_platform_compatible_with_wheel_platform_from_filepath(platform: str, return blender_platform_compatible_with_wheel_platform(platform, wheel_platform) +def python_versions_from_wheel_python_tag(python_tag: str) -> set[tuple[int] | tuple[int, int]] | str: + """ + 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. + version_prefix_known = {"py", "cp"} + + versions: set[tuple[int] | tuple[int, int]] = set() + + for version_string in versions_string: + m = RE_PYTHON_WHEEL_VERSION_TAG.match(version_string) + if m is None: + return "wheel filename version could not be extracted from: \"{:s}\"".format( + version_string, + ) + + version_prefix = m.group(1).lower() + version_number = m.group(2) + + if version_prefix in version_prefix_known: + if len(version_number) > 1: + # Convert: `"311"` to `(3, 11)`. + version: tuple[int, int] | tuple[int] = (int(version_number[:1]), int(version_number[1:])) + else: + # Common for "py3". + version = (int(version_number), ) + versions.add(version) + else: + return ( + "wheel filename version prefix failed to be extracted " + "found \"{:s}\" int \"{:s}\", expected a value in ({:s})" + ).format( + version_prefix, + python_tag, + ", ".join(sorted(version_prefix_known)), + ) + + 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. + """ + wheel_filename_split = wheel_filename.split("-") + + # pylint: disable-next=superfluous-parens + 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]) + + +def python_versions_from_wheels(wheel_files: Sequence[str]) -> set[tuple[int] | tuple[int, int]] | str: + if not wheel_files: + assert False, "unreachable" + return () + + version_major_only: set[tuple[int]] = set() + version_major_minor: set[tuple[int, int]] = set() + for filepath in wheel_files: + if isinstance(result := python_versions_from_wheel(os.path.basename(filepath)), str): + return result + # Check for an empty set - no version info. + if not result: + continue + + for v in result: + # Exclude support for historic Python versions. + # While including this info is technically correct, it's *never* useful to include this info. + # So listing this information in every server JSON listing is redundant. + if v[0] <= 2: + continue + + if len(v) == 1: + version_major_only.add(v) + else: + version_major_minor.add(v) + + # Clean the versions, exclude `(3,)` when `(3, 11)` is present. + for v in version_major_minor: + version_major_only.discard((v[0],)) + + return version_major_only | version_major_minor + + def paths_filter_wheels_by_platform( wheels: list[str], platform: str, @@ -2262,6 +2362,7 @@ def repository_filter_skip( *, filter_blender_version: tuple[int, int, int], filter_platform: str, + filter_python_version: tuple[int, int, int], # When `skip_message_fn` is set, returning true must call the `skip_message_fn` function. skip_message_fn: Callable[[str], None] | None, error_fn: Callable[[Exception], None], @@ -2278,6 +2379,47 @@ def repository_filter_skip( )) return True + if filter_python_version != (0, 0, 0): + if (python_versions := item.get("python_versions")) is not None: + if not isinstance(python_versions, list): + # Possibly noisy, but this should *not* be happening on a regular basis. + error_fn(TypeError("python_versions is not a list, found a: {:s}".format(str(type(python_versions))))) + elif python_versions: + ok = True + python_versions_as_set: set[str] = set() + for v in python_versions: + if not isinstance(v, str): + error_fn(TypeError(( + "python_versions is not a list of strings, " + "found an item of type: {:s}" + ).format(str(type(v))))) + ok = False + break + # The full Python version isn't explicitly disallowed, + # ignore trailing values only check major/minor. + if v.count(".") > 1: + v = ".".join(v.split(".")[:2]) + python_versions_as_set.add(v) + + if ok: + # There is no need to do any complex extraction and comparison. + # Simply check if the `{major}.{minor}` or `{major}` exists in the set. + python_versions_as_set = set(python_versions) + filter_python_version_major_minor = "{:d}.{:d}".format(*filter_python_version[:2]) + filter_python_version_major_only = "{:d}".format(filter_python_version[0]) + if not ( + filter_python_version_major_minor in python_versions_as_set or + filter_python_version_major_only in python_versions_as_set + ): + if skip_message_fn is not None: + skip_message_fn("This Python version ({:s}) isn't compatible with ({:s})".format( + filter_python_version_major_minor, + ", ".join(python_versions), + )) + + return True + del filter_python_version_major_minor, filter_python_version_major_only, python_versions_as_set + if filter_blender_version != (0, 0, 0): version_min_str = item.get("blender_version_min") version_max_str = item.get("blender_version_max") @@ -2326,14 +2468,14 @@ def repository_filter_skip( return False -def blender_version_parse_or_error(version: str) -> tuple[int, int, int] | str: +def generic_version_triple_parse_or_error(version: str, identifier: str) -> tuple[int, int, int] | str: try: version_tuple: tuple[int, ...] = tuple(int(x) for x in version.split(".")) except Exception as ex: - return "unable to parse blender version: {:s}, {:s}".format(version, str(ex)) + return "unable to parse {:s} version: {:s}, {:s}".format(identifier, version, str(ex)) if not version_tuple: - return "unable to parse empty blender version: {:s}".format(version) + return "unable to parse empty {:s} version: {:s}".format(identifier, version) # `mypy` can't detect that this is guaranteed to be 3 items. return ( @@ -2342,6 +2484,14 @@ def blender_version_parse_or_error(version: str) -> tuple[int, int, int] | str: ) +def blender_version_parse_or_error(version: str) -> tuple[int, int, int] | str: + return generic_version_triple_parse_or_error(version, "Blender") + + +def python_version_parse_or_error(version: str) -> tuple[int, int, int] | str: + return generic_version_triple_parse_or_error(version, "Python") + + def blender_version_parse_any_or_error(version: Any) -> tuple[int, int, int] | str: if not isinstance(version, str): return "blender version should be a string, found a: {:s}".format(str(type(version))) @@ -2433,13 +2583,20 @@ def pkg_manifest_toml_is_valid_or_error(filepath: str, strict: bool) -> tuple[st return None, result -def pkg_manifest_detect_duplicates(pkg_items: list[PkgManifest]) -> str | None: +def pkg_manifest_detect_duplicates( + pkg_items: list[tuple[ + PkgManifest, + str, + list[tuple[int] | tuple[int, int]], + ]], +) -> str | None: """ When a repository includes multiple packages with the same ID, ensure they don't conflict. Ensure packages have non-overlapping: - Platforms. - Blender versions. + - Python versions. Return an error if they do, otherwise None. """ @@ -2448,6 +2605,9 @@ def pkg_manifest_detect_duplicates(pkg_items: list[PkgManifest]) -> str | None: dummy_verion_min = 0, 0, 0 dummy_verion_max = 1000, 0, 0 + dummy_platform = "" + dummy_python_version = (0,) + def parse_version_or_default(version: str | None, default: tuple[int, int, int]) -> tuple[int, int, int]: if version is None: return default @@ -2466,68 +2626,175 @@ def pkg_manifest_detect_duplicates(pkg_items: list[PkgManifest]) -> str | None: version_max_str = "..." if dummy_max else "{:d}.{:d}.{:d}".format(*version_max) return "[{:s} -> {:s}]".format(version_min_str, version_max_str) - # Sort for predictable output. - platforms_all = tuple(sorted(set( - platform - for manifest in pkg_items - for platform in (manifest.platforms or ()) - ))) + def ordered_int_pair(a: int, b: int) -> tuple[int, int]: + assert a != b + if a > b: + return b, a + return a, b - manifest_per_platform: dict[str, list[PkgManifest]] = {platform: [] for platform in platforms_all} - if platforms_all: - for manifest in pkg_items: - # No platforms means all platforms. - for platform in (manifest.platforms or platforms_all): - manifest_per_platform[platform].append(manifest) - else: - manifest_per_platform[""] = pkg_items + class PkgManifest_DupeInfo: + __slots__ = ( + "manifest", + "filename", + "python_versions", + "index", - # Packages have been split by platform, now detect version overlap. - platform_dupliates = {} - for platform, pkg_items_platform in manifest_per_platform.items(): - # Must never be empty. - assert pkg_items_platform - if len(pkg_items_platform) == 1: - continue + # Version range (min, max) or defaults. + "blender_version_range", - version_ranges: list[tuple[tuple[int, int, int], tuple[int, int, int]]] = [] - for manifest in pkg_items_platform: - version_ranges.append(( + # Expand values so duplicates can be detected by comparing literal overlaps. + "expanded_platforms", + "expanded_python_versions", + ) + + def __init__( + self, + *, + manifest: PkgManifest, + filename: str, + python_versions: list[tuple[int] | tuple[int, int]], + index: int, + ): + self.manifest = manifest + self.filename = filename + self.python_versions: list[tuple[int] | tuple[int, int]] = python_versions + self.index = index + + # NOTE: this assignment could be deferred as it's only needed in the case potential duplicates are found. + self.blender_version_range = ( parse_version_or_default(manifest.blender_version_min, dummy_verion_min), parse_version_or_default(manifest.blender_version_max, dummy_verion_max), - )) - # Sort by the version range so overlaps can be detected between adjacent members. - version_ranges.sort() + ) - duplicates_found = [] - item_prev = version_ranges[0] - for i in range(1, len(version_ranges)): - item_curr = version_ranges[i] + self.expanded_platforms: list[str] = [] + self.expanded_python_versions: list[tuple[int] | tuple[int, int]] = [] + + pkg_items_dup = [ + PkgManifest_DupeInfo( + manifest=manifest, + filename=filename, + python_versions=python_versions, + index=i, + ) for i, (manifest, filename, python_versions) in enumerate(pkg_items) + ] + + # Store all configurations. + platforms_all = set() + python_versions_all = set() + + for item_dup in pkg_items_dup: + item = item_dup.manifest + if item.platforms: + platforms_all.update(item.platforms) + + if item_dup.python_versions: + python_versions_all.update(item_dup.python_versions) + del item + + # Expand values. + for item_dup in pkg_items_dup: + item = item_dup.manifest + if platforms_all: + item_dup.expanded_platforms[:] = item.platforms or platforms_all + else: + item_dup.expanded_platforms[:] = [dummy_platform] + + if python_versions_all: + item_dup.expanded_python_versions[:] = item_dup.python_versions or python_versions_all + else: + item_dup.expanded_python_versions[:] = [dummy_python_version] + del item + + # Expand Python "major only" versions. + # Some wheels define "py3" only, this will be something we have to deal with + # when Python moves to Python 4. + # It's important that Python 3.11 detects as conflicting with Python 3. + python_versions_all_map_major_to_full: dict[int, list[tuple[int, int]]] = {} + for python_version in python_versions_all: + if len(python_version) != 2: + continue + python_version_major = python_version[0] + if (python_version_major,) not in python_versions_all: + continue + + if (python_versions := python_versions_all_map_major_to_full.get(python_version_major)) is None: + python_versions = python_versions_all_map_major_to_full[python_version_major] = [] + python_versions.append(python_version) + del python_versions + + for item_dup in pkg_items_dup: + for python_version in item_dup.python_versions: + if len(python_version) != 1: + continue + python_version_major = python_version[0] + + expanded_python_versions_set = set(item_dup.expanded_python_versions) + if (python_versions_full := python_versions_all_map_major_to_full.get(python_version_major)) is not None: + # Expand major to major-minor versions. + item_dup.expanded_python_versions.extend([ + v for v in python_versions_full + if v not in expanded_python_versions_set + ]) + del python_versions_full + + # This can be expanded with additional values as needed. + # We could in principle have ABI flags (debug/release) for e.g. + PkgCfgKey = tuple[ + # Platform. + str, + # Python Version. + tuple[int] | tuple[int, int], + ] + + pkg_items_dup_per_cfg: dict[PkgCfgKey, list[PkgManifest_DupeInfo]] = {} + + for item_dup in pkg_items_dup: + for platform in item_dup.expanded_platforms: + for python_version in item_dup.expanded_python_versions: + key = (platform, python_version) + if (pkg_items_cfg := pkg_items_dup_per_cfg.get(key)) is None: + pkg_items_cfg = pkg_items_dup_per_cfg[key] = [] + pkg_items_cfg.append(item_dup) + del pkg_items_cfg + + # Don't report duplicates more than once. + duplicate_indices: set[tuple[int, int]] = set() + + # Packages have been split by configuration, now detect version overlap. + duplicates_found = [] + + # NOTE: we might want to include the `key` in the message, + # after all, it's useful to know if the conflict occurs between specific platforms or Python versions. + + for pkg_items_cfg in pkg_items_dup_per_cfg.values(): + # Must never be empty. + assert pkg_items_cfg + if len(pkg_items_cfg) == 1: + continue + + # Sort by the version range so overlaps can be detected between adjacent members. + pkg_items_cfg.sort(key=lambda item_dup: item_dup.blender_version_range) + + item_prev = pkg_items_cfg[0] + for i in range(1, len(pkg_items_cfg)): + item_curr = pkg_items_cfg[i] # Previous maximum is less than or equal to the current minimum, no overlap. - if item_prev[1] > item_curr[0]: - duplicates_found.append("{:s} & {:s}".format( - version_range_as_str(*item_prev), - version_range_as_str(*item_curr), - )) + if item_prev.blender_version_range[1] > item_curr.blender_version_range[0]: + # Don't report multiple times. + dup_key = ordered_int_pair(item_prev.index, item_curr.index) + if dup_key not in duplicate_indices: + duplicate_indices.add(dup_key) + duplicates_found.append("{:s}={:s} & {:s}={:s}".format( + item_prev.filename, version_range_as_str(*item_prev.blender_version_range), + item_curr.filename, version_range_as_str(*item_curr.blender_version_range), + )) item_prev = item_curr - if duplicates_found: - platform_dupliates[platform] = duplicates_found - - if platform_dupliates: - # Simpler, no platforms. - if platforms_all: - error_text = ", ".join([ - "\"{:s}\": ({:s})".format(platform, ", ".join(errors)) - for platform, errors in platform_dupliates.items() - ]) - else: - error_text = ", ".join(platform_dupliates[""]) - + if duplicates_found: return "{:d} duplicate(s) found, conflicting blender versions {:s}".format( - sum(map(len, platform_dupliates.values())), - error_text, + len(duplicates_found), + ", ".join(duplicates_found), ) # No collisions found. @@ -3020,6 +3287,19 @@ def generic_arg_blender_version(subparse: argparse.ArgumentParser) -> None: ) +def generic_arg_python_version(subparse: argparse.ArgumentParser) -> None: + subparse.add_argument( + "--python-version", + dest="python_version", + default="0.0.0", + type=str, + help=( + "The version of Python used for selecting packages." + ), + required=False, + ) + + def generic_arg_temp_prefix_and_suffix(subparse: argparse.ArgumentParser) -> None: subparse.add_argument( "--temp-prefix-and-suffix", @@ -3262,6 +3542,7 @@ class subcmd_server: fh.write("