Files
ocp/tools/inventory.py
stroblme 091a2ad0cf Housekeeping ahead of the Inc 1-4 coverage work
- inventory.py --methods: report the instance methods the app calls per class,
  plus chained calls that constrain a return type. --check says which classes
  to bind; this says what to bind on them, which is what writing ~38 module
  TUs needs.
- Drop StlAPI from the inventory: StlAPI_Writer has no app call site (the only
  use was a test fixture, now on the app's own STL writer). 138 symbols / 47
  modules.
- adding-symbols.md: scope the executing-constructor ban to the BRepAlgoAPI
  booleans, which are the only classes with a deferred Set*/Build form —
  BRepMesh_IncrementalMesh, GeomAPI_*, BRepCheck_Analyzer and friends compute
  in their constructor by design and bind as stock. Replace the per-increment
  app-test guidance: backend/tests/conftest.py imports n3xd.main, so no app
  test can collect until the last module is bound. Increments gate on
  stock-recorded fixtures here; the app suite is the Inc 4 gate.
- parity_venv.sh: run the ocp suite in the swapped venv (it imports only
  OCP/n3xd_ocp, so it works throughout).
- Fix a stale macro name in occt_handle.h (ocp_new, not OCP_TRANSIENT_NEW) and
  drop the unused ocp_transient_class helper.

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

248 lines
9.1 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 methods(app_root: pathlib.Path, only: str | None) -> int:
"""Report the *instance* methods the app calls on each OCP class.
`scan` only sees names reached through an import, which is enough to know
*which* classes to bind but not *what* to bind on them. This fills that
gap well enough to write a module in one pass instead of discovering the
surface one AttributeError at a time: variables assigned straight from a
constructor carry their class through the file, so `a = BRepAdaptor_Surface(f)`
followed by `a.GetType()` is resolved.
Chained calls are reported separately as `Klass.Outer() -> Inner`, because
what they constrain is the *return* type — `adaptor.Cylinder().Radius()`
says gp_Cylinder needs `Radius`, not that BRepAdaptor_Surface does.
Heuristic by construction: it does not follow arguments, returns or
attributes, so treat a quiet class as "look again", not "nothing needed".
"""
direct: dict[str, set[str]] = collections.defaultdict(set)
chained: dict[str, set[str]] = collections.defaultdict(set)
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
classes = {
alias.asname or alias.name: alias.name
for node in ast.walk(tree)
if isinstance(node, ast.ImportFrom)
and node.module
and (node.module == "OCP" or node.module.startswith("OCP."))
for alias in node.names
}
if not classes:
continue
# local variable -> OCP class, from `v = Klass(...)`
env: dict[str, str] = {}
for node in ast.walk(tree):
if isinstance(node, ast.Assign) and isinstance(node.value, ast.Call):
fn = node.value.func
cls = classes.get(getattr(fn, "id", ""))
if cls:
for tgt in node.targets:
if isinstance(tgt, ast.Name):
env[tgt.id] = cls
def owner(node: ast.expr) -> str | None:
"""The OCP class an expression evaluates to, when knowable."""
if isinstance(node, ast.Name):
return env.get(node.id)
if isinstance(node, ast.Call):
return classes.get(getattr(node.func, "id", ""))
return None
for node in ast.walk(tree):
if not isinstance(node, ast.Attribute):
continue
cls = owner(node.value)
if cls:
direct[cls].add(node.attr)
continue
# <known>.Outer().Inner — constrains Outer's return type
inner = node.value
if isinstance(inner, ast.Call) and isinstance(inner.func, ast.Attribute):
cls = owner(inner.func.value)
if cls:
chained[f"{cls}.{inner.func.attr}()"].add(node.attr)
def dump(title: str, data: dict[str, set[str]]) -> None:
keys = [k for k in sorted(data) if only is None or only in k]
if not keys:
return
print(f"\n{title}")
for key in keys:
print(f" {key}: {', '.join(sorted(data[key]))}")
dump("instance methods, by class:", direct)
dump("chained calls (constrain the return type):", chained)
return 0
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("--methods", action="store_true",
help="report the instance methods the app calls per class")
ap.add_argument("--only", help="with --methods: substring filter on the class")
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.methods:
if not args.app.is_dir():
print(f"app sources not found: {args.app}", file=sys.stderr)
return 2
return methods(args.app, args.only)
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())