Add conformance tests from Blender docs and generate ContextDict TypedDict

Add conformance test files for bpy.ops, bpy.types.Object, bpy.types.Context,
bpy.msgbus, and bpy.data from official Blender documentation for both 5.0 and
5.1. Generate a ContextDict TypedDict from Context properties so that
Context.copy() returns precisely typed values instead of dict[str, object],
fixing type errors when unpacking into temp_override().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph HENRY 2026-04-02 11:40:51 +02:00
parent 004b6b11bc
commit 5b3253b81a
15 changed files with 307 additions and 2 deletions

View File

@ -0,0 +1,26 @@
import bpy
# Print all objects.
for obj in bpy.data.objects:
print(obj.name)
# Print all scene names in a list.
print(bpy.data.scenes.keys())
# Remove mesh Cube.
if "Cube" in bpy.data.meshes:
mesh = bpy.data.meshes["Cube"]
print("removing mesh", mesh)
bpy.data.meshes.remove(mesh)
# Write images into a file next to the blend.
import os
with open(os.path.splitext(bpy.data.filepath)[0] + ".txt", "w") as fs:
for image in bpy.data.images:
fs.write(
"{:s} {:d} x {:d}\n".format(image.filepath, image.size[0], image.size[1])
)

View File

@ -0,0 +1,26 @@
import bpy
# Any Python object can act as the subscription's owner.
owner = object()
assert bpy.context.object is not None
subscribe_to = bpy.context.object.location
def msgbus_callback(*args: int):
# This will print:
# Something changed! (1, 2, 3)
print("Something changed!", args)
bpy.msgbus.subscribe_rna(
key=subscribe_to,
owner=owner,
args=(1, 2, 3),
notify=msgbus_callback,
)
assert bpy.context.object is not None
subscribe_to = bpy.context.object.path_resolve("name", False)
subscribe_to = (bpy.types.Object, "location")

View File

@ -0,0 +1,14 @@
# Collection add popup.
import bpy
from bpy import context
bpy.ops.object.collection_instance_add("INVOKE_DEFAULT")
# Maximize 3d view in all windows.
for window in context.window_manager.windows:
screen = window.screen
for area in screen.areas:
if area.type == "VIEW_3D":
with context.temp_override(window=window, area=area):
bpy.ops.screen.screen_full_area()
break

View File

@ -0,0 +1,9 @@
# Calling an operator.
import bpy
bpy.ops.mesh.subdivide(number_cuts=3, smoothness=0.5)
# Check poll() to avoid exception.
if bpy.ops.object.mode_set.poll():
bpy.ops.object.mode_set(mode="EDIT")

View File

@ -0,0 +1,8 @@
# Remove all objects in scene rather than the selected ones.
import bpy
from bpy import context
context_override = context.copy()
context_override["selected_objects"] = list(context.scene.objects)
with context.temp_override(**context_override):
bpy.ops.object.delete()

View File

@ -0,0 +1,27 @@
import bpy
from bpy import context
# Reload the current file and select all.
bpy.ops.wm.open_mainfile(filepath=bpy.data.filepath)
window = context.window_manager.windows[0]
with context.temp_override(window=window):
bpy.ops.mesh.primitive_uv_sphere_add()
# The context override is needed so it's possible to set edit-mode.
bpy.ops.object.mode_set(mode="EDIT")
win_active = context.window
win_other = None
for win_iter in context.window_manager.windows:
if win_iter != win_active:
win_other = win_iter
break
# Add cube in the other window.
with context.temp_override(window=win_other):
bpy.ops.mesh.primitive_cube_add()
my_objects = [context.scene.camera]
with context.temp_override(selected_objects=my_objects) as override:
override.logging_set(True) # Enable logging.
bpy.ops.object.delete()

View File

