From 5b3253b81ab32a2f5d42c2b7e9613b53fd508e16 Mon Sep 17 00:00:00 2001 From: Joseph HENRY Date: Thu, 2 Apr 2026 11:40:51 +0200 Subject: [PATCH] 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) --- conformance/5.0/test_bpy_data.py | 26 ++++++++++++ .../5.0/test_bpy_msgbus_example_use.py | 26 ++++++++++++ .../5.0/test_bpy_ops_execution_context.py | 14 +++++++ ...y_ops_keywords_and_positional_arguments.py | 9 +++++ .../5.0/test_bpy_ops_overriding_context.py | 8 ++++ conformance/5.0/test_bpy_types_context.py | 27 +++++++++++++ conformance/5.0/test_bpy_types_object.py | 20 ++++++++++ conformance/5.1/test_bpy_data.py | 26 ++++++++++++ .../5.1/test_bpy_msgbus_example_use.py | 26 ++++++++++++ .../5.1/test_bpy_ops_execution_context.py | 14 +++++++ ...y_ops_keywords_and_positional_arguments.py | 9 +++++ .../5.1/test_bpy_ops_overriding_context.py | 8 ++++ conformance/5.1/test_bpy_types_context.py | 36 +++++++++++++++++ conformance/5.1/test_bpy_types_object.py | 20 ++++++++++ generate_stubs.py | 40 ++++++++++++++++++- 15 files changed, 307 insertions(+), 2 deletions(-) create mode 100644 conformance/5.0/test_bpy_data.py create mode 100644 conformance/5.0/test_bpy_msgbus_example_use.py create mode 100644 conformance/5.0/test_bpy_ops_execution_context.py create mode 100644 conformance/5.0/test_bpy_ops_keywords_and_positional_arguments.py create mode 100644 conformance/5.0/test_bpy_ops_overriding_context.py create mode 100644 conformance/5.0/test_bpy_types_context.py create mode 100644 conformance/5.0/test_bpy_types_object.py create mode 100644 conformance/5.1/test_bpy_data.py create mode 100644 conformance/5.1/test_bpy_msgbus_example_use.py create mode 100644 conformance/5.1/test_bpy_ops_execution_context.py create mode 100644 conformance/5.1/test_bpy_ops_keywords_and_positional_arguments.py create mode 100644 conformance/5.1/test_bpy_ops_overriding_context.py create mode 100644 conformance/5.1/test_bpy_types_context.py create mode 100644 conformance/5.1/test_bpy_types_object.py diff --git a/conformance/5.0/test_bpy_data.py b/conformance/5.0/test_bpy_data.py new file mode 100644 index 0000000..6b6415b --- /dev/null +++ b/conformance/5.0/test_bpy_data.py @@ -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]) + ) diff --git a/conformance/5.0/test_bpy_msgbus_example_use.py b/conformance/5.0/test_bpy_msgbus_example_use.py new file mode 100644 index 0000000..b15b393 --- /dev/null +++ b/conformance/5.0/test_bpy_msgbus_example_use.py @@ -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") diff --git a/conformance/5.0/test_bpy_ops_execution_context.py b/conformance/5.0/test_bpy_ops_execution_context.py new file mode 100644 index 0000000..06dc086 --- /dev/null +++ b/conformance/5.0/test_bpy_ops_execution_context.py @@ -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 diff --git a/conformance/5.0/test_bpy_ops_keywords_and_positional_arguments.py b/conformance/5.0/test_bpy_ops_keywords_and_positional_arguments.py new file mode 100644 index 0000000..b9e9c2c --- /dev/null +++ b/conformance/5.0/test_bpy_ops_keywords_and_positional_arguments.py @@ -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") diff --git a/conformance/5.0/test_bpy_ops_overriding_context.py b/conformance/5.0/test_bpy_ops_overriding_context.py new file mode 100644 index 0000000..db50c3f --- /dev/null +++ b/conformance/5.0/test_bpy_ops_overriding_context.py @@ -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() diff --git a/conformance/5.0/test_bpy_types_context.py b/conformance/5.0/test_bpy_types_context.py new file mode 100644 index 0000000..1279ca4 --- /dev/null +++ b/conformance/5.0/test_bpy_types_context.py @@ -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() diff --git a/conformance/5.0/test_bpy_types_object.py b/conformance/5.0/test_bpy_types_object.py new file mode 100644 index 0000000..ecfdad2 --- /dev/null +++ b/conformance/5.0/test_bpy_types_object.py @@ -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 diff --git a/conformance/5.1/test_bpy_data.py b/conformance/5.1/test_bpy_data.py new file mode 100644 index 0000000..6b6415b --- /dev/null +++ b/conformance/5.1/test_bpy_data.py @@ -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]) + ) diff --git a/conformance/5.1/test_bpy_msgbus_example_use.py b/conformance/5.1/test_bpy_msgbus_example_use.py new file mode 100644 index 0000000..b15b393 --- /dev/null +++ b/conformance/5.1/test_bpy_msgbus_example_use.py @@ -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") diff --git a/conformance/5.1/test_bpy_ops_execution_context.py b/conformance/5.1/test_bpy_ops_execution_context.py new file mode 100644 index 0000000..06dc086 --- /dev/null +++ b/conformance/5.1/test_bpy_ops_execution_context.py @@ -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 diff --git a/conformance/5.1/test_bpy_ops_keywords_and_positional_arguments.py b/conformance/5.1/test_bpy_ops_keywords_and_positional_arguments.py new file mode 100644 index 0000000..b9e9c2c --- /dev/null +++ b/conformance/5.1/test_bpy_ops_keywords_and_positional_arguments.py @@ -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") diff --git a/conformance/5.1/test_bpy_ops_overriding_context.py b/conformance/5.1/test_bpy_ops_overriding_context.py new file mode 100644 index 0000000..db50c3f --- /dev/null +++ b/conformance/5.1/test_bpy_ops_overriding_context.py @@ -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() diff --git a/conformance/5.1/test_bpy_types_context.py b/conformance/5.1/test_bpy_types_context.py new file mode 100644 index 0000000..6d6b241 --- /dev/null +++ b/conformance/5.1/test_bpy_types_context.py @@ -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() diff --git a/conformance/5.1/test_bpy_types_object.py b/conformance/5.1/test_bpy_types_object.py new file mode 100644 index 0000000..ecfdad2 --- /dev/null +++ b/conformance/5.1/test_bpy_types_object.py @@ -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 diff --git a/generate_stubs.py b/generate_stubs.py index cebf69a..90ba69d 100644 --- a/generate_stubs.py +++ b/generate_stubs.py @@ -384,7 +384,9 @@ def generate_property_stub( else: result += f"{indent} ...\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 result = f"{indent}{prop['name']}: {prop['type']}\n" @@ -553,6 +555,31 @@ def collect_inherited_info( 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( structs: list[StructData], python_version: str = "3.11", doc: str = "" ) -> str: @@ -570,7 +597,7 @@ def generate_types_stub( all_type_strs_parts.append(m["return_type"]) all_type_strs = " ".join(all_type_strs_parts) - typing_imports = ["Generic", "TypeVar"] + typing_imports = ["Generic", "TypedDict", "TypeVar"] if "Literal[" in all_type_strs: typing_imports.append("Literal") if re.search(r"\bSelf\b", all_type_strs): @@ -615,6 +642,11 @@ def generate_types_stub( sorted_structs = topological_sort_structs(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: info = inherited_map.get(struct["name"], _InheritedInfo(set(), set())) # Force writable properties to readonly when they override a parent's @@ -622,6 +654,10 @@ def generate_types_stub( for prop in struct["properties"]: if not prop["is_readonly"] and prop["name"] in info.readonly_props: 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(generate_struct_stub(struct, info.methods))