Files
AnyImage/tools/pack.py
nan 7e55af6722 Add comprehensive tests for edge depth extension, empty image edits, and material view adaptation
- Introduced `test_edge_depth.py` to validate the functionality of edge depth extension, ensuring core alpha preservation, proper depth handling, and edge padding behavior.
- Created `test_empty_image_edits.py` to verify the behavior of image edits in Blender, focusing on shared edits, preservation of image properties, and handling of failed operations.
- Developed `test_material_view_adaptation.py` to test material adaptation preferences, ensuring color space and alpha settings are correctly applied and preserved across various scenarios.
2026-09-06 21:43:47 +08:00

249 lines
8.4 KiB
Python

import argparse
import platform as host_platform
import re
import shutil
import subprocess
import sys
import tempfile
import tomllib
from dataclasses import dataclass
from pathlib import Path
from zipfile import ZIP_DEFLATED, ZipFile
PROJECT_ROOT = Path(__file__).resolve().parents[1]
SOURCE_DIR = PROJECT_ROOT / "src" / "anyimage"
DIST_DIR = PROJECT_ROOT / "dist"
PYTHON_TARGETS = {
"3.11": "numpy>=1.26,<2.0",
"3.13": "numpy>=2.1,<3.0",
}
# Resolve NumPy for dependency compatibility, but use Blender's runtime copy.
BLENDER_PROVIDED_PACKAGES = frozenset({"numpy"})
@dataclass(frozen=True)
class Platform:
pip_platform: str
PLATFORM_CONFIGS = {
"windows-x64": Platform(pip_platform="win_amd64"),
"macos-arm64": Platform(pip_platform="macosx_12_0_arm64"),
"linux-x64": Platform(pip_platform="manylinux2014_x86_64"),
}
PLATFORMS = tuple(PLATFORM_CONFIGS)
def project_dependencies():
"""Return Extension wheel requirements declared by the project."""
project = tomllib.loads(
(PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8")
)["project"]
return project.get("dependencies", [])
def download_wheels(platform, destination):
"""Download default dependencies as wheels for one target platform."""
dependencies = project_dependencies()
if not dependencies:
return []
config = PLATFORM_CONFIGS[platform]
# Blender supplies NumPy; resolve against each runtime's compatible range.
dependencies = [
dependency
for dependency in dependencies
if re.split(r"[\s\[<>=!~;@]", dependency, maxsplit=1)[0]
.lower().replace("_", "-") not in BLENDER_PROVIDED_PACKAGES
]
wheels_by_name = {}
shared_constraints = destination / "shared-constraints.txt"
for python_version, numpy_requirement in PYTHON_TARGETS.items():
target_directory = destination / python_version
target_directory.mkdir()
command = [
sys.executable,
"-m",
"pip",
"download",
*dependencies,
numpy_requirement,
"--dest",
str(target_directory),
"--find-links",
str(SOURCE_DIR / "wheels"),
"--only-binary=:all:",
f"--python-version={python_version}",
"--implementation=cp",
f"--platform={config.pip_platform}",
]
if shared_constraints.exists():
command.extend(["--constraint", str(shared_constraints)])
subprocess.run(command, check=True)
wheels = extension_wheels(sorted(target_directory.glob("*.whl")))
if not wheels:
raise RuntimeError(
f"No wheels downloaded for {platform}, Python {python_version}"
)
if not shared_constraints.exists():
shared_constraints.write_text(
"\n".join(
"==".join(wheel.name.split("-")[:2])
for wheel in wheels
if wheel.name.endswith("-none-any.whl")
),
encoding="utf-8",
)
for wheel in wheels:
wheels_by_name[wheel.name] = wheel
return [wheels_by_name[name] for name in sorted(wheels_by_name)]
def extension_wheels(wheels):
"""Exclude distributions supplied by the supported Blender runtime."""
return [
wheel
for wheel in wheels
if wheel.name.split("-", 1)[0].lower().replace("_", "-")
not in BLENDER_PROVIDED_PACKAGES
]
def current_platform():
target = {
("Windows", "amd64"): "windows-x64",
("Darwin", "arm64"): "macos-arm64",
("Linux", "x86_64"): "linux-x64",
}.get((host_platform.system(), host_platform.machine().lower()))
if target is None:
raise ValueError("Unsupported host platform; specify --platform explicitly")
return target
def prepare_local_dependencies(platform):
"""Synchronize local wheels and Manifest after a successful download."""
if platform not in PLATFORMS:
raise ValueError(f"Unsupported platform: {platform}")
with tempfile.TemporaryDirectory() as directory:
wheels = download_wheels(platform, Path(directory))
manifest = manifest_for_platform(platform, wheels)
destination = SOURCE_DIR / "wheels"
destination.mkdir(exist_ok=True)
for wheel in wheels:
shutil.copy2(wheel, destination / wheel.name)
selected = {wheel.name for wheel in wheels}
for wheel in destination.glob("*.whl"):
if wheel.name not in selected:
wheel.unlink()
manifest_path = SOURCE_DIR / "blender_manifest.toml"
temporary_manifest = manifest_path.with_suffix(".toml.tmp")
temporary_manifest.write_text(manifest, encoding="utf-8")
temporary_manifest.replace(manifest_path)
return sorted(destination.glob("*.whl"))
def manifest_for_platform(platform, wheels):
"""Return manifest text for one platform and its downloaded wheels."""
wheels = extension_wheels(wheels)
text = (SOURCE_DIR / "blender_manifest.toml").read_text(encoding="utf-8")
lines = text.splitlines()
table_start = next(
(i for i, line in enumerate(lines) if line.startswith("[")),
len(lines),
)
head = lines[:table_start]
tail = lines[table_start:]
head = [
line
for line in head
if not line.strip().startswith(("platforms =", "wheels ="))
]
insert_at = next(
(
i + 1
for i, line in enumerate(head)
if line.strip().startswith("blender_version_min")
),
len(head),
)
head.insert(insert_at, f'platforms = ["{platform}"]')
wheel_names = ", ".join(f'"./wheels/{wheel.name}"' for wheel in wheels)
head.insert(insert_at + 1, f"wheels = [{wheel_names}]")
return "\n".join(head + tail) + "\n"
def _build_extension(platform, wheels):
wheels = extension_wheels(wheels)
manifest_text = manifest_for_platform(platform, wheels)
manifest = tomllib.loads(manifest_text)
output_path = DIST_DIR / (
f"{manifest['name']}.v{manifest['version']}.{platform}.zip"
)
DIST_DIR.mkdir(exist_ok=True)
with ZipFile(output_path, "w", ZIP_DEFLATED) as archive:
for source_path in sorted(SOURCE_DIR.rglob("*")):
if not source_path.is_file():
continue
if "__pycache__" in source_path.parts:
continue
if (
source_path.suffix.startswith(".blend")
and source_path.suffix != ".blend"
):
continue
relative_path = source_path.relative_to(SOURCE_DIR)
if relative_path.parts[0] == "wheels":
continue
if source_path.name == "blender_manifest.toml":
continue
archive.write(source_path, relative_path)
archive.writestr("blender_manifest.toml", manifest_text)
for wheel in wheels:
archive.write(wheel, Path("wheels") / wheel.name)
return output_path
def build_extension(platform):
if platform not in PLATFORMS:
raise ValueError(f"Unsupported platform: {platform}")
with tempfile.TemporaryDirectory() as directory:
wheels = download_wheels(platform, Path(directory))
return _build_extension(platform, wheels)
def build_all():
return [build_extension(platform) for platform in PLATFORMS]
def main(argv=None):
parser = argparse.ArgumentParser(
description="Prepare local dependencies or pack Blender extensions"
)
parser.add_argument(
"--prepare",
action="store_true",
help="Update source wheels and Manifest for local testing",
)
parser.add_argument(
"--platform",
choices=PLATFORMS,
help="Target platform; preparation defaults to this computer",
)
args = parser.parse_args(argv)
if args.prepare:
platform = args.platform or current_platform()
wheels = prepare_local_dependencies(platform)
print(
f"Prepared {len(wheels)} wheels for {platform} in {SOURCE_DIR / 'wheels'}"
)
print(f"Updated {SOURCE_DIR / 'blender_manifest.toml'}")
return
extension_paths = [build_extension(args.platform)] if args.platform else build_all()
for extension_path in extension_paths:
size_mib = extension_path.stat().st_size / (1024 * 1024)
print(f"Built {extension_path} ({size_mib:.2f} MiB)")
if __name__ == "__main__":
main()