Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/gds-integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest]
gds-version: ["1.22", "2.0.0a1"]
defaults:
run:
working-directory: python-wrapper
Expand All @@ -33,11 +34,12 @@ jobs:
with:
python-version: "3.11"
enable-cache: true
- run: uv sync --group dev --extra pandas --extra neo4j --extra gds
- name: Install just
uses: extractions/setup-just@v2

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Semgrep identified an issue in your code:

extractions/setup-just@v2 uses a movable third-party tag, so a retagged or compromised action could run attacker-controlled code in CI and access Aura credentials.

More details about this

Install just pulls extractions/setup-just@v2, which is a moving tag from a third-party GitHub repository rather than a specific commit. If the extractions/setup-just repo is compromised or its v2 tag is retargeted, this workflow will run the attacker’s code on the runner before just py-ci-test-gds starts, with access to the job environment including AURA_API_CLIENT_ID, ${{ secrets.AURA_API_CLIENT_SECRET }}, and AURA_API_PROJECT_ID.

A plausible attack looks like this:

  1. An attacker gains control of the extractions/setup-just repository or its release/tag management.
  2. They move the v2 tag to a new commit that adds a malicious step inside the action.
  3. When this workflow runs uses: extractions/setup-just@v2, GitHub Actions fetches that new code automatically because the reference is not immutable.
  4. The malicious action reads the job’s environment or workspace and exfiltrates values such as AURA_API_CLIENT_SECRET, for example by sending them to an attacker-controlled server.
  5. The attacker can then use the stolen Aura credentials and project ID to access or manipulate the same Aura resources this test job uses.

Because the reference is @v2 instead of a full 40-character commit SHA, anyone who can change what v2 points to can change what code your CI executes.

To resolve this comment:

✨ Commit fix suggestion
  1. Replace the tag-based action reference with a full 40-character commit SHA in the uses line.
    Change uses: extractions/setup-just@v2 to uses: extractions/setup-just@<full-commit-sha> # v2.

  2. Resolve the SHA from the current v2 release in the extractions/setup-just repository, and use that exact commit hash instead of the tag.
    This keeps the workflow on the intended version while making the action immutable.

  3. Keep the version comment after the SHA so future updates are easier to review.
    Use the same format as uses: extractions/setup-just@<full-commit-sha> # v2.

  4. Alternatively, if you need to stay aligned with a newer upstream release instead of the current v2 target, pin to the full commit SHA for that newer release and update the trailing comment to match, for example # v3.

💬 Ignore this finding

Reply with Semgrep commands to ignore this finding.

  • /fp <comment> for false positive
  • /ar <comment> for acceptable risk
  • /other <comment> for all other reasons

Alternatively, triage in Semgrep AppSec Platform to ignore the finding created by third-party-action-not-pinned-to-commit-sha.

To get more information about secure coding and some most popular vulnerabilities, check our secure coding guidelines

You can view more details about this finding in the Semgrep AppSec Platform.


- name: Run tests
env:
AURA_API_CLIENT_ID: 4V1HYCYEeoU4dSxThKnBeLvE2U4hSphx
AURA_API_CLIENT_SECRET: ${{ secrets.AURA_API_CLIENT_SECRET }}
AURA_API_PROJECT_ID: 3f8df5e7-4800-4d4f-ad1d-2d044dfd587c
run: uv run pytest tests/ --include-neo4j-and-gds
run: just py-ci-test-gds "${{ matrix.gds-version }}"
13 changes: 3 additions & 10 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,14 @@
# Changes in 1.5.0
# Changes

## Breaking changes

## New features

* Add `GraphWidget` methods to change render options in place without re-rendering: `set_layout`, `set_zoom`, `set_pan`, `set_renderer`, and `set_show_layout_button`
* Add `GraphWidget` methods to change styling in place without re-rendering such as `color_relationships`
* Added the ability to set the selection mode (gesture) of the `GraphWidget` from Python, via the `selection_mode` render option or the `GraphWidget.set_selection_mode` method.
* Added the `GraphWidget.selected` trait to read back the IDs of the nodes and relationships selected in the widget UI. Use the `GraphWidget.on_selection_change` method (or `widget.observe`) to react to selection changes.

