diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e1fce95 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,45 @@ +# blender-python-stubs — AI agent instructions + +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. + +## Commands + +- `uv run poe generate ` — generate stubs (e.g., `5.0`) +- `uv run poe typecheck-stubs ` — type-check generated stubs +- `uv run poe conformance ` — run conformance tests +- `uv run poe check` — format, lint, typecheck, test +- `uv run poe test` / `format` / `lint` — individual steps +- Run a script inside Blender: `downloads/blender--linux-x64/blender --background --python script.py` + +## Code style + +- Format with **black** and lint with **ruff** after editing +- **basedpyright strict**, 0 errors is the target +- Never use `typing.Any` — use concrete types, TypedDicts, Protocols, or unions +- Use raw dict literals for TypedDicts (`{"name": ..., "type": ...}`, not `ParamData(name=..., type=...)`) + +## Never bypass typing issues + +Fix root causes — no `# type: ignore`, no pyright excludes/overrides, no placeholder stubs, no shims. + +## Testing + +- Add tests for every behavior/regex change — at least two examples per change +- Tests live in `tests/`, run via `python -m unittest discover -s tests -v` + +## Conformance tests + +- Never modify files in `conformance//` — copied verbatim from Blender docs +- On failure, fix stub generation or `overrides//.json`, not the test + +## Architecture + +- Prefer **introspection** over hardcoding — if runtime can discover it, don't hardcode +- Key files: `introspect.py` (collects data inside Blender), `generate_stubs.py` (emits stubs), `main.py` (CLI) +- `introspect.py` must work on every Blender Python (4.0 ships Python 3.10) — no newer-version syntax + +## Git + +- Always add specific files by name — **never `git add -A` or `git add .`** diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 72ff12f..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,59 +0,0 @@ -# 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/introspect.py b/introspect.py index 4efcd19..ac32b62 100644 --- a/introspect.py +++ b/introspect.py @@ -2320,14 +2320,22 @@ RNA_TYPE_MAP: dict[str, str] = { } -def rna_property_to_type(prop: object) -> str: - """Map an RNA property to a PEP 484 type annotation string.""" +def rna_property_to_type(prop: object, *, nullable_pointer: bool = True) -> str: + """Map an RNA property to a PEP 484 type annotation string. + + ``nullable_pointer`` controls whether pointer types are widened to ``T | None`` + based on the RNA ``is_never_none`` flag. Set to False for function return + values: Blender's RNA marks most function returns ``is_never_none=False`` + even though they raise on failure rather than returning None. + """ prop_type: str = getattr(prop, "type", "") fixed_type: object = getattr(prop, "fixed_type", None) array_length: int = getattr(prop, "array_length", 0) if prop_type == "pointer" and fixed_type is not None: type_name: str = getattr(fixed_type, "identifier", "object") + if nullable_pointer and not getattr(prop, "is_never_none", False): + return f"{type_name} | None" return type_name if prop_type == "collection" and fixed_type is not None: @@ -2342,6 +2350,18 @@ def rna_property_to_type(prop: object) -> str: element_type: str = getattr(fixed_type, "identifier", "object") return f"bpy_prop_collection[{element_type}]" + if prop_type == "enum": + if getattr(prop, "is_enum_flag", False): + return "set[str]" + # rna_info exposes enum_items as list[tuple[identifier, name, description]]; + # an empty list means a dynamic enum (items computed at runtime). + raw_items = cast("list[tuple[str, str, str]]", getattr(prop, "enum_items", [])) + identifiers = [item[0] for item in raw_items if item] + if identifiers: + quoted = ", ".join(f'"{v}"' for v in identifiers) + return f"Literal[{quoted}]" + return "str" + # Dynamic-length arrays have array_length=0 but is_array=True on the raw # RNA property. rna_info wraps properties in InfoPropertyRNA which stores # the raw prop as bl_prop; fall back to checking the prop itself. @@ -2423,9 +2443,11 @@ def rna_function_to_data(func_info: object) -> FunctionData: return_type: str | None = None if return_values: if len(return_values) == 1: - return_type = rna_property_to_type(return_values[0]) + return_type = rna_property_to_type(return_values[0], nullable_pointer=False) else: - types = [rna_property_to_type(rv) for rv in return_values] + types = [ + rna_property_to_type(rv, nullable_pointer=False) for rv in return_values + ] return_type = f"tuple[{', '.join(types)}]" return { @@ -2694,12 +2716,97 @@ def _rna_struct_to_data( } +_NULLABLE_DOC_HINTS = ("may be none", "can be none", "may be null", "can be null") + + +def description_hints_nullable(description: str) -> bool: + """Return True if the RST description explicitly hints the value may be None.""" + lowered = description.lower() + return any(hint in lowered for hint in _NULLABLE_DOC_HINTS) + + +def _find_view3d_override(ctx: object) -> dict[str, object]: + """Find a (window, area, region) override that simulates a 3D viewport. + + Returns kwargs for ``bpy.context.temp_override``. Empty dict if no 3D + viewport is reachable (degenerate startup state). + """ + wm = getattr(ctx, "window_manager", None) + windows = getattr(wm, "windows", None) if wm is not None else None + if not windows: + return {} + for window in windows: + screen = getattr(window, "screen", None) + areas = getattr(screen, "areas", None) if screen is not None else None + if not areas: + continue + for area in areas: + if getattr(area, "type", None) != "VIEW_3D": + continue + for region in getattr(area, "regions", []) or []: + if getattr(region, "type", None) == "WINDOW": + return {"window": window, "area": area, "region": region} + return {} + + +def _refine_context_nullability(struct: StructData) -> None: + """Strip ``| None`` from Context pointer properties that are never None. + + Blender's RNA reports ``is_never_none=False`` for most ``bpy.context.*`` + pointers, but several (``scene``, ``view_layer``, ``window_manager`` …, + plus UI-structural ones like ``area``, ``region``, ``region_data``) are + populated whenever a script runs from a normal 3D viewport — which is + what Blender's documentation assumes. In ``--background`` mode the + viewport context is empty, so we probe inside a ``temp_override`` that + simulates one. + + Members that stay None even inside the override (``gizmo_group``, + ``asset``, ``region_popup``) are correctly UI-event-dependent and stay + nullable. + + The probe is overridden by Blender's own RST documentation: if the + description explicitly says "may be None" (or similar), the property + stays nullable even when the probe found a value. + """ + bpy = importlib.import_module("bpy") + ctx = getattr(bpy, "context", None) + if ctx is None: + return + override = _find_view3d_override(ctx) + temp_override = getattr(ctx, "temp_override", None) + if not override or temp_override is None: + # Fall back to a plain probe — better than nothing on older Blenders + # or in headless setups without windows. + probe_ctx: object = ctx + cm = None + else: + cm = temp_override(**override) + cm.__enter__() + probe_ctx = bpy.context # ``bpy.context`` reflects the override + try: + for prop in struct["properties"]: + if not prop["type"].endswith(" | None"): + continue + if description_hints_nullable(prop["description"]): + continue + try: + value = getattr(probe_ctx, prop["name"]) + except Exception: + continue + if value is not None: + prop["type"] = prop["type"].removesuffix(" | None") + finally: + if cm is not None: + cm.__exit__(None, None, None) + + def _merge_screen_context_members(structs: list[StructData]) -> None: """Merge dynamic bpy.context members into the Context struct.""" known_types = {s["name"] for s in structs} for struct in structs: if struct["name"] != "Context": continue + _refine_context_nullability(struct) rna_names = {p["name"] for p in struct["properties"]} rna_names |= {m["name"] for m in struct["methods"]} screen_props = introspect_screen_context_members(rna_names) diff --git a/publish.py b/publish.py index 929fc58..2b09812 100644 --- a/publish.py +++ b/publish.py @@ -5,7 +5,9 @@ asks for confirmation, then builds and uploads. """ import argparse +import configparser import json +import os import subprocess import sys import tomllib @@ -23,6 +25,26 @@ TEST_PYPI_JSON_URL = "https://test.pypi.org/pypi/{package}/json" TEST_PYPI_UPLOAD_URL = "https://test.pypi.org/legacy/" +def load_pypirc_token(use_test_pypi: bool) -> str | None: + """Read a token from ~/.pypirc for the matching index, if present. + + `uv publish` does not read ~/.pypirc (twine convention), so we surface it + through UV_PUBLISH_TOKEN. Returns None when no matching token is found. + """ + pypirc = Path.home() / ".pypirc" + if not pypirc.is_file(): + return None + parser = configparser.ConfigParser() + try: + parser.read(pypirc) + except configparser.Error: + return None + section = "testpypi" if use_test_pypi else "pypi" + if not parser.has_section(section): + return None + return parser.get(section, "password", fallback=None) + + def fetch_latest_revision(blender_version: str, use_test_pypi: bool) -> int | None: """Fetch the latest revision for a Blender version from PyPI. @@ -175,6 +197,11 @@ def publish_version( # Publish print(f" Publishing to {publish_url}...") + env = os.environ.copy() + if "UV_PUBLISH_TOKEN" not in env: + token = load_pypirc_token(use_test_pypi) + if token: + env["UV_PUBLISH_TOKEN"] = token result = subprocess.run( [ "uv", @@ -184,6 +211,7 @@ def publish_version( publish_url, ], cwd=str(SCRIPT_DIR), + env=env, ) if result.returncode != 0: print("Publish failed.", file=sys.stderr) diff --git a/tests/test_introspect.py b/tests/test_introspect.py index db73aee..0fcc682 100644 --- a/tests/test_introspect.py +++ b/tests/test_introspect.py @@ -8,6 +8,7 @@ from unittest.mock import patch from introspect import ( FunctionData, + description_hints_nullable, infer_getter_return_types, parse_rst_function_sig, infer_context_member_type, @@ -19,6 +20,7 @@ from introspect import ( introspect_screen_context_members, parse_docstring_types, python_type_name, + rna_property_to_type, ) @@ -650,5 +652,107 @@ class TestRuntimeEdgeCases(unittest.TestCase): self.assertEqual(data["variables"], []) +class TestRnaPropertyToType(unittest.TestCase): + def test_enum_flag_is_set_of_str(self) -> None: + prop = SimpleNamespace(type="enum", is_enum_flag=True, fixed_type=None) + self.assertEqual(rna_property_to_type(prop), "set[str]") + + def test_enum_scalar_with_items_is_literal(self) -> None: + prop = SimpleNamespace( + type="enum", + is_enum_flag=False, + fixed_type=None, + enum_items=[ + ("NONE", "", ""), + ("LEFTMOUSE", "", ""), + ("RIGHTMOUSE", "", ""), + ], + ) + self.assertEqual( + rna_property_to_type(prop), + 'Literal["NONE", "LEFTMOUSE", "RIGHTMOUSE"]', + ) + + def test_enum_scalar_with_empty_items_is_str(self) -> None: + # Dynamic enums (items computed at runtime) have empty enum_items. + prop = SimpleNamespace( + type="enum", is_enum_flag=False, fixed_type=None, enum_items=[] + ) + self.assertEqual(rna_property_to_type(prop), "str") + + def test_enum_without_flag_attr_is_str(self) -> None: + prop = SimpleNamespace(type="enum", fixed_type=None) + self.assertEqual(rna_property_to_type(prop), "str") + + def test_pointer_nullable_when_not_never_none(self) -> None: + prop = SimpleNamespace( + type="pointer", + fixed_type=SimpleNamespace(identifier="Area"), + is_never_none=False, + ) + self.assertEqual(rna_property_to_type(prop), "Area | None") + + def test_pointer_non_nullable_when_never_none(self) -> None: + prop = SimpleNamespace( + type="pointer", + fixed_type=SimpleNamespace(identifier="Scene"), + is_never_none=True, + ) + self.assertEqual(rna_property_to_type(prop), "Scene") + + def test_pointer_defaults_to_nullable_without_attr(self) -> None: + prop = SimpleNamespace( + type="pointer", + fixed_type=SimpleNamespace(identifier="Object"), + ) + self.assertEqual(rna_property_to_type(prop), "Object | None") + + def test_pointer_function_return_stays_non_nullable(self) -> None: + # Function returns get nullable_pointer=False because Blender raises + # on failure rather than returning None, even though is_never_none=False. + prop = SimpleNamespace( + type="pointer", + fixed_type=SimpleNamespace(identifier="Object"), + is_never_none=False, + ) + self.assertEqual(rna_property_to_type(prop, nullable_pointer=False), "Object") + + def test_pointer_function_return_never_none_stays_non_nullable(self) -> None: + prop = SimpleNamespace( + type="pointer", + fixed_type=SimpleNamespace(identifier="Scene"), + is_never_none=True, + ) + self.assertEqual(rna_property_to_type(prop, nullable_pointer=False), "Scene") + + +class TestDescriptionHintsNullable(unittest.TestCase): + def test_may_be_none_phrase(self) -> None: + self.assertTrue( + description_hints_nullable( + "The current space, may be None in background-mode" + ) + ) + + def test_can_be_none_phrase(self) -> None: + self.assertTrue( + description_hints_nullable( + "The active object — can be None when no object is selected" + ) + ) + + def test_may_be_null_phrase(self) -> None: + self.assertTrue(description_hints_nullable("Result may be null on failure")) + + def test_case_insensitive(self) -> None: + self.assertTrue(description_hints_nullable("This MAY BE NONE in some cases")) + + def test_no_hint_returns_false(self) -> None: + self.assertFalse(description_hints_nullable("The active scene")) + + def test_empty_returns_false(self) -> None: + self.assertFalse(description_hints_nullable("")) + + if __name__ == "__main__": unittest.main()