anywidget-graph 0.2.6__tar.gz → 0.2.8__tar.gz

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 (42) hide show
  1. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/PKG-INFO +2 -1
  2. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/README.md +1 -0
  3. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/pyproject.toml +1 -1
  4. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/__init__.py +1 -1
  5. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/backends/grafeo.py +61 -3
  6. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/ui/__init__.py +4 -0
  7. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/ui/icons.js +3 -0
  8. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/ui/index.js +121 -3
  9. anywidget_graph-0.2.8/src/anywidget_graph/ui/results.js +183 -0
  10. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/ui/styles.css +181 -1
  11. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/ui/toolbar.js +49 -1
  12. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/widget.py +4 -0
  13. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/tests/test_graph.py +148 -0
  14. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/uv.lock +29 -29
  15. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/.github/workflows/ci.yml +0 -0
  16. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/.github/workflows/pypi.yml +0 -0
  17. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/.gitignore +0 -0
  18. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/.python-version +0 -0
  19. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/LICENSE +0 -0
  20. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/examples/demo.py +0 -0
  21. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/backends/__init__.py +0 -0
  22. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/backends/arango.py +0 -0
  23. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/backends/cosmosdb.py +0 -0
  24. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/backends/ladybug.py +0 -0
  25. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/backends/neo4j.py +0 -0
  26. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/converters/__init__.py +0 -0
  27. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/converters/base.py +0 -0
  28. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/converters/common.py +0 -0
  29. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/converters/cypher.py +0 -0
  30. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/converters/gql.py +0 -0
  31. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/converters/graphql.py +0 -0
  32. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/converters/gremlin.py +0 -0
  33. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/converters/sparql.py +0 -0
  34. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/demo.py +0 -0
  35. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/py.typed +0 -0
  36. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/ui/grafeo-embed.js +0 -0
  37. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/ui/grafeo.js +0 -0
  38. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/ui/neo4j.js +0 -0
  39. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/ui/properties.js +0 -0
  40. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/ui/schema.js +0 -0
  41. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/ui/settings.js +0 -0
  42. {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/tests/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: anywidget-graph
3
- Version: 0.2.6
3
+ Version: 0.2.8
4
4
  Summary: Interactive graph visualization for Python notebooks using anywidget
5
5
  Project-URL: Homepage, https://grafeo.dev/
6
6
  Project-URL: Repository, https://github.com/GrafeoDB/anywidget-graph
@@ -326,6 +326,7 @@ graph.save_html("graph.html") # Write HTML to file
326
326
  - [anywidget](https://anywidget.dev/), custom Jupyter widgets made easy
327
327
  - [Grafeo](https://github.com/GrafeoDB/grafeo), embeddable graph database
328
328
  - [grafeo-web](https://github.com/GrafeoDB/grafeo-web), Grafeo in the browser
329
+ - [Playground](https://grafeo.ai), interactive browser playground for Grafeo
329
330
 
330
331
  ## License
331
332
 
@@ -285,6 +285,7 @@ graph.save_html("graph.html") # Write HTML to file
285
285
  - [anywidget](https://anywidget.dev/), custom Jupyter widgets made easy
286
286
  - [Grafeo](https://github.com/GrafeoDB/grafeo), embeddable graph database
287
287
  - [grafeo-web](https://github.com/GrafeoDB/grafeo-web), Grafeo in the browser
288
+ - [Playground](https://grafeo.ai), interactive browser playground for Grafeo
288
289
 
289
290
  ## License
290
291
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "anywidget-graph"
3
- version = "0.2.6"
3
+ version = "0.2.8"
4
4
  description = "Interactive graph visualization for Python notebooks using anywidget"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -3,4 +3,4 @@
3
3
  from anywidget_graph.widget import Graph
4
4
 
5
5
  __all__ = ["Graph"]
6
- __version__ = "0.2.5"
6
+ __version__ = "0.2.8"
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import TYPE_CHECKING, Any
5
+ from typing import TYPE_CHECKING, Any, ClassVar
6
6
 
7
7
  from anywidget_graph.converters import (
8
8
  get_node_id,
@@ -19,6 +19,14 @@ if TYPE_CHECKING:
19
19
  class GrafeoBackend:
20
20
  """Backend for Grafeo database connections."""
21
21
 
22
+ _LANGUAGE_METHODS: ClassVar[dict[str, str]] = {
23
+ "cypher": "execute_cypher",
24
+ "gremlin": "execute_gremlin",
25
+ "graphql": "execute_graphql",
26
+ "sparql": "execute_sparql",
27
+ "sql": "execute_sql",
28
+ }
29
+
22
30
  def __init__(self, db: Any) -> None:
23
31
  """Initialize with a GrafeoDB instance."""
24
32
  self._db = db
@@ -30,7 +38,11 @@ class GrafeoBackend:
30
38
 
31
39
  def execute(self, query: str, *, language: str = "cypher") -> tuple[list[dict], list[dict]]:
32
40
  """Execute a query in the specified language and return (nodes, edges)."""
33
- result = self._db.execute(query)
41
+ method_name = self._LANGUAGE_METHODS.get(language)
42
+ if method_name and hasattr(self._db, method_name):
43
+ result = getattr(self._db, method_name)(query)
44
+ else:
45
+ result = self._db.execute(query)
34
46
  return self._process_result(result)
35
47
 
36
48
  def fetch_schema(self) -> tuple[list[dict], list[dict]]:
@@ -61,7 +73,14 @@ class GrafeoBackend:
61
73
  for record in records:
62
74
  items = self._extract_items(record)
63
75
  for _key, value in items:
64
- if is_node(value):
76
+ if self._is_grafeo_node(value):
77
+ node_dict = self._grafeo_node_to_dict(value)
78
+ node_id = node_dict["id"]
79
+ if node_id not in nodes:
80
+ nodes[node_id] = node_dict
81
+ elif self._is_grafeo_relationship(value):
82
+ edges.append(self._grafeo_relationship_to_dict(value))
83
+ elif is_node(value):
65
84
  node_id = get_node_id(value)
66
85
  if node_id not in nodes:
67
86
  nodes[node_id] = node_to_dict(value)
@@ -70,6 +89,45 @@ class GrafeoBackend:
70
89
 
71
90
  return list(nodes.values()), edges
72
91
 
92
+ @staticmethod
93
+ def _is_grafeo_node(obj: Any) -> bool:
94
+ """Check if a value is a Grafeo node dict (has ``_id`` and ``_labels``)."""
95
+ return isinstance(obj, dict) and "_id" in obj and "_labels" in obj
96
+
97
+ @staticmethod
98
+ def _is_grafeo_relationship(obj: Any) -> bool:
99
+ """Check if a value is a Grafeo relationship dict (has ``_source``, ``_target``, ``_type``)."""
100
+ return isinstance(obj, dict) and "_source" in obj and "_target" in obj and "_type" in obj
101
+
102
+ @staticmethod
103
+ def _grafeo_node_to_dict(node: dict) -> dict:
104
+ """Convert a Grafeo node dict to the widget node format."""
105
+ node_id = str(node["_id"])
106
+ labels = node.get("_labels", [])
107
+ result: dict = {"id": node_id}
108
+ if labels:
109
+ result["label"] = labels[0]
110
+ result["labels"] = list(labels)
111
+ for key, value in node.items():
112
+ if not key.startswith("_"):
113
+ result[key] = value
114
+ if "label" not in result and "name" in result:
115
+ result["label"] = result["name"]
116
+ return result
117
+
118
+ @staticmethod
119
+ def _grafeo_relationship_to_dict(rel: dict) -> dict:
120
+ """Convert a Grafeo relationship dict to the widget edge format."""
121
+ result: dict = {
122
+ "source": str(rel["_source"]),
123
+ "target": str(rel["_target"]),
124
+ "label": str(rel["_type"]),
125
+ }
126
+ for key, value in rel.items():
127
+ if not key.startswith("_"):
128
+ result[key] = value
129
+ return result
130
+
73
131
  def _extract_items(self, record: Any) -> list[tuple[str, Any]]:
74
132
  """Extract key-value items from a record."""
75
133
  if hasattr(record, "items"):
@@ -140,6 +140,7 @@ def get_esm() -> str:
140
140
  schema_js = _read_file(_UI_DIR / "schema.js")
141
141
  settings_js = _read_file(_UI_DIR / "settings.js")
142
142
  properties_js = _read_file(_UI_DIR / "properties.js")
143
+ results_js = _read_file(_UI_DIR / "results.js")
143
144
  toolbar_js = _read_file(_UI_DIR / "toolbar.js")
144
145
  index_js = _read_file(_UI_DIR / "index.js")
145
146
 
@@ -173,6 +174,9 @@ import neo4j from "https://cdn.jsdelivr.net/npm/neo4j-driver@5.28.0/lib/browser/
173
174
  // === Properties Panel ===
174
175
  {_strip_imports_exports(properties_js)}
175
176
 
177
+ // === Results Drawer ===
178
+ {_strip_imports_exports(results_js)}
179
+
176
180
  // === Toolbar ===
177
181
  {_resolve_namespaces(toolbar_js)}
178
182
 
@@ -15,4 +15,7 @@ export const ICONS = {
15
15
  zoomIn: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>`,
16
16
  zoomOut: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/></svg>`,
17
17
  zoomFit: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></svg>`,
18
+ search: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>`,
19
+ chevronUp: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 15l-6-6-6 6"/></svg>`,
20
+ table: `<svg width="14" height="14" 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="M3 9h18M3 15h18M9 3v18"/></svg>`,
18
21
  };
@@ -9,6 +9,7 @@ import { createToolbar } from "./toolbar.js";
9
9
  import { createSchemaPanel } from "./schema.js";
10
10
  import { createSettingsPanel } from "./settings.js";
11
11
  import { createPropertiesPanel } from "./properties.js";
12
+ import { createResultsDrawer } from "./results.js";
12
13
  import * as neo4jBackend from "./neo4j.js";
13
14
  import * as grafeoBackend from "./grafeo.js";
14
15
  import * as grafeoEmbedBackend from "./grafeo-embed.js";
@@ -168,6 +169,8 @@ async function executeQuery(model) {
168
169
  return;
169
170
  }
170
171
 
172
+ const start = performance.now();
173
+
171
174
  if (backend === "neo4j") {
172
175
  // Browser-side Neo4j driver
173
176
  const result = await neo4jBackend.executeQuery(
@@ -176,6 +179,7 @@ async function executeQuery(model) {
176
179
  model
177
180
  );
178
181
  if (result) {
182
+ model.set("query_time", performance.now() - start);
179
183
  model.set("nodes", result.nodes);
180
184
  model.set("edges", result.edges);
181
185
  model.save_changes();
@@ -189,6 +193,7 @@ async function executeQuery(model) {
189
193
  model
190
194
  );
191
195
  if (result) {
196
+ model.set("query_time", performance.now() - start);
192
197
  model.set("nodes", result.nodes);
193
198
  model.set("edges", result.edges);
194
199
  model.save_changes();
@@ -197,12 +202,14 @@ async function executeQuery(model) {
197
202
  // Browser-side Grafeo WASM
198
203
  const result = await grafeoEmbedBackend.executeQuery(query, language, model);
199
204
  if (result) {
205
+ model.set("query_time", performance.now() - start);
200
206
  model.set("nodes", result.nodes);
201
207
  model.set("edges", result.edges);
202
208
  model.save_changes();
203
209
  }
204
210
  } else {
205
211
  // Python-side backends (grafeo-embedded, ladybug, arango, cosmosdb)
212
+ // Timing is handled on the Python side
206
213
  model.set("_execute_query", model.get("_execute_query") + 1);
207
214
  model.save_changes();
208
215
  }
@@ -236,9 +243,28 @@ function render({ model, el }) {
236
243
  // Panel references for mutual exclusion
237
244
  const panels = { schema, settings, properties };
238
245
 
246
+ // Search state (shared with nodeReducer)
247
+ let searchTerm = "";
248
+ let searchMatches = new Set();
249
+
250
+ function onSearch(term) {
251
+ searchTerm = (term || "").toLowerCase().trim();
252
+ searchMatches.clear();
253
+ if (searchTerm) {
254
+ graph.forEachNode((id, attrs) => {
255
+ const label = (attrs.label || "").toLowerCase();
256
+ const nodeId = String(id).toLowerCase();
257
+ if (label.includes(searchTerm) || nodeId.includes(searchTerm)) {
258
+ searchMatches.add(id);
259
+ }
260
+ });
261
+ }
262
+ renderer.refresh();
263
+ }
264
+
239
265
  // Create toolbar if enabled
240
266
  if (model.get("show_toolbar")) {
241
- const toolbar = createToolbar(model, onExecuteQuery, panels);
267
+ const toolbar = createToolbar(model, onExecuteQuery, panels, onSearch);
242
268
  wrapper.appendChild(toolbar);
243
269
  }
244
270
 
@@ -264,6 +290,11 @@ function render({ model, el }) {
264
290
  }
265
291
 
266
292
  wrapper.appendChild(content);
293
+
294
+ // Results drawer (bottom, hidden by default with lip toggle)
295
+ const results = createResultsDrawer(model);
296
+ wrapper.appendChild(results.element);
297
+
267
298
  el.appendChild(wrapper);
268
299
 
269
300
  // Initialize Graphology graph
@@ -307,6 +338,52 @@ function render({ model, el }) {
307
338
  case "random":
308
339
  random.assign(graph);
309
340
  break;
341
+ case "cluster": {
342
+ // Group nodes by first label, arrange clusters in a circle
343
+ const nodes = model.get("nodes") || [];
344
+ const labelGroups = new Map();
345
+ nodes.forEach((node) => {
346
+ const label = (node.labels && node.labels[0]) || node.label || "__other";
347
+ if (!labelGroups.has(label)) labelGroups.set(label, []);
348
+ labelGroups.get(label).push(node.id);
349
+ });
350
+
351
+ const clusterLabels = [...labelGroups.entries()].filter(([, g]) => g.length >= 2);
352
+ const useClusters = clusterLabels.length >= 2;
353
+
354
+ if (!useClusters) {
355
+ // Not enough clusters, fall back to force
356
+ applyLayout("force");
357
+ return;
358
+ }
359
+
360
+ // Collect all labels (clusters with 2+ nodes first, then singles)
361
+ const singleNodes = [...labelGroups.entries()]
362
+ .filter(([, g]) => g.length < 2)
363
+ .flatMap(([, g]) => g);
364
+ const allGroups = [...clusterLabels];
365
+ if (singleNodes.length > 0) {
366
+ allGroups.push(["__other", singleNodes]);
367
+ }
368
+
369
+ const cx = 50, cy = 50;
370
+ const clusterRadius = 40;
371
+
372
+ allGroups.forEach(([, nodeIds], clusterIdx) => {
373
+ const clusterAngle = (2 * Math.PI * clusterIdx) / allGroups.length;
374
+ const clusterCx = cx + clusterRadius * Math.cos(clusterAngle);
375
+ const clusterCy = cy + clusterRadius * Math.sin(clusterAngle);
376
+ const innerRadius = Math.max(5, Math.min(20, nodeIds.length * 2));
377
+
378
+ nodeIds.forEach((nodeId, nodeIdx) => {
379
+ if (!graph.hasNode(nodeId)) return;
380
+ const innerAngle = (2 * Math.PI * nodeIdx) / nodeIds.length;
381
+ graph.setNodeAttribute(nodeId, "x", clusterCx + innerRadius * Math.cos(innerAngle));
382
+ graph.setNodeAttribute(nodeId, "y", clusterCy + innerRadius * Math.sin(innerAngle));
383
+ });
384
+ });
385
+ break;
386
+ }
310
387
  case "force": {
311
388
  const n = graph.order;
312
389
  // Scale layout parameters to graph size for good spreading
@@ -351,7 +428,7 @@ function render({ model, el }) {
351
428
  labelWeight: "500",
352
429
  });
353
430
 
354
- // Node reducer for selection highlighting + pinned indicator
431
+ // Node reducer for selection highlighting, search filtering, and pinned indicator
355
432
  renderer.setSetting("nodeReducer", (node, data) => {
356
433
  const selectedNodes = model.get("selected_nodes") || [];
357
434
  const pinnedNodes = model.get("pinned_nodes") || {};
@@ -363,6 +440,12 @@ function render({ model, el }) {
363
440
  res.label = "";
364
441
  }
365
442
 
443
+ // Dim non-matching nodes when search is active
444
+ if (searchTerm && !searchMatches.has(node)) {
445
+ res.color = data.color + "20";
446
+ res.label = "";
447
+ }
448
+
366
449
  // Show ring on pinned nodes
367
450
  if (pinnedNodes[node]) {
368
451
  res.borderColor = "#f59e0b";
@@ -444,7 +527,7 @@ function render({ model, el }) {
444
527
  const layoutSelect = document.createElement("select");
445
528
  layoutSelect.className = "awg-layout-select";
446
529
  layoutSelect.title = "Layout algorithm";
447
- [["force", "Force"], ["circular", "Circular"], ["random", "Random"]].forEach(([val, text]) => {
530
+ [["force", "Force"], ["cluster", "Cluster"], ["circular", "Circular"], ["random", "Random"]].forEach(([val, text]) => {
448
531
  const opt = document.createElement("option");
449
532
  opt.value = val;
450
533
  opt.textContent = text;
@@ -676,6 +759,41 @@ function render({ model, el }) {
676
759
  model.save_changes();
677
760
  });
678
761
 
762
+ // Keyboard shortcuts
763
+ wrapper.tabIndex = 0;
764
+ wrapper.style.outline = "none";
765
+ wrapper.addEventListener("keydown", (e) => {
766
+ const tag = e.target.tagName;
767
+ if (tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") return;
768
+
769
+ if (e.key === "f" || e.key === "F") {
770
+ e.preventDefault();
771
+ renderer.getCamera().animatedReset({ duration: 200 });
772
+ } else if (e.key === "Escape") {
773
+ model.set("selected_node", null);
774
+ model.set("selected_edge", null);
775
+ model.set("selected_nodes", []);
776
+ model.set("selected_edges", []);
777
+ model.save_changes();
778
+ } else if (e.key === "Delete" || e.key === "Backspace") {
779
+ const selected = model.get("selected_nodes") || [];
780
+ if (selected.length === 0) return;
781
+ e.preventDefault();
782
+ const removeSet = new Set(selected);
783
+ const nodes = (model.get("nodes") || []).filter(n => !removeSet.has(n.id));
784
+ const edges = (model.get("edges") || []).filter(
785
+ edge => !removeSet.has(edge.source) && !removeSet.has(edge.target)
786
+ );
787
+ model.set("nodes", nodes);
788
+ model.set("edges", edges);
789
+ model.set("selected_node", null);
790
+ model.set("selected_edge", null);
791
+ model.set("selected_nodes", []);
792
+ model.set("selected_edges", []);
793
+ model.save_changes();
794
+ }
795
+ });
796
+
679
797
  // Double-click node: unpin if pinned, otherwise expand (fetch neighbors)
680
798
  renderer.on("doubleClickNode", ({ node }) => {
681
799
  const pinned = { ...(model.get("pinned_nodes") || {}) };
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Results drawer component with collapsible lip toggle.
3
+ */
4
+ import { ICONS } from "./icons.js";
5
+
6
+ /**
7
+ * Create the results drawer (bottom panel with lip toggle).
8
+ */
9
+ export function createResultsDrawer(model) {
10
+ const drawer = document.createElement("div");
11
+ drawer.className = "awg-results-drawer";
12
+
13
+ // Lip (always visible, toggles the drawer)
14
+ const lip = document.createElement("div");
15
+ lip.className = "awg-results-lip";
16
+
17
+ const lipIcon = document.createElement("span");
18
+ lipIcon.className = "awg-results-lip-icon";
19
+ lipIcon.innerHTML = ICONS.chevronUp;
20
+
21
+ const lipLabel = document.createElement("span");
22
+ lipLabel.className = "awg-results-lip-label";
23
+ lipLabel.innerHTML = `${ICONS.table} Results`;
24
+
25
+ const lipTiming = document.createElement("span");
26
+ lipTiming.className = "awg-results-lip-timing";
27
+
28
+ function updateLipTiming() {
29
+ const ms = model.get("query_time") || 0;
30
+ const nodes = model.get("nodes") || [];
31
+ const edges = model.get("edges") || [];
32
+ const parts = [];
33
+ if (nodes.length > 0 || edges.length > 0) {
34
+ parts.push(`${nodes.length}n / ${edges.length}e`);
35
+ }
36
+ if (ms > 0) {
37
+ parts.push(ms < 1 ? "<1 ms" : `${ms.toFixed(1)} ms`);
38
+ }
39
+ lipTiming.textContent = parts.join(" \u00b7 ");
40
+ }
41
+ updateLipTiming();
42
+ model.on("change:query_time", updateLipTiming);
43
+ model.on("change:nodes", updateLipTiming);
44
+ model.on("change:edges", updateLipTiming);
45
+
46
+ lip.appendChild(lipLabel);
47
+ lip.appendChild(lipTiming);
48
+ lip.appendChild(lipIcon);
49
+
50
+ // Body (collapsible)
51
+ const body = document.createElement("div");
52
+ body.className = "awg-results-body";
53
+
54
+ // Tab bar
55
+ const tabBar = document.createElement("div");
56
+ tabBar.className = "awg-results-tabs";
57
+
58
+ const nodesTab = document.createElement("button");
59
+ nodesTab.className = "awg-results-tab awg-results-tab-active";
60
+ nodesTab.textContent = "Nodes";
61
+
62
+ const edgesTab = document.createElement("button");
63
+ edgesTab.className = "awg-results-tab";
64
+ edgesTab.textContent = "Edges";
65
+
66
+ tabBar.appendChild(nodesTab);
67
+ tabBar.appendChild(edgesTab);
68
+ body.appendChild(tabBar);
69
+
70
+ // Table container
71
+ const tableContainer = document.createElement("div");
72
+ tableContainer.className = "awg-results-table-wrap";
73
+ body.appendChild(tableContainer);
74
+
75
+ let activeTab = "nodes";
76
+
77
+ function setActiveTab(tab) {
78
+ activeTab = tab;
79
+ nodesTab.classList.toggle("awg-results-tab-active", tab === "nodes");
80
+ edgesTab.classList.toggle("awg-results-tab-active", tab === "edges");
81
+ renderTable();
82
+ }
83
+
84
+ nodesTab.addEventListener("click", () => setActiveTab("nodes"));
85
+ edgesTab.addEventListener("click", () => setActiveTab("edges"));
86
+
87
+ function renderTable() {
88
+ tableContainer.innerHTML = "";
89
+ const items = model.get(activeTab === "nodes" ? "nodes" : "edges") || [];
90
+
91
+ if (items.length === 0) {
92
+ const empty = document.createElement("div");
93
+ empty.className = "awg-results-empty";
94
+ empty.textContent = activeTab === "nodes" ? "No nodes" : "No edges";
95
+ tableContainer.appendChild(empty);
96
+ return;
97
+ }
98
+
99
+ // Collect all keys across items (skip internal rendering keys)
100
+ const skipKeys = new Set(["x", "y", "size", "color"]);
101
+ const keys = [];
102
+ const seen = new Set();
103
+ items.forEach((item) => {
104
+ Object.keys(item).forEach((k) => {
105
+ if (!seen.has(k) && !skipKeys.has(k)) {
106
+ seen.add(k);
107
+ keys.push(k);
108
+ }
109
+ });
110
+ });
111
+
112
+ const table = document.createElement("table");
113
+ table.className = "awg-results-table";
114
+
115
+ // Header
116
+ const thead = document.createElement("thead");
117
+ const headerRow = document.createElement("tr");
118
+ keys.forEach((k) => {
119
+ const th = document.createElement("th");
120
+ th.textContent = k;
121
+ headerRow.appendChild(th);
122
+ });
123
+ thead.appendChild(headerRow);
124
+ table.appendChild(thead);
125
+
126
+ // Body (limit to 100 rows for performance)
127
+ const tbody = document.createElement("tbody");
128
+ const limit = Math.min(items.length, 100);
129
+ for (let i = 0; i < limit; i++) {
130
+ const row = document.createElement("tr");
131
+ keys.forEach((k) => {
132
+ const td = document.createElement("td");
133
+ const val = items[i][k];
134
+ if (val === undefined || val === null) {
135
+ td.textContent = "";
136
+ } else if (typeof val === "object") {
137
+ td.textContent = JSON.stringify(val);
138
+ } else {
139
+ td.textContent = String(val);
140
+ }
141
+ row.appendChild(td);
142
+ });
143
+ tbody.appendChild(row);
144
+ }
145
+ table.appendChild(tbody);
146
+ tableContainer.appendChild(table);
147
+
148
+ if (items.length > 100) {
149
+ const more = document.createElement("div");
150
+ more.className = "awg-results-empty";
151
+ more.textContent = `Showing 100 of ${items.length} rows`;
152
+ tableContainer.appendChild(more);
153
+ }
154
+ }
155
+
156
+ model.on("change:nodes", () => { if (activeTab === "nodes") renderTable(); });
157
+ model.on("change:edges", () => { if (activeTab === "edges") renderTable(); });
158
+ renderTable();
159
+
160
+ drawer.appendChild(lip);
161
+ drawer.appendChild(body);
162
+
163
+ let isOpen = false;
164
+
165
+ function toggle() {
166
+ isOpen = !isOpen;
167
+ drawer.classList.toggle("awg-results-open", isOpen);
168
+ lipIcon.style.transform = isOpen ? "" : "rotate(180deg)";
169
+ }
170
+
171
+ lip.addEventListener("click", toggle);
172
+
173
+ // Start closed
174
+ lipIcon.style.transform = "rotate(180deg)";
175
+
176
+ return {
177
+ element: drawer,
178
+ open: () => { if (!isOpen) toggle(); },
179
+ close: () => { if (isOpen) toggle(); },
180
+ toggle,
181
+ isOpen: () => isOpen,
182
+ };
183
+ }
@@ -90,6 +90,53 @@
90
90
  color: var(--awg-text-muted);
91
91
  }
92
92
 
93
+ /* === SEARCH INPUT === */
94
+ .awg-search-wrap {
95
+ display: flex;
96
+ align-items: center;
97
+ position: relative;
98
+ flex-shrink: 0;
99
+ }
100
+
101
+ .awg-search-icon {
102
+ position: absolute;
103
+ left: 8px;
104
+ color: var(--awg-text-muted);
105
+ display: flex;
106
+ pointer-events: none;
107
+ }
108
+
109
+ .awg-search-input {
110
+ width: 140px;
111
+ padding: 6px 8px 6px 28px;
112
+ border: 1px solid var(--awg-input-border);
113
+ border-radius: 6px;
114
+ font-size: 12px;
115
+ background: var(--awg-input-bg);
116
+ color: var(--awg-text);
117
+ transition: border-color 0.15s, box-shadow 0.15s, width 0.2s;
118
+ }
119
+
120
+ .awg-search-input:focus {
121
+ outline: none;
122
+ border-color: var(--awg-accent);
123
+ box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.15);
124
+ width: 180px;
125
+ }
126
+
127
+ .awg-search-input::placeholder {
128
+ color: var(--awg-text-muted);
129
+ }
130
+
131
+ /* === COUNT BADGE === */
132
+ .awg-count-badge {
133
+ font-size: 11px;
134
+ color: var(--awg-text-muted);
135
+ white-space: nowrap;
136
+ flex-shrink: 0;
137
+ font-variant-numeric: tabular-nums;
138
+ }
139
+
93
140
  /* === BUTTONS === */
94
141
  .awg-btn {
95
142
  display: inline-flex;
@@ -218,7 +265,7 @@
218
265
  display: flex;
219
266
  flex-direction: column;
220
267
  gap: 2px;
221
- z-index: 10;
268
+ z-index: 25;
222
269
  }
223
270
 
224
271
  .awg-zoom-btn {
@@ -774,6 +821,139 @@
774
821
  }
775
822
  }
776
823
 
824
+ /* === TIMING BADGE === */
825
+ .awg-timing-badge {
826
+ color: var(--awg-accent);
827
+ font-size: 11px;
828
+ }
829
+
830
+ /* === RESULTS DRAWER === */
831
+ .awg-results-drawer {
832
+ border-top: 1px solid var(--awg-border);
833
+ background: var(--awg-bg-secondary);
834
+ }
835
+
836
+ .awg-results-lip {
837
+ display: flex;
838
+ align-items: center;
839
+ justify-content: center;
840
+ gap: 8px;
841
+ height: 28px;
842
+ cursor: pointer;
843
+ font-size: 11px;
844
+ color: var(--awg-text-secondary);
845
+ user-select: none;
846
+ transition: background 0.15s;
847
+ }
848
+
849
+ .awg-results-lip:hover {
850
+ background: var(--awg-bg-tertiary);
851
+ }
852
+
853
+ .awg-results-lip-icon {
854
+ display: flex;
855
+ transition: transform 0.2s;
856
+ }
857
+
858
+ .awg-results-lip-label {
859
+ display: flex;
860
+ align-items: center;
861
+ gap: 4px;
862
+ font-weight: 500;
863
+ text-transform: uppercase;
864
+ letter-spacing: 0.3px;
865
+ }
866
+
867
+ .awg-results-lip-timing {
868
+ font-variant-numeric: tabular-nums;
869
+ color: var(--awg-text-muted);
870
+ }
871
+
872
+ .awg-results-body {
873
+ max-height: 0;
874
+ overflow: hidden;
875
+ transition: max-height 0.25s ease;
876
+ }
877
+
878
+ .awg-results-open .awg-results-body {
879
+ max-height: 220px;
880
+ overflow-y: auto;
881
+ }
882
+
883
+ .awg-results-tabs {
884
+ display: flex;
885
+ gap: 0;
886
+ border-bottom: 1px solid var(--awg-border);
887
+ padding: 0 12px;
888
+ }
889
+
890
+ .awg-results-tab {
891
+ padding: 6px 14px;
892
+ border: none;
893
+ background: none;
894
+ font-size: 12px;
895
+ font-weight: 500;
896
+ color: var(--awg-text-muted);
897
+ cursor: pointer;
898
+ border-bottom: 2px solid transparent;
899
+ transition: color 0.15s, border-color 0.15s;
900
+ }
901
+
902
+ .awg-results-tab:hover {
903
+ color: var(--awg-text);
904
+ }
905
+
906
+ .awg-results-tab-active {
907
+ color: var(--awg-accent);
908
+ border-bottom-color: var(--awg-accent);
909
+ }
910
+
911
+ .awg-results-table-wrap {
912
+ overflow-x: auto;
913
+ }
914
+
915
+ .awg-results-table {
916
+ width: 100%;
917
+ border-collapse: collapse;
918
+ font-size: 12px;
919
+ }
920
+
921
+ .awg-results-table th {
922
+ position: sticky;
923
+ top: 0;
924
+ background: var(--awg-bg-tertiary);
925
+ padding: 6px 10px;
926
+ text-align: left;
927
+ font-weight: 600;
928
+ font-size: 10px;
929
+ text-transform: uppercase;
930
+ letter-spacing: 0.3px;
931
+ color: var(--awg-text-secondary);
932
+ white-space: nowrap;
933
+ border-bottom: 1px solid var(--awg-border);
934
+ }
935
+
936
+ .awg-results-table td {
937
+ padding: 5px 10px;
938
+ color: var(--awg-text);
939
+ border-bottom: 1px solid var(--awg-border);
940
+ max-width: 200px;
941
+ overflow: hidden;
942
+ text-overflow: ellipsis;
943
+ white-space: nowrap;
944
+ }
945
+
946
+ .awg-results-table tbody tr:hover {
947
+ background: var(--awg-bg-tertiary);
948
+ }
949
+
950
+ .awg-results-empty {
951
+ padding: 16px;
952
+ text-align: center;
953
+ color: var(--awg-text-muted);
954
+ font-size: 12px;
955
+ }
956
+
777
957
  /* === Touch-Friendly Targets === */
778
958
  @media (pointer: coarse) {
779
959
  .awg-btn {
@@ -7,8 +7,9 @@ import { updateStatusDot } from "./settings.js";
7
7
  /**
8
8
  * Create the toolbar component.
9
9
  * @param {object} panels - { schema, settings, properties } panel objects with toggle/close/isOpen
10
+ * @param {function} onSearch - callback(term) for search filtering
10
11
  */
11
- export function createToolbar(model, onExecuteQuery, panels) {
12
+ export function createToolbar(model, onExecuteQuery, panels, onSearch) {
12
13
  const toolbar = document.createElement("div");
13
14
  toolbar.className = "awg-toolbar";
14
15
 
@@ -71,6 +72,53 @@ export function createToolbar(model, onExecuteQuery, panels) {
71
72
  toolbar.appendChild(queryContainer);
72
73
  }
73
74
 
75
+ // Search input (compact filter for nodes)
76
+ const searchWrap = document.createElement("div");
77
+ searchWrap.className = "awg-search-wrap";
78
+ const searchIcon = document.createElement("span");
79
+ searchIcon.className = "awg-search-icon";
80
+ searchIcon.innerHTML = ICONS.search;
81
+ searchWrap.appendChild(searchIcon);
82
+
83
+ const searchInput = document.createElement("input");
84
+ searchInput.type = "text";
85
+ searchInput.className = "awg-search-input";
86
+ searchInput.placeholder = "Filter nodes...";
87
+ searchInput.addEventListener("input", () => {
88
+ onSearch?.(searchInput.value);
89
+ });
90
+ searchWrap.appendChild(searchInput);
91
+ toolbar.appendChild(searchWrap);
92
+
93
+ // Count badge
94
+ const countBadge = document.createElement("span");
95
+ countBadge.className = "awg-count-badge";
96
+ function updateCount() {
97
+ const nodes = model.get("nodes") || [];
98
+ const edges = model.get("edges") || [];
99
+ countBadge.textContent = `${nodes.length} nodes \u00b7 ${edges.length} edges`;
100
+ }
101
+ updateCount();
102
+ model.on("change:nodes", updateCount);
103
+ model.on("change:edges", updateCount);
104
+ toolbar.appendChild(countBadge);
105
+
106
+ // Query timing badge
107
+ const timingBadge = document.createElement("span");
108
+ timingBadge.className = "awg-count-badge awg-timing-badge";
109
+ function updateTiming() {
110
+ const ms = model.get("query_time") || 0;
111
+ if (ms > 0) {
112
+ timingBadge.textContent = ms < 1 ? "<1 ms" : `${ms.toFixed(1)} ms`;
113
+ timingBadge.style.display = "";
114
+ } else {
115
+ timingBadge.style.display = "none";
116
+ }
117
+ }
118
+ updateTiming();
119
+ model.on("change:query_time", updateTiming);
120
+ toolbar.appendChild(timingBadge);
121
+
74
122
  // Settings button (mutual exclusion with properties)
75
123
  if (model.get("show_settings")) {
76
124
  const settingsBtn = document.createElement("button");
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import time
6
7
  from typing import TYPE_CHECKING, Any
7
8
 
8
9
  import anywidget
@@ -115,6 +116,7 @@ class Graph(anywidget.AnyWidget):
115
116
  query = traitlets.Unicode(default_value="").tag(sync=True)
116
117
  query_language = traitlets.Unicode(default_value="cypher").tag(sync=True)
117
118
  query_running = traitlets.Bool(default_value=False).tag(sync=True)
119
+ query_time = traitlets.Float(default_value=0.0).tag(sync=True)
118
120
  query_error = traitlets.Unicode(default_value="").tag(sync=True)
119
121
 
120
122
  # === Connection State ===
@@ -271,7 +273,9 @@ class Graph(anywidget.AnyWidget):
271
273
  try:
272
274
  self.query_running = True
273
275
  self.query_error = ""
276
+ start = time.perf_counter()
274
277
  nodes, edges = self._backend.execute(self.query, language=self.query_language)
278
+ self.query_time = (time.perf_counter() - start) * 1000
275
279
  self.nodes = nodes
276
280
  self.edges = edges
277
281
  except Exception as e:
@@ -55,6 +55,7 @@ def test_default_options():
55
55
  assert graph.selection_mode == "click"
56
56
  assert graph.database_backend == "grafeo"
57
57
  assert graph.max_nodes == 300
58
+ assert graph.query_time == 0.0
58
59
 
59
60
 
60
61
  def test_custom_options():
@@ -587,3 +588,150 @@ def test_build_neighbor_query_aql():
587
588
  q = Graph._build_neighbor_query("doc/1", "aql")
588
589
  assert "doc/1" in q
589
590
  assert "FOR" in q
591
+
592
+
593
+ # ------------------------------------------------------------------ #
594
+ # Grafeo backend: language dispatch #
595
+ # ------------------------------------------------------------------ #
596
+
597
+
598
+ def test_grafeo_backend_language_dispatch():
599
+ """execute() uses the language-specific method when available."""
600
+ from anywidget_graph.backends.grafeo import GrafeoBackend
601
+
602
+ calls: list[tuple[str, str]] = []
603
+
604
+ class FakeDB:
605
+ def execute_cypher(self, query):
606
+ calls.append(("cypher", query))
607
+ return []
608
+
609
+ def execute(self, query):
610
+ calls.append(("generic", query))
611
+ return []
612
+
613
+ backend = GrafeoBackend(FakeDB())
614
+ backend.execute("MATCH (n) RETURN n", language="cypher")
615
+ assert calls[-1] == ("cypher", "MATCH (n) RETURN n")
616
+
617
+
618
+ def test_grafeo_backend_language_fallback():
619
+ """execute() falls back to generic execute for unknown languages."""
620
+ from anywidget_graph.backends.grafeo import GrafeoBackend
621
+
622
+ calls: list[tuple[str, str]] = []
623
+
624
+ class FakeDB:
625
+ def execute(self, query):
626
+ calls.append(("generic", query))
627
+ return []
628
+
629
+ backend = GrafeoBackend(FakeDB())
630
+ backend.execute("SELECT 1", language="sql")
631
+ assert calls[-1] == ("generic", "SELECT 1")
632
+
633
+
634
+ # ------------------------------------------------------------------ #
635
+ # Grafeo backend: native dict processing #
636
+ # ------------------------------------------------------------------ #
637
+
638
+
639
+ def test_grafeo_node_detection():
640
+ """_is_grafeo_node detects dicts with _id and _labels."""
641
+ from anywidget_graph.backends.grafeo import GrafeoBackend
642
+
643
+ assert GrafeoBackend._is_grafeo_node({"_id": 1, "_labels": ["Person"]})
644
+ assert not GrafeoBackend._is_grafeo_node({"id": 1, "label": "Person"})
645
+ assert not GrafeoBackend._is_grafeo_node({"_id": 1})
646
+ assert not GrafeoBackend._is_grafeo_node("not a dict")
647
+
648
+
649
+ def test_grafeo_relationship_detection():
650
+ """_is_grafeo_relationship detects dicts with _source, _target, _type."""
651
+ from anywidget_graph.backends.grafeo import GrafeoBackend
652
+
653
+ assert GrafeoBackend._is_grafeo_relationship({"_source": 1, "_target": 2, "_type": "KNOWS"})
654
+ assert not GrafeoBackend._is_grafeo_relationship({"source": 1, "target": 2, "label": "KNOWS"})
655
+ assert not GrafeoBackend._is_grafeo_relationship({"_source": 1, "_target": 2})
656
+
657
+
658
+ def test_grafeo_node_to_dict():
659
+ """_grafeo_node_to_dict converts Grafeo node format to widget format."""
660
+ from anywidget_graph.backends.grafeo import GrafeoBackend
661
+
662
+ node = {"_id": 42, "_labels": ["Person", "Employee"], "name": "Alice", "age": 30}
663
+ result = GrafeoBackend._grafeo_node_to_dict(node)
664
+
665
+ assert result["id"] == "42"
666
+ assert result["label"] == "Person"
667
+ assert result["labels"] == ["Person", "Employee"]
668
+ assert result["name"] == "Alice"
669
+ assert result["age"] == 30
670
+ assert "_id" not in result
671
+ assert "_labels" not in result
672
+
673
+
674
+ def test_grafeo_node_to_dict_name_fallback():
675
+ """_grafeo_node_to_dict uses name as label when no _labels."""
676
+ from anywidget_graph.backends.grafeo import GrafeoBackend
677
+
678
+ node = {"_id": 1, "_labels": [], "name": "Bob"}
679
+ result = GrafeoBackend._grafeo_node_to_dict(node)
680
+ assert result["label"] == "Bob"
681
+
682
+
683
+ def test_grafeo_relationship_to_dict():
684
+ """_grafeo_relationship_to_dict converts Grafeo edge format to widget format."""
685
+ from anywidget_graph.backends.grafeo import GrafeoBackend
686
+
687
+ rel = {"_source": 1, "_target": 2, "_type": "KNOWS", "since": 2020}
688
+ result = GrafeoBackend._grafeo_relationship_to_dict(rel)
689
+
690
+ assert result["source"] == "1"
691
+ assert result["target"] == "2"
692
+ assert result["label"] == "KNOWS"
693
+ assert result["since"] == 2020
694
+ assert "_source" not in result
695
+ assert "_type" not in result
696
+
697
+
698
+ def test_grafeo_backend_process_native_dicts():
699
+ """Full round-trip: backend processes Grafeo native dict results."""
700
+ from anywidget_graph.backends.grafeo import GrafeoBackend
701
+
702
+ class FakeDB:
703
+ def execute(self, query):
704
+ return [
705
+ {
706
+ "n": {"_id": 1, "_labels": ["Person"], "name": "Alice"},
707
+ "r": {"_source": 1, "_target": 2, "_type": "KNOWS", "since": 2020},
708
+ "m": {"_id": 2, "_labels": ["Person"], "name": "Bob"},
709
+ },
710
+ ]
711
+
712
+ backend = GrafeoBackend(FakeDB())
713
+ nodes, edges = backend.execute("MATCH (n)-[r]->(m) RETURN n, r, m")
714
+
715
+ assert len(nodes) == 2
716
+ assert len(edges) == 1
717
+
718
+ node_ids = {n["id"] for n in nodes}
719
+ assert node_ids == {"1", "2"}
720
+
721
+ assert edges[0]["source"] == "1"
722
+ assert edges[0]["target"] == "2"
723
+ assert edges[0]["label"] == "KNOWS"
724
+ assert edges[0]["since"] == 2020
725
+
726
+
727
+ # ------------------------------------------------------------------ #
728
+ # Query timing #
729
+ # ------------------------------------------------------------------ #
730
+
731
+
732
+ def test_query_time_traitlet():
733
+ """query_time traitlet defaults to 0.0 and can be set."""
734
+ graph = Graph()
735
+ assert graph.query_time == 0.0
736
+ graph.query_time = 42.5
737
+ assert graph.query_time == 42.5
@@ -154,7 +154,7 @@ wheels = [
154
154
 
155
155
  [[package]]
156
156
  name = "anywidget-graph"
157
- version = "0.2.6"
157
+ version = "0.2.8"
158
158
  source = { editable = "." }
159
159
  dependencies = [
160
160
  { name = "anywidget" },
@@ -191,16 +191,16 @@ dev = [
191
191
  [package.metadata]
192
192
  requires-dist = [
193
193
  { name = "anywidget", specifier = ">=0.9.21" },
194
- { name = "grafeo", marker = "extra == 'grafeo'", specifier = ">=0.5.0" },
194
+ { name = "grafeo", marker = "extra == 'grafeo'", specifier = ">=0.5" },
195
195
  { name = "gremlinpython", marker = "extra == 'cosmosdb'", specifier = ">=3.7" },
196
196
  { name = "marimo", specifier = ">=0.19.9" },
197
- { name = "marimo", marker = "extra == 'dev'", specifier = ">=0.19.9" },
197
+ { name = "marimo", marker = "extra == 'dev'", specifier = ">=0.20" },
198
198
  { name = "networkx", marker = "extra == 'networkx'", specifier = ">=3.0" },
199
199
  { name = "pandas", marker = "extra == 'pandas'", specifier = ">=2.0" },
200
- { name = "prek", marker = "extra == 'dev'", specifier = ">=0.3.2" },
200
+ { name = "prek", marker = "extra == 'dev'", specifier = ">=0.3" },
201
201
  { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" },
202
- { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.0" },
203
- { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.16" },
202
+ { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15" },
203
+ { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.20" },
204
204
  ]
205
205
 
206
206
  [package.metadata.requires-dev]
@@ -614,7 +614,7 @@ wheels = [
614
614
 
615
615
  [[package]]
616
616
  name = "marimo"
617
- version = "0.19.10"
617
+ version = "0.20.4"
618
618
  source = { registry = "https://pypi.org/simple" }
619
619
  dependencies = [
620
620
  { name = "click" },
@@ -635,9 +635,9 @@ dependencies = [
635
635
  { name = "uvicorn" },
636
636
  { name = "websockets" },
637
637
  ]
638
- sdist = { url = "https://files.pythonhosted.org/packages/2b/1f/da120728d93960e7db35840800af9216cd300ef02fa2831b085186614c82/marimo-0.19.10.tar.gz", hash = "sha256:a4fad325b8c5e7aef77c21d62764c20899c352dfa1afbc44c30760ae081d2b27", size = 38202668 }
638
+ sdist = { url = "https://files.pythonhosted.org/packages/9c/eb/3c19ce54be3440cece96bffbb2027c57d618ab336d53ef6a962f570ce468/marimo-0.20.4.tar.gz", hash = "sha256:7f46ce837953717504673e33e47fb42a619bf5f9d2000d1a3a3b1663a47c5498", size = 38336283 }
639
639
  wheels = [
640
- { url = "https://files.pythonhosted.org/packages/f5/cd/b1c382afc8f00c05bb52202fdfdd8db2ffbd0c13a96e6d1eb93fd5d72516/marimo-0.19.10-py3-none-any.whl", hash = "sha256:c87541222cbf34ab864e91c88d72d6a8abf3a6db8a9bc93968e21bd58390a944", size = 38608642 },
640
+ { url = "https://files.pythonhosted.org/packages/56/62/bf2c92cc7aab69edfb3907e9bf102c37c1e16b8f4bc6ddd4258ae4c8063c/marimo-0.20.4-py3-none-any.whl", hash = "sha256:3200edd7209f470821b58deb4a45e0577889fab1052748d77088f18bef7d9dd8", size = 38752562 },
641
641
  ]
642
642
 
643
643
  [[package]]
@@ -1381,26 +1381,26 @@ wheels = [
1381
1381
 
1382
1382
  [[package]]
1383
1383
  name = "ty"
1384
- version = "0.0.16"
1385
- source = { registry = "https://pypi.org/simple" }
1386
- sdist = { url = "https://files.pythonhosted.org/packages/ee/18/77f84d89db54ea0d1d1b09fa2f630ac4c240c8e270761cb908c06b6e735c/ty-0.0.16.tar.gz", hash = "sha256:a999b0db6aed7d6294d036ebe43301105681e0c821a19989be7c145805d7351c", size = 5129637 }
1387
- wheels = [
1388
- { url = "https://files.pythonhosted.org/packages/67/b9/909ebcc7f59eaf8a2c18fb54bfcf1c106f99afb3e5460058d4b46dec7b20/ty-0.0.16-py3-none-linux_armv6l.whl", hash = "sha256:6d8833b86396ed742f2b34028f51c0e98dbf010b13ae4b79d1126749dc9dab15", size = 10113870 },
1389
- { url = "https://files.pythonhosted.org/packages/c3/2c/b963204f3df2fdbf46a4a1ea4a060af9bb676e065d59c70ad0f5ae0dbae8/ty-0.0.16-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:934c0055d3b7f1cf3c8eab78c6c127ef7f347ff00443cef69614bda6f1502377", size = 9936286 },
1390
- { url = "https://files.pythonhosted.org/packages/ef/4d/3d78294f2ddfdded231e94453dea0e0adef212b2bd6536296039164c2a3e/ty-0.0.16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b55e8e8733b416d914003cd22e831e139f034681b05afed7e951cc1a5ea1b8d4", size = 9442660 },
1391
- { url = "https://files.pythonhosted.org/packages/15/40/ce48c0541e3b5749b0890725870769904e6b043e077d4710e5325d5cf807/ty-0.0.16-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:feccae8f4abd6657de111353bd604f36e164844466346eb81ffee2c2b06ea0f0", size = 9934506 },
1392
- { url = "https://files.pythonhosted.org/packages/84/16/3b29de57e1ec6e56f50a4bb625ee0923edb058c5f53e29014873573a00cd/ty-0.0.16-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1cad5e29d8765b92db5fa284940ac57149561f3f89470b363b9aab8a6ce553b0", size = 9933099 },
1393
- { url = "https://files.pythonhosted.org/packages/f7/a1/e546995c25563d318c502b2f42af0fdbed91e1fc343708241e2076373644/ty-0.0.16-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:86f28797c7dc06f081238270b533bf4fc8e93852f34df49fb660e0b58a5cda9a", size = 10438370 },
1394
- { url = "https://files.pythonhosted.org/packages/11/c1/22d301a4b2cce0f75ae84d07a495f87da193bcb68e096d43695a815c4708/ty-0.0.16-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be971a3b42bcae44d0e5787f88156ed2102ad07558c05a5ae4bfd32a99118e66", size = 10992160 },
1395
- { url = "https://files.pythonhosted.org/packages/6f/40/f1892b8c890db3f39a1bab8ec459b572de2df49e76d3cad2a9a239adcde9/ty-0.0.16-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3c9f982b7c4250eb91af66933f436b3a2363c24b6353e94992eab6551166c8b7", size = 10717892 },
1396
- { url = "https://files.pythonhosted.org/packages/2f/1b/caf9be8d0c738983845f503f2e92ea64b8d5fae1dd5ca98c3fca4aa7dadc/ty-0.0.16-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d122edf85ce7bdf6f85d19158c991d858fc835677bd31ca46319c4913043dc84", size = 10510916 },
1397
- { url = "https://files.pythonhosted.org/packages/60/ea/28980f5c7e1f4c9c44995811ea6a36f2fcb205232a6ae0f5b60b11504621/ty-0.0.16-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:497ebdddbb0e35c7758ded5aa4c6245e8696a69d531d5c9b0c1a28a075374241", size = 9908506 },
1398
- { url = "https://files.pythonhosted.org/packages/f7/80/8672306596349463c21644554f935ff8720679a14fd658fef658f66da944/ty-0.0.16-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e1e0ac0837bde634b030243aeba8499383c0487e08f22e80f5abdacb5b0bd8ce", size = 9949486 },
1399
- { url = "https://files.pythonhosted.org/packages/8b/8a/d8747d36f30bd82ea157835f5b70d084c9bb5d52dd9491dba8a149792d6a/ty-0.0.16-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1216c9bcca551d9f89f47a817ebc80e88ac37683d71504e5509a6445f24fd024", size = 10145269 },
1400
- { url = "https://files.pythonhosted.org/packages/6f/4c/753535acc7243570c259158b7df67e9c9dd7dab9a21ee110baa4cdcec45d/ty-0.0.16-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:221bbdd2c6ee558452c96916ab67fcc465b86967cf0482e19571d18f9c831828", size = 10608644 },
1401
- { url = "https://files.pythonhosted.org/packages/3e/05/8e8db64cf45a8b16757e907f7a3bfde8d6203e4769b11b64e28d5bdcd79a/ty-0.0.16-py3-none-win32.whl", hash = "sha256:d52c4eb786be878e7514cab637200af607216fcc5539a06d26573ea496b26512", size = 9582579 },
1402
- { url = "https://files.pythonhosted.org/packages/25/bc/45759faea132cd1b2a9ff8374e42ba03d39d076594fbb94f3e0e2c226c62/ty-0.0.16-py3-none-win_amd64.whl", hash = "sha256:f572c216aa8ecf79e86589c6e6d4bebc01f1f3cb3be765c0febd942013e1e73a", size = 10436043 },
1403
- { url = "https://files.pythonhosted.org/packages/7f/02/70a491802e7593e444137ed4e41a04c34d186eb2856f452dd76b60f2e325/ty-0.0.16-py3-none-win_arm64.whl", hash = "sha256:430eadeb1c0de0c31ef7bef9d002bdbb5f25a31e3aad546f1714d76cd8da0a87", size = 9915122 },
1384
+ version = "0.0.20"
1385
+ source = { registry = "https://pypi.org/simple" }
1386
+ sdist = { url = "https://files.pythonhosted.org/packages/56/95/8de69bb98417227b01f1b1d743c819d6456c9fd140255b6124b05b17dfd6/ty-0.0.20.tar.gz", hash = "sha256:ebba6be7974c14efbb2a9adda6ac59848f880d7259f089dfa72a093039f1dcc6", size = 5262529 }
1387
+ wheels = [
1388
+ { url = "https://files.pythonhosted.org/packages/0b/2c/718abe48393e521bf852cd6b0f984766869b09c258d6e38a118768a91731/ty-0.0.20-py3-none-linux_armv6l.whl", hash = "sha256:7cc12769c169c9709a829c2248ee2826b7aae82e92caeac813d856f07c021eae", size = 10333656 },
1389
+ { url = "https://files.pythonhosted.org/packages/41/0e/eb1c4cc4a12862e2327b72657bcebb10b7d9f17046f1bdcd6457a0211615/ty-0.0.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3b777c1bf13bc0a95985ebb8a324b8668a4a9b2e514dde5ccf09e4d55d2ff232", size = 10168505 },
1390
+ { url = "https://files.pythonhosted.org/packages/89/7f/10230798e673f0dd3094dfd16e43bfd90e9494e7af6e8e7db516fb431ddf/ty-0.0.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b2a4a7db48bf8cba30365001bc2cad7fd13c1a5aacdd704cc4b7925de8ca5eb3", size = 9678510 },
1391
+ { url = "https://files.pythonhosted.org/packages/7a/3d/59d9159577494edd1728f7db77b51bb07884bd21384f517963114e3ab5f6/ty-0.0.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6846427b8b353a43483e9c19936dc6a25612573b44c8f7d983dfa317e7f00d4c", size = 10162926 },
1392
+ { url = "https://files.pythonhosted.org/packages/9c/a8/b7273eec3e802f78eb913fbe0ce0c16ef263723173e06a5776a8359b2c66/ty-0.0.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:245ceef5bd88df366869385cf96411cb14696334f8daa75597cf7e41c3012eb8", size = 10171702 },
1393
+ { url = "https://files.pythonhosted.org/packages/9f/32/5f1144f2f04a275109db06e3498450c4721554215b80ae73652ef412eeab/ty-0.0.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c4d21d1cdf67a444d3c37583c17291ddba9382a9871021f3f5d5735e09e85efe", size = 10682552 },
1394
+ { url = "https://files.pythonhosted.org/packages/6a/db/9f1f637310792f12bd6ed37d5fc8ab39ba1a9b0c6c55a33865e9f1cad840/ty-0.0.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd4ffd907d1bd70e46af9e9a2f88622f215e1bf44658ea43b32c2c0b357299e4", size = 11242605 },
1395
+ { url = "https://files.pythonhosted.org/packages/1a/68/cc9cae2e732fcfd20ccdffc508407905a023fc8493b8771c392d915528dc/ty-0.0.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6594b58d8b0e9d16a22b3045fc1305db4b132c8d70c17784ab8c7a7cc986807", size = 10974655 },
1396
+ { url = "https://files.pythonhosted.org/packages/1c/c1/b9e3e3f28fe63486331e653f6aeb4184af8b1fe80542fcf74d2dda40a93d/ty-0.0.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3662f890518ce6cf4d7568f57d03906912d2afbf948a01089a28e325b1ef198c", size = 10761325 },
1397
+ { url = "https://files.pythonhosted.org/packages/39/9e/67db935bdedf219a00fb69ec5437ba24dab66e0f2e706dd54a4eca234b84/ty-0.0.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e3ffbae58f9f0d17cdc4ac6d175ceae560b7ed7d54f9ddfb1c9f31054bcdc2c", size = 10145793 },
1398
+ { url = "https://files.pythonhosted.org/packages/c7/de/b0eb815d4dc5a819c7e4faddc2a79058611169f7eef07ccc006531ce228c/ty-0.0.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:176e52bc8bb00b0e84efd34583962878a447a3a0e34ecc45fd7097a37554261b", size = 10189640 },
1399
+ { url = "https://files.pythonhosted.org/packages/b8/71/63734923965cbb70df1da3e93e4b8875434e326b89e9f850611122f279bf/ty-0.0.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b2bc73025418e976ca4143dde71fb9025a90754a08ac03e6aa9b80d4bed1294b", size = 10370568 },
1400
+ { url = "https://files.pythonhosted.org/packages/32/a0/a532c2048533347dff48e9ca98bd86d2c224356e101688a8edaf8d6973fb/ty-0.0.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d52f7c9ec6e363e094b3c389c344d5a140401f14a77f0625e3f28c21918552f5", size = 10853999 },
1401
+ { url = "https://files.pythonhosted.org/packages/48/88/36c652c658fe96658043e4abc8ea97801de6fb6e63ab50aaa82807bff1d8/ty-0.0.20-py3-none-win32.whl", hash = "sha256:c7d32bfe93f8fcaa52b6eef3f1b930fd7da410c2c94e96f7412c30cfbabf1d17", size = 9744206 },
1402
+ { url = "https://files.pythonhosted.org/packages/ff/a7/a4a13bed1d7fd9d97aaa3c5bb5e6d3e9a689e6984806cbca2ab4c9233cac/ty-0.0.20-py3-none-win_amd64.whl", hash = "sha256:a5e10f40fc4a0a1cbcb740a4aad5c7ce35d79f030836ea3183b7a28f43170248", size = 10711999 },
1403
+ { url = "https://files.pythonhosted.org/packages/8d/7e/6bfd748a9f4ff9267ed3329b86a0f02cdf6ab49f87bc36c8a164852f99fc/ty-0.0.20-py3-none-win_arm64.whl", hash = "sha256:53f7a5c12c960e71f160b734f328eff9a35d578af4b67a36b0bb5990ac5cdc27", size = 10150143 },
1404
1404
  ]
1405
1405
 
1406
1406
  [[package]]
File without changes