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(" Description\n") fh.write(" Website\n") fh.write(" Blender Versions\n") + fh.write(" Python Versions\n") fh.write(" Platforms\n") fh.write(" Size\n") fh.write(" \n") @@ -3273,6 +3554,7 @@ class subcmd_server: fh.write(" \n") platforms = manifest_dict.get("platforms", []) + python_versions = manifest_dict.get("python_versions", []) # Parse the URL and add parameters use for drag & drop. parsed_url = urllib.parse.urlparse(manifest_dict["archive_url"]) @@ -3285,6 +3567,8 @@ class subcmd_server: query["blender_version_max"] = value if platforms: query["platforms"] = ",".join(platforms) + if python_versions: + query["python_versions"] = ",".join(python_versions) del value id_and_link = "{:s}".format( @@ -3317,7 +3601,14 @@ class subcmd_server: ) else: blender_version_str = "all" + + if python_versions: + python_version_str = ", ".join(python_versions) + else: + python_version_str = "all" + fh.write(" {:s}\n".format(html.escape(blender_version_str))) + fh.write(" {:s}\n".format(html.escape(python_version_str))) fh.write(" {:s}\n".format(html.escape(", ".join(platforms) if platforms else "all"))) fh.write(" {:s}\n".format(html.escape(size_as_fmt_string(manifest_dict["archive_size"])))) fh.write(" \n") @@ -3403,7 +3694,7 @@ class subcmd_server: return False assert repo_config is None or isinstance(repo_config, PkgServerRepoConfig) - repo_data_idname_map: dict[str, list[PkgManifest]] = {} + repo_data_idname_map: dict[str, list[tuple[PkgManifest, str, list[tuple[int] | tuple[int, int]]]]] = {} repo_data: list[dict[str, Any]] = [] # Write package meta-data into each directory. @@ -3436,9 +3727,6 @@ class subcmd_server: manifest_dict = manifest._asdict() pkg_idname = manifest_dict["id"] - if (pkg_items := repo_data_idname_map.get(pkg_idname)) is None: - pkg_items = repo_data_idname_map[pkg_idname] = [] - pkg_items.append(manifest) # Call all optional keys so the JSON never contains `null` items. for key, value in list(manifest_dict.items()): @@ -3446,8 +3734,27 @@ class subcmd_server: del manifest_dict[key] # Don't include these in the server listing. - for key in ("wheels", ): - manifest_dict.pop(key, None) + wheels: list[str] = manifest_dict.pop("wheels", []) + + # Extract the `python_versions` from wheels. + python_versions_final: list[tuple[int] | tuple[int, int]] = [] + if wheels: + if isinstance(python_versions := python_versions_from_wheels(wheels), str): + msglog.warn("unable to parse Python version from \"wheels\" ({:s}): {:s}".format( + python_versions, + filepath, + )) + else: + python_versions_final[:] = sorted(python_versions) + + manifest_dict["python_versions"] = [ + ".".join(str(v) for v in version) + for version in python_versions_final + ] + + if (pkg_items := repo_data_idname_map.get(pkg_idname)) is None: + pkg_items = repo_data_idname_map[pkg_idname] = [] + pkg_items.append((manifest, filename, python_versions_final)) # These are added, ensure they don't exist. has_key_error = False @@ -3476,6 +3783,8 @@ class subcmd_server: for pkg_idname, pkg_items in repo_data_idname_map.items(): if len(pkg_items) == 1: continue + # Sort for predictable output. + pkg_items.sort(key=lambda pkg_item_ext: pkg_item_ext[1]) if (error := pkg_manifest_detect_duplicates(pkg_items)) is not None: msglog.warn("archive found with duplicates for id {:s}: {:s}".format(pkg_idname, error)) @@ -3614,6 +3923,7 @@ class subcmd_client: local_dir: str, filepath_archive: str, blender_version_tuple: tuple[int, int, int], + python_version_tuple: tuple[int, int, int], manifest_compare: PkgManifest | None, temp_prefix_and_suffix: tuple[str, str], ) -> bool: @@ -3670,6 +3980,7 @@ class subcmd_client: manifest._asdict(), filter_blender_version=blender_version_tuple, filter_platform=platform_from_this_system(), + filter_python_version=python_version_tuple, skip_message_fn=lambda message: any_as_none( msglog.error("{:s}: {:s}".format(manifest.id, message)) ), @@ -3736,6 +4047,7 @@ class subcmd_client: local_dir: str, package_files: Sequence[str], blender_version: str, + python_version: str, temp_prefix_and_suffix: tuple[str, str], ) -> bool: if not os.path.exists(local_dir): @@ -3747,6 +4059,11 @@ class subcmd_client: return False assert isinstance(blender_version_tuple, tuple) + if isinstance(python_version_tuple := python_version_parse_or_error(python_version), str): + msglog.fatal_error(python_version_tuple) + return False + assert isinstance(python_version_tuple, tuple) + # This is a simple file extraction, the main difference is that it validates the manifest before installing. directories_to_clean: list[str] = [] with CleanupPathsContext(files=(), directories=directories_to_clean): @@ -3756,6 +4073,7 @@ class subcmd_client: local_dir=local_dir, filepath_archive=filepath_archive, blender_version_tuple=blender_version_tuple, + python_version_tuple=python_version_tuple, # There is no manifest from the repository, leave this unset. manifest_compare=None, temp_prefix_and_suffix=temp_prefix_and_suffix, @@ -3775,6 +4093,7 @@ class subcmd_client: packages: Sequence[str], online_user_agent: str, blender_version: str, + python_version: str, access_token: str, timeout_in_seconds: float, temp_prefix_and_suffix: tuple[str, str], @@ -3790,6 +4109,11 @@ class subcmd_client: return False assert isinstance(blender_version_tuple, tuple) + if isinstance(python_version_tuple := python_version_parse_or_error(python_version), str): + msglog.fatal_error(python_version_tuple) + return False + assert isinstance(python_version_tuple, tuple) + # Extract... if isinstance((pkg_repo_data := repo_pkginfo_from_local_or_none(local_dir=local_dir)), str): msglog.fatal_error("Error loading package repository: {:s}".format(pkg_repo_data)) @@ -3854,6 +4178,7 @@ class subcmd_client: pkg_info, filter_blender_version=blender_version_tuple, filter_platform=platform_this, + filter_python_version=python_version_tuple, skip_message_fn=None, error_fn=lambda ex: any_as_none( # pylint: disable-next=cell-var-from-loop @@ -4002,6 +4327,7 @@ class subcmd_client: local_dir=local_dir, filepath_archive=filepath_local_cache_archive, blender_version_tuple=blender_version_tuple, + python_version_tuple=python_version_tuple, manifest_compare=manifest_archive.manifest, temp_prefix_and_suffix=temp_prefix_and_suffix, ): @@ -4813,6 +5139,7 @@ def argparse_create_client_install_files(subparsers: "argparse._SubParsersAction generic_arg_local_dir(subparse) generic_arg_blender_version(subparse) + generic_arg_python_version(subparse) generic_arg_temp_prefix_and_suffix(subparse) @@ -4824,6 +5151,7 @@ def argparse_create_client_install_files(subparsers: "argparse._SubParsersAction local_dir=args.local_dir, package_files=args.files, blender_version=args.blender_version, + python_version=args.python_version, temp_prefix_and_suffix=args.temp_prefix_and_suffix, ), ) @@ -4843,6 +5171,7 @@ def argparse_create_client_install(subparsers: "argparse._SubParsersAction[argpa generic_arg_local_cache(subparse) generic_arg_online_user_agent(subparse) generic_arg_blender_version(subparse) + generic_arg_python_version(subparse) generic_arg_access_token(subparse) generic_arg_temp_prefix_and_suffix(subparse) @@ -4859,6 +5188,7 @@ def argparse_create_client_install(subparsers: "argparse._SubParsersAction[argpa packages=args.packages.split(","), online_user_agent=args.online_user_agent, blender_version=args.blender_version, + python_version=args.python_version, access_token=args.access_token, timeout_in_seconds=args.timeout, temp_prefix_and_suffix=args.temp_prefix_and_suffix, diff --git a/scripts/addons_core/bl_pkg/tests/test_cli.py b/scripts/addons_core/bl_pkg/tests/test_cli.py index 838421f13b8..8bf20a56571 100644 --- a/scripts/addons_core/bl_pkg/tests/test_cli.py +++ b/scripts/addons_core/bl_pkg/tests/test_cli.py @@ -33,7 +33,7 @@ JSON_OutputElem = tuple[str, Any] # For more useful output that isn't clipped. # pylint: disable-next=protected-access -unittest.util._MAX_LENGTH = 10_000 +unittest.util._MAX_LENGTH = 10_000 # type: ignore IS_WIN32 = sys.platform == "win32" 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 02f11884516..6632d2532e7 100644 --- a/scripts/addons_core/bl_pkg/tests/test_cli_blender.py +++ b/scripts/addons_core/bl_pkg/tests/test_cli_blender.py @@ -40,7 +40,8 @@ from collections.abc import ( # For more useful output that isn't clipped. # pylint: disable-next=protected-access -unittest.util._MAX_LENGTH = 10_000 +unittest.util._MAX_LENGTH = 10_000 # type: ignore + PKG_EXT = ".zip" @@ -131,6 +132,8 @@ class WheelModuleParams(NamedTuple): module_name: str module_version: str + filename: str | None = None + def path_to_url(path: str) -> str: from urllib.parse import urljoin @@ -171,7 +174,7 @@ def create_package( pkg_idname: str, # Optional. - wheel_params: WheelModuleParams | None = None, + wheel_params: Sequence[WheelModuleParams] = (), platforms: tuple[str, ...] | None = None, blender_version_min: str | None = None, blender_version_max: str | None = None, @@ -180,22 +183,28 @@ def create_package( ) -> None: pkg_name = pkg_idname.replace("_", " ").title() - if wheel_params is not None: - wheel_filename, wheel_filedata = python_wheel_generate.generate_from_source( - module_name=wheel_params.module_name, - version=wheel_params.module_version, - source=( - "__version__ = {!r}\n" - "print(\"The wheel has been found\")\n" - ).format(wheel_params.module_version), - ) + wheel_filenames = [] + if wheel_params: + for w in wheel_params: + wheel_filename, wheel_filedata = python_wheel_generate.generate_from_source( + module_name=w.module_name, + version=w.module_version, + source=( + "__version__ = {!r}\n" + "print(\"The wheel has been found\")\n" + ).format(w.module_version), + ) + if w.filename is not None: + wheel_filename = w.filename - wheel_dir = os.path.join(pkg_src_dir, "wheels") - os.makedirs(wheel_dir, exist_ok=True) + wheel_dir = os.path.join(pkg_src_dir, "wheels") + os.makedirs(wheel_dir, exist_ok=True) - wheel_path = os.path.join(wheel_dir, wheel_filename) - with open(wheel_path, "wb") as fh: - fh.write(wheel_filedata) + wheel_path = os.path.join(wheel_dir, wheel_filename) + with open(wheel_path, "wb") as fh: + fh.write(wheel_filedata) + + wheel_filenames.append(wheel_filename) with open(os.path.join(pkg_src_dir, PKG_MANIFEST_FILENAME_TOML), "w", encoding="utf-8") as fh: fh.write('''# Example\n''') @@ -213,15 +222,18 @@ def create_package( fh.write('''blender_version_max = "{:s}"\n'''.format(blender_version_max)) fh.write('''\n''') - if wheel_params is not None: - fh.write('''wheels = ["./wheels/{:s}"]\n'''.format(wheel_filename)) + if wheel_filenames: + fh.write('''wheels = [''') + for wheel_filename in wheel_filenames: + fh.write(''' "./wheels/{:s}",\n'''.format(wheel_filename)) + fh.write(''']\n''') if platforms is not None: fh.write('''platforms = [{:s}]\n'''.format(", ".join(["\"{:s}\"".format(x) for x in platforms]))) with open(os.path.join(pkg_src_dir, "__init__.py"), "w", encoding="utf-8") as fh: - if wheel_params is not None: - fh.write("import {:s}\n".format(wheel_params.module_name)) + for w in wheel_params: + fh.write("import {:s}\n".format(w.module_name)) if python_script is not None: fh.write(python_script) @@ -408,7 +420,7 @@ class TestWithTempBlenderUser_MixIn(unittest.TestCase): self, *, pkg_idname: str, - wheel_params: WheelModuleParams | None = None, + wheel_params: Sequence[WheelModuleParams] = (), # Optional. pkg_filename: str | None = None, @@ -467,9 +479,11 @@ class TestSimple(TestWithTempBlenderUser_MixIn, unittest.TestCase): pkg_idname = "my_test_pkg" self.build_package( pkg_idname=pkg_idname, - wheel_params=WheelModuleParams( - module_name=wheel_module_name, - module_version="1.0.1", + wheel_params=( + WheelModuleParams( + module_name=wheel_module_name, + module_version="1.0.1", + ), ), ) @@ -554,9 +568,11 @@ class TestSimple(TestWithTempBlenderUser_MixIn, unittest.TestCase): packages_to_install.append(pkg_idname) self.build_package( pkg_idname=pkg_idname, - wheel_params=WheelModuleParams( - module_name=wheel_module_name, - module_version=wheel_module_version, + wheel_params=( + WheelModuleParams( + module_name=wheel_module_name, + module_version=wheel_module_version, + ), ), ) @@ -755,23 +771,150 @@ class TestPlatform(TestWithTempBlenderUser_MixIn, unittest.TestCase): "--repo-dir", TEMP_DIR_REMOTE, )) self.assertEqual(stdout, ( - '''WARN: archive found with duplicates for id {pkg_idname:s}: ''' - '''3 duplicate(s) found, conflicting blender versions \"{platform:s}\": ''' - '''([undefined] & [{version_a:s} -> {version_b:s}], ''' - '''[{version_a:s} -> {version_b:s}] & [{version_a:s} -> {version_e:s}], ''' - '''[{version_a:s} -> {version_e:s}] & [{version_c:s} -> {version_d:s}])\n''' + '''WARN: archive found with duplicates for id my_platform_test: ''' + '''3 duplicate(s) found, conflicting blender versions ''' + '''my_platform_test-linux_x64_conflict_no_version.zip=[undefined] & ''' + '''my_platform_test-linux_x64_no_conflict_old.zip=[2.3.0 -> 3.3.0], ''' + '''my_platform_test-linux_x64_no_conflict_old.zip=[2.3.0 -> 3.3.0] & ''' + '''my_platform_test-linux_x64_conflict.zip=[2.3.0 -> 4.5.0], ''' + '''my_platform_test-linux_x64_conflict.zip=[2.3.0 -> 4.5.0] & ''' + '''my_platform_test-linux_x64.zip=[4.3.0 -> 4.4.0]\n''' '''found 7 packages.\n''' - ).format( - pkg_idname=pkg_idname, - platform=platform_this, - version_a=version_a, - version_b=version_b, - version_c=version_c_this, - version_d=version_d, - version_e=version_e, )) +# While other tests use this command, focus on testing specific behaviors work as expected. +class TestPythonVersions(TestWithTempBlenderUser_MixIn, unittest.TestCase): + + def test_server_generate_version_with_dupes(self) -> None: + repo_id = "test_repo_blocklist" + repo_name = "MyTestRepoServerGenerate" + + self.repo_add(repo_id=repo_id, repo_name=repo_name) + + pkg_idnames = ( + ("my_test_pkg", "my_test_a", "example-1.2.3-cp311-cp311-any.whl"), + ("my_test_pkg", "my_test_b", "example-1.2.3-cp311-cp311-any.whl"), + ("my_test_pkg", "my_test_c", "example-1.2.3-cp311-cp311-any.whl"), + ) + + # Create a package contents. + for pkg_idname, pkg_filename, wheel_filename in pkg_idnames: + self.build_package( + pkg_idname=pkg_idname, + pkg_filename=pkg_filename, + blender_version_min="4.2.0", + blender_version_max="4.3.0", + wheel_params=( + WheelModuleParams( + module_name="example", + module_version="1.0.1", + filename=wheel_filename, + ), + ), + ) + + # Generate the repository. + returncode, stdout, stderr = run_blender_extensions(( + "server-generate", + "--repo-dir", TEMP_DIR_REMOTE, + )) + self.assertEqual(stderr, "") + self.assertEqual(returncode, 0) + self.assertEqual( + stdout, + ( + '''WARN: archive found with duplicates for id my_test_pkg: ''' + '''2 duplicate(s) found, conflicting blender versions ''' + '''my_test_a.zip=[4.2.0 -> 4.3.0] & my_test_b.zip=[4.2.0 -> 4.3.0], ''' + '''my_test_b.zip=[4.2.0 -> 4.3.0] & my_test_c.zip=[4.2.0 -> 4.3.0]\n''' + '''found 3 packages.\n''' + ), + ) + + def test_server_generate_version_with_dupes_major_only_mix(self) -> None: + repo_id = "test_repo_blocklist" + repo_name = "MyTestRepoServerGenerate" + + self.repo_add(repo_id=repo_id, repo_name=repo_name) + + pkg_idnames = ( + ("my_test_pkg", "my_test_a", "example-1.2.3-cp311-cp311-any.whl"), + ("my_test_pkg", "my_test_b", "example-1.2.3-py3.py4-py3.py4-any.whl"), + ) + + # Create a package contents. + for pkg_idname, pkg_filename, wheel_filename in pkg_idnames: + self.build_package( + pkg_idname=pkg_idname, + pkg_filename=pkg_filename, + blender_version_min="4.2.0", + blender_version_max="4.3.0", + wheel_params=( + WheelModuleParams( + module_name="example", + module_version="1.0.1", + filename=wheel_filename, + ), + ), + ) + + # Generate the repository. + returncode, stdout, stderr = run_blender_extensions(( + "server-generate", + "--repo-dir", TEMP_DIR_REMOTE, + )) + self.assertEqual(stderr, "") + self.assertEqual(returncode, 0) + self.assertEqual( + stdout, + ( + '''WARN: archive found with duplicates for id my_test_pkg: ''' + '''1 duplicate(s) found, conflicting blender versions ''' + '''my_test_a.zip=[4.2.0 -> 4.3.0] & my_test_b.zip=[4.2.0 -> 4.3.0]\n''' + '''found 2 packages.\n''' + ), + ) + + def test_server_generate_version_without_dupes(self) -> None: + # The different Python versions in the wheels cause the packages not to conflict. + repo_id = "test_repo_blocklist" + repo_name = "MyTestRepoServerGenerate" + + self.repo_add(repo_id=repo_id, repo_name=repo_name) + + pkg_idnames = ( + ("my_test_pkg", "my_test_a", "example-1.2.3-cp311-cp311-any.whl"), + ("my_test_pkg", "my_test_b", "example-1.2.3-cp312-cp312-any.whl"), + ("my_test_pkg", "my_test_c", "example-1.2.3-cp313-cp313-any.whl"), + ) + + # Create a package contents. + for pkg_idname, pkg_filename, wheel_filename in pkg_idnames: + self.build_package( + pkg_idname=pkg_idname, + pkg_filename=pkg_filename, + blender_version_min="4.2.0", + blender_version_max="4.3.0", + wheel_params=( + WheelModuleParams( + module_name="example", + module_version="1.0.1", + filename=wheel_filename, + ), + ), + ) + + # 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 3 packages.\n''') + + class TestModuleViolation(TestWithTempBlenderUser_MixIn, unittest.TestCase): def test_extension(self) -> None: