anywidget-graph 0.1.0__py3-none-any.whl → 0.2.1__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.
anywidget_graph/widget.py CHANGED
@@ -6,186 +6,448 @@ from typing import TYPE_CHECKING, Any
6
6
 
7
7
  import anywidget
8
8
  import traitlets
9
+ from traitlets import observe
10
+
11
+ from anywidget_graph.backends import DatabaseBackend
12
+ from anywidget_graph.backends.grafeo import GrafeoBackend
13
+ from anywidget_graph.ui import get_css, get_esm
9
14
 
10
15
  if TYPE_CHECKING:
11
16
  from collections.abc import Callable
12
17
 
13
- _ESM = """
14
- import Graph from "https://esm.sh/graphology@0.25.4";
15
- import Sigma from "https://esm.sh/sigma@3.0.0";
16
-
17
- function render({ model, el }) {
18
- const container = document.createElement("div");
19
- container.style.width = model.get("width") + "px";
20
- container.style.height = model.get("height") + "px";
21
- container.style.border = "1px solid #ddd";
22
- container.style.borderRadius = "4px";
23
- container.style.background = model.get("background") || "#fafafa";
24
- el.appendChild(container);
25
-
26
- const graph = new Graph();
27
-
28
- const nodes = model.get("nodes") || [];
29
- nodes.forEach((node) => {
30
- graph.addNode(node.id, {
31
- label: node.label || node.id,
32
- x: node.x ?? Math.random() * 100,
33
- y: node.y ?? Math.random() * 100,
34
- size: node.size || 10,
35
- color: node.color || "#6366f1",
36
- });
37
- });
38
-
39
- const edges = model.get("edges") || [];
40
- edges.forEach((edge, i) => {
41
- graph.addEdge(edge.source, edge.target, {
42
- label: edge.label || "",
43
- size: edge.size || 2,
44
- color: edge.color || "#94a3b8",
45
- });
46
- });
47
-
48
- const renderer = new Sigma(graph, container, {
49
- renderLabels: model.get("show_labels"),
50
- renderEdgeLabels: model.get("show_edge_labels"),
51
- defaultNodeColor: "#6366f1",
52
- defaultEdgeColor: "#94a3b8",
53
- labelColor: { color: "#333" },
54
- labelSize: 12,
55
- labelWeight: "500",
56
- });
57
-
58
- renderer.on("clickNode", ({ node }) => {
59
- const nodeData = graph.getNodeAttributes(node);
60
- model.set("selected_node", { id: node, ...nodeData });
61
- model.save_changes();
62
- });
63
-
64
- renderer.on("clickEdge", ({ edge }) => {
65
- const edgeData = graph.getEdgeAttributes(edge);
66
- const [source, target] = graph.extremities(edge);
67
- model.set("selected_edge", { source, target, ...edgeData });
68
- model.save_changes();
69
- });
70
-
71
- renderer.on("clickStage", () => {
72
- model.set("selected_node", null);
73
- model.set("selected_edge", null);
74
- model.save_changes();
75
- });
76
-
77
- model.on("change:nodes", () => {
78
- graph.clear();
79
- const newNodes = model.get("nodes") || [];
80
- newNodes.forEach((node) => {
81
- graph.addNode(node.id, {
82
- label: node.label || node.id,
83
- x: node.x ?? Math.random() * 100,
84
- y: node.y ?? Math.random() * 100,
85
- size: node.size || 10,
86
- color: node.color || "#6366f1",
87
- });
88
- });
89
- const newEdges = model.get("edges") || [];
90
- newEdges.forEach((edge) => {
91
- graph.addEdge(edge.source, edge.target, {
92
- label: edge.label || "",
93
- size: edge.size || 2,
94
- color: edge.color || "#94a3b8",
95
- });
96
- });
97
- renderer.refresh();
98
- });
99
-
100
- model.on("change:edges", () => {
101
- graph.clearEdges();
102
- const newEdges = model.get("edges") || [];
103
- newEdges.forEach((edge) => {
104
- graph.addEdge(edge.source, edge.target, {
105
- label: edge.label || "",
106
- size: edge.size || 2,
107
- color: edge.color || "#94a3b8",
108
- });
109
- });
110
- renderer.refresh();
111
- });
112
-
113
- return () => {
114
- renderer.kill();
115
- };
116
- }
117
-
118
- export default { render };
119
- """
120
-
121
- _CSS = """
122
- .anywidget-graph {
123
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
124
- }
125
- """
126
-
127
18
 
