py2dag 0.1.4__py3-none-any.whl → 0.1.6__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 CHANGED
@@ -9,7 +9,7 @@ from . import export_svg
9
9
  def main() -> None:
10
10
  ap = argparse.ArgumentParser(description="Convert Python function plan to DAG")
11
11
  ap.add_argument("file", help="Python file containing the plan function")
12
- ap.add_argument("--func", default="plan", help="Function name to parse")
12
+ ap.add_argument("--func", default=None, help="Function name to parse (auto-detect if omitted)")
13
13
  ap.add_argument("--svg", action="store_true", help="Also export plan.svg via Graphviz (requires dot)")
14
14
  ap.add_argument("--html", action="store_true", help="Also export plan.html via Dagre (no system deps)")
15
15
  args = ap.parse_args()
py2dag/parser.py CHANGED
@@ -35,139 +35,177 @@ def _get_call_name(func: ast.AST) -> str:
35
35
  raise DSLParseError("Only simple or attribute names are allowed for operations")
36
36
 
37
37
 
38
- def parse(source: str, function_name: str = "plan") -> Dict[str, Any]:
38
+ def parse(source: str, function_name: Optional[str] = None) -> Dict[str, Any]:
39
39
  if len(source) > 20_000:
40
40
  raise DSLParseError("Source too large")
41
41
  module = ast.parse(source)
