From 27cf49b3b2b4244767038cf8f53a7365a01f665d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Fri, 28 Mar 2025 10:49:10 +0100 Subject: [PATCH 01/10] Add docker compose for neo4j+gds container --- CONTRIBUTING.md | 16 +++++++++++++--- test-envs/neo4j-gds/compose.yml | 13 +++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) create mode 100755 test-envs/neo4j-gds/compose.yml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 889ca094..f5bd2c43 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -94,16 +94,26 @@ pytest python-wrapper/tests Additionally, there are integration tests that require an external data source. These require additional setup and configuration, such as environment variables specifying connection details. -To run tests requiring a Neo4j DB instance with GDS installed, execute: + +For a local Neo4j instance with GDS installed, execute: +```sh +cd test-envs/neo4j-gds +docker compose up -d +``` + +To run tests requiring a Neo4j DB instance with GDS installed, execute: ```sh -pytest python-wrapper/tests --include-neo4j-and-gds +export NEO4J_URI=localhost:7687 # or credentials for Aura API +cd python-wrapper/ +pytest tests --include-neo4j-and-gds ``` To run tests requiring a Snowflake connection, execute: ```sh -pytest python-wrapper/tests --include-snowflake +cd python-wrapper/ +pytest tests/ --include-snowflake ``` diff --git a/test-envs/neo4j-gds/compose.yml b/test-envs/neo4j-gds/compose.yml new file mode 100755 index 00000000..ebdcca39 --- /dev/null +++ b/test-envs/neo4j-gds/compose.yml @@ -0,0 +1,13 @@ +services: + neo4j: + image: neo4j:latest + environment: + - NEO4J_AUTH=none # for testing + + - NEO4J_PLUGINS=["graph-data-science"] + - NEO4J_dbms_security_procedures_allowlist=gds.* + - NEO4J_dbms_security_procedures_unrestricted=gds.* + ports: + - "7474:7474" + - "7687:7687" + restart: always From db4809a811c34a581b19fee5cb87108ef4e0dc56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Fri, 28 Mar 2025 12:41:44 +0100 Subject: [PATCH 02/10] Add convenience for neo4j graph --- docs/source/api-reference/from_neo4j.rst | 5 + examples/neo4j-nvl-example.ipynb | 183 +++++++++-------------- python-wrapper/README.md | 3 +- python-wrapper/pyproject.toml | 1 + python-wrapper/src/neo4j_viz/neo4j.py | 100 +++++++++++++ python-wrapper/tests/conftest.py | 24 ++- python-wrapper/tests/test_gds.py | 2 - python-wrapper/tests/test_neo4j.py | 38 +++++ 8 files changed, 237 insertions(+), 119 deletions(-) create mode 100644 docs/source/api-reference/from_neo4j.rst create mode 100644 python-wrapper/src/neo4j_viz/neo4j.py create mode 100644 python-wrapper/tests/test_neo4j.py diff --git a/docs/source/api-reference/from_neo4j.rst b/docs/source/api-reference/from_neo4j.rst new file mode 100644 index 00000000..f079da26 --- /dev/null +++ b/docs/source/api-reference/from_neo4j.rst @@ -0,0 +1,5 @@ +Import from Neo4j +------------------------------------------------ + +.. automodule:: neo4j_viz.neo4j + :members: diff --git a/examples/neo4j-nvl-example.ipynb b/examples/neo4j-nvl-example.ipynb index 6e72d7b9..3b0240ff 100644 --- a/examples/neo4j-nvl-example.ipynb +++ b/examples/neo4j-nvl-example.ipynb @@ -73,30 +73,45 @@ " driver.execute_query(\n", " \"\"\"\n", " CREATE\n", - " (home:Page {name:'Home'}),\n", - " (about:Page {name:'About'}),\n", - " (product:Page {name:'Product'}),\n", - " (links:Page {name:'Links'}),\n", - " (a:Page {name:'Site A'}),\n", - " (b:Page {name:'Site B'}),\n", - " (c:Page {name:'Site C'}),\n", - " (d:Page {name:'Site D'}),\n", + " (dan:Person {name: 'Dan'}),\n", + " (annie:Person {name: 'Annie'}),\n", + " (matt:Person {name: 'Matt'}),\n", + " (jeff:Person {name: 'Jeff'}),\n", + " (brie:Person {name: 'Brie'}),\n", + " (elsa:Person {name: 'Elsa'}),\n", "\n", - " (home)-[:LINKS {weight: 0.2}]->(about),\n", - " (home)-[:LINKS {weight: 0.2}]->(links),\n", - " (home)-[:LINKS {weight: 0.6}]->(product),\n", - " (about)-[:LINKS {weight: 1.0}]->(home),\n", - " (product)-[:LINKS {weight: 1.0}]->(home),\n", - " (a)-[:LINKS {weight: 1.0}]->(home),\n", - " (b)-[:LINKS {weight: 1.0}]->(home),\n", - " (c)-[:LINKS {weight: 1.0}]->(home),\n", - " (d)-[:LINKS {weight: 1.0}]->(home),\n", - " (links)-[:LINKS {weight: 0.8}]->(home),\n", - " (links)-[:LINKS {weight: 0.05}]->(a),\n", - " (links)-[:LINKS {weight: 0.05}]->(b),\n", - " (links)-[:LINKS {weight: 0.05}]->(c),\n", - " (links)-[:LINKS {weight: 0.05}]->(d);\n", - " \"\"\"\n", + " (cookies:Product {name: 'Cookies'}),\n", + " (tomatoes:Product {name: 'Tomatoes'}),\n", + " (cucumber:Product {name: 'Cucumber'}),\n", + " (celery:Product {name: 'Celery'}),\n", + " (kale:Product {name: 'Kale'}),\n", + " (milk:Product {name: 'Milk'}),\n", + " (chocolate:Product {name: 'Chocolate'}),\n", + "\n", + " (dan)-[:BUYS {amount: 1.2}]->(cookies),\n", + " (dan)-[:BUYS {amount: 3.2}]->(milk),\n", + " (dan)-[:BUYS {amount: 2.2}]->(chocolate),\n", + "\n", + " (annie)-[:BUYS {amount: 1.2}]->(cucumber),\n", + " (annie)-[:BUYS {amount: 3.2}]->(milk),\n", + " (annie)-[:BUYS {amount: 3.2}]->(tomatoes),\n", + "\n", + " (matt)-[:BUYS {amount: 3}]->(tomatoes),\n", + " (matt)-[:BUYS {amount: 2}]->(kale),\n", + " (matt)-[:BUYS {amount: 1}]->(cucumber),\n", + "\n", + " (jeff)-[:BUYS {amount: 3}]->(cookies),\n", + " (jeff)-[:BUYS {amount: 2}]->(milk),\n", + "\n", + " (brie)-[:BUYS {amount: 1}]->(tomatoes),\n", + " (brie)-[:BUYS {amount: 2}]->(milk),\n", + " (brie)-[:BUYS {amount: 2}]->(kale),\n", + " (brie)-[:BUYS {amount: 3}]->(cucumber),\n", + " (brie)-[:BUYS {amount: 0.3}]->(celery),\n", + "\n", + " (elsa)-[:BUYS {amount: 3}]->(chocolate),\n", + " (elsa)-[:BUYS {amount: 3}]->(milk)\n", + " \"\"\"\n", " )" ] }, @@ -119,14 +134,16 @@ " driver.verify_connectivity()\n", "\n", " result = driver.execute_query(\n", - " \"MATCH (n)-[r]-(m) RETURN n,r,m LIMIT 10\",\n", + " \"MATCH (n)-[r]->(m) RETURN n,r,m\",\n", " database_=\"neo4j\",\n", " result_transformer_=Result.graph,\n", " )\n", "\n", " result\n", "\n", - "result" + "print(\n", + " f\"Result graph has: {len(result.nodes)} nodes, {len(result.relationships)} relationships\"\n", + ")" ] }, { @@ -137,38 +154,6 @@ "Below we map the graph object's nodes and relationships to the correct format for NVL. The Node and Relationship type documentation can be found in NVL's docs: https://neo4j.com/docs/nvl/current/core-library/#_nodes" ] }, - { - "cell_type": "code", - "execution_count": null, - "id": "d935b3d4", - "metadata": {}, - "outputs": [], - "source": [ - "from neo4j_viz import Node, Relationship\n", - "\n", - "\n", - "def map_nodes(n) -> Node:\n", - " caption = \"\"\n", - " for label in n.labels:\n", - " caption += label\n", - " return Node(id=str(n.element_id), caption=caption)\n", - "\n", - "\n", - "def map_relationships(r):\n", - " return Relationship(\n", - " id=str(r.element_id),\n", - " source=str(r.start_node.element_id),\n", - " target=str(r.end_node.element_id),\n", - " caption=str(r.type),\n", - " )\n", - "\n", - "\n", - "nodes = list(map(map_nodes, result.nodes))\n", - "relationships = list(map(map_relationships, result.relationships))\n", - "\n", - "nodes" - ] - }, { "cell_type": "markdown", "id": "a28bd5aa", @@ -179,32 +164,8 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 14, "id": "2322065c", - "metadata": {}, - "outputs": [], - "source": [ - "from neo4j_viz import VisualizationGraph\n", - "\n", - "VG = VisualizationGraph(nodes=nodes, relationships=relationships)\n", - "\n", - "VG" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "46aaa12317299ad0", - "metadata": {}, - "outputs": [], - "source": [ - "VG.color_nodes(\"caption\")" - ] - }, - { - "cell_type": "code", - "execution_count": 20, - "id": "e8b0f4c6", "metadata": { "tags": [ "preserve-output" @@ -215,14 +176,14 @@ "data": { "text/html": [ "\n", - "
\n", + "
\n", " \n", @@ -282,15 +249,13 @@ "" ] }, - "execution_count": 21, + "execution_count": 16, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "from neo4j_viz import Renderer\n", - "\n", - "VG.render(renderer=Renderer.WEB_GL)" + "VG.render()" ] }, { @@ -305,16 +270,14 @@ "cell_type": "code", "execution_count": null, "id": "fe49b6a242e8fff0", - "metadata": { - "tags": [ - "teardown" - ] - }, + "metadata": {}, "outputs": [], "source": [ "with GraphDatabase.driver(URI, auth=auth) as driver:\n", - " with driver.session() as session:\n", - " session.run(\"MATCH (n) DETACH DELETE n\")" + " result = driver.execute_query(\n", + " \"MATCH (n:Person|Product) DETACH DELETE n RETURN count(n) \"\n", + " )\n", + " print(result.summary.counters)" ] } ], diff --git a/python-wrapper/README.md b/python-wrapper/README.md index dadd7edd..94e8dbc5 100644 --- a/python-wrapper/README.md +++ b/python-wrapper/README.md @@ -25,6 +25,7 @@ Proper documentation is forthcoming. * Easy to import graphs represented as: * projections in the Neo4j Graph Data Science (GDS) library + * graphs from Neo4j query results * pandas DataFrames * Node features: * Sizing @@ -112,4 +113,4 @@ This will return a `IPython.display.HTML` object that can be rendered in a Jupyt ### Examples For more extensive examples, including how to import graphs from Neo4j GDS projections and Pandas DataFrames, -checkout the [tutorials chapter](https://neo4j.com/docs/nvl-python/preview/tutorials/index.html) in the documentation. \ No newline at end of file +checkout the [tutorials chapter](https://neo4j.com/docs/nvl-python/preview/tutorials/index.html) in the documentation. diff --git a/python-wrapper/pyproject.toml b/python-wrapper/pyproject.toml index 5ead667a..e589bd58 100644 --- a/python-wrapper/pyproject.toml +++ b/python-wrapper/pyproject.toml @@ -59,6 +59,7 @@ docs = [ ] pandas = ["pandas>=2, <3", "pandas-stubs>=2, <3"] gds = ["graphdatascience>=1, <2"] # not compatible yet with Python 3.13 +neo4j = ["neo4j"] notebook = [ "ipykernel==6.29.5", "pykernel==0.1.6", diff --git a/python-wrapper/src/neo4j_viz/neo4j.py b/python-wrapper/src/neo4j_viz/neo4j.py new file mode 100644 index 00000000..883dab26 --- /dev/null +++ b/python-wrapper/src/neo4j_viz/neo4j.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from typing import Optional, Union + +import neo4j.graph +from neo4j import Result + +from neo4j_viz.node import Node +from neo4j_viz.relationship import Relationship +from neo4j_viz.visualization_graph import VisualizationGraph + + +def from_neo4j( + result: Union[neo4j.graph.Graph, Result], + size_property: Optional[str] = None, + node_caption: Optional[str] = "labels", + relationship_caption: Optional[str] = "type", + node_radius_min_max: Optional[tuple[float, float]] = (3, 60), +) -> VisualizationGraph: + """ + Create a VisualizationGraph from a Neo4j Graph or Neo4j Result object. + + + Parameters + ---------- + result : Union[neo4j.graph.Graph, Result] + Query result either in shape of a Graph or result. + size_property : str, optional + Property to use for node size, by default None. + node_caption : str, optional + Property to use for node caption, by default the node labels will be used. + relationship_caption : str, optional + Property to use for relationship caption, by the relationships type will be used. + node_radius_min_max : tuple[float, float], optional + Minimum and maximum node radius, by default (3, 60). + To avoid tiny or huge nodes in the visualization, the node sizes are scaled to fit in the given range. + """ + + if isinstance(result, Result): + graph = result.graph() + elif isinstance(result, neo4j.graph.Graph): + graph = result + else: + raise ValueError("Invalid input type expected `neo4j.Graph` or `neo4j.Result`") + + nodes = [_map_node(node, size_property, caption_property=node_caption) for node in graph.nodes] + relationships = [] + for rel in graph.relationships: + mapped_rel = _map_relationship(rel, caption_property=relationship_caption) + if mapped_rel: + relationships.append(mapped_rel) + + VG = VisualizationGraph(nodes, relationships) + + if node_radius_min_max and size_property is not None: + VG.resize_nodes(node_radius_min_max=node_radius_min_max) + + return VG + + +def _map_node(node: neo4j.graph.Node, size_property: Optional[str], caption_property: Optional[str]) -> Node: + if size_property: + size = node.get(size_property) + else: + size = None + + labels = sorted([label for label in node.labels]) + if caption_property: + if caption_property == "labels": + labels = labels + if len(labels) != 1: + caption = ":".join([label for label in labels]) + else: + caption = next(iter(labels)) + else: + caption = node.get(caption) + + return Node(id=node.element_id, caption=caption, labels=labels, size=size, **{k: v for k, v in node.items()}) + + +def _map_relationship(rel: neo4j.graph.Relationship, caption_property: Optional[str]) -> Optional[Relationship]: + if rel.start_node is None or rel.end_node is None: + return None + + if caption_property: + if caption_property == "type": + caption = rel.type + else: + caption = rel.get(caption) + else: + caption = None + + return Relationship( + id=rel.element_id, + source=rel.start_node.element_id, + target=rel.end_node.element_id, + type_=rel.type, + caption=caption, + **{k: v for k, v in rel.items()}, + ) diff --git a/python-wrapper/tests/conftest.py b/python-wrapper/tests/conftest.py index ecaa0add..3bfa05a7 100644 --- a/python-wrapper/tests/conftest.py +++ b/python-wrapper/tests/conftest.py @@ -1,6 +1,7 @@ import os from typing import Any, Generator +import neo4j import pytest @@ -35,13 +36,9 @@ def gds() -> Generator[Any, None, None]: from gds_helper import aura_api, connect_to_plugin_gds, create_aurads_instance from graphdatascience import GraphDataScience - NEO4J_URI = os.environ.get("NEO4J_URI") + use_cloud_setup = os.environ.get("AURA_API_CLIENT_ID", None) - if NEO4J_URI: - gds = connect_to_plugin_gds(NEO4J_URI) - yield gds - gds.close() - else: + if use_cloud_setup: api = aura_api() id, dbms_connection_info = create_aurads_instance(api) @@ -61,3 +58,18 @@ def gds() -> Generator[Any, None, None]: os.environ["NEO4J_URI"] = "" api.delete_instance(id) + else: + NEO4J_URI = os.environ.get("NEO4J_URI", "neo4j://localhost:7687") + gds = connect_to_plugin_gds(NEO4J_URI) + yield gds + gds.close() + + +@pytest.fixture(scope="package") +def neo4j_session() -> Generator[neo4j.Session, None, None]: + NEO4J_URI = os.environ.get("NEO4J_URI", "neo4j://localhost:7687") + + with neo4j.GraphDatabase.driver(NEO4J_URI) as driver: + driver.verify_connectivity() + with driver.session() as session: + yield session diff --git a/python-wrapper/tests/test_gds.py b/python-wrapper/tests/test_gds.py index d6a506de..3df4e055 100644 --- a/python-wrapper/tests/test_gds.py +++ b/python-wrapper/tests/test_gds.py @@ -8,7 +8,6 @@ from neo4j_viz import Node -@pytest.mark.skipif(sys.version_info >= (3, 13), reason="requires python 3.12 or lower") @pytest.mark.requires_neo4j_and_gds def test_from_gds_integration(gds: Any) -> None: from neo4j_viz.gds import from_gds @@ -50,7 +49,6 @@ def test_from_gds_integration(gds: Any) -> None: ] -@pytest.mark.skipif(sys.version_info >= (3, 13), reason="requires python 3.12 or lower") def test_from_gds_mocked(mocker: MockerFixture) -> None: from graphdatascience import Graph, GraphDataScience diff --git a/python-wrapper/tests/test_neo4j.py b/python-wrapper/tests/test_neo4j.py new file mode 100644 index 00000000..35aa0a9d --- /dev/null +++ b/python-wrapper/tests/test_neo4j.py @@ -0,0 +1,38 @@ +from typing import Generator + +import pytest +from neo4j import Session + +from neo4j_viz.neo4j import from_neo4j +from neo4j_viz.node import Node + + +@pytest.fixture(scope="class", autouse=True) +def graph_setup(neo4j_session: Session) -> Generator[None, None, None]: + neo4j_session.run("CREATE (a:_CI_A {name:'Alice'})-[:KNOWS]->(b:_CI_A:_CI_B {name:'Bob'}), (b)-[:RELATED]->(a)") + yield + neo4j_session.run("MATCH (n:_CI_A|_CI_B) DETACH DELETE n") + + +@pytest.mark.requires_neo4j_and_gds +def test_from_neo4j_graph(neo4j_session: Session) -> None: + graph = neo4j_session.run("MATCH (a:_CI_A|_CI_B)-[r]->(b) RETURN a, b, r ORDER BY a").graph() + + VG = from_neo4j(graph) + + node_ids: list[str] = [node.element_id for node in graph.nodes] + + expected_nodes = [ + Node(id=node_ids[0], caption="_CI_A", labels=["_CI_A"], name="Alice"), + Node(id=node_ids[1], caption="_CI_A:_CI_B", labels=["_CI_A", "_CI_B"], name="Bob"), + ] + + assert len(VG.nodes) == 2 + assert sorted(VG.nodes, key=lambda x: x.name) == expected_nodes # type: ignore[attr-defined] + + assert len(VG.relationships) == 2 + vg_rels = sorted([(e.source, e.target, e.caption) for e in VG.relationships], key=lambda x: x[0]) + assert vg_rels == [ + (node_ids[0], node_ids[1], "KNOWS"), + (node_ids[1], node_ids[0], "RELATED"), + ] From df47d0781c6c849295b14ad1b1e60b19407f6c91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Fri, 28 Mar 2025 12:53:49 +0100 Subject: [PATCH 03/10] Lazy import neo4j --- python-wrapper/tests/conftest.py | 5 +++-- python-wrapper/tests/test_gds.py | 1 - 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/python-wrapper/tests/conftest.py b/python-wrapper/tests/conftest.py index 3bfa05a7..531d25f6 100644 --- a/python-wrapper/tests/conftest.py +++ b/python-wrapper/tests/conftest.py @@ -1,7 +1,6 @@ import os from typing import Any, Generator -import neo4j import pytest @@ -66,7 +65,9 @@ def gds() -> Generator[Any, None, None]: @pytest.fixture(scope="package") -def neo4j_session() -> Generator[neo4j.Session, None, None]: +def neo4j_session() -> Generator[Any, None, None]: + import neo4j + NEO4J_URI = os.environ.get("NEO4J_URI", "neo4j://localhost:7687") with neo4j.GraphDatabase.driver(NEO4J_URI) as driver: diff --git a/python-wrapper/tests/test_gds.py b/python-wrapper/tests/test_gds.py index 3df4e055..c6610a4a 100644 --- a/python-wrapper/tests/test_gds.py +++ b/python-wrapper/tests/test_gds.py @@ -1,4 +1,3 @@ -import sys from typing import Any import pandas as pd From 2692c797abd2846cd771e74eee3ec25472c2d7b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Fri, 28 Mar 2025 12:56:19 +0100 Subject: [PATCH 04/10] Mention neo4j integratin at installation --- docs/source/installation.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/source/installation.rst b/docs/source/installation.rst index 8fbeb259..9bb60b44 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -24,6 +24,15 @@ To install the additional dependencies required for the :doc:`from_dfs importer pip install neo4j-viz[pandas] +Neo4j ``from_neo4j`` importer +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To install the additional dependencies required for the :doc:`from_neo4j importer <./api-reference/from_neo4j>` you can run: + +.. code-block:: bash + + pip install neo4j-viz[neo4j] + Neo4j Graph Data Science ``from_gds`` importer ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ From 237c26787305cc8d431681e6739ef236734e7546 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Fri, 28 Mar 2025 12:56:28 +0100 Subject: [PATCH 05/10] Test gds with python 3.13 as well --- .github/workflows/unit-tests.yml | 23 ++--------------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 3068e743..7b5e2e83 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -23,7 +23,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] defaults: run: @@ -40,27 +40,8 @@ jobs: cache-dependency-path: pyproject.toml - run: pip install ".[dev]" - run: pip install ".[pandas]" + - run: pip install ".[neo4j]" - run: pip install ".[gds]" - name: Run tests run: pytest tests/ - - tests-313: - runs-on: ubuntu-latest - defaults: - run: - working-directory: python-wrapper - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 - with: - python-version: 3.13 - cache: 'pip' - cache-dependency-path: pyproject.toml - - run: pip install ".[dev]" - - run: pip install ".[pandas]" - # skip gds for now as it is not available for python 3.13 - - - name: Run tests - run: pytest tests/ From 249198258ac0a143fb726a1aa65df3c5fcfcefcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Fri, 28 Mar 2025 13:05:00 +0100 Subject: [PATCH 06/10] Fix caption logic --- python-wrapper/src/neo4j_viz/neo4j.py | 4 ++-- python-wrapper/tests/test_neo4j.py | 32 ++++++++++++++++++++++++--- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/python-wrapper/src/neo4j_viz/neo4j.py b/python-wrapper/src/neo4j_viz/neo4j.py index 883dab26..b2d2fc07 100644 --- a/python-wrapper/src/neo4j_viz/neo4j.py +++ b/python-wrapper/src/neo4j_viz/neo4j.py @@ -73,7 +73,7 @@ def _map_node(node: neo4j.graph.Node, size_property: Optional[str], caption_prop else: caption = next(iter(labels)) else: - caption = node.get(caption) + caption = str(node.get(caption_property)) return Node(id=node.element_id, caption=caption, labels=labels, size=size, **{k: v for k, v in node.items()}) @@ -86,7 +86,7 @@ def _map_relationship(rel: neo4j.graph.Relationship, caption_property: Optional[ if caption_property == "type": caption = rel.type else: - caption = rel.get(caption) + caption = str(rel.get(caption_property)) else: caption = None diff --git a/python-wrapper/tests/test_neo4j.py b/python-wrapper/tests/test_neo4j.py index 35aa0a9d..714afd02 100644 --- a/python-wrapper/tests/test_neo4j.py +++ b/python-wrapper/tests/test_neo4j.py @@ -9,7 +9,9 @@ @pytest.fixture(scope="class", autouse=True) def graph_setup(neo4j_session: Session) -> Generator[None, None, None]: - neo4j_session.run("CREATE (a:_CI_A {name:'Alice'})-[:KNOWS]->(b:_CI_A:_CI_B {name:'Bob'}), (b)-[:RELATED]->(a)") + neo4j_session.run( + "CREATE (a:_CI_A {name:'Alice', height:20})-[:KNOWS {year: 2025}]->(b:_CI_A:_CI_B {name:'Bob', height:10}), (b)-[:RELATED {year: 2015}]->(a)" + ) yield neo4j_session.run("MATCH (n:_CI_A|_CI_B) DETACH DELETE n") @@ -23,8 +25,8 @@ def test_from_neo4j_graph(neo4j_session: Session) -> None: node_ids: list[str] = [node.element_id for node in graph.nodes] expected_nodes = [ - Node(id=node_ids[0], caption="_CI_A", labels=["_CI_A"], name="Alice"), - Node(id=node_ids[1], caption="_CI_A:_CI_B", labels=["_CI_A", "_CI_B"], name="Bob"), + Node(id=node_ids[0], caption="_CI_A", labels=["_CI_A"], name="Alice", height=20), + Node(id=node_ids[1], caption="_CI_A:_CI_B", labels=["_CI_A", "_CI_B"], name="Bob", height=10), ] assert len(VG.nodes) == 2 @@ -36,3 +38,27 @@ def test_from_neo4j_graph(neo4j_session: Session) -> None: (node_ids[0], node_ids[1], "KNOWS"), (node_ids[1], node_ids[0], "RELATED"), ] + + +@pytest.mark.requires_neo4j_and_gds +def test_from_neo4j_graph_full(neo4j_session: Session) -> None: + graph = neo4j_session.run("MATCH (a:_CI_A|_CI_B)-[r]->(b) RETURN a, b, r ORDER BY a").graph() + + VG = from_neo4j(graph, node_caption="name", relationship_caption="year", size_property="height") + + node_ids: list[str] = [node.element_id for node in graph.nodes] + + expected_nodes = [ + Node(id=node_ids[0], caption="Alice", labels=["_CI_A"], name="Alice", height=20, size=60.0), + Node(id=node_ids[1], caption="Bob", labels=["_CI_A", "_CI_B"], name="Bob", height=10, size=3.0), + ] + + assert len(VG.nodes) == 2 + assert sorted(VG.nodes, key=lambda x: x.name) == expected_nodes # type: ignore[attr-defined] + + assert len(VG.relationships) == 2 + vg_rels = sorted([(e.source, e.target, e.caption) for e in VG.relationships], key=lambda x: x[0]) + assert vg_rels == [ + (node_ids[0], node_ids[1], "2025"), + (node_ids[1], node_ids[0], "2015"), + ] From c19238ca670e1bd92c01ebf96e1cb5dc24f00679 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Fri, 28 Mar 2025 14:50:25 +0100 Subject: [PATCH 07/10] Improve wording + add test for neo4j.result Co-authored-by: Adam Schill Collberg --- README.md | 1 + docs/source/installation.rst | 2 +- python-wrapper/src/neo4j_viz/neo4j.py | 11 +++++----- python-wrapper/tests/test_neo4j.py | 31 ++++++++++++++++++++++++--- 4 files changed, 35 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index d1cfefea..4646d384 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Proper documentation is forthcoming. * Easy to import graphs represented as: * projections in the Neo4j Graph Data Science (GDS) library + * graphs from Neo4j query results * pandas DataFrames * Node features: * Sizing diff --git a/docs/source/installation.rst b/docs/source/installation.rst index 9bb60b44..4adf4c4d 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -25,7 +25,7 @@ To install the additional dependencies required for the :doc:`from_dfs importer pip install neo4j-viz[pandas] Neo4j ``from_neo4j`` importer -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ To install the additional dependencies required for the :doc:`from_neo4j importer <./api-reference/from_neo4j>` you can run: diff --git a/python-wrapper/src/neo4j_viz/neo4j.py b/python-wrapper/src/neo4j_viz/neo4j.py index b2d2fc07..c6cec8ca 100644 --- a/python-wrapper/src/neo4j_viz/neo4j.py +++ b/python-wrapper/src/neo4j_viz/neo4j.py @@ -28,9 +28,9 @@ def from_neo4j( size_property : str, optional Property to use for node size, by default None. node_caption : str, optional - Property to use for node caption, by default the node labels will be used. + Property to use as the node caption, by default the node labels will be used. relationship_caption : str, optional - Property to use for relationship caption, by the relationships type will be used. + Property to use as the relationship caption, by default the relationship type will be used. node_radius_min_max : tuple[float, float], optional Minimum and maximum node radius, by default (3, 60). To avoid tiny or huge nodes in the visualization, the node sizes are scaled to fit in the given range. @@ -41,7 +41,7 @@ def from_neo4j( elif isinstance(result, neo4j.graph.Graph): graph = result else: - raise ValueError("Invalid input type expected `neo4j.Graph` or `neo4j.Result`") + raise ValueError(f"Invalid input type `{type(result)}`. Expected `neo4j.Graph` or `neo4j.Result`") nodes = [_map_node(node, size_property, caption_property=node_caption) for node in graph.nodes] relationships = [] @@ -67,11 +67,10 @@ def _map_node(node: neo4j.graph.Node, size_property: Optional[str], caption_prop labels = sorted([label for label in node.labels]) if caption_property: if caption_property == "labels": - labels = labels - if len(labels) != 1: + if len(labels) > 0: caption = ":".join([label for label in labels]) else: - caption = next(iter(labels)) + caption = None else: caption = str(node.get(caption_property)) diff --git a/python-wrapper/tests/test_neo4j.py b/python-wrapper/tests/test_neo4j.py index 714afd02..5219fd3a 100644 --- a/python-wrapper/tests/test_neo4j.py +++ b/python-wrapper/tests/test_neo4j.py @@ -33,7 +33,32 @@ def test_from_neo4j_graph(neo4j_session: Session) -> None: assert sorted(VG.nodes, key=lambda x: x.name) == expected_nodes # type: ignore[attr-defined] assert len(VG.relationships) == 2 - vg_rels = sorted([(e.source, e.target, e.caption) for e in VG.relationships], key=lambda x: x[0]) + vg_rels = sorted([(e.source, e.target, e.caption) for e in VG.relationships], key=lambda x: x[2]) + assert vg_rels == [ + (node_ids[0], node_ids[1], "KNOWS"), + (node_ids[1], node_ids[0], "RELATED"), + ] + + +@pytest.mark.requires_neo4j_and_gds +def test_from_neo4j_result(neo4j_session: Session) -> None: + result = neo4j_session.run("MATCH (a:_CI_A|_CI_B)-[r]->(b) RETURN a, b, r ORDER BY a") + + VG = from_neo4j(result) + + graph = result.graph() + node_ids: list[str] = [node.element_id for node in graph.nodes] + + expected_nodes = [ + Node(id=node_ids[0], caption="_CI_A", labels=["_CI_A"], name="Alice", height=20), + Node(id=node_ids[1], caption="_CI_A:_CI_B", labels=["_CI_A", "_CI_B"], name="Bob", height=10), + ] + + assert len(VG.nodes) == 2 + assert sorted(VG.nodes, key=lambda x: x.name) == expected_nodes # type: ignore[attr-defined] + + assert len(VG.relationships) == 2 + vg_rels = sorted([(e.source, e.target, e.caption) for e in VG.relationships], key=lambda x: x[2]) assert vg_rels == [ (node_ids[0], node_ids[1], "KNOWS"), (node_ids[1], node_ids[0], "RELATED"), @@ -57,8 +82,8 @@ def test_from_neo4j_graph_full(neo4j_session: Session) -> None: assert sorted(VG.nodes, key=lambda x: x.name) == expected_nodes # type: ignore[attr-defined] assert len(VG.relationships) == 2 - vg_rels = sorted([(e.source, e.target, e.caption) for e in VG.relationships], key=lambda x: x[0]) + vg_rels = sorted([(e.source, e.target, e.caption) for e in VG.relationships], key=lambda x: x[2]) assert vg_rels == [ - (node_ids[0], node_ids[1], "2025"), (node_ids[1], node_ids[0], "2015"), + (node_ids[0], node_ids[1], "2025"), ] From 571488bb8b388b548ebee3f85110ead68f1c8634 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Fri, 28 Mar 2025 14:58:13 +0100 Subject: [PATCH 08/10] Document neo4j integration in integration page Co-authored-by: Adam Schill Collberg --- docs/source/integration.rst | 62 +++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/docs/source/integration.rst b/docs/source/integration.rst index c5d33bac..bf8a6feb 100644 --- a/docs/source/integration.rst +++ b/docs/source/integration.rst @@ -141,3 +141,65 @@ We use the "pagerank" property to determine the size of the nodes, and the "comp Please see the :doc:`Visualizing Neo4j Graph Data Science (GDS) Graphs tutorial <./tutorials/gds-nvl-example>` for a more extensive example. + + +Neo4j Database +--------------- + +The ``neo4j-viz`` library provides a convenience method for importing data from Neo4j. +It requires and additional dependency to be installed, which you can do by running: + +.. code-block:: bash + + pip install neo4j-viz[neo4j] + +Once you have installed the additional dependency, you can use the :doc:`from_neo4j <./api-reference/from_neo4j>` method +to import query results from Neo4j. + +The ``from_neo4j`` method takes one mandatory positional parameters: + +* A ``result`` representing the query result either in form of `neo4j.graph.Graph` or `neo4j.Result`. + +The ``node_caption`` parameter is also optional, and indicates the value to use for the caption of each node in the visualization. + +We can also provide an optional ``size_property`` parameter, which should refer to a node property of the projection, +and will be used to determine the size of the nodes in the visualization. + +The last optional property, ``node_radius_min_max``, can be used (and is used by default) to scale the node sizes for +the visualization. +It is a tuple of two numbers, representing the radii (sizes) in pixels of the smallest and largest nodes respectively in +the visualization. +The node sizes will be scaled such that the smallest node will have the size of the first value, and the largest node +will have the size of the second value. +The other nodes will be scaled linearly between these two values according to their relative size. +This can be useful if node sizes vary a lot, or are all very small or very big. + + +Example +~~~~~~~ + +In this small example, we import a graph from a Neo4j query result. + +.. code-block:: python + + from neo4j import GraphDataScience + from neo4j_viz.gds import from_gds + + # Modify this to match your Neo4j instance's URI and credentials + URI = "neo4j://localhost:7687" + auth = ("neo4j", "password") + + with GraphDatabase.driver(URI, auth=auth) as driver: + driver.verify_connectivity() + + result = driver.execute_query( + "MATCH (n)-[r]->(m) RETURN n,r,m", + database_="neo4j", + result_transformer_=Result.graph, + ) + + VG = from_neo4j(result) + + +Please see the :doc:`Visualizing Neo4j Graphs tutorial <./tutorials/neo4j-nvl-example>` for a +more extensive example. From 689ddf837f4dc1e2702a480d908a226a322ab8ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Fri, 28 Mar 2025 15:04:42 +0100 Subject: [PATCH 09/10] Remove nvl references from python/example names (internal detail) --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- docs/source/adjusting.rst | 4 ++-- docs/source/integration.rst | 6 +++--- .../{gds-nvl-example.nblink => gds-example.nblink} | 2 +- docs/source/tutorials/neo4j-example.nblink | 3 +++ docs/source/tutorials/neo4j-nvl-example.nblink | 3 --- docs/source/tutorials/snowpark-example.nblink | 3 +++ docs/source/tutorials/snowpark-nvl-example.nblink | 3 --- examples/{gds-nvl-example.ipynb => gds-example.ipynb} | 8 +++++++- examples/{neo4j-nvl-example.ipynb => neo4j-example.ipynb} | 0 ...{snowpark-nvl-example.ipynb => snowpark-example.ipynb} | 0 11 files changed, 20 insertions(+), 14 deletions(-) rename docs/source/tutorials/{gds-nvl-example.nblink => gds-example.nblink} (58%) create mode 100644 docs/source/tutorials/neo4j-example.nblink delete mode 100644 docs/source/tutorials/neo4j-nvl-example.nblink create mode 100644 docs/source/tutorials/snowpark-example.nblink delete mode 100644 docs/source/tutorials/snowpark-nvl-example.nblink rename examples/{gds-nvl-example.ipynb => gds-example.ipynb} (99%) rename examples/{neo4j-nvl-example.ipynb => neo4j-example.ipynb} (100%) rename examples/{snowpark-nvl-example.ipynb => snowpark-example.ipynb} (100%) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 6f459a00..4680941e 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -4,7 +4,7 @@ Thank you for your contribution to the Graph Data Science Client project. If relevant, link to the corresponding issue. Please also include relevant motivation and context. List any dependencies that are required for this change. --> -Before submitting this PR, please read [Contributing to the Neo4j Ecosystem](https://github.com/neo4j/nvl-python-wrapper/blob/main/CONTRIBUTING.md). +Before submitting this PR, please read [Contributing to the Neo4j Ecosystem](https://github.com/neo4j/python-graph-visualization/blob/main/CONTRIBUTING.md). Make sure: - [ ] You signed the [Neo4j CLA](https://neo4j.com/developer/cla/#sign-cla) (Contributor License Agreement) so that we are allowed to ship your code in our library diff --git a/docs/source/adjusting.rst b/docs/source/adjusting.rst index d5169d8a..bb39d5c9 100644 --- a/docs/source/adjusting.rst +++ b/docs/source/adjusting.rst @@ -51,7 +51,7 @@ In this case, all nodes with the same caption will get the same color. If there are fewer colors that unique values for the node ``property`` provided, the colors will be reused in a cycle. To avoid that, you could use another palette or extend one with additional colors. Please refer to the -:doc:`Visualizing Neo4j Graph Data Science (GDS) Graphs tutorial <./tutorials/gds-nvl-example>` for an example on how +:doc:`Visualizing Neo4j Graph Data Science (GDS) Graphs tutorial <./tutorials/gds-example>` for an example on how to do the latter. If some nodes already have a ``color`` set, you can choose whether or not to override it with the ``override`` @@ -142,4 +142,4 @@ Each node and relationship has attributes that can be accessed and modified dire VG.nodes[0].size = 10 VG.relationships[4].caption = "BUYS" -Any changes made to the nodes and relationships will be reflected in the next rendering of the graph. \ No newline at end of file +Any changes made to the nodes and relationships will be reflected in the next rendering of the graph. diff --git a/docs/source/integration.rst b/docs/source/integration.rst index bf8a6feb..53ba45da 100644 --- a/docs/source/integration.rst +++ b/docs/source/integration.rst @@ -67,7 +67,7 @@ and :doc:`Relationships <./api-reference/relationship>`. VG = from_dfs(nodes, relationships) For another example of the ``from_dfs`` importer in action, see the -:doc:`Visualizing Snowflake Tables tutorial <./tutorials/snowpark-nvl-example>`. +:doc:`Visualizing Snowflake Tables tutorial <./tutorials/snowpark-example>`. Neo4j Graph Data Science (GDS) library @@ -139,7 +139,7 @@ We use the "pagerank" property to determine the size of the nodes, and the "comp VG.color_nodes("componentId") -Please see the :doc:`Visualizing Neo4j Graph Data Science (GDS) Graphs tutorial <./tutorials/gds-nvl-example>` for a +Please see the :doc:`Visualizing Neo4j Graph Data Science (GDS) Graphs tutorial <./tutorials/gds-example>` for a more extensive example. @@ -201,5 +201,5 @@ In this small example, we import a graph from a Neo4j query result. VG = from_neo4j(result) -Please see the :doc:`Visualizing Neo4j Graphs tutorial <./tutorials/neo4j-nvl-example>` for a +Please see the :doc:`Visualizing Neo4j Graphs tutorial <./tutorials/neo4j-example>` for a more extensive example. diff --git a/docs/source/tutorials/gds-nvl-example.nblink b/docs/source/tutorials/gds-example.nblink similarity index 58% rename from docs/source/tutorials/gds-nvl-example.nblink rename to docs/source/tutorials/gds-example.nblink index 96bcb5c0..602f42a6 100644 --- a/docs/source/tutorials/gds-nvl-example.nblink +++ b/docs/source/tutorials/gds-example.nblink @@ -1,5 +1,5 @@ { - "path": "../../../examples/gds-nvl-example.ipynb", + "path": "../../../examples/gds-example.ipynb", "extra-media": [ "../../../examples/example_cora_graph.png" ] diff --git a/docs/source/tutorials/neo4j-example.nblink b/docs/source/tutorials/neo4j-example.nblink new file mode 100644 index 00000000..014929c0 --- /dev/null +++ b/docs/source/tutorials/neo4j-example.nblink @@ -0,0 +1,3 @@ +{ + "path": "../../../examples/neo4j-example.ipynb" +} diff --git a/docs/source/tutorials/neo4j-nvl-example.nblink b/docs/source/tutorials/neo4j-nvl-example.nblink deleted file mode 100644 index 74cf40a0..00000000 --- a/docs/source/tutorials/neo4j-nvl-example.nblink +++ /dev/null @@ -1,3 +0,0 @@ -{ - "path": "../../../examples/neo4j-nvl-example.ipynb" -} diff --git a/docs/source/tutorials/snowpark-example.nblink b/docs/source/tutorials/snowpark-example.nblink new file mode 100644 index 00000000..d812a4df --- /dev/null +++ b/docs/source/tutorials/snowpark-example.nblink @@ -0,0 +1,3 @@ +{ + "path": "../../../examples/snowpark-example.ipynb" +} diff --git a/docs/source/tutorials/snowpark-nvl-example.nblink b/docs/source/tutorials/snowpark-nvl-example.nblink deleted file mode 100644 index 39c60981..00000000 --- a/docs/source/tutorials/snowpark-nvl-example.nblink +++ /dev/null @@ -1,3 +0,0 @@ -{ - "path": "../../../examples/snowpark-nvl-example.ipynb" -} diff --git a/examples/gds-nvl-example.ipynb b/examples/gds-example.ipynb similarity index 99% rename from examples/gds-nvl-example.ipynb rename to examples/gds-example.ipynb index d1f4497c..2a934e98 100644 --- a/examples/gds-nvl-example.ipynb +++ b/examples/gds-example.ipynb @@ -472,8 +472,14 @@ } ], "metadata": { + "kernelspec": { + "display_name": ".venv311", + "language": "python", + "name": "python3" + }, "language_info": { - "name": "python" + "name": "python", + "version": "3.11.9" } }, "nbformat": 4, diff --git a/examples/neo4j-nvl-example.ipynb b/examples/neo4j-example.ipynb similarity index 100% rename from examples/neo4j-nvl-example.ipynb rename to examples/neo4j-example.ipynb diff --git a/examples/snowpark-nvl-example.ipynb b/examples/snowpark-example.ipynb similarity index 100% rename from examples/snowpark-nvl-example.ipynb rename to examples/snowpark-example.ipynb From ee1150b70e9eedea9296d0ee2041b8953fb6ade0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Fri, 28 Mar 2025 15:09:35 +0100 Subject: [PATCH 10/10] Fix mypy issue --- examples/gds-example.ipynb | 8 +------- python-wrapper/tests/test_neo4j.py | 6 +++--- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/examples/gds-example.ipynb b/examples/gds-example.ipynb index 2a934e98..d1f4497c 100644 --- a/examples/gds-example.ipynb +++ b/examples/gds-example.ipynb @@ -472,14 +472,8 @@ } ], "metadata": { - "kernelspec": { - "display_name": ".venv311", - "language": "python", - "name": "python3" - }, "language_info": { - "name": "python", - "version": "3.11.9" + "name": "python" } }, "nbformat": 4, diff --git a/python-wrapper/tests/test_neo4j.py b/python-wrapper/tests/test_neo4j.py index 5219fd3a..6ad80623 100644 --- a/python-wrapper/tests/test_neo4j.py +++ b/python-wrapper/tests/test_neo4j.py @@ -33,7 +33,7 @@ def test_from_neo4j_graph(neo4j_session: Session) -> None: assert sorted(VG.nodes, key=lambda x: x.name) == expected_nodes # type: ignore[attr-defined] assert len(VG.relationships) == 2 - vg_rels = sorted([(e.source, e.target, e.caption) for e in VG.relationships], key=lambda x: x[2]) + vg_rels = sorted([(e.source, e.target, e.caption) for e in VG.relationships], key=lambda x: x[2] if x[2] else "foo") assert vg_rels == [ (node_ids[0], node_ids[1], "KNOWS"), (node_ids[1], node_ids[0], "RELATED"), @@ -58,7 +58,7 @@ def test_from_neo4j_result(neo4j_session: Session) -> None: assert sorted(VG.nodes, key=lambda x: x.name) == expected_nodes # type: ignore[attr-defined] assert len(VG.relationships) == 2 - vg_rels = sorted([(e.source, e.target, e.caption) for e in VG.relationships], key=lambda x: x[2]) + vg_rels = sorted([(e.source, e.target, e.caption) for e in VG.relationships], key=lambda x: x[2] if x[2] else "foo") assert vg_rels == [ (node_ids[0], node_ids[1], "KNOWS"), (node_ids[1], node_ids[0], "RELATED"), @@ -82,7 +82,7 @@ def test_from_neo4j_graph_full(neo4j_session: Session) -> None: assert sorted(VG.nodes, key=lambda x: x.name) == expected_nodes # type: ignore[attr-defined] assert len(VG.relationships) == 2 - vg_rels = sorted([(e.source, e.target, e.caption) for e in VG.relationships], key=lambda x: x[2]) + vg_rels = sorted([(e.source, e.target, e.caption) for e in VG.relationships], key=lambda x: x[2] if x[2] else "foo") assert vg_rels == [ (node_ids[1], node_ids[0], "2015"), (node_ids[0], node_ids[1], "2025"),