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:
Joseph HENRY 2026-04-02 11:49:53 +02:00
parent 7e1e18d845
commit 673b9e54bc
7 changed files with 420 additions and 44 deletions

View File

@ -4,6 +4,7 @@ import platform
import re import re
import tarfile import tarfile
import urllib.request import urllib.request
import zipfile
from pathlib import Path from pathlib import Path
BLENDER_RELEASE_URL = "https://download.blender.org/release" 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}" 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: def download_blender(version: str) -> Path:
"""Download and extract a Blender version. Returns path to the extracted directory.""" """Download and extract a Blender version. Returns path to the extracted directory."""
DOWNLOADS_DIR.mkdir(parents=True, exist_ok=True) DOWNLOADS_DIR.mkdir(parents=True, exist_ok=True)
@ -121,17 +190,10 @@ def download_blender(version: str) -> Path:
print() print()
print(f" Extracting {archive_name}...") print(f" Extracting {archive_name}...")
with tarfile.open(archive_path, "r:xz") as tar: if ext == ".zip":
members = tar.getmembers() extract_zip_archive(archive_path, DOWNLOADS_DIR)
total_members = len(members) else:
for i, member in enumerate(members): extract_tar_archive(archive_path, DOWNLOADS_DIR)
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 # Remove the archive to save space
archive_path.unlink() archive_path.unlink()
@ -151,14 +213,19 @@ def get_blender_executable(major_minor: str) -> Path:
# Check if already cached # Check if already cached
DOWNLOADS_DIR.mkdir(parents=True, exist_ok=True) DOWNLOADS_DIR.mkdir(parents=True, exist_ok=True)
suffix = get_platform_suffix() suffix = get_platform_suffix()
pattern = f"blender-{major_minor}.*-{suffix}" cached_candidates: list[tuple[int, Path]] = []
for entry in DOWNLOADS_DIR.iterdir(): for entry in DOWNLOADS_DIR.iterdir():
if entry.is_dir() and re.match(pattern, entry.name): if not entry.is_dir():
executable = entry / "blender" continue
if executable.exists(): patch = _extract_cached_patch(entry.name, major_minor, suffix)
print(f" Using cached: {executable}") if patch is not None:
return executable 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 # Download the latest patch version
print(f"Resolving latest Blender {major_minor} 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}") print(f" Latest: {version}")
extracted_dir = download_blender(version) extracted_dir = download_blender(version)
executable = extracted_dir / "blender" executable = _expected_executable_path(extracted_dir)
if not executable.exists(): if not executable.exists():
msg = f"Blender executable not found at {executable}" msg = f"Blender executable not found at {executable}"

View File

@ -483,7 +483,6 @@ def conformance_check(versions: list[str] | None = None) -> None:
"typeCheckingMode": "strict", "typeCheckingMode": "strict",
"pythonVersion": python_version, "pythonVersion": python_version,
"reportUnusedExpression": False, "reportUnusedExpression": False,
"reportUnknownMemberType": False,
} }
) )
) )

View File

@ -33,7 +33,7 @@ extraPaths = ["stubs"]
exclude = ["downloads/", ".venv/", "dist/", "conformance/"] exclude = ["downloads/", ".venv/", "dist/", "conformance/"]
[tool.ruff] [tool.ruff]
exclude = ["dist/"] exclude = ["dist/", "conformance/"]
[tool.poe.tasks] [tool.poe.tasks]
format = { cmd = "black .", help = "Format all Python files" } format = { cmd = "black .", help = "Format all Python files" }

View File

