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
This commit is contained in:
2026-08-10 19:23:27 +02:00
parent bfeb089542
commit 091a2ad0cf
6 changed files with 134 additions and 25 deletions

View File

@@ -80,6 +80,91 @@ def scan(app_root: pathlib.Path) -> dict:
}
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")
@@ -130,10 +215,18 @@ def main() -> int:
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)