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:
"""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)

View File

@ -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)

View File

@ -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()