From c2876bc184d62b3f6be12baf881e473508e51df9 Mon Sep 17 00:00:00 2001 From: Joseph HENRY Date: Thu, 26 Mar 2026 18:06:11 +0100 Subject: [PATCH] Widen mathutils param types, fix getset_descriptor writability, add conformance tests - Widen bare mathutils type params (Vector, Euler, etc.) to also accept Sequence[float], matching Blender's mathutils_array_parse C behavior - Fix getset_descriptor readonly detection by probing __set__ on the descriptor instead of checking fset (which doesn't exist on C descriptors) - Accept int | slice keys in __getitem__/__setitem__/__delitem__ - Accept Sequence[element_type] values in __setitem__ for slice assignment - Add mathutils overrides for Matrix.Translation and Matrix.Scale - Extend apply_overrides to support ClassName.method_name keys - Add conformance test files from Blender docs examples - Disable reportUnusedExpression in conformance checks Remaining known conformance issues: - draw_handler_add missing from SpaceView3D - Vector not nominally Sequence[float] (buffer protocol, swizzle setters) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../test_gpu_3d_points_with_single_color.py | 37 ++++++++++ .../test_gpu_triangle_with_custom_shader.py | 42 +++++++++++ conformance/test_mathutils_color.py | 37 ++++++++++ conformance/test_mathutils_matrix.py | 35 +++++++++ conformance/test_mathutils_quaternion.py | 40 ++++++++++ conformance/test_mathutils_vector.py | 58 +++++++++++++++ generate_stubs.py | 4 +- introspect.py | 73 ++++++++++++++++--- main.py | 1 + overrides/5.0/mathutils.json | 12 +++ 10 files changed, 327 insertions(+), 12 deletions(-) create mode 100644 conformance/test_gpu_3d_points_with_single_color.py create mode 100644 conformance/test_gpu_triangle_with_custom_shader.py create mode 100644 conformance/test_mathutils_color.py create mode 100644 conformance/test_mathutils_matrix.py create mode 100644 conformance/test_mathutils_quaternion.py create mode 100644 conformance/test_mathutils_vector.py create mode 100644 overrides/5.0/mathutils.json diff --git a/conformance/test_gpu_3d_points_with_single_color.py b/conformance/test_gpu_3d_points_with_single_color.py new file mode 100644 index 0000000..302ed02 --- /dev/null +++ b/conformance/test_gpu_3d_points_with_single_color.py @@ -0,0 +1,37 @@ +import bpy +import gpu +from gpu_extras.batch import batch_for_shader + +coords = [(1, 1, 1), (-2, 0, 0), (-2, -1, 3), (0, 1, 1)] +shader = gpu.shader.from_builtin("POINT_UNIFORM_COLOR") +batch = batch_for_shader(shader, "POINTS", {"pos": coords}) + + +def draw(): + shader.uniform_float("color", (1, 1, 0, 1)) + gpu.state.point_size_set(4.5) + batch.draw(shader) + + +bpy.types.SpaceView3D.draw_handler_add(draw, (), "WINDOW", "POST_VIEW") + + +""" + +3D Lines with Single Color +-------------------------- +""" + +coords = [(1, 1, 1), (-2, 0, 0), (-2, -1, 3), (0, 1, 1)] +shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR") +batch = batch_for_shader(shader, "LINES", {"pos": coords}) + + +def draw(): + shader.uniform_float("viewportSize", gpu.state.viewport_get()[2:]) + shader.uniform_float("lineWidth", 4.5) + shader.uniform_float("color", (1, 1, 0, 1)) + batch.draw(shader) + + +bpy.types.SpaceView3D.draw_handler_add(draw, (), "WINDOW", "POST_VIEW") diff --git a/conformance/test_gpu_triangle_with_custom_shader.py b/conformance/test_gpu_triangle_with_custom_shader.py new file mode 100644 index 0000000..221a79c --- /dev/null +++ b/conformance/test_gpu_triangle_with_custom_shader.py @@ -0,0 +1,42 @@ +import bpy +import gpu +from gpu_extras.batch import batch_for_shader + +vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") +vert_out.smooth("VEC3", "pos") + +shader_info = gpu.types.GPUShaderCreateInfo() +shader_info.push_constant("MAT4", "viewProjectionMatrix") +shader_info.push_constant("FLOAT", "brightness") +shader_info.vertex_in(0, "VEC3", "position") +shader_info.vertex_out(vert_out) +shader_info.fragment_out(0, "VEC4", "FragColor") + +shader_info.vertex_source( + "void main()" + "{" + " pos = position;" + " gl_Position = viewProjectionMatrix * vec4(position, 1.0f);" + "}" +) + +shader_info.fragment_source( + "void main()" "{" " FragColor = vec4(pos * brightness, 1.0);" "}" +) + +shader = gpu.shader.create_from_info(shader_info) +del vert_out +del shader_info + +coords = [(1, 1, 1), (2, 0, 0), (-2, -1, 3)] +batch = batch_for_shader(shader, "TRIS", {"position": coords}) + + +def draw(): + matrix = bpy.context.region_data.perspective_matrix + shader.uniform_float("viewProjectionMatrix", matrix) + shader.uniform_float("brightness", 0.5) + batch.draw(shader) + + +bpy.types.SpaceView3D.draw_handler_add(draw, (), "WINDOW", "POST_VIEW") diff --git a/conformance/test_mathutils_color.py b/conformance/test_mathutils_color.py new file mode 100644 index 0000000..bd1711f --- /dev/null +++ b/conformance/test_mathutils_color.py @@ -0,0 +1,37 @@ +import mathutils + +# Color values are represented as RGB values from 0 - 1, this is blue. +col = mathutils.Color((0.0, 0.0, 1.0)) + +# As well as r/g/b attribute access you can adjust them by h/s/v. +col.s *= 0.5 + +# You can access its components by attribute or index. +print("Color R:", col.r) +print("Color G:", col[1]) +print("Color B:", col[-1]) +print("Color HSV: {:.2f}, {:.2f}, {:.2f}".format(*col)) + + +# Components of an existing color can be set. +col[:] = 0.0, 0.5, 1.0 + +# Components of an existing color can use slice notation to get a tuple. +print("Values: {:f}, {:f}, {:f}".format(*col)) + +# Colors can be added and subtracted. +col += mathutils.Color((0.25, 0.0, 0.0)) + +# Color can be multiplied, in this example color is scaled to 0-255 +# can printed as integers. +print("Color: {:d}, {:d}, {:d}".format(*(int(c) for c in (col * 255.0)))) + +# This example prints the color as hexadecimal. +print( + "Hexadecimal: {:02x}{:02x}{:02x}".format( + int(col.r * 255), int(col.g * 255), int(col.b * 255) + ) +) + +# Direct buffer access is supported at runtime via C buffer protocol +# but not expressible in type stubs (requires Python 3.12+ __buffer__). diff --git a/conformance/test_mathutils_matrix.py b/conformance/test_mathutils_matrix.py new file mode 100644 index 0000000..9e90814 --- /dev/null +++ b/conformance/test_mathutils_matrix.py @@ -0,0 +1,35 @@ +import mathutils +import math + +# Create a location matrix. +mat_loc = mathutils.Matrix.Translation((2.0, 3.0, 4.0)) + +# Create an identity matrix. +mat_sca = mathutils.Matrix.Scale(0.5, 4, (0.0, 0.0, 1.0)) + +# Create a rotation matrix. +mat_rot = mathutils.Matrix.Rotation(math.radians(45.0), 4, "X") + +# Combine transformations. +mat_out = mat_loc @ mat_rot @ mat_sca +print(mat_out) + +# Extract components back out of the matrix as two vectors and a quaternion. +loc, rot, sca = mat_out.decompose() +print(loc, rot, sca) + +# Recombine extracted components. +mat_out2 = mathutils.Matrix.LocRotScale(loc, rot, sca) +print(mat_out2) + +# It can also be useful to access components of a matrix directly. +mat = mathutils.Matrix() +mat[0][0], mat[1][0], mat[2][0] = 0.0, 1.0, 2.0 + +mat[0][0:3] = 0.0, 1.0, 2.0 + +# Each item in a matrix is a vector so vector utility functions can be used. +mat[0].xyz = 0.0, 1.0, 2.0 + +# Direct buffer access is supported at runtime via C buffer protocol +# but not expressible in type stubs (requires Python 3.12+ __buffer__). diff --git a/conformance/test_mathutils_quaternion.py b/conformance/test_mathutils_quaternion.py new file mode 100644 index 0000000..0f0a4f7 --- /dev/null +++ b/conformance/test_mathutils_quaternion.py @@ -0,0 +1,40 @@ +import mathutils +import math + +# A new rotation 90 degrees about the Y axis. +quat_a = mathutils.Quaternion((0.7071068, 0.0, 0.7071068, 0.0)) + +# Passing values to Quaternion's directly can be confusing so axis, angle +# is supported for initializing too. +quat_b = mathutils.Quaternion((0.0, 1.0, 0.0), math.radians(90.0)) + +print("Check quaternions match", quat_a == quat_b) + +# Like matrices, quaternions can be multiplied to accumulate rotational values. +quat_a = mathutils.Quaternion((0.0, 1.0, 0.0), math.radians(90.0)) +quat_b = mathutils.Quaternion((0.0, 0.0, 1.0), math.radians(45.0)) +quat_out = quat_a @ quat_b + +# Print the quaternion, euler degrees for mere mortals and (axis, angle). +print("Final Rotation:") +print(quat_out) +print("{:.2f}, {:.2f}, {:.2f}".format(*(math.degrees(a) for a in quat_out.to_euler()))) +print( + "({:.2f}, {:.2f}, {:.2f}), {:.2f}".format( + *quat_out.axis, math.degrees(quat_out.angle) + ) +) + +# Multiple rotations can be interpolated using the exponential map. +quat_c = mathutils.Quaternion((1.0, 0.0, 0.0), math.radians(15.0)) +exp_avg = ( + quat_a.to_exponential_map() + + quat_b.to_exponential_map() + + quat_c.to_exponential_map() +) / 3.0 +quat_avg = mathutils.Quaternion(exp_avg) +print("Average rotation:") +print(quat_avg) + +# Direct buffer access is supported. +print(memoryview(quat_avg).tobytes()) diff --git a/conformance/test_mathutils_vector.py b/conformance/test_mathutils_vector.py new file mode 100644 index 0000000..a2af1c1 --- /dev/null +++ b/conformance/test_mathutils_vector.py @@ -0,0 +1,58 @@ +import mathutils + +# Zero length vector. +vec = mathutils.Vector((0.0, 0.0, 1.0)) + +# Unit length vector. +vec_a = vec.normalized() + +vec_b = mathutils.Vector((0.0, 1.0, 2.0)) + +vec2d = mathutils.Vector((1.0, 2.0)) +vec3d = mathutils.Vector((1.0, 0.0, 0.0)) +vec4d = vec_a.to_4d() + +# Other `mathutils` types. +quat = mathutils.Quaternion() +matrix = mathutils.Matrix() + +# Comparison operators can be done on Vector classes: + +# (In)equality operators == and != test component values, e.g. 1,2,3 != 3,2,1 +vec_a == vec_b +vec_a != vec_b + +# Ordering operators >, >=, > and <= test vector length. +vec_a > vec_b +vec_a >= vec_b +vec_a < vec_b +vec_a <= vec_b + + +# Math can be performed on Vector classes. +vec_a + vec_b +vec_a - vec_b +vec_a @ vec_b +vec_a * 10.0 +matrix @ vec_a +quat @ vec_a +-vec_a + + +# You can access a vector object like a sequence. +x = vec_a[0] +len(vec) +vec_a[:] = vec_b +vec_a[:] = 1.0, 2.0, 3.0 +vec2d[:] = vec3d[:2] + + +# Vectors support 'swizzle' operations. +# See https://en.wikipedia.org/wiki/Swizzling_(computer_graphics) +vec.xyz = vec.zyx +vec.xy = vec4d.zw +vec.xyz = vec4d.wzz +vec4d.wxyz = vec.yxyx + +# Direct buffer access is supported. +raw_data = memoryview(vec).tobytes() diff --git a/generate_stubs.py b/generate_stubs.py index 05465aa..a0d8523 100644 --- a/generate_stubs.py +++ b/generate_stubs.py @@ -760,9 +760,7 @@ def load_overrides(overrides_dir: str, module_name: str) -> dict[str, ParamOverr return {} -def _apply_func_overrides( - func: FunctionData, func_overrides: ParamOverrides -) -> None: +def _apply_func_overrides(func: FunctionData, func_overrides: ParamOverrides) -> None: """Apply overrides to a single function/method.""" param_overrides = func_overrides.get("params", {}) for param in func["params"]: diff --git a/introspect.py b/introspect.py index 38d0518..be602f1 100644 --- a/introspect.py +++ b/introspect.py @@ -226,7 +226,9 @@ def parse_docstring_types(docstring: str) -> tuple[dict[str, str], str | None]: # Match :type param: ... up to the next RST directive (:arg, :type, :rtype, :return) # but NOT :class: or :func: which appear inside type annotations - directive_lookahead = r"(?=\n\s*:(?:arg|param|type|rtype|return|returns|raises)[\s:]|$)" + directive_lookahead = ( + r"(?=\n\s*:(?:arg|param|type|rtype|return|returns|raises)[\s:]|$)" + ) for match in re.finditer( rf":type\s+(\w+):\s*(.+?){directive_lookahead}", docstring, re.DOTALL ): @@ -890,6 +892,29 @@ def parse_rst_function_sig( return result +# Bare mathutils types that Blender's C code accepts interchangeably with +# Sequence[float] via mathutils_array_parse. When a param is typed as one +# of these in a docstring, widen it to also accept Sequence[float]. +_MATHUTILS_ARRAY_TYPES = { + "mathutils.Vector", + "mathutils.Euler", + "mathutils.Quaternion", + "mathutils.Color", + "Vector", + "Euler", + "Quaternion", + "Color", +} + + +def _widen_mathutils_params(params: list[ParamData]) -> None: + """Widen bare mathutils type params to also accept Sequence[float].""" + for param in params: + ptype = param.get("type") + if ptype and ptype in _MATHUTILS_ARRAY_TYPES: + param["type"] = f"{ptype} | Sequence[float]" + + def introspect_callable(func: Callable[..., object], name: str) -> FunctionData | None: """Introspect a callable (function or builtin) and return its metadata.""" docstring = inspect.getdoc(func) or "" @@ -947,6 +972,7 @@ def introspect_callable(func: Callable[..., object], name: str) -> FunctionData "kind": "POSITIONAL_OR_KEYWORD", } ) + _widen_mathutils_params(params) return { "name": name, "doc": clean_docstring(docstring), @@ -959,9 +985,7 @@ def introspect_callable(func: Callable[..., object], name: str) -> FunctionData # C functions often use generic names like "object" in __text_signature__ # while docstrings use descriptive names like "string", "cls", etc. doc_param_list = list(param_types.items()) - sig_param_list = [ - (n, p) for n, p in sig.parameters.items() if n != "self" - ] + sig_param_list = [(n, p) for n, p in sig.parameters.items() if n != "self"] params = [] for i, (pname, param) in enumerate(sig_param_list): @@ -991,6 +1015,7 @@ def introspect_callable(func: Callable[..., object], name: str) -> FunctionData } ) + _widen_mathutils_params(params) return { "name": name, "doc": clean_docstring(docstring), @@ -1099,6 +1124,7 @@ def _parse_class_constructor(class_doc: str, cls: type) -> FunctionData | None: if not params: return None + _widen_mathutils_params(params) return { "name": "__init__", "doc": "", @@ -1192,7 +1218,7 @@ def _fix_dunder_signatures( method["params"] = [ { "name": "key", - "type": "int", + "type": "int | slice", "default": None, "kind": "POSITIONAL_OR_KEYWORD", } @@ -1216,7 +1242,7 @@ def _fix_dunder_signatures( method["params"] = [ { "name": "key", - "type": "int", + "type": "int | slice", "default": None, "kind": "POSITIONAL_OR_KEYWORD", } @@ -1244,13 +1270,13 @@ def _fix_dunder_signatures( method["params"] = [ { "name": "key", - "type": "int", + "type": "int | slice", "default": None, "kind": "POSITIONAL_OR_KEYWORD", }, { "name": "value", - "type": vtype, + "type": f"{vtype} | Sequence[{vtype}]", "default": None, "kind": "POSITIONAL_OR_KEYWORD", }, @@ -1304,6 +1330,31 @@ def _fix_dunder_signatures( continue +def _is_getset_writable(cls: type[object], attr_name: str) -> bool: + """Test if a C getset_descriptor property is writable. + + C getset_descriptors that have a setter implement __set__. + Those without a setter raise AttributeError on __set__ calls. + We test this by checking if __set__ raises on a dummy call. + """ + descriptor = cls.__dict__.get(attr_name) + if descriptor is None: + return False + # If the descriptor doesn't implement __set__ at all, it's read-only + if not hasattr(descriptor, "__set__"): + return False + # Try calling __set__ with None — a writable descriptor will attempt + # the set (and fail on None), while a read-only one raises AttributeError + try: + descriptor.__set__(None, None) + except AttributeError: + return False + except Exception: + # Any other error means __set__ exists (it just didn't like our args) + return True + return True + + def introspect_class(cls: type[object], module_name: str) -> StructData: """Introspect a class (C extension or Python) and return StructData.""" # Determine base class (skip object and internal bases) @@ -1361,7 +1412,11 @@ def introspect_class(cls: type[object], module_name: str) -> StructData: elif isinstance(raw, property) or type(raw).__name__ == "getset_descriptor": doc = inspect.getdoc(raw) or "" _, rtype = parse_docstring_types(doc) - is_readonly = not hasattr(raw, "fset") or raw.fset is None + if isinstance(raw, property): + is_readonly = raw.fset is None + else: + # C getset_descriptors don't expose fset; probe at runtime + is_readonly = not _is_getset_writable(cls, name) properties.append( { "name": name, diff --git a/main.py b/main.py index 2190ade..ff686b1 100644 --- a/main.py +++ b/main.py @@ -441,6 +441,7 @@ def conformance_check(versions: list[str] | None = None) -> None: "extraPaths": [str(version_dir)], "typeCheckingMode": "strict", "pythonVersion": python_version, + "reportUnusedExpression": False, } ) ) diff --git a/overrides/5.0/mathutils.json b/overrides/5.0/mathutils.json new file mode 100644 index 0000000..a4eb474 --- /dev/null +++ b/overrides/5.0/mathutils.json @@ -0,0 +1,12 @@ +{ + "Matrix.Translation": { + "params": { + "vector": "Vector | Sequence[float]" + } + }, + "Matrix.Scale": { + "params": { + "axis": "Vector | Sequence[float]" + } + } +}