py2dag 0.2.3__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {py2dag-0.2.3 → py2dag-0.3.0}/PKG-INFO +1 -1
- py2dag-0.3.0/py2dag/cli.py +376 -0
- py2dag-0.3.0/py2dag/colors.py +28 -0
- {py2dag-0.2.3 → py2dag-0.3.0}/py2dag/export_dagre.py +37 -10
- py2dag-0.3.0/py2dag/export_svg.py +102 -0
- {py2dag-0.2.3 → py2dag-0.3.0}/py2dag/parser.py +72 -13
- {py2dag-0.2.3 → py2dag-0.3.0}/pyproject.toml +1 -1
- py2dag-0.2.3/py2dag/cli.py +0 -34
- py2dag-0.2.3/py2dag/export_svg.py +0 -65
- {py2dag-0.2.3 → py2dag-0.3.0}/LICENSE +0 -0
- {py2dag-0.2.3 → py2dag-0.3.0}/README.md +0 -0
- {py2dag-0.2.3 → py2dag-0.3.0}/py2dag/__init__.py +0 -0
- {py2dag-0.2.3 → py2dag-0.3.0}/py2dag/pseudo.py +0 -0
@@ -0,0 +1,376 @@
|
|
1
|
+
import argparse
|
2
|
+
import json
|
3
|
+
|
4
|
+
from . import parser as dsl_parser
|
5
|
+
from . import pseudo as pseudo_module
|
6
|
+
from . import export_svg
|
7
|
+
import json as _json
|
8
|
+
import re as _re
|
9
|
+
|
10
|
+
|
11
|
+
def _to_nodes_edges(plan: dict) -> dict:
|
12
|
+
"""Convert internal plan (with ops/outputs) to explicit nodes/edges.
|
13
|
+
|
14
|
+
Nodes: one per op, plus one per output sink.
|
15
|
+
Edges: for each dep -> op.id, with labels similar to exporters; and op.id -> output sink.
|
16
|
+
"""
|
17
|
+
ops = list(plan.get("ops", []))
|
18
|
+
outputs = list(plan.get("outputs", []))
|
19
|
+
|
20
|
+
op_by_id = {op["id"]: op for op in ops}
|
21
|
+
|
22
|
+
# Optional remapping of node ids for nicer presentation
|
23
|
+
base_id_map: dict[str, str] = {}
|
24
|
+
loop_ctx_map: dict[str, str] = {} # maps 'loop1' -> 'for_loop_1'
|
25
|
+
for_count = 0
|
26
|
+
for op in ops:
|
27
|
+
new_id = op["id"]
|
28
|
+
if op.get("op") == "ITER.eval" and (op.get("args") or {}).get("kind") == "for":
|
29
|
+
for_count += 1
|
30
|
+
new_id = f"for_loop_{for_count}"
|
31
|
+
loop_ctx_map[f"loop{for_count}"] = new_id
|
32
|
+
base_id_map[op["id"]] = new_id
|
33
|
+
|
34
|
+
def _remap_ctx_suffix(op_id: str) -> str:
|
35
|
+
# Replace '@loopN' with '@for_loop_N' when present
|
36
|
+
if '@' in op_id:
|
37
|
+
base, ctx = op_id.split('@', 1)
|
38
|
+
mapped_ctx = loop_ctx_map.get(ctx)
|
39
|
+
if mapped_ctx:
|
40
|
+
return f"{base}@{mapped_ctx}"
|
41
|
+
return op_id
|
42
|
+
|
43
|
+
# Final id map includes context-aware remapping
|
44
|
+
id_map: dict[str, str] = {}
|
45
|
+
for op in ops:
|
46
|
+
oid = op["id"]
|
47
|
+
# first apply context suffix remap, then base remap if any
|
48
|
+
ctx_remapped = _remap_ctx_suffix(oid)
|
49
|
+
id_map[oid] = base_id_map.get(ctx_remapped, ctx_remapped)
|
50
|
+
|
51
|
+
nodes = []
|
52
|
+
edges = []
|
53
|
+
|
54
|
+
def _base_name(ssa: str) -> str:
|
55
|
+
# Extract base variable name from SSA id like "name_2@ctx" -> "name"
|
56
|
+
m = _re.match(r"^([a-z_][a-z0-9_]*?)_\d+(?:@.*)?$", ssa)
|
57
|
+
return m.group(1) if m else ssa
|
58
|
+
|
59
|
+
def _expr_for(op: dict) -> str:
|
60
|
+
op_name = op.get("op", "")
|
61
|
+
deps = op.get("deps", []) or []
|
62
|
+
args = op.get("args", {}) or {}
|
63
|
+
dep_labels = op.get("dep_labels", []) or []
|
64
|
+
# Helper to stringify deps as base names
|
65
|
+
dep_names = [_base_name(d) for d in deps]
|
66
|
+
# Special-case control/struct ops first
|
67
|
+
if op_name == "TEXT.format":
|
68
|
+
return f"f\"{args.get('template','')}\""
|
69
|
+
if op_name == "CONST.value":
|
70
|
+
return _json.dumps(args.get("value"))
|
71
|
+
if op_name == "GET.item":
|
72
|
+
base = _base_name(deps[0]) if deps else "<unknown>"
|
73
|
+
return f"{base}[{_json.dumps(args.get('key'))}]"
|
74
|
+
if op_name == "PACK.list":
|
75
|
+
return "[" + ", ".join(dep_names) + "]"
|
76
|
+
if op_name == "PACK.tuple":
|
77
|
+
return "(" + ", ".join(dep_names) + ")"
|
78
|
+
if op_name == "PACK.dict":
|
79
|
+
keys = (args.get("keys") or [])
|
80
|
+
items = []
|
81
|
+
for i, k in enumerate(keys):
|
82
|
+
val = dep_names[i] if i < len(dep_names) else "<val>"
|
83
|
+
items.append(f"{k}: {val}")
|
84
|
+
return "{" + ", ".join(items) + "}"
|
85
|
+
if op_name == "COND.eval":
|
86
|
+
kind = args.get("kind")
|
87
|
+
return f"{kind} {args.get('expr')}" if kind else str(args.get('expr'))
|
88
|
+
if op_name == "ITER.eval":
|
89
|
+
tgt = args.get("target")
|
90
|
+
expr = args.get("expr")
|
91
|
+
if tgt:
|
92
|
+
return f"for {tgt} in {expr}"
|
93
|
+
return f"iter {expr}"
|
94
|
+
if op_name == "ITER.item":
|
95
|
+
return str(args.get("target") or "item")
|
96
|
+
if op_name == "PHI":
|
97
|
+
return "phi(" + ", ".join(dep_names) + ")"
|
98
|
+
if op_name.startswith("COMP."):
|
99
|
+
return op_name
|
100
|
+
# Generic tool/attribute call ops
|
101
|
+
if op_name.endswith(".op") or "." in op_name:
|
102
|
+
parts = []
|
103
|
+
used_args = set()
|
104
|
+
# Encode deps in original order, honoring labels for kwargs and vararg markers
|
105
|
+
for i, name in enumerate(dep_names):
|
106
|
+
lbl = dep_labels[i] if i < len(dep_labels) else ""
|
107
|
+
dep_id = deps[i] if i < len(deps) else ""
|
108
|
+
src = op_by_id.get(dep_id)
|
109
|
+
if (
|
110
|
+
lbl
|
111
|
+
and src
|
112
|
+
and src.get("op") == "CONST.value"
|
113
|
+
and isinstance(args, dict)
|
114
|
+
and lbl in args
|
115
|
+
):
|
116
|
+
parts.append(f"{lbl}={_json.dumps(args[lbl])}")
|
117
|
+
used_args.add(lbl)
|
118
|
+
elif lbl == "*":
|
119
|
+
parts.append(f"*{name}")
|
120
|
+
elif lbl == "**":
|
121
|
+
parts.append(f"**{name}")
|
122
|
+
elif lbl: # keyword variable arg
|
123
|
+
parts.append(f"{lbl}={name}")
|
124
|
+
else: # positional
|
125
|
+
parts.append(name)
|
126
|
+
# Append literal kwargs (in insertion order)
|
127
|
+
for k, v in (args.items() if isinstance(args, dict) else []):
|
128
|
+
if k in {"template", "expr", "kind", "keys", "target", "var"} or k in used_args:
|
129
|
+
continue
|
130
|
+
parts.append(f"{k}={_json.dumps(v)}")
|
131
|
+
return f"{op_name}(" + ", ".join(parts) + ")"
|
132
|
+
# Fallback: show op name and dep names
|
133
|
+
return f"{op_name}(" + ", ".join(dep_names) + ")"
|
134
|
+
|
135
|
+
def _type_for(op: dict) -> str:
|
136
|
+
op_name = op.get("op", "")
|
137
|
+
if op_name == "CONST.value":
|
138
|
+
return "const"
|
139
|
+
if op_name == "TEXT.format":
|
140
|
+
return "format"
|
141
|
+
if op_name == "GET.item":
|
142
|
+
return "get_item"
|
143
|
+
if op_name.startswith("PACK."):
|
144
|
+
kind = op_name.split(".", 1)[1]
|
145
|
+
return f"pack:{kind}"
|
146
|
+
if op_name == "COND.eval":
|
147
|
+
kind = (op.get("args") or {}).get("kind")
|
148
|
+
if kind in {"if", "while"}:
|
149
|
+
return kind
|
150
|
+
return "cond"
|
151
|
+
if op_name == "ITER.eval":
|
152
|
+
kind = (op.get("args") or {}).get("kind")
|
153
|
+
return "forloop" if kind == "for" else "iter"
|
154
|
+
if op_name == "ITER.item":
|
155
|
+
return "forloop_item"
|
156
|
+
if op_name == "PHI":
|
157
|
+
return "phi"
|
158
|
+
if op_name == "CTRL.break":
|
159
|
+
return "break"
|
160
|
+
if op_name.startswith("COMP."):
|
161
|
+
comp = op_name.split(".", 1)[1]
|
162
|
+
return f"comp:{comp}"
|
163
|
+
# Default: treat as a call/tool node
|
164
|
+
return "call"
|
165
|
+
|
166
|
+
# Create nodes for ops
|
167
|
+
# For friendly IDs, keep execution-order counters per base
|
168
|
+
pretty_counters: dict[str, int] = {}
|
169
|
+
|
170
|
+
for op in ops:
|
171
|
+
# Compute final node id (may override for pretty IDs)
|
172
|
+
op_id = op["id"]
|
173
|
+
op_name = op.get("op", "")
|
174
|
+
# Choose displayed variable name: prefer loop target for ITER.eval
|
175
|
+
var_name = _base_name(op_id)
|
176
|
+
if op.get("op") == "ITER.eval":
|
177
|
+
tgt = (op.get("args") or {}).get("target")
|
178
|
+
if isinstance(tgt, str) and tgt:
|
179
|
+
var_name = tgt
|
180
|
+
# Start from base remapped id
|
181
|
+
node_id = id_map[op_id]
|
182
|
+
# If this is a synthetic expression-call (base var 'call'), use op name plus context suffix
|
183
|
+
if var_name == "call" and (op_name.endswith(".op") or "." in op_name):
|
184
|
+
suffix = ""
|
185
|
+
if "@" in node_id:
|
186
|
+
suffix = node_id.split("@", 1)[1]
|
187
|
+
base_pretty = f"{op_name}@{suffix}" if suffix else op_name
|
188
|
+
# Increment execution-order counter and append #N, starting at 1
|
189
|
+
n = pretty_counters.get(base_pretty, 0) + 1
|
190
|
+
pretty_counters[base_pretty] = n
|
191
|
+
node_id = f"{base_pretty}#{n}"
|
192
|
+
# Update map so downstream references use the pretty id
|
193
|
+
id_map[op_id] = node_id
|
194
|
+
|
195
|
+
# Merge literal kwargs with variable bindings into args map
|
196
|
+
merged_args = dict(op.get("args", {}) or {})
|
197
|
+
dep_labels = op.get("dep_labels", []) or []
|
198
|
+
# track positional index for unlabeled deps
|
199
|
+
pos_index = 0
|
200
|
+
for idx, dep in enumerate(op.get("deps", []) or []):
|
201
|
+
label = dep_labels[idx] if idx < len(dep_labels) else ""
|
202
|
+
if not label:
|
203
|
+
# positional arg
|
204
|
+
merged_args[str(pos_index)] = dep
|
205
|
+
pos_index += 1
|
206
|
+
elif label not in {"*", "**"}:
|
207
|
+
# keyword variable arg
|
208
|
+
merged_args[label] = dep
|
209
|
+
# ignore '*'/'**' labels in args map; they are reflected by numeric positions or literal kwargs
|
210
|
+
|
211
|
+
# Remap any arg values that reference op ids
|
212
|
+
for k, v in list(merged_args.items()):
|
213
|
+
if isinstance(v, str):
|
214
|
+
vv = id_map.get(v, _remap_ctx_suffix(v))
|
215
|
+
merged_args[k] = vv
|
216
|
+
|
217
|
+
# Ensure deterministic ordering of params in args (sorted by key)
|
218
|
+
if merged_args:
|
219
|
+
merged_args = {k: merged_args[k] for k in sorted(merged_args)}
|
220
|
+
|
221
|
+
# Choose displayed variable name: prefer loop target for ITER.eval
|
222
|
+
var_name = _base_name(op["id"])
|
223
|
+
if op.get("op") == "ITER.eval":
|
224
|
+
tgt = (op.get("args") or {}).get("target")
|
225
|
+
if isinstance(tgt, str) and tgt:
|
226
|
+
var_name = tgt
|
227
|
+
|
228
|
+
label = op_name
|
229
|
+
if op.get("op") == "ITER.eval" and (op.get("args") or {}).get("kind") == "for":
|
230
|
+
label = "forloop"
|
231
|
+
elif op.get("op") == "ITER.item":
|
232
|
+
label = "FORLOOP.item"
|
233
|
+
|
234
|
+
node_obj = {
|
235
|
+
"id": node_id,
|
236
|
+
"type": _type_for(op),
|
237
|
+
"label": label,
|
238
|
+
"var": var_name,
|
239
|
+
"expr": _expr_for(op),
|
240
|
+
"args": merged_args,
|
241
|
+
}
|
242
|
+
# For control/struct item binders, drop args to avoid redundant control deps
|
243
|
+
if op.get("op") == "ITER.item":
|
244
|
+
node_obj.pop("args", None)
|
245
|
+
nodes.append(node_obj)
|
246
|
+
|
247
|
+
# Create edges for deps
|
248
|
+
for op in ops:
|
249
|
+
deps = op.get("deps", []) or []
|
250
|
+
# Special case: PACK.dict can label edges by key
|
251
|
+
keys = []
|
252
|
+
if op.get("op") == "PACK.dict":
|
253
|
+
keys = list((op.get("args") or {}).get("keys", []) or [])
|
254
|
+
dep_labels = op.get("dep_labels", []) or []
|
255
|
+
seen = set()
|
256
|
+
for idx, dep in enumerate(deps):
|
257
|
+
to_id = id_map[op["id"]]
|
258
|
+
from_id = id_map.get(dep, _remap_ctx_suffix(dep))
|
259
|
+
if (from_id, to_id) in seen:
|
260
|
+
continue
|
261
|
+
seen.add((from_id, to_id))
|
262
|
+
label = (dep_labels[idx] if idx < len(dep_labels) else "") or ""
|
263
|
+
src = op_by_id.get(dep)
|
264
|
+
if not label:
|
265
|
+
if keys and idx < len(keys):
|
266
|
+
label = str(keys[idx])
|
267
|
+
elif src is not None:
|
268
|
+
if src.get("op") == "COND.eval":
|
269
|
+
# Label IF branches as then/else based on destination context; keep 'cond' for while
|
270
|
+
kind = (src.get("args") or {}).get("kind")
|
271
|
+
if kind == "if":
|
272
|
+
if "@then" in to_id:
|
273
|
+
label = "then"
|
274
|
+
elif "@else" in to_id:
|
275
|
+
label = "else"
|
276
|
+
else:
|
277
|
+
label = "cond"
|
278
|
+
else:
|
279
|
+
label = "cond"
|
280
|
+
elif src.get("op") == "ITER.eval":
|
281
|
+
label = str((src.get("args") or {}).get("target") or "iter")
|
282
|
+
edges.append({"from": from_id, "to": to_id, "label": label})
|
283
|
+
|
284
|
+
# Add explicit true/false edges for `if ...: continue` patterns inside loops
|
285
|
+
# When an IF inside a loop has a `continue` body and no explicit else branch,
|
286
|
+
# no edges are emitted from the COND node to represent the two control-flow
|
287
|
+
# outcomes. Detect such cases and add synthetic edges so that both the
|
288
|
+
# "true" (continue) and "false" paths are visible in the exported graph.
|
289
|
+
for idx, op in enumerate(ops):
|
290
|
+
if op.get("op") != "COND.eval":
|
291
|
+
continue
|
292
|
+
oid = op["id"]
|
293
|
+
if "@" not in oid:
|
294
|
+
continue
|
295
|
+
_, ctx = oid.split("@", 1)
|
296
|
+
if not ctx.startswith("loop"):
|
297
|
+
continue
|
298
|
+
from_id = id_map[oid]
|
299
|
+
existing = [e for e in edges if e["from"] == from_id]
|
300
|
+
# Skip if we already have explicit branch labels from this cond
|
301
|
+
if any(e.get("label") in {"then", "else", "true", "false"} for e in existing):
|
302
|
+
continue
|
303
|
+
# Find first op after this cond within the same loop context
|
304
|
+
next_op = None
|
305
|
+
for later in ops[idx + 1 :]:
|
306
|
+
if later["id"].endswith(f"@{ctx}"):
|
307
|
+
next_op = later
|
308
|
+
break
|
309
|
+
if next_op is not None:
|
310
|
+
to_id = id_map[next_op["id"]]
|
311
|
+
# If an unlabeled edge already exists, relabel it as the false branch
|
312
|
+
updated = False
|
313
|
+
for e in existing:
|
314
|
+
if e["to"] == to_id:
|
315
|
+
e["label"] = "false"
|
316
|
+
updated = True
|
317
|
+
break
|
318
|
+
if not updated:
|
319
|
+
edges.append({"from": from_id, "to": to_id, "label": "false"})
|
320
|
+
# True branch loops back to the for-loop node itself
|
321
|
+
loop_node = loop_ctx_map.get(ctx)
|
322
|
+
if loop_node:
|
323
|
+
edges.append({"from": from_id, "to": loop_node, "label": "true"})
|
324
|
+
|
325
|
+
# Output nodes and edges
|
326
|
+
for out in outputs:
|
327
|
+
out_id = f"out:{out['as']}"
|
328
|
+
nodes.append({
|
329
|
+
"id": out_id,
|
330
|
+
"type": "output",
|
331
|
+
"label": out["as"],
|
332
|
+
"var": out["as"],
|
333
|
+
"expr": out["as"],
|
334
|
+
"args": {"kind": "output"},
|
335
|
+
})
|
336
|
+
edges.append({"from": out["from"], "to": out_id})
|
337
|
+
|
338
|
+
graph = {
|
339
|
+
"version": plan.get("version", 2),
|
340
|
+
"function": plan.get("function"),
|
341
|
+
"nodes": nodes,
|
342
|
+
"edges": edges,
|
343
|
+
}
|
344
|
+
if plan.get("settings"):
|
345
|
+
graph["settings"] = plan["settings"]
|
346
|
+
return graph
|
347
|
+
|
348
|
+
|
349
|
+
def main() -> None:
|
350
|
+
ap = argparse.ArgumentParser(description="Convert Python function plan to DAG")
|
351
|
+
ap.add_argument("file", help="Python file containing the plan function")
|
352
|
+
ap.add_argument("--func", default=None, help="Function name to parse (auto-detect if omitted)")
|
353
|
+
ap.add_argument("--svg", action="store_true", help="Also export plan.svg via Graphviz (requires dot)")
|
354
|
+
ap.add_argument("--html", action="store_true", help="Also export plan.html via Dagre (no system deps)")
|
355
|
+
args = ap.parse_args()
|
356
|
+
|
357
|
+
plan = dsl_parser.parse_file(args.file, function_name=args.func)
|
358
|
+
# Write explicit nodes/edges form to plan.json for downstream use
|
359
|
+
graph = _to_nodes_edges(plan)
|
360
|
+
with open("plan.json", "w", encoding="utf-8") as f:
|
361
|
+
json.dump(graph, f, indent=2)
|
362
|
+
pseudo_code = pseudo_module.generate(plan)
|
363
|
+
with open("plan.pseudo", "w", encoding="utf-8") as f:
|
364
|
+
f.write(pseudo_code)
|
365
|
+
if args.html:
|
366
|
+
from . import export_dagre
|
367
|
+
export_dagre.export(plan, filename="plan.html")
|
368
|
+
elif args.svg:
|
369
|
+
try:
|
370
|
+
export_svg.export(plan, filename="plan.svg")
|
371
|
+
except RuntimeError as e:
|
372
|
+
print(f"Warning: SVG export skipped: {e}")
|
373
|
+
|
374
|
+
|
375
|
+
if __name__ == "__main__": # pragma: no cover
|
376
|
+
main()
|
@@ -0,0 +1,28 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import hashlib
|
4
|
+
|
5
|
+
CRAYON_COLORS = [
|
6
|
+
"cornflowerblue",
|
7
|
+
"lightcoral",
|
8
|
+
"gold",
|
9
|
+
"mediumseagreen",
|
10
|
+
"orchid",
|
11
|
+
"sandybrown",
|
12
|
+
"plum",
|
13
|
+
"turquoise",
|
14
|
+
"khaki",
|
15
|
+
"salmon",
|
16
|
+
]
|
17
|
+
|
18
|
+
|
19
|
+
def color_for(name: str) -> str:
|
20
|
+
"""Return a pseudo-random but stable color for a given name.
|
21
|
+
|
22
|
+
Picks from CRAYON_COLORS using a SHA256 hash for determinism so the same
|
23
|
+
node type is colored consistently across exports.
|
24
|
+
"""
|
25
|
+
h = hashlib.sha256(name.encode("utf-8")).hexdigest()
|
26
|
+
idx = int(h, 16) % len(CRAYON_COLORS)
|
27
|
+
return CRAYON_COLORS[idx]
|
28
|
+
|
@@ -4,6 +4,8 @@ import json
|
|
4
4
|
from pathlib import Path
|
5
5
|
from typing import Any, Dict
|
6
6
|
|
7
|
+
from .colors import color_for
|
8
|
+
|
7
9
|
|
8
10
|
HTML_TEMPLATE = """<!doctype html>
|
9
11
|
<html lang="en">
|
@@ -15,7 +17,7 @@ HTML_TEMPLATE = """<!doctype html>
|
|
15
17
|
body { font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Arial, sans-serif; margin: 0; padding: 0; }
|
16
18
|
header { padding: 10px 16px; background: #111; color: #eee; font-size: 14px; }
|
17
19
|
#container { padding: 12px; }
|
18
|
-
svg { width: 100%; height: 80vh; border
|
20
|
+
svg { width: 100%; height: 80vh; border: 1px solid #ddd; margin: 10px; padding: 10px; }
|
19
21
|
.node rect { stroke: #666; fill: #fff; rx: 4; ry: 4; }
|
20
22
|
.node.note rect { fill: #fff8dc; }
|
21
23
|
.edgePath path { stroke: #333; fill: none; stroke-width: 1.2px; }
|
@@ -30,6 +32,7 @@ HTML_TEMPLATE = """<!doctype html>
|
|
30
32
|
</div>
|
31
33
|
<script>
|
32
34
|
const plan = __PLAN_JSON__;
|
35
|
+
const COLOR_MAP = __COLOR_MAP__;
|
33
36
|
|
34
37
|
function showMessage(msg) {
|
35
38
|
const el = document.getElementById('container');
|
@@ -44,7 +47,7 @@ HTML_TEMPLATE = """<!doctype html>
|
|
44
47
|
} else {
|
45
48
|
try {
|
46
49
|
const g = new dagreD3.graphlib.Graph({ multigraph: true })
|
47
|
-
.setGraph({ rankdir: '
|
50
|
+
.setGraph({ rankdir: 'TB', nodesep: 30, ranksep: 40 });
|
48
51
|
// Ensure edges have an object for labels/attrs to avoid TypeErrors
|
49
52
|
g.setDefaultEdgeLabel(() => ({}));
|
50
53
|
|
@@ -63,13 +66,15 @@ HTML_TEMPLATE = """<!doctype html>
|
|
63
66
|
label = 'PHI' + (op.args && op.args.var ? ` (${op.args.var})` : '');
|
64
67
|
klass = 'note';
|
65
68
|
}
|
66
|
-
|
69
|
+
const color = COLOR_MAP[op.op] || '#fff';
|
70
|
+
g.setNode(op.id, { label, class: klass, padding: 8, style: 'fill: ' + color });
|
67
71
|
});
|
68
72
|
|
69
73
|
// Add output nodes and edges from source to output
|
70
74
|
(plan.outputs || []).forEach(out => {
|
71
75
|
const outId = `out:${out.as}`;
|
72
|
-
|
76
|
+
const ocolor = COLOR_MAP['output'] || '#fff';
|
77
|
+
g.setNode(outId, { label: out.as, class: 'note', padding: 8, style: 'fill: ' + ocolor });
|
73
78
|
g.setEdge(out.from, outId);
|
74
79
|
});
|
75
80
|
|
@@ -79,13 +84,32 @@ HTML_TEMPLATE = """<!doctype html>
|
|
79
84
|
|
80
85
|
// Add dependency edges between ops with labels
|
81
86
|
(plan.ops || []).forEach(op => {
|
82
|
-
|
87
|
+
const depLabels = op.dep_labels || [];
|
88
|
+
const seen = new Set();
|
89
|
+
(op.deps || []).forEach((dep, idx) => {
|
90
|
+
const pair = dep + '->' + op.id;
|
91
|
+
if (seen.has(pair)) return; seen.add(pair);
|
83
92
|
const src = opById[dep];
|
84
|
-
let edgeLabel =
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
93
|
+
let edgeLabel = (depLabels[idx] || '').toString();
|
94
|
+
// Special-cases override when no explicit label was provided
|
95
|
+
if (!edgeLabel) {
|
96
|
+
if (op.op === 'PACK.dict' && op.args && Array.isArray(op.args.keys)) {
|
97
|
+
edgeLabel = (op.args.keys[idx] || '').toString();
|
98
|
+
} else if (src && src.op === 'COND.eval') {
|
99
|
+
// For IF, label branches as then/else based on dest id; for WHILE keep 'cond'
|
100
|
+
const kind = src.args && src.args.kind;
|
101
|
+
if (kind === 'if') {
|
102
|
+
if ((op.id || '').includes('@then')) edgeLabel = 'then';
|
103
|
+
else if ((op.id || '').includes('@else')) edgeLabel = 'else';
|
104
|
+
else edgeLabel = 'cond';
|
105
|
+
} else {
|
106
|
+
edgeLabel = 'cond';
|
107
|
+
}
|
108
|
+
} else if (src && src.op === 'ITER.eval') {
|
109
|
+
edgeLabel = (src.args && src.args.target) ? src.args.target : 'iter';
|
110
|
+
} else {
|
111
|
+
edgeLabel = dep; // fallback: SSA id
|
112
|
+
}
|
89
113
|
}
|
90
114
|
g.setEdge(dep, op.id, { label: edgeLabel });
|
91
115
|
});
|
@@ -117,7 +141,10 @@ def export(plan: Dict[str, Any], filename: str = "plan.html") -> str:
|
|
117
141
|
|
118
142
|
Returns the written filename.
|
119
143
|
"""
|
144
|
+
color_map = {op["op"]: color_for(op["op"]) for op in plan.get("ops", [])}
|
145
|
+
color_map["output"] = color_for("output")
|
120
146
|
html = HTML_TEMPLATE.replace("__PLAN_JSON__", json.dumps(plan))
|
147
|
+
html = html.replace("__COLOR_MAP__", json.dumps(color_map))
|
121
148
|
path = Path(filename)
|
122
149
|
path.write_text(html, encoding="utf-8")
|
123
150
|
return str(path)
|
@@ -0,0 +1,102 @@
|
|
1
|
+
from typing import Dict, Any
|
2
|
+
|
3
|
+
from .colors import color_for
|
4
|
+
|
5
|
+
try:
|
6
|
+
from graphviz import Digraph
|
7
|
+
except Exception: # pragma: no cover
|
8
|
+
Digraph = None # type: ignore
|
9
|
+
|
10
|
+
try: # optional: only available when graphviz is installed
|
11
|
+
from graphviz.backend.execute import ExecutableNotFound # type: ignore
|
12
|
+
except Exception: # pragma: no cover
|
13
|
+
ExecutableNotFound = None # type: ignore
|
14
|
+
|
15
|
+
|
16
|
+
def export(plan: Dict[str, Any], filename: str = "plan.svg") -> str:
|
17
|
+
"""Export the plan as an SVG using graphviz.
|
18
|
+
|
19
|
+
Writes a true SVG file at `filename`. If Graphviz system binaries are
|
20
|
+
missing, raises RuntimeError with a helpful message. This avoids leaving a
|
21
|
+
stray DOT file named `plan.svg` when rendering fails.
|
22
|
+
"""
|
23
|
+
if Digraph is None:
|
24
|
+
raise RuntimeError("Python package 'graphviz' is required for SVG export")
|
25
|
+
|
26
|
+
graph = Digraph(format="svg")
|
27
|
+
# Top-down layout with a bounding box around the graph
|
28
|
+
graph.attr(rankdir="TB")
|
29
|
+
graph.attr("graph", margin="0.2", pad="0.3", color="#bbb")
|
30
|
+
|
31
|
+
# Index ops for edge label decisions
|
32
|
+
ops = list(plan.get("ops", []))
|
33
|
+
op_by_id = {op["id"]: op for op in ops}
|
34
|
+
|
35
|
+
# Nodes
|
36
|
+
for op in ops:
|
37
|
+
graph.node(
|
38
|
+
op["id"],
|
39
|
+
label=op["op"],
|
40
|
+
style="filled",
|
41
|
+
fillcolor=color_for(op["op"]),
|
42
|
+
)
|
43
|
+
|
44
|
+
# Dependency edges with labels showing data/control
|
45
|
+
for op in ops:
|
46
|
+
dep_labels = op.get("dep_labels", []) or []
|
47
|
+
seen = set()
|
48
|
+
for idx, dep in enumerate(op.get("deps", []) or []):
|
49
|
+
pair = (dep, op["id"])
|
50
|
+
if pair in seen:
|
51
|
+
continue
|
52
|
+
seen.add(pair)
|
53
|
+
src = op_by_id.get(dep)
|
54
|
+
label = (dep_labels[idx] if idx < len(dep_labels) else "") or ""
|
55
|
+
if not label:
|
56
|
+
if op.get("op") == "PACK.dict":
|
57
|
+
keys = (op.get("args", {}) or {}).get("keys", []) or []
|
58
|
+
if idx < len(keys):
|
59
|
+
label = str(keys[idx])
|
60
|
+
elif src is not None:
|
61
|
+
if src.get("op") == "COND.eval":
|
62
|
+
const_args = src.get("args", {}) or {}
|
63
|
+
if const_args.get("kind") == "if":
|
64
|
+
dest_id = op.get("id", "")
|
65
|
+
if "@then" in dest_id:
|
66
|
+
label = "then"
|
67
|
+
elif "@else" in dest_id:
|
68
|
+
label = "else"
|
69
|
+
else:
|
70
|
+
label = "cond"
|
71
|
+
else:
|
72
|
+
label = "cond"
|
73
|
+
elif src.get("op") == "ITER.eval":
|
74
|
+
args = src.get("args", {}) or {}
|
75
|
+
label = str(args.get("target") or "iter")
|
76
|
+
graph.edge(dep, op["id"], label=label or dep)
|
77
|
+
for out in plan.get("outputs", []):
|
78
|
+
out_id = f"out:{out['as']}"
|
79
|
+
graph.node(
|
80
|
+
out_id,
|
81
|
+
label=out['as'],
|
82
|
+
shape="note",
|
83
|
+
style="filled",
|
84
|
+
fillcolor=color_for("output"),
|
85
|
+
)
|
86
|
+
graph.edge(out["from"], out_id)
|
87
|
+
|
88
|
+
try:
|
89
|
+
# Use pipe() to obtain SVG bytes directly so we only write the
|
90
|
+
# destination file on successful rendering.
|
91
|
+
svg_bytes = graph.pipe(format="svg")
|
92
|
+
except Exception as e: # pragma: no cover - depends on local system
|
93
|
+
if ExecutableNotFound is not None and isinstance(e, ExecutableNotFound):
|
94
|
+
raise RuntimeError(
|
95
|
+
"Graphviz 'dot' executable not found. Install Graphviz (e.g., 'brew install graphviz' on macOS) "
|
96
|
+
"or run without --svg."
|
97
|
+
) from e
|
98
|
+
raise
|
99
|
+
|
100
|
+
with open(filename, "wb") as f:
|
101
|
+
f.write(svg_bytes)
|
102
|
+
return filename
|
@@ -118,9 +118,10 @@ def parse(source: str, function_name: Optional[str] = None) -> Dict[str, Any]:
|
|
118
118
|
except Exception:
|
119
119
|
return node.__class__.__name__
|
120
120
|
|
121
|
-
def _emit_assign_from_call(var_name: str, call: ast.Call) -> str:
|
121
|
+
def _emit_assign_from_call(var_name: str, call: ast.Call, awaited: bool = False) -> str:
|
122
122
|
op_name = _get_call_name(call.func)
|
123
123
|
deps: List[str] = []
|
124
|
+
dep_labels: List[str] = []
|
124
125
|
|
125
126
|
def _expand_star_name(ssa_var: str) -> List[str]:
|
126
127
|
# Expand if previous op was a PACK.*
|
@@ -133,21 +134,26 @@ def parse(source: str, function_name: Optional[str] = None) -> Dict[str, Any]:
|
|
133
134
|
if isinstance(arg, ast.Starred):
|
134
135
|
star_val = arg.value
|
135
136
|
if isinstance(star_val, ast.Name):
|
136
|
-
|
137
|
+
expanded = _expand_star_name(_ssa_get(star_val.id))
|
138
|
+
deps.extend(expanded)
|
139
|
+
dep_labels.extend(["*"] * len(expanded))
|
137
140
|
elif isinstance(star_val, (ast.List, ast.Tuple)):
|
138
141
|
for elt in star_val.elts:
|
139
142
|
if not isinstance(elt, ast.Name):
|
140
143
|
raise DSLParseError("Starred list/tuple elements must be names")
|
141
144
|
deps.append(_ssa_get(elt.id))
|
145
|
+
dep_labels.append("*")
|
142
146
|
else:
|
143
147
|
raise DSLParseError("*args must be a name or list/tuple of names")
|
144
148
|
elif isinstance(arg, ast.Name):
|
145
149
|
deps.append(_ssa_get(arg.id))
|
150
|
+
dep_labels.append("")
|
146
151
|
elif isinstance(arg, (ast.List, ast.Tuple)):
|
147
152
|
for elt in arg.elts:
|
148
153
|
if not isinstance(elt, ast.Name):
|
149
154
|
raise DSLParseError("List/Tuple positional args must be variable names")
|
150
155
|
deps.append(_ssa_get(elt.id))
|
156
|
+
dep_labels.append("")
|
151
157
|
else:
|
152
158
|
raise DSLParseError("Positional args must be variable names or lists/tuples of names")
|
153
159
|
|
@@ -161,21 +167,38 @@ def parse(source: str, function_name: Optional[str] = None) -> Dict[str, Any]:
|
|
161
167
|
kwargs[str(k)] = val
|
162
168
|
elif isinstance(v, ast.Name):
|
163
169
|
deps.append(_ssa_get(v.id))
|
170
|
+
dep_labels.append("**")
|
164
171
|
else:
|
165
172
|
raise DSLParseError("**kwargs must be a dict literal or a variable name")
|
166
173
|
else:
|
167
174
|
if isinstance(kw.value, ast.Name):
|
168
175
|
deps.append(_ssa_get(kw.value.id))
|
176
|
+
dep_labels.append(kw.arg or "")
|
169
177
|
else:
|
170
|
-
|
178
|
+
lit = _literal(kw.value)
|
179
|
+
kwargs[kw.arg] = lit
|
180
|
+
const_id = _ssa_new(f"{var_name}_{kw.arg}")
|
181
|
+
ops.append(
|
182
|
+
{
|
183
|
+
"id": const_id,
|
184
|
+
"op": "CONST.value",
|
185
|
+
"deps": [],
|
186
|
+
"args": {"value": lit},
|
187
|
+
}
|
188
|
+
)
|
189
|
+
deps.append(const_id)
|
190
|
+
dep_labels.append(kw.arg or "")
|
171
191
|
|
172
192
|
ssa = _ssa_new(var_name)
|
173
|
-
|
193
|
+
op: Dict[str, Any] = {"id": ssa, "op": op_name, "deps": deps, "args": kwargs, "dep_labels": dep_labels}
|
194
|
+
if awaited:
|
195
|
+
op["await"] = True
|
196
|
+
ops.append(op)
|
174
197
|
return ssa
|
175
198
|
|
176
|
-
def _emit_expr_call(call: ast.Call) -> str:
|
199
|
+
def _emit_expr_call(call: ast.Call, awaited: bool = False) -> str:
|
177
200
|
"""Emit a node for a bare expression call (no assignment)."""
|
178
|
-
return _emit_assign_from_call("call", call)
|
201
|
+
return _emit_assign_from_call("call", call, awaited)
|
179
202
|
|
180
203
|
def _emit_assign_from_fstring(var_name: str, fstr: ast.JoinedStr) -> str:
|
181
204
|
deps: List[str] = []
|
@@ -241,7 +264,7 @@ def parse(source: str, function_name: Optional[str] = None) -> Dict[str, Any]:
|
|
241
264
|
inner = v_node.value
|
242
265
|
if not isinstance(inner, ast.Call):
|
243
266
|
raise DSLParseError("await must wrap a call in dict value")
|
244
|
-
tmp_id = _emit_assign_from_call(f"{var_name}_field", inner)
|
267
|
+
tmp_id = _emit_assign_from_call(f"{var_name}_field", inner, awaited=True)
|
245
268
|
deps.append(tmp_id)
|
246
269
|
elif isinstance(v_node, ast.Call):
|
247
270
|
tmp_id = _emit_assign_from_call(f"{var_name}_field", v_node)
|
@@ -288,6 +311,26 @@ def parse(source: str, function_name: Optional[str] = None) -> Dict[str, Any]:
|
|
288
311
|
})
|
289
312
|
return ssa
|
290
313
|
|
314
|
+
def _emit_assign_from_subscript(var_name: str, node: ast.Subscript) -> str:
|
315
|
+
# Support name[key] where key is a JSON-serialisable literal
|
316
|
+
base = node.value
|
317
|
+
if not isinstance(base, ast.Name):
|
318
|
+
raise DSLParseError("Subscript base must be a variable name")
|
319
|
+
# Extract slice expression across Python versions
|
320
|
+
sl = getattr(node, 'slice', None)
|
321
|
+
# In Python >=3.9, slice is the actual node; before it may be ast.Index
|
322
|
+
if hasattr(ast, 'Index') and isinstance(sl, getattr(ast, 'Index')): # type: ignore[attr-defined]
|
323
|
+
sl = sl.value # type: ignore[assignment]
|
324
|
+
key = _literal(sl) # may raise if not literal
|
325
|
+
ssa = _ssa_new(var_name)
|
326
|
+
ops.append({
|
327
|
+
"id": ssa,
|
328
|
+
"op": "GET.item",
|
329
|
+
"deps": [_ssa_get(base.id)],
|
330
|
+
"args": {"key": key},
|
331
|
+
})
|
332
|
+
return ssa
|
333
|
+
|
291
334
|
def _emit_cond(node: ast.AST, kind: str = "if") -> str:
|
292
335
|
expr = _stringify(node)
|
293
336
|
deps = [_ssa_get(n) for n in _collect_value_deps(node)]
|
@@ -312,22 +355,28 @@ def parse(source: str, function_name: Optional[str] = None) -> Dict[str, Any]:
|
|
312
355
|
raise DSLParseError("Assignment targets must be simple names")
|
313
356
|
var_name = stmt.targets[0].id
|
314
357
|
value = stmt.value
|
358
|
+
awaited = False
|
315
359
|
if isinstance(value, ast.Await):
|
316
360
|
value = value.value
|
361
|
+
awaited = True
|
317
362
|
if isinstance(value, ast.Call):
|
318
|
-
return _emit_assign_from_call(var_name, value)
|
363
|
+
return _emit_assign_from_call(var_name, value, awaited)
|
319
364
|
elif isinstance(value, ast.JoinedStr):
|
320
365
|
return _emit_assign_from_fstring(var_name, value)
|
321
366
|
elif isinstance(value, (ast.Constant, ast.List, ast.Tuple, ast.Dict)):
|
322
367
|
return _emit_assign_from_literal_or_pack(var_name, value)
|
323
368
|
elif isinstance(value, (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)):
|
324
369
|
return _emit_assign_from_comp(var_name, value)
|
370
|
+
elif isinstance(value, ast.Subscript):
|
371
|
+
return _emit_assign_from_subscript(var_name, value)
|
325
372
|
else:
|
326
373
|
raise DSLParseError("Right hand side must be a call or f-string")
|
327
374
|
elif isinstance(stmt, ast.Expr):
|
328
375
|
call = stmt.value
|
376
|
+
awaited = False
|
329
377
|
if isinstance(call, ast.Await):
|
330
378
|
call = call.value
|
379
|
+
awaited = True
|
331
380
|
if not isinstance(call, ast.Call):
|
332
381
|
raise DSLParseError("Only call expressions allowed at top level")
|
333
382
|
name = _get_call_name(call.func)
|
@@ -354,7 +403,7 @@ def parse(source: str, function_name: Optional[str] = None) -> Dict[str, Any]:
|
|
354
403
|
outputs.append({"from": ssa_from, "as": filename})
|
355
404
|
else:
|
356
405
|
# General expression call: represent as an op node too
|
357
|
-
_emit_expr_call(call)
|
406
|
+
_emit_expr_call(call, awaited)
|
358
407
|
return None
|
359
408
|
elif isinstance(stmt, ast.Return):
|
360
409
|
if isinstance(stmt.value, ast.Name):
|
@@ -483,9 +532,12 @@ def parse(source: str, function_name: Optional[str] = None) -> Dict[str, Any]:
|
|
483
532
|
versions_body, latest_body = versions, latest
|
484
533
|
versions, latest = saved_versions, saved_latest
|
485
534
|
context_suffix = saved_ctx
|
486
|
-
# Add iter dep to first op in body
|
535
|
+
# Add iter dep to first op in body if not already present
|
487
536
|
if len(ops) > body_ops_start:
|
488
|
-
|
537
|
+
first = ops[body_ops_start]
|
538
|
+
deps0 = first.get("deps", []) or []
|
539
|
+
if iter_id not in deps0:
|
540
|
+
first["deps"] = [*deps0, iter_id]
|
489
541
|
# Emit a summary foreach comp node depending on iterable value deps
|
490
542
|
iter_name_deps = _collect_value_deps(stmt.iter)
|
491
543
|
foreach_deps = [_ssa_get(n) for n in iter_name_deps]
|
@@ -526,7 +578,10 @@ def parse(source: str, function_name: Optional[str] = None) -> Dict[str, Any]:
|
|
526
578
|
versions, latest = saved_versions, saved_latest
|
527
579
|
context_suffix = saved_ctx
|
528
580
|
if len(ops) > body_ops_start:
|
529
|
-
|
581
|
+
first = ops[body_ops_start]
|
582
|
+
deps0 = first.get("deps", []) or []
|
583
|
+
if cond_id not in deps0:
|
584
|
+
first["deps"] = [*deps0, cond_id]
|
530
585
|
changed = {k for k in latest_body if pre_latest.get(k) != latest_body.get(k)}
|
531
586
|
carried = [k for k in changed if k in pre_latest]
|
532
587
|
for var in sorted(carried):
|
@@ -538,7 +593,11 @@ def parse(source: str, function_name: Optional[str] = None) -> Dict[str, Any]:
|
|
538
593
|
"args": {"var": var},
|
539
594
|
})
|
540
595
|
return None
|
541
|
-
elif isinstance(stmt,
|
596
|
+
elif isinstance(stmt, ast.Break):
|
597
|
+
ssa = _ssa_new("break")
|
598
|
+
ops.append({"id": ssa, "op": "CTRL.break", "deps": [], "args": {}})
|
599
|
+
return None
|
600
|
+
elif isinstance(stmt, (ast.Pass, ast.Continue)):
|
542
601
|
return None
|
543
602
|
else:
|
544
603
|
raise DSLParseError("Only assignments, control flow, settings/output calls, and return are allowed in function body")
|
py2dag-0.2.3/py2dag/cli.py
DELETED
@@ -1,34 +0,0 @@
|
|
1
|
-
import argparse
|
2
|
-
import json
|
3
|
-
|
4
|
-
from . import parser as dsl_parser
|
5
|
-
from . import pseudo as pseudo_module
|
6
|
-
from . import export_svg
|
7
|
-
|
8
|
-
|
9
|
-
def main() -> None:
|
10
|
-
ap = argparse.ArgumentParser(description="Convert Python function plan to DAG")
|
11
|
-
ap.add_argument("file", help="Python file containing the plan function")
|
12
|
-
ap.add_argument("--func", default=None, help="Function name to parse (auto-detect if omitted)")
|
13
|
-
ap.add_argument("--svg", action="store_true", help="Also export plan.svg via Graphviz (requires dot)")
|
14
|
-
ap.add_argument("--html", action="store_true", help="Also export plan.html via Dagre (no system deps)")
|
15
|
-
args = ap.parse_args()
|
16
|
-
|
17
|
-
plan = dsl_parser.parse_file(args.file, function_name=args.func)
|
18
|
-
with open("plan.json", "w", encoding="utf-8") as f:
|
19
|
-
json.dump(plan, f, indent=2)
|
20
|
-
pseudo_code = pseudo_module.generate(plan)
|
21
|
-
with open("plan.pseudo", "w", encoding="utf-8") as f:
|
22
|
-
f.write(pseudo_code)
|
23
|
-
if args.html:
|
24
|
-
from . import export_dagre
|
25
|
-
export_dagre.export(plan, filename="plan.html")
|
26
|
-
elif args.svg:
|
27
|
-
try:
|
28
|
-
export_svg.export(plan, filename="plan.svg")
|
29
|
-
except RuntimeError as e:
|
30
|
-
print(f"Warning: SVG export skipped: {e}")
|
31
|
-
|
32
|
-
|
33
|
-
if __name__ == "__main__": # pragma: no cover
|
34
|
-
main()
|
@@ -1,65 +0,0 @@
|
|
1
|
-
from typing import Dict, Any
|
2
|
-
|
3
|
-
try:
|
4
|
-
from graphviz import Digraph
|
5
|
-
except Exception: # pragma: no cover
|
6
|
-
Digraph = None # type: ignore
|
7
|
-
|
8
|
-
try: # optional: only available when graphviz is installed
|
9
|
-
from graphviz.backend.execute import ExecutableNotFound # type: ignore
|
10
|
-
except Exception: # pragma: no cover
|
11
|
-
ExecutableNotFound = None # type: ignore
|
12
|
-
|
13
|
-
|
14
|
-
def export(plan: Dict[str, Any], filename: str = "plan.svg") -> str:
|
15
|
-
"""Export the plan as an SVG using graphviz.
|
16
|
-
|
17
|
-
Writes a true SVG file at `filename`. If Graphviz system binaries are
|
18
|
-
missing, raises RuntimeError with a helpful message. This avoids leaving a
|
19
|
-
stray DOT file named `plan.svg` when rendering fails.
|
20
|
-
"""
|
21
|
-
if Digraph is None:
|
22
|
-
raise RuntimeError("Python package 'graphviz' is required for SVG export")
|
23
|
-
|
24
|
-
graph = Digraph(format="svg")
|
25
|
-
|
26
|
-
# Index ops for edge label decisions
|
27
|
-
ops = list(plan.get("ops", []))
|
28
|
-
op_by_id = {op["id"]: op for op in ops}
|
29
|
-
|
30
|
-
# Nodes
|
31
|
-
for op in ops:
|
32
|
-
graph.node(op["id"], label=op["op"])
|
33
|
-
|
34
|
-
# Dependency edges with labels showing data/control
|
35
|
-
for op in ops:
|
36
|
-
for dep in op.get("deps", []):
|
37
|
-
src = op_by_id.get(dep)
|
38
|
-
label = dep # default to SSA id
|
39
|
-
if src is not None:
|
40
|
-
if src.get("op") == "COND.eval":
|
41
|
-
label = "cond"
|
42
|
-
elif src.get("op") == "ITER.eval":
|
43
|
-
args = src.get("args", {}) or {}
|
44
|
-
label = str(args.get("target") or "iter")
|
45
|
-
graph.edge(dep, op["id"], label=label)
|
46
|
-
for out in plan.get("outputs", []):
|
47
|
-
out_id = f"out:{out['as']}"
|
48
|
-
graph.node(out_id, label=out['as'], shape="note")
|
49
|
-
graph.edge(out["from"], out_id)
|
50
|
-
|
51
|
-
try:
|
52
|
-
# Use pipe() to obtain SVG bytes directly so we only write the
|
53
|
-
# destination file on successful rendering.
|
54
|
-
svg_bytes = graph.pipe(format="svg")
|
55
|
-
except Exception as e: # pragma: no cover - depends on local system
|
56
|
-
if ExecutableNotFound is not None and isinstance(e, ExecutableNotFound):
|
57
|
-
raise RuntimeError(
|
58
|
-
"Graphviz 'dot' executable not found. Install Graphviz (e.g., 'brew install graphviz' on macOS) "
|
59
|
-
"or run without --svg."
|
60
|
-
) from e
|
61
|
-
raise
|
62
|
-
|
63
|
-
with open(filename, "wb") as f:
|
64
|
-
f.write(svg_bytes)
|
65
|
-
return filename
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|