anywidget-graph 0.2.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.
@@ -0,0 +1,167 @@
1
+ """Converter for SPARQL query results (RDF triples)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from anywidget_graph.converters.base import GraphData
8
+
9
+
10
+ class SPARQLConverter:
11
+ """Convert SPARQL query results to graph data.
12
+
13
+ Handles RDF data from SPARQL endpoints:
14
+ - SELECT queries with ?s ?p ?o bindings
15
+ - CONSTRUCT queries returning triples
16
+ - RDFLib result objects
17
+
18
+ The converter treats URIs as node identifiers and predicates as edge labels.
19
+ Literal values become node properties.
20
+
21
+ Parameters
22
+ ----------
23
+ subject_var : str
24
+ Variable name for subjects in SELECT results (default: "s").
25
+ predicate_var : str
26
+ Variable name for predicates in SELECT results (default: "p").
27
+ object_var : str
28
+ Variable name for objects in SELECT results (default: "o").
29
+ node_label_predicate : str | None
30
+ Predicate URI used for node labels (default: rdfs:label).
31
+ Set to None to disable label extraction.
32
+
33
+ Example
34
+ -------
35
+ >>> from rdflib import Graph as RDFGraph
36
+ >>> g = RDFGraph()
37
+ >>> g.parse("data.ttl")
38
+ >>> result = g.query("SELECT ?s ?p ?o WHERE { ?s ?p ?o }")
39
+ >>> converter = SPARQLConverter()
40
+ >>> data = converter.convert(result)
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ subject_var: str = "s",
46
+ predicate_var: str = "p",
47
+ object_var: str = "o",
48
+ node_label_predicate: str | None = "http://www.w3.org/2000/01/rdf-schema#label",
49
+ ) -> None:
50
+ self.subject_var = subject_var
51
+ self.predicate_var = predicate_var
52
+ self.object_var = object_var
53
+ self.node_label_predicate = node_label_predicate
54
+
55
+ def convert(self, result: Any) -> GraphData:
56
+ """Convert SPARQL result to nodes and edges.
57
+
58
+ Parameters
59
+ ----------
60
+ result : Any
61
+ SPARQL query result (e.g., from RDFLib or SPARQLWrapper).
62
+
63
+ Returns
64
+ -------
65
+ GraphData
66
+ Dictionary with 'nodes' and 'edges' lists.
67
+ """
68
+ nodes: dict[str, dict[str, Any]] = {}
69
+ edges: list[dict[str, Any]] = []
70
+ labels: dict[str, str] = {} # URI -> label mapping
71
+
72
+ bindings = list(self._iter_bindings(result))
73
+
74
+ # First pass: collect labels
75
+ if self.node_label_predicate:
76
+ for binding in bindings:
77
+ pred = self._get_value(binding, self.predicate_var)
78
+ if pred == self.node_label_predicate:
79
+ subj = self._get_value(binding, self.subject_var)
80
+ obj = self._get_value(binding, self.object_var)
81
+ if subj and obj:
82
+ labels[subj] = obj
83
+
84
+ # Second pass: build graph
85
+ for binding in bindings:
86
+ subj = self._get_value(binding, self.subject_var)
87
+ pred = self._get_value(binding, self.predicate_var)
88
+ obj = self._get_value(binding, self.object_var)
89
+
90
+ if not subj or not pred:
91
+ continue
92
+
93
+ # Skip label triples (already processed)
94
+ if pred == self.node_label_predicate:
95
+ continue
96
+
97
+ # Create subject node
98
+ if subj not in nodes:
99
+ nodes[subj] = {
100
+ "id": subj,
101
+ "label": labels.get(subj, self._extract_local_name(subj)),
102
+ "uri": subj,
103
+ }
104
+
105
+ # If object is a URI, create object node and edge
106
+ if obj and self._is_uri(obj):
107
+ if obj not in nodes:
108
+ nodes[obj] = {
109
+ "id": obj,
110
+ "label": labels.get(obj, self._extract_local_name(obj)),
111
+ "uri": obj,
112
+ }
113
+ edges.append(
114
+ {
115
+ "source": subj,
116
+ "target": obj,
117
+ "label": self._extract_local_name(pred),
118
+ "predicate": pred,
119
+ }
120
+ )
121
+ elif obj:
122
+ # Literal value - add as node property
123
+ prop_name = self._extract_local_name(pred)
124
+ nodes[subj][prop_name] = obj
125
+
126
+ return {"nodes": list(nodes.values()), "edges": edges}
127
+
128
+ def _iter_bindings(self, result: Any):
129
+ """Iterate over result bindings."""
130
+ # RDFLib Result
131
+ if hasattr(result, "bindings"):
132
+ yield from result.bindings
133
+ # SPARQLWrapper JSON result
134
+ elif isinstance(result, dict) and "results" in result:
135
+ yield from result["results"].get("bindings", [])
136
+ # Iterable of bindings
137
+ elif hasattr(result, "__iter__"):
138
+ yield from result
139
+
140
+ def _get_value(self, binding: Any, var: str) -> str:
141
+ """Extract value from a binding for a variable."""
142
+ if isinstance(binding, dict):
143
+ # SPARQLWrapper format: {"var": {"value": "..."}}
144
+ val = binding.get(var, {})
145
+ if isinstance(val, dict):
146
+ return str(val.get("value", ""))
147
+ return str(val) if val else ""
148
+ # RDFLib format: binding[var] is an RDF term
149
+ elif hasattr(binding, "__getitem__"):
150
+ try:
151
+ val = binding[var]
152
+ return str(val) if val else ""
153
+ except (KeyError, TypeError):
154
+ return ""
155
+ return ""
156
+
157
+ def _is_uri(self, value: str) -> bool:
158
+ """Check if a value looks like a URI."""
159
+ return value.startswith(("http://", "https://", "urn:"))
160
+
161
+ def _extract_local_name(self, uri: str) -> str:
162
+ """Extract local name from URI (part after # or last /)."""
163
+ if "#" in uri:
164
+ return uri.split("#")[-1]
165
+ if "/" in uri:
166
+ return uri.rsplit("/", 1)[-1]
167
+ return uri
@@ -0,0 +1,19 @@
1
+ """UI components for anywidget-graph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ __all__ = ["get_esm", "get_css"]
8
+
9
+ _UI_DIR = Path(__file__).parent
10
+
11
+
12
+ def get_esm() -> str:
13
+ """Get the combined ESM JavaScript for the widget."""
14
+ return (_UI_DIR / "index.js").read_text(encoding="utf-8")
15
+
16
+
17
+ def get_css() -> str:
18
+ """Get the combined CSS styles for the widget."""
19
+ return (_UI_DIR / "styles.css").read_text(encoding="utf-8")
@@ -0,0 +1,15 @@
1
+ /**
2
+ * SVG icons for the graph widget UI.
3
+ */
4
+ export const ICONS = {
5
+ play: `<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>`,
6
+ settings: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v4m0 14v4m11-11h-4M5 12H1m17.36-5.64l-2.83 2.83M9.47 14.53l-2.83 2.83m0-10.72l2.83 2.83m5.06 5.06l2.83 2.83"/></svg>`,
7
+ close: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>`,
8
+ chevron: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>`,
9
+ chevronRight: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>`,
10
+ node: `<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="8"/></svg>`,
11
+ edge: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14m-4-4l4 4-4 4"/></svg>`,
12
+ property: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7h16M4 12h16M4 17h10"/></svg>`,
13
+ sidebar: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 3v18"/></svg>`,
14
+ refresh: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/></svg>`,
15
+ };
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Main entry point for the anywidget-graph UI.
3
+ * Orchestrates all UI components and graph rendering.
4
+ */
5
+ import Graph from "https://esm.sh/graphology@0.25.4";
6
+ import Sigma from "https://esm.sh/sigma@3.0.0";
7
+
8
+ import { createToolbar } from "./toolbar.js";
9
+ import { createSchemaPanel } from "./schema.js";
10
+ import { createSettingsPanel } from "./settings.js";
11
+ import { createPropertiesPanel } from "./properties.js";
12
+ import * as neo4jBackend from "./neo4j.js";
13
+
14
+ /**
15
+ * Execute a query based on the current backend.
16
+ */
17
+ async function executeQuery(model) {
18
+ const backend = model.get("database_backend");
19
+ const query = model.get("query");
20
+
21
+ if (!query.trim()) {
22
+ model.set("query_error", "Please enter a query");
23
+ model.save_changes();
24
+ return;
25
+ }
26
+
27
+ if (backend === "neo4j") {
28
+ const result = await neo4jBackend.executeQuery(
29
+ query,
30
+ model.get("connection_database"),
31
+ model
32
+ );
33
+
34
+ if (result) {
35
+ model.set("nodes", result.nodes);
36
+ model.set("edges", result.edges);
37
+ model.save_changes();
38
+ }
39
+ } else if (backend === "grafeo") {
40
+ // Trigger Python-side execution
41
+ model.set("_execute_query", model.get("_execute_query") + 1);
42
+ model.save_changes();
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Main render function for the anywidget.
48
+ */
49
+ function render({ model, el }) {
50
+ const wrapper = document.createElement("div");
51
+ wrapper.className = "awg-wrapper";
52
+
53
+ // Apply dark mode
54
+ function updateTheme() {
55
+ wrapper.classList.toggle("awg-dark", model.get("dark_mode"));
56
+ }
57
+ updateTheme();
58
+ model.on("change:dark_mode", updateTheme);
59
+
60
+ // Create query executor callback
61
+ const onExecuteQuery = () => executeQuery(model);
62
+
63
+ // Create toolbar if enabled
64
+ if (model.get("show_toolbar")) {
65
+ const toolbar = createToolbar(model, wrapper, onExecuteQuery);
66
+ wrapper.appendChild(toolbar);
67
+ }
68
+
69
+ // Main content area (schema + graph + properties)
70
+ const content = document.createElement("div");
71
+ content.className = "awg-content";
72
+
73
+ // Create schema sidebar (left) - collapsed by default
74
+ const schemaSidebar = createSchemaPanel(model, onExecuteQuery);
75
+ content.appendChild(schemaSidebar);
76
+
77
+ // Create graph container
78
+ const container = document.createElement("div");
79
+ container.className = "awg-graph-container";
80
+ container.style.width = model.get("width") + "px";
81
+ container.style.height = model.get("height") + "px";
82
+ content.appendChild(container);
83
+
84
+ // Create properties panel (right) - collapsed by default
85
+ const propertiesPanel = createPropertiesPanel(model);
86
+ content.appendChild(propertiesPanel);
87
+
88
+ // Create settings panel if enabled
89
+ if (model.get("show_settings")) {
90
+ const settingsPanel = createSettingsPanel(model);
91
+ content.appendChild(settingsPanel);
92
+ }
93
+
94
+ wrapper.appendChild(content);
95
+ el.appendChild(wrapper);
96
+
97
+ // Initialize Graphology graph
98
+ const graph = new Graph();
99
+
100
+ // Add initial nodes
101
+ const nodes = model.get("nodes") || [];
102
+ nodes.forEach((node) => {
103
+ graph.addNode(node.id, {
104
+ label: node.label || node.id,
105
+ x: node.x ?? Math.random() * 100,
106
+ y: node.y ?? Math.random() * 100,
107
+ size: node.size || 10,
108
+ color: node.color || "#6366f1",
109
+ });
110
+ });
111
+
112
+ // Add initial edges
113
+ const edges = model.get("edges") || [];
114
+ edges.forEach((edge) => {
115
+ graph.addEdge(edge.source, edge.target, {
116
+ label: edge.label || "",
117
+ size: edge.size || 2,
118
+ color: edge.color || "#94a3b8",
119
+ });
120
+ });
121
+
122
+ // Initialize Sigma renderer
123
+ const renderer = new Sigma(graph, container, {
124
+ renderLabels: model.get("show_labels"),
125
+ renderEdgeLabels: model.get("show_edge_labels"),
126
+ defaultNodeColor: "#6366f1",
127
+ defaultEdgeColor: "#94a3b8",
128
+ labelColor: { color: "#333" },
129
+ labelSize: 12,
130
+ labelWeight: "500",
131
+ });
132
+
133
+ // Node click handler
134
+ renderer.on("clickNode", ({ node }) => {
135
+ const nodeData = graph.getNodeAttributes(node);
136
+ model.set("selected_node", { id: node, ...nodeData });
137
+ model.save_changes();
138
+ });
139
+
140
+ // Edge click handler
141
+ renderer.on("clickEdge", ({ edge }) => {
142
+ const edgeData = graph.getEdgeAttributes(edge);
143
+ const [source, target] = graph.extremities(edge);
144
+ model.set("selected_edge", { source, target, ...edgeData });
145
+ model.save_changes();
146
+ });
147
+
148
+ // Stage click (deselect)
149
+ renderer.on("clickStage", () => {
150
+ model.set("selected_node", null);
151
+ model.set("selected_edge", null);
152
+ model.save_changes();
153
+ });
154
+
155
+ // Update graph when nodes change
156
+ model.on("change:nodes", () => {
157
+ graph.clear();
158
+ const newNodes = model.get("nodes") || [];
159
+ newNodes.forEach((node) => {
160
+ graph.addNode(node.id, {
161
+ label: node.label || node.id,
162
+ x: node.x ?? Math.random() * 100,
163
+ y: node.y ?? Math.random() * 100,
164
+ size: node.size || 10,
165
+ color: node.color || "#6366f1",
166
+ });
167
+ });
168
+ const newEdges = model.get("edges") || [];
169
+ newEdges.forEach((edge) => {
170
+ graph.addEdge(edge.source, edge.target, {
171
+ label: edge.label || "",
172
+ size: edge.size || 2,
173
+ color: edge.color || "#94a3b8",
174
+ });
175
+ });
176
+ renderer.refresh();
177
+ });
178
+
179
+ // Update graph when edges change
180
+ model.on("change:edges", () => {
181
+ graph.clearEdges();
182
+ const newEdges = model.get("edges") || [];
183
+ newEdges.forEach((edge) => {
184
+ graph.addEdge(edge.source, edge.target, {
185
+ label: edge.label || "",
186
+ size: edge.size || 2,
187
+ color: edge.color || "#94a3b8",
188
+ });
189
+ });
190
+ renderer.refresh();
191
+ });
192
+
193
+ // Cleanup on destroy
194
+ return () => {
195
+ renderer.kill();
196
+ };
197
+ }
198
+
199
+ export default { render };
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Neo4j database connection and query execution (browser-side).
3
+ */
4
+ import neo4j from "https://cdn.jsdelivr.net/npm/neo4j-driver@5.28.0/lib/browser/neo4j-web.esm.min.js";
5
+
6
+ let driver = null;
7
+
8
+ /**
9
+ * Connect to a Neo4j database.
10
+ */
11
+ export async function connect(uri, username, password, model) {
12
+ if (driver) {
13
+ await driver.close();
14
+ driver = null;
15
+ }
16
+
17
+ model.set("connection_status", "connecting");
18
+ model.save_changes();
19
+
20
+ try {
21
+ driver = neo4j.driver(uri, neo4j.auth.basic(username, password));
22
+ await driver.verifyConnectivity();
23
+ model.set("connection_status", "connected");
24
+ model.set("query_error", "");
25
+ model.save_changes();
26
+
27
+ await fetchSchema(model);
28
+ return true;
29
+ } catch (error) {
30
+ model.set("connection_status", "error");
31
+ model.set("query_error", "Connection failed: " + error.message);
32
+ model.save_changes();
33
+ driver = null;
34
+ return false;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Disconnect from Neo4j database.
40
+ */
41
+ export async function disconnect(model) {
42
+ if (driver) {
43
+ await driver.close();
44
+ driver = null;
45
+ }
46
+ model.set("connection_status", "disconnected");
47
+ model.save_changes();
48
+ }
49
+
50
+ /**
51
+ * Check if connected to Neo4j.
52
+ */
53
+ export function isConnected() {
54
+ return driver !== null;
55
+ }
56
+
57
+ /**
58
+ * Fetch database schema (labels and relationship types).
59
+ */
60
+ export async function fetchSchema(model) {
61
+ if (!driver) return;
62
+
63
+ const database = model.get("connection_database") || "neo4j";
64
+ const session = driver.session({ database });
65
+
66
+ try {
67
+ // Fetch node labels and their properties
68
+ const labelsResult = await session.run("CALL db.labels()");
69
+ const nodeTypes = [];
70
+
71
+ for (const record of labelsResult.records) {
72
+ const label = record.get(0);
73
+ const propsResult = await session.run(
74
+ `MATCH (n:\`${label}\`) UNWIND keys(n) AS key RETURN DISTINCT key LIMIT 20`
75
+ );
76
+ const properties = propsResult.records.map((r) => r.get(0));
77
+ nodeTypes.push({ label, properties, count: null });
78
+ }
79
+
80
+ // Fetch relationship types and their properties
81
+ const relTypesResult = await session.run("CALL db.relationshipTypes()");
82
+ const edgeTypes = [];
83
+
84
+ for (const record of relTypesResult.records) {
85
+ const type = record.get(0);
86
+ const propsResult = await session.run(
87
+ `MATCH ()-[r:\`${type}\`]->() UNWIND keys(r) AS key RETURN DISTINCT key LIMIT 20`
88
+ );
89
+ const properties = propsResult.records.map((r) => r.get(0));
90
+ edgeTypes.push({ type, properties, count: null });
91
+ }
92
+
93
+ model.set("schema_node_types", nodeTypes);
94
+ model.set("schema_edge_types", edgeTypes);
95
+ model.save_changes();
96
+ } catch (error) {
97
+ console.error("Failed to fetch schema:", error);
98
+ } finally {
99
+ await session.close();
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Execute a Cypher query.
105
+ */
106
+ export async function executeQuery(query, database, model) {
107
+ if (!driver) {
108
+ model.set("query_error", "Not connected to database");
109
+ model.save_changes();
110
+ return null;
111
+ }
112
+
113
+ model.set("query_running", true);
114
+ model.set("query_error", "");
115
+ model.save_changes();
116
+
117
+ const session = driver.session({ database: database || "neo4j" });
118
+
119
+ try {
120
+ const result = await session.run(query);
121
+ model.set("query_running", false);
122
+ model.save_changes();
123
+ return processRecords(result.records);
124
+ } catch (error) {
125
+ model.set("query_running", false);
126
+ model.set("query_error", "Query error: " + error.message);
127
+ model.save_changes();
128
+ return null;
129
+ } finally {
130
+ await session.close();
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Process Neo4j records into nodes and edges.
136
+ */
137
+ function processRecords(records) {
138
+ const nodes = new Map();
139
+ const edges = [];
140
+
141
+ for (const record of records) {
142
+ for (const key of record.keys) {
143
+ const value = record.get(key);
144
+ processValue(value, nodes, edges);
145
+ }
146
+ }
147
+
148
+ return { nodes: Array.from(nodes.values()), edges };
149
+ }
150
+
151
+ /**
152
+ * Process a single Neo4j value (node, relationship, path, or array).
153
+ */
154
+ function processValue(value, nodes, edges) {
155
+ if (!value) return;
156
+
157
+ if (neo4j.isNode(value)) {
158
+ const nodeId = value.elementId || value.identity.toString();
159
+ if (!nodes.has(nodeId)) {
160
+ const props = {};
161
+ for (const [k, v] of Object.entries(value.properties)) {
162
+ props[k] = neo4j.isInt(v) ? v.toNumber() : v;
163
+ }
164
+ nodes.set(nodeId, {
165
+ id: nodeId,
166
+ label: props.name || props.title || value.labels[0] || nodeId,
167
+ labels: value.labels,
168
+ ...props,
169
+ });
170
+ }
171
+ } else if (neo4j.isRelationship(value)) {
172
+ const props = {};
173
+ for (const [k, v] of Object.entries(value.properties)) {
174
+ props[k] = neo4j.isInt(v) ? v.toNumber() : v;
175
+ }
176
+ edges.push({
177
+ source: value.startNodeElementId || value.start.toString(),
178
+ target: value.endNodeElementId || value.end.toString(),
179
+ label: value.type,
180
+ ...props,
181
+ });
182
+ } else if (neo4j.isPath(value)) {
183
+ for (const segment of value.segments) {
184
+ processValue(segment.start, nodes, edges);
185
+ processValue(segment.end, nodes, edges);
186
+ processValue(segment.relationship, nodes, edges);
187
+ }
188
+ } else if (Array.isArray(value)) {
189
+ for (const item of value) {
190
+ processValue(item, nodes, edges);
191
+ }
192
+ }
193
+ }