Merge branch 'blender-v4.3-release'

This commit is contained in:
Campbell Barton
2024-11-07 18:25:00 +11:00
7 changed files with 705 additions and 117 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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'}

View File

@@ -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

View File

@@ -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(" <th>Description</th>\n")
fh.write(" <th>Website</th>\n")
fh.write(" <th>Blender Versions</th>\n")
fh.write(" <th>Python Versions</th>\n")
fh.write(" <th>Platforms</th>\n")
fh.write(" <th>Size</th>\n")
fh.write(" </tr>\n")
@@ -3273,6 +3554,7 @@ class subcmd_server:
fh.write(" <tr>\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 = "<a href=\"{:s}\">{:s}</a>".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(" <td>{:s}</td>\n".format(html.escape(blender_version_str)))
fh.write(" <td>{:s}</td>\n".format(html.escape(python_version_str)))
fh.write(" <td>{:s}</td>\n".format(html.escape(", ".join(platforms) if platforms else "all")))
fh.write(" <td>{:s}</td>\n".format(html.escape(size_as_fmt_string(manifest_dict["archive_size"]))))
fh.write(" </tr>\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,

View File

@@ -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"

View File

@@ -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: