graph-ted-db 0.1.0__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.
@@ -0,0 +1,23 @@
1
+ """Local property-graph storage for Python: open a folder and query it in-process (import graph_ted_db)."""
2
+
3
+ from graph_ted_db.engine import CypherError
4
+ from graph_ted_db.store import (
5
+ EdgeRecord,
6
+ GraphStore,
7
+ NodeRecord,
8
+ Tombstone,
9
+ VectorRecord,
10
+ init_graph,
11
+ )
12
+
13
+ __version__ = "0.1.0"
14
+ __all__ = [
15
+ "CypherError",
16
+ "EdgeRecord",
17
+ "GraphStore",
18
+ "NodeRecord",
19
+ "Tombstone",
20
+ "VectorRecord",
21
+ "__version__",
22
+ "init_graph",
23
+ ]
@@ -0,0 +1,3 @@
1
+ from graph_ted_db.cli import main
2
+
3
+ raise SystemExit(main())
graph_ted_db/cli.py ADDED
@@ -0,0 +1,367 @@
1
+ """Command-line entry: `graph-ted-db` (alias `graphted-db`)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import errno
7
+ import json
8
+ import os
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from graph_ted_db import __version__
14
+ from graph_ted_db.engine import CypherError
15
+ from graph_ted_db.store import GraphFormatError, GraphStore, init_graph, load_graph_meta
16
+
17
+ UPDATED_BY_ENV = "GRAPH_TED_DB_UPDATED_BY"
18
+
19
+
20
+ def _who(explicit: str | None) -> str:
21
+ """Author string for written records: --by, else $GRAPH_TED_DB_UPDATED_BY, else empty.
22
+
23
+ Never the OS login name: records travel with the folder when it is synced.
24
+ """
25
+ if explicit:
26
+ return explicit
27
+ return os.environ.get(UPDATED_BY_ENV, "")
28
+
29
+
30
+ def _parse_prop(raw: str) -> tuple[str, Any]:
31
+ key, sep, value = raw.partition("=")
32
+ if not key or sep != "=":
33
+ raise argparse.ArgumentTypeError(f"expected key=value, got {raw!r}")
34
+ try:
35
+ return key, json.loads(value)
36
+ except json.JSONDecodeError:
37
+ return key, value
38
+
39
+
40
+ def _store(path: Path) -> GraphStore:
41
+ root = path.expanduser().resolve()
42
+ if not (root / "graph.json").is_file():
43
+ raise GraphFormatError(f"no graph at {path}; create one with `graph-ted-db init {path}`")
44
+ return GraphStore.open(root)
45
+
46
+
47
+ def main(argv: list[str] | None = None) -> int:
48
+ parser = argparse.ArgumentParser(
49
+ prog="graph-ted-db",
50
+ description="Local property-graph storage. Open a folder and query it in-process; no database server for the default path.",
51
+ epilog="The graphted-db command is the same entry point.",
52
+ )
53
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
54
+ sub = parser.add_subparsers(dest="command", required=True)
55
+
56
+ init_p = sub.add_parser("init", help="create an empty graph folder")
57
+ init_p.add_argument("path", type=Path)
58
+ init_p.add_argument("--name", default="graph")
59
+ init_p.add_argument("--exist-ok", action="store_true")
60
+
61
+ info_p = sub.add_parser("info", help="print graph.json for a folder")
62
+ info_p.add_argument(
63
+ "--check",
64
+ action="store_true",
65
+ help="also open the store and print problem counts (skipped lines, files still syncing)",
66
+ )
67
+ info_p.add_argument("path", type=Path)
68
+
69
+ put_n = sub.add_parser("put-node", help="create or overwrite a node (LWW)")
70
+ put_n.add_argument("path", type=Path)
71
+ put_n.add_argument("--id", dest="record_id", default=None)
72
+ put_n.add_argument("--label", action="append", default=[])
73
+ put_n.add_argument("--prop", action="append", default=[], type=_parse_prop)
74
+ put_n.add_argument(
75
+ "--by",
76
+ "--updated-by",
77
+ dest="updated_by",
78
+ default=None,
79
+ help="author stored on the record (default: $GRAPH_TED_DB_UPDATED_BY, else empty)",
80
+ )
81
+
82
+ get_n = sub.add_parser("get-node", help="print one node as JSON")
83
+ get_n.add_argument("path", type=Path)
84
+ get_n.add_argument("record_id")
85
+
86
+ ls_n = sub.add_parser("ls-nodes", help="print live nodes as JSON lines")
87
+ ls_n.add_argument("path", type=Path)
88
+
89
+ del_n = sub.add_parser("delete-node", help="tombstone a node")
90
+ del_n.add_argument("path", type=Path)
91
+ del_n.add_argument("record_id")
92
+ del_n.add_argument(
93
+ "--by",
94
+ "--updated-by",
95
+ dest="updated_by",
96
+ default=None,
97
+ help="author stored on the record (default: $GRAPH_TED_DB_UPDATED_BY, else empty)",
98
+ )
99
+
100
+ put_e = sub.add_parser("put-edge", help="create or overwrite an edge (LWW)")
101
+ put_e.add_argument("path", type=Path)
102
+ put_e.add_argument("--type", required=True, dest="edge_type")
103
+ put_e.add_argument("--from", required=True, dest="from_id")
104
+ put_e.add_argument("--to", required=True, dest="to_id")
105
+ put_e.add_argument("--id", dest="record_id", default=None)
106
+ put_e.add_argument("--prop", action="append", default=[], type=_parse_prop)
107
+ put_e.add_argument(
108
+ "--by",
109
+ "--updated-by",
110
+ dest="updated_by",
111
+ default=None,
112
+ help="author stored on the record (default: $GRAPH_TED_DB_UPDATED_BY, else empty)",
113
+ )
114
+
115
+ get_e = sub.add_parser("get-edge", help="print one edge as JSON")
116
+ get_e.add_argument("path", type=Path)
117
+ get_e.add_argument("record_id")
118
+
119
+ ls_e = sub.add_parser("ls-edges", help="print live edges as JSON lines")
120
+ ls_e.add_argument("path", type=Path)
121
+
122
+ del_e = sub.add_parser("delete-edge", help="tombstone an edge")
123
+ del_e.add_argument("path", type=Path)
124
+ del_e.add_argument("record_id")
125
+ del_e.add_argument(
126
+ "--by",
127
+ "--updated-by",
128
+ dest="updated_by",
129
+ default=None,
130
+ help="author stored on the record (default: $GRAPH_TED_DB_UPDATED_BY, else empty)",
131
+ )
132
+
133
+ compact_p = sub.add_parser(
134
+ "compact",
135
+ help="rewrite canonical shards to LWW winners (keeps conflict copies)",
136
+ )
137
+ compact_p.add_argument("path", type=Path)
138
+
139
+ export_p = sub.add_parser(
140
+ "export", help="write the current state (live records) to one JSONL file, for backup"
141
+ )
142
+ export_p.add_argument("path", type=Path)
143
+ export_p.add_argument("out", type=Path, help="export file (outside the graph folder)")
144
+
145
+ import_p = sub.add_parser("import", help="create a new graph folder from an export file")
146
+ import_p.add_argument("export_file", type=Path)
147
+ import_p.add_argument("dest", type=Path, help="new graph folder (must not exist yet)")
148
+ import_p.add_argument("--name", default=None)
149
+
150
+ doctor_p = sub.add_parser(
151
+ "doctor",
152
+ help="repair torn JSONL, rebuild labels, report dangling edges (--fix tombstones them)",
153
+ description=(
154
+ "Repair torn JSONL lines, remove leftover tmp files, rebuild labels.json, and report "
155
+ "dangling edges (an endpoint is not a live node). Dangling edges are only reported "
156
+ "unless --fix is given."
157
+ ),
158
+ )
159
+ doctor_p.add_argument("path", type=Path)
160
+ doctor_p.add_argument(
161
+ "--fix",
162
+ action="store_true",
163
+ help=(
164
+ "tombstone dangling edges. Permanent, and it syncs to every device: in a synced "
165
+ "folder, wait until sync has finished, because an edge can arrive before its nodes"
166
+ ),
167
+ )
168
+
169
+ cypher_p = sub.add_parser(
170
+ "cypher", help="run an openCypher query (supported subset; see docs/cypher.md)"
171
+ )
172
+ cypher_p.add_argument("path", type=Path)
173
+ cypher_p.add_argument("query", nargs="?", default=None, help="query; omit to read stdin")
174
+ cypher_p.add_argument(
175
+ "--params",
176
+ default="{}",
177
+ help="JSON object of query parameters",
178
+ )
179
+ cypher_p.add_argument(
180
+ "--by",
181
+ "--updated-by",
182
+ dest="updated_by",
183
+ default=None,
184
+ help='author stored on written records (default: $GRAPH_TED_DB_UPDATED_BY, else "cypher")',
185
+ )
186
+
187
+ serve_p = sub.add_parser(
188
+ "serve",
189
+ help="localhost HTTP daemon (POST /cypher, GET /health); see docs/http.md",
190
+ )
191
+ serve_p.add_argument("path", type=Path, help="graph folder to serve")
192
+ serve_p.add_argument("--host", default=None, help="bind address (default 127.0.0.1)")
193
+ serve_p.add_argument("--port", type=int, default=None, help="TCP port (default 8099)")
194
+ serve_p.add_argument(
195
+ "--token",
196
+ default=None,
197
+ help="HTTP token (or GRAPH_TED_DB_TOKEN); required when --host is not loopback",
198
+ )
199
+ serve_p.add_argument(
200
+ "--max-records",
201
+ type=int,
202
+ default=None,
203
+ help="truncate query results after this many rows (default 500)",
204
+ )
205
+
206
+ args, extra = parser.parse_known_args(argv)
207
+ # Python < 3.12 argparse binds the optional `query` positional before it
208
+ # sees later options, so `cypher PATH --params P QUERY` leaves QUERY over.
209
+ if getattr(args, "command", None) == "cypher" and args.query is None and len(extra) == 1:
210
+ args.query, extra = extra[0], []
211
+ if extra:
212
+ parser.error(f"unrecognized arguments: {' '.join(extra)}")
213
+ try:
214
+ return _dispatch(args)
215
+ except (
216
+ OSError,
217
+ RuntimeError,
218
+ GraphFormatError,
219
+ FileExistsError,
220
+ ValueError,
221
+ CypherError,
222
+ json.JSONDecodeError,
223
+ ) as exc:
224
+ print(f"graph-ted-db: {exc}", file=sys.stderr)
225
+ return 1
226
+
227
+
228
+ def _dispatch(args: argparse.Namespace) -> int:
229
+ if args.command == "init":
230
+ meta = init_graph(args.path, name=args.name, exist_ok=args.exist_ok)
231
+ print(f"initialized {args.path} id={meta.id} name={meta.name}")
232
+ return 0
233
+ if args.command == "info":
234
+ meta = load_graph_meta(args.path)
235
+ print(
236
+ f"{meta.name} id={meta.id} format={meta.format} "
237
+ f"v{meta.format_version} created_at={meta.created_at}"
238
+ )
239
+ if args.check:
240
+ store = _store(args.path)
241
+ problems = store.problems()
242
+ print(
243
+ f"writer={store.writer_id} writers={len(store.writers())} "
244
+ + " ".join(f"{k}={v}" for k, v in problems.items())
245
+ )
246
+ return 1 if any(problems.values()) else 0
247
+ return 0
248
+ if args.command == "import":
249
+ from graph_ted_db.store import import_export
250
+
251
+ imported = import_export(args.export_file, args.dest, name=args.name)
252
+ print(f"imported into {args.dest} id={imported.meta.id}")
253
+ return 0
254
+
255
+ store = _store(args.path)
256
+ if args.command == "export":
257
+ counts = store.export(args.out)
258
+ print(" ".join(f"{k}={v}" for k, v in counts.items()))
259
+ return 0
260
+
261
+ if args.command == "put-node":
262
+ rec = store.make_node(
263
+ labels=args.label,
264
+ props=dict(args.prop),
265
+ record_id=args.record_id,
266
+ updated_by=_who(args.updated_by),
267
+ )
268
+ print(json.dumps(rec.to_dict(), ensure_ascii=False))
269
+ return 0
270
+ if args.command == "get-node":
271
+ found_node = store.get_node(args.record_id)
272
+ if found_node is None:
273
+ print(f"graph-ted-db: node {args.record_id} not found", file=sys.stderr)
274
+ return 1
275
+ print(json.dumps(found_node.to_dict(), ensure_ascii=False))
276
+ return 0
277
+ if args.command == "ls-nodes":
278
+ for rec in store.iter_nodes():
279
+ print(json.dumps(rec.to_dict(), ensure_ascii=False))
280
+ return 0
281
+ if args.command == "delete-node":
282
+ tomb = store.delete_node(args.record_id, updated_by=_who(args.updated_by))
283
+ print(json.dumps(tomb.to_dict(), ensure_ascii=False))
284
+ return 0
285
+ if args.command == "put-edge":
286
+ edge = store.make_edge(
287
+ type=args.edge_type,
288
+ from_id=args.from_id,
289
+ to_id=args.to_id,
290
+ props=dict(args.prop),
291
+ record_id=args.record_id,
292
+ updated_by=_who(args.updated_by),
293
+ )
294
+ print(json.dumps(edge.to_dict(), ensure_ascii=False))
295
+ return 0
296
+ if args.command == "get-edge":
297
+ found_edge = store.get_edge(args.record_id)
298
+ if found_edge is None:
299
+ print(f"graph-ted-db: edge {args.record_id} not found", file=sys.stderr)
300
+ return 1
301
+ print(json.dumps(found_edge.to_dict(), ensure_ascii=False))
302
+ return 0
303
+ if args.command == "ls-edges":
304
+ for edge in store.iter_edges():
305
+ print(json.dumps(edge.to_dict(), ensure_ascii=False))
306
+ return 0
307
+ if args.command == "delete-edge":
308
+ tomb = store.delete_edge(args.record_id, updated_by=_who(args.updated_by))
309
+ print(json.dumps(tomb.to_dict(), ensure_ascii=False))
310
+ return 0
311
+ if args.command == "compact":
312
+ store.compact()
313
+ print(f"compacted {args.path}")
314
+ return 0
315
+ if args.command == "doctor":
316
+ report = store.doctor(fix=args.fix)
317
+ print(report.summary(), end="")
318
+ return 0
319
+ if args.command == "cypher":
320
+ query = args.query if args.query is not None else sys.stdin.read()
321
+ params = json.loads(args.params) if args.params else {}
322
+ if not isinstance(params, dict):
323
+ raise ValueError("--params must be a JSON object")
324
+ rows = store.execute(query, params, updated_by=_who(args.updated_by) or "cypher")
325
+ for row in rows:
326
+ print(json.dumps(row, ensure_ascii=False))
327
+ return 0
328
+ if args.command == "serve":
329
+ from graph_ted_db.server import (
330
+ DEFAULT_HOST,
331
+ DEFAULT_PORT,
332
+ LOOPBACK_HOSTS,
333
+ MAX_QUERY_RECORDS,
334
+ serve,
335
+ )
336
+
337
+ host = args.host or DEFAULT_HOST
338
+ port = DEFAULT_PORT if args.port is None else args.port
339
+ max_records = MAX_QUERY_RECORDS if args.max_records is None else args.max_records
340
+ if args.token is not None:
341
+ token = args.token.strip()
342
+ else:
343
+ token = os.environ.get("GRAPH_TED_DB_TOKEN", "").strip()
344
+ if host not in LOOPBACK_HOSTS and not token:
345
+ print(
346
+ "graph-ted-db: refusing to bind off loopback without a token "
347
+ "(pass --token or set GRAPH_TED_DB_TOKEN)",
348
+ file=sys.stderr,
349
+ )
350
+ return 1
351
+ try:
352
+ serve(store, host=host, port=port, max_records=max_records, token=token)
353
+ except OSError as exc:
354
+ if exc.errno in (errno.EADDRINUSE, 10048): # 10048: WSAEADDRINUSE
355
+ print(
356
+ f"graph-ted-db: port {port} in use; pass --port "
357
+ "(another graph-ted-db serve may still be running)",
358
+ file=sys.stderr,
359
+ )
360
+ return 1
361
+ raise
362
+ return 0
363
+ raise AssertionError(f"unhandled command {args.command}")
364
+
365
+
366
+ if __name__ == "__main__":
367
+ raise SystemExit(main())
@@ -0,0 +1,16 @@
1
+ """Optional Graphiti GraphDriver. Import GraphTedDbDriver from .graphiti."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ def __getattr__(name: str) -> Any:
9
+ if name == "GraphTedDbDriver":
10
+ from graph_ted_db.driver.graphiti import GraphTedDbDriver
11
+
12
+ return GraphTedDbDriver
13
+ raise AttributeError(name)
14
+
15
+
16
+ __all__ = ["GraphTedDbDriver"]
@@ -0,0 +1,201 @@
1
+ """graphiti-core GraphDriver over a graph-ted-db folder or HTTP serve.
2
+
3
+ Reports GraphProvider.NEO4J so Graphiti emits the query dialect this engine's openCypher subset targets.
4
+ Pass an http(s) URL to use serve (one process owns the folder). Pass a
5
+ filesystem path to open GraphStore in-process (library / CLI use).
6
+ Install graphiti-core in the process that constructs this driver.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from collections.abc import Coroutine
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from graph_ted_db.driver.http import (
17
+ get_health,
18
+ is_http_url,
19
+ normalize_base,
20
+ post_cypher,
21
+ post_cypher_many,
22
+ )
23
+ from graph_ted_db.store import GraphStore, init_graph
24
+ from graph_ted_db.store.init import GraphFormatError
25
+
26
+ try:
27
+ from graphiti_core.driver.driver import (
28
+ GraphDriver,
29
+ GraphDriverSession,
30
+ GraphProvider,
31
+ )
32
+ except ImportError as exc: # pragma: no cover
33
+ raise ImportError(
34
+ "GraphTedDbDriver requires graphiti-core. Install it in the Graphiti MCP environment."
35
+ ) from exc
36
+
37
+
38
+ def _params_from_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]:
39
+ kwargs = dict(kwargs)
40
+ kwargs.pop("routing_", None)
41
+ kwargs.pop("database_", None)
42
+ nested = kwargs.pop("params", None)
43
+ params: dict[str, Any] = dict(nested) if isinstance(nested, dict) else {}
44
+ params.update(kwargs)
45
+ return params
46
+
47
+
48
+ class GraphTedDbSession(GraphDriverSession):
49
+ provider = GraphProvider.NEO4J
50
+
51
+ def __init__(self, driver: GraphTedDbDriver):
52
+ self.driver = driver
53
+ self.store = driver.store
54
+ self._batch: list[tuple[str, dict[str, Any]]] | None = None
55
+
56
+ async def __aenter__(self):
57
+ return self
58
+
59
+ async def __aexit__(self, exc_type, exc, tb):
60
+ return None
61
+
62
+ async def close(self):
63
+ return None
64
+
65
+ def _enqueue(self, query: str | list, kwargs: dict[str, Any]) -> None:
66
+ assert self._batch is not None
67
+ if isinstance(query, list):
68
+ for cypher, params in query:
69
+ self._batch.append((str(cypher), dict(params or {})))
70
+ return
71
+ self._batch.append((str(query), _params_from_kwargs(kwargs)))
72
+
73
+ async def run(self, query: str | list, **kwargs: Any) -> Any:
74
+ if self._batch is not None:
75
+ self._enqueue(query, kwargs)
76
+ return None
77
+ if self.driver._http:
78
+ if isinstance(query, list):
79
+ statements = [(str(cypher), dict(params or {})) for cypher, params in query]
80
+ await asyncio.to_thread(post_cypher_many, self.driver._http, statements)
81
+ else:
82
+ await asyncio.to_thread(
83
+ post_cypher,
84
+ self.driver._http,
85
+ str(query),
86
+ _params_from_kwargs(kwargs),
87
+ )
88
+ return None
89
+ assert self.store is not None
90
+ if isinstance(query, list):
91
+ for cypher, params in query:
92
+ await self._execute_store(cypher, dict(params or {}))
93
+ return None
94
+ await self._execute_store(query, _params_from_kwargs(kwargs))
95
+ return None
96
+
97
+ async def _execute_store(self, query: str, params: dict[str, Any]) -> list:
98
+ """Run queries off the event loop, unless this thread already holds the store lock.
99
+
100
+ ``execute_write`` keeps the file lock on the loop thread and calls
101
+ ``run`` from there. Moving that call to a worker would see the lock
102
+ as re-entrant and corrupt ``_lock_depth``.
103
+ """
104
+ assert self.store is not None
105
+ if self.store._tx is not None or self.store._lock_depth:
106
+ return self.store.execute(str(query), params)
107
+ return await asyncio.to_thread(self.store.execute, str(query), params)
108
+
109
+ async def execute_write(self, func, *args, **kwargs):
110
+ if self.driver._http:
111
+ self._batch = []
112
+ try:
113
+ result = await func(self, *args, **kwargs)
114
+ statements = self._batch
115
+ self._batch = None
116
+ if statements:
117
+ await asyncio.to_thread(post_cypher_many, self.driver._http, statements)
118
+ return result
119
+ except Exception:
120
+ self._batch = None
121
+ raise
122
+ assert self.store is not None
123
+ with self.store._lock():
124
+ self.store._begin_unlocked()
125
+ try:
126
+ result = await func(self, *args, **kwargs)
127
+ self.store._commit_unlocked()
128
+ return result
129
+ except Exception:
130
+ self.store._rollback_unlocked()
131
+ raise
132
+
133
+
134
+ class GraphTedDbDriver(GraphDriver):
135
+ provider = GraphProvider.NEO4J
136
+ aoss_client = None
137
+ fulltext_syntax = ""
138
+
139
+ def __init__(self, root: str | Path, *, data_dir: Path | None = None):
140
+ super().__init__()
141
+ raw = str(root).strip()
142
+ if is_http_url(raw):
143
+ self._http: str | None = normalize_base(raw)
144
+ self.store = None
145
+ else:
146
+ self._http = None
147
+ path = Path(raw).expanduser().resolve()
148
+ try:
149
+ self.store = GraphStore.open(path, data_dir=data_dir)
150
+ except GraphFormatError:
151
+ init_graph(path, name=path.name, exist_ok=True)
152
+ self.store = GraphStore.open(path, data_dir=data_dir)
153
+ self._database = "neo4j"
154
+ self.client = self
155
+
156
+ async def execute_query(self, cypher_query_, **kwargs: Any):
157
+ params = _params_from_kwargs(kwargs)
158
+ if self._http:
159
+ rows = await asyncio.to_thread(
160
+ post_cypher,
161
+ self._http,
162
+ str(cypher_query_),
163
+ params,
164
+ max_records=0,
165
+ )
166
+ return rows, None, None
167
+ assert self.store is not None
168
+ if self.store._tx is not None or self.store._lock_depth:
169
+ rows = self.store.execute(str(cypher_query_), params)
170
+ else:
171
+ rows = await asyncio.to_thread(self.store.execute, str(cypher_query_), params)
172
+ return rows, None, None
173
+
174
+ def session(self, database: str | None = None) -> GraphTedDbSession:
175
+ del database
176
+ return GraphTedDbSession(self)
177
+
178
+ async def close(self) -> None:
179
+ return None
180
+
181
+ def close_sync(self) -> None:
182
+ return None
183
+
184
+ def delete_all_indexes(self) -> Coroutine:
185
+ async def _noop():
186
+ return None
187
+
188
+ return _noop()
189
+
190
+ async def verify_connectivity(self) -> None:
191
+ if self._http:
192
+ try:
193
+ await asyncio.to_thread(get_health, self._http)
194
+ return
195
+ except ValueError:
196
+ rows = await asyncio.to_thread(post_cypher, self._http, "RETURN 1 AS ok")
197
+ else:
198
+ assert self.store is not None
199
+ rows = await asyncio.to_thread(self.store.execute, "RETURN 1 AS ok")
200
+ if not rows or rows[0].get("ok") not in (1, "1"):
201
+ raise RuntimeError("graph-ted-db connectivity check failed")