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
162 lines
6.0 KiB
Python
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())
|