diff --git a/.github/workflows/gds-integration-tests.yml b/.github/workflows/gds-integration-tests.yml index 21034a7d..53a94b86 100644 --- a/.github/workflows/gds-integration-tests.yml +++ b/.github/workflows/gds-integration-tests.yml @@ -33,11 +33,11 @@ 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: 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_PROJECT_ID: 3f8df5e7-4800-4d4f-ad1d-2d044dfd587c run: uv run pytest tests/ --include-neo4j-and-gds diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 8d7cc821..99e324f1 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 eb2810e6..7db9a34d 100644 --- a/changelog.md +++ b/changelog.md @@ -1,17 +1,21 @@ -# Changes in 1.4.0 +# Changes in 1.5.0 ## Breaking changes ## 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 -- Fixed a bug in displaying the `Download`, `Selection` and `Layout` buttons, which was introduced in 1.2.0. +* 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 `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 Python 3.14 +* Support Aura Graph Analytics +* Support `gds.v2` endpoints +* Use typed options field in `GraphWidget` ## Other changes diff --git a/docs/antora/modules/ROOT/pages/rendering.adoc b/docs/antora/modules/ROOT/pages/rendering.adoc index e3ae6181..a76ae2cc 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 +---- diff --git a/docs/source/api-reference/widget.rst b/docs/source/api-reference/widget.rst new file mode 100644 index 00000000..8ec6510f --- /dev/null +++ b/docs/source/api-reference/widget.rst @@ -0,0 +1,2 @@ +.. autoclass:: neo4j_viz.GraphWidget + :members: diff --git a/examples/gds-example.ipynb b/examples/gds-example.ipynb index 29b1387c..cd7ab333 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,54 +11,112 @@ { "cell_type": "code", "execution_count": null, + "id": "eab42b4e", "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": "1dde3aae", + "metadata": {}, + "outputs": [], + "source": [ + "import dotenv\n", + "\n", + "dotenv.load_dotenv()" ] }, { "cell_type": "markdown", + "id": "d9daded3", "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": "9ca9a1de", "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": "735411fa", "metadata": {}, "outputs": [], "source": [ "G = gds.graph.load_cora(graph_name=\"cora\")" ] }, + { + "cell_type": "markdown", + "id": "8cbc843a", + "metadata": {}, + "source": [] + }, { "cell_type": "code", "execution_count": null, + "id": "35a65700", "metadata": {}, "outputs": [], "source": [ @@ -71,6 +130,7 @@ }, { "cell_type": "markdown", + "id": "cde09804", "metadata": {}, "source": [ "## Visualization" @@ -79,1648 +139,32 @@ { "cell_type": "code", "execution_count": null, + "id": "cab5ffab", "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": "9f37e34f", + "metadata": {}, + "outputs": [], "source": [ - "VG.render(theme=\"auto\")" + "VG.render()" ] }, { "cell_type": "markdown", + "id": "47f4b968", "metadata": {}, "source": [ "### Changing captions\n", @@ -1732,6 +176,7 @@ { "cell_type": "code", "execution_count": null, + "id": "ceb601eb", "metadata": {}, "outputs": [], "source": [ @@ -1742,9 +187,8 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "tags": [] - }, + "id": "1ad40c84", + "metadata": {}, "outputs": [], "source": [ "VG.render()" @@ -1752,6 +196,7 @@ }, { "cell_type": "markdown", + "id": "a752ad45", "metadata": {}, "source": [ "## Sizing the nodes\n", @@ -1762,16 +207,17 @@ { "cell_type": "code", "execution_count": null, + "id": "4e6f0c10", "metadata": {}, "outputs": [], "source": [ "VG.resize_nodes(property=\"pagerank\")\n", - "VG.color_nodes(property=\"componentId\")\n", "VG.render()" ] }, { "cell_type": "markdown", + "id": "c36a218b", "metadata": {}, "source": [ "### Coloring" @@ -1779,6 +225,7 @@ }, { "cell_type": "markdown", + "id": "40665ee4", "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": "25afe567", + "metadata": {}, "outputs": [], "source": [ - "VG.color_nodes(property=\"componentId\")\n", + "VG.color_nodes(property=\"subject\")\n", "VG.render()" ] }, { "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", @@ -1813,9 +260,8 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "tags": [] - }, + "id": "931a3465", + "metadata": {}, "outputs": [], "source": [ "from neo4j_viz.colors import ColorSpace\n", @@ -1826,6 +272,7 @@ }, { "cell_type": "markdown", + "id": "07a51bda", "metadata": {}, "source": [ "#### Custom coloring\n", @@ -1837,6 +284,7 @@ { "cell_type": "code", "execution_count": null, + "id": "583b37b7", "metadata": {}, "outputs": [], "source": [ @@ -1846,6 +294,7 @@ { "cell_type": "code", "execution_count": null, + "id": "fa2a8d32", "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": "dd7b6944", + "metadata": {}, + "outputs": [], "source": [ "VG.render()" ] }, { "cell_type": "markdown", + "id": "e163e522", "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": "02d2e7ce", + "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": "c325ea54", "metadata": {}, "source": [ "## Saving the visualization" @@ -5150,6 +359,7 @@ { "cell_type": "code", "execution_count": null, + "id": "d505dfb9", "metadata": {}, "outputs": [], "source": [ @@ -5164,6 +374,7 @@ }, { "cell_type": "markdown", + "id": "71006712", "metadata": {}, "source": [ "## Cleanup\n", @@ -5174,15 +385,23 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "tags": [ - "teardown" - ] - }, + "id": "b2197a5f", + "metadata": {}, "outputs": [], "source": [ "gds.graph.drop(\"cora\")" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "74468c43", + "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/getting-started.ipynb b/examples/getting-started.ipynb index 2ae449b0..7a012a5a 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-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" ], @@ -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": "8f9849af878743d4b73329f4fd7cc977", + "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,37 @@ "\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" + ] + }, + { + "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/examples/neo4j-example.ipynb b/examples/neo4j-example.ipynb index 0fb13102..2e8da54e 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()\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 9f5048a6..837e4c62 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: @@ -18,13 +19,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/README.md b/python-wrapper/README.md index 64fddaca..f164c036 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 diff --git a/python-wrapper/pyproject.toml b/python-wrapper/pyproject.toml index 417d6e5b..11cffa32 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" }] @@ -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", @@ -42,31 +43,35 @@ 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"] [dependency-groups] dev = [ - "ruff==0.15.12", - "mypy==1.20.2", + "ruff==0.15.17", + "mypy==2.1.0", "pytest==9.0.3", - "selenium==4.43.0", - "ipykernel==7.2.0", + "selenium==4.44.0", + "ipykernel==7.3.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]" + "anywidget[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", - "nbsphinx-link==1.3.1", + "nbsphinx-link==1.4.1", ] notebook = [ "ipykernel>=6.29.5", @@ -75,10 +80,10 @@ notebook = [ "ipywidgets>=8.0.0", "palettable>=3.3.3", "matplotlib>=3.9.4", - "snowflake-snowpark-python==1.50.0", - "python-dotenv", + "snowflake-snowpark-python==1.52.0", "requests", "marimo", + "python-dotenv" ] [project.urls] @@ -113,7 +118,12 @@ 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 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] @@ -174,9 +184,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/__init__.py b/python-wrapper/src/neo4j_viz/__init__.py index 920e6feb..7d759d50 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/_graph_entity_operations.py b/python-wrapper/src/neo4j_viz/_graph_entity_operations.py new file mode 100644 index 00000000..5ef0ee8a --- /dev/null +++ b/python-wrapper/src/neo4j_viz/_graph_entity_operations.py @@ -0,0 +1,408 @@ +from __future__ import annotations + +import warnings +from collections.abc import Hashable, Iterable +from typing import Any, Callable, Protocol + +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 + + +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: + """Pin or unpin nodes. See `VisualizationGraph.toggle_nodes_pinned` for details.""" + 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 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" + ) + + 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 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") + + 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 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") + + 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 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" + ) + + 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 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" + ) + + 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/_validation.py b/python-wrapper/src/neo4j_viz/_validation.py new file mode 100644 index 00000000..8d8b1456 --- /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/gds.py b/python-wrapper/src/neo4j_viz/gds.py index e6a399ac..78e37bae 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/src/neo4j_viz/nvl.py b/python-wrapper/src/neo4j_viz/nvl.py index ffabec8e..a31dd30d 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 2c4eb4ca..8969c842 100644 --- a/python-wrapper/src/neo4j_viz/options.py +++ b/python-wrapper/src/neo4j_viz/options.py @@ -6,6 +6,7 @@ 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,6 +145,56 @@ def check(self, renderer: Renderer, num_nodes: int) -> None: } +class PanPosition(BaseModel): + """The ``{x, y}`` pan position.""" + + x: float + y: float + + +# 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"): """ Options as documented at https://neo4j.com/docs/nvl/current/base-library/#_options @@ -178,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) -> dict[str, Any]: - """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: dict[str, Any] = {} + 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: dict[str, Any] = {} + 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/visualization_graph.py b/python-wrapper/src/neo4j_viz/visualization_graph.py index c25b8604..4b986374 100644 --- a/python-wrapper/src/neo4j_viz/visualization_graph.py +++ b/python-wrapper/src/neo4j_viz/visualization_graph.py @@ -1,16 +1,15 @@ 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 +from ._validation import OnDangling, check_dangling_relationships +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,202 +86,12 @@ 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)})" - def _build_render_options( - self, - layout: Layout | str | None, - layout_options: dict[str, Any] | LayoutOptions | None, - renderer: Renderer | str, - pan_position: tuple[float, float] | None, - initial_zoom: float | None, - min_zoom: float, - max_zoom: float, - allow_dynamic_min_zoom: bool, - max_allowed_nodes: int, - show_layout_button: bool, - ) -> RenderOptions: - """Shared validation + option building for render / render_widget.""" - num_nodes = len(self.nodes) - if num_nodes > max_allowed_nodes: - raise ValueError( - f"Too many nodes ({num_nodes}) to render. Maximum allowed nodes is set " - f"to {max_allowed_nodes} for performance reasons. It can be increased by " - "overriding `max_allowed_nodes`, but rendering could then take a long time" - ) - - if isinstance(renderer, str): - renderer = Renderer(renderer) - - Renderer.check(renderer, num_nodes) - - if not layout: - layout = Layout.FORCE_DIRECTED - if isinstance(layout, str): - layout = Layout(layout.lower()) - if not layout_options: - layout_options = {} - - if isinstance(layout_options, dict): - layout_options_typed = construct_layout_options(layout, layout_options) - else: - layout_options_typed = layout_options - - return RenderOptions( - layout=layout, - layout_options=layout_options_typed, - renderer=renderer, - pan_X=pan_position[0] if pan_position is not None else None, - pan_Y=pan_position[1] if pan_position is not None else None, - initial_zoom=initial_zoom, - min_zoom=min_zoom, - max_zoom=max_zoom, - allow_dynamic_min_zoom=allow_dynamic_min_zoom, - show_layout_button=show_layout_button, - ) - - def render( - self, - layout: Layout | str | None = None, - layout_options: dict[str, Any] | LayoutOptions | None = None, - renderer: Renderer | str = Renderer.CANVAS, - width: str = "100%", - height: str = "600px", - pan_position: tuple[float, float] | None = None, - initial_zoom: float | None = None, - min_zoom: float = 0.075, - max_zoom: float = 10, - allow_dynamic_min_zoom: bool = True, - max_allowed_nodes: int = 10_000, - theme: Literal["auto"] | Literal["light"] | Literal["dark"] = "auto", - ) -> HTML: - """ - Render the graph as an HTML object. - - Returns an :class:`IPython.display.HTML` object that will be displayed in environments - that support HTML rendering, such as Jupyter notebooks or Streamlit applications. - - Parameters - ---------- - layout: - The `Layout` to use. - layout_options: - The `LayoutOptions` to use. - renderer: - The `Renderer` to use. - width: - The width of the rendered graph. - height: - The height of the rendered graph. - pan_position: - The initial pan position. - initial_zoom: - The initial zoom level. - min_zoom: - The minimum zoom level. - max_zoom: - The maximum zoom level. - allow_dynamic_min_zoom: - Whether to allow dynamic minimum zoom level. - max_allowed_nodes: - The maximum allowed number of nodes to render. - theme: - The theme of the rendered graph. Can be 'auto', 'light', or 'dark' - - Example - ------- - Basic rendering of a VisualizationGraph: - >>> from neo4j_viz import Node, Relationship, VisualizationGraph - """ - render_options = self._build_render_options( - layout, - layout_options, - renderer, - pan_position, - initial_zoom, - min_zoom, - max_zoom, - allow_dynamic_min_zoom, - max_allowed_nodes, - show_layout_button=False, # The button only works with the widget - ) - - return NVL().render( - self.nodes, - self.relationships, - render_options, - width, - height, - theme, - ) - - def render_widget( - self, - layout: Layout | str | None = None, - layout_options: dict[str, Any] | LayoutOptions | None = None, - renderer: Renderer | str = Renderer.CANVAS, - width: str = "100%", - height: str = "600px", - pan_position: tuple[float, float] | None = None, - initial_zoom: float | None = None, - min_zoom: float = 0.075, - max_zoom: float = 10, - allow_dynamic_min_zoom: bool = True, - max_allowed_nodes: int = 10_000, - theme: Literal["auto"] | Literal["light"] | Literal["dark"] = "auto", - ) -> GraphWidget: - """ - Render the graph as an interactive Jupyter widget (anywidget). - - Returns a :class:`GraphWidget` that provides two-way data sync between Python - and JavaScript. Works in JupyterLab, Notebook 7, VS Code, and Colab. - - Parameters - ---------- - layout: - The `Layout` to use. - layout_options: - The `LayoutOptions` to use. - renderer: - The `Renderer` to use. - width: - The width of the rendered graph. - height: - The height of the rendered graph. - pan_position: - The initial pan position. - initial_zoom: - The initial zoom level. - min_zoom: - The minimum zoom level. - max_zoom: - The maximum zoom level. - allow_dynamic_min_zoom: - Whether to allow dynamic minimum zoom level. - max_allowed_nodes: - The maximum allowed number of nodes to render. - theme: - The theme to use for the rendered graph. - """ - render_options = self._build_render_options( - layout, - layout_options, - renderer, - pan_position, - initial_zoom, - min_zoom, - max_zoom, - allow_dynamic_min_zoom, - max_allowed_nodes, - show_layout_button=True, - ) + @cached_property + def _entity_ops(self) -> GraphEntityOperations: + return GraphEntityOperations(self) - return GraphWidget.from_graph_data( - self.nodes, - self.relationships, - width=width, - height=height, - options=render_options, - theme=theme, - ) + 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.""" def toggle_nodes_pinned(self, pinned: dict[NodeIdType, bool]) -> None: """ @@ -293,13 +102,7 @@ def toggle_nodes_pinned(self, pinned: dict[NodeIdType, bool]) -> None: 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._entity_ops.toggle_nodes_pinned(pinned) def set_node_captions( self, @@ -328,7 +131,7 @@ def set_node_captions( ... Node(id="0", properties={"name": "Alice", "age": 30}), ... Node(id="1", properties={"name": "Bob", "age": 25}), ... ] - >>> VG = VisualizationGraph(nodes=nodes) + >>> VG = VisualizationGraph(nodes=nodes, relationships=[]) Set node captions from a property: @@ -337,38 +140,8 @@ def set_node_captions( 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._entity_ops.set_node_captions(field=field, property=property, override=override) def resize_nodes( self, @@ -391,55 +164,7 @@ def resize_nodes( 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._entity_ops.resize_nodes(sizes=sizes, node_radius_min_max=node_radius_min_max, property=property) def resize_relationships( self, @@ -458,61 +183,7 @@ def resize_relationships( 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 + self._entity_ops.resize_relationships(widths=widths, property=property) def color_nodes( self, @@ -561,7 +232,7 @@ def color_nodes( ... Node(id="0", properties={"label": "Person", "score": 10}), ... Node(id="1", properties={"label": "Person", "score": 20}), ... ] - >>> VG = VisualizationGraph(nodes=nodes) + >>> VG = VisualizationGraph(nodes=nodes, relationships=[]) Color nodes based on a discrete field such as "label": @@ -576,49 +247,9 @@ def color_nodes( >>> 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._entity_ops.color_nodes( + field=field, property=property, colors=colors, color_space=color_space, override=override + ) def color_relationships( self, @@ -678,129 +309,214 @@ def color_relationships( >>> VG.color_relationships(property="score", color_space=ColorSpace.CONTINUOUS) """ - if not ((field is None) ^ (property is 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, + layout_options: dict[str, Any] | LayoutOptions | None, + renderer: Renderer | str, + pan_position: tuple[float, float] | None, + initial_zoom: float | None, + min_zoom: float, + max_zoom: float, + 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) + if num_nodes > max_allowed_nodes: raise ValueError( - f"Exactly one of the arguments `field` (received '{field}') and `property` (received '{property}') must be provided" + f"Too many nodes ({num_nodes}) to render. Maximum allowed nodes is set " + f"to {max_allowed_nodes} for performance reasons. It can be increased by " + "overriding `max_allowed_nodes`, but rendering could then take a long time" ) - if field is None: - assert property is not None - attribute = property + check_dangling_relationships(self.nodes, self.relationships, on_dangling) - def rel_to_attr(rel: Relationship) -> Any: - return rel.properties.get(attribute) + if isinstance(renderer, str): + renderer = Renderer(renderer) - else: - assert field is not None - attribute = to_snake(field) + Renderer.check(renderer, num_nodes) - def rel_to_attr(rel: Relationship) -> Any: - return getattr(rel, attribute) + if not layout: + layout = Layout.FORCE_DIRECTED + if isinstance(layout, str): + layout = Layout(layout.lower()) + if not layout_options: + layout_options = {} - if color_space == ColorSpace.DISCRETE: - if colors is None: - colors = NEO4J_COLORS_DISCRETE + if isinstance(layout_options, dict): + layout_options_typed = construct_layout_options(layout, layout_options) 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 - } + layout_options_typed = layout_options - 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) + return RenderOptions( + layout=layout, + layout_options=layout_options_typed, + renderer=renderer, + pan_X=pan_position[0] if pan_position is not None else None, + pan_Y=pan_position[1] if pan_position is not None else None, + initial_zoom=initial_zoom, + min_zoom=min_zoom, + max_zoom=max_zoom, + allow_dynamic_min_zoom=allow_dynamic_min_zoom, + show_layout_button=show_layout_button, + ) - def _color_items_dict( + def render( 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)) + layout: Layout | str | None = None, + layout_options: dict[str, Any] | LayoutOptions | None = None, + renderer: Renderer | str = Renderer.CANVAS, + width: str = "100%", + height: str = "600px", + pan_position: tuple[float, float] | None = None, + initial_zoom: float | None = None, + min_zoom: float = 0.075, + max_zoom: float = 10, + 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. - if color is None: - continue + Returns an :class:`IPython.display.HTML` object that will be displayed in environments + that support HTML rendering, such as Jupyter notebooks or Streamlit applications. - if item.color is not None and not override: - continue + Parameters + ---------- + layout: + The `Layout` to use. + layout_options: + The `LayoutOptions` to use. + renderer: + The `Renderer` to use. + width: + The width of the rendered graph. + height: + The height of the rendered graph. + pan_position: + The initial pan position. + initial_zoom: + The initial zoom level. + min_zoom: + The minimum zoom level. + max_zoom: + The maximum zoom level. + allow_dynamic_min_zoom: + Whether to allow dynamic minimum zoom level. + max_allowed_nodes: + 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". - if not isinstance(color, Color): - item.color = Color(color) - else: - item.color = color + Example + ------- + Basic rendering of a VisualizationGraph: + >>> from neo4j_viz import Node, Relationship, VisualizationGraph + """ + render_options = self._build_render_options( + layout, + layout_options, + renderer, + pan_position, + initial_zoom, + min_zoom, + max_zoom, + allow_dynamic_min_zoom, + max_allowed_nodes, + show_layout_button=False, # The button only works with the widget + on_dangling=on_dangling, + ) - 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" - ) + return NVL().render( + self.nodes, + self.relationships, + render_options, + width, + height, + theme, + ) - @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())) + def render_widget( + self, + layout: Layout | str | None = None, + layout_options: dict[str, Any] | LayoutOptions | None = None, + renderer: Renderer | str = Renderer.CANVAS, + width: str = "100%", + height: str = "600px", + pan_position: tuple[float, float] | None = None, + initial_zoom: float | None = None, + min_zoom: float = 0.075, + max_zoom: float = 10, + 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). - try: - hash(prop) - except TypeError: - raise ValueError(f"Unable to convert '{raw_prop}' of type {type(raw_prop)} to a hashable type") + Returns a :class:`GraphWidget` that provides two-way data sync between Python + and JavaScript. Works in JupyterLab, Notebook 7, VS Code, and Colab. - assert isinstance(prop, Hashable) + Parameters + ---------- + layout: + The `Layout` to use. + layout_options: + The `LayoutOptions` to use. + renderer: + The `Renderer` to use. + width: + The width of the rendered graph. + height: + The height of the rendered graph. + pan_position: + The initial pan position. + initial_zoom: + The initial zoom level. + min_zoom: + The minimum zoom level. + max_zoom: + The maximum zoom level. + allow_dynamic_min_zoom: + Whether to allow dynamic minimum zoom level. + max_allowed_nodes: + 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, + layout_options, + renderer, + pan_position, + initial_zoom, + min_zoom, + max_zoom, + allow_dynamic_min_zoom, + max_allowed_nodes, + show_layout_button=True, + on_dangling=on_dangling, + ) - return prop + return GraphWidget.from_graph_data( + self.nodes, + self.relationships, + width=width, + height=height, + options=render_options, + theme=theme, + ) diff --git a/python-wrapper/src/neo4j_viz/widget.py b/python-wrapper/src/neo4j_viz/widget.py index 433d411d..4a8d9d3c 100644 --- a/python-wrapper/src/neo4j_viz/widget.py +++ b/python-wrapper/src/neo4j_viz/widget.py @@ -2,13 +2,27 @@ import json import pathlib +from functools import cached_property from typing import Any, Union import anywidget 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 .options import RenderOptions +from .node_size import RealNumber +from .options import ( + Layout, + LayoutOptions, + NvlOptions, + PanPosition, + Renderer, + RenderOptions, + WidgetOptions, + construct_layout_options, +) from .relationship import Relationship, RelationshipIdType @@ -42,14 +56,18 @@ 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. Uses anywidget to render a React-based graph component with two-way data sync between Python and JavaScript. - Dev mode: set ANYWIDGET_HMR=1 and run ``yarn dev`` in js-applet/ - for hot module replacement during development. + The widget exposes utility methods that mutate the graph in place and + automatically sync the changes to the frontend. """ _esm = _STATIC / "widget.js" @@ -59,11 +77,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, @@ -80,15 +111,361 @@ 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, ) 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_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. + + 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]) + + 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) + + 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 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) + + 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) + + 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) + + 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 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 + ) + + 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 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 + ) + + 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: + """ + 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_widget_options() + + new = self._render_options() + new.layout = js.layout + new.layout_options = js.layout_options + self.options = 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 = 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 = PanPosition(x=x, y=y) + self.options = 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 = 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: + """ + 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.show_layout_button = show + 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. @@ -99,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] @@ -110,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/conftest.py b/python-wrapper/tests/conftest.py index 40f7f4e8..70f70bb7 100644 --- a/python-wrapper/tests/conftest.py +++ b/python-wrapper/tests/conftest.py @@ -1,10 +1,7 @@ -import os -from typing import Any, Generator - import pytest -def pytest_addoption(parser: Any) -> None: +def pytest_addoption(parser: pytest.Parser) -> None: parser.addoption( "--include-neo4j-and-gds", action="store_true", @@ -17,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: @@ -28,72 +25,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_ds_instance() -> Generator[Any, None, None]: - if os.environ.get("AURA_API_CLIENT_ID", None) is None: - yield None - return - - from tests.gds_helper import aura_api, create_aurads_instance - - api = aura_api() - id, dbms_connection_info = create_aurads_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 - assert isinstance(dbms_connection_info.password, str) - os.environ["NEO4J_PASSWORD"] = dbms_connection_info.password - yield dbms_connection_info - - # Clear Neo4j_URI after test (rerun should create a new instance) - os.environ["NEO4J_URI"] = "" - api.delete_instance(id) - - -@pytest.fixture(scope="package") -def gds(aura_ds_instance: Any) -> Generator[Any, None, None]: - from graphdatascience import GraphDataScience - - from tests.gds_helper import connect_to_plugin_gds - - 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", - ) - else: - NEO4J_URI = os.environ.get("NEO4J_URI", "neo4j://localhost:7687") - gds = connect_to_plugin_gds(NEO4J_URI) - yield gds - gds.close() - - -@pytest.fixture(scope="package") -def neo4j_driver(aura_ds_instance: Any) -> Generator[Any, None, None]: - import neo4j - - if aura_ds_instance: - driver = neo4j.GraphDatabase.driver( - aura_ds_instance.uri, auth=(aura_ds_instance.username, aura_ds_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/gds_helper.py b/python-wrapper/tests/gds_helper.py deleted file mode 100644 index e5a0d3dc..00000000 --- a/python-wrapper/tests/gds_helper.py +++ /dev/null @@ -1,69 +0,0 @@ -import os -import re - -from graphdatascience import GraphDataScience -from graphdatascience.semantic_version.semantic_version import SemanticVersion -from graphdatascience.session import DbmsConnectionInfo, SessionMemory -from graphdatascience.session.aura_api import AuraApi -from graphdatascience.session.aura_api_responses import InstanceCreateDetails -from graphdatascience.version import __version__ - - -def parse_version(version: str) -> SemanticVersion: - server_version_match = re.search(r"(\d+\.)?(\d+\.)?(\*|\d+)", version) - if not server_version_match: - raise ValueError(f"{version} is not a valid semantic version") - - groups = [int(g.replace(".", "")) for g in server_version_match.groups() if g] - - major = groups[0] if len(groups) > 0 else 0 - minor = groups[1] if len(groups) > 1 else 0 - patch = groups[2] if len(groups) > 2 else 0 - - return SemanticVersion(major=major, minor=minor, patch=patch) - - -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")) - - return GraphDataScience(endpoint=uri, auth=NEO4J_AUTH, database="neo4j") - - -def aura_api() -> AuraApi: - if GDS_VERSION >= SemanticVersion(1, 15, 0): - 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"), - ) - 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 - ) - - -def create_aurads_instance(api: AuraApi) -> tuple[str, DbmsConnectionInfo]: - # Switch to Sessions once they can be created without a DB - instance_details: InstanceCreateDetails = api.create_instance( - name="ci-neo4j-viz-session", - memory=SessionMemory.m_8GB.value, - cloud_provider="gcp", - region="europe-west1", - ) - - 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, - ) 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 00000000..e69de29b 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 00000000..ffc454d2 --- /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/neo4j_and_gds/gds_helper.py b/python-wrapper/tests/neo4j_and_gds/gds_helper.py new file mode 100644 index 00000000..f0a66da2 --- /dev/null +++ b/python-wrapper/tests/neo4j_and_gds/gds_helper.py @@ -0,0 +1,99 @@ +import logging +import os +import re + +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 AuraAPICredentials, AuraGraphDataScience, DbmsConnectionInfo, SessionMemory +from graphdatascience.session.aura_api import AuraApi +from graphdatascience.session.aura_api_responses import InstanceCreateDetails +from graphdatascience.version import __version__ + + +def parse_version(version: str) -> SemanticVersion: + server_version_match = re.search(r"(\d+\.)?(\d+\.)?(\*|\d+)", version) + if not server_version_match: + raise ValueError(f"{version} is not a valid semantic version") + + groups = [int(g.replace(".", "")) for g in server_version_match.groups() if g] + + major = groups[0] if len(groups) > 0 else 0 + minor = groups[1] if len(groups) > 1 else 0 + patch = groups[2] if len(groups) > 2 else 0 + + return SemanticVersion(major=major, minor=minor, patch=patch) + + +GDS_VERSION = parse_version(__version__) + + +def connect_to_plugin_gds(uri: str, auth: tuple[str, str]) -> GraphDataScience: + return GraphDataScience(endpoint=uri, auth=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: + if GDS_VERSION >= SemanticVersion(1, 15, 0): + 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_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_PROJECT_ID"), # type: ignore + ) + + +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_PROJECT_ID"), + ) + ) + + +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=type, + ) + logger = logging.getLogger(__name__) + logger.debug(f"Created instance with 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, + uri=wait_result.connection_url, + ) diff --git a/python-wrapper/tests/test_gds.py b/python-wrapper/tests/neo4j_and_gds/test_gds.py similarity index 78% rename from python-wrapper/tests/test_gds.py rename to python-wrapper/tests/neo4j_and_gds/test_gds.py index fb078aad..46d9994d 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,12 +25,18 @@ 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: GraphDataScience | AuraGraphDataScience) -> AbstractContextManager[GraphV2]: + 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: +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"]) assert len(VG.nodes) == 2 @@ -33,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], @@ -55,7 +64,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 @@ -103,10 +112,8 @@ 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 - - with gds.graph.generate("hello", node_count=11_000, average_degree=1) as G: +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, match=re.escape( @@ -125,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], @@ -159,7 +164,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_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 00000000..e9d61c30 --- /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 00000000..d38bd3cc --- /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 00000000..e69de29b 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 c786b921..3a47bbf5 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 00000000..7e45ccba --- /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 00000000..d179bb66 --- /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 d3b7c60f..efa97672 100644 --- a/python-wrapper/tests/test_notebooks.py +++ b/python-wrapper/tests/test_notebooks.py @@ -1,142 +1,5 @@ -import pathlib -import signal -import sys -from datetime import datetime -from typing import Any, Callable, NamedTuple - -import nbformat -import pytest -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"] - - 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/tests/test_options.py b/python-wrapper/tests/test_options.py index 3dee679d..48f76d23 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_render.py b/python-wrapper/tests/test_render.py index 8b7d85b1..abce9e11 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 00000000..0f1915fa --- /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 8033411c..de8caa62 100644 --- a/python-wrapper/tests/test_widget.py +++ b/python-wrapper/tests/test_widget.py @@ -1,10 +1,11 @@ import datetime +import re from typing import Any 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, WidgetOptions from neo4j_viz.widget import _serialize_entity @@ -67,7 +68,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 +84,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 +93,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 +123,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() @@ -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,103 @@ 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]: + 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": {}, @@ -280,7 +378,103 @@ 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: + 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.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 widget.options.layout_options is None + + 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 + assert widget.options.pan.y == 50 + + def test_set_renderer_canvas(self) -> None: + widget = GraphWidget() + + widget.set_renderer(Renderer.CANVAS) + + assert widget.options.nvl_options.disable_web_gl is True + + def test_set_renderer_webgl(self) -> None: + widget = GraphWidget() + + with pytest.warns(UserWarning): + widget.set_renderer(Renderer.WEB_GL) + + 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.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.show_layout_button is True + + widget.set_show_layout_button(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" + + 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" diff --git a/python-wrapper/uv.lock b/python-wrapper/uv.lock index 39871d69..cce7a5af 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" @@ -193,15 +233,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 +1133,7 @@ wheels = [ [[package]] name = "graphdatascience" -version = "1.21" +version = "1.22" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "multimethod" }, @@ -1118,9 +1149,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 +1189,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 +1272,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" @@ -1214,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'" }, @@ -1225,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]] @@ -1435,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" }, @@ -1443,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]] @@ -1732,87 +1802,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]] @@ -2287,60 +2357,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]] @@ -2444,15 +2515,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]] @@ -2469,7 +2540,7 @@ wheels = [ [[package]] name = "neo4j-viz" -version = "1.4.0" +version = "1.5.0" source = { editable = "." } dependencies = [ { name = "anywidget" }, @@ -2507,6 +2578,7 @@ dev = [ { name = "palettable" }, { name = "pytest" }, { name = "pytest-mock" }, + { name = "python-dotenv" }, { name = "ruff" }, { name = "selenium" }, { name = "streamlit" }, @@ -2534,7 +2606,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" }, @@ -2549,22 +2621,23 @@ 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 = "==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 = "ruff", specifier = "==0.15.12" }, - { name = "selenium", specifier = "==4.40.0" }, - { name = "streamlit", specifier = "==1.56.0" }, + { name = "python-dotenv" }, + { name = "ruff", specifier = "==0.15.17" }, + { 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 = [ @@ -2577,16 +2650,16 @@ 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.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]] @@ -3497,6 +3570,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" @@ -3930,27 +4012,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.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]] @@ -3967,22 +4049,19 @@ wheels = [ [[package]] name = "selenium" -version = "4.40.0" +version = "4.44.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/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/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/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]] @@ -4088,7 +4167,7 @@ wheels = [ [[package]] name = "snowflake-snowpark-python" -version = "1.42.0" +version = "1.52.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cloudpickle" }, @@ -4101,9 +4180,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/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/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/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]] @@ -4360,14 +4439,17 @@ wheels = [ [[package]] name = "streamlit" -version = "1.56.0" +version = "1.58.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 +4458,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/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/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/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]] @@ -4569,23 +4654,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 +4669,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 +4678,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 +5014,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" }, -]