alef 0.115.3

Opinionated polyglot binding generator for Rust libraries
Documentation
{#- Python stub conformance test template

   Context variables:
   - header: file header comment
   - import_name: public Python package that ships the native module and its stub
   - native_module: name of the native extension module inside that package
   - forwarded_methods: (class name, method names) pairs the package forwards from each public
     dataclass in `options` to the native class
#}
{{ header }}"""Check native class members while respecting exception instance annotations."""

import ast
import importlib
from pathlib import Path

import pytest


def _is_class_variable(annotation: ast.expr) -> bool:
    if not isinstance(annotation, ast.Subscript):
        return False
    name = annotation.value
    return (isinstance(name, ast.Name) and name.id == "ClassVar") or (
        isinstance(name, ast.Attribute) and name.attr == "ClassVar"
    )


def _is_protocol(base: ast.expr) -> bool:
    if isinstance(base, ast.Subscript):
        base = base.value
    return (isinstance(base, ast.Name) and base.id == "Protocol") or (
        isinstance(base, ast.Attribute)
        and isinstance(base.value, ast.Name)
        and base.value.id == "typing"
        and base.attr == "Protocol"
    )


def _declared_members(stub: Path) -> dict[str, dict[str, bool]]:
    members: dict[str, dict[str, bool]] = {}
    for node in ast.parse(stub.read_text(encoding="utf-8")).body:
        if not isinstance(node, ast.ClassDef) or any(_is_protocol(base) for base in node.bases):
            continue
        names = members.setdefault(node.name, {})
        for item in node.body:
            if isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name):
                names[item.target.id] = not _is_class_variable(item.annotation)
            elif isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and item.name != "__init__":
                names[item.name] = False
    return members


def test_stub_declares_only_members_present_at_runtime() -> None:
    package = importlib.import_module("{{ import_name }}")
    stub = Path(package.__file__).parent / "{{ native_module }}.pyi"
    if not stub.is_file():
        pytest.skip(f"{stub} is not shipped with this installation")
    native = importlib.import_module("{{ import_name }}.{{ native_module }}")
    declared = _declared_members(stub)
    checked = 0
    missing: list[str] = []
    for class_name, names in declared.items():
        cls = getattr(native, class_name, None)
        if cls is None:
            continue
        is_exception = isinstance(cls, type) and issubclass(cls, BaseException)
        for name in sorted(names):
            # Exception payload annotations describe raised instances rather than class descriptors. ~keep
            if is_exception and names[name]:
                continue
            checked += 1
            if not hasattr(cls, name):
                missing.append(f"{class_name}.{name}")
    assert checked > 0, "the stub declares no class members to check"
    assert not missing, f"declared in the stub but absent at runtime: {missing}"
{% if forwarded_methods %}


FORWARDED_METHODS = {
{% for class_name, names in forwarded_methods %}
    "{{ class_name }}": [{% for name in names %}"{{ name }}"{% if not loop.last %}, {% endif %}{% endfor %}],
{% endfor %}
}


def test_public_dataclasses_forward_native_methods() -> None:
    options = importlib.import_module("{{ import_name }}.options")
    missing = [
        f"{class_name}.{name}"
        for class_name, names in FORWARDED_METHODS.items()
        for name in names
        if not callable(getattr(getattr(options, class_name, None), name, None))
    ]
    assert not missing, f"forwarded by the package generator but absent at runtime: {missing}"
{% endif %}