blender/doc/python_api/sphinx_stub_validate.py
Campbell Barton 0cc7cc9e3e PyDoc: add *.pyi stub generator, that generates stubs from docs
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
2026-05-09 05:16:41 +00:00

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