fromscratch-pygraphdb 0.4.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.
pygraphdb/graph.py ADDED
@@ -0,0 +1,509 @@
1
+ """Graph -- the container that holds nodes and edges together.
2
+
3
+ Imagine a giant whiteboard in your classroom. You can stick Post-it notes
4
+ on it (those are nodes) and draw lines between them (those are edges).
5
+ The whiteboard itself is the Graph -- it keeps track of everything and
6
+ lets you add, find, update, or remove nodes and edges.
7
+
8
+ This is the heart of PyGraphDB. Everything else (traversal, queries,
9
+ persistence) builds on top of this container.
10
+
11
+ Example:
12
+ >>> from pygraphdb.graph import Graph
13
+ >>> g = Graph()
14
+ >>> g.add_node("alice", label="Person", properties={"name": "Alice"})
15
+ >>> g.add_node("bob", label="Person", properties={"name": "Bob"})
16
+ >>> g.add_edge("e1", "alice", "bob", rel_type="KNOWS")
17
+ >>> g.get_node("alice").properties["name"]
18
+ 'Alice'
19
+
20
+ """
21
+
22
+ import copy
23
+
24
+ from pygraphdb.edge import Edge
25
+ from pygraphdb.errors import (
26
+ DuplicateEdgeError,
27
+ DuplicateNodeError,
28
+ EdgeNotFoundError,
29
+ NodeNotFoundError,
30
+ )
31
+ from pygraphdb.node import Node
32
+
33
+
34
+ class Graph:
35
+ """A container for nodes and edges forming a directed graph.
36
+
37
+ Think of the Graph as a city map:
38
+ - Nodes are buildings (houses, schools, shops)
39
+ - Edges are roads connecting them
40
+ - The Graph keeps a directory of all buildings and all roads
41
+
42
+ You can add, look up, update, and remove both nodes and edges.
43
+
44
+ Every building also keeps its *own* list of roads leaving it and roads
45
+ arriving at it. So "who are my neighbours?" only looks at that one
46
+ building's roads -- never at every road in the city. Graph databases
47
+ call this **index-free adjacency**, and it's why they're fast at
48
+ following connections.
49
+ """
50
+
51
+ def __init__(self) -> None:
52
+ """Initialize an empty graph."""
53
+ self._nodes: dict[str, Node] = {}
54
+ self._edges: dict[str, Edge] = {}
55
+ # node id -> ids of its outgoing / incoming edges. A dict with no
56
+ # values works as a set that remembers the order roads were built.
57
+ self._out: dict[str, dict[str, None]] = {}
58
+ self._in: dict[str, dict[str, None]] = {}
59
+
60
+ def _store_node(self, node: Node) -> None:
61
+ """File a node and give it empty road lists."""
62
+ self._nodes[node.node_id] = node
63
+ self._out[node.node_id] = {}
64
+ self._in[node.node_id] = {}
65
+
66
+ def _store_edge(self, edge: Edge) -> None:
67
+ """File an edge and add it to both end nodes' road lists."""
68
+ self._edges[edge.edge_id] = edge
69
+ self._out[edge.source_id][edge.edge_id] = None
70
+ self._in[edge.target_id][edge.edge_id] = None
71
+
72
+ def _discard_edge(self, edge_id: str) -> Edge:
73
+ """Remove an edge and cross it off both end nodes' road lists."""
74
+ edge = self._edges.pop(edge_id)
75
+ self._out[edge.source_id].pop(edge_id, None)
76
+ self._in[edge.target_id].pop(edge_id, None)
77
+ return edge
78
+
79
+ @property
80
+ def node_count(self) -> int:
81
+ """Return the number of nodes in the graph."""
82
+ return len(self._nodes)
83
+
84
+ @property
85
+ def edge_count(self) -> int:
86
+ """Return the number of edges in the graph."""
87
+ return len(self._edges)
88
+
89
+ # --- Node CRUD ---
90
+
91
+ def add_node(
92
+ self,
93
+ node_id: str,
94
+ *,
95
+ label: str = "",
96
+ properties: dict[str, object] | None = None,
97
+ ) -> Node:
98
+ """Add a new node to the graph.
99
+
100
+ Args:
101
+ node_id: A unique identifier for the node.
102
+ label: An optional category label.
103
+ properties: Optional key-value attributes.
104
+
105
+ Returns:
106
+ The newly created Node.
107
+
108
+ Raises:
109
+ DuplicateNodeError: If a node with this ID already exists.
110
+
111
+ """
112
+ if node_id in self._nodes:
113
+ msg = f"Node '{node_id}' already exists"
114
+ raise DuplicateNodeError(msg)
115
+ # Keep our own copy, so changing the caller's dict later can't reach in.
116
+ node = Node(node_id=node_id, label=label, properties=copy.deepcopy(properties or {}))
117
+ self._store_node(node)
118
+ return node
119
+
120
+ def get_node(self, node_id: str) -> Node:
121
+ """Retrieve a node by its ID.
122
+
123
+ Args:
124
+ node_id: The unique identifier of the node.
125
+
126
+ Returns:
127
+ The matching Node.
128
+
129
+ Raises:
130
+ NodeNotFoundError: If no node with this ID exists.
131
+
132
+ """
133
+ if node_id not in self._nodes:
134
+ msg = f"Node '{node_id}' not found"
135
+ raise NodeNotFoundError(msg)
136
+ return self._nodes[node_id]
137
+
138
+ def has_node(self, node_id: str) -> bool:
139
+ """Check whether a node with the given ID exists.
140
+
141
+ Args:
142
+ node_id: The node ID to check.
143
+
144
+ Returns:
145
+ True if the node exists, False otherwise.
146
+
147
+ """
148
+ return node_id in self._nodes
149
+
150
+ def update_node(
151
+ self,
152
+ node_id: str,
153
+ *,
154
+ label: str | None = None,
155
+ properties: dict[str, object] | None = None,
156
+ ) -> Node:
157
+ """Update an existing node's label and/or properties.
158
+
159
+ Args:
160
+ node_id: The ID of the node to update.
161
+ label: New label (if provided).
162
+ properties: Properties to merge in (if provided).
163
+
164
+ Returns:
165
+ The updated Node.
166
+
167
+ Raises:
168
+ NodeNotFoundError: If the node does not exist.
169
+
170
+ """
171
+ node = self.get_node(node_id)
172
+ if label is not None:
173
+ node.label = label
174
+ if properties is not None:
175
+ node.properties.update(copy.deepcopy(properties))
176
+ return node
177
+
178
+ def remove_node(self, node_id: str) -> Node:
179
+ """Remove a node and all its connected edges from the graph.
180
+
181
+ Args:
182
+ node_id: The ID of the node to remove.
183
+
184
+ Returns:
185
+ The removed Node.
186
+
187
+ Raises:
188
+ NodeNotFoundError: If the node does not exist.
189
+
190
+ """
191
+ node = self.get_node(node_id)
192
+ # Remove all edges connected to this node -- its own road lists say which.
193
+ for eid in [*self._out[node_id], *self._in[node_id]]:
194
+ if eid in self._edges: # a loop edge appears in both lists
195
+ self._discard_edge(eid)
196
+ del self._nodes[node_id]
197
+ del self._out[node_id]
198
+ del self._in[node_id]
199
+ return node
200
+
201
+ def get_all_nodes(self) -> list[Node]:
202
+ """Return a list of all nodes in the graph.
203
+
204
+ Returns:
205
+ A list of all Node objects.
206
+
207
+ """
208
+ return list(self._nodes.values())
209
+
210
+ # --- Edge CRUD ---
211
+
212
+ def add_edge(
213
+ self,
214
+ edge_id: str,
215
+ source_id: str,
216
+ target_id: str,
217
+ *,
218
+ rel_type: str = "",
219
+ properties: dict[str, object] | None = None,
220
+ ) -> Edge:
221
+ """Add a new directed edge between two nodes.
222
+
223
+ Args:
224
+ edge_id: A unique identifier for the edge.
225
+ source_id: The ID of the source node.
226
+ target_id: The ID of the target node.
227
+ rel_type: The relationship type.
228
+ properties: Optional key-value attributes.
229
+
230
+ Returns:
231
+ The newly created Edge.
232
+
233
+ Raises:
234
+ DuplicateEdgeError: If an edge with this ID already exists.
235
+ NodeNotFoundError: If source or target node does not exist.
236
+
237
+ """
238
+ if edge_id in self._edges:
239
+ msg = f"Edge '{edge_id}' already exists"
240
+ raise DuplicateEdgeError(msg)
241
+ if source_id not in self._nodes:
242
+ msg = f"Source node '{source_id}' not found"
243
+ raise NodeNotFoundError(msg)
244
+ if target_id not in self._nodes:
245
+ msg = f"Target node '{target_id}' not found"
246
+ raise NodeNotFoundError(msg)
247
+ edge = Edge(
248
+ edge_id=edge_id,
249
+ source_id=source_id,
250
+ target_id=target_id,
251
+ rel_type=rel_type,
252
+ properties=copy.deepcopy(properties or {}), # our own copy, as in add_node
253
+ )
254
+ self._store_edge(edge)
255
+ return edge
256
+
257
+ def get_edge(self, edge_id: str) -> Edge:
258
+ """Retrieve an edge by its ID.
259
+
260
+ Args:
261
+ edge_id: The unique identifier of the edge.
262
+
263
+ Returns:
264
+ The matching Edge.
265
+
266
+ Raises:
267
+ EdgeNotFoundError: If no edge with this ID exists.
268
+
269
+ """
270
+ if edge_id not in self._edges:
271
+ msg = f"Edge '{edge_id}' not found"
272
+ raise EdgeNotFoundError(msg)
273
+ return self._edges[edge_id]
274
+
275
+ def has_edge(self, edge_id: str) -> bool:
276
+ """Check whether an edge with the given ID exists.
277
+
278
+ Args:
279
+ edge_id: The edge ID to check.
280
+
281
+ Returns:
282
+ True if the edge exists, False otherwise.
283
+
284
+ """
285
+ return edge_id in self._edges
286
+
287
+ def update_edge(
288
+ self,
289
+ edge_id: str,
290
+ *,
291
+ rel_type: str | None = None,
292
+ properties: dict[str, object] | None = None,
293
+ ) -> Edge:
294
+ """Update an existing edge's type and/or properties.
295
+
296
+ Args:
297
+ edge_id: The ID of the edge to update.
298
+ rel_type: New relationship type (if provided).
299
+ properties: Properties to merge in (if provided).
300
+
301
+ Returns:
302
+ The updated Edge.
303
+
304
+ Raises:
305
+ EdgeNotFoundError: If the edge does not exist.
306
+
307
+ """
308
+ edge = self.get_edge(edge_id)
309
+ if rel_type is not None:
310
+ edge.rel_type = rel_type
311
+ if properties is not None:
312
+ edge.properties.update(copy.deepcopy(properties))
313
+ return edge
314
+
315
+ def remove_edge(self, edge_id: str) -> Edge:
316
+ """Remove an edge from the graph.
317
+
318
+ Args:
319
+ edge_id: The ID of the edge to remove.
320
+
321
+ Returns:
322
+ The removed Edge.
323
+
324
+ Raises:
325
+ EdgeNotFoundError: If the edge does not exist.
326
+
327
+ """
328
+ self.get_edge(edge_id) # Validate existence
329
+ return self._discard_edge(edge_id)
330
+
331
+ def get_all_edges(self) -> list[Edge]:
332
+ """Return a list of all edges in the graph.
333
+
334
+ Returns:
335
+ A list of all Edge objects.
336
+
337
+ """
338
+ return list(self._edges.values())
339
+
340
+ # --- Neighbor / Degree queries ---
341
+
342
+ def get_outgoing_edges(self, node_id: str) -> list[Edge]:
343
+ """Get all edges going out from a node.
344
+
345
+ Args:
346
+ node_id: The source node ID.
347
+
348
+ Returns:
349
+ A list of outgoing edges.
350
+
351
+ Raises:
352
+ NodeNotFoundError: If the node does not exist.
353
+
354
+ """
355
+ self.get_node(node_id) # Validate existence
356
+ return [self._edges[eid] for eid in self._out[node_id]]
357
+
358
+ def get_incoming_edges(self, node_id: str) -> list[Edge]:
359
+ """Get all edges coming into a node.
360
+
361
+ Args:
362
+ node_id: The target node ID.
363
+
364
+ Returns:
365
+ A list of incoming edges.
366
+
367
+ Raises:
368
+ NodeNotFoundError: If the node does not exist.
369
+
370
+ """
371
+ self.get_node(node_id) # Validate existence
372
+ return [self._edges[eid] for eid in self._in[node_id]]
373
+
374
+ def get_neighbors(
375
+ self,
376
+ node_id: str,
377
+ *,
378
+ direction: str = "both",
379
+ ) -> list[Node]:
380
+ """Get all nodes connected to a given node.
381
+
382
+ Args:
383
+ node_id: The node to find neighbors for.
384
+ direction: One of "outgoing", "incoming", or "both".
385
+
386
+ Returns:
387
+ A list of neighboring nodes (no duplicates), in the order their
388
+ connecting edges were added -- outgoing first, then incoming.
389
+
390
+ Raises:
391
+ NodeNotFoundError: If the node does not exist.
392
+
393
+ """
394
+ self.get_node(node_id) # Validate existence
395
+ # A dict keeps the first time we meet each neighbour, in road order:
396
+ # outgoing roads first, then incoming ones.
397
+ neighbor_ids: dict[str, None] = {}
398
+ if direction in ("outgoing", "both"):
399
+ for eid in self._out[node_id]:
400
+ neighbor_ids[self._edges[eid].target_id] = None
401
+ if direction in ("incoming", "both"):
402
+ for eid in self._in[node_id]:
403
+ neighbor_ids[self._edges[eid].source_id] = None
404
+ return [self._nodes[nid] for nid in neighbor_ids]
405
+
406
+ def out_degree(self, node_id: str) -> int:
407
+ """Count outgoing edges from a node.
408
+
409
+ Args:
410
+ node_id: The node ID.
411
+
412
+ Returns:
413
+ The number of outgoing edges.
414
+
415
+ """
416
+ return len(self.get_outgoing_edges(node_id))
417
+
418
+ def in_degree(self, node_id: str) -> int:
419
+ """Count incoming edges to a node.
420
+
421
+ Args:
422
+ node_id: The node ID.
423
+
424
+ Returns:
425
+ The number of incoming edges.
426
+
427
+ """
428
+ return len(self.get_incoming_edges(node_id))
429
+
430
+ def degree(self, node_id: str) -> int:
431
+ """Count total edges connected to a node (in + out).
432
+
433
+ Args:
434
+ node_id: The node ID.
435
+
436
+ Returns:
437
+ The total degree.
438
+
439
+ """
440
+ return self.in_degree(node_id) + self.out_degree(node_id)
441
+
442
+ def to_dict(self) -> dict[str, object]:
443
+ """Copy the whole whiteboard onto paper so it can be saved.
444
+
445
+ Like photographing your classroom whiteboard: every Post-it note
446
+ (node) and every line (edge) is written down into a plain
447
+ dictionary you can store in a file and rebuild later.
448
+
449
+ Returns:
450
+ A dictionary with "nodes" and "edges" keys.
451
+
452
+ """
453
+ return {
454
+ "nodes": [n.to_dict() for n in self._nodes.values()],
455
+ "edges": [e.to_dict() for e in self._edges.values()],
456
+ }
457
+
458
+ @classmethod
459
+ def from_dict(cls, data: dict[str, object]) -> Graph:
460
+ """Rebuild a whole whiteboard from the paper copy made by to_dict.
461
+
462
+ Like re-drawing your classroom whiteboard from a photo: it puts
463
+ back every Post-it note (node) and then every line (edge). Each
464
+ line must connect two notes that really exist, otherwise the
465
+ drawing makes no sense.
466
+
467
+ Args:
468
+ data: A dictionary with "nodes" and "edges" lists.
469
+
470
+ Returns:
471
+ A new Graph instance.
472
+
473
+ Raises:
474
+ NodeNotFoundError: If an edge refers to a node that is not
475
+ present in the data.
476
+ DuplicateNodeError: If two nodes share an id.
477
+ DuplicateEdgeError: If two edges share an id.
478
+
479
+ """
480
+ graph = cls()
481
+ raw_nodes = data.get("nodes")
482
+ raw_edges = data.get("edges")
483
+ if isinstance(raw_nodes, list):
484
+ for item in raw_nodes: # pyright: ignore[reportUnknownVariableType]
485
+ if isinstance(item, dict):
486
+ node_data: dict[str, object] = {str(k): v for k, v in item.items()} # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType]
487
+ node = Node.from_dict(node_data)
488
+ if node.node_id in graph._nodes:
489
+ msg = f"Node '{node.node_id}' already exists"
490
+ raise DuplicateNodeError(msg)
491
+ graph._store_node(node)
492
+ if isinstance(raw_edges, list):
493
+ for item in raw_edges: # pyright: ignore[reportUnknownVariableType]
494
+ if isinstance(item, dict):
495
+ edge_data: dict[str, object] = {str(k): v for k, v in item.items()} # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType]
496
+ edge = Edge.from_dict(edge_data)
497
+ # A second edge with the same id would replace the first in
498
+ # the edge list but not in its node's road list.
499
+ if edge.edge_id in graph._edges:
500
+ msg = f"Edge '{edge.edge_id}' already exists"
501
+ raise DuplicateEdgeError(msg)
502
+ if edge.source_id not in graph._nodes:
503
+ msg = f"Source node '{edge.source_id}' not found"
504
+ raise NodeNotFoundError(msg)
505
+ if edge.target_id not in graph._nodes:
506
+ msg = f"Target node '{edge.target_id}' not found"
507
+ raise NodeNotFoundError(msg)
508
+ graph._store_edge(edge)
509
+ return graph
pygraphdb/matching.py ADDED
@@ -0,0 +1,111 @@
1
+ """Pattern matching -- find nodes and edges that match criteria.
2
+
3
+ Imagine you're at a huge party and you want to find everyone who:
4
+ - Is a "Student" (label matching)
5
+ - Likes pizza (property matching)
6
+ - Is over 12 years old (property comparison)
7
+
8
+ Pattern matching lets you search through your graph and find exactly
9
+ the nodes or edges you're looking for, like a filter on Instagram
10
+ but for graph data!
11
+
12
+ Example:
13
+ >>> from pygraphdb.graph import Graph
14
+ >>> from pygraphdb.matching import find_nodes
15
+ >>> g = Graph()
16
+ >>> g.add_node("1", label="Person", properties={"name": "Alice", "age": 13})
17
+ >>> g.add_node("2", label="Person", properties={"name": "Bob", "age": 14})
18
+ >>> g.add_node("3", label="City", properties={"name": "London"})
19
+ >>> people = find_nodes(g, label="Person")
20
+ >>> len(people)
21
+ 2
22
+
23
+ """
24
+
25
+ from pygraphdb.edge import Edge
26
+ from pygraphdb.graph import Graph
27
+ from pygraphdb.node import Node
28
+
29
+
30
+ def find_nodes(
31
+ graph: Graph,
32
+ *,
33
+ label: str | None = None,
34
+ properties: dict[str, object] | None = None,
35
+ ) -> list[Node]:
36
+ """Find all nodes matching the given label and/or properties.
37
+
38
+ Like filtering your contact list: show me everyone who is a
39
+ "Student" and has "age" equal to 13.
40
+
41
+ Args:
42
+ graph: The graph to search.
43
+ label: If provided, only match nodes with this label.
44
+ properties: If provided, only match nodes that have all these
45
+ key-value pairs in their properties.
46
+
47
+ Returns:
48
+ A list of matching nodes.
49
+
50
+ """
51
+ results: list[Node] = []
52
+ for node in graph.get_all_nodes():
53
+ if label is not None and node.label != label:
54
+ continue
55
+ if properties is not None and not _properties_match(node.properties, properties):
56
+ continue
57
+ results.append(node)
58
+ return results
59
+
60
+
61
+ def find_edges(
62
+ graph: Graph,
63
+ *,
64
+ rel_type: str | None = None,
65
+ properties: dict[str, object] | None = None,
66
+ source_id: str | None = None,
67
+ target_id: str | None = None,
68
+ ) -> list[Edge]:
69
+ """Find all edges matching the given criteria.
70
+
71
+ Like searching for specific kinds of relationships: show me all
72
+ "FRIENDS_WITH" connections, or all edges going from Alice to anyone.
73
+
74
+ Args:
75
+ graph: The graph to search.
76
+ rel_type: If provided, only match edges with this relationship type.
77
+ properties: If provided, only match edges that have all these
78
+ key-value pairs in their properties.
79
+ source_id: If provided, only match edges from this source node.
80
+ target_id: If provided, only match edges to this target node.
81
+
82
+ Returns:
83
+ A list of matching edges.
84
+
85
+ """
86
+ results: list[Edge] = []
87
+ for edge in graph.get_all_edges():
88
+ if rel_type is not None and edge.rel_type != rel_type:
89
+ continue
90
+ if properties is not None and not _properties_match(edge.properties, properties):
91
+ continue
92
+ if source_id is not None and edge.source_id != source_id:
93
+ continue
94
+ if target_id is not None and edge.target_id != target_id:
95
+ continue
96
+ results.append(edge)
97
+ return results
98
+
99
+
100
+ def _properties_match(actual: dict[str, object], expected: dict[str, object]) -> bool:
101
+ """Check whether all expected properties exist in the actual properties.
102
+
103
+ Args:
104
+ actual: The actual properties dictionary.
105
+ expected: The expected key-value pairs.
106
+
107
+ Returns:
108
+ True if all expected pairs are found in actual.
109
+
110
+ """
111
+ return all(actual.get(k) == v for k, v in expected.items())