@ -1,9 +1,18 @@
"""Tests for the Blender downloader module.""" """Tests for the Blender downloader module."""
import io
import tarfile
import tempfile
import unittest import unittest
import zipfile
from pathlib import Path
from unittest.mock import patch
from blender_downloader import ( from blender_downloader import (
extract_tar_archive,
extract_zip_archive,
get_archive_extension, get_archive_extension,
get_blender_executable,
get_download_url, get_download_url,
get_extracted_dir_name, get_extracted_dir_name,
) )
@ -11,31 +20,145 @@ from blender_downloader import (
class TestGetDownloadUrl(unittest.TestCase): class TestGetDownloadUrl(unittest.TestCase):
def test_builds_correct_url(self) -> None: def test_builds_correct_url(self) -> None:
url = get_download_url("5.0.1") with patch("blender_downloader.get_platform_suffix", return_value="linux-x64"):
self.assertIn("https://download.blender.org/release/Blender5.0/", url) with patch(
self.assertIn("blender-5.0.1-", url) "blender_downloader.get_archive_extension", return_value=".tar.xz"
self.assertTrue(url.endswith(get_archive_extension())) ):
url = get_download_url("5.0.1")
self.assertEqual(
url,
"https://download.blender.org/release/Blender5.0/blender-5.0.1-linux-x64.tar.xz",
)
def test_major_minor_extraction(self) -> None: def test_major_minor_extraction(self) -> None:
url = get_download_url("4.3.2") with patch("blender_downloader.get_platform_suffix", return_value="linux-x64"):
self.assertIn("/Blender4.3/", url) with patch(
self.assertIn("blender-4.3.2-", url) "blender_downloader.get_archive_extension", return_value=".tar.xz"
):
url = get_download_url("4.3.2")
self.assertEqual(
url,
"https://download.blender.org/release/Blender4.3/blender-4.3.2-linux-x64.tar.xz",
)
class TestGetExtractedDirName(unittest.TestCase): class TestGetExtractedDirName(unittest.TestCase):
def test_linux_dir_name(self) -> None: def test_dir_name_uses_platform_suffix(self) -> None:
name = get_extracted_dir_name("5.0.1") with patch(
self.assertEqual(name, "blender-5.0.1-linux-x64") "blender_downloader.get_platform_suffix", return_value="windows-x64"
):
def test_different_version(self) -> None: name = get_extracted_dir_name("5.0.1")
name = get_extracted_dir_name("4.3.2") self.assertEqual(name, "blender-5.0.1-windows-x64")
self.assertEqual(name, "blender-4.3.2-linux-x64")
class TestGetArchiveExtension(unittest.TestCase): class TestGetArchiveExtension(unittest.TestCase):
def test_linux_extension(self) -> None: def test_windows_extension(self) -> None:
ext = get_archive_extension() with patch("blender_downloader.platform.system", return_value="Windows"):
self.assertEqual(ext, ".tar.xz") self.assertEqual(get_archive_extension(), ".zip")
def test_non_windows_extension(self) -> None:
with patch("blender_downloader.platform.system", return_value="Linux"):
self.assertEqual(get_archive_extension(), ".tar.xz")
class TestGetBlenderExecutable(unittest.TestCase):
def _touch_executable(self, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("")
def test_cache_match_is_exact_for_major_minor(self) -> None:
with tempfile.TemporaryDirectory() as td:
downloads = Path(td)
target_dir = downloads / "blender-5.1.2-linux-x64"
self._touch_executable(target_dir / "blender")
# Must not match when requesting 5.1
wrong_dir = downloads / "blender-5.10.1-linux-x64"
self._touch_executable(wrong_dir / "blender")
with patch("blender_downloader.DOWNLOADS_DIR", downloads):
with patch(
"blender_downloader.get_platform_suffix", return_value="linux-x64"
):
with patch(
"blender_downloader.platform.system", return_value="Linux"
):
with patch("blender_downloader.find_latest_patch") as latest:
executable = get_blender_executable("5.1")
self.assertEqual(executable, target_dir / "blender")
latest.assert_not_called()
def test_uses_windows_executable_name(self) -> None:
with tempfile.TemporaryDirectory() as td:
downloads = Path(td)
target_dir = downloads / "blender-5.0.3-windows-x64"
self._touch_executable(target_dir / "blender.exe")
with patch("blender_downloader.DOWNLOADS_DIR", downloads):
with patch(
"blender_downloader.get_platform_suffix", return_value="windows-x64"
):
with patch(
"blender_downloader.platform.system", return_value="Windows"
):
executable = get_blender_executable("5.0")
self.assertEqual(executable, target_dir / "blender.exe")
def test_uses_macos_bundle_executable_path(self) -> None:
with tempfile.TemporaryDirectory() as td:
downloads = Path(td)
target_dir = downloads / "blender-5.1.1-macos-arm64"
self._touch_executable(
target_dir / "Blender.app" / "Contents" / "MacOS" / "Blender"
)
with patch("blender_downloader.DOWNLOADS_DIR", downloads):
with patch(
"blender_downloader.get_platform_suffix", return_value="macos-arm64"
):
with patch(
"blender_downloader.platform.system", return_value="Darwin"
):
executable = get_blender_executable("5.1")
self.assertEqual(
executable,
target_dir / "Blender.app" / "Contents" / "MacOS" / "Blender",
)
class TestSafeExtraction(unittest.TestCase):
def test_tar_extraction_blocks_path_traversal(self) -> None:
with tempfile.TemporaryDirectory() as td:
temp_dir = Path(td)
archive = temp_dir / "bad.tar.xz"
out_dir = temp_dir / "extract"
out_dir.mkdir()
info = tarfile.TarInfo("../evil.txt")
data = b"malicious"
info.size = len(data)
with tarfile.open(archive, "w:xz") as tar:
tar.addfile(info, io.BytesIO(data))
with self.assertRaises(RuntimeError):
extract_tar_archive(archive, out_dir)
self.assertFalse((temp_dir / "evil.txt").exists())
def test_zip_extraction_blocks_path_traversal(self) -> None:
with tempfile.TemporaryDirectory() as td:
temp_dir = Path(td)
archive = temp_dir / "bad.zip"
out_dir = temp_dir / "extract"
out_dir.mkdir()
with zipfile.ZipFile(archive, "w") as zf:
zf.writestr("../evil.txt", "malicious")
with self.assertRaises(RuntimeError):
extract_zip_archive(archive, out_dir)
self.assertFalse((temp_dir / "evil.txt").exists())
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -139,8 +139,7 @@ class TestGenerateFunctionStub(unittest.TestCase):
"is_classmethod": False, "is_classmethod": False,
} }
result = generate_function_stub(func) result = generate_function_stub(func)
self.assertNotIn("*, ", result) self.assertEqual(result, "def foo(x: str, **kwargs: object) -> None: ...\n")
self.assertIn("**kwargs", result)
def test_default_before_nondefault_reordered(self) -> None: def test_default_before_nondefault_reordered(self) -> None:
"""Non-default params must come before default params.""" """Non-default params must come before default params."""
@ -640,6 +639,10 @@ class TestTopologicalSort(unittest.TestCase):
] ]
result = topological_sort_structs(structs) result = topological_sort_structs(structs)
self.assertEqual(len(result), 2) self.assertEqual(len(result), 2)
names = [s["name"] for s in result]
self.assertEqual(set(names), {"A", "B"})
self.assertEqual(names.count("A"), 1)
self.assertEqual(names.count("B"), 1)
class TestCollectAllMethods(unittest.TestCase): class TestCollectAllMethods(unittest.TestCase):