128
19
  class Graph(anywidget.AnyWidget):
129
- """Interactive graph visualization widget using Sigma.js."""
20
+ """Interactive graph visualization widget using Sigma.js.
21
+
22
+ Supports Neo4j (browser-side) and Grafeo (Python-side) backends.
23
+
24
+ Examples
25
+ --------
26
+ Basic usage with static data:
27
+
28
+ >>> graph = Graph(
29
+ ... nodes=[{"id": "a", "label": "Alice"}, {"id": "b", "label": "Bob"}],
30
+ ... edges=[{"source": "a", "target": "b", "label": "KNOWS"}],
31
+ ... )
32
+
33
+ With Neo4j connection:
34
+
35
+ >>> graph = Graph(
36
+ ... database_backend="neo4j",
37
+ ... connection_uri="neo4j+s://demo.neo4jlabs.com",
38
+ ... connection_username="neo4j",
39
+ ... connection_password="password",
40
+ ... )
41
+
42
+ With Grafeo backend:
43
+
44
+ >>> import grafeo
45
+ >>> db = grafeo.GrafeoDB()
46
+ >>> graph = Graph(database_backend="grafeo", grafeo_db=db)
47
+ """
130
48
 
131
- _esm = _ESM
132
- _css = _CSS
49
+ _esm = get_esm()
50
+ _css = get_css()
133
51
 
52
+ # === Graph Data ===
134
53
  nodes = traitlets.List(trait=traitlets.Dict()).tag(sync=True)
135
54
  edges = traitlets.List(trait=traitlets.Dict()).tag(sync=True)
136
55
 
56
+ # === Display Settings ===
137
57
  width = traitlets.Int(default_value=800).tag(sync=True)
138
58
  height = traitlets.Int(default_value=600).tag(sync=True)
139
59
  background = traitlets.Unicode(default_value="#fafafa").tag(sync=True)
140
60
  show_labels = traitlets.Bool(default_value=True).tag(sync=True)
141
61
  show_edge_labels = traitlets.Bool(default_value=False).tag(sync=True)
142
62
 
63
+ # === Selection State ===
143
64
  selected_node = traitlets.Dict(allow_none=True, default_value=None).tag(sync=True)
144
65
  selected_edge = traitlets.Dict(allow_none=True, default_value=None).tag(sync=True)
145
66
 
67
+ # === Toolbar Visibility ===
68
+ show_toolbar = traitlets.Bool(default_value=True).tag(sync=True)
69
+ show_settings = traitlets.Bool(default_value=True).tag(sync=True)
70
+ show_query_input = traitlets.Bool(default_value=True).tag(sync=True)
71
+
72
+ # === Theme ===
73
+ dark_mode = traitlets.Bool(default_value=True).tag(sync=True)
74
+
75
+ # === Database Backend ===
76
+ database_backend = traitlets.Unicode(default_value="neo4j").tag(sync=True)
77
+
78
+ # === Neo4j Connection (browser-side) ===
79
+ connection_uri = traitlets.Unicode(default_value="").tag(sync=True)
80
+ connection_username = traitlets.Unicode(default_value="").tag(sync=True)
81
+ connection_password = traitlets.Unicode(default_value="").tag(sync=True)
82
+ connection_database = traitlets.Unicode(default_value="neo4j").tag(sync=True)
83
+
84
+ # === Query State ===
85
+ query = traitlets.Unicode(default_value="").tag(sync=True)
86
+ query_language = traitlets.Unicode(default_value="cypher").tag(sync=True)
87
+ query_running = traitlets.Bool(default_value=False).tag(sync=True)
88
+ query_error = traitlets.Unicode(default_value="").tag(sync=True)
89
+
90
+ # === Connection State ===
91
+ connection_status = traitlets.Unicode(default_value="disconnected").tag(sync=True)
92
+
93
+ # === Schema Data ===
94
+ schema_node_types = traitlets.List(trait=traitlets.Dict()).tag(sync=True)
95
+ schema_edge_types = traitlets.List(trait=traitlets.Dict()).tag(sync=True)
96
+
97
+ # === Query Execution Trigger (for Python backends) ===
98
+ _execute_query = traitlets.Int(default_value=0).tag(sync=True)
99
+
146
100
  def __init__(
147
101
  self,
148
102
  nodes: list[dict[str, Any]] | None = None,
149
103
  edges: list[dict[str, Any]] | None = None,
104
+ *,
105
+ width: int = 800,
106
+ height: int = 600,
107
+ background: str = "#fafafa",
108
+ show_labels: bool = True,
109
+ show_edge_labels: bool = False,
110
+ show_toolbar: bool = True,
111
+ show_settings: bool = True,
112
+ show_query_input: bool = True,
113
+ dark_mode: bool = True,
114
+ database_backend: str = "neo4j",
115
+ connection_uri: str = "",
116
+ connection_username: str = "",
117
+ connection_password: str = "",
118
+ connection_database: str = "neo4j",
119
+ grafeo_db: Any = None,
120
+ backend: DatabaseBackend | None = None,
150
121
  **kwargs: Any,
151
122
  ) -> None:
