Merge branch 'blender-v4.2-release'

This commit is contained in:
Campbell Barton 2024-06-14 15:43:00 +10:00
commit 21a2d4f962
5 changed files with 631 additions and 95 deletions

@ -498,7 +498,7 @@ def repo_cache_store_ensure():
bl_extension_ops,
bl_extension_utils,
)
_repo_cache_store = bl_extension_utils.RepoCacheStore()
_repo_cache_store = bl_extension_utils.RepoCacheStore(bpy.app.version)
bl_extension_ops.repo_cache_store_refresh_from_prefs(_repo_cache_store)
return _repo_cache_store

@ -1423,6 +1423,7 @@ class EXTENSIONS_OT_package_upgrade_all(Operator, _ExtCmdMixIn):
remote_url=url_append_defaults(repo_item.remote_url),
pkg_id_sequence=pkg_id_sequence,
online_user_agent=online_user_agent_from_blender(),
blender_version=bpy.app.version,
access_token=repo_item.access_token,
use_cache=repo_item.use_cache,
use_idle=is_modal,
@ -1532,6 +1533,7 @@ class EXTENSIONS_OT_package_install_marked(Operator, _ExtCmdMixIn):
remote_url=url_append_defaults(repo_item.remote_url),
pkg_id_sequence=pkg_id_sequence,
online_user_agent=online_user_agent_from_blender(),
blender_version=bpy.app.version,
access_token=repo_item.access_token,
use_cache=repo_item.use_cache,
use_idle=is_modal,
@ -2154,6 +2156,7 @@ class EXTENSIONS_OT_package_install(Operator, _ExtCmdMixIn):
remote_url=url_append_defaults(repo_item.remote_url),
pkg_id_sequence=(pkg_id,),
online_user_agent=online_user_agent_from_blender(),
blender_version=bpy.app.version,
access_token=repo_item.access_token,
use_cache=repo_item.use_cache,
use_idle=is_modal,

