From 72c0b65a52719cbe87b4cef287e025f47badca1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Fri, 9 Oct 2026 18:11:10 +0200 Subject: [PATCH 1/2] Use latest gds client version per default --- .github/workflows/gds-integration-tests.yml | 2 +- .../workflows/snowflake-integration-tests.yml | 2 +- CONTRIBUTING.md | 2 +- changelog.md | 1 + .../modules/ROOT/pages/integration/gds.adoc | 13 +++--- examples/gds-example.ipynb | 10 ++--- justfile | 35 +++++++++------- python-wrapper/tests/conftest.py | 41 ++++++++++++++----- .../tests/neo4j_and_gds/test_notebooks.py | 17 ++++++-- python-wrapper/uv.lock | 6 +-- 10 files changed, 84 insertions(+), 45 deletions(-) diff --git a/.github/workflows/gds-integration-tests.yml b/.github/workflows/gds-integration-tests.yml index 98eea1e9..728e5333 100644 --- a/.github/workflows/gds-integration-tests.yml +++ b/.github/workflows/gds-integration-tests.yml @@ -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 diff --git a/.github/workflows/snowflake-integration-tests.yml b/.github/workflows/snowflake-integration-tests.yml index 50272d34..56941672 100644 --- a/.github/workflows/snowflake-integration-tests.yml +++ b/.github/workflows/snowflake-integration-tests.yml @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 322c27f2..b4f384a1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/changelog.md b/changelog.md index 9b2bbc87..c16e0f6a 100644 --- a/changelog.md +++ b/changelog.md @@ -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. diff --git a/docs/antora/modules/ROOT/pages/integration/gds.adoc b/docs/antora/modules/ROOT/pages/integration/gds.adoc index 46e80935..e64148dc 100644 --- a/docs/antora/modules/ROOT/pages/integration/gds.adoc +++ b/docs/antora/modules/ROOT/pages/integration/gds.adoc @@ -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. \ No newline at end of file +See the link:{tutorials-docs-uri}/gds-example[Visualizing Neo4j Graph Data Science (GDS) Graphs tutorial] for a more extensive example. diff --git a/examples/gds-example.ipynb b/examples/gds-example.ipynb index c0a4d199..c4baedad 100644 --- a/examples/gds-example.ipynb +++ b/examples/gds-example.ipynb @@ -104,7 +104,7 @@ "metadata": {}, "outputs": [], "source": [ - "G = gds.graph.load_cora(graph_name=\"cora\")" + "G = gds.graph.datasets.load_cora(graph_name=\"cora\")" ] }, { @@ -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\")" ] }, { @@ -286,7 +286,7 @@ "metadata": {}, "outputs": [], "source": [ - "%pip install matplotlib, palettable" + "%pip install palettable" ] }, { diff --git a/justfile b/justfile index 96379e41..f5b7a017 100644 --- a/justfile +++ b/justfile @@ -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 @@ -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. @@ -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 diff --git a/python-wrapper/tests/conftest.py b/python-wrapper/tests/conftest.py index f58c5eeb..3ca22656 100644 --- a/python-wrapper/tests/conftest.py +++ b/python-wrapper/tests/conftest.py @@ -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) diff --git a/python-wrapper/tests/neo4j_and_gds/test_notebooks.py b/python-wrapper/tests/neo4j_and_gds/test_notebooks.py index 9f2d2cfe..08ce15f6 100644 --- a/python-wrapper/tests/neo4j_and_gds/test_notebooks.py +++ b/python-wrapper/tests/neo4j_and_gds/test_notebooks.py @@ -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")) @@ -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}, ) diff --git a/python-wrapper/uv.lock b/python-wrapper/uv.lock index 58c5c0b7..e637481c 100644 --- a/python-wrapper/uv.lock +++ b/python-wrapper/uv.lock @@ -1270,7 +1270,7 @@ wheels = [ [[package]] name = "graphdatascience" -version = "2.0" +version = "2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "multimethod", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1287,9 +1287,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f3/3e/04ec09f9bbe62ae04e295e6a59d06acc2a55cba13e355a7db8e026f65667/graphdatascience-2.0.tar.gz", hash = "sha256:f583677a0fe1d10346381a45a6f06a259bf9ce7d82ff40d9c42c793e1b9045aa", size = 1774568, upload-time = "2026-09-18T12:18:11.004Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/a5/4ff0502b014a9c86965e6edeef96a2c59d65ae7d56c553a1c259cbdb11bf/graphdatascience-2.1.tar.gz", hash = "sha256:cde3743357c478d6e562ac7c52d82cd378550b855b41c1f40868060eb4ffaf9e", size = 1781016, upload-time = "2026-09-28T14:37:11.201Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/b4/904997df96df66c85f371cca7d129bfa71ded6cfc9f10de4e3c37eedb57b/graphdatascience-2.0-py3-none-any.whl", hash = "sha256:5c19253cb04776e65e48ed88837c113f047bdf2416f6377d68de5b65ae445dec", size = 2084309, upload-time = "2026-09-18T12:18:08.905Z" }, + { url = "https://files.pythonhosted.org/packages/22/64/f05cfdc6595d5b8bc80547026d345bad7bec4fbcb46369387524d395734b/graphdatascience-2.1-py3-none-any.whl", hash = "sha256:bf93b3ea9869247a3affdb0355efb66e036673c0a7f7cba30431ce8ae34c90f8", size = 2097484, upload-time = "2026-09-28T14:37:09.405Z" }, ] [[package]] From e678aa4974e6f7cb087c5093a10c75502ed75948 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Fri, 9 Oct 2026 18:19:11 +0200 Subject: [PATCH 2/2] Update pre-release script ignore dependabot script + a prior failed release --- scripts/release/prerelease.py | 77 +++++++++++++++++++++++++++-------- 1 file changed, 60 insertions(+), 17 deletions(-) diff --git a/scripts/release/prerelease.py b/scripts/release/prerelease.py index d600833f..cd5f993b 100755 --- a/scripts/release/prerelease.py +++ b/scripts/release/prerelease.py @@ -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. @@ -21,6 +22,7 @@ import sys import urllib.error import urllib.request +from dataclasses import dataclass from typing import Any from _common import ( @@ -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.""" @@ -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", @@ -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: @@ -151,17 +197,14 @@ def main() -> int: failed = False for run in runs: - name = run.get("workflowName", "") - 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(