From 63da09abdacfbeaf2dad197938d3da9133170917 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Fri, 8 May 2026 15:18:46 +0200 Subject: [PATCH 01/23] Update lib version --- changelog.md | 2 +- python-wrapper/pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog.md b/changelog.md index eb2810e..2b09d1e 100644 --- a/changelog.md +++ b/changelog.md @@ -1,4 +1,4 @@ -# Changes in 1.4.0 +# Changes in 1.5.0 ## Breaking changes diff --git a/python-wrapper/pyproject.toml b/python-wrapper/pyproject.toml index 417d6e5..a2b60e8 100644 --- a/python-wrapper/pyproject.toml +++ b/python-wrapper/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "neo4j-viz" -version = "1.4.0" +version = "1.5.0" description = "A simple graph visualization tool" readme = "README.md" authors = [{ name = "Neo4j", email = "team-gds@neo4j.org" }] From ce0cb5984b7340ff83c4be9fcb52b9ae9fbfe2ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Fri, 5 Jun 2026 15:20:12 +0200 Subject: [PATCH 02/23] Add support for AGA --- changelog.md | 7 +- examples/gds-example.ipynb | 5031 +----------------------- examples/neo4j-example.ipynb | 24 +- justfile | 16 +- python-wrapper/pyproject.toml | 18 +- python-wrapper/src/neo4j_viz/gds.py | 84 +- python-wrapper/tests/conftest.py | 61 +- python-wrapper/tests/gds_helper.py | 47 +- python-wrapper/tests/test_gds.py | 19 +- python-wrapper/tests/test_notebooks.py | 4 + python-wrapper/uv.lock | 171 +- 11 files changed, 386 insertions(+), 5096 deletions(-) diff --git a/changelog.md b/changelog.md index 2b09d1e..500e1c1 100644 --- a/changelog.md +++ b/changelog.md @@ -6,12 +6,9 @@ ## Bug fixes -- Fixed a bug in displaying the `Download`, `Selection` and `Layout` buttons, which was introduced in 1.2.0. - ## Improvements -- Support `neo4j.EagerResult` in the `from_neo4j` integration which is the default return type by `neo4j.Driver.execute_query()`. -- Detect light/dark theme changes and adapt rendering unless theme was explicitly set. Before the theme would only be checked on the first render. - +* Support Aura Graph Analytics +* Support `gds.v2` endpoints ## Other changes diff --git a/examples/gds-example.ipynb b/examples/gds-example.ipynb index 29b1387..661f6ff 100644 --- a/examples/gds-example.ipynb +++ b/examples/gds-example.ipynb @@ -2,6 +2,7 @@ "cells": [ { "cell_type": "markdown", + "id": "e613852e", "metadata": {}, "source": [ "# Visualizing Neo4j Graph Data Science (GDS) Graphs" @@ -10,54 +11,112 @@ { "cell_type": "code", "execution_count": null, + "id": "7149a3b4", "metadata": {}, "outputs": [], "source": [ "%pip install graphdatascience\n", - "%pip install matplotlib" + "%pip install matplotlib\n", + "%pip install python-dotenv" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a6a8c792", + "metadata": {}, + "outputs": [], + "source": [ + "import dotenv\n", + "\n", + "dotenv.load_dotenv(\"../local.env\", override=True)" ] }, { "cell_type": "markdown", + "id": "533c652a", "metadata": {}, "source": [ - "## Setup GDS graph" + "## Setup GDS graph\n", + "\n", + "To use GDS, you can either use GDS as a plugin or Aura Graph Analytics.\n", + "In the following, you can choose:\n", + "\n", + " * Provide Aura API credentials and and use Aura Graph Analytics.\n", + " * Use Neo4j + GDS Plugin.\n", + "\n", + "For more information, see the [GDS documentation](https://neo4j.com/docs/graph-data-science/current/installation/)." ] }, { "cell_type": "code", "execution_count": null, + "id": "3d98bfe7", "metadata": {}, "outputs": [], "source": [ "import os\n", "\n", + "from graphdatascience.session import (\n", + " GdsSessions,\n", + " DbmsConnectionInfo,\n", + " AuraAPICredentials,\n", + " SessionMemory,\n", + ")\n", "from graphdatascience import GraphDataScience\n", "\n", "# Get Neo4j DB URI, credentials and name from environment if applicable\n", - "NEO4J_URI = os.environ.get(\"NEO4J_URI\", \"bolt://localhost:7687\")\n", - "NEO4J_AUTH = (\"neo4j\", None)\n", - "NEO4J_DB = os.environ.get(\"NEO4J_DB\", \"neo4j\")\n", - "if os.environ.get(\"NEO4J_USER\") and os.environ.get(\"NEO4J_PASSWORD\"):\n", - " NEO4J_AUTH = (\n", - " os.environ.get(\"NEO4J_USER\"),\n", - " os.environ.get(\"NEO4J_PASSWORD\"),\n", + "db_connection = DbmsConnectionInfo(\n", + " aura_instance_id=os.environ.get(\"AURA_INSTANCEID\"),\n", + " username=os.environ.get(\"NEO4J_USERNAME\", \"neo4j\"),\n", + " password=os.environ.get(\"NEO4J_PASSWORD\"),\n", + " uri=os.environ[\"NEO4J_URI\"],\n", + ")\n", + "\n", + "session_name = \"neo4j-viz-gds-example\"\n", + "if os.environ.get(\"AURA_API_CLIENT_ID\"):\n", + " # Use Aura Graph Analytics\n", + " sessions = GdsSessions(\n", + " api_credentials=AuraAPICredentials(\n", + " client_id=os.environ[\"AURA_API_CLIENT_ID\"],\n", + " client_secret=os.environ[\"AURA_API_CLIENT_SECRET\"],\n", + " project_id=os.environ.get(\"AURA_API_PROJECT_ID\"),\n", + " )\n", + " )\n", + " gds = sessions.get_or_create(\n", + " session_name=session_name,\n", + " memory=SessionMemory.m_2GB,\n", + " db_connection=db_connection,\n", " )\n", - "gds = GraphDataScience(NEO4J_URI, auth=NEO4J_AUTH, database=NEO4J_DB)" + "else:\n", + " # Use GDS Plugin\n", + " sessions = None\n", + " gds = GraphDataScience(\n", + " endpoint=db_connection.get_uri(),\n", + " auth=(db_connection.username, db_connection.password),\n", + " )" ] }, { "cell_type": "code", "execution_count": null, + "id": "051e4229", "metadata": {}, "outputs": [], "source": [ "G = gds.graph.load_cora(graph_name=\"cora\")" ] }, + { + "cell_type": "markdown", + "id": "f2dff187", + "metadata": {}, + "source": [] + }, { "cell_type": "code", "execution_count": null, + "id": "618e4529", "metadata": {}, "outputs": [], "source": [ @@ -71,6 +130,7 @@ }, { "cell_type": "markdown", + "id": "2a7ccb13", "metadata": {}, "source": [ "## Visualization" @@ -79,1648 +139,32 @@ { "cell_type": "code", "execution_count": null, + "id": "9d29891b", "metadata": {}, "outputs": [], "source": [ "from neo4j_viz.gds import from_gds\n", "\n", - "VG = from_gds(gds, G, max_node_count=500)\n", - "str(VG)" + "VG = from_gds(\n", + " gds,\n", + " G,\n", + " max_node_count=100,\n", + ")" ] }, { "cell_type": "code", - "execution_count": 6, - "metadata": { - "tags": [ - "preserve-output" - ] - }, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - "\n", - " \n", - " \n", - " \n", - " neo4j-viz\n", - " \n", - " \n", - " \n", - " \n", - " \n", - "\n", - " \n", - "
\n", - " \n", - "\n" - ], - "text/plain": [ - "" - ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" - } - ], + "execution_count": null, + "id": "00929fe9", + "metadata": {}, + "outputs": [], "source": [ - "VG.render(theme=\"auto\")" + "VG.render()" ] }, { "cell_type": "markdown", + "id": "5246cda2", "metadata": {}, "source": [ "### Changing captions\n", @@ -1732,6 +176,7 @@ { "cell_type": "code", "execution_count": null, + "id": "449956ba", "metadata": {}, "outputs": [], "source": [ @@ -1742,9 +187,8 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "tags": [] - }, + "id": "d3289f24", + "metadata": {}, "outputs": [], "source": [ "VG.render()" @@ -1752,6 +196,7 @@ }, { "cell_type": "markdown", + "id": "7eb5e6a2", "metadata": {}, "source": [ "## Sizing the nodes\n", @@ -1762,16 +207,17 @@ { "cell_type": "code", "execution_count": null, + "id": "80801ca0", "metadata": {}, "outputs": [], "source": [ "VG.resize_nodes(property=\"pagerank\")\n", - "VG.color_nodes(property=\"componentId\")\n", "VG.render()" ] }, { "cell_type": "markdown", + "id": "9f0e22eb", "metadata": {}, "source": [ "### Coloring" @@ -1779,6 +225,7 @@ }, { "cell_type": "markdown", + "id": "e2eda873", "metadata": {}, "source": [ "There are two main ways of coloring the nodes of a graph:\n", @@ -1792,17 +239,17 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "tags": [] - }, + "id": "b1c33ce3", + "metadata": {}, "outputs": [], "source": [ - "VG.color_nodes(property=\"componentId\")\n", + "VG.color_nodes(property=\"subject\")\n", "VG.render()" ] }, { "cell_type": "markdown", + "id": "9e3d9b21", "metadata": {}, "source": [ "Now, let us color by our continuous node field \"size\" that we computed above with PageRank, again using the default colors.\n", @@ -1813,9 +260,8 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "tags": [] - }, + "id": "a25c21cc", + "metadata": {}, "outputs": [], "source": [ "from neo4j_viz.colors import ColorSpace\n", @@ -1826,6 +272,7 @@ }, { "cell_type": "markdown", + "id": "170d3457", "metadata": {}, "source": [ "#### Custom coloring\n", @@ -1837,6 +284,7 @@ { "cell_type": "code", "execution_count": null, + "id": "d0fbf37e", "metadata": {}, "outputs": [], "source": [ @@ -1846,6 +294,7 @@ { "cell_type": "code", "execution_count": null, + "id": "c5027692", "metadata": {}, "outputs": [], "source": [ @@ -1863,1637 +312,17 @@ }, { "cell_type": "code", - "execution_count": 14, - "metadata": { - "tags": [ - "preserve-output" - ] - }, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - "\n", - " \n", - " \n", - " \n", - " neo4j-viz\n", - " \n", - " \n", - " \n", - " \n", - " \n", - "\n", - " \n", - "
\n", - " \n", - "\n" - ], - "text/plain": [ - "" - ] - }, - "execution_count": 14, - "metadata": {}, - "output_type": "execute_result" - } - ], + "execution_count": null, + "id": "58e11be0", + "metadata": {}, + "outputs": [], "source": [ "VG.render()" ] }, { "cell_type": "markdown", + "id": "397221c3", "metadata": {}, "source": [ "### Render options\n", @@ -3509,1639 +338,19 @@ }, { "cell_type": "code", - "execution_count": 15, - "metadata": { - "tags": [ - "preserve-output" - ] - }, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - "\n", - " \n", - " \n", - " \n", - " neo4j-viz\n", - " \n", - " \n", - " \n", - " \n", - " \n", - "\n", - " \n", - "
\n", - " \n", - "\n" - ], - "text/plain": [ - "" - ] - }, - "execution_count": 15, - "metadata": {}, - "output_type": "execute_result" - } - ], + "execution_count": null, + "id": "d639feeb", + "metadata": {}, + "outputs": [], "source": [ "from neo4j_viz import Layout\n", "\n", - "VG.render(layout=Layout.CIRCULAR, initial_zoom=0.15)" + "VG.render(layout=Layout.CIRCULAR, initial_zoom=0.5)" ] }, { "cell_type": "markdown", + "id": "0120a2aa", "metadata": {}, "source": [ "## Saving the visualization" @@ -5150,6 +359,7 @@ { "cell_type": "code", "execution_count": null, + "id": "1993a72c", "metadata": {}, "outputs": [], "source": [ @@ -5164,6 +374,7 @@ }, { "cell_type": "markdown", + "id": "f2bc05f4", "metadata": {}, "source": [ "## Cleanup\n", @@ -5174,15 +385,23 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "tags": [ - "teardown" - ] - }, + "id": "aa3c4f06", + "metadata": {}, "outputs": [], "source": [ "gds.graph.drop(\"cora\")" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c2af45ef", + "metadata": {}, + "outputs": [], + "source": [ + "if sessions:\n", + " sessions.delete(session_name=session_name)" + ] } ], "metadata": { @@ -5191,5 +410,5 @@ } }, "nbformat": 4, - "nbformat_minor": 4 + "nbformat_minor": 5 } diff --git a/examples/neo4j-example.ipynb b/examples/neo4j-example.ipynb index 0fb1310..e2149fa 100644 --- a/examples/neo4j-example.ipynb +++ b/examples/neo4j-example.ipynb @@ -24,7 +24,8 @@ "outputs": [], "source": [ "%pip install neo4j\n", - "%pip install neo4j-viz" + "%pip install neo4j-viz\n", + "%pip install python-dotenv" ] }, { @@ -43,12 +44,13 @@ "outputs": [], "source": [ "import os\n", + "import dotenv\n", + "\n", + "dotenv.load_dotenv(\"../local.env\", override=True)\n", "\n", - "URI = os.environ.get(\"NEO4J_URI\", \"bolt://localhost:7687\")\n", "\n", - "auth = None\n", - "if os.environ.get(\"NEO4J_USER\") and os.environ.get(\"NEO4J_PASSWORD\"):\n", - " auth = (os.environ.get(\"NEO4J_USER\"), os.environ.get(\"NEO4J_PASSWORD\"))" + "URI = os.environ.get(\"NEO4J_URI\", \"bolt://localhost:7687\")\n", + "auth = (os.environ.get(\"NEO4J_USERNAME\"), os.environ.get(\"NEO4J_PASSWORD\"))" ] }, { @@ -1775,12 +1777,12 @@ "

Expected window.__NEO4J_VIZ_DATA__ to be set.

\n", "

This page should be generated by neo4j_viz's render() method.

\n", " \n", - " `,new Error(\"window.__NEO4J_VIZ_DATA__ is not defined\");const ypr={get(t){return x3[t]},on(){},off(){},set(){},save_changes(){}},_3=document.getElementById(\"neo4j-viz-26eaf825ab79\");if(!_3)throw new Error(\"Container element #neo4j-viz-26eaf825ab79 not found\");_3.style.width=x3.width??\"100%\";_3.style.height=x3.height??\"100vh\";mpr.render({model:ypr,el:_3});\n", + " `,new Error(\"window.__NEO4J_VIZ_DATA__ is not defined\");const ypr={get(t){return x3[t]},on(){},off(){},set(){},save_changes(){}},_3=document.getElementById(\"neo4j-viz-03e8377c9452\");if(!_3)throw new Error(\"Container element #neo4j-viz-03e8377c9452 not found\");_3.style.width=x3.width??\"100%\";_3.style.height=x3.height??\"100vh\";mpr.render({model:ypr,el:_3});\n", " \n", - " \n", + " \n", "\n", " \n", - "
\n", + "
\n", " \n", "\n" ], @@ -3421,12 +3423,12 @@ "

Expected window.__NEO4J_VIZ_DATA__ to be set.

\n", "

This page should be generated by neo4j_viz's render() method.

\n", " \n", - " `,new Error(\"window.__NEO4J_VIZ_DATA__ is not defined\");const ypr={get(t){return x3[t]},on(){},off(){},set(){},save_changes(){}},_3=document.getElementById(\"neo4j-viz-77c0906029ee\");if(!_3)throw new Error(\"Container element #neo4j-viz-77c0906029ee not found\");_3.style.width=x3.width??\"100%\";_3.style.height=x3.height??\"100vh\";mpr.render({model:ypr,el:_3});\n", + " `,new Error(\"window.__NEO4J_VIZ_DATA__ is not defined\");const ypr={get(t){return x3[t]},on(){},off(){},set(){},save_changes(){}},_3=document.getElementById(\"neo4j-viz-bcdfb11b9d7f\");if(!_3)throw new Error(\"Container element #neo4j-viz-bcdfb11b9d7f not found\");_3.style.width=x3.width??\"100%\";_3.style.height=x3.height??\"100vh\";mpr.render({model:ypr,el:_3});\n", " \n", - " \n", + " \n", "\n", " \n", - "
\n", + "
\n", " \n", "\n" ], diff --git a/justfile b/justfile index 9f5048a..f876c3e 100644 --- a/justfile +++ b/justfile @@ -18,13 +18,25 @@ py-test-gds: trap "cd $ENV_DIR && docker compose down" EXIT cd $ENV_DIR && docker compose up -d cd - + cd python-wrapper && \ NEO4J_URI=bolt://localhost:7687 \ - NEO4J_USER=neo4j \ + NEO4J_USERNAME=neo4j \ NEO4J_PASSWORD=password \ NEO4J_DB=neo4j \ - cd python-wrapper && uv run --group dev --extra gds pytest tests --include-neo4j-and-gds + uv run --group dev --extra gds pytest tests --include-neo4j-and-gds cd .. + +# this expects the local compose setup to be running. +py-test-gds-sessions filter="": + #!/usr/bin/env bash + cd python-wrapper && \ + GDS_SESSION_URI=bolt://localhost:7688 \ + 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 { "" } }} + local-neo4j-setup: #!/usr/bin/env bash set -e diff --git a/python-wrapper/pyproject.toml b/python-wrapper/pyproject.toml index a2b60e8..66b5e4e 100644 --- a/python-wrapper/pyproject.toml +++ b/python-wrapper/pyproject.toml @@ -42,7 +42,7 @@ requires-python = ">=3.10" [project.optional-dependencies] pandas = ["pandas>=2, <3", "pandas-stubs>=2, <3"] -gds = ["graphdatascience>=1, <2"] +gds = ["graphdatascience>=1.22, <2"] neo4j = ["neo4j"] snowflake = ["snowflake-snowpark-python>=1, <2"] @@ -60,7 +60,8 @@ dev = [ "streamlit==1.57.0", "matplotlib>=3.9.4", "jupyterlab>=4.5.7", - "anywidget[dev]" + "anywidget[dev]", + "python-dotenv" ] docs = [ "sphinx==8.1.3", @@ -76,9 +77,9 @@ notebook = [ "palettable>=3.3.3", "matplotlib>=3.9.4", "snowflake-snowpark-python==1.50.0", - "python-dotenv", "requests", "marimo", + "python-dotenv" ] [project.urls] @@ -113,7 +114,10 @@ markers = [ ] filterwarnings = [ "error", - "ignore:Jupyter is migrating its paths to use standard platformdirs:DeprecationWarning" + "ignore:Jupyter is migrating its paths to use standard platformdirs:DeprecationWarning", + # snowflake vendors an older `requests` whose dependency check rejects the chardet + # version pulled in transitively by the notebook group. Harmless; ignore it. + "ignore:.*doesn't match a supported version:snowflake.connector.vendored.requests.exceptions.RequestsDependencyWarning" ] [tool.ruff] @@ -174,9 +178,3 @@ exclude = [ ] plugins = ['pydantic.mypy'] untyped_calls_exclude=["nbconvert"] - -[tool.marimo.runtime] -output_max_bytes = 20_000_000 -# -#[tool.marimo.server] -#follow_symlink = true diff --git a/python-wrapper/src/neo4j_viz/gds.py b/python-wrapper/src/neo4j_viz/gds.py index e6a399a..78e37ba 100644 --- a/python-wrapper/src/neo4j_viz/gds.py +++ b/python-wrapper/src/neo4j_viz/gds.py @@ -2,11 +2,13 @@ import warnings from itertools import chain -from typing import Optional, cast +from typing import Collection, Optional from uuid import uuid4 import pandas as pd from graphdatascience import Graph, GraphDataScience +from graphdatascience.graph.v2 import GraphV2 +from graphdatascience.session import AuraGraphDataScience from neo4j_viz.colors import NEO4J_COLORS_DISCRETE, ColorSpace @@ -15,39 +17,43 @@ def _fetch_node_dfs( - gds: GraphDataScience, - G: Graph, + gds: GraphDataScience | AuraGraphDataScience, + G: GraphV2, node_properties_by_label: dict[str, list[str]], - node_labels: list[str], + node_labels: Collection[str], additional_db_node_properties: list[str], ) -> dict[str, pd.DataFrame]: return { - lbl: gds.graph.nodeProperties.stream( + lbl: gds.v2.graph.node_properties.stream( G, node_properties=node_properties_by_label[lbl], node_labels=[lbl], - separate_property_columns=True, db_node_properties=additional_db_node_properties, ) for lbl in node_labels } -def _fetch_rel_dfs(gds: GraphDataScience, G: Graph) -> list[pd.DataFrame]: - rel_types = G.relationship_types() - - rel_props = {rel_type: G.relationship_properties(rel_type) for rel_type in rel_types} +def _fetch_rel_dfs(gds: GraphDataScience | AuraGraphDataScience, G: GraphV2) -> list[pd.DataFrame]: + rel_props = G.relationship_properties() rel_dfs: list[pd.DataFrame] = [] + # Have to call per stream per relationship type as there was a bug in GDS < 2.21 for rel_type, props in rel_props.items(): - assert isinstance(props, list) - if len(props) > 0: - rel_df = gds.graph.relationshipProperties.stream( - G, relationship_types=rel_type, relationship_properties=list(props), separate_property_columns=True + rel_df = gds.v2.graph.relationships.stream( + G, relationship_types=[rel_type], relationship_properties=list(props) + ) + + # there was a bug in the v2 endpoints in GDS (1.22) where for dataframe would have the incorrect shape + if "propertyValue" and "relationshipProperty" in rel_df.keys(): + rel_df = rel_df.pivot( + index=["sourceNodeId", "targetNodeId", "relationshipType"], + columns="relationshipProperty", + values="propertyValue", ) - else: - rel_df = gds.graph.relationships.stream(G, relationship_types=[rel_type]) + rel_df = rel_df.reset_index() + rel_df.columns.name = None rel_dfs.append(rel_df) @@ -55,8 +61,8 @@ def _fetch_rel_dfs(gds: GraphDataScience, G: Graph) -> list[pd.DataFrame]: def from_gds( - gds: GraphDataScience, - G: Graph, + gds: GraphDataScience | AuraGraphDataScience, + G: Graph | GraphV2, node_properties: Optional[list[str]] = None, db_node_properties: Optional[list[str]] = None, max_node_count: int = 10_000, @@ -76,9 +82,9 @@ def from_gds( Parameters ---------- - gds : GraphDataScience - GraphDataScience object. - G : Graph + gds + GraphDataScience object. AuraGraphDataScience object if using Aura Graph Analytics. + G Graph object. node_properties : list[str], optional Additional properties to include in the visualization node, by default None which means that all node @@ -91,37 +97,41 @@ def from_gds( """ if db_node_properties is None: db_node_properties = [] + if isinstance(G, Graph): + G_v2 = gds.v2.graph.get(G.name()) + else: + G_v2 = G - node_properties_from_gds = G.node_properties() - assert isinstance(node_properties_from_gds, pd.Series) - actual_node_properties: dict[str, list[str]] = cast(dict[str, list[str]], node_properties_from_gds.to_dict()) - all_actual_node_properties = list(chain.from_iterable(actual_node_properties.values())) + gds_properties_per_label = G_v2.node_properties() + all_gds_properties = list(chain.from_iterable(gds_properties_per_label.values())) node_properties_by_label_sets: dict[str, set[str]] = dict() if node_properties is None: - node_properties_by_label_sets = {k: set(v) for k, v in actual_node_properties.items()} + node_properties_by_label_sets = {k: set(v) for k, v in gds_properties_per_label.items()} else: for prop in node_properties: - if prop not in all_actual_node_properties: + if prop not in all_gds_properties: raise ValueError(f"There is no node property '{prop}' in graph '{G.name()}'") - for label, props in actual_node_properties.items(): + for label, props in gds_properties_per_label.items(): node_properties_by_label_sets[label] = { - prop for prop in actual_node_properties[label] if prop in node_properties + prop for prop in gds_properties_per_label[label] if prop in node_properties } node_properties_by_label = {k: list(v) for k, v in node_properties_by_label_sets.items()} - node_count = G.node_count() + node_count = G_v2.node_count() if node_count > max_node_count: warnings.warn( - f"The '{G.name()}' projection's node count ({G.node_count()}) exceeds `max_node_count` ({max_node_count}), so subsampling will be applied. Increase `max_node_count` if needed" + f"The '{G_v2.name()}' projection's node count ({G_v2.node_count()}) exceeds `max_node_count` ({max_node_count}), so subsampling will be applied. Increase `max_node_count` if needed" ) sampling_ratio = float(max_node_count) / node_count sample_name = f"neo4j-viz_sample_{uuid4()}" - G_fetched, _ = gds.graph.sample.rwr(sample_name, G, samplingRatio=sampling_ratio, nodeLabelStratification=True) + G_fetched, _ = gds.v2.graph.sample.rwr( + G_v2, sample_name, sampling_ratio=sampling_ratio, node_label_stratification=True + ) else: - G_fetched = G + G_fetched = G_v2 property_name = None try: @@ -129,12 +139,12 @@ def from_gds( # as a temporary property to ensure that we have at least one property for each label to fetch if sum([len(props) == 0 for props in node_properties_by_label.values()]) > 0: property_name = f"neo4j-viz_property_{uuid4()}" - gds.degree.mutate(G_fetched, mutateProperty=property_name) + gds.v2.degree_centrality.mutate(G_fetched, mutate_property=property_name) for props in node_properties_by_label.values(): props.append(property_name) node_dfs = _fetch_node_dfs( - gds, G_fetched, node_properties_by_label, G_fetched.node_labels(), db_node_properties + gds, G_fetched, node_properties_by_label, node_properties_by_label.keys(), db_node_properties ) if property_name is not None: for df in node_dfs.values(): @@ -145,7 +155,7 @@ def from_gds( if G_fetched.name() != G.name(): G_fetched.drop() elif property_name is not None: - gds.graph.nodeProperties.drop(G_fetched, node_properties=[property_name]) + gds.v2.graph.node_properties.drop(G_fetched, node_properties=[property_name]) for df in node_dfs.values(): if property_name is not None and property_name in df.columns: @@ -154,7 +164,7 @@ def from_gds( node_props_df = pd.concat(node_dfs.values(), ignore_index=True, axis=0).drop_duplicates(subset=["nodeId"]) for lbl, df in node_dfs.items(): - if "labels" in all_actual_node_properties: + if "labels" in all_gds_properties: df.rename(columns={"labels": "__labels"}, inplace=True) df["labels"] = lbl diff --git a/python-wrapper/tests/conftest.py b/python-wrapper/tests/conftest.py index 40f7f4e..5bdf775 100644 --- a/python-wrapper/tests/conftest.py +++ b/python-wrapper/tests/conftest.py @@ -1,4 +1,5 @@ import os +import random from typing import Any, Generator import pytest @@ -31,56 +32,76 @@ def pytest_collection_modifyitems(config: Any, items: Any) -> None: @pytest.fixture(scope="package") -def aura_ds_instance() -> Generator[Any, None, None]: +def aura_db_instance() -> Generator[Any, None, None]: + if os.environ.get("NEO4J_URI", ""): + print(f"Skipping Aura DB setup since NEO4J_URI is set to {os.environ['NEO4J_URI']}") + yield None + return + if os.environ.get("AURA_API_CLIENT_ID", None) is None: yield None return - from tests.gds_helper import aura_api, create_aurads_instance + from tests.gds_helper import aura_api, create_auradb_instance api = aura_api() - id, dbms_connection_info = create_aurads_instance(api) + dbms_connection_info = create_auradb_instance(api) # setting as environment variables to run notebooks with this connection os.environ["NEO4J_URI"] = dbms_connection_info.get_uri() assert isinstance(dbms_connection_info.username, str) - os.environ["NEO4J_USER"] = dbms_connection_info.username + os.environ["NEO4J_USERNAME"] = dbms_connection_info.username assert isinstance(dbms_connection_info.password, str) os.environ["NEO4J_PASSWORD"] = dbms_connection_info.password + old_instance = os.environ.get("AURA_INSTANCEID", "") + if dbms_connection_info.aura_instance_id: + os.environ["AURA_INSTANCEID"] = dbms_connection_info.aura_instance_id + yield dbms_connection_info # Clear Neo4j_URI after test (rerun should create a new instance) - os.environ["NEO4J_URI"] = "" - api.delete_instance(id) + os.environ["AURA_INSTANCEID"] = old_instance + assert dbms_connection_info.aura_instance_id is not None + api.delete_instance(dbms_connection_info.aura_instance_id) @pytest.fixture(scope="package") -def gds(aura_ds_instance: Any) -> Generator[Any, None, None]: - from graphdatascience import GraphDataScience +def gds(aura_db_instance: Any) -> Generator[Any, None, None]: + from graphdatascience.session import SessionMemory - from tests.gds_helper import connect_to_plugin_gds + from tests.gds_helper import connect_to_local_gds_session, connect_to_plugin_gds, gds_sessions - if aura_ds_instance: - yield GraphDataScience( - endpoint=aura_ds_instance.uri, - auth=(aura_ds_instance.username, aura_ds_instance.password), - aura_ds=True, - database="neo4j", + if aura_db_instance: + sessions = gds_sessions() + + gds = sessions.get_or_create( + f"neo4j-viz-ci-{os.environ.get('GITHUB_RUN_ID', random.randint(0, 10**6))}", + memory=SessionMemory.m_2GB, + db_connection=aura_db_instance, ) + + yield gds + gds.delete() else: - NEO4J_URI = os.environ.get("NEO4J_URI", "neo4j://localhost:7687") - gds = connect_to_plugin_gds(NEO4J_URI) + neo4j_uri = os.environ["NEO4J_URI"] + neo4j_auth = (os.environ.get("NEO4J_USERNAME", "neo4j"), os.environ.get("NEO4J_PASSWORD", "password")) + + session_uri = os.environ.get("GDS_SESSION_URI") + if session_uri: + gds = connect_to_local_gds_session(session_uri, neo4j_uri, neo4j_auth) + else: + gds = connect_to_plugin_gds(neo4j_uri, neo4j_auth) # type: ignore yield gds gds.close() @pytest.fixture(scope="package") -def neo4j_driver(aura_ds_instance: Any) -> Generator[Any, None, None]: +def neo4j_driver(aura_db_instance: Any) -> Generator[Any, None, None]: import neo4j - if aura_ds_instance: + if aura_db_instance: driver = neo4j.GraphDatabase.driver( - aura_ds_instance.uri, auth=(aura_ds_instance.username, aura_ds_instance.password) + aura_db_instance.uri, auth=(aura_db_instance.username, aura_db_instance.password) ) else: NEO4J_URI = os.environ.get("NEO4J_URI", "neo4j://localhost:7687") diff --git a/python-wrapper/tests/gds_helper.py b/python-wrapper/tests/gds_helper.py index e5a0d3d..5d7a677 100644 --- a/python-wrapper/tests/gds_helper.py +++ b/python-wrapper/tests/gds_helper.py @@ -1,9 +1,10 @@ import os import re -from graphdatascience import GraphDataScience +from graphdatascience import GdsSessions, GraphDataScience +from graphdatascience.arrow_client.arrow_authentication import UsernamePasswordAuthentication from graphdatascience.semantic_version.semantic_version import SemanticVersion -from graphdatascience.session import DbmsConnectionInfo, SessionMemory +from graphdatascience.session import AuraAPICredentials, AuraGraphDataScience, DbmsConnectionInfo, SessionMemory from graphdatascience.session.aura_api import AuraApi from graphdatascience.session.aura_api_responses import InstanceCreateDetails from graphdatascience.version import __version__ @@ -26,12 +27,20 @@ def parse_version(version: str) -> SemanticVersion: GDS_VERSION = parse_version(__version__) -def connect_to_plugin_gds(uri: str) -> GraphDataScience: - NEO4J_AUTH = ("neo4j", "password") - if os.environ.get("NEO4J_USER"): - NEO4J_AUTH = (os.environ.get("NEO4J_USER", "DUMMY"), os.environ.get("NEO4J_PASSWORD", "neo4j")) +def connect_to_plugin_gds(uri: str, auth: tuple[str, str]) -> GraphDataScience: + return GraphDataScience(endpoint=uri, auth=auth, database="neo4j") - return GraphDataScience(endpoint=uri, auth=NEO4J_AUTH, database="neo4j") + +def connect_to_local_gds_session(session_uri: str, db_uri: str, db_auth: tuple[str, str]) -> AuraGraphDataScience: + session_bolt_connection_info = DbmsConnectionInfo(uri=session_uri, username="neo4j", password="password") + db_connection_info = DbmsConnectionInfo(uri=db_uri, username=db_auth[0], password=db_auth[1]) + + return AuraGraphDataScience.create( + session_bolt_connection_info=session_bolt_connection_info, + arrow_authentication=UsernamePasswordAuthentication("neo4j", "password"), + session_lifecycle_manager=None, # type: ignore + db_endpoint=db_connection_info, + ) def aura_api() -> AuraApi: @@ -49,21 +58,29 @@ def aura_api() -> AuraApi: ) -def create_aurads_instance(api: AuraApi) -> tuple[str, DbmsConnectionInfo]: - # Switch to Sessions once they can be created without a DB +def gds_sessions() -> GdsSessions: + return GdsSessions( + api_credentials=AuraAPICredentials( + client_id=os.environ["AURA_API_CLIENT_ID"], + client_secret=os.environ["AURA_API_CLIENT_SECRET"], + project_id=os.environ.get("AURA_API_TENANT_ID"), + ) + ) + + +def create_auradb_instance(api: AuraApi) -> DbmsConnectionInfo: instance_details: InstanceCreateDetails = api.create_instance( - name="ci-neo4j-viz-session", - memory=SessionMemory.m_8GB.value, + name="ci-neo4j-viz-db", + memory=SessionMemory.m_2GB.value, cloud_provider="gcp", region="europe-west1", + type="enterprise-db", ) wait_result = api.wait_for_instance_running(instance_id=instance_details.id) if wait_result.error: raise Exception(f"Error while waiting for instance to be running: {wait_result.error}") - return instance_details.id, DbmsConnectionInfo( - uri=wait_result.connection_url, - username="neo4j", - password=instance_details.password, + return DbmsConnectionInfo( + username="neo4j", password=instance_details.password, aura_instance_id=instance_details.id ) diff --git a/python-wrapper/tests/test_gds.py b/python-wrapper/tests/test_gds.py index fb078aa..ef9c4d8 100644 --- a/python-wrapper/tests/test_gds.py +++ b/python-wrapper/tests/test_gds.py @@ -20,12 +20,23 @@ def db_setup(gds: Any) -> Generator[None, None, None]: gds.run_cypher("MATCH (n:_CI_A|_CI_B) DETACH DELETE n") +def project_graph(gds: Any) -> Any: + from graphdatascience import GraphDataScience + from graphdatascience.session import AuraGraphDataScience + + if isinstance(gds, GraphDataScience): + return gds.v2.graph.project("g2", "*", "*") + elif isinstance(gds, AuraGraphDataScience): + return gds.v2.graph.project("g2", "MATCH (n)–->(m) RETURN gds.graph.project.remote(n, m)") + raise Exception(f"Unsupported GDS type {type(gds)}") + + @pytest.mark.filterwarnings("ignore::DeprecationWarning") @pytest.mark.requires_neo4j_and_gds def test_from_gds_integration_all_db_properties(gds: Any, db_setup: None) -> None: from neo4j_viz.gds import from_gds - with gds.graph.project("g2", ["_CI_A", "_CI_B"], "*") as G: + with project_graph(gds) as G: VG = from_gds(gds, G, db_node_properties=["name"]) assert len(VG.nodes) == 2 @@ -55,7 +66,7 @@ def test_from_gds_integration_all_properties(gds: Any) -> None: } ) - with gds.graph.construct("flo", nodes, rels) as G: + with gds.v2.graph.construct("flo", nodes, rels) as G: VG = from_gds(gds, G) assert len(VG.nodes) == 3 @@ -106,7 +117,7 @@ def test_from_gds_integration_all_properties(gds: Any) -> None: def test_from_gds_sample(gds: Any) -> None: from neo4j_viz.gds import from_gds - with gds.graph.generate("hello", node_count=11_000, average_degree=1) as G: + with gds.v2.graph.generate("hello", node_count=11_000, average_degree=1) as G: with pytest.warns( UserWarning, match=re.escape( @@ -159,7 +170,7 @@ def test_from_gds_hetero(gds: Any) -> None: } ) - with gds.graph.construct("flo", [A_nodes, B_nodes], [X_rels, Y_rels]) as G: + with gds.v2.graph.construct("flo", [A_nodes, B_nodes], [X_rels, Y_rels]) as G: VG = from_gds( gds, G, diff --git a/python-wrapper/tests/test_notebooks.py b/python-wrapper/tests/test_notebooks.py index d3b7c60..939a01e 100644 --- a/python-wrapper/tests/test_notebooks.py +++ b/python-wrapper/tests/test_notebooks.py @@ -1,3 +1,4 @@ +import os import pathlib import signal import sys @@ -6,6 +7,7 @@ import nbformat import pytest +from dotenv import load_dotenv from nbclient.exceptions import CellExecutionError from nbconvert.preprocessors.execute import ExecutePreprocessor @@ -117,6 +119,8 @@ def run_notebooks(filter_func: Callable[[str], bool]) -> None: def test_neo4j(gds: Any) -> None: neo4j_notebooks = ["neo4j-example.ipynb", "gds-example.ipynb"] + load_dotenv(os.environ.get("ENV_FILE")) + def filter_func(notebook: str) -> bool: return notebook in neo4j_notebooks diff --git a/python-wrapper/uv.lock b/python-wrapper/uv.lock index 39871d6..32ee284 100644 --- a/python-wrapper/uv.lock +++ b/python-wrapper/uv.lock @@ -193,15 +193,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, ] -[[package]] -name = "async-generator" -version = "1.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/b6/6fa6b3b598a03cba5e80f829e0dadbb49d7645f523d209b2fb7ea0bbb02a/async_generator-1.10.tar.gz", hash = "sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144", size = 29870, upload-time = "2018-08-01T03:36:21.69Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/71/52/39d20e03abd0ac9159c162ec24b93fbcaa111e8400308f2465432495ca2b/async_generator-1.10-py3-none-any.whl", hash = "sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b", size = 18857, upload-time = "2018-08-01T03:36:20.029Z" }, -] - [[package]] name = "async-lru" version = "2.3.0" @@ -1102,7 +1093,7 @@ wheels = [ [[package]] name = "graphdatascience" -version = "1.21" +version = "1.22" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "multimethod" }, @@ -1118,9 +1109,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/e9/a49127cc728c7ea2acaa2aad44f06f291aabe0a5a700f980ffdf88f853da/graphdatascience-1.21.tar.gz", hash = "sha256:5a9f16be010eee69d027c5b1ea76bba7029fad8426646c603f137cc9841e3934", size = 1746004, upload-time = "2026-04-16T11:49:01.974Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/9f/290f299f770ef9aad73b0c49e5683a6f7455cbc16b1b0a5546109a6f0fd9/graphdatascience-1.22.tar.gz", hash = "sha256:00bfad65dcaccd3d40f1f4e23661125dc7400ecffdb5a04c34512fc9a287f5a9", size = 1781480, upload-time = "2026-06-04T11:57:24.877Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/67/a2a944d6d6c0baea1b30017435d2b04ce0b766328c277fd90b7ee36b7bbc/graphdatascience-1.21-py3-none-any.whl", hash = "sha256:4813c3fa6eef5d469a7d344ac37b8992461ed067ee30b6b11bd17c3d5c471592", size = 2016416, upload-time = "2026-04-16T11:48:59.86Z" }, + { url = "https://files.pythonhosted.org/packages/2f/4c/a55ff1ad641414456be2b2bc3d57a4d2bb86c5dadca378c0c83503bddbb4/graphdatascience-1.22-py3-none-any.whl", hash = "sha256:e2dfbd206daa61c39d00653d420edc63f409674d1449d6d0253794e1807b7604", size = 2101782, upload-time = "2026-06-04T11:57:23.036Z" }, ] [[package]] @@ -1158,6 +1149,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/b9/be66eb0decd730d89b9c94f930e4b8d87787b05724bb84af98bfd825f72c/httptools-0.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bf3b6f807c8541503cecfbb8a8dffb385640d0d96102f3d112aa8740f9b7c826", size = 208805, upload-time = "2026-05-25T22:16:50.434Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f7/b4d41eaae2869d31356bc4bbf546f44fae83ff298af0a043ca0625b06773/httptools-0.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da684f2e1aa2ee9bdcb083f3f3a68c5956750b375bc5df864d3a5f0c42a40b77", size = 113527, upload-time = "2026-05-25T22:16:51.672Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e4/77487e14fc7be47180fd0eb4267c7486d0cc59b74031839a3daf8650136b/httptools-0.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6f21e2a3b0067bbe7f67e34cfd16276af556e5e52f4c7503be0cb5f90e905e4", size = 450035, upload-time = "2026-05-25T22:16:53.313Z" }, + { url = "https://files.pythonhosted.org/packages/da/72/5a8f787e323f56fbd86c32a4be92a86776e4cfe8b4317db999f452028362/httptools-0.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea897f0c729581ebf72131a438a7932d9b14efef72d75ada966700cac3caaeb", size = 451101, upload-time = "2026-05-25T22:16:54.696Z" }, + { url = "https://files.pythonhosted.org/packages/ed/41/b44a25560955197674b6744cb903664300e239235a5eaa69df0890d87054/httptools-0.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c0d726cc107fceb7d45f978483b4b70dd8caa836f5914d3434bb18628eb73813", size = 436140, upload-time = "2026-05-25T22:16:56.239Z" }, + { url = "https://files.pythonhosted.org/packages/74/b0/054aac84c03d7e097bf4c605fb7e74eec3d65c0276adf64ee97f3a103ff5/httptools-0.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9878eb2785ba5eb70631ad269b37976f73d647955e26c91d490eb8a4edfda4ba", size = 437041, upload-time = "2026-05-25T22:16:57.716Z" }, + { url = "https://files.pythonhosted.org/packages/bb/e8/86b85bbc0ac7892232f1a99ab96a9aa71936984fa06adfc0afc83ca7789e/httptools-0.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:b205e5f5523fa039679da0dfe5a10132b2a4abeae6a86fdd1ddc035f7f836557", size = 90454, upload-time = "2026-05-25T22:16:58.871Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload-time = "2026-05-25T22:16:59.717Z" }, + { url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload-time = "2026-05-25T22:17:00.795Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload-time = "2026-05-25T22:17:02.014Z" }, + { url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload-time = "2026-05-25T22:17:03.109Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload-time = "2026-05-25T22:17:04.443Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload-time = "2026-05-25T22:17:05.476Z" }, + { url = "https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07", size = 90991, upload-time = "2026-05-25T22:17:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -1191,18 +1232,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, ] -[[package]] -name = "importlib-metadata" -version = "9.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "zipp" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, -] - [[package]] name = "iniconfig" version = "2.3.0" @@ -2469,7 +2498,7 @@ wheels = [ [[package]] name = "neo4j-viz" -version = "1.4.0" +version = "1.5.0" source = { editable = "." } dependencies = [ { name = "anywidget" }, @@ -2507,6 +2536,7 @@ dev = [ { name = "palettable" }, { name = "pytest" }, { name = "pytest-mock" }, + { name = "python-dotenv" }, { name = "ruff" }, { name = "selenium" }, { name = "streamlit" }, @@ -2534,7 +2564,7 @@ notebook = [ requires-dist = [ { name = "anywidget", specifier = ">=0.9,<1" }, { name = "enum-tools", specifier = "==0.13.0" }, - { name = "graphdatascience", marker = "extra == 'gds'", specifier = ">=1,<2" }, + { name = "graphdatascience", marker = "extra == 'gds'", specifier = ">=1.22,<2" }, { name = "ipython", specifier = ">=7,<10" }, { name = "neo4j", marker = "extra == 'neo4j'" }, { name = "pandas", marker = "extra == 'pandas'", specifier = ">=2,<3" }, @@ -2557,9 +2587,10 @@ dev = [ { name = "palettable", specifier = "==3.3.3" }, { name = "pytest", specifier = "==9.0.3" }, { name = "pytest-mock", specifier = "==3.15.1" }, + { name = "python-dotenv" }, { name = "ruff", specifier = "==0.15.12" }, - { name = "selenium", specifier = "==4.40.0" }, - { name = "streamlit", specifier = "==1.56.0" }, + { name = "selenium", specifier = "==4.43.0" }, + { name = "streamlit", specifier = "==1.57.0" }, ] docs = [ { name = "enum-tools", extras = ["sphinx"] }, @@ -2577,7 +2608,7 @@ notebook = [ { name = "pykernel", specifier = ">=0.1.6" }, { name = "python-dotenv" }, { name = "requests" }, - { name = "snowflake-snowpark-python", specifier = "==1.42.0" }, + { name = "snowflake-snowpark-python", specifier = "==1.50.0" }, ] [[package]] @@ -3497,6 +3528,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/be/0631a861af4d1c875f096c07d34e9a63639560a717130e7a87cbc82b7e3f/python_json_logger-4.1.0-py3-none-any.whl", hash = "sha256:132994765cf75bf44554be9aa49b06ef2345d23661a96720262716438141b6b2", size = 15021, upload-time = "2026-03-29T04:39:55.266Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + [[package]] name = "pytz" version = "2026.2" @@ -3967,22 +4007,19 @@ wheels = [ [[package]] name = "selenium" -version = "4.40.0" +version = "4.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "trio" }, - { name = "trio-typing" }, { name = "trio-websocket" }, - { name = "types-certifi" }, - { name = "types-urllib3" }, { name = "typing-extensions" }, { name = "urllib3", extra = ["socks"] }, { name = "websocket-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/ef/a5727fa7b33d20d296322adf851b76072d8d3513e1b151969d3228437faf/selenium-4.40.0.tar.gz", hash = "sha256:a88f5905d88ad0b84991c2386ea39e2bbde6d6c334be38df5842318ba98eaa8c", size = 930444, upload-time = "2026-01-18T23:12:31.565Z" } +sdist = { url = "https://files.pythonhosted.org/packages/09/6a/fe950b498a3c570ab538ad1c2b60f18863eecf077a865eea4459f3fa78a9/selenium-4.43.0.tar.gz", hash = "sha256:bada5c08a989f812728a4b5bea884d8e91894e939a441cc3a025201ce718581e", size = 967747, upload-time = "2026-04-10T06:47:03.149Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/74/eb9d6540aca1911106fa0877b8e9ef24171bc18857937a6b0ffe0586c623/selenium-4.40.0-py3-none-any.whl", hash = "sha256:c8823fc02e2c771d9ad9a0cf899cee7de1a57a6697e3d0b91f67566129f2b729", size = 9608184, upload-time = "2026-01-18T23:12:29.435Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/0c55fbb0275fc368676ea50514ce7d7839d799a8b3ff8425f380186c7626/selenium-4.43.0-py3-none-any.whl", hash = "sha256:4f97639055dcfa9eadf8ccf549ba7b0e49c655d4e2bde19b9a44e916b754e769", size = 9573091, upload-time = "2026-04-10T06:47:01.134Z" }, ] [[package]] @@ -4088,7 +4125,7 @@ wheels = [ [[package]] name = "snowflake-snowpark-python" -version = "1.42.0" +version = "1.50.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cloudpickle" }, @@ -4101,9 +4138,9 @@ dependencies = [ { name = "tzlocal" }, { name = "wheel" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/e3/b70799997481185cdad44b0786c7597764935d78d71632b4735fb05d63a1/snowflake_snowpark_python-1.42.0.tar.gz", hash = "sha256:e994b3860c816d1b5fdf0c6272f8d9e41505e470140b063ff9418d234fd8cc00", size = 1781749, upload-time = "2025-10-28T18:10:52.418Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/f9/c7e4d0f4dceb6cf1b88ed984948af5471ecc19514a7e519da7276226429f/snowflake_snowpark_python-1.50.0.tar.gz", hash = "sha256:8af823326c2681333bf59ad3d6152b07098b7926165667a7fdcebd5adb53642f", size = 1760444, upload-time = "2026-04-23T20:33:56.522Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/ea/a3f1ff82aa144fd072f4be440ed636f4c298a7ee7a278e68709cf2753da5/snowflake_snowpark_python-1.42.0-py3-none-any.whl", hash = "sha256:fd92a3633b79573bb481b6e85a1434842758637dc6a30b32b9c5ce2824f4296d", size = 1825602, upload-time = "2025-10-28T18:10:48.778Z" }, + { url = "https://files.pythonhosted.org/packages/b3/11/0fbeb214832e5f9a6c8123e8de3e94610cab9c9a94eb1f011580f717f682/snowflake_snowpark_python-1.50.0-py3-none-any.whl", hash = "sha256:1a0140dba9a4d44910a052110494069fc9142577ef5db67d58abe6996f420a11", size = 1851309, upload-time = "2026-04-23T20:33:54.654Z" }, ] [[package]] @@ -4360,14 +4397,17 @@ wheels = [ [[package]] name = "streamlit" -version = "1.56.0" +version = "1.57.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "altair" }, + { name = "anyio" }, { name = "blinker" }, { name = "cachetools" }, { name = "click" }, { name = "gitpython" }, + { name = "httptools" }, + { name = "itsdangerous" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "packaging" }, @@ -4376,16 +4416,19 @@ dependencies = [ { name = "protobuf" }, { name = "pyarrow" }, { name = "pydeck" }, + { name = "python-multipart" }, { name = "requests" }, + { name = "starlette" }, { name = "tenacity" }, { name = "toml" }, - { name = "tornado" }, { name = "typing-extensions" }, + { name = "uvicorn" }, { name = "watchdog", marker = "sys_platform != 'darwin'" }, + { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/03/85/7c669b3a1336d34ef39fa9760fbd343185f3b15db2ad0838fd78423d1c7f/streamlit-1.56.0.tar.gz", hash = "sha256:1176acfa89ae1318b79078e8efe689a9d02e8d58e325c00fc0e55fa2f3fe8d6a", size = 8559239, upload-time = "2026-03-31T22:29:38.59Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5d/f8/b2daf7a5f8ae15527daf94406e771bb6075e958a01c3dde9eba79dc3c9a3/streamlit-1.57.0.tar.gz", hash = "sha256:0b028d305c1a1a757071b2c9504966787602842fc8af6e873795ca58d2b4d12f", size = 8678859, upload-time = "2026-04-28T22:13:32.238Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/91/cb6f13a89e376ef179309d74f37a70ea0041d5e4b5ba5c4836dbf6e020ad/streamlit-1.56.0-py3-none-any.whl", hash = "sha256:8677a335734a30a51bc57ad0ec910e365d95f7c456fc02c60032927cd0729dc5", size = 9052089, upload-time = "2026-03-31T22:29:36.342Z" }, + { url = "https://files.pythonhosted.org/packages/d8/1a/3ca2293d8552bacea3e67e9600d2d1df7df4a325059769ad83d91c279595/streamlit-1.57.0-py3-none-any.whl", hash = "sha256:0d1d41972aeade5637dbb0e7f0eefa5312272f85304923d240a1b1f0475249c8", size = 9194216, upload-time = "2026-04-28T22:13:29.624Z" }, ] [[package]] @@ -4569,23 +4612,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1c/93/dab25dc87ac48da0fe0f6419e07d0bfd98799bed4e05e7b9e0f85a1a4b4b/trio-0.33.0-py3-none-any.whl", hash = "sha256:3bd5d87f781d9b0192d592aef28691f8951d6c2e41b7e1da4c25cde6c180ae9b", size = 510294, upload-time = "2026-02-14T18:40:53.313Z" }, ] -[[package]] -name = "trio-typing" -version = "0.10.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "async-generator" }, - { name = "importlib-metadata" }, - { name = "mypy-extensions" }, - { name = "packaging" }, - { name = "trio" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b5/74/a87aafa40ec3a37089148b859892cbe2eef08d132c816d58a60459be5337/trio-typing-0.10.0.tar.gz", hash = "sha256:065ee684296d52a8ab0e2374666301aec36ee5747ac0e7a61f230250f8907ac3", size = 38747, upload-time = "2023-12-01T02:54:55.508Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/89/ff/9bd795273eb14fac7f6a59d16cc8c4d0948a619a1193d375437c7f50f3eb/trio_typing-0.10.0-py3-none-any.whl", hash = "sha256:6d0e7ec9d837a2fe03591031a172533fbf4a1a95baf369edebfc51d5a49f0264", size = 42224, upload-time = "2023-12-01T02:54:54.1Z" }, -] - [[package]] name = "trio-websocket" version = "0.12.2" @@ -4601,15 +4627,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/19/eb640a397bba49ba49ef9dbe2e7e5c04202ba045b6ce2ec36e9cadc51e04/trio_websocket-0.12.2-py3-none-any.whl", hash = "sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6", size = 21221, upload-time = "2025-02-25T05:16:57.545Z" }, ] -[[package]] -name = "types-certifi" -version = "2021.10.8.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/68/943c3aeaf14624712a0357c4a67814dba5cea36d194f5c764dad7959a00c/types-certifi-2021.10.8.3.tar.gz", hash = "sha256:72cf7798d165bc0b76e1c10dd1ea3097c7063c42c21d664523b928e88b554a4f", size = 2095, upload-time = "2022-06-09T15:19:05.244Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/63/2463d89481e811f007b0e1cd0a91e52e141b47f9de724d20db7b861dcfec/types_certifi-2021.10.8.3-py3-none-any.whl", hash = "sha256:b2d1e325e69f71f7c78e5943d410e650b4707bb0ef32e4ddf3da37f54176e88a", size = 2136, upload-time = "2022-06-09T15:19:03.127Z" }, -] - [[package]] name = "types-pytz" version = "2026.1.1.20260408" @@ -4619,15 +4636,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/90/12c059e6bb330a22d9cc97daf027ac7fb7f50fbf518e4d88185b4d39120e/types_pytz-2026.1.1.20260408-py3-none-any.whl", hash = "sha256:c7e4dec76221fb7d0c97b91ad8561d689bebe39b6bcb7b728387e7ffd8cde788", size = 10124, upload-time = "2026-04-08T04:28:13.353Z" }, ] -[[package]] -name = "types-urllib3" -version = "1.26.25.14" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/73/de/b9d7a68ad39092368fb21dd6194b362b98a1daeea5dcfef5e1adb5031c7e/types-urllib3-1.26.25.14.tar.gz", hash = "sha256:229b7f577c951b8c1b92c1bc2b2fdb0b49847bd2af6d1cc2a2e3dd340f3bda8f", size = 11239, upload-time = "2023-07-20T15:19:31.307Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/11/7b/3fc711b2efea5e85a7a0bbfe269ea944aa767bbba5ec52f9ee45d362ccf3/types_urllib3-1.26.25.14-py3-none-any.whl", hash = "sha256:9683bbb7fb72e32bfe9d2be6e04875fbe1b3eeec3cbb4ea231435aa7fd6b4f0e", size = 15377, upload-time = "2023-07-20T15:19:30.379Z" }, -] - [[package]] name = "typing-extensions" version = "4.15.0" @@ -4964,12 +4972,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b wheels = [ { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, ] - -[[package]] -name = "zipp" -version = "3.23.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/21/093488dfc7cc8964ded15ab726fad40f25fd3d788fd741cc1c5a17d78ee8/zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110", size = 25965, upload-time = "2026-04-13T23:21:46.6Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/08/8a/0861bec20485572fbddf3dfba2910e38fe249796cb73ecdeb74e07eeb8d3/zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", size = 10378, upload-time = "2026-04-13T23:21:45.386Z" }, -] From 4ed1b894521f5ba516171f52b55e0a944d443f35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Fri, 5 Jun 2026 15:59:51 +0200 Subject: [PATCH 03/23] Fix setup for AGA --- examples/gds-example.ipynb | 34 +----------------------- examples/neo4j-example.ipynb | 2 +- python-wrapper/tests/conftest.py | 42 ++++++++++++++++-------------- python-wrapper/tests/gds_helper.py | 21 ++++++++++----- 4 files changed, 39 insertions(+), 60 deletions(-) diff --git a/examples/gds-example.ipynb b/examples/gds-example.ipynb index 661f6ff..e9606cc 100644 --- a/examples/gds-example.ipynb +++ b/examples/gds-example.ipynb @@ -2,7 +2,6 @@ "cells": [ { "cell_type": "markdown", - "id": "e613852e", "metadata": {}, "source": [ "# Visualizing Neo4j Graph Data Science (GDS) Graphs" @@ -11,7 +10,6 @@ { "cell_type": "code", "execution_count": null, - "id": "7149a3b4", "metadata": {}, "outputs": [], "source": [ @@ -23,18 +21,16 @@ { "cell_type": "code", "execution_count": null, - "id": "a6a8c792", "metadata": {}, "outputs": [], "source": [ "import dotenv\n", "\n", - "dotenv.load_dotenv(\"../local.env\", override=True)" + "dotenv.load_dotenv()" ] }, { "cell_type": "markdown", - "id": "533c652a", "metadata": {}, "source": [ "## Setup GDS graph\n", @@ -51,7 +47,6 @@ { "cell_type": "code", "execution_count": null, - "id": "3d98bfe7", "metadata": {}, "outputs": [], "source": [ @@ -100,7 +95,6 @@ { "cell_type": "code", "execution_count": null, - "id": "051e4229", "metadata": {}, "outputs": [], "source": [ @@ -109,14 +103,12 @@ }, { "cell_type": "markdown", - "id": "f2dff187", "metadata": {}, "source": [] }, { "cell_type": "code", "execution_count": null, - "id": "618e4529", "metadata": {}, "outputs": [], "source": [ @@ -130,7 +122,6 @@ }, { "cell_type": "markdown", - "id": "2a7ccb13", "metadata": {}, "source": [ "## Visualization" @@ -139,7 +130,6 @@ { "cell_type": "code", "execution_count": null, - "id": "9d29891b", "metadata": {}, "outputs": [], "source": [ @@ -155,7 +145,6 @@ { "cell_type": "code", "execution_count": null, - "id": "00929fe9", "metadata": {}, "outputs": [], "source": [ @@ -164,7 +153,6 @@ }, { "cell_type": "markdown", - "id": "5246cda2", "metadata": {}, "source": [ "### Changing captions\n", @@ -176,7 +164,6 @@ { "cell_type": "code", "execution_count": null, - "id": "449956ba", "metadata": {}, "outputs": [], "source": [ @@ -187,7 +174,6 @@ { "cell_type": "code", "execution_count": null, - "id": "d3289f24", "metadata": {}, "outputs": [], "source": [ @@ -196,7 +182,6 @@ }, { "cell_type": "markdown", - "id": "7eb5e6a2", "metadata": {}, "source": [ "## Sizing the nodes\n", @@ -207,7 +192,6 @@ { "cell_type": "code", "execution_count": null, - "id": "80801ca0", "metadata": {}, "outputs": [], "source": [ @@ -217,7 +201,6 @@ }, { "cell_type": "markdown", - "id": "9f0e22eb", "metadata": {}, "source": [ "### Coloring" @@ -225,7 +208,6 @@ }, { "cell_type": "markdown", - "id": "e2eda873", "metadata": {}, "source": [ "There are two main ways of coloring the nodes of a graph:\n", @@ -239,7 +221,6 @@ { "cell_type": "code", "execution_count": null, - "id": "b1c33ce3", "metadata": {}, "outputs": [], "source": [ @@ -249,7 +230,6 @@ }, { "cell_type": "markdown", - "id": "9e3d9b21", "metadata": {}, "source": [ "Now, let us color by our continuous node field \"size\" that we computed above with PageRank, again using the default colors.\n", @@ -260,7 +240,6 @@ { "cell_type": "code", "execution_count": null, - "id": "a25c21cc", "metadata": {}, "outputs": [], "source": [ @@ -272,7 +251,6 @@ }, { "cell_type": "markdown", - "id": "170d3457", "metadata": {}, "source": [ "#### Custom coloring\n", @@ -284,7 +262,6 @@ { "cell_type": "code", "execution_count": null, - "id": "d0fbf37e", "metadata": {}, "outputs": [], "source": [ @@ -294,7 +271,6 @@ { "cell_type": "code", "execution_count": null, - "id": "c5027692", "metadata": {}, "outputs": [], "source": [ @@ -313,7 +289,6 @@ { "cell_type": "code", "execution_count": null, - "id": "58e11be0", "metadata": {}, "outputs": [], "source": [ @@ -322,7 +297,6 @@ }, { "cell_type": "markdown", - "id": "397221c3", "metadata": {}, "source": [ "### Render options\n", @@ -339,7 +313,6 @@ { "cell_type": "code", "execution_count": null, - "id": "d639feeb", "metadata": {}, "outputs": [], "source": [ @@ -350,7 +323,6 @@ }, { "cell_type": "markdown", - "id": "0120a2aa", "metadata": {}, "source": [ "## Saving the visualization" @@ -359,7 +331,6 @@ { "cell_type": "code", "execution_count": null, - "id": "1993a72c", "metadata": {}, "outputs": [], "source": [ @@ -374,7 +345,6 @@ }, { "cell_type": "markdown", - "id": "f2bc05f4", "metadata": {}, "source": [ "## Cleanup\n", @@ -385,7 +355,6 @@ { "cell_type": "code", "execution_count": null, - "id": "aa3c4f06", "metadata": {}, "outputs": [], "source": [ @@ -395,7 +364,6 @@ { "cell_type": "code", "execution_count": null, - "id": "c2af45ef", "metadata": {}, "outputs": [], "source": [ diff --git a/examples/neo4j-example.ipynb b/examples/neo4j-example.ipynb index e2149fa..2e8da54 100644 --- a/examples/neo4j-example.ipynb +++ b/examples/neo4j-example.ipynb @@ -46,7 +46,7 @@ "import os\n", "import dotenv\n", "\n", - "dotenv.load_dotenv(\"../local.env\", override=True)\n", + "dotenv.load_dotenv()\n", "\n", "\n", "URI = os.environ.get(\"NEO4J_URI\", \"bolt://localhost:7687\")\n", diff --git a/python-wrapper/tests/conftest.py b/python-wrapper/tests/conftest.py index 5bdf775..543b276 100644 --- a/python-wrapper/tests/conftest.py +++ b/python-wrapper/tests/conftest.py @@ -42,27 +42,31 @@ def aura_db_instance() -> Generator[Any, None, None]: yield None return - from tests.gds_helper import aura_api, create_auradb_instance + from tests.gds_helper import aura_api, create_auradb_instance, wait_for_instance api = aura_api() - dbms_connection_info = create_auradb_instance(api) - - # setting as environment variables to run notebooks with this connection - os.environ["NEO4J_URI"] = dbms_connection_info.get_uri() - assert isinstance(dbms_connection_info.username, str) - os.environ["NEO4J_USERNAME"] = dbms_connection_info.username - assert isinstance(dbms_connection_info.password, str) - os.environ["NEO4J_PASSWORD"] = dbms_connection_info.password - old_instance = os.environ.get("AURA_INSTANCEID", "") - if dbms_connection_info.aura_instance_id: - os.environ["AURA_INSTANCEID"] = dbms_connection_info.aura_instance_id - - yield dbms_connection_info - - # Clear Neo4j_URI after test (rerun should create a new instance) - os.environ["AURA_INSTANCEID"] = old_instance - assert dbms_connection_info.aura_instance_id is not None - api.delete_instance(dbms_connection_info.aura_instance_id) + instance_details = create_auradb_instance(api) + + try: + dbms_connection_info = wait_for_instance(api, instance_details) + + # setting as environment variables to run notebooks with this connection + os.environ["NEO4J_URI"] = dbms_connection_info.get_uri() + assert isinstance(dbms_connection_info.username, str) + os.environ["NEO4J_USERNAME"] = dbms_connection_info.username + assert isinstance(dbms_connection_info.password, str) + os.environ["NEO4J_PASSWORD"] = dbms_connection_info.password + old_instance = os.environ.get("AURA_INSTANCEID", "") + if dbms_connection_info.aura_instance_id: + os.environ["AURA_INSTANCEID"] = dbms_connection_info.aura_instance_id + + yield dbms_connection_info + + # Clear Neo4j_URI after test (rerun should create a new instance) + os.environ["AURA_INSTANCEID"] = old_instance + assert dbms_connection_info.aura_instance_id is not None + finally: + api.delete_instance(instance_details.id) @pytest.fixture(scope="package") diff --git a/python-wrapper/tests/gds_helper.py b/python-wrapper/tests/gds_helper.py index 5d7a677..8644fa2 100644 --- a/python-wrapper/tests/gds_helper.py +++ b/python-wrapper/tests/gds_helper.py @@ -1,3 +1,4 @@ +import logging import os import re @@ -48,13 +49,13 @@ def aura_api() -> AuraApi: return AuraApi( client_id=os.environ["AURA_API_CLIENT_ID"], client_secret=os.environ["AURA_API_CLIENT_SECRET"], - project_id=os.environ.get("AURA_API_TENANT_ID"), + project_id=os.environ.get("AURA_API_PROJECT_ID"), ) else: return AuraApi( client_id=os.environ["AURA_API_CLIENT_ID"], client_secret=os.environ["AURA_API_CLIENT_SECRET"], - tenant_id=os.environ.get("AURA_API_TENANT_ID"), # type: ignore + tenant_id=os.environ.get("AURA_API_PROJECT_ID"), # type: ignore ) @@ -63,24 +64,30 @@ def gds_sessions() -> GdsSessions: api_credentials=AuraAPICredentials( client_id=os.environ["AURA_API_CLIENT_ID"], client_secret=os.environ["AURA_API_CLIENT_SECRET"], - project_id=os.environ.get("AURA_API_TENANT_ID"), + project_id=os.environ.get("AURA_API_PROJECT_ID"), ) ) -def create_auradb_instance(api: AuraApi) -> DbmsConnectionInfo: +def create_auradb_instance(api: AuraApi) -> InstanceCreateDetails: + type = "enterprise-db" if os.environ.get("AURA_ENTERPRISE_PROJECT", "false").lower() == "true" else "professional-db" instance_details: InstanceCreateDetails = api.create_instance( name="ci-neo4j-viz-db", memory=SessionMemory.m_2GB.value, cloud_provider="gcp", region="europe-west1", - type="enterprise-db", + type=type, ) + logger = logging.getLogger(__name__) + logger.debug(f"Created instance with ID: {instance_details.id}") - wait_result = api.wait_for_instance_running(instance_id=instance_details.id) + return instance_details + +def wait_for_instance(api: AuraApi, instance_details: InstanceCreateDetails) -> DbmsConnectionInfo: + wait_result = api.wait_for_instance_running(instance_id=instance_details.id, max_wait_time=600) if wait_result.error: raise Exception(f"Error while waiting for instance to be running: {wait_result.error}") return DbmsConnectionInfo( - username="neo4j", password=instance_details.password, aura_instance_id=instance_details.id + username="neo4j", password=instance_details.password, aura_instance_id=instance_details.id, uri=wait_result.connection_url ) From c2e5ef7089cdea77e78ac19a58b3c5fd1b31ef8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Fri, 5 Jun 2026 16:14:49 +0200 Subject: [PATCH 04/23] Fix cell ids in gds notebook --- examples/gds-example.ipynb | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/examples/gds-example.ipynb b/examples/gds-example.ipynb index e9606cc..cd7ab33 100644 --- a/examples/gds-example.ipynb +++ b/examples/gds-example.ipynb @@ -2,6 +2,7 @@ "cells": [ { "cell_type": "markdown", + "id": "133e0a86", "metadata": {}, "source": [ "# Visualizing Neo4j Graph Data Science (GDS) Graphs" @@ -10,6 +11,7 @@ { "cell_type": "code", "execution_count": null, + "id": "eab42b4e", "metadata": {}, "outputs": [], "source": [ @@ -21,6 +23,7 @@ { "cell_type": "code", "execution_count": null, + "id": "1dde3aae", "metadata": {}, "outputs": [], "source": [ @@ -31,6 +34,7 @@ }, { "cell_type": "markdown", + "id": "d9daded3", "metadata": {}, "source": [ "## Setup GDS graph\n", @@ -47,6 +51,7 @@ { "cell_type": "code", "execution_count": null, + "id": "9ca9a1de", "metadata": {}, "outputs": [], "source": [ @@ -95,6 +100,7 @@ { "cell_type": "code", "execution_count": null, + "id": "735411fa", "metadata": {}, "outputs": [], "source": [ @@ -103,12 +109,14 @@ }, { "cell_type": "markdown", + "id": "8cbc843a", "metadata": {}, "source": [] }, { "cell_type": "code", "execution_count": null, + "id": "35a65700", "metadata": {}, "outputs": [], "source": [ @@ -122,6 +130,7 @@ }, { "cell_type": "markdown", + "id": "cde09804", "metadata": {}, "source": [ "## Visualization" @@ -130,6 +139,7 @@ { "cell_type": "code", "execution_count": null, + "id": "cab5ffab", "metadata": {}, "outputs": [], "source": [ @@ -145,6 +155,7 @@ { "cell_type": "code", "execution_count": null, + "id": "9f37e34f", "metadata": {}, "outputs": [], "source": [ @@ -153,6 +164,7 @@ }, { "cell_type": "markdown", + "id": "47f4b968", "metadata": {}, "source": [ "### Changing captions\n", @@ -164,6 +176,7 @@ { "cell_type": "code", "execution_count": null, + "id": "ceb601eb", "metadata": {}, "outputs": [], "source": [ @@ -174,6 +187,7 @@ { "cell_type": "code", "execution_count": null, + "id": "1ad40c84", "metadata": {}, "outputs": [], "source": [ @@ -182,6 +196,7 @@ }, { "cell_type": "markdown", + "id": "a752ad45", "metadata": {}, "source": [ "## Sizing the nodes\n", @@ -192,6 +207,7 @@ { "cell_type": "code", "execution_count": null, + "id": "4e6f0c10", "metadata": {}, "outputs": [], "source": [ @@ -201,6 +217,7 @@ }, { "cell_type": "markdown", + "id": "c36a218b", "metadata": {}, "source": [ "### Coloring" @@ -208,6 +225,7 @@ }, { "cell_type": "markdown", + "id": "40665ee4", "metadata": {}, "source": [ "There are two main ways of coloring the nodes of a graph:\n", @@ -221,6 +239,7 @@ { "cell_type": "code", "execution_count": null, + "id": "25afe567", "metadata": {}, "outputs": [], "source": [ @@ -230,6 +249,7 @@ }, { "cell_type": "markdown", + "id": "dbe78a8c", "metadata": {}, "source": [ "Now, let us color by our continuous node field \"size\" that we computed above with PageRank, again using the default colors.\n", @@ -240,6 +260,7 @@ { "cell_type": "code", "execution_count": null, + "id": "931a3465", "metadata": {}, "outputs": [], "source": [ @@ -251,6 +272,7 @@ }, { "cell_type": "markdown", + "id": "07a51bda", "metadata": {}, "source": [ "#### Custom coloring\n", @@ -262,6 +284,7 @@ { "cell_type": "code", "execution_count": null, + "id": "583b37b7", "metadata": {}, "outputs": [], "source": [ @@ -271,6 +294,7 @@ { "cell_type": "code", "execution_count": null, + "id": "fa2a8d32", "metadata": {}, "outputs": [], "source": [ @@ -289,6 +313,7 @@ { "cell_type": "code", "execution_count": null, + "id": "dd7b6944", "metadata": {}, "outputs": [], "source": [ @@ -297,6 +322,7 @@ }, { "cell_type": "markdown", + "id": "e163e522", "metadata": {}, "source": [ "### Render options\n", @@ -313,6 +339,7 @@ { "cell_type": "code", "execution_count": null, + "id": "02d2e7ce", "metadata": {}, "outputs": [], "source": [ @@ -323,6 +350,7 @@ }, { "cell_type": "markdown", + "id": "c325ea54", "metadata": {}, "source": [ "## Saving the visualization" @@ -331,6 +359,7 @@ { "cell_type": "code", "execution_count": null, + "id": "d505dfb9", "metadata": {}, "outputs": [], "source": [ @@ -345,6 +374,7 @@ }, { "cell_type": "markdown", + "id": "71006712", "metadata": {}, "source": [ "## Cleanup\n", @@ -355,6 +385,7 @@ { "cell_type": "code", "execution_count": null, + "id": "b2197a5f", "metadata": {}, "outputs": [], "source": [ @@ -364,6 +395,7 @@ { "cell_type": "code", "execution_count": null, + "id": "74468c43", "metadata": {}, "outputs": [], "source": [ From cb34a6d1e957c7acc1d1e8627bb6ff03dbc06542 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Fri, 5 Jun 2026 16:18:09 +0200 Subject: [PATCH 05/23] Switch CI to AuraDB project --- .github/workflows/gds-integration-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/gds-integration-tests.yml b/.github/workflows/gds-integration-tests.yml index 21034a7..fb51388 100644 --- a/.github/workflows/gds-integration-tests.yml +++ b/.github/workflows/gds-integration-tests.yml @@ -39,5 +39,5 @@ jobs: env: AURA_API_CLIENT_ID: 4V1HYCYEeoU4dSxThKnBeLvE2U4hSphx AURA_API_CLIENT_SECRET: ${{ secrets.AURA_API_CLIENT_SECRET }} - AURA_API_TENANT_ID: eee7ec28-6b1a-5286-8e3a-3362cc1c4c78 + AURA_API_TENANT_ID: 3f8df5e7-4800-4d4f-ad1d-2d044dfd587c run: uv run pytest tests/ --include-neo4j-and-gds From 127abc5b6d85a81fe47d1a37bd9c6cf054612d46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Fri, 5 Jun 2026 16:21:19 +0200 Subject: [PATCH 06/23] Try to fix example graph image --- python-wrapper/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python-wrapper/README.md b/python-wrapper/README.md index 64fddac..f164c03 100644 --- a/python-wrapper/README.md +++ b/python-wrapper/README.md @@ -16,7 +16,7 @@ Alternatively, you can export the output to a file and view it in a web browser. The package wraps the [Neo4j Visualization JavaScript library (NVL)](https://neo4j.com/docs/nvl/current/). -![Example Graph](https://github.com/neo4j/python-graph-visualization/blob/main/examples/example_graph.png) +![Example Graph](https://raw.githubusercontent.com/neo4j/python-graph-visualization/main/examples/example_graph.png) ## Some notable features From ea47a6267b44c4e3ef0be0e8a59029f75f5e2ef3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Fri, 5 Jun 2026 16:25:50 +0200 Subject: [PATCH 07/23] Format code --- python-wrapper/tests/gds_helper.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/python-wrapper/tests/gds_helper.py b/python-wrapper/tests/gds_helper.py index 8644fa2..f0a66da 100644 --- a/python-wrapper/tests/gds_helper.py +++ b/python-wrapper/tests/gds_helper.py @@ -70,7 +70,9 @@ def gds_sessions() -> GdsSessions: def create_auradb_instance(api: AuraApi) -> InstanceCreateDetails: - type = "enterprise-db" if os.environ.get("AURA_ENTERPRISE_PROJECT", "false").lower() == "true" else "professional-db" + type = ( + "enterprise-db" if os.environ.get("AURA_ENTERPRISE_PROJECT", "false").lower() == "true" else "professional-db" + ) instance_details: InstanceCreateDetails = api.create_instance( name="ci-neo4j-viz-db", memory=SessionMemory.m_2GB.value, @@ -83,11 +85,15 @@ def create_auradb_instance(api: AuraApi) -> InstanceCreateDetails: return instance_details + def wait_for_instance(api: AuraApi, instance_details: InstanceCreateDetails) -> DbmsConnectionInfo: wait_result = api.wait_for_instance_running(instance_id=instance_details.id, max_wait_time=600) if wait_result.error: raise Exception(f"Error while waiting for instance to be running: {wait_result.error}") return DbmsConnectionInfo( - username="neo4j", password=instance_details.password, aura_instance_id=instance_details.id, uri=wait_result.connection_url + username="neo4j", + password=instance_details.password, + aura_instance_id=instance_details.id, + uri=wait_result.connection_url, ) From a3a7171f0594bffaefc413b547590bc6cef5a385 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:40:17 +0000 Subject: [PATCH 08/23] Bump the python-patch-and-minor group across 1 directory with 5 updates Bumps the python-patch-and-minor group with 5 updates in the /python-wrapper directory: | Package | From | To | | --- | --- | --- | | [snowflake-snowpark-python](https://github.com/snowflakedb/snowpark-python) | `1.50.0` | `1.51.1` | | [ruff](https://github.com/astral-sh/ruff) | `0.15.12` | `0.15.16` | | [selenium](https://github.com/SeleniumHQ/Selenium) | `4.43.0` | `4.44.0` | | [streamlit](https://github.com/streamlit/streamlit) | `1.57.0` | `1.58.0` | | [nbsphinx-link](https://github.com/vidartf/nbsphinx-link) | `1.3.1` | `1.4.1` | Updates `snowflake-snowpark-python` from 1.50.0 to 1.51.1 - [Release notes](https://github.com/snowflakedb/snowpark-python/releases) - [Changelog](https://github.com/snowflakedb/snowpark-python/blob/main/CHANGELOG.md) - [Commits](https://github.com/snowflakedb/snowpark-python/compare/v1.50.0...v1.51.1) Updates `ruff` from 0.15.12 to 0.15.16 - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.15.12...0.15.16) Updates `selenium` from 4.43.0 to 4.44.0 - [Release notes](https://github.com/SeleniumHQ/Selenium/releases) - [Commits](https://github.com/SeleniumHQ/Selenium/compare/selenium-4.43.0...selenium-4.44.0) Updates `streamlit` from 1.57.0 to 1.58.0 - [Release notes](https://github.com/streamlit/streamlit/releases) - [Commits](https://github.com/streamlit/streamlit/compare/1.57.0...1.58.0) Updates `nbsphinx-link` from 1.3.1 to 1.4.1 - [Commits](https://github.com/vidartf/nbsphinx-link/compare/1.3.1...1.4.1) --- updated-dependencies: - dependency-name: nbsphinx-link dependency-version: 1.4.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: python-patch-and-minor - dependency-name: ruff dependency-version: 0.15.15 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: python-patch-and-minor - dependency-name: selenium dependency-version: 4.44.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: python-patch-and-minor - dependency-name: snowflake-snowpark-python dependency-version: 1.51.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: python-patch-and-minor - dependency-name: streamlit dependency-version: 1.58.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: python-patch-and-minor ... Signed-off-by: dependabot[bot] --- python-wrapper/pyproject.toml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/python-wrapper/pyproject.toml b/python-wrapper/pyproject.toml index 66b5e4e..fd065a4 100644 --- a/python-wrapper/pyproject.toml +++ b/python-wrapper/pyproject.toml @@ -49,15 +49,15 @@ snowflake = ["snowflake-snowpark-python>=1, <2"] [dependency-groups] dev = [ - "ruff==0.15.12", + "ruff==0.15.16", "mypy==1.20.2", "pytest==9.0.3", - "selenium==4.43.0", + "selenium==4.44.0", "ipykernel==7.2.0", "palettable==3.3.3", "pytest-mock==3.15.1", "nbconvert==7.17.1", - "streamlit==1.57.0", + "streamlit==1.58.0", "matplotlib>=3.9.4", "jupyterlab>=4.5.7", "anywidget[dev]", @@ -67,7 +67,7 @@ docs = [ "sphinx==8.1.3", "enum-tools[sphinx]", "nbsphinx==0.9.8", - "nbsphinx-link==1.3.1", + "nbsphinx-link==1.4.1", ] notebook = [ "ipykernel>=6.29.5", @@ -76,7 +76,7 @@ notebook = [ "ipywidgets>=8.0.0", "palettable>=3.3.3", "matplotlib>=3.9.4", - "snowflake-snowpark-python==1.50.0", + "snowflake-snowpark-python==1.51.1", "requests", "marimo", "python-dotenv" From 17bcd3fb3832d9b345da8b955c105a41db55c4e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Mon, 8 Jun 2026 13:28:32 +0200 Subject: [PATCH 09/23] Add utiltiy methods to GraphWidget similar API to VisualizationGraph going forward. this will avoid rerendering for every change to take effect --- .../src/neo4j_viz/_graph_entity_operations.py | 592 +++++++++++++++++ .../src/neo4j_viz/visualization_graph.py | 599 +++--------------- python-wrapper/src/neo4j_viz/widget.py | 88 +++ python-wrapper/tests/test_widget.py | 81 +++ 4 files changed, 832 insertions(+), 528 deletions(-) create mode 100644 python-wrapper/src/neo4j_viz/_graph_entity_operations.py diff --git a/python-wrapper/src/neo4j_viz/_graph_entity_operations.py b/python-wrapper/src/neo4j_viz/_graph_entity_operations.py new file mode 100644 index 0000000..fd20abe --- /dev/null +++ b/python-wrapper/src/neo4j_viz/_graph_entity_operations.py @@ -0,0 +1,592 @@ +from __future__ import annotations + +import warnings +from collections.abc import Hashable, Iterable +from typing import Any, Callable, Protocol, TypeVar + +from pydantic.alias_generators import to_snake +from pydantic_extra_types.color import Color, ColorType + +from .colors import NEO4J_COLORS_CONTINUOUS, NEO4J_COLORS_DISCRETE, ColorSpace, ColorsType +from .node import Node, NodeIdType +from .node_size import RealNumber, verify_radii +from .relationship import Relationship + +F = TypeVar("F", bound=Callable[..., Any]) + + +def delegate_doc(target: Callable[..., Any]) -> Callable[[F], F]: + """Copy the docstring of `target` onto the decorated function. + + Lets the thin delegating methods on the host classes reuse the canonical docstrings + defined on `GraphEntityOperations` without duplicating the text. + """ + + def decorator(fn: F) -> F: + fn.__doc__ = target.__doc__ + return fn + + return decorator + + +class EntityHost(Protocol): + """The interface a host must expose to be driven by `GraphEntityOperations`.""" + + nodes: list[Node] + relationships: list[Relationship] + + def _sync_entities(self, *, nodes: bool = ..., relationships: bool = ...) -> None: ... + + +class GraphEntityOperations: + """Recolor, resize, caption and pin operations over a host's graph entities. + + This is a composable component: it does not own the data, but reads the `nodes` and + `relationships` from its `host` and mutates the entities in place. After each mutation + it calls the host's `_sync_entities` hook so the host can react (e.g. the widget pushes + the changes to its frontend). + """ + + def __init__(self, host: EntityHost) -> None: + self._host = host + + @property + def nodes(self) -> list[Node]: + return self._host.nodes + + @property + def relationships(self) -> list[Relationship]: + return self._host.relationships + + def toggle_nodes_pinned(self, pinned: dict[NodeIdType, bool]) -> None: + """ + Toggle whether nodes should be pinned or not. + + Parameters + ---------- + pinned: + A dictionary mapping from node ID to whether the node should be pinned or not. + """ + for node in self.nodes: + node_pinned = pinned.get(node.id) + + if node_pinned is None: + continue + + node.pinned = node_pinned + + self._host._sync_entities(nodes=True) + + def set_node_captions( + self, + *, + field: str | None = None, + property: str | None = None, + override: bool = True, + ) -> None: + """ + Set the caption for nodes in the graph based on either a node field or a node property. + + Parameters + ---------- + field: + The field of the nodes to use as the caption. Must be None if `property` is provided. + property: + The property of the nodes to use as the caption. Must be None if `field` is provided. + override: + Whether to override existing captions of the nodes, if they have any. + + Examples + -------- + Given a VisualizationGraph `VG`: + + >>> nodes = [ + ... Node(id="0", properties={"name": "Alice", "age": 30}), + ... Node(id="1", properties={"name": "Bob", "age": 25}), + ... ] + >>> VG = VisualizationGraph(nodes=nodes) + + Set node captions from a property: + + >>> VG.set_node_captions(property="name") + + Set node captions from a field, only if not already set: + + >>> VG.set_node_captions(field="id", override=False) + + Set captions from multiple properties with fallback: + + >>> for node in VG.nodes: + ... caption = node.properties.get("name") or node.properties.get("title") or node.id + ... if override or node.caption is None: + ... node.caption = str(caption) + """ + if not ((field is None) ^ (property is None)): + raise ValueError( + f"Exactly one of the arguments `field` (received '{field}') and `property` (received '{property}') must be provided" + ) + + if property: + # Use property + for node in self.nodes: + if not override and node.caption is not None: + continue + + value = node.properties.get(property, "") + node.caption = str(value) + else: + # Use field + assert field is not None + attribute = to_snake(field) + + for node in self.nodes: + if not override and node.caption is not None: + continue + + value = getattr(node, attribute, "") + node.caption = str(value) + + self._host._sync_entities(nodes=True) + + def resize_nodes( + self, + sizes: dict[NodeIdType, RealNumber] | None = None, + node_radius_min_max: tuple[RealNumber, RealNumber] | None = (3, 60), + property: str | None = None, + ) -> None: + """ + Resize the nodes in the graph. + + Parameters + ---------- + sizes: + A dictionary mapping from node ID to the new size of the node. + If a node ID is not in the dictionary, the size of the node is not changed. + Must be None if `property` is provided. + node_radius_min_max: + Minimum and maximum node size radius as a tuple. To avoid tiny or huge nodes in the visualization, the + node sizes are scaled to fit in the given range. If None, the sizes are used as is. + property: + The property of the nodes to use for sizing. Must be None if `sizes` is provided. + """ + if sizes is not None and property is not None: + raise ValueError("At most one of the arguments `sizes` and `property` can be provided") + + if sizes is None and property is None and node_radius_min_max is None: + raise ValueError("At least one of `sizes`, `property` or `node_radius_min_max` must be given") + + # Gather node sizes + all_sizes = {} + if sizes is not None: + for node in self.nodes: + size = sizes.get(node.id, node.size) + if size is not None: + all_sizes[node.id] = size + elif property is not None: + for node in self.nodes: + size = node.properties.get(property, node.size) + if size is not None: + all_sizes[node.id] = size + else: + for node in self.nodes: + if node.size is not None: + all_sizes[node.id] = node.size + + # Validate node sizes + for id, size in all_sizes.items(): + if size is None: + continue + + if not isinstance(size, (int, float)): + raise ValueError(f"Size for node '{id}' must be a real number, but was {size}") + + if size < 0: + raise ValueError(f"Size for node '{id}' must be non-negative, but was {size}") + + if node_radius_min_max is not None: + verify_radii(node_radius_min_max) + + final_sizes = self._normalize_values(all_sizes, node_radius_min_max) + else: + final_sizes = all_sizes + + # Apply the final sizes to the nodes + for node in self.nodes: + size = final_sizes.get(node.id) + + if size is None: + continue + + node.size = size + + self._host._sync_entities(nodes=True) + + def resize_relationships( + self, + widths: dict[str | int, RealNumber] | None = None, + property: str | None = None, + ) -> None: + """ + Resize the width of relationships in the graph. + + Parameters + ---------- + widths: + A dictionary mapping from relationship ID to the new width of the relationship. + If a relationship ID is not in the dictionary, the width of the relationship is not changed. + Must be None if `property` is provided. + property: + The property of the relationships to use for sizing. Must be None if `widths` is provided. + """ + if widths is not None and property is not None: + raise ValueError("At most one of the arguments `widths` and `property` can be provided") + + if widths is None and property is None: + raise ValueError("At least one of `widths` or `property` must be given") + + # Gather relationship widths + all_widths = {} + if widths is not None: + for rel in self.relationships: + width = widths.get(rel.id, rel.width) + if width is not None: + all_widths[rel.id] = width + elif property is not None: + for rel in self.relationships: + width = rel.properties.get(property, rel.width) + if width is not None: + all_widths[rel.id] = width + + # Validate and apply relationship widths + for rel in self.relationships: + width = all_widths.get(rel.id) + + if width is None: + continue + + if not isinstance(width, (int, float)): + raise ValueError(f"Width for relationship '{rel.id}' must be a real number, but was {width}") + + if width <= 0: + raise ValueError(f"Width for relationship '{rel.id}' must be positive, but was {width}") + + rel.width = width + + self._host._sync_entities(relationships=True) + + @staticmethod + def _normalize_values( + node_map: dict[NodeIdType, RealNumber], min_max: tuple[float, float] = (0, 1) + ) -> dict[NodeIdType, RealNumber]: + unscaled_min_size = min(node_map.values()) + unscaled_max_size = max(node_map.values()) + unscaled_size_range = float(unscaled_max_size - unscaled_min_size) + + new_min_size, new_max_size = min_max + new_size_range = new_max_size - new_min_size + + if abs(unscaled_size_range) < 1e-6: + default_node_size = new_min_size + new_size_range / 2.0 + new_map = {id: default_node_size for id in node_map} + else: + new_map = { + id: new_min_size + new_size_range * ((nz - unscaled_min_size) / unscaled_size_range) + for id, nz in node_map.items() + } + + return new_map + + def color_nodes( + self, + *, + field: str | None = None, + property: str | None = None, + colors: ColorsType | None = None, + color_space: ColorSpace = ColorSpace.DISCRETE, + override: bool = True, + ) -> None: + """ + Color the nodes in the graph based on either a node field, or a node property. + + It's possible to color the nodes based on a discrete or continuous color space. In the discrete case, a new + color from the `colors` provided is assigned to each unique value of the node field/property. + In the continuous case, the `colors` should be a list of colors representing a range that are used to + create a gradient of colors based on the values of the node field/property. + + Parameters + ---------- + field: + The field of the nodes to base the coloring on. The type of this field must be hashable, or be a + list, set or dict containing only hashable types. Must be None if `property` is provided. + property: + The property of the nodes to base the coloring on. The type of this property must be hashable, or be a + list, set or dict containing only hashable types. Must be None if `field` is provided. + colors: + The colors to use for the nodes. + If `color_space` is `ColorSpace.DISCRETE`, the colors can be a dictionary mapping from field/property value + to color, or an iterable of colors in which case the colors are used in order. + If `color_space` is `ColorSpace.CONTINUOUS`, the colors must be a list of colors representing a range. + Allowed color values are for example “#FF0000”, “red” or (255, 0, 0) (full list: https://docs.pydantic.dev/2.0/usage/types/extra_types/color_types/). + The default colors are the Neo4j graph colors. + color_space: + The type of space of the provided `colors`. Either `ColorSpace.DISCRETE` or `ColorSpace.CONTINUOUS`. It determines whether + colors are assigned based on unique field/property values or a gradient of the values of the field/property. + override: + Whether to override existing colors of the nodes, if they have any. + + Examples + -------- + + Given a VisualizationGraph `VG`: + + >>> nodes = [ + ... Node(id="0", properties={"label": "Person", "score": 10}), + ... Node(id="1", properties={"label": "Person", "score": 20}), + ... ] + >>> VG = VisualizationGraph(nodes=nodes) + + Color nodes based on a discrete field such as "label": + + >>> VG.color_nodes(field="label", color_space=ColorSpace.DISCRETE) + + Color nodes based on a continuous field such as "score": + + >>> VG.color_nodes(field="score", color_space=ColorSpace.CONTINUOUS) + + Color nodes based on a custom colors such as from palettable: + + >>> from palettable.wesanderson import Moonrise1_5 # type: ignore[import-untyped] + >>> VG.color_nodes(field="label", colors=Moonrise1_5.colors) + """ + if not ((field is None) ^ (property is None)): + raise ValueError( + f"Exactly one of the arguments `field` (received '{field}') and `property` (received '{property}') must be provided" + ) + + if field is None: + assert property is not None + attribute = property + + def node_to_attr(node: Node) -> Any: + return node.properties.get(attribute) + + else: + assert field is not None + attribute = to_snake(field) + + def node_to_attr(node: Node) -> Any: + return getattr(node, attribute) + + if color_space == ColorSpace.DISCRETE: + if colors is None: + colors = NEO4J_COLORS_DISCRETE + else: + node_map = {node.id: node_to_attr(node) for node in self.nodes if node_to_attr(node) is not None} + normalized_map = self._normalize_values(node_map) + + if colors is None: + colors = NEO4J_COLORS_CONTINUOUS + + if not isinstance(colors, list): + raise ValueError("For continuous properties, `colors` must be a list of colors representing a range") + + num_colors = len(colors) + colors = { + node_to_attr(node): colors[round(normalized_map[node.id] * (num_colors - 1))] + for node in self.nodes + if node_to_attr(node) is not None + } + + if isinstance(colors, dict): + self._color_items_dict(self.nodes, colors, override, node_to_attr) + else: + self._color_items_iter(self.nodes, attribute, colors, override, node_to_attr) + + self._host._sync_entities(nodes=True) + + def color_relationships( + self, + *, + field: str | None = None, + property: str | None = None, + colors: ColorsType | None = None, + color_space: ColorSpace = ColorSpace.DISCRETE, + override: bool = True, + ) -> None: + """ + Color the relationships in the graph based on either a relationship field, or a relationship property. + + It's possible to color the relationships based on a discrete or continuous color space. In the discrete case, + a new color from the `colors` provided is assigned to each unique value of the relationship field/property. + In the continuous case, the `colors` should be a list of colors representing a range that are used to + create a gradient of colors based on the values of the relationship field/property. + + Parameters + ---------- + field: + The field of the relationships to base the coloring on. The type of this field must be hashable, or be a + list, set or dict containing only hashable types. Must be None if `property` is provided. + property: + The property of the relationships to base the coloring on. The type of this property must be hashable, or be a + list, set or dict containing only hashable types. Must be None if `field` is provided. + colors: + The colors to use for the relationships. + If `color_space` is `ColorSpace.DISCRETE`, the colors can be a dictionary mapping from field/property value + to color, or an iterable of colors in which case the colors are used in order. + If `color_space` is `ColorSpace.CONTINUOUS`, the colors must be a list of colors representing a range. + Allowed color values are for example “#FF0000”, “red” or (255, 0, 0) (full list: https://docs.pydantic.dev/2.0/usage/types/extra_types/color_types/). + The default colors are the Neo4j graph colors. + color_space: + The type of space of the provided `colors`. Either `ColorSpace.DISCRETE` or `ColorSpace.CONTINUOUS`. It determines whether + colors are assigned based on unique field/property values or a gradient of the values of the field/property. + override: + Whether to override existing colors of the relationships, if they have any. + + Examples + -------- + + Given a VisualizationGraph `VG`: + + >>> nodes = [Node(id="0"), Node(id="1")] + >>> relationships = [ + ... Relationship(source="0", target="1", caption="ACTED_IN", properties={"score": 10}), + ... Relationship(source="1", target="0", caption="DIRECTED", properties={"score": 20}), + ... ] + >>> VG = VisualizationGraph(nodes=nodes, relationships=relationships) + + Color relationships based on a discrete field such as "caption": + + >>> VG.color_relationships(field="caption", color_space=ColorSpace.DISCRETE) + + Color relationships based on a continuous field such as "score": + + >>> VG.color_relationships(property="score", color_space=ColorSpace.CONTINUOUS) + """ + if not ((field is None) ^ (property is None)): + raise ValueError( + f"Exactly one of the arguments `field` (received '{field}') and `property` (received '{property}') must be provided" + ) + + if field is None: + assert property is not None + attribute = property + + def rel_to_attr(rel: Relationship) -> Any: + return rel.properties.get(attribute) + + else: + assert field is not None + attribute = to_snake(field) + + def rel_to_attr(rel: Relationship) -> Any: + return getattr(rel, attribute) + + if color_space == ColorSpace.DISCRETE: + if colors is None: + colors = NEO4J_COLORS_DISCRETE + else: + rel_map = {rel.id: rel_to_attr(rel) for rel in self.relationships if rel_to_attr(rel) is not None} + normalized_map = self._normalize_values(rel_map) + + if colors is None: + colors = NEO4J_COLORS_CONTINUOUS + + if not isinstance(colors, list): + raise ValueError("For continuous properties, `colors` must be a list of colors representing a range") + + num_colors = len(colors) + colors = { + rel_to_attr(rel): colors[round(normalized_map[rel.id] * (num_colors - 1))] + for rel in self.relationships + if rel_to_attr(rel) is not None + } + + if isinstance(colors, dict): + self._color_items_dict(self.relationships, colors, override, rel_to_attr) + else: + self._color_items_iter(self.relationships, attribute, colors, override, rel_to_attr) + + self._host._sync_entities(relationships=True) + + def _color_items_dict( + self, + items: list[Node] | list[Relationship], + colors: dict[Hashable, ColorType], + override: bool, + item_to_attr: Callable[[Any], Any], + ) -> None: + for item in items: + color = colors.get(item_to_attr(item)) + + if color is None: + continue + + if item.color is not None and not override: + continue + + if not isinstance(color, Color): + item.color = Color(color) + else: + item.color = color + + def _color_items_iter( + self, + items: list[Node] | list[Relationship], + attribute: str, + colors: Iterable[ColorType], + override: bool, + item_to_attr: Callable[[Any], Any], + ) -> None: + exhausted_colors = False + prop_to_color = {} + colors_iter = iter(colors) + for item in items: + raw_prop = item_to_attr(item) + try: + prop = self._make_hashable(raw_prop) + except ValueError: + item_type = "nodes" if isinstance(item, Node) else "relationships" + raise ValueError(f"Unable to color {item_type} by unhashable property type '{type(raw_prop)}'") + + if prop not in prop_to_color: + next_color = next(colors_iter, None) + if next_color is None: + exhausted_colors = True + colors_iter = iter(colors) + next_color = next(colors_iter) + prop_to_color[prop] = next_color + + color = prop_to_color[prop] + + if item.color is not None and not override: + continue + + if not isinstance(color, Color): + item.color = Color(color) + else: + item.color = color + + if exhausted_colors: + warnings.warn( + f"Ran out of colors for property '{attribute}'. {len(prop_to_color)} colors were needed, but only " + f"{len(set(prop_to_color.values()))} were given, so reused colors" + ) + + @staticmethod + def _make_hashable(raw_prop: Any) -> Hashable: + prop = raw_prop + if isinstance(raw_prop, list): + prop = tuple(raw_prop) + elif isinstance(raw_prop, set): + prop = frozenset(raw_prop) + elif isinstance(raw_prop, dict): + prop = tuple(sorted(raw_prop.items())) + + try: + hash(prop) + except TypeError: + raise ValueError(f"Unable to convert '{raw_prop}' of type {type(raw_prop)} to a hashable type") + + assert isinstance(prop, Hashable) + + return prop diff --git a/python-wrapper/src/neo4j_viz/visualization_graph.py b/python-wrapper/src/neo4j_viz/visualization_graph.py index c25b860..67bff0b 100644 --- a/python-wrapper/src/neo4j_viz/visualization_graph.py +++ b/python-wrapper/src/neo4j_viz/visualization_graph.py @@ -1,16 +1,14 @@ from __future__ import annotations -import warnings -from collections.abc import Hashable, Iterable -from typing import Any, Callable, Literal +from functools import cached_property +from typing import Any, Literal from IPython.display import HTML -from pydantic.alias_generators import to_snake -from pydantic_extra_types.color import Color, ColorType -from .colors import NEO4J_COLORS_CONTINUOUS, NEO4J_COLORS_DISCRETE, ColorSpace, ColorsType +from ._graph_entity_operations import GraphEntityOperations, delegate_doc +from .colors import ColorSpace, ColorsType from .node import Node, NodeIdType -from .node_size import RealNumber, verify_radii +from .node_size import RealNumber from .nvl import NVL from .options import ( Layout, @@ -87,6 +85,72 @@ def __init__(self, nodes: list[Node], relationships: list[Relationship]) -> None def __str__(self) -> str: return f"VisualizationGraph(nodes={len(self.nodes)}, relationships={len(self.relationships)})" + @cached_property + def _entity_ops(self) -> GraphEntityOperations: + return GraphEntityOperations(self) + + def _sync_entities(self, *, nodes: bool = False, relationships: bool = False) -> None: + """Hook invoked after entities are mutated in place. A no-op for a plain graph.""" + + @delegate_doc(GraphEntityOperations.toggle_nodes_pinned) + def toggle_nodes_pinned(self, pinned: dict[NodeIdType, bool]) -> None: + self._entity_ops.toggle_nodes_pinned(pinned) + + @delegate_doc(GraphEntityOperations.set_node_captions) + def set_node_captions( + self, + *, + field: str | None = None, + property: str | None = None, + override: bool = True, + ) -> None: + self._entity_ops.set_node_captions(field=field, property=property, override=override) + + @delegate_doc(GraphEntityOperations.resize_nodes) + def resize_nodes( + self, + sizes: dict[NodeIdType, RealNumber] | None = None, + node_radius_min_max: tuple[RealNumber, RealNumber] | None = (3, 60), + property: str | None = None, + ) -> None: + self._entity_ops.resize_nodes(sizes=sizes, node_radius_min_max=node_radius_min_max, property=property) + + @delegate_doc(GraphEntityOperations.resize_relationships) + def resize_relationships( + self, + widths: dict[str | int, RealNumber] | None = None, + property: str | None = None, + ) -> None: + self._entity_ops.resize_relationships(widths=widths, property=property) + + @delegate_doc(GraphEntityOperations.color_nodes) + def color_nodes( + self, + *, + field: str | None = None, + property: str | None = None, + colors: ColorsType | None = None, + color_space: ColorSpace = ColorSpace.DISCRETE, + override: bool = True, + ) -> None: + self._entity_ops.color_nodes( + field=field, property=property, colors=colors, color_space=color_space, override=override + ) + + @delegate_doc(GraphEntityOperations.color_relationships) + def color_relationships( + self, + *, + field: str | None = None, + property: str | None = None, + colors: ColorsType | None = None, + color_space: ColorSpace = ColorSpace.DISCRETE, + override: bool = True, + ) -> None: + self._entity_ops.color_relationships( + field=field, property=property, colors=colors, color_space=color_space, override=override + ) + def _build_render_options( self, layout: Layout | str | None, @@ -283,524 +347,3 @@ def render_widget( options=render_options, theme=theme, ) - - def toggle_nodes_pinned(self, pinned: dict[NodeIdType, bool]) -> None: - """ - Toggle whether nodes should be pinned or not. - - Parameters - ---------- - pinned: - A dictionary mapping from node ID to whether the node should be pinned or not. - """ - for node in self.nodes: - node_pinned = pinned.get(node.id) - - if node_pinned is None: - continue - - node.pinned = node_pinned - - def set_node_captions( - self, - *, - field: str | None = None, - property: str | None = None, - override: bool = True, - ) -> None: - """ - Set the caption for nodes in the graph based on either a node field or a node property. - - Parameters - ---------- - field: - The field of the nodes to use as the caption. Must be None if `property` is provided. - property: - The property of the nodes to use as the caption. Must be None if `field` is provided. - override: - Whether to override existing captions of the nodes, if they have any. - - Examples - -------- - Given a VisualizationGraph `VG`: - - >>> nodes = [ - ... Node(id="0", properties={"name": "Alice", "age": 30}), - ... Node(id="1", properties={"name": "Bob", "age": 25}), - ... ] - >>> VG = VisualizationGraph(nodes=nodes) - - Set node captions from a property: - - >>> VG.set_node_captions(property="name") - - Set node captions from a field, only if not already set: - - >>> VG.set_node_captions(field="id", override=False) - - Set captions from multiple properties with fallback: - - >>> for node in VG.nodes: - ... caption = node.properties.get("name") or node.properties.get("title") or node.id - ... if override or node.caption is None: - ... node.caption = str(caption) - """ - if not ((field is None) ^ (property is None)): - raise ValueError( - f"Exactly one of the arguments `field` (received '{field}') and `property` (received '{property}') must be provided" - ) - - if property: - # Use property - for node in self.nodes: - if not override and node.caption is not None: - continue - - value = node.properties.get(property, "") - node.caption = str(value) - else: - # Use field - assert field is not None - attribute = to_snake(field) - - for node in self.nodes: - if not override and node.caption is not None: - continue - - value = getattr(node, attribute, "") - node.caption = str(value) - - def resize_nodes( - self, - sizes: dict[NodeIdType, RealNumber] | None = None, - node_radius_min_max: tuple[RealNumber, RealNumber] | None = (3, 60), - property: str | None = None, - ) -> None: - """ - Resize the nodes in the graph. - - Parameters - ---------- - sizes: - A dictionary mapping from node ID to the new size of the node. - If a node ID is not in the dictionary, the size of the node is not changed. - Must be None if `property` is provided. - node_radius_min_max: - Minimum and maximum node size radius as a tuple. To avoid tiny or huge nodes in the visualization, the - node sizes are scaled to fit in the given range. If None, the sizes are used as is. - property: - The property of the nodes to use for sizing. Must be None if `sizes` is provided. - """ - if sizes is not None and property is not None: - raise ValueError("At most one of the arguments `sizes` and `property` can be provided") - - if sizes is None and property is None and node_radius_min_max is None: - raise ValueError("At least one of `sizes`, `property` or `node_radius_min_max` must be given") - - # Gather node sizes - all_sizes = {} - if sizes is not None: - for node in self.nodes: - size = sizes.get(node.id, node.size) - if size is not None: - all_sizes[node.id] = size - elif property is not None: - for node in self.nodes: - size = node.properties.get(property, node.size) - if size is not None: - all_sizes[node.id] = size - else: - for node in self.nodes: - if node.size is not None: - all_sizes[node.id] = node.size - - # Validate node sizes - for id, size in all_sizes.items(): - if size is None: - continue - - if not isinstance(size, (int, float)): - raise ValueError(f"Size for node '{id}' must be a real number, but was {size}") - - if size < 0: - raise ValueError(f"Size for node '{id}' must be non-negative, but was {size}") - - if node_radius_min_max is not None: - verify_radii(node_radius_min_max) - - final_sizes = self._normalize_values(all_sizes, node_radius_min_max) - else: - final_sizes = all_sizes - - # Apply the final sizes to the nodes - for node in self.nodes: - size = final_sizes.get(node.id) - - if size is None: - continue - - node.size = size - - def resize_relationships( - self, - widths: dict[str | int, RealNumber] | None = None, - property: str | None = None, - ) -> None: - """ - Resize the width of relationships in the graph. - - Parameters - ---------- - widths: - A dictionary mapping from relationship ID to the new width of the relationship. - If a relationship ID is not in the dictionary, the width of the relationship is not changed. - Must be None if `property` is provided. - property: - The property of the relationships to use for sizing. Must be None if `widths` is provided. - """ - if widths is not None and property is not None: - raise ValueError("At most one of the arguments `widths` and `property` can be provided") - - if widths is None and property is None: - raise ValueError("At least one of `widths` or `property` must be given") - - # Gather relationship widths - all_widths = {} - if widths is not None: - for rel in self.relationships: - width = widths.get(rel.id, rel.width) - if width is not None: - all_widths[rel.id] = width - elif property is not None: - for rel in self.relationships: - width = rel.properties.get(property, rel.width) - if width is not None: - all_widths[rel.id] = width - - # Validate and apply relationship widths - for rel in self.relationships: - width = all_widths.get(rel.id) - - if width is None: - continue - - if not isinstance(width, (int, float)): - raise ValueError(f"Width for relationship '{rel.id}' must be a real number, but was {width}") - - if width <= 0: - raise ValueError(f"Width for relationship '{rel.id}' must be positive, but was {width}") - - rel.width = width - - @staticmethod - def _normalize_values( - node_map: dict[NodeIdType, RealNumber], min_max: tuple[float, float] = (0, 1) - ) -> dict[NodeIdType, RealNumber]: - unscaled_min_size = min(node_map.values()) - unscaled_max_size = max(node_map.values()) - unscaled_size_range = float(unscaled_max_size - unscaled_min_size) - - new_min_size, new_max_size = min_max - new_size_range = new_max_size - new_min_size - - if abs(unscaled_size_range) < 1e-6: - default_node_size = new_min_size + new_size_range / 2.0 - new_map = {id: default_node_size for id in node_map} - else: - new_map = { - id: new_min_size + new_size_range * ((nz - unscaled_min_size) / unscaled_size_range) - for id, nz in node_map.items() - } - - return new_map - - def color_nodes( - self, - *, - field: str | None = None, - property: str | None = None, - colors: ColorsType | None = None, - color_space: ColorSpace = ColorSpace.DISCRETE, - override: bool = True, - ) -> None: - """ - Color the nodes in the graph based on either a node field, or a node property. - - It's possible to color the nodes based on a discrete or continuous color space. In the discrete case, a new - color from the `colors` provided is assigned to each unique value of the node field/property. - In the continuous case, the `colors` should be a list of colors representing a range that are used to - create a gradient of colors based on the values of the node field/property. - - Parameters - ---------- - field: - The field of the nodes to base the coloring on. The type of this field must be hashable, or be a - list, set or dict containing only hashable types. Must be None if `property` is provided. - property: - The property of the nodes to base the coloring on. The type of this property must be hashable, or be a - list, set or dict containing only hashable types. Must be None if `field` is provided. - colors: - The colors to use for the nodes. - If `color_space` is `ColorSpace.DISCRETE`, the colors can be a dictionary mapping from field/property value - to color, or an iterable of colors in which case the colors are used in order. - If `color_space` is `ColorSpace.CONTINUOUS`, the colors must be a list of colors representing a range. - Allowed color values are for example “#FF0000”, “red” or (255, 0, 0) (full list: https://docs.pydantic.dev/2.0/usage/types/extra_types/color_types/). - The default colors are the Neo4j graph colors. - color_space: - The type of space of the provided `colors`. Either `ColorSpace.DISCRETE` or `ColorSpace.CONTINUOUS`. It determines whether - colors are assigned based on unique field/property values or a gradient of the values of the field/property. - override: - Whether to override existing colors of the nodes, if they have any. - - Examples - -------- - - Given a VisualizationGraph `VG`: - - >>> nodes = [ - ... Node(id="0", properties={"label": "Person", "score": 10}), - ... Node(id="1", properties={"label": "Person", "score": 20}), - ... ] - >>> VG = VisualizationGraph(nodes=nodes) - - Color nodes based on a discrete field such as "label": - - >>> VG.color_nodes(field="label", color_space=ColorSpace.DISCRETE) - - Color nodes based on a continuous field such as "score": - - >>> VG.color_nodes(field="score", color_space=ColorSpace.CONTINUOUS) - - Color nodes based on a custom colors such as from palettable: - - >>> from palettable.wesanderson import Moonrise1_5 # type: ignore[import-untyped] - >>> VG.color_nodes(field="label", colors=Moonrise1_5.colors) - """ - if not ((field is None) ^ (property is None)): - raise ValueError( - f"Exactly one of the arguments `field` (received '{field}') and `property` (received '{property}') must be provided" - ) - - if field is None: - assert property is not None - attribute = property - - def node_to_attr(node: Node) -> Any: - return node.properties.get(attribute) - - else: - assert field is not None - attribute = to_snake(field) - - def node_to_attr(node: Node) -> Any: - return getattr(node, attribute) - - if color_space == ColorSpace.DISCRETE: - if colors is None: - colors = NEO4J_COLORS_DISCRETE - else: - node_map = {node.id: node_to_attr(node) for node in self.nodes if node_to_attr(node) is not None} - normalized_map = self._normalize_values(node_map) - - if colors is None: - colors = NEO4J_COLORS_CONTINUOUS - - if not isinstance(colors, list): - raise ValueError("For continuous properties, `colors` must be a list of colors representing a range") - - num_colors = len(colors) - colors = { - node_to_attr(node): colors[round(normalized_map[node.id] * (num_colors - 1))] - for node in self.nodes - if node_to_attr(node) is not None - } - - if isinstance(colors, dict): - self._color_items_dict(self.nodes, colors, override, node_to_attr) - else: - self._color_items_iter(self.nodes, attribute, colors, override, node_to_attr) - - def color_relationships( - self, - *, - field: str | None = None, - property: str | None = None, - colors: ColorsType | None = None, - color_space: ColorSpace = ColorSpace.DISCRETE, - override: bool = True, - ) -> None: - """ - Color the relationships in the graph based on either a relationship field, or a relationship property. - - It's possible to color the relationships based on a discrete or continuous color space. In the discrete case, - a new color from the `colors` provided is assigned to each unique value of the relationship field/property. - In the continuous case, the `colors` should be a list of colors representing a range that are used to - create a gradient of colors based on the values of the relationship field/property. - - Parameters - ---------- - field: - The field of the relationships to base the coloring on. The type of this field must be hashable, or be a - list, set or dict containing only hashable types. Must be None if `property` is provided. - property: - The property of the relationships to base the coloring on. The type of this property must be hashable, or be a - list, set or dict containing only hashable types. Must be None if `field` is provided. - colors: - The colors to use for the relationships. - If `color_space` is `ColorSpace.DISCRETE`, the colors can be a dictionary mapping from field/property value - to color, or an iterable of colors in which case the colors are used in order. - If `color_space` is `ColorSpace.CONTINUOUS`, the colors must be a list of colors representing a range. - Allowed color values are for example “#FF0000”, “red” or (255, 0, 0) (full list: https://docs.pydantic.dev/2.0/usage/types/extra_types/color_types/). - The default colors are the Neo4j graph colors. - color_space: - The type of space of the provided `colors`. Either `ColorSpace.DISCRETE` or `ColorSpace.CONTINUOUS`. It determines whether - colors are assigned based on unique field/property values or a gradient of the values of the field/property. - override: - Whether to override existing colors of the relationships, if they have any. - - Examples - -------- - - Given a VisualizationGraph `VG`: - - >>> nodes = [Node(id="0"), Node(id="1")] - >>> relationships = [ - ... Relationship(source="0", target="1", caption="ACTED_IN", properties={"score": 10}), - ... Relationship(source="1", target="0", caption="DIRECTED", properties={"score": 20}), - ... ] - >>> VG = VisualizationGraph(nodes=nodes, relationships=relationships) - - Color relationships based on a discrete field such as "caption": - - >>> VG.color_relationships(field="caption", color_space=ColorSpace.DISCRETE) - - Color relationships based on a continuous field such as "score": - - >>> VG.color_relationships(property="score", color_space=ColorSpace.CONTINUOUS) - """ - if not ((field is None) ^ (property is None)): - raise ValueError( - f"Exactly one of the arguments `field` (received '{field}') and `property` (received '{property}') must be provided" - ) - - if field is None: - assert property is not None - attribute = property - - def rel_to_attr(rel: Relationship) -> Any: - return rel.properties.get(attribute) - - else: - assert field is not None - attribute = to_snake(field) - - def rel_to_attr(rel: Relationship) -> Any: - return getattr(rel, attribute) - - if color_space == ColorSpace.DISCRETE: - if colors is None: - colors = NEO4J_COLORS_DISCRETE - else: - rel_map = {rel.id: rel_to_attr(rel) for rel in self.relationships if rel_to_attr(rel) is not None} - normalized_map = self._normalize_values(rel_map) - - if colors is None: - colors = NEO4J_COLORS_CONTINUOUS - - if not isinstance(colors, list): - raise ValueError("For continuous properties, `colors` must be a list of colors representing a range") - - num_colors = len(colors) - colors = { - rel_to_attr(rel): colors[round(normalized_map[rel.id] * (num_colors - 1))] - for rel in self.relationships - if rel_to_attr(rel) is not None - } - - if isinstance(colors, dict): - self._color_items_dict(self.relationships, colors, override, rel_to_attr) - else: - self._color_items_iter(self.relationships, attribute, colors, override, rel_to_attr) - - def _color_items_dict( - self, - items: list[Node] | list[Relationship], - colors: dict[Hashable, ColorType], - override: bool, - item_to_attr: Callable[[Any], Any], - ) -> None: - for item in items: - color = colors.get(item_to_attr(item)) - - if color is None: - continue - - if item.color is not None and not override: - continue - - if not isinstance(color, Color): - item.color = Color(color) - else: - item.color = color - - def _color_items_iter( - self, - items: list[Node] | list[Relationship], - attribute: str, - colors: Iterable[ColorType], - override: bool, - item_to_attr: Callable[[Any], Any], - ) -> None: - exhausted_colors = False - prop_to_color = {} - colors_iter = iter(colors) - for item in items: - raw_prop = item_to_attr(item) - try: - prop = self._make_hashable(raw_prop) - except ValueError: - item_type = "nodes" if isinstance(item, Node) else "relationships" - raise ValueError(f"Unable to color {item_type} by unhashable property type '{type(raw_prop)}'") - - if prop not in prop_to_color: - next_color = next(colors_iter, None) - if next_color is None: - exhausted_colors = True - colors_iter = iter(colors) - next_color = next(colors_iter) - prop_to_color[prop] = next_color - - color = prop_to_color[prop] - - if item.color is not None and not override: - continue - - if not isinstance(color, Color): - item.color = Color(color) - else: - item.color = color - - if exhausted_colors: - warnings.warn( - f"Ran out of colors for property '{attribute}'. {len(prop_to_color)} colors were needed, but only " - f"{len(set(prop_to_color.values()))} were given, so reused colors" - ) - - @staticmethod - def _make_hashable(raw_prop: Any) -> Hashable: - prop = raw_prop - if isinstance(raw_prop, list): - prop = tuple(raw_prop) - elif isinstance(raw_prop, set): - prop = frozenset(raw_prop) - elif isinstance(raw_prop, dict): - prop = tuple(sorted(raw_prop.items())) - - try: - hash(prop) - except TypeError: - raise ValueError(f"Unable to convert '{raw_prop}' of type {type(raw_prop)} to a hashable type") - - assert isinstance(prop, Hashable) - - return prop diff --git a/python-wrapper/src/neo4j_viz/widget.py b/python-wrapper/src/neo4j_viz/widget.py index 433d411..abd589e 100644 --- a/python-wrapper/src/neo4j_viz/widget.py +++ b/python-wrapper/src/neo4j_viz/widget.py @@ -2,12 +2,16 @@ import json import pathlib +from functools import cached_property from typing import Any, Union import anywidget import traitlets +from ._graph_entity_operations import GraphEntityOperations, delegate_doc +from .colors import ColorSpace, ColorsType from .node import Node, NodeIdType +from .node_size import RealNumber from .options import RenderOptions from .relationship import Relationship, RelationshipIdType @@ -48,6 +52,9 @@ class GraphWidget(anywidget.AnyWidget): Uses anywidget to render a React-based graph component with two-way data sync between Python and JavaScript. + The widget exposes utility methods that mutate the graph in place and + automatically sync the changes to the frontend. + Dev mode: set ANYWIDGET_HMR=1 and run ``yarn dev`` in js-applet/ for hot module replacement during development. """ @@ -87,6 +94,87 @@ def from_graph_data( def __str__(self) -> str: return f"GraphWidget(nodes={len(self.nodes)}, relationships={len(self.relationships)}, options={self.options}, theme={self.theme}, width={self.width}, height={self.height})" + @cached_property + def _entity_ops(self) -> GraphEntityOperations: + return GraphEntityOperations(self) + + def _sync_entities(self, *, nodes: bool = False, relationships: bool = False) -> None: + """Propagate in-place entity mutations to the frontend. + + The utility methods delegated to :class:`GraphEntityOperations` mutate the `Node` + and `Relationship` objects in place. This does not change the identity (or equality) + of the `nodes`/`relationships` lists, so traitlets does not detect a change and would + not sync. We therefore explicitly push the affected trait(s) to JavaScript, which + re-serializes them via `entity_to_json`. When the widget is not connected to a + frontend (e.g. outside a notebook), `send_state` is a no-op. + """ + keys = [] + if nodes: + keys.append("nodes") + if relationships: + keys.append("relationships") + if keys: + self.send_state(keys if len(keys) > 1 else keys[0]) + + @delegate_doc(GraphEntityOperations.toggle_nodes_pinned) + def toggle_nodes_pinned(self, pinned: dict[NodeIdType, bool]) -> None: + self._entity_ops.toggle_nodes_pinned(pinned) + + @delegate_doc(GraphEntityOperations.set_node_captions) + def set_node_captions( + self, + *, + field: str | None = None, + property: str | None = None, + override: bool = True, + ) -> None: + self._entity_ops.set_node_captions(field=field, property=property, override=override) + + @delegate_doc(GraphEntityOperations.resize_nodes) + def resize_nodes( + self, + sizes: dict[NodeIdType, RealNumber] | None = None, + node_radius_min_max: tuple[RealNumber, RealNumber] | None = (3, 60), + property: str | None = None, + ) -> None: + self._entity_ops.resize_nodes(sizes=sizes, node_radius_min_max=node_radius_min_max, property=property) + + @delegate_doc(GraphEntityOperations.resize_relationships) + def resize_relationships( + self, + widths: dict[str | int, RealNumber] | None = None, + property: str | None = None, + ) -> None: + self._entity_ops.resize_relationships(widths=widths, property=property) + + @delegate_doc(GraphEntityOperations.color_nodes) + def color_nodes( + self, + *, + field: str | None = None, + property: str | None = None, + colors: ColorsType | None = None, + color_space: ColorSpace = ColorSpace.DISCRETE, + override: bool = True, + ) -> None: + self._entity_ops.color_nodes( + field=field, property=property, colors=colors, color_space=color_space, override=override + ) + + @delegate_doc(GraphEntityOperations.color_relationships) + def color_relationships( + self, + *, + field: str | None = None, + property: str | None = None, + colors: ColorsType | None = None, + color_space: ColorSpace = ColorSpace.DISCRETE, + override: bool = True, + ) -> None: + self._entity_ops.color_relationships( + field=field, property=property, colors=colors, color_space=color_space, override=override + ) + def add_data( self, nodes: Node | list[Node] | None = None, relationships: Relationship | list[Relationship] | None = None ) -> None: diff --git a/python-wrapper/tests/test_widget.py b/python-wrapper/tests/test_widget.py index 8033411..8860332 100644 --- a/python-wrapper/tests/test_widget.py +++ b/python-wrapper/tests/test_widget.py @@ -213,6 +213,87 @@ def test_remove_data(self) -> None: assert {r.id for r in widget.relationships} == {43} +class TestWidgetUtilityMethods: + def _spy_send_state(self, widget: GraphWidget) -> list[Any]: + synced: list[Any] = [] + widget.send_state = lambda key=None: synced.append(key) + return synced + + def test_color_nodes(self) -> None: + widget = GraphWidget(nodes=[Node(id="n1", properties={"label": "A"}), Node(id="n2", properties={"label": "B"})]) + synced = self._spy_send_state(widget) + + widget.color_nodes(property="label") + + assert widget.nodes[0].color is not None + assert widget.nodes[1].color is not None + assert widget.nodes[0].color != widget.nodes[1].color + # Mutating in place must still push the updated nodes to the frontend. + assert synced == ["nodes"] + + def test_color_relationships(self) -> None: + widget = GraphWidget( + nodes=[Node(id="n1"), Node(id="n2")], + relationships=[ + Relationship(source="n1", target="n2", caption="KNOWS"), + Relationship(source="n2", target="n1", caption="LIKES"), + ], + ) + synced = self._spy_send_state(widget) + + widget.color_relationships(field="caption") + + assert widget.relationships[0].color is not None + assert widget.relationships[0].color != widget.relationships[1].color + assert synced == ["relationships"] + + def test_resize_nodes(self) -> None: + widget = GraphWidget( + nodes=[ + Node(id="n1", properties={"score": 10}), + Node(id="n2", properties={"score": 20}), + ] + ) + synced = self._spy_send_state(widget) + + widget.resize_nodes(property="score", node_radius_min_max=(10, 50)) + + assert widget.nodes[0].size == 10 + assert widget.nodes[1].size == 50 + assert synced == ["nodes"] + + def test_resize_relationships(self) -> None: + widget = GraphWidget( + nodes=[Node(id="n1"), Node(id="n2")], + relationships=[Relationship(id="r1", source="n1", target="n2")], + ) + synced = self._spy_send_state(widget) + + widget.resize_relationships(widths={"r1": 5}) + + assert widget.relationships[0].width == 5 + assert synced == ["relationships"] + + def test_set_node_captions(self) -> None: + widget = GraphWidget(nodes=[Node(id="n1", properties={"name": "Alice"})]) + synced = self._spy_send_state(widget) + + widget.set_node_captions(property="name") + + assert widget.nodes[0].caption == "Alice" + assert synced == ["nodes"] + + def test_toggle_nodes_pinned(self) -> None: + widget = GraphWidget(nodes=[Node(id="n1", pinned=False), Node(id="n2")]) + synced = self._spy_send_state(widget) + + widget.toggle_nodes_pinned({"n1": True}) + + assert widget.nodes[0].pinned is True + assert widget.nodes[1].pinned is None + assert synced == ["nodes"] + + render_widget_cases = { "default": {}, "force layout": {"layout": Layout.FORCE_DIRECTED}, From e7763913450b8488860dbbf4e25908533ad07ea5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Mon, 8 Jun 2026 13:38:30 +0200 Subject: [PATCH 10/23] Use new methods in getting-started --- examples/getting-started.ipynb | 1717 +----------------------- python-wrapper/src/neo4j_viz/widget.py | 8 + 2 files changed, 71 insertions(+), 1654 deletions(-) diff --git a/examples/getting-started.ipynb b/examples/getting-started.ipynb index 2ae449b..4878c52 100644 --- a/examples/getting-started.ipynb +++ b/examples/getting-started.ipynb @@ -1633,12 +1633,12 @@ "

Expected window.__NEO4J_VIZ_DATA__ to be set.

\n", "

This page should be generated by neo4j_viz's render() method.

\n", " \n", - " `,new Error(\"window.__NEO4J_VIZ_DATA__ is not defined\");const ypr={get(t){return x3[t]},on(){},off(){},set(){},save_changes(){}},_3=document.getElementById(\"neo4j-viz-f81941349bdf\");if(!_3)throw new Error(\"Container element #neo4j-viz-f81941349bdf not found\");_3.style.width=x3.width??\"100%\";_3.style.height=x3.height??\"100vh\";mpr.render({model:ypr,el:_3});\n", + " `,new Error(\"window.__NEO4J_VIZ_DATA__ is not defined\");const ypr={get(t){return x3[t]},on(){},off(){},set(){},save_changes(){}},_3=document.getElementById(\"neo4j-viz-4970d3c5503e\");if(!_3)throw new Error(\"Container element #neo4j-viz-4970d3c5503e not found\");_3.style.width=x3.width??\"100%\";_3.style.height=x3.height??\"100vh\";mpr.render({model:ypr,el:_3});\n", " \n", - " \n", + " \n", "\n", " \n", - "
\n", + "
\n", " \n", "\n" ], @@ -1681,1660 +1681,28 @@ "\n", "VG = VisualizationGraph(nodes=nodes, relationships=relationships)\n", "\n", - "VG.render(initial_zoom=2)" - ] - }, - { - "cell_type": "markdown", - "id": "365a1c31", - "metadata": {}, - "source": [ - "As we can see in the graph above, the radius of one of the nodes is larger than the others.\n", - "This is because we set the \"size\" field of the node to 20, while the others are set to 10.\n", - "\n", - "At this time all nodes have the same color.\n", - "If we want to distinguish between the different types of nodes, we can color them differently with the `color_nodes` method.\n", - "We can pass the field we want to use to color the nodes as an argument.\n", - "In this case, we will use the \"caption\" field.\n", - "Nodes with the same \"caption\" will have the same color.\n", - "We will use the default colorscheme, which is the Neo4j colorscheme.\n" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "d935b3d4", - "metadata": { - "tags": [ - "preserve-output" - ] - }, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - "\n", - " \n", - " \n", - " \n", - " neo4j-viz\n", - " \n", - " \n", - " \n", - " \n", - " \n", - "\n", - " \n", - "
\n", - " \n", - "\n" - ], - "text/plain": [ - "" - ] - }, - "execution_count": 2, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ "VG.color_nodes(field=\"size\")\n", "VG.set_node_captions(field=\"size\")\n", "\n", "VG.render(initial_zoom=2)" ] }, + { + "cell_type": "markdown", + "id": "365a1c31", + "metadata": {}, + "source": [ + "As we can see in the graph above, the radius of one of the nodes is larger than the others.\n", + "This is because we set the \"size\" field of the node to 20, while the others are set to 10.\n", + "\n", + "At this time all nodes have the same color.\n", + "If we want to distinguish between the different types of nodes, we can color them differently with the `color_nodes` method.\n", + "We can pass the field we want to use to color the nodes as an argument.\n", + "In this case, we will use the \"caption\" field.\n", + "Nodes with the same \"caption\" will have the same color.\n", + "We will use the default colorscheme, which is the Neo4j colorscheme.\n" + ] + }, { "cell_type": "markdown", "id": "a28bd5aa", @@ -3358,10 +1726,30 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "id": "6j6duo4v7p9", - "metadata": {}, - "outputs": [], + "metadata": { + "tags": [ + "preserve-output" + ] + }, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "93d50cdafdc042078a8d37c3a951bea9", + "version_major": 2, + "version_minor": 1 + }, + "text/plain": [ + "" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "widget = VG.render_widget()\n", "widget" @@ -3395,6 +1783,27 @@ "\n", "widget.add_data(nodes=new_node, relationships=new_rel)" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "686e0beb", + "metadata": {}, + "outputs": [], + "source": [ + "widget.color_relationships(field=\"caption\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c68712f0", + "metadata": {}, + "outputs": [], + "source": [ + "widget.nodes[0].size = 50\n", + "widget.sync_nodes() # manually trigger sync to update widget" + ] } ], "metadata": { diff --git a/python-wrapper/src/neo4j_viz/widget.py b/python-wrapper/src/neo4j_viz/widget.py index abd589e..b1d8e9f 100644 --- a/python-wrapper/src/neo4j_viz/widget.py +++ b/python-wrapper/src/neo4j_viz/widget.py @@ -98,6 +98,14 @@ def __str__(self) -> str: def _entity_ops(self) -> GraphEntityOperations: return GraphEntityOperations(self) + def sync_nodes(self) -> None: + """Manually trigger a sync of the `nodes` list to the frontend.""" + self._sync_entities(nodes=True) + + def sync_relationships(self) -> None: + """Manually trigger a sync of the `relationships` list to the frontend.""" + self._sync_entities(relationships=True) + def _sync_entities(self, *, nodes: bool = False, relationships: bool = False) -> None: """Propagate in-place entity mutations to the frontend. From ba49877a2e0a6e18f14fe21418fc36bd77a14a8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Tue, 9 Jun 2026 13:51:24 +0200 Subject: [PATCH 11/23] Add utiltiy methods for rendering --- changelog.md | 2 + docs/source/api-reference/widget.rst | 2 + examples/getting-started.ipynb | 20 ++- python-wrapper/src/neo4j_viz/options.py | 43 +++++- python-wrapper/src/neo4j_viz/widget.py | 188 ++++++++++++++++++------ python-wrapper/tests/test_widget.py | 97 +++++++++++- 6 files changed, 297 insertions(+), 55 deletions(-) create mode 100644 docs/source/api-reference/widget.rst diff --git a/changelog.md b/changelog.md index 500e1c1..a358f94 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,8 @@ ## New features +* Add `GraphWidget` methods to change render options in place without re-rendering: `set_layout`, `set_zoom`, `set_pan`, `set_renderer`, and `set_show_layout_button` + ## Bug fixes ## Improvements diff --git a/docs/source/api-reference/widget.rst b/docs/source/api-reference/widget.rst new file mode 100644 index 0000000..8ec6510 --- /dev/null +++ b/docs/source/api-reference/widget.rst @@ -0,0 +1,2 @@ +.. autoclass:: neo4j_viz.GraphWidget + :members: diff --git a/examples/getting-started.ipynb b/examples/getting-started.ipynb index 4878c52..7a012a5 100644 --- a/examples/getting-started.ipynb +++ b/examples/getting-started.ipynb @@ -1633,12 +1633,12 @@ "

Expected window.__NEO4J_VIZ_DATA__ to be set.

\n", "

This page should be generated by neo4j_viz's render() method.

\n", " \n", - " `,new Error(\"window.__NEO4J_VIZ_DATA__ is not defined\");const ypr={get(t){return x3[t]},on(){},off(){},set(){},save_changes(){}},_3=document.getElementById(\"neo4j-viz-4970d3c5503e\");if(!_3)throw new Error(\"Container element #neo4j-viz-4970d3c5503e not found\");_3.style.width=x3.width??\"100%\";_3.style.height=x3.height??\"100vh\";mpr.render({model:ypr,el:_3});\n", + " `,new Error(\"window.__NEO4J_VIZ_DATA__ is not defined\");const ypr={get(t){return x3[t]},on(){},off(){},set(){},save_changes(){}},_3=document.getElementById(\"neo4j-viz-75a25c3af547\");if(!_3)throw new Error(\"Container element #neo4j-viz-75a25c3af547 not found\");_3.style.width=x3.width??\"100%\";_3.style.height=x3.height??\"100vh\";mpr.render({model:ypr,el:_3});\n", " \n", - " \n", + " \n", "\n", " \n", - "
\n", + "
\n", " \n", "\n" ], @@ -1737,12 +1737,12 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "93d50cdafdc042078a8d37c3a951bea9", + "model_id": "8f9849af878743d4b73329f4fd7cc977", "version_major": 2, "version_minor": 1 }, "text/plain": [ - "" + "" ] }, "execution_count": 2, @@ -1804,6 +1804,16 @@ "widget.nodes[0].size = 50\n", "widget.sync_nodes() # manually trigger sync to update widget" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f174b6ed00027bf5", + "metadata": {}, + "outputs": [], + "source": [ + "widget.set_zoom(1.5) # change the rendering options dynamically" + ] } ], "metadata": { diff --git a/python-wrapper/src/neo4j_viz/options.py b/python-wrapper/src/neo4j_viz/options.py index 2c4eb4c..ad36bbf 100644 --- a/python-wrapper/src/neo4j_viz/options.py +++ b/python-wrapper/src/neo4j_viz/options.py @@ -2,7 +2,7 @@ import warnings from enum import Enum -from typing import Any, Optional, Union +from typing import Any, Optional, TypedDict, Union import enum_tools.documentation from pydantic import BaseModel, Field, ValidationError, model_validator @@ -144,6 +144,41 @@ def check(self, renderer: Renderer, num_nodes: int) -> None: } +class PanPosition(TypedDict): + """The ``{x, y}`` pan position consumed by the frontend.""" + + x: float + y: float + + +class NvlOptionsDict(TypedDict, total=False): + """The subset of NVL instance options set from Python, nested under ``nvlOptions``. + + The frontend's ``nvlOptions`` is a ``Partial`` with many more fields; this only + types the keys the Python wrapper writes. Other keys round-trip through unchanged at runtime. + """ + + disableWebGL: bool + minZoom: float + maxZoom: float + allowDynamicMinZoom: bool + + +class RenderOptionsDict(TypedDict, total=False): + """The JS-shaped render options consumed by the ``GraphWidget`` frontend. + + This mirrors the ``GraphOptions`` type in ``js-applet/src/graph-widget.tsx`` and is the + structure stored in :attr:`GraphWidget.options`. + """ + + layout: str + layoutOptions: dict[str, Any] + nvlOptions: NvlOptionsDict + zoom: float + pan: PanPosition + showLayoutButton: bool + + class RenderOptions(BaseModel, extra="allow"): """ Options as documented at https://neo4j.com/docs/nvl/current/base-library/#_options @@ -178,7 +213,7 @@ def check_layout_options_match(self) -> RenderOptions: raise ValueError("layout_options must be of type ForceDirectedLayoutOptions for force-directed layout") return self - def to_js_options(self) -> dict[str, Any]: + def to_js_options(self) -> RenderOptionsDict: """Convert render options to the JS-compatible format for the GraphVisualization component. Returns a dict with keys that map to React component props and NVL options: @@ -188,7 +223,7 @@ def to_js_options(self) -> dict[str, Any]: - ``pan``: ``{x, y}`` pan position - ``layoutOptions``: layout-specific options """ - result: dict[str, Any] = {} + result: RenderOptionsDict = {} if self.layout is not None: match self.layout: @@ -206,7 +241,7 @@ def to_js_options(self) -> dict[str, Any]: if self.layout_options is not None: result["layoutOptions"] = self.layout_options.model_dump(exclude_none=True) - nvl_options: dict[str, Any] = {} + nvl_options: NvlOptionsDict = {} if self.renderer is not None: nvl_options["disableWebGL"] = self.renderer != Renderer.WEB_GL if self.min_zoom is not None: diff --git a/python-wrapper/src/neo4j_viz/widget.py b/python-wrapper/src/neo4j_viz/widget.py index b1d8e9f..5690f6a 100644 --- a/python-wrapper/src/neo4j_viz/widget.py +++ b/python-wrapper/src/neo4j_viz/widget.py @@ -3,7 +3,7 @@ import json import pathlib from functools import cached_property -from typing import Any, Union +from typing import Any, Union, cast import anywidget import traitlets @@ -12,7 +12,15 @@ from .colors import ColorSpace, ColorsType from .node import Node, NodeIdType from .node_size import RealNumber -from .options import RenderOptions +from .options import ( + Layout, + LayoutOptions, + NvlOptionsDict, + Renderer, + RenderOptions, + RenderOptionsDict, + construct_layout_options, +) from .relationship import Relationship, RelationshipIdType @@ -46,6 +54,8 @@ def entity_to_json(entity_list: list[Node | Relationship], widget: anywidget.Any return [_serialize_entity(entity) for entity in entity_list] +# Dev mode: set ANYWIDGET_HMR=1 and run ``yarn dev`` in js-applet/ +# for hot module replacement during development. class GraphWidget(anywidget.AnyWidget): """Jupyter widget for interactive graph visualization. @@ -54,9 +64,6 @@ class GraphWidget(anywidget.AnyWidget): The widget exposes utility methods that mutate the graph in place and automatically sync the changes to the frontend. - - Dev mode: set ANYWIDGET_HMR=1 and run ``yarn dev`` in js-applet/ - for hot module replacement during development. """ _esm = _STATIC / "widget.js" @@ -73,13 +80,13 @@ class GraphWidget(anywidget.AnyWidget): @classmethod def from_graph_data( - cls, - nodes: list[Node], - relationships: list[Relationship], - width: str = "100%", - height: str = "600px", - options: RenderOptions | None = None, - theme: str = "auto", + cls, + nodes: list[Node], + relationships: list[Relationship], + width: str = "100%", + height: str = "600px", + options: RenderOptions | None = None, + theme: str = "auto", ) -> GraphWidget: """Create a GraphWidget from Node and Relationship lists.""" return cls( @@ -130,40 +137,40 @@ def toggle_nodes_pinned(self, pinned: dict[NodeIdType, bool]) -> None: @delegate_doc(GraphEntityOperations.set_node_captions) def set_node_captions( - self, - *, - field: str | None = None, - property: str | None = None, - override: bool = True, + self, + *, + field: str | None = None, + property: str | None = None, + override: bool = True, ) -> None: self._entity_ops.set_node_captions(field=field, property=property, override=override) @delegate_doc(GraphEntityOperations.resize_nodes) def resize_nodes( - self, - sizes: dict[NodeIdType, RealNumber] | None = None, - node_radius_min_max: tuple[RealNumber, RealNumber] | None = (3, 60), - property: str | None = None, + self, + sizes: dict[NodeIdType, RealNumber] | None = None, + node_radius_min_max: tuple[RealNumber, RealNumber] | None = (3, 60), + property: str | None = None, ) -> None: self._entity_ops.resize_nodes(sizes=sizes, node_radius_min_max=node_radius_min_max, property=property) @delegate_doc(GraphEntityOperations.resize_relationships) def resize_relationships( - self, - widths: dict[str | int, RealNumber] | None = None, - property: str | None = None, + self, + widths: dict[str | int, RealNumber] | None = None, + property: str | None = None, ) -> None: self._entity_ops.resize_relationships(widths=widths, property=property) @delegate_doc(GraphEntityOperations.color_nodes) def color_nodes( - self, - *, - field: str | None = None, - property: str | None = None, - colors: ColorsType | None = None, - color_space: ColorSpace = ColorSpace.DISCRETE, - override: bool = True, + self, + *, + field: str | None = None, + property: str | None = None, + colors: ColorsType | None = None, + color_space: ColorSpace = ColorSpace.DISCRETE, + override: bool = True, ) -> None: self._entity_ops.color_nodes( field=field, property=property, colors=colors, color_space=color_space, override=override @@ -171,20 +178,111 @@ def color_nodes( @delegate_doc(GraphEntityOperations.color_relationships) def color_relationships( - self, - *, - field: str | None = None, - property: str | None = None, - colors: ColorsType | None = None, - color_space: ColorSpace = ColorSpace.DISCRETE, - override: bool = True, + self, + *, + field: str | None = None, + property: str | None = None, + colors: ColorsType | None = None, + color_space: ColorSpace = ColorSpace.DISCRETE, + override: bool = True, ) -> None: self._entity_ops.color_relationships( field=field, property=property, colors=colors, color_space=color_space, override=override ) + def _render_options(self) -> RenderOptionsDict: + """Return a typed, mutable copy of the current JS-shaped render options.""" + return cast(RenderOptionsDict, dict(self.options)) + + def set_layout(self, layout: Layout | str, layout_options: dict[str, Any] | LayoutOptions | None = None) -> None: + """ + Change the layout algorithm used to position the graph, in place. + + Parameters + ----------- + layout: + The layout algorithm to use (e.g. `Layout.FORCE_DIRECTED`, `Layout.HIERARCHICAL`). + layout_options: + Optional layout-specific options. Either a `HierarchicalLayoutOptions`/`ForceDirectedLayoutOptions` + instance or a plain dict, which is validated against the chosen layout. Layout options are only + supported for the force-directed and hierarchical layouts. + """ + if isinstance(layout, str): + layout = Layout(layout) + + if isinstance(layout_options, dict): + layout_options = construct_layout_options(layout, layout_options) + + js = RenderOptions(layout=layout, layout_options=layout_options).to_js_options() + + new = self._render_options() + new["layout"] = js["layout"] + if "layoutOptions" in js: + new["layoutOptions"] = js["layoutOptions"] + else: + new.pop("layoutOptions", None) + self.options = dict(new) + + def set_zoom(self, zoom: float) -> None: + """ + Change the zoom level of the graph, in place. + + Parameters + ----------- + zoom: + The zoom level to apply. + """ + new = self._render_options() + new["zoom"] = zoom + self.options = dict(new) + + def set_pan(self, x: float, y: float) -> None: + """ + Change the pan position of the graph, in place. + + Parameters + ----------- + x: + The pan position along the x-axis. + y: + The pan position along the y-axis. + """ + new = self._render_options() + new["pan"] = {"x": x, "y": y} + self.options = dict(new) + + def set_renderer(self, renderer: Renderer) -> None: + """ + Change the renderer used to draw the graph, in place. + + Parameters + ----------- + renderer: + The renderer to use, either `Renderer.WEB_GL` or `Renderer.CANVAS`. + """ + Renderer.check(renderer, len(self.nodes)) + + new = self._render_options() + nvl_options = cast(NvlOptionsDict, dict(new.get("nvlOptions", {}))) + nvl_options["disableWebGL"] = renderer != Renderer.WEB_GL + new["nvlOptions"] = nvl_options + self.options = dict(new) + + def set_show_layout_button(self, show: bool = True) -> None: + """ + Toggle the layout selector button in the widget UI, in place. + + Parameters + ----------- + show: + Whether the layout button should be shown. + """ + new = self._render_options() + new["showLayoutButton"] = show + self.options = dict(new) + def add_data( - self, nodes: Node | list[Node] | None = None, relationships: Relationship | list[Relationship] | None = None + self, nodes: Node | list[Node] | None = None, relationships: Relationship | list[Relationship] | None = None ) -> None: """ Add nodes or relationships to the graph widget. @@ -207,9 +305,9 @@ def add_data( self.relationships = self.relationships + relationships def remove_data( - self, - nodes: Node | list[Node | NodeIdType] | NodeIdType | None = None, - relationships: Relationship | list[Relationship | RelationshipIdType] | RelationshipIdType | None = None, + self, + nodes: Node | list[Node | NodeIdType] | NodeIdType | None = None, + relationships: Relationship | list[Relationship | RelationshipIdType] | RelationshipIdType | None = None, ) -> None: """ Remove nodes or relationships from the graph widget. @@ -244,9 +342,9 @@ def remove_data( def keep_rel(r: Relationship) -> bool: return ( - r.id not in rel_ids_to_remove - and r.source not in node_ids_to_remove - and r.target not in node_ids_to_remove + r.id not in rel_ids_to_remove + and r.source not in node_ids_to_remove + and r.target not in node_ids_to_remove ) if rel_ids_to_remove: diff --git a/python-wrapper/tests/test_widget.py b/python-wrapper/tests/test_widget.py index 8860332..83bc26c 100644 --- a/python-wrapper/tests/test_widget.py +++ b/python-wrapper/tests/test_widget.py @@ -4,7 +4,7 @@ import pytest from neo4j_viz import GraphWidget, Node, Relationship, VisualizationGraph -from neo4j_viz.options import Layout, RenderOptions +from neo4j_viz.options import Layout, Renderer, RenderOptions from neo4j_viz.widget import _serialize_entity @@ -365,3 +365,98 @@ def test_render_widget_options_passed_through(self) -> None: assert widget.options["zoom"] == 2.0 assert widget.options["nvlOptions"]["minZoom"] == 0.1 assert widget.options["nvlOptions"]["maxZoom"] == 5.0 + + +class TestRenderOptionSetters: + def test_set_layout(self) -> None: + widget = GraphWidget() + + widget.set_layout(Layout.HIERARCHICAL) + + assert widget.options["layout"] == "hierarchical" + + def test_set_layout_with_options(self) -> None: + widget = GraphWidget() + + widget.set_layout(Layout.FORCE_DIRECTED, {"gravity": 0.1}) + + assert widget.options["layout"] == "d3Force" + assert widget.options["layoutOptions"] == {"gravity": 0.1} + + def test_set_layout_clears_stale_layout_options(self) -> None: + widget = GraphWidget(options={"layoutOptions": {"gravity": 0.1}}) + + widget.set_layout(Layout.GRID) + + assert widget.options["layout"] == "grid" + assert "layoutOptions" not in widget.options + + def test_set_layout_with_mismatched_options_raises(self) -> None: + widget = GraphWidget() + + with pytest.raises(ValueError): + widget.set_layout(Layout.HIERARCHICAL, {"gravity": 0.1}) + + def test_set_zoom(self) -> None: + widget = GraphWidget() + + widget.set_zoom(2.0) + + assert widget.options["zoom"] == 2.0 + + def test_set_pan(self) -> None: + widget = GraphWidget() + + widget.set_pan(100, 50) + + assert widget.options["pan"] == {"x": 100, "y": 50} + + def test_set_renderer_canvas(self) -> None: + widget = GraphWidget() + + widget.set_renderer(Renderer.CANVAS) + + assert widget.options["nvlOptions"]["disableWebGL"] is True + + def test_set_renderer_webgl(self) -> None: + widget = GraphWidget() + + with pytest.warns(UserWarning): + widget.set_renderer(Renderer.WEB_GL) + + assert widget.options["nvlOptions"]["disableWebGL"] is False + + def test_set_renderer_preserves_other_nvl_options(self) -> None: + widget = GraphWidget(options={"nvlOptions": {"minZoom": 0.1}}) + + widget.set_renderer(Renderer.CANVAS) + + assert widget.options["nvlOptions"]["minZoom"] == 0.1 + assert widget.options["nvlOptions"]["disableWebGL"] is True + + def test_set_show_layout_button(self) -> None: + widget = GraphWidget() + + widget.set_show_layout_button() + assert widget.options["showLayoutButton"] is True + + widget.set_show_layout_button(False) + assert widget.options["showLayoutButton"] is False + + def test_setter_preserves_unrelated_options(self) -> None: + widget = GraphWidget(options={"layout": "hierarchical"}) + + widget.set_zoom(3.0) + + assert widget.options["zoom"] == 3.0 + assert widget.options["layout"] == "hierarchical" + + def test_setter_triggers_sync(self) -> None: + widget = GraphWidget() + changes: list[dict[str, Any]] = [] + widget.observe(lambda change: changes.append(change), names=["options"]) + + widget.set_zoom(2.0) + + assert len(changes) == 1 + assert changes[0]["name"] == "options" From bfe4048efcbbfdae02f80db8494001d9d57e5d12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Tue, 9 Jun 2026 15:42:35 +0200 Subject: [PATCH 12/23] Fix doc strings --- .../src/neo4j_viz/_graph_entity_operations.py | 198 +------------- .../src/neo4j_viz/visualization_graph.py | 175 +++++++++++- python-wrapper/src/neo4j_viz/widget.py | 257 ++++++++++++++---- 3 files changed, 385 insertions(+), 245 deletions(-) diff --git a/python-wrapper/src/neo4j_viz/_graph_entity_operations.py b/python-wrapper/src/neo4j_viz/_graph_entity_operations.py index fd20abe..5ef0ee8 100644 --- a/python-wrapper/src/neo4j_viz/_graph_entity_operations.py +++ b/python-wrapper/src/neo4j_viz/_graph_entity_operations.py @@ -2,7 +2,7 @@ import warnings from collections.abc import Hashable, Iterable -from typing import Any, Callable, Protocol, TypeVar +from typing import Any, Callable, Protocol from pydantic.alias_generators import to_snake from pydantic_extra_types.color import Color, ColorType @@ -12,22 +12,6 @@ from .node_size import RealNumber, verify_radii from .relationship import Relationship -F = TypeVar("F", bound=Callable[..., Any]) - - -def delegate_doc(target: Callable[..., Any]) -> Callable[[F], F]: - """Copy the docstring of `target` onto the decorated function. - - Lets the thin delegating methods on the host classes reuse the canonical docstrings - defined on `GraphEntityOperations` without duplicating the text. - """ - - def decorator(fn: F) -> F: - fn.__doc__ = target.__doc__ - return fn - - return decorator - class EntityHost(Protocol): """The interface a host must expose to be driven by `GraphEntityOperations`.""" @@ -59,14 +43,7 @@ def relationships(self) -> list[Relationship]: return self._host.relationships def toggle_nodes_pinned(self, pinned: dict[NodeIdType, bool]) -> None: - """ - Toggle whether nodes should be pinned or not. - - Parameters - ---------- - pinned: - A dictionary mapping from node ID to whether the node should be pinned or not. - """ + """Pin or unpin nodes. See `VisualizationGraph.toggle_nodes_pinned` for details.""" for node in self.nodes: node_pinned = pinned.get(node.id) @@ -84,43 +61,7 @@ def set_node_captions( property: str | None = None, override: bool = True, ) -> None: - """ - Set the caption for nodes in the graph based on either a node field or a node property. - - Parameters - ---------- - field: - The field of the nodes to use as the caption. Must be None if `property` is provided. - property: - The property of the nodes to use as the caption. Must be None if `field` is provided. - override: - Whether to override existing captions of the nodes, if they have any. - - Examples - -------- - Given a VisualizationGraph `VG`: - - >>> nodes = [ - ... Node(id="0", properties={"name": "Alice", "age": 30}), - ... Node(id="1", properties={"name": "Bob", "age": 25}), - ... ] - >>> VG = VisualizationGraph(nodes=nodes) - - Set node captions from a property: - - >>> VG.set_node_captions(property="name") - - Set node captions from a field, only if not already set: - - >>> VG.set_node_captions(field="id", override=False) - - Set captions from multiple properties with fallback: - - >>> for node in VG.nodes: - ... caption = node.properties.get("name") or node.properties.get("title") or node.id - ... if override or node.caption is None: - ... node.caption = str(caption) - """ + """Set node captions from a field or property. See `VisualizationGraph.set_node_captions` for details.""" if not ((field is None) ^ (property is None)): raise ValueError( f"Exactly one of the arguments `field` (received '{field}') and `property` (received '{property}') must be provided" @@ -154,21 +95,7 @@ def resize_nodes( node_radius_min_max: tuple[RealNumber, RealNumber] | None = (3, 60), property: str | None = None, ) -> None: - """ - Resize the nodes in the graph. - - Parameters - ---------- - sizes: - A dictionary mapping from node ID to the new size of the node. - If a node ID is not in the dictionary, the size of the node is not changed. - Must be None if `property` is provided. - node_radius_min_max: - Minimum and maximum node size radius as a tuple. To avoid tiny or huge nodes in the visualization, the - node sizes are scaled to fit in the given range. If None, the sizes are used as is. - property: - The property of the nodes to use for sizing. Must be None if `sizes` is provided. - """ + """Resize nodes from explicit sizes or a property. See `VisualizationGraph.resize_nodes` for details.""" if sizes is not None and property is not None: raise ValueError("At most one of the arguments `sizes` and `property` can be provided") @@ -226,18 +153,7 @@ def resize_relationships( widths: dict[str | int, RealNumber] | None = None, property: str | None = None, ) -> None: - """ - Resize the width of relationships in the graph. - - Parameters - ---------- - widths: - A dictionary mapping from relationship ID to the new width of the relationship. - If a relationship ID is not in the dictionary, the width of the relationship is not changed. - Must be None if `property` is provided. - property: - The property of the relationships to use for sizing. Must be None if `widths` is provided. - """ + """Resize relationship widths from explicit widths or a property. See `VisualizationGraph.resize_relationships` for details.""" if widths is not None and property is not None: raise ValueError("At most one of the arguments `widths` and `property` can be provided") @@ -305,59 +221,7 @@ def color_nodes( color_space: ColorSpace = ColorSpace.DISCRETE, override: bool = True, ) -> None: - """ - Color the nodes in the graph based on either a node field, or a node property. - - It's possible to color the nodes based on a discrete or continuous color space. In the discrete case, a new - color from the `colors` provided is assigned to each unique value of the node field/property. - In the continuous case, the `colors` should be a list of colors representing a range that are used to - create a gradient of colors based on the values of the node field/property. - - Parameters - ---------- - field: - The field of the nodes to base the coloring on. The type of this field must be hashable, or be a - list, set or dict containing only hashable types. Must be None if `property` is provided. - property: - The property of the nodes to base the coloring on. The type of this property must be hashable, or be a - list, set or dict containing only hashable types. Must be None if `field` is provided. - colors: - The colors to use for the nodes. - If `color_space` is `ColorSpace.DISCRETE`, the colors can be a dictionary mapping from field/property value - to color, or an iterable of colors in which case the colors are used in order. - If `color_space` is `ColorSpace.CONTINUOUS`, the colors must be a list of colors representing a range. - Allowed color values are for example “#FF0000”, “red” or (255, 0, 0) (full list: https://docs.pydantic.dev/2.0/usage/types/extra_types/color_types/). - The default colors are the Neo4j graph colors. - color_space: - The type of space of the provided `colors`. Either `ColorSpace.DISCRETE` or `ColorSpace.CONTINUOUS`. It determines whether - colors are assigned based on unique field/property values or a gradient of the values of the field/property. - override: - Whether to override existing colors of the nodes, if they have any. - - Examples - -------- - - Given a VisualizationGraph `VG`: - - >>> nodes = [ - ... Node(id="0", properties={"label": "Person", "score": 10}), - ... Node(id="1", properties={"label": "Person", "score": 20}), - ... ] - >>> VG = VisualizationGraph(nodes=nodes) - - Color nodes based on a discrete field such as "label": - - >>> VG.color_nodes(field="label", color_space=ColorSpace.DISCRETE) - - Color nodes based on a continuous field such as "score": - - >>> VG.color_nodes(field="score", color_space=ColorSpace.CONTINUOUS) - - Color nodes based on a custom colors such as from palettable: - - >>> from palettable.wesanderson import Moonrise1_5 # type: ignore[import-untyped] - >>> VG.color_nodes(field="label", colors=Moonrise1_5.colors) - """ + """Color nodes by a field or property (discrete or continuous). See `VisualizationGraph.color_nodes` for details.""" if not ((field is None) ^ (property is None)): raise ValueError( f"Exactly one of the arguments `field` (received '{field}') and `property` (received '{property}') must be provided" @@ -413,55 +277,7 @@ def color_relationships( color_space: ColorSpace = ColorSpace.DISCRETE, override: bool = True, ) -> None: - """ - Color the relationships in the graph based on either a relationship field, or a relationship property. - - It's possible to color the relationships based on a discrete or continuous color space. In the discrete case, - a new color from the `colors` provided is assigned to each unique value of the relationship field/property. - In the continuous case, the `colors` should be a list of colors representing a range that are used to - create a gradient of colors based on the values of the relationship field/property. - - Parameters - ---------- - field: - The field of the relationships to base the coloring on. The type of this field must be hashable, or be a - list, set or dict containing only hashable types. Must be None if `property` is provided. - property: - The property of the relationships to base the coloring on. The type of this property must be hashable, or be a - list, set or dict containing only hashable types. Must be None if `field` is provided. - colors: - The colors to use for the relationships. - If `color_space` is `ColorSpace.DISCRETE`, the colors can be a dictionary mapping from field/property value - to color, or an iterable of colors in which case the colors are used in order. - If `color_space` is `ColorSpace.CONTINUOUS`, the colors must be a list of colors representing a range. - Allowed color values are for example “#FF0000”, “red” or (255, 0, 0) (full list: https://docs.pydantic.dev/2.0/usage/types/extra_types/color_types/). - The default colors are the Neo4j graph colors. - color_space: - The type of space of the provided `colors`. Either `ColorSpace.DISCRETE` or `ColorSpace.CONTINUOUS`. It determines whether - colors are assigned based on unique field/property values or a gradient of the values of the field/property. - override: - Whether to override existing colors of the relationships, if they have any. - - Examples - -------- - - Given a VisualizationGraph `VG`: - - >>> nodes = [Node(id="0"), Node(id="1")] - >>> relationships = [ - ... Relationship(source="0", target="1", caption="ACTED_IN", properties={"score": 10}), - ... Relationship(source="1", target="0", caption="DIRECTED", properties={"score": 20}), - ... ] - >>> VG = VisualizationGraph(nodes=nodes, relationships=relationships) - - Color relationships based on a discrete field such as "caption": - - >>> VG.color_relationships(field="caption", color_space=ColorSpace.DISCRETE) - - Color relationships based on a continuous field such as "score": - - >>> VG.color_relationships(property="score", color_space=ColorSpace.CONTINUOUS) - """ + """Color relationships by a field or property (discrete or continuous). See `VisualizationGraph.color_relationships` for details.""" if not ((field is None) ^ (property is None)): raise ValueError( f"Exactly one of the arguments `field` (received '{field}') and `property` (received '{property}') must be provided" diff --git a/python-wrapper/src/neo4j_viz/visualization_graph.py b/python-wrapper/src/neo4j_viz/visualization_graph.py index 67bff0b..28ba65c 100644 --- a/python-wrapper/src/neo4j_viz/visualization_graph.py +++ b/python-wrapper/src/neo4j_viz/visualization_graph.py @@ -5,7 +5,7 @@ from IPython.display import HTML -from ._graph_entity_operations import GraphEntityOperations, delegate_doc +from ._graph_entity_operations import GraphEntityOperations from .colors import ColorSpace, ColorsType from .node import Node, NodeIdType from .node_size import RealNumber @@ -92,11 +92,17 @@ def _entity_ops(self) -> GraphEntityOperations: def _sync_entities(self, *, nodes: bool = False, relationships: bool = False) -> None: """Hook invoked after entities are mutated in place. A no-op for a plain graph.""" - @delegate_doc(GraphEntityOperations.toggle_nodes_pinned) def toggle_nodes_pinned(self, pinned: dict[NodeIdType, bool]) -> None: + """ + Toggle whether nodes should be pinned or not. + + Parameters + ---------- + pinned: + A dictionary mapping from node ID to whether the node should be pinned or not. + """ self._entity_ops.toggle_nodes_pinned(pinned) - @delegate_doc(GraphEntityOperations.set_node_captions) def set_node_captions( self, *, @@ -104,26 +110,80 @@ def set_node_captions( property: str | None = None, override: bool = True, ) -> None: + """ + Set the caption for nodes in the graph based on either a node field or a node property. + + Parameters + ---------- + field: + The field of the nodes to use as the caption. Must be None if `property` is provided. + property: + The property of the nodes to use as the caption. Must be None if `field` is provided. + override: + Whether to override existing captions of the nodes, if they have any. + + Examples + -------- + Given a VisualizationGraph `VG`: + + >>> nodes = [ + ... Node(id="0", properties={"name": "Alice", "age": 30}), + ... Node(id="1", properties={"name": "Bob", "age": 25}), + ... ] + >>> VG = VisualizationGraph(nodes=nodes, relationships=[]) + + Set node captions from a property: + + >>> VG.set_node_captions(property="name") + + Set node captions from a field, only if not already set: + + >>> VG.set_node_captions(field="id", override=False) + """ self._entity_ops.set_node_captions(field=field, property=property, override=override) - @delegate_doc(GraphEntityOperations.resize_nodes) def resize_nodes( self, sizes: dict[NodeIdType, RealNumber] | None = None, node_radius_min_max: tuple[RealNumber, RealNumber] | None = (3, 60), property: str | None = None, ) -> None: + """ + Resize the nodes in the graph. + + Parameters + ---------- + sizes: + A dictionary mapping from node ID to the new size of the node. + If a node ID is not in the dictionary, the size of the node is not changed. + Must be None if `property` is provided. + node_radius_min_max: + Minimum and maximum node size radius as a tuple. To avoid tiny or huge nodes in the visualization, the + node sizes are scaled to fit in the given range. If None, the sizes are used as is. + property: + The property of the nodes to use for sizing. Must be None if `sizes` is provided. + """ self._entity_ops.resize_nodes(sizes=sizes, node_radius_min_max=node_radius_min_max, property=property) - @delegate_doc(GraphEntityOperations.resize_relationships) def resize_relationships( self, widths: dict[str | int, RealNumber] | None = None, property: str | None = None, ) -> None: + """ + Resize the width of relationships in the graph. + + Parameters + ---------- + widths: + A dictionary mapping from relationship ID to the new width of the relationship. + If a relationship ID is not in the dictionary, the width of the relationship is not changed. + Must be None if `property` is provided. + property: + The property of the relationships to use for sizing. Must be None if `widths` is provided. + """ self._entity_ops.resize_relationships(widths=widths, property=property) - @delegate_doc(GraphEntityOperations.color_nodes) def color_nodes( self, *, @@ -133,11 +193,63 @@ def color_nodes( color_space: ColorSpace = ColorSpace.DISCRETE, override: bool = True, ) -> None: + """ + Color the nodes in the graph based on either a node field, or a node property. + + It's possible to color the nodes based on a discrete or continuous color space. In the discrete case, a new + color from the `colors` provided is assigned to each unique value of the node field/property. + In the continuous case, the `colors` should be a list of colors representing a range that are used to + create a gradient of colors based on the values of the node field/property. + + Parameters + ---------- + field: + The field of the nodes to base the coloring on. The type of this field must be hashable, or be a + list, set or dict containing only hashable types. Must be None if `property` is provided. + property: + The property of the nodes to base the coloring on. The type of this property must be hashable, or be a + list, set or dict containing only hashable types. Must be None if `field` is provided. + colors: + The colors to use for the nodes. + If `color_space` is `ColorSpace.DISCRETE`, the colors can be a dictionary mapping from field/property value + to color, or an iterable of colors in which case the colors are used in order. + If `color_space` is `ColorSpace.CONTINUOUS`, the colors must be a list of colors representing a range. + Allowed color values are for example “#FF0000”, “red” or (255, 0, 0) (full list: https://docs.pydantic.dev/2.0/usage/types/extra_types/color_types/). + The default colors are the Neo4j graph colors. + color_space: + The type of space of the provided `colors`. Either `ColorSpace.DISCRETE` or `ColorSpace.CONTINUOUS`. It determines whether + colors are assigned based on unique field/property values or a gradient of the values of the field/property. + override: + Whether to override existing colors of the nodes, if they have any. + + Examples + -------- + + Given a VisualizationGraph `VG`: + + >>> nodes = [ + ... Node(id="0", properties={"label": "Person", "score": 10}), + ... Node(id="1", properties={"label": "Person", "score": 20}), + ... ] + >>> VG = VisualizationGraph(nodes=nodes, relationships=[]) + + Color nodes based on a discrete field such as "label": + + >>> VG.color_nodes(field="label", color_space=ColorSpace.DISCRETE) + + Color nodes based on a continuous field such as "score": + + >>> VG.color_nodes(field="score", color_space=ColorSpace.CONTINUOUS) + + Color nodes based on a custom colors such as from palettable: + + >>> from palettable.wesanderson import Moonrise1_5 # type: ignore[import-untyped] + >>> VG.color_nodes(field="label", colors=Moonrise1_5.colors) + """ self._entity_ops.color_nodes( field=field, property=property, colors=colors, color_space=color_space, override=override ) - @delegate_doc(GraphEntityOperations.color_relationships) def color_relationships( self, *, @@ -147,6 +259,55 @@ def color_relationships( color_space: ColorSpace = ColorSpace.DISCRETE, override: bool = True, ) -> None: + """ + Color the relationships in the graph based on either a relationship field, or a relationship property. + + It's possible to color the relationships based on a discrete or continuous color space. In the discrete case, + a new color from the `colors` provided is assigned to each unique value of the relationship field/property. + In the continuous case, the `colors` should be a list of colors representing a range that are used to + create a gradient of colors based on the values of the relationship field/property. + + Parameters + ---------- + field: + The field of the relationships to base the coloring on. The type of this field must be hashable, or be a + list, set or dict containing only hashable types. Must be None if `property` is provided. + property: + The property of the relationships to base the coloring on. The type of this property must be hashable, or be a + list, set or dict containing only hashable types. Must be None if `field` is provided. + colors: + The colors to use for the relationships. + If `color_space` is `ColorSpace.DISCRETE`, the colors can be a dictionary mapping from field/property value + to color, or an iterable of colors in which case the colors are used in order. + If `color_space` is `ColorSpace.CONTINUOUS`, the colors must be a list of colors representing a range. + Allowed color values are for example “#FF0000”, “red” or (255, 0, 0) (full list: https://docs.pydantic.dev/2.0/usage/types/extra_types/color_types/). + The default colors are the Neo4j graph colors. + color_space: + The type of space of the provided `colors`. Either `ColorSpace.DISCRETE` or `ColorSpace.CONTINUOUS`. It determines whether + colors are assigned based on unique field/property values or a gradient of the values of the field/property. + override: + Whether to override existing colors of the relationships, if they have any. + + Examples + -------- + + Given a VisualizationGraph `VG`: + + >>> nodes = [Node(id="0"), Node(id="1")] + >>> relationships = [ + ... Relationship(source="0", target="1", caption="ACTED_IN", properties={"score": 10}), + ... Relationship(source="1", target="0", caption="DIRECTED", properties={"score": 20}), + ... ] + >>> VG = VisualizationGraph(nodes=nodes, relationships=relationships) + + Color relationships based on a discrete field such as "caption": + + >>> VG.color_relationships(field="caption", color_space=ColorSpace.DISCRETE) + + Color relationships based on a continuous field such as "score": + + >>> VG.color_relationships(property="score", color_space=ColorSpace.CONTINUOUS) + """ self._entity_ops.color_relationships( field=field, property=property, colors=colors, color_space=color_space, override=override ) diff --git a/python-wrapper/src/neo4j_viz/widget.py b/python-wrapper/src/neo4j_viz/widget.py index 5690f6a..0abfa7c 100644 --- a/python-wrapper/src/neo4j_viz/widget.py +++ b/python-wrapper/src/neo4j_viz/widget.py @@ -8,7 +8,7 @@ import anywidget import traitlets -from ._graph_entity_operations import GraphEntityOperations, delegate_doc +from ._graph_entity_operations import GraphEntityOperations from .colors import ColorSpace, ColorsType from .node import Node, NodeIdType from .node_size import RealNumber @@ -56,6 +56,8 @@ def entity_to_json(entity_list: list[Node | Relationship], widget: anywidget.Any # Dev mode: set ANYWIDGET_HMR=1 and run ``yarn dev`` in js-applet/ # for hot module replacement during development. + + class GraphWidget(anywidget.AnyWidget): """Jupyter widget for interactive graph visualization. @@ -80,13 +82,13 @@ class GraphWidget(anywidget.AnyWidget): @classmethod def from_graph_data( - cls, - nodes: list[Node], - relationships: list[Relationship], - width: str = "100%", - height: str = "600px", - options: RenderOptions | None = None, - theme: str = "auto", + cls, + nodes: list[Node], + relationships: list[Relationship], + width: str = "100%", + height: str = "600px", + options: RenderOptions | None = None, + theme: str = "auto", ) -> GraphWidget: """Create a GraphWidget from Node and Relationship lists.""" return cls( @@ -131,61 +133,222 @@ def _sync_entities(self, *, nodes: bool = False, relationships: bool = False) -> if keys: self.send_state(keys if len(keys) > 1 else keys[0]) - @delegate_doc(GraphEntityOperations.toggle_nodes_pinned) def toggle_nodes_pinned(self, pinned: dict[NodeIdType, bool]) -> None: + """ + Toggle whether nodes should be pinned or not. + + Parameters + ---------- + pinned: + A dictionary mapping from node ID to whether the node should be pinned or not. + """ self._entity_ops.toggle_nodes_pinned(pinned) - @delegate_doc(GraphEntityOperations.set_node_captions) def set_node_captions( - self, - *, - field: str | None = None, - property: str | None = None, - override: bool = True, + self, + *, + field: str | None = None, + property: str | None = None, + override: bool = True, ) -> None: + """ + Set the caption for nodes in the graph based on either a node field or a node property. + + Parameters + ---------- + field: + The field of the nodes to use as the caption. Must be None if `property` is provided. + property: + The property of the nodes to use as the caption. Must be None if `field` is provided. + override: + Whether to override existing captions of the nodes, if they have any. + + Examples + -------- + Given a GraphWidget `widget`: + + >>> nodes = [ + ... Node(id="0", properties={"name": "Alice", "age": 30}), + ... Node(id="1", properties={"name": "Bob", "age": 25}), + ... ] + >>> widget = GraphWidget(nodes=nodes) + + Set node captions from a property: + + >>> widget.set_node_captions(property="name") + + Set node captions from a field, only if not already set: + + >>> widget.set_node_captions(field="id", override=False) + """ self._entity_ops.set_node_captions(field=field, property=property, override=override) - @delegate_doc(GraphEntityOperations.resize_nodes) def resize_nodes( - self, - sizes: dict[NodeIdType, RealNumber] | None = None, - node_radius_min_max: tuple[RealNumber, RealNumber] | None = (3, 60), - property: str | None = None, + self, + sizes: dict[NodeIdType, RealNumber] | None = None, + node_radius_min_max: tuple[RealNumber, RealNumber] | None = (3, 60), + property: str | None = None, ) -> None: + """ + Resize the nodes in the graph. + + Parameters + ---------- + sizes: + A dictionary mapping from node ID to the new size of the node. + If a node ID is not in the dictionary, the size of the node is not changed. + Must be None if `property` is provided. + node_radius_min_max: + Minimum and maximum node size radius as a tuple. To avoid tiny or huge nodes in the visualization, the + node sizes are scaled to fit in the given range. If None, the sizes are used as is. + property: + The property of the nodes to use for sizing. Must be None if `sizes` is provided. + """ self._entity_ops.resize_nodes(sizes=sizes, node_radius_min_max=node_radius_min_max, property=property) - @delegate_doc(GraphEntityOperations.resize_relationships) def resize_relationships( - self, - widths: dict[str | int, RealNumber] | None = None, - property: str | None = None, + self, + widths: dict[str | int, RealNumber] | None = None, + property: str | None = None, ) -> None: + """ + Resize the width of relationships in the graph. + + Parameters + ---------- + widths: + A dictionary mapping from relationship ID to the new width of the relationship. + If a relationship ID is not in the dictionary, the width of the relationship is not changed. + Must be None if `property` is provided. + property: + The property of the relationships to use for sizing. Must be None if `widths` is provided. + """ self._entity_ops.resize_relationships(widths=widths, property=property) - @delegate_doc(GraphEntityOperations.color_nodes) def color_nodes( - self, - *, - field: str | None = None, - property: str | None = None, - colors: ColorsType | None = None, - color_space: ColorSpace = ColorSpace.DISCRETE, - override: bool = True, + self, + *, + field: str | None = None, + property: str | None = None, + colors: ColorsType | None = None, + color_space: ColorSpace = ColorSpace.DISCRETE, + override: bool = True, ) -> None: + """ + Color the nodes in the graph based on either a node field, or a node property. + + It's possible to color the nodes based on a discrete or continuous color space. In the discrete case, a new + color from the `colors` provided is assigned to each unique value of the node field/property. + In the continuous case, the `colors` should be a list of colors representing a range that are used to + create a gradient of colors based on the values of the node field/property. + + Parameters + ---------- + field: + The field of the nodes to base the coloring on. The type of this field must be hashable, or be a + list, set or dict containing only hashable types. Must be None if `property` is provided. + property: + The property of the nodes to base the coloring on. The type of this property must be hashable, or be a + list, set or dict containing only hashable types. Must be None if `field` is provided. + colors: + The colors to use for the nodes. + If `color_space` is `ColorSpace.DISCRETE`, the colors can be a dictionary mapping from field/property value + to color, or an iterable of colors in which case the colors are used in order. + If `color_space` is `ColorSpace.CONTINUOUS`, the colors must be a list of colors representing a range. + Allowed color values are for example “#FF0000”, “red” or (255, 0, 0) (full list: https://docs.pydantic.dev/2.0/usage/types/extra_types/color_types/). + The default colors are the Neo4j graph colors. + color_space: + The type of space of the provided `colors`. Either `ColorSpace.DISCRETE` or `ColorSpace.CONTINUOUS`. It determines whether + colors are assigned based on unique field/property values or a gradient of the values of the field/property. + override: + Whether to override existing colors of the nodes, if they have any. + + Examples + -------- + + Given a GraphWidget `widget`: + + >>> nodes = [ + ... Node(id="0", properties={"label": "Person", "score": 10}), + ... Node(id="1", properties={"label": "Person", "score": 20}), + ... ] + >>> widget = GraphWidget(nodes=nodes) + + Color nodes based on a discrete field such as "label": + + >>> widget.color_nodes(field="label", color_space=ColorSpace.DISCRETE) + + Color nodes based on a continuous field such as "score": + + >>> widget.color_nodes(field="score", color_space=ColorSpace.CONTINUOUS) + + Color nodes based on a custom colors such as from palettable: + + >>> from palettable.wesanderson import Moonrise1_5 # type: ignore[import-untyped] + >>> widget.color_nodes(field="label", colors=Moonrise1_5.colors) + """ self._entity_ops.color_nodes( field=field, property=property, colors=colors, color_space=color_space, override=override ) - @delegate_doc(GraphEntityOperations.color_relationships) def color_relationships( - self, - *, - field: str | None = None, - property: str | None = None, - colors: ColorsType | None = None, - color_space: ColorSpace = ColorSpace.DISCRETE, - override: bool = True, + self, + *, + field: str | None = None, + property: str | None = None, + colors: ColorsType | None = None, + color_space: ColorSpace = ColorSpace.DISCRETE, + override: bool = True, ) -> None: + """ + Color the relationships in the graph based on either a relationship field, or a relationship property. + + It's possible to color the relationships based on a discrete or continuous color space. In the discrete case, + a new color from the `colors` provided is assigned to each unique value of the relationship field/property. + In the continuous case, the `colors` should be a list of colors representing a range that are used to + create a gradient of colors based on the values of the relationship field/property. + + Parameters + ---------- + field: + The field of the relationships to base the coloring on. The type of this field must be hashable, or be a + list, set or dict containing only hashable types. Must be None if `property` is provided. + property: + The property of the relationships to base the coloring on. The type of this property must be hashable, or be a + list, set or dict containing only hashable types. Must be None if `field` is provided. + colors: + The colors to use for the relationships. + If `color_space` is `ColorSpace.DISCRETE`, the colors can be a dictionary mapping from field/property value + to color, or an iterable of colors in which case the colors are used in order. + If `color_space` is `ColorSpace.CONTINUOUS`, the colors must be a list of colors representing a range. + Allowed color values are for example “#FF0000”, “red” or (255, 0, 0) (full list: https://docs.pydantic.dev/2.0/usage/types/extra_types/color_types/). + The default colors are the Neo4j graph colors. + color_space: + The type of space of the provided `colors`. Either `ColorSpace.DISCRETE` or `ColorSpace.CONTINUOUS`. It determines whether + colors are assigned based on unique field/property values or a gradient of the values of the field/property. + override: + Whether to override existing colors of the relationships, if they have any. + + Examples + -------- + + Given a GraphWidget `widget`: + + >>> nodes = [Node(id="0"), Node(id="1")] + >>> relationships = [ + ... Relationship(source="0", target="1", caption="ACTED_IN", properties={"score": 10}), + ... Relationship(source="1", target="0", caption="DIRECTED", properties={"score": 20}), + ... ] + >>> widget = GraphWidget(nodes=nodes, relationships=relationships) + + Color relationships based on a discrete field such as "caption": + + >>> widget.color_relationships(field="caption", color_space=ColorSpace.DISCRETE) + + Color relationships based on a continuous field such as "score": + + >>> widget.color_relationships(property="score", color_space=ColorSpace.CONTINUOUS) + """ self._entity_ops.color_relationships( field=field, property=property, colors=colors, color_space=color_space, override=override ) @@ -282,7 +445,7 @@ def set_show_layout_button(self, show: bool = True) -> None: self.options = dict(new) def add_data( - self, nodes: Node | list[Node] | None = None, relationships: Relationship | list[Relationship] | None = None + self, nodes: Node | list[Node] | None = None, relationships: Relationship | list[Relationship] | None = None ) -> None: """ Add nodes or relationships to the graph widget. @@ -305,9 +468,9 @@ def add_data( self.relationships = self.relationships + relationships def remove_data( - self, - nodes: Node | list[Node | NodeIdType] | NodeIdType | None = None, - relationships: Relationship | list[Relationship | RelationshipIdType] | RelationshipIdType | None = None, + self, + nodes: Node | list[Node | NodeIdType] | NodeIdType | None = None, + relationships: Relationship | list[Relationship | RelationshipIdType] | RelationshipIdType | None = None, ) -> None: """ Remove nodes or relationships from the graph widget. @@ -342,9 +505,9 @@ def remove_data( def keep_rel(r: Relationship) -> bool: return ( - r.id not in rel_ids_to_remove - and r.source not in node_ids_to_remove - and r.target not in node_ids_to_remove + r.id not in rel_ids_to_remove + and r.source not in node_ids_to_remove + and r.target not in node_ids_to_remove ) if rel_ids_to_remove: From c85dfa11b535e95e4a3c6a16f981d62553971ac2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Tue, 9 Jun 2026 15:43:29 +0200 Subject: [PATCH 13/23] Fix doc strings --- changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog.md b/changelog.md index a358f94..9fbd605 100644 --- a/changelog.md +++ b/changelog.md @@ -5,6 +5,7 @@ ## New features * Add `GraphWidget` methods to change render options in place without re-rendering: `set_layout`, `set_zoom`, `set_pan`, `set_renderer`, and `set_show_layout_button` +* Add `GraphWidget` methods to change styling in place without re-rendering such as `color_relationships` ## Bug fixes From 58e04f595d447ab2528d4f927ea1ceeb1f51c603 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Fri, 12 Jun 2026 14:06:50 +0200 Subject: [PATCH 14/23] Use pydantic model for graphwidget options --- changelog.md | 1 + justfile | 1 + python-wrapper/src/neo4j_viz/__init__.py | 6 ++ python-wrapper/src/neo4j_viz/nvl.py | 2 +- python-wrapper/src/neo4j_viz/options.py | 121 ++++++++++++----------- python-wrapper/src/neo4j_viz/widget.py | 66 ++++++++----- python-wrapper/tests/test_options.py | 56 +++++------ python-wrapper/tests/test_widget.py | 53 +++++----- 8 files changed, 170 insertions(+), 136 deletions(-) diff --git a/changelog.md b/changelog.md index 9fbd605..cdec7e8 100644 --- a/changelog.md +++ b/changelog.md @@ -13,5 +13,6 @@ * Support Aura Graph Analytics * Support `gds.v2` endpoints +* Use typed options field in `GraphWidget` ## Other changes diff --git a/justfile b/justfile index f876c3e..837e4c6 100644 --- a/justfile +++ b/justfile @@ -9,6 +9,7 @@ py-style: ./scripts/makestyle.sh && ./scripts/checkstyle.sh py-test: + cd python-wrapper && uv sync --all-extras --group dev cd python-wrapper && uv run --group dev pytest py-test-gds: diff --git a/python-wrapper/src/neo4j_viz/__init__.py b/python-wrapper/src/neo4j_viz/__init__.py index 920e6fe..7d759d5 100644 --- a/python-wrapper/src/neo4j_viz/__init__.py +++ b/python-wrapper/src/neo4j_viz/__init__.py @@ -5,8 +5,11 @@ ForceDirectedLayoutOptions, HierarchicalLayoutOptions, Layout, + NvlOptions, Packing, + PanPosition, Renderer, + WidgetOptions, ) from .relationship import Relationship from .visualization_graph import VisualizationGraph @@ -15,6 +18,9 @@ __all__ = [ "VisualizationGraph", "GraphWidget", + "WidgetOptions", + "NvlOptions", + "PanPosition", "Node", "Relationship", "CaptionAlignment", diff --git a/python-wrapper/src/neo4j_viz/nvl.py b/python-wrapper/src/neo4j_viz/nvl.py index ffabec8..a31dd30 100644 --- a/python-wrapper/src/neo4j_viz/nvl.py +++ b/python-wrapper/src/neo4j_viz/nvl.py @@ -46,7 +46,7 @@ def render( "width": width, "height": height, "theme": theme, - "options": render_options.to_js_options(), + "options": render_options.to_widget_options().to_json(), } data_json = json.dumps(data_dict) container_id = f"neo4j-viz-{uuid.uuid4().hex[:12]}" diff --git a/python-wrapper/src/neo4j_viz/options.py b/python-wrapper/src/neo4j_viz/options.py index ad36bbf..8969c84 100644 --- a/python-wrapper/src/neo4j_viz/options.py +++ b/python-wrapper/src/neo4j_viz/options.py @@ -2,10 +2,11 @@ import warnings from enum import Enum -from typing import Any, Optional, TypedDict, Union +from typing import Any, Optional, Union import enum_tools.documentation from pydantic import BaseModel, Field, ValidationError, model_validator +from pydantic.alias_generators import to_camel @enum_tools.documentation.document_enum @@ -144,39 +145,54 @@ def check(self, renderer: Renderer, num_nodes: int) -> None: } -class PanPosition(TypedDict): - """The ``{x, y}`` pan position consumed by the frontend.""" +class PanPosition(BaseModel): + """The ``{x, y}`` pan position.""" x: float y: float -class NvlOptionsDict(TypedDict, total=False): - """The subset of NVL instance options set from Python, nested under ``nvlOptions``. - - The frontend's ``nvlOptions`` is a ``Partial`` with many more fields; this only - types the keys the Python wrapper writes. Other keys round-trip through unchanged at runtime. - """ - - disableWebGL: bool - minZoom: float - maxZoom: float - allowDynamicMinZoom: bool - - -class RenderOptionsDict(TypedDict, total=False): - """The JS-shaped render options consumed by the ``GraphWidget`` frontend. - - This mirrors the ``GraphOptions`` type in ``js-applet/src/graph-widget.tsx`` and is the - structure stored in :attr:`GraphWidget.options`. - """ - - layout: str - layoutOptions: dict[str, Any] - nvlOptions: NvlOptionsDict - zoom: float - pan: PanPosition - showLayoutButton: bool +# Fields are snake_case in Python; pydantic serializes them to the camelCase keys the +# frontend's Partial expects (and accepts either casing on input). The frontend +# has many more fields, so extra="allow" lets other keys round-trip unchanged. +class NvlOptions( + BaseModel, + extra="allow", + alias_generator=to_camel, + populate_by_name=True, + serialize_by_alias=True, +): + """The subset of NVL instance options set from Python, nested under ``nvl_options``.""" + + # ``to_camel("disable_web_gl")`` would yield ``disableWebGl``; NVL expects ``disableWebGL``. + disable_web_gl: Optional[bool] = Field(None, alias="disableWebGL") + min_zoom: Optional[float] = None + max_zoom: Optional[float] = None + allow_dynamic_min_zoom: Optional[bool] = None + + +# Mirrors the GraphOptions type in js-applet/src/graph-widget.tsx and is the structure stored +# in GraphWidget.options. Fields are snake_case in Python; pydantic serializes them to the +# camelCase wire format the frontend expects (and accepts either casing on input). +class WidgetOptions( + BaseModel, + extra="allow", + alias_generator=to_camel, + populate_by_name=True, + serialize_by_alias=True, +): + """The render options consumed by the ``GraphWidget``.""" + + layout: Optional[str] = None + layout_options: Optional[dict[str, Any]] = None + nvl_options: Optional[NvlOptions] = None + zoom: Optional[float] = None + pan: Optional[PanPosition] = None + show_layout_button: Optional[bool] = None + + def to_json(self) -> dict[str, Any]: + """Serialize to the camelCase dict the frontend consumes, dropping unset fields.""" + return self.model_dump(exclude_none=True) class RenderOptions(BaseModel, extra="allow"): @@ -213,53 +229,46 @@ def check_layout_options_match(self) -> RenderOptions: raise ValueError("layout_options must be of type ForceDirectedLayoutOptions for force-directed layout") return self - def to_js_options(self) -> RenderOptionsDict: - """Convert render options to the JS-compatible format for the GraphVisualization component. - - Returns a dict with keys that map to React component props and NVL options: - - ``layout``: NVL layout name (e.g. ``"d3Force"``, ``"hierarchical"``) - - ``nvlOptions``: dict of NVL instance options (``minZoom``, ``maxZoom``, etc.) - - ``zoom``: initial zoom level - - ``pan``: ``{x, y}`` pan position - - ``layoutOptions``: layout-specific options - """ - result: RenderOptionsDict = {} + def to_widget_options(self) -> WidgetOptions: + result = WidgetOptions() if self.layout is not None: match self.layout: case Layout.FORCE_DIRECTED: - result["layout"] = "d3Force" + result.layout = "d3Force" case Layout.HIERARCHICAL: - result["layout"] = "hierarchical" + result.layout = "hierarchical" case Layout.COORDINATE: - result["layout"] = "free" + result.layout = "free" case Layout.GRID: - result["layout"] = "grid" + result.layout = "grid" case Layout.CIRCULAR: - result["layout"] = "circular" + result.layout = "circular" if self.layout_options is not None: - result["layoutOptions"] = self.layout_options.model_dump(exclude_none=True) + result.layout_options = self.layout_options.model_dump(exclude_none=True) - nvl_options: NvlOptionsDict = {} + nvl_options = NvlOptions() if self.renderer is not None: - nvl_options["disableWebGL"] = self.renderer != Renderer.WEB_GL + nvl_options.disable_web_gl = self.renderer != Renderer.WEB_GL if self.min_zoom is not None: - nvl_options["minZoom"] = self.min_zoom + nvl_options.min_zoom = self.min_zoom if self.max_zoom is not None: - nvl_options["maxZoom"] = self.max_zoom + nvl_options.max_zoom = self.max_zoom if self.allow_dynamic_min_zoom is not None: - nvl_options["allowDynamicMinZoom"] = self.allow_dynamic_min_zoom - if nvl_options: - result["nvlOptions"] = nvl_options + nvl_options.allow_dynamic_min_zoom = self.allow_dynamic_min_zoom + + # check if any nvl options are set + if nvl_options.model_dump(exclude_none=True): + result.nvl_options = nvl_options if self.initial_zoom is not None: - result["zoom"] = self.initial_zoom + result.zoom = self.initial_zoom if self.pan_X is not None or self.pan_Y is not None: - result["pan"] = {"x": self.pan_X or 0, "y": self.pan_Y or 0} + result.pan = PanPosition(x=self.pan_X or 0, y=self.pan_Y or 0) - result["showLayoutButton"] = self.show_layout_button + result.show_layout_button = self.show_layout_button return result diff --git a/python-wrapper/src/neo4j_viz/widget.py b/python-wrapper/src/neo4j_viz/widget.py index 0abfa7c..c201f16 100644 --- a/python-wrapper/src/neo4j_viz/widget.py +++ b/python-wrapper/src/neo4j_viz/widget.py @@ -3,7 +3,7 @@ import json import pathlib from functools import cached_property -from typing import Any, Union, cast +from typing import Any, Union import anywidget import traitlets @@ -15,10 +15,11 @@ from .options import ( Layout, LayoutOptions, - NvlOptionsDict, + NvlOptions, + PanPosition, Renderer, RenderOptions, - RenderOptionsDict, + WidgetOptions, construct_layout_options, ) from .relationship import Relationship, RelationshipIdType @@ -75,11 +76,24 @@ class GraphWidget(anywidget.AnyWidget): relationships: traitlets.List[Relationship] = traitlets.List([]).tag(sync=True, to_json=entity_to_json) width: traitlets.Unicode[str, str | bytes] = traitlets.Unicode("100%").tag(sync=True) height: traitlets.Unicode[str, str | bytes] = traitlets.Unicode("600px").tag(sync=True) - options: traitlets.Dict[str, Any] = traitlets.Dict({}).tag(sync=True) + options: traitlets.Any = traitlets.Any().tag( + sync=True, + to_json=lambda value, widget: value.to_json(), + from_json=lambda value, widget: WidgetOptions.model_validate(value), + ) theme: traitlets.Unicode[str, str | bytes] = traitlets.Unicode( default_value="auto", help="Theme of the graph widget. Can be 'auto', 'light', or 'dark'." ).tag(sync=True) + @traitlets.default("options") + def _default_options(self) -> WidgetOptions: + return WidgetOptions() + + @traitlets.validate("options") + def _coerce_options(self, proposal: dict[str, Any]) -> WidgetOptions: + value = proposal["value"] + return value if isinstance(value, WidgetOptions) else WidgetOptions.model_validate(value) + @classmethod def from_graph_data( cls, @@ -96,7 +110,7 @@ def from_graph_data( relationships=relationships, width=width, height=height, - options=options.to_js_options() if options else {}, + options=options.to_widget_options() if options else WidgetOptions(), theme=theme, ) @@ -353,9 +367,14 @@ def color_relationships( field=field, property=property, colors=colors, color_space=color_space, override=override ) - def _render_options(self) -> RenderOptionsDict: - """Return a typed, mutable copy of the current JS-shaped render options.""" - return cast(RenderOptionsDict, dict(self.options)) + def _render_options(self) -> WidgetOptions: + """Return a mutable copy of the current JS-shaped render options. + + Mutating and then reassigning the returned model to :attr:`options` triggers the + traitlets change notification that syncs the new options to the frontend. + """ + current: WidgetOptions = self.options + return current.model_copy(deep=True) def set_layout(self, layout: Layout | str, layout_options: dict[str, Any] | LayoutOptions | None = None) -> None: """ @@ -376,15 +395,12 @@ def set_layout(self, layout: Layout | str, layout_options: dict[str, Any] | Layo if isinstance(layout_options, dict): layout_options = construct_layout_options(layout, layout_options) - js = RenderOptions(layout=layout, layout_options=layout_options).to_js_options() + js = RenderOptions(layout=layout, layout_options=layout_options).to_widget_options() new = self._render_options() - new["layout"] = js["layout"] - if "layoutOptions" in js: - new["layoutOptions"] = js["layoutOptions"] - else: - new.pop("layoutOptions", None) - self.options = dict(new) + new.layout = js.layout + new.layout_options = js.layout_options + self.options = new def set_zoom(self, zoom: float) -> None: """ @@ -396,8 +412,8 @@ def set_zoom(self, zoom: float) -> None: The zoom level to apply. """ new = self._render_options() - new["zoom"] = zoom - self.options = dict(new) + new.zoom = zoom + self.options = new def set_pan(self, x: float, y: float) -> None: """ @@ -411,8 +427,8 @@ def set_pan(self, x: float, y: float) -> None: The pan position along the y-axis. """ new = self._render_options() - new["pan"] = {"x": x, "y": y} - self.options = dict(new) + new.pan = PanPosition(x=x, y=y) + self.options = new def set_renderer(self, renderer: Renderer) -> None: """ @@ -426,10 +442,10 @@ def set_renderer(self, renderer: Renderer) -> None: Renderer.check(renderer, len(self.nodes)) new = self._render_options() - nvl_options = cast(NvlOptionsDict, dict(new.get("nvlOptions", {}))) - nvl_options["disableWebGL"] = renderer != Renderer.WEB_GL - new["nvlOptions"] = nvl_options - self.options = dict(new) + nvl_options = new.nvl_options or NvlOptions() + nvl_options.disable_web_gl = renderer != Renderer.WEB_GL + new.nvl_options = nvl_options + self.options = new def set_show_layout_button(self, show: bool = True) -> None: """ @@ -441,8 +457,8 @@ def set_show_layout_button(self, show: bool = True) -> None: Whether the layout button should be shown. """ new = self._render_options() - new["showLayoutButton"] = show - self.options = dict(new) + new.show_layout_button = show + self.options = new def add_data( self, nodes: Node | list[Node] | None = None, relationships: Relationship | list[Relationship] | None = None diff --git a/python-wrapper/tests/test_options.py b/python-wrapper/tests/test_options.py index 3dee679..48f76d2 100644 --- a/python-wrapper/tests/test_options.py +++ b/python-wrapper/tests/test_options.py @@ -8,82 +8,82 @@ ) -def test_js_options_empty() -> None: +def test_widget_options_empty() -> None: options = RenderOptions() - assert options.to_js_options() == {"showLayoutButton": False} + assert options.to_widget_options().to_json() == {"showLayoutButton": False} -def test_js_options_layout_force_directed() -> None: +def test_widget_options_layout_force_directed() -> None: options = RenderOptions(layout=Layout.FORCE_DIRECTED) - js = options.to_js_options() - assert js["layout"] == "d3Force" + widget_options = options.to_widget_options().to_json() + assert widget_options["layout"] == "d3Force" -def test_js_options_layout_hierarchical() -> None: +def test_widget_options_layout_hierarchical() -> None: options = RenderOptions(layout=Layout.HIERARCHICAL) - js = options.to_js_options() - assert js["layout"] == "hierarchical" + widget_options = options.to_widget_options().to_json() + assert widget_options["layout"] == "hierarchical" -def test_js_options_layout_coordinate() -> None: +def test_widget_options_layout_coordinate() -> None: options = RenderOptions(layout=Layout.COORDINATE) - js = options.to_js_options() + js = options.to_widget_options().to_json() assert js["layout"] == "free" -def test_js_options_renderer_canvas() -> None: +def test_widget_options_renderer_canvas() -> None: options = RenderOptions(renderer=Renderer.CANVAS) - js = options.to_js_options() + js = options.to_widget_options().to_json() assert js["nvlOptions"]["disableWebGL"] is True -def test_js_options_renderer_webgl() -> None: +def test_widget_options_renderer_webgl() -> None: options = RenderOptions(renderer=Renderer.WEB_GL) - js = options.to_js_options() + js = options.to_widget_options().to_json() assert js["nvlOptions"]["disableWebGL"] is False -def test_js_options_zoom_and_pan() -> None: +def test_widget_options_zoom_and_pan() -> None: options = RenderOptions(initial_zoom=2.0, pan_X=100.0, pan_Y=200.0) - js = options.to_js_options() + js = options.to_widget_options().to_json() assert js["zoom"] == 2.0 assert js["pan"] == {"x": 100.0, "y": 200.0} -def test_js_options_min_max_zoom() -> None: +def test_widget_options_min_max_zoom() -> None: options = RenderOptions(min_zoom=0.1, max_zoom=5.0) - js = options.to_js_options() + js = options.to_widget_options().to_json() assert js["nvlOptions"]["minZoom"] == 0.1 assert js["nvlOptions"]["maxZoom"] == 5.0 -def test_js_options_allow_dynamic_min_zoom() -> None: +def test_widget_options_allow_dynamic_min_zoom() -> None: options = RenderOptions(allow_dynamic_min_zoom=False) - js = options.to_js_options() + js = options.to_widget_options().to_json() assert js["nvlOptions"]["allowDynamicMinZoom"] is False -def test_js_options_with_layout_options() -> None: +def test_widget_options_with_layout_options() -> None: options = RenderOptions( layout=Layout.HIERARCHICAL, layout_options=HierarchicalLayoutOptions(direction=Direction.LEFT), ) - js = options.to_js_options() + js = options.to_widget_options().to_json() assert js["layout"] == "hierarchical" assert js["layoutOptions"] == {"direction": "left"} -def test_js_options_with_force_directed_layout_options() -> None: +def test_widget_options_with_force_directed_layout_options() -> None: options = RenderOptions( layout=Layout.FORCE_DIRECTED, layout_options=ForceDirectedLayoutOptions(gravity=0.5), ) - js = options.to_js_options() - assert js["layout"] == "d3Force" - assert js["layoutOptions"] == {"gravity": 0.5} + widget_options = options.to_widget_options().to_json() + assert widget_options["layout"] == "d3Force" + assert widget_options["layoutOptions"] == {"gravity": 0.5} -def test_js_options_full() -> None: +def test_widget_options_full() -> None: options = RenderOptions( layout=Layout.HIERARCHICAL, layout_options=HierarchicalLayoutOptions(direction=Direction.DOWN), @@ -96,7 +96,7 @@ def test_js_options_full() -> None: pan_Y=-30.0, show_layout_button=True, ) - js = options.to_js_options() + js = options.to_widget_options().to_json() assert js == { "layout": "hierarchical", "layoutOptions": {"direction": "down"}, diff --git a/python-wrapper/tests/test_widget.py b/python-wrapper/tests/test_widget.py index 83bc26c..e66f071 100644 --- a/python-wrapper/tests/test_widget.py +++ b/python-wrapper/tests/test_widget.py @@ -4,7 +4,7 @@ import pytest from neo4j_viz import GraphWidget, Node, Relationship, VisualizationGraph -from neo4j_viz.options import Layout, Renderer, RenderOptions +from neo4j_viz.options import Layout, Renderer, RenderOptions, WidgetOptions from neo4j_viz.widget import _serialize_entity @@ -67,7 +67,7 @@ def test_from_graph_data_basic(self) -> None: assert len(widget.relationships) == 1 assert widget.width == "100%" assert widget.height == "600px" - assert widget.options == {} + assert widget.options == WidgetOptions() def test_from_graph_data_with_options(self) -> None: nodes = [Node(id="n1")] @@ -83,7 +83,7 @@ def test_from_graph_data_with_options(self) -> None: assert widget.width == "800px" assert widget.height == "400px" - assert widget.options == {"layout": "d3Force", "showLayoutButton": False} + assert widget.options == WidgetOptions(layout="d3Force", show_layout_button=False) def test_widget_trait_defaults(self) -> None: widget = GraphWidget() @@ -92,7 +92,7 @@ def test_widget_trait_defaults(self) -> None: assert widget.relationships == [] assert widget.width == "100%" assert widget.height == "600px" - assert widget.options == {} + assert widget.options == WidgetOptions() class TestWidgetDataBinding: @@ -122,9 +122,9 @@ def test_update_relationships(self) -> None: def test_update_options(self) -> None: widget = GraphWidget(options={"layout": "d3Force"}) - widget.options = {"layout": "hierarchical", "zoom": 2.0} - assert widget.options["layout"] == "hierarchical" - assert widget.options["zoom"] == 2.0 + new_options: Any = {"layout": "hierarchical", "zoom": 2.0} + widget.options = new_options + assert widget.options == WidgetOptions(layout="hierarchical", zoom=2.0) def test_update_dimensions(self) -> None: widget = GraphWidget() @@ -361,10 +361,10 @@ def test_render_widget_options_passed_through(self) -> None: max_zoom=5.0, ) - assert widget.options["layout"] == "hierarchical" - assert widget.options["zoom"] == 2.0 - assert widget.options["nvlOptions"]["minZoom"] == 0.1 - assert widget.options["nvlOptions"]["maxZoom"] == 5.0 + assert widget.options.layout == "hierarchical" + assert widget.options.zoom == 2.0 + assert widget.options.nvl_options.min_zoom == 0.1 + assert widget.options.nvl_options.max_zoom == 5.0 class TestRenderOptionSetters: @@ -373,23 +373,23 @@ def test_set_layout(self) -> None: widget.set_layout(Layout.HIERARCHICAL) - assert widget.options["layout"] == "hierarchical" + assert widget.options.layout == "hierarchical" def test_set_layout_with_options(self) -> None: widget = GraphWidget() widget.set_layout(Layout.FORCE_DIRECTED, {"gravity": 0.1}) - assert widget.options["layout"] == "d3Force" - assert widget.options["layoutOptions"] == {"gravity": 0.1} + assert widget.options.layout == "d3Force" + assert widget.options.layout_options == {"gravity": 0.1} def test_set_layout_clears_stale_layout_options(self) -> None: widget = GraphWidget(options={"layoutOptions": {"gravity": 0.1}}) widget.set_layout(Layout.GRID) - assert widget.options["layout"] == "grid" - assert "layoutOptions" not in widget.options + assert widget.options.layout == "grid" + assert widget.options.layout_options is None def test_set_layout_with_mismatched_options_raises(self) -> None: widget = GraphWidget() @@ -402,21 +402,22 @@ def test_set_zoom(self) -> None: widget.set_zoom(2.0) - assert widget.options["zoom"] == 2.0 + assert widget.options.zoom == 2.0 def test_set_pan(self) -> None: widget = GraphWidget() widget.set_pan(100, 50) - assert widget.options["pan"] == {"x": 100, "y": 50} + assert widget.options.pan.x == 100 + assert widget.options.pan.y == 50 def test_set_renderer_canvas(self) -> None: widget = GraphWidget() widget.set_renderer(Renderer.CANVAS) - assert widget.options["nvlOptions"]["disableWebGL"] is True + assert widget.options.nvl_options.disable_web_gl is True def test_set_renderer_webgl(self) -> None: widget = GraphWidget() @@ -424,32 +425,32 @@ def test_set_renderer_webgl(self) -> None: with pytest.warns(UserWarning): widget.set_renderer(Renderer.WEB_GL) - assert widget.options["nvlOptions"]["disableWebGL"] is False + assert widget.options.nvl_options.disable_web_gl is False def test_set_renderer_preserves_other_nvl_options(self) -> None: widget = GraphWidget(options={"nvlOptions": {"minZoom": 0.1}}) widget.set_renderer(Renderer.CANVAS) - assert widget.options["nvlOptions"]["minZoom"] == 0.1 - assert widget.options["nvlOptions"]["disableWebGL"] is True + assert widget.options.nvl_options.min_zoom == 0.1 + assert widget.options.nvl_options.disable_web_gl is True def test_set_show_layout_button(self) -> None: widget = GraphWidget() widget.set_show_layout_button() - assert widget.options["showLayoutButton"] is True + assert widget.options.show_layout_button is True widget.set_show_layout_button(False) - assert widget.options["showLayoutButton"] is False + assert widget.options.show_layout_button is False def test_setter_preserves_unrelated_options(self) -> None: widget = GraphWidget(options={"layout": "hierarchical"}) widget.set_zoom(3.0) - assert widget.options["zoom"] == 3.0 - assert widget.options["layout"] == "hierarchical" + assert widget.options.zoom == 3.0 + assert widget.options.layout == "hierarchical" def test_setter_triggers_sync(self) -> None: widget = GraphWidget() From 7e52bd37ccdc7f79a3e9a6ffbe9858ffd2796b69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Fri, 12 Jun 2026 14:58:56 +0200 Subject: [PATCH 15/23] Use correct env var name for aura project --- .github/workflows/gds-integration-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/gds-integration-tests.yml b/.github/workflows/gds-integration-tests.yml index fb51388..e2b8d17 100644 --- a/.github/workflows/gds-integration-tests.yml +++ b/.github/workflows/gds-integration-tests.yml @@ -39,5 +39,5 @@ jobs: env: AURA_API_CLIENT_ID: 4V1HYCYEeoU4dSxThKnBeLvE2U4hSphx AURA_API_CLIENT_SECRET: ${{ secrets.AURA_API_CLIENT_SECRET }} - AURA_API_TENANT_ID: 3f8df5e7-4800-4d4f-ad1d-2d044dfd587c + AURA_API_PROJECT_ID: 3f8df5e7-4800-4d4f-ad1d-2d044dfd587c run: uv run pytest tests/ --include-neo4j-and-gds From d4fd7fa5d925e2e70117f1a6ad4b9c16c7c49ce2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Fri, 12 Jun 2026 15:25:33 +0200 Subject: [PATCH 16/23] Move extra tests in sub dirs allows for proper typing --- python-wrapper/pyproject.toml | 6 +- python-wrapper/tests/conftest.py | 97 +----------- .../tests/neo4j_and_gds/__init__.py | 0 .../tests/neo4j_and_gds/conftest.py | 110 +++++++++++++ .../tests/{ => neo4j_and_gds}/gds_helper.py | 0 .../tests/{ => neo4j_and_gds}/test_gds.py | 30 ++-- .../tests/{ => neo4j_and_gds}/test_neo4j.py | 0 .../tests/neo4j_and_gds/test_notebooks.py | 17 ++ python-wrapper/tests/notebook_runner.py | 112 ++++++++++++++ python-wrapper/tests/snowflake/__init__.py | 0 .../conftest.py} | 33 +--- .../tests/snowflake/test_notebooks.py | 8 + .../tests/snowflake/test_snowflake.py | 31 ++++ python-wrapper/tests/test_notebooks.py | 145 +----------------- python-wrapper/uv.lock | 76 ++++----- 15 files changed, 338 insertions(+), 327 deletions(-) create mode 100644 python-wrapper/tests/neo4j_and_gds/__init__.py create mode 100644 python-wrapper/tests/neo4j_and_gds/conftest.py rename python-wrapper/tests/{ => neo4j_and_gds}/gds_helper.py (100%) rename python-wrapper/tests/{ => neo4j_and_gds}/test_gds.py (87%) rename python-wrapper/tests/{ => neo4j_and_gds}/test_neo4j.py (100%) create mode 100644 python-wrapper/tests/neo4j_and_gds/test_notebooks.py create mode 100644 python-wrapper/tests/notebook_runner.py create mode 100644 python-wrapper/tests/snowflake/__init__.py rename python-wrapper/tests/{test_snowflake.py => snowflake/conftest.py} (53%) create mode 100644 python-wrapper/tests/snowflake/test_notebooks.py create mode 100644 python-wrapper/tests/snowflake/test_snowflake.py diff --git a/python-wrapper/pyproject.toml b/python-wrapper/pyproject.toml index fd065a4..2f550e3 100644 --- a/python-wrapper/pyproject.toml +++ b/python-wrapper/pyproject.toml @@ -116,8 +116,10 @@ filterwarnings = [ "error", "ignore:Jupyter is migrating its paths to use standard platformdirs:DeprecationWarning", # snowflake vendors an older `requests` whose dependency check rejects the chardet - # version pulled in transitively by the notebook group. Harmless; ignore it. - "ignore:.*doesn't match a supported version:snowflake.connector.vendored.requests.exceptions.RequestsDependencyWarning" + # version (pulled in transitively via `encutils`). Harmless; ignore it. + # Matched by message only: naming the category here would make pytest import + # `snowflake.connector` to resolve it, and that import is exactly what fails. + "ignore:.*doesn't match a supported version" ] [tool.ruff] diff --git a/python-wrapper/tests/conftest.py b/python-wrapper/tests/conftest.py index 543b276..18fe2e2 100644 --- a/python-wrapper/tests/conftest.py +++ b/python-wrapper/tests/conftest.py @@ -1,6 +1,4 @@ -import os -import random -from typing import Any, Generator +from typing import Any import pytest @@ -29,96 +27,3 @@ def pytest_collection_modifyitems(config: Any, items: Any) -> None: for item in items: if "requires_snowflake" in item.keywords: item.add_marker(skip) - - -@pytest.fixture(scope="package") -def aura_db_instance() -> Generator[Any, None, None]: - if os.environ.get("NEO4J_URI", ""): - print(f"Skipping Aura DB setup since NEO4J_URI is set to {os.environ['NEO4J_URI']}") - yield None - return - - if os.environ.get("AURA_API_CLIENT_ID", None) is None: - yield None - return - - from tests.gds_helper import aura_api, create_auradb_instance, wait_for_instance - - api = aura_api() - instance_details = create_auradb_instance(api) - - try: - dbms_connection_info = wait_for_instance(api, instance_details) - - # setting as environment variables to run notebooks with this connection - os.environ["NEO4J_URI"] = dbms_connection_info.get_uri() - assert isinstance(dbms_connection_info.username, str) - os.environ["NEO4J_USERNAME"] = dbms_connection_info.username - assert isinstance(dbms_connection_info.password, str) - os.environ["NEO4J_PASSWORD"] = dbms_connection_info.password - old_instance = os.environ.get("AURA_INSTANCEID", "") - if dbms_connection_info.aura_instance_id: - os.environ["AURA_INSTANCEID"] = dbms_connection_info.aura_instance_id - - yield dbms_connection_info - - # Clear Neo4j_URI after test (rerun should create a new instance) - os.environ["AURA_INSTANCEID"] = old_instance - assert dbms_connection_info.aura_instance_id is not None - finally: - api.delete_instance(instance_details.id) - - -@pytest.fixture(scope="package") -def gds(aura_db_instance: Any) -> Generator[Any, None, None]: - from graphdatascience.session import SessionMemory - - from tests.gds_helper import connect_to_local_gds_session, connect_to_plugin_gds, gds_sessions - - if aura_db_instance: - sessions = gds_sessions() - - gds = sessions.get_or_create( - f"neo4j-viz-ci-{os.environ.get('GITHUB_RUN_ID', random.randint(0, 10**6))}", - memory=SessionMemory.m_2GB, - db_connection=aura_db_instance, - ) - - yield gds - gds.delete() - else: - neo4j_uri = os.environ["NEO4J_URI"] - neo4j_auth = (os.environ.get("NEO4J_USERNAME", "neo4j"), os.environ.get("NEO4J_PASSWORD", "password")) - - session_uri = os.environ.get("GDS_SESSION_URI") - if session_uri: - gds = connect_to_local_gds_session(session_uri, neo4j_uri, neo4j_auth) - else: - gds = connect_to_plugin_gds(neo4j_uri, neo4j_auth) # type: ignore - yield gds - gds.close() - - -@pytest.fixture(scope="package") -def neo4j_driver(aura_db_instance: Any) -> Generator[Any, None, None]: - import neo4j - - if aura_db_instance: - driver = neo4j.GraphDatabase.driver( - aura_db_instance.uri, auth=(aura_db_instance.username, aura_db_instance.password) - ) - else: - NEO4J_URI = os.environ.get("NEO4J_URI", "neo4j://localhost:7687") - driver = neo4j.GraphDatabase.driver(NEO4J_URI) - - try: - driver.verify_connectivity() - yield driver - finally: - driver.close() - - -@pytest.fixture(scope="package") -def neo4j_session(neo4j_driver: Any) -> Generator[Any, None, None]: - with neo4j_driver.session() as session: - yield session diff --git a/python-wrapper/tests/neo4j_and_gds/__init__.py b/python-wrapper/tests/neo4j_and_gds/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python-wrapper/tests/neo4j_and_gds/conftest.py b/python-wrapper/tests/neo4j_and_gds/conftest.py new file mode 100644 index 0000000..ffc454d --- /dev/null +++ b/python-wrapper/tests/neo4j_and_gds/conftest.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import os +import random +from typing import Generator + +import pytest + +pytest.importorskip("graphdatascience") + +import neo4j +from graphdatascience import GraphDataScience +from graphdatascience.session import AuraGraphDataScience, DbmsConnectionInfo, SessionMemory + +from tests.neo4j_and_gds.gds_helper import ( + aura_api, + connect_to_local_gds_session, + connect_to_plugin_gds, + create_auradb_instance, + gds_sessions, + wait_for_instance, +) + + +@pytest.fixture(scope="package") +def aura_db_instance() -> Generator[DbmsConnectionInfo | None, None, None]: + if os.environ.get("NEO4J_URI", ""): + print(f"Skipping Aura DB setup since NEO4J_URI is set to {os.environ['NEO4J_URI']}") + yield None + return + + if os.environ.get("AURA_API_CLIENT_ID", None) is None: + yield None + return + + api = aura_api() + instance_details = create_auradb_instance(api) + + try: + dbms_connection_info = wait_for_instance(api, instance_details) + + # setting as environment variables to run notebooks with this connection + os.environ["NEO4J_URI"] = dbms_connection_info.get_uri() + assert isinstance(dbms_connection_info.username, str) + os.environ["NEO4J_USERNAME"] = dbms_connection_info.username + assert isinstance(dbms_connection_info.password, str) + os.environ["NEO4J_PASSWORD"] = dbms_connection_info.password + old_instance = os.environ.get("AURA_INSTANCEID", "") + if dbms_connection_info.aura_instance_id: + os.environ["AURA_INSTANCEID"] = dbms_connection_info.aura_instance_id + + yield dbms_connection_info + + # Clear Neo4j_URI after test (rerun should create a new instance) + os.environ["AURA_INSTANCEID"] = old_instance + assert dbms_connection_info.aura_instance_id is not None + finally: + api.delete_instance(instance_details.id) + + +@pytest.fixture(scope="package") +def gds(aura_db_instance: DbmsConnectionInfo | None) -> Generator[GraphDataScience | AuraGraphDataScience, None, None]: + if aura_db_instance: + sessions = gds_sessions() + + gds = sessions.get_or_create( + f"neo4j-viz-ci-{os.environ.get('GITHUB_RUN_ID', random.randint(0, 10**6))}", + memory=SessionMemory.m_2GB, + db_connection=aura_db_instance, + ) + + yield gds + gds.delete() + else: + neo4j_uri = os.environ["NEO4J_URI"] + neo4j_auth = (os.environ.get("NEO4J_USERNAME", "neo4j"), os.environ.get("NEO4J_PASSWORD", "password")) + + session_uri = os.environ.get("GDS_SESSION_URI") + if session_uri: + gds = connect_to_local_gds_session(session_uri, neo4j_uri, neo4j_auth) + else: + gds = connect_to_plugin_gds(neo4j_uri, neo4j_auth) # type: ignore + yield gds + gds.close() + + +@pytest.fixture(scope="package") +def neo4j_driver(aura_db_instance: DbmsConnectionInfo | None) -> Generator[neo4j.Driver, None, None]: + if aura_db_instance: + assert aura_db_instance.uri is not None + assert aura_db_instance.username is not None + assert aura_db_instance.password is not None + driver = neo4j.GraphDatabase.driver( + aura_db_instance.uri, auth=(aura_db_instance.username, aura_db_instance.password) + ) + else: + NEO4J_URI = os.environ.get("NEO4J_URI", "neo4j://localhost:7687") + driver = neo4j.GraphDatabase.driver(NEO4J_URI) + + try: + driver.verify_connectivity() + yield driver + finally: + driver.close() + + +@pytest.fixture(scope="package") +def neo4j_session(neo4j_driver: neo4j.Driver) -> Generator[neo4j.Session, None, None]: + with neo4j_driver.session() as session: + yield session diff --git a/python-wrapper/tests/gds_helper.py b/python-wrapper/tests/neo4j_and_gds/gds_helper.py similarity index 100% rename from python-wrapper/tests/gds_helper.py rename to python-wrapper/tests/neo4j_and_gds/gds_helper.py diff --git a/python-wrapper/tests/test_gds.py b/python-wrapper/tests/neo4j_and_gds/test_gds.py similarity index 87% rename from python-wrapper/tests/test_gds.py rename to python-wrapper/tests/neo4j_and_gds/test_gds.py index ef9c4d8..46d9994 100644 --- a/python-wrapper/tests/test_gds.py +++ b/python-wrapper/tests/neo4j_and_gds/test_gds.py @@ -1,14 +1,19 @@ import re -from typing import Any, Generator +from contextlib import AbstractContextManager +from typing import Generator import pandas as pd import pytest +from graphdatascience import GraphDataScience +from graphdatascience.graph.v2 import GraphV2 +from graphdatascience.session import AuraGraphDataScience from neo4j_viz import Node +from neo4j_viz.gds import from_gds @pytest.fixture(scope="class") -def db_setup(gds: Any) -> Generator[None, None, None]: +def db_setup(gds: GraphDataScience | AuraGraphDataScience) -> Generator[None, None, None]: gds.run_cypher( "CREATE " " (a:_CI_A {name:'Alice', height:20, id:42, _id: 1337, caption: 'hello'})" @@ -20,10 +25,7 @@ def db_setup(gds: Any) -> Generator[None, None, None]: gds.run_cypher("MATCH (n:_CI_A|_CI_B) DETACH DELETE n") -def project_graph(gds: Any) -> Any: - from graphdatascience import GraphDataScience - from graphdatascience.session import AuraGraphDataScience - +def project_graph(gds: GraphDataScience | AuraGraphDataScience) -> AbstractContextManager[GraphV2]: if isinstance(gds, GraphDataScience): return gds.v2.graph.project("g2", "*", "*") elif isinstance(gds, AuraGraphDataScience): @@ -33,9 +35,7 @@ def project_graph(gds: Any) -> Any: @pytest.mark.filterwarnings("ignore::DeprecationWarning") @pytest.mark.requires_neo4j_and_gds -def test_from_gds_integration_all_db_properties(gds: Any, db_setup: None) -> None: - from neo4j_viz.gds import from_gds - +def test_from_gds_integration_all_db_properties(gds: GraphDataScience | AuraGraphDataScience, db_setup: None) -> None: with project_graph(gds) as G: VG = from_gds(gds, G, db_node_properties=["name"]) @@ -44,9 +44,7 @@ def test_from_gds_integration_all_db_properties(gds: Any, db_setup: None) -> Non @pytest.mark.requires_neo4j_and_gds -def test_from_gds_integration_all_properties(gds: Any) -> None: - from neo4j_viz.gds import from_gds - +def test_from_gds_integration_all_properties(gds: GraphDataScience | AuraGraphDataScience) -> None: nodes = pd.DataFrame( { "nodeId": [0, 1, 2], @@ -114,9 +112,7 @@ def test_from_gds_integration_all_properties(gds: Any) -> None: @pytest.mark.requires_neo4j_and_gds -def test_from_gds_sample(gds: Any) -> None: - from neo4j_viz.gds import from_gds - +def test_from_gds_sample(gds: GraphDataScience | AuraGraphDataScience) -> None: with gds.v2.graph.generate("hello", node_count=11_000, average_degree=1) as G: with pytest.warns( UserWarning, @@ -136,9 +132,7 @@ def test_from_gds_sample(gds: Any) -> None: @pytest.mark.requires_neo4j_and_gds -def test_from_gds_hetero(gds: Any) -> None: - from neo4j_viz.gds import from_gds - +def test_from_gds_hetero(gds: GraphDataScience | AuraGraphDataScience) -> None: A_nodes = pd.DataFrame( { "nodeId": [0, 1], diff --git a/python-wrapper/tests/test_neo4j.py b/python-wrapper/tests/neo4j_and_gds/test_neo4j.py similarity index 100% rename from python-wrapper/tests/test_neo4j.py rename to python-wrapper/tests/neo4j_and_gds/test_neo4j.py diff --git a/python-wrapper/tests/neo4j_and_gds/test_notebooks.py b/python-wrapper/tests/neo4j_and_gds/test_notebooks.py new file mode 100644 index 0000000..e9d61c3 --- /dev/null +++ b/python-wrapper/tests/neo4j_and_gds/test_notebooks.py @@ -0,0 +1,17 @@ +import os + +import pytest +from dotenv import load_dotenv +from graphdatascience import GraphDataScience +from graphdatascience.session import AuraGraphDataScience + +from tests.notebook_runner import run_notebooks + + +@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", "gds-example.ipynb"]) diff --git a/python-wrapper/tests/notebook_runner.py b/python-wrapper/tests/notebook_runner.py new file mode 100644 index 0000000..d38bd3c --- /dev/null +++ b/python-wrapper/tests/notebook_runner.py @@ -0,0 +1,112 @@ +import pathlib +import signal +import sys +from datetime import datetime +from typing import Any, NamedTuple + +import nbformat +from nbclient.exceptions import CellExecutionError +from nbconvert.preprocessors.execute import ExecutePreprocessor + +TEARDOWN_CELL_TAG = "teardown" + + +class IndexedCell(NamedTuple): + cell: Any + index: int # type: ignore + + +class TeardownExecutePreprocessor(ExecutePreprocessor): + def __init__(self, **kw: Any): + super().__init__(**kw) # type: ignore + + def init_notebook(self, tear_down_cells: list[IndexedCell]) -> None: + self.tear_down_cells = tear_down_cells + self._skip_rest = False + + # run the cell of a notebook + def preprocess_cell(self, cell: Any, resources: Any, index: int) -> None: + if index == 0: + + def handle_signal(sig, frame): # type: ignore + print("Received SIGNAL, running tear down cells") + self.teardown(resources) + sys.exit(1) + + signal.signal(signal.SIGINT, handle_signal) + signal.signal(signal.SIGTERM, handle_signal) + + try: + if not self._skip_rest: + super().preprocess_cell(cell, resources, index) # type: ignore + except CellExecutionError as e: + if self.tear_down_cells: + print(f"Running tear down cells due to error in notebook execution: {e}") + self.teardown(resources) + raise e + + def teardown(self, resources: Any) -> None: + for td_cell, td_idx in self.tear_down_cells: + try: + super().preprocess_cell(td_cell, resources, td_idx) # type: ignore + except CellExecutionError as td_e: + print(f"Error running tear down cell {td_idx}: {td_e}") + + +class TearDownCollector(ExecutePreprocessor): + def __init__(self, **kw: Any): + super().__init__(**kw) # type: ignore + + def init_notebook(self) -> None: + self._tear_down_cells: list[IndexedCell] = [] + + def preprocess_cell(self, cell: Any, resources: Any, index: int) -> None: + if TEARDOWN_CELL_TAG in cell["metadata"].get("tags", []): + self._tear_down_cells.append(IndexedCell(cell, index)) + + def tear_down_cells(self) -> list[IndexedCell]: + return self._tear_down_cells + + +def run_notebooks(notebook_names: list[str]) -> None: + current_dir = pathlib.Path(__file__).parent.resolve() + examples_path = current_dir.parent.parent / "examples" + + notebook_files = [ + f for f in examples_path.iterdir() if f.is_file() and f.suffix == ".ipynb" and f.name in notebook_names + ] + + if not notebook_files: + raise RuntimeError(f"No matching notebooks found in {examples_path}") + + ep = TeardownExecutePreprocessor(kernel_name="python3") + td_collector = TearDownCollector(kernel_name="python3") + exceptions: list[RuntimeError] = [] + + for notebook_filename in notebook_files: + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + print(f"{now}: Executing notebook {notebook_filename}", flush=True) + + with open(notebook_filename) as f: + nb = nbformat.read(f, as_version=4) # type: ignore + + # Collect tear down cells + td_collector.init_notebook() + td_collector.preprocess(nb) + + ep.init_notebook(tear_down_cells=td_collector.tear_down_cells()) + + # run the notebook + try: + ep.preprocess(nb) + print(f"Finished executing notebook {notebook_filename}") + except CellExecutionError as e: + exceptions.append(RuntimeError(f"Error executing notebook {notebook_filename}", e)) + continue + + if exceptions: + for nb_ex in exceptions: + print(nb_ex) + raise RuntimeError(f"{len(exceptions)} Errors occurred while executing notebooks") + else: + print("Finished executing notebooks") diff --git a/python-wrapper/tests/snowflake/__init__.py b/python-wrapper/tests/snowflake/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python-wrapper/tests/test_snowflake.py b/python-wrapper/tests/snowflake/conftest.py similarity index 53% rename from python-wrapper/tests/test_snowflake.py rename to python-wrapper/tests/snowflake/conftest.py index c786b92..3a47bbf 100644 --- a/python-wrapper/tests/test_snowflake.py +++ b/python-wrapper/tests/snowflake/conftest.py @@ -1,10 +1,10 @@ import pytest + +pytest.importorskip("snowflake.snowpark") + from snowflake.snowpark import Session from snowflake.snowpark.types import LongType, StructField, StructType -from neo4j_viz.node import Node -from neo4j_viz.snowflake import from_snowflake - @pytest.fixture def session() -> Session: @@ -43,30 +43,3 @@ def session_with_minimal_graph(session: Session) -> Session: rel_df.write.save_as_table("RELS") return session - - -def test_from_snowflake(session_with_minimal_graph: Session) -> None: - VG = from_snowflake( - session_with_minimal_graph, - { - "nodeTables": ["NODES"], - "relationshipTables": { - "RELS": { - "sourceTable": "NODES", - "targetTable": "NODES", - }, - }, - }, - ) - - assert VG.nodes == [ - Node(id=0, caption="NODES", color="#ffdf81", properties={"SNOWFLAKEID": 6}), - Node(id=1, caption="NODES", color="#ffdf81", properties={"SNOWFLAKEID": 7}), - ] - - assert len(VG.relationships) == 1 - - assert VG.relationships[0].source == 0 - assert VG.relationships[0].target == 1 - assert VG.relationships[0].caption == "RELS" - assert VG.relationships[0].properties == {} diff --git a/python-wrapper/tests/snowflake/test_notebooks.py b/python-wrapper/tests/snowflake/test_notebooks.py new file mode 100644 index 0000000..7e45ccb --- /dev/null +++ b/python-wrapper/tests/snowflake/test_notebooks.py @@ -0,0 +1,8 @@ +import pytest + +from tests.notebook_runner import run_notebooks + + +@pytest.mark.requires_snowflake +def test_snowflake() -> None: + run_notebooks(["snowflake-example.ipynb"]) diff --git a/python-wrapper/tests/snowflake/test_snowflake.py b/python-wrapper/tests/snowflake/test_snowflake.py new file mode 100644 index 0000000..d179bb6 --- /dev/null +++ b/python-wrapper/tests/snowflake/test_snowflake.py @@ -0,0 +1,31 @@ +from snowflake.snowpark import Session + +from neo4j_viz.node import Node +from neo4j_viz.snowflake import from_snowflake + + +def test_from_snowflake(session_with_minimal_graph: Session) -> None: + VG = from_snowflake( + session_with_minimal_graph, + { + "nodeTables": ["NODES"], + "relationshipTables": { + "RELS": { + "sourceTable": "NODES", + "targetTable": "NODES", + }, + }, + }, + ) + + assert VG.nodes == [ + Node(id=0, caption="NODES", color="#ffdf81", properties={"SNOWFLAKEID": 6}), + Node(id=1, caption="NODES", color="#ffdf81", properties={"SNOWFLAKEID": 7}), + ] + + assert len(VG.relationships) == 1 + + assert VG.relationships[0].source == 0 + assert VG.relationships[0].target == 1 + assert VG.relationships[0].caption == "RELS" + assert VG.relationships[0].properties == {} diff --git a/python-wrapper/tests/test_notebooks.py b/python-wrapper/tests/test_notebooks.py index 939a01e..efa9767 100644 --- a/python-wrapper/tests/test_notebooks.py +++ b/python-wrapper/tests/test_notebooks.py @@ -1,146 +1,5 @@ -import os -import pathlib -import signal -import sys -from datetime import datetime -from typing import Any, Callable, NamedTuple - -import nbformat -import pytest -from dotenv import load_dotenv -from nbclient.exceptions import CellExecutionError -from nbconvert.preprocessors.execute import ExecutePreprocessor - -TEARDOWN_CELL_TAG = "teardown" - - -class IndexedCell(NamedTuple): - cell: Any - index: int # type: ignore - - -class TeardownExecutePreprocessor(ExecutePreprocessor): - def __init__(self, **kw: Any): - super().__init__(**kw) # type: ignore - - def init_notebook(self, tear_down_cells: list[IndexedCell]) -> None: - self.tear_down_cells = tear_down_cells - self._skip_rest = False - - # run the cell of a notebook - def preprocess_cell(self, cell: Any, resources: Any, index: int) -> None: - if index == 0: - - def handle_signal(sig, frame): # type: ignore - print("Received SIGNAL, running tear down cells") - self.teardown(resources) - sys.exit(1) - - signal.signal(signal.SIGINT, handle_signal) - signal.signal(signal.SIGTERM, handle_signal) - - try: - if not self._skip_rest: - super().preprocess_cell(cell, resources, index) # type: ignore - except CellExecutionError as e: - if self.tear_down_cells: - print(f"Running tear down cells due to error in notebook execution: {e}") - self.teardown(resources) - raise e - - def teardown(self, resources: Any) -> None: - for td_cell, td_idx in self.tear_down_cells: - try: - super().preprocess_cell(td_cell, resources, td_idx) # type: ignore - except CellExecutionError as td_e: - print(f"Error running tear down cell {td_idx}: {td_e}") - - -class TearDownCollector(ExecutePreprocessor): - def __init__(self, **kw: Any): - super().__init__(**kw) # type: ignore - - def init_notebook(self) -> None: - self._tear_down_cells: list[IndexedCell] = [] - - def preprocess_cell(self, cell: Any, resources: Any, index: int) -> None: - if TEARDOWN_CELL_TAG in cell["metadata"].get("tags", []): - self._tear_down_cells.append(IndexedCell(cell, index)) - - def tear_down_cells(self) -> list[IndexedCell]: - return self._tear_down_cells - - -def run_notebooks(filter_func: Callable[[str], bool]) -> None: - current_dir = pathlib.Path(__file__).parent.resolve() - examples_path = current_dir.parent.parent / "examples" - - notebook_files = [ - f for f in examples_path.iterdir() if f.is_file() and f.suffix == ".ipynb" and filter_func(f.name) - ] - - if not notebook_files: - raise RuntimeError(f"No matching notebooks found in {examples_path}") - - ep = TeardownExecutePreprocessor(kernel_name="python3") - td_collector = TearDownCollector(kernel_name="python3") - exceptions: list[RuntimeError] = [] - - for notebook_filename in notebook_files: - now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - print(f"{now}: Executing notebook {notebook_filename}", flush=True) - - with open(notebook_filename) as f: - nb = nbformat.read(f, as_version=4) # type: ignore - - # Collect tear down cells - td_collector.init_notebook() - td_collector.preprocess(nb) - - ep.init_notebook(tear_down_cells=td_collector.tear_down_cells()) - - # run the notebook - try: - ep.preprocess(nb) - print(f"Finished executing notebook {notebook_filename}") - except CellExecutionError as e: - exceptions.append(RuntimeError(f"Error executing notebook {notebook_filename}", e)) - continue - - if exceptions: - for nb_ex in exceptions: - print(nb_ex) - raise RuntimeError(f"{len(exceptions)} Errors occurred while executing notebooks") - else: - print("Finished executing notebooks") - - -@pytest.mark.requires_neo4j_and_gds -def test_neo4j(gds: Any) -> None: - neo4j_notebooks = ["neo4j-example.ipynb", "gds-example.ipynb"] - - load_dotenv(os.environ.get("ENV_FILE")) - - def filter_func(notebook: str) -> bool: - return notebook in neo4j_notebooks - - run_notebooks(filter_func) - - -@pytest.mark.requires_snowflake -def test_snowflake() -> None: - snowflake_notebooks = ["snowflake-example.ipynb"] - - def filter_func(notebook: str) -> bool: - return notebook in snowflake_notebooks - - run_notebooks(filter_func) +from tests.notebook_runner import run_notebooks def test_simple() -> None: - simple_notebooks = ["getting-started.ipynb"] - - def filter_func(notebook: str) -> bool: - return notebook in simple_notebooks - - run_notebooks(filter_func) + run_notebooks(["getting-started.ipynb"]) diff --git a/python-wrapper/uv.lock b/python-wrapper/uv.lock index 32ee284..0621c27 100644 --- a/python-wrapper/uv.lock +++ b/python-wrapper/uv.lock @@ -2473,15 +2473,15 @@ wheels = [ [[package]] name = "nbsphinx-link" -version = "1.3.1" +version = "1.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nbsphinx" }, { name = "sphinx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e6/d3/aabce2e96a7080b15a19342fa0839573463b9c3a8c57ee8e657a7ce9c176/nbsphinx-link-1.3.1.tar.gz", hash = "sha256:8b5a3f5279ba9e9f81c01309ff5436dfcc14c23f188ec8a04658f871a3860420", size = 16624, upload-time = "2024-09-18T12:20:26.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/1a/e38d8c65034ba12fce5d2579fd42ce8a8be3a766c03c49b24cd525e9a99c/nbsphinx_link-1.4.1.tar.gz", hash = "sha256:566d5640c165627dc3ae8442a9424ae89cc4af9794261737d8b16bb6717597b8", size = 17657, upload-time = "2026-05-29T19:24:13.051Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/8f/cd4bb6849fef05dafc02e38b3c9b9e985d266c3ca7cb485c3160ddeb96f2/nbsphinx_link-1.3.1-py3-none-any.whl", hash = "sha256:2188fc42294a38ba253eedd184dea8750ab035fd94fe63e855ff01f911631769", size = 5230, upload-time = "2024-09-18T12:20:45.982Z" }, + { url = "https://files.pythonhosted.org/packages/03/ad/4f4aa68f8e9566187e9e3ff1677fc2a02364baa3007deb38a1704010b32e/nbsphinx_link-1.4.1-py3-none-any.whl", hash = "sha256:9f5554b844b0f2c4098f5886e331ffea4999e13d13b846235c1bcb4efc9d9b54", size = 5799, upload-time = "2026-05-29T19:24:11.895Z" }, ] [[package]] @@ -2588,14 +2588,14 @@ dev = [ { name = "pytest", specifier = "==9.0.3" }, { name = "pytest-mock", specifier = "==3.15.1" }, { name = "python-dotenv" }, - { name = "ruff", specifier = "==0.15.12" }, - { name = "selenium", specifier = "==4.43.0" }, - { name = "streamlit", specifier = "==1.57.0" }, + { name = "ruff", specifier = "==0.15.16" }, + { name = "selenium", specifier = "==4.44.0" }, + { name = "streamlit", specifier = "==1.58.0" }, ] docs = [ { name = "enum-tools", extras = ["sphinx"] }, { name = "nbsphinx", specifier = "==0.9.8" }, - { name = "nbsphinx-link", specifier = "==1.3.1" }, + { name = "nbsphinx-link", specifier = "==1.4.1" }, { name = "sphinx", specifier = "==8.1.3" }, ] notebook = [ @@ -2608,7 +2608,7 @@ notebook = [ { name = "pykernel", specifier = ">=0.1.6" }, { name = "python-dotenv" }, { name = "requests" }, - { name = "snowflake-snowpark-python", specifier = "==1.50.0" }, + { name = "snowflake-snowpark-python", specifier = "==1.51.1" }, ] [[package]] @@ -3970,27 +3970,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, - { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, - { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, - { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, - { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, - { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, - { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, - { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, - { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, - { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, - { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, - { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, - { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, +version = "0.15.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/5f7ec371001337d8fa61701c186ff8b613ecac1651848c5950f4c4d5f2e9/ruff-0.15.16.tar.gz", hash = "sha256:d05e78d38c78caf020b03789e25106c93017db5a0cb6e2819885018c61343b78", size = 4714267, upload-time = "2026-06-04T16:33:09.974Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/42/53ef1c3953f157956db9bf7861e3bc50b9b887ce93300aa48cdba8336fe6/ruff-0.15.16-py3-none-linux_armv6l.whl", hash = "sha256:6ac3c0b3969cc6cf6b158c4e2f8f682acb58e7d700d8a44b65ecdc72d66ab0b2", size = 10709025, upload-time = "2026-06-04T16:32:51.935Z" }, + { url = "https://files.pythonhosted.org/packages/93/9a/a79159346f19134a956607754e57d8d128f7a4c00f4ad2f7514d224c172c/ruff-0.15.16-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:197c207ed75ffba54a0dec23db4aa939a27a3053073e085e0042433cbdc58e4a", size = 11063550, upload-time = "2026-06-04T16:32:42.24Z" }, + { url = "https://files.pythonhosted.org/packages/bc/72/3ce2ac000a5299ec238e01f51397b3b653c93b077d9b1bfe8715bb895f20/ruff-0.15.16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3a39fec45ab316cc23e7558f23fea4a70403ddb5648ea9a4a3854a16973d0071", size = 10421345, upload-time = "2026-06-04T16:32:37.251Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c2/cc7fad3ec9169373f5b6a18f1917b91080feec40c3f9658334a1d28e2f03/ruff-0.15.16-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba93191d79003116b95128c9d306e045200fdbd0bccb782b110f3cd1d4abc5cf", size = 10757217, upload-time = "2026-06-04T16:32:54.722Z" }, + { url = "https://files.pythonhosted.org/packages/69/d2/3474009eaa0a65b31fa7152a2fad5e2f050c640ceb1e6b02ee6922e94c82/ruff-0.15.16-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6ee4b90520630120ef032aa5cc10db483852dff950e78b1d717e2993a61ac8d", size = 10507035, upload-time = "2026-06-04T16:33:05.343Z" }, + { url = "https://files.pythonhosted.org/packages/ca/81/b7ae6ccbd11f0c8dc3d5d67fc4be9b57ff57ca86ba56152021378e1277f2/ruff-0.15.16-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e4215bc938bc3c8215c1472c1aa437e310fee20cd427335fec9d7e609563628", size = 11255291, upload-time = "2026-06-04T16:32:49.49Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e1/46e526f1a7cc90857ce6ddf25fbb77eb6568651ac38d71b033af07076dd5/ruff-0.15.16-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c8d26be963b090f10e29abc8b3e74a2a321f6fa34e02424e30b5af89350ecbb", size = 12124922, upload-time = "2026-06-04T16:33:07.821Z" }, + { url = "https://files.pythonhosted.org/packages/1a/da/5c791b088b596b24d0deb967fa28ae02ad751a140c0b9ea81c5ab915d6c0/ruff-0.15.16-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f198cf4123602a2280ed46c307bcbafe41758d6fee5b456b6b6058ca1514b3b4", size = 11332186, upload-time = "2026-06-04T16:33:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/72/11/5da87abe20047c8962361473923ebb2f62b595250126aadfad8c20649c1e/ruff-0.15.16-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb27515fa6240fb586ae82b901a59e67d24acff86f2190b433dc542fe0435aeb", size = 11373541, upload-time = "2026-06-04T16:32:47.007Z" }, + { url = "https://files.pythonhosted.org/packages/fe/2a/8554754c23a854ae3fd6b507e36ad61ddb121e298c6d5d617dec94ed0f14/ruff-0.15.16-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a267c46ba1593fc26b8eecbea050b39d40c0b6bb7781ee11c90a02cd10032951", size = 11353014, upload-time = "2026-06-04T16:32:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/62/25/62ea41529ec89f742ea3fed9cb1059c72877ec7cf9b9e99ac9cf3294d1d9/ruff-0.15.16-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:528c68f39a91498a8d50e91ff5985df3d105782bab49cc378e73ac26bff083e8", size = 10737467, upload-time = "2026-06-04T16:32:26.348Z" }, + { url = "https://files.pythonhosted.org/packages/90/17/334d3ad9de4d40f9dd58fdd09e35ce64553bb501e2f19a839e2fb6be14fc/ruff-0.15.16-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7ed55c58950df60589a9a7a5d2f8fa5f54ebd287163be805adfe6ee95a9de123", size = 10521910, upload-time = "2026-06-04T16:32:32.54Z" }, + { url = "https://files.pythonhosted.org/packages/4d/bd/3ac7c6ae77a885c1004b3dda2446ea401768d24f851c14b4ad4b24f6639c/ruff-0.15.16-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d482feaf51512b50f9790ceb417a56a61dd1e9d9bf967662b9ed27c01b34f53a", size = 10979190, upload-time = "2026-06-04T16:32:57.492Z" }, + { url = "https://files.pythonhosted.org/packages/33/d7/609546e6a413c3f216fbf2a50c928f97c80939154f6a0503114094a86191/ruff-0.15.16-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1e15bc8c94513dae2a40cc9ef07c94fdd4ecc9e29dabebeebe170f952322c9e3", size = 11477014, upload-time = "2026-06-04T16:32:44.687Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/f2cd247ad32633a5c36e97141a2c21b11c6279f7957bc2ff360b1e08fddd/ruff-0.15.16-py3-none-win32.whl", hash = "sha256:580378f7bd4aa25f72e74aa54948a9622f142b1e509521dd10902e886681cc1e", size = 10735541, upload-time = "2026-06-04T16:32:30.145Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9e/02e845ef151b1dee585e55c4739f8e1734ae1d9f1221dff65761c162208b/ruff-0.15.16-py3-none-win_amd64.whl", hash = "sha256:408256017284eddf98fff77b29aa4fb30f586042d535b2d9befc6512f400aaec", size = 11843403, upload-time = "2026-06-04T16:32:39.76Z" }, + { url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" }, ] [[package]] @@ -4007,7 +4007,7 @@ wheels = [ [[package]] name = "selenium" -version = "4.43.0" +version = "4.44.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -4017,9 +4017,9 @@ dependencies = [ { name = "urllib3", extra = ["socks"] }, { name = "websocket-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/09/6a/fe950b498a3c570ab538ad1c2b60f18863eecf077a865eea4459f3fa78a9/selenium-4.43.0.tar.gz", hash = "sha256:bada5c08a989f812728a4b5bea884d8e91894e939a441cc3a025201ce718581e", size = 967747, upload-time = "2026-04-10T06:47:03.149Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/4a/6d0a4f4a07e2a91511a51398203ee82bf6ce644a448aaa35c59b44aa9531/selenium-4.44.0.tar.gz", hash = "sha256:b03a831fcfcab9d912b4682f60718c48a04560d6c62f7496c16b7498c9a4427e", size = 993133, upload-time = "2026-05-12T22:48:19.246Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/82/c7/0c55fbb0275fc368676ea50514ce7d7839d799a8b3ff8425f380186c7626/selenium-4.43.0-py3-none-any.whl", hash = "sha256:4f97639055dcfa9eadf8ccf549ba7b0e49c655d4e2bde19b9a44e916b754e769", size = 9573091, upload-time = "2026-04-10T06:47:01.134Z" }, + { url = "https://files.pythonhosted.org/packages/1f/bc/885047e975e996cb317db31c4551caa915aafc6befea990f082c7233adc2/selenium-4.44.0-py3-none-any.whl", hash = "sha256:d01ea3e5ecad8149460a765f7cf5177194c21dcc0173093fc05427c289b1bf24", size = 9654291, upload-time = "2026-05-12T22:48:16.836Z" }, ] [[package]] @@ -4125,7 +4125,7 @@ wheels = [ [[package]] name = "snowflake-snowpark-python" -version = "1.50.0" +version = "1.51.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cloudpickle" }, @@ -4138,9 +4138,9 @@ dependencies = [ { name = "tzlocal" }, { name = "wheel" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/f9/c7e4d0f4dceb6cf1b88ed984948af5471ecc19514a7e519da7276226429f/snowflake_snowpark_python-1.50.0.tar.gz", hash = "sha256:8af823326c2681333bf59ad3d6152b07098b7926165667a7fdcebd5adb53642f", size = 1760444, upload-time = "2026-04-23T20:33:56.522Z" } +sdist = { url = "https://files.pythonhosted.org/packages/79/56/7e746286afe2c92247f7ee00f0408c3f0c281c1ba53e3fd09ce48afdb7be/snowflake_snowpark_python-1.51.1.tar.gz", hash = "sha256:734bf5e89a6193472aa369192b61e082ae36304bbf07b2050228abb887ac2649", size = 1768391, upload-time = "2026-05-28T18:10:44.191Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/11/0fbeb214832e5f9a6c8123e8de3e94610cab9c9a94eb1f011580f717f682/snowflake_snowpark_python-1.50.0-py3-none-any.whl", hash = "sha256:1a0140dba9a4d44910a052110494069fc9142577ef5db67d58abe6996f420a11", size = 1851309, upload-time = "2026-04-23T20:33:54.654Z" }, + { url = "https://files.pythonhosted.org/packages/97/24/e0c396986054d9a6e44fece81a2f4a3cea0e651658b03485c12ee2689fd5/snowflake_snowpark_python-1.51.1-py3-none-any.whl", hash = "sha256:e4435d5201b5864287bcdfd4380e6517436505e3a8cb30cc69126c33fcba2209", size = 1858422, upload-time = "2026-05-28T18:10:42.422Z" }, ] [[package]] @@ -4397,7 +4397,7 @@ wheels = [ [[package]] name = "streamlit" -version = "1.57.0" +version = "1.58.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "altair" }, @@ -4426,9 +4426,9 @@ dependencies = [ { name = "watchdog", marker = "sys_platform != 'darwin'" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5d/f8/b2daf7a5f8ae15527daf94406e771bb6075e958a01c3dde9eba79dc3c9a3/streamlit-1.57.0.tar.gz", hash = "sha256:0b028d305c1a1a757071b2c9504966787602842fc8af6e873795ca58d2b4d12f", size = 8678859, upload-time = "2026-04-28T22:13:32.238Z" } +sdist = { url = "https://files.pythonhosted.org/packages/91/74/20dac6d6200d6ec0e1c230fb8eeb6a1a423645eacb76e8d802adfc246456/streamlit-1.58.0.tar.gz", hash = "sha256:78a22e7085b053af7ce544442bf4b670771e68c509ba1bdaa056ba0708f49c3d", size = 8721149, upload-time = "2026-05-28T18:02:44.606Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/1a/3ca2293d8552bacea3e67e9600d2d1df7df4a325059769ad83d91c279595/streamlit-1.57.0-py3-none-any.whl", hash = "sha256:0d1d41972aeade5637dbb0e7f0eefa5312272f85304923d240a1b1f0475249c8", size = 9194216, upload-time = "2026-04-28T22:13:29.624Z" }, + { url = "https://files.pythonhosted.org/packages/df/84/14c36a92fb24f8e1cea452f53b0744b5da69d52cdd2fe22e71e6fbf765d5/streamlit-1.58.0-py3-none-any.whl", hash = "sha256:4ca8a7afc5bd16a5f176ccf4be1e34e8121cad0240becd127fb58a103ea3178d", size = 9219185, upload-time = "2026-05-28T18:02:41.993Z" }, ] [[package]] From 99f1fa7489882b8c355c8fa2d122f0ce23d1262a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Fri, 12 Jun 2026 15:27:01 +0200 Subject: [PATCH 17/23] Avoid any type in conftest --- python-wrapper/tests/conftest.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/python-wrapper/tests/conftest.py b/python-wrapper/tests/conftest.py index 18fe2e2..70f70bb 100644 --- a/python-wrapper/tests/conftest.py +++ b/python-wrapper/tests/conftest.py @@ -1,9 +1,7 @@ -from typing import Any - import pytest -def pytest_addoption(parser: Any) -> None: +def pytest_addoption(parser: pytest.Parser) -> None: parser.addoption( "--include-neo4j-and-gds", action="store_true", @@ -16,7 +14,7 @@ def pytest_addoption(parser: Any) -> None: ) -def pytest_collection_modifyitems(config: Any, items: Any) -> None: +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: From c0f65f6c4304f687b9a6863ce39bb291ac3ef699 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Fri, 12 Jun 2026 15:44:53 +0200 Subject: [PATCH 18/23] Add support for python 3.14 --- .github/workflows/unit-tests.yml | 2 +- changelog.md | 1 + python-wrapper/pyproject.toml | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 8d7cc82..99e324f 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.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] defaults: run: diff --git a/changelog.md b/changelog.md index cdec7e8..c0902b3 100644 --- a/changelog.md +++ b/changelog.md @@ -11,6 +11,7 @@ ## Improvements +* Support Python 3.14 * Support Aura Graph Analytics * Support `gds.v2` endpoints * Use typed options field in `GraphWidget` diff --git a/python-wrapper/pyproject.toml b/python-wrapper/pyproject.toml index 2f550e3..b67bbcc 100644 --- a/python-wrapper/pyproject.toml +++ b/python-wrapper/pyproject.toml @@ -22,6 +22,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Database :: Front-Ends", "Topic :: Scientific/Engineering", "Topic :: Scientific/Engineering :: Visualization", From 771a5976da280263d808de942fb937f916a46560 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Fri, 12 Jun 2026 15:57:46 +0200 Subject: [PATCH 19/23] Bump mypy and ruff --- python-wrapper/pyproject.toml | 4 +- python-wrapper/uv.lock | 341 +++++++++++++++++++--------------- 2 files changed, 193 insertions(+), 152 deletions(-) diff --git a/python-wrapper/pyproject.toml b/python-wrapper/pyproject.toml index b67bbcc..a8608b3 100644 --- a/python-wrapper/pyproject.toml +++ b/python-wrapper/pyproject.toml @@ -50,8 +50,8 @@ snowflake = ["snowflake-snowpark-python>=1, <2"] [dependency-groups] dev = [ - "ruff==0.15.16", - "mypy==1.20.2", + "ruff==0.15.17", + "mypy==2.1.0", "pytest==9.0.3", "selenium==4.44.0", "ipykernel==7.2.0", diff --git a/python-wrapper/uv.lock b/python-wrapper/uv.lock index 0621c27..7994450 100644 --- a/python-wrapper/uv.lock +++ b/python-wrapper/uv.lock @@ -184,6 +184,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c9/7f/09065fd9e27da0eda08b4d6897f1c13535066174cc023af248fc2a8d5e5a/asn1crypto-1.5.1-py2.py3-none-any.whl", hash = "sha256:db4e40728b728508912cbb3d44f19ce188f218e9eba635821bb4b68564f8fd67", size = 105045, upload-time = "2022-03-15T14:46:51.055Z" }, ] +[[package]] +name = "ast-serialize" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size = 1183520, upload-time = "2026-05-17T17:47:30.831Z" }, + { url = "https://files.pythonhosted.org/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size = 1175779, upload-time = "2026-05-17T17:47:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size = 1233750, upload-time = "2026-05-17T17:47:34.731Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size = 1235942, upload-time = "2026-05-17T17:47:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size = 1442517, upload-time = "2026-05-17T17:47:38.17Z" }, + { url = "https://files.pythonhosted.org/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size = 1254081, upload-time = "2026-05-17T17:47:39.826Z" }, + { url = "https://files.pythonhosted.org/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size = 1259910, upload-time = "2026-05-17T17:47:41.369Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size = 1250678, upload-time = "2026-05-17T17:47:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size = 1301603, upload-time = "2026-05-17T17:47:46.256Z" }, + { url = "https://files.pythonhosted.org/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size = 1410332, upload-time = "2026-05-17T17:47:47.899Z" }, + { url = "https://files.pythonhosted.org/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size = 1509979, upload-time = "2026-05-17T17:47:50.942Z" }, + { url = "https://files.pythonhosted.org/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size = 1505002, upload-time = "2026-05-17T17:47:54.093Z" }, + { url = "https://files.pythonhosted.org/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size = 1456231, upload-time = "2026-05-17T17:47:56.311Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size = 1058668, upload-time = "2026-05-17T17:47:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size = 1101075, upload-time = "2026-05-17T17:48:00.35Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size = 1075347, upload-time = "2026-05-17T17:48:01.753Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" }, + { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" }, + { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" }, + { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" }, + { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" }, + { url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" }, + { url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" }, + { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, +] + [[package]] name = "asttokens" version = "3.0.1" @@ -1761,87 +1801,87 @@ wheels = [ [[package]] name = "librt" -version = "0.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/eb/6b/3d5c13fb3e3c4f43206c8f9dfed13778c2ed4f000bacaa0b7ce3c402a265/librt-0.9.0.tar.gz", hash = "sha256:a0951822531e7aee6e0dfb556b30d5ee36bbe234faf60c20a16c01be3530869d", size = 184368, upload-time = "2026-04-09T16:06:26.173Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/4a/c64265d71b84030174ff3ac2cd16d8b664072afab8c41fccd8e2ee5a6f8d/librt-0.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f8e12706dcb8ff6b3ed57514a19e45c49ad00bcd423e87b2b2e4b5f64578443", size = 67529, upload-time = "2026-04-09T16:04:27.373Z" }, - { url = "https://files.pythonhosted.org/packages/23/b1/30ca0b3a8bdac209a00145c66cf42e5e7da2cc056ffc6ebc5c7b430ddd34/librt-0.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4e3dda8345307fd7306db0ed0cb109a63a2c85ba780eb9dc2d09b2049a931f9c", size = 70248, upload-time = "2026-04-09T16:04:28.758Z" }, - { url = "https://files.pythonhosted.org/packages/fa/fc/c6018dc181478d6ac5aa24a5846b8185101eb90894346db239eb3ea53209/librt-0.9.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:de7dac64e3eb832ffc7b840eb8f52f76420cde1b845be51b2a0f6b870890645e", size = 202184, upload-time = "2026-04-09T16:04:29.893Z" }, - { url = "https://files.pythonhosted.org/packages/bf/58/d69629f002203370ef41ea69ff71c49a2c618aec39b226ff49986ecd8623/librt-0.9.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22a904cbdb678f7cb348c90d543d3c52f581663d687992fee47fd566dcbf5285", size = 212926, upload-time = "2026-04-09T16:04:31.126Z" }, - { url = "https://files.pythonhosted.org/packages/cc/55/01d859f57824e42bd02465c77bec31fa5ef9d8c2bcee702ccf8ef1b9f508/librt-0.9.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:224b9727eb8bc188bc3bcf29d969dba0cd61b01d9bac80c41575520cc4baabb2", size = 225664, upload-time = "2026-04-09T16:04:32.352Z" }, - { url = "https://files.pythonhosted.org/packages/9b/02/32f63ad0ef085a94a70315291efe1151a48b9947af12261882f8445b2a30/librt-0.9.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e94cbc6ad9a6aeea46d775cbb11f361022f778a9cc8cc90af653d3a594b057ce", size = 219534, upload-time = "2026-04-09T16:04:33.667Z" }, - { url = "https://files.pythonhosted.org/packages/6a/5a/9d77111a183c885acf3b3b6e4c00f5b5b07b5817028226499a55f1fedc59/librt-0.9.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7bc30ad339f4e1a01d4917d645e522a0bc0030644d8973f6346397c93ba1503f", size = 227322, upload-time = "2026-04-09T16:04:34.945Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e7/05d700c93063753e12ab230b972002a3f8f3b9c95d8a980c2f646c8b6963/librt-0.9.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:56d65b583cf43b8cf4c8fbe1e1da20fa3076cc32a1149a141507af1062718236", size = 223407, upload-time = "2026-04-09T16:04:36.22Z" }, - { url = "https://files.pythonhosted.org/packages/c0/26/26c3124823c67c987456977c683da9a27cc874befc194ddcead5f9988425/librt-0.9.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0a1be03168b2691ba61927e299b352a6315189199ca18a57b733f86cb3cc8d38", size = 221302, upload-time = "2026-04-09T16:04:37.62Z" }, - { url = "https://files.pythonhosted.org/packages/50/2b/c7cc2be5cf4ff7b017d948a789256288cb33a517687ff1995e72a7eea79f/librt-0.9.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:63c12efcd160e1d14da11af0c46c0217473e1e0d2ae1acbccc83f561ea4c2a7b", size = 243893, upload-time = "2026-04-09T16:04:38.909Z" }, - { url = "https://files.pythonhosted.org/packages/62/d3/da553d37417a337d12660450535d5fd51373caffbedf6962173c87867246/librt-0.9.0-cp310-cp310-win32.whl", hash = "sha256:e9002e98dcb1c0a66723592520decd86238ddcef168b37ff6cfb559200b4b774", size = 55375, upload-time = "2026-04-09T16:04:40.148Z" }, - { url = "https://files.pythonhosted.org/packages/9b/5a/46fa357bab8311b6442a83471591f2f9e5b15ecc1d2121a43725e0c529b8/librt-0.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:9fcb461fbf70654a52a7cc670e606f04449e2374c199b1825f754e16dacfedd8", size = 62581, upload-time = "2026-04-09T16:04:41.452Z" }, - { url = "https://files.pythonhosted.org/packages/e2/1e/2ec7afcebcf3efea593d13aee18bbcfdd3a243043d848ebf385055e9f636/librt-0.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:90904fac73c478f4b83f4ed96c99c8208b75e6f9a8a1910548f69a00f1eaa671", size = 67155, upload-time = "2026-04-09T16:04:42.933Z" }, - { url = "https://files.pythonhosted.org/packages/18/77/72b85afd4435268338ad4ec6231b3da8c77363f212a0227c1ff3b45e4d35/librt-0.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:789fff71757facc0738e8d89e3b84e4f0251c1c975e85e81b152cdaca927cc2d", size = 69916, upload-time = "2026-04-09T16:04:44.042Z" }, - { url = "https://files.pythonhosted.org/packages/27/fb/948ea0204fbe2e78add6d46b48330e58d39897e425560674aee302dca81c/librt-0.9.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1bf465d1e5b0a27713862441f6467b5ab76385f4ecf8f1f3a44f8aa3c695b4b6", size = 199635, upload-time = "2026-04-09T16:04:45.5Z" }, - { url = "https://files.pythonhosted.org/packages/ac/cd/894a29e251b296a27957856804cfd21e93c194aa131de8bb8032021be07e/librt-0.9.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f819e0c6413e259a17a7c0d49f97f405abadd3c2a316a3b46c6440b7dbbedbb1", size = 211051, upload-time = "2026-04-09T16:04:47.016Z" }, - { url = "https://files.pythonhosted.org/packages/18/8f/dcaed0bc084a35f3721ff2d081158db569d2c57ea07d35623ddaca5cfc8e/librt-0.9.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0785c2fb4a81e1aece366aa3e2e039f4a4d7d21aaaded5227d7f3c703427882", size = 224031, upload-time = "2026-04-09T16:04:48.207Z" }, - { url = "https://files.pythonhosted.org/packages/03/44/88f6c1ed1132cd418601cc041fbd92fed28b3a09f39de81978e0822d13ff/librt-0.9.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80b25c7b570a86c03b5da69e665809deb39265476e8e21d96a9328f9762f9990", size = 218069, upload-time = "2026-04-09T16:04:50.025Z" }, - { url = "https://files.pythonhosted.org/packages/a3/90/7d02e981c2db12188d82b4410ff3e35bfdb844b26aecd02233626f46af2b/librt-0.9.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d4d16b608a1c43d7e33142099a75cd93af482dadce0bf82421e91cad077157f4", size = 224857, upload-time = "2026-04-09T16:04:51.684Z" }, - { url = "https://files.pythonhosted.org/packages/ef/c3/c77e706b7215ca32e928d47535cf13dbc3d25f096f84ddf8fbc06693e229/librt-0.9.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:194fc1a32e1e21fe809d38b5faea66cc65eaa00217c8901fbdb99866938adbdb", size = 219865, upload-time = "2026-04-09T16:04:52.949Z" }, - { url = "https://files.pythonhosted.org/packages/52/d1/32b0c1a0eb8461c70c11656c46a29f760b7c7edf3c36d6f102470c17170f/librt-0.9.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8c6bc1384d9738781cfd41d09ad7f6e8af13cfea2c75ece6bd6d2566cdea2076", size = 218451, upload-time = "2026-04-09T16:04:54.174Z" }, - { url = "https://files.pythonhosted.org/packages/74/d1/adfd0f9c44761b1d49b1bec66173389834c33ee2bd3c7fd2e2367f1942d4/librt-0.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:15cb151e52a044f06e54ac7f7b47adbfc89b5c8e2b63e1175a9d587c43e8942a", size = 241300, upload-time = "2026-04-09T16:04:55.452Z" }, - { url = "https://files.pythonhosted.org/packages/09/b0/9074b64407712f0003c27f5b1d7655d1438979155f049720e8a1abd9b1a1/librt-0.9.0-cp311-cp311-win32.whl", hash = "sha256:f100bfe2acf8a3689af9d0cc660d89f17286c9c795f9f18f7b62dd1a6b247ae6", size = 55668, upload-time = "2026-04-09T16:04:56.689Z" }, - { url = "https://files.pythonhosted.org/packages/24/19/40b77b77ce80b9389fb03971431b09b6b913911c38d412059e0b3e2a9ef2/librt-0.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:0b73e4266307e51c95e09c0750b7ec383c561d2e97d58e473f6f6a209952fbb8", size = 62976, upload-time = "2026-04-09T16:04:57.733Z" }, - { url = "https://files.pythonhosted.org/packages/70/9d/9fa7a64041e29035cb8c575af5f0e3840be1b97b4c4d9061e0713f171849/librt-0.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:bc5518873822d2faa8ebdd2c1a4d7c8ef47b01a058495ab7924cb65bdbf5fc9a", size = 53502, upload-time = "2026-04-09T16:04:58.806Z" }, - { url = "https://files.pythonhosted.org/packages/bf/90/89ddba8e1c20b0922783cd93ed8e64f34dc05ab59c38a9c7e313632e20ff/librt-0.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b3e3bc363f71bda1639a4ee593cb78f7fbfeacc73411ec0d4c92f00730010a4", size = 68332, upload-time = "2026-04-09T16:05:00.09Z" }, - { url = "https://files.pythonhosted.org/packages/a8/40/7aa4da1fb08bdeeb540cb07bfc8207cb32c5c41642f2594dbd0098a0662d/librt-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a09c2f5869649101738653a9b7ab70cf045a1105ac66cbb8f4055e61df78f2d", size = 70581, upload-time = "2026-04-09T16:05:01.213Z" }, - { url = "https://files.pythonhosted.org/packages/48/ac/73a2187e1031041e93b7e3a25aae37aa6f13b838c550f7e0f06f66766212/librt-0.9.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ca8e133d799c948db2ab1afc081c333a825b5540475164726dcbf73537e5c2f", size = 203984, upload-time = "2026-04-09T16:05:02.542Z" }, - { url = "https://files.pythonhosted.org/packages/5e/3d/23460d571e9cbddb405b017681df04c142fb1b04cbfce77c54b08e28b108/librt-0.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:603138ee838ee1583f1b960b62d5d0007845c5c423feb68e44648b1359014e27", size = 215762, upload-time = "2026-04-09T16:05:04.127Z" }, - { url = "https://files.pythonhosted.org/packages/de/1e/42dc7f8ab63e65b20640d058e63e97fd3e482c1edbda3570d813b4d0b927/librt-0.9.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4003f70c56a5addd6aa0897f200dd59afd3bf7bcd5b3cce46dd21f925743bc2", size = 230288, upload-time = "2026-04-09T16:05:05.883Z" }, - { url = "https://files.pythonhosted.org/packages/dc/08/ca812b6d8259ad9ece703397f8ad5c03af5b5fedfce64279693d3ce4087c/librt-0.9.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78042f6facfd98ecb25e9829c7e37cce23363d9d7c83bc5f72702c5059eb082b", size = 224103, upload-time = "2026-04-09T16:05:07.148Z" }, - { url = "https://files.pythonhosted.org/packages/b6/3f/620490fb2fa66ffd44e7f900254bc110ebec8dac6c1b7514d64662570e6f/librt-0.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a361c9434a64d70a7dbb771d1de302c0cc9f13c0bffe1cf7e642152814b35265", size = 232122, upload-time = "2026-04-09T16:05:08.386Z" }, - { url = "https://files.pythonhosted.org/packages/e9/83/12864700a1b6a8be458cf5d05db209b0d8e94ae281e7ec261dbe616597b4/librt-0.9.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:dd2c7e082b0b92e1baa4da28163a808672485617bc855cc22a2fd06978fa9084", size = 225045, upload-time = "2026-04-09T16:05:09.707Z" }, - { url = "https://files.pythonhosted.org/packages/fd/1b/845d339c29dc7dbc87a2e992a1ba8d28d25d0e0372f9a0a2ecebde298186/librt-0.9.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7e6274fd33fc5b2a14d41c9119629d3ff395849d8bcbc80cf637d9e8d2034da8", size = 227372, upload-time = "2026-04-09T16:05:10.942Z" }, - { url = "https://files.pythonhosted.org/packages/8d/fe/277985610269d926a64c606f761d58d3db67b956dbbf40024921e95e7fcb/librt-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5093043afb226ecfa1400120d1ebd4442b4f99977783e4f4f7248879009b227f", size = 248224, upload-time = "2026-04-09T16:05:12.254Z" }, - { url = "https://files.pythonhosted.org/packages/92/1b/ee486d244b8de6b8b5dbaefabe6bfdd4a72e08f6353edf7d16d27114da8d/librt-0.9.0-cp312-cp312-win32.whl", hash = "sha256:9edcc35d1cae9fd5320171b1a838c7da8a5c968af31e82ecc3dff30b4be0957f", size = 55986, upload-time = "2026-04-09T16:05:13.529Z" }, - { url = "https://files.pythonhosted.org/packages/89/7a/ba1737012308c17dc6d5516143b5dce9a2c7ba3474afd54e11f44a4d1ef3/librt-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc2917258e131ae5f958a4d872e07555b51cb7466a43433218061c74ef33745", size = 63260, upload-time = "2026-04-09T16:05:14.68Z" }, - { url = "https://files.pythonhosted.org/packages/36/e4/01752c113da15127f18f7bf11142f5640038f062407a611c059d0036c6aa/librt-0.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:90e6d5420fc8a300518d4d2288154ff45005e920425c22cbbfe8330f3f754bd9", size = 53694, upload-time = "2026-04-09T16:05:16.095Z" }, - { url = "https://files.pythonhosted.org/packages/5f/d7/1b3e26fffde1452d82f5666164858a81c26ebe808e7ae8c9c88628981540/librt-0.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f29b68cd9714531672db62cc54f6e8ff981900f824d13fa0e00749189e13778e", size = 68367, upload-time = "2026-04-09T16:05:17.243Z" }, - { url = "https://files.pythonhosted.org/packages/a5/5b/c61b043ad2e091fbe1f2d35d14795e545d0b56b03edaa390fa1dcee3d160/librt-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d5c8a5929ac325729f6119802070b561f4db793dffc45e9ac750992a4ed4d22", size = 70595, upload-time = "2026-04-09T16:05:18.471Z" }, - { url = "https://files.pythonhosted.org/packages/a3/22/2448471196d8a73370aa2f23445455dc42712c21404081fcd7a03b9e0749/librt-0.9.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:756775d25ec8345b837ab52effee3ad2f3b2dfd6bbee3e3f029c517bd5d8f05a", size = 204354, upload-time = "2026-04-09T16:05:19.593Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5e/39fc4b153c78cfd2c8a2dcb32700f2d41d2312aa1050513183be4540930d/librt-0.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b8f5d00b49818f4e2b1667db994488b045835e0ac16fe2f924f3871bd2b8ac5", size = 216238, upload-time = "2026-04-09T16:05:20.868Z" }, - { url = "https://files.pythonhosted.org/packages/d7/42/bc2d02d0fa7badfa63aa8d6dcd8793a9f7ef5a94396801684a51ed8d8287/librt-0.9.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c81aef782380f0f13ead670aae01825eb653b44b046aa0e5ebbb79f76ed4aa11", size = 230589, upload-time = "2026-04-09T16:05:22.305Z" }, - { url = "https://files.pythonhosted.org/packages/c8/7b/e2d95cc513866373692aa5edf98080d5602dd07cabfb9e5d2f70df2f25f7/librt-0.9.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66b58fed90a545328e80d575467244de3741e088c1af928f0b489ebec3ef3858", size = 224610, upload-time = "2026-04-09T16:05:23.647Z" }, - { url = "https://files.pythonhosted.org/packages/31/d5/6cec4607e998eaba57564d06a1295c21b0a0c8de76e4e74d699e627bd98c/librt-0.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e78fb7419e07d98c2af4b8567b72b3eaf8cb05caad642e9963465569c8b2d87e", size = 232558, upload-time = "2026-04-09T16:05:25.025Z" }, - { url = "https://files.pythonhosted.org/packages/95/8c/27f1d8d3aaf079d3eb26439bf0b32f1482340c3552e324f7db9dca858671/librt-0.9.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c3786f0f4490a5cd87f1ed6cefae833ad6b1060d52044ce0434a2e85893afd0", size = 225521, upload-time = "2026-04-09T16:05:26.311Z" }, - { url = "https://files.pythonhosted.org/packages/6b/d8/1e0d43b1c329b416017619469b3c3801a25a6a4ef4a1c68332aeaa6f72ca/librt-0.9.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8494cfc61e03542f2d381e71804990b3931175a29b9278fdb4a5459948778dc2", size = 227789, upload-time = "2026-04-09T16:05:27.624Z" }, - { url = "https://files.pythonhosted.org/packages/2c/b4/d3d842e88610fcd4c8eec7067b0c23ef2d7d3bff31496eded6a83b0f99be/librt-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:07cf11f769831186eeac424376e6189f20ace4f7263e2134bdb9757340d84d4d", size = 248616, upload-time = "2026-04-09T16:05:29.181Z" }, - { url = "https://files.pythonhosted.org/packages/ec/28/527df8ad0d1eb6c8bdfa82fc190f1f7c4cca5a1b6d7b36aeabf95b52d74d/librt-0.9.0-cp313-cp313-win32.whl", hash = "sha256:850d6d03177e52700af605fd60db7f37dcb89782049a149674d1a9649c2138fd", size = 56039, upload-time = "2026-04-09T16:05:30.709Z" }, - { url = "https://files.pythonhosted.org/packages/f3/a7/413652ad0d92273ee5e30c000fc494b361171177c83e57c060ecd3c21538/librt-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:a5af136bfba820d592f86c67affcef9b3ff4d4360ac3255e341e964489b48519", size = 63264, upload-time = "2026-04-09T16:05:31.881Z" }, - { url = "https://files.pythonhosted.org/packages/a4/0a/92c244309b774e290ddb15e93363846ae7aa753d9586b8aad511c5e6145b/librt-0.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:4c4d0440a3a8e31d962340c3e1cc3fc9ee7febd34c8d8f770d06adb947779ea5", size = 53728, upload-time = "2026-04-09T16:05:33.31Z" }, - { url = "https://files.pythonhosted.org/packages/cd/c1/184e539543f06ea2912f4b92a5ffaede4f9b392689e3f00acbf8134bee92/librt-0.9.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:3f05d145df35dca5056a8bc3838e940efebd893a54b3e19b2dda39ceaa299bcb", size = 67830, upload-time = "2026-04-09T16:05:34.517Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ad/23399bdcb7afca819acacdef31b37ee59de261bd66b503a7995c03c4b0dc/librt-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1c587494461ebd42229d0f1739f3aa34237dd9980623ecf1be8d3bcba79f4499", size = 70280, upload-time = "2026-04-09T16:05:35.649Z" }, - { url = "https://files.pythonhosted.org/packages/9f/0b/4542dc5a2b8772dbf92cafb9194701230157e73c14b017b6961a23598b03/librt-0.9.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0a2040f801406b93657a70b72fa12311063a319fee72ce98e1524da7200171f", size = 201925, upload-time = "2026-04-09T16:05:36.739Z" }, - { url = "https://files.pythonhosted.org/packages/31/d4/8ee7358b08fd0cfce051ef96695380f09b3c2c11b77c9bfbc367c921cce5/librt-0.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f38bc489037eca88d6ebefc9c4d41a4e07c8e8b4de5188a9e6d290273ad7ebb1", size = 212381, upload-time = "2026-04-09T16:05:38.043Z" }, - { url = "https://files.pythonhosted.org/packages/f2/94/a2025fe442abedf8b038038dab3dba942009ad42b38ea064a1a9e6094241/librt-0.9.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3fd278f5e6bf7c75ccd6d12344eb686cc020712683363b66f46ac79d37c799f", size = 227065, upload-time = "2026-04-09T16:05:39.394Z" }, - { url = "https://files.pythonhosted.org/packages/7c/e9/b9fcf6afa909f957cfbbf918802f9dada1bd5d3c1da43d722fd6a310dc3f/librt-0.9.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fcbdf2a9ca24e87bbebb47f1fe34e531ef06f104f98c9ccfc953a3f3344c567a", size = 221333, upload-time = "2026-04-09T16:05:40.999Z" }, - { url = "https://files.pythonhosted.org/packages/ac/7c/ba54cd6aa6a3c8cd12757a6870e0c79a64b1e6327f5248dcff98423f4d43/librt-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e306d956cfa027fe041585f02a1602c32bfa6bb8ebea4899d373383295a6c62f", size = 229051, upload-time = "2026-04-09T16:05:42.605Z" }, - { url = "https://files.pythonhosted.org/packages/4b/4b/8cfdbad314c8677a0148bf0b70591d6d18587f9884d930276098a235461b/librt-0.9.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:465814ab157986acb9dfa5ccd7df944be5eefc0d08d31ec6e8d88bc71251d845", size = 222492, upload-time = "2026-04-09T16:05:43.842Z" }, - { url = "https://files.pythonhosted.org/packages/1f/d1/2eda69563a1a88706808decdce035e4b32755dbfbb0d05e1a65db9547ed1/librt-0.9.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:703f4ae36d6240bfe24f542bac784c7e4194ec49c3ba5a994d02891649e2d85b", size = 223849, upload-time = "2026-04-09T16:05:45.054Z" }, - { url = "https://files.pythonhosted.org/packages/04/44/b2ed37df6be5b3d42cfe36318e0598e80843d5c6308dd63d0bf4e0ce5028/librt-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3be322a15ee5e70b93b7a59cfd074614f22cc8c9ff18bd27f474e79137ea8d3b", size = 245001, upload-time = "2026-04-09T16:05:46.34Z" }, - { url = "https://files.pythonhosted.org/packages/47/e7/617e412426df89169dd2a9ed0cc8752d5763336252c65dbf945199915119/librt-0.9.0-cp314-cp314-win32.whl", hash = "sha256:b8da9f8035bb417770b1e1610526d87ad4fc58a2804dc4d79c53f6d2cf5a6eb9", size = 51799, upload-time = "2026-04-09T16:05:47.738Z" }, - { url = "https://files.pythonhosted.org/packages/24/ed/c22ca4db0ca3cbc285e4d9206108746beda561a9792289c3c31281d7e9df/librt-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:b8bd70d5d816566a580d193326912f4a76ec2d28a97dc4cd4cc831c0af8e330e", size = 59165, upload-time = "2026-04-09T16:05:49.198Z" }, - { url = "https://files.pythonhosted.org/packages/24/56/875398fafa4cbc8f15b89366fc3287304ddd3314d861f182a4b87595ace0/librt-0.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:fc5758e2b7a56532dc33e3c544d78cbaa9ecf0a0f2a2da2df882c1d6b99a317f", size = 49292, upload-time = "2026-04-09T16:05:50.362Z" }, - { url = "https://files.pythonhosted.org/packages/4c/61/bc448ecbf9b2d69c5cff88fe41496b19ab2a1cbda0065e47d4d0d51c0867/librt-0.9.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f24b90b0e0c8cc9491fb1693ae91fe17cb7963153a1946395acdbdd5818429a4", size = 70175, upload-time = "2026-04-09T16:05:51.564Z" }, - { url = "https://files.pythonhosted.org/packages/60/f2/c47bb71069a73e2f04e70acbd196c1e5cc411578ac99039a224b98920fd4/librt-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fe56e80badb66fdcde06bef81bbaa5bfcf6fbd7aefb86222d9e369c38c6b228", size = 72951, upload-time = "2026-04-09T16:05:52.699Z" }, - { url = "https://files.pythonhosted.org/packages/29/19/0549df59060631732df758e8886d92088da5fdbedb35b80e4643664e8412/librt-0.9.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:527b5b820b47a09e09829051452bb0d1dd2122261254e2a6f674d12f1d793d54", size = 225864, upload-time = "2026-04-09T16:05:53.895Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f8/3b144396d302ac08e50f89e64452c38db84bc7b23f6c60479c5d3abd303c/librt-0.9.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d429bdd4ac0ab17c8e4a8af0ed2a7440b16eba474909ab357131018fe8c7e71", size = 241155, upload-time = "2026-04-09T16:05:55.191Z" }, - { url = "https://files.pythonhosted.org/packages/7a/ce/ee67ec14581de4043e61d05786d2aed6c9b5338816b7859bcf07455c6a9f/librt-0.9.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7202bdcac47d3a708271c4304a474a8605a4a9a4a709e954bf2d3241140aa938", size = 252235, upload-time = "2026-04-09T16:05:56.549Z" }, - { url = "https://files.pythonhosted.org/packages/8a/fa/0ead15daa2b293a54101550b08d4bafe387b7d4a9fc6d2b985602bae69b6/librt-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0d620e74897f8c2613b3c4e2e9c1e422eb46d2ddd07df540784d44117836af3", size = 244963, upload-time = "2026-04-09T16:05:57.858Z" }, - { url = "https://files.pythonhosted.org/packages/29/68/9fbf9a9aa704ba87689e40017e720aced8d9a4d2b46b82451d8142f91ec9/librt-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d69fc39e627908f4c03297d5a88d9284b73f4d90b424461e32e8c2485e21c283", size = 257364, upload-time = "2026-04-09T16:05:59.686Z" }, - { url = "https://files.pythonhosted.org/packages/1a/8d/9d60869f1b6716c762e45f66ed945b1e5dd649f7377684c3b176ae424648/librt-0.9.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:c2640e23d2b7c98796f123ffd95cf2022c7777aa8a4a3b98b36c570d37e85eee", size = 247661, upload-time = "2026-04-09T16:06:00.938Z" }, - { url = "https://files.pythonhosted.org/packages/70/ff/a5c365093962310bfdb4f6af256f191085078ffb529b3f0cbebb5b33ebe2/librt-0.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:451daa98463b7695b0a30aa56bf637831ea559e7b8101ac2ef6382e8eb15e29c", size = 248238, upload-time = "2026-04-09T16:06:02.537Z" }, - { url = "https://files.pythonhosted.org/packages/a0/3c/2d34365177f412c9e19c0a29f969d70f5343f27634b76b765a54d8b27705/librt-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:928bd06eca2c2bbf4349e5b817f837509b0604342e65a502de1d50a7570afd15", size = 269457, upload-time = "2026-04-09T16:06:03.833Z" }, - { url = "https://files.pythonhosted.org/packages/bc/cd/de45b239ea3bdf626f982a00c14bfcf2e12d261c510ba7db62c5969a27cd/librt-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:a9c63e04d003bc0fb6a03b348018b9a3002f98268200e22cc80f146beac5dc40", size = 52453, upload-time = "2026-04-09T16:06:05.229Z" }, - { url = "https://files.pythonhosted.org/packages/7f/f9/bfb32ae428aa75c0c533915622176f0a17d6da7b72b5a3c6363685914f70/librt-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f162af66a2ed3f7d1d161a82ca584efd15acd9c1cff190a373458c32f7d42118", size = 60044, upload-time = "2026-04-09T16:06:06.398Z" }, - { url = "https://files.pythonhosted.org/packages/aa/47/7d70414bcdbb3bc1f458a8d10558f00bbfdb24e5a11740fc8197e12c3255/librt-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a4b25c6c25cac5d0d9d6d6da855195b254e0021e513e0249f0e3b444dc6e0e61", size = 50009, upload-time = "2026-04-09T16:06:07.995Z" }, +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/10/37fd9e9ba96cb0bd742dfb20fc3d082e54bdbec759d7300df927f360ef07/librt-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e94ebfcfa2d5e9926d6c3b9aa4617ffc42a845b4321fb84021b872358c82a0f", size = 141706, upload-time = "2026-05-10T18:15:16.129Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/1b1466f358e4a0b728051f69bc27e67b432c6eaa2e05b88db49d3785ae0d/librt-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ae627397a2f351560440d872d6f7c8dbb4072e57868e7b2fc5b8b430fe489d45", size = 142605, upload-time = "2026-05-10T18:15:18.148Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/ed26dd2f6bc9a0baf48306433e579e8d354d70b2bcb78134ed950a5d0e1e/librt-0.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc329359321b67d24efdf4bc69012b0597001649544db662c001db5a0184794c", size = 476555, upload-time = "2026-05-10T18:15:19.569Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/11891191c0e0a3fd617724e891f6e67a71a7658974a892b9a9a97fdb2977/librt-0.11.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:7e82e642ab0f7608ce2fe53d76ca2280a9ee33a1b06556142c7c6fe80a86fc33", size = 468434, upload-time = "2026-05-10T18:15:20.87Z" }, + { url = "https://files.pythonhosted.org/packages/6f/50/5ec949d7f9ce1a07af903aa3e13abb98b717923bdead6e719b2f824ccc07/librt-0.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88145c15c67731d54283d135b03244028c750cc9edc334a96a4f5950ebdb2884", size = 496918, upload-time = "2026-05-10T18:15:22.616Z" }, + { url = "https://files.pythonhosted.org/packages/ea/c4/177336c7524e34875a38bf668e88b193a6723a4eb4045d07f74df6e1506c/librt-0.11.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d36a51b3d93320b686588e27123f4995804dbf1bce81df78c02fc3c6eea9280", size = 490334, upload-time = "2026-05-10T18:15:24.2Z" }, + { url = "https://files.pythonhosted.org/packages/13/1f/da3112f7569eda3b49f9a2629bae1fe059812b6085df16c885f6454dff49/librt-0.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3ac06a2a8b246327f11e186a53a100a4d5c7ed52346367e5ec751d51586c", size = 511287, upload-time = "2026-05-10T18:15:26.226Z" }, + { url = "https://files.pythonhosted.org/packages/fa/94/03fec301522e172d105581431223be56b27594ff46440ebfbb658a3735d5/librt-0.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:461bbceede621f1ffb8839755f8663e886087ee7af16294cab7fb4d782c62eeb", size = 517202, upload-time = "2026-05-10T18:15:27.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/6e/339f6e5a7b413ce014f1917a756dae630fe59cc99f34153205b1cb540901/librt-0.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0cad8a4d6a8ff03c9b76f9414caccd78e7cfbc8a2e12fa334d8e1d9932753783", size = 497517, upload-time = "2026-05-10T18:15:29.614Z" }, + { url = "https://files.pythonhosted.org/packages/cd/43/acdd5ce317cb46e8253ca9bfbdb8b12e68a24d745949336a7f3d5fb79ba0/librt-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f37aa505b3cf60701562eddb32df74b12a9e380c207fd8b06dd157a943ac7ea0", size = 538878, upload-time = "2026-05-10T18:15:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/29/b5/7a25bb12e3172839f647f196b3e988318b7bb1ca7501732a225c4dce2ec0/librt-0.11.0-cp310-cp310-win32.whl", hash = "sha256:94663a21534637f0e787ec2a2a756022df6e5b7b2335a5cdd7d8e33d68a2af89", size = 100070, upload-time = "2026-05-10T18:15:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0d/ebbcf4d77999c02c937b05d2b90ff4cd4dcc7e9a365ba132329ac1fe7a0f/librt-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:dec7db73758c2b54953fd8b7fe348c45188fe26b39ee18446196edd08453a5d4", size = 117918, upload-time = "2026-05-10T18:15:33.678Z" }, + { url = "https://files.pythonhosted.org/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size = 141092, upload-time = "2026-05-10T18:15:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size = 142035, upload-time = "2026-05-10T18:15:36.242Z" }, + { url = "https://files.pythonhosted.org/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size = 475022, upload-time = "2026-05-10T18:15:37.56Z" }, + { url = "https://files.pythonhosted.org/packages/de/f3/aa81523e45184c6ec23dc7f63263362ec55f80a09d424c012359ecbe7e35/librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b", size = 467273, upload-time = "2026-05-10T18:15:39.182Z" }, + { url = "https://files.pythonhosted.org/packages/6b/6f/59c74b560ca8853834d5501d589c8a2519f4184f273a085ffd0f37a1cc47/librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89", size = 497083, upload-time = "2026-05-10T18:15:40.634Z" }, + { url = "https://files.pythonhosted.org/packages/fe/7b/5aa4d2c9600a719401160bf7055417df0b2a47439b9d88286ce45e56b65f/librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc", size = 489139, upload-time = "2026-05-10T18:15:41.934Z" }, + { url = "https://files.pythonhosted.org/packages/d6/31/9143803d7da6856a69153785768c4936864430eec0fd9461c3ea527d9922/librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5", size = 508442, upload-time = "2026-05-10T18:15:43.206Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5a/bce08184488426bda4ccc2c4964ac048c8f68ae89bd7120082eef4233cfd/librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7", size = 514230, upload-time = "2026-05-10T18:15:44.761Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/bb5e213d254b7505a0e658da199d8ab719086632ce09eef311ab27976523/librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d", size = 494231, upload-time = "2026-05-10T18:15:46.308Z" }, + { url = "https://files.pythonhosted.org/packages/9d/fb/541cdad5b1ab1300398c74c4c9a497b88e5074c21b1244c8f49731d3a284/librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412", size = 537585, upload-time = "2026-05-10T18:15:47.629Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f2/464bb69295c320cb06bddb4f14a4ec67934ee14b2bffb12b19fb7ab287ba/librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d", size = 100509, upload-time = "2026-05-10T18:15:49.157Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e7/a17ee1788f9e4fbf548c19f4afa07c92089b9e24fef6cb2410863781ef4c/librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73", size = 118628, upload-time = "2026-05-10T18:15:50.345Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/6c766214f9f9903bcfcfbef97d807af8d8f5aa3502d247858ab17582d212/librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c", size = 103122, upload-time = "2026-05-10T18:15:52.068Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" }, + { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" }, + { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" }, + { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" }, + { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" }, + { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" }, + { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" }, + { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, + { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, + { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" }, + { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" }, + { url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" }, + { url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" }, + { url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" }, + { url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" }, + { url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" }, + { url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" }, + { url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" }, + { url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" }, + { url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" }, + { url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" }, + { url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" }, + { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, ] [[package]] @@ -2316,60 +2356,61 @@ wheels = [ [[package]] name = "mypy" -version = "1.20.2" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "ast-serialize" }, { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/97/ce2502df2cecf2ef997b6c6527c4a223b92feb9e7b790cdc8dcd683f3a8a/mypy-1.20.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cf5a4db6dca263010e2c7bff081c89383c72d187ba2cf4c44759aac970e2f0c4", size = 14457059, upload-time = "2026-04-21T17:06:14.935Z" }, - { url = "https://files.pythonhosted.org/packages/c9/34/417ee60b822cc80c0f3dc9f495ad7fd8dbb8d8b2cf4baf22d4046d25d01d/mypy-1.20.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7b0e817b518bff7facd7f85ea05b643ad8bdcce684cf29784987b0a7c8e1f997", size = 13346816, upload-time = "2026-04-21T17:10:41.433Z" }, - { url = "https://files.pythonhosted.org/packages/4a/85/e20951978702df58379d0bcc2e8f7ccdca4e78cd7dc66dd3ddbf9b29d517/mypy-1.20.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97d7b9a485b40f8ca425460e89bf1da2814625b2da627c0dcc6aa46c92631d14", size = 13772593, upload-time = "2026-04-21T17:08:11.24Z" }, - { url = "https://files.pythonhosted.org/packages/63/a5/5441a13259ec516c56fd5de0fd96a69a9590ae6c5e5d3e5174aa84b97973/mypy-1.20.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e1c12f6d2db3d78b909b5f77513c11eb7f2dd2782b96a3ab6dffc7d44575c99", size = 14656635, upload-time = "2026-04-21T17:09:54.042Z" }, - { url = "https://files.pythonhosted.org/packages/3b/51/b89c69157c5e1f19fd125a65d991166a26906e7902f026f00feebbcfa2b9/mypy-1.20.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:89dce27e142d25ffbc154c1819383b69f2e9234dc4ed4766f42e0e8cb264ab5c", size = 14943278, upload-time = "2026-04-21T17:09:15.599Z" }, - { url = "https://files.pythonhosted.org/packages/e9/44/6b0eeecfe96d7cce1d71c66b8e03cb304aa70ec11f1955dc1d6b46aca3c3/mypy-1.20.2-cp310-cp310-win_amd64.whl", hash = "sha256:f376e37f9bf2a946872fc5fd1199c99310748e3c26c7a26683f13f8bdb756cbd", size = 10851915, upload-time = "2026-04-21T17:06:03.5Z" }, - { url = "https://files.pythonhosted.org/packages/3c/36/6593dc88545d75fb96416184be5392da5e2a8e8c2802a8597913e16ae25c/mypy-1.20.2-cp310-cp310-win_arm64.whl", hash = "sha256:6e2b469efd811707bc530fd1effef0f5d6eebcb7fe376affae69025da4b979a2", size = 9786676, upload-time = "2026-04-21T17:07:02.035Z" }, - { url = "https://files.pythonhosted.org/packages/1f/4d/9ebeae211caccbdaddde7ed5e31dfcf57faac66be9b11deb1dc6526c8078/mypy-1.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4077797a273e56e8843d001e9dfe4ba10e33323d6ade647ff260e5cd97d9758c", size = 14371307, upload-time = "2026-04-21T17:08:56.442Z" }, - { url = "https://files.pythonhosted.org/packages/95/d7/93473d34b61f04fac1aecc01368485c89c5c4af7a4b9a0cab5d77d04b63f/mypy-1.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cdecf62abcc4292500d7858aeae87a1f8f1150f4c4dd08fb0b336ee79b2a6df3", size = 13258917, upload-time = "2026-04-21T17:05:50.978Z" }, - { url = "https://files.pythonhosted.org/packages/e2/30/3dd903e8bafb7b5f7bf87fcd58f8382086dea2aa19f0a7b357f21f63071b/mypy-1.20.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c566c3a88b6ece59b3d70f65bedef17304f48eb52ff040a6a18214e1917b3254", size = 13700516, upload-time = "2026-04-21T17:11:33.161Z" }, - { url = "https://files.pythonhosted.org/packages/07/05/c61a140aba4c729ac7bc99ae26fc627c78a6e08f5b9dd319244ea71a3d7e/mypy-1.20.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0deb80d062b2479f2c87ae568f89845afc71d11bc41b04179e58165fd9f31e98", size = 14562889, upload-time = "2026-04-21T17:05:27.674Z" }, - { url = "https://files.pythonhosted.org/packages/fd/87/da78243742ffa8a36d98c3010f0d829f93d5da4e6786f1a1a6f2ad616502/mypy-1.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bba9ad231e92a3e424b3e56b65aa17704993425bba97e302c832f9466bb85bac", size = 14803844, upload-time = "2026-04-21T17:10:06.2Z" }, - { url = "https://files.pythonhosted.org/packages/37/52/10a1ddf91b40f843943a3c6db51e2df59c9e237f29d355e95eaab427461f/mypy-1.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:baf593f2765fa3a6b1ef95807dbaa3d25b594f6a52adcc506a6b9cb115e1be67", size = 10846300, upload-time = "2026-04-21T17:12:23.886Z" }, - { url = "https://files.pythonhosted.org/packages/20/02/f9a4415b664c53bd34d6709be59da303abcae986dc4ac847b402edb6fa1e/mypy-1.20.2-cp311-cp311-win_arm64.whl", hash = "sha256:20175a1c0f49863946ec20b7f63255768058ac4f07d2b9ded6a6b46cfb5a9100", size = 9779498, upload-time = "2026-04-21T17:09:23.695Z" }, - { url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" }, - { url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" }, - { url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" }, - { url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" }, - { url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" }, - { url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" }, - { url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" }, - { url = "https://files.pythonhosted.org/packages/5b/c4/b93812d3a192c9bcf5df405bd2f30277cd0e48106a14d1023c7f6ed6e39b/mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026", size = 14524670, upload-time = "2026-04-21T17:10:30.737Z" }, - { url = "https://files.pythonhosted.org/packages/f3/47/42c122501bff18eaf1e8f457f5c017933452d8acdc52918a9f59f6812955/mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943", size = 13336218, upload-time = "2026-04-21T17:08:44.069Z" }, - { url = "https://files.pythonhosted.org/packages/92/8f/75bbc92f41725fbd585fb17b440b1119b576105df1013622983e18640a93/mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517", size = 13724906, upload-time = "2026-04-21T17:08:01.02Z" }, - { url = "https://files.pythonhosted.org/packages/a1/32/4c49da27a606167391ff0c39aa955707a00edc500572e562f7c36c08a71f/mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15", size = 14726046, upload-time = "2026-04-21T17:11:22.354Z" }, - { url = "https://files.pythonhosted.org/packages/7f/fc/4e354a1bd70216359deb0c9c54847ee6b32ef78dfb09f5131ff99b494078/mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee", size = 14955587, upload-time = "2026-04-21T17:12:16.033Z" }, - { url = "https://files.pythonhosted.org/packages/62/b2/c0f2056e9eb8f08c62cafd9715e4584b89132bdc832fcf85d27d07b5f3e5/mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f", size = 10922681, upload-time = "2026-04-21T17:06:35.842Z" }, - { url = "https://files.pythonhosted.org/packages/e5/14/065e333721f05de8ef683d0aa804c23026bcc287446b61cac657b902ccac/mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330", size = 9830560, upload-time = "2026-04-21T17:07:51.023Z" }, - { url = "https://files.pythonhosted.org/packages/ae/d1/b4ec96b0ecc620a4443570c6e95c867903428cfcde4206518eafdd5880c3/mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30", size = 14524561, upload-time = "2026-04-21T17:06:27.325Z" }, - { url = "https://files.pythonhosted.org/packages/3a/63/d2c2ff4fa66bc49477d32dfa26e8a167ba803ea6a69c5efb416036909d30/mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924", size = 13363883, upload-time = "2026-04-21T17:11:11.239Z" }, - { url = "https://files.pythonhosted.org/packages/2a/56/983916806bf4eddeaaa2c9230903c3669c6718552a921154e1c5182c701f/mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb", size = 13742945, upload-time = "2026-04-21T17:08:34.181Z" }, - { url = "https://files.pythonhosted.org/packages/19/65/0cd9285ab010ee8214c83d67c6b49417c40d86ce46f1aa109457b5a9b8d7/mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc", size = 14706163, upload-time = "2026-04-21T17:05:15.51Z" }, - { url = "https://files.pythonhosted.org/packages/94/97/48ff3b297cafcc94d185243a9190836fb1b01c1b0918fff64e941e973cc9/mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558", size = 14938677, upload-time = "2026-04-21T17:05:39.562Z" }, - { url = "https://files.pythonhosted.org/packages/fd/a1/1b4233d255bdd0b38a1f284feeb1c143ca508c19184964e22f8d837ec851/mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8", size = 11089322, upload-time = "2026-04-21T17:06:44.29Z" }, - { url = "https://files.pythonhosted.org/packages/78/c2/ce7ee2ba36aeb954ba50f18fa25d9c1188578654b97d02a66a15b6f09531/mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3", size = 10017775, upload-time = "2026-04-21T17:07:20.732Z" }, - { url = "https://files.pythonhosted.org/packages/4e/a1/9d93a7d0b5859af0ead82b4888b46df6c8797e1bc5e1e262a08518c6d48e/mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609", size = 15549002, upload-time = "2026-04-21T17:08:23.107Z" }, - { url = "https://files.pythonhosted.org/packages/00/d2/09a6a10ee1bf0008f6c144d9676f2ca6a12512151b4e0ad0ff6c4fac5337/mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2", size = 14401942, upload-time = "2026-04-21T17:07:31.837Z" }, - { url = "https://files.pythonhosted.org/packages/57/da/9594b75c3c019e805250bed3583bdf4443ff9e6ef08f97e39ae308cb06f2/mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c", size = 15041649, upload-time = "2026-04-21T17:09:34.653Z" }, - { url = "https://files.pythonhosted.org/packages/97/77/f75a65c278e6e8eba2071f7f5a90481891053ecc39878cc444634d892abe/mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744", size = 15864588, upload-time = "2026-04-21T17:11:44.936Z" }, - { url = "https://files.pythonhosted.org/packages/d7/46/1a4e1c66e96c1a3246ddf5403d122ac9b0a8d2b7e65730b9d6533ba7a6d3/mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6", size = 16093956, upload-time = "2026-04-21T17:10:17.683Z" }, - { url = "https://files.pythonhosted.org/packages/5a/2c/78a8851264dec38cd736ca5b8bc9380674df0dd0be7792f538916157716c/mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec", size = 12568661, upload-time = "2026-04-21T17:11:54.473Z" }, - { url = "https://files.pythonhosted.org/packages/83/01/cd7318aa03493322ce275a0e14f4f52b8896335e4e79d4fb8153a7ad2b77/mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382", size = 10389240, upload-time = "2026-04-21T17:09:42.719Z" }, - { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/71/d351dca3e9b30da2328ee9d445c88b8388072808ebfbc49eb69d30b67749/mypy-2.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:11a6beb180257a805961aea9ec591bbd0bd17f1e18d35b8456d57aee5bedfedc", size = 14778792, upload-time = "2026-05-11T18:36:23.605Z" }, + { url = "https://files.pythonhosted.org/packages/2f/45/7d51594b644c17c0bcf74ed8cd5fc33b324276d708e8506f220b70dab9d9/mypy-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ef78c1d306bbf9a8a12f526c44902c9c28dffd6c52c52bf6a72641ce18d3849", size = 13645739, upload-time = "2026-05-11T18:37:22.752Z" }, + { url = "https://files.pythonhosted.org/packages/65/01/455c31b170e9468265074840bf18863a8482a24103fdaabe4e199392aa5f/mypy-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c209a90853081ff01d01ee895cafe10f7db1474e0d95beaeef0f6c1db9119bbd", size = 14074199, upload-time = "2026-05-11T18:35:09.292Z" }, + { url = "https://files.pythonhosted.org/packages/41/5a/93093f0b29a9e982deafde698f740a2eb2e05886e79ccf0594c7fd5413a3/mypy-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47cebf61abde7c088a4e27718a8b13a81655686b2e9c251f5c0915a802248166", size = 14953128, upload-time = "2026-05-11T18:31:57.678Z" }, + { url = "https://files.pythonhosted.org/packages/7f/2f/a196f5331d96170ad3d28f144d2aba690d4b2911381f68d51e489c7ab82a/mypy-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d57a90ae5e872138a425ec328edbc9b235d1934c4377881a33ec05b341acc9a8", size = 15249378, upload-time = "2026-05-11T18:33:00.101Z" }, + { url = "https://files.pythonhosted.org/packages/54/de/94d321cc12da9f71341ac0c270efbed5c725750c7b4c334d957de9a087d9/mypy-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:aea7f7a8a55b459c34275fc468ada6ca7c173a5e43a68f5dbe588a563d8a06b8", size = 11060994, upload-time = "2026-05-11T18:33:18.848Z" }, + { url = "https://files.pythonhosted.org/packages/e1/62/0c27ca55219a7c764a7fb88c7bb2b7b2f9780ade8bbf16bc8ed8400eef6b/mypy-2.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:c989640253f0d76843e9c6c1bbf4bd48c5e85ada61bde4beb37cb3eca035685e", size = 9976743, upload-time = "2026-05-11T18:31:25.554Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a1/639f3024794a2a15899cb90707fe02e044c4412794c39c5769fd3df2e2ef/mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41", size = 14691685, upload-time = "2026-05-11T18:33:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/3b/08/9a585dea4325f20d8b80dc78623fa50d1fd2173b710f6237afd6ba6ab39b/mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca", size = 13555165, upload-time = "2026-05-11T18:32:16.107Z" }, + { url = "https://files.pythonhosted.org/packages/81/dc/7c42cc9c6cb01e8eb09961f1f738741d3e9c7e9d5c5b30ec69222625cd5f/mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538", size = 13994376, upload-time = "2026-05-11T18:32:39.256Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/285946c33bce716e082c11dfeee9ee196eaf1f5042efb3581a31f9f205e4/mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398", size = 14864618, upload-time = "2026-05-11T18:34:49.765Z" }, + { url = "https://files.pythonhosted.org/packages/2b/83/82397f48af6c27e295d57979ded8490c9829040152cf7571b2f026aeb9a0/mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563", size = 15102063, upload-time = "2026-05-11T18:34:05.855Z" }, + { url = "https://files.pythonhosted.org/packages/40/68/b02dec39057b88eb03dc0aa854732e26e8361f34f9d0e20c7614967d1eba/mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389", size = 11060564, upload-time = "2026-05-11T18:35:36.494Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a8/ea3dcbef31f99b634f2ee23bb0321cbc8c1b388b76a861eb849f13c347dc/mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666", size = 9966983, upload-time = "2026-05-11T18:37:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" }, + { url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" }, + { url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" }, + { url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" }, + { url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" }, + { url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" }, + { url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" }, + { url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" }, + { url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" }, + { url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, ] [[package]] @@ -2582,13 +2623,13 @@ dev = [ { name = "ipykernel", specifier = "==7.2.0" }, { name = "jupyterlab", specifier = ">=4.5.7" }, { name = "matplotlib", specifier = ">=3.9.4" }, - { name = "mypy", specifier = "==1.20.2" }, + { name = "mypy", specifier = "==2.1.0" }, { name = "nbconvert", specifier = "==7.17.1" }, { name = "palettable", specifier = "==3.3.3" }, { name = "pytest", specifier = "==9.0.3" }, { name = "pytest-mock", specifier = "==3.15.1" }, { name = "python-dotenv" }, - { name = "ruff", specifier = "==0.15.16" }, + { name = "ruff", specifier = "==0.15.17" }, { name = "selenium", specifier = "==4.44.0" }, { name = "streamlit", specifier = "==1.58.0" }, ] @@ -3970,27 +4011,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.16" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/5f7ec371001337d8fa61701c186ff8b613ecac1651848c5950f4c4d5f2e9/ruff-0.15.16.tar.gz", hash = "sha256:d05e78d38c78caf020b03789e25106c93017db5a0cb6e2819885018c61343b78", size = 4714267, upload-time = "2026-06-04T16:33:09.974Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/42/53ef1c3953f157956db9bf7861e3bc50b9b887ce93300aa48cdba8336fe6/ruff-0.15.16-py3-none-linux_armv6l.whl", hash = "sha256:6ac3c0b3969cc6cf6b158c4e2f8f682acb58e7d700d8a44b65ecdc72d66ab0b2", size = 10709025, upload-time = "2026-06-04T16:32:51.935Z" }, - { url = "https://files.pythonhosted.org/packages/93/9a/a79159346f19134a956607754e57d8d128f7a4c00f4ad2f7514d224c172c/ruff-0.15.16-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:197c207ed75ffba54a0dec23db4aa939a27a3053073e085e0042433cbdc58e4a", size = 11063550, upload-time = "2026-06-04T16:32:42.24Z" }, - { url = "https://files.pythonhosted.org/packages/bc/72/3ce2ac000a5299ec238e01f51397b3b653c93b077d9b1bfe8715bb895f20/ruff-0.15.16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3a39fec45ab316cc23e7558f23fea4a70403ddb5648ea9a4a3854a16973d0071", size = 10421345, upload-time = "2026-06-04T16:32:37.251Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c2/cc7fad3ec9169373f5b6a18f1917b91080feec40c3f9658334a1d28e2f03/ruff-0.15.16-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba93191d79003116b95128c9d306e045200fdbd0bccb782b110f3cd1d4abc5cf", size = 10757217, upload-time = "2026-06-04T16:32:54.722Z" }, - { url = "https://files.pythonhosted.org/packages/69/d2/3474009eaa0a65b31fa7152a2fad5e2f050c640ceb1e6b02ee6922e94c82/ruff-0.15.16-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6ee4b90520630120ef032aa5cc10db483852dff950e78b1d717e2993a61ac8d", size = 10507035, upload-time = "2026-06-04T16:33:05.343Z" }, - { url = "https://files.pythonhosted.org/packages/ca/81/b7ae6ccbd11f0c8dc3d5d67fc4be9b57ff57ca86ba56152021378e1277f2/ruff-0.15.16-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e4215bc938bc3c8215c1472c1aa437e310fee20cd427335fec9d7e609563628", size = 11255291, upload-time = "2026-06-04T16:32:49.49Z" }, - { url = "https://files.pythonhosted.org/packages/d9/e1/46e526f1a7cc90857ce6ddf25fbb77eb6568651ac38d71b033af07076dd5/ruff-0.15.16-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c8d26be963b090f10e29abc8b3e74a2a321f6fa34e02424e30b5af89350ecbb", size = 12124922, upload-time = "2026-06-04T16:33:07.821Z" }, - { url = "https://files.pythonhosted.org/packages/1a/da/5c791b088b596b24d0deb967fa28ae02ad751a140c0b9ea81c5ab915d6c0/ruff-0.15.16-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f198cf4123602a2280ed46c307bcbafe41758d6fee5b456b6b6058ca1514b3b4", size = 11332186, upload-time = "2026-06-04T16:33:02.971Z" }, - { url = "https://files.pythonhosted.org/packages/72/11/5da87abe20047c8962361473923ebb2f62b595250126aadfad8c20649c1e/ruff-0.15.16-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb27515fa6240fb586ae82b901a59e67d24acff86f2190b433dc542fe0435aeb", size = 11373541, upload-time = "2026-06-04T16:32:47.007Z" }, - { url = "https://files.pythonhosted.org/packages/fe/2a/8554754c23a854ae3fd6b507e36ad61ddb121e298c6d5d617dec94ed0f14/ruff-0.15.16-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a267c46ba1593fc26b8eecbea050b39d40c0b6bb7781ee11c90a02cd10032951", size = 11353014, upload-time = "2026-06-04T16:32:34.795Z" }, - { url = "https://files.pythonhosted.org/packages/62/25/62ea41529ec89f742ea3fed9cb1059c72877ec7cf9b9e99ac9cf3294d1d9/ruff-0.15.16-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:528c68f39a91498a8d50e91ff5985df3d105782bab49cc378e73ac26bff083e8", size = 10737467, upload-time = "2026-06-04T16:32:26.348Z" }, - { url = "https://files.pythonhosted.org/packages/90/17/334d3ad9de4d40f9dd58fdd09e35ce64553bb501e2f19a839e2fb6be14fc/ruff-0.15.16-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7ed55c58950df60589a9a7a5d2f8fa5f54ebd287163be805adfe6ee95a9de123", size = 10521910, upload-time = "2026-06-04T16:32:32.54Z" }, - { url = "https://files.pythonhosted.org/packages/4d/bd/3ac7c6ae77a885c1004b3dda2446ea401768d24f851c14b4ad4b24f6639c/ruff-0.15.16-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d482feaf51512b50f9790ceb417a56a61dd1e9d9bf967662b9ed27c01b34f53a", size = 10979190, upload-time = "2026-06-04T16:32:57.492Z" }, - { url = "https://files.pythonhosted.org/packages/33/d7/609546e6a413c3f216fbf2a50c928f97c80939154f6a0503114094a86191/ruff-0.15.16-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1e15bc8c94513dae2a40cc9ef07c94fdd4ecc9e29dabebeebe170f952322c9e3", size = 11477014, upload-time = "2026-06-04T16:32:44.687Z" }, - { url = "https://files.pythonhosted.org/packages/74/0d/f2cd247ad32633a5c36e97141a2c21b11c6279f7957bc2ff360b1e08fddd/ruff-0.15.16-py3-none-win32.whl", hash = "sha256:580378f7bd4aa25f72e74aa54948a9622f142b1e509521dd10902e886681cc1e", size = 10735541, upload-time = "2026-06-04T16:32:30.145Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9e/02e845ef151b1dee585e55c4739f8e1734ae1d9f1221dff65761c162208b/ruff-0.15.16-py3-none-win_amd64.whl", hash = "sha256:408256017284eddf98fff77b29aa4fb30f586042d535b2d9befc6512f400aaec", size = 11843403, upload-time = "2026-06-04T16:32:39.76Z" }, - { url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" }, +version = "0.15.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346, upload-time = "2026-06-11T17:54:47.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677, upload-time = "2026-06-11T17:54:49.533Z" }, + { url = "https://files.pythonhosted.org/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443, upload-time = "2026-06-11T17:55:00.573Z" }, + { url = "https://files.pythonhosted.org/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458, upload-time = "2026-06-11T17:55:07.52Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483, upload-time = "2026-06-11T17:55:05.501Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497, upload-time = "2026-06-11T17:54:57.733Z" }, + { url = "https://files.pythonhosted.org/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967, upload-time = "2026-06-11T17:54:51.461Z" }, + { url = "https://files.pythonhosted.org/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770, upload-time = "2026-06-11T17:54:45.793Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441, upload-time = "2026-06-11T17:54:32.661Z" }, + { url = "https://files.pythonhosted.org/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614, upload-time = "2026-06-11T17:54:34.698Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450, upload-time = "2026-06-11T17:54:39.042Z" }, + { url = "https://files.pythonhosted.org/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524, upload-time = "2026-06-11T17:55:09.432Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573, upload-time = "2026-06-11T17:54:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818, upload-time = "2026-06-11T17:55:03.124Z" }, + { url = "https://files.pythonhosted.org/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901, upload-time = "2026-06-11T17:54:53.562Z" }, + { url = "https://files.pythonhosted.org/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574, upload-time = "2026-06-11T17:54:55.723Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788, upload-time = "2026-06-11T17:54:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" }, ] [[package]] From 0804dae9cfb414fea16c8a50441f4c3984ef7294 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Fri, 12 Jun 2026 16:13:26 +0200 Subject: [PATCH 20/23] Update python deps --- python-wrapper/pyproject.toml | 7 +++++-- python-wrapper/uv.lock | 33 +++++++++++++++++---------------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/python-wrapper/pyproject.toml b/python-wrapper/pyproject.toml index a8608b3..11cffa3 100644 --- a/python-wrapper/pyproject.toml +++ b/python-wrapper/pyproject.toml @@ -54,7 +54,7 @@ dev = [ "mypy==2.1.0", "pytest==9.0.3", "selenium==4.44.0", - "ipykernel==7.2.0", + "ipykernel==7.3.0", "palettable==3.3.3", "pytest-mock==3.15.1", "nbconvert==7.17.1", @@ -65,6 +65,9 @@ dev = [ "python-dotenv" ] docs = [ + # Sphinx is capped at 8.1.x: 8.2+ requires Python >=3.11 and Sphinx 9 requires >=3.12 + # (the docs group must satisfy requires-python = ">=3.10"), and Sphinx 9 also breaks + # `enum_tools.autoenum` (removed `logger` from `sphinx.ext.autodoc`). "sphinx==8.1.3", "enum-tools[sphinx]", "nbsphinx==0.9.8", @@ -77,7 +80,7 @@ notebook = [ "ipywidgets>=8.0.0", "palettable>=3.3.3", "matplotlib>=3.9.4", - "snowflake-snowpark-python==1.51.1", + "snowflake-snowpark-python==1.52.0", "requests", "marimo", "python-dotenv" diff --git a/python-wrapper/uv.lock b/python-wrapper/uv.lock index 7994450..cce7a5a 100644 --- a/python-wrapper/uv.lock +++ b/python-wrapper/uv.lock @@ -1283,7 +1283,7 @@ wheels = [ [[package]] name = "ipykernel" -version = "7.2.0" +version = "7.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "appnope", marker = "sys_platform == 'darwin'" }, @@ -1294,16 +1294,16 @@ dependencies = [ { name = "jupyter-client" }, { name = "jupyter-core" }, { name = "matplotlib-inline" }, - { name = "nest-asyncio" }, + { name = "nest-asyncio2" }, { name = "packaging" }, { name = "psutil" }, { name = "pyzmq" }, { name = "tornado" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ca/8d/b68b728e2d06b9e0051019640a40a9eb7a88fcd82c2e1b5ce70bef5ff044/ipykernel-7.2.0.tar.gz", hash = "sha256:18ed160b6dee2cbb16e5f3575858bc19d8f1fe6046a9a680c708494ce31d909e", size = 176046, upload-time = "2026-02-06T16:43:27.403Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/c4/e4a38f579de4225a561305666f7541cdabb30075def2aa1ac17bd73c1fb5/ipykernel-7.3.0.tar.gz", hash = "sha256:9acaaaf97d16355166e4085afe9d225bfbdf2b7ef520f9df3be8f2b248275e09", size = 184899, upload-time = "2026-06-10T08:41:25.481Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/82/b9/e73d5d9f405cba7706c539aa8b311b49d4c2f3d698d9c12f815231169c71/ipykernel-7.2.0-py3-none-any.whl", hash = "sha256:3bbd4420d2b3cc105cbdf3756bfc04500b1e52f090a90716851f3916c62e1661", size = 118788, upload-time = "2026-02-06T16:43:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/3d/02/77b271f5dc58bfbc0b577c877b2365d1ffea2afe66a80c13f2312820348c/ipykernel-7.3.0-py3-none-any.whl", hash = "sha256:897eb64da762549ef610698fca5e9675195ec6ac8ec7f19d81ce1ca20c876057", size = 120583, upload-time = "2026-06-10T08:41:23.648Z" }, ] [[package]] @@ -1504,7 +1504,7 @@ wheels = [ [[package]] name = "jupyter-client" -version = "8.8.0" +version = "8.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jupyter-core" }, @@ -1512,10 +1512,11 @@ dependencies = [ { name = "pyzmq" }, { name = "tornado" }, { name = "traitlets" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/05/e4/ba649102a3bc3fbca54e7239fb924fd434c766f855693d86de0b1f2bec81/jupyter_client-8.8.0.tar.gz", hash = "sha256:d556811419a4f2d96c869af34e854e3f059b7cc2d6d01a9cd9c85c267691be3e", size = 348020, upload-time = "2026-01-08T13:55:47.938Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/dc/5512503b088997c2250b8bf18258fba9d9ce5ead641183700960d3c9d342/jupyter_client-8.9.1.tar.gz", hash = "sha256:a58f730dd9e728ba16ba1d62ebccf7ffe1ebbdbce4e95cfae941b7321ae1f4fa", size = 359256, upload-time = "2026-06-09T13:15:01.033Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/0b/ceb7694d864abc0a047649aec263878acb9f792e1fec3e676f22dc9015e3/jupyter_client-8.8.0-py3-none-any.whl", hash = "sha256:f93a5b99c5e23a507b773d3a1136bd6e16c67883ccdbd9a829b0bbdb98cd7d7a", size = 107371, upload-time = "2026-01-08T13:55:45.562Z" }, + { url = "https://files.pythonhosted.org/packages/3f/6f/56d39bf385c5c27988aebaf0c18a2a17e960575740100973511018bd904e/jupyter_client-8.9.1-py3-none-any.whl", hash = "sha256:0b7a295bc46e8751e9adae84781f726c851c1d911bd793edc4a3bde942e3da81", size = 109828, upload-time = "2026-06-09T13:14:58.835Z" }, ] [[package]] @@ -2620,7 +2621,7 @@ provides-extras = ["pandas", "gds", "neo4j", "snowflake"] [package.metadata.requires-dev] dev = [ { name = "anywidget", extras = ["dev"] }, - { name = "ipykernel", specifier = "==7.2.0" }, + { name = "ipykernel", specifier = "==7.3.0" }, { name = "jupyterlab", specifier = ">=4.5.7" }, { name = "matplotlib", specifier = ">=3.9.4" }, { name = "mypy", specifier = "==2.1.0" }, @@ -2649,16 +2650,16 @@ notebook = [ { name = "pykernel", specifier = ">=0.1.6" }, { name = "python-dotenv" }, { name = "requests" }, - { name = "snowflake-snowpark-python", specifier = "==1.51.1" }, + { name = "snowflake-snowpark-python", specifier = "==1.52.0" }, ] [[package]] -name = "nest-asyncio" -version = "1.6.0" +name = "nest-asyncio2" +version = "1.7.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/73/731debf26e27e0a0323d7bda270dc2f634b398e38f040a09da1f4351d0aa/nest_asyncio2-1.7.2.tar.gz", hash = "sha256:1921d70b92cc4612c374928d081552efb59b83d91b2b789d935c665fa01729a8", size = 14743, upload-time = "2026-02-13T00:34:04.386Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3c/3179b85b0e1c3659f0369940200cd6d0fa900e6cefcc7ea0bc6dd0e29ffb/nest_asyncio2-1.7.2-py3-none-any.whl", hash = "sha256:f5dfa702f3f81f6a03857e9a19e2ba578c0946a4ad417b4c50a24d7ba641fe01", size = 7843, upload-time = "2026-02-13T00:34:02.691Z" }, ] [[package]] @@ -4166,7 +4167,7 @@ wheels = [ [[package]] name = "snowflake-snowpark-python" -version = "1.51.1" +version = "1.52.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cloudpickle" }, @@ -4179,9 +4180,9 @@ dependencies = [ { name = "tzlocal" }, { name = "wheel" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/79/56/7e746286afe2c92247f7ee00f0408c3f0c281c1ba53e3fd09ce48afdb7be/snowflake_snowpark_python-1.51.1.tar.gz", hash = "sha256:734bf5e89a6193472aa369192b61e082ae36304bbf07b2050228abb887ac2649", size = 1768391, upload-time = "2026-05-28T18:10:44.191Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/b9/91ea25db74623bf8d13bf7b7c9492739cbe823d000784664a3b1acf908d7/snowflake_snowpark_python-1.52.0.tar.gz", hash = "sha256:c25ffe570f4b858c737312e451b0940044bebbaa2e6b3d2c3fc995e874874d9a", size = 1769229, upload-time = "2026-06-10T20:36:00.47Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/97/24/e0c396986054d9a6e44fece81a2f4a3cea0e651658b03485c12ee2689fd5/snowflake_snowpark_python-1.51.1-py3-none-any.whl", hash = "sha256:e4435d5201b5864287bcdfd4380e6517436505e3a8cb30cc69126c33fcba2209", size = 1858422, upload-time = "2026-05-28T18:10:42.422Z" }, + { url = "https://files.pythonhosted.org/packages/10/a2/1c74f6f16fff42f959e64315f7983fb6fe0452aa37e25a4b98843eda2dfd/snowflake_snowpark_python-1.52.0-py3-none-any.whl", hash = "sha256:2ade8caf623b3c6d5ec35e6974ef5de03ff1ad8b2d3ad7501b4ad2c35f94286a", size = 1858806, upload-time = "2026-06-10T20:35:58.734Z" }, ] [[package]] From dd0890d375e4235f078ee050b7e46a5c974a9efa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Fri, 12 Jun 2026 16:14:26 +0200 Subject: [PATCH 21/23] Remove unused extra from integration test --- .github/workflows/gds-integration-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/gds-integration-tests.yml b/.github/workflows/gds-integration-tests.yml index e2b8d17..53a94b8 100644 --- a/.github/workflows/gds-integration-tests.yml +++ b/.github/workflows/gds-integration-tests.yml @@ -33,7 +33,7 @@ jobs: with: python-version: "3.11" enable-cache: true - - run: uv sync --group dev --extra pandas --extra neo4j --extra gds --extra snowflake + - run: uv sync --group dev --extra pandas --extra neo4j --extra gds - name: Run tests env: From 4bd1517e11ecc148a048b685cd8344aaefba2a48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Fri, 12 Jun 2026 16:56:01 +0200 Subject: [PATCH 22/23] Check for dangling relationships --- changelog.md | 2 + python-wrapper/src/neo4j_viz/_validation.py | 61 ++++++++++ .../src/neo4j_viz/visualization_graph.py | 12 ++ python-wrapper/src/neo4j_viz/widget.py | 12 +- python-wrapper/tests/test_render.py | 40 +++++++ python-wrapper/tests/test_validation.py | 113 ++++++++++++++++++ python-wrapper/tests/test_widget.py | 21 +++- 7 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 python-wrapper/src/neo4j_viz/_validation.py create mode 100644 python-wrapper/tests/test_validation.py diff --git a/changelog.md b/changelog.md index c0902b3..7db9a34 100644 --- a/changelog.md +++ b/changelog.md @@ -9,6 +9,8 @@ ## Bug fixes +* Warn when relationships reference node ids that are not in the graph. It is configurable via the `on_dangling` parameter (`"warn"` (default), `"error"`, or `"none"`) on `render`, `render_widget`, and `GraphWidget.add_data` + ## Improvements * Support Python 3.14 diff --git a/python-wrapper/src/neo4j_viz/_validation.py b/python-wrapper/src/neo4j_viz/_validation.py new file mode 100644 index 0000000..8d8b145 --- /dev/null +++ b/python-wrapper/src/neo4j_viz/_validation.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import warnings +from typing import Literal + +from .node import Node +from .relationship import Relationship + +OnDangling = Literal["error", "warn", "none"] + +# Number of offending relationships to name in the message before truncating. +_MAX_REPORTED = 5 + + +def check_dangling_relationships( + nodes: list[Node], + relationships: list[Relationship], + on_dangling: OnDangling = "warn", +) -> None: + """Check for relationships referencing node ids that are not in ``nodes``. + + The frontend silently renders an empty graph when a relationship's ``source`` or ``target`` + is missing from the nodes, so by default we surface this as a warning. Node and relationship + ids are compared as strings, matching how they are serialized for the frontend (so e.g. + ``Node(id=1)`` and ``Relationship(source="1")`` are considered to match). + + Parameters + ---------- + nodes: + The nodes in the graph. + relationships: + The relationships to check against ``nodes``. + on_dangling: + What to do when a dangling relationship is found: ``"warn"`` (default) emits a warning, + ``"error"`` raises a ``ValueError``, and ``"none"`` skips the check entirely. + """ + if on_dangling == "none": + return + + node_ids = {str(node.id) for node in nodes} + dangling = [rel for rel in relationships if str(rel.source) not in node_ids or str(rel.target) not in node_ids] + if not dangling: + return + + examples = [] + for rel in dangling[:_MAX_REPORTED]: + missing = [str(end) for end in (rel.source, rel.target) if str(end) not in node_ids] + examples.append(f"relationship {rel.id!r} (source={rel.source!r}, target={rel.target!r}) -> missing {missing}") + if len(dangling) > _MAX_REPORTED: + examples.append(f"... and {len(dangling) - _MAX_REPORTED} more") + + message = ( + f"{len(dangling)} relationship(s) reference node ids that are not in the graph, " + "so they will not be drawn:\n " + "\n ".join(examples) + "\n" + "Add the missing nodes, or pass `on_dangling='none'` to silence this. " + "Pass `on_dangling='error'` to raise instead." + ) + + if on_dangling == "error": + raise ValueError(message) + warnings.warn(message) diff --git a/python-wrapper/src/neo4j_viz/visualization_graph.py b/python-wrapper/src/neo4j_viz/visualization_graph.py index 28ba65c..4b98637 100644 --- a/python-wrapper/src/neo4j_viz/visualization_graph.py +++ b/python-wrapper/src/neo4j_viz/visualization_graph.py @@ -6,6 +6,7 @@ from IPython.display import HTML from ._graph_entity_operations import GraphEntityOperations +from ._validation import OnDangling, check_dangling_relationships from .colors import ColorSpace, ColorsType from .node import Node, NodeIdType from .node_size import RealNumber @@ -324,6 +325,7 @@ def _build_render_options( allow_dynamic_min_zoom: bool, max_allowed_nodes: int, show_layout_button: bool, + on_dangling: OnDangling, ) -> RenderOptions: """Shared validation + option building for render / render_widget.""" num_nodes = len(self.nodes) @@ -334,6 +336,8 @@ def _build_render_options( "overriding `max_allowed_nodes`, but rendering could then take a long time" ) + check_dangling_relationships(self.nodes, self.relationships, on_dangling) + if isinstance(renderer, str): renderer = Renderer(renderer) @@ -378,6 +382,7 @@ def render( allow_dynamic_min_zoom: bool = True, max_allowed_nodes: int = 10_000, theme: Literal["auto"] | Literal["light"] | Literal["dark"] = "auto", + on_dangling: OnDangling = "warn", ) -> HTML: """ Render the graph as an HTML object. @@ -411,6 +416,8 @@ def render( The maximum allowed number of nodes to render. theme: The theme of the rendered graph. Can be 'auto', 'light', or 'dark' + on_dangling: + What to do when a relationship references a node id that is not in the graph . One of "warn" (default), "error", or "none". Example ------- @@ -428,6 +435,7 @@ def render( allow_dynamic_min_zoom, max_allowed_nodes, show_layout_button=False, # The button only works with the widget + on_dangling=on_dangling, ) return NVL().render( @@ -453,6 +461,7 @@ def render_widget( allow_dynamic_min_zoom: bool = True, max_allowed_nodes: int = 10_000, theme: Literal["auto"] | Literal["light"] | Literal["dark"] = "auto", + on_dangling: OnDangling = "warn", ) -> GraphWidget: """ Render the graph as an interactive Jupyter widget (anywidget). @@ -486,6 +495,8 @@ def render_widget( The maximum allowed number of nodes to render. theme: The theme to use for the rendered graph. + on_dangling: + What to do when a relationship references a node id that is not in the graph. One of "warn" (default), "error", or "none". """ render_options = self._build_render_options( layout, @@ -498,6 +509,7 @@ def render_widget( allow_dynamic_min_zoom, max_allowed_nodes, show_layout_button=True, + on_dangling=on_dangling, ) return GraphWidget.from_graph_data( diff --git a/python-wrapper/src/neo4j_viz/widget.py b/python-wrapper/src/neo4j_viz/widget.py index c201f16..4a8d9d3 100644 --- a/python-wrapper/src/neo4j_viz/widget.py +++ b/python-wrapper/src/neo4j_viz/widget.py @@ -9,6 +9,7 @@ import traitlets from ._graph_entity_operations import GraphEntityOperations +from ._validation import OnDangling, check_dangling_relationships from .colors import ColorSpace, ColorsType from .node import Node, NodeIdType from .node_size import RealNumber @@ -461,7 +462,10 @@ def set_show_layout_button(self, show: bool = True) -> None: self.options = new def add_data( - self, nodes: Node | list[Node] | None = None, relationships: Relationship | list[Relationship] | None = None + self, + nodes: Node | list[Node] | None = None, + relationships: Relationship | list[Relationship] | None = None, + on_dangling: OnDangling = "warn", ) -> None: """ Add nodes or relationships to the graph widget. @@ -472,6 +476,10 @@ def add_data( Nodes to add to the graph widget. relationships: Relationships to add to the graph widget. + on_dangling: + What to do when a resulting relationship references a node id that is not in the graph + (which the frontend would silently render as empty). One of "warn" (default), "error", + or "none". """ if isinstance(nodes, Node): nodes = [nodes] @@ -483,6 +491,8 @@ def add_data( if relationships: self.relationships = self.relationships + relationships + check_dangling_relationships(self.nodes, self.relationships, on_dangling) + def remove_data( self, nodes: Node | list[Node | NodeIdType] | NodeIdType | None = None, diff --git a/python-wrapper/tests/test_render.py b/python-wrapper/tests/test_render.py index 8b7d85b..abce9e1 100644 --- a/python-wrapper/tests/test_render.py +++ b/python-wrapper/tests/test_render.py @@ -89,6 +89,46 @@ def test_render_warnings() -> None: ): VG.render(max_allowed_nodes=20_000, renderer=Renderer.CANVAS) + +_DANGLING_MATCH = re.escape("reference node ids that are not in the graph") + + +def _dangling_graph() -> VisualizationGraph: + nodes = [Node(id="a"), Node(id="b")] + relationships = [ + Relationship(source="a", target="b"), + Relationship(source="a", target="missing"), # `missing` is not a node + ] + return VisualizationGraph(nodes=nodes, relationships=relationships) + + +def test_dangling_relationship_warns_by_default() -> None: + with pytest.warns(UserWarning, match=_DANGLING_MATCH): + _dangling_graph().render() + + +def test_dangling_relationship_error() -> None: + with pytest.raises(ValueError, match=_DANGLING_MATCH): + _dangling_graph().render(on_dangling="error") + + +def test_dangling_relationship_none_is_silent() -> None: + # `filterwarnings = error` (pyproject) would surface any warning; none should be emitted + _dangling_graph().render(on_dangling="none") + + +def test_dangling_relationship_render_widget_warns() -> None: + with pytest.warns(UserWarning, match=_DANGLING_MATCH): + _dangling_graph().render_widget() + + +def test_no_dangling_with_mixed_id_types() -> None: + # Node(id=1) (int) and Relationship(source="1") (str) must be treated as the same node + nodes = [Node(id=1), Node(id=2)] + relationships = [Relationship(source="1", target=2)] + VG = VisualizationGraph(nodes=nodes, relationships=relationships) + VG.render(on_dangling="error") # must not raise + with pytest.warns( UserWarning, match="Although better for performance, the WebGL renderer cannot render text, icons and arrowheads on " diff --git a/python-wrapper/tests/test_validation.py b/python-wrapper/tests/test_validation.py new file mode 100644 index 0000000..0f1915f --- /dev/null +++ b/python-wrapper/tests/test_validation.py @@ -0,0 +1,113 @@ +import contextlib +import warnings +from collections.abc import Iterator + +import pytest + +from neo4j_viz._validation import check_dangling_relationships +from neo4j_viz.node import Node +from neo4j_viz.relationship import Relationship + + +@contextlib.contextmanager +def assert_no_warning() -> Iterator[None]: + """Turn any emitted warning into an error, so the test fails if one is raised.""" + with warnings.catch_warnings(): + warnings.simplefilter("error") + yield + + +def test_no_dangling_is_silent() -> None: + nodes = [Node(id="a"), Node(id="b")] + rels = [Relationship(id="r1", source="a", target="b")] + with assert_no_warning(): + check_dangling_relationships(nodes, rels) # must not warn or raise + + +def test_reports_only_the_missing_target() -> None: + nodes = [Node(id="a"), Node(id="b")] + rels = [Relationship(id="r1", source="a", target="missing")] + + with pytest.warns(UserWarning) as record: + check_dangling_relationships(nodes, rels) + + msg = str(record[0].message) + assert "1 relationship(s) reference node ids that are not in the graph" in msg + assert "relationship 'r1' (source='a', target='missing') -> missing ['missing']" in msg + # the present endpoint must not be reported as missing + assert "'a'" not in msg.split("missing [", 1)[1] + + +def test_reports_only_the_missing_source() -> None: + nodes = [Node(id="a"), Node(id="b")] + rels = [Relationship(id="r1", source="ghost", target="b")] + + with pytest.warns(UserWarning) as record: + check_dangling_relationships(nodes, rels) + + assert "-> missing ['ghost']" in str(record[0].message) + + +def test_reports_both_missing_endpoints_in_order() -> None: + nodes = [Node(id="a")] + rels = [Relationship(id="r1", source="x", target="y")] + + with pytest.warns(UserWarning) as record: + check_dangling_relationships(nodes, rels) + + # source is reported before target + assert "-> missing ['x', 'y']" in str(record[0].message) + + +def test_mixed_id_types_match() -> None: + # Node(id=1) (int) and Relationship(source="1") (str) refer to the same node -> not dangling + nodes = [Node(id=1), Node(id=2)] + rels = [Relationship(id="r1", source="1", target=2)] + with assert_no_warning(): + check_dangling_relationships(nodes, rels) + + +def test_mixed_id_types_partial_miss() -> None: + nodes = [Node(id=1)] + rels = [Relationship(id="r1", source="1", target="x")] + + with pytest.warns(UserWarning) as record: + check_dangling_relationships(nodes, rels) + + # source "1" matches Node(id=1); only the genuinely missing target is reported + assert "-> missing ['x']" in str(record[0].message) + + +def test_error_mode_reports_missing_ids() -> None: + nodes = [Node(id="a")] + rels = [Relationship(id="r1", source="a", target="b")] + + with pytest.raises(ValueError) as excinfo: + check_dangling_relationships(nodes, rels, on_dangling="error") + + msg = str(excinfo.value) + assert "1 relationship(s) reference node ids that are not in the graph" in msg + assert "relationship 'r1' (source='a', target='b') -> missing ['b']" in msg + + +def test_none_mode_is_silent_even_with_dangling() -> None: + nodes = [Node(id="a")] + rels = [Relationship(id="r1", source="a", target="b")] + with assert_no_warning(): + check_dangling_relationships(nodes, rels, on_dangling="none") # must not warn or raise + + +def test_multiple_dangling_are_all_counted_and_capped() -> None: + nodes = [Node(id="n")] + rels = [Relationship(id=f"r{i}", source="n", target=f"m{i}") for i in range(7)] + + with pytest.warns(UserWarning) as record: + check_dangling_relationships(nodes, rels) + + msg = str(record[0].message) + assert "7 relationship(s) reference node ids that are not in the graph" in msg + # first 5 are listed, the rest summarized + assert "-> missing ['m0']" in msg + assert "-> missing ['m4']" in msg + assert "'m5'" not in msg and "'m6'" not in msg + assert "... and 2 more" in msg diff --git a/python-wrapper/tests/test_widget.py b/python-wrapper/tests/test_widget.py index e66f071..de8caa6 100644 --- a/python-wrapper/tests/test_widget.py +++ b/python-wrapper/tests/test_widget.py @@ -1,4 +1,5 @@ import datetime +import re from typing import Any import pytest @@ -191,9 +192,9 @@ def test_add_data(self) -> None: rels = [Relationship(source="n1", target="n2")] widget = GraphWidget.from_graph_data(nodes, rels) - widget.add_data(Node(id="x1"), Relationship(source="x1", target="x2")) + widget.add_data([Node(id="x1"), Node(id="x2")], Relationship(source="x1", target="x2")) - assert len(widget.nodes) == 3 + assert len(widget.nodes) == 4 assert len(widget.relationships) == 2 def test_remove_data(self) -> None: @@ -212,6 +213,22 @@ def test_remove_data(self) -> None: assert {n.id for n in widget.nodes} == {"n3"} assert {r.id for r in widget.relationships} == {43} + def test_add_data_dangling_warns_by_default(self) -> None: + widget = GraphWidget.from_graph_data([Node(id="n1")], []) + with pytest.warns(UserWarning, match=re.escape("reference node ids that are not in the graph")): + widget.add_data(relationships=Relationship(source="n1", target="missing")) + + def test_add_data_dangling_error(self) -> None: + widget = GraphWidget.from_graph_data([Node(id="n1")], []) + with pytest.raises(ValueError, match=re.escape("reference node ids that are not in the graph")): + widget.add_data(relationships=Relationship(source="n1", target="missing"), on_dangling="error") + + def test_add_data_node_and_relationship_together_ok(self) -> None: + widget = GraphWidget.from_graph_data([Node(id="n1")], []) + # adding the endpoint node together with the relationship must not be flagged + widget.add_data(Node(id="n2"), Relationship(source="n1", target="n2")) + assert len(widget.relationships) == 1 + class TestWidgetUtilityMethods: def _spy_send_state(self, widget: GraphWidget) -> list[Any]: From cab5832597e06ce1dc6780a30004a421f997b034 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Fri, 12 Jun 2026 16:58:26 +0200 Subject: [PATCH 23/23] Document new parameter --- docs/antora/modules/ROOT/pages/rendering.adoc | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/antora/modules/ROOT/pages/rendering.adoc b/docs/antora/modules/ROOT/pages/rendering.adoc index e3ae618..a76ae2c 100644 --- a/docs/antora/modules/ROOT/pages/rendering.adoc +++ b/docs/antora/modules/ROOT/pages/rendering.adoc @@ -38,6 +38,12 @@ It defaults to 10.000, because rendering a large number of nodes can be slow and However, you can increase this value if you are confident that your environment can handle the scale. In this case you might also want to pass `Renderer.WEB_GL` as the `renderer` to improve performance. +Relationships whose `source` or `target` is not part of the graph's nodes ("dangling" relationships) cannot be +drawn, and a graph containing them may appear empty. +By default `render` emits a warning that lists the offending relationships so the problem is not silent. +You can change this with the `on_dangling` parameter: pass `"error"` to raise a `ValueError` instead, or `"none"` +to skip the check entirely. + By default a tooltip showing IDs and properties will be shown when mouse hovering over a node or relationship. But you can disable this by passing `show_hover_tooltip=False`. @@ -60,4 +66,4 @@ html = VG.render(...) with open("my_graph.html", "w") as f: f.write(html.data) ----- \ No newline at end of file +----