152
- super().__init__(nodes=nodes or [], edges=edges or [], **kwargs)
123
+ """Initialize the Graph widget.
124
+
125
+ Parameters
126
+ ----------
127
+ nodes : list[dict], optional
128
+ List of node dictionaries with 'id' and optional 'label', 'color', etc.
129
+ edges : list[dict], optional
130
+ List of edge dictionaries with 'source', 'target', and optional 'label'.
131
+ width : int
132
+ Widget width in pixels.
133
+ height : int
134
+ Widget height in pixels.
135
+ background : str
136
+ Background color for the graph area.
137
+ show_labels : bool
138
+ Whether to show node labels.
139
+ show_edge_labels : bool
140
+ Whether to show edge labels.
141
+ show_toolbar : bool
142
+ Whether to show the toolbar.
143
+ show_settings : bool
144
+ Whether to show the settings button.
145
+ show_query_input : bool
146
+ Whether to show the query input.
147
+ dark_mode : bool
148
+ Whether to use dark theme.
149
+ database_backend : str
150
+ Database backend: "neo4j" or "grafeo".
151
+ connection_uri : str
152
+ Neo4j connection URI.
153
+ connection_username : str
154
+ Neo4j username.
155
+ connection_password : str
156
+ Neo4j password.
157
+ connection_database : str
158
+ Neo4j database name.
159
+ grafeo_db : Any
160
+ Grafeo database instance for Python-side execution (legacy).
161
+ backend : DatabaseBackend | None
162
+ Generic database backend implementing the DatabaseBackend protocol.
163
+ """
164
+ super().__init__(
165
+ nodes=nodes or [],
166
+ edges=edges or [],
167
+ width=width,
168
+ height=height,
169
+ background=background,
170
+ show_labels=show_labels,
171
+ show_edge_labels=show_edge_labels,
172
+ show_toolbar=show_toolbar,
173
+ show_settings=show_settings,
174
+ show_query_input=show_query_input,
175
+ dark_mode=dark_mode,
176
+ database_backend=database_backend,
177
+ connection_uri=connection_uri,
178
+ connection_username=connection_username,
179
+ connection_password=connection_password,
180
+ connection_database=connection_database,
181
+ **kwargs,
182
+ )
153
183
  self._node_click_callbacks: list[Callable] = []
154
184
  self._edge_click_callbacks: list[Callable] = []
155
185
 
186
+ # Support both legacy grafeo_db and new generic backend
187
+ if backend is not None:
188
+ self._backend = backend
189
+ elif grafeo_db is not None:
190
+ self._backend = GrafeoBackend(grafeo_db)
191
+ else:
192
+ self._backend = None
193
+
194
+ @property
195
+ def backend(self) -> DatabaseBackend | None:
196
+ """Get the current database backend."""
197
+ return self._backend
198
+
199
+ @backend.setter
200
+ def backend(self, value: DatabaseBackend | None) -> None:
201
+ """Set the database backend."""
202
+ self._backend = value
203
+
204
+ @property
205
+ def grafeo_db(self) -> Any:
206
+ """Get the Grafeo database instance (legacy property)."""
207
+ if isinstance(self._backend, GrafeoBackend):
208
+ return self._backend.db
209
+ return None
210
+
211
+ @grafeo_db.setter
212
+ def grafeo_db(self, value: Any) -> None:
213
+ """Set the Grafeo database instance (legacy property)."""
214
+ self._backend = GrafeoBackend(value) if value else None
215
+
216
+ @observe("_execute_query")
217
+ def _on_execute_query(self, change: dict) -> None:
218
+ """Handle query execution trigger from JavaScript."""
219
+ if change["new"] == 0:
220
+ return # Skip initial value
221
+ if self._backend is not None:
222
+ self._execute_backend_query()
223
+
224
+ def _execute_backend_query(self) -> None:
225
+ """Execute query against the configured backend."""
226
+ if not self._backend:
227
+ self.query_error = "No database backend configured"
228
+ return
229
+
230
+ try:
231
+ self.query_running = True
232
+ self.query_error = ""
233
+ nodes, edges = self._backend.execute(self.query)
234
+ self.nodes = nodes
235
+ self.edges = edges
236
+ except Exception as e:
237
+ self.query_error = str(e)
238
+ finally:
239
+ self.query_running = False
240
+
156
241
  @classmethod
