"""Compare this binding's bound surface against the stock wheel's. `inventory.py --check` answers "does the symbol exist"; this answers "does it mean the same thing". The gap between those two is where a binding does real damage: an `nb::init<...>` whose types differ from the constructor you meant can bind a *different overload* through an implicit conversion and build valid but wrong geometry, silently. That happened once — BRepPrimAPI_MakePrism's gp_Vec form resolving to the semi-infinite gp_Dir one, because gp_Dir converts from gp_Vec — and the fixture digests are what caught it. This finds the class of bug directly. Two runs, then a diff: # under the stock wheel, from the app checkout uv run --project backend python ../ocp/tools/sigdiff.py --dump stock.json # under ours make shell # then: /cache/venv/bin/python /io/tools/sigdiff.py --dump /io/ours.json python tools/sigdiff.py --compare stock.json ours.json The comparison is one-sided on purpose: binding *fewer* overloads than stock is the normal state of this project, so only surface we expose that stock does not is reported. pybind11 spells the receiver as `self: Cls` and nanobind as plain `self`, so the leading class name is dropped before comparing. """ from __future__ import annotations import argparse import importlib import json import pathlib import re import sys import types HERE = pathlib.Path(__file__).resolve().parent DEFAULT_INVENTORY = HERE.parent / "inventory.json" #: Parameter annotations, as both wrappers write them: `name: Some.Type`. _ANNOTATION = re.compile(r":\s*([A-Za-z_][\w.]*)") def _signatures(owner, name: str) -> list[tuple[str, ...]]: """Every overload of `name`, reduced to its parameter type names.""" doc = getattr(getattr(owner, name, None), "__doc__", None) if not doc: return [] out = set() for line in doc.splitlines(): line = re.sub(r"^\d+\.\s*", "", line.strip()) if "(" not in line or ")" not in line: continue inner = line[line.index("(") + 1 : line.rindex(")")] types = tuple(t.split(".")[-1] for t in _ANNOTATION.findall(inner)) out.add(types) return sorted(out) def dump(inventory: pathlib.Path, out: pathlib.Path) -> int: data = json.loads(inventory.read_text()) result: dict = {} for mod, symbols in data["modules"].items(): try: module = importlib.import_module(f"OCP.{mod}") except ImportError: continue for name in symbols: obj = getattr(module, name, None) if obj is None: continue # OCCT namespaces (TopoDS, BRepGProp, ...) have no class to hang # their statics on: upstream exposes them as a module, this binding # as an empty carrier class. Both answer `TopoDS.Face_s(...)`, so # compare their members either way. if not isinstance(obj, (type, types.ModuleType)): continue # An nb::is_arithmetic() enum derives from int, so dir() carries # int's methods; pybind11's enum does not. Not surface we bound. inherited = set(dir(int)) if isinstance(obj, type) and issubclass( obj, int) else set() result[f"{mod}.{name}"] = { "init": _signatures(obj, "__init__"), "members": sorted( m for m in dir(obj) if not m.startswith("_") and m not in inherited ), } out.write_text(json.dumps(result, indent=1) + "\n") print(f"{len(result)} classes -> {out}") return 0 def compare(stock_path: pathlib.Path, ours_path: pathlib.Path) -> int: stock = json.loads(stock_path.read_text()) ours = json.loads(ours_path.read_text()) problems = 0 def normalized(entry: dict, cls: str) -> set[tuple[str, ...]]: out = set() for sig in entry.get("init", []): sig = list(sig) if sig and sig[0] == cls: # pybind11's `self: Cls` sig = sig[1:] out.add(tuple(sig)) return out for key, mine in sorted(ours.items()): theirs = stock.get(key) if theirs is None: print(f"{key}: not present in the stock wheel") problems += 1 continue cls = key.split(".", 1)[1] extra_init = normalized(mine, cls) - normalized(theirs, cls) if extra_init: problems += 1 print(f"{key}: constructor overloads stock does not have") for sig in sorted(extra_init): print(f" ours : ({', '.join(sig)})") for sig in sorted(normalized(theirs, cls)): print(f" stock: ({', '.join(sig)})") extra_members = set(mine["members"]) - set(theirs["members"]) if extra_members: problems += 1 print(f"{key}: members stock does not have: {sorted(extra_members)}") if problems: print(f"\n{problems} difference(s) — each is either a mis-bound overload " f"or a deliberate deviation worth a comment in its module") return 1 print(f"{len(ours)} classes, no surface we expose that stock does not") return 0 def main() -> int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--dump", type=pathlib.Path, help="write this wheel's surface") ap.add_argument("--compare", nargs=2, type=pathlib.Path, metavar=("STOCK", "OURS")) ap.add_argument("--inventory", type=pathlib.Path, default=DEFAULT_INVENTORY) args = ap.parse_args() if args.dump: return dump(args.inventory, args.dump) if args.compare: return compare(*args.compare) ap.print_help() return 2 if __name__ == "__main__": raise SystemExit(main())