42
- fn = None
43
- for node in module.body:
44
- if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == function_name:
45
- fn = node
46
- break
47
- if fn is None:
48
- raise DSLParseError(f"Function {function_name!r} not found")
49
-
50
- defined: set[str] = set()
51
- ops: List[Dict[str, Any]] = []
52
- outputs: List[Dict[str, str]] = []
53
- settings: Dict[str, Any] = {}
54
-
55
- returned_var: Optional[str] = None
56
- for i, stmt in enumerate(fn.body):
57
- if isinstance(stmt, ast.Assign):
58
- if len(stmt.targets) != 1 or not isinstance(stmt.targets[0], ast.Name):
59
- raise DSLParseError("Assignment targets must be simple names")
60
- var_name = stmt.targets[0].id
61
- if not VALID_NAME_RE.match(var_name):
62
- raise DSLParseError(f"Invalid variable name: {var_name}")
63
- if var_name in defined:
64
- raise DSLParseError(f"Duplicate variable name: {var_name}")
65
-
66
- value = stmt.value
67
- if isinstance(value, ast.Await):
68
- value = value.value
69
- if isinstance(value, ast.Call):
70
- op_name = _get_call_name(value.func)
71
-
72
- deps: List[str] = []
73
- for arg in value.args:
74
- if not isinstance(arg, ast.Name):
75
- raise DSLParseError("Positional args must be variable names")
76
- if arg.id not in defined:
77
- raise DSLParseError(f"Undefined dependency: {arg.id}")
78
- deps.append(arg.id)
79
-
80
- kwargs: Dict[str, Any] = {}
81
- for kw in value.keywords:
82
- if kw.arg is None:
83
- raise DSLParseError("**kwargs are not allowed")
84
- kwargs[kw.arg] = _literal(kw.value)
85
-
86
- ops.append({"id": var_name, "op": op_name, "deps": deps, "args": kwargs})
87
- elif isinstance(value, ast.JoinedStr):
88
- # Minimal f-string support: only variable placeholders
89
- deps: List[str] = []
90
- parts: List[str] = []
91
- for item in value.values:
92
- if isinstance(item, ast.Constant) and isinstance(item.value, str):
93
- parts.append(item.value)
94
- elif isinstance(item, ast.FormattedValue) and isinstance(item.value, ast.Name):
95
- name = item.value.id
96
- if name not in defined:
97
- raise DSLParseError(f"Undefined dependency: {name}")
98
- deps.append(name)
99
- parts.append("{" + str(len(deps) - 1) + "}")
100
- else:
101
- raise DSLParseError("f-strings may only contain variable names")
102
- template = "".join(parts)
103
- ops.append({
104
- "id": var_name,
105
- "op": "TEXT.format",
106
- "deps": deps,
107
- "args": {"template": template},
108
- })
42
+
43
+ def _parse_fn(fn: ast.AST) -> Dict[str, Any]:
44
+ defined: set[str] = set()
45
+ ops: List[Dict[str, Any]] = []
46
+ outputs: List[Dict[str, str]] = []
47
+ settings: Dict[str, Any] = {}
48
+
49
+ returned_var: Optional[str] = None
50
+ # type: ignore[attr-defined]
51
+ for i, stmt in enumerate(fn.body): # type: ignore[attr-defined]
52
+ if isinstance(stmt, ast.Assign):
53
+ if len(stmt.targets) != 1 or not isinstance(stmt.targets[0], ast.Name):
54
+ raise DSLParseError("Assignment targets must be simple names")
55
+ var_name = stmt.targets[0].id
56
+ if not VALID_NAME_RE.match(var_name):
57
+ raise DSLParseError(f"Invalid variable name: {var_name}")
58
+ if var_name in defined:
59
+ raise DSLParseError(f"Duplicate variable name: {var_name}")
60
+
61
+ value = stmt.value
62
+ if isinstance(value, ast.Await):
63
+ value = value.value
64
+ if isinstance(value, ast.Call):
65
+ op_name = _get_call_name(value.func)
66
+
67
+ deps: List[str] = []
68
+ for arg in value.args:
69
+ if not isinstance(arg, ast.Name):
70
+ raise DSLParseError("Positional args must be variable names")
71
+ if arg.id not in defined:
72
+ raise DSLParseError(f"Undefined dependency: {arg.id}")
73
+ deps.append(arg.id)
74
+
75
+ kwargs: Dict[str, Any] = {}
76
+ for kw in value.keywords:
77
+ if kw.arg is None:
78
+ raise DSLParseError("**kwargs are not allowed")
79
+ kwargs[kw.arg] = _literal(kw.value)
80
+
81
+ ops.append({"id": var_name, "op": op_name, "deps": deps, "args": kwargs})
82
+ elif isinstance(value, ast.JoinedStr):
83
+ # Minimal f-string support: only variable placeholders
84
+ deps: List[str] = []
85
+ parts: List[str] = []
86
+ for item in value.values:
87
+ if isinstance(item, ast.Constant) and isinstance(item.value, str):
88
+ parts.append(item.value)
89
+ elif isinstance(item, ast.FormattedValue) and isinstance(item.value, ast.Name):
90
+ name = item.value.id
91
+ if name not in defined:
92
+ raise DSLParseError(f"Undefined dependency: {name}")
93
+ deps.append(name)
94
+ parts.append("{" + str(len(deps) - 1) + "}")
95
+ else:
96
+ raise DSLParseError("f-strings may only contain variable names")
97
+ template = "".join(parts)
98
+ ops.append({
99
+ "id": var_name,
100
+ "op": "TEXT.format",
101
+ "deps": deps,
102
+ "args": {"template": template},
103
+ })
104
+ else:
105
+ raise DSLParseError("Right hand side must be a call or f-string")
106
+ defined.add(var_name)
107
+
108
+ elif isinstance(stmt, ast.Expr):
109
+ call = stmt.value
110
+ if isinstance(call, ast.Await):
111
+ call = call.value
112
+ if not isinstance(call, ast.Call):
113
+ raise DSLParseError("Only call expressions allowed at top level")
114
+ name = _get_call_name(call.func)
115
+ if name == "settings":
116
+ for kw in call.keywords:
117
+ if kw.arg is None:
118
+ raise DSLParseError("settings does not accept **kwargs")
119
+ settings[kw.arg] = _literal(kw.value)
120
+ if call.args:
121
+ raise DSLParseError("settings only accepts keyword literals")
122
+ elif name == "output":
123
+ if len(call.args) != 1 or not isinstance(call.args[0], ast.Name):
124
+ raise DSLParseError("output requires a single variable name argument")
125
+ var = call.args[0].id
126
+ if var not in defined:
127
+ raise DSLParseError(f"Undefined output variable: {var}")
128
+ filename = None
129
+ for kw in call.keywords:
130
+ if kw.arg in {"as", "as_"}:
131
+ filename = _literal(kw.value)
132
+ else:
133
+ raise DSLParseError("output only accepts 'as' keyword")
134
+ if filename is None or not isinstance(filename, str):
135
+ raise DSLParseError("output requires as=\"filename\"")
136
+ outputs.append({"from": var, "as": filename})
137
+ else:
138
+ raise DSLParseError("Only settings() and output() calls allowed as expressions")
139
+ elif isinstance(stmt, ast.Return):
140
+ if i != len(fn.body) - 1: # type: ignore[index]
141
+ raise DSLParseError("return must be the last statement")
142
+ if isinstance(stmt.value, ast.Name):
143
+ var = stmt.value.id
144
+ if var not in defined:
145
+ raise DSLParseError(f"Undefined return variable: {var}")
146
+ returned_var = var
147
+ elif isinstance(stmt.value, ast.Constant):
148
+ # Support returning a literal (e.g., "DONE"): synthesize a const op
149
+ lit = stmt.value.value
150
+ const_id_base = "return_value"
151
+ const_id = const_id_base
152
+ n = 1
153
+ while const_id in defined:
154
+ const_id = f"{const_id_base}_{n}"
155
+ n += 1
156
+ ops.append({
157
+ "id": const_id,
158
+ "op": "CONST.value",
159
+ "deps": [],
160
+ "args": {"value": lit},
161
+ })
162
+ returned_var = const_id
163
+ else:
164
+ raise DSLParseError("return must return a variable name or literal")
109
165
  else:
110
- raise DSLParseError("Right hand side must be a call or f-string")
111
- defined.add(var_name)
112
-
113
- elif isinstance(stmt, ast.Expr):
114
- call = stmt.value
115
- if isinstance(call, ast.Await):
116
- call = call.value
117
- if not isinstance(call, ast.Call):
118
- raise DSLParseError("Only call expressions allowed at top level")
119
- name = _get_call_name(call.func)
120
- if name == "settings":
121
- for kw in call.keywords:
122
- if kw.arg is None:
123
- raise DSLParseError("settings does not accept **kwargs")
124
- settings[kw.arg] = _literal(kw.value)
125
- if call.args:
126
- raise DSLParseError("settings only accepts keyword literals")
127
- elif name == "output":
128
- if len(call.args) != 1 or not isinstance(call.args[0], ast.Name):
129
- raise DSLParseError("output requires a single variable name argument")
130
- var = call.args[0].id
131
- if var not in defined:
132
- raise DSLParseError(f"Undefined output variable: {var}")
133
- filename = None
134
- for kw in call.keywords:
135
- if kw.arg in {"as", "as_"}:
136
- filename = _literal(kw.value)
137
- else:
138
- raise DSLParseError("output only accepts 'as' keyword")
139
- if filename is None or not isinstance(filename, str):
140
- raise DSLParseError("output requires as=\"filename\"")
141
- outputs.append({"from": var, "as": filename})
166
+ raise DSLParseError("Only assignments, expression calls, and a final return are allowed in function body")
167
+
168
+ if not outputs:
169
+ if returned_var is not None:
170
+ outputs.append({"from": returned_var, "as": "return"})
142
171
  else:
143
- raise DSLParseError("Only settings() and output() calls allowed as expressions")
144
- elif isinstance(stmt, ast.Return):
145
- if i != len(fn.body) - 1:
146
- raise DSLParseError("return must be the last statement")
147
- if not isinstance(stmt.value, ast.Name):
148
- raise DSLParseError("return must return a variable name")
149
- var = stmt.value.id
150
- if var not in defined:
151
- raise DSLParseError(f"Undefined return variable: {var}")
152
- returned_var = var
153
- else:
154
- raise DSLParseError("Only assignments, expression calls, and a final return are allowed in function body")
155
-
156
- if not outputs:
157
- if returned_var is not None:
158
- outputs.append({"from": returned_var, "as": "return"})
159
- else:
160
- raise DSLParseError("At least one output() call required")
161
- if len(ops) > 200:
162
- raise DSLParseError("Too many operations")
163
-
164
- plan: Dict[str, Any] = {"version": 1, "ops": ops, "outputs": outputs}
165
- if settings:
166
- plan["settings"] = settings
167
- return plan
168
-
169
-
170
- def parse_file(filename: str, function_name: str = "plan") -> Dict[str, Any]:
172
+ raise DSLParseError("At least one output() call required")
173
+ if len(ops) > 200:
174
+ raise DSLParseError("Too many operations")
175
+
176
+ # Include the parsed function name for visibility/debugging
177
+ fn_name = getattr(fn, "name", None) # type: ignore[attr-defined]
178
+ plan: Dict[str, Any] = {"version": 1, "function": fn_name, "ops": ops, "outputs": outputs}
179
+ if settings:
180
+ plan["settings"] = settings
181
+ return plan
182
+
183
+ # If a specific function name is provided, use it; otherwise try to auto-detect
184
+ if function_name is not None:
185
+ fn: Optional[ast.AST] = None
186
+ for node in module.body:
187
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == function_name:
188
+ fn = node
189
+ break
190
+ if fn is None:
191
+ raise DSLParseError(f"Function {function_name!r} not found")
192
+ return _parse_fn(fn)
193
+ else:
194
+ last_err: Optional[Exception] = None
195
+ for node in module.body:
196
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
197
+ try:
198
+ return _parse_fn(node)
199
+ except DSLParseError as e:
200
+ last_err = e
201
+ continue
202
+ # If we got here, either there are no functions or none matched the DSL
203
+ if last_err is not None:
204
+ raise DSLParseError("No suitable function matched the DSL; specify --func to disambiguate") from last_err
205
+ raise DSLParseError("No function definitions found in source")
206
+
207
+
208
+ def parse_file(filename: str, function_name: Optional[str] = None) -> Dict[str, Any]:
171
209
  with open(filename, "r", encoding="utf-8") as f:
172
210
  src = f.read()
173
211
  return parse(src, function_name=function_name)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: py2dag
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: Convert Python function plans to DAG (JSON, pseudo, optional SVG).
5
5
  License: MIT
6
6
  Author: rvergis
@@ -76,6 +76,8 @@ poetry run python cli.py path/to/your_file.py --html
76
76
 
77
77
  This generates `plan.json`, `plan.pseudo`, and if `--html` is used, `plan.html`.
78
78
 
79
+ - Function name: By default the tool auto-detects a suitable function in the file. To target a specific function, pass `--func NAME`.
80
+
79
81
  ### Offline HTML (no internet)
80
82
 
81
83
  `plan.html` references d3 and dagre-d3 from CDNs. To view graphs fully offline, download those files and place them next to `plan.html`, then edit the two `<script>` tags in `py2dag/export_dagre.py` to point at your local copies.
@@ -111,18 +113,17 @@ This repo includes a GitHub Actions workflow that publishes to PyPI when you pus
111
113
 
112
114
  1) In GitHub repo settings, add a repository secret named `PYPI_API_TOKEN` with your PyPI token (format: `pypi-***`).
113
115
 
114
- 2) Bump patch version, create tag, and push it (this triggers the workflow):
116
+ 2) Bump patch version, commit, and tag locally; then push tags to trigger the workflow:
115
117
 