@ -0,0 +1,20 @@
import bpy
view_layer = bpy.context.view_layer
# Create new light data-block.
light_data = bpy.data.lights.new(name="New Light", type="POINT")
# Create new object with our light data-block.
light_object = bpy.data.objects.new(name="New Light", object_data=light_data)
# Link light object to the active collection of current view layer,
# so that it'll appear in the current scene.
view_layer.active_layer_collection.collection.objects.link(light_object)
# Place light to a specified location.
light_object.location = (5.0, 5.0, 5.0)
# And finally select it and make it active.
light_object.select_set(True)
view_layer.objects.active = light_object

View File

@ -0,0 +1,26 @@
import bpy
# Print all objects.
for obj in bpy.data.objects:
print(obj.name)
# Print all scene names in a list.
print(bpy.data.scenes.keys())
# Remove mesh Cube.
if "Cube" in bpy.data.meshes:
mesh = bpy.data.meshes["Cube"]
print("removing mesh", mesh)
bpy.data.meshes.remove(mesh)
# Write images into a file next to the blend.
import os
with open(os.path.splitext(bpy.data.filepath)[0] + ".txt", "w") as fs:
for image in bpy.data.images:
fs.write(
"{:s} {:d} x {:d}\n".format(image.filepath, image.size[0], image.size[1])
)

View File

@ -0,0 +1,26 @@
import bpy
# Any Python object can act as the subscription's owner.
owner = object()
assert bpy.context.object is not None
subscribe_to = bpy.context.object.location
def msgbus_callback(*args: int):
# This will print:
# Something changed! (1, 2, 3)
print("Something changed!", args)
bpy.msgbus.subscribe_rna(
key=subscribe_to,
owner=owner,
args=(1, 2, 3),
notify=msgbus_callback,
)
assert bpy.context.object is not None
subscribe_to = bpy.context.object.path_resolve("name", False)
subscribe_to = (bpy.types.Object, "location")

View File

@ -0,0 +1,14 @@
# Collection add popup.
import bpy
from bpy import context
bpy.ops.object.collection_instance_add("INVOKE_DEFAULT")
# Maximize 3d view in all windows.
for window in context.window_manager.windows:
screen = window.screen
for area in screen.areas:
if area.type == "VIEW_3D":
with context.temp_override(window=window, area=area):
bpy.ops.screen.screen_full_area()
break

View File

@ -0,0 +1,9 @@
# Calling an operator.
import bpy
bpy.ops.mesh.subdivide(number_cuts=3, smoothness=0.5)
# Check poll() to avoid exception.
if bpy.ops.object.mode_set.poll():
bpy.ops.object.mode_set(mode="EDIT")

View File

@ -0,0 +1,8 @@
# Remove all objects in scene rather than the selected ones.
import bpy
from bpy import context
context_override = context.copy()
context_override["selected_objects"] = list(context.scene.objects)
with context.temp_override(**context_override):
bpy.ops.object.delete()

View File

@ -0,0 +1,36 @@
import bpy
from bpy import context
# Example inserting keyframe for the hovered property.
active_property = bpy.context.property
if active_property:
datablock, data_path, index = active_property
datablock.keyframe_insert(data_path=data_path, index=index, frame=1)
# Reload the current file and select all.
bpy.ops.wm.open_mainfile(filepath=bpy.data.filepath)
window = context.window_manager.windows[0]
with context.temp_override(window=window):
bpy.ops.mesh.primitive_uv_sphere_add()
# The context override is needed so it's possible to set edit-mode.
bpy.ops.object.mode_set(mode="EDIT")
win_active = context.window
win_other = None
for win_iter in context.window_manager.windows:
if win_iter != win_active:
win_other = win_iter
break
# Add cube in the other window.
with context.temp_override(window=win_other):
bpy.ops.mesh.primitive_cube_add()
my_objects = [context.scene.camera]
with context.temp_override(selected_objects=my_objects) as override:
override.logging_set(
True, # Enable logging.
hide_missing=True, # Don't show failed attempts.
)
bpy.ops.object.delete()

View File

