spanner-graph-notebook 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.
Files changed (40) hide show
  1. assets/authentication.png +0 -0
  2. assets/full_viz.png +0 -0
  3. assets/hero.png +0 -0
  4. assets/hero_with_properties.png +0 -0
  5. assets/jupyter-spin-up.png +0 -0
  6. assets/load_ext.png +0 -0
  7. assets/mock_data_result.png +0 -0
  8. assets/new_notebook.png +0 -0
  9. assets/notebook_package_load.png +0 -0
  10. assets/query_graph.png +0 -0
  11. assets/sample_jupyter.png +0 -0
  12. spanner_graph_notebook-1.0.0.dist-info/LICENSE +202 -0
  13. spanner_graph_notebook-1.0.0.dist-info/METADATA +169 -0
  14. spanner_graph_notebook-1.0.0.dist-info/RECORD +40 -0
  15. spanner_graph_notebook-1.0.0.dist-info/WHEEL +5 -0
  16. spanner_graph_notebook-1.0.0.dist-info/top_level.txt +2 -0
  17. spanner_graphs/__init__.py +19 -0
  18. spanner_graphs/conversion.py +299 -0
  19. spanner_graphs/database.py +199 -0
  20. spanner_graphs/graph_entities.py +405 -0
  21. spanner_graphs/graph_mock_data.csv +51 -0
  22. spanner_graphs/graph_mock_schema.json +681 -0
  23. spanner_graphs/magics.py +211 -0
  24. spanner_graphs/schema_manager.py +60 -0
  25. templates/assets/images/graph-bg.svg +4 -0
  26. templates/spanner-graph/models/edge.js +77 -0
  27. templates/spanner-graph/models/graph-object.js +64 -0
  28. templates/spanner-graph/models/node.js +77 -0
  29. templates/spanner-graph/models/schema.js +327 -0
  30. templates/spanner-graph/spanner-config.js +304 -0
  31. templates/spanner-graph/spanner-store.js +382 -0
  32. templates/spanner-graph/visualization/spanner-forcegraph.js +1380 -0
  33. templates/spanner-graph/visualization/spanner-sidebar.js +904 -0
  34. templates/template-spannergraph.html +210 -0
  35. tests/__init__.py +13 -0
  36. tests/conversion_test.py +163 -0
  37. tests/database_test.py +62 -0
  38. tests/graph_entities_test.py +124 -0
  39. tests/schema_manager_test.py +115 -0
  40. tests/test_notebook.json +23 -0
