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/.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/ 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/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/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/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/docs/source/installation.rst b/docs/source/installation.rst index 8fbeb259..4adf4c4d 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 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/source/integration.rst b/docs/source/integration.rst index c5d33bac..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,5 +139,67 @@ 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. + + +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-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 100% rename from examples/gds-nvl-example.ipynb rename to examples/gds-example.ipynb diff --git a/examples/neo4j-nvl-example.ipynb b/examples/neo4j-example.ipynb similarity index 64% rename from examples/neo4j-nvl-example.ipynb rename to examples/neo4j-example.ipynb index 6e72d7b9..3b0240ff 100644 --- a/examples/neo4j-nvl-example.ipynb +++ b/examples/neo4j-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/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 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..c6cec8ca --- /dev/null +++ b/python-wrapper/src/neo4j_viz/neo4j.py @@ -0,0 +1,99 @@ +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 as the node caption, by default the node labels will be used. + relationship_caption : str, optional + 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. + """ + + if isinstance(result, Result): + graph = result.graph() + elif isinstance(result, neo4j.graph.Graph): + graph = result + else: + 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 = [] + 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": + if len(labels) > 0: + caption = ":".join([label for label in labels]) + else: + caption = None + else: + 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()}) + + +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 = str(rel.get(caption_property)) + 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..531d25f6 100644 --- a/python-wrapper/tests/conftest.py +++ b/python-wrapper/tests/conftest.py @@ -35,13 +35,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 +57,20 @@ 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[Any, None, None]: + import neo4j + + 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..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 @@ -8,7 +7,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 +48,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..6ad80623 --- /dev/null +++ b/python-wrapper/tests/test_neo4j.py @@ -0,0 +1,89 @@ +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', 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") + + +@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", 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] if x[2] else "foo") + 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] if x[2] else "foo") + 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_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[2] if x[2] else "foo") + assert vg_rels == [ + (node_ids[1], node_ids[0], "2015"), + (node_ids[0], node_ids[1], "2025"), + ] 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