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 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}"

View File

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

View File

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

View File

@ -1,9 +1,18 @@
"""Tests for the Blender downloader module."""
import io
import tarfile
import tempfile
import unittest
import zipfile
from pathlib import Path
from unittest.mock import patch
from blender_downloader import (
extract_tar_archive,
extract_zip_archive,
get_archive_extension,
get_blender_executable,
get_download_url,
get_extracted_dir_name,
)
@ -11,31 +20,145 @@ from blender_downloader import (
class TestGetDownloadUrl(unittest.TestCase):
def test_builds_correct_url(self) -> None:
url = get_download_url("5.0.1")
self.assertIn("https://download.blender.org/release/Blender5.0/", url)
self.assertIn("blender-5.0.1-", url)
self.assertTrue(url.endswith(get_archive_extension()))
with patch("blender_downloader.get_platform_suffix", return_value="linux-x64"):
with patch(
"blender_downloader.get_archive_extension", return_value=".tar.xz"
):
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:
url = get_download_url("4.3.2")
self.assertIn("/Blender4.3/", url)
self.assertIn("blender-4.3.2-", url)
with patch("blender_downloader.get_platform_suffix", return_value="linux-x64"):
with patch(
"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):
def test_linux_dir_name(self) -> None:
name = get_extracted_dir_name("5.0.1")
self.assertEqual(name, "blender-5.0.1-linux-x64")
def test_different_version(self) -> None:
name = get_extracted_dir_name("4.3.2")
self.assertEqual(name, "blender-4.3.2-linux-x64")
def test_dir_name_uses_platform_suffix(self) -> None:
with patch(
"blender_downloader.get_platform_suffix", return_value="windows-x64"
):
name = get_extracted_dir_name("5.0.1")
self.assertEqual(name, "blender-5.0.1-windows-x64")
class TestGetArchiveExtension(unittest.TestCase):
def test_linux_extension(self) -> None:
ext = get_archive_extension()
self.assertEqual(ext, ".tar.xz")
def test_windows_extension(self) -> None:
with patch("blender_downloader.platform.system", return_value="Windows"):
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__":

View File

@ -139,8 +139,7 @@ class TestGenerateFunctionStub(unittest.TestCase):
"is_classmethod": False,
}
result = generate_function_stub(func)
self.assertNotIn("*, ", result)
self.assertIn("**kwargs", result)
self.assertEqual(result, "def foo(x: str, **kwargs: object) -> None: ...\n")
def test_default_before_nondefault_reordered(self) -> None:
"""Non-default params must come before default params."""
@ -640,6 +639,10 @@ class TestTopologicalSort(unittest.TestCase):
]
result = topological_sort_structs(structs)
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):

View File

@ -1,7 +1,9 @@
"""Tests for the introspection module."""
import unittest
from types import SimpleNamespace
from typing import cast
from unittest.mock import patch
from introspect import (
FunctionData,
@ -12,6 +14,8 @@ from introspect import (
refine_types_by_context,
clean_docstring,
clean_type_str,
introspect_ops_module,
introspect_screen_context_members,
parse_docstring_types,
python_type_name,
)
@ -160,6 +164,30 @@ class TestParseDocstringTypes(unittest.TestCase):
def test_plural_matrices(self) -> None:
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:
self.assertEqual(
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."
)
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(result["params"][0]["name"], "seq")
self.assertIsNone(result["params"][0]["type"])
@ -349,7 +378,8 @@ class TestRstOnlyParams(unittest.TestCase):
"""RST params without :type: are added after typed params."""
func = _NoSigCallable(".. method:: foo(a, b)\n\n:type a: str")
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(result["params"][0]["name"], "a")
self.assertEqual(result["params"][0]["type"], "str")
@ -367,7 +397,8 @@ class TestParamNameMismatch(unittest.TestCase):
return ""
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]["type"], "str")
@ -378,7 +409,8 @@ class TestParamNameMismatch(unittest.TestCase):
return ""
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]["type"], "str")
@ -527,5 +559,71 @@ class TestInferGetterReturnTypes(unittest.TestCase):
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__":
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()