## Bug fixes

* Warn when relationships reference node ids that are not in the graph. It is configurable via the `on_dangling` parameter (`"warn"` (default), `"error"`, or `"none"`) on `render`, `render_widget`, and `GraphWidget.add_data`

## Improvements

* Support Python 3.14
* Support Aura Graph Analytics
* Support `gds.v2` endpoints
* Use typed options field in `GraphWidget`

## Other changes
32 changes: 32 additions & 0 deletions docs/antora/modules/ROOT/pages/customizing.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,38 @@ In the following example, we pin the node with ID 1337 and unpin the node with I
VG.toggle_nodes_pinned(1337: True, 42: False)})
----

== Reading the selection

When a graph is rendered as an interactive widget via
link:{api-docs-uri}/visualization-graph/#neo4j_viz.VisualizationGraph.render_widget[`neo4j_viz.VisualizationGraph.render_widget()`],
you can read back which nodes and relationships the user has selected in the UI (using single, box, or lasso selection).

The widget exposes the `selected` attribute, a typed link:{api-docs-uri}/widget[`GraphSelection`] with `nodeIds` and
`relationshipIds` fields holding the IDs of the currently selected nodes and relationships.
Note that the IDs are strings, so match them against `str(node.id)` / `str(relationship.id)` to recover the
link:{api-docs-uri}/node[Node] and link:{api-docs-uri}/relationship[Relationship] objects.

To react to selection changes interactively, register a callback with `widget.on_selection_change(...)`. This is a
convenience wrapper around `widget.observe(..., names=["selected"])`: the callback runs every time the user selects
nodes or relationships in the widget, and receives the new `GraphSelection` directly.

[source, python]
----
# VG is a VisualizationGraph object
widget = VG.render_widget()


def on_selection_change(selection):
selected_nodes = [node for node in widget.nodes if str(node.id) in set(selection.nodeIds)]
# ... act on the selected nodes, e.g. add data or update other widgets


widget.on_selection_change(on_selection_change)
----

`on_selection_change` returns the registered handler, which you can pass to `widget.unobserve(handler, names=["selected"])`
to stop reacting. You can also read `widget.selected` directly at any point for the current selection.

== Direct modification of nodes and relationships

Nodes and relationships can also be modified directly by accessing the `nodes` and `relationships` fields of an
Expand Down
3 changes: 3 additions & 0 deletions docs/source/api-reference/render_options.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@

.. autoenum:: neo4j_viz.Renderer
:members:

.. autoenum:: neo4j_viz.SelectionMode
:members:
4 changes: 4 additions & 0 deletions docs/source/api-reference/widget.rst
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
.. autoclass:: neo4j_viz.GraphWidget
:members:

.. autoclass:: neo4j_viz.GraphSelection
:members:
:exclude-members: model_config
1,711 changes: 55 additions & 1,656 deletions examples/getting-started.ipynb

Large diffs are not rendered by default.

30 changes: 28 additions & 2 deletions js-applet/src/graph-widget.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type WidgetState = {
height: string;
width: string;
theme: "light" | "dark" | "auto";
selected: { nodeIds: string[]; relationshipIds: string[] };
};

class FakeModel {
Expand Down Expand Up @@ -65,6 +66,7 @@ class FakeModel {

type RenderedWidget = {
el: HTMLDivElement;
model: FakeModel;
teardown: void | (() => void | Promise<void>) | (() => Promise<void>);
};

Expand All @@ -90,6 +92,7 @@ async function renderWidget(
height: overrides.height ?? "400px",
width: overrides.width ?? "600px",
theme: overrides.theme ?? "light",
selected: overrides.selected ?? { nodeIds: [], relationshipIds: [] },
});

let teardown: RenderedWidget["teardown"] = undefined;
Expand All @@ -101,7 +104,7 @@ async function renderWidget(
});
});

return { el, teardown };
return { el, model, teardown };
}

async function renderWidgetInShadowRoot(
Expand All @@ -124,6 +127,7 @@ async function renderWidgetInShadowRoot(
height: "400px",
width: "600px",
theme: "light",
selected: { nodeIds: [], relationshipIds: [] },
});