View File

@ -1,7 +1,9 @@
"""Tests for the introspection module.""" """Tests for the introspection module."""
import unittest import unittest
from types import SimpleNamespace
from typing import cast from typing import cast
from unittest.mock import patch
from introspect import ( from introspect import (
FunctionData, FunctionData,
@ -12,6 +14,8 @@ from introspect import (
refine_types_by_context, refine_types_by_context,
clean_docstring, clean_docstring,
clean_type_str, clean_type_str,
introspect_ops_module,
introspect_screen_context_members,
parse_docstring_types, parse_docstring_types,
python_type_name, python_type_name,
) )
@ -160,6 +164,30 @@ class TestParseDocstringTypes(unittest.TestCase):
def test_plural_matrices(self) -> None: def test_plural_matrices(self) -> None:
self.assertEqual(clean_type_str("matrices"), "mathutils.Matrix") self.assertEqual(clean_type_str("matrices"), "mathutils.Matrix")
def test_informal_mathutils_types_are_qualified(self) -> None:
self.assertEqual(
clean_type_str("vector | matrix | quaternion | euler | color"),
"mathutils.Vector | mathutils.Matrix | mathutils.Quaternion | mathutils.Euler | mathutils.Color",
)
def test_literal_values_are_quoted(self) -> None:
self.assertEqual(
clean_type_str("Literal[NONE, ALPHA]"),
"Literal['NONE', 'ALPHA']",
)
def test_str_in_values_converts_to_literal(self) -> None:
self.assertEqual(
clean_type_str("string in ['NONE', 'ALPHA']"),
"Literal['NONE', 'ALPHA']",
)
def test_str_in_values_supports_double_quotes(self) -> None:
self.assertEqual(
clean_type_str('str in ["NONE", "ALPHA"]'),
"Literal['NONE', 'ALPHA']",
)
def test_sequence_multi_arg_to_tuple(self) -> None: def test_sequence_multi_arg_to_tuple(self) -> None:
self.assertEqual( self.assertEqual(
clean_type_str("Sequence[int, int]"), "Sequence[tuple[int, int]]" clean_type_str("Sequence[int, int]"), "Sequence[tuple[int, int]]"
@ -340,7 +368,8 @@ class TestRstOnlyParams(unittest.TestCase):
".. method:: foreach_get(seq)\n\nFast access to array data." ".. method:: foreach_get(seq)\n\nFast access to array data."
) )
result = introspect_callable(func, "foreach_get") result = introspect_callable(func, "foreach_get")
assert result is not None self.assertIsNotNone(result)
result = cast(FunctionData, result)
self.assertEqual(len(result["params"]), 1) self.assertEqual(len(result["params"]), 1)
self.assertEqual(result["params"][0]["name"], "seq") self.assertEqual(result["params"][0]["name"], "seq")
self.assertIsNone(result["params"][0]["type"]) self.assertIsNone(result["params"][0]["type"])
@ -349,7 +378,8 @@ class TestRstOnlyParams(unittest.TestCase):
"""RST params without :type: are added after typed params.""" """RST params without :type: are added after typed params."""
func = _NoSigCallable(".. method:: foo(a, b)\n\n:type a: str") func = _NoSigCallable(".. method:: foo(a, b)\n\n:type a: str")
result = introspect_callable(func, "foo") result = introspect_callable(func, "foo")
assert result is not None self.assertIsNotNone(result)
result = cast(FunctionData, result)
self.assertEqual(len(result["params"]), 2) self.assertEqual(len(result["params"]), 2)
self.assertEqual(result["params"][0]["name"], "a") self.assertEqual(result["params"][0]["name"], "a")
self.assertEqual(result["params"][0]["type"], "str") self.assertEqual(result["params"][0]["type"], "str")
@ -367,7 +397,8 @@ class TestParamNameMismatch(unittest.TestCase):
return "" return ""
result = introspect_callable(fake_func, "escape_identifier") result = introspect_callable(fake_func, "escape_identifier")
assert result is not None self.assertIsNotNone(result)
result = cast(FunctionData, result)
self.assertEqual(result["params"][0]["name"], "string") self.assertEqual(result["params"][0]["name"], "string")
self.assertEqual(result["params"][0]["type"], "str") self.assertEqual(result["params"][0]["type"], "str")
@ -378,7 +409,8 @@ class TestParamNameMismatch(unittest.TestCase):
return "" return ""
result = introspect_callable(fake_func, "basename") result = introspect_callable(fake_func, "basename")
assert result is not None self.assertIsNotNone(result)
result = cast(FunctionData, result)
self.assertEqual(result["params"][0]["name"], "path") self.assertEqual(result["params"][0]["name"], "path")
self.assertEqual(result["params"][0]["type"], "str") self.assertEqual(result["params"][0]["type"], "str")
@ -527,5 +559,71 @@ class TestInferGetterReturnTypes(unittest.TestCase):
self.assertIsNone(functions[0]["return_type"]) self.assertIsNone(functions[0]["return_type"])
class _ContextWithRaisingMember:
ok = 7
def __dir__(self) -> list[str]:
return ["ok", "broken", "callable_member", "rna_type"]
@property
def broken(self) -> object:
raise RuntimeError("cannot access in this context")
def callable_member(self) -> None:
return None
class _OpsNoCallableSubmodule:
non_callable = 123
def __dir__(self) -> list[str]:
return ["non_callable"]
class _OpsRootNoCallable:
mesh = _OpsNoCallableSubmodule()
def __dir__(self) -> list[str]:
return ["mesh"]
class _OpsRootEmpty:
def __dir__(self) -> list[str]:
return []
class TestRuntimeEdgeCases(unittest.TestCase):
def test_context_member_discovery_skips_raising_attributes(self) -> None:
fake_bpy = SimpleNamespace(context=_ContextWithRaisingMember())
with patch("introspect.importlib.import_module", return_value=fake_bpy):
props = introspect_screen_context_members(set())
by_name = {p["name"]: p for p in props}
self.assertIn("ok", by_name)
self.assertEqual(by_name["ok"]["type"], "int | None")
self.assertNotIn("broken", by_name)
self.assertNotIn("callable_member", by_name)
def test_ops_introspection_falls_back_when_no_callable_operator(self) -> None:
fake_bpy = SimpleNamespace(ops=_OpsRootNoCallable())
with patch("introspect.importlib.import_module", return_value=fake_bpy):
data = introspect_ops_module()
self.assertEqual(data["module"], "bpy.ops")
self.assertEqual(data["structs"], [])
self.assertEqual(
data["variables"], [{"name": "mesh", "type": "object", "value": "..."}]
)
def test_ops_introspection_falls_back_when_no_submodules(self) -> None:
fake_bpy = SimpleNamespace(ops=_OpsRootEmpty())
with patch("introspect.importlib.import_module", return_value=fake_bpy):
data = introspect_ops_module()
self.assertEqual(data["module"], "bpy.ops")
self.assertEqual(data["structs"], [])
self.assertEqual(data["variables"], [])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

86
tests/test_main.py Normal file
View File

@ -0,0 +1,86 @@
"""Tests for main orchestration helpers."""
import tempfile
import tomllib
import unittest
from pathlib import Path
from unittest.mock import patch
import main
class TestTemporaryConfigCleanup(unittest.TestCase):
def test_typecheck_stubs_removes_config_on_subprocess_exception(self) -> None:
with tempfile.TemporaryDirectory() as td:
script_dir = Path(td)
version_dir = script_dir / "dist" / "5.0"
version_dir.mkdir(parents=True)
(version_dir / ".python-version").write_text("3.11")
config_path = version_dir / "pyrightconfig.json"
with patch("main.SCRIPT_DIR", script_dir):
with patch("main.subprocess.run", side_effect=RuntimeError("boom")):
with self.assertRaises(RuntimeError):
main.typecheck_stubs(["5.0"])
self.assertFalse(config_path.exists())
def test_conformance_removes_config_on_subprocess_exception(self) -> None:
with tempfile.TemporaryDirectory() as td:
script_dir = Path(td)
version_dir = script_dir / "dist" / "5.0"
version_dir.mkdir(parents=True)
(version_dir / ".python-version").write_text("3.11")
test_dir = script_dir / "conformance" / "5.0"
test_dir.mkdir(parents=True)
(test_dir / "test_dummy.py").write_text("x = 1\n")
config_path = version_dir / "pyrightconfig.conformance.json"
with patch("main.SCRIPT_DIR", script_dir):
with patch("main.subprocess.run", side_effect=RuntimeError("boom")):
with self.assertRaises(RuntimeError):
main.conformance_check(["5.0"])
self.assertFalse(config_path.exists())
class TestGeneratedPyproject(unittest.TestCase):
def test_generated_pyproject_is_valid_toml(self) -> None:
metadata: dict[str, object] = {
"name": "blender-python-stubs",
"license": "MIT",
"keywords": ["blender", "typing"],
"authors": [
{"name": "Alice", "email": "alice@example.com"},
{"name": "Bob"},
],
"classifiers": ["Programming Language :: Python :: 3"],
"urls": {
"Homepage": "https://example.test/home",
"Issue Tracker": "https://example.test/issues",
},
}
with patch("main.load_project_metadata", return_value=metadata):
content = main.build_generated_pyproject(
blender_version="5.1",
package_version="5.1.0",
packages=["bpy", "mathutils"],
python_version="3.13",
)
parsed = tomllib.loads(content)
self.assertEqual(parsed["project"]["name"], "blender-python-stubs")
self.assertEqual(parsed["project"]["version"], "5.1.0")
self.assertEqual(parsed["project"]["requires-python"], ">=3.13")
self.assertEqual(
parsed["tool"]["hatch"]["build"]["targets"]["wheel"]["packages"],
["bpy", "mathutils"],
)
self.assertEqual(
parsed["project"]["urls"]["Issue Tracker"],
"https://example.test/issues",
)
if __name__ == "__main__":
unittest.main()