Files
ocp/tools/inventory.py
stroblme 6139852768 Phase 10A/10B Inc 0: build system, handle model, first module surface
Builds n3xd-ocp end to end and publishes 7.9.3.1.dev1 to the Gitea registry,
where it installs anonymously and passes its suite.

- occt/Dockerfile: OCCT 7.9.3 compiled once into a manylinux_2_28 builder
  image (base digest + tarball sha256 pinned), Draw/VTK/Tk/Xlib/OpenGL off,
  FreeType on, -O2 without fast-math or march=native. A final layer asserts
  TKService/TKV3d exist with no libGL/libX11 DT_NEEDED, which is what lets the
  app image drop libgl1/libx11-6. Mounted into, never built FROM.
- scikit-build-core + nanobind STABLE_ABI -> one cp312-abi3 extension that
  registers every OCP.* submodule via PyImport_AddModule, so `import
  OCP.TopoDS` needs no shim and cls.__module__ is right. Version <occt>.N is
  asserted against the OCCT found, keeping occt_version() truthful.
- occt_handle.h: type caster for opencascade::handle<T> over OCCT's intrusive
  refcount. Wrappers are non-owning instances holding exactly one handle in
  their keep-alive list, reusing an existing wrapper so identity survives a
  round trip. Transient constructors go through ocp_new (never nb::init<>,
  which would let OCCT delete nanobind's storage); the caster refuses a
  refcount-0 object rather than corrupt the heap. Verified under ASAN with no
  memory-safety errors, plus an RSS bound over 50k create/destroy cycles.
- Sub-shapes are returned by value everywhere, making the TShape lifetime class
  that segfaulted a process-global face memo unrepresentable.
- Standard_Failure derives RuntimeError, with ~20 concrete types dispatched on
  the dynamic OCCT type (cad_pool marshals failures home by type name).
- Inc 0 surface: gp subset, TopAbs, TopoDS (+ downcasts), TopExp, TopLoc,
  TopTools, BRep, BinTools, Poly, Standard. 34 of the app's 139 symbols.
- n3xd_ocp: additive APIs kept out of the OCP namespace so parity testing stays
  meaningful. bintools (shape <-> bytes, GIL-free, byte-identical) and _debug.

Two findings worth the record, both verified against the stock wheel rather
than assumed: upstream binds __hash__ but leaves __eq__ at identity, which is
exactly what geom_memo.py's hash-bucket + IsSame scan is built around, so we
match it instead of "fixing" it; and BinTools can release the GIL after all, by
slurping the file-like object instead of bridging a streambuf that would call
back into Python.

Gate: BREP round-trips are byte-identical to cadquery-ocp-novtk across six
fixtures (the generator asserts stock idempotency first). That matters beyond
IPC — derive.py content-addresses BREP payloads by sha256 and stores the ref.
2026-08-10 16:10:28 +02:00

155 lines
5.2 KiB
Python

"""Symbol inventory: what the app imports from OCP, and what this wheel provides.
Two jobs, one source of truth:
--emit parse the app's sources for every `from OCP.<mod> import <name>`
plus the attributes reached on those names, and write inventory.json.
That file is the coverage work-queue for the remaining increments
and, re-run later, the drift detector for newly used symbols.
--check import the installed OCP and report what the inventory asks for but
the wheel does not provide, grouped by module.
Static analysis only reaches names reached through an imported symbol
(`TopExp.MapShapes_s`), not methods called on instances (`shape.IsSame(...)`) —
those are covered by running the app's own suite in the parity venv, where a
gap is a loud AttributeError. So a green --check is necessary, not sufficient.
python tools/inventory.py --emit --app ../app/backend
python tools/inventory.py --check
"""
from __future__ import annotations
import argparse
import ast
import collections
import json
import pathlib
import sys
HERE = pathlib.Path(__file__).resolve().parent
DEFAULT_INVENTORY = HERE.parent / "inventory.json"
DEFAULT_APP = HERE.parent.parent / "app" / "backend"
def scan(app_root: pathlib.Path) -> dict:
"""Collect {module: {symbol: [attributes reached on it]}} from the app."""
modules: dict[str, dict[str, set[str]]] = collections.defaultdict(
lambda: collections.defaultdict(set)
)
files = 0
for path in sorted(app_root.rglob("*.py")):
if "__pycache__" in path.parts:
continue
try:
tree = ast.parse(path.read_text(encoding="utf-8"))
except (SyntaxError, UnicodeDecodeError):
continue
# local alias -> (module, original name)
local: dict[str, tuple[str, str]] = {}
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module:
if node.module == "OCP" or node.module.startswith("OCP."):
mod = node.module[4:] or "OCP"
for alias in node.names:
local[alias.asname or alias.name] = (mod, alias.name)
modules[mod][alias.name] # noqa: B018 — create the entry
if local:
files += 1
for node in ast.walk(tree):
if (
isinstance(node, ast.Attribute)
and isinstance(node.value, ast.Name)
and node.value.id in local
):
mod, name = local[node.value.id]
modules[mod][name].add(node.attr)
return {
"app_root": str(app_root),
"files_importing_ocp": files,
"modules": {
mod: {name: sorted(attrs) for name, attrs in sorted(syms.items())}
for mod, syms in sorted(modules.items())
},
}
def emit(app_root: pathlib.Path, out: pathlib.Path) -> int:
data = scan(app_root)
out.write_text(json.dumps(data, indent=2) + "\n")
n_mod = len(data["modules"])
n_sym = sum(len(v) for v in data["modules"].values())
print(f"{n_sym} symbols across {n_mod} modules "
f"({data['files_importing_ocp']} files) -> {out}")
return 0
def check(inventory: pathlib.Path) -> int:
import importlib
data = json.loads(inventory.read_text())
missing: dict[str, list[str]] = {}
present = 0
for mod, symbols in data["modules"].items():
try:
m = importlib.import_module(f"OCP.{mod}")
except ImportError:
missing[mod] = [f"<module missing> ({len(symbols)} symbols)"]
continue
for name, attrs in symbols.items():
obj = getattr(m, name, None)
if obj is None:
missing.setdefault(mod, []).append(name)
continue
present += 1
for attr in attrs:
if not hasattr(obj, attr):
missing.setdefault(mod, []).append(f"{name}.{attr}")
total = sum(len(v) for v in data["modules"].values())
print(f"{present}/{total} imported symbols available")
if missing:
print(f"\nmissing, by module ({len(missing)} modules):")
for mod in sorted(missing):
print(f" {mod}: {', '.join(sorted(missing[mod]))}")
return 1
print("complete")
return 0
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--emit", action="store_true", help="rewrite the inventory")
ap.add_argument("--check", action="store_true",
help="compare the installed OCP against the inventory")
ap.add_argument("--app", type=pathlib.Path, default=DEFAULT_APP)
ap.add_argument("--inventory", type=pathlib.Path, default=DEFAULT_INVENTORY)
args = ap.parse_args()
if args.emit:
if not args.app.is_dir():
print(f"app sources not found: {args.app}", file=sys.stderr)
return 2
return emit(args.app, args.inventory)
if args.check:
if not args.inventory.exists():
print(f"no inventory at {args.inventory}; run --emit first",
file=sys.stderr)
return 2
return check(args.inventory)
ap.print_help()
return 2
if __name__ == "__main__":
raise SystemExit(main())