157
242
  def from_dict(cls, data: dict[str, Any], **kwargs: Any) -> Graph:
158
- """Create a Graph from a dictionary with nodes and edges keys."""
243
+ """Create a Graph from a dictionary with 'nodes' and 'edges' keys.
244
+
245
+ Parameters
246
+ ----------
247
+ data : dict
248
+ Dictionary with 'nodes' and 'edges' lists.
249
+ **kwargs
250
+ Additional arguments passed to Graph constructor.
251
+
252
+ Returns
253
+ -------
254
+ Graph
255
+ New Graph instance.
256
+ """
159
257
  return cls(nodes=data.get("nodes", []), edges=data.get("edges", []), **kwargs)
160
258
 
161
259
  @classmethod
162
260
  def from_cypher(cls, result: Any, **kwargs: Any) -> Graph:
163
- """Create a Graph from Cypher query results."""
164
- nodes: dict[str, dict] = {}
165
- edges: list[dict] = []
166
-
167
- records = list(result) if hasattr(result, "__iter__") else [result]
168
-
169
- for record in records:
170
- if hasattr(record, "items"):
171
- items = record.items() if callable(record.items) else record.items
172
- elif hasattr(record, "data"):
173
- items = record.data().items()
174
- else:
175
- items = record.items() if isinstance(record, dict) else []
176
-
177
- for key, value in items:
178
- if _is_node(value):
179
- node_id = _get_node_id(value)
180
- if node_id not in nodes:
181
- nodes[node_id] = _node_to_dict(value)
182
- elif _is_relationship(value):
183
- edges.append(_relationship_to_dict(value))
184
-
185
- return cls(nodes=list(nodes.values()), edges=edges, **kwargs)
261
+ """Create a Graph from Cypher query results.
262
+
263
+ Parameters
264
+ ----------
265
+ result : Any
266
+ Query result from Neo4j driver or similar.
267
+ **kwargs
268
+ Additional arguments passed to Graph constructor.
269
+
270
+ Returns
271
+ -------
272
+ Graph
273
+ New Graph instance with extracted nodes and edges.
274
+
275
+ Example
276
+ -------
277
+ >>> from neo4j import GraphDatabase
278
+ >>> driver = GraphDatabase.driver(uri, auth=auth)
279
+ >>> with driver.session() as session:
280
+ ... result = session.run("MATCH (n)-[r]->(m) RETURN n, r, m")
281
+ ... graph = Graph.from_cypher(result)
282
+ """
283
+ from anywidget_graph.converters import CypherConverter
284
+
285
+ data = CypherConverter().convert(result)
286
+ return cls(nodes=data["nodes"], edges=data["edges"], **kwargs)
287
+
288
+ @classmethod
289
+ def from_gql(cls, result: Any, **kwargs: Any) -> Graph:
290
+ """Create a Graph from GQL query results.
291
+
292
+ GQL (ISO Graph Query Language) uses a similar format to Cypher.
293
+
294
+ Parameters
295
+ ----------
296
+ result : Any
297
+ Query result from GQL-compatible database.
298
+ **kwargs
299
+ Additional arguments passed to Graph constructor.
300
+
301
+ Returns
302
+ -------
303
+ Graph
304
+ New Graph instance with extracted nodes and edges.
305
+ """
306
+ from anywidget_graph.converters import GQLConverter
307
+
308
+ data = GQLConverter().convert(result)
309
+ return cls(nodes=data["nodes"], edges=data["edges"], **kwargs)
310
+
311
+ @classmethod
312
+ def from_sparql(
313
+ cls,
314
+ result: Any,
315
+ subject_var: str = "s",
316
+ predicate_var: str = "p",
317
+ object_var: str = "o",
318
+ **kwargs: Any,
319
+ ) -> Graph:
320
+ """Create a Graph from SPARQL query results.
321
+
322
+ Parameters
323
+ ----------
324
+ result : Any
325
+ SPARQL query result (e.g., from RDFLib or SPARQLWrapper).
326
+ subject_var : str
327
+ Variable name for subjects (default: "s").
328
+ predicate_var : str
329
+ Variable name for predicates (default: "p").
330
+ object_var : str
331
+ Variable name for objects (default: "o").
332
+ **kwargs
333
+ Additional arguments passed to Graph constructor.
334
+
335
+ Returns
336
+ -------
337
+ Graph
338
+ New Graph instance with extracted nodes and edges.
339
+
340
+ Example
341
+ -------
342
+ >>> from rdflib import Graph as RDFGraph
343
+ >>> g = RDFGraph()
344
+ >>> g.parse("data.ttl")
345
+ >>> result = g.query("SELECT ?s ?p ?o WHERE { ?s ?p ?o }")
346
+ >>> graph = Graph.from_sparql(result)
347
+ """
348
+ from anywidget_graph.converters import SPARQLConverter
349
+
350
+ converter = SPARQLConverter(
351
+ subject_var=subject_var,
352
+ predicate_var=predicate_var,
353
+ object_var=object_var,
354
+ )
355
+ data = converter.convert(result)
356
+ return cls(nodes=data["nodes"], edges=data["edges"], **kwargs)
357
+
358
+ @classmethod
359
+ def from_gremlin(cls, result: Any, **kwargs: Any) -> Graph:
360
+ """Create a Graph from Gremlin/TinkerPop query results.
361
+
362
+ Parameters
363
+ ----------
364
+ result : Any
365
+ Gremlin traversal result.
366
+ **kwargs
367
+ Additional arguments passed to Graph constructor.
368
+
369
+ Returns
370
+ -------
371
+ Graph
372
+ New Graph instance with extracted nodes and edges.
373
+
374
+ Example
375
+ -------
376
+ >>> from gremlin_python.driver import client
377
+ >>> gremlin_client = client.Client('ws://localhost:8182/gremlin', 'g')
378
+ >>> result = gremlin_client.submit("g.V().limit(10)").all().result()
379
+ >>> graph = Graph.from_gremlin(result)
380
+ """
381
+ from anywidget_graph.converters import GremlinConverter
382
+
383
+ data = GremlinConverter().convert(result)
384
+ return cls(nodes=data["nodes"], edges=data["edges"], **kwargs)
385
+
386
+ @classmethod
387
+ def from_graphql(
388
+ cls,
389
+ result: Any,
390
+ nodes_path: str | None = None,
391
+ edges_path: str | None = None,
392
+ id_field: str = "id",
393
+ label_field: str = "name",
394
+ **kwargs: Any,
395
+ ) -> Graph:
396
+ """Create a Graph from GraphQL JSON response.
397
+
398
+ Parameters
399
+ ----------
400
+ result : Any
401
+ GraphQL JSON response.
402
+ nodes_path : str | None
403
+ Dot-separated path to nodes list (e.g., "data.users").
404
+ edges_path : str | None
405
+ Dot-separated path to edges list.
406
+ id_field : str
407
+ Field name for node IDs (default: "id").
408
+ label_field : str
409
+ Field name for node labels (default: "name").
410
+ **kwargs
411
+ Additional arguments passed to Graph constructor.
412
+
413
+ Returns
414
+ -------
415
+ Graph
416
+ New Graph instance with extracted nodes and edges.
417
+
418
+ Example
419
+ -------
420
+ >>> import requests
421
+ >>> response = requests.post(url, json={"query": query})
422
+ >>> graph = Graph.from_graphql(
423
+ ... response.json(),
424
+ ... nodes_path="data.characters.results",
425
+ ... )
426
+ """
427
+ from anywidget_graph.converters import GraphQLConverter
428
+
429
+ converter = GraphQLConverter(
430
+ nodes_path=nodes_path,
431
+ edges_path=edges_path,
432
+ id_field=id_field,
433
+ label_field=label_field,
434
+ )
435
+ data = converter.convert(result)
436
+ return cls(nodes=data["nodes"], edges=data["edges"], **kwargs)
186
437
 
