grand-graph 0.7.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.
grand/__init__.py ADDED
@@ -0,0 +1,83 @@
1
+ """
2
+ Grand graphs package.
3
+
4
+ """
5
+
6
+ from typing import Optional
7
+ from .backends import Backend, NetworkXBackend
8
+ from .dialects import NetworkXDialect, IGraphDialect, NetworkitDialect
9
+
10
+
11
+ _DEFAULT_BACKEND = NetworkXBackend
12
+
13
+ __version__ = "0.7.0"
14
+
15
+
16
+ class Graph:
17
+ """
18
+ A grand.Graph enables you to manipulate a graph using multiple dialects.
19
+
20
+ """
21
+
22
+ nx: NetworkXDialect
23
+ networkit: NetworkitDialect
24
+ igraph: IGraphDialect
25
+
26
+ def __init__(self, backend: Optional[Backend] = None, **backend_kwargs: dict):
27
+ """
28
+ Create a new grand.Graph.
29
+
30
+ The only positional argument is the backend to use. All other arguments
31
+ are passed to the backend's constructor, if a type is provided.
32
+ Otherwise, kwargs are ignored.
33
+
34
+ Arguments:
35
+ backend (Backend): The backend to use. If none is provided, will
36
+ default to _DEFAULT_BACKEND.
37
+
38
+ """
39
+ self.backend = backend or _DEFAULT_BACKEND
40
+
41
+ # If you passed a class instead of an instance, instantiate it with
42
+ # kwargs from the constructor:
43
+ if isinstance(self.backend, type):
44
+ self.backend = self.backend(**backend_kwargs)
45
+
46
+ # Attach dialects:
47
+ self.attach_dialect("nx", NetworkXDialect)
48
+ self.attach_dialect("igraph", IGraphDialect)
49
+ self.attach_dialect("networkit", NetworkitDialect)
50
+
51
+ def attach_dialect(self, name: str, dialect: type):
52
+ """
53
+ Attach a dialect to the graph.
54
+
55
+ Arguments:
56
+ name (str): The name of the dialect.
57
+ dialect (type): The dialect class to attach.
58
+
59
+ """
60
+ setattr(self, name, dialect(self))
61
+
62
+
63
+ class DiGraph(Graph):
64
+ """
65
+ A grand.DiGraph enables you to manipulate a directed graph. This is a
66
+ convenience class that inherits from grand.Graph.
67
+
68
+ """
69
+
70
+ def __init__(self, backend: Optional[Backend] = None, **backend_kwargs: dict):
71
+ """
72
+ Create a new grand.DiGraph.
73
+
74
+ The only positional argument is the backend to use. All other arguments
75
+ are passed to the backend's constructor, if a type is provided.
76
+ Otherwise, kwargs are ignored.
77
+
78
+ Arguments:
79
+ backend (Backend): The backend to use. If none is provided, will
80
+ default to _DEFAULT_BACKEND.
81
+
82
+ """
83
+ super().__init__(backend, **{**backend_kwargs, "directed": True})
@@ -0,0 +1,29 @@
1
+ from .backend import Backend, CachedBackend, InMemoryCachedBackend
2
+
3
+ try:
4
+ from ._dynamodb import DynamoDBBackend
5
+ except ImportError:
6
+ pass
7
+ from ._networkx import NetworkXBackend
8
+ from ._dataframe import DataFrameBackend
9
+
10
+ try:
11
+ from ._sqlbackend import SQLBackend
12
+ except ImportError:
13
+ pass
14
+
15
+ try:
16
+ from ._networkit import NetworkitBackend
17
+ except ImportError:
18
+ pass
19
+
20
+ __all__ = [
21
+ "Backend",
22
+ "CachedBackend",
23
+ "InMemoryCachedBackend",
24
+ "NetworkXBackend",
25
+ "DataFrameBackend",
26
+ "DynamoDBBackend",
27
+ "SQLBackend",
28
+ "NetworkitBackend",
29
+ ]
@@ -0,0 +1,498 @@
1
+ from typing import Hashable, Generator
2
+ import time
3
+
4
+ import pandas as pd
5
+
6
+ from .backend import Backend
7
+
8
+
9
+ class DataFrameBackend(Backend):
10
+ def __init__(
11
+ self,
12
+ directed: bool = False,
13
+ edge_df: pd.DataFrame = None,
14
+ node_df: pd.DataFrame = None,
15
+ edge_df_source_column: str = "Source",
16
+ edge_df_target_column: str = "Target",
17
+ node_df_id_column: str = "id",
18
+ ):
19
+ """
20
+ Create a new DataFrame backend.
21
+
22
+ You must pass an edgelist. A nodelist is optional.
23
+
24
+ Arguments:
25
+ edge_df (pd.DataFrame): An edgelist dataframe with one edge per row
26
+ directed (bool: False): Whether the graph is directed
27
+ node_df (pd.DataFrame): A node metadata lookup
28
+ edge_df_source_column (str): The name of the column in `edge_df` to
29
+ use as the source of edges
30
+ edge_df_target_column (str): The name of the column in `edge_df` to
31
+ use as the target of edges
32
+ node_df_id_column (str): The name of the column in `node_df` to
33
+ use as the node ID
34
+ """
35
+ self._directed = directed
36
+ self._edge_df = (
37
+ edge_df
38
+ if edge_df is not None
39
+ else pd.DataFrame(columns=[edge_df_source_column, edge_df_target_column])
40
+ )
41
+ self._node_df = node_df if node_df is not None else None
42
+ self._edge_df_source_column = edge_df_source_column
43
+ self._edge_df_target_column = edge_df_target_column
44
+ self._node_df_id_column = node_df_id_column
45
+
46
+ def is_directed(self) -> bool:
47
+ """
48
+ Return True if the backend graph is directed.
49
+
50
+ Arguments:
51
+ None
52
+
53
+ Returns:
54
+ bool: True if the backend graph is directed.
55
+
56
+ """
57
+ return self._directed
58
+
59
+ def teardown(self, yes_i_am_sure: bool = False):
60
+ """
61
+ Tear down this graph, deleting all evidence it once was here.
62
+
63
+ """
64
+ return
65
+
66
+ def add_node(self, node_name: Hashable, metadata: dict) -> Hashable:
67
+ """
68
+ Add a new node to the graph.
69
+
70
+ Insert a new document into the nodes table.
71
+
72
+ Arguments:
73
+ node_name (Hashable): The ID of the node
74
+ metadata (dict: None): An optional dictionary of metadata
75
+
76
+ Returns:
77
+ Hashable: The ID of this node, as inserted
78
+
79
+ """
80
+
81
+ # Add a new row to the nodes table:
82
+ if self._node_df is None:
83
+ self._node_df = pd.DataFrame(
84
+ [
85
+ {
86
+ self._node_df_id_column: node_name,
87
+ **metadata,
88
+ }
89
+ ],
90
+ columns=[
91
+ self._node_df_id_column,
92
+ *metadata.keys(),
93
+ ],
94
+ )
95
+ self._node_df.set_index(self._node_df_id_column, inplace=True)
96
+ else:
97
+ if self.has_node(node_name):
98
+ existing_metadata = self.get_node_by_id(node_name)
99
+ existing_metadata.update(metadata)
100
+ for k, v in existing_metadata.items():
101
+ self._node_df.at[node_name, k] = v
102
+ else:
103
+ # Insert a new row:
104
+ self._node_df = pd.concat(
105
+ [self._node_df, pd.DataFrame([{node_name: metadata}]).T]
106
+ )
107
+
108
+ return node_name
109
+
110
+ def all_nodes_as_iterable(self, include_metadata: bool = False):
111
+ """
112
+ Get a generator of all of the nodes in this graph.
113
+
114
+ Arguments:
115
+ include_metadata (bool: False): Whether to include node metadata in
116
+ the response
117
+
118
+ Returns:
119
+ Generator: A generator of all nodes (arbitrary sort)
120
+
121
+ """
122
+ if self._node_df is not None:
123
+ return [
124
+ (
125
+ (
126
+ node_id,
127
+ row.to_dict(),
128
+ )
129
+ if include_metadata
130
+ else node_id
131
+ )
132
+ for node_id, row in self._node_df.iterrows()
133
+ ]
134
+
135
+ else:
136
+ return [
137
+ (node_id, {}) if include_metadata else node_id
138
+ for node_id in self._edge_df[self._edge_df_source_column]
139
+ ] + [
140
+ (node_id, {}) if include_metadata else node_id
141
+ for node_id in self._edge_df[self._edge_df_target_column]
142
+ ]
143
+
144
+ def has_node(self, u: Hashable) -> bool:
145
+ """
146
+ Return true if the node exists in the graph.
147
+
148
+ Arguments:
149
+ u (Hashable): The ID of the node to check
150
+
151
+ Returns:
152
+ bool: True if the node exists
153
+ """
154
+ if self._node_df is not None:
155
+ return u in self._node_df.index
156
+
157
+ return u in (self._edge_df[self._edge_df_source_column]) or u in (
158
+ self._edge_df[self._edge_df_target_column]
159
+ )
160
+
161
+ def add_edge(self, u: Hashable, v: Hashable, metadata: dict):
162
+ """
163
+ Add a new edge to the graph between two nodes.
164
+
165
+ If the graph is directed, this edge will start (source) at the `u` node
166
+ and end (target) at the `v` node.
167
+
168
+ Arguments:
169
+ u (Hashable): The source node ID
170
+ v (Hashable): The target node ID
171
+ metadata (dict): Optional metadata to associate with the edge
172
+
173
+ Returns:
174
+ Hashable: The edge ID, as inserted.
175
+
176
+ """
177
+
178
+ if not self.has_node(u):
179
+ self.add_node(u, {})
180
+ if not self.has_node(v):
181
+ self.add_node(v, {})
182
+
183
+ if self._has_edge(u, v):
184
+ # Update the existing edge:
185
+ for k, m in metadata.items():
186
+ if self._directed:
187
+ self._edge_df.loc[
188
+ (self._edge_df[self._edge_df_source_column] == u)
189
+ & (self._edge_df[self._edge_df_target_column] == v),
190
+ k,
191
+ ] = m
192
+ else:
193
+ # Check for the edge in both directions:
194
+ self._edge_df.loc[
195
+ (self._edge_df[self._edge_df_source_column] == u)
196
+ & (self._edge_df[self._edge_df_target_column] == v),
197
+ k,
198
+ ] = m
199
+ self._edge_df.loc[
200
+ (self._edge_df[self._edge_df_source_column] == v)
201
+ & (self._edge_df[self._edge_df_target_column] == u),
202
+ k,
203
+ ] = m
204
+ else:
205
+ row = {
206
+ self._edge_df_source_column: u,
207
+ self._edge_df_target_column: v,
208
+ **metadata,
209
+ }
210
+ self._edge_df.loc[len(self._edge_df)] = None
211
+ for k, m in row.items():
212
+ self._edge_df.loc[len(self._edge_df) - 1, k] = m
213
+ return (u, v)
214
+
215
+ def _has_edge(self, u: Hashable, v: Hashable) -> bool:
216
+ """
217
+ Return true if the edge exists in the graph.
218
+
219
+ Arguments:
220
+ u (Hashable): The source node ID
221
+ v (Hashable): The target node ID
222
+
223
+ Returns:
224
+ bool: True if the edge exists
225
+ """
226
+ if self._directed:
227
+ return (
228
+ (self._edge_df[self._edge_df_source_column] == u)
229
+ & (self._edge_df[self._edge_df_target_column] == v)
230
+ ).any()
231
+ else:
232
+ return (
233
+ (self._edge_df[self._edge_df_source_column] == u)
234
+ & (self._edge_df[self._edge_df_target_column] == v)
235
+ ).any() or (
236
+ (self._edge_df[self._edge_df_source_column] == v)
237
+ & (self._edge_df[self._edge_df_target_column] == u)
238
+ ).any()
239
+
240
+ def all_edges_as_iterable(self, include_metadata: bool = False) -> Generator:
241
+ """
242
+ Get a list of all edges in this graph, arbitrary sort.
243
+
244
+ Arguments:
245
+ include_metadata (bool: False): Whether to include edge metadata
246
+
247
+ Returns:
248
+ Generator: A generator of all edges (arbitrary sort)
249
+
250
+ """
251
+ for _, row in self._edge_df.iterrows():
252
+ if include_metadata:
253
+ yield (
254
+ row[self._edge_df_source_column],
255
+ row[self._edge_df_target_column],
256
+ dict(row),
257
+ )
258
+ else:
259
+ yield (
260
+ row[self._edge_df_source_column],
261
+ row[self._edge_df_target_column],
262
+ )
263
+
264
+ def get_node_by_id(self, node_name: Hashable):
265
+ """
266
+ Return the data associated with a node.
267
+
268
+ Arguments:
269
+ node_name (Hashable): The node ID to look up
270
+
271
+ Returns:
272
+ dict: The metadata associated with this node
273
+
274
+ """
275
+ if self._node_df is not None:
276
+ res = (self._node_df.loc[node_name]).to_dict()
277
+ return res.get(0, res)
278
+
279
+ return {}
280
+
281
+ def get_edge_by_id(self, u: Hashable, v: Hashable):
282
+ """
283
+ Get an edge by its source and target IDs.
284
+
285
+ Arguments:
286
+ u (Hashable): The source node ID
287
+ v (Hashable): The target node ID
288
+
289
+ Returns:
290
+ dict: Metadata associated with this edge
291
+
292
+ """
293
+ if self._directed:
294
+ result = self._edge_df[
295
+ (self._edge_df[self._edge_df_source_column] == u)
296
+ & (self._edge_df[self._edge_df_target_column] == v)
297
+ ]
298
+ if len(result):
299
+ return self._edge_as_dict(result.iloc[0])
300
+
301
+ else:
302
+ left = self._edge_df[
303
+ (self._edge_df[self._edge_df_source_column] == u)
304
+ & (self._edge_df[self._edge_df_target_column] == v)
305
+ ]
306
+ if len(left):
307
+ return self._edge_as_dict(left.iloc[0])
308
+ right = self._edge_df[
309
+ (self._edge_df[self._edge_df_source_column] == v)
310
+ & (self._edge_df[self._edge_df_target_column] == u)
311
+ ]
312
+ if len(right):
313
+ return self._edge_as_dict(right.iloc[0])
314
+
315
+ def get_node_neighbors(self, u: Hashable, include_metadata: bool = False):
316
+ """
317
+ Get a generator of all downstream nodes from this node.
318
+
319
+ Arguments:
320
+ u (Hashable): The source node ID
321
+
322
+ Returns:
323
+ Generator
324
+
325
+ """
326
+
327
+ if include_metadata:
328
+ if self._directed:
329
+ return {
330
+ (r[self._edge_df_target_column]): self._edge_as_dict(r)
331
+ for _, r in self._edge_df[
332
+ (self._edge_df[self._edge_df_source_column] == u)
333
+ ].iterrows()
334
+ }
335
+ else:
336
+ return {
337
+ (
338
+ r[self._edge_df_source_column]
339
+ if r[self._edge_df_source_column] != u
340
+ else r[self._edge_df_target_column]
341
+ ): self._edge_as_dict(r)
342
+ for _, r in self._edge_df[
343
+ (self._edge_df[self._edge_df_source_column] == u)
344
+ | (self._edge_df[self._edge_df_target_column] == u)
345
+ ].iterrows()
346
+ }
347
+
348
+ if self._directed:
349
+ return iter(
350
+ [
351
+ row[self._edge_df_target_column]
352
+ for _, row in self._edge_df[
353
+ (self._edge_df[self._edge_df_source_column] == u)
354
+ ].iterrows()
355
+ ]
356
+ )
357
+ else:
358
+ return iter(
359
+ [
360
+ (
361
+ row[self._edge_df_source_column]
362
+ if row[self._edge_df_source_column] != u
363
+ else row[self._edge_df_target_column]
364
+ )
365
+ for _, row in self._edge_df[
366
+ (self._edge_df[self._edge_df_source_column] == u)
367
+ | (self._edge_df[self._edge_df_target_column] == u)
368
+ ].iterrows()
369
+ ]
370
+ )
371
+
372
+ def _edge_as_dict(self, row):
373
+ """
374
+ Convert an edge row to a dictionary.
375
+
376
+ Arguments:
377
+ row (pandas.Series): The edge row
378
+
379
+ Returns:
380
+ dict: The edge metadata
381
+
382
+ """
383
+ r = row.to_dict()
384
+ r.pop(self._edge_df_source_column)
385
+ r.pop(self._edge_df_target_column)
386
+ return r
387
+
388
+ def get_node_predecessors(self, u: Hashable, include_metadata: bool = False):
389
+ """
390
+ Get a generator of all upstream nodes from this node.
391
+
392
+ Arguments:
393
+ u (Hashable): The source node ID
394
+
395
+ Returns:
396
+ Generator
397
+
398
+ """
399
+
400
+ if include_metadata:
401
+ if self._directed:
402
+ return {
403
+ (
404
+ r[self._edge_df_target_column]
405
+ if r[self._edge_df_target_column] != u
406
+ else r[self._edge_df_source_column]
407
+ ): self._edge_as_dict(r)
408
+ for _, r in self._edge_df[
409
+ (self._edge_df[self._edge_df_target_column] == u)
410
+ ].iterrows()
411
+ }
412
+ else:
413
+ return {
414
+ (
415
+ r[self._edge_df_target_column]
416
+ if r[self._edge_df_target_column] != u
417
+ else r[self._edge_df_source_column]
418
+ ): self._edge_as_dict(r)
419
+ for _, r in self._edge_df[
420
+ (self._edge_df[self._edge_df_target_column] == u)
421
+ | (self._edge_df[self._edge_df_source_column] == u)
422
+ ].iterrows()
423
+ }
424
+
425
+ if self._directed:
426
+ return iter(
427
+ [
428
+ row[self._edge_df_source_column]
429
+ for _, row in self._edge_df[
430
+ (self._edge_df[self._edge_df_target_column] == u)
431
+ ].iterrows()
432
+ ]
433
+ )
434
+ else:
435
+ return iter(
436
+ [
437
+ (
438
+ row[self._edge_df_source_column]
439
+ if row[self._edge_df_target_column] != u
440
+ else row[self._edge_df_target_column]
441
+ )
442
+ for _, row in self._edge_df[
443
+ (self._edge_df[self._edge_df_target_column] == u)
444
+ | (self._edge_df[self._edge_df_source_column] == u)
445
+ ].iterrows()
446
+ ]
447
+ )
448
+
449
+ def get_node_count(self) -> int:
450
+ """
451
+ Get an integer count of the number of nodes in this graph.
452
+
453
+ Arguments:
454
+ None
455
+
456
+ Returns:
457
+ int: The count of nodes
458
+
459
+ """
460
+ if self._node_df is not None:
461
+ return len(self._node_df)
462
+ # Return number of unique sources intersected with number of unique targets
463
+ return len(
464
+ set(self._edge_df[self._edge_df_source_column]).intersection(
465
+ set(self._edge_df[self._edge_df_target_column])
466
+ )
467
+ )
468
+
469
+ def get_edge_count(self) -> int:
470
+ """
471
+ Get an integer count of the number of edges in this graph.
472
+
473
+ Arguments:
474
+ None
475
+
476
+ Returns:
477
+ int: The count of edges
478
+
479
+ """
480
+ return len(self._edge_df)
481
+
482
+ def ingest_from_edgelist_dataframe(
483
+ self, edgelist: pd.DataFrame, source_column: str, target_column: str
484
+ ) -> dict:
485
+ """
486
+ Ingest an edgelist from a Pandas DataFrame.
487
+
488
+ """
489
+ # Produce edge list:
490
+
491
+ edge_tic = time.time()
492
+ self._edge_df = edgelist
493
+ self._edge_df_source_column = source_column
494
+ self._edge_df_target_column = target_column
495
+
496
+ return {
497
+ "edge_duration": time.time() - edge_tic,
498
+ }