"""Tests for the introspection module.""" from typing_extensions import override import unittest from types import SimpleNamespace from typing import cast, final from unittest.mock import patch from introspect import ( FunctionData, infer_getter_return_types, parse_rst_function_sig, infer_context_member_type, introspect_callable, refine_types_by_context, clean_docstring, clean_type_str, introspect_ops_module, introspect_screen_context_members, parse_docstring_types, python_type_name, ) class TestCleanTypeStr(unittest.TestCase): def test_class_markup_removal(self) -> None: self.assertEqual( clean_type_str(":class:`bpy.types.Library`"), "bpy.types.Library" ) def test_plain_type_passthrough(self) -> None: self.assertEqual(clean_type_str("str | bytes"), "str | bytes") def test_generic_type(self) -> None: self.assertEqual( clean_type_str("list[tuple[str, str]]"), "list[tuple[str, str]]" ) def test_sequence_type(self) -> None: self.assertEqual(clean_type_str("Sequence[str]"), "Sequence[str]") def test_whitespace_collapse(self) -> None: self.assertEqual(clean_type_str("str | bytes"), "str | bytes") def test_multiline_collapse(self) -> None: self.assertEqual(clean_type_str("str\n | bytes"), "str | bytes") class TestParseDocstringTypes(unittest.TestCase): def test_simple_type(self) -> None: doc = ":type name: str\n:rtype: str" param_types, return_type = parse_docstring_types(doc) self.assertEqual(param_types, {"name": "str"}) self.assertEqual(return_type, "str") def test_union_type(self) -> None: doc = ":type path: str | bytes\n:rtype: str" param_types, return_type = parse_docstring_types(doc) self.assertEqual(param_types, {"path": "str | bytes"}) self.assertEqual(return_type, "str") def test_class_reference(self) -> None: doc = ":type library: :class:`bpy.types.Library`\n:rtype: str" param_types, _return_type = parse_docstring_types(doc) self.assertEqual(param_types, {"library": "bpy.types.Library"}) def test_generic_return(self) -> None: doc = ":rtype: list[tuple[str, str]]" _param_types, return_type = parse_docstring_types(doc) self.assertEqual(return_type, "list[tuple[str, str]]") def test_empty_docstring(self) -> None: param_types, return_type = parse_docstring_types("") self.assertEqual(param_types, {}) self.assertIsNone(return_type) def test_no_type_annotations(self) -> None: doc = "Just a description with no type info." param_types, return_type = parse_docstring_types(doc) self.assertEqual(param_types, {}) self.assertIsNone(return_type) def test_multiple_params(self) -> None: doc = ( ":arg start: Relative to this path.\n" ":type start: str | bytes\n" ":arg library: The library.\n" ":type library: :class:`bpy.types.Library`\n" ":rtype: str" ) param_types, return_type = parse_docstring_types(doc) self.assertEqual( param_types, { "start": "str | bytes", "library": "bpy.types.Library", }, ) self.assertEqual(return_type, "str") def test_literal_inferred_from_arg_enum_values(self) -> None: doc = ( ":arg mode: The blend mode.\n" " * ``NONE`` No blending.\n" " * ``ALPHA`` Alpha blend.\n" " * ``ADDITIVE`` Additive.\n" ":type mode: str" ) param_types, _ = parse_docstring_types(doc) self.assertEqual( param_types["mode"], 'Literal["NONE", "ALPHA", "ADDITIVE"]', ) def test_literal_not_inferred_when_few_values(self) -> None: doc = ":arg path: The ``PATH`` to use.\n:type path: str" param_types, _ = parse_docstring_types(doc) self.assertEqual(param_types["path"], "str") def test_lowercase_generator(self) -> None: self.assertEqual( clean_type_str("generator"), "Generator[object, None, None]", ) def test_lowercase_sequence(self) -> None: self.assertEqual(clean_type_str("sequence"), "Sequence[object]") def test_bare_set_gets_parameterized(self) -> None: self.assertEqual(clean_type_str("set"), "set[object]") def test_bare_frozenset_gets_parameterized(self) -> None: self.assertEqual(clean_type_str("frozenset"), "frozenset[object]") def test_module_maps_to_types_moduletype(self) -> None: self.assertEqual(clean_type_str("Module"), "types.ModuleType") self.assertEqual(clean_type_str("Module | None"), "types.ModuleType | None") self.assertEqual(clean_type_str("module"), "types.ModuleType") def test_old_typing_names_to_builtins(self) -> None: self.assertEqual(clean_type_str("Dict[str, int]"), "dict[str, int]") self.assertEqual(clean_type_str("Set[int]"), "set[int]") self.assertEqual(clean_type_str("List[str]"), "list[str]") self.assertEqual(clean_type_str("Tuple[int, str]"), "tuple[int, str]") self.assertEqual(clean_type_str("FrozenSet[int]"), "frozenset[int]") def test_nonetype_maps_to_none(self) -> None: self.assertEqual(clean_type_str("NoneType"), "None") self.assertEqual(clean_type_str("str | NoneType"), "str | None") def test_undefined_maps_to_object(self) -> None: self.assertEqual(clean_type_str("Undefined"), "object") def test_uint_maps_to_int(self) -> None: self.assertEqual(clean_type_str("uint"), "int") self.assertEqual(clean_type_str("uint | None"), "int | None") def test_type_sequence_pattern(self) -> None: self.assertEqual(clean_type_str("int or int sequence"), "int | Sequence[int]") self.assertEqual( clean_type_str("float or float sequence"), "float | Sequence[float]" ) def test_plural_bools(self) -> None: self.assertEqual(clean_type_str("Sequence of bools"), "Sequence[bool]") def test_plural_numbers(self) -> None: self.assertEqual(clean_type_str("Sequence of numbers"), "Sequence[float]") def test_plural_vectors(self) -> None: self.assertEqual(clean_type_str("vectors"), "mathutils.Vector") def test_plural_matrices(self) -> None: self.assertEqual(clean_type_str("matrices"), "mathutils.Matrix") def test_informal_mathutils_types_are_qualified(self) -> None: self.assertEqual( clean_type_str("vector | matrix | quaternion | euler | color"), "mathutils.Vector | mathutils.Matrix | mathutils.Quaternion | mathutils.Euler | mathutils.Color", ) def test_literal_values_are_quoted(self) -> None: self.assertEqual( clean_type_str("Literal[NONE, ALPHA]"), "Literal['NONE', 'ALPHA']", ) def test_str_in_values_converts_to_literal(self) -> None: self.assertEqual( clean_type_str("string in ['NONE', 'ALPHA']"), "Literal['NONE', 'ALPHA']", ) def test_str_in_values_supports_double_quotes(self) -> None: self.assertEqual( clean_type_str('str in ["NONE", "ALPHA"]'), "Literal['NONE', 'ALPHA']", ) def test_sequence_multi_arg_to_tuple(self) -> None: self.assertEqual( clean_type_str("Sequence[int, int]"), "Sequence[tuple[int, int]]" ) self.assertEqual( clean_type_str("Sequence[Sequence[int, int]]"), "Sequence[Sequence[tuple[int, int]]]", ) def test_plural_tuples(self) -> None: self.assertEqual(clean_type_str("list[tuples]"), "list[tuple[object, ...]]") def test_sequence_of_n_type_skips_number(self) -> None: self.assertEqual(clean_type_str("Sequence of 3 float"), "Sequence[float]") self.assertEqual(clean_type_str("Iterable of 4 int"), "Iterable[int]") def test_sequence_of_n_or_m_type_skips_dimensions(self) -> None: self.assertEqual(clean_type_str("Sequence of 3 or 4 float"), "Sequence[float]") self.assertEqual(clean_type_str("Sequence of 1 to 4 float"), "Sequence[float]") def test_numeric_type_arg_stripped(self) -> None: # "Sequence[3]" from malformed docstring -> bare Sequence -> parameterized self.assertEqual(clean_type_str("Sequence[3]"), "Sequence[object]") def test_numeric_union_parts_stripped(self) -> None: # "Sequence[1] | 2 | 3" from dimension descriptions result = clean_type_str("Sequence[1] | 2 | 3 | object") self.assertNotIn("2", result) self.assertNotIn("3", result) def test_empty_brackets_treated_as_bare(self) -> None: self.assertEqual(clean_type_str("dict[]"), "dict[str, object]") self.assertEqual(clean_type_str("list[]"), "list[object]") def test_callable_empty_params_preserved(self) -> None: self.assertEqual(clean_type_str("Callable[[], None]"), "Callable[[], None]") def test_callable_ellipsis_params_no_double_comma(self) -> None: # Callable[[...], X] should become Callable[..., X] without double comma self.assertEqual( clean_type_str("Callable[[...], str | None]"), "Callable[..., str | None]", ) self.assertEqual( clean_type_str("Callable[[...], object]"), "Callable[..., object]", ) def test_bare_generic_no_false_match(self) -> None: # \bSequence\b should not match inside SequenceEntry self.assertEqual(clean_type_str("SequenceEntry"), "SequenceEntry") # \bset\b should not match inside SettingsGroup self.assertEqual(clean_type_str("SettingsGroup"), "SettingsGroup") class TestRefineTypesByContext(unittest.TestCase): def test_bool_vector_default(self) -> None: param_types = {"default": "Sequence"} param_types, _ = refine_types_by_context( "BoolVectorProperty", param_types, None ) self.assertEqual(param_types["default"], "Sequence[bool]") def test_float_vector_default(self) -> None: param_types = {"default": "Sequence[object]"} param_types, _ = refine_types_by_context( "FloatVectorProperty", param_types, None ) self.assertEqual(param_types["default"], "Sequence[float]") def test_int_vector_default(self) -> None: param_types = {"default": "Sequence[object]"} param_types, _ = refine_types_by_context("IntVectorProperty", param_types, None) self.assertEqual(param_types["default"], "Sequence[int]") def test_property_options_set_str(self) -> None: param_types = {"options": "set[object]", "override": "set[object]"} param_types, _ = refine_types_by_context("BoolProperty", param_types, None) self.assertEqual(param_types["options"], "set[str]") self.assertEqual(param_types["override"], "set[str]") def test_generator_return_refined(self) -> None: _, return_type = refine_types_by_context("app_template_paths", {}, "Generator") self.assertEqual(return_type, "Generator[str, None, None]") def test_non_property_set_unchanged(self) -> None: param_types = {"options": "set[object]"} param_types, _ = refine_types_by_context("some_function", param_types, None) self.assertEqual(param_types["options"], "set[object]") class TestPythonTypeName(unittest.TestCase): def test_set_with_string_contents(self) -> None: obj: object = {"a", "b"} self.assertEqual(python_type_name(obj), "set[str]") def test_empty_set_defaults_to_str(self) -> None: obj = cast(object, set()) self.assertEqual(python_type_name(obj), "set[str]") def test_list_with_int_contents(self) -> None: obj: object = [1, 2, 3] self.assertEqual(python_type_name(obj), "list[int]") def test_empty_list_defaults_to_object(self) -> None: obj: object = [] self.assertEqual(python_type_name(obj), "list[object]") def test_frozenset_with_contents(self) -> None: obj: object = frozenset({"x"}) self.assertEqual(python_type_name(obj), "frozenset[str]") def test_empty_frozenset_defaults_to_str(self) -> None: obj = cast(object, frozenset()) self.assertEqual(python_type_name(obj), "frozenset[str]") class TestParseRstFunctionSig(unittest.TestCase): def test_keyword_only_with_defaults(self) -> None: doc = ".. function:: blend_paths(*, absolute=False, packed=False)" result = parse_rst_function_sig(doc) self.assertEqual(result["absolute"], ("False", "KEYWORD_ONLY")) self.assertEqual(result["packed"], ("False", "KEYWORD_ONLY")) def test_positional_and_keyword(self) -> None: doc = ".. function:: foo(path, *, create=True)" result = parse_rst_function_sig(doc) self.assertEqual(result["path"], (None, "POSITIONAL_OR_KEYWORD")) self.assertEqual(result["create"], ("True", "KEYWORD_ONLY")) def test_no_rst_directive(self) -> None: doc = "Just a plain docstring." result = parse_rst_function_sig(doc) self.assertEqual(result, {}) def test_class_directive(self) -> None: doc = ".. class:: GPUBatch(type, buf, elem=None)" result = parse_rst_function_sig(doc) self.assertEqual(result["type"], (None, "POSITIONAL_OR_KEYWORD")) self.assertEqual(result["buf"], (None, "POSITIONAL_OR_KEYWORD")) self.assertEqual(result["elem"], ("None", "POSITIONAL_OR_KEYWORD")) def test_positional_only_separator(self) -> None: doc = ".. class:: Color(/, rgb=(0.0, 0.0, 0.0))" result = parse_rst_function_sig(doc) self.assertNotIn("/", result) self.assertEqual(result["rgb"], ("...", "POSITIONAL_OR_KEYWORD")) def test_rst_optional_brackets(self) -> None: doc = ".. method:: write(data[, position])" result = parse_rst_function_sig(doc) self.assertEqual(result["data"], (None, "POSITIONAL_OR_KEYWORD")) self.assertEqual(result["position"], (None, "POSITIONAL_OR_KEYWORD")) def test_rst_nested_optional_brackets(self) -> None: doc = ".. method:: bar(a[, b[, c]])" result = parse_rst_function_sig(doc) self.assertIn("a", result) self.assertIn("b", result) self.assertIn("c", result) def test_positional_only_with_named_params(self) -> None: doc = ".. function:: foo(a, b, /, c, *, d=True)" result = parse_rst_function_sig(doc) self.assertEqual(result["a"], (None, "POSITIONAL_ONLY")) self.assertEqual(result["b"], (None, "POSITIONAL_ONLY")) self.assertEqual(result["c"], (None, "POSITIONAL_OR_KEYWORD")) self.assertEqual(result["d"], ("True", "KEYWORD_ONLY")) class _NoSigCallable: """Callable that raises ValueError on inspect.signature (like C builtins).""" def __init__(self, doc: str) -> None: self.__doc__ = doc def __call__(self) -> None: pass @property def __signature__(self) -> None: raise ValueError("no signature") class TestRstOnlyParams(unittest.TestCase): def test_param_from_rst_sig_without_type_directive(self) -> None: """Params in RST signature but without :type: should still appear.""" func = _NoSigCallable( ".. method:: foreach_get(seq)\n\nFast access to array data." ) result = introspect_callable(func, "foreach_get") self.assertIsNotNone(result) result = cast(FunctionData, result) self.assertEqual(len(result["params"]), 1) self.assertEqual(result["params"][0]["name"], "seq") self.assertIsNone(result["params"][0]["type"]) def test_rst_params_merged_with_typed_params(self) -> None: """RST params without :type: are added after typed params.""" func = _NoSigCallable(".. method:: foo(a, b)\n\n:type a: str") result = introspect_callable(func, "foo") self.assertIsNotNone(result) result = cast(FunctionData, result) self.assertEqual(len(result["params"]), 2) self.assertEqual(result["params"][0]["name"], "a") self.assertEqual(result["params"][0]["type"], "str") self.assertEqual(result["params"][1]["name"], "b") self.assertIsNone(result["params"][1]["type"]) class TestParamNameMismatch(unittest.TestCase): def test_docstring_name_used_when_sig_has_generic(self) -> None: """When __text_signature__ uses 'object' but docstring uses 'string'.""" def fake_func(object: object) -> str: # noqa: A002 """:type string: str :rtype: str""" return str(object) result = introspect_callable(fake_func, "escape_identifier") self.assertIsNotNone(result) result = cast(FunctionData, result) self.assertEqual(result["params"][0]["name"], "string") self.assertEqual(result["params"][0]["type"], "str") def test_matching_names_work_normally(self) -> None: def fake_func(path: str) -> str: """:type path: str :rtype: str""" return path result = introspect_callable(fake_func, "basename") self.assertIsNotNone(result) result = cast(FunctionData, result) self.assertEqual(result["params"][0]["name"], "path") self.assertEqual(result["params"][0]["type"], "str") class TestInferContextMemberType(unittest.TestCase): def test_objects_suffix(self) -> None: self.assertEqual( infer_context_member_type("selected_objects"), "Sequence[Object]" ) self.assertEqual( infer_context_member_type("visible_objects"), "Sequence[Object]" ) def test_object_suffix(self) -> None: self.assertEqual(infer_context_member_type("active_object"), "Object") self.assertEqual(infer_context_member_type("edit_object"), "Object") def test_bones_suffix(self) -> None: self.assertEqual( infer_context_member_type("selected_bones"), "Sequence[EditBone]" ) def test_bone_suffix(self) -> None: self.assertEqual(infer_context_member_type("active_bone"), "EditBone") def test_fcurves_suffix(self) -> None: self.assertEqual( infer_context_member_type("visible_fcurves"), "Sequence[FCurve]" ) def test_strips_suffix(self) -> None: self.assertEqual( infer_context_member_type("selected_strips"), "Sequence[NlaStrip]" ) def test_unknown_returns_none(self) -> None: self.assertIsNone(infer_context_member_type("some_random_thing")) self.assertIsNone(infer_context_member_type("property")) class TestCleanDocstring(unittest.TestCase): def test_strips_arg_directives(self) -> None: doc = "Description of function.\n\n:arg name: The name.\n:type name: str" result = clean_docstring(doc) self.assertEqual(result, "Description of function.") def test_preserves_multiline_description(self) -> None: doc = "First line.\nSecond line.\n\n:arg x: something" result = clean_docstring(doc) self.assertEqual(result, "First line.\nSecond line.") def test_empty(self) -> None: self.assertEqual(clean_docstring(""), "") def test_no_directives(self) -> None: doc = "Just a plain description." self.assertEqual(clean_docstring(doc), "Just a plain description.") class TestInferGetterReturnTypes(unittest.TestCase): def test_getter_inferred_from_setter(self) -> None: functions: list[FunctionData] = [ { "name": "blend_get", "doc": "", "params": [], "return_type": None, "is_classmethod": False, }, { "name": "blend_set", "doc": "", "params": [ { "name": "mode", "type": "Literal['NONE', 'ALPHA']", "default": None, "kind": "POSITIONAL_OR_KEYWORD", } ], "return_type": "None", "is_classmethod": False, }, ] infer_getter_return_types(functions) self.assertEqual(functions[0]["return_type"], "Literal['NONE', 'ALPHA']") def test_getter_not_overwritten_when_typed(self) -> None: functions: list[FunctionData] = [ { "name": "depth_mask_get", "doc": "", "params": [], "return_type": "bool", "is_classmethod": False, }, { "name": "depth_mask_set", "doc": "", "params": [ { "name": "value", "type": "bool", "default": None, "kind": "POSITIONAL_OR_KEYWORD", } ], "return_type": "None", "is_classmethod": False, }, ] infer_getter_return_types(functions) self.assertEqual(functions[0]["return_type"], "bool") def test_setter_with_multiple_params_ignored(self) -> None: functions: list[FunctionData] = [ { "name": "scissor_get", "doc": "", "params": [], "return_type": None, "is_classmethod": False, }, { "name": "scissor_set", "doc": "", "params": [ { "name": "x", "type": "int", "default": None, "kind": "POSITIONAL_OR_KEYWORD", }, { "name": "y", "type": "int", "default": None, "kind": "POSITIONAL_OR_KEYWORD", }, ], "return_type": "None", "is_classmethod": False, }, ] infer_getter_return_types(functions) self.assertIsNone(functions[0]["return_type"]) class _ContextWithRaisingMember: ok: int = 7 @override def __dir__(self) -> list[str]: return ["ok", "broken", "callable_member", "rna_type"] @property def broken(self) -> object: raise RuntimeError("cannot access in this context") def callable_member(self) -> None: return None class _OpsNoCallableSubmodule: non_callable: int = 123 @override def __dir__(self) -> list[str]: return ["non_callable"] @final class _OpsRootNoCallable: mesh = _OpsNoCallableSubmodule() @override def __dir__(self) -> list[str]: return ["mesh"] class _OpsRootEmpty: @override def __dir__(self) -> list[str]: return [] class TestRuntimeEdgeCases(unittest.TestCase): def test_context_member_discovery_skips_raising_attributes(self) -> None: fake_bpy = SimpleNamespace(context=_ContextWithRaisingMember()) with patch("introspect.importlib.import_module", return_value=fake_bpy): props = introspect_screen_context_members(set()) by_name = {p["name"]: p for p in props} self.assertIn("ok", by_name) self.assertEqual(by_name["ok"]["type"], "int | None") self.assertNotIn("broken", by_name) self.assertNotIn("callable_member", by_name) def test_ops_introspection_falls_back_when_no_callable_operator(self) -> None: fake_bpy = SimpleNamespace(ops=_OpsRootNoCallable()) with patch("introspect.importlib.import_module", return_value=fake_bpy): data = introspect_ops_module() self.assertEqual(data["module"], "bpy.ops") self.assertEqual(data["structs"], []) self.assertEqual( data["variables"], [{"name": "mesh", "type": "object", "value": "..."}] ) def test_ops_introspection_falls_back_when_no_submodules(self) -> None: fake_bpy = SimpleNamespace(ops=_OpsRootEmpty()) with patch("introspect.importlib.import_module", return_value=fake_bpy): data = introspect_ops_module() self.assertEqual(data["module"], "bpy.ops") self.assertEqual(data["structs"], []) self.assertEqual(data["variables"], []) if __name__ == "__main__": unittest.main()