py2dag 0.2.4__tar.gz → 0.3.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: py2dag
3
- Version: 0.2.4
3
+ Version: 0.3.1
4
4
  Summary: Convert Python function plans to DAG (JSON, pseudo, optional SVG).
5
5
  License: MIT
6
6
  Author: rvergis
@@ -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-top: 1px solid #ddd; }
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: 'LR', nodesep: 30, ranksep: 40 });
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
- g.setNode(op.id, { label, class: klass, padding: 8 });
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
- g.setNode(outId, { label: out.as, class: 'note', padding: 8 });
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
- (op.deps || []).forEach(dep => {
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 = dep; // default to SSA id
85
- if (src && src.op === 'COND.eval') {
86
- edgeLabel = 'cond';
87
- } else if (src && src.op === 'ITER.eval') {
88
- edgeLabel = (src.args && src.args.target) ? src.args.target : 'iter';
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
- deps.extend(_expand_star_name(_ssa_get(star_val.id)))
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
- kwargs[kw.arg] = _literal(kw.value)
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
- ops.append({"id": ssa, "op": op_name, "deps": deps, "args": kwargs})
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
- ops[body_ops_start]["deps"] = [*ops[body_ops_start].get("deps", []), iter_id]
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
- ops[body_ops_start]["deps"] = [*ops[body_ops_start].get("deps", []), cond_id]
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, (ast.Pass, ast.Continue, ast.Break)):
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
- for i, stmt in enumerate(fn.body): # type: ignore[attr-defined]
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:
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [tool.poetry]
6
6
  name = "py2dag"
7
- version = "0.2.4"
7
+ version = "0.3.1"
8
8
  description = "Convert Python function plans to DAG (JSON, pseudo, optional SVG)."
9
9
  authors = ["rvergis"]
10
10
  license = "MIT"
@@ -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