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
2 changes: 1 addition & 1 deletion .github/workflows/gds-integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest]
gds-version: ["1.22", "2.0"]
gds-version: ["1.22", "latest"]
defaults:
run:
working-directory: python-wrapper
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/snowflake-integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,4 @@ jobs:
SNOWFLAKE_PASSWORD: ${{ secrets.SNOWFLAKE_PASSWORD }}
SNOWFLAKE_ROLE: ACCOUNTADMIN
SNOWFLAKE_WAREHOUSE: ${{ secrets.SNOWFLAKE_WAREHOUSE }}
run: uv run pytest tests/ --include-snowflake
run: uv run pytest tests/ --only-snowflake
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ This will spinup a Neo4j container with the GDS plugin installed.
To run tests requiring a Snowflake connection, execute:
```sh
cd python-wrapper/
pytest tests/ --include-snowflake
pytest tests/ --only-snowflake
```

### Project structure
Expand Down
1 change: 1 addition & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@
## Other changes

- Stop committing the built widget JS/CSS assets; they are now built by `just js-build` and in CI, and the shipped Streamlit stylesheet no longer duplicates the widget's.
- Update the GDS example notebook to the GDS client 2.x API and exercise it against the latest client in the integration tests.
13 changes: 7 additions & 6 deletions docs/antora/modules/ROOT/pages/integration/gds.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -36,21 +36,22 @@ We use the "pagerank" property to determine the size of the nodes, and the "comp
from graphdatascience import GraphDataScience
from neo4j_viz.gds import from_gds
gds = GraphDataScience(...)
G = gds.graph.project(...)
G = gds.graph.project.native(...)
# Compute the PageRank and Weakly Connected Components
gds.pageRank.mutate(G, mutateProperty="pagerank")
gds.wcc.mutate(G, mutateProperty="componentId")
gds.page_rank.mutate(G, mutate_property="pagerank")
gds.wcc.mutate(G, mutate_property="componentId")
# Import the projection into a `VisualizationGraph`
# Make sure to include `pagerank` and `componentId`
VG = from_gds(
gds,
G,
size_property="pagerank",
additional_node_properties=["componentId"],
node_properties=["pagerank", "componentId"],
)
# Size the nodes by their PageRank score
VG.resize_nodes(property="pagerank")
# Color the nodes by the `componentId` property, so that the nodes are
# colored by the connected component they belong to
VG.color_nodes(property="componentId")
----

See the link:{tutorials-docs-uri}/gds-example[Visualizing Neo4j Graph Data Science (GDS) Graphs tutorial] for a more extensive example.
See the link:{tutorials-docs-uri}/gds-example[Visualizing Neo4j Graph Data Science (GDS) Graphs tutorial] for a more extensive example.
10 changes: 5 additions & 5 deletions examples/gds-example.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@
"metadata": {},
"outputs": [],
"source": [
"G = gds.graph.load_cora(graph_name=\"cora\")"
"G = gds.graph.datasets.load_cora(graph_name=\"cora\")"
]
},
{
Expand All @@ -121,9 +121,9 @@
"outputs": [],
"source": [
"# Run some algorithms to use later for visualization\n",
"gds.nodeSimilarity.mutate(G, mutateRelationshipType=\"SIMILAR\", mutateProperty=\"similarity\")\n",
"gds.pageRank.mutate(G, mutateProperty=\"pagerank\")\n",
"gds.louvain.mutate(G, mutateProperty=\"componentId\")"
"gds.node_similarity.mutate(G, mutate_relationship_type=\"SIMILAR\", mutate_property=\"similarity\")\n",
"gds.page_rank.mutate(G, mutate_property=\"pagerank\")\n",
"gds.louvain.mutate(G, mutate_property=\"componentId\")"
]
},
{
Expand Down Expand Up @@ -286,7 +286,7 @@
"metadata": {},
"outputs": [],
"source": [
"%pip install matplotlib, palettable"
"%pip install palettable"
]
},
{
Expand Down
35 changes: 21 additions & 14 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,18 @@ py-style:
# Run Python style checks (ruff + mypy) against a pinned `graphdatascience` version, so
# the v1/v2 compat surface in _gds_compat is type-checked under that version. mypy is
# scoped to `src` because the test helpers import v2-only modules (covered by the default
# v2 gate); ruff runs on the whole tree as usual.
# example: just py-style-gds 1.22
py-style-gds version="2.0":
# v2 gate); ruff runs on the whole tree as usual. Pass `latest` to install the newest
# client instead of a pin.
# examples: just py-style-gds 1.22 / just py-style-gds latest
py-style-gds version="latest":
#!/usr/bin/env bash
set -e
just py-sync
uv pip install --python python-wrapper/.venv/bin/python "graphdatascience=={{version}}"
if [ "{{version}}" = "latest" ]; then
uv pip install --python python-wrapper/.venv/bin/python --upgrade graphdatascience
else
uv pip install --python python-wrapper/.venv/bin/python "graphdatascience=={{version}}"
fi
# UV_NO_SYNC stops `uv run` inside the style scripts from re-syncing (which would
# revert the pin back to the latest GDS).
UV_NO_SYNC=1 ./scripts/makestyle.sh
Expand All @@ -51,30 +56,32 @@ 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
# install a specific GDS client version (or `latest`) and run the GDS integration tests (used by CI)
# examples: just py-ci-test-gds 1.22 / just py-ci-test-gds latest
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
if [ "{{gds_version}}" = "latest" ]; then
uv pip install --upgrade graphdatascience
else
uv pip install "graphdatascience=={{gds_version}}"
fi
uv run pytest tests/ --only-neo4j-and-gds

py-test-gds:
#!/usr/bin/env bash
set -e
ENV_DIR="test-envs/neo4j-gds"
ENV_DIR="{{root_dir}}/test-envs/neo4j-gds"
trap "cd $ENV_DIR && docker compose down" EXIT
cd $ENV_DIR && docker compose up -d
cd -
cd python-wrapper && \
cd {{py_dir}} && \
NEO4J_URI=bolt://localhost:7687 \
NEO4J_USERNAME=neo4j \
NEO4J_PASSWORD=password \
NEO4J_DB=neo4j \
uv run --group dev --extra gds pytest tests --include-neo4j-and-gds
cd ..
uv run --group dev --extra gds pytest tests --only-neo4j-and-gds


# this expects the local compose setup to be running.
Expand All @@ -85,7 +92,7 @@ py-test-gds-sessions filter="":
NEO4J_URI=bolt://localhost:7687 \
NEO4J_USERNAME=neo4j \
NEO4J_PASSWORD=password \
uv run --group dev --extra gds pytest tests --include-neo4j-and-gds {{ if filter != "" { "-k '" + filter + "'" } else { "" } }}
uv run --group dev --extra gds pytest tests --only-neo4j-and-gds {{ if filter != "" { "-k '" + filter + "'" } else { "" } }}

local-neo4j-setup:
#!/usr/bin/env bash
Expand Down
41 changes: 31 additions & 10 deletions python-wrapper/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,21 +28,42 @@ def pytest_addoption(parser: pytest.Parser) -> None:
action="store_true",
help="include tests requiring a Neo4j instance with GDS running",
)
parser.addoption(
"--only-neo4j-and-gds",
action="store_true",
help="run only tests requiring a Neo4j instance with GDS running",
)
parser.addoption(
"--include-snowflake",
action="store_true",
help="include tests requiring a Snowflake connection",
)
parser.addoption(
"--only-snowflake",
action="store_true",
help="run only tests requiring a Snowflake connection",
)


def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
if not config.getoption("--include-neo4j-and-gds"):
skip = pytest.mark.skip(reason="skipping since requiring Neo4j instance with GDS running")
for item in items:
if "requires_neo4j_and_gds" in item.keywords:
item.add_marker(skip)
if not config.getoption("--include-snowflake"):
skip = pytest.mark.skip(reason="skipping since requiring a Snowflake connection")
for item in items:
if "requires_snowflake" in item.keywords:
item.add_marker(skip)
only_neo4j_and_gds = config.getoption("--only-neo4j-and-gds")
only_snowflake = config.getoption("--only-snowflake")
# `--only-X` implies `--include-X` for that integration.
include_neo4j_and_gds = config.getoption("--include-neo4j-and-gds") or only_neo4j_and_gds
include_snowflake = config.getoption("--include-snowflake") or only_snowflake
only_integration = only_neo4j_and_gds or only_snowflake

skip_neo4j_and_gds = pytest.mark.skip(reason="skipping since requiring Neo4j instance with GDS running")
skip_snowflake = pytest.mark.skip(reason="skipping since requiring a Snowflake connection")
skip_non_integration = pytest.mark.skip(reason="skipping since only integration tests were requested")

for item in items:
requires_neo4j_and_gds = "requires_neo4j_and_gds" in item.keywords
requires_snowflake = "requires_snowflake" in item.keywords

if requires_neo4j_and_gds and not include_neo4j_and_gds:
item.add_marker(skip_neo4j_and_gds)
elif requires_snowflake and not include_snowflake:
item.add_marker(skip_snowflake)
elif only_integration and not (requires_neo4j_and_gds or requires_snowflake):
item.add_marker(skip_non_integration)
17 changes: 13 additions & 4 deletions python-wrapper/tests/neo4j_and_gds/test_notebooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,21 @@
DEFAULT_SESSION_NAME = "neo4j-viz-gds-example"


@pytest.mark.requires_neo4j_and_gds
def test_neo4j(gds: GraphDataScience | AuraGraphDataScience) -> None:
# The `gds` fixture provisions the Aura DB / GDS session and sets the NEO4J_* env vars
# that the notebooks read to connect.
load_dotenv(os.environ.get("ENV_FILE"))

run_notebooks(["neo4j-example.ipynb"])


