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 +1 -0
- qgraph/cli.py +347 -0
- qgraph/core/__init__.py +0 -0
- qgraph/core/models.py +85 -0
- qgraph/core/storage.py +99 -0
- qgraph/engine/__init__.py +0 -0
- qgraph/engine/executor.py +338 -0
- qgraph/engine/run_manager.py +197 -0
- qgraph/server/__init__.py +0 -0
- qgraph/server/api.py +232 -0
- qgraph/server/app.py +36 -0
- qgraph/server/ws.py +113 -0
- qgraph/web/__init__.py +0 -0
- qgraph/web/dist/assets/index-B20Fv_Jk.css +1 -0
- qgraph/web/dist/assets/index-DyZ3ujxj.js +71 -0
- qgraph/web/dist/index.html +13 -0
- qgraph-0.1.2.dist-info/METADATA +490 -0
- qgraph-0.1.2.dist-info/RECORD +20 -0
- qgraph-0.1.2.dist-info/WHEEL +4 -0
- qgraph-0.1.2.dist-info/entry_points.txt +2 -0
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()
|
qgraph/core/__init__.py
ADDED
|
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
|