py2dag 0.1.4__py3-none-any.whl → 0.1.5__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 +1 -1
- py2dag/parser.py +147 -128
- {py2dag-0.1.4.dist-info → py2dag-0.1.5.dist-info}/METADATA +8 -7
- py2dag-0.1.5.dist-info/RECORD +11 -0
- py2dag-0.1.4.dist-info/RECORD +0 -11
- {py2dag-0.1.4.dist-info → py2dag-0.1.5.dist-info}/LICENSE +0 -0
- {py2dag-0.1.4.dist-info → py2dag-0.1.5.dist-info}/WHEEL +0 -0
- {py2dag-0.1.4.dist-info → py2dag-0.1.5.dist-info}/entry_points.txt +0 -0
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=
|
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,158 @@ 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 =
|
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
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
"
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
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 not isinstance(stmt.value, ast.Name):
|
143
|
+
raise DSLParseError("return must return a variable name")
|
144
|
+
var = stmt.value.id
|
131
145
|
if var not in defined:
|
132
|
-
raise DSLParseError(f"Undefined
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
raise DSLParseError("output requires as=\"filename\"")
|
141
|
-
outputs.append({"from": var, "as": filename})
|
146
|
+
raise DSLParseError(f"Undefined return variable: {var}")
|
147
|
+
returned_var = var
|
148
|
+
else:
|
149
|
+
raise DSLParseError("Only assignments, expression calls, and a final return are allowed in function body")
|
150
|
+
|
151
|
+
if not outputs:
|
152
|
+
if returned_var is not None:
|
153
|
+
outputs.append({"from": returned_var, "as": "return"})
|
142
154
|
else:
|
143
|
-
raise DSLParseError("
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
raise DSLParseError("
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
155
|
+
raise DSLParseError("At least one output() call required")
|
156
|
+
if len(ops) > 200:
|
157
|
+
raise DSLParseError("Too many operations")
|
158
|
+
|
159
|
+
plan: Dict[str, Any] = {"version": 1, "ops": ops, "outputs": outputs}
|
160
|
+
if settings:
|
161
|
+
plan["settings"] = settings
|
162
|
+
return plan
|
163
|
+
|
164
|
+
# If a specific function name is provided, use it; otherwise try to auto-detect
|
165
|
+
if function_name is not None:
|
166
|
+
fn: Optional[ast.AST] = None
|
167
|
+
for node in module.body:
|
168
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == function_name:
|
169
|
+
fn = node
|
170
|
+
break
|
171
|
+
if fn is None:
|
172
|
+
raise DSLParseError(f"Function {function_name!r} not found")
|
173
|
+
return _parse_fn(fn)
|
174
|
+
else:
|
175
|
+
last_err: Optional[Exception] = None
|
176
|
+
for node in module.body:
|
177
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
178
|
+
try:
|
179
|
+
return _parse_fn(node)
|
180
|
+
except DSLParseError as e:
|
181
|
+
last_err = e
|
182
|
+
continue
|
183
|
+
# If we got here, either there are no functions or none matched the DSL
|
184
|
+
if last_err is not None:
|
185
|
+
raise DSLParseError("No suitable function matched the DSL; specify --func to disambiguate") from last_err
|
186
|
+
raise DSLParseError("No function definitions found in source")
|
187
|
+
|
188
|
+
|
189
|
+
def parse_file(filename: str, function_name: Optional[str] = None) -> Dict[str, Any]:
|
171
190
|
with open(filename, "r", encoding="utf-8") as f:
|
172
191
|
src = f.read()
|
173
192
|
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.
|
3
|
+
Version: 0.1.5
|
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,
|
116
|
+
2) Bump patch version, commit, and tag locally; then push tags to trigger the workflow:
|
115
117
|
|
116
118
|
```
|
117
|
-
make
|
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
|
123
|
+
Or push tags manually:
|
121
124
|
|
122
125
|
```
|
123
|
-
|
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=2sYscG3hFHK7RxJ6rNlO1w9GnxF-9hZG6SMAs_oGDu0,8849
|
6
|
+
py2dag/pseudo.py,sha256=NJK61slyFLtSjhj8gJDJneUInEpBN57_41g8IfHNPWI,922
|
7
|
+
py2dag-0.1.5.dist-info/LICENSE,sha256=3Qee1EPwej_nusovTbyIQ8LvD2rXHdM0c6LNwk_D8Kc,1067
|
8
|
+
py2dag-0.1.5.dist-info/METADATA,sha256=yEDK8WOlw2SYEa5J_3H7OINAfFLmPLgvi0UzJ50O9Cg,3537
|
9
|
+
py2dag-0.1.5.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
10
|
+
py2dag-0.1.5.dist-info/entry_points.txt,sha256=Q0SHexJJ0z1te4AYL1xTZogx5FrxCCE1ZJ5qntkFMZs,42
|
11
|
+
py2dag-0.1.5.dist-info/RECORD,,
|
py2dag-0.1.4.dist-info/RECORD
DELETED
@@ -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
|
File without changes
|
File without changes
|