187
438
  def on_node_click(self, callback: Callable[[str, dict], None]) -> Callable:
188
- """Register a callback for node click events."""
439
+ """Register a callback for node click events.
440
+
441
+ Parameters
442
+ ----------
443
+ callback : Callable[[str, dict], None]
444
+ Function called with (node_id, node_attributes) when a node is clicked.
445
+
446
+ Returns
447
+ -------
448
+ Callable
449
+ The callback function (for decorator usage).
450
+ """
189
451
  self._node_click_callbacks.append(callback)
190
452
 
191
453
  def observer(change: dict) -> None:
@@ -199,7 +461,18 @@ class Graph(anywidget.AnyWidget):
199
461
  return callback
200
462
 
201
463
  def on_edge_click(self, callback: Callable[[dict], None]) -> Callable:
202
- """Register a callback for edge click events."""
464
+ """Register a callback for edge click events.
465
+
466
+ Parameters
467
+ ----------
468
+ callback : Callable[[dict], None]
469
+ Function called with edge data when an edge is clicked.
470
+
471
+ Returns
472
+ -------
473
+ Callable
474
+ The callback function (for decorator usage).
475
+ """
203
476
  self._edge_click_callbacks.append(callback)
204
477
 
205
478
  def observer(change: dict) -> None:
