diff --git a/blender_downloader.py b/blender_downloader.py index b21122e..6b162b8 100644 --- a/blender_downloader.py +++ b/blender_downloader.py @@ -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}" diff --git a/main.py b/main.py index f0bc2a0..d819bf5 100644 --- a/main.py +++ b/main.py @@ -483,7 +483,6 @@ def conformance_check(versions: list[str] | None = None) -> None: "typeCheckingMode": "strict", "pythonVersion": python_version, "reportUnusedExpression": False, - "reportUnknownMemberType": False, } ) ) diff --git a/pyproject.toml b/pyproject.toml index 5c56dec..8a36129 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/tests/test_blender_downloader.py b/tests/test_blender_downloader.py index 180bb7b..56f6e7f 100644 --- a/tests/test_blender_downloader.py +++ b/tests/test_blender_downloader.py @@ -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__": diff --git a/tests/test_generate_stubs.py b/tests/test_generate_stubs.py index 5c9dfd8..f16f314 100644 --- a/tests/test_generate_stubs.py +++ b/tests/test_generate_stubs.py @@ -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): diff --git a/tests/test_introspect.py b/tests/test_introspect.py index a7e0615..8fd853a 100644 --- a/tests/test_introspect.py +++ b/tests/test_introspect.py @@ -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() diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..d462171 --- /dev/null +++ b/tests/test_main.py @@ -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()