tests and agents.md
This commit is contained in:
parent
c46b37f88e
commit
f575a84c13
45
AGENTS.md
Normal file
45
AGENTS.md
Normal 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.0–5.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 .`**
|
||||
59
CLAUDE.md
59
CLAUDE.md
@ -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 .`**
|
||||
115
introspect.py
115
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)
|
||||
|
||||
28
publish.py
28
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)
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user