116
118
  ```
117
- make release
119
+ make patch # bumps version, commits, and tags vX.Y.Z
120
+ make release # pushes tags to GitHub (triggers publish)
118
121
  ```
119
122
 
120
- Or do individual steps:
123
+ Or push tags manually:
121
124
 
122
125
  ```
123
- make bump-patch
124
- make tag
125
- make push-tags
126
+ git push --tags
126
127
  ```
127
128
 
128
129
  You can also install from the local build with `pip install dist/*.whl`.
@@ -0,0 +1,11 @@
1
+ py2dag/__init__.py,sha256=i8VB44JCVcRJAcvnQtbH8YVRUz5j7dE355iRbikXPGQ,250
2
+ py2dag/cli.py,sha256=BCBi5mNxOqeEN8uEMt_hiDx0iSt7ZE3y74cGXREzZ2I,1296
3
+ py2dag/export_dagre.py,sha256=S244wUxBMuM9qXD8bklaslkMp3rcbBGWvMhwWVBzBF0,3487
4
+ py2dag/export_svg.py,sha256=YyjqOuj8GhUTDWP70SKnnSWAKI1PvJwyOhHLwB29uNM,1812
5
+ py2dag/parser.py,sha256=1U16-GOQMa8oriJLePJ2TQlRZsrC9A4apy5V-V10M1A,9774
6
+ py2dag/pseudo.py,sha256=NJK61slyFLtSjhj8gJDJneUInEpBN57_41g8IfHNPWI,922
7
+ py2dag-0.1.6.dist-info/LICENSE,sha256=3Qee1EPwej_nusovTbyIQ8LvD2rXHdM0c6LNwk_D8Kc,1067
8
+ py2dag-0.1.6.dist-info/METADATA,sha256=v3cvP4wgmttSoY7LQtSqnk9zjSWc2mIYbO_FdyyAEZw,3537
9
+ py2dag-0.1.6.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
10
+ py2dag-0.1.6.dist-info/entry_points.txt,sha256=Q0SHexJJ0z1te4AYL1xTZogx5FrxCCE1ZJ5qntkFMZs,42
11
+ py2dag-0.1.6.dist-info/RECORD,,
@@ -1,11 +0,0 @@
1
- py2dag/__init__.py,sha256=i8VB44JCVcRJAcvnQtbH8YVRUz5j7dE355iRbikXPGQ,250
2
- py2dag/cli.py,sha256=zedwk2_bnm4BlzrPxwYP3NNaofySxEz_ydUk6e7sE_E,1273
3
- py2dag/export_dagre.py,sha256=S244wUxBMuM9qXD8bklaslkMp3rcbBGWvMhwWVBzBF0,3487
4
- py2dag/export_svg.py,sha256=YyjqOuj8GhUTDWP70SKnnSWAKI1PvJwyOhHLwB29uNM,1812
5
- py2dag/parser.py,sha256=aWchBHISd5zJfvHTiXr5TUsog4725vMHezYoLTlHE3o,7425
6
- py2dag/pseudo.py,sha256=NJK61slyFLtSjhj8gJDJneUInEpBN57_41g8IfHNPWI,922
7
- py2dag-0.1.4.dist-info/LICENSE,sha256=3Qee1EPwej_nusovTbyIQ8LvD2rXHdM0c6LNwk_D8Kc,1067
8
- py2dag-0.1.4.dist-info/METADATA,sha256=4x14DTr7S4WjsGT0ht4x-zalL_pwbD3ir1cOUtl8bSw,3308
9
- py2dag-0.1.4.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
10
- py2dag-0.1.4.dist-info/entry_points.txt,sha256=Q0SHexJJ0z1te4AYL1xTZogx5FrxCCE1ZJ5qntkFMZs,42
11
- py2dag-0.1.4.dist-info/RECORD,,
File without changes