anywidget-graph 0.3.0__tar.gz → 0.4.0__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 (62) hide show
  1. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/.github/workflows/ci.yml +9 -0
  2. anywidget_graph-0.4.0/.github/workflows/grafeo-latest.yml +47 -0
  3. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/CHANGELOG.md +48 -0
  4. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/PKG-INFO +53 -4
  5. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/README.md +50 -1
  6. anywidget_graph-0.4.0/assets/demo.png +0 -0
  7. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/pyproject.toml +7 -6
  8. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/__init__.py +1 -1
  9. anywidget_graph-0.4.0/src/anywidget_graph/backends/grafeo.py +274 -0
  10. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/demo.py +1 -1
  11. anywidget_graph-0.4.0/src/anywidget_graph/ui/__init__.py +161 -0
  12. anywidget_graph-0.4.0/src/anywidget_graph/ui/grafeo-embed.js +180 -0
  13. anywidget_graph-0.4.0/src/anywidget_graph/ui/grafeo-result.js +194 -0
  14. anywidget_graph-0.4.0/src/anywidget_graph/ui/grafeo.js +157 -0
  15. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/ui/icons.js +1 -0
  16. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/ui/index.js +515 -74
  17. anywidget_graph-0.4.0/src/anywidget_graph/ui/lanes.js +189 -0
  18. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/ui/settings.js +1 -1
  19. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/ui/styles.css +9 -4
  20. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/widget.py +53 -10
  21. anywidget_graph-0.4.0/tests/conftest.py +42 -0
  22. anywidget_graph-0.4.0/tests/js/grafeo-result.test.mjs +198 -0
  23. anywidget_graph-0.4.0/tests/js/lanes.test.mjs +133 -0
  24. anywidget_graph-0.4.0/tests/test_backends_grafeo.py +302 -0
  25. anywidget_graph-0.4.0/tests/test_demo.py +54 -0
  26. anywidget_graph-0.4.0/tests/test_grafeo_contract.py +317 -0
  27. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/tests/test_graph.py +67 -0
  28. anywidget_graph-0.4.0/tests/test_ui.py +225 -0
  29. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/uv.lock +922 -932
  30. anywidget_graph-0.3.0/assets/demo.png +0 -0
  31. anywidget_graph-0.3.0/assets/widget.png +0 -0
  32. anywidget_graph-0.3.0/src/anywidget_graph/backends/grafeo.py +0 -140
  33. anywidget_graph-0.3.0/src/anywidget_graph/ui/__init__.py +0 -193
  34. anywidget_graph-0.3.0/src/anywidget_graph/ui/grafeo-embed.js +0 -186
  35. anywidget_graph-0.3.0/src/anywidget_graph/ui/grafeo.js +0 -197
  36. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/.github/workflows/pypi.yml +0 -0
  37. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/.gitignore +0 -0
  38. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/.python-version +0 -0
  39. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/LICENSE +0 -0
  40. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/examples/demo.py +0 -0
  41. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/examples/test_hierarchy.py +0 -0
  42. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/examples/test_large_graph.py +0 -0
  43. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/backends/__init__.py +0 -0
  44. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/backends/arango.py +0 -0
  45. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/backends/cosmosdb.py +0 -0
  46. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/backends/ladybug.py +0 -0
  47. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/backends/neo4j.py +0 -0
  48. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/converters/__init__.py +0 -0
  49. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/converters/base.py +0 -0
  50. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/converters/common.py +0 -0
  51. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/converters/cypher.py +0 -0
  52. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/converters/gql.py +0 -0
  53. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/converters/graphql.py +0 -0
  54. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/converters/gremlin.py +0 -0
  55. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/converters/sparql.py +0 -0
  56. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/py.typed +0 -0
  57. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/ui/neo4j.js +0 -0
  58. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/ui/properties.js +0 -0
  59. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/ui/results.js +0 -0
  60. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/ui/schema.js +0 -0
  61. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/ui/toolbar.js +0 -0
  62. {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/tests/__init__.py +0 -0
@@ -12,6 +12,9 @@ jobs:
12
12
  strategy:
13
13
  matrix:
14
14
  python-version: ["3.12", "3.13", "3.14"]
15
+ env:
16
+ # Without this, uv follows .python-version and every job runs 3.12
17
+ UV_PYTHON: ${{ matrix.python-version }}
15
18
 
16
19
  steps:
17
20
  - uses: actions/checkout@v4
@@ -22,6 +25,12 @@ jobs:
22
25
  - name: Set up Python ${{ matrix.python-version }}
23
26
  run: uv python install ${{ matrix.python-version }}
24
27
 
28
+ # The suite parses the ESM bundle and runs the browser-side modules with Node
29
+ - name: Set up Node.js
30
+ uses: actions/setup-node@v4
31
+ with:
32
+ node-version: "22"
33
+
25
34
  - name: Install dependencies
26
35
  run: uv sync --dev
27
36
 
@@ -0,0 +1,47 @@
1
+ name: Grafeo latest
2
+
3
+ # Runs the suite against the newest grafeo on PyPI (pre-releases included),
4
+ # so engine changes show up before users hit them. The lockfile pins the
5
+ # version used by regular CI; this job deliberately ignores it.
6
+
7
+ on:
8
+ schedule:
9
+ - cron: "17 5 * * 1" # Mondays 05:17 UTC
10
+ workflow_dispatch:
11
+
12
+ jobs:
13
+ test:
14
+ runs-on: ubuntu-latest
15
+ strategy:
16
+ fail-fast: false
17
+ matrix:
18
+ python-version: ["3.12", "3.14"]
19
+ env:
20
+ UV_PYTHON: ${{ matrix.python-version }}
21
+
22
+ steps:
23
+ - uses: actions/checkout@v4
24
+
25
+ - name: Install uv
26
+ uses: astral-sh/setup-uv@v4
27
+
28
+ - name: Set up Python ${{ matrix.python-version }}
29
+ run: uv python install ${{ matrix.python-version }}
30
+
31
+ - name: Set up Node.js
32
+ uses: actions/setup-node@v4
33
+ with:
34
+ node-version: "22"
35
+
36
+ - name: Install dependencies
37
+ run: uv sync --dev
38
+
39
+ - name: Upgrade grafeo to the newest release (pre-releases included)
40
+ run: uv pip install --upgrade --prerelease allow grafeo
41
+
42
+ - name: Show engine
43
+ run: uv run --no-sync python -c "import grafeo; print(grafeo.__version__, grafeo.build_info())"
44
+
45
+ # --no-sync keeps the upgraded grafeo instead of restoring the locked one
46
+ - name: Run tests
47
+ run: uv run --no-sync pytest -v
@@ -1,5 +1,53 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 2026-10-09
4
+
5
+ ### Features
6
+
7
+ - **Linked lanes**: `lanes` shows several graphs side by side in one canvas, each with its own layout and title; edges between lanes (or marked `cross`) are drawn as soft curves on an overlay and light up with hover or selection
8
+ - **Live append**: `append()` (or the `append_batch` state a host sets) adds nodes and edges without a re-layout; existing nodes keep their positions, new ones fade in and edges draw in, staggered by `append_stagger_ms` and kept under 1.5 s per batch (`append_animation="none"` turns it off)
9
+ - **Lane widths, column lanes and action lanes**: a lane can be narrower or wider (`width`), stack its nodes in a column (`arrange: "column"`), or hold a clickable glyph instead of nodes (`action`, `glyph`, `caption`) that reports clicks in `lane_action`; the camera frames every lane and the lanes stretch to the canvas's shape
10
+ - **Pulse**: `pulse_nodes` gives the listed nodes a soft pulse
11
+ - **Host theme**: `theme` sets the background, panels, text, borders and accent from the host page
12
+ - **Feature detection**: the widget sets `_features` when it renders
13
+ - **Fill the host**: `fill=True` makes the widget take its host element's size (100% wide and high) and follow its resizes; `width` and `height` are then ignored
14
+
15
+ ### Improvements
16
+
17
+ - **Grafeo 0.5.44**: the `grafeo` extra now requires `grafeo>=0.5.44,<0.7`, ready for the 0.6.0 release
18
+ - **Browser engine 0.5.44**: WASM mode loads `@grafeo-db/wasm@0.5.44` (was 0.5.0) from jsDelivr; the version is set in one place (`GRAFEO_WASM_VERSION` in `ui/grafeo-embed.js`)
19
+ - **Paths and lists render**: results with `RETURN p`, `collect(n)`, variable-length relationships or `nodes(p)` / `relationships(p)` now show their nodes and edges in all three Grafeo modes; nodes that are only referenced by a path or an edge are looked up
20
+ - **Shared Grafeo result conversion** for the server and WASM modes (`ui/grafeo-result.js`)
21
+ - Schema panel shows counts in Grafeo server and WASM modes
22
+ - Grafeo server errors show the server's `detail` message instead of raw JSON
23
+ - Unknown query languages in embedded mode (for example AQL) report Grafeo's error instead of a GQL syntax error
24
+ - The ESM bundler renames every backend function automatically and no longer touches method calls with the same name
25
+ - Weekly CI job runs the suite against the newest grafeo on PyPI, pre-releases included; the CI Python matrix now really runs 3.13 and 3.14
26
+
27
+ ### Bug Fixes
28
+
29
+ - Fixed WASM mode: `@grafeo-db/wasm` is built for bundlers since 0.5.x and has no init function, so the esm.sh import failed; the binary is now instantiated directly (0.5.0 also panicked on every query in the browser)
30
+ - Fixed Grafeo server and WASM results rendering nothing: nodes and edges were matched on `labels` / `type` / `start` instead of Grafeo's `_labels` / `_type` / `_source` / `_target`
31
+ - Fixed Grafeo server schema request (`/databases/{name}/schema` does not exist, now `GET /db/{name}/schema`)
32
+ - Fixed Grafeo schema parsing in WASM mode and in `GrafeoBackend.fetch_schema()` (labels are `{name, count}` objects)
33
+ - Fixed a node property called `id` (or an edge property called `source` / `target`) replacing the engine identity, which left edges pointing at missing nodes and blanked the widget (the demo data has such properties)
34
+ - Fixed duplicate edges from undirected matches in embedded mode
35
+ - Fixed neighbor expansion on Grafeo: integer ids were compared as strings (`id(n) = "42"`) and never matched; node ids are now escaped in generated queries, and SPARQL / GraphQL sessions expand with Cypher
36
+ - Fixed the widget going blank when an edge's endpoint is missing or a node id repeats (Graphology throws), for example when new results replace the demo data
37
+ - Demo mode loads its statements one at a time and refreshes the schema panel afterwards
38
+ - Fixed `Sigma: Container has no width` when the widget is mounted in a hidden or not yet sized container (a closed panel, a tab); it now renders once the container has a size
39
+
40
+ ## 0.3.1 2026-03-16
41
+
42
+ ### New Features
43
+
44
+ - **Lasso selection**: Freeform lasso tool for selecting nodes by drawing around them
45
+
46
+ ### Bug Fixes
47
+
48
+ - Fixed missing ICONS import causing zoom and lasso buttons to fail
49
+ - Fixed `lassoSvg` referenced before declaration, crashing widget on load
50
+
3
51
  ## 0.3.0
4
52
 
5
53
  ### New Features
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: anywidget-graph
3
- Version: 0.3.0
3
+ Version: 0.4.0
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
@@ -32,7 +32,7 @@ Requires-Dist: ruff>=0.15; extra == 'dev'
32
32
  Requires-Dist: ty>=0.0; extra == 'dev'
33
33
  Provides-Extra: embed
34
34
  Provides-Extra: grafeo
35
- Requires-Dist: grafeo>=0.5; extra == 'grafeo'
35
+ Requires-Dist: grafeo<0.7,>=0.5.44; extra == 'grafeo'
36
36
  Provides-Extra: networkx
37
37
  Requires-Dist: networkx>=3.0; extra == 'networkx'
38
38
  Provides-Extra: pandas
@@ -224,6 +224,29 @@ graph.unpin_all() # Unpin everything
224
224
  graph.clear() # Remove all nodes, edges, pins, and selection
225
225
  ```
226
226
 
227
+ ### Linked lanes and live append
228
+
229
+ Show two graphs side by side in one canvas, each laid out in its own lane, with curved edges between them, and grow them while a process runs:
230
+
231
+ ```python
232
+ graph = Graph(
233
+ lanes=[{"id": "source", "title": "Source graph"}, {"id": "target", "title": "Target graph"}],
234
+ nodes=[{"id": "a", "lane": "source"}, {"id": "b", "lane": "source"}, {"id": "x", "lane": "target"}],
235
+ edges=[{"source": "a", "target": "b"}, {"source": "x", "target": "a", "cross": True}],
236
+ )
237
+ graph.append(nodes=[{"id": "y", "lane": "target"}], edges=[{"source": "y", "target": "b"}])
238
+ graph.pulse_nodes = ["b"] # a soft pulse on these nodes; [] stops it
239
+ graph.theme = {"background": "#0d1416", "panel": "#121b1e", "text": "#dbe7e5", "muted": "#8ea3a3", "border": "#1e2b2f", "accent": "#5bb8a9"}
240
+ ```
241
+
242
+ - `lanes`: lanes left to right; a node's `lane` picks one (a node without a known lane goes to the first). Edges between lanes (or marked `cross`) are drawn as soft curves above both lanes and light up when either end is hovered or selected. Without `lanes` the widget behaves as before.
243
+ - `append(nodes, edges)`: adds items without a re-layout; existing nodes keep their positions, a new node is placed at the height of its neighbours in other lanes (or next to its neighbours in its lane). New items fade in and edges draw from source to target, `append_stagger_ms` (default 60) apart; a large batch is kept under 1.5 s. `append_animation="none"` turns the animation off.
244
+ - Lane options: `width` is a lane's share of the standard width (default 1); `arrange: "column"` stacks the lane's nodes in the middle of its band (for a few roots, such as repositories, whose edges then fan out into the next lane); `action: True` makes a lane without nodes that shows a glyph button (`glyph`: a list of colours drawn as stacked bars, `caption`: text under it). The camera frames all lanes, and the lanes stretch to the canvas's shape.
245
+ - `lane_action`: set to `{"lane": <id>, "seq": <n>}` when an action lane's glyph is clicked, so the host can respond (open a panel, switch a view).
246
+ - `pulse_nodes`: ids that pulse softly.
247
+ - `theme`: host colours (`background`, `panel`, `text`, `muted`, `border`, `accent`) that replace the widget's light and dark defaults.
248
+ - `_features`: the widget sets the capabilities it supports when it renders, so a host can detect an older build.
249
+
227
250
  ## Styling
228
251
 
229
252
  ### Property-based coloring
@@ -263,6 +286,7 @@ graph = Graph(
263
286
  edges=edges,
264
287
  width=800, # Widget width (px)
265
288
  height=600, # Widget height (px)
289
+ fill=False, # Take the host's size and follow resizes (ignores width/height)
266
290
  background="#fafafa", # Background color
267
291
  show_labels=True, # Node labels
268
292
  show_edge_labels=False, # Edge labels
@@ -280,10 +304,35 @@ graph = Graph(
280
304
 
281
305
  ### Grafeo (default)
282
306
 
307
+ Grafeo runs in one of three modes, picked with `grafeo_connection_mode` or in the settings panel.
308
+
309
+ **Embedded** (Python, the default): queries run in the notebook kernel. Requires the `grafeo` extra (`grafeo>=0.5.44,<0.7`).
310
+
283
311
  ```python
284
312
  import grafeo
285
313
  db = grafeo.GrafeoDB()
286
- graph = Graph(database_backend="grafeo", grafeo_db=db)
314
+ graph = Graph(database_backend="grafeo", grafeo_db=db, query_language="gql")
315
+ ```
316
+
317
+ **Server**: the browser talks to [grafeo-server](https://github.com/GrafeoDB/grafeo-server) over HTTP (`POST /query`, `GET /db/{name}/schema`). The server must allow the notebook's origin, for example `grafeo-server --cors-origins http://localhost:8888`.
318
+
319
+ ```python
320
+ graph = Graph(database_backend="grafeo", grafeo_connection_mode="server", grafeo_server_url="http://localhost:7474")
321
+ ```
322
+
323
+ **WASM**: the engine runs in the browser. `@grafeo-db/wasm` (0.5.44) is downloaded from jsDelivr when you click "Initialize WASM", so nothing extra is installed in Python.
324
+
325
+ ```python
326
+ graph = Graph(database_backend="grafeo", grafeo_connection_mode="wasm", query_language="gql")
327
+ ```
328
+
329
+ Query languages: GQL, Cypher, Gremlin, GraphQL and SPARQL. Nodes and edges are taken from every result column, including lists (`collect(n)`, variable-length relationships, `nodes(p)`) and paths (`RETURN p`); scalar columns are ignored. Run one statement per query: Grafeo rejects several `;`-separated statements in one call.
330
+
331
+ Try it without any setup:
332
+
333
+ ```python
334
+ from anywidget_graph.demo import demo_graph
335
+ demo_graph() # WASM mode with a small movie graph loaded
287
336
  ```
288
337
 
289
338
  ### Neo4j (browser-side)
@@ -183,6 +183,29 @@ graph.unpin_all() # Unpin everything
183
183
  graph.clear() # Remove all nodes, edges, pins, and selection
184
184
  ```
185
185
 
186
+ ### Linked lanes and live append
187
+
188
+ Show two graphs side by side in one canvas, each laid out in its own lane, with curved edges between them, and grow them while a process runs:
189
+
190
+ ```python
191
+ graph = Graph(
192
+ lanes=[{"id": "source", "title": "Source graph"}, {"id": "target", "title": "Target graph"}],
193
+ nodes=[{"id": "a", "lane": "source"}, {"id": "b", "lane": "source"}, {"id": "x", "lane": "target"}],
194
+ edges=[{"source": "a", "target": "b"}, {"source": "x", "target": "a", "cross": True}],
195
+ )
196
+ graph.append(nodes=[{"id": "y", "lane": "target"}], edges=[{"source": "y", "target": "b"}])
197
+ graph.pulse_nodes = ["b"] # a soft pulse on these nodes; [] stops it
198
+ graph.theme = {"background": "#0d1416", "panel": "#121b1e", "text": "#dbe7e5", "muted": "#8ea3a3", "border": "#1e2b2f", "accent": "#5bb8a9"}
199
+ ```
200
+
201
+ - `lanes`: lanes left to right; a node's `lane` picks one (a node without a known lane goes to the first). Edges between lanes (or marked `cross`) are drawn as soft curves above both lanes and light up when either end is hovered or selected. Without `lanes` the widget behaves as before.
202
+ - `append(nodes, edges)`: adds items without a re-layout; existing nodes keep their positions, a new node is placed at the height of its neighbours in other lanes (or next to its neighbours in its lane). New items fade in and edges draw from source to target, `append_stagger_ms` (default 60) apart; a large batch is kept under 1.5 s. `append_animation="none"` turns the animation off.
203
+ - Lane options: `width` is a lane's share of the standard width (default 1); `arrange: "column"` stacks the lane's nodes in the middle of its band (for a few roots, such as repositories, whose edges then fan out into the next lane); `action: True` makes a lane without nodes that shows a glyph button (`glyph`: a list of colours drawn as stacked bars, `caption`: text under it). The camera frames all lanes, and the lanes stretch to the canvas's shape.
204
+ - `lane_action`: set to `{"lane": <id>, "seq": <n>}` when an action lane's glyph is clicked, so the host can respond (open a panel, switch a view).
205
+ - `pulse_nodes`: ids that pulse softly.
206
+ - `theme`: host colours (`background`, `panel`, `text`, `muted`, `border`, `accent`) that replace the widget's light and dark defaults.
207
+ - `_features`: the widget sets the capabilities it supports when it renders, so a host can detect an older build.
208
+
186
209
  ## Styling
187
210
 
188
211
  ### Property-based coloring
@@ -222,6 +245,7 @@ graph = Graph(
222
245
  edges=edges,
223
246
  width=800, # Widget width (px)
224
247
  height=600, # Widget height (px)
248
+ fill=False, # Take the host's size and follow resizes (ignores width/height)
225
249
  background="#fafafa", # Background color
226
250
  show_labels=True, # Node labels
227
251
  show_edge_labels=False, # Edge labels
@@ -239,10 +263,35 @@ graph = Graph(
239
263
 
240
264
  ### Grafeo (default)
241
265
 
266
+ Grafeo runs in one of three modes, picked with `grafeo_connection_mode` or in the settings panel.
267
+
268
+ **Embedded** (Python, the default): queries run in the notebook kernel. Requires the `grafeo` extra (`grafeo>=0.5.44,<0.7`).
269
+
242
270
  ```python
243
271
  import grafeo
244
272
  db = grafeo.GrafeoDB()
245
- graph = Graph(database_backend="grafeo", grafeo_db=db)
273
+ graph = Graph(database_backend="grafeo", grafeo_db=db, query_language="gql")
274
+ ```
275
+
276
+ **Server**: the browser talks to [grafeo-server](https://github.com/GrafeoDB/grafeo-server) over HTTP (`POST /query`, `GET /db/{name}/schema`). The server must allow the notebook's origin, for example `grafeo-server --cors-origins http://localhost:8888`.
277
+
278
+ ```python
279
+ graph = Graph(database_backend="grafeo", grafeo_connection_mode="server", grafeo_server_url="http://localhost:7474")
280
+ ```
281
+
282
+ **WASM**: the engine runs in the browser. `@grafeo-db/wasm` (0.5.44) is downloaded from jsDelivr when you click "Initialize WASM", so nothing extra is installed in Python.
283
+
284
+ ```python
285
+ graph = Graph(database_backend="grafeo", grafeo_connection_mode="wasm", query_language="gql")
286
+ ```
287
+
288
+ Query languages: GQL, Cypher, Gremlin, GraphQL and SPARQL. Nodes and edges are taken from every result column, including lists (`collect(n)`, variable-length relationships, `nodes(p)`) and paths (`RETURN p`); scalar columns are ignored. Run one statement per query: Grafeo rejects several `;`-separated statements in one call.
289
+
290
+ Try it without any setup:
291
+
292
+ ```python
293
+ from anywidget_graph.demo import demo_graph
294
+ demo_graph() # WASM mode with a small movie graph loaded
246
295
  ```
247
296
 
248
297
  ### Neo4j (browser-side)
Binary file
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "anywidget-graph"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "Interactive graph visualization for Python notebooks using anywidget"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -35,10 +35,10 @@ dev = [
35
35
  ]
36
36
  networkx = ["networkx>=3.0"]
37
37
  pandas = ["pandas>=2.0"]
38
- grafeo = ["grafeo>=0.5"]
38
+ grafeo = ["grafeo>=0.5.44,<0.7"]
39
39
  cosmosdb = ["gremlinpython>=3.7"]
40
- embed = [] # Marker extra — WASM loads from CDN on demand
41
- demo = [] # Marker extra — enables demo mode with pre-populated dataset
40
+ embed = [] # Marker extra: WASM loads from CDN on demand
41
+ demo = [] # Marker extra: enables demo mode with pre-populated dataset
42
42
 
43
43
  [project.urls]
44
44
  Homepage = "https://grafeo.dev/"
@@ -74,8 +74,9 @@ repos = [
74
74
  ]},
