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
30 changes: 30 additions & 0 deletions .github/workflows/py-code-style.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ on:
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:

permissions:
contents: read

# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
code-style:
Expand All @@ -37,3 +40,30 @@ jobs:

- name: Check code style
run: source .venv/bin/activate && cd ${GITHUB_WORKSPACE} && ./scripts/checkstyle.sh

# Type-check the v1 (1.22) side of the GDS compat surface.
code-style-gds-v1:
runs-on: ubuntu-latest

defaults:
run:
working-directory: python-wrapper

steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Install uv and set the Python version
uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
with:
python-version: "3.11"
enable-cache: true
- run: uv venv
- run: uv sync --group dev --extra pandas --extra neo4j --extra gds --extra snowflake
- run: uv pip install --python .venv/bin/python "graphdatascience==1.22"

- name: Check code style (GDS 1.22)
env:
UV_NO_SYNC: "1"
MYPY_TARGETS: "python-wrapper/src"
run: source .venv/bin/activate && cd ${GITHUB_WORKSPACE} && ./scripts/checkstyle.sh
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

15 changes: 15 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,21 @@ py-style:
just py-sync
./scripts/makestyle.sh && ./scripts/checkstyle.sh

# 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="1.22":
#!/usr/bin/env bash
set -e
just py-sync
uv pip install --python python-wrapper/.venv/bin/python "graphdatascience=={{version}}"
# 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
UV_NO_SYNC=1 MYPY_TARGETS=python-wrapper/src ./scripts/checkstyle.sh

py-test:
cd python-wrapper && uv sync --all-extras --group dev
cd python-wrapper && uv run --group dev pytest
Expand Down
65 changes: 59 additions & 6 deletions python-wrapper/src/neo4j_viz/_gds_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,22 @@

import importlib
import re
from typing import Any
from typing import Any, ContextManager, cast

from graphdatascience import Graph, GraphDataScience
from graphdatascience.procedure_surface.api.centrality.degree_endpoints import DegreeEndpoints
from graphdatascience.procedure_surface.arrow.catalog.catalog_arrow_endpoints import (
CatalogArrowEndpoints,
)
from graphdatascience.procedure_surface.cypher.catalog.catalog_cypher_endpoints import (
CatalogCypherEndpoints,
)
from graphdatascience.session import AuraGraphDataScience
from graphdatascience.version import __version__ as _gds_version

CatalogEndpoints = CatalogArrowEndpoints | CatalogCypherEndpoints
_GdsClient = GraphDataScience | AuraGraphDataScience


def _parse_major(version: str) -> int:
match = re.match(r"\s*(\d+)", version)
Expand All @@ -29,11 +41,52 @@ def _check_graph_type(G: Any) -> None:
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 _catalog(gds: _GdsClient) -> CatalogEndpoints:
"""Return the graph catalog endpoints for either client version.

In GDS v2 the catalog lives at ``gds.graph``; in v1 (1.22) the v2-compatible surface
is reached via ``gds.v2.graph``. The client is cast to ``Any`` for the attribute access
so the same code type-checks under either install (the two versions expose the catalog
under different attributes); the return type stays the shared concrete catalog union,
so callers are type-checked against whichever version is installed.
"""
if IS_GDS_2:
return cast("CatalogEndpoints", cast(Any, gds).graph)
return cast("CatalogEndpoints", cast(Any, gds).v2.graph)


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


def _project_native(
gds: _GdsClient, graph_name: str, node_labels: list[str], relationship_types: list[str]
) -> ContextManager[Graph]:
"""Native (label/type filter) projection for either client version.

In GDS v2 this is ``graph.project.native(...)`` (a property); in v1 it is the callable
``graph.project(...)``. The dispatch differs, so the catalog is cast to ``Any`` here —
but the helper signature stays concrete, so wrong call-site arguments (e.g. passing
``"*"`` instead of ``["*"]``) are still caught by mypy. The result is a context manager
yielding the projected graph in both versions.
"""
if IS_GDS_2:
return cast(
"ContextManager[Graph]",
cast(Any, _catalog(gds)).project.native(graph_name, node_labels, relationship_types),
)
return cast("ContextManager[Graph]", cast(Any, _catalog(gds)).project(graph_name, node_labels, relationship_types))


def _project_cypher(gds: _GdsClient, graph_name: str, query: str) -> ContextManager[Graph]:
"""Cypher projection for either client version.

In GDS v2 this is ``graph.project.cypher(...)`` (a property); in v1 it is the callable
``graph.project(name, query)``.
"""
if IS_GDS_2:
return cast("ContextManager[Graph]", cast(Any, _catalog(gds)).project.cypher(graph_name, query))
return cast("ContextManager[Graph]", cast(Any, _catalog(gds)).project(graph_name, query))
6 changes: 3 additions & 3 deletions python-wrapper/tests/neo4j_and_gds/test_gds.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from graphdatascience.session import AuraGraphDataScience

from neo4j_viz import Node
from neo4j_viz._gds_compat import _catalog
from neo4j_viz._gds_compat import _catalog, _project_cypher, _project_native
from neo4j_viz.gds import from_gds


Expand All @@ -26,9 +26,9 @@ def db_setup(gds: GraphDataScience | AuraGraphDataScience) -> Generator[None, No

def project_graph(gds: GraphDataScience | AuraGraphDataScience) -> Any:
if isinstance(gds, GraphDataScience):
return _catalog(gds).project("g2", "*", "*")
return _project_native(gds, "g2", ["*"], ["*"])
elif isinstance(gds, AuraGraphDataScience):
return _catalog(gds).project("g2", "MATCH (n)–->(m) RETURN gds.graph.project.remote(n, m)")
return _project_cypher(gds, "g2", "MATCH (n)–->(m) RETURN gds.graph.project.remote(n, m)")
raise Exception(f"Unsupported GDS type {type(gds)}")


Expand Down
5 changes: 4 additions & 1 deletion scripts/checkstyle.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ set -o pipefail

uv run --project "${PY_PROJECT}" python -m ruff check .
uv run --project "${PY_PROJECT}" python -m ruff format --check .
uv run --project "${PY_PROJECT}" python -m mypy --config-file "${PY_PROJECT}/pyproject.toml" .
# MYPY_TARGETS scopes mypy to a subset (e.g. `python-wrapper/src`) — used to type-check
# the v1 GDS compat surface under a pinned graphdatascience without pulling in test
# helpers that import v2-only modules.
uv run --project "${PY_PROJECT}" python -m mypy --config-file "${PY_PROJECT}/pyproject.toml" "${MYPY_TARGETS:-.}"


if [ "${SKIP_NOTEBOOKS:-false}" == "true" ]; then
Expand Down