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.
- assets/authentication.png +0 -0
- assets/full_viz.png +0 -0
- assets/hero.png +0 -0
- assets/hero_with_properties.png +0 -0
- assets/jupyter-spin-up.png +0 -0
- assets/load_ext.png +0 -0
- assets/mock_data_result.png +0 -0
- assets/new_notebook.png +0 -0
- assets/notebook_package_load.png +0 -0
- assets/query_graph.png +0 -0
- assets/sample_jupyter.png +0 -0
- spanner_graph_notebook-1.0.0.dist-info/LICENSE +202 -0
- spanner_graph_notebook-1.0.0.dist-info/METADATA +169 -0
- spanner_graph_notebook-1.0.0.dist-info/RECORD +40 -0
- spanner_graph_notebook-1.0.0.dist-info/WHEEL +5 -0
- spanner_graph_notebook-1.0.0.dist-info/top_level.txt +2 -0
- spanner_graphs/__init__.py +19 -0
- spanner_graphs/conversion.py +299 -0
- spanner_graphs/database.py +199 -0
- spanner_graphs/graph_entities.py +405 -0
- spanner_graphs/graph_mock_data.csv +51 -0
- spanner_graphs/graph_mock_schema.json +681 -0
- spanner_graphs/magics.py +211 -0
- spanner_graphs/schema_manager.py +60 -0
- templates/assets/images/graph-bg.svg +4 -0
- templates/spanner-graph/models/edge.js +77 -0
- templates/spanner-graph/models/graph-object.js +64 -0
- templates/spanner-graph/models/node.js +77 -0
- templates/spanner-graph/models/schema.js +327 -0
- templates/spanner-graph/spanner-config.js +304 -0
- templates/spanner-graph/spanner-store.js +382 -0
- templates/spanner-graph/visualization/spanner-forcegraph.js +1380 -0
- templates/spanner-graph/visualization/spanner-sidebar.js +904 -0
- templates/template-spannergraph.html +210 -0
- tests/__init__.py +13 -0
- tests/conversion_test.py +163 -0
- tests/database_test.py +62 -0
- tests/graph_entities_test.py +124 -0
- tests/schema_manager_test.py +115 -0
- 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
|
+
)
|