diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index d1096f5e..df811abd 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -120,6 +120,20 @@ jobs: echo "=== Neo4j logs ===" docker logs neo4j-test 2>&1 | tail -30 || true + - name: Run Memgraph HA Cluster + env: + MEMGRAPH_ENTERPRISE_LICENSE: ${{ secrets.MEMGRAPH_ENTERPRISE_LICENSE }} + MEMGRAPH_ORGANIZATION_NAME: ${{ secrets.MEMGRAPH_ORGANIZATION_NAME }} + run: | + if [ -z "$MEMGRAPH_ENTERPRISE_LICENSE" ] || [ -z "$MEMGRAPH_ORGANIZATION_NAME" ]; then + echo "::warning::Memgraph enterprise license secrets are not set; skipping the HA cluster and the routing tests." + exit 0 + fi + + bash scripts/ha_cluster.sh start + echo "MEMGRAPH_HA_COORDINATOR_HOST=127.0.0.1" >> "$GITHUB_ENV" + echo "MEMGRAPH_HA_COORDINATOR_PORT=7703" >> "$GITHUB_ENV" + - name: Install More Packages (Python <= 3.12) if: ${{ matrix.python-version == '3.10' || matrix.python-version == '3.11' || matrix.python-version == '3.12' }} run: | @@ -193,6 +207,10 @@ jobs: # pyproject.toml from the repo root) and is covered by the source run. pytest tests --ignore=tests/test_packaging.py -o addopts='' -vvv -m "not slow and not ubuntu and not docker and not tfgnn" + - name: Stop Memgraph HA Cluster + if: always() + run: bash scripts/ha_cluster.sh stop || echo "HA cluster does not exist" + - name: Use the Upload Artifact GitHub Action uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() @@ -256,7 +274,7 @@ jobs: mkdir -p /var/lib/memgraph/data-windows-${{ matrix.python-version }} chown -R memgraph:memgraph /var/lib/memgraph/data-windows-${{ matrix.python-version }} pkill -f '/usr/lib/memgraph/memgraph' || true - runuser -l memgraph -c 'nohup /usr/lib/memgraph/memgraph --bolt-address 0.0.0.0 --bolt-port 7687 --data-directory="/var/lib/memgraph/data-windows-${{ matrix.python-version }}" --storage-properties-on-edges=true --storage-snapshot-interval-sec=0 --storage-wal-enabled=false --storage-snapshot-on-exit=false --telemetry-enabled=false --log-level=TRACE --also-log-to-stderr=true --log-file=/mnt/c/memgraph/memgraph-windows-${{ matrix.python-version }}.log >/mnt/c/memgraph/memgraph-windows-${{ matrix.python-version }}-stdout.log 2>&1 < /dev/null &' + runuser -l memgraph -c 'nohup /usr/lib/memgraph/memgraph --bolt-address 0.0.0.0 --bolt-port 7687 --data-directory="/var/lib/memgraph/data-windows-${{ matrix.python-version }}" --storage-properties-on-edges=true --storage-snapshot-interval-sec=0 --storage-wal-enabled=false --storage-snapshot-on-exit=false --telemetry-enabled=false --log-level=TRACE --also-log-to-stderr=true --log-file=/mnt/c/memgraph/memgraph-windows-${{ matrix.python-version }}.log >/mnt/c/memgraph/memgraph-windows-${{ matrix.python-version }}-stdout.log 2>&1 < /dev/null &' - name: Wait for Memgraph to be ready (WSL) shell: wsl-bash {0} # root shell diff --git a/docs/how-to-guides/high-availability.md b/docs/how-to-guides/high-availability.md new file mode 100644 index 00000000..adc6bab5 --- /dev/null +++ b/docs/how-to-guides/high-availability.md @@ -0,0 +1,169 @@ +# How to connect to a high-availability cluster + +Through this guide, you will learn how to use GQLAlchemy to connect to a +[Memgraph high-availability (HA) cluster](https://memgraph.com/docs/clustering/high-availability) +with client-side routing: you point GQLAlchemy at a *coordinator* and it routes +each query to the right data instance — writes to the main, reads to a replica. + +- [How to connect to a high-availability cluster](#how-to-connect-to-a-high-availability-cluster) + - [Route a client through a coordinator](#route-a-client-through-a-coordinator) + - [Choose an access mode](#choose-an-access-mode) + - [Run managed transactions](#run-managed-transactions) + - [Handle transient errors and tune retries](#handle-transient-errors-and-tune-retries) + - [Inspect the routing table](#inspect-the-routing-table) + +>If you have any more questions, join our community and ping us on [Discord](https://discord.gg/memgraph). + +!!! info + High availability is a [Memgraph Enterprise](https://memgraph.com/docs/database-management/enabling-memgraph-enterprise) + feature, so you need a running HA cluster (coordinators and data instances) + to use the features below. Client-side routing requires `pymgclient>=1.6.0`, + which GQLAlchemy depends on. If you're unsure how to run an HA cluster, check + out the Memgraph [high-availability documentation](https://memgraph.com/docs/clustering/high-availability). + +## Route a client through a coordinator + +Create a [`Memgraph`](../reference/gqlalchemy/vendors/memgraph.md) client with +`routing=True` and point its `host`/`port` at a cluster **coordinator** rather +than a specific data instance. GQLAlchemy fetches the cluster topology from the +coordinator and routes queries accordingly: + +```python +from gqlalchemy import Memgraph + +db = Memgraph(host="coordinator-1", port=7687, routing=True) + +# Routed to the main. +db.execute("MERGE (:Person {name: 'Ada'})") + +# Also routed to the main (the default access mode serves both reads and writes). +for result in db.execute_and_fetch("MATCH (n:Person) RETURN n.name AS name"): + print(result["name"]) +``` + +With `routing=False` (the default) GQLAlchemy connects directly to the given +`host`/`port`, exactly as before. + +## Choose an access mode + +`access_mode` selects which instance a routed client connects to: `"WRITE"` (the +default) targets the main, `"READ"` targets a replica. You can pass the string +or the `mgclient` constant: + +```python +from gqlalchemy import Memgraph + +# A read-only client, routed to a replica. +reader = Memgraph(host="coordinator-1", port=7687, routing=True, access_mode="READ") +for result in reader.execute_and_fetch("MATCH (n:Person) RETURN count(n) AS count"): + print(result["count"]) +``` + +```python +import mgclient +from gqlalchemy import Memgraph + +writer = Memgraph( + host="coordinator-1", + port=7687, + routing=True, + access_mode=mgclient.ACCESS_MODE_WRITE, +) +``` + +!!! info + A routed client opens its connection to whichever instance was current when + it connected. To keep working through a failover — where the main changes — + use the managed transactions described below, which re-route and retry + automatically. + +## Run managed transactions + +For failover-safe operations, use [`execute_write()`](../reference/gqlalchemy/vendors/memgraph.md#execute_write) +and [`execute_read()`](../reference/gqlalchemy/vendors/memgraph.md#execute_read). +Each takes a `work` callable that receives a transaction object exposing the +usual `execute` / `execute_and_fetch`; the transaction is routed (writes to the +main, reads to a replica), committed for you, and **retried automatically** if +the cluster is briefly in flux (for example while a new main is being elected). +Whatever `work` returns is returned to you: + +```python +from gqlalchemy import Memgraph + +db = Memgraph(host="coordinator-1", port=7687, routing=True) + +def add_person(tx): + tx.execute("MERGE (:Person {name: $name})", {"name": "Ada"}) + rows = list(tx.execute_and_fetch("MATCH (n:Person) RETURN count(n) AS count")) + return rows[0]["count"] + +count = db.execute_write(add_person) + +def count_people(tx): + rows = list(tx.execute_and_fetch("MATCH (:Person) RETURN count(*) AS count")) + return rows[0]["count"] + +total = db.execute_read(count_people) +``` + +!!! warning + Because a managed transaction may run **more than once** on retry, make the + `work` idempotent — use `MERGE` rather than `CREATE` so a re-run cannot + duplicate a write. Materialize any results you need (e.g. wrap + `execute_and_fetch` in `list(...)`) before returning them, since the + transaction is closed once `work` returns. + +## Handle transient errors and tune retries + +If a managed transaction cannot complete even after refreshing the routing table +and exhausting its retries, GQLAlchemy raises +[`GQLAlchemyTransientError`](../reference/gqlalchemy/exceptions.md) — a subclass +of `GQLAlchemyDatabaseError`, so existing error handling keeps working while +letting you single out retryable failures: + +```python +from gqlalchemy import Memgraph, GQLAlchemyTransientError + +db = Memgraph(host="coordinator-1", port=7687, routing=True) + +try: + db.execute_write(add_person) +except GQLAlchemyTransientError: + # The cluster did not settle within the retry budget; surface or re-queue. + raise +``` + +The retry budget is configurable on the client. Backoff is capped exponential — +`retry_backoff`, `2 * retry_backoff`, … up to `retry_backoff_cap` seconds: + +```python +db = Memgraph( + host="coordinator-1", + port=7687, + routing=True, + max_retries=8, + retry_backoff=1.0, + retry_backoff_cap=15.0, +) +``` + +## Inspect the routing table + +To see the topology the coordinator reports, use +[`get_routing_table()`](../reference/gqlalchemy/vendors/memgraph.md#get_routing_table). +It returns a dict with `ttl`, `write`, `read` and `route` entries: + +```python +from gqlalchemy import Memgraph + +db = Memgraph(host="coordinator-1", port=7687, routing=True) + +table = db.get_routing_table() +print("main(s):", table["write"]) +print("replica(s):", table["read"]) +print("coordinator(s):", table["route"]) +``` + +>Hopefully, this guide has taught you how to connect to a Memgraph +>high-availability cluster using GQLAlchemy. If you have any more questions, join +>our community and ping us on [Discord](https://discord.gg/memgraph). diff --git a/docs/how-to-guides/overview.md b/docs/how-to-guides/overview.md index baee5eae..802f764b 100644 --- a/docs/how-to-guides/overview.md +++ b/docs/how-to-guides/overview.md @@ -82,6 +82,15 @@ statistics for index optimization. - [**Manage Memgraph database**](manage-database.md) +## Connect to a high-availability cluster + +GQLAlchemy can connect to a Memgraph high-availability cluster through a +coordinator and route each query to the right data instance — writes to the +main, reads to a replica — including failover-safe managed transactions that +retry transient cluster conditions automatically. + +- [**Connect to a high-availability cluster**](high-availability.md) + ## Transform Python graphs into Memgraph graphs GQLAlchemy holds transformations that can transform NetworkX, PyG and DGL graphs diff --git a/docs/reference/gqlalchemy/connection.md b/docs/reference/gqlalchemy/connection.md index a2606070..58c468b6 100644 --- a/docs/reference/gqlalchemy/connection.md +++ b/docs/reference/gqlalchemy/connection.md @@ -72,6 +72,38 @@ def is_active() -> bool Returns True if connection is active and can be used. +## \_RoutedTransaction Objects + +```python +class _RoutedTransaction() +``` + +The object handed to an ``execute_read`` / ``execute_write`` work function. + +Wraps the pymgclient cursor of a managed transaction and exposes the same +``execute`` / ``execute_and_fetch`` surface as ``class MemgraphConnection()``, +converting rows to gqlalchemy values. The surrounding transaction is managed +by the router (begun, committed and retried around the work), so the work +must not commit or roll back itself. + +#### execute + +```python +def execute(query: str, parameters: Dict[str, Any] = {}) -> None +``` + +Executes Cypher query without returning any results. + +#### execute\_and\_fetch + +```python +def execute_and_fetch( + query: str, + parameters: Dict[str, Any] = {}) -> Iterator[Dict[str, Any]] +``` + +Executes Cypher query and returns iterator of results. + ## Neo4jConnection Objects ```python diff --git a/docs/reference/gqlalchemy/exceptions.md b/docs/reference/gqlalchemy/exceptions.md index bf8ada6f..76a307ee 100644 --- a/docs/reference/gqlalchemy/exceptions.md +++ b/docs/reference/gqlalchemy/exceptions.md @@ -3,6 +3,14 @@ sidebar_label: exceptions title: gqlalchemy.exceptions --- +## GQLAlchemyTransientError Objects + +```python +class GQLAlchemyTransientError(GQLAlchemyDatabaseError) +``` + +A database error that is worth retrying. + #### connection\_handler ```python diff --git a/docs/reference/gqlalchemy/vendors/memgraph.md b/docs/reference/gqlalchemy/vendors/memgraph.md index dd5d3568..d972a184 100644 --- a/docs/reference/gqlalchemy/vendors/memgraph.md +++ b/docs/reference/gqlalchemy/vendors/memgraph.md @@ -91,6 +91,38 @@ def new_connection() -> Connection Creates new Memgraph connection. +#### execute\_write + +```python +def execute_write(work) +``` + +Runs ``work(tx)`` as a managed write against the main. + +The router wraps the work in a transaction, commits it, and retries +transient failover conditions (with a routing refresh and capped +exponential backoff). Because the work may run more than once, make it +idempotent (e.g. ``MERGE`` rather than ``CREATE``). Requires ``routing=True``. + +#### execute\_read + +```python +def execute_read(work) +``` + +Runs ``work(tx)`` as a managed read against a replica. + +Same retry semantics as :meth:`execute_write`. Requires ``routing=True``. + +#### get\_routing\_table + +```python +def get_routing_table() +``` + +Returns a snapshot of the cluster routing table as a dict with +``ttl``, ``write``, ``read`` and ``route`` entries. Requires ``routing=True``. + #### create\_stream ```python diff --git a/gqlalchemy/__init__.py b/gqlalchemy/__init__.py index 1c841bbe..b00ec5fc 100644 --- a/gqlalchemy/__init__.py +++ b/gqlalchemy/__init__.py @@ -37,7 +37,12 @@ wait_for_docker_container, wait_for_port, ) -from gqlalchemy.exceptions import GQLAlchemyError, GQLAlchemyWarning # noqa F401 +from gqlalchemy.exceptions import ( # noqa F401 + GQLAlchemyError, + GQLAlchemyWarning, + GQLAlchemyDatabaseError, + GQLAlchemyTransientError, +) from gqlalchemy.query_builders import ( # noqa F401 neo4j_query_builder, diff --git a/gqlalchemy/connection.py b/gqlalchemy/connection.py index 9a9306f7..55bb392f 100644 --- a/gqlalchemy/connection.py +++ b/gqlalchemy/connection.py @@ -1,4 +1,4 @@ -# Copyright (c) 2016-2022 Memgraph Ltd. [https://memgraph.com] +# Copyright (c) 2016-2026 Memgraph Ltd. [https://memgraph.com] # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -70,11 +70,15 @@ def __init__( encrypted: bool, client_name: Optional[str] = None, lazy: bool = False, + routing: bool = False, + access_mode: Optional[str] = None, ): super().__init__( host=host, port=port, username=username, password=password, encrypted=encrypted, client_name=client_name ) self.lazy = lazy + self.routing = routing + self.access_mode = access_mode self._connection = self._create_connection() @database_error_handler @@ -101,21 +105,61 @@ def is_active(self) -> bool: @connection_handler def _create_connection(self) -> Connection: - """Creates and returns a connection with Memgraph.""" + """Creates and returns a connection with Memgraph. + + When ``routing`` is enabled the ``host``/``port`` are treated as a + cluster coordinator and the connection is routed to a data instance + serving ``access_mode`` (writes to the main, reads to a replica); see + pymgclient's ``connect(routing=True, ...)``. + """ sslmode = mgclient.MG_SSLMODE_REQUIRE if self.encrypted else mgclient.MG_SSLMODE_DISABLE - connection = mgclient.connect( + kwargs = dict( host=self.host, port=self.port, username=self.username, password=self.password, sslmode=sslmode, - lazy=self.lazy, client_name=self.client_name, ) + + if self.routing: + kwargs["routing"] = True + if self.access_mode is not None: + kwargs["access_mode"] = self.access_mode + else: + kwargs["lazy"] = self.lazy + connection = mgclient.connect(**kwargs) connection.autocommit = True return connection +class _RoutedTransaction: + """The object handed to an ``execute_read`` / ``execute_write`` work function. + + Wraps the pymgclient cursor of a managed transaction and exposes the same + ``execute`` / ``execute_and_fetch`` surface as ``class MemgraphConnection()``, + converting rows to gqlalchemy values. The surrounding transaction is managed + by the router (begun, committed and retried around the work), so the work + must not commit or roll back itself. + """ + + def __init__(self, cursor): + self._cursor = cursor + + def execute(self, query: str, parameters: Dict[str, Any] = {}) -> None: + """Executes Cypher query without returning any results.""" + self._cursor.execute(query, parameters) + + def execute_and_fetch(self, query: str, parameters: Dict[str, Any] = {}) -> Iterator[Dict[str, Any]]: + """Executes Cypher query and returns iterator of results.""" + self._cursor.execute(query, parameters) + while True: + row = self._cursor.fetchone() + if row is None: + break + yield {dsc.name: _convert_memgraph_value(row[index]) for index, dsc in enumerate(self._cursor.description)} + + def _convert_memgraph_value(value: Any) -> Any: """Converts Memgraph objects to custom Node/Relationship objects.""" if isinstance(value, mgclient.Relationship): diff --git a/gqlalchemy/exceptions.py b/gqlalchemy/exceptions.py index 1419d40d..e1bf449a 100644 --- a/gqlalchemy/exceptions.py +++ b/gqlalchemy/exceptions.py @@ -15,6 +15,8 @@ from enum import Enum import time +import mgclient + DATABASE_MISSING_IN_FIELD_ERROR_MESSAGE = """ Can't have an index on a property without providing the database `db` object. Define your property as: @@ -172,6 +174,12 @@ def __init__(self, message): self.message = message +class GQLAlchemyTransientError(GQLAlchemyDatabaseError): + """A database error that is worth retrying.""" + + pass + + class GQLAlchemyOperatorTypeError(GQLAlchemyError): def __init__(self, clause) -> None: self.message = OPERATOR_TYPE_ERROR.format(clause=clause) @@ -212,6 +220,8 @@ def database_error_handler(func): def inner_function(*args, **kwargs): try: return func(*args, **kwargs) + except mgclient.TransientError as e: + raise GQLAlchemyTransientError(e) from e except Exception as e: raise GQLAlchemyDatabaseError(e) from e diff --git a/gqlalchemy/vendors/memgraph.py b/gqlalchemy/vendors/memgraph.py index 00c138a9..156522dc 100644 --- a/gqlalchemy/vendors/memgraph.py +++ b/gqlalchemy/vendors/memgraph.py @@ -17,13 +17,16 @@ import sqlite3 from typing import List, Optional, Union -from gqlalchemy.connection import Connection, MemgraphConnection +import mgclient + +from gqlalchemy.connection import Connection, MemgraphConnection, _RoutedTransaction from gqlalchemy.disk_storage import OnDiskPropertyDatabase from gqlalchemy.exceptions import ( GQLAlchemyError, GQLAlchemyFileNotFoundError, GQLAlchemyOnDiskPropertyDatabaseNotDefinedError, GQLAlchemyUniquenessConstraintError, + database_error_handler, ) from gqlalchemy.models import ( Index, @@ -122,11 +125,22 @@ def __init__( encrypted: bool = mg_consts.MG_ENCRYPTED, client_name: str = mg_consts.MG_CLIENT_NAME, lazy: bool = mg_consts.MG_LAZY, + routing: bool = False, + access_mode: Optional[str] = None, + max_retries: Optional[int] = None, + retry_backoff: Optional[float] = None, + retry_backoff_cap: Optional[float] = None, ): super().__init__( host=host, port=port, username=username, password=password, encrypted=encrypted, client_name=client_name ) self._lazy = lazy + self._routing = routing + self._access_mode = access_mode + self._max_retries = max_retries + self._retry_backoff = retry_backoff + self._retry_backoff_cap = retry_backoff_cap + self._router = None self._on_disk_db = None @staticmethod @@ -228,9 +242,70 @@ def new_connection(self) -> Connection: password=self._password, encrypted=self._encrypted, client_name=self._client_name, + routing=self._routing, + access_mode=self._access_mode, ) return MemgraphConnection(**args) + def _get_router(self): + """Lazily builds and caches the long-lived routing engine (a Router). + + Raises GQLAlchemyError if this client was not created with routing=True. + """ + if not self._routing: + raise GQLAlchemyError( + "Managed transactions (execute_read/execute_write) and get_routing_table() require routing=True." + ) + if self._router is None: + sslmode = mgclient.MG_SSLMODE_REQUIRE if self._encrypted else mgclient.MG_SSLMODE_DISABLE + kwargs = dict( + host=self._host, + port=self._port, + username=self._username, + password=self._password, + sslmode=sslmode, + client_name=self._client_name, + ) + # Forward the routing options only when set; the Router applies its + # own defaults for anything omitted. + optional = { + "max_retries": self._max_retries, + "retry_backoff": self._retry_backoff, + "retry_backoff_cap": self._retry_backoff_cap, + } + kwargs.update({key: value for key, value in optional.items() if value is not None}) + self._router = mgclient.Router(**kwargs) + return self._router + + def execute_write(self, work): + """Runs ``work(tx)`` as a managed write against the main. + + The router wraps the work in a transaction, commits it, and retries + transient failover conditions (with a routing refresh and capped + exponential backoff). Because the work may run more than once, make it + idempotent (e.g. ``MERGE`` rather than ``CREATE``). Requires ``routing=True``. + """ + router = self._get_router() + return self._run_managed(router.execute_write, work) + + def execute_read(self, work): + """Runs ``work(tx)`` as a managed read against a replica. + + Same retry semantics as :meth:`execute_write`. Requires ``routing=True``. + """ + router = self._get_router() + return self._run_managed(router.execute_read, work) + + @database_error_handler + def _run_managed(self, run, work): + return run(lambda cursor: work(_RoutedTransaction(cursor))) + + def get_routing_table(self): + """Returns a snapshot of the cluster routing table as a dict with + ``ttl``, ``write``, ``read`` and ``route`` entries. Requires ``routing=True``. + """ + return self._get_router().routing_table + def create_stream(self, stream: MemgraphStream) -> None: """Create a stream.""" query = stream.to_cypher() @@ -301,6 +376,8 @@ def _new_connection(self) -> Connection: password=self._password, encrypted=self._encrypted, client_name=self._client_name, + routing=self._routing, + access_mode=self._access_mode, ) return MemgraphConnection(**args) diff --git a/mkdocs.yml b/mkdocs.yml index e02f910e..2ae4b770 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -38,6 +38,7 @@ nav: - Use on-disk storage: 'how-to-guides/on-disk-storage/on-disk-storage.md' - Create a graph projection: 'how-to-guides/query-builder/graph-projection.md' - Manage Memgraph database: 'how-to-guides/manage-database.md' + - Connect to a high-availability cluster: 'how-to-guides/high-availability.md' - Translate Python graphs: - Import Python graphs into Memgraph: 'how-to-guides/translators/import-python-graphs.md' - Export data from Memgraph into Python graphs: 'how-to-guides/translators/export-python-graphs.md' diff --git a/pyproject.toml b/pyproject.toml index 15ef7184..fd862757 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ authors = [ { name = "Josip Mrden", email = "josip.mrden@memgraph.io" }, ] dependencies = [ - "pymgclient>=1.5.1,<2.0.0", + "pymgclient>=1.6.0,<2.0.0", "networkx>=2.5.1,<4.0.0", "pydantic>=2.3.0,<3.0.0", "psutil>=5.9,<7.0", diff --git a/pytest.ini b/pytest.ini index e7a9b1bf..2af424f4 100644 --- a/pytest.ini +++ b/pytest.ini @@ -16,3 +16,4 @@ markers = tfgnn: tests using the TFGNN (TensorFlow GNN) extra ubuntu: slow tests docker: slow tests + routing: tests requiring a Memgraph high-availability cluster diff --git a/scripts/ha_cluster.sh b/scripts/ha_cluster.sh new file mode 100755 index 00000000..ecca4616 --- /dev/null +++ b/scripts/ha_cluster.sh @@ -0,0 +1,135 @@ +#!/bin/bash + +# Start (or stop) a local Memgraph high-availability cluster in Docker: three +# data instances and three coordinators. Used by CI and for running the +# client-side routing tests locally. +# +# High availability is a Memgraph Enterprise feature, so `start` requires: +# MEMGRAPH_ENTERPRISE_LICENSE, MEMGRAPH_ORGANIZATION_NAME +# +# The containers use host networking, so every instance shares localhost and +# gets a distinct port. The ports sit above the ones the rest of the test +# suite uses (7687 for the standalone Memgraph, 7688 for Neo4j) so the cluster +# can run alongside them. The advertised addresses are 127.0.0.1:, which +# are directly reachable from the test runner on the host. +# +# Usage: +# scripts/ha_cluster.sh start # start, register the topology, wait to converge +# scripts/ha_cluster.sh stop # stop and remove the containers +# +# On success `start` prints the coordinator to point clients at, e.g.: +# MEMGRAPH_HA_COORDINATOR_HOST=127.0.0.1 MEMGRAPH_HA_COORDINATOR_PORT=7703 + +set -Eeuo pipefail + +IMAGE="${MEMGRAPH_HA_IMAGE:-memgraph/memgraph}" + +DATA_NAMES=(mg-data1 mg-data2 mg-data3) +COORD_NAMES=(mg-coord1 mg-coord2 mg-coord3) + +# Per-instance ports, indexed 0..2. Bolt ports start at 7700 to clear 7687/7688. +DATA_BOLT=(7700 7701 7702) +COORD_BOLT=(7703 7704 7705) +DATA_MGMT=(13011 13012 13013) +COORD_MGMT=(13021 13022 13023) +COORD_CPORT=(12121 12122 12123) +DATA_REPL=(10001 10002 10003) + +# Run mgconsole (bundled in the memgraph image) against the bootstrap +# coordinator, reading queries from stdin. +mgconsole() { + docker exec -i mg-coord1 mgconsole --host localhost --port "${COORD_BOLT[0]}" +} + +stop_cluster() { + for name in "${DATA_NAMES[@]}" "${COORD_NAMES[@]}"; do + docker stop "$name" >/dev/null 2>&1 || true + done +} + +start_cluster() { + if [ -z "${MEMGRAPH_ENTERPRISE_LICENSE:-}" ] || + [ -z "${MEMGRAPH_ORGANIZATION_NAME:-}" ]; then + echo "error: set MEMGRAPH_ENTERPRISE_LICENSE and MEMGRAPH_ORGANIZATION_NAME" \ + "(high availability is a Memgraph Enterprise feature)" >&2 + exit 1 + fi + + local lic=(-e "MEMGRAPH_ENTERPRISE_LICENSE=$MEMGRAPH_ENTERPRISE_LICENSE" \ + -e "MEMGRAPH_ORGANIZATION_NAME=$MEMGRAPH_ORGANIZATION_NAME") + + # Three data instances. + local i + for i in 0 1 2; do + docker run -d --rm --name "${DATA_NAMES[$i]}" --network host "${lic[@]}" \ + "$IMAGE" --bolt-port="${DATA_BOLT[$i]}" \ + --management-port="${DATA_MGMT[$i]}" --telemetry-enabled=false + done + + # Three coordinators for a Raft quorum. mg-coord1 is the bootstrap leader; + # the others are added during registration below. + for i in 0 1 2; do + docker run -d --rm --name "${COORD_NAMES[$i]}" --network host "${lic[@]}" \ + "$IMAGE" --bolt-port="${COORD_BOLT[$i]}" --coordinator-id="$((i + 1))" \ + --coordinator-port="${COORD_CPORT[$i]}" \ + --coordinator-hostname=127.0.0.1 \ + --management-port="${COORD_MGMT[$i]}" --telemetry-enabled=false + done + + # Fail early (with logs) if any container didn't come up, rather than as an + # opaque connection error later. + sleep 10 + for name in "${DATA_NAMES[@]}" "${COORD_NAMES[@]}"; do + if [ "$(docker inspect -f '{{.State.Running}}' "$name" 2>/dev/null)" != "true" ]; then + echo "error: Memgraph HA container $name is not running" >&2 + docker logs "$name" || true + exit 1 + fi + done + + # Register the topology on the bootstrap coordinator. + { + for i in 1 2; do + printf 'ADD COORDINATOR %d WITH CONFIG {"bolt_server": "127.0.0.1:%s", "coordinator_server": "127.0.0.1:%s", "management_server": "127.0.0.1:%s"};\n' \ + "$((i + 1))" \ + "${COORD_BOLT[$i]}" "${COORD_CPORT[$i]}" "${COORD_MGMT[$i]}" + done + for i in 0 1 2; do + printf 'REGISTER INSTANCE instance_%d WITH CONFIG {"bolt_server": "127.0.0.1:%s", "management_server": "127.0.0.1:%s", "replication_server": "127.0.0.1:%s"};\n' \ + "$((i + 1))" \ + "${DATA_BOLT[$i]}" "${DATA_MGMT[$i]}" "${DATA_REPL[$i]}" + done + printf 'SET INSTANCE instance_1 TO MAIN;\n' + } | mgconsole + + # Wait for the cluster to converge: the coordinator must advertise a main + # (WRITE) and at least one replica. + local converged=false instances="" + for _ in $(seq 1 60); do + instances="$(echo 'SHOW INSTANCES;' | mgconsole 2>/dev/null || true)" + if echo "$instances" | grep -qiw main && + echo "$instances" | grep -qiw replica; then + converged=true + break + fi + sleep 1 + done + if [ "$converged" != "true" ]; then + echo "error: HA cluster did not converge" >&2 + echo "$instances" >&2 + exit 1 + fi + + echo "HA cluster ready. Point clients at the coordinator:" + echo " MEMGRAPH_HA_COORDINATOR_HOST=127.0.0.1" \ + "MEMGRAPH_HA_COORDINATOR_PORT=${COORD_BOLT[0]}" +} + +case "${1:-start}" in + start) start_cluster ;; + stop) stop_cluster ;; + *) + echo "usage: $0 [start|stop]" >&2 + exit 2 + ;; +esac diff --git a/tests/integration/test_routing.py b/tests/integration/test_routing.py new file mode 100644 index 00000000..c318930f --- /dev/null +++ b/tests/integration/test_routing.py @@ -0,0 +1,158 @@ +# Copyright (c) 2016-2026 Memgraph Ltd. [https://memgraph.com] +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Client-side routing against a Memgraph high-availability cluster. + +Most of these tests need a running HA cluster whose coordinator is advertised +through ``MEMGRAPH_HA_COORDINATOR_HOST`` / ``MEMGRAPH_HA_COORDINATOR_PORT`` +(started in CI by the "Run Memgraph HA Cluster" step, ``scripts/ha_cluster.sh``, +or locally by running that script); they carry ``@requires_cluster`` and are +skipped otherwise. The pure API-contract tests have no such requirement. +""" + +import os + +import mgclient +import pytest + +from gqlalchemy import Memgraph, Node, GQLAlchemyError +from gqlalchemy.connection import MemgraphConnection + +HA_HOST = os.environ.get("MEMGRAPH_HA_COORDINATOR_HOST") +HA_PORT = os.environ.get("MEMGRAPH_HA_COORDINATOR_PORT") + +pytestmark = pytest.mark.routing + +requires_cluster = pytest.mark.skipif( + not (HA_HOST and HA_PORT), + reason="requires a Memgraph HA cluster (set MEMGRAPH_HA_COORDINATOR_HOST/PORT)", +) + + +def _routing_memgraph(access_mode=None, **kwargs): + return Memgraph(host=HA_HOST, port=int(HA_PORT), routing=True, access_mode=access_mode, **kwargs) + + +def _routing_connection(access_mode=None): + """A routed ``MemgraphConnection`` against the cluster coordinator.""" + return MemgraphConnection( + host=HA_HOST, + port=int(HA_PORT), + username="", + password="", + encrypted=False, + routing=True, + access_mode=access_mode, + ) + + +def _replication_role(connection): + """Return 'main' or 'replica' for the instance a connection is bound to.""" + row = list(connection.execute_and_fetch("SHOW REPLICATION ROLE"))[0] + return next(iter(row.values())) + + +@requires_cluster +def test_write_connection_targets_main(): + assert _replication_role(_routing_connection(mgclient.ACCESS_MODE_WRITE)) == "main" + + +@requires_cluster +def test_read_connection_targets_replica(): + assert _replication_role(_routing_connection(mgclient.ACCESS_MODE_READ)) == "replica" + + +@requires_cluster +def test_default_access_mode_targets_main(): + # No access_mode given -> pymgclient defaults to writes (the main). + assert _replication_role(_routing_connection()) == "main" + + +@requires_cluster +def test_routed_write_is_readable_from_main(): + connection = _routing_connection(mgclient.ACCESS_MODE_WRITE) + connection.execute("MERGE (n:RoutingTest {id: 1}) SET n.value = 'ok'") + result = list(connection.execute_and_fetch("MATCH (n:RoutingTest {id: 1}) RETURN n.value AS value")) + assert result[0]["value"] == "ok" + + +def _vendor_role(db): + row = list(db.execute_and_fetch("SHOW REPLICATION ROLE"))[0] + return next(iter(row.values())) + + +@requires_cluster +def test_vendor_write_client_targets_main(): + assert _vendor_role(_routing_memgraph(mgclient.ACCESS_MODE_WRITE)) == "main" + + +@requires_cluster +def test_vendor_read_client_targets_replica(): + assert _vendor_role(_routing_memgraph(mgclient.ACCESS_MODE_READ)) == "replica" + + +@requires_cluster +def test_vendor_default_access_mode_targets_main(): + assert _vendor_role(_routing_memgraph()) == "main" + + +@requires_cluster +def test_execute_write_commits_and_is_readable(): + db = _routing_memgraph() + + def work(tx): + tx.execute("MERGE (n:RoutingTest {id: 2}) SET n.value = 'managed'") + return list(tx.execute_and_fetch("MATCH (n:RoutingTest {id: 2}) RETURN n.value AS value")) + + result = db.execute_write(work) + assert result[0]["value"] == "managed" + + +@requires_cluster +def test_execute_write_returns_work_result(): + db = _routing_memgraph() + assert db.execute_write(lambda tx: 42) == 42 + + +@requires_cluster +def test_execute_read_returns_work_result(): + db = _routing_memgraph() + result = db.execute_read(lambda tx: list(tx.execute_and_fetch("RETURN 1 AS one"))[0]["one"]) + assert result == 1 + + +@requires_cluster +def test_execute_read_converts_graph_values(): + # Managed transactions return gqlalchemy models, like execute_and_fetch does. + db = _routing_memgraph() + db.execute_write(lambda tx: tx.execute("MERGE (:RoutingTest {id: 4})")) + # Read the node back on the main (execute_write) to avoid replica lag. + node = db.execute_write(lambda tx: list(tx.execute_and_fetch("MATCH (n:RoutingTest {id: 4}) RETURN n"))[0]["n"]) + assert isinstance(node, Node) + + +@requires_cluster +def test_get_routing_table(): + table = _routing_memgraph().get_routing_table() + assert {"ttl", "write", "read", "route"}.issubset(table.keys()) + assert table["write"] # at least one main is advertised + + +def test_managed_transactions_require_routing(): + # No cluster needed: without routing there is no Router to manage them. + db = Memgraph() + with pytest.raises(GQLAlchemyError): + db.execute_write(lambda tx: None) + with pytest.raises(GQLAlchemyError): + db.execute_read(lambda tx: None) diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 00000000..82915d4f --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,57 @@ +# Copyright (c) 2016-2026 Memgraph Ltd. [https://memgraph.com] +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import mgclient +import pytest + +from gqlalchemy.exceptions import ( + GQLAlchemyDatabaseError, + GQLAlchemyTransientError, + database_error_handler, +) + + +def test_transient_error_is_a_database_error(): + # Backwards compatible: code catching GQLAlchemyDatabaseError still catches + # the transient subclass. + assert issubclass(GQLAlchemyTransientError, GQLAlchemyDatabaseError) + + +def test_transient_error_is_mapped_to_gqlalchemy_transient_error(): + @database_error_handler + def boom(): + raise mgclient.TransientError("instance briefly unreachable during a failover") + + with pytest.raises(GQLAlchemyTransientError): + boom() + + +def test_non_transient_database_error_is_not_transient(): + @database_error_handler + def boom(): + raise mgclient.DatabaseError("syntax error") + + with pytest.raises(GQLAlchemyDatabaseError) as exc_info: + boom() + assert not isinstance(exc_info.value, GQLAlchemyTransientError) + + +def test_plain_exception_is_mapped_to_database_error(): + @database_error_handler + def boom(): + raise ValueError("not a database error") + + with pytest.raises(GQLAlchemyDatabaseError) as exc_info: + boom() + assert not isinstance(exc_info.value, GQLAlchemyTransientError) diff --git a/uv.lock b/uv.lock index b4328090..0747be31 100644 --- a/uv.lock +++ b/uv.lock @@ -1133,7 +1133,7 @@ wheels = [ [[package]] name = "gqlalchemy" -version = "1.8.0" +version = "1.9.0" source = { editable = "." } dependencies = [ { name = "adlfs" }, @@ -1217,7 +1217,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.3.0,<3.0.0" }, { name = "pydot", marker = "extra == 'all'", specifier = ">=1.4.2,<5.0.0" }, { name = "pydot", marker = "extra == 'dot'", specifier = ">=1.4.2,<5.0.0" }, - { name = "pymgclient", specifier = ">=1.5.1,<2.0.0" }, + { name = "pymgclient", specifier = ">=1.6.0,<2.0.0" }, { name = "tensorflow", marker = "(python_full_version >= '3.10' and python_full_version < '3.13' and sys_platform != 'win32') or (python_full_version == '3.12.*' and sys_platform == 'win32')", specifier = "==2.16.2" }, { name = "tensorflow", marker = "(python_full_version >= '3.10' and python_full_version < '3.13' and sys_platform != 'win32' and extra == 'tfgnn') or (python_full_version == '3.12.*' and sys_platform == 'win32' and extra == 'tfgnn')", specifier = "==2.16.2" }, { name = "tensorflow-gnn", marker = "(python_full_version >= '3.10' and python_full_version < '3.13' and sys_platform != 'win32' and extra == 'all') or (python_full_version == '3.12.*' and sys_platform == 'win32' and extra == 'all')", specifier = ">=1.0.0,<2.0.0" }, @@ -3005,7 +3005,7 @@ crypto = [ [[package]] name = "pymgclient" -version = "1.5.2" +version = "1.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -3013,33 +3013,33 @@ dependencies = [ { name = "pyopenssl" }, { name = "tzdata" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d4/b6/92ea5be27a91e28f26bf68d157e1f680d53e14ac14084181a8b03834d06a/pymgclient-1.5.2.tar.gz", hash = "sha256:0346f6225d29315228d8931239624fb47e34af3a2f9a352bce21428cfa4b0efe", size = 131194, upload-time = "2026-02-17T11:51:27.256Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/79/29764ed6046e3243309acd1e95f9295545ae82be70f87ea282478c624a6b/pymgclient-1.5.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:c81a6c4c89f91a29b740e2a5d5c66549676b8b713f6eb2ecfcccfa7266f81acc", size = 2505542, upload-time = "2026-02-17T11:50:42.173Z" }, - { url = "https://files.pythonhosted.org/packages/a4/5b/eccbd429510736f4acee254f534f153361839498478b015775bbd50f9dbf/pymgclient-1.5.2-cp310-cp310-macosx_15_0_arm64.whl", hash = "sha256:d2472c9f6344e7af132c16f8e58b502e786dfd8680c8099d25d69c02a940160e", size = 2517639, upload-time = "2026-02-17T11:50:44.789Z" }, - { url = "https://files.pythonhosted.org/packages/3e/14/acd6f9c55e6615cf26ff228985a777c5e2aaff81f11c2fe3e9ddfb2be51c/pymgclient-1.5.2-cp310-cp310-manylinux_2_34_aarch64.whl", hash = "sha256:47cbb4fa3f86a511f672042bb76a9111560768bd8d8755ee808a387ab1730972", size = 3273758, upload-time = "2026-02-17T11:50:47.017Z" }, - { url = "https://files.pythonhosted.org/packages/f4/b1/b0085bfbba48b4a7f61c742f8a5a7887b0b0c2f773a4570caa14c2ecaccc/pymgclient-1.5.2-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:60df025be5c3bb8850af4f48dfb87704c578ce7a519aa7bf1aae543134d49ea5", size = 2999472, upload-time = "2026-02-17T11:50:49.331Z" }, - { url = "https://files.pythonhosted.org/packages/0e/5f/f22477bf86514e74f3121395cc206bb08c6fd29043e9c7871f84adf44c57/pymgclient-1.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:c02ceacf62c7f2c22e428801570d3ba74f2132c46b3e30d0ebc46f0b8fc7909a", size = 2414241, upload-time = "2026-02-17T11:50:50.69Z" }, - { url = "https://files.pythonhosted.org/packages/53/a6/8c50b7d8d61df569d902234b2f456d508fe094d720595612de269fb2d196/pymgclient-1.5.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a873d96dc116b4e6ed63fc51005530a079f37b47914690c30815aee60f7e7a82", size = 2505473, upload-time = "2026-02-17T11:50:52.734Z" }, - { url = "https://files.pythonhosted.org/packages/34/47/acb3b9a65ccc8641db130d1bfcc4c7754dad1810fc85eff253beb35e6cf5/pymgclient-1.5.2-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:117e652ce0ec50ee41c7a6bc64f27f51bc6dd1a61288456d4796af8f6010a486", size = 2517692, upload-time = "2026-02-17T11:50:54.512Z" }, - { url = "https://files.pythonhosted.org/packages/63/1e/d863e1139d0f1e1619e25b5b2313518afacfac2d6d729e781a8d4d9c5e79/pymgclient-1.5.2-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:db1b7686229ce6256d61fe74a9df7d219de6e023ea5ce20fdad62bcc454cfe38", size = 3273879, upload-time = "2026-02-17T11:50:57.323Z" }, - { url = "https://files.pythonhosted.org/packages/bb/2f/522772108afb25c72f7a95017dabe271cc6f3b8deca81f88c940a6563018/pymgclient-1.5.2-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:cf689a1e487c2be7c0b8e09f76260e9e46b8b9e58d89ac36c1c73d74d4d2109d", size = 2999929, upload-time = "2026-02-17T11:50:59.456Z" }, - { url = "https://files.pythonhosted.org/packages/9d/2f/3b5d1e9d497bc00950157a25f2e64205e03ecd6da33083e522a4cb855f79/pymgclient-1.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:66e922e88e1112dbf9d3ae33413863d8f50a7215358e75c69ab19d60efde991b", size = 2414364, upload-time = "2026-02-17T11:51:00.891Z" }, - { url = "https://files.pythonhosted.org/packages/2d/de/915a9f2eaa0f931a5e6db3f26b066df251ff57bc96ded3808a2e668fa2f3/pymgclient-1.5.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:40e06da9e78173b98b8c4b2f01629812fbbc7f9101fae9186c1187d0eda06e5a", size = 2505200, upload-time = "2026-02-17T11:51:02.243Z" }, - { url = "https://files.pythonhosted.org/packages/c2/27/28b7f73d469984f1588438764e457dbf0b23826a172d54108925859edb34/pymgclient-1.5.2-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:c4a824485ccf6d37e08fb9df967ac43aa23648830113951d5aec49f69ad0a880", size = 2517420, upload-time = "2026-02-17T11:51:04.185Z" }, - { url = "https://files.pythonhosted.org/packages/87/30/589b2467fb03d960bbfaa47977cf556309dfc0d6dc7390477092dcd8baf9/pymgclient-1.5.2-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:7eb0b1b448ef4787f51497d7b2edd56dc0a235a5cdcd0c8cda9e40cbb91dc500", size = 3274302, upload-time = "2026-02-17T11:51:05.499Z" }, - { url = "https://files.pythonhosted.org/packages/35/e4/7eec6379bf150e6958924a6e0bfe4a6f98bf79c87d367ac8bc264856bc06/pymgclient-1.5.2-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:dc63b66f860a556d9b4ffda6854b7d779d54632c8440cf818af86768152c2b8e", size = 2999915, upload-time = "2026-02-17T11:51:07.517Z" }, - { url = "https://files.pythonhosted.org/packages/30/16/91ba2152659749d2b3bc47239791394ada9bc6ff371dea67780252972134/pymgclient-1.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:9a933e7162eeafd36b454fbb00c5ffcf424705937f2642bafae3154fb5723b43", size = 2414656, upload-time = "2026-02-17T11:51:09.282Z" }, - { url = "https://files.pythonhosted.org/packages/74/5a/249de9d9d3b84b7389067197f8a9cb8faa6b8f47ca82a190090564b219bc/pymgclient-1.5.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4888bc0c73665619e4769eaa1e02dc6a3b15a0b108e347627736276b47d24dc0", size = 2505197, upload-time = "2026-02-17T11:51:10.628Z" }, - { url = "https://files.pythonhosted.org/packages/5f/22/393b9a00d58419befae7242d4c64240a1d85ff595b11a3e64e6dfd3351aa/pymgclient-1.5.2-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:dd221309614b2a2de124fe365f7feefa877edad9b5b6000e3dd801694986cc0c", size = 2517423, upload-time = "2026-02-17T11:51:12.275Z" }, - { url = "https://files.pythonhosted.org/packages/47/07/f70e76434d6115430119fdf111caea7460749abe482d45d90276eb932141/pymgclient-1.5.2-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:cef990e32e841b86f4fafdf125fa4cc0355a21b05e01854ac94b49d8a4f73062", size = 3274594, upload-time = "2026-02-17T11:51:13.879Z" }, - { url = "https://files.pythonhosted.org/packages/63/7a/942c62a86d0b03a76a2148b255d4f0f91cf86d7af3350097a40f3890967d/pymgclient-1.5.2-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:40b4eb82c2cc32ad3ff05ee257aaf58164c0d846a9126ca7d0d1689f2c6706ad", size = 3000242, upload-time = "2026-02-17T11:51:15.369Z" }, - { url = "https://files.pythonhosted.org/packages/e9/49/5b339236b345092f200918243f2768aa482aafaefe64336124a8798958d6/pymgclient-1.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:830bbd3017def3c561c3062c66108617d0cc3722d0bfbe682a6622b05cf9380d", size = 2415269, upload-time = "2026-02-17T11:51:16.807Z" }, - { url = "https://files.pythonhosted.org/packages/8b/12/7a34e9582e475e00eedaf9db620a046afb64449b6a6333d412e9fd727370/pymgclient-1.5.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:c153049821f81bf9b59756c5875edba6a7f2edafce6386fbaf2f01869a1bc8de", size = 2505210, upload-time = "2026-02-17T11:51:18.346Z" }, - { url = "https://files.pythonhosted.org/packages/08/1f/7564f5ff88819241849a67314337729a9116fd9421f7e8df5810ddd39905/pymgclient-1.5.2-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:e22473eccf2d9ca662d95e177548edb9f3d6a65bbfd8ceed6e381a14bf30f38a", size = 2517085, upload-time = "2026-02-17T11:51:20.355Z" }, - { url = "https://files.pythonhosted.org/packages/b2/c3/65bae8eb9dd8753daf2e54c3115571f9c5260ca7ef02a7b9cc7c775af07a/pymgclient-1.5.2-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:9df71428dd5da34157ccad72d33f0d3572a9e05c558a602dde826b3e502f5a29", size = 3274543, upload-time = "2026-02-17T11:51:22.568Z" }, - { url = "https://files.pythonhosted.org/packages/64/70/bbe8eda40db1a91bbf8f45dcac4591548904ba3fed27f71bbd3161f32329/pymgclient-1.5.2-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:8d3d0e9e6787d749d8d0ef1cde9b0f9623f1917181bd709a6f40f516bf4ca551", size = 3000339, upload-time = "2026-02-17T11:51:24.481Z" }, - { url = "https://files.pythonhosted.org/packages/43/e3/c8e0fd846d8dfeb06dd83734e52b5ca9a3a3151d0db4be9a82138a0d60f0/pymgclient-1.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:075b4027abbc24a5efa4a45addec5ebaeecc0d14036bf47fc85551a21777a984", size = 2495414, upload-time = "2026-02-17T11:51:26.01Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/6a/f0/63ab674b99d9da5d6a5444b3975ed66d4c174533a2221d17697cd92ec665/pymgclient-1.6.0.tar.gz", hash = "sha256:9499b865355447c1ffb5a9a0c9e87eb718b9d2e83c743722b7414d17bf47d807", size = 166641, upload-time = "2026-07-20T11:03:43.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/a1/99fba6c4f73794e32eb689ba199b3c81748cc094be4afc4513739cc2409a/pymgclient-1.6.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:f7de9865a38cccd3beed6b6115e79c09482e93ed9c460e42f69cee640e03ee4e", size = 2522236, upload-time = "2026-07-20T11:03:05.243Z" }, + { url = "https://files.pythonhosted.org/packages/16/d3/02a27fba36c67b6c3ad6ef0bf21b31c934eeb6bb54a4bf9c838355587af0/pymgclient-1.6.0-cp310-cp310-macosx_15_0_arm64.whl", hash = "sha256:71d368451cc8a4b1455eb4987efb646ca00a05c25aa6cfea8d38dc529b4d1ca3", size = 2535268, upload-time = "2026-07-20T11:03:07.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/bd/5d8842bbb1b13f7c5ceca087200919821c2eab35fd50b44a58225eb1da19/pymgclient-1.6.0-cp310-cp310-manylinux_2_34_aarch64.whl", hash = "sha256:6e54ffb35d296de60412b069535f77072517b29a2db0296a89a8fcfa2adf73de", size = 3292595, upload-time = "2026-07-20T11:03:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/33/fd/40bebbd15dd7c2396452c2902176665909cc6b799c575c6eb627b5c8d08e/pymgclient-1.6.0-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:2df88f865a1c8111107ecca6f95e77050a751ea4e3caae24a5128f730f0525a4", size = 3015049, upload-time = "2026-07-20T11:03:10.474Z" }, + { url = "https://files.pythonhosted.org/packages/0c/86/0cb161f91d8ab4b158f66cb2c093cd4fcfe450d130af04ccaaa268f6661f/pymgclient-1.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:717b83f2f36d20ddca81b041ca633f19eb79fff29e3d910295e166fba2ccb909", size = 2284407, upload-time = "2026-07-20T11:03:11.836Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/85f13a1ee9a09a38eb5f5a050c332249cbd860064d0ebdd079c2b111d8d2/pymgclient-1.6.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:52d3145ce75fe6adac2a60521e0dde6ed10ddebe87bd584983a79a37b9702a71", size = 2522081, upload-time = "2026-07-20T11:03:13.279Z" }, + { url = "https://files.pythonhosted.org/packages/56/70/1deea700a85d424bbf88d7059877a1e8dcba2bc4e14c5762f06bdd895e48/pymgclient-1.6.0-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:fe48038164212ef324960912a97e371cd8eb746d7d3ed9d49dede162b56f8ad6", size = 2535144, upload-time = "2026-07-20T11:03:14.506Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/adb21b9602d62a4b5509a50dbaec2d721a5d5473dd31501a62dfbda1b3f0/pymgclient-1.6.0-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:13c61d072022b4fc80b2be91685a5c33072582334e013f0111ac1ed5e1faa53a", size = 3292738, upload-time = "2026-07-20T11:03:16.073Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8f/fa274ed8003e7335070a1d5fe210aa98edc856ac9a2efff91f3f464dd22e/pymgclient-1.6.0-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:43f6953abc22e18ba1123f35ea7787afb2dda7abc1b91db4cf5763c9966e794f", size = 3015446, upload-time = "2026-07-20T11:03:17.324Z" }, + { url = "https://files.pythonhosted.org/packages/23/6d/67807a11a4f6bf36ae55aa47a8406ef9b4ee6cda6a80ea7e3048edfb20aa/pymgclient-1.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:9748e20c27366e8f50f42691169cef440790caf0fcc4d88060da0ab14a30d313", size = 2284563, upload-time = "2026-07-20T11:03:18.796Z" }, + { url = "https://files.pythonhosted.org/packages/de/86/da5d9f7ac150ebb5625cf31eb99d159e192fa7f3f02bd1864ab05f99648f/pymgclient-1.6.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:325f7e0b7796c3869f33044567ab1891387e1ec4f23057ac2ea80545e74c2ea9", size = 2521406, upload-time = "2026-07-20T11:03:20.163Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e5/a17e5b7e01df6f4effdd1da001fc31cebba8e5169a5a42f0c803e37ad89a/pymgclient-1.6.0-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:cedfccd63a77b9c5cd5901c560cd7adee8ff78c2c837bd66b06732db567e6e21", size = 2534940, upload-time = "2026-07-20T11:03:21.509Z" }, + { url = "https://files.pythonhosted.org/packages/4f/cd/c3a2f4172b0ba6a8bb1a729cbb6c2d63d0873fd41c3d9bd11bd30694b265/pymgclient-1.6.0-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:ee47f99e4f58d86407c7ce7395c028b580ac8b29fc24bb58e6dd0286b7ba9bda", size = 3293697, upload-time = "2026-07-20T11:03:23.311Z" }, + { url = "https://files.pythonhosted.org/packages/5b/2d/696710243b8179d8b989b71cba469482df9283a4bc009bce8e76cb96575b/pymgclient-1.6.0-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:fac84ad070dd7fae7f2fdadfb2c7dc3b05d20b5b0215f749e5ae9103eb18460a", size = 3016721, upload-time = "2026-07-20T11:03:25.047Z" }, + { url = "https://files.pythonhosted.org/packages/40/b3/856cc1a3f69b4f4bed3e1b775906b7a72217d3f8dff2c51280b45e7a249b/pymgclient-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:b7da2602071e9a557365f02b4cd6b179413aaa811284ec2c12a82a3a851e35b7", size = 2285070, upload-time = "2026-07-20T11:03:26.537Z" }, + { url = "https://files.pythonhosted.org/packages/b8/39/a59ff5393fb6e4e1e3121732153b7cafffcf0b0ac2ede15ae5bfa3fbada1/pymgclient-1.6.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:0cc7333b6644c2d471cb1d3c5ae94363b1ff3ff5c882f79d5649d6377583262a", size = 2521408, upload-time = "2026-07-20T11:03:28.077Z" }, + { url = "https://files.pythonhosted.org/packages/46/94/01c78999e71ba35ae4fae95b43e0beeab13f33fdc4c76fd88c49a65a3dd0/pymgclient-1.6.0-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:beeb5cb99be794a02ba22dd6497763f37f3de220fba5951258c380dfe3d07c28", size = 2534945, upload-time = "2026-07-20T11:03:29.464Z" }, + { url = "https://files.pythonhosted.org/packages/a3/10/3ad5ab8f3115eb20f2d147f233efaea74887824e6268a2d311e7ee16ef77/pymgclient-1.6.0-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:b78a3abe428fc37c2b68631cd585bc5b1daa9b13775ff7a0544590674eecb1f1", size = 3293974, upload-time = "2026-07-20T11:03:31.131Z" }, + { url = "https://files.pythonhosted.org/packages/40/d1/cfe20e749a559ae6d52b6808db9c3e70077dfebd505690b7a4f738692dcb/pymgclient-1.6.0-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:b50aac94874a954d2bbf07e939d9130c03a0447ad721c7479133b71024a14dce", size = 3017213, upload-time = "2026-07-20T11:03:32.643Z" }, + { url = "https://files.pythonhosted.org/packages/52/48/d3c0bb7f68d0244b0c0df0d41162cb74c95399549879732d8099a874debe/pymgclient-1.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:a7b4ca46f6a2f6ae3b62a82a5c42318a6707c1e8af9d3f80f5ec2bc38af24b9d", size = 2285876, upload-time = "2026-07-20T11:03:34.551Z" }, + { url = "https://files.pythonhosted.org/packages/21/3a/2a2d5c137ce070cac3d0112b8aa931d8543d823eede30c90f3812be401c7/pymgclient-1.6.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:9cf8132981f1798a74aae4ef6f8295e822618d596bbff4f9ce516d6550043521", size = 2521471, upload-time = "2026-07-20T11:03:36.402Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7d/1e3e45cd7c9661c7f8c4cb3b07ba6097ec8cba4957cf56f4cde022cb17b3/pymgclient-1.6.0-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:ebf7feafdbcf4bfb0e2d1744dfb2943aedbfb61f6dfee3b41108d86f4d9814a1", size = 2534994, upload-time = "2026-07-20T11:03:37.733Z" }, + { url = "https://files.pythonhosted.org/packages/90/59/5ba6a83262ee2bd7f5ac132deda3edf396b20dcc800852384e6e38e2eb1d/pymgclient-1.6.0-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:23a2e1c24184167c04ed4d87fac2aaaec1bb86a948f1a72bf398faf8fcb7cb54", size = 3294076, upload-time = "2026-07-20T11:03:39.397Z" }, + { url = "https://files.pythonhosted.org/packages/54/3d/9ee5cf0070a0fcd3fe79493023bccf7de53bb1f239628625ff3d5447ed8a/pymgclient-1.6.0-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:4368dee53bd7e2a6663e704097f235f1e0b27b03a4012e5b5749ec9451b55a0d", size = 3016907, upload-time = "2026-07-20T11:03:40.846Z" }, + { url = "https://files.pythonhosted.org/packages/77/19/2078a1965310da2d7c98479d08193175663ac0afd33773a898369c935286/pymgclient-1.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:660d40b32b6a5d81fb5f58c005e2d2546a3ee04d97398fc1ef3a046ae4c70458", size = 2359941, upload-time = "2026-07-20T11:03:42.364Z" }, ] [[package]] @@ -3692,13 +3692,13 @@ dependencies = [ { name = "typing-extensions", marker = "sys_platform == 'darwin'" }, ] wheels = [ - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:91209c7d8a2460b76e8ff5b28b7623da4ab1d27474b79e1de83e954871985afe" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d75eadcd97fe0dc7cd0eedc4d72152484c19cb2cfe46ce55766c8e129116425f" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:43b35116802c85fb88d99f4a396b8bd4472bfca1dd82e69499e5a4f9b8b4e252" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:442ec9dc78592564fdad69cf0beaa9da2f82ab810ccb4f13903869a90bf3f15d" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cc3a195701bba2239c313ee311487f80f8aaebe9e89b9073dddbcf2f93b5a0ba" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:072a0d6e4865e8b0dc0dbfe6ebed68fae235124222835ef03e5814d414d8c012" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:23ec7789017da9d95b6d543d790814785e6f30905c5443efa8257d1490d73f79" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:91209c7d8a2460b76e8ff5b28b7623da4ab1d27474b79e1de83e954871985afe", upload-time = "2026-03-23T15:16:50Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d75eadcd97fe0dc7cd0eedc4d72152484c19cb2cfe46ce55766c8e129116425f", upload-time = "2026-03-23T15:16:54Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:43b35116802c85fb88d99f4a396b8bd4472bfca1dd82e69499e5a4f9b8b4e252", upload-time = "2026-03-23T15:16:58Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:442ec9dc78592564fdad69cf0beaa9da2f82ab810ccb4f13903869a90bf3f15d", upload-time = "2026-03-23T15:17:02Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cc3a195701bba2239c313ee311487f80f8aaebe9e89b9073dddbcf2f93b5a0ba", upload-time = "2026-03-23T15:17:06Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:072a0d6e4865e8b0dc0dbfe6ebed68fae235124222835ef03e5814d414d8c012", upload-time = "2026-03-23T15:17:10Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:23ec7789017da9d95b6d543d790814785e6f30905c5443efa8257d1490d73f79", upload-time = "2026-03-23T15:17:14Z" }, ] [[package]] @@ -3726,34 +3726,34 @@ dependencies = [ { name = "typing-extensions", marker = "sys_platform != 'darwin'" }, ] wheels = [ - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp310-cp310-linux_s390x.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp310-cp310-manylinux_2_28_aarch64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp310-cp310-manylinux_2_28_x86_64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp310-cp310-win_amd64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp311-cp311-linux_s390x.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp311-cp311-manylinux_2_28_aarch64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp311-cp311-win_amd64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp312-cp312-linux_s390x.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp312-cp312-win_amd64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313-linux_s390x.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313-win_amd64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313t-linux_s390x.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313t-win_amd64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314-linux_s390x.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314-win_amd64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314t-linux_s390x.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314t-win_amd64.whl" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp310-cp310-linux_s390x.whl", hash = "sha256:3d8a7789e61dbf11f8922672c43354614b9b0debd40899c0a94f1ad9e0bd6bd9", upload-time = "2026-04-27T21:55:13Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:c7dbae3a5cd0a4a3eab760742b9be3bd79282259488cf83176197cef31ce1610", upload-time = "2026-04-27T21:55:20Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:f378df648bf7fb94bbc820dfec37d3d346bd1703c692a2215edebf6edcef8b75", upload-time = "2026-04-27T21:55:27Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp310-cp310-win_amd64.whl", hash = "sha256:fe708aa0c1df5cce9962df806065534ae9af5eb29ee6145a705176266427c931", upload-time = "2026-04-27T21:55:35Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp311-cp311-linux_s390x.whl", hash = "sha256:5214b203ee187f8746c66f1378b72611b7c1e15c5cb325037541899e705ea24e", upload-time = "2026-04-27T21:55:40Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:46fbb0aa257bb781efbfad648f5b045c0e232573b661f1461593db61342e9096", upload-time = "2026-04-28T00:05:38Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:8a56a8c95531ef0e454510ba8bbd9d11dc7a9000337265210b10f6bfeacdd485", upload-time = "2026-04-28T00:05:47Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp311-cp311-win_amd64.whl", hash = "sha256:51a221769d4a316f4b47a786c12e67c3f4807db8ed13c7b8817ebe73786acbbc", upload-time = "2026-04-28T00:06:00Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp312-cp312-linux_s390x.whl", hash = "sha256:2db3ae5404e32cb42b5fcbd94f13607761eaec0cf1687fde95095289d1e26cfb", upload-time = "2026-04-28T00:06:06Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:70ecb2659af6373b7c5336e692e665605b0201ea21ff51aaea47e1d75ea6b5aa", upload-time = "2026-04-28T00:06:14Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:f82e2ae20c1545bb03997d1cc3143d94e14b800038669ee1aca45808a9acc338", upload-time = "2026-04-28T00:06:24Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp312-cp312-win_amd64.whl", hash = "sha256:1abeaa46fa7532ed35ed79146f4de5d7a9d4b30462c98052ea4ddfe781ea3eca", upload-time = "2026-04-28T00:06:34Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313-linux_s390x.whl", hash = "sha256:d1eff25ccc454faf21c9666c81bfab8e405e87c12d300708d4559620bc191a36", upload-time = "2026-04-28T00:06:42Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:48b3e21a311445acdd0b27f13830e21d93adef70d4721e051e9f059baeb9b8f9", upload-time = "2026-04-28T00:06:51Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:45025d7752dbc6b4c784c03afaee9c5f19730ce084b2e43fc9a2fe1677d9ff86", upload-time = "2026-04-28T00:07:02Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313-win_amd64.whl", hash = "sha256:ed70d4a4fc9f8b826c02fa1a9800a83820fb2fa6ae607680b53390f9ef394d85", upload-time = "2026-04-28T00:07:12Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313t-linux_s390x.whl", hash = "sha256:65d427a196ab0abe359b93c5bffedd76ded02df2b1b1d2d9f11a2609b69f426a", upload-time = "2026-04-28T00:07:19Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:8f13dc7075ae04ca5f876a9f40b4e47522a04c23e30824b4409f42a3f3e57aa4", upload-time = "2026-04-28T00:07:27Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:8713bb8679376ea0ec25742100b6cfb8447e0904c48bddefb9eb0ac1abbfa60a", upload-time = "2026-04-28T00:07:37Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313t-win_amd64.whl", hash = "sha256:62ec1f1694c185f601eab74eb7fc0e8e10c64c06ae82f13c3592774c231c4877", upload-time = "2026-04-28T00:07:47Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314-linux_s390x.whl", hash = "sha256:c9a14c367f470623b978e273a4e1915995b4ba7a0ae999178b06c273eea3536f", upload-time = "2026-04-28T00:07:54Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:71676f6a9a84bbd385e010198b51fa1c2324fb8f3c512a32d2c81af65f68f4c9", upload-time = "2026-04-28T00:08:02Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:f8481ea9088e4e5b81178a75aabdbb658bde8639bc1a15fd5d8f930abc966735", upload-time = "2026-04-28T00:08:11Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314-win_amd64.whl", hash = "sha256:7575af4c9f7f7500ed62b1dafeb069aa0ba35b368a5f09793b3976b3d50f4fe4", upload-time = "2026-04-28T00:08:20Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314t-linux_s390x.whl", hash = "sha256:825f1596878280a3a4c861441674888bc2d792e4ab7b045cb35feeab3f4f5dd7", upload-time = "2026-04-28T00:08:27Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c8a0bdfb2fd915b6c2cd27c856f63f729c366a4917772eba6b2b02aa3bce70d5", upload-time = "2026-04-28T00:08:36Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:768f22924a25cad2adeb9c6cbac5159e71067c8d4019b1511960d7435a5ca652", upload-time = "2026-04-28T00:08:47Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314t-win_amd64.whl", hash = "sha256:6db45e7b2526d996fbf47c3d08737807a60a4e17996a6d91a97027fe260832c8", upload-time = "2026-04-28T00:08:57Z" }, ] [[package]]