Improve tests, downloader, and project config

- Expand blender_downloader with better error handling and platform support
- Add test_main.py for main module tests
- Expand test coverage for introspect and generate_stubs
- Remove reportUnknownMemberType suppression from conformance config
- Exclude conformance/ from ruff linting (docs examples have E402)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-02 11:49:53 +02:00
co-authored by Claude Opus 4.6
parent 7e1e18d845
commit 673b9e54bc
7 changed files with 420 additions and 44 deletions
+86 -19
View File
@@ -4,6 +4,7 @@ import platform
import re
import tarfile
import urllib.request
import zipfile
from pathlib import Path
BLENDER_RELEASE_URL = "https://download.blender.org/release"
@@ -79,6 +80,74 @@ def get_extracted_dir_name(version: str) -> str:
return f"blender-{version}-{suffix}"
def _expected_executable_path(extracted_dir: Path) -> Path:
"""Return the Blender executable path within an extracted archive directory."""
system = platform.system()
if system == "Windows":
return extracted_dir / "blender.exe"
if system == "Darwin":
return extracted_dir / "Blender.app" / "Contents" / "MacOS" / "Blender"
return extracted_dir / "blender"
def _is_safe_extract_path(base_dir: Path, member_path: str) -> bool:
"""Check if an archive member path is safe to extract under base_dir."""
base_resolved = base_dir.resolve()
target_resolved = (base_dir / member_path).resolve()
try:
target_resolved.relative_to(base_resolved)
except ValueError:
return False
return True
def extract_tar_archive(archive_path: Path, destination: Path) -> None:
"""Extract a .tar.xz archive with path traversal protection."""
with tarfile.open(archive_path, "r:xz") as tar:
members = tar.getmembers()
total_members = len(members)
for i, member in enumerate(members):
if not _is_safe_extract_path(destination, member.name):
msg = f"Unsafe path in archive: {member.name}"
raise RuntimeError(msg)
tar.extract(member, path=destination)
if total_members:
pct = (i + 1) * 100 // total_members
print(
f"\r [{pct:3d}%] {i + 1}/{total_members} files", end="", flush=True
)
print()
def extract_zip_archive(archive_path: Path, destination: Path) -> None:
"""Extract a .zip archive with path traversal protection."""
with zipfile.ZipFile(archive_path, "r") as zf:
members = zf.infolist()
total_members = len(members)
for i, member in enumerate(members):
if not _is_safe_extract_path(destination, member.filename):
msg = f"Unsafe path in archive: {member.filename}"
raise RuntimeError(msg)
zf.extract(member, path=destination)
if total_members:
pct = (i + 1) * 100 // total_members
print(
f"\r [{pct:3d}%] {i + 1}/{total_members} files", end="", flush=True
)
print()
def _extract_cached_patch(name: str, major_minor: str, suffix: str) -> int | None:
"""Extract patch number from a cached directory name if it matches major.minor."""
match = re.fullmatch(
rf"blender-{re.escape(major_minor)}\.(\d+)-{re.escape(suffix)}",
name,
)
if match is None:
return None
return int(match.group(1))
def download_blender(version: str) -> Path:
"""Download and extract a Blender version. Returns path to the extracted directory."""
DOWNLOADS_DIR.mkdir(parents=True, exist_ok=True)
@@ -121,17 +190,10 @@ def download_blender(version: str) -> Path:
print()
print(f" Extracting {archive_name}...")
with tarfile.open(archive_path, "r:xz") as tar:
members = tar.getmembers()
total_members = len(members)
for i, member in enumerate(members):
tar.extract(member, path=DOWNLOADS_DIR)
if total_members:
pct = (i + 1) * 100 // total_members
print(
f"\r [{pct:3d}%] {i + 1}/{total_members} files", end="", flush=True
)
print()
if ext == ".zip":
extract_zip_archive(archive_path, DOWNLOADS_DIR)
else:
extract_tar_archive(archive_path, DOWNLOADS_DIR)
# Remove the archive to save space
archive_path.unlink()
@@ -151,14 +213,19 @@ def get_blender_executable(major_minor: str) -> Path:
# Check if already cached
DOWNLOADS_DIR.mkdir(parents=True, exist_ok=True)
suffix = get_platform_suffix()
pattern = f"blender-{major_minor}.*-{suffix}"
cached_candidates: list[tuple[int, Path]] = []
for entry in DOWNLOADS_DIR.iterdir():
if entry.is_dir() and re.match(pattern, entry.name):
executable = entry / "blender"
if executable.exists():
print(f" Using cached: {executable}")
return executable
if not entry.is_dir():
continue
patch = _extract_cached_patch(entry.name, major_minor, suffix)
if patch is not None:
cached_candidates.append((patch, entry))
for _patch, entry in sorted(cached_candidates, reverse=True):
executable = _expected_executable_path(entry)
if executable.exists():
print(f" Using cached: {executable}")
return executable
# Download the latest patch version
print(f"Resolving latest Blender {major_minor} patch version...")
@@ -166,7 +233,7 @@ def get_blender_executable(major_minor: str) -> Path:
print(f" Latest: {version}")
extracted_dir = download_blender(version)
executable = extracted_dir / "blender"
executable = _expected_executable_path(extracted_dir)
if not executable.exists():
msg = f"Blender executable not found at {executable}"