← Commit history

fusion_sch_batch (standard sch_batch)

Colby Knox ·5dd23fd7ca ·12d ago ·parent 846f879
7 files changed +159−1
CHANGELOG.md+6
@@ -5,6 +5,12 @@ Canonical: https://wiki.adom.inc/adom/fusion-bridge ยท GitHub backup: `adom-inc/  ## 1.13.29 (unreleased) +- `fusion_sch_batch` (the standard's sch_batch, agreed on eda-bridge-standard#29): schematic edits all or nothing. Each op+  is a standard sch_* verb with its arguments, run as that verb runs, so it sees the sheet as the earlier ops left it.+  A failing op undoes the ops before it and the schematic is checked back at its starting revision (`failedOp`,+  nothing changed); if UNDO cannot return, the steps are redone and the reply says `partial` with `ranOps`. Fusion's+  ERC runs before and after (`ercBefore`, `ercAfter`).+ - `fusion_log_tail` (the standard's log_tail, agreed on eda-bridge-standard#29): Fusion's own log since a byte offset,   `entries` [{level, text}] (alert, error, warning, info; `errorsOnly` keeps all but info), at most `maxBytes`   (default 200000), `next` for the next read, `truncated`, and `rotated` when Fusion started a new log. `fusion_alerts` takes the same `sinceOffset` cursor and now
bridge.json+1
@@ -287,6 +287,7 @@     "fusion_save",     "fusion_save_document",     "fusion_save_to_cloud",+    "fusion_sch_batch",     "fusion_sch_bus",     "fusion_sch_bus_entry",     "fusion_sch_connect",
contracts/describe.json+47
@@ -21058,6 +21058,53 @@       "summary": "First save of the ACTIVE document to Autodesk cloud storage with an explicit name and destination, dialog-free. On an electronics design it saves the DESIGN (.fprj with its .fsch and .fbrd, linked; the 3D PCB beside them), whichever of its tabs is in front, and puts the same kind of tab back in front. A design that is already saved is REFUSED (already_saved_use_save_document: a Save As by name forks the board into an unlinked .fbrd) unless asCopy:true. Missing folders on folderPath are created; a name already in the folder is refused unless overwrite:true. A mechanical document is saved as before.",       "timeoutSeconds": 240     },+    {+      "example": {+        "ops": [+          {+            "op": "sch_text",+            "texts": [+              {+                "at": [+                  10,+                  10+                ],+                "text": "NOTE"+              }+            ]+          },+          {+            "at": [+              20,+              20+            ],+            "op": "sch_junction"+          }+        ]+      },+      "input": {+        "dryRun": "bool",+        "expectDocument": "str",+        "expectedRevision": "str",+        "ops": "[{op: schematic verb name, ...its arguments}] (required)",+        "save": "bool: save the design after a batch that ran",+        "sheet": "int (each op's default)"+      },+      "longRunning": false,+      "name": "fusion_sch_batch",+      "output": {+        "ercAfter": "{errors, warnings, messages}",+        "ercBefore": "{errors, warnings, messages}",+        "failedOp": "int (nothing is changed then)",+        "mutated": "bool",+        "partial": "bool: UNDO could not return; ranOps are left in place",+        "results": "[{op, output}] in order",+        "revision": "str"+      },+      "statusVerb": null,+      "summary": "A list of schematic edits applied all or nothing (the standard's sch_batch). Each op is a standard schematic verb with its own arguments (sch_place_part, sch_connect_pins, sch_connect, sch_text, sch_junction, sch_edit_label, sch_bus, sch_move_region, annotate, ...), run as that verb runs (commands, then a read back that must match), so each sees the sheet as the earlier ops left it. When an op fails, the ops before it are undone and the schematic is checked back at its revision before the batch: failedOp names the op and nothing is changed. If Fusion's UNDO cannot bring it back, the steps are redone and the reply says partial with ranOps. Fusion's own ERC runs before and after (ercBefore, ercAfter). dryRun plans each op against the sheet as it is.",+      "timeoutSeconds": 240+    },     {       "example": {         "label": "D[0..7]",
describe.py+4
@@ -466,6 +466,10 @@ _T = [      {"libraryPath": ".lbr on the Fusion machine (required)", "dryRun": "bool"},      {"data": "{removed: int, items: ['component X' | 'symbol X' | 'footprint X']}", "backup": "str"},      30, None, False, {"libraryPath": "C:/libs/parts.lbr", "dryRun": True}),+    ('fusion_sch_batch', "A list of schematic edits applied all or nothing (the standard's sch_batch). Each op is a standard schematic verb with its own arguments (sch_place_part, sch_connect_pins, sch_connect, sch_text, sch_junction, sch_edit_label, sch_bus, sch_move_region, annotate, ...), run as that verb runs (commands, then a read back that must match), so each sees the sheet as the earlier ops left it. When an op fails, the ops before it are undone and the schematic is checked back at its revision before the batch: failedOp names the op and nothing is changed. If Fusion's UNDO cannot bring it back, the steps are redone and the reply says partial with ranOps. Fusion's own ERC runs before and after (ercBefore, ercAfter). dryRun plans each op against the sheet as it is.",+     {"ops": "[{op: schematic verb name, ...its arguments}] (required)", "expectDocument": "str", "sheet": "int (each op's default)", "expectedRevision": "str", "dryRun": "bool", "save": "bool: save the design after a batch that ran"},+     {"results": "[{op, output}] in order", "ercBefore": "{errors, warnings, messages}", "ercAfter": "{errors, warnings, messages}", "failedOp": "int (nothing is changed then)", "partial": "bool: UNDO could not return; ranOps are left in place", "mutated": "bool", "revision": "str"},+     240, None, False, {"ops": [{"op": "sch_text", "texts": [{"text": "NOTE", "at": [10, 10]}]}, {"op": "sch_junction", "at": [20, 20]}]}),     ('fusion_sch_update_from_library', "Compare placed parts with their library (the standard's sch_update_from_library), the plan: each part's component as the schematic holds it (Fusion keeps a copy of every library a design uses) against the same-named deviceset in the .lbr files named: gates added or removed, each symbol's pins added, removed or changed (position, length, direction, turn), its drawing, and the device's attributes (MPN ...). Each part is up_to_date, differs or library_not_found; droppedWiredPins names pins the library's symbol drops that are wired on the sheet. apply is refused with the plan, because Fusion's UPDATE asks about each part in a dialog.",      {"library": ".lbr on the Fusion machine (or libraries: [.lbr]; required)", "symbols": "[deviceset names] (default every part)", "expectDocument": "str", "filePath": "a .sch/.fsch to read instead of the open design", "apply": "bool: refused with the plan (not_supported)"},      {"plan": "true", "differs": "int", "parts": "[{designator, symbol, library, status: up_to_date|differs|library_not_found, diff?: {gates?, symbols?: {gate: {pinsAdded?, pinsRemoved?, pinsChanged?, drawingChanged?}}, attributes?, device?}, droppedWiredPins?}]", "revision": "str"},
rust/bridge/src/sch.rs+20−1
@@ -2,9 +2,10 @@ //! thread through the script transport, as the routing verbs do. use serde_json::{json, Value}; const SOURCE: &str = include_str!("sch_script.py");-pub fn owns(cmd: &str) -> bool { matches!(cmd, "sch_update_from_library" | "sch_items" | "sch_pins" | "sch_trace_point" | "sch_page" | "sch_hierarchy" | "sch_text" | "sch_junction" | "sch_delete_wire" | "sch_connect_pins" | "sch_split_wire" | "sch_bus" | "sch_edit_label" | "sch_move_region" | "sch_fix_connectivity" | "sch_bus_entry" | "sch_connect_to_bus" | "render_schematic" | "sch_reset_fields" | "sch_create" | "sch_sheet_pins" | "sch_update_from_library" | "cross_probe" | "cross_check" | "schematic_data" | "part_parameters" | "doc_params" | "sheet_params" | "sch_edit" | "annotate" | "sch_connect" | "sch_place_part" | "sch_swap_part" | "export_spice" | "simulate" | "sch_zoom" | "jlcpcb_order" | "variants") }+pub fn owns(cmd: &str) -> bool { matches!(cmd, "sch_batch" | "sch_update_from_library" | "sch_items" | "sch_pins" | "sch_trace_point" | "sch_page" | "sch_hierarchy" | "sch_text" | "sch_junction" | "sch_delete_wire" | "sch_connect_pins" | "sch_split_wire" | "sch_bus" | "sch_edit_label" | "sch_move_region" | "sch_fix_connectivity" | "sch_bus_entry" | "sch_connect_to_bus" | "render_schematic" | "sch_reset_fields" | "sch_create" | "sch_sheet_pins" | "sch_update_from_library" | "cross_probe" | "cross_check" | "schematic_data" | "part_parameters" | "doc_params" | "sheet_params" | "sch_edit" | "annotate" | "sch_connect" | "sch_place_part" | "sch_swap_part" | "export_spice" | "simulate" | "sch_zoom" | "jlcpcb_order" | "variants") } pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {     if cmd == "render_schematic" { return render_schematic(args, pass); }+    if cmd == "sch_batch" && args["dryRun"].as_bool() != Some(true) { return sch_batch(args, pass); }     let script = format!("import json\nexec({})\nresult=handle(app,json.loads({}),{})\n",         serde_json::to_string(SOURCE).unwrap(), serde_json::to_string(&args.to_string()).unwrap(), serde_json::to_string(cmd).unwrap());     let r = pass("run_modeling_script", json!({"script": script}));@@ -160,3 +161,21 @@ mod tests {         assert_eq!(0.0, compare_png(&a, &a, &d).unwrap().0);     } }++/// The ERC counts of the open design: Fusion's own ERC (fusion_run_erc), or why it could not run.+fn erc_counts(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+    let r = crate::routing::call("run_erc", &json!({"expectDocument": args["ercDocument"].as_str().or(args["expectDocument"].as_str())}), pass);+    if r["success"].as_bool() != Some(true) { return json!({"ran": false, "error": r["error"]}); }+    json!({"errors": r["errorCount"], "warnings": r["warningCount"], "messages": r["messageCount"]})+}++/// `sch_batch`: the batch with an ERC before and after (the standard's ercBefore, ercAfter).+fn sch_batch(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+    let before = erc_counts(args, pass);+    let mut a = args.clone();+    a["dryRun"] = json!(false);+    let mut r = call("sch_batch_run", &a, pass);+    r["ercBefore"] = before;+    if r["mutated"].as_bool() == Some(true) { r["ercAfter"] = erc_counts(args, pass); } else { r["ercAfter"] = r["ercBefore"].clone(); }+    r+}
rust/bridge/src/sch_script.py+79
@@ -625,6 +625,9 @@ def current_sheet(app):         return None  +STEPS = [0]   # undo steps the edits of this script run have sent (sch_batch undoes them on a failure)++ class Edit:     """One edit: the commands, then a read back that must match, or every command is undone.""" @@ -651,6 +654,8 @@ class Edit:             for c in self.cmds:                 app.executeTextCommand('Electron.run ' + c)                 sent += 1+                if not c.startswith('GROUP'):+                    STEPS[0] += 1         finally:             try:                 app.executeTextCommand('Electron.run GRID LAST;')@@ -2149,6 +2154,78 @@ def sch_update_from_library(root, args):     return {'plan': True, 'differs': differs, 'parts': rows}  +def sch_batch(app, args):+    """The standard's sch_batch: a list of schematic edits, all or nothing. Each op is a standard sch_*+    verb with its own arguments, run as that verb runs (commands, read back, undone on a mismatch), so+    it sees the sheet as the earlier ops left it. When an op fails, the ops before it are undone and the+    schematic is checked back at its revision before the batch; if UNDO cannot bring it back, the steps+    are redone and the batch is reported as partial with the ops that ran."""+    ops = args.get('ops')+    if not isinstance(ops, list) or not ops:+        sfail('bad_args', 'ops is [{op: a schematic verb (sch_place_part, sch_connect_pins, ...), ...its arguments}]')+    names = []+    for i, o in enumerate(ops):+        n = str((o or {}).get('op') or '').replace('fusion_', '', 1) if isinstance(o, dict) else ''+        if n not in EDITS:+            sfail('bad_args', 'op %d (%s) is not a schematic edit this batch can run: one of %s' % (i, n or 'missing', ', '.join(sorted(EDITS))))+        names.append(n)+    shared = {k: args[k] for k in ('expectDocument', 'sheet') if k in args}+    root0, meta0, tabs = sch_snapshot(app, args)+    if args.get('expectedRevision') not in (None, meta0['revision']):+        sfail('stale_schematic', 'expectedRevision is %s but the schematic is now %s: read it again' % (args.get('expectedRevision'), meta0['revision']))+    results = []+    if dry(args):+        for n, o in zip(names, ops):+            a = {**shared, **{k: v for k, v in o.items() if k != 'op'}, 'dryRun': True}+            try:+                results.append({'op': n, 'output': EDITS[n](app, a)})+            except SchError as e:+                results.append({'op': n, 'output': {'success': False, 'errorCode': e.code, 'error': str(e)}})+        return {'success': True, 'mutated': False, 'dryRun': True, **meta0, 'results': results,+                '_hint': 'Each op planned against the sheet as it is now (a dry run writes nothing, so an op that needs an earlier op\'s part or wire plans against the sheet without it).'}+    STEPS[0] = 0+    for i, (n, o) in enumerate(zip(names, ops)):+        before = STEPS[0]+        a = {**shared, **{k: v for k, v in o.items() if k != 'op'}}+        a.pop('expectedRevision', None)+        try:+            r = EDITS[n](app, a)+            err = None if r.get('success') is not False else (r.get('errorCode'), r.get('error'))+        except SchError as e:+            r, err = None, (e.code, str(e))+        if err is None:+            results.append({'op': n, 'output': r})+            continue+        # the failing op undid its own commands unless it says it left them in place+        if 'left in place' not in str(err[1]) and 'unknown state' not in str(err[1]):+            STEPS[0] = before+        steps = STEPS[0]+        undone, state = 0, None+        if steps:+            app.executeTextCommand('UI.SwitchToTab %d' % tabs[0])+            for _ in range(steps):+                app.executeTextCommand('Electron.run UNDO;')+                undone += 1+            if tabs[1] is not None:+                app.executeTextCommand('UI.SwitchToTab %d' % tabs[1])+            state = sch_snapshot(app, args)[1]['revision']+            if state != meta0['revision']:+                app.executeTextCommand('UI.SwitchToTab %d' % tabs[0])+                for _ in range(undone):+                    app.executeTextCommand('Electron.run REDO;')+                if tabs[1] is not None:+                    app.executeTextCommand('UI.SwitchToTab %d' % tabs[1])+                return {'success': False, 'errorCode': 'batch_partial', 'failedOp': i, 'mutated': True, 'partial': True, 'ranOps': list(range(i)), 'results': results,+                        'error': 'op %d (%s) failed: %s. UNDO did not bring the schematic back to its revision before the batch, so ops 0-%d are left in place' % (i, n, err[1], i - 1),+                        'opError': {'errorCode': err[0], 'error': err[1]}, **sch_snapshot(app, args)[1]}+        return {'success': False, 'errorCode': 'batch_failed', 'failedOp': i, 'mutated': False, 'results': results, 'undoneSteps': undone,+                'error': 'op %d (%s) failed: %s. Nothing is changed: the ops before it were undone' % (i, n, err[1]),+                'opError': {'errorCode': err[0], 'error': err[1]}, **meta0}+    meta1 = sch_snapshot(app, args)[1]+    return {'success': True, 'mutated': True, **meta1, 'results': results, 'saved': False,+            '_hint': 'Every op ran and read back. save:true saves the design as a new version.'}++ EDITS = {'doc_params': doc_params, 'sheet_params': sheet_params, 'sch_edit': sch_edit, 'annotate': annotate, 'sch_text': sch_text, 'sch_junction': sch_junction, 'sch_delete_wire': sch_delete_wire, 'sch_connect_pins': sch_connect_pins,          'sch_split_wire': sch_split_wire, 'sch_bus': sch_bus, 'sch_edit_label': sch_edit_label, 'sch_move_region': sch_move_region,          'sch_fix_connectivity': sch_fix_connectivity, 'sch_bus_entry': sch_bus_entry, 'sch_connect_to_bus': sch_connect_to_bus, 'sch_reset_fields': sch_reset_fields, 'sch_create': sch_create, 'sch_connect': sch_connect, 'sch_place_part': sch_place_part, 'sch_swap_part': sch_swap_part, 'variants': variants}@@ -2355,6 +2432,8 @@ def handle(app, args, action):     try:         if action in EDITS:             return EDITS[action](app, args)+        if action in ('sch_batch', 'sch_batch_run'):+            return sch_batch(app, args)         if action == 'export_spice':             return export_spice(app, args)         if action == 'sch_zoom':
tests/run_verb_tests.py+2
@@ -233,6 +233,8 @@ t("fusion_sch_connect_pins#dry", "daily", dict(B, pairs=[{"from": "IC1.1", "to": t("fusion_sch_fix_connectivity", "daily", B, {"ok": True, "fields": ["moves", "unresolved"]}, "dryRun is its default") t("fusion_render_schematic", "daily", dict(B, name="verbtest", width=1200), {"ok": True, "fields": ["png", "width", "dpi"]}, "EXPORT IMAGE of sheet 1, no window") t("fusion_sch_update_from_library#refuse", "negative", B, {"pred": "must_refuse"}, "Fusion's Library Manager asks per part: refused with the reason")+t("fusion_sch_batch#dry", "daily", dict(B, dryRun=True, ops=[{"op": "sch_text", "texts": [{"text": "FL BATCH", "at": [5, 5]}]}]), {"ok": True, "fields": ["results", "revision"]}, "the standard's sch_batch, planned only")+t("fusion_sch_batch#rollback", "daily", dict(B, ops=[{"op": "sch_text", "texts": [{"text": "FL BATCH", "at": [5, 5]}]}, {"op": "sch_text", "texts": [{"text": ""}]}]), {"errorCode": "batch_failed"}, "op 0 writes a text, op 1 is refused: the batch undoes op 0 (the copy ends at its revision before)") for v in ("fusion_sch_text", "fusion_sch_junction", "fusion_sch_delete_wire", "fusion_sch_connect_pins", "fusion_sch_split_wire", "fusion_sch_bus",           "fusion_sch_edit_label", "fusion_sch_move_region", "fusion_sch_bus_entry", "fusion_sch_connect_to_bus", "fusion_sch_reset_fields", "fusion_sch_create"):     t(v, "manual", note="schematic edit: tested live on a scratch import of BQ25792 (never the cloud design)")