qgraph 0.1.2__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.
qgraph/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.2"
qgraph/cli.py ADDED
@@ -0,0 +1,347 @@
1
+ import click
2
+
3
+ from qgraph import __version__
4
+
5
+
6
+ @click.group()
7
+ @click.version_option(version=__version__, prog_name="qgraph")
8
+ def main():
9
+ """QGraph - Visual Pipeline Editor for Workflow Orchestration"""
10
+ pass
11
+
12
+
13
+ @main.command()
14
+ @click.option("--port", default=9800, help="Port to run the server on")
15
+ @click.option("--host", default="0.0.0.0", help="Host to bind the server to")
16
+ def serve(port: int, host: str):
17
+ """Start the QGraph web UI server."""
18
+ import uvicorn
19
+
20
+ from qgraph.server.app import create_app
21
+
22
+ app = create_app()
23
+ click.echo(f"Starting QGraph server at http://{host}:{port}")
24
+ uvicorn.run(app, host=host, port=port)
25
+
26
+
27
+ @main.command("list")
28
+ def list_graphs():
29
+ """List all saved graphs."""
30
+ from qgraph.core.storage import GraphStorage
31
+
32
+ storage = GraphStorage()
33
+ graphs = storage.list_graphs()
34
+ if not graphs:
35
+ click.echo("No graphs found. Create one with: qgraph create <name>")
36
+ return
37
+ from rich.console import Console
38
+ from rich.table import Table
39
+
40
+ console = Console()
41
+ table = Table(title="QGraph Pipelines")
42
+ table.add_column("Name", style="cyan")
43
+ table.add_column("Created", style="green")
44
+ table.add_column("Updated", style="yellow")
45
+ table.add_column("Nodes", justify="right")
46
+
47
+ for g in graphs:
48
+ table.add_row(g["name"], g["created_at"], g["updated_at"], str(g["node_count"]))
49
+
50
+ console.print(table)
51
+
52
+
53
+ @main.command()
54
+ @click.argument("name")
55
+ def create(name: str):
56
+ """Create a new graph and open it in the web UI."""
57
+ from qgraph.core.storage import GraphStorage
58
+
59
+ storage = GraphStorage()
60
+ storage.create_graph(name)
61
+ click.echo(f"Created graph: {name}")
62
+ click.echo(f"Open in browser: http://127.0.0.1:9800/editor/{name}")
63
+
64
+
65
+ @main.command()
66
+ @click.argument("name")
67
+ def edit(name: str):
68
+ """Open a graph in the web UI for editing."""
69
+ click.echo(f"Open in browser: http://127.0.0.1:9800/editor/{name}")
70
+
71
+
72
+ @main.command()
73
+ @click.argument("name")
74
+ @click.option("-d", "--detach", is_flag=True, help="Run in background")
75
+ def run(name: str, detach: bool):
76
+ """Execute a graph pipeline."""
77
+ import asyncio
78
+ import sys
79
+
80
+ from rich.console import Console
81
+
82
+ from qgraph.core.models import NodeStatus
83
+ from qgraph.core.storage import GraphStorage
84
+ from qgraph.engine.executor import PipelineExecutor
85
+
86
+ console = Console()
87
+ storage = GraphStorage()
88
+ graph_data = storage.load_graph(name)
89
+ if graph_data is None:
90
+ console.print(f"[red]Graph '{name}' not found.[/red]")
91
+ raise SystemExit(1)
92
+
93
+ console.print(f"[bold cyan]Running graph:[/bold cyan] {name}")
94
+ console.print()
95
+
96
+ all_logs: list[str] = []
97
+
98
+ async def on_log(_gn: str, node_id: str, message: str):
99
+ console.print(f" {message}")
100
+ all_logs.append(message)
101
+
102
+ async def on_status(_gn: str, node_id: str, status: str):
103
+ style = {
104
+ "running": "bold yellow",
105
+ "success": "bold green",
106
+ "failed": "bold red",
107
+ "queued": "dim",
108
+ "skipped": "dim yellow",
109
+ }.get(status, "")
110
+ icon = {
111
+ "running": "⟳",
112
+ "success": "✓",
113
+ "failed": "✗",
114
+ "queued": "◦",
115
+ "skipped": "⊘",
116
+ }.get(status, "○")
117
+ console.print(f" [{style}]{icon} [{node_id}] → {status}[/{style}]")
118
+
119
+ executor = PipelineExecutor(on_log=on_log, on_status=on_status)
120
+ results = asyncio.run(executor.execute(graph_data))
121
+
122
+ console.print()
123
+ failed = sum(1 for r in results.values() if r.status == NodeStatus.FAILED)
124
+ skipped = sum(1 for r in results.values() if r.status == NodeStatus.SKIPPED)
125
+ succeeded = sum(1 for r in results.values() if r.status == NodeStatus.SUCCESS)
126
+
127
+ if failed > 0:
128
+ parts = [f"{succeeded} succeeded", f"{failed} failed"]
129
+ if skipped > 0:
130
+ parts.append(f"{skipped} skipped")
131
+ msg = ", ".join(parts)
132
+ console.print(f"[bold red]Pipeline finished with errors: {msg}[/bold red]")
133
+ sys.exit(1)
134
+ else:
135
+ console.print(
136
+ f"[bold green]Pipeline completed successfully: "
137
+ f"{succeeded} nodes[/bold green]"
138
+ )
139
+
140
+
141
+ @main.command()
142
+ @click.option("-a", "--all", "show_all", is_flag=True, help="Show all executions including finished")
143
+ def ps(show_all: bool):
144
+ """List running graph executions (requires server running)."""
145
+ import json
146
+ import urllib.request
147
+
148
+ url = "http://127.0.0.1:9800/api/runs"
149
+ if show_all:
150
+ url += "?all=true"
151
+
152
+ try:
153
+ req = urllib.request.Request(url)
154
+ with urllib.request.urlopen(req, timeout=3) as resp:
155
+ runs = json.loads(resp.read().decode())
156
+ except Exception:
157
+ if show_all:
158
+ from qgraph.engine.run_manager import RunManager
159
+ saved = RunManager.list_saved_logs()
160
+ if not saved:
161
+ click.echo("No executions found.")
162
+ return
163
+ from rich.console import Console
164
+ from rich.table import Table
165
+
166
+ console = Console()
167
+ table = Table(title="Saved Executions (server not running)")
168
+ table.add_column("Run ID", style="cyan")
169
+ table.add_column("Graph", style="green")
170
+ table.add_column("Status", style="yellow")
171
+ table.add_column("Logs", justify="right")
172
+ for e in saved[:20]:
173
+ sc = "green" if e["status"] == "completed" else "red"
174
+ st = f"[{sc}]{e['status']}[/{sc}]"
175
+ table.add_row(
176
+ e["run_id"], e["graph_name"], st, str(e["log_count"])
177
+ )
178
+ console.print(table)
179
+ return
180
+ click.echo("Could not connect to QGraph server. Is it running? (qgraph serve)")
181
+ return
182
+
183
+ if not runs:
184
+ click.echo("No running executions." if not show_all else "No executions found.")
185
+ return
186
+
187
+ from rich.console import Console
188
+ from rich.table import Table
189
+
190
+ console = Console()
191
+ title = "All Executions" if show_all else "Running Executions"
192
+ table = Table(title=title)
193
+ table.add_column("Run ID", style="cyan")
194
+ table.add_column("Graph", style="green")
195
+ table.add_column("Status", style="yellow")
196
+ table.add_column("Elapsed", justify="right")
197
+ table.add_column("Current Node", style="magenta")
198
+
199
+ for r in runs:
200
+ elapsed = f"{r['elapsed_seconds']}s"
201
+ status_style = {
202
+ "running": "[bold yellow]",
203
+ "completed": "[green]",
204
+ "stopped": "[red]",
205
+ }.get(r["status"], "")
206
+ status_end = "[/]" if status_style else ""
207
+ table.add_row(
208
+ r["run_id"],
209
+ r["graph_name"],
210
+ f"{status_style}{r['status']}{status_end}",
211
+ elapsed,
212
+ r.get("current_node") or "-",
213
+ )
214
+
215
+ console.print(table)
216
+
217
+
218
+ @main.command()
219
+ @click.argument("run_id")
220
+ def logs(run_id: str):
221
+ """Show logs for a run. Tries server first, falls back to saved logs."""
222
+ import json
223
+ import urllib.request
224
+
225
+ from rich.console import Console
226
+
227
+ console = Console()
228
+ log_data = None
229
+
230
+ try:
231
+ req = urllib.request.Request(f"http://127.0.0.1:9800/api/runs/{run_id}/logs")
232
+ with urllib.request.urlopen(req, timeout=3) as resp:
233
+ log_data = json.loads(resp.read().decode())
234
+ except Exception:
235
+ pass
236
+
237
+ if log_data is None:
238
+ from qgraph.engine.run_manager import RunManager
239
+ log_data = RunManager.load_log(run_id)
240
+
241
+ if log_data is None:
242
+ console.print(f"[red]Logs for run '{run_id}' not found.[/red]")
243
+ console.print()
244
+ console.print("Available runs:")
245
+ from qgraph.engine.run_manager import RunManager as RM
246
+ for entry in RM.list_saved_logs()[:10]:
247
+ status_color = "green" if entry["status"] == "completed" else "red"
248
+ console.print(
249
+ f" [{status_color}]{entry['status']:10}[/{status_color}] "
250
+ f"[cyan]{entry['run_id']}[/cyan] {entry['graph_name']}"
251
+ )
252
+ raise SystemExit(1)
253
+
254
+ console.print(f"[bold cyan]Run:[/bold cyan] {log_data['run_id']}")
255
+ console.print(f"[bold cyan]Graph:[/bold cyan] {log_data['graph_name']}")
256
+ console.print(f"[bold cyan]Status:[/bold cyan] {log_data['status']}")
257
+ console.print(f"[bold cyan]Started:[/bold cyan] {log_data.get('started_at', '')}")
258
+ console.print(f"[bold cyan]Finished:[/bold cyan] {log_data.get('finished_at', '-')}")
259
+ console.print()
260
+
261
+ for entry in log_data.get("logs", []):
262
+ if isinstance(entry, dict):
263
+ msg = entry.get("message", "")
264
+ node_id = entry.get("node_id", "")[:12]
265
+ line = f" [dim][{node_id}][/dim] {msg}"
266
+ else:
267
+ msg = str(entry)
268
+ line = f" {msg}"
269
+ is_err = "Failed" in msg or "ERROR" in msg or "[stderr]" in msg
270
+ is_ok = "Completed" in msg or "successfully" in msg
271
+ if is_err:
272
+ console.print(f"[red]{line}[/red]")
273
+ elif is_ok:
274
+ console.print(f"[green]{line}[/green]")
275
+ else:
276
+ console.print(line)
277
+
278
+
279
+ @main.command()
280
+ @click.argument("name")
281
+ def delete(name: str):
282
+ """Delete a graph."""
283
+ from qgraph.core.storage import GraphStorage
284
+
285
+ storage = GraphStorage()
286
+ storage.delete_graph(name)
287
+ click.echo(f"Deleted graph: {name}")
288
+
289
+
290
+ @main.command("export")
291
+ @click.argument("name")
292
+ @click.option("-o", "--output", default=None, help="Output file path")
293
+ def export_graph(name: str, output: str | None):
294
+ """Export a graph to a JSON file."""
295
+ import json
296
+ from pathlib import Path
297
+
298
+ from qgraph.core.storage import GraphStorage
299
+
300
+ storage = GraphStorage()
301
+ graph_data = storage.load_graph(name)
302
+ if graph_data is None:
303
+ click.echo(f"Graph '{name}' not found.", err=True)
304
+ raise SystemExit(1)
305
+
306
+ output_path = Path(output) if output else Path(f"{name}.json")
307
+ output_path.write_text(json.dumps(graph_data, indent=2, ensure_ascii=False), encoding="utf-8")
308
+ click.echo(f"Exported graph to: {output_path}")
309
+
310
+
311
+ @main.command("import")
312
+ @click.argument("file_path", type=click.Path(exists=True))
313
+ def import_graph(file_path: str):
314
+ """Import a graph from a JSON file."""
315
+ import json
316
+ from pathlib import Path
317
+
318
+ from qgraph.core.storage import GraphStorage
319
+
320
+ data = json.loads(Path(file_path).read_text(encoding="utf-8"))
321
+ storage = GraphStorage()
322
+ name = data.get("name", Path(file_path).stem)
323
+ storage.save_graph(name, data)
324
+ click.echo(f"Imported graph: {name}")
325
+
326
+
327
+ @main.command()
328
+ @click.argument("run_id")
329
+ def stop(run_id: str):
330
+ """Stop a running graph execution."""
331
+ import json
332
+ import urllib.request
333
+
334
+ try:
335
+ req = urllib.request.Request(
336
+ f"http://127.0.0.1:9800/api/runs/{run_id}/stop",
337
+ method="POST",
338
+ )
339
+ with urllib.request.urlopen(req, timeout=3) as resp:
340
+ result = json.loads(resp.read().decode())
341
+ click.echo(f"Stopped: {result.get('status', 'ok')}")
342
+ except Exception as e:
343
+ click.echo(f"Failed to stop run: {e}")
344
+
345
+
346
+ if __name__ == "__main__":
347
+ main()
File without changes
qgraph/core/models.py ADDED
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ import enum
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class NodeType(str, enum.Enum):
10
+ SHELL_COMMAND = "shell_command"
11
+ PYTHON_SCRIPT = "python_script"
12
+ PYTHON_FUNCTION = "python_function"
13
+ INPUT = "input"
14
+ OUTPUT = "output"
15
+
16
+
17
+ class NodeStatus(str, enum.Enum):
18
+ IDLE = "idle"
19
+ QUEUED = "queued"
20
+ RUNNING = "running"
21
+ SUCCESS = "success"
22
+ FAILED = "failed"
23
+ SKIPPED = "skipped"
24
+ CANCELLED = "cancelled"
25
+
26
+
27
+ class Position(BaseModel):
28
+ x: float = 0.0
29
+ y: float = 0.0
30
+
31
+
32
+ class Port(BaseModel):
33
+ id: str
34
+ name: str
35
+ data_type: str = "any"
36
+
37
+
38
+ class NodeConfig(BaseModel):
39
+ command: str | None = None
40
+ working_dir: str | None = None
41
+ env_vars: dict[str, str] = Field(default_factory=dict)
42
+ script_path: str | None = None
43
+ args: list[str] = Field(default_factory=list)
44
+ python_path: str | None = None
45
+ module_path: str | None = None
46
+ function_name: str | None = None
47
+ kwargs: dict[str, Any] = Field(default_factory=dict)
48
+ parameters: dict[str, Any] = Field(default_factory=dict)
49
+
50
+
51
+ class Node(BaseModel):
52
+ id: str
53
+ name: str
54
+ node_type: NodeType
55
+ position: Position = Field(default_factory=Position)
56
+ inputs: list[Port] = Field(default_factory=list)
57
+ outputs: list[Port] = Field(default_factory=list)
58
+ config: NodeConfig = Field(default_factory=NodeConfig)
59
+ status: NodeStatus = NodeStatus.IDLE
60
+
61
+
62
+ class Edge(BaseModel):
63
+ id: str
64
+ source: str
65
+ source_port: str
66
+ target: str
67
+ target_port: str
68
+
69
+
70
+ class Graph(BaseModel):
71
+ name: str
72
+ description: str = ""
73
+ nodes: list[Node] = Field(default_factory=list)
74
+ edges: list[Edge] = Field(default_factory=list)
75
+ created_at: str = ""
76
+ updated_at: str = ""
77
+
78
+
79
+ class ExecutionResult(BaseModel):
80
+ node_id: str
81
+ status: NodeStatus
82
+ output: dict[str, Any] = Field(default_factory=dict)
83
+ logs: list[str] = Field(default_factory=list)
84
+ error: str | None = None
85
+ duration_ms: float = 0.0
qgraph/core/storage.py ADDED
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+
9
+ def get_qgraph_home() -> Path:
10
+ home = Path.home() / ".qgraph"
11
+ home.mkdir(parents=True, exist_ok=True)
12
+ return home
13
+
14
+
15
+ def get_graphs_dir() -> Path:
16
+ graphs_dir = get_qgraph_home() / "graphs"
17
+ graphs_dir.mkdir(parents=True, exist_ok=True)
18
+ return graphs_dir
19
+
20
+
21
+ def get_logs_dir() -> Path:
22
+ logs_dir = get_qgraph_home() / "logs"
23
+ logs_dir.mkdir(parents=True, exist_ok=True)
24
+ return logs_dir
25
+
26
+
27
+ class GraphStorage:
28
+ def __init__(self):
29
+ self.graphs_dir = get_graphs_dir()
30
+
31
+ def _graph_path(self, name: str) -> Path:
32
+ return self.graphs_dir / f"{name}.json"
33
+
34
+ def list_graphs(self) -> list[dict[str, Any]]:
35
+ results = []
36
+ for path in sorted(self.graphs_dir.glob("*.json")):
37
+ try:
38
+ data = json.loads(path.read_text(encoding="utf-8"))
39
+ results.append({
40
+ "name": data.get("name", path.stem),
41
+ "created_at": data.get("created_at", ""),
42
+ "updated_at": data.get("updated_at", ""),
43
+ "node_count": len(data.get("nodes", [])),
44
+ })
45
+ except (json.JSONDecodeError, OSError):
46
+ continue
47
+ return results
48
+
49
+ def create_graph(self, name: str) -> dict[str, Any]:
50
+ path = self._graph_path(name)
51
+ if path.exists():
52
+ raise FileExistsError(f"Graph '{name}' already exists")
53
+
54
+ now = datetime.now(timezone.utc).isoformat()
55
+ data: dict[str, Any] = {
56
+ "name": name,
57
+ "description": "",
58
+ "nodes": [],
59
+ "edges": [],
60
+ "created_at": now,
61
+ "updated_at": now,
62
+ }
63
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
64
+ return data
65
+
66
+ def load_graph(self, name: str) -> dict[str, Any] | None:
67
+ path = self._graph_path(name)
68
+ if not path.exists():
69
+ return None
70
+ return json.loads(path.read_text(encoding="utf-8"))
71
+
72
+ def save_graph(self, name: str, data: dict[str, Any]) -> None:
73
+ data["name"] = name
74
+ data["updated_at"] = datetime.now(timezone.utc).isoformat()
75
+ if "created_at" not in data or not data["created_at"]:
76
+ data["created_at"] = data["updated_at"]
77
+ path = self._graph_path(name)
78
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
79
+
80
+ def delete_graph(self, name: str) -> bool:
81
+ path = self._graph_path(name)
82
+ if not path.exists():
83
+ return False
84
+ try:
85
+ path.unlink()
86
+ except OSError:
87
+ pass
88
+ if path.exists():
89
+ import os
90
+ try:
91
+ os.remove(str(path))
92
+ except OSError:
93
+ pass
94
+ if path.exists():
95
+ raise OSError(f"Failed to delete file: {path}")
96
+ return True
97
+
98
+ def graph_exists(self, name: str) -> bool:
99
+ return self._graph_path(name).exists()
File without changes