Files
rerun-io--rerun/scripts/ci/python_check_signatures.py
T
2026-07-13 13:05:14 +08:00

321 lines
12 KiB
Python

"""
Compares the signatures in `rerun_bindings.pyi` with the actual runtime signatures in `rerun_bindings.so`.
This does not check that the type annotations match. However, it does ensure that the number of arguments,
the argument names, and whether the arguments are required or have defaults match between the stub and runtime.
"""
from __future__ import annotations
import ast
import difflib
import importlib
import inspect
import sys
import textwrap
from inspect import Parameter, Signature
from pathlib import Path
from typing import Any
import parso
from colorama import Fore, Style
from colorama import init as colorama_init
colorama_init()
def print_colored_diff(runtime: str, stub: str) -> None:
# Split the strings into lines
runtime_lines = runtime.splitlines()
stub_lines = stub.splitlines()
# Generate the diff
diff = difflib.unified_diff(runtime_lines, stub_lines, fromfile="runtime", tofile="stub")
# Print the diff output with colored lines
for line in diff:
if line.startswith("+"):
print(Fore.GREEN + line + Style.RESET_ALL)
elif line.startswith("-"):
print(Fore.RED + line + Style.RESET_ALL)
elif line.startswith("?"):
print(Fore.YELLOW + line + Style.RESET_ALL)
else:
print(line)
class APIDef:
def __init__(
self, name: str, signature: Signature, internal_object: bool, doc: str | None, getset: bool = False
) -> None:
self.name = name
self.signature = signature
self.internal_object = internal_object
self.doc = inspect.cleandoc(doc) if doc else None
self.getset = getset
def __str__(self) -> str:
doclines = (self.doc or "").split("\n")
if len(doclines) == 1:
docstring = f'"""{doclines[0]}"""'
else:
docstring = '"""\n' + "\n".join(doclines) + '\n"""'
docstring = textwrap.indent(docstring, " ")
return f"{self.name}{self.signature}:\n{docstring}"
def __eq__(self, other: object) -> bool:
if not isinstance(other, APIDef):
return NotImplemented
if self.name in ("__init__", "__iter__", "__len__"):
# pyo3 has a special way to handle these methods that makes it impossible to match everything.
# TODO(#7779): Remove this special case once we have a better way to handle these methods
return self.name == other.name and self.signature == other.signature
elif self.name in ("__getitem__"):
# TODO(#7779): It's somehow even worse for these.
return self.name == other.name
elif self.internal_object:
# We don't care about docstrings for internal objects
return self.name == other.name and self.signature == other.signature
elif self.getset:
# Getter/setter methods are complicated. We don't enforce the signatures match.
return self.name == other.name and self.doc == other.doc
else:
return self.name == other.name and self.signature == other.signature and self.doc == other.doc
TotalSignature = dict[str, APIDef | dict[str, APIDef]]
def parse_function_signature(node: Any) -> APIDef:
"""Convert a parso function definition node into a Python inspect.Signature object."""
params = []
found_star = False
for param in node.children[2].children:
if param.type == "operator":
if param.value == "*":
found_star = True
continue
param_name = param.name.value
try:
default = Parameter.empty
if param.default:
code = param.default.get_code()
try:
default = ast.literal_eval(code)
except Exception as e:
# TODO(emilk): When we update to Python 3.11, use e.add_note
raise ValueError(f"code='{code}', {e}") from e
# Determine kind of parameter (positional, keyword, etc.)
if param.star_count == 1:
kind: Any = Parameter.VAR_POSITIONAL # *args
found_star = True
elif param.star_count == 2:
kind = Parameter.VAR_KEYWORD # **kwargs
elif param_name == "self":
kind = Parameter.POSITIONAL_ONLY
elif found_star:
kind = Parameter.KEYWORD_ONLY
else:
kind = Parameter.POSITIONAL_OR_KEYWORD
params.append(Parameter(name=param_name, kind=kind, default=default))
except Exception as e:
# TODO(emilk): When we update to Python 3.11, use e.add_note
raise ValueError(f"param_name='{param_name}', {e}") from e
doc = None
for child in node.children:
if child.type == "suite":
first_child = child.children[1]
if first_child.type == "simple_stmt" and first_child.children[0].type == "string":
doc = first_child.children[0].value.replace('"""', "")
sig = Signature(parameters=params)
return APIDef(node.name.value, sig, internal_object=False, doc=doc)
def load_stub_signatures(pyi_file: Path) -> TotalSignature:
"""Use parso to parse the .pyi file and convert function and class signatures into inspect.Signature objects."""
pyi_code = Path(pyi_file).read_text(encoding="utf-8")
tree = parso.parse(pyi_code)
signatures: TotalSignature = {}
for node in tree.children:
if node.type == "funcdef":
func_name = node.name.value
func_signature = parse_function_signature(node)
signatures[func_name] = func_signature
elif node.type == "classdef":
class_name = node.name.value
try:
class_def = {}
doc = None
for child in node.children:
if child.type == "suite":
first_child = child.children[1]
if first_child.type == "simple_stmt" and first_child.children[0].type == "string":
doc = first_child.children[0].value.replace('"""', "")
if doc is not None:
class_def["__doc__"] = doc
# Extract methods within the class
for class_node in node.iter_funcdefs():
method_name = class_node.name.value
try:
method_signature = parse_function_signature(class_node)
except Exception as e:
# TODO(emilk): When we update to Python 3.11, use e.add_note
raise ValueError(f"method_name='{method_name}', {e}") from e
class_def[method_name] = method_signature
signatures[class_name] = class_def
except Exception as e:
# TODO(emilk): When we update to Python 3.11, use e.add_note
raise ValueError(f"class_name='{class_name}', {e}") from e
return signatures
def load_runtime_signatures(module_name: str) -> TotalSignature:
"""Use inspect to extract runtime signatures for both functions and classes."""
module = importlib.import_module(module_name)
signatures: TotalSignature = {}
# Get top-level functions and classes
for name, obj in inspect.getmembers(module):
if inspect.isfunction(obj) or inspect.isbuiltin(obj):
api_def = APIDef(name, inspect.signature(obj), False, obj.__doc__)
signatures[name] = api_def
elif inspect.isclass(obj):
class_def = {}
is_internal_class = name.endswith("Internal")
# Get methods within the class
for method_name, method_obj in inspect.getmembers(obj):
# Need special handling for __init__ methods because pyo3 doesn't expose them as functions
# Instead we use the __text_signature__ attribute from the class
if method_name == "__init__" and obj.__text_signature__ is not None:
sig = "def __init__" + obj.__text_signature__ + ": ..." # NOLINT
parsed = parso.parse(sig).children[0]
class_def[method_name] = parse_function_signature(parsed)
continue
try:
api_def = APIDef(method_name, inspect.signature(method_obj), is_internal_class, method_obj.__doc__)
class_def[method_name] = api_def
except Exception:
pass
# Get property getters
for method_name, method_obj in inspect.getmembers(
obj,
lambda o: o.__class__.__name__ == "getset_descriptor",
):
api_def = APIDef(
method_name,
Signature(parameters=[Parameter("self", Parameter.POSITIONAL_ONLY)]),
is_internal_class,
method_obj.__doc__,
getset=True,
)
class_def[method_name] = api_def
signatures[name] = class_def
return signatures
def compare_signatures(stub_signatures: TotalSignature, runtime_signatures: TotalSignature) -> int:
"""Compare stub signatures with runtime signatures."""
result = 0
for name, stub_signature in stub_signatures.items():
if isinstance(stub_signature, dict):
if name in runtime_signatures:
runtime_class_signature = runtime_signatures.get(name)
is_internal_class = name.endswith("Internal")
if not isinstance(runtime_class_signature, dict):
print()
print(f"{name} signature mismatch:")
print("Stub expected class, but runtime provided function.")
continue
for method_name, stub_method_signature in stub_signature.items():
if isinstance(stub_method_signature, str):
continue
if stub_method_signature.doc is None and not is_internal_class:
print()
print(f"{name}.{method_name} missing docstring")
result += 1
runtime_method_signature = runtime_class_signature.get(method_name)
if runtime_method_signature != stub_method_signature:
print()
print(f"{name}.{method_name}(…) signature mismatch:")
print_colored_diff(str(runtime_method_signature), str(stub_method_signature))
result += 1
else:
docstr = stub_signature.get("__doc__", "")
if isinstance(docstr, str) and "Required-feature:" in docstr:
print(f"Skipping class {name} since it requires features not enabled in default runtime")
else:
print(f"Class {name} not found in runtime")
result += 1
else:
if stub_signature.doc is None:
print()
print(f"{name} missing docstring")
result += 1
if name in runtime_signatures:
# Handle top-level functions
runtime_signature = runtime_signatures.get(name)
if runtime_signature != stub_signature:
print()
print(f"{name}(…) signature mismatch:")
print_colored_diff(str(runtime_signature), str(stub_signature))
result += 1
else:
print()
if stub_signature.doc is not None and "Required-feature:" in stub_signature.doc:
print(f"Skipping Function {name} since it requires features not enabled in default runtime")
else:
print(f"Function {name} not found in runtime")
result += 1
if result > 0:
print()
print(
f"{result} error(s) found. Please update rerun_py/rerun_bindings/rerun_bindings.pyi to match the runtime."
)
else:
print("All stub signatures match!")
return result
def main() -> int:
# load the stub file
path_to_stub = Path(__file__).parent.parent.parent / "rerun_py" / "rerun_bindings" / "rerun_bindings.pyi"
stub_signatures = load_stub_signatures(path_to_stub)
# load the runtime signatures
runtime_signatures = load_runtime_signatures("rerun_bindings")
sys.exit(compare_signatures(stub_signatures, runtime_signatures))
if __name__ == "__main__":
main()