let teardown: RenderedWidget["teardown"] = undefined;
Expand All @@ -135,7 +139,7 @@ async function renderWidgetInShadowRoot(
});
});

return { el, host, shadowRoot, teardown };
return { el, host, shadowRoot, model, teardown };
}

afterEach(() => {
Expand Down Expand Up @@ -183,6 +187,28 @@ describe("graph-widget button testing", () => {
}
});

it("renders with an initial selection sourced from the model", async () => {
const { el, model, teardown } = await renderWidget({
selected: { nodeIds: ["n1"], relationshipIds: [] },
});

try {
await waitFor(() => {
expect(within(el).getByRole("button", { name: /download/i })).toBeTruthy();
});

// The selection is controlled by the model and left untouched on initial render.
expect(model.get("selected")).toEqual({
nodeIds: ["n1"],
relationshipIds: [],
});
} finally {
if (typeof teardown === "function") {
await teardown();
}
}
});

it("bridges NDL styles to document.head when rendered inside a shadow root", async () => {
const { shadowRoot, teardown } = await renderWidgetInShadowRoot();

Expand Down
19 changes: 16 additions & 3 deletions js-applet/src/graph-widget.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createRender, useModelState } from "@anywidget/react";
import ndlCssText from "@neo4j-ndl/base/lib/neo4j-ds-styles.css?inline";
import { Gesture, GraphVisualization } from "@neo4j-ndl/react-graph";
import { Gesture, GraphSelection, GraphVisualization } from "@neo4j-ndl/react-graph";
import type { Layout, NvlOptions } from "@neo4j-nvl/base";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Expand All @@ -25,6 +25,7 @@ export type GraphOptions = {
pan?: { x: number; y: number };
layoutOptions?: Record<string, unknown>;
showLayoutButton: boolean;
selectionMode?: Gesture;
};

export type WidgetData = {
Expand All @@ -34,8 +35,11 @@ export type WidgetData = {
height: string;
width: string;
theme: Theme;
selected: GraphSelection;
};

const EMPTY_SELECTION: GraphSelection = { nodeIds: [], relationshipIds: [] };

function detectTheme(): "light" | "dark" {
if (document.body.classList.contains("vscode-light") || document.body.classList.contains("light-theme")) {
return "light";
Expand Down Expand Up @@ -167,9 +171,16 @@ function GraphWidget() {
const [height] = useModelState<WidgetData["height"]>("height");
const [width] = useModelState<WidgetData["width"]>("width");
const [theme] = useModelState<WidgetData["theme"]>("theme");
const [gesture, setGesture] = useState<Gesture>("single");
const { layout, nvlOptions, zoom, pan, layoutOptions, showLayoutButton } =
const [selected, setSelected] =
useModelState<WidgetData["selected"]>("selected");
const { layout, nvlOptions, zoom, pan, layoutOptions, showLayoutButton, selectionMode } =
options ?? {};
// `gesture` is locally controlled so the GestureSelectButton stays interactive, but it is
// seeded from (and re-synced to) the Python-provided `selectionMode` when that changes.
const [gesture, setGesture] = useState<Gesture>(selectionMode ?? "single");
useEffect(() => {
if (selectionMode) setGesture(selectionMode);
}, [selectionMode]);
const setLayout = (layout: Layout) => {
setOptions({ ...options, layout });
};
Expand Down Expand Up @@ -216,6 +227,8 @@ function GraphWidget() {
rels={neoRelationships}
gesture={gesture}
setGesture={setGesture}
selected={selected ?? EMPTY_SELECTION}
setSelected={setSelected}
layout={layout}
setLayout={setLayout}
nvlOptions={nvlOptionsWithoutWorkers}
Expand Down
23 changes: 23 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ py_dir := root_dir / 'python-wrapper'
py-sync:
cd python-wrapper && uv sync --group dev --group docs --group notebook --extra pandas --extra neo4j --extra gds --extra snowflake

# check the release version is unpublished and main's CI is green
# example: just prerelease
prerelease:
python scripts/release/prerelease.py

# bump the version and reset the changelog after a release (default: minor bump)
# examples:
# just postrelease # 1.5.0 -> 1.6.0
# just postrelease patch # 1.5.0 -> 1.5.1
# just postrelease major # 1.5.0 -> 2.0.0
postrelease part="minor":
python scripts/release/postrelease.py --part {{part}}

py-style:
just py-sync
./scripts/makestyle.sh && ./scripts/checkstyle.sh
Expand All @@ -12,6 +25,16 @@ py-test:
cd python-wrapper && uv sync --all-extras --group dev
cd python-wrapper && uv run --group dev pytest

# install a specific GDS client version and run the GDS integration tests (used by CI)
# example: just py-ci-test-gds 2.0.0a1
py-ci-test-gds gds_version:
#!/usr/bin/env bash
set -e
cd {{py_dir}}
uv sync --group dev --extra pandas --extra neo4j --extra gds
uv pip install "graphdatascience=={{gds_version}}"
uv run pytest tests/ --include-neo4j-and-gds

py-test-gds:
#!/usr/bin/env bash
set -e
Expand Down
4 changes: 2 additions & 2 deletions python-wrapper/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "neo4j-viz"
version = "1.5.0"
version = "1.6.0"
description = "A simple graph visualization tool"
readme = "README.md"
authors = [{ name = "Neo4j", email = "team-gds@neo4j.org" }]
Expand Down Expand Up @@ -43,7 +43,7 @@ requires-python = ">=3.10"

[project.optional-dependencies]
pandas = ["pandas>=2, <3", "pandas-stubs>=2, <3"]
gds = ["graphdatascience>=1.22, <2"]
gds = ["graphdatascience>=1.22, <3"]
neo4j = ["neo4j"]
snowflake = ["snowflake-snowpark-python>=1, <2"]

Expand Down
4 changes: 4 additions & 0 deletions python-wrapper/src/neo4j_viz/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
CaptionAlignment,
Direction,
ForceDirectedLayoutOptions,
GraphSelection,
HierarchicalLayoutOptions,
Layout,
NvlOptions,
Packing,
PanPosition,
Renderer,
SelectionMode,
WidgetOptions,
)
from .relationship import Relationship
Expand All @@ -21,11 +23,13 @@
"WidgetOptions",
"NvlOptions",
"PanPosition",
"GraphSelection",
"Node",
"Relationship",
"CaptionAlignment",
"Layout",
"Renderer",
"SelectionMode",
"ForceDirectedLayoutOptions",
"HierarchicalLayoutOptions",
"Direction",
Expand Down
39 changes: 39 additions & 0 deletions python-wrapper/src/neo4j_viz/_gds_compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from __future__ import annotations

import importlib
import re
from typing import Any

from graphdatascience.version import __version__ as _gds_version


def _parse_major(version: str) -> int:
match = re.match(r"\s*(\d+)", version)
return int(match.group(1)) if match else 0


IS_GDS_2: bool = _parse_major(_gds_version) >= 2

if IS_GDS_2:
GdsGraph: Any = importlib.import_module("graphdatascience.graph").Graph
_GRAPH_TYPES: tuple[type, ...] = (GdsGraph,)
else:
GdsGraph = importlib.import_module("graphdatascience.graph.v2").GraphV2
_GRAPH_TYPES = (GdsGraph, importlib.import_module("graphdatascience").Graph)


def _check_graph_type(G: Any) -> None:
"""Raise ``TypeError`` unless ``G`` is a graph object accepted by the installed client."""
if not isinstance(G, _GRAPH_TYPES):
accepted = " or ".join(t.__name__ for t in _GRAPH_TYPES)
raise TypeError(f"`G` must be a GDS graph object ({accepted}), but got {type(G).__name__}")


def _catalog(gds: Any) -> Any:
"""Return the graph catalog endpoints for either client version."""
return gds.graph if IS_GDS_2 else gds.v2.graph


def _degree_centrality(gds: Any) -> Any:
"""Return the degree centrality endpoints for either client version."""
return gds.degree_centrality if IS_GDS_2 else gds.v2.degree_centrality
Loading
Loading