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 +21 -0
- py2dag-0.1.2/PKG-INFO +129 -0
- py2dag-0.1.2/README.md +109 -0
- py2dag-0.1.2/py2dag/__init__.py +14 -0
- py2dag-0.1.2/py2dag/cli.py +34 -0
- py2dag-0.1.2/py2dag/export_dagre.py +99 -0
- py2dag-0.1.2/py2dag/export_svg.py +48 -0
- py2dag-0.1.2/py2dag/parser.py +173 -0
- py2dag-0.1.2/py2dag/pseudo.py +28 -0
- py2dag-0.1.2/pyproject.toml +27 -0
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"]
|