Initial commit
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,417 @@
|
||||
"""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 TypeAlias
|
||||
|
||||
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"
|
||||
|
||||
|
||||
TomlValue: TypeAlias = str | int | bool | list["TomlValue"] | dict[str, "TomlValue"]
|
||||
TomlDict: TypeAlias = dict[str, "TomlValue"]
|
||||
|
||||
|
||||
def _serialize_toml_value(value: TomlValue) -> str:
|
||||
"""Serialize a single TOML value."""
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, str):
|
||||
return f'"{value}"'
|
||||
if isinstance(value, int):
|
||||
return str(value)
|
||||
if isinstance(value, list):
|
||||
if value and isinstance(value[0], dict):
|
||||
items: list[str] = []
|
||||
for item in value:
|
||||
if isinstance(item, dict):
|
||||
pairs = ", ".join(
|
||||
f"{k} = {_serialize_toml_value(v)}" for k, v in item.items()
|
||||
)
|
||||
items.append(f"{{ {pairs} }}")
|
||||
joined = ",\n ".join(items)
|
||||
return f"[\n {joined},\n]"
|
||||
str_items = [_serialize_toml_value(v) for v in value]
|
||||
if len(str_items) <= 3:
|
||||
return f"[{', '.join(str_items)}]"
|
||||
joined = ",\n ".join(str_items)
|
||||
return f"[\n {joined},\n]"
|
||||
return str(value)
|
||||
|
||||
|
||||
def _serialize_toml(data: TomlDict, prefix: str = "") -> str:
|
||||
"""Serialize a dict to TOML format."""
|
||||
lines: list[str] = []
|
||||
tables: list[tuple[str, TomlDict]] = []
|
||||
|
||||
for key, value in data.items():
|
||||
full_key = f"{prefix}.{key}" if prefix else key
|
||||
if isinstance(value, dict):
|
||||
has_values = any(not isinstance(v, dict) for v in value.values())
|
||||
has_tables = any(isinstance(v, dict) for v in value.values())
|
||||
|
||||
if has_values:
|
||||
tables.append((full_key, value))
|
||||
elif has_tables:
|
||||
lines.append(_serialize_toml(value, full_key))
|
||||
else:
|
||||
quoted_key = f'"{key}"' if "." in key else key
|
||||
lines.append(f"{quoted_key} = {_serialize_toml_value(value)}")
|
||||
|
||||
result_parts: list[str] = []
|
||||
if lines:
|
||||
if prefix:
|
||||
result_parts.append(f"[{prefix}]")
|
||||
result_parts.extend(lines)
|
||||
|
||||
for table_key, table_value in tables:
|
||||
result_parts.append("")
|
||||
result_parts.append(f"[{table_key}]")
|
||||
for k, v in table_value.items():
|
||||
if isinstance(v, dict):
|
||||
result_parts.append("")
|
||||
result_parts.append(_serialize_toml(v, f"{table_key}.{k}"))
|
||||
else:
|
||||
quoted_k = f'"{k}"' if "." in k else k
|
||||
result_parts.append(f"{quoted_k} = {_serialize_toml_value(v)}")
|
||||
|
||||
return "\n".join(result_parts)
|
||||
|
||||
|
||||
def load_project_metadata() -> TomlDict:
|
||||
"""Load [project] metadata from this project's pyproject.toml."""
|
||||
with (SCRIPT_DIR / "pyproject.toml").open("rb") as f:
|
||||
data: TomlDict = 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_raw = meta.get("classifiers", [])
|
||||
classifiers: list[TomlValue] = []
|
||||
if isinstance(classifiers_raw, list):
|
||||
classifiers = [c for c in classifiers_raw if isinstance(c, str)]
|
||||
classifiers.append("Typing :: Stubs Only")
|
||||
|
||||
project: TomlDict = {
|
||||
"name": str(meta.get("name", "blender-python-stubs")),
|
||||
"version": package_version,
|
||||
"description": f"Type stubs for Blender {blender_version} Python API",
|
||||
"readme": "README.md",
|
||||
"requires-python": f">={python_version}",
|
||||
"license": str(meta.get("license", "GPL-2.0-or-later")),
|
||||
"keywords": meta.get("keywords", []),
|
||||
"authors": meta.get("authors", []),
|
||||
"classifiers": classifiers,
|
||||
}
|
||||
|
||||
urls = meta.get("urls")
|
||||
if isinstance(urls, dict):
|
||||
project["urls"] = urls
|
||||
|
||||
build_system: TomlDict = {
|
||||
"requires": ["hatchling"],
|
||||
"build-backend": "hatchling.build",
|
||||
}
|
||||
pkg_list: list[TomlValue] = [p for p in sorted(packages)]
|
||||
wheel: TomlDict = {"packages": pkg_list}
|
||||
targets: TomlDict = {"wheel": wheel}
|
||||
build: TomlDict = {"targets": targets}
|
||||
hatch: TomlDict = {"build": build}
|
||||
tool: TomlDict = {"hatch": hatch}
|
||||
|
||||
generated: TomlDict = {
|
||||
"build-system": build_system,
|
||||
"project": project,
|
||||
"tool": tool,
|
||||
}
|
||||
|
||||
return _serialize_toml(generated)
|
||||
|
||||
|
||||
README_TEMPLATE = """\
|
||||
# blender-python-stubs
|
||||
|
||||
Type stubs for Blender {blender_version} Python API.
|
||||
|
||||
Provides type information for `bpy`, `mathutils`, `bmesh`, `gpu`, `freestyle`, and other Blender Python modules.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install "blender-python-stubs>={major_minor},<{next_minor}"
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Install alongside your Blender addon project for type checking with mypy, pyright, ty or your IDE.
|
||||
|
||||
```python
|
||||
import bpy
|
||||
|
||||
# Your IDE now provides autocomplete and type checking
|
||||
obj: bpy.types.Object = bpy.context.active_object
|
||||
```
|
||||
"""
|
||||
|
||||
|
||||
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)
|
||||
|
||||
# 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": "strict",
|
||||
"pythonVersion": python_version,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
["basedpyright", "--project", str(config)],
|
||||
)
|
||||
|
||||
config.unlink()
|
||||
|
||||
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(
|
||||
"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)
|
||||
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.")
|
||||
Reference in New Issue
Block a user