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.
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/.github/workflows/ci.yml +9 -0
- anywidget_graph-0.4.0/.github/workflows/grafeo-latest.yml +47 -0
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/CHANGELOG.md +48 -0
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/PKG-INFO +53 -4
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/README.md +50 -1
- anywidget_graph-0.4.0/assets/demo.png +0 -0
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/pyproject.toml +7 -6
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/__init__.py +1 -1
- anywidget_graph-0.4.0/src/anywidget_graph/backends/grafeo.py +274 -0
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/demo.py +1 -1
- anywidget_graph-0.4.0/src/anywidget_graph/ui/__init__.py +161 -0
- anywidget_graph-0.4.0/src/anywidget_graph/ui/grafeo-embed.js +180 -0
- anywidget_graph-0.4.0/src/anywidget_graph/ui/grafeo-result.js +194 -0
- anywidget_graph-0.4.0/src/anywidget_graph/ui/grafeo.js +157 -0
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/ui/icons.js +1 -0
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/ui/index.js +515 -74
- anywidget_graph-0.4.0/src/anywidget_graph/ui/lanes.js +189 -0
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/ui/settings.js +1 -1
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/ui/styles.css +9 -4
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/widget.py +53 -10
- anywidget_graph-0.4.0/tests/conftest.py +42 -0
- anywidget_graph-0.4.0/tests/js/grafeo-result.test.mjs +198 -0
- anywidget_graph-0.4.0/tests/js/lanes.test.mjs +133 -0
- anywidget_graph-0.4.0/tests/test_backends_grafeo.py +302 -0
- anywidget_graph-0.4.0/tests/test_demo.py +54 -0
- anywidget_graph-0.4.0/tests/test_grafeo_contract.py +317 -0
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/tests/test_graph.py +67 -0
- anywidget_graph-0.4.0/tests/test_ui.py +225 -0
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/uv.lock +922 -932
- anywidget_graph-0.3.0/assets/demo.png +0 -0
- anywidget_graph-0.3.0/assets/widget.png +0 -0
- anywidget_graph-0.3.0/src/anywidget_graph/backends/grafeo.py +0 -140
- anywidget_graph-0.3.0/src/anywidget_graph/ui/__init__.py +0 -193
- anywidget_graph-0.3.0/src/anywidget_graph/ui/grafeo-embed.js +0 -186
- anywidget_graph-0.3.0/src/anywidget_graph/ui/grafeo.js +0 -197
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/.github/workflows/pypi.yml +0 -0
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/.gitignore +0 -0
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/.python-version +0 -0
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/LICENSE +0 -0
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/examples/demo.py +0 -0
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/examples/test_hierarchy.py +0 -0
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/examples/test_large_graph.py +0 -0
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/backends/__init__.py +0 -0
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/backends/arango.py +0 -0
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/backends/cosmosdb.py +0 -0
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/backends/ladybug.py +0 -0
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/backends/neo4j.py +0 -0
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/converters/__init__.py +0 -0
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/converters/base.py +0 -0
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/converters/common.py +0 -0
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/converters/cypher.py +0 -0
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/converters/gql.py +0 -0
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/converters/graphql.py +0 -0
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/converters/gremlin.py +0 -0
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/converters/sparql.py +0 -0
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/py.typed +0 -0
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/ui/neo4j.js +0 -0
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/ui/properties.js +0 -0
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/ui/results.js +0 -0
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/ui/schema.js +0 -0
- {anywidget_graph-0.3.0 → anywidget_graph-0.4.0}/src/anywidget_graph/ui/toolbar.js +0 -0
- {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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: anywidget-graph
|
|
3
|
-
Version: 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
|
|
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
|
+
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
|
|
41
|
-
demo = [] # Marker extra
|
|
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
|
-
[
|
|
78
|
-
dev
|
|
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
|
]
|
|
@@ -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
|
|
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"},
|