@ -360,20 +360,10 @@ def repo_index_outdated(directory: str) -> bool:
def platform_from_this_system() -> str:
import platform
system_replace = {
"darwin": "macos",
}
machine_replace = {
"x86_64": "x64",
"amd64": "x64",
}
system = platform.system().lower()
machine = platform.machine().lower()
return "{:s}-{:s}".format(
system_replace.get(system, system),
machine_replace.get(machine, machine),
)
from .cli.blender_ext import platform_from_this_system
result = platform_from_this_system()
assert isinstance(result, str)
return result
def _url_append_query(url: str, query: Dict[str, str]) -> str:
@ -565,6 +555,7 @@ def pkg_install(
directory: str,
remote_url: str,
pkg_id_sequence: Sequence[str],
blender_version: Tuple[int, int, int],
online_user_agent: str,
access_token: str,
use_cache: bool,
@ -578,6 +569,7 @@ def pkg_install(
"install", ",".join(pkg_id_sequence),
"--local-dir", directory,
"--remote-url", remote_url,
"--blender-version", "{:d}.{:d}.{:d}".format(*blender_version),
"--online-user-agent", online_user_agent,
"--access-token", access_token,
"--local-cache", str(int(use_cache)),
@ -1175,17 +1167,33 @@ def repository_id_with_error_fn(
return pkg_idname
def repository_filter_skip(item: Dict[str, Any]) -> bool:
# TODO: filter out items that:
# - Don't match this systems platform.
# - Don't match the version range of Blender.
return False
# Values used to exclude incompatible packages when listing & installing.
class PkgManifest_FilterParams(NamedTuple):
platform: str
blender_version: Tuple[int, int, int]
def repository_filter_skip(
item: Dict[str, Any],
filter_params: PkgManifest_FilterParams,
error_fn: Callable[[Exception], None],
) -> bool:
from .cli.blender_ext import repository_filter_skip
result = repository_filter_skip(
item,
filter_blender_version=filter_params.blender_version,
filter_platform=filter_params.platform,
error_fn=error_fn,
)
assert isinstance(result, bool)
return result
def repository_filter_packages(
data: List[Dict[str, Any]],
*,
repo_directory: str,
filter_params: PkgManifest_FilterParams,
error_fn: Callable[[Exception], None],
) -> Dict[str, PkgManifest_Normalized]:
pkg_manifest_map = {}
@ -1197,7 +1205,7 @@ def repository_filter_packages(
)) is None:
continue
if repository_filter_skip(item):
if repository_filter_skip(item, filter_params, error_fn):
continue
if (value := PkgManifest_Normalized.from_dict_with_error_fn(
@ -1288,14 +1296,20 @@ class _RepoDataSouce_JSON(_RepoDataSouce_ABC):
"_data",
"_filepath",
"_filter_params",
"_mtime",
)
def __init__(self, directory: str):
def __init__(
self,
directory: str,
filter_params: PkgManifest_FilterParams,
):
filepath = os.path.join(directory, REPO_LOCAL_JSON)
self._filepath: str = filepath
self._mtime: int = 0
self._filter_params: PkgManifest_FilterParams = filter_params
self._data: Optional[RepoRemoteData] = None
def exists(self) -> bool:
@ -1362,6 +1376,7 @@ class _RepoDataSouce_JSON(_RepoDataSouce_ABC):
pkg_manifest_map=repository_filter_packages(
data_dict.get("data", []),
repo_directory=os.path.dirname(self._filepath),
filter_params=self._filter_params,
error_fn=error_fn,
),
)
@ -1377,11 +1392,17 @@ class _RepoDataSouce_TOML_FILES(_RepoDataSouce_ABC):
"_data",
"_directory",
"_filter_params",
"_mtime_for_each_package",
)
def __init__(self, directory: str):
def __init__(
self,
directory: str,
filter_params: PkgManifest_FilterParams,
):
self._directory: str = directory
self._filter_params = filter_params
self._mtime_for_each_package: Optional[Dict[str, int]] = None
self._data: Optional[RepoRemoteData] = None
@ -1449,7 +1470,7 @@ class _RepoDataSouce_TOML_FILES(_RepoDataSouce_ABC):
)) is None:
continue
if repository_filter_skip(item_local):
if repository_filter_skip(item_local, self._filter_params, error_fn):
continue
if (value := PkgManifest_Normalized.from_dict_with_error_fn(
@ -1546,7 +1567,12 @@ class _RepoCacheEntry:
)
def __init__(self, directory: str, remote_url: str) -> None:
def __init__(
self,
directory: str,
remote_url: str,
filter_params: PkgManifest_FilterParams,
) -> None:
assert directory != ""
self.directory = directory
self.remote_url = remote_url
@ -1554,8 +1580,8 @@ class _RepoCacheEntry:
self._pkg_manifest_local: Optional[Dict[str, PkgManifest_Normalized]] = None
self._pkg_manifest_remote: Optional[Dict[str, PkgManifest_Normalized]] = None
self._pkg_manifest_remote_data_source: _RepoDataSouce_ABC = (
_RepoDataSouce_JSON(directory) if remote_url else
_RepoDataSouce_TOML_FILES(directory)
_RepoDataSouce_JSON(directory, filter_params) if remote_url else
_RepoDataSouce_TOML_FILES(directory, filter_params)
)
# Avoid many noisy prints.
self._pkg_manifest_remote_has_warning = False
@ -1691,11 +1717,16 @@ class _RepoCacheEntry:
class RepoCacheStore:
__slots__ = (
"_repos",
"_filter_params",
"_is_init",
)
def __init__(self) -> None:
def __init__(self, blender_version: Tuple[int, int, int]) -> None:
self._repos: List[_RepoCacheEntry] = []
self._filter_params = PkgManifest_FilterParams(
platform=platform_from_this_system(),
blender_version=blender_version,
)
self._is_init = False
def is_init(self) -> bool:
@ -1718,7 +1749,7 @@ class RepoCacheStore:
for directory, remote_url in repos:
repo_entry_test = repos_prev.get((directory, remote_url))
if repo_entry_test is None:
repo_entry_test = _RepoCacheEntry(directory, remote_url)
repo_entry_test = _RepoCacheEntry(directory, remote_url, self._filter_params)
self._repos.append(repo_entry_test)
self._is_init = True

@ -307,6 +307,7 @@ class PkgManifest(NamedTuple):
copyright: Optional[List[str]] = None
permissions: Optional[List[str]] = None
tags: Optional[List[str]] = None
platforms: Optional[List[str]] = None
wheels: Optional[List[str]] = None
@ -1490,6 +1491,7 @@ pkg_manifest_known_keys_and_types: Tuple[
# Type should be `dict` eventually, some existing packages will have a list of strings instead.
("permissions", (dict, list), pkg_manifest_validate_field_permissions),
("tags", list, pkg_manifest_validate_field_any_non_empty_list_of_non_empty_strings),
("platforms", list, pkg_manifest_validate_field_any_non_empty_list_of_non_empty_strings),
("wheels", list, pkg_manifest_validate_field_wheels),
)
@ -1611,6 +1613,99 @@ def pkg_manifest_is_valid_or_error_all(
# -----------------------------------------------------------------------------
# Standalone Utilities
def platform_from_this_system() -> str:
import platform
system_replace = {
"darwin": "macos",
}
machine_replace = {
"x86_64": "x64",
"amd64": "x64",
}
system = platform.system().lower()
machine = platform.machine().lower()
return "{:s}-{:s}".format(
system_replace.get(system, system),
machine_replace.get(machine, machine),
)
def repository_filter_skip(
item: Dict[str, Any],
*,
filter_blender_version: Tuple[int, int, int],
filter_platform: str,
error_fn: Callable[[Exception], None],
) -> bool:
if (platforms := item.get("platforms")) is not None:
if not isinstance(platforms, list):
# Possibly noisy, but this should *not* be happening on a regular basis.
error_fn(TypeError("platforms is not a list, found a: {:s}".format(str(type(platforms)))))
elif platforms and (filter_platform not in platforms):
return True
if filter_blender_version != (0, 0, 0):
version_min_str = item.get("blender_version_min")
version_max_str = item.get("blender_version_max")
if not (isinstance(version_min_str, str) or version_min_str is None):
error_fn(TypeError("blender_version_min expected a string, found: {:s}".format(str(type(version_min_str)))))
version_min_str = None
if not (isinstance(version_max_str, str) or version_max_str is None):
error_fn(TypeError("blender_version_max expected a string, found: {:s}".format(str(type(version_max_str)))))
version_max_str = None
if version_min_str is None:
version_min = None
elif isinstance(version_min := blender_version_parse_any_or_error(version_min_str), str):
error_fn(TypeError("blender_version_min invalid format: {:s}".format(version_min)))
version_min = None
if version_max_str is None:
version_max = None
elif isinstance(version_max := blender_version_parse_any_or_error(version_max_str), str):
error_fn(TypeError("blender_version_max invalid format: {:s}".format(version_max)))
version_max = None
del version_min_str, version_max_str
assert (isinstance(version_min, tuple) or version_min is None)
assert (isinstance(version_max, tuple) or version_max is None)
if (version_min is not None) and (filter_blender_version < version_min):
# Blender is older than the packages minimum supported version.
return True
if (version_max is not None) and (filter_blender_version >= version_max):
# Blender is newer or equal to the maximum value.
return True
return False
def blender_version_parse_or_error(version: str) -> Union[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))
if not version_tuple:
return "unable to parse empty blender version: {:s}".format(version)
# `mypy` can't detect that this is guaranteed to be 3 items.
return (
version_tuple if (len(version_tuple) == 3) else
(*version_tuple, (0, 0))[:3] # type: ignore
)
def blender_version_parse_any_or_error(version: Any) -> Union[Tuple[int, int, int], str]:
if not isinstance(version, str):
return "blender version should be a string, found a: {:s}".format(str(type(version)))
result = blender_version_parse_or_error(version)
assert isinstance(result, (tuple, str))
return result
def url_request_headers_create(*, accept_json: bool, user_agent: str, access_token: str) -> Dict[str, str]:
headers = {}
@ -1694,6 +1789,107 @@ def pkg_manifest_toml_is_valid_or_error(filepath: str, strict: bool) -> Tuple[Op
return None, result
def pkg_manifest_detect_duplicates(pkg_idname: str, pkg_items: List[PkgManifest]) -> Optional[str]:
"""
When a repository includes multiple packages with the same ID, ensure they don't conflict.
Ensure packages have non-overlapping:
- Platforms.
- Blender versions.
Return an error if they do, otherwise None.
"""
# Dummy ranges for the purpose of valid comparisons.
dummy_verion_min = 0, 0, 0
dummy_verion_max = 1000, 0, 0
def parse_version_or_default(version: Optional[str], default: Tuple[int, int, int]) -> Tuple[int, int, int]:
if version is None:
return default
if isinstance(version_parsed := blender_version_parse_or_error(version), str):
# NOTE: any error here will have already been handled.
assert False, "unreachable"
return default
return version_parsed
def version_range_as_str(version_min: Tuple[int, int, int], version_max: Tuple[int, int, int]) -> str:
dummy_min = version_min == dummy_verion_min
dummy_max = version_max == dummy_verion_max
if dummy_min and dummy_max:
return "[undefined]"
version_min_str = "..." if dummy_min else "{:d}.{:d}.{:d}".format(*version_min)
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 ())
)))
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
# 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_ranges: List[Tuple[Tuple[int, int, int], Tuple[int, int, int]]] = []
for manifest in pkg_items_platform:
version_ranges.append((
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]
# Previous maximum is less than or equal to the current minimum, no overlap.
if not (item_prev[1] <= item_curr[0]):
duplicates_found.append("{:s} & {:s}".format(
version_range_as_str(*item_prev),
version_range_as_str(*item_curr),
))
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[""])
return "{:d} duplicate(s) found, conflicting blender versions {:s}".format(
sum(map(len, platform_dupliates.values())),
error_text,
)
# No collisions found.
return None
def toml_from_bytes(data: bytes) -> Optional[Dict[str, Any]]:
result = tomllib.loads(data.decode('utf-8'))
assert isinstance(result, dict)
@ -1975,6 +2171,19 @@ def generic_arg_local_dir(subparse: argparse.ArgumentParser) -> None:
)
def generic_arg_blender_version(subparse: argparse.ArgumentParser) -> None:
subparse.add_argument(
"--blender-version",
dest="blender_version",
default="0.0.0",
type=str,
help=(
"The version of Blender used for selecting packages."
),
required=False,
)
# Only for authoring.
def generic_arg_package_source_path_positional(subparse: argparse.ArgumentParser) -> None:
subparse.add_argument(
@ -2176,7 +2385,7 @@ class subcmd_server:
message_error(msg_fn, "Directory: {!r} not found!".format(repo_dir))
return False
repo_data_idname_unique: Set[str] = set()
repo_data_idname_map: Dict[str, List[PkgManifest]] = {}
repo_data: List[Dict[str, Any]] = []
# Write package meta-data into each directory.
repo_gen_dict = {
@ -2204,11 +2413,10 @@ class subcmd_server:
continue
manifest_dict = manifest._asdict()
repo_data_idname_unique_len = len(repo_data_idname_unique)
repo_data_idname_unique.add(manifest_dict["id"])
if len(repo_data_idname_unique) == repo_data_idname_unique_len:
message_warn(msg_fn, "archive found with duplicate id {!r}, {!r}".format(manifest_dict["id"], filepath))
continue
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()):
@ -2239,6 +2447,15 @@ class subcmd_server:
repo_data.append(manifest_dict)
# Detect duplicates:
# repo_data_idname_map
for pkg_idname, pkg_items in repo_data_idname_map.items():
if len(pkg_items) == 1:
continue
if (error := pkg_manifest_detect_duplicates(pkg_idname, pkg_items)) is not None:
message_warn(msg_fn, "archive found with duplicates for id {:s}: {:s}".format(pkg_idname, error))
del repo_data_idname_map
filepath_repo_json = os.path.join(repo_dir, PKG_REPO_LIST_FILENAME)
with open(filepath_repo_json, "w", encoding="utf-8") as fh:
@ -2484,6 +2701,7 @@ class subcmd_client:
local_cache: bool,
packages: Sequence[str],
online_user_agent: str,
blender_version: str,
access_token: str,
timeout_in_seconds: float,
) -> bool:
@ -2493,6 +2711,11 @@ class subcmd_client:
message_error(msg_fn, error)
return False
if isinstance(blender_version_tuple := blender_version_parse_or_error(blender_version), str):
message_error(msg_fn, blender_version_tuple)
return False
assert isinstance(blender_version_tuple, tuple)
# Extract...
pkg_repo_data = repo_pkginfo_from_local(local_dir=local_dir)
if pkg_repo_data is None:
@ -2521,6 +2744,8 @@ class subcmd_client:
for pkg_info in json_data_pkg_info:
json_data_pkg_info_map[pkg_info["id"]].append(pkg_info)
platform_this = platform_from_this_system()
has_error = False
packages_info: List[PkgManifest_Archive] = []
for pkg_idname, pkg_info_list in json_data_pkg_info_map.items():
@ -2529,6 +2754,24 @@ class subcmd_client:
has_error = True
continue
def error_handle(ex: Exception) -> None:
message_warn(msg_fn, "{:s}: {:s}".format(pkg_idname, str(ex)))
pkg_info_list = [
pkg_info for pkg_info in pkg_info_list
if not repository_filter_skip(
pkg_info,
filter_blender_version=blender_version_tuple,
filter_platform=platform_this,
error_fn=error_handle,
)
]
if not pkg_info_list:
message_error(msg_fn, "Package \"{:s}\", found but not compatible with this system".format(pkg_idname))
has_error = True
continue
# TODO: use a tie breaker.
pkg_info = pkg_info_list[0]
@ -3301,6 +3544,7 @@ def argparse_create_client_install(subparsers: "argparse._SubParsersAction[argpa
generic_arg_local_dir(subparse)
generic_arg_local_cache(subparse)
generic_arg_online_user_agent(subparse)
generic_arg_blender_version(subparse)
generic_arg_access_token(subparse)
generic_arg_output_type(subparse)
@ -3314,6 +3558,7 @@ def argparse_create_client_install(subparsers: "argparse._SubParsersAction[argpa
local_cache=args.local_cache,
packages=args.packages.split(","),
online_user_agent=args.online_user_agent,
blender_version=args.blender_version,
access_token=args.access_token,
timeout_in_seconds=args.timeout,
),

@ -5,7 +5,9 @@
"""
This test emulates running packaging commands with Blender via the command line.
This also happens to test packages with ``*.whl``.
This also happens to test:
- Packages with ``*.whl``.
- Packages compatibility (mixing supported/unsupported platforms & versions).
Command to run this test:
make test_cli_blender BLENDER_BIN=$PWD/../../../blender.bin
@ -21,12 +23,18 @@ import time
import unittest
from typing import (
Any,
Dict,
NamedTuple,
Optional,
Sequence,
Tuple,
)
# For more useful output that isn't clipped.
unittest.util._MAX_LENGTH = 10_000
PKG_MANIFEST_FILENAME_TOML = "blender_manifest.toml"
VERBOSE_CMD = False
@ -36,6 +44,10 @@ BLENDER_BIN = os.environ.get("BLENDER_BIN")
if BLENDER_BIN is None:
raise Exception("BLENDER_BIN: environment variable not defined")
BLENDER_VERSION_STR = subprocess.check_output([BLENDER_BIN, "--version"]).split()[1].decode('ascii')
BLENDER_VERSION: Tuple[int, int, int] = tuple(int(x) for x in BLENDER_VERSION_STR.split(".")) # type: ignore
assert len(BLENDER_VERSION) == 3
# Arguments to ensure extensions are enabled (currently it's an experimental feature).
BLENDER_ENABLE_EXTENSION_ARGS = [
@ -48,6 +60,27 @@ sys.path.append(os.path.join(BASE_DIR, "modules"))
import python_wheel_generate # noqa: E402
# Don't import as module, instead load the class.
def execfile(filepath: str, *, name: str = "__main__") -> Dict[str, Any]:
global_namespace = {"__file__": filepath, "__name__": name}
with open(filepath, encoding="utf-8") as file_handle:
exec(compile(file_handle.read(), filepath, 'exec'), global_namespace)
return global_namespace
_blender_ext = execfile(
os.path.join(
BASE_DIR,
"..",
"cli",
"blender_ext.py",
),
name="blender_ext",
)
platform_from_this_system = _blender_ext["platform_from_this_system"]
assert callable(platform_from_this_system)
# Write the command to a script, use so it's possible to manually run commands outside of the test environment.
TEMP_COMMAND_OUTPUT = "" # os.path.join(tempfile.gettempdir(), "blender_test.sh")
@ -58,6 +91,37 @@ USE_PAUSE_BEFORE_EXIT = False
# -----------------------------------------------------------------------------
# Utility Functions
# Generate different version numbers as strings, used for automatically creating versions
# which are known to be compatible or incompatible with the current version.
def blender_version_relative(version_offset: Tuple[int, int, int]) -> str:
version_new = (
BLENDER_VERSION[0] + version_offset[0],
BLENDER_VERSION[1] + version_offset[1],
BLENDER_VERSION[2] + version_offset[2],
)
assert min(*version_new) >= 0
return "{:d}.{:d}.{:d}".format(*version_new)
def python_script_generate_for_addon(text: str) -> str:
return (
'''def register():\n'''
''' print("Register success{sep:s}{text:s}:", __name__)\n'''
'''\n'''
'''def unregister():\n'''
''' print("Unregister success{sep:s}{text:s}:", __name__)\n'''
).format(
sep=" " if text else "",
text=text,
)
class WheelModuleParams(NamedTuple):
module_name: str
module_version: str
def path_to_url(path: str) -> str:
from urllib.parse import urljoin
from urllib.request import pathname2url
@ -67,7 +131,7 @@ def path_to_url(path: str) -> str:
def pause_until_keyboard_interrupt() -> None:
print("Waiting for keyboard interrupt...")
try:
time.sleep(10_000)
time.sleep(100_000)
except KeyboardInterrupt:
pass
print("Exiting!")
@ -93,26 +157,34 @@ def contents_to_filesystem(
def create_package(
pkg_src_dir: str,
*,
pkg_idname: str,
wheel_module_name: str,
wheel_module_version: str,
# Optional.
wheel_params: Optional[WheelModuleParams] = None,
platforms: Optional[Tuple[str, ...]] = None,
blender_version_min: Optional[str] = None,
blender_version_max: Optional[str] = None,
python_script: Optional[str] = None,
) -> None:
pkg_name = pkg_idname.replace("_", " ").title()
wheel_filename, wheel_filedata = python_wheel_generate.generate_from_source(
module_name=wheel_module_name,
version=wheel_module_version,
source=(
"__version__ = {!r}\n"
"print(\"The wheel has been found\")\n"
).format(wheel_module_version),
)
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_dir = os.path.join(pkg_src_dir, "wheels")
os.makedirs(wheel_dir, exist_ok=True)
path = os.path.join(wheel_dir, wheel_filename)
with open(path, "wb") as fh:
fh.write(wheel_filedata)
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)
with open(os.path.join(pkg_src_dir, PKG_MANIFEST_FILENAME_TOML), "w", encoding="utf-8") as fh:
fh.write('''# Example\n''')
@ -125,19 +197,25 @@ def create_package(
fh.write('''license = ["SPDX:GPL-2.0-or-later"]\n''')
fh.write('''version = "1.0.0"\n''')
fh.write('''tagline = "This is a tagline"\n''')
fh.write('''blender_version_min = "0.0.0"\n''')
fh.write('''blender_version_min = "{:s}"\n'''.format(blender_version_min or "0.0.0"))
if blender_version_min is not None:
fh.write('''blender_version_max = "{:s}"\n'''.format(blender_version_max))
fh.write('''\n''')
fh.write('''wheels = ["./wheels/{:s}"]\n'''.format(wheel_filename))
if wheel_params is not None:
fh.write('''wheels = ["./wheels/{:s}"]\n'''.format(wheel_filename))
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:
fh.write((
'''import {:s}\n'''
'''def register():\n'''
''' print("Register success:", __name__)\n'''
'''\n'''
'''def unregister():\n'''
''' print("Unregister success:", __name__)\n'''
).format(wheel_module_name))
if wheel_params is not None:
fh.write("import {:s}\n".format(wheel_params.module_name))
if python_script is not None:
fh.write(python_script)
else:
fh.write(python_script_generate_for_addon(text=""))
def run_blender(
@ -275,34 +353,64 @@ user_dirs: Tuple[str, ...] = (
class TestWithTempBlenderUser_MixIn(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
@staticmethod
def _repo_dirs_create() -> None:
for dirname in user_dirs:
os.makedirs(os.path.join(TEMP_DIR_BLENDER_USER, dirname), exist_ok=True)
os.makedirs(os.path.join(TEMP_DIR_BLENDER_USER, dirname), exist_ok=True)
os.makedirs(TEMP_DIR_REMOTE, exist_ok=True)
@classmethod
def tearDownClass(cls) -> None:
@staticmethod
def _repo_dirs_destroy() -> None:
for dirname in user_dirs:
shutil.rmtree(os.path.join(TEMP_DIR_BLENDER_USER, dirname))
shutil.rmtree(TEMP_DIR_REMOTE)
def setUp(self) -> None:
self._repo_dirs_create()
class TestSimple(TestWithTempBlenderUser_MixIn, unittest.TestCase):
def tearDown(self) -> None:
self._repo_dirs_destroy()
# Internal utilities.
def _build_package(
def repo_add(self, *, repo_id: str, repo_name: str) -> None:
stdout = run_blender_extensions_no_errors((
"repo-add",
"--name", repo_name,
"--directory", TEMP_DIR_LOCAL,
"--url", TEMP_DIR_REMOTE_AS_URL,
# A bit odd, this argument avoids running so many commands to setup a test.
"--clear-all",
repo_id,
))
self.assertEqual(stdout, "Info: Preferences saved\n")
def build_package(
self,
*,
pkg_idname: str,
wheel_module_name: str,
wheel_module_version: str,
wheel_params: Optional[WheelModuleParams] = None,
# Optional.
pkg_filename: Optional[str] = None,
platforms: Optional[Tuple[str, ...]] = None,
blender_version_min: Optional[str] = None,
blender_version_max: Optional[str] = None,
python_script: Optional[str] = None,
) -> None:
pkg_output_filepath = os.path.join(TEMP_DIR_REMOTE, pkg_idname + ".zip")
if pkg_filename is None:
pkg_filename = pkg_idname
pkg_output_filepath = os.path.join(TEMP_DIR_REMOTE, pkg_filename + ".zip")
with tempfile.TemporaryDirectory() as package_build_dir:
create_package(
package_build_dir,
pkg_idname=pkg_idname,
wheel_module_name=wheel_module_name,
wheel_module_version=wheel_module_version,
# Optional.
wheel_params=wheel_params,
platforms=platforms,
blender_version_min=blender_version_min,
blender_version_max=blender_version_max,
python_script=python_script,
)
stdout = run_blender_extensions_no_errors((
"build",
@ -315,35 +423,32 @@ class TestSimple(TestWithTempBlenderUser_MixIn, unittest.TestCase):
"Building {:s}.zip\n"
"complete\n"
"created \"{:s}\", {:d}\n"
).format(pkg_idname, pkg_output_filepath, os.path.getsize(pkg_output_filepath)),
).format(pkg_filename, pkg_output_filepath, os.path.getsize(pkg_output_filepath)),
)
class TestSimple(TestWithTempBlenderUser_MixIn, unittest.TestCase):
def test_simple_package(self) -> None:
"""
Create a simple package and install it.
"""
repo_id = "test_repo_module_name"
repo_name = "MyTestRepo"
stdout = run_blender_extensions_no_errors((
"repo-add",
"--name", "MyTestRepo",
"--directory", TEMP_DIR_LOCAL,
"--url", TEMP_DIR_REMOTE_AS_URL,
# A bit odd, this argument avoids running so many commands to setup a test.
"--clear-all",
repo_id,
))
self.assertEqual(stdout, "Info: Preferences saved\n")
self.repo_add(repo_id=repo_id, repo_name=repo_name)
wheel_module_name = "my_custom_wheel"
# Create a package contents.
pkg_idname = "my_test_pkg"
self._build_package(
self.build_package(
pkg_idname=pkg_idname,
wheel_module_name=wheel_module_name,
wheel_module_version="1.0.1",
wheel_params=WheelModuleParams(
module_name=wheel_module_name,
module_version="1.0.1",
),
)
# Generate the repository.
@ -367,19 +472,19 @@ class TestSimple(TestWithTempBlenderUser_MixIn, unittest.TestCase):
self.assertEqual(
stdout,
(
'''test_repo_module_name:\n'''
'''{:s}:\n'''
''' name: "MyTestRepo"\n'''
''' directory: "{:s}"\n'''
''' url: "{:s}"\n'''
).format(TEMP_DIR_LOCAL, TEMP_DIR_REMOTE_AS_URL))
).format(repo_id, TEMP_DIR_LOCAL, TEMP_DIR_REMOTE_AS_URL))
stdout = run_blender_extensions_no_errors(("list",))
self.assertEqual(
stdout,
(
'''Repository: "MyTestRepo" (id=test_repo_module_name)\n'''
'''Repository: "MyTestRepo" (id={:s})\n'''
''' my_test_pkg: "My Test Pkg", This is a tagline\n'''
)
).format(repo_id)
)
stdout = run_blender_extensions_no_errors(("install", pkg_idname, "--enable"))
@ -424,10 +529,12 @@ class TestSimple(TestWithTempBlenderUser_MixIn, unittest.TestCase):
("my_test_pkg_c", "3.0.1"),
):
packages_to_install.append(pkg_idname)
self._build_package(
self.build_package(
pkg_idname=pkg_idname,
wheel_module_name=wheel_module_name,
wheel_module_version=wheel_module_version,
wheel_params=WheelModuleParams(
module_name=wheel_module_name,
module_version=wheel_module_version,
),
)
# Generate the repository.
@ -488,6 +595,156 @@ class TestSimple(TestWithTempBlenderUser_MixIn, unittest.TestCase):
pause_until_keyboard_interrupt()
class TestPlatform(TestWithTempBlenderUser_MixIn, unittest.TestCase):
def test_platform_filter(self) -> None:
"""
Check that packages from different platforms are properly filtered.
"""
platforms_other = ["linux-x64", "macos-arm64", "windows-x64"]
platform_this = platform_from_this_system()
if platform_this in platforms_other:
platforms_other.remove(platform_this)
else: # For a predictable length.
del platforms_other[-1]
assert len(platforms_other) == 2
# Create two packages with the same ID, and ensure the package seen by Blender is the one for our platform.
repo_id = "test_repo_module_name"
repo_name = "MyTestRepo"
self.repo_add(repo_id=repo_id, repo_name=repo_name)
# Create a range of versions, note that only minimum versions beginning
# with `version_c_this` and higher can be installed with this Blender session.
version_a = blender_version_relative((-2, 0, 0))
version_b = blender_version_relative((-1, 0, 0))
version_c_this = blender_version_relative((0, 0, 0))
version_d = blender_version_relative((0, 1, 0))
version_e = blender_version_relative((0, 2, 0))
python_script_this = python_script_generate_for_addon("for this platform")
python_script_old = python_script_generate_for_addon("old")
python_script_new = python_script_generate_for_addon("new")
python_script_other = python_script_generate_for_addon("other")
python_script_conflict = python_script_generate_for_addon("conflict")
# Create a package contents (with a different wheel version).
pkg_idname = "my_platform_test"
for platform in (platform_this, *platforms_other):
if platform == platform_this:
python_script = python_script_this
else:
python_script = python_script_other
self.build_package(
pkg_idname=pkg_idname,
platforms=(platform,),
# Needed to prevent duplicates.
pkg_filename="{:s}-{:s}".format(pkg_idname, platform.replace("-", "_")),
blender_version_min=version_c_this,
blender_version_max=version_d,
python_script=python_script,
)
# Generate the repository.
stdout = run_blender_extensions_no_errors((
"server-generate",
"--repo-dir", TEMP_DIR_REMOTE,
))
self.assertEqual(stdout, "found 3 packages.\n")
for version_range, pkg_filename_suffix, python_script in (
((version_a, version_b), "_no_conflict_old", python_script_old),
((version_d, version_e), "_no_conflict_new", python_script_new),
):
self.build_package(
pkg_idname=pkg_idname,
platforms=(platform_this,),
pkg_filename="{:s}-{:s}{:s}".format(pkg_idname, platform_this.replace("-", "_"), pkg_filename_suffix),
blender_version_min=version_range[0],
blender_version_max=version_range[1],
python_script=python_script + "\n" + "print(" + repr(version_range) + ")",
)
# Re-generate the repository (no conflicts).
stdout = run_blender_extensions_no_errors((
"server-generate",
"--repo-dir", TEMP_DIR_REMOTE,
))
self.assertEqual(stdout, "found 5 packages.\n")
# Install the package and check it installs the correct package.
stdout = run_blender_extensions_no_errors((
"sync",
))
self.assertEqual(
stdout.rstrip("\n").split("\n")[-1],
"STATUS Extensions list for \"MyTestRepo\" updated",
)
stdout = run_blender_extensions_no_errors(("list",))
self.assertEqual(
stdout,
(
'''Repository: "MyTestRepo" (id={:s})\n'''
''' {:s}: "My Platform Test", This is a tagline\n'''
).format(repo_id, pkg_idname)
)
stdout = run_blender_extensions_no_errors(("install", pkg_idname, "--enable"), force_script_and_pause=False)
self.assertEqual(
[line for line in stdout.split("\n") if line.startswith("STATUS ")][0],
"STATUS Installed \"{:s}\"".format(pkg_idname)
)
# Ensure the correct package was installed, using the script text as an identifier.
self.assertTrue("Register success for this platform: " in stdout)
stdout = run_blender_extensions_no_errors(("remove", pkg_idname))
self.assertEqual(
[line for line in stdout.split("\n") if line.startswith("STATUS ")][0],
"STATUS Removed \"{:s}\"".format(pkg_idname)
)
# Now add two conflicting packages, one with a version, one without any versions.
for version_range, pkg_filename_suffix in (
(("", ""), "_conflict_no_version"),
((version_a, version_e), "_conflict"),
):
self.build_package(
pkg_idname=pkg_idname,
platforms=(platform_this,),
pkg_filename="{:s}-{:s}{:s}".format(pkg_idname, platform_this.replace("-", "_"), pkg_filename_suffix),
blender_version_min=version_range[0] or None,
blender_version_max=version_range[1] or None,
python_script=python_script_conflict,
)
stdout = run_blender_extensions_no_errors((
"server-generate",
"--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'''
'''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,
))
def main() -> None:
global TEMP_DIR_BLENDER_USER
global TEMP_DIR_REMOTE