@pytest.mark.requires_neo4j_and_gds
@pytest.mark.skipif(
GDS_VERSION >= SemanticVersion(2, 0, 0),
reason="Notebooks are only supported on GDS < 2",
GDS_VERSION < SemanticVersion(2, 0, 0),
reason="The GDS example notebook uses the GDS client 2.x syntax",
)
def test_neo4j(gds: GraphDataScience | AuraGraphDataScience) -> None:
def test_gds(gds: GraphDataScience | AuraGraphDataScience) -> None:
# The `gds` fixture provisions the Aura DB / GDS session and sets the NEO4J_* env vars
# that the notebooks read to connect.
load_dotenv(os.environ.get("ENV_FILE"))
Expand All @@ -36,6 +45,6 @@ def test_neo4j(gds: GraphDataScience | AuraGraphDataScience) -> None:
session_name = f"{DEFAULT_SESSION_NAME}-ci-{run_id()}-{__version__}"

run_notebooks(
["neo4j-example.ipynb", "gds-example.ipynb"],
["gds-example.ipynb"],
replacements={DEFAULT_SESSION_NAME: session_name},
)
6 changes: 3 additions & 3 deletions python-wrapper/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

77 changes: 60 additions & 17 deletions scripts/release/prerelease.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
1. The version about to be released is printed.
2. changelog.md has at least one entry.
3. That version is not already published on PyPI.
4. All GitHub Actions runs on the latest commit of the main branch passed.
4. The release-gating GitHub Actions runs on the latest commit of the main
branch passed.

Requires: gh (authenticated) on PATH. Run from anywhere inside the repo.

Expand All @@ -21,6 +22,7 @@
import sys
import urllib.error
import urllib.request
from dataclasses import dataclass
from typing import Any

from _common import (
Expand All @@ -36,6 +38,44 @@
# Conclusions that do not block a release.
_OK_CONCLUSIONS = {"success", "neutral", "skipped"}

# Workflow runs that must not gate a release:
# * "Release" is the workflow running this check; a previous attempt (e.g. a
# cancelled or failed re-run) must not block a new one.
# * The `dynamic` event covers GitHub-managed runs (Dependabot Updates,
# Dependency Graph, CodeQL) which are unrelated to release readiness.
_IGNORED_WORKFLOWS = {"Release"}
_IGNORED_EVENTS = {"dynamic"}


@dataclass(frozen=True)
class WorkflowRun:
"""A GitHub Actions workflow run on the commit being released."""

database_id: int
workflow_name: str
status: str
conclusion: str | None
event: str
url: str

@classmethod
def from_json(cls, data: dict[str, Any]) -> WorkflowRun:
return cls(
database_id=data["databaseId"],
workflow_name=data["workflowName"],
status=data["status"],
conclusion=data["conclusion"],
event=data["event"],
url=data["url"],
)

def gates_release(self) -> bool:
"""Whether this run should be checked before releasing."""
return (
self.workflow_name not in _IGNORED_WORKFLOWS
and self.event not in _IGNORED_EVENTS
)


def is_on_pypi(version: str) -> bool:
"""Return True if PACKAGE==version is already published on PyPI."""
Expand Down Expand Up @@ -76,8 +116,12 @@ def latest_main_sha() -> str:
return out.strip()


def workflow_runs(sha: str) -> list[dict[str, Any]]:
"""Workflow runs for `sha`, excluding the current run when on GitHub Actions."""
def workflow_runs(sha: str) -> list[WorkflowRun]:
"""Release-gating workflow runs for `sha`.

Excludes the run currently executing on GitHub Actions and runs that do not
gate a release (see `_IGNORED_WORKFLOWS` / `_IGNORED_EVENTS`).
"""
out = _gh(
[
"run",
Expand All @@ -87,14 +131,16 @@ def workflow_runs(sha: str) -> list[dict[str, Any]]:
"--limit",
"100",
"--json",
"databaseId,workflowName,status,conclusion",
"databaseId,workflowName,status,conclusion,event,url",
]
)
runs: list[dict[str, Any]] = json.loads(out)
current = os.environ.get("GITHUB_RUN_ID")
if current:
runs = [r for r in runs if str(r.get("databaseId")) != current]
return runs
all_runs = [WorkflowRun.from_json(raw) for raw in json.loads(out)]
gating_runs = [run for run in all_runs if run.gates_release()]

current_run_id = os.environ.get("GITHUB_RUN_ID")
if current_run_id is None:
return gating_runs
return [run for run in gating_runs if str(run.database_id) != current_run_id]


def check_changelog() -> bool:
Expand Down Expand Up @@ -151,17 +197,14 @@ def main() -> int:

failed = False
for run in runs:
name = run.get("workflowName", "<unknown>")
status = run.get("status")
conclusion = run.get("conclusion") or ""
if status != "completed":
print(red(f" ✗ {name}: still {status}"))
if run.status != "completed":
print(red(f" ✗ {run.workflow_name}: still {run.status}"))
failed = True
elif conclusion not in _OK_CONCLUSIONS:
print(red(f" ✗ {name}: {conclusion}"))
elif run.conclusion not in _OK_CONCLUSIONS:
print(red(f" ✗ {run.workflow_name}: {run.conclusion} ({run.url})"))
failed = True
else:
print(green(f" ✓ {name}: {conclusion}"))
print(green(f" ✓ {run.workflow_name}: {run.conclusion}"))

if failed:
print(
Expand Down
Loading