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 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}"
|
||||
|
||||
1
main.py
1
main.py
@ -483,7 +483,6 @@ def conformance_check(versions: list[str] | None = None) -> None:
|
||||
"typeCheckingMode": "strict",
|
||||
"pythonVersion": python_version,
|
||||
"reportUnusedExpression": False,
|
||||
"reportUnknownMemberType": False,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@ -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" }
|
||||
|
||||
@ -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__":
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
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