diff --git a/README.md b/README.md index 33b4a4c..6a42ac7 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,20 @@ bpystubgen generates stubs from Blender's Sphinx documentation, not from runtime Blender 4.0 through 5.1. Each Blender version gets its own stub package version. +## Generating Stubs + +To generate stubs for a specific Blender version, run: + +```bash +uv run poe generate 5.1 +``` + +This will automatically download the corresponding Blender executable and run the introspection and stub generation process. You can also type-check the generated stubs: + +```bash +uv run poe typecheck-stubs 5.1 +``` + ## Contributing Pull requests are welcome. The project uses: @@ -106,16 +120,14 @@ Pull requests are welcome. The project uses: - `ruff` for linting ```bash -# Generate stubs for a specific version -uv run poe generate 5.1 - -# Type-check generated stubs -uv run poe typecheck-stubs 5.1 - # Run all checks (format, lint, typecheck, tests) uv run poe check ``` +## Disclaimer + +This project was vibe coded using [Claude](https://claude.ai) by Anthropic. + ## License [MIT](LICENSE) diff --git a/generate_stubs.py b/generate_stubs.py index 2625896..1fce837 100644 --- a/generate_stubs.py +++ b/generate_stubs.py @@ -717,6 +717,21 @@ def generate_module_stub( class_names = {s["name"] for s in module_data.get("structs", [])} result = strip_self_module_prefix(result, module_name, class_names) + # If any variable/class name shadows a Python builtin used as a type + # annotation (e.g. bpy.ops has a variable named "object"), qualify it. + _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 + # Qualify type names that clash with class names in the same module for clash in clashing_imports: match = re.search(r"from ([\w.]+) import (\w+)", clash) diff --git a/introspect.py b/introspect.py index fbc2117..54d0dbb 100644 --- a/introspect.py +++ b/introspect.py @@ -483,11 +483,12 @@ def clean_type_str(type_str: str) -> str: 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 = [] + parts: list[str] = [] depth = 0 current: list[str] = [] for ch in inner: @@ -1093,7 +1094,7 @@ def _parse_class_constructor(class_doc: str, cls: type) -> FunctionData | None: } -def introspect_class(cls: type, module_name: str) -> StructData: +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) bases = [ @@ -1216,11 +1217,134 @@ 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. + + 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") + 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] = {} + for method in op_struct["methods"]: + name = method["name"] + if method["return_type"] is not None: + continue + func = getattr(op, name, None) + if func is None or not callable(func): + continue + try: + result = func() + _return_type_fixups[name] = type(result).__name__ + except Exception: + pass + + for method in op_struct["methods"]: + if method["name"] in _return_type_fixups: + method["return_type"] = _return_type_fixups[method["name"]] + + # Fix bl_options type — it's a set of strings at runtime + 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 + 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, + } + ) + + # _OpsSubModule: a class with __getattr__ returning operators. + # Each ops submodule (mesh, object, etc.) is typed as this class. + sub_struct: StructData = { + "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": op_struct["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": "..."}) + + return { + "module": "bpy.ops", + "doc": "Blender operator access.", + "functions": [], + "variables": variables, + "structs": [op_struct, sub_struct], + } + + def introspect_module(module_name: str) -> ModuleData: """Introspect a module and return its full metadata as a dict.""" if module_name == "bpy.types": return introspect_rna_types() + if module_name == "bpy.ops": + return introspect_ops_module() + module = importlib.import_module(module_name) # Use __all__ as the base, but also include public callables from dir() @@ -1243,10 +1367,24 @@ def introspect_module(module_name: str) -> ModuleData: # 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", + "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) @@ -1282,7 +1420,7 @@ def introspect_module(module_name: str) -> ModuleData: variables.append( { "name": name, - "type": f"TypeAlias", + "type": "TypeAlias", "value": type_repr, } ) @@ -1810,7 +1948,26 @@ def introspect_rna_types() -> ModuleData: # Introspect bpy_struct (base of all RNA types) bpy_struct_cls = getattr(_bpy_types, "bpy_struct", None) if bpy_struct_cls is not None: - structs.append(introspect_class(bpy_struct_cls, "bpy.types")) + 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) for cls_name in ["bpy_prop_collection", "bpy_prop_array"]: cls = getattr(_bpy_types, cls_name, None) @@ -1893,6 +2050,24 @@ def introspect_rna_types() -> ModuleData: 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")) + return { "module": "bpy.types", "doc": "Blender RNA type definitions.", diff --git a/main.py b/main.py index 8af432a..fec6aa7 100644 --- a/main.py +++ b/main.py @@ -155,7 +155,7 @@ README_TEMPLATE = """\ Type stubs for Blender {blender_version} Python API. -Provides type information for `bpy`, `mathutils`, `bmesh`, `gpu`, `freestyle`, and other Blender Python modules. +Provides autocomplete, type checking, and inline documentation for `bpy`, `mathutils`, `bmesh`, `gpu`, `freestyle`, and all other Blender Python modules. ## Installation @@ -163,16 +163,31 @@ Provides type information for `bpy`, `mathutils`, `bmesh`, `gpu`, `freestyle`, a pip install "blender-python-stubs>={major_minor},<{next_minor}" ``` -## Usage +## Features -Install alongside your Blender addon project for type checking with mypy, pyright, ty or your IDE. +- Full Blender API coverage (`bpy`, `mathutils`, `bmesh`, `gpu`, `gpu_extras`, `bpy_extras`, `freestyle`, `aud`, `blf`, `bl_math`, `imbuf`, `idprop`) +- Accurate collection types (`BlendDataObjects` instead of generic `bpy_prop_collection`) +- Readonly `@property` decorators for RNA attributes +- Typed context members (`bpy.context.active_object`, `selected_objects`, etc.) +- Constructor signatures for `mathutils`, `gpu`, and other C extension types +- Literal enum types instead of plain `str` +- Zero `typing.Any` usage +- 0 errors in basedpyright strict mode + +## Usage ```python import bpy -# Your IDE now provides autocomplete and type checking obj: bpy.types.Object = bpy.context.active_object +obj.location.x = 1.0 + +bpy.data.objects.new("Cube", bpy.data.meshes.new("Mesh")) ``` + +--- + +Generated by [blender-python-stubs](https://git.autourdeminuit.com/autour_de_minuit/blender-python-stubs). """ diff --git a/pyproject.toml b/pyproject.toml index 25d26fa..76ad3f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,9 +20,9 @@ classifiers = [ dependencies = [] [project.urls] -Homepage = "https://github.com/autourdeminuit/blender-python-stubs" -Repository = "https://github.com/autourdeminuit/blender-python-stubs" -Issues = "https://github.com/autourdeminuit/blender-python-stubs/issues" +Homepage = "https://git.autourdeminuit.com/autour_de_minuit/blender-python-stubs" +Repository = "https://git.autourdeminuit.com/autour_de_minuit/blender-python-stubs" +Issues = "https://git.autourdeminuit.com/autour_de_minuit/blender-python-stubs/issues" [tool.black] target-version = ["py311"] @@ -46,6 +46,20 @@ test = { cmd = "python -m unittest discover -s tests -v", help = "Run unit tests 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)" + [tool.poe.tasks.check] help = "Run all checks (format, lint, typecheck, test)" sequence = ["format", "lint", "typecheck", "test", "typecheck-stubs"] diff --git a/uv.lock b/uv.lock index 0f7ed66..f1e21d1 100644 --- a/uv.lock +++ b/uv.lock @@ -52,7 +52,7 @@ wheels = [ ] [[package]] -name = "blender-stubs" +name = "blender-python-stubs" version = "0.1.0" source = { virtual = "." }