75
75
  ]
76
76
 
77
- [tool.uv]
78
- dev-dependencies = [
77
+ [dependency-groups]
78
+ dev = [
79
+ "anywidget-graph[grafeo]", # Engine contract tests run against the pinned grafeo extra
79
80
  "pytest>=9.0.2",
80
81
  "ruff>=0.14.14",
81
82
  ]
@@ -3,4 +3,4 @@
3
3
  from anywidget_graph.widget import Graph
4
4
 
5
5
  __all__ = ["Graph"]
6
- __version__ = "0.2.8"
6
+ __version__ = "0.4.0"
@@ -0,0 +1,274 @@
1
+ """Grafeo database backend (Python-side execution).
2
+
3
+ Grafeo returns result rows as dicts. Entities are encoded as dicts with
4
+ metadata keys, and that encoding is the contract this module depends on:
5
+
6
+ - node: ``{"_id": int, "_labels": [str, ...], **properties}``
7
+ - edge: ``{"_id": int, "_type": str, "_source": int, "_target": int, **properties}``
8
+ - path: ``{"nodes": [int, ...], "edges": [int, ...]}`` (ids only)
9
+
10
+ Entities can also sit inside lists (``collect(n)``, variable-length edge
11
+ variables, ``nodes(p)``). Ids that are referenced but not returned as
12
+ entities (path members, edge endpoints) are resolved with
13
+ ``db.get_node(id)`` / ``db.get_edge(id)``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field
19
+ from typing import Any, ClassVar
20
+
21
+ from anywidget_graph.converters import (
22
+ get_node_id,
23
+ is_node,
24
+ is_relationship,
25
+ node_to_dict,
26
+ relationship_to_dict,
27
+ )
28
+
29
+
30
+ @dataclass(slots=True)
31
+ class _GraphAccumulator:
32
+ """Nodes and edges collected from one result, plus ids still to resolve."""
33
+
34
+ nodes: dict[str, dict] = field(default_factory=dict)
35
+ edges: dict[Any, dict] = field(default_factory=dict)
36
+ grafeo_edge_keys: set[Any] = field(default_factory=set)
37
+ path_node_ids: set[int] = field(default_factory=set)
38
+ path_edge_ids: set[int] = field(default_factory=set)
39
+
40
+
41
+ class GrafeoBackend:
42
+ """Backend for Grafeo database connections."""
43
+
44
+ _LANGUAGE_METHODS: ClassVar[dict[str, str]] = {
45
+ "cypher": "execute_cypher",
46
+ "gremlin": "execute_gremlin",
47
+ "graphql": "execute_graphql",
48
+ "sparql": "execute_sparql",
49
+ "sql": "execute_sql",
50
+ }
51
+
52
+ def __init__(self, db: Any) -> None:
53
+ """Initialize with a GrafeoDB instance."""
54
+ self._db = db
55
+
56
+ @property
57
+ def db(self) -> Any:
58
+ """Get the underlying database instance."""
59
+ return self._db
60
+
61
+ def execute(self, query: str, *, language: str = "cypher") -> tuple[list[dict], list[dict]]:
62
+ """Execute a query in the specified language and return (nodes, edges)."""
63
+ return self._process_result(self._run(query, language))
64
+
65
+ def _run(self, query: str, language: str) -> Any:
66
+ method_name = self._LANGUAGE_METHODS.get(language)
67
+ if method_name and hasattr(self._db, method_name):
68
+ return getattr(self._db, method_name)(query)
69
+ if method_name is None and language not in ("", "gql") and hasattr(self._db, "execute_language"):
70
+ # Lets the engine reject unsupported languages with a clear error
71
+ return self._db.execute_language(language, query)
72
+ return self._db.execute(query)
73
+
74
+ def fetch_schema(self) -> tuple[list[dict], list[dict]]:
75
+ """Fetch schema from Grafeo database.
76
+
77
+ ``db.schema()`` returns ``{"labels": [{"name", "count"}], "edge_types": [...]}``;
78
+ plain string entries are accepted as well.
79
+ """
80
+ if not hasattr(self._db, "schema"):
81
+ return [], []
82
+ schema = self._db.schema()
83
+ if not isinstance(schema, dict):
84
+ return [], []
85
+ node_types = [self._schema_entry(item, "label") for item in schema.get("labels") or []]
86
+ edge_types = [self._schema_entry(item, "type") for item in schema.get("edge_types") or []]
87
+ return node_types, edge_types
88
+
89
+ @staticmethod
90
+ def _schema_entry(item: Any, key: str) -> dict:
91
+ if isinstance(item, dict):
92
+ entry: dict = {key: str(item.get("name", "")), "properties": list(item.get("properties") or [])}
93
+ if isinstance(item.get("count"), int):
94
+ entry["count"] = item["count"]
95
+ return entry
96
+ return {key: str(item), "properties": []}
97
+
98
+ def _process_result(self, result: Any) -> tuple[list[dict], list[dict]]:
99
+ """Process query results into nodes and edges."""
100
+ acc = _GraphAccumulator()
101
+
102
+ records = list(result) if hasattr(result, "__iter__") else [result]
103
+ for record in records:
104
+ for _key, value in self._extract_items(record):
105
+ self._add_top_level(acc, value)
106
+
107
+ self._resolve_missing(acc)
108
+
109
+ edges = [
110
+ edge
111
+ for key, edge in acc.edges.items()
112
+ if key not in acc.grafeo_edge_keys or (edge["source"] in acc.nodes and edge["target"] in acc.nodes)
113
+ ]
114
+ return list(acc.nodes.values()), edges
115
+
116
+ def _add_top_level(self, acc: _GraphAccumulator, value: Any) -> None:
117
+ if self._add_grafeo_entity(acc, value):
118
+ return
119
+ # Values from other drivers or hand-built rows
120
+ if is_node(value):
121
+ node_id = get_node_id(value)
122
+ if node_id not in acc.nodes:
123
+ acc.nodes[node_id] = node_to_dict(value)
124
+ elif is_relationship(value):
125
+ acc.edges[("generic", len(acc.edges))] = relationship_to_dict(value)
126
+ else:
127
+ self._add_nested(acc, value)
128
+
129
+ def _add_value(self, acc: _GraphAccumulator, value: Any) -> None:
130
+ if not self._add_grafeo_entity(acc, value):
131
+ self._add_nested(acc, value)
132
+
133
+ def _add_nested(self, acc: _GraphAccumulator, value: Any) -> None:
134
+ """Search lists and plain maps for entities (collect, nodes(p), variable-length edges)."""
135
+ if isinstance(value, list | tuple):
136
+ for item in value:
137
+ self._add_value(acc, item)
138
+ elif isinstance(value, dict):
139
+ for item in value.values():
140
+ self._add_value(acc, item)
141
+
142
+ def _add_grafeo_entity(self, acc: _GraphAccumulator, value: Any) -> bool:
143
+ """Collect a Grafeo node, edge or path; return False for any other value."""
144
+ if self._is_grafeo_node(value):
145
+ node = self._grafeo_node_to_dict(value)
146
+ acc.nodes.setdefault(node["id"], node)
147
+ return True
148
+ if self._is_grafeo_relationship(value):
149
+ key = ("grafeo", value["_id"]) if "_id" in value else ("grafeo-anon", len(acc.edges))
150
+ if key not in acc.edges:
151
+ acc.edges[key] = self._grafeo_relationship_to_dict(value)
152
+ acc.grafeo_edge_keys.add(key)
153
+ return True
154
+ if self._is_grafeo_path(value):
155
+ for member, ids in ((value["nodes"], acc.path_node_ids), (value["edges"], acc.path_edge_ids)):
156
+ for item in member:
157
+ if self._is_entity_id(item):
158
+ ids.add(item)
159
+ else:
160
+ self._add_value(acc, item)
161
+ return True
162
+ return False
163
+
164
+ def _resolve_missing(self, acc: _GraphAccumulator) -> None:
165
+ """Look up path members and edge endpoints that were only returned as ids."""
166
+ get_edge = getattr(self._db, "get_edge", None)
167
+ if get_edge is not None:
168
+ for edge_id in sorted(acc.path_edge_ids):
169
+ if ("grafeo", edge_id) in acc.edges:
170
+ continue
171
+ edge = get_edge(edge_id)
172
+ if edge is not None:
173
+ self._add_grafeo_entity(acc, self._edge_object_to_raw(edge))
174
+
175
+ get_node = getattr(self._db, "get_node", None)
176
+ if get_node is None:
177
+ return
178
+ wanted = {str(node_id): node_id for node_id in acc.path_node_ids}
179
+ for key in acc.grafeo_edge_keys:
180
+ edge = acc.edges[key]
181
+ for endpoint in (edge["source"], edge["target"]):
182
+ if endpoint.isdigit():
183
+ wanted.setdefault(endpoint, int(endpoint))
184
+ for node_key, node_id in sorted(wanted.items(), key=lambda item: item[1]):
185
+ if node_key in acc.nodes:
186
+ continue
187
+ node = get_node(node_id)
188
+ if node is not None:
189
+ self._add_grafeo_entity(acc, self._node_object_to_raw(node))
190
+
191
+ @staticmethod
192
+ def _is_entity_id(value: Any) -> bool:
193
+ return isinstance(value, int) and not isinstance(value, bool) and value >= 0
194
+
195
+ @staticmethod
196
+ def _node_object_to_raw(node: Any) -> dict:
197
+ """Convert a ``grafeo.Node`` (from ``db.get_node``) to the row dict encoding."""
198
+ return {**node.properties(), "_id": node.id, "_labels": list(node.labels)}
199
+
200
+ @staticmethod
201
+ def _edge_object_to_raw(edge: Any) -> dict:
202
+ """Convert a ``grafeo.Edge`` (from ``db.get_edge``) to the row dict encoding."""
203
+ return {
204
+ **edge.properties(),
205
+ "_id": edge.id,
206
+ "_type": edge.edge_type,
207
+ "_source": edge.source_id,
208
+ "_target": edge.target_id,
209
+ }
210
+
211
+ @staticmethod
212
+ def _is_grafeo_node(obj: Any) -> bool:
213
+ """Check if a value is a Grafeo node dict (has ``_id`` and ``_labels``)."""
214
+ return isinstance(obj, dict) and "_id" in obj and "_labels" in obj
215
+
216
+ @staticmethod
217
+ def _is_grafeo_relationship(obj: Any) -> bool:
218
+ """Check if a value is a Grafeo relationship dict (has ``_source``, ``_target``, ``_type``)."""
219
+ return isinstance(obj, dict) and "_source" in obj and "_target" in obj and "_type" in obj
220
+
221
+ @staticmethod
222
+ def _is_grafeo_path(obj: Any) -> bool:
223
+ """Check if a value is a Grafeo path (``nodes`` and ``edges`` lists, no ``_id``)."""
224
+ return (
225
+ isinstance(obj, dict)
226
+ and "_id" not in obj
227
+ and isinstance(obj.get("nodes"), list)
228
+ and isinstance(obj.get("edges"), list)
229
+ )
230
+
231
+ @staticmethod
232
+ def _grafeo_node_to_dict(node: dict) -> dict:
233
+ """Convert a Grafeo node dict to the widget node format.
234
+
235
+ Properties are applied before ``id`` and ``labels`` so that a user
236
+ property called ``id`` cannot replace the node identity that edges
237
+ refer to.
238
+ """
239
+ labels = list(node.get("_labels") or [])
240
+ props = {key: value for key, value in node.items() if not key.startswith("_")}
241
+ result: dict = {}
242
+ if labels:
243
+ result["label"] = labels[0]
244
+ result.update(props)
245
+ result["id"] = str(node["_id"])
246
+ if labels:
247
+ result["labels"] = labels
248
+ if "label" not in result and "name" in result:
249
+ result["label"] = result["name"]
250
+ return result
251
+
252
+ @staticmethod
253
+ def _grafeo_relationship_to_dict(rel: dict) -> dict:
254
+ """Convert a Grafeo relationship dict to the widget edge format.
255
+
256
+ ``source`` and ``target`` always come from the engine, never from
257
+ properties with the same name.
258
+ """
259
+ result: dict = {"label": str(rel["_type"])}
260
+ result.update({key: value for key, value in rel.items() if not key.startswith("_")})
261
+ result["source"] = str(rel["_source"])
262
+ result["target"] = str(rel["_target"])
263
+ return result
264
+
265
+ def _extract_items(self, record: Any) -> list[tuple[str, Any]]:
266
+ """Extract key-value items from a record."""
267
+ if hasattr(record, "items"):
268
+ items = record.items() if callable(record.items) else record.items
269
+ return list(items)
270
+ elif hasattr(record, "data"):
271
+ return list(record.data().items())
272
+ elif isinstance(record, dict):
273
+ return list(record.items())
274
+ return []
@@ -18,7 +18,7 @@ import json
18
18
 
19
19
  from anywidget_graph.widget import Graph
20
20
 
21
- # Classic movie/actor graph dataset — small enough to load instantly,
21
+ # Classic movie/actor graph dataset, small enough to load instantly,
22
22
  # rich enough to demonstrate labels, relationships, and properties.
23
23
  DEMO_NODES = [
24
24
  {"id": "m1", "label": "The Matrix", "labels": ["Movie"], "year": 1999, "color": "#6366f1"},