@@ -209,85 +482,3 @@ class Graph(anywidget.AnyWidget):
209
482
 
210
483
  self.observe(observer, names=["selected_edge"])
211
484
  return callback
212
-
213
-
214
- def _is_node(obj: Any) -> bool:
215
- if hasattr(obj, "labels") and hasattr(obj, "element_id"):
216
- return True
217
- if hasattr(obj, "labels") and hasattr(obj, "properties"):
218
- return True
219
- if isinstance(obj, dict) and "id" in obj:
220
- return True
221
- return False
222
-
223
-
224
- def _is_relationship(obj: Any) -> bool:
225
- if hasattr(obj, "type") and hasattr(obj, "start_node"):
226
- return True
227
- if hasattr(obj, "type") and hasattr(obj, "source") and hasattr(obj, "target"):
228
- return True
229
- if isinstance(obj, dict) and "source" in obj and "target" in obj:
230
- return True
231
- return False
232
-
233
-
234
- def _get_node_id(node: Any) -> str:
235
- if hasattr(node, "element_id"):
236
- return str(node.element_id)
237
- if hasattr(node, "id"):
238
- return str(node.id)
239
- if isinstance(node, dict):
240
- return str(node.get("id", id(node)))
241
- return str(id(node))
242
-
243
-
244
- def _node_to_dict(node: Any) -> dict:
245
- result: dict[str, Any] = {"id": _get_node_id(node)}
246
-
247
- if hasattr(node, "labels"):
248
- labels = list(node.labels) if hasattr(node.labels, "__iter__") else [node.labels]
249
- if labels:
250
- result["label"] = labels[0]
251
- result["labels"] = labels
252
-
253
- if hasattr(node, "properties"):
254
- props = node.properties if isinstance(node.properties, dict) else dict(node.properties)
255
- result.update(props)
256
- elif hasattr(node, "items"):
257
- result.update(dict(node))
258
- elif isinstance(node, dict):
259
- result.update(node)
260
-
261
- if "label" not in result and "name" in result:
262
- result["label"] = result["name"]
263
-
264
- return result
265
-
266
-
267
- def _relationship_to_dict(rel: Any) -> dict:
268
- result: dict[str, Any] = {}
269
-
270
- if hasattr(rel, "start_node") and hasattr(rel, "end_node"):
271
- result["source"] = _get_node_id(rel.start_node)
272
- result["target"] = _get_node_id(rel.end_node)
273
- elif hasattr(rel, "source") and hasattr(rel, "target"):
274
- result["source"] = _get_node_id(rel.source)
275
- result["target"] = _get_node_id(rel.target)
276
- elif isinstance(rel, dict):
277
- result["source"] = str(rel.get("source", ""))
278
- result["target"] = str(rel.get("target", ""))
279
-
280
- if hasattr(rel, "type"):
281
- result["label"] = str(rel.type)
282
- elif isinstance(rel, dict) and "type" in rel:
283
- result["label"] = str(rel["type"])
284
- elif isinstance(rel, dict) and "label" in rel:
285
- result["label"] = str(rel["label"])
286
-
287
- if hasattr(rel, "properties"):
288
- props = rel.properties if isinstance(rel.properties, dict) else dict(rel.properties)
289
- result.update(props)
290
- elif hasattr(rel, "items") and not isinstance(rel, dict):
291
- result.update(dict(rel))
292
-
293
- return result