176 lines
5.6 KiB
Python
176 lines
5.6 KiB
Python
"""Download and cache Blender binaries for stub generation."""
|
|
|
|
import platform
|
|
import re
|
|
import tarfile
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
BLENDER_RELEASE_URL = "https://download.blender.org/release"
|
|
DOWNLOADS_DIR = Path(__file__).parent / "downloads"
|
|
|
|
|
|
def get_platform_suffix() -> str:
|
|
"""Get the platform-specific filename suffix for Blender downloads."""
|
|
system = platform.system()
|
|
machine = platform.machine()
|
|
|
|
if system == "Linux" and machine == "x86_64":
|
|
return "linux-x64"
|
|
|
|
if system == "Darwin":
|
|
if machine == "arm64":
|
|
return "macos-arm64"
|
|
return "macos-x64"
|
|
|
|
if system == "Windows":
|
|
return "windows-x64"
|
|
|
|
msg = f"Unsupported platform: {system} {machine}"
|
|
raise RuntimeError(msg)
|
|
|
|
|
|
def get_archive_extension() -> str:
|
|
"""Get the archive extension for the current platform."""
|
|
if platform.system() == "Windows":
|
|
return ".zip"
|
|
return ".tar.xz"
|
|
|
|
|
|
def find_latest_patch(major_minor: str) -> str:
|
|
"""Find the latest patch version for a given major.minor version.
|
|
|
|
Parses the HTML index at https://download.blender.org/release/Blender{X.Y}/
|
|
to find the highest patch version.
|
|
"""
|
|
url = f"{BLENDER_RELEASE_URL}/Blender{major_minor}/"
|
|
suffix = get_platform_suffix()
|
|
ext = get_archive_extension()
|
|
pattern = (
|
|
rf"blender-({re.escape(major_minor)}\.\d+)-{re.escape(suffix)}{re.escape(ext)}"
|
|
)
|
|
|
|
req = urllib.request.Request(url, headers={"User-Agent": "blender-python-stubs"})
|
|
with urllib.request.urlopen(req) as response:
|
|
html = response.read().decode()
|
|
|
|
versions = re.findall(pattern, html)
|
|
if not versions:
|
|
msg = f"No Blender {major_minor} releases found for {suffix} at {url}"
|
|
raise RuntimeError(msg)
|
|
|
|
# Sort by patch version and return the latest
|
|
versions.sort(key=lambda v: [int(x) for x in v.split(".")])
|
|
return versions[-1]
|
|
|
|
|
|
def get_download_url(version: str) -> str:
|
|
"""Build the download URL for a specific Blender version."""
|
|
major_minor = ".".join(version.split(".")[:2])
|
|
suffix = get_platform_suffix()
|
|
ext = get_archive_extension()
|
|
filename = f"blender-{version}-{suffix}{ext}"
|
|
return f"{BLENDER_RELEASE_URL}/Blender{major_minor}/{filename}"
|
|
|
|
|
|
def get_extracted_dir_name(version: str) -> str:
|
|
"""Get the expected directory name after extraction."""
|
|
suffix = get_platform_suffix()
|
|
return f"blender-{version}-{suffix}"
|
|
|
|
|
|
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)
|
|
|
|
dir_name = get_extracted_dir_name(version)
|
|
extracted_dir = DOWNLOADS_DIR / dir_name
|
|
|
|
if extracted_dir.exists():
|
|
print(f" Already cached: {extracted_dir}")
|
|
return extracted_dir
|
|
|
|
url = get_download_url(version)
|
|
suffix = get_platform_suffix()
|
|
ext = get_archive_extension()
|
|
archive_name = f"blender-{version}-{suffix}{ext}"
|
|
archive_path = DOWNLOADS_DIR / archive_name
|
|
|
|
print(f" Downloading {url}...")
|
|
req = urllib.request.Request(url, headers={"User-Agent": "blender-python-stubs"})
|
|
with urllib.request.urlopen(req) as response, archive_path.open("wb") as f:
|
|
total = int(response.headers.get("Content-Length", 0))
|
|
downloaded = 0
|
|
chunk_size = 1024 * 1024 # 1MB
|
|
while True:
|
|
chunk = response.read(chunk_size)
|
|
if not chunk:
|
|
break
|
|
f.write(chunk)
|
|
downloaded += len(chunk)
|
|
if total:
|
|
pct = downloaded * 100 // total
|
|
mb_done = downloaded / (1024 * 1024)
|
|
mb_total = total / (1024 * 1024)
|
|
print(
|
|
f"\r [{pct:3d}%] {mb_done:.0f}/{mb_total:.0f} MB",
|
|
end="",
|
|
flush=True,
|
|
)
|
|
if total:
|
|
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()
|
|
|
|
# Remove the archive to save space
|
|
archive_path.unlink()
|
|
|
|
if not extracted_dir.exists():
|
|
msg = f"Expected directory {extracted_dir} not found after extraction"
|
|
raise RuntimeError(msg)
|
|
|
|
return extracted_dir
|
|
|
|
|
|
def get_blender_executable(major_minor: str) -> Path:
|
|
"""Get the path to a Blender executable, downloading if needed.
|
|
|
|
Returns the path to the blender binary.
|
|
"""
|
|
# Check if already cached
|
|
DOWNLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
|
suffix = get_platform_suffix()
|
|
pattern = f"blender-{major_minor}.*-{suffix}"
|
|
|
|
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
|
|
|
|
# Download the latest patch version
|
|
print(f"Resolving latest Blender {major_minor} patch version...")
|
|
version = find_latest_patch(major_minor)
|
|
print(f" Latest: {version}")
|
|
|
|
extracted_dir = download_blender(version)
|
|
executable = extracted_dir / "blender"
|
|
|
|
if not executable.exists():
|
|
msg = f"Blender executable not found at {executable}"
|
|
raise RuntimeError(msg)
|
|
|
|
return executable
|