From a30e772cf8dd7e0277330da1d4777bc217fa4ab0 Mon Sep 17 00:00:00 2001 From: Joseph HENRY Date: Tue, 31 Mar 2026 12:34:19 +0200 Subject: [PATCH] Discover Python API functions on RNA types (Context.copy, etc.) Pick up Python functions defined on RNA classes that have RST docstrings (:rtype: or :type:), indicating they are public API methods rather than operator/panel implementation details. Also add Iterable and mathutils imports to bpy.types stub generator for functions that reference these types in their annotations. Co-Authored-By: Claude Opus 4.6 (1M context) --- generate_stubs.py | 412 ++++++------ introspect.py | 1645 ++++++++++++++++++++++++--------------------- 2 files changed, 1107 insertions(+), 950 deletions(-) diff --git a/generate_stubs.py b/generate_stubs.py index cf8190c..ae8277f 100644 --- a/generate_stubs.py +++ b/generate_stubs.py @@ -98,73 +98,87 @@ def collect_all_type_strings(module_data: ModuleData) -> list[str]: return all_types -def collect_imports(module_data: ModuleData) -> set[str]: - """Collect all import statements needed for the stub file.""" +_TOKEN_IMPORTS: tuple[tuple[str, str], ...] = ( + ("Sequence", "from collections.abc import Sequence"), + ("Iterable", "from collections.abc import Iterable"), + ("Callable", "from collections.abc import Callable"), + ("Iterator", "from collections.abc import Iterator"), + ("Literal", "from typing import Literal"), + ("Any", "from typing import Any"), + ("Generator", "from collections.abc import Generator"), + ("Mapping", "from collections.abc import Mapping"), + ("Collection", "from collections.abc import Collection"), + ("TypeAlias", "from typing import TypeAlias"), + ("Generic", "from typing import Generic, TypeVar"), +) + +_KNOWN_TYPED_MODULES: tuple[str, ...] = ( + "bpy.types", + "bpy.props", + "bpy.app", + "mathutils", + "gpu.types", + "imbuf.types", + "idprop.types", + "freestyle.types", + "bmesh.types", + "bmesh", +) + +_SPECIAL_IMPORT_MARKERS: tuple[tuple[str, str], ...] = ( + ("datetime", "import datetime"), + ("types.ModuleType", "import types"), + ("collections.OrderedDict", "import collections"), + ("collections.abc.", "import collections.abc"), +) + + +def _has_token(joined_types: str, token: str) -> bool: + """Return whether a type token appears as a standalone identifier.""" + return bool(re.search(rf"\b{token}\b", joined_types)) + + +def _collect_token_imports(joined_types: str) -> set[str]: + """Collect import lines triggered by standalone type tokens.""" imports: set[str] = set() - all_types = collect_all_type_strings(module_data) - joined = " ".join(all_types) - - def _has_type(name: str) -> bool: - return bool(re.search(rf"\b{name}\b", joined)) - - if _has_type("Sequence"): - imports.add("from collections.abc import Sequence") - if _has_type("Iterable"): - imports.add("from collections.abc import Iterable") - if _has_type("Callable"): - imports.add("from collections.abc import Callable") - if _has_type("Iterator"): - imports.add("from collections.abc import Iterator") - if _has_type("Literal"): - imports.add("from typing import Literal") - if _has_type("Any"): - imports.add("from typing import Any") - if _has_type("Generator"): - imports.add("from collections.abc import Generator") - if _has_type("Mapping"): - imports.add("from collections.abc import Mapping") - if _has_type("Collection"): - imports.add("from collections.abc import Collection") - if "Self" in joined: + for token, import_line in _TOKEN_IMPORTS: + if _has_token(joined_types, token): + imports.add(import_line) + if "Self" in joined_types: imports.add("from typing import Self") - if _has_type("TypeAlias"): - imports.add("from typing import TypeAlias") - if _has_type("Generic"): - imports.add("from typing import Generic, TypeVar") + return imports - # Detect module-qualified references (e.g. bpy.types.Object, mathutils.Vector) - # Detect qualified module references - known_modules = [ - "bpy.types", - "bpy.props", - "bpy.app", - "mathutils", - "gpu.types", - "imbuf.types", - "idprop.types", - "freestyle.types", - "bmesh.types", - "bmesh", - ] - if "datetime" in joined: - imports.add("import datetime") - if "types.ModuleType" in joined: - imports.add("import types") - if "collections.OrderedDict" in joined: - imports.add("import collections") - if "collections.abc." in joined: - imports.add("import collections.abc") - for mod in known_modules: - if mod + "." in joined or re.search(rf"\b{re.escape(mod)}\b", joined): + +def _collect_module_reference_imports(joined_types: str) -> set[str]: + """Collect import lines required by module-qualified type references.""" + imports: set[str] = set() + for marker, import_line in _SPECIAL_IMPORT_MARKERS: + if marker in joined_types: + imports.add(import_line) + for mod in _KNOWN_TYPED_MODULES: + if mod + "." in joined_types or re.search( + rf"\b{re.escape(mod)}\b", joined_types + ): imports.add(f"import {mod}") + return imports - # fixup_shadowed_builtins will add builtins.X references for structs - # with property names that shadow builtins — need the import + +def _requires_builtins_import(module_data: ModuleData) -> bool: + """Return whether builtins import is needed for builtin-name shadowing.""" for struct in module_data.get("structs", []): prop_names = {p["name"] for p in struct["properties"]} if prop_names & BUILTIN_NAMES: - imports.add("import builtins") - break + 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) + joined = " ".join(all_types) + imports = _collect_token_imports(joined) | _collect_module_reference_imports(joined) + if _requires_builtins_import(module_data): + imports.add("import builtins") return imports @@ -216,9 +230,16 @@ def format_docstring(doc: str, indent: str = " ") -> str: return result -def generate_function_stub(func: FunctionData) -> str: - """Generate a stub for a single function.""" - # Build parameter list, separating by kind and default presence +def _build_signature_params( + params: list[ParamData], + leading_params: list[str] | None = None, + skip_names: set[str] | None = None, +) -> str: + """Build a function signature parameter list string from ParamData entries.""" + filtered_params = [ + param for param in params if param["name"] not in (skip_names or set()) + ] + positional_only_no_default: list[str] = [] positional_only_with_default: list[str] = [] positional_no_default: list[str] = [] @@ -226,7 +247,7 @@ def generate_function_stub(func: FunctionData) -> str: keyword_params: list[str] = [] has_positional_only = False - for param in func["params"]: + for param in filtered_params: formatted = format_param(param, force_type=True) kind = param.get("kind", "POSITIONAL_OR_KEYWORD") if kind == "POSITIONAL_ONLY": @@ -248,7 +269,7 @@ def generate_function_stub(func: FunctionData) -> str: else: positional_no_default.append(formatted) - all_params: list[str] = [] + all_params: list[str] = list(leading_params or []) if has_positional_only and not ( positional_only_with_default and positional_no_default ): @@ -260,21 +281,30 @@ def generate_function_stub(func: FunctionData) -> str: # Merge positional-only into regular params when / would be invalid positional_no_default = positional_only_no_default + positional_no_default positional_with_default = positional_only_with_default + positional_with_default + # Non-default positional params must come before default ones all_params.extend(positional_no_default) all_params.extend(positional_with_default) + if keyword_params: # Insert * separator if there are keyword-only args and no VAR_POSITIONAL. # Don't insert * if the only keyword params are **kwargs (already captures all). has_var_positional = any( - p.get("kind") == "VAR_POSITIONAL" for p in func["params"] + p.get("kind") == "VAR_POSITIONAL" for p in filtered_params + ) + has_named_keyword = any( + p.get("kind") == "KEYWORD_ONLY" for p in filtered_params ) - has_named_keyword = any(p.get("kind") == "KEYWORD_ONLY" for p in func["params"]) if not has_var_positional and has_named_keyword: all_params.append("*") all_params.extend(keyword_params) - params_str = ", ".join(all_params) + return ", ".join(all_params) + + +def generate_function_stub(func: FunctionData) -> str: + """Generate a stub for a single function.""" + params_str = _build_signature_params(func["params"]) # Return type ret = f" -> {func['return_type']}" if func["return_type"] else " -> None" @@ -303,63 +333,9 @@ def generate_method_stub( """Generate a stub for a method inside a class.""" is_cls = func.get("is_classmethod", False) first_param = "cls" if is_cls else "self" - - # Categorize params by kind, keeping positional ordering correct - positional_only_no_default: list[str] = [] - positional_only_with_default: list[str] = [] - positional_no_default: list[str] = [] - positional_with_default: list[str] = [] - keyword_params: list[str] = [] - has_positional_only = False - - for param in func["params"]: - if param["name"] in ("cls", "self"): - continue - formatted = format_param(param, force_type=True) - kind = param.get("kind", "POSITIONAL_OR_KEYWORD") - if kind == "POSITIONAL_ONLY": - has_positional_only = True - if param["default"] is not None: - positional_only_with_default.append(formatted) - else: - positional_only_no_default.append(formatted) - elif kind == "KEYWORD_ONLY": - keyword_params.append(formatted) - elif kind == "VAR_POSITIONAL": - type_ann = f": {param['type']}" if param["type"] else ": object" - positional_no_default.append(f"*{param['name']}{type_ann}") - elif kind == "VAR_KEYWORD": - type_ann = f": {param['type']}" if param["type"] else ": object" - keyword_params.append(f"**{param['name']}{type_ann}") - elif param["default"] is not None: - positional_with_default.append(formatted) - else: - positional_no_default.append(formatted) - - all_params: list[str] = [first_param] - if has_positional_only and not ( - positional_only_with_default and positional_no_default - ): - # Only emit / when it won't cause "non-default follows default" errors - all_params.extend(positional_only_no_default) - all_params.extend(positional_only_with_default) - all_params.append("/") - else: - # Merge positional-only into regular params when / would be invalid - positional_no_default = positional_only_no_default + positional_no_default - positional_with_default = positional_only_with_default + positional_with_default - all_params.extend(positional_no_default) - all_params.extend(positional_with_default) - if keyword_params: - has_var_positional = any( - p.get("kind") == "VAR_POSITIONAL" for p in func["params"] - ) - has_named_keyword = any(p.get("kind") == "KEYWORD_ONLY" for p in func["params"]) - if not has_var_positional and has_named_keyword: - all_params.append("*") - all_params.extend(keyword_params) - - params_str = ", ".join(all_params) + params_str = _build_signature_params( + func["params"], leading_params=[first_param], skip_names={"cls", "self"} + ) ret = f" -> {func['return_type']}" if func["return_type"] else " -> None" decorators = "" if is_override: @@ -583,6 +559,8 @@ def generate_types_stub( abc_imports.append("Callable") if "MutableSequence[" in all_type_strs: abc_imports.append("MutableSequence") + if re.search(r"(? None: + """Add Generic[_T] bases when classes are used as parameterized generics.""" + all_types = collect_all_type_strings(module_data) + joined_types = " ".join(all_types) + struct_names = {s["name"] for s in module_data.get("structs", [])} + for struct in module_data.get("structs", []): + name = struct["name"] + if name in struct_names and re.search(rf"\b{re.escape(name)}\[", joined_types): + base = struct.get("base") or "" + if "Generic[" not in base: + struct["base"] = f"{base}, Generic[_T]" if base else "Generic[_T]" + + +def _prepare_module_imports( + module_data: ModuleData, + module_name: str, + submodule_names: list[str], +) -> tuple[set[str], set[str], set[str]]: + """Collect, filter, and classify imports for a generated module stub.""" + imports = collect_imports(module_data) + for sub in submodule_names: + imports.add(f"from . import {sub} as {sub}") + imports = {i for i in imports if i != f"import {module_name}"} + + class_names = {s["name"] for s in module_data.get("structs", [])} + clashing_imports: set[str] = set() + for imp in imports: + for name in class_names: + if f"import {name}" in imp: + clashing_imports.add(imp) + + return imports - clashing_imports, clashing_imports, class_names + + +def _append_module_members( + parts: list[str], + module_data: ModuleData, + submodule_names: list[str], +) -> None: + """Append variables, functions, and classes to a module stub body.""" + sub_names = set(submodule_names) + + if module_data["variables"]: + parts.append("") + for var in module_data["variables"]: + if var["name"] not in sub_names: + parts.append(generate_variable_stub(var)) + + for func in module_data["functions"]: + parts.append("") + parts.append(generate_function_stub(func)) + + for struct in module_data.get("structs", []): + parts.append("") + parts.append(generate_struct_stub(struct)) + + +def _qualify_shadowed_builtin_annotations( + result: str, + module_data: ModuleData, +) -> str: + """Qualify builtin type names when module-level variables shadow them.""" + builtins_used_as_types = {"object", "type", "int", "str", "float", "bool", "set"} + var_names = {v["name"] for v in module_data.get("variables", [])} + shadowed = var_names & builtins_used_as_types + if shadowed: + for name in shadowed: + result = re.sub( + rf"(?<=: ){name}\b", + f"builtins.{name}", + result, + ) + if "import builtins" not in result: + result = "import builtins\n" + result + return result + + +def _qualify_clashing_type_names( + result: str, + clashing_imports: set[str], + class_names: set[str], +) -> str: + """Qualify type references when imported names clash with local class names.""" + for clash in clashing_imports: + match = re.search(r"from ([\w.]+) import (\w+)", clash) + if match: + full_module = match.group(1) + name = match.group(2) + if name in class_names: + result = re.sub( + rf"(? str: - """Clean up RST type annotations to plain Python type strings.""" - # Strip "(readonly)" / "(never None)" annotations from Blender docstrings - type_str = re.sub(r",?\s*\(readonly\)", "", type_str) - type_str = re.sub(r",?\s*\(never None\)", "", type_str) - type_str = re.sub(r":class:`([^`]+)`", r"\1", type_str) - # Strip RST escaped backslash + space before generic brackets (e.g. "Foo\ [Bar]" -> "Foo[Bar]") - type_str = re.sub("\\\\\\s*\\[", "[", type_str) - # Remove double backtick RST markup - type_str = re.sub(r"``([^`]+)``", r"\1", type_str) - # Remove single backtick markup (` int ` → int) - type_str = re.sub(r"`\s*([^`]+?)\s*`", r"\1", type_str) - # Strip leaked RST directives from type strings - type_str = re.sub(r"\.?\s*(?:r?type|returns?):.*", "", type_str) - # Strip stray RST role colons but not :param, :arg, :type directives - type_str = re.sub(r":(?!param|arg|type|return)(\w)", r"\1", type_str) - type_str = type_str.rstrip(":.,") +def _replace_commas_outside_brackets(s: str) -> str: + """Replace top-level commas with union separators while preserving generics.""" + result: list[str] = [] + depth = 0 + i = 0 + while i < len(s): + if s[i] in "([": + depth += 1 + result.append(s[i]) + elif s[i] in ")]": + depth -= 1 + result.append(s[i]) + elif s[i] == "," and depth == 0: + result.append(" |") + else: + result.append(s[i]) + i += 1 + return "".join(result) - # Convert "string in ['X', 'Y', ...]" to Literal["X", "Y", ...] - str_in_match = re.match(r"str(?:ing)?\s+in\s+\[([^\]]+)\]", type_str, re.IGNORECASE) - if str_in_match: - values = re.findall(r"'([^']+)'", str_in_match.group(1)) - if values: - quoted = ", ".join(f'"{v}"' for v in values) - return f"Literal[{quoted}]" - # Convert tuple(X, Y) to tuple[X, Y] (docstrings sometimes use parens) - type_str = re.sub(r"\btuple\(([^)]+)\)", r"tuple[\1]", type_str) +def _fix_multi_arg_list(match: re.Match[str]) -> str: + """Normalize malformed list[T, U] style annotations from docstrings.""" + inner = match.group(1) + parts: list[str] = [] + depth = 0 + current: list[str] = [] + for ch in inner: + if ch in "([": + depth += 1 + elif ch in ")]": + depth -= 1 + if ch == "," and depth == 0: + parts.append("".join(current).strip()) + current = [] + else: + current.append(ch) + parts.append("".join(current).strip()) + if len(parts) <= 1: + return match.group(0) + non_ellipsis = [p for p in parts if p != "..."] + if len(set(non_ellipsis)) == 1: + return f"list[{non_ellipsis[0]}]" + return f"tuple[{', '.join(non_ellipsis)}]" - # Truncate at parameter-like patterns that leaked from function signatures - # e.g. "Callable[[BMVert], bool] | None, reverse: bool" -> "Callable[[BMVert], bool] | None" - type_str = re.sub(r",\s+\w+\s*:", "", type_str) - # Normalize comma-separated types to unions (outside brackets only) - # "int, float" -> "int | float" but not "tuple[int, float]" - def replace_commas_outside_brackets(s: str) -> str: - result: list[str] = [] - depth = 0 - i = 0 - while i < len(s): - if s[i] in "([": - depth += 1 - result.append(s[i]) - elif s[i] in ")]": - depth -= 1 - result.append(s[i]) - elif s[i] == "," and depth == 0: - result.append(" |") - else: - result.append(s[i]) - i += 1 - return "".join(result) +def _split_union_outside_brackets(s: str) -> list[str]: + """Split a union string on top-level pipes, preserving nested generics.""" + parts: list[str] = [] + current: list[str] = [] + depth = 0 + for ch in s: + if ch in "([": + depth += 1 + current.append(ch) + elif ch in ")]": + depth -= 1 + current.append(ch) + elif ch == "|" and depth == 0: + parts.append("".join(current)) + current = [] + else: + current.append(ch) + parts.append("".join(current)) + return parts - if "Callable" not in type_str: - type_str = replace_commas_outside_brackets(type_str) - # Strip RST directives like ".. note::" and everything after - type_str = re.sub(r"\.\.\s+\w+::.*", "", type_str, flags=re.DOTALL) - # Strip trailing prose (sentences after a valid type), but not type keywords like None - type_str = re.sub( - r"\s+(?!None\b|True\b|False\b)[A-Z][a-z]+\s+[a-z].*$", "", type_str - ) - # Strip prose after "or None" / "| None" (e.g. "or None when there is no intersection") - type_str = re.sub(r"(\bNone)\s+\w.*$", r"\1", type_str) - # Strip "of size N" suffixes - type_str = re.sub(r"\s+of size \d+", "", type_str) - # Strip dimension prefixes like "2d ", "3D ", "4x4 ", "1D or 2D " before type names - # Must run before "Sequence of" regex to avoid capturing "3d" as a type - type_str = re.sub(r"\b\d+[dDxX]\d*(?:\s+or\s+\d+[dDxX]\d*)*\s+", "", type_str) - # "Sequence of Xs containing Ys" -> "Sequence[Sequence[Y]]" - type_str = re.sub( - r"\b[Ss]equence of \w+s\s+containing\s+(\w+)s?\b", + +def _is_valid_union_component(type_part: str) -> bool: + """Return True if a union component looks like a valid type expression.""" + if " " in type_part and "[" not in type_part: + return False + if re.match(r"^[a-z][a-z0-9_]+$", type_part) and "_" in type_part: + return False + if re.match(r"^[a-z]+$", type_part) and type_part not in ( + "bool", + "int", + "float", + "str", + "bytes", + "object", + "type", + "None", + ): + return False + return True + + +def _normalize_union_fallback(type_str: str) -> str: + """Apply final union cleanup and prose fallback handling.""" + if "|" in type_str: + parts = [ + p.strip().rstrip(".,;:") for p in _split_union_outside_brackets(type_str) + ] + cleaned = [p if _is_valid_union_component(p) else "object" for p in parts if p] + seen: set[str] = set() + unique: list[str] = [] + for part in cleaned: + if part not in seen: + seen.add(part) + unique.append(part) + return " | ".join(unique) + if " " in type_str and "[" not in type_str: + return "object" + return type_str + + +RegexReplacement = str | Callable[[re.Match[str]], str] +RegexRule = tuple[re.Pattern[str], RegexReplacement] + +_CLEAN_TYPE_RST_RULES: tuple[RegexRule, ...] = ( + (re.compile(r",?\s*\(readonly\)"), ""), + (re.compile(r",?\s*\(never None\)"), ""), + (re.compile(r":class:`([^`]+)`"), r"\1"), + (re.compile("\\\\\\s*\\["), "["), + (re.compile(r"``([^`]+)``"), r"\1"), + (re.compile(r"`\s*([^`]+?)\s*`"), r"\1"), + (re.compile(r"\.?\s*(?:r?type|returns?):.*"), ""), + (re.compile(r":(?!param|arg|type|return)(\w)"), r"\1"), +) + +_CLEAN_TYPE_PRE_PROSE_RULES: tuple[RegexRule, ...] = ( + (re.compile(r"\btuple\(([^)]+)\)"), r"tuple[\1]"), + (re.compile(r",\s+\w+\s*:"), ""), +) + +_CLEAN_TYPE_PROSE_RULES: tuple[RegexRule, ...] = ( + (re.compile(r"\.\.\s+\w+::.*", re.DOTALL), ""), + (re.compile(r"\s+(?!None\b|True\b|False\b)[A-Z][a-z]+\s+[a-z].*$"), ""), + (re.compile(r"(\bNone)\s+\w.*$"), r"\1"), + (re.compile(r"\s+of size \d+"), ""), + (re.compile(r"\b\d+[dDxX]\d*(?:\s+or\s+\d+[dDxX]\d*)*\s+"), ""), +) + +_CLEAN_TYPE_PROSE_POST_COLLECTION_RULES: tuple[RegexRule, ...] = ( + (re.compile(r"\b(float|int)\s+(triplet|pair|array)\b"), r"\1"), + ( + re.compile(r"\b(?:one|two|three|four|five|six|seven|eight|nine|ten)\s+"), + "", + ), + (re.compile(r"\btuple of (?:\d+ )?([\w.]+\w)\b"), r"tuple[\1, ...]"), + (re.compile(r"\blist of ([\w.]+\w)\b"), r"list[\1]"), +) + +_DIM_PREFIX = r"(?:\d+(?:\s+(?:or|and|to)\s+(?:\d+|more|fewer))*\s+)?" +_RE_SEQUENCE_CONTAINING = re.compile(r"\b[Ss]equence of \w+s\s+containing\s+(\w+)s?\b") +_RE_PLURAL_CONTAINING = re.compile(r"\b\w+s\s+containing\s+(\w+)s?\b") +_RE_SEQUENCE_OF = re.compile(rf"\b[Ss]equence of {_DIM_PREFIX}(\w[\w.]*)\b") +_RE_ITERABLE_OF = re.compile(rf"\b[Ii]terable of {_DIM_PREFIX}(\w[\w.]*)\b") +_RE_COLLECTION_OF = re.compile(rf"\b[Cc]ollection of {_DIM_PREFIX}(\w[\w.]*)\b") +_RE_SEQUENCE_OF_TUPLE = re.compile(r"\b[Ss]equence of \(([^)]+)\)") +_RE_ITERABLE_OF_TUPLE = re.compile(r"\b[Ii]terable of \(([^)]+)\)") + +_CLEAN_TYPE_ALIAS_RULES: tuple[RegexRule, ...] = ( + (re.compile(r"\bclass\b"), "type"), + (re.compile(r"\bstrings\b"), "str"), + (re.compile(r"\bfloats\b"), "float"), + (re.compile(r"\bints\b"), "int"), + (re.compile(r"\bbools\b"), "bool"), + (re.compile(r"\bnumbers\b"), "float"), + (re.compile(r"\bvectors\b"), "mathutils.Vector"), + (re.compile(r"\bmatrices\b"), "mathutils.Matrix"), + (re.compile(r"\btuples\b"), "tuple[object, ...]"), + (re.compile(r"\bstring\b"), "str"), + (re.compile(r"\bdouble\b"), "float"), + (re.compile(r"\binteger\b"), "int"), + (re.compile(r"\bboolean\b"), "bool"), + (re.compile(r"\bnumber\b"), "float"), + (re.compile(r"\buint\b"), "int"), + (re.compile(r"\bNone[Tt]ype\b"), "None"), + (re.compile(r"\bbuffer\b"), "object"), + (re.compile(r"\b[Aa]ny\b"), "object"), + (re.compile(r"\bidprop\.types?\.\w+\b"), "object"), + (re.compile(r"\b(?:bpy\.types\.)?IDProperty\w*\b"), "object"), + (re.compile(r"\b(?:bpy\.types\.)?bpy_prop\b(?!_)"), "object"), + (re.compile(r"\b(int|float|bool|str)\s+sequence\b"), r"Sequence[\1]"), + (re.compile(r"\s+or\s+"), " | "), + (re.compile(r"\|[A-Z_]+\|"), "str"), + (re.compile(r"\bcallable\b"), "Callable[..., object]"), + (re.compile(r"\bfunction\b"), "Callable[..., object]"), + (re.compile(r"\bgenerator\b"), "Generator"), + (re.compile(r"\bsequence\b"), "Sequence"), +) + +_CLEAN_TYPE_GENERIC_RULES: tuple[RegexRule, ...] = ( + (re.compile(r"\[\d+\]"), ""), + (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[...,"), + (re.compile(r"\bdict\b(?!\[)"), "dict[str, object]"), + (re.compile(r"\blist\b(?!\[)"), "list[object]"), + (re.compile(r"\btuple\b(?!\[)"), "tuple[object, ...]"), + (re.compile(r"\bset\b(?!\[)"), "set[object]"), + (re.compile(r"\bfrozenset\b(?!\[)"), "frozenset[object]"), + (re.compile(r"\bGenerator\b(?!\[)"), "Generator[object, None, None]"), + (re.compile(r"\bSequence\b(?!\[)"), "Sequence[object]"), + (re.compile(r"\bIterator\b(?!\[)"), "Iterator[object]"), + (re.compile(r"\bIterable\b(?!\[)"), "Iterable[object]"), + (re.compile(r"\bSequence\[(\w+),\s*(\w+)\]"), r"Sequence[tuple[\1, \2]]"), +) + +_CLEAN_TYPE_POST_PRECHECK_RULES: tuple[RegexRule, ...] = ( + (re.compile(r"\s+"), " "), + (re.compile(r"'s\b"), ""), +) + +_CLEAN_TYPE_POST_FINAL_RULES: tuple[RegexRule, ...] = ( + (re.compile(r"\b(a|an|the)\s+", re.IGNORECASE), ""), + (re.compile(r"\|\s*\|"), "|"), + (re.compile(r"\|\s*$"), ""), + (re.compile(r"^\s*\|"), ""), + (re.compile(r"(\])\s*:.*"), r"\1"), + (re.compile(r"(\])\s+\w.*"), r"\1"), + (re.compile(r"(\w)\s*:\s+\w.*"), r"\1"), + (re.compile(r"\breal\b"), "float"), +) + +_UNDEFINED_TYPE_NAMES = { + "numpy", + "bpy_app_translations", + "BLFImBufContext", + "AnimateablePropertyP", + "ModuleType", + "Undefined", + "capsule", + "_translations_type", + "_PropertyDeferred", +} + +_UNDEFINED_TYPE_MAP = { + "ContextTempOverride": "object", +} + +_UNDEFINED_GENERIC_TYPES = {"BMVertSkin"} + +_RE_LITERAL_VALUES = re.compile(r"\bLiteral\[([^\]]+)\]") +_RE_STR_IN_VALUES = re.compile(r"str(?:ing)?\s+in\s+\[([^\]]+)\]", re.IGNORECASE) +_RE_QUOTED_LITERAL_VALUE = re.compile(r"""['"]([^'"]+)['"]""") + +_INFORMAL_TYPE_RULES: tuple[RegexRule, ...] = ( + (re.compile(r"(? str: + """Apply an ordered sequence of regex substitutions.""" + for pattern, replacement in rules: + text = pattern.sub(replacement, text) + return text + + +def _normalize_collection_prose(type_str: str) -> str: + """Normalize prose-style collection descriptions to generic type syntax.""" + type_str = _RE_SEQUENCE_CONTAINING.sub( lambda m: f"Sequence[Sequence[{m.group(1)}]]", type_str, ) - # "Xs containing Y" -> "Sequence[Y]" (standalone, not after "of") - type_str = re.sub( - r"\b\w+s\s+containing\s+(\w+)s?\b", + type_str = _RE_PLURAL_CONTAINING.sub( lambda m: f"Sequence[{m.group(1)}]", type_str, ) - # Normalize prose-style generic types like "sequence of X", "iterable of X", "collection of X" - # Optionally skip leading dimension descriptions: "sequence of 3 or 4 floats" -> "Sequence[float]" - _dim_prefix = r"(?:\d+(?:\s+(?:or|and|to)\s+(?:\d+|more|fewer))*\s+)?" - type_str = re.sub( - rf"\b[Ss]equence of {_dim_prefix}(\w[\w.]*)\b", - lambda m: f"Sequence[{m.group(1)}]", - type_str, - ) - type_str = re.sub( - rf"\b[Ii]terable of {_dim_prefix}(\w[\w.]*)\b", - lambda m: f"Iterable[{m.group(1)}]", - type_str, - ) - type_str = re.sub( - rf"\b[Cc]ollection of {_dim_prefix}(\w[\w.]*)\b", - lambda m: f"Collection[{m.group(1)}]", - type_str, - ) - # Handle "Sequence of (A, B)" -> "Sequence[tuple[A, B]]" - type_str = re.sub( - r"\b[Ss]equence of \(([^)]+)\)", + type_str = _RE_SEQUENCE_OF.sub(lambda m: f"Sequence[{m.group(1)}]", type_str) + type_str = _RE_ITERABLE_OF.sub(lambda m: f"Iterable[{m.group(1)}]", type_str) + type_str = _RE_COLLECTION_OF.sub(lambda m: f"Collection[{m.group(1)}]", type_str) + type_str = _RE_SEQUENCE_OF_TUPLE.sub( lambda m: f"Sequence[tuple[{m.group(1)}]]", type_str, ) - type_str = re.sub( - r"\b[Ii]terable of \(([^)]+)\)", + type_str = _RE_ITERABLE_OF_TUPLE.sub( lambda m: f"Iterable[tuple[{m.group(1)}]]", type_str, ) - # Strip prose qualifiers like "float triplet" -> "float" - type_str = re.sub(r"\b(float|int)\s+(triplet|pair|array)\b", r"\1", type_str) - # Strip number words used as counts (e.g. "four floats" -> "floats") - type_str = re.sub( - r"\b(?:one|two|three|four|five|six|seven|eight|nine|ten)\s+", - "", - type_str, - ) - # Handle "tuple of [N] type" -> "tuple[type, ...]" - type_str = re.sub( - r"\btuple of (?:\d+ )?([\w.]+\w)\b", - lambda m: f"tuple[{m.group(1)}, ...]", - type_str, - ) - # Handle "list of type" -> "list[type]" (type can be dotted like mathutils.Vector) - type_str = re.sub( - r"\blist of ([\w.]+\w)\b", - lambda m: f"list[{m.group(1)}]", - type_str, - ) - # Map "class" -> "type" (used as param type in some docstrings) - type_str = re.sub(r"\bclass\b", "type", type_str) - # Map plural/informal type names to proper Python types - type_str = re.sub(r"\bstrings\b", "str", type_str) - type_str = re.sub(r"\bfloats\b", "float", type_str) - type_str = re.sub(r"\bints\b", "int", type_str) - type_str = re.sub(r"\bbools\b", "bool", type_str) - type_str = re.sub(r"\bnumbers\b", "float", type_str) - type_str = re.sub(r"\bvectors\b", "mathutils.Vector", type_str) - type_str = re.sub(r"\bmatrices\b", "mathutils.Matrix", type_str) - type_str = re.sub(r"\btuples\b", "tuple[object, ...]", type_str) - type_str = re.sub(r"\bstring\b", "str", type_str) - type_str = re.sub(r"\bdouble\b", "float", type_str) - type_str = re.sub(r"\binteger\b", "int", type_str) - type_str = re.sub(r"\bboolean\b", "bool", type_str) - type_str = re.sub(r"\bnumber\b", "float", type_str) - type_str = re.sub(r"\buint\b", "int", type_str) - # Map NoneType -> None (valid in type annotations) - type_str = re.sub(r"\bNone[Tt]ype\b", "None", type_str) - # Map types that don't exist in Python stubs - type_str = re.sub(r"\bbuffer\b", "object", type_str) - type_str = re.sub(r"\b[Aa]ny\b", "object", type_str) - # Map idprop internal types to object (not available in stubs) - type_str = re.sub(r"\bidprop\.types?\.\w+\b", "object", type_str) - type_str = re.sub(r"\b(?:bpy\.types\.)?IDProperty\w*\b", "object", type_str) - # Map bpy_prop and bpy.types.bpy_prop (internal base, not in stubs) to object - type_str = re.sub(r"\b(?:bpy\.types\.)?bpy_prop\b(?!_)", "object", type_str) - # Handle "TYPE sequence" -> "Sequence[TYPE]" (e.g. "int sequence" -> "Sequence[int]") - type_str = re.sub(r"\b(int|float|bool|str)\s+sequence\b", r"Sequence[\1]", type_str) - # Normalize "X or Y" -> "X | Y" - type_str = re.sub(r"\s+or\s+", " | ", type_str) - # Remove pipe-wrapped type macros like |UV_STICKY_SELECT_MODE_TYPE| - type_str = re.sub(r"\|[A-Z_]+\|", "str", type_str) - # Map "callable" / "function" -> "Callable[..., object]" - type_str = re.sub(r"\bcallable\b", "Callable[..., object]", type_str) - type_str = re.sub(r"\bfunction\b", "Callable[..., object]", type_str) - # Map lowercase generic names to proper capitalized forms - type_str = re.sub(r"\bgenerator\b", "Generator", type_str) - type_str = re.sub(r"\bsequence\b", "Sequence", type_str) - # Strip numeric type args: "Sequence[3]" -> "Sequence" (becomes bare, parameterized below) - type_str = re.sub(r"\[\d+\]", "", type_str) - # Strip numeric-only union parts: "| 2 | 3" from dimension descriptions - type_str = re.sub(r"\s*\|\s*\d+\b", "", type_str) - # Empty brackets after a type name (e.g. dict[] from malformed Blender docstrings) - # -> strip them so bare generic handling kicks in. Don't strip [] inside Callable[[], ...] - type_str = re.sub(r"(\w)\[\]", r"\1", type_str) - # Bare generics without params -> add default params. - # Use \b on both sides to avoid matching inside longer names (e.g. SequenceEntry). - type_str = re.sub(r"\bCallable\b(?!\[)", "Callable[..., object]", type_str) - # Fix Callable[[..., ...], X] or Callable[[object, ...], X] -> Callable[..., X] - type_str = re.sub(r"Callable\[\[[^\]]*\.\.\.[^\]]*\]", "Callable[...", type_str) - type_str = re.sub(r"\bdict\b(?!\[)", "dict[str, object]", type_str) - type_str = re.sub(r"\blist\b(?!\[)", "list[object]", type_str) - type_str = re.sub(r"\btuple\b(?!\[)", "tuple[object, ...]", type_str) - type_str = re.sub(r"\bset\b(?!\[)", "set[object]", type_str) - type_str = re.sub(r"\bfrozenset\b(?!\[)", "frozenset[object]", type_str) - type_str = re.sub(r"\bGenerator\b(?!\[)", "Generator[object, None, None]", type_str) - type_str = re.sub(r"\bSequence\b(?!\[)", "Sequence[object]", type_str) - type_str = re.sub(r"\bIterator\b(?!\[)", "Iterator[object]", type_str) - type_str = re.sub(r"\bIterable\b(?!\[)", "Iterable[object]", type_str) + return type_str - # Fix Sequence/list with multiple type args (docstring bug): - # Sequence[int, int] -> Sequence[tuple[int, int]] - type_str = re.sub( - r"\bSequence\[(\w+),\s*(\w+)\]", - r"Sequence[tuple[\1, \2]]", - type_str, - ) - # list[X, Y, ...] with >1 type args -> treat as list[X] (drop extras) - def _fix_multi_arg_list(m: re.Match[str]) -> str: - inner = m.group(1) - # If it contains nested generics like list[float], keep first one - parts: list[str] = [] - depth = 0 - current: list[str] = [] - for ch in inner: - if ch in "([": - depth += 1 - elif ch in ")]": - depth -= 1 - if ch == "," and depth == 0: - parts.append("".join(current).strip()) - current = [] - else: - current.append(ch) - parts.append("".join(current).strip()) - if len(parts) <= 1: - return m.group(0) - # If all parts are identical, use list[that_type] - non_ellipsis = [p for p in parts if p != "..."] - if len(set(non_ellipsis)) == 1: - return f"list[{non_ellipsis[0]}]" - # Mixed types -> treat as tuple - return f"tuple[{', '.join(non_ellipsis)}]" +def _normalize_literal_values(type_str: str) -> str: + """Quote bare Literal values to valid Python string literals.""" + + def _quote_items(match: re.Match[str]) -> str: + items = match.group(1) + quoted = ", ".join( + f"'{item.strip()}'" if not item.strip().startswith("'") else item.strip() + for item in items.split(",") + ) + return f"Literal[{quoted}]" + + return _RE_LITERAL_VALUES.sub(_quote_items, type_str) + + +def _extract_string_enum_literal(type_str: str) -> str | None: + """Extract `str in [...]` docstring enums into a Literal[...] annotation.""" + match = _RE_STR_IN_VALUES.match(type_str) + if not match: + return None + values = _RE_QUOTED_LITERAL_VALUE.findall(match.group(1)) + if not values: + return None + quoted = ", ".join(f"'{value}'" for value in values) + return f"Literal[{quoted}]" + + +def _qualify_informal_types(type_str: str) -> str: + """Qualify informal mathutils type names to fully-qualified types.""" + return _apply_regex_rules(type_str, _INFORMAL_TYPE_RULES) + + +def clean_type_str(type_str: str) -> str: + """Clean up RST type annotations to plain Python type strings.""" + type_str = _apply_regex_rules(type_str, _CLEAN_TYPE_RST_RULES) + type_str = type_str.rstrip(":.,") + + literal_from_str_in = _extract_string_enum_literal(type_str) + if literal_from_str_in is not None: + return _normalize_literal_values(literal_from_str_in) + + type_str = _apply_regex_rules(type_str, _CLEAN_TYPE_PRE_PROSE_RULES) + + # Normalize comma-separated types to unions (outside brackets only) + # "int, float" -> "int | float" but not "tuple[int, float]" + if "Callable" not in type_str: + type_str = _replace_commas_outside_brackets(type_str) + + type_str = _apply_regex_rules(type_str, _CLEAN_TYPE_PROSE_RULES) + type_str = _normalize_collection_prose(type_str) + type_str = _apply_regex_rules(type_str, _CLEAN_TYPE_PROSE_POST_COLLECTION_RULES) + type_str = _apply_regex_rules(type_str, _CLEAN_TYPE_ALIAS_RULES) + type_str = _apply_regex_rules(type_str, _CLEAN_TYPE_GENERIC_RULES) # Apply from innermost out, then handle nested brackets prev = "" @@ -546,46 +655,12 @@ def clean_type_str(type_str: str) -> str: type_str = re.sub(r"\blist\[([^\[\]]+)\]", _fix_multi_arg_list, type_str) type_str = re.sub(r"\blist\[(.+)\]", _fix_multi_arg_list, type_str) - # Fix Literal[X, Y, Z] -> Literal['X', 'Y', 'Z'] (quote bare identifiers) - def fix_literal(m: re.Match[str]) -> str: - items = m.group(1) - quoted = ", ".join( - f"'{item.strip()}'" if not item.strip().startswith("'") else item.strip() - for item in items.split(",") - ) - return f"Literal[{quoted}]" + type_str = _normalize_literal_values(type_str) + type_str = _qualify_informal_types(type_str) - type_str = re.sub(r"\bLiteral\[([^\]]+)\]", fix_literal, type_str) - informal_types: dict[str, str] = { - "vector": "mathutils.Vector", - "matrix": "mathutils.Matrix", - "quaternion": "mathutils.Quaternion", - "euler": "mathutils.Euler", - "color": "mathutils.Color", - } - for informal, formal in informal_types.items(): - type_str = re.sub( - rf"(? str: for bare, qualified in UNQUALIFIED_TYPES.items(): type_str = re.sub(rf"(?()\"']+$", type_str): return "object" - # If brackets are unbalanced, the type is malformed — fall back to object + # If brackets are unbalanced, the type is malformed - fall back to object if type_str.count("[") != type_str.count("]") or type_str.count( "(" ) != type_str.count(")"): return "object" - # Strip English articles before type names - type_str = re.sub(r"\b(a|an|the)\s+", "", type_str, flags=re.IGNORECASE) - # Clean up empty union parts and trailing pipes - type_str = re.sub(r"\|\s*\|", "|", type_str) - type_str = re.sub(r"\|\s*$", "", type_str) - type_str = re.sub(r"^\s*\|", "", type_str) - # Strip trailing text/prose after types (colon-separated or space-separated) - type_str = re.sub(r"(\])\s*:.*", r"\1", type_str) - type_str = re.sub(r"(\])\s+\w.*", r"\1", type_str) - type_str = re.sub(r"(\w)\s*:\s+\w.*", r"\1", type_str) - # Map informal numeric types - type_str = re.sub(r"\breal\b", "float", type_str) - # Split union on | only outside brackets - def split_union(s: str) -> list[str]: - parts: list[str] = [] - current: list[str] = [] - depth = 0 - for ch in s: - if ch in "([": - depth += 1 - current.append(ch) - elif ch in ")]": - depth -= 1 - current.append(ch) - elif ch == "|" and depth == 0: - parts.append("".join(current)) - current = [] - else: - current.append(ch) - parts.append("".join(current)) - return parts + type_str = _apply_regex_rules(type_str, _CLEAN_TYPE_POST_FINAL_RULES) # Final fallback: check each union component for prose (spaces without brackets) # Also strip trailing punctuation from each component - if "|" in type_str: - parts = [p.strip().rstrip(".,;:") for p in split_union(type_str)] - - def is_valid_type(t: str) -> bool: - if " " in t and "[" not in t: - return False - # snake_case identifiers are variable names, not types - # (e.g. "sphere_radius" from a buggy docstring) - if re.match(r"^[a-z][a-z0-9_]+$", t) and "_" in t: - return False - # Bare lowercase words that aren't known types (e.g. "four", "sequences") - if re.match(r"^[a-z]+$", t) and t not in ( - "bool", - "int", - "float", - "str", - "bytes", - "object", - "type", - "None", - ): - return False - return True - - cleaned = [p if is_valid_type(p) else "object" for p in parts if p] - # Deduplicate while preserving order - seen: set[str] = set() - unique: list[str] = [] - for p in cleaned: - if p not in seen: - seen.add(p) - unique.append(p) - type_str = " | ".join(unique) - elif " " in type_str and "[" not in type_str: - type_str = "object" + type_str = _normalize_union_fallback(type_str) # Standalone invalid types (snake_case variable names, bare lowercase prose words) if re.match(r"^[a-z][a-z0-9_]+$", type_str) and "_" in type_str: @@ -697,17 +706,14 @@ def clean_type_str(type_str: str) -> str: type_str = "object" # Map types that exist in docstrings but not in the Blender Python API - _UNDEFINED_TYPE_MAP = { - "ContextTempOverride": "object", - } for undef_name, replacement in _UNDEFINED_TYPE_MAP.items(): - type_str = re.sub(rf"\b{undef_name}\b", replacement, type_str) - # Replace undefined types in generic arguments with object - _UNDEFINED_TYPES = {"BMVertSkin"} - for undef in _UNDEFINED_TYPES: - type_str = re.sub(rf"(?:\w+\.)*{undef}", "object", type_str) + type_str = re.sub(rf"\b{re.escape(undef_name)}\b", replacement, type_str) - # Final balance check — catch any remaining malformed types + # Replace undefined types in generic arguments with object + for undef in _UNDEFINED_GENERIC_TYPES: + type_str = re.sub(rf"(?:\w+\.)*{re.escape(undef)}", "object", type_str) + + # Final balance check - catch any remaining malformed types if type_str.count("[") != type_str.count("]") or type_str.count( "(" ) != type_str.count(")"): @@ -1223,6 +1229,33 @@ _USEFUL_DUNDERS = { } +_TYPING_REEXPORT_NAMES = { + "Callable", + "Collection", + "Generator", + "Iterable", + "Iterator", + "Mapping", + "MutableMapping", + "MutableSequence", + "MutableSet", + "Sequence", + "Set", + "FrozenSet", + "Dict", + "List", + "Tuple", + "Type", + "Optional", + "Union", +} + +_SAFE_PROBE_MODULES = {"mathutils"} +_SKIP_HIDDEN_PROP_TYPES = frozenset( + ("int", "float", "str", "bool", "NoneType", "list", "tuple", "dict", "set") +) + + def _type_name(obj: object) -> str: """Return the type name of an object, normalizing NoneType to None.""" name = type(obj).__name__ @@ -1411,106 +1444,138 @@ def _is_getset_writable(cls: type[object], attr_name: str) -> bool: 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) +def _resolve_base_name(cls: type[object], module_name: str) -> str | None: + """Resolve a class base name only if it is part of the public API.""" bases = [ b for b in cls.__mro__[1:] if b is not object and b.__module__ != "builtins" ] - base_name: str | None = None - if bases: - base_cls = bases[0] - # Only use the base if it's accessible (in the same module's public API - # or fully qualified from another module) - parent_mod = importlib.import_module(base_cls.__module__) - public = getattr(parent_mod, "__all__", None) - is_public = public is None or base_cls.__name__ in public - if is_public: - if base_cls.__module__ == module_name: - base_name = base_cls.__name__ - else: - base_name = f"{base_cls.__module__}.{base_cls.__name__}" + if not bases: + return None + base_cls = bases[0] + parent_mod = importlib.import_module(base_cls.__module__) + public = getattr(parent_mod, "__all__", None) + is_public = public is None or base_cls.__name__ in public + if not is_public: + return None + if base_cls.__module__ == module_name: + return base_cls.__name__ + return f"{base_cls.__module__}.{base_cls.__name__}" - properties: list[PropertyData] = [] - methods: list[FunctionData] = [] +def _iter_declared_class_members( + cls: type[object], +) -> list[tuple[str, object, object]]: + """List public members declared directly on the class (not inherited).""" + members: list[tuple[str, object, object]] = [] for name in sorted(dir(cls)): if name.startswith("_") and name not in _USEFUL_DUNDERS: continue - try: obj = getattr(cls, name) except AttributeError: continue - - # Check if this member is defined on this class, not inherited if name not in cls.__dict__: continue - raw = cls.__dict__[name] + members.append((name, obj, raw)) + return members + +def _append_callable_method( + methods: list[FunctionData], + obj: object, + name: str, + is_classmethod: bool = False, +) -> None: + """Introspect and append a callable method when possible.""" + if not callable(obj): + return + func_data = introspect_callable(obj, name) + if not func_data: + return + if is_classmethod: + func_data["is_classmethod"] = True + methods.append(func_data) + + +def _property_data_from_member( + cls: type[object], + name: str, + obj: object, + raw: object, +) -> PropertyData: + """Build PropertyData from a property/getset descriptor class member.""" + doc = inspect.getdoc(raw) or "" + _, rtype = parse_docstring_types(doc) + if isinstance(raw, property): + is_readonly = raw.fset is None + else: + is_readonly = not _is_getset_writable(cls, name) + prop_type = rtype or "object" + if not is_readonly and prop_type in _MATHUTILS_ARRAY_TYPES: + prop_type = f"{prop_type} | Sequence[float]" + return { + "name": name, + "type": prop_type, + "is_readonly": is_readonly, + "description": doc, + } + + +def _introspect_declared_class_members( + cls: type[object], +) -> tuple[list[PropertyData], list[FunctionData]]: + """Introspect properties and methods declared directly on the class.""" + properties: list[PropertyData] = [] + methods: list[FunctionData] = [] + + for name, obj, raw in _iter_declared_class_members(cls): if ( isinstance(raw, classmethod) or type(raw).__name__ == "classmethod_descriptor" ): - func_data = introspect_callable(obj, name) - if func_data: - func_data["is_classmethod"] = True - methods.append(func_data) - elif isinstance(raw, staticmethod): - func_data = introspect_callable(obj, name) - if func_data: - methods.append(func_data) - elif callable(obj): - func_data = introspect_callable(obj, name) - if func_data: - methods.append(func_data) - elif isinstance(raw, property) or type(raw).__name__ == "getset_descriptor": - doc = inspect.getdoc(raw) or "" - _, rtype = parse_docstring_types(doc) - 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) - prop_type = rtype or "object" - # Writable properties with mathutils types also accept Sequence[float] - if not is_readonly and prop_type in _MATHUTILS_ARRAY_TYPES: - prop_type = f"{prop_type} | Sequence[float]" - properties.append( - { - "name": name, - "type": prop_type, - "is_readonly": is_readonly, - "description": doc, - } - ) - else: - properties.append( - { - "name": name, - "type": python_type_name(obj, name), - "is_readonly": True, - "description": "", - } - ) + _append_callable_method(methods, obj, name, is_classmethod=True) + continue + if isinstance(raw, staticmethod): + _append_callable_method(methods, obj, name) + continue + if callable(obj): + _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)) + continue + properties.append( + { + "name": name, + "type": python_type_name(obj, name), + "is_readonly": True, + "description": "", + } + ) - # Generate __init__ from the class docstring's ".. class::" RST directive - # (C extension types expose constructor info this way, not via __init__) - class_doc = inspect.getdoc(cls) or "" + return properties, methods + + +def _insert_constructor_from_doc( + cls: type[object], + class_doc: str, + methods: list[FunctionData], +) -> None: + """Insert a constructor parsed from class RST doc when available.""" init_method = _parse_class_constructor(class_doc, cls) if init_method: methods.insert(0, init_method) - # Fix dunder signatures by runtime probing. C wrapper descriptors have no - # type info in docstrings, but we can create a default instance and call - # the dunders to discover actual return types and parameter types. + +def _refine_and_synthesize_dunders( + cls: type[object], methods: list[FunctionData] +) -> None: + """Refine dunder signatures and synthesize missing protocol methods.""" dunder_methods = [m for m in methods if m["name"] in _USEFUL_DUNDERS] if dunder_methods: _fix_dunder_signatures(cls, dunder_methods) - # Synthesize __iter__ if the class supports iteration via __getitem__ - # but doesn't define __iter__ explicitly (old-style C iteration protocol). method_names = {m["name"] for m in methods} if "__getitem__" in method_names and "__iter__" not in method_names: getitem = next(m for m in methods if m["name"] == "__getitem__") @@ -1525,8 +1590,6 @@ def introspect_class(cls: type[object], module_name: str) -> StructData: } ) - # Synthesize __buffer__ for classes supporting the C buffer protocol. - # Only available in Python 3.12+ (PEP 688). if sys.version_info >= (3, 12) and "__buffer__" not in method_names: try: instance = cls() @@ -1550,6 +1613,134 @@ def introspect_class(cls: type[object], module_name: str) -> StructData: except Exception: pass + +def _collect_public_module_names(module: ModuleType, module_name: str) -> list[str]: + """Collect public module members, extending __all__ with local callables/types.""" + all_attr_obj = getattr(module, "__all__", None) + if all_attr_obj is None: + return [n for n in dir(module) if not n.startswith("_")] + + names_set = set(cast(list[str] | tuple[str, ...], all_attr_obj)) + for name in dir(module): + if name.startswith("_") or name in names_set: + continue + obj = getattr(module, name, None) + if obj is None: + continue + obj_module = getattr(obj, "__module__", None) + if obj_module == module_name and (callable(obj) or isinstance(obj, type)): + names_set.add(name) + elif hasattr(obj, "__origin__") and name not in _TYPING_REEXPORT_NAMES: + names_set.add(name) + return sorted(names_set) + + +def _is_type_alias_object(obj: object) -> bool: + """Return whether an object should be emitted as a type alias variable.""" + return hasattr(obj, "__origin__") or ( + hasattr(obj, "__module__") and getattr(obj, "__module__", "") == "typing" + ) + + +def _normalize_type_alias_repr(obj: object) -> str: + """Normalize typing-based alias repr to modern built-in generic forms.""" + type_repr = str(obj).replace("typing.", "") + type_repr = re.sub(r"\bTuple\b", "tuple", type_repr) + type_repr = re.sub(r"\bList\b", "list", type_repr) + type_repr = re.sub(r"\bDict\b", "dict", type_repr) + type_repr = re.sub(r"\bSet\b", "set", type_repr) + type_repr = re.sub(r"\bFrozenSet\b", "frozenset", type_repr) + return type_repr + + +def _classify_module_member( + module_name: str, + name: str, + obj: object, + functions: list[FunctionData], + variables: list[VariableData], + structs: list[StructData], +) -> None: + """Classify and append a module member into functions/variables/structs.""" + if isinstance(obj, ModuleType): + return + if isinstance(obj, type): + structs.append(introspect_class(obj, module_name)) + return + if _is_type_alias_object(obj): + variables.append( + { + "name": name, + "type": "TypeAlias", + "value": _normalize_type_alias_repr(obj), + } + ) + return + if callable(obj): + func_data = introspect_callable(obj, name) + if func_data: + functions.append(func_data) + return + variables.append( + { + "name": name, + "type": python_type_name(obj, name), + "value": repr(obj), + } + ) + + +def _probe_hidden_property_structs( + module: ModuleType, + module_name: str, + structs: list[StructData], +) -> None: + """Discover hidden C types reachable through runtime property values.""" + if module_name not in _SAFE_PROBE_MODULES: + return + + import builtins as _builtins_mod + + known_struct_names = {s["name"] for s in structs} + for struct in list(structs): + cls = getattr(module, struct["name"], None) + if cls is None or not isinstance(cls, type): + continue + try: + instance = cls() + except Exception: + continue + for prop in struct["properties"]: + if prop["type"] != "object": + continue + try: + val = getattr(instance, prop["name"]) + except Exception: + continue + val_cls = val.__class__ + val_name = val_cls.__name__ + if hasattr(_builtins_mod, val_name) or val_name in _SKIP_HIDDEN_PROP_TYPES: + continue + if val_name not in known_struct_names: + hidden_struct = introspect_class(val_cls, module_name) + dunder_methods = [ + m for m in hidden_struct["methods"] if m["name"] in _USEFUL_DUNDERS + ] + if dunder_methods: + _fix_dunder_signatures_with_instance(val, dunder_methods) + structs.append(hidden_struct) + known_struct_names.add(val_name) + prop["type"] = val_name + + +def introspect_class(cls: type[object], module_name: str) -> StructData: + """Introspect a class (C extension or Python) and return StructData.""" + class_doc = inspect.getdoc(cls) or "" + base_name = _resolve_base_name(cls, module_name) + properties, methods = _introspect_declared_class_members(cls) + _insert_constructor_from_doc(cls, class_doc, methods) + _refine_and_synthesize_dunders(cls, methods) + return { "name": cls.__name__, "doc": class_doc, @@ -1590,64 +1781,70 @@ def infer_getter_return_types(functions: list[FunctionData]) -> None: func["return_type"] = setters[prefix] -def introspect_ops_module() -> ModuleData: - """Introspect bpy.ops, including the operator proxy classes. +def _build_ops_fallback_module(submodule_names: list[str]) -> ModuleData: + """Build a safe fallback bpy.ops module when no operator instances are found.""" + return { + "module": "bpy.ops", + "doc": "Blender operator access.", + "functions": [], + "variables": [ + {"name": name, "type": "object", "value": "..."} for name in submodule_names + ], + "structs": [], + } - bpy.ops submodules (e.g. bpy.ops.mesh) are real Python module objects, - but individual operators (e.g. bpy.ops.mesh.primitive_cube_add) are - instances of _BPyOpsSubModOp — a C class with methods like poll(), - idname(), get_rna_type(), and bl_options. - We introspect _BPyOpsSubModOp from a live instance and fix up return - types that C methods don't expose via docstrings. For the submodule - level, we create a synthetic _OpsModule class with __getattr__ since - the actual type is just Python's builtin module. - """ - bpy = importlib.import_module("bpy") +def _find_sample_operator(ops_mod: object, submodule_names: list[str]) -> object | None: + """Find one callable bpy.ops operator instance for structural introspection.""" + for sub_name in submodule_names: + try: + sub_mod = getattr(ops_mod, sub_name) + except Exception: + continue + for op_name in sorted(n for n in dir(sub_mod) if not n.startswith("_")): + try: + op_candidate: object = getattr(sub_mod, op_name) + except Exception: + continue + if callable(op_candidate): + return op_candidate + return None - # Get a real operator instance to discover the proxy type - ops_mod = getattr(bpy, "ops") - sub_name = next(n for n in dir(ops_mod) if not n.startswith("_")) - sub_mod = getattr(ops_mod, sub_name) - op_name = next(n for n in dir(sub_mod) if not n.startswith("_")) - op: object = getattr(sub_mod, op_name) - # Introspect _BPyOpsSubModOp (individual operator wrapper) - op_cls = type(op) - assert isinstance(op_cls, type) - op_struct = introspect_class(op_cls, "bpy.ops") - - # Fix return types that introspection can't discover from docstrings. - # We call each method on a real operator to determine the actual types. - _return_type_fixups: dict[str, str] = {} +def _apply_ops_method_return_fixups(op_struct: StructData, operator: object) -> None: + """Fill missing operator wrapper return types by probing a live operator.""" + return_type_fixups: dict[str, str] = {} for method in op_struct["methods"]: name = method["name"] if method["return_type"] is not None: continue - func = getattr(op, name, None) + func = getattr(operator, name, None) if func is None or not callable(func): continue try: result = func() - _return_type_fixups[name] = _type_name(result) + return_type_fixups[name] = _type_name(result) except Exception: pass for method in op_struct["methods"]: - if method["name"] in _return_type_fixups: - method["return_type"] = _return_type_fixups[method["name"]] + fixed = return_type_fixups.get(method["name"]) + if fixed is not None: + method["return_type"] = fixed - # Fix bl_options type — it's a set of strings at runtime + +def _apply_ops_property_and_method_fixups(op_struct: StructData) -> None: + """Apply known manual type fixups for bpy.ops wrapper metadata.""" for prop in op_struct["properties"]: if prop["name"] == "bl_options": prop["type"] = "set[str]" - - # get_rna_type returns bpy.types.Struct for method in op_struct["methods"]: if method["name"] == "get_rna_type": method["return_type"] = "bpy.types.Struct" - # Add __call__ — operators are callable and return a set of status strings + +def _append_ops_call_method(op_struct: StructData) -> None: + """Append the callable operator wrapper method definition.""" op_struct["methods"].append( { "name": "__call__", @@ -1671,9 +1868,10 @@ def introspect_ops_module() -> ModuleData: } ) - # _OpsSubModule: a class with __getattr__ returning operators. - # Each ops submodule (mesh, object, etc.) is typed as this class. - sub_struct: StructData = { + +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, @@ -1690,16 +1888,56 @@ def introspect_ops_module() -> ModuleData: "kind": "POSITIONAL_OR_KEYWORD", }, ], - "return_type": op_struct["name"], + "return_type": operator_type_name, "is_classmethod": False, } ], } - # List each ops submodule as a typed variable - variables: list[VariableData] = [] - for sub_name in sorted(n for n in dir(ops_mod) if not n.startswith("_")): - variables.append({"name": sub_name, "type": "_OpsSubModule", "value": "..."}) + +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_module() -> ModuleData: + """Introspect bpy.ops, including the operator proxy classes. + + bpy.ops submodules (e.g. bpy.ops.mesh) are real Python module objects, + but individual operators (e.g. bpy.ops.mesh.primitive_cube_add) are + instances of _BPyOpsSubModOp — a C class with methods like poll(), + idname(), get_rna_type(), and bl_options. + + We introspect _BPyOpsSubModOp from a live instance and fix up return + types that C methods don't expose via docstrings. For the submodule + level, we create a synthetic _OpsModule class with __getattr__ since + the actual type is just Python's builtin module. + """ + bpy = importlib.import_module("bpy") + + # Get a real operator instance to discover the proxy type + ops_mod = getattr(bpy, "ops") + submodule_names = sorted(n for n in dir(ops_mod) if not n.startswith("_")) + sample_op = _find_sample_operator(ops_mod, submodule_names) + + # Some stripped-down or unusual Blender environments can expose bpy.ops + # without any discoverable operator instances. Fall back to a safe module. + if sample_op is None: + return _build_ops_fallback_module(submodule_names) + + # Introspect _BPyOpsSubModOp (individual operator wrapper) + op_cls = type(sample_op) + op_struct = introspect_class(op_cls, "bpy.ops") + + _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) return { "module": "bpy.ops", @@ -1720,144 +1958,20 @@ def introspect_module(module_name: str) -> ModuleData: module = importlib.import_module(module_name) - # Use __all__ as the base, but also include public callables from dir() - # that are defined in this module (not imported from elsewhere). - # This catches functions like is_path_builtin that are in the module - # but not in __all__. - all_attr: tuple[str, ...] | None = getattr(module, "__all__", None) - if all_attr is not None: - names_set = set(all_attr) - for n in dir(module): - if n.startswith("_") or n in names_set: - continue - obj = getattr(module, n, None) - if obj is None: - continue - # Only add functions/classes defined in this module - obj_module = getattr(obj, "__module__", None) - if obj_module == module_name and (callable(obj) or isinstance(obj, type)): - names_set.add(n) - # Also add type aliases (e.g. FCurveKey = Tuple[str, int]) - # but not stdlib re-exports (Iterable, Sequence, etc.) - elif hasattr(obj, "__origin__") and n not in { - "Callable", - "Collection", - "Generator", - "Iterable", - "Iterator", - "Mapping", - "MutableMapping", - "MutableSequence", - "MutableSet", - "Sequence", - "Set", - "FrozenSet", - "Dict", - "List", - "Tuple", - "Type", - "Optional", - "Union", - }: - names_set.add(n) - public_names: list[str] = sorted(names_set) - else: - public_names = [n for n in dir(module) if not n.startswith("_")] + public_names = _collect_public_module_names(module, module_name) functions: list[FunctionData] = [] variables: list[VariableData] = [] structs: list[StructData] = [] - for name in sorted(public_names): + for name in public_names: obj = getattr(module, name, None) if obj is None: continue - - # Skip submodules - if isinstance(obj, ModuleType): - continue - - if isinstance(obj, type): - structs.append(introspect_class(obj, module_name)) - elif hasattr(obj, "__origin__") or ( - hasattr(obj, "__module__") and getattr(obj, "__module__", "") == "typing" - ): - # Type alias (e.g. FCurveKey = Tuple[str, int]) - type_repr = str(obj).replace("typing.", "") - # Normalize old-style typing generics to PEP 585 (Tuple -> tuple, etc.) - type_repr = re.sub(r"\bTuple\b", "tuple", type_repr) - type_repr = re.sub(r"\bList\b", "list", type_repr) - type_repr = re.sub(r"\bDict\b", "dict", type_repr) - type_repr = re.sub(r"\bSet\b", "set", type_repr) - type_repr = re.sub(r"\bFrozenSet\b", "frozenset", type_repr) - variables.append( - { - "name": name, - "type": "TypeAlias", - "value": type_repr, - } - ) - elif callable(obj): - func_data = introspect_callable(obj, name) - if func_data: - functions.append(func_data) - else: - variables.append( - { - "name": name, - "type": python_type_name(obj, name), - "value": repr(obj), - } - ) + _classify_module_member(module_name, name, obj, functions, variables, structs) infer_getter_return_types(functions) - - # Discover hidden C types reachable through properties of known classes. - # E.g. mathutils.Matrix.col returns MatrixAccess which isn't in dir(mathutils). - # Only enabled for modules known to have safe, side-effect-free constructors. - _SAFE_PROBE_MODULES = {"mathutils"} - if module_name in _SAFE_PROBE_MODULES: - import builtins as _builtins_mod - - _SKIP_PROP_TYPES = frozenset( - ("int", "float", "str", "bool", "NoneType", "list", "tuple", "dict", "set") - ) - known_struct_names = {s["name"] for s in structs} - for struct in list(structs): - cls = getattr(module, struct["name"], None) - if cls is None or not isinstance(cls, type): - continue - try: - instance = cls() - except Exception: - continue - for prop in struct["properties"]: - if prop["type"] != "object": - continue - try: - val = getattr(instance, prop["name"]) - except Exception: - continue - val_cls = val.__class__ - val_name = val_cls.__name__ - if hasattr(_builtins_mod, val_name) or val_name in _SKIP_PROP_TYPES: - continue - # Add the hidden type if not already known - if val_name not in known_struct_names: - hidden_struct = introspect_class(val_cls, module_name) - # Fix dunders using the live instance (since the type - # may not be directly constructible) - dunder_methods = [ - m - for m in hidden_struct["methods"] - if m["name"] in _USEFUL_DUNDERS - ] - if dunder_methods: - _fix_dunder_signatures_with_instance(val, dunder_methods) - structs.append(hidden_struct) - known_struct_names.add(val_name) - # Fix the property type either way - prop["type"] = val_name + _probe_hidden_property_structs(module, module_name, structs) return { "module": module_name, @@ -2124,25 +2238,22 @@ def introspect_screen_context_members( ctx: object = getattr(bpy, "context") skip = {"bl_rna", "id_data", "rna_type"} - extra_attrs = sorted( - name - for name in dir(ctx) - if not name.startswith("_") - and name not in rna_property_names - and name not in skip - and not ( - callable(getattr(ctx, name)) - and not isinstance(getattr(ctx, name), (list, tuple)) - ) - ) + # Fetch each context attribute once and handle failures per-attribute. + # Some context members can raise depending on active mode/editor. + extra_members: list[tuple[str, object]] = [] + for name in sorted(dir(ctx)): + if name.startswith("_") or name in rna_property_names or name in skip: + continue + try: + value = cast(object, getattr(ctx, name)) + except Exception: + continue + if callable(value) and not isinstance(value, (list, tuple)): + continue + extra_members.append((name, cast(object, value))) properties: list[PropertyData] = [] - for name in extra_attrs: - try: - value = getattr(ctx, name) - except AttributeError: - continue - + for name, value in extra_members: type_str: str | None = None # Tier 1: runtime inspection (non-None values) @@ -2216,12 +2327,208 @@ def _validate_context_prop_type(type_str: str, known_types: set[str]) -> str: return _re.sub(r"\b([A-Z]\w+)\b", _replace_match, type_str) +def _struct_identifier(struct_info: object) -> str: + """Return RNA struct identifier safely as a string.""" + return str(getattr(struct_info, "identifier", "")) + + +def _add_core_bpy_type_structs( + bpy_types_module: ModuleType, + generic_bases: dict[str, str], + extra_dunders: dict[str, list[FunctionData]], +) -> list[StructData]: + """Introspect core bpy.types C base classes and generic collection wrappers.""" + structs: list[StructData] = [] + + bpy_struct_cls = getattr(bpy_types_module, "bpy_struct", None) + if bpy_struct_cls is not None: + bpy_struct_data = introspect_class(bpy_struct_cls, "bpy.types") + bpy_struct_data["properties"].extend( + [ + { + "name": "bl_rna", + "type": "Struct", + "is_readonly": True, + "description": "RNA type definition", + }, + { + "name": "rna_type", + "type": "Struct", + "is_readonly": True, + "description": "RNA type definition", + }, + ] + ) + structs.append(bpy_struct_data) + + for cls_name in generic_bases: + cls = getattr(bpy_types_module, cls_name, None) + if cls is None: + continue + struct = introspect_class(cls, "bpy.types") + struct["base"] = generic_bases[cls_name] + replacement_dunders = extra_dunders[cls_name] + replacement_names = {m["name"] for m in replacement_dunders} + struct["methods"] = replacement_dunders + [ + m for m in struct["methods"] if m["name"] not in replacement_names + ] + structs.append(struct) + + return structs + + +def _collect_collection_element_types( + structs_dict: dict[object, object], +) -> dict[str, str]: + """Build map of collection wrapper class name to element type name.""" + collection_element_types: dict[str, str] = {} + for raw_struct_info in structs_dict.values(): + for prop in getattr(raw_struct_info, "properties", []): + if getattr(prop, "type", "") != "collection": + continue + fixed_type = getattr(prop, "fixed_type", None) + if fixed_type is None: + continue + srna: object = getattr(prop, "srna", None) + if srna is None: + continue + srna_id = str(getattr(srna, "identifier", "")) + element_type = str(getattr(fixed_type, "identifier", "")) + if srna_id and element_type: + collection_element_types[srna_id] = element_type + return collection_element_types + + +def _rna_struct_to_data( + struct_info: object, + collection_element_types: dict[str, str], +) -> StructData: + """Convert a single RNA struct info object to StructData.""" + sid = _struct_identifier(struct_info) + base_obj = getattr(struct_info, "base", None) + + if sid in collection_element_types: + base_name = f"bpy_prop_collection[{collection_element_types[sid]}]" + elif base_obj: + base_name = str(getattr(base_obj, "identifier", "bpy_struct")) + else: + base_name = "bpy_struct" + + properties: list[PropertyData] = [] + for prop in getattr(struct_info, "properties", []): + properties.append( + { + "name": str(getattr(prop, "identifier", "")), + "type": rna_property_to_type(prop), + "is_readonly": bool(getattr(prop, "is_readonly", False)), + "description": str(getattr(prop, "description", "") or ""), + } + ) + + methods: list[FunctionData] = [] + is_collection_wrapper = sid in collection_element_types + for func_info in getattr(struct_info, "functions", []): + func_name = str(getattr(func_info, "identifier", "")) + if is_collection_wrapper and func_name in ("find", "get"): + continue + methods.append(rna_function_to_data(func_info)) + + return { + "name": sid, + "doc": str(getattr(struct_info, "description", "") or ""), + "base": base_name, + "properties": properties, + "methods": methods, + } + + +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 + 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) + for prop in screen_props: + prop["type"] = _validate_context_prop_type(prop["type"], known_types) + prop["type"] = prop["type"].replace( + "Sequence[", "collections.abc.Sequence[" + ) + struct["properties"].extend(screen_props) + break + + +def _merge_non_rna_bpy_types( + structs: list[StructData], bpy_types_module: ModuleType +) -> None: + """Add C-level bpy.types classes not present in RNA metadata.""" + import builtins as _builtins + + known_names = {s["name"] for s in structs} + for name in sorted(dir(bpy_types_module)): + if name.startswith("_") or name in known_names: + continue + obj = getattr(bpy_types_module, name, None) + if not isinstance(obj, type): + continue + if obj.__module__ == "bpy.types" or ( + obj.__module__ == "builtins" and not hasattr(_builtins, name) + ): + structs.append(introspect_class(obj, "bpy.types")) + + +def _merge_missing_c_methods( + structs: list[StructData], bpy_types_module: ModuleType +) -> None: + """Add C-level methods missing from RNA metadata for each struct.""" + 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"]} + for attr_name in sorted(cls.__dict__): + if attr_name.startswith("_") or attr_name in existing: + continue + if attr_name in ("bl_rna", "rna_type"): + continue + raw = cls.__dict__[attr_name] + raw_type = type(raw).__name__ + is_c_classmethod = raw_type == "classmethod_descriptor" + if not is_c_classmethod and raw_type == "classmethod": + inner = getattr(raw, "__func__", None) + is_c_classmethod = inner is not None and not hasattr(inner, "__code__") + if is_c_classmethod: + bound = getattr(cls, attr_name) + if callable(bound): + func_data = introspect_callable(bound, attr_name) + if func_data: + func_data["is_classmethod"] = 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: + struct["methods"].append(func_data) + elif raw_type == "function": + # Python functions with RST docstrings are API methods + # (e.g. Context.copy, Object.evaluated_geometry) + doc = inspect.getdoc(raw) or "" + if ":rtype:" in doc or ":type " in doc: + func_data = introspect_callable(raw, attr_name) + if func_data: + struct["methods"].append(func_data) + + def introspect_rna_types() -> ModuleData: """Introspect all RNA-defined types using rna_info.BuildRNAInfo().""" rna_info = _import_rna_info() info = rna_info.BuildRNAInfo() - structs_dict = info[0] + rna_structs_dict = cast(dict[object, object], info[0]) # Introspect the C-level base classes that aren't in RNA but are in bpy.types. # These provide fundamental methods like __getitem__, foreach_get, etc. @@ -2363,175 +2670,15 @@ def introspect_rna_types() -> ModuleData: "bpy_prop_array": _ARRAY_DUNDERS, } - structs: list[StructData] = [] + structs = _add_core_bpy_type_structs(_bpy_types, _GENERIC_BASES, _EXTRA_DUNDERS) - # Introspect bpy_struct (base of all RNA types) - bpy_struct_cls = getattr(_bpy_types, "bpy_struct", None) - if bpy_struct_cls is not None: - bpy_struct_data = introspect_class(bpy_struct_cls, "bpy.types") - # rna_type and bl_rna are built-in RNA meta-properties not discovered - # by introspect_class (they're injected at the C level). - bpy_struct_data["properties"].extend( - [ - { - "name": "bl_rna", - "type": "Struct", - "is_readonly": True, - "description": "RNA type definition", - }, - { - "name": "rna_type", - "type": "Struct", - "is_readonly": True, - "description": "RNA type definition", - }, - ] - ) - structs.append(bpy_struct_data) + collection_element_types = _collect_collection_element_types(rna_structs_dict) + for struct_info in sorted(rna_structs_dict.values(), key=_struct_identifier): + structs.append(_rna_struct_to_data(struct_info, collection_element_types)) - for cls_name in ["bpy_prop_collection", "bpy_prop_array"]: - cls = getattr(_bpy_types, cls_name, None) - if cls is not None: - struct = introspect_class(cls, "bpy.types") - struct["base"] = _GENERIC_BASES[cls_name] - # Replace introspected dunders with manually-typed versions that - # use generic type parameters (_T). - extra_names = {m["name"] for m in _EXTRA_DUNDERS[cls_name]} - struct["methods"] = _EXTRA_DUNDERS[cls_name] + [ - m for m in struct["methods"] if m["name"] not in extra_names - ] - structs.append(struct) - - # Build a map of collection wrapper class -> element type. - # When a property has type=collection and an srna, the srna identifies - # the wrapper class (e.g. BlendDataImages) and fixed_type is the element - # (e.g. Image). These wrappers should inherit from bpy_prop_collection[T]. - collection_element_types: dict[str, str] = {} - for struct_info in structs_dict.values(): - for prop in struct_info.properties: - if prop.type != "collection" or not prop.fixed_type: - continue - srna: object = getattr(prop, "srna", None) - if srna is not None: - srna_id: str = getattr(srna, "identifier", "") - if srna_id: - collection_element_types[srna_id] = prop.fixed_type.identifier - - for struct_info in sorted(structs_dict.values(), key=lambda s: s.identifier): - base_name: str | None = None - sid = struct_info.identifier - if sid in collection_element_types: - # Collection wrapper class — inherit from bpy_prop_collection[T] - base_name = f"bpy_prop_collection[{collection_element_types[sid]}]" - elif struct_info.base: - base_name = struct_info.base.identifier - else: - # All RNA types implicitly inherit from bpy_struct - base_name = "bpy_struct" - - properties: list[PropertyData] = [] - for prop in struct_info.properties: - properties.append( - { - "name": prop.identifier, - "type": rna_property_to_type(prop), - "is_readonly": prop.is_readonly, - "description": prop.description or "", - } - ) - - methods: list[FunctionData] = [] - is_collection_wrapper = sid in collection_element_types - for func_info in struct_info.functions: - # Skip RNA methods that would incompatibly override bpy_prop_collection - if is_collection_wrapper and func_info.identifier in ("find", "get"): - continue - methods.append(rna_function_to_data(func_info)) - - structs.append( - { - "name": struct_info.identifier, - "doc": struct_info.description or "", - "base": base_name, - "properties": properties, - "methods": methods, - } - ) - - # Add screen context members to the Context struct - known_types = {s["name"] for s in structs} - for struct in structs: - if struct["name"] == "Context": - 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) - # Validate type references and qualify Sequence to avoid - # shadowing by bpy.types.Sequence (video sequencer strip) - for prop in screen_props: - prop["type"] = _validate_context_prop_type(prop["type"], known_types) - prop["type"] = prop["type"].replace( - "Sequence[", "collections.abc.Sequence[" - ) - struct["properties"].extend(screen_props) - break - - # Pick up non-RNA C classes in bpy.types (e.g. GeometrySet) that - # aren't discovered by BuildRNAInfo. - import builtins as _builtins - - known_names = {s["name"] for s in structs} - for name in sorted(dir(_bpy_types)): - if name.startswith("_") or name in known_names: - continue - obj = getattr(_bpy_types, name, None) - if not isinstance(obj, type): - continue - # Accept classes from bpy.types or C-level classes (builtins) that - # aren't standard Python builtins (int, str, etc.) - if obj.__module__ == "bpy.types" or ( - obj.__module__ == "builtins" and not hasattr(_builtins, name) - ): - structs.append(introspect_class(obj, "bpy.types")) - - # Pick up C-level methods on RNA types that aren't in rna_info - # (e.g. Space.draw_handler_add/draw_handler_remove are classmethods - # injected at the C level, not RNA functions). - for struct in structs: - cls = getattr(_bpy_types, struct["name"], None) - if cls is None: - continue - existing = {m["name"] for m in struct["methods"]} - existing |= {p["name"] for p in struct["properties"]} - for attr_name in sorted(cls.__dict__): - if attr_name.startswith("_") or attr_name in existing: - continue - if attr_name in ("bl_rna", "rna_type"): - continue - raw = cls.__dict__[attr_name] - raw_type = type(raw).__name__ - # Only pick up C-level methods, not Python-defined ones. - # classmethod wrapping a builtin is C-level (e.g. Space.draw_handler_add) - is_c_classmethod = raw_type == "classmethod_descriptor" - if not is_c_classmethod and raw_type == "classmethod": - # classmethod wrapping a builtin (e.g. Space.draw_handler_add) - inner = getattr(raw, "__func__", None) - is_c_classmethod = inner is not None and not hasattr(inner, "__code__") - if is_c_classmethod: - bound = getattr(cls, attr_name) - if callable(bound): - func_data = introspect_callable(bound, attr_name) - if func_data: - func_data["is_classmethod"] = 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: - struct["methods"].append(func_data) + _merge_screen_context_members(structs) + _merge_non_rna_bpy_types(structs, _bpy_types) + _merge_missing_c_methods(structs, _bpy_types) return { "module": "bpy.types",