@ -0,0 +1,20 @@
import bpy
view_layer = bpy.context.view_layer
# Create new light data-block.
light_data = bpy.data.lights.new(name="New Light", type="POINT")
# Create new object with our light data-block.
light_object = bpy.data.objects.new(name="New Light", object_data=light_data)
# Link light object to the active collection of current view layer,
# so that it'll appear in the current scene.
view_layer.active_layer_collection.collection.objects.link(light_object)
# Place light to a specified location.
light_object.location = (5.0, 5.0, 5.0)
# And finally select it and make it active.
light_object.select_set(True)
view_layer.objects.active = light_object

View File

@ -384,7 +384,9 @@ def generate_property_stub(
else: else:
result += f"{indent} ...\n" result += f"{indent} ...\n"
result += f"{indent}@{prop['name']}.setter\n" result += f"{indent}@{prop['name']}.setter\n"
result += f"{indent}def {prop['name']}(self, value: {setter_type}) -> None: ...\n" result += (
f"{indent}def {prop['name']}(self, value: {setter_type}) -> None: ...\n"
)
return result return result
result = f"{indent}{prop['name']}: {prop['type']}\n" result = f"{indent}{prop['name']}: {prop['type']}\n"
@ -553,6 +555,31 @@ def collect_inherited_info(
return cache return cache
def _generate_context_dict(context_struct: StructData) -> str:
"""Generate a ContextDict TypedDict from Context properties.
This TypedDict mirrors all Context properties so that Context.copy()
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."""')
method_names = {m["name"] for m in context_struct["methods"]}
for prop in context_struct["properties"]:
if prop["name"] in method_names:
continue
lines.append(f" {prop['name']}: {prop['type']}")
lines.append("")
return "\n".join(lines)
def _patch_context_copy_return_type(context_struct: StructData) -> None:
"""Change Context.copy() return type from dict[str, object] to ContextDict."""
for method in context_struct["methods"]:
if method["name"] == "copy":
method["return_type"] = "ContextDict"
break
def generate_types_stub( def generate_types_stub(
structs: list[StructData], python_version: str = "3.11", doc: str = "" structs: list[StructData], python_version: str = "3.11", doc: str = ""
) -> str: ) -> str:
@ -570,7 +597,7 @@ def generate_types_stub(
all_type_strs_parts.append(m["return_type"]) all_type_strs_parts.append(m["return_type"])
all_type_strs = " ".join(all_type_strs_parts) all_type_strs = " ".join(all_type_strs_parts)
typing_imports = ["Generic", "TypeVar"] typing_imports = ["Generic", "TypedDict", "TypeVar"]
if "Literal[" in all_type_strs: if "Literal[" in all_type_strs:
typing_imports.append("Literal") typing_imports.append("Literal")
if re.search(r"\bSelf\b", all_type_strs): if re.search(r"\bSelf\b", all_type_strs):
@ -615,6 +642,11 @@ def generate_types_stub(
sorted_structs = topological_sort_structs(structs) sorted_structs = topological_sort_structs(structs)
inherited_map = collect_inherited_info(sorted_structs) inherited_map = collect_inherited_info(sorted_structs)
# Patch Context.copy() return type and generate ContextDict TypedDict
context_struct = next((s for s in sorted_structs if s["name"] == "Context"), None)
if context_struct:
_patch_context_copy_return_type(context_struct)
for struct in sorted_structs: for struct in sorted_structs:
info = inherited_map.get(struct["name"], _InheritedInfo(set(), set())) info = inherited_map.get(struct["name"], _InheritedInfo(set(), set()))
# Force writable properties to readonly when they override a parent's # Force writable properties to readonly when they override a parent's
@ -622,6 +654,10 @@ def generate_types_stub(
for prop in struct["properties"]: for prop in struct["properties"]:
if not prop["is_readonly"] and prop["name"] in info.readonly_props: if not prop["is_readonly"] and prop["name"] in info.readonly_props:
prop["is_readonly"] = True prop["is_readonly"] = True
# Emit ContextDict TypedDict right before the Context class
if struct["name"] == "Context":
parts.append("")
parts.append(_generate_context_dict(struct))
parts.append("") parts.append("")
parts.append(generate_struct_stub(struct, info.methods)) parts.append(generate_struct_stub(struct, info.methods))