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:
parent
7e1e18d845
commit
673b9e54bc
@ -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}"
|
||||||
|
|||||||
1
main.py
1
main.py
@ -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,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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" }
|
||||||
|
|||||||
@ -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__":
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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
86
tests/test_main.py
Normal 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()
|
||||||
Loading…
x
Reference in New Issue
Block a user