Clean up dead code, fix type errors, and add publish workflow

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>
This commit is contained in:
Joseph HENRY 2026-04-03 11:07:52 +02:00
parent 673b9e54bc
commit f4df542dd1
17 changed files with 954 additions and 165 deletions

59
CLAUDE.md Normal file
View File

@ -0,0 +1,59 @@
# CLAUDE.md - Project Guidelines
## Project Overview
Type stubs generator for the Blender Python API (`bpy`, `mathutils`, `bmesh`, `gpu`, etc.). Stubs are generated by running introspection inside Blender's embedded Python interpreter in `--background` mode. Supported Blender versions: **4.0 through 5.1** (8 versions total).
## Commands
- `uv run poe generate <version>` — Generate stubs for a Blender version (e.g., 5.0)
- `uv run poe typecheck-stubs <version>` — Type-check generated stubs
- `uv run poe conformance <version>` — Run conformance tests against stubs
- `uv run poe check` — Run all checks (format, lint, typecheck, test)
- `uv run poe test` — Run unit tests
- `uv run poe format` — Format with black
- `uv run poe lint` — Lint with ruff
## Code Style
- Format all Python files with **black** after modifying them
- Use **basedpyright strict** mode — 0 errors is the target
- Never use `typing.Any` — use concrete types, TypedDicts, Protocols, or unions instead
- Use raw dict literals for TypedDicts, not explicit constructors (e.g., `{"name": ..., "type": ...}` not `ParamData(name=..., type=...)`)
## Type Checking Rules
- **Never use `# type: ignore`** — fix the actual typing issue instead
- **Never suppress typecheck rules** with excludes or pyright config overrides — fix the root cause
- **Never bypass issues with workarounds** — always investigate and fix root causes properly (no empty placeholder stubs, no shims)
## Testing
- **Add tests for every behavior/regex change** — at least two examples per change
- Test files live in `tests/` and run via `python -m unittest discover -s tests -v`
## Conformance Tests
- **Never modify conformance test files** — they are copied verbatim from Blender documentation
- When a conformance test fails, fix the stub generation or overrides, not the test
- Conformance tests live in `conformance/<version>/`
## Architecture
- Stubs are versioned per Blender version: `dist/<version>/` (e.g., `dist/5.0/`)
- Type overrides are organized per version: `overrides/<version>/<module>.json`
- Always prefer **introspection** over hardcoding — if something can be discovered at runtime, don't hardcode it
- Key files: `introspect.py` (data collection), `generate_stubs.py` (stub output), `main.py` (CLI)
- `introspect.py` runs inside Blender's embedded Python interpreter, which varies by version (e.g., Blender 4.0 uses Python 3.10). Code in this file must be compatible with all supported Blender Python versions.
## Blender Binaries
Cached Blender executables are in `downloads/` (e.g., `downloads/blender-5.1.0-linux-x64/blender`). You can run scripts inside Blender with:
```bash
downloads/blender-5.1.0-linux-x64/blender --background --python script.py
```
## Git
- Always add specific files by name — **never use `git add -A` or `git add .`**

View File

