topologicpy 0.8.54__py3-none-any.whl → 0.8.57__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.
topologicpy/Kuzu.py ADDED
@@ -0,0 +1,589 @@
1
+ from __future__ import annotations
2
+ import threading, contextlib, time, json
3
+ from typing import Dict, Any, List, Optional
4
+
5
+
6
+ # Optional TopologicPy imports (make this file safe to import without TopologicPy)
7
+ from topologicpy.Graph import Graph
8
+ from topologicpy.Vertex import Vertex
9
+ from topologicpy.Edge import Edge
10
+ from topologicpy.Dictionary import Dictionary
11
+ from topologicpy.Topology import Topology
12
+
13
+ import os
14
+ import warnings
15
+
16
+ try:
17
+ import kuzu
18
+ except:
19
+ print("Kuzu - Installing required kuzu library.")
20
+ try:
21
+ os.system("pip install kuzu")
22
+ except:
23
+ os.system("pip install kuzu --user")
24
+ try:
25
+ import kuzu
26
+ except:
27
+ warnings.warn("Kuzu - Error: Could not import Kuzu.")
28
+ kuzu = None
29
+
30
+
31
+ class _DBCache:
32
+ """
33
+ One kuzu.Database per path. Thread-safe and process-local.
34
+ """
35
+ def __init__(self):
36
+ self._lock = threading.RLock()
37
+ self._cache: Dict[str, "kuzu.Database"] = {}
38
+
39
+ def get(self, path: str) -> "kuzu.Database":
40
+ if kuzu is None:
41
+ raise "Kuzu - Error: Kuzu is not available"
42
+ with self._lock:
43
+ db = self._cache.get(path)
44
+ if db is None:
45
+ db = kuzu.Database(path)
46
+ self._cache[path] = db
47
+ return db
48
+
49
+ class _WriteGate:
50
+ """
51
+ Serialize writes to avoid IO lock contention.
52
+ """
53
+ def __init__(self):
54
+ self._lock = threading.RLock()
55
+
56
+ @contextlib.contextmanager
57
+ def hold(self):
58
+ with self._lock:
59
+ yield
60
+
61
+ _db_cache = _DBCache()
62
+ _write_gate = _WriteGate()
63
+
64
+ class _ConnectionPool:
65
+ """
66
+ Per-thread kuzu.Connection pool bound to a Database instance.
67
+ """
68
+ def __init__(self, db: "kuzu.Database"):
69
+ self.db = db
70
+ self._local = threading.local()
71
+
72
+ def _ensure(self) -> "kuzu.Connection":
73
+ if not hasattr(self._local, "conn"):
74
+ self._local.conn = kuzu.Connection(self.db)
75
+ return self._local.conn
76
+
77
+ @contextlib.contextmanager
78
+ def connection(self, write: bool = False, retries: int = 5, backoff: float = 0.15):
79
+ conn = self._ensure()
80
+ if not write:
81
+ yield conn
82
+ return
83
+ # Serialize writes and retry transient failures
84
+ with _write_gate.hold():
85
+ attempt = 0
86
+ while True:
87
+ try:
88
+ yield conn
89
+ break
90
+ except Exception as e:
91
+ attempt += 1
92
+ if attempt > retries:
93
+ raise f"Kuzu write failed after {retries} retries: {e}"
94
+ time.sleep(backoff * attempt)
95
+
96
+ class _Mgr:
97
+ """
98
+ Lightweight facade (per-db-path) providing read/write execution and schema bootstrap.
99
+ """
100
+ def __init__(self, db_path: str):
101
+ self.db_path = db_path
102
+ self._db = _db_cache.get(db_path)
103
+ self._pool = _ConnectionPool(self._db)
104
+
105
+ @contextlib.contextmanager
106
+ def read(self):
107
+ with self._pool.connection(write=False) as c:
108
+ yield c
109
+
110
+ @contextlib.contextmanager
111
+ def write(self):
112
+ with self._pool.connection(write=True) as c:
113
+ yield c
114
+
115
+ def exec(self, query: str, params: Optional[dict] = None, write: bool = False):
116
+ with (self.write() if write else self.read()) as c:
117
+ with c.execute(query, parameters=params or {}) as res:
118
+ try:
119
+ return res.rows_as_dict().get_all()
120
+ except Exception:
121
+ return None
122
+
123
+ def ensure_schema(self):
124
+ # Node tables
125
+ self.exec("""
126
+ CREATE NODE TABLE IF NOT EXISTS GraphCard(
127
+ id STRING,
128
+ label STRING,
129
+ num_nodes INT64,
130
+ num_edges INT64,
131
+ props STRING,
132
+ PRIMARY KEY(id)
133
+ );
134
+ """, write=True)
135
+ self.exec("""
136
+ CREATE NODE TABLE IF NOT EXISTS Vertex(
137
+ id STRING,
138
+ graph_id STRING,
139
+ label STRING,
140
+ x DOUBLE,
141
+ y DOUBLE,
142
+ z DOUBLE,
143
+ props STRING,
144
+ PRIMARY KEY(id)
145
+ );
146
+ """, write=True)
147
+
148
+ # Relationship tables
149
+ self.exec("""
150
+ CREATE REL TABLE IF NOT EXISTS CONNECT(FROM Vertex TO Vertex, label STRING, props STRING);
151
+ """, write=True)
152
+
153
+ # Figure out later if we need sessions and steps
154
+ # self.exec("""
155
+ # CREATE NODE TABLE IF NOT EXISTS Session(
156
+ # id STRING,
157
+ # title STRING,
158
+ # created_at STRING,
159
+ # PRIMARY KEY(id)
160
+ # );
161
+ # """, write=True)
162
+ # self.exec("""
163
+ # CREATE NODE TABLE IF NOT EXISTS Step(
164
+ # id STRING,
165
+ # session_id STRING,
166
+ # idx INT64,
167
+ # action STRING,
168
+ # ok BOOL,
169
+ # message STRING,
170
+ # snapshot_before STRING,
171
+ # snapshot_after STRING,
172
+ # evidence STRING,
173
+ # created_at STRING,
174
+ # PRIMARY KEY(id)
175
+ # );
176
+ # """, write=True)
177
+ # self.exec("CREATE REL TABLE IF NOT EXISTS SessionHasStep(FROM Session TO Step);", write=True)
178
+
179
+
180
+ class Kuzu:
181
+ """
182
+ TopologicPy-style class of static methods for Kùzu integration.
183
+
184
+ Notes
185
+ -----
186
+ - All methods are *static* to match TopologicPy's style.
187
+ - Graph persistence:
188
+ * Vertices: stored in `Vertex` with (id, graph_id, label, props JSON)
189
+ * Edges: stored as `CONNECT` relations a->b with label + props JSON
190
+ * We assume undirected design intent; only one CONNECT is stored (a->b),
191
+ but TopologicPy Graph treats edges as undirected by default.
192
+ """
193
+
194
+ # ---------- Core (DB + Connection + Schema) ----------
195
+ @staticmethod
196
+ def EnsureSchema(db_path: str, silent: bool = False) -> bool:
197
+ """
198
+ Ensures the required Kùzu schema exists in the database at `db_path`.
199
+
200
+ Parameters
201
+ ----------
202
+ db_path : str
203
+ Path to the Kùzu database directory. It will be created if it does not exist.
204
+ silent : bool , optional
205
+ If True, suppresses error messages. Default is False.
206
+
207
+ Returns
208
+ -------
209
+ bool
210
+ True if successful, False otherwise.
211
+ """
212
+ try:
213
+ mgr = _Mgr(db_path)
214
+ mgr.ensure_schema()
215
+ return True
216
+ except Exception as e:
217
+ if not silent:
218
+ print(f"Kuzu.EnsureSchema - Error: {e}")
219
+ return False
220
+
221
+ @staticmethod
222
+ def Database(db_path: str):
223
+ """
224
+ Returns the underlying `kuzu.Database` instance for `db_path`.
225
+ """
226
+ return _db_cache.get(db_path)
227
+
228
+ @staticmethod
229
+ def Connection(db_path: str):
230
+ """
231
+ Returns a `kuzu.Connection` bound to the database at `db_path`.
232
+ """
233
+ mgr = _Mgr(db_path)
234
+ with mgr.read() as c:
235
+ return c # Note: returns a live connection (do not use across threads)
236
+
237
+ # ---------- Graph <-> DB Conversion ----------
238
+
239
+ @staticmethod
240
+ def UpsertGraph(db_path: str,
241
+ graph,
242
+ graphIDKey: Optional[str] = None,
243
+ vertexIDKey: Optional[str] = None,
244
+ vertexLabelKey: Optional[str] = None,
245
+ mantissa: int = 6,
246
+ silent: bool = False) -> str:
247
+ """
248
+ Upserts (deletes prior + inserts new) a TopologicPy graph and its GraphCard.
249
+
250
+ Parameters
251
+ ----------
252
+ db_path : str
253
+ Kùzu database path.
254
+ graph : topologicpy.Graph
255
+ The input TopologicPy graph.
256
+ graphIDKey : str , optional
257
+ The graph dictionary key under which the graph ID is stored. If None, a UUID is generated.
258
+ title, domain, geo, time_start, time_end, summary : str , optional
259
+ Optional metadata for GraphCard.
260
+ silent : bool , optional
261
+ If True, suppresses error messages. Default is False.
262
+
263
+ Returns
264
+ -------
265
+ str
266
+ The graph_id used.
267
+ """
268
+ from topologicpy.Topology import Topology
269
+ from topologicpy.Dictionary import Dictionary
270
+ d = Topology.Dictionary(graph)
271
+ if graphIDKey is None:
272
+ gid = Topology.UUID(graph)
273
+ else:
274
+ gid = Dictionary.ValueAtKey(d, graphIDKey, Topology.UUID(graph))
275
+ g_props = Dictionary.PythonDictionary(d)
276
+ mesh_data = Graph.MeshData(graph, mantissa=mantissa)
277
+ verts = mesh_data['vertices']
278
+ v_props = mesh_data['vertexDictionaries']
279
+ edges = mesh_data['edges']
280
+ e_props = mesh_data['edgeDictionaries']
281
+ num_nodes = len(verts)
282
+ num_edges = len(edges)
283
+ mgr = _Mgr(db_path)
284
+ try:
285
+ mgr.ensure_schema()
286
+ # Upsert GraphCard
287
+ mgr.exec("MATCH (g:GraphCard) WHERE g.id = $id DELETE g;", {"id": gid}, write=True)
288
+ mgr.exec("""
289
+ CREATE (g:GraphCard {id:$id, num_nodes:$num_nodes, num_edges: $num_edges, props:$props});
290
+ """, {"id": gid, "num_nodes": num_nodes, "num_edges": num_edges, "props": json.dumps(g_props)}, write=True)
291
+
292
+ # Remove existing vertices/edges for this graph_id
293
+ mgr.exec("""
294
+ MATCH (a:Vertex)-[r:CONNECT]->(b:Vertex)
295
+ WHERE a.graph_id = $gid AND b.graph_id = $gid
296
+ DELETE r;
297
+ """, {"gid": gid}, write=True)
298
+ mgr.exec("MATCH (v:Vertex) WHERE v.graph_id = $gid DELETE v;", {"gid": gid}, write=True)
299
+
300
+ # Insert vertices
301
+ for i, v in enumerate(verts):
302
+ x,y,z = v
303
+ if vertexIDKey is None:
304
+ vid = f"{gid}:{i}"
305
+ else:
306
+ vid = v_props[i].get(vertexIDKey, f"{gid}:{i}")
307
+ if vertexLabelKey is None:
308
+ label = str(i)
309
+ else:
310
+ label = v_props[i].get(vertexIDKey, str(i))
311
+ mgr.exec("""
312
+ CREATE (v:Vertex {id:$id, graph_id:$gid, label:$label, props:$props, x:$x, y:$y, z:$z});
313
+ """, {"id": vid, "gid": gid, "label": label, "x": x, "y": y, "z": z,
314
+ "props": json.dumps(v_props[i])}, write=True)
315
+
316
+ # Insert edges
317
+ for i, e in enumerate(edges):
318
+ a_id = v_props[e[0]].get(vertexIDKey, f"{gid}:{e[0]}")
319
+ b_id = v_props[e[1]].get(vertexIDKey, f"{gid}:{e[1]}")
320
+ mgr.exec("""
321
+ MATCH (a:Vertex {id:$a}), (b:Vertex {id:$b})
322
+ CREATE (a)-[:CONNECT {label:$label, props:$props}]->(b);
323
+ """, {"a": a_id, "b": b_id,
324
+ "label": e_props[i].get("label", str(i)),
325
+ "props": json.dumps(e_props[i])}, write=True)
326
+
327
+ return gid
328
+ except Exception as e:
329
+ if not silent:
330
+ print(f"Kuzu.UpsertGraph - Error: {e}")
331
+ raise
332
+
333
+ @staticmethod
334
+ def GraphByID(db_path: str, graphID: str, silent: bool = False):
335
+ """
336
+ Reads a graph with id `graph_id` from Kùzu and constructs a TopologicPy graph.
337
+
338
+ Returns
339
+ -------
340
+ topologicpy.Graph
341
+ A new TopologicPy Graph, or None on error.
342
+ """
343
+ # if TGraph is None:
344
+ # raise _KuzuError("TopologicPy is required to use Kuzu.ReadTopologicGraph.")
345
+ import random
346
+ mgr = _Mgr(db_path)
347
+
348
+ try:
349
+ mgr.ensure_schema()
350
+ # Read the GraphCard
351
+ g = mgr.exec("""
352
+ MATCH (g:GraphCard) WHERE g.id = $id
353
+ RETURN g.id AS id, g.num_nodes AS num_nodes, g.num_edges AS num_edges, g.props AS props
354
+ ;
355
+ """, {"id": graphID}, write=False) or None
356
+ if g is None:
357
+ return None
358
+ g = g[0]
359
+ g_dict = dict(json.loads(g.get("props") or "{}") or {})
360
+ g_dict = Dictionary.ByPythonDictionary(g_dict)
361
+ # Read vertices
362
+ rows_v = mgr.exec("""
363
+ MATCH (v:Vertex) WHERE v.graph_id = $gid
364
+ RETURN v.id AS id, v.label AS label, v.x AS x, v.y AS y, v.z AS z, v.props AS props
365
+ ORDER BY id;
366
+ """, {"gid": graphID}, write=False) or []
367
+
368
+ id_to_vertex = {}
369
+ vertices = []
370
+ for row in rows_v:
371
+ try:
372
+ x = row.get("x") or random.uniform(0,1000)
373
+ y = row.get("y") or random.uniform(0,1000)
374
+ z = row.get("z") or random.uniform(0,1000)
375
+ except:
376
+ x = random.uniform(0,1000)
377
+ y = random.uniform(0,1000)
378
+ z = random.uniform(0,1000)
379
+ v = Vertex.ByCoordinates(x,y,z)
380
+ props = {}
381
+ try:
382
+ props = json.loads(row.get("props") or "{}")
383
+ except Exception:
384
+ props = {}
385
+ # Ensure 'label' key present
386
+ props = dict(props or {})
387
+ if "label" not in props:
388
+ props["label"] = row.get("label") or ""
389
+ d = Dictionary.ByKeysValues(list(props.keys()), list(props.values()))
390
+ v = Topology.SetDictionary(v, d)
391
+ id_to_vertex[row["id"]] = v
392
+ vertices.append(v)
393
+
394
+ # Read edges
395
+ rows_e = mgr.exec("""
396
+ MATCH (a:Vertex)-[r:CONNECT]->(b:Vertex)
397
+ WHERE a.graph_id = $gid AND b.graph_id = $gid
398
+ RETURN a.id AS a_id, b.id AS b_id, r.label AS label, r.props AS props;
399
+ """, {"gid": graphID}, write=False) or []
400
+ edges = []
401
+ for row in rows_e:
402
+ va = id_to_vertex.get(row["a_id"])
403
+ vb = id_to_vertex.get(row["b_id"])
404
+ if not va or not vb:
405
+ continue
406
+ e = Edge.ByStartVertexEndVertex(va, vb)
407
+ props = {}
408
+ try:
409
+ props = json.loads(row.get("props") or "{}")
410
+ except Exception:
411
+ props = {}
412
+ props = dict(props or {})
413
+ if "label" not in props:
414
+ props["label"] = row.get("label") or "connect"
415
+ d = Dictionary.ByKeysValues(list(props.keys()), list(props.values()))
416
+ e = Topology.SetDictionary(e, d)
417
+ edges.append(e)
418
+ if len(vertices) > 0:
419
+ g = Graph.ByVerticesEdges(vertices, edges)
420
+ g = Topology.SetDictionary(g, g_dict)
421
+ else:
422
+ g = None
423
+ return g
424
+ except Exception as e:
425
+ if not silent:
426
+ print(f"Kuzu.GraphByID - Error: {e}")
427
+ return None
428
+
429
+ @staticmethod
430
+ def GraphsByQuery(
431
+ db_path: str,
432
+ query: str,
433
+ params: dict | None = None,
434
+ graphIDKey: str = "graph_id",
435
+ silent: bool = False,
436
+ ):
437
+ """
438
+ Executes a Kùzu Cypher query and returns a list of TopologicPy Graphs.
439
+ The query should return at least one column identifying each graph.
440
+ By default this column is expected to be named 'graph_id', but you can
441
+ override that via `graph_id_field`.
442
+
443
+ The method will:
444
+ 1) run the query,
445
+ 2) extract distinct graph IDs from the result set (using `graph_id_field`
446
+ if present; otherwise it attempts to infer IDs from common fields like
447
+ 'a_id', 'b_id', or 'id' that look like '<graph_id>:<vertex_index>'),
448
+ 3) reconstruct each graph via Kuzu.ReadTopologicGraph(...).
449
+
450
+ Parameters
451
+ ----------
452
+ db_path : str
453
+ Path to the Kùzu database directory.
454
+ query : str
455
+ A valid Kùzu Cypher query.
456
+ params : dict , optional
457
+ Parameters to pass with the query.
458
+ graph_id_field : str , optional
459
+ The field name in the query result that contains the graph ID(s).
460
+ Default is "graph_id".
461
+ silent : bool , optional
462
+ If True, suppresses errors and returns an empty list on failure.
463
+
464
+ Returns
465
+ -------
466
+ list[topologicpy.Graph]
467
+ A list of reconstructed TopologicPy graphs.
468
+ """
469
+ # if TGraph is None:
470
+ # raise _KuzuError("TopologicPy is required to use Kuzu.GraphsFromQuery.")
471
+
472
+ try:
473
+ mgr = _Mgr(db_path)
474
+ mgr.ensure_schema()
475
+ rows = mgr.exec(query, params or {}, write=False) or []
476
+
477
+ # Collect distinct graph IDs
478
+ gids = []
479
+ for r in rows:
480
+ gid = r.get(graphIDKey)
481
+
482
+ # Fallback: try to infer from common id fields like "<graph_id>:<i>"
483
+ if gid is None:
484
+ for k in ("a_id", "b_id", "id"):
485
+ v = r.get(k)
486
+ if isinstance(v, str) and ":" in v:
487
+ gid = v.split(":", 1)[0]
488
+ break
489
+
490
+ if gid and gid not in gids:
491
+ gids.append(gid)
492
+
493
+ # Reconstruct each graph
494
+ graphs = []
495
+ for gid in gids:
496
+ g = Kuzu.GraphByID(db_path, gid, silent=True)
497
+ if g is not None:
498
+ graphs.append(g)
499
+ return graphs
500
+
501
+ except Exception as e:
502
+ if not silent:
503
+ print(f"Kuzu.GraphsByQuery - Error: {e}")
504
+ return []
505
+
506
+ @staticmethod
507
+ def DeleteGraph(db_path: str, graph_id: str, silent: bool = False) -> bool:
508
+ """
509
+ Deletes a graph (vertices/edges) and its GraphCard by id.
510
+ """
511
+ try:
512
+ mgr = _Mgr(db_path)
513
+ mgr.ensure_schema()
514
+ # Delete edges
515
+ mgr.exec("""
516
+ MATCH (a:Vertex)-[r:CONNECT]->(b:Vertex)
517
+ WHERE a.graph_id = $gid AND b.graph_id = $gid
518
+ DELETE r;
519
+ """, {"gid": graph_id}, write=True)
520
+ # Delete vertices
521
+ mgr.exec("MATCH (v:Vertex) WHERE v.graph_id = $gid DELETE v;", {"gid": graph_id}, write=True)
522
+ # Delete card
523
+ mgr.exec("MATCH (g:GraphCard) WHERE g.id = $gid DELETE g;", {"gid": graph_id}, write=True)
524
+ return True
525
+ except Exception as e:
526
+ if not silent:
527
+ print(f"Kuzu.DeleteGraph - Error: {e}")
528
+ return False
529
+
530
+ @staticmethod
531
+ def EmptyDatabase(db_path: str, drop_schema: bool = False, recreate_schema: bool = True, silent: bool = False) -> bool:
532
+ """
533
+ Empties the Kùzu database at `db_path`.
534
+
535
+ Two modes:
536
+ - Soft clear (default): delete ALL relationships, then ALL nodes across all tables.
537
+ - Hard reset (drop_schema=True): drop known node/rel tables, optionally recreate schema.
538
+
539
+ Parameters
540
+ ----------
541
+ db_path : str
542
+ Path to the Kùzu database directory.
543
+ drop_schema : bool , optional
544
+ If True, DROP the known tables instead of deleting rows. Default False.
545
+ recreate_schema : bool , optional
546
+ If True and drop_schema=True, re-create the minimal schema after dropping. Default True.
547
+ silent : bool , optional
548
+ Suppress errors if True. Default False.
549
+
550
+ Returns
551
+ -------
552
+ bool
553
+ True on success, False otherwise.
554
+ """
555
+ try:
556
+ mgr = _Mgr(db_path)
557
+ # Ensure DB exists (does not create tables unless needed)
558
+ mgr.ensure_schema()
559
+
560
+ if drop_schema:
561
+ # Drop relationship tables FIRST (to release dependencies), then node tables.
562
+ # IF EXISTS is convenient; if your Kùzu version doesn't support it, remove and ignore exceptions.
563
+ for stmt in [
564
+ "DROP TABLE IF EXISTS CONNECT;",
565
+ "DROP TABLE IF EXISTS Vertex;",
566
+ "DROP TABLE IF EXISTS GraphCard;",
567
+ ]:
568
+ try:
569
+ mgr.exec(stmt, write=True)
570
+ except Exception as _e:
571
+ if not silent:
572
+ print(f"Kuzu.EmptyDatabase - Warning dropping table: {_e}")
573
+
574
+ if recreate_schema:
575
+ mgr.ensure_schema()
576
+ return True
577
+
578
+ # Soft clear: remove all relationships, then all nodes (covers all labels/tables).
579
+ # Delete all edges (any direction)
580
+ mgr.exec("MATCH (a)-[r]->(b) DELETE r;", write=True)
581
+ # Delete all nodes (from all node tables)
582
+ mgr.exec("MATCH (n) DELETE n;", write=True)
583
+ return True
584
+
585
+ except Exception as e:
586
+ if not silent:
587
+ print(f"Kuzu.EmptyDatabase - Error: {e}")
588
+ return False
589
+