py2dag 0.2.4__py3-none-any.whl → 0.3.1__py3-none-any.whl
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/cli.py +343 -1
- py2dag/colors.py +28 -0
- py2dag/export_dagre.py +37 -10
- py2dag/export_svg.py +48 -11
- py2dag/parser.py +54 -14
- {py2dag-0.2.4.dist-info → py2dag-0.3.1.dist-info}/METADATA +1 -1
- py2dag-0.3.1.dist-info/RECORD +12 -0
- py2dag-0.2.4.dist-info/RECORD +0 -11
- {py2dag-0.2.4.dist-info → py2dag-0.3.1.dist-info}/LICENSE +0 -0
- {py2dag-0.2.4.dist-info → py2dag-0.3.1.dist-info}/WHEEL +0 -0
- {py2dag-0.2.4.dist-info → py2dag-0.3.1.dist-info}/entry_points.txt +0 -0
py2dag/cli.py
CHANGED
@@ -4,6 +4,346 @@ import json
|
|
4
4
|
from . import parser as dsl_parser
|
5
5
|
from . import pseudo as pseudo_module
|
6
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
|
7
347
|
|
8
348
|
|
9
349
|
def main() -> None:
|
@@ -15,8 +355,10 @@ def main() -> None:
|
|
15
355
|
args = ap.parse_args()
|
16
356
|
|
17
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)
|
18
360
|
with open("plan.json", "w", encoding="utf-8") as f:
|
19
|
-
json.dump(
|
361
|
+
json.dump(graph, f, indent=2)
|
20
362
|
pseudo_code = pseudo_module.generate(plan)
|
21
363
|
with open("plan.pseudo", "w", encoding="utf-8") as f:
|
22
364
|
f.write(pseudo_code)
|
py2dag/colors.py
ADDED
@@ -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
|
+
|
py2dag/export_dagre.py
CHANGED
@@ -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)
|
py2dag/export_svg.py
CHANGED
@@ -1,5 +1,7 @@
|
|
1
1
|
from typing import Dict, Any
|
2
2
|
|
3
|
+
from .colors import color_for
|
4
|
+
|
3
5
|
try:
|
4
6
|
from graphviz import Digraph
|
5
7
|
except Exception: # pragma: no cover
|
@@ -22,6 +24,9 @@ def export(plan: Dict[str, Any], filename: str = "plan.svg") -> str:
|
|
22
24
|
raise RuntimeError("Python package 'graphviz' is required for SVG export")
|
23
25
|
|
24
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")
|
25
30
|
|
26
31
|
# Index ops for edge label decisions
|
27
32
|
ops = list(plan.get("ops", []))
|
@@ -29,23 +34,55 @@ def export(plan: Dict[str, Any], filename: str = "plan.svg") -> str:
|
|
29
34
|
|
30
35
|
# Nodes
|
31
36
|
for op in ops:
|
32
|
-
graph.node(
|
37
|
+
graph.node(
|
38
|
+
op["id"],
|
39
|
+
label=op["op"],
|
40
|
+
style="filled",
|
41
|
+
fillcolor=color_for(op["op"]),
|
42
|
+
)
|
33
43
|
|
34
44
|
# Dependency edges with labels showing data/control
|
35
45
|
for op in ops:
|
36
|
-
|
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)
|
37
53
|
src = op_by_id.get(dep)
|
38
|
-
label =
|
39
|
-
if
|
40
|
-
if
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
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)
|
46
77
|
for out in plan.get("outputs", []):
|
47
78
|
out_id = f"out:{out['as']}"
|
48
|
-
graph.node(
|
79
|
+
graph.node(
|
80
|
+
out_id,
|
81
|
+
label=out['as'],
|
82
|
+
shape="note",
|
83
|
+
style="filled",
|
84
|
+
fillcolor=color_for("output"),
|
85
|
+
)
|
49
86
|
graph.edge(out["from"], out_id)
|
50
87
|
|
51
88
|
try:
|
py2dag/parser.py
CHANGED
@@ -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)
|
@@ -332,10 +355,12 @@ def parse(source: str, function_name: Optional[str] = None) -> Dict[str, Any]:
|
|
332
355
|
raise DSLParseError("Assignment targets must be simple names")
|
333
356
|
var_name = stmt.targets[0].id
|
334
357
|
value = stmt.value
|
358
|
+
awaited = False
|
335
359
|
if isinstance(value, ast.Await):
|
336
360
|
value = value.value
|
361
|
+
awaited = True
|
337
362
|
if isinstance(value, ast.Call):
|
338
|
-
return _emit_assign_from_call(var_name, value)
|
363
|
+
return _emit_assign_from_call(var_name, value, awaited)
|
339
364
|
elif isinstance(value, ast.JoinedStr):
|
340
365
|
return _emit_assign_from_fstring(var_name, value)
|
341
366
|
elif isinstance(value, (ast.Constant, ast.List, ast.Tuple, ast.Dict)):
|
@@ -348,8 +373,10 @@ def parse(source: str, function_name: Optional[str] = None) -> Dict[str, Any]:
|
|
348
373
|
raise DSLParseError("Right hand side must be a call or f-string")
|
349
374
|
elif isinstance(stmt, ast.Expr):
|
350
375
|
call = stmt.value
|
376
|
+
awaited = False
|
351
377
|
if isinstance(call, ast.Await):
|
352
378
|
call = call.value
|
379
|
+
awaited = True
|
353
380
|
if not isinstance(call, ast.Call):
|
354
381
|
raise DSLParseError("Only call expressions allowed at top level")
|
355
382
|
name = _get_call_name(call.func)
|
@@ -376,7 +403,7 @@ def parse(source: str, function_name: Optional[str] = None) -> Dict[str, Any]:
|
|
376
403
|
outputs.append({"from": ssa_from, "as": filename})
|
377
404
|
else:
|
378
405
|
# General expression call: represent as an op node too
|
379
|
-
_emit_expr_call(call)
|
406
|
+
_emit_expr_call(call, awaited)
|
380
407
|
return None
|
381
408
|
elif isinstance(stmt, ast.Return):
|
382
409
|
if isinstance(stmt.value, ast.Name):
|
@@ -505,9 +532,12 @@ def parse(source: str, function_name: Optional[str] = None) -> Dict[str, Any]:
|
|
505
532
|
versions_body, latest_body = versions, latest
|
506
533
|
versions, latest = saved_versions, saved_latest
|
507
534
|
context_suffix = saved_ctx
|
508
|
-
# Add iter dep to first op in body
|
535
|
+
# Add iter dep to first op in body if not already present
|
509
536
|
if len(ops) > body_ops_start:
|
510
|
-
|
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]
|
511
541
|
# Emit a summary foreach comp node depending on iterable value deps
|
512
542
|
iter_name_deps = _collect_value_deps(stmt.iter)
|
513
543
|
foreach_deps = [_ssa_get(n) for n in iter_name_deps]
|
@@ -548,7 +578,10 @@ def parse(source: str, function_name: Optional[str] = None) -> Dict[str, Any]:
|
|
548
578
|
versions, latest = saved_versions, saved_latest
|
549
579
|
context_suffix = saved_ctx
|
550
580
|
if len(ops) > body_ops_start:
|
551
|
-
|
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]
|
552
585
|
changed = {k for k in latest_body if pre_latest.get(k) != latest_body.get(k)}
|
553
586
|
carried = [k for k in changed if k in pre_latest]
|
554
587
|
for var in sorted(carried):
|
@@ -560,13 +593,20 @@ def parse(source: str, function_name: Optional[str] = None) -> Dict[str, Any]:
|
|
560
593
|
"args": {"var": var},
|
561
594
|
})
|
562
595
|
return None
|
563
|
-
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)):
|
564
601
|
return None
|
565
602
|
else:
|
566
603
|
raise DSLParseError("Only assignments, control flow, settings/output calls, and return are allowed in function body")
|
567
604
|
|
568
605
|
# Parse body sequentially; still require a resulting output
|
569
|
-
|
606
|
+
body = list(getattr(fn, "body", [])) # type: ignore[attr-defined]
|
607
|
+
if body and isinstance(body[0], ast.Expr) and isinstance(getattr(body[0], "value", None), ast.Constant) and isinstance(getattr(body[0].value, "value", None), str): # type: ignore[attr-defined]
|
608
|
+
body = body[1:]
|
609
|
+
for stmt in body:
|
570
610
|
_parse_stmt(stmt)
|
571
611
|
|
572
612
|
if not outputs:
|
@@ -0,0 +1,12 @@
|
|
1
|
+
py2dag/__init__.py,sha256=i8VB44JCVcRJAcvnQtbH8YVRUz5j7dE355iRbikXPGQ,250
|
2
|
+
py2dag/cli.py,sha256=q8ocafpGtkrls1mx9QiFU1wL6KKeeJhT0Oklk6SwHB0,15151
|
3
|
+
py2dag/colors.py,sha256=kVMVWUKJY1uLQFQux5XKiEcofDFpNPQoAEzpMJWs2b0,604
|
4
|
+
py2dag/export_dagre.py,sha256=Og-oVjKzc7bRgeU7rgLHDjiY6wIcNyr2nIYlSAQzKoM,6041
|
5
|
+
py2dag/export_svg.py,sha256=3ZmZqxIUidSbuKIIh5kFqhfVnoqZ6PbEQpMih4AX9xo,3793
|
6
|
+
py2dag/parser.py,sha256=bpwOZVsJUS3fRQTFfm2CfTxqlTWUYaLYDOHJRA2Yj34,30657
|
7
|
+
py2dag/pseudo.py,sha256=NJK61slyFLtSjhj8gJDJneUInEpBN57_41g8IfHNPWI,922
|
8
|
+
py2dag-0.3.1.dist-info/LICENSE,sha256=3Qee1EPwej_nusovTbyIQ8LvD2rXHdM0c6LNwk_D8Kc,1067
|
9
|
+
py2dag-0.3.1.dist-info/METADATA,sha256=GhuNW_nWmz0P0eNjX6Zs2y65AglkhUT1lplPeDzxmaU,3549
|
10
|
+
py2dag-0.3.1.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
11
|
+
py2dag-0.3.1.dist-info/entry_points.txt,sha256=Q0SHexJJ0z1te4AYL1xTZogx5FrxCCE1ZJ5qntkFMZs,42
|
12
|
+
py2dag-0.3.1.dist-info/RECORD,,
|
py2dag-0.2.4.dist-info/RECORD
DELETED
@@ -1,11 +0,0 @@
|
|
1
|
-
py2dag/__init__.py,sha256=i8VB44JCVcRJAcvnQtbH8YVRUz5j7dE355iRbikXPGQ,250
|
2
|
-
py2dag/cli.py,sha256=BCBi5mNxOqeEN8uEMt_hiDx0iSt7ZE3y74cGXREzZ2I,1296
|
3
|
-
py2dag/export_dagre.py,sha256=lScDQCIh5nE3RI_ac4Ye8AsLnn3MIn-fm1WlqP9Ei4U,4608
|
4
|
-
py2dag/export_svg.py,sha256=tKqh16hCrCE1QJ9-MTue1ELRxmXOb2akokpSz_a5eE8,2383
|
5
|
-
py2dag/parser.py,sha256=mJX_rjjSnO7G0-3thh5bBpPlG8N0IWAUqnp4Yn7Vd3A,28711
|
6
|
-
py2dag/pseudo.py,sha256=NJK61slyFLtSjhj8gJDJneUInEpBN57_41g8IfHNPWI,922
|
7
|
-
py2dag-0.2.4.dist-info/LICENSE,sha256=3Qee1EPwej_nusovTbyIQ8LvD2rXHdM0c6LNwk_D8Kc,1067
|
8
|
-
py2dag-0.2.4.dist-info/METADATA,sha256=yNQ2eR_WmySOQGRyq0uzvuYeKzV5_AxH2FTlSfPIvd0,3549
|
9
|
-
py2dag-0.2.4.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
10
|
-
py2dag-0.2.4.dist-info/entry_points.txt,sha256=Q0SHexJJ0z1te4AYL1xTZogx5FrxCCE1ZJ5qntkFMZs,42
|
11
|
-
py2dag-0.2.4.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|