Files
ocp/tools/sigdiff.py
stroblme 01a5bcf188 10C Inc 3 + 4: I/O and text — the app's whole OCP surface is bound
138/138 symbols across 53 modules. New: IFSelect, Interface, XSControl,
IGESData, STEPControl, IGESControl, RWStl, Graphic3d, NCollection, StdPrs.

**The full app suite passes against this wheel: 1797 passed, 1 skipped — the
same result as the stock wheel**, run from the parity venv.

Getting there needed six methods that no static analysis could have found:
`inventory --check` only sees symbols reached through an import, so a method
called on an instance is invisible to it. The suite found them in one pass, and
one of them (gp_Vec.Reverse, which every outward-normal probe calls) accounted
for 311 of the 255 failing tests on its own. The others: gp_Trsf.SetMirror over
a plane and a point, Geom_Surface.D0, BRep_Builder.MakeFace from a
triangulation, MakePipeShell.SetMode with a fixed binormal, and MakeFace from a
surface plus tolerance.

Open question **S5 is settled: no**. The wheel does not need OCCT's share/
resources. test_inc3_io.py asserts no CSF_* variable is set and then round-trips
STEP and IGES, reading the declared units back off both — which is exactly the
resource-less container the question was about.

The XSTEP readers keep the GIL, amending the blanket "file readers and writers"
line in design.md's GIL policy. STEP and IGES traffic in process-global
Interface_Static state, the IGES reader is documented as not thread-safe, and
the app already serialises imports behind a lock — so holding it costs nothing
and removes a class of question. RWStl, which touches no global state, releases.

XSControl_Reader and IGESData are registered although the app imports neither:
they are the reader base both concrete readers inherit their transfer surface
from, and the model-to-global-section chain the IGES unit probe walks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfriM8XUkn7uYf5Dwe2xo6
2026-08-10 20:31:15 +02:00

162 lines
6.0 KiB
Python

"""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 []
# A class with no bound constructor still exposes object's __init__ stub,
# whose docstring carries no signature — that would otherwise read as a
# nullary overload the binding does not actually have.
if name == "__init__" and doc.startswith("Initialize self."):
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())