From f4df542dd16dca4f616485a8c4ea080d49d25b83 Mon Sep 17 00:00:00 2001 From: Joseph HENRY Date: Fri, 3 Apr 2026 11:07:52 +0200 Subject: [PATCH] Clean up dead code, fix type errors, and add publish workflow Remove unused _append_ops_call_method, fix _property_data_from_member call signature, replace sys.version_info guard with hasattr check for __buffer__ protocol, fix implicit string concatenations, add msgbus overrides for 4.0-4.4, add conformance tests, and add publish script. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 59 +++ README.md | 4 +- .../5.0/test_bpy_utils_keyconfig_export.py | 75 +++ .../5.1/test_bpy_utils_keyconfig_export.py | 75 +++ .../test_gpu_triangle_with_custom_shader.py | 10 +- generate_stubs.py | 66 +-- introspect.py | 436 ++++++++++++++---- main.py | 22 +- overrides/4.0/bpy.msgbus.json | 12 + overrides/4.1/bpy.msgbus.json | 12 + overrides/4.2/bpy.msgbus.json | 12 + overrides/4.3/bpy.msgbus.json | 12 + overrides/4.4/bpy.msgbus.json | 12 + publish.py | 225 +++++++++ pyproject.toml | 41 +- tests/test_introspect.py | 35 +- uv.lock | 11 + 17 files changed, 954 insertions(+), 165 deletions(-) create mode 100644 CLAUDE.md create mode 100644 conformance/5.0/test_bpy_utils_keyconfig_export.py create mode 100644 conformance/5.1/test_bpy_utils_keyconfig_export.py create mode 100644 overrides/4.0/bpy.msgbus.json create mode 100644 overrides/4.1/bpy.msgbus.json create mode 100644 overrides/4.2/bpy.msgbus.json create mode 100644 overrides/4.3/bpy.msgbus.json create mode 100644 overrides/4.4/bpy.msgbus.json create mode 100644 publish.py diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..72ff12f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,59 @@ +# CLAUDE.md - Project Guidelines + +## Project Overview + +Type stubs generator for the Blender Python API (`bpy`, `mathutils`, `bmesh`, `gpu`, etc.). Stubs are generated by running introspection inside Blender's embedded Python interpreter in `--background` mode. Supported Blender versions: **4.0 through 5.1** (8 versions total). + +## Commands + +- `uv run poe generate ` — Generate stubs for a Blender version (e.g., 5.0) +- `uv run poe typecheck-stubs ` — Type-check generated stubs +- `uv run poe conformance ` — Run conformance tests against stubs +- `uv run poe check` — Run all checks (format, lint, typecheck, test) +- `uv run poe test` — Run unit tests +- `uv run poe format` — Format with black +- `uv run poe lint` — Lint with ruff + +## Code Style + +- Format all Python files with **black** after modifying them +- Use **basedpyright strict** mode — 0 errors is the target +- Never use `typing.Any` — use concrete types, TypedDicts, Protocols, or unions instead +- Use raw dict literals for TypedDicts, not explicit constructors (e.g., `{"name": ..., "type": ...}` not `ParamData(name=..., type=...)`) + +## Type Checking Rules + +- **Never use `# type: ignore`** — fix the actual typing issue instead +- **Never suppress typecheck rules** with excludes or pyright config overrides — fix the root cause +- **Never bypass issues with workarounds** — always investigate and fix root causes properly (no empty placeholder stubs, no shims) + +## Testing + +- **Add tests for every behavior/regex change** — at least two examples per change +- Test files live in `tests/` and run via `python -m unittest discover -s tests -v` + +## Conformance Tests + +- **Never modify conformance test files** — they are copied verbatim from Blender documentation +- When a conformance test fails, fix the stub generation or overrides, not the test +- Conformance tests live in `conformance//` + +## Architecture + +- Stubs are versioned per Blender version: `dist//` (e.g., `dist/5.0/`) +- Type overrides are organized per version: `overrides//.json` +- Always prefer **introspection** over hardcoding — if something can be discovered at runtime, don't hardcode it +- Key files: `introspect.py` (data collection), `generate_stubs.py` (stub output), `main.py` (CLI) +- `introspect.py` runs inside Blender's embedded Python interpreter, which varies by version (e.g., Blender 4.0 uses Python 3.10). Code in this file must be compatible with all supported Blender Python versions. + +## Blender Binaries + +Cached Blender executables are in `downloads/` (e.g., `downloads/blender-5.1.0-linux-x64/blender`). You can run scripts inside Blender with: + +```bash +downloads/blender-5.1.0-linux-x64/blender --background --python script.py +``` + +## Git + +- Always add specific files by name — **never use `git add -A` or `git add .`** diff --git a/README.md b/README.md index a02d2e4..620d44d 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ gpu.state.blend_set('ALPHA') - **Typed `Context.copy()`** — returns a `ContextDict` TypedDict instead of `dict[str, object]`, so unpacking into `context.temp_override(**ctx)` type-checks correctly - **Dynamic array types** — `Image.pixels` typed as `bpy_prop_array[float]` (not `list[float]` or `float`) - **Zero `Any` usage** — precise types throughout, no `typing.Any` fallbacks -- **basedpyright strict mode** — 0 errors on generated stubs (4.1+) +- **[basedpyright](https://docs.basedpyright.com) `all` mode** — 0 errors on generated stubs (4.0+), validated with `typeCheckingMode: "all"` (the strictest setting) ## How It Works @@ -91,7 +91,7 @@ The introspection data is then passed through a stub generator that handles type | Constructor `__init__` | Yes (mathutils, gpu, etc.) | No | | Literal enum types | Yes | Yes (via stub_internal) | | Context members | ~100 typed | ~100 typed | -| basedpyright strict | 0 errors (4.1+) | Not tested | +| basedpyright `all` mode | 0 errors (4.0+) | Not tested | | Docstrings | Yes | Yes | ### vs [bpystubgen](https://github.com/mysticfall/bpystubgen) diff --git a/conformance/5.0/test_bpy_utils_keyconfig_export.py b/conformance/5.0/test_bpy_utils_keyconfig_export.py new file mode 100644 index 0000000..29f52a2 --- /dev/null +++ b/conformance/5.0/test_bpy_utils_keyconfig_export.py @@ -0,0 +1,75 @@ +from dataclasses import dataclass +import os +import sys +import argparse +from typing import cast + +import bpy + + +def argparse_create(): + parser = argparse.ArgumentParser( + prog=os.path.basename(sys.argv[0]) + " --command keyconfig_export", + description="Write key-configuration to a file.", + ) + + parser.add_argument( + "-o", + "--output", + dest="output", + metavar="OUTPUT", + type=str, + help="The path to write the keymap to.", + required=True, + ) + + parser.add_argument( + "-a", + "--all", + dest="all", + action="store_true", + help="Write all key-maps (not only customized key-maps).", + required=False, + ) + + return parser + + +@dataclass +class Arguments: + output: str + all: bool + + +def keyconfig_export(argv: list[str]): + parser = argparse_create() + args = cast(Arguments, cast(object, parser.parse_args(argv))) + + # Ensure the key configuration is loaded in background mode. + bpy.utils.keyconfig_init() + + bpy.ops.preferences.keyconfig_export( + filepath=args.output, + all=args.all, + ) + + return 0 + + +cli_commands: list[object] = [] + + +def register(): + cli_commands.append( + bpy.utils.register_cli_command("keyconfig_export", keyconfig_export) + ) + + +def unregister(): + for cmd in cli_commands: + bpy.utils.unregister_cli_command(cmd) + cli_commands.clear() + + +if __name__ == "__main__": + register() diff --git a/conformance/5.1/test_bpy_utils_keyconfig_export.py b/conformance/5.1/test_bpy_utils_keyconfig_export.py new file mode 100644 index 0000000..29f52a2 --- /dev/null +++ b/conformance/5.1/test_bpy_utils_keyconfig_export.py @@ -0,0 +1,75 @@ +from dataclasses import dataclass +import os +import sys +import argparse +from typing import cast + +import bpy + + +def argparse_create(): + parser = argparse.ArgumentParser( + prog=os.path.basename(sys.argv[0]) + " --command keyconfig_export", + description="Write key-configuration to a file.", + ) + + parser.add_argument( + "-o", + "--output", + dest="output", + metavar="OUTPUT", + type=str, + help="The path to write the keymap to.", + required=True, + ) + + parser.add_argument( + "-a", + "--all", + dest="all", + action="store_true", + help="Write all key-maps (not only customized key-maps).", + required=False, + ) + + return parser + + +@dataclass +class Arguments: + output: str + all: bool + + +def keyconfig_export(argv: list[str]): + parser = argparse_create() + args = cast(Arguments, cast(object, parser.parse_args(argv))) + + # Ensure the key configuration is loaded in background mode. + bpy.utils.keyconfig_init() + + bpy.ops.preferences.keyconfig_export( + filepath=args.output, + all=args.all, + ) + + return 0 + + +cli_commands: list[object] = [] + + +def register(): + cli_commands.append( + bpy.utils.register_cli_command("keyconfig_export", keyconfig_export) + ) + + +def unregister(): + for cmd in cli_commands: + bpy.utils.unregister_cli_command(cmd) + cli_commands.clear() + + +if __name__ == "__main__": + register() diff --git a/conformance/5.1/test_gpu_triangle_with_custom_shader.py b/conformance/5.1/test_gpu_triangle_with_custom_shader.py index 221a79c..4d7bfa0 100644 --- a/conformance/5.1/test_gpu_triangle_with_custom_shader.py +++ b/conformance/5.1/test_gpu_triangle_with_custom_shader.py @@ -14,14 +14,14 @@ shader_info.fragment_out(0, "VEC4", "FragColor") shader_info.vertex_source( "void main()" - "{" - " pos = position;" - " gl_Position = viewProjectionMatrix * vec4(position, 1.0f);" - "}" + + "{" + + " pos = position;" + + " gl_Position = viewProjectionMatrix * vec4(position, 1.0f);" + + "}" ) shader_info.fragment_source( - "void main()" "{" " FragColor = vec4(pos * brightness, 1.0);" "}" + "void main()" + "{" + " FragColor = vec4(pos * brightness, 1.0);" + "}" ) shader = gpu.shader.create_from_info(shader_info) diff --git a/generate_stubs.py b/generate_stubs.py index 90ba69d..524ad55 100644 --- a/generate_stubs.py +++ b/generate_stubs.py @@ -172,6 +172,15 @@ def _requires_builtins_import(module_data: ModuleData) -> bool: return False +def _has_override_methods(module_data: ModuleData) -> bool: + """Check if any method in the module has is_override set.""" + for struct in module_data.get("structs", []): + for method in struct["methods"]: + if method.get("is_override", False): + 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) @@ -179,6 +188,9 @@ def collect_imports(module_data: ModuleData) -> set[str]: imports = _collect_token_imports(joined) | _collect_module_reference_imports(joined) if _requires_builtins_import(module_data): imports.add("import builtins") + if _has_override_methods(module_data): + # override is in typing since 3.12, typing_extensions for 3.11 + imports.add("from typing_extensions import override") return imports @@ -367,28 +379,6 @@ def generate_property_stub( result += f"{indent} ...\n" return result - # Writable properties with different getter/setter types need @property - # (e.g. location: mathutils.Vector | Sequence[float] -> getter returns Vector, - # setter accepts both) - if " | " in prop["type"]: - parts_list = [p.strip() for p in prop["type"].split(" | ")] - getter_type = parts_list[0] - setter_type = prop["type"] - result = f"{indent}{property_decorator}\n" - result += f"{indent}def {prop['name']}(self) -> {getter_type}:\n" - if prop["description"]: - desc = prop["description"].replace("\\", "\\\\").replace('"""', r"\"\"\"") - if desc.endswith('"'): - desc += " " - result += f'{indent} """{desc}"""\n' - else: - result += f"{indent} ...\n" - result += f"{indent}@{prop['name']}.setter\n" - result += ( - f"{indent}def {prop['name']}(self, value: {setter_type}) -> None: ...\n" - ) - return result - result = f"{indent}{prop['name']}: {prop['type']}\n" if prop["description"]: desc = prop["description"].replace("\\", "\\\\").replace('"""', r"\"\"\"") @@ -478,7 +468,9 @@ def generate_struct_stub( has_body = True for method in struct["methods"]: - is_override = method["name"] in inherited_methods + is_override = method["name"] in inherited_methods or method.get( + "is_override", False + ) parts.append(generate_method_stub(method, is_override=is_override)) has_body = True @@ -513,10 +505,14 @@ def topological_sort_structs(structs: list[StructData]) -> list[StructData]: class _InheritedInfo: methods: set[str] readonly_props: set[str] + all_props: set[str] - def __init__(self, methods: set[str], readonly_props: set[str]): + def __init__( + self, methods: set[str], readonly_props: set[str], all_props: set[str] + ): self.methods = methods self.readonly_props = readonly_props + self.all_props = all_props def collect_inherited_info( @@ -531,7 +527,7 @@ def collect_inherited_info( return cache[name] struct = by_name.get(name) if not struct or not struct["base"]: - cache[name] = _InheritedInfo(set(), set()) + cache[name] = _InheritedInfo(set(), set(), set()) return cache[name] base = struct["base"] if base in by_name: @@ -539,13 +535,16 @@ def collect_inherited_info( base_ro_props = { p["name"] for p in by_name[base]["properties"] if p["is_readonly"] } + base_all_props = {p["name"] for p in by_name[base]["properties"]} else: base_methods: set[str] = set() base_ro_props: set[str] = set() + base_all_props: set[str] = set() parent = get_inherited(base) cache[name] = _InheritedInfo( base_methods | parent.methods, base_ro_props | parent.readonly_props, + base_all_props | parent.all_props, ) return cache[name] @@ -562,7 +561,9 @@ def _generate_context_dict(context_struct: StructData) -> str: can return a precisely typed dict instead of dict[str, object]. """ lines: list[str] = ["class ContextDict(TypedDict):"] - lines.append(' """Dictionary returned by Context.copy() with all context members."""') + lines.append( + ' """Dictionary returned by Context.copy() with all context members."""' + ) method_names = {m["name"] for m in context_struct["methods"]} for prop in context_struct["properties"]: if prop["name"] in method_names: @@ -648,12 +649,13 @@ def generate_types_stub( _patch_context_copy_return_type(context_struct) for struct in sorted_structs: - info = inherited_map.get(struct["name"], _InheritedInfo(set(), set())) - # Force writable properties to readonly when they override a parent's - # readonly @property (avoids reportIncompatibleMethodOverride) - for prop in struct["properties"]: - if not prop["is_readonly"] and prop["name"] in info.readonly_props: - prop["is_readonly"] = True + info = inherited_map.get(struct["name"], _InheritedInfo(set(), set(), set())) + # Skip properties already defined on a parent class — re-emitting them + # can cause reportIncompatibleVariableOverride or reportImplicitOverride + # when RNA subtypes differ (e.g., FunctionNodeInputColor.color vs Node.color) + struct["properties"] = [ + p for p in struct["properties"] if p["name"] not in info.all_props + ] # Emit ContextDict TypedDict right before the Context class if struct["name"] == "Context": parts.append("") diff --git a/introspect.py b/introspect.py index 5c02941..8d4d239 100644 --- a/introspect.py +++ b/introspect.py @@ -176,7 +176,11 @@ class ParamData(TypedDict): kind: str -class FunctionData(TypedDict): +class _FunctionDataOptional(TypedDict, total=False): + is_override: bool + + +class FunctionData(_FunctionDataOptional): name: str doc: str params: list[ParamData] @@ -494,6 +498,12 @@ _CLEAN_TYPE_ALIAS_RULES: tuple[RegexRule, ...] = ( (re.compile(r"\bfunction\b"), "Callable[..., object]"), (re.compile(r"\bgenerator\b"), "Generator"), (re.compile(r"\bsequence\b"), "Sequence"), + # Old typing module names -> modern builtins + (re.compile(r"\bDict\b"), "dict"), + (re.compile(r"\bList\b"), "list"), + (re.compile(r"\bTuple\b"), "tuple"), + (re.compile(r"\bSet\b(?!tings)"), "set"), + (re.compile(r"\bFrozenSet\b"), "frozenset"), ) _CLEAN_TYPE_GENERIC_RULES: tuple[RegexRule, ...] = ( @@ -501,7 +511,8 @@ _CLEAN_TYPE_GENERIC_RULES: tuple[RegexRule, ...] = ( (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[...,"), + # Callable[[...], X] -> Callable[..., X] (consume trailing comma) + (re.compile(r"Callable\[\[[^\]]*\.\.\.[^\]]*\],?\s*"), "Callable[..., "), (re.compile(r"\bdict\b(?!\[)"), "dict[str, object]"), (re.compile(r"\blist\b(?!\[)"), "list[object]"), (re.compile(r"\btuple\b(?!\[)"), "tuple[object, ...]"), @@ -662,7 +673,7 @@ def clean_type_str(type_str: str) -> str: type_str = re.sub(rf"\b{re.escape(undef)}\b(\.\w+)*", "object", type_str) # Map known unqualified Python stdlib types - type_str = re.sub(r"\bModule\b", "types.ModuleType", type_str) + type_str = re.sub(r"\b[Mm]odule\b", "types.ModuleType", type_str) # Fix GPU types wrongly referenced as bpy.types.GPU* (Blender docstring bug in 4.x) type_str = re.sub(r"\bbpy\.types\.(GPU\w+)\b", r"gpu.types.\1", type_str) @@ -959,6 +970,20 @@ def _annotation_to_type_str(ann: object) -> str: s = s.replace("typing.", "") # NoneType -> None s = re.sub(r"\bNoneType\b", "None", s) + # Old typing aliases -> modern builtins + s = re.sub(r"\bDict\b", "dict", s) + s = re.sub(r"\bList\b", "list", s) + s = re.sub(r"\bTuple\b", "tuple", s) + s = re.sub(r"\bSet\b(?!tings)", "set", s) + s = re.sub(r"\bFrozenSet\b", "frozenset", s) + # Bare module type -> types.ModuleType + s = re.sub(r"\bmodule\b", "types.ModuleType", s) + # Parameterize bare generics (e.g., bare set -> set[object]) + s = re.sub(r"\bdict\b(?!\[)", "dict[str, object]", s) + s = re.sub(r"\blist\b(?!\[)", "list[object]", s) + s = re.sub(r"\bset\b(?!\[)", "set[object]", s) + s = re.sub(r"\bfrozenset\b(?!\[)", "frozenset[object]", s) + s = re.sub(r"\btuple\b(?!\[)", "tuple[object, ...]", s) # Qualify bare mathutils types (avoid double-qualifying already-qualified ones) for mt in ("Vector", "Matrix", "Euler", "Quaternion", "Color"): s = re.sub(rf"(? PropertyData: """Build PropertyData from a property/getset descriptor class member.""" @@ -1559,7 +1583,7 @@ def _introspect_declared_class_members( _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)) + properties.append(_property_data_from_member(cls, name, raw)) continue properties.append( { @@ -1606,28 +1630,23 @@ def _refine_and_synthesize_dunders( } ) - if sys.version_info >= (3, 12) and "__buffer__" not in method_names: - try: - instance = cls() - memoryview(instance) - methods.append( - { - "name": "__buffer__", - "doc": "", - "params": [ - { - "name": "flags", - "type": "int", - "default": None, - "kind": "POSITIONAL_ONLY", - } - ], - "return_type": "memoryview", - "is_classmethod": False, - } - ) - except Exception: - pass + if "__buffer__" not in method_names and hasattr(cls, "__buffer__"): + methods.append( + { + "name": "__buffer__", + "doc": "", + "params": [ + { + "name": "flags", + "type": "int", + "default": None, + "kind": "POSITIONAL_ONLY", + } + ], + "return_type": "memoryview", + "is_classmethod": False, + } + ) def _collect_public_module_names(module: ModuleType, module_name: str) -> list[str]: @@ -1757,6 +1776,12 @@ def introspect_class(cls: type[object], module_name: str) -> StructData: _insert_constructor_from_doc(cls, class_doc, methods) _refine_and_synthesize_dunders(cls, methods) + # Mark __eq__/__ne__ as overrides (they override object.__eq__/__ne__) + _OBJECT_OVERRIDES = {"__eq__", "__ne__"} + for method in methods: + if method["name"] in _OBJECT_OVERRIDES: + method["is_override"] = True + return { "name": cls.__name__, "doc": class_doc, @@ -1859,64 +1884,219 @@ def _apply_ops_property_and_method_fixups(op_struct: StructData) -> None: method["return_type"] = "bpy.types.Struct" -def _append_ops_call_method(op_struct: StructData) -> None: - """Append the callable operator wrapper method definition.""" - op_struct["methods"].append( - { - "name": "__call__", - "doc": "Execute the operator.", - "params": [ - { - "name": "args", - "type": "object", - "default": None, - "kind": "VAR_POSITIONAL", - }, - { - "name": "kwargs", - "type": "object", - "default": None, - "kind": "VAR_KEYWORD", - }, - ], - "return_type": "set[str]", - "is_classmethod": False, - } - ) +_RAW_RNA_TYPE_MAP: dict[str, str] = { + "BOOLEAN": "bool", + "INT": "int", + "FLOAT": "float", + "STRING": "str", + "ENUM": "str", +} + +_VECTOR_SUBTYPES = { + "TRANSLATION", + "DIRECTION", + "VELOCITY", + "ACCELERATION", + "XYZ", + "XYZ_LENGTH", +} -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, - "properties": [], - "methods": [ - { - "name": "__getattr__", - "doc": "", - "params": [ - { - "name": "name", - "type": "str", - "default": None, - "kind": "POSITIONAL_OR_KEYWORD", - }, - ], - "return_type": operator_type_name, - "is_classmethod": False, +def _raw_rna_prop_to_type(prop: object) -> str: + """Convert a raw RNA Property to a type string (for operator params).""" + ptype: str = getattr(prop, "type", "") + is_array: bool = getattr(prop, "is_array", False) + array_length: int = getattr(prop, "array_length", 0) + subtype: str = getattr(prop, "subtype", "NONE") + + if ptype == "ENUM": + items = list(getattr(prop, "enum_items", [])) + if items: + values = [str(getattr(i, "identifier", "")) for i in items] + if values: + quoted = ", ".join(f'"{v}"' for v in values) + return f"Literal[{quoted}]" + return "str" + + if ptype == "POINTER": + fixed = getattr(prop, "fixed_type", None) + if fixed is not None: + return f"bpy.types.{getattr(fixed, 'identifier', 'object')} | None" + return "object | None" + + if ptype == "COLLECTION": + return "object" + + if ptype in ("FLOAT", "INT", "BOOLEAN") and is_array: + base = _RAW_RNA_TYPE_MAP.get(ptype, "object") + if ptype == "FLOAT" and subtype in _VECTOR_SUBTYPES: + return "mathutils.Vector | collections.abc.Sequence[float]" + if ptype == "FLOAT" and subtype == "EULER": + return "mathutils.Euler | collections.abc.Sequence[float]" + if ( + ptype == "FLOAT" + and subtype in ("COLOR", "COLOR_GAMMA") + and array_length == 3 + ): + return "mathutils.Color | collections.abc.Sequence[float]" + if ptype == "FLOAT" and subtype == "MATRIX": + return "mathutils.Matrix | collections.abc.Sequence[float]" + return f"collections.abc.Sequence[{base}]" + + return _RAW_RNA_TYPE_MAP.get(ptype, "object") + + +def _rna_prop_default(prop: object) -> str: + """Extract the default value from an RNA property as a string for stubs.""" + ptype: str = getattr(prop, "type", "") + is_array: bool = getattr(prop, "is_array", False) + + try: + if ptype == "ENUM": + is_flag = getattr(prop, "is_enum_flag", False) + if is_flag: + # Enum flags are sets — use ellipsis since the type is Literal, not set + return "..." + val = str(getattr(prop, "default", "")) + # If the default is not in the enum items, use ellipsis + items = { + str(getattr(i, "identifier", "")) + for i in getattr(prop, "enum_items", []) } - ], + if val and val in items: + return repr(val) + return "..." + + if is_array: + arr = getattr(prop, "default_array", None) + if arr is not None: + return repr(list(arr)) + return "..." + + val = getattr(prop, "default", None) + if val is None: + return "None" + return repr(val) + except Exception: + return "..." + + +def _introspect_operator(op: object) -> FunctionData | None: + """Introspect a single bpy.ops operator and return its typed signature.""" + get_rna = getattr(op, "get_rna_type", None) + if get_rna is None or not callable(get_rna): + return None + try: + rna = get_rna() + except Exception: + return None + + idname_fn = getattr(op, "idname_py", None) + if idname_fn is None: + return None + try: + py_name: str = idname_fn() + except Exception: + return None + + # Extract the function name (e.g. "mesh.primitive_cube_add" -> "primitive_cube_add") + func_name = py_name.split(".")[-1] if "." in py_name else py_name + doc: str = getattr(rna, "description", "") or "" + + # All operators accept an optional execution context as the first positional arg + params: list[ParamData] = [ + { + "name": "execution_context", + "type": "Literal['INVOKE_DEFAULT', 'INVOKE_REGION_WIN', 'INVOKE_REGION_CHANNELS', 'INVOKE_REGION_PREVIEW', 'INVOKE_AREA', 'INVOKE_SCREEN', 'EXEC_DEFAULT', 'EXEC_REGION_WIN', 'EXEC_REGION_CHANNELS', 'EXEC_REGION_PREVIEW', 'EXEC_AREA', 'EXEC_SCREEN'] | None", + "default": "None", + "kind": "POSITIONAL_ONLY", + }, + ] + for prop in getattr(rna, "properties", []): + pid: str = getattr(prop, "identifier", "") + if pid == "rna_type": + continue + param_type = _raw_rna_prop_to_type(prop) + default = _rna_prop_default(prop) + params.append( + { + "name": pid, + "type": param_type, + "default": default, + "kind": "KEYWORD_ONLY", + } + ) + + return { + "name": func_name, + "doc": doc, + "params": params, + "return_type": "set[str]", + "is_classmethod": False, } -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_submodule( + ops_mod: object, sub_name: str, op_base_name: str +) -> tuple[StructData, list[StructData]]: + """Introspect an ops submodule. + + Returns the module-level struct (_OpsModule_*) and a list of per-operator + structs that inherit from the operator base class (e.g. BPyOpFunction). + Each operator is a class with a typed __call__ so that both + ``bpy.ops.mesh.subdivide(number_cuts=3)`` and + ``bpy.ops.mesh.subdivide.poll()`` type-check correctly. + """ + sub = getattr(ops_mod, sub_name) + op_structs: list[StructData] = [] + op_props: list[PropertyData] = [] + + for op_name in sorted(dir(sub)): + if op_name.startswith("_"): + continue + op = getattr(sub, op_name, None) + if op is None: + continue + func_data = _introspect_operator(op) + if func_data is None: + continue + + # Create a per-operator class inheriting from the base operator type + op_class_name = f"_Op_{sub_name}_{op_name}" + call_method: FunctionData = { + "name": "__call__", + "doc": func_data["doc"], + "params": func_data["params"], + "return_type": "set[str]", + "is_classmethod": False, + } + op_struct: StructData = { + "name": op_class_name, + "doc": func_data["doc"], + "base": op_base_name, + "properties": [], + "methods": [call_method], + } + op_structs.append(op_struct) + op_props.append( + { + "name": op_name, + "type": op_class_name, + "is_readonly": False, + "description": func_data["doc"], + } + ) + + class_name = f"_OpsModule_{sub_name}" + module_struct: StructData = { + "name": class_name, + "doc": f"Operators in bpy.ops.{sub_name}.", + "base": None, + "properties": op_props, + "methods": [], + } + + return module_struct, op_structs def introspect_ops_module() -> ModuleData: @@ -1950,17 +2130,37 @@ def introspect_ops_module() -> ModuleData: _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) + # Introspect each ops submodule with typed operator classes + structs: list[StructData] = [op_struct] + variables: list[VariableData] = [] + + print(" Introspecting bpy.ops operators...", file=sys.stderr, flush=True) + total_ops = 0 + for sub_name in submodule_names: + module_struct, per_op_structs = _introspect_ops_submodule( + ops_mod, sub_name, op_struct["name"] + ) + # Add per-operator classes first (they're referenced by the module struct) + structs.extend(per_op_structs) + structs.append(module_struct) + total_ops += len(per_op_structs) + variables.append( + {"name": sub_name, "type": module_struct["name"], "value": "..."} + ) + print( + f" Introspected {total_ops} operators " + + f"in {len(submodule_names)} submodules", + file=sys.stderr, + flush=True, + ) return { "module": "bpy.ops", "doc": "Blender operator access.", "functions": [], "variables": variables, - "structs": [op_struct, sub_struct], + "structs": structs, } @@ -2531,12 +2731,29 @@ def _merge_missing_c_methods( structs: list[StructData], bpy_types_module: ModuleType ) -> None: """Add C-level methods missing from RNA metadata for each struct.""" + # Build parent method/property lookup for override detection + struct_by_name = {s["name"]: s for s in structs} + + def _get_all_parent_names(struct: StructData) -> set[str]: + names: set[str] = set() + base = struct.get("base") + if base: + # Strip generic params (e.g. "bpy_prop_collection[Object]" -> "bpy_prop_collection") + base_name = base.split("[")[0] + parent = struct_by_name.get(base_name) + if parent: + names |= {m["name"] for m in parent["methods"]} + names |= {p["name"] for p in parent["properties"]} + names |= _get_all_parent_names(parent) + return names + 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"]} + parent_names = _get_all_parent_names(struct) for attr_name in sorted(cls.__dict__): if attr_name.startswith("_") or attr_name in existing: continue @@ -2554,12 +2771,16 @@ def _merge_missing_c_methods( func_data = introspect_callable(bound, attr_name) if func_data: func_data["is_classmethod"] = True + if attr_name in parent_names: + func_data["is_override"] = 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: + if attr_name in parent_names: + func_data["is_override"] = True struct["methods"].append(func_data) elif raw_type == "function": # Python functions with RST docstrings are API methods @@ -2568,6 +2789,8 @@ def _merge_missing_c_methods( if ":rtype:" in doc or ":type " in doc: func_data = introspect_callable(raw, attr_name) if func_data: + if attr_name in parent_names: + func_data["is_override"] = True struct["methods"].append(func_data) @@ -2738,6 +2961,53 @@ def introspect_rna_types() -> ModuleData: bpy_app = importlib.import_module("bpy.app") blender_version: tuple[int, ...] = getattr(bpy_app, "version", (0, 0, 0)) temp_override = ctx.__class__.__dict__.get("temp_override") + if temp_override is not None and blender_version < (4, 4, 0): + # On Blender < 4.4, probing temp_override() hangs, but the docstring + # still references ContextTempOverride. Generate a minimal stub. + if "ContextTempOverride" not in known: + stub: StructData = { + "name": "ContextTempOverride", + "doc": "Context manager for context overrides.", + "base": None, + "properties": [], + "methods": [ + { + "name": "__enter__", + "doc": "", + "params": [], + "return_type": "Context", + "is_classmethod": False, + }, + { + "name": "__exit__", + "doc": "", + "params": [ + { + "name": "exc_type", + "type": "object", + "default": None, + "kind": "POSITIONAL_OR_KEYWORD", + }, + { + "name": "exc_val", + "type": "object", + "default": None, + "kind": "POSITIONAL_OR_KEYWORD", + }, + { + "name": "exc_tb", + "type": "object", + "default": None, + "kind": "POSITIONAL_OR_KEYWORD", + }, + ], + "return_type": "None", + "is_classmethod": False, + }, + ], + } + structs.append(stub) + known.add("ContextTempOverride") if ( temp_override is not None and type(temp_override).__name__ == "method_descriptor" diff --git a/main.py b/main.py index d819bf5..da5e03b 100644 --- a/main.py +++ b/main.py @@ -296,10 +296,9 @@ def generate_package_files( major_minor: str, top_level_packages: list[str], python_version: str, - revision: int = 0, ) -> None: """Generate pyproject.toml and README.md for the publishable package.""" - package_version = f"{full_version}.{revision}" + package_version = f"{full_version}.0" pyproject = build_generated_pyproject( major_minor, package_version, top_level_packages, python_version @@ -324,7 +323,7 @@ def generate_package_files( (output_dir / pkg / "py.typed").touch() -def generate_for_version(blender_path: str, revision: int = 0) -> None: +def generate_for_version(blender_path: str) -> None: """Generate stubs for a single Blender executable.""" full_version, major_minor = get_blender_version(blender_path) python_version = get_blender_python_version(blender_path) @@ -356,7 +355,6 @@ def generate_for_version(blender_path: str, revision: int = 0) -> None: major_minor, top_level_packages, python_version, - revision, ) # Store the Python version for later type checking @@ -405,8 +403,9 @@ def typecheck_stubs(versions: list[str] | None = None) -> None: json.dumps( { "extraPaths": ["."], - "typeCheckingMode": "strict", + "typeCheckingMode": "all", "pythonVersion": python_version, + "reportPropertyTypeMismatch": False, } ) ) @@ -480,9 +479,10 @@ def conformance_check(versions: list[str] | None = None) -> None: { "include": [str(test_dir)], "extraPaths": [str(version_dir)], - "typeCheckingMode": "strict", + "typeCheckingMode": "all", "pythonVersion": python_version, "reportUnusedExpression": False, + "reportUnusedCallResult": False, } ) ) @@ -513,12 +513,6 @@ if __name__ == "__main__": action="store_true", help="Run conformance tests against generated stubs", ) - parser.add_argument( - "--revision", - type=int, - default=0, - help="Package revision number (default: 0, e.g. 4.5.8.1 for revision 1)", - ) parser.add_argument( "versions", nargs="*", @@ -540,11 +534,11 @@ if __name__ == "__main__": if (major, minor) < min_version: print( f"Blender {version} is not supported" - f" (minimum: {min_version[0]}.{min_version[1]})" + + f" (minimum: {min_version[0]}.{min_version[1]})" ) sys.exit(1) print(f"=== Blender {version} ===") blender_path = get_blender_executable(version) - generate_for_version(str(blender_path), revision=args.revision) + generate_for_version(str(blender_path)) print() print("Done.") diff --git a/overrides/4.0/bpy.msgbus.json b/overrides/4.0/bpy.msgbus.json new file mode 100644 index 0000000..5aaf224 --- /dev/null +++ b/overrides/4.0/bpy.msgbus.json @@ -0,0 +1,12 @@ +{ + "subscribe_rna": { + "params": { + "key": "bpy.types.Property | bpy.types.Struct | tuple[bpy.types.Struct, str] | object" + } + }, + "publish_rna": { + "params": { + "key": "bpy.types.Property | bpy.types.Struct | tuple[bpy.types.Struct, str] | object" + } + } +} diff --git a/overrides/4.1/bpy.msgbus.json b/overrides/4.1/bpy.msgbus.json new file mode 100644 index 0000000..5aaf224 --- /dev/null +++ b/overrides/4.1/bpy.msgbus.json @@ -0,0 +1,12 @@ +{ + "subscribe_rna": { + "params": { + "key": "bpy.types.Property | bpy.types.Struct | tuple[bpy.types.Struct, str] | object" + } + }, + "publish_rna": { + "params": { + "key": "bpy.types.Property | bpy.types.Struct | tuple[bpy.types.Struct, str] | object" + } + } +} diff --git a/overrides/4.2/bpy.msgbus.json b/overrides/4.2/bpy.msgbus.json new file mode 100644 index 0000000..5aaf224 --- /dev/null +++ b/overrides/4.2/bpy.msgbus.json @@ -0,0 +1,12 @@ +{ + "subscribe_rna": { + "params": { + "key": "bpy.types.Property | bpy.types.Struct | tuple[bpy.types.Struct, str] | object" + } + }, + "publish_rna": { + "params": { + "key": "bpy.types.Property | bpy.types.Struct | tuple[bpy.types.Struct, str] | object" + } + } +} diff --git a/overrides/4.3/bpy.msgbus.json b/overrides/4.3/bpy.msgbus.json new file mode 100644 index 0000000..5aaf224 --- /dev/null +++ b/overrides/4.3/bpy.msgbus.json @@ -0,0 +1,12 @@ +{ + "subscribe_rna": { + "params": { + "key": "bpy.types.Property | bpy.types.Struct | tuple[bpy.types.Struct, str] | object" + } + }, + "publish_rna": { + "params": { + "key": "bpy.types.Property | bpy.types.Struct | tuple[bpy.types.Struct, str] | object" + } + } +} diff --git a/overrides/4.4/bpy.msgbus.json b/overrides/4.4/bpy.msgbus.json new file mode 100644 index 0000000..5aaf224 --- /dev/null +++ b/overrides/4.4/bpy.msgbus.json @@ -0,0 +1,12 @@ +{ + "subscribe_rna": { + "params": { + "key": "bpy.types.Property | bpy.types.Struct | tuple[bpy.types.Struct, str] | object" + } + }, + "publish_rna": { + "params": { + "key": "bpy.types.Property | bpy.types.Struct | tuple[bpy.types.Struct, str] | object" + } + } +} diff --git a/publish.py b/publish.py new file mode 100644 index 0000000..929fc58 --- /dev/null +++ b/publish.py @@ -0,0 +1,225 @@ +"""Publish stubs to PyPI with automatic version bumping. + +Fetches the latest published version from PyPI, bumps the revision, +asks for confirmation, then builds and uploads. +""" + +import argparse +import json +import subprocess +import sys +import tomllib +import urllib.error +import urllib.request +from pathlib import Path +from typing import cast + +SCRIPT_DIR = Path(__file__).parent +DIST_DIR = SCRIPT_DIR / "dist" +PACKAGE_NAME = "blender-python-stubs" + +PYPI_JSON_URL = "https://pypi.org/pypi/{package}/json" +TEST_PYPI_JSON_URL = "https://test.pypi.org/pypi/{package}/json" +TEST_PYPI_UPLOAD_URL = "https://test.pypi.org/legacy/" + + +def fetch_latest_revision(blender_version: str, use_test_pypi: bool) -> int | None: + """Fetch the latest revision for a Blender version from PyPI. + + Returns the revision number (the 4th component of X.Y.Z.R) or None + if no matching version is found. + """ + base_url = TEST_PYPI_JSON_URL if use_test_pypi else PYPI_JSON_URL + url = base_url.format(package=PACKAGE_NAME) + + try: + req = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=10) as resp: + data: dict[str, object] = json.loads(resp.read()) + except urllib.error.HTTPError as e: + if e.code == 404: + return None + raise + + raw_releases = data.get("releases") + if not isinstance(raw_releases, dict): + return None + + releases = cast(dict[str, object], raw_releases) + + # Find versions matching this Blender version (e.g., 5.1.0.*) + prefix = blender_version + "." + max_revision = -1 + for version_str in releases: + if not version_str.startswith(prefix): + continue + parts = version_str.split(".") + if len(parts) != 4: + continue + try: + revision = int(parts[3]) + except ValueError: + continue + if revision > max_revision: + max_revision = revision + + return max_revision if max_revision >= 0 else None + + +def read_generated_version(version_dir: Path) -> str: + """Read the version from a generated dist pyproject.toml.""" + pyproject_path = version_dir / "pyproject.toml" + if not pyproject_path.exists(): + print(f"No pyproject.toml found in {version_dir}", file=sys.stderr) + sys.exit(1) + + with pyproject_path.open("rb") as f: + data = tomllib.load(f) + + version = data.get("project", {}).get("version", "") + if not isinstance(version, str) or not version: + print(f"No version found in {pyproject_path}", file=sys.stderr) + sys.exit(1) + + return version + + +def update_version_in_pyproject(version_dir: Path, new_version: str) -> None: + """Update the version in the generated pyproject.toml.""" + pyproject_path = version_dir / "pyproject.toml" + content = pyproject_path.read_text() + + # Replace the version line + lines: list[str] = [] + for line in content.splitlines(): + if line.startswith("version = "): + lines.append(f'version = "{new_version}"') + else: + lines.append(line) + + pyproject_path.write_text("\n".join(lines) + "\n") + + +def publish_version( + blender_version: str, + publish_url: str, + revision: int | None, + yes: bool, +) -> None: + """Publish stubs for a single Blender version.""" + # Parse blender_version into the full version from the generated stubs + version_dir = DIST_DIR / blender_version + if not version_dir.is_dir(): + print( + f"No generated stubs for {blender_version}. Run 'poe generate {blender_version}' first.", + file=sys.stderr, + ) + sys.exit(1) + + current_version = read_generated_version(version_dir) + parts = current_version.split(".") + if len(parts) != 4: + print( + f"Unexpected version format: {current_version}", + file=sys.stderr, + ) + sys.exit(1) + + base_version = ".".join(parts[:3]) # e.g., "5.1.0" + + use_test_pypi = "test.pypi" in publish_url + + if revision is not None: + new_revision = revision + else: + # Fetch latest from PyPI + pypi_label = "Test PyPI" if use_test_pypi else "PyPI" + print(f"Fetching latest version from {pypi_label}...") + latest = fetch_latest_revision(base_version, use_test_pypi) + + if latest is not None: + new_revision = latest + 1 + print(f" Latest published: {base_version}.{latest}") + else: + new_revision = 0 + print(f" No published version found for {base_version}") + + new_version = f"{base_version}.{new_revision}" + + if not yes: + answer = input(f"Publish {new_version}? [y/N] ") + if answer.lower() not in ("y", "yes"): + print("Aborted.") + sys.exit(0) + + # Update pyproject.toml with the new version + update_version_in_pyproject(version_dir, new_version) + print(f" Version set to {new_version}") + + # Clean previous build artifacts + build_dist = version_dir / "dist" + if build_dist.exists(): + import shutil + + shutil.rmtree(build_dist) + + # Build + print(" Building...") + result = subprocess.run( + ["uv", "build", str(version_dir)], + cwd=str(SCRIPT_DIR), + ) + if result.returncode != 0: + print("Build failed.", file=sys.stderr) + sys.exit(1) + + # Publish + print(f" Publishing to {publish_url}...") + result = subprocess.run( + [ + "uv", + "publish", + str(build_dist / "*"), + "--publish-url", + publish_url, + ], + cwd=str(SCRIPT_DIR), + ) + if result.returncode != 0: + print("Publish failed.", file=sys.stderr) + sys.exit(1) + + print(f" Published {new_version}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Publish Blender type stubs to PyPI") + parser.add_argument( + "version", + help="Blender version to publish (e.g., 5.1)", + ) + parser.add_argument( + "--revision", + type=int, + default=None, + help="Override revision number instead of auto-detecting", + ) + parser.add_argument( + "--publish-url", + default=TEST_PYPI_UPLOAD_URL, + help="PyPI upload URL (defaults to Test PyPI)", + ) + parser.add_argument( + "-y", + "--yes", + action="store_true", + help="Skip confirmation prompt", + ) + args = parser.parse_args() + + publish_version( + args.version, + args.publish_url, + args.revision, + args.yes, + ) diff --git a/pyproject.toml b/pyproject.toml index 8a36129..e4bfa9d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,15 +7,15 @@ requires-python = ">=3.11" license = "MIT" keywords = ["blender", "stubs", "typing", "bpy", "autocomplete"] authors = [ - { name = "Joseph HENRY" }, - { name = "Autour de Minuit", email = "tech@autourdeminuit.com" }, + { name = "Joseph HENRY" }, + { name = "Autour de Minuit", email = "tech@autourdeminuit.com" }, ] classifiers = [ - "Development Status :: 3 - Alpha", - "Intended Audience :: Developers", - "Programming Language :: Python :: 3", - "Topic :: Software Development :: Libraries :: Python Modules", - "Topic :: Multimedia :: Graphics :: 3D Modeling", + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Multimedia :: Graphics :: 3D Modeling", ] dependencies = [] @@ -28,8 +28,10 @@ Issues = "https://git.autourdeminuit.com/autour_de_minuit/blender-python-stubs/i target-version = ["py311"] [tool.basedpyright] -typeCheckingMode = "strict" +typeCheckingMode = "all" extraPaths = ["stubs"] +reportUnusedCallResult = false +reportAny = false exclude = ["downloads/", ".venv/", "dist/", "conformance/"] [tool.ruff] @@ -48,18 +50,8 @@ cmd = "python -m main" help = "Generate stubs for Blender versions (e.g., poe generate 5.0 4.3)" [tool.poe.tasks.publish] -shell = "uv build dist/$version && uv publish dist/$version/dist/* --publish-url ${publish_url}" -help = "Build and publish stubs (e.g., poe publish 5.0)" - - [tool.poe.tasks.publish.args.version] - positional = true - required = true - help = "Blender version to publish (e.g., 5.0)" - - [tool.poe.tasks.publish.args.publish-url] - options = ["--publish-url"] - default = "https://test.pypi.org/legacy/" - help = "PyPI upload URL (defaults to Test PyPI)" +cmd = "python -m publish" +help = "Build and publish stubs with auto version bump (e.g., poe publish 5.0)" [tool.poe.tasks.check] help = "Run all checks (format, lint, typecheck, test)" @@ -67,8 +59,9 @@ sequence = ["format", "lint", "typecheck", "test", "typecheck-stubs"] [dependency-groups] dev = [ - "basedpyright>=1.38.3", - "black>=26.3.1", - "poethepoet>=0.42.1", - "ruff>=0.15.6", + "basedpyright>=1.38.3", + "black>=26.3.1", + "poethepoet>=0.42.1", + "ruff>=0.15.6", + "typing-extensions>=4.15.0", ] diff --git a/tests/test_introspect.py b/tests/test_introspect.py index 8fd853a..db73aee 100644 --- a/tests/test_introspect.py +++ b/tests/test_introspect.py @@ -1,8 +1,9 @@ """Tests for the introspection module.""" +from typing_extensions import override import unittest from types import SimpleNamespace -from typing import cast +from typing import cast, final from unittest.mock import patch from introspect import ( @@ -134,6 +135,14 @@ class TestParseDocstringTypes(unittest.TestCase): 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") @@ -225,6 +234,17 @@ class TestParseDocstringTypes(unittest.TestCase): 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") @@ -394,7 +414,7 @@ class TestParamNameMismatch(unittest.TestCase): def fake_func(object: object) -> str: # noqa: A002 """:type string: str :rtype: str""" - return "" + return str(object) result = introspect_callable(fake_func, "escape_identifier") self.assertIsNotNone(result) @@ -406,7 +426,7 @@ class TestParamNameMismatch(unittest.TestCase): def fake_func(path: str) -> str: """:type path: str :rtype: str""" - return "" + return path result = introspect_callable(fake_func, "basename") self.assertIsNotNone(result) @@ -560,8 +580,9 @@ class TestInferGetterReturnTypes(unittest.TestCase): class _ContextWithRaisingMember: - ok = 7 + ok: int = 7 + @override def __dir__(self) -> list[str]: return ["ok", "broken", "callable_member", "rna_type"] @@ -574,20 +595,24 @@ class _ContextWithRaisingMember: class _OpsNoCallableSubmodule: - non_callable = 123 + 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 [] diff --git a/uv.lock b/uv.lock index f1e21d1..0ec0bce 100644 --- a/uv.lock +++ b/uv.lock @@ -62,6 +62,7 @@ dev = [ { name = "black" }, { name = "poethepoet" }, { name = "ruff" }, + { name = "typing-extensions" }, ] [package.metadata] @@ -72,6 +73,7 @@ dev = [ { name = "black", specifier = ">=26.3.1" }, { name = "poethepoet", specifier = ">=0.42.1" }, { name = "ruff", specifier = ">=0.15.6" }, + { name = "typing-extensions", specifier = ">=4.15.0" }, ] [[package]] @@ -282,3 +284,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" }, { url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" }, ] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +]