Skip to content

Commit dcb99ae

Browse files
authored
Merge pull request #268 from neo4j/use-aura-graph-analytics
use aura graph analytics
2 parents 63da09a + ea47a62 commit dcb99ae

13 files changed

Lines changed: 414 additions & 5107 deletions

File tree

.github/workflows/gds-integration-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,5 +39,5 @@ jobs:
3939
env:
4040
AURA_API_CLIENT_ID: 4V1HYCYEeoU4dSxThKnBeLvE2U4hSphx
4141
AURA_API_CLIENT_SECRET: ${{ secrets.AURA_API_CLIENT_SECRET }}
42-
AURA_API_TENANT_ID: eee7ec28-6b1a-5286-8e3a-3362cc1c4c78
42+
AURA_API_TENANT_ID: 3f8df5e7-4800-4d4f-ad1d-2d044dfd587c
4343
run: uv run pytest tests/ --include-neo4j-and-gds

changelog.md

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,9 @@
66

77
## Bug fixes
88

9-
- Fixed a bug in displaying the `Download`, `Selection` and `Layout` buttons, which was introduced in 1.2.0.
10-
119
## Improvements
1210

13-
- Support `neo4j.EagerResult` in the `from_neo4j` integration which is the default return type by `neo4j.Driver.execute_query()`.
14-
- Detect light/dark theme changes and adapt rendering unless theme was explicitly set. Before the theme would only be checked on the first render.
15-
11+
* Support Aura Graph Analytics
12+
* Support `gds.v2` endpoints
1613

1714
## Other changes

examples/gds-example.ipynb

Lines changed: 125 additions & 4906 deletions
Large diffs are not rendered by default.

examples/neo4j-example.ipynb

Lines changed: 13 additions & 11 deletions
Large diffs are not rendered by default.

justfile

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,25 @@ py-test-gds:
1818
trap "cd $ENV_DIR && docker compose down" EXIT
1919
cd $ENV_DIR && docker compose up -d
2020
cd -
21+
cd python-wrapper && \
2122
NEO4J_URI=bolt://localhost:7687 \
22-
NEO4J_USER=neo4j \
23+
NEO4J_USERNAME=neo4j \
2324
NEO4J_PASSWORD=password \
2425
NEO4J_DB=neo4j \
25-
cd python-wrapper && uv run --group dev --extra gds pytest tests --include-neo4j-and-gds
26+
uv run --group dev --extra gds pytest tests --include-neo4j-and-gds
2627
cd ..
2728
29+
30+
# this expects the local compose setup to be running.
31+
py-test-gds-sessions filter="":
32+
#!/usr/bin/env bash
33+
cd python-wrapper && \
34+
GDS_SESSION_URI=bolt://localhost:7688 \
35+
NEO4J_URI=bolt://localhost:7687 \
36+
NEO4J_USERNAME=neo4j \
37+
NEO4J_PASSWORD=password \
38+
uv run --group dev --extra gds pytest tests --include-neo4j-and-gds {{ if filter != "" { "-k '" + filter + "'" } else { "" } }}
39+
2840
local-neo4j-setup:
2941
#!/usr/bin/env bash
3042
set -e

