commit 852a5de7004f298f8723a59aaa8dec95231e8583 Author: Joseph HENRY Date: Thu Mar 26 13:00:51 2026 +0100 Initial commit Co-Authored-By: Claude Opus 4.6 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0ddc7c2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv + +# Downloaded Blender binaries +downloads/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2c07333 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..97e7535 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Autour de Minuit + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..33b4a4c --- /dev/null +++ b/README.md @@ -0,0 +1,125 @@ +# blender-python-stubs + +![Python](https://img.shields.io/badge/Python-3776AB?style=for-the-badge&logo=python&logoColor=white) +![Blender](https://img.shields.io/badge/Blender-E87D0D?style=for-the-badge&logo=blender&logoColor=white) +![License: MIT](https://img.shields.io/badge/License-MIT-green.svg?style=for-the-badge) + +Type stubs for the Blender Python API. Provides autocomplete, type checking, and inline documentation for `bpy`, `mathutils`, `bmesh`, `gpu`, `freestyle`, and all other Blender Python modules. + +## Installation + +```bash +pip install blender-python-stubs==5.1.0 +``` + +The version matches your target Blender version: + +| Blender Version | Install Command | +| --------------- | ---------------------------------------------- | +| 5.1 | `pip install "blender-python-stubs>=5.1,<5.2"` | +| 5.0 | `pip install "blender-python-stubs>=5.0,<5.1"` | +| 4.5 | `pip install "blender-python-stubs>=4.5,<4.6"` | +| 4.4 | `pip install "blender-python-stubs>=4.4,<4.5"` | +| 4.3 | `pip install "blender-python-stubs>=4.3,<4.4"` | +| 4.2 | `pip install "blender-python-stubs>=4.2,<4.3"` | +| 4.1 | `pip install "blender-python-stubs>=4.1,<4.2"` | +| 4.0 | `pip install "blender-python-stubs>=4.0,<4.1"` | + +## Usage + +Install alongside your Blender addon project for type checking with mypy, pyright, basedpyright, ty or your IDE. + +```python +import bpy + +# Full autocomplete and type checking +obj: bpy.types.Object = bpy.context.active_object +obj.location.x = 1.0 + +# Collection types with proper methods +bpy.data.objects.new("Cube", bpy.data.meshes.new("Mesh")) +bpy.data.images.remove(bpy.data.images["old"]) + +# GPU module fully typed +import gpu +shader = gpu.shader.from_builtin('UNIFORM_COLOR') +gpu.state.blend_set('ALPHA') +``` + +## Features + +- **Full Blender API coverage** — `bpy.types`, `bpy.ops`, `bpy.props`, `bpy.utils`, `bpy.path`, `bpy.app`, `mathutils`, `bmesh`, `gpu`, `gpu_extras`, `bpy_extras`, `freestyle`, `aud`, `blf`, `bl_math`, `imbuf`, `idprop` +- **Accurate collection types** — `bpy.data.objects` returns `BlendDataObjects` (not generic `bpy_prop_collection`), exposing `new()`, `remove()`, and other collection-specific methods +- **Readonly properties** — uses `@property` decorators for readonly RNA attributes +- **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` +- **Docstrings** — inline documentation on properties, methods, and functions +- **Classmethod detection** — `Matrix.Identity()`, `Vector.Fill()`, etc. correctly typed as `@classmethod` +- **Dynamic array types** — `Image.pixels` typed as `bpy_prop_array[float]` (not `list[float]` or `float`) +- **Zero `Any` usage** — precise types throughout, no `typing.Any` fallbacks +- **basedpyright strict mode** — 0 errors on generated stubs (4.1+) + +## How It Works + +Stubs are generated by running introspection **inside Blender itself**. A script runs in Blender's embedded Python interpreter using `--background` mode and collects: + +1. **RNA type definitions** via `rna_info.BuildRNAInfo()` — all `bpy.types` classes, their properties, methods, inheritance, and readonly status +2. **C extension signatures** via `inspect.signature()` and RST docstring parsing — for `mathutils`, `bmesh.types`, `gpu.types`, `aud`, etc. +3. **Screen context members** via `dir(bpy.context)` — dynamically injected context attributes with type inference from runtime values, hardcoded overrides, and name-pattern heuristics +4. **Collection wrapper classes** via RNA `srna` attributes — maps `bpy.data.objects` to `BlendDataObjects(bpy_prop_collection[Object])` instead of generic `bpy_prop_collection[Object]` + +The introspection data is then passed through a stub generator that handles type cleaning, import resolution, docstring formatting, and `black` formatting. + +## Comparison with Alternatives + +### vs [fake-bpy-module](https://github.com/nutti/fake-bpy-module) + +| Feature | blender-python-stubs | fake-bpy-module | +| ------------------------ | -------------------------- | ----------------------------- | +| `@property` for readonly | Yes (1700+) | No | +| `Any` usage | 0 | 1800+ | +| Collection wrapper types | `BlendDataObjects` | `bpy_prop_collection[Object]` | +| `bpy_struct` methods | Introspected | Missing | +| Constructor `__init__` | Yes (mathutils, gpu, etc.) | No | +| Literal enum types | Yes | Yes (via stub_internal) | +| Context members | ~100 typed | ~100 typed | +| basedpyright strict | 0 errors (4.1+) | Not tested | +| Docstrings | Yes | Yes | + +### vs [bpystubgen](https://github.com/mysticfall/bpystubgen) + +bpystubgen generates stubs from Blender's Sphinx documentation, not from runtime introspection. This means it can miss C-level methods, dynamic context members, and runtime-only type information. Our approach introspects the actual running Blender instance, ensuring stubs match the real API. + +## Supported Versions + +Blender 4.0 through 5.1. Each Blender version gets its own stub package version. + +## Contributing + +Pull requests are welcome. The project uses: + +- `uv` for dependency management +- `poe` for task running +- `basedpyright` for type checking +- `black` for formatting +- `ruff` for linting + +```bash +# Generate stubs for a specific version +uv run poe generate 5.1 + +# Type-check generated stubs +uv run poe typecheck-stubs 5.1 + +# Run all checks (format, lint, typecheck, tests) +uv run poe check +``` + +## License + +[MIT](LICENSE) + +--- + +Made with ❤️ at [Autour de Minuit (ADV)](https://blog.autourdeminuit.com/) blender diff --git a/blender_downloader.py b/blender_downloader.py new file mode 100644 index 0000000..b21122e --- /dev/null +++ b/blender_downloader.py @@ -0,0 +1,175 @@ +"""Download and cache Blender binaries for stub generation.""" + +import platform +import re +import tarfile +import urllib.request +from pathlib import Path + +BLENDER_RELEASE_URL = "https://download.blender.org/release" +DOWNLOADS_DIR = Path(__file__).parent / "downloads" + + +def get_platform_suffix() -> str: + """Get the platform-specific filename suffix for Blender downloads.""" + system = platform.system() + machine = platform.machine() + + if system == "Linux" and machine == "x86_64": + return "linux-x64" + + if system == "Darwin": + if machine == "arm64": + return "macos-arm64" + return "macos-x64" + + if system == "Windows": + return "windows-x64" + + msg = f"Unsupported platform: {system} {machine}" + raise RuntimeError(msg) + + +def get_archive_extension() -> str: + """Get the archive extension for the current platform.""" + if platform.system() == "Windows": + return ".zip" + return ".tar.xz" + + +def find_latest_patch(major_minor: str) -> str: + """Find the latest patch version for a given major.minor version. + + Parses the HTML index at https://download.blender.org/release/Blender{X.Y}/ + to find the highest patch version. + """ + url = f"{BLENDER_RELEASE_URL}/Blender{major_minor}/" + suffix = get_platform_suffix() + ext = get_archive_extension() + pattern = ( + rf"blender-({re.escape(major_minor)}\.\d+)-{re.escape(suffix)}{re.escape(ext)}" + ) + + req = urllib.request.Request(url, headers={"User-Agent": "blender-python-stubs"}) + with urllib.request.urlopen(req) as response: + html = response.read().decode() + + versions = re.findall(pattern, html) + if not versions: + msg = f"No Blender {major_minor} releases found for {suffix} at {url}" + raise RuntimeError(msg) + + # Sort by patch version and return the latest + versions.sort(key=lambda v: [int(x) for x in v.split(".")]) + return versions[-1] + + +def get_download_url(version: str) -> str: + """Build the download URL for a specific Blender version.""" + major_minor = ".".join(version.split(".")[:2]) + suffix = get_platform_suffix() + ext = get_archive_extension() + filename = f"blender-{version}-{suffix}{ext}" + return f"{BLENDER_RELEASE_URL}/Blender{major_minor}/{filename}" + + +def get_extracted_dir_name(version: str) -> str: + """Get the expected directory name after extraction.""" + suffix = get_platform_suffix() + return f"blender-{version}-{suffix}" + + +def download_blender(version: str) -> Path: + """Download and extract a Blender version. Returns path to the extracted directory.""" + DOWNLOADS_DIR.mkdir(parents=True, exist_ok=True) + + dir_name = get_extracted_dir_name(version) + extracted_dir = DOWNLOADS_DIR / dir_name + + if extracted_dir.exists(): + print(f" Already cached: {extracted_dir}") + return extracted_dir + + url = get_download_url(version) + suffix = get_platform_suffix() + ext = get_archive_extension() + archive_name = f"blender-{version}-{suffix}{ext}" + archive_path = DOWNLOADS_DIR / archive_name + + print(f" Downloading {url}...") + req = urllib.request.Request(url, headers={"User-Agent": "blender-python-stubs"}) + with urllib.request.urlopen(req) as response, archive_path.open("wb") as f: + total = int(response.headers.get("Content-Length", 0)) + downloaded = 0 + chunk_size = 1024 * 1024 # 1MB + while True: + chunk = response.read(chunk_size) + if not chunk: + break + f.write(chunk) + downloaded += len(chunk) + if total: + pct = downloaded * 100 // total + mb_done = downloaded / (1024 * 1024) + mb_total = total / (1024 * 1024) + print( + f"\r [{pct:3d}%] {mb_done:.0f}/{mb_total:.0f} MB", + end="", + flush=True, + ) + if total: + print() + + print(f" Extracting {archive_name}...") + with tarfile.open(archive_path, "r:xz") as tar: + members = tar.getmembers() + total_members = len(members) + for i, member in enumerate(members): + tar.extract(member, path=DOWNLOADS_DIR) + if total_members: + pct = (i + 1) * 100 // total_members + print( + f"\r [{pct:3d}%] {i + 1}/{total_members} files", end="", flush=True + ) + print() + + # Remove the archive to save space + archive_path.unlink() + + if not extracted_dir.exists(): + msg = f"Expected directory {extracted_dir} not found after extraction" + raise RuntimeError(msg) + + return extracted_dir + + +def get_blender_executable(major_minor: str) -> Path: + """Get the path to a Blender executable, downloading if needed. + + Returns the path to the blender binary. + """ + # Check if already cached + DOWNLOADS_DIR.mkdir(parents=True, exist_ok=True) + suffix = get_platform_suffix() + pattern = f"blender-{major_minor}.*-{suffix}" + + for entry in DOWNLOADS_DIR.iterdir(): + if entry.is_dir() and re.match(pattern, entry.name): + executable = entry / "blender" + if executable.exists(): + print(f" Using cached: {executable}") + return executable + + # Download the latest patch version + print(f"Resolving latest Blender {major_minor} patch version...") + version = find_latest_patch(major_minor) + print(f" Latest: {version}") + + extracted_dir = download_blender(version) + executable = extracted_dir / "blender" + + if not executable.exists(): + msg = f"Blender executable not found at {executable}" + raise RuntimeError(msg) + + return executable diff --git a/generate_stubs.py b/generate_stubs.py new file mode 100644 index 0000000..2625896 --- /dev/null +++ b/generate_stubs.py @@ -0,0 +1,862 @@ +"""Generate PEP 484-compliant .pyi stub files from introspection JSON.""" + +import json +import os +import re +import sys +from typing import TypedDict + +import black + + +from introspect import ( + UNQUALIFIED_TYPES, + FunctionData, + ModuleData, + ParamData, + PropertyData, + StructData, + VariableData, +) + +# Map raw type names from introspection to proper Python type annotations +TYPE_MAP: dict[str, str] = { + "frozenset": "frozenset[str]", +} + + +class ParamOverrides(TypedDict, total=False): + params: dict[str, str] + return_type: str + + +def map_type(type_str: str) -> str: + """Map an introspected type string to a proper PEP 484 annotation.""" + if type_str in TYPE_MAP: + return TYPE_MAP[type_str] + return type_str + + +def _qualify_type(type_str: str) -> str: + """Qualify bare type names (e.g. Mesh -> bpy.types.Mesh) in a type string. + + This catches RNA type identifiers that bypass clean_type_str during introspection. + """ + for bare, qualified in UNQUALIFIED_TYPES.items(): + type_str = re.sub(rf"(? None: + """Qualify bare type names in all type strings of a module (in-place). + + RNA introspection returns bare identifiers like 'Mesh' which need to be + qualified to 'bpy.types.Mesh' for modules outside bpy.types. + """ + for func in module_data["functions"]: + for param in func["params"]: + if param["type"]: + param["type"] = _qualify_type(param["type"]) + if func["return_type"]: + func["return_type"] = _qualify_type(func["return_type"]) + for var in module_data["variables"]: + var["type"] = _qualify_type(var["type"]) + for struct in module_data.get("structs", []): + for prop in struct["properties"]: + prop["type"] = _qualify_type(prop["type"]) + for method in struct["methods"]: + for param in method["params"]: + if param["type"]: + param["type"] = _qualify_type(param["type"]) + if method["return_type"]: + method["return_type"] = _qualify_type(method["return_type"]) + + +def collect_all_type_strings(module_data: ModuleData) -> list[str]: + """Collect all type annotation strings from a module's data.""" + all_types: list[str] = [] + for func in module_data["functions"]: + for param in func["params"]: + if param["type"]: + all_types.append(param["type"]) + if func["return_type"]: + all_types.append(func["return_type"]) + for var in module_data["variables"]: + all_types.append(map_type(var["type"])) + for struct in module_data.get("structs", []): + for prop in struct["properties"]: + all_types.append(prop["type"]) + for method in struct["methods"]: + for param in method["params"]: + if param["type"]: + all_types.append(param["type"]) + if method["return_type"]: + all_types.append(method["return_type"]) + return all_types + + +def collect_imports(module_data: ModuleData) -> set[str]: + """Collect all import statements needed for the stub file.""" + imports: set[str] = set() + all_types = collect_all_type_strings(module_data) + joined = " ".join(all_types) + + def _has_type(name: str) -> bool: + return bool(re.search(rf"\b{name}\b", joined)) + + if _has_type("Sequence"): + imports.add("from collections.abc import Sequence") + if _has_type("Iterable"): + imports.add("from collections.abc import Iterable") + if _has_type("Callable"): + imports.add("from collections.abc import Callable") + if _has_type("Iterator"): + imports.add("from collections.abc import Iterator") + if _has_type("Literal"): + imports.add("from typing import Literal") + if _has_type("Any"): + imports.add("from typing import Any") + if _has_type("Generator"): + imports.add("from collections.abc import Generator") + if _has_type("Mapping"): + imports.add("from collections.abc import Mapping") + if _has_type("Collection"): + imports.add("from collections.abc import Collection") + if "Self" in joined: + imports.add("from typing import Self") + if _has_type("TypeAlias"): + imports.add("from typing import TypeAlias") + + # Detect module-qualified references (e.g. bpy.types.Object, mathutils.Vector) + # Detect qualified module references + known_modules = [ + "bpy.types", + "bpy.props", + "bpy.app", + "mathutils", + "gpu.types", + "imbuf.types", + "idprop.types", + "freestyle.types", + "bmesh.types", + "bmesh", + ] + if "datetime" in joined: + imports.add("import datetime") + if "types.ModuleType" in joined: + imports.add("import types") + if "collections.OrderedDict" in joined: + imports.add("import collections") + if "collections.abc." in joined: + imports.add("import collections.abc") + for mod in known_modules: + if mod + "." in joined or re.search(rf"\b{re.escape(mod)}\b", joined): + imports.add(f"import {mod}") + + # fixup_shadowed_builtins will add builtins.X references for structs + # with property names that shadow builtins — need the import + for struct in module_data.get("structs", []): + prop_names = {p["name"] for p in struct["properties"]} + if prop_names & BUILTIN_NAMES: + imports.add("import builtins") + break + + return imports + + +def format_param(param: ParamData, force_type: bool = False) -> str: + """Format a single parameter for a function signature.""" + parts = [param["name"]] + + type_str = param["type"] + if not type_str and force_type: + type_str = "object" + + if type_str: + parts.append(f": {type_str}") + + if param["default"] is not None: + if type_str: + parts.append(f" = {param['default']}") + else: + parts.append(f"={param['default']}") + + return "".join(parts) + + +def format_docstring(doc: str, indent: str = " ") -> str: + """Format a docstring with proper indentation.""" + if not doc: + return "" + # Escape triple quotes and backslashes inside the docstring + doc = doc.replace("\\", "\\\\").replace('"""', r"\"\"\"") + lines = doc.split("\n") + if len(lines) == 1: + line = lines[0] + if line.endswith('"'): + line += " " + return f'{indent}"""{line}"""\n' + + result = f'{indent}"""{lines[0]}\n' + for line in lines[1:]: + if line.strip(): + result += f"{indent}{line}\n" + else: + result += "\n" + result += f'{indent}"""\n' + return result + + +def generate_function_stub(func: FunctionData) -> str: + """Generate a stub for a single function.""" + # Build parameter list, separating by kind and default presence + positional_only_no_default: list[str] = [] + positional_only_with_default: list[str] = [] + positional_no_default: list[str] = [] + positional_with_default: list[str] = [] + keyword_params: list[str] = [] + has_positional_only = False + + for param in func["params"]: + formatted = format_param(param, force_type=True) + kind = param.get("kind", "POSITIONAL_OR_KEYWORD") + if kind == "POSITIONAL_ONLY": + has_positional_only = True + if param["default"] is not None: + positional_only_with_default.append(formatted) + else: + positional_only_no_default.append(formatted) + elif kind == "KEYWORD_ONLY": + keyword_params.append(formatted) + elif kind == "VAR_POSITIONAL": + type_ann = f": {param['type']}" if param["type"] else ": object" + positional_no_default.append(f"*{param['name']}{type_ann}") + elif kind == "VAR_KEYWORD": + type_ann = f": {param['type']}" if param["type"] else ": object" + keyword_params.append(f"**{param['name']}{type_ann}") + elif param["default"] is not None: + positional_with_default.append(formatted) + else: + positional_no_default.append(formatted) + + all_params: list[str] = [] + if has_positional_only and not ( + positional_only_with_default and positional_no_default + ): + # Only emit / when it won't cause "non-default follows default" errors + all_params.extend(positional_only_no_default) + all_params.extend(positional_only_with_default) + all_params.append("/") + else: + # Merge positional-only into regular params when / would be invalid + positional_no_default = positional_only_no_default + positional_no_default + positional_with_default = positional_only_with_default + positional_with_default + # Non-default positional params must come before default ones + all_params.extend(positional_no_default) + all_params.extend(positional_with_default) + if keyword_params: + # Insert * separator if there are keyword-only args and no VAR_POSITIONAL. + # Don't insert * if the only keyword params are **kwargs (already captures all). + has_var_positional = any( + p.get("kind") == "VAR_POSITIONAL" for p in func["params"] + ) + has_named_keyword = any(p.get("kind") == "KEYWORD_ONLY" for p in func["params"]) + if not has_var_positional and has_named_keyword: + all_params.append("*") + all_params.extend(keyword_params) + + params_str = ", ".join(all_params) + + # Return type + ret = f" -> {func['return_type']}" if func["return_type"] else " -> None" + + # Build the function + if func["doc"]: + result = f"def {func['name']}({params_str}){ret}:\n" + result += format_docstring(func["doc"]) + return result + return f"def {func['name']}({params_str}){ret}: ...\n" + + +def generate_variable_stub(var: VariableData) -> str: + """Generate a stub for a module-level variable.""" + type_str = map_type(var["type"]) + if type_str == "TypeAlias": + return f"{var['name']}: TypeAlias = {var['value']}\n" + return f"{var['name']}: {type_str}\n" + + +def generate_method_stub( + func: FunctionData, + indent: str = " ", + is_override: bool = False, +) -> str: + """Generate a stub for a method inside a class.""" + is_cls = func.get("is_classmethod", False) + first_param = "cls" if is_cls else "self" + + # Categorize params by kind, keeping positional ordering correct + positional_only_no_default: list[str] = [] + positional_only_with_default: list[str] = [] + positional_no_default: list[str] = [] + positional_with_default: list[str] = [] + keyword_params: list[str] = [] + has_positional_only = False + + for param in func["params"]: + if param["name"] in ("cls", "self"): + continue + formatted = format_param(param, force_type=True) + kind = param.get("kind", "POSITIONAL_OR_KEYWORD") + if kind == "POSITIONAL_ONLY": + has_positional_only = True + if param["default"] is not None: + positional_only_with_default.append(formatted) + else: + positional_only_no_default.append(formatted) + elif kind == "KEYWORD_ONLY": + keyword_params.append(formatted) + elif kind == "VAR_POSITIONAL": + type_ann = f": {param['type']}" if param["type"] else ": object" + positional_no_default.append(f"*{param['name']}{type_ann}") + elif kind == "VAR_KEYWORD": + type_ann = f": {param['type']}" if param["type"] else ": object" + keyword_params.append(f"**{param['name']}{type_ann}") + elif param["default"] is not None: + positional_with_default.append(formatted) + else: + positional_no_default.append(formatted) + + all_params: list[str] = [first_param] + if has_positional_only and not ( + positional_only_with_default and positional_no_default + ): + # Only emit / when it won't cause "non-default follows default" errors + all_params.extend(positional_only_no_default) + all_params.extend(positional_only_with_default) + all_params.append("/") + else: + # Merge positional-only into regular params when / would be invalid + positional_no_default = positional_only_no_default + positional_no_default + positional_with_default = positional_only_with_default + positional_with_default + all_params.extend(positional_no_default) + all_params.extend(positional_with_default) + if keyword_params: + has_var_positional = any( + p.get("kind") == "VAR_POSITIONAL" for p in func["params"] + ) + has_named_keyword = any(p.get("kind") == "KEYWORD_ONLY" for p in func["params"]) + if not has_var_positional and has_named_keyword: + all_params.append("*") + all_params.extend(keyword_params) + + params_str = ", ".join(all_params) + ret = f" -> {func['return_type']}" if func["return_type"] else " -> None" + decorators = "" + if is_override: + decorators += f"{indent}@override\n" + if is_cls: + decorators += f"{indent}@classmethod\n" + if func["doc"]: + result = f"{decorators}{indent}def {func['name']}({params_str}){ret}:\n" + result += format_docstring(func["doc"], indent + " ") + return result + return f"{decorators}{indent}def {func['name']}({params_str}){ret}: ...\n" + + +def generate_property_stub( + prop: PropertyData, + indent: str = " ", + property_decorator: str = "@property", +) -> str: + """Generate a stub for a class property.""" + if prop["is_readonly"]: + result = f"{indent}{property_decorator}\n" + result += f"{indent}def {prop['name']}(self) -> {prop['type']}:\n" + if prop["description"]: + desc = prop["description"].replace("\\", "\\\\").replace('"""', r"\"\"\"") + if desc.endswith('"'): + desc += " " + result += f'{indent} """{desc}"""\n' + else: + result += f"{indent} ...\n" + return result + + result = f"{indent}{prop['name']}: {prop['type']}\n" + if prop["description"]: + desc = prop["description"].replace("\\", "\\\\").replace('"""', r"\"\"\"") + if desc.endswith('"'): + desc += " " + result += f'{indent}"""{desc}"""\n' + return result + + +BUILTIN_NAMES = { + "int", + "float", + "bool", + "str", + "list", + "dict", + "set", + "tuple", + "type", + "object", +} + + +def fixup_shadowed_builtins( + properties: list[PropertyData], +) -> list[PropertyData]: + """If a property name shadows a builtin, qualify type references with builtins.""" + shadowed = {p["name"] for p in properties} & BUILTIN_NAMES + if not shadowed: + return properties + + fixed: list[PropertyData] = [] + for prop in properties: + new_type = prop["type"] + for name in shadowed: + # Replace bare builtin type references with builtins.X + # e.g. "int" -> "builtins.int", "list[int]" -> "list[builtins.int]" + new_type = re.sub( + rf"\b{name}\b", + f"builtins.{name}", + new_type, + ) + fixed.append( + { + "name": prop["name"], + "type": new_type, + "is_readonly": prop["is_readonly"], + "description": prop["description"], + } + ) + return fixed + + +def generate_struct_stub( + struct: StructData, + inherited_methods: set[str] | None = None, +) -> str: + """Generate a stub for a single RNA struct (class).""" + base = struct["base"] if struct["base"] else "" + class_decl = ( + f"class {struct['name']}({base}):" if base else f"class {struct['name']}:" + ) + + parts: list[str] = [class_decl] + if struct["doc"]: + parts.append(format_docstring(struct["doc"])) + + has_body = bool(struct["doc"]) + if inherited_methods is None: + inherited_methods = set() + + # Collect method names to skip conflicting properties + method_names = {m["name"] for m in struct["methods"]} + properties = fixup_shadowed_builtins(struct["properties"]) + # If any property shadows "property", readonly getters must use builtins.property + prop_names = {p["name"] for p in properties} + property_decorator = ( + "@builtins.property" if "property" in prop_names else "@property" + ) + + for prop in properties: + if prop["name"] in method_names: + continue + parts.append( + generate_property_stub(prop, property_decorator=property_decorator) + ) + has_body = True + + for method in struct["methods"]: + is_override = method["name"] in inherited_methods + parts.append(generate_method_stub(method, is_override=is_override)) + has_body = True + + if not has_body: + parts.append(" ...\n") + + return "\n".join(parts) + + +def topological_sort_structs(structs: list[StructData]) -> list[StructData]: + """Sort structs so that base classes come before subclasses.""" + by_name: dict[str, StructData] = {s["name"]: s for s in structs} + visited: set[str] = set() + result: list[StructData] = [] + + def visit(name: str) -> None: + if name in visited: + return + visited.add(name) + struct = by_name.get(name) + if struct and struct["base"] and struct["base"] in by_name: + visit(struct["base"]) + if struct: + result.append(struct) + + for struct in structs: + visit(struct["name"]) + + return result + + +def collect_all_methods( + structs: list[StructData], +) -> dict[str, set[str]]: + """Build a map of struct name -> all method names inherited from ancestors.""" + by_name: dict[str, StructData] = {s["name"]: s for s in structs} + cache: dict[str, set[str]] = {} + + def get_inherited(name: str) -> set[str]: + if name in cache: + return cache[name] + struct = by_name.get(name) + if not struct or not struct["base"]: + cache[name] = set() + return cache[name] + base = struct["base"] + base_own: set[str] = ( + {m["name"] for m in by_name[base]["methods"]} if base in by_name else set() + ) + cache[name] = base_own | get_inherited(base) + return cache[name] + + for struct in structs: + get_inherited(struct["name"]) + + return cache + + +def generate_types_stub( + structs: list[StructData], python_version: str = "3.11", doc: str = "" +) -> str: + """Generate the complete bpy/types.pyi content.""" + # Collect all type strings to detect needed imports + all_type_strs_parts: list[str] = [] + for s in structs: + for p in s["properties"]: + all_type_strs_parts.append(p["type"]) + for m in s["methods"]: + for param in m["params"]: + if param["type"]: + all_type_strs_parts.append(param["type"]) + if m["return_type"]: + all_type_strs_parts.append(m["return_type"]) + all_type_strs = " ".join(all_type_strs_parts) + + typing_imports = ["Generic", "TypeVar"] + if "Literal[" in all_type_strs: + typing_imports.append("Literal") + + abc_imports = ["Iterator"] + if "MutableSequence[" in all_type_strs: + abc_imports.append("MutableSequence") + # Only import bare Sequence if used without collections.abc. prefix + if re.search(r"(?= (3, 12) + else "from typing_extensions import override" + ), + ] + if "Sequence[" in all_type_strs: + # Use fully qualified import to avoid shadowing by bpy.types.Sequence + # (the video sequencer strip type) + imports.append("import collections.abc") + + parts: list[str] = [] + if doc: + parts.append(f'"""{doc}"""\n') + parts.extend( + [ + "\n".join(sorted(imports)), + "", + '_T = TypeVar("_T")', + ] + ) + + sorted_structs = topological_sort_structs(structs) + inherited_map = collect_all_methods(sorted_structs) + + for struct in sorted_structs: + parts.append("") + parts.append( + generate_struct_stub(struct, inherited_map.get(struct["name"], set())) + ) + + result = "\n".join(parts) + class_names = {s["name"] for s in structs} + result = strip_self_module_prefix(result, "bpy.types", class_names) + return prune_unused_imports(result) + + +def prune_unused_imports(content: str) -> str: + """Remove import lines where the imported name is not used in the body.""" + lines = content.split("\n") + import_lines: list[int] = [] + for i, line in enumerate(lines): + # Skip relative re-exports (from . import X) — these are intentional + if line.strip().startswith("from . import"): + continue + if re.match(r"^(import |from .+ import )", line.strip()): + import_lines.append(i) + + to_remove: set[int] = set() + for i in import_lines: + line = lines[i].strip() + # "from X import Y" -> check Y is used + m = re.match(r"from .+ import (\w+)", line) + if m: + name = m.group(1) + # Check if name is used in type annotations (not just in docstrings). + # Look for: ": Name", "-> Name", "[Name", "| Name", "(Name)" + body = "\n".join(lines[j] for j in range(len(lines)) if j != i) + if not re.search(rf"[:>\[|( @]{name}\b", body): + to_remove.add(i) + continue + # "import X" -> check X. is used + m = re.match(r"import ([\w.]+)", line) + if m: + mod = m.group(1) + body = "\n".join(lines[j] for j in range(len(lines)) if j != i) + if mod + "." not in body: + to_remove.add(i) + + return "\n".join(line for i, line in enumerate(lines) if i not in to_remove) + + +def strip_self_module_prefix( + content: str, module_name: str, class_names: set[str] +) -> str: + """Remove self-referencing module prefixes only for types defined in this module.""" + # Strip full module prefix: bpy_extras.anim_utils.BakeOptions -> BakeOptions + for cls_name in class_names: + content = content.replace(f"{module_name}.{cls_name}", cls_name) + + # Also strip short module name prefix for relative references: + # anim_utils.BakeOptions -> BakeOptions (when inside bpy_extras.anim_utils) + # This handles classes that weren't introspected but are referenced by type annotations. + # Use negative lookbehind to avoid stripping "types." from "bpy.types.Mesh" etc. + short_name = module_name.rsplit(".", 1)[-1] + if short_name != module_name: + content = re.sub( + rf"(? str: + """Generate the complete .pyi content for a module.""" + module_name = module_data["module"] + + # bpy.types uses the specialized types generator + if module_name == "bpy.types": + return generate_types_stub( + module_data["structs"], python_version, module_data["doc"] + ) + + parts: list[str] = [] + + # Module docstring + if module_data["doc"]: + parts.append(f'"""{module_data["doc"]}"""\n') + + # Imports + imports = collect_imports(module_data) + # Re-export submodules so type checkers can resolve e.g. bpy.utils. + # Use "X as X" pattern to mark as intentional re-export in stubs. + for sub in submodule_names or []: + imports.add(f"from . import {sub} as {sub}") + # Remove exact self-imports (but keep submodule imports like bmesh.types for bmesh) + imports = {i for i in imports if i != f"import {module_name}"} + # Remove imports that clash with class names in this module + class_names = {s["name"] for s in module_data.get("structs", [])} + clashing_imports: set[str] = set() + for i in imports: + for name in class_names: + if f"import {name}" in i: + clashing_imports.add(i) + imports -= clashing_imports + if imports: + parts.append("") + for imp in sorted(imports): + parts.append(imp) + parts.append("") + + # Variables + if module_data["variables"]: + parts.append("") + for var in module_data["variables"]: + parts.append(generate_variable_stub(var)) + + # Functions + for func in module_data["functions"]: + parts.append("") + parts.append(generate_function_stub(func)) + + # Classes + for struct in module_data.get("structs", []): + parts.append("") + parts.append(generate_struct_stub(struct)) + + result = "\n".join(parts) + class_names = {s["name"] for s in module_data.get("structs", [])} + result = strip_self_module_prefix(result, module_name, class_names) + + # Qualify type names that clash with class names in the same module + for clash in clashing_imports: + match = re.search(r"from ([\w.]+) import (\w+)", clash) + if match: + full_module = match.group(1) + name = match.group(2) + if name in class_names: + result = re.sub( + rf"(? dict[str, ParamOverrides]: + """Load type overrides for a module from a versioned overrides directory.""" + override_path = os.path.join(overrides_dir, f"{module_name}.json") + if os.path.exists(override_path): + with open(override_path) as f: + loaded: dict[str, ParamOverrides] = json.load(f) + return loaded + return {} + + +def apply_overrides( + module_data: ModuleData, overrides: dict[str, ParamOverrides] +) -> ModuleData: + """Apply type overrides to introspected module data.""" + for func in module_data["functions"]: + func_overrides = overrides.get(func["name"], {}) + param_overrides = func_overrides.get("params", {}) + for param in func["params"]: + if param["name"] in param_overrides: + param["type"] = param_overrides[param["name"]] + if "return_type" in func_overrides: + func["return_type"] = func_overrides["return_type"] + return module_data + + +def write_stubs( + modules_data: list[ModuleData], + output_dir: str, + overrides_dir: str | None = None, + python_version: str = "3.11", +) -> list[str]: + """Write .pyi stub files from introspection data. + + Returns the list of top-level package directory names created. + """ + os.makedirs(output_dir, exist_ok=True) + + # Determine which modules are packages (have submodules) + all_module_names = {m["module"] for m in modules_data} + package_modules: set[str] = set() + for name in all_module_names: + parts = name.split(".") + for i in range(1, len(parts)): + package_modules.add(".".join(parts[:i])) + + top_level_packages: set[str] = set() + + for module_data in modules_data: + module_name = module_data["module"] + + # Apply type overrides if available + if overrides_dir: + overrides = load_overrides(overrides_dir, module_name) + if overrides: + module_data = apply_overrides(module_data, overrides) + + # Qualify bare RNA type names for modules outside bpy.types + if module_name != "bpy.types": + qualify_module_types(module_data) + + parts = module_name.split(".") + top_level_packages.add(parts[0]) + + # Create package directories and __init__.pyi files + current_dir = output_dir + for part in parts[:-1]: + current_dir = os.path.join(current_dir, part) + os.makedirs(current_dir, exist_ok=True) + init_pyi = os.path.join(current_dir, "__init__.pyi") + if not os.path.exists(init_pyi): + with open(init_pyi, "w") as f: + pass + + # Always write as directory/__init__.pyi for consistent packaging + pkg_dir = os.path.join(current_dir, parts[-1]) + os.makedirs(pkg_dir, exist_ok=True) + stub_path = os.path.join(pkg_dir, "__init__.pyi") + + # Collect direct child submodule names for re-export in __init__.pyi + child_submodules: list[str] = [] + if module_name in package_modules: + prefix = module_name + "." + child_submodules = sorted( + { + n[len(prefix) :].split(".")[0] + for n in all_module_names + if n.startswith(prefix) + } + ) + + content = generate_module_stub(module_data, python_version, child_submodules) + try: + content = black.format_str(content, mode=black.Mode(is_pyi=True)) + except Exception as e: + print(f" ERROR formatting {module_name}: {e}", file=sys.stderr) + with open(stub_path, "w") as f: + f.write(content) + + print(f" {stub_path}") + + return sorted(top_level_packages) + + +def main() -> None: + import argparse + + parser = argparse.ArgumentParser( + description="Generate .pyi stubs from introspection JSON" + ) + parser.add_argument("input", help="Path to introspection JSON file") + parser.add_argument( + "--output-dir", default="blender-stubs", help="Output directory for stubs" + ) + args = parser.parse_args() + + with open(args.input) as f: + modules_data: list[ModuleData] = json.load(f) + + print(f"Generating stubs in {args.output_dir}/") + write_stubs(modules_data, args.output_dir) + print("Done.") + + +if __name__ == "__main__": + main() diff --git a/introspect.py b/introspect.py new file mode 100644 index 0000000..fbc2117 --- /dev/null +++ b/introspect.py @@ -0,0 +1,1946 @@ +"""Introspection script that runs inside Blender headless. + +Usage: blender --background --factory-startup -noaudio --python introspect.py +""" + +import argparse +import importlib +import inspect +import json +import pkgutil +import re +import sys +from collections.abc import Callable +from dataclasses import dataclass +from types import ModuleType +from typing import TypedDict, cast + +BLENDER_MODULES = [ + "aud", + "bl_math", + "blf", + "bmesh", + "bpy", + "bpy_extras", + "freestyle", + "gpu", + "gpu_extras", + "idprop", + "imbuf", + "mathutils", +] + +# Virtual modules not discoverable via pkgutil (C-level or RNA-defined) +EXTRA_MODULES = [ + "bpy.types", + "bpy.props", + "bpy.app", + "bmesh.types", + "gpu.types", + "imbuf.types", + "idprop.types", +] + +# Hardcoded types for screen context members that are None in headless mode. +# These are dynamically injected by Blender based on the active editor/mode. +SCREEN_CONTEXT_TYPE_OVERRIDES: dict[str, str] = { + "active_action": "Action", + "active_annotation_layer": "GPencilLayer", + "active_bone": "EditBone", + "active_editable_fcurve": "FCurve", + "active_gpencil_frame": "GreasePencilFrame", + "active_gpencil_layer": "GreasePencilLayer", + "active_nla_strip": "NlaStrip", + "active_nla_track": "NlaTrack", + "active_node": "Node", + "active_object": "Object", + "active_operator": "Operator", + "active_pose_bone": "PoseBone", + "active_sequence_strip": "Sequence", + "active_strip": "NlaStrip", + "annotation_data": "GreasePencil", + "annotation_data_owner": "ID", + "edit_object": "Object", + "editable_bones": "Sequence[EditBone]", + "editable_gpencil_layers": "Sequence[GPencilLayer]", + "editable_gpencil_strokes": "Sequence[GPencilStroke]", + "editable_objects": "Sequence[Object]", + "gpencil_data": "GreasePencil", + "gpencil_data_owner": "ID", + "grease_pencil": "GreasePencil", + "image_paint_object": "Object", + "object": "Object", + "objects_in_mode": "Sequence[Object]", + "objects_in_mode_unique_data": "Sequence[Object]", + "particle_edit_object": "Object", + "pose_object": "Object", + "property": "str", + "sculpt_object": "Object", + "selectable_objects": "Sequence[Object]", + "selected_bones": "Sequence[EditBone]", + "selected_editable_actions": "Sequence[Action]", + "selected_editable_bones": "Sequence[EditBone]", + "selected_editable_fcurves": "Sequence[FCurve]", + "selected_editable_keyframes": "Sequence[Keyframe]", + "selected_editable_objects": "Sequence[Object]", + "selected_editable_sequences": "Sequence[Sequence]", + "selected_editable_strips": "Sequence[NlaStrip]", + "selected_movieclip_tracks": "Sequence[MovieTrackingTrack]", + "selected_nla_strips": "Sequence[NlaStrip]", + "selected_objects": "Sequence[Object]", + "selected_pose_bones": "Sequence[PoseBone]", + "selected_pose_bones_from_active_object": "Sequence[PoseBone]", + "selected_sequences": "Sequence[Sequence]", + "selected_strips": "Sequence[NlaStrip]", + "selected_visible_actions": "Sequence[Action]", + "selected_visible_fcurves": "Sequence[FCurve]", + "sequencer_scene": "Scene", + "sequences": "Sequence[Sequence]", + "strips": "Sequence[NlaStrip]", + "ui_list": "UIList", + "vertex_paint_object": "Object", + "visible_bones": "Sequence[EditBone]", + "visible_fcurves": "Sequence[FCurve]", + "visible_gpencil_layers": "Sequence[GPencilLayer]", + "visible_objects": "Sequence[Object]", + "visible_pose_bones": "Sequence[PoseBone]", + "weight_paint_object": "Object", + # Buttons context members (Properties editor panels, not in dir() in headless) + "armature": "Armature", + "bone": "Bone", + "brush": "Brush", + "camera": "Camera", + "cloth": "ClothModifier", + "collision": "CollisionModifier", + "curve": "Curve", + "dynamic_paint": "DynamicPaintModifier", + "edit_bone": "EditBone", + "fluid": "FluidModifier", + "hair_curves": "Curves", + "lattice": "Lattice", + "light": "Light", + "lightprobe": "LightProbe", + "line_style": "FreestyleLineStyle", + "material": "Material", + "material_slot": "MaterialSlot", + "mesh": "Mesh", + "meta_ball": "MetaBall", + "node": "Node", + "particle_settings": "ParticleSettings", + "particle_system": "ParticleSystem", + "particle_system_editable": "ParticleSystem", + "pointcloud": "PointCloud", + "pose_bone": "PoseBone", + "soft_body": "SoftBodyModifier", + "speaker": "Speaker", + "texture": "Texture", + "texture_node": "Node", + "texture_slot": "TextureSlot", + "texture_user": "ID", + "texture_user_property": "Property", + "volume": "Volume", + "world": "World", +} + +# Suffix-based heuristics for screen context member types (order matters: longer first) +SCREEN_CONTEXT_NAME_PATTERNS: list[tuple[str, str]] = [ + ("_objects", "Sequence[Object]"), + ("_object", "Object"), + ("_bones", "Sequence[EditBone]"), + ("_bone", "EditBone"), + ("_fcurves", "Sequence[FCurve]"), + ("_fcurve", "FCurve"), + ("_strips", "Sequence[NlaStrip]"), + ("_strip", "NlaStrip"), + ("_actions", "Sequence[Action]"), + ("_action", "Action"), + ("_track", "NlaTrack"), + ("_sequences", "Sequence[Sequence]"), + ("_nodes", "Sequence[Node]"), + ("_node", "Node"), +] + + +def infer_context_member_type(name: str) -> str | None: + """Infer a screen context member's type from its name suffix.""" + for suffix, type_str in SCREEN_CONTEXT_NAME_PATTERNS: + if name.endswith(suffix): + return type_str + return None + + +class ParamData(TypedDict): + name: str + type: str | None + default: str | None + kind: str + + +class FunctionData(TypedDict): + name: str + doc: str + params: list[ParamData] + return_type: str | None + is_classmethod: bool + + +class VariableData(TypedDict): + name: str + type: str + value: str + + +class PropertyData(TypedDict): + name: str + type: str + is_readonly: bool + description: str + + +class StructData(TypedDict): + name: str + doc: str + base: str | None + properties: list[PropertyData] + methods: list[FunctionData] + + +class ModuleData(TypedDict): + module: str + doc: str + functions: list[FunctionData] + variables: list[VariableData] + structs: list[StructData] + + +def parse_docstring_types(docstring: str) -> tuple[dict[str, str], str | None]: + """Parse RST-style :type: and :rtype: annotations from a docstring. + + Returns (param_types, return_type) where param_types maps param name to type string. + """ + if not docstring: + return {}, None + + param_types: dict[str, str] = {} + return_type: str | None = None + + # Match :type param: ... up to the next RST directive (:arg, :type, :rtype, :return) + # but NOT :class: or :func: which appear inside type annotations + directive_lookahead = r"(?=\n\s*:(?:arg|param|type|rtype|return|returns)[\s:]|$)" + for match in re.finditer( + rf":type\s+(\w+):\s*(.+?){directive_lookahead}", docstring, re.DOTALL + ): + name = match.group(1) + type_str = clean_type_str(match.group(2).strip()) + param_types[name] = type_str + + rtype_match = re.search( + rf":rtype:\s*(.+?){directive_lookahead}", docstring, re.DOTALL + ) + if rtype_match: + return_type = clean_type_str(rtype_match.group(1).strip()) + + # Infer Literal types from :arg: descriptions when :type: is just "str". + # Blender 5.0 and earlier list enum values as ``VALUE`` bullet items in :arg: + # but only declare :type param: str. + for name, type_str in param_types.items(): + if type_str != "str": + continue + # Find the :arg name: block + arg_match = re.search( + rf":arg\s+{re.escape(name)}:\s*(.+?){directive_lookahead}", + docstring, + re.DOTALL, + ) + if not arg_match: + continue + arg_text = arg_match.group(1) + values = re.findall(r"``([A-Z][A-Z0-9_]*)``", arg_text) + if len(values) >= 2: + quoted = ", ".join(f'"{v}"' for v in values) + param_types[name] = f"Literal[{quoted}]" + + return param_types, return_type + + +UNQUALIFIED_TYPES: dict[str, str] = { + "Stroke": "freestyle.types.Stroke", + "ViewEdge": "freestyle.types.ViewEdge", + "Interface0DIterator": "freestyle.types.Interface0DIterator", + "UnaryFunction0D": "freestyle.types.UnaryFunction0D", + "IntegrationType": "freestyle.types.IntegrationType", + "ImBuf": "imbuf.types.ImBuf", + "Buffer": "gpu.types.Buffer", + "GPUShader": "gpu.types.GPUShader", + "GPUShaderCreateInfo": "gpu.types.GPUShaderCreateInfo", + "GPUStageInterfaceInfo": "gpu.types.GPUStageInterfaceInfo", + "GPUBatch": "gpu.types.GPUBatch", + "GPUTexture": "gpu.types.GPUTexture", + "GPUFrameBuffer": "gpu.types.GPUFrameBuffer", + "GPUOffScreen": "gpu.types.GPUOffScreen", + "GPUVertBuf": "gpu.types.GPUVertBuf", + "GPUVertFormat": "gpu.types.GPUVertFormat", + "GPUIndexBuf": "gpu.types.GPUIndexBuf", + "GPUUniformBuf": "gpu.types.GPUUniformBuf", + "bpy_struct": "bpy.types.bpy_struct", + "Context": "bpy.types.Context", + "BlendData": "bpy.types.BlendData", + "Mesh": "bpy.types.Mesh", + "Object": "bpy.types.Object", + "Depsgraph": "bpy.types.Depsgraph", + "Scene": "bpy.types.Scene", + "ViewLayer": "bpy.types.ViewLayer", + "SpaceView3D": "bpy.types.SpaceView3D", + "Region": "bpy.types.Region", + "AdjacencyIterator": "freestyle.types.AdjacencyIterator", + "ChainingIterator": "freestyle.types.ChainingIterator", + "BMesh": "bmesh.types.BMesh", + "BMLayerItem": "bmesh.types.BMLayerItem", + "BMVert": "bmesh.types.BMVert", + "BMEdge": "bmesh.types.BMEdge", + "BMFace": "bmesh.types.BMFace", + "BMLoop": "bmesh.types.BMLoop", +} + + +def clean_type_str(type_str: str) -> str: + """Clean up RST type annotations to plain Python type strings.""" + type_str = re.sub(r":class:`([^`]+)`", r"\1", type_str) + # Remove double backtick RST markup + type_str = re.sub(r"``([^`]+)``", r"\1", type_str) + # Strip leaked RST directives from type strings + type_str = re.sub(r"\.?\s*(?:r?type|returns?):.*", "", type_str) + # Strip stray RST role colons but not :param, :arg, :type directives + type_str = re.sub(r":(?!param|arg|type|return)(\w)", r"\1", type_str) + type_str = type_str.rstrip(":.,") + + # Convert tuple(X, Y) to tuple[X, Y] (docstrings sometimes use parens) + type_str = re.sub(r"\btuple\(([^)]+)\)", r"tuple[\1]", type_str) + + # Truncate at parameter-like patterns that leaked from function signatures + # e.g. "Callable[[BMVert], bool] | None, reverse: bool" -> "Callable[[BMVert], bool] | None" + type_str = re.sub(r",\s+\w+\s*:", "", type_str) + + # Normalize comma-separated types to unions (outside brackets only) + # "int, float" -> "int | float" but not "tuple[int, float]" + def replace_commas_outside_brackets(s: str) -> str: + result: list[str] = [] + depth = 0 + i = 0 + while i < len(s): + if s[i] in "([": + depth += 1 + result.append(s[i]) + elif s[i] in ")]": + depth -= 1 + result.append(s[i]) + elif s[i] == "," and depth == 0: + result.append(" |") + else: + result.append(s[i]) + i += 1 + return "".join(result) + + if "Callable" not in type_str: + type_str = replace_commas_outside_brackets(type_str) + # Strip RST directives like ".. note::" and everything after + type_str = re.sub(r"\.\.\s+\w+::.*", "", type_str, flags=re.DOTALL) + # Strip trailing prose (sentences after a valid type), but not type keywords like None + type_str = re.sub( + r"\s+(?!None\b|True\b|False\b)[A-Z][a-z]+\s+[a-z].*$", "", type_str + ) + # Strip prose after "or None" / "| None" (e.g. "or None when there is no intersection") + type_str = re.sub(r"(\bNone)\s+\w.*$", r"\1", type_str) + # Strip "of size N" suffixes + type_str = re.sub(r"\s+of size \d+", "", type_str) + # Strip dimension prefixes like "2d ", "3D ", "4x4 ", "1D or 2D " before type names + # Must run before "Sequence of" regex to avoid capturing "3d" as a type + type_str = re.sub(r"\b\d+[dDxX]\d*(?:\s+or\s+\d+[dDxX]\d*)*\s+", "", type_str) + # "Sequence of Xs containing Ys" -> "Sequence[Sequence[Y]]" + type_str = re.sub( + r"\b[Ss]equence of \w+s\s+containing\s+(\w+)s?\b", + lambda m: f"Sequence[Sequence[{m.group(1)}]]", + type_str, + ) + # "Xs containing Y" -> "Sequence[Y]" (standalone, not after "of") + type_str = re.sub( + r"\b\w+s\s+containing\s+(\w+)s?\b", + lambda m: f"Sequence[{m.group(1)}]", + type_str, + ) + # Normalize prose-style generic types like "sequence of X", "iterable of X", "collection of X" + # Optionally skip leading dimension descriptions: "sequence of 3 or 4 floats" -> "Sequence[float]" + _dim_prefix = r"(?:\d+(?:\s+(?:or|and|to)\s+(?:\d+|more|fewer))*\s+)?" + type_str = re.sub( + rf"\b[Ss]equence of {_dim_prefix}(\w[\w.]*)\b", + lambda m: f"Sequence[{m.group(1)}]", + type_str, + ) + type_str = re.sub( + rf"\b[Ii]terable of {_dim_prefix}(\w[\w.]*)\b", + lambda m: f"Iterable[{m.group(1)}]", + type_str, + ) + type_str = re.sub( + rf"\b[Cc]ollection of {_dim_prefix}(\w[\w.]*)\b", + lambda m: f"Collection[{m.group(1)}]", + type_str, + ) + # Handle "Sequence of (A, B)" -> "Sequence[tuple[A, B]]" + type_str = re.sub( + r"\b[Ss]equence of \(([^)]+)\)", + lambda m: f"Sequence[tuple[{m.group(1)}]]", + type_str, + ) + type_str = re.sub( + r"\b[Ii]terable of \(([^)]+)\)", + lambda m: f"Iterable[tuple[{m.group(1)}]]", + type_str, + ) + # Strip prose qualifiers like "float triplet" -> "float" + type_str = re.sub(r"\b(float|int)\s+(triplet|pair|array)\b", r"\1", type_str) + # Strip number words used as counts (e.g. "four floats" -> "floats") + type_str = re.sub( + r"\b(?:one|two|three|four|five|six|seven|eight|nine|ten)\s+", + "", + type_str, + ) + # Handle "tuple of [N] type" -> "tuple[type, ...]" + type_str = re.sub( + r"\btuple of (?:\d+ )?([\w.]+\w)\b", + lambda m: f"tuple[{m.group(1)}, ...]", + type_str, + ) + # Handle "list of type" -> "list[type]" (type can be dotted like mathutils.Vector) + type_str = re.sub( + r"\blist of ([\w.]+\w)\b", + lambda m: f"list[{m.group(1)}]", + type_str, + ) + # Map "class" -> "type" (used as param type in some docstrings) + type_str = re.sub(r"\bclass\b", "type", type_str) + # Map plural/informal type names to proper Python types + type_str = re.sub(r"\bstrings\b", "str", type_str) + type_str = re.sub(r"\bfloats\b", "float", type_str) + type_str = re.sub(r"\bints\b", "int", type_str) + type_str = re.sub(r"\bbools\b", "bool", type_str) + type_str = re.sub(r"\bnumbers\b", "float", type_str) + type_str = re.sub(r"\bvectors\b", "mathutils.Vector", type_str) + type_str = re.sub(r"\bmatrices\b", "mathutils.Matrix", type_str) + type_str = re.sub(r"\btuples\b", "tuple[object, ...]", type_str) + type_str = re.sub(r"\bstring\b", "str", type_str) + type_str = re.sub(r"\bdouble\b", "float", type_str) + type_str = re.sub(r"\binteger\b", "int", type_str) + type_str = re.sub(r"\bboolean\b", "bool", type_str) + type_str = re.sub(r"\bnumber\b", "float", type_str) + type_str = re.sub(r"\buint\b", "int", type_str) + # Map NoneType -> None (valid in type annotations) + type_str = re.sub(r"\bNone[Tt]ype\b", "None", type_str) + # Map types that don't exist in Python stubs + type_str = re.sub(r"\bbuffer\b", "object", type_str) + type_str = re.sub(r"\b[Aa]ny\b", "object", type_str) + # Map idprop internal types to object (not available in stubs) + type_str = re.sub(r"\bidprop\.types?\.\w+\b", "object", type_str) + type_str = re.sub(r"\b(?:bpy\.types\.)?IDProperty\w*\b", "object", type_str) + # Map bpy_prop and bpy.types.bpy_prop (internal base, not in stubs) to object + type_str = re.sub(r"\b(?:bpy\.types\.)?bpy_prop\b(?!_)", "object", type_str) + # Handle "TYPE sequence" -> "Sequence[TYPE]" (e.g. "int sequence" -> "Sequence[int]") + type_str = re.sub(r"\b(int|float|bool|str)\s+sequence\b", r"Sequence[\1]", type_str) + # Normalize "X or Y" -> "X | Y" + type_str = re.sub(r"\s+or\s+", " | ", type_str) + # Remove pipe-wrapped type macros like |UV_STICKY_SELECT_MODE_TYPE| + type_str = re.sub(r"\|[A-Z_]+\|", "str", type_str) + # Map "callable" / "function" -> "Callable[..., object]" + type_str = re.sub(r"\bcallable\b", "Callable[..., object]", type_str) + type_str = re.sub(r"\bfunction\b", "Callable[..., object]", type_str) + # Map lowercase generic names to proper capitalized forms + type_str = re.sub(r"\bgenerator\b", "Generator", type_str) + type_str = re.sub(r"\bsequence\b", "Sequence", type_str) + # Strip numeric type args: "Sequence[3]" -> "Sequence" (becomes bare, parameterized below) + type_str = re.sub(r"\[\d+\]", "", type_str) + # Strip numeric-only union parts: "| 2 | 3" from dimension descriptions + type_str = re.sub(r"\s*\|\s*\d+\b", "", type_str) + # Empty brackets after a type name (e.g. dict[] from malformed Blender docstrings) + # -> strip them so bare generic handling kicks in. Don't strip [] inside Callable[[], ...] + type_str = re.sub(r"(\w)\[\]", r"\1", type_str) + # Bare generics without params -> add default params. + # Use \b on both sides to avoid matching inside longer names (e.g. SequenceEntry). + type_str = re.sub(r"\bCallable\b(?!\[)", "Callable[..., object]", type_str) + type_str = re.sub(r"\bdict\b(?!\[)", "dict[str, object]", type_str) + type_str = re.sub(r"\blist\b(?!\[)", "list[object]", type_str) + type_str = re.sub(r"\btuple\b(?!\[)", "tuple[object, ...]", type_str) + type_str = re.sub(r"\bset\b(?!\[)", "set[object]", type_str) + type_str = re.sub(r"\bfrozenset\b(?!\[)", "frozenset[object]", type_str) + type_str = re.sub(r"\bGenerator\b(?!\[)", "Generator[object, None, None]", type_str) + type_str = re.sub(r"\bSequence\b(?!\[)", "Sequence[object]", type_str) + type_str = re.sub(r"\bIterator\b(?!\[)", "Iterator[object]", type_str) + type_str = re.sub(r"\bIterable\b(?!\[)", "Iterable[object]", type_str) + + # Fix Sequence/list with multiple type args (docstring bug): + # Sequence[int, int] -> Sequence[tuple[int, int]] + type_str = re.sub( + r"\bSequence\[(\w+),\s*(\w+)\]", + r"Sequence[tuple[\1, \2]]", + type_str, + ) + # list[X, Y, ...] with >1 type args -> treat as list[X] (drop extras) + def _fix_multi_arg_list(m: re.Match[str]) -> str: + inner = m.group(1) + # If it contains nested generics like list[float], keep first one + parts = [] + depth = 0 + current: list[str] = [] + for ch in inner: + if ch in "([": + depth += 1 + elif ch in ")]": + depth -= 1 + if ch == "," and depth == 0: + parts.append("".join(current).strip()) + current = [] + else: + current.append(ch) + parts.append("".join(current).strip()) + if len(parts) <= 1: + return m.group(0) + # If all parts are identical, use list[that_type] + non_ellipsis = [p for p in parts if p != "..."] + if len(set(non_ellipsis)) == 1: + return f"list[{non_ellipsis[0]}]" + # Mixed types -> treat as tuple + return f"tuple[{', '.join(non_ellipsis)}]" + + # Apply from innermost out, then handle nested brackets + prev = "" + while prev != type_str: + prev = type_str + type_str = re.sub(r"\blist\[([^\[\]]+)\]", _fix_multi_arg_list, type_str) + type_str = re.sub(r"\blist\[(.+)\]", _fix_multi_arg_list, type_str) + + # Fix Literal[X, Y, Z] -> Literal['X', 'Y', 'Z'] (quote bare identifiers) + def fix_literal(m: re.Match[str]) -> str: + items = m.group(1) + quoted = ", ".join( + f"'{item.strip()}'" if not item.strip().startswith("'") else item.strip() + for item in items.split(",") + ) + return f"Literal[{quoted}]" + + type_str = re.sub(r"\bLiteral\[([^\]]+)\]", fix_literal, type_str) + informal_types: dict[str, str] = { + "vector": "mathutils.Vector", + "matrix": "mathutils.Matrix", + "quaternion": "mathutils.Quaternion", + "euler": "mathutils.Euler", + "color": "mathutils.Color", + } + for informal, formal in informal_types.items(): + type_str = re.sub( + rf"(?()\"']+$", type_str): + return "object" + # If brackets are unbalanced, the type is malformed — fall back to object + if type_str.count("[") != type_str.count("]") or type_str.count( + "(" + ) != type_str.count(")"): + return "object" + # Strip English articles before type names + type_str = re.sub(r"\b(a|an|the)\s+", "", type_str, flags=re.IGNORECASE) + # Clean up empty union parts and trailing pipes + type_str = re.sub(r"\|\s*\|", "|", type_str) + type_str = re.sub(r"\|\s*$", "", type_str) + type_str = re.sub(r"^\s*\|", "", type_str) + # Strip trailing text/prose after types (colon-separated or space-separated) + type_str = re.sub(r"(\])\s*:.*", r"\1", type_str) + type_str = re.sub(r"(\])\s+\w.*", r"\1", type_str) + type_str = re.sub(r"(\w)\s*:\s+\w.*", r"\1", type_str) + # Map informal numeric types + type_str = re.sub(r"\breal\b", "float", type_str) + + # Split union on | only outside brackets + def split_union(s: str) -> list[str]: + parts: list[str] = [] + current: list[str] = [] + depth = 0 + for ch in s: + if ch in "([": + depth += 1 + current.append(ch) + elif ch in ")]": + depth -= 1 + current.append(ch) + elif ch == "|" and depth == 0: + parts.append("".join(current)) + current = [] + else: + current.append(ch) + parts.append("".join(current)) + return parts + + # Final fallback: check each union component for prose (spaces without brackets) + # Also strip trailing punctuation from each component + if "|" in type_str: + parts = [p.strip().rstrip(".,;:") for p in split_union(type_str)] + + def is_valid_type(t: str) -> bool: + if " " in t and "[" not in t: + return False + # snake_case identifiers are variable names, not types + # (e.g. "sphere_radius" from a buggy docstring) + if re.match(r"^[a-z][a-z0-9_]+$", t) and "_" in t: + return False + # Bare lowercase words that aren't known types (e.g. "four", "sequences") + if re.match(r"^[a-z]+$", t) and t not in ( + "bool", + "int", + "float", + "str", + "bytes", + "object", + "type", + "None", + ): + return False + return True + + cleaned = [p if is_valid_type(p) else "object" for p in parts if p] + # Deduplicate while preserving order + seen: set[str] = set() + unique: list[str] = [] + for p in cleaned: + if p not in seen: + seen.add(p) + unique.append(p) + type_str = " | ".join(unique) + elif " " in type_str and "[" not in type_str: + type_str = "object" + + # Standalone invalid types (snake_case variable names, bare lowercase prose words) + if re.match(r"^[a-z][a-z0-9_]+$", type_str) and "_" in type_str: + type_str = "object" + if re.match(r"^[a-z]+$", type_str) and type_str not in ( + "bool", + "int", + "float", + "str", + "bytes", + "object", + "type", + ): + type_str = "object" + + # Final balance check — catch any remaining malformed types + if type_str.count("[") != type_str.count("]") or type_str.count( + "(" + ) != type_str.count(")"): + return "object" + return type_str + + +def sanitize_default(value: str) -> str: + """Sanitize a repr'd default value to be valid Python syntax.""" + if "<" in value: + return "..." + # Replace callable/mutable defaults with ... (not valid as literal defaults in stubs) + if value in ("set()", "frozenset()", "dict()", "list()"): + return "..." + if value.startswith("{") or value.startswith("["): + return "..." + # Replace complex expressions (e.g. sys.float_info.min) with ... + if "." in value and not value.replace(".", "", 1).lstrip("-").isdigit(): + return "..." + # Replace bare identifiers that aren't Python literals + # (e.g. "data" from "data=data" in RST signatures) + if value.isidentifier() and value not in ("True", "False", "None"): + return "..." + # Parenthesized single value like (1) is not a valid tuple literal + if re.match(r"^\(\d+\)$", value): + return "..." + return value + + +# Types that are C-level descriptors, not valid as type annotations +C_INTERNAL_TYPES = { + "getset_descriptor", + "member_descriptor", + "method_descriptor", + "wrapper_descriptor", + "builtin_function_or_method", + "_tuplegetter", + "classmethod_descriptor", + "_translations_type", +} + + +def clean_docstring(docstring: str) -> str: + """Extract the descriptive part of a docstring, removing RST directives and markup.""" + if not docstring: + return "" + lines: list[str] = [] + skip_block = False + for line in docstring.split("\n"): + stripped = line.strip() + # Stop at type annotation directives + if stripped.startswith((":arg ", ":type ", ":rtype:", ":return:", ":returns:")): + break + # Skip RST directive blocks (.. code-block::, .. method::, .. seealso::, etc.) + if stripped.startswith(".. "): + skip_block = True + continue + # Indented lines after a directive are part of the block + if skip_block: + if stripped and not line[0].isspace(): + skip_block = False + else: + continue + lines.append(line) + while lines and not lines[-1].strip(): + lines.pop() + return "\n".join(lines) + + +def param_kind_str(kind: int) -> str: + """Convert inspect parameter kind to a string.""" + if kind == inspect.Parameter.POSITIONAL_ONLY: + return "POSITIONAL_ONLY" + if kind == inspect.Parameter.POSITIONAL_OR_KEYWORD: + return "POSITIONAL_OR_KEYWORD" + if kind == inspect.Parameter.VAR_POSITIONAL: + return "VAR_POSITIONAL" + if kind == inspect.Parameter.KEYWORD_ONLY: + return "KEYWORD_ONLY" + if kind == inspect.Parameter.VAR_KEYWORD: + return "VAR_KEYWORD" + msg = f"Unknown parameter kind: {kind}" + raise ValueError(msg) + + +# bpy.props param names whose type is always set[str] (string option enums) +PROP_SET_PARAMS = {"options", "override", "tags", "search_options"} + + +def refine_types_by_context( + func_name: str, + param_types: dict[str, str], + return_type: str | None, +) -> tuple[dict[str, str], str | None]: + """Refine imprecise types using function name context. + + For example, BoolVectorProperty's 'default' param with bare 'Sequence' + can be refined to 'Sequence[bool]' from the function name. + """ + is_property_func = func_name.endswith("Property") + + element_type_map: dict[str, str] = { + "Bool": "bool", + "Float": "float", + "Int": "int", + } + for prefix, element_type in element_type_map.items(): + if func_name.startswith(prefix) and "Vector" in func_name: + for pname, ptype in param_types.items(): + if pname == "default" and ptype in ("Sequence", "Sequence[object]"): + param_types[pname] = f"Sequence[{element_type}]" + + # bpy.props *Property functions: all set types contain string enum values + if is_property_func: + for pname, ptype in param_types.items(): + if "set[object]" in ptype: + param_types[pname] = ptype.replace("set[object]", "set[str]") + + if return_type in ("Generator", "Generator[object, None, None]"): + return_type = "Generator[str, None, None]" + + return param_types, return_type + + +def parse_rst_function_sig( + docstring: str, +) -> dict[str, tuple[str | None, str]]: + """Parse the '.. function:: name(args)' RST directive for defaults and kinds. + + Returns {param_name: (default_value_or_None, kind_str)}. + """ + result: dict[str, tuple[str | None, str]] = {} + # Find the function signature, handling nested parens in defaults like set() + match = re.search(r"\.\.\s+(?:function|method|class)::\s+\w+\(", docstring) + if not match: + return result + + # Extract content between outermost parens, respecting nesting + start = match.end() + depth = 1 + i = start + while i < len(docstring) and depth > 0: + if docstring[i] == "(": + depth += 1 + elif docstring[i] == ")": + depth -= 1 + i += 1 + sig_str = docstring[start : i - 1] + # Strip RST optional parameter brackets: + # "data[, position]" -> "data, position" + # "[rows]" -> "rows" (all-optional) + # These indicate optional params in RST, not Python generics. + # Process from innermost outward to handle nested brackets like "a[, b[, c]]" + while "[," in sig_str: + sig_str = re.sub(r"\[,([^\[\]]*)\]", r",\1", sig_str) + # Handle remaining RST optional brackets: "[param]" or "[param=default]" + # Only strip brackets that wrap param-like content (identifiers, not types) + while re.search(r"\[(?!['\"])\w+[^\[\]]*\]", sig_str): + sig_str = re.sub(r"\[(\w+[^\[\]]*)\]", r"\1", sig_str) + parts: list[str] = [] + current: list[str] = [] + depth = 0 + for ch in sig_str: + if ch in "({[": + depth += 1 + current.append(ch) + elif ch in ")}]": + depth -= 1 + current.append(ch) + elif ch == "," and depth == 0: + parts.append("".join(current)) + current = [] + else: + current.append(ch) + if current: + parts.append("".join(current)) + + kind = "POSITIONAL_OR_KEYWORD" + for part in parts: + part = part.strip() + if not part: + continue + if part == "/": + # Positional-only separator: mark all preceding params as POSITIONAL_ONLY + for pname in result: + result[pname] = (result[pname][0], "POSITIONAL_ONLY") + continue + if part == "*": + kind = "KEYWORD_ONLY" + continue + if part.startswith("**"): + param_name = part.lstrip("*").split("=")[0].strip() + result[param_name] = (None, "VAR_KEYWORD") + continue + if part.startswith("*"): + param_name = part.lstrip("*").split("=")[0].strip() + result[param_name] = (None, "VAR_POSITIONAL") + kind = "KEYWORD_ONLY" + continue + + if "=" in part: + param_name, default = part.split("=", 1) + result[param_name.strip()] = (sanitize_default(default.strip()), kind) + else: + result[part.strip()] = (None, kind) + + return result + + +def introspect_callable(func: Callable[..., object], name: str) -> FunctionData | None: + """Introspect a callable (function or builtin) and return its metadata.""" + docstring = inspect.getdoc(func) or "" + param_types, return_type = parse_docstring_types(docstring) + param_types, return_type = refine_types_by_context(name, param_types, return_type) + + try: + sig = inspect.signature(func) + # fmt: off + except (ValueError, TypeError): + # fmt: on + # C extension without signature — build params from docstring :type: + # and extract defaults/kinds from RST ".. function::" directive + rst_sig = parse_rst_function_sig(docstring) + params: list[ParamData] = [] + + if rst_sig: + # RST signature has the authoritative param names and order. + # Match :type: info by name first, then positionally for mismatches. + # Positional fallback only fires when ALL :type: names are mismatched + # (i.e. the docstring uses different names than the RST signature). + rst_names = set(rst_sig.keys()) + any_name_match = bool(rst_names & set(param_types.keys())) + type_values = list(param_types.values()) + positional_idx = 0 + for rst_name, (default, kind) in rst_sig.items(): + param_type = param_types.get(rst_name) + if param_type is None and not any_name_match and type_values: + # Positional fallback: all :type: names differ from RST names + if positional_idx < len(type_values): + param_type = type_values[positional_idx] + positional_idx += 1 + if ( + default == "None" + and param_type + and not re.search(r"\| None\b", param_type) + ): + param_type = param_type + " | None" + params.append( + { + "name": rst_name, + "type": param_type, + "default": default, + "kind": kind, + } + ) + else: + # No RST signature — use :type: directives only + for param_name, param_type in param_types.items(): + params.append( + { + "name": param_name, + "type": param_type, + "default": None, + "kind": "POSITIONAL_OR_KEYWORD", + } + ) + return { + "name": name, + "doc": clean_docstring(docstring), + "params": params, + "return_type": return_type, + "is_classmethod": False, + } + + # Build positional fallback for param name mismatches: + # C functions often use generic names like "object" in __text_signature__ + # while docstrings use descriptive names like "string", "cls", etc. + doc_param_list = list(param_types.items()) + sig_param_list = list(sig.parameters.items()) + + params = [] + for i, (pname, param) in enumerate(sig_param_list): + default: str | None = None + if param.default is not inspect.Parameter.empty: + default = sanitize_default(repr(param.default)) + + type_str = param_types.get(pname) + actual_name = pname + + # Positional fallback: use docstring name + type when sig name doesn't match + if type_str is None and i < len(doc_param_list): + doc_name, doc_type = doc_param_list[i] + if doc_name not in sig.parameters: + type_str = doc_type + actual_name = doc_name + + if default == "None" and type_str and not re.search(r"\| None\b", type_str): + type_str = type_str + " | None" + + params.append( + { + "name": actual_name, + "type": type_str, + "default": default, + "kind": param_kind_str(param.kind), + } + ) + + return { + "name": name, + "doc": clean_docstring(docstring), + "params": params, + "return_type": return_type, + "is_classmethod": False, + } + + +RUNTIME_TYPE_QUALIFICATIONS: dict[str, str] = { + "Context": "bpy.types.Context", + "BlendData": "bpy.types.BlendData", + "bpy_app_translations": "object", + "dict": "dict[str, object]", + "tuple": "tuple[object, ...]", + "OrderedDict": "collections.OrderedDict[str, object]", + "Callable": "Callable[..., object]", + "ShaderWrapper": "object", +} + + +def python_type_name(obj: object, var_name: str = "") -> str: + """Get a reasonable type annotation string for a Python object.""" + type_name = type(obj).__name__ + if type_name in C_INTERNAL_TYPES: + return "object" + if type_name == var_name: + return "object" + if type_name in RUNTIME_TYPE_QUALIFICATIONS: + return RUNTIME_TYPE_QUALIFICATIONS[type_name] + if isinstance(obj, type): + return f"type[{obj.__name__}]" + # Parameterize containers by inspecting their runtime contents. + # Cast before list() to avoid basedpyright inferring set[Unknown]/list[Unknown] + # from isinstance narrowing of `object`. + if type_name in ("set", "frozenset", "list"): + from collections.abc import Iterable + + contents = list(cast(Iterable[object], obj)) + if contents: + elem_type = type(contents[0]).__name__ + if elem_type in C_INTERNAL_TYPES: + elem_type = "object" + elif elem_type in RUNTIME_TYPE_QUALIFICATIONS: + elem_type = RUNTIME_TYPE_QUALIFICATIONS[elem_type] + elif isinstance(obj, (set, frozenset)): + elem_type = "str" + else: + elem_type = "object" + return f"{type_name}[{elem_type}]" + return type_name + + +def _parse_class_constructor(class_doc: str, cls: type) -> FunctionData | None: + """Parse a ``.. class:: ClassName(params)`` RST directive into an __init__ method. + + C extension types expose constructor info in their class docstring rather + than via an inspectable ``__init__``. Returns None if no constructor + directive is found or if the constructor takes no parameters. + """ + # Check if this class already has an inspectable __init__ with a real signature + init = cls.__dict__.get("__init__") + if init is not None: + try: + sig = inspect.signature(init) + # Has real params beyond just *args/**kwargs → skip RST parsing + real_params = [ + p + for p in sig.parameters.values() + if p.name != "self" + and p.kind + not in ( + inspect.Parameter.VAR_POSITIONAL, + inspect.Parameter.VAR_KEYWORD, + ) + ] + if real_params: + return None + except (ValueError, TypeError): + pass + + # Look for ".. class:: ClassName(params)" in the docstring + if not re.search(r"\.\.\s+class::", class_doc): + return None + + rst_sig = parse_rst_function_sig(class_doc) + if not rst_sig: + return None + + param_types, _ = parse_docstring_types(class_doc) + + params: list[ParamData] = [] + for param_name, (default, kind) in rst_sig.items(): + param_type = param_types.get(param_name) + if default == "None" and param_type and not re.search(r"\| None\b", param_type): + param_type = param_type + " | None" + params.append( + { + "name": param_name, + "type": param_type, + "default": default, + "kind": kind, + } + ) + + if not params: + return None + + return { + "name": "__init__", + "doc": "", + "params": params, + "return_type": "None", + "is_classmethod": False, + } + + +def introspect_class(cls: type, module_name: str) -> StructData: + """Introspect a class (C extension or Python) and return StructData.""" + # Determine base class (skip object and internal bases) + bases = [ + b for b in cls.__mro__[1:] if b is not object and b.__module__ != "builtins" + ] + base_name: str | None = None + if bases: + base_cls = bases[0] + # Only use the base if it's accessible (in the same module's public API + # or fully qualified from another module) + parent_mod = importlib.import_module(base_cls.__module__) + public = getattr(parent_mod, "__all__", None) + is_public = public is None or base_cls.__name__ in public + if is_public: + if base_cls.__module__ == module_name: + base_name = base_cls.__name__ + else: + base_name = f"{base_cls.__module__}.{base_cls.__name__}" + + properties: list[PropertyData] = [] + methods: list[FunctionData] = [] + + for name in sorted(dir(cls)): + if name.startswith("_"): + continue + + try: + obj = getattr(cls, name) + except AttributeError: + continue + + # Check if this member is defined on this class, not inherited + if name not in cls.__dict__: + continue + + raw = cls.__dict__[name] + + if ( + isinstance(raw, classmethod) + or type(raw).__name__ == "classmethod_descriptor" + ): + func_data = introspect_callable(obj, name) + if func_data: + func_data["is_classmethod"] = True + methods.append(func_data) + elif isinstance(raw, staticmethod): + func_data = introspect_callable(obj, name) + if func_data: + methods.append(func_data) + elif callable(obj): + func_data = introspect_callable(obj, name) + if func_data: + methods.append(func_data) + elif isinstance(raw, property) or type(raw).__name__ == "getset_descriptor": + doc = inspect.getdoc(raw) or "" + _, rtype = parse_docstring_types(doc) + is_readonly = not hasattr(raw, "fset") or raw.fset is None + properties.append( + { + "name": name, + "type": rtype or "object", + "is_readonly": is_readonly, + "description": doc, + } + ) + else: + properties.append( + { + "name": name, + "type": python_type_name(obj, name), + "is_readonly": True, + "description": "", + } + ) + + # Generate __init__ from the class docstring's ".. class::" RST directive + # (C extension types expose constructor info this way, not via __init__) + class_doc = inspect.getdoc(cls) or "" + init_method = _parse_class_constructor(class_doc, cls) + if init_method: + methods.insert(0, init_method) + + return { + "name": cls.__name__, + "doc": class_doc, + "base": base_name, + "properties": properties, + "methods": methods, + } + + +def infer_getter_return_types(functions: list[FunctionData]) -> None: + """Infer return types for *_get functions from matching *_set parameters. + + Many Blender modules (e.g. gpu.state) follow a pattern where ``foo_set(value)`` + and ``foo_get()`` are paired. When the getter has no return type but the setter + has a typed parameter, the getter's return type is inferred from it. + """ + setters: dict[str, str] = {} + for func in functions: + name = func["name"] + if not name.endswith("_set"): + continue + params = func["params"] + if len(params) != 1: + continue + param_type = params[0].get("type") + if param_type: + prefix = name[: -len("_set")] + setters[prefix] = param_type + + for func in functions: + name = func["name"] + if not name.endswith("_get"): + continue + if func["return_type"] is not None: + continue + prefix = name[: -len("_get")] + if prefix in setters: + func["return_type"] = setters[prefix] + + +def introspect_module(module_name: str) -> ModuleData: + """Introspect a module and return its full metadata as a dict.""" + if module_name == "bpy.types": + return introspect_rna_types() + + module = importlib.import_module(module_name) + + # Use __all__ as the base, but also include public callables from dir() + # that are defined in this module (not imported from elsewhere). + # This catches functions like is_path_builtin that are in the module + # but not in __all__. + all_attr: tuple[str, ...] | None = getattr(module, "__all__", None) + if all_attr is not None: + names_set = set(all_attr) + for n in dir(module): + if n.startswith("_") or n in names_set: + continue + obj = getattr(module, n, None) + if obj is None: + continue + # Only add functions/classes defined in this module + obj_module = getattr(obj, "__module__", None) + if obj_module == module_name and (callable(obj) or isinstance(obj, type)): + names_set.add(n) + # Also add type aliases (e.g. FCurveKey = Tuple[str, int]) + # but not stdlib re-exports (Iterable, Sequence, etc.) + elif hasattr(obj, "__origin__") and n not in { + "Callable", "Collection", "Generator", "Iterable", + "Iterator", "Mapping", "MutableMapping", "MutableSequence", + "MutableSet", "Sequence", "Set", "FrozenSet", + "Dict", "List", "Tuple", "Type", "Optional", "Union", + }: + names_set.add(n) + public_names: list[str] = sorted(names_set) + else: + public_names = [n for n in dir(module) if not n.startswith("_")] + + functions: list[FunctionData] = [] + variables: list[VariableData] = [] + structs: list[StructData] = [] + + for name in sorted(public_names): + obj = getattr(module, name, None) + if obj is None: + continue + + # Skip submodules + if isinstance(obj, ModuleType): + continue + + if isinstance(obj, type): + structs.append(introspect_class(obj, module_name)) + elif hasattr(obj, "__origin__") or ( + hasattr(obj, "__module__") and getattr(obj, "__module__", "") == "typing" + ): + # Type alias (e.g. FCurveKey = Tuple[str, int]) + type_repr = str(obj).replace("typing.", "") + # Normalize old-style typing generics to PEP 585 (Tuple -> tuple, etc.) + type_repr = re.sub(r"\bTuple\b", "tuple", type_repr) + type_repr = re.sub(r"\bList\b", "list", type_repr) + type_repr = re.sub(r"\bDict\b", "dict", type_repr) + type_repr = re.sub(r"\bSet\b", "set", type_repr) + type_repr = re.sub(r"\bFrozenSet\b", "frozenset", type_repr) + variables.append( + { + "name": name, + "type": f"TypeAlias", + "value": type_repr, + } + ) + elif callable(obj): + func_data = introspect_callable(obj, name) + if func_data: + functions.append(func_data) + else: + variables.append( + { + "name": name, + "type": python_type_name(obj, name), + "value": repr(obj), + } + ) + + infer_getter_return_types(functions) + + return { + "module": module_name, + "doc": inspect.getdoc(module) or "", + "functions": functions, + "variables": variables, + "structs": structs, + } + + +def _try_import_or_attr(module_name: str) -> bool: + """Try to import a module, falling back to attribute lookup on parent. + + Returns True if the module is now accessible via importlib. + """ + try: + importlib.import_module(module_name) + return True + except ImportError: + pass + + # Fallback: access the submodule via attribute lookup on the parent + # and register it in sys.modules so importlib works later. + # This is needed for C-level submodules in older Blender versions (< 4.1). + parts = module_name.split(".") + try: + parent = importlib.import_module(parts[0]) + obj: object = parent + for part in parts[1:]: + obj = getattr(obj, part) + if isinstance(obj, ModuleType): + sys.modules[module_name] = obj + return True + except (ImportError, AttributeError): + pass + + return False + + +def _discover_submodules_via_dir(mod: ModuleType, parent_name: str) -> list[str]: + """Discover C-level submodules by inspecting dir() for ModuleType attributes. + + pkgutil.walk_packages only works for filesystem-backed packages with __path__. + Many Blender modules (gpu.state, bpy.app.handlers, etc.) are C-level and + only discoverable via attribute access. + """ + found: list[str] = [] + for attr_name in dir(mod): + if attr_name.startswith("_"): + continue + obj = getattr(mod, attr_name, None) + if isinstance(obj, ModuleType): + submodule_name = f"{parent_name}.{attr_name}" + # Verify the module actually belongs to this parent + # (filter out stray re-exports like 'sys', 'os', etc.) + obj_name = getattr(obj, "__name__", "") + if obj_name == submodule_name or obj_name.startswith(parent_name + "."): + found.append(submodule_name) + return found + + +def discover_modules() -> list[str]: + """Discover all Blender Python modules and submodules.""" + modules: list[str] = [] + seen: set[str] = set() + + def _add(name: str) -> bool: + if name in seen: + return False + seen.add(name) + modules.append(name) + return True + + for top_name in BLENDER_MODULES: + try: + mod = importlib.import_module(top_name) + except ImportError: + print(f" Skipping {top_name} (import failed)", file=sys.stderr) + continue + + _add(top_name) + + # Discover via pkgutil for filesystem-backed packages + if hasattr(mod, "__path__"): + for _importer, subname, _ispkg in pkgutil.walk_packages( + mod.__path__, prefix=top_name + "." + ): + try: + importlib.import_module(subname) + _add(subname) + except ImportError: + print(f" Skipping {subname} (import failed)", file=sys.stderr) + + # Also discover C-level submodules via dir() attribute inspection + for subname in _discover_submodules_via_dir(mod, top_name): + if _try_import_or_attr(subname): + if _add(subname): + # Recurse one level for nested submodules (e.g. bpy.app.handlers) + sub_mod = importlib.import_module(subname) + for nested in _discover_submodules_via_dir(sub_mod, subname): + if _try_import_or_attr(nested): + _add(nested) + + # Add hardcoded extra modules that can't be discovered via dir() either + # (e.g. modules only accessible after explicit import in some versions) + for extra in EXTRA_MODULES: + if extra not in seen and _try_import_or_attr(extra): + _add(extra) + + return modules + + +# --- RNA introspection (bpy.types) --- + +RNA_TYPE_MAP: dict[str, str] = { + "boolean": "bool", + "int": "int", + "float": "float", + "string": "str", + "enum": "str", +} + + +def rna_property_to_type(prop: object) -> str: + """Map an RNA property to a PEP 484 type annotation string.""" + 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") + return type_name + + if prop_type == "collection" and fixed_type is not None: + # Use the specific collection wrapper class (e.g. BlendDataImages) if + # available via srna, rather than the generic bpy_prop_collection[T]. + # This preserves collection-specific methods like new(), remove(), etc. + srna: object = getattr(prop, "srna", None) + if srna is not None: + srna_id: str = getattr(srna, "identifier", "") + if srna_id: + return srna_id + element_type: str = getattr(fixed_type, "identifier", "object") + return f"bpy_prop_collection[{element_type}]" + + # 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. + raw_prop: object = getattr(prop, "bl_prop", prop) + is_array: bool = getattr(raw_prop, "is_array", False) + if prop_type in ("float", "int", "boolean") and (array_length > 0 or is_array): + base = RNA_TYPE_MAP.get(prop_type, prop_type) + if array_length == 0 and is_array: + # Dynamic-length array — at runtime this is a bpy_prop_array + return f"bpy_prop_array[{base}]" + return f"list[{base}]" + + return RNA_TYPE_MAP.get(prop_type, prop_type) + + +def rna_function_to_data(func_info: object) -> FunctionData: + """Convert an RNA function info object to FunctionData.""" + identifier: str = getattr(func_info, "identifier", "") + description: str = getattr(func_info, "description", "") + is_classmethod: bool = getattr(func_info, "is_classmethod", False) + args_list: list[object] = getattr(func_info, "args", []) + return_values: tuple[object, ...] = getattr(func_info, "return_values", ()) + + params: list[ParamData] = [] + + if is_classmethod: + params.append( + { + "name": "cls", + "type": None, + "default": None, + "kind": "POSITIONAL_OR_KEYWORD", + } + ) + + for arg in args_list: + arg_name: str = getattr(arg, "identifier", "") + arg_type = rna_property_to_type(arg) + default_val: str | None = None + + is_required: bool = getattr(arg, "is_required", False) + + if not is_required: + arg_type = f"{arg_type} | None" + default_val = "None" + + params.append( + { + "name": arg_name, + "type": arg_type, + "default": default_val, + "kind": "POSITIONAL_OR_KEYWORD", + } + ) + + return_type: str | None = None + if return_values: + if len(return_values) == 1: + return_type = rna_property_to_type(return_values[0]) + else: + types = [rna_property_to_type(rv) for rv in return_values] + return_type = f"tuple[{', '.join(types)}]" + + return { + "name": identifier, + "doc": description, + "params": params, + "return_type": return_type, + "is_classmethod": is_classmethod, + } + + +def _import_rna_info() -> ModuleType: + """Import the rna_info module, handling different Blender versions.""" + try: + return importlib.import_module("_rna_info") + except ImportError: + return importlib.import_module("rna_info") + + +def _infer_type_from_runtime_value(value: object) -> str | None: + """Infer a type string from a runtime bpy.context attribute value.""" + type_name = type(value).__name__ + + # Single RNA objects — use the class name directly + if hasattr(type(value), "bl_rna"): + return type_name + + if isinstance(value, list): + contents = cast(list[object], value) + if contents: + elem_type = type(contents[0]) + if hasattr(elem_type, "bl_rna"): + return f"Sequence[{elem_type.__name__}]" + return None + + if isinstance(value, str): + return "str" + if isinstance(value, bool): + return "bool" + if isinstance(value, int): + return "int" + if isinstance(value, float): + return "float" + + return None + + +def introspect_screen_context_members( + rna_property_names: set[str], +) -> list[PropertyData]: + """Discover screen context members from bpy.context that aren't in RNA. + + These are dynamically injected by Blender based on the active editor/mode. + All are typed as T | None since they're context-dependent. + This function only runs inside Blender's Python environment. + """ + bpy = importlib.import_module("bpy") + ctx: object = getattr(bpy, "context") + skip = {"bl_rna", "id_data", "rna_type"} + + extra_attrs = sorted( + name + for name in dir(ctx) + if not name.startswith("_") + and name not in rna_property_names + and name not in skip + and not ( + callable(getattr(ctx, name)) + and not isinstance(getattr(ctx, name), (list, tuple)) + ) + ) + + properties: list[PropertyData] = [] + for name in extra_attrs: + try: + value = getattr(ctx, name) + except AttributeError: + continue + + type_str: str | None = None + + # Tier 1: runtime inspection (non-None values) + if value is not None: + type_str = _infer_type_from_runtime_value(value) + + # Tier 2: hardcoded override + if type_str is None: + type_str = SCREEN_CONTEXT_TYPE_OVERRIDES.get(name) + + # Tier 3: name-pattern heuristic + if type_str is None: + type_str = infer_context_member_type(name) + + # Final fallback + if type_str is None: + type_str = "object" + + # Sequence/collection types are never None — they return empty sequences. + # Only singular object references (active_object, etc.) can be None. + is_collection = type_str.startswith("Sequence[") or type_str.startswith( + "bpy_prop_collection[" + ) + final_type = type_str if is_collection else f"{type_str} | None" + properties.append( + { + "name": name, + "type": final_type, + "is_readonly": True, + "description": "", + } + ) + + # Also inject overrides not found in dir() (e.g. buttons context members + # like meta_ball, mesh, armature that require active UI panels) + discovered = {p["name"] for p in properties} + for name, type_str in sorted(SCREEN_CONTEXT_TYPE_OVERRIDES.items()): + if name not in discovered and name not in rna_property_names: + is_collection = type_str.startswith("Sequence[") or type_str.startswith( + "bpy_prop_collection[" + ) + final_type = type_str if is_collection else f"{type_str} | None" + properties.append( + { + "name": name, + "type": final_type, + "is_readonly": True, + "description": "", + } + ) + + return properties + + +def _validate_context_prop_type(type_str: str, known_types: set[str]) -> str: + """Replace type references that don't exist in this version with 'object'.""" + import re as _re + + def _replace_match(match: re.Match[str]) -> str: + name = match.group(1) + if name == "None": + return name + # "X[" is a generic usage (e.g. Sequence[...]) — keep it + end = match.end() + if end < len(type_str) and type_str[end] == "[": + return name + if name not in known_types: + return "object" + return name + + return _re.sub(r"\b([A-Z]\w+)\b", _replace_match, type_str) + + +def introspect_rna_types() -> ModuleData: + """Introspect all RNA-defined types using rna_info.BuildRNAInfo().""" + rna_info = _import_rna_info() + + info = rna_info.BuildRNAInfo() + structs_dict = info[0] + + # Introspect the C-level base classes that aren't in RNA but are in bpy.types. + # These provide fundamental methods like __getitem__, foreach_get, etc. + _bpy_types = importlib.import_module("bpy.types") + + # Generic base classes need manual type parameter annotation since + # introspection can't discover Python generics from C types. + _GENERIC_BASES: dict[str, str] = { + "bpy_prop_collection": "Generic[_T]", + "bpy_prop_array": "Generic[_T]", + } + + # Dunder methods for generic types can't be discovered from runtime since + # they need generic type parameters (_T). Define them explicitly. + _COLLECTION_DUNDERS: list[FunctionData] = [ + { + "name": "__getitem__", + "doc": "", + "params": [ + { + "name": "key", + "type": "int | str", + "default": None, + "kind": "POSITIONAL_OR_KEYWORD", + } + ], + "return_type": "_T", + "is_classmethod": False, + }, + { + "name": "__iter__", + "doc": "", + "params": [], + "return_type": "Iterator[_T]", + "is_classmethod": False, + }, + { + "name": "__len__", + "doc": "", + "params": [], + "return_type": "int", + "is_classmethod": False, + }, + { + "name": "__contains__", + "doc": "", + "params": [ + { + "name": "key", + "type": "str", + "default": None, + "kind": "POSITIONAL_OR_KEYWORD", + } + ], + "return_type": "bool", + "is_classmethod": False, + }, + ] + _ARRAY_DUNDERS: list[FunctionData] = [ + { + "name": "__getitem__", + "doc": "", + "params": [ + { + "name": "index", + "type": "int", + "default": None, + "kind": "POSITIONAL_OR_KEYWORD", + } + ], + "return_type": "_T", + "is_classmethod": False, + }, + { + "name": "__setitem__", + "doc": "", + "params": [ + { + "name": "index", + "type": "int", + "default": None, + "kind": "POSITIONAL_OR_KEYWORD", + }, + { + "name": "value", + "type": "_T", + "default": None, + "kind": "POSITIONAL_OR_KEYWORD", + }, + ], + "return_type": "None", + "is_classmethod": False, + }, + { + "name": "__delitem__", + "doc": "", + "params": [ + { + "name": "index", + "type": "int | slice", + "default": None, + "kind": "POSITIONAL_OR_KEYWORD", + } + ], + "return_type": "None", + "is_classmethod": False, + }, + { + "name": "__iter__", + "doc": "", + "params": [], + "return_type": "Iterator[_T]", + "is_classmethod": False, + }, + { + "name": "__len__", + "doc": "", + "params": [], + "return_type": "int", + "is_classmethod": False, + }, + { + "name": "__contains__", + "doc": "", + "params": [ + { + "name": "value", + "type": "_T", + "default": None, + "kind": "POSITIONAL_OR_KEYWORD", + } + ], + "return_type": "bool", + "is_classmethod": False, + }, + ] + _EXTRA_DUNDERS: dict[str, list[FunctionData]] = { + "bpy_prop_collection": _COLLECTION_DUNDERS, + "bpy_prop_array": _ARRAY_DUNDERS, + } + + structs: list[StructData] = [] + + # Introspect bpy_struct (base of all RNA types) + bpy_struct_cls = getattr(_bpy_types, "bpy_struct", None) + if bpy_struct_cls is not None: + structs.append(introspect_class(bpy_struct_cls, "bpy.types")) + + for cls_name in ["bpy_prop_collection", "bpy_prop_array"]: + cls = getattr(_bpy_types, cls_name, None) + if cls is not None: + struct = introspect_class(cls, "bpy.types") + struct["base"] = _GENERIC_BASES[cls_name] + struct["methods"] = _EXTRA_DUNDERS[cls_name] + struct["methods"] + structs.append(struct) + + # Build a map of collection wrapper class -> element type. + # When a property has type=collection and an srna, the srna identifies + # the wrapper class (e.g. BlendDataImages) and fixed_type is the element + # (e.g. Image). These wrappers should inherit from bpy_prop_collection[T]. + collection_element_types: dict[str, str] = {} + for struct_info in structs_dict.values(): + for prop in struct_info.properties: + if prop.type != "collection" or not prop.fixed_type: + continue + srna: object = getattr(prop, "srna", None) + if srna is not None: + srna_id: str = getattr(srna, "identifier", "") + if srna_id: + collection_element_types[srna_id] = prop.fixed_type.identifier + + for struct_info in sorted(structs_dict.values(), key=lambda s: s.identifier): + base_name: str | None = None + sid = struct_info.identifier + if sid in collection_element_types: + # Collection wrapper class — inherit from bpy_prop_collection[T] + base_name = f"bpy_prop_collection[{collection_element_types[sid]}]" + elif struct_info.base: + base_name = struct_info.base.identifier + else: + # All RNA types implicitly inherit from bpy_struct + base_name = "bpy_struct" + + properties: list[PropertyData] = [] + for prop in struct_info.properties: + properties.append( + { + "name": prop.identifier, + "type": rna_property_to_type(prop), + "is_readonly": prop.is_readonly, + "description": prop.description or "", + } + ) + + methods: list[FunctionData] = [] + is_collection_wrapper = sid in collection_element_types + for func_info in struct_info.functions: + # Skip RNA methods that would incompatibly override bpy_prop_collection + if is_collection_wrapper and func_info.identifier in ("find", "get"): + continue + methods.append(rna_function_to_data(func_info)) + + structs.append( + { + "name": struct_info.identifier, + "doc": struct_info.description or "", + "base": base_name, + "properties": properties, + "methods": methods, + } + ) + + # Add screen context members to the Context struct + known_types = {s["name"] for s in structs} + for struct in structs: + if struct["name"] == "Context": + 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) + # Validate type references and qualify Sequence to avoid + # shadowing by bpy.types.Sequence (video sequencer strip) + for prop in screen_props: + prop["type"] = _validate_context_prop_type(prop["type"], known_types) + prop["type"] = prop["type"].replace( + "Sequence[", "collections.abc.Sequence[" + ) + struct["properties"].extend(screen_props) + break + + return { + "module": "bpy.types", + "doc": "Blender RNA type definitions.", + "functions": [], + "variables": [], + "structs": structs, + } + + +@dataclass +class IntrospectArgs: + output: str | None = None + + +def main() -> None: + argv = sys.argv + if "--" in argv: + argv = argv[argv.index("--") + 1 :] + else: + argv = [] + + parser = argparse.ArgumentParser(description="Introspect Blender Python modules") + parser.add_argument( + "--output", default=None, help="Output JSON file (default: stdout)" + ) + parsed = parser.parse_args(argv) + args = IntrospectArgs(output=parsed.output) + + print("Discovering modules...", file=sys.stderr) + module_names = discover_modules() + print(f"Found {len(module_names)} modules", file=sys.stderr) + + results: list[ModuleData] = [] + for module_name in module_names: + print(f" Introspecting {module_name}...", file=sys.stderr) + results.append(introspect_module(module_name)) + + output = json.dumps(results, indent=2) + + if args.output: + with open(args.output, "w") as f: + f.write(output) + print(f"Written to {args.output}", file=sys.stderr) + else: + print("__INTROSPECT_JSON_START__") + print(output) + print("__INTROSPECT_JSON_END__") + + +if __name__ == "__main__": + main() diff --git a/main.py b/main.py new file mode 100644 index 0000000..8af432a --- /dev/null +++ b/main.py @@ -0,0 +1,417 @@ +"""Blender type stubs generator. + +Orchestrates the introspection and stub generation pipeline. +""" + +import argparse +import json +import shutil +import subprocess +import sys +import tomllib +from dataclasses import dataclass +from pathlib import Path +from typing import TypeAlias + +from blender_downloader import get_blender_executable +from generate_stubs import write_stubs +from introspect import ModuleData + +SCRIPT_DIR = Path(__file__).parent +INTROSPECT_SCRIPT = SCRIPT_DIR / "introspect.py" +OVERRIDES_DIR = SCRIPT_DIR / "overrides" + + +TomlValue: TypeAlias = str | int | bool | list["TomlValue"] | dict[str, "TomlValue"] +TomlDict: TypeAlias = dict[str, "TomlValue"] + + +def _serialize_toml_value(value: TomlValue) -> str: + """Serialize a single TOML value.""" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, str): + return f'"{value}"' + if isinstance(value, int): + return str(value) + if isinstance(value, list): + if value and isinstance(value[0], dict): + items: list[str] = [] + for item in value: + if isinstance(item, dict): + pairs = ", ".join( + f"{k} = {_serialize_toml_value(v)}" for k, v in item.items() + ) + items.append(f"{{ {pairs} }}") + joined = ",\n ".join(items) + return f"[\n {joined},\n]" + str_items = [_serialize_toml_value(v) for v in value] + if len(str_items) <= 3: + return f"[{', '.join(str_items)}]" + joined = ",\n ".join(str_items) + return f"[\n {joined},\n]" + return str(value) + + +def _serialize_toml(data: TomlDict, prefix: str = "") -> str: + """Serialize a dict to TOML format.""" + lines: list[str] = [] + tables: list[tuple[str, TomlDict]] = [] + + for key, value in data.items(): + full_key = f"{prefix}.{key}" if prefix else key + if isinstance(value, dict): + has_values = any(not isinstance(v, dict) for v in value.values()) + has_tables = any(isinstance(v, dict) for v in value.values()) + + if has_values: + tables.append((full_key, value)) + elif has_tables: + lines.append(_serialize_toml(value, full_key)) + else: + quoted_key = f'"{key}"' if "." in key else key + lines.append(f"{quoted_key} = {_serialize_toml_value(value)}") + + result_parts: list[str] = [] + if lines: + if prefix: + result_parts.append(f"[{prefix}]") + result_parts.extend(lines) + + for table_key, table_value in tables: + result_parts.append("") + result_parts.append(f"[{table_key}]") + for k, v in table_value.items(): + if isinstance(v, dict): + result_parts.append("") + result_parts.append(_serialize_toml(v, f"{table_key}.{k}")) + else: + quoted_k = f'"{k}"' if "." in k else k + result_parts.append(f"{quoted_k} = {_serialize_toml_value(v)}") + + return "\n".join(result_parts) + + +def load_project_metadata() -> TomlDict: + """Load [project] metadata from this project's pyproject.toml.""" + with (SCRIPT_DIR / "pyproject.toml").open("rb") as f: + data: TomlDict = tomllib.load(f)["project"] + return data + + +def build_generated_pyproject( + blender_version: str, + package_version: str, + packages: list[str], + python_version: str, +) -> str: + """Build a pyproject.toml for the generated stubs, inheriting metadata from the project.""" + meta = load_project_metadata() + + classifiers_raw = meta.get("classifiers", []) + classifiers: list[TomlValue] = [] + if isinstance(classifiers_raw, list): + classifiers = [c for c in classifiers_raw if isinstance(c, str)] + classifiers.append("Typing :: Stubs Only") + + project: TomlDict = { + "name": str(meta.get("name", "blender-python-stubs")), + "version": package_version, + "description": f"Type stubs for Blender {blender_version} Python API", + "readme": "README.md", + "requires-python": f">={python_version}", + "license": str(meta.get("license", "GPL-2.0-or-later")), + "keywords": meta.get("keywords", []), + "authors": meta.get("authors", []), + "classifiers": classifiers, + } + + urls = meta.get("urls") + if isinstance(urls, dict): + project["urls"] = urls + + build_system: TomlDict = { + "requires": ["hatchling"], + "build-backend": "hatchling.build", + } + pkg_list: list[TomlValue] = [p for p in sorted(packages)] + wheel: TomlDict = {"packages": pkg_list} + targets: TomlDict = {"wheel": wheel} + build: TomlDict = {"targets": targets} + hatch: TomlDict = {"build": build} + tool: TomlDict = {"hatch": hatch} + + generated: TomlDict = { + "build-system": build_system, + "project": project, + "tool": tool, + } + + return _serialize_toml(generated) + + +README_TEMPLATE = """\ +# blender-python-stubs + +Type stubs for Blender {blender_version} Python API. + +Provides type information for `bpy`, `mathutils`, `bmesh`, `gpu`, `freestyle`, and other Blender Python modules. + +## Installation + +```bash +pip install "blender-python-stubs>={major_minor},<{next_minor}" +``` + +## Usage + +Install alongside your Blender addon project for type checking with mypy, pyright, ty or your IDE. + +```python +import bpy + +# Your IDE now provides autocomplete and type checking +obj: bpy.types.Object = bpy.context.active_object +``` +""" + + +def get_blender_version(blender_path: str) -> tuple[str, str]: + """Get the full and major.minor version strings from a Blender executable.""" + result = subprocess.run( + [blender_path, "--version"], + capture_output=True, + text=True, + ) + for line in result.stdout.splitlines(): + if line.startswith("Blender "): + full_version = line.split()[1] + parts = full_version.split(".") + major_minor = f"{parts[0]}.{parts[1]}" + return full_version, major_minor + print("Could not determine Blender version", file=sys.stderr) + sys.exit(1) + + +def get_blender_python_version(blender_path: str) -> str: + """Detect the Python version embedded in a Blender executable.""" + result = subprocess.run( + [ + blender_path, + "--background", + "--factory-startup", + "-noaudio", + "--python-expr", + "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')", + ], + capture_output=True, + text=True, + ) + for line in result.stdout.splitlines(): + line = line.strip() + if line and line[0].isdigit() and "." in line: + return line + print("Could not determine Blender Python version", file=sys.stderr) + sys.exit(1) + + +def run_introspection(blender_path: str) -> list[ModuleData]: + """Run the introspection script inside Blender and return the JSON result.""" + cmd = [ + blender_path, + "--background", + "--factory-startup", + "-noaudio", + "--python-exit-code", + "1", + "--python", + str(INTROSPECT_SCRIPT), + ] + + proc = subprocess.run(cmd, capture_output=True, text=True) + + if proc.returncode != 0: + print("Blender introspection failed:", file=sys.stderr) + print(proc.stderr, file=sys.stderr) + sys.exit(1) + + output = proc.stdout + start_marker = "__INTROSPECT_JSON_START__" + end_marker = "__INTROSPECT_JSON_END__" + + start_idx = output.find(start_marker) + end_idx = output.find(end_marker) + + if start_idx == -1 or end_idx == -1: + print("Could not find JSON markers in Blender output:", file=sys.stderr) + print(output, file=sys.stderr) + sys.exit(1) + + json_str = output[start_idx + len(start_marker) : end_idx].strip() + parsed: list[ModuleData] = json.loads(json_str) + return parsed + + +def compute_next_minor(major_minor: str) -> str: + """Compute the next minor version. 5.0 -> 5.1, 4.9 -> 4.10.""" + major, minor = major_minor.split(".") + return f"{major}.{int(minor) + 1}" + + +def generate_package_files( + output_dir: Path, + full_version: str, + major_minor: str, + top_level_packages: list[str], + python_version: str, +) -> None: + """Generate pyproject.toml and README.md for the publishable package.""" + package_version = f"{full_version}.0" + + pyproject = build_generated_pyproject( + major_minor, package_version, top_level_packages, python_version + ) + (output_dir / "pyproject.toml").write_text(pyproject + "\n") + + next_minor = compute_next_minor(major_minor) + readme = README_TEMPLATE.format( + blender_version=major_minor, + major_minor=major_minor, + next_minor=next_minor, + ) + (output_dir / "README.md").write_text(readme) + + # Add py.typed marker to each top-level package so mypy recognizes the stubs + for pkg in top_level_packages: + (output_dir / pkg / "py.typed").touch() + + +def generate_for_version(blender_path: str) -> None: + """Generate stubs for a single Blender executable.""" + full_version, major_minor = get_blender_version(blender_path) + python_version = get_blender_python_version(blender_path) + print(f" Blender {full_version} (Python {python_version})") + + output_dir = SCRIPT_DIR / "dist" / major_minor + if output_dir.exists(): + shutil.rmtree(output_dir) + + print(" Running introspection...") + modules_data = run_introspection(blender_path) + print(f" Introspected {len(modules_data)} modules") + + overrides_path = OVERRIDES_DIR / major_minor + overrides_str: str | None = None + if overrides_path.exists(): + print(f" Using overrides from {overrides_path}/") + overrides_str = str(overrides_path) + + print(" Generating stubs...") + top_level_packages = write_stubs( + modules_data, str(output_dir), overrides_str, python_version + ) + + print(" Generating package files...") + generate_package_files( + output_dir, full_version, major_minor, top_level_packages, python_version + ) + + # Store the Python version for later type checking + (output_dir / ".python-version").write_text(python_version) + + print(f" Output: {output_dir}/") + + +@dataclass +class MainArgs: + versions: list[str] + + +def typecheck_stubs(versions: list[str] | None = None) -> None: + """Type-check generated stubs by running basedpyright scoped to each version directory.""" + dist_dir = SCRIPT_DIR / "dist" + if not dist_dir.exists(): + print("No dist/ directory found. Run 'poe generate' first.") + sys.exit(1) + + if not versions: + versions = sorted( + d.name + for d in dist_dir.iterdir() + if d.is_dir() and not d.name.startswith(".") + ) + + if not versions: + print("No generated stubs found in dist/.") + sys.exit(1) + + failed = False + for version in versions: + version_dir = dist_dir / version + print(f"=== Checking stubs for Blender {version} ===") + + python_version_file = version_dir / ".python-version" + python_version = ( + python_version_file.read_text().strip() + if python_version_file.exists() + else "3.11" + ) + + config = version_dir / "pyrightconfig.json" + config.write_text( + json.dumps( + { + "extraPaths": ["."], + "typeCheckingMode": "strict", + "pythonVersion": python_version, + } + ) + ) + + result = subprocess.run( + ["basedpyright", "--project", str(config)], + ) + + config.unlink() + + if result.returncode != 0: + failed = True + + if failed: + sys.exit(1) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Blender type stubs generator") + parser.add_argument( + "--typecheck-stubs", + action="store_true", + help="Type-check generated stubs instead of generating", + ) + parser.add_argument( + "versions", + nargs="*", + help="Blender versions (e.g., 4.0 4.1)", + ) + args = parser.parse_args() + + if args.typecheck_stubs: + typecheck_stubs(args.versions or None) + else: + if not args.versions: + parser.error("versions are required for generation") + main_args = MainArgs(versions=args.versions) + min_version = (4, 0) + for version in main_args.versions: + major, minor = (int(x) for x in version.split(".")) + if (major, minor) < min_version: + print( + f"Blender {version} is not supported" + f" (minimum: {min_version[0]}.{min_version[1]})" + ) + sys.exit(1) + print(f"=== Blender {version} ===") + blender_path = get_blender_executable(version) + generate_for_version(str(blender_path)) + print() + print("Done.") diff --git a/overrides/4.3/bpy_extras.anim_utils.json b/overrides/4.3/bpy_extras.anim_utils.json new file mode 100644 index 0000000..a103761 --- /dev/null +++ b/overrides/4.3/bpy_extras.anim_utils.json @@ -0,0 +1,7 @@ +{ + "bake_action_objects_iter": { + "params": { + "bake_options": "object" + } + } +} diff --git a/overrides/5.0/bpy.path.json b/overrides/5.0/bpy.path.json new file mode 100644 index 0000000..bc36764 --- /dev/null +++ b/overrides/5.0/bpy.path.json @@ -0,0 +1,22 @@ +{ + "abspath": { + "params": { + "path": "str | bytes" + } + }, + "basename": { + "params": { + "path": "str | bytes" + } + }, + "is_subdir": { + "params": { + "directory": "str | bytes" + } + }, + "native_pathsep": { + "params": { + "path": "str | bytes" + } + } +} diff --git a/overrides/5.0/gpu.state.json b/overrides/5.0/gpu.state.json new file mode 100644 index 0000000..63647e9 --- /dev/null +++ b/overrides/5.0/gpu.state.json @@ -0,0 +1,8 @@ +{ + "active_framebuffer_get": { + "return_type": "gpu.types.GPUFrameBuffer" + }, + "viewport_get": { + "return_type": "tuple[int, int, int, int]" + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..25d26fa --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,59 @@ +[project] +name = "blender-python-stubs" +version = "0.1.0" +description = "Type stubs generator for Blender's Python API" +readme = "README.md" +requires-python = ">=3.11" +license = "MIT" +keywords = ["blender", "stubs", "typing", "bpy", "autocomplete"] +authors = [ + { name = "Joseph HENRY" }, + { name = "Autour de Minuit", email = "tech@autourdeminuit.com" }, +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Multimedia :: Graphics :: 3D Modeling", +] +dependencies = [] + +[project.urls] +Homepage = "https://github.com/autourdeminuit/blender-python-stubs" +Repository = "https://github.com/autourdeminuit/blender-python-stubs" +Issues = "https://github.com/autourdeminuit/blender-python-stubs/issues" + +[tool.black] +target-version = ["py311"] + +[tool.basedpyright] +typeCheckingMode = "strict" +extraPaths = ["stubs"] +exclude = ["downloads/", ".venv/", "dist/"] + +[tool.ruff] +exclude = ["dist/"] + +[tool.poe.tasks] +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)" } +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)" + +[tool.poe.tasks.check] +help = "Run all checks (format, lint, typecheck, test)" +sequence = ["format", "lint", "typecheck", "test", "typecheck-stubs"] + +[dependency-groups] +dev = [ + "basedpyright>=1.38.3", + "black>=26.3.1", + "poethepoet>=0.42.1", + "ruff>=0.15.6", +] diff --git a/stubs/_rna_info.py b/stubs/_rna_info.py new file mode 100644 index 0000000..fbaac22 --- /dev/null +++ b/stubs/_rna_info.py @@ -0,0 +1,3 @@ +# This module only exists inside Blender's embedded Python. +# This file exists to satisfy type checkers that require a source file. +raise ImportError("_rna_info is only available inside Blender") diff --git a/stubs/_rna_info.pyi b/stubs/_rna_info.pyi new file mode 100644 index 0000000..588a276 --- /dev/null +++ b/stubs/_rna_info.pyi @@ -0,0 +1,40 @@ +"""Stub for Blender's internal _rna_info module.""" + +class InfoPropertyRNA: + identifier: str + type: str + fixed_type: InfoStructRNA | None + array_length: int + array_dimensions: tuple[int, ...] + is_readonly: bool + is_required: bool + is_argument_optional: bool + is_enum_flag: bool + description: str + default: str + default_str: str + enum_items: list[tuple[str, str, str]] + collection_type: InfoStructRNA | None + subtype: str + +class InfoFunctionRNA: + identifier: str + description: str + is_classmethod: bool + args: list[InfoPropertyRNA] + return_values: tuple[InfoPropertyRNA, ...] + +class InfoStructRNA: + identifier: str + name: str + description: str + base: InfoStructRNA | None + properties: list[InfoPropertyRNA] + functions: list[InfoFunctionRNA] + +def BuildRNAInfo() -> tuple[ + dict[str, InfoStructRNA], + dict[str, InfoFunctionRNA], + dict[str, object], + dict[str, InfoPropertyRNA], +]: ... diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_blender_downloader.py b/tests/test_blender_downloader.py new file mode 100644 index 0000000..180bb7b --- /dev/null +++ b/tests/test_blender_downloader.py @@ -0,0 +1,42 @@ +"""Tests for the Blender downloader module.""" + +import unittest + +from blender_downloader import ( + get_archive_extension, + get_download_url, + get_extracted_dir_name, +) + + +class TestGetDownloadUrl(unittest.TestCase): + def test_builds_correct_url(self) -> None: + url = get_download_url("5.0.1") + self.assertIn("https://download.blender.org/release/Blender5.0/", url) + self.assertIn("blender-5.0.1-", url) + self.assertTrue(url.endswith(get_archive_extension())) + + def test_major_minor_extraction(self) -> None: + url = get_download_url("4.3.2") + self.assertIn("/Blender4.3/", url) + self.assertIn("blender-4.3.2-", url) + + +class TestGetExtractedDirName(unittest.TestCase): + def test_linux_dir_name(self) -> None: + name = get_extracted_dir_name("5.0.1") + self.assertEqual(name, "blender-5.0.1-linux-x64") + + def test_different_version(self) -> None: + name = get_extracted_dir_name("4.3.2") + self.assertEqual(name, "blender-4.3.2-linux-x64") + + +class TestGetArchiveExtension(unittest.TestCase): + def test_linux_extension(self) -> None: + ext = get_archive_extension() + self.assertEqual(ext, ".tar.xz") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_generate_stubs.py b/tests/test_generate_stubs.py new file mode 100644 index 0000000..27af91e --- /dev/null +++ b/tests/test_generate_stubs.py @@ -0,0 +1,784 @@ +"""Tests for the stub generator.""" + +import unittest + +from generate_stubs import ( + ParamOverrides, + apply_overrides, + collect_all_methods, + generate_function_stub, + generate_method_stub, + generate_property_stub, + generate_struct_stub, + generate_types_stub, + generate_variable_stub, + generate_module_stub, + collect_imports, + map_type, + topological_sort_structs, +) +from introspect import ( + FunctionData, + ModuleData, + PropertyData, + StructData, + VariableData, +) + + +class TestMapType(unittest.TestCase): + def test_frozenset_gets_type_param(self) -> None: + self.assertEqual(map_type("frozenset"), "frozenset[str]") + + def test_unknown_passthrough(self) -> None: + self.assertEqual(map_type("list[str]"), "list[str]") + + +class TestGenerateFunctionStub(unittest.TestCase): + def test_simple_function(self) -> None: + func: FunctionData = { + "name": "basename", + "doc": "Get basename.", + "params": [ + { + "name": "path", + "type": "str", + "default": None, + "kind": "POSITIONAL_OR_KEYWORD", + }, + ], + "return_type": "str", + "is_classmethod": False, + } + result = generate_function_stub(func) + self.assertIn("def basename(path: str) -> str:", result) + self.assertIn('"""Get basename."""', result) + + def test_keyword_only_params(self) -> None: + func: FunctionData = { + "name": "abspath", + "doc": "", + "params": [ + { + "name": "path", + "type": "str | bytes", + "default": None, + "kind": "POSITIONAL_OR_KEYWORD", + }, + { + "name": "start", + "type": "str | bytes | None", + "default": "None", + "kind": "KEYWORD_ONLY", + }, + { + "name": "library", + "type": "bpy.types.Library | None", + "default": "None", + "kind": "KEYWORD_ONLY", + }, + ], + "return_type": "str", + "is_classmethod": False, + } + result = generate_function_stub(func) + self.assertEqual( + result, + "def abspath(path: str | bytes, *, start: str | bytes | None = None, library: bpy.types.Library | None = None) -> str: ...\n", + ) + + def test_no_return_type(self) -> None: + func: FunctionData = { + "name": "do_thing", + "doc": "", + "params": [], + "return_type": None, + "is_classmethod": False, + } + result = generate_function_stub(func) + self.assertEqual(result, "def do_thing() -> None: ...\n") + + def test_no_param_type_gets_object(self) -> None: + func: FunctionData = { + "name": "foo", + "doc": "", + "params": [ + { + "name": "x", + "type": None, + "default": None, + "kind": "POSITIONAL_OR_KEYWORD", + }, + ], + "return_type": None, + "is_classmethod": False, + } + result = generate_function_stub(func) + self.assertEqual(result, "def foo(x: object) -> None: ...\n") + + def test_no_star_separator_with_only_var_keyword(self) -> None: + """Don't insert * when the only keyword param is **kwargs.""" + func: FunctionData = { + "name": "foo", + "doc": "", + "params": [ + { + "name": "x", + "type": "str", + "default": None, + "kind": "POSITIONAL_OR_KEYWORD", + }, + { + "name": "kwargs", + "type": "object", + "default": None, + "kind": "VAR_KEYWORD", + }, + ], + "return_type": None, + "is_classmethod": False, + } + result = generate_function_stub(func) + self.assertNotIn("*, ", result) + self.assertIn("**kwargs", result) + + def test_default_before_nondefault_reordered(self) -> None: + """Non-default params must come before default params.""" + func: FunctionData = { + "name": "bake", + "doc": "", + "params": [ + { + "name": "start", + "type": "int", + "default": None, + "kind": "POSITIONAL_OR_KEYWORD", + }, + { + "name": "step", + "type": "float", + "default": "1.0", + "kind": "POSITIONAL_OR_KEYWORD", + }, + { + "name": "remove", + "type": "str", + "default": None, + "kind": "POSITIONAL_OR_KEYWORD", + }, + ], + "return_type": None, + "is_classmethod": False, + } + result = generate_function_stub(func) + # remove (no default) should come before step (has default) + self.assertIn( + "def bake(start: int, remove: str, step: float = 1.0) -> None: ...", + result, + ) + + def test_star_separator_with_named_keyword(self) -> None: + """Insert * when there are named keyword-only params.""" + func: FunctionData = { + "name": "bar", + "doc": "", + "params": [ + { + "name": "x", + "type": "str", + "default": None, + "kind": "POSITIONAL_OR_KEYWORD", + }, + { + "name": "flag", + "type": "bool", + "default": "False", + "kind": "KEYWORD_ONLY", + }, + ], + "return_type": None, + "is_classmethod": False, + } + result = generate_function_stub(func) + self.assertIn("*, flag", result) + + def test_default_without_type_gets_object(self) -> None: + func: FunctionData = { + "name": "foo", + "doc": "", + "params": [ + { + "name": "x", + "type": None, + "default": "42", + "kind": "POSITIONAL_OR_KEYWORD", + }, + ], + "return_type": None, + "is_classmethod": False, + } + result = generate_function_stub(func) + self.assertEqual(result, "def foo(x: object = 42) -> None: ...\n") + + +class TestGenerateVariableStub(unittest.TestCase): + def test_frozenset_variable(self) -> None: + var: VariableData = { + "name": "extensions_image", + "type": "frozenset", + "value": "frozenset({'.png', '.jpg'})", + } + result = generate_variable_stub(var) + self.assertEqual(result, "extensions_image: frozenset[str]\n") + + +class TestCollectImports(unittest.TestCase): + def test_sequence_import(self) -> None: + module_data: ModuleData = { + "module": "test", + "doc": "", + "functions": [ + { + "name": "f", + "doc": "", + "params": [ + { + "name": "x", + "type": "Sequence[str]", + "default": None, + "kind": "POSITIONAL_OR_KEYWORD", + } + ], + "return_type": None, + "is_classmethod": False, + }, + ], + "variables": [], + "structs": [], + } + imports = collect_imports(module_data) + self.assertIn("from collections.abc import Sequence", imports) + + def test_bpy_types_import(self) -> None: + module_data: ModuleData = { + "module": "test", + "doc": "", + "functions": [ + { + "name": "f", + "doc": "", + "params": [ + { + "name": "x", + "type": "bpy.types.Library | None", + "default": None, + "kind": "POSITIONAL_OR_KEYWORD", + } + ], + "return_type": None, + "is_classmethod": False, + }, + ], + "variables": [], + "structs": [], + } + imports = collect_imports(module_data) + self.assertIn("import bpy.types", imports) + + def test_types_module_import(self) -> None: + module_data: ModuleData = { + "module": "test", + "doc": "", + "functions": [ + { + "name": "f", + "doc": "", + "params": [ + { + "name": "mod", + "type": "types.ModuleType", + "default": None, + "kind": "POSITIONAL_OR_KEYWORD", + } + ], + "return_type": None, + "is_classmethod": False, + }, + ], + "variables": [], + "structs": [], + } + imports = collect_imports(module_data) + self.assertIn("import types", imports) + + def test_no_imports_needed(self) -> None: + module_data: ModuleData = { + "module": "test", + "doc": "", + "functions": [ + { + "name": "f", + "doc": "", + "params": [ + { + "name": "x", + "type": "str", + "default": None, + "kind": "POSITIONAL_OR_KEYWORD", + } + ], + "return_type": "str", + "is_classmethod": False, + }, + ], + "variables": [], + "structs": [], + } + imports = collect_imports(module_data) + self.assertEqual(imports, set()) + + +class TestGenerateModuleStub(unittest.TestCase): + def test_full_module(self) -> None: + module_data: ModuleData = { + "module": "bpy.path", + "doc": "Path utilities.", + "functions": [ + { + "name": "basename", + "doc": "Get basename.", + "params": [ + { + "name": "path", + "type": "str", + "default": None, + "kind": "POSITIONAL_OR_KEYWORD", + }, + ], + "return_type": "str", + "is_classmethod": False, + }, + ], + "variables": [ + { + "name": "extensions_image", + "type": "frozenset", + "value": "frozenset({'.png'})", + }, + ], + "structs": [], + } + result = generate_module_stub(module_data) + self.assertIn('"""Path utilities."""', result) + self.assertIn("extensions_image: frozenset[str]", result) + self.assertIn("def basename(path: str) -> str:", result) + self.assertIn('"""Get basename."""', result) + + +class TestGeneratePropertyStub(unittest.TestCase): + def test_simple_property(self) -> None: + prop: PropertyData = { + "name": "filepath", + "type": "str", + "is_readonly": False, + "description": "Path to the library.", + } + result = generate_property_stub(prop) + self.assertIn("filepath: str", result) + self.assertIn('"""Path to the library."""', result) + + def test_property_no_description(self) -> None: + prop: PropertyData = { + "name": "x", + "type": "float", + "is_readonly": False, + "description": "", + } + result = generate_property_stub(prop) + self.assertEqual(result, " x: float\n") + + def test_readonly_property(self) -> None: + prop: PropertyData = { + "name": "name", + "type": "str", + "is_readonly": True, + "description": "Unique name used in the code and scripting.", + } + result = generate_property_stub(prop) + self.assertIn("@property", result) + self.assertIn("def name(self) -> str:", result) + self.assertIn('"""Unique name used in the code and scripting."""', result) + + def test_readonly_no_description(self) -> None: + prop: PropertyData = { + "name": "type", + "type": "str", + "is_readonly": True, + "description": "", + } + result = generate_property_stub(prop) + self.assertIn("@property", result) + self.assertIn("def type(self) -> str:", result) + self.assertIn("...", result) + + +class TestGenerateMethodStub(unittest.TestCase): + def test_simple_method(self) -> None: + func: FunctionData = { + "name": "select_get", + "doc": "Test if selected.", + "params": [ + { + "name": "view_layer", + "type": "ViewLayer", + "default": "None", + "kind": "POSITIONAL_OR_KEYWORD", + }, + ], + "return_type": "bool", + "is_classmethod": False, + } + result = generate_method_stub(func) + self.assertIn( + "def select_get(self, view_layer: ViewLayer = None) -> bool:", result + ) + self.assertIn('"""Test if selected."""', result) + + def test_classmethod(self) -> None: + func: FunctionData = { + "name": "is_running", + "doc": "", + "params": [ + { + "name": "context", + "type": "Context", + "default": None, + "kind": "POSITIONAL_OR_KEYWORD", + }, + ], + "return_type": "bool", + "is_classmethod": True, + } + result = generate_method_stub(func) + self.assertIn("@classmethod", result) + self.assertIn("def is_running(cls, context: Context) -> bool: ...", result) + + def test_override_decorator(self) -> None: + func: FunctionData = { + "name": "is_registered_node_type", + "doc": "", + "params": [], + "return_type": "bool", + "is_classmethod": True, + } + result = generate_method_stub(func, is_override=True) + self.assertIn("@override", result) + self.assertIn("@classmethod", result) + lines = result.strip().split("\n") + self.assertEqual(lines[0].strip(), "@override") + self.assertEqual(lines[1].strip(), "@classmethod") + + def test_positional_only_params(self) -> None: + """Positional-only params get a / separator.""" + func: FunctionData = { + "name": "__init__", + "doc": "", + "params": [ + { + "name": "rgb", + "type": "Sequence[float]", + "default": "...", + "kind": "POSITIONAL_ONLY", + }, + ], + "return_type": "None", + "is_classmethod": False, + } + result = generate_method_stub(func) + self.assertIn( + "def __init__(self, rgb: Sequence[float] = ..., /) -> None: ...", result + ) + + def test_keyword_only_method_params(self) -> None: + """Keyword-only params get a * separator.""" + func: FunctionData = { + "name": "__init__", + "doc": "", + "params": [ + { + "name": "width", + "type": "int", + "default": None, + "kind": "POSITIONAL_OR_KEYWORD", + }, + { + "name": "format", + "type": "str", + "default": "'RGBA8'", + "kind": "KEYWORD_ONLY", + }, + ], + "return_type": "None", + "is_classmethod": False, + } + result = generate_method_stub(func) + self.assertIn( + "def __init__(self, width: int, *, format: str = 'RGBA8') -> None: ...", + result, + ) + + +class TestGenerateStructStub(unittest.TestCase): + def test_with_base(self) -> None: + struct: StructData = { + "name": "Library", + "doc": "Library data-block.", + "base": "ID", + "properties": [ + { + "name": "filepath", + "type": "str", + "is_readonly": False, + "description": "", + }, + ], + "methods": [], + } + result = generate_struct_stub(struct) + self.assertIn("class Library(ID):", result) + self.assertIn(" filepath: str", result) + + def test_without_base(self) -> None: + struct: StructData = { + "name": "ID", + "doc": "Base type.", + "base": None, + "properties": [], + "methods": [], + } + result = generate_struct_stub(struct) + self.assertIn("class ID:", result) + self.assertIn('"""Base type."""', result) + + def test_with_methods(self) -> None: + struct: StructData = { + "name": "Object", + "doc": "", + "base": "ID", + "properties": [], + "methods": [ + { + "name": "select_get", + "doc": "", + "params": [], + "return_type": "bool", + "is_classmethod": False, + }, + ], + } + result = generate_struct_stub(struct) + self.assertIn("def select_get(self) -> bool: ...", result) + + def test_method_no_return_gets_none(self) -> None: + struct: StructData = { + "name": "Object", + "doc": "", + "base": None, + "properties": [], + "methods": [ + { + "name": "do_thing", + "doc": "", + "params": [], + "return_type": None, + "is_classmethod": False, + }, + ], + } + result = generate_struct_stub(struct) + self.assertIn("def do_thing(self) -> None: ...", result) + + +class TestTopologicalSort(unittest.TestCase): + def test_base_before_child(self) -> None: + structs: list[StructData] = [ + { + "name": "Library", + "doc": "", + "base": "ID", + "properties": [], + "methods": [], + }, + { + "name": "ID", + "doc": "", + "base": None, + "properties": [], + "methods": [], + }, + ] + result = topological_sort_structs(structs) + names = [s["name"] for s in result] + self.assertEqual(names, ["ID", "Library"]) + + def test_circular_deps_dont_loop(self) -> None: + structs: list[StructData] = [ + { + "name": "A", + "doc": "", + "base": "B", + "properties": [], + "methods": [], + }, + { + "name": "B", + "doc": "", + "base": "A", + "properties": [], + "methods": [], + }, + ] + result = topological_sort_structs(structs) + self.assertEqual(len(result), 2) + + +class TestCollectAllMethods(unittest.TestCase): + def test_inherits_from_base(self) -> None: + method: FunctionData = { + "name": "is_registered_node_type", + "doc": "", + "params": [], + "return_type": "bool", + "is_classmethod": True, + } + structs: list[StructData] = [ + { + "name": "Node", + "doc": "", + "base": None, + "properties": [], + "methods": [method], + }, + { + "name": "TextureNode", + "doc": "", + "base": "Node", + "properties": [], + "methods": [method], + }, + { + "name": "TextureNodeValToRGB", + "doc": "", + "base": "TextureNode", + "properties": [], + "methods": [method], + }, + ] + result = collect_all_methods(structs) + self.assertEqual(result["Node"], set()) + self.assertIn("is_registered_node_type", result["TextureNode"]) + self.assertIn("is_registered_node_type", result["TextureNodeValToRGB"]) + + +class TestGenerateTypesStub(unittest.TestCase): + def test_includes_header(self) -> None: + structs: list[StructData] = [ + { + "name": "ID", + "doc": "", + "base": None, + "properties": [], + "methods": [], + }, + ] + result = generate_types_stub(structs) + self.assertIn('_T = TypeVar("_T")', result) + self.assertIn("class ID:", result) + + +class TestApplyOverrides(unittest.TestCase): + def test_overrides_param_type(self) -> None: + module_data: ModuleData = { + "module": "test", + "doc": "", + "functions": [ + { + "name": "abspath", + "doc": "", + "params": [ + { + "name": "path", + "type": None, + "default": None, + "kind": "POSITIONAL_OR_KEYWORD", + }, + ], + "return_type": "str", + "is_classmethod": False, + }, + ], + "variables": [], + "structs": [], + } + overrides: dict[str, ParamOverrides] = { + "abspath": { + "params": {"path": "str | bytes"}, + }, + } + result = apply_overrides(module_data, overrides) + self.assertEqual(result["functions"][0]["params"][0]["type"], "str | bytes") + + def test_overrides_return_type(self) -> None: + module_data: ModuleData = { + "module": "test", + "doc": "", + "functions": [ + { + "name": "foo", + "doc": "", + "params": [], + "return_type": None, + "is_classmethod": False, + }, + ], + "variables": [], + "structs": [], + } + overrides: dict[str, ParamOverrides] = { + "foo": {"return_type": "int"}, + } + result = apply_overrides(module_data, overrides) + self.assertEqual(result["functions"][0]["return_type"], "int") + + def test_no_matching_override(self) -> None: + module_data: ModuleData = { + "module": "test", + "doc": "", + "functions": [ + { + "name": "bar", + "doc": "", + "params": [ + { + "name": "x", + "type": None, + "default": None, + "kind": "POSITIONAL_OR_KEYWORD", + } + ], + "return_type": None, + "is_classmethod": False, + }, + ], + "variables": [], + "structs": [], + } + overrides: dict[str, ParamOverrides] = { + "foo": {"params": {"x": "int"}}, + } + result = apply_overrides(module_data, overrides) + self.assertIsNone(result["functions"][0]["params"][0]["type"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_introspect.py b/tests/test_introspect.py new file mode 100644 index 0000000..a7e0615 --- /dev/null +++ b/tests/test_introspect.py @@ -0,0 +1,531 @@ +"""Tests for the introspection module.""" + +import unittest +from typing import cast + +from introspect import ( + FunctionData, + infer_getter_return_types, + parse_rst_function_sig, + infer_context_member_type, + introspect_callable, + refine_types_by_context, + clean_docstring, + clean_type_str, + parse_docstring_types, + python_type_name, +) + + +class TestCleanTypeStr(unittest.TestCase): + def test_class_markup_removal(self) -> None: + self.assertEqual( + clean_type_str(":class:`bpy.types.Library`"), "bpy.types.Library" + ) + + def test_plain_type_passthrough(self) -> None: + self.assertEqual(clean_type_str("str | bytes"), "str | bytes") + + def test_generic_type(self) -> None: + self.assertEqual( + clean_type_str("list[tuple[str, str]]"), "list[tuple[str, str]]" + ) + + def test_sequence_type(self) -> None: + self.assertEqual(clean_type_str("Sequence[str]"), "Sequence[str]") + + def test_whitespace_collapse(self) -> None: + self.assertEqual(clean_type_str("str | bytes"), "str | bytes") + + def test_multiline_collapse(self) -> None: + self.assertEqual(clean_type_str("str\n | bytes"), "str | bytes") + + +class TestParseDocstringTypes(unittest.TestCase): + def test_simple_type(self) -> None: + doc = ":type name: str\n:rtype: str" + param_types, return_type = parse_docstring_types(doc) + self.assertEqual(param_types, {"name": "str"}) + self.assertEqual(return_type, "str") + + def test_union_type(self) -> None: + doc = ":type path: str | bytes\n:rtype: str" + param_types, return_type = parse_docstring_types(doc) + self.assertEqual(param_types, {"path": "str | bytes"}) + self.assertEqual(return_type, "str") + + def test_class_reference(self) -> None: + doc = ":type library: :class:`bpy.types.Library`\n:rtype: str" + param_types, _return_type = parse_docstring_types(doc) + self.assertEqual(param_types, {"library": "bpy.types.Library"}) + + def test_generic_return(self) -> None: + doc = ":rtype: list[tuple[str, str]]" + _param_types, return_type = parse_docstring_types(doc) + self.assertEqual(return_type, "list[tuple[str, str]]") + + def test_empty_docstring(self) -> None: + param_types, return_type = parse_docstring_types("") + self.assertEqual(param_types, {}) + self.assertIsNone(return_type) + + def test_no_type_annotations(self) -> None: + doc = "Just a description with no type info." + param_types, return_type = parse_docstring_types(doc) + self.assertEqual(param_types, {}) + self.assertIsNone(return_type) + + def test_multiple_params(self) -> None: + doc = ( + ":arg start: Relative to this path.\n" + ":type start: str | bytes\n" + ":arg library: The library.\n" + ":type library: :class:`bpy.types.Library`\n" + ":rtype: str" + ) + param_types, return_type = parse_docstring_types(doc) + self.assertEqual( + param_types, + { + "start": "str | bytes", + "library": "bpy.types.Library", + }, + ) + self.assertEqual(return_type, "str") + + def test_literal_inferred_from_arg_enum_values(self) -> None: + doc = ( + ":arg mode: The blend mode.\n" + " * ``NONE`` No blending.\n" + " * ``ALPHA`` Alpha blend.\n" + " * ``ADDITIVE`` Additive.\n" + ":type mode: str" + ) + param_types, _ = parse_docstring_types(doc) + self.assertEqual( + param_types["mode"], + 'Literal["NONE", "ALPHA", "ADDITIVE"]', + ) + + def test_literal_not_inferred_when_few_values(self) -> None: + doc = ":arg path: The ``PATH`` to use.\n:type path: str" + param_types, _ = parse_docstring_types(doc) + self.assertEqual(param_types["path"], "str") + + def test_lowercase_generator(self) -> None: + self.assertEqual( + clean_type_str("generator"), + "Generator[object, None, None]", + ) + + def test_lowercase_sequence(self) -> None: + self.assertEqual(clean_type_str("sequence"), "Sequence[object]") + + def test_bare_set_gets_parameterized(self) -> None: + self.assertEqual(clean_type_str("set"), "set[object]") + + def test_bare_frozenset_gets_parameterized(self) -> None: + self.assertEqual(clean_type_str("frozenset"), "frozenset[object]") + + def test_module_maps_to_types_moduletype(self) -> None: + self.assertEqual(clean_type_str("Module"), "types.ModuleType") + self.assertEqual(clean_type_str("Module | None"), "types.ModuleType | None") + + def test_nonetype_maps_to_none(self) -> None: + self.assertEqual(clean_type_str("NoneType"), "None") + self.assertEqual(clean_type_str("str | NoneType"), "str | None") + + def test_undefined_maps_to_object(self) -> None: + self.assertEqual(clean_type_str("Undefined"), "object") + + def test_uint_maps_to_int(self) -> None: + self.assertEqual(clean_type_str("uint"), "int") + self.assertEqual(clean_type_str("uint | None"), "int | None") + + def test_type_sequence_pattern(self) -> None: + self.assertEqual(clean_type_str("int or int sequence"), "int | Sequence[int]") + self.assertEqual( + clean_type_str("float or float sequence"), "float | Sequence[float]" + ) + + def test_plural_bools(self) -> None: + self.assertEqual(clean_type_str("Sequence of bools"), "Sequence[bool]") + + def test_plural_numbers(self) -> None: + self.assertEqual(clean_type_str("Sequence of numbers"), "Sequence[float]") + + def test_plural_vectors(self) -> None: + self.assertEqual(clean_type_str("vectors"), "mathutils.Vector") + + def test_plural_matrices(self) -> None: + self.assertEqual(clean_type_str("matrices"), "mathutils.Matrix") + + def test_sequence_multi_arg_to_tuple(self) -> None: + self.assertEqual( + clean_type_str("Sequence[int, int]"), "Sequence[tuple[int, int]]" + ) + self.assertEqual( + clean_type_str("Sequence[Sequence[int, int]]"), + "Sequence[Sequence[tuple[int, int]]]", + ) + + def test_plural_tuples(self) -> None: + self.assertEqual(clean_type_str("list[tuples]"), "list[tuple[object, ...]]") + + def test_sequence_of_n_type_skips_number(self) -> None: + self.assertEqual(clean_type_str("Sequence of 3 float"), "Sequence[float]") + self.assertEqual(clean_type_str("Iterable of 4 int"), "Iterable[int]") + + def test_sequence_of_n_or_m_type_skips_dimensions(self) -> None: + self.assertEqual(clean_type_str("Sequence of 3 or 4 float"), "Sequence[float]") + self.assertEqual(clean_type_str("Sequence of 1 to 4 float"), "Sequence[float]") + + def test_numeric_type_arg_stripped(self) -> None: + # "Sequence[3]" from malformed docstring -> bare Sequence -> parameterized + self.assertEqual(clean_type_str("Sequence[3]"), "Sequence[object]") + + def test_numeric_union_parts_stripped(self) -> None: + # "Sequence[1] | 2 | 3" from dimension descriptions + result = clean_type_str("Sequence[1] | 2 | 3 | object") + self.assertNotIn("2", result) + self.assertNotIn("3", result) + + def test_empty_brackets_treated_as_bare(self) -> None: + self.assertEqual(clean_type_str("dict[]"), "dict[str, object]") + self.assertEqual(clean_type_str("list[]"), "list[object]") + + def test_callable_empty_params_preserved(self) -> None: + self.assertEqual(clean_type_str("Callable[[], None]"), "Callable[[], None]") + + def test_bare_generic_no_false_match(self) -> None: + # \bSequence\b should not match inside SequenceEntry + self.assertEqual(clean_type_str("SequenceEntry"), "SequenceEntry") + # \bset\b should not match inside SettingsGroup + self.assertEqual(clean_type_str("SettingsGroup"), "SettingsGroup") + + +class TestRefineTypesByContext(unittest.TestCase): + def test_bool_vector_default(self) -> None: + param_types = {"default": "Sequence"} + param_types, _ = refine_types_by_context( + "BoolVectorProperty", param_types, None + ) + self.assertEqual(param_types["default"], "Sequence[bool]") + + def test_float_vector_default(self) -> None: + param_types = {"default": "Sequence[object]"} + param_types, _ = refine_types_by_context( + "FloatVectorProperty", param_types, None + ) + self.assertEqual(param_types["default"], "Sequence[float]") + + def test_int_vector_default(self) -> None: + param_types = {"default": "Sequence[object]"} + param_types, _ = refine_types_by_context("IntVectorProperty", param_types, None) + self.assertEqual(param_types["default"], "Sequence[int]") + + def test_property_options_set_str(self) -> None: + param_types = {"options": "set[object]", "override": "set[object]"} + param_types, _ = refine_types_by_context("BoolProperty", param_types, None) + self.assertEqual(param_types["options"], "set[str]") + self.assertEqual(param_types["override"], "set[str]") + + def test_generator_return_refined(self) -> None: + _, return_type = refine_types_by_context("app_template_paths", {}, "Generator") + self.assertEqual(return_type, "Generator[str, None, None]") + + def test_non_property_set_unchanged(self) -> None: + param_types = {"options": "set[object]"} + param_types, _ = refine_types_by_context("some_function", param_types, None) + self.assertEqual(param_types["options"], "set[object]") + + +class TestPythonTypeName(unittest.TestCase): + def test_set_with_string_contents(self) -> None: + obj: object = {"a", "b"} + self.assertEqual(python_type_name(obj), "set[str]") + + def test_empty_set_defaults_to_str(self) -> None: + obj = cast(object, set()) + self.assertEqual(python_type_name(obj), "set[str]") + + def test_list_with_int_contents(self) -> None: + obj: object = [1, 2, 3] + self.assertEqual(python_type_name(obj), "list[int]") + + def test_empty_list_defaults_to_object(self) -> None: + obj: object = [] + self.assertEqual(python_type_name(obj), "list[object]") + + def test_frozenset_with_contents(self) -> None: + obj: object = frozenset({"x"}) + self.assertEqual(python_type_name(obj), "frozenset[str]") + + def test_empty_frozenset_defaults_to_str(self) -> None: + obj = cast(object, frozenset()) + self.assertEqual(python_type_name(obj), "frozenset[str]") + + +class TestParseRstFunctionSig(unittest.TestCase): + def test_keyword_only_with_defaults(self) -> None: + doc = ".. function:: blend_paths(*, absolute=False, packed=False)" + result = parse_rst_function_sig(doc) + self.assertEqual(result["absolute"], ("False", "KEYWORD_ONLY")) + self.assertEqual(result["packed"], ("False", "KEYWORD_ONLY")) + + def test_positional_and_keyword(self) -> None: + doc = ".. function:: foo(path, *, create=True)" + result = parse_rst_function_sig(doc) + self.assertEqual(result["path"], (None, "POSITIONAL_OR_KEYWORD")) + self.assertEqual(result["create"], ("True", "KEYWORD_ONLY")) + + def test_no_rst_directive(self) -> None: + doc = "Just a plain docstring." + result = parse_rst_function_sig(doc) + self.assertEqual(result, {}) + + def test_class_directive(self) -> None: + doc = ".. class:: GPUBatch(type, buf, elem=None)" + result = parse_rst_function_sig(doc) + self.assertEqual(result["type"], (None, "POSITIONAL_OR_KEYWORD")) + self.assertEqual(result["buf"], (None, "POSITIONAL_OR_KEYWORD")) + self.assertEqual(result["elem"], ("None", "POSITIONAL_OR_KEYWORD")) + + def test_positional_only_separator(self) -> None: + doc = ".. class:: Color(/, rgb=(0.0, 0.0, 0.0))" + result = parse_rst_function_sig(doc) + self.assertNotIn("/", result) + self.assertEqual(result["rgb"], ("...", "POSITIONAL_OR_KEYWORD")) + + def test_rst_optional_brackets(self) -> None: + doc = ".. method:: write(data[, position])" + result = parse_rst_function_sig(doc) + self.assertEqual(result["data"], (None, "POSITIONAL_OR_KEYWORD")) + self.assertEqual(result["position"], (None, "POSITIONAL_OR_KEYWORD")) + + def test_rst_nested_optional_brackets(self) -> None: + doc = ".. method:: bar(a[, b[, c]])" + result = parse_rst_function_sig(doc) + self.assertIn("a", result) + self.assertIn("b", result) + self.assertIn("c", result) + + def test_positional_only_with_named_params(self) -> None: + doc = ".. function:: foo(a, b, /, c, *, d=True)" + result = parse_rst_function_sig(doc) + self.assertEqual(result["a"], (None, "POSITIONAL_ONLY")) + self.assertEqual(result["b"], (None, "POSITIONAL_ONLY")) + self.assertEqual(result["c"], (None, "POSITIONAL_OR_KEYWORD")) + self.assertEqual(result["d"], ("True", "KEYWORD_ONLY")) + + +class _NoSigCallable: + """Callable that raises ValueError on inspect.signature (like C builtins).""" + + def __init__(self, doc: str) -> None: + self.__doc__ = doc + + def __call__(self) -> None: + pass + + @property + def __signature__(self) -> None: + raise ValueError("no signature") + + +class TestRstOnlyParams(unittest.TestCase): + def test_param_from_rst_sig_without_type_directive(self) -> None: + """Params in RST signature but without :type: should still appear.""" + func = _NoSigCallable( + ".. method:: foreach_get(seq)\n\nFast access to array data." + ) + result = introspect_callable(func, "foreach_get") + assert result is not None + self.assertEqual(len(result["params"]), 1) + self.assertEqual(result["params"][0]["name"], "seq") + self.assertIsNone(result["params"][0]["type"]) + + def test_rst_params_merged_with_typed_params(self) -> None: + """RST params without :type: are added after typed params.""" + func = _NoSigCallable(".. method:: foo(a, b)\n\n:type a: str") + result = introspect_callable(func, "foo") + assert result is not None + self.assertEqual(len(result["params"]), 2) + self.assertEqual(result["params"][0]["name"], "a") + self.assertEqual(result["params"][0]["type"], "str") + self.assertEqual(result["params"][1]["name"], "b") + self.assertIsNone(result["params"][1]["type"]) + + +class TestParamNameMismatch(unittest.TestCase): + def test_docstring_name_used_when_sig_has_generic(self) -> None: + """When __text_signature__ uses 'object' but docstring uses 'string'.""" + + def fake_func(object: object) -> str: # noqa: A002 + """:type string: str + :rtype: str""" + return "" + + result = introspect_callable(fake_func, "escape_identifier") + assert result is not None + self.assertEqual(result["params"][0]["name"], "string") + self.assertEqual(result["params"][0]["type"], "str") + + def test_matching_names_work_normally(self) -> None: + def fake_func(path: str) -> str: + """:type path: str + :rtype: str""" + return "" + + result = introspect_callable(fake_func, "basename") + assert result is not None + self.assertEqual(result["params"][0]["name"], "path") + self.assertEqual(result["params"][0]["type"], "str") + + +class TestInferContextMemberType(unittest.TestCase): + def test_objects_suffix(self) -> None: + self.assertEqual( + infer_context_member_type("selected_objects"), "Sequence[Object]" + ) + self.assertEqual( + infer_context_member_type("visible_objects"), "Sequence[Object]" + ) + + def test_object_suffix(self) -> None: + self.assertEqual(infer_context_member_type("active_object"), "Object") + self.assertEqual(infer_context_member_type("edit_object"), "Object") + + def test_bones_suffix(self) -> None: + self.assertEqual( + infer_context_member_type("selected_bones"), "Sequence[EditBone]" + ) + + def test_bone_suffix(self) -> None: + self.assertEqual(infer_context_member_type("active_bone"), "EditBone") + + def test_fcurves_suffix(self) -> None: + self.assertEqual( + infer_context_member_type("visible_fcurves"), "Sequence[FCurve]" + ) + + def test_strips_suffix(self) -> None: + self.assertEqual( + infer_context_member_type("selected_strips"), "Sequence[NlaStrip]" + ) + + def test_unknown_returns_none(self) -> None: + self.assertIsNone(infer_context_member_type("some_random_thing")) + self.assertIsNone(infer_context_member_type("property")) + + +class TestCleanDocstring(unittest.TestCase): + def test_strips_arg_directives(self) -> None: + doc = "Description of function.\n\n:arg name: The name.\n:type name: str" + result = clean_docstring(doc) + self.assertEqual(result, "Description of function.") + + def test_preserves_multiline_description(self) -> None: + doc = "First line.\nSecond line.\n\n:arg x: something" + result = clean_docstring(doc) + self.assertEqual(result, "First line.\nSecond line.") + + def test_empty(self) -> None: + self.assertEqual(clean_docstring(""), "") + + def test_no_directives(self) -> None: + doc = "Just a plain description." + self.assertEqual(clean_docstring(doc), "Just a plain description.") + + +class TestInferGetterReturnTypes(unittest.TestCase): + def test_getter_inferred_from_setter(self) -> None: + functions: list[FunctionData] = [ + { + "name": "blend_get", + "doc": "", + "params": [], + "return_type": None, + "is_classmethod": False, + }, + { + "name": "blend_set", + "doc": "", + "params": [ + { + "name": "mode", + "type": "Literal['NONE', 'ALPHA']", + "default": None, + "kind": "POSITIONAL_OR_KEYWORD", + } + ], + "return_type": "None", + "is_classmethod": False, + }, + ] + infer_getter_return_types(functions) + self.assertEqual(functions[0]["return_type"], "Literal['NONE', 'ALPHA']") + + def test_getter_not_overwritten_when_typed(self) -> None: + functions: list[FunctionData] = [ + { + "name": "depth_mask_get", + "doc": "", + "params": [], + "return_type": "bool", + "is_classmethod": False, + }, + { + "name": "depth_mask_set", + "doc": "", + "params": [ + { + "name": "value", + "type": "bool", + "default": None, + "kind": "POSITIONAL_OR_KEYWORD", + } + ], + "return_type": "None", + "is_classmethod": False, + }, + ] + infer_getter_return_types(functions) + self.assertEqual(functions[0]["return_type"], "bool") + + def test_setter_with_multiple_params_ignored(self) -> None: + functions: list[FunctionData] = [ + { + "name": "scissor_get", + "doc": "", + "params": [], + "return_type": None, + "is_classmethod": False, + }, + { + "name": "scissor_set", + "doc": "", + "params": [ + { + "name": "x", + "type": "int", + "default": None, + "kind": "POSITIONAL_OR_KEYWORD", + }, + { + "name": "y", + "type": "int", + "default": None, + "kind": "POSITIONAL_OR_KEYWORD", + }, + ], + "return_type": "None", + "is_classmethod": False, + }, + ] + infer_getter_return_types(functions) + self.assertIsNone(functions[0]["return_type"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..0f7ed66 --- /dev/null +++ b/uv.lock @@ -0,0 +1,284 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "basedpyright" +version = "1.38.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodejs-wheel-binaries" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/58/7abba2c743571a42b2548f07aee556ebc1e4d0bc2b277aeba1ee6c83b0af/basedpyright-1.38.3.tar.gz", hash = "sha256:9725419786afbfad8a9539527f162da02d462afad440b0412fdb3f3cdf179b90", size = 25277430, upload-time = "2026-03-17T13:10:41.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e3/3ebb5c23bd3abb5fc2053b8a06a889aa5c1cf8cff738c78cb6c1957e90cd/basedpyright-1.38.3-py3-none-any.whl", hash = "sha256:1f15c2e489c67d6c5e896c24b6a63251195c04223a55e4568b8f8e8ed49ca830", size = 12313363, upload-time = "2026-03-17T13:10:47.344Z" }, +] + +[[package]] +name = "black" +version = "26.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/57/5f11c92861f9c92eb9dddf515530bc2d06db843e44bdcf1c83c1427824bc/black-26.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:28ef38aee69e4b12fda8dba75e21f9b4f979b490c8ac0baa7cb505369ac9e1ff", size = 1851987, upload-time = "2026-03-12T03:40:06.248Z" }, + { url = "https://files.pythonhosted.org/packages/54/aa/340a1463660bf6831f9e39646bf774086dbd8ca7fc3cded9d59bbdf4ad0a/black-26.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf9bf162ed91a26f1adba8efda0b573bc6924ec1408a52cc6f82cb73ec2b142c", size = 1689499, upload-time = "2026-03-12T03:40:07.642Z" }, + { url = "https://files.pythonhosted.org/packages/f3/01/b726c93d717d72733da031d2de10b92c9fa4c8d0c67e8a8a372076579279/black-26.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:474c27574d6d7037c1bc875a81d9be0a9a4f9ee95e62800dab3cfaadbf75acd5", size = 1754369, upload-time = "2026-03-12T03:40:09.279Z" }, + { url = "https://files.pythonhosted.org/packages/e3/09/61e91881ca291f150cfc9eb7ba19473c2e59df28859a11a88248b5cbbc4d/black-26.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:5e9d0d86df21f2e1677cc4bd090cd0e446278bcbbe49bf3659c308c3e402843e", size = 1413613, upload-time = "2026-03-12T03:40:10.943Z" }, + { url = "https://files.pythonhosted.org/packages/16/73/544f23891b22e7efe4d8f812371ab85b57f6a01b2fc45e3ba2e52ba985b8/black-26.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9a5e9f45e5d5e1c5b5c29b3bd4265dcc90e8b92cf4534520896ed77f791f4da5", size = 1219719, upload-time = "2026-03-12T03:40:12.597Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f8/da5eae4fc75e78e6dceb60624e1b9662ab00d6b452996046dfa9b8a6025b/black-26.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e6f89631eb88a7302d416594a32faeee9fb8fb848290da9d0a5f2903519fc1", size = 1895920, upload-time = "2026-03-12T03:40:13.921Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9f/04e6f26534da2e1629b2b48255c264cabf5eedc5141d04516d9d68a24111/black-26.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cd2012d35b47d589cb8a16faf8a32ef7a336f56356babd9fcf70939ad1897f", size = 1718499, upload-time = "2026-03-12T03:40:15.239Z" }, + { url = "https://files.pythonhosted.org/packages/04/91/a5935b2a63e31b331060c4a9fdb5a6c725840858c599032a6f3aac94055f/black-26.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f76ff19ec5297dd8e66eb64deda23631e642c9393ab592826fd4bdc97a4bce7", size = 1794994, upload-time = "2026-03-12T03:40:17.124Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/86e462cdd311a3c2a8ece708d22aba17d0b2a0d5348ca34b40cdcbea512e/black-26.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ddb113db38838eb9f043623ba274cfaf7d51d5b0c22ecb30afe58b1bb8322983", size = 1420867, upload-time = "2026-03-12T03:40:18.83Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e5/22515a19cb7eaee3440325a6b0d95d2c0e88dd180cb011b12ae488e031d1/black-26.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:dfdd51fc3e64ea4f35873d1b3fb25326773d55d2329ff8449139ebaad7357efb", size = 1230124, upload-time = "2026-03-12T03:40:20.425Z" }, + { url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034, upload-time = "2026-03-12T03:40:21.813Z" }, + { url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503, upload-time = "2026-03-12T03:40:23.666Z" }, + { url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557, upload-time = "2026-03-12T03:40:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766, upload-time = "2026-03-12T03:40:27.14Z" }, + { url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140, upload-time = "2026-03-12T03:40:28.882Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" }, + { url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" }, + { url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" }, + { url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" }, + { url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, +] + +[[package]] +name = "blender-stubs" +version = "0.1.0" +source = { virtual = "." } + +[package.dev-dependencies] +dev = [ + { name = "basedpyright" }, + { name = "black" }, + { name = "poethepoet" }, + { name = "ruff" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "basedpyright", specifier = ">=1.38.3" }, + { name = "black", specifier = ">=26.3.1" }, + { name = "poethepoet", specifier = ">=0.42.1" }, + { name = "ruff", specifier = ">=0.15.6" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nodejs-wheel-binaries" +version = "24.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/05/c75c0940b1ebf82975d14f37176679b6f3229eae8b47b6a70d1e1dae0723/nodejs_wheel_binaries-24.14.0.tar.gz", hash = "sha256:c87b515e44b0e4a523017d8c59f26ccbd05b54fe593338582825d4b51fc91e1c", size = 8057, upload-time = "2026-02-27T02:57:30.931Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/8c/b057c2db3551a6fe04e93dd14e33d810ac8907891534ffcc7a051b253858/nodejs_wheel_binaries-24.14.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:59bb78b8eb08c3e32186da1ef913f1c806b5473d8bd0bb4492702092747b674a", size = 54798488, upload-time = "2026-02-27T02:56:56.831Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/7e1b29c067b6625c97c81eb8b0ef37cf5ad5b62bb81e23f4bde804910ec9/nodejs_wheel_binaries-24.14.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:348fa061b57625de7250d608e2d9b7c4bc170544da7e328325343860eadd59e5", size = 54972803, upload-time = "2026-02-27T02:57:01.696Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e0/a83f0ff12faca2a56366462e572e38ac6f5cb361877bb29e289138eb7f24/nodejs_wheel_binaries-24.14.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:222dbf516ccc877afcad4e4789a81b4ee93daaa9f0ad97c464417d9597f49449", size = 59340859, upload-time = "2026-02-27T02:57:06.125Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9f/06fad4ae8a723ae7096b5311eba67ad8b4df5f359c0a68e366750b7fef78/nodejs_wheel_binaries-24.14.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:b35d6fcccfe4fb0a409392d237fbc67796bac0d357b996bc12d057a1531a238b", size = 59838751, upload-time = "2026-02-27T02:57:10.449Z" }, + { url = "https://files.pythonhosted.org/packages/8c/72/4916dadc7307c3e9bcfa43b4b6f88237932d502c66f89eb2d90fb07810db/nodejs_wheel_binaries-24.14.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:519507fb74f3f2b296ab1e9f00dcc211f36bbfb93c60229e72dcdee9dafd301a", size = 61340534, upload-time = "2026-02-27T02:57:15.309Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/a8ba881ee5d04b04e0d93abc8ce501ff7292813583e97f9789eb3fc0472a/nodejs_wheel_binaries-24.14.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:68c93c52ff06d704bcb5ed160b4ba04ab1b291d238aaf996b03a5396e0e9a7ed", size = 61922394, upload-time = "2026-02-27T02:57:20.24Z" }, + { url = "https://files.pythonhosted.org/packages/60/8c/b8c5f61201c72a0c7dc694b459941f89a6defda85deff258a9940a4e2efc/nodejs_wheel_binaries-24.14.0-py2.py3-none-win_amd64.whl", hash = "sha256:60b83c4e98b0c7d836ac9ccb67dcb36e343691cbe62cd325799ff9ed936286f3", size = 41218783, upload-time = "2026-02-27T02:57:24.175Z" }, + { url = "https://files.pythonhosted.org/packages/91/23/1f904bc9cbd8eece393e20840c08ba3ac03440090c3a4e95168fa6d2709f/nodejs_wheel_binaries-24.14.0-py2.py3-none-win_arm64.whl", hash = "sha256:78a9bd1d6b11baf1433f9fb84962ff8aa71c87d48b6434f98224bc49a2253a6e", size = 38926103, upload-time = "2026-02-27T02:57:27.458Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pastel" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/f1/4594f5e0fcddb6953e5b8fe00da8c317b8b41b547e2b3ae2da7512943c62/pastel-0.2.1.tar.gz", hash = "sha256:e6581ac04e973cac858828c6202c1e1e81fee1dc7de7683f3e1ffe0bfd8a573d", size = 7555, upload-time = "2020-09-16T19:21:12.43Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/18/a8444036c6dd65ba3624c63b734d3ba95ba63ace513078e1580590075d21/pastel-0.2.1-py2.py3-none-any.whl", hash = "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364", size = 5955, upload-time = "2020-09-16T19:21:11.409Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, +] + +[[package]] +name = "poethepoet" +version = "0.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pastel" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/9b/e717572686bbf23e17483389c1bf3a381ca2427c84c7e0af0cdc0f23fccc/poethepoet-0.42.1.tar.gz", hash = "sha256:205747e276062c2aaba8afd8a98838f8a3a0237b7ab94715fab8d82718aac14f", size = 93209, upload-time = "2026-02-26T22:57:50.883Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/68/75fa0a5ef39718ea6ba7ab6a3d031fa93640e57585580cec85539540bb65/poethepoet-0.42.1-py3-none-any.whl", hash = "sha256:d8d1345a5ca521be9255e7c13bc2c4c8698ed5e5ac5e9e94890d239fcd423d0a", size = 119967, upload-time = "2026-02-26T22:57:49.467Z" }, +] + +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", size = 264075, upload-time = "2026-01-30T01:03:04.143Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", size = 103323, upload-time = "2026-01-30T01:03:05.412Z" }, + { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" }, + { url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" }, + { url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" }, + { url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" }, + { url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" }, + { url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" }, + { url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" }, + { url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" }, + { url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" }, + { url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" }, +]