Joseph HENRY 673b9e54bc 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>
2026-04-02 11:49:53 +02:00

551 lines
17 KiB
Python

"""Blender type stubs generator.
Orchestrates the introspection and stub generation pipeline.
"""
import argparse
import json
import shutil
import subprocess
import sys
import tomllib
from dataclasses import dataclass
from pathlib import Path
from typing import cast
from blender_downloader import get_blender_executable
from generate_stubs import write_stubs
from introspect import ModuleData
SCRIPT_DIR = Path(__file__).parent
INTROSPECT_SCRIPT = SCRIPT_DIR / "introspect.py"
OVERRIDES_DIR = SCRIPT_DIR / "overrides"
def _toml_quote(value: str) -> str:
"""Quote and escape a TOML string value."""
return json.dumps(value)
def _toml_string_array(values: list[str]) -> str:
"""Serialize a list of strings as a TOML array."""
return "[" + ", ".join(_toml_quote(v) for v in values) + "]"
def _as_object_list(value: object) -> list[object]:
"""Return value as list[object] when possible, otherwise an empty list."""
if isinstance(value, list):
return cast(list[object], value)
return []
def _as_object_dict(value: object) -> dict[object, object]:
"""Return value as dict[object, object] when possible, otherwise an empty dict."""
if isinstance(value, dict):
return cast(dict[object, object], value)
return {}
def _as_str(value: object, default: str) -> str:
"""Return a string value with fallback."""
if isinstance(value, str):
return value
return default
def load_project_metadata() -> dict[str, object]:
"""Load [project] metadata from this project's pyproject.toml."""
with (SCRIPT_DIR / "pyproject.toml").open("rb") as f:
data: dict[str, object] = tomllib.load(f)["project"]
return data
def build_generated_pyproject(
blender_version: str,
package_version: str,
packages: list[str],
python_version: str,
) -> str:
"""Build a pyproject.toml for the generated stubs, inheriting metadata from the project."""
meta = load_project_metadata()
classifiers: list[str] = []
for classifier_item in _as_object_list(meta.get("classifiers", [])):
if isinstance(classifier_item, str):
classifiers.append(classifier_item)
classifiers.append("Typing :: Stubs Only")
keywords: list[str] = []
for keyword_item in _as_object_list(meta.get("keywords", [])):
if isinstance(keyword_item, str):
keywords.append(keyword_item)
authors: list[dict[str, str]] = []
for raw_author in _as_object_list(meta.get("authors", [])):
author = _as_object_dict(raw_author)
author_item: dict[str, str] = {}
name = author.get("name")
email = author.get("email")
if isinstance(name, str):
author_item["name"] = name
if isinstance(email, str):
author_item["email"] = email
if author_item:
authors.append(author_item)
urls_dict: dict[str, str] = {}
for raw_key, raw_value in _as_object_dict(meta.get("urls")).items():
if isinstance(raw_key, str) and isinstance(raw_value, str):
urls_dict[raw_key] = raw_value
license_value = meta.get("license", "GPL-2.0-or-later")
if isinstance(license_value, dict):
license_dict = cast(dict[object, object], license_value)
license_text = _as_str(license_dict.get("text"), "GPL-2.0-or-later")
else:
license_text = str(license_value)
project_name = _as_str(
meta.get("name", "blender-python-stubs"), "blender-python-stubs"
)
lines: list[str] = [
"[build-system]",
'requires = ["hatchling"]',
'build-backend = "hatchling.build"',
"",
"[project]",
f"name = {_toml_quote(project_name)}",
f"version = {_toml_quote(package_version)}",
f'description = {_toml_quote(f"Type stubs for Blender {blender_version} Python API")}',
'readme = "README.md"',
f'requires-python = {_toml_quote(f">={python_version}")}',
f"license = {_toml_quote(license_text)}",
f"keywords = {_toml_string_array(keywords)}",
]
if authors:
lines.append("authors = [")
for author in authors:
fields: list[str] = []
if "name" in author:
fields.append(f'name = {_toml_quote(author["name"])}')
if "email" in author:
fields.append(f'email = {_toml_quote(author["email"])}')
lines.append(f" {{ {', '.join(fields)} }},")
lines.append("]")
else:
lines.append("authors = []")
lines.append(f"classifiers = {_toml_string_array(classifiers)}")
if urls_dict:
lines.append("")
lines.append("[project.urls]")
for key in sorted(urls_dict):
lines.append(f"{_toml_quote(key)} = {_toml_quote(urls_dict[key])}")
lines.extend(
[
"",
"[tool.hatch.build.targets.wheel]",
f"packages = {_toml_string_array(sorted(packages))}",
]
)
return "\n".join(lines)
README_TEMPLATE = """\
# blender-python-stubs
Type stubs for Blender {blender_version} Python API.
Provides autocomplete, type checking, and inline documentation for `bpy`, `mathutils`, `bmesh`, `gpu`, `freestyle`, and all other Blender Python modules.
## Installation
```bash
pip install "blender-python-stubs>={major_minor},<{next_minor}"
```
## Features
- Full Blender API coverage (`bpy`, `mathutils`, `bmesh`, `gpu`, `gpu_extras`, `bpy_extras`, `freestyle`, `aud`, `blf`, `bl_math`, `imbuf`, `idprop`)
- Accurate collection types (`BlendDataObjects` instead of generic `bpy_prop_collection`)
- Readonly `@property` decorators for RNA attributes
- Typed context members (`bpy.context.active_object`, `selected_objects`, etc.)
- Constructor signatures for `mathutils`, `gpu`, and other C extension types
- Literal enum types instead of plain `str`
- Zero `typing.Any` usage
- 0 errors in basedpyright strict mode
## Usage
```python
import bpy
obj = bpy.context.active_object
assert obj is not None
obj.location.x = 1.0
bpy.data.objects.new("Cube", bpy.data.meshes.new("Mesh"))
```
## Disclaimer
This project was coded with assistance of AI.
## License
[MIT](LICENSE)
---
Generated by [blender-python-stubs](https://git.autourdeminuit.com/autour_de_minuit/blender-python-stubs).
Made with ❤️ at [Autour de Minuit (ADV)](https://blog.autourdeminuit.com/) <img src="https://upload.wikimedia.org/wikipedia/commons/0/0c/Blender_logo_no_text.svg" alt="blender" width="20"/>
"""
def get_blender_version(blender_path: str) -> tuple[str, str]:
"""Get the full and major.minor version strings from a Blender executable."""
result = subprocess.run(
[blender_path, "--version"],
capture_output=True,
text=True,
)
for line in result.stdout.splitlines():
if line.startswith("Blender "):
full_version = line.split()[1]
parts = full_version.split(".")
major_minor = f"{parts[0]}.{parts[1]}"
return full_version, major_minor
print("Could not determine Blender version", file=sys.stderr)
sys.exit(1)
def get_blender_python_version(blender_path: str) -> str:
"""Detect the Python version embedded in a Blender executable."""
result = subprocess.run(
[
blender_path,
"--background",
"--factory-startup",
"-noaudio",
"--python-expr",
"import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')",
],
capture_output=True,
text=True,
)
for line in result.stdout.splitlines():
line = line.strip()
if line and line[0].isdigit() and "." in line:
return line
print("Could not determine Blender Python version", file=sys.stderr)
sys.exit(1)
def run_introspection(blender_path: str) -> list[ModuleData]:
"""Run the introspection script inside Blender and return the JSON result."""
cmd = [
blender_path,
"--background",
"--factory-startup",
"-noaudio",
"--python-exit-code",
"1",
"--python",
str(INTROSPECT_SCRIPT),
]
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
print("Blender introspection failed:", file=sys.stderr)
print(proc.stderr, file=sys.stderr)
sys.exit(1)
output = proc.stdout
start_marker = "__INTROSPECT_JSON_START__"
end_marker = "__INTROSPECT_JSON_END__"
start_idx = output.find(start_marker)
end_idx = output.find(end_marker)
if start_idx == -1 or end_idx == -1:
print("Could not find JSON markers in Blender output:", file=sys.stderr)
print(output, file=sys.stderr)
sys.exit(1)
json_str = output[start_idx + len(start_marker) : end_idx].strip()
parsed: list[ModuleData] = json.loads(json_str)
return parsed
def compute_next_minor(major_minor: str) -> str:
"""Compute the next minor version. 5.0 -> 5.1, 4.9 -> 4.10."""
major, minor = major_minor.split(".")
return f"{major}.{int(minor) + 1}"
def generate_package_files(
output_dir: Path,
full_version: str,
major_minor: str,
top_level_packages: list[str],
python_version: str,
revision: int = 0,
) -> None:
"""Generate pyproject.toml and README.md for the publishable package."""
package_version = f"{full_version}.{revision}"
pyproject = build_generated_pyproject(
major_minor, package_version, top_level_packages, python_version
)
(output_dir / "pyproject.toml").write_text(pyproject + "\n")
next_minor = compute_next_minor(major_minor)
readme = README_TEMPLATE.format(
blender_version=major_minor,
major_minor=major_minor,
next_minor=next_minor,
)
(output_dir / "README.md").write_text(readme)
# Copy LICENSE file from the project root
license_src = SCRIPT_DIR / "LICENSE"
if license_src.exists():
shutil.copy2(license_src, output_dir / "LICENSE")
# Add py.typed marker to each top-level package so mypy recognizes the stubs
for pkg in top_level_packages:
(output_dir / pkg / "py.typed").touch()
def generate_for_version(blender_path: str, revision: int = 0) -> None:
"""Generate stubs for a single Blender executable."""
full_version, major_minor = get_blender_version(blender_path)
python_version = get_blender_python_version(blender_path)
print(f" Blender {full_version} (Python {python_version})")
output_dir = SCRIPT_DIR / "dist" / major_minor
if output_dir.exists():
shutil.rmtree(output_dir)
print(" Running introspection...")
modules_data = run_introspection(blender_path)
print(f" Introspected {len(modules_data)} modules")
overrides_path = OVERRIDES_DIR / major_minor
overrides_str: str | None = None
if overrides_path.exists():
print(f" Using overrides from {overrides_path}/")
overrides_str = str(overrides_path)
print(" Generating stubs...")
top_level_packages = write_stubs(
modules_data, str(output_dir), overrides_str, python_version
)
print(" Generating package files...")
generate_package_files(
output_dir,
full_version,
major_minor,
top_level_packages,
python_version,
revision,
)
# Store the Python version for later type checking
(output_dir / ".python-version").write_text(python_version)
print(f" Output: {output_dir}/")
@dataclass
class MainArgs:
versions: list[str]
def typecheck_stubs(versions: list[str] | None = None) -> None:
"""Type-check generated stubs by running basedpyright scoped to each version directory."""
dist_dir = SCRIPT_DIR / "dist"
if not dist_dir.exists():
print("No dist/ directory found. Run 'poe generate' first.")
sys.exit(1)
if not versions:
versions = sorted(
d.name
for d in dist_dir.iterdir()
if d.is_dir() and not d.name.startswith(".")
)
if not versions:
print("No generated stubs found in dist/.")
sys.exit(1)
failed = False
for version in versions:
version_dir = dist_dir / version
print(f"=== Checking stubs for Blender {version} ===")
python_version_file = version_dir / ".python-version"
python_version = (
python_version_file.read_text().strip()
if python_version_file.exists()
else "3.11"
)
config = version_dir / "pyrightconfig.json"
config.write_text(
json.dumps(
{
"extraPaths": ["."],
"typeCheckingMode": "strict",
"pythonVersion": python_version,
}
)
)
try:
result = subprocess.run(
["basedpyright", "--project", str(config)],
)
finally:
config.unlink(missing_ok=True)
if result.returncode != 0:
failed = True
if failed:
sys.exit(1)
def conformance_check(versions: list[str] | None = None) -> None:
"""Type-check conformance test files against generated stubs.
Each version's tests live in conformance/<version>/*.py and are checked
against the corresponding generated stubs in dist/<version>/.
"""
dist_dir = SCRIPT_DIR / "dist"
conformance_dir = SCRIPT_DIR / "conformance"
if not conformance_dir.exists():
print("No conformance/ directory found.")
sys.exit(1)
if versions:
missing_stubs = [v for v in versions if not (dist_dir / v).is_dir()]
if missing_stubs:
print(f"Missing stubs for: {', '.join(missing_stubs)}")
sys.exit(1)
missing_tests = [v for v in versions if not (conformance_dir / v).is_dir()]
if missing_tests:
print(f"No conformance tests for: {', '.join(missing_tests)}")
sys.exit(1)
else:
versions = sorted(
d.name
for d in conformance_dir.iterdir()
if d.is_dir() and (dist_dir / d.name).is_dir()
)
if not versions:
print("No conformance test directories found.")
sys.exit(1)
failed = False
for version in versions:
version_dir = dist_dir / version
test_dir = conformance_dir / version
python_version_file = version_dir / ".python-version"
python_version = (
python_version_file.read_text().strip()
if python_version_file.exists()
else "3.11"
)
print(
f"=== Conformance check against Blender {version} stubs with Python {python_version} ==="
)
config = version_dir / "pyrightconfig.conformance.json"
config.write_text(
json.dumps(
{
"include": [str(test_dir)],
"extraPaths": [str(version_dir)],
"typeCheckingMode": "strict",
"pythonVersion": python_version,
"reportUnusedExpression": False,
}
)
)
try:
result = subprocess.run(
["basedpyright", "--project", str(config)],
)
finally:
config.unlink(missing_ok=True)
if result.returncode != 0:
failed = True
if failed:
sys.exit(1)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Blender type stubs generator")
parser.add_argument(
"--typecheck-stubs",
action="store_true",
help="Type-check generated stubs instead of generating",
)
parser.add_argument(
"--conformance",
action="store_true",
help="Run conformance tests against generated stubs",
)
parser.add_argument(
"--revision",
type=int,
default=0,
help="Package revision number (default: 0, e.g. 4.5.8.1 for revision 1)",
)
parser.add_argument(
"versions",
nargs="*",
help="Blender versions (e.g., 4.0 4.1)",
)
args = parser.parse_args()
if args.typecheck_stubs:
typecheck_stubs(args.versions or None)
elif args.conformance:
conformance_check(args.versions or None)
else:
if not args.versions:
parser.error("versions are required for generation")
main_args = MainArgs(versions=args.versions)
min_version = (4, 0)
for version in main_args.versions:
major, minor = (int(x) for x in version.split("."))
if (major, minor) < min_version:
print(
f"Blender {version} is not supported"
f" (minimum: {min_version[0]}.{min_version[1]})"
)
sys.exit(1)
print(f"=== Blender {version} ===")
blender_path = get_blender_executable(version)
generate_for_version(str(blender_path), revision=args.revision)
print()
print("Done.")