py2dag 0.2.1__tar.gz → 0.2.3__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.1
3
+ Version: 0.2.3
4
4
  Summary: Convert Python function plans to DAG (JSON, pseudo, optional SVG).
5
5
  License: MIT
6
6
  Author: rvergis
@@ -73,10 +73,21 @@ HTML_TEMPLATE = """<!doctype html>
73
73
  g.setEdge(out.from, outId);
74
74
  });
75
75
 
76
- // Add dependency edges between ops
76
+ // Build index for source op lookup
77
+ const opById = {};
78
+ (plan.ops || []).forEach(op => { opById[op.id] = op; });
79
+
80
+ // Add dependency edges between ops with labels
77
81
  (plan.ops || []).forEach(op => {
78
82
  (op.deps || []).forEach(dep => {
79
- g.setEdge(dep, op.id);
83
+ 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';
89
+ }
90
+ g.setEdge(dep, op.id, { label: edgeLabel });
80
91
  });
81
92
  });
82
93
 
@@ -22,10 +22,27 @@ def export(plan: Dict[str, Any], filename: str = "plan.svg") -> str:
22
22
  raise RuntimeError("Python package 'graphviz' is required for SVG export")
23
23
 
24
24
  graph = Digraph(format="svg")
25
- for op in plan.get("ops", []):
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:
26
32
  graph.node(op["id"], label=op["op"])
33
+
34
+ # Dependency edges with labels showing data/control
35
+ for op in ops:
27
36
  for dep in op.get("deps", []):
28
- graph.edge(dep, op["id"])
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)
29
46
  for out in plan.get("outputs", []):
30
47
  out_id = f"out:{out['as']}"
31
48
  graph.node(out_id, label=out['as'], shape="note")
@@ -173,6 +173,10 @@ def parse(source: str, function_name: Optional[str] = None) -> Dict[str, Any]:
173
173
  ops.append({"id": ssa, "op": op_name, "deps": deps, "args": kwargs})
174
174
  return ssa
175
175
 
176
+ def _emit_expr_call(call: ast.Call) -> str:
177
+ """Emit a node for a bare expression call (no assignment)."""
178
+ return _emit_assign_from_call("call", call)
179
+
176
180
  def _emit_assign_from_fstring(var_name: str, fstr: ast.JoinedStr) -> str:
177
181
  deps: List[str] = []
178
182
  parts: List[str] = []
@@ -222,6 +226,45 @@ def parse(source: str, function_name: Optional[str] = None) -> Dict[str, Any]:
222
226
  "args": {},
223
227
  })
224
228
  return ssa
229
+ if isinstance(value, ast.Dict):
230
+ # Support dict with values from names/calls or literals by synthesizing nodes
231
+ keys: List[str] = []
232
+ deps: List[str] = []
233
+ for k_node, v_node in zip(value.keys, value.values):
234
+ k_str = _literal(k_node)
235
+ if not isinstance(k_str, (str, int, float, bool)):
236
+ k_str = str(k_str)
237
+ keys.append(str(k_str))
238
+ if isinstance(v_node, ast.Name):
239
+ deps.append(_ssa_get(v_node.id))
240
+ elif isinstance(v_node, ast.Await):
241
+ inner = v_node.value
242
+ if not isinstance(inner, ast.Call):
243
+ raise DSLParseError("await must wrap a call in dict value")
244
+ tmp_id = _emit_assign_from_call(f"{var_name}_field", inner)
245
+ deps.append(tmp_id)
246
+ elif isinstance(v_node, ast.Call):
247
+ tmp_id = _emit_assign_from_call(f"{var_name}_field", v_node)
248
+ deps.append(tmp_id)
249
+ else:
250
+ # Synthesize const for literal value
251
+ lit_val = _literal(v_node)
252
+ tmp = _ssa_new(f"{var_name}_lit")
253
+ ops.append({
254
+ "id": tmp,
255
+ "op": "CONST.value",
256
+ "deps": [],
257
+ "args": {"value": lit_val},
258
+ })
259
+ deps.append(tmp)
260
+ ssa = _ssa_new(var_name)
261
+ ops.append({
262
+ "id": ssa,
263
+ "op": "PACK.dict",
264
+ "deps": deps,
265
+ "args": {"keys": keys},
266
+ })
267
+ return ssa
225
268
  raise
226
269
 
227
270
  def _emit_assign_from_comp(var_name: str, node: ast.AST) -> str:
@@ -252,11 +295,14 @@ def parse(source: str, function_name: Optional[str] = None) -> Dict[str, Any]:
252
295
  ops.append({"id": ssa, "op": "COND.eval", "deps": deps, "args": {"expr": expr, "kind": kind}})
253
296
  return ssa
254
297
 
255
- def _emit_iter(node: ast.AST) -> str:
298
+ def _emit_iter(node: ast.AST, target_label: Optional[str] = None) -> str:
256
299
  expr = _stringify(node)
257
300
  deps = [_ssa_get(n) for n in _collect_value_deps(node)]
258
301
  ssa = _ssa_new("iter")
259
- ops.append({"id": ssa, "op": "ITER.eval", "deps": deps, "args": {"expr": expr, "kind": "for"}})
302
+ args = {"expr": expr, "kind": "for"}
303
+ if target_label:
304
+ args["target"] = target_label
305
+ ops.append({"id": ssa, "op": "ITER.eval", "deps": deps, "args": args})
260
306
  return ssa
261
307
 
262
308
  def _parse_stmt(stmt: ast.stmt) -> Optional[str]:
@@ -307,7 +353,8 @@ def parse(source: str, function_name: Optional[str] = None) -> Dict[str, Any]:
307
353
  raise DSLParseError("output requires as=\"filename\"")
308
354
  outputs.append({"from": ssa_from, "as": filename})
309
355
  else:
310
- raise DSLParseError("Only settings() and output() calls allowed as expressions")
356
+ # General expression call: represent as an op node too
357
+ _emit_expr_call(call)
311
358
  return None
312
359
  elif isinstance(stmt, ast.Return):
313
360
  if isinstance(stmt.value, ast.Name):
@@ -383,7 +430,19 @@ def parse(source: str, function_name: Optional[str] = None) -> Dict[str, Any]:
383
430
  return None
384
431
  elif isinstance(stmt, (ast.For, ast.AsyncFor)):
385
432
  # ITER over iterable
386
- iter_id = _emit_iter(stmt.iter)
433
+ # Determine loop target label if simple
434
+ t = stmt.target
435
+ t_label: Optional[str] = None
436
+ if isinstance(t, ast.Name):
437
+ t_label = t.id
438
+ elif isinstance(t, ast.Tuple) and all(isinstance(e, ast.Name) for e in t.elts):
439
+ t_label = ",".join(e.id for e in t.elts) # type: ignore[attr-defined]
440
+ else:
441
+ try:
442
+ t_label = ast.unparse(t) # type: ignore[attr-defined]
443
+ except Exception:
444
+ t_label = None
445
+ iter_id = _emit_iter(stmt.iter, target_label=t_label)
387
446
  # Save pre-loop state
388
447
  pre_versions = dict(versions)
389
448
  pre_latest = dict(latest)
@@ -396,6 +455,29 @@ def parse(source: str, function_name: Optional[str] = None) -> Dict[str, Any]:
396
455
  ctx_counts["loop"] += 1
397
456
  context_suffix = f"loop{ctx_counts['loop']}"
398
457
  versions, latest = versions_body, latest_body
458
+ # Predefine loop target variables as items from iterator for dependency resolution
459
+ def _bind_loop_target(target: ast.AST):
460
+ if isinstance(target, ast.Name):
461
+ ssa_item = _ssa_new(target.id)
462
+ ops.append({
463
+ "id": ssa_item,
464
+ "op": "ITER.item",
465
+ "deps": [iter_id],
466
+ "args": {"target": target.id},
467
+ })
468
+ elif isinstance(target, ast.Tuple):
469
+ for elt in target.elts:
470
+ if isinstance(elt, ast.Name):
471
+ ssa_item = _ssa_new(elt.id)
472
+ ops.append({
473
+ "id": ssa_item,
474
+ "op": "ITER.item",
475
+ "deps": [iter_id],
476
+ "args": {"target": elt.id},
477
+ })
478
+ # Other patterns are ignored for now
479
+
480
+ _bind_loop_target(stmt.target)
399
481
  for inner in stmt.body:
400
482
  _parse_stmt(inner)
401
483
  versions_body, latest_body = versions, latest
@@ -404,6 +486,16 @@ def parse(source: str, function_name: Optional[str] = None) -> Dict[str, Any]:
404
486
  # Add iter dep to first op in body
405
487
  if len(ops) > body_ops_start:
406
488
  ops[body_ops_start]["deps"] = [*ops[body_ops_start].get("deps", []), iter_id]
489
+ # Emit a summary foreach comp node depending on iterable value deps
490
+ iter_name_deps = _collect_value_deps(stmt.iter)
491
+ foreach_deps = [_ssa_get(n) for n in iter_name_deps]
492
+ ssa_foreach = _ssa_new("foreach")
493
+ ops.append({
494
+ "id": ssa_foreach,
495
+ "op": "COMP.foreach",
496
+ "deps": foreach_deps,
497
+ "args": {"target": t_label or ""},
498
+ })
407
499
  # Loop-carried vars: only those existing pre-loop and reassigned in body
408
500
  changed = {k for k in latest_body if pre_latest.get(k) != latest_body.get(k)}
409
501
  carried = [k for k in changed if k in pre_latest]
@@ -446,7 +538,7 @@ def parse(source: str, function_name: Optional[str] = None) -> Dict[str, Any]:
446
538
  "args": {"var": var},
447
539
  })
448
540
  return None
449
- elif isinstance(stmt, (ast.Pass,)):
541
+ elif isinstance(stmt, (ast.Pass, ast.Continue, ast.Break)):
450
542
  return None
451
543
  else:
452
544
  raise DSLParseError("Only assignments, control flow, settings/output calls, and return are allowed in function body")
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [tool.poetry]
6
6
  name = "py2dag"
7
- version = "0.2.1"
7
+ version = "0.2.3"
8
8
  description = "Convert Python function plans to DAG (JSON, pseudo, optional SVG)."
9
9
  authors = ["rvergis"]
10
10
  license = "MIT"
File without changes
File without changes
File without changes
File without changes
File without changes