python-wrapper/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Alternatively, you can export the output to a file and view it in a web browser.
1616
The package wraps the [Neo4j Visualization JavaScript library (NVL)](https://neo4j.com/docs/nvl/current/).
1717

1818

19-
![Example Graph](https://github.com/neo4j/python-graph-visualization/blob/main/examples/example_graph.png)
19+
![Example Graph](https://raw.githubusercontent.com/neo4j/python-graph-visualization/main/examples/example_graph.png)
2020

2121

2222
## Some notable features

python-wrapper/pyproject.toml

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ requires-python = ">=3.10"
4242

4343
[project.optional-dependencies]
4444
pandas = ["pandas>=2, <3", "pandas-stubs>=2, <3"]
45-
gds = ["graphdatascience>=1, <2"]
45+
gds = ["graphdatascience>=1.22, <2"]
4646
neo4j = ["neo4j"]
4747
snowflake = ["snowflake-snowpark-python>=1, <2"]
4848

@@ -60,7 +60,8 @@ dev = [
6060
"streamlit==1.57.0",
6161
"matplotlib>=3.9.4",
6262
"jupyterlab>=4.5.7",
63-
"anywidget[dev]"
63+
"anywidget[dev]",
64+
"python-dotenv"
6465
]
6566
docs = [
6667
"sphinx==8.1.3",
@@ -76,9 +77,9 @@ notebook = [
7677
"palettable>=3.3.3",
7778
"matplotlib>=3.9.4",
7879
"snowflake-snowpark-python==1.50.0",
79-
"python-dotenv",
8080
"requests",
8181
"marimo",
82+
"python-dotenv"
8283
]
8384

8485
[project.urls]
@@ -113,7 +114,10 @@ markers = [
113114
]
114115
filterwarnings = [
115116
"error",
116-
"ignore:Jupyter is migrating its paths to use standard platformdirs:DeprecationWarning"
117+
"ignore:Jupyter is migrating its paths to use standard platformdirs:DeprecationWarning",
118+
# snowflake vendors an older `requests` whose dependency check rejects the chardet
119+
# version pulled in transitively by the notebook group. Harmless; ignore it.
120+
"ignore:.*doesn't match a supported version:snowflake.connector.vendored.requests.exceptions.RequestsDependencyWarning"
117121
]
118122

119123
[tool.ruff]
@@ -174,9 +178,3 @@ exclude = [
174178
]
175179
plugins = ['pydantic.mypy']
176180
untyped_calls_exclude=["nbconvert"]
177-
178-
[tool.marimo.runtime]
179-
output_max_bytes = 20_000_000
180-
#
181-
#[tool.marimo.server]
182-
#follow_symlink = true

python-wrapper/src/neo4j_viz/gds.py

Lines changed: 47 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22

33
import warnings
44
from itertools import chain
5-
from typing import Optional, cast
5+
from typing import Collection, Optional
66
from uuid import uuid4
77

88
import pandas as pd
99
from graphdatascience import Graph, GraphDataScience
10+
from graphdatascience.graph.v2 import GraphV2
11+
from graphdatascience.session import AuraGraphDataScience
1012

1113
from neo4j_viz.colors import NEO4J_COLORS_DISCRETE, ColorSpace
1214

@@ -15,48 +17,52 @@
1517

1618

1719
def _fetch_node_dfs(
18-
gds: GraphDataScience,
19-
G: Graph,
20+
gds: GraphDataScience | AuraGraphDataScience,
21+
G: GraphV2,
2022
node_properties_by_label: dict[str, list[str]],
21-
node_labels: list[str],
23+
node_labels: Collection[str],
2224
additional_db_node_properties: list[str],
2325
) -> dict[str, pd.DataFrame]:
2426
return {
25-
lbl: gds.graph.nodeProperties.stream(
27+
lbl: gds.v2.graph.node_properties.stream(
2628
G,
2729
node_properties=node_properties_by_label[lbl],
2830
node_labels=[lbl],
29-
separate_property_columns=True,
3031
db_node_properties=additional_db_node_properties,
3132
)
3233
for lbl in node_labels
3334
}
3435

3536

36-
def _fetch_rel_dfs(gds: GraphDataScience, G: Graph) -> list[pd.DataFrame]:
37-
rel_types = G.relationship_types()
38-
39-
rel_props = {rel_type: G.relationship_properties(rel_type) for rel_type in rel_types}
37+
def _fetch_rel_dfs(gds: GraphDataScience | AuraGraphDataScience, G: GraphV2) -> list[pd.DataFrame]:
38+
rel_props = G.relationship_properties()
4039

4140
rel_dfs: list[pd.DataFrame] = []
41+
4242
# Have to call per stream per relationship type as there was a bug in GDS < 2.21
4343
for rel_type, props in rel_props.items():
44-
assert isinstance(props, list)
45-
if len(props) > 0:
46-
rel_df = gds.graph.relationshipProperties.stream(
47-
G, relationship_types=rel_type, relationship_properties=list(props), separate_property_columns=True
44+
rel_df = gds.v2.graph.relationships.stream(
45+
G, relationship_types=[rel_type], relationship_properties=list(props)
46+
)
47+
48+
# there was a bug in the v2 endpoints in GDS (1.22) where for dataframe would have the incorrect shape
49+
if "propertyValue" and "relationshipProperty" in rel_df.keys():
50+
rel_df = rel_df.pivot(
51+
index=["sourceNodeId", "targetNodeId", "relationshipType"],
52+
columns="relationshipProperty",
53+
values="propertyValue",
4854
)
49-
else:
50-
rel_df = gds.graph.relationships.stream(G, relationship_types=[rel_type])
55+
rel_df = rel_df.reset_index()
56+
rel_df.columns.name = None
5157

5258
rel_dfs.append(rel_df)
5359

5460
return rel_dfs
5561

5662

5763
def from_gds(
58-
gds: GraphDataScience,
59-
G: Graph,
64+
gds: GraphDataScience | AuraGraphDataScience,
65+
G: Graph | GraphV2,
6066
node_properties: Optional[list[str]] = None,
6167
db_node_properties: Optional[list[str]] = None,
6268
max_node_count: int = 10_000,
@@ -76,9 +82,9 @@ def from_gds(
7682
7783
Parameters
7884
----------
79-
gds : GraphDataScience
80-
GraphDataScience object.
81-
G : Graph
85+
gds
86+
GraphDataScience object. AuraGraphDataScience object if using Aura Graph Analytics.
87+
G
8288
Graph object.
8389
node_properties : list[str], optional
8490
Additional properties to include in the visualization node, by default None which means that all node
@@ -91,50 +97,54 @@ def from_gds(
9197
"""
9298
if db_node_properties is None:
9399
db_node_properties = []
100+
if isinstance(G, Graph):
101+
G_v2 = gds.v2.graph.get(G.name())
102+
else:
103+
G_v2 = G
94104

95-
node_properties_from_gds = G.node_properties()
96-
assert isinstance(node_properties_from_gds, pd.Series)
97-
actual_node_properties: dict[str, list[str]] = cast(dict[str, list[str]], node_properties_from_gds.to_dict())
98-
all_actual_node_properties = list(chain.from_iterable(actual_node_properties.values()))
105+
gds_properties_per_label = G_v2.node_properties()
106+
all_gds_properties = list(chain.from_iterable(gds_properties_per_label.values()))
99107

100108
node_properties_by_label_sets: dict[str, set[str]] = dict()
101109
if node_properties is None:
102-
node_properties_by_label_sets = {k: set(v) for k, v in actual_node_properties.items()}
110+
node_properties_by_label_sets = {k: set(v) for k, v in gds_properties_per_label.items()}
103111
else:
104112
for prop in node_properties:
105-
if prop not in all_actual_node_properties:
113+
if prop not in all_gds_properties:
106114
raise ValueError(f"There is no node property '{prop}' in graph '{G.name()}'")
107115

108-
for label, props in actual_node_properties.items():
116+
for label, props in gds_properties_per_label.items():
109117
node_properties_by_label_sets[label] = {
110-
prop for prop in actual_node_properties[label] if prop in node_properties
118+
prop for prop in gds_properties_per_label[label] if prop in node_properties
111119
}
112120

113121
node_properties_by_label = {k: list(v) for k, v in node_properties_by_label_sets.items()}
114122

115-
node_count = G.node_count()
123+
node_count = G_v2.node_count()
116124
if node_count > max_node_count:
117125
warnings.warn(
118-
f"The '{G.name()}' projection's node count ({G.node_count()}) exceeds `max_node_count` ({max_node_count}), so subsampling will be applied. Increase `max_node_count` if needed"
126+
f"The '{G_v2.name()}' projection's node count ({G_v2.node_count()}) exceeds `max_node_count` ({max_node_count}), so subsampling will be applied. Increase `max_node_count` if needed"
119127
)
120128
sampling_ratio = float(max_node_count) / node_count
121129
sample_name = f"neo4j-viz_sample_{uuid4()}"
122-
G_fetched, _ = gds.graph.sample.rwr(sample_name, G, samplingRatio=sampling_ratio, nodeLabelStratification=True)
130+
G_fetched, _ = gds.v2.graph.sample.rwr(
131+
G_v2, sample_name, sampling_ratio=sampling_ratio, node_label_stratification=True
132+
)
123133
else:
124-
G_fetched = G
134+
G_fetched = G_v2
125135

126136
property_name = None
127137
try:
128138
# Since GDS does not allow us to only fetch node IDs, we add the degree property
129139
# as a temporary property to ensure that we have at least one property for each label to fetch
130140
if sum([len(props) == 0 for props in node_properties_by_label.values()]) > 0:
131141
property_name = f"neo4j-viz_property_{uuid4()}"
132-
gds.degree.mutate(G_fetched, mutateProperty=property_name)
142+
gds.v2.degree_centrality.mutate(G_fetched, mutate_property=property_name)
133143
for props in node_properties_by_label.values():
134144
props.append(property_name)
135145

136146
node_dfs = _fetch_node_dfs(
137-
gds, G_fetched, node_properties_by_label, G_fetched.node_labels(), db_node_properties
147+
gds, G_fetched, node_properties_by_label, node_properties_by_label.keys(), db_node_properties
138148
)
139149
if property_name is not None:
140150
for df in node_dfs.values():
@@ -145,7 +155,7 @@ def from_gds(
145155
if G_fetched.name() != G.name():
146156
G_fetched.drop()
147157
elif property_name is not None:
148-
gds.graph.nodeProperties.drop(G_fetched, node_properties=[property_name])
158+
gds.v2.graph.node_properties.drop(G_fetched, node_properties=[property_name])
149159

150160
for df in node_dfs.values():
151161
if property_name is not None and property_name in df.columns:
@@ -154,7 +164,7 @@ def from_gds(
154164
node_props_df = pd.concat(node_dfs.values(), ignore_index=True, axis=0).drop_duplicates(subset=["nodeId"])
155165

156166
for lbl, df in node_dfs.items():
157-
if "labels" in all_actual_node_properties:
167+
if "labels" in all_gds_properties:
158168
df.rename(columns={"labels": "__labels"}, inplace=True)
159169
df["labels"] = lbl
160170

0 commit comments

Comments
 (0)