py2dag 0.1.2__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.
py2dag-0.1.2/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Ron Vergis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
py2dag-0.1.2/PKG-INFO ADDED
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.3
2
+ Name: py2dag
3
+ Version: 0.1.2
4
+ Summary: Convert Python function plans to DAG (JSON, pseudo, optional SVG).
5
+ License: MIT
6
+ Author: rvergis
7
+ Requires-Python: >=3.8,<4.0
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.8
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Provides-Extra: svg
17
+ Requires-Dist: graphviz (>=0.20.3,<0.21.0) ; extra == "svg"
18
+ Description-Content-Type: text/markdown
19
+
20
+ # py2dag
21
+ Convert Python function plans to a DAG (JSON, pseudo, optional SVG).
22
+
23
+ ## Install
24
+
25
+ - From PyPI (once published):
26
+
27
+ ```
28
+ pip install py2dag
29
+ ```
30
+
31
+ - From source using Makefile targets (recommended for development):
32
+
33
+ ```
34
+ make setup # create .venv and install deps (SVG extra by default)
35
+ ```
36
+
37
+ ## Dev Environment via Makefile
38
+
39
+ - Install Poetry (see poetry docs or `pipx install poetry`).
40
+ - From the repo root:
41
+
42
+ ```
43
+ poetry install
44
+ ```
45
+
46
+ Notes:
47
+ - HTML export uses Dagre via CDN (d3.js + dagre-d3). No local system deps, but an internet connection is required when opening `plan.html`. If you are offline or behind a firewall, the page will show a message; vendor the JS locally or open with internet access.
48
+ - Optional Graphviz SVG export (`--svg`) requires Graphviz system binaries (e.g., `brew install graphviz`).
49
+
50
+ ### Local Virtualenv (.venv)
51
+
52
+ - Create/update venv and install deps: `make setup`
53
+ - Open interactive shell inside venv: `make shell`
54
+ - `.venv/` is ignored by git.
55
+
56
+ ## Usage
57
+
58
+ - Run via Makefile (dev) — generates an interactive Dagre HTML graph:
59
+
60
+ ```
61
+ make run FILE=path/to/your_file.py ARGS=--html
62
+ ```
63
+
64
+ - Run the installed CLI (after `pip install py2dag`):
65
+
66
+ ```
67
+ py2dag path/to/your_file.py --html
68
+ ```
69
+
70
+
71
+ - Or directly with Python (inside venv):
72
+
73
+ ```
74
+ poetry run python cli.py path/to/your_file.py --html
75
+ ```
76
+
77
+ This generates `plan.json`, `plan.pseudo`, and if `--html` is used, `plan.html`.
78
+
79
+ ### Offline HTML (no internet)
80
+
81
+ `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.
82
+
83
+ Example (download once):
84
+
85
+ ```
86
+ wget https://d3js.org/d3.v5.min.js -O d3.v5.min.js
87
+ wget https://unpkg.com/dagre-d3@0.6.4/dist/dagre-d3.min.js -O dagre-d3.min.js
88
+ ```
89
+
90
+ Then update the script tags to `./d3.v5.min.js` and `./dagre-d3.min.js` and regenerate `plan.html`.
91
+
92
+ ## Tests
93
+
94
+ Run tests using the Makefile (prints visible with `-s`):
95
+
96
+ ```
97
+ make test
98
+ ```
99
+
100
+ ## Build
101
+
102
+ Build the package artifacts (wheel and sdist):
103
+
104
+ ```
105
+ make build
106
+ ```
107
+
108
+ ## Releasing to PyPI
109
+
110
+ This repo includes a GitHub Actions workflow that publishes to PyPI when you push a Git tag like `v0.1.1`.
111
+
112
+ 1) In GitHub repo settings, add a repository secret named `PYPI_API_TOKEN` with your PyPI token (format: `pypi-***`).
113
+
114
+ 2) Bump patch version, create tag, and push it (this triggers the workflow):
115
+
116
+ ```
117
+ make release
118
+ ```
119
+
120
+ Or do individual steps:
121
+
122
+ ```
123
+ make bump-patch
124
+ make tag
125
+ make push-tags
126
+ ```
127
+
128
+ You can also install from the local build with `pip install dist/*.whl`.
129
+
py2dag-0.1.2/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # py2dag
2
+ Convert Python function plans to a DAG (JSON, pseudo, optional SVG).
3
+
4
+ ## Install
5
+
6
+ - From PyPI (once published):
7
+
8
+ ```
9
+ pip install py2dag
10
+ ```
11
+
12
+ - From source using Makefile targets (recommended for development):
13
+
14
+ ```
15
+ make setup # create .venv and install deps (SVG extra by default)
16
+ ```
17
+
18
+ ## Dev Environment via Makefile
19
+
20
+ - Install Poetry (see poetry docs or `pipx install poetry`).
21
+ - From the repo root:
22
+
23
+ ```
24
+ poetry install
25
+ ```
26
+
27
+ Notes:
28
+ - HTML export uses Dagre via CDN (d3.js + dagre-d3). No local system deps, but an internet connection is required when opening `plan.html`. If you are offline or behind a firewall, the page will show a message; vendor the JS locally or open with internet access.
29
+ - Optional Graphviz SVG export (`--svg`) requires Graphviz system binaries (e.g., `brew install graphviz`).
30
+
31
+ ### Local Virtualenv (.venv)
32
+
33
+ - Create/update venv and install deps: `make setup`
34
+ - Open interactive shell inside venv: `make shell`
35
+ - `.venv/` is ignored by git.
36
+
37
+ ## Usage
38
+
39
+ - Run via Makefile (dev) — generates an interactive Dagre HTML graph:
40
+
41
+ ```
42
+ make run FILE=path/to/your_file.py ARGS=--html
43
+ ```
44
+
45
+ - Run the installed CLI (after `pip install py2dag`):
46
+
47
+ ```
48
+ py2dag path/to/your_file.py --html
49
+ ```
50
+
51
+
52
+ - Or directly with Python (inside venv):
53
+
54
+ ```
55
+ poetry run python cli.py path/to/your_file.py --html
56
+ ```
57
+
58
+ This generates `plan.json`, `plan.pseudo`, and if `--html` is used, `plan.html`.
59
+
60
+ ### Offline HTML (no internet)
61
+
62
+ `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.
63
+
64
+ Example (download once):
65
+
66
+ ```
67
+ wget https://d3js.org/d3.v5.min.js -O d3.v5.min.js
68
+ wget https://unpkg.com/dagre-d3@0.6.4/dist/dagre-d3.min.js -O dagre-d3.min.js
69
+ ```
70
+
71
+ Then update the script tags to `./d3.v5.min.js` and `./dagre-d3.min.js` and regenerate `plan.html`.
72
+
73
+ ## Tests
74
+
75
+ Run tests using the Makefile (prints visible with `-s`):
76
+
77
+ ```
78
+ make test
79
+ ```
80
+
81
+ ## Build
82
+
83
+ Build the package artifacts (wheel and sdist):
84
+
85
+ ```
86
+ make build
87
+ ```
88
+
89
+ ## Releasing to PyPI
90
+
91
+ This repo includes a GitHub Actions workflow that publishes to PyPI when you push a Git tag like `v0.1.1`.
92
+
93
+ 1) In GitHub repo settings, add a repository secret named `PYPI_API_TOKEN` with your PyPI token (format: `pypi-***`).
94
+
95
+ 2) Bump patch version, create tag, and push it (this triggers the workflow):
96
+
97
+ ```
98
+ make release
99
+ ```
100
+
101
+ Or do individual steps:
102
+
103
+ ```
104
+ make bump-patch
105
+ make tag
106
+ make push-tags
107
+ ```
108
+
109
+ You can also install from the local build with `pip install dist/*.whl`.
@@ -0,0 +1,14 @@
1
+ """py2dag package.
2
+
3
+ Public modules:
4
+ - parser: parse() and parse_file() from the DSL
5
+ - pseudo: generate() for pseudo-code
6
+ - export_svg: export() for svg rendering (optional dependency)
7
+ """
8
+
9
+ __all__ = [
10
+ "parser",
11
+ "pseudo",
12
+ "export_svg",
13
+ ]
14
+
@@ -0,0 +1,34 @@
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="plan", help="Function name to parse")
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()
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any, Dict
6
+
7
+
8
+ HTML_TEMPLATE = """<!doctype html>
9
+ <html lang="en">
10
+ <head>
11
+ <meta charset="utf-8">
12
+ <meta name="viewport" content="width=device-width, initial-scale=1">
13
+ <title>py2dag Plan</title>
14
+ <style>
15
+ body { font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Arial, sans-serif; margin: 0; padding: 0; }
16
+ header { padding: 10px 16px; background: #111; color: #eee; font-size: 14px; }
17
+ #container { padding: 12px; }
18
+ svg { width: 100%; height: 80vh; border-top: 1px solid #ddd; }
19
+ .node rect { stroke: #666; fill: #fff; rx: 4; ry: 4; }
20
+ .node.note rect { fill: #fff8dc; }
21
+ .edgePath path { stroke: #333; fill: none; stroke-width: 1.2px; }
22
+ </style>
23
+ <script src="https://d3js.org/d3.v5.min.js"></script>
24
+ <script src="https://unpkg.com/dagre-d3@0.6.4/dist/dagre-d3.min.js"></script>
25
+ </head>
26
+ <body>
27
+ <header>py2dag — Dagre graph</header>
28
+ <div id="container">
29
+ <svg><g/></svg>
30
+ </div>
31
+ <script>
32
+ const plan = __PLAN_JSON__;
33
+
34
+ function showMessage(msg) {
35
+ const el = document.getElementById('container');
36
+ el.innerHTML = '<div style="padding:12px;color:#b00;background:#fff3f3;border-top:1px solid #f0caca;">' +
37
+ msg + '</div>' +
38
+ '<pre style="margin:0;padding:12px;white-space:pre-wrap;">' +
39
+ (typeof plan === 'object' ? JSON.stringify(plan, null, 2) : '') + '</pre>';
40
+ }
41
+
42
+ if (typeof window.d3 === 'undefined' || typeof window.dagreD3 === 'undefined') {
43
+ showMessage('Failed to load Dagre assets (d3/dagre-d3). Check internet connectivity or vendor the JS locally.');
44
+ } else {
45
+ try {
46
+ const g = new dagreD3.graphlib.Graph({ multigraph: true })
47
+ .setGraph({ rankdir: 'LR', nodesep: 30, ranksep: 40 });
48
+ // Ensure edges have an object for labels/attrs to avoid TypeErrors
49
+ g.setDefaultEdgeLabel(() => ({}));
50
+
51
+ // Add op nodes
52
+ (plan.ops || []).forEach(op => {
53
+ g.setNode(op.id, { label: op.op, class: 'op', padding: 8 });
54
+ });
55
+
56
+ // Add output nodes and edges from source to output
57
+ (plan.outputs || []).forEach(out => {
58
+ const outId = `out:${out.as}`;
59
+ g.setNode(outId, { label: out.as, class: 'note', padding: 8 });
60
+ g.setEdge(out.from, outId);
61
+ });
62
+
63
+ // Add dependency edges between ops
64
+ (plan.ops || []).forEach(op => {
65
+ (op.deps || []).forEach(dep => {
66
+ g.setEdge(dep, op.id);
67
+ });
68
+ });
69
+
70
+ const svg = d3.select('svg');
71
+ const inner = svg.select('g');
72
+ const render = new dagreD3.render();
73
+ render(inner, g);
74
+
75
+ // Centering
76
+ const { width, height } = g.graph();
77
+ const svgWidth = document.querySelector('svg').clientWidth;
78
+ const xCenterOffset = (svgWidth - width) / 2;
79
+ inner.attr('transform', 'translate(' + Math.max(10, xCenterOffset) + ', 20)');
80
+ svg.attr('height', height + 40);
81
+ } catch (e) {
82
+ showMessage('Failed to render Dagre graph: ' + e);
83
+ }
84
+ }
85
+ </script>
86
+ </body>
87
+ </html>
88
+ """
89
+
90
+
91
+ def export(plan: Dict[str, Any], filename: str = "plan.html") -> str:
92
+ """Export the plan as an interactive HTML using Dagre (via dagre-d3).
93
+
94
+ Returns the written filename.
95
+ """
96
+ html = HTML_TEMPLATE.replace("__PLAN_JSON__", json.dumps(plan))
97
+ path = Path(filename)
98
+ path.write_text(html, encoding="utf-8")
99
+ return str(path)
@@ -0,0 +1,48 @@
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
+ for op in plan.get("ops", []):
26
+ graph.node(op["id"], label=op["op"])
27
+ for dep in op.get("deps", []):
28
+ graph.edge(dep, op["id"])
29
+ for out in plan.get("outputs", []):
30
+ out_id = f"out:{out['as']}"
31
+ graph.node(out_id, label=out['as'], shape="note")
32
+ graph.edge(out["from"], out_id)
33
+
34
+ try:
35
+ # Use pipe() to obtain SVG bytes directly so we only write the
36
+ # destination file on successful rendering.
37
+ svg_bytes = graph.pipe(format="svg")
38
+ except Exception as e: # pragma: no cover - depends on local system
39
+ if ExecutableNotFound is not None and isinstance(e, ExecutableNotFound):
40
+ raise RuntimeError(
41
+ "Graphviz 'dot' executable not found. Install Graphviz (e.g., 'brew install graphviz' on macOS) "
42
+ "or run without --svg."
43
+ ) from e
44
+ raise
45
+
46
+ with open(filename, "wb") as f:
47
+ f.write(svg_bytes)
48
+ return filename
@@ -0,0 +1,173 @@
1
+ import ast
2
+ import json
3
+ import re
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ VALID_NAME_RE = re.compile(r'^[a-z_][a-z0-9_]{0,63}$')
7
+
8
+
9
+ class DSLParseError(Exception):
10
+ """Raised when the mini-DSL constraints are violated."""
11
+
12
+
13
+ def _literal(node: ast.AST) -> Any:
14
+ """Return a Python literal from an AST node or raise DSLParseError."""
15
+ if isinstance(node, ast.Constant):
16
+ return node.value
17
+ if isinstance(node, (ast.List, ast.Tuple)):
18
+ return [_literal(elt) for elt in node.elts]
19
+ if isinstance(node, ast.Dict):
20
+ return {_literal(k): _literal(v) for k, v in zip(node.keys, node.values)}
21
+ raise DSLParseError("Keyword argument values must be JSON-serialisable literals")
22
+
23
+
24
+ def _get_call_name(func: ast.AST) -> str:
25
+ if isinstance(func, ast.Name):
26
+ return func.id
27
+ if isinstance(func, ast.Attribute):
28
+ parts: List[str] = []
29
+ while isinstance(func, ast.Attribute):
30
+ parts.append(func.attr)
31
+ func = func.value
32
+ if isinstance(func, ast.Name):
33
+ parts.append(func.id)
34
+ return ".".join(reversed(parts))
35
+ raise DSLParseError("Only simple or attribute names are allowed for operations")
36
+
37
+
38
+ def parse(source: str, function_name: str = "plan") -> Dict[str, Any]:
39
+ if len(source) > 20_000:
40
+ raise DSLParseError("Source too large")
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
+ })
109
+ 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})
142
+ 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]:
171
+ with open(filename, "r", encoding="utf-8") as f:
172
+ src = f.read()
173
+ return parse(src, function_name=function_name)
@@ -0,0 +1,28 @@
1
+ import json
2
+ from typing import Dict, Any
3
+
4
+
5
+ def generate(plan: Dict[str, Any]) -> str:
6
+ """Generate human readable pseudo-code from a plan dict."""
7
+ lines = []
8
+ settings = plan.get("settings")
9
+ if settings:
10
+ args = ", ".join(f"{k}={json.dumps(v)}" for k, v in settings.items())
11
+ lines.append(f"settings({args})")
12
+ lines.append("")
13
+ for op in plan.get("ops", []):
14
+ deps = op.get("deps", [])
15
+ kw = op.get("args", {})
16
+ parts = []
17
+ if deps:
18
+ parts.extend(deps)
19
+ if kw:
20
+ parts.extend(f"{k}={json.dumps(v)}" for k, v in kw.items())
21
+ arg_str = ", ".join(parts)
22
+ lines.append(f"{op['id']} = {op['op']}({arg_str})")
23
+ if plan.get("ops"):
24
+ lines.append("")
25
+ for out in plan.get("outputs", []):
26
+ lines.append(f"output({out['from']}, as={json.dumps(out['as'])})")
27
+ return "\n".join(lines).rstrip() + "\n"
28
+
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["poetry-core>=1.8.0"]
3
+ build-backend = "poetry.core.masonry.api"
4
+
5
+ [tool.poetry]
6
+ name = "py2dag"
7
+ version = "0.1.2"
8
+ description = "Convert Python function plans to DAG (JSON, pseudo, optional SVG)."
9
+ authors = ["rvergis"]
10
+ license = "MIT"
11
+ readme = "README.md"
12
+
13
+ [tool.poetry.dependencies]
14
+ python = "^3.8"
15
+ graphviz = { version = "^0.20.3", optional = true }
16
+
17
+ [tool.poetry.extras]
18
+ svg = ["graphviz"]
19
+
20
+ [tool.poetry.group.dev.dependencies]
21
+ pytest = "^8.0"
22
+
23
+ [tool.poetry.scripts]
24
+ py2dag = "py2dag.cli:main"
25
+
26
+ [tool.pytest.ini_options]
27
+ testpaths = ["tests"]