ison-graph 1.0.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.
- ison_graph/__init__.py +1513 -0
- ison_graph/py.typed +0 -0
- ison_graph/query.py +1390 -0
- ison_graph/schema.py +1010 -0
- ison_graph-1.0.0.dist-info/METADATA +1035 -0
- ison_graph-1.0.0.dist-info/RECORD +9 -0
- ison_graph-1.0.0.dist-info/WHEEL +5 -0
- ison_graph-1.0.0.dist-info/licenses/LICENSE +21 -0
- ison_graph-1.0.0.dist-info/top_level.txt +1 -0
ison_graph/__init__.py
ADDED
|
@@ -0,0 +1,1513 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
ISONGraph - A Token-Efficient Graph Store
|
|
4
|
+
|
|
5
|
+
A file-based, in-memory graph store built on ISON format.
|
|
6
|
+
Supports property graphs, multi-hop traversal, and path finding.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
from ison_graph import ISONGraph, NodeRef
|
|
10
|
+
|
|
11
|
+
# Create a graph
|
|
12
|
+
graph = ISONGraph()
|
|
13
|
+
|
|
14
|
+
# Add nodes
|
|
15
|
+
graph.add_node('person', 1, name='Alice', age=30)
|
|
16
|
+
graph.add_node('person', 2, name='Bob', age=25)
|
|
17
|
+
graph.add_node('company', 100, name='Acme')
|
|
18
|
+
|
|
19
|
+
# Add edges
|
|
20
|
+
graph.add_edge('KNOWS', ('person', 1), ('person', 2), since=2020)
|
|
21
|
+
graph.add_edge('WORKS_AT', ('person', 1), ('company', 100), role='Engineer')
|
|
22
|
+
|
|
23
|
+
# Traverse
|
|
24
|
+
friends = graph.neighbors(('person', 1), 'KNOWS')
|
|
25
|
+
fof = graph.multi_hop(('person', 1), 'KNOWS', hops=2)
|
|
26
|
+
|
|
27
|
+
# Path finding
|
|
28
|
+
path = graph.shortest_path(('person', 1), ('person', 3))
|
|
29
|
+
|
|
30
|
+
# Persistence
|
|
31
|
+
graph.save('social.isong')
|
|
32
|
+
graph = ISONGraph.load('social.isong')
|
|
33
|
+
|
|
34
|
+
Author: Mahesh Vaikri
|
|
35
|
+
Version: 1.0.0
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import re
|
|
41
|
+
from dataclasses import dataclass, field
|
|
42
|
+
from typing import (
|
|
43
|
+
Any, Dict, List, Optional, Set, Tuple,
|
|
44
|
+
Iterator, Callable, Union, Generator
|
|
45
|
+
)
|
|
46
|
+
from pathlib import Path as FilePath
|
|
47
|
+
from collections import defaultdict
|
|
48
|
+
from enum import Enum
|
|
49
|
+
|
|
50
|
+
# Import from ison-py
|
|
51
|
+
from ison_parser import (
|
|
52
|
+
Document, Block, Reference,
|
|
53
|
+
loads, dumps, load, dump,
|
|
54
|
+
loads_isonl, dumps_isonl
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
__version__ = "1.0.0"
|
|
58
|
+
__author__ = "Mahesh Vaikri"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# =============================================================================
|
|
62
|
+
# Type Aliases
|
|
63
|
+
# =============================================================================
|
|
64
|
+
|
|
65
|
+
NodeRef = Tuple[str, Union[int, str]] # (type, id) e.g., ('person', 1)
|
|
66
|
+
EdgeKey = Tuple[str, NodeRef, NodeRef] # (rel_type, source, target)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# =============================================================================
|
|
70
|
+
# Data Classes
|
|
71
|
+
# =============================================================================
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class Node:
|
|
75
|
+
"""Represents a graph node with properties"""
|
|
76
|
+
type: str
|
|
77
|
+
id: Union[int, str]
|
|
78
|
+
properties: Dict[str, Any] = field(default_factory=dict)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def ref(self) -> NodeRef:
|
|
82
|
+
"""Get node reference tuple"""
|
|
83
|
+
return (self.type, self.id)
|
|
84
|
+
|
|
85
|
+
def to_ison_ref(self) -> str:
|
|
86
|
+
"""Convert to ISON reference string"""
|
|
87
|
+
return f":{self.type}:{self.id}"
|
|
88
|
+
|
|
89
|
+
def __hash__(self) -> int:
|
|
90
|
+
return hash(self.ref)
|
|
91
|
+
|
|
92
|
+
def __eq__(self, other: object) -> bool:
|
|
93
|
+
if isinstance(other, Node):
|
|
94
|
+
return self.ref == other.ref
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
def __repr__(self) -> str:
|
|
98
|
+
return f"Node({self.type}:{self.id}, {self.properties})"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class Edge:
|
|
103
|
+
"""Represents a graph edge with properties"""
|
|
104
|
+
rel_type: str
|
|
105
|
+
source: NodeRef
|
|
106
|
+
target: NodeRef
|
|
107
|
+
properties: Dict[str, Any] = field(default_factory=dict)
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def key(self) -> EdgeKey:
|
|
111
|
+
"""Get edge key tuple"""
|
|
112
|
+
return (self.rel_type, self.source, self.target)
|
|
113
|
+
|
|
114
|
+
def __hash__(self) -> int:
|
|
115
|
+
return hash(self.key)
|
|
116
|
+
|
|
117
|
+
def __eq__(self, other: object) -> bool:
|
|
118
|
+
if isinstance(other, Edge):
|
|
119
|
+
return self.key == other.key
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
def __repr__(self) -> str:
|
|
123
|
+
return f"Edge({self.source} -[{self.rel_type}]-> {self.target})"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class Path:
|
|
128
|
+
"""Represents a path through the graph"""
|
|
129
|
+
nodes: List[NodeRef]
|
|
130
|
+
edges: List[Edge]
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def length(self) -> int:
|
|
134
|
+
"""Number of hops in the path"""
|
|
135
|
+
return len(self.edges)
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def start(self) -> NodeRef:
|
|
139
|
+
"""Starting node"""
|
|
140
|
+
return self.nodes[0] if self.nodes else None
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def end(self) -> NodeRef:
|
|
144
|
+
"""Ending node"""
|
|
145
|
+
return self.nodes[-1] if self.nodes else None
|
|
146
|
+
|
|
147
|
+
def __repr__(self) -> str:
|
|
148
|
+
path_str = " -> ".join([f":{n[0]}:{n[1]}" for n in self.nodes])
|
|
149
|
+
return f"Path({path_str})"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class Direction(Enum):
|
|
153
|
+
"""Edge traversal direction"""
|
|
154
|
+
OUT = "out"
|
|
155
|
+
IN = "in"
|
|
156
|
+
BOTH = "both"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# =============================================================================
|
|
160
|
+
# Graph Errors
|
|
161
|
+
# =============================================================================
|
|
162
|
+
|
|
163
|
+
class GraphError(Exception):
|
|
164
|
+
"""Base exception for graph errors"""
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class NodeNotFoundError(GraphError):
|
|
169
|
+
"""Node does not exist in graph"""
|
|
170
|
+
def __init__(self, node_ref: NodeRef):
|
|
171
|
+
self.node_ref = node_ref
|
|
172
|
+
super().__init__(f"Node not found: :{node_ref[0]}:{node_ref[1]}")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class EdgeNotFoundError(GraphError):
|
|
176
|
+
"""Edge does not exist in graph"""
|
|
177
|
+
def __init__(self, edge_key: EdgeKey):
|
|
178
|
+
self.edge_key = edge_key
|
|
179
|
+
super().__init__(f"Edge not found: {edge_key}")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class DuplicateNodeError(GraphError):
|
|
183
|
+
"""Node already exists"""
|
|
184
|
+
def __init__(self, node_ref: NodeRef):
|
|
185
|
+
self.node_ref = node_ref
|
|
186
|
+
super().__init__(f"Node already exists: :{node_ref[0]}:{node_ref[1]}")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class DuplicateEdgeError(GraphError):
|
|
190
|
+
"""Edge already exists"""
|
|
191
|
+
def __init__(self, edge_key: EdgeKey):
|
|
192
|
+
self.edge_key = edge_key
|
|
193
|
+
super().__init__(f"Edge already exists: {edge_key}")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# =============================================================================
|
|
197
|
+
# ISONGraph - Main Graph Class
|
|
198
|
+
# =============================================================================
|
|
199
|
+
|
|
200
|
+
class ISONGraph:
|
|
201
|
+
"""
|
|
202
|
+
In-memory property graph store with ISON persistence.
|
|
203
|
+
|
|
204
|
+
Features:
|
|
205
|
+
- Property graph model (nodes and edges with properties)
|
|
206
|
+
- Multiple node types and relationship types
|
|
207
|
+
- O(1) node lookup by (type, id)
|
|
208
|
+
- Multi-hop traversal
|
|
209
|
+
- Shortest path finding (BFS)
|
|
210
|
+
- All paths finding (DFS)
|
|
211
|
+
- ISON/ISONL persistence
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
def __init__(self, name: str = "graph", directed: bool = True):
|
|
215
|
+
"""
|
|
216
|
+
Initialize an empty graph.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
name: Graph name (used in serialization)
|
|
220
|
+
directed: Whether edges are directed (default: True)
|
|
221
|
+
"""
|
|
222
|
+
self.name = name
|
|
223
|
+
self.directed = directed
|
|
224
|
+
|
|
225
|
+
# Node storage: {type: {id: Node}}
|
|
226
|
+
self._nodes: Dict[str, Dict[Union[int, str], Node]] = defaultdict(dict)
|
|
227
|
+
|
|
228
|
+
# Edge storage: {rel_type: [Edge]}
|
|
229
|
+
self._edges: Dict[str, List[Edge]] = defaultdict(list)
|
|
230
|
+
|
|
231
|
+
# Index: outgoing edges per node
|
|
232
|
+
self._out_edges: Dict[NodeRef, List[Edge]] = defaultdict(list)
|
|
233
|
+
|
|
234
|
+
# Index: incoming edges per node
|
|
235
|
+
self._in_edges: Dict[NodeRef, List[Edge]] = defaultdict(list)
|
|
236
|
+
|
|
237
|
+
# Edge uniqueness set
|
|
238
|
+
self._edge_set: Set[EdgeKey] = set()
|
|
239
|
+
|
|
240
|
+
# =========================================================================
|
|
241
|
+
# Node Operations
|
|
242
|
+
# =========================================================================
|
|
243
|
+
|
|
244
|
+
def add_node(
|
|
245
|
+
self,
|
|
246
|
+
node_type: str,
|
|
247
|
+
node_id: Union[int, str],
|
|
248
|
+
**properties: Any
|
|
249
|
+
) -> Node:
|
|
250
|
+
"""
|
|
251
|
+
Add a node to the graph.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
node_type: Type of node (e.g., 'person', 'company')
|
|
255
|
+
node_id: Unique ID within the type
|
|
256
|
+
**properties: Node properties
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
The created Node
|
|
260
|
+
|
|
261
|
+
Raises:
|
|
262
|
+
DuplicateNodeError: If node already exists
|
|
263
|
+
"""
|
|
264
|
+
if node_id in self._nodes[node_type]:
|
|
265
|
+
raise DuplicateNodeError((node_type, node_id))
|
|
266
|
+
|
|
267
|
+
node = Node(type=node_type, id=node_id, properties=properties)
|
|
268
|
+
self._nodes[node_type][node_id] = node
|
|
269
|
+
return node
|
|
270
|
+
|
|
271
|
+
def get_node(self, node_type: str, node_id: Union[int, str]) -> Node:
|
|
272
|
+
"""
|
|
273
|
+
Get a node by type and ID.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
node_type: Type of node
|
|
277
|
+
node_id: Node ID
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
The Node
|
|
281
|
+
|
|
282
|
+
Raises:
|
|
283
|
+
NodeNotFoundError: If node doesn't exist
|
|
284
|
+
"""
|
|
285
|
+
if node_type not in self._nodes or node_id not in self._nodes[node_type]:
|
|
286
|
+
raise NodeNotFoundError((node_type, node_id))
|
|
287
|
+
return self._nodes[node_type][node_id]
|
|
288
|
+
|
|
289
|
+
def get_node_by_ref(self, ref: NodeRef) -> Node:
|
|
290
|
+
"""Get node by reference tuple"""
|
|
291
|
+
return self.get_node(ref[0], ref[1])
|
|
292
|
+
|
|
293
|
+
def has_node(self, node_type: str, node_id: Union[int, str]) -> bool:
|
|
294
|
+
"""Check if node exists"""
|
|
295
|
+
return node_type in self._nodes and node_id in self._nodes[node_type]
|
|
296
|
+
|
|
297
|
+
def remove_node(self, node_type: str, node_id: Union[int, str]) -> None:
|
|
298
|
+
"""
|
|
299
|
+
Remove a node and all its edges.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
node_type: Type of node
|
|
303
|
+
node_id: Node ID
|
|
304
|
+
|
|
305
|
+
Raises:
|
|
306
|
+
NodeNotFoundError: If node doesn't exist
|
|
307
|
+
"""
|
|
308
|
+
ref = (node_type, node_id)
|
|
309
|
+
if not self.has_node(node_type, node_id):
|
|
310
|
+
raise NodeNotFoundError(ref)
|
|
311
|
+
|
|
312
|
+
# Remove all edges connected to this node
|
|
313
|
+
edges_to_remove = list(self._out_edges[ref]) + list(self._in_edges[ref])
|
|
314
|
+
for edge in edges_to_remove:
|
|
315
|
+
self._remove_edge_internal(edge)
|
|
316
|
+
|
|
317
|
+
# Remove node
|
|
318
|
+
del self._nodes[node_type][node_id]
|
|
319
|
+
if not self._nodes[node_type]:
|
|
320
|
+
del self._nodes[node_type]
|
|
321
|
+
|
|
322
|
+
def update_node(
|
|
323
|
+
self,
|
|
324
|
+
node_type: str,
|
|
325
|
+
node_id: Union[int, str],
|
|
326
|
+
**properties: Any
|
|
327
|
+
) -> Node:
|
|
328
|
+
"""Update node properties"""
|
|
329
|
+
node = self.get_node(node_type, node_id)
|
|
330
|
+
node.properties.update(properties)
|
|
331
|
+
return node
|
|
332
|
+
|
|
333
|
+
def nodes(self, node_type: Optional[str] = None) -> Iterator[Node]:
|
|
334
|
+
"""
|
|
335
|
+
Iterate over nodes.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
node_type: Filter by type (optional)
|
|
339
|
+
|
|
340
|
+
Yields:
|
|
341
|
+
Node objects
|
|
342
|
+
"""
|
|
343
|
+
if node_type:
|
|
344
|
+
if node_type in self._nodes:
|
|
345
|
+
yield from self._nodes[node_type].values()
|
|
346
|
+
else:
|
|
347
|
+
for type_nodes in self._nodes.values():
|
|
348
|
+
yield from type_nodes.values()
|
|
349
|
+
|
|
350
|
+
def node_count(self, node_type: Optional[str] = None) -> int:
|
|
351
|
+
"""Count nodes, optionally filtered by type"""
|
|
352
|
+
if node_type:
|
|
353
|
+
return len(self._nodes.get(node_type, {}))
|
|
354
|
+
return sum(len(nodes) for nodes in self._nodes.values())
|
|
355
|
+
|
|
356
|
+
def node_types(self) -> List[str]:
|
|
357
|
+
"""Get all node types in the graph"""
|
|
358
|
+
return list(self._nodes.keys())
|
|
359
|
+
|
|
360
|
+
# =========================================================================
|
|
361
|
+
# Edge Operations
|
|
362
|
+
# =========================================================================
|
|
363
|
+
|
|
364
|
+
def add_edge(
|
|
365
|
+
self,
|
|
366
|
+
rel_type: str,
|
|
367
|
+
source: NodeRef,
|
|
368
|
+
target: NodeRef,
|
|
369
|
+
**properties: Any
|
|
370
|
+
) -> Edge:
|
|
371
|
+
"""
|
|
372
|
+
Add an edge to the graph.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
rel_type: Relationship type (e.g., 'KNOWS', 'WORKS_AT')
|
|
376
|
+
source: Source node reference (type, id)
|
|
377
|
+
target: Target node reference (type, id)
|
|
378
|
+
**properties: Edge properties
|
|
379
|
+
|
|
380
|
+
Returns:
|
|
381
|
+
The created Edge
|
|
382
|
+
|
|
383
|
+
Raises:
|
|
384
|
+
NodeNotFoundError: If source or target node doesn't exist
|
|
385
|
+
DuplicateEdgeError: If edge already exists
|
|
386
|
+
"""
|
|
387
|
+
# Validate nodes exist
|
|
388
|
+
if not self.has_node(source[0], source[1]):
|
|
389
|
+
raise NodeNotFoundError(source)
|
|
390
|
+
if not self.has_node(target[0], target[1]):
|
|
391
|
+
raise NodeNotFoundError(target)
|
|
392
|
+
|
|
393
|
+
edge_key = (rel_type, source, target)
|
|
394
|
+
if edge_key in self._edge_set:
|
|
395
|
+
raise DuplicateEdgeError(edge_key)
|
|
396
|
+
|
|
397
|
+
edge = Edge(rel_type=rel_type, source=source, target=target, properties=properties)
|
|
398
|
+
|
|
399
|
+
# Add to storage
|
|
400
|
+
self._edges[rel_type].append(edge)
|
|
401
|
+
self._out_edges[source].append(edge)
|
|
402
|
+
self._in_edges[target].append(edge)
|
|
403
|
+
self._edge_set.add(edge_key)
|
|
404
|
+
|
|
405
|
+
# For undirected graphs, add reverse edge
|
|
406
|
+
if not self.directed:
|
|
407
|
+
reverse_key = (rel_type, target, source)
|
|
408
|
+
if reverse_key not in self._edge_set:
|
|
409
|
+
reverse_edge = Edge(
|
|
410
|
+
rel_type=rel_type,
|
|
411
|
+
source=target,
|
|
412
|
+
target=source,
|
|
413
|
+
properties=properties
|
|
414
|
+
)
|
|
415
|
+
self._edges[rel_type].append(reverse_edge)
|
|
416
|
+
self._out_edges[target].append(reverse_edge)
|
|
417
|
+
self._in_edges[source].append(reverse_edge)
|
|
418
|
+
self._edge_set.add(reverse_key)
|
|
419
|
+
|
|
420
|
+
return edge
|
|
421
|
+
|
|
422
|
+
def _remove_edge_internal(self, edge: Edge) -> None:
|
|
423
|
+
"""Internal method to remove an edge"""
|
|
424
|
+
if edge.key in self._edge_set:
|
|
425
|
+
self._edge_set.remove(edge.key)
|
|
426
|
+
self._edges[edge.rel_type].remove(edge)
|
|
427
|
+
self._out_edges[edge.source].remove(edge)
|
|
428
|
+
self._in_edges[edge.target].remove(edge)
|
|
429
|
+
|
|
430
|
+
def remove_edge(
|
|
431
|
+
self,
|
|
432
|
+
rel_type: str,
|
|
433
|
+
source: NodeRef,
|
|
434
|
+
target: NodeRef
|
|
435
|
+
) -> None:
|
|
436
|
+
"""Remove an edge from the graph.
|
|
437
|
+
|
|
438
|
+
On undirected graphs the auto-created reverse edge is removed too.
|
|
439
|
+
"""
|
|
440
|
+
edge_key = (rel_type, source, target)
|
|
441
|
+
if edge_key not in self._edge_set:
|
|
442
|
+
raise EdgeNotFoundError(edge_key)
|
|
443
|
+
|
|
444
|
+
keys_to_remove = [edge_key]
|
|
445
|
+
if not self.directed:
|
|
446
|
+
reverse_key = (rel_type, target, source)
|
|
447
|
+
if reverse_key != edge_key and reverse_key in self._edge_set:
|
|
448
|
+
keys_to_remove.append(reverse_key)
|
|
449
|
+
|
|
450
|
+
for key in keys_to_remove:
|
|
451
|
+
for edge in self._edges[rel_type]:
|
|
452
|
+
if edge.key == key:
|
|
453
|
+
self._remove_edge_internal(edge)
|
|
454
|
+
break
|
|
455
|
+
|
|
456
|
+
def has_edge(
|
|
457
|
+
self,
|
|
458
|
+
rel_type: str,
|
|
459
|
+
source: NodeRef,
|
|
460
|
+
target: NodeRef
|
|
461
|
+
) -> bool:
|
|
462
|
+
"""Check if edge exists"""
|
|
463
|
+
return (rel_type, source, target) in self._edge_set
|
|
464
|
+
|
|
465
|
+
def get_edge(
|
|
466
|
+
self,
|
|
467
|
+
rel_type: str,
|
|
468
|
+
source: NodeRef,
|
|
469
|
+
target: NodeRef
|
|
470
|
+
) -> Edge:
|
|
471
|
+
"""Get an edge by its components"""
|
|
472
|
+
edge_key = (rel_type, source, target)
|
|
473
|
+
if edge_key not in self._edge_set:
|
|
474
|
+
raise EdgeNotFoundError(edge_key)
|
|
475
|
+
|
|
476
|
+
for edge in self._edges[rel_type]:
|
|
477
|
+
if edge.key == edge_key:
|
|
478
|
+
return edge
|
|
479
|
+
raise EdgeNotFoundError(edge_key)
|
|
480
|
+
|
|
481
|
+
def edges(
|
|
482
|
+
self,
|
|
483
|
+
rel_type: Optional[str] = None,
|
|
484
|
+
source: Optional[NodeRef] = None,
|
|
485
|
+
target: Optional[NodeRef] = None
|
|
486
|
+
) -> Iterator[Edge]:
|
|
487
|
+
"""
|
|
488
|
+
Iterate over edges with optional filters.
|
|
489
|
+
|
|
490
|
+
Args:
|
|
491
|
+
rel_type: Filter by relationship type
|
|
492
|
+
source: Filter by source node
|
|
493
|
+
target: Filter by target node
|
|
494
|
+
|
|
495
|
+
Yields:
|
|
496
|
+
Edge objects
|
|
497
|
+
"""
|
|
498
|
+
if source:
|
|
499
|
+
edge_list = self._out_edges.get(source, [])
|
|
500
|
+
elif target:
|
|
501
|
+
edge_list = self._in_edges.get(target, [])
|
|
502
|
+
elif rel_type:
|
|
503
|
+
edge_list = self._edges.get(rel_type, [])
|
|
504
|
+
else:
|
|
505
|
+
edge_list = [e for edges in self._edges.values() for e in edges]
|
|
506
|
+
|
|
507
|
+
for edge in edge_list:
|
|
508
|
+
if rel_type and edge.rel_type != rel_type:
|
|
509
|
+
continue
|
|
510
|
+
if source and edge.source != source:
|
|
511
|
+
continue
|
|
512
|
+
if target and edge.target != target:
|
|
513
|
+
continue
|
|
514
|
+
yield edge
|
|
515
|
+
|
|
516
|
+
def edge_count(self, rel_type: Optional[str] = None) -> int:
|
|
517
|
+
"""Count edges, optionally filtered by type"""
|
|
518
|
+
if rel_type:
|
|
519
|
+
return len(self._edges.get(rel_type, []))
|
|
520
|
+
return len(self._edge_set)
|
|
521
|
+
|
|
522
|
+
def edge_types(self) -> List[str]:
|
|
523
|
+
"""Get all edge/relationship types in the graph"""
|
|
524
|
+
return list(self._edges.keys())
|
|
525
|
+
|
|
526
|
+
# =========================================================================
|
|
527
|
+
# Traversal Operations
|
|
528
|
+
# =========================================================================
|
|
529
|
+
|
|
530
|
+
def neighbors(
|
|
531
|
+
self,
|
|
532
|
+
node_ref: NodeRef,
|
|
533
|
+
rel_type: Optional[str] = None,
|
|
534
|
+
direction: Direction = Direction.OUT
|
|
535
|
+
) -> List[NodeRef]:
|
|
536
|
+
"""
|
|
537
|
+
Get neighboring nodes.
|
|
538
|
+
|
|
539
|
+
Args:
|
|
540
|
+
node_ref: Starting node reference
|
|
541
|
+
rel_type: Filter by relationship type (optional)
|
|
542
|
+
direction: OUT (default), IN, or BOTH
|
|
543
|
+
|
|
544
|
+
Returns:
|
|
545
|
+
List of neighbor node references
|
|
546
|
+
"""
|
|
547
|
+
neighbors = []
|
|
548
|
+
|
|
549
|
+
if direction in (Direction.OUT, Direction.BOTH):
|
|
550
|
+
for edge in self._out_edges.get(node_ref, []):
|
|
551
|
+
if rel_type is None or edge.rel_type == rel_type:
|
|
552
|
+
neighbors.append(edge.target)
|
|
553
|
+
|
|
554
|
+
if direction in (Direction.IN, Direction.BOTH):
|
|
555
|
+
for edge in self._in_edges.get(node_ref, []):
|
|
556
|
+
if rel_type is None or edge.rel_type == rel_type:
|
|
557
|
+
neighbors.append(edge.source)
|
|
558
|
+
|
|
559
|
+
return neighbors
|
|
560
|
+
|
|
561
|
+
def multi_hop(
|
|
562
|
+
self,
|
|
563
|
+
start: NodeRef,
|
|
564
|
+
rel_type: Optional[str] = None,
|
|
565
|
+
hops: int = 1,
|
|
566
|
+
direction: Direction = Direction.OUT
|
|
567
|
+
) -> List[NodeRef]:
|
|
568
|
+
"""
|
|
569
|
+
Get nodes N hops away.
|
|
570
|
+
|
|
571
|
+
Args:
|
|
572
|
+
start: Starting node reference
|
|
573
|
+
rel_type: Relationship type to follow (optional, any if None)
|
|
574
|
+
hops: Number of hops (default: 1)
|
|
575
|
+
direction: Traversal direction
|
|
576
|
+
|
|
577
|
+
Returns:
|
|
578
|
+
List of nodes exactly N hops away
|
|
579
|
+
"""
|
|
580
|
+
if hops < 1:
|
|
581
|
+
return [start]
|
|
582
|
+
|
|
583
|
+
current = {start}
|
|
584
|
+
visited = {start}
|
|
585
|
+
|
|
586
|
+
for _ in range(hops):
|
|
587
|
+
next_level = set()
|
|
588
|
+
for node in current:
|
|
589
|
+
for neighbor in self.neighbors(node, rel_type, direction):
|
|
590
|
+
if neighbor not in visited:
|
|
591
|
+
next_level.add(neighbor)
|
|
592
|
+
visited.update(next_level)
|
|
593
|
+
current = next_level
|
|
594
|
+
|
|
595
|
+
return list(current)
|
|
596
|
+
|
|
597
|
+
def multi_hop_range(
|
|
598
|
+
self,
|
|
599
|
+
start: NodeRef,
|
|
600
|
+
rel_type: Optional[str] = None,
|
|
601
|
+
min_hops: int = 1,
|
|
602
|
+
max_hops: int = 3,
|
|
603
|
+
direction: Direction = Direction.OUT
|
|
604
|
+
) -> List[NodeRef]:
|
|
605
|
+
"""
|
|
606
|
+
Get nodes within a range of hops.
|
|
607
|
+
|
|
608
|
+
Args:
|
|
609
|
+
start: Starting node
|
|
610
|
+
rel_type: Relationship type (optional)
|
|
611
|
+
min_hops: Minimum hops (inclusive)
|
|
612
|
+
max_hops: Maximum hops (inclusive)
|
|
613
|
+
direction: Traversal direction
|
|
614
|
+
|
|
615
|
+
Returns:
|
|
616
|
+
List of nodes within hop range
|
|
617
|
+
"""
|
|
618
|
+
result = set()
|
|
619
|
+
current = {start}
|
|
620
|
+
visited = {start}
|
|
621
|
+
|
|
622
|
+
for hop in range(1, max_hops + 1):
|
|
623
|
+
next_level = set()
|
|
624
|
+
for node in current:
|
|
625
|
+
for neighbor in self.neighbors(node, rel_type, direction):
|
|
626
|
+
if neighbor not in visited:
|
|
627
|
+
next_level.add(neighbor)
|
|
628
|
+
if hop >= min_hops:
|
|
629
|
+
result.add(neighbor)
|
|
630
|
+
visited.update(next_level)
|
|
631
|
+
current = next_level
|
|
632
|
+
|
|
633
|
+
if not current:
|
|
634
|
+
break
|
|
635
|
+
|
|
636
|
+
return list(result)
|
|
637
|
+
|
|
638
|
+
def traverse(
|
|
639
|
+
self,
|
|
640
|
+
start: NodeRef,
|
|
641
|
+
pattern: List[Tuple[str, Direction]],
|
|
642
|
+
filter_fn: Optional[Callable[[Node], bool]] = None
|
|
643
|
+
) -> List[NodeRef]:
|
|
644
|
+
"""
|
|
645
|
+
Traverse graph following a pattern of relationship types.
|
|
646
|
+
|
|
647
|
+
Args:
|
|
648
|
+
start: Starting node
|
|
649
|
+
pattern: List of (rel_type, direction) tuples defining the path
|
|
650
|
+
filter_fn: Optional filter function applied at each step
|
|
651
|
+
|
|
652
|
+
Returns:
|
|
653
|
+
List of nodes reached after following the pattern
|
|
654
|
+
|
|
655
|
+
Example:
|
|
656
|
+
# Find companies where friends work
|
|
657
|
+
graph.traverse(
|
|
658
|
+
('person', 1),
|
|
659
|
+
[('KNOWS', Direction.OUT), ('WORKS_AT', Direction.OUT)]
|
|
660
|
+
)
|
|
661
|
+
"""
|
|
662
|
+
current = {start}
|
|
663
|
+
|
|
664
|
+
for rel_type, direction in pattern:
|
|
665
|
+
next_level = set()
|
|
666
|
+
for node_ref in current:
|
|
667
|
+
neighbors = self.neighbors(node_ref, rel_type, direction)
|
|
668
|
+
for neighbor in neighbors:
|
|
669
|
+
if filter_fn is None:
|
|
670
|
+
next_level.add(neighbor)
|
|
671
|
+
else:
|
|
672
|
+
node = self.get_node_by_ref(neighbor)
|
|
673
|
+
if filter_fn(node):
|
|
674
|
+
next_level.add(neighbor)
|
|
675
|
+
current = next_level
|
|
676
|
+
if not current:
|
|
677
|
+
break
|
|
678
|
+
|
|
679
|
+
return list(current)
|
|
680
|
+
|
|
681
|
+
# =========================================================================
|
|
682
|
+
# Path Finding
|
|
683
|
+
# =========================================================================
|
|
684
|
+
|
|
685
|
+
def shortest_path(
|
|
686
|
+
self,
|
|
687
|
+
start: NodeRef,
|
|
688
|
+
end: NodeRef,
|
|
689
|
+
rel_type: Optional[str] = None,
|
|
690
|
+
max_hops: int = 10,
|
|
691
|
+
direction: Direction = Direction.OUT
|
|
692
|
+
) -> Optional[Path]:
|
|
693
|
+
"""
|
|
694
|
+
Find shortest path between two nodes using BFS.
|
|
695
|
+
|
|
696
|
+
Args:
|
|
697
|
+
start: Starting node
|
|
698
|
+
end: Target node
|
|
699
|
+
rel_type: Relationship type to follow (optional)
|
|
700
|
+
max_hops: Maximum path length
|
|
701
|
+
direction: Traversal direction
|
|
702
|
+
|
|
703
|
+
Returns:
|
|
704
|
+
Path object or None if no path exists
|
|
705
|
+
"""
|
|
706
|
+
if start == end:
|
|
707
|
+
return Path(nodes=[start], edges=[])
|
|
708
|
+
|
|
709
|
+
# BFS with path tracking
|
|
710
|
+
queue = [(start, [start], [])]
|
|
711
|
+
visited = {start}
|
|
712
|
+
|
|
713
|
+
while queue:
|
|
714
|
+
current, path_nodes, path_edges = queue.pop(0)
|
|
715
|
+
|
|
716
|
+
# A path with N nodes has N-1 hops; expanding adds one more hop,
|
|
717
|
+
# so only expand when the resulting path stays within max_hops.
|
|
718
|
+
if len(path_nodes) > max_hops:
|
|
719
|
+
continue
|
|
720
|
+
|
|
721
|
+
for edge in self._out_edges.get(current, []) if direction != Direction.IN else []:
|
|
722
|
+
if rel_type and edge.rel_type != rel_type:
|
|
723
|
+
continue
|
|
724
|
+
if edge.target == end:
|
|
725
|
+
return Path(
|
|
726
|
+
nodes=path_nodes + [edge.target],
|
|
727
|
+
edges=path_edges + [edge]
|
|
728
|
+
)
|
|
729
|
+
if edge.target not in visited:
|
|
730
|
+
visited.add(edge.target)
|
|
731
|
+
queue.append((
|
|
732
|
+
edge.target,
|
|
733
|
+
path_nodes + [edge.target],
|
|
734
|
+
path_edges + [edge]
|
|
735
|
+
))
|
|
736
|
+
|
|
737
|
+
if direction in (Direction.IN, Direction.BOTH):
|
|
738
|
+
for edge in self._in_edges.get(current, []):
|
|
739
|
+
if rel_type and edge.rel_type != rel_type:
|
|
740
|
+
continue
|
|
741
|
+
if edge.source == end:
|
|
742
|
+
return Path(
|
|
743
|
+
nodes=path_nodes + [edge.source],
|
|
744
|
+
edges=path_edges + [edge]
|
|
745
|
+
)
|
|
746
|
+
if edge.source not in visited:
|
|
747
|
+
visited.add(edge.source)
|
|
748
|
+
queue.append((
|
|
749
|
+
edge.source,
|
|
750
|
+
path_nodes + [edge.source],
|
|
751
|
+
path_edges + [edge]
|
|
752
|
+
))
|
|
753
|
+
|
|
754
|
+
return None
|
|
755
|
+
|
|
756
|
+
def all_paths(
|
|
757
|
+
self,
|
|
758
|
+
start: NodeRef,
|
|
759
|
+
end: NodeRef,
|
|
760
|
+
rel_type: Optional[str] = None,
|
|
761
|
+
max_hops: int = 5,
|
|
762
|
+
direction: Direction = Direction.OUT
|
|
763
|
+
) -> List[Path]:
|
|
764
|
+
"""
|
|
765
|
+
Find all paths between two nodes using DFS.
|
|
766
|
+
|
|
767
|
+
Args:
|
|
768
|
+
start: Starting node
|
|
769
|
+
end: Target node
|
|
770
|
+
rel_type: Relationship type (optional)
|
|
771
|
+
max_hops: Maximum path length
|
|
772
|
+
direction: Traversal direction
|
|
773
|
+
|
|
774
|
+
Returns:
|
|
775
|
+
List of all valid paths
|
|
776
|
+
"""
|
|
777
|
+
paths: List[Path] = []
|
|
778
|
+
|
|
779
|
+
def edges_from(node: NodeRef) -> List[Edge]:
|
|
780
|
+
edges_to_follow: List[Edge] = []
|
|
781
|
+
if direction in (Direction.OUT, Direction.BOTH):
|
|
782
|
+
edges_to_follow.extend(self._out_edges.get(node, []))
|
|
783
|
+
if direction in (Direction.IN, Direction.BOTH):
|
|
784
|
+
edges_to_follow.extend(
|
|
785
|
+
Edge(e.rel_type, e.target, e.source, e.properties)
|
|
786
|
+
for e in self._in_edges.get(node, [])
|
|
787
|
+
)
|
|
788
|
+
if rel_type:
|
|
789
|
+
edges_to_follow = [e for e in edges_to_follow if e.rel_type == rel_type]
|
|
790
|
+
return edges_to_follow
|
|
791
|
+
|
|
792
|
+
if start == end:
|
|
793
|
+
return [Path(nodes=[start], edges=[])]
|
|
794
|
+
|
|
795
|
+
# Iterative DFS with backtracking: one edge-iterator frame per node
|
|
796
|
+
# on the current path, so deep graphs cannot overflow the call stack.
|
|
797
|
+
path_nodes: List[NodeRef] = [start]
|
|
798
|
+
path_edges: List[Edge] = []
|
|
799
|
+
visited: Set[NodeRef] = {start}
|
|
800
|
+
stack: List[Iterator[Edge]] = [iter(edges_from(start))]
|
|
801
|
+
|
|
802
|
+
while stack:
|
|
803
|
+
edge = next(stack[-1], None)
|
|
804
|
+
if edge is None:
|
|
805
|
+
# Frame exhausted: backtrack (never pop the start node)
|
|
806
|
+
stack.pop()
|
|
807
|
+
if path_edges:
|
|
808
|
+
visited.remove(path_nodes.pop())
|
|
809
|
+
path_edges.pop()
|
|
810
|
+
continue
|
|
811
|
+
|
|
812
|
+
next_node = edge.target
|
|
813
|
+
if next_node in visited:
|
|
814
|
+
continue
|
|
815
|
+
|
|
816
|
+
visited.add(next_node)
|
|
817
|
+
path_nodes.append(next_node)
|
|
818
|
+
path_edges.append(edge)
|
|
819
|
+
|
|
820
|
+
if len(path_nodes) > max_hops + 1:
|
|
821
|
+
# Path too long: undo this step
|
|
822
|
+
visited.remove(path_nodes.pop())
|
|
823
|
+
path_edges.pop()
|
|
824
|
+
continue
|
|
825
|
+
|
|
826
|
+
if next_node == end:
|
|
827
|
+
paths.append(Path(nodes=list(path_nodes), edges=list(path_edges)))
|
|
828
|
+
visited.remove(path_nodes.pop())
|
|
829
|
+
path_edges.pop()
|
|
830
|
+
continue
|
|
831
|
+
|
|
832
|
+
stack.append(iter(edges_from(next_node)))
|
|
833
|
+
|
|
834
|
+
return paths
|
|
835
|
+
|
|
836
|
+
def path_exists(
|
|
837
|
+
self,
|
|
838
|
+
start: NodeRef,
|
|
839
|
+
end: NodeRef,
|
|
840
|
+
rel_type: Optional[str] = None,
|
|
841
|
+
max_hops: int = 10
|
|
842
|
+
) -> bool:
|
|
843
|
+
"""Check if a path exists between two nodes"""
|
|
844
|
+
return self.shortest_path(start, end, rel_type, max_hops) is not None
|
|
845
|
+
|
|
846
|
+
# =========================================================================
|
|
847
|
+
# Graph Analysis
|
|
848
|
+
# =========================================================================
|
|
849
|
+
|
|
850
|
+
def in_degree(self, node_ref: NodeRef) -> int:
|
|
851
|
+
"""Count incoming edges"""
|
|
852
|
+
return len(self._in_edges.get(node_ref, []))
|
|
853
|
+
|
|
854
|
+
def out_degree(self, node_ref: NodeRef) -> int:
|
|
855
|
+
"""Count outgoing edges"""
|
|
856
|
+
return len(self._out_edges.get(node_ref, []))
|
|
857
|
+
|
|
858
|
+
def degree(self, node_ref: NodeRef) -> int:
|
|
859
|
+
"""Total degree (in + out for directed, unique edges for undirected)"""
|
|
860
|
+
if self.directed:
|
|
861
|
+
return self.in_degree(node_ref) + self.out_degree(node_ref)
|
|
862
|
+
else:
|
|
863
|
+
# For undirected, count unique edges
|
|
864
|
+
edges = set()
|
|
865
|
+
for e in self._out_edges.get(node_ref, []):
|
|
866
|
+
edges.add(frozenset([e.source, e.target]))
|
|
867
|
+
for e in self._in_edges.get(node_ref, []):
|
|
868
|
+
edges.add(frozenset([e.source, e.target]))
|
|
869
|
+
return len(edges)
|
|
870
|
+
|
|
871
|
+
def is_connected(self) -> bool:
|
|
872
|
+
"""Check if graph is connected (all nodes reachable from any node)"""
|
|
873
|
+
all_nodes = list(self.nodes())
|
|
874
|
+
if not all_nodes:
|
|
875
|
+
return True
|
|
876
|
+
|
|
877
|
+
start = all_nodes[0].ref
|
|
878
|
+
visited = {start}
|
|
879
|
+
queue = [start]
|
|
880
|
+
|
|
881
|
+
while queue:
|
|
882
|
+
current = queue.pop(0)
|
|
883
|
+
for neighbor in self.neighbors(current, direction=Direction.BOTH):
|
|
884
|
+
if neighbor not in visited:
|
|
885
|
+
visited.add(neighbor)
|
|
886
|
+
queue.append(neighbor)
|
|
887
|
+
|
|
888
|
+
return len(visited) == len(all_nodes)
|
|
889
|
+
|
|
890
|
+
def has_cycle(self, rel_type: Optional[str] = None) -> bool:
|
|
891
|
+
"""
|
|
892
|
+
Check if graph has cycles.
|
|
893
|
+
|
|
894
|
+
For directed graphs, checks for directed cycles (iterative DFS with a
|
|
895
|
+
recursion stack). For undirected graphs, uses standard undirected
|
|
896
|
+
cycle detection: DFS that tracks the parent node it arrived from, so
|
|
897
|
+
the auto-created reverse edge is not mistaken for a cycle.
|
|
898
|
+
"""
|
|
899
|
+
if self.directed:
|
|
900
|
+
return self._has_cycle_directed(rel_type)
|
|
901
|
+
return self._has_cycle_undirected(rel_type)
|
|
902
|
+
|
|
903
|
+
def _has_cycle_directed(self, rel_type: Optional[str]) -> bool:
|
|
904
|
+
"""Directed cycle detection using an explicit DFS stack."""
|
|
905
|
+
visited: Set[NodeRef] = set()
|
|
906
|
+
|
|
907
|
+
for node in self.nodes():
|
|
908
|
+
start = node.ref
|
|
909
|
+
if start in visited:
|
|
910
|
+
continue
|
|
911
|
+
|
|
912
|
+
rec_stack: Set[NodeRef] = {start}
|
|
913
|
+
visited.add(start)
|
|
914
|
+
stack: List[Tuple[NodeRef, Iterator[NodeRef]]] = [
|
|
915
|
+
(start, iter(self.neighbors(start, rel_type)))
|
|
916
|
+
]
|
|
917
|
+
|
|
918
|
+
while stack:
|
|
919
|
+
current, neighbors_iter = stack[-1]
|
|
920
|
+
neighbor = next(neighbors_iter, None)
|
|
921
|
+
if neighbor is None:
|
|
922
|
+
stack.pop()
|
|
923
|
+
rec_stack.discard(current)
|
|
924
|
+
continue
|
|
925
|
+
if neighbor in rec_stack:
|
|
926
|
+
return True
|
|
927
|
+
if neighbor not in visited:
|
|
928
|
+
visited.add(neighbor)
|
|
929
|
+
rec_stack.add(neighbor)
|
|
930
|
+
stack.append((neighbor, iter(self.neighbors(neighbor, rel_type))))
|
|
931
|
+
|
|
932
|
+
return False
|
|
933
|
+
|
|
934
|
+
def _has_cycle_undirected(self, rel_type: Optional[str]) -> bool:
|
|
935
|
+
"""Undirected cycle detection: iterative DFS tracking the parent node."""
|
|
936
|
+
visited: Set[NodeRef] = set()
|
|
937
|
+
|
|
938
|
+
for node in self.nodes():
|
|
939
|
+
start = node.ref
|
|
940
|
+
if start in visited:
|
|
941
|
+
continue
|
|
942
|
+
|
|
943
|
+
visited.add(start)
|
|
944
|
+
stack: List[Tuple[NodeRef, Optional[NodeRef], Iterator[NodeRef]]] = [
|
|
945
|
+
(start, None, iter(self.neighbors(start, rel_type)))
|
|
946
|
+
]
|
|
947
|
+
|
|
948
|
+
while stack:
|
|
949
|
+
current, parent, neighbors_iter = stack[-1]
|
|
950
|
+
neighbor = next(neighbors_iter, None)
|
|
951
|
+
if neighbor is None:
|
|
952
|
+
stack.pop()
|
|
953
|
+
continue
|
|
954
|
+
if neighbor not in visited:
|
|
955
|
+
visited.add(neighbor)
|
|
956
|
+
stack.append(
|
|
957
|
+
(neighbor, current, iter(self.neighbors(neighbor, rel_type)))
|
|
958
|
+
)
|
|
959
|
+
elif neighbor != parent:
|
|
960
|
+
# Reached an already-visited node via a new edge -> cycle
|
|
961
|
+
return True
|
|
962
|
+
|
|
963
|
+
return False
|
|
964
|
+
|
|
965
|
+
def connected_components(self) -> List[Set[NodeRef]]:
|
|
966
|
+
"""Get all connected components"""
|
|
967
|
+
visited = set()
|
|
968
|
+
components = []
|
|
969
|
+
|
|
970
|
+
for node in self.nodes():
|
|
971
|
+
if node.ref not in visited:
|
|
972
|
+
component = set()
|
|
973
|
+
queue = [node.ref]
|
|
974
|
+
|
|
975
|
+
while queue:
|
|
976
|
+
current = queue.pop(0)
|
|
977
|
+
if current not in visited:
|
|
978
|
+
visited.add(current)
|
|
979
|
+
component.add(current)
|
|
980
|
+
for neighbor in self.neighbors(current, direction=Direction.BOTH):
|
|
981
|
+
if neighbor not in visited:
|
|
982
|
+
queue.append(neighbor)
|
|
983
|
+
|
|
984
|
+
components.append(component)
|
|
985
|
+
|
|
986
|
+
return components
|
|
987
|
+
|
|
988
|
+
# =========================================================================
|
|
989
|
+
# Serialization (ISON/ISONL)
|
|
990
|
+
# =========================================================================
|
|
991
|
+
|
|
992
|
+
def to_ison(self) -> str:
|
|
993
|
+
"""
|
|
994
|
+
Serialize graph to ISON format.
|
|
995
|
+
|
|
996
|
+
Format:
|
|
997
|
+
nodes.{type}
|
|
998
|
+
id {property_fields...}
|
|
999
|
+
{id} {property_values...}
|
|
1000
|
+
|
|
1001
|
+
edges.{rel_type}
|
|
1002
|
+
source target {property_fields...}
|
|
1003
|
+
{source_ref} {target_ref} {property_values...}
|
|
1004
|
+
"""
|
|
1005
|
+
blocks = []
|
|
1006
|
+
|
|
1007
|
+
# Serialize nodes by type
|
|
1008
|
+
for node_type in sorted(self._nodes.keys()):
|
|
1009
|
+
nodes = list(self._nodes[node_type].values())
|
|
1010
|
+
if not nodes:
|
|
1011
|
+
continue
|
|
1012
|
+
|
|
1013
|
+
# Collect all property keys
|
|
1014
|
+
prop_keys = set()
|
|
1015
|
+
for node in nodes:
|
|
1016
|
+
prop_keys.update(node.properties.keys())
|
|
1017
|
+
prop_keys = sorted(prop_keys)
|
|
1018
|
+
|
|
1019
|
+
# Build block
|
|
1020
|
+
lines = [f"nodes.{node_type}"]
|
|
1021
|
+
fields = ["id"] + prop_keys
|
|
1022
|
+
lines.append(" ".join(fields))
|
|
1023
|
+
|
|
1024
|
+
for node in nodes:
|
|
1025
|
+
values = [str(node.id)]
|
|
1026
|
+
for key in prop_keys:
|
|
1027
|
+
val = node.properties.get(key)
|
|
1028
|
+
values.append(self._value_to_ison(val))
|
|
1029
|
+
lines.append(" ".join(values))
|
|
1030
|
+
|
|
1031
|
+
blocks.append("\n".join(lines))
|
|
1032
|
+
|
|
1033
|
+
# Serialize edges by type
|
|
1034
|
+
for rel_type in sorted(self._edges.keys()):
|
|
1035
|
+
edges = self._edges[rel_type]
|
|
1036
|
+
if not edges:
|
|
1037
|
+
continue
|
|
1038
|
+
|
|
1039
|
+
# Collect all property keys
|
|
1040
|
+
prop_keys = set()
|
|
1041
|
+
for edge in edges:
|
|
1042
|
+
prop_keys.update(edge.properties.keys())
|
|
1043
|
+
prop_keys = sorted(prop_keys)
|
|
1044
|
+
|
|
1045
|
+
# Build block
|
|
1046
|
+
lines = [f"edges.{rel_type}"]
|
|
1047
|
+
fields = ["source", "target"] + prop_keys
|
|
1048
|
+
lines.append(" ".join(fields))
|
|
1049
|
+
|
|
1050
|
+
for edge in edges:
|
|
1051
|
+
source_ref = f":{edge.source[0]}:{edge.source[1]}"
|
|
1052
|
+
target_ref = f":{edge.target[0]}:{edge.target[1]}"
|
|
1053
|
+
values = [source_ref, target_ref]
|
|
1054
|
+
for key in prop_keys:
|
|
1055
|
+
val = edge.properties.get(key)
|
|
1056
|
+
values.append(self._value_to_ison(val))
|
|
1057
|
+
lines.append(" ".join(values))
|
|
1058
|
+
|
|
1059
|
+
blocks.append("\n".join(lines))
|
|
1060
|
+
|
|
1061
|
+
return "\n\n".join(blocks)
|
|
1062
|
+
|
|
1063
|
+
def _value_to_ison(self, value: Any) -> str:
|
|
1064
|
+
"""Convert value to ISON string representation.
|
|
1065
|
+
|
|
1066
|
+
Standardized quoting rule (shared by all ISON language ports): a
|
|
1067
|
+
string is wrapped in double quotes when it contains a space, ``|``,
|
|
1068
|
+
``"``, a newline, or is the empty string. Embedded ``"`` is escaped
|
|
1069
|
+
as ``\\"`` and newline as ``\\n``; the ISON/ISONL loaders reverse
|
|
1070
|
+
these so round-trips are lossless.
|
|
1071
|
+
"""
|
|
1072
|
+
if value is None:
|
|
1073
|
+
return "null"
|
|
1074
|
+
if isinstance(value, bool):
|
|
1075
|
+
return "true" if value else "false"
|
|
1076
|
+
if isinstance(value, str):
|
|
1077
|
+
if value == "" or any(ch in value for ch in (' ', '|', '"', '\n')):
|
|
1078
|
+
escaped = value.replace('"', '\\"').replace('\n', '\\n')
|
|
1079
|
+
return f'"{escaped}"'
|
|
1080
|
+
return value
|
|
1081
|
+
return str(value)
|
|
1082
|
+
|
|
1083
|
+
def to_isonl(self) -> str:
|
|
1084
|
+
"""Serialize graph to ISONL streaming format"""
|
|
1085
|
+
lines = []
|
|
1086
|
+
|
|
1087
|
+
# Serialize nodes
|
|
1088
|
+
for node in self.nodes():
|
|
1089
|
+
prop_keys = sorted(node.properties.keys())
|
|
1090
|
+
fields = ["id"] + prop_keys
|
|
1091
|
+
values = [str(node.id)] + [
|
|
1092
|
+
self._value_to_ison(node.properties.get(k))
|
|
1093
|
+
for k in prop_keys
|
|
1094
|
+
]
|
|
1095
|
+
line = f"nodes.{node.type}|{' '.join(fields)}|{' '.join(values)}"
|
|
1096
|
+
lines.append(line)
|
|
1097
|
+
|
|
1098
|
+
# Serialize edges
|
|
1099
|
+
for rel_type, edges in self._edges.items():
|
|
1100
|
+
for edge in edges:
|
|
1101
|
+
prop_keys = sorted(edge.properties.keys())
|
|
1102
|
+
fields = ["source", "target"] + prop_keys
|
|
1103
|
+
source_ref = f":{edge.source[0]}:{edge.source[1]}"
|
|
1104
|
+
target_ref = f":{edge.target[0]}:{edge.target[1]}"
|
|
1105
|
+
values = [source_ref, target_ref] + [
|
|
1106
|
+
self._value_to_ison(edge.properties.get(k))
|
|
1107
|
+
for k in prop_keys
|
|
1108
|
+
]
|
|
1109
|
+
line = f"edges.{rel_type}|{' '.join(fields)}|{' '.join(values)}"
|
|
1110
|
+
lines.append(line)
|
|
1111
|
+
|
|
1112
|
+
return "\n".join(lines)
|
|
1113
|
+
|
|
1114
|
+
def save(self, path: Union[str, FilePath], format: str = "auto") -> None:
|
|
1115
|
+
"""
|
|
1116
|
+
Save graph to file.
|
|
1117
|
+
|
|
1118
|
+
Args:
|
|
1119
|
+
path: Output file path
|
|
1120
|
+
format: 'ison', 'isonl', or 'auto' (detect from extension)
|
|
1121
|
+
"""
|
|
1122
|
+
path = FilePath(path)
|
|
1123
|
+
|
|
1124
|
+
if format == "auto":
|
|
1125
|
+
format = "isonl" if path.suffix == ".isonl" else "ison"
|
|
1126
|
+
|
|
1127
|
+
if format == "isonl":
|
|
1128
|
+
content = self.to_isonl()
|
|
1129
|
+
else:
|
|
1130
|
+
content = self.to_ison()
|
|
1131
|
+
|
|
1132
|
+
path.write_text(content, encoding="utf-8")
|
|
1133
|
+
|
|
1134
|
+
@classmethod
|
|
1135
|
+
def from_ison(cls, text: str, name: str = "graph") -> 'ISONGraph':
|
|
1136
|
+
"""
|
|
1137
|
+
Parse graph from ISON format.
|
|
1138
|
+
|
|
1139
|
+
Args:
|
|
1140
|
+
text: ISON formatted string
|
|
1141
|
+
name: Graph name
|
|
1142
|
+
|
|
1143
|
+
Returns:
|
|
1144
|
+
ISONGraph instance
|
|
1145
|
+
"""
|
|
1146
|
+
graph = cls(name=name)
|
|
1147
|
+
doc = loads(text)
|
|
1148
|
+
|
|
1149
|
+
for block in doc.blocks:
|
|
1150
|
+
if block.kind == "nodes":
|
|
1151
|
+
node_type = block.name
|
|
1152
|
+
for row in block.rows:
|
|
1153
|
+
node_id = row.get("id")
|
|
1154
|
+
props = {k: v for k, v in row.items() if k != "id"}
|
|
1155
|
+
graph.add_node(node_type, node_id, **props)
|
|
1156
|
+
|
|
1157
|
+
elif block.kind == "edges":
|
|
1158
|
+
rel_type = block.name
|
|
1159
|
+
for row in block.rows:
|
|
1160
|
+
source = row.get("source")
|
|
1161
|
+
target = row.get("target")
|
|
1162
|
+
props = {k: v for k, v in row.items() if k not in ("source", "target")}
|
|
1163
|
+
|
|
1164
|
+
# Convert Reference objects to tuples
|
|
1165
|
+
if isinstance(source, Reference):
|
|
1166
|
+
source = (source.type, int(source.id) if source.id.isdigit() else source.id)
|
|
1167
|
+
if isinstance(target, Reference):
|
|
1168
|
+
target = (target.type, int(target.id) if target.id.isdigit() else target.id)
|
|
1169
|
+
|
|
1170
|
+
graph.add_edge(rel_type, source, target, **props)
|
|
1171
|
+
|
|
1172
|
+
return graph
|
|
1173
|
+
|
|
1174
|
+
@classmethod
|
|
1175
|
+
def from_isonl(cls, text: str, name: str = "graph") -> 'ISONGraph':
|
|
1176
|
+
"""Parse graph from ISONL format"""
|
|
1177
|
+
graph = cls(name=name)
|
|
1178
|
+
doc = loads_isonl(text)
|
|
1179
|
+
|
|
1180
|
+
# Same logic as from_ison since loads_isonl returns Document
|
|
1181
|
+
for block in doc.blocks:
|
|
1182
|
+
if block.kind == "nodes":
|
|
1183
|
+
node_type = block.name
|
|
1184
|
+
for row in block.rows:
|
|
1185
|
+
node_id = row.get("id")
|
|
1186
|
+
props = {k: v for k, v in row.items() if k != "id"}
|
|
1187
|
+
graph.add_node(node_type, node_id, **props)
|
|
1188
|
+
|
|
1189
|
+
elif block.kind == "edges":
|
|
1190
|
+
rel_type = block.name
|
|
1191
|
+
for row in block.rows:
|
|
1192
|
+
source = row.get("source")
|
|
1193
|
+
target = row.get("target")
|
|
1194
|
+
props = {k: v for k, v in row.items() if k not in ("source", "target")}
|
|
1195
|
+
|
|
1196
|
+
if isinstance(source, Reference):
|
|
1197
|
+
source = (source.type, int(source.id) if source.id.isdigit() else source.id)
|
|
1198
|
+
if isinstance(target, Reference):
|
|
1199
|
+
target = (target.type, int(target.id) if target.id.isdigit() else target.id)
|
|
1200
|
+
|
|
1201
|
+
graph.add_edge(rel_type, source, target, **props)
|
|
1202
|
+
|
|
1203
|
+
return graph
|
|
1204
|
+
|
|
1205
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
1206
|
+
"""
|
|
1207
|
+
Serialize graph to dictionary/JSON format.
|
|
1208
|
+
|
|
1209
|
+
Returns:
|
|
1210
|
+
Dictionary with 'name', 'directed', 'nodes', and 'edges' keys
|
|
1211
|
+
"""
|
|
1212
|
+
result = {
|
|
1213
|
+
"name": self.name,
|
|
1214
|
+
"directed": self.directed,
|
|
1215
|
+
"nodes": [],
|
|
1216
|
+
"edges": []
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
# Serialize nodes
|
|
1220
|
+
for node in self.nodes():
|
|
1221
|
+
node_dict = {
|
|
1222
|
+
"type": node.type,
|
|
1223
|
+
"id": node.id,
|
|
1224
|
+
**node.properties
|
|
1225
|
+
}
|
|
1226
|
+
result["nodes"].append(node_dict)
|
|
1227
|
+
|
|
1228
|
+
# Serialize edges
|
|
1229
|
+
for edge in self.edges():
|
|
1230
|
+
edge_dict = {
|
|
1231
|
+
"rel_type": edge.rel_type,
|
|
1232
|
+
"source": {"type": edge.source[0], "id": edge.source[1]},
|
|
1233
|
+
"target": {"type": edge.target[0], "id": edge.target[1]},
|
|
1234
|
+
"properties": edge.properties
|
|
1235
|
+
}
|
|
1236
|
+
result["edges"].append(edge_dict)
|
|
1237
|
+
|
|
1238
|
+
return result
|
|
1239
|
+
|
|
1240
|
+
@classmethod
|
|
1241
|
+
def from_dict(cls, data: Dict[str, Any], name: Optional[str] = None) -> 'ISONGraph':
|
|
1242
|
+
"""
|
|
1243
|
+
Parse graph from dictionary/JSON format.
|
|
1244
|
+
|
|
1245
|
+
Args:
|
|
1246
|
+
data: Dictionary with 'nodes' and 'edges' keys
|
|
1247
|
+
name: Graph name (uses data['name'] if not provided)
|
|
1248
|
+
|
|
1249
|
+
Returns:
|
|
1250
|
+
ISONGraph instance
|
|
1251
|
+
"""
|
|
1252
|
+
graph_name = name or data.get("name", "graph")
|
|
1253
|
+
directed = data.get("directed", True)
|
|
1254
|
+
graph = cls(name=graph_name, directed=directed)
|
|
1255
|
+
|
|
1256
|
+
# Parse nodes
|
|
1257
|
+
for node_data in data.get("nodes", []):
|
|
1258
|
+
node_type = node_data.get("type", "entity")
|
|
1259
|
+
node_id = node_data.get("id")
|
|
1260
|
+
# Remaining fields are properties
|
|
1261
|
+
props = {k: v for k, v in node_data.items() if k not in ("type", "id")}
|
|
1262
|
+
graph.add_node(node_type, node_id, **props)
|
|
1263
|
+
|
|
1264
|
+
# Parse edges
|
|
1265
|
+
for edge_data in data.get("edges", []):
|
|
1266
|
+
rel_type = edge_data.get("rel_type", edge_data.get("type", "RELATED_TO"))
|
|
1267
|
+
|
|
1268
|
+
# Handle source/target as dict or tuple
|
|
1269
|
+
source = edge_data.get("source")
|
|
1270
|
+
target = edge_data.get("target")
|
|
1271
|
+
|
|
1272
|
+
if isinstance(source, dict):
|
|
1273
|
+
source = (source.get("type", "entity"), source.get("id"))
|
|
1274
|
+
if isinstance(target, dict):
|
|
1275
|
+
target = (target.get("type", "entity"), target.get("id"))
|
|
1276
|
+
|
|
1277
|
+
props = edge_data.get("properties", {})
|
|
1278
|
+
# Also support inline properties
|
|
1279
|
+
for k, v in edge_data.items():
|
|
1280
|
+
if k not in ("rel_type", "type", "source", "target", "properties"):
|
|
1281
|
+
props[k] = v
|
|
1282
|
+
|
|
1283
|
+
graph.add_edge(rel_type, source, target, **props)
|
|
1284
|
+
|
|
1285
|
+
return graph
|
|
1286
|
+
|
|
1287
|
+
@classmethod
|
|
1288
|
+
def load(cls, path: Union[str, FilePath], format: str = "auto") -> 'ISONGraph':
|
|
1289
|
+
"""
|
|
1290
|
+
Load graph from file.
|
|
1291
|
+
|
|
1292
|
+
Args:
|
|
1293
|
+
path: Input file path
|
|
1294
|
+
format: 'ison', 'isonl', or 'auto'
|
|
1295
|
+
|
|
1296
|
+
Returns:
|
|
1297
|
+
ISONGraph instance
|
|
1298
|
+
"""
|
|
1299
|
+
path = FilePath(path)
|
|
1300
|
+
text = path.read_text(encoding="utf-8")
|
|
1301
|
+
|
|
1302
|
+
if format == "auto":
|
|
1303
|
+
format = "isonl" if path.suffix == ".isonl" else "ison"
|
|
1304
|
+
|
|
1305
|
+
name = path.stem
|
|
1306
|
+
|
|
1307
|
+
if format == "isonl":
|
|
1308
|
+
return cls.from_isonl(text, name)
|
|
1309
|
+
return cls.from_ison(text, name)
|
|
1310
|
+
|
|
1311
|
+
@classmethod
|
|
1312
|
+
def parse(cls, text: str, name: str = "graph") -> 'ISONGraph':
|
|
1313
|
+
"""Alias for from_ison"""
|
|
1314
|
+
return cls.from_ison(text, name)
|
|
1315
|
+
|
|
1316
|
+
# =========================================================================
|
|
1317
|
+
# Query Interface (Fluent API)
|
|
1318
|
+
# =========================================================================
|
|
1319
|
+
|
|
1320
|
+
def start(self, node_ref: NodeRef) -> 'GraphTraversal':
|
|
1321
|
+
"""
|
|
1322
|
+
Start a fluent traversal from a node.
|
|
1323
|
+
|
|
1324
|
+
Example:
|
|
1325
|
+
graph.start(('person', 1)) \\
|
|
1326
|
+
.hop('KNOWS') \\
|
|
1327
|
+
.hop('WORKS_AT') \\
|
|
1328
|
+
.collect()
|
|
1329
|
+
"""
|
|
1330
|
+
return GraphTraversal(self, node_ref)
|
|
1331
|
+
|
|
1332
|
+
def query(self, pattern: str) -> List[NodeRef]:
|
|
1333
|
+
"""
|
|
1334
|
+
Execute a simple pattern query.
|
|
1335
|
+
|
|
1336
|
+
Pattern syntax:
|
|
1337
|
+
:type:id -[:REL]-> :type:id
|
|
1338
|
+
:type:id -[:REL*N]-> * (N hops)
|
|
1339
|
+
:type:id -[:REL*1..3]-> * (1-3 hops)
|
|
1340
|
+
|
|
1341
|
+
Example:
|
|
1342
|
+
graph.query(":person:1 -[:KNOWS*2]-> *")
|
|
1343
|
+
"""
|
|
1344
|
+
# Simple pattern parsing
|
|
1345
|
+
pattern = pattern.strip()
|
|
1346
|
+
|
|
1347
|
+
# Match: :type:id -[:REL*N]-> *
|
|
1348
|
+
hop_match = re.match(
|
|
1349
|
+
r':(\w+):(\w+)\s*-\[:(\w+)\*(\d+)\]->\s*\*',
|
|
1350
|
+
pattern
|
|
1351
|
+
)
|
|
1352
|
+
if hop_match:
|
|
1353
|
+
node_type, node_id, rel_type, hops = hop_match.groups()
|
|
1354
|
+
node_id = int(node_id) if node_id.isdigit() else node_id
|
|
1355
|
+
return self.multi_hop((node_type, node_id), rel_type, int(hops))
|
|
1356
|
+
|
|
1357
|
+
# Match: :type:id -[:REL*N..M]-> *
|
|
1358
|
+
range_match = re.match(
|
|
1359
|
+
r':(\w+):(\w+)\s*-\[:(\w+)\*(\d+)\.\.(\d+)\]->\s*\*',
|
|
1360
|
+
pattern
|
|
1361
|
+
)
|
|
1362
|
+
if range_match:
|
|
1363
|
+
node_type, node_id, rel_type, min_h, max_h = range_match.groups()
|
|
1364
|
+
node_id = int(node_id) if node_id.isdigit() else node_id
|
|
1365
|
+
return self.multi_hop_range(
|
|
1366
|
+
(node_type, node_id), rel_type,
|
|
1367
|
+
int(min_h), int(max_h)
|
|
1368
|
+
)
|
|
1369
|
+
|
|
1370
|
+
# Match: :type:id -[:REL]-> *
|
|
1371
|
+
simple_match = re.match(
|
|
1372
|
+
r':(\w+):(\w+)\s*-\[:(\w+)\]->\s*\*',
|
|
1373
|
+
pattern
|
|
1374
|
+
)
|
|
1375
|
+
if simple_match:
|
|
1376
|
+
node_type, node_id, rel_type = simple_match.groups()
|
|
1377
|
+
node_id = int(node_id) if node_id.isdigit() else node_id
|
|
1378
|
+
return self.neighbors((node_type, node_id), rel_type)
|
|
1379
|
+
|
|
1380
|
+
raise ValueError(f"Invalid query pattern: {pattern}")
|
|
1381
|
+
|
|
1382
|
+
def __repr__(self) -> str:
|
|
1383
|
+
return f"ISONGraph(name={self.name}, nodes={self.node_count()}, edges={self.edge_count()})"
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
# =============================================================================
|
|
1387
|
+
# Fluent Traversal API
|
|
1388
|
+
# =============================================================================
|
|
1389
|
+
|
|
1390
|
+
class GraphTraversal:
|
|
1391
|
+
"""Fluent API for graph traversal"""
|
|
1392
|
+
|
|
1393
|
+
def __init__(self, graph: ISONGraph, start: NodeRef):
|
|
1394
|
+
self._graph = graph
|
|
1395
|
+
self._current: Set[NodeRef] = {start}
|
|
1396
|
+
self._visited: Set[NodeRef] = {start}
|
|
1397
|
+
|
|
1398
|
+
def hop(
|
|
1399
|
+
self,
|
|
1400
|
+
rel_type: Optional[str] = None,
|
|
1401
|
+
direction: Direction = Direction.OUT,
|
|
1402
|
+
where: Optional[Callable[[Node], bool]] = None
|
|
1403
|
+
) -> 'GraphTraversal':
|
|
1404
|
+
"""
|
|
1405
|
+
Traverse one hop following edges.
|
|
1406
|
+
|
|
1407
|
+
Args:
|
|
1408
|
+
rel_type: Relationship type (optional, any if None)
|
|
1409
|
+
direction: Traversal direction
|
|
1410
|
+
where: Filter function for nodes
|
|
1411
|
+
|
|
1412
|
+
Returns:
|
|
1413
|
+
Self for chaining
|
|
1414
|
+
"""
|
|
1415
|
+
next_level = set()
|
|
1416
|
+
|
|
1417
|
+
for node_ref in self._current:
|
|
1418
|
+
neighbors = self._graph.neighbors(node_ref, rel_type, direction)
|
|
1419
|
+
for neighbor in neighbors:
|
|
1420
|
+
if neighbor not in self._visited:
|
|
1421
|
+
if where is None:
|
|
1422
|
+
next_level.add(neighbor)
|
|
1423
|
+
else:
|
|
1424
|
+
node = self._graph.get_node_by_ref(neighbor)
|
|
1425
|
+
if where(node):
|
|
1426
|
+
next_level.add(neighbor)
|
|
1427
|
+
|
|
1428
|
+
self._visited.update(next_level)
|
|
1429
|
+
self._current = next_level
|
|
1430
|
+
return self
|
|
1431
|
+
|
|
1432
|
+
def hops(
|
|
1433
|
+
self,
|
|
1434
|
+
n: int,
|
|
1435
|
+
rel_type: Optional[str] = None,
|
|
1436
|
+
direction: Direction = Direction.OUT
|
|
1437
|
+
) -> 'GraphTraversal':
|
|
1438
|
+
"""Traverse N hops"""
|
|
1439
|
+
for _ in range(n):
|
|
1440
|
+
self.hop(rel_type, direction)
|
|
1441
|
+
return self
|
|
1442
|
+
|
|
1443
|
+
def filter(self, fn: Callable[[Node], bool]) -> 'GraphTraversal':
|
|
1444
|
+
"""Filter current nodes"""
|
|
1445
|
+
self._current = {
|
|
1446
|
+
ref for ref in self._current
|
|
1447
|
+
if fn(self._graph.get_node_by_ref(ref))
|
|
1448
|
+
}
|
|
1449
|
+
return self
|
|
1450
|
+
|
|
1451
|
+
def collect(self) -> List[NodeRef]:
|
|
1452
|
+
"""Return current nodes as list"""
|
|
1453
|
+
return list(self._current)
|
|
1454
|
+
|
|
1455
|
+
def collect_nodes(self) -> List[Node]:
|
|
1456
|
+
"""Return current nodes as Node objects"""
|
|
1457
|
+
return [self._graph.get_node_by_ref(ref) for ref in self._current]
|
|
1458
|
+
|
|
1459
|
+
def count(self) -> int:
|
|
1460
|
+
"""Count current nodes"""
|
|
1461
|
+
return len(self._current)
|
|
1462
|
+
|
|
1463
|
+
def first(self) -> Optional[NodeRef]:
|
|
1464
|
+
"""Get first node or None"""
|
|
1465
|
+
return next(iter(self._current), None)
|
|
1466
|
+
|
|
1467
|
+
|
|
1468
|
+
# =============================================================================
|
|
1469
|
+
# Exports
|
|
1470
|
+
# =============================================================================
|
|
1471
|
+
|
|
1472
|
+
__all__ = [
|
|
1473
|
+
# Version
|
|
1474
|
+
'__version__',
|
|
1475
|
+
|
|
1476
|
+
# Core Classes
|
|
1477
|
+
'ISONGraph',
|
|
1478
|
+
'Node',
|
|
1479
|
+
'Edge',
|
|
1480
|
+
'Path',
|
|
1481
|
+
'GraphTraversal',
|
|
1482
|
+
|
|
1483
|
+
# Types
|
|
1484
|
+
'NodeRef',
|
|
1485
|
+
'EdgeKey',
|
|
1486
|
+
'Direction',
|
|
1487
|
+
|
|
1488
|
+
# Errors
|
|
1489
|
+
'GraphError',
|
|
1490
|
+
'NodeNotFoundError',
|
|
1491
|
+
'EdgeNotFoundError',
|
|
1492
|
+
'DuplicateNodeError',
|
|
1493
|
+
'DuplicateEdgeError',
|
|
1494
|
+
|
|
1495
|
+
# Submodules
|
|
1496
|
+
'query',
|
|
1497
|
+
'schema',
|
|
1498
|
+
]
|
|
1499
|
+
|
|
1500
|
+
|
|
1501
|
+
# =============================================================================
|
|
1502
|
+
# Submodule Imports (Lazy)
|
|
1503
|
+
# =============================================================================
|
|
1504
|
+
|
|
1505
|
+
def __getattr__(name: str):
|
|
1506
|
+
"""Lazy import for submodules."""
|
|
1507
|
+
if name == 'query':
|
|
1508
|
+
from . import query
|
|
1509
|
+
return query
|
|
1510
|
+
if name == 'schema':
|
|
1511
|
+
from . import schema
|
|
1512
|
+
return schema
|
|
1513
|
+
raise AttributeError(f"module 'ison_graph' has no attribute '{name}'")
|