diff --git a/AGENTS.md b/AGENTS.md index e1fce95..4e069a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ These instructions apply to any AI coding agent working in this repository. -Type stubs generator for the Blender Python API (`bpy`, `mathutils`, `bmesh`, `gpu`, etc.). Introspects Blender's embedded Python in `--background` mode. Supported: Blender 4.0–5.1. +Type stubs generator for the Blender Python API (`bpy`, `mathutils`, `bmesh`, `gpu`, etc.). Introspects Blender's embedded Python in `--background` mode. Supported: Blender 4.0–5.2. ## Commands diff --git a/README.md b/README.md index aeda1be..738359c 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ The version matches your target Blender version: | Blender Version | Install Command | PyPI | | --------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| 5.2 | `pip install "blender-python-stubs>=5.2,<5.3"` | [![PyPI](https://img.shields.io/badge/PyPI-5.2-blue?logo=pypi)](https://pypi.org/project/blender-python-stubs/5.2.1.1/) | | 5.1 | `pip install "blender-python-stubs>=5.1,<5.2"` | [![PyPI](https://img.shields.io/badge/PyPI-5.1-blue?logo=pypi)](https://pypi.org/project/blender-python-stubs/5.1.0.1/) | | 5.0 | `pip install "blender-python-stubs>=5.0,<5.1"` | [![PyPI](https://img.shields.io/badge/PyPI-5.0-blue?logo=pypi)](https://pypi.org/project/blender-python-stubs/5.0.1.0/) | | 4.5 | `pip install "blender-python-stubs>=4.5,<4.6"` | [![PyPI](https://img.shields.io/badge/PyPI-4.5-blue?logo=pypi)](https://pypi.org/project/blender-python-stubs/4.5.8.1/) | @@ -56,6 +57,8 @@ gpu.state.blend_set('ALPHA') - **Context members** — `bpy.context.active_object`, `selected_objects`, `edit_object`, and ~100 other screen context attributes are properly typed - **Constructor signatures** — GPU types, mathutils types (`Vector`, `Matrix`, `Euler`, etc.) have typed `__init__` methods - **Literal enum types** — string parameters like `gpu.state.blend_set(mode)` use `Literal["NONE", "ALPHA", ...]` instead of plain `str` +- **RNA enum properties** — class properties such as `bpy.types.Panel.bl_space_type` derive their `Literal[...]` values directly from Blender RNA metadata +- **Reusable enum aliases** — annotate addon class attributes with `bpy.types.SpaceType` or `bpy.types.RegionType` instead of repeating every allowed literal - **Docstrings** — inline documentation on properties, methods, and functions - **Classmethod detection** — `Matrix.Identity()`, `Vector.Fill()`, etc. correctly typed as `@classmethod` - **Dunder methods** — `__getitem__`, `__len__`, `__add__`, `__matmul__`, and other operators introspected with correct return types (e.g. `Matrix[0]` returns `Vector`) @@ -101,20 +104,20 @@ bpystubgen generates stubs from Blender's Sphinx documentation, not from runtime ## Supported Versions -Blender 4.0 through 5.1. Each Blender version gets its own stub package version. +Blender 4.0 through 5.2. 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 +uv run poe generate 5.2 ``` 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 +uv run poe typecheck-stubs 5.2 ``` ## Contributing diff --git a/generate_stubs.py b/generate_stubs.py index eaa30ba..709f94f 100644 --- a/generate_stubs.py +++ b/generate_stubs.py @@ -603,6 +603,19 @@ def generate_types_stub( structs: list[StructData], python_version: str = "3.11", doc: str = "" ) -> str: """Generate the complete bpy/types.pyi content.""" + enum_aliases: dict[str, str] = {} + enum_property_aliases = { + "bl_space_type": "SpaceType", + "bl_region_type": "RegionType", + } + for struct in structs: + for prop in struct["properties"]: + alias_name = enum_property_aliases.get(prop["name"]) + if alias_name is None or not prop["type"].startswith("Literal["): + continue + enum_aliases.setdefault(alias_name, prop["type"]) + prop["type"] = alias_name + # Collect all type strings to detect needed imports all_type_strs_parts: list[str] = [] for s in structs: @@ -616,7 +629,7 @@ def generate_types_stub( all_type_strs_parts.append(m["return_type"]) all_type_strs = " ".join(all_type_strs_parts) - typing_imports = ["Generic", "TypedDict", "TypeVar"] + typing_imports = ["Generic", "TypedDict", "TypeAlias", "TypeVar"] if "Literal[" in all_type_strs: typing_imports.append("Literal") if re.search(r"\bSelf\b", all_type_strs): @@ -657,6 +670,9 @@ def generate_types_stub( '_T = TypeVar("_T")', ] ) + for alias_name, alias_type in enum_aliases.items(): + parts.append("") + parts.append(f"{alias_name}: TypeAlias = {alias_type}") sorted_structs = topological_sort_structs(structs) inherited_map = collect_inherited_info(sorted_structs) diff --git a/introspect.py b/introspect.py index ac32b62..0d4f01a 100644 --- a/introspect.py +++ b/introspect.py @@ -1555,7 +1555,7 @@ def _property_data_from_member( is_readonly = raw.fset is None else: is_readonly = not _is_getset_writable(cls, name) - prop_type = rtype or "object" + prop_type = rna_enum_literal_type(cls, name) or rtype or "object" result: PropertyData = { "name": name, "type": prop_type, @@ -1567,6 +1567,41 @@ def _property_data_from_member( return result +def rna_enum_literal_type(cls: type[object], name: str) -> str | None: + """Return a Literal annotation for an RNA enum property, when available. + + RNA metadata is the authoritative source for enum identifiers. Blender's + Python docstrings do not consistently include the enum items for class + properties, so relying on the docstring can leave properties such as + ``Panel.bl_space_type`` typed as plain ``str``. + """ + rna = getattr(cls, "bl_rna", None) + if rna is None: + return None + + properties = getattr(rna, "properties", None) + if properties is None: + return None + + try: + prop = properties[name] + except (KeyError, IndexError, TypeError): + return None + + if getattr(prop, "type", None) != "ENUM": + return None + + values = [ + str(getattr(item, "identifier", "")) for item in getattr(prop, "enum_items", []) + ] + values = [value for value in values if value] + if not values: + return None + + quoted = ", ".join(f'"{value}"' for value in values) + return f"Literal[{quoted}]" + + def _introspect_declared_class_members( cls: type[object], ) -> tuple[list[PropertyData], list[FunctionData]]: diff --git a/pyproject.toml b/pyproject.toml index 3b91e25..1185c06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,13 +41,13 @@ exclude = ["dist/", "conformance/"] format = { cmd = "black .", help = "Format all Python files" } lint = { cmd = "ruff check .", help = "Lint all Python files" } typecheck = { cmd = "basedpyright", help = "Run type checker on source code" } -typecheck-stubs = { cmd = "python -m main --typecheck-stubs", help = "Type-check generated stubs (e.g., poe typecheck-stubs 4.0 4.1)" } +typecheck-stubs = { cmd = "python -m main --typecheck-stubs", help = "Type-check generated stubs (e.g., poe typecheck-stubs 5.2)" } conformance = { cmd = "python -m main --conformance", help = "Run conformance tests against generated stubs (e.g., poe conformance 5.0)" } test = { cmd = "python -m unittest discover -s tests -v", help = "Run unit tests" } [tool.poe.tasks.generate] 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.2 5.1)" [tool.poe.tasks.publish] cmd = "python -m publish" diff --git a/tests/test_blender_downloader.py b/tests/test_blender_downloader.py index 56f6e7f..de53dcb 100644 --- a/tests/test_blender_downloader.py +++ b/tests/test_blender_downloader.py @@ -19,6 +19,17 @@ from blender_downloader import ( class TestGetDownloadUrl(unittest.TestCase): + def test_builds_correct_blender_5_2_url(self) -> None: + with patch("blender_downloader.get_platform_suffix", return_value="linux-x64"): + with patch( + "blender_downloader.get_archive_extension", return_value=".tar.xz" + ): + url = get_download_url("5.2.0") + self.assertEqual( + url, + "https://download.blender.org/release/Blender5.2/blender-5.2.0-linux-x64.tar.xz", + ) + def test_builds_correct_url(self) -> None: with patch("blender_downloader.get_platform_suffix", return_value="linux-x64"): with patch( diff --git a/tests/test_generate_stubs.py b/tests/test_generate_stubs.py index 6cef3b6..b5fe586 100644 --- a/tests/test_generate_stubs.py +++ b/tests/test_generate_stubs.py @@ -719,6 +719,41 @@ class TestCollectAllMethods(unittest.TestCase): class TestGenerateTypesStub(unittest.TestCase): + def test_rna_enum_aliases_are_reusable(self) -> None: + structs = [ + { + "name": "Panel", + "doc": "", + "base": "bpy_struct", + "properties": [ + { + "name": "bl_space_type", + "type": 'Literal["EMPTY", "PROPERTIES"]', + "is_readonly": False, + "description": "", + }, + { + "name": "bl_region_type", + "type": 'Literal["WINDOW", "HEADER"]', + "is_readonly": False, + "description": "", + }, + ], + "methods": [], + } + ] + + content = generate_types_stub(structs) + + self.assertIn( + 'SpaceType: TypeAlias = Literal["EMPTY", "PROPERTIES"]', content + ) + self.assertIn( + 'RegionType: TypeAlias = Literal["WINDOW", "HEADER"]', content + ) + self.assertIn("bl_space_type: SpaceType", content) + self.assertIn("bl_region_type: RegionType", content) + def test_includes_header(self) -> None: structs: list[StructData] = [ { diff --git a/tests/test_introspect.py b/tests/test_introspect.py index 0fcc682..129f243 100644 --- a/tests/test_introspect.py +++ b/tests/test_introspect.py @@ -21,6 +21,7 @@ from introspect import ( parse_docstring_types, python_type_name, rna_property_to_type, + rna_enum_literal_type, ) @@ -254,6 +255,31 @@ class TestParseDocstringTypes(unittest.TestCase): self.assertEqual(clean_type_str("SettingsGroup"), "SettingsGroup") +class TestRnaEnumLiteralType(unittest.TestCase): + def test_reads_enum_identifiers_from_rna_metadata(self) -> None: + enum_property = SimpleNamespace( + type="ENUM", + enum_items=[ + SimpleNamespace(identifier="EMPTY"), + SimpleNamespace(identifier="VIEW_3D"), + ], + ) + rna = SimpleNamespace(properties={"bl_space_type": enum_property}) + cls = cast(type[object], SimpleNamespace(bl_rna=rna)) + + self.assertEqual( + rna_enum_literal_type(cls, "bl_space_type"), + 'Literal["EMPTY", "VIEW_3D"]', + ) + + def test_returns_none_for_non_enum_properties(self) -> None: + prop = SimpleNamespace(type="STRING") + rna = SimpleNamespace(properties={"name": prop}) + cls = cast(type[object], SimpleNamespace(bl_rna=rna)) + + self.assertIsNone(rna_enum_literal_type(cls, "name")) + + class TestRefineTypesByContext(unittest.TestCase): def test_bool_vector_default(self) -> None: param_types = {"default": "Sequence"}