mirror of
https://github.com/blender/blender
synced 2026-10-25 03:07:01 +03:00
Currently the stub generation mostly serves to expose gaps/errors in Python's documentation, although it should be reasonably complete too. While it is possible to use the generated stubs with mypy, most scripts generate many errors, mainly because of `bpy.props.*` annotations which aren't recognized by mypy. - Generate mypy-compatible `.pyi` stubs from RST (`sphinx_stub_gen.py`). - Validate stubs via syntax + import check (`sphinx_stub_validate.py`). - Add `python_stubs` make target. - Include dunder methods (`__*__` methods) in RST doc-strings, these used for the stub files, but also available in generated docs. - Use foldable `.. details::` so as not to add unnecessary noise to the HTML output. - Use literal default reprs for Matrix defaults so signatures can be evaluated. Ref !156260
214 lines
7.2 KiB
Python
214 lines
7.2 KiB
Python
#!/usr/bin/env python3
|
|
# SPDX-FileCopyrightText: 2026 Blender Authors
|
|
#
|
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
"""
|
|
Validate Python type stubs (.pyi) produced by ``sphinx_stub_gen.py``.
|
|
|
|
Two checks are performed:
|
|
|
|
- Syntactic: each ``.pyi`` is fed through :func:`compile`.
|
|
- Semantic: each module is loaded via a custom ``.pyi`` finder, surfacing
|
|
base-class and module-level evaluation errors (e.g. references to undefined
|
|
names from class bases or non-trivial defaults).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
__all__ = (
|
|
"main",
|
|
"validate_stubs",
|
|
)
|
|
|
|
import argparse
|
|
import contextlib
|
|
import importlib
|
|
import importlib.abc
|
|
import importlib.machinery
|
|
import importlib.util
|
|
import sys
|
|
import types
|
|
|
|
from collections.abc import Iterator, Sequence
|
|
from pathlib import Path
|
|
|
|
|
|
def discover_stubs(stubs_dir: Path) -> tuple[list[Path], list[str]]:
|
|
"""
|
|
Walk *stubs_dir* once and return ``(stub_paths, module_names)``, both sorted.
|
|
|
|
*stub_paths* lists every ``.pyi`` file. *module_names* contains the dotted
|
|
module name for each ``__init__.pyi`` (top-level ``__init__.pyi`` excluded).
|
|
"""
|
|
stub_paths: list[Path] = []
|
|
module_names: list[str] = []
|
|
for pyi in sorted(stubs_dir.rglob("*.pyi")):
|
|
stub_paths.append(pyi)
|
|
if pyi.name == "__init__.pyi":
|
|
rel = pyi.parent.relative_to(stubs_dir)
|
|
if rel.parts:
|
|
module_names.append(".".join(rel.parts))
|
|
return stub_paths, module_names
|
|
|
|
|
|
def validate_stubs_syntactically(stub_paths: list[Path]) -> list[str]:
|
|
"""Run :func:`compile` on each path; return error messages (empty on success).
|
|
|
|
Catches read errors (``OSError``), decoding errors (``UnicodeDecodeError``,
|
|
``ValueError`` for null bytes), and parse errors (``SyntaxError``) - each
|
|
is recorded rather than propagated.
|
|
"""
|
|
errors: list[str] = []
|
|
for pyi_path in stub_paths:
|
|
try:
|
|
compile(pyi_path.read_text(encoding="utf-8"), str(pyi_path), "exec")
|
|
except SyntaxError as ex:
|
|
errors.append("{:s}:{:d}: {:s}".format(str(pyi_path), ex.lineno or 0, str(ex.msg)))
|
|
except (OSError, ValueError) as ex:
|
|
errors.append("{:s}: {:s}: {:s}".format(str(pyi_path), type(ex).__name__, str(ex)))
|
|
return errors
|
|
|
|
|
|
class PyiFinder(importlib.abc.MetaPathFinder):
|
|
"""``MetaPathFinder`` that resolves ``__init__.pyi`` files under *base*."""
|
|
|
|
def __init__(self, base: Path) -> None:
|
|
self.base = base
|
|
|
|
def find_spec(
|
|
self,
|
|
fullname: str,
|
|
path: Sequence[str] | None,
|
|
target: types.ModuleType | None = None,
|
|
/,
|
|
) -> importlib.machinery.ModuleSpec | None:
|
|
del path, target
|
|
parts = fullname.split(".")
|
|
pkg_init = self.base.joinpath(*parts, "__init__.pyi")
|
|
if pkg_init.is_file():
|
|
loader = importlib.machinery.SourceFileLoader(fullname, str(pkg_init))
|
|
spec = importlib.util.spec_from_loader(fullname, loader)
|
|
if spec is not None:
|
|
spec.submodule_search_locations = [str(pkg_init.parent)]
|
|
return spec
|
|
return None
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def stub_import_context(stubs_dir: Path, module_names: list[str]) -> Iterator[None]:
|
|
"""
|
|
Temporarily install a ``.pyi`` finder and clear *module_names* from ``sys.modules``.
|
|
|
|
Restores both on exit so that ``sys.meta_path`` and ``sys.modules`` are not
|
|
permanently mutated. Safe to nest and re-enter.
|
|
"""
|
|
finder = PyiFinder(stubs_dir.resolve())
|
|
sys.meta_path.insert(0, finder)
|
|
# Drop any pre-existing entries (real Blender modules) so the finder is consulted.
|
|
for name in module_names:
|
|
sys.modules.pop(name, None)
|
|
try:
|
|
yield
|
|
finally:
|
|
try:
|
|
sys.meta_path.remove(finder)
|
|
except ValueError:
|
|
pass # Already removed by something else; nothing to undo.
|
|
for name in module_names:
|
|
sys.modules.pop(name, None)
|
|
|
|
|
|
def validate_stubs_semantically(
|
|
stubs_dir: Path,
|
|
module_names: list[str],
|
|
) -> list[str]:
|
|
"""
|
|
Import each stub module from *stubs_dir*; return error messages.
|
|
|
|
Uses :func:`stub_import_context` so ``sys.meta_path`` and ``sys.modules``
|
|
are restored after the call. ``typing.TYPE_CHECKING`` is intentionally left
|
|
``False`` so that cross-stub imports under ``if TYPE_CHECKING:`` blocks do
|
|
not execute - this is what allows cycles to break at runtime. The check
|
|
therefore validates "every stub is importable in isolation", which catches
|
|
base-class and module-level evaluation errors (the bugs runtime-importing
|
|
actually surfaces).
|
|
"""
|
|
errors: list[str] = []
|
|
with stub_import_context(stubs_dir, module_names):
|
|
for name in module_names:
|
|
try:
|
|
importlib.import_module(name)
|
|
except Exception as ex:
|
|
errors.append("{:s}: {:s}: {:s}".format(name, type(ex).__name__, str(ex)))
|
|
return errors
|
|
|
|
|
|
def validate_stubs(
|
|
stubs_dir: Path,
|
|
stub_paths: list[Path],
|
|
module_names: list[str],
|
|
*,
|
|
verbose: bool = False,
|
|
) -> int:
|
|
"""
|
|
Run syntax and semantic validation against the given stubs.
|
|
|
|
Steps:
|
|
|
|
- Syntax: each path in *stub_paths* is compiled via :func:`compile`.
|
|
- Semantic: each name in *module_names* is imported via a temporary ``.pyi`` finder rooted at *stubs_dir*.
|
|
|
|
On any error, details are written to ``stderr`` and the function returns ``1``.
|
|
On success, returns ``0``. The semantic step is skipped if syntax errors are present.
|
|
"""
|
|
if verbose:
|
|
print("Checking {:d} stub(s) under {:s}".format(len(stub_paths), str(stubs_dir)))
|
|
|
|
syntax_errors = validate_stubs_syntactically(stub_paths)
|
|
if syntax_errors:
|
|
print("\nStub syntax errors:", file=sys.stderr)
|
|
for err in syntax_errors:
|
|
print(" {:s}".format(err), file=sys.stderr)
|
|
return 1
|
|
|
|
semantic_errors = validate_stubs_semantically(stubs_dir, module_names)
|
|
if semantic_errors:
|
|
print("\nStub semantic errors:", file=sys.stderr)
|
|
for err in semantic_errors:
|
|
print(" {:s}".format(err), file=sys.stderr)
|
|
return 1
|
|
|
|
if verbose:
|
|
print("\nAll {:d} stub(s) passed validation.".format(len(stub_paths)))
|
|
return 0
|
|
|
|
|
|
def main() -> int:
|
|
"""Validate stubs found under a directory passed on the command line."""
|
|
parser = argparse.ArgumentParser(description="Validate .pyi stubs.")
|
|
parser.add_argument(
|
|
"stubs_dir", nargs="?",
|
|
default=str(Path(__file__).parent / "stubs"),
|
|
help="Directory containing .pyi stubs (default: %(default)s)",
|
|
)
|
|
parser.add_argument(
|
|
"-v", "--verbose", action="store_true",
|
|
help="Show per-stub progress.",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
stubs_dir = Path(args.stubs_dir)
|
|
if not stubs_dir.is_dir():
|
|
print("Error: stubs directory not found: {:s}".format(str(stubs_dir)), file=sys.stderr)
|
|
return 1
|
|
|
|
stub_paths, module_names = discover_stubs(stubs_dir)
|
|
if not stub_paths:
|
|
print("Error: no .pyi files found under {:s}".format(str(stubs_dir)), file=sys.stderr)
|
|
return 1
|
|
|
|
return validate_stubs(stubs_dir, stub_paths, module_names, verbose=args.verbose)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|