@@ -0,0 +1,405 @@
1
+ # Copyright 2024 Google LLC
2
+
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ This module defines the Node and Edge classes used to represent graph entities.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import datetime
22
+ import json
23
+ from typing import Any, Dict, List
24
+ import networkx as nx
25
+ from numpy import number
26
+
27
+ reserved_colors: dict[str, str] = {}
28
+
29
+ palette = ['#cc7722', '#ffe5b4', '#dda0dd', '#fffacd', '#e6e6fa',
30
+ '#d2b48c', '#6a5acd', '#ffe4e1', '#6495ed', '#4b4b4b',
31
+ '#ace1af', '#808000', '#e6e6e6', '#9671e8', '#6b8e23',
32
+ '#654321', '#b0e0e6', '#1e1e1e', '#c8c8c8', '#cd853f']
33
+
34
+ def format_value(key: str, value: Any) -> str:
35
+ if value is None:
36
+ return '<span style="color: #9CA3AF;">None</span>'
37
+
38
+ if isinstance(value, bool):
39
+ return '✅ <span style="font-family: monospace">true</span>' if value else '❌ <span style="font-family: monospace">false</span>'
40
+
41
+ if isinstance(value, (int, float)):
42
+ return f'<span style="font-family: monospace;">{value}</span>'
43
+
44
+ if isinstance(value, list):
45
+ return f'<span style="color: #4B5563;">[{", ".join(map(str, value))}]</span>'
46
+
47
+ if isinstance(value, dict):
48
+ json_str = json.dumps(value)
49
+ preview = json_str[:20] + ('...' if len(json_str) > 20 else '')
50
+ full_json = json.dumps(value, indent=2)
51
+ return f'''
52
+ <div onclick="toggleJson_{key}()" style="cursor: pointer;">
53
+ <span id="jsonPreview_{key}" style="color: #4B5563;">{preview}</span>
54
+ <span id="expandCollapseText_{key}" style="color: #3B82F6;"> (Click to expand)</span>
55
+ </div>
56
+ <div id="jsonContent_{key}" style="display: none; padding: 10px; background-color: #F3F4F6; border-radius: 4px; margin-top: 5px;">
57
+ <pre style="white-space: pre-wrap; font-family: monospace; font-size: 12px;">{full_json}</pre>
58
+ </div>
59
+ <script>
60
+ function toggleJson_{key}() {{
61
+ var content = document.getElementById('jsonContent_{key}');
62
+ var preview = document.getElementById('jsonPreview_{key}');
63
+ var text = document.getElementById('expandCollapseText_{key}');
64
+ if (content.style.display === 'none') {{
65
+ content.style.display = 'block';
66
+ preview.style.display = 'none';
67
+ text.innerHTML = ' (Click to collapse)';
68
+ }} else {{
69
+ content.style.display = 'none';
70
+ preview.style.display = 'inline';
71
+ text.innerHTML = ' (Click to expand)';
72
+ }}
73
+ }}
74
+ </script>
75
+ '''
76
+
77
+ if isinstance(value, str):
78
+ if key.lower().endswith('id') or key.lower().startswith('id'):
79
+ return f'<span style="font-family: monospace; background-color: #E5E7EB; padding: 2px 4px; border-radius: 4px;">{value}</span>'
80
+
81
+ if '@' in value and '.' in value.split('@')[1]:
82
+ return f'✉️ <a href="mailto:{value}" style="color: #3B82F6;">{value}</a>'
83
+
84
+ if value.startswith(('http://', 'https://', 'www.')):
85
+ return f'🔗 <a href="{value}" target="_blank" style="color: #3B82F6;">{value}</a>'
86
+
87
+ try:
88
+ date = datetime.datetime.fromisoformat(value)
89
+ return f'🗓 {date.strftime("%Y-%m-%d %H:%M:%S")}'
90
+ except ValueError:
91
+ pass
92
+
93
+ return str(value)
94
+
95
+ def generate_tooltip_html(title: str, properties: dict[str, any]) -> str:
96
+ tooltip_html = f'''
97
+ <div style="margin: -5px; font-family: Arial, sans-serif; width: 300px; background-color: #FFFFFF;">
98
+ <div style="background-color: #3B82F6; color: #FFFFFF; padding: 10px;">
99
+ <h3 style="margin: 0; font-size: 16px;">{title} Properties</h3>
100
+ </div>
101
+ <div style="padding: 10px;">
102
+ <table style="width: 100%; border-collapse: collapse;">
103
+ '''
104
+
105
+ property_items = properties.items()
106
+ if len(property_items) == 0:
107
+ tooltip_html += f'''
108
+ <tr>
109
+ <td style="padding: 8px; font-size: 12px; color: #374151; font-weight: 500;">No properties found</td>
110
+ </tr>
111
+ '''
112
+ else:
113
+ tooltip_html += f'''
114
+ <tr style="background-color: #F3F4F6;">
115
+ <th style="text-align: left; padding: 8px; font-size: 12px; color: #4B5563;">Property</th>
116
+ <th style="text-align: left; padding: 8px; font-size: 12px; color: #4B5563;">Value</th>
117
+ </tr>
118
+ '''
119
+
120
+ counter = 0
121
+ for key, value in property_items:
122
+ formatted_value = format_value(key, value)
123
+ bg_color = '#FFFFFF' if counter % 2 == 0 else '#F9FAFB'
124
+ tooltip_html += f'''
125
+ <tr style="background-color: {bg_color}">
126
+ <td style="padding: 8px; font-size: 12px; color: #374151; font-weight: 500;">{key}</td>
127
+ <td style="padding: 8px; font-size: 12px; color: #374151; max-width: 200px; overflow: hidden; text-overflow: ellipsis;">{formatted_value}</td>
128
+ </tr>
129
+ '''
130
+ counter += 1
131
+
132
+ tooltip_html += '''
133
+ </table>
134
+ </div>
135
+ </div>
136
+ '''
137
+
138
+ return tooltip_html
139
+
140
+
141
+ class Node:
142
+ """
143
+ Represents a node in a graph.
144
+
145
+ Attributes:
146
+ identifier (str): The unique identifier for the node.
147
+ labels (List[str]): The labels associated with the node.
148
+ properties (Dict[str, Any]): The properties of the node.
149
+ """
150
+
151
+ def __init__(self, identifier: str, labels: List[str],
152
+ properties: Dict[str, Any]):
153
+ self.identifier = identifier
154
+ self.labels = labels
155
+ self.key_property_names = []
156
+ self.label_str = ""
157
+ self.properties = properties
158
+
159
+ def __repr__(self):
160
+ return (f"Node(identifier={self.identifier}, "
161
+ f"labels={self.labels}, properties={self.properties})")
162
+
163
+ @classmethod
164
+ def from_json(cls, data: Dict[str, Any]) -> Node:
165
+ """Create a Node instance from a JSON object."""
166
+ return cls(
167
+ identifier=data["identifier"],
168
+ labels=data.get("labels", []),
169
+ properties=data.get("properties", {}),
170
+ )
171
+
172
+ def decide_label_string(self, node_display_props: dict[str, str]) -> None:
173
+ """Returns the label string for the node."""
174
+ if not node_display_props:
175
+ self.label_str = "|".join(self.labels)
176
+ else:
177
+ for label in self.labels:
178
+ label_case_incens = label.lower()
179
+ if label_case_incens in node_display_props:
180
+ prop = node_display_props[label_case_incens]
181
+ if prop in self.properties:
182
+ val = self.properties[prop]
183
+ self.label_str = f"{label} ({val})"
184
+ break
185
+ if not self.label_str:
186
+ self.label_str = "|".join(self.labels)
187
+
188
+ def add_to_graph(self, graph: nx.MultiDiGraph,
189
+ node_mapper: Dict[str, int]) -> None:
190
+ """Add the node to a NetworkX graph using the `node_mapping`
191
+ for retrieving the node_id for use in the graph.
192
+ The node_id retrieved from the node_mapper helps hide
193
+ the opaque identifier with a long unreadable string.
194
+
195
+ Args:
196
+ graph: The networkx graph to add this node onto.
197
+ node_id_mapping: A mapping to replace the opaque node identifer
198
+ with a number for ease of use.
199
+ """
200
+
201
+ node_id = node_mapper[self.identifier]
202
+
203
+ graph.add_node(
204
+ node_id,
205
+ id=node_id,
206
+ label=self.label_str,
207
+ properties=self.properties,
208
+ key_property_names=self.key_property_names,
209
+ )
210
+
211
+ @staticmethod
212
+ def hash_to_rgb(hash_value: int) -> str:
213
+ """Computes an rgb color from `hash_value`"""
214
+ red = (hash_value >> 16) & 0xFF
215
+ green = (hash_value >> 8) & 0xFF
216
+ blue = hash_value & 0xFF
217
+ return f"rgb({red}, {green}, {blue})"
218
+
219
+ @staticmethod
220
+ def get_unique_color_from_palette(label: str,
221
+ color_palette: list[str]) -> str:
222
+ if not label:
223
+ return "lightgray"
224
+
225
+ # Check if label already has an assigned color
226
+ if (label in reserved_colors):
227
+ return reserved_colors[label]
228
+
229
+ # Fallback to hash if all colors have been reserved
230
+ if len(color_palette) == 0:
231
+ return Node.hash_to_rgb(sum(ord(char) for char in label))
232
+
233
+ # Assign a color to the label
234
+ color = color_palette[0]
235
+ reserved_colors[label] = color
236
+ color_palette.pop(0)
237
+
238
+ return color
239
+
240
+ @staticmethod
241
+ def is_valid_node_json(node_data: Dict[str, Any]) -> bool:
242
+ """
243
+ Check that the provided `node_data` is a valid node structure
244
+ """
245
+ required_keys = [
246
+ "identifier",
247
+ "labels",
248
+ "properties",
249
+ ]
250
+
251
+ type_checks = {
252
+ "identifier": str,
253
+ "labels": list,
254
+ "properties": dict,
255
+ }
256
+
257
+ try:
258
+ # Check if all required keys are present
259
+ if not all(key in node_data for key in required_keys):
260
+ return False
261
+
262
+ # Check types
263
+ if not all(
264
+ isinstance(node_data[key], type_)
265
+ for key, type_ in type_checks.items()):
266
+ return False
267
+
268
+ # Check if all labels are strings
269
+ if not all(
270
+ isinstance(label, str) for label in node_data["labels"]):
271
+ return False
272
+
273
+ return True
274
+
275
+ except KeyError:
276
+ return False
277
+
278
+
279
+ class Edge:
280
+ """
281
+ Represents an edge or connection in the graph.
282
+
283
+ Attributes:
284
+ identifer(str): The identifer of the edge itself.
285
+ source (str): The identifier of the source node.
286
+ destination (str): The identifier of the destination node.
287
+ labels (List[str]): The labels associated with the edge.
288
+ properties (Dict[str, Any]): The properties of the edge.
289
+ """
290
+
291
+ def __init__(
292
+ self,
293
+ identifier: str,
294
+ source: str,
295
+ destination: str,
296
+ labels: List[str],
297
+ properties: Dict[str, Any],
298
+ ):
299
+ self.identifier = identifier
300
+ self.source = source
301
+ self.destination = destination
302
+ self.labels = labels
303
+ self.label_str = ""
304
+ self.properties = properties
305
+
306
+ def __repr__(self):
307
+ return (f"Edge(source={self.source}, destination={self.destination}, "
308
+ f"labels={self.labels}, properties={self.properties})")
309
+
310
+ @classmethod
311
+ def from_json(cls, data: Dict[str, Any]) -> Edge:
312
+ """Create an Edge instance from a JSON object"""
313
+ return cls(
314
+ identifier=data["identifier"],
315
+ source=data["source_node_identifier"],
316
+ destination=data["destination_node_identifier"],
317
+ labels=data.get("labels", []),
318
+ properties=data.get("properties", {}),
319
+ )
320
+
321
+ @staticmethod
322
+ def is_valid_edge_json(edge_data: Dict[str, Any]) -> bool:
323
+ """
324
+ Check that the provided `edge_data` is a valid edge structure
325
+ """
326
+ required_keys = [
327
+ "identifier",
328
+ "source_node_identifier",
329
+ "destination_node_identifier",
330
+ "labels",
331
+ "properties",
332
+ ]
333
+
334
+ type_checks = {
335
+ "identifier": str,
336
+ "source_node_identifier": str,
337
+ "destination_node_identifier": str,
338
+ "labels": list,
339
+ "properties": dict,
340
+ }
341
+
342
+ try:
343
+ # Check if all required keys are present
344
+ if not all(key in edge_data for key in required_keys):
345
+ return False
346
+
347
+ # Check types
348
+ if not all(
349
+ isinstance(edge_data[key], type_)
350
+ for key, type_ in type_checks.items()):
351
+ return False
352
+
353
+ # Check if all labels are strings
354
+ if not all(
355
+ isinstance(label, str) for label in edge_data["labels"]):
356
+ return False
357
+
358
+ return True
359
+
360
+ except KeyError:
361
+ return False
362
+
363
+ def decide_label_string(self, edge_display_props: dict[str, str]) -> None:
364
+ """Returns the label string for the edge."""
365
+ if not edge_display_props:
366
+ self.label_str = "|".join(self.labels)
367
+ else:
368
+ for label in self.labels:
369
+ label_case_incens = label.lower()
370
+ if label_case_incens in edge_display_props:
371
+ prop = edge_display_props[label_case_incens]
372
+ if prop in self.properties:
373
+ val = self.properties[prop]
374
+ self.label_str = f"{label} ({val})"
375
+ break
376
+ if not self.label_str:
377
+ self.label_str = "|".join(self.labels)
378
+
379
+ def add_to_graph(self, graph: nx.MultiDiGraph,
380
+ node_mapping: Dict[str, int], numerical_id: number) -> None:
381
+ """Add this edge to a NetworkX graph using the `node_mapping` to find
382
+ the source and destination numeric identifer.
383
+ All nodes must have been added to the graph before adding edges.
384
+
385
+ Args:
386
+ graph: The networkx graph to add this edge onto.
387
+ node_mapping: A mapping containing all the nodes and their
388
+ numeric node identifier.
389
+
390
+ Returns: None
391
+ """
392
+ if self.source in node_mapping and self.destination in node_mapping:
393
+ source = node_mapping[self.source]
394
+ destination = node_mapping[self.destination]
395
+
396
+ graph.add_edge(
397
+ source,
398
+ destination,
399
+ key=self.identifier,
400
+ id=numerical_id,
401
+ source=source,
402
+ target=destination,
403
+ label=self.label_str,
404
+ properties=self.properties,
405
+ )