{#- 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 %}