@ -63,7 +63,7 @@ gpu.state.blend_set('ALPHA')
- **Typed `Context.copy()`** — returns a `ContextDict` TypedDict instead of `dict[str, object]`, so unpacking into `context.temp_override(**ctx)` type-checks correctly
- **Dynamic array types**`Image.pixels` typed as `bpy_prop_array[float]` (not `list[float]` or `float`)
- **Zero `Any` usage** — precise types throughout, no `typing.Any` fallbacks
- **basedpyright strict mode** — 0 errors on generated stubs (4.1+)
- **[basedpyright](https://docs.basedpyright.com) `all` mode** — 0 errors on generated stubs (4.0+), validated with `typeCheckingMode: "all"` (the strictest setting)
## How It Works
@ -91,7 +91,7 @@ The introspection data is then passed through a stub generator that handles type
| Constructor `__init__` | Yes (mathutils, gpu, etc.) | No |
| Literal enum types | Yes | Yes (via stub_internal) |
| Context members | ~100 typed | ~100 typed |
| basedpyright strict | 0 errors (4.1+) | Not tested |
| basedpyright `all` mode | 0 errors (4.0+) | Not tested |
| Docstrings | Yes | Yes |
### vs [bpystubgen](https://github.com/mysticfall/bpystubgen)

View File

@ -0,0 +1,75 @@
from dataclasses import dataclass
import os
import sys
import argparse
from typing import cast
import bpy
def argparse_create():
parser = argparse.ArgumentParser(
prog=os.path.basename(sys.argv[0]) + " --command keyconfig_export",
description="Write key-configuration to a file.",
)
parser.add_argument(
"-o",
"--output",
dest="output",
metavar="OUTPUT",
type=str,
help="The path to write the keymap to.",
required=True,
)
parser.add_argument(
"-a",
"--all",
dest="all",
action="store_true",
help="Write all key-maps (not only customized key-maps).",
required=False,
)
return parser
@dataclass
class Arguments:
output: str
all: bool
def keyconfig_export(argv: list[str]):
parser = argparse_create()
args = cast(Arguments, cast(object, parser.parse_args(argv)))
# Ensure the key configuration is loaded in background mode.
bpy.utils.keyconfig_init()
bpy.ops.preferences.keyconfig_export(
filepath=args.output,
all=args.all,
)
return 0
cli_commands: list[object] = []
def register():
cli_commands.append(
bpy.utils.register_cli_command("keyconfig_export", keyconfig_export)
)
def unregister():
for cmd in cli_commands:
bpy.utils.unregister_cli_command(cmd)
cli_commands.clear()
if __name__ == "__main__":
register()

View File

@ -0,0 +1,75 @@
from dataclasses import dataclass
import os
import sys
import argparse
from typing import cast
import bpy
def argparse_create():
parser = argparse.ArgumentParser(
prog=os.path.basename(sys.argv[0]) + " --command keyconfig_export",
description="Write key-configuration to a file.",
)
parser.add_argument(
"-o",
"--output",
dest="output",
metavar="OUTPUT",
type=str,
help="The path to write the keymap to.",
required=True,
)
parser.add_argument(
"-a",
"--all",
dest="all",
action="store_true",
help="Write all key-maps (not only customized key-maps).",
required=False,
)
return parser
@dataclass
class Arguments:
output: str
all: bool
def keyconfig_export(argv: list[str]):
parser = argparse_create()
args = cast(Arguments, cast(object, parser.parse_args(argv)))
# Ensure the key configuration is loaded in background mode.
bpy.utils.keyconfig_init()
bpy.ops.preferences.keyconfig_export(
filepath=args.output,
all=args.all,
)
return 0
cli_commands: list[object] = []
def register():
cli_commands.append(
bpy.utils.register_cli_command("keyconfig_export", keyconfig_export)
)
def unregister():
for cmd in cli_commands:
bpy.utils.unregister_cli_command(cmd)
cli_commands.clear()
if __name__ == "__main__":
register()

View File

@ -14,14 +14,14 @@ shader_info.fragment_out(0, "VEC4", "FragColor")
shader_info.vertex_source(
"void main()"
"{"
" pos = position;"
" gl_Position = viewProjectionMatrix * vec4(position, 1.0f);"
"}"
+ "{"
+ " pos = position;"
+ " gl_Position = viewProjectionMatrix * vec4(position, 1.0f);"
+ "}"
)
shader_info.fragment_source(
"void main()" "{" " FragColor = vec4(pos * brightness, 1.0);" "}"
"void main()" + "{" + " FragColor = vec4(pos * brightness, 1.0);" + "}"
)
shader = gpu.shader.create_from_info(shader_info)

View File

@ -172,6 +172,15 @@ def _requires_builtins_import(module_data: ModuleData) -> bool:
return False
def _has_override_methods(module_data: ModuleData) -> bool:
"""Check if any method in the module has is_override set."""
for struct in module_data.get("structs", []):
for method in struct["methods"]:
if method.get("is_override", False):
return True
return False
def collect_imports(module_data: ModuleData) -> set[str]:
"""Collect all import statements needed for the stub file."""
all_types = collect_all_type_strings(module_data)
@ -179,6 +188,9 @@ def collect_imports(module_data: ModuleData) -> set[str]:
imports = _collect_token_imports(joined) | _collect_module_reference_imports(joined)
if _requires_builtins_import(module_data):
imports.add("import builtins")
if _has_override_methods(module_data):
# override is in typing since 3.12, typing_extensions for 3.11
imports.add("from typing_extensions import override")
return imports
@ -367,28 +379,6 @@ def generate_property_stub(
result += f"{indent} ...\n"
return result
# Writable properties with different getter/setter types need @property
# (e.g. location: mathutils.Vector | Sequence[float] -> getter returns Vector,
# setter accepts both)
if " | " in prop["type"]:
parts_list = [p.strip() for p in prop["type"].split(" | ")]
getter_type = parts_list[0]
setter_type = prop["type"]
result = f"{indent}{property_decorator}\n"
result += f"{indent}def {prop['name']}(self) -> {getter_type}:\n"
if prop["description"]:
desc = prop["description"].replace("\\", "\\\\").replace('"""', r"\"\"\"")
if desc.endswith('"'):
desc += " "
result += f'{indent} """{desc}"""\n'
else:
result += f"{indent} ...\n"
result += f"{indent}@{prop['name']}.setter\n"
result += (
f"{indent}def {prop['name']}(self, value: {setter_type}) -> None: ...\n"
)
return result
result = f"{indent}{prop['name']}: {prop['type']}\n"
if prop["description"]:
desc = prop["description"].replace("\\", "\\\\").replace('"""', r"\"\"\"")
@ -478,7 +468,9 @@ def generate_struct_stub(
has_body = True
for method in struct["methods"]:
is_override = method["name"] in inherited_methods
is_override = method["name"] in inherited_methods or method.get(
"is_override", False
)
parts.append(generate_method_stub(method, is_override=is_override))
has_body = True
@ -513,10 +505,14 @@ def topological_sort_structs(structs: list[StructData]) -> list[StructData]:
class _InheritedInfo:
methods: set[str]
readonly_props: set[str]
all_props: set[str]
def __init__(self, methods: set[str], readonly_props: set[str]):
def __init__(
self, methods: set[str], readonly_props: set[str], all_props: set[str]
):
self.methods = methods
self.readonly_props = readonly_props
self.all_props = all_props
def collect_inherited_info(
@ -531,7 +527,7 @@ def collect_inherited_info(
return cache[name]
struct = by_name.get(name)
if not struct or not struct["base"]:
cache[name] = _InheritedInfo(set(), set())
cache[name] = _InheritedInfo(set(), set(), set())
return cache[name]
base = struct["base"]
if base in by_name:
@ -539,13 +535,16 @@ def collect_inherited_info(
base_ro_props = {
p["name"] for p in by_name[base]["properties"] if p["is_readonly"]
}
base_all_props = {p["name"] for p in by_name[base]["properties"]}
else:
base_methods: set[str] = set()
base_ro_props: set[str] = set()
base_all_props: set[str] = set()
parent = get_inherited(base)
cache[name] = _InheritedInfo(
base_methods | parent.methods,
base_ro_props | parent.readonly_props,
base_all_props | parent.all_props,
)
return cache[name]
@ -562,7 +561,9 @@ def _generate_context_dict(context_struct: StructData) -> str:
can return a precisely typed dict instead of dict[str, object].
"""
lines: list[str] = ["class ContextDict(TypedDict):"]
lines.append(' """Dictionary returned by Context.copy() with all context members."""')
lines.append(
' """Dictionary returned by Context.copy() with all context members."""'
)
method_names = {m["name"] for m in context_struct["methods"]}
for prop in context_struct["properties"]:
if prop["name"] in method_names:
@ -648,12 +649,13 @@ def generate_types_stub(
_patch_context_copy_return_type(context_struct)
for struct in sorted_structs:
info = inherited_map.get(struct["name"], _InheritedInfo(set(), set()))
# Force writable properties to readonly when they override a parent's
# readonly @property (avoids reportIncompatibleMethodOverride)
for prop in struct["properties"]:
if not prop["is_readonly"] and prop["name"] in info.readonly_props:
prop["is_readonly"] = True
info = inherited_map.get(struct["name"], _InheritedInfo(set(), set(), set()))
# Skip properties already defined on a parent class — re-emitting them
# can cause reportIncompatibleVariableOverride or reportImplicitOverride
# when RNA subtypes differ (e.g., FunctionNodeInputColor.color vs Node.color)
struct["properties"] = [
p for p in struct["properties"] if p["name"] not in info.all_props
]
# Emit ContextDict TypedDict right before the Context class
if struct["name"] == "Context":
parts.append("")

View File

@ -176,7 +176,11 @@ class ParamData(TypedDict):
kind: str
class FunctionData(TypedDict):
class _FunctionDataOptional(TypedDict, total=False):
is_override: bool
class FunctionData(_FunctionDataOptional):
name: str
doc: str
params: list[ParamData]
@ -494,6 +498,12 @@ _CLEAN_TYPE_ALIAS_RULES: tuple[RegexRule, ...] = (
(re.compile(r"\bfunction\b"), "Callable[..., object]"),
(re.compile(r"\bgenerator\b"), "Generator"),
(re.compile(r"\bsequence\b"), "Sequence"),
# Old typing module names -> modern builtins
(re.compile(r"\bDict\b"), "dict"),
(re.compile(r"\bList\b"), "list"),
(re.compile(r"\bTuple\b"), "tuple"),
(re.compile(r"\bSet\b(?!tings)"), "set"),
(re.compile(r"\bFrozenSet\b"), "frozenset"),
)
_CLEAN_TYPE_GENERIC_RULES: tuple[RegexRule, ...] = (
@ -501,7 +511,8 @@ _CLEAN_TYPE_GENERIC_RULES: tuple[RegexRule, ...] = (
(re.compile(r"\s*\|\s*\d+\b"), ""),
(re.compile(r"(\w)\[\]"), r"\1"),
(re.compile(r"\bCallable\b(?!\[)"), "Callable[..., object]"),
(re.compile(r"Callable\[\[[^\]]*\.\.\.[^\]]*\]"), "Callable[...,"),
# Callable[[...], X] -> Callable[..., X] (consume trailing comma)
(re.compile(r"Callable\[\[[^\]]*\.\.\.[^\]]*\],?\s*"), "Callable[..., "),
(re.compile(r"\bdict\b(?!\[)"), "dict[str, object]"),
(re.compile(r"\blist\b(?!\[)"), "list[object]"),
(re.compile(r"\btuple\b(?!\[)"), "tuple[object, ...]"),
@ -662,7 +673,7 @@ def clean_type_str(type_str: str) -> str:
type_str = re.sub(rf"\b{re.escape(undef)}\b(\.\w+)*", "object", type_str)
# Map known unqualified Python stdlib types
type_str = re.sub(r"\bModule\b", "types.ModuleType", type_str)
type_str = re.sub(r"\b[Mm]odule\b", "types.ModuleType", type_str)
# Fix GPU types wrongly referenced as bpy.types.GPU* (Blender docstring bug in 4.x)
type_str = re.sub(r"\bbpy\.types\.(GPU\w+)\b", r"gpu.types.\1", type_str)
@ -959,6 +970,20 @@ def _annotation_to_type_str(ann: object) -> str:
s = s.replace("typing.", "")
# NoneType -> None
s = re.sub(r"\bNoneType\b", "None", s)
# Old typing aliases -> modern builtins
s = re.sub(r"\bDict\b", "dict", s)
s = re.sub(r"\bList\b", "list", s)
s = re.sub(r"\bTuple\b", "tuple", s)
s = re.sub(r"\bSet\b(?!tings)", "set", s)
s = re.sub(r"\bFrozenSet\b", "frozenset", s)
# Bare module type -> types.ModuleType
s = re.sub(r"\bmodule\b", "types.ModuleType", s)
# Parameterize bare generics (e.g., bare set -> set[object])
s = re.sub(r"\bdict\b(?!\[)", "dict[str, object]", s)
s = re.sub(r"\blist\b(?!\[)", "list[object]", s)
s = re.sub(r"\bset\b(?!\[)", "set[object]", s)
s = re.sub(r"\bfrozenset\b(?!\[)", "frozenset[object]", s)
s = re.sub(r"\btuple\b(?!\[)", "tuple[object, ...]", s)
# Qualify bare mathutils types (avoid double-qualifying already-qualified ones)
for mt in ("Vector", "Matrix", "Euler", "Quaternion", "Color"):
s = re.sub(rf"(?<!\.)(?<!\w)\b{mt}\b", f"mathutils.{mt}", s)
@ -1517,7 +1542,6 @@ def _append_callable_method(
def _property_data_from_member(
cls: type[object],
name: str,
obj: object,
raw: object,
) -> PropertyData:
"""Build PropertyData from a property/getset descriptor class member."""
@ -1559,7 +1583,7 @@ def _introspect_declared_class_members(
_append_callable_method(methods, obj, name)
continue
if isinstance(raw, property) or type(raw).__name__ == "getset_descriptor":
properties.append(_property_data_from_member(cls, name, obj, raw))
properties.append(_property_data_from_member(cls, name, raw))
continue
properties.append(
{
@ -1606,28 +1630,23 @@ def _refine_and_synthesize_dunders(
}
)
if sys.version_info >= (3, 12) and "__buffer__" not in method_names:
try:
instance = cls()
memoryview(instance)
methods.append(
{
"name": "__buffer__",
"doc": "",
"params": [
{
"name": "flags",
"type": "int",
"default": None,
"kind": "POSITIONAL_ONLY",
}
],
"return_type": "memoryview",
"is_classmethod": False,
}
)
except Exception:
pass
if "__buffer__" not in method_names and hasattr(cls, "__buffer__"):
methods.append(
{
"name": "__buffer__",
"doc": "",
"params": [
{
"name": "flags",
"type": "int",
"default": None,
"kind": "POSITIONAL_ONLY",
}
],
"return_type": "memoryview",
"is_classmethod": False,
}
)
def _collect_public_module_names(module: ModuleType, module_name: str) -> list[str]:
@ -1757,6 +1776,12 @@ def introspect_class(cls: type[object], module_name: str) -> StructData:
_insert_constructor_from_doc(cls, class_doc, methods)
_refine_and_synthesize_dunders(cls, methods)
# Mark __eq__/__ne__ as overrides (they override object.__eq__/__ne__)
_OBJECT_OVERRIDES = {"__eq__", "__ne__"}
for method in methods:
if method["name"] in _OBJECT_OVERRIDES:
method["is_override"] = True
return {
"name": cls.__name__,
"doc": class_doc,
@ -1859,64 +1884,219 @@ def _apply_ops_property_and_method_fixups(op_struct: StructData) -> None:
method["return_type"] = "bpy.types.Struct"
def _append_ops_call_method(op_struct: StructData) -> None:
"""Append the callable operator wrapper method definition."""
op_struct["methods"].append(
{
"name": "__call__",
"doc": "Execute the operator.",
"params": [
{
"name": "args",
"type": "object",
"default": None,
"kind": "VAR_POSITIONAL",
},
{
"name": "kwargs",
"type": "object",
"default": None,
"kind": "VAR_KEYWORD",
},
],
"return_type": "set[str]",
"is_classmethod": False,
}
)
_RAW_RNA_TYPE_MAP: dict[str, str] = {
"BOOLEAN": "bool",
"INT": "int",
"FLOAT": "float",
"STRING": "str",
"ENUM": "str",
}
_VECTOR_SUBTYPES = {
"TRANSLATION",
"DIRECTION",
"VELOCITY",
"ACCELERATION",
"XYZ",
"XYZ_LENGTH",
}
def _build_ops_submodule_struct(operator_type_name: str) -> StructData:
"""Build synthetic bpy.ops submodule structure for attribute access."""
return {
"name": "_OpsSubModule",
"doc": "Operator submodule (e.g. bpy.ops.mesh).",
"base": None,
"properties": [],
"methods": [
{
"name": "__getattr__",
"doc": "",
"params": [
{
"name": "name",
"type": "str",
"default": None,
"kind": "POSITIONAL_OR_KEYWORD",
},
],
"return_type": operator_type_name,
"is_classmethod": False,
def _raw_rna_prop_to_type(prop: object) -> str:
"""Convert a raw RNA Property to a type string (for operator params)."""
ptype: str = getattr(prop, "type", "")
is_array: bool = getattr(prop, "is_array", False)
array_length: int = getattr(prop, "array_length", 0)
subtype: str = getattr(prop, "subtype", "NONE")
if ptype == "ENUM":
items = list(getattr(prop, "enum_items", []))
if items:
values = [str(getattr(i, "identifier", "")) for i in items]
if values:
quoted = ", ".join(f'"{v}"' for v in values)
return f"Literal[{quoted}]"
return "str"
if ptype == "POINTER":
fixed = getattr(prop, "fixed_type", None)
if fixed is not None:
return f"bpy.types.{getattr(fixed, 'identifier', 'object')} | None"
return "object | None"
if ptype == "COLLECTION":
return "object"
if ptype in ("FLOAT", "INT", "BOOLEAN") and is_array:
base = _RAW_RNA_TYPE_MAP.get(ptype, "object")
if ptype == "FLOAT" and subtype in _VECTOR_SUBTYPES:
return "mathutils.Vector | collections.abc.Sequence[float]"
if ptype == "FLOAT" and subtype == "EULER":
return "mathutils.Euler | collections.abc.Sequence[float]"
if (
ptype == "FLOAT"
and subtype in ("COLOR", "COLOR_GAMMA")
and array_length == 3
):
return "mathutils.Color | collections.abc.Sequence[float]"
if ptype == "FLOAT" and subtype == "MATRIX":
return "mathutils.Matrix | collections.abc.Sequence[float]"
return f"collections.abc.Sequence[{base}]"
return _RAW_RNA_TYPE_MAP.get(ptype, "object")
def _rna_prop_default(prop: object) -> str:
"""Extract the default value from an RNA property as a string for stubs."""
ptype: str = getattr(prop, "type", "")
is_array: bool = getattr(prop, "is_array", False)
try:
if ptype == "ENUM":
is_flag = getattr(prop, "is_enum_flag", False)
if is_flag:
# Enum flags are sets — use ellipsis since the type is Literal, not set
return "..."
val = str(getattr(prop, "default", ""))
# If the default is not in the enum items, use ellipsis
items = {
str(getattr(i, "identifier", ""))
for i in getattr(prop, "enum_items", [])
}
],
if val and val in items:
return repr(val)
return "..."
if is_array:
arr = getattr(prop, "default_array", None)
if arr is not None:
return repr(list(arr))
return "..."
val = getattr(prop, "default", None)
if val is None:
return "None"
return repr(val)
except Exception:
return "..."
def _introspect_operator(op: object) -> FunctionData | None:
"""Introspect a single bpy.ops operator and return its typed signature."""
get_rna = getattr(op, "get_rna_type", None)
if get_rna is None or not callable(get_rna):
return None
try:
rna = get_rna()
except Exception:
return None
idname_fn = getattr(op, "idname_py", None)
if idname_fn is None:
return None
try:
py_name: str = idname_fn()
except Exception:
return None
# Extract the function name (e.g. "mesh.primitive_cube_add" -> "primitive_cube_add")
func_name = py_name.split(".")[-1] if "." in py_name else py_name
doc: str = getattr(rna, "description", "") or ""
# All operators accept an optional execution context as the first positional arg
params: list[ParamData] = [
{
"name": "execution_context",
"type": "Literal['INVOKE_DEFAULT', 'INVOKE_REGION_WIN', 'INVOKE_REGION_CHANNELS', 'INVOKE_REGION_PREVIEW', 'INVOKE_AREA', 'INVOKE_SCREEN', 'EXEC_DEFAULT', 'EXEC_REGION_WIN', 'EXEC_REGION_CHANNELS', 'EXEC_REGION_PREVIEW', 'EXEC_AREA', 'EXEC_SCREEN'] | None",
"default": "None",
"kind": "POSITIONAL_ONLY",
},
]
for prop in getattr(rna, "properties", []):
pid: str = getattr(prop, "identifier", "")
if pid == "rna_type":
continue
param_type = _raw_rna_prop_to_type(prop)
default = _rna_prop_default(prop)
params.append(
{
"name": pid,
"type": param_type,
"default": default,
"kind": "KEYWORD_ONLY",
}
)
return {
"name": func_name,
"doc": doc,
"params": params,
"return_type": "set[str]",
"is_classmethod": False,
}
def _build_ops_submodule_variables(submodule_names: list[str]) -> list[VariableData]:
"""Build typed bpy.ops submodule variable declarations."""
return [
{"name": sub_name, "type": "_OpsSubModule", "value": "..."}
for sub_name in submodule_names
]
def _introspect_ops_submodule(
ops_mod: object, sub_name: str, op_base_name: str
) -> tuple[StructData, list[StructData]]:
"""Introspect an ops submodule.
Returns the module-level struct (_OpsModule_*) and a list of per-operator
structs that inherit from the operator base class (e.g. BPyOpFunction).
Each operator is a class with a typed __call__ so that both
``bpy.ops.mesh.subdivide(number_cuts=3)`` and
``bpy.ops.mesh.subdivide.poll()`` type-check correctly.
"""
sub = getattr(ops_mod, sub_name)
op_structs: list[StructData] = []
op_props: list[PropertyData] = []
for op_name in sorted(dir(sub)):
if op_name.startswith("_"):
continue
op = getattr(sub, op_name, None)
if op is None:
continue
func_data = _introspect_operator(op)
if func_data is None:
continue
# Create a per-operator class inheriting from the base operator type
op_class_name = f"_Op_{sub_name}_{op_name}"
call_method: FunctionData = {
"name": "__call__",
"doc": func_data["doc"],
"params": func_data["params"],
"return_type": "set[str]",
"is_classmethod": False,
}
op_struct: StructData = {
"name": op_class_name,
"doc": func_data["doc"],
"base": op_base_name,
"properties": [],
"methods": [call_method],
}
op_structs.append(op_struct)
op_props.append(
{
"name": op_name,
"type": op_class_name,
"is_readonly": False,
"description": func_data["doc"],
}
)
class_name = f"_OpsModule_{sub_name}"
module_struct: StructData = {
"name": class_name,
"doc": f"Operators in bpy.ops.{sub_name}.",
"base": None,
"properties": op_props,
"methods": [],
}
return module_struct, op_structs
def introspect_ops_module() -> ModuleData:
@ -1950,17 +2130,37 @@ def introspect_ops_module() -> ModuleData:
_apply_ops_method_return_fixups(op_struct, sample_op)
_apply_ops_property_and_method_fixups(op_struct)
_append_ops_call_method(op_struct)
sub_struct = _build_ops_submodule_struct(op_struct["name"])
variables = _build_ops_submodule_variables(submodule_names)
# Introspect each ops submodule with typed operator classes
structs: list[StructData] = [op_struct]
variables: list[VariableData] = []
print(" Introspecting bpy.ops operators...", file=sys.stderr, flush=True)
total_ops = 0
for sub_name in submodule_names:
module_struct, per_op_structs = _introspect_ops_submodule(
ops_mod, sub_name, op_struct["name"]
)
# Add per-operator classes first (they're referenced by the module struct)
structs.extend(per_op_structs)
structs.append(module_struct)
total_ops += len(per_op_structs)
variables.append(
{"name": sub_name, "type": module_struct["name"], "value": "..."}
)
print(
f" Introspected {total_ops} operators "
+ f"in {len(submodule_names)} submodules",
file=sys.stderr,
flush=True,
)
return {
"module": "bpy.ops",
"doc": "Blender operator access.",
"functions": [],
"variables": variables,
"structs": [op_struct, sub_struct],
"structs": structs,
}
@ -2531,12 +2731,29 @@ def _merge_missing_c_methods(
structs: list[StructData], bpy_types_module: ModuleType
) -> None:
"""Add C-level methods missing from RNA metadata for each struct."""
# Build parent method/property lookup for override detection
struct_by_name = {s["name"]: s for s in structs}
def _get_all_parent_names(struct: StructData) -> set[str]:
names: set[str] = set()
base = struct.get("base")
if base:
# Strip generic params (e.g. "bpy_prop_collection[Object]" -> "bpy_prop_collection")
base_name = base.split("[")[0]
parent = struct_by_name.get(base_name)
if parent:
names |= {m["name"] for m in parent["methods"]}
names |= {p["name"] for p in parent["properties"]}
names |= _get_all_parent_names(parent)
return names
for struct in structs:
cls = getattr(bpy_types_module, struct["name"], None)
if cls is None:
continue
existing = {m["name"] for m in struct["methods"]}
existing |= {p["name"] for p in struct["properties"]}
parent_names = _get_all_parent_names(struct)
for attr_name in sorted(cls.__dict__):
if attr_name.startswith("_") or attr_name in existing:
continue
@ -2554,12 +2771,16 @@ def _merge_missing_c_methods(
func_data = introspect_callable(bound, attr_name)
if func_data:
func_data["is_classmethod"] = True
if attr_name in parent_names:
func_data["is_override"] = True
struct["methods"].append(func_data)
elif raw_type in ("method_descriptor", "builtin_function_or_method"):
obj = getattr(cls, attr_name)
if callable(obj):
func_data = introspect_callable(obj, attr_name)
if func_data:
if attr_name in parent_names:
func_data["is_override"] = True
struct["methods"].append(func_data)
elif raw_type == "function":
# Python functions with RST docstrings are API methods
@ -2568,6 +2789,8 @@ def _merge_missing_c_methods(
if ":rtype:" in doc or ":type " in doc:
func_data = introspect_callable(raw, attr_name)
if func_data:
if attr_name in parent_names:
func_data["is_override"] = True
struct["methods"].append(func_data)
@ -2738,6 +2961,53 @@ def introspect_rna_types() -> ModuleData:
bpy_app = importlib.import_module("bpy.app")
blender_version: tuple[int, ...] = getattr(bpy_app, "version", (0, 0, 0))
temp_override = ctx.__class__.__dict__.get("temp_override")
if temp_override is not None and blender_version < (4, 4, 0):
# On Blender < 4.4, probing temp_override() hangs, but the docstring
# still references ContextTempOverride. Generate a minimal stub.
if "ContextTempOverride" not in known:
stub: StructData = {
"name": "ContextTempOverride",
"doc": "Context manager for context overrides.",
"base": None,
"properties": [],
"methods": [
{
"name": "__enter__",
"doc": "",
"params": [],
"return_type": "Context",
"is_classmethod": False,
},
{
"name": "__exit__",
"doc": "",
"params": [
{
"name": "exc_type",
"type": "object",
"default": None,
"kind": "POSITIONAL_OR_KEYWORD",
},
{
"name": "exc_val",
"type": "object",
"default": None,
"kind": "POSITIONAL_OR_KEYWORD",
},
{
"name": "exc_tb",
"type": "object",
"default": None,
"kind": "POSITIONAL_OR_KEYWORD",
},
],
"return_type": "None",
"is_classmethod": False,
},
],
}
structs.append(stub)
known.add("ContextTempOverride")
if (
temp_override is not None
and type(temp_override).__name__ == "method_descriptor"

22
main.py
View File

@ -296,10 +296,9 @@ def generate_package_files(
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}"
package_version = f"{full_version}.0"
pyproject = build_generated_pyproject(
major_minor, package_version, top_level_packages, python_version
@ -324,7 +323,7 @@ def generate_package_files(
(output_dir / pkg / "py.typed").touch()
def generate_for_version(blender_path: str, revision: int = 0) -> None:
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)
@ -356,7 +355,6 @@ def generate_for_version(blender_path: str, revision: int = 0) -> None:
major_minor,
top_level_packages,
python_version,
revision,
)
# Store the Python version for later type checking
@ -405,8 +403,9 @@ def typecheck_stubs(versions: list[str] | None = None) -> None:
json.dumps(
{
"extraPaths": ["."],
"typeCheckingMode": "strict",
"typeCheckingMode": "all",
"pythonVersion": python_version,
"reportPropertyTypeMismatch": False,
}
)
)
@ -480,9 +479,10 @@ def conformance_check(versions: list[str] | None = None) -> None:
{
"include": [str(test_dir)],
"extraPaths": [str(version_dir)],
"typeCheckingMode": "strict",
"typeCheckingMode": "all",
"pythonVersion": python_version,
"reportUnusedExpression": False,
"reportUnusedCallResult": False,
}
)
)
@ -513,12 +513,6 @@ if __name__ == "__main__":
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="*",
@ -540,11 +534,11 @@ if __name__ == "__main__":
if (major, minor) < min_version:
print(
f"Blender {version} is not supported"
f" (minimum: {min_version[0]}.{min_version[1]})"
+ 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)
generate_for_version(str(blender_path))
print()
print("Done.")

View File

@ -0,0 +1,12 @@
{
"subscribe_rna": {
"params": {
"key": "bpy.types.Property | bpy.types.Struct | tuple[bpy.types.Struct, str] | object"
}
},
"publish_rna": {
"params": {
"key": "bpy.types.Property | bpy.types.Struct | tuple[bpy.types.Struct, str] | object"
}
}
}

View File

@ -0,0 +1,12 @@
{
"subscribe_rna": {
"params": {
"key": "bpy.types.Property | bpy.types.Struct | tuple[bpy.types.Struct, str] | object"
}
},
"publish_rna": {
"params": {
"key": "bpy.types.Property | bpy.types.Struct | tuple[bpy.types.Struct, str] | object"
}
}
}

View File

@ -0,0 +1,12 @@
{
"subscribe_rna": {
"params": {
"key": "bpy.types.Property | bpy.types.Struct | tuple[bpy.types.Struct, str] | object"
}
},
"publish_rna": {
"params": {
"key": "bpy.types.Property | bpy.types.Struct | tuple[bpy.types.Struct, str] | object"
}
}
}

View File

@ -0,0 +1,12 @@
{
"subscribe_rna": {
"params": {
"key": "bpy.types.Property | bpy.types.Struct | tuple[bpy.types.Struct, str] | object"
}
},
"publish_rna": {
"params": {
"key": "bpy.types.Property | bpy.types.Struct | tuple[bpy.types.Struct, str] | object"
}
}
}

View File

@ -0,0 +1,12 @@
{
"subscribe_rna": {
"params": {
"key": "bpy.types.Property | bpy.types.Struct | tuple[bpy.types.Struct, str] | object"
}
},
"publish_rna": {
"params": {
"key": "bpy.types.Property | bpy.types.Struct | tuple[bpy.types.Struct, str] | object"
}
}
}

225
publish.py Normal file
View File

@ -0,0 +1,225 @@
"""Publish stubs to PyPI with automatic version bumping.
Fetches the latest published version from PyPI, bumps the revision,
asks for confirmation, then builds and uploads.
"""
import argparse
import json
import subprocess
import sys
import tomllib
import urllib.error
import urllib.request
from pathlib import Path
from typing import cast
SCRIPT_DIR = Path(__file__).parent
DIST_DIR = SCRIPT_DIR / "dist"
PACKAGE_NAME = "blender-python-stubs"
PYPI_JSON_URL = "https://pypi.org/pypi/{package}/json"
TEST_PYPI_JSON_URL = "https://test.pypi.org/pypi/{package}/json"
TEST_PYPI_UPLOAD_URL = "https://test.pypi.org/legacy/"
def fetch_latest_revision(blender_version: str, use_test_pypi: bool) -> int | None:
"""Fetch the latest revision for a Blender version from PyPI.
Returns the revision number (the 4th component of X.Y.Z.R) or None
if no matching version is found.
"""
base_url = TEST_PYPI_JSON_URL if use_test_pypi else PYPI_JSON_URL
url = base_url.format(package=PACKAGE_NAME)
try:
req = urllib.request.Request(url, headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=10) as resp:
data: dict[str, object] = json.loads(resp.read())
except urllib.error.HTTPError as e:
if e.code == 404:
return None
raise
raw_releases = data.get("releases")
if not isinstance(raw_releases, dict):
return None
releases = cast(dict[str, object], raw_releases)
# Find versions matching this Blender version (e.g., 5.1.0.*)
prefix = blender_version + "."
max_revision = -1
for version_str in releases:
if not version_str.startswith(prefix):
continue
parts = version_str.split(".")
if len(parts) != 4:
continue
try:
revision = int(parts[3])
except ValueError:
continue
if revision > max_revision:
max_revision = revision
return max_revision if max_revision >= 0 else None
def read_generated_version(version_dir: Path) -> str:
"""Read the version from a generated dist pyproject.toml."""
pyproject_path = version_dir / "pyproject.toml"
if not pyproject_path.exists():
print(f"No pyproject.toml found in {version_dir}", file=sys.stderr)
sys.exit(1)
with pyproject_path.open("rb") as f:
data = tomllib.load(f)
version = data.get("project", {}).get("version", "")
if not isinstance(version, str) or not version:
print(f"No version found in {pyproject_path}", file=sys.stderr)
sys.exit(1)
return version
def update_version_in_pyproject(version_dir: Path, new_version: str) -> None:
"""Update the version in the generated pyproject.toml."""
pyproject_path = version_dir / "pyproject.toml"
content = pyproject_path.read_text()
# Replace the version line
lines: list[str] = []
for line in content.splitlines():
if line.startswith("version = "):
lines.append(f'version = "{new_version}"')
else:
lines.append(line)
pyproject_path.write_text("\n".join(lines) + "\n")
def publish_version(
blender_version: str,
publish_url: str,
revision: int | None,
yes: bool,
) -> None:
"""Publish stubs for a single Blender version."""
# Parse blender_version into the full version from the generated stubs
version_dir = DIST_DIR / blender_version
if not version_dir.is_dir():
print(
f"No generated stubs for {blender_version}. Run 'poe generate {blender_version}' first.",
file=sys.stderr,
)
sys.exit(1)
current_version = read_generated_version(version_dir)
parts = current_version.split(".")
if len(parts) != 4:
print(
f"Unexpected version format: {current_version}",
file=sys.stderr,
)
sys.exit(1)
base_version = ".".join(parts[:3]) # e.g., "5.1.0"
use_test_pypi = "test.pypi" in publish_url
if revision is not None:
new_revision = revision
else:
# Fetch latest from PyPI
pypi_label = "Test PyPI" if use_test_pypi else "PyPI"
print(f"Fetching latest version from {pypi_label}...")
latest = fetch_latest_revision(base_version, use_test_pypi)
if latest is not None:
new_revision = latest + 1
print(f" Latest published: {base_version}.{latest}")
else:
new_revision = 0
print(f" No published version found for {base_version}")
new_version = f"{base_version}.{new_revision}"
if not yes:
answer = input(f"Publish {new_version}? [y/N] ")
if answer.lower() not in ("y", "yes"):
print("Aborted.")
sys.exit(0)
# Update pyproject.toml with the new version
update_version_in_pyproject(version_dir, new_version)
print(f" Version set to {new_version}")
# Clean previous build artifacts
build_dist = version_dir / "dist"
if build_dist.exists():
import shutil
shutil.rmtree(build_dist)
# Build
print(" Building...")
result = subprocess.run(
["uv", "build", str(version_dir)],
cwd=str(SCRIPT_DIR),
)
if result.returncode != 0:
print("Build failed.", file=sys.stderr)
sys.exit(1)
# Publish
print(f" Publishing to {publish_url}...")
result = subprocess.run(
[
"uv",
"publish",
str(build_dist / "*"),
"--publish-url",
publish_url,
],
cwd=str(SCRIPT_DIR),
)
if result.returncode != 0:
print("Publish failed.", file=sys.stderr)
sys.exit(1)
print(f" Published {new_version}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Publish Blender type stubs to PyPI")
parser.add_argument(
"version",
help="Blender version to publish (e.g., 5.1)",
)
parser.add_argument(
"--revision",
type=int,
default=None,
help="Override revision number instead of auto-detecting",
)
parser.add_argument(
"--publish-url",
default=TEST_PYPI_UPLOAD_URL,
help="PyPI upload URL (defaults to Test PyPI)",
)
parser.add_argument(
"-y",
"--yes",
action="store_true",
help="Skip confirmation prompt",
)
args = parser.parse_args()
publish_version(
args.version,
args.publish_url,
args.revision,
args.yes,
)

View File

@ -7,15 +7,15 @@ requires-python = ">=3.11"
license = "MIT"
keywords = ["blender", "stubs", "typing", "bpy", "autocomplete"]
authors = [
{ name = "Joseph HENRY" },
{ name = "Autour de Minuit", email = "tech@autourdeminuit.com" },
{ name = "Joseph HENRY" },
{ name = "Autour de Minuit", email = "tech@autourdeminuit.com" },
]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Multimedia :: Graphics :: 3D Modeling",
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Multimedia :: Graphics :: 3D Modeling",
]
dependencies = []
@ -28,8 +28,10 @@ Issues = "https://git.autourdeminuit.com/autour_de_minuit/blender-python-stubs/i
target-version = ["py311"]
[tool.basedpyright]
typeCheckingMode = "strict"
typeCheckingMode = "all"
extraPaths = ["stubs"]
reportUnusedCallResult = false
reportAny = false
exclude = ["downloads/", ".venv/", "dist/", "conformance/"]
[tool.ruff]
@ -48,18 +50,8 @@ cmd = "python -m main"
help = "Generate stubs for Blender versions (e.g., poe generate 5.0 4.3)"
[tool.poe.tasks.publish]
shell = "uv build dist/$version && uv publish dist/$version/dist/* --publish-url ${publish_url}"
help = "Build and publish stubs (e.g., poe publish 5.0)"
[tool.poe.tasks.publish.args.version]
positional = true
required = true
help = "Blender version to publish (e.g., 5.0)"
[tool.poe.tasks.publish.args.publish-url]
options = ["--publish-url"]
default = "https://test.pypi.org/legacy/"
help = "PyPI upload URL (defaults to Test PyPI)"
cmd = "python -m publish"
help = "Build and publish stubs with auto version bump (e.g., poe publish 5.0)"
[tool.poe.tasks.check]
help = "Run all checks (format, lint, typecheck, test)"
@ -67,8 +59,9 @@ sequence = ["format", "lint", "typecheck", "test", "typecheck-stubs"]
[dependency-groups]
dev = [
"basedpyright>=1.38.3",
"black>=26.3.1",
"poethepoet>=0.42.1",
"ruff>=0.15.6",
"basedpyright>=1.38.3",
"black>=26.3.1",
"poethepoet>=0.42.1",
"ruff>=0.15.6",
"typing-extensions>=4.15.0",
]

View File

@ -1,8 +1,9 @@
"""Tests for the introspection module."""
from typing_extensions import override
import unittest
from types import SimpleNamespace
from typing import cast
from typing import cast, final
from unittest.mock import patch
from introspect import (
@ -134,6 +135,14 @@ class TestParseDocstringTypes(unittest.TestCase):
def test_module_maps_to_types_moduletype(self) -> None:
self.assertEqual(clean_type_str("Module"), "types.ModuleType")
self.assertEqual(clean_type_str("Module | None"), "types.ModuleType | None")
self.assertEqual(clean_type_str("module"), "types.ModuleType")
def test_old_typing_names_to_builtins(self) -> None:
self.assertEqual(clean_type_str("Dict[str, int]"), "dict[str, int]")
self.assertEqual(clean_type_str("Set[int]"), "set[int]")
self.assertEqual(clean_type_str("List[str]"), "list[str]")
self.assertEqual(clean_type_str("Tuple[int, str]"), "tuple[int, str]")
self.assertEqual(clean_type_str("FrozenSet[int]"), "frozenset[int]")
def test_nonetype_maps_to_none(self) -> None:
self.assertEqual(clean_type_str("NoneType"), "None")
@ -225,6 +234,17 @@ class TestParseDocstringTypes(unittest.TestCase):
def test_callable_empty_params_preserved(self) -> None:
self.assertEqual(clean_type_str("Callable[[], None]"), "Callable[[], None]")
def test_callable_ellipsis_params_no_double_comma(self) -> None:
# Callable[[...], X] should become Callable[..., X] without double comma
self.assertEqual(
clean_type_str("Callable[[...], str | None]"),
"Callable[..., str | None]",
)
self.assertEqual(
clean_type_str("Callable[[...], object]"),
"Callable[..., object]",
)
def test_bare_generic_no_false_match(self) -> None:
# \bSequence\b should not match inside SequenceEntry
self.assertEqual(clean_type_str("SequenceEntry"), "SequenceEntry")
@ -394,7 +414,7 @@ class TestParamNameMismatch(unittest.TestCase):
def fake_func(object: object) -> str: # noqa: A002
""":type string: str
:rtype: str"""
return ""
return str(object)
result = introspect_callable(fake_func, "escape_identifier")
self.assertIsNotNone(result)
@ -406,7 +426,7 @@ class TestParamNameMismatch(unittest.TestCase):
def fake_func(path: str) -> str:
""":type path: str
:rtype: str"""
return ""
return path
result = introspect_callable(fake_func, "basename")
self.assertIsNotNone(result)
@ -560,8 +580,9 @@ class TestInferGetterReturnTypes(unittest.TestCase):
class _ContextWithRaisingMember:
ok = 7
ok: int = 7
@override
def __dir__(self) -> list[str]:
return ["ok", "broken", "callable_member", "rna_type"]
@ -574,20 +595,24 @@ class _ContextWithRaisingMember:
class _OpsNoCallableSubmodule:
non_callable = 123
non_callable: int = 123
@override
def __dir__(self) -> list[str]:
return ["non_callable"]
@final
class _OpsRootNoCallable:
mesh = _OpsNoCallableSubmodule()
@override
def __dir__(self) -> list[str]:
return ["mesh"]
class _OpsRootEmpty:
@override
def __dir__(self) -> list[str]:
return []

11
uv.lock generated
View File

@ -62,6 +62,7 @@ dev = [
{ name = "black" },
{ name = "poethepoet" },
{ name = "ruff" },
{ name = "typing-extensions" },
]
[package.metadata]
@ -72,6 +73,7 @@ dev = [
{ name = "black", specifier = ">=26.3.1" },
{ name = "poethepoet", specifier = ">=0.42.1" },
{ name = "ruff", specifier = ">=0.15.6" },
{ name = "typing-extensions", specifier = ">=4.15.0" },
]
[[package]]
@ -282,3 +284,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" },
{ url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]