Initial commit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph HENRY 2026-03-26 13:00:51 +01:00
commit 852a5de700
19 changed files with 5340 additions and 0 deletions

13
.gitignore vendored Normal file
View File

@ -0,0 +1,13 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
# Downloaded Blender binaries
downloads/

1
.python-version Normal file
View File

@ -0,0 +1 @@
3.11

21
LICENSE Normal file
View File

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

125
README.md Normal file
View File

@ -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/) <img src="https://upload.wikimedia.org/wikipedia/commons/0/0c/Blender_logo_no_text.svg" alt="blender" width="20"/>

175
blender_downloader.py Normal file
View File

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

862
generate_stubs.py Normal file
View File

@ -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"(?<!\.)(?<!\w)\b{bare}\b", qualified, type_str)
return type_str
def qualify_module_types(module_data: ModuleData) -> 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"(?<!\.)\bSequence\[", all_type_strs):
abc_imports.append("Sequence")
imports: list[str] = [
f"from collections.abc import {', '.join(abc_imports)}",
f"from typing import {', '.join(typing_imports)}",
"import builtins",
(
"from typing import override"
if tuple(int(x) for x in python_version.split(".")) >= (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"(?<!\w\.)(?<!\w){re.escape(short_name)}\.(\w+)", r"\1", content
)
return content
def generate_module_stub(
module_data: ModuleData,
python_version: str = "3.11",
submodule_names: list[str] | None = None,
) -> 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"(?<!\w){name}\[",
f"{full_module}.{name}[",
result,
)
if f"import {full_module}" not in result:
result = f"import {full_module}\n" + result
return prune_unused_imports(result)
def load_overrides(overrides_dir: str, module_name: str) -> 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()

1946
introspect.py Normal file

File diff suppressed because it is too large Load Diff

417
main.py Normal file
View File

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

View File

@ -0,0 +1,7 @@
{
"bake_action_objects_iter": {
"params": {
"bake_options": "object"
}
}
}

View File

@ -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"
}
}
}

View File

@ -0,0 +1,8 @@
{
"active_framebuffer_get": {
"return_type": "gpu.types.GPUFrameBuffer"
},
"viewport_get": {
"return_type": "tuple[int, int, int, int]"
}
}

59
pyproject.toml Normal file
View File

@ -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",
]

3
stubs/_rna_info.py Normal file
View File

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

40
stubs/_rna_info.pyi Normal file
View File

@ -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],
]: ...

0
tests/__init__.py Normal file
View File

View File

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

View File

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

531
tests/test_introspect.py Normal file
View File

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

284
uv.lock generated Normal file
View File

@ -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" },
]