Remove unused _append_ops_call_method, fix _property_data_from_member call signature, replace sys.version_info guard with hasattr check for __buffer__ protocol, fix implicit string concatenations, add msgbus overrides for 4.0-4.4, add conformance tests, and add publish script. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
545 lines
17 KiB
Python
545 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,
|
|
) -> None:
|
|
"""Generate pyproject.toml and README.md for the publishable package."""
|
|
package_version = f"{full_version}.0"
|
|
|
|
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) -> 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,
|
|
)
|
|
|
|
# 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": "all",
|
|
"pythonVersion": python_version,
|
|
"reportPropertyTypeMismatch": 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)
|
|
|
|
|
|
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": "all",
|
|
"pythonVersion": python_version,
|
|
"reportUnusedExpression": False,
|
|
"reportUnusedCallResult": 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(
|
|
"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))
|
|
print()
|
|
print("Done.")
|