diff --git a/.github/workflows/py-code-style.yml b/.github/workflows/py-code-style.yml index 59d2896..c9f5c61 100644 --- a/.github/workflows/py-code-style.yml +++ b/.github/workflows/py-code-style.yml @@ -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: @@ -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 + diff --git a/justfile b/justfile index 0330113..648d3cb 100644 --- a/justfile +++ b/justfile @@ -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 diff --git a/python-wrapper/src/neo4j_viz/_gds_compat.py b/python-wrapper/src/neo4j_viz/_gds_compat.py index d09ccb8..39935e6 100644 --- a/python-wrapper/src/neo4j_viz/_gds_compat.py +++ b/python-wrapper/src/neo4j_viz/_gds_compat.py @@ -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) @@ -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)) diff --git a/python-wrapper/tests/neo4j_and_gds/test_gds.py b/python-wrapper/tests/neo4j_and_gds/test_gds.py index dfbdeab..c3d7ae8 100644 --- a/python-wrapper/tests/neo4j_and_gds/test_gds.py +++ b/python-wrapper/tests/neo4j_and_gds/test_gds.py @@ -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 @@ -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)}") diff --git a/scripts/checkstyle.sh b/scripts/checkstyle.sh index 3bf374f..2cc5e5f 100755 --- a/scripts/checkstyle.sh +++ b/scripts/checkstyle.sh @@ -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