tests and agents.md

This commit is contained in:
Joseph HENRY 2026-08-25 17:54:13 +02:00
parent c46b37f88e
commit f575a84c13
5 changed files with 288 additions and 63 deletions

45
AGENTS.md Normal file
View File

@ -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.05.1.
## Commands
- `uv run poe generate <version>` — generate stubs (e.g., `5.0`)
- `uv run poe typecheck-stubs <version>` — type-check generated stubs
- `uv run poe conformance <version>` — 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-<version>-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/<version>/` — copied verbatim from Blender docs
- On failure, fix stub generation or `overrides/<version>/<module>.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 .`**

View File

@ -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 <version>` — Generate stubs for a Blender version (e.g., 5.0)
- `uv run poe typecheck-stubs <version>` — Type-check generated stubs
- `uv run poe conformance <version>` — 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/<version>/`
## Architecture
- Stubs are versioned per Blender version: `dist/<version>/` (e.g., `dist/5.0/`)
- Type overrides are organized per version: `overrides/<version>/<module>.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 .`**

View File

@ -2320,14 +2320,22 @@ RNA_TYPE_MAP: dict[str, str] = {
} }
def rna_property_to_type(prop: object) -> str: def rna_property_to_type(prop: object, *, nullable_pointer: bool = True) -> str:
"""Map an RNA property to a PEP 484 type annotation string.""" """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", "") prop_type: str = getattr(prop, "type", "")
fixed_type: object = getattr(prop, "fixed_type", None) fixed_type: object = getattr(prop, "fixed_type", None)
array_length: int = getattr(prop, "array_length", 0) array_length: int = getattr(prop, "array_length", 0)
if prop_type == "pointer" and fixed_type is not None: if prop_type == "pointer" and fixed_type is not None:
type_name: str = getattr(fixed_type, "identifier", "object") 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 return type_name
if prop_type == "collection" and fixed_type is not None: 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") element_type: str = getattr(fixed_type, "identifier", "object")
return f"bpy_prop_collection[{element_type}]" 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 # Dynamic-length arrays have array_length=0 but is_array=True on the raw
# RNA property. rna_info wraps properties in InfoPropertyRNA which stores # RNA property. rna_info wraps properties in InfoPropertyRNA which stores
# the raw prop as bl_prop; fall back to checking the prop itself. # 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 return_type: str | None = None
if return_values: if return_values:
if len(return_values) == 1: 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: 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_type = f"tuple[{', '.join(types)}]"
return { 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: def _merge_screen_context_members(structs: list[StructData]) -> None:
"""Merge dynamic bpy.context members into the Context struct.""" """Merge dynamic bpy.context members into the Context struct."""
known_types = {s["name"] for s in structs} known_types = {s["name"] for s in structs}
for struct in structs: for struct in structs:
if struct["name"] != "Context": if struct["name"] != "Context":
continue continue
_refine_context_nullability(struct)
rna_names = {p["name"] for p in struct["properties"]} rna_names = {p["name"] for p in struct["properties"]}
rna_names |= {m["name"] for m in struct["methods"]} rna_names |= {m["name"] for m in struct["methods"]}
screen_props = introspect_screen_context_members(rna_names) screen_props = introspect_screen_context_members(rna_names)

View File

@ -5,7 +5,9 @@ asks for confirmation, then builds and uploads.
""" """
import argparse import argparse
import configparser
import json import json
import os
import subprocess import subprocess
import sys import sys
import tomllib 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/" 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: def fetch_latest_revision(blender_version: str, use_test_pypi: bool) -> int | None:
"""Fetch the latest revision for a Blender version from PyPI. """Fetch the latest revision for a Blender version from PyPI.
@ -175,6 +197,11 @@ def publish_version(
# Publish # Publish
print(f" Publishing to {publish_url}...") 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( result = subprocess.run(
[ [
"uv", "uv",
@ -184,6 +211,7 @@ def publish_version(
publish_url, publish_url,
], ],
cwd=str(SCRIPT_DIR), cwd=str(SCRIPT_DIR),
env=env,
) )
if result.returncode != 0: if result.returncode != 0:
print("Publish failed.", file=sys.stderr) print("Publish failed.", file=sys.stderr)

View File

@ -8,6 +8,7 @@ from unittest.mock import patch
from introspect import ( from introspect import (
FunctionData, FunctionData,
description_hints_nullable,
infer_getter_return_types, infer_getter_return_types,
parse_rst_function_sig, parse_rst_function_sig,
infer_context_member_type, infer_context_member_type,
@ -19,6 +20,7 @@ from introspect import (
introspect_screen_context_members, introspect_screen_context_members,
parse_docstring_types, parse_docstring_types,
python_type_name, python_type_name,
rna_property_to_type,
) )
@ -650,5 +652,107 @@ class TestRuntimeEdgeCases(unittest.TestCase):
self.assertEqual(data["variables"], []) 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__": if __name__ == "__main__":
unittest.main() unittest.main()