Initial commit
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
852a5de700
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal 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
1
.python-version
Normal file
@ -0,0 +1 @@
|
||||
3.11
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
125
README.md
Normal file
@ -0,0 +1,125 @@
|
||||
# blender-python-stubs
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
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
175
blender_downloader.py
Normal 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
862
generate_stubs.py
Normal 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
1946
introspect.py
Normal file
File diff suppressed because it is too large
Load Diff
417
main.py
Normal file
417
main.py
Normal 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.")
|
||||
7
overrides/4.3/bpy_extras.anim_utils.json
Normal file
7
overrides/4.3/bpy_extras.anim_utils.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"bake_action_objects_iter": {
|
||||
"params": {
|
||||
"bake_options": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
22
overrides/5.0/bpy.path.json
Normal file
22
overrides/5.0/bpy.path.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
8
overrides/5.0/gpu.state.json
Normal file
8
overrides/5.0/gpu.state.json
Normal 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
59
pyproject.toml
Normal 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
3
stubs/_rna_info.py
Normal 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
40
stubs/_rna_info.pyi
Normal 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
0
tests/__init__.py
Normal file
42
tests/test_blender_downloader.py
Normal file
42
tests/test_blender_downloader.py
Normal 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()
|
||||
784
tests/test_generate_stubs.py
Normal file
784
tests/test_generate_stubs.py
Normal 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
531
tests/test_introspect.py
Normal 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
284
uv.lock
generated
Normal 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" },
|
||||
]
|
||||
Loading…
x
Reference in New Issue
Block a user