tests and agents.md

This commit is contained in:
2026-08-25 17:54:13 +02:00
parent c46b37f88e
commit f575a84c13
5 changed files with 288 additions and 63 deletions
+111 -4
View File
@@ -2320,14 +2320,22 @@ RNA_TYPE_MAP: dict[str, str] = {
}
def rna_property_to_type(prop: object) -> str:
"""Map an RNA property to a PEP 484 type annotation string."""
def rna_property_to_type(prop: object, *, nullable_pointer: bool = True) -> str:
"""Map an RNA property to a PEP 484 type annotation string.
``nullable_pointer`` controls whether pointer types are widened to ``T | None``
based on the RNA ``is_never_none`` flag. Set to False for function return
values: Blender's RNA marks most function returns ``is_never_none=False``
even though they raise on failure rather than returning None.
"""
prop_type: str = getattr(prop, "type", "")
fixed_type: object = getattr(prop, "fixed_type", None)
array_length: int = getattr(prop, "array_length", 0)
if prop_type == "pointer" and fixed_type is not None:
type_name: str = getattr(fixed_type, "identifier", "object")
if nullable_pointer and not getattr(prop, "is_never_none", False):
return f"{type_name} | None"
return type_name
if prop_type == "collection" and fixed_type is not None:
@@ -2342,6 +2350,18 @@ def rna_property_to_type(prop: object) -> str:
element_type: str = getattr(fixed_type, "identifier", "object")
return f"bpy_prop_collection[{element_type}]"
if prop_type == "enum":
if getattr(prop, "is_enum_flag", False):
return "set[str]"
# rna_info exposes enum_items as list[tuple[identifier, name, description]];
# an empty list means a dynamic enum (items computed at runtime).
raw_items = cast("list[tuple[str, str, str]]", getattr(prop, "enum_items", []))
identifiers = [item[0] for item in raw_items if item]
if identifiers:
quoted = ", ".join(f'"{v}"' for v in identifiers)
return f"Literal[{quoted}]"
return "str"
# Dynamic-length arrays have array_length=0 but is_array=True on the raw
# RNA property. rna_info wraps properties in InfoPropertyRNA which stores
# the raw prop as bl_prop; fall back to checking the prop itself.
@@ -2423,9 +2443,11 @@ def rna_function_to_data(func_info: object) -> FunctionData:
return_type: str | None = None
if return_values:
if len(return_values) == 1:
return_type = rna_property_to_type(return_values[0])
return_type = rna_property_to_type(return_values[0], nullable_pointer=False)
else:
types = [rna_property_to_type(rv) for rv in return_values]
types = [
rna_property_to_type(rv, nullable_pointer=False) for rv in return_values
]
return_type = f"tuple[{', '.join(types)}]"
return {
@@ -2694,12 +2716,97 @@ def _rna_struct_to_data(
}
_NULLABLE_DOC_HINTS = ("may be none", "can be none", "may be null", "can be null")
def description_hints_nullable(description: str) -> bool:
"""Return True if the RST description explicitly hints the value may be None."""
lowered = description.lower()
return any(hint in lowered for hint in _NULLABLE_DOC_HINTS)
def _find_view3d_override(ctx: object) -> dict[str, object]:
"""Find a (window, area, region) override that simulates a 3D viewport.
Returns kwargs for ``bpy.context.temp_override``. Empty dict if no 3D
viewport is reachable (degenerate startup state).
"""
wm = getattr(ctx, "window_manager", None)
windows = getattr(wm, "windows", None) if wm is not None else None
if not windows:
return {}
for window in windows:
screen = getattr(window, "screen", None)
areas = getattr(screen, "areas", None) if screen is not None else None
if not areas:
continue
for area in areas:
if getattr(area, "type", None) != "VIEW_3D":
continue
for region in getattr(area, "regions", []) or []:
if getattr(region, "type", None) == "WINDOW":
return {"window": window, "area": area, "region": region}
return {}
def _refine_context_nullability(struct: StructData) -> None:
"""Strip ``| None`` from Context pointer properties that are never None.
Blender's RNA reports ``is_never_none=False`` for most ``bpy.context.*``
pointers, but several (``scene``, ``view_layer``, ``window_manager`` …,
plus UI-structural ones like ``area``, ``region``, ``region_data``) are
populated whenever a script runs from a normal 3D viewport — which is
what Blender's documentation assumes. In ``--background`` mode the
viewport context is empty, so we probe inside a ``temp_override`` that
simulates one.
Members that stay None even inside the override (``gizmo_group``,
``asset``, ``region_popup``) are correctly UI-event-dependent and stay
nullable.
The probe is overridden by Blender's own RST documentation: if the
description explicitly says "may be None" (or similar), the property
stays nullable even when the probe found a value.
"""
bpy = importlib.import_module("bpy")
ctx = getattr(bpy, "context", None)
if ctx is None:
return
override = _find_view3d_override(ctx)
temp_override = getattr(ctx, "temp_override", None)
if not override or temp_override is None:
# Fall back to a plain probe — better than nothing on older Blenders
# or in headless setups without windows.
probe_ctx: object = ctx
cm = None
else:
cm = temp_override(**override)
cm.__enter__()
probe_ctx = bpy.context # ``bpy.context`` reflects the override
try:
for prop in struct["properties"]:
if not prop["type"].endswith(" | None"):
continue
if description_hints_nullable(prop["description"]):
continue
try:
value = getattr(probe_ctx, prop["name"])
except Exception:
continue
if value is not None:
prop["type"] = prop["type"].removesuffix(" | None")
finally:
if cm is not None:
cm.__exit__(None, None, None)
def _merge_screen_context_members(structs: list[StructData]) -> None:
"""Merge dynamic bpy.context members into the Context struct."""
known_types = {s["name"] for s in structs}
for struct in structs:
if struct["name"] != "Context":
continue
_refine_context_nullability(struct)
rna_names = {p["name"] for p in struct["properties"]}
rna_names |= {m["name"] for m in struct["methods"]}
screen_props = introspect_screen_context_members(rna_names)