Add rna_type/bl_rna, GeometrySet, bpy.ops typing, publish task, and README updates

- Add rna_type and bl_rna as readonly properties on bpy_struct (fixes fake-bpy-module#419)
- Discover non-RNA C classes in bpy.types like GeometrySet (fixes fake-bpy-module#436)
- Introspect bpy.ops operator wrapper with poll(), idname(), get_rna_type(), bl_options
- Handle builtin name shadowing (e.g. bpy.ops.object) with builtins. qualification
- Add poe publish task for building and publishing to PyPI
- Update project URLs to Gitea, improve generated README, add disclaimer
- Fix f-string lint warning and type annotation for introspect_class

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph HENRY 2026-03-26 15:07:35 +01:00
parent 852a5de700
commit 6ea41f3b4d
6 changed files with 253 additions and 22 deletions

View File

@ -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. 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 ## Contributing
Pull requests are welcome. The project uses: Pull requests are welcome. The project uses:
@ -106,16 +120,14 @@ Pull requests are welcome. The project uses:
- `ruff` for linting - `ruff` for linting
```bash ```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) # Run all checks (format, lint, typecheck, tests)
uv run poe check uv run poe check
``` ```
## Disclaimer
This project was vibe coded using [Claude](https://claude.ai) by Anthropic.
## License ## License
[MIT](LICENSE) [MIT](LICENSE)

View File

@ -717,6 +717,21 @@ def generate_module_stub(
class_names = {s["name"] for s in module_data.get("structs", [])} class_names = {s["name"] for s in module_data.get("structs", [])}
result = strip_self_module_prefix(result, module_name, class_names) 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 # Qualify type names that clash with class names in the same module
for clash in clashing_imports: for clash in clashing_imports:
match = re.search(r"from ([\w.]+) import (\w+)", clash) match = re.search(r"from ([\w.]+) import (\w+)", clash)

View File

@ -483,11 +483,12 @@ def clean_type_str(type_str: str) -> str:
r"Sequence[tuple[\1, \2]]", r"Sequence[tuple[\1, \2]]",
type_str, type_str,
) )
# list[X, Y, ...] with >1 type args -> treat as list[X] (drop extras) # list[X, Y, ...] with >1 type args -> treat as list[X] (drop extras)
def _fix_multi_arg_list(m: re.Match[str]) -> str: def _fix_multi_arg_list(m: re.Match[str]) -> str:
inner = m.group(1) inner = m.group(1)
# If it contains nested generics like list[float], keep first one # If it contains nested generics like list[float], keep first one
parts = [] parts: list[str] = []
depth = 0 depth = 0
current: list[str] = [] current: list[str] = []
for ch in inner: 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.""" """Introspect a class (C extension or Python) and return StructData."""
# Determine base class (skip object and internal bases) # Determine base class (skip object and internal bases)
bases = [ bases = [
@ -1216,11 +1217,134 @@ def infer_getter_return_types(functions: list[FunctionData]) -> None:
func["return_type"] = setters[prefix] 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: def introspect_module(module_name: str) -> ModuleData:
"""Introspect a module and return its full metadata as a dict.""" """Introspect a module and return its full metadata as a dict."""
if module_name == "bpy.types": if module_name == "bpy.types":
return introspect_rna_types() return introspect_rna_types()
if module_name == "bpy.ops":
return introspect_ops_module()
module = importlib.import_module(module_name) module = importlib.import_module(module_name)
# Use __all__ as the base, but also include public callables from dir() # 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]) # Also add type aliases (e.g. FCurveKey = Tuple[str, int])
# but not stdlib re-exports (Iterable, Sequence, etc.) # but not stdlib re-exports (Iterable, Sequence, etc.)
elif hasattr(obj, "__origin__") and n not in { elif hasattr(obj, "__origin__") and n not in {
"Callable", "Collection", "Generator", "Iterable", "Callable",
"Iterator", "Mapping", "MutableMapping", "MutableSequence", "Collection",
"MutableSet", "Sequence", "Set", "FrozenSet", "Generator",
"Dict", "List", "Tuple", "Type", "Optional", "Union", "Iterable",
"Iterator",
"Mapping",
"MutableMapping",
"MutableSequence",
"MutableSet",
"Sequence",
"Set",
"FrozenSet",
"Dict",
"List",
"Tuple",
"Type",
"Optional",
"Union",
}: }:
names_set.add(n) names_set.add(n)
public_names: list[str] = sorted(names_set) public_names: list[str] = sorted(names_set)
@ -1282,7 +1420,7 @@ def introspect_module(module_name: str) -> ModuleData:
variables.append( variables.append(
{ {
"name": name, "name": name,
"type": f"TypeAlias", "type": "TypeAlias",
"value": type_repr, "value": type_repr,
} }
) )
@ -1810,7 +1948,26 @@ def introspect_rna_types() -> ModuleData:
# Introspect bpy_struct (base of all RNA types) # Introspect bpy_struct (base of all RNA types)
bpy_struct_cls = getattr(_bpy_types, "bpy_struct", None) bpy_struct_cls = getattr(_bpy_types, "bpy_struct", None)
if bpy_struct_cls is not 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"]: for cls_name in ["bpy_prop_collection", "bpy_prop_array"]:
cls = getattr(_bpy_types, cls_name, None) cls = getattr(_bpy_types, cls_name, None)
@ -1893,6 +2050,24 @@ def introspect_rna_types() -> ModuleData:
struct["properties"].extend(screen_props) struct["properties"].extend(screen_props)
break 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 { return {
"module": "bpy.types", "module": "bpy.types",
"doc": "Blender RNA type definitions.", "doc": "Blender RNA type definitions.",

23
main.py
View File

@ -155,7 +155,7 @@ README_TEMPLATE = """\
Type stubs for Blender {blender_version} Python API. 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 ## Installation
@ -163,16 +163,31 @@ Provides type information for `bpy`, `mathutils`, `bmesh`, `gpu`, `freestyle`, a
pip install "blender-python-stubs>={major_minor},<{next_minor}" 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 ```python
import bpy import bpy
# Your IDE now provides autocomplete and type checking
obj: bpy.types.Object = bpy.context.active_object 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).
""" """

View File

@ -20,9 +20,9 @@ classifiers = [
dependencies = [] dependencies = []
[project.urls] [project.urls]
Homepage = "https://github.com/autourdeminuit/blender-python-stubs" Homepage = "https://git.autourdeminuit.com/autour_de_minuit/blender-python-stubs"
Repository = "https://github.com/autourdeminuit/blender-python-stubs" Repository = "https://git.autourdeminuit.com/autour_de_minuit/blender-python-stubs"
Issues = "https://github.com/autourdeminuit/blender-python-stubs/issues" Issues = "https://git.autourdeminuit.com/autour_de_minuit/blender-python-stubs/issues"
[tool.black] [tool.black]
target-version = ["py311"] target-version = ["py311"]
@ -46,6 +46,20 @@ test = { cmd = "python -m unittest discover -s tests -v", help = "Run unit tests
cmd = "python -m main" cmd = "python -m main"
help = "Generate stubs for Blender versions (e.g., poe generate 5.0 4.3)" 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] [tool.poe.tasks.check]
help = "Run all checks (format, lint, typecheck, test)" help = "Run all checks (format, lint, typecheck, test)"
sequence = ["format", "lint", "typecheck", "test", "typecheck-stubs"] sequence = ["format", "lint", "typecheck", "test", "typecheck-stubs"]

2
uv.lock generated
View File

@ -52,7 +52,7 @@ wheels = [
] ]
[[package]] [[package]]
name = "blender-stubs" name = "blender-python-stubs"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }