diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f3910ec7b..5c823f5a0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -33,7 +33,7 @@ jobs: run: uv build --sdist - name: Upload source artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: source-dist path: dist/*.tar.gz @@ -62,7 +62,7 @@ jobs: run: uv build --wheel - name: Upload wheel artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: wheels-standard path: dist/*.whl @@ -179,7 +179,7 @@ jobs: MYPYC_OPT_LEVEL=3 MYPYC_DEBUG_LEVEL=0 MYPYC_MULTI_FILE=1 - name: Upload wheel artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: wheels-mypyc-${{ matrix.os }}-py${{ matrix.python-version }} path: wheelhouse/*.whl @@ -224,13 +224,13 @@ jobs: run: uv python install ${{ matrix.python-version }} - name: Download standard wheel artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: wheels-standard path: dist-standard/ - name: Download mypyc wheel artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: wheels-mypyc-* merge-multiple: true @@ -275,7 +275,7 @@ jobs: enable-cache: false - name: Download all artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: "*" merge-multiple: true diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index fab6f41e7..92b973002 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -191,7 +191,7 @@ jobs: run: uv build --sdist - name: Upload source artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: source-dist path: dist/*.tar.gz @@ -222,7 +222,7 @@ jobs: run: uv build --wheel - name: Upload wheel artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: wheels-standard path: dist/*.whl @@ -349,7 +349,7 @@ jobs: MYPYC_OPT_LEVEL=3 MYPYC_DEBUG_LEVEL=0 MYPYC_MULTI_FILE=1 - name: Upload wheel artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: wheels-mypyc-${{ matrix.os }}-py${{ matrix.python-version }} path: wheelhouse/*.whl @@ -393,13 +393,13 @@ jobs: run: uv python install "$PYTHON_VERSION" - name: Download standard wheel artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: wheels-standard path: dist-standard/ - name: Download mypyc wheel artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: wheels-mypyc-* merge-multiple: true @@ -439,7 +439,7 @@ jobs: cache-dependency-glob: uv.lock - name: Download all artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: "*" merge-multiple: true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8267c1175..e1f290dcb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,7 +18,7 @@ repos: - id: mixed-line-ending - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.16.8" + rev: "v0.16.9" hooks: - id: ruff-check args: ["--fix"] diff --git a/docs/changelog.rst b/docs/changelog.rst index daea02840..17ae3cfff 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,6 +9,69 @@ important operational fixes. Recent Updates ============== +Unreleased +---------- + +**Added:** + +* Spanner adds a native asyncio adapter built on the async Spanner client: + ``SpannerAsyncConfig`` and ``SpannerAsyncDriver`` support pooled sessions, + read and write sessions, ``run_in_transaction()`` with ``Aborted`` retries, + streaming selects, Arrow import and export, and ``SpannerAsyncDataDictionary``. + The sync adapter keeps its existing behavior. + (`#831 `_) +* Spanner ships async extension stores: ``SpannerAsyncADKStore`` and + ``SpannerAsyncADKMemoryStore`` for ADK, ``SpannerAsyncStore`` for Litestar + sessions, and ``SpannerAsyncEventQueueStore`` for event channels. + (`#831 `_) +* Spanner binds ``datetime.timedelta`` values and lists as ``INTERVAL``, + keeping day and microsecond precision and allowing null list elements. + Declare ``FLOAT32``, ``ARRAY``, or ``VECTOR`` parameters with + ``TypedParameter(value, semantic_name="FLOAT32")``. + (`#831 `_) +* Spanner runs SQL and Python migrations on both adapters. DDL statements go + through the database ``update_ddl`` schema API, scripts apply consecutive DDL + as one schema change, and a begun write transaction is committed before a + schema change. + (`#831 `_) + +**Changed:** + +* Spanner sync classes now use the ``SpannerSync*`` prefix used by other + adapters, and the old names are removed: ``SpannerExceptionHandler`` is now + ``SpannerSyncExceptionHandler``, ``SpannerDataDictionary`` is now + ``SpannerSyncDataDictionary``, ``SpannerConnectionContext`` is now + ``SpannerSyncConnectionContext``, ``SpannerSessionContext`` is now + ``SpannerSyncSessionContext``, and ``SpannerConnection`` is now + ``SpannerSyncConnection``. + (`#831 `_) + +**Fixed:** + +* Spanner write sessions, sync and async, commit on a clean exit and roll + back on an exception. After an explicit ``commit()`` or ``rollback()`` the + next statement runs in a new transaction instead of failing on the finished + one. A rollback also discards mutations buffered before the transaction + began, such as rows from ``load_from_arrow()``. + (`#831 `_) +* Spanner column DDL renders unsized text and binary columns as + ``STRING(MAX)`` and ``BYTES(MAX)``, and column defaults in the parentheses + Spanner requires. + (`#831 `_) +* Spanner DML with ``THEN RETURN`` (including ``WITH ACTION``) returns its + rows on both adapters instead of discarding them and reporting only a row + count. + (`#831 `_) +* Script splitting keeps semicolons inside backtick-quoted identifiers, which + split MySQL, BigQuery, and Spanner scripts in the wrong place. + (`#831 `_) +* Compiled wheels build again with mypy 2.4, which failed on loops over + ``reversed()``. + (`#831 `_) +* ``sqlspec`` imports on SQLGlot 30.13 through 30.17 again. The ``??`` JSONB + operator rendering is registered only on SQLGlot releases that define it. + (`#831 `_) + v0.65.0 - IBM Db2 adapter, dialect isolation, and native adapter hardening ------------------------------------------------------------------------- diff --git a/docs/extensions/adk/adapters.rst b/docs/extensions/adk/adapters.rst index e895c88ee..19f96db2a 100644 --- a/docs/extensions/adk/adapters.rst +++ b/docs/extensions/adk/adapters.rst @@ -17,10 +17,10 @@ Use async adapters for best performance with ADK runners: - **Oracle**: ``oracledb`` - **DuckDB**: ``duckdb`` (analytics; reduced-scope for ADK) - **ADBC**: ``adbc`` (Arrow-native portability; reduced-scope for ADK) -- **Spanner**: ``spanner`` (Google Cloud, globally distributed) +- **Spanner**: ``spanner`` (Google Cloud, globally distributed; supports both async and sync modes) - **SQL Server**: ``mssql_python`` (native Arrow and BulkCopy), ``pymssql`` (FreeTDS), ``arrow_odbc`` (Arrow over Microsoft ODBC Driver 18) -Sync adapters (``psycopg`` sync mode, ``sqlite``, ``mysqlconnector``, +Sync adapters (``psycopg`` sync mode, ``spanner`` sync mode, ``sqlite``, ``mysqlconnector``, ``pymysql``, ``mssql_python``, ``pymssql``, ``arrow_odbc``) work but require wrapping with ``anyio`` for async ADK runners. diff --git a/docs/extensions/adk/backends.rst b/docs/extensions/adk/backends.rst index 85fdb3dd2..b4520894c 100644 --- a/docs/extensions/adk/backends.rst +++ b/docs/extensions/adk/backends.rst @@ -112,7 +112,7 @@ The table below classifies every backend by its ADK support level. - Supported - Full - Full - - Google Cloud Spanner (cloud-managed). + - Google Cloud Spanner (cloud-managed; supports both sync and async modes). * - bigquery - Supported (Analytics) - Replay / Search @@ -329,7 +329,9 @@ Driver 18: Spanner ------- -Google Cloud Spanner provides globally distributed ADK storage: +Google Cloud Spanner provides globally distributed ADK storage in both native +async (``SpannerAsyncADKStore`` / ``SpannerAsyncADKMemoryStore``) and sync +(``SpannerSyncADKStore`` / ``SpannerSyncADKMemoryStore``) modes: - Cloud-managed, horizontally scalable. - Optional hash sharding via ``shard_count`` to reduce hot spots. diff --git a/docs/reference/adapters/index.rst b/docs/reference/adapters/index.rst index 3546a59be..c04e60635 100644 --- a/docs/reference/adapters/index.rst +++ b/docs/reference/adapters/index.rst @@ -83,7 +83,7 @@ exports a typed config class and a driver implementation. :link: spanner :link-type: doc - Google Cloud Spanner. + Sync + Async Google Cloud Spanner. .. grid-item-card:: CockroachDB + AsyncPG :link: cockroach_asyncpg @@ -289,9 +289,9 @@ means the parameter pipeline preserves the Python value for the driver. - Text * - spanner - Yes - - - Yes - - + - Yes + - Yes - - Yes - Native diff --git a/docs/reference/adapters/spanner.rst b/docs/reference/adapters/spanner.rst index 28f7403c6..960bd6a76 100644 --- a/docs/reference/adapters/spanner.rst +++ b/docs/reference/adapters/spanner.rst @@ -2,8 +2,8 @@ Spanner ======= -Google Cloud Spanner adapter using the Spanner client library with session -pool management. +Google Cloud Spanner adapter using the Spanner client library with sync and +async session pool management. Request And Session Controls ============================ @@ -13,7 +13,7 @@ expose public ``execute_with_options()``, ``execute_partitioned_dml()``, ``apply_mutations()``, or ``provide_batch_snapshot()`` methods. Default request controls can be configured through -``SpannerSyncConfig.driver_features``: +``SpannerSyncConfig.driver_features`` or ``SpannerAsyncConfig.driver_features``: ``request_options`` Forwarded to Spanner ``execute_sql()``, ``execute_update()``, and @@ -57,8 +57,8 @@ context or driver API. Session-Scoped Controls ======================= -``SpannerSyncConfig.provide_session()`` also accepts explicit Spanner controls -for the returned session context: +``SpannerSyncConfig.provide_session()`` and ``SpannerAsyncConfig.provide_session()`` +also accept explicit Spanner controls for the returned session context: .. code-block:: python @@ -69,16 +69,88 @@ for the returned session context: ) as driver: driver.execute("UPDATE orders SET status = @status WHERE id = @id", status="paid", id="o-1") + async with async_config.provide_session( + request_options={"transaction_tag": "orders.write"}, + retry=retry, + timeout=20.0, + ) as driver: + await driver.execute("UPDATE orders SET status = @status WHERE id = @id", status="paid", id="o-1") + The explicit ``provide_session()`` arguments are copied into the returned -driver's feature set and do not mutate ``config.driver_features``. They also do -not hide a ``database_provider`` feature for unrelated database-level methods. +driver's feature set and do not mutate ``config.driver_features``. ``provide_read_session()`` is the read-only helper for single-use snapshot reads. For DDL, DML, and write-capable transactions, use ``provide_session()`` or ``provide_write_session()``. -Configuration -============= +For read-write transactions with automatic ``Aborted`` retry semantics, use +``run_in_transaction()`` on either configuration or driver: + +.. code-block:: python + + async def transfer(driver: SpannerAsyncDriver, amount: int) -> None: + await driver.execute( + "UPDATE accounts SET balance = balance - @amount WHERE id = @id", + amount=amount, + id="a-1", + ) + + await async_config.run_in_transaction( + transfer, + 100, + transaction_tag="accounts.transfer", + ) + +Native Execution Controls +========================= + +``query_options`` can be configured on the driver, supplied when opening a +session, or overridden per call. They apply to queries and single DML operations; +native batch DML does not accept them. ``last_statement=True`` marks final +transaction DML, including only the final statement of a script. + +Opt-in Arrow Batch Write ingestion works from database-backed read sessions. +Mutation groups commit independently. Arrow overwrite retains transactional +delete-and-insert behavior without partitioned DML or Batch Write. + +Schema Changes And Migrations +============================= + +DDL statements (``CREATE``, ``ALTER``, ``DROP``, ``GRANT``, ``REVOKE``, +``RENAME``, and ``ANALYZE``) run through the database ``update_ddl`` schema API +instead of the session transaction. They run from write sessions; read +sessions reject them as they reject DML. Work the write transaction has already +begun is committed before the schema change, the way DDL implicitly commits on +MySQL or Oracle. +Consecutive DDL statements in a script are applied as one schema change. + +A write session keeps working after ``commit()`` or ``rollback()``: the next +statement runs in a new transaction on the same session. + +SQL and Python migrations run on both adapters. Spanner schema changes are not +transactional, so a failed migration can leave its earlier statements applied. +Every table needs a primary key, so write migration DDL in Spanner form: + +.. code-block:: sql + + -- name: migrate-0001-up + CREATE TABLE users ( + id INT64 NOT NULL, + name STRING(255) NOT NULL + ) PRIMARY KEY (id); + + -- name: migrate-0001-down + DROP TABLE IF EXISTS users; + +Parameter Types +=============== + +Both adapters bind ``datetime.timedelta`` parameters as Spanner ``INTERVAL`` +values, including arrays with null elements. Conversion preserves days and +microseconds without rounding through floating-point seconds. + +Sync Configuration +================== With ``google-cloud-spanner==3.71.0``, closing a database that used multiplexed sessions can wait up to ten minutes for the SDK's maintenance thread. The @@ -90,6 +162,17 @@ shutdown until a fixed SDK is available. :members: :show-inheritance: +Async Configuration +=================== + +An async write session commits on successful context exit and rolls back on an +exception. If you explicitly call ``await driver.commit()`` or +``await driver.rollback()``, context exit preserves that completed transaction. + +.. autoclass:: sqlspec.adapters.spanner.SpannerAsyncConfig + :members: + :show-inheritance: + Connection Parameters ===================== @@ -117,17 +200,63 @@ Custom Dialects Spanner uses the :doc:`Spanner and Spangres dialects <../dialects>` for SQL compilation. See the :doc:`Dialects <../dialects>` reference for details. -Driver -====== +Sync Driver +=========== .. autoclass:: sqlspec.adapters.spanner.SpannerSyncDriver :members: :show-inheritance: +Async Driver +============ + +.. autoclass:: sqlspec.adapters.spanner.SpannerAsyncDriver + :members: + :show-inheritance: + Data Dictionary =============== -.. autoclass:: sqlspec.adapters.spanner.data_dictionary.SpannerDataDictionary +.. autoclass:: sqlspec.adapters.spanner.data_dictionary.SpannerSyncDataDictionary + :members: + :show-inheritance: + +.. autoclass:: sqlspec.adapters.spanner.data_dictionary.SpannerAsyncDataDictionary + :members: + :show-inheritance: + +Extensions +========== + +.. autoclass:: sqlspec.adapters.spanner.adk.SpannerSyncADKStore + :members: + :show-inheritance: + +.. autoclass:: sqlspec.adapters.spanner.adk.SpannerAsyncADKStore + :members: + :show-inheritance: + +.. autoclass:: sqlspec.adapters.spanner.adk.SpannerSyncADKMemoryStore + :members: + :show-inheritance: + +.. autoclass:: sqlspec.adapters.spanner.adk.SpannerAsyncADKMemoryStore + :members: + :show-inheritance: + +.. autoclass:: sqlspec.adapters.spanner.litestar.SpannerSyncStore + :members: + :show-inheritance: + +.. autoclass:: sqlspec.adapters.spanner.litestar.SpannerAsyncStore + :members: + :show-inheritance: + +.. autoclass:: sqlspec.adapters.spanner.events.SpannerSyncEventQueueStore + :members: + :show-inheritance: + +.. autoclass:: sqlspec.adapters.spanner.events.SpannerAsyncEventQueueStore :members: :show-inheritance: @@ -148,15 +277,3 @@ namespace: ``"litestar"``, ``"events"``, or ``"adk"`` as supported by this adapt .. autoclass:: sqlspec.adapters.spanner.adk.SpannerADKRetentionConfig :members: :show-inheritance: - -Native execution controls -------------------------- - -``query_options`` can be configured on the driver, supplied when opening a -session, or overridden per call. They apply to queries and single DML operations; -native batch DML does not accept them. ``last_statement=True`` marks final -transaction DML, including only the final statement of a script. - -Opt-in Arrow Batch Write ingestion works from database-backed read sessions. -Mutation groups commit independently. Arrow overwrite retains transactional -delete-and-insert behavior without partitioned DML or Batch Write. diff --git a/docs/reference/driver.rst b/docs/reference/driver.rst index f3a36b922..0bf5a9014 100644 --- a/docs/reference/driver.rst +++ b/docs/reference/driver.rst @@ -243,8 +243,9 @@ together: ``sqlspec.adapters.adbc.data_dictionary.AdbcDataDictionary`` when the driver dialect is BigQuery. - DuckDB and Spanner currently have native-only adapter dictionaries: - ``sqlspec.adapters.duckdb.data_dictionary.DuckDBDataDictionary`` and - ``sqlspec.adapters.spanner.data_dictionary.SpannerDataDictionary``. + ``sqlspec.adapters.duckdb.data_dictionary.DuckDBDataDictionary``, + ``sqlspec.adapters.spanner.data_dictionary.SpannerSyncDataDictionary``, and + ``sqlspec.adapters.spanner.data_dictionary.SpannerAsyncDataDictionary``. Feature Flag Types ================== diff --git a/docs/usage/frameworks/litestar/session_stores.rst b/docs/usage/frameworks/litestar/session_stores.rst index 149b40b33..d99193201 100644 --- a/docs/usage/frameworks/litestar/session_stores.rst +++ b/docs/usage/frameworks/litestar/session_stores.rst @@ -38,7 +38,7 @@ from :class:`~sqlspec.extensions.litestar.store.BaseSQLSpecStore`: - **SQL Server / ODBC**: ``sqlspec.adapters.arrow_odbc.litestar.ArrowOdbcStore``, ``sqlspec.adapters.pymssql.litestar.PymssqlStore``, and ``sqlspec.adapters.mssql_python.litestar.MssqlPythonStore`` - **CockroachDB**: ``sqlspec.adapters.cockroach_asyncpg.litestar.CockroachAsyncpgStore`` - **BigQuery**: ``sqlspec.adapters.bigquery.litestar.BigQueryStore`` -- **Spanner**: ``sqlspec.adapters.spanner.litestar.SpannerSyncStore`` +- **Spanner**: ``sqlspec.adapters.spanner.litestar.SpannerAsyncStore`` and ``sqlspec.adapters.spanner.litestar.SpannerSyncStore`` - **Oracle**: ``sqlspec.adapters.oracledb.litestar.OracleAsyncStore`` and ``sqlspec.adapters.oracledb.litestar.OracleSyncStore`` - **Arrow / ADBC**: ``sqlspec.adapters.adbc.litestar.ADBCStore`` diff --git a/pyproject.toml b/pyproject.toml index 86d58b865..d63bacb4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,7 +72,7 @@ pydantic = ["pydantic", "pydantic-extra-types"] pymssql = ["pymssql"] pymysql = ["pymysql"] sanic = ["sanic"] -spanner = ["google-cloud-spanner"] +spanner = ["google-cloud-spanner>=3.64.0"] starlette = ["starlette"] uuid = ["uuid-utils>=0.17.0"] diff --git a/sqlspec/adapters/arrow_odbc/driver.py b/sqlspec/adapters/arrow_odbc/driver.py index fbf5748b9..580dacadc 100644 --- a/sqlspec/adapters/arrow_odbc/driver.py +++ b/sqlspec/adapters/arrow_odbc/driver.py @@ -574,7 +574,7 @@ def _inline_mssql_pagination_parameters( if not consumed: return sql, parameters - for position, value in reversed(replacements): + for position, value in replacements[::-1]: sql = sql[:position] + value + sql[position + 1 :] return sql, [value for index, value in enumerate(parameters) if index not in consumed] diff --git a/sqlspec/adapters/spanner/__init__.py b/sqlspec/adapters/spanner/__init__.py index 3a7e23415..0134bb9b8 100644 --- a/sqlspec/adapters/spanner/__init__.py +++ b/sqlspec/adapters/spanner/__init__.py @@ -1,13 +1,26 @@ """Google Cloud Spanner Adapter.""" +from sqlspec.adapters.spanner._typing import ( + SpannerAsyncConnection, + SpannerAsyncCursor, + SpannerSyncConnection, + SpannerSyncCursor, +) from sqlspec.adapters.spanner.config import ( + SpannerAsyncConfig, SpannerConnectionParams, SpannerDriverFeatures, SpannerPoolParams, SpannerSyncConfig, build_connection_config, ) -from sqlspec.adapters.spanner.driver import SpannerSyncDriver +from sqlspec.adapters.spanner.core import default_statement_config +from sqlspec.adapters.spanner.driver import ( + SpannerAsyncDriver, + SpannerAsyncExceptionHandler, + SpannerSyncDriver, + SpannerSyncExceptionHandler, +) from sqlspec.adapters.spanner.type_converter import ( bytes_to_spanner, coerce_params_for_spanner, @@ -19,14 +32,23 @@ ) __all__ = ( + "SpannerAsyncConfig", + "SpannerAsyncConnection", + "SpannerAsyncCursor", + "SpannerAsyncDriver", + "SpannerAsyncExceptionHandler", "SpannerConnectionParams", "SpannerDriverFeatures", "SpannerPoolParams", "SpannerSyncConfig", + "SpannerSyncConnection", + "SpannerSyncCursor", "SpannerSyncDriver", + "SpannerSyncExceptionHandler", "build_connection_config", "bytes_to_spanner", "coerce_params_for_spanner", + "default_statement_config", "infer_spanner_param_types", "spanner_json", "spanner_to_bytes", diff --git a/sqlspec/adapters/spanner/_typing.py b/sqlspec/adapters/spanner/_typing.py index dad16d15c..b57e3138a 100644 --- a/sqlspec/adapters/spanner/_typing.py +++ b/sqlspec/adapters/spanner/_typing.py @@ -18,8 +18,12 @@ class _UnavailableSpannerTransaction: """Fallback Spanner transaction class when google-cloud-spanner is unavailable.""" +class _UnavailableSpannerAsyncTransaction: + """Fallback Spanner async transaction class when google-cloud-spanner is unavailable.""" + + if TYPE_CHECKING: - from collections.abc import Callable + from collections.abc import Awaitable, Callable from types import TracebackType from typing import TypeAlias @@ -37,6 +41,22 @@ class _UnavailableSpannerTransaction: from google.cloud.spanner_v1 import ExecuteSqlRequest as SpannerExecuteSqlRequest from google.cloud.spanner_v1 import RequestOptions as SpannerRequestOptions from google.cloud.spanner_v1 import param_types as spanner_param_types + from google.cloud.spanner_v1._async.batch import Batch as SpannerAsyncBatch + from google.cloud.spanner_v1._async.batch import MutationGroups as SpannerAsyncMutationGroups + from google.cloud.spanner_v1._async.client import Client as SpannerAsyncClient + from google.cloud.spanner_v1._async.database import Database as SpannerAsyncDatabase + from google.cloud.spanner_v1._async.database import SnapshotCheckout as SpannerAsyncSnapshotCheckout + from google.cloud.spanner_v1._async.database_sessions_manager import TransactionType as SpannerAsyncTransactionType + from google.cloud.spanner_v1._async.pool import AbstractSessionPool as SpannerAsyncAbstractSessionPool + from google.cloud.spanner_v1._async.pool import BurstyPool as SpannerAsyncBurstyPool + from google.cloud.spanner_v1._async.pool import FixedSizePool as SpannerAsyncFixedSizePool + from google.cloud.spanner_v1._async.pool import PingingPool as SpannerAsyncPingingPool + from google.cloud.spanner_v1._async.pool import TransactionPingingPool as SpannerAsyncTransactionPingingPool + from google.cloud.spanner_v1._async.session import Session as SpannerAsyncSession + from google.cloud.spanner_v1._async.snapshot import Snapshot as SpannerAsyncSnapshot + from google.cloud.spanner_v1._async.streamed import StreamedResultSet as SpannerAsyncStreamedResultSet + from google.cloud.spanner_v1._async.transaction import Transaction as _SpannerAsyncTransaction + from google.cloud.spanner_v1.data_types import Interval as SpannerInterval from google.cloud.spanner_v1.data_types import JsonObject as SpannerJsonObject from google.cloud.spanner_v1.database import Database as SpannerDatabase from google.cloud.spanner_v1.database import SnapshotCheckout @@ -45,21 +65,27 @@ class _UnavailableSpannerTransaction: from google.cloud.spanner_v1.pool import BurstyPool as SpannerBurstyPool from google.cloud.spanner_v1.pool import FixedSizePool as SpannerFixedSizePool from google.cloud.spanner_v1.pool import PingingPool as SpannerPingingPool + from google.cloud.spanner_v1.pool import TransactionPingingPool as SpannerTransactionPingingPool from google.cloud.spanner_v1.snapshot import Snapshot from google.cloud.spanner_v1.transaction import DefaultTransactionOptions as SpannerDefaultTransactionOptions from google.cloud.spanner_v1.transaction import Transaction as _SpannerTransaction from google.cloud.spanner_v1.types.type import TypeCode as SpannerTypeCode - from sqlspec.adapters.spanner.driver import SpannerSyncDriver + from sqlspec.adapters.spanner.driver import SpannerAsyncDriver, SpannerSyncDriver from sqlspec.core import StatementConfig - SpannerConnection: TypeAlias = Snapshot | SnapshotCheckout | _SpannerTransaction + SpannerSyncConnection: TypeAlias = Snapshot | SnapshotCheckout | _SpannerTransaction + SpannerAsyncConnection: TypeAlias = ( + SpannerAsyncSnapshot | SpannerAsyncSnapshotCheckout | _SpannerAsyncTransaction | SpannerAsyncBatch + ) SpannerGoogleAPICallError: TypeAlias = _SpannerGoogleAPICallError SpannerTransaction: TypeAlias = _SpannerTransaction + SpannerAsyncTransaction: TypeAlias = _SpannerAsyncTransaction if not TYPE_CHECKING: - SpannerConnection = Any + SpannerSyncConnection = Any + SpannerAsyncConnection = Any SpannerGoogleAPICallError = ( import_optional_attr("google.api_core.exceptions", "GoogleAPICallError") or _UnavailableSpannerGoogleAPICallError @@ -67,15 +93,35 @@ class _UnavailableSpannerTransaction: SpannerTransaction = ( import_optional_attr("google.cloud.spanner_v1.transaction", "Transaction") or _UnavailableSpannerTransaction ) + SpannerAsyncTransaction = ( + import_optional_attr("google.cloud.spanner_v1._async.transaction", "Transaction") + or _UnavailableSpannerAsyncTransaction + ) __all__ = ( "SpannerAbstractSessionPool", + "SpannerAsyncAbstractSessionPool", + "SpannerAsyncBatch", + "SpannerAsyncBurstyPool", + "SpannerAsyncClient", + "SpannerAsyncConnection", + "SpannerAsyncCursor", + "SpannerAsyncDatabase", + "SpannerAsyncFixedSizePool", + "SpannerAsyncMutationGroups", + "SpannerAsyncPingingPool", + "SpannerAsyncSession", + "SpannerAsyncSessionContext", + "SpannerAsyncSnapshot", + "SpannerAsyncStreamedResultSet", + "SpannerAsyncTransaction", + "SpannerAsyncTransactionPingingPool", + "SpannerAsyncTransactionType", "SpannerBurstyPool", "SpannerClient", "SpannerClientInfo", "SpannerClientOptions", - "SpannerConnection", "SpannerCredentials", "SpannerDatabase", "SpannerDatabaseDialect", @@ -85,14 +131,17 @@ class _UnavailableSpannerTransaction: "SpannerExecuteSqlRequest", "SpannerFixedSizePool", "SpannerGoogleAPICallError", + "SpannerInterval", "SpannerJsonObject", "SpannerNotFound", "SpannerPingingPool", "SpannerRequestOptions", "SpannerRetry", - "SpannerSessionContext", + "SpannerSyncConnection", "SpannerSyncCursor", + "SpannerSyncSessionContext", "SpannerTransaction", + "SpannerTransactionPingingPool", "SpannerTransactionType", "SpannerTypeCode", "spanner_exceptions", @@ -105,31 +154,40 @@ class SpannerSyncCursor: __slots__ = ("connection",) - def __init__(self, connection: "SpannerConnection") -> None: + def __init__(self, connection: "SpannerSyncConnection") -> None: self.connection = connection - def __enter__(self) -> "SpannerConnection": + def __enter__(self) -> "SpannerSyncConnection": return self.connection def __exit__(self, *_: Any) -> None: return None -class SpannerSessionContext: - """Sync context manager for Spanner sessions. +class SpannerAsyncCursor: + """Async context manager that yields the active Spanner async connection.""" + + __slots__ = ("connection",) - This class is intentionally excluded from mypyc compilation to avoid ABI - boundary issues. It receives callables from uncompiled config classes and - instantiates compiled Driver objects, acting as a bridge between compiled - and uncompiled code. + def __init__(self, connection: "SpannerAsyncConnection") -> None: + self.connection = connection + + async def __aenter__(self) -> "SpannerAsyncConnection": + return self.connection + + async def __aexit__( + self, exc_type: "type[BaseException] | None", exc_val: "BaseException | None", exc_tb: "TracebackType | None" + ) -> None: + return None - Note: This context manager receives a pre-configured connection context - that already has the transaction flag set. The config.provide_session() - creates the connection context with the appropriate transaction setting. - Uses callable-based connection management to decouple from config implementation. +class SpannerSyncSessionContext: + """Sync context manager for Spanner sessions. - Spanner requires exception info in release_connection for commit/rollback decisions. + Excluded from mypyc compilation: it receives callables from uncompiled + config classes and instantiates compiled driver objects. The release + callable receives exception details so the connection context can commit + or roll back the active transaction. """ __slots__ = ( @@ -171,15 +229,87 @@ def __exit__( self, exc_type: "type[BaseException] | None", exc_val: "BaseException | None", exc_tb: "TracebackType | None" ) -> "bool | None": if self._connection is not None: - self._release_connection(self._connection, exc_type=exc_type, exc_val=exc_val, exc_tb=exc_tb) + connection = self._driver.connection if self._driver is not None else self._connection + self._release_connection(connection, exc_type=exc_type, exc_val=exc_val, exc_tb=exc_tb) + self._connection = None + self._driver = None + return None + + +class SpannerAsyncSessionContext: + """Async context manager for Spanner sessions. + + Excluded from mypyc compilation: it receives callables from uncompiled + config classes and instantiates compiled driver objects. The release + callable receives exception details so the connection context can commit + or roll back the active transaction. + """ + + __slots__ = ( + "_acquire_connection", + "_connection", + "_driver", + "_driver_features", + "_prepare_driver", + "_release_connection", + "_statement_config", + ) + + def __init__( + self, + acquire_connection: "Callable[[], Awaitable[Any]]", + release_connection: "Callable[..., Awaitable[Any]]", + statement_config: "StatementConfig", + driver_features: "dict[str, Any]", + prepare_driver: "Callable[[SpannerAsyncDriver], SpannerAsyncDriver]", + ) -> None: + self._acquire_connection = acquire_connection + self._release_connection = release_connection + self._statement_config = statement_config + self._driver_features = driver_features + self._prepare_driver = prepare_driver + self._connection: Any = None + self._driver: SpannerAsyncDriver | None = None + + async def __aenter__(self) -> "SpannerAsyncDriver": + from sqlspec.adapters.spanner.driver import SpannerAsyncDriver + + self._connection = await self._acquire_connection() + self._driver = SpannerAsyncDriver( + connection=self._connection, statement_config=self._statement_config, driver_features=self._driver_features + ) + return self._prepare_driver(self._driver) + + async def __aexit__( + self, exc_type: "type[BaseException] | None", exc_val: "BaseException | None", exc_tb: "TracebackType | None" + ) -> "bool | None": + if self._connection is not None: + connection = self._driver.connection if self._driver is not None else self._connection + await self._release_connection(connection, exc_type=exc_type, exc_val=exc_val, exc_tb=exc_tb) self._connection = None + self._driver = None return None _LAZY_DRIVER_EXPORTS: dict[str, tuple[str, str]] = { + "SpannerAsyncTransactionType": ("google.cloud.spanner_v1._async.database_sessions_manager", "TransactionType"), + "SpannerInterval": ("google.cloud.spanner_v1.data_types", "Interval"), + "SpannerAsyncAbstractSessionPool": ("google.cloud.spanner_v1._async.pool", "AbstractSessionPool"), + "SpannerAsyncBatch": ("google.cloud.spanner_v1._async.batch", "Batch"), + "SpannerAsyncBurstyPool": ("google.cloud.spanner_v1._async.pool", "BurstyPool"), + "SpannerAsyncClient": ("google.cloud.spanner_v1._async.client", "Client"), + "SpannerAsyncDatabase": ("google.cloud.spanner_v1._async.database", "Database"), + "SpannerAsyncFixedSizePool": ("google.cloud.spanner_v1._async.pool", "FixedSizePool"), + "SpannerAsyncMutationGroups": ("google.cloud.spanner_v1._async.batch", "MutationGroups"), + "SpannerAsyncPingingPool": ("google.cloud.spanner_v1._async.pool", "PingingPool"), + "SpannerAsyncSession": ("google.cloud.spanner_v1._async.session", "Session"), + "SpannerAsyncSnapshot": ("google.cloud.spanner_v1._async.snapshot", "Snapshot"), + "SpannerAsyncStreamedResultSet": ("google.cloud.spanner_v1._async.streamed", "StreamedResultSet"), + "SpannerAsyncTransactionPingingPool": ("google.cloud.spanner_v1._async.pool", "TransactionPingingPool"), "SpannerBurstyPool": ("google.cloud.spanner_v1.pool", "BurstyPool"), "SpannerFixedSizePool": ("google.cloud.spanner_v1.pool", "FixedSizePool"), "SpannerPingingPool": ("google.cloud.spanner_v1.pool", "PingingPool"), + "SpannerTransactionPingingPool": ("google.cloud.spanner_v1.pool", "TransactionPingingPool"), "spanner_exceptions": ("google.api_core", "exceptions"), "SpannerNotFound": ("google.api_core.exceptions", "NotFound"), "SpannerClient": ("google.cloud.spanner_v1", "Client"), diff --git a/sqlspec/adapters/spanner/adk/__init__.py b/sqlspec/adapters/spanner/adk/__init__.py index aefd364b9..cd11be9db 100644 --- a/sqlspec/adapters/spanner/adk/__init__.py +++ b/sqlspec/adapters/spanner/adk/__init__.py @@ -3,8 +3,17 @@ from sqlspec.adapters.spanner.adk.store import ( SpannerADKConfig, SpannerADKRetentionConfig, + SpannerAsyncADKMemoryStore, + SpannerAsyncADKStore, SpannerSyncADKMemoryStore, SpannerSyncADKStore, ) -__all__ = ("SpannerADKConfig", "SpannerADKRetentionConfig", "SpannerSyncADKMemoryStore", "SpannerSyncADKStore") +__all__ = ( + "SpannerADKConfig", + "SpannerADKRetentionConfig", + "SpannerAsyncADKMemoryStore", + "SpannerAsyncADKStore", + "SpannerSyncADKMemoryStore", + "SpannerSyncADKStore", +) diff --git a/sqlspec/adapters/spanner/adk/store.py b/sqlspec/adapters/spanner/adk/store.py index c2862f18d..f5d7e3f20 100644 --- a/sqlspec/adapters/spanner/adk/store.py +++ b/sqlspec/adapters/spanner/adk/store.py @@ -11,24 +11,48 @@ import sqlspec.dialects.spanner # noqa: F401 from sqlspec.adapters.spanner._typing import SpannerNotFound as NotFound from sqlspec.adapters.spanner._typing import spanner_param_types as param_types -from sqlspec.adapters.spanner.config import SpannerSyncConfig +from sqlspec.adapters.spanner.config import SpannerAsyncConfig, SpannerSyncConfig +from sqlspec.adapters.spanner.core import ( + execute_ddl_async, + execute_ddl_sync, + list_existing_table_names_async, + list_existing_table_names_sync, +) from sqlspec.config import ADKConfig from sqlspec.exceptions import OperationalError -from sqlspec.extensions.adk import BaseSyncADKStore, StoredEvent, StoredSession, normalize_session_list_options -from sqlspec.extensions.adk.memory.store import BaseSyncADKMemoryStore +from sqlspec.extensions.adk import ( + BaseAsyncADKStore, + BaseSyncADKStore, + StoredEvent, + StoredSession, + normalize_session_list_options, +) +from sqlspec.extensions.adk.memory.store import BaseAsyncADKMemoryStore, BaseSyncADKMemoryStore from sqlspec.protocols import SpannerParamTypesProtocol from sqlspec.utils.serializers import from_json, to_json if TYPE_CHECKING: from collections.abc import Sequence - from sqlspec.adapters.spanner._typing import SpannerDatabase as Database - from sqlspec.adapters.spanner._typing import SpannerTransaction as Transaction + from sqlspec.adapters.spanner._typing import ( + SpannerAsyncDatabase, + SpannerAsyncTransaction, + SpannerDatabase, + SpannerTransaction, + ) from sqlspec.extensions.adk import SessionOrderBy, StoredMemory -__all__ = ("SpannerADKConfig", "SpannerADKRetentionConfig", "SpannerSyncADKMemoryStore", "SpannerSyncADKStore") +__all__ = ( + "SpannerADKConfig", + "SpannerADKRetentionConfig", + "SpannerAsyncADKMemoryStore", + "SpannerAsyncADKStore", + "SpannerSyncADKMemoryStore", + "SpannerSyncADKStore", +) SPANNER_PARAM_TYPES: SpannerParamTypesProtocol = cast("SpannerParamTypesProtocol", param_types) +_DDL_TIMEOUT_SECONDS = 300 class SpannerADKRetentionConfig(TypedDict): @@ -66,167 +90,25 @@ class SpannerADKConfig(ADKConfig): """Spanner row-deletion retention policy settings.""" -class SpannerSyncADKStore(BaseSyncADKStore[SpannerSyncConfig]): - """Spanner ADK store backed by synchronous Spanner client.""" - - connector_name: ClassVar[str] = "spanner" - - def __init__(self, config: SpannerSyncConfig) -> None: - super().__init__(config) - adk_config = _adk_config(config) - self._shard_count: int = int(adk_config.get("shard_count", 0)) if adk_config.get("shard_count") else 0 - self._session_table_options: str | None = adk_config.get("session_table_options") - self._events_table_options: str | None = adk_config.get("events_table_options") - self._expires_index_options: str | None = adk_config.get("expires_index_options") - self._session_row_deletion_policy = _spanner_row_deletion_policy( - adk_config, "session_ttl_seconds", "create_time" - ) - self._events_row_deletion_policy = _spanner_row_deletion_policy(adk_config, "event_ttl_seconds", "timestamp") - - def create_tables(self) -> None: - """Create tables if they don't exist.""" - if not self.create_schema_enabled: - self.reconcile_schema() - return - - self._create_tables() - - def create_session( - self, session_id: str, app_name: str, user_id: str, state: "dict[str, Any]", owner_id: "Any | None" = None - ) -> StoredSession: - """Create a new session.""" - return self._create_session(session_id, app_name, user_id, state, owner_id) - - def get_session( - self, app_name: str, user_id: str, session_id: str, *, renew_for: "int | timedelta | None" = None - ) -> "StoredSession | None": - """Get session by ID.""" - try: - return self._get_session(app_name, user_id, session_id, renew_for=renew_for) - except NotFound as exc: - if _is_spanner_table_missing(exc, self._session_table): - return None - raise - - def update_session_state(self, app_name: str, user_id: str, session_id: str, state: "dict[str, Any]") -> None: - """Update session state.""" - self._update_session_state(app_name, user_id, session_id, state) - - def list_sessions( - self, - app_name: str, - user_id: "str | None" = None, - *, - order_by: "SessionOrderBy" = "update_time", - descending: bool = True, - limit: "int | None" = None, - offset: "int | None" = None, - ) -> "list[StoredSession]": - """List sessions for an app.""" - try: - return self._list_sessions( - app_name, user_id, order_by=order_by, descending=descending, limit=limit, offset=offset - ) - except NotFound as exc: - if _is_spanner_table_missing(exc, self._session_table): - return [] - raise - - def delete_session(self, app_name: str, user_id: str, session_id: str) -> None: - """Delete session and associated events.""" - self._delete_session(app_name, user_id, session_id) - - def append_event(self, event_record: StoredEvent) -> None: - """Append an event to a session.""" - self._append_event(event_record) - - def append_event_and_update_state( - self, - event_record: StoredEvent, - app_name: str, - user_id: str, - session_id: str, - state: "dict[str, Any]", - *, - app_state: "dict[str, Any] | None" = None, - user_state: "dict[str, Any] | None" = None, - ) -> StoredSession: - """Atomically append an event and update the session's durable state.""" - return self._append_event_and_update_state( - event_record, app_name, user_id, session_id, state, app_state=app_state, user_state=user_state - ) - - def get_events( - self, - app_name: str, - user_id: str, - session_id: str, - after_timestamp: "datetime | None" = None, - limit: "int | None" = None, - ) -> "list[StoredEvent]": - """Get events for a session.""" - try: - return self._get_events(app_name, user_id, session_id, after_timestamp, limit) - except NotFound as exc: - if _is_spanner_table_missing(exc, self._events_table, self._session_table): - return [] - raise - - def delete_expired_events(self, before: datetime, app_name: "str | None" = None) -> int: - """Delete events older than a timestamp, optionally scoped to one application.""" - return self._delete_expired_events(before, app_name) - - def delete_idle_sessions(self, updated_before: datetime, app_name: "str | None" = None) -> int: - """Delete sessions older than a timestamp, optionally scoped to one application.""" - return self._delete_idle_sessions(updated_before, app_name) - - def delete_idle_user_states(self, updated_before: datetime, app_name: "str | None" = None) -> int: - """Delete user-scoped state rows older than a timestamp, optionally scoped to one application.""" - return self._delete_idle_user_states(updated_before, app_name) - - def get_app_state(self, app_name: str) -> "dict[str, Any] | None": - """Return app-scoped state.""" - return self._get_app_state(app_name) - - def get_user_state(self, app_name: str, user_id: str) -> "dict[str, Any] | None": - """Return user-scoped state.""" - return self._get_user_state(app_name, user_id) - - def upsert_app_state(self, app_name: str, state: "dict[str, Any]") -> None: - """Insert or replace app-scoped state.""" - self._upsert_app_state(app_name, state) - - def upsert_user_state(self, app_name: str, user_id: str, state: "dict[str, Any]") -> None: - """Insert or replace user-scoped state.""" - self._upsert_user_state(app_name, user_id, state) - - def get_metadata(self, key: str) -> "str | None": - """Return a metadata value.""" - return self._get_metadata(key) +class _SpannerADKStoreMixin: + """Shared SQL, DDL, parameter type, and decoding helpers for Spanner ADK stores.""" - def set_metadata(self, key: str, value: str) -> None: - """Set a metadata value.""" - self._set_metadata(key, value) - - def _database(self) -> "Database": - return self._config.get_database() - - def _reset_drop_tables_sql(self) -> "list[str]": - return _filter_existing_spanner_drops(super()._reset_drop_tables_sql(), self._existing_tables()) - - def _existing_tables(self) -> "set[str]": - database = self._database() - return {table.table_id for table in database.list_tables()} # type: ignore[no-untyped-call] - - def _run_read( - self, sql: str, params: "dict[str, Any] | None" = None, types: "dict[str, Any] | None" = None - ) -> "list[Any]": - with self._config.provide_connection() as snapshot: - result_set = cast("Any", snapshot).execute_sql(sql, params=params, param_types=types) - return list(result_set) + __slots__ = () - def _run_write(self, statements: "list[tuple[str, dict[str, Any], dict[str, Any]]]") -> None: - self._database().run_in_transaction(_SpannerWriteJob(statements)) # type: ignore[no-untyped-call] + if TYPE_CHECKING: + _session_table: str + _events_table: str + _app_state_table: str + _user_state_table: str + _metadata_table: str + _owner_id_column_ddl: str | None + _owner_id_column_name: str | None + _shard_count: int + _session_table_options: str | None + _events_table_options: str | None + _expires_index_options: str | None + _session_row_deletion_policy: str + _events_row_deletion_policy: str def _session_param_types(self, include_owner: bool) -> "dict[str, Any]": json_type = _json_param_type() @@ -277,9 +159,9 @@ def _decode_json(self, raw: Any) -> Any: return from_json(raw) return raw - def _create_session( + def _build_create_session_statement( self, session_id: str, app_name: str, user_id: str, state: "dict[str, Any]", owner_id: "Any | None" = None - ) -> StoredSession: + ) -> "tuple[str, dict[str, Any], dict[str, Any]]": state_json = to_json(state) params: dict[str, Any] = {"id": session_id, "app_name": app_name, "user_id": user_id, "state": state_json} columns = "id, app_name, user_id, state, create_time, update_time" @@ -295,40 +177,31 @@ def _create_session( INSERT INTO {self._session_table} ({columns}) VALUES ({values}) """ - self._run_write([(sql, params, self._session_param_types(self._owner_id_column_name is not None))]) - - return { - "id": session_id, - "app_name": app_name, - "user_id": user_id, - "state": state, - "create_time": datetime.now(timezone.utc), - "update_time": datetime.now(timezone.utc), - } + return (sql, params, self._session_param_types(self._owner_id_column_name is not None)) - def _get_session( - self, app_name: str, user_id: str, session_id: str, *, renew_for: "int | timedelta | None" = None - ) -> "StoredSession | None": - if renew_for is not None and self._calculate_expires_at(renew_for) is not None: - update_sql = f""" - UPDATE {self._session_table} - SET update_time = PENDING_COMMIT_TIMESTAMP() - WHERE app_name = @app_name AND user_id = @user_id AND id = @id - """ - if self._shard_count > 1: - update_sql = f"{update_sql} AND shard_id = MOD(FARM_FINGERPRINT(@id), {self._shard_count})" - self._run_write([ - ( - update_sql, - {"app_name": app_name, "user_id": user_id, "id": session_id}, - { - "app_name": SPANNER_PARAM_TYPES.STRING, - "user_id": SPANNER_PARAM_TYPES.STRING, - "id": SPANNER_PARAM_TYPES.STRING, - }, - ) - ]) + def _build_renew_session_statement( + self, app_name: str, user_id: str, session_id: str + ) -> "tuple[str, dict[str, Any], dict[str, Any]]": + update_sql = f""" + UPDATE {self._session_table} + SET update_time = PENDING_COMMIT_TIMESTAMP() + WHERE app_name = @app_name AND user_id = @user_id AND id = @id + """ + if self._shard_count > 1: + update_sql = f"{update_sql} AND shard_id = MOD(FARM_FINGERPRINT(@id), {self._shard_count})" + return ( + update_sql, + {"app_name": app_name, "user_id": user_id, "id": session_id}, + { + "app_name": SPANNER_PARAM_TYPES.STRING, + "user_id": SPANNER_PARAM_TYPES.STRING, + "id": SPANNER_PARAM_TYPES.STRING, + }, + ) + def _build_get_session_query( + self, app_name: str, user_id: str, session_id: str + ) -> "tuple[str, dict[str, Any], dict[str, Any]]": sql = f""" SELECT id, app_name, user_id, state, create_time, update_time{", " + self._owner_id_column_name if self._owner_id_column_name else ""} FROM {self._session_table} @@ -338,21 +211,16 @@ def _get_session( sql = f"{sql} AND shard_id = MOD(FARM_FINGERPRINT(@id), {self._shard_count})" sql = f"{sql} LIMIT 1" params = {"app_name": app_name, "user_id": user_id, "id": session_id} - rows = self._run_read( - sql, - params, - { - "app_name": SPANNER_PARAM_TYPES.STRING, - "user_id": SPANNER_PARAM_TYPES.STRING, - "id": SPANNER_PARAM_TYPES.STRING, - }, - ) - if not rows: - return None + types = { + "app_name": SPANNER_PARAM_TYPES.STRING, + "user_id": SPANNER_PARAM_TYPES.STRING, + "id": SPANNER_PARAM_TYPES.STRING, + } + return (sql, params, types) - row = rows[0] + def _decode_session_row(self, row: Any) -> StoredSession: state_value = self._decode_state(row[3]) - record: StoredSession = { + return { "id": row[0], "app_name": row[1], "user_id": row[2], @@ -360,9 +228,10 @@ def _get_session( "create_time": row[4], "update_time": row[5], } - return record - def _update_session_state(self, app_name: str, user_id: str, session_id: str, state: "dict[str, Any]") -> None: + def _build_update_session_state_statement( + self, app_name: str, user_id: str, session_id: str, state: "dict[str, Any]" + ) -> "tuple[str, dict[str, Any], dict[str, Any]]": params = {"app_name": app_name, "user_id": user_id, "id": session_id, "state": to_json(state)} json_type = _json_param_type() sql = f""" @@ -372,20 +241,18 @@ def _update_session_state(self, app_name: str, user_id: str, session_id: str, st """ if self._shard_count > 1: sql = f"{sql} AND shard_id = MOD(FARM_FINGERPRINT(@id), {self._shard_count})" - self._run_write([ - ( - sql, - params, - { - "app_name": SPANNER_PARAM_TYPES.STRING, - "user_id": SPANNER_PARAM_TYPES.STRING, - "id": SPANNER_PARAM_TYPES.STRING, - "state": json_type, - }, - ) - ]) + return ( + sql, + params, + { + "app_name": SPANNER_PARAM_TYPES.STRING, + "user_id": SPANNER_PARAM_TYPES.STRING, + "id": SPANNER_PARAM_TYPES.STRING, + "state": json_type, + }, + ) - def _list_sessions( + def _build_list_sessions_query( self, app_name: str, user_id: "str | None" = None, @@ -394,10 +261,10 @@ def _list_sessions( descending: bool = True, limit: "int | None" = None, offset: "int | None" = None, - ) -> "list[StoredSession]": + ) -> "tuple[str, dict[str, Any], dict[str, Any]] | None": column, direction, page_limit, page_offset = normalize_session_list_options(order_by, descending, limit, offset) if page_limit == 0: - return [] + return None sql = f""" SELECT id, app_name, user_id, state, create_time, update_time{", " + self._owner_id_column_name if self._owner_id_column_name else ""} @@ -419,23 +286,11 @@ def _list_sessions( params["offset"] = page_offset types["limit"] = SPANNER_PARAM_TYPES.INT64 types["offset"] = SPANNER_PARAM_TYPES.INT64 + return (sql, params, types) - rows = self._run_read(sql, params, types) - records: list[StoredSession] = [] - for row in rows: - state_value = self._decode_state(row[3]) - record: StoredSession = { - "id": row[0], - "app_name": row[1], - "user_id": row[2], - "state": state_value, - "create_time": row[4], - "update_time": row[5], - } - records.append(record) - return records - - def _delete_session(self, app_name: str, user_id: str, session_id: str) -> None: + def _build_delete_session_statements( + self, app_name: str, user_id: str, session_id: str + ) -> "list[tuple[str, dict[str, Any], dict[str, Any]]]": shard_clause = ( f" AND shard_id = MOD(FARM_FINGERPRINT(@session_id), {self._shard_count})" if self._shard_count > 1 else "" ) @@ -447,36 +302,11 @@ def _delete_session(self, app_name: str, user_id: str, session_id: str) -> None: "user_id": SPANNER_PARAM_TYPES.STRING, "session_id": SPANNER_PARAM_TYPES.STRING, } - self._run_write([(delete_events_sql, params, types), (delete_session_sql, params, types)]) + return [(delete_events_sql, params, types), (delete_session_sql, params, types)] - def _append_event_and_update_state( - self, - event_record: "StoredEvent", - app_name: str, - user_id: str, - session_id: str, - state: "dict[str, Any]", - *, - app_state: "dict[str, Any] | None" = None, - user_state: "dict[str, Any] | None" = None, - ) -> StoredSession: - """Atomically insert an event and update session state in one transaction. - - Both the event INSERT and the session state UPDATE execute within a single - Spanner transaction so they succeed or fail together. A follow-up - single-use read returns the StoredSession; we can't capture update_time - inside the write txn because PENDING_COMMIT_TIMESTAMP() only materialises - on commit. - - Args: - event_record: Event record to store. - app_name: Application name. - user_id: User identifier. - session_id: Session whose state should be updated. - state: Post-append durable state snapshot. - app_state: Optional app-scoped state snapshot. - user_state: Optional user-scoped state snapshot. - """ + def _build_insert_event_statement( + self, event_record: "StoredEvent" + ) -> "tuple[str, dict[str, Any], dict[str, Any]]": event_params: dict[str, Any] = { "id": event_record["id"], "app_name": event_record["app_name"], @@ -490,88 +320,39 @@ def _append_event_and_update_state( INSERT INTO {self._events_table} (id, app_name, user_id, session_id, invocation_id, timestamp, event_data) VALUES (@id, @app_name, @user_id, @session_id, @invocation_id, @timestamp, @event_data) """ + return (insert_sql, event_params, self._event_param_types()) - json_type = _json_param_type() - state_params: dict[str, Any] = { - "app_name": app_name, - "user_id": user_id, - "id": session_id, - "state": to_json(state), - } - update_sql = f""" - UPDATE {self._session_table} - SET state = @state, update_time = PENDING_COMMIT_TIMESTAMP() - WHERE app_name = @app_name AND user_id = @user_id AND id = @id - """ - if self._shard_count > 1: - update_sql = f"{update_sql} AND shard_id = MOD(FARM_FINGERPRINT(@id), {self._shard_count})" - + def _build_append_event_and_update_state_statements( + self, + event_record: "StoredEvent", + app_name: str, + user_id: str, + session_id: str, + state: "dict[str, Any]", + *, + app_state: "dict[str, Any] | None" = None, + user_state: "dict[str, Any] | None" = None, + ) -> "list[tuple[str, dict[str, Any], dict[str, Any]]]": statements: list[tuple[str, dict[str, Any], dict[str, Any]]] = [ - (insert_sql, event_params, self._event_param_types()), - ( - update_sql, - state_params, - { - "app_name": SPANNER_PARAM_TYPES.STRING, - "user_id": SPANNER_PARAM_TYPES.STRING, - "id": SPANNER_PARAM_TYPES.STRING, - "state": json_type, - }, - ), + self._build_insert_event_statement(event_record), + self._build_update_session_state_statement(app_name, user_id, session_id, state), ] if app_state is not None: - statements.append(( - f""" - INSERT OR UPDATE {self._app_state_table} (app_name, state, update_time) - VALUES (@app_name, @state, PENDING_COMMIT_TIMESTAMP()) - """, - {"app_name": app_name, "state": to_json(app_state)}, - self._app_state_param_types(), - )) + statements.append(self._build_upsert_app_state_statement(app_name, app_state)) if user_state is not None: - statements.append(( - f""" - INSERT OR UPDATE {self._user_state_table} (app_name, user_id, state, update_time) - VALUES (@app_name, @user_id, @state, PENDING_COMMIT_TIMESTAMP()) - """, - {"app_name": app_name, "user_id": user_id, "state": to_json(user_state)}, - self._user_state_param_types(), - )) + statements.append(self._build_upsert_user_state_statement(app_name, user_id, user_state)) + return statements - self._run_write(statements) - - record = self._get_session(app_name, user_id, session_id) - if record is None: - msg = f"Session {session_id} not found during append_event_and_update_state." - raise ValueError(msg) - return record - - def _insert_event(self, event_record: "StoredEvent") -> None: - event_params: dict[str, Any] = { - "id": event_record["id"], - "app_name": event_record["app_name"], - "user_id": event_record["user_id"], - "session_id": event_record["session_id"], - "invocation_id": event_record["invocation_id"], - "timestamp": event_record["timestamp"], - "event_data": to_json(event_record["event_data"]), - } - insert_sql = f""" - INSERT INTO {self._events_table} (id, app_name, user_id, session_id, invocation_id, timestamp, event_data) - VALUES (@id, @app_name, @user_id, @session_id, @invocation_id, @timestamp, @event_data) - """ - self._run_write([(insert_sql, event_params, self._event_param_types())]) - - def _get_events( - self, - app_name: str, - user_id: str, - session_id: str, - after_timestamp: "datetime | None" = None, - limit: "int | None" = None, - ) -> "list[StoredEvent]": - if limit == 0: - return [] + def _build_get_events_query( + self, + app_name: str, + user_id: str, + session_id: str, + after_timestamp: "datetime | None" = None, + limit: "int | None" = None, + ) -> "tuple[str, dict[str, Any], dict[str, Any]] | None": + if limit == 0: + return None sql = f""" SELECT e.id, e.session_id, e.invocation_id, e.timestamp, e.event_data, s.app_name, s.user_id @@ -596,7 +377,9 @@ def _get_events( sql = f"{sql} LIMIT @limit" params["limit"] = limit types["limit"] = SPANNER_PARAM_TYPES.INT64 - rows = self._run_read(sql, params, types) + return (sql, params, types) + + def _decode_event_rows(self, rows: "Iterable[Any]") -> "list[StoredEvent]": return [ { "id": row[0], @@ -610,11 +393,9 @@ def _get_events( for row in rows ] - def _append_event(self, event_record: StoredEvent) -> None: - """Synchronous implementation of append_event.""" - self._insert_event(event_record) - - def _delete_expired_events(self, before: datetime, app_name: "str | None" = None) -> int: + def _build_delete_expired_events_statement( + self, before: datetime, app_name: "str | None" = None + ) -> "tuple[str, dict[str, Any], dict[str, Any]]": sql = f"DELETE FROM {self._events_table} WHERE timestamp < @before" params: dict[str, Any] = {"before": before} types: dict[str, Any] = {"before": SPANNER_PARAM_TYPES.TIMESTAMP} @@ -622,9 +403,11 @@ def _delete_expired_events(self, before: datetime, app_name: "str | None" = None sql += " AND app_name = @app_name" params["app_name"] = app_name types["app_name"] = SPANNER_PARAM_TYPES.STRING - return int(cast("Any", self._database()).run_in_transaction(_SpannerUpdateJob(sql, params, types))) + return (sql, params, types) - def _delete_idle_sessions(self, updated_before: datetime, app_name: "str | None" = None) -> int: + def _build_delete_idle_sessions_statement( + self, updated_before: datetime, app_name: "str | None" = None + ) -> "tuple[str, dict[str, Any], dict[str, Any]]": sql = f"DELETE FROM {self._session_table} WHERE update_time < @updated_before" params: dict[str, Any] = {"updated_before": updated_before} types: dict[str, Any] = {"updated_before": SPANNER_PARAM_TYPES.TIMESTAMP} @@ -632,9 +415,11 @@ def _delete_idle_sessions(self, updated_before: datetime, app_name: "str | None" sql += " AND app_name = @app_name" params["app_name"] = app_name types["app_name"] = SPANNER_PARAM_TYPES.STRING - return int(cast("Any", self._database()).run_in_transaction(_SpannerUpdateJob(sql, params, types))) + return (sql, params, types) - def _delete_idle_user_states(self, updated_before: datetime, app_name: "str | None" = None) -> int: + def _build_delete_idle_user_states_statement( + self, updated_before: datetime, app_name: "str | None" = None + ) -> "tuple[str, dict[str, Any], dict[str, Any]]": sql = f"DELETE FROM {self._user_state_table} WHERE update_time < @updated_before" params: dict[str, Any] = {"updated_before": updated_before} types: dict[str, Any] = {"updated_before": SPANNER_PARAM_TYPES.TIMESTAMP} @@ -642,82 +427,59 @@ def _delete_idle_user_states(self, updated_before: datetime, app_name: "str | No sql += " AND app_name = @app_name" params["app_name"] = app_name types["app_name"] = SPANNER_PARAM_TYPES.STRING - return int(cast("Any", self._database()).run_in_transaction(_SpannerUpdateJob(sql, params, types))) + return (sql, params, types) - def _get_app_state(self, app_name: str) -> "dict[str, Any] | None": + def _build_get_app_state_query(self, app_name: str) -> "tuple[str, dict[str, Any], dict[str, Any]]": sql = f"SELECT state FROM {self._app_state_table} WHERE app_name = @app_name LIMIT 1" - rows = self._run_read(sql, {"app_name": app_name}, {"app_name": SPANNER_PARAM_TYPES.STRING}) - if not rows: - return None - return self._decode_json(rows[0][0]) or {} + return (sql, {"app_name": app_name}, {"app_name": SPANNER_PARAM_TYPES.STRING}) - def _get_user_state(self, app_name: str, user_id: str) -> "dict[str, Any] | None": + def _build_get_user_state_query(self, app_name: str, user_id: str) -> "tuple[str, dict[str, Any], dict[str, Any]]": sql = f""" SELECT state FROM {self._user_state_table} WHERE app_name = @app_name AND user_id = @user_id LIMIT 1 """ - rows = self._run_read( + return ( sql, {"app_name": app_name, "user_id": user_id}, {"app_name": SPANNER_PARAM_TYPES.STRING, "user_id": SPANNER_PARAM_TYPES.STRING}, ) - if not rows: - return None - return self._decode_json(rows[0][0]) or {} - def _upsert_app_state(self, app_name: str, state: "dict[str, Any]") -> None: + def _build_upsert_app_state_statement( + self, app_name: str, state: "dict[str, Any]" + ) -> "tuple[str, dict[str, Any], dict[str, Any]]": sql = f""" INSERT OR UPDATE {self._app_state_table} (app_name, state, update_time) VALUES (@app_name, @state, PENDING_COMMIT_TIMESTAMP()) """ - self._run_write([(sql, {"app_name": app_name, "state": to_json(state)}, self._app_state_param_types())]) + return (sql, {"app_name": app_name, "state": to_json(state)}, self._app_state_param_types()) - def _upsert_user_state(self, app_name: str, user_id: str, state: "dict[str, Any]") -> None: + def _build_upsert_user_state_statement( + self, app_name: str, user_id: str, state: "dict[str, Any]" + ) -> "tuple[str, dict[str, Any], dict[str, Any]]": sql = f""" INSERT OR UPDATE {self._user_state_table} (app_name, user_id, state, update_time) VALUES (@app_name, @user_id, @state, PENDING_COMMIT_TIMESTAMP()) """ - self._run_write([ - (sql, {"app_name": app_name, "user_id": user_id, "state": to_json(state)}, self._user_state_param_types()) - ]) + return ( + sql, + {"app_name": app_name, "user_id": user_id, "state": to_json(state)}, + self._user_state_param_types(), + ) - def _get_metadata(self, key: str) -> "str | None": + def _build_get_metadata_query(self, key: str) -> "tuple[str, dict[str, Any], dict[str, Any]]": sql = f"SELECT value FROM {self._metadata_table} WHERE key = @key LIMIT 1" - rows = self._run_read(sql, {"key": key}, {"key": SPANNER_PARAM_TYPES.STRING}) - if not rows: - return None - return str(rows[0][0]) + return (sql, {"key": key}, {"key": SPANNER_PARAM_TYPES.STRING}) - def _set_metadata(self, key: str, value: str) -> None: + def _build_set_metadata_statement(self, key: str, value: str) -> "tuple[str, dict[str, Any], dict[str, Any]]": sql = f""" INSERT OR UPDATE {self._metadata_table} (key, value) VALUES (@key, @value) """ - self._run_write([(sql, {"key": key, "value": value}, self._metadata_param_types())]) - - def _create_tables(self) -> None: - database = self._database() - existing_tables = {t.table_id for t in database.list_tables()} # type: ignore[no-untyped-call] - - ddl_statements: list[str] = [] - if self._session_table not in existing_tables: - ddl_statements.append(self._sessions_table_ddl()) - if self._events_table not in existing_tables: - ddl_statements.append(self._events_table_ddl()) - if self._app_state_table not in existing_tables: - ddl_statements.append(self._app_states_table_ddl()) - if self._user_state_table not in existing_tables: - ddl_statements.append(self._user_states_table_ddl()) - if self._metadata_table not in existing_tables: - ddl_statements.append(self._metadata_table_ddl()) - ddl_statements.extend(self._expiration_index_ddl()) + return (sql, {"key": key, "value": value}, self._metadata_param_types()) - if ddl_statements: - database.update_ddl(ddl_statements).result(300) # type: ignore[no-untyped-call] - - def _sessions_table_ddl(self) -> str: + def _build_sessions_table_ddl(self) -> str: owner_line = "" if self._owner_id_column_ddl: owner_line = f",\n {self._owner_id_column_ddl}" @@ -740,7 +502,7 @@ def _sessions_table_ddl(self) -> str: ) {pk}{options}{self._session_row_deletion_policy} """ - def _events_table_ddl(self) -> str: + def _build_events_table_ddl(self) -> str: shard_column = "" pk = "PRIMARY KEY (session_id, timestamp)" if self._shard_count > 1: @@ -761,7 +523,7 @@ def _events_table_ddl(self) -> str: ) {pk}{options}{self._events_row_deletion_policy} """ - def _app_states_table_ddl(self) -> str: + def _build_app_states_table_ddl(self) -> str: return f""" CREATE TABLE {self._app_state_table} ( app_name STRING(128) NOT NULL, @@ -770,7 +532,7 @@ def _app_states_table_ddl(self) -> str: ) PRIMARY KEY (app_name) """ - def _user_states_table_ddl(self) -> str: + def _build_user_states_table_ddl(self) -> str: return f""" CREATE TABLE {self._user_state_table} ( app_name STRING(128) NOT NULL, @@ -780,7 +542,7 @@ def _user_states_table_ddl(self) -> str: ) PRIMARY KEY (app_name, user_id) """ - def _metadata_table_ddl(self) -> str: + def _build_metadata_table_ddl(self) -> str: return f""" CREATE TABLE {self._metadata_table} ( key STRING(128) NOT NULL, @@ -795,53 +557,1093 @@ def _expiration_index_ddl(self) -> "list[str]": f"CREATE INDEX IF NOT EXISTS idx_{self._events_table}_timestamp ON {self._events_table}(timestamp){options}", ] - def _drop_app_states_table_sql(self) -> str: - return f"DROP TABLE {self._app_state_table}" + def _missing_table_ddl_statements(self, existing_tables: "set[str]") -> "list[str]": + ddl_statements: list[str] = [] + if self._session_table not in existing_tables: + ddl_statements.append(self._build_sessions_table_ddl()) + if self._events_table not in existing_tables: + ddl_statements.append(self._build_events_table_ddl()) + if self._app_state_table not in existing_tables: + ddl_statements.append(self._build_app_states_table_ddl()) + if self._user_state_table not in existing_tables: + ddl_statements.append(self._build_user_states_table_ddl()) + if self._metadata_table not in existing_tables: + ddl_statements.append(self._build_metadata_table_ddl()) + ddl_statements.extend(self._expiration_index_ddl()) + return ddl_statements + + def _drop_app_states_table_sql(self) -> str: + return f"DROP TABLE {self._app_state_table}" + + def _drop_user_states_table_sql(self) -> str: + return f"DROP TABLE {self._user_state_table}" + + def _drop_metadata_table_sql(self) -> str: + return f"DROP TABLE {self._metadata_table}" + + def _drop_tables_sql(self) -> "list[str]": + return [ + f"DROP INDEX idx_{self._events_table}_timestamp", + f"DROP INDEX idx_{self._session_table}_update_time", + self._drop_metadata_table_sql(), + self._drop_user_states_table_sql(), + self._drop_app_states_table_sql(), + f"DROP TABLE {self._events_table}", + f"DROP TABLE {self._session_table}", + ] + + +class SpannerSyncADKStore(_SpannerADKStoreMixin, BaseSyncADKStore[SpannerSyncConfig]): + """Spanner ADK store backed by synchronous Spanner client.""" + + __slots__ = ( + "_events_row_deletion_policy", + "_events_table_options", + "_expires_index_options", + "_session_row_deletion_policy", + "_session_table_options", + "_shard_count", + ) + + connector_name: ClassVar[str] = "spanner" + + def __init__(self, config: SpannerSyncConfig) -> None: + super().__init__(config) + ( + self._shard_count, + self._session_table_options, + self._events_table_options, + self._expires_index_options, + self._session_row_deletion_policy, + self._events_row_deletion_policy, + ) = _extract_spanner_adk_options(config) + + def create_tables(self) -> None: + """Create tables if they don't exist.""" + if not self.create_schema_enabled: + self.reconcile_schema() + return + + self._create_tables() + + def create_session( + self, session_id: str, app_name: str, user_id: str, state: "dict[str, Any]", owner_id: "Any | None" = None + ) -> StoredSession: + """Create a new session.""" + return self._create_session(session_id, app_name, user_id, state, owner_id) + + def get_session( + self, app_name: str, user_id: str, session_id: str, *, renew_for: "int | timedelta | None" = None + ) -> "StoredSession | None": + """Get session by ID.""" + try: + return self._get_session(app_name, user_id, session_id, renew_for=renew_for) + except NotFound as exc: + if _is_spanner_table_missing(exc, self._session_table): + return None + raise + + def update_session_state(self, app_name: str, user_id: str, session_id: str, state: "dict[str, Any]") -> None: + """Update session state.""" + self._update_session_state(app_name, user_id, session_id, state) + + def list_sessions( + self, + app_name: str, + user_id: "str | None" = None, + *, + order_by: "SessionOrderBy" = "update_time", + descending: bool = True, + limit: "int | None" = None, + offset: "int | None" = None, + ) -> "list[StoredSession]": + """List sessions for an app.""" + try: + return self._list_sessions( + app_name, user_id, order_by=order_by, descending=descending, limit=limit, offset=offset + ) + except NotFound as exc: + if _is_spanner_table_missing(exc, self._session_table): + return [] + raise + + def delete_session(self, app_name: str, user_id: str, session_id: str) -> None: + """Delete session and associated events.""" + self._delete_session(app_name, user_id, session_id) + + def append_event(self, event_record: StoredEvent) -> None: + """Append an event to a session.""" + self._append_event(event_record) + + def append_event_and_update_state( + self, + event_record: StoredEvent, + app_name: str, + user_id: str, + session_id: str, + state: "dict[str, Any]", + *, + app_state: "dict[str, Any] | None" = None, + user_state: "dict[str, Any] | None" = None, + ) -> StoredSession: + """Atomically append an event and update the session's durable state.""" + return self._append_event_and_update_state( + event_record, app_name, user_id, session_id, state, app_state=app_state, user_state=user_state + ) + + def get_events( + self, + app_name: str, + user_id: str, + session_id: str, + after_timestamp: "datetime | None" = None, + limit: "int | None" = None, + ) -> "list[StoredEvent]": + """Get events for a session.""" + try: + return self._get_events(app_name, user_id, session_id, after_timestamp, limit) + except NotFound as exc: + if _is_spanner_table_missing(exc, self._events_table, self._session_table): + return [] + raise + + def delete_expired_events(self, before: datetime, app_name: "str | None" = None) -> int: + """Delete events older than a timestamp, optionally scoped to one application.""" + return self._delete_expired_events(before, app_name) + + def delete_idle_sessions(self, updated_before: datetime, app_name: "str | None" = None) -> int: + """Delete sessions older than a timestamp, optionally scoped to one application.""" + return self._delete_idle_sessions(updated_before, app_name) + + def delete_idle_user_states(self, updated_before: datetime, app_name: "str | None" = None) -> int: + """Delete user-scoped state rows older than a timestamp, optionally scoped to one application.""" + return self._delete_idle_user_states(updated_before, app_name) + + def get_app_state(self, app_name: str) -> "dict[str, Any] | None": + """Return app-scoped state.""" + return self._get_app_state(app_name) + + def get_user_state(self, app_name: str, user_id: str) -> "dict[str, Any] | None": + """Return user-scoped state.""" + return self._get_user_state(app_name, user_id) + + def upsert_app_state(self, app_name: str, state: "dict[str, Any]") -> None: + """Insert or replace app-scoped state.""" + self._upsert_app_state(app_name, state) + + def upsert_user_state(self, app_name: str, user_id: str, state: "dict[str, Any]") -> None: + """Insert or replace user-scoped state.""" + self._upsert_user_state(app_name, user_id, state) + + def get_metadata(self, key: str) -> "str | None": + """Return a metadata value.""" + return self._get_metadata(key) + + def set_metadata(self, key: str, value: str) -> None: + """Set a metadata value.""" + self._set_metadata(key, value) + + def _database(self) -> "SpannerDatabase": + return self._config.get_database() + + def _reset_drop_tables_sql(self) -> "list[str]": + return _filter_existing_spanner_drops(self._reset_drop_statements(), self._existing_tables()) + + def _existing_tables(self) -> "set[str]": + return list_existing_table_names_sync(self._database()) + + def _run_read( + self, sql: str, params: "dict[str, Any] | None" = None, types: "dict[str, Any] | None" = None + ) -> "list[Any]": + with self._config.provide_connection() as snapshot: + reader = cast("_SpannerReadProtocol", snapshot) + return list(reader.execute_sql(sql, params=params, param_types=types)) + + def _run_write(self, statements: "list[tuple[str, dict[str, Any], dict[str, Any]]]") -> None: + cast("Any", self._database()).run_in_transaction(_SpannerSyncWriteJob(statements)) + + def _execute_update(self, sql: str, params: "dict[str, Any]", types: "dict[str, Any]") -> int: + return int(cast("Any", self._database()).run_in_transaction(_SpannerSyncUpdateJob(sql, params, types))) + + def _create_session( + self, session_id: str, app_name: str, user_id: str, state: "dict[str, Any]", owner_id: "Any | None" = None + ) -> StoredSession: + self._run_write([self._build_create_session_statement(session_id, app_name, user_id, state, owner_id)]) + return { + "id": session_id, + "app_name": app_name, + "user_id": user_id, + "state": state, + "create_time": datetime.now(timezone.utc), + "update_time": datetime.now(timezone.utc), + } + + def _get_session( + self, app_name: str, user_id: str, session_id: str, *, renew_for: "int | timedelta | None" = None + ) -> "StoredSession | None": + if renew_for is not None and self._calculate_expires_at(renew_for) is not None: + self._run_write([self._build_renew_session_statement(app_name, user_id, session_id)]) + + sql, params, types = self._build_get_session_query(app_name, user_id, session_id) + rows = self._run_read(sql, params, types) + if not rows: + return None + return self._decode_session_row(rows[0]) + + def _update_session_state(self, app_name: str, user_id: str, session_id: str, state: "dict[str, Any]") -> None: + self._run_write([self._build_update_session_state_statement(app_name, user_id, session_id, state)]) + + def _list_sessions( + self, + app_name: str, + user_id: "str | None" = None, + *, + order_by: "SessionOrderBy" = "update_time", + descending: bool = True, + limit: "int | None" = None, + offset: "int | None" = None, + ) -> "list[StoredSession]": + query = self._build_list_sessions_query( + app_name, user_id, order_by=order_by, descending=descending, limit=limit, offset=offset + ) + if query is None: + return [] + sql, params, types = query + rows = self._run_read(sql, params, types) + return [self._decode_session_row(row) for row in rows] + + def _delete_session(self, app_name: str, user_id: str, session_id: str) -> None: + self._run_write(self._build_delete_session_statements(app_name, user_id, session_id)) + + def _append_event_and_update_state( + self, + event_record: "StoredEvent", + app_name: str, + user_id: str, + session_id: str, + state: "dict[str, Any]", + *, + app_state: "dict[str, Any] | None" = None, + user_state: "dict[str, Any] | None" = None, + ) -> StoredSession: + """Atomically insert an event and update session state in one transaction.""" + statements = self._build_append_event_and_update_state_statements( + event_record, app_name, user_id, session_id, state, app_state=app_state, user_state=user_state + ) + self._run_write(statements) + + record = self._get_session(app_name, user_id, session_id) + if record is None: + msg = f"Session {session_id} not found during append_event_and_update_state." + raise ValueError(msg) + return record + + def _insert_event(self, event_record: "StoredEvent") -> None: + self._run_write([self._build_insert_event_statement(event_record)]) + + def _get_events( + self, + app_name: str, + user_id: str, + session_id: str, + after_timestamp: "datetime | None" = None, + limit: "int | None" = None, + ) -> "list[StoredEvent]": + query = self._build_get_events_query(app_name, user_id, session_id, after_timestamp, limit) + if query is None: + return [] + sql, params, types = query + rows = self._run_read(sql, params, types) + return self._decode_event_rows(rows) + + def _append_event(self, event_record: StoredEvent) -> None: + """Synchronous implementation of append_event.""" + self._insert_event(event_record) + + def _delete_expired_events(self, before: datetime, app_name: "str | None" = None) -> int: + sql, params, types = self._build_delete_expired_events_statement(before, app_name) + return self._execute_update(sql, params, types) + + def _delete_idle_sessions(self, updated_before: datetime, app_name: "str | None" = None) -> int: + sql, params, types = self._build_delete_idle_sessions_statement(updated_before, app_name) + return self._execute_update(sql, params, types) + + def _delete_idle_user_states(self, updated_before: datetime, app_name: "str | None" = None) -> int: + sql, params, types = self._build_delete_idle_user_states_statement(updated_before, app_name) + return self._execute_update(sql, params, types) + + def _get_app_state(self, app_name: str) -> "dict[str, Any] | None": + sql, params, types = self._build_get_app_state_query(app_name) + rows = self._run_read(sql, params, types) + if not rows: + return None + return self._decode_json(rows[0][0]) or {} + + def _get_user_state(self, app_name: str, user_id: str) -> "dict[str, Any] | None": + sql, params, types = self._build_get_user_state_query(app_name, user_id) + rows = self._run_read(sql, params, types) + if not rows: + return None + return self._decode_json(rows[0][0]) or {} + + def _upsert_app_state(self, app_name: str, state: "dict[str, Any]") -> None: + self._run_write([self._build_upsert_app_state_statement(app_name, state)]) + + def _upsert_user_state(self, app_name: str, user_id: str, state: "dict[str, Any]") -> None: + self._run_write([self._build_upsert_user_state_statement(app_name, user_id, state)]) + + def _get_metadata(self, key: str) -> "str | None": + sql, params, types = self._build_get_metadata_query(key) + rows = self._run_read(sql, params, types) + if not rows: + return None + return str(rows[0][0]) + + def _set_metadata(self, key: str, value: str) -> None: + self._run_write([self._build_set_metadata_statement(key, value)]) + + def _create_tables(self) -> None: + ddl_statements = self._missing_table_ddl_statements(self._existing_tables()) + execute_ddl_sync(self._database(), ddl_statements, timeout=_DDL_TIMEOUT_SECONDS) + + def _sessions_table_ddl(self) -> str: + return self._build_sessions_table_ddl() + + def _events_table_ddl(self) -> str: + return self._build_events_table_ddl() + + def _app_states_table_ddl(self) -> str: + return self._build_app_states_table_ddl() + + def _user_states_table_ddl(self) -> str: + return self._build_user_states_table_ddl() + + def _metadata_table_ddl(self) -> str: + return self._build_metadata_table_ddl() + + +class SpannerAsyncADKStore(_SpannerADKStoreMixin, BaseAsyncADKStore[SpannerAsyncConfig]): + """Spanner ADK store backed by asynchronous Spanner client.""" + + __slots__ = ( + "_events_row_deletion_policy", + "_events_table_options", + "_expires_index_options", + "_session_row_deletion_policy", + "_session_table_options", + "_shard_count", + ) + + connector_name: ClassVar[str] = "spanner" + + def __init__(self, config: SpannerAsyncConfig) -> None: + super().__init__(config) + ( + self._shard_count, + self._session_table_options, + self._events_table_options, + self._expires_index_options, + self._session_row_deletion_policy, + self._events_row_deletion_policy, + ) = _extract_spanner_adk_options(config) + + async def create_tables(self) -> None: + """Create tables if they don't exist.""" + if not self.create_schema_enabled: + await self.reconcile_schema() + return + + await self._create_tables() + + async def create_session( + self, session_id: str, app_name: str, user_id: str, state: "dict[str, Any]", owner_id: "Any | None" = None + ) -> StoredSession: + """Create a new session.""" + return await self._create_session(session_id, app_name, user_id, state, owner_id) + + async def get_session( + self, app_name: str, user_id: str, session_id: str, *, renew_for: "int | timedelta | None" = None + ) -> "StoredSession | None": + """Get session by ID.""" + try: + return await self._get_session(app_name, user_id, session_id, renew_for=renew_for) + except NotFound as exc: + if _is_spanner_table_missing(exc, self._session_table): + return None + raise + + async def update_session_state(self, app_name: str, user_id: str, session_id: str, state: "dict[str, Any]") -> None: + """Update session state.""" + await self._update_session_state(app_name, user_id, session_id, state) + + async def list_sessions( + self, + app_name: str, + user_id: "str | None" = None, + *, + order_by: "SessionOrderBy" = "update_time", + descending: bool = True, + limit: "int | None" = None, + offset: "int | None" = None, + ) -> "list[StoredSession]": + """List sessions for an app.""" + try: + return await self._list_sessions( + app_name, user_id, order_by=order_by, descending=descending, limit=limit, offset=offset + ) + except NotFound as exc: + if _is_spanner_table_missing(exc, self._session_table): + return [] + raise + + async def delete_session(self, app_name: str, user_id: str, session_id: str) -> None: + """Delete session and associated events.""" + await self._delete_session(app_name, user_id, session_id) + + async def append_event(self, event_record: StoredEvent) -> None: + """Append an event to a session.""" + await self._append_event(event_record) + + async def append_event_and_update_state( + self, + event_record: StoredEvent, + app_name: str, + user_id: str, + session_id: str, + state: "dict[str, Any]", + *, + app_state: "dict[str, Any] | None" = None, + user_state: "dict[str, Any] | None" = None, + ) -> StoredSession: + """Atomically append an event and update the session's durable state.""" + return await self._append_event_and_update_state( + event_record, app_name, user_id, session_id, state, app_state=app_state, user_state=user_state + ) + + async def get_events( + self, + app_name: str, + user_id: str, + session_id: str, + after_timestamp: "datetime | None" = None, + limit: "int | None" = None, + ) -> "list[StoredEvent]": + """Get events for a session.""" + try: + return await self._get_events(app_name, user_id, session_id, after_timestamp, limit) + except NotFound as exc: + if _is_spanner_table_missing(exc, self._events_table, self._session_table): + return [] + raise + + async def delete_expired_events(self, before: datetime, app_name: "str | None" = None) -> int: + """Delete events older than a timestamp, optionally scoped to one application.""" + return await self._delete_expired_events(before, app_name) + + async def delete_idle_sessions(self, updated_before: datetime, app_name: "str | None" = None) -> int: + """Delete sessions older than a timestamp, optionally scoped to one application.""" + return await self._delete_idle_sessions(updated_before, app_name) + + async def delete_idle_user_states(self, updated_before: datetime, app_name: "str | None" = None) -> int: + """Delete user-scoped state rows older than a timestamp, optionally scoped to one application.""" + return await self._delete_idle_user_states(updated_before, app_name) + + async def get_app_state(self, app_name: str) -> "dict[str, Any] | None": + """Return app-scoped state.""" + return await self._get_app_state(app_name) + + async def get_user_state(self, app_name: str, user_id: str) -> "dict[str, Any] | None": + """Return user-scoped state.""" + return await self._get_user_state(app_name, user_id) + + async def upsert_app_state(self, app_name: str, state: "dict[str, Any]") -> None: + """Insert or replace app-scoped state.""" + await self._upsert_app_state(app_name, state) + + async def upsert_user_state(self, app_name: str, user_id: str, state: "dict[str, Any]") -> None: + """Insert or replace user-scoped state.""" + await self._upsert_user_state(app_name, user_id, state) + + async def get_metadata(self, key: str) -> "str | None": + """Return a metadata value.""" + return await self._get_metadata(key) + + async def set_metadata(self, key: str, value: str) -> None: + """Set a metadata value.""" + await self._set_metadata(key, value) + + async def _database(self) -> "SpannerAsyncDatabase": + return await self._config.get_database() + + async def _reset_drop_tables_sql(self) -> "list[str]": + return _filter_existing_spanner_drops(self._reset_drop_statements(), await self._existing_tables()) + + async def _existing_tables(self) -> "set[str]": + return await list_existing_table_names_async(await self._database()) + + async def _run_read( + self, sql: str, params: "dict[str, Any] | None" = None, types: "dict[str, Any] | None" = None + ) -> "list[Any]": + return await _run_read_async(await self._database(), sql, params, types) + + async def _run_write(self, statements: "list[tuple[str, dict[str, Any], dict[str, Any]]]") -> None: + database = await self._database() + await database.run_in_transaction(_SpannerAsyncWriteJob(statements)) + + async def _execute_update(self, sql: str, params: "dict[str, Any]", types: "dict[str, Any]") -> int: + database = await self._database() + return int(await database.run_in_transaction(_SpannerAsyncUpdateJob(sql, params, types))) + + async def _create_session( + self, session_id: str, app_name: str, user_id: str, state: "dict[str, Any]", owner_id: "Any | None" = None + ) -> StoredSession: + await self._run_write([self._build_create_session_statement(session_id, app_name, user_id, state, owner_id)]) + return { + "id": session_id, + "app_name": app_name, + "user_id": user_id, + "state": state, + "create_time": datetime.now(timezone.utc), + "update_time": datetime.now(timezone.utc), + } + + async def _get_session( + self, app_name: str, user_id: str, session_id: str, *, renew_for: "int | timedelta | None" = None + ) -> "StoredSession | None": + if renew_for is not None and self._calculate_expires_at(renew_for) is not None: + await self._run_write([self._build_renew_session_statement(app_name, user_id, session_id)]) + + sql, params, types = self._build_get_session_query(app_name, user_id, session_id) + rows = await self._run_read(sql, params, types) + if not rows: + return None + return self._decode_session_row(rows[0]) + + async def _update_session_state( + self, app_name: str, user_id: str, session_id: str, state: "dict[str, Any]" + ) -> None: + await self._run_write([self._build_update_session_state_statement(app_name, user_id, session_id, state)]) + + async def _list_sessions( + self, + app_name: str, + user_id: "str | None" = None, + *, + order_by: "SessionOrderBy" = "update_time", + descending: bool = True, + limit: "int | None" = None, + offset: "int | None" = None, + ) -> "list[StoredSession]": + query = self._build_list_sessions_query( + app_name, user_id, order_by=order_by, descending=descending, limit=limit, offset=offset + ) + if query is None: + return [] + sql, params, types = query + rows = await self._run_read(sql, params, types) + return [self._decode_session_row(row) for row in rows] + + async def _delete_session(self, app_name: str, user_id: str, session_id: str) -> None: + await self._run_write(self._build_delete_session_statements(app_name, user_id, session_id)) + + async def _append_event_and_update_state( + self, + event_record: "StoredEvent", + app_name: str, + user_id: str, + session_id: str, + state: "dict[str, Any]", + *, + app_state: "dict[str, Any] | None" = None, + user_state: "dict[str, Any] | None" = None, + ) -> StoredSession: + """Atomically insert an event and update session state in one transaction.""" + statements = self._build_append_event_and_update_state_statements( + event_record, app_name, user_id, session_id, state, app_state=app_state, user_state=user_state + ) + await self._run_write(statements) + + record = await self._get_session(app_name, user_id, session_id) + if record is None: + msg = f"Session {session_id} not found during append_event_and_update_state." + raise ValueError(msg) + return record + + async def _insert_event(self, event_record: "StoredEvent") -> None: + await self._run_write([self._build_insert_event_statement(event_record)]) + + async def _get_events( + self, + app_name: str, + user_id: str, + session_id: str, + after_timestamp: "datetime | None" = None, + limit: "int | None" = None, + ) -> "list[StoredEvent]": + query = self._build_get_events_query(app_name, user_id, session_id, after_timestamp, limit) + if query is None: + return [] + sql, params, types = query + rows = await self._run_read(sql, params, types) + return self._decode_event_rows(rows) + + async def _append_event(self, event_record: StoredEvent) -> None: + """Asynchronous implementation of append_event.""" + await self._insert_event(event_record) + + async def _delete_expired_events(self, before: datetime, app_name: "str | None" = None) -> int: + sql, params, types = self._build_delete_expired_events_statement(before, app_name) + return await self._execute_update(sql, params, types) + + async def _delete_idle_sessions(self, updated_before: datetime, app_name: "str | None" = None) -> int: + sql, params, types = self._build_delete_idle_sessions_statement(updated_before, app_name) + return await self._execute_update(sql, params, types) + + async def _delete_idle_user_states(self, updated_before: datetime, app_name: "str | None" = None) -> int: + sql, params, types = self._build_delete_idle_user_states_statement(updated_before, app_name) + return await self._execute_update(sql, params, types) + + async def _get_app_state(self, app_name: str) -> "dict[str, Any] | None": + sql, params, types = self._build_get_app_state_query(app_name) + rows = await self._run_read(sql, params, types) + if not rows: + return None + return self._decode_json(rows[0][0]) or {} + + async def _get_user_state(self, app_name: str, user_id: str) -> "dict[str, Any] | None": + sql, params, types = self._build_get_user_state_query(app_name, user_id) + rows = await self._run_read(sql, params, types) + if not rows: + return None + return self._decode_json(rows[0][0]) or {} + + async def _upsert_app_state(self, app_name: str, state: "dict[str, Any]") -> None: + await self._run_write([self._build_upsert_app_state_statement(app_name, state)]) + + async def _upsert_user_state(self, app_name: str, user_id: str, state: "dict[str, Any]") -> None: + await self._run_write([self._build_upsert_user_state_statement(app_name, user_id, state)]) + + async def _get_metadata(self, key: str) -> "str | None": + sql, params, types = self._build_get_metadata_query(key) + rows = await self._run_read(sql, params, types) + if not rows: + return None + return str(rows[0][0]) + + async def _set_metadata(self, key: str, value: str) -> None: + await self._run_write([self._build_set_metadata_statement(key, value)]) + + async def _create_tables(self) -> None: + ddl_statements = self._missing_table_ddl_statements(await self._existing_tables()) + await execute_ddl_async(await self._database(), ddl_statements, timeout=_DDL_TIMEOUT_SECONDS) + + async def _sessions_table_ddl(self) -> str: + return self._build_sessions_table_ddl() + + async def _events_table_ddl(self) -> str: + return self._build_events_table_ddl() + + async def _app_states_table_ddl(self) -> str: + return self._build_app_states_table_ddl() + + async def _user_states_table_ddl(self) -> str: + return self._build_user_states_table_ddl() + + async def _metadata_table_ddl(self) -> str: + return self._build_metadata_table_ddl() + + +class _SpannerADKMemoryStoreMixin: + """Shared SQL, DDL, parameter type, and decoding helpers for Spanner ADK memory stores.""" + + __slots__ = () + + if TYPE_CHECKING: + _memory_table: str + _enabled: bool + _max_results: int + _use_fts: bool + _owner_id_column_ddl: str | None + _owner_id_column_name: str | None + _shard_count: int + _memory_table_options: str | None + _memory_row_deletion_policy: str + + def _memory_param_types(self, include_owner: bool) -> "dict[str, Any]": + types: dict[str, Any] = { + "id": SPANNER_PARAM_TYPES.STRING, + "session_id": SPANNER_PARAM_TYPES.STRING, + "app_name": SPANNER_PARAM_TYPES.STRING, + "user_id": SPANNER_PARAM_TYPES.STRING, + "scope": SPANNER_PARAM_TYPES.STRING, + "event_id": SPANNER_PARAM_TYPES.STRING, + "author": SPANNER_PARAM_TYPES.STRING, + "timestamp": SPANNER_PARAM_TYPES.TIMESTAMP, + "content_json": _json_param_type(), + "content_text": SPANNER_PARAM_TYPES.STRING, + "metadata_json": _json_param_type(), + "inserted_at": SPANNER_PARAM_TYPES.TIMESTAMP, + } + if include_owner and self._owner_id_column_name: + types["owner_id"] = SPANNER_PARAM_TYPES.STRING + return types + + def _decode_json(self, raw: Any) -> Any: + if raw is None: + return None + if isinstance(raw, str): + return from_json(raw) + return raw + + def _build_memory_table_ddl(self) -> "list[str]": + owner_line = "" + if self._owner_id_column_ddl: + owner_line = f",\n {self._owner_id_column_ddl}" + + fts_column_line = "" + fts_index = "" + if self._use_fts: + fts_column_line = "\n content_tokens TOKENLIST AS (TOKENIZE_FULLTEXT(content_text)) HIDDEN" + fts_index = f"CREATE SEARCH INDEX idx_{self._memory_table}_fts ON {self._memory_table}(content_tokens)" + + shard_column = "" + pk = "PRIMARY KEY (id)" + if self._shard_count > 1: + shard_column = f",\n shard_id INT64 AS (MOD(FARM_FINGERPRINT(id), {self._shard_count})) STORED" + pk = "PRIMARY KEY (shard_id, id)" + options = "" + if self._memory_table_options: + options = f"\nOPTIONS ({self._memory_table_options})" + + table_sql = f""" +CREATE TABLE {self._memory_table} ( + id STRING(128) NOT NULL, + session_id STRING(128) NOT NULL, + app_name STRING(128) NOT NULL, + user_id STRING(128) NOT NULL, + scope STRING(16) NOT NULL, + event_id STRING(128) NOT NULL, + author STRING(256){owner_line}, + timestamp TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true), + content_json JSON NOT NULL, + content_text STRING(MAX) NOT NULL, + metadata_json JSON, + inserted_at TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true){fts_column_line}{shard_column} +) {pk}{options}{self._memory_row_deletion_policy} +""" + + app_scope_user_idx = f"CREATE INDEX idx_{self._memory_table}_app_scope_user_time ON {self._memory_table}(app_name, scope, user_id, timestamp DESC)" + scope_idx = f"CREATE INDEX idx_{self._memory_table}_scope ON {self._memory_table}(app_name, scope)" + session_idx = f"CREATE INDEX idx_{self._memory_table}_session ON {self._memory_table}(session_id)" + + statements = [table_sql, app_scope_user_idx, scope_idx, session_idx] + if fts_index: + statements.append(fts_index) + return statements + + def _drop_memory_table_sql(self) -> "list[str]": + """Get SQL to drop the memory table and its indexes. + + Returns: + List of SQL statements to drop the memory table and associated indexes. + """ + statements: list[str] = [] + if self._use_fts: + statements.append(f"DROP SEARCH INDEX idx_{self._memory_table}_fts") + statements.extend([ + f"DROP INDEX idx_{self._memory_table}_session", + f"DROP INDEX idx_{self._memory_table}_app_scope_user_time", + f"DROP INDEX idx_{self._memory_table}_scope", + f"DROP TABLE {self._memory_table}", + ]) + return statements + + def _build_insert_memory_entry_statement( + self, entry: "StoredMemory", owner_id: "object | None" = None + ) -> "tuple[str, dict[str, Any], dict[str, Any]]": + owner_column = f", {self._owner_id_column_name}" if self._owner_id_column_name else "" + owner_param = ", @owner_id" if self._owner_id_column_name else "" + insert_sql = f""" + INSERT INTO {self._memory_table} ( + id, session_id, app_name, user_id, scope, event_id, author{owner_column}, + timestamp, content_json, content_text, metadata_json, inserted_at + ) VALUES ( + @id, @session_id, @app_name, @user_id, @scope, @event_id, @author{owner_param}, + @timestamp, @content_json, @content_text, @metadata_json, @inserted_at + ) + """ + params: dict[str, Any] = { + "id": entry["id"], + "session_id": entry["session_id"], + "app_name": entry["app_name"], + "user_id": entry["user_id"], + "scope": entry.get("scope", "user"), + "event_id": entry["event_id"], + "author": entry["author"], + "timestamp": entry["timestamp"], + "content_json": to_json(entry["content_json"]), + "content_text": entry["content_text"], + "metadata_json": to_json(entry["metadata_json"]) if entry["metadata_json"] is not None else None, + "inserted_at": entry["inserted_at"], + } + if self._owner_id_column_name: + params["owner_id"] = str(owner_id) if owner_id is not None else None + return (insert_sql, params, self._memory_param_types(self._owner_id_column_name is not None)) + + def _build_event_exists_query(self, event_id: str) -> "tuple[str, dict[str, Any], dict[str, Any]]": + sql = f"SELECT event_id FROM {self._memory_table} WHERE event_id = @event_id LIMIT 1" + return (sql, {"event_id": event_id}, {"event_id": SPANNER_PARAM_TYPES.STRING}) + + def _build_search_entries_fts_query( + self, query: str, app_name: str, user_id: str, limit: int, scope_filter: Literal["all", "user", "app"] = "all" + ) -> "tuple[str, dict[str, Any], dict[str, Any]]": + where_scope, scope_params, scope_types = _build_spanner_scope_where(app_name, user_id, scope_filter) + sql = f""" + SELECT id, session_id, app_name, user_id, scope, event_id, author, + timestamp, content_json, content_text, metadata_json, inserted_at + FROM {self._memory_table} + WHERE {where_scope} + AND SEARCH(content_tokens, @query) + ORDER BY timestamp DESC + LIMIT @limit + """ + params = {**scope_params, "query": query, "limit": limit} + types = {**scope_types, "query": SPANNER_PARAM_TYPES.STRING, "limit": SPANNER_PARAM_TYPES.INT64} + return (sql, params, types) + + def _build_search_entries_simple_query( + self, query: str, app_name: str, user_id: str, limit: int, scope_filter: Literal["all", "user", "app"] = "all" + ) -> "tuple[str, dict[str, Any], dict[str, Any]]": + where_scope, scope_params, scope_types = _build_spanner_scope_where(app_name, user_id, scope_filter) + sql = f""" + SELECT id, session_id, app_name, user_id, scope, event_id, author, + timestamp, content_json, content_text, metadata_json, inserted_at + FROM {self._memory_table} + WHERE {where_scope} + AND LOWER(content_text) LIKE @pattern + ORDER BY timestamp DESC + LIMIT @limit + """ + pattern = f"%{query.lower()}%" + params = {**scope_params, "pattern": pattern, "limit": limit} + types = {**scope_types, "pattern": SPANNER_PARAM_TYPES.STRING, "limit": SPANNER_PARAM_TYPES.INT64} + return (sql, params, types) + + def _build_delete_entries_by_session_statement( + self, session_id: str + ) -> "tuple[str, dict[str, Any], dict[str, Any]]": + sql = f"DELETE FROM {self._memory_table} WHERE session_id = @session_id" + params = {"session_id": session_id} + types = {"session_id": SPANNER_PARAM_TYPES.STRING} + return (sql, params, types) + + def _build_delete_entries_older_than_statement( + self, days: int, app_name: "str | None" = None, scope: "str | None" = None + ) -> "tuple[str, dict[str, Any], dict[str, Any]]": + cutoff = datetime.now(timezone.utc) - timedelta(days=days) + clauses = ["inserted_at < @cutoff"] + params: dict[str, Any] = {"cutoff": cutoff} + types: dict[str, Any] = {"cutoff": SPANNER_PARAM_TYPES.TIMESTAMP} + if app_name is not None: + clauses.append("app_name = @app_name") + params["app_name"] = app_name + types["app_name"] = SPANNER_PARAM_TYPES.STRING + if scope is not None: + clauses.append("scope = @scope") + params["scope"] = scope + types["scope"] = SPANNER_PARAM_TYPES.STRING + where_sql = " AND ".join(clauses) + sql = f"DELETE FROM {self._memory_table} WHERE {where_sql}" + return (sql, params, types) + + def _rows_to_records(self, rows: "list[Any]") -> "list[StoredMemory]": + return [ + { + "id": row[0], + "session_id": row[1], + "app_name": row[2], + "user_id": row[3], + "scope": row[4], + "event_id": row[5], + "author": row[6], + "timestamp": row[7], + "content_json": self._decode_json(row[8]), + "content_text": row[9], + "metadata_json": self._decode_json(row[10]), + "inserted_at": row[11], + "embedding": None, + } + for row in rows + ] + + +class SpannerSyncADKMemoryStore(_SpannerADKMemoryStoreMixin, BaseSyncADKMemoryStore[SpannerSyncConfig]): + """Spanner ADK memory store backed by synchronous Spanner client.""" + + __slots__ = ("_memory_row_deletion_policy", "_memory_table_options", "_shard_count") + + connector_name: ClassVar[str] = "spanner" + + def __init__(self, config: SpannerSyncConfig) -> None: + super().__init__(config) + self._shard_count, self._memory_table_options, self._memory_row_deletion_policy = ( + _extract_spanner_memory_options(config) + ) + + def create_tables(self) -> None: + """Create tables if they don't exist.""" + if not self.create_schema_enabled: + self.reconcile_schema() + return + + self._create_tables() + + def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object | None" = None) -> int: + """Bulk insert memory entries with deduplication.""" + return self._insert_memory_entries(entries, owner_id) + + def search_entries( + self, + query: str, + app_name: str, + user_id: str, + limit: "int | None" = None, + scope_filter: Literal["all", "user", "app"] = "all", + embedding: "Sequence[float] | None" = None, + ) -> "list[StoredMemory]": + """Search memory entries by text query.""" + return self._search_entries(query, app_name, user_id, limit, scope_filter) + + def delete_entries_by_session(self, session_id: str) -> int: + """Delete all memory entries for a specific session.""" + return self._delete_entries_by_session(session_id) + + def delete_entries_older_than(self, days: int, app_name: "str | None" = None, scope: "str | None" = None) -> int: + """Delete memory entries older than specified days.""" + return self._delete_entries_older_than(days, app_name, scope) + + def _database(self) -> "SpannerDatabase": + return self._config.get_database() + + def _reset_drop_memory_table_sql(self) -> "list[str]": + return _filter_existing_spanner_drops(self._reset_drop_memory_statements(), self._existing_tables()) + + def _existing_tables(self) -> "set[str]": + return list_existing_table_names_sync(self._database()) + + def _run_read( + self, sql: str, params: "dict[str, Any] | None" = None, types: "dict[str, Any] | None" = None + ) -> "list[Any]": + with self._config.provide_connection() as snapshot: + reader = cast("_SpannerReadProtocol", snapshot) + return list(reader.execute_sql(sql, params=params, param_types=types)) + + def _run_write(self, statements: "list[tuple[str, dict[str, Any], dict[str, Any]]]") -> None: + cast("Any", self._database()).run_in_transaction(_SpannerSyncWriteJob(statements)) + + def _execute_update(self, sql: str, params: "dict[str, Any]", types: "dict[str, Any]") -> int: + return int(cast("Any", self._database()).run_in_transaction(_SpannerSyncUpdateJob(sql, params, types))) + + def _create_tables(self) -> None: + if not self._enabled or self._memory_table in self._existing_tables(): + return + execute_ddl_sync(self._database(), self._memory_table_ddl(), timeout=_DDL_TIMEOUT_SECONDS) + + def _memory_table_ddl(self) -> "list[str]": + return self._build_memory_table_ddl() + + def _insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object | None" = None) -> int: + if not self._enabled: + msg = "Memory store is disabled" + raise RuntimeError(msg) + + if not entries: + return 0 + + inserted_count = 0 + statements: list[tuple[str, dict[str, Any], dict[str, Any]]] = [] + for entry in entries: + if self._event_exists(entry["event_id"]): + continue + statements.append(self._build_insert_memory_entry_statement(entry, owner_id)) + inserted_count += 1 + + if statements: + self._run_write(statements) + return inserted_count + + def _event_exists(self, event_id: str) -> bool: + sql, params, types = self._build_event_exists_query(event_id) + rows = self._run_read(sql, params, types) + return bool(rows) + + def _search_entries( + self, + query: str, + app_name: str, + user_id: str, + limit: "int | None" = None, + scope_filter: Literal["all", "user", "app"] = "all", + ) -> "list[StoredMemory]": + if not self._enabled: + msg = "Memory store is disabled" + raise RuntimeError(msg) + + effective_limit = limit if limit is not None else self._max_results + + if self._use_fts: + return self._search_entries_fts(query, app_name, user_id, effective_limit, scope_filter) + return self._search_entries_simple(query, app_name, user_id, effective_limit, scope_filter) + + def _search_entries_fts( + self, query: str, app_name: str, user_id: str, limit: int, scope_filter: Literal["all", "user", "app"] = "all" + ) -> "list[StoredMemory]": + sql, params, types = self._build_search_entries_fts_query(query, app_name, user_id, limit, scope_filter) + rows = self._run_read(sql, params, types) + return self._rows_to_records(rows) - def _drop_user_states_table_sql(self) -> str: - return f"DROP TABLE {self._user_state_table}" + def _search_entries_simple( + self, query: str, app_name: str, user_id: str, limit: int, scope_filter: Literal["all", "user", "app"] = "all" + ) -> "list[StoredMemory]": + sql, params, types = self._build_search_entries_simple_query(query, app_name, user_id, limit, scope_filter) + rows = self._run_read(sql, params, types) + return self._rows_to_records(rows) - def _drop_metadata_table_sql(self) -> str: - return f"DROP TABLE {self._metadata_table}" + def _delete_entries_by_session(self, session_id: str) -> int: + sql, params, types = self._build_delete_entries_by_session_statement(session_id) + return self._execute_update(sql, params, types) - def _drop_tables_sql(self) -> "list[str]": - return [ - f"DROP INDEX idx_{self._events_table}_timestamp", - f"DROP INDEX idx_{self._session_table}_update_time", - self._drop_metadata_table_sql(), - self._drop_user_states_table_sql(), - self._drop_app_states_table_sql(), - f"DROP TABLE {self._events_table}", - f"DROP TABLE {self._session_table}", - ] + def _delete_entries_older_than(self, days: int, app_name: "str | None" = None, scope: "str | None" = None) -> int: + sql, params, types = self._build_delete_entries_older_than_statement(days, app_name, scope) + return self._execute_update(sql, params, types) -class SpannerSyncADKMemoryStore(BaseSyncADKMemoryStore[SpannerSyncConfig]): - """Spanner ADK memory store backed by synchronous Spanner client.""" +class SpannerAsyncADKMemoryStore(_SpannerADKMemoryStoreMixin, BaseAsyncADKMemoryStore[SpannerAsyncConfig]): + """Spanner ADK memory store backed by asynchronous Spanner client.""" + + __slots__ = ("_memory_row_deletion_policy", "_memory_table_options", "_shard_count") connector_name: ClassVar[str] = "spanner" - def __init__(self, config: SpannerSyncConfig) -> None: + def __init__(self, config: SpannerAsyncConfig) -> None: super().__init__(config) - adk_config = _adk_config(config) - shard_count = adk_config.get("shard_count") - self._shard_count = int(shard_count) if isinstance(shard_count, int) else 0 - self._memory_table_options: str | None = adk_config.get("memory_table_options") - self._memory_row_deletion_policy = _spanner_row_deletion_policy(adk_config, "memory_ttl_seconds", "inserted_at") + self._shard_count, self._memory_table_options, self._memory_row_deletion_policy = ( + _extract_spanner_memory_options(config) + ) - def create_tables(self) -> None: + async def create_tables(self) -> None: """Create tables if they don't exist.""" if not self.create_schema_enabled: - self.reconcile_schema() + await self.reconcile_schema() return - self._create_tables() + await self._create_tables() - def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object | None" = None) -> int: + async def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object | None" = None) -> int: """Bulk insert memory entries with deduplication.""" - return self._insert_memory_entries(entries, owner_id) + return await self._insert_memory_entries(entries, owner_id) - def search_entries( + async def search_entries( self, query: str, app_name: str, @@ -851,144 +1653,49 @@ def search_entries( embedding: "Sequence[float] | None" = None, ) -> "list[StoredMemory]": """Search memory entries by text query.""" - return self._search_entries(query, app_name, user_id, limit, scope_filter) + return await self._search_entries(query, app_name, user_id, limit, scope_filter) - def delete_entries_by_session(self, session_id: str) -> int: + async def delete_entries_by_session(self, session_id: str) -> int: """Delete all memory entries for a specific session.""" - return self._delete_entries_by_session(session_id) + return await self._delete_entries_by_session(session_id) - def delete_entries_older_than(self, days: int, app_name: "str | None" = None, scope: "str | None" = None) -> int: + async def delete_entries_older_than( + self, days: int, app_name: "str | None" = None, scope: "str | None" = None + ) -> int: """Delete memory entries older than specified days.""" - return self._delete_entries_older_than(days, app_name, scope) + return await self._delete_entries_older_than(days, app_name, scope) - def _database(self) -> "Database": - return self._config.get_database() + async def _database(self) -> "SpannerAsyncDatabase": + return await self._config.get_database() - def _reset_drop_memory_table_sql(self) -> "list[str]": - return _filter_existing_spanner_drops(super()._reset_drop_memory_table_sql(), self._existing_tables()) + async def _reset_drop_memory_table_sql(self) -> "list[str]": + return _filter_existing_spanner_drops(self._reset_drop_memory_statements(), await self._existing_tables()) - def _existing_tables(self) -> "set[str]": - database = self._database() - return {table.table_id for table in database.list_tables()} # type: ignore[no-untyped-call] + async def _existing_tables(self) -> "set[str]": + return await list_existing_table_names_async(await self._database()) - def _run_read( + async def _run_read( self, sql: str, params: "dict[str, Any] | None" = None, types: "dict[str, Any] | None" = None ) -> "list[Any]": - with self._config.provide_connection() as snapshot: - reader = cast("_SpannerReadProtocol", snapshot) - result_set = reader.execute_sql(sql, params=params, param_types=types) - return list(result_set) - - def _run_write(self, statements: "list[tuple[str, dict[str, Any], dict[str, Any]]]") -> None: - self._database().run_in_transaction(_SpannerMemoryWriteJob(statements)) # type: ignore[no-untyped-call] + return await _run_read_async(await self._database(), sql, params, types) - def _execute_update(self, sql: str, params: "dict[str, Any]", types: "dict[str, Any]") -> int: - return int(self._database().run_in_transaction(_SpannerMemoryUpdateJob(sql, params, types))) # type: ignore[no-untyped-call] - - def _memory_param_types(self, include_owner: bool) -> "dict[str, Any]": - types: dict[str, Any] = { - "id": SPANNER_PARAM_TYPES.STRING, - "session_id": SPANNER_PARAM_TYPES.STRING, - "app_name": SPANNER_PARAM_TYPES.STRING, - "user_id": SPANNER_PARAM_TYPES.STRING, - "scope": SPANNER_PARAM_TYPES.STRING, - "event_id": SPANNER_PARAM_TYPES.STRING, - "author": SPANNER_PARAM_TYPES.STRING, - "timestamp": SPANNER_PARAM_TYPES.TIMESTAMP, - "content_json": _json_param_type(), - "content_text": SPANNER_PARAM_TYPES.STRING, - "metadata_json": _json_param_type(), - "inserted_at": SPANNER_PARAM_TYPES.TIMESTAMP, - } - if include_owner and self._owner_id_column_name: - types["owner_id"] = SPANNER_PARAM_TYPES.STRING - return types + async def _run_write(self, statements: "list[tuple[str, dict[str, Any], dict[str, Any]]]") -> None: + database = await self._database() + await database.run_in_transaction(_SpannerAsyncWriteJob(statements)) - def _decode_json(self, raw: Any) -> Any: - if raw is None: - return None - if isinstance(raw, str): - return from_json(raw) - return raw + async def _execute_update(self, sql: str, params: "dict[str, Any]", types: "dict[str, Any]") -> int: + database = await self._database() + return int(await database.run_in_transaction(_SpannerAsyncUpdateJob(sql, params, types))) - def _create_tables(self) -> None: - if not self._enabled: + async def _create_tables(self) -> None: + if not self._enabled or self._memory_table in await self._existing_tables(): return + await execute_ddl_async(await self._database(), await self._memory_table_ddl(), timeout=_DDL_TIMEOUT_SECONDS) - database = self._database() - existing_tables = {t.table_id for t in database.list_tables()} # type: ignore[no-untyped-call] - - ddl_statements: list[str] = [] - if self._memory_table not in existing_tables: - ddl_statements.extend(self._memory_table_ddl()) - - if ddl_statements: - database.update_ddl(ddl_statements).result(300) # type: ignore[no-untyped-call] - - def _memory_table_ddl(self) -> "list[str]": - owner_line = "" - if self._owner_id_column_ddl: - owner_line = f",\n {self._owner_id_column_ddl}" - - fts_column_line = "" - fts_index = "" - if self._use_fts: - fts_column_line = "\n content_tokens TOKENLIST AS (TOKENIZE_FULLTEXT(content_text)) HIDDEN" - fts_index = f"CREATE SEARCH INDEX idx_{self._memory_table}_fts ON {self._memory_table}(content_tokens)" - - shard_column = "" - pk = "PRIMARY KEY (id)" - if self._shard_count > 1: - shard_column = f",\n shard_id INT64 AS (MOD(FARM_FINGERPRINT(id), {self._shard_count})) STORED" - pk = "PRIMARY KEY (shard_id, id)" - options = "" - if self._memory_table_options: - options = f"\nOPTIONS ({self._memory_table_options})" - - table_sql = f""" -CREATE TABLE {self._memory_table} ( - id STRING(128) NOT NULL, - session_id STRING(128) NOT NULL, - app_name STRING(128) NOT NULL, - user_id STRING(128) NOT NULL, - scope STRING(16) NOT NULL, - event_id STRING(128) NOT NULL, - author STRING(256){owner_line}, - timestamp TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true), - content_json JSON NOT NULL, - content_text STRING(MAX) NOT NULL, - metadata_json JSON, - inserted_at TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true){fts_column_line}{shard_column} -) {pk}{options}{self._memory_row_deletion_policy} -""" - - app_scope_user_idx = f"CREATE INDEX idx_{self._memory_table}_app_scope_user_time ON {self._memory_table}(app_name, scope, user_id, timestamp DESC)" - scope_idx = f"CREATE INDEX idx_{self._memory_table}_scope ON {self._memory_table}(app_name, scope)" - session_idx = f"CREATE INDEX idx_{self._memory_table}_session ON {self._memory_table}(session_id)" - - statements = [table_sql, app_scope_user_idx, scope_idx, session_idx] - if fts_index: - statements.append(fts_index) - return statements - - def _drop_memory_table_sql(self) -> "list[str]": - """Get SQL to drop the memory table and its indexes. - - Returns: - List of SQL statements to drop the memory table and associated indexes. - """ - statements: list[str] = [] - if self._use_fts: - statements.append(f"DROP SEARCH INDEX idx_{self._memory_table}_fts") - statements.extend([ - f"DROP INDEX idx_{self._memory_table}_session", - f"DROP INDEX idx_{self._memory_table}_app_scope_user_time", - f"DROP INDEX idx_{self._memory_table}_scope", - f"DROP TABLE {self._memory_table}", - ]) - return statements + async def _memory_table_ddl(self) -> "list[str]": + return self._build_memory_table_ddl() - def _insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object | None" = None) -> int: + async def _insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object | None" = None) -> int: if not self._enabled: msg = "Memory store is disabled" raise RuntimeError(msg) @@ -998,52 +1705,22 @@ def _insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "objec inserted_count = 0 statements: list[tuple[str, dict[str, Any], dict[str, Any]]] = [] - - owner_column = f", {self._owner_id_column_name}" if self._owner_id_column_name else "" - owner_param = ", @owner_id" if self._owner_id_column_name else "" - - insert_sql = f""" - INSERT INTO {self._memory_table} ( - id, session_id, app_name, user_id, scope, event_id, author{owner_column}, - timestamp, content_json, content_text, metadata_json, inserted_at - ) VALUES ( - @id, @session_id, @app_name, @user_id, @scope, @event_id, @author{owner_param}, - @timestamp, @content_json, @content_text, @metadata_json, @inserted_at - ) - """ - for entry in entries: - if self._event_exists(entry["event_id"]): + if await self._event_exists(entry["event_id"]): continue - params = { - "id": entry["id"], - "session_id": entry["session_id"], - "app_name": entry["app_name"], - "user_id": entry["user_id"], - "scope": entry.get("scope", "user"), - "event_id": entry["event_id"], - "author": entry["author"], - "timestamp": entry["timestamp"], - "content_json": to_json(entry["content_json"]), - "content_text": entry["content_text"], - "metadata_json": to_json(entry["metadata_json"]) if entry["metadata_json"] is not None else None, - "inserted_at": entry["inserted_at"], - } - if self._owner_id_column_name: - params["owner_id"] = str(owner_id) if owner_id is not None else None - statements.append((insert_sql, params, self._memory_param_types(self._owner_id_column_name is not None))) + statements.append(self._build_insert_memory_entry_statement(entry, owner_id)) inserted_count += 1 if statements: - self._run_write(statements) + await self._run_write(statements) return inserted_count - def _event_exists(self, event_id: str) -> bool: - sql = f"SELECT event_id FROM {self._memory_table} WHERE event_id = @event_id LIMIT 1" - rows = self._run_read(sql, {"event_id": event_id}, {"event_id": SPANNER_PARAM_TYPES.STRING}) + async def _event_exists(self, event_id: str) -> bool: + sql, params, types = self._build_event_exists_query(event_id) + rows = await self._run_read(sql, params, types) return bool(rows) - def _search_entries( + async def _search_entries( self, query: str, app_name: str, @@ -1058,88 +1735,43 @@ def _search_entries( effective_limit = limit if limit is not None else self._max_results if self._use_fts: - return self._search_entries_fts(query, app_name, user_id, effective_limit, scope_filter) - return self._search_entries_simple(query, app_name, user_id, effective_limit, scope_filter) + return await self._search_entries_fts(query, app_name, user_id, effective_limit, scope_filter) + return await self._search_entries_simple(query, app_name, user_id, effective_limit, scope_filter) - def _search_entries_fts( + async def _search_entries_fts( self, query: str, app_name: str, user_id: str, limit: int, scope_filter: Literal["all", "user", "app"] = "all" ) -> "list[StoredMemory]": - where_scope, scope_params, scope_types = _build_spanner_scope_where(app_name, user_id, scope_filter) - sql = f""" - SELECT id, session_id, app_name, user_id, scope, event_id, author, - timestamp, content_json, content_text, metadata_json, inserted_at - FROM {self._memory_table} - WHERE {where_scope} - AND SEARCH(content_tokens, @query) - ORDER BY timestamp DESC - LIMIT @limit - """ - params = {**scope_params, "query": query, "limit": limit} - types = {**scope_types, "query": SPANNER_PARAM_TYPES.STRING, "limit": SPANNER_PARAM_TYPES.INT64} - rows = self._run_read(sql, params, types) + sql, params, types = self._build_search_entries_fts_query(query, app_name, user_id, limit, scope_filter) + rows = await self._run_read(sql, params, types) return self._rows_to_records(rows) - def _search_entries_simple( + async def _search_entries_simple( self, query: str, app_name: str, user_id: str, limit: int, scope_filter: Literal["all", "user", "app"] = "all" ) -> "list[StoredMemory]": - where_scope, scope_params, scope_types = _build_spanner_scope_where(app_name, user_id, scope_filter) - sql = f""" - SELECT id, session_id, app_name, user_id, scope, event_id, author, - timestamp, content_json, content_text, metadata_json, inserted_at - FROM {self._memory_table} - WHERE {where_scope} - AND LOWER(content_text) LIKE @pattern - ORDER BY timestamp DESC - LIMIT @limit - """ - pattern = f"%{query.lower()}%" - params = {**scope_params, "pattern": pattern, "limit": limit} - types = {**scope_types, "pattern": SPANNER_PARAM_TYPES.STRING, "limit": SPANNER_PARAM_TYPES.INT64} - rows = self._run_read(sql, params, types) + sql, params, types = self._build_search_entries_simple_query(query, app_name, user_id, limit, scope_filter) + rows = await self._run_read(sql, params, types) return self._rows_to_records(rows) - def _delete_entries_by_session(self, session_id: str) -> int: - sql = f"DELETE FROM {self._memory_table} WHERE session_id = @session_id" - params = {"session_id": session_id} - types = {"session_id": SPANNER_PARAM_TYPES.STRING} - return self._execute_update(sql, params, types) + async def _delete_entries_by_session(self, session_id: str) -> int: + sql, params, types = self._build_delete_entries_by_session_statement(session_id) + return await self._execute_update(sql, params, types) - def _delete_entries_older_than(self, days: int, app_name: "str | None" = None, scope: "str | None" = None) -> int: - cutoff = datetime.now(timezone.utc) - timedelta(days=days) - clauses = ["inserted_at < @cutoff"] - params: dict[str, Any] = {"cutoff": cutoff} - types: dict[str, Any] = {"cutoff": SPANNER_PARAM_TYPES.TIMESTAMP} - if app_name is not None: - clauses.append("app_name = @app_name") - params["app_name"] = app_name - types["app_name"] = SPANNER_PARAM_TYPES.STRING - if scope is not None: - clauses.append("scope = @scope") - params["scope"] = scope - types["scope"] = SPANNER_PARAM_TYPES.STRING - where_sql = " AND ".join(clauses) - sql = f"DELETE FROM {self._memory_table} WHERE {where_sql}" - return self._execute_update(sql, params, types) + async def _delete_entries_older_than( + self, days: int, app_name: "str | None" = None, scope: "str | None" = None + ) -> int: + sql, params, types = self._build_delete_entries_older_than_statement(days, app_name, scope) + return await self._execute_update(sql, params, types) - def _rows_to_records(self, rows: "list[Any]") -> "list[StoredMemory]": - return [ - { - "id": row[0], - "session_id": row[1], - "app_name": row[2], - "user_id": row[3], - "scope": row[4], - "event_id": row[5], - "author": row[6], - "timestamp": row[7], - "content_json": self._decode_json(row[8]), - "content_text": row[9], - "metadata_json": self._decode_json(row[10]), - "inserted_at": row[11], - "embedding": None, - } - for row in rows - ] + +async def _run_read_async( + database: "SpannerAsyncDatabase", + sql: str, + params: "dict[str, Any] | None" = None, + types: "dict[str, Any] | None" = None, +) -> "list[Any]": + async with cast("Any", database).snapshot() as snapshot: + result_set = await snapshot.execute_sql(sql, params=params, param_types=types) + return [row async for row in result_set] def _json_param_type() -> Any: @@ -1149,6 +1781,34 @@ def _json_param_type() -> Any: return SPANNER_PARAM_TYPES.STRING +def _extract_spanner_adk_options(config: Any) -> "tuple[int, str | None, str | None, str | None, str, str]": + """Return shard count, table options, and row deletion policies for the ADK session tables.""" + adk_config = _adk_config(config) + return ( + _spanner_shard_count(adk_config), + adk_config.get("session_table_options"), + adk_config.get("events_table_options"), + adk_config.get("expires_index_options"), + _spanner_row_deletion_policy(adk_config, "session_ttl_seconds", "create_time"), + _spanner_row_deletion_policy(adk_config, "event_ttl_seconds", "timestamp"), + ) + + +def _extract_spanner_memory_options(config: Any) -> "tuple[int, str | None, str]": + """Return shard count, table options, and row deletion policy for the ADK memory table.""" + adk_config = _adk_config(config) + return ( + _spanner_shard_count(adk_config), + adk_config.get("memory_table_options"), + _spanner_row_deletion_policy(adk_config, "memory_ttl_seconds", "inserted_at"), + ) + + +def _spanner_shard_count(adk_config: "SpannerADKConfig") -> int: + shard_count = adk_config.get("shard_count") + return int(shard_count) if shard_count else 0 + + def _spanner_ttl_days(ttl_seconds: Any) -> int: if not isinstance(ttl_seconds, int) or ttl_seconds <= 0: return 0 @@ -1212,13 +1872,15 @@ def _spanner_drop_statement_table(statement: str, existing_tables: "set[str]") - return None -class _SpannerWriteJob: +class _SpannerSyncWriteJob: + """Callable transaction work item for Spanner sync ADK write batches.""" + __slots__ = ("_statements",) def __init__(self, statements: "list[tuple[str, dict[str, Any], dict[str, Any]]]") -> None: self._statements = statements - def __call__(self, transaction: "Transaction") -> None: + def __call__(self, transaction: "SpannerTransaction") -> None: if len(self._statements) > 1: status, _row_counts = transaction.batch_update(self._statements) # type: ignore[no-untyped-call] if status.code != 0: @@ -1229,24 +1891,28 @@ def __call__(self, transaction: "Transaction") -> None: transaction.execute_update(sql, params=params, param_types=types) # type: ignore[no-untyped-call] -class _SpannerMemoryWriteJob: +class _SpannerAsyncWriteJob: + """Callable async transaction work item for Spanner async ADK write batches.""" + __slots__ = ("_statements",) def __init__(self, statements: "list[tuple[str, dict[str, Any], dict[str, Any]]]") -> None: self._statements = statements - def __call__(self, transaction: "Transaction") -> None: + async def __call__(self, transaction: "SpannerAsyncTransaction") -> None: if len(self._statements) > 1: - status, _row_counts = transaction.batch_update(self._statements) # type: ignore[no-untyped-call] + status, _row_counts = await transaction.batch_update(self._statements) if status.code != 0: msg = f"Spanner batch update failed (code {status.code}): {status.message}" raise OperationalError(msg) return for sql, params, types in self._statements: - transaction.execute_update(sql, params=params, param_types=types) # type: ignore[no-untyped-call] + await transaction.execute_update(sql, params=params, param_types=types) + +class _SpannerSyncUpdateJob: + """Callable transaction work item for Spanner sync ADK single DML updates.""" -class _SpannerUpdateJob: __slots__ = ("_params", "_sql", "_types") def __init__(self, sql: str, params: "dict[str, Any]", types: "dict[str, Any]") -> None: @@ -1254,11 +1920,13 @@ def __init__(self, sql: str, params: "dict[str, Any]", types: "dict[str, Any]") self._params = params self._types = types - def __call__(self, transaction: "Transaction") -> int: + def __call__(self, transaction: "SpannerTransaction") -> int: return int(transaction.execute_update(self._sql, params=self._params, param_types=self._types)) # type: ignore[no-untyped-call] -class _SpannerMemoryUpdateJob: +class _SpannerAsyncUpdateJob: + """Callable async transaction work item for Spanner async ADK single DML updates.""" + __slots__ = ("_params", "_sql", "_types") def __init__(self, sql: str, params: "dict[str, Any]", types: "dict[str, Any]") -> None: @@ -1266,8 +1934,8 @@ def __init__(self, sql: str, params: "dict[str, Any]", types: "dict[str, Any]") self._params = params self._types = types - def __call__(self, transaction: "Transaction") -> int: - return int(transaction.execute_update(self._sql, params=self._params, param_types=self._types)) # type: ignore[no-untyped-call] + async def __call__(self, transaction: "SpannerAsyncTransaction") -> int: + return int(await transaction.execute_update(self._sql, params=self._params, param_types=self._types)) class _SpannerReadProtocol(Protocol): diff --git a/sqlspec/adapters/spanner/config.py b/sqlspec/adapters/spanner/config.py index b32286a87..0c1c3658d 100644 --- a/sqlspec/adapters/spanner/config.py +++ b/sqlspec/adapters/spanner/config.py @@ -5,28 +5,52 @@ from typing_extensions import NotRequired +from sqlspec.adapters.spanner._typing import SpannerAbstractSessionPool as AbstractSessionPool +from sqlspec.adapters.spanner._typing import SpannerAsyncAbstractSessionPool as AsyncAbstractSessionPool +from sqlspec.adapters.spanner._typing import SpannerAsyncBurstyPool as AsyncBurstyPool +from sqlspec.adapters.spanner._typing import SpannerAsyncClient as AsyncClient +from sqlspec.adapters.spanner._typing import ( + SpannerAsyncConnection, + SpannerAsyncSessionContext, + SpannerAsyncTransactionType, + SpannerGoogleAPICallError, + SpannerSyncConnection, + SpannerSyncSessionContext, + SpannerTransactionType, +) +from sqlspec.adapters.spanner._typing import SpannerAsyncFixedSizePool as AsyncFixedSizePool +from sqlspec.adapters.spanner._typing import SpannerAsyncPingingPool as AsyncPingingPool from sqlspec.adapters.spanner._typing import SpannerBurstyPool as BurstyPool from sqlspec.adapters.spanner._typing import SpannerClient as Client -from sqlspec.adapters.spanner._typing import SpannerConnection from sqlspec.adapters.spanner._typing import SpannerFixedSizePool as FixedSizePool from sqlspec.adapters.spanner._typing import SpannerPingingPool as PingingPool -from sqlspec.adapters.spanner._typing import SpannerTransactionType as TransactionType -from sqlspec.adapters.spanner.core import apply_driver_features, default_statement_config -from sqlspec.adapters.spanner.driver import SpannerSessionContext, SpannerSyncDriver -from sqlspec.config import SyncDatabaseConfig +from sqlspec.adapters.spanner.core import ( + apply_driver_features, + build_session_driver_features, + default_statement_config, + resolve_transaction_completion, + run_in_transaction_async, + run_in_transaction_sync, +) +from sqlspec.adapters.spanner.driver import SpannerAsyncDriver, SpannerSyncDriver +from sqlspec.config import AsyncDatabaseConfig, SyncDatabaseConfig from sqlspec.core import TypeCoercionCapabilities -from sqlspec.driver import SyncPoolConnectionContext, SyncPoolSessionFactory +from sqlspec.driver import ( + AsyncPoolConnectionContext, + AsyncPoolSessionFactory, + SyncPoolConnectionContext, + SyncPoolSessionFactory, +) from sqlspec.exceptions import ImproperConfigurationError from sqlspec.extensions.events import EventRuntimeHints from sqlspec.utils.config_tools import normalize_connection_config -from sqlspec.utils.type_guards import supports_close if TYPE_CHECKING: - from collections.abc import Callable, Mapping + from collections.abc import Awaitable, Callable, Mapping from logging import Logger from types import TracebackType - from sqlspec.adapters.spanner._typing import SpannerAbstractSessionPool as AbstractSessionPool + from sqlspec.adapters.spanner._typing import SpannerAsyncDatabase as AsyncDatabase from sqlspec.adapters.spanner._typing import SpannerClientInfo as ClientInfo from sqlspec.adapters.spanner._typing import SpannerClientOptions as ClientOptions from sqlspec.adapters.spanner._typing import SpannerCredentials as Credentials @@ -43,6 +67,7 @@ from sqlspec.observability import ObservabilityConfig __all__ = ( + "SpannerAsyncConfig", "SpannerConnectionParams", "SpannerDriverFeatures", "SpannerPoolParams", @@ -53,10 +78,8 @@ _DEFAULT_SESSION_TRANSACTION: bool = True """Default ``transaction`` flag for ``provide_session`` / ``provide_connection``. -``True`` yields a write-capable :class:`Transaction` context matching every -other sqlspec adapter. Read-only :class:`Snapshot` contexts are available via -:meth:`SpannerSyncConfig.provide_read_session`. Pulled into a module-level -constant so an eventual ``SpannerAsyncConfig`` can import the same default.""" +``True`` yields a write-capable ``Transaction`` context; read-only +``Snapshot`` contexts are available through ``provide_read_session``.""" _CLIENT_CONFIG_FIELDS = frozenset({ "project", @@ -133,9 +156,14 @@ class SpannerConnectionParams(TypedDict): class SpannerPoolParams(SpannerConnectionParams): - """Session pool configuration.""" + """Session pool configuration. + + ``pool_type`` must be a sync pool class for :class:`SpannerSyncConfig` + (default ``FixedSizePool``) and an async pool class for + :class:`SpannerAsyncConfig` (default async ``FixedSizePool``). + """ - pool_type: "NotRequired[type[AbstractSessionPool]]" + pool_type: "NotRequired[type[AbstractSessionPool | AsyncAbstractSessionPool]]" size: "NotRequired[int]" target_size: "NotRequired[int]" max_sessions: "NotRequired[int]" @@ -202,92 +230,178 @@ def build_connection_config( return config -class SpannerConnectionContext(SyncPoolConnectionContext): - """Context manager for Spanner connections.""" +def _prepare_settings( + connection_config: "SpannerPoolParams | dict[str, Any] | None", + driver_features: "SpannerDriverFeatures | dict[str, Any] | None", + *, + default_pool_type: type[Any], +) -> "tuple[dict[str, Any], dict[str, Any]]": + """Normalize connection settings and move legacy session labels onto the pool settings.""" + config = build_connection_config(connection_config) + if "min_sessions" in config: + msg = "Spanner session pools do not support 'min_sessions'; use 'size' or 'target_size'." + raise ImproperConfigurationError(msg) + + raw_driver_features: dict[str, Any] = dict(driver_features) if driver_features else {} + legacy_session_labels = raw_driver_features.pop("session_labels", None) + if legacy_session_labels is not None and "session_labels" not in config and "labels" not in config: + config["session_labels"] = legacy_session_labels + + config.setdefault("size", config.pop("max_sessions", 10)) + config.setdefault("pool_type", default_pool_type) + return config, raw_driver_features + + +def _require_database_ids(connection_config: "dict[str, Any]") -> "tuple[str, str]": + instance_id = connection_config.get("instance_id") + database_id = connection_config.get("database_id") + if not instance_id or not database_id: + msg = "instance_id and database_id are required." + raise ImproperConfigurationError(msg) + return instance_id, database_id + + +def _connection_kwargs_for(connection_config: "dict[str, Any]", fields: "frozenset[str] | set[str]") -> dict[str, Any]: + return {field: connection_config[field] for field in fields if connection_config.get(field) is not None} + + +def _instance_kwargs(connection_config: "dict[str, Any]") -> dict[str, Any]: + instance_kwargs = _connection_kwargs_for(connection_config, _INSTANCE_CONFIG_FIELDS) + instance_labels = connection_config.get("instance_labels") + if instance_labels is not None: + instance_kwargs["labels"] = instance_labels + return instance_kwargs + + +def _resolve_pool_type( + connection_config: "dict[str, Any]", *, base: type[Any], config_name: str, expected: str +) -> type[Any]: + pool_type = connection_config["pool_type"] + if not isinstance(pool_type, type) or not issubclass(pool_type, base): + msg = f"{config_name} requires {expected} session pool class; got {pool_type!r}." + raise ImproperConfigurationError(msg) + return pool_type + + +def _pool_kwargs(connection_config: "dict[str, Any]", pool_kind: str) -> dict[str, Any]: + """Build session pool constructor arguments for a pool kind. + + Args: + connection_config: Normalized connection settings. + pool_kind: ``"pinging"``, ``"fixed"``, ``"bursty"``, or ``"custom"``. + + Returns: + Keyword arguments for the pool class. + """ + pool_kwargs: dict[str, Any] = {} + labels = connection_config.get("session_labels", connection_config.get("labels")) + if labels is not None: + pool_kwargs["labels"] = labels + database_role = connection_config.get("database_role") + if database_role is not None: + pool_kwargs["database_role"] = database_role + if pool_kind == "pinging": + pool_kwargs.update(_connection_kwargs_for(connection_config, {"size", "default_timeout"})) + pool_kwargs["ping_interval"] = connection_config.get("ping_interval", 1800) + elif pool_kind == "fixed": + pool_kwargs.update(_connection_kwargs_for(connection_config, {"size", "default_timeout", "max_age_minutes"})) + elif pool_kind == "bursty": + target_size = connection_config.get("target_size", connection_config.get("size")) + if target_size is not None: + pool_kwargs["target_size"] = target_size + else: + pool_kwargs.update( + _connection_kwargs_for( + connection_config, {"size", "target_size", "default_timeout", "ping_interval", "max_age_minutes"} + ) + ) + return pool_kwargs + + +class SpannerSyncConnectionContext(SyncPoolConnectionContext): + """Context manager for sync Spanner connections (Snapshot or Transaction).""" __slots__ = ("_connection", "_session", "_transaction") def __init__(self, config: "SpannerSyncConfig", transaction: bool = False) -> None: super().__init__(config) self._transaction = transaction - self._connection: SpannerConnection | None = None + self._connection: SpannerSyncConnection | None = None self._session: Any = None - def __enter__(self) -> SpannerConnection: + def __enter__(self) -> "SpannerSyncConnection": database = self._config.get_database() if self._transaction: manager = cast("Any", database).sessions_manager - self._session = manager.get_session(TransactionType.READ_WRITE) + self._session = manager.get_session(SpannerTransactionType.READ_WRITE) try: txn = self._session.transaction() txn.__enter__() - self._connection = cast("SpannerConnection", txn) except Exception: manager.put_session(self._session) self._session = None raise - else: - return self._connection - else: - self._session = cast("Any", database).snapshot(multi_use=True) - self._connection = cast("SpannerConnection", self._session.__enter__()) + self._connection = cast("SpannerSyncConnection", txn) return self._connection + self._session = cast("Any", database).snapshot(multi_use=True) + self._connection = cast("SpannerSyncConnection", self._session.__enter__()) + return self._connection + def __exit__( self, exc_type: "type[BaseException] | None", exc_val: "BaseException | None", exc_tb: "TracebackType | None" - ) -> bool | None: - if self._transaction and self._connection: - txn = cast("Any", self._connection) + ) -> "bool | None": + if self._transaction: try: - if exc_type is None: - try: - txn_id = txn._transaction_id - except AttributeError: - txn_id = None - mutations = cast("list[Any] | None", getattr(txn, "_mutations", None)) - try: - committed = txn.committed - except AttributeError: - committed = None - if committed is None and (txn_id is not None or bool(mutations)): + if self._connection is not None: + txn = cast("Any", self._connection) + action = resolve_transaction_completion(txn, failed=exc_type is not None) + if action == "commit": txn.commit() - else: - try: - rollback_txn_id = txn._transaction_id - except AttributeError: - rollback_txn_id = None - if rollback_txn_id is not None: + elif action == "rollback": txn.rollback() finally: - if self._session: + if self._session is not None: cast("Any", self._config.get_database()).sessions_manager.put_session(self._session) - elif self._session: - self._session.__exit__(exc_type, exc_val, exc_tb) + self._connection = None + self._session = None + return None + if self._session is not None: + self._session.__exit__(exc_type, exc_val, exc_tb) self._connection = None self._session = None return None -class _SpannerSessionConnectionHandler(SyncPoolSessionFactory): - __slots__ = ("_connection_ctx",) +class _SpannerSyncSessionConnectionHandler(SyncPoolSessionFactory): + """Sync session handler that uses SpannerSyncConnectionContext.""" - def __init__(self, config: "SpannerSyncConfig", connection_ctx: "SpannerConnectionContext") -> None: + __slots__ = ("_transaction",) + + def __init__(self, config: "SpannerSyncConfig", transaction: bool = False) -> None: super().__init__(config) - self._connection_ctx = connection_ctx + self._transaction = transaction - def acquire_connection(self) -> "SpannerConnection": - return self._connection_ctx.__enter__() + def acquire_connection(self) -> "SpannerSyncConnection": + self._ctx = SpannerSyncConnectionContext(self._config, transaction=self._transaction) + return cast("SpannerSyncConnectionContext", self._ctx).__enter__() - def release_connection(self, _conn: "SpannerConnection", **kwargs: Any) -> None: - self._connection_ctx.__exit__(kwargs.get("exc_type"), kwargs.get("exc_val"), kwargs.get("exc_tb")) + def release_connection(self, _conn: "SpannerSyncConnection", **kwargs: Any) -> None: + if self._ctx is not None: + context = cast("SpannerSyncConnectionContext", self._ctx) + context._connection = _conn + context.__exit__(kwargs.get("exc_type"), kwargs.get("exc_val"), kwargs.get("exc_tb")) + self._ctx = None -class SpannerSyncConfig(SyncDatabaseConfig["SpannerConnection", "AbstractSessionPool", SpannerSyncDriver]): +class SpannerSyncConfig(SyncDatabaseConfig["SpannerSyncConnection", "AbstractSessionPool", SpannerSyncDriver]): """Spanner configuration and session management.""" driver_type: ClassVar[type["SpannerSyncDriver"]] = SpannerSyncDriver - connection_type: ClassVar[type["SpannerConnection"]] = cast("type[SpannerConnection]", SpannerConnection) + connection_type: ClassVar[type["SpannerSyncConnection"]] = cast( + "type[SpannerSyncConnection]", SpannerSyncConnection + ) supports_transactional_ddl: ClassVar[bool] = False supports_native_arrow_export: ClassVar[bool] = True supports_native_arrow_import: ClassVar[bool] = True @@ -296,9 +410,11 @@ class SpannerSyncConfig(SyncDatabaseConfig["SpannerConnection", "AbstractSession type_coercion_capabilities: ClassVar[TypeCoercionCapabilities] = TypeCoercionCapabilities( datetime_binding="native", timestamp_precision="microsecond", json_columns_decoded=True, uuid_binding="text" ) - _connection_context_class: "ClassVar[type[SpannerConnectionContext]]" = SpannerConnectionContext - _session_factory_class: "ClassVar[type[_SpannerSessionConnectionHandler]]" = _SpannerSessionConnectionHandler - _session_context_class: "ClassVar[type[SpannerSessionContext]]" = SpannerSessionContext + _connection_context_class: "ClassVar[type[SpannerSyncConnectionContext]]" = SpannerSyncConnectionContext + _session_factory_class: "ClassVar[type[_SpannerSyncSessionConnectionHandler]]" = ( + _SpannerSyncSessionConnectionHandler + ) + _session_context_class: "ClassVar[type[SpannerSyncSessionContext]]" = SpannerSyncSessionContext _default_statement_config = default_statement_config def __init__( @@ -314,32 +430,19 @@ def __init__( observability_config: "ObservabilityConfig | None" = None, **kwargs: Any, ) -> None: - self.connection_config = build_connection_config(connection_config) - if "min_sessions" in self.connection_config: - msg = "Spanner session pools do not support 'min_sessions'; use 'size' or 'target_size'." - raise ImproperConfigurationError(msg) - - raw_driver_features: dict[str, Any] = dict(driver_features) if driver_features else {} - legacy_session_labels = raw_driver_features.pop("session_labels", None) - if ( - legacy_session_labels is not None - and "session_labels" not in self.connection_config - and "labels" not in self.connection_config - ): - self.connection_config["session_labels"] = legacy_session_labels - - self.connection_config.setdefault("size", self.connection_config.pop("max_sessions", 10)) - self.connection_config.setdefault("pool_type", FixedSizePool) - - statement_config = statement_config or default_statement_config - statement_config, driver_features = apply_driver_features(statement_config, raw_driver_features) + self.connection_config, raw_driver_features = _prepare_settings( + connection_config, driver_features, default_pool_type=FixedSizePool + ) + statement_config, processed_features = apply_driver_features( + statement_config or default_statement_config, raw_driver_features + ) super().__init__( connection_config=self.connection_config, connection_instance=connection_instance, migration_config=migration_config, statement_config=statement_config, - driver_features=driver_features, + driver_features=processed_features, bind_key=bind_key, extension_config=extension_config, observability_config=observability_config, @@ -351,113 +454,77 @@ def __init__( def _get_client(self) -> "Client": if self._client is None: - client_kwargs = self._connection_kwargs_for(_CLIENT_CONFIG_FIELDS) - self._client = Client(**client_kwargs) + self._client = Client(**_connection_kwargs_for(self.connection_config, _CLIENT_CONFIG_FIELDS)) return self._client def get_database(self) -> "Database": - instance_id = self.connection_config.get("instance_id") - database_id = self.connection_config.get("database_id") - if not instance_id or not database_id: - msg = "instance_id and database_id are required." - raise ImproperConfigurationError(msg) + """Return the configured database, creating the pool and client on first use. + + Returns: + The Spanner database bound to the configured session pool. + + Raises: + ImproperConfigurationError: If ``instance_id`` or ``database_id`` is missing. + """ + instance_id, database_id = _require_database_ids(self.connection_config) if self.connection_instance is None: self.connection_instance = self.provide_pool() if self._database is None: - client = self._get_client() - instance_kwargs = self._connection_kwargs_for(_INSTANCE_CONFIG_FIELDS) - instance_labels = self.connection_config.get("instance_labels") - if instance_labels is not None: - instance_kwargs["labels"] = instance_labels - database_kwargs = self._connection_kwargs_for(_DATABASE_CONFIG_FIELDS) - database_kwargs["pool"] = self.connection_instance - self._database = client.instance(instance_id, **instance_kwargs).database( # type: ignore[no-untyped-call] - database_id, **database_kwargs + instance = cast("Any", self._get_client()).instance(instance_id, **_instance_kwargs(self.connection_config)) + self._database = cast( + "Database", + instance.database( + database_id, + pool=self.connection_instance, + **_connection_kwargs_for(self.connection_config, _DATABASE_CONFIG_FIELDS), + ), ) return self._database - def create_connection(self) -> SpannerConnection: + def create_connection(self) -> "SpannerSyncConnection": """Return a read-only snapshot checkout owned by the caller. - The result is the database's own checkout object: it borrows a pooled - session when entered and returns it on exit. Returning an already-entered - snapshot instead would borrow a session that nothing could give back. + The checkout borrows a pooled session when entered and returns it on exit. Returns: A snapshot checkout to be used as a context manager. """ - return cast("SpannerConnection", self.get_database().snapshot(multi_use=True)) # type: ignore[no-untyped-call] + return cast("SpannerSyncConnection", cast("Any", self.get_database()).snapshot(multi_use=True)) def _create_pool(self) -> "AbstractSessionPool": - instance_id = self.connection_config.get("instance_id") - database_id = self.connection_config.get("database_id") - if not instance_id or not database_id: - msg = "instance_id and database_id are required." - raise ImproperConfigurationError(msg) - - pool_type = cast("type[AbstractSessionPool]", self.connection_config.get("pool_type", FixedSizePool)) - - labels = self.connection_config.get("session_labels", self.connection_config.get("labels")) - pool_kwargs: dict[str, Any] = self._pool_base_kwargs(labels=cast("dict[str, str] | None", labels)) + _require_database_ids(self.connection_config) + pool_type = _resolve_pool_type( + self.connection_config, base=AbstractSessionPool, config_name="SpannerSyncConfig", expected="a sync" + ) if issubclass(pool_type, PingingPool): - pool_kwargs.update(self._connection_kwargs_for({"size", "default_timeout", "ping_interval"})) + pool_kind = "pinging" elif issubclass(pool_type, FixedSizePool): - pool_kwargs.update(self._connection_kwargs_for({"size", "default_timeout", "max_age_minutes"})) + pool_kind = "fixed" elif issubclass(pool_type, BurstyPool): - target_size = self.connection_config.get("target_size", self.connection_config.get("size")) - if target_size is not None: - pool_kwargs["target_size"] = target_size + pool_kind = "bursty" else: - pool_kwargs.update( - self._connection_kwargs_for({ - "size", - "target_size", - "default_timeout", - "ping_interval", - "max_age_minutes", - }) - ) - - pool_factory = cast("Callable[..., AbstractSessionPool]", pool_type) - return pool_factory(**pool_kwargs) - - def _pool_base_kwargs(self, *, labels: "dict[str, str] | None") -> dict[str, Any]: - pool_kwargs: dict[str, Any] = {} - if labels is not None: - pool_kwargs["labels"] = labels - database_role = self.connection_config.get("database_role") - if database_role is not None: - pool_kwargs["database_role"] = database_role - return pool_kwargs - - def _connection_kwargs_for(self, fields: "frozenset[str] | set[str]") -> dict[str, Any]: - return { - field: self.connection_config[field] for field in fields if self.connection_config.get(field) is not None - } + pool_kind = "custom" + return cast("AbstractSessionPool", pool_type(**_pool_kwargs(self.connection_config, pool_kind))) def _close_pool(self) -> None: - """Release sessions before the database that manages them is torn down.""" + """Release pooled sessions, then close the database and client.""" pool = self.connection_instance if pool is not None: - clear = getattr(pool, "clear", None) - if callable(clear): - with contextlib.suppress(Exception): - clear() - elif supports_close(pool): - pool.close() - if self._database is not None and supports_close(self._database): - with contextlib.suppress(Exception): - self._database.close() - if self._client and supports_close(self._client): - self._client.close() + with contextlib.suppress(SpannerGoogleAPICallError): + pool.clear() # type: ignore[no-untyped-call] + if self._database is not None: + with contextlib.suppress(SpannerGoogleAPICallError): + self._database.close() # type: ignore[no-untyped-call] + if self._client is not None: + self._client.close() # type: ignore[no-untyped-call] self._client = None self._database = None def provide_connection( self, *args: Any, transaction: "bool" = _DEFAULT_SESSION_TRANSACTION, **kwargs: Any - ) -> "SpannerConnectionContext": + ) -> "SpannerSyncConnectionContext": """Yield a Transaction (default) or Snapshot context from the configured pool. Args: @@ -466,8 +533,11 @@ def provide_connection( supports execute_update() for DML statements. If False, yields a read-only Snapshot context for SELECT queries. **kwargs: Additional keyword arguments (unused, for interface compatibility). + + Returns: + A Spanner connection context manager. """ - return SpannerConnectionContext(self, transaction=transaction) + return SpannerSyncConnectionContext(self, transaction=transaction) def provide_session( self, @@ -480,12 +550,12 @@ def provide_session( retry: "Retry | None" = None, timeout: "float | None" = None, **kwargs: Any, - ) -> "SpannerSessionContext": + ) -> "SpannerSyncSessionContext": """Provide a Spanner driver session context manager. - Returns a write-capable Transaction session by default, matching every - other sqlspec adapter. Pass ``transaction=False`` or use - :meth:`provide_read_session` to obtain a read-only Snapshot session. + Returns a write-capable Transaction session by default. Pass + ``transaction=False`` or use :meth:`provide_read_session` to obtain a + read-only Snapshot session. Args: *args: Additional arguments. @@ -502,14 +572,13 @@ def provide_session( Returns: A Spanner driver session context manager. """ - connection_ctx = SpannerConnectionContext(self, transaction=transaction) - handler = _SpannerSessionConnectionHandler(self, connection_ctx) - - return SpannerSessionContext( + handler = _SpannerSyncSessionConnectionHandler(self, transaction=transaction) + return SpannerSyncSessionContext( acquire_connection=handler.acquire_connection, release_connection=handler.release_connection, statement_config=statement_config or self.statement_config or default_statement_config, - driver_features=self._session_driver_features( + driver_features=build_session_driver_features( + self.driver_features, request_options=request_options, directed_read_options=directed_read_options, query_options=query_options, @@ -529,7 +598,7 @@ def provide_write_session( retry: "Retry | None" = None, timeout: "float | None" = None, **kwargs: Any, - ) -> "SpannerSessionContext": + ) -> "SpannerSyncSessionContext": """Provide a write-capable Spanner session (alias for :meth:`provide_session`).""" return self.provide_session( *args, @@ -553,7 +622,7 @@ def provide_read_session( retry: "Retry | None" = None, timeout: "float | None" = None, **kwargs: Any, - ) -> "SpannerSessionContext": + ) -> "SpannerSyncSessionContext": """Provide a read-only Snapshot Spanner session. Use for query workloads that benefit from Spanner's snapshot reads. @@ -571,35 +640,22 @@ def provide_read_session( **kwargs, ) - def _session_driver_features( - self, - *, - request_options: "RequestOptions | dict[str, Any] | None", - directed_read_options: "DirectedReadOptions | None", - query_options: "ExecuteSqlRequest.QueryOptions | dict[str, Any] | None" = None, - retry: "Retry | None", - timeout: "float | None", - ) -> "dict[str, Any]": - if ( - request_options is None - and directed_read_options is None - and query_options is None - and retry is None - and timeout is None - ): - return self.driver_features - driver_features = dict(self.driver_features) - if request_options is not None: - driver_features["request_options"] = request_options - if directed_read_options is not None: - driver_features["directed_read_options"] = directed_read_options - if query_options is not None: - driver_features["query_options"] = query_options - if retry is not None: - driver_features["retry"] = retry - if timeout is not None: - driver_features["timeout"] = timeout - return driver_features + def run_in_transaction(self, fn: "Callable[..., Any]", *args: Any, **kwargs: Any) -> Any: + """Execute a callable inside Spanner's retryable transaction runner. + + ``fn`` is called as ``fn(driver, *args, **kwargs)`` with a driver bound + to the runner's transaction. A ``DeadlockError`` caused by + ``google.api_core.exceptions.Aborted`` is unwrapped so the runner + retries, and a terminal ``Aborted`` is raised as ``DeadlockError``. + """ + return run_in_transaction_sync(self.get_database(), self._transaction_driver, fn, *args, **kwargs) + + def _transaction_driver(self, transaction: "SpannerSyncConnection") -> "SpannerSyncDriver": + driver = self.driver_type( + connection=transaction, statement_config=self.statement_config, driver_features=self.driver_features + ) + driver._owns_transaction = False # pyright: ignore[reportPrivateUsage] + return self._prepare_driver(driver) def get_signature_namespace(self) -> "dict[str, Any]": """Get the signature namespace for SpannerSyncConfig types. @@ -609,18 +665,383 @@ def get_signature_namespace(self) -> "dict[str, Any]": """ namespace = super().get_signature_namespace() namespace.update({ - "SpannerConnectionContext": SpannerConnectionContext, - "SpannerConnection": SpannerConnection, "SpannerConnectionParams": SpannerConnectionParams, "SpannerDriverFeatures": SpannerDriverFeatures, "SpannerPoolParams": SpannerPoolParams, - "SpannerSessionContext": SpannerSessionContext, "SpannerSyncConfig": SpannerSyncConfig, + "SpannerSyncConnection": SpannerSyncConnection, + "SpannerSyncConnectionContext": SpannerSyncConnectionContext, "SpannerSyncDriver": SpannerSyncDriver, + "SpannerSyncSessionContext": SpannerSyncSessionContext, }) return namespace def get_event_runtime_hints(self) -> "EventRuntimeHints": """Return queue defaults for Spanner JSON handling.""" + return EventRuntimeHints() + + +class SpannerAsyncConnectionContext(AsyncPoolConnectionContext): + """Context manager for async Spanner connections (Snapshot or Transaction).""" + + __slots__ = ("_session", "_transaction") + + def __init__(self, config: "SpannerAsyncConfig", transaction: bool = False) -> None: + super().__init__(config) + self._transaction = transaction + self._connection: SpannerAsyncConnection | None = None + self._session: Any = None + + async def __aenter__(self) -> "SpannerAsyncConnection": + database = await self._config.get_database() + if self._transaction: + manager = cast("Any", database).sessions_manager + self._session = await manager.get_session(SpannerAsyncTransactionType.READ_WRITE) + try: + txn = self._session.transaction() + await txn.__aenter__() + except Exception: + await manager.put_session(self._session) + self._session = None + raise + self._connection = cast("SpannerAsyncConnection", txn) + return self._connection + + self._session = cast("Any", database).snapshot(multi_use=True) + self._connection = cast("SpannerAsyncConnection", await self._session.__aenter__()) + return self._connection + + async def __aexit__( + self, exc_type: "type[BaseException] | None", exc_val: "BaseException | None", exc_tb: "TracebackType | None" + ) -> "bool | None": + if self._transaction: + try: + if self._connection is not None: + txn = cast("Any", self._connection) + action = resolve_transaction_completion(txn, failed=exc_type is not None) + if action == "commit": + await txn.commit() + elif action == "rollback": + await txn.rollback() + finally: + if self._session is not None: + database = await self._config.get_database() + await cast("Any", database).sessions_manager.put_session(self._session) + self._connection = None + self._session = None + return None + + if self._session is not None: + await self._session.__aexit__(exc_type, exc_val, exc_tb) + self._connection = None + self._session = None + return None + + +class _SpannerAsyncSessionConnectionHandler(AsyncPoolSessionFactory): + """Async session handler that uses SpannerAsyncConnectionContext.""" + + __slots__ = ("_ctx", "_transaction") + def __init__(self, config: "SpannerAsyncConfig", transaction: bool = False) -> None: + super().__init__(config) + self._transaction = transaction + self._ctx: SpannerAsyncConnectionContext | None = None + + async def acquire_connection(self) -> "SpannerAsyncConnection": + self._ctx = SpannerAsyncConnectionContext(self._config, transaction=self._transaction) + return await self._ctx.__aenter__() + + async def release_connection(self, _conn: "SpannerAsyncConnection", **kwargs: Any) -> None: + if self._ctx is not None: + self._ctx._connection = _conn + await self._ctx.__aexit__(kwargs.get("exc_type"), kwargs.get("exc_val"), kwargs.get("exc_tb")) + self._ctx = None + + +class SpannerAsyncConfig(AsyncDatabaseConfig["SpannerAsyncConnection", "AsyncAbstractSessionPool", SpannerAsyncDriver]): + """Async Spanner configuration and session management.""" + + driver_type: ClassVar[type["SpannerAsyncDriver"]] = SpannerAsyncDriver + connection_type: ClassVar[type["SpannerAsyncConnection"]] = cast( + "type[SpannerAsyncConnection]", SpannerAsyncConnection + ) + supports_transactional_ddl: ClassVar[bool] = False + supports_native_arrow_export: ClassVar[bool] = True + supports_native_arrow_import: ClassVar[bool] = True + supports_native_parquet_export: ClassVar[bool] = False + supports_native_parquet_import: ClassVar[bool] = False + type_coercion_capabilities: ClassVar[TypeCoercionCapabilities] = TypeCoercionCapabilities( + datetime_binding="native", timestamp_precision="microsecond", json_columns_decoded=True, uuid_binding="text" + ) + _connection_context_class: "ClassVar[type[SpannerAsyncConnectionContext]]" = SpannerAsyncConnectionContext + _session_factory_class: "ClassVar[type[_SpannerAsyncSessionConnectionHandler]]" = ( + _SpannerAsyncSessionConnectionHandler + ) + _session_context_class: "ClassVar[type[SpannerAsyncSessionContext]]" = SpannerAsyncSessionContext + _default_statement_config = default_statement_config + + def __init__( + self, + *, + connection_config: "SpannerPoolParams | dict[str, Any] | None" = None, + connection_instance: "AsyncAbstractSessionPool | None" = None, + migration_config: "dict[str, Any] | None" = None, + statement_config: "StatementConfig | None" = None, + driver_features: "SpannerDriverFeatures | dict[str, Any] | None" = None, + bind_key: "str | None" = None, + extension_config: "ExtensionConfigs | None" = None, + observability_config: "ObservabilityConfig | None" = None, + **kwargs: Any, + ) -> None: + self.connection_config, raw_driver_features = _prepare_settings( + connection_config, driver_features, default_pool_type=AsyncFixedSizePool + ) + statement_config, processed_features = apply_driver_features( + statement_config or default_statement_config, raw_driver_features + ) + + super().__init__( + connection_config=self.connection_config, + connection_instance=connection_instance, + migration_config=migration_config, + statement_config=statement_config, + driver_features=processed_features, + bind_key=bind_key, + extension_config=extension_config, + observability_config=observability_config, + **kwargs, + ) + + self._client: AsyncClient | None = None + self._database: AsyncDatabase | None = None + + def _get_client(self) -> "AsyncClient": + if self._client is None: + self._client = AsyncClient(**_connection_kwargs_for(self.connection_config, _CLIENT_CONFIG_FIELDS)) + return self._client + + async def get_database(self) -> "AsyncDatabase": + """Return the configured database, creating the pool and client on first use. + + Returns: + The async Spanner database bound to the configured session pool. + + Raises: + ImproperConfigurationError: If ``instance_id`` or ``database_id`` is missing. + """ + instance_id, database_id = _require_database_ids(self.connection_config) + + if self.connection_instance is None: + self.connection_instance = await self.provide_pool() + + if self._database is None: + instance = cast("Any", self._get_client()).instance(instance_id, **_instance_kwargs(self.connection_config)) + self._database = cast( + "AsyncDatabase", + await instance.database( + database_id, + pool=self.connection_instance, + **_connection_kwargs_for(self.connection_config, _DATABASE_CONFIG_FIELDS), + ), + ) + return self._database + + async def create_connection(self) -> "SpannerAsyncConnection": + """Return a read-only async snapshot checkout owned by the caller. + + The checkout borrows a pooled session when entered and returns it on exit. + + Returns: + A snapshot checkout to be used as an async context manager. + """ + database = await self.get_database() + return cast("SpannerAsyncConnection", cast("Any", database).snapshot(multi_use=True)) + + async def _create_pool(self) -> "AsyncAbstractSessionPool": + _require_database_ids(self.connection_config) + pool_type = _resolve_pool_type( + self.connection_config, base=AsyncAbstractSessionPool, config_name="SpannerAsyncConfig", expected="an async" + ) + if issubclass(pool_type, AsyncPingingPool): + pool_kind = "pinging" + elif issubclass(pool_type, AsyncFixedSizePool): + pool_kind = "fixed" + elif issubclass(pool_type, AsyncBurstyPool): + pool_kind = "bursty" + else: + pool_kind = "custom" + return cast("AsyncAbstractSessionPool", pool_type(**_pool_kwargs(self.connection_config, pool_kind))) + + async def _close_pool(self) -> None: + """Release pooled sessions, then close the database and client.""" + pool = self.connection_instance + if pool is not None: + with contextlib.suppress(SpannerGoogleAPICallError): + await pool.clear() # type: ignore[no-untyped-call] + if self._database is not None: + with contextlib.suppress(SpannerGoogleAPICallError): + await self._database.close() + if self._client is not None: + self._client.close() # type: ignore[no-untyped-call] + self._client = None + self._database = None + + def provide_connection( + self, *args: Any, transaction: "bool" = _DEFAULT_SESSION_TRANSACTION, **kwargs: Any + ) -> "SpannerAsyncConnectionContext": + """Yield a Transaction (default) or Snapshot context from the configured pool. + + Args: + *args: Additional positional arguments (unused, for interface compatibility). + transaction: If True (default), yields a Transaction context that + supports execute_update() for DML statements. If False, yields + a read-only Snapshot context for SELECT queries. + **kwargs: Additional keyword arguments (unused, for interface compatibility). + + Returns: + An async Spanner connection context manager. + """ + return SpannerAsyncConnectionContext(self, transaction=transaction) + + def provide_session( + self, + *args: Any, + statement_config: "StatementConfig | None" = None, + transaction: "bool" = _DEFAULT_SESSION_TRANSACTION, + request_options: "RequestOptions | dict[str, Any] | None" = None, + directed_read_options: "DirectedReadOptions | None" = None, + query_options: "ExecuteSqlRequest.QueryOptions | dict[str, Any] | None" = None, + retry: "Retry | None" = None, + timeout: "float | None" = None, + **kwargs: Any, + ) -> "SpannerAsyncSessionContext": + """Provide an async Spanner driver session context manager. + + Returns a write-capable Transaction session by default. Pass + ``transaction=False`` or use :meth:`provide_read_session` to obtain a + read-only Snapshot session. + + Args: + *args: Additional arguments. + statement_config: Optional statement configuration override. + transaction: Whether to use a Transaction (True, default) or + Snapshot (False). + request_options: Session-scoped RequestOptions for Spanner statements. + directed_read_options: Session-scoped DirectedReadOptions for reads. + query_options: Session-scoped QueryOptions for Spanner statements. + retry: Session-scoped retry policy for Spanner statement calls. + timeout: Session-scoped timeout for Spanner statement calls. + **kwargs: Additional keyword arguments. + + Returns: + An async Spanner driver session context manager. + """ + handler = _SpannerAsyncSessionConnectionHandler(self, transaction=transaction) + return SpannerAsyncSessionContext( + acquire_connection=handler.acquire_connection, + release_connection=handler.release_connection, + statement_config=statement_config or self.statement_config or default_statement_config, + driver_features=build_session_driver_features( + self.driver_features, + request_options=request_options, + directed_read_options=directed_read_options, + query_options=query_options, + retry=retry, + timeout=timeout, + ), + prepare_driver=self._prepare_driver, + ) + + def provide_write_session( + self, + *args: Any, + statement_config: "StatementConfig | None" = None, + request_options: "RequestOptions | dict[str, Any] | None" = None, + directed_read_options: "DirectedReadOptions | None" = None, + query_options: "ExecuteSqlRequest.QueryOptions | dict[str, Any] | None" = None, + retry: "Retry | None" = None, + timeout: "float | None" = None, + **kwargs: Any, + ) -> "SpannerAsyncSessionContext": + """Provide a write-capable async Spanner session (alias for :meth:`provide_session`).""" + return self.provide_session( + *args, + statement_config=statement_config, + transaction=True, + request_options=request_options, + directed_read_options=directed_read_options, + query_options=query_options, + retry=retry, + timeout=timeout, + **kwargs, + ) + + def provide_read_session( + self, + *args: Any, + statement_config: "StatementConfig | None" = None, + request_options: "RequestOptions | dict[str, Any] | None" = None, + directed_read_options: "DirectedReadOptions | None" = None, + query_options: "ExecuteSqlRequest.QueryOptions | dict[str, Any] | None" = None, + retry: "Retry | None" = None, + timeout: "float | None" = None, + **kwargs: Any, + ) -> "SpannerAsyncSessionContext": + """Provide a read-only Snapshot async Spanner session. + + Use for query workloads that benefit from Spanner's snapshot reads. + For DDL/DML, use :meth:`provide_session` (write-capable by default). + """ + return self.provide_session( + *args, + statement_config=statement_config, + transaction=False, + request_options=request_options, + directed_read_options=directed_read_options, + query_options=query_options, + retry=retry, + timeout=timeout, + **kwargs, + ) + + async def run_in_transaction(self, fn: "Callable[..., Awaitable[Any]]", *args: Any, **kwargs: Any) -> Any: + """Execute a coroutine function inside Spanner's async retryable transaction runner. + + ``fn`` is awaited as ``fn(driver, *args, **kwargs)`` with a driver bound + to the runner's transaction. A ``DeadlockError`` caused by + ``google.api_core.exceptions.Aborted`` is unwrapped so the runner + retries, and a terminal ``Aborted`` is raised as ``DeadlockError``. + """ + database = await self.get_database() + return await run_in_transaction_async(database, self._transaction_driver, fn, *args, **kwargs) + + def _transaction_driver(self, transaction: "SpannerAsyncConnection") -> "SpannerAsyncDriver": + driver = self.driver_type( + connection=transaction, statement_config=self.statement_config, driver_features=self.driver_features + ) + driver._owns_transaction = False # pyright: ignore[reportPrivateUsage] + return self._prepare_driver(driver) + + def get_signature_namespace(self) -> "dict[str, Any]": + """Get the signature namespace for SpannerAsyncConfig types. + + Returns: + Dictionary mapping type names to types. + """ + namespace = super().get_signature_namespace() + namespace.update({ + "SpannerAsyncConfig": SpannerAsyncConfig, + "SpannerAsyncConnection": SpannerAsyncConnection, + "SpannerAsyncConnectionContext": SpannerAsyncConnectionContext, + "SpannerAsyncDriver": SpannerAsyncDriver, + "SpannerAsyncSessionContext": SpannerAsyncSessionContext, + "SpannerConnectionParams": SpannerConnectionParams, + "SpannerDriverFeatures": SpannerDriverFeatures, + "SpannerPoolParams": SpannerPoolParams, + }) + return namespace + + def get_event_runtime_hints(self) -> "EventRuntimeHints": + """Return queue defaults for Spanner JSON handling.""" return EventRuntimeHints() diff --git a/sqlspec/adapters/spanner/core.py b/sqlspec/adapters/spanner/core.py index febb40f6f..6367811a1 100644 --- a/sqlspec/adapters/spanner/core.py +++ b/sqlspec/adapters/spanner/core.py @@ -1,7 +1,14 @@ """Spanner adapter compiled helpers.""" +import contextlib +import re from functools import partial -from typing import TYPE_CHECKING, Any, cast +from itertools import islice +from typing import TYPE_CHECKING, Any, Final, Literal, cast + +import sqlglot +from sqlglot import exp +from sqlglot.errors import SqlglotError from sqlspec.adapters.spanner._typing import SpannerJsonObject as JsonObject from sqlspec.adapters.spanner._typing import SpannerTypeCode as TypeCode @@ -14,6 +21,7 @@ TypedParameter, build_statement_config_from_profile, ) +from sqlspec.driver import rows_to_dicts from sqlspec.exceptions import ( DeadlockError, NotFoundError, @@ -21,6 +29,7 @@ OperationCancelledError, PermissionDeniedError, QueryTimeoutError, + SQLConversionError, SQLParsingError, SQLSpecError, UniqueViolationError, @@ -29,29 +38,159 @@ from sqlspec.utils.serializers import from_json, to_json if TYPE_CHECKING: - from collections.abc import Callable, Mapping, Sequence + from collections.abc import AsyncGenerator, Awaitable, Callable, Generator, Mapping, Sequence + from sqlspec.adapters.spanner._typing import SpannerDirectedReadOptions as DirectedReadOptions + from sqlspec.adapters.spanner._typing import SpannerExecuteSqlRequest as ExecuteSqlRequest + from sqlspec.adapters.spanner._typing import SpannerRequestOptions as RequestOptions + from sqlspec.adapters.spanner._typing import SpannerRetry as Retry from sqlspec.typing import ArrowRecordBatch, ArrowRecordBatchReader, ArrowReturnFormat, ArrowTable __all__ = ( + "SpannerAsyncStreamSource", + "SpannerExecuteOptions", + "SpannerSyncStreamSource", "apply_driver_features", + "build_execute_kwargs", "build_param_type_signature", "build_profile", + "build_session_driver_features", "build_statement_config", + "chunk_mutation_rows", "coerce_params", "collect_rows", "create_arrow_data", "create_mapped_exception", "default_statement_config", "driver_profile", + "execute_ddl_async", + "execute_ddl_sync", "infer_param_types", + "is_ddl_statement", + "is_query_statement", + "list_existing_table_names_async", + "list_existing_table_names_sync", + "pop_execute_options", + "renew_transaction", "resolve_column_names", "resolve_row_plan", + "resolve_transaction_completion", + "run_in_transaction_async", + "run_in_transaction_sync", "supports_batch_update", "supports_write", ) COLUMN_CACHE_MAX_SIZE: int = 128 +_MAX_MUTATIONS_PER_COMMIT = 80_000 +_DDL_KEYWORDS: Final[frozenset[str]] = frozenset({"ALTER", "ANALYZE", "CREATE", "DROP", "GRANT", "RENAME", "REVOKE"}) +_LEADING_KEYWORD_PATTERN: Final[re.Pattern[str]] = re.compile( + r"\A(?:\s+|--[^\n]*(?:\n|\Z)|/\*.*?\*/)*([A-Za-z]+)", re.DOTALL +) + + +class SpannerExecuteOptions: + """Per-call Spanner execution options captured for a single dispatch.""" + + __slots__ = ("directed_read_options", "last_statement", "query_options", "request_options", "retry", "timeout") + + def __init__( + self, + *, + request_options: "RequestOptions | dict[str, Any] | None" = None, + query_options: "ExecuteSqlRequest.QueryOptions | dict[str, Any] | None" = None, + directed_read_options: "DirectedReadOptions | None" = None, + retry: "Retry | None" = None, + timeout: "float | None" = None, + last_statement: bool = False, + ) -> None: + self.request_options = request_options + self.query_options = query_options + self.directed_read_options = directed_read_options + self.retry = retry + self.timeout = timeout + self.last_statement = last_statement + + +def build_execute_kwargs( + driver_features: dict[str, Any], + pending: "SpannerExecuteOptions | None", + *, + for_read: bool = False, + for_batch: bool = False, +) -> dict[str, Any]: + """Merge driver defaults and per-call options for the native execution method.""" + kwargs: dict[str, Any] = {key: driver_features[key] for key in ("retry", "timeout") if key in driver_features} + request_options = driver_features.get("request_options") + if request_options is not None: + kwargs["request_options"] = request_options + if not for_batch: + query_options = driver_features.get("query_options") + if query_options is not None: + kwargs["query_options"] = query_options + if for_read and not for_batch: + directed_read_options = driver_features.get("directed_read_options") + if directed_read_options is not None: + kwargs["directed_read_options"] = directed_read_options + if pending is not None: + if pending.request_options is not None: + kwargs["request_options"] = pending.request_options + if not for_batch and pending.query_options is not None: + kwargs["query_options"] = pending.query_options + if pending.retry is not None: + kwargs["retry"] = pending.retry + if pending.timeout is not None: + kwargs["timeout"] = pending.timeout + if for_read and not for_batch and pending.directed_read_options is not None: + kwargs["directed_read_options"] = pending.directed_read_options + if not for_read and pending.last_statement: + kwargs["last_statement"] = True + return kwargs + + +def pop_execute_options(kwargs: dict[str, Any]) -> "SpannerExecuteOptions | None": + """Remove native execution options before processing SQL parameters.""" + if not any( + key in kwargs + for key in ("request_options", "query_options", "directed_read_options", "retry", "timeout", "last_statement") + ): + return None + return SpannerExecuteOptions( + request_options=kwargs.pop("request_options", None), + query_options=kwargs.pop("query_options", None), + directed_read_options=kwargs.pop("directed_read_options", None), + retry=kwargs.pop("retry", None), + timeout=kwargs.pop("timeout", None), + last_statement=bool(kwargs.pop("last_statement", False)), + ) + + +def chunk_mutation_rows( + columns: "list[str]", + records: "list[tuple[Any, ...]]", + coerce_row: "Callable[[dict[str, Any]], dict[str, Any] | None]", +) -> "list[list[list[Any]]]": + """Coerce Arrow rows into chunks bounded by Spanner's mutation-group ceiling.""" + column_count = len(columns) + max_cells = _MAX_MUTATIONS_PER_COMMIT + chunks: list[list[list[Any]]] = [] + values: list[list[Any]] = [] + pending_cells = 0 + for record in records: + if values and pending_cells + column_count > max_cells: + chunks.append(values) + values = [] + pending_cells = 0 + coerced = coerce_row({f"p{i}": value for i, value in enumerate(record)}) or {} + values.append([coerced.get(f"p{i}") for i in range(column_count)]) + pending_cells += column_count + if pending_cells == max_cells: + chunks.append(values) + values = [] + pending_cells = 0 + if values: + chunks.append(values) + return chunks def build_profile() -> "DriverParameterProfile": @@ -303,10 +442,435 @@ def create_mapped_exception(error: Any, *, logger: Any | None = None) -> SQLSpec return _create_spanner_error(error, SQLSpecError, "error") +def build_session_driver_features( + features: dict[str, Any], + *, + request_options: "RequestOptions | dict[str, Any] | None" = None, + directed_read_options: "DirectedReadOptions | None" = None, + query_options: "ExecuteSqlRequest.QueryOptions | dict[str, Any] | None" = None, + retry: "Retry | None" = None, + timeout: "float | None" = None, +) -> "dict[str, Any]": + """Apply session overrides without changing the configuration's defaults. + + Args: + features: Configuration driver features. + request_options: Session-scoped RequestOptions. + directed_read_options: Session-scoped DirectedReadOptions. + query_options: Session-scoped QueryOptions. + retry: Session-scoped retry policy. + timeout: Session-scoped timeout in seconds. + + Returns: + The configuration features when no override is given, otherwise a copy + with the overrides applied. + """ + if ( + request_options is None + and directed_read_options is None + and query_options is None + and retry is None + and timeout is None + ): + return features + driver_features = dict(features) + if request_options is not None: + driver_features["request_options"] = request_options + if directed_read_options is not None: + driver_features["directed_read_options"] = directed_read_options + if query_options is not None: + driver_features["query_options"] = query_options + if retry is not None: + driver_features["retry"] = retry + if timeout is not None: + driver_features["timeout"] = timeout + return driver_features + + +def is_ddl_statement(sql: str) -> bool: + """Return True when a statement is schema DDL that Spanner applies through ``update_ddl``. + + Args: + sql: A single SQL statement. + + Returns: + True when the first keyword after any leading comments starts a Spanner + DDL statement (``CREATE``, ``ALTER``, ``DROP``, ``GRANT``, ``REVOKE``, + ``RENAME`` or ``ANALYZE``). + """ + match = _LEADING_KEYWORD_PATTERN.match(sql) + return match is not None and match.group(1).upper() in _DDL_KEYWORDS + + +def is_query_statement(sql: str, dialect: str) -> bool: + """Return True when a script statement returns rows. + + Args: + sql: A single SQL statement. + dialect: SQLGlot dialect used to parse the statement. + + Returns: + True for queries; statements SQLGlot cannot parse are treated as + queries when they start with ``SELECT``. + """ + try: + return isinstance(sqlglot.parse_one(sql, read=dialect), exp.Query) + except SqlglotError: + return sql.upper().strip().startswith("SELECT") + + +def renew_transaction(transaction: Any) -> Any: + """Return a new read-write transaction on the session that owns ``transaction``. + + Args: + transaction: A finished sync or async Spanner ``Transaction``. + + Returns: + A transaction of the same kind that has not begun on the server. + """ + return transaction._session.transaction() + + +def resolve_transaction_completion(transaction: Any, *, failed: bool) -> "Literal['commit', 'rollback', 'skip']": + """Decide how to finish a Spanner read-write transaction. + + A transaction that already committed or rolled back is skipped. After + failure it rolls back, which discards buffered mutations and contacts the + server only when the transaction has begun there. After success it + commits when it has begun on the server or buffered mutations. + + Args: + transaction: A sync or async Spanner ``Transaction``. + failed: Whether the enclosing unit of work raised or asked to roll back. + + Returns: + ``"commit"``, ``"rollback"``, or ``"skip"``. + """ + if transaction.committed is not None or transaction.rolled_back: + return "skip" + if failed: + return "rollback" + if transaction._transaction_id is not None or transaction._mutations: + return "commit" + return "skip" + + +class _SpannerSyncTransactionWork: + """Run a unit of work for the sync Spanner transaction runner.""" + + __slots__ = ("_build_driver", "_fn") + + def __init__(self, build_driver: "Callable[[Any], Any]", fn: "Callable[..., Any]") -> None: + self._build_driver = build_driver + self._fn = fn + + def __call__(self, transaction: Any, *args: Any, **kwargs: Any) -> Any: + driver = self._build_driver(transaction) + try: + return self._fn(driver, *args, **kwargs) + except DeadlockError as exc: + cause = exc.__cause__ + if isinstance(cause, api_exceptions.Aborted): + raise cause from None + raise + + +class _SpannerAsyncTransactionWork: + """Run a unit of work for the async Spanner transaction runner.""" + + __slots__ = ("_build_driver", "_fn") + + def __init__(self, build_driver: "Callable[[Any], Any]", fn: "Callable[..., Awaitable[Any]]") -> None: + self._build_driver = build_driver + self._fn = fn + + async def __call__(self, transaction: Any, *args: Any, **kwargs: Any) -> Any: + driver = self._build_driver(transaction) + try: + return await self._fn(driver, *args, **kwargs) + except DeadlockError as exc: + cause = exc.__cause__ + if isinstance(cause, api_exceptions.Aborted): + raise cause from None + raise + + +def run_in_transaction_sync( + database: Any, build_driver: "Callable[[Any], Any]", fn: "Callable[..., Any]", *args: Any, **kwargs: Any +) -> Any: + """Run ``fn`` inside the sync Spanner retryable transaction runner. + + ``fn`` receives a driver bound to the runner's transaction. A + ``DeadlockError`` caused by ``Aborted`` is unwrapped so the runner retries, + and a terminal Google API error is mapped to a SQLSpec exception. + + Args: + database: Sync Spanner database. + build_driver: Factory that binds a driver to a transaction. + fn: Unit of work called as ``fn(driver, *args, **kwargs)``. + *args: Positional arguments for ``fn``. + **kwargs: Keyword arguments for the runner and ``fn``. + + Returns: + The value returned by ``fn``. + + Raises: + SQLSpecError: The mapped error when the runner fails. + """ + try: + return database.run_in_transaction(_SpannerSyncTransactionWork(build_driver, fn), *args, **kwargs) + except api_exceptions.GoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc + + +async def run_in_transaction_async( + database: Any, build_driver: "Callable[[Any], Any]", fn: "Callable[..., Awaitable[Any]]", *args: Any, **kwargs: Any +) -> Any: + """Run ``fn`` inside the async Spanner retryable transaction runner. + + ``fn`` receives a driver bound to the runner's transaction. A + ``DeadlockError`` caused by ``Aborted`` is unwrapped so the runner retries, + and a terminal Google API error is mapped to a SQLSpec exception. + + Args: + database: Async Spanner database. + build_driver: Factory that binds a driver to a transaction. + fn: Coroutine function called as ``await fn(driver, *args, **kwargs)``. + *args: Positional arguments for ``fn``. + **kwargs: Keyword arguments for the runner and ``fn``. + + Returns: + The value returned by ``fn``. + + Raises: + SQLSpecError: The mapped error when the runner fails. + """ + try: + return await database.run_in_transaction(_SpannerAsyncTransactionWork(build_driver, fn), *args, **kwargs) + except api_exceptions.GoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc + + +def list_existing_table_names_sync(database: Any) -> "set[str]": + """Return the table names in a sync Spanner database's default schema. + + Args: + database: Sync Spanner database. + + Returns: + Table names. + """ + return {table.table_id for table in database.list_tables()} + + +async def list_existing_table_names_async(database: Any) -> "set[str]": + """Return the table names in an async Spanner database's default schema. + + Args: + database: Async Spanner database. + + Returns: + Table names. + """ + names: set[str] = set() + async for table in database.list_tables(): + names.add(table.table_id) + return names + + +def execute_ddl_sync(database: Any, statements: "list[str]", *, timeout: "float | None" = None) -> None: + """Apply DDL statements through a sync Spanner database and wait for completion. + + Args: + database: Sync Spanner database. + statements: DDL statements; nothing runs when empty. + timeout: Seconds to wait for the schema operation, or None to wait indefinitely. + """ + if not statements: + return + database.update_ddl(statements).result(timeout=timeout) + + +async def execute_ddl_async(database: Any, statements: "list[str]", *, timeout: "float | None" = None) -> None: + """Apply DDL statements through an async Spanner database and wait for completion. + + Args: + database: Async Spanner database. + statements: DDL statements; nothing runs when empty. + timeout: Seconds to wait for the schema operation, or None to wait indefinitely. + """ + if not statements: + return + operation = await database.update_ddl(statements) + await operation.result(timeout=timeout) + + +class _SpannerStreamSourceBase: + """Row conversion shared by the sync and async Spanner stream sources.""" + + __slots__ = ( + "_chunk_size", + "_column_names", + "_column_plan", + "_driver", + "_execute_kwargs", + "_param_types", + "_params", + "_result_set", + "_sql", + ) + + def __init__( + self, + driver: Any, + sql: str, + params: "dict[str, Any] | None", + param_types: "dict[str, Any]", + chunk_size: int, + execute_kwargs: "dict[str, Any]", + ) -> None: + self._driver = driver + self._sql = sql + self._params = params + self._param_types = param_types + self._chunk_size = chunk_size + self._execute_kwargs = execute_kwargs + self._column_names: list[str] | None = None + self._column_plan: tuple[tuple[int, Any], ...] | None = None + self._result_set: Any = None + + def _rows_to_chunk(self, rows: "list[Any]") -> "list[dict[str, Any]]": + column_names = self._column_names + if column_names is None: + metadata = self._result_set.metadata + fields = metadata.row_type.fields if metadata is not None else None + if not fields: + msg = "Result set metadata not available." + raise SQLConversionError(msg) + column_names, self._column_plan = self._driver._resolve_row_plan(fields) + converted_rows, resolved_column_names = collect_rows( + rows, (), column_names=column_names, column_plan=self._column_plan + ) + self._column_names = resolved_column_names + return rows_to_dicts(converted_rows, resolved_column_names) + + def _reset(self) -> None: + self._result_set = None + self._column_names = None + self._column_plan = None + + +class SpannerSyncStreamSource(_SpannerStreamSourceBase): + """Chunk source streaming dict rows from a sync Spanner query.""" + + __slots__ = ("_row_iterator",) + + def __init__( + self, + driver: Any, + sql: str, + params: "dict[str, Any] | None", + param_types: "dict[str, Any]", + chunk_size: int, + execute_kwargs: "dict[str, Any]", + ) -> None: + super().__init__(driver, sql, params, param_types, chunk_size, execute_kwargs) + self._row_iterator: Generator[Any, None, None] | None = None + + def start(self) -> None: + handler = self._driver.handle_database_exceptions() + with handler: + result_set = self._driver.connection.execute_sql( + self._sql, params=self._params, param_types=self._param_types, **self._execute_kwargs + ) + self._result_set = result_set + self._row_iterator = cast("Generator[Any, None, None]", iter(result_set)) + self._driver._check_pending_exception(handler) + + def fetch_chunk(self) -> "list[dict[str, Any]]": + row_iterator = self._row_iterator + if self._result_set is None or row_iterator is None: + return [] + handler = self._driver.handle_database_exceptions() + rows: list[Any] = [] + with handler: + rows = list(islice(row_iterator, self._chunk_size)) + self._driver._check_pending_exception(handler) + if not rows: + return [] + return self._rows_to_chunk(rows) + + def close(self, error: bool = False) -> None: + row_iterator = self._row_iterator + self._row_iterator = None + if row_iterator is not None: + row_iterator.close() + self._reset() + + +class SpannerAsyncStreamSource(_SpannerStreamSourceBase): + """Chunk source streaming dict rows from an async Spanner query.""" + + __slots__ = ("_row_iterator",) + + def __init__( + self, + driver: Any, + sql: str, + params: "dict[str, Any] | None", + param_types: "dict[str, Any]", + chunk_size: int, + execute_kwargs: "dict[str, Any]", + ) -> None: + super().__init__(driver, sql, params, param_types, chunk_size, execute_kwargs) + self._row_iterator: AsyncGenerator[Any, None] | None = None + + async def start(self) -> None: + handler = self._driver.handle_database_exceptions() + async with handler: + result_set = await self._driver.connection.execute_sql( + self._sql, params=self._params, param_types=self._param_types, **self._execute_kwargs + ) + self._result_set = result_set + self._row_iterator = cast("AsyncGenerator[Any, None]", result_set.__aiter__()) + self._driver._check_pending_exception(handler) + + async def fetch_chunk(self) -> "list[dict[str, Any]]": + row_iterator = self._row_iterator + if self._result_set is None or row_iterator is None: + return [] + handler = self._driver.handle_database_exceptions() + rows: list[Any] = [] + async with handler: + with contextlib.suppress(StopAsyncIteration): + while len(rows) < self._chunk_size: + rows.append(await row_iterator.__anext__()) + self._driver._check_pending_exception(handler) + if not rows: + return [] + return self._rows_to_chunk(rows) + + async def close(self, error: bool = False) -> None: + row_iterator = self._row_iterator + self._row_iterator = None + if row_iterator is not None: + await row_iterator.aclose() + self._reset() + + def _convert_json_row_value(value: Any, *, json_deserializer: "Callable[[str], Any]") -> Any: """Convert a native Spanner JSON cell using the configured deserializer.""" if isinstance(value, JsonObject): - json_value = cast("Any", value).serialize() + json_object = cast("Any", value) + if json_object._is_null: + return None + if json_deserializer is from_json: + if json_object._is_array: + return list(json_object._array_value) + if json_object._is_scalar_value: + return json_object._simple_value + return dict(value) + json_value = json_object.serialize() if json_value is None: return None elif isinstance(value, str): diff --git a/sqlspec/adapters/spanner/data_dictionary.py b/sqlspec/adapters/spanner/data_dictionary.py index f6b0986c7..78ad1519e 100644 --- a/sqlspec/adapters/spanner/data_dictionary.py +++ b/sqlspec/adapters/spanner/data_dictionary.py @@ -26,14 +26,14 @@ ensure_system_metadata_request, system_metadata_gated_result, ) -from sqlspec.driver import SyncDataDictionaryBase +from sqlspec.driver import AsyncDataDictionaryBase, SyncDataDictionaryBase if TYPE_CHECKING: from collections.abc import Sequence - from sqlspec.adapters.spanner.driver import SpannerSyncDriver + from sqlspec.adapters.spanner.driver import SpannerAsyncDriver, SpannerSyncDriver -__all__ = ("SpannerDataDictionary",) +__all__ = ("SpannerAsyncDataDictionary", "SpannerSyncDataDictionary") _DEFAULT_METADATA_DOMAINS = ( "schemas", @@ -72,7 +72,7 @@ @mypyc_attr(allow_interpreted_subclasses=True, native_class=False) -class SpannerDataDictionary(SyncDataDictionaryBase): +class SpannerSyncDataDictionary(SyncDataDictionaryBase): """Fetch table, column, and index metadata from Spanner.""" dialect: ClassVar[str] = "spanner" @@ -322,6 +322,257 @@ def get_ddl( ) +@mypyc_attr(allow_interpreted_subclasses=True, native_class=False) +class SpannerAsyncDataDictionary(AsyncDataDictionaryBase): + """Fetch table, column, and index metadata from Spanner.""" + + dialect: ClassVar[str] = "spanner" + + def __init__(self, mode: str = "googlesql") -> None: + super().__init__() + self.mode = _normalize_spanner_metadata_mode(mode) + + def get_query(self, domain: str, operation: str, *, mode: "str | None" = None) -> SQL: + """Return an exact domain query for this dialect.""" + resolved_mode = self.mode if mode is None else _normalize_spanner_metadata_mode(mode) + return super().get_query(domain, operation, mode=resolved_mode) + + async def get_version(self, driver: "SpannerAsyncDriver | None" = None) -> "VersionInfo | None": + """Get Spanner version information. + + Args: + driver: Async Spanner driver instance. + + Returns: + None since Spanner does not expose version information. + """ + _ = driver + return None + + async def get_feature_flag(self, driver: "SpannerAsyncDriver | None" = None, feature: str = "") -> bool: + """Check if Spanner supports a specific feature. + + Args: + driver: Async Spanner driver instance. + feature: Feature name to check. + + Returns: + True if feature is supported, False otherwise. + """ + _ = driver + return self.resolve_feature_flag(feature, None) + + async def get_optimal_type(self, driver: "SpannerAsyncDriver | None" = None, type_category: str = "") -> str: + """Get optimal Spanner type for a category. + + Args: + driver: Async Spanner driver instance. + type_category: Type category. + + Returns: + Spanner-specific type name. + """ + _ = driver + return self.get_dialect_config().get_optimal_type(type_category) + + async def get_tables(self, driver: "SpannerAsyncDriver", schema: "str | None" = None) -> "list[TableMetadata]": + """Get tables using INFORMATION_SCHEMA.""" + schema_name = self.resolve_schema(schema) + self._log_schema_introspect(driver, schema_name=schema_name, table_name=None, operation="tables") + return await driver.select( + self.get_query("tables", "by_schema", mode=self.mode), schema_name=schema_name, schema_type=TableMetadata + ) + + async def get_columns( + self, driver: "SpannerAsyncDriver", table: "str | None" = None, schema: "str | None" = None + ) -> "list[ColumnMetadata]": + """Get column information for a table or schema.""" + schema_name = self.resolve_schema(schema) + if table is None: + self._log_schema_introspect(driver, schema_name=schema_name, table_name=None, operation="columns") + return await driver.select( + self.get_query("columns", "by_schema", mode=self.mode), + schema_name=schema_name, + schema_type=ColumnMetadata, + ) + + self._log_table_describe(driver, schema_name=schema_name, table_name=table, operation="columns") + return await driver.select( + self.get_query("columns", "by_table", mode=self.mode), + table_name=table, + schema_name=schema_name, + schema_type=ColumnMetadata, + ) + + async def get_indexes( + self, driver: "SpannerAsyncDriver", table: "str | None" = None, schema: "str | None" = None + ) -> "list[IndexMetadata]": + """Get index metadata for a table or schema.""" + schema_name = self.resolve_schema(schema) + if table is None: + self._log_schema_introspect(driver, schema_name=schema_name, table_name=None, operation="indexes") + return await driver.select( + self.get_query("indexes", "by_schema", mode=self.mode), + schema_name=schema_name, + schema_type=IndexMetadata, + ) + + self._log_table_describe(driver, schema_name=schema_name, table_name=table, operation="indexes") + return await driver.select( + self.get_query("indexes", "by_table", mode=self.mode), + table_name=table, + schema_name=schema_name, + schema_type=IndexMetadata, + ) + + async def get_foreign_keys( + self, driver: "SpannerAsyncDriver", table: "str | None" = None, schema: "str | None" = None + ) -> "list[ForeignKeyMetadata]": + """Get foreign key metadata.""" + schema_name = self.resolve_schema(schema) + if table is None: + self._log_schema_introspect(driver, schema_name=schema_name, table_name=None, operation="foreign_keys") + return await driver.select( + self.get_query("foreign_keys", "by_schema", mode=self.mode), + schema_name=schema_name, + schema_type=ForeignKeyMetadata, + ) + self._log_table_describe(driver, schema_name=schema_name, table_name=table, operation="foreign_keys") + return await driver.select( + self.get_query("foreign_keys", "by_table", mode=self.mode), + table_name=table, + schema_name=schema_name, + schema_type=ForeignKeyMetadata, + ) + + def _build_domain_result(self, domain: str, rows: list[Any]) -> MetadataResult: + """Wrap domain query rows into a standard MetadataResult envelope.""" + capability = _spanner_capability_for_domain(domain, mode=self.mode) + return MetadataResult(domain, capability=capability, items=tuple(rows), warnings=capability.warnings) + + async def get_constraints( + self, driver: "SpannerAsyncDriver", table: "str | None" = None, schema: "str | None" = None + ) -> MetadataResult: + """Get constraint metadata for a table or schema.""" + schema_name = self.resolve_schema(schema) + if table is None: + self._log_schema_introspect(driver, schema_name=schema_name, table_name=None, operation="constraints") + rows = await driver.select( + self.get_query("constraints", "by_schema", mode=self.mode), schema_name=schema_name, table_name=None + ) + else: + self._log_table_describe(driver, schema_name=schema_name, table_name=table, operation="constraints") + rows = await driver.select( + self.get_query("constraints", "by_table", mode=self.mode), table_name=table, schema_name=schema_name + ) + return self._build_domain_result("constraints", rows) + + async def get_sequences( + self, driver: "SpannerAsyncDriver", schema: "str | None" = None, sequence_name: "str | None" = None + ) -> MetadataResult: + """Get sequence metadata.""" + schema_name = self.resolve_schema(schema) + self._log_schema_introspect(driver, schema_name=schema_name, table_name=None, operation="sequences") + rows = await driver.select( + self.get_query("sequences", "by_schema", mode=self.mode), + schema_name=schema_name, + sequence_name=sequence_name, + ) + return self._build_domain_result("sequences", rows) + + async def get_change_streams( + self, driver: "SpannerAsyncDriver", schema: "str | None" = None, stream_name: "str | None" = None + ) -> MetadataResult: + """Get change stream metadata.""" + schema_name = self.resolve_schema(schema) + self._log_schema_introspect(driver, schema_name=schema_name, table_name=None, operation="change_streams") + rows = await driver.select( + self.get_query("change_streams", "list", mode=self.mode), + schema_name=schema_name, + change_stream_name=stream_name, + ) + return self._build_domain_result("change_streams", rows) + + async def get_system_metadata( + self, driver: "SpannerAsyncDriver", request: SystemMetadataRequest | str | None = None, **kwargs: Any + ) -> SystemMetadataResult: + """Get opt-in Spanner system metadata from SPANNER_SYS.""" + query_kwargs = dict(kwargs) + limit = query_kwargs.pop("limit", 100) + interval_end_after = query_kwargs.pop("interval_end_after", None) + metadata_request = ensure_system_metadata_request(request, **query_kwargs) + capability = SystemMetadataCapability( + domain=metadata_request.domain, + support=MetadataSupport.SUPPORTED, + fidelity=MetadataFidelity.PARTIAL, + source=MetadataSource.SYSTEM_VIEW, + risks=(MetadataRisk.PRIVILEGED, MetadataRisk.EXPENSIVE), + warnings=_SPANNER_SYSTEM_WARNINGS, + ) + gate_result = system_metadata_gated_result(metadata_request, capability) + if gate_result.capability.support != MetadataSupport.SUPPORTED: + return gate_result + + query_name = ( + "table_sizes" if metadata_request.domain in {"table_statistics", "table_sizes"} else "query_stats_top" + ) + parameters: dict[str, Any] = {"interval_end_after": interval_end_after, "limit": limit} + if query_name == "table_sizes": + parameters["table_name"] = metadata_request.table + rows = await driver.select(self.get_query("system", query_name, mode=self.mode), **parameters) + return SystemMetadataResult.from_rows( + metadata_request, + capability, + rows=tuple(rows) if isinstance(rows, (list, tuple)) else (), + source=MetadataSource.SYSTEM_VIEW, + ) + + async def get_metadata_capabilities( + self, driver: Any, domains: "Sequence[str] | None" = None, *, mode: "str | None" = None + ) -> MetadataCapabilityProfile: + """Get Spanner data-dictionary capability profile.""" + _ = driver + requested_domains = tuple(domains) if domains is not None else _DEFAULT_METADATA_DOMAINS + normalized_mode = _normalize_spanner_metadata_mode(mode or self.mode) + capabilities = tuple( + _spanner_capability_for_domain(domain, mode=normalized_mode) for domain in requested_domains + ) + return MetadataCapabilityProfile(self.dialect, adapter=type(self).__name__, capabilities=capabilities) + + async def get_ddl( + self, + driver: Any, + object_name: str, + schema: "str | None" = None, + *, + object_type: str = "table", + include_dependencies: bool = True, + prefer_native: bool = True, + redact: bool = True, + ) -> DDLResult: + """Get Spanner DDL through the Database Admin API.""" + _ = include_dependencies, prefer_native, redact + ddl_statements = await _get_spanner_ddl_statements_async(driver) + identity = ObjectIdentity( + name=object_name, + object_type=object_type, + schema=schema, + dialect=self.dialect, + source=MetadataSource.NATIVE_API, + ) + if not ddl_statements: + return DDLResult.unsupported(identity, source=MetadataSource.NATIVE_API, warnings=_SPANNER_DDL_WARNINGS) + ddl = _select_spanner_ddl_for_object(ddl_statements, object_name) + return DDLResult( + identity=identity, + status=MetadataSupport.SUPPORTED, + fidelity=MetadataFidelity.NATIVE, + source=MetadataSource.NATIVE_API, + ddl=ddl, + warnings=_SPANNER_DDL_WARNINGS, + ) + + def _spanner_capability_for_domain(domain: str, *, mode: str) -> MetadataCapability: if mode not in {"googlesql", "postgresql"}: return MetadataCapability( @@ -375,16 +626,24 @@ def _get_spanner_ddl_statements(driver: Any) -> "tuple[str, ...]": database = _get_spanner_database(driver) if database is None: return () - get_ddl = getattr(database, "get_ddl", None) - if callable(get_ddl): - return tuple(str(statement) for statement in cast("Sequence[object]", get_ddl())) admin_api = _get_spanner_database_admin_api(database) database_name = getattr(database, "name", None) if admin_api is None or database_name is None: return () response = admin_api.get_database_ddl(database=database_name) - statements = getattr(response, "statements", ()) - return tuple(str(statement) for statement in cast("Sequence[object]", statements)) + return tuple(str(statement) for statement in cast("Sequence[object]", response.statements)) + + +async def _get_spanner_ddl_statements_async(driver: Any) -> "tuple[str, ...]": + database = _get_spanner_database(driver) + if database is None: + return () + admin_api = _get_spanner_database_admin_api(database) + database_name = getattr(database, "name", None) + if admin_api is None or database_name is None: + return () + response = await admin_api.get_database_ddl(database=database_name) + return tuple(str(statement) for statement in cast("Sequence[object]", response.statements)) def _get_spanner_database(driver: Any) -> Any: diff --git a/sqlspec/adapters/spanner/driver.py b/sqlspec/adapters/spanner/driver.py index 0bbe76363..37b4f6c65 100644 --- a/sqlspec/adapters/spanner/driver.py +++ b/sqlspec/adapters/spanner/driver.py @@ -1,53 +1,66 @@ """Spanner driver implementation.""" -import contextlib -from collections.abc import Iterator -from itertools import islice +from collections.abc import AsyncIterator, Iterator from typing import TYPE_CHECKING, Any, Protocol, cast, overload -import sqlglot as _sqlglot from sqlglot import exp as _sqlglot_exp from sqlspec.adapters.spanner._typing import ( + SpannerAsyncConnection, + SpannerAsyncCursor, + SpannerAsyncSessionContext, + SpannerAsyncTransaction, SpannerGoogleAPICallError, - SpannerSessionContext, SpannerSyncCursor, + SpannerSyncSessionContext, SpannerTransaction, ) from sqlspec.adapters.spanner.core import ( + SpannerAsyncStreamSource, + SpannerExecuteOptions, + SpannerSyncStreamSource, + build_execute_kwargs, build_param_type_signature, + chunk_mutation_rows, coerce_params, collect_rows, create_mapped_exception, default_statement_config, driver_profile, + execute_ddl_async, + execute_ddl_sync, infer_param_types, + is_ddl_statement, + is_query_statement, + pop_execute_options, + renew_transaction, resolve_row_plan, + resolve_transaction_completion, + run_in_transaction_async, + run_in_transaction_sync, supports_batch_update, supports_write, ) -from sqlspec.adapters.spanner.data_dictionary import SpannerDataDictionary +from sqlspec.adapters.spanner.data_dictionary import SpannerAsyncDataDictionary, SpannerSyncDataDictionary from sqlspec.core import StatementConfig, register_driver_profile from sqlspec.driver import ( + AsyncDriverAdapterBase, + AsyncRowStream, + BaseAsyncExceptionHandler, BaseSyncExceptionHandler, ExecutionResult, SyncDriverAdapterBase, SyncRowStream, - rows_to_dicts, ) from sqlspec.exceptions import SQLConversionError from sqlspec.utils.serializers import from_json if TYPE_CHECKING: - from collections.abc import Callable, Sequence + from collections.abc import Awaitable, Callable, Sequence from sqlglot.dialects.dialect import DialectType - from sqlspec.adapters.spanner._typing import SpannerConnection - from sqlspec.adapters.spanner._typing import SpannerDirectedReadOptions as DirectedReadOptions - from sqlspec.adapters.spanner._typing import SpannerExecuteSqlRequest as ExecuteSqlRequest - from sqlspec.adapters.spanner._typing import SpannerRequestOptions as RequestOptions - from sqlspec.adapters.spanner._typing import SpannerRetry as Retry + from sqlspec.adapters.spanner._typing import SpannerSyncConnection from sqlspec.builder import QueryBuilder from sqlspec.core import ArrowResult, SQLResult, Statement, StatementFilter from sqlspec.core.statement import SQL @@ -55,21 +68,41 @@ from sqlspec.typing import SchemaT, StatementParameters __all__ = ( - "SpannerDataDictionary", - "SpannerExceptionHandler", - "SpannerSessionContext", + "SpannerAsyncCursor", + "SpannerAsyncDriver", + "SpannerAsyncExceptionHandler", + "SpannerAsyncSessionContext", "SpannerSyncCursor", "SpannerSyncDriver", + "SpannerSyncExceptionHandler", + "SpannerSyncSessionContext", ) + _READ_ONLY_SNAPSHOT_ERROR_MESSAGE = ( "Cannot execute DML in a read-only Snapshot context. " "SpannerSyncConfig.provide_session() opens a write-capable Transaction by default; " "the current session must have been opened via SpannerSyncConfig.provide_read_session()." ) +_READ_ONLY_ASYNC_SNAPSHOT_ERROR_MESSAGE = ( + "Cannot execute DML in a read-only Snapshot context. " + "SpannerAsyncConfig.provide_session() opens a write-capable Transaction by default; " + "the current session must have been opened via SpannerAsyncConfig.provide_read_session()." +) + +_READ_ONLY_SNAPSHOT_DDL_ERROR_MESSAGE = ( + "Cannot execute DDL in a read-only Snapshot context. " + "Use SpannerSyncConfig.provide_session() or provide_write_session() for schema changes." +) + +_READ_ONLY_ASYNC_SNAPSHOT_DDL_ERROR_MESSAGE = ( + "Cannot execute DDL in a read-only Snapshot context. " + "Use SpannerAsyncConfig.provide_session() or provide_write_session() for schema changes." +) + -class SpannerExceptionHandler(BaseSyncExceptionHandler): +class SpannerSyncExceptionHandler(BaseSyncExceptionHandler): """Map Spanner client exceptions to SQLSpec exceptions. Uses deferred exception pattern for mypyc compatibility: exceptions @@ -80,7 +113,26 @@ class SpannerExceptionHandler(BaseSyncExceptionHandler): __slots__ = () def _handle_exception(self, exc_type: "type[BaseException] | None", exc_val: "BaseException") -> bool: + if exc_type is None: + return False + + if isinstance(exc_val, SpannerGoogleAPICallError): + self.pending_exception = create_mapped_exception(exc_val) + return True + return False + + +class SpannerAsyncExceptionHandler(BaseAsyncExceptionHandler): + """Map Spanner client exceptions to SQLSpec exceptions. + + Uses deferred exception pattern for mypyc compatibility: exceptions + are stored in pending_exception rather than raised from __aexit__ + to avoid ABI boundary violations with compiled code. + """ + + __slots__ = () + def _handle_exception(self, exc_type: "type[BaseException] | None", exc_val: "BaseException") -> bool: if exc_type is None: return False @@ -94,11 +146,17 @@ class SpannerSyncDriver(SyncDriverAdapterBase): """Synchronous Spanner driver operating on Snapshot or Transaction contexts.""" dialect: "DialectType" = "spanner" - __slots__ = ("_data_dictionary", "_pending_execute_options", "_row_plan_cache", "_row_plan_deserializer") + __slots__ = ( + "_data_dictionary", + "_owns_transaction", + "_pending_execute_options", + "_row_plan_cache", + "_row_plan_deserializer", + ) def __init__( self, - connection: "SpannerConnection", + connection: "SpannerSyncConnection", statement_config: "StatementConfig | None" = None, driver_features: "dict[str, Any] | None" = None, ) -> None: @@ -107,13 +165,20 @@ def __init__( statement_config = default_statement_config super().__init__(connection=connection, statement_config=statement_config, driver_features=features) - self._data_dictionary: SpannerDataDictionary | None = None - self._pending_execute_options: _PerCallExecuteOptions | None = None + self._data_dictionary: SpannerSyncDataDictionary | None = None + self._owns_transaction = True + self._pending_execute_options: SpannerExecuteOptions | None = None self._row_plan_cache: dict[int, tuple[Any, list[str], tuple[tuple[int, Any], ...] | None]] = {} self._row_plan_deserializer = cast("Callable[[str], Any]", features.get("json_deserializer", from_json)) - def dispatch_execute(self, cursor: "SpannerConnection", statement: "SQL") -> ExecutionResult: + def dispatch_execute(self, cursor: "SpannerSyncConnection", statement: "SQL") -> ExecutionResult: sql, params = self._compiled_sql(statement, self.statement_config) + if is_ddl_statement(sql): + if not supports_write(cursor): + raise SQLConversionError(_READ_ONLY_SNAPSHOT_DDL_ERROR_MESSAGE) + self._commit_before_ddl() + execute_ddl_sync(self._resolve_database(), [sql]) + return self.create_execution_result(cursor, rowcount_override=0) params = cast("dict[str, Any] | None", params) param_types_map = self._infer_param_types(params) coerced_params = self._coerce_params(params) @@ -159,12 +224,12 @@ def dispatch_select_stream(self, statement: "SQL", chunk_size: int) -> "SyncRowS param_types_map = self._infer_param_types(params) coerced_params = self._coerce_params(params) return SyncRowStream( - _SpannerSelectStreamSource( + SpannerSyncStreamSource( self, sql, coerced_params, param_types_map, chunk_size, self._execute_kwargs(for_read=True) ) ) - def dispatch_execute_many(self, cursor: "SpannerConnection", statement: "SQL") -> ExecutionResult: + def dispatch_execute_many(self, cursor: "SpannerSyncConnection", statement: "SQL") -> ExecutionResult: if not supports_batch_update(cursor): msg = "execute_many requires a Transaction context" raise SQLConversionError(msg) @@ -201,7 +266,7 @@ def dispatch_execute_many(self, cursor: "SpannerConnection", statement: "SQL") - return self.create_execution_result(cursor, rowcount_override=total_rows, is_many_result=True) - def dispatch_execute_script(self, cursor: "SpannerConnection", statement: "SQL") -> ExecutionResult: + def dispatch_execute_script(self, cursor: "SpannerSyncConnection", statement: "SQL") -> ExecutionResult: sql, params = self._compiled_sql(statement, self.statement_config) statements = self.split_script_statements(sql, statement.statement_config, strip_trailing_semicolon=True) is_transaction = supports_write(cursor) @@ -214,12 +279,21 @@ def dispatch_execute_script(self, cursor: "SpannerConnection", statement: "SQL") read_execute_kwargs = self._execute_kwargs(for_read=True) write_execute_kwargs = self._execute_kwargs() dialect_str = str(self.dialect) if self.dialect else "spanner" + pending_ddl: list[str] = [] for index, stmt in enumerate(statements): - try: - parsed = _sqlglot.parse_one(stmt, read=dialect_str) - is_select = isinstance(parsed, _sqlglot_exp.Query) - except Exception: - is_select = stmt.upper().strip().startswith("SELECT") + if is_ddl_statement(stmt): + if not is_transaction: + raise SQLConversionError(_READ_ONLY_SNAPSHOT_DDL_ERROR_MESSAGE) + pending_ddl.append(stmt) + count += 1 + continue + if pending_ddl: + self._commit_before_ddl() + execute_ddl_sync(self._resolve_database(), pending_ddl) + pending_ddl = [] + cursor = self.connection + reader = cast("_SpannerReadProtocol", cursor) + is_select = is_query_statement(stmt, dialect_str) if not is_select and not is_transaction: raise SQLConversionError(_READ_ONLY_SNAPSHOT_ERROR_MESSAGE) if not is_select and is_transaction: @@ -236,6 +310,9 @@ def dispatch_execute_script(self, cursor: "SpannerConnection", statement: "SQL") ) count += 1 + if pending_ddl: + self._commit_before_ddl() + execute_ddl_sync(self._resolve_database(), pending_ddl) return self.create_execution_result( cursor, statement_count=count, successful_statements=count, is_script_result=True ) @@ -244,16 +321,24 @@ def begin(self) -> None: return None def commit(self) -> None: + """Commit the active transaction when it has work to commit.""" if isinstance(self.connection, SpannerTransaction): - writer = cast("_SpannerWriteProtocol", self.connection) - if writer.committed is not None: - return - writer.commit() + exc_handler = self.handle_database_exceptions() + with exc_handler: + if resolve_transaction_completion(self.connection, failed=False) == "commit": + cast("_SpannerWriteProtocol", self.connection).commit() + self._check_pending_exception(exc_handler) + self._renew_finished_transaction() def rollback(self) -> None: + """Roll back the active transaction when it has begun.""" if isinstance(self.connection, SpannerTransaction): - writer = cast("_SpannerWriteProtocol", self.connection) - writer.rollback() + exc_handler = self.handle_database_exceptions() + with exc_handler: + if resolve_transaction_completion(self.connection, failed=True) == "rollback": + cast("_SpannerWriteProtocol", self.connection).rollback() + self._check_pending_exception(exc_handler) + self._renew_finished_transaction() def create_savepoint(self, name: str) -> None: """Raise because Spanner does not support savepoints. @@ -282,11 +367,11 @@ def rollback_to_savepoint(self, name: str) -> None: msg = "Spanner does not support savepoints." raise NotImplementedError(msg) - def with_cursor(self, connection: "SpannerConnection") -> "SpannerSyncCursor": + def with_cursor(self, connection: "SpannerSyncConnection") -> "SpannerSyncCursor": return SpannerSyncCursor(connection) - def handle_database_exceptions(self) -> "SpannerExceptionHandler": - return SpannerExceptionHandler() + def handle_database_exceptions(self) -> "SpannerSyncExceptionHandler": + return SpannerSyncExceptionHandler() def execute( self, @@ -297,7 +382,7 @@ def execute( **kwargs: Any, ) -> "SQLResult": """Execute a statement with optional Spanner per-call request options.""" - execute_options = self._pop_execute_options(kwargs) + execute_options = pop_execute_options(kwargs) if execute_options is None: return super().execute(statement, *parameters, statement_config=statement_config, **kwargs) previous_options = self._pending_execute_options @@ -317,7 +402,7 @@ def execute_many( **kwargs: Any, ) -> "SQLResult": """Execute a batch statement with optional Spanner per-call request options.""" - execute_options = self._pop_execute_options(kwargs) + execute_options = pop_execute_options(kwargs) if execute_options is None: return super().execute_many(statement, parameters, *filters, statement_config=statement_config, **kwargs) previous_options = self._pending_execute_options @@ -336,7 +421,7 @@ def execute_script( **kwargs: Any, ) -> "SQLResult": """Execute a multi-statement script with optional Spanner per-call request options.""" - execute_options = self._pop_execute_options(kwargs) + execute_options = pop_execute_options(kwargs) if execute_options is None: return super().execute_script(statement, *parameters, statement_config=statement_config, **kwargs) previous_options = self._pending_execute_options @@ -384,7 +469,7 @@ def select_stream( **kwargs: Any, ) -> "SyncRowStream[SchemaT] | SyncRowStream[dict[str, Any]]": """Execute a query and stream rows with optional Spanner per-call options.""" - execute_options = self._pop_execute_options(kwargs) + execute_options = pop_execute_options(kwargs) if execute_options is None: return super().select_stream( statement, @@ -445,30 +530,33 @@ def load_from_arrow( self._require_capability("arrow_import_enabled") arrow_table = self._coerce_arrow_table(source) - if overwrite: - dialect_str = str(self.dialect) if self.dialect else "spanner" - table_sql = _sqlglot_exp.to_table(table, dialect=dialect_str).sql(dialect=dialect_str, identify=True) - delete_sql = f"DELETE FROM {table_sql} WHERE TRUE" - if isinstance(self.connection, SpannerTransaction): - writer = cast("_SpannerWriteProtocol", self.connection) - writer.execute_update(delete_sql) - else: - msg = "Delete requires a Transaction context." - raise SQLConversionError(msg) - - columns, records = self._arrow_table_to_rows(arrow_table) - if records: - chunks = self._chunk_mutation_rows(columns, records) - if self.driver_features.get("enable_batch_write_api") and not overwrite: - self._batch_write_mutations(table, columns, chunks) - else: - conn = self.connection - if not isinstance(conn, SpannerTransaction): - msg = "Arrow import requires a Transaction context." + exc_handler = self.handle_database_exceptions() + with exc_handler: + if overwrite: + dialect_str = str(self.dialect) if self.dialect else "spanner" + table_sql = _sqlglot_exp.to_table(table, dialect=dialect_str).sql(dialect=dialect_str, identify=True) + delete_sql = f"DELETE FROM {table_sql} WHERE TRUE" + if isinstance(self.connection, SpannerTransaction): + writer = cast("_SpannerWriteProtocol", self.connection) + writer.execute_update(delete_sql) + else: + msg = "Delete requires a Transaction context." raise SQLConversionError(msg) - writer = cast("_SpannerWriteProtocol", conn) - for chunk in chunks: - writer.insert_or_update(table, columns, chunk) + + columns, records = self._arrow_table_to_rows(arrow_table) + if records: + chunks = self._chunk_mutation_rows(columns, records) + if self.driver_features.get("enable_batch_write_api") and not overwrite: + self._batch_write_mutations(table, columns, chunks) + else: + conn = self.connection + if not isinstance(conn, SpannerTransaction): + msg = "Arrow import requires a Transaction context." + raise SQLConversionError(msg) + writer = cast("_SpannerWriteProtocol", conn) + for chunk in chunks: + writer.insert_or_update(table, columns, chunk) + self._check_pending_exception(exc_handler) telemetry_payload = self._ingest_telemetry(arrow_table) telemetry_payload["destination"] = table @@ -489,22 +577,18 @@ def load_from_storage( return self.load_from_arrow(table, arrow_table, partitioner=partitioner, overwrite=overwrite, telemetry=inbound) @property - def data_dictionary(self) -> "SpannerDataDictionary": + def data_dictionary(self) -> "SpannerSyncDataDictionary": if self._data_dictionary is None: - self._data_dictionary = SpannerDataDictionary() + dialect_str = str(self.statement_config.dialect) if self.statement_config.dialect else "spanner" + mode = "postgresql" if dialect_str in {"spangres", "postgres", "postgresql"} else "googlesql" + self._data_dictionary = SpannerSyncDataDictionary(mode=mode) return self._data_dictionary - def collect_rows(self, cursor: "SpannerConnection", fetched: "list[Any]") -> "tuple[list[Any], list[str], int]": + def collect_rows(self, cursor: "SpannerSyncConnection", fetched: "list[Any]") -> "tuple[list[Any], list[str], int]": """Collect Spanner rows for the direct execution path. - Note: Spanner's collect_rows requires result set fields and a type converter. - The direct execution path may not always have this metadata available, - so this falls back to basic collection. - - For the direct path, if result set fields metadata is not available, - it returns raw data with no column names. If rows are dicts, it attempts - to extract column names from dict keys. For tuple rows without metadata, - it returns them as-is. + Dict rows supply their keys as column names; other rows are returned + unchanged with no column names. """ if not fetched: return [], [], 0 @@ -513,7 +597,7 @@ def collect_rows(self, cursor: "SpannerConnection", fetched: "list[Any]") -> "tu return fetched, column_names, len(fetched) return fetched, [], len(fetched) - def resolve_rowcount(self, cursor: "SpannerConnection") -> int: + def resolve_rowcount(self, cursor: "SpannerSyncConnection") -> int: """Resolve rowcount from Spanner cursor for the direct execution path. Spanner uses execute_update return value, not cursor.rowcount, so this @@ -522,88 +606,19 @@ def resolve_rowcount(self, cursor: "SpannerConnection") -> int: return 0 def _execute_kwargs(self, *, for_read: bool = False, for_batch: bool = False) -> dict[str, Any]: - kwargs: dict[str, Any] = { - key: self.driver_features[key] for key in ("retry", "timeout") if key in self.driver_features - } - request_options = self.driver_features.get("request_options") - if request_options is not None: - kwargs["request_options"] = request_options - if not for_batch: - query_options = self.driver_features.get("query_options") - if query_options is not None: - kwargs["query_options"] = query_options - if for_read and not for_batch: - directed_read_options = self.driver_features.get("directed_read_options") - if directed_read_options is not None: - kwargs["directed_read_options"] = directed_read_options - pending = self._pending_execute_options - if pending is not None: - if pending.request_options is not None: - kwargs["request_options"] = pending.request_options - if not for_batch and pending.query_options is not None: - kwargs["query_options"] = pending.query_options - if pending.retry is not None: - kwargs["retry"] = pending.retry - if pending.timeout is not None: - kwargs["timeout"] = pending.timeout - if for_read and not for_batch and pending.directed_read_options is not None: - kwargs["directed_read_options"] = pending.directed_read_options - if not for_read and pending.last_statement: - kwargs["last_statement"] = True - return kwargs - - def _pop_execute_options(self, kwargs: dict[str, Any]) -> "_PerCallExecuteOptions | None": - if not any( - key in kwargs - for key in ( - "request_options", - "query_options", - "directed_read_options", - "retry", - "timeout", - "last_statement", - ) - ): - return None - return _PerCallExecuteOptions( - request_options=kwargs.pop("request_options", None), - query_options=kwargs.pop("query_options", None), - directed_read_options=kwargs.pop("directed_read_options", None), - retry=kwargs.pop("retry", None), - timeout=kwargs.pop("timeout", None), - last_statement=bool(kwargs.pop("last_statement", False)), + return build_execute_kwargs( + self.driver_features, self._pending_execute_options, for_read=for_read, for_batch=for_batch ) def _chunk_mutation_rows(self, columns: "list[str]", records: "list[tuple[Any, ...]]") -> "list[list[list[Any]]]": - """Coerce Arrow rows into chunks bounded by Spanner's mutation-group ceiling.""" - column_count = len(columns) - max_cells = 80_000 - chunks: list[list[list[Any]]] = [] - values: list[list[Any]] = [] - pending_cells = 0 - for record in records: - if values and pending_cells + column_count > max_cells: - chunks.append(values) - values = [] - pending_cells = 0 - coerced = self._coerce_params({f"p{i}": value for i, value in enumerate(record)}) or {} - values.append([coerced.get(f"p{i}") for i in range(column_count)]) - pending_cells += column_count - if pending_cells == max_cells: - chunks.append(values) - values = [] - pending_cells = 0 - if values: - chunks.append(values) - return chunks + return chunk_mutation_rows(columns, records, self._coerce_params) + + def _resolve_database(self) -> Any: + return cast("Any", self.connection)._session._database def _batch_write_mutations(self, table: str, columns: "list[str]", chunks: "list[list[list[Any]]]") -> None: """High-throughput ingest via the Spanner Batch Write API (one mutation group per chunk).""" - session = cast("object", getattr(self.connection, "_session", None)) - database = cast("Any", getattr(session, "_database", None)) if session is not None else None - if database is None: - msg = "Spanner Batch Write API requires a database-backed session." - raise SQLConversionError(msg) + database = self._resolve_database() with database.mutation_groups() as mutation_groups: for chunk in chunks: group = mutation_groups.group() @@ -614,6 +629,586 @@ def _batch_write_mutations(self, table: str, columns: "list[str]", chunks: "list msg = f"Spanner batch_write group failed: {status.message}" raise SQLConversionError(msg) + def run_in_transaction(self, fn: "Callable[..., Any]", *args: Any, **kwargs: Any) -> Any: + """Execute a callable inside Spanner's retryable transaction runner. + + ``fn`` is called as ``fn(driver, *args, **kwargs)`` with a driver bound + to the runner's transaction. A ``DeadlockError`` caused by + ``google.api_core.exceptions.Aborted`` is unwrapped so the runner + retries, and a terminal ``Aborted`` is raised as ``DeadlockError``. + """ + return run_in_transaction_sync(self._resolve_database(), self._transaction_driver, fn, *args, **kwargs) + + def _commit_before_ddl(self) -> None: + """Commit a begun session transaction before a schema change, as DDL does on MySQL or Oracle.""" + if self._owns_transaction and isinstance(self.connection, SpannerTransaction): + self.commit() + + def _renew_finished_transaction(self) -> None: + """Start a new transaction on the session once the owned transaction has finished.""" + connection = cast("Any", self.connection) + if self._owns_transaction and (connection.committed is not None or connection.rolled_back): + self.connection = renew_transaction(connection) + + def _transaction_driver(self, transaction: "SpannerSyncConnection") -> "SpannerSyncDriver": + driver = type(self)( + connection=transaction, statement_config=self.statement_config, driver_features=self.driver_features + ) + driver._owns_transaction = False + return driver + + def _connection_in_transaction(self) -> bool: + """Check if connection is in transaction.""" + return False + + def _coerce_params(self, params: "dict[str, Any] | list[Any] | tuple[Any, ...] | None") -> "dict[str, Any] | None": + return coerce_params( + params, + json_serializer=self.driver_features.get("json_serializer"), + enable_uuid_conversion=self.driver_features.get("enable_uuid_conversion", True), + ) + + def _infer_param_types(self, params: "dict[str, Any] | list[Any] | tuple[Any, ...] | None") -> "dict[str, Any]": + return infer_param_types(params) + + def _resolve_row_plan(self, fields: Any) -> "tuple[list[str], tuple[tuple[int, Any], ...] | None]": + json_deserializer = cast("Callable[[str], Any]", self.driver_features.get("json_deserializer", from_json)) + if json_deserializer is not self._row_plan_deserializer: + self._row_plan_cache.clear() + self._row_plan_deserializer = json_deserializer + return resolve_row_plan(fields, self._row_plan_cache, json_deserializer=json_deserializer) + + +class SpannerAsyncDriver(AsyncDriverAdapterBase): + """Asynchronous Spanner driver operating on AsyncSnapshot or AsyncTransaction contexts.""" + + dialect: "DialectType" = "spanner" + __slots__ = ( + "_data_dictionary", + "_owns_transaction", + "_pending_execute_options", + "_row_plan_cache", + "_row_plan_deserializer", + ) + + def __init__( + self, + connection: "SpannerAsyncConnection", + statement_config: "StatementConfig | None" = None, + driver_features: "dict[str, Any] | None" = None, + ) -> None: + features = dict(driver_features) if driver_features else {} + if statement_config is None: + statement_config = default_statement_config + + super().__init__(connection=connection, statement_config=statement_config, driver_features=features) + self._data_dictionary: SpannerAsyncDataDictionary | None = None + self._owns_transaction = True + self._pending_execute_options: SpannerExecuteOptions | None = None + self._row_plan_cache: dict[int, tuple[Any, list[str], tuple[tuple[int, Any], ...] | None]] = {} + self._row_plan_deserializer = cast("Callable[[str], Any]", features.get("json_deserializer", from_json)) + + async def dispatch_execute(self, cursor: "SpannerAsyncConnection", statement: "SQL") -> ExecutionResult: + sql, params = self._compiled_sql(statement, self.statement_config) + if is_ddl_statement(sql): + if not supports_write(cursor): + raise SQLConversionError(_READ_ONLY_ASYNC_SNAPSHOT_DDL_ERROR_MESSAGE) + await self._commit_before_ddl() + await execute_ddl_async(self._resolve_database(), [sql]) + return self.create_execution_result(cursor, rowcount_override=0) + params = cast("dict[str, Any] | None", params) + param_types_map = self._infer_param_types(params) + coerced_params = self._coerce_params(params) + + if statement.returns_rows(): + reader = cast("_SpannerAsyncReadProtocol", cursor) + execute_kwargs = self._execute_kwargs(for_read=True) + result_set = await reader.execute_sql( + sql, params=coerced_params, param_types=param_types_map, **execute_kwargs + ) + rows = [row async for row in result_set] + try: + metadata = result_set.metadata + row_type = metadata.row_type + fields = row_type.fields + except AttributeError: + fields = None + if not fields: + msg = "Result set metadata not available." + raise SQLConversionError(msg) + column_names, column_plan = self._resolve_row_plan(fields) + data, column_names = collect_rows(rows, fields, column_names=column_names, column_plan=column_plan) + return self.create_execution_result( + cursor, + selected_data=data, + column_names=column_names, + data_row_count=len(data), + is_select_result=True, + row_format="tuple", + ) + + if supports_write(cursor): + writer = cast("_SpannerAsyncWriteProtocol", cursor) + execute_kwargs = self._execute_kwargs() + row_count = await writer.execute_update( + sql, params=coerced_params, param_types=param_types_map, **execute_kwargs + ) + return self.create_execution_result(cursor, rowcount_override=row_count) + + raise SQLConversionError(_READ_ONLY_ASYNC_SNAPSHOT_ERROR_MESSAGE) + + def dispatch_select_stream(self, statement: "SQL", chunk_size: int) -> "AsyncRowStream[dict[str, Any]] | None": + if not statement.returns_rows(): + return None + sql, params = self._compiled_sql(statement, self.statement_config) + params = cast("dict[str, Any] | None", params) + param_types_map = self._infer_param_types(params) + coerced_params = self._coerce_params(params) + return AsyncRowStream( + SpannerAsyncStreamSource( + self, sql, coerced_params, param_types_map, chunk_size, self._execute_kwargs(for_read=True) + ) + ) + + async def dispatch_execute_many(self, cursor: "SpannerAsyncConnection", statement: "SQL") -> ExecutionResult: + if not supports_batch_update(cursor): + msg = "execute_many requires a Transaction context" + raise SQLConversionError(msg) + + sql, prepared_parameters = self._compiled_sql(statement, self.statement_config) + + if not isinstance(prepared_parameters, list): + msg = "execute_many requires a list of parameter sets" + raise SQLConversionError(msg) + + _coerce = self._coerce_params + _infer = self._infer_param_types + execute_kwargs = self._execute_kwargs(for_batch=True) + param_types_cache: dict[tuple[tuple[str, type[Any], Any], ...], dict[str, Any]] = {} + empty_param_types: dict[str, Any] = {} + batch_args: list[tuple[str, dict[str, Any] | None, dict[str, Any]]] = [] + append_batch_arg = batch_args.append + for params in prepared_parameters: + raw_params = cast("dict[str, Any] | None", params) + coerced_params = _coerce(raw_params) + if not coerced_params: + append_batch_arg((sql, {}, empty_param_types)) + continue + signature = build_param_type_signature(raw_params) + param_types = param_types_cache.get(signature) + if param_types is None: + param_types = _infer(raw_params) + param_types_cache[signature] = param_types + append_batch_arg((sql, coerced_params, param_types)) + + writer = cast("_SpannerAsyncWriteProtocol", cursor) + _status, row_counts = await writer.batch_update(batch_args, **execute_kwargs) + total_rows = sum(row_counts) if row_counts else 0 + + return self.create_execution_result(cursor, rowcount_override=total_rows, is_many_result=True) + + async def dispatch_execute_script(self, cursor: "SpannerAsyncConnection", statement: "SQL") -> ExecutionResult: + sql, params = self._compiled_sql(statement, self.statement_config) + statements = self.split_script_statements(sql, statement.statement_config, strip_trailing_semicolon=True) + is_transaction = supports_write(cursor) + reader = cast("_SpannerAsyncReadProtocol", cursor) + + count = 0 + script_params = cast("dict[str, Any] | None", params) + param_types_map = self._infer_param_types(script_params) + coerced_params = self._coerce_params(script_params) + read_execute_kwargs = self._execute_kwargs(for_read=True) + write_execute_kwargs = self._execute_kwargs() + dialect_str = str(self.dialect) if self.dialect else "spanner" + pending_ddl: list[str] = [] + for index, stmt in enumerate(statements): + if is_ddl_statement(stmt): + if not is_transaction: + raise SQLConversionError(_READ_ONLY_ASYNC_SNAPSHOT_DDL_ERROR_MESSAGE) + pending_ddl.append(stmt) + count += 1 + continue + if pending_ddl: + await self._commit_before_ddl() + await execute_ddl_async(self._resolve_database(), pending_ddl) + pending_ddl = [] + cursor = self.connection + reader = cast("_SpannerAsyncReadProtocol", cursor) + is_select = is_query_statement(stmt, dialect_str) + if not is_select and not is_transaction: + raise SQLConversionError(_READ_ONLY_ASYNC_SNAPSHOT_ERROR_MESSAGE) + if not is_select and is_transaction: + writer = cast("_SpannerAsyncWriteProtocol", cursor) + statement_kwargs = write_execute_kwargs + if "last_statement" in write_execute_kwargs and index != len(statements) - 1: + statement_kwargs = { + key: value for key, value in write_execute_kwargs.items() if key != "last_statement" + } + await writer.execute_update( + stmt, params=coerced_params, param_types=param_types_map, **statement_kwargs + ) + else: + rs = await reader.execute_sql( + stmt, params=coerced_params, param_types=param_types_map, **read_execute_kwargs + ) + _ = [row async for row in rs] + count += 1 + + if pending_ddl: + await self._commit_before_ddl() + await execute_ddl_async(self._resolve_database(), pending_ddl) + return self.create_execution_result( + cursor, statement_count=count, successful_statements=count, is_script_result=True + ) + + async def begin(self) -> None: + return None + + async def commit(self) -> None: + """Commit the active transaction when it has work to commit.""" + if isinstance(self.connection, SpannerAsyncTransaction): + exc_handler = self.handle_database_exceptions() + async with exc_handler: + if resolve_transaction_completion(self.connection, failed=False) == "commit": + await cast("_SpannerAsyncWriteProtocol", self.connection).commit() + self._check_pending_exception(exc_handler) + self._renew_finished_transaction() + + async def rollback(self) -> None: + """Roll back the active transaction when it has begun.""" + if isinstance(self.connection, SpannerAsyncTransaction): + exc_handler = self.handle_database_exceptions() + async with exc_handler: + if resolve_transaction_completion(self.connection, failed=True) == "rollback": + await cast("_SpannerAsyncWriteProtocol", self.connection).rollback() + self._check_pending_exception(exc_handler) + self._renew_finished_transaction() + + async def create_savepoint(self, name: str) -> None: + """Raise because Spanner does not support savepoints. + + Raises: + NotImplementedError: Always. + """ + msg = "Spanner does not support savepoints." + raise NotImplementedError(msg) + + async def release_savepoint(self, name: str) -> None: + """Raise because Spanner does not support savepoints. + + Raises: + NotImplementedError: Always. + """ + msg = "Spanner does not support savepoints." + raise NotImplementedError(msg) + + async def rollback_to_savepoint(self, name: str) -> None: + """Raise because Spanner does not support savepoints. + + Raises: + NotImplementedError: Always. + """ + msg = "Spanner does not support savepoints." + raise NotImplementedError(msg) + + def with_cursor(self, connection: "SpannerAsyncConnection") -> "SpannerAsyncCursor": + return SpannerAsyncCursor(connection) + + def handle_database_exceptions(self) -> "SpannerAsyncExceptionHandler": + return SpannerAsyncExceptionHandler() + + async def execute( + self, + statement: "SQL | Statement | QueryBuilder", + /, + *parameters: "StatementParameters | StatementFilter", + statement_config: "StatementConfig | None" = None, + **kwargs: Any, + ) -> "SQLResult": + """Execute a statement with optional Spanner per-call request options.""" + execute_options = pop_execute_options(kwargs) + if execute_options is None: + return await super().execute(statement, *parameters, statement_config=statement_config, **kwargs) + previous_options = self._pending_execute_options + self._pending_execute_options = execute_options + try: + return await super().execute(statement, *parameters, statement_config=statement_config, **kwargs) + finally: + self._pending_execute_options = previous_options + + async def execute_many( + self, + statement: "SQL | Statement | QueryBuilder", + /, + parameters: "Sequence[StatementParameters]", + *filters: "StatementParameters | StatementFilter", + statement_config: "StatementConfig | None" = None, + **kwargs: Any, + ) -> "SQLResult": + """Execute a batch statement with optional Spanner per-call request options.""" + execute_options = pop_execute_options(kwargs) + if execute_options is None: + return await super().execute_many( + statement, parameters, *filters, statement_config=statement_config, **kwargs + ) + previous_options = self._pending_execute_options + self._pending_execute_options = execute_options + try: + return await super().execute_many( + statement, parameters, *filters, statement_config=statement_config, **kwargs + ) + finally: + self._pending_execute_options = previous_options + + async def execute_script( + self, + statement: "str | SQL", + /, + *parameters: "StatementParameters | StatementFilter", + statement_config: "StatementConfig | None" = None, + **kwargs: Any, + ) -> "SQLResult": + """Execute a multi-statement script with optional Spanner per-call request options.""" + execute_options = pop_execute_options(kwargs) + if execute_options is None: + return await super().execute_script(statement, *parameters, statement_config=statement_config, **kwargs) + previous_options = self._pending_execute_options + self._pending_execute_options = execute_options + try: + return await super().execute_script(statement, *parameters, statement_config=statement_config, **kwargs) + finally: + self._pending_execute_options = previous_options + + @overload + def select_stream( + self, + statement: "SQL | Statement | QueryBuilder", + /, + *parameters: "StatementParameters | StatementFilter", + schema_type: "type[SchemaT]", + statement_config: "StatementConfig | None" = None, + chunk_size: int = 1000, + native_only: bool = False, + **kwargs: Any, + ) -> "AsyncRowStream[SchemaT]": ... + + @overload + def select_stream( + self, + statement: "SQL | Statement | QueryBuilder", + /, + *parameters: "StatementParameters | StatementFilter", + schema_type: None = None, + statement_config: "StatementConfig | None" = None, + chunk_size: int = 1000, + native_only: bool = False, + **kwargs: Any, + ) -> "AsyncRowStream[dict[str, Any]]": ... + + def select_stream( + self, + statement: "SQL | Statement | QueryBuilder", + /, + *parameters: "StatementParameters | StatementFilter", + schema_type: "type[SchemaT] | None" = None, + statement_config: "StatementConfig | None" = None, + chunk_size: int = 1000, + native_only: bool = False, + **kwargs: Any, + ) -> "AsyncRowStream[SchemaT] | AsyncRowStream[dict[str, Any]]": + """Execute a query and stream rows with optional Spanner per-call options.""" + execute_options = pop_execute_options(kwargs) + if execute_options is None: + return super().select_stream( + statement, + *parameters, + schema_type=schema_type, + statement_config=statement_config, + chunk_size=chunk_size, + native_only=native_only, + **kwargs, + ) + previous_options = self._pending_execute_options + self._pending_execute_options = execute_options + try: + return super().select_stream( + statement, + *parameters, + schema_type=schema_type, + statement_config=statement_config, + chunk_size=chunk_size, + native_only=native_only, + **kwargs, + ) + finally: + self._pending_execute_options = previous_options + + async def select_to_storage( + self, + statement: "SQL | str", + destination: "StorageDestination", + /, + *parameters: Any, + statement_config: "StatementConfig | None" = None, + partitioner: "dict[str, object] | None" = None, + format_hint: "StorageFormat | None" = None, + telemetry: "StorageTelemetry | None" = None, + **kwargs: Any, + ) -> "StorageBridgeJob": + """Execute query and stream Arrow results to storage asynchronously.""" + self._require_capability("arrow_export_enabled") + arrow_result = await self.select_to_arrow(statement, *parameters, statement_config=statement_config, **kwargs) + async_pipeline = self._storage_pipeline() + telemetry_payload = await self._write_storage_result( + arrow_result, destination, format_hint=format_hint, pipeline=async_pipeline + ) + self._attach_partition_telemetry(telemetry_payload, partitioner) + return self._storage_job(telemetry_payload, telemetry) + + async def load_from_arrow( + self, + table: str, + source: "ArrowResult | Any", + *, + partitioner: "dict[str, object] | None" = None, + overwrite: bool = False, + telemetry: "StorageTelemetry | None" = None, + ) -> "StorageBridgeJob": + """Load Arrow data into Spanner table via batch mutations.""" + self._require_capability("arrow_import_enabled") + arrow_table = self._coerce_arrow_table(source) + + exc_handler = self.handle_database_exceptions() + async with exc_handler: + if overwrite: + dialect_str = str(self.dialect) if self.dialect else "spanner" + table_sql = _sqlglot_exp.to_table(table, dialect=dialect_str).sql(dialect=dialect_str, identify=True) + delete_sql = f"DELETE FROM {table_sql} WHERE TRUE" + if isinstance(self.connection, SpannerAsyncTransaction): + writer = cast("_SpannerAsyncWriteProtocol", self.connection) + await writer.execute_update(delete_sql) + else: + msg = "Delete requires a Transaction context." + raise SQLConversionError(msg) + + columns, records = self._arrow_table_to_rows(arrow_table) + if records: + chunks = self._chunk_mutation_rows(columns, records) + if self.driver_features.get("enable_batch_write_api") and not overwrite: + await self._batch_write_mutations(table, columns, chunks) + else: + conn = self.connection + if not isinstance(conn, SpannerAsyncTransaction): + msg = "Arrow import requires a Transaction context." + raise SQLConversionError(msg) + writer = cast("_SpannerAsyncWriteProtocol", conn) + for chunk in chunks: + writer.insert_or_update(table, columns, chunk) + self._check_pending_exception(exc_handler) + + telemetry_payload = self._ingest_telemetry(arrow_table) + telemetry_payload["destination"] = table + self._attach_partition_telemetry(telemetry_payload, partitioner) + return self._storage_job(telemetry_payload, telemetry) + + async def load_from_storage( + self, + table: str, + source: "StorageDestination", + *, + file_format: "StorageFormat", + partitioner: "dict[str, object] | None" = None, + overwrite: bool = False, + ) -> "StorageBridgeJob": + """Load artifacts from storage into Spanner table asynchronously.""" + arrow_table, inbound = await self._read_storage_arrow(source, file_format=file_format) + return await self.load_from_arrow( + table, arrow_table, partitioner=partitioner, overwrite=overwrite, telemetry=inbound + ) + + @property + def data_dictionary(self) -> "SpannerAsyncDataDictionary": + if self._data_dictionary is None: + dialect_str = str(self.statement_config.dialect) if self.statement_config.dialect else "spanner" + mode = "postgresql" if dialect_str in {"spangres", "postgres", "postgresql"} else "googlesql" + self._data_dictionary = SpannerAsyncDataDictionary(mode=mode) + return self._data_dictionary + + def collect_rows( + self, cursor: "SpannerAsyncConnection", fetched: "list[Any]" + ) -> "tuple[list[Any], list[str], int]": + """Collect Spanner rows for the direct execution path. + + Dict rows supply their keys as column names; other rows are returned + unchanged with no column names. + """ + if not fetched: + return [], [], 0 + if isinstance(fetched[0], dict): + column_names = list(fetched[0].keys()) + return fetched, column_names, len(fetched) + return fetched, [], len(fetched) + + def resolve_rowcount(self, cursor: "SpannerAsyncConnection") -> int: + """Resolve rowcount from Spanner cursor for the direct execution path. + + Spanner uses execute_update return value, not cursor.rowcount, so this + returns 0. + """ + return 0 + + def _execute_kwargs(self, *, for_read: bool = False, for_batch: bool = False) -> dict[str, Any]: + return build_execute_kwargs( + self.driver_features, self._pending_execute_options, for_read=for_read, for_batch=for_batch + ) + + def _chunk_mutation_rows(self, columns: "list[str]", records: "list[tuple[Any, ...]]") -> "list[list[list[Any]]]": + return chunk_mutation_rows(columns, records, self._coerce_params) + + def _resolve_database(self) -> Any: + return cast("Any", self.connection)._session._database + + async def _batch_write_mutations(self, table: str, columns: "list[str]", chunks: "list[list[list[Any]]]") -> None: + """High-throughput async ingest via the Spanner Batch Write API (one mutation group per chunk).""" + database = self._resolve_database() + async with database.mutation_groups() as mutation_groups: + for chunk in chunks: + group = mutation_groups.group() + group.insert_or_update(table, columns, chunk) + async for response in await mutation_groups.batch_write(): + status = response.status + if status is not None and status.code: + msg = f"Spanner batch_write group failed: {status.message}" + raise SQLConversionError(msg) + + async def run_in_transaction(self, fn: "Callable[..., Awaitable[Any]]", *args: Any, **kwargs: Any) -> Any: + """Execute a coroutine function inside Spanner's async retryable transaction runner. + + ``fn`` is awaited as ``fn(driver, *args, **kwargs)`` with a driver bound + to the runner's transaction. A ``DeadlockError`` caused by + ``google.api_core.exceptions.Aborted`` is unwrapped so the runner + retries, and a terminal ``Aborted`` is raised as ``DeadlockError``. + """ + return await run_in_transaction_async(self._resolve_database(), self._transaction_driver, fn, *args, **kwargs) + + async def _commit_before_ddl(self) -> None: + """Commit a begun session transaction before a schema change, as DDL does on MySQL or Oracle.""" + if self._owns_transaction and isinstance(self.connection, SpannerAsyncTransaction): + await self.commit() + + def _renew_finished_transaction(self) -> None: + """Start a new transaction on the session once the owned transaction has finished.""" + connection = cast("Any", self.connection) + if self._owns_transaction and (connection.committed is not None or connection.rolled_back): + self.connection = renew_transaction(connection) + + def _transaction_driver(self, transaction: "SpannerAsyncConnection") -> "SpannerAsyncDriver": + driver = type(self)( + connection=transaction, statement_config=self.statement_config, driver_features=self.driver_features + ) + driver._owns_transaction = False + return driver + def _connection_in_transaction(self) -> bool: """Check if connection is in transaction.""" return False @@ -674,122 +1269,42 @@ def commit(self) -> None: ... def rollback(self) -> None: ... -class _PerCallExecuteOptions: - """Per-call Spanner execution options captured for a single dispatch.""" +class _SpannerAsyncResultSetProtocol(Protocol): + metadata: Any - __slots__ = ("directed_read_options", "last_statement", "query_options", "request_options", "retry", "timeout") + def __aiter__(self) -> AsyncIterator[Any]: ... - def __init__( - self, - *, - request_options: "RequestOptions | dict[str, Any] | None" = None, - query_options: "ExecuteSqlRequest.QueryOptions | dict[str, Any] | None" = None, - directed_read_options: "DirectedReadOptions | None" = None, - retry: "Retry | None" = None, - timeout: "float | None" = None, - last_statement: bool = False, - ) -> None: - self.request_options = request_options - self.query_options = query_options - self.directed_read_options = directed_read_options - self.retry = retry - self.timeout = timeout - self.last_statement = last_statement +class _SpannerAsyncReadProtocol(Protocol): + async def execute_sql( + self, + sql: str, + params: "dict[str, Any] | None" = None, + param_types: "dict[str, Any] | None" = None, + **kwargs: Any, + ) -> _SpannerAsyncResultSetProtocol: ... -class _SpannerSelectStreamSource: - """Native chunk source for Spanner SELECT streaming.""" - __slots__ = ( - "_chunk_size", - "_column_names", - "_column_plan", - "_driver", - "_execute_kwargs", - "_param_types", - "_params", - "_result_set", - "_row_iterator", - "_sql", - ) +class _SpannerAsyncWriteProtocol(_SpannerAsyncReadProtocol, Protocol): + committed: "Any | None" - def __init__( + async def execute_update( self, - driver: "SpannerSyncDriver", sql: str, - params: "dict[str, Any] | None", - param_types: "dict[str, Any]", - chunk_size: int, - execute_kwargs: "dict[str, Any]", - ) -> None: - self._driver = driver - self._sql = sql - self._params = params - self._param_types = param_types - self._chunk_size = chunk_size - self._execute_kwargs = execute_kwargs - self._column_names: list[str] | None = None - self._column_plan: tuple[tuple[int, Any], ...] | None = None - self._result_set: _SpannerResultSetProtocol | None = None - self._row_iterator: Iterator[Any] | None = None - - def start(self) -> None: - handler = self._driver.handle_database_exceptions() - with handler: - result_set = self._driver.connection.execute_sql( - self._sql, params=self._params, param_types=self._param_types, **self._execute_kwargs - ) - self._result_set = result_set - self._row_iterator = iter(result_set) - self._driver._check_pending_exception(handler) - - def fetch_chunk(self) -> "list[dict[str, Any]]": - result_set = self._result_set - row_iterator = self._row_iterator - column_names = self._column_names - if result_set is None or row_iterator is None: - return [] - - handler = self._driver.handle_database_exceptions() - rows: list[Any] = [] - with handler: - rows = list(islice(row_iterator, self._chunk_size)) - self._driver._check_pending_exception(handler) - if not rows: - return [] - - if column_names is None: - try: - metadata = result_set.metadata - row_type = metadata.row_type - fields = row_type.fields - except AttributeError: - msg = "Result set metadata not available." - raise SQLConversionError(msg) - if not fields: - msg = "Result set metadata not available." - raise SQLConversionError(msg) - column_names, column_plan = self._driver._resolve_row_plan(fields) - self._column_names = column_names - self._column_plan = column_plan + params: "dict[str, Any] | None" = None, + param_types: "dict[str, Any] | None" = None, + **kwargs: Any, + ) -> int: ... - converted_rows, resolved_column_names = collect_rows( - rows, (), column_names=column_names, column_plan=self._column_plan - ) - self._column_names = resolved_column_names - return rows_to_dicts(converted_rows, resolved_column_names) - - def close(self, error: bool = False) -> None: - result_set = self._result_set - if result_set is not None: - close = getattr(result_set, "close", None) - if callable(close): - with contextlib.suppress(Exception): - close() - self._result_set = None - self._row_iterator = None - self._column_names = None - self._column_plan = None + async def batch_update( + self, batch: "list[tuple[str, dict[str, Any] | None, dict[str, Any]]]", **kwargs: Any + ) -> "tuple[Any, list[int]]": ... + + def insert_or_update(self, table: str, columns: "list[str]", values: "list[list[Any]]") -> None: ... + + async def commit(self) -> None: ... + + async def rollback(self) -> None: ... register_driver_profile("spanner", driver_profile) diff --git a/sqlspec/adapters/spanner/events/__init__.py b/sqlspec/adapters/spanner/events/__init__.py index 98a60e9c8..b3ecb28a6 100644 --- a/sqlspec/adapters/spanner/events/__init__.py +++ b/sqlspec/adapters/spanner/events/__init__.py @@ -1,5 +1,5 @@ """Events helpers for the Spanner adapter.""" -from sqlspec.adapters.spanner.events.store import SpannerSyncEventQueueStore +from sqlspec.adapters.spanner.events.store import SpannerAsyncEventQueueStore, SpannerSyncEventQueueStore -__all__ = ("SpannerSyncEventQueueStore",) +__all__ = ("SpannerAsyncEventQueueStore", "SpannerSyncEventQueueStore") diff --git a/sqlspec/adapters/spanner/events/store.py b/sqlspec/adapters/spanner/events/store.py index 960573d00..aa81983b1 100644 --- a/sqlspec/adapters/spanner/events/store.py +++ b/sqlspec/adapters/spanner/events/store.py @@ -9,28 +9,29 @@ """ import logging +from typing import TYPE_CHECKING -from sqlspec.adapters.spanner.config import SpannerSyncConfig +from sqlspec.adapters.spanner.config import SpannerAsyncConfig, SpannerSyncConfig +from sqlspec.adapters.spanner.core import execute_ddl_async, execute_ddl_sync from sqlspec.extensions.events import BaseEventQueueStore from sqlspec.utils.logging import get_logger, log_with_context -__all__ = ("SpannerSyncEventQueueStore",) +__all__ = ("SpannerAsyncEventQueueStore", "SpannerSyncEventQueueStore") logger = get_logger("sqlspec.adapters.spanner.events.store") -class SpannerSyncEventQueueStore(BaseEventQueueStore["SpannerSyncConfig"]): - """Spanner-specific event queue store with GoogleSQL DDL. +class _SpannerEventStoreMixin: + """GoogleSQL DDL hooks for sync and async Spanner event queue stores.""" - Generates optimized DDL for Google Cloud Spanner using GoogleSQL dialect. - Spanner does not support IF NOT EXISTS, so statements must be executed - with proper error handling for existing objects. + __slots__ = () - Args: - config: SpannerSyncConfig with extension_config["events"] settings. - """ + if TYPE_CHECKING: - __slots__ = () + @property + def table_name(self) -> str: ... + + def _index_name(self) -> str: ... def _column_types(self) -> "tuple[str, str, str]": """Return Spanner-specific column types.""" @@ -51,8 +52,8 @@ def _primary_key_syntax(self) -> str: def _table_ddl(self) -> str: """Build Spanner CREATE TABLE with PRIMARY KEY inline. - Spanner does not support DEFAULT clauses on non-computed columns, - so we omit them entirely. Values must be provided at insert time. + Spanner does not support DEFAULT clauses on non-computed columns, so + the DDL has none; values are provided at insert time. """ payload_type, metadata_type, timestamp_type = self._column_types() string_64 = self._string_type(64) @@ -63,26 +64,16 @@ def _table_ddl(self) -> str: return f"CREATE TABLE {self.table_name} (event_id {string_64} NOT NULL, channel {string_128} NOT NULL, payload_json {payload_type} NOT NULL, metadata_json {metadata_type}, status {string_32} NOT NULL, available_at {timestamp_type} NOT NULL, lease_expires_at {timestamp_type}, attempts {integer_type} NOT NULL, created_at {timestamp_type} NOT NULL, acknowledged_at {timestamp_type}){pk_inline}" - def _index_ddl(self) -> str | None: + def _index_ddl(self) -> "str | None": """Build Spanner secondary index for queue operations.""" - index_name = self._index_name() - return f"CREATE INDEX {index_name} ON {self.table_name}(channel, status, available_at)" + return f"CREATE INDEX {self._index_name()} ON {self.table_name}(channel, status, available_at)" def _wrap_create_statement(self, statement: str, object_type: str) -> str: - """Return statement unchanged - Spanner does not support IF NOT EXISTS. - - Args: - statement: The DDL statement. - object_type: Type of object (table, index). - - Returns: - The statement unchanged. - """ - del object_type + """Return statement unchanged because Spanner does not support IF NOT EXISTS.""" return statement def _wrap_drop_statement(self, statement: str) -> str: - """Return statement unchanged - Spanner does not support IF EXISTS.""" + """Return statement unchanged because Spanner does not support IF EXISTS.""" return statement def create_statements(self) -> "list[str]": @@ -103,53 +94,96 @@ def drop_statements(self) -> "list[str]": Spanner requires index to be dropped before the table. The caller should handle errors for non-existent objects. """ - index_name = self._index_name() - return [f"DROP INDEX {index_name}", f"DROP TABLE {self.table_name}"] + return [f"DROP INDEX {self._index_name()}", f"DROP TABLE {self.table_name}"] - def create_table(self) -> None: - """Create the event queue table and index. + def _log_ddl(self, event: str, statement_count: int) -> None: + log_with_context( + logger, + logging.DEBUG, + event, + adapter_name="spanner", + table_name=self.table_name, + statement_count=statement_count, + ) + + +class SpannerSyncEventQueueStore(_SpannerEventStoreMixin, BaseEventQueueStore[SpannerSyncConfig]): + """Spanner event queue store for synchronous configs. + + Spanner does not support IF NOT EXISTS, so statements must be executed + with proper error handling for existing objects. + + Args: + config: SpannerSyncConfig with extension_config["events"] settings. + """ + + __slots__ = () - Executes DDL statements via database.update_ddl() which is the - recommended approach for Spanner schema changes. + def create_table(self) -> None: + """Create the event queue table and index through ``Database.update_ddl``. + Raises: + TypeError: If the store's config is not a SpannerSyncConfig. """ config = self._config if not isinstance(config, SpannerSyncConfig): msg = "create_table requires SpannerSyncConfig" raise TypeError(msg) - - database = config.get_database() statements = self.create_statements() - log_with_context( - logger, - logging.DEBUG, - "events.queue.create", - adapter_name="spanner", - table_name=self.table_name, - statement_count=len(statements), - ) - database.update_ddl(statements).result() # type: ignore[no-untyped-call] + self._log_ddl("events.queue.create", len(statements)) + execute_ddl_sync(config.get_database(), statements) def drop_table(self) -> None: - """Drop the event queue table and index. - - Executes DDL statements via database.update_ddl() which is the - recommended approach for Spanner schema changes. + """Drop the event queue table and index through ``Database.update_ddl``. + Raises: + TypeError: If the store's config is not a SpannerSyncConfig. """ config = self._config if not isinstance(config, SpannerSyncConfig): msg = "drop_table requires SpannerSyncConfig" raise TypeError(msg) + statements = self.drop_statements() + self._log_ddl("events.queue.drop", len(statements)) + execute_ddl_sync(config.get_database(), statements) + + +class SpannerAsyncEventQueueStore(_SpannerEventStoreMixin, BaseEventQueueStore[SpannerAsyncConfig]): + """Spanner event queue store for asynchronous configs. + + Spanner does not support IF NOT EXISTS, so statements must be executed + with proper error handling for existing objects. + + Args: + config: SpannerAsyncConfig with extension_config["events"] settings. + """ + + __slots__ = () + + async def create_table(self) -> None: + """Create the event queue table and index through ``Database.update_ddl``. + + Raises: + TypeError: If the store's config is not a SpannerAsyncConfig. + """ + config = self._config + if not isinstance(config, SpannerAsyncConfig): + msg = "create_table requires SpannerAsyncConfig" + raise TypeError(msg) + statements = self.create_statements() + self._log_ddl("events.queue.create", len(statements)) + await execute_ddl_async(await config.get_database(), statements) - database = config.get_database() + async def drop_table(self) -> None: + """Drop the event queue table and index through ``Database.update_ddl``. + + Raises: + TypeError: If the store's config is not a SpannerAsyncConfig. + """ + config = self._config + if not isinstance(config, SpannerAsyncConfig): + msg = "drop_table requires SpannerAsyncConfig" + raise TypeError(msg) statements = self.drop_statements() - log_with_context( - logger, - logging.DEBUG, - "events.queue.drop", - adapter_name="spanner", - table_name=self.table_name, - statement_count=len(statements), - ) - database.update_ddl(statements).result() # type: ignore[no-untyped-call] + self._log_ddl("events.queue.drop", len(statements)) + await execute_ddl_async(await config.get_database(), statements) diff --git a/sqlspec/adapters/spanner/litestar/__init__.py b/sqlspec/adapters/spanner/litestar/__init__.py index 9f4ce6b3a..d3dab4ee3 100644 --- a/sqlspec/adapters/spanner/litestar/__init__.py +++ b/sqlspec/adapters/spanner/litestar/__init__.py @@ -1,5 +1,5 @@ """Litestar integration for Spanner adapter.""" -from sqlspec.adapters.spanner.litestar.store import SpannerLitestarConfig, SpannerSyncStore +from sqlspec.adapters.spanner.litestar.store import SpannerAsyncStore, SpannerLitestarConfig, SpannerSyncStore -__all__ = ("SpannerLitestarConfig", "SpannerSyncStore") +__all__ = ("SpannerAsyncStore", "SpannerLitestarConfig", "SpannerSyncStore") diff --git a/sqlspec/adapters/spanner/litestar/store.py b/sqlspec/adapters/spanner/litestar/store.py index e81ecb153..2b88b2b0f 100644 --- a/sqlspec/adapters/spanner/litestar/store.py +++ b/sqlspec/adapters/spanner/litestar/store.py @@ -6,6 +6,12 @@ from typing_extensions import NotRequired from sqlspec.adapters.spanner._typing import spanner_param_types as param_types +from sqlspec.adapters.spanner.core import ( + execute_ddl_async, + execute_ddl_sync, + list_existing_table_names_async, + list_existing_table_names_sync, +) from sqlspec.adapters.spanner.type_converter import bytes_to_spanner, spanner_to_bytes from sqlspec.config import LitestarConfig from sqlspec.extensions.litestar.store import BaseSQLSpecStore @@ -15,18 +21,18 @@ from collections.abc import Callable from typing import Protocol + from sqlspec.adapters.spanner._typing import SpannerAsyncTransaction as AsyncTransaction from sqlspec.adapters.spanner._typing import SpannerTransaction as Transaction - from sqlspec.adapters.spanner.config import SpannerSyncConfig + from sqlspec.adapters.spanner.config import SpannerAsyncConfig, SpannerSyncConfig - class _DatabaseProtocol(Protocol): + class _SpannerSyncDatabaseProtocol(Protocol): def run_in_transaction(self, func: "Callable[[Transaction], Any]") -> Any: ... - def update_ddl(self, ddl_statements: "list[str]") -> Any: ... + class _SpannerAsyncDatabaseProtocol(Protocol): + async def run_in_transaction(self, func: "Callable[[AsyncTransaction], Any]") -> Any: ... - def list_tables(self) -> Any: ... - -__all__ = ("SpannerLitestarConfig", "SpannerSyncStore") +__all__ = ("SpannerAsyncStore", "SpannerLitestarConfig", "SpannerSyncStore") class SpannerLitestarConfig(LitestarConfig): @@ -45,53 +51,23 @@ class SpannerLitestarConfig(LitestarConfig): """Index DDL options.""" -class SpannerSyncStore(BaseSQLSpecStore["SpannerSyncConfig"]): - """Spanner-backed Litestar session store using sync driver wrapped as async.""" - - __slots__ = ("_index_options", "_shard_count", "_table_options") - extension_config_options = BaseSQLSpecStore.extension_config_options | frozenset({ - "index_options", - "shard_count", - "table_options", - }) - - def __init__(self, config: "SpannerSyncConfig") -> None: - super().__init__(config) - litestar_cfg = cast("dict[str, Any]", config.extension_config.get("litestar", {})) - self._shard_count: int = int(litestar_cfg.get("shard_count", 0)) if litestar_cfg.get("shard_count") else 0 - self._table_options: str | None = litestar_cfg.get("table_options") - self._index_options: str | None = litestar_cfg.get("index_options") - - async def get(self, key: str, renew_for: "int | timedelta | None" = None) -> "bytes | None": - return await async_(self._get)(key, renew_for) - - async def set(self, key: str, value: "str | bytes", expires_in: "int | timedelta | None" = None) -> None: - await async_(self._set)(key, value, expires_in) - - async def delete(self, key: str) -> None: - await async_(self._delete)(key) - - async def delete_all(self) -> None: - await async_(self._delete_all)() - - async def exists(self, key: str) -> bool: - return await async_(self._exists)(key) +def _extract_spanner_litestar_options(config: Any) -> "tuple[int, str | None, str | None]": + litestar_cfg = cast("dict[str, Any]", config.extension_config.get("litestar", {})) + shard_count = int(litestar_cfg.get("shard_count", 0)) if litestar_cfg.get("shard_count") else 0 + table_options = cast("str | None", litestar_cfg.get("table_options")) + index_options = cast("str | None", litestar_cfg.get("index_options")) + return shard_count, table_options, index_options - async def expires_in(self, key: str) -> "int | None": - return await async_(self._expires_in)(key) - async def delete_expired(self) -> int: - return await async_(self._delete_expired)() +class _SpannerLitestarStoreCommonMixin: + """Shared SQL, DDL, and parameter helpers for Spanner Litestar session stores.""" - async def create_table(self) -> None: - if not self.create_schema_enabled: - await self.reconcile_schema() - return - await async_(self._create_table)() - await self.reconcile_schema(assume_existing=True) + __slots__ = () - def _database(self) -> "_DatabaseProtocol": - return cast("_DatabaseProtocol", self._config.get_database()) + _table_name: str + _shard_count: int + _table_options: "str | None" + _index_options: "str | None" def _datetime_to_timestamp(self, dt: "datetime | None") -> "datetime | None": if dt is None: @@ -128,52 +104,33 @@ def _get_param_types( types["data"] = param_types.BYTES return types - def _get(self, key: str, renew_for: "int | timedelta | None" = None) -> "bytes | None": - sql = f""" - SELECT data, expires_at - FROM {self._table_name} - WHERE session_id = @session_id - AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP()) - """ + def _build_select_session_sql(self) -> str: if self._shard_count > 1: - sql = f""" + return f""" SELECT data, expires_at FROM {self._table_name} WHERE shard_id = MOD(FARM_FINGERPRINT(@session_id), {self._shard_count}) AND session_id = @session_id AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP()) """ + return f""" + SELECT data, expires_at + FROM {self._table_name} + WHERE session_id = @session_id + AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP()) + """ - with self._config.provide_session() as driver: - result = driver.select_one_or_none(sql, {"session_id": key}) - - if result is None: - return None - - data = result.get("data") - expires_at = self._timestamp_to_datetime(result.get("expires_at")) - - if renew_for is not None and expires_at is not None: - new_expires = self._calculate_expires_at(renew_for) - update_sql = f""" - UPDATE {self._table_name} - SET expires_at = @expires_at, updated_at = PENDING_COMMIT_TIMESTAMP() - WHERE session_id = @session_id - """ - if self._shard_count > 1: - update_sql = f"{update_sql} AND shard_id = MOD(FARM_FINGERPRINT(@session_id), {self._shard_count})" - params = self._build_params(key, new_expires) - types = self._get_param_types(expires_at=True) - self._database().run_in_transaction(_SpannerExecuteUpdateJob(update_sql, params, types)) - - return spanner_to_bytes(data) - - def _set(self, key: str, value: "str | bytes", expires_in: "int | timedelta | None" = None) -> None: - data = self._value_to_bytes(value) - expires_at = self._calculate_expires_at(expires_in) - params = self._build_params(key, expires_at, data) - types = self._get_param_types(session_id=True, expires_at=True, data=True) + def _build_renew_session_sql(self) -> str: + update_sql = f""" + UPDATE {self._table_name} + SET expires_at = @expires_at, updated_at = PENDING_COMMIT_TIMESTAMP() + WHERE session_id = @session_id + """ + if self._shard_count > 1: + update_sql = f"{update_sql} AND shard_id = MOD(FARM_FINGERPRINT(@session_id), {self._shard_count})" + return update_sql + def _build_upsert_sql(self) -> "tuple[str, str]": update_sql = f""" UPDATE {self._table_name} SET data = @data, @@ -187,63 +144,39 @@ def _set(self, key: str, value: "str | bytes", expires_in: "int | timedelta | No INSERT {self._table_name} (session_id, data, expires_at, created_at, updated_at) VALUES (@session_id, @data, @expires_at, PENDING_COMMIT_TIMESTAMP(), PENDING_COMMIT_TIMESTAMP()) """ - self._database().run_in_transaction(_SpannerUpsertJob(update_sql, insert_sql, params, types)) + return update_sql, insert_sql - def _delete(self, key: str) -> None: + def _build_delete_sql(self) -> str: sql = f"DELETE FROM {self._table_name} WHERE session_id = @session_id" if self._shard_count > 1: sql = f"{sql} AND shard_id = MOD(FARM_FINGERPRINT(@session_id), {self._shard_count})" - params = {"session_id": key} - types = self._get_param_types(session_id=True) - self._database().run_in_transaction(_SpannerExecuteUpdateJob(sql, params, types)) + return sql - def _delete_all(self) -> None: - sql = f"DELETE FROM {self._table_name} WHERE TRUE" - self._database().run_in_transaction(_SpannerExecuteUpdateJob(sql)) + def _build_delete_all_sql(self) -> str: + return f"DELETE FROM {self._table_name} WHERE TRUE" - def _exists(self, key: str) -> bool: - sql = f""" + def _build_exists_sql(self) -> str: + return f""" SELECT 1 FROM {self._table_name} WHERE session_id = @session_id AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP()) LIMIT 1 """ - with self._config.provide_session() as driver: - row = driver.select_one_or_none(sql, {"session_id": key}) - return row is not None - def _expires_in(self, key: str) -> "int | None": + def _build_expires_in_sql(self) -> str: sql = f""" SELECT expires_at FROM {self._table_name} WHERE session_id = @session_id """ if self._shard_count > 1: sql = f"{sql} AND shard_id = MOD(FARM_FINGERPRINT(@session_id), {self._shard_count})" - with self._config.provide_session() as driver: - row = driver.select_one_or_none(sql, {"session_id": key}) - if row is None: - return None - expires_at = self._timestamp_to_datetime(row.get("expires_at")) - if expires_at is None: - return None - delta = expires_at - datetime.now(timezone.utc) - return max(int(delta.total_seconds()), 0) + return sql - def _delete_expired(self) -> int: - sql = f""" + def _build_delete_expired_sql(self) -> str: + return f""" DELETE FROM {self._table_name} WHERE expires_at IS NOT NULL AND expires_at <= CURRENT_TIMESTAMP() """ - result = self._database().run_in_transaction(_SpannerExecuteUpdateCountJob(sql)) - return cast("int", result) - - def _create_table(self) -> None: - database = self._config.get_database() - existing_tables = {t.table_id for t in database.list_tables()} # type: ignore[no-untyped-call] - - if self._table_name not in existing_tables: - ddl_statements = [self._table_ddl(), self._index_ddl()] - database.update_ddl(ddl_statements).result(300) # type: ignore[no-untyped-call] def _table_ddl(self) -> str: shard_column = "" @@ -277,7 +210,215 @@ def _drop_table_sql(self) -> "list[str]": return [f"DROP INDEX idx_{self._table_name}_expires_at", f"DROP TABLE {self._table_name}"] -class _SpannerExecuteUpdateJob: +class SpannerSyncStore(_SpannerLitestarStoreCommonMixin, BaseSQLSpecStore["SpannerSyncConfig"]): + """Spanner-backed Litestar session store using sync driver wrapped as async.""" + + __slots__ = ("_index_options", "_shard_count", "_table_options") + extension_config_options = BaseSQLSpecStore.extension_config_options | frozenset({ + "index_options", + "shard_count", + "table_options", + }) + + def __init__(self, config: "SpannerSyncConfig") -> None: + super().__init__(config) + self._shard_count, self._table_options, self._index_options = _extract_spanner_litestar_options(config) + + async def get(self, key: str, renew_for: "int | timedelta | None" = None) -> "bytes | None": + return await async_(self._get)(key, renew_for) + + async def set(self, key: str, value: "str | bytes", expires_in: "int | timedelta | None" = None) -> None: + await async_(self._set)(key, value, expires_in) + + async def delete(self, key: str) -> None: + await async_(self._delete)(key) + + async def delete_all(self) -> None: + await async_(self._delete_all)() + + async def exists(self, key: str) -> bool: + return await async_(self._exists)(key) + + async def expires_in(self, key: str) -> "int | None": + return await async_(self._expires_in)(key) + + async def delete_expired(self) -> int: + return await async_(self._delete_expired)() + + async def create_table(self) -> None: + if not self.create_schema_enabled: + await self.reconcile_schema() + return + await async_(self._create_table)() + await self.reconcile_schema(assume_existing=True) + + def _database(self) -> "_SpannerSyncDatabaseProtocol": + return cast("_SpannerSyncDatabaseProtocol", self._config.get_database()) + + def _get(self, key: str, renew_for: "int | timedelta | None" = None) -> "bytes | None": + sql = self._build_select_session_sql() + with self._config.provide_session() as driver: + result = driver.select_one_or_none(sql, {"session_id": key}) + + if result is None: + return None + + data = result.get("data") + expires_at = self._timestamp_to_datetime(result.get("expires_at")) + + if renew_for is not None and expires_at is not None: + new_expires = self._calculate_expires_at(renew_for) + update_sql = self._build_renew_session_sql() + params = self._build_params(key, new_expires) + types = self._get_param_types(expires_at=True) + self._database().run_in_transaction(_SpannerSyncExecuteUpdateJob(update_sql, params, types)) + + return spanner_to_bytes(data) + + def _set(self, key: str, value: "str | bytes", expires_in: "int | timedelta | None" = None) -> None: + data = self._value_to_bytes(value) + expires_at = self._calculate_expires_at(expires_in) + params = self._build_params(key, expires_at, data) + types = self._get_param_types(session_id=True, expires_at=True, data=True) + update_sql, insert_sql = self._build_upsert_sql() + self._database().run_in_transaction(_SpannerSyncUpsertJob(update_sql, insert_sql, params, types)) + + def _delete(self, key: str) -> None: + sql = self._build_delete_sql() + params = {"session_id": key} + types = self._get_param_types(session_id=True) + self._database().run_in_transaction(_SpannerSyncExecuteUpdateJob(sql, params, types)) + + def _delete_all(self) -> None: + sql = self._build_delete_all_sql() + self._database().run_in_transaction(_SpannerSyncExecuteUpdateJob(sql)) + + def _exists(self, key: str) -> bool: + sql = self._build_exists_sql() + with self._config.provide_session() as driver: + row = driver.select_one_or_none(sql, {"session_id": key}) + return row is not None + + def _expires_in(self, key: str) -> "int | None": + sql = self._build_expires_in_sql() + with self._config.provide_session() as driver: + row = driver.select_one_or_none(sql, {"session_id": key}) + if row is None: + return None + expires_at = self._timestamp_to_datetime(row.get("expires_at")) + if expires_at is None: + return None + delta = expires_at - datetime.now(timezone.utc) + return max(int(delta.total_seconds()), 0) + + def _delete_expired(self) -> int: + sql = self._build_delete_expired_sql() + result = self._database().run_in_transaction(_SpannerSyncExecuteUpdateCountJob(sql)) + return cast("int", result) + + def _create_table(self) -> None: + database = self._config.get_database() + if self._table_name not in list_existing_table_names_sync(database): + execute_ddl_sync(database, [self._table_ddl(), self._index_ddl()], timeout=300) + + +class SpannerAsyncStore(_SpannerLitestarStoreCommonMixin, BaseSQLSpecStore["SpannerAsyncConfig"]): + """Spanner-backed Litestar session store using native async Spanner client.""" + + __slots__ = ("_index_options", "_shard_count", "_table_options") + extension_config_options = BaseSQLSpecStore.extension_config_options | frozenset({ + "index_options", + "shard_count", + "table_options", + }) + + def __init__(self, config: "SpannerAsyncConfig") -> None: + super().__init__(config) + self._shard_count, self._table_options, self._index_options = _extract_spanner_litestar_options(config) + + async def _database(self) -> "_SpannerAsyncDatabaseProtocol": + return cast("_SpannerAsyncDatabaseProtocol", await self._config.get_database()) + + async def get(self, key: str, renew_for: "int | timedelta | None" = None) -> "bytes | None": + sql = self._build_select_session_sql() + async with self._config.provide_session() as driver: + result = await driver.select_one_or_none(sql, {"session_id": key}) + + if result is None: + return None + + data = result.get("data") + expires_at = self._timestamp_to_datetime(result.get("expires_at")) + + if renew_for is not None and expires_at is not None: + new_expires = self._calculate_expires_at(renew_for) + update_sql = self._build_renew_session_sql() + params = self._build_params(key, new_expires) + types = self._get_param_types(expires_at=True) + database = await self._database() + await database.run_in_transaction(_SpannerAsyncExecuteUpdateJob(update_sql, params, types)) + + return spanner_to_bytes(data) + + async def set(self, key: str, value: "str | bytes", expires_in: "int | timedelta | None" = None) -> None: + data = self._value_to_bytes(value) + expires_at = self._calculate_expires_at(expires_in) + params = self._build_params(key, expires_at, data) + types = self._get_param_types(session_id=True, expires_at=True, data=True) + update_sql, insert_sql = self._build_upsert_sql() + database = await self._database() + await database.run_in_transaction(_SpannerAsyncUpsertJob(update_sql, insert_sql, params, types)) + + async def delete(self, key: str) -> None: + sql = self._build_delete_sql() + params = {"session_id": key} + types = self._get_param_types(session_id=True) + database = await self._database() + await database.run_in_transaction(_SpannerAsyncExecuteUpdateJob(sql, params, types)) + + async def delete_all(self) -> None: + sql = self._build_delete_all_sql() + database = await self._database() + await database.run_in_transaction(_SpannerAsyncExecuteUpdateJob(sql)) + + async def exists(self, key: str) -> bool: + sql = self._build_exists_sql() + async with self._config.provide_session() as driver: + row = await driver.select_one_or_none(sql, {"session_id": key}) + return row is not None + + async def expires_in(self, key: str) -> "int | None": + sql = self._build_expires_in_sql() + async with self._config.provide_session() as driver: + row = await driver.select_one_or_none(sql, {"session_id": key}) + if row is None: + return None + expires_at = self._timestamp_to_datetime(row.get("expires_at")) + if expires_at is None: + return None + delta = expires_at - datetime.now(timezone.utc) + return max(int(delta.total_seconds()), 0) + + async def delete_expired(self) -> int: + sql = self._build_delete_expired_sql() + database = await self._database() + result = await database.run_in_transaction(_SpannerAsyncExecuteUpdateCountJob(sql)) + return int(result) + + async def create_table(self) -> None: + if not self.create_schema_enabled: + await self.reconcile_schema() + return + await self._create_table() + await self.reconcile_schema(assume_existing=True) + + async def _create_table(self) -> None: + database = await self._config.get_database() + if self._table_name not in await list_existing_table_names_async(database): + await execute_ddl_async(database, [self._table_ddl(), self._index_ddl()], timeout=300) + + +class _SpannerSyncExecuteUpdateJob: __slots__ = ("_params", "_sql", "_types") def __init__(self, sql: str, params: "dict[str, Any] | None" = None, types: "dict[str, Any] | None" = None) -> None: @@ -292,7 +433,22 @@ def __call__(self, transaction: "Transaction") -> None: transaction.execute_update(self._sql, params=self._params or {}, param_types=self._types) # type: ignore[no-untyped-call] -class _SpannerUpsertJob: +class _SpannerAsyncExecuteUpdateJob: + __slots__ = ("_params", "_sql", "_types") + + def __init__(self, sql: str, params: "dict[str, Any] | None" = None, types: "dict[str, Any] | None" = None) -> None: + self._sql = sql + self._params = params + self._types = types + + async def __call__(self, transaction: "AsyncTransaction") -> None: + if self._params is None and self._types is None: + await transaction.execute_update(self._sql) + return + await transaction.execute_update(self._sql, params=self._params or {}, param_types=self._types) + + +class _SpannerSyncUpsertJob: __slots__ = ("_insert_sql", "_params", "_types", "_update_sql") def __init__(self, update_sql: str, insert_sql: str, params: "dict[str, Any]", types: "dict[str, Any]") -> None: @@ -307,7 +463,22 @@ def __call__(self, transaction: "Transaction") -> None: transaction.execute_update(self._insert_sql, params=self._params, param_types=self._types) # type: ignore[no-untyped-call] -class _SpannerExecuteUpdateCountJob: +class _SpannerAsyncUpsertJob: + __slots__ = ("_insert_sql", "_params", "_types", "_update_sql") + + def __init__(self, update_sql: str, insert_sql: str, params: "dict[str, Any]", types: "dict[str, Any]") -> None: + self._update_sql = update_sql + self._insert_sql = insert_sql + self._params = params + self._types = types + + async def __call__(self, transaction: "AsyncTransaction") -> None: + row_ct = await transaction.execute_update(self._update_sql, params=self._params, param_types=self._types) + if row_ct == 0: + await transaction.execute_update(self._insert_sql, params=self._params, param_types=self._types) + + +class _SpannerSyncExecuteUpdateCountJob: __slots__ = ("_sql",) def __init__(self, sql: str) -> None: @@ -315,3 +486,13 @@ def __init__(self, sql: str) -> None: def __call__(self, transaction: "Transaction") -> int: return int(transaction.execute_update(self._sql)) # type: ignore[no-untyped-call] + + +class _SpannerAsyncExecuteUpdateCountJob: + __slots__ = ("_sql",) + + def __init__(self, sql: str) -> None: + self._sql = sql + + async def __call__(self, transaction: "AsyncTransaction") -> int: + return int(await transaction.execute_update(self._sql)) diff --git a/sqlspec/adapters/spanner/type_converter.py b/sqlspec/adapters/spanner/type_converter.py index eda78791d..7f11aa4c9 100644 --- a/sqlspec/adapters/spanner/type_converter.py +++ b/sqlspec/adapters/spanner/type_converter.py @@ -17,11 +17,12 @@ """ import base64 -from datetime import date, datetime, timezone +from datetime import date, datetime, timedelta, timezone from decimal import Decimal from typing import TYPE_CHECKING, Any, cast from uuid import UUID +from sqlspec.adapters.spanner._typing import SpannerInterval as Interval from sqlspec.adapters.spanner._typing import SpannerJsonObject as JsonObject from sqlspec.adapters.spanner._typing import spanner_param_types as param_types from sqlspec.core import TypedParameter @@ -154,6 +155,7 @@ def coerce_params_for_spanner( - datetime timezone awareness - dict → JsonObject for JSON columns - nested sequences → JsonObject for JSON arrays + - TypedParameter ``semantic_name`` FLOAT32 and ARRAY/VECTOR coercion Args: params: Parameter dictionary or None. @@ -169,10 +171,20 @@ def coerce_params_for_spanner( json_object_type = _get_json_object_type() coerced: dict[str, Any] = {} changed = False - for key, value in params.items(): - if type(value) is TypedParameter: - value = value.value + for key, raw_value in params.items(): + value = raw_value + if type(raw_value) is TypedParameter: + value = raw_value.value changed = True + declared = raw_value.semantic_name + if declared is not None: + normalized_declared = declared.strip().upper() + if normalized_declared in {"ARRAY", "VECTOR"} and isinstance(value, (list, tuple)): + coerced[key] = [float(item) for item in value] + continue + if normalized_declared == "FLOAT32" and value is not None: + coerced[key] = float(value) + continue if isinstance(value, _UUID_TYPES): if enable_uuid_conversion: coerced[key] = str(value) @@ -185,13 +197,24 @@ def coerce_params_for_spanner( elif isinstance(value, datetime) and value.tzinfo is None: coerced[key] = value.replace(tzinfo=timezone.utc) changed = True + elif isinstance(value, timedelta): + coerced[key] = Interval(days=value.days, nanos=(value.seconds * 1_000_000 + value.microseconds) * 1000) + changed = True elif isinstance(value, json_object_type): coerced[key] = value elif isinstance(value, dict): coerced[key] = spanner_json(value) changed = True elif isinstance(value, (list, tuple)): - if should_json_encode_sequence(value): + if any(isinstance(item, timedelta) for item in value): + coerced[key] = [ + Interval(days=item.days, nanos=(item.seconds * 1_000_000 + item.microseconds) * 1000) + if isinstance(item, timedelta) + else item + for item in value + ] + changed = True + elif should_json_encode_sequence(value): coerced[key] = spanner_json(list(value)) changed = True elif isinstance(value, tuple): @@ -212,12 +235,47 @@ def coerce_params_for_spanner( bytes: "BYTES", datetime: "TIMESTAMP", date: "DATE", + timedelta: "INTERVAL", Decimal: "NUMERIC", UUID: "STRING", dict: "JSON", } +def _resolve_declared_param_type( + raw_value: TypedParameter, param_types_mod: "SpannerParamTypesProtocol" +) -> "Any | None": + """Resolve a TypedParameter's declared Spanner type. + + ``semantic_name`` names a Spanner type such as ``"FLOAT32"``, + ``"ARRAY"``, ``"VECTOR"``, ``"INTERVAL"``, or ``"JSON"``; without + one, the declared Python ``original_type`` maps to its Spanner type. + + Args: + raw_value: The typed parameter. + param_types_mod: The Spanner param_types module. + + Returns: + The Spanner param type, or None when nothing is declared. + """ + semantic_name = raw_value.semantic_name + if semantic_name is not None: + normalized = semantic_name.strip().upper() + if normalized == "FLOAT32": + return param_types_mod.FLOAT32 + if normalized in {"ARRAY", "VECTOR"}: + return param_types_mod.Array(param_types_mod.FLOAT32) + if normalized == "INTERVAL": + return param_types_mod.INTERVAL + if normalized == "JSON": + return _json_param_type() + return getattr(param_types_mod, normalized, None) + resolver = _NULL_PARAM_TYPE_NAMES.get(raw_value.original_type) + if resolver == "JSON": + return _json_param_type() + return getattr(param_types_mod, resolver, None) if resolver is not None else None + + def _infer_sequence_param_type(value: Any, param_types: Any, json_type: Any) -> Any | None: """Infer Spanner parameter type for sequence values. @@ -244,6 +302,8 @@ def _infer_sequence_param_type(value: Any, param_types: Any, json_type: Any) -> return param_types.Array(param_types.FLOAT64) if isinstance(first, Decimal): return param_types.Array(param_types.NUMERIC) + if isinstance(first, timedelta): + return param_types.Array(param_types.INTERVAL) return None @@ -266,6 +326,11 @@ def infer_spanner_param_types(params: "dict[str, Any] | None") -> "dict[str, Any for key, raw_value in params.items(): is_typed = type(raw_value) is TypedParameter value = raw_value.value if is_typed else raw_value + if is_typed: + declared_param_type = _resolve_declared_param_type(raw_value, param_types) + if declared_param_type is not None: + types[key] = declared_param_type + continue if value is None: null_type = _null_param_type(raw_value, param_types) if null_type is not None: @@ -287,6 +352,8 @@ def infer_spanner_param_types(params: "dict[str, Any] | None") -> "dict[str, Any types[key] = param_types.TIMESTAMP elif isinstance(value, date): types[key] = param_types.DATE + elif isinstance(value, timedelta): + types[key] = param_types.INTERVAL elif isinstance(value, (dict, json_object_type)): types[key] = json_type elif isinstance(value, (list, tuple)): @@ -311,13 +378,9 @@ def _null_param_type(raw_value: Any, param_types: "SpannerParamTypesProtocol") - Returns: The Spanner param type, or None when no type is declared. """ - declared = raw_value.original_type if type(raw_value) is TypedParameter else None - if declared is None: + if type(raw_value) is not TypedParameter: return None - resolver = _NULL_PARAM_TYPE_NAMES.get(declared) - if resolver == "JSON": - return _json_param_type() - return getattr(param_types, resolver) if resolver is not None else None + return _resolve_declared_param_type(raw_value, param_types) def _get_param_types() -> "SpannerParamTypesProtocol": diff --git a/sqlspec/builder/_base.py b/sqlspec/builder/_base.py index a703fd206..3f9416ea3 100644 --- a/sqlspec/builder/_base.py +++ b/sqlspec/builder/_base.py @@ -12,6 +12,7 @@ from sqlglot import Dialect, exp from sqlglot import optimizer as sqlglot_optimizer from sqlglot.dialects.dialect import DialectType +from sqlglot.errors import OptimizeError as SQLGlotOptimizeError from sqlglot.errors import ParseError as SQLGlotParseError from sqlglot.optimizer import RULES from sqlglot.optimizer.eliminate_ctes import eliminate_ctes as _eliminate_ctes_rule @@ -19,6 +20,7 @@ from sqlglot.optimizer.normalize_identifiers import normalize_identifiers as _normalize_identifiers_rule from sqlglot.optimizer.optimize_joins import optimize_joins as _optimize_joins_rule from sqlglot.optimizer.pushdown_predicates import pushdown_predicates as _pushdown_predicates_rule +from sqlglot.optimizer.pushdown_projections import pushdown_projections as _pushdown_projections_rule from sqlglot.optimizer.qualify_columns import quote_identifiers as _quote_identifiers_rule from sqlglot.optimizer.simplify import simplify as _simplify_rule from typing_extensions import Self @@ -859,19 +861,29 @@ def _optimize_expression(self, expression: exp.Expr, *, force: bool = False) -> if expression.args.get("with_") is not None or self._with_ctes: excluded_rules.add(_eliminate_ctes_rule) excluded_rules.add(_merge_subqueries_rule) + excluded_rules.add(_pushdown_projections_rule) + elif self.schema is None and isinstance(expression, exp.Select) and expression.is_star: + excluded_rules.add(_pushdown_projections_rule) rules = RULES if not excluded_rules else tuple(rule for rule in RULES if rule not in excluded_rules) + rule_sets = [rules] + if _pushdown_projections_rule not in excluded_rules: + rule_sets.append(tuple(rule for rule in rules if rule is not _pushdown_projections_rule)) - try: - optimized = sqlglot_optimizer.optimize( - expression, schema=cast("dict[str, object] | None", self.schema), dialect=self.dialect_name, rules=rules - ) - cache.put_optimized(cache_key, optimized.copy()) - except Exception: - logger.debug("Expression optimization failed, using original expression") - return expression - else: + schema = cast("dict[str, object] | None", self.schema) + for rule_set in rule_sets: + try: + optimized = sqlglot_optimizer.optimize( + expression, schema=schema, dialect=self.dialect_name, rules=rule_set + ) + cache.put_optimized(cache_key, optimized.copy()) + except SQLGlotOptimizeError: + continue + except Exception: + break return optimized + logger.debug("Expression optimization failed, using original expression") + return expression def _optimize_insert_with_conflict( self, expression: exp.Expr, conflict: exp.OnConflict, *, force: bool diff --git a/sqlspec/builder/_parsing_utils.py b/sqlspec/builder/_parsing_utils.py index 12d387a36..701641f80 100644 --- a/sqlspec/builder/_parsing_utils.py +++ b/sqlspec/builder/_parsing_utils.py @@ -285,7 +285,7 @@ def parse_condition_expression(condition_input: str | exp.Expr | tuple[str, Any] if param_info: converted_condition = condition_input - for param in reversed(param_info): # Reverse to preserve positions + for param in param_info[::-1]: # Reverse to preserve positions if param.style in { ParameterStyle.NUMERIC, ParameterStyle.POSITIONAL_PYFORMAT, diff --git a/sqlspec/core/_operators.py b/sqlspec/core/_operators.py index 1be52f418..d631dccc7 100644 --- a/sqlspec/core/_operators.py +++ b/sqlspec/core/_operators.py @@ -4,12 +4,16 @@ from sqlglot.generator import Generator -def _jsonb_top_key(generator: Generator, expression: exp.JSONBContainsTopKey) -> str: +def _jsonb_top_key(generator: Generator, expression: exp.Binary) -> str: return generator.binary(expression, "??") def _register_jsonb_operator() -> None: - default = Generator.TRANSFORMS[exp.JSONBContainsTopKey] + """Render ``JSONBContainsTopKey`` as ``??`` on SQLGlot releases that define it.""" + top_key = getattr(exp, "JSONBContainsTopKey", None) + if top_key is None: + return + default = Generator.TRANSFORMS[top_key] pending = [Generator] seen: set[type[Generator]] = set() while pending: @@ -18,8 +22,8 @@ def _register_jsonb_operator() -> None: continue seen.add(cls) pending.extend(cls.__subclasses__()) - if cls.TRANSFORMS.get(exp.JSONBContainsTopKey) is default: - cls.TRANSFORMS[exp.JSONBContainsTopKey] = _jsonb_top_key + if cls.TRANSFORMS.get(top_key) is default: + cls.TRANSFORMS[top_key] = _jsonb_top_key try: from sqlglot.generator import _DISPATCH_CACHE diff --git a/sqlspec/core/filters.py b/sqlspec/core/filters.py index 24e012527..0a3f80c50 100644 --- a/sqlspec/core/filters.py +++ b/sqlspec/core/filters.py @@ -810,7 +810,7 @@ def append_to_statement(self, statement: "SQL") -> "SQL": target.order_by(*items, append=False, copy=False) if predicates: predicate = predicates[-1][0] - for after, aoe in reversed(predicates[:-1]): + for after, aoe in predicates[-2::-1]: predicate = _fold_and(aoe, _fold_or(after, predicate)) if predicate is False: target.where(exp.EQ(this=exp.Literal.number(1), expression=exp.Literal.number(0)), copy=False) diff --git a/sqlspec/core/parameters/_converter.py b/sqlspec/core/parameters/_converter.py index b8bae7ba7..e6d95f1ba 100644 --- a/sqlspec/core/parameters/_converter.py +++ b/sqlspec/core/parameters/_converter.py @@ -582,7 +582,7 @@ def _embed_static_parameters( segments: list[str] = [] last_start = len(sql) - for param in reversed(param_info): + for param in param_info[::-1]: param_value = self._parameter_value(parameters, param, unique_params, explicit_indexes) literal = self._format_literal(param_value, dialect) segments.extend((sql[param.position + len(param.placeholder_text) : last_start], literal)) diff --git a/sqlspec/core/query_modifiers.py b/sqlspec/core/query_modifiers.py index 57f9b0e55..fc086f03c 100644 --- a/sqlspec/core/query_modifiers.py +++ b/sqlspec/core/query_modifiers.py @@ -493,9 +493,8 @@ def apply_column_pruning( # If qualification fails, return unchanged expression return expression - # Apply pushdown_projections to remove unused columns try: - pruned: exp.Expr = pushdown_projections_module.pushdown_projections(qualified, dialect=dialect) + pruned: exp.Expr = pushdown_projections_module.pushdown_projections(qualified) except Exception: # If pushdown fails, return the qualified expression pruned = qualified diff --git a/sqlspec/core/splitter.py b/sqlspec/core/splitter.py index d31575c1a..f38e9d8d9 100644 --- a/sqlspec/core/splitter.py +++ b/sqlspec/core/splitter.py @@ -192,7 +192,7 @@ def get_all_token_patterns(self) -> "list[tuple[TokenType, TokenPattern]]": (TokenType.COMMENT_LINE, r"--[^\n]*"), (TokenType.COMMENT_BLOCK, r"/\*[\s\S]*?\*/"), (TokenType.STRING_LITERAL, r"'(?:[^']|'')*'"), - (TokenType.QUOTED_IDENTIFIER, r'"[^"]*"|\[[^\]]*\]'), + (TokenType.QUOTED_IDENTIFIER, r'"[^"]*"|\[[^\]]*\]|`(?:[^`]|``)*`'), ] patterns.extend(self._get_dialect_specific_patterns()) @@ -551,6 +551,8 @@ class Db2DialectConfig(_EagerDialectConfig): "sqlite": SQLiteDialectConfig, "duckdb": DuckDBDialectConfig, "bigquery": BigQueryDialectConfig, + "spanner": BigQueryDialectConfig, + "spangres": PostgreSQLDialectConfig, "db2": Db2DialectConfig, } diff --git a/sqlspec/core/statement.py b/sqlspec/core/statement.py index 66f67419e..424939a8e 100644 --- a/sqlspec/core/statement.py +++ b/sqlspec/core/statement.py @@ -840,7 +840,7 @@ def _mixed_parameter_inputs(self) -> "tuple[str, dict[str, Any]] | None": name += "_p" names[index] = name used.add(name) - for info, index in reversed(list(zip(selected, indexes, strict=True))): + for info, index in list(zip(selected, indexes, strict=True))[::-1]: if info.style in positional_styles: raw = raw[: info.position] + ":" + names[index] + raw[info.position + len(info.placeholder_text) :] return raw, {**{name: positional[index] for index, name in names.items()}, **named} diff --git a/sqlspec/dialects/spanner/_generators.py b/sqlspec/dialects/spanner/_generators.py index 5d64c4762..97b568482 100644 --- a/sqlspec/dialects/spanner/_generators.py +++ b/sqlspec/dialects/spanner/_generators.py @@ -35,6 +35,13 @@ _SPANNER_PROPERTY_NAMES: Final[frozenset[str]] = frozenset({_INTERLEAVE_NAME, _INTERLEAVE_IN_NAME, _ROW_DELETION_NAME}) _DAYS_PATTERN: Final[re.Pattern[str]] = re.compile(r"^\s*(\d+)\s*days?\s*$", re.IGNORECASE) +_SPANNER_UNSIZED_COLUMN_TYPES: Final[dict[exp.DType, str]] = { + exp.DType.TEXT: "STRING(MAX)", + exp.DType.VARCHAR: "STRING(MAX)", + exp.DType.NVARCHAR: "STRING(MAX)", + exp.DType.BINARY: "BYTES(MAX)", + exp.DType.VARBINARY: "BYTES(MAX)", +} _original_bq_property_sql = BigQueryGenerator.property_sql _original_bq_properties_sql = BigQueryGenerator.properties_sql @@ -238,7 +245,9 @@ def _spangres_properties_sql(self: Any, expression: exp.Properties) -> str: _original_bq_drop_transform = BigQueryGenerator.TRANSFORMS.get(exp.Drop) _original_bq_computed_column_transform = BigQueryGenerator.TRANSFORMS.get(exp.ComputedColumnConstraint) _original_bq_datatype_transform = BigQueryGenerator.TRANSFORMS.get(exp.DataType) +_original_bq_default_transform = BigQueryGenerator.TRANSFORMS.get(exp.DefaultColumnConstraint) _original_bq_hint_transform = BigQueryGenerator.TRANSFORMS.get(exp.Hint) +_original_bq_returning_transform = BigQueryGenerator.TRANSFORMS.get(exp.Returning) _original_bq_select_transform = BigQueryGenerator.TRANSFORMS.get(exp.Select) _original_bq_table_transform = BigQueryGenerator.TRANSFORMS.get(exp.Table) _original_bq_anonymous_transform = BigQueryGenerator.TRANSFORMS.get(exp.Anonymous) @@ -573,11 +582,45 @@ def _bq_datatype_transform(generator: Any, expression: exp.DataType) -> str: return "FLOAT64" if type_value == exp.DataType.Type.USERDEFINED and expression.args.get("kind") == "TOKENLIST": return "TOKENLIST" + unsized = _SPANNER_UNSIZED_COLUMN_TYPES.get(type_value) + if ( + unsized is not None + and not expression.expressions + and isinstance(expression.find_ancestor(exp.ColumnDef, exp.Cast), exp.ColumnDef) + ): + return unsized if _original_bq_datatype_transform is not None: return str(_original_bq_datatype_transform(generator, expression)) return str(BigQueryGenerator.datatype_sql(generator, expression)) +def _bq_default_transform(generator: Any, expression: exp.DefaultColumnConstraint) -> str: + """Render Spanner column defaults as the parenthesized expression Spanner requires.""" + if _get_dialect_name(generator) == "Spanner": + default = expression.this + if isinstance(default, exp.Paren): + return f"DEFAULT {generator.sql(default)}" + return f"DEFAULT ({generator.sql(default)})" + if _original_bq_default_transform is not None: + return str(_original_bq_default_transform(generator, expression)) + return str(generator.defaultcolumnconstraint_sql(expression)) + + +def _bq_returning_transform(generator: Any, expression: exp.Returning) -> str: + """Render ``RETURNING`` as Spanner ``THEN RETURN [WITH ACTION [AS alias]]``.""" + if _get_dialect_name(generator) == "Spanner": + clause = " THEN RETURN" + action = expression.args.get("with_action") + if action is not None: + clause += " WITH ACTION" + if isinstance(action, exp.Identifier): + clause += f" AS {generator.sql(action)}" + return f"{clause} {generator.expressions(expression, flat=True)}" + if _original_bq_returning_transform is not None: + return str(_original_bq_returning_transform(generator, expression)) + return str(generator.returning_sql(expression)) + + def _bq_hint_transform(generator: Any, expression: exp.Hint) -> str: """Transform hints for Spanner or delegate to original BigQuery transform.""" if _get_dialect_name(generator) == "Spanner": @@ -727,7 +770,9 @@ def _transform(generator: Any, expression: exp.Expr) -> str: BigQueryGenerator.TRANSFORMS[exp.Drop] = _bq_drop_transform BigQueryGenerator.TRANSFORMS[exp.ComputedColumnConstraint] = _bq_computed_column_transform BigQueryGenerator.TRANSFORMS[exp.DataType] = _bq_datatype_transform +BigQueryGenerator.TRANSFORMS[exp.DefaultColumnConstraint] = _bq_default_transform BigQueryGenerator.TRANSFORMS[exp.Hint] = _bq_hint_transform +BigQueryGenerator.TRANSFORMS[exp.Returning] = _bq_returning_transform BigQueryGenerator.TRANSFORMS[exp.Select] = _bq_select_transform BigQueryGenerator.TRANSFORMS[exp.Table] = _bq_table_transform BigQueryGenerator.TRANSFORMS[exp.Join] = _spanner_join_sql diff --git a/sqlspec/dialects/spanner/_parsers.py b/sqlspec/dialects/spanner/_parsers.py index 95c3c4cbd..6ba0f6a42 100644 --- a/sqlspec/dialects/spanner/_parsers.py +++ b/sqlspec/dialects/spanner/_parsers.py @@ -11,12 +11,12 @@ generators, so either dialect can re-render them. """ -from typing import TYPE_CHECKING, Any, Final, cast +from typing import Any, Final, cast from sqlglot import exp from sqlglot.parsers.bigquery import BigQueryParser from sqlglot.parsers.postgres import PostgresParser -from sqlglot.tokenizer_core import TokenType +from sqlglot.tokenizer_core import Token, TokenType from sqlspec.dialects.spanner._expressions import ( CosineDistance, @@ -32,9 +32,6 @@ _normalize_interval_expression, ) -if TYPE_CHECKING: - from sqlglot.tokenizer_core import Token - __all__ = ( "SpangresParser", "SpannerParser", @@ -46,6 +43,8 @@ ) _PROPERTY_PARSERS_REGISTERED_ATTR: Final[str] = "_sqlspec_spanner_property_parsers" +_WITH_ACTION_SENTINEL: Final[str] = "__sqlspec_then_return_with_action__" +_DML_STATEMENT_TOKENS: Final[frozenset[TokenType]] = frozenset({TokenType.INSERT, TokenType.UPDATE, TokenType.DELETE}) _SPANNER_DIALECT_NAMES: Final[frozenset[str]] = frozenset({"Spangres", "Spanner"}) @@ -397,9 +396,94 @@ def normalize_spanner_tokens(tokens: "list[Token]", sql: str = "") -> "list[Toke pending_comments = [] result.append(token) index += 1 + return _collapse_then_return(result) + + +def _is_word(token: "Token", word: str) -> bool: + return token.token_type in {TokenType.VAR, TokenType.IDENTIFIER} and token.text.upper() == word + + +def _collapse_then_return(tokens: "list[Token]") -> "list[Token]": + """Fold top-level DML ``THEN RETURN [WITH ACTION [AS alias]]`` into a ``RETURNING`` clause. + + ``WITH ACTION`` becomes a leading sentinel column, followed by a comma, that + ``attach_hints`` moves onto the parsed ``Returning`` node. + + Args: + tokens: Tokens with Spanner hints already normalized. + + Returns: + Tokens where each DML ``THEN RETURN`` is a single ``RETURNING`` token. + """ + result: list[Token] = [] + statement_kind: TokenType | None = None + depth = 0 + index = 0 + total = len(tokens) + while index < total: + token = tokens[index] + token_type = token.token_type + if token_type == TokenType.SEMICOLON: + statement_kind = None + depth = 0 + elif statement_kind is None: + statement_kind = token_type + if token_type == TokenType.L_PAREN: + depth += 1 + elif token_type == TokenType.R_PAREN: + depth -= 1 + if ( + token_type == TokenType.THEN + and depth == 0 + and statement_kind in _DML_STATEMENT_TOKENS + and index + 1 < total + and _is_word(tokens[index + 1], "RETURN") + ): + return_token = tokens[index + 1] + result.append( + Token( + TokenType.RETURNING, + "THEN RETURN", + token.line, + token.col, + token.start, + return_token.end, + [*token.comments, *return_token.comments], + ) + ) + index += 2 + if ( + index + 1 < total + and tokens[index].token_type == TokenType.WITH + and _is_word(tokens[index + 1], "ACTION") + ): + action = tokens[index + 1] + result.append( + Token(TokenType.VAR, _WITH_ACTION_SENTINEL, action.line, action.col, action.start, action.end) + ) + index += 2 + if index + 1 < total and tokens[index].token_type == TokenType.ALIAS: + result.extend(tokens[index : index + 2]) + index += 2 + result.append(Token(TokenType.COMMA, ",", action.line, action.col, action.end, action.end)) + continue + result.append(token) + index += 1 return result +def _attach_then_return_action(returning: exp.Returning) -> None: + expressions = list(returning.expressions) + if not expressions: + return + first = expressions[0] + column = first.this if isinstance(first, exp.Alias) else first + if not isinstance(column, exp.Column) or column.name != _WITH_ACTION_SENTINEL: + return + returning.set("expressions", expressions[1:]) + returning.set("with_action", first.args["alias"] if isinstance(first, exp.Alias) else exp.true()) + + def parse_hint_expression(raw_hint: str) -> exp.Hint: """Parse hint string into canonical exp.Hint.""" raw = raw_hint.strip() @@ -419,8 +503,10 @@ def parse_hint_expression(raw_hint: str) -> exp.Hint: def attach_hints(expression: exp.Expr) -> None: - """Attach parsed hints from node comments to AST nodes.""" + """Attach parsed hints from node comments to AST nodes and resolve ``THEN RETURN WITH ACTION``.""" for node in expression.walk(): + if isinstance(node, exp.Returning): + _attach_then_return_action(node) comments = getattr(node, "comments", None) if not comments: continue diff --git a/sqlspec/extensions/adk/memory/store.py b/sqlspec/extensions/adk/memory/store.py index 4e72c6b8d..620f74e8e 100644 --- a/sqlspec/extensions/adk/memory/store.py +++ b/sqlspec/extensions/adk/memory/store.py @@ -155,7 +155,8 @@ def _drop_sql_for_table(self, table_name: str) -> list[str]: finally: self._memory_table = current_table - def _reset_drop_memory_table_sql(self) -> list[str]: + def _reset_drop_memory_statements(self) -> list[str]: + """Return drops for the configured memory table and every legacy memory table name.""" configured = self._memory_table candidates = (configured, *[name for name in ADK_RESET_MEMORY_TABLES if name != configured]) statements: list[str] = [] @@ -315,6 +316,10 @@ def _drop_memory_table_sql(self) -> "list[str]": """Get the DROP TABLE SQL statements for this database dialect.""" raise NotImplementedError + async def _reset_drop_memory_table_sql(self) -> "list[str]": + """Return the drops that remove configured and legacy ADK memory tables.""" + return self._reset_drop_memory_statements() + class BaseSyncADKMemoryStore(_ADKMemoryStoreCommon[ConfigT], ABC): """Base class for sync SQLSpec-backed ADK memory stores.""" @@ -408,3 +413,7 @@ def _memory_table_ddl(self) -> "str | list[str]": def _drop_memory_table_sql(self) -> "list[str]": """Get the DROP TABLE SQL statements for this database dialect.""" raise NotImplementedError + + def _reset_drop_memory_table_sql(self) -> "list[str]": + """Return the drops that remove configured and legacy ADK memory tables.""" + return self._reset_drop_memory_statements() diff --git a/sqlspec/extensions/adk/migrations/0001_create_adk_tables.py b/sqlspec/extensions/adk/migrations/0001_create_adk_tables.py index 5dfd5c46a..7380fd555 100644 --- a/sqlspec/extensions/adk/migrations/0001_create_adk_tables.py +++ b/sqlspec/extensions/adk/migrations/0001_create_adk_tables.py @@ -90,12 +90,12 @@ async def down(context: "MigrationContext | None" = None) -> "list[str]": memory_store_class = _get_memory_store_class(context) if memory_store_class is not None: memory_store = memory_store_class(config=context.config) - statements.extend(memory_store._reset_drop_memory_table_sql()) # pyright: ignore[reportPrivateUsage] + statements.extend(await _resolve_statements(memory_store._reset_drop_memory_table_sql())) # pyright: ignore[reportPrivateUsage] if _is_sessions_enabled(context): store_class = _get_store_class(context) store_instance = store_class(config=context.config) - statements.extend(store_instance._reset_drop_tables_sql()) # pyright: ignore[reportPrivateUsage] + statements.extend(await _resolve_statements(store_instance._reset_drop_tables_sql())) # pyright: ignore[reportPrivateUsage] return statements @@ -137,6 +137,12 @@ async def _resolve_sql(value: "str | Awaitable[str]") -> str: return value +async def _resolve_statements(value: "list[str] | Awaitable[list[str]]") -> "list[str]": + if inspect.isawaitable(value): + return await value + return value + + async def _prepare_schema( store: "BaseAsyncADKStore | BaseSyncADKStore | BaseAsyncADKMemoryStore | BaseSyncADKMemoryStore", context: "MigrationContext", diff --git a/sqlspec/extensions/adk/store.py b/sqlspec/extensions/adk/store.py index 626b32f88..540172265 100644 --- a/sqlspec/extensions/adk/store.py +++ b/sqlspec/extensions/adk/store.py @@ -130,7 +130,7 @@ def create_schema_enabled(self) -> bool: manage_schema, create_schema = self._schema_management_flags() return manage_schema and create_schema - def _reset_drop_tables_sql(self) -> "list[str]": + def _reset_drop_statements(self) -> "list[str]": """Return all table drops needed before recreating the clean-break schema.""" statements = list(self._drop_tables_sql()) for table_profile in ADK_RESET_TABLE_PROFILES: @@ -678,6 +678,10 @@ def _drop_tables_sql(self) -> "list[str]": """ raise NotImplementedError + async def _reset_drop_tables_sql(self) -> "list[str]": + """Return the drops that remove configured and legacy ADK session tables.""" + return self._reset_drop_statements() + class BaseSyncADKStore(_ADKStoreCommon[ConfigT], ABC): """Base class for sync SQLSpec-backed ADK session stores. @@ -919,6 +923,10 @@ def _drop_tables_sql(self) -> "list[str]": """Get the DROP TABLE SQL statements for this database dialect.""" raise NotImplementedError + def _reset_drop_tables_sql(self) -> "list[str]": + """Return the drops that remove configured and legacy ADK session tables.""" + return self._reset_drop_statements() + def normalize_session_list_options( order_by: "SessionOrderBy" = "update_time", diff --git a/sqlspec/extensions/sanic/extension.py b/sqlspec/extensions/sanic/extension.py index 6959c36cd..1f1a3921e 100644 --- a/sqlspec/extensions/sanic/extension.py +++ b/sqlspec/extensions/sanic/extension.py @@ -189,7 +189,7 @@ async def _on_request(self, request: Any) -> None: await self._acquire_request_connection(request, config_state) acquired_states.append(config_state) except Exception: - for config_state in reversed(acquired_states): + for config_state in acquired_states[::-1]: await self._release_request_connection(request, config_state) self._restore_observability_contexts(request, None) raise @@ -202,7 +202,7 @@ async def _on_response(self, request: Any, response: Any) -> None: response: Sanic response instance. """ try: - for config_state in reversed(self._config_states): + for config_state in self._config_states[::-1]: if config_state.disable_di: continue await self._finalize_request_connection(request, response, config_state) diff --git a/sqlspec/migrations/base.py b/sqlspec/migrations/base.py index 078190c93..7904accfe 100644 --- a/sqlspec/migrations/base.py +++ b/sqlspec/migrations/base.py @@ -728,7 +728,7 @@ def _collect_revert_migrations( return list(reversed(applied)) parsed_revision = parse_version(revision) to_revert = [] - for migration in reversed(applied): + for migration in applied[::-1]: parsed_migration_version = parse_version(migration["version_num"]) if parsed_migration_version > parsed_revision: to_revert.append(migration) diff --git a/sqlspec/migrations/squash.py b/sqlspec/migrations/squash.py index c7ddf674f..14944ade2 100644 --- a/sqlspec/migrations/squash.py +++ b/sqlspec/migrations/squash.py @@ -233,7 +233,7 @@ def extract_sql(self, migrations: list[tuple[str, Path]]) -> tuple[list[str], li migration_down_sql.append([]) # DOWN statements in REVERSE order - for down_sql in reversed(migration_down_sql): + for down_sql in migration_down_sql[::-1]: down_statements.extend(down_sql) return up_statements, down_statements diff --git a/sqlspec/protocols.py b/sqlspec/protocols.py index c9874ae44..1303a7679 100644 --- a/sqlspec/protocols.py +++ b/sqlspec/protocols.py @@ -282,12 +282,14 @@ class SpannerParamTypesProtocol(SupportsJsonTypeProtocol, Protocol): BOOL: Any INT64: Any + FLOAT32: Any FLOAT64: Any NUMERIC: Any STRING: Any BYTES: Any TIMESTAMP: Any DATE: Any + INTERVAL: Any Array: "Callable[[Any], Any]" diff --git a/tests/integration/adapters/_shared/_cases.py b/tests/integration/adapters/_shared/_cases.py index b1419ca52..6b6d28da4 100644 --- a/tests/integration/adapters/_shared/_cases.py +++ b/tests/integration/adapters/_shared/_cases.py @@ -132,6 +132,8 @@ class DriverCaseContext: BIGQUERY_XDIST_MARK = pytest.mark.xdist_group("bigquery") DB2_MARK = pytest.mark.db2 DB2_XDIST_MARK = pytest.mark.xdist_group("db2") +SPANNER_MARK = pytest.mark.spanner +SPANNER_XDIST_MARK = pytest.mark.xdist_group("spanner") def _db2_case(mode: Literal["sync", "async"], marks: tuple[Mark | MarkDecorator, ...]) -> DriverCase: diff --git a/tests/integration/adapters/_shared/_migration_cases.py b/tests/integration/adapters/_shared/_migration_cases.py index ae0ba4997..e0b0afbee 100644 --- a/tests/integration/adapters/_shared/_migration_cases.py +++ b/tests/integration/adapters/_shared/_migration_cases.py @@ -16,6 +16,8 @@ MYSQL_XDIST_MARK, ORACLE_XDIST_MARK, POSTGRES_XDIST_MARK, + SPANNER_MARK, + SPANNER_XDIST_MARK, SQLITE_XDIST_MARK, ) @@ -37,6 +39,7 @@ class MigrationCase: supports_missing_schema_validation: bool = False uses_oracle_ddl: bool = False uses_db2_ddl: bool = False + uses_spanner_ddl: bool = False marks: tuple[Mark | MarkDecorator, ...] = () @@ -120,6 +123,14 @@ class MigrationCaseContext: MigrationCase( "db2-sync", "migration_config_db2_sync", "db2", "sync", uses_db2_ddl=True, marks=(DB2_MARK, DB2_XDIST_MARK) ), + MigrationCase( + "spanner-sync", + "migration_config_spanner_sync", + "spanner", + "sync", + uses_spanner_ddl=True, + marks=(SPANNER_MARK, SPANNER_XDIST_MARK), + ), ) ASYNC_MIGRATION_CASES = ( @@ -194,6 +205,14 @@ class MigrationCaseContext: uses_db2_ddl=True, marks=(DB2_MARK, DB2_XDIST_MARK, pytest.mark.anyio), ), + MigrationCase( + "spanner-async", + "migration_config_spanner_async", + "spanner", + "async", + uses_spanner_ddl=True, + marks=(SPANNER_MARK, SPANNER_XDIST_MARK, pytest.mark.anyio), + ), ) ACTIVE_MIGRATION_CASES = SYNC_MIGRATION_CASES + ASYNC_MIGRATION_CASES diff --git a/tests/integration/adapters/_shared/migration_behaviors.py b/tests/integration/adapters/_shared/migration_behaviors.py index 63c70aea8..d0d5d70ed 100644 --- a/tests/integration/adapters/_shared/migration_behaviors.py +++ b/tests/integration/adapters/_shared/migration_behaviors.py @@ -30,13 +30,21 @@ def _migration_paths(case: MigrationCase, behavior: str, tmp_path: Path) -> "tup return token, script_location, version_table, table -def _create_table_migration(case: MigrationCase, table: str) -> str: +def _table_ddl(case: MigrationCase, table: str) -> "tuple[str, str]": if case.uses_oracle_ddl: - create_sql = f"CREATE TABLE {table} (id NUMBER, name VARCHAR2(255) NOT NULL)" - drop_sql = f"DROP TABLE {table}" - else: - create_sql = f"CREATE TABLE {table} (id INTEGER, name VARCHAR(255) NOT NULL)" - drop_sql = f"DROP TABLE {table}" if case.uses_db2_ddl else f"DROP TABLE IF EXISTS {table}" + return f"CREATE TABLE {table} (id NUMBER, name VARCHAR2(255) NOT NULL)", f"DROP TABLE {table}" + if case.uses_spanner_ddl: + return ( + f"CREATE TABLE {table} (id INT64 NOT NULL, name STRING(255) NOT NULL) PRIMARY KEY (id)", + f"DROP TABLE IF EXISTS {table}", + ) + create_sql = f"CREATE TABLE {table} (id INTEGER, name VARCHAR(255) NOT NULL)" + drop_sql = f"DROP TABLE {table}" if case.uses_db2_ddl else f"DROP TABLE IF EXISTS {table}" + return create_sql, drop_sql + + +def _create_table_migration(case: MigrationCase, table: str) -> str: + create_sql, drop_sql = _table_ddl(case, table) return f'''"""Create {table}.""" @@ -52,12 +60,7 @@ def down(): def _seeded_table_migration(case: MigrationCase, table: str) -> str: - if case.uses_oracle_ddl: - create_sql = f"CREATE TABLE {table} (id NUMBER, name VARCHAR2(255) NOT NULL)" - drop_sql = f"DROP TABLE {table}" - else: - create_sql = f"CREATE TABLE {table} (id INTEGER, name VARCHAR(255) NOT NULL)" - drop_sql = f"DROP TABLE {table}" if case.uses_db2_ddl else f"DROP TABLE IF EXISTS {table}" + create_sql, drop_sql = _table_ddl(case, table) return f'''"""Create {table} with seed rows.""" diff --git a/tests/integration/adapters/_shared/suite_case_metadata_contract.py b/tests/integration/adapters/_shared/suite_case_metadata_contract.py index 58ef7b70e..744b2a21d 100644 --- a/tests/integration/adapters/_shared/suite_case_metadata_contract.py +++ b/tests/integration/adapters/_shared/suite_case_metadata_contract.py @@ -75,6 +75,8 @@ "psycopg-sync", "pymssql-sync", "pymysql-sync", + "spanner-async", + "spanner-sync", "sqlite-sync", } MERGE_CASE_IDS = {"asyncpg-async", "oracledb-async", "oracledb-sync", "psqlpy-async", "psycopg-async", "psycopg-sync"} diff --git a/tests/integration/adapters/spanner/conftest.py b/tests/integration/adapters/spanner/conftest.py new file mode 100644 index 000000000..579885826 --- /dev/null +++ b/tests/integration/adapters/spanner/conftest.py @@ -0,0 +1,71 @@ +"""Spanner-family integration fixtures shared by driver and shared-contract tests.""" + +import logging +import sys +from collections.abc import Generator +from datetime import timedelta +from typing import TextIO, cast + +import pytest +from google.cloud.spanner_v1._async.database_sessions_manager import ( + DatabaseSessionsManager as AsyncDatabaseSessionsManager, +) +from google.cloud.spanner_v1.database_sessions_manager import DatabaseSessionsManager + + +@pytest.fixture(scope="session", autouse=True) +def spanner_emulator_session_polling() -> Generator[None, None, None]: + """Shorten the SDK's uninterruptible maintenance sleep in emulator tests. + + Remove this workaround when google-cloud-python#18317 is released. + Multiplexed sessions and real database cleanup remain enabled. + """ + with pytest.MonkeyPatch.context() as patch: + patch.setattr(DatabaseSessionsManager, "_MAINTENANCE_THREAD_POLLING_INTERVAL", timedelta(milliseconds=100)) + patch.setattr(AsyncDatabaseSessionsManager, "_MAINTENANCE_THREAD_POLLING_INTERVAL", timedelta(milliseconds=100)) + yield + + +class _FilteredWriter: + """Stream wrapper that drops noisy emulator lines.""" + + __slots__ = ("_needle", "_stream") + + def __init__(self, stream: "TextIO", needle: str) -> None: + self._stream = stream + self._needle = needle + + def write(self, data: object) -> int: + text = data.decode() if isinstance(data, bytes) else str(data) + if self._needle in text: + return len(text) + return self._stream.write(text) + + def flush(self) -> None: + self._stream.flush() + + def isatty(self) -> bool: + return self._stream.isatty() + + def fileno(self) -> int: + return self._stream.fileno() + + @property + def encoding(self) -> str | None: + return cast("str | None", getattr(self._stream, "encoding", None)) + + +@pytest.fixture(scope="session", autouse=True) +def spanner_emulator_log_filter() -> Generator[None, None, None]: + """Suppress noisy emulator session creation logs.""" + needle = "Created multiplexed session." + stdout = sys.stdout + stderr = sys.stderr + sys.stdout = _FilteredWriter(stdout, needle) + sys.stderr = _FilteredWriter(stderr, needle) + logging.getLogger("database_sessions_manager").setLevel(logging.WARNING) + try: + yield + finally: + sys.stdout = stdout + sys.stderr = stderr diff --git a/tests/integration/adapters/spanner/spanner/_modes.py b/tests/integration/adapters/spanner/spanner/_modes.py new file mode 100644 index 000000000..44b51c016 --- /dev/null +++ b/tests/integration/adapters/spanner/spanner/_modes.py @@ -0,0 +1,38 @@ +"""Helpers that run one Spanner test body against the sync and async adapters.""" + +from collections.abc import AsyncIterator, Awaitable +from contextlib import asynccontextmanager +from inspect import isawaitable +from typing import Any, Literal, TypeVar + +from sqlspec.adapters.spanner import SpannerAsyncConfig, SpannerAsyncDriver, SpannerSyncConfig, SpannerSyncDriver + +__all__ = ("SpannerModeConfig", "SpannerModeDriver", "invoke", "mode_session") + +T = TypeVar("T") + +SpannerModeConfig = SpannerSyncConfig | SpannerAsyncConfig +SpannerModeDriver = SpannerSyncDriver | SpannerAsyncDriver + +_PROVIDERS = {"session": "provide_session", "read": "provide_read_session", "write": "provide_write_session"} + + +async def invoke(result: "T | Awaitable[T]") -> T: + """Return a driver call result, awaiting it for the async adapter.""" + if isawaitable(result): + return await result + return result + + +@asynccontextmanager +async def mode_session( + config: SpannerModeConfig, kind: Literal["session", "read", "write"] = "session", **kwargs: Any +) -> "AsyncIterator[Any]": + """Open a default, read, or write session on either adapter.""" + provider: Any = getattr(config, _PROVIDERS[kind]) + if isinstance(config, SpannerAsyncConfig): + async with provider(**kwargs) as session: + yield session + else: + with provider(**kwargs) as session: + yield session diff --git a/tests/integration/adapters/spanner/spanner/conftest.py b/tests/integration/adapters/spanner/spanner/conftest.py index cd9920612..3407076c9 100644 --- a/tests/integration/adapters/spanner/spanner/conftest.py +++ b/tests/integration/adapters/spanner/spanner/conftest.py @@ -1,12 +1,9 @@ -import logging -import sys from collections.abc import Generator -from datetime import timedelta -from typing import TYPE_CHECKING, TextIO, cast +from typing import TYPE_CHECKING, cast import pytest -from google.cloud.spanner_v1.database_sessions_manager import DatabaseSessionsManager +from tests.integration.adapters.spanner.spanner._modes import SpannerModeConfig from tests.integration.fixtures.spanner import drop_table_if_exists, run_ddl if TYPE_CHECKING: @@ -16,61 +13,11 @@ pytestmark = pytest.mark.xdist_group("spanner") -@pytest.fixture(scope="session", autouse=True) -def spanner_emulator_session_polling() -> Generator[None, None, None]: - """Shorten the SDK's uninterruptible maintenance sleep in emulator tests. - - Remove this workaround when google-cloud-python#18317 is released. - Multiplexed sessions and real database cleanup remain enabled. - """ - with pytest.MonkeyPatch.context() as patch: - patch.setattr(DatabaseSessionsManager, "_MAINTENANCE_THREAD_POLLING_INTERVAL", timedelta(milliseconds=100)) - yield - - -class _FilteredWriter: - """Stream wrapper that drops noisy emulator lines.""" - - __slots__ = ("_needle", "_stream") - - def __init__(self, stream: "TextIO", needle: str) -> None: - self._stream = stream - self._needle = needle - - def write(self, data: object) -> int: - text = data.decode() if isinstance(data, bytes) else str(data) - if self._needle in text: - return len(text) - return self._stream.write(text) - - def flush(self) -> None: - self._stream.flush() - - def isatty(self) -> bool: - return self._stream.isatty() - - def fileno(self) -> int: - return self._stream.fileno() - - @property - def encoding(self) -> str | None: - return cast("str | None", getattr(self._stream, "encoding", None)) - - -@pytest.fixture(scope="session", autouse=True) -def spanner_emulator_log_filter() -> Generator[None, None, None]: - """Suppress noisy emulator session creation logs.""" - needle = "Created multiplexed session." - stdout = sys.stdout - stderr = sys.stderr - sys.stdout = _FilteredWriter(stdout, needle) - sys.stderr = _FilteredWriter(stderr, needle) - logging.getLogger("database_sessions_manager").setLevel(logging.WARNING) - try: - yield - finally: - sys.stdout = stdout - sys.stderr = stderr +@pytest.fixture(params=("sync", "async")) +def spanner_mode_config(request: pytest.FixtureRequest) -> SpannerModeConfig: + """Provide the sync or async Spanner configuration for mode-parametrized tests.""" + fixture_name = "spanner_config" if request.param == "sync" else "spanner_async_config" + return cast("SpannerModeConfig", request.getfixturevalue(fixture_name)) @pytest.fixture diff --git a/tests/integration/adapters/spanner/spanner/extensions/adk/test_adk_store.py b/tests/integration/adapters/spanner/spanner/extensions/adk/test_adk_store.py index b46d9b66f..c3f7a9b57 100644 --- a/tests/integration/adapters/spanner/spanner/extensions/adk/test_adk_store.py +++ b/tests/integration/adapters/spanner/spanner/extensions/adk/test_adk_store.py @@ -1,103 +1,90 @@ -"""Spanner ADK store residuals. +"""Spanner ADK store integration tests for the sync and async adapters. -The shared ADK contract matrix does not activate Spanner yet because the -contract package has no Spanner admin-API database/table fixture. These tests -keep the sync Spanner ADK session/event behavior local until that fixture exists. +Spanner is not part of the shared ADK contract matrix because its tables need +admin-API DDL. These tests run the shared contract behaviors against both +Spanner stores through adapter-local factories instead. """ -import json +from collections.abc import Awaitable, Callable from datetime import datetime, timezone -from typing import Any +from inspect import isawaitable +from typing import Any, TypeVar import pytest -from sqlspec.extensions.adk import StoredEvent - -pytestmark = [pytest.mark.spanner, pytest.mark.integration] - - -def test_create_and_get_session(spanner_adk_store: Any) -> None: - session_id = "session-create" - spanner_adk_store.delete_session("app", "user", session_id) - created = spanner_adk_store.create_session(session_id, "app", "user", {"a": 1}) - assert created["id"] == session_id - - fetched = spanner_adk_store.get_session("app", "user", session_id) - assert fetched is not None - assert fetched["state"] == {"a": 1} - - -def test_update_session_state(spanner_adk_store: Any) -> None: - session_id = "session-update" - spanner_adk_store.delete_session("app", "user", session_id) - spanner_adk_store.create_session(session_id, "app", "user", {"a": 1}) - - spanner_adk_store.update_session_state("app", "user", session_id, {"a": 2, "b": True}) - - fetched = spanner_adk_store.get_session("app", "user", session_id) - assert fetched is not None - assert fetched["state"] == {"a": 2, "b": True} - - -def test_list_sessions(spanner_adk_store: Any) -> None: - spanner_adk_store.delete_session("app-list", "user1", "session-list-1") - spanner_adk_store.delete_session("app-list", "user1", "session-list-2") - spanner_adk_store.delete_session("app-list", "user2", "session-list-3") - spanner_adk_store.create_session("session-list-1", "app-list", "user1", {"v": 1}) - spanner_adk_store.create_session("session-list-2", "app-list", "user1", {"v": 2}) - spanner_adk_store.create_session("session-list-3", "app-list", "user2", {"v": 3}) - - sessions = spanner_adk_store.list_sessions("app-list", "user1") - session_ids = {s["id"] for s in sessions} - assert session_ids == {"session-list-1", "session-list-2"} - - -def test_delete_session(spanner_adk_store: Any) -> None: - session_id = "session-delete" - spanner_adk_store.delete_session("app", "user", session_id) - spanner_adk_store.create_session(session_id, "app", "user", {"k": "v"}) - spanner_adk_store.delete_session("app", "user", session_id) - - assert spanner_adk_store.get_session("app", "user", session_id) is None - - -def test_create_and_list_events(spanner_adk_store: Any) -> None: - session_id = "session-events" - spanner_adk_store.delete_session("app", "user", session_id) - spanner_adk_store.create_session(session_id, "app", "user", {"x": 1}) - - event_one: StoredEvent = { - "id": "event-1", +from sqlspec.extensions.adk.memory import StoredMemory +from tests.integration.adapters._shared.adk_behaviors import ( + assert_adk_append_and_get_events_contract, + assert_adk_append_event_and_update_state_contract, + assert_adk_create_tables_idempotent_contract, + assert_adk_delete_session_cascade_contract, + assert_adk_get_events_filtering_contract, + assert_adk_get_nonexistent_session_contract, + assert_adk_list_sessions_contract, + assert_adk_session_round_trip_contract, + assert_adk_update_session_state_contract, +) + +pytestmark = [pytest.mark.spanner, pytest.mark.integration, pytest.mark.anyio] + +T = TypeVar("T") + +ADK_CONTRACTS = ( + assert_adk_create_tables_idempotent_contract, + assert_adk_session_round_trip_contract, + assert_adk_get_nonexistent_session_contract, + assert_adk_update_session_state_contract, + assert_adk_list_sessions_contract, + assert_adk_delete_session_cascade_contract, + assert_adk_append_and_get_events_contract, + assert_adk_append_event_and_update_state_contract, + assert_adk_get_events_filtering_contract, +) + + +async def _resolve(result: "T | Awaitable[T]") -> T: + if isawaitable(result): + return await result + return result + + +@pytest.mark.parametrize("contract", ADK_CONTRACTS, ids=lambda contract: contract.__name__.removeprefix("assert_adk_")) +async def test_spanner_adk_store_contract( + spanner_adk_store_factory: "Callable[[], tuple[Any, Any]]", contract: "Callable[[Any], Awaitable[None]]" +) -> None: + """Spanner ADK session stores satisfy the shared session and event contracts.""" + await contract(spanner_adk_store_factory) + + +async def test_spanner_adk_memory_store_round_trip( + spanner_adk_memory_store_factory: "Callable[[], tuple[Any, Any]]", +) -> None: + """Spanner ADK memory stores insert, search, and delete entries by session.""" + config, store = spanner_adk_memory_store_factory() + now = datetime.now(timezone.utc) + entry: StoredMemory = { + "id": "memory-1", + "session_id": "session-memory", "app_name": "app", "user_id": "user", - "session_id": session_id, - "invocation_id": "event-1", - "timestamp": datetime.now(timezone.utc), - "event_data": {"id": "event-1", "author": "user", "content": {"msg": "hi"}}, + "scope": "user", + "event_id": "event-1", + "author": "user", + "timestamp": now, + "content_json": {"parts": [{"text": "spanner remembers coffee orders"}]}, + "content_text": "spanner remembers coffee orders", + "metadata_json": None, + "inserted_at": now, + "embedding": None, } - event_two: StoredEvent = { - "id": "event-2", - "app_name": "app", - "user_id": "user", - "session_id": session_id, - "invocation_id": "event-2", - "timestamp": datetime.now(timezone.utc), - "event_data": {"id": "event-2", "author": "assistant", "content": {"msg": "ok"}}, - } - - spanner_adk_store.append_event(event_one) - spanner_adk_store.append_event(event_two) + try: + await _resolve(store.create_tables()) + assert await _resolve(store.insert_memory_entries([entry])) == 1 - events = spanner_adk_store.get_events("app", "user", session_id) - assert len(events) == 2 + matches = await _resolve(store.search_entries("coffee", "app", "user")) + assert [match["id"] for match in matches] == ["memory-1"] - event0_data = ( - json.loads(events[0]["event_data"]) if isinstance(events[0]["event_data"], str) else events[0]["event_data"] - ) - event1_data = ( - json.loads(events[1]["event_data"]) if isinstance(events[1]["event_data"], str) else events[1]["event_data"] - ) - assert event0_data["author"] == "user" - assert event1_data["author"] == "assistant" - assert event0_data["content"] == {"msg": "hi"} - assert event1_data["content"] == {"msg": "ok"} + assert await _resolve(store.delete_entries_by_session("session-memory")) == 1 + assert await _resolve(store.search_entries("coffee", "app", "user")) == [] + finally: + await _resolve(config.close_pool()) diff --git a/tests/integration/adapters/spanner/spanner/extensions/events/test_queue_backend.py b/tests/integration/adapters/spanner/spanner/extensions/events/test_queue_backend.py index 70a811afd..e4ee885ce 100644 --- a/tests/integration/adapters/spanner/spanner/extensions/events/test_queue_backend.py +++ b/tests/integration/adapters/spanner/spanner/extensions/events/test_queue_backend.py @@ -1,35 +1,72 @@ -"""Integration tests for Spanner EventChannel queue backend.""" +"""Integration tests for the Spanner EventChannel queue backend.""" + +from typing import Any import pytest from sqlspec import SQLSpec -from sqlspec.adapters.spanner import SpannerSyncConfig -from sqlspec.adapters.spanner.events import SpannerSyncEventQueueStore +from sqlspec.adapters.spanner import SpannerAsyncConfig, SpannerSyncConfig +from sqlspec.adapters.spanner.events import SpannerAsyncEventQueueStore, SpannerSyncEventQueueStore +from tests.integration.adapters.spanner.spanner._modes import invoke, mode_session -pytestmark = [pytest.mark.spanner, pytest.mark.integration] +pytestmark = [pytest.mark.spanner, pytest.mark.integration, pytest.mark.anyio] -def test_spanner_event_channel_queue_fallback( - spanner_events_config: SpannerSyncConfig, spanner_event_store: SpannerSyncEventQueueStore -) -> None: - """Queue-backed events work on Spanner via the table queue backend.""" +async def _publish_and_consume( + config: "SpannerSyncConfig | SpannerAsyncConfig", + channel_name: str, + payload: "dict[str, Any]", + **publish_kwargs: Any, +) -> "tuple[Any, Any]": spec = SQLSpec() - spec.add_config(spanner_events_config) - channel = spec.event_channel(spanner_events_config) + spec.add_config(config) + channel = spec.event_channel(config) + event_id = await invoke(channel.publish(channel_name, payload, **publish_kwargs)) + iterator: Any = channel.iter_events(channel_name, poll_interval=0.05) + if isinstance(config, SpannerAsyncConfig): + message = await iterator.__anext__() + await iterator.aclose() + else: + message = next(iterator) + iterator.close() + await invoke(channel.ack(message.event_id)) + return event_id, message + + +async def test_spanner_event_channel_queue_lifecycle( + spanner_events_mode_config: "SpannerSyncConfig | SpannerAsyncConfig", + spanner_event_store: "SpannerSyncEventQueueStore | SpannerAsyncEventQueueStore", +) -> None: + """Queue-backed events publish, consume, and ack on both Spanner adapters.""" + event_id, message = await _publish_and_consume( + spanner_events_mode_config, "notifications", {"action": "spanner_event"} + ) + async with mode_session(spanner_events_mode_config) as driver: + row = await invoke( + driver.select_one( + f"SELECT status FROM {spanner_event_store.table_name} WHERE event_id = @event_id", + {"event_id": event_id}, + ) + ) - event_id = channel.publish("notifications", {"action": "spanner_event"}) + assert message.event_id == event_id + assert message.payload == {"action": "spanner_event"} + assert row["status"] == "acked" - iterator = channel.iter_events("notifications", poll_interval=0.05) - message = next(iterator) - channel.ack(message.event_id) - with spanner_events_config.provide_session() as driver: - row = driver.select_one( - "SELECT status FROM sqlspec_event_queue WHERE event_id = @event_id", {"event_id": event_id} - ) +async def test_spanner_event_metadata_roundtrip( + spanner_events_mode_config: "SpannerSyncConfig | SpannerAsyncConfig", + spanner_event_store: "SpannerSyncEventQueueStore | SpannerAsyncEventQueueStore", +) -> None: + """Event metadata survives the queue round-trip on both Spanner adapters.""" + metadata = {"source": "test", "priority": 1} + event_id, message = await _publish_and_consume( + spanner_events_mode_config, "metadata_test", {"data": "value"}, metadata=metadata + ) - assert message.payload["action"] == "spanner_event" - assert row["status"] == "acked" + assert message.event_id == event_id + assert message.metadata == metadata + assert message.payload == {"data": "value"} def test_spanner_event_store_create_statements(spanner_events_config: SpannerSyncConfig) -> None: @@ -75,23 +112,3 @@ def test_spanner_event_store_column_types(spanner_events_config: SpannerSyncConf assert "JSON" in table_sql assert "VARCHAR" not in table_sql assert "INTEGER" not in table_sql - - -def test_spanner_event_metadata_roundtrip( - spanner_events_config: SpannerSyncConfig, spanner_event_store: SpannerSyncEventQueueStore -) -> None: - """Events with metadata are correctly stored and retrieved.""" - spec = SQLSpec() - spec.add_config(spanner_events_config) - channel = spec.event_channel(spanner_events_config) - - metadata = {"source": "test", "priority": 1} - event_id = channel.publish("metadata_test", {"data": "value"}, metadata=metadata) - - iterator = channel.iter_events("metadata_test", poll_interval=0.05) - message = next(iterator) - channel.ack(message.event_id) - - assert message.event_id == event_id - assert message.metadata == metadata - assert message.payload == {"data": "value"} diff --git a/tests/integration/adapters/spanner/spanner/extensions/litestar/test_store.py b/tests/integration/adapters/spanner/spanner/extensions/litestar/test_store.py index 11e09665e..c4ce96695 100644 --- a/tests/integration/adapters/spanner/spanner/extensions/litestar/test_store.py +++ b/tests/integration/adapters/spanner/spanner/extensions/litestar/test_store.py @@ -1,44 +1,68 @@ -"""Integration tests for Spanner session store.""" +"""Spanner Litestar session store integration tests for the sync and async adapters.""" import asyncio +from collections.abc import Awaitable, Callable +from typing import Any import pytest -from sqlspec.adapters.spanner.litestar import SpannerSyncStore +from sqlspec.adapters.spanner.litestar import SpannerAsyncStore, SpannerSyncStore +from tests.integration.adapters._shared.store_behaviors import ( + assert_store_cleanup_contract, + assert_store_delete_all_contract, + assert_store_delete_contract, + assert_store_delete_nonexistent_contract, + assert_store_exists_contract, + assert_store_expiration_int_contract, + assert_store_expiration_timedelta_contract, + assert_store_expires_in_contract, + assert_store_get_nonexistent_contract, + assert_store_large_data_contract, + assert_store_no_expiration_contract, + assert_store_renew_for_contract, + assert_store_set_and_get_contract, + assert_store_set_string_value_contract, + assert_store_upsert_contract, + assert_store_upsert_expiration_change_contract, +) -pytestmark = [pytest.mark.spanner, pytest.mark.integration] +pytestmark = [pytest.mark.spanner, pytest.mark.integration, pytest.mark.anyio] +STORE_CONTRACTS = ( + assert_store_set_and_get_contract, + assert_store_get_nonexistent_contract, + assert_store_set_string_value_contract, + assert_store_delete_contract, + assert_store_delete_nonexistent_contract, + assert_store_expiration_int_contract, + assert_store_expiration_timedelta_contract, + assert_store_no_expiration_contract, + assert_store_expires_in_contract, + assert_store_cleanup_contract, + assert_store_upsert_contract, + assert_store_upsert_expiration_change_contract, + assert_store_renew_for_contract, + assert_store_large_data_contract, + assert_store_delete_all_contract, + assert_store_exists_contract, +) -async def test_store_set_and_get(spanner_store: SpannerSyncStore) -> None: - data = b"payload" - await spanner_store.set("s1", data) - assert await spanner_store.get("s1") == data +@pytest.mark.parametrize( + "contract", STORE_CONTRACTS, ids=lambda contract: contract.__name__.removeprefix("assert_store_") +) +async def test_spanner_store_contract( + spanner_store: "SpannerSyncStore | SpannerAsyncStore", contract: "Callable[[Any], Awaitable[None]]" +) -> None: + """Spanner session stores satisfy the shared Litestar store contracts.""" + await contract(spanner_store) -async def test_store_expiration(spanner_store: SpannerSyncStore) -> None: - await spanner_store.set("expiring", b"data", expires_in=1) - assert await spanner_store.exists("expiring") - await asyncio.sleep(1.1) - assert await spanner_store.get("expiring") is None - -async def test_store_delete(spanner_store: SpannerSyncStore) -> None: - await spanner_store.set("todelete", b"d") - await spanner_store.delete("todelete") - assert await spanner_store.get("todelete") is None - - -async def test_store_renew(spanner_store: SpannerSyncStore) -> None: - await spanner_store.set("renew", b"r", expires_in=2) - await asyncio.sleep(1) - await spanner_store.get("renew", renew_for=4) +async def test_delete_expired_returns_count(spanner_store: "SpannerSyncStore | SpannerAsyncStore") -> None: + """delete_expired() reports how many expired sessions it removed.""" + await spanner_store.set("exp1", b"x", expires_in=1) + await spanner_store.set("exp2", b"x", expires_in=1) + await spanner_store.set("kept", b"x", expires_in=60) await asyncio.sleep(1.5) - assert await spanner_store.get("renew") == b"r" - - -async def test_delete_expired_returns_count(spanner_store: SpannerSyncStore) -> None: - await spanner_store.set("exp1", b"x", expires_in=2) - await spanner_store.set("exp2", b"x", expires_in=2) - await asyncio.sleep(2.5) - deleted = await spanner_store.delete_expired() - assert deleted >= 2 + assert await spanner_store.delete_expired() == 2 + assert await spanner_store.get("kept") == b"x" diff --git a/tests/integration/adapters/spanner/spanner/test_async_driver.py b/tests/integration/adapters/spanner/spanner/test_async_driver.py new file mode 100644 index 000000000..870938eff --- /dev/null +++ b/tests/integration/adapters/spanner/spanner/test_async_driver.py @@ -0,0 +1,135 @@ +"""Async Spanner driver integration tests against the Spanner emulator.""" + +from uuid import uuid4 + +import pyarrow as pa +import pytest + +from sqlspec import SQLResult +from sqlspec.adapters.spanner import SpannerAsyncConfig + +pytestmark = [pytest.mark.spanner, pytest.mark.anyio] + + +async def test_async_driver_select_one_and_dml( + spanner_async_config: "SpannerAsyncConfig", test_users_table: str +) -> None: + """Test async INSERT, SELECT, UPDATE, and DELETE.""" + user_id = str(uuid4()) + + async with spanner_async_config.provide_write_session() as session: + insert_result = await session.execute( + f"INSERT INTO {test_users_table} (id, name, email, age) VALUES (@id, @name, @email, @age)", + id=user_id, + name="Async User", + email="async@example.com", + age=28, + ) + assert isinstance(insert_result, SQLResult) + assert insert_result.rows_affected == 1 + + async with spanner_async_config.provide_session() as session: + row = await session.select_one( + f"SELECT id, name, email, age FROM {test_users_table} WHERE id = @id", id=user_id + ) + assert row is not None + assert str(row["id"]) == user_id + assert row["name"] == "Async User" + assert row["email"] == "async@example.com" + assert row["age"] == 28 + + async with spanner_async_config.provide_write_session() as session: + update_result = await session.execute( + f"UPDATE {test_users_table} SET age = @age WHERE id = @id", id=user_id, age=29 + ) + assert update_result.rows_affected == 1 + + async with spanner_async_config.provide_write_session() as session: + delete_result = await session.execute(f"DELETE FROM {test_users_table} WHERE id = @id", id=user_id) + assert delete_result.rows_affected == 1 + + +async def test_async_driver_execute_many(spanner_async_config: "SpannerAsyncConfig", test_users_table: str) -> None: + """Test async execute_many() using native batch_update.""" + user_ids = [str(uuid4()) for _ in range(3)] + params = [ + {"id": uid, "name": f"Batch {idx}", "email": f"batch{idx}@example.com", "age": 20 + idx} + for idx, uid in enumerate(user_ids) + ] + + async with spanner_async_config.provide_write_session() as session: + result = await session.execute_many( + f"INSERT INTO {test_users_table} (id, name, email, age) VALUES (@id, @name, @email, @age)", params + ) + assert result.rows_affected == 3 + + async with spanner_async_config.provide_session() as session: + rows = await session.select(f"SELECT id, name FROM {test_users_table} WHERE id IN UNNEST(@ids)", ids=user_ids) + assert len(rows) == 3 + + async with spanner_async_config.provide_write_session() as session: + for uid in user_ids: + await session.execute(f"DELETE FROM {test_users_table} WHERE id = @id", id=uid) + + +async def test_async_driver_execute_script(spanner_async_config: "SpannerAsyncConfig", test_users_table: str) -> None: + """Test async execute_script() across multiple DML statements.""" + uid1 = str(uuid4()) + uid2 = str(uuid4()) + + script = f""" + INSERT INTO {test_users_table} (id, name, email, age) VALUES ('{uid1}', 'Script 1', 's1@example.com', 31); + INSERT INTO {test_users_table} (id, name, email, age) VALUES ('{uid2}', 'Script 2', 's2@example.com', 32); + """ + + async with spanner_async_config.provide_write_session() as session: + result = await session.execute_script(script) + assert result.total_statements == 2 + assert result.successful_statements == 2 + + async with spanner_async_config.provide_write_session() as session: + await session.execute(f"DELETE FROM {test_users_table} WHERE id IN UNNEST(@ids)", ids=[uid1, uid2]) + + +async def test_async_driver_select_stream(spanner_async_config: "SpannerAsyncConfig", test_users_table: str) -> None: + """Test async select_stream() yielding rows.""" + user_ids = [str(uuid4()) for _ in range(2)] + + async with spanner_async_config.provide_write_session() as session: + for idx, uid in enumerate(user_ids): + await session.execute( + f"INSERT INTO {test_users_table} (id, name, email, age) VALUES (@id, @name, @email, @age)", + id=uid, + name=f"Stream {idx}", + email=f"stream{idx}@example.com", + age=40 + idx, + ) + + async with spanner_async_config.provide_session() as session: + async with session.select_stream( + f"SELECT id, name FROM {test_users_table} WHERE id IN UNNEST(@ids) ORDER BY name", {"ids": user_ids} + ) as stream: + collected = [row async for row in stream] + + assert len(collected) == 2 + + async with spanner_async_config.provide_write_session() as session: + await session.execute(f"DELETE FROM {test_users_table} WHERE id IN UNNEST(@ids)", ids=user_ids) + + +async def test_async_driver_arrow_roundtrip(spanner_async_config: "SpannerAsyncConfig", test_arrow_table: str) -> None: + """Test async load_from_arrow() and select_to_arrow().""" + table = pa.table({"id": [101, 102], "name": ["Arrow 1", "Arrow 2"], "value": [10, 20]}) + + async with spanner_async_config.provide_write_session() as session: + job = await session.load_from_arrow(test_arrow_table, table) + assert job.telemetry["rows_processed"] == 2 + + async with spanner_async_config.provide_session() as session: + arrow_result = await session.select_to_arrow( + f"SELECT id, name, value FROM {test_arrow_table} WHERE id IN (101, 102) ORDER BY id" + ) + assert arrow_result.rows_affected == 2 + + async with spanner_async_config.provide_write_session() as session: + await session.execute(f"DELETE FROM {test_arrow_table} WHERE id IN (101, 102)") diff --git a/tests/integration/adapters/spanner/spanner/test_driver.py b/tests/integration/adapters/spanner/spanner/test_driver.py index f2abf8355..855792497 100644 --- a/tests/integration/adapters/spanner/spanner/test_driver.py +++ b/tests/integration/adapters/spanner/spanner/test_driver.py @@ -12,20 +12,15 @@ from sqlspec import SQLResult from sqlspec.adapters.spanner import SpannerSyncConfig, SpannerSyncDriver +from tests.integration.adapters.spanner.spanner._modes import SpannerModeConfig, invoke, mode_session -pytestmark = pytest.mark.spanner +pytestmark = [pytest.mark.spanner, pytest.mark.anyio] -def test_connection_pooling(spanner_session: "SpannerSyncDriver") -> None: - """Test that we can acquire a session and execute a simple query.""" - result = spanner_session.select_value("SELECT 1") - assert result == 1 - - -def test_session_management(spanner_config: "SpannerSyncConfig") -> None: - """Test session lifecycle.""" - with spanner_config.provide_session() as session: - assert session.select_value("SELECT 1") == 1 +async def test_session_management(spanner_mode_config: "SpannerModeConfig") -> None: + """Test session lifecycle on both adapters.""" + async with mode_session(spanner_mode_config) as session: + assert await invoke(session.select_value("SELECT 1")) == 1 def test_driver_select_value(spanner_session: "SpannerSyncDriver") -> None: @@ -34,10 +29,10 @@ def test_driver_select_value(spanner_session: "SpannerSyncDriver") -> None: assert result == 42 -def test_driver_select_value_with_params(spanner_session: "SpannerSyncDriver") -> None: - """Test select_value() with parameters.""" - result = spanner_session.select_value("SELECT @val", val=100) - assert result == 100 +async def test_driver_select_value_with_params(spanner_mode_config: "SpannerModeConfig") -> None: + """Test select_value() with parameters on both adapters.""" + async with mode_session(spanner_mode_config) as session: + assert await invoke(session.select_value("SELECT @val", val=100)) == 100 def test_driver_select_value_string(spanner_session: "SpannerSyncDriver") -> None: diff --git a/tests/integration/adapters/spanner/spanner/test_returning.py b/tests/integration/adapters/spanner/spanner/test_returning.py new file mode 100644 index 000000000..f924bb36f --- /dev/null +++ b/tests/integration/adapters/spanner/spanner/test_returning.py @@ -0,0 +1,51 @@ +"""Spanner ``THEN RETURN`` DML tests for the sync and async adapters.""" + +from uuid import uuid4 + +import pytest + +from tests.integration.adapters.spanner.spanner._modes import SpannerModeConfig, invoke, mode_session + +pytestmark = [pytest.mark.spanner, pytest.mark.anyio] + + +async def test_then_return_returns_rows_for_each_dml_kind( + spanner_mode_config: SpannerModeConfig, test_users_table: str +) -> None: + """INSERT, UPDATE, and DELETE with ``THEN RETURN`` return the affected rows.""" + user_id = str(uuid4()) + async with mode_session(spanner_mode_config, "write") as session: + inserted = await invoke( + session.execute( + f"INSERT INTO {test_users_table} (id, name, age) VALUES (@id, @name, @age) THEN RETURN id, name", + id=user_id, + name="returning", + age=30, + ) + ) + updated = await invoke( + session.execute( + f"UPDATE {test_users_table} SET age = age + 1 WHERE id = @id THEN RETURN WITH ACTION AS act age", + id=user_id, + ) + ) + deleted = await invoke( + session.execute(f"DELETE FROM {test_users_table} WHERE id = @id THEN RETURN name", id=user_id) + ) + + assert inserted.get_data() == [{"id": user_id, "name": "returning"}] + assert updated.get_data() == [{"act": "UPDATE", "age": 31}] + assert deleted.get_data() == [{"name": "returning"}] + + +async def test_then_return_rows_survive_repeated_execution( + spanner_mode_config: SpannerModeConfig, test_users_table: str +) -> None: + """Repeating a cached ``THEN RETURN`` statement still returns its rows.""" + sql = f"INSERT INTO {test_users_table} (id, name) VALUES (@id, @name) THEN RETURN name" + async with mode_session(spanner_mode_config, "write") as session: + first = await invoke(session.execute(sql, id=str(uuid4()), name="first")) + second = await invoke(session.execute(sql, id=str(uuid4()), name="second")) + + assert first.get_data() == [{"name": "first"}] + assert second.get_data() == [{"name": "second"}] diff --git a/tests/integration/adapters/spanner/spanner/test_transactions.py b/tests/integration/adapters/spanner/spanner/test_transactions.py new file mode 100644 index 000000000..ac590f14c --- /dev/null +++ b/tests/integration/adapters/spanner/spanner/test_transactions.py @@ -0,0 +1,100 @@ +"""Spanner transaction completion tests for the sync and async adapters.""" + +from typing import Any +from uuid import uuid4 + +import pyarrow as pa +import pytest + +from sqlspec.adapters.spanner import SpannerAsyncConfig, SpannerAsyncDriver, SpannerSyncDriver +from tests.integration.adapters.spanner.spanner._modes import SpannerModeConfig, invoke, mode_session + +pytestmark = [pytest.mark.spanner, pytest.mark.anyio] + + +@pytest.mark.parametrize("commit", [False, True]) +async def test_explicit_transaction_completion( + spanner_mode_config: SpannerModeConfig, test_users_table: str, commit: bool +) -> None: + """Explicit commit or rollback is final, repeatable, and preserved on context exit.""" + user_id = str(uuid4()) + async with mode_session(spanner_mode_config, "write") as session: + await invoke( + session.execute( + f"INSERT INTO {test_users_table} (id, name) VALUES (@id, @name)", id=user_id, name="completion" + ) + ) + complete = session.commit if commit else session.rollback + await invoke(complete()) + await invoke(complete()) + async with mode_session(spanner_mode_config, "read") as session: + count = await invoke( + session.select_value(f"SELECT COUNT(*) FROM {test_users_table} WHERE id = @id", id=user_id) + ) + assert count == int(commit) + + +async def test_write_session_continues_after_commit( + spanner_mode_config: SpannerModeConfig, test_users_table: str +) -> None: + """Statements after an explicit commit run in a new transaction that commits on exit.""" + first_id = str(uuid4()) + second_id = str(uuid4()) + async with mode_session(spanner_mode_config, "write") as session: + await invoke( + session.execute(f"INSERT INTO {test_users_table} (id, name) VALUES (@id, @name)", id=first_id, name="one") + ) + await invoke(session.commit()) + await invoke( + session.execute(f"INSERT INTO {test_users_table} (id, name) VALUES (@id, @name)", id=second_id, name="two") + ) + async with mode_session(spanner_mode_config, "read") as session: + count = await invoke( + session.select_value( + f"SELECT COUNT(*) FROM {test_users_table} WHERE id IN UNNEST(@ids)", ids=[first_id, second_id] + ) + ) + assert count == 2 + + +async def test_rollback_discards_buffered_arrow_mutations( + spanner_mode_config: SpannerModeConfig, test_arrow_table: str +) -> None: + """Rolling back a write session discards Arrow rows buffered as mutations.""" + async with mode_session(spanner_mode_config, "write") as session: + await invoke( + session.load_from_arrow(test_arrow_table, pa.table({"id": [103], "name": ["rolled back"], "value": [1]})) + ) + await invoke(session.rollback()) + async with mode_session(spanner_mode_config, "read") as session: + count = await invoke(session.select_value(f"SELECT COUNT(*) FROM {test_arrow_table} WHERE id = @id", id=103)) + assert count == 0 + + +async def test_config_run_in_transaction(spanner_mode_config: SpannerModeConfig, test_users_table: str) -> None: + """run_in_transaction() commits the work and returns its result.""" + user_id = str(uuid4()) + insert_sql = f"INSERT INTO {test_users_table} (id, name, email, age) VALUES (@id, @name, @email, @age)" + params: dict[str, Any] = {"id": user_id, "name": "Tx Runner", "email": "tx@example.com", "age": 50} + + if isinstance(spanner_mode_config, SpannerAsyncConfig): + + async def _async_work(driver: SpannerAsyncDriver) -> str: + await driver.execute(insert_sql, **params) + return user_id + + returned_id = await spanner_mode_config.run_in_transaction(_async_work) + else: + + def _sync_work(driver: SpannerSyncDriver) -> str: + driver.execute(insert_sql, **params) + return user_id + + returned_id = spanner_mode_config.run_in_transaction(_sync_work) + + assert returned_id == user_id + async with mode_session(spanner_mode_config, "read") as session: + row = await invoke( + session.select_one_or_none(f"SELECT name FROM {test_users_table} WHERE id = @id", id=user_id) + ) + assert row == {"name": "Tx Runner"} diff --git a/tests/integration/adapters/spanner/test_shared.py b/tests/integration/adapters/spanner/test_shared.py new file mode 100644 index 000000000..38c59f7c0 --- /dev/null +++ b/tests/integration/adapters/spanner/test_shared.py @@ -0,0 +1,5 @@ +"""Shared Spanner-family integration contracts.""" + +from tests.integration.adapters._shared import install_shared_tests + +install_shared_tests(globals(), "spanner") diff --git a/tests/integration/fixtures/__init__.py b/tests/integration/fixtures/__init__.py index 0b39096a2..e31011183 100644 --- a/tests/integration/fixtures/__init__.py +++ b/tests/integration/fixtures/__init__.py @@ -92,6 +92,7 @@ psycopg_sync_config, ) from tests.integration.fixtures.spanner import ( + spanner_async_config, spanner_config, spanner_database, spanner_read_session, @@ -194,6 +195,7 @@ "pymysql_config", "pymysql_driver", "pymysql_transaction_config", + "spanner_async_config", "spanner_config", "spanner_database", "spanner_read_session", diff --git a/tests/integration/fixtures/adapter_cases.py b/tests/integration/fixtures/adapter_cases.py index c8d390398..e2e0fb680 100644 --- a/tests/integration/fixtures/adapter_cases.py +++ b/tests/integration/fixtures/adapter_cases.py @@ -14,6 +14,7 @@ from pytest_databases.docker.mysql import MySQLService from pytest_databases.docker.oracle import OracleService from pytest_databases.docker.postgres import PostgresService +from pytest_databases.docker.spanner import SpannerService from sqlspec.adapters.adbc import AdbcConfig, AdbcDriver from sqlspec.adapters.adbc.adk import AdbcADKStore @@ -91,6 +92,7 @@ from sqlspec.adapters.pymysql import PyMysqlConfig, PyMysqlDriver, PyMysqlDriverFeatures from sqlspec.adapters.pymysql.adk import PyMysqlADKStore from sqlspec.adapters.pymysql.litestar import PyMysqlStore +from sqlspec.adapters.spanner import SpannerAsyncConfig, SpannerSyncConfig from sqlspec.adapters.sqlite import SqliteConfig, SqliteDriver, SqliteDriverFeatures from sqlspec.adapters.sqlite.adk import SqliteADKStore from sqlspec.adapters.sqlite.litestar import SQLiteStore @@ -151,6 +153,7 @@ _postgres_conninfo, _psqlpy_dsn, ) +from tests.integration.fixtures.spanner import build_spanner_connection_config @pytest.fixture @@ -2510,3 +2513,29 @@ def make(*, script_location: str, version_table_name: str, suffix: str) -> Db2As ) return make + + +@pytest.fixture +def migration_config_spanner_sync(spanner_service: SpannerService, spanner_database: Any) -> Callable[..., Any]: + """Build Spanner sync configs for migration contract tests.""" + + def make(*, script_location: str, version_table_name: str, suffix: str) -> SpannerSyncConfig: + return SpannerSyncConfig( + connection_config=build_spanner_connection_config(spanner_service), + migration_config={"script_location": script_location, "version_table_name": version_table_name}, + ) + + return make + + +@pytest.fixture +def migration_config_spanner_async(spanner_service: SpannerService, spanner_database: Any) -> Callable[..., Any]: + """Build Spanner async configs for migration contract tests.""" + + def make(*, script_location: str, version_table_name: str, suffix: str) -> SpannerAsyncConfig: + return SpannerAsyncConfig( + connection_config=build_spanner_connection_config(spanner_service), + migration_config={"script_location": script_location, "version_table_name": version_table_name}, + ) + + return make diff --git a/tests/integration/fixtures/spanner.py b/tests/integration/fixtures/spanner.py index d286f7835..102abf16c 100644 --- a/tests/integration/fixtures/spanner.py +++ b/tests/integration/fixtures/spanner.py @@ -1,6 +1,6 @@ """Shared Spanner integration fixtures.""" -from collections.abc import Generator +from collections.abc import AsyncGenerator, Generator from typing import TYPE_CHECKING, Any, cast import pytest @@ -9,15 +9,22 @@ from pytest_databases.docker.spanner import SpannerService from sqlspec import SQLSpec -from sqlspec.adapters.spanner import SpannerSyncConfig, SpannerSyncDriver +from sqlspec.adapters.spanner import SpannerAsyncConfig, SpannerSyncConfig, SpannerSyncDriver if TYPE_CHECKING: from google.cloud.spanner_v1.database import Database -__all__ = ("spanner_config", "spanner_database", "spanner_read_session", "spanner_session", "spanner_write_session") +__all__ = ( + "spanner_async_config", + "spanner_config", + "spanner_database", + "spanner_read_session", + "spanner_session", + "spanner_write_session", +) -def _spanner_connection_config(spanner_service: "SpannerService") -> "dict[str, Any]": +def build_spanner_connection_config(spanner_service: "SpannerService") -> "dict[str, Any]": return { "project": spanner_service.project, "instance_id": spanner_service.instance_name, @@ -33,12 +40,11 @@ def spanner_database( spanner_service: "SpannerService", spanner_connection: "spanner.Client" ) -> "Generator[Database, None, None]": """Ensure the emulator instance and database exist.""" - instance = spanner_connection.instance(spanner_service.instance_name) # type: ignore[no-untyped-call] + client = cast("Any", spanner_connection) + instance = client.instance(spanner_service.instance_name) if not instance.exists(): config_name = f"{spanner_connection.project_name}/instanceConfigs/emulator-config" - instance = spanner_connection.instance( # type: ignore[no-untyped-call] - spanner_service.instance_name, configuration_name=config_name - ) + instance = client.instance(spanner_service.instance_name, configuration_name=config_name) instance.create().result(300) database = instance.database(spanner_service.database_name) @@ -52,7 +58,7 @@ def spanner_config( spanner_service: "SpannerService", spanner_connection: "spanner.Client", spanner_database: "Database" ) -> "Generator[SpannerSyncConfig, None, None]": """Create a Spanner configuration after ensuring the database exists.""" - config = SpannerSyncConfig(connection_config=_spanner_connection_config(spanner_service)) + config = SpannerSyncConfig(connection_config=build_spanner_connection_config(spanner_service)) try: yield config finally: @@ -82,6 +88,19 @@ def spanner_read_session(spanner_config: "SpannerSyncConfig") -> "Generator[Span yield session +@pytest.fixture(scope="session") +async def spanner_async_config( + spanner_service: "SpannerService", spanner_database: "Database" +) -> "AsyncGenerator[SpannerAsyncConfig, None]": + """Create an async Spanner configuration after ensuring the database exists.""" + del spanner_database + config = SpannerAsyncConfig(connection_config=build_spanner_connection_config(spanner_service)) + try: + yield config + finally: + await config.close_pool() + + def run_ddl(database: "Database", statements: "list[str]", timeout: int = 300) -> None: """Execute DDL statements on a Spanner database.""" operation = cast("Any", database).update_ddl(statements) diff --git a/tests/integration/fixtures/spanner_adk.py b/tests/integration/fixtures/spanner_adk.py index 9dc5cf6e6..40d9dad78 100644 --- a/tests/integration/fixtures/spanner_adk.py +++ b/tests/integration/fixtures/spanner_adk.py @@ -1,35 +1,73 @@ -from collections.abc import Generator -from typing import TYPE_CHECKING +"""Fixtures for Spanner ADK store integration tests.""" + +from collections.abc import Callable +from typing import TYPE_CHECKING, Any +from uuid import uuid4 import pytest from pytest_databases.docker.spanner import SpannerService -from sqlspec.adapters.spanner import SpannerSyncConfig -from sqlspec.adapters.spanner.adk import SpannerSyncADKStore +from sqlspec.adapters.spanner import SpannerAsyncConfig, SpannerSyncConfig +from sqlspec.adapters.spanner.adk import ( + SpannerAsyncADKMemoryStore, + SpannerAsyncADKStore, + SpannerSyncADKMemoryStore, + SpannerSyncADKStore, +) +from tests.integration.fixtures.spanner import build_spanner_connection_config if TYPE_CHECKING: from google.cloud.spanner_v1.database import Database +__all__ = ("spanner_adk_memory_store_factory", "spanner_adk_store_factory") + + +def _spanner_adk_extension_config(suffix: str) -> "dict[str, Any]": + return { + "adk": { + "session_table": f"adk_s_{suffix}", + "events_table": f"adk_e_{suffix}", + "app_state_table": f"adk_app_{suffix}", + "user_state_table": f"adk_user_{suffix}", + "metadata_table": f"adk_meta_{suffix}", + "memory_table": f"adk_mem_{suffix}", + } + } + + +@pytest.fixture(params=("sync", "async")) +def spanner_adk_store_factory( + request: pytest.FixtureRequest, spanner_service: SpannerService, spanner_database: "Database" +) -> "Callable[[], tuple[Any, Any]]": + """Build a sync or async Spanner ADK session store with isolated tables per call.""" + del spanner_database + connection_config = build_spanner_connection_config(spanner_service) + + def make() -> "tuple[Any, Any]": + extension_config = _spanner_adk_extension_config(uuid4().hex[:8]) + if request.param == "sync": + sync_config = SpannerSyncConfig(connection_config=connection_config, extension_config=extension_config) + return sync_config, SpannerSyncADKStore(sync_config) + async_config = SpannerAsyncConfig(connection_config=connection_config, extension_config=extension_config) + return async_config, SpannerAsyncADKStore(async_config) + + return make + + +@pytest.fixture(params=("sync", "async")) +def spanner_adk_memory_store_factory( + request: pytest.FixtureRequest, spanner_service: SpannerService, spanner_database: "Database" +) -> "Callable[[], tuple[Any, Any]]": + """Build a sync or async Spanner ADK memory store with an isolated table per call.""" + del spanner_database + connection_config = build_spanner_connection_config(spanner_service) + + def make() -> "tuple[Any, Any]": + extension_config = _spanner_adk_extension_config(uuid4().hex[:8]) + if request.param == "sync": + sync_config = SpannerSyncConfig(connection_config=connection_config, extension_config=extension_config) + return sync_config, SpannerSyncADKMemoryStore(sync_config) + async_config = SpannerAsyncConfig(connection_config=connection_config, extension_config=extension_config) + return async_config, SpannerAsyncADKMemoryStore(async_config) -@pytest.fixture(scope="session") -def spanner_adk_config(spanner_service: SpannerService, spanner_database: "Database") -> SpannerSyncConfig: - api_endpoint = f"{spanner_service.host}:{spanner_service.port}" - - return SpannerSyncConfig( - connection_config={ - "project": spanner_service.project, - "instance_id": spanner_service.instance_name, - "database_id": spanner_service.database_name, - "credentials": spanner_service.credentials, - "client_options": {"api_endpoint": api_endpoint}, - "size": 5, - }, - extension_config={"adk": {"session_table": "adk_sessions", "events_table": "adk_events"}}, - ) - - -@pytest.fixture -def spanner_adk_store(spanner_adk_config: SpannerSyncConfig) -> Generator[SpannerSyncADKStore, None, None]: - store = SpannerSyncADKStore(spanner_adk_config) - store.create_tables() - yield store + return make diff --git a/tests/integration/fixtures/spanner_events.py b/tests/integration/fixtures/spanner_events.py index bd7d4370c..1932bd3de 100644 --- a/tests/integration/fixtures/spanner_events.py +++ b/tests/integration/fixtures/spanner_events.py @@ -1,52 +1,82 @@ """Fixtures for Spanner event queue tests.""" -from collections.abc import Generator -from typing import TYPE_CHECKING +from collections.abc import AsyncGenerator +from inspect import isawaitable +from typing import TYPE_CHECKING, cast import pytest from google.api_core import exceptions as api_exceptions from pytest_databases.docker.spanner import SpannerService -from sqlspec.adapters.spanner import SpannerSyncConfig -from sqlspec.adapters.spanner.events import SpannerSyncEventQueueStore +from sqlspec.adapters.spanner import SpannerAsyncConfig, SpannerSyncConfig +from sqlspec.adapters.spanner.events import SpannerAsyncEventQueueStore, SpannerSyncEventQueueStore +from tests.integration.fixtures.spanner import build_spanner_connection_config if TYPE_CHECKING: from google.cloud.spanner_v1.database import Database + from sqlspec.config import ExtensionConfigs + +__all__ = ("spanner_async_events_config", "spanner_event_store", "spanner_events_config", "spanner_events_mode_config") + + +def _events_extension_config() -> "ExtensionConfigs": + return {"events": {"queue_table": "sqlspec_event_queue"}} + @pytest.fixture(scope="session") def spanner_events_config(spanner_service: SpannerService, spanner_database: "Database") -> SpannerSyncConfig: """Create SpannerSyncConfig with events extension enabled.""" - api_endpoint = f"{spanner_service.host}:{spanner_service.port}" - + del spanner_database return SpannerSyncConfig( - connection_config={ - "project": spanner_service.project, - "instance_id": spanner_service.instance_name, - "database_id": spanner_service.database_name, - "credentials": spanner_service.credentials, - "client_options": {"api_endpoint": api_endpoint}, - "size": 5, - }, - extension_config={"events": {"queue_table": "sqlspec_event_queue"}}, + connection_config=build_spanner_connection_config(spanner_service), extension_config=_events_extension_config() ) -@pytest.fixture -def spanner_event_store( - spanner_events_config: SpannerSyncConfig, spanner_database: "Database" -) -> Generator[SpannerSyncEventQueueStore, None, None]: - """Create event queue store and ensure table exists.""" - store = SpannerSyncEventQueueStore(spanner_events_config) - +@pytest.fixture(scope="session") +async def spanner_async_events_config( + spanner_service: SpannerService, spanner_database: "Database" +) -> "AsyncGenerator[SpannerAsyncConfig, None]": + """Create SpannerAsyncConfig with events extension enabled.""" + del spanner_database + config = SpannerAsyncConfig( + connection_config=build_spanner_connection_config(spanner_service), extension_config=_events_extension_config() + ) try: - store.create_table() - except api_exceptions.AlreadyExists: - pass + yield config + finally: + await config.close_pool() + - yield store +@pytest.fixture(params=("sync", "async")) +def spanner_events_mode_config(request: pytest.FixtureRequest) -> "SpannerSyncConfig | SpannerAsyncConfig": + """Select the sync or async Spanner configuration with events enabled.""" + fixture_name = "spanner_events_config" if request.param == "sync" else "spanner_async_events_config" + return cast("SpannerSyncConfig | SpannerAsyncConfig", request.getfixturevalue(fixture_name)) + +@pytest.fixture +async def spanner_event_store( + spanner_events_mode_config: "SpannerSyncConfig | SpannerAsyncConfig", +) -> "AsyncGenerator[SpannerSyncEventQueueStore | SpannerAsyncEventQueueStore, None]": + """Create the event queue table for the selected adapter and drop it afterwards.""" + store: SpannerSyncEventQueueStore | SpannerAsyncEventQueueStore + if isinstance(spanner_events_mode_config, SpannerAsyncConfig): + store = SpannerAsyncEventQueueStore(spanner_events_mode_config) + else: + store = SpannerSyncEventQueueStore(spanner_events_mode_config) try: - store.drop_table() - except api_exceptions.NotFound: + created = store.create_table() + if isawaitable(created): + await created + except api_exceptions.AlreadyExists: pass + try: + yield store + finally: + try: + dropped = store.drop_table() + if isawaitable(dropped): + await dropped + except api_exceptions.NotFound: + pass diff --git a/tests/integration/fixtures/spanner_litestar.py b/tests/integration/fixtures/spanner_litestar.py index 71085ea05..8a21dd6fb 100644 --- a/tests/integration/fixtures/spanner_litestar.py +++ b/tests/integration/fixtures/spanner_litestar.py @@ -1,36 +1,70 @@ -from collections.abc import AsyncGenerator -from typing import TYPE_CHECKING +"""Fixtures for Spanner Litestar session store integration tests.""" + +from collections.abc import AsyncGenerator, Generator +from typing import TYPE_CHECKING, cast import pytest from pytest_databases.docker.spanner import SpannerService -from sqlspec.adapters.spanner import SpannerSyncConfig -from sqlspec.adapters.spanner.litestar import SpannerSyncStore +from sqlspec.adapters.spanner import SpannerAsyncConfig, SpannerSyncConfig +from sqlspec.adapters.spanner.litestar import SpannerAsyncStore, SpannerSyncStore +from tests.integration.fixtures.spanner import build_spanner_connection_config if TYPE_CHECKING: from google.cloud.spanner_v1.database import Database +__all__ = ("spanner_async_litestar_config", "spanner_litestar_config", "spanner_litestar_mode_config", "spanner_store") + @pytest.fixture(scope="session") -def spanner_litestar_config(spanner_service: SpannerService, spanner_database: "Database") -> SpannerSyncConfig: - api_endpoint = f"{spanner_service.host}:{spanner_service.port}" - - return SpannerSyncConfig( - connection_config={ - "project": spanner_service.project, - "instance_id": spanner_service.instance_name, - "database_id": spanner_service.database_name, - "credentials": spanner_service.credentials, - "client_options": {"api_endpoint": api_endpoint}, - "size": 5, - }, +def spanner_litestar_config( + spanner_service: SpannerService, spanner_database: "Database" +) -> "Generator[SpannerSyncConfig, None, None]": + """Create a sync Spanner configuration for the Litestar session store.""" + del spanner_database + config = SpannerSyncConfig( + connection_config=build_spanner_connection_config(spanner_service), extension_config={"litestar": {"session_table": "litestar_sessions"}}, ) + try: + yield config + finally: + config.close_pool() + + +@pytest.fixture(scope="session") +async def spanner_async_litestar_config( + spanner_service: SpannerService, spanner_database: "Database" +) -> "AsyncGenerator[SpannerAsyncConfig, None]": + """Create an async Spanner configuration for the Litestar session store.""" + del spanner_database + config = SpannerAsyncConfig( + connection_config=build_spanner_connection_config(spanner_service), + extension_config={"litestar": {"session_table": "litestar_sessions_async"}}, + ) + try: + yield config + finally: + await config.close_pool() + + +@pytest.fixture(params=("sync", "async")) +def spanner_litestar_mode_config(request: pytest.FixtureRequest) -> "SpannerSyncConfig | SpannerAsyncConfig": + """Select the sync or async Spanner configuration for the Litestar store.""" + fixture_name = "spanner_litestar_config" if request.param == "sync" else "spanner_async_litestar_config" + return cast("SpannerSyncConfig | SpannerAsyncConfig", request.getfixturevalue(fixture_name)) @pytest.fixture -async def spanner_store(spanner_litestar_config: SpannerSyncConfig) -> AsyncGenerator[SpannerSyncStore, None]: - store = SpannerSyncStore(spanner_litestar_config) +async def spanner_store( + spanner_litestar_mode_config: "SpannerSyncConfig | SpannerAsyncConfig", +) -> "AsyncGenerator[SpannerSyncStore | SpannerAsyncStore, None]": + """Provide a sync or async Spanner Litestar store with an empty session table.""" + store: SpannerSyncStore | SpannerAsyncStore + if isinstance(spanner_litestar_mode_config, SpannerAsyncConfig): + store = SpannerAsyncStore(spanner_litestar_mode_config) + else: + store = SpannerSyncStore(spanner_litestar_mode_config) await store.create_table() try: yield store diff --git a/tests/unit/adapters/test_adapter_implementations.py b/tests/unit/adapters/test_adapter_implementations.py index 21b36d4f6..ed02e73a5 100644 --- a/tests/unit/adapters/test_adapter_implementations.py +++ b/tests/unit/adapters/test_adapter_implementations.py @@ -690,7 +690,7 @@ def test_every_config_declares_type_coercion_capabilities() -> None: ): concrete_configs.append(obj) - assert len(concrete_configs) == 25 + assert len(concrete_configs) == 26 for config_cls in concrete_configs: assert "type_coercion_capabilities" in config_cls.__dict__, ( diff --git a/tests/unit/adapters/test_extension_config.py b/tests/unit/adapters/test_extension_config.py index 98d94bef7..172aac880 100644 --- a/tests/unit/adapters/test_extension_config.py +++ b/tests/unit/adapters/test_extension_config.py @@ -14,6 +14,7 @@ from sqlspec.adapters.oracledb import OracleAsyncConfig, OracleSyncConfig from sqlspec.adapters.psqlpy import PsqlpyConfig from sqlspec.adapters.psycopg import PsycopgAsyncConfig, PsycopgSyncConfig +from sqlspec.adapters.spanner import SpannerAsyncConfig, SpannerSyncConfig from sqlspec.adapters.sqlite import SqliteConfig from sqlspec.config import ExtensionConfigs @@ -163,6 +164,8 @@ def test_extension_config_defaults_to_empty_dict() -> None: BigQueryConfig(connection_config={"project": "test"}), Db2SyncConfig(connection_config={"database": "test"}), Db2AsyncConfig(connection_config={"database": "test"}), + SpannerSyncConfig(connection_config={"project": "test", "instance_id": "inst", "database_id": "db"}), + SpannerAsyncConfig(connection_config={"project": "test", "instance_id": "inst", "database_id": "db"}), ] for config in configs: @@ -207,6 +210,8 @@ def test_extension_config_with_multiple_extensions() -> None: (BigQueryConfig, {"connection_config": {"project": "test"}}), (Db2SyncConfig, {"connection_config": {"database": "test"}}), (Db2AsyncConfig, {"connection_config": {"database": "test"}}), + (SpannerSyncConfig, {"connection_config": {"project": "test", "instance_id": "inst", "database_id": "db"}}), + (SpannerAsyncConfig, {"connection_config": {"project": "test", "instance_id": "inst", "database_id": "db"}}), ], ) def test_all_adapters_accept_extension_config(config_class: type, init_kwargs: dict) -> None: diff --git a/tests/unit/adapters/test_spanner/test_adk_store.py b/tests/unit/adapters/test_spanner/test_adk_store.py index c46e1ac42..cbdc3f873 100644 --- a/tests/unit/adapters/test_spanner/test_adk_store.py +++ b/tests/unit/adapters/test_spanner/test_adk_store.py @@ -1,10 +1,12 @@ # pyright: reportPrivateUsage=false """Unit tests for Spanner ADK store behavior.""" +from collections.abc import Iterator +from contextlib import contextmanager from datetime import datetime, timezone from types import SimpleNamespace from typing import Any, cast, get_args, get_origin -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from google.api_core.exceptions import NotFound @@ -14,6 +16,8 @@ from sqlspec.adapters.spanner.adk import ( SpannerADKConfig, SpannerADKRetentionConfig, + SpannerAsyncADKMemoryStore, + SpannerAsyncADKStore, SpannerSyncADKMemoryStore, SpannerSyncADKStore, ) @@ -70,7 +74,7 @@ def test_insert_event_preserves_event_record_timestamp() -> None: "event_data": {"content": "hello"}, } - with patch.object(store, "_run_write") as run_write: + with patch.object(type(store), "_run_write") as run_write: store._insert_event(event) # pyright: ignore[reportPrivateUsage] statements = run_write.call_args.args[0] @@ -104,7 +108,10 @@ def test_append_event_and_update_state_preserves_event_record_timestamp() -> Non "update_time": timestamp, } - with patch.object(store, "_run_write") as run_write, patch.object(store, "_get_session", return_value=fake_record): + with ( + patch.object(type(store), "_run_write") as run_write, + patch.object(type(store), "_get_session", return_value=fake_record), + ): returned = store.append_event_and_update_state(event, "app", "u1", "session-1", {"turn": 1}) event_sql, event_params, _event_types = run_write.call_args.args[0][0] @@ -191,7 +198,10 @@ def test_spanner_memory_insert_entries_writes_clean_break_record() -> None: "embedding": None, } - with patch.object(store, "_event_exists", return_value=False), patch.object(store, "_run_write") as run_write: + with ( + patch.object(type(store), "_event_exists", return_value=False), + patch.object(type(store), "_run_write") as run_write, + ): inserted = store.insert_memory_entries([entry]) assert inserted == 1 @@ -256,6 +266,45 @@ def test_spanner_memory_reset_drop_tables_filters_absent_tables_and_indexes() -> ] +async def test_async_spanner_reset_drop_tables_filters_absent_tables() -> None: + """The async session store drops only the ADK tables that exist in the database.""" + config = _mock_config() + database = MagicMock() + + async def _list_tables() -> Any: + yield SimpleNamespace(table_id="adk_events") + + database.list_tables.side_effect = _list_tables + config.get_database = AsyncMock(return_value=database) + store = SpannerAsyncADKStore(config) + + statements = await store._reset_drop_tables_sql() + + assert statements == ["DROP INDEX idx_adk_events_timestamp", "DROP TABLE adk_events"] + + +async def test_async_spanner_memory_reset_drop_tables_filters_absent_tables_and_indexes() -> None: + """The async memory store drops only the memory tables and indexes that exist in the database.""" + config = _mock_config() + database = MagicMock() + + async def _list_tables() -> Any: + yield SimpleNamespace(table_id="adk_memory_entries") + + database.list_tables.side_effect = _list_tables + config.get_database = AsyncMock(return_value=database) + store = SpannerAsyncADKMemoryStore(config) + + statements = await store._reset_drop_memory_table_sql() + + assert statements == [ + "DROP INDEX idx_adk_memory_entries_session", + "DROP INDEX idx_adk_memory_entries_app_scope_user_time", + "DROP INDEX idx_adk_memory_entries_scope", + "DROP TABLE adk_memory_entries", + ] + + def test_spanner_drop_statement_table_handles_if_exists_and_quoted_identifiers() -> None: existing = {"adk_events", "adk_memory_entries"} @@ -272,7 +321,7 @@ def test_spanner_drop_statement_table_handles_if_exists_and_quoted_identifiers() def test_get_session_returns_none_when_spanner_session_table_missing() -> None: store = SpannerSyncADKStore(_mock_config()) - with patch.object(store, "_run_read", side_effect=_spanner_not_found("adk_session not found")): + with patch.object(type(store), "_run_read", side_effect=_spanner_not_found("adk_session not found")): result = store.get_session("app", "user", "session") assert result is None @@ -281,7 +330,7 @@ def test_get_session_returns_none_when_spanner_session_table_missing() -> None: def test_list_sessions_returns_empty_when_spanner_session_table_missing() -> None: store = SpannerSyncADKStore(_mock_config()) - with patch.object(store, "_run_read", side_effect=_spanner_not_found("adk_session not found")): + with patch.object(type(store), "_run_read", side_effect=_spanner_not_found("adk_session not found")): result = store.list_sessions("app", "user") assert result == [] @@ -290,7 +339,7 @@ def test_list_sessions_returns_empty_when_spanner_session_table_missing() -> Non def test_get_events_returns_empty_when_spanner_events_table_missing() -> None: store = SpannerSyncADKStore(_mock_config()) - with patch.object(store, "_run_read", side_effect=_spanner_not_found("adk_event not found")): + with patch.object(type(store), "_run_read", side_effect=_spanner_not_found("adk_event not found")): result = store.get_events("app", "user", "session") assert result == [] @@ -300,23 +349,23 @@ def _normalized(sql: str) -> str: return " ".join(sql.split()) -def _capture_session_list(store: SpannerSyncADKStore) -> "list[tuple[str, dict[str, Any], dict[str, Any]]]": +@contextmanager +def _capture_session_list() -> "Iterator[list[tuple[str, dict[str, Any], dict[str, Any]]]]": calls: list[tuple[str, dict[str, Any], dict[str, Any]]] = [] def capture(sql: str, params: "dict[str, Any] | None" = None, types: "dict[str, Any] | None" = None) -> "list[Any]": calls.append((sql, dict(params or {}), dict(types or {}))) return [] - store._run_read = capture # type: ignore[method-assign] - return calls + with patch.object(SpannerSyncADKStore, "_run_read", side_effect=capture): + yield calls def test_spanner_list_sessions_binds_order_and_page() -> None: """Explicit ordering renders inline while page bounds bind as typed named parameters.""" store = SpannerSyncADKStore(_mock_config()) - calls = _capture_session_list(store) - - store.list_sessions("app", "u1", order_by="create_time", descending=False, limit=10, offset=20) + with _capture_session_list() as calls: + store.list_sessions("app", "u1", order_by="create_time", descending=False, limit=10, offset=20) sql, params, types = calls[0] assert _normalized(sql).endswith("ORDER BY create_time ASC, id ASC LIMIT @limit OFFSET @offset") @@ -329,9 +378,8 @@ def test_spanner_list_sessions_binds_order_and_page() -> None: def test_spanner_list_sessions_defaults_to_recent_first_without_a_page() -> None: """The default listing keeps recent-first ordering and binds no page values.""" store = SpannerSyncADKStore(_mock_config()) - calls = _capture_session_list(store) - - store.list_sessions("app") + with _capture_session_list() as calls: + store.list_sessions("app") sql, params, _ = calls[0] assert _normalized(sql).endswith("ORDER BY update_time DESC, id DESC") @@ -341,9 +389,8 @@ def test_spanner_list_sessions_defaults_to_recent_first_without_a_page() -> None def test_spanner_list_sessions_composes_user_filter_with_a_page() -> None: """User filtering composes with the bound page values.""" store = SpannerSyncADKStore(_mock_config()) - calls = _capture_session_list(store) - - store.list_sessions("app", "u1", limit=5) + with _capture_session_list() as calls: + store.list_sessions("app", "u1", limit=5) sql, params, _ = calls[0] assert "AND user_id = @user_id" in sql @@ -353,9 +400,8 @@ def test_spanner_list_sessions_composes_user_filter_with_a_page() -> None: def test_spanner_list_sessions_zero_limit_never_reads() -> None: """A zero limit short-circuits before any read is issued.""" store = SpannerSyncADKStore(_mock_config()) - calls = _capture_session_list(store) - - assert store.list_sessions("app", limit=0) == [] + with _capture_session_list() as calls: + assert store.list_sessions("app", limit=0) == [] assert calls == [] @@ -371,9 +417,220 @@ def test_spanner_list_sessions_zero_limit_never_reads() -> None: def test_spanner_list_sessions_rejects_invalid_options(options: "dict[str, Any]") -> None: """Invalid ordering or paging fails before a read is issued.""" store = SpannerSyncADKStore(_mock_config()) - calls = _capture_session_list(store) - - with pytest.raises(ValueError): + with _capture_session_list() as calls, pytest.raises(ValueError): store.list_sessions("app", **options) assert calls == [] + + +async def test_async_adk_store_create_tables_with_async_generator_list_tables() -> None: + """Verify SpannerAsyncADKStore.create_tables consumes async generator list_tables and awaits update_ddl().result().""" + config = _mock_config({"expires_index_options": "locality_group = 'cold'"}) + database = MagicMock() + + async def _list_tables() -> Any: + yield SimpleNamespace(table_id="adk_session") + + database.list_tables.side_effect = _list_tables + op_mock = MagicMock() + op_mock.result = AsyncMock(return_value=None) + database.update_ddl = AsyncMock(return_value=op_mock) + config.get_database = AsyncMock(return_value=database) + + store = SpannerAsyncADKStore(config) + await store.create_tables() + + database.update_ddl.assert_awaited_once() + ddl_statements = database.update_ddl.call_args.args[0] + assert not any("CREATE TABLE adk_session" in stmt for stmt in ddl_statements) + assert any("CREATE TABLE adk_event" in stmt for stmt in ddl_statements) + op_mock.result.assert_awaited_once_with(timeout=300) + + +async def test_async_adk_store_create_get_list_delete_session() -> None: + """Verify SpannerAsyncADKStore CRUD operations over async snapshots and transactions.""" + config = _mock_config() + database = MagicMock() + executed_writes: list[tuple[str, dict[str, Any], dict[str, Any]]] = [] + + async def _run_in_txn(job: Any) -> Any: + txn = MagicMock() + + async def _exec_update(sql: str, params: dict[str, Any], param_types: dict[str, Any]) -> int: + executed_writes.append((sql, params, param_types)) + return 1 + + async def _batch_update(stmts: list[tuple[str, dict[str, Any], dict[str, Any]]]) -> tuple[Any, list[int]]: + executed_writes.extend(stmts) + return SimpleNamespace(code=0, message=""), [1] * len(stmts) + + txn.execute_update = _exec_update + txn.batch_update = _batch_update + return await job(txn) + + database.run_in_transaction = _run_in_txn + now = datetime(2026, 5, 10, 12, 0, tzinfo=timezone.utc) + + snapshot = MagicMock() + + async def _execute_sql( + sql: str, params: dict[str, Any] | None = None, param_types: dict[str, Any] | None = None + ) -> Any: + del sql, params, param_types + + async def _rows() -> Any: + yield ("s1", "app", "u1", '{"k":"v"}', now, now) + + return _rows() + + snapshot.execute_sql = _execute_sql + snap_ctx = MagicMock() + snap_ctx.__aenter__ = AsyncMock(return_value=snapshot) + snap_ctx.__aexit__ = AsyncMock(return_value=None) + database.snapshot.return_value = snap_ctx + config.get_database = AsyncMock(return_value=database) + + store = SpannerAsyncADKStore(config) + created = await store.create_session("s1", "app", "u1", {"k": "v"}) + assert created["id"] == "s1" + assert len(executed_writes) == 1 + + fetched = await store.get_session("app", "u1", "s1") + assert fetched == { + "id": "s1", + "app_name": "app", + "user_id": "u1", + "state": {"k": "v"}, + "create_time": now, + "update_time": now, + } + + sessions = await store.list_sessions("app", "u1", limit=5) + assert len(sessions) == 1 + assert sessions[0]["id"] == "s1" + + executed_writes.clear() + await store.delete_session("app", "u1", "s1") + assert len(executed_writes) == 2 + + +async def test_async_adk_store_append_event_and_update_state_and_get_events() -> None: + """Verify SpannerAsyncADKStore atomic append_event_and_update_state and get_events.""" + store = SpannerAsyncADKStore(_mock_config()) + timestamp = datetime(2026, 5, 10, 12, 0, tzinfo=timezone.utc) + event: StoredEvent = { + "id": "event-1", + "app_name": "app", + "user_id": "u1", + "session_id": "session-1", + "invocation_id": "inv-1", + "timestamp": timestamp, + "event_data": {"content": "hello"}, + } + fake_record = { + "id": "session-1", + "app_name": "app", + "user_id": "u1", + "state": {"turn": 1}, + "create_time": timestamp, + "update_time": timestamp, + } + + with ( + patch.object(type(store), "_run_write", new_callable=AsyncMock) as run_write, + patch.object(type(store), "_get_session", new_callable=AsyncMock, return_value=fake_record), + ): + returned = await store.append_event_and_update_state(event, "app", "u1", "session-1", {"turn": 1}) + + assert returned == fake_record + run_write.assert_awaited_once() + event_sql, event_params, _ = run_write.call_args.args[0][0] + assert "@timestamp" in event_sql + assert event_params["timestamp"] is timestamp + + with patch.object( + type(store), + "_run_read", + new_callable=AsyncMock, + return_value=[("event-1", "session-1", "inv-1", timestamp, '{"content":"hello"}', "app", "u1")], + ): + events = await store.get_events("app", "u1", "session-1") + assert len(events) == 1 + assert events[0]["event_data"] == {"content": "hello"} + + +async def test_async_adk_memory_store_create_drop_insert_search_and_delete() -> None: + """Verify SpannerAsyncADKMemoryStore DDL, insert, search, and delete operations.""" + config = _mock_config() + database = MagicMock() + + async def _list_tables() -> Any: + if False: + yield None + + database.list_tables.side_effect = _list_tables + op_mock = MagicMock() + op_mock.result = AsyncMock(return_value=None) + database.update_ddl = AsyncMock(return_value=op_mock) + config.get_database = AsyncMock(return_value=database) + + store = SpannerAsyncADKMemoryStore(config) + await store.create_tables() + database.update_ddl.assert_awaited_once() + + timestamp = datetime(2026, 5, 10, 12, 0, tzinfo=timezone.utc) + entry: StoredMemory = { + "id": "memory-1", + "session_id": "session-1", + "app_name": "app", + "user_id": "user", + "scope": "user", + "event_id": "event-1", + "author": "assistant", + "timestamp": timestamp, + "content_json": {"text": "hello"}, + "content_text": "hello", + "metadata_json": {"source": "unit"}, + "inserted_at": timestamp, + "embedding": None, + } + + with ( + patch.object(type(store), "_event_exists", new_callable=AsyncMock, return_value=False), + patch.object(type(store), "_run_write", new_callable=AsyncMock) as run_write, + ): + inserted = await store.insert_memory_entries([entry]) + assert inserted == 1 + run_write.assert_awaited_once() + + with patch.object( + type(store), + "_run_read", + new_callable=AsyncMock, + return_value=[ + ( + "memory-1", + "session-1", + "app", + "user", + "user", + "event-1", + "assistant", + timestamp, + '{"text":"hello"}', + "hello", + '{"source":"unit"}', + timestamp, + ) + ], + ): + results = await store.search_entries("hello", "app", "user") + assert len(results) == 1 + assert results[0]["content_json"] == {"text": "hello"} + + with patch.object(type(store), "_execute_update", new_callable=AsyncMock, return_value=2) as exec_update: + deleted = await store.delete_entries_by_session("session-1") + assert deleted == 2 + deleted_old = await store.delete_entries_older_than(7, app_name="app") + assert deleted_old == 2 + assert exec_update.await_count == 2 diff --git a/tests/unit/adapters/test_spanner/test_async_driver.py b/tests/unit/adapters/test_spanner/test_async_driver.py new file mode 100644 index 000000000..bbba946d1 --- /dev/null +++ b/tests/unit/adapters/test_spanner/test_async_driver.py @@ -0,0 +1,401 @@ +"""Unit tests for SpannerAsyncDriver, SpannerAsyncExceptionHandler, and SpannerAsyncStreamSource.""" + +from collections.abc import AsyncGenerator +from types import SimpleNamespace +from typing import Any, cast +from unittest.mock import AsyncMock, MagicMock + +import pyarrow as pa +import pytest +from google.api_core import exceptions as api_exceptions +from google.cloud.spanner_v1.data_types import JsonObject +from google.cloud.spanner_v1.types.type import TypeCode +from typing_extensions import Self + +import sqlspec.adapters.spanner.driver as spanner_driver_module +from sqlspec.adapters.spanner.core import SpannerAsyncStreamSource, default_statement_config, resolve_row_plan +from sqlspec.adapters.spanner.driver import SpannerAsyncDriver, SpannerAsyncExceptionHandler +from sqlspec.driver import AsyncRowStream +from sqlspec.exceptions import DeadlockError, SQLConversionError, UniqueViolationError +from sqlspec.utils.serializers import from_json + +_ARROW_CAPABILITIES = { + "arrow_export_enabled": True, + "arrow_import_enabled": True, + "parquet_export_enabled": True, + "parquet_import_enabled": True, + "partition_strategies": ["fixed"], +} + + +def _field(name: str, code: int) -> SimpleNamespace: + return SimpleNamespace(name=name, type_=SimpleNamespace(code=code)) + + +class _FakeAsyncResultSet: + def __init__(self, rows: list[tuple[Any, ...]], fields: list[Any]) -> None: + self._rows = rows + self._fields = fields + self.metadata: Any = None + self.closed = False + + def to_dict_list(self) -> list[dict[str, Any]]: + msg = "SpannerAsyncDriver must not call blocking to_dict_list() on AsyncStreamedResultSet" + raise AssertionError(msg) + + async def __aiter__(self) -> "AsyncGenerator[tuple[Any, ...], None]": + try: + for index, row in enumerate(self._rows): + if index == 0: + self.metadata = SimpleNamespace(row_type=SimpleNamespace(fields=self._fields)) + yield row + finally: + self.closed = True + + +async def test_spanner_async_exception_handler_maps_google_api_errors() -> None: + """Verify SpannerAsyncExceptionHandler maps GoogleAPICallError subclasses to SQLSpecError.""" + handler = SpannerAsyncExceptionHandler() + async with handler: + raise cast("Any", api_exceptions.Aborted)("Transaction was aborted.") + + assert isinstance(handler.pending_exception, DeadlockError) + assert isinstance(handler.pending_exception.__cause__, api_exceptions.Aborted) + + handler2 = SpannerAsyncExceptionHandler() + async with handler2: + raise cast("Any", api_exceptions.AlreadyExists)("Row already exists.") + + assert isinstance(handler2.pending_exception, UniqueViolationError) + + +async def test_spanner_async_select_stream_source_chunks_and_resolves_metadata() -> None: + """Verify SpannerAsyncStreamSource streams chunks via AsyncRowStream and resolves metadata lazily.""" + json_cls = cast("Any", JsonObject) + fields = [_field("id", TypeCode.INT64), _field("payload", TypeCode.JSON)] + fake_rs = _FakeAsyncResultSet( + rows=[(1, json_cls({"k": "v1"})), (2, json_cls({"k": "v2"})), (3, json_cls({"k": "v3"}))], fields=fields + ) + + class _FakeConnection: + def __init__(self) -> None: + self.calls: list[tuple[str, Any, Any, dict[str, Any]]] = [] + + async def execute_sql( + self, + sql: str, + params: dict[str, Any] | None = None, + param_types: dict[str, Any] | None = None, + **kwargs: Any, + ) -> _FakeAsyncResultSet: + self.calls.append((sql, params, param_types, kwargs)) + return fake_rs + + conn = _FakeConnection() + + class _FakeDriver: + def __init__(self) -> None: + self.connection = conn + + def handle_database_exceptions(self) -> SpannerAsyncExceptionHandler: + return SpannerAsyncExceptionHandler() + + def _check_pending_exception(self, exc_handler: SpannerAsyncExceptionHandler) -> None: + if exc_handler.pending_exception is not None: + raise exc_handler.pending_exception + + def _resolve_row_plan(self, result_fields: Any) -> tuple[list[str], tuple[tuple[int, Any], ...] | None]: + return resolve_row_plan(result_fields, {}, json_deserializer=from_json) + + source = SpannerAsyncStreamSource( + cast("Any", _FakeDriver()), "SELECT id, payload FROM items", {"p": 1}, {}, 2, {"timeout": 5.0} + ) + stream: AsyncRowStream[dict[str, Any]] = AsyncRowStream(source) + async with stream as active_stream: + collected = [item async for item in active_stream] + + assert collected == [ + {"id": 1, "payload": {"k": "v1"}}, + {"id": 2, "payload": {"k": "v2"}}, + {"id": 3, "payload": {"k": "v3"}}, + ] + assert fake_rs.closed is True + + +async def test_async_driver_dispatch_execute_select() -> None: + """Verify SpannerAsyncDriver executes SELECT queries via async iteration and resolves metadata.""" + json_cls = cast("Any", JsonObject) + fields = [_field("id", TypeCode.INT64), _field("meta", TypeCode.JSON)] + fake_rs = _FakeAsyncResultSet(rows=[(10, json_cls({"ok": True}))], fields=fields) + + mock_conn = MagicMock() + mock_conn.execute_sql = AsyncMock(return_value=fake_rs) + + driver = SpannerAsyncDriver(connection=mock_conn, statement_config=default_statement_config) + query_opts = {"optimizer_version": "6"} + result = await driver.execute("SELECT id, meta FROM users WHERE id = @id", {"id": 10}, query_options=query_opts) + + assert result.all() == [{"id": 10, "meta": {"ok": True}}] + mock_conn.execute_sql.assert_awaited_once() + _, kwargs = mock_conn.execute_sql.call_args + assert kwargs.get("query_options") == query_opts + + +async def test_async_driver_dispatch_execute_dml_and_last_statement() -> None: + """Verify SpannerAsyncDriver executes DML via await execute_update and forwards last_statement.""" + mock_conn = MagicMock() + mock_conn.execute_update = AsyncMock(return_value=2) + mock_conn.committed = None + + driver = SpannerAsyncDriver(connection=mock_conn) + result = await driver.execute("UPDATE users SET active = TRUE WHERE id = @id", {"id": 1}, last_statement=True) + + assert result.rows_affected == 2 + mock_conn.execute_update.assert_awaited_once() + _, kwargs = mock_conn.execute_update.call_args + assert kwargs.get("last_statement") is True + + +async def test_async_driver_dispatch_execute_dml_on_read_only_snapshot_raises() -> None: + """Verify SpannerAsyncDriver raises SQLConversionError on DML when cursor lacks execute_update.""" + snapshot = SimpleNamespace(execute_sql=AsyncMock()) + driver = SpannerAsyncDriver(connection=cast("Any", snapshot)) + + with pytest.raises(SQLConversionError, match="Cannot execute DML in a read-only Snapshot context"): + await driver.execute("DELETE FROM users WHERE id = 1") + + +async def test_async_driver_dispatch_execute_many() -> None: + """Verify SpannerAsyncDriver.execute_many invokes await batch_update and omits query_options.""" + mock_conn = MagicMock() + mock_conn.batch_update = AsyncMock(return_value=(None, [1, 1])) + + driver = SpannerAsyncDriver( + connection=mock_conn, + statement_config=default_statement_config, + driver_features={"query_options": {"optimizer_version": "latest"}}, + ) + result = await driver.execute_many( + "INSERT INTO users (id, name) VALUES (:id, :name)", + [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}], + query_options={"optimizer_version": "6"}, + ) + + assert result.rows_affected == 2 + mock_conn.batch_update.assert_awaited_once() + _, kwargs = mock_conn.batch_update.call_args + assert "query_options" not in kwargs + + +async def test_async_driver_dispatch_execute_script_marks_only_final_dml_as_last_statement() -> None: + """Verify SpannerAsyncDriver.execute_script forwards last_statement only to the final DML statement.""" + mock_conn = MagicMock() + mock_conn.execute_update = AsyncMock(return_value=1) + + driver = SpannerAsyncDriver(connection=mock_conn) + result = await driver.execute_script("UPDATE t SET x = 1; UPDATE t SET x = 2", last_statement=True) + + assert result.total_statements == 2 + calls = mock_conn.execute_update.await_args_list + assert len(calls) == 2 + assert "last_statement" not in calls[0].kwargs + assert calls[1].kwargs["last_statement"] is True + + +async def test_async_driver_select_stream_and_select_to_arrow() -> None: + """Verify SpannerAsyncDriver.select_stream and select_to_arrow work asynchronously.""" + fields = [_field("id", TypeCode.INT64), _field("name", TypeCode.STRING)] + + mock_conn = MagicMock() + mock_conn.execute_sql = AsyncMock( + side_effect=lambda *args, **kwargs: _FakeAsyncResultSet(rows=[(1, "alice"), (2, "bob")], fields=fields) + ) + + driver = SpannerAsyncDriver(connection=mock_conn) + stream = driver.select_stream("SELECT id, name FROM users", chunk_size=1, query_options={"optimizer_version": "6"}) + async with stream as active_stream: + streamed_rows = [row async for row in active_stream] + + assert streamed_rows == [{"id": 1, "name": "alice"}, {"id": 2, "name": "bob"}] + + arrow_result = await driver.select_to_arrow("SELECT id, name FROM users") + assert arrow_result.to_dict() == [{"id": 1, "name": "alice"}, {"id": 2, "name": "bob"}] + + +async def test_async_driver_commit_rollback_and_savepoints(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify SpannerAsyncDriver commit and rollback renew the session transaction, and savepoints raise.""" + + class _FakeAsyncTxn: + def __init__(self, session: Any, *, begun: bool) -> None: + self._session = session + self.committed: Any = None + self.rolled_back = False + self._transaction_id = b"txn" if begun else None + self._mutations: list[Any] = [] + self.commit_calls = 0 + self.rollback_calls = 0 + + async def commit(self) -> None: + self.commit_calls += 1 + self.committed = True + + async def rollback(self) -> None: + self.rollback_calls += 1 + self.rolled_back = True + + session = SimpleNamespace() + session.transaction = lambda: _FakeAsyncTxn(session, begun=False) + monkeypatch.setattr(spanner_driver_module, "SpannerAsyncTransaction", _FakeAsyncTxn) + txn = _FakeAsyncTxn(session, begun=True) + driver = SpannerAsyncDriver(connection=cast("Any", txn)) + + await driver.begin() + await driver.commit() + renewed = cast("Any", driver.connection) + assert txn.commit_calls == 1 + assert renewed is not txn + assert renewed._transaction_id is None + + await driver.commit() + assert renewed.commit_calls == 0 + assert driver.connection is renewed + + renewed._transaction_id = b"txn-2" + await driver.rollback() + assert renewed.rollback_calls == 1 + assert driver.connection is not renewed + + for method in ("create_savepoint", "release_savepoint", "rollback_to_savepoint"): + with pytest.raises(NotImplementedError, match="Spanner"): + await getattr(driver, method)("sp1") + + +async def test_async_driver_load_from_arrow_transactional_and_overwrite(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify SpannerAsyncDriver.load_from_arrow mutations and overwrite=True delete-then-mutate.""" + + class _FakeAsyncMutationTxn: + def __init__(self) -> None: + self.insert_or_update_calls: list[tuple[str, list[str], list[list[Any]]]] = [] + self.execute_update_calls: list[str] = [] + + def insert_or_update(self, table: str, columns: Any, values: Any) -> None: + self.insert_or_update_calls.append((table, list(columns), [list(v) for v in values])) + + async def execute_update(self, sql: str, params: Any = None, param_types: Any = None, **kwargs: Any) -> int: + del params, param_types, kwargs + self.execute_update_calls.append(sql) + return 0 + + monkeypatch.setattr(spanner_driver_module, "SpannerAsyncTransaction", _FakeAsyncMutationTxn) + txn = _FakeAsyncMutationTxn() + driver = SpannerAsyncDriver( + connection=cast("Any", txn), driver_features={"storage_capabilities": _ARROW_CAPABILITIES} + ) + + arrow_table = pa.table({"id": [1, 2], "name": ["a", "b"]}) + job = await driver.load_from_arrow("my_schema.users", arrow_table, overwrite=True) + + assert job.telemetry["rows_processed"] == 2 + assert txn.execute_update_calls == ["DELETE FROM `my_schema`.`users` WHERE TRUE"] + assert txn.insert_or_update_calls == [("my_schema.users", ["id", "name"], [[1, "a"], [2, "b"]])] + + +async def test_async_driver_load_from_arrow_batch_write_api() -> None: + """Verify SpannerAsyncDriver.load_from_arrow with enable_batch_write_api=True uses async mutation_groups.""" + + class _FakeGroup: + def __init__(self) -> None: + self.calls: list[tuple[str, list[str], list[list[Any]]]] = [] + + def insert_or_update(self, table: str, columns: Any, values: Any) -> None: + self.calls.append((table, list(columns), [list(v) for v in values])) + + class _FakeAsyncMutationGroups: + def __init__(self) -> None: + self.groups: list[_FakeGroup] = [] + self.batch_write_calls = 0 + self.entered = False + self.exited = False + + async def __aenter__(self) -> Self: + self.entered = True + return self + + async def __aexit__(self, exc_type: Any, exc: Any, traceback: Any) -> None: + self.exited = True + + def group(self) -> _FakeGroup: + group = _FakeGroup() + self.groups.append(group) + return group + + async def batch_write(self) -> Any: + self.batch_write_calls += 1 + + async def _gen() -> Any: + yield SimpleNamespace(status=SimpleNamespace(code=0, message="OK")) + + return _gen() + + class _FakeAsyncDatabase: + def __init__(self) -> None: + self.mutation_groups_obj = _FakeAsyncMutationGroups() + + def mutation_groups(self) -> _FakeAsyncMutationGroups: + return self.mutation_groups_obj + + database = _FakeAsyncDatabase() + snapshot = SimpleNamespace(_session=SimpleNamespace(_database=database)) + driver = SpannerAsyncDriver( + connection=cast("Any", snapshot), + driver_features={"storage_capabilities": _ARROW_CAPABILITIES, "enable_batch_write_api": True}, + ) + + job = await driver.load_from_arrow("users", pa.table({"id": [1, 2], "name": ["a", "b"]})) + assert job.telemetry["rows_processed"] == 2 + assert database.mutation_groups_obj.entered is True + assert database.mutation_groups_obj.exited is True + assert database.mutation_groups_obj.batch_write_calls == 1 + assert database.mutation_groups_obj.groups[0].calls == [("users", ["id", "name"], [[1, "a"], [2, "b"]])] + + +async def test_async_driver_select_stream_handles_empty_result_set() -> None: + """Verify SpannerAsyncDriver.select_stream handles an empty AsyncStreamedResultSet cleanly.""" + fake_rs = _FakeAsyncResultSet(rows=[], fields=[_field("id", TypeCode.INT64)]) + mock_conn = MagicMock() + mock_conn.execute_sql = AsyncMock(return_value=fake_rs) + + driver = SpannerAsyncDriver(connection=mock_conn) + async with driver.select_stream("SELECT id FROM users WHERE FALSE") as active_stream: + rows = [row async for row in active_stream] + + assert rows == [] + assert fake_rs.closed is True + + +async def test_async_driver_load_from_arrow_empty_and_multi_chunk(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify SpannerAsyncDriver.load_from_arrow handles empty tables and splits chunks exceeding 80,000 cells.""" + + class _FakeAsyncMutationTxn: + def __init__(self) -> None: + self.insert_or_update_calls: list[tuple[str, list[str], int]] = [] + + def insert_or_update(self, table: str, columns: Any, values: Any) -> None: + self.insert_or_update_calls.append((table, list(columns), len(values))) + + monkeypatch.setattr(spanner_driver_module, "SpannerAsyncTransaction", _FakeAsyncMutationTxn) + txn = _FakeAsyncMutationTxn() + driver = SpannerAsyncDriver( + connection=cast("Any", txn), driver_features={"storage_capabilities": _ARROW_CAPABILITIES} + ) + + empty_table = pa.table({"id": pa.array([], type=pa.int64()), "name": pa.array([], type=pa.string())}) + empty_job = await driver.load_from_arrow("users", empty_table) + assert empty_job.telemetry["rows_processed"] == 0 + assert txn.insert_or_update_calls == [] + + monkeypatch.setattr("sqlspec.adapters.spanner.core._MAX_MUTATIONS_PER_COMMIT", 4) + multi_table = pa.table({"id": [1, 2, 3, 4, 5], "name": ["a", "b", "c", "d", "e"]}) + multi_job = await driver.load_from_arrow("users", multi_table) + assert multi_job.telemetry["rows_processed"] == 5 + assert [count for _, _, count in txn.insert_or_update_calls] == [2, 2, 1] diff --git a/tests/unit/adapters/test_spanner/test_config.py b/tests/unit/adapters/test_spanner/test_config.py index 39f470588..bf3f56b50 100644 --- a/tests/unit/adapters/test_spanner/test_config.py +++ b/tests/unit/adapters/test_spanner/test_config.py @@ -1,10 +1,28 @@ from datetime import timedelta +from types import SimpleNamespace from typing import TYPE_CHECKING, Any, cast +from unittest.mock import AsyncMock import pytest -from google.cloud.spanner_v1.pool import AbstractSessionPool, BurstyPool, FixedSizePool +from google.api_core import exceptions as api_exceptions +from google.cloud.spanner_v1._async.database_sessions_manager import TransactionType as AsyncTransactionType +from google.cloud.spanner_v1._async.pool import BurstyPool as AsyncBurstyPool +from google.cloud.spanner_v1._async.pool import FixedSizePool as AsyncFixedSizePool +from google.cloud.spanner_v1._async.pool import PingingPool as AsyncPingingPool +from google.cloud.spanner_v1._async.pool import TransactionPingingPool as AsyncTransactionPingingPool +from google.cloud.spanner_v1.pool import ( + AbstractSessionPool, + BurstyPool, + FixedSizePool, + PingingPool, + TransactionPingingPool, +) +from typing_extensions import Self +import sqlspec.adapters.spanner.config as spanner_config from sqlspec.adapters.spanner.config import ( + SpannerAsyncConfig, + SpannerAsyncConnectionContext, SpannerConnectionParams, SpannerDriverFeatures, SpannerPoolParams, @@ -12,8 +30,9 @@ build_connection_config, ) from sqlspec.adapters.spanner.core import default_statement_config +from sqlspec.adapters.spanner.driver import SpannerAsyncDriver, SpannerSyncDriver from sqlspec.driver import SyncDriverAdapterBase -from sqlspec.exceptions import ImproperConfigurationError +from sqlspec.exceptions import DeadlockError, ImproperConfigurationError from tests.conftest import requires_interpreted if TYPE_CHECKING: @@ -347,6 +366,9 @@ def __exit__(self, *_): class _Txn: _transaction_id = "test-txn-id" + _mutations: tuple[object, ...] = () + committed = None + rolled_back = False def __enter__(self): return self @@ -411,6 +433,7 @@ def __init__(self) -> None: self._transaction_id = None self._mutations = [object()] self.committed = None + self.rolled_back = False self.commit_calls = 0 self.rollback_calls = 0 @@ -475,6 +498,7 @@ def __init__(self) -> None: self._transaction_id = None self._mutations: list[object] = [] self.committed = None + self.rolled_back = False self.commit_calls = 0 self.rollback_calls = 0 @@ -535,6 +559,9 @@ def test_provide_session_uses_batch_when_transaction_requested() -> None: class _Txn: _transaction_id = "test-txn-id" + _mutations: tuple[object, ...] = () + committed = None + rolled_back = False def __enter__(self): return self @@ -591,6 +618,9 @@ def test_provide_write_session_alias() -> None: class _Txn: _transaction_id = "test-txn-id" + _mutations: tuple[object, ...] = () + committed = None + rolled_back = False def __enter__(self): return self @@ -713,3 +743,493 @@ def test_spanner_config_accepts_aliases() -> None: assert config.connection_config["database_id"] == "db" pool = config.provide_pool() assert pool is not None + + +async def test_async_config_initialization_and_get_database(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify SpannerAsyncConfig initializes async pool and awaits instance.database(...).""" + + class _FakeAsyncDatabase: + def __init__(self, database_id: str, kwargs: dict[str, Any]) -> None: + self.database_id = database_id + self.kwargs = kwargs + self.closed = False + + async def close(self) -> None: + self.closed = True + + class _FakeAsyncInstance: + def __init__(self, instance_id: str, kwargs: dict[str, Any]) -> None: + self.instance_id = instance_id + self.kwargs = kwargs + + async def database(self, database_id: str, **kwargs: Any) -> _FakeAsyncDatabase: + return _FakeAsyncDatabase(database_id, kwargs) + + class _FakeAsyncClient: + def __init__(self, **kwargs: Any) -> None: + self.kwargs = kwargs + self.closed = False + created_async_clients.append(self) + + def instance(self, instance_id: str, **kwargs: Any) -> _FakeAsyncInstance: + return _FakeAsyncInstance(instance_id, kwargs) + + def close(self) -> None: + self.closed = True + + created_async_clients: list[_FakeAsyncClient] = [] + monkeypatch.setattr(spanner_config, "AsyncClient", _FakeAsyncClient) + + config = SpannerAsyncConfig(connection_config={"project": "p", "instance_id": "i", "database_id": "d"}) + pool = await config.provide_pool() + assert pool is not None + database = cast("_FakeAsyncDatabase", await config.get_database()) + assert database.database_id == "d" + assert database.kwargs["pool"] is pool + + await config.close_pool() + assert database.closed is True + assert created_async_clients[0].closed is True + assert config._database is None + assert config._client is None + + +async def test_async_config_provide_connection_and_sessions() -> None: + """Verify SpannerAsyncConfig provide_connection, provide_session, provide_write_session, and provide_read_session.""" + snap_obj = object() + + class _AsyncSnapshotCheckout: + def __init__(self, val: object) -> None: + self.val = val + self.exited = False + + async def __aenter__(self) -> object: + return self.val + + async def __aexit__(self, *_: object) -> bool: + self.exited = True + return False + + class _AsyncTxn: + def __init__(self) -> None: + self._transaction_id = b"async-txn-id" + self._mutations: list[object] = [] + self.committed: Any = None + self.rolled_back = False + self.commit_calls = 0 + self.rollback_calls = 0 + + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, *_: object) -> bool: + return False + + async def commit(self) -> None: + self.commit_calls += 1 + self.committed = object() + + async def rollback(self) -> None: + self.rollback_calls += 1 + + class _AsyncSession: + def __init__(self) -> None: + self.txn = _AsyncTxn() + + def transaction(self) -> _AsyncTxn: + return self.txn + + class _AsyncSessionsManager: + def __init__(self) -> None: + self.session = _AsyncSession() + self.checked_out = 0 + self.returned = 0 + + async def get_session(self, _transaction_type: object) -> _AsyncSession: + assert _transaction_type is AsyncTransactionType.READ_WRITE + self.checked_out += 1 + return self.session + + async def put_session(self, _session: object) -> None: + self.returned += 1 + + class _AsyncDB: + def __init__(self) -> None: + self.sessions_manager = _AsyncSessionsManager() + self.snapshot_checkout = _AsyncSnapshotCheckout(snap_obj) + + def snapshot(self, multi_use: bool = False) -> _AsyncSnapshotCheckout: + assert multi_use is True + return self.snapshot_checkout + + database = _AsyncDB() + config = SpannerAsyncConfig(connection_config={"project": "p", "instance_id": "i", "database_id": "d"}) + + async def _get_db() -> Any: + return database + + object.__setattr__(config, "get_database", _get_db) + + ctx = config.provide_connection(transaction=True) + assert isinstance(ctx, SpannerAsyncConnectionContext) + async with ctx as conn: + assert isinstance(conn, _AsyncTxn) + assert database.sessions_manager.session.txn.commit_calls == 1 + assert database.sessions_manager.checked_out == 1 + assert database.sessions_manager.returned == 1 + + async with config.provide_read_session() as read_driver: + assert read_driver.connection is snap_obj + assert database.snapshot_checkout.exited is True + + database.sessions_manager.session.txn.committed = None + async with config.provide_write_session() as write_driver: + assert isinstance(write_driver.connection, _AsyncTxn) + assert database.sessions_manager.session.txn.commit_calls == 2 + + +@pytest.mark.parametrize("finished_state", ["committed", "rolled_back"]) +@pytest.mark.parametrize("error", [None, ValueError("application failure")]) +async def test_async_connection_exit_preserves_finished_transaction( + finished_state: str, error: Exception | None +) -> None: + txn = SimpleNamespace( + _transaction_id=b"transaction", + _mutations=[], + committed=None, + rolled_back=False, + commit=AsyncMock(), + rollback=AsyncMock(), + ) + setattr(txn, finished_state, True) + manager = SimpleNamespace(put_session=AsyncMock()) + config = SpannerAsyncConfig() + object.__setattr__(config, "get_database", AsyncMock(return_value=SimpleNamespace(sessions_manager=manager))) + context = config.provide_connection(transaction=True) + context._session = object() + context._connection = txn + await context.__aexit__(type(error) if error else None, error, None) + txn.commit.assert_not_awaited() + txn.rollback.assert_not_awaited() + manager.put_session.assert_awaited_once() + + +async def test_async_connection_context_mutations_empty_rollback_and_aenter_failure() -> None: + """Verify SpannerAsyncConnectionContext handles mutations-only, empty, exception rollback, and __aenter__ failure.""" + + class _AsyncTxn: + def __init__( + self, *, txn_id: bytes | None = None, mutations: list[object] | None = None, fail_enter: bool = False + ) -> None: + self._transaction_id = txn_id + self._mutations = mutations if mutations is not None else [] + self.fail_enter = fail_enter + self.committed: Any = None + self.rolled_back = False + self.commit_calls = 0 + self.rollback_calls = 0 + + async def __aenter__(self) -> Self: + if self.fail_enter: + msg = "Failed entering transaction" + raise RuntimeError(msg) + return self + + async def __aexit__(self, *_: object) -> bool: + return False + + async def commit(self) -> None: + self.commit_calls += 1 + self.committed = object() + + async def rollback(self) -> None: + self.rollback_calls += 1 + + class _AsyncSession: + def __init__(self, txn: _AsyncTxn) -> None: + self.txn = txn + + def transaction(self) -> _AsyncTxn: + return self.txn + + class _AsyncSessionsManager: + def __init__(self, session: _AsyncSession) -> None: + self.session = session + self.returned = 0 + + async def get_session(self, _transaction_type: object) -> _AsyncSession: + return self.session + + async def put_session(self, _session: object) -> None: + self.returned += 1 + + class _AsyncDB: + def __init__(self, txn: _AsyncTxn) -> None: + self.txn = txn + self.sessions_manager = _AsyncSessionsManager(_AsyncSession(txn)) + + config = SpannerAsyncConfig(connection_config={"project": "p", "instance_id": "i", "database_id": "d"}) + + mutations_db = _AsyncDB(_AsyncTxn(txn_id=None, mutations=[object()])) + object.__setattr__(config, "get_database", AsyncMock(return_value=mutations_db)) + async with config.provide_connection(transaction=True): + pass + assert mutations_db.txn.commit_calls == 1 + assert mutations_db.txn.rollback_calls == 0 + assert mutations_db.sessions_manager.returned == 1 + + empty_db = _AsyncDB(_AsyncTxn(txn_id=None, mutations=[])) + object.__setattr__(config, "get_database", AsyncMock(return_value=empty_db)) + async with config.provide_connection(transaction=True): + pass + assert empty_db.txn.commit_calls == 0 + assert empty_db.txn.rollback_calls == 0 + assert empty_db.sessions_manager.returned == 1 + + rollback_db = _AsyncDB(_AsyncTxn(txn_id=b"active-txn", mutations=[])) + object.__setattr__(config, "get_database", AsyncMock(return_value=rollback_db)) + with pytest.raises(ValueError, match="boom"): + async with config.provide_connection(transaction=True): + msg = "boom" + raise ValueError(msg) + assert rollback_db.txn.commit_calls == 0 + assert rollback_db.txn.rollback_calls == 1 + assert rollback_db.sessions_manager.returned == 1 + + fail_enter_db = _AsyncDB(_AsyncTxn(fail_enter=True)) + object.__setattr__(config, "get_database", AsyncMock(return_value=fail_enter_db)) + with pytest.raises(RuntimeError, match="Failed entering transaction"): + async with config.provide_connection(transaction=True): + pass + assert fail_enter_db.sessions_manager.returned == 1 + + +def _make_aborted(message: str) -> api_exceptions.Aborted: + aborted_cls = cast("Any", api_exceptions.Aborted) + return cast("api_exceptions.Aborted", aborted_cls(message)) + + +def test_sync_run_in_transaction_unwraps_deadlock_error_and_retries() -> None: + """SpannerSyncConfig.run_in_transaction and SpannerSyncDriver.run_in_transaction should unwrap DeadlockError.""" + + class _SyncDB: + def __init__(self, max_attempts: int = 2) -> None: + self.max_attempts = max_attempts + self.attempts = 0 + + def run_in_transaction(self, fn: Any, *args: Any, **kwargs: Any) -> Any: + last_aborted: api_exceptions.Aborted | None = None + for _ in range(self.max_attempts): + self.attempts += 1 + try: + return fn(object(), *args, **kwargs) + except api_exceptions.Aborted as exc: + last_aborted = exc + assert last_aborted is not None + raise last_aborted + + db = _SyncDB(max_attempts=2) + config = SpannerSyncConfig(connection_config={"project": "p", "instance_id": "i", "database_id": "d"}) + object.__setattr__(config, "get_database", lambda: cast("Any", db)) + + call_count = 0 + + def _work(driver: SpannerSyncDriver, value: int) -> int: + nonlocal call_count + call_count += 1 + assert isinstance(driver, SpannerSyncDriver) + if call_count == 1: + aborted = _make_aborted("Transaction aborted") + err = DeadlockError("Transaction aborted") + err.__cause__ = aborted + raise err + return value * 2 + + assert config.run_in_transaction(_work, 21) == 42 + assert db.attempts == 2 + + db_exhausted = _SyncDB(max_attempts=2) + object.__setattr__(config, "get_database", lambda: cast("Any", db_exhausted)) + + def _always_abort(_driver: SpannerSyncDriver) -> None: + aborted = _make_aborted("Always aborted") + err = DeadlockError("Always aborted") + err.__cause__ = aborted + raise err + + with pytest.raises(DeadlockError, match="Always aborted"): + config.run_in_transaction(_always_abort) + assert db_exhausted.attempts == 2 + + db_driver = _SyncDB(max_attempts=2) + sync_driver = SpannerSyncDriver( + connection=cast("Any", SimpleNamespace(_session=SimpleNamespace(_database=db_driver))), + statement_config=default_statement_config, + ) + driver_calls = 0 + + def _driver_work(txn_driver: SpannerSyncDriver) -> str: + nonlocal driver_calls + driver_calls += 1 + assert isinstance(txn_driver, SpannerSyncDriver) + if driver_calls == 1: + aborted = _make_aborted("Driver abort") + err = DeadlockError("Driver abort") + err.__cause__ = aborted + raise err + return "committed" + + assert sync_driver.run_in_transaction(_driver_work) == "committed" + assert db_driver.attempts == 2 + + +async def test_async_run_in_transaction_unwraps_deadlock_error_and_retries() -> None: + """SpannerAsyncConfig.run_in_transaction and SpannerAsyncDriver.run_in_transaction should unwrap DeadlockError.""" + + class _AsyncDB: + def __init__(self, max_attempts: int = 2) -> None: + self.max_attempts = max_attempts + self.attempts = 0 + + async def run_in_transaction(self, fn: Any, *args: Any, **kwargs: Any) -> Any: + last_aborted: api_exceptions.Aborted | None = None + for _ in range(self.max_attempts): + self.attempts += 1 + try: + return await fn(object(), *args, **kwargs) + except api_exceptions.Aborted as exc: + last_aborted = exc + assert last_aborted is not None + raise last_aborted + + db = _AsyncDB(max_attempts=2) + config = SpannerAsyncConfig(connection_config={"project": "p", "instance_id": "i", "database_id": "d"}) + + async def _get_db() -> Any: + return db + + object.__setattr__(config, "get_database", _get_db) + + call_count = 0 + + async def _work(driver: SpannerAsyncDriver, value: int) -> int: + nonlocal call_count + call_count += 1 + assert isinstance(driver, SpannerAsyncDriver) + if call_count == 1: + aborted = _make_aborted("Async transaction aborted") + err = DeadlockError("Async transaction aborted") + err.__cause__ = aborted + raise err + return value + 10 + + assert await config.run_in_transaction(_work, 32) == 42 + assert db.attempts == 2 + + db_exhausted = _AsyncDB(max_attempts=2) + + async def _get_exhausted_db() -> Any: + return db_exhausted + + object.__setattr__(config, "get_database", _get_exhausted_db) + + async def _always_abort(_driver: SpannerAsyncDriver) -> None: + aborted = _make_aborted("Async always aborted") + err = DeadlockError("Async always aborted") + err.__cause__ = aborted + raise err + + with pytest.raises(DeadlockError, match="Async always aborted"): + await config.run_in_transaction(_always_abort) + assert db_exhausted.attempts == 2 + + db_driver = _AsyncDB(max_attempts=2) + async_driver = SpannerAsyncDriver( + connection=cast("Any", SimpleNamespace(_session=SimpleNamespace(_database=db_driver))), + statement_config=default_statement_config, + ) + driver_calls = 0 + + async def _driver_work(txn_driver: SpannerAsyncDriver) -> str: + nonlocal driver_calls + driver_calls += 1 + assert isinstance(txn_driver, SpannerAsyncDriver) + if driver_calls == 1: + aborted = _make_aborted("Async driver abort") + err = DeadlockError("Async driver abort") + err.__cause__ = aborted + raise err + return "async-committed" + + assert await async_driver.run_in_transaction(_driver_work) == "async-committed" + assert db_driver.attempts == 2 + + +def test_sync_pinging_and_transaction_pinging_pool_defaults() -> None: + """PingingPool and TransactionPingingPool should default ping_interval to 1800s.""" + pinging_config = SpannerSyncConfig( + connection_config={"project": "p", "instance_id": "i", "database_id": "d", "pool_type": PingingPool, "size": 5} + ) + pinging_pool = pinging_config.provide_pool() + assert isinstance(pinging_pool, PingingPool) + assert pinging_pool.size == 5 + assert cast("Any", pinging_pool)._delta == timedelta(seconds=1800) + + txn_pinging_config = SpannerSyncConfig( + connection_config={ + "project": "p", + "instance_id": "i", + "database_id": "d", + "pool_type": TransactionPingingPool, + "size": 8, + "ping_interval": 900, + } + ) + txn_pinging_pool = txn_pinging_config.provide_pool() + assert isinstance(txn_pinging_pool, TransactionPingingPool) + assert txn_pinging_pool.size == 8 + assert cast("Any", txn_pinging_pool)._delta == timedelta(seconds=900) + + +async def test_async_config_pool_types() -> None: + """SpannerAsyncConfig defaults to an async FixedSizePool and configures each async pool class.""" + connection = {"project": "p", "instance_id": "i", "database_id": "d"} + default_pool = await SpannerAsyncConfig(connection_config=dict(connection)).provide_pool() + assert isinstance(default_pool, AsyncFixedSizePool) + assert default_pool.size == 10 + + fixed_pool = await SpannerAsyncConfig( + connection_config={**connection, "pool_type": AsyncFixedSizePool, "size": 6, "max_age_minutes": 30} + ).provide_pool() + assert isinstance(fixed_pool, AsyncFixedSizePool) + assert fixed_pool.size == 6 + assert fixed_pool._max_age == timedelta(minutes=30) + + bursty_pool = await SpannerAsyncConfig( + connection_config={**connection, "pool_type": AsyncBurstyPool, "target_size": 7} + ).provide_pool() + assert isinstance(bursty_pool, AsyncBurstyPool) + assert bursty_pool.target_size == 7 + + pinging_pool = await SpannerAsyncConfig( + connection_config={**connection, "pool_type": AsyncPingingPool, "size": 4} + ).provide_pool() + assert isinstance(pinging_pool, AsyncPingingPool) + assert pinging_pool.size == 4 + assert cast("Any", pinging_pool)._delta == timedelta(seconds=1800) + + txn_pinging_pool = await SpannerAsyncConfig( + connection_config={**connection, "pool_type": AsyncTransactionPingingPool, "size": 3, "ping_interval": 600} + ).provide_pool() + assert isinstance(txn_pinging_pool, AsyncTransactionPingingPool) + assert txn_pinging_pool.size == 3 + assert cast("Any", txn_pinging_pool)._delta == timedelta(seconds=600) + + +async def test_pool_type_from_other_variant_is_rejected() -> None: + """Each config rejects a session pool class built for the other sync/async variant.""" + connection = {"project": "p", "instance_id": "i", "database_id": "d"} + with pytest.raises(ImproperConfigurationError, match="SpannerAsyncConfig requires an async session pool"): + await SpannerAsyncConfig(connection_config={**connection, "pool_type": FixedSizePool}).provide_pool() + with pytest.raises(ImproperConfigurationError, match="SpannerSyncConfig requires a sync session pool"): + SpannerSyncConfig(connection_config={**connection, "pool_type": AsyncFixedSizePool}).provide_pool() diff --git a/tests/unit/adapters/test_spanner/test_core.py b/tests/unit/adapters/test_spanner/test_core.py index 0ba44654c..f174e1a2c 100644 --- a/tests/unit/adapters/test_spanner/test_core.py +++ b/tests/unit/adapters/test_spanner/test_core.py @@ -3,17 +3,22 @@ from types import SimpleNamespace from typing import Any, cast +import pytest from google.cloud.spanner_v1.data_types import JsonObject from google.cloud.spanner_v1.types.type import TypeCode from sqlspec.adapters.spanner.core import ( build_param_type_signature, + build_statement_config, collect_rows, + is_query_statement, resolve_column_names, resolve_row_plan, + resolve_transaction_completion, ) from sqlspec.adapters.spanner.driver import SpannerSyncDriver -from sqlspec.core import TypedParameter +from sqlspec.core import SQL, TypedParameter +from sqlspec.utils.serializers import from_json def _field(name: str, code: int) -> SimpleNamespace: @@ -145,3 +150,88 @@ def test_resolve_column_names_reuses_cached_fields() -> None: assert first == ["id", "name"] assert second is first assert len(cache) == 1 + + +def test_convert_json_row_value_zero_copy_unwrapping_with_default_deserializer(monkeypatch: Any) -> None: + """Verify JsonObject is unwrapped directly without calling serialize() when using default from_json.""" + fields = [ + _field("obj", TypeCode.JSON), + _field("arr", TypeCode.JSON), + _field("scalar", TypeCode.JSON), + _field("null_val", TypeCode.JSON), + ] + json_cls = cast("Any", JsonObject) + json_obj = json_cls({"a": 1}) + json_arr = json_cls([1, 2, 3]) + json_scalar = json_cls("hello") + json_null = json_cls(None) + + def fail_serialize(self: Any) -> str: + msg = "serialize() should not be called when json_deserializer is from_json" + raise AssertionError(msg) + + monkeypatch.setattr(JsonObject, "serialize", fail_serialize) + + column_names, column_plan = resolve_row_plan(fields, {}, json_deserializer=from_json) + rows = [(json_obj, json_arr, json_scalar, json_null)] + data, _ = collect_rows(rows, fields, column_names=column_names, column_plan=column_plan) + + assert data == [({"a": 1}, [1, 2, 3], "hello", None)] + assert type(data[0][0]) is dict + assert type(data[0][1]) is list + + +@pytest.mark.parametrize( + ("state", "failed", "expected"), + [ + ({"_transaction_id": b"txn"}, False, "commit"), + ({"_mutations": [object()]}, False, "commit"), + ({}, False, "skip"), + ({"_transaction_id": b"txn"}, True, "rollback"), + ({"_mutations": [object()]}, True, "rollback"), + ({}, True, "rollback"), + ({"_transaction_id": b"txn", "rolled_back": True}, False, "skip"), + ({"_transaction_id": b"txn", "rolled_back": True}, True, "skip"), + ({"_transaction_id": b"txn", "committed": object()}, False, "skip"), + ({"_transaction_id": b"txn", "committed": object()}, True, "skip"), + ], + ids=[ + "begun-commits", + "mutations-commit", + "empty-skips", + "failure-rolls-back", + "failure-discards-buffered-mutations", + "failure-before-begin-rolls-back", + "rolled-back-skips-commit", + "rolled-back-skips-rollback", + "committed-skips-commit", + "committed-skips-rollback", + ], +) +def test_resolve_transaction_completion(state: dict[str, Any], failed: bool, expected: str) -> None: + transaction = SimpleNamespace(**{ + "_transaction_id": None, + "_mutations": [], + "committed": None, + "rolled_back": False, + **state, + }) + assert resolve_transaction_completion(transaction, failed=failed) == expected + + +@pytest.mark.parametrize( + ("sql", "expected"), + [("SELECT 1", True), ("WITH t AS (SELECT 1) SELECT * FROM t", True), ("UPDATE t SET a = 1 WHERE TRUE", False)], +) +def test_is_query_statement(sql: str, expected: bool) -> None: + assert is_query_statement(sql, "spanner") is expected + + +def test_then_return_statement_returns_rows() -> None: + """Verify ``THEN RETURN`` DML is classified as returning rows.""" + statement = SQL( + "INSERT INTO t (id) VALUES (@id) THEN RETURN id", {"id": 1}, statement_config=build_statement_config() + ) + statement.compile() + + assert statement.returns_rows() diff --git a/tests/unit/adapters/test_spanner/test_data_dictionary_routing.py b/tests/unit/adapters/test_spanner/test_data_dictionary_routing.py index fbce9f8ab..553111107 100644 --- a/tests/unit/adapters/test_spanner/test_data_dictionary_routing.py +++ b/tests/unit/adapters/test_spanner/test_data_dictionary_routing.py @@ -1,10 +1,12 @@ -"""Tests for SpannerDataDictionary query routing.""" +"""Tests for SpannerSyncDataDictionary query routing.""" from typing import Any, cast import pytest -from sqlspec.adapters.spanner.data_dictionary import SpannerDataDictionary +from sqlspec.adapters.spanner.data_dictionary import SpannerAsyncDataDictionary, SpannerSyncDataDictionary +from sqlspec.adapters.spanner.driver import SpannerAsyncDriver, SpannerSyncDriver +from sqlspec.core import StatementConfig from sqlspec.data_dictionary import ColumnMetadata, ForeignKeyMetadata, IndexMetadata, TableMetadata @@ -49,13 +51,13 @@ def select(self, query: Any, **params: Any) -> list[Any]: def test_spanner_data_dictionary_default_mode() -> None: """Default mode is googlesql.""" - dictionary = SpannerDataDictionary() + dictionary = SpannerSyncDataDictionary() assert dictionary.mode == "googlesql" def test_get_tables_routes_to_googlesql() -> None: """get_tables executes query containing GoogleSQL fields and TableMetadata aliases.""" - dictionary = SpannerDataDictionary() + dictionary = SpannerSyncDataDictionary() driver = MockSpannerDriver() tables = dictionary.get_tables(cast(Any, driver)) @@ -70,7 +72,7 @@ def test_get_tables_routes_to_googlesql() -> None: def test_get_columns_routes_to_googlesql() -> None: """get_columns executes query containing GoogleSQL column extensions and ColumnMetadata aliases.""" - dictionary = SpannerDataDictionary() + dictionary = SpannerSyncDataDictionary() driver = MockSpannerDriver() columns = dictionary.get_columns(cast(Any, driver), table="users") @@ -84,7 +86,7 @@ def test_get_columns_routes_to_googlesql() -> None: def test_get_indexes_routes_to_googlesql() -> None: """get_indexes executes query containing GoogleSQL index extensions and IndexMetadata aliases.""" - dictionary = SpannerDataDictionary() + dictionary = SpannerSyncDataDictionary() driver = MockSpannerDriver() indexes = dictionary.get_indexes(cast(Any, driver), table="users") @@ -100,7 +102,7 @@ def test_get_indexes_routes_to_googlesql() -> None: def test_get_foreign_keys_routes_to_googlesql() -> None: """get_foreign_keys executes query containing GoogleSQL CTEs.""" - dictionary = SpannerDataDictionary() + dictionary = SpannerSyncDataDictionary() driver = MockSpannerDriver() fks = dictionary.get_foreign_keys(cast(Any, driver), table="users") @@ -112,7 +114,7 @@ def test_get_foreign_keys_routes_to_googlesql() -> None: def test_get_query_explicit_mode_override() -> None: """Explicit mode override routes to PostgreSQL query templates with expected aliases.""" - dictionary = SpannerDataDictionary() + dictionary = SpannerSyncDataDictionary() query = dictionary.get_query("tables", "by_schema", mode="postgresql") assert "table_catalog" in query.raw_sql @@ -127,10 +129,97 @@ def test_get_query_explicit_mode_override() -> None: @pytest.mark.parametrize("mode", ["googlesql", "postgresql"]) @pytest.mark.parametrize("domain", ["tables", "columns", "indexes"]) def test_schema_metadata_binds_only_schema(mode: str, domain: str) -> None: - dictionary = SpannerDataDictionary(mode=mode) + dictionary = SpannerSyncDataDictionary(mode=mode) driver = MockSpannerDriver() getattr(dictionary, f"get_{domain}")(cast("Any", driver), schema="public") statement = driver.last_query.copy(parameters={"schema_name": driver.last_params["schema_name"]}) _sql, parameters = statement.compile() assert parameters == ("public", "public") + + +class MockAsyncSpannerDriver: + """Mock SpannerAsyncDriver to capture async query execution.""" + + def __init__(self) -> None: + self.last_query: Any = None + self.last_params: dict[str, Any] = {} + + async def select(self, query: Any, **params: Any) -> list[Any]: + self.last_query = query + self.last_params = params + schema_type = params.get("schema_type") + if schema_type is TableMetadata: + return [TableMetadata(table_name="users", schema_name="public", table_type="BASE TABLE")] + if schema_type is ColumnMetadata: + return [ + ColumnMetadata( + column_name="id", table_name="users", schema_name="public", data_type="INT64", ordinal_position=1 + ) + ] + if schema_type is IndexMetadata: + return [ + IndexMetadata(index_name="users_by_name", table_name="users", schema_name="public", columns=["name"]) + ] + if schema_type is ForeignKeyMetadata: + return [ + ForeignKeyMetadata( + table_name="users", + column_name="org_id", + referenced_table="orgs", + referenced_column="id", + constraint_name="fk_users_org", + schema="public", + referenced_schema="public", + ) + ] + return [] + + +async def test_async_data_dictionary_routing_and_sync_alias() -> None: + """Verify SpannerAsyncDataDictionary routes GoogleSQL and PostgreSQL queries and SpannerSyncDataDictionary is aliased.""" + assert SpannerSyncDataDictionary is SpannerSyncDataDictionary + + dictionary = SpannerAsyncDataDictionary() + assert dictionary.mode == "googlesql" + driver = MockAsyncSpannerDriver() + + tables = await dictionary.get_tables(cast("Any", driver)) + assert len(tables) == 1 + assert "TABLE_SCHEMA AS schema_name" in str(driver.last_query) + + columns = await dictionary.get_columns(cast("Any", driver), table="users") + assert len(columns) == 1 + assert "COLUMN_NAME AS column_name" in str(driver.last_query) + + indexes = await dictionary.get_indexes(cast("Any", driver), table="users") + assert len(indexes) == 1 + assert "i.INDEX_NAME AS index_name" in str(driver.last_query) + + fks = await dictionary.get_foreign_keys(cast("Any", driver), table="users") + assert len(fks) == 1 + assert "fk_columns" in str(driver.last_query) + + pg_dictionary = SpannerAsyncDataDictionary(mode="postgresql") + assert pg_dictionary.mode == "postgresql" + await pg_dictionary.get_tables(cast("Any", driver), schema="public") + assert "table_catalog" in str(driver.last_query) + + +def test_driver_data_dictionary_dialect_mode_routing() -> None: + """Verify SpannerSyncDriver and SpannerAsyncDriver instantiate data dictionaries with dialect-matched mode.""" + async_driver_gsql = SpannerAsyncDriver(connection=cast("Any", object())) + assert isinstance(async_driver_gsql.data_dictionary, SpannerAsyncDataDictionary) + assert async_driver_gsql.data_dictionary.mode == "googlesql" + + async_driver_pg = SpannerAsyncDriver( + connection=cast("Any", object()), statement_config=StatementConfig(dialect="spangres") + ) + assert isinstance(async_driver_pg.data_dictionary, SpannerAsyncDataDictionary) + assert async_driver_pg.data_dictionary.mode == "postgresql" + + sync_driver_pg = SpannerSyncDriver( + connection=cast("Any", object()), statement_config=StatementConfig(dialect="spangres") + ) + assert isinstance(sync_driver_pg.data_dictionary, SpannerSyncDataDictionary) + assert sync_driver_pg.data_dictionary.mode == "postgresql" diff --git a/tests/unit/adapters/test_spanner/test_ddl_routing.py b/tests/unit/adapters/test_spanner/test_ddl_routing.py new file mode 100644 index 000000000..ce89975a5 --- /dev/null +++ b/tests/unit/adapters/test_spanner/test_ddl_routing.py @@ -0,0 +1,284 @@ +"""Unit tests for Spanner DDL routing and write-session transaction renewal.""" + +import inspect +from types import SimpleNamespace +from typing import Any, cast +from unittest.mock import AsyncMock, MagicMock + +import pytest + +import sqlspec.adapters.spanner.driver as spanner_driver_module +from sqlspec.adapters.spanner._typing import SpannerAsyncSessionContext, SpannerSyncSessionContext +from sqlspec.adapters.spanner.core import default_statement_config, is_ddl_statement +from sqlspec.adapters.spanner.driver import SpannerAsyncDriver, SpannerSyncDriver +from sqlspec.exceptions import SQLConversionError + + +@pytest.mark.parametrize( + "sql", + [ + "CREATE TABLE t (id INT64) PRIMARY KEY (id)", + "create index idx_t_a on t (a)", + "ALTER TABLE t ADD COLUMN b STRING(MAX)", + "DROP TABLE IF EXISTS t", + "CREATE SEARCH INDEX si ON t (tokens)", + "GRANT SELECT ON TABLE t TO ROLE reader", + "REVOKE SELECT ON TABLE t FROM ROLE reader", + "RENAME TABLE a TO b", + "ANALYZE", + "-- name: create-t\nCREATE TABLE t (id INT64) PRIMARY KEY (id)", + "/* migration */ ALTER DATABASE db SET OPTIONS (version_retention_period = '7d')", + ], +) +def test_is_ddl_statement_detects_spanner_ddl(sql: str) -> None: + """Verify schema statements are detected by their leading keyword, after comments.""" + assert is_ddl_statement(sql) + + +@pytest.mark.parametrize( + "sql", + [ + "SELECT 1", + "WITH x AS (SELECT 1) SELECT * FROM x", + "INSERT INTO t (id) VALUES (1)", + "UPDATE t SET a = 1 WHERE TRUE", + "DELETE FROM t WHERE TRUE", + "-- CREATE TABLE in a comment\nSELECT 1", + "", + ], +) +def test_is_ddl_statement_ignores_queries_and_dml(sql: str) -> None: + """Verify queries, DML and comment-only mentions of DDL keywords are not DDL.""" + assert not is_ddl_statement(sql) + + +def _build_driver(mode: str, events: "list[tuple[str, Any]]", *, writable: bool = True) -> Any: + def record_ddl(statements: "list[str]") -> Any: + events.append(("ddl", list(statements))) + if mode == "sync": + return SimpleNamespace(result=MagicMock(return_value=None)) + return SimpleNamespace(result=AsyncMock(return_value=None)) + + def record_update(sql: str, **_: Any) -> int: + events.append(("dml", sql)) + return 1 + + database = SimpleNamespace(update_ddl=AsyncMock(side_effect=record_ddl) if mode == "async" else record_ddl) + connection = MagicMock() + connection._session = SimpleNamespace(_database=database) + if writable: + connection.execute_update = AsyncMock(side_effect=record_update) if mode == "async" else record_update + else: + del connection.execute_update + del connection.batch_update + driver_cls = SpannerAsyncDriver if mode == "async" else SpannerSyncDriver + return driver_cls(connection=cast("Any", connection)) + + +class _EmptyAsyncResult: + """Async result set double with no rows.""" + + def __aiter__(self) -> "_EmptyAsyncResult": + return self + + async def __anext__(self) -> Any: + raise StopAsyncIteration + + +async def _resolve(value: Any) -> Any: + return await value if inspect.isawaitable(value) else value + + +@pytest.fixture(params=["sync", "async"]) +def mode(request: pytest.FixtureRequest) -> str: + return cast("str", request.param) + + +async def test_execute_routes_ddl_to_update_ddl(mode: str) -> None: + """Verify a DDL statement runs through ``update_ddl`` instead of the transaction.""" + events: list[tuple[str, Any]] = [] + driver = _build_driver(mode, events) + + result = await _resolve(driver.execute("CREATE TABLE t (id INT64) PRIMARY KEY (id)")) + + assert events == [("ddl", ["CREATE TABLE t (id INT64) PRIMARY KEY (id)"])] + assert result.rows_affected == 0 + + +@pytest.mark.parametrize( + "run", + [ + pytest.param(lambda driver: driver.execute("DROP TABLE IF EXISTS t"), id="execute"), + pytest.param(lambda driver: driver.execute_script("SELECT 1; DROP TABLE IF EXISTS t"), id="script"), + ], +) +async def test_read_only_snapshot_rejects_ddl(mode: str, run: Any) -> None: + """Verify read sessions refuse schema changes, as they refuse DML.""" + events: list[tuple[str, Any]] = [] + driver = _build_driver(mode, events, writable=False) + driver.connection.execute_sql = ( + AsyncMock(return_value=_EmptyAsyncResult()) if mode == "async" else MagicMock(return_value=[]) + ) + + with pytest.raises(SQLConversionError, match="Cannot execute DDL in a read-only Snapshot context"): + await _resolve(run(driver)) + + assert events == [] + + +async def test_execute_script_batches_consecutive_ddl_in_order(mode: str) -> None: + """Verify scripts batch adjacent DDL and apply it before the DML that follows.""" + events: list[tuple[str, Any]] = [] + driver = _build_driver(mode, events) + script = """ + CREATE TABLE a (id INT64) PRIMARY KEY (id); + CREATE INDEX idx_a_id ON a (id); + INSERT INTO a (id) VALUES (1); + ALTER TABLE a ADD COLUMN note STRING(MAX) + """ + + result = await _resolve(driver.execute_script(script)) + + assert [kind for kind, _ in events] == ["ddl", "dml", "ddl"] + assert events[0][1] == ["CREATE TABLE a (id INT64) PRIMARY KEY (id)", "CREATE INDEX idx_a_id ON a (id)"] + assert events[2][1] == ["ALTER TABLE a ADD COLUMN note STRING(MAX)"] + assert result.total_statements == 4 + + +def _maybe_async(mode: str, value: Any) -> Any: + if mode == "sync": + return value + + async def _value() -> Any: + return value + + return _value() + + +class _FakeTransaction: + """Read-write transaction double whose session hands out fresh transactions.""" + + def __init__(self, session: Any, events: "list[tuple[str, Any]]", mode: str, *, begun: bool) -> None: + self._session = session + self._events = events + self._mode = mode + self.committed: Any = None + self.rolled_back = False + self._transaction_id = b"txn" if begun else None + self._mutations: list[Any] = [] + + def commit(self) -> Any: + self._events.append(("commit", self)) + self.committed = True + return _maybe_async(self._mode, None) + + def rollback(self) -> Any: + self._events.append(("rollback", self)) + self.rolled_back = True + return _maybe_async(self._mode, None) + + def execute_update(self, sql: str, **_: Any) -> Any: + if self._transaction_id is None: + self._transaction_id = b"txn" + self._events.append(("dml", (sql, self))) + return _maybe_async(self._mode, 1) + + +def _transaction_driver( + mode: str, events: "list[tuple[str, Any]]", monkeypatch: pytest.MonkeyPatch, *, begun: bool +) -> "tuple[Any, _FakeTransaction]": + def record_ddl(statements: "list[str]") -> Any: + events.append(("ddl", list(statements))) + operation = SimpleNamespace(result=lambda timeout=None: _maybe_async(mode, None)) + return _maybe_async(mode, operation) + + session = SimpleNamespace(_database=SimpleNamespace(update_ddl=record_ddl)) + session.transaction = lambda: _FakeTransaction(session, events, mode, begun=False) + monkeypatch.setattr(spanner_driver_module, "SpannerTransaction", _FakeTransaction) + monkeypatch.setattr(spanner_driver_module, "SpannerAsyncTransaction", _FakeTransaction) + transaction = _FakeTransaction(session, events, mode, begun=begun) + driver_cls = SpannerAsyncDriver if mode == "async" else SpannerSyncDriver + return driver_cls(connection=cast("Any", transaction)), transaction + + +async def test_ddl_commits_begun_transaction_and_renews_it(mode: str, monkeypatch: pytest.MonkeyPatch) -> None: + """Verify DDL commits begun work first and leaves the session on a fresh transaction.""" + events: list[tuple[str, Any]] = [] + driver, transaction = _transaction_driver(mode, events, monkeypatch, begun=True) + + await _resolve(driver.execute("CREATE TABLE t (id INT64) PRIMARY KEY (id)")) + + assert events == [("commit", transaction), ("ddl", ["CREATE TABLE t (id INT64) PRIMARY KEY (id)"])] + assert driver.connection is not transaction + assert driver.connection._transaction_id is None + + +async def test_ddl_keeps_unbegun_transaction(mode: str, monkeypatch: pytest.MonkeyPatch) -> None: + """Verify DDL leaves a transaction that has not begun untouched.""" + events: list[tuple[str, Any]] = [] + driver, transaction = _transaction_driver(mode, events, monkeypatch, begun=False) + + await _resolve(driver.execute("DROP TABLE IF EXISTS t")) + + assert events == [("ddl", ["DROP TABLE IF EXISTS t"])] + assert driver.connection is transaction + + +async def test_managed_transaction_driver_never_commits_for_ddl(mode: str, monkeypatch: pytest.MonkeyPatch) -> None: + """Verify DDL inside ``run_in_transaction`` leaves commit and retry to the SDK.""" + events: list[tuple[str, Any]] = [] + session_driver, transaction = _transaction_driver(mode, events, monkeypatch, begun=True) + driver = session_driver._transaction_driver(transaction) + + await _resolve(driver.execute("CREATE INDEX idx_t_id ON t (id)")) + + assert events == [("ddl", ["CREATE INDEX idx_t_id ON t (id)"])] + assert driver.connection is transaction + + +async def test_script_dml_after_ddl_runs_on_renewed_transaction(mode: str, monkeypatch: pytest.MonkeyPatch) -> None: + """Verify script DML before DDL commits, and DML after it uses the renewed transaction.""" + events: list[tuple[str, Any]] = [] + driver, transaction = _transaction_driver(mode, events, monkeypatch, begun=False) + + await _resolve( + driver.execute_script( + "INSERT INTO a (id) VALUES (1); CREATE INDEX idx_a_id ON a (id); INSERT INTO a (id) VALUES (2)" + ) + ) + + assert [kind for kind, _ in events] == ["dml", "commit", "ddl", "dml"] + assert events[0][1] == ("INSERT INTO a (id) VALUES (1)", transaction) + renewed = driver.connection + assert renewed is not transaction + assert events[3][1] == ("INSERT INTO a (id) VALUES (2)", renewed) + + +async def test_session_context_releases_the_current_driver_connection(mode: str) -> None: + """Verify session release completes the transaction the driver holds at exit.""" + released: list[Any] = [] + original = object() + renewed = object() + + def acquire() -> Any: + return _maybe_async(mode, original) + + def release(connection: Any, **_: Any) -> Any: + released.append(connection) + return _maybe_async(mode, None) + + options: dict[str, Any] = { + "acquire_connection": acquire, + "release_connection": release, + "statement_config": default_statement_config, + "driver_features": {}, + "prepare_driver": lambda driver: driver, + } + if mode == "async": + async with SpannerAsyncSessionContext(**options) as async_driver: + async_driver.connection = cast("Any", renewed) + else: + with SpannerSyncSessionContext(**options) as sync_driver: + sync_driver.connection = cast("Any", renewed) + + assert released == [renewed] diff --git a/tests/unit/adapters/test_spanner/test_events_store.py b/tests/unit/adapters/test_spanner/test_events_store.py index 63e96af90..3108b24b5 100644 --- a/tests/unit/adapters/test_spanner/test_events_store.py +++ b/tests/unit/adapters/test_spanner/test_events_store.py @@ -1,9 +1,14 @@ # pyright: reportPrivateUsage=false -"""Unit tests for SpannerSyncEventQueueStore.""" +"""Unit tests for SpannerSyncEventQueueStore and SpannerAsyncEventQueueStore.""" -from unittest.mock import MagicMock, patch +import inspect +from typing import Any +from unittest.mock import AsyncMock, MagicMock -from sqlspec.adapters.spanner.events import SpannerSyncEventQueueStore +import pytest + +from sqlspec.adapters.spanner.config import SpannerAsyncConfig, SpannerSyncConfig +from sqlspec.adapters.spanner.events import SpannerAsyncEventQueueStore, SpannerSyncEventQueueStore def _mock_spanner_config() -> MagicMock: @@ -154,49 +159,55 @@ def test_index_name_generation() -> None: assert index_name == "idx_test_events_channel_status" -def test_create_table_uses_update_ddl() -> None: - """Verify create_table calls database.update_ddl with statements.""" - from sqlspec.adapters.spanner.config import SpannerSyncConfig - - config = MagicMock(spec=SpannerSyncConfig) +_STORE_VARIANTS = pytest.mark.parametrize( + ("store_cls", "config_cls", "mock_factory"), + [ + (SpannerSyncEventQueueStore, SpannerSyncConfig, MagicMock), + (SpannerAsyncEventQueueStore, SpannerAsyncConfig, AsyncMock), + ], + ids=["sync", "async"], +) + + +@_STORE_VARIANTS +@pytest.mark.parametrize( + ("operation", "expected_prefixes"), + [("create_table", ("CREATE TABLE", "CREATE INDEX")), ("drop_table", ("DROP INDEX", "DROP TABLE"))], +) +async def test_ddl_operations_run_through_update_ddl( + store_cls: Any, + config_cls: type[Any], + mock_factory: type[MagicMock], + operation: str, + expected_prefixes: tuple[str, str], +) -> None: + """create_table and drop_table submit both statements through update_ddl and wait for the operation.""" + config = MagicMock(spec=config_cls) config.extension_config = {"events": {"queue_table": "test_events"}} - - mock_database = MagicMock() mock_operation = MagicMock() - mock_database.update_ddl.return_value = mock_operation - config.get_database.return_value = mock_database - - store = SpannerSyncEventQueueStore(config) - - with patch.object(store, "_config", config): - store.create_table() - - mock_database.update_ddl.assert_called_once() - call_args = mock_database.update_ddl.call_args[0][0] - assert len(call_args) == 2 - mock_operation.result.assert_called_once() - - -def test_drop_table_uses_update_ddl() -> None: - """Verify drop_table calls database.update_ddl with statements.""" - from sqlspec.adapters.spanner.config import SpannerSyncConfig - - config = MagicMock(spec=SpannerSyncConfig) - config.extension_config = {"events": {"queue_table": "test_events"}} - + mock_operation.result = mock_factory(return_value=None) mock_database = MagicMock() - mock_operation = MagicMock() - mock_database.update_ddl.return_value = mock_operation - config.get_database.return_value = mock_database - - store = SpannerSyncEventQueueStore(config) - - with patch.object(store, "_config", config): - store.drop_table() - - mock_database.update_ddl.assert_called_once() - call_args = mock_database.update_ddl.call_args[0][0] - assert len(call_args) == 2 - assert "DROP INDEX" in call_args[0] - assert "DROP TABLE" in call_args[1] - mock_operation.result.assert_called_once() + mock_database.update_ddl = mock_factory(return_value=mock_operation) + config.get_database = mock_factory(return_value=mock_database) + + result = getattr(store_cls(config), operation)() + if inspect.isawaitable(result): + await result + + statements = mock_database.update_ddl.call_args.args[0] + assert all(statement.startswith(prefix) for statement, prefix in zip(statements, expected_prefixes, strict=True)) + mock_operation.result.assert_called_once_with(timeout=None) + + +@_STORE_VARIANTS +async def test_ddl_operations_reject_other_config_types( + store_cls: Any, config_cls: type[Any], mock_factory: type[MagicMock] +) -> None: + """create_table and drop_table raise TypeError when the store's config is not its variant's config.""" + store = store_cls(_mock_spanner_config()) + + for operation in ("create_table", "drop_table"): + with pytest.raises(TypeError, match=config_cls.__name__): + result = getattr(store, operation)() + if inspect.isawaitable(result): + await result diff --git a/tests/unit/adapters/test_spanner/test_litestar_store.py b/tests/unit/adapters/test_spanner/test_litestar_store.py index 6022cc66c..a68e4d1df 100644 --- a/tests/unit/adapters/test_spanner/test_litestar_store.py +++ b/tests/unit/adapters/test_spanner/test_litestar_store.py @@ -1,7 +1,9 @@ +from datetime import datetime, timedelta, timezone from typing import Any -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock, patch -from sqlspec.adapters.spanner.litestar import SpannerSyncStore +from sqlspec.adapters.spanner.litestar import SpannerAsyncStore, SpannerSyncStore +from sqlspec.adapters.spanner.type_converter import bytes_to_spanner def _mock_database() -> MagicMock: @@ -78,6 +80,17 @@ def __exit__(self, *_: Any) -> None: return _Ctx() +def _async_context_manager_yielding(value: Any) -> Any: + class _AsyncCtx: + async def __aenter__(self) -> Any: + return value + + async def __aexit__(self, *_: Any) -> None: + pass + + return _AsyncCtx() + + def test_get_uses_snapshot_session() -> None: """Verify _get uses snapshot session for read operations.""" driver = MagicMock() @@ -110,3 +123,90 @@ def test_exists_uses_snapshot_session() -> None: config.provide_session.assert_called_once_with() assert result is True + + +def _mock_async_database() -> MagicMock: + db = MagicMock() + txn = MagicMock() + txn.execute_update = AsyncMock(return_value=1) + + async def _run_in_txn(func: Any) -> Any: + return await func(txn) + + db.run_in_transaction = AsyncMock(side_effect=_run_in_txn) + db._txn = txn + return db + + +async def test_async_store_create_table() -> None: + """Verify SpannerAsyncStore.create_table consumes async list_tables and awaits update_ddl().result().""" + db = _mock_async_database() + + async def _list_tables() -> Any: + if False: + yield None + + db.list_tables.side_effect = _list_tables + op_mock = MagicMock() + op_mock.result = AsyncMock(return_value=None) + db.update_ddl = AsyncMock(return_value=op_mock) + + config = MagicMock() + config.extension_config = {"litestar": {"session_table": "sess", "manage_schema": True, "create_schema": True}} + config.get_database = AsyncMock(return_value=db) + driver = MagicMock() + config.provide_session.return_value = _async_context_manager_yielding(driver) + + store = SpannerAsyncStore(config) + with patch.object(SpannerAsyncStore, "reconcile_schema", new_callable=AsyncMock) as mock_reconcile: + await store.create_table() + + db.update_ddl.assert_awaited_once() + op_mock.result.assert_awaited_once_with(timeout=300) + mock_reconcile.assert_awaited_once_with(assume_existing=True) + + +async def test_async_store_set_delete_delete_all_delete_expired() -> None: + """Verify SpannerAsyncStore write operations use async run_in_transaction.""" + db = _mock_async_database() + config = MagicMock() + config.extension_config = {"litestar": {"session_table": "sess"}} + config.get_database = AsyncMock(return_value=db) + + store = SpannerAsyncStore(config) + await store.set("s1", b"payload", expires_in=60) + assert db.run_in_transaction.await_count == 1 + + await store.delete("s1") + assert db.run_in_transaction.await_count == 2 + + await store.delete_all() + assert db.run_in_transaction.await_count == 3 + + deleted = await store.delete_expired() + assert deleted == 1 + assert db.run_in_transaction.await_count == 4 + + +async def test_async_store_get_exists_expires_in() -> None: + """Verify SpannerAsyncStore read operations use async provide_session and optional renewal.""" + db = _mock_async_database() + future_time = datetime.now(timezone.utc) + timedelta(seconds=120) + driver = MagicMock() + driver.select_one_or_none = AsyncMock(return_value={"data": bytes_to_spanner(b"abc"), "expires_at": future_time}) + + config = MagicMock() + config.extension_config = {"litestar": {"session_table": "sess", "shard_count": 4}} + config.get_database = AsyncMock(return_value=db) + config.provide_session.side_effect = lambda: _async_context_manager_yielding(driver) + + store = SpannerAsyncStore(config) + val = await store.get("s1", renew_for=60) + assert val == b"abc" + db.run_in_transaction.assert_awaited_once() + + exists = await store.exists("s1") + assert exists is True + + ttl = await store.expires_in("s1") + assert ttl in range(1, 121) diff --git a/tests/unit/adapters/test_spanner/test_session_controls.py b/tests/unit/adapters/test_spanner/test_session_controls.py index bd40b216a..748a83308 100644 --- a/tests/unit/adapters/test_spanner/test_session_controls.py +++ b/tests/unit/adapters/test_spanner/test_session_controls.py @@ -22,14 +22,13 @@ def __init__(self, **kwargs: Any) -> None: connection_config={"project": "p", "instance_id": "i", "database_id": "d"}, driver_features={"request_options": request_options}, ) - monkeypatch.setattr(spanner_config, "SpannerSessionContext", _SessionContext) + monkeypatch.setattr(spanner_config, "SpannerSyncSessionContext", _SessionContext) context = config.provide_session() assert isinstance(context, _SessionContext) assert captured["driver_features"] is config.driver_features assert captured["driver_features"]["request_options"] is request_options - assert "database_provider" not in captured["driver_features"] def test_provide_session_accepts_spanner_execution_overrides(monkeypatch: pytest.MonkeyPatch) -> None: @@ -47,7 +46,7 @@ def __init__(self, **kwargs: Any) -> None: connection_config={"project": "p", "instance_id": "i", "database_id": "d"}, driver_features={"request_options": config_request_options}, ) - monkeypatch.setattr(spanner_config, "SpannerSessionContext", _SessionContext) + monkeypatch.setattr(spanner_config, "SpannerSyncSessionContext", _SessionContext) config.provide_session( request_options=session_request_options, directed_read_options=directed_read_options, retry=retry, timeout=12.0 @@ -59,7 +58,6 @@ def __init__(self, **kwargs: Any) -> None: assert captured["driver_features"]["retry"] is retry assert captured["driver_features"]["timeout"] == 12.0 assert config.driver_features["request_options"] is config_request_options - assert "database_provider" not in captured["driver_features"] def test_close_pool_closes_the_database() -> None: @@ -71,7 +69,7 @@ def close(self) -> None: calls.append("database") class _Pool: - def close(self) -> None: + def clear(self) -> None: calls.append("pool") config = SpannerSyncConfig(connection_config={"project": "p", "instance_id": "i", "database_id": "d"}) @@ -89,6 +87,9 @@ def test_write_transactions_reuse_pooled_sessions() -> None: class _Txn: _transaction_id = "txn" + _mutations: tuple[object, ...] = () + committed = None + rolled_back = False def __enter__(self) -> "Self": return self diff --git a/tests/unit/adapters/test_spanner/test_spanner_metadata_extensions.py b/tests/unit/adapters/test_spanner/test_spanner_metadata_extensions.py index cde7ca969..f47032e64 100644 --- a/tests/unit/adapters/test_spanner/test_spanner_metadata_extensions.py +++ b/tests/unit/adapters/test_spanner/test_spanner_metadata_extensions.py @@ -1,8 +1,8 @@ -"""Tests for SpannerDataDictionary metadata extensions and PostgreSQL mode.""" +"""Tests for SpannerSyncDataDictionary metadata extensions and PostgreSQL mode.""" from typing import Any, cast -from sqlspec.adapters.spanner.data_dictionary import SpannerDataDictionary +from sqlspec.adapters.spanner.data_dictionary import SpannerSyncDataDictionary from sqlspec.data_dictionary import MetadataSource, MetadataSupport, SystemMetadataResult @@ -22,7 +22,7 @@ def select(self, query: Any, **params: Any) -> list[dict[str, Any]]: def test_get_constraints_by_table() -> None: """get_constraints queries constraints for a specific table.""" - dictionary = SpannerDataDictionary() + dictionary = SpannerSyncDataDictionary() driver = MockSpannerDriver() results = dictionary.get_constraints(cast(Any, driver), table="Albums") @@ -33,7 +33,7 @@ def test_get_constraints_by_table() -> None: def test_get_constraints_by_schema() -> None: """get_constraints queries constraints for an entire schema.""" - dictionary = SpannerDataDictionary() + dictionary = SpannerSyncDataDictionary() driver = MockSpannerDriver() results = dictionary.get_constraints(cast(Any, driver), schema="catalog") @@ -44,7 +44,7 @@ def test_get_constraints_by_schema() -> None: def test_get_sequences() -> None: """get_sequences queries sequence metadata.""" - dictionary = SpannerDataDictionary() + dictionary = SpannerSyncDataDictionary() driver = MockSpannerDriver() results = dictionary.get_sequences(cast(Any, driver), sequence_name="OrderSeq") @@ -55,7 +55,7 @@ def test_get_sequences() -> None: def test_get_change_streams() -> None: """get_change_streams queries change stream metadata.""" - dictionary = SpannerDataDictionary() + dictionary = SpannerSyncDataDictionary() driver = MockSpannerDriver() results = dictionary.get_change_streams(cast(Any, driver), stream_name="StreamAll") @@ -66,7 +66,7 @@ def test_get_change_streams() -> None: def test_get_system_metadata_query_stats() -> None: """get_system_metadata queries SPANNER_SYS.QUERY_STATS_TOP_MINUTE when enabled.""" - dictionary = SpannerDataDictionary() + dictionary = SpannerSyncDataDictionary() driver = MockSpannerDriver() result = dictionary.get_system_metadata(cast(Any, driver), "query_stats_top", include_performance=True, limit=25) @@ -79,7 +79,7 @@ def test_get_system_metadata_query_stats() -> None: def test_get_system_metadata_table_sizes() -> None: """get_system_metadata queries SPANNER_SYS.TABLE_SIZES_STATS_1HOUR.""" - dictionary = SpannerDataDictionary() + dictionary = SpannerSyncDataDictionary() driver = MockSpannerDriver() result = dictionary.get_system_metadata(cast(Any, driver), "table_sizes", include_system=True, table="Albums") @@ -91,7 +91,7 @@ def test_get_system_metadata_table_sizes() -> None: def test_get_system_metadata_gated_by_default() -> None: """get_system_metadata returns gated status without opt-in flags.""" - dictionary = SpannerDataDictionary() + dictionary = SpannerSyncDataDictionary() driver = MockSpannerDriver() result = dictionary.get_system_metadata(cast(Any, driver), "query_stats_top") @@ -101,7 +101,7 @@ def test_get_system_metadata_gated_by_default() -> None: def test_metadata_capabilities_postgresql_mode() -> None: """get_metadata_capabilities reports SUPPORTED for standard domains in PostgreSQL mode.""" - dictionary = SpannerDataDictionary() + dictionary = SpannerSyncDataDictionary() driver = MockSpannerDriver() profile = dictionary.get_metadata_capabilities(driver, mode="postgresql") @@ -113,8 +113,8 @@ def test_metadata_capabilities_postgresql_mode() -> None: def test_postgresql_mode_data_dictionary_routing() -> None: - """SpannerDataDictionary initialized with mode='postgresql' routes all queries to postgresql pack.""" - dictionary = SpannerDataDictionary(mode="postgresql") + """SpannerSyncDataDictionary initialized with mode='postgresql' routes all queries to postgresql pack.""" + dictionary = SpannerSyncDataDictionary(mode="postgresql") driver = MockSpannerDriver() dictionary.get_tables(cast(Any, driver)) diff --git a/tests/unit/adapters/test_spanner/test_spanner_query_options.py b/tests/unit/adapters/test_spanner/test_spanner_query_options.py index e4e973e1a..dcf9aa0c1 100644 --- a/tests/unit/adapters/test_spanner/test_spanner_query_options.py +++ b/tests/unit/adapters/test_spanner/test_spanner_query_options.py @@ -55,10 +55,9 @@ def test_config_provide_session_query_options() -> None: """Verify config.provide_session forwards query_options to driver_features.""" config = SpannerSyncConfig(connection_config={"project": "p", "instance_id": "i", "database_id": "d"}) query_opts = {"optimizer_version": "5"} - features = config._session_driver_features( - request_options=None, directed_read_options=None, query_options=query_opts, retry=None, timeout=None - ) - assert features["query_options"] == query_opts + context = config.provide_session(query_options=query_opts) + assert context._driver_features["query_options"] == query_opts # pyright: ignore[reportPrivateUsage] + assert "query_options" not in config.driver_features def test_driver_execute_many_omits_query_options() -> None: diff --git a/tests/unit/adapters/test_spanner/test_spanner_type_inference.py b/tests/unit/adapters/test_spanner/test_spanner_type_inference.py index 3ff2dd354..8bb811ae2 100644 --- a/tests/unit/adapters/test_spanner/test_spanner_type_inference.py +++ b/tests/unit/adapters/test_spanner/test_spanner_type_inference.py @@ -1,13 +1,35 @@ -"""Unit tests for Decimal and JSON parameter type inference.""" +"""Unit tests for Decimal, JSON, Vector (FLOAT32), and INTERVAL parameter type inference.""" +from datetime import timedelta from decimal import Decimal +import pytest +from google.cloud.spanner_v1.data_types import Interval from google.cloud.spanner_v1.types.type import TypeCode -from sqlspec.adapters.spanner.type_converter import infer_spanner_param_types +from sqlspec.adapters.spanner.type_converter import coerce_params_for_spanner, infer_spanner_param_types from sqlspec.core import TypedParameter +@pytest.mark.parametrize("duration", [timedelta(), timedelta(days=2, microseconds=123456), timedelta(microseconds=-1)]) +def test_coerce_interval_parameters(duration: timedelta) -> None: + expected = Interval(days=duration.days, nanos=(duration.seconds * 1_000_000 + duration.microseconds) * 1000) + params = { + "duration": duration, + "typed": TypedParameter(duration, semantic_name="INTERVAL"), + "durations": [None, duration], + "tuple_durations": (duration, None), + "null": TypedParameter(None, timedelta), + } + assert coerce_params_for_spanner(params) == { + "duration": expected, + "typed": expected, + "durations": [None, expected], + "tuple_durations": [expected, None], + "null": None, + } + + def test_infer_decimal_param_types() -> None: """Verify that non-null Decimal parameters infer as NUMERIC.""" params = {"price": Decimal("19.99")} @@ -36,3 +58,49 @@ def test_infer_boolean_array_param_types() -> None: types = infer_spanner_param_types({"flags": [True, False]}) assert types["flags"].code == TypeCode.ARRAY assert types["flags"].array_element_type.code == TypeCode.BOOL + + +def test_infer_and_coerce_float32_and_vector_params() -> None: + """Verify FLOAT32 and ARRAY/VECTOR TypedParameter inference and coercion.""" + params = { + "score": TypedParameter(0.25, semantic_name="FLOAT32"), + "embedding": TypedParameter([0.1, 0.2, 0.3], semantic_name="ARRAY"), + "vec": TypedParameter((1, 2, 3), semantic_name="VECTOR"), + "null_score": TypedParameter(None, semantic_name="FLOAT32"), + "null_embedding": TypedParameter(None, semantic_name="ARRAY"), + } + types = infer_spanner_param_types(params) + assert types["score"].code == TypeCode.FLOAT32 + assert types["embedding"].code == TypeCode.ARRAY + assert types["embedding"].array_element_type.code == TypeCode.FLOAT32 + assert types["vec"].code == TypeCode.ARRAY + assert types["vec"].array_element_type.code == TypeCode.FLOAT32 + assert types["null_score"].code == TypeCode.FLOAT32 + assert types["null_embedding"].code == TypeCode.ARRAY + assert types["null_embedding"].array_element_type.code == TypeCode.FLOAT32 + + coerced = coerce_params_for_spanner(params) + assert coerced is not None + assert coerced["score"] == 0.25 + assert isinstance(coerced["score"], float) + assert coerced["embedding"] == [0.1, 0.2, 0.3] + assert coerced["vec"] == [1.0, 2.0, 3.0] + assert all(isinstance(v, float) for v in coerced["vec"]) + assert coerced["null_score"] is None + assert coerced["null_embedding"] is None + + +def test_infer_interval_timedelta_params() -> None: + """Verify timedelta and list[timedelta] infer as INTERVAL and Array(INTERVAL).""" + params = { + "duration": timedelta(days=1, hours=2), + "durations": [timedelta(minutes=5), timedelta(minutes=10)], + "null_duration": TypedParameter(None, timedelta), + "declared_interval": TypedParameter(timedelta(seconds=30), semantic_name="INTERVAL"), + } + types = infer_spanner_param_types(params) + assert types["duration"].code == TypeCode.INTERVAL + assert types["durations"].code == TypeCode.ARRAY + assert types["durations"].array_element_type.code == TypeCode.INTERVAL + assert types["null_duration"].code == TypeCode.INTERVAL + assert types["declared_interval"].code == TypeCode.INTERVAL diff --git a/tests/unit/config/test_capability_flags.py b/tests/unit/config/test_capability_flags.py index edd9377b7..02f44a955 100644 --- a/tests/unit/config/test_capability_flags.py +++ b/tests/unit/config/test_capability_flags.py @@ -24,6 +24,7 @@ ("sqlspec.adapters.psycopg.config", "PsycopgSyncConfig"), ("sqlspec.adapters.pymssql.config", "PymssqlConfig"), ("sqlspec.adapters.pymysql.config", "PyMysqlConfig"), + ("sqlspec.adapters.spanner.config", "SpannerAsyncConfig"), ("sqlspec.adapters.spanner.config", "SpannerSyncConfig"), ("sqlspec.adapters.sqlite.config", "SqliteConfig"), ) diff --git a/tests/unit/config/test_connection_config_parameters.py b/tests/unit/config/test_connection_config_parameters.py index 8d4149704..9263cfc90 100644 --- a/tests/unit/config/test_connection_config_parameters.py +++ b/tests/unit/config/test_connection_config_parameters.py @@ -26,7 +26,7 @@ from sqlspec.adapters.oracledb.config import OracleAsyncConfig, OraclePoolParams, OracleSyncConfig from sqlspec.adapters.psqlpy.config import PsqlpyConfig, PsqlpyPoolParams from sqlspec.adapters.psycopg.config import PsycopgAsyncConfig, PsycopgPoolParams, PsycopgSyncConfig -from sqlspec.adapters.spanner.config import SpannerSyncConfig +from sqlspec.adapters.spanner.config import SpannerAsyncConfig, SpannerSyncConfig from sqlspec.adapters.sqlite.config import SqliteConfig, SqliteConnectionParams @@ -272,14 +272,15 @@ def test_adbc_config_accepts_connection_parameters() -> None: def test_spanner_config_accepts_connection_parameters() -> None: - """Test SpannerSyncConfig accepts connection_config and connection_instance.""" - config = SpannerSyncConfig( - connection_config={"instance_id": "test-instance", "database_id": "test-database"}, connection_instance=None - ) + """Test SpannerSyncConfig and SpannerAsyncConfig accept connection_config and connection_instance.""" + for config_cls in (SpannerSyncConfig, SpannerAsyncConfig): + config = config_cls( + connection_config={"instance_id": "test-instance", "database_id": "test-database"}, connection_instance=None + ) - assert config.connection_config["instance_id"] == "test-instance" - assert config.connection_config["database_id"] == "test-database" - assert config.connection_instance is None + assert config.connection_config["instance_id"] == "test-instance" + assert config.connection_config["database_id"] == "test-database" + assert config.connection_instance is None def test_connection_config_empty_dict_is_valid() -> None: @@ -427,6 +428,7 @@ def test_connection_config_parameter_naming_consistency() -> None: (DuckDBConfig, DuckDBPoolParams(database=":memory:"), "database"), # Converts :memory: to shared_db (BigQueryConfig, BigQueryConnectionParams(project="test-project"), None), (AdbcConfig, cast(AdbcConnectionParams, {"driver": "adbc_driver_sqlite"}), None), + (SpannerAsyncConfig, {"instance_id": "test", "database_id": "test"}, None), (SpannerSyncConfig, {"instance_id": "test", "database_id": "test"}, None), ] @@ -458,6 +460,7 @@ def test_connection_instance_parameter_naming_consistency() -> None: (DuckDBConfig, DuckDBPoolParams(database=":memory:")), (BigQueryConfig, BigQueryConnectionParams(project="test-project")), (AdbcConfig, cast(AdbcConnectionParams, {"driver": "adbc_driver_sqlite"})), + (SpannerAsyncConfig, {"instance_id": "test", "database_id": "test"}), (SpannerSyncConfig, {"instance_id": "test", "database_id": "test"}), ] diff --git a/tests/unit/config/test_provide_methods.py b/tests/unit/config/test_provide_methods.py index 7b1ddbf68..572be81b9 100644 --- a/tests/unit/config/test_provide_methods.py +++ b/tests/unit/config/test_provide_methods.py @@ -15,7 +15,7 @@ from sqlspec.adapters.psqlpy.config import PsqlpyConfig from sqlspec.adapters.psycopg.config import PsycopgAsyncConfig, PsycopgSyncConfig from sqlspec.adapters.pymysql.config import PyMysqlConfig -from sqlspec.adapters.spanner.config import SpannerSyncConfig +from sqlspec.adapters.spanner.config import SpannerAsyncConfig, SpannerSyncConfig from sqlspec.adapters.sqlite.config import SqliteConfig, SqliteConnectionContext from sqlspec.adapters.sqlite.driver import SqliteSessionContext from sqlspec.config import AsyncDatabaseConfig, SyncDatabaseConfig @@ -368,6 +368,7 @@ def test_template_only_adapters_inherit_base_provide_session(config_type: type[A CockroachPsycopgSyncConfig, CockroachPsycopgAsyncConfig, SpannerSyncConfig, + SpannerAsyncConfig, ], ) def test_specialized_adapters_keep_provide_session_override(config_type: type[Any]) -> None: diff --git a/tests/unit/core/test_splitter.py b/tests/unit/core/test_splitter.py index 720e9ed9f..ce29c3df8 100644 --- a/tests/unit/core/test_splitter.py +++ b/tests/unit/core/test_splitter.py @@ -51,10 +51,34 @@ def test_dialect_class_map_contains_expected_aliases() -> None: "sqlite": splitter_module.SQLiteDialectConfig, "duckdb": splitter_module.DuckDBDialectConfig, "bigquery": splitter_module.BigQueryDialectConfig, + "spanner": splitter_module.BigQueryDialectConfig, + "spangres": splitter_module.PostgreSQLDialectConfig, "db2": splitter_module.Db2DialectConfig, } +@pytest.mark.parametrize("dialect", ["mysql", "bigquery", "spanner"]) +def test_split_sql_script_keeps_terminators_inside_backtick_identifiers(dialect: str) -> None: + """Backtick-quoted identifiers, including doubled-backtick escapes, never end a statement.""" + script = "SELECT `a;b`, `c``;d` FROM t; SELECT 2" + + assert split_sql_script(script, dialect=dialect, strip_trailing_terminator=True) == [ + "SELECT `a;b`, `c``;d` FROM t", + "SELECT 2", + ] + + +def test_split_sql_script_spanner_keeps_quoted_terminators() -> None: + """Spanner scripts split on statement terminators outside backtick and triple-quoted literals.""" + script = 'CREATE TABLE `a;b` (id INT64) PRIMARY KEY (id);\nINSERT INTO `a;b` (id) VALUES (1);\nSELECT """x;y""";' + + assert split_sql_script(script, dialect="spanner", strip_trailing_terminator=True) == [ + "CREATE TABLE `a;b` (id INT64) PRIMARY KEY (id)", + "INSERT INTO `a;b` (id) VALUES (1)", + 'SELECT """x;y"""', + ] + + def test_split_sql_script_preserves_statement_output() -> None: """Statement splitting should preserve existing semicolon handling.""" assert split_sql_script("SELECT 1; SELECT 2;", strip_trailing_terminator=True) == ["SELECT 1", "SELECT 2"] diff --git a/tests/unit/data_dictionary/test_bigquery_spanner_metadata.py b/tests/unit/data_dictionary/test_bigquery_spanner_metadata.py index 47ff7f51c..c8441c1b5 100644 --- a/tests/unit/data_dictionary/test_bigquery_spanner_metadata.py +++ b/tests/unit/data_dictionary/test_bigquery_spanner_metadata.py @@ -1,9 +1,10 @@ """Unit tests for BigQuery and Spanner metadata packs.""" +from types import SimpleNamespace from typing import Any, cast from sqlspec.adapters.bigquery import data_dictionary as bigquery_data_dictionary -from sqlspec.adapters.spanner.data_dictionary import SpannerDataDictionary +from sqlspec.adapters.spanner.data_dictionary import SpannerSyncDataDictionary from sqlspec.data_dictionary import ( MetadataFidelity, MetadataRisk, @@ -25,16 +26,24 @@ def select(self, statement: Any, **kwargs: Any) -> list[dict[str, Any]]: return [] -class _FakeSpannerDatabase: - """Minimal Spanner database wrapper for Admin API DDL tests.""" +class _FakeDatabaseAdminApi: + """Minimal Database Admin API returning static DDL statements.""" def __init__(self) -> None: - self.get_ddl_calls = 0 + self.requested: list[str] = [] + + def get_database_ddl(self, *, database: str) -> SimpleNamespace: + self.requested.append(database) + return SimpleNamespace(statements=["CREATE TABLE Singers (SingerId INT64) PRIMARY KEY (SingerId)"]) + - def get_ddl(self) -> list[str]: - """Return static DDL statements.""" - self.get_ddl_calls += 1 - return ["CREATE TABLE Singers (SingerId INT64) PRIMARY KEY (SingerId)"] +class _FakeSpannerDatabase: + """Minimal Spanner database exposing its instance client's Admin API.""" + + def __init__(self) -> None: + self.name = "projects/p/instances/i/databases/d" + self.admin_api = _FakeDatabaseAdminApi() + self._instance = SimpleNamespace(_client=SimpleNamespace(database_admin_api=self.admin_api)) class _FakeSpannerDriver: @@ -114,7 +123,7 @@ def test_spanner_mode_does_not_reuse_googlesql_for_postgresql() -> None: googlesql = loader.get_domain_query("spanner", "tables", "by_schema", mode="googlesql") postgresql = loader.get_domain_query("spanner", "tables", "by_schema", mode="postgresql") - dictionary = SpannerDataDictionary() + dictionary = SpannerSyncDataDictionary() profile = dictionary.get_metadata_capabilities(cast(Any, object()), mode="postgresql") alias_profile = dictionary.get_metadata_capabilities(cast(Any, object()), mode="spanner_postgresql") unknown_profile = dictionary.get_metadata_capabilities(cast(Any, object()), mode="unknown") @@ -133,7 +142,7 @@ def test_spanner_mode_does_not_reuse_googlesql_for_postgresql() -> None: def test_spanner_nullable_schema_queries_cast_bind_as_string() -> None: """Spanner must not infer a nullable schema bind as both INT64 and STRING.""" - dictionary = SpannerDataDictionary() + dictionary = SpannerSyncDataDictionary() for domain in ("tables", "columns", "indexes", "foreign_keys"): query_text = dictionary.get_query_text(domain, "by_schema") @@ -145,7 +154,7 @@ def test_spanner_nullable_schema_queries_cast_bind_as_string() -> None: def test_spanner_get_ddl_uses_admin_api_capability() -> None: """Spanner native DDL is modeled as Admin API metadata, not SQL text.""" driver = _FakeSpannerDriver() - dictionary = SpannerDataDictionary() + dictionary = SpannerSyncDataDictionary() result = dictionary.get_ddl(cast(Any, driver), "Singers") ddl_capability = dictionary.get_metadata_capabilities(cast(Any, driver)).get("ddl") @@ -154,6 +163,6 @@ def test_spanner_get_ddl_uses_admin_api_capability() -> None: assert result.source == MetadataSource.NATIVE_API assert result.fidelity == MetadataFidelity.NATIVE assert result.ddl == "CREATE TABLE Singers (SingerId INT64) PRIMARY KEY (SingerId)" - assert driver.database.get_ddl_calls == 1 + assert driver.database.admin_api.requested == ["projects/p/instances/i/databases/d"] assert ddl_capability.source == MetadataSource.NATIVE_API assert MetadataRisk.PRIVILEGED in ddl_capability.risks diff --git a/tests/unit/data_dictionary/test_spanner_config.py b/tests/unit/data_dictionary/test_spanner_config.py index fdcddf17c..2c8b949d8 100644 --- a/tests/unit/data_dictionary/test_spanner_config.py +++ b/tests/unit/data_dictionary/test_spanner_config.py @@ -2,7 +2,7 @@ from sqlglot import exp -from sqlspec.adapters.spanner.data_dictionary import SpannerDataDictionary +from sqlspec.adapters.spanner.data_dictionary import SpannerSyncDataDictionary from sqlspec.data_dictionary import get_dialect_config from sqlspec.data_dictionary.dialects.spanner.config import SPANNER_CONFIG, SPANNER_FEATURE_FLAGS @@ -12,7 +12,7 @@ def test_spanner_supports_for_update_feature_flag() -> None: assert SPANNER_FEATURE_FLAGS["supports_for_update"] is False assert SPANNER_CONFIG.get_feature_flag("supports_for_update") is False - dictionary = SpannerDataDictionary() + dictionary = SpannerSyncDataDictionary() assert dictionary.get_feature_flag(None, "supports_for_update") is False diff --git a/tests/unit/dialects/test_spanner.py b/tests/unit/dialects/test_spanner.py index d3dd08dc3..d6c241f98 100644 --- a/tests/unit/dialects/test_spanner.py +++ b/tests/unit/dialects/test_spanner.py @@ -7,6 +7,7 @@ from pathlib import Path +import pytest from sqlglot import Dialect, exp, parse_one from sqlspec.dialects.spanner import _generators, _parsers @@ -214,3 +215,33 @@ def test_brace_hint_inside_string_literal_preserved() -> None: rendered = parsed.sql(dialect="spanner") assert rendered == "SELECT '@{FORCE_INDEX=Idx}' AS literal" assert "/*@" not in rendered + + +@pytest.mark.parametrize( + "sql", + [ + "INSERT INTO t (id, name) VALUES (1, 'a') THEN RETURN id, name", + "UPDATE t SET a = 1 WHERE id = 1 THEN RETURN WITH ACTION *", + "UPDATE t SET a = 1 WHERE id = 1 THEN RETURN WITH ACTION AS act a, id", + "DELETE FROM t WHERE TRUE THEN RETURN *", + "INSERT INTO t (id) SELECT CASE WHEN x THEN y END FROM s THEN RETURN id", + ], +) +def test_then_return_round_trips_as_returning(sql: str) -> None: + """Verify DML ``THEN RETURN`` parses into a Returning clause and renders back unchanged.""" + parsed = parse_one(sql, read="spanner") + + assert isinstance(parsed.args.get("returning"), exp.Returning) + assert parsed.sql(dialect="spanner") == sql + + +@pytest.mark.parametrize( + "sql", + ["SELECT CASE WHEN a THEN b END FROM t", "UPDATE t SET a = (SELECT CASE WHEN x THEN 1 END FROM s) WHERE TRUE"], +) +def test_then_outside_top_level_dml_is_unchanged(sql: str) -> None: + """Verify ``THEN`` in CASE expressions is never read as ``THEN RETURN``.""" + parsed = parse_one(sql, read="spanner") + + assert parsed.find(exp.Returning) is None + assert parsed.sql(dialect="spanner") == sql diff --git a/tests/unit/dialects/test_spanner_ddl.py b/tests/unit/dialects/test_spanner_ddl.py index 7ac5efed5..396671ec7 100644 --- a/tests/unit/dialects/test_spanner_ddl.py +++ b/tests/unit/dialects/test_spanner_ddl.py @@ -1,7 +1,10 @@ -"""Unit tests for Spanner DDL (STORED generated columns and CHANGE STREAM).""" +"""Unit tests for Spanner DDL (column types, defaults, STORED generated columns, CHANGE STREAM).""" +import pytest from sqlglot import exp, parse_one +from sqlspec.builder import sql as sql_builder + def test_generated_column_stored_enforcement() -> None: """Verify generated columns in Spanner DDL always emit STORED.""" @@ -56,3 +59,46 @@ def test_drop_change_stream() -> None: assert isinstance(parsed, exp.Drop) rendered = parsed.sql(dialect="spanner") assert "DROP CHANGE STREAM AllStream" in rendered + + +@pytest.mark.parametrize( + ("column_type", "expected"), + [ + ("STRING", "STRING(MAX)"), + ("STRING(64)", "STRING(64)"), + ("BYTES", "BYTES(MAX)"), + ("ARRAY", "ARRAY"), + ("INT64", "INT64"), + ], +) +def test_unsized_column_types_render_max_length(column_type: str, expected: str) -> None: + """Verify column definitions give unsized STRING and BYTES an explicit MAX length.""" + sql = parse_one(f"CREATE TABLE t (id INT64, c {column_type}) PRIMARY KEY (id)", read="spanner").sql( + dialect="spanner" + ) + + assert sql == f"CREATE TABLE t (id INT64, c {expected}) PRIMARY KEY (id)" + + +def test_cast_types_keep_unsized_form() -> None: + """Verify query casts keep the unsized STRING and BYTES forms.""" + sql = parse_one("SELECT CAST(a AS STRING), CAST(b AS BYTES) FROM t", read="spanner").sql(dialect="spanner") + + assert sql == "SELECT CAST(a AS STRING), CAST(b AS BYTES) FROM t" + + +def test_column_default_renders_parenthesized() -> None: + """Verify builder column defaults render inside the parentheses Spanner requires.""" + builder = sql_builder.create_table("events").column("id", "STRING(36)", primary_key=True) + builder = builder.column("created_at", "TIMESTAMP", default="CURRENT_TIMESTAMP", not_null=True) + + rendered = builder.build(dialect="spanner").sql + + assert "`created_at` TIMESTAMP NOT NULL DEFAULT (CURRENT_TIMESTAMP())" in rendered + + +def test_parenthesized_column_default_is_not_wrapped_twice() -> None: + """Verify an already parenthesized default round-trips unchanged.""" + statement = "CREATE TABLE t (id INT64, d STRING(MAX) DEFAULT (CAST(1 AS STRING))) PRIMARY KEY (id)" + + assert parse_one(statement, read="spanner").sql(dialect="spanner") == statement diff --git a/tests/unit/driver/test_data_dictionary.py b/tests/unit/driver/test_data_dictionary.py index e6a8beed4..ab3d16a67 100644 --- a/tests/unit/driver/test_data_dictionary.py +++ b/tests/unit/driver/test_data_dictionary.py @@ -22,7 +22,7 @@ from sqlspec.adapters.psqlpy.data_dictionary import PsqlpyDataDictionary from sqlspec.adapters.psycopg.data_dictionary import PsycopgAsyncDataDictionary, PsycopgSyncDataDictionary from sqlspec.adapters.pymysql.data_dictionary import PyMysqlDataDictionary -from sqlspec.adapters.spanner.data_dictionary import SpannerDataDictionary +from sqlspec.adapters.spanner.data_dictionary import SpannerSyncDataDictionary from sqlspec.adapters.sqlite.data_dictionary import SqliteDataDictionary from sqlspec.data_dictionary import IndexMetadata, VersionInfo from sqlspec.driver import SyncDriverAdapterBase @@ -95,7 +95,7 @@ def test_public_data_dictionary_classes_remain_constructible() -> None: PsycopgAsyncDataDictionary, PsycopgSyncDataDictionary, PyMysqlDataDictionary, - SpannerDataDictionary, + SpannerSyncDataDictionary, SqliteDataDictionary, ) diff --git a/tests/unit/extensions/test_adk/test_migrations.py b/tests/unit/extensions/test_adk/test_migrations.py index c788e2b0f..3e58beb42 100644 --- a/tests/unit/extensions/test_adk/test_migrations.py +++ b/tests/unit/extensions/test_adk/test_migrations.py @@ -2,7 +2,9 @@ """Tests for the packaged ADK schema migration.""" import importlib +from types import SimpleNamespace from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -10,6 +12,7 @@ from sqlspec.adapters.cockroach_asyncpg import CockroachAsyncpgConfig from sqlspec.adapters.psqlpy import PsqlpyConfig, PsqlpyPoolParams from sqlspec.adapters.psycopg import PsycopgAsyncConfig, PsycopgPoolParams +from sqlspec.adapters.spanner import SpannerAsyncConfig from sqlspec.adapters.sqlite import SqliteConfig from sqlspec.migrations.context import MigrationContext @@ -76,6 +79,33 @@ async def test_create_migration_emits_ddl_only_for_enabled_features(enable_sessi assert ("DROP TABLE IF EXISTS adk_memory" in down_statements) is enable_memory +async def test_create_migration_down_awaits_async_store_reset_drops() -> None: + """Rolling back through async stores emits only the drops their awaited reset hooks keep.""" + config = SpannerAsyncConfig( + connection_config={"project": "p", "instance_id": "i", "database_id": "d"}, + extension_config={"adk": {"enable_memory": True}}, + ) + database = MagicMock() + + async def _list_tables() -> Any: + yield SimpleNamespace(table_id="adk_event") + yield SimpleNamespace(table_id="adk_memory_entries") + + database.list_tables.side_effect = _list_tables + + with patch.object(SpannerAsyncConfig, "get_database", AsyncMock(return_value=database)): + statements = await migration.down(MigrationContext(config=config, dialect="spanner")) + + assert statements == [ + "DROP INDEX idx_adk_memory_entries_session", + "DROP INDEX idx_adk_memory_entries_app_scope_user_time", + "DROP INDEX idx_adk_memory_entries_scope", + "DROP TABLE adk_memory_entries", + "DROP INDEX idx_adk_event_timestamp", + "DROP TABLE adk_event", + ] + + async def test_create_migration_skips_store_resolution_for_disabled_features(monkeypatch: pytest.MonkeyPatch) -> None: """A disabled feature never resolves its store class in either direction.""" config = _sqlite_config({"enable_sessions": False, "enable_memory": False}) diff --git a/tests/unit/extensions/test_adk/test_store_config.py b/tests/unit/extensions/test_adk/test_store_config.py index 57f72aa53..fec7e93b8 100644 --- a/tests/unit/extensions/test_adk/test_store_config.py +++ b/tests/unit/extensions/test_adk/test_store_config.py @@ -406,10 +406,10 @@ def fake_log_with_context(logger: Any, level: int, event: str, **context: Any) - assert "db_system" in calls[0]["context"] -def test_session_store_reset_drop_tables_includes_legacy_metadata_table() -> None: +async def test_session_store_reset_drop_tables_includes_legacy_metadata_table() -> None: store = _AsyncSessionStore(_Config()) - statements = store._reset_drop_tables_sql() + statements = await store._reset_drop_tables_sql() assert "DROP TABLE IF EXISTS adk_internal_metadata" in statements assert "DROP TABLE IF EXISTS adk_metadata" in statements @@ -424,10 +424,10 @@ def test_session_store_reset_drop_tables_includes_legacy_metadata_table() -> Non assert store.metadata_table == "adk_internal_metadata" -def test_session_store_reset_drop_tables_does_not_duplicate_configured_legacy_metadata_table() -> None: +async def test_session_store_reset_drop_tables_does_not_duplicate_configured_legacy_metadata_table() -> None: store = _AsyncSessionStore(_Config({"metadata_table": "adk_metadata"})) - statements = store._reset_drop_tables_sql() + statements = await store._reset_drop_tables_sql() assert statements.count("DROP TABLE IF EXISTS adk_metadata") == 1 assert store.metadata_table == "adk_metadata" diff --git a/tests/unit/extensions/test_adk/test_store_instantiation.py b/tests/unit/extensions/test_adk/test_store_instantiation.py index 30d9bd449..1e6787868 100644 --- a/tests/unit/extensions/test_adk/test_store_instantiation.py +++ b/tests/unit/extensions/test_adk/test_store_instantiation.py @@ -40,6 +40,7 @@ "sqlspec.adapters.psycopg.adk.PsycopgSyncADKStore", "sqlspec.adapters.pymssql.adk.PymssqlADKStore", "sqlspec.adapters.pymysql.adk.PyMysqlADKStore", + "sqlspec.adapters.spanner.adk.SpannerAsyncADKStore", "sqlspec.adapters.spanner.adk.SpannerSyncADKStore", "sqlspec.adapters.sqlite.adk.SqliteADKStore", ] @@ -65,6 +66,7 @@ "sqlspec.adapters.psycopg.adk.PsycopgSyncADKMemoryStore", "sqlspec.adapters.pymssql.adk.PymssqlADKMemoryStore", "sqlspec.adapters.pymysql.adk.PyMysqlADKMemoryStore", + "sqlspec.adapters.spanner.adk.SpannerAsyncADKMemoryStore", "sqlspec.adapters.spanner.adk.SpannerSyncADKMemoryStore", "sqlspec.adapters.sqlite.adk.SqliteADKMemoryStore", ] diff --git a/uv.lock b/uv.lock index 96dc815b1..1b606f136 100644 --- a/uv.lock +++ b/uv.lock @@ -786,16 +786,16 @@ wheels = [ [[package]] name = "cattrs" -version = "26.2.0" +version = "26.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d6/b2/42f4524e5479b090040b5fd8bb316dd8c65a079bb6492494ce2079dc91be/cattrs-26.2.0.tar.gz", hash = "sha256:3cf49f69df8326bcf17a3cb3d3d3ec4a856858fe3a7473746c9044c317d3ba55", size = 524993, upload-time = "2026-09-06T20:31:58.055Z" } +sdist = { url = "https://files.pythonhosted.org/packages/23/75/e72b839c3dc869c990b4842f3dba730bdcdf5215f68fc7955edf849a1792/cattrs-26.2.1.tar.gz", hash = "sha256:679132bfdc225c5ee40c024fc42519954767c387f950dc6751946c586bccdc6d", size = 525617, upload-time = "2026-09-26T20:53:21.114Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/4d/6b8460462012a52075ba97932197f8c141be71b8e5f7e918af08ee73424b/cattrs-26.2.0-py3-none-any.whl", hash = "sha256:c680f17cd1df3a1c038ad1db8d90a9c595568cc54c4e8098e7ceb8d7b9bd826b", size = 74830, upload-time = "2026-09-06T20:31:56.327Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/22794a399d99480486120e26e879ef008e21f5e85274c2ed591d568bb326/cattrs-26.2.1-py3-none-any.whl", hash = "sha256:a12aaa3453dc8f633a815293179f08b7421ed18d2575c459c3c736f840beac24", size = 74843, upload-time = "2026-09-26T20:53:19.767Z" }, ] [[package]] @@ -1091,7 +1091,7 @@ wheels = [ [[package]] name = "click-extra" -version = "9.3.3" +version = "9.3.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "boltons" }, @@ -1104,9 +1104,9 @@ dependencies = [ { name = "wcmatch" }, { name = "wcwidth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/87/0c/728a88e0d35562f4751d7aa7cee8f6a41ab2425c5e6cf03cc1164a3de381/click_extra-9.3.3.tar.gz", hash = "sha256:6d81750fd943c44b9dd578c5cbb3955244666b404453baf3d1137c632761ffb5", size = 1680747, upload-time = "2026-09-19T15:49:43.487Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/c5/a856a174f091d796d6ad21ac531f20192485afcb321c35c7d260c31fdd3f/click_extra-9.3.5.tar.gz", hash = "sha256:92c90e7307867c56713a30230272e24d31dc52439dac76d555e68c6da2793d64", size = 1701948, upload-time = "2026-09-28T07:41:14.092Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/71/fb7a241a9285cf509285bff776dcecedb2402635bda5a05f2eb9f54e9248/click_extra-9.3.3-py3-none-any.whl", hash = "sha256:11c7d1459ffa746d62e477643919de1b8331fa78fe185fdf442d3fb44bc10872", size = 647592, upload-time = "2026-09-19T15:49:41.287Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3f/8e39d5ffe830eb20cee391e7682ffb2fb90fe5197b6b88276cb4c6ac8732/click_extra-9.3.5-py3-none-any.whl", hash = "sha256:693b42bb45228adb3dafed9d603fcf7b604f4a307169da7cde097df7d92db865", size = 652050, upload-time = "2026-09-28T07:41:11.858Z" }, ] [package.optional-dependencies] @@ -1174,130 +1174,130 @@ wheels = [ [[package]] name = "coverage" -version = "7.16.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/2d/c738872f477f5687152acae68635790387425d407ae37dd3d3a8a6692307/coverage-7.16.1.tar.gz", hash = "sha256:f83981779bcf9dfa06fa0a8d4cb43e0faec1706328ce07aa3e7b665b4ac0f210", size = 969651, upload-time = "2026-09-13T19:12:21.422Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/62/a8b4dd53308a4b1571e6bdd77ee562c9b1c1742db9886c0aef2f69463067/coverage-7.16.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4f12a9e27ca7b65e40a8475d27899b2d45064d9020e6a89148939e01987b5853", size = 223178, upload-time = "2026-09-13T19:08:21.805Z" }, - { url = "https://files.pythonhosted.org/packages/5e/7a/b9325b8d486a5c05fd4a9eb1cd9c9f697c361ddeda0e73567d5c2d8959f6/coverage-7.16.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f590d46c30d9e4c1fda3efefe5443f4c2f6a4192c5ca2ba403653e9ebadf097", size = 223701, upload-time = "2026-09-13T19:08:23.391Z" }, - { url = "https://files.pythonhosted.org/packages/ef/ee/3ab3ac6e9e7165a623695b73a83d1bfbdf5069681a68a012cb5abb096060/coverage-7.16.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3df82f0a3cef4e1bcfc799436056f0b978dda319d0bfd4460c6e479b2802d98a", size = 250434, upload-time = "2026-09-13T19:08:24.948Z" }, - { url = "https://files.pythonhosted.org/packages/b6/a9/31ffc231e9879876f611cf1ac7622cf092d76762c07ca64dce47b29a200b/coverage-7.16.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb462d59146656e278d1e8ed913ce374d0ba68a4e081acde1867f6d3377fc881", size = 252264, upload-time = "2026-09-13T19:08:26.734Z" }, - { url = "https://files.pythonhosted.org/packages/c9/f4/4fb4f6b16de07dbae3d3a111ae28d32ad657ce6ff4d364c49c10962daad5/coverage-7.16.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7db888dd0a1df1a653cae7f99d4047430d2187a3626eda51bc847b0fd6b9b43", size = 254127, upload-time = "2026-09-13T19:08:28.254Z" }, - { url = "https://files.pythonhosted.org/packages/9b/3d/048e88eb610f44b7542dac10fa45bb2ffad5fbfcdab0f03289a843da934b/coverage-7.16.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:38a7e16f061504ac2b45370bf5bf97e8250d8d3f25e37385bae884978166554b", size = 256043, upload-time = "2026-09-13T19:08:30.019Z" }, - { url = "https://files.pythonhosted.org/packages/97/59/d27b113c80b04c7fc64c69457132648e1d287b83a1de41189a504b50b52b/coverage-7.16.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b26b55b18e1a53e1a159dd743728c4ddbbef28ba19000a91aaff5ce023197ec", size = 251092, upload-time = "2026-09-13T19:08:31.57Z" }, - { url = "https://files.pythonhosted.org/packages/45/42/b7eafaceff08dc1dcbfc924fa9233031fd00c8b8287cc4a1a0dcdedcb0f9/coverage-7.16.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fdb2f528b50953e29d22033b3256c396a700193c6e45b2490222ef9c333cbbf9", size = 252170, upload-time = "2026-09-13T19:08:33.191Z" }, - { url = "https://files.pythonhosted.org/packages/7c/8f/57dcc5360aa6d8d24370c12fe855f6ebf5fbf2718c154fb9fdec7161de07/coverage-7.16.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3284754371dc78592aa3ae4d661d30ee20e02b2b6b0de3590a181a936d0d3b38", size = 250173, upload-time = "2026-09-13T19:08:34.75Z" }, - { url = "https://files.pythonhosted.org/packages/10/bf/379a8c7ac0f3063769dc43fd2475e9fc9b2722cb88eabe71f4d862660c93/coverage-7.16.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:802d1246c540e07486d4ee1adfa19a797e4b33529ffc371dc140644e8f27da0a", size = 253986, upload-time = "2026-09-13T19:08:36.737Z" }, - { url = "https://files.pythonhosted.org/packages/45/25/70218d2a6077730843b75ee2525fa64a750283d71cc4c063dc0b75ba6979/coverage-7.16.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:6fc735d6fe6d57f803e7ba021be4dde48e43e6aff94e6954350555d5332b0594", size = 250444, upload-time = "2026-09-13T19:08:38.434Z" }, - { url = "https://files.pythonhosted.org/packages/58/ac/6c41c441baa6c8a1fed5615e4e1e7d0ca649b33b472ee28591a92d70a1ff/coverage-7.16.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ed5ade1bb18f62edace1bd198c66f9d4c75a8385d5fd24e87d917ea1a5958773", size = 251046, upload-time = "2026-09-13T19:08:40.133Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f9/01166d6f8aa42acd148b4c2c88173d60eefdd0270ea2bcbba79be35bc193/coverage-7.16.1-cp310-cp310-win32.whl", hash = "sha256:0c309096926b119543dc16438a11ef4c80783d2f4e59ff94f7f462651a944cdc", size = 225242, upload-time = "2026-09-13T19:08:41.672Z" }, - { url = "https://files.pythonhosted.org/packages/d2/28/a76060478919becf0fbea2df2053a6e1a7195f11edf8dfd51c58c1636b43/coverage-7.16.1-cp310-cp310-win_amd64.whl", hash = "sha256:7562f8067ed9360e8b9739e5703403a7686dd1b36bf0f89fc538047c54cdea90", size = 225868, upload-time = "2026-09-13T19:08:43.22Z" }, - { url = "https://files.pythonhosted.org/packages/af/3a/d09495dfd5191b5593852bbe2f037afae768157443378d066d9ecea69a49/coverage-7.16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:72e013665e25cf9d44779f01f340af26319756f9a76822b7c94ce6b1d93813da", size = 223307, upload-time = "2026-09-13T19:08:44.914Z" }, - { url = "https://files.pythonhosted.org/packages/a3/34/310196a80258e28ebf6e5aa814a5a6ffbb18c03cf3c0c0c04191ed893e88/coverage-7.16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb05c0ff98b56ba6969adf35556bc43bcb8d094df8bc9cb403acff53460c4e07", size = 223817, upload-time = "2026-09-13T19:08:46.738Z" }, - { url = "https://files.pythonhosted.org/packages/73/29/ed0fda1fcd440cdfc07e72a07f3c9c3b43f65a6b3d53bff2cba9b9de50e8/coverage-7.16.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d0ececb32090e3fbb03e0d352b973a0485879b4de6c58daf47227b9988b99e5", size = 254222, upload-time = "2026-09-13T19:08:48.358Z" }, - { url = "https://files.pythonhosted.org/packages/ac/4e/5e2507fbd71ec1047620978f39568b0d2ed6c4468d08495a0c24f5677f65/coverage-7.16.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f0ba3892d81aacf36996c52f16bca04e39af31a6c5de930b7688ab617f4a6475", size = 256134, upload-time = "2026-09-13T19:08:49.926Z" }, - { url = "https://files.pythonhosted.org/packages/7e/b1/a9f97846554bc240473656f9c3874591105f29c08eccf4282daadc6bc281/coverage-7.16.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6410b75fe07d5271eaa95fc24bd0a9177ed588d9d1c10c0cf67829adb8f0567", size = 258240, upload-time = "2026-09-13T19:08:51.584Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c3/2b1ab208d06719394851cdd7dc322157870a93b178410b1bda6cac7755f9/coverage-7.16.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1039cb2de093225d597109342ce1675626bd127565e80b3044f4eca07c15b2e", size = 260202, upload-time = "2026-09-13T19:08:53.204Z" }, - { url = "https://files.pythonhosted.org/packages/af/14/ff4db31d6e3126d5b72aab1e868d35ec94e1b71dee46f5c3b54cb1b2438e/coverage-7.16.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cf047bc39fde5425be2628666d0f435ed8817859111c3aacc84b32d858069f5d", size = 254305, upload-time = "2026-09-13T19:08:54.833Z" }, - { url = "https://files.pythonhosted.org/packages/ce/27/c28faa4818f61c49ca3bfb4a77e1c191e79a40447504b89af0bd859cd6fc/coverage-7.16.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c05913d0d5badf7ac83200f35dcf9514cce5df16a7cc89e7d1d7fff0461813b", size = 255935, upload-time = "2026-09-13T19:08:56.453Z" }, - { url = "https://files.pythonhosted.org/packages/46/5a/42e64e5b716048cae33e2e15f8c6fe04a927467056c533b8e88900da895e/coverage-7.16.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4027bf6d7bc0a16df058ce913b69f10c5687f8e1ca668f08caa659ce101744bf", size = 253995, upload-time = "2026-09-13T19:08:58.159Z" }, - { url = "https://files.pythonhosted.org/packages/84/e5/860287acd5a1e29acd337f302b476b54148203590091b1a1555b2e914b50/coverage-7.16.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4f48b345f831eaf4402ab6333c2dc3e2e2b5bc7b9c1b8fe12680dee3f0538f01", size = 257767, upload-time = "2026-09-13T19:08:59.958Z" }, - { url = "https://files.pythonhosted.org/packages/a5/c5/4b68006da567b4b413352f891776c35776b3557a0479a28187ab57c21659/coverage-7.16.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8643baeb590726c558b2faed6cd59b0917480f9367fcc692026f1a86d824fd08", size = 253715, upload-time = "2026-09-13T19:09:01.778Z" }, - { url = "https://files.pythonhosted.org/packages/a1/0e/b2d0c47cfbf11770e197f1d0f11a92864eb29fd7eaca45bc9915573d9725/coverage-7.16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d06dcc420b570bf683cdb647cc8fe62b672d9e429ef711c3cbbb7a6880ca1572", size = 254624, upload-time = "2026-09-13T19:09:03.376Z" }, - { url = "https://files.pythonhosted.org/packages/2e/14/3bab8821b2b942155578fc34e88e21b0670d922f039ab23651f6035ba7f7/coverage-7.16.1-cp311-cp311-win32.whl", hash = "sha256:946f58aa59b08bcd6afcc6a7bd0ff54ed5eee844f32ad69fe6814836d15856a2", size = 225404, upload-time = "2026-09-13T19:09:05.174Z" }, - { url = "https://files.pythonhosted.org/packages/9b/ce/4f4ce667a97d1c538b46a79b89161395ad2beffc248b6c782afc9f521927/coverage-7.16.1-cp311-cp311-win_amd64.whl", hash = "sha256:684c7ee9b4c04358fe6ac8b517ab51ec35fcd79d08ff0f105dd8bcd96885bbb7", size = 225879, upload-time = "2026-09-13T19:09:06.81Z" }, - { url = "https://files.pythonhosted.org/packages/26/17/f3dce5e47351ab34522dd037fd64b111742958e24861bdd2820971bc1a26/coverage-7.16.1-cp311-cp311-win_arm64.whl", hash = "sha256:1b24f79e25bcf6c73931aeca7a3dfc7595c0cb5e9364aba3fdf387a3de4b1c22", size = 225428, upload-time = "2026-09-13T19:09:08.502Z" }, - { url = "https://files.pythonhosted.org/packages/1a/f7/7cd4c9f2a3b7574414222ec425d5eee21cc690d867a013315e3be8ba185c/coverage-7.16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b7f2c26ce6ce0b1e0ca0d5fae96ea510e3a2e78b7207f06e76b7f2c87fa3d0af", size = 223475, upload-time = "2026-09-13T19:09:10.145Z" }, - { url = "https://files.pythonhosted.org/packages/3e/f2/9ac65f9cedd43f91e2d3657c11f13aef82aebae89b2243dc9e44fe58a740/coverage-7.16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070acb9da788dff743a4d36fc015feee12d68f0349959017542017c79f59c21c", size = 223845, upload-time = "2026-09-13T19:09:11.988Z" }, - { url = "https://files.pythonhosted.org/packages/62/4d/f13db452d4367fe1122abfd98ae330596f65a3b40498f8e1a5811f68f123/coverage-7.16.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e366587b370bc9b8b51b7b7272c610c56db5d5b4795b9e4a29d28ff2f440f809", size = 255341, upload-time = "2026-09-13T19:09:13.708Z" }, - { url = "https://files.pythonhosted.org/packages/5d/6b/85ed86e82835a96ddfbe7705c387c28ce1cbbecd1b6d606f5ddfeeb712df/coverage-7.16.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e82e10b9d290f60b63459cfb245a841aec347603997206296b93881463a93dcf", size = 258078, upload-time = "2026-09-13T19:09:15.368Z" }, - { url = "https://files.pythonhosted.org/packages/cd/d2/f66945853d850b9c05f4e012e37396f1f31d0cd40d062394b229c22e7b56/coverage-7.16.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d73bb1f85c4150ac208fb0755beb04b2e44897bad81414de9380f98dd74729f", size = 259191, upload-time = "2026-09-13T19:09:17.046Z" }, - { url = "https://files.pythonhosted.org/packages/77/a1/7b907abe62461f289035c7ac60ab3e6334efb8c425151aac4abfa0c97820/coverage-7.16.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3397b9032553d281ad6a9253b12675b65e0cc8cd7a3b0633cf48872c9eb13360", size = 261452, upload-time = "2026-09-13T19:09:18.756Z" }, - { url = "https://files.pythonhosted.org/packages/51/e6/e26f4d6b1069a2a2fee3238a132f85c5b4c1dc25aebd88e015451ff0bd68/coverage-7.16.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77890395cf37026a5907d3ad32376aa51f41c0f163b7477fdbd4f94966cc1d08", size = 255698, upload-time = "2026-09-13T19:09:20.786Z" }, - { url = "https://files.pythonhosted.org/packages/7f/ec/99db7450e813050ebce300e31bbd4f997c76a8c6e3a6d168c5dffc104109/coverage-7.16.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b0944dc3bee3091039bf970d73caaf930c906013128a421bdc132e797494d941", size = 257111, upload-time = "2026-09-13T19:09:22.693Z" }, - { url = "https://files.pythonhosted.org/packages/2e/9d/500df9d3cd8c541ac84b5e0bcab00646be75654e05aa34e8410ec851282d/coverage-7.16.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b89d22a89d5bc05dd95b64e08295b8394aa96dc88e08f8ba210c9ebfebbe0489", size = 255258, upload-time = "2026-09-13T19:09:24.429Z" }, - { url = "https://files.pythonhosted.org/packages/c0/24/53318d96da332bb4946f8ac646fa19fd37fecbd0c49144735c716ac69bf0/coverage-7.16.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:550a2a1faf7559f13d5344f12d1eb886ad87955155d7dfab2a3fe5c8ec8fe776", size = 259326, upload-time = "2026-09-13T19:09:26.32Z" }, - { url = "https://files.pythonhosted.org/packages/c5/ce/abc0462b2e6ae96ae22197d2bc30fe33b6b4e52937e782745436ceb2a761/coverage-7.16.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:55eb268e5b81aefac759766c9162625b06c1bedb7b77d936225bafc4f038a6f6", size = 254827, upload-time = "2026-09-13T19:09:28.191Z" }, - { url = "https://files.pythonhosted.org/packages/8b/e1/0a04eedaaf19196b51f0180968134228c7646e469ed29914e48690a7cf3e/coverage-7.16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65a8fc80898c9ce59f04349fe8b4849b1f9787f14e52ead990e5f849ff4727a0", size = 256698, upload-time = "2026-09-13T19:09:29.922Z" }, - { url = "https://files.pythonhosted.org/packages/de/1d/d441a55cf22ce9d8e9e34814806c47441ab844bd534e0f4b64e1c6ae8bd1/coverage-7.16.1-cp312-cp312-win32.whl", hash = "sha256:528a61be40977c340cf201d23b69bd6a6bab507da60e9dbda85f8b30e935d70d", size = 225541, upload-time = "2026-09-13T19:09:31.752Z" }, - { url = "https://files.pythonhosted.org/packages/73/27/ec3d032375735dd331477caa051419678079ff90fcb53d0284a6c2bfb757/coverage-7.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:d0f02c633630e2b74522108ee95a84ad6e1204a8016a6cca5297f335ea27147e", size = 226075, upload-time = "2026-09-13T19:09:33.556Z" }, - { url = "https://files.pythonhosted.org/packages/94/00/90e9f5c4434878494306b9c0ee8068ebbd829483737d8cefccc58884d728/coverage-7.16.1-cp312-cp312-win_arm64.whl", hash = "sha256:2959978f9d1d20a2c0c15d0a68baaeccf615ac1aa214cf4a05a10d6f568926c8", size = 225461, upload-time = "2026-09-13T19:09:35.48Z" }, - { url = "https://files.pythonhosted.org/packages/aa/74/c08c0c4dc9fa6bcd1d90728a63660aa1b17b488a806948598456c48f75d1/coverage-7.16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ee5465db6e9152a7d09f3215309326878c6aa3ac509195a369f9d264ff4bfbd9", size = 223501, upload-time = "2026-09-13T19:09:37.276Z" }, - { url = "https://files.pythonhosted.org/packages/5b/c8/784986d326663285258a8e39835c46fb730cc85284f0dbcd82078586dd22/coverage-7.16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b8256f8b525ba233d2e4cdcdce0d6673c66fc9bf70df1fd5e67c54a74e2d245", size = 223876, upload-time = "2026-09-13T19:09:39.385Z" }, - { url = "https://files.pythonhosted.org/packages/15/76/73fb792928872bbb07e553f920ff55c65ee962c469265feb5d1d4ae5f97a/coverage-7.16.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d57cc400275b9a2892e905fc893f732b21ddb95271bf96406c88e2f6367848b5", size = 254863, upload-time = "2026-09-13T19:09:41.326Z" }, - { url = "https://files.pythonhosted.org/packages/ca/8d/1fd78899513244b065ccecdd1cfc6aa8b8f1bdee796d8c4f2aa8e8e5397d/coverage-7.16.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b3fd0f3435ebb7a7183b32a6062a8b755f08242ced1f3f22761d30b56b3c2a5", size = 257460, upload-time = "2026-09-13T19:09:43.086Z" }, - { url = "https://files.pythonhosted.org/packages/47/1c/b23ddfcb7ef9bd7b3fdb7be9a5dccf9925ae88e556df7aa5b655283c78f2/coverage-7.16.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5eb1762e7eb5fad34ef913e8107c7788a66f19d328e598ce95bf7217f9e5c8f", size = 258697, upload-time = "2026-09-13T19:09:45.161Z" }, - { url = "https://files.pythonhosted.org/packages/1c/c9/19d0c6ca35778a7e9415c30c46a0c64c9d4374219d004a35563132296896/coverage-7.16.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46cd3a73e9140410de62cceb66214bce0e08fb3922b9176fbfc1522fec151b41", size = 260827, upload-time = "2026-09-13T19:09:47.061Z" }, - { url = "https://files.pythonhosted.org/packages/97/fc/5862f7344382c62b85df43d483e579168cc062e007f54d895dfa51fe3e7d/coverage-7.16.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d1ba5142d68dd2cb775cbd0ac8601819298152047803c8efe4eec6d7d7aa7878", size = 255038, upload-time = "2026-09-13T19:09:48.945Z" }, - { url = "https://files.pythonhosted.org/packages/2e/9f/5c26583199a68df8d15124b4590520903f8eb7cef17db293fea712bb0783/coverage-7.16.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c389c6f9d1d518e1249ddcb8a7f158135644ce2c508fa6cc17b680777dad5bf2", size = 256827, upload-time = "2026-09-13T19:09:50.738Z" }, - { url = "https://files.pythonhosted.org/packages/95/13/605198bff079b107336710f28a797312ab132587168600d70a290e7ecd2e/coverage-7.16.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cdc57746c7ac0ea063351b4d651c3bb4dd4fd35e64dbb8e90c10e14eb03c4080", size = 254794, upload-time = "2026-09-13T19:09:52.577Z" }, - { url = "https://files.pythonhosted.org/packages/43/b7/0bbb32dc5ccdac766a13763fdfbffd7d73fc80349a69230c46c6b71e7508/coverage-7.16.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5597180ed7670cc94c04c65347418a467d3a43d5f0cf52fcac647f5425f42037", size = 258947, upload-time = "2026-09-13T19:09:54.372Z" }, - { url = "https://files.pythonhosted.org/packages/13/bd/65c31ddd43ff4e61b63721b3dad17cca412dba6e2ce89f92abdf75734339/coverage-7.16.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9647a0ac46255b8fef59a433a2161f03e5483f3a35e1cbd9dfe4600baff0c6b", size = 254613, upload-time = "2026-09-13T19:09:56.198Z" }, - { url = "https://files.pythonhosted.org/packages/25/20/d278115f2ba522b3e3128c6852be265f4e0df7202b2effca4db96cf0217d/coverage-7.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e306e98186b9cd109121f3583aeb7978797ad21d948f22944c5c08845cd554d0", size = 256388, upload-time = "2026-09-13T19:09:58.095Z" }, - { url = "https://files.pythonhosted.org/packages/e1/ca/d43aa396fb3a2f71c9411b99d926475f5acaf5c124f605c592865c80c8d5/coverage-7.16.1-cp313-cp313-win32.whl", hash = "sha256:48a78a66fcce49d7f6156524bf979c0ac633d584199717c68c6ffa949fc14e6a", size = 225550, upload-time = "2026-09-13T19:09:59.998Z" }, - { url = "https://files.pythonhosted.org/packages/0e/30/df2f6114f6a17cffe94721bd1d298f77f86aad493717fa5bc03cb291a1a6/coverage-7.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af03247d598a353bbbbe1b925deb735276e4d845e7197c4073dc89352b236fa", size = 226091, upload-time = "2026-09-13T19:10:02.331Z" }, - { url = "https://files.pythonhosted.org/packages/ee/31/6f90d8aab72a112492bd50e3b5485b53b772b1f5fa70da0a620e723caae6/coverage-7.16.1-cp313-cp313-win_arm64.whl", hash = "sha256:166adae25b05b04c9a84135912066d9c97482115af38df1a419a38aacc6b6f5d", size = 225481, upload-time = "2026-09-13T19:10:04.151Z" }, - { url = "https://files.pythonhosted.org/packages/8e/b4/2a7c793965bae9f067aabab793a44d7a2f3ee7fb16b01ce1976bbd4a0218/coverage-7.16.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:cc0b37fe6f5ce5f1ccc62ad4fa9b1ad201d8e9b6027fd5e0170877beee4b2d15", size = 223546, upload-time = "2026-09-13T19:10:06.019Z" }, - { url = "https://files.pythonhosted.org/packages/ef/e2/633469076a2dbbea036cc15a268a3a5d6b2c7dd5d9a9567b2553dfc5ad61/coverage-7.16.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6618f481053b63fc6121faf8fc676bd9b7163c2a19d9e984a2e850002c28ab57", size = 223881, upload-time = "2026-09-13T19:10:08.246Z" }, - { url = "https://files.pythonhosted.org/packages/de/c3/f06150c13284569d53273b909f31222874276a595637b7852571dfeb2c18/coverage-7.16.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa02d561eb1d8d2f8ba43ba6e3cef4c6c402a3b632a9460fa329fcadcd5df6a3", size = 254919, upload-time = "2026-09-13T19:10:10.254Z" }, - { url = "https://files.pythonhosted.org/packages/d5/40/47e25b215ae18a29010c8e29be8782a6e04d18ba6224be2bf6cebfce6427/coverage-7.16.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc5354a124799f1f87b7637bbe6f18cd4bc66a1f37f6aa2b5db40f9adad531dc", size = 257428, upload-time = "2026-09-13T19:10:12.124Z" }, - { url = "https://files.pythonhosted.org/packages/27/4b/1e2a4267d14cbd12a8489364a9d40020233e6be836d929b363f0e77209e2/coverage-7.16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34bafe9f4094315248573e6223e11af0ec1b25f9cbca43bf0e9a26a189ba2751", size = 258771, upload-time = "2026-09-13T19:10:14.031Z" }, - { url = "https://files.pythonhosted.org/packages/be/2e/9aa6146cea929fab9185bb2642ffef7f47520a6e5efe407f75f9b12f4cf0/coverage-7.16.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:29c4d3e32a3b5efa420a3dc627c7e570deb80ef997def52c7686a474f5edc7ab", size = 261086, upload-time = "2026-09-13T19:10:16.213Z" }, - { url = "https://files.pythonhosted.org/packages/13/3c/f9ad8bcd4fb3d21c9d20a16d6d6c6f999eee8f4498ed7659a3dbd2f4b74a/coverage-7.16.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2066c447fdd0bca39a9633a082d8ce67bf9a539a203b85059a364a405dc9fe9", size = 254895, upload-time = "2026-09-13T19:10:18.602Z" }, - { url = "https://files.pythonhosted.org/packages/b7/d1/47eda9fd1eaeea39fa7b5b13a63b2bed92ab901841fb120b3f9f5e1dc30c/coverage-7.16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd8ac10cd2458b3c6343aac082fb9bd0e3fa806cb2c4975f2280153474b88412", size = 256783, upload-time = "2026-09-13T19:10:20.778Z" }, - { url = "https://files.pythonhosted.org/packages/38/c3/565edf044877cb8cd3373c56885347ffc38f0edfd1f1679a487b208c19a8/coverage-7.16.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d8c54ec32e5c102b9241f75d88ae26538b53662868ca491736611db448d9c7a", size = 254742, upload-time = "2026-09-13T19:10:22.733Z" }, - { url = "https://files.pythonhosted.org/packages/fd/88/87d2b2aeaba719192b2089ff1c2cf89a06cf73a6d2e9f1f145626617700c/coverage-7.16.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6dd8dda3402a01a1a8fe8b753a282466f615128574a5590a9108acd07b1f8540", size = 259016, upload-time = "2026-09-13T19:10:24.769Z" }, - { url = "https://files.pythonhosted.org/packages/fc/1b/70813185b125768abdcf7899fec4d37edc2e5fc9b60c7045c8f4271ec757/coverage-7.16.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:79afa9726438912e5cddd1fe541815cea9763c92935f594835e4c432565b68a9", size = 254559, upload-time = "2026-09-13T19:10:26.781Z" }, - { url = "https://files.pythonhosted.org/packages/d8/fa/e7aa5af279aafda633a1ede8bfd7d6916b0c8b2082be86759e0b52e73a61/coverage-7.16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3db3978211c3cead5437a80136ca0556bab8bc7828de15a762884b0598c41361", size = 256215, upload-time = "2026-09-13T19:10:28.714Z" }, - { url = "https://files.pythonhosted.org/packages/38/87/7a894fa4f8c6662d2b6a87a3436950e15b1fa56e01765c9d6634fb2cbeb8/coverage-7.16.1-cp314-cp314-win32.whl", hash = "sha256:49c39c7068a494f8eb427155f5682f44feee43f9b3107fd54b1e52465379c54b", size = 225719, upload-time = "2026-09-13T19:10:30.743Z" }, - { url = "https://files.pythonhosted.org/packages/8b/01/fa7193c8005fb85488f02b0e1cc3c05a233cf2640206dd978af447aeecbf/coverage-7.16.1-cp314-cp314-win_amd64.whl", hash = "sha256:c510dad19552d912058e4c3e3cbec3fb155dbe8d0ce0ceb7e7dbf5c5822bae0b", size = 226208, upload-time = "2026-09-13T19:10:32.698Z" }, - { url = "https://files.pythonhosted.org/packages/da/5c/a08634c714924c3eaef811bb3576c044128aa5e7dfa86c75e52f0761849e/coverage-7.16.1-cp314-cp314-win_arm64.whl", hash = "sha256:b7d4d7e6dcaf33e85f1919f03346403bdcc27437c420a78835f3805bca0ab71f", size = 225633, upload-time = "2026-09-13T19:10:34.79Z" }, - { url = "https://files.pythonhosted.org/packages/43/df/ddb8a4c664046b1a0ee29c9c2d25b993e5dbc8fbde715df3694a64532781/coverage-7.16.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3d0a3681c12d3e0bcdea3d9414b04087828d6c1a482802d6f7f42c37ed530152", size = 224281, upload-time = "2026-09-13T19:10:36.853Z" }, - { url = "https://files.pythonhosted.org/packages/e2/d0/9076e0c762d8afd91182e60a520fa5c92c4a334785eeb9fd6b8ef8fe7e3c/coverage-7.16.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f3b4469d3da3ecced775d1a8c9c5d9fc80f259e30b7b89f9fed0700d6035ecb", size = 224547, upload-time = "2026-09-13T19:10:39.359Z" }, - { url = "https://files.pythonhosted.org/packages/03/e5/9c59e64b6161704f35fe91549bb19b2bb355e95caf596c26a2065564807c/coverage-7.16.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c08ae35c1be2fe1ce4b4c628df5c6fc0dc9a87f8e5fe8e20238d249678984741", size = 265906, upload-time = "2026-09-13T19:10:41.434Z" }, - { url = "https://files.pythonhosted.org/packages/57/5a/13ccaffb77f766101bf6f38be9dba9e468b02cc92da4552a57877dbf1c1f/coverage-7.16.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ee71a38c54bb2676bbe762b8b0943a79ccb1c2fd6a52054f66e63eda392f8c1", size = 268023, upload-time = "2026-09-13T19:10:43.533Z" }, - { url = "https://files.pythonhosted.org/packages/ad/a1/05cfcf01d3c7c922832698ad46e51d3441d820ce87a943014bb5cf5710dd/coverage-7.16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76491917771f179f9772efe218c5ccc65950dbdb35f4439298d8a8dfc6ec1f72", size = 270442, upload-time = "2026-09-13T19:10:45.895Z" }, - { url = "https://files.pythonhosted.org/packages/72/15/a2f1544b8e3835d7b769f7dabcc9ac0283e0b646ef3344703ff8f18d83e6/coverage-7.16.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4aa0b0a6f81fa3deb211e643f6954e78b4376b62b9c218271236cfa757664e8", size = 271565, upload-time = "2026-09-13T19:10:48.123Z" }, - { url = "https://files.pythonhosted.org/packages/df/5b/963c2993a82bd313f298d663afe03e164b96ace4d9d4c7561740a559e13d/coverage-7.16.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:756ba2d96d073c5a2a55d67fa22784763710fadbe22c41adde2d9cfa4dd78a8c", size = 264959, upload-time = "2026-09-13T19:10:50.195Z" }, - { url = "https://files.pythonhosted.org/packages/12/59/5eba06d1943735d7cd61d46d8c8a20ffe8ddd2da06b3c94366078dadeb9b/coverage-7.16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:99bf9ea435cefcefd220f8687c3ddbbf78dc2de0bd11b57c3ae9fbbdf8d5561a", size = 267897, upload-time = "2026-09-13T19:10:52.252Z" }, - { url = "https://files.pythonhosted.org/packages/bd/48/af6c30f6ea431bb9b83f9070d268a9cc4fc97490abd32080164177ea999f/coverage-7.16.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:35cbc81f937fc402971df45c897d2df2bfb2014efcd990360032aa0a651635da", size = 265504, upload-time = "2026-09-13T19:10:54.432Z" }, - { url = "https://files.pythonhosted.org/packages/80/f2/6e13852a8656d05fa83284567dd5a5b1e6d89bef79fe3effca2787159eab/coverage-7.16.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:8fae08e85b334ac6ac886002b5041396a31bcf805225bbe19847627203da99e2", size = 269235, upload-time = "2026-09-13T19:10:56.563Z" }, - { url = "https://files.pythonhosted.org/packages/c2/32/b4fe465daa64ece674f83a750dfa4ba0fa3c5c74d6ef5dbb8dfce892cf0d/coverage-7.16.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:83362b64e215ef00b0ba33fcf13655ace6c9fdd144d5ad2ab59ac86c2daf166e", size = 264347, upload-time = "2026-09-13T19:10:58.634Z" }, - { url = "https://files.pythonhosted.org/packages/54/f3/88b5c0e4ca3994c6d5feb7b1bf4c9a62cee205553159184968426930a7b1/coverage-7.16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:33300f2e140ccf26af3d8152e62bff71993f9310cfc63ba7a20940b0d246a0ae", size = 266660, upload-time = "2026-09-13T19:11:00.746Z" }, - { url = "https://files.pythonhosted.org/packages/97/72/6eff5456d7ba7f1c4678af531c33f9d957cae3201bd229b056fd13a204a3/coverage-7.16.1-cp314-cp314t-win32.whl", hash = "sha256:5539304fdbb2cc144df684d35a33b81145334d23e1c2367b5a923d25107f70b2", size = 226026, upload-time = "2026-09-13T19:11:02.846Z" }, - { url = "https://files.pythonhosted.org/packages/8e/c8/6e5ae3d8d4d0f2c0078985bf4db55fafd90e8107b1bf91ee3547a13f5694/coverage-7.16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:715dcb72c3280c428c3a20134b87e42c29acec9669136e899ab2de69ca86218d", size = 226862, upload-time = "2026-09-13T19:11:04.921Z" }, - { url = "https://files.pythonhosted.org/packages/be/c7/68f9f0734afc904a92b974b489545b6a15700f3b1c4bd36eae764561e661/coverage-7.16.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dac8b84c03e6029d272b8249c77018db83de59ca009a9adef7c144b4a62ee5e6", size = 226171, upload-time = "2026-09-13T19:11:06.969Z" }, - { url = "https://files.pythonhosted.org/packages/ae/16/e11addf5322d98e86307da9e00c94644b97cb72c534c74cda89705bebeef/coverage-7.16.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a337dc2d54c74430cd2febb8ee04f7c508ba8b3b412bf0463f077a66cfc73743", size = 223544, upload-time = "2026-09-13T19:11:09.108Z" }, - { url = "https://files.pythonhosted.org/packages/92/d9/7ccd6484f615961d94b09c75904f87d86375109596069ae3a495a205dbbe/coverage-7.16.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3acd1d78397dead78dd1b011b5fc19cc823c190349acd549e63856dff649c80e", size = 223882, upload-time = "2026-09-13T19:11:11.132Z" }, - { url = "https://files.pythonhosted.org/packages/59/37/2fd78152a557df4a7e4ad78d6851ede90c211838d526e4916c6016f45144/coverage-7.16.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73a32694603a34ad01d7e51a481a4023410d8099e1d0757e067945695c10f0ae", size = 254987, upload-time = "2026-09-13T19:11:13.735Z" }, - { url = "https://files.pythonhosted.org/packages/7a/4a/58e2f9b8b13cc422aa7f474a498945ec7f446ef473b5b6bae18d2981f2a9/coverage-7.16.1-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fbbe8265736659a6be2e6042b6a35be13545d14b243cc1d7ecf65f90d788a370", size = 257902, upload-time = "2026-09-13T19:11:16.025Z" }, - { url = "https://files.pythonhosted.org/packages/3c/74/63f8f6a67c03b86455c726be273b2aa5b57fd1122f22350d141bbb142e91/coverage-7.16.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b0359eb4c62f9993e176bc8f50450fc736a6b90dbcc05bb8584e948812699ae", size = 259522, upload-time = "2026-09-13T19:11:18.206Z" }, - { url = "https://files.pythonhosted.org/packages/e2/3b/1f2261da7483e345fe55de11e41ffc16a543511e37c0912134c0dccc4f6c/coverage-7.16.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:da506e669a8a851b59e122b4b219ea70996a6296f44f3a9348a852526ff961de", size = 261723, upload-time = "2026-09-13T19:11:20.628Z" }, - { url = "https://files.pythonhosted.org/packages/a7/8a/7eb1a361e044b7293f49c921a974b532627ed37f3bef5e7e9f58bcd0d5a2/coverage-7.16.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:64a2a5985d81810ed605ff0dc4ccd6555efcb5700353825532a9a0aea65826e1", size = 255462, upload-time = "2026-09-13T19:11:22.733Z" }, - { url = "https://files.pythonhosted.org/packages/67/66/6d94f7ea87e99c519def5cfa5d4a1ab20247d6b403eb6e6b5b63d6aa5985/coverage-7.16.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:66d70132b69b861805dc1ca46cdd733e54c416890e8f1371d2fd103f70b59c9c", size = 257617, upload-time = "2026-09-13T19:11:24.933Z" }, - { url = "https://files.pythonhosted.org/packages/3d/52/c3de0a3868589a1463a4f1cb0222eca0e5fa9f91da865ccf20d2e19ec19c/coverage-7.16.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:db651a9cf325a542bc2b7b8cc8f1b2bdc6492739bae3103731b2f1c85b96cff6", size = 255495, upload-time = "2026-09-13T19:11:27.158Z" }, - { url = "https://files.pythonhosted.org/packages/9e/a3/ea10e75f20fc1e7be10b166a494826a965d19b1dc9c8a81c511a01153311/coverage-7.16.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:4184e78a4465dcda359fb403172b8951dd220929cb0984c02fabca1742fff06f", size = 259728, upload-time = "2026-09-13T19:11:29.33Z" }, - { url = "https://files.pythonhosted.org/packages/0e/96/9d8020d97de46f0390739beda196322586cdd87e1fc357223e6a28bc4135/coverage-7.16.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:bc53c3f3adaa939b7a063533ffe0ae1259e7073393c618043a99a6970a87e3df", size = 254904, upload-time = "2026-09-13T19:11:31.86Z" }, - { url = "https://files.pythonhosted.org/packages/5b/21/2ff8867d4560f4f639a82d8fcf13c27eea88a96193daba096027c48bb929/coverage-7.16.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b44308854ef210b9b78df9cdfd4e159513382a859f5ef8464306d14a54c2a040", size = 256828, upload-time = "2026-09-13T19:11:34.078Z" }, - { url = "https://files.pythonhosted.org/packages/6a/4b/feacad51cb5163e3baa13281f8a3098f15d7934cc1f28cfe656557eb4e46/coverage-7.16.1-cp315-cp315-win32.whl", hash = "sha256:dccc142614d3419ed71857deb43f1d757829a4c7fce9994464b71e7e38309827", size = 225722, upload-time = "2026-09-13T19:11:36.314Z" }, - { url = "https://files.pythonhosted.org/packages/39/34/66bbc6ffd3c51fa67604c566920e772c5e3baa57f88dcf7b109c7f18b2cb/coverage-7.16.1-cp315-cp315-win_amd64.whl", hash = "sha256:961fc424e9d5229a99f8f1189942d8e7f4e1519147c3af64842f944aca03914d", size = 226195, upload-time = "2026-09-13T19:11:38.493Z" }, - { url = "https://files.pythonhosted.org/packages/f7/96/4bad920d4caed127c37c136a64b0730fa495517d2eb58809e0d81e6e7dff/coverage-7.16.1-cp315-cp315-win_arm64.whl", hash = "sha256:681a9488c5a234397c4f013da065aa9e53eb7af4c78f1f80c6f15e7208acb855", size = 225625, upload-time = "2026-09-13T19:11:40.664Z" }, - { url = "https://files.pythonhosted.org/packages/f5/2b/a88c7906eeb7da4394e932060d7008e153b3a2cd8d11782d82faa03bb7a3/coverage-7.16.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:8bb09a2d19b04db1fa0e087a7ca4f12458f7e0e7364cfcd838441d86fb1c61f6", size = 224271, upload-time = "2026-09-13T19:11:42.876Z" }, - { url = "https://files.pythonhosted.org/packages/1f/1f/98db59c595680d16f553890deac6d72610e04f192c1964ec9b69cbe790ab/coverage-7.16.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2270a794600b635ca9452ce4c32e2fe81a35f9caa17ffac0eba99f14f275bd4d", size = 224564, upload-time = "2026-09-13T19:11:45.071Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e8/699e236d4cb97ec282b0655afc85acf12349e71a02fff0f1a7d0fb643079/coverage-7.16.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a4eff405b545dfcf79cf0d9d3ff750e5c5a887aa066114175193a81d249c5ee6", size = 265423, upload-time = "2026-09-13T19:11:47.666Z" }, - { url = "https://files.pythonhosted.org/packages/96/1b/6eaa21912863b3e3bf23cfe605fb62929bb1a41dc33d2bfa8e92000232bd/coverage-7.16.1-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ee1d5fc9e3bd6a217906929cc97880239a91d20dae7746f538eb0eefee705ab1", size = 268503, upload-time = "2026-09-13T19:11:50.12Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ac/4eb46bffa98c28a80559a58c12f17e19308d52b31174af1bd49decf76606/coverage-7.16.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4ec944947de098ad5a1738413f9364689a57067ecbc328e9de37218aa1e5cc1", size = 271059, upload-time = "2026-09-13T19:11:52.363Z" }, - { url = "https://files.pythonhosted.org/packages/ed/d4/17a51ef1f6a084c9abbead522cfbee0fe9d29a593128781e724b7a0795b3/coverage-7.16.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3f73ee3956fde2d461c9e2955dd48166e4821fc8587d135e8780fb84da2a098b", size = 272039, upload-time = "2026-09-13T19:11:54.935Z" }, - { url = "https://files.pythonhosted.org/packages/4a/ff/9fdeba8b8aa0848030f45f869ddedc306795a8c6e53e901b8553afbf5415/coverage-7.16.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ec9a4ee989c0d06ad95add0dbfdbb72b00ef53f43431ca0b612384e7878e5de", size = 265869, upload-time = "2026-09-13T19:11:57.423Z" }, - { url = "https://files.pythonhosted.org/packages/95/eb/ab3eb506b2e4dd278440fbbbc7ed18424a86d8a981db0b90fdba66f5d34d/coverage-7.16.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7e5727b2508f817f3126d6c33327dda32fe69d15514badfcd61db8bc4209ecef", size = 268883, upload-time = "2026-09-13T19:11:59.71Z" }, - { url = "https://files.pythonhosted.org/packages/d5/0c/f4edefdce33f01954a74237df5ee83ccef5dcd1165e04abf0adf47543fca/coverage-7.16.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:19a3ea2f364012ef06678118fffdc92442a16bef4a5c8ad4f4019dd8f9ac8876", size = 265359, upload-time = "2026-09-13T19:12:01.953Z" }, - { url = "https://files.pythonhosted.org/packages/df/86/ce5ed885fc7ce2adc9a5971134ace257d0f84f19c3d117da89c179b9662e/coverage-7.16.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:996c2b891b441ec2b39725ee3e8386e2f11b4894b92be225fdfd54a3eeada2c8", size = 270056, upload-time = "2026-09-13T19:12:04.46Z" }, - { url = "https://files.pythonhosted.org/packages/48/a1/5dbd5f95070cece25c6df9e6ea4ded2a87dc695ce82a49bcb2116452b342/coverage-7.16.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:a125fac1f6b1e88488d208a86b578e1790e3c4937f2e1568d23356141d236220", size = 265498, upload-time = "2026-09-13T19:12:06.811Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ff/254b26f3300f87c7c53ac97da41ae75f0e2ac066aa964bc073c0f3750b38/coverage-7.16.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b10095528b866d322d33d6bf1709b7f8cbf959f12e8cb2ba22fc59c8717866b0", size = 267459, upload-time = "2026-09-13T19:12:09.479Z" }, - { url = "https://files.pythonhosted.org/packages/61/20/bf9958f8ddbbf6a2d39da3d4009f33e0fcf19c2d2fd99715c6494cdc8e8c/coverage-7.16.1-cp315-cp315t-win32.whl", hash = "sha256:531d9be377fdcc05593b974656872eb82e808ebeb42a72515e3aaeb8bb7166f5", size = 226021, upload-time = "2026-09-13T19:12:11.933Z" }, - { url = "https://files.pythonhosted.org/packages/35/8f/12d46948704d9e437c8dca2718e5d54ecc2f85396b64b500c29d8dbe10db/coverage-7.16.1-cp315-cp315t-win_amd64.whl", hash = "sha256:46a88f51770df7c9bc376bd57d3f86cdc7624b8e16ac4b585a655c22b7a1b4db", size = 226853, upload-time = "2026-09-13T19:12:14.254Z" }, - { url = "https://files.pythonhosted.org/packages/a5/7f/1ca5fd0601054fce5a3539375b997e67683646e2bf62f60c41e70b529fcc/coverage-7.16.1-cp315-cp315t-win_arm64.whl", hash = "sha256:7580432cbe1e8b762660ae5806f04f869e1c02e519836a43f8094437e561e9f0", size = 226163, upload-time = "2026-09-13T19:12:16.589Z" }, - { url = "https://files.pythonhosted.org/packages/96/1a/d6d16babd0a5fe4c3fae40702158c570351694e74516d8d81b86c5637448/coverage-7.16.1-py3-none-any.whl", hash = "sha256:3d8bd4e58b6a5c2018d808f297905393c6c61da466a48c3f0596a76a4900ebe4", size = 215264, upload-time = "2026-09-13T19:12:18.895Z" }, +version = "7.16.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/55/d1eaf3e73781174340a00dc1ba2aee8a65f82fadb18e2797b192b6b3925b/coverage-7.16.2.tar.gz", hash = "sha256:ca64d9f1f384f151b9511bec01126072acd2f313439f8ed015a22d8790aab6fa", size = 971999, upload-time = "2026-09-27T12:29:01.118Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/72/db17bf5f87568ab524413693385be2eeb03e652ab56c54ea05fa85515675/coverage-7.16.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:23219888477edd736b6fcaec1272d47d93b926e999641ffea7e53a1738e70b2b", size = 223670, upload-time = "2026-09-27T12:25:34.675Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e1/285727a8a74d48de256e8605413ee4cf82301228253ea58e5ab4270138b0/coverage-7.16.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:40c0f00899fe6181ae7f434ceb200e51f5ee4b8ed10e3b5f0b605f0cae15da87", size = 224191, upload-time = "2026-09-27T12:25:36.476Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/c737b73bac9bf5034f5ff45a4237b7a189d417a4e36751faf5d10c082cbe/coverage-7.16.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a4624f80732f6b427ac58f1f59c577a0994a12e8174b5af6a027b4b58795d4c3", size = 250921, upload-time = "2026-09-27T12:25:37.857Z" }, + { url = "https://files.pythonhosted.org/packages/29/4e/e1d38d27817d91776ca543d01276a67eace3df675a673cac32b12c167d58/coverage-7.16.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:191803c4996b499fcd78c2ad5e5f767dcc53cb4dc6de6d6a741b443a1821ef02", size = 252751, upload-time = "2026-09-27T12:25:39.291Z" }, + { url = "https://files.pythonhosted.org/packages/0e/19/14a8e44cbb2ad36ae62aaa03c5eec4a06a7f7df220ea3776af26927acbe2/coverage-7.16.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fd670ac43b709c575aefc25bf52d8a598a3bc5017bddfd0a179152ab06a2deb", size = 254616, upload-time = "2026-09-27T12:25:40.506Z" }, + { url = "https://files.pythonhosted.org/packages/f9/72/f5bcad0d9a9b450080032344fbff7ec60c1b0e3d38019f7735dee2b69645/coverage-7.16.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:705e5af11d34647efdc170c7840b6857c81cf74be96419a553f237e68e62cb72", size = 256541, upload-time = "2026-09-27T12:25:41.785Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9a/8c735234e8abb52bf5d063f98c780fa942e77c4c0225f9fcb9b33effc346/coverage-7.16.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8afd9bf35cc6a1f22eb3634808fa8e0b91902459c5721ef2e4461dfe771d7f08", size = 251589, upload-time = "2026-09-27T12:25:43.269Z" }, + { url = "https://files.pythonhosted.org/packages/35/6a/1bf6d32e55642d6972aa842640e0a8850e612d19ac7d05e2c32fa59dfd34/coverage-7.16.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3f43bac1856ba269b905302778d4df433d6006489a192174ad77ac528e395032", size = 252660, upload-time = "2026-09-27T12:25:45.051Z" }, + { url = "https://files.pythonhosted.org/packages/53/5b/05b1c0d0e9495cb056155a16066259adc1e935e7411a82a62779bf729975/coverage-7.16.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f8475460aa33ee28ac896ab1156d0bb3b6c639f7f8383c2677d3359eb35f8205", size = 250660, upload-time = "2026-09-27T12:25:46.51Z" }, + { url = "https://files.pythonhosted.org/packages/05/33/5bc3db57fc9c56b4ef055725c38c34faa818a27d17d65e251371f7f0e3a0/coverage-7.16.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d6276d78f6fca7d0ac066d5da4165c5acd07829e8305c2cb900b738fb3a75a72", size = 254479, upload-time = "2026-09-27T12:25:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0f/4a5de66daef26eb919213f63b84f45d5065ab2df94cd767fc7c1174ac5e2/coverage-7.16.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:736fde09ea39646d11f8e3b76bd3425c075aa4dd45f24891970bb77c14ff20f5", size = 250940, upload-time = "2026-09-27T12:25:49.227Z" }, + { url = "https://files.pythonhosted.org/packages/57/d3/84cd6a11e707e422194739e9734d947746efa5e5b3358ff2d601f823ee45/coverage-7.16.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c85d54e7e8a2ca932fe8399301af9b8d5907ea2a455ffaff6e7d1208db83b943", size = 251543, upload-time = "2026-09-27T12:25:50.554Z" }, + { url = "https://files.pythonhosted.org/packages/ab/bc/3d84c2e2a95f38e346ce3730d9f0e53114f4e8aace72e6e51e014afb9992/coverage-7.16.2-cp310-cp310-win32.whl", hash = "sha256:5139009b5efd2194fc168ee9362f0e191ba612ef5d29242f9269c22f9b8f80c7", size = 225732, upload-time = "2026-09-27T12:25:51.945Z" }, + { url = "https://files.pythonhosted.org/packages/07/86/31f1f3170571a345ab9d8361a7b2f8e0c5698173fbecd1f7149b6e2089ee/coverage-7.16.2-cp310-cp310-win_amd64.whl", hash = "sha256:c3305c38a2fa21a4254f2ace7dd9ef5fc569c9a558b66e7017650b3d637fb95e", size = 226357, upload-time = "2026-09-27T12:25:53.272Z" }, + { url = "https://files.pythonhosted.org/packages/58/fa/ce3baf63d85b730398d92a7162f486f3a5e4e2cc3382a02488b3943725ba/coverage-7.16.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:732d950e51f3ba4fb6209c73250f3e8924fefca42953ee04a9e65d8c02414d7d", size = 223797, upload-time = "2026-09-27T12:25:54.756Z" }, + { url = "https://files.pythonhosted.org/packages/7a/57/9ba29c2aac7f756d479f03d45762120060f0f988788001001bf36e0e6fca/coverage-7.16.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5dca0bb66b4c3d624ba047887bf70270030c150692d543cb501293dc38a9f4b5", size = 224303, upload-time = "2026-09-27T12:25:56.214Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/0d6d60906dca7d28cc1e3fce12a9861801c4fbb6cbf220ad78cd059c9467/coverage-7.16.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:af2a2a8c7c74de0559e0c368d94c8def9e16c58faaee33a0bf081057c4227e3b", size = 254715, upload-time = "2026-09-27T12:25:57.755Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b8/9198b865679379fb165c689c64f6e11105ef380f6bd1c7673e83f73d9f5c/coverage-7.16.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db5f8394e17f877a625b257f2ba0ce8e728a499c2c1579ad66220272cd3df510", size = 256627, upload-time = "2026-09-27T12:25:59.131Z" }, + { url = "https://files.pythonhosted.org/packages/98/79/9521462cb6072fe394701bc8974b74afd576c9c9355156c7844e1a86a42b/coverage-7.16.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3146d2317c75f70df2509066d979dadd941f7021cdf9b5db4bcd8568258e25", size = 258724, upload-time = "2026-09-27T12:26:00.691Z" }, + { url = "https://files.pythonhosted.org/packages/a6/76/8d7d5d633db9fe0f3182fedc731bf09f9bcf2366055735152504ad614677/coverage-7.16.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e1d0ced76318bab499693ff25f64faa343415187cb2e4d7befdfdd391a1cf6a", size = 260692, upload-time = "2026-09-27T12:26:02.083Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a7/76cb09c89ba46d74d37428bf93251fc14fb0bbe9e05cc2a5ef61773d318a/coverage-7.16.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:af98ad5ed9d6daaca956201e00bb429a7eb2b080426686f70a20353e0f9839f5", size = 254800, upload-time = "2026-09-27T12:26:03.369Z" }, + { url = "https://files.pythonhosted.org/packages/72/b6/2351c1979aaeb5b4a8091a75b90ca997ad60de36e181ddba267cf61dac97/coverage-7.16.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d56e4d21c56d2046447733f8b118409597db48c01efe898ee9ac24e858ec2d6", size = 256423, upload-time = "2026-09-27T12:26:04.751Z" }, + { url = "https://files.pythonhosted.org/packages/0f/f4/ad9a4f8b5cb2d494fa9452b546fe742ed2f9d3847cc14c05e36279a3e649/coverage-7.16.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1d5d0e3b660506fb84f995814e3118a21efdc0c8eb80127da1be627d90093c17", size = 254487, upload-time = "2026-09-27T12:26:06.082Z" }, + { url = "https://files.pythonhosted.org/packages/6c/1f/a520470472f3e8b01169bf42162b1470c9ba992230432f62ca36269bf3a0/coverage-7.16.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:17228fbca0f22976f797be94e975dcd237799c657d49551c7de1e0654d1202e9", size = 258259, upload-time = "2026-09-27T12:26:07.513Z" }, + { url = "https://files.pythonhosted.org/packages/09/d2/ff26d5938274745855fa61cfcba0245c88ccc10d98d2cbd96064f16cd5a7/coverage-7.16.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:bc0b0ac781d489304b741269857f1f8338b7a26b1b89c06c0344658001ec0035", size = 254216, upload-time = "2026-09-27T12:26:08.982Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1d/5d832d3b06785d9f53267e4f2724a9f60c312eee6ebed9063a461d0d3b45/coverage-7.16.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bf1bd822ec4e387ed245bed0d71151582cf7be9e5309bc4145eefe36083d5878", size = 255125, upload-time = "2026-09-27T12:26:10.35Z" }, + { url = "https://files.pythonhosted.org/packages/55/4d/1d33edbc2fcf7d99e384e393e712aa5a2ebbbd8409825357815982207976/coverage-7.16.2-cp311-cp311-win32.whl", hash = "sha256:7ed238d227e23cc300c3d464babdaf9f6ddc740aa1b15a77ae96136e6a7c4516", size = 225898, upload-time = "2026-09-27T12:26:11.7Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7c/676df4882118756c4f8f560c954eddb93e166d84dda8c5f0b6a829689bde/coverage-7.16.2-cp311-cp311-win_amd64.whl", hash = "sha256:a90700f743e29aa3d75a6ff5f01953176a889c00e526194bc4d281731b88d99d", size = 226369, upload-time = "2026-09-27T12:26:13.375Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0e/a457f4a461b3c5610d845137fdd45fa465e011a64c25af440518ab1f4e41/coverage-7.16.2-cp311-cp311-win_arm64.whl", hash = "sha256:a336eec40e3520d369b8a6cdabb4f596e69a8b42927ca074aa1452fed943238a", size = 226344, upload-time = "2026-09-27T12:26:15.127Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/f8296c63c5d542f3d21aed685e56b7031a419037d155bb3382fc0940d249/coverage-7.16.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:218d742afca2b5ad5ca759e93eddedfbcc6eadf8322f080dcefc40b7bd4e2d48", size = 223969, upload-time = "2026-09-27T12:26:16.753Z" }, + { url = "https://files.pythonhosted.org/packages/90/23/6f3dcb1423a0d43216e402ea1746e4a7c7c44f38896b97dd573790f56a40/coverage-7.16.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9a638be322a8d76a41cdb17781c7f82aaee6a66493d8ffb7e2c09ee22423d99", size = 224328, upload-time = "2026-09-27T12:26:18.15Z" }, + { url = "https://files.pythonhosted.org/packages/ac/7d/8f3b6dc920e3fc6732f7678785a2091db439f186afbec30dbf2214d9b1f7/coverage-7.16.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:724bd0f1e81856b35e59fc98cf7b4e544a3cb662e4e0864dca73d4326ee9d808", size = 255832, upload-time = "2026-09-27T12:26:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/d1/36/6c45f15be4eca4ac1062c6a55a323286494c99726a7e58951fe85967ac08/coverage-7.16.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5375ebd99038021b35e99dc88255022912c06565d316212f4a576e4b08d30f5d", size = 258571, upload-time = "2026-09-27T12:26:21.199Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/b54cbeba3ad89082c2e441278681859e538322cc34b84b2af7ebff00080f/coverage-7.16.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a076277ca9f5750cc230f0f578ebd2620cec60255b25707361699fef6fb465c", size = 259680, upload-time = "2026-09-27T12:26:22.822Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a2/0dc65ec3d61930e1e4c2e371763b15eb4290896eb343a12d5d3091308116/coverage-7.16.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:58d4a54c6ea672afef66d49be922a2c69826c5ae1a42a9cd94f0c9c2bacdf800", size = 261945, upload-time = "2026-09-27T12:26:24.336Z" }, + { url = "https://files.pythonhosted.org/packages/d6/93/5fad7a61f2c14e08e98946fc31c1c7ffc1195061bf3fdc351db3be77a863/coverage-7.16.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcbcfcc059117284c603ff8cb61a65872512882f84a8cf0339241f7f7c2f148", size = 256191, upload-time = "2026-09-27T12:26:25.89Z" }, + { url = "https://files.pythonhosted.org/packages/2d/47/74e5de9227b939ece9f64e729645ddc4296bea10dbfa98721c1333c8be2e/coverage-7.16.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:afdf43b72ef3876c1fe66423b91466e37877c9e81e8cec70542b7e8525b9d1b7", size = 257610, upload-time = "2026-09-27T12:26:27.35Z" }, + { url = "https://files.pythonhosted.org/packages/13/fe/2cf28d40b43645d1b72388fe3ee7f7c747533a6a9557bb8c24a7ae74fe1a/coverage-7.16.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9acc7f7ec4a1b5f89bd929fde5b8a714f6fafdc6cc18725413d510aa082b47ad", size = 255757, upload-time = "2026-09-27T12:26:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/d7/3d/7c149fd99fc8bbc39c80db5e688d1d39fd040be2ecb78b8335a51a55b9c0/coverage-7.16.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:80d3f7b48d43ee8fc5e8707a8adb43d743a5a1a85256c25a24f9d6d0e2238fa6", size = 259822, upload-time = "2026-09-27T12:26:30.515Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/b283fce09d5995e227bd8e513358dd7471bedc0f78abc85a925ebdb0a2f6/coverage-7.16.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:126d1af8804d7224421fe991ff65d3ce649081560df7a98b1a5ffff07f9923bd", size = 255325, upload-time = "2026-09-27T12:26:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/bf/91/f3325edf0c4223fb1fe1532b8dbef2a1d2f729459a9a7d1a44d073bae534/coverage-7.16.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c19cd6d025c1673f22afcd22c7df8a662d779e05d8e3fa6820c22afb895b0206", size = 257191, upload-time = "2026-09-27T12:26:33.525Z" }, + { url = "https://files.pythonhosted.org/packages/c4/89/21eb5e83ecf2eed523c4eb3d65ae513cd082c8fd1b6deb34c4cb6c332f97/coverage-7.16.2-cp312-cp312-win32.whl", hash = "sha256:152877cdc8a07264882cfcd503ba56a3ef6cba56a70e8c70f6eb8ffd7384789a", size = 226034, upload-time = "2026-09-27T12:26:35.021Z" }, + { url = "https://files.pythonhosted.org/packages/db/de/e3ad6d864c0833624b4f1f9b53f9e58e116c945e5e965c3f1e172c5e84cd/coverage-7.16.2-cp312-cp312-win_amd64.whl", hash = "sha256:e6c52d3307824ff93b39efd99e4185d557db40bd841452abfb32e5d9151ca162", size = 226569, upload-time = "2026-09-27T12:26:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c1/bccc58ebe5489cc70628f635c1932fd371f5d7da850dbcf960f95f4c4afc/coverage-7.16.2-cp312-cp312-win_arm64.whl", hash = "sha256:a678c0b6b22086ec2427359d22e37445d4a792f5fdbbc744112c7dade65cad02", size = 226372, upload-time = "2026-09-27T12:26:38.406Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f6/8eb4f220ef24f84fb27d852d4f9bf83e0c73ec1a4a08dd9a87e3f4529739/coverage-7.16.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1a37c6e478cf687e1aa30a593d19c92c02fad9d122b51ab73f51b8dc7a0c0fc9", size = 223993, upload-time = "2026-09-27T12:26:40.164Z" }, + { url = "https://files.pythonhosted.org/packages/40/23/d4bbaf0c154e0b0c2b5264890dbf6ef098dcb50ec8f2469be9490d191660/coverage-7.16.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0993d0e90858c03943d3cb152e068a20dd4707924deec84dd2230261baae3b1b", size = 224368, upload-time = "2026-09-27T12:26:41.762Z" }, + { url = "https://files.pythonhosted.org/packages/7f/48/fc1e88fd571ec5cb38150b7f89f7696ca1bdf9920e01432febb69774cc85/coverage-7.16.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bb2fc905bbf4e6b7f40806ea79e31515abf6349594cdf0adf27c4215f0463204", size = 255358, upload-time = "2026-09-27T12:26:43.442Z" }, + { url = "https://files.pythonhosted.org/packages/1d/56/6785397d07c29c8e70fbb9a07e97d062b43c21ffc5f12385917847f09f63/coverage-7.16.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4358b9c8c0125b460407f3017c6cce8156e904b32772c5630d27112f52bdbfe5", size = 257955, upload-time = "2026-09-27T12:26:45.725Z" }, + { url = "https://files.pythonhosted.org/packages/27/3b/c8cdd07721e5f99abd81cea970d971997f99bf158c0b85f51bd284179c8b/coverage-7.16.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f15254427c9b33eedac4f198eaf9e356eb4f6214551afb43da6194a2c088ad7", size = 259183, upload-time = "2026-09-27T12:26:47.208Z" }, + { url = "https://files.pythonhosted.org/packages/9b/11/606b192fe43d32574ec6238549d48de588fdcc18485682a5ec0a8ac357f2/coverage-7.16.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9a75a4704ff640e46170042eec1f984385a121227c505d5a16ad8e495f452541", size = 261325, upload-time = "2026-09-27T12:26:49.084Z" }, + { url = "https://files.pythonhosted.org/packages/67/90/eea481f8b0305ceeb33f081a5f47e298391dbd1b589de0c4b3b3aa50d3f2/coverage-7.16.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:14253fc7bb15749b849795a06f5d3b6d8bc3fb8a4b5ddc341faf7a89dce205fc", size = 255531, upload-time = "2026-09-27T12:26:50.509Z" }, + { url = "https://files.pythonhosted.org/packages/6b/be/dedbf9aea1457b120c27ac10b8fc2a357f37fa2b54c3e7286d42980a0a2a/coverage-7.16.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:921415102a90637fcc2e3f169f61dad7699ecf690e8639fc21b813acbedc0967", size = 257329, upload-time = "2026-09-27T12:26:52.005Z" }, + { url = "https://files.pythonhosted.org/packages/fa/cb/b25c19d5bb2bd0f2e4e27fe8e2ffcae80c7a91ae181c0dc749ed60e9b1a4/coverage-7.16.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cce2bc991293f15cc4084ca116827b5900c5f34e1a54dfe83f10ab5c43162eb7", size = 255294, upload-time = "2026-09-27T12:26:53.634Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a2/892c5c5f4ad44b7b2ca009aee705191f3f268f15052244f2f9e3539b2e35/coverage-7.16.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e1fa594c887365b69745f25a416806e61085dd07b94c9eae68a6e20730629b23", size = 259444, upload-time = "2026-09-27T12:26:55.243Z" }, + { url = "https://files.pythonhosted.org/packages/ed/99/a562537deba0a3e370182ae71c149be796c39d8087365f17a09188f27145/coverage-7.16.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:11e597173af1dc33d5f8a7332ada544199269a223af1ee1770ddd5e245ad0fe8", size = 255111, upload-time = "2026-09-27T12:26:56.851Z" }, + { url = "https://files.pythonhosted.org/packages/2d/20/854ec68641a9b3362ff068a32dfa41637299761617ef253791dbade6fc76/coverage-7.16.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3e7f99698ba3a7d13988bdd984b7ebf13af4dbe2166dc8502eef90d77603b0a4", size = 256882, upload-time = "2026-09-27T12:26:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/db/0d/748e4518b0ac0f9ff2687c248a6e5f8c0737306e709372632a2556f84443/coverage-7.16.2-cp313-cp313-win32.whl", hash = "sha256:f80bd9f9633eafc73d0a913ba2645c96ba58bba1befc30590f7c0fbfde59d865", size = 226045, upload-time = "2026-09-27T12:26:59.983Z" }, + { url = "https://files.pythonhosted.org/packages/31/fa/6e46edba66a183fe4d99d4bb52c173287e9b8dddabe0888d24cb8210e580/coverage-7.16.2-cp313-cp313-win_amd64.whl", hash = "sha256:8be099e979fc42559328a21828281b4578304191ae46ed4e80a407048a82eee6", size = 226581, upload-time = "2026-09-27T12:27:01.494Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d9/9ef6845367600b336ff75d000444a0d32497d6972c833141bd39356abf68/coverage-7.16.2-cp313-cp313-win_arm64.whl", hash = "sha256:28ff850182a67d117990fa2ce5ea1032836d8c9630dae867e8bdd3bff4533b79", size = 226409, upload-time = "2026-09-27T12:27:03.116Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/577fc0803dab4155dcf808faffbdd7b159256781c0874a8586e17b81b149/coverage-7.16.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4ee546b9e4872ffa194bf07ac87bfa1202ebb824d0795dc1ef22f175545ca90a", size = 224138, upload-time = "2026-09-27T12:27:05.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/9e/e3785ba3ecba2bd11efc74bfe2801ca4b78c4480b15a375648d809a59da3/coverage-7.16.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a2fac6895eb299a2e52d7bbb8fb3903502b9da8d3f5309ceb16ec40c646b58ee", size = 224445, upload-time = "2026-09-27T12:27:06.805Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d0/963ff22d3fd27117da3b8cc442f5bdc91196f783321e1a8ff0ec43476772/coverage-7.16.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:57ff3783f99d75a1e81dd56a9737eb5665e6736a5d93258ba596b6dcad8fd05b", size = 256112, upload-time = "2026-09-27T12:27:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d4/a306940c81c6ae759e82fff27d20b7fdc6896e422b821f51313cce212b6c/coverage-7.16.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f37886699cb9abd29958247d718628d5bc6f39e623dff66a09e546c42a7e03", size = 258727, upload-time = "2026-09-27T12:27:09.927Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a3/d3d99d93b02517087aa05bc0cf2d04d372956b849e5443e059079901429b/coverage-7.16.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fd7a86fdda7cb6d616d178654bd0ad6bc0f3f33c2e478aa598500a1a9e34eda", size = 259909, upload-time = "2026-09-27T12:27:11.55Z" }, + { url = "https://files.pythonhosted.org/packages/08/44/39dd599181726758dd185ae4dc0c0ab3aeabf7ca70e68e145060feeaaa16/coverage-7.16.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ac0f3b379c94acc2f7dce5f5f0b24d44fa1cc6a509717ef83dfee07450c2117c", size = 262481, upload-time = "2026-09-27T12:27:13.17Z" }, + { url = "https://files.pythonhosted.org/packages/99/e8/91ee43f6ded411460c359d7e1aebde4d6fd8f00a2e5394182d9d212eb23c/coverage-7.16.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d0732c83746bc24123c581a85d9dd96b70ddb538c9076020aa1a041790361e9", size = 256012, upload-time = "2026-09-27T12:27:14.91Z" }, + { url = "https://files.pythonhosted.org/packages/11/8c/e9499ddc33197bd7eabcb1118ca81756fc874457b324e2b479a4804b2ad2/coverage-7.16.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b451c68218c150f616bc9649783ec8de76a59792c759b43aa0c9c0466a465e4", size = 257898, upload-time = "2026-09-27T12:27:16.588Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6e/c081cb5991a0afba99f9c4ad6c74a5fce9513a38ddc64e3e6680c6fed9af/coverage-7.16.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a56ac4fa5a75c7e182e8f62600cfb4aff43c5ed7356a034f3557659c3bec1d90", size = 255938, upload-time = "2026-09-27T12:27:18.19Z" }, + { url = "https://files.pythonhosted.org/packages/b2/42/1c3d819e8f9b6eb01c2fe90874d67a8882adb9507e0bbb09361ed131ea89/coverage-7.16.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4cc4f73aa3fabc36e32046d6cd2971405948d8a903636508a3d3b2f9128b3a95", size = 260368, upload-time = "2026-09-27T12:27:19.903Z" }, + { url = "https://files.pythonhosted.org/packages/19/4f/d70eac07901fd587b6ab05e659b52afe13959992aa5113bf6cce059cc572/coverage-7.16.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:723dcdab91357159b722935b500ee8abc0a66c8c432e1e9fabf4cc7598952de8", size = 255620, upload-time = "2026-09-27T12:27:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/6d87af88317d3d9a9b18a9ca1bc1673eb516917f296e579d0d4a55cb3490/coverage-7.16.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5397e21a90dde0e9c6896b77ded8f0be26b66f8b22b33aed41f6043ed95d55e6", size = 257521, upload-time = "2026-09-27T12:27:23.358Z" }, + { url = "https://files.pythonhosted.org/packages/79/bb/90c2641170d2fa1a6757b3f8450ba2740197317b0ddd749e9604b914e886/coverage-7.16.2-cp314-cp314-win32.whl", hash = "sha256:848893e1d361448c113dc2f0913503522a6f7be231d0e38333d2a22d9698a011", size = 226248, upload-time = "2026-09-27T12:27:25.153Z" }, + { url = "https://files.pythonhosted.org/packages/30/08/d8d0478bb02c8eb0ae20a496fc80c40fcf4d3450bd184300d682ba2d28a6/coverage-7.16.2-cp314-cp314-win_amd64.whl", hash = "sha256:5a27b731c171e43dc8b5f32b76a5051dde2ec9b9366c87028f08a7088ebc2c7b", size = 226732, upload-time = "2026-09-27T12:27:26.907Z" }, + { url = "https://files.pythonhosted.org/packages/32/3f/0001da22155b0a8ce063ec0f7e64ecbe17b373f306e7a74435f6d6accb72/coverage-7.16.2-cp314-cp314-win_arm64.whl", hash = "sha256:1c569a9fd25505f1cd6bea90588818f90373ce90e2632e2cacf19ddbd6e14fdb", size = 226645, upload-time = "2026-09-27T12:27:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/d7/85/6d8813aff9b8b8586691a9d33c43c5604f7227622574da7cdc3d91a86861/coverage-7.16.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d93db87adb6b1c1b408dce4763314b55d76a9f589e96783a84ac9e7689e48bdf", size = 224871, upload-time = "2026-09-27T12:27:30.32Z" }, + { url = "https://files.pythonhosted.org/packages/5c/70/444f3a4981ac2cda40fdcf4cc9b56a4e1a33c222abeb33e51ed3e3eb2a6b/coverage-7.16.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aa62c85046473959c13ba9edca9dc90a77d5c1095b1ba313556314d77fe5b036", size = 225123, upload-time = "2026-09-27T12:27:32.33Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c1/980681cd7b33eb66ac835044116ef0a92e11fcc7bdd866cc89d10b1130b9/coverage-7.16.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:db76506aa5416081f3e8974ae0f7965c58ada0bb0ef7339ac86099588dbb20d3", size = 265049, upload-time = "2026-09-27T12:27:34.085Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e3/87679875c33bb2191f0f05544a1cc9adcc940fe0c35443a10f2df753dde5/coverage-7.16.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a0f2285329dac10ab08f79cb11f5692c497018e6c7c511f95e6fd63a70b8f831", size = 267717, upload-time = "2026-09-27T12:27:36.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/64/5d372776d6eb523d4e93bafba2253f96984e3b18261c4cc56a50863c6d0d/coverage-7.16.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:382d3346d56b0eec1b793d53a4c88799c8053f516aa3a8d7c44315696954bacf", size = 270047, upload-time = "2026-09-27T12:27:37.96Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/44082ff0cbf9f97d0043f57970a71204097ec7ba606361a9fd2065393669/coverage-7.16.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:648352b94507179d82637292e7ae8802508d95f78e2f00a705a50b6c48011681", size = 271360, upload-time = "2026-09-27T12:27:39.766Z" }, + { url = "https://files.pythonhosted.org/packages/b8/17/9a215efe25b5e0ecc87c89dbe525c4a87d14d87c8c0c7316ef140a5f6f3e/coverage-7.16.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb2bde05838fffae1a1bf75e5d411a6cac3e4e9bb97e6640fed8cd47888b33f0", size = 264736, upload-time = "2026-09-27T12:27:42.072Z" }, + { url = "https://files.pythonhosted.org/packages/a2/da/7f0a31af8e448107d4d32844bd684757f51ea907bc0c68c8fd537b2123ff/coverage-7.16.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6a75180829efb8ae62b4aded25be6ddca1c888d138d2d82e21d93bfbd88f41cb", size = 267600, upload-time = "2026-09-27T12:27:43.85Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/3bfecbd3366b775bacdcb3330394d356cf384b5d8f5b2146ac4b14b252b5/coverage-7.16.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:99704f73721e23859112072d522076e11c31744fc96b5652e5dd2018aa4359f7", size = 264767, upload-time = "2026-09-27T12:27:45.768Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3f/5d62163732d87e4a0c4710a0eab30f0fd6a2d480112abe2029f014fe8c9d/coverage-7.16.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:29309ccc86b7f33df7db12813c299f215bbbc470ed6292d0bedd63ffae1ebf64", size = 269026, upload-time = "2026-09-27T12:27:47.787Z" }, + { url = "https://files.pythonhosted.org/packages/49/4d/8e4579f225426535085a9be371cc75e3b026d058d679b80affbdfb4c3ef0/coverage-7.16.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:30c1b65d529e46569899fadca59e4a87c1faf2886923f1307ba61e654d4f3c20", size = 264147, upload-time = "2026-09-27T12:27:49.681Z" }, + { url = "https://files.pythonhosted.org/packages/d1/36/ef1f77e2c3f7bb03c2b13b9a2006f88700fdd75535ef158d70049f425c1c/coverage-7.16.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcf4bc2aab4e16b1c4c0c2005918f23a7dd5d7821ddae82caed9e3342dc2fcce", size = 266376, upload-time = "2026-09-27T12:27:51.551Z" }, + { url = "https://files.pythonhosted.org/packages/be/79/0cb2bf4428830dec971c718c2c841a039c084415c99e67281f5a72841aab/coverage-7.16.2-cp314-cp314t-win32.whl", hash = "sha256:a9cd3de0a5bfe7b0e21ee10e1a14e3d61bf52efc88217ab1d95d6ace6970bd46", size = 226585, upload-time = "2026-09-27T12:27:53.945Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f9/da17121c16667fd84998e972200ae226a41540f6ea4795776c6d99e8976f/coverage-7.16.2-cp314-cp314t-win_amd64.whl", hash = "sha256:611a44e5229a59d7483ce830160e1a0e85f700562c7a5651c7c63fb8f4eb528c", size = 227378, upload-time = "2026-09-27T12:27:55.778Z" }, + { url = "https://files.pythonhosted.org/packages/74/89/01179c62d1b7e6e33bd5001566b02d7f778cf33d3ec1e81e94ca170c517f/coverage-7.16.2-cp314-cp314t-win_arm64.whl", hash = "sha256:22957cef43ce038641de78ba995de7568d2d6a37c6ddbf7fa0fd7d1ae2344d91", size = 227064, upload-time = "2026-09-27T12:27:57.496Z" }, + { url = "https://files.pythonhosted.org/packages/4c/57/52935003c3f627ba6e5203d7179aad32448c10899663a30336aba8e81a2c/coverage-7.16.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:414c26dfdb96aac2d570a54e03008f001e32eb2d413705365503648c6bd361d8", size = 224140, upload-time = "2026-09-27T12:27:59.343Z" }, + { url = "https://files.pythonhosted.org/packages/31/38/df472520f3e626524d7e2fc9d6da0afe7895a2f1489d36b48af8ca40bb41/coverage-7.16.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:00d3eb96e9988c45f50cccd1f1496571ac5c1f91386ac02c4d55516eeda19a24", size = 224449, upload-time = "2026-09-27T12:28:01.299Z" }, + { url = "https://files.pythonhosted.org/packages/0c/aa/3be084d5b82e63ccdad4ed751e4acbae294673573e30481d29f8b7402eec/coverage-7.16.2-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4dbbd1155ca46e6e0b6b89d204428c56ef6a459af21333f365d135a2820e5a09", size = 256060, upload-time = "2026-09-27T12:28:03.185Z" }, + { url = "https://files.pythonhosted.org/packages/de/29/48fca82a7ebf7ff7b2e35019cc9537e7f65e4d2aa1215cc5a8792c989251/coverage-7.16.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8fc15cc8d0d06e873c00ef18e1372d605f9aaf3de27d8c24e50782e75bc8b843", size = 259106, upload-time = "2026-09-27T12:28:05.15Z" }, + { url = "https://files.pythonhosted.org/packages/06/3d/b2d5986f2dd53fe201aa1be2e4ab204fa1aed5101e67c0dbbb419b850aee/coverage-7.16.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c6afdd69218202bc1758c9a14b86b8cf1084f37ed2ca143e567a103772b16d1", size = 260674, upload-time = "2026-09-27T12:28:06.868Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7e/b50160be3506ead12e6480d14279af7f0f17627694300a2d1fd2c42d2ff5/coverage-7.16.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aba5c63b7afdc749cc9eae943d5b868cba2b261a176378fa1c5a30bc8bc89982", size = 263139, upload-time = "2026-09-27T12:28:08.771Z" }, + { url = "https://files.pythonhosted.org/packages/14/5e/7c805ac9a32606de1399bd7e9bd375aa2f973dc61b12680d9e6403c2e891/coverage-7.16.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9174f0af24e5eff248b9dbfe76ec5275a3d19d37edbc2810543f12cf97347a34", size = 256591, upload-time = "2026-09-27T12:28:10.842Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9e/76f1ed129a2daf658a3ea17122824cf2e3b91fea0460d8d3664fc5a61018/coverage-7.16.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:80e9fdb4c3d926b6ba721d4bf7435bdb869c3527ae7803290361d0ab73db13b6", size = 258708, upload-time = "2026-09-27T12:28:12.962Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b7/8d62e75f48b527619239a65294f842d4b7fd02a0839d43ae1de80184e2df/coverage-7.16.2-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:7b3bce4a0d05401d70b7d0d5ca783e686bc9d30e81dbd7d980d532609bf809e4", size = 256562, upload-time = "2026-09-27T12:28:14.934Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8d/0a15f95c3afb78e947c52644786ba4bc9de259905687dd720d5e6fae2e76/coverage-7.16.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:44f21e407b278efdfc1ee5e481e00518bd1d500310a30a5fbf2bcbedfef4aaf0", size = 261077, upload-time = "2026-09-27T12:28:17.215Z" }, + { url = "https://files.pythonhosted.org/packages/25/00/88389987305a47d732866c07c8a500000ab574df9505e3114ac69c8d027f/coverage-7.16.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:59c3926585e1cd1f2190f4b2ac9014de1bbeaf0d5d0587b0dc6b0aa90d17896a", size = 256034, upload-time = "2026-09-27T12:28:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/92/02/34d079d4952ad461bde037d353f9a6e037a7edc45fe0f9ee8781ff73f028/coverage-7.16.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:066429634299e14dd2d511e1e85f8f9cecc500781f6b41907c0dd6f1baea7e63", size = 258034, upload-time = "2026-09-27T12:28:21.242Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d8/3e59a62879285b464ec1b10fd824fbc1af9ce66e842cd39974f80a0becc4/coverage-7.16.2-cp315-cp315-win32.whl", hash = "sha256:893ea9cf86cb8d2546812ac93d973aaf2ee1fb45110a873b014214fd23e3725e", size = 226252, upload-time = "2026-09-27T12:28:23.102Z" }, + { url = "https://files.pythonhosted.org/packages/f4/e1/128026e1b2836e9ad6b219207ba9edf1c5e0088a7869e23088aee7fbbe7a/coverage-7.16.2-cp315-cp315-win_amd64.whl", hash = "sha256:01c6908bc613b420c26c818fe948e1b97dfd041a53c98b01c63bd8321f5c9aae", size = 226728, upload-time = "2026-09-27T12:28:25.21Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f4/c9fa8e7cf525ca7748ac52b0ee89331d13fe09808e45c679830708782e90/coverage-7.16.2-cp315-cp315-win_arm64.whl", hash = "sha256:967d72c835d7a8cf0af99ec813a2d06e3db6df706402f1fe85b31b437645f495", size = 226648, upload-time = "2026-09-27T12:28:27.136Z" }, + { url = "https://files.pythonhosted.org/packages/a2/13/e96b045447a856666f36f9c653e2a80bdaa732aaaf72412b19aa2c26a473/coverage-7.16.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:98d9c97f51b334b0adce7b964442a9af33c1a00c6ac856984cc5dc8d18f81c75", size = 224858, upload-time = "2026-09-27T12:28:29.169Z" }, + { url = "https://files.pythonhosted.org/packages/23/90/087f6ad1bd3df059632ca3407a4e6552ed1053ee35354de0a771acf35423/coverage-7.16.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:3e861f1071dcc2fec1e88bef0920f6b1eaa66a143555b4f8ab79ba2b0f30ef55", size = 225139, upload-time = "2026-09-27T12:28:31.131Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8e/285dcef0184358044e7cbcd810a1bdc9566bc620f54702d605477155df4a/coverage-7.16.2-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb9d92ecfe2d5b494367c67f7446f8b75b68d8d0c8cf3bc3e6997478be25d9e2", size = 265090, upload-time = "2026-09-27T12:28:33.04Z" }, + { url = "https://files.pythonhosted.org/packages/06/b2/cc83f3a6e5789a4e89059c69555bc641c2efcde568405a1c06fc702951ab/coverage-7.16.2-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb57acff4a74246ae513c142d4b36e18c389c3aed8661914a53f7cd0071031b2", size = 268254, upload-time = "2026-09-27T12:28:35.135Z" }, + { url = "https://files.pythonhosted.org/packages/ac/41/f548c19530f5d66ac6e3c92bbcbc49da7261de3a458b9f3e54a3efb1a0b2/coverage-7.16.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:444889f7f66b74e4455c0a97e0e166dd41177f1dca8c0239a47cff25e05ba7e1", size = 270695, upload-time = "2026-09-27T12:28:36.959Z" }, + { url = "https://files.pythonhosted.org/packages/94/61/4dc27cf82ef96434d2874110ad0cc10ea4621025705dc5049862bd3bd181/coverage-7.16.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a740ea6f083c6db7b926534d159508f80ba275ab35e722522de0d18d0f56e55f", size = 271853, upload-time = "2026-09-27T12:28:38.821Z" }, + { url = "https://files.pythonhosted.org/packages/38/29/bf8072b1b8bd5f2de8b21460a404460b1a2b97e80a9464c78ec0271f6199/coverage-7.16.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e209591f7c41ae4a9171335cf6156afda0b21de73b02f73f5aa95b2d5fbb08d", size = 265644, upload-time = "2026-09-27T12:28:40.815Z" }, + { url = "https://files.pythonhosted.org/packages/7c/2f/0aecb8721be5cdeb8afd9d6d9f6b463f074e4d8d37f00f4c42442522709f/coverage-7.16.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:396bb16e04ce04efbb3df91456ae4e3da918e69ecdf67fb711b0a0fdf35ccce0", size = 268530, upload-time = "2026-09-27T12:28:42.725Z" }, + { url = "https://files.pythonhosted.org/packages/ab/0b/92b4b7628268ee711249958e68fc0328779bd3d9a7ab4715379465aedb84/coverage-7.16.2-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9cdf19874e0d247f32f03609200370343c3c7aa260b191d8c2bb251d36198283", size = 265154, upload-time = "2026-09-27T12:28:44.684Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/41c95c1ab29b3dcd357cd1227181d1c98185632aca41ce670ce671b23a43/coverage-7.16.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:fd3d72233eb8b48acc94fa57d44e2d32ce8e7abed02882ccb6d855ccc4ed33ec", size = 269799, upload-time = "2026-09-27T12:28:46.672Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ebeb259aa5362b033a137b86d7274ff4b109d59be8cc9913889b783bf75a/coverage-7.16.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:bb4ffe96aa663cee727659db5a2afeb38c95f8677b747d447b90d6d4874ea2c5", size = 265196, upload-time = "2026-09-27T12:28:48.996Z" }, + { url = "https://files.pythonhosted.org/packages/b2/18/8437620f90d023680a072eee02f968055f3658bbfb7d386d0ea34cfb7f30/coverage-7.16.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dba2edfb054f6d4a08df9d1637c39a5aa3865bca6617c13c86be21e45658a59c", size = 267266, upload-time = "2026-09-27T12:28:51.361Z" }, + { url = "https://files.pythonhosted.org/packages/28/6c/f08e8ee4293e6434035424180bef4d45e028e8ecc006c61bf9453e74405e/coverage-7.16.2-cp315-cp315t-win32.whl", hash = "sha256:251aed777c47c77aba047096d4542889db089227655711dfc2b9c54ef0e15e35", size = 226583, upload-time = "2026-09-27T12:28:53.33Z" }, + { url = "https://files.pythonhosted.org/packages/f7/fd/3f939c2847f4a72c20cff8b1ac33da78ea91a2d38d9b43336e60db719103/coverage-7.16.2-cp315-cp315t-win_amd64.whl", hash = "sha256:2aca0bdfa9e91621d5b09d815357bf63def4fc0e9cb66da67bf2cf93f3b1a6f5", size = 227374, upload-time = "2026-09-27T12:28:55.158Z" }, + { url = "https://files.pythonhosted.org/packages/5a/35/b98cdc354c952402132e675a87f2cc3227fb68f959c84aaa491fbe15933d/coverage-7.16.2-cp315-cp315t-win_arm64.whl", hash = "sha256:b88841e654f09732804809e435b3e005a929ffd9998b872b7b213957b8759cb8", size = 227064, upload-time = "2026-09-27T12:28:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0c/7a64e1ac90541a8edf50daef0914848011fb057a5bf55284a4811e21939a/coverage-7.16.2-py3-none-any.whl", hash = "sha256:11d28e9123a9156cb405d8d27b44256c9a58fb5decc2073a8f17862057e3aa0f", size = 215754, upload-time = "2026-09-27T12:28:59.075Z" }, ] [package.optional-dependencies] @@ -1455,44 +1455,44 @@ wheels = [ [[package]] name = "duckdb" -version = "1.5.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/19/e57151753576373c6696a12022648546cca6038e8833fda2908ee2342d9b/duckdb-1.5.5.tar.gz", hash = "sha256:72f33ee57ca7595b23957671a2cc7f7fe2be0ecc2d68f63abedcfcaa3a5c1238", size = 18066741, upload-time = "2026-07-22T10:55:17.819Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/52/d4/298acf9331a80b3ce6ac64dd940e7e13f4058fb69d18914445f02e3c7bfe/duckdb-1.5.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3b805507f88171b428b21c966c30e9a3d54e30b24528918a44ed0032542bc26f", size = 32702934, upload-time = "2026-07-22T10:53:19.069Z" }, - { url = "https://files.pythonhosted.org/packages/d5/90/c489fb63d64b2e7ee109ce8460bdede003a0f256e5b41a03a2a1c4764058/duckdb-1.5.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b08e19cc856220d8a26fa62abc2264b349aff67255e9373c6a3f607addd56dc6", size = 17343604, upload-time = "2026-07-22T10:53:22.767Z" }, - { url = "https://files.pythonhosted.org/packages/0b/27/effa80a15b1f0c61c235622f797868485359e8c9ad6a8e358e7a0c479151/duckdb-1.5.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a17e6a922e42a5c06ed2353fe78c5dff2610f6632d603836f9606ad0bf754079", size = 15488179, upload-time = "2026-07-22T10:53:25.945Z" }, - { url = "https://files.pythonhosted.org/packages/5d/07/21212345c8d24ba62dceaa20be3b21f5c46f1510b1b42ce93bb058afe0c4/duckdb-1.5.5-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bdc38922c365c37720149f90d90b1e9823eb82dad6830855b5f87537fa6fc0c", size = 19367323, upload-time = "2026-07-22T10:53:30.23Z" }, - { url = "https://files.pythonhosted.org/packages/3f/d0/10371ae875fb4b5ef61bb892743b4b2e90c512b371fdf29317deb744857d/duckdb-1.5.5-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e238060db5ca59879882a6e9b015e2c65d5c64ddf281ba1d7a9a2033764152cf", size = 21476568, upload-time = "2026-07-22T10:53:33.486Z" }, - { url = "https://files.pythonhosted.org/packages/b2/35/09568ce617dd7bc0757b3d7b6a981660b9e4f0b7594de8ed776755eae740/duckdb-1.5.5-cp310-cp310-win_amd64.whl", hash = "sha256:4acc72798ba1885a9c17d1242903d2cd502f13b1271c7677f7cab25d8578eceb", size = 13156129, upload-time = "2026-07-22T10:53:37.55Z" }, - { url = "https://files.pythonhosted.org/packages/9c/c2/b62ec24d57bb8df4e24b0b58f7f8facb32f5fdb9f1895aed9e9fcdded168/duckdb-1.5.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1b543841b0ae18a9c982345cfa3987e9c065d3a4b0f067daa473d92d1e65f528", size = 32708371, upload-time = "2026-07-22T10:53:41.642Z" }, - { url = "https://files.pythonhosted.org/packages/8a/ce/769171ba45f0b73632dc3bc3108d891e81dd6c6bbfba630a34a75b4dcc0f/duckdb-1.5.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a925d06c2a4c3b64553d6cc1aced5028d376d4479bed689a7d47e9b1dccd80a", size = 17343979, upload-time = "2026-07-22T10:53:44.951Z" }, - { url = "https://files.pythonhosted.org/packages/46/59/a8e3384ee916e00d5dcf985194c1511d61978540778a1e96fa47f9fb3e0d/duckdb-1.5.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0c42757cb34722144bd4dfb94b6f336339e7b2468f6813fa7fa9a319ba07bab4", size = 15493704, upload-time = "2026-07-22T10:53:47.912Z" }, - { url = "https://files.pythonhosted.org/packages/6f/1d/9840179c2607b90523a2884a129c4d4e6dbdc1178ba62a976c1043beba88/duckdb-1.5.5-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e72f9e1a4f90a5c8483ad4d540e495bf0834ba61c360b52499a573d7ed62a3f", size = 19366574, upload-time = "2026-07-22T10:53:51.876Z" }, - { url = "https://files.pythonhosted.org/packages/b5/55/f9641a4eebcc2f4df631287d6c3b9ed2eea3b92644f93acbad825e3972b6/duckdb-1.5.5-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9b6f86ed85d4ef5e0211eaebf75d057bd8bb520bba438a95dd0f4e42234bbfe", size = 21477952, upload-time = "2026-07-22T10:53:55.575Z" }, - { url = "https://files.pythonhosted.org/packages/3c/3a/07c3556e37a5c97b95917b029c8fdde4a25fbd76a660bacdac195cf20dcb/duckdb-1.5.5-cp311-cp311-win_amd64.whl", hash = "sha256:9f4287f97ccf0c1f3d471e7115be2b067cbf99627e2d34bffd462dd64703cddc", size = 13156986, upload-time = "2026-07-22T10:53:58.823Z" }, - { url = "https://files.pythonhosted.org/packages/4f/ff/07b48eef2078ca033847e9caa46cc7633b714c5f91ad1ce091c8ca89d792/duckdb-1.5.5-cp311-cp311-win_arm64.whl", hash = "sha256:179633a3fc6296c75d57c69c1e239fa9e5cdcb670fd1dbff88a02663f932905c", size = 14001317, upload-time = "2026-07-22T10:54:01.724Z" }, - { url = "https://files.pythonhosted.org/packages/d6/40/2e05d324400fdaa5656c9f48d6298da421cb034d85e509fa0e6e325cf04b/duckdb-1.5.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d4dd65f8941a604b947e0b9b4b4f7165988e29a23ec0b69b4038520956d9933e", size = 32753858, upload-time = "2026-07-22T10:54:05.514Z" }, - { url = "https://files.pythonhosted.org/packages/79/15/5ceb58ffb5bb8a62b3fd7abb39c41467cdf94850ece02e6d88664dfc75ce/duckdb-1.5.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33db46679b071f108d57139493dee2d37e1f5efcf5c5c039c2969eed11a6c8a7", size = 17368293, upload-time = "2026-07-22T10:54:09.139Z" }, - { url = "https://files.pythonhosted.org/packages/bf/5c/bf02da0b354fe83cca4f95a4fbf762181af466f7d551ab2a093f7698882a/duckdb-1.5.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f0b88535a5d86fdd63dba6ea02ab68c003dfb9e4892b11256ef24c4da208baae", size = 15509131, upload-time = "2026-07-22T10:54:12.228Z" }, - { url = "https://files.pythonhosted.org/packages/ea/a9/5f1f09da421d8e930e0b063d11c1b3f90363f40ede74438cd188afdd13a2/duckdb-1.5.5-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f316eae2323d9a851883fdf2dee91c1f9efe251ab33e14a2272f82a913422ed6", size = 19391959, upload-time = "2026-07-22T10:54:15.551Z" }, - { url = "https://files.pythonhosted.org/packages/4f/98/6549769f158126fa64fd6c1ac2eb59a18282146c939867a3eb31b7c1db07/duckdb-1.5.5-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7a6d2d11859d82a936ebdcb30ce3d8a1cbb3e990bff05c12abb9b54c44fa7bd1", size = 21510909, upload-time = "2026-07-22T10:54:19.681Z" }, - { url = "https://files.pythonhosted.org/packages/af/b7/5753b41d3124838f868f9f523362812d9fc45409e9e4dd70dcbb0a25826e/duckdb-1.5.5-cp312-cp312-win_amd64.whl", hash = "sha256:ddfbdb096c11d51ee22492397d342c90a82e62c5d09961477895934d0a25372f", size = 13168544, upload-time = "2026-07-22T10:54:22.789Z" }, - { url = "https://files.pythonhosted.org/packages/5c/28/44b679c7d46245f8398feae7edac959d1b83d4eb143e25b3fce0630b78bd/duckdb-1.5.5-cp312-cp312-win_arm64.whl", hash = "sha256:2725d2b9ace3a4e75d72fc5a239f6a44b502c580edadb8fb2676db772c5f9282", size = 13988684, upload-time = "2026-07-22T10:54:26.003Z" }, - { url = "https://files.pythonhosted.org/packages/47/37/4a38116e7700720fd152c666292214fd3abdf916496991296d8d1f66efbf/duckdb-1.5.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd98829b67788609017e65c761bd42a5dd0f9129441bed8bda4d6881ccf819f0", size = 32754294, upload-time = "2026-07-22T10:54:29.822Z" }, - { url = "https://files.pythonhosted.org/packages/66/42/7d392f1ba1eee0eaf4ab4c8c7a604bfe3536cd63f979cf5c98798664f807/duckdb-1.5.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:feead93c56679b79592d437c62975d39cb67adedffa7592c763baf8160ac7366", size = 17368211, upload-time = "2026-07-22T10:54:33.359Z" }, - { url = "https://files.pythonhosted.org/packages/9f/a5/0a6f4fa60562faa615e55e15bd1953a2f2b17a8edd8105e5cda215e43457/duckdb-1.5.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:49c963d9469373d7aba8d750d9ea565ab823e94166efed953f184dd9b169b98c", size = 15509136, upload-time = "2026-07-22T10:54:36.369Z" }, - { url = "https://files.pythonhosted.org/packages/e4/cb/023c89f51978545b9fab318581bba0c457a58e7530d2d933e54ae7d8647c/duckdb-1.5.5-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a736217825461732b5442d05a220f3da2e23a0dae114efbf08c9bf171b53098a", size = 19392147, upload-time = "2026-07-22T10:54:39.551Z" }, - { url = "https://files.pythonhosted.org/packages/3e/c5/41bef391fb8b23dbc133c9f2ba016e7a7a8124513d2cc1b430f1897d87e4/duckdb-1.5.5-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:078e6a60dd8eedde5832f45422ca5c4a6b8c837aeabd8a56ca0b7d933f588053", size = 21511060, upload-time = "2026-07-22T10:54:42.788Z" }, - { url = "https://files.pythonhosted.org/packages/07/9f/c44dfc1f924ac29b3252dc1b91393c01d009dbfe9f8ed33f10b986151bd1/duckdb-1.5.5-cp313-cp313-win_amd64.whl", hash = "sha256:6826504277dba513c0c5d71d828456c94d729c9d2482f94b2e289f90a9167e28", size = 13168028, upload-time = "2026-07-22T10:54:46.127Z" }, - { url = "https://files.pythonhosted.org/packages/ca/88/591384b2cd59abddd6f5dc175e60374f9abae6064429f0c4402854c10f44/duckdb-1.5.5-cp313-cp313-win_arm64.whl", hash = "sha256:baa9c5702002fabb559ded2a39008f9f421fcbc7237d388b8213eff1e08858de", size = 13989955, upload-time = "2026-07-22T10:54:49.262Z" }, - { url = "https://files.pythonhosted.org/packages/3e/56/12c65bfa2d2605b81981b264788891bcf11ec72227889554cead5d8d13b9/duckdb-1.5.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8e6413dd40facb7b8ab21bd844450cd8f549b29e138635be9cf090ef4d2049e2", size = 32761946, upload-time = "2026-07-22T10:54:53.412Z" }, - { url = "https://files.pythonhosted.org/packages/b9/46/682ce155f17e0d2822d4f13ee3db9ca4b5b7c2da61b841b2629035e1f4bc/duckdb-1.5.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:64078acfd16541132ac6e191eb81b2845554444a0305cc1aa581ba107e514aa8", size = 17375069, upload-time = "2026-07-22T10:54:57.269Z" }, - { url = "https://files.pythonhosted.org/packages/39/ce/a24bcbd3289c8f305a430759c5fc12242740b4af3e17f7593f3a34e333d2/duckdb-1.5.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8c11775cc99a447618d5f1840126db17f2652f3eae05529df4f81f40e2df7151", size = 15519791, upload-time = "2026-07-22T10:55:00.681Z" }, - { url = "https://files.pythonhosted.org/packages/d9/76/3a01afbc615c1d418c0de58a6b68ac5ce2a8563232c0464bfbc2ce552398/duckdb-1.5.5-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77bbc1e6ba12e1e06f9020117bdf848627ecfdf36f907550e62e008e6109dece", size = 19398251, upload-time = "2026-07-22T10:55:04.168Z" }, - { url = "https://files.pythonhosted.org/packages/a1/43/3a5e81d1728f4d234c79bfe385808ee7c04834f7c37a4b5c257459c25614/duckdb-1.5.5-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fbf0f2d48b43c6c304d00463b463c27ead6c4b01c3c1816b750f728decf71afe", size = 21513851, upload-time = "2026-07-22T10:55:07.864Z" }, - { url = "https://files.pythonhosted.org/packages/91/41/fc7c829172c60ca22485251eab285f4f1a0d87b486a024c726f21471d86e/duckdb-1.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:9dc826c4b50e64f6c4e4d07a3a9cb075ef70ba3899dc43ec5493dc3d7b04b353", size = 13691858, upload-time = "2026-07-22T10:55:11.181Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2c/95d9216b79e9273689d7ebce125a54503ed0c9bd7da931f0265888e99779/duckdb-1.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:63e48d4b74b15aeacd688976432a7225163df8c226eddeb8536bba2d4d4ff433", size = 14470180, upload-time = "2026-07-22T10:55:14.445Z" }, +version = "1.5.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/59/0b/d65ea3be00ea79aa276a8388bec588a9cbf409ce637c6d306e5316210d15/duckdb-1.5.6.tar.gz", hash = "sha256:166a91dbfacfc0c9f08cc76c0243cb6d3d4296bfab5bad72a3cfb63140a5b7c8", size = 18032957, upload-time = "2026-09-28T13:38:37.978Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/e1/5d05ecb59e3fd401414dacc9c969a326fe3a0b1eb07920058b656fe728d6/duckdb-1.5.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:64db8a6700e81fe419fba130d8f1780686ad40fbf2eb69f78d2a1533728a0549", size = 32758341, upload-time = "2026-09-28T13:37:14.588Z" }, + { url = "https://files.pythonhosted.org/packages/0e/d0/a382d9677097a1493049ae38f8219d751db989bfc72bf3a3766dc5af038e/duckdb-1.5.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d6d1eac4de11779bb249b89b0544916ad65751da031df5c5f6d779c85b753109", size = 17372329, upload-time = "2026-09-28T13:37:17.997Z" }, + { url = "https://files.pythonhosted.org/packages/5c/dc/76577ce6520db9e4e8b33f90ec2f503cbf79652a1fd34e391b8043f921f2/duckdb-1.5.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:56355a543a79c7f4d8576d27edcbd9aaed19a562a0901188b021c10f4c818800", size = 15511297, upload-time = "2026-09-28T13:37:20.236Z" }, + { url = "https://files.pythonhosted.org/packages/e0/3e/eeeef69e0c3cf3bb463b544435695647a4802437cfcc2b94035026bf5f84/duckdb-1.5.6-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:95a6b91bb9149950baeb5d02466c006550d0ea98b9d10f15f7d614a8eb32e174", size = 19428638, upload-time = "2026-09-28T13:37:22.436Z" }, + { url = "https://files.pythonhosted.org/packages/58/05/4ed0a651d55c8cbf9f7e826cfa95e67c9955a5db22a0c7c0cc5378f4a90c/duckdb-1.5.6-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dbd348e9ebdc8b28f1f9930efb5a74a382063c35d9c43901075566fbae50ab5c", size = 21534632, upload-time = "2026-09-28T13:37:25.139Z" }, + { url = "https://files.pythonhosted.org/packages/33/34/66f49f13f4286871e54b8d5478fb0b10e1f334f6ffe81536213e7fb55f09/duckdb-1.5.6-cp310-cp310-win_amd64.whl", hash = "sha256:f14551eef9180fc72869e2d9a2896410a8826169e22495e98a825abaa0eac1a7", size = 13178288, upload-time = "2026-09-28T13:37:27.578Z" }, + { url = "https://files.pythonhosted.org/packages/36/e5/01e03d30b7ba33a030a4269fdca16ce445ce10f9d29b84a10fdbe0636ad2/duckdb-1.5.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c88700d0ee68ad149a0cc624df21b0f21efc136ea2449aaadd7cd0c9a564962a", size = 32757482, upload-time = "2026-09-28T13:37:29.916Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4f/7f7be626a4649a3948ca646c84d6afc1a00121f292f98e6f0d9ed68330df/duckdb-1.5.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:03e4f1b10a8b8ff476eb2b73955590fadbcef978da1167c593114c5edf763960", size = 17372997, upload-time = "2026-09-28T13:37:32.363Z" }, + { url = "https://files.pythonhosted.org/packages/1a/66/9d57573729348d800a0eebdd508f1a833d3714f72e984fef79b47f0e6c45/duckdb-1.5.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34623eaabd2c66ba5c20f1a39486321c3b7d32e4e0e001ced95f81e3372dd361", size = 15514224, upload-time = "2026-09-28T13:37:34.467Z" }, + { url = "https://files.pythonhosted.org/packages/57/ec/97f595214b3a27b4ca42b8cab6d8121c06f3537dcc4d2da7bca0332de4c5/duckdb-1.5.6-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56c0f71c6bee982e9c30568bb12371bf66b26bf129c75d8d7f60bc69d6590a2c", size = 19428776, upload-time = "2026-09-28T13:37:36.689Z" }, + { url = "https://files.pythonhosted.org/packages/68/4a/ab59f4c1f76fb89e28d23f19b2729538e0723c8d328a07e1b8c37f9ee128/duckdb-1.5.6-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:73b108c04c932b36c2fa4e41110cc1c3c8cd510eb49f065f92d050be8e6929fd", size = 21537771, upload-time = "2026-09-28T13:37:39.548Z" }, + { url = "https://files.pythonhosted.org/packages/31/4f/9306c442ecad76f2a4d19f249e7fc8861f139dcf748315102eb69de8ca56/duckdb-1.5.6-cp311-cp311-win_amd64.whl", hash = "sha256:dda311932cf5aae955a53fe28a4fc1700c2ab5fa02dc1f165abdd5ec6c39141e", size = 13179009, upload-time = "2026-09-28T13:37:41.981Z" }, + { url = "https://files.pythonhosted.org/packages/a0/40/8a370e998293d3ebbbac4d926db30bb4ac5f700851a06ac31e7093bee386/duckdb-1.5.6-cp311-cp311-win_arm64.whl", hash = "sha256:df5ae02af278e084f54a9730a9f4f211ed736d0bd8f3bc12af925c2effb5b33d", size = 14046340, upload-time = "2026-09-28T13:37:44.187Z" }, + { url = "https://files.pythonhosted.org/packages/d9/d5/d0ab77a0a1702a43171c93874f44c1f6481e30038bd3987df0d77a16a5c6/duckdb-1.5.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:48d07d0651aaeac2c3974afd37599970154b7b79b54c18f27c319c14ccf98d9d", size = 32810486, upload-time = "2026-09-28T13:37:47.254Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cd/b22201de5377faa3be6c38d5f3eaa504cb480392a448bed6a4d2239469b4/duckdb-1.5.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:79de3dfa8705b1ba0d59e7e3252e40ff399e0afd12f485502a6c7bf7c2fd809a", size = 17405278, upload-time = "2026-09-28T13:37:50.135Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6d/f9cfb1493bbdc2f095693a402e42dce1192077f9e11573f00baed6a748de/duckdb-1.5.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dcccce20965e6986cd083fdf192c461685ad0b93cd1ccd0b2a8207f1185f078b", size = 15532943, upload-time = "2026-09-28T13:37:52.927Z" }, + { url = "https://files.pythonhosted.org/packages/53/04/f65ccfaa5a833f2e570c4a140f03c8f95da416da9fe8ed08401f81f8242a/duckdb-1.5.6-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce89a1025a5317ebe9c520876c48032b5247ac574865486648b1a004f6009875", size = 19454940, upload-time = "2026-09-28T13:37:55.732Z" }, + { url = "https://files.pythonhosted.org/packages/4c/99/be75c788a492f8d77b7a1cdc1b19939ae7be0007f2028691ad371a1a33ee/duckdb-1.5.6-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc9619ed7d4ffa117b5155d84b44794366bb6635178d78ed5e13a6024845c757", size = 21568087, upload-time = "2026-09-28T13:37:58.191Z" }, + { url = "https://files.pythonhosted.org/packages/b5/95/889f8508960e47c0a7c75cc5bf57cde8512fc24f8db7b3129cca5388da42/duckdb-1.5.6-cp312-cp312-win_amd64.whl", hash = "sha256:09ff51b230219f0d8b47fc8a1e17fb595ba9fab0c3d96a6de4d00b8ff86b3cf1", size = 13190189, upload-time = "2026-09-28T13:38:00.407Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c9/baab503364a68309f8368c88e77f5341e7d94927bdf3e6d703f0e5035f3e/duckdb-1.5.6-cp312-cp312-win_arm64.whl", hash = "sha256:b8d795c8b2d5634b3269f974aa97f1fdf878f62f032317a52252a151b693fb1e", size = 14021977, upload-time = "2026-09-28T13:38:02.682Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/a476197fcba557738a588ec844747a19bc0a24b0e6f1809e308f29d68c0e/duckdb-1.5.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ae352646374cacf48e9981cf031191c494865192fc436d13667a2531fc5d1da3", size = 32810376, upload-time = "2026-09-28T13:38:05.148Z" }, + { url = "https://files.pythonhosted.org/packages/0c/6d/5466a2b53ddd557644dfa47a763f68748efccdf282e6ae7c4f1bcfb3da69/duckdb-1.5.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a1261e90785e9d29953293e44f60fa073bd1137098924e8de21a037a861b051", size = 17405385, upload-time = "2026-09-28T13:38:07.363Z" }, + { url = "https://files.pythonhosted.org/packages/d4/a0/bf87071170835ee4a34fe764fc11c1c6e7040a0e021b36c1b6f834a4c22f/duckdb-1.5.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:97dd7a555b8f5298b76bc7d48a11cb2c64336e8de9bfde783cffb86ea9f54807", size = 15533132, upload-time = "2026-09-28T13:38:09.681Z" }, + { url = "https://files.pythonhosted.org/packages/31/e0/38095c8e140ecfbe847519ac07bcba94301b8fbb76b2870015e33e07f179/duckdb-1.5.6-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:364992ba1089a2b327391cfcb68fd0bd0ce9090cf293baef861a0ba6847abfee", size = 19454994, upload-time = "2026-09-28T13:38:11.836Z" }, + { url = "https://files.pythonhosted.org/packages/70/21/61dd2876bbaa69cf77d7b5c620e52e8b25faae7096f4d2e4a812b52095d7/duckdb-1.5.6-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:644f54ce99b3b61844bc9a3fe80e0aecb1ea4084b1fffc4396d1569db6111679", size = 21568700, upload-time = "2026-09-28T13:38:14.258Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/100730e7785e85268be4d4d5bd62cfc8314e261d2f42efa208243eef35cb/duckdb-1.5.6-cp313-cp313-win_amd64.whl", hash = "sha256:ced693d33ddcee2e5345f077d342c87d2aaa80e41c514e64c9ff2d4e5963c251", size = 13190707, upload-time = "2026-09-28T13:38:16.875Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2e/bc7f44eab4e89ee5c1cb427bb1168ad021d985042e6841ec0694c3d3d501/duckdb-1.5.6-cp313-cp313-win_arm64.whl", hash = "sha256:41ecc75bb9328d72d154a705c1a653d2c5c60f686a5c0c6578aa80020753c884", size = 14020962, upload-time = "2026-09-28T13:38:19.007Z" }, + { url = "https://files.pythonhosted.org/packages/fb/62/a8a30a4c6b94c0861d348ed5633b963f6745a5525527530f02f3c1a7c931/duckdb-1.5.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:aa21d2ad803b2524326e8622d7d96b2bb1ff1d5b60368e1978ee805df9c21fb3", size = 32828003, upload-time = "2026-09-28T13:38:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/71/b7/1dcca0005eb8c67adf9fc06bf0cbb1d2bf4ea1974cc89e7a7c2ad66aac28/duckdb-1.5.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:8a1b2ad27d414068cbca06c55cfa802eece10f86ea4812ff082f8ab4cb25fc85", size = 17413912, upload-time = "2026-09-28T13:38:23.915Z" }, + { url = "https://files.pythonhosted.org/packages/93/b0/e3ac175443550f3464f2d95731a8b0aae9b4dc3875c3a186c352262b43c2/duckdb-1.5.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c79c6d222b1d015cde73b5139087186b00db65357fb4e2c94c2308fbbf465a72", size = 15543122, upload-time = "2026-09-28T13:38:26.317Z" }, + { url = "https://files.pythonhosted.org/packages/9d/08/cc510a7952aba69d5cdca17f3ef61c95713d86143f2ee9aa3e097d38f50b/duckdb-1.5.6-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1052b8050ef5696e2c0d8c836949c72f3dd11f0690466acbea739613e8e2750b", size = 19457946, upload-time = "2026-09-28T13:38:28.877Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a5/6f8099d9a5a02ddff89e5c85875df3465054845b0920fb0703fbdf8dd2ec/duckdb-1.5.6-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19c5e485e59613b8878d1670bcaa7a010f53c5a4da5ae8e08863e5e529ca6182", size = 21575132, upload-time = "2026-09-28T13:38:31.231Z" }, + { url = "https://files.pythonhosted.org/packages/9f/58/762f7159662d7859e201fa05ca29f306795daeabf84f3e087215a966b001/duckdb-1.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:ebcbd09cd8578ab1093393e9b16289cda0e8f1791ac595bf00eb5bad75c3cf00", size = 13713963, upload-time = "2026-09-28T13:38:33.543Z" }, + { url = "https://files.pythonhosted.org/packages/46/69/64d165db322de13f5c3e75d377b6b9694df1821155ad1fa4b14b04601abc/duckdb-1.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:820a8384faef11cd86068ea48c5da57ce2d8f1c7b3d2bdb9be3398317a7c3728", size = 14514368, upload-time = "2026-09-28T13:38:35.676Z" }, ] [[package]] @@ -1502,7 +1502,8 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "duckdb" }, { name = "packaging" }, - { name = "sqlalchemy" }, + { name = "sqlalchemy", version = "2.0.54", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sqlalchemy", version = "2.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/89/d5/c0d8d0a4ca3ffea92266f33d92a375e2794820ad89f9be97cf0c9a9697d0/duckdb_engine-0.17.0.tar.gz", hash = "sha256:396b23869754e536aa80881a92622b8b488015cf711c5a40032d05d2cf08f3cf", size = 48054, upload-time = "2025-03-29T09:49:17.663Z" } wheels = [ @@ -1541,14 +1542,14 @@ wheels = [ [[package]] name = "faker" -version = "40.39.0" +version = "40.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6d/62/fea935af7a312f073c99d470b05a7c96650ee8d9562ce82e8cb7ee4c1247/faker-40.39.0.tar.gz", hash = "sha256:52799ad96fcf92aab2fc96a9e03869bbf1a634300f7cd91c38dbfd51f639f6fa", size = 2029463, upload-time = "2026-09-14T16:50:17.939Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/43/7f5870d05331cb1649704040f51332d37f2468ed857de1e226482e2948b0/faker-40.40.0.tar.gz", hash = "sha256:803ee7f920679e7367c3f7b7f97752f5a9780aa5566c71723fbe57d591beafe1", size = 2029985, upload-time = "2026-09-29T00:56:19.983Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/c8/901282279841ebb59216433be01aac825fb905e1a597de3b7b5ab8b0c43e/faker-40.39.0-py3-none-any.whl", hash = "sha256:c4c7ec2cddfaf602c5a8a2c960614946aa8c161250bd9d49a8846cd24d1ef028", size = 2066232, upload-time = "2026-09-14T16:50:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d2/58beb2b8355f89b483fa21b99fe9934ee7312e3ccf228bb7cefe9e8d979c/faker-40.40.0-py3-none-any.whl", hash = "sha256:cd45ebdd1363f92a45740ac49945e49fa18f7e10771884a83c796a235550d7b7", size = 2066663, upload-time = "2026-09-29T00:56:17.952Z" }, ] [[package]] @@ -1661,11 +1662,11 @@ wheels = [ [[package]] name = "filelock" -version = "4.0.1" +version = "4.0.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/38/88cd6eda96c40594a1e3da7d8b40f04bc40ace5a6aef9ac5cb407540f173/filelock-4.0.1.tar.gz", hash = "sha256:fdefc3f3e87716d855ae2b732c1cfd521dd99799ef2b4d00e8c0d4dcdc7cc94b", size = 238888, upload-time = "2026-09-19T01:08:15.958Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/2d/5cb7a5ac017031e96a63173f3d57a8a5162ce36f9ff461e62bc6da4d88ab/filelock-4.0.6.tar.gz", hash = "sha256:323fab3b2fb22d889b29fa83774f60029addddb4b6a1bcfa1e73066eabffb5f2", size = 248237, upload-time = "2026-09-28T22:16:16.257Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/33/af0635ab07fe83b1788a1dbe370ff3e226062495a998335cb18a1cac81aa/filelock-4.0.1-py3-none-any.whl", hash = "sha256:481a321a27bef441e23c53371c6abc8d7d16e26b97090074ba44f7538a3fd55a", size = 106219, upload-time = "2026-09-19T01:08:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3b/b74ae9db2f78f1354278731372ae89870626be7b86cbb46b8d707acd1a98/filelock-4.0.6-py3-none-any.whl", hash = "sha256:9b139fb93b2ac5807f7feaf63aa4546fbd74074a2d3f554c040f40a4694d7b9f", size = 109077, upload-time = "2026-09-28T22:16:14.731Z" }, ] [[package]] @@ -1822,7 +1823,7 @@ s3 = [ [[package]] name = "google-adk" -version = "2.9.2" +version = "2.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiosqlite" }, @@ -1849,9 +1850,9 @@ dependencies = [ { name = "watchdog" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/db/08/7ac5e31305b9c0e1714af8ea8c1a5df16e980090eed5e5e456dc8fe5b583/google_adk-2.9.2.tar.gz", hash = "sha256:9d4b91a3cc3a52a471ca5831127e4c46ac174da0db519b4d157a09011737da10", size = 4002237, upload-time = "2026-09-18T18:10:46.112Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/2e/3f5a2ed6b50da6c4420ddc4fbc9f2be0fc2ba2e25b83345b622fcdf8a033/google_adk-2.10.0.tar.gz", hash = "sha256:9e8d64f18e91780e70bf32726250595a9ca38fc8d51d90750f7a71b44922432f", size = 4127907, upload-time = "2026-09-25T19:02:06.67Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/23/a6/e375f8a124e28f31c0cd0f9ff8607d7bdc652f8ce4bc67c39863353b4c6d/google_adk-2.9.2-py3-none-any.whl", hash = "sha256:ad887aadff34ebfe06586ded2b04848413c8bed6d6c7dd14dc7485ec7e54b2d2", size = 4631024, upload-time = "2026-09-18T18:10:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/29/8b/ae4a9750874ec35e4d88c2a8fd416884cb0695141691689e5affcaad17a7/google_adk-2.10.0-py3-none-any.whl", hash = "sha256:7550fecbb428a8a6de15ab88ba3c1d4704c752fe0b5594dc6d67ff440f370886", size = 4792014, upload-time = "2026-09-25T19:02:04.233Z" }, ] [[package]] @@ -1878,15 +1879,15 @@ grpc = [ [[package]] name = "google-auth" -version = "2.58.0" +version = "2.59.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ac/ca/f398a483ce5aad18ca2f735646e45ccee2439bd94a41a4ad0cfa646bd495/google_auth-2.58.0.tar.gz", hash = "sha256:55e30cf15e737de92c5323d78cda8a83fcd57e7ffbaf900c4600039fd60a80fd", size = 380018, upload-time = "2026-09-09T20:49:38.043Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/0b/9b4e806ebcd29701b5193a162dd9906c4c5a16cbde8476461622d2bfa70e/google_auth-2.59.0.tar.gz", hash = "sha256:eb32f44f89f6b577947ebee5887c1db46e6b1a278889ba369a88179643f32240", size = 381025, upload-time = "2026-09-28T21:49:59.047Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/13/477d90d09591b3938b45c4e11f4d8a51291682112cb5efcac961e815d562/google_auth-2.58.0-py3-none-any.whl", hash = "sha256:8a9c4645bb4c8e91668fb1934b95ae6a8687084232753639220ba9bf04a1610d", size = 262404, upload-time = "2026-09-09T20:49:33.951Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fd/4208e3ac1d7df39f4e8b044a3943eb8a5a99aaf8699285bf780f614a0b30/google_auth-2.59.0-py3-none-any.whl", hash = "sha256:43e47b1d3067fb90d77bd9ba21f139bb0577b3f2b9f3e2516fc00f37435efcad", size = 262487, upload-time = "2026-09-28T21:49:57.428Z" }, ] [package.optional-dependencies] @@ -2023,7 +2024,7 @@ wheels = [ [[package]] name = "google-cloud-storage" -version = "3.14.1" +version = "3.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core" }, @@ -2033,49 +2034,52 @@ dependencies = [ { name = "google-resumable-media" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/06/33e124df40437c292c7d666616d890c12cfea5b83842acf5be7dc3fde016/google_cloud_storage-3.14.1.tar.gz", hash = "sha256:b24e74b493c60b19b83462933a83bb831e45b6ca4924b0d75eac8e176b58b3a7", size = 17348933, upload-time = "2026-09-08T17:10:11.871Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/b9/de4f0a218485bdbac839ad337166997171471a29c7d28c03b41b37830379/google_cloud_storage-3.15.0.tar.gz", hash = "sha256:110041ee1cb566114015aa7c2492f1ff18c92af528588386663f4b41b6279989", size = 17350807, upload-time = "2026-09-24T23:20:16.709Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/65/7201c1816ae46e0b4e70a3ce61e5121963315dc9df2e00fcb8897a3d0926/google_cloud_storage-3.14.1-py3-none-any.whl", hash = "sha256:8f0fe2b74bd80ddcbd67247a3b47b81035a0a4f448569ed358fa40a46c940118", size = 343228, upload-time = "2026-09-08T17:10:09.833Z" }, + { url = "https://files.pythonhosted.org/packages/d7/e7/77c6358819e53f2cf5175e50b61edcf0af4f7fd1158e9703649659630b5c/google_cloud_storage-3.15.0-py3-none-any.whl", hash = "sha256:248905def997a300ad456c0696cf7fad04b8c38412c1103ebb2a60cc6341579f", size = 343392, upload-time = "2026-09-24T23:19:52.066Z" }, ] [[package]] name = "google-crc32c" -version = "1.8.0" +version = "1.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192, upload-time = "2025-12-16T00:35:25.142Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/ac/6f7bc93886a823ab545948c2dd48143027b2355ad1944c7cf852b338dc91/google_crc32c-1.8.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:0470b8c3d73b5f4e3300165498e4cf25221c7eb37f1159e221d1825b6df8a7ff", size = 31296, upload-time = "2025-12-16T00:19:07.261Z" }, - { url = "https://files.pythonhosted.org/packages/f7/97/a5accde175dee985311d949cfcb1249dcbb290f5ec83c994ea733311948f/google_crc32c-1.8.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:119fcd90c57c89f30040b47c211acee231b25a45d225e3225294386f5d258288", size = 30870, upload-time = "2025-12-16T00:29:17.669Z" }, - { url = "https://files.pythonhosted.org/packages/3d/63/bec827e70b7a0d4094e7476f863c0dbd6b5f0f1f91d9c9b32b76dcdfeb4e/google_crc32c-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6f35aaffc8ccd81ba3162443fabb920e65b1f20ab1952a31b13173a67811467d", size = 33214, upload-time = "2025-12-16T00:40:19.618Z" }, - { url = "https://files.pythonhosted.org/packages/63/bc/11b70614df04c289128d782efc084b9035ef8466b3d0a8757c1b6f5cf7ac/google_crc32c-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:864abafe7d6e2c4c66395c1eb0fe12dc891879769b52a3d56499612ca93b6092", size = 33589, upload-time = "2025-12-16T00:40:20.7Z" }, - { url = "https://files.pythonhosted.org/packages/3e/00/a08a4bc24f1261cc5b0f47312d8aebfbe4b53c2e6307f1b595605eed246b/google_crc32c-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:db3fe8eaf0612fc8b20fa21a5f25bd785bc3cd5be69f8f3412b0ac2ffd49e733", size = 34437, upload-time = "2025-12-16T00:35:19.437Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ef/21ccfaab3d5078d41efe8612e0ed0bfc9ce22475de074162a91a25f7980d/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:014a7e68d623e9a4222d663931febc3033c5c7c9730785727de2a81f87d5bab8", size = 31298, upload-time = "2025-12-16T00:20:32.241Z" }, - { url = "https://files.pythonhosted.org/packages/c5/b8/f8413d3f4b676136e965e764ceedec904fe38ae8de0cdc52a12d8eb1096e/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:86cfc00fe45a0ac7359e5214a1704e51a99e757d0272554874f419f79838c5f7", size = 30872, upload-time = "2025-12-16T00:33:58.785Z" }, - { url = "https://files.pythonhosted.org/packages/f6/fd/33aa4ec62b290477181c55bb1c9302c9698c58c0ce9a6ab4874abc8b0d60/google_crc32c-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:19b40d637a54cb71e0829179f6cb41835f0fbd9e8eb60552152a8b52c36cbe15", size = 33243, upload-time = "2025-12-16T00:40:21.46Z" }, - { url = "https://files.pythonhosted.org/packages/71/03/4820b3bd99c9653d1a5210cb32f9ba4da9681619b4d35b6a052432df4773/google_crc32c-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:17446feb05abddc187e5441a45971b8394ea4c1b6efd88ab0af393fd9e0a156a", size = 33608, upload-time = "2025-12-16T00:40:22.204Z" }, - { url = "https://files.pythonhosted.org/packages/7c/43/acf61476a11437bf9733fb2f70599b1ced11ec7ed9ea760fdd9a77d0c619/google_crc32c-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:71734788a88f551fbd6a97be9668a0020698e07b2bf5b3aa26a36c10cdfb27b2", size = 34439, upload-time = "2025-12-16T00:35:20.458Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5f/7307325b1198b59324c0fa9807cafb551afb65e831699f2ce211ad5c8240/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113", size = 31300, upload-time = "2025-12-16T00:21:56.723Z" }, - { url = "https://files.pythonhosted.org/packages/21/8e/58c0d5d86e2220e6a37befe7e6a94dd2f6006044b1a33edf1ff6d9f7e319/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb", size = 30867, upload-time = "2025-12-16T00:38:31.302Z" }, - { url = "https://files.pythonhosted.org/packages/ce/a9/a780cc66f86335a6019f557a8aaca8fbb970728f0efd2430d15ff1beae0e/google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", size = 33364, upload-time = "2025-12-16T00:40:22.96Z" }, - { url = "https://files.pythonhosted.org/packages/21/3f/3457ea803db0198c9aaca2dd373750972ce28a26f00544b6b85088811939/google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454", size = 33740, upload-time = "2025-12-16T00:40:23.96Z" }, - { url = "https://files.pythonhosted.org/packages/df/c0/87c2073e0c72515bb8733d4eef7b21548e8d189f094b5dad20b0ecaf64f6/google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962", size = 34437, upload-time = "2025-12-16T00:35:21.395Z" }, - { url = "https://files.pythonhosted.org/packages/d1/db/000f15b41724589b0e7bc24bc7a8967898d8d3bc8caf64c513d91ef1f6c0/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3ebb04528e83b2634857f43f9bb8ef5b2bbe7f10f140daeb01b58f972d04736b", size = 31297, upload-time = "2025-12-16T00:23:20.709Z" }, - { url = "https://files.pythonhosted.org/packages/d7/0d/8ebed0c39c53a7e838e2a486da8abb0e52de135f1b376ae2f0b160eb4c1a/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:450dc98429d3e33ed2926fc99ee81001928d63460f8538f21a5d6060912a8e27", size = 30867, upload-time = "2025-12-16T00:43:14.628Z" }, - { url = "https://files.pythonhosted.org/packages/ce/42/b468aec74a0354b34c8cbf748db20d6e350a68a2b0912e128cabee49806c/google_crc32c-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa", size = 33344, upload-time = "2025-12-16T00:40:24.742Z" }, - { url = "https://files.pythonhosted.org/packages/1c/e8/b33784d6fc77fb5062a8a7854e43e1e618b87d5ddf610a88025e4de6226e/google_crc32c-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:89c17d53d75562edfff86679244830599ee0a48efc216200691de8b02ab6b2b8", size = 33694, upload-time = "2025-12-16T00:40:25.505Z" }, - { url = "https://files.pythonhosted.org/packages/92/b1/d3cbd4d988afb3d8e4db94ca953df429ed6db7282ed0e700d25e6c7bfc8d/google_crc32c-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:57a50a9035b75643996fbf224d6661e386c7162d1dfdab9bc4ca790947d1007f", size = 34435, upload-time = "2025-12-16T00:35:22.107Z" }, - { url = "https://files.pythonhosted.org/packages/21/88/8ecf3c2b864a490b9e7010c84fd203ec8cf3b280651106a3a74dd1b0ca72/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:e6584b12cb06796d285d09e33f63309a09368b9d806a551d8036a4207ea43697", size = 31301, upload-time = "2025-12-16T00:24:48.527Z" }, - { url = "https://files.pythonhosted.org/packages/36/c6/f7ff6c11f5ca215d9f43d3629163727a272eabc356e5c9b2853df2bfe965/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:f4b51844ef67d6cf2e9425983274da75f18b1597bb2c998e1c0a0e8d46f8f651", size = 30868, upload-time = "2025-12-16T00:48:12.163Z" }, - { url = "https://files.pythonhosted.org/packages/56/15/c25671c7aad70f8179d858c55a6ae8404902abe0cdcf32a29d581792b491/google_crc32c-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b0d1a7afc6e8e4635564ba8aa5c0548e3173e41b6384d7711a9123165f582de2", size = 33381, upload-time = "2025-12-16T00:40:26.268Z" }, - { url = "https://files.pythonhosted.org/packages/42/fa/f50f51260d7b0ef5d4898af122d8a7ec5a84e2984f676f746445f783705f/google_crc32c-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3f68782f3cbd1bce027e48768293072813469af6a61a86f6bb4977a4380f21", size = 33734, upload-time = "2025-12-16T00:40:27.028Z" }, - { url = "https://files.pythonhosted.org/packages/08/a5/7b059810934a09fb3ccb657e0843813c1fee1183d3bc2c8041800374aa2c/google_crc32c-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:d511b3153e7011a27ab6ee6bb3a5404a55b994dc1a7322c0b87b29606d9790e2", size = 34878, upload-time = "2025-12-16T00:35:23.142Z" }, - { url = "https://files.pythonhosted.org/packages/52/c5/c171e4d8c44fec1422d801a6d2e5d7ddabd733eeda505c79730ee9607f07/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:87fa445064e7db928226b2e6f0d5304ab4cd0339e664a4e9a25029f384d9bb93", size = 28615, upload-time = "2025-12-16T00:40:29.298Z" }, - { url = "https://files.pythonhosted.org/packages/9c/97/7d75fe37a7a6ed171a2cf17117177e7aab7e6e0d115858741b41e9dd4254/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f639065ea2042d5c034bf258a9f085eaa7af0cd250667c0635a3118e8f92c69c", size = 28800, upload-time = "2025-12-16T00:40:30.322Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/fa/25/9cb0c1c31c45b893eb8f11ae70b3f4309432d59b5acaebca5dbe791729a4/google_crc32c-1.9.0.tar.gz", hash = "sha256:7b8c84c3d159ab6817fe3f74e6e6cef099c3f95dcec3abc0d8afb1404642efbe", size = 14857, upload-time = "2026-09-24T21:39:32.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/7c/e89a13c971bcab4a0464ecc78f8dc162c5c7ec8986a54dd867fc093b2c6f/google_crc32c-1.9.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:e6b529a6a287104ec79d281c411685231200ce954a29c28ab8e5093cb6e130fb", size = 31959, upload-time = "2026-09-24T21:19:00.091Z" }, + { url = "https://files.pythonhosted.org/packages/c6/06/510062c2acbdbf602d759b7b0086032c487126106bb25197b9da1ff1047f/google_crc32c-1.9.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:51cb4e23a38ad4f495f35f87c233ca3ea6b9c4559e7ac383cdef786fab0f7977", size = 31801, upload-time = "2026-09-24T21:22:24.222Z" }, + { url = "https://files.pythonhosted.org/packages/9a/c6/53eaa12dc62625f4605760b09854a0814ef6afebdc0e611a38c7b15aa6d1/google_crc32c-1.9.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8535e75dfead304f30e9122b9ea2c0a570dbaa52c176a0a591540c7914c1e46d", size = 37203, upload-time = "2026-09-24T21:38:04.791Z" }, + { url = "https://files.pythonhosted.org/packages/dd/92/770c2713df471df73998f79758739da83e410ef576bfafd05e5e845959ff/google_crc32c-1.9.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:280f3a3e47af0eeba3a3e5aa7d311af77001812b8df80fb8beafcd0b40eaf7f1", size = 35816, upload-time = "2026-09-24T21:38:05.798Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f3/181945217690644aa502220a3ec9bf0d2ef9af930bbfffee555fe5236e2f/google_crc32c-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:56610f548f1b35c9568b9d1de30423480f505dae4991556072d5802820ff35c4", size = 35097, upload-time = "2026-09-24T21:39:27.402Z" }, + { url = "https://files.pythonhosted.org/packages/0e/55/a2f07f15e624f0de79359b1a6c1deb59ec5061bd3b38744b3b2849400662/google_crc32c-1.9.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:457d0d9a4718fd52b1494eac5c200ad25beeadbdc91843d550a003910838589f", size = 31958, upload-time = "2026-09-24T21:19:00.994Z" }, + { url = "https://files.pythonhosted.org/packages/f8/b3/923743597b774bbcf12a7c3e00e48d745e15fd616ad7489a40a63fff8f2f/google_crc32c-1.9.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:ccfe40021fd6afe23361175cf7551e3cef5fd34dc1ebe319f14993a83579e0eb", size = 31805, upload-time = "2026-09-24T21:22:25.019Z" }, + { url = "https://files.pythonhosted.org/packages/df/a6/4d0352fe889663e0d81cea7fc664ec9158727384de4a44ab10e9967a7682/google_crc32c-1.9.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fbef61a3794e011c65fb4396a196cf123a7f474fe5a443db8e5dd7d751b9e6d4", size = 38119, upload-time = "2026-09-24T21:38:06.634Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e3/26685384e4b66ff0928d9566ef6110a7df76029175a1842329d7e3515f10/google_crc32c-1.9.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86764b99e7a607830d93cb5b75e0ec3ff6cb06d3c274624418473cee701900d4", size = 36677, upload-time = "2026-09-24T21:38:08.082Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ce/4e90102e84880e97d3cf935f2672ecd29191bdeacf57f01740f92debda00/google_crc32c-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:43a2dc26f9be213fbe0b4fc4a1088c5d45cbfcb3247420ccc820f0fc3edeea86", size = 35096, upload-time = "2026-09-24T21:39:28.201Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5d/0730e1b3a14d054d1466f2fec88dadf978509c749a3d96d8b069cc56d38a/google_crc32c-1.9.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:53fdafef58e230d0c946ab5f8446d123d9f548230a73b29c8b41c9546f268bc1", size = 31961, upload-time = "2026-09-24T21:19:01.724Z" }, + { url = "https://files.pythonhosted.org/packages/dd/32/d085abaf2fd907121975b92245bb3480fb8be40c37d03f9d6c41857f84c3/google_crc32c-1.9.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:8b91f41645b15a720357183fa5716682ada441873e3c462c15f9714be36f146b", size = 31804, upload-time = "2026-09-24T21:22:25.81Z" }, + { url = "https://files.pythonhosted.org/packages/94/78/dd1935432337e5da7af391a6fc9f161c1c8e9b9002a402b9190135fe1b59/google_crc32c-1.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:16865b477d7941712cb0e0aad8ad4815e984fb5fc16d3fdaef7d986e26e53c95", size = 37775, upload-time = "2026-09-24T21:38:09.249Z" }, + { url = "https://files.pythonhosted.org/packages/9e/43/9db03635bb10188d93dcbab9baa2a8670a0da4e868b4370cdbd98d65fed8/google_crc32c-1.9.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3abb18297d9ef0ab120531838be0e6d68c9fa876570e11c229c48f2edac23ce7", size = 36347, upload-time = "2026-09-24T21:38:10.141Z" }, + { url = "https://files.pythonhosted.org/packages/cf/eb/94dee516c846bd9382c3f566d8f8e5fb9e90599e45afeb697f9fc2533528/google_crc32c-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:fb63a8d7fa2e95dcff1ca16af2f4d88b526fa5ff72d1696285884ac2d49b6963", size = 35097, upload-time = "2026-09-24T21:39:28.934Z" }, + { url = "https://files.pythonhosted.org/packages/3f/34/cb484e8b6174f130f8c6dc79c733a9dd8869b410ad6511fb6104c46b973a/google_crc32c-1.9.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:f1dc17d987ddcc5eba12a7ce48f0eb93141dea236b170c1101151396edf2f0cf", size = 31960, upload-time = "2026-09-24T21:19:02.454Z" }, + { url = "https://files.pythonhosted.org/packages/af/25/3e8e567bd48448e225ea27318ccf2b94e05124e7b8b97b13eaec9e127199/google_crc32c-1.9.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:f894a2877650b56201d26a012a257b76d54a68834dc3913a93830ca8a047b075", size = 31803, upload-time = "2026-09-24T21:22:27.008Z" }, + { url = "https://files.pythonhosted.org/packages/f0/18/bee0dd59ae622482dc6463636c79e4bde7c954d061c859c9256362c9931a/google_crc32c-1.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4488f1553a9ab7e86cdedc833374a7e904031803b995dc0bd0be48c271fa6556", size = 37776, upload-time = "2026-09-24T21:38:11.056Z" }, + { url = "https://files.pythonhosted.org/packages/fd/b6/e76e80fed5f2558273c7839e622f98095c9b36c719c7147e38e3c055cb70/google_crc32c-1.9.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0568b17ed90ac596f29400d99e243fd0cc6276766183def888d1bf8d1dc13827", size = 36350, upload-time = "2026-09-24T21:38:12.138Z" }, + { url = "https://files.pythonhosted.org/packages/87/34/165542bfa99dfef91a76471cc48cce74b8ff4e295722896087ab2b8e8611/google_crc32c-1.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:8583ec21d56b565d68ab2963cc7e21b3b271247c29b04286068255ef65f221bd", size = 35090, upload-time = "2026-09-24T21:39:29.764Z" }, + { url = "https://files.pythonhosted.org/packages/8f/eb/43ea41f4061a1cad87b2b6559c98e960e45bf551fe66f83d833b98aaf0c9/google_crc32c-1.9.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:6a3b2c8a343c570ed8100a7627c20badfd92c6caa2067093a86be45af27f5b1b", size = 31963, upload-time = "2026-09-24T21:19:03.208Z" }, + { url = "https://files.pythonhosted.org/packages/45/d2/a968c0c29ccd2b0c980ff4f9e3f7035cee28c23a1c57541825cc8221858c/google_crc32c-1.9.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:13179f7e3282617923e957b8e54b8f9c3968030f48640a9f47fd7c5c38c4a215", size = 31805, upload-time = "2026-09-24T21:22:27.917Z" }, + { url = "https://files.pythonhosted.org/packages/03/73/388e493d6c3e252e37165d22efe5a1361f872a24425391b999822861b23a/google_crc32c-1.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:265233aff33d835f5b909584fe36ab29647b598c271b661a300001099109e53e", size = 37925, upload-time = "2026-09-24T21:38:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/36/190d32caa363ef25d685f422ed1bbf93ff1140fb22fd4d90f24cec209977/google_crc32c-1.9.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dee799544cae42a42b17a88e38b59cf2c271051dc001da2117a8ff240ffa0548", size = 36495, upload-time = "2026-09-24T21:38:14.211Z" }, + { url = "https://files.pythonhosted.org/packages/d3/fd/81cefea6adae7bd92abb23d4567d199f6485a20ec0a305ca5fa04c52b9c5/google_crc32c-1.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:af73200fa9791ccd380f3598235dba8d82b8af0905df045b3dc60b59836e8ddd", size = 35535, upload-time = "2026-09-24T21:39:30.52Z" }, + { url = "https://files.pythonhosted.org/packages/c5/18/19d4f17f3f33f8fdffcb3e1e69219d6f7ec2c359c160867b04dac1d0a64d/google_crc32c-1.9.0-cp315-cp315-macosx_12_0_arm64.whl", hash = "sha256:e6e8be8a94436079cb5340f6d495d9d7ba30124d8b952703994c739c7c06e236", size = 31977, upload-time = "2026-09-24T21:19:03.976Z" }, + { url = "https://files.pythonhosted.org/packages/81/b4/8010372c4b46f2ee2352dfdb630c397570cd85522a315df024ad2f9459aa/google_crc32c-1.9.0-cp315-cp315-macosx_12_0_x86_64.whl", hash = "sha256:f2b64641bca27497b986b9d87883014035aa904cb4fa333407c6752b3afee9ba", size = 31809, upload-time = "2026-09-24T21:22:29.1Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f8/7e33845d6b90ce1cf37cfabf25cb859277c7d3533ef1b6b1e1ca58581549/google_crc32c-1.9.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f97c3806dcea41c29c04965347b0e12481561b75e0045dc7a4f69d75dec5d9b1", size = 38359, upload-time = "2026-09-24T21:38:14.983Z" }, + { url = "https://files.pythonhosted.org/packages/36/ff/556b2423f449a7515af6b8222a4d7833cbe09ff3e8d2f0b80471f5f6d02e/google_crc32c-1.9.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0abe7e202c25909869c35672ab0f2fe748a7acf276eb78577332a7c38999740f", size = 36858, upload-time = "2026-09-24T21:38:15.799Z" }, + { url = "https://files.pythonhosted.org/packages/40/71/4733f1b7c921d04a2bb9b9916cf66498bf7ad0860a06289413830da83192/google_crc32c-1.9.0-cp315-cp315-win_amd64.whl", hash = "sha256:5695c8b9327e040b2aba12c6659b0acb5995314ef0af0192da66e662e011103b", size = 35534, upload-time = "2026-09-24T21:39:31.337Z" }, ] [[package]] name = "google-genai" -version = "2.24.0" +version = "2.25.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -2089,9 +2093,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/35/26/176d04f442e82d60cadc74cfaf81f01017062ce274a790455a57aec48c8f/google_genai-2.24.0.tar.gz", hash = "sha256:814b63d05dca4776c5a322699eb30c565040cf8d5fd77c38714076535fb2e019", size = 705409, upload-time = "2026-09-16T22:38:37.367Z" } +sdist = { url = "https://files.pythonhosted.org/packages/62/0a/a3b7856ca840031d4393dbdd97b67fe811b20061315ed68b67b5c85ca80d/google_genai-2.25.0.tar.gz", hash = "sha256:ab603baa5eee0205926ad0f8d7f93e0400df6d67650e99c33e01ab228ea16ad6", size = 699564, upload-time = "2026-09-22T17:23:01.238Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/5b/87913fe392ba7b6402577259289ed3740c577956554a674b191a2971d33e/google_genai-2.24.0-py3-none-any.whl", hash = "sha256:59e5f2d88bc2d6ed04aa1aa14cb5629090cfe9458401745e620b24d1fe9893d7", size = 1142675, upload-time = "2026-09-16T22:38:35.332Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a8/178dbb9d1d6cac721b01592e291146a024bae5ee3224e36569348921dd6c/google_genai-2.25.0-py3-none-any.whl", hash = "sha256:2e8f3a5f76ed00d5ffc3153f6089bc7d3511054eed53ad9cfc6095a7dc59b028", size = 1159631, upload-time = "2026-09-22T17:22:59.291Z" }, ] [[package]] @@ -2108,14 +2112,14 @@ wheels = [ [[package]] name = "googleapis-common-protos" -version = "1.75.3" +version = "1.75.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8a/c5/4353a188e2c335aee33269e8b654af228278cca8e5f0b4b5f11e5d0e9adb/googleapis_common_protos-1.75.3.tar.gz", hash = "sha256:57c435ac2c68b108999b6db075d9053e4d7a936ba57b4a3d45667b1346f1738a", size = 153905, upload-time = "2026-09-03T22:31:21.869Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/13/f83676de1dce4f8106bcba91725b3f3f4baf6ca1977685102b008b8e0097/googleapis_common_protos-1.75.4.tar.gz", hash = "sha256:4587babdc82a8d7e5a3d4f5a6697e064bf44a598b4d08341c212b68185eadbcd", size = 154248, upload-time = "2026-09-24T23:20:21.759Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/7a/7d79170c6ce6f12e109df2b3879d6b934010cf4f99aea8de8b7e5408c174/googleapis_common_protos-1.75.3-py3-none-any.whl", hash = "sha256:a018d2bf098ca9fb6faa08d5bb780e2a2c2f73c566f069761331386c9596d3f2", size = 306984, upload-time = "2026-09-03T22:30:45.133Z" }, + { url = "https://files.pythonhosted.org/packages/11/59/6474c44460037b8474d85c1a82311ec7244f3aabd53197be5b06a4db778e/googleapis_common_protos-1.75.4-py3-none-any.whl", hash = "sha256:e8eb9cffa9a9f3423090cc7a086012004196abab388a0561690e81887fd79cc1", size = 307743, upload-time = "2026-09-24T23:19:57.136Z" }, ] [package.optional-dependencies] @@ -2394,15 +2398,15 @@ wheels = [ [[package]] name = "httpcore2" -version = "2.13.0" +version = "2.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "h11" }, { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/15/8c/e925b1c92018abb3a1863ce1549d76d2381e334d21d65d4ac8f65dabd78a/httpcore2-2.13.0.tar.gz", hash = "sha256:2adc8be4fb285fbcd6d894298db3b52c177e74b6674eda3a76bd36be3292a3db", size = 67740, upload-time = "2026-09-14T14:18:04.717Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/0d/117a771a2bb91df334b66bf4da14cd02f21aefbcfe53180f336ce55e8f90/httpcore2-2.13.0-py3-none-any.whl", hash = "sha256:35ae5be347aa40467b4a5dc032ac67ebb6d27189fc97e8cebcf99616f6a1bb9e", size = 83162, upload-time = "2026-09-14T14:18:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" }, ] [[package]] @@ -2472,7 +2476,7 @@ wheels = [ [[package]] name = "httpx2" -version = "2.13.0" +version = "2.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform != 'emscripten'" }, @@ -2482,9 +2486,9 @@ dependencies = [ { name = "truststore", marker = "sys_platform != 'emscripten'" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/a0/e9deef4654132857b5a5dbe4eddd0ac59c2814500e11f2f5044cd81103ee/httpx2-2.13.0.tar.gz", hash = "sha256:81bd07dc67a3701729ef1f777a3c00c915d4539604fdb5afd327f8682f6b7b44", size = 100290, upload-time = "2026-09-14T14:18:05.486Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/44/474bef2a0e9d90f1715d32cb98b0738695ca17ba324095fb2497ed7fbd59/httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa", size = 100405, upload-time = "2026-09-23T07:47:23.052Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/d1/a0c72b0e006df654709fbc366cc5bcb53e5aee13e1e3395152c6dd293376/httpx2-2.13.0-py3-none-any.whl", hash = "sha256:fc12720cedf72faa26cca6b4ca394e05c894e7d7933fc45cafe767960804e49a", size = 95565, upload-time = "2026-09-14T14:18:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/d8/9c/6fe8931fd9f381042a9e4c7d5a7b4cbf7016b252bec0c99a49fce42c3326/httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4", size = 95597, upload-time = "2026-09-23T07:47:20.995Z" }, ] [[package]] @@ -2642,132 +2646,132 @@ wheels = [ [[package]] name = "librt" -version = "0.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/48/12/e2e9ca532cf5a0e08c9489826c4a35c6958c92ba0313fda70e8c6c3912be/librt-0.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1a49adf16a7c9d9646816c2946135527197b6fcf4347c7b8b761cf1bfbf4489", size = 148673, upload-time = "2026-08-07T10:46:22.569Z" }, - { url = "https://files.pythonhosted.org/packages/6d/7c/02005e23478bd5950618d9712e0fd2b4c511657857f3efd8ba6a5feabcdd/librt-0.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81a398f45b45a59200e13cd5ad1ae1d3f44334de98b148331afe2cdfee701c52", size = 153547, upload-time = "2026-08-07T10:46:23.931Z" }, - { url = "https://files.pythonhosted.org/packages/a0/90/d8848a735f5642077fc4b3b4bebcdb08edf10178e3add45597f5201a368f/librt-0.15.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4eafbaff06b9563f8b1c850621ce51605de05208e09d4d71ce490bc972b7b9e8", size = 494355, upload-time = "2026-08-07T10:46:25.122Z" }, - { url = "https://files.pythonhosted.org/packages/e1/0b/8604f41ea02feace490e9e405a338a15f9905369f55b239a9ce31c946f24/librt-0.15.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b0411b4066db926b80258c60dcb0e6db4c9cee312eab45b7e8866b17ddf9ada1", size = 485459, upload-time = "2026-08-07T10:46:26.447Z" }, - { url = "https://files.pythonhosted.org/packages/a0/ac/84153bda1ce0da609182527ab92b40d961809e544eefdc5a1c2422971416/librt-0.15.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:febb1ce6cac545a54e6b769982824e955a700fdd9fbf3a08a3d82c990968b57d", size = 498398, upload-time = "2026-08-07T10:46:27.701Z" }, - { url = "https://files.pythonhosted.org/packages/2c/3a/5ca6cd282b2c244bec8ec84102e09773264e9c02891d56ab3a8f0e4d7083/librt-0.15.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b230acc1c3bfe2d6f2627ba2b95dc92e58aa494600e9722d0e6ccbc931e59702", size = 515474, upload-time = "2026-08-07T10:46:28.9Z" }, - { url = "https://files.pythonhosted.org/packages/73/d3/bd34110234779eb843c6ed66aba7c9b2091d3dd85989f1fb9922f564cb7a/librt-0.15.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6da110e5f314c19ab8478464d02ae18808ae73d522c15260fa4918acdcd64da9", size = 509484, upload-time = "2026-08-07T10:46:30.124Z" }, - { url = "https://files.pythonhosted.org/packages/1b/6c/43c3f7f071d71631a7daa3b835ef2168ea39f20692d81464d4e47fbaa6d6/librt-0.15.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:eab9208b00ca55bf75983ec99f7bf13acc746a36102e98953addaad7f7ea1e1b", size = 532534, upload-time = "2026-08-07T10:46:31.511Z" }, - { url = "https://files.pythonhosted.org/packages/c5/1c/b854adf036ea817c40408873a5b794d65a91d9f0f39826f2ad2a2d5d7f48/librt-0.15.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6c013cd3a1721e69e14380ada97eaa4b7b0cdf1c6b96fa765d4ea47c875088db", size = 537087, upload-time = "2026-08-07T10:46:32.734Z" }, - { url = "https://files.pythonhosted.org/packages/25/5c/c9a890e244e7dd725d3bd8b560e41f0aec787eaf343b46956a290ab7b841/librt-0.15.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:567b1c430f8bd560e689421468278ac5941bab4a05303b5d95b6ae10db03f451", size = 536575, upload-time = "2026-08-07T10:46:33.965Z" }, - { url = "https://files.pythonhosted.org/packages/5f/c5/c8e70b60b704299555f55db468eb46b1c81bfc60201ffbfe20407d89870c/librt-0.15.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:29c4cab9df457b19672c39be7f384ebb2bc925c4e2684b8780c222b43eb36389", size = 517142, upload-time = "2026-08-07T10:46:35.577Z" }, - { url = "https://files.pythonhosted.org/packages/56/d1/767a90c41f5d381b3195bc88ac0ec4afda35777c9c781e1f9848fedd965e/librt-0.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bccbd8e5b0bffb7106cf18eb1baa3d7194b1cebb3b4b1cdbd4bdb19382a6ee6c", size = 558714, upload-time = "2026-08-07T10:46:36.829Z" }, - { url = "https://files.pythonhosted.org/packages/f9/b4/3c0624b8dc8301ab808f2b3a910995bcabe28df070fb9a0e5505ae997dae/librt-0.15.0-cp310-cp310-win32.whl", hash = "sha256:8ae493ed5f659a7761c43d42f183db514536073ded9bcf671d2d1df47e29a07e", size = 104426, upload-time = "2026-08-07T10:46:38.594Z" }, - { url = "https://files.pythonhosted.org/packages/31/98/e91c0382304bedb2db9c6801897319a9dcb68daac5e975819b562362f20d/librt-0.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:bc25fb356d0c7810bb49ff3df908ad1fda6995d660ab099ded69244ed7ab6053", size = 125057, upload-time = "2026-08-07T10:46:40.052Z" }, - { url = "https://files.pythonhosted.org/packages/59/52/06790ced2ac7117f890c21bda43c39c958ec82aa665c0718e821d33ff939/librt-0.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:823b92cf3c18ecd08afc70c42473888b41b6e8ef5046f3b82c05c154a2fa3d22", size = 148039, upload-time = "2026-08-07T10:46:41.165Z" }, - { url = "https://files.pythonhosted.org/packages/e7/1d/8e150b7fc449a1f33c8a760965cc1f43b14fc1577d9d0b50ab2701420e74/librt-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c70bc1b602cf59917e8f0c7a2cbc8bcc6fbc14d5486136b00707a79619121d63", size = 153067, upload-time = "2026-08-07T10:46:42.418Z" }, - { url = "https://files.pythonhosted.org/packages/51/87/a162bc5a66a35599dc619ecb215145f4de7d68e886b479b6d12593139f7c/librt-0.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:814ff83a25b5fce8b9c80c4dd803153fb5c5599fc74db9e022466938368957ef", size = 493087, upload-time = "2026-08-07T10:46:43.657Z" }, - { url = "https://files.pythonhosted.org/packages/e5/3a/aeea1fc620cf48060d3065b37614edbf97043c099d0f50782bc8ca61d897/librt-0.15.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:57f5eeb6ad4c180de583b1038e61fe5fbd9796bb69a8a1c1a0c7ddbec4c8c60f", size = 485608, upload-time = "2026-08-07T10:46:45.038Z" }, - { url = "https://files.pythonhosted.org/packages/52/ff/fe571ad416f0856fd0d5578ffc2e6dc531891e586e36b647bcf50569cab8/librt-0.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82909c8f7eb9952656b65d3147afde4cf8e6d5a991eebc86418b5e65843b0ab8", size = 498723, upload-time = "2026-08-07T10:46:46.35Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e1/7a65eb5dedb1f00aebd948cdd8e17add48bf066cab3514e9daf84ab45a6c/librt-0.15.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f779070399f991400fc451719e0ea388eb7de313388bada2c127a35de05f798a", size = 516002, upload-time = "2026-08-07T10:46:47.599Z" }, - { url = "https://files.pythonhosted.org/packages/5f/45/59832b0ebfbd08c2742e6ece372ceb53f18bf1faef5d33c8daf3abebf749/librt-0.15.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bac89069bc496ebdf4f79ebb57bbd10d0b214c8454225deb672d91002bd17e18", size = 508607, upload-time = "2026-08-07T10:46:48.873Z" }, - { url = "https://files.pythonhosted.org/packages/ea/0d/37fa73f3b43ebd8259f91ae9102a15e5a54e65d581e48dea72df3e81d7a4/librt-0.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e0d00c708fb2f5822b152429b1ac80a58dbbbc3f6c232c4d13a3f7fcf2ea5b4c", size = 530422, upload-time = "2026-08-07T10:46:50.45Z" }, - { url = "https://files.pythonhosted.org/packages/26/02/e046c6fe7a5881ac34623242192f484426ba8a75595fd18f22c53a3f530f/librt-0.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6c6624fe268625869485553dd7cc1daf30d22558215bb2a4ff16f67a9801a31a", size = 534303, upload-time = "2026-08-07T10:46:51.693Z" }, - { url = "https://files.pythonhosted.org/packages/95/32/d5e6d861ab0366f3edf74f887ab0c9eb9f535aaf01d32b80b4f734daa179/librt-0.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f56b397858a23dacf35ede366ed2212fdc03a6a57a1ad36468ad6e9dc5fac091", size = 536084, upload-time = "2026-08-07T10:46:52.951Z" }, - { url = "https://files.pythonhosted.org/packages/2a/de/d69d725513fe53fc90c6d7a1f86e4428939bad2fb905b17fe4c18d413dde/librt-0.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4388184646efe2054911c5b00a1077d6d1ee86a95b7e8ba96dc7850a809f3f40", size = 514307, upload-time = "2026-08-07T10:46:54.194Z" }, - { url = "https://files.pythonhosted.org/packages/36/93/f8aded0d6682b4f25820fa86e0690f87f01df9fd7bd09ddb04d9167ad021/librt-0.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:97335f59082f9fe2ce6c2a9cc6433a0114bbb6cd4d5c09dd76c95c68b9f9a8b0", size = 557686, upload-time = "2026-08-07T10:46:55.443Z" }, - { url = "https://files.pythonhosted.org/packages/74/09/ffeb6bdeb6cd862b4272fddc8ad05f938dd25d020ed517e631813917d80a/librt-0.15.0-cp311-cp311-win32.whl", hash = "sha256:83380ffde38062a2e9bb55d83e74474f6614665528b98a6928720fc006dfffbb", size = 104917, upload-time = "2026-08-07T10:46:56.605Z" }, - { url = "https://files.pythonhosted.org/packages/96/28/7e2313a3ffbf0b4de7ba3da58a09e488507b4bd1ea2b5e69378354a23415/librt-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:f75720477ee05d509a310e856cacc8d909adc182f7b91193c207bcc26d7ee6db", size = 125886, upload-time = "2026-08-07T10:46:57.729Z" }, - { url = "https://files.pythonhosted.org/packages/39/9e/04b8c3cde014ef255ee785730425268354543acc38902093a40afa0dc164/librt-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:256237037a3ab001ae8d9803b2d43562a4c3aa38739843694349e4d5ebb0fd56", size = 111885, upload-time = "2026-08-07T10:46:58.787Z" }, - { url = "https://files.pythonhosted.org/packages/ba/39/99c25030e782bdfb7a21be8c05254806a2e4bbb05c8d50c2a2130acbfa05/librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e", size = 151021, upload-time = "2026-08-07T10:47:00.057Z" }, - { url = "https://files.pythonhosted.org/packages/14/43/f4b1bd1b2888798a1409808889a25ea1ba49eaabce7d681ed27734c2df9d/librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d", size = 155267, upload-time = "2026-08-07T10:47:01.311Z" }, - { url = "https://files.pythonhosted.org/packages/0c/db/3ad9c965c72f1e1d6beeec44ec10a54e17be8ae042fbb4baade16cbadced/librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1", size = 503136, upload-time = "2026-08-07T10:47:02.45Z" }, - { url = "https://files.pythonhosted.org/packages/4b/07/5888a6d76acd62ebce66c61b74d94e9370b9c32929f111e487bb6546f8ed/librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa", size = 496670, upload-time = "2026-08-07T10:47:03.675Z" }, - { url = "https://files.pythonhosted.org/packages/29/39/ab57cc2f5b276156da02bb7f5a8921bada1cb1993ffec99acf811c602c23/librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd", size = 513688, upload-time = "2026-08-07T10:47:04.981Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b9/bdbb0b648b5c2befb031f4c6f3b1dd857415e8fb492a25a3c764a6681e6c/librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa", size = 531904, upload-time = "2026-08-07T10:47:06.211Z" }, - { url = "https://files.pythonhosted.org/packages/93/26/473c2e4b6c104e9e58e27ce95fc8005c8bd4fc36cae4f254371125a92db8/librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d", size = 524427, upload-time = "2026-08-07T10:47:07.592Z" }, - { url = "https://files.pythonhosted.org/packages/26/60/03b3abb82b41714671b907bf6989b228e31e6a8af52dec82b5b0728dc250/librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656", size = 543155, upload-time = "2026-08-07T10:47:08.866Z" }, - { url = "https://files.pythonhosted.org/packages/f2/0e/9bb1f0a4affbd0a1888f4f79dc03ed2a299d9a2c26c59ab2a97dcbf11903/librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81", size = 546890, upload-time = "2026-08-07T10:47:10.327Z" }, - { url = "https://files.pythonhosted.org/packages/dc/84/6937a280d461f7de6e031ffb02edc2b7c3c90d49d630565ce8ff27cbc5f2/librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d", size = 555163, upload-time = "2026-08-07T10:47:11.798Z" }, - { url = "https://files.pythonhosted.org/packages/bc/95/2a2853c1ee014bf102116e7f897a04beeaeb2461b45b79af98bdfb95f1ef/librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0", size = 535812, upload-time = "2026-08-07T10:47:13.279Z" }, - { url = "https://files.pythonhosted.org/packages/c9/4c/cf9601c1b4c5f09280acd5d83abdb2e68527a2be8257136eb42304218622/librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2", size = 573688, upload-time = "2026-08-07T10:47:14.727Z" }, - { url = "https://files.pythonhosted.org/packages/47/6d/9ac7cbec46189a7625af4b5acbd25f10d827f4141b2002181848c8418923/librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1", size = 106138, upload-time = "2026-08-07T10:47:15.973Z" }, - { url = "https://files.pythonhosted.org/packages/38/d0/2ae99c83be86ce23f925ac1aeeedc777e97f427c4a8d190c70d0a16e9a87/librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022", size = 126974, upload-time = "2026-08-07T10:47:17.049Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ef/dd24f9635c730b86b87587967dda7516b1845e8b17684603d31607fed598/librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570", size = 112292, upload-time = "2026-08-07T10:47:18.222Z" }, - { url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" }, - { url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" }, - { url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" }, - { url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" }, - { url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" }, - { url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" }, - { url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" }, - { url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" }, - { url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" }, - { url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" }, - { url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" }, - { url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" }, - { url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" }, - { url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231, upload-time = "2026-08-07T10:47:36.251Z" }, - { url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996, upload-time = "2026-08-07T10:47:37.453Z" }, - { url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188, upload-time = "2026-08-07T10:47:38.729Z" }, - { url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" }, - { url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" }, - { url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" }, - { url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" }, - { url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" }, - { url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" }, - { url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" }, - { url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" }, - { url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" }, - { url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" }, - { url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" }, - { url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" }, - { url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" }, - { url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" }, - { url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" }, - { url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" }, - { url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" }, - { url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" }, - { url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" }, - { url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" }, - { url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" }, - { url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" }, - { url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" }, - { url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" }, - { url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" }, - { url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" }, - { url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" }, - { url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" }, - { url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" }, - { url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" }, - { url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" }, - { url = "https://files.pythonhosted.org/packages/c6/44/4de9f4ddadb009a55c7758eb5736d62534a7daaf27bd71bc50e64b606b06/librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca", size = 149843, upload-time = "2026-08-07T10:48:25.148Z" }, - { url = "https://files.pythonhosted.org/packages/1f/eb/5d9ab71e30119c44094e0275f38b47dd327aea0f843a080396677029d508/librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965", size = 154510, upload-time = "2026-08-07T10:48:26.485Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9c/8505d1b8f5e8c19587bd03f7429993b3e9ce5c06819d856bfb11d919374c/librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad", size = 497543, upload-time = "2026-08-07T10:48:28.045Z" }, - { url = "https://files.pythonhosted.org/packages/1d/9a/3a8390775cb095765aded027ac9c63e7c8ea74e731498607544c6505de0e/librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9", size = 480452, upload-time = "2026-08-07T10:48:29.531Z" }, - { url = "https://files.pythonhosted.org/packages/e7/40/258a4a7117ee915d66de5cd9b8ade65a440993161107ce3a686f1859955c/librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328", size = 507768, upload-time = "2026-08-07T10:48:31.007Z" }, - { url = "https://files.pythonhosted.org/packages/6b/c6/2f4dd296c97a0b85b98894519b279408ec9dd602d4f692b1ea0e25dee670/librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0", size = 525122, upload-time = "2026-08-07T10:48:32.7Z" }, - { url = "https://files.pythonhosted.org/packages/49/dd/29eab42be13b2bf0ea8cb227135a45d44693e30a7e8b92871981ff56b82b/librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9", size = 520371, upload-time = "2026-08-07T10:48:34.294Z" }, - { url = "https://files.pythonhosted.org/packages/91/ed/4bad71adeca8fe208b775c2a35417fa5a2584c8f4791daaf89a89450fea1/librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659", size = 537258, upload-time = "2026-08-07T10:48:35.88Z" }, - { url = "https://files.pythonhosted.org/packages/4c/63/59dba6143fdcc7240c54458b629f3250000a61b8945890fc9efd451b19c5/librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0", size = 527432, upload-time = "2026-08-07T10:48:37.466Z" }, - { url = "https://files.pythonhosted.org/packages/ec/21/21a24c6a2327d8362580efebe77286bf47b0f4062ec5ea41766e609d3c7d/librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d", size = 548108, upload-time = "2026-08-07T10:48:39.384Z" }, - { url = "https://files.pythonhosted.org/packages/5a/6d/fc68c89a7971418b41f9a873623ff935cb864097544c6a2f8ce491c8ef5d/librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c", size = 529681, upload-time = "2026-08-07T10:48:41.392Z" }, - { url = "https://files.pythonhosted.org/packages/65/7e/c2d98766124400d722063a630b0fde38a9fc768705d37eecca15c47dc192/librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d", size = 567736, upload-time = "2026-08-07T10:48:43.124Z" }, - { url = "https://files.pythonhosted.org/packages/55/6c/f8c34a95e3a515c6e1c192b89511e7253c89a7760c6b500d57ffdb8d2dc8/librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374", size = 81673, upload-time = "2026-08-07T10:48:44.645Z" }, - { url = "https://files.pythonhosted.org/packages/c9/9e/e23fa8e78679ec45728188650b39e8ff476c83b691c96f749217df3b1b7c/librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9", size = 100081, upload-time = "2026-08-07T10:48:46.171Z" }, - { url = "https://files.pythonhosted.org/packages/e1/dc/3eb4c5e297343f0620a55532cd7c8d764d3001fa2159212dadf480464827/librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8", size = 121228, upload-time = "2026-08-07T10:48:47.631Z" }, - { url = "https://files.pythonhosted.org/packages/97/70/43abce19f04e49762f8ec834c8fafee13cc40fd6b94a72a24e534febfcd0/librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b", size = 106487, upload-time = "2026-08-07T10:48:49.095Z" }, - { url = "https://files.pythonhosted.org/packages/de/15/83f2deddb9368b8951ec8c9477269b5b9b8bd9bbf15e57402d0f38817dca/librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54", size = 159448, upload-time = "2026-08-07T10:48:50.649Z" }, - { url = "https://files.pythonhosted.org/packages/06/bf/043097353f9b3c73b583d07f6b8e552795463f4bfc8caf85e42eee50c26a/librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6", size = 161686, upload-time = "2026-08-07T10:48:52.174Z" }, - { url = "https://files.pythonhosted.org/packages/f4/2a/8ae77f9719d42ce71cd708560a3557b38ac3c17a0383e57f87084de45bbe/librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988", size = 710668, upload-time = "2026-08-07T10:48:53.782Z" }, - { url = "https://files.pythonhosted.org/packages/61/34/c0436ea134deb9a0d6da80a396a2739a81cb31e0418f7227239e23140898/librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13", size = 679396, upload-time = "2026-08-07T10:48:55.645Z" }, - { url = "https://files.pythonhosted.org/packages/4a/9f/001e0d99aa9250d5cd5715a9081291a20656083459f9019cda15255329e1/librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416", size = 704313, upload-time = "2026-08-07T10:48:57.46Z" }, - { url = "https://files.pythonhosted.org/packages/2d/53/b34fa9d0ff00f136f4d58ebb4c411ff634baed1eb412bb602a2bc8dcafcb/librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a", size = 729847, upload-time = "2026-08-07T10:48:59.231Z" }, - { url = "https://files.pythonhosted.org/packages/86/ac/fa4d7a424665040e95baf480a6d523446057684b6758624c85338e8a23b2/librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c", size = 742736, upload-time = "2026-08-07T10:49:01.151Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f1/e17a9bb5de6fb8c3186ed1a7d68d21618b027ac2d3633e03d3b6109c67ae/librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302", size = 763454, upload-time = "2026-08-07T10:49:03.039Z" }, - { url = "https://files.pythonhosted.org/packages/1d/ec/ecd02cd30935b931b9cdbfed6ab5a099c51b280b4e7baa274da80978ed27/librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d", size = 743296, upload-time = "2026-08-07T10:49:04.941Z" }, - { url = "https://files.pythonhosted.org/packages/e6/b5/b3c2b8353ce820a4854f78d19321344242f89fa71c975b71132ba9bf242a/librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9", size = 756217, upload-time = "2026-08-07T10:49:06.825Z" }, - { url = "https://files.pythonhosted.org/packages/3c/52/6cc22542ba59146b05cca2a656f9ff8bb67e38e63d12c3b0cc183d837bf1/librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab", size = 741934, upload-time = "2026-08-07T10:49:08.839Z" }, - { url = "https://files.pythonhosted.org/packages/40/32/a04b72b1aa86e3be23b2ecff8c1aad2dcc955bd3956d6d26e7e34267e57a/librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b", size = 783763, upload-time = "2026-08-07T10:49:10.661Z" }, - { url = "https://files.pythonhosted.org/packages/6c/f0/89eb11dffbe9279ff37144dec786927314502ae0b114f1449dc78c458aab/librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162", size = 104313, upload-time = "2026-08-07T10:49:12.305Z" }, - { url = "https://files.pythonhosted.org/packages/6d/4a/1f1978c200f563beda63c36adff2d65bbecb81e365e8e69e572f5f70fbc6/librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1", size = 126889, upload-time = "2026-08-07T10:49:13.881Z" }, - { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" }, +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/f5/9dc696772d241814bacac7880bac32f2930b5a6ebc1f85317b83161a011c/librt-0.16.0.tar.gz", hash = "sha256:ac38d6d8d66bf3d744148dbbc0b8e193e195a51e364ed55e224631f5721891fc", size = 219839, upload-time = "2026-09-29T00:55:32.891Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/77/900294b3c9a81415323d81b3f3f271a7f5d7ed9d01e824fc38ffa49470b6/librt-0.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:96f576f2711f8519152ec76d0e599243555c1f07679fa73606ca8c8c868c0be6", size = 148668, upload-time = "2026-09-29T00:44:20.282Z" }, + { url = "https://files.pythonhosted.org/packages/55/3b/c301e8cc47758e4518b93e926a0111080155cc79fa03651d202ac2025065/librt-0.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2bec3818c7da7c96ceae0ef5915a3d16c52dd08f3ea913bf1fe8568c447c7978", size = 153562, upload-time = "2026-09-29T00:44:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/62/b1/4d5b98114a58bf17e3832cd33400e40f2582f68c2fb7ce25b4468cb1afd3/librt-0.16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c79520414a3fdfc6aabd7593e6169afa14d5f8d9908d4b498db068868b08dd", size = 494337, upload-time = "2026-09-29T00:44:23.096Z" }, + { url = "https://files.pythonhosted.org/packages/88/36/14223c9b0dc38982eb4f399732c8cb27b19c7e027ae0faef5ca51d08d19f/librt-0.16.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f81b5b19ce748ef68d4746656b7929762eb2fe99269b266e4be07e2ee4de7144", size = 485440, upload-time = "2026-09-29T00:44:24.555Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/d7155e6800d0c33e297e529b217649f7361d706491ebf9d2fc0fc627309e/librt-0.16.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c71d1b76210a36729fedfc5115069b50a3d8619054745f8758fe5d6f19e86671", size = 498363, upload-time = "2026-09-29T00:44:26.03Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ce/d5f1fb9f913ac0979d6fc5db9fb9c0bf28cc3470988528957922f15ed40c/librt-0.16.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:473eebc7866bb0a0c8849a292b5e7157c1aba5d14d0f0f610c52158d6d964262", size = 515453, upload-time = "2026-09-29T00:44:27.566Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/ca0fca1a27c26080d1a25b2b31f271e532078a5719878c3daca0c3d4c539/librt-0.16.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4eb1313a19847089ee81e88742abedf285c60538816640b99742d8534b81d26a", size = 509537, upload-time = "2026-09-29T00:44:29.14Z" }, + { url = "https://files.pythonhosted.org/packages/ee/c0/26807bf2221a53a2c66f2e23a5c4d57f1c0eaa4afed7cb81d841cf3d1903/librt-0.16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ebefd60b42e2a82b32d136bb5f7c94eadfcd29f772b547df6f3291d1ed855a1c", size = 532541, upload-time = "2026-09-29T00:44:30.745Z" }, + { url = "https://files.pythonhosted.org/packages/0e/50/c329d509907e1bc061abeec3796f94d255d43ba25d28c3854d2138ed5ab2/librt-0.16.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6c5da27e8056439f927ea896735da60e616c477a8293feaa3233d4e7781a6726", size = 537059, upload-time = "2026-09-29T00:44:32.102Z" }, + { url = "https://files.pythonhosted.org/packages/84/d6/4571b9c9e65d98e5503734166dce2fd3e216f526dc3c59a3773faa8a60e3/librt-0.16.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:67e718c7a43f8db325abbbf1404e2d535f12f8f7a1a82259568385cc5274b82a", size = 536614, upload-time = "2026-09-29T00:44:33.653Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d3/a2ed8a292e4ab7889792a9c3f4ad5d1caf6557afce7f6b452e77f8ed20b7/librt-0.16.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:314e703f0c19320dc8094e7a784b9cf29e1b67402515abf580069a6363c0b4f1", size = 517199, upload-time = "2026-09-29T00:44:35.115Z" }, + { url = "https://files.pythonhosted.org/packages/9e/24/276e5218b4b7adb6258a42b7403cb1b7cef25616238d0c2152288a9d2f53/librt-0.16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:943c6bbecbdf7fa575a4f2952fcfd848c88ef95507c3fca411e89d4ac3ff8143", size = 558746, upload-time = "2026-09-29T00:44:36.656Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5c/9156df81a1726e1efc9567379def2b8777a6d56f464d2f1b760bf951b8af/librt-0.16.0-cp310-cp310-win32.whl", hash = "sha256:7cc365f006891afb006b52d5ee5ee74c09306ffa20e2f8705a32a4450af2f3ba", size = 104424, upload-time = "2026-09-29T00:44:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/0b/62/4fd172cb3f49ab2cf5ef17de519a93cfb935202c1270ba9d44c6a64178c3/librt-0.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:0314058469f4d2fd279ce7c62ac274ac82c3918ef7db62ef0697c4c359370155", size = 124267, upload-time = "2026-09-29T00:44:39.455Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0e/b04fd2d76b6a78a02286938251b2c119fb36ddece92c76cb4886ef7d44b8/librt-0.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fe4372c52d4849096c6cc1cda2817d293ec51440c890474ed59ef38d46556f18", size = 148058, upload-time = "2026-09-29T00:44:40.903Z" }, + { url = "https://files.pythonhosted.org/packages/a3/fc/e1ed24aadb32d38603c0c07f579d739eb4d90e64e90d525b024e4a999b4d/librt-0.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c72c5295a84bd249526da9bdca38f2e176d15c31c13bb0063c5053f4ca023421", size = 153073, upload-time = "2026-09-29T00:44:42.165Z" }, + { url = "https://files.pythonhosted.org/packages/be/38/133ab655409420e0c34a9a9836a7f4e5ae75fba758d7fe177099130d58ba/librt-0.16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be56ba9c884143495b517f23fe794ae367d58cd89ea0fdd6d437e3c024a87f9f", size = 493052, upload-time = "2026-09-29T00:44:44.043Z" }, + { url = "https://files.pythonhosted.org/packages/94/42/aaa663bc9421f5bf7bbbe9d3316d7219749698ec7688ac3b519770a89c04/librt-0.16.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:ef46c1a29ffb8c72e882e22618ec618778eacd0578fb22c6e7cf9c11d15f357b", size = 485506, upload-time = "2026-09-29T00:44:45.479Z" }, + { url = "https://files.pythonhosted.org/packages/7b/59/83a6eb5087ba8b45b083859e2085256963e06d4c2531944cdd962670bfb5/librt-0.16.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d3c94211ee0c4f8d649ec06b7c115c0ec4eadb873a0e3154ca15cef3f814b071", size = 498708, upload-time = "2026-09-29T00:44:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e4/15a8d62b2bc12935080ec7277e2e0b9b233047781eb784a51d3e25af669a/librt-0.16.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94aed6a8308818b91677957d1bd03188869cd7aeb23c5dba7912a6c0402f7602", size = 515981, upload-time = "2026-09-29T00:44:48.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/4f/287cc282ac5dd815204c0779e79401bb0a568c3a1335fb319bf0838fb566/librt-0.16.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:349c0bcb87ebd07481b6ff781e25cdc699723dbe2212e57dabb27f7a13b7b87d", size = 508670, upload-time = "2026-09-29T00:44:49.726Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5b/ab7292add898ade0d2b5b75d32a923aae315861671aa5c39f5491309f7ae/librt-0.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:001bfd59a7d45b17e3e75f2a8c6405280b35e7b84471792778e718c4f368950e", size = 530393, upload-time = "2026-09-29T00:44:51.356Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/8e6bcf027d4bcbc16a4e864b3d158a1d3e5f55ffa53af0238020cad89271/librt-0.16.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:28e038895b998d7a0c7798922ce8a1dc157675df5cf1c9ef0aca809ed804b7a1", size = 534302, upload-time = "2026-09-29T00:44:52.893Z" }, + { url = "https://files.pythonhosted.org/packages/96/a4/8c5ab4f3b3a8e905ff8305d76976a2e666f52fe049edd398aed2402beb36/librt-0.16.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0dbe4096a7ecc00fa835d24510ad8545a4efef738dac96e0e63516783ccde905", size = 536150, upload-time = "2026-09-29T00:44:54.421Z" }, + { url = "https://files.pythonhosted.org/packages/ea/5e/7c4bc267e3cd4b8bd288de9f62d61fff203829c9cc649ea2aa12e9218cf0/librt-0.16.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5cd5b092441053364af968ea12084692cb9d4a22f3ce9524e377880bf028761e", size = 514316, upload-time = "2026-09-29T00:44:55.998Z" }, + { url = "https://files.pythonhosted.org/packages/e0/75/c8bc6b50f43c73819334233f5a3259c81216742cef56d2140c3dd377c3f0/librt-0.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ddeb3c9dedb461bb457c6c7d9aa7fbf35329da313d1a7543d00c8d0f3473c96", size = 557731, upload-time = "2026-09-29T00:44:57.581Z" }, + { url = "https://files.pythonhosted.org/packages/93/fb/e241f91bc8de6c7ac1c6a20ea6dd381bc52312b44d711ffcd97140db1e57/librt-0.16.0-cp311-cp311-win32.whl", hash = "sha256:e05108e0849966f53a8d2d3112a7af881d0efaa479bc735bba91108f9f2350a7", size = 104922, upload-time = "2026-09-29T00:44:59.078Z" }, + { url = "https://files.pythonhosted.org/packages/d1/38/c892383d2bb1a6a80b2ec8ede84970a265a792c56614a06282bf3008517c/librt-0.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:5f49cff01bd608ef7d97104cb035c75455e79c2d70bf4a506cf773338ac1860d", size = 125093, upload-time = "2026-09-29T00:49:01.621Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/8bbde1ed0b21480163617648f638fdaec9374cf5e9c846ebcee34ec02a42/librt-0.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:1d28ae980ae2218f9c5b95d191e947296f918c9bf0b400d467a9430275bbe678", size = 115910, upload-time = "2026-09-29T00:52:40.131Z" }, + { url = "https://files.pythonhosted.org/packages/ad/76/bbdaeb87b7c47b5c7343e90222b9bfa4e4a8a83f647e02933ad0129225b1/librt-0.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe52bf4641069e7978a14253b036cb9002def1926317e710f2e249f8a8c47742", size = 151028, upload-time = "2026-09-29T00:52:41.508Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0c/ab8ed3dab0085931aec4a792c7eaac8dc6c5ff4691fda3a5360d9d8a9cd2/librt-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5bcc2c4726ced915b00de0c9856a4eeabfb3fddb93e10e0b8f735b7709358b6d", size = 155283, upload-time = "2026-09-29T00:52:42.873Z" }, + { url = "https://files.pythonhosted.org/packages/eb/36/494e79d460c80c1f030661e8287c9eca5e1ad652dc2b2180b6cd42abce0a/librt-0.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff7baa55f8e7c69851419e50a666015d02a74198716fd45c0125a2112e0a389f", size = 503185, upload-time = "2026-09-29T00:52:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/9b/34/a8464038dd9db6e4381fa2b6eb73dc9a50888d77102c4c139304ac35cddc/librt-0.16.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b95d5d92ab83d39e760a52091bb1baba664f3a2351e39b1e16801e5747c2f0e9", size = 496738, upload-time = "2026-09-29T00:52:46.441Z" }, + { url = "https://files.pythonhosted.org/packages/6a/53/e0e5e334ef0c6ed27039d323819368b9ef6712be87d55ee2bf9799398afd/librt-0.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b6d085d70bce51d43c5c7c36d63490770180d8779e71c49305c87b4213918de7", size = 513818, upload-time = "2026-09-29T00:52:48.068Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f7/7ce72cbf19d0addd05090b339152fd0548a02562c2866a603e6e3b3da2df/librt-0.16.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36e53948e99bbe3ffea257124cfcae1cfb01831555c9a9c903c9f9a72db7fd07", size = 531952, upload-time = "2026-09-29T00:52:49.816Z" }, + { url = "https://files.pythonhosted.org/packages/83/22/0b1bcb6a8e723c8b4fd60dfc8ae8ec6461c54073fbc8685efeb8d900d407/librt-0.16.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:54d11f726aae9df5a6ffbbf0a03a52449bbac84a53ef03669cb41cdfd4ae41bf", size = 524465, upload-time = "2026-09-29T00:52:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/64/2e/e9c23b8b9df1813da1be205deca9606beb7ddd033972246cd426d05374a0/librt-0.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4323193ac0cd025f85af531df8ba91bf24d1973b401697347a6282e8fd3fcf5e", size = 543191, upload-time = "2026-09-29T00:52:53.284Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e5/6a8b21b342c03ed7e230fa3afbfd2edc58e6e87ef1f0d11fa2b9a748c252/librt-0.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e42f8e098b9c5396fefa05fb1cc7e33b0e08fc51da106b5de4a45fd22aac6743", size = 546979, upload-time = "2026-09-29T00:52:54.932Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/b5129023eced1be01e01c22757f53be551d463b1bb7264f787927404c1d6/librt-0.16.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:39ec1d5a14e37baf1450a6cabf03fe552340808bf1ad9d71824ab90117716459", size = 555317, upload-time = "2026-09-29T00:52:56.869Z" }, + { url = "https://files.pythonhosted.org/packages/71/89/28bba5938c725fe91f06bf93f7fa6c6b150229df53a87b454b0d5c2a796e/librt-0.16.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d1aabe3925cbb4a08d15b7b20ba4011b53019da0c4173a25155139b7b1baed65", size = 535846, upload-time = "2026-09-29T00:52:58.475Z" }, + { url = "https://files.pythonhosted.org/packages/22/92/63773026614f888c5d4e370e395ce42ca604b89f70b3acdfedbf94851b80/librt-0.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:300c3ffdc459f4a779a8411ecb188e3ac0b1ff3a3a7b099642555dedae06c69b", size = 573700, upload-time = "2026-09-29T00:53:00.127Z" }, + { url = "https://files.pythonhosted.org/packages/66/8f/347d4677eefb57cd9f8e01d95a1dcee9a91b4e44e664173c32ff6f3752e5/librt-0.16.0-cp312-cp312-win32.whl", hash = "sha256:c17194318e4c0c0348b36f36c2ec7534436fe0a4c15582403162a4f08c80797a", size = 106158, upload-time = "2026-09-29T00:53:02.031Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2c/5193dc81127cd5ddfad031391b046bf32dda219b38463ab872407ca30646/librt-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:25a58a19ea8d83b68209f04912df765e9260635ef77646542ed4b4abe6bc7940", size = 126176, upload-time = "2026-09-29T00:53:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/ff/3d/9668a400c8dd81d162eba38b33fa49fa6205f1a64493570a13fbb815c3ee/librt-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7be7cf555bc30ec12622e9447299cc4a9b8ff307548b634794353db0c2065dc", size = 116572, upload-time = "2026-09-29T00:53:04.815Z" }, + { url = "https://files.pythonhosted.org/packages/46/cd/ae5e0e9dba45d1399aa04a5395bcc0bead40d9fa06dc903634a7b4d7473d/librt-0.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c5e6144e68b577f157519f2ba88ca20e3ed61c29b00e5cdfa76cd2d45acf059a", size = 151036, upload-time = "2026-09-29T00:53:06.284Z" }, + { url = "https://files.pythonhosted.org/packages/41/5a/48a16e323c5f9447a94cce7b59babf60fa04e62c3365ecf060c77ed8b320/librt-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:33f41443a1f4e1f099331b3d8120e409fbff84b9760bc1cc9ea496f37ddaa5cc", size = 155211, upload-time = "2026-09-29T00:53:07.72Z" }, + { url = "https://files.pythonhosted.org/packages/3f/29/0f59299eb4251a409b2e690ad4b7d9f8a676db829d7817ec961f32b44f7e/librt-0.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e510b7770bee609617a3374a96548eb114cae048023e3f049ee449e7ff2db32", size = 502587, upload-time = "2026-09-29T00:53:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/de/ba/d6fb4ef8d1537c396079d72289f16be7cd35a366e5065c51253fea2760b6/librt-0.16.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:efc49c462d4516b8a58b00b490078fa64689fd1fe66970cc190131d7afb8027e", size = 496146, upload-time = "2026-09-29T00:53:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/52/fc/8c50dd4d7cc97c0ee8f252c8a3104980f234391cf1519b554e8b9de08b60/librt-0.16.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:92caf82ebef5e12d21c72242b70d1e92536f1711cf2a727a4c276de4b4469087", size = 513336, upload-time = "2026-09-29T00:53:12.655Z" }, + { url = "https://files.pythonhosted.org/packages/a9/59/16c409c56f708eda2db9a0553662845d45e3871c77d70a240dae3f3bdc56/librt-0.16.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17bac7f7a16b328fff77e440287693eb017abde913595b5827ebccbc21ecd8a6", size = 531689, upload-time = "2026-09-29T00:53:14.39Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/d34772a6c29d1446dcca6e64d74062efd508523ab351aa16625a4689d5cc/librt-0.16.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5b976054553670829985ed767feb78fb6bcede0175327c4844dd5c281c1be659", size = 524537, upload-time = "2026-09-29T00:53:16.064Z" }, + { url = "https://files.pythonhosted.org/packages/77/8f/24c5631313746131ccee53bc91fdc8374f9cf25e0082a1fee9c93bb98acc/librt-0.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0058f9d68721094105917254c72ac0569117bb7b13b9769cf45d26d89f9d21cd", size = 543231, upload-time = "2026-09-29T00:53:17.939Z" }, + { url = "https://files.pythonhosted.org/packages/f2/cb/5f8e0d41dbd8b499c2265e939c31acc9ba59845565bf99539ad1c06aebcf/librt-0.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:30b7beaf3f4487b7d8adef1f158b49067cb4d5a19fa7a3bf31a4e7a820e435c5", size = 546499, upload-time = "2026-09-29T00:53:19.666Z" }, + { url = "https://files.pythonhosted.org/packages/be/61/063052de441d1385f59cea4223f184bf9e5d125de1ae3239b490aa1e486e/librt-0.16.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:468df902df016a06eb0e40b0747dc8d14e47d7a38b18b63b1fb167d85cb94d63", size = 555261, upload-time = "2026-09-29T00:53:21.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/11/a2ada0529372268d6401afa9d457a095b68cd7753532b6f7f33049a19b43/librt-0.16.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:aea7b1f2b125dad5de85f049136651bff256c883c65e6b9209b2da0a1ac3cdef", size = 536029, upload-time = "2026-09-29T00:53:23.07Z" }, + { url = "https://files.pythonhosted.org/packages/23/9d/5bb6d38853382986dca702fc7e06c8256d30f5fa0676d882773b744610dc/librt-0.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a8afb6557920860b7a3a596eb804cf37e09e7cf8a803db2478c202acc72d8c2e", size = 573748, upload-time = "2026-09-29T00:53:24.696Z" }, + { url = "https://files.pythonhosted.org/packages/99/f6/0025cde35ff7f607684dc775a2b2d732cce2561cec050a6ee1fb2e1fc6fe/librt-0.16.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:77c7a2b4fe2c1369e0d5aa1cade26740a7b14be32fbc9a5535d617d20065c39d", size = 79861, upload-time = "2026-09-29T00:53:26.089Z" }, + { url = "https://files.pythonhosted.org/packages/93/93/303b8592909bd583f83f02818ffbea3f7647ca1e22b1cd5465d04b8145fe/librt-0.16.0-cp313-cp313-win32.whl", hash = "sha256:02d89c813d5ff74b17df72d3a34819d132cd168e56b81bf755b809bd9e46b8c4", size = 106236, upload-time = "2026-09-29T00:53:27.43Z" }, + { url = "https://files.pythonhosted.org/packages/cf/24/80bbb463c60ed18e29cb26aba386ddb76609580e4e7160710e550587018c/librt-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:14ed6ebe3e4f85f326d7920011ad30ff49ed9334e62cf88caef9ba973d9e3a92", size = 126180, upload-time = "2026-09-29T00:53:28.7Z" }, + { url = "https://files.pythonhosted.org/packages/43/80/b1a6fbdd7da825cdd55c71aa81eb6cfa82c513c360774152eacb50b4a771/librt-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:83d4041a3d9b2fd053a8a4e1f22878b3e5833e2712956382d5c048d791454e91", size = 116557, upload-time = "2026-09-29T00:53:30.012Z" }, + { url = "https://files.pythonhosted.org/packages/1e/93/9e0cf7da129a93c3dc7f45bc3cd4a660f2aaa995aa8a6f95c2583ef41239/librt-0.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:931a0bb0fcac88f263e269e46eb30ba8e21402cd3c62ca40cb97034c0693fab1", size = 149844, upload-time = "2026-09-29T00:53:31.391Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/8a90d2a8f2b2e471bb486b7aec117b8ae622715ed6c39853aec48ea20073/librt-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1bc17e54e5305f8d40b7ca203671ff5a9e59c1d0f8ea0f625dcca53a3984de11", size = 154111, upload-time = "2026-09-29T00:53:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/35/ce/67abb46258da4d3e42ff5b141db6f38c59357bef84c7979f183b22f924f1/librt-0.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:877698bf6bca5721d8be345f2fe09778e40ecadea8b58c73075f2b1a53666bf2", size = 494234, upload-time = "2026-09-29T00:53:34.466Z" }, + { url = "https://files.pythonhosted.org/packages/12/f9/ea7162414a16f8f1bbd3b493ad6d22b926c5471916e078350bdbca8c4e5f/librt-0.16.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5981c011b306781ce561e18e14230a14524a3d8109b97553666c942c18f31a96", size = 491173, upload-time = "2026-09-29T00:53:36.124Z" }, + { url = "https://files.pythonhosted.org/packages/b1/09/9b3e869060dd33f9989b80ba4fea306f6db8cecefcdfe6d7346ef0603f65/librt-0.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:afced3dfc17cd805ecf7a3d77996a71cf5f2c75aa66eb0c21a9930f4fc992f86", size = 505547, upload-time = "2026-09-29T00:53:37.686Z" }, + { url = "https://files.pythonhosted.org/packages/50/07/79007d2165f649ea93e08c0962d1d62c70af9cde77965255095bf9d96f9a/librt-0.16.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca8052401c55d7511dda6760719fda7618067e83535d7d0010096d216c34b667", size = 523057, upload-time = "2026-09-29T00:53:39.301Z" }, + { url = "https://files.pythonhosted.org/packages/61/0c/8fbaff66d0ba376d8864653f5acce1569bae27e89648671f26f7eca67ab8/librt-0.16.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1e511762a074005bb0aa569166779834e75e438370226930d0ce1866d4b6a33b", size = 515158, upload-time = "2026-09-29T00:53:41.012Z" }, + { url = "https://files.pythonhosted.org/packages/df/2e/23ff0dece76f07a4124413a57682efa0bbeb5765ac0122bc0955513f82ca/librt-0.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1e8591bd8a5a628cd7f07954c6a1592359a878bf032957a8e9057a41d644311", size = 534183, upload-time = "2026-09-29T00:53:42.451Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/e11dea21d9a29486eba78887380374189d472734fa32cc50cb37ca44d3d0/librt-0.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4aaefb4ba6c07e1aeebb2795c8958148f1d6f9af3b555b53d23d766edb6d67a", size = 540663, upload-time = "2026-09-29T00:53:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/75/e873ae158a8b7f5359be33e7fd6c1fbe02d9a78a3e89a77de6b0e837f476/librt-0.16.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d92db7a0f6aee44f1baee94750457e8d2d1c6ccea41842de6268d34e8dc7eddd", size = 545908, upload-time = "2026-09-29T00:53:45.812Z" }, + { url = "https://files.pythonhosted.org/packages/ba/36/8939d3f6a93e11bd9592e6fe28d2b44f1c2dc4bed6e22e72359a91e18ffb/librt-0.16.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:378dfaffb38e59c24a87cde5713cd865d51ff7383fa12947f3907f306ea1ca55", size = 523302, upload-time = "2026-09-29T00:53:47.596Z" }, + { url = "https://files.pythonhosted.org/packages/71/14/35309f44a077f0f42ade0e2e7cd88c0cea760c661af205c01ea90d3c0e1f/librt-0.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3e0c39bdc85370422e8b637be76eb1fd07d30967551b03e62267dd156f553152", size = 565911, upload-time = "2026-09-29T00:53:49.278Z" }, + { url = "https://files.pythonhosted.org/packages/78/0c/df6255b94967f3159ebc46f08d8e783c12da6ee269ddb74b2efed63c640c/librt-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:1b384b90ab79a7bc30b566895809a636e0666f21f3cf12b54823d025b7e83839", size = 79119, upload-time = "2026-09-29T00:53:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/6b/44/d30d5a5461378c9d33f36736c6791c3b4c4ba4b1ffe0da7350aedcb2c9a0/librt-0.16.0-cp314-cp314-win32.whl", hash = "sha256:52327da75a94012e7f932f913d20d3876bed3c102be00e6c3e8600ff7bdd58a7", size = 100140, upload-time = "2026-09-29T00:53:52.205Z" }, + { url = "https://files.pythonhosted.org/packages/c2/98/769712f356a1e897df3581bb0c3100375d054a000de26099360ea65b5111/librt-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:3f0b8114c44b2ac06ff5dacd08e07e8e807ff4f46083f2a1602685122559be41", size = 120464, upload-time = "2026-09-29T00:53:53.495Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d3/ae2abccc8bdc8b063c1613a77686e17b74e9d3d60cfe6f12c63fa2821b9f/librt-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:8caf96a4ef8fb27d0ac0d1ad8337d26a240acd4a02fe4345d0a8f264753e8f99", size = 110889, upload-time = "2026-09-29T00:53:54.817Z" }, + { url = "https://files.pythonhosted.org/packages/e4/26/0737d4be058dd6376eade7dd8b380d4b869b6394cee929393a5a431c45bb/librt-0.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:953107e2f68d0f3512c48f898b0dbf0ce5cc52bba0f318d847c985dc555ee4cc", size = 159838, upload-time = "2026-09-29T00:53:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/01/96/9bc96531d7c620e9949af904470f02e3fa8f35129ab8e8f281c51eaa3788/librt-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad37d5b9abd49c9a655dcda7ea52a8a752884062ef1ee71ae17c2f2a0f81fe6a", size = 162018, upload-time = "2026-09-29T00:53:57.532Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fa/b0289dcb186eb3f97221ba00da5f4bd3ba7fa5e99752d48aa8d336615334/librt-0.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c4aa329c17bd1aaea4f6e89335d8ccd494b3a5830b6654462273e50e11023f0", size = 703983, upload-time = "2026-09-29T00:53:59.024Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ab/05ebbbde7530fc5eeb58fbd1581522a9f64f132fa703c4c595eed6e14760/librt-0.16.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0ead24d2562a49473dddd9efef8581f020007eb0054389c3ee3ffad38b1ca4c9", size = 683847, upload-time = "2026-09-29T00:54:00.671Z" }, + { url = "https://files.pythonhosted.org/packages/a3/75/f52aeecd4dbadbddf80725ba7de126d8bd5d0eb66247eae17a81ed90dd4d/librt-0.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02118f56a9c36ddd07dfd9b919d9ecc117ba20a90987d56aa4c429fa34509188", size = 698045, upload-time = "2026-09-29T00:54:02.247Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/fa277a835cd6eb42380591ceb85f48c5b4d2b2d7e2cb9869e1e17d24d237/librt-0.16.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e29522c62e28595ff7e324c6834ade51127707f0e255b18d1c1cf03d39c1048", size = 724640, upload-time = "2026-09-29T00:54:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/25/e4/2cf64354f3fde8ebd591b3b48f96510bee24ac5f7d1e567adbbe210abdbd/librt-0.16.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ff4b2367926b69c6215635902cccb04048e73094e9862900d27cb2c6bbff143", size = 731748, upload-time = "2026-09-29T00:54:05.741Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c9/c180af3e94e01aa529fa93d7733fec2abc47f222345400cf21d5481d5f8a/librt-0.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c6f1b27bf1632a7e016af9f145f82be95e1edd7721a646505c21059257cb5a04", size = 754960, upload-time = "2026-09-29T00:54:07.38Z" }, + { url = "https://files.pythonhosted.org/packages/95/d6/01073aa78c58f356b10d9c57b3fe9abb143df338142bcd316df417b89db0/librt-0.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5696d7f52e7b37217cb3a8f92c744fe835942602fdd4c1a8bc4741d3bfdce15e", size = 747000, upload-time = "2026-09-29T00:54:09.06Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d8/1de3783908658d697a8cfc00582f61299ffba7796d7260c112e4700b1109/librt-0.16.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6072e92dd876ff6ceeb6cf371e35e51f479349837391341f479b08df4564242b", size = 749569, upload-time = "2026-09-29T00:54:10.702Z" }, + { url = "https://files.pythonhosted.org/packages/b7/32/e817f66c96d6caa8bb8435ff93c4c220624d59efc506be59ab98fcd01d0c/librt-0.16.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:39ca4f2f2fe05de8e63493da592d84311adabe5bef52b193851981da9816b302", size = 729483, upload-time = "2026-09-29T00:54:12.25Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c9/23992ccd2b9d22798fdd0f61353183a47414e651da782ad83eb680f50833/librt-0.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9807485a908f00355820f18e91e045ffdcdc5adb68aaec40a1e2b88c5f7bba1", size = 776584, upload-time = "2026-09-29T00:54:13.837Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/e8af957f08e6e2e8e566b099e43d2748418aa565df6ee1d9d3331209fdfe/librt-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:94be5cb7bca4df6201f4183e9e4fa2086c655283d20b38cd84500a69057575a7", size = 104402, upload-time = "2026-09-29T00:54:15.568Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/946e6443d7cd32347a086043395e421e52fd603c9163d2ec970ceab8eed6/librt-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d46ca272b251d033dd4527b0dec5f261a28a52bd5fa0f99c117b0a1f8588cc2d", size = 126227, upload-time = "2026-09-29T00:54:17.165Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a3/bc4f9959d3c62bcbf9e5fd3470a8bbd8bf33224ed4c25d7fba173201b8ac/librt-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b9d6d4b14e92d876f8026b54c20c445f36425214c1081dc76f74e40db386b82b", size = 116493, upload-time = "2026-09-29T00:54:18.567Z" }, + { url = "https://files.pythonhosted.org/packages/b6/4b/10fdb42dfab4c1533e1570e686b18e86ff4328b406361b39fb3016667638/librt-0.16.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:6fe436af2eaf630474f491af5d032cbe45f93fcff5c3b9fe4ab194a7255b20ff", size = 149829, upload-time = "2026-09-29T00:54:19.933Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/a90ca13f1d3d91af1680000a4038536907018fbd51764767287a05d28b8b/librt-0.16.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:8ff5d26c529336be9bd7ae04483235d77778ee7d6444a95353102b542601ce81", size = 154522, upload-time = "2026-09-29T00:54:21.306Z" }, + { url = "https://files.pythonhosted.org/packages/5c/dd/bcf364eacfa070bb1fc88d503111ae7177914197ba1fa717c748926e7930/librt-0.16.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:909d8e3c1faee44cb762b1c519ff8613dcc5ceae5c99987a00917b5a31fd1d6a", size = 497593, upload-time = "2026-09-29T00:54:22.798Z" }, + { url = "https://files.pythonhosted.org/packages/18/c1/2c4e81e347bdabfe8346bfc6dc37ae5e154d61c88e30848ec0606025a5e0/librt-0.16.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:6d4a64283ee61824b5790de882bc68e2d9d7a5143537cb7a966f7354f71646d4", size = 480459, upload-time = "2026-09-29T00:54:24.364Z" }, + { url = "https://files.pythonhosted.org/packages/ab/d7/fef2a3cb8400701be496f6e459876f650b3451f407e30cb243de571ae615/librt-0.16.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5810ba811297fdf37a1531a57667cb8ace0842013ca8606bf9eb7c24cf4be154", size = 507814, upload-time = "2026-09-29T00:54:25.997Z" }, + { url = "https://files.pythonhosted.org/packages/e1/6f/53762927a32e9dc9eb1d1c1f3528da281290c86d96929a8af652f671266e/librt-0.16.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e56aaf8c167548dc8e5d6f3bd0f48dcdd299a23c73be3f744aab79d99e9c7f5d", size = 525114, upload-time = "2026-09-29T00:54:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/6c/67/0b9d031f303c4e8c691a9a8ef9d272f13b530c11cc563b819df621b6a348/librt-0.16.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f36c58e33b304b525c6c9c5076399c6ebf1109e17b9051a05a407b091b9215b", size = 520465, upload-time = "2026-09-29T00:54:29.331Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/2056a3a85864e882eb17a203a10ddb26fa748bc9718ec67e79059ab46cae/librt-0.16.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:242e00b3d4fa37c3d3c1ca5f5c9adb7d909ddb1eac9c41f2787320d00caa0af2", size = 537257, upload-time = "2026-09-29T00:54:30.915Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/e0d79790c163cbc0909e209a6f62e30bb713b647f905a176cdc64848fd4d/librt-0.16.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:0253721561787b8df8443eb347b7a6461015354e5bdd37ee38a41fef220d2bb0", size = 527442, upload-time = "2026-09-29T00:54:32.478Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/1c0c95aba7af51f4752ea34fbf2eb79b36e7cae3a72536248e8c735de735/librt-0.16.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:3e483a8d69ede8067db70c0e83007423b6925de6fd53afed01d66160f2e9398c", size = 548139, upload-time = "2026-09-29T00:54:34.183Z" }, + { url = "https://files.pythonhosted.org/packages/98/91/a8a43dd5138d4f55f88846b8f0c85454a4fad69952cebfcff9948f831290/librt-0.16.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:69ba927445cfaaffb4081003ef5224c55a5c2ab67ef956f416ef744916e44121", size = 529708, upload-time = "2026-09-29T00:54:35.761Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/d5226881ab2b7c20d9d587b37bdd4a0ec8775a96d00ca87ac9f385587db4/librt-0.16.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:d6a365f2ab45a984d0e00eee0dd17f599ceab8cadab6ea07b6111c8132fc0e42", size = 567781, upload-time = "2026-09-29T00:54:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/bb/bf/2345ba57a626e8c78c4ddcc724636a8df5a593fe46c1ee76bbf477e32b0d/librt-0.16.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f01f3805f2dae4781c0c34b440e31740d082950bdaf89a6f601ad589a28af57a", size = 79177, upload-time = "2026-09-29T00:54:38.792Z" }, + { url = "https://files.pythonhosted.org/packages/1e/40/99e77936cc9207f629b077bf7cbc5e2f0827cddd7c29792f04ef881bd2c3/librt-0.16.0-cp315-cp315-win32.whl", hash = "sha256:b0e3e721c75d2e79a76d4422c79d7ba705fe1bbafec907037fe7a657a480a0e3", size = 100127, upload-time = "2026-09-29T00:54:40.251Z" }, + { url = "https://files.pythonhosted.org/packages/56/1e/801fe26bc622061b9dfd010e166d94142cb774b733217e6d98d1c0cf2638/librt-0.16.0-cp315-cp315-win_amd64.whl", hash = "sha256:bc02954b1295de798bbdb0b4e2d8a28c2117de8b5c73dcbeb27dc32572dfb971", size = 120480, upload-time = "2026-09-29T00:54:41.722Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a9/d533983055bd36e112627384c2c038845d1df882540b8bcefb566475640b/librt-0.16.0-cp315-cp315-win_arm64.whl", hash = "sha256:c5db585d43449a5f54303d4b2774e45e1babd975cfe1630a3d708c0b80c3e560", size = 110915, upload-time = "2026-09-29T00:54:43.052Z" }, + { url = "https://files.pythonhosted.org/packages/55/fe/d62238fa9c653b0e0613290349467cb65711b5800e8e474f45e942a0ad95/librt-0.16.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f06c689cb14afd9b612727553a5ec5a40febf113ca41c4413a2b0b334285884b", size = 159714, upload-time = "2026-09-29T00:54:44.497Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ac/f31efe7818700be72ba4f9af8a80fa67c39808c8d26dacc55dc1f6f35172/librt-0.16.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:13b4e8aba90b0b1c82474e9844aa9ffe7ad3faa484350e1da64cb8188d903134", size = 162086, upload-time = "2026-09-29T00:54:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/d9/16/4d7487bf86a9d7e8e18f37ba5538789233ea693637379b75701bd35ec9de/librt-0.16.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a269c46ae327d8e6f8c1f85f7516cb52c0fa48127565a1105a4f4a05ff2a0b4", size = 712727, upload-time = "2026-09-29T00:54:47.486Z" }, + { url = "https://files.pythonhosted.org/packages/77/74/50cd550ccc1a517b9ed62347625c01ddf8c4afad66490312677c011458f6/librt-0.16.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a33e0dae1f8592146a4764d54ce842b278732d21a84e17c3bbe6b1bc158a2248", size = 681106, upload-time = "2026-09-29T00:54:49.126Z" }, + { url = "https://files.pythonhosted.org/packages/df/5d/7293f712975ee6fdd2251411fd9ef1c62bc99c7b83ecbe62dbc999b1fab7/librt-0.16.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:47ada6ea32636492c61aa8ad27ae3b9404bfe7a97e3ba946d1984236cc741da0", size = 705921, upload-time = "2026-09-29T00:54:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/67/f7/8aab946f11d59d1bece9ffc65d994b200c789a8ca5a95c17e17e609f912a/librt-0.16.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43bd6e642d8a248c114327f98dd25ac5a7cb5aa168ef02f0559b91874df16b8", size = 731786, upload-time = "2026-09-29T00:54:52.584Z" }, + { url = "https://files.pythonhosted.org/packages/43/76/1c42ab31e7cb8384ebf6d3af607213c495222474f4940443ae7639ab7685/librt-0.16.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c3d1bb7841a816ace6449bb26d3f9560dbfa20e71c568d23f0f62bf1e68f50b1", size = 744964, upload-time = "2026-09-29T00:54:54.217Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2e/4b19982d933d2dfced671e840b219e4c1cd3f507df6e6dabb51bbd4e3850/librt-0.16.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3931f7a3db322e7f44e02a280e3949326ce9579ad388ee8d691dc7c76da9fb70", size = 765305, upload-time = "2026-09-29T00:54:55.815Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1b/e872583de2dcb3ac7746e7a2321aeeb168274f3952a87dc66e05ddb29faf/librt-0.16.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f4462528b6000afe8f16907b5c7c2553abf1df005ba5140e6eb394541c3624c3", size = 744959, upload-time = "2026-09-29T00:54:57.532Z" }, + { url = "https://files.pythonhosted.org/packages/10/de/a18c6bcfb297af2674233e90b3c3661c3a0af0f1434a0c966d2ef708a835/librt-0.16.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:80039ba9b6a7d5f1a0175a4cca6bbefead87bd854c80abad1cb30afe47a830db", size = 758138, upload-time = "2026-09-29T00:54:59.484Z" }, + { url = "https://files.pythonhosted.org/packages/22/1c/0df1d732539c297bb1a093e1fe3204d2faf76a9022cf4e1d1c2fce059790/librt-0.16.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7a1d272724b581bb6bc769dfdafed6da2ecc9886ba2450311de55a4ac2e1e9cd", size = 744268, upload-time = "2026-09-29T00:55:01.179Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f7/ccaf31331f20c91a5bd9bd48ffc3f743f9c81bb5720cc7b2a720ca204f0a/librt-0.16.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:fbe4fb8c5445f7496d7f7f6bb0807875d09d47e6771ffa175fb2df2895fb86ba", size = 785783, upload-time = "2026-09-29T00:55:02.938Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ea/7421d9e6db894cd6cd788b604b3394a163ded6b942052b6f96c23ccf08e1/librt-0.16.0-cp315-cp315t-win32.whl", hash = "sha256:375bfe6b572a8f6cfc398709356046173bf27e64c4c5edaf5f7062f051fb4bf9", size = 104355, upload-time = "2026-09-29T00:55:04.533Z" }, + { url = "https://files.pythonhosted.org/packages/85/6d/7c31a506eb847bc58aeb2402d58e17605e821f68ab5df5e66fe274a6df41/librt-0.16.0-cp315-cp315t-win_amd64.whl", hash = "sha256:bd3150023d3dc2bc70f3784e59ffa1140d56ddba3d8125b3d6f9f85221279bfc", size = 126134, upload-time = "2026-09-29T00:55:06.017Z" }, + { url = "https://files.pythonhosted.org/packages/36/69/7a5d10ac409c4da0355e054a14371871da9b5557fcc42772cd00181c6cce/librt-0.16.0-cp315-cp315t-win_arm64.whl", hash = "sha256:8ceafb70f2a4f0826f11031942e59c0728fd98da112dc346d4352bde1e486866", size = 116391, upload-time = "2026-09-29T00:55:07.484Z" }, ] [[package]] @@ -3134,58 +3138,91 @@ wheels = [ [[package]] name = "msgspec" -version = "0.21.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/60/f79b9b013a16fa3a58350c9295ddc6789f2e335f36ea61ed10a21b215364/msgspec-0.21.1.tar.gz", hash = "sha256:2313508e394b0d208f8f56892ca9b2799e2561329de9763b19619595a6c0f72c", size = 319193, upload-time = "2026-04-12T21:44:50.394Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/96/38/d591d9f66d43d897ecbd249f2833665823d19c8b043f16619bc8343e23df/msgspec-0.21.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72d9cd03241b8b2edb2e12dcc66c500fa480d8cbd71a8bac105809d468882064", size = 195172, upload-time = "2026-04-12T21:43:45.062Z" }, - { url = "https://files.pythonhosted.org/packages/69/1a/6899188b5982ec1324e0c629b7801eed2db987f6634fab58abd9fc82d317/msgspec-0.21.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ed2ab278200e743a1d2610a4e0c8fc74f6cecb8548544cdec43f927bd9265238", size = 188316, upload-time = "2026-04-12T21:43:46.641Z" }, - { url = "https://files.pythonhosted.org/packages/9e/95/7e591b4fa11fdbbf9891164473c23420a8c781ef553295abe416bf335f42/msgspec-0.21.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd677e3001fdfed9186de72eab434da2976303cd5eb9550921d3d0c3e3e168ce", size = 216565, upload-time = "2026-04-12T21:43:48.081Z" }, - { url = "https://files.pythonhosted.org/packages/19/86/714feeaf3b84cf2027235681725593840153dedd2868578f9f2715e296bb/msgspec-0.21.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f667b90b37fad734a91671abd68e0d7f4d066862771b87e91c53996dcb7a9027", size = 222689, upload-time = "2026-04-12T21:43:49.385Z" }, - { url = "https://files.pythonhosted.org/packages/7d/b9/4384243e814f2579e5205e17d170b9c1a30121afd1393298d904817a7fa7/msgspec-0.21.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:49880fd20fdbcfe1b793f07dd83f12572bab679c9800352c8b2240289aa46a06", size = 222343, upload-time = "2026-04-12T21:43:50.612Z" }, - { url = "https://files.pythonhosted.org/packages/04/01/4b227d9c4057346271043632bad41979cf8c3dca372e41bb1f7d546395b2/msgspec-0.21.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ae0162e22849a5e91eaad907766525107523b0daea3df267a9fcb5ba4e0936ae", size = 225607, upload-time = "2026-04-12T21:43:52.129Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ce/27021d1c3e5da837743092a7b7a5e8818397e1f4c05ee8b068bd7d1fd78a/msgspec-0.21.1-cp310-cp310-win_amd64.whl", hash = "sha256:f041a2279f31e3a53319005e4d60ba77c085cfcbe394cdc7ce803c2d01fe9449", size = 188392, upload-time = "2026-04-12T21:43:53.384Z" }, - { url = "https://files.pythonhosted.org/packages/80/2b/daf7a8d6d7cf00e0dcd0439178b284ade701234abdcadf3385601da04fbd/msgspec-0.21.1-cp310-cp310-win_arm64.whl", hash = "sha256:1bf17cbd7b28a5dffc7e764c654eed8ccde5e0f1de7970628608304640d4ce4e", size = 174191, upload-time = "2026-04-12T21:43:54.6Z" }, - { url = "https://files.pythonhosted.org/packages/ba/7f/bbc4e74cd33d316b75541149e4d35b163b63bce066530ae185a2ec3b5bfc/msgspec-0.21.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b504b6e7f7a22a24b27232b73034421692147865162daaec9f3bf62439007c87", size = 193131, upload-time = "2026-04-12T21:43:56.094Z" }, - { url = "https://files.pythonhosted.org/packages/c1/60/504886af1aaf854112663b842d5eea9a15d9588f9bf7d0d2df736424b84d/msgspec-0.21.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4692b7c1609155708c4418f88e92f63c13fdf08aa095c84bae82bad75b53389b", size = 186597, upload-time = "2026-04-12T21:43:57.242Z" }, - { url = "https://files.pythonhosted.org/packages/fa/54/d24ddeaa65b5278c9e67f48ce3c17a9831e8f3722f3c8322ee120aca22ef/msgspec-0.21.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3124010b3815451494c85ff345e693cb9fe5889cfcbbef39ed8622e0e72319c", size = 215158, upload-time = "2026-04-12T21:43:58.442Z" }, - { url = "https://files.pythonhosted.org/packages/9f/75/bb79c8b89a93ae23cd33c0d802373f16feaf9633f05d8af77091350dda0a/msgspec-0.21.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6badc03b9725352219cca017bfe71c61f2fbd0fb5982b410ac17c97c213deb30", size = 219856, upload-time = "2026-04-12T21:44:00.015Z" }, - { url = "https://files.pythonhosted.org/packages/b4/9c/c5ca26b46f0ebbd3a6683695ef89396712cb9e4199fd1f0bc1dd968216b1/msgspec-0.21.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5d2d4116ebe3035a78d9ec76e99a9d64e5fa6d44fe61a9c5de7fd1acf54bcc69", size = 220314, upload-time = "2026-04-12T21:44:01.548Z" }, - { url = "https://files.pythonhosted.org/packages/c8/31/645a351c4285dce40ed6755c3dcc0aa648e26dacb20a98018fe2cce5e87b/msgspec-0.21.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0d1009f6715f5bff3b54d4ff5c7428ad96197e0534e1645b8e9b955890c84664", size = 223215, upload-time = "2026-04-12T21:44:02.884Z" }, - { url = "https://files.pythonhosted.org/packages/09/af/8bf15736a6dd3cb4f90c5467f6dc39197d2daaf10754490cdc0aa17b7312/msgspec-0.21.1-cp311-cp311-win_amd64.whl", hash = "sha256:c6faffe5bb644ec884052679af4dfd776d4b5ca90e4a7ec7e7e319e4e6b93a6e", size = 188554, upload-time = "2026-04-12T21:44:04.151Z" }, - { url = "https://files.pythonhosted.org/packages/ef/29/cc7db3a165b62d16e64a83f82eccb79655055cb5bc1f60459a6f9d7c82f2/msgspec-0.21.1-cp311-cp311-win_arm64.whl", hash = "sha256:ee9e3f11fa94603f7d673bf795cfa31b549c4a2c723bc39b45beb1e7f5a3fb99", size = 174517, upload-time = "2026-04-12T21:44:05.66Z" }, - { url = "https://files.pythonhosted.org/packages/6e/cf/317224852c00248c620a9bcf4b26e2e4ab8afd752f18d2a6ef73ebd423b6/msgspec-0.21.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4248cf0b6129b7d230eacd493c17cc2d4f3989f3bb7f633a928a85b7dcfa251", size = 196188, upload-time = "2026-04-12T21:44:07.181Z" }, - { url = "https://files.pythonhosted.org/packages/6d/81/074612945c0666078f7366f40000013de9f6ba687491d450df699bceebc9/msgspec-0.21.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5102c7e9b3acff82178449b85006d96310e690291bb1ea0142f1b24bcb8aabcb", size = 188473, upload-time = "2026-04-12T21:44:08.736Z" }, - { url = "https://files.pythonhosted.org/packages/8a/37/655101799590bcc5fddb2bd3fe0e6194e816c2d1da7c361725f5eb89a910/msgspec-0.21.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:846758412e9518252b2ac9bffd6f0e54d9ff614f5f9488df7749f81ff5c80920", size = 218871, upload-time = "2026-04-12T21:44:09.917Z" }, - { url = "https://files.pythonhosted.org/packages/b5/d1/d4cd9fe89c7d400d7a18f86ccc94daa3f0927f53558846fcb60791dce5d6/msgspec-0.21.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21995e74b5c598c2e004110ad66ec7f1b8c20bf2bcf3b2de8fd9a3094422d3ff", size = 225025, upload-time = "2026-04-12T21:44:11.191Z" }, - { url = "https://files.pythonhosted.org/packages/24/bf/e20549e602b9edccadeeff98760345a416f9cce846a657e8b18e3396b212/msgspec-0.21.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6129f0cca52992e898fd5344187f7c8127b63d810b2fd73e36fca73b4c6475ee", size = 222672, upload-time = "2026-04-12T21:44:12.481Z" }, - { url = "https://files.pythonhosted.org/packages/b4/68/04d7a8f0f786545cf9b8c280c57aa6befb5977af6e884b8b54191cbe44b3/msgspec-0.21.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ef3ec2296248d1f8b9231acb051b6d471dfde8f21819e86c9adaaa9f42918521", size = 227303, upload-time = "2026-04-12T21:44:13.709Z" }, - { url = "https://files.pythonhosted.org/packages/cc/4d/619866af2840875be408047bf9e70ceafbae6ab50660de7134ed1b25eb86/msgspec-0.21.1-cp312-cp312-win_amd64.whl", hash = "sha256:d4ab834a054c6f0cbeef6df9e7e1b33d5f1bc7b86dea1d2fd7cad003873e783d", size = 190017, upload-time = "2026-04-12T21:44:14.977Z" }, - { url = "https://files.pythonhosted.org/packages/5e/2e/a8f9eca8fd00e097d7a9e99ba8a4685db994494448e3d4f0b7f6e9a3c0f7/msgspec-0.21.1-cp312-cp312-win_arm64.whl", hash = "sha256:628aaa35c74950a8c59da330d7e98917e1c7188f983745782027748ee4ca573e", size = 175345, upload-time = "2026-04-12T21:44:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/7e/74/f11ede02839b19ff459f88e3145df5d711626ca84da4e23520cebf819367/msgspec-0.21.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:764173717a01743f007e9f74520ed281f24672c604514f7d76c1c3a10e8edb66", size = 196176, upload-time = "2026-04-12T21:44:17.613Z" }, - { url = "https://files.pythonhosted.org/packages/bb/40/4476c1bd341418a046c4955aff632ec769315d1e3cb94e6acf86d461f9ed/msgspec-0.21.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:344c7cd0eaed1fb81d7959f99100ef71ec9b536881a376f11b9a6c4803365697", size = 188524, upload-time = "2026-04-12T21:44:18.815Z" }, - { url = "https://files.pythonhosted.org/packages/ca/d9/9e9d7d7e5061b47540d03d640fab9b3965ba7ae49c1b2154861c8f007518/msgspec-0.21.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48943e278b3854c2f89f955ddc6f9f430d3f0784b16e47d10604ee0463cd21f5", size = 218880, upload-time = "2026-04-12T21:44:20.028Z" }, - { url = "https://files.pythonhosted.org/packages/74/66/2bb344f34abb4b57e60c7c9c761994e0417b9718ec1460bf00c296f2a7ea/msgspec-0.21.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9aa659ebb0101b1cbc31461212b87e341d961f0ab0772aaf068a99e001ec4aa", size = 225050, upload-time = "2026-04-12T21:44:21.577Z" }, - { url = "https://files.pythonhosted.org/packages/1a/84/7c1e412f76092277bf760cef12b7979d03314d259ab5b5cafde5d0c1722d/msgspec-0.21.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7b27d1a8ead2b6f5b0c4f2d07b8be1ccfcc041c8a0e704781edebe3ae13c484", size = 222713, upload-time = "2026-04-12T21:44:22.83Z" }, - { url = "https://files.pythonhosted.org/packages/4e/27/0bba04b2b4ef05f3d068429410bc71d2cea925f1596a8f41152cccd5edb8/msgspec-0.21.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:38fe93e86b61328fe544cb7fd871fad5a27c8734bfda90f65e5dbe288ae50f61", size = 227259, upload-time = "2026-04-12T21:44:24.11Z" }, - { url = "https://files.pythonhosted.org/packages/b0/2d/09574b0eea02fed2c2c1383dbaae2c7f79dc16dcd6487a886000afb5d7c4/msgspec-0.21.1-cp313-cp313-win_amd64.whl", hash = "sha256:8bc666331c35fcce05a7cd2d6221adbe0f6058f8e750711413d22793c080ac6a", size = 189857, upload-time = "2026-04-12T21:44:25.359Z" }, - { url = "https://files.pythonhosted.org/packages/46/34/105b1576ad182879914f0c821f17ee1d13abb165cb060448f96fe2aff078/msgspec-0.21.1-cp313-cp313-win_arm64.whl", hash = "sha256:42bb1241e0750c1a4346f2aa84db26c5ffd99a4eb3a954927d9f149ff2f42898", size = 175403, upload-time = "2026-04-12T21:44:26.608Z" }, - { url = "https://files.pythonhosted.org/packages/5a/ad/86954e987d1d6a5c579e2c2e7832b65e0fff194179fdac4f581536086024/msgspec-0.21.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fab48eb45fdbfbdb2c0edfec00ffc53b6b6085beefc6b50b61e01659f9f8757f", size = 196261, upload-time = "2026-04-12T21:44:27.807Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a1/c5e46c3e42b866199365e35d11dddfd1fbd8bba4fdb3c52f965b1607ce94/msgspec-0.21.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3cb779ea0c35bc807ff941d415875c1f69ca0be91a2e907ab99a171811d86a9a", size = 188729, upload-time = "2026-04-12T21:44:28.99Z" }, - { url = "https://files.pythonhosted.org/packages/85/7d/1e29a319d678d6cb962ae5bdf32a6858ebdf38f73bc654c0e9c742a0c2c8/msgspec-0.21.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68604db36b3b4dd9bf160e436e12798a4738848144cea1aca1cb984011eb160f", size = 219866, upload-time = "2026-04-12T21:44:31.104Z" }, - { url = "https://files.pythonhosted.org/packages/25/1f/cca084ca2572810fff12ea9dbdcbe39eac048f40daf4a9077b49fcbe8cee/msgspec-0.21.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d6b9dc50948eaf65df54d2fd0ff66e6d8c32f116037209ee861810eb9b676cb", size = 224993, upload-time = "2026-04-12T21:44:32.649Z" }, - { url = "https://files.pythonhosted.org/packages/71/94/d2120fc9d419a89a3a7c13e5b7078798c4b392a96a02a6e2b3ce43a8766c/msgspec-0.21.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:52c5e21930942302394429c5a582ce7e6b62c7f983b3760834c2ce107e0dd6df", size = 223535, upload-time = "2026-04-12T21:44:33.839Z" }, - { url = "https://files.pythonhosted.org/packages/75/17/42418b66a3ad972a89bab73dd78b79cc6282bb488a25e73c853cee7443b9/msgspec-0.21.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:abbb39d65681fa24ed394e01af3d59d869068324f900c61d06062b7fb9980f2f", size = 227222, upload-time = "2026-04-12T21:44:35.093Z" }, - { url = "https://files.pythonhosted.org/packages/c4/33/265c894268cca88ff67b144ca2b4c522fc8b9a6f1966a3640c70516e78e1/msgspec-0.21.1-cp314-cp314-win_amd64.whl", hash = "sha256:5666b1b560b97b6ec2eb3fca8a502298ebac56e13bbca1f88523538ce83d01ea", size = 193810, upload-time = "2026-04-12T21:44:36.612Z" }, - { url = "https://files.pythonhosted.org/packages/3b/8f/a6d35f25bf1fc63c492fdd88fdce01ba0875ead48c2b91f90f33653b4131/msgspec-0.21.1-cp314-cp314-win_arm64.whl", hash = "sha256:d8b8578e4c83b14ceea4cef0d0b747e31d9330fe4b03b2b2ad4063866a178f93", size = 179125, upload-time = "2026-04-12T21:44:38.198Z" }, - { url = "https://files.pythonhosted.org/packages/c6/39/74839641e64b99d87da55af0fc472854d42b46e2183b9e2a67fe1bb2a512/msgspec-0.21.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:15f523d51c00ebad412213bfe9f06f0a50ec2b93e0c19e824a2d267cabb48ea2", size = 200171, upload-time = "2026-04-12T21:44:39.414Z" }, - { url = "https://files.pythonhosted.org/packages/70/9b/ce0cca6d2d87fcd4b6ff97600790494e64f26a2c55d61507cd2755c16193/msgspec-0.21.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e47390360583ba3d5c6cb44cf0a9f61b0a06a899d3c2c00627cedebb2e2884b", size = 192879, upload-time = "2026-04-12T21:44:40.882Z" }, - { url = "https://files.pythonhosted.org/packages/a7/08/673a7bb05e5702dc787ddd3011195b509f9867927970da59052211929987/msgspec-0.21.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f60800e6299b798142dc40b0644da77ceac5ea0568be58228417eae14135c847", size = 226281, upload-time = "2026-04-12T21:44:42.181Z" }, - { url = "https://files.pythonhosted.org/packages/7d/45/86508cf57283e9070b3c447e3ab25b792a7a0855a3ea4e0c6d111ac34c97/msgspec-0.21.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f8e9dfcd98419cf7568808470c4317a3fb30bef0e3715b568730a2b272a20d7", size = 229863, upload-time = "2026-04-12T21:44:43.442Z" }, - { url = "https://files.pythonhosted.org/packages/2c/62/e7c9367cd08d590559faacd711edbae36840342843e669440363f33c7d36/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92d89dfad13bd1ea640dc3e37e724ed380da1030b272bdf5ecafb983c3ad7c75", size = 230445, upload-time = "2026-04-12T21:44:44.806Z" }, - { url = "https://files.pythonhosted.org/packages/42/b4/c0f54632103846b658a10930025f4de41c8724b5e4805a5f3b395586cb7e/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0d03867786e5d7ba25d666df4b11320c27170f4aeafcb8e3a8b0a50a4fb742ca", size = 231822, upload-time = "2026-04-12T21:44:46.343Z" }, - { url = "https://files.pythonhosted.org/packages/ea/1d/0d85cc79d0ccf5508e9c846cc66552a6a16bf92abd1dbd8362617f7b35cd/msgspec-0.21.1-cp314-cp314t-win_amd64.whl", hash = "sha256:740fbf1c9d59992ca3537d6fbe9ebbf9eaf726a65fbf31448e0ecbc710697a63", size = 206650, upload-time = "2026-04-12T21:44:47.601Z" }, - { url = "https://files.pythonhosted.org/packages/90/91/56c5d560f20e6c20e9e4f55bd0e458f7f162aa689ee350346c04c48eac0b/msgspec-0.21.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0d2cc73df6058d811a126ac3a8ad63a4dfa210c82f9cf5a004802eaf4712de90", size = 183149, upload-time = "2026-04-12T21:44:48.833Z" }, +version = "0.22.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/e6/6dcf9306ff3c5e486578f3bf29ed11dfbdbbc2a8bf0caf7e07d392887fda/msgspec-0.22.0.tar.gz", hash = "sha256:0a13624a4969159fe35d8c2a3d377b2b61bbd8585e327440d5e52725affcce38", size = 343188, upload-time = "2026-09-29T14:14:11.422Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/5e/78d4fa2073bb3a891753e7f915d51094e2ded5aa5e9b20402518929b373e/msgspec-0.22.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f3413e3647275f787b21b4dfb4836a59a1a5acf1018ab1d45843b1d7edf15c22", size = 200076, upload-time = "2026-09-29T14:12:07.599Z" }, + { url = "https://files.pythonhosted.org/packages/38/f8/59701da04584af4ccd55f42200da303ebf146cd6867186a8b9b1e127a4a2/msgspec-0.22.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:38c5b9bd347bc9abbcee40752be3c5117854e891ea7a1881a56d4b3dec58c5e7", size = 192337, upload-time = "2026-09-29T14:12:09.198Z" }, + { url = "https://files.pythonhosted.org/packages/eb/dd/bd4131da741aa349656fe32a5cca0c4266c58d7b5ad75485bed29565f7cd/msgspec-0.22.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57c282f474e17acf6bcf84f393c73afd45d6eba47cccff8b76b79c4fbb8a3b54", size = 222888, upload-time = "2026-09-29T14:12:10.691Z" }, + { url = "https://files.pythonhosted.org/packages/c6/46/01fe71c42b3342f00e2dd6c5a8837f5dc4d0e1596b4c74c054fb13075201/msgspec-0.22.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12a887c4c06e4a771a2db32c9a80c7bb21866b12458025f636dcdc2253331c28", size = 227838, upload-time = "2026-09-29T14:12:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/62/8f/1a459825e0a5510de882af461459bd7f0525342b3c0bf1000e27be7aeef5/msgspec-0.22.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6c8a3f210421e29d8f7e9815f106cf59d758665b7fe5428e61152ce24fe65d7", size = 235818, upload-time = "2026-09-29T14:12:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/9d37b6f1190101b452f6c455e8715cc9960afad231e18cf9545af58710b9/msgspec-0.22.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ebd211d7af79ed8710c64e9e8d4c0d02749bc20170e7ab4e1c5801ca7c99d25b", size = 228019, upload-time = "2026-09-29T14:12:15.156Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d5/33723137c96b8f244d8e6fc57a0a8d3b57b3599ce9b4a4dd58dc55a46d1c/msgspec-0.22.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:27d9ef46c80884f9c4f323e0b18bec464287e872121e70f2cbe47335780bf597", size = 236406, upload-time = "2026-09-29T14:12:16.908Z" }, + { url = "https://files.pythonhosted.org/packages/44/4a/f0e4a9ab970ce0a31f191acb772d3e1af67eeb73e1d73b70c079252aed02/msgspec-0.22.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ec108e96fdaa8fdbe5bb993ec97a9d1faa69b3a521eecd71a6e5acbe0e29ae69", size = 231028, upload-time = "2026-09-29T14:12:18.497Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e8/3de7345a8944a5bcfc9dd861d30fcea5f20f51057bcafacbbff9164e55fc/msgspec-0.22.0-cp310-cp310-win_amd64.whl", hash = "sha256:21c887d4de397355f6635c2a037b1c067882dac5d132a1793d63bbf7cf5ca78e", size = 190753, upload-time = "2026-09-29T14:12:20.291Z" }, + { url = "https://files.pythonhosted.org/packages/66/c9/f0d3bd2dfc3753806ab70b8d00a1613019c39148a87da797771d7f72a0a9/msgspec-0.22.0-cp310-cp310-win_arm64.whl", hash = "sha256:4a663a8d7f6ad56ac1dbcba91e046ba8ebab7773ae72ef3dd3c47f8226919184", size = 188939, upload-time = "2026-09-29T14:12:21.645Z" }, + { url = "https://files.pythonhosted.org/packages/9d/22/45c17acb1a85360b10afb95f66777f76bc2634993c66db8b7833832bd343/msgspec-0.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fb1e129b81ac8fcf9ec649b081c6c8da1c7ea6f87cab336d46386abc2cd855c1", size = 198231, upload-time = "2026-09-29T14:12:23.016Z" }, + { url = "https://files.pythonhosted.org/packages/34/79/1cf725694125051e866066d74e6199206838d1465cbfc35081dc29b6e366/msgspec-0.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dce29a04966e31abf9b83b697c6d672486526dc5d03fcd6970cb56d5dc1fbeea", size = 190911, upload-time = "2026-09-29T14:12:24.636Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b2/e0ace038031a2988aa2e85c431c4d7aef734fbba4749ace6bc5bf310b769/msgspec-0.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b962000e11dd34fb210a5a2c57a8a62b2d92b381c8cb3b05c075a83e38f8d645", size = 220343, upload-time = "2026-09-29T14:12:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e6/16ddb09185d79dc00177994cf0bdb1cd8e5cc44a1d1bfba61bdda5f382cb/msgspec-0.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6db3806b3b76ca78064255eac6fa101a8a64fe6f698d80fbaf81fdfa21217d4", size = 225251, upload-time = "2026-09-29T14:12:27.559Z" }, + { url = "https://files.pythonhosted.org/packages/16/c2/a6af0d38fb0e72f02851ed084c4b8175140cfaf3eaf48b38da0c3941db26/msgspec-0.22.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a88d939d3fe4b8c7314645ebcd6e86c8c8a512ea7820d6550355973e803bc0f1", size = 233488, upload-time = "2026-09-29T14:12:28.996Z" }, + { url = "https://files.pythonhosted.org/packages/0b/9b/b1c4208cdf487e2ba7af145f721b279444ff76af05a9f8fce992ed0588ee/msgspec-0.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0b31746da07cba0e330c6433a94a4699ad77d3aeb9638d1a320a7686b69f6249", size = 225688, upload-time = "2026-09-29T14:12:30.351Z" }, + { url = "https://files.pythonhosted.org/packages/83/54/b9240d908674ef7c41d02cb909731ad6d9931c23bd6a27d8d10776c6f964/msgspec-0.22.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:6ae370f92f3517f0e6f209ba7cc649c957b444868439197e046be07154667551", size = 234250, upload-time = "2026-09-29T14:12:31.887Z" }, + { url = "https://files.pythonhosted.org/packages/df/c0/d498798aaab3bd191a33955de47b40f07fae7667d86a33b705443a7e9491/msgspec-0.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9a696f23f7c1ffb31fae308502e01a3965c3891d5c400f01d0d1096dbe77519e", size = 228337, upload-time = "2026-09-29T14:12:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/fa/51/5e9ae5a5ddc254e15435749328161e95598750e5df644bb00fa9e2297122/msgspec-0.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:024138c51afd335d0b4dce401be33902caafac2b64f8c9f2509a378986175d98", size = 190962, upload-time = "2026-09-29T14:12:34.847Z" }, + { url = "https://files.pythonhosted.org/packages/12/38/fb64a18543bcbebc53a375cb00b1c93bf264a0b6c7bbe9e38b37cc5f0768/msgspec-0.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:4600dbec738ed74e4c9bd35503e84701200ea7db344cfdeda80677b3ee53eb64", size = 189458, upload-time = "2026-09-29T14:12:36.277Z" }, + { url = "https://files.pythonhosted.org/packages/a4/87/3e017dca361d09ed1cd09dc981a6df21b32e830fbec3470f7486d38b6be5/msgspec-0.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab1e9e7531e353653b906cdd12a0220cc288a1e8e3436aabc65f4508d91b14d9", size = 201301, upload-time = "2026-09-29T14:12:38.048Z" }, + { url = "https://files.pythonhosted.org/packages/fb/02/109165edaafb895668d87177972a32ade9126a54f3736123d8e44be9096d/msgspec-0.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b60b43425a47eb9cfe987f6874e354ca7c760e58e295b4e2273ff03574df28a1", size = 193044, upload-time = "2026-09-29T14:12:39.46Z" }, + { url = "https://files.pythonhosted.org/packages/54/a5/65de05f8804492f76ea121b21a125cdf1d97ec461c677bfa0ba354d6fbdd/msgspec-0.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5a169b5b03f0f2c7a296c002647db1dab75d2cd501bca34e32b71cab0261b56", size = 224035, upload-time = "2026-09-29T14:12:40.876Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cc/aa1a47f8c92280d37498a5ea56a2a36606d034383e3e6472d64cbb56cf85/msgspec-0.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99c401861c5bb3a57f7d6423ea7ed4352cd57aa3f04f4fbe9f3e3e4564a10f08", size = 230377, upload-time = "2026-09-29T14:12:42.796Z" }, + { url = "https://files.pythonhosted.org/packages/61/50/f8bcdb3d613a4a4b92704297a12eba5c985cf572a64ee1a004d265759c69/msgspec-0.22.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08826f5e5b0fa2f7a88592c396a243cfcc63d37e19f9d4fbe3b3f1be2fbdc404", size = 237390, upload-time = "2026-09-29T14:12:44.282Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8a/473fa423f8fdd1b810b8652594323d7301df6920b62844d860daa0feff34/msgspec-0.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21460f54cee9208239b1a8421fdf25bffc77293e1daba88f585711ad839b9758", size = 227733, upload-time = "2026-09-29T14:12:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/03/1d/272ce23adae6c71b3f763aed3ee6e115cccc56124ed8ee0e3e3d2681e2c8/msgspec-0.22.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cfc3d9557de9c806318725b702f3e664db33167bb42892079b693c69893fd33b", size = 236783, upload-time = "2026-09-29T14:12:47.234Z" }, + { url = "https://files.pythonhosted.org/packages/f6/26/29e0b9a8605c8819a3c718158e345a616ac42c092dd7d7ab248c2f2b0a72/msgspec-0.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0b25dcbc108783cb72503ed705b9fbb8c3cb02ee5801923f44b5f038c91cc365", size = 232728, upload-time = "2026-09-29T14:12:48.792Z" }, + { url = "https://files.pythonhosted.org/packages/e1/a6/99597c281d716da6c662b48dcc3f734669f716b41d5df2af367dac9e7c21/msgspec-0.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:6ad64f5c260866b0d543f89f50cee43628989c1433c5de7ce820281fa28a2611", size = 192885, upload-time = "2026-09-29T14:12:50.274Z" }, + { url = "https://files.pythonhosted.org/packages/46/80/85fff923d448b886ec3a85900c578d9367f08dad54fe48879495b4c6d055/msgspec-0.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:0922714feff5300aacd8ecd65fa828317ce4bf5212b3139258c0bfc0253cd80e", size = 191223, upload-time = "2026-09-29T14:12:51.699Z" }, + { url = "https://files.pythonhosted.org/packages/7f/62/5374fba2ede0408f4bd8b9b3a6c8464f8d0ea7ae9a2a064bd81ca492bd1e/msgspec-0.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f13c127a945479bc9db057eb253b8851075c8e1ae07ffc967bfa1c5676203a86", size = 201355, upload-time = "2026-09-29T14:12:53.145Z" }, + { url = "https://files.pythonhosted.org/packages/cc/e3/357baa8d2a9164a98dfd7ef9d3a58125df0ed981be909945bdd337be7194/msgspec-0.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5aa24eb475d070ecbbe5b21080fc3ce4b0b76c60de25cfe0c9678d8fb44bb42f", size = 193097, upload-time = "2026-09-29T14:12:54.52Z" }, + { url = "https://files.pythonhosted.org/packages/fa/1b/9cc07718d1dee8ed5e89a265801d565bc0f15ead435ccb198f9c7bf92574/msgspec-0.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:627bfdfe5a4b3d916b3360b30f4cddeee3a084f56593e33527c6872fa8322ff9", size = 224112, upload-time = "2026-09-29T14:12:55.983Z" }, + { url = "https://files.pythonhosted.org/packages/46/64/f33fdfe95aca76601194a7064d14816c7c22c4eccc1b03a5335785895fa3/msgspec-0.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6c310ef83e7e291b01a63298828f848348bb99e84a1098c4b3923c05674d032", size = 230472, upload-time = "2026-09-29T14:12:57.648Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b3/8ceaa9981c230adf43c45a6e8da25da23a381eddc7ed05aeaca1d5e7928b/msgspec-0.22.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c1e76c6bd523141b9c05c2f8a70979cd0efedbd68855a66f292f8892c0b8fc7", size = 237382, upload-time = "2026-09-29T14:12:59.414Z" }, + { url = "https://files.pythonhosted.org/packages/88/a6/7b5c4fb39e0bf2dabc8be923c33c39b07ba769a0ce6f0afbbdfaadb1f2f2/msgspec-0.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc374dedd5f85a5f4de2386dc5f737894ccb8c1ac18e9566ce66fd9839e6285d", size = 227717, upload-time = "2026-09-29T14:13:00.88Z" }, + { url = "https://files.pythonhosted.org/packages/b8/5b/2334ee638880e756c8bc54a1177bd65877c786433693a43594ef5ecbe2d8/msgspec-0.22.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:feafe612034d49e9144340c0b5168ee4e22c2af4aaa2c1db11ae84e1aac9543b", size = 236781, upload-time = "2026-09-29T14:13:02.468Z" }, + { url = "https://files.pythonhosted.org/packages/6c/e5/b4c5323b17ecfce45350695d40fc93e16856db957a53cbcf2f53007d6e12/msgspec-0.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f48317f05312bfdf78248f53933f830f07ab75cc1c813ac3ca4220cb3b5b019", size = 232777, upload-time = "2026-09-29T14:13:04.025Z" }, + { url = "https://files.pythonhosted.org/packages/01/33/e591f9d3d8d6c9cfc02ae95f3e3c44920f2d18050f3f252c244e0f293a0e/msgspec-0.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:0739b068f31f2004a364f97679ba91f2f5ecd6ec2a5b4b890188ab5c57d20672", size = 192829, upload-time = "2026-09-29T14:13:05.519Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cd/a011a5b8732cd781e2ea6da5b38d71ae4a9a329338411d1f008a58f5edbf/msgspec-0.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:508278300dd4efbd21cd3a4b2b016160a5feac98bc880d3673f6c06697baaf62", size = 191258, upload-time = "2026-09-29T14:13:06.909Z" }, + { url = "https://files.pythonhosted.org/packages/53/f9/ac027b35477e6b83bcee32b3d9675b37abfa130f098dd6500fa67d768852/msgspec-0.22.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:221cbcbfa4478152b91d37dcfd4830e2be92773e8139e883f43773450ebacef8", size = 201276, upload-time = "2026-09-29T14:13:08.311Z" }, + { url = "https://files.pythonhosted.org/packages/13/6b/2bffffa31662b1353a62e672442865d51c291ad778352fd490de16361dc6/msgspec-0.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dd9568695911055440d2bb7099ed9098fc181d335daa772d0eb3fe8f31ba4efb", size = 193233, upload-time = "2026-09-29T14:13:09.943Z" }, + { url = "https://files.pythonhosted.org/packages/14/bc/4066416ff6aa918d1ef9295edee0041e4629e4079ad3839bdd8a68fd87f0/msgspec-0.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f039ef5207b847f075a0a43020ee6140cd47505f890e47e157f2deb485c2dc96", size = 225101, upload-time = "2026-09-29T14:13:11.391Z" }, + { url = "https://files.pythonhosted.org/packages/63/ba/a8d390d5bd4c7d9ccde87c95cf071ada934cc9ca2c6af4d3d50b38f2d718/msgspec-0.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4f7e09cceac7dbf4c0761b8ae7df51c55b5df5e9af7aff2c895aac1ebea015", size = 230505, upload-time = "2026-09-29T14:13:12.869Z" }, + { url = "https://files.pythonhosted.org/packages/9c/89/979664fdc913c624ef88a139b40e3a95ddf2a47c89e8b5c4147f69ee9c48/msgspec-0.22.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:614e2c827e0a3f934f3cf0cf4ba65210df8132b75a69a8a1f51bb3b2caf0ac5a", size = 237382, upload-time = "2026-09-29T14:13:14.317Z" }, + { url = "https://files.pythonhosted.org/packages/07/3f/7d44c614376ae008ac6099be5f589b322c4ad44e32c6dbb0edd256215028/msgspec-0.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3689b9dfcc663358ef23ba4299d7460f01108515b041a7d30d05908ac9c32f", size = 228962, upload-time = "2026-09-29T14:13:15.763Z" }, + { url = "https://files.pythonhosted.org/packages/0b/59/bf8504e6f63f6769d01fb66f8bd856cf0ed39a07fde354f440d711640054/msgspec-0.22.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2f950239ff1fc7322c6f9634807310265149cb168270d3ddcdda5b6ada13a28", size = 236691, upload-time = "2026-09-29T14:13:17.195Z" }, + { url = "https://files.pythonhosted.org/packages/2b/40/5a9d2bde12af16a22ddbf371990a81d3e3c0dcd4bb4ef3b3f9616b033c14/msgspec-0.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c789b5ccd07c0a3c09767108ee06e089b2875f2309a4569c2648f30a8d31dfa", size = 232750, upload-time = "2026-09-29T14:13:18.691Z" }, + { url = "https://files.pythonhosted.org/packages/75/5d/c0e6bdb81a87f6bd56a663a330c271af7670490c80d8d635d9fa21ad1adf/msgspec-0.22.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:a66b1766311e42371e509c996c3933b161c7ae0eabdf361af5316dec197e1022", size = 136814, upload-time = "2026-09-29T14:13:20.415Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c0/b0cfc6d33608e5ea8871f3be31f9146c56699e737a7d8862bf018484f278/msgspec-0.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:749899563d26b211379f142b8ffd7e2d7da149a51717798f0ce994dce50324f0", size = 197097, upload-time = "2026-09-29T14:13:21.869Z" }, + { url = "https://files.pythonhosted.org/packages/42/1f/571f7fe7c725380605d680fc4c0084212b23d2dfcf6be0f2277f14462c56/msgspec-0.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:10d0d1d464960d99a949f7ca01ef8928e51c472433a5f5ab74b2d695fb830652", size = 196779, upload-time = "2026-09-29T14:13:23.62Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f3/3c87372bac651b37911e0dc6926c3958949d3fcb8cec1016adbc44d948b2/msgspec-0.22.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:e79725246291516a7359caad5fb743ddc0ec66ed40d2381fb846325b5031504e", size = 205214, upload-time = "2026-09-29T14:13:25.158Z" }, + { url = "https://files.pythonhosted.org/packages/43/4c/fbccd6e0fbbdf10c4d9b6bac8a26148dd5483b3ffff6d6c5a376ff1f5cb1/msgspec-0.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:38f7022fbe91954b31afe3888a0af1b652e0f370fafdeb1d425f4a814d789c9f", size = 196941, upload-time = "2026-09-29T14:13:26.637Z" }, + { url = "https://files.pythonhosted.org/packages/55/04/8db7186d3ae8818356bc623cc132db8b77da37ce4b1345f35719c8ad5726/msgspec-0.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b6d3ca19a8ff28d0a67a1824e2bff7ec649ec795c80a265f20ade4caa63080de", size = 229934, upload-time = "2026-09-29T14:13:28.285Z" }, + { url = "https://files.pythonhosted.org/packages/17/24/a249f3491cabbe77cc65a1a6f87c128582aa39357227149be61cac8e554f/msgspec-0.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8b98ae215a102cbf6635f7df45f5c4af12f77fad1f7b71b9808fcf868a5735d", size = 234378, upload-time = "2026-09-29T14:13:29.821Z" }, + { url = "https://files.pythonhosted.org/packages/87/ee/6dbcb1b5de8e9d47e8f0fde9a288628dc178c1749a570b98251218fa10c4/msgspec-0.22.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e0aa0cc3f18c35bab79bd7b87fde95d6274a9deddeebd1ea541f8066a5073165", size = 243118, upload-time = "2026-09-29T14:13:31.544Z" }, + { url = "https://files.pythonhosted.org/packages/79/03/7dd2d0ca988600e01fc00ad0cf20d1d44bc59369a913c988654c65f6582b/msgspec-0.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c8e84789918fbc15a503b92a829115ddd7567ecd3e4778bd418c56abbb86c11", size = 234557, upload-time = "2026-09-29T14:13:33.068Z" }, + { url = "https://files.pythonhosted.org/packages/74/e2/43f3c63bff1650efcaaea31466246e28b46927323fc9ff416c68cc6e4047/msgspec-0.22.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:3ca7d4cd69fbb66bd2da6211d3e79d40542d196c16c6d99bf838f76767ad35be", size = 241288, upload-time = "2026-09-29T14:13:34.532Z" }, + { url = "https://files.pythonhosted.org/packages/8b/70/11b93815a59674f33182dc3e873d343ca0b37e25be52ecb28f52092f1fed/msgspec-0.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:28f53f3604dd3e70225f7563c831628dbb03299b428f8e62aadb4b628e386874", size = 236432, upload-time = "2026-09-29T14:13:36.083Z" }, + { url = "https://files.pythonhosted.org/packages/b7/82/7aad0f033f8dcb3f23868773c2ede803ae162a784828ccde75aa3f9b2f9d/msgspec-0.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7293dee54de040cfa225c22151cc3d72f17cd674b5ebcb52f38fb9f5701592e6", size = 202062, upload-time = "2026-09-29T14:13:37.955Z" }, + { url = "https://files.pythonhosted.org/packages/e3/45/cf52577926d73e2369e25927e389cb4ea1461169c489f46d3248159b5be7/msgspec-0.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c3c510aba9015c085e514b75a9b3f1ed7c4591ae5e379655821b8bba51f30cc7", size = 201686, upload-time = "2026-09-29T14:13:39.42Z" }, + { url = "https://files.pythonhosted.org/packages/c8/63/d93937e2aae34ff1ea33b62799d1963cacc1bf432d196d6130039657a122/msgspec-0.22.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:263e110955ed76fe0af2d79f819903b50a70dc0e7a752eb7aabe79d2e0a084fb", size = 202241, upload-time = "2026-09-29T14:13:40.919Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e2/46ece11a244cd56432eb2362ffbb8014f3f02963136d84d941f71fdc2a3f/msgspec-0.22.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:c6f06576eced70462179a4b4638e84cf69fdbba37f44d13a64a21739c131a830", size = 194232, upload-time = "2026-09-29T14:13:42.454Z" }, + { url = "https://files.pythonhosted.org/packages/cf/b1/1c385f2f93006cdc2af1511cc512c347cb22e2d4f11952c205230aedf586/msgspec-0.22.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d67582478b0eaabb899f2fb255c878ee7de57dff80eb73ab24f1865524ec441", size = 226524, upload-time = "2026-09-29T14:13:43.876Z" }, + { url = "https://files.pythonhosted.org/packages/dc/fb/c80c8842d40347cacf89a60a4986b849dae1a6dfd25830441efdd6faa65b/msgspec-0.22.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:71cbbdb39631064e2f2f9e9ac2b1b69931d72276eb5f9da4ed025726296bdbb6", size = 231816, upload-time = "2026-09-29T14:13:45.329Z" }, + { url = "https://files.pythonhosted.org/packages/73/ac/90bbcfd890b4bda90c93f7e1b7fc24e84b270420486d9d43ae31443d15ab/msgspec-0.22.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f0a5c25516e2034b2db7767081759ff8996e214def9c43b3055f61e1be1caad", size = 244241, upload-time = "2026-09-29T14:13:46.851Z" }, + { url = "https://files.pythonhosted.org/packages/72/9a/eabdb5f1b5e6013b0e2f9f2a95790587f6864aa9ca37f9d7dece65b53878/msgspec-0.22.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:a1dab6a99c759d1391ab2993388c1892746a697254f4b5dc6c059ca6e3bfbc8b", size = 230198, upload-time = "2026-09-29T14:13:48.296Z" }, + { url = "https://files.pythonhosted.org/packages/e9/89/9f080532d4ac52f416dd7318e55c2053cc071853d17d58e24897a5b553bf/msgspec-0.22.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:a52eba5c9528fd181fcec39d22b67aaa1dccc6cfe8e24d3f5d41130e6d04289d", size = 242949, upload-time = "2026-09-29T14:13:49.829Z" }, + { url = "https://files.pythonhosted.org/packages/11/df/6baf9b2f3523ebe2b820820c7929fd72ec5f483a93147130338ecc353fac/msgspec-0.22.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1e547966017265c0d23342bcf2e027305dde40ea042d16694a9b96b4f696a052", size = 233914, upload-time = "2026-09-29T14:13:51.5Z" }, + { url = "https://files.pythonhosted.org/packages/bb/37/9cf650779c8c1e53291ef184c838703930a4cabb1fb37e222c85a7d49fa9/msgspec-0.22.0-cp315-cp315-win_amd64.whl", hash = "sha256:0067057df265795f742658b15dbe53f3b6f21d19dcfa53676db11088cfa41e0a", size = 197910, upload-time = "2026-09-29T14:13:53.071Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ce/2f78c93d4f69e0167a19c2d40d4fbf7bbd6f074e1047536735832a4368ee/msgspec-0.22.0-cp315-cp315-win_arm64.whl", hash = "sha256:05dbc8268e50c9232ec72b9af1c7b13049aade4d1197764e38c427048706e046", size = 197590, upload-time = "2026-09-29T14:13:54.47Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bf/282e9a443058b85b8f706c9a651e2d8cdd11cc09d16e8fa347b6c57b75bb/msgspec-0.22.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:b3113ebcceeb7693a915183c73d92c10bf5c62851dd187cab43bd025fb587419", size = 206298, upload-time = "2026-09-29T14:13:55.913Z" }, + { url = "https://files.pythonhosted.org/packages/ef/2d/2e694fa46f55319007f72013b17341ea3868be1c77e7a597176b202dda92/msgspec-0.22.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0dfadea8bdcfafc614bd031de55a8ede22b43445cfff6d8b77cc0c07d3edc8a8", size = 198145, upload-time = "2026-09-29T14:13:57.412Z" }, + { url = "https://files.pythonhosted.org/packages/5b/2e/2fa279cb57cb47175ae604d572787f903d4ad3f0afa867201bbd99e6647e/msgspec-0.22.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7a738826936c72348c613061d260446f13c82b6fd7d5d7705b6911ab8dca2f3", size = 232362, upload-time = "2026-09-29T14:13:58.817Z" }, + { url = "https://files.pythonhosted.org/packages/a0/58/a7e759b11b28441c27f803b29d9b5f4b5ad85150c89354b5ede1baca9258/msgspec-0.22.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2ddea9d78d09460f06c26a7a508adcd049761c3208776162b8eb79b8a032cff", size = 235885, upload-time = "2026-09-29T14:14:00.381Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/8d7ee098e94cbd9f35fa643dc497e06a4a6307b9f562cfbe48103fc3b209/msgspec-0.22.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:884c28c80b0a511595b29a9b04a3a230c3797369e4a033e6d5c6d9b5427f8e09", size = 248155, upload-time = "2026-09-29T14:14:01.945Z" }, + { url = "https://files.pythonhosted.org/packages/b9/6d/1cabb4b8a5dbf696e2b24df9e482b2e0333bb3b1b13ebb5433813e6616ec/msgspec-0.22.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f7a923bcde480065c8e25967464cfb2a687ee67000bb43157e2d57e40eca7305", size = 236416, upload-time = "2026-09-29T14:14:03.363Z" }, + { url = "https://files.pythonhosted.org/packages/ba/43/8bf0f558eb369f1f2d494b3d5ab9d0ae0907d07ecc0cdbe11b6768b02867/msgspec-0.22.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:65eea14bc65ccfeb8f3af62cb204841871e2961f002d7fa87dbe0f79dacf1c1c", size = 247292, upload-time = "2026-09-29T14:14:04.829Z" }, + { url = "https://files.pythonhosted.org/packages/81/33/2fbaadf98b5510cac4bb56d2b03937e0b1fb4bfcd1ae6aba20361f299583/msgspec-0.22.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:0666a1520cab86796612e794e71107e0fbf5e8ff3ddcdfcfff8f1d94b860d2f1", size = 238220, upload-time = "2026-09-29T14:14:06.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/cc/b6be6041098ab859a8472983ccc2c08339fc2ef53f28d4f5fe7f4f34276b/msgspec-0.22.0-cp315-cp315t-win_amd64.whl", hash = "sha256:885c6e0c89d6103648525fe62aa78d600054dedf7b3713d23b15d7ddb6d66a13", size = 202939, upload-time = "2026-09-29T14:14:08.079Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c1/664578dd98be70cd4ab1a9dcf3a181b1376b83c65ec41ee162130b58c8c0/msgspec-0.22.0-cp315-cp315t-win_arm64.whl", hash = "sha256:268594d0bae5510572599a6ab0364dd9de43c867d24a30856cd9f5edb63d8dc6", size = 202117, upload-time = "2026-09-29T14:14:09.891Z" }, ] [[package]] @@ -3430,11 +3467,11 @@ wheels = [ [[package]] name = "multipart" -version = "2.0.0" +version = "2.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/49/c4/f3f0c57ec3e845bae038d3849af0e2b9d3f62a046b2a09af3f697ad882d6/multipart-2.0.0.tar.gz", hash = "sha256:d6076567b489270271d9ec103d404745da1501ad5b70e3a7f1f07f5623d82850", size = 42606, upload-time = "2026-07-18T16:03:23.993Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/d5/440ca1e07162d93b6645942e36e8721d2e54ff28d265abec73a6339a6c59/multipart-2.0.1.tar.gz", hash = "sha256:5dfea7ce4260e10a9e620369449e7d41c269a94e192db9ea4af1a0059cddd370", size = 42844, upload-time = "2026-09-25T14:07:02.649Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/dd/feb9c23d5c5a382194a9f86b02e98b8724d745e1a1cf4ecb1a5c0f884aa1/multipart-2.0.0-py3-none-any.whl", hash = "sha256:b3d4d473c4b96bb43e0d54b76fcc3b89b904df28387daf54e08405eb2123a081", size = 16418, upload-time = "2026-07-18T16:03:22.599Z" }, + { url = "https://files.pythonhosted.org/packages/20/f1/11873ce60ad824ac44f796de9879879dcd62672a172cbafe6e26eb2ea848/multipart-2.0.1-py3-none-any.whl", hash = "sha256:ac54d6bd0353ee30d2703af75adacd8b2052d4adb634e9e74dc7c5b06e0bcd86", size = 16448, upload-time = "2026-09-25T14:07:01.247Z" }, ] [[package]] @@ -3604,11 +3641,11 @@ wheels = [ [[package]] name = "nodeenv" -version = "1.10.0" +version = "1.11.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/105de02c1322cfada6d9710d9146ef8026419d433c9d08359a2d35805811/nodeenv-1.11.0.tar.gz", hash = "sha256:3ce8fe5b71d16e8af7039ca65257354100bc772965d6bc549070649e53b1b146", size = 99301, upload-time = "2026-09-26T11:29:21.367Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, + { url = "https://files.pythonhosted.org/packages/54/c8/12811c9b48fde162bb72b6f2e78fada9a247a09d7bb5be2050a5d099c77b/nodeenv-1.11.0-py2.py3-none-any.whl", hash = "sha256:edaa16e6c14d7cf395d75d4bbd5a26390f4dc06501a33b4e76282b02cc688a25", size = 34277, upload-time = "2026-09-26T11:29:19.933Z" }, ] [[package]] @@ -3994,61 +4031,61 @@ wheels = [ [[package]] name = "oracledb" -version = "26.0.0" +version = "26.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ad/0f/7eefd0e10328d94109180a877f9f1297b8b8dcc80f0bcccb0b0efea2ca9b/oracledb-26.0.0.tar.gz", hash = "sha256:d15728bae7546984203ae17f8296153d52d10ffdaf4929bebe46b70d5819d270", size = 912239, upload-time = "2026-09-10T16:39:01.602Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/13/fac2cdc973071741cd9140e83445eca1e38d63820fb53e48d6bb031e8cff/oracledb-26.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c4c76a3b29c5c457c9db4114b715bafd4d2af6b3d3c050bbc3aebc63b597421", size = 4853759, upload-time = "2026-09-10T16:39:08.165Z" }, - { url = "https://files.pythonhosted.org/packages/34/cb/5ad3048d9982c9b588f63cf20a4537cf9b7124918f541f6c5b502fe1c961/oracledb-26.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99c2f2cf2d2aaa4e4346a2aff01782b94cb156313eb0df467d9b0d196ff6bb26", size = 2643007, upload-time = "2026-09-10T16:39:09.946Z" }, - { url = "https://files.pythonhosted.org/packages/de/ec/d374aeddb7e0765d9cf6af73c55f0ed2b5a72a0df5e8f3f50682d7e35ef5/oracledb-26.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dea8e181062c367db459627389a4956fa2ca19a36bdfdbee87e6b6442984e12e", size = 2841680, upload-time = "2026-09-10T16:39:11.485Z" }, - { url = "https://files.pythonhosted.org/packages/4f/be/a6a6f1886e2671b2d2ec91ca8513bcfe3985900eb9ba3560447d18644759/oracledb-26.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7870e922b9b2800e72632d233464789c9812274954ba911fd7dfeb1fa9456899", size = 2693235, upload-time = "2026-09-10T16:39:13.717Z" }, - { url = "https://files.pythonhosted.org/packages/ab/fb/7573b740839787e189aeaf8d9714b21ee343ca4b149d5d71b36a90cc8544/oracledb-26.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e327ab743ba2ba0ac3edbd56a3aa8f12a926581de6fc1b4141a4138d371ab648", size = 2867740, upload-time = "2026-09-10T16:39:15.889Z" }, - { url = "https://files.pythonhosted.org/packages/23/14/45b9db2f6b3813cc478c33bdfa63d3fc1e31f30e968afe310635429a256d/oracledb-26.0.0-cp310-cp310-win32.whl", hash = "sha256:661c4d85e80d7bc2142d035ddf7135aaec73202173c3450f5325f7765eb0cbc2", size = 1612865, upload-time = "2026-09-10T16:39:17.462Z" }, - { url = "https://files.pythonhosted.org/packages/07/f8/48a119b8a959c5a5de846922f9e5a1b4d398ee82ee3860c5923a02f07c40/oracledb-26.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:3c7ec25379f1daf011b0c8be5cc435ff9d12e4a8daafbc5802e599b75d302209", size = 2063605, upload-time = "2026-09-10T16:39:19.023Z" }, - { url = "https://files.pythonhosted.org/packages/5f/1f/26c437e62cbe3fb75b3fc0c1b522677ae4783255b76b85a9c95ba8760411/oracledb-26.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:fac7a7312cf847849b43351c8cda7f5e7f847f27fa46ac8f3c81d82935c0b621", size = 4805020, upload-time = "2026-09-10T16:39:20.846Z" }, - { url = "https://files.pythonhosted.org/packages/d6/6e/ffde2b32698d730e74391543af5d9cd5db794f98fe6892df60a7e8161d81/oracledb-26.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9614f8e6c71e663d9d854283e2db2afe36713623ded4caca34ae037bc5b3ee73", size = 2640830, upload-time = "2026-09-10T16:39:22.836Z" }, - { url = "https://files.pythonhosted.org/packages/10/5f/64eb9038abb8d0a5c36388f0ad1816c4a08d9bf49d3c4cf8a7dd96404738/oracledb-26.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:716925cd532bfd1880bd91484ee2cc47a0dfddd229a8f8bb9d60cc18454883aa", size = 2838709, upload-time = "2026-09-10T16:39:24.315Z" }, - { url = "https://files.pythonhosted.org/packages/40/b5/937b6266cc3eb6c0968cf24d9a86607ac239dd00db4cd37d3fa1cc24b89f/oracledb-26.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e1991536b651dd78ae63c342a0cfbfbe4140f2440ee9eba6651e442593b1090f", size = 2690107, upload-time = "2026-09-10T16:39:25.987Z" }, - { url = "https://files.pythonhosted.org/packages/22/31/a372a82ac857b096956a3240e365dedfa03fca33ba7ba31015521f1a78bb/oracledb-26.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b354e525956110327a31c41da6bbc7539d27dafc39dc192f174628678c8d44f3", size = 2863970, upload-time = "2026-09-10T16:39:28.495Z" }, - { url = "https://files.pythonhosted.org/packages/fe/f5/fb0073a726b3197b750cd5d6d15b6c4ffadb966e022077047f1d9984f923/oracledb-26.0.0-cp311-cp311-win32.whl", hash = "sha256:7ee2b1e15b9d6705ef5f37905bac86a687185c572306258111f18f499309d7ca", size = 1606712, upload-time = "2026-09-10T16:39:29.898Z" }, - { url = "https://files.pythonhosted.org/packages/80/2d/1b3d8c9acbb457f2dc0f3ef5240fc914ad8a415370d6cf09c0d8f350cfc2/oracledb-26.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:072690413f6b55833e669e49cae6e03ad2561156134caa1e9cdaa5b8f8951b44", size = 2067566, upload-time = "2026-09-10T16:39:31.346Z" }, - { url = "https://files.pythonhosted.org/packages/68/4b/183713888f90a443545f75481029f16340ad8fb1c0488983a49a9ecc4a18/oracledb-26.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:06982164e3babab2f54ad4d72fcd5f613d112aa8c53c9ad363ff7e1d32a957c2", size = 1708447, upload-time = "2026-09-10T16:39:32.634Z" }, - { url = "https://files.pythonhosted.org/packages/ef/40/1e165629d4b474b7cdf8013e3e093e91e2fe0f60583e96658b7391d8dde2/oracledb-26.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7b325e2662be477f36360284c96b22405ba2f6bd398c028e163aa335c89264c4", size = 4905352, upload-time = "2026-09-10T16:39:34.305Z" }, - { url = "https://files.pythonhosted.org/packages/12/d4/b221f2cf61af8a4a52a62a02d0857af548410897c9ae8de9d00ea26c3a86/oracledb-26.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:838f5dce05c359ff0adf3656bc906b63ac3aaecf6c794860e93bd5ab0e2acdb9", size = 2445639, upload-time = "2026-09-10T16:39:35.808Z" }, - { url = "https://files.pythonhosted.org/packages/7a/72/263fdf667c1963bcf393901719ed546342f1338daa8a4d236bef09cc5ee4/oracledb-26.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e6b5fe7cfddf99544b2e3c63fae588cb73e9545dc776a059711e34d2d734edbf", size = 2662086, upload-time = "2026-09-10T16:39:37.221Z" }, - { url = "https://files.pythonhosted.org/packages/27/6e/3f71a580e73fc9ead3bf43b3f26f30cb0fe07cbd0698887706dcd5b8346a/oracledb-26.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c41cf87895246062fecfabbcd0fc035fd859b2fb908c0199f3dff2b3016dab48", size = 2490449, upload-time = "2026-09-10T16:39:38.957Z" }, - { url = "https://files.pythonhosted.org/packages/61/0a/626d27278e97dbc963f3f7fe652d47a4519cb56bfc4e03e981bb468c4a8f/oracledb-26.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf3e22f2ee97d664033de509219439838b5df92a05673c21029d06fb3b9fc496", size = 2693458, upload-time = "2026-09-10T16:39:40.326Z" }, - { url = "https://files.pythonhosted.org/packages/79/a5/25647561fd8889c373753d10dbfbb95961db89ccf1b3c3ecc9704d9728e8/oracledb-26.0.0-cp312-cp312-win32.whl", hash = "sha256:d87e7603dcb6f7fcdeed9d6a210142070dbb5b4597e819c60ad25f1ff126da6c", size = 1577691, upload-time = "2026-09-10T16:39:41.829Z" }, - { url = "https://files.pythonhosted.org/packages/1e/23/f913842954516c148a3fac9a42c8c3c47f9410efcc5ccb231a5fe202466f/oracledb-26.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:90fc434182b6d8fe1b131c97a66cc6d1d002def733c62db88c7bf1cd79182d50", size = 1969794, upload-time = "2026-09-10T16:39:43.24Z" }, - { url = "https://files.pythonhosted.org/packages/4f/27/9cf1f98e23726e54fefde3cca7a55dcfdd96c7033410250ebb750980a9bb/oracledb-26.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:893c01a3bbe1facd131aade9889fb54e074b10828b85ee93959da29179e2f238", size = 1621113, upload-time = "2026-09-10T16:39:44.86Z" }, - { url = "https://files.pythonhosted.org/packages/52/09/0c388ff9bf418abad68f492780d3cef8a33ca8a109ccdc71ff0e06344319/oracledb-26.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f69bafe46bf854c70f19e2f1d068c1b9f3d3066ff79cf90354e0adfeafac1b08", size = 4907337, upload-time = "2026-09-10T16:39:46.657Z" }, - { url = "https://files.pythonhosted.org/packages/c5/74/f689dffb9abd3f008f081795677658ce7750371f4ec8718c78663648c19f/oracledb-26.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26b9934aa9a2c431e9e3126b524a8154b78f1f3a12c258a9e7ee311b78135a78", size = 2442648, upload-time = "2026-09-10T16:39:48.349Z" }, - { url = "https://files.pythonhosted.org/packages/fe/56/1bc5d3061ba4d35a70489757d2308865490b0a9abc5116cddfdf19954030/oracledb-26.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:587acb63db873dc9784284e02b8c690730289255791bfa1e1ca70cbad50ac130", size = 2643188, upload-time = "2026-09-10T16:39:49.933Z" }, - { url = "https://files.pythonhosted.org/packages/44/09/db87288a174b1b03d36de305e96801f808e740d7986db96624ab5541c4eb/oracledb-26.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:65275fc0f3e2c8f52549b6b8099d6eb5efe4748e6d62d4bd07bc3b006044aa60", size = 2497863, upload-time = "2026-09-10T16:39:51.392Z" }, - { url = "https://files.pythonhosted.org/packages/2a/78/82fd386fcc679eae6f65b2866ea96d8b06ae0eecf17b176f847e8ece9aa5/oracledb-26.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:184869bbb43a1b074e4783b0da5b1793681e3129f4b02c305ad1ba4c6f931621", size = 2671060, upload-time = "2026-09-10T16:39:52.871Z" }, - { url = "https://files.pythonhosted.org/packages/ea/fe/f4c5825ccd7e8c221dad1d16405e02fb57cf7f965837ff74715fc865cbe6/oracledb-26.0.0-cp313-cp313-win32.whl", hash = "sha256:d82d5cea25884ab76db46173e1469588093f35e066a3e972abf1a6a91775edec", size = 1575954, upload-time = "2026-09-10T16:39:54.282Z" }, - { url = "https://files.pythonhosted.org/packages/3e/1a/7410ebf575438e2439827dc38be3851640b4406f880e69f78d92141892bc/oracledb-26.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:747ec61dc933db33db4814b24c8cf0a57c7583482ca444b124e44c910f25bd54", size = 1969227, upload-time = "2026-09-10T16:39:55.578Z" }, - { url = "https://files.pythonhosted.org/packages/5f/dc/1a5bd6233d3de25be8efe48c96077078e7cf940154096dea789e17c8af88/oracledb-26.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:85c775fd2b9f93c2a6ff48cabc2be307ba9fa951b7556e5149dc2ee62c1fbfbe", size = 1620768, upload-time = "2026-09-10T16:39:56.901Z" }, - { url = "https://files.pythonhosted.org/packages/1b/ad/72720d3a3066e0202deb4a4fb0640aa93c661ee0af486a23f4b8a5e92f4f/oracledb-26.0.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:79b612ae836b2182d4c980e1670eb1b689cbe8a68fe5002104dc410fbe16e568", size = 4960030, upload-time = "2026-09-10T16:39:58.637Z" }, - { url = "https://files.pythonhosted.org/packages/e1/03/af2b24c0c099db043bc567d268adf194aa6db1f7867b9798932da5e8981d/oracledb-26.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d24dfacbff11a4b3c15b72b446a56b8f83a4a4013ed694ab2719c79227dc81a", size = 2485508, upload-time = "2026-09-10T16:40:00.371Z" }, - { url = "https://files.pythonhosted.org/packages/25/30/3b913e044b04c72608cf00dc11c382ed8c3c11bf30114862adf0b0301e5a/oracledb-26.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4380a300f4094efe0282f9990929b9189a8e1aa52677d023dd9bdeb0163948e1", size = 2662863, upload-time = "2026-09-10T16:40:01.841Z" }, - { url = "https://files.pythonhosted.org/packages/b7/01/9d114dade656c6f4477ebdd1a1e3c3310ca675c6ae2af59b4fe466f58c33/oracledb-26.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa365bb1f9016d1a688af06dd021481d63d49be00f4e5dc91cf7370d4a2adef8", size = 2542811, upload-time = "2026-09-10T16:40:03.495Z" }, - { url = "https://files.pythonhosted.org/packages/f4/9a/b634a2afc9dd025d93f25843a91a368a7726134b73999c55acc804b47f71/oracledb-26.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a4779752a3ea31558088a306d346964ef4107f8589266ce625651066f80972e7", size = 2690239, upload-time = "2026-09-10T16:40:05.509Z" }, - { url = "https://files.pythonhosted.org/packages/1d/80/cc0eeb56900792f6135d423b69133f2186869ccb9f998ab0d4eb2a5f2a8d/oracledb-26.0.0-cp314-cp314-win32.whl", hash = "sha256:ada309ac4a5f23f98d6b74627d1508f5fa47ad3a681fe42c7769d7ade281d04d", size = 1601816, upload-time = "2026-09-10T16:40:07.213Z" }, - { url = "https://files.pythonhosted.org/packages/d4/ac/c03f4fda9bda88fb694ccac78e6894331928d8b5c1244c34e8678cf644f5/oracledb-26.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:7a92c2b002abf425078269c9d7db8544458c5dc6b59affb5a21ff075315d9468", size = 2026664, upload-time = "2026-09-10T16:40:08.684Z" }, - { url = "https://files.pythonhosted.org/packages/f6/01/399c6267811e38f0ac74c1a048ec1588431dcf80306392cf9da656f65f1c/oracledb-26.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a77c5ab67ec3b2e2953bcca2b9fa31491b94c9985e02861e306e01ddc61737d", size = 1679554, upload-time = "2026-09-10T16:40:10.14Z" }, - { url = "https://files.pythonhosted.org/packages/cc/ff/44dd56f1f6f93f5d8efbfd6cf82235357d8408d36706664f46b8ccb57302/oracledb-26.0.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:02b725959a714a911d0b03264d825ad9e631c68c9601e4d753b207e2245ce13d", size = 4958274, upload-time = "2026-09-10T16:40:11.701Z" }, - { url = "https://files.pythonhosted.org/packages/cb/b4/f1c3dae696a146b072332dd5bd8149e5d384a6089cf592dff2fe54aa6b50/oracledb-26.0.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98a38d5a82ae587f5d8dbe7c624eb59380fe5bb761c39ac7529cc6c5eafd83c8", size = 2487339, upload-time = "2026-09-10T16:40:13.422Z" }, - { url = "https://files.pythonhosted.org/packages/c1/cc/f202034fe4c8ecce3eac980b9f1c6bbd4e460bf660c2c248e22e56b84339/oracledb-26.0.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ab05b254671ae1d12a3699f3cd9b9d28a6fba19db0e82842c38ea3bf587f9a1", size = 2683681, upload-time = "2026-09-10T16:40:15.318Z" }, - { url = "https://files.pythonhosted.org/packages/3c/bb/fe2035571ac695c80db6b5fcd8b2b18b0f9b8e20c913261b8f756876fc93/oracledb-26.0.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:342a63514e59bc0860c0050bf9be56d0220ae3b0bd32a0e4d22a86dec532bc78", size = 2541907, upload-time = "2026-09-10T16:40:16.923Z" }, - { url = "https://files.pythonhosted.org/packages/51/de/6c8bfa247a9825b5511bca1e329468e3bb583f440d848b924e44eaedf594/oracledb-26.0.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:262dacf9d90450e29f971ae68a8bcb81edad94194628c3f2d1afa50ec8b42140", size = 2712297, upload-time = "2026-09-10T16:40:18.336Z" }, - { url = "https://files.pythonhosted.org/packages/b2/b0/6c8af7bd9216ec55ba67a38ce6e10fd05fd399f884792d1b20e45efb3820/oracledb-26.0.0-cp315-cp315-win32.whl", hash = "sha256:64a9d42dbea41c1f0d45e890b57404e5fc1b1dfeb3c0939128ecaddac9eaacd6", size = 1600829, upload-time = "2026-09-10T16:40:19.969Z" }, - { url = "https://files.pythonhosted.org/packages/d9/7d/a3b51eefaaedbb49d23ea437d019d7eafcd2e65249be9090c1223543cb45/oracledb-26.0.0-cp315-cp315-win_amd64.whl", hash = "sha256:90794a9ba26ee6a6ccfaf47ae88b5d1bfe3b6192e3591fa6c0851f9ee9cabc7d", size = 2025725, upload-time = "2026-09-10T16:40:21.569Z" }, - { url = "https://files.pythonhosted.org/packages/f8/fb/48be38127faacd006c28ac2773cc1dbe41f42f89a38e79c8ec217bfba595/oracledb-26.0.0-cp315-cp315-win_arm64.whl", hash = "sha256:0fae924f6d07d33dbf267f52d686b0eeb79640853b749cdce1da513241eed1da", size = 1679377, upload-time = "2026-09-10T16:40:23.067Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/8c/f3/22113415f48b6608ada31ebb46047b3b8ac361dba393d1b5ed1f768d8f6a/oracledb-26.0.1.tar.gz", hash = "sha256:786397a6b37e94ebfa6c1c6cd026755eb8fccfd39e15bcb64cb5879491882aef", size = 910463, upload-time = "2026-09-22T21:23:20.378Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/46/64c6e1f4b456740f20d64ebf34edb73d137a4ba94f24be521672d34f8100/oracledb-26.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:73d4c8331f2608029eec074ff6e4d9a78e91253f4d47b7485199fe9a6b114b8d", size = 4856055, upload-time = "2026-09-22T21:23:25.364Z" }, + { url = "https://files.pythonhosted.org/packages/d2/dc/7e59265d2b9b0c28a2e001068a901baf59f54f766f0e86c52ab13669eb76/oracledb-26.0.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2abec64bddf992c9dcdb0637463317b1ced2faff054ca599dcf44719e572c27", size = 2644115, upload-time = "2026-09-22T21:23:27.039Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6c/38fe83561d07508bc6be48d90d52307d1aee5af761bd9a41515c607d841d/oracledb-26.0.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bb6af067d7014bb22ea1799f111bd3f5ac7338179264b444f164b4ca9d9cb1c", size = 2843839, upload-time = "2026-09-22T21:23:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2f/c89aa7b5d339323e4f4e5b35555f0dc3187630741367d3f5d7249c9ec2a3/oracledb-26.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:90ab26b9968278881c564ddca2a255b08008b5b8daf92f91c0382732cc384077", size = 2696987, upload-time = "2026-09-22T21:23:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/bd/83/5a2fee01e141c1f5efef07d4c729d1304f40837e1d47a16eb401b73728ae/oracledb-26.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d11a759bffadc6f28e2f994701a2e32c7bc2db3d0d1f212a1e4f7690814f84ed", size = 2870964, upload-time = "2026-09-22T21:23:31.875Z" }, + { url = "https://files.pythonhosted.org/packages/f0/51/5d4820896f576a6b71374755661a17316a737359c3d86c9621d219ebfd8c/oracledb-26.0.1-cp310-cp310-win32.whl", hash = "sha256:75631b2db015e96074e2927d927541e2956f3f74082634118be21c3c09517cfc", size = 1613129, upload-time = "2026-09-22T21:23:33.938Z" }, + { url = "https://files.pythonhosted.org/packages/ab/7b/a2b3ed7f9475f99f464b4fcd3818c5b9d392f28f6bd2e5454e52649ca1bc/oracledb-26.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:d22fbf5c0a87826b961b5e90f55b06d769ecaa8a38667ee8708fa46b56bfb6d6", size = 2064138, upload-time = "2026-09-22T21:23:35.341Z" }, + { url = "https://files.pythonhosted.org/packages/ce/91/e63f777028a560908bc3662b1cbbaf8aba3a27dd57304655349fcbd128fe/oracledb-26.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c8635ed9f8d659421679e16d7da56a0d50078d91b40782fb9c27c85f8d6c3075", size = 4806896, upload-time = "2026-09-22T21:23:36.916Z" }, + { url = "https://files.pythonhosted.org/packages/0e/59/149845af7f23cac1138f76ff1f68d0e6e03fe65e67da8084251c98bc4fa7/oracledb-26.0.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1872361b806678c8865e375b8a46b59e2b4923191fb49af37d1dabc9438189f", size = 2633349, upload-time = "2026-09-22T21:23:38.505Z" }, + { url = "https://files.pythonhosted.org/packages/e4/58/caf7db67e8c66e0e099c30b58d257e3a60b1b33c88b331dd2802c9e356df/oracledb-26.0.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5b6949975dcd283dd4904c79c2263bbb32843116eb07f16b266b07b069ca2593", size = 2829714, upload-time = "2026-09-22T21:23:39.971Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e8/979beffc989f146ae5e37cd5b53ebe08231306afcd5ffe245368a2710a39/oracledb-26.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7d1fecfa6e7c39a3ee8aedeca7be2e48f4008d90cdcfa1b49ae9f825827b8156", size = 2685600, upload-time = "2026-09-22T21:23:41.73Z" }, + { url = "https://files.pythonhosted.org/packages/29/57/5eb64f683afa2b56ece4f8d81bd09d60db19ff487c9a92911bff2be6c795/oracledb-26.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7d407685928a87240c54d2ca8cc5e0a89fa44639b459030519f120db461004a0", size = 2856509, upload-time = "2026-09-22T21:23:43.229Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c9/49e89422cfbc04fb93aabaca1008892322ea8c177136e14e86c50131270f/oracledb-26.0.1-cp311-cp311-win32.whl", hash = "sha256:391b61d248ade2f988581a2acadce20f3b3b21886481fc1e5bc57aceab7ab376", size = 1608982, upload-time = "2026-09-22T21:23:44.594Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2d/12b184980a87ed89cf6b23816b3aa6e4ed720a5708ee7af5960f56eee9c6/oracledb-26.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:582d629ca1282653f575a74b57e3c146175a55ea43c1470ddca44c3eef04dddc", size = 2068149, upload-time = "2026-09-22T21:23:46.024Z" }, + { url = "https://files.pythonhosted.org/packages/47/f2/61869d47cf3d74099d9398c56c839a21394b35dd19d05e91a60abb6e7881/oracledb-26.0.1-cp311-cp311-win_arm64.whl", hash = "sha256:b857ecc33ed1ec8141f4e45a7420a4df4fc625dcaa35e32b01dca62400376d5d", size = 1709535, upload-time = "2026-09-22T21:23:47.737Z" }, + { url = "https://files.pythonhosted.org/packages/83/fa/349ee179b3e106f210ac070d199dd341f37e2af9f5faf2563b80d8aa6ab1/oracledb-26.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eea4d0824fa82b03c0d90aad81f10dbbacdaf351979c0130190a362a539e63ae", size = 4908265, upload-time = "2026-09-22T21:23:49.524Z" }, + { url = "https://files.pythonhosted.org/packages/60/68/2dc6a99d385835290825c0d6689097af29637db8754197040b95814bb5cd/oracledb-26.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c05707b2ba57d8210bdf062ba3419900591f3369d1532fb116af51445c2993d", size = 2448388, upload-time = "2026-09-22T21:23:50.993Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4b/ce48ed072c2328b429a6bc87a35ddd735945151145d9fe49c1e177b0da83/oracledb-26.0.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36cbf2be185412fee95be96aa6f187a73fd5181bb03186b53d5b70ac18fa354b", size = 2663296, upload-time = "2026-09-22T21:23:52.426Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c8/73166ff29389c5ce3272fcc534360ab65248f90fcdf175a99b5fa2bb8466/oracledb-26.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5ad1b6f4db3d8a43182e7734922342fac5901456b34acead92f6fc60cc59882", size = 2493289, upload-time = "2026-09-22T21:23:53.949Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c0/f6b4f82d6dc7f7d7654c0e4889caa1abbe019ec76daa1b4b4ee80859172b/oracledb-26.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aecc4cdd03cb5a5ad51f43680d16d74132ae7df65c991e7bd69bbe2ccc0fd209", size = 2694730, upload-time = "2026-09-22T21:23:55.609Z" }, + { url = "https://files.pythonhosted.org/packages/9b/da/a3ad79e18c5b27b357de005ce9d79dff1ef1646b7c55b6e6557b238f03bd/oracledb-26.0.1-cp312-cp312-win32.whl", hash = "sha256:f55d6fd9ebc3e8bf3bf4828fcd6ae152b363859628d27f92a4764111adfb1001", size = 1578393, upload-time = "2026-09-22T21:23:56.975Z" }, + { url = "https://files.pythonhosted.org/packages/06/c6/8e2ad66bda2cda6d085f3516ad8f7834b1d7d4c9da21f96834467525b551/oracledb-26.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:1c6f2f0d7290b15f1a166c0d24187e7746a6f08ea6e886e521110f1bf8d2002c", size = 1970972, upload-time = "2026-09-22T21:23:58.352Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ce/98c3a83142a562e40135abb8503b59eba7af1135c11c8077986a289f3a35/oracledb-26.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:c31b6f7440047c867a1b41cbaace7867db0750ecab4dc6454c9f501991152deb", size = 1622016, upload-time = "2026-09-22T21:23:59.996Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d7/2a911476339d9fa6eb83e6a4639b007c13af26d7261907ecc2d8722a6712/oracledb-26.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:339dd6c4f0d50dab4026a36ae89d902240b2a879a17cdf18e3feadc113957b4e", size = 4911060, upload-time = "2026-09-22T21:24:01.757Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e8/97e3b2c283ab996efe1d12b6afa86042c746b0ce4e408942bb4b7c35e39a/oracledb-26.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5907bb5c9df123e417ddf358f540554dfc6766064c2feb84121c099bc4e039a3", size = 2445641, upload-time = "2026-09-22T21:24:03.587Z" }, + { url = "https://files.pythonhosted.org/packages/03/f7/bbf75b91248ea489e8393ae84e9985b37c895781c658b6d9e5d83d02c1c1/oracledb-26.0.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:16d08328bf9b02e980ad4d5f3e0e99596f9a42aaf6bd1e244ea85be753f2c292", size = 2646590, upload-time = "2026-09-22T21:24:05.22Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/cbdacb302a2ca047e36e0f804c493391844aad83822d428184fa1d4203d5/oracledb-26.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50e42111d5c620f60478e4571a5847c335286e8650e63a8789f8bd51035f90da", size = 2499129, upload-time = "2026-09-22T21:24:06.801Z" }, + { url = "https://files.pythonhosted.org/packages/9a/1e/ce86b581a4f1c8d5844019f84eacc9f4084c882ba676a90c94d7eaeb749e/oracledb-26.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0453f8f1a6748c982dcdac8af09bcd81971979c7ec9d1d151e15595f58d9164b", size = 2675979, upload-time = "2026-09-22T21:24:08.153Z" }, + { url = "https://files.pythonhosted.org/packages/82/7c/49e6af631b84db68d801b6d03ff58c4fa7b767ffa8a8402de643584e9d63/oracledb-26.0.1-cp313-cp313-win32.whl", hash = "sha256:93e6a7cda6ad7b14e2ce8689c9694872b3d69d57015aab070028c9a93faf3bf1", size = 1576622, upload-time = "2026-09-22T21:24:09.492Z" }, + { url = "https://files.pythonhosted.org/packages/59/10/ba7a5ce82fda9bbfcf222930b9a6f9a967c2b3d49f787ba7f4f99b598c6f/oracledb-26.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:6cf0c4d8662072917e2f98598cb97a18e157785bd0f294fbcf770d822ca239a9", size = 1970210, upload-time = "2026-09-22T21:24:10.806Z" }, + { url = "https://files.pythonhosted.org/packages/24/dc/96ce57dad3b1bb3d3c30be936d0f6daea0a02649d8936b878921004b15e1/oracledb-26.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:22bb1e47a01a5259eead9202be8f631682b69be08ee29d8e532ed9b9364bd7bd", size = 1621513, upload-time = "2026-09-22T21:24:12.246Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/7cf147ea1daf0c7c86fcd77acce1a09b0d4ab75539a4677a2034f112c860/oracledb-26.0.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c2201184b46bbec476a7ac29a72f14ee4ed75d692ade3b6c5d672fdbca71e6b2", size = 4961776, upload-time = "2026-09-22T21:24:13.967Z" }, + { url = "https://files.pythonhosted.org/packages/2b/7e/1ada40fce6ec550718e6eb1b8be9bfcc6e9697f19b6244ed6e0b89aa8da8/oracledb-26.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8bddb6bd7156df750045b419ffc400cfe429276f1a17d83913f10adefd0baa9a", size = 2486660, upload-time = "2026-09-22T21:24:16.397Z" }, + { url = "https://files.pythonhosted.org/packages/65/2a/a4cf6f8081b1ab3498c82e3917b2174843bf22e2fa4c41e98ad1e0b66d47/oracledb-26.0.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b33b22800a9b02ee0ead00e6f3073b810f7dc51c1e82fc9fc5475c09812420df", size = 2664849, upload-time = "2026-09-22T21:24:18.16Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f2/50a32fa2d8c6cbb0e5fb69c262b01e7412ad08e430192404af9d2b9f4bea/oracledb-26.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:124f0072e6c7df277c561ec3a4c42cb2c02c30e9ce15e75a460b4abcfee41046", size = 2544330, upload-time = "2026-09-22T21:24:19.627Z" }, + { url = "https://files.pythonhosted.org/packages/b0/90/9b6dd9bbc1899ca3a982e9012f25be2a70e4795eacc6f8582c340c64fe1c/oracledb-26.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4c5094d5ba60b0f67ce7c74eb0fbc738d827103c0ba2db5f8483db405ab4e9c4", size = 2693190, upload-time = "2026-09-22T21:24:21.228Z" }, + { url = "https://files.pythonhosted.org/packages/92/42/0df9265f2f562283c88590a6bcd442084cef80c832ff598bdbab994a3cb8/oracledb-26.0.1-cp314-cp314-win32.whl", hash = "sha256:03424ccfbd25c402133c2a23c5c1a630e57effda7b962085d91c41d765af1b25", size = 1602133, upload-time = "2026-09-22T21:24:22.733Z" }, + { url = "https://files.pythonhosted.org/packages/db/d4/1b90c5e252a53dbc71833217746f2673e9e2ce0e06115bac1dc42ae347d0/oracledb-26.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:ee8f4736b72f38ade26f9770f7b40e8662bfa825ef48cefcc55cef0c30525d4b", size = 2027835, upload-time = "2026-09-22T21:24:24.105Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4e/9febea61cbf476c2ae5d290fb3f823f1e46668e3ac15b8af5307531e33ad/oracledb-26.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:13b0dfbbc503c6107d6c08b1658df260c2c077bc5574d9545159df0a0301fe7d", size = 1680743, upload-time = "2026-09-22T21:24:25.475Z" }, + { url = "https://files.pythonhosted.org/packages/77/bb/71f7861bfbf586757873259a858f370030a137b4172865992c3cc9a462b1/oracledb-26.0.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9e77a6b3b01a3a2f4b786939f59d862fbe73fbc4c68cdf87aa45a5398725a16c", size = 4960764, upload-time = "2026-09-22T21:24:27.244Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/86aa68f08e5c1d25b864ac5777c559bf0bc8911e5d8262b70bec5e05cc21/oracledb-26.0.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d722f185f9c05ecccb2702a58a1e5ecfd309ae92cad0c38373e0969e83bf2bd", size = 2488466, upload-time = "2026-09-22T21:24:29.177Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f4/af94230a3c2c6679323f3578a0a5281cfd87f72994f66e6044b5ff6c95e9/oracledb-26.0.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfcf6382bc48e21e448ca2b71644cc75626056a638bca50181acdc8adc69dd3c", size = 2685915, upload-time = "2026-09-22T21:24:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/f5/a7/aa99f8e60961e80795ca926ead66c66d98c3aee78d970320eacdfa843614/oracledb-26.0.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d42a2e0caea9bcf7d260d430b225d72cff6dffa0d6729be7a8823db2678e0b74", size = 2544920, upload-time = "2026-09-22T21:24:32.667Z" }, + { url = "https://files.pythonhosted.org/packages/e2/65/b636a18a578cffd5819df08ae80deeb931f1215d2d2b18112a37e9d2bbea/oracledb-26.0.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2a073cc8bd74a7ce32d12f4bc898dd143971981b52ecd2f72c37b4474bff76cb", size = 2713492, upload-time = "2026-09-22T21:24:34.22Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4a/e758838b14540a94599487d0d15ce4390edba5a257e53f2cea66f94bd138/oracledb-26.0.1-cp315-cp315-win32.whl", hash = "sha256:f4188bf8eedfc05ef266835e98ce304fda92d6bc87dd776d5f9a8b510d8b54ba", size = 1601872, upload-time = "2026-09-22T21:24:35.591Z" }, + { url = "https://files.pythonhosted.org/packages/62/b1/51b2a51abd89ba43dc0f9ee14d456a9b002d0b9c31840c7de55387e555eb/oracledb-26.0.1-cp315-cp315-win_amd64.whl", hash = "sha256:9d508d2df7bb9a4802247ec95d06064e2babf8ae9e1f91d89c349bcd30df44ed", size = 2026829, upload-time = "2026-09-22T21:24:37.15Z" }, + { url = "https://files.pythonhosted.org/packages/62/6b/b099ff2c51447cbf0d2f1c33ea7de1abf96d9bf4c3d93b2a5dff48728965/oracledb-26.0.1-cp315-cp315-win_arm64.whl", hash = "sha256:7c9541d7cf2d324e9516c955c033235bbf25b7d96ce41a856b3bff2eab3494ed", size = 1680535, upload-time = "2026-09-22T21:24:38.551Z" }, ] [[package]] @@ -4369,18 +4406,18 @@ wheels = [ [[package]] name = "prek" -version = "0.5.3" +version = "0.5.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/93/d1e5afc996b9fde04d71c37fd28bdd404e54daf1da6c76b883c5cdbdb411/prek-0.5.3.tar.gz", hash = "sha256:06d88bed9a5b2886cd3796957e4cecf05fa9b06724b625df31dbf0a48ff2330c", size = 551929, upload-time = "2026-09-13T08:38:37.823Z" } +sdist = { url = "https://files.pythonhosted.org/packages/30/f1/8f3143530bf43fa82f8873f4e536f318b6143dd5c09171aba0d465e21f04/prek-0.5.4.tar.gz", hash = "sha256:7f5bc061880141e362ef422e52188d0090794c0622e442e3157686d1e342780a", size = 565182, upload-time = "2026-09-28T04:54:43.453Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/5d/8731dc49cb5424d37424db3ea2f3ff6a0b20caa971db72796e822ce48b24/prek-0.5.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2d7240c5e6a996ef5bebe5d57d43049803e41c225b343681c020bf0c87dab2b2", size = 6003518, upload-time = "2026-09-13T08:38:20.584Z" }, - { url = "https://files.pythonhosted.org/packages/51/45/ca413aefb3ea2411bbf0adb6eebfac1e9bfc5ac7679dbe26336fc651c347/prek-0.5.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1dd6df8235ca361dbbeec89c305ccb41a138088a2fc548484b4cb89f66a80d20", size = 5538754, upload-time = "2026-09-13T08:38:23.004Z" }, - { url = "https://files.pythonhosted.org/packages/dc/92/874d58ba40d2fe7cfec2789ae8bf415f3751666ad5c59928821fe53c3c01/prek-0.5.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:61ff791bb850ba52cc0d86576498df7e1bd1e9ed20df63dae113a4a6aa495e19", size = 5841568, upload-time = "2026-09-13T08:38:25.008Z" }, - { url = "https://files.pythonhosted.org/packages/8f/40/167037b8eef75f6f036c60ff7e93f42defebb7c3733677e2f30e5a479257/prek-0.5.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a863379a2668c4ef079d820ffd5b14073145380dfbe0e70fef498dc73c568f86", size = 6226960, upload-time = "2026-09-13T08:38:26.788Z" }, - { url = "https://files.pythonhosted.org/packages/42/9a/bfcd3c1fe1fdefee8dc144a92c4b0b93b59f31c7df60ee1720c49223a90a/prek-0.5.3-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:086d1b77557f0a089568dc5b93c22ccd793351524f3eeb29d36781421a350e72", size = 5854832, upload-time = "2026-09-13T08:38:28.954Z" }, - { url = "https://files.pythonhosted.org/packages/15/41/1450b995b18bbf0d8ab65016860198052baad5c79c231151fa1dcca4a2de/prek-0.5.3-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:4f09b947255124e9591a7f7ef16eb5340a36ba94a275d788d7ea804fab35f0e2", size = 6343055, upload-time = "2026-09-13T08:38:30.96Z" }, - { url = "https://files.pythonhosted.org/packages/9c/e8/8f45950b8a8ddcf22efc7183e9d6b1d7e30373229ff7d9e56f918a55922c/prek-0.5.3-py3-none-win_amd64.whl", hash = "sha256:5b74a9742c3e8f8688d5dad1439688f112e2e1746fcb91016015dae38df3eb42", size = 5744293, upload-time = "2026-09-13T08:38:33.104Z" }, - { url = "https://files.pythonhosted.org/packages/24/cd/ce637f4b6cc6c583516dc16b539408dab593672f7c6fe7723a37caf378ff/prek-0.5.3-py3-none-win_arm64.whl", hash = "sha256:20d92aef53a5e237f4659ecc6669c0fbc98a1b83f0e2476530400a72d42ca390", size = 5512958, upload-time = "2026-09-13T08:38:36.123Z" }, + { url = "https://files.pythonhosted.org/packages/78/a7/f3a4a252c0846c5846dd86118ee0c1fddb7381dc35e3d44c2a5f3cea1757/prek-0.5.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:550ecaae0601f9405526c866a57cc6882ed1e9d8696ac1117b8703bb37d087eb", size = 6011113, upload-time = "2026-09-28T04:54:28.107Z" }, + { url = "https://files.pythonhosted.org/packages/1b/e3/26638cf090b620e63e60a639b8e7d7eb38b9fbea74a0827a182fc922cf8e/prek-0.5.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a7ef67cc23e23c6e9c6ca0b27a7b12c19f16774d36b1c88ed035f2639995718d", size = 5534338, upload-time = "2026-09-28T04:54:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/a0/08/64b668eb088204650b96183cea739b56e6b179130c4398a8a83cbe9b125a/prek-0.5.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:59f2206850f643bafe46b28b4431525f8dcc5b7a763f271179bff10b8157870d", size = 5840937, upload-time = "2026-09-28T04:54:32.226Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ee/a0f847194eb6a2b67921b4c94c4fbf3f33a785affb190c6a2097344dbdd6/prek-0.5.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d5b0cab1219a7bc9cc90785c7436f563765440616543ede7dfa3ba5c332cede", size = 6234354, upload-time = "2026-09-28T04:54:34.176Z" }, + { url = "https://files.pythonhosted.org/packages/32/9a/14186d90ed29b46650452068d6bd1ac233ca0ea41a3b35081c37f6b77b1c/prek-0.5.4-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:8fc22db8132dab9aed4e152bf6a58a7f791f4c91c74305d65d96c48b8ec0bc62", size = 5845649, upload-time = "2026-09-28T04:54:36.252Z" }, + { url = "https://files.pythonhosted.org/packages/e0/88/ff5bbbada4098f59740ab767f5c2f82d4110b90ea7db309a9bcf21f3dcb9/prek-0.5.4-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:19d4f3986dcd1dfa15f9417e32a75fb2ba084156dbd7ace161f0764281b8064b", size = 6351516, upload-time = "2026-09-28T04:54:38.539Z" }, + { url = "https://files.pythonhosted.org/packages/c8/1a/0cc410232ef42750b3bf34babd6def4a3943ec98259d9fecda3b26fcd63f/prek-0.5.4-py3-none-win_amd64.whl", hash = "sha256:3100233ba802eff6a4b09bc045c8f50d599bb9d810fa234823bc1642938b224f", size = 5755956, upload-time = "2026-09-28T04:54:40.227Z" }, + { url = "https://files.pythonhosted.org/packages/98/78/3fd53cc54468db37462885d3139eae40bb469d51beae4575c986b190ab6e/prek-0.5.4-py3-none-win_arm64.whl", hash = "sha256:9fc4ad2d647f70a8a3a9900849e1dd89472cf5a71d830f083b62cf87a0406e27", size = 5502478, upload-time = "2026-09-28T04:54:41.973Z" }, ] [[package]] @@ -4788,14 +4825,14 @@ wheels = [ [[package]] name = "psycopg-pool" -version = "3.3.2" +version = "3.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f9/7f/1e54512e817442017efa6f7b4c663bc769abd3ff2b62adb910190fc4731d/psycopg_pool-3.3.2.tar.gz", hash = "sha256:0c70903c7f50cd7173ef0f6364bb7c9271fa3810f5746e058449a77afe579f49", size = 31912, upload-time = "2026-09-18T13:22:59.127Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/5e/c0664b968b102ff68b811d999c728546c48d5c1eec03e3bbaf88c0cb4472/psycopg_pool-3.3.3.tar.gz", hash = "sha256:df87b5d9d0ad7db37f6cdad4fa8ce113d250f5997f6db38e9a99192fb67f9e1d", size = 32006, upload-time = "2026-09-22T15:53:24.947Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/39/7dd00f3570b8847b20da54c98c9388b166967625723bc36dd01e1ab1d64f/psycopg_pool-3.3.2-py3-none-any.whl", hash = "sha256:96971f245eee54f1632d8eaae0de5bb6e9c624c1effdd42d8d6843e534f56e07", size = 40224, upload-time = "2026-09-18T13:22:53.384Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b4/452c6607a0f479465cd8a9b0d9956919fcb150050c1f83f9f11e6b8ee8dc/psycopg_pool-3.3.3-py3-none-any.whl", hash = "sha256:9b9cd6a4fcec47a410f7e82d408540e7f77b478509e91b44c1a5457a13e5ff37", size = 40304, upload-time = "2026-09-22T15:53:23.712Z" }, ] [[package]] @@ -5071,14 +5108,14 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.14.0" +version = "2.15.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" }, + { url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" }, ] [package.optional-dependencies] @@ -5088,66 +5125,66 @@ crypto = [ [[package]] name = "pymssql" -version = "2.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/fa/00f1d56d38ce3d070602ba3687079b961b4fcbbf5085f83f92dcf5d924ba/pymssql-2.4.1.tar.gz", hash = "sha256:16720f33d7dcfc7ef906cad8d182f05ba7e5d2c35c6be6a5bc17ce5edfc428b2", size = 208142, upload-time = "2026-09-07T20:52:19.316Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/7a/1777dedad1d31a57dea21db4b7b57014badd6d6269ed6e0ebddc39b7d78f/pymssql-2.4.1-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:cf5fc51c6a4b6c1fcd2c1daad03b839345a6d420ddbb6b163fdfe5eab4b9782f", size = 3209539, upload-time = "2026-09-07T20:50:39.349Z" }, - { url = "https://files.pythonhosted.org/packages/e2/9e/0446fe39324db294b3b6922b7733850a6b16158b0f82d153b6846ec4f931/pymssql-2.4.1-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:bd824fd07c418e604aeb79a1ba18171b940cc1bdbf535fc90622e370cd77a517", size = 3014275, upload-time = "2026-09-07T20:50:41.228Z" }, - { url = "https://files.pythonhosted.org/packages/ca/9b/32889104988dfea633fd156c9fa9306f80ff317060e03991b2d82dbdac1d/pymssql-2.4.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7ee1b4f8b8cba79df58e0a8d09c3750a4f4f59c17c67c47f414199f915912678", size = 3064266, upload-time = "2026-09-07T20:50:42.603Z" }, - { url = "https://files.pythonhosted.org/packages/66/88/5142e8bab5a604032e1c9ce7832474a9dfb01f8b91375d0847bb81dece8e/pymssql-2.4.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8399d84c2c9ac245592b513a2b18c6de3d6ebc4196a87ba3e6d3a4d4fdf43f3d", size = 3195463, upload-time = "2026-09-07T20:50:44.253Z" }, - { url = "https://files.pythonhosted.org/packages/ce/04/e80a214766bbd36ce1d63c86e9d566c96805aa29577dd2176ad29baa8720/pymssql-2.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1dfa18f2c46d72b675430d87e9fb8a12f2746aa418ff78e635e4f7c247f3745e", size = 3719173, upload-time = "2026-09-07T20:50:45.698Z" }, - { url = "https://files.pythonhosted.org/packages/2f/19/810da50a13413a42470f6c37ea3b14f2099eab915132b9873e40683811c4/pymssql-2.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d1bcc46063e3eadd6d7d359c1d08fe9bcd46c6c674c83495f150582f09b018b3", size = 3466086, upload-time = "2026-09-07T20:50:47.477Z" }, - { url = "https://files.pythonhosted.org/packages/02/e2/e0a486bdaaa1b5fd47482cdf110711302853fb611d5d82509c47168b4117/pymssql-2.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:613cb873cbcad9acb232ff448156ce878ab2cc9cbdeb08d91911c28868a6c786", size = 2350433, upload-time = "2026-09-07T20:50:49.211Z" }, - { url = "https://files.pythonhosted.org/packages/96/f2/c5eb7f7032580fde552f6900517575fc79d7460a4d60ee3f80a24c87d172/pymssql-2.4.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:47d383d45ae13480211345b476fce9d8ad02264c1d3aea4781e9c8f06fa07a95", size = 3208157, upload-time = "2026-09-07T20:50:50.656Z" }, - { url = "https://files.pythonhosted.org/packages/af/0f/218b62860854f459af263a7b1d0c562dfed0f4f587ce56afd3ee922dca66/pymssql-2.4.1-cp311-cp311-macosx_15_0_x86_64.whl", hash = "sha256:e15a07b448bfa5626b166555be96b6bc56c3dfb0982d2b355eacfeb03574a50e", size = 3012474, upload-time = "2026-09-07T20:50:52.217Z" }, - { url = "https://files.pythonhosted.org/packages/36/ae/bc56f0cde5554c3c70de7ea8ca666d4b20103352d7dab138b948c91d5032/pymssql-2.4.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26bf66f8b7c06d41523b5e58972a2c404021fd20e50e2dce36dec0b5923b1146", size = 3054819, upload-time = "2026-09-07T20:50:53.598Z" }, - { url = "https://files.pythonhosted.org/packages/ee/57/168d56ef8a6509f0a4e8d279acbe7a454b2e250f1e796fb54faa81ca4850/pymssql-2.4.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2dd48984a8bd678f57126ec28f1393b35295c7ac60c44f8e0d640d4839c6fa3", size = 3183990, upload-time = "2026-09-07T20:50:55.27Z" }, - { url = "https://files.pythonhosted.org/packages/19/2d/cd69bb7177c60b725a9db16382b62f24b324b76d8e7f76b4e3bceeddac0d/pymssql-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a46d55470a0e142b5dd1f15f9de519d35a80c89db6131dccdfab694209e5065b", size = 3710502, upload-time = "2026-09-07T20:50:56.881Z" }, - { url = "https://files.pythonhosted.org/packages/f2/eb/6fb7fcb35c9d95753d75b0ba1cdfaa3325989b0be65520e2a7fd88722857/pymssql-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2affd3b80a28b12471ae09cf5e2386b30019991a11cd3ff32e1e46066a183ac0", size = 3452543, upload-time = "2026-09-07T20:50:58.299Z" }, - { url = "https://files.pythonhosted.org/packages/79/75/dadff01e34e1c04bec312c07408d3f65965b589ea0209b0ebc20ba0f9dc0/pymssql-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:8dc745dd2f37aae6f7353e3a79bc8811d4098dc6ab4f3fcc754faea0db677d7c", size = 2350268, upload-time = "2026-09-07T20:50:59.593Z" }, - { url = "https://files.pythonhosted.org/packages/33/3f/ee6f3d51cefeb60c1d32351c80743ad711068f5ade76e7d04e95927ce6ff/pymssql-2.4.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:097df7b0fd55274f2b2fbb15fdee40e3ef5e72a56dc87182aadaf1fa763c7864", size = 3190268, upload-time = "2026-09-07T20:51:00.952Z" }, - { url = "https://files.pythonhosted.org/packages/11/3e/c80c91e1d617e1a0ff5daebeb81cb696a54a6792c56b4ea6a524513de615/pymssql-2.4.1-cp312-cp312-macosx_15_0_x86_64.whl", hash = "sha256:3b88d66baad723d3f78f646c909ff3c02274b96bb543899a7139f70ec8221b42", size = 2993734, upload-time = "2026-09-07T20:51:02.42Z" }, - { url = "https://files.pythonhosted.org/packages/a0/76/438f2dc6649900e38d11714d986a10cdbf6a666a67a09fa04cab9258d8dd/pymssql-2.4.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e30b1a1e424f454ddeaf5293eb7758bbafecd4bd336ef15588866d892342cd12", size = 3085313, upload-time = "2026-09-07T20:51:03.882Z" }, - { url = "https://files.pythonhosted.org/packages/1d/ed/d47c1f94eaf5f10d211b90b26d932fab5ae1838619aa2ff6d2c27c35f672/pymssql-2.4.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9732d9689cfdd60d454c5a2236e6ade41dcb86b9f87ee0549cbacae51ff03e43", size = 3212608, upload-time = "2026-09-07T20:51:05.304Z" }, - { url = "https://files.pythonhosted.org/packages/de/b5/f0356904307284debfaed363bd06d6d38ee40352645cd6a19dc073db93d7/pymssql-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:489ea50678fc8f6c1f2950e978b6893333a6bb5450b6aeb1783004f7869cab04", size = 3739568, upload-time = "2026-09-07T20:51:06.89Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5b/b258c5bf6b0e9b241b417a03927b967f740d0b9e06f8c69fedc96e22bea5/pymssql-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6811ac269462d001ffabf9162f939c9358ec10a70a645b7fd6b849009b947ebd", size = 3480815, upload-time = "2026-09-07T20:51:08.611Z" }, - { url = "https://files.pythonhosted.org/packages/ad/c3/3dde3f750a37d071a1ea483c63fdb114036e79278cc974c6b626b5af91b7/pymssql-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:2f5a1e28e4be6e50fea670ae35bf63f53858abb2edf1dbc17f6a2fc037e0db94", size = 2336876, upload-time = "2026-09-07T20:51:10.246Z" }, - { url = "https://files.pythonhosted.org/packages/1f/0d/f0aa8d4aaf9375d05e98ad94c281574c07809400b84cdd2a166b764dcc17/pymssql-2.4.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:27f7d905a25f46b43a7ad136c7989763c4bb34493f56c0d978577ca5aaa65309", size = 3190800, upload-time = "2026-09-07T20:51:11.719Z" }, - { url = "https://files.pythonhosted.org/packages/56/c1/cbbdad100e9650adbc964f4c879e0ecf8b54eb375d13c0fbe4b29fd205a1/pymssql-2.4.1-cp313-cp313-macosx_15_0_x86_64.whl", hash = "sha256:d722aa98efbb8e5fa0d813b1f83862fc7a9fa46adc340a586bf1bcf6a801537b", size = 2999064, upload-time = "2026-09-07T20:51:13.362Z" }, - { url = "https://files.pythonhosted.org/packages/d0/7a/7db5d585f13b1f94552c9520de6caad6001657da6fdc275d2cd10e2edb0a/pymssql-2.4.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f14e19f59f216c169e5992d52350ba501a80858a14120c333bd4aeecbb4d3cf", size = 3084817, upload-time = "2026-09-07T20:51:15.087Z" }, - { url = "https://files.pythonhosted.org/packages/4f/f7/073d33046adcb94e402224bc9af9ccd9cb10d07f9405facb895254f7c3fc/pymssql-2.4.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eaf899774605e79127cc9fcbcf9460192d7413562a28bfea6bf2c083b20bedd3", size = 3212131, upload-time = "2026-09-07T20:51:16.639Z" }, - { url = "https://files.pythonhosted.org/packages/e1/1a/d5e2a350550be9993c36f8d0ffcd62f56222b268a379dd106c5731c0d94a/pymssql-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:870b3891643dbed85025e9ea2cd56c31035dc8facefb564acc56b8742f7a8895", size = 3739060, upload-time = "2026-09-07T20:51:18.39Z" }, - { url = "https://files.pythonhosted.org/packages/e1/a8/469d26da2071ecacb1a2cb86bc42a5032605ba7da4d95ab343db56e2c32b/pymssql-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1b8b4779840c08d26dafe892b38392f632b79ce948191e2f1b03a56ebd937b26", size = 3480354, upload-time = "2026-09-07T20:51:20.15Z" }, - { url = "https://files.pythonhosted.org/packages/19/2d/2d3331c3283732ee10f053b8f056c05995972f34638e14972923a0da9400/pymssql-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:78c643a7f5b0faf2e031b47a2b2086a66f814e1c7ef2505116fff41b14420df8", size = 2336076, upload-time = "2026-09-07T20:51:21.943Z" }, - { url = "https://files.pythonhosted.org/packages/74/73/56f6f1596233e3839a373de505c361d8e35d04eeb0762d4d1e2efa5fb92c/pymssql-2.4.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:c3ce52139a5201ee3158c08d8801d1c2ba9d139e7049faa0aa1a5b8d7c1f7c51", size = 3194212, upload-time = "2026-09-07T20:51:23.474Z" }, - { url = "https://files.pythonhosted.org/packages/2a/b1/e96b78c225594490386ce8b0c4bc1b4b918e609ee8622419f5ebe8c15800/pymssql-2.4.1-cp314-cp314-macosx_15_0_x86_64.whl", hash = "sha256:05b809ebee27311f5a8118f0458442af34c129145603695c716a3e27c34e2d74", size = 3000120, upload-time = "2026-09-07T20:51:25.143Z" }, - { url = "https://files.pythonhosted.org/packages/f6/5b/58d4d146a024498287a87aeea8f40dcc1ec47d19f43e1001235508e3ab1c/pymssql-2.4.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e6ec84412f3593adec8cdb65883910abeee84b2d4f7e1d75bcf2b1c0b1347660", size = 3083072, upload-time = "2026-09-07T20:51:27.06Z" }, - { url = "https://files.pythonhosted.org/packages/1a/3a/11c5136ef781c2d9fea1bd0d1f3d8e127e842756869a060f7e7d1b104d44/pymssql-2.4.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46c82b8764c7062d3b6ccb75c15a7b0dc556b5d6fee3327450abadc5da0a2ec7", size = 3210524, upload-time = "2026-09-07T20:51:28.597Z" }, - { url = "https://files.pythonhosted.org/packages/52/2a/a73cd654737f7df79a95d3e8ba739acc84000e61f55ac74b8892fe06ff0c/pymssql-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d6e9df554c702afc5c3069b340cdae4ca764d30d6da001d14cb6b077a5a001ad", size = 3737852, upload-time = "2026-09-07T20:51:30.107Z" }, - { url = "https://files.pythonhosted.org/packages/66/f8/1943cb6cbb44ee87db11fbd8800a45c1f5fed89d734e837402ce8816615c/pymssql-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:709da29d82bb0c7237dc68ec16f0fdb273f97120e1795d80f8ffe6c28885b39a", size = 3479021, upload-time = "2026-09-07T20:51:31.776Z" }, - { url = "https://files.pythonhosted.org/packages/15/15/4a075329a1fa3a3159d98d550ed406d3efd4f530caa8b0fd3a284394850f/pymssql-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:0d4ce2c51816a5e3690d749012b7cdfe524f2f7b61d3041c8024e788d205754d", size = 2399181, upload-time = "2026-09-07T20:51:33.744Z" }, - { url = "https://files.pythonhosted.org/packages/1a/f8/e4dfe0d10a1d14eacbfe407fc1d340b1275f31f57fab63be3d983cd6dcfc/pymssql-2.4.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:d0f4b076bc9cd758e0837c5db13ba58ff8259b487c1410adf7ca297b03e3f194", size = 3221687, upload-time = "2026-09-07T20:51:35.241Z" }, - { url = "https://files.pythonhosted.org/packages/dc/58/8d980aaa627259a89ff9fb336cb8b02cce1fe5672cc4a31cfc1666e94590/pymssql-2.4.1-cp314-cp314t-macosx_15_0_x86_64.whl", hash = "sha256:5d92156bda4d27578c87e73c9f9a1f95c6a3de83396bec933ed1c994279e8d3d", size = 3014333, upload-time = "2026-09-07T20:51:36.685Z" }, - { url = "https://files.pythonhosted.org/packages/1d/5c/ff6d55e87f0578273592e75d0476280b4846baf2531fee075910c19e89ba/pymssql-2.4.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf15d3c41f2fba30f4613b131f9dfd9a91e2de45c5e667278c6363107b6a0465", size = 3076346, upload-time = "2026-09-07T20:51:38.153Z" }, - { url = "https://files.pythonhosted.org/packages/f8/3f/5e135d360180c903d316d850f76a9ee63d88d67790353a038223b892e27d/pymssql-2.4.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a879c978e9f2f5e7fa7bcefca2305c0af7047626982f739af240cfd69d3855b3", size = 3205190, upload-time = "2026-09-07T20:51:39.666Z" }, - { url = "https://files.pythonhosted.org/packages/25/7e/da3cc35c93684a2525996db1e2e6211104a37b1c5c1666333e884b301844/pymssql-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a0c9b201a8e70453411c26b851906bdbe18652098d0b4c0f2008c25c41a056e8", size = 3732362, upload-time = "2026-09-07T20:51:41.753Z" }, - { url = "https://files.pythonhosted.org/packages/3a/2e/79afe886f903e737a09cecd4242b47879b3fbf2a55afc1775e58a3dc18a9/pymssql-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:68771495155af75c7c4ea71f1640bdd23daf58b642d5fa9ec39a977dfa407ec6", size = 3476637, upload-time = "2026-09-07T20:51:43.319Z" }, - { url = "https://files.pythonhosted.org/packages/fd/be/a45930aebb1228e5d39490f3b89865a261e1e9f52c3e0cfb595249189f11/pymssql-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e32a066c248a905723f142c3b34231f1c9adbe5d81d8b6157a868e2717937b8", size = 2418568, upload-time = "2026-09-07T20:51:45.213Z" }, - { url = "https://files.pythonhosted.org/packages/d5/34/132a9c1a17f96baa8b99d7123c6b6b93dced772ec5ba3bab8287e65f47f9/pymssql-2.4.1-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:37f7d52c38f6fd7c2752eb2a3751bd21d7a1cba545c227cf1b339a001b8fac8b", size = 3193101, upload-time = "2026-09-07T20:51:47.198Z" }, - { url = "https://files.pythonhosted.org/packages/8d/db/235cfa3fa46c8e052f571ff2f5530389226bc8931f74667c2d7082b4c18f/pymssql-2.4.1-cp315-cp315-macosx_15_0_x86_64.whl", hash = "sha256:490aa41a6b8d92f785510d92a0bbbefb0d31fecc4ede56e675b4ba8067f70b50", size = 2998924, upload-time = "2026-09-07T20:51:48.706Z" }, - { url = "https://files.pythonhosted.org/packages/ef/d1/c6eac814162ac68fe69d5a76e1b169ac8be72fcda27d9f23be14423e9115/pymssql-2.4.1-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:387a9775e03225a1b1cf03d62f6c345556e94149512b7fdf6323a7b1fa187764", size = 3083321, upload-time = "2026-09-07T20:51:50.208Z" }, - { url = "https://files.pythonhosted.org/packages/b1/45/31cf257114e2b5ffa3209c80e8a12ca13937f6b94bfa957a17de4646f932/pymssql-2.4.1-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:77a7d73d661b7ec7e97effa51ee84285ab653bc99617c6b1968d028245e3fba7", size = 3210661, upload-time = "2026-09-07T20:51:51.631Z" }, - { url = "https://files.pythonhosted.org/packages/3d/2a/c02799c3752cc0291c8872878635c127ebd92b46d7765301e02078239708/pymssql-2.4.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:409acdff480ac93d87078febe511908311981944fcdb5a0ba3d708db5c0f0822", size = 3738153, upload-time = "2026-09-07T20:51:53.216Z" }, - { url = "https://files.pythonhosted.org/packages/19/ec/045feaa866015ea36ffd87074070c8672fbf66dc74597f132d8c8e8cfed6/pymssql-2.4.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:8098763793ed83e804bbd2fc45aa0ce635813a862faee886461140f193d2ec26", size = 3479155, upload-time = "2026-09-07T20:51:54.744Z" }, - { url = "https://files.pythonhosted.org/packages/7f/62/46b362c04f74d4440169bb2996cd207d0739dcc0487786f699cdd5219544/pymssql-2.4.1-cp315-cp315-win_amd64.whl", hash = "sha256:79e43dfe669731bcf9824676f389f43f4efe5930e5f715889f4a00f275ba6ef9", size = 2398614, upload-time = "2026-09-07T20:51:56.226Z" }, - { url = "https://files.pythonhosted.org/packages/b7/f6/b38302dcc7ea7f0591b0606a180a73ef41782b8f9b3e3f77a6f24eb71543/pymssql-2.4.1-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:a2794eb20fe69f646f1811318909ef3ed9b727c2d43316757be193463e8d6e86", size = 3221033, upload-time = "2026-09-07T20:51:57.824Z" }, - { url = "https://files.pythonhosted.org/packages/46/7b/1656aa6832a9652f49502bdc6f2ef9bc24a9fb823b343d9e01f07d05c0bf/pymssql-2.4.1-cp315-cp315t-macosx_15_0_x86_64.whl", hash = "sha256:5968c34d324aee93dbe2e649f63f543cd40bd8de6af2708ea5ebd3eb8851b5c3", size = 3013409, upload-time = "2026-09-07T20:51:59.279Z" }, - { url = "https://files.pythonhosted.org/packages/7b/f4/7637f1d7d63aa5b9952deec80b2db7e926323c8f754993571a06a9416c32/pymssql-2.4.1-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:87a0e293608a20b98c24d3b9cd744d32d92593e4323089ac0e97cad370bc234f", size = 3075820, upload-time = "2026-09-07T20:52:00.814Z" }, - { url = "https://files.pythonhosted.org/packages/2d/41/a54eb4154bb580fdbaf133f720197df1f4dbc999a510ea7080845b42c5ea/pymssql-2.4.1-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70083f1b3e7b83892735594f76dbb1119abffe7dc0a7eb878338e8cb08b2b847", size = 3204474, upload-time = "2026-09-07T20:52:02.479Z" }, - { url = "https://files.pythonhosted.org/packages/76/c1/f7c39f9e700fbed2ddb34ee70ca2dfc8af8ab7aa8f80665c58df690a38c2/pymssql-2.4.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:e6fb52f61248cedab757880099f441cb2e97f34e527089f59df2c17a76f92e33", size = 3731724, upload-time = "2026-09-07T20:52:04.001Z" }, - { url = "https://files.pythonhosted.org/packages/c2/79/0554b8a6c0909b3de53793d2f832109e31b18e881c43ca697ef9888fe8ed/pymssql-2.4.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2e0abf167dd198b8f865a5fec4601256ee0e95658ee748ba632072aa84480bdb", size = 3476013, upload-time = "2026-09-07T20:52:05.596Z" }, - { url = "https://files.pythonhosted.org/packages/bd/ba/ae38159179308f2ef630c49bca3f6f3760d4e83d532ac10f633bfb29595b/pymssql-2.4.1-cp315-cp315t-win_amd64.whl", hash = "sha256:13f2136deda8ba7c5f3e91797d709548541d34c81067355dc77194e08a0eb995", size = 2416862, upload-time = "2026-09-07T20:52:07.022Z" }, +version = "2.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/ae/44c335d6d380dc75da6fb47c15cb90ba2faa3016237817f7ec193ebc8824/pymssql-2.4.2.tar.gz", hash = "sha256:f0f7e2fffc25de6ec7f56551c91afa7981644ffc9c4d2d9c2a19ef4890797b67", size = 209508, upload-time = "2026-09-27T01:32:54.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/09/b2310e761154e1c59bca89815136b93bc3bf844ab9b6ec7d39daf71053b4/pymssql-2.4.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:593028018016ac3544e8a022abaf894148ad60843ec9b247f80d95d9662b5105", size = 3209599, upload-time = "2026-09-27T01:31:21.395Z" }, + { url = "https://files.pythonhosted.org/packages/e9/82/f4142bfe3f2923b9af720eaa47ce61fd29f68fb8fe810691c7994e0b46a4/pymssql-2.4.2-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:d2b8b57c5b74a10904971cd30910f0ffcde7dd4eb96663cfbe8edcf80332cfc5", size = 3014095, upload-time = "2026-09-27T01:31:22.997Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ee/87cf5d28c13b8e2711eeb322b09456dfd62316c14e8e44b9935e4f9a32a0/pymssql-2.4.2-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33461bece3b64420a371b5b061c8849f9519cda2d9aa1353ea5469fa410ee332", size = 3077012, upload-time = "2026-09-27T01:31:24.236Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c9/2f5c439b71d045fcf37905bbe463320211c8258e5e9503814e5feaeee2c8/pymssql-2.4.2-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9d1205a1046b413dc50a4253ab623fad2cdf74b50d55fc0b9af3aa8f262f030", size = 3258371, upload-time = "2026-09-27T01:31:25.443Z" }, + { url = "https://files.pythonhosted.org/packages/24/55/4629ca1187f69936d640dcbf049b230f5e90a1e2cd25eca8e38ed383f74d/pymssql-2.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:14bef8a505ea452f7e01aef06acc811cc641dc3f6ff14aaa9e7a4ca7b5a420b0", size = 3726139, upload-time = "2026-09-27T01:31:27.087Z" }, + { url = "https://files.pythonhosted.org/packages/28/f5/f65ef8aca27facad609e3dab8745a020b6f03a4381c3824ebde0d8a2860d/pymssql-2.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d842bc3b9079b9ec55a477161926240c74b208d1cbeada8d9472e0a8078946bf", size = 3468488, upload-time = "2026-09-27T01:31:28.722Z" }, + { url = "https://files.pythonhosted.org/packages/a0/6c/e22392a25ea30bdb7a6275093bcf7f394170c162cd38611eda162313bae6/pymssql-2.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:0cf996d159b1650fc903a01538e832f25e748bd64d8fdbfe32d19dc1061190ba", size = 2350394, upload-time = "2026-09-27T01:31:30.258Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ec/8cfb9dc051e2698ca683099e56c3792d236b253032c919bfc2868d73c4e0/pymssql-2.4.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:17d802a8b4a56eac1f004bf6bdf24be5fa9d99bc7f2d53812da95df265082c64", size = 3208069, upload-time = "2026-09-27T01:31:31.738Z" }, + { url = "https://files.pythonhosted.org/packages/67/0a/f76a2844ffa8b54ec3bb00453ccc92965e47ccaf72e0e31f9ec99037ada3/pymssql-2.4.2-cp311-cp311-macosx_15_0_x86_64.whl", hash = "sha256:19dda2f9581c862e135e1597abd5bd5f435c576dd3cf7b7be68f25b799aaab2f", size = 3012266, upload-time = "2026-09-27T01:31:33.135Z" }, + { url = "https://files.pythonhosted.org/packages/bf/33/d8e69edadef56686b38457a6d4c26bce4615d925bc7b176dd9c3c03485e8/pymssql-2.4.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fa9da3cc867003785b9a5ccb8d14180ae09b4f1462ffa00d36503b18b1a07d4", size = 3068155, upload-time = "2026-09-27T01:31:34.386Z" }, + { url = "https://files.pythonhosted.org/packages/88/b8/d4f568252ab47bbff6ff1ffd54be338a72735700659d4e91080cb1748e34/pymssql-2.4.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7eca757831c92836b8e4d5e20cc57034bb06b8026e0fe84925328c15fa0cfac", size = 3246808, upload-time = "2026-09-27T01:31:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ea/78a1e7248fa7f77eb4ea5b98022b577aaadcc57f086acce067e69b21b728/pymssql-2.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4b1d5646bb39a38b7f47cb9015979b39afd91e79b743d736281db64c2afbf7a1", size = 3717493, upload-time = "2026-09-27T01:31:37.152Z" }, + { url = "https://files.pythonhosted.org/packages/1f/c7/1451fbeb67fe7e85cec221c4eb9e4d3bdd1df282ce050c19374807f71c47/pymssql-2.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bab53310babced1db80563f05841343023ea0af85013a50b59d7934c3191efd6", size = 3454959, upload-time = "2026-09-27T01:31:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/af/ce/1cd313a0fff46982f6ccb48de7028c07189596bb11a51a2048b7024e9129/pymssql-2.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:5080a65bd6430cc15edc775f82cb0428ee21e825c7236b06ac69b915c7627e42", size = 2350195, upload-time = "2026-09-27T01:31:40.302Z" }, + { url = "https://files.pythonhosted.org/packages/fe/71/78f488879b130474b912cb4bf3c1b2e44dd7c4d66c400d3be19e47e5045b/pymssql-2.4.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:766f5d000cd116893b044b14ea8c96d43096c2acf4e93a89703ba75d52712b57", size = 3190010, upload-time = "2026-09-27T01:31:41.766Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5e/6ff0d46c6f723f4f8278ca255b4a3c79ae6fd01af11100053c6f2810fec3/pymssql-2.4.2-cp312-cp312-macosx_15_0_x86_64.whl", hash = "sha256:2b974acfa7f7151a31bcfcc058219a515597bdbef52162d44aee8108c1e4de79", size = 2993346, upload-time = "2026-09-27T01:31:43.219Z" }, + { url = "https://files.pythonhosted.org/packages/2d/da/306af14a8c8f2d4aa3a4b6ad6e85608e5d9789059cd5bd500bc17a17a5c4/pymssql-2.4.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6bd05d4703ec7b0b889af621c9fd8704d22f32ae5ec7d7e4d685d9e0c02014", size = 3098493, upload-time = "2026-09-27T01:31:44.7Z" }, + { url = "https://files.pythonhosted.org/packages/2c/30/3349790aedcf90166aca43a069c1c7b6013511895533ae94d0f3f5d1e91b/pymssql-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ac37a077666ca15f0ee839447ecef5109a6248ea1f329665142afea36c48ecd", size = 3275231, upload-time = "2026-09-27T01:31:46.274Z" }, + { url = "https://files.pythonhosted.org/packages/f9/58/1fbef3ffd3ed4d80542baac14319655f6ef9c34abe60789023cc21025205/pymssql-2.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1eb3f0762650eb420de443d64fab1bfe6234b09ad9769c94f426f465ad7412d9", size = 3746494, upload-time = "2026-09-27T01:31:48.01Z" }, + { url = "https://files.pythonhosted.org/packages/c0/bd/1a9ffa743cf528271b71186082f68792968b9cf625739235d218b928ed5a/pymssql-2.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6784c439424bef6735ea1a05b9daafcd2ff367457da7a248d2b3b1cdae29e997", size = 3482983, upload-time = "2026-09-27T01:31:49.472Z" }, + { url = "https://files.pythonhosted.org/packages/10/c0/39dbcc514c6b004254445e3ced97b436d74b4c4e44edef31bf08b1965cca/pymssql-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:4758def2f0acd9cc1dd3d0b878307b6a2629ef9b903d9001d039b02312e69843", size = 2336763, upload-time = "2026-09-27T01:31:50.753Z" }, + { url = "https://files.pythonhosted.org/packages/d7/60/5fb0de21ec8db8cede9771fc42d2bddb3dce6c5dea9b2c7e035b50380ae0/pymssql-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:2dad5b22ebac474dea760df16fc173a8be0ae6f7063414aeff9ca0ab83166ffd", size = 3190559, upload-time = "2026-09-27T01:31:52.192Z" }, + { url = "https://files.pythonhosted.org/packages/9b/4d/cdbf6c8397983953473025646c6c7e91e5e6ea5f6c3f8f47dd4af46633eb/pymssql-2.4.2-cp313-cp313-macosx_15_0_x86_64.whl", hash = "sha256:1d0f0f4e86933b1ed63b9cca4223d92abcff394b161bfee7b2859fbf7e904dd8", size = 2998677, upload-time = "2026-09-27T01:31:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/f4/30/8a82987c7f36724fcbd85a4383d8786f8754381ce8c62f8773556b5986d6/pymssql-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:207d1d8378c882123455300ad1baf145c3fcde66800622402d28deb001ca69f8", size = 3098065, upload-time = "2026-09-27T01:31:54.59Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c8/0efb224dcf80ab3651ac0fafdee12e594801c51406b6ec8fb710d7b94721/pymssql-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95881385d6f69488a601565659b6d1444e374c05a5936334b8efe109149cc888", size = 3274614, upload-time = "2026-09-27T01:31:56.113Z" }, + { url = "https://files.pythonhosted.org/packages/9f/19/0863b6c650aca1c1d2e952a20f4eb39a2d4010311e872e3e4b3b3652599d/pymssql-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8fdaaf4b6c35c879e5bc540d47f8905ed310ff5f5c855b7f613bc8584900ac54", size = 3746059, upload-time = "2026-09-27T01:31:57.4Z" }, + { url = "https://files.pythonhosted.org/packages/c9/88/24954c7981ec171bb5af29e44c5121ba19de8028142e3e9498bc34ba49aa/pymssql-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fb59ccad66e75085fbbf64e186ce683193986a9a3e6db46ca91ebdf49b88c655", size = 3482476, upload-time = "2026-09-27T01:31:58.68Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/7f8474be42856301d71ec9117dac84405ca14dfa639ac46c673dc3c75021/pymssql-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:e38e2daa3242f7ec026060c0472bf8be1c7845e1fa7d05efc0110d1b2ffcce1f", size = 2336029, upload-time = "2026-09-27T01:32:01.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6f/3bbc27d204cdf671b38357d4e451499afdc96e65290020f0bdf4415d2e15/pymssql-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:438a7a37a245236ba5182bd792638fbcbfad9a97122a0cbf88d8ece14a7b4cde", size = 3193880, upload-time = "2026-09-27T01:32:02.561Z" }, + { url = "https://files.pythonhosted.org/packages/e0/3d/75437cf2aea3e1c3206d9acd7561e0f10362fe665de9ab498b1459bd3e30/pymssql-2.4.2-cp314-cp314-macosx_15_0_x86_64.whl", hash = "sha256:8ceef1088170dcfc08e0b3536cad2be03e6effa66b6881dbf8ab0ed1d9da8219", size = 2999878, upload-time = "2026-09-27T01:32:04.342Z" }, + { url = "https://files.pythonhosted.org/packages/38/58/e1f13fec6673c64d411b18600501e83e5a253555733914c62a497b6ed494/pymssql-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a0cf9b05da9e3747296508ab7adb72601a7d56790000b7953c3fb170566215a", size = 3096237, upload-time = "2026-09-27T01:32:05.94Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a8/0bfaa2577de662cccc584fc10dfd1c7a132ce222bef957c676b465f4a807/pymssql-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22d0f0589abe9a4d6c12cb002563ea6f317090b8171ae2ae2b0df29fde825385", size = 3273053, upload-time = "2026-09-27T01:32:07.343Z" }, + { url = "https://files.pythonhosted.org/packages/61/ec/60230a1754b68e0c28c655a6ad7db840627d112bd354490371dc828f9641/pymssql-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:98e0d028ebdece1770d878172d65d0ef098e366c276ed9992c7bde2cf1e38e30", size = 3744955, upload-time = "2026-09-27T01:32:09.066Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f9/75ac2c0d398d7fea2c6477f1d42bf98048d0ee61439fbcef573dfd90b3bd/pymssql-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d5fa2c62a195d2b4b96155270081903c2f5d0b4ba6a5a1a37bb361c85ed21311", size = 3481043, upload-time = "2026-09-27T01:32:10.521Z" }, + { url = "https://files.pythonhosted.org/packages/da/24/2987ce47d8f03eb7c087dffa190e6e6109edfddfdb08ae0e76c6bd6885a0/pymssql-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:cb3029d053577b66d7dbf20225ffe75de2fed4a641d398c6fa03376ecd44bad6", size = 2399016, upload-time = "2026-09-27T01:32:11.851Z" }, + { url = "https://files.pythonhosted.org/packages/cf/7a/1a1a20c4e19807f0173d9363ba928990636a8f17eacc0120da601523d16c/pymssql-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:40da129a58600070c1c51498c36188ecebfea2158217336169e13ad4d1a04e1d", size = 3221444, upload-time = "2026-09-27T01:32:13.131Z" }, + { url = "https://files.pythonhosted.org/packages/6b/b6/05813934f24ed79033c073d70a11bdc3b72c4c9f0c886cd03f9056c4b1b5/pymssql-2.4.2-cp314-cp314t-macosx_15_0_x86_64.whl", hash = "sha256:539d358ed944c10dd09bd8ebe2832494b976782de8fa314a0e6c902f5d32ef6c", size = 3014102, upload-time = "2026-09-27T01:32:14.467Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4e/0728bffafe8a6a1b8a4e8727064d1511757e9ef101f7ba1fe07ed263dfda/pymssql-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1126b337f1adc509208df60e19c968c19117e39116fb02b46b01de1e64ba2a76", size = 3089542, upload-time = "2026-09-27T01:32:16.196Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5c/911fe0f09f777cb1eea7567599cfdd3d19eb6d40d23161618c353eabb9e6/pymssql-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136b15c790148245b3395a9bcabbfaa7d16406fb116043a92bac470701521dd0", size = 3268996, upload-time = "2026-09-27T01:32:17.878Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/7fb80d4b6c6805b46727c36133470144d822311e060eed44ef1365651b6c/pymssql-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:65e0adad5899b7d2e97407b8e3b6794207affc783cf5b32e76561d6413e709df", size = 3739514, upload-time = "2026-09-27T01:32:19.269Z" }, + { url = "https://files.pythonhosted.org/packages/cc/14/8c5c68753a6f8308d4b1e44720479bfaa44f6c92b765a6b519392056774f/pymssql-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b25f82d7fea0217e8a2932216425d4d149db2e2bed5e8899e835709b54fe046", size = 3479023, upload-time = "2026-09-27T01:32:21.211Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ab/93a63918f59bcdecaaa1cd5f8011fc353b2f5e5e4afd748b25842dd7a69c/pymssql-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:83ed0ed455e644de52065e7c6e7d606b2d511166ea28f05a58642dde0d3ddafc", size = 2418410, upload-time = "2026-09-27T01:32:22.854Z" }, + { url = "https://files.pythonhosted.org/packages/b9/39/20e8d6a3290e88be22655bedc3fec3ffd3d7beb26f2521b36bff681ca2b1/pymssql-2.4.2-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:4d917f3f8aeeeae6f5ddf5a5f3cba91772781f58081598820da7b9a75a129677", size = 3192838, upload-time = "2026-09-27T01:32:24.187Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f3/539159d5fa09a3540ccb387157b6255ff1d487a5df3a53b2a9d11496d10e/pymssql-2.4.2-cp315-cp315-macosx_15_0_x86_64.whl", hash = "sha256:655d0d1215672604d2b8400a7a1af29c6d84a45a87fc8de68c9de74208983a7e", size = 2998694, upload-time = "2026-09-27T01:32:25.608Z" }, + { url = "https://files.pythonhosted.org/packages/49/8d/f3d99ec233a8c029919c12b1c0d6cc8e21eaab959ad16f4221b96773b11d/pymssql-2.4.2-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a7a0016dd896a4c80e9dbcad9dae3197fd3cebed8bea43d35fc7c9f57740e33f", size = 3096511, upload-time = "2026-09-27T01:32:26.972Z" }, + { url = "https://files.pythonhosted.org/packages/29/31/5a8f1d2394669f1b4afb3f55701dc59563d7b07184f00216cf49757e3bd8/pymssql-2.4.2-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:afc14d099fcab4242922350f753acd35f1ee24ad64695ec8f1676b29ddcee63f", size = 3273234, upload-time = "2026-09-27T01:32:28.319Z" }, + { url = "https://files.pythonhosted.org/packages/66/9f/6f639086c6438e2613a6835cd99a852efd4cbedd36b18170eb368b62b1cc/pymssql-2.4.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:7d1418b9b13c1c78b5ae0b52174e48d49021f7923bbc6fca8f57f22093dc756a", size = 3745155, upload-time = "2026-09-27T01:32:30.267Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d9/e97641fd79c1e1e50f2eb4481623f53fc8b49aedc4f7b8ccc4e097b8d2de/pymssql-2.4.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:7ffedac5cd70d4d2767d30f4c90f210757f49eda9e8b87a254f5e79be19129d5", size = 3481225, upload-time = "2026-09-27T01:32:31.9Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ea/19793256603c96f63a666dcee804812c459c73692a79a04f6b60279fe2f4/pymssql-2.4.2-cp315-cp315-win_amd64.whl", hash = "sha256:f497b6f917983890e842180762a5925c1983036395fafee4ae7dcf4db270f53e", size = 2398139, upload-time = "2026-09-27T01:32:33.198Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8e/25c33f91108c037a8d893c6d0483478095d50a9dd690c3ca4ad2309acbdf/pymssql-2.4.2-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:1c4f473420a17764e61ace8498d2190df6cba1120e1fbcf2414819bd392dd5cb", size = 3220829, upload-time = "2026-09-27T01:32:34.626Z" }, + { url = "https://files.pythonhosted.org/packages/48/fb/48b47d2f65c024f000418baec8ad4eb39e201c8d146a44e18837a01883fa/pymssql-2.4.2-cp315-cp315t-macosx_15_0_x86_64.whl", hash = "sha256:9e1063f3e319baba3ffe6c6f0c64ebcdd3438347a787fa7b14326e6b00fa6a3c", size = 3013308, upload-time = "2026-09-27T01:32:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/83/09/e467880baf373f3f8fc2ef3c3e8123eaa2225f5a6e936f4e4c74345b073d/pymssql-2.4.2-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5ff8e0f4dcf317e462e1094fea1c62118594c5496bb12d621a641b062c5f8d2", size = 3088785, upload-time = "2026-09-27T01:32:37.379Z" }, + { url = "https://files.pythonhosted.org/packages/67/37/1be61d48ace07c4cdc2f19e81bcb814b78fa5d862b3ba3eb1bf7be0a610a/pymssql-2.4.2-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2698ce5ae14bfede29e8b025946f76ef0abe207f0fa726e74011b11bb00dd248", size = 3268196, upload-time = "2026-09-27T01:32:38.688Z" }, + { url = "https://files.pythonhosted.org/packages/70/f9/6e38131b7a618ccb7d96ccbd7d5a21ba60f4029524f98ba7019948a6c6ef/pymssql-2.4.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:4bfbb6bf5313e0278afb0ebd609f9a052ac5816ac170a7d06d14c867df18aff4", size = 3738905, upload-time = "2026-09-27T01:32:40.045Z" }, + { url = "https://files.pythonhosted.org/packages/10/6d/fd0b85b68fa587254b74c5dc67f395d6b6f0bf3c1199eb0115873e1da85e/pymssql-2.4.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:eaf2cc76ba16375f8df95ac16cb76e1d55696e0be4960953e2a6122741a2e8b2", size = 3478329, upload-time = "2026-09-27T01:32:41.507Z" }, + { url = "https://files.pythonhosted.org/packages/47/ce/c42fc1d9dc12116e9fdc7a1609809fd25059546791fe45408e9976eb0cd0/pymssql-2.4.2-cp315-cp315t-win_amd64.whl", hash = "sha256:cf960b137f6ff411ff8a2b289daec6f8d13a50820b75e6942ee81b2e28046c4d", size = 2416827, upload-time = "2026-09-27T01:32:43.656Z" }, ] [[package]] @@ -5234,14 +5271,14 @@ spanner = [ [[package]] name = "pytest-mock" -version = "3.15.1" +version = "3.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/7f/6ed29931d5c8cd396e7c0a55412e6cc88020373365c8685985dea53d26d7/pytest_mock-3.16.0.tar.gz", hash = "sha256:5a8395528b8f498205f3718f575228d0edaed7425fff638f87d1a6c3e0383636", size = 35362, upload-time = "2026-09-27T14:57:55.46Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/5b/b83a9bf1a3b4ec222f9fa083147ff6816245223da0ab92370e7e056f113f/pytest_mock-3.16.0-py3-none-any.whl", hash = "sha256:007cfeb257801d88d9c0b2a7b5a15a15e73b71968dfd72e7bf8c4a2f8393aec8", size = 10016, upload-time = "2026-09-27T14:57:54.283Z" }, ] [[package]] @@ -5314,11 +5351,11 @@ wheels = [ [[package]] name = "pytz" -version = "2026.3.post1" +version = "2026.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fb/48/fb042503b6ca6cd271261dc559fd6432f7d8c713153e9ec5c591af4dfc1c/pytz-2026.3.post1.tar.gz", hash = "sha256:2211d3fcf9a797d3405cac96ac7f61d80e6a644f72a3309607282fe8a2010c5d", size = 319745, upload-time = "2026-07-25T15:12:07.385Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/ed/fa23d28713004418bbf2407127f80f385bfb0bf4e677bef02c25c27b00ee/pytz-2026.4.tar.gz", hash = "sha256:464303645bafafd72418898368b2429458f709cf1eb6a15372fbcc396b64da63", size = 318444, upload-time = "2026-09-24T03:58:40.443Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/7b/39c34ca613b0b198cb866466651b26b045e2009864c5183c979a3b83f383/pytz-2026.3.post1-py2.py3-none-any.whl", hash = "sha256:dd95840dd199baea12d9cc096a1d452caa6596a1c1e4b5f3dbd1541855d5e815", size = 508283, upload-time = "2026-07-25T15:12:05.782Z" }, + { url = "https://files.pythonhosted.org/packages/d5/11/e3df59614bebe0ca50c905442603eda64ae7ac19811d4e4f74aa2d51ca85/pytz-2026.4-py2.py3-none-any.whl", hash = "sha256:9d514388fbc89ca0833203464272ac485b8828568ab73532f5020f17e892a0ff", size = 506747, upload-time = "2026-09-24T03:58:38.629Z" }, ] [[package]] @@ -5757,27 +5794,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/78/449cb84790bd5cc3823b2652ee405a4558856e5c4195aee3a16bf7b3eb5d/ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b", size = 4938814, upload-time = "2026-09-16T15:54:46.688Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ac/25/6071aabc530e9be7e2c195e8fe3f7aea2735405b6cf447212832d7811831/ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38", size = 10048966, upload-time = "2026-09-16T15:53:57.605Z" }, - { url = "https://files.pythonhosted.org/packages/54/98/07f90ecbc74dd5fb5764f11f2bc774d6a7cffef92d2ff5f5b4e9e23c754e/ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e", size = 10165498, upload-time = "2026-09-16T15:54:01.14Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1f/e6a712e3b47cad4a40600134105ed193cb773f618a42eb7ba323cb812cc0/ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170", size = 9830004, upload-time = "2026-09-16T15:54:03.998Z" }, - { url = "https://files.pythonhosted.org/packages/23/f2/311a08776d75d81c7676e20b6b020ae63cbe881fcdc7a8dd64e6e18bdd93/ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659", size = 9986558, upload-time = "2026-09-16T15:54:06.804Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ed/37b6cb3d3ba8c73e68ae3eb1d502383beb5aa05a582bb7bb3a922f929f54/ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2", size = 9877332, upload-time = "2026-09-16T15:54:09.552Z" }, - { url = "https://files.pythonhosted.org/packages/22/cc/40873a8f36ad084cc540d55fcca7077264d5b13b24659e9180c176fb2b08/ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812", size = 10507125, upload-time = "2026-09-16T15:54:12.152Z" }, - { url = "https://files.pythonhosted.org/packages/c3/e4/fc91a642b78ccbab6b9477720f3644ae7a10a9bcce69a934679cd64f62bc/ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b", size = 11336694, upload-time = "2026-09-16T15:54:15.489Z" }, - { url = "https://files.pythonhosted.org/packages/c2/3d/bbd2a9a600a4e73dc3e7548a249c8d1671273464b55822c6fae50f602dff/ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f", size = 10774448, upload-time = "2026-09-16T15:54:18.16Z" }, - { url = "https://files.pythonhosted.org/packages/1a/41/d83af9879a7b6e8bf5fe16b1da0b134049d2f5d3afac12defb0897cb84bd/ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df", size = 10323796, upload-time = "2026-09-16T15:54:20.743Z" }, - { url = "https://files.pythonhosted.org/packages/f5/2c/cefd07bfe914b84943ea769ade8d607bd22750b965d3228eefd7cebd15d0/ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e", size = 10514115, upload-time = "2026-09-16T15:54:23.497Z" }, - { url = "https://files.pythonhosted.org/packages/f3/9d/76a2e26c79a23be6e6e3664c57bec9e9fc8de155cfb9e4b67ea91b64f9d7/ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f", size = 10072582, upload-time = "2026-09-16T15:54:26.185Z" }, - { url = "https://files.pythonhosted.org/packages/2e/d4/f42edddb39668af1a559ceafa3823aedd65633a48dc9768e775485faa2c1/ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2", size = 9879644, upload-time = "2026-09-16T15:54:29.278Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d4/913e3195d95e0378786c6656945c865f534a3560e29139da4882aff630d1/ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9", size = 10231569, upload-time = "2026-09-16T15:54:32.036Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c4/8aa6ea0bdcedbd1bf87397e2fc4ed8406448ea5842f8660bc6e5f163039d/ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773", size = 10663666, upload-time = "2026-09-16T15:54:34.838Z" }, - { url = "https://files.pythonhosted.org/packages/3d/02/7f10ef4700bc223c30a3fdd10631a29830c45524b810a3c7ed947af64591/ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f", size = 10093472, upload-time = "2026-09-16T15:54:37.47Z" }, - { url = "https://files.pythonhosted.org/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899, upload-time = "2026-09-16T15:54:40.488Z" }, - { url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" }, +version = "0.16.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" }, + { url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" }, + { url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" }, + { url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" }, + { url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" }, + { url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" }, + { url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" }, + { url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" }, + { url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" }, + { url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" }, + { url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" }, ] [[package]] @@ -6095,7 +6132,7 @@ wheels = [ [[package]] name = "sphinx-autodoc-typehints" -version = "3.13.7" +version = "3.13.8" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15' and sys_platform == 'win32'", @@ -6114,9 +6151,9 @@ resolution-markers = [ dependencies = [ { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" } }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2b/d1/69ddd9bad8965986d11f92f9526f81e0805936763d34aed5e5b1e80795d1/sphinx_autodoc_typehints-3.13.7.tar.gz", hash = "sha256:c090ee6cb2c9e0a86074be9c288915ea3519e0992c77c5fb3b00fbfe61d50d1f", size = 96990, upload-time = "2026-09-18T02:05:27.243Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/7a/7595b6b0a4b17469496a4c022cad82b518c49d982e7d1dff6489c7b265ba/sphinx_autodoc_typehints-3.13.8.tar.gz", hash = "sha256:13ec0aa539f952562c78f51e9e05ab19645ab9aaca8509f031874f384baea2ab", size = 97092, upload-time = "2026-09-29T15:38:05.837Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/66/0c/3ee05b107b8c58b41e5df954a8354a9f1db5c334299be5167a5a77df6369/sphinx_autodoc_typehints-3.13.7-py3-none-any.whl", hash = "sha256:7367a6736a0d779bfee4533217fa784b6cadb4f5466b5ea47e722e9fcf0946e8", size = 49279, upload-time = "2026-09-18T02:05:25.708Z" }, + { url = "https://files.pythonhosted.org/packages/bb/65/8d4bcb876423694e24beac3b35d508dd50b3de336b088efb1638ef4ca85c/sphinx_autodoc_typehints-3.13.8-py3-none-any.whl", hash = "sha256:9e0db0e16dcd552a44dc59fd47f4a5194ffecf6a3b80e365c681dab03a5a1dcf", size = 49305, upload-time = "2026-09-29T15:38:04.439Z" }, ] [[package]] @@ -6310,6 +6347,9 @@ wheels = [ name = "sqlalchemy" version = "2.0.54" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, @@ -6366,13 +6406,117 @@ asyncio = [ { name = "greenlet" }, ] +[[package]] +name = "sqlalchemy" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/cb/7c68da82239ffae9378eca076afe14c310e17a4f01e3d315112f266e26c7/sqlalchemy-2.1.1.tar.gz", hash = "sha256:fcf3cbb33bb23bad75d2150157c21804618745731e9931ab27e61879a9f6123b", size = 10503203, upload-time = "2026-09-25T14:59:00.117Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/49/5046a3fc309a3c1953bb163d37d417914ce17e4768f64e76376a4ccdc908/sqlalchemy-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5516bdaacf5521b7de69a5c76961fde2cdc4edcb39730eccff16246a44cb8715", size = 2455148, upload-time = "2026-09-25T15:17:03.244Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7d/b3aaed3af03e8f36953c8d0eeec76c3b16fbec17dcfba4966d602ad7f22e/sqlalchemy-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f083b4b6a6d0061b0f9ffec0ea9ded82700a6bd11ced32ccb42f855f7806f812", size = 4612428, upload-time = "2026-09-25T15:25:43.007Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7d/a1df6a02d5765cbf564bc115f6116857e7d84eff5e053b4198fea1a02661/sqlalchemy-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:758d52653c8902baac25269c5b7c6fca4a198e066f2212fd2ed1b98174fd7bbb", size = 4633286, upload-time = "2026-09-25T15:25:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3e/04d3f468bc8e8eaedd300fd72936ae857428f9ecddc8ff598b0645718490/sqlalchemy-2.1.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6a7ff6a02a102e0575d237172e90adc96466e6ab6511eae7fd43775e6ab19db2", size = 4363892, upload-time = "2026-09-25T15:31:39.47Z" }, + { url = "https://files.pythonhosted.org/packages/fb/56/6c34b184f9e29d124603d70fb072bb386be019bb37ba1c182d9f1e47d07f/sqlalchemy-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0b91e020ba33c0cb9842be6eb7de32dfebd9ad1e8f9a660a0fdb567fd0aeaed1", size = 4551082, upload-time = "2026-09-25T15:25:44.916Z" }, + { url = "https://files.pythonhosted.org/packages/d6/8b/a94db16b6f62e6d9793355ea4b0fe649b8c56adc24802345159b8610a0e0/sqlalchemy-2.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:73743f135b489d3551eeb3cbbb3756d6cc41bb68e033d2b7b2b620b11c6c9d12", size = 4361249, upload-time = "2026-09-25T15:31:41.444Z" }, + { url = "https://files.pythonhosted.org/packages/4d/24/80c7de16f0248faeb4193fea62ec346f4b56df2ad2e906c82dbea7e73c69/sqlalchemy-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0593ed87886c44e94dfac4b22d20ed88b19a7dacd7440177d7156f9b011e4376", size = 4603313, upload-time = "2026-09-25T15:25:53.288Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/18d9916461ff24deb02c564cb26740ddb536b21da72164670995cefe11fa/sqlalchemy-2.1.1-cp311-cp311-win32.whl", hash = "sha256:c19633f7614e1af93192f89debfa5bfbfaed93c80dd8903417d1605d019958dc", size = 2372093, upload-time = "2026-09-25T15:36:01.461Z" }, + { url = "https://files.pythonhosted.org/packages/4a/a1/d91a5a3b2637a9ed1b99ba0ed35384404f5cf470dc638e9bab096bae512e/sqlalchemy-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:9dcedb4b1ad160a1297772162f9a15fc317627b76ac7c98d12c41ce38522d8ef", size = 2422083, upload-time = "2026-09-25T15:36:03.268Z" }, + { url = "https://files.pythonhosted.org/packages/42/18/1240d7c33784b6737bb47bfec63da246b174db4878c1cf2bc3b7897e945a/sqlalchemy-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:e08c92feb447b60e352c3581cc8a03119e36917b79362af62fd1a289c4d8e27b", size = 2387119, upload-time = "2026-09-25T15:11:43.595Z" }, + { url = "https://files.pythonhosted.org/packages/c8/44/950ac8e093784fdd5fd4fc385d110e0cf1d1d2bae781dc7be659bf3e7af3/sqlalchemy-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1abf1acecd4d0fc34d4ab7e9cda9e849ce95b647e1a5f76db7d2a5a0bea4e1e5", size = 2458475, upload-time = "2026-09-25T15:17:05.018Z" }, + { url = "https://files.pythonhosted.org/packages/20/13/ade5c1da6221e865a57ee45132a3c61528bb244d7b22ddbbddd589989754/sqlalchemy-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61333b2f806f64a08a1edf0b0e4271905f73c1f7e525c23da7a491f74c109835", size = 4653012, upload-time = "2026-09-25T15:25:47.467Z" }, + { url = "https://files.pythonhosted.org/packages/b2/95/37b6846549a76c69beca71974905446130d78bddd88a4414b5b88c88af16/sqlalchemy-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0fad3dc37851b096ef72538b18152a5c1ae9c8b2013cd89f51eca83fc6eba36b", size = 4701803, upload-time = "2026-09-25T15:25:55.644Z" }, + { url = "https://files.pythonhosted.org/packages/57/88/abdc1ce5daa0af77c5d27183625ab7bb1155de41067fd77db4fb1cdc9616/sqlalchemy-2.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8a3126c1203cdfc0f00884f9e8dbe95307b813f883ee9d9691983a563ebc96f8", size = 4351745, upload-time = "2026-09-25T15:31:43.337Z" }, + { url = "https://files.pythonhosted.org/packages/7b/28/4375244fc658c95033593429bc6dfc61df96beb9b64c8bf57821fdebf85d/sqlalchemy-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f90e517b2eb9c4c40fcc978c8c242789fa6cb0064c501fd8c1515145ed5fe23d", size = 4576898, upload-time = "2026-09-25T15:25:49.478Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ce/0aa3c294150a7a018e61af42462cf299022ad71b4b94384be36fff6ec567/sqlalchemy-2.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:054a2c2b6cbe3ca7cb88aba9c5e2ecdbfc43656bea22326a41d09e5398c86ec4", size = 4346156, upload-time = "2026-09-25T15:31:45.624Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/cf00fdbcdd8f1933aa4b33d499218a8d470ec9fd935a2f33ec55dcef4fdc/sqlalchemy-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ea049f3e2702967414bc79f58a484dab191359267726607e6c1579a09ef93788", size = 4650545, upload-time = "2026-09-25T15:25:57.819Z" }, + { url = "https://files.pythonhosted.org/packages/48/9b/8b4a17884e2b286d5f89e3b93bc2be928c6d86d7bfeadf9ae4459d64c244/sqlalchemy-2.1.1-cp312-cp312-win32.whl", hash = "sha256:842a5c00ca9719cc26fba6f401f70e4bac19dc8cc4dc36350803a03954d6aea2", size = 2369166, upload-time = "2026-09-25T15:36:05.081Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5a/c91c3cf7f0e65ae7ad9ebc87ec4cc68f6d163fddaa36736621a00dac5905/sqlalchemy-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:0220e0d1d1ad2391a03cd6713ca5d795419033cc40baa1233f9ad454dffa25e6", size = 2421474, upload-time = "2026-09-25T15:36:07.069Z" }, + { url = "https://files.pythonhosted.org/packages/e3/65/fc72cd55397792553b72a8c43e14531baa436d08f832486d2cff9e806f36/sqlalchemy-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af189bd861bd31a84f0ba244d6db0c8aeae9e56448c9001bf02ee6a93758b39e", size = 2381060, upload-time = "2026-09-25T15:11:45.018Z" }, + { url = "https://files.pythonhosted.org/packages/cc/54/83400a33b70e02ef03b877e9d35c451124b4e74846a02e8a8a42d66b3dc9/sqlalchemy-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b04365ffe57cdeaf4d0f75630332d857dde3f91336bc4bdec76c810473c9c35e", size = 2452431, upload-time = "2026-09-25T15:12:48.551Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ab/15a4bda565d148aae53f8df650a5e3c908735460b97ce37a89f637a87d49/sqlalchemy-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dee7886f4725c304641929a38d1ebe163f9fb45a5c9363f34bbb85bfdcb084c5", size = 4577496, upload-time = "2026-09-25T15:18:56.457Z" }, + { url = "https://files.pythonhosted.org/packages/b0/98/35dd80a408ae2d880f92631eaaff7ae1b1004a3cd8f070f95d9c534380ee/sqlalchemy-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abd893deac6b0765f51d87129c5631cc5bc8f784f8bf22a9d3903695c764bccc", size = 4628607, upload-time = "2026-09-25T15:19:51.653Z" }, + { url = "https://files.pythonhosted.org/packages/47/61/177fad5305fbd3ca2b528361f8723d7804995eeb458bdf33eba5547eb747/sqlalchemy-2.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c60fb6793cf60b2733012352f0e1861c38b24727d0e37a1da1f3974eb79f0ed", size = 4295693, upload-time = "2026-09-25T16:17:51.491Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2a/ec0273d82a196180f75084cfd7bc5ad5ae43724d0b8e4addf5dd97f1a0a5/sqlalchemy-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a74c46cd93e34b912adafab98aa71c862518451a019d97f9c2f37392fc8eea3f", size = 4502443, upload-time = "2026-09-25T15:18:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/4b/91/36ff623fa5f28c5b96cec0f31c5c76c66ab9362d21f1d97df14261b5ec3e/sqlalchemy-2.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:eef01acb10295adfac8a960f716687052fe16f14a3f5955b56c24eefa9df7851", size = 4297102, upload-time = "2026-09-25T16:17:54.314Z" }, + { url = "https://files.pythonhosted.org/packages/71/58/a5a62bb8e2aa32c202dad2930efe7d7775b98f6cce7424dc7e6b5f357500/sqlalchemy-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ed18c0747eec4a0d8d9abe4c89d96ec0eda97721ab7d3a31d0c1ebcbf38cd2e", size = 4589847, upload-time = "2026-09-25T15:19:53.839Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d4/8e80c47eea173dcfb09ed73d255ae2f5f52b6c053a335639fed9000b0cfd/sqlalchemy-2.1.1-cp313-cp313-win32.whl", hash = "sha256:389e7098e9ae15094f222496de98b0fd211c06b896c73c6e46aa9ca5b6fcd590", size = 2366456, upload-time = "2026-09-25T15:20:28.824Z" }, + { url = "https://files.pythonhosted.org/packages/07/ec/29eb6ce8f821d36df50ebe7bd05fb6f04b61aeed78ccb6cf830f413b34d0/sqlalchemy-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:cf88881d593f527860fa7c63a50095496a5fa15632b1fee58e3114353f7fbc22", size = 2416469, upload-time = "2026-09-25T15:20:30.607Z" }, + { url = "https://files.pythonhosted.org/packages/69/ff/ab43fb0042119bd7ac7347d2df1391f37515b2e27cc49bf29bf799651878/sqlalchemy-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:28b882509319789d52966d70164ccfc5bc1aaffc1c7f98a79bbccec13346ebd7", size = 2377438, upload-time = "2026-09-25T15:25:19.304Z" }, + { url = "https://files.pythonhosted.org/packages/19/6e/3739dc61a0bd20db049f1b220cf380d55cc6c6e6e80a9cab382b3fe2d826/sqlalchemy-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:597e1edf9cbe6ce63974813000c02c7a44da3d21f1da47f07174732ba188e8ae", size = 2455544, upload-time = "2026-09-25T15:12:50.464Z" }, + { url = "https://files.pythonhosted.org/packages/51/9b/29879ca69033f466da33486d6fb238ce1f4465e15529959e5bd3a47a8f8d/sqlalchemy-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e37b1fe2e080bf47f3b693dfbec9aa39d3c3d52bfac3b4fef290a449570bcf3f", size = 4571544, upload-time = "2026-09-25T15:19:00.881Z" }, + { url = "https://files.pythonhosted.org/packages/41/0f/6db5347662a9ed83509a5236e64afc15285947ed4dd71e618912bde83445/sqlalchemy-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18148691d761ef20a92cfc7dced305834e2c6ab4f4f3648f66f5d4028e234089", size = 4601803, upload-time = "2026-09-25T15:19:55.751Z" }, + { url = "https://files.pythonhosted.org/packages/77/e4/073eecd01e8a1bb5b90638c15f06b86c897518ef96d70118a70ab37bb673/sqlalchemy-2.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44dff425e7d88b04497b4317478a6af2a694327a087e6f1670a4266180838269", size = 4295583, upload-time = "2026-09-25T16:17:56.955Z" }, + { url = "https://files.pythonhosted.org/packages/25/71/c4e261d50297457c2496bb39b03f2a2ea0578a298defe4edc5ef514d4423/sqlalchemy-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6a261e2244fa88e6eded95a078550c0584da55db22d49b0b2db8c075788282fb", size = 4501686, upload-time = "2026-09-25T15:19:02.804Z" }, + { url = "https://files.pythonhosted.org/packages/7c/85/9ec520ba12027bfdbcaa3e6633e977d1984dca5739ab73bc6c9f16cb4900/sqlalchemy-2.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:4789593e8979d7c946da1985a560200b6ef6c3dc6dcc5442985d62c6f2908875", size = 4296847, upload-time = "2026-09-25T16:17:59.584Z" }, + { url = "https://files.pythonhosted.org/packages/dd/65/877c7f19fe976dffbffc579148d842bb99665e520af83d28b287a162f843/sqlalchemy-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1477f2f1f531697753a729dd8f3e4e98f7019086f001fdc4d50350c929871c8c", size = 4560888, upload-time = "2026-09-25T15:19:57.887Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f3/feb8b0803a6a2d095f4330cdc598aacef46cfb1ab6e83f4b396c6f5b89cb/sqlalchemy-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1ef7bc38d140b3cf08cc6e8823b8e76da2e188b5594997e2c16586c9778af472", size = 2373288, upload-time = "2026-09-25T15:20:32.159Z" }, + { url = "https://files.pythonhosted.org/packages/be/b6/eef7d11752b5b52ad3fecfe03fabf7324e676bf1db5a22788cbcb3ba69c6/sqlalchemy-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:73b0f7cb41a3df79dc0427377a9e2a099eb15995a327120d651f21309faa662a", size = 2423714, upload-time = "2026-09-25T15:20:34.012Z" }, + { url = "https://files.pythonhosted.org/packages/22/a0/6ce78eaac3a3197847a1d184a24633869cc343b0885f9207aa605e916bf5/sqlalchemy-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:a0421f9eedad616b04a097ae3f155c5a0a31380364369861fd6e0f46074c3fce", size = 2388489, upload-time = "2026-09-25T15:25:20.78Z" }, + { url = "https://files.pythonhosted.org/packages/b1/56/db8f82c576dbe4f52db82e0e430debca0a6398fd0bf6a45bde11e7feb126/sqlalchemy-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2f0bb0502de28baed425278d4bd98658d8256419fd9ad6ca964138710f933aa0", size = 2494990, upload-time = "2026-09-25T15:27:27.737Z" }, + { url = "https://files.pythonhosted.org/packages/d8/e0/cbadd9f851fa376c619f97adb4abd4b0c9d727841e988c4283dc45007c1f/sqlalchemy-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e20233c7d000c006a03f3734d8e7a6e63aa878fcd3482c1f412c864a0992e5a0", size = 4899710, upload-time = "2026-09-25T15:28:30.224Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5f/8cb1a5c751161cdac50355b2c91617e2a872c237922a54b47ce014f11dd3/sqlalchemy-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b0660ded5f9c090f9bf49fba78d52843ff3ca8c10f71624811f42f11655762d", size = 4810110, upload-time = "2026-09-25T15:49:02.468Z" }, + { url = "https://files.pythonhosted.org/packages/6b/4f/a4329f5a006b6cd2e8836b160d179c522c0cd7edfd494971a6d5a504f33e/sqlalchemy-2.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:63c0291bf0f721543db3719c71f2d5ad6649f12abbcbc8324d42805dac112cbc", size = 4490328, upload-time = "2026-09-25T17:35:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/4c/40/7bfd8136b8038bc1ffad70f283bd51aa93e43808ea522dac5a99be7b26b3/sqlalchemy-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:092a743f28f3a660321e9b093144b6a5b16f08dbb9b2f52466b47a2a128475c2", size = 4757854, upload-time = "2026-09-25T15:28:32.778Z" }, + { url = "https://files.pythonhosted.org/packages/ab/4b/a25d5f0e8493fa8ad2334a7100479302cd1928f788c6200faa952b47ee27/sqlalchemy-2.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fdef70d59afce463bedef54580ba6a832f9f2313c1d0054e15cbb4ff46f37a97", size = 4490016, upload-time = "2026-09-25T17:35:05.664Z" }, + { url = "https://files.pythonhosted.org/packages/20/74/075ae28f89ba586a2229dc746aa8b4e5b0b2595f228c96bacadf66940bf4/sqlalchemy-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0f94929be35def6580cf161f20c71930d9364d62f7040b6aed02b13fd3ca09e6", size = 4748267, upload-time = "2026-09-25T15:49:05.376Z" }, + { url = "https://files.pythonhosted.org/packages/57/fd/744a9e6d2788d1d4a077ed711d06cf503762ee8a614f887e284f0869beba/sqlalchemy-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:dabf6a73f2cc1fa2155b29f2a8a85afd82d8613262e6054cc3c932f5dee5d474", size = 2431659, upload-time = "2026-09-25T15:25:37.802Z" }, + { url = "https://files.pythonhosted.org/packages/c7/11/f4a73cb472cf1401ba3f47b02eed7983a0545f0347582afd469d5da2f324/sqlalchemy-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:3353333f8c25fc5e0661f54630238a17b0d6600305e224400cd4642cce4af34f", size = 2499109, upload-time = "2026-09-25T15:25:39.706Z" }, + { url = "https://files.pythonhosted.org/packages/ee/54/21ceb7c4ad51255be2333aa4fb32c973d35fa28b26f9895c6d66767aff4d/sqlalchemy-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7f15820c84b2377bcf02cf754232690ac2ad90d425978bf1e5ba30cc04f94d55", size = 2417171, upload-time = "2026-09-25T15:20:58.386Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b4/bd32e5cc1d641871ca95c0d7bd06c9d7e03d47bcab100f0c27fd2e6c8c74/sqlalchemy-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:481d926922d9bff632955ed6f07e2b87066b0d1069eb67cbc8c35e27f7fe5e1e", size = 2455487, upload-time = "2026-09-25T15:07:27.083Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2c/87f7754471eff7ad3186243a835ebd609f10a46d73d0c62a40188d8ace70/sqlalchemy-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2a036e3c8b183f9c31df7611e03a4bd160e055915772bb8c13ec382da8f3571", size = 4575174, upload-time = "2026-09-25T15:24:16.573Z" }, + { url = "https://files.pythonhosted.org/packages/c5/82/c6684ed8aea294894b26b8b832b5ad42dc448e385d35d8d265f6c24309bd/sqlalchemy-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb1f36ca818146b0e844dbde6dced65805d4ee06ce6282aa46a807f9a6619ede", size = 4610877, upload-time = "2026-09-25T15:35:07.336Z" }, + { url = "https://files.pythonhosted.org/packages/e8/14/ce3422645f694cc59eadc267009c245c401f15e03b342a9342cb05517b99/sqlalchemy-2.1.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:583038541d9246dbb05fc4ab73038dc61820983c12fa5e32a3b0d903e1ba7bba", size = 4312066, upload-time = "2026-09-25T15:28:55.17Z" }, + { url = "https://files.pythonhosted.org/packages/51/58/0888d935b0f9e2a61dfd28872cdda833be63239a4f21ac49f22a79fbb848/sqlalchemy-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e5274d07ae1dc5dc0e452ec24bdc97cf76ba189cab3f0c1c0386a1523240f775", size = 4505127, upload-time = "2026-09-25T15:24:18.447Z" }, + { url = "https://files.pythonhosted.org/packages/95/4b/c54b6cd56a1d064bef1dd1e202aa54d6b9baac4f3ecb8f42c179907d5e99/sqlalchemy-2.1.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:f2c1c27a41d1a26db380b3bad45f822d979c9608108c32ee613a418ec454ecbf", size = 4312019, upload-time = "2026-09-25T15:28:57.606Z" }, + { url = "https://files.pythonhosted.org/packages/db/b0/a42e854cc34c9fd47b36f3df15b4036f778b5032bc3206b1be31d35476f9/sqlalchemy-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3ac9781474e4b6c3e184487643d47408666be794d5782165a817da63f981f89a", size = 4571994, upload-time = "2026-09-25T15:35:10.02Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/34b2b42bdc5c5e4bd2d65406ac02cea51ad4bb6fed258223f6501a5ee902/sqlalchemy-2.1.1-cp315-cp315-win32.whl", hash = "sha256:eb2095d39baecbf8b78ea202d3d21178cc8d73e52c3b450c82feb200d5b0d1a9", size = 2373014, upload-time = "2026-09-25T15:19:07.42Z" }, + { url = "https://files.pythonhosted.org/packages/5c/91/441d75e7a94f3b5cf92b655282f5405925403daeba543d2cdfbcc6742923/sqlalchemy-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:ee446c07db92e94af30e1390ef7d65c1172756403f743fb7d8b4fc5f377cd427", size = 2423755, upload-time = "2026-09-25T15:19:09.005Z" }, + { url = "https://files.pythonhosted.org/packages/52/1e/414a98da13eec0e6b984a0659c7cdcc835be8e5cb73ed13cf134aefff9e8/sqlalchemy-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:57ae70935cd382cd41d84757e802687f7c5e7411aecf2b962b7bdb6ea74e088c", size = 2388191, upload-time = "2026-09-25T15:29:56.041Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d6/750d7aeb670f4954704206a5a0d20b07833549080d2e5cb1503890da568a/sqlalchemy-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:558d6d7d6eec0e54ed212320a587bb7f30553550396226d8488803c93757f17b", size = 2491192, upload-time = "2026-09-25T15:27:29.424Z" }, + { url = "https://files.pythonhosted.org/packages/e3/07/8e5c0691325466bbca0768406b4d5c2b6585d9684e25a4894bdc4df9c745/sqlalchemy-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1986faa95fd7b206e5bdd1af7f4ba9ac10f1166225fb2eca0fbca04e3851f7d", size = 4875769, upload-time = "2026-09-25T15:28:35.083Z" }, + { url = "https://files.pythonhosted.org/packages/29/2f/4deede5e238c5d539422e8f6cd692925a604a37b7c518bbcc89ac49ca801/sqlalchemy-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87d45175e8ba4a1ee027ad9307e6a4bf8a242469415f12afc9477def739474b0", size = 4826021, upload-time = "2026-09-25T15:49:07.847Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0d/b620b9ccafbdefd83cda6fd479e82d7f5dd0baec0fcae8c9e2b1296091f1/sqlalchemy-2.1.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8d846615b89702ef5d0809e701ff5f3816b88dd02670c777adc456f6d81ef3ef", size = 4476462, upload-time = "2026-09-25T17:35:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b1/e6829e519e73365c180568b64b5d2f79350daf6eb468e74cdc45b2bef018/sqlalchemy-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9324681cf8d8e9c7f6e507fea6a8ac2215fee253340d4399645216a08b936660", size = 4733363, upload-time = "2026-09-25T15:28:37.973Z" }, + { url = "https://files.pythonhosted.org/packages/af/78/9315b9732ce85aae954a4609f9e7d45975a8cf3a4133f2f8bda7fb7bdca0/sqlalchemy-2.1.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:88cb0a27ee9571fe339d7809a40b14ff2387ea05d02629a390a03c87cc981277", size = 4475138, upload-time = "2026-09-25T17:35:10.766Z" }, + { url = "https://files.pythonhosted.org/packages/99/f0/50f86f633f6ddb76d4a81bba1d0b382acfe465063699bc19900daeefdf05/sqlalchemy-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:359e2411cf4713b4b323b3c2558a07663a19ab82a5d7b23c601e1be5432aac0c", size = 4752931, upload-time = "2026-09-25T15:49:10.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/0c/e5a498c55b4f73b06bd9d541dff00b9736942bade23203719f1ba2c90774/sqlalchemy-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:d111318e32b4452d85b491d2ba3f39f6be2736ff858da616f0e6627bb3810145", size = 2428440, upload-time = "2026-09-25T15:25:41.517Z" }, + { url = "https://files.pythonhosted.org/packages/99/0b/df1c33f575ef39074dc682db2a33a1c5f259d48ca392b95a695f43553a29/sqlalchemy-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:b357598df0676accd1ccfb6bbc5db53dbfb499052d2d1677adf4ce14a7180664", size = 2494146, upload-time = "2026-09-25T15:25:43.453Z" }, + { url = "https://files.pythonhosted.org/packages/43/e1/f3d25c2ca66ed394b0fc9786af9857c448bc9ac68e59c3d2283245e9dc40/sqlalchemy-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:b76d8fddf080c53f2afdea6e7149721e7b980086a2f17a9b0bdb42571a92c285", size = 2412985, upload-time = "2026-09-25T15:20:59.747Z" }, + { url = "https://files.pythonhosted.org/packages/c5/7a/7d1879d78c086737acee5f3250cbff9d285e5bd7cb6bc2c730f24e8d400e/sqlalchemy-2.1.1-py3-none-any.whl", hash = "sha256:4357c1a222e141662251a59d3702f9b124294941171dfa6bd570513e9f4905ee", size = 2047471, upload-time = "2026-09-25T15:20:37.797Z" }, +] + +[package.optional-dependencies] +asyncio = [ + { name = "greenlet" }, +] + [[package]] name = "sqlglot" -version = "30.20.0" +version = "30.21.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5f/4b/5b7cc5bef933d92646637aa21211901069e2463e32ea095e07a41049632b/sqlglot-30.20.0.tar.gz", hash = "sha256:8210b49131ac80cb3d4135edd3f64a50c95f2046305f4e6978fca59b39c82407", size = 6051245, upload-time = "2026-09-27T16:35:37.059Z" } +sdist = { url = "https://files.pythonhosted.org/packages/11/c0/cd761f018601bf954c9018420eb6707e3675292dfa514cfda44d06aff2ee/sqlglot-30.21.0.tar.gz", hash = "sha256:6787ac202a3adc0e25434f1512370a655e51cb3de8c792544de5f6ff9c618d25", size = 6064227, upload-time = "2026-09-30T17:35:09.731Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/cc/b42e2b2efc612427d0402c37974f0eb69654faf287a95f72960863a7dff1/sqlglot-30.20.0-py3-none-any.whl", hash = "sha256:886fd92eba9bf944dee97164b16760f21576a7f55ef4a9211337903442e8aded", size = 758365, upload-time = "2026-09-27T16:35:35.285Z" }, + { url = "https://files.pythonhosted.org/packages/7e/93/acaa0862f841051e490326c1fc8c61d618d2df26f9e7aed26c331419ab15/sqlglot-30.21.0-py3-none-any.whl", hash = "sha256:816d1a4815b7562b3976efcc0b561c928a743f976efd0fb8ad6db7a5b96c069e", size = 763892, upload-time = "2026-09-30T17:35:07.976Z" }, ] [package.optional-dependencies] @@ -6382,33 +6526,33 @@ c = [ [[package]] name = "sqlglotc" -version = "30.20.0" +version = "30.21.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sqlglot" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/64/7243e5fbd0c264ea3c8edb817bd593ed4ad003da04bda55889ea11695190/sqlglotc-30.20.0.tar.gz", hash = "sha256:b6a3af3ba07c58d106552e2acb025d18afca840babb4a71246189f9fe915e911", size = 526020, upload-time = "2026-09-27T16:34:46.339Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/68/3c/37774dbbec71ecd2e78f35440b14e61de9d7587abf31fe1188edf2416a9a/sqlglotc-30.20.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e56d8371e36831dc324b06b0ad79ed52c1b96de3c264b8a61485f3bd48dcc47e", size = 33103767, upload-time = "2026-09-27T16:33:58.845Z" }, - { url = "https://files.pythonhosted.org/packages/ad/2b/e494e88b9bc482b0d3b31b3dc07a44512721338bcdae2e6e6638e163495f/sqlglotc-30.20.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f10fb4b80ba9187a9a6a482091f340bc5769eaa1e1796253c7c805e085a98b0", size = 25421090, upload-time = "2026-09-27T16:34:01.648Z" }, - { url = "https://files.pythonhosted.org/packages/a4/10/63ac9576dc735ed86445978156063264be61b8f59a3a873fcaeba5bf7e64/sqlglotc-30.20.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:71f9f9659387618a813ad8f61c9db9ca7734bf978ea7ca25c95834222833b826", size = 26567942, upload-time = "2026-09-27T16:34:04.206Z" }, - { url = "https://files.pythonhosted.org/packages/ca/bc/cb763220f6192cfdd6b24cad85f40155e5da748e48d7abed0b434a293e5a/sqlglotc-30.20.0-cp310-cp310-win_amd64.whl", hash = "sha256:a8f8dfd01dd9c6524d9a884a044d46427f67525ce8af5999c2148593c75571e2", size = 11161696, upload-time = "2026-09-27T16:34:06.168Z" }, - { url = "https://files.pythonhosted.org/packages/49/a1/8b21c0d62787f897676d0dc6dbab2016a6898f2d1c847cc091d22c75bc7e/sqlglotc-30.20.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:597d52f4f92bb43c5e2854519de4c8b9554e53aba07b942fbbbb69193dbf7dae", size = 32947885, upload-time = "2026-09-27T16:34:08.652Z" }, - { url = "https://files.pythonhosted.org/packages/54/96/3a59b4108f5a5383c0664b673ae7ac692c2e50baddde73338d45c3a12942/sqlglotc-30.20.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28193b56bbbc8309067aeebc1b85e901d43512ba1588c7176e9df55c9ccbc12a", size = 25615938, upload-time = "2026-09-27T16:34:10.884Z" }, - { url = "https://files.pythonhosted.org/packages/ed/cb/92841bc8805e0baf6f715c3f879ba4e7e53a6501ca029aeeeff379147385/sqlglotc-30.20.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14eed6d79608dcf438e3505bc0474a41a1102d51dbca40ef4c2b34e6c33f474c", size = 26789036, upload-time = "2026-09-27T16:34:13.189Z" }, - { url = "https://files.pythonhosted.org/packages/1a/29/54a94434022d59f372813fee2cef5fc0f9f3308de4ca1eea38280d252fc4/sqlglotc-30.20.0-cp311-cp311-win_amd64.whl", hash = "sha256:17257752fa9a8bf3c10968e93b1368103960e4cc05f9de104d46adc539659562", size = 11155991, upload-time = "2026-09-27T16:34:15.368Z" }, - { url = "https://files.pythonhosted.org/packages/0f/f1/0f38d276d111fa42366ac42603f67a77a7dbfb24e9f80675dbed9c714240/sqlglotc-30.20.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d08ab10290af9a01834a89165ed91b27dd0c2165bbc3124f3a066fd2c55ce47f", size = 33198415, upload-time = "2026-09-27T16:34:17.8Z" }, - { url = "https://files.pythonhosted.org/packages/ec/7e/f5ca49b466129a088674299629508a9ba4656e0679bf33d425ded5ef1f73/sqlglotc-30.20.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a36541cb4e13ffcb4588959917210374d067977a6d716bf627f4c8bc92074f90", size = 26555507, upload-time = "2026-09-27T16:34:20.615Z" }, - { url = "https://files.pythonhosted.org/packages/35/a5/5179ab5d625f1be698301577eb718cf5d3fec9a49eb1b3ef17217d9408aa/sqlglotc-30.20.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6fba0e274f25f910bf2bf0c82f8cb821e61498d74467c1033ed35447e08d6cba", size = 27851338, upload-time = "2026-09-27T16:34:22.767Z" }, - { url = "https://files.pythonhosted.org/packages/a8/56/c3b2f78c17d649084b19f4049a9314a0f6bd759455685a2c78088a198f0b/sqlglotc-30.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:5cf23599a74a0287b920a1d909694f36b2b4f42f448dd4947db64eaf6ae0859d", size = 11377731, upload-time = "2026-09-27T16:34:24.879Z" }, - { url = "https://files.pythonhosted.org/packages/6d/f1/9cd2509ca6dce9d2a45be25875d394c9c0e8ce245dfc04fa9eb11f827ac7/sqlglotc-30.20.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68d6298e3a8b76a3134e50781fa45ebe4ab904369121387e5e899f9ccd51b68c", size = 32724055, upload-time = "2026-09-27T16:34:26.925Z" }, - { url = "https://files.pythonhosted.org/packages/38/83/be307e6e4ebff62f950e8690ae389c1a3b4a62ba22a7780f2cafd05d950f/sqlglotc-30.20.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da7919e8ed7ba8558c169d83072b4dcf8506040fb8ed717bfff7e3f9fba5a27", size = 26135974, upload-time = "2026-09-27T16:34:29.617Z" }, - { url = "https://files.pythonhosted.org/packages/b2/5c/219b46891cbb58d7550260b54f7a54d6e65c033e1c14373ab1e9cc412e1b/sqlglotc-30.20.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:744c4d2012da3dbcb9309d784c0b44299d3ea334d356329c414f54613e416560", size = 27440392, upload-time = "2026-09-27T16:34:32.049Z" }, - { url = "https://files.pythonhosted.org/packages/9d/d1/272929e78eebf6cb7a7fab4675ebc2e7e9e131945f438b6f6292ceb1d8a6/sqlglotc-30.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:78c670ff9948b101be388bcb07393a5e04aecec0f46d179c86cbf954a2378a8b", size = 11375650, upload-time = "2026-09-27T16:34:34.322Z" }, - { url = "https://files.pythonhosted.org/packages/c1/2c/dfb13732b0578206ebc99491104eff8ca739ef9248c186b47e6236eaa266/sqlglotc-30.20.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c9da5833314d5d4fd6dbf94b52568506eddc1be66e1bb691255f943deecdbd4d", size = 32646022, upload-time = "2026-09-27T16:34:37.147Z" }, - { url = "https://files.pythonhosted.org/packages/9c/9f/cad44ca203637980d23c5accb13a01f1716a5ff3462643e6e1cc8c9a2a1d/sqlglotc-30.20.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d4eb042eb9d7d5d3d1213f5b0a28405e922eb7bf511195466b66f3cc4600187", size = 26122847, upload-time = "2026-09-27T16:34:39.615Z" }, - { url = "https://files.pythonhosted.org/packages/d5/46/5b0a9de6c0e5695b8ce47223a304c2c565f6940f7dbd624eb3a7a073e9a8/sqlglotc-30.20.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51b1c80dc272797994bbdb4c29bd9b5940c2dbc98933df5a8155c2e7d5edaed0", size = 27338685, upload-time = "2026-09-27T16:34:41.953Z" }, - { url = "https://files.pythonhosted.org/packages/64/d8/74a3c5ff530d4c7de5a904618d25d9256aea7df2e0dce9ab22f174a2bc89/sqlglotc-30.20.0-cp314-cp314-win_amd64.whl", hash = "sha256:b18f3cd807f805b4fd8372abb3413062c1c18f26cb32c399a844d530e4fa7245", size = 11520728, upload-time = "2026-09-27T16:34:44.248Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/c3/ab/fd1e7bc4be367998624be1392334d61b3e69e2c2a9089ec80ef4c71b93fe/sqlglotc-30.21.0.tar.gz", hash = "sha256:7644276e946fa3985d361e9f12b097cb910c4c215ccf73d9caa09e289ef59432", size = 528711, upload-time = "2026-10-01T01:29:13.274Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/ff/23271960e0bc68b41457a417163a3b8622732fa36a5cf538daa2ffdccb22/sqlglotc-30.21.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:eae2c90ea39ea4936eb0157c4f872ac59c58a9195875c8455dcf2b2dc3baed95", size = 33144926, upload-time = "2026-09-30T17:22:49.054Z" }, + { url = "https://files.pythonhosted.org/packages/8f/5a/59e5f0a5869aac62359680f78c07d71eda3ef4e44bed7b8044ab4756c5a0/sqlglotc-30.21.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1756ebfc2217c7aa76238ab0e2c4ef7d76cb348de44886815470fafd14fb69f4", size = 25461244, upload-time = "2026-09-30T17:22:51.579Z" }, + { url = "https://files.pythonhosted.org/packages/8d/99/26d7a1d8470760ba694672892719569bf4e87800239a9656ee0227e2ca1f/sqlglotc-30.21.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78d53a9df94a2cf6d91b3c4e12b42e6d8ee750e44e35e8f466a8d4e1cb7d2c00", size = 26610532, upload-time = "2026-09-30T17:22:53.889Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/aaee4711248e411f87d8e8e378bfd8c618c4fd81c9bcbc82781c07fcc9bf/sqlglotc-30.21.0-cp310-cp310-win_amd64.whl", hash = "sha256:e766776ebcca8366df573a1dff8ff70d4b72a221b2cd5eb0a0151d14361884da", size = 11178279, upload-time = "2026-09-30T17:22:56.166Z" }, + { url = "https://files.pythonhosted.org/packages/e7/23/262dd51f76e2c2a387b46152cf7447c927f82c961edd2d5ff3d4007f6491/sqlglotc-30.21.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:57e84ef998118435bfabc23087da198b1e07dd5de5e500671a60ec89683a9fb9", size = 32995491, upload-time = "2026-09-30T17:22:58.205Z" }, + { url = "https://files.pythonhosted.org/packages/4f/86/49988180d083bc058a4e162cb81983d07d70e5e05b4002d8a69ead39f50f/sqlglotc-30.21.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2571092e68ebda8a18a91e69a9c8568c9e4f6947f58909ae683a3126e11b0021", size = 25656024, upload-time = "2026-09-30T17:23:00.492Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0e/440800dab52972bec060a6ada9fb0a02caf63a7916d2a1a95fb4854f929d/sqlglotc-30.21.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:319e7487cb7255bbb2bae372368e89504fb8b20c17f5b54dcba756e275c7f6b8", size = 26832002, upload-time = "2026-09-30T17:23:02.781Z" }, + { url = "https://files.pythonhosted.org/packages/88/17/042e2963755d89ec33a7bde9557fcf1a597898a6088ca4f1f7ba281b2ba1/sqlglotc-30.21.0-cp311-cp311-win_amd64.whl", hash = "sha256:06a0e5b9e64896e35c11b4ede4622e6320619924f9d8702a1d149c0aa00e6ea5", size = 11172800, upload-time = "2026-09-30T17:23:05.163Z" }, + { url = "https://files.pythonhosted.org/packages/f3/e2/b903d898bbeea50bf2802449525b9ddee08517aa47778a0d36659b354a4c/sqlglotc-30.21.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1a47a487fbd6cb3f6719f48d559c8d364a1d5dfda1c9cbf76e1ca8f4211ca061", size = 33243670, upload-time = "2026-09-30T17:23:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/1e/9d/d8b3474751f4bc858ffaf90a6a7aefb227c0b0d5d643ea0ae8a7509a7895/sqlglotc-30.21.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8715acf06a2b8f2da10deb9366ec1e0e31632a10885527751d0fbfb50bbf184f", size = 26594522, upload-time = "2026-09-30T17:23:09.519Z" }, + { url = "https://files.pythonhosted.org/packages/3c/7b/a6fe2fc2bc00e37a22e4a8cc656573bfb4a53853f7ae96cd7597c65d085e/sqlglotc-30.21.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a357e02dd3227987942b87b6c2bf842b406afa5748c64f3f6a2ac39eecee144", size = 27899445, upload-time = "2026-09-30T17:23:11.66Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e0/04f317efc30953c428d2ace9337219e19be10af68054207445623aac55fb/sqlglotc-30.21.0-cp312-cp312-win_amd64.whl", hash = "sha256:c586ed7bfbeaba0bd8c0a493f9789887d2016e74a06f1b780bbf027122722f39", size = 11395574, upload-time = "2026-09-30T17:23:13.605Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ef/5ef47473988f8646eb230426de6278aec70a314e5163d6e8c774dd1495ac/sqlglotc-30.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:76bb5342590166516d87d1b9a543ac6c40c0b841d7e8118f54d5f6d09fae5743", size = 32778050, upload-time = "2026-09-30T17:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/06/24/657a783f8d7a663b9635d5dc730a03e5a7515688bcb7531adc79d577695d/sqlglotc-30.21.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a5548cc19e6847d810179b23cd0c05fd12329f7c2f9717704260a808961dbef", size = 26173118, upload-time = "2026-09-30T17:23:18.582Z" }, + { url = "https://files.pythonhosted.org/packages/35/d5/81657c0a4fd5fb2054de416f86c0e8378cd832cb61a2dccd85a6beb55cdd/sqlglotc-30.21.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e9de58f46f701635dc9ad930ca5b1c6a4b6ee45b2539cb47b9dcd8a45d4a0603", size = 27486336, upload-time = "2026-09-30T17:23:21.287Z" }, + { url = "https://files.pythonhosted.org/packages/84/6e/2973b3c3cf4078741c2dd0524a6ed128d2a9a92934f2691d44426f295383/sqlglotc-30.21.0-cp313-cp313-win_amd64.whl", hash = "sha256:8c6f8b96175678b05ea7f06a286324975529d010a3d959a13354f4546cb166df", size = 11393918, upload-time = "2026-10-01T01:29:01.753Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1d/2e616071ec17925a3c5dcf56e3d814f4fafcf38ea29f1d13ff22b4006bc3/sqlglotc-30.21.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:4444f40d9b71f8fade88f3347d9cac80915b2ac7c8b2fa74dcf68ea7ded52716", size = 32700689, upload-time = "2026-10-01T01:29:04.098Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f1/fd493441c16486488756d513f5abdb2e01eceb9dd6b5d7c68fdda79f27e7/sqlglotc-30.21.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14e761ad9eb25560bc944cce7a1f21a364652816615864ad0200e95e0a5709f0", size = 26160896, upload-time = "2026-10-01T01:29:06.907Z" }, + { url = "https://files.pythonhosted.org/packages/83/37/d5050e37d8be978f2d06d1917f8f6f473aaca1668daa4d83030e1c6a9f3a/sqlglotc-30.21.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fd777af74926a6d35846088958f33d36385430c12cfb714dea4707732b0a8e1", size = 27384629, upload-time = "2026-10-01T01:29:09.36Z" }, + { url = "https://files.pythonhosted.org/packages/6f/42/5eaeec1ba43d5f4738f1cf1ea1762b6a9a0fb4f98ea783fe8f6254008d12/sqlglotc-30.21.0-cp314-cp314-win_amd64.whl", hash = "sha256:92cfc4ee59b83eabee6646d88ea83030dcb9c41777a12fcad8805e0ec7486713", size = 11537628, upload-time = "2026-10-01T01:29:11.587Z" }, ] [[package]] @@ -6571,7 +6715,8 @@ uuid = [ benchmarks = [ { name = "duckdb-engine" }, { name = "psutil" }, - { name = "sqlalchemy", extra = ["asyncio"] }, + { name = "sqlalchemy", version = "2.0.54", source = { registry = "https://pypi.org/simple" }, extra = ["asyncio"], marker = "python_full_version < '3.11'" }, + { name = "sqlalchemy", version = "2.1.1", source = { registry = "https://pypi.org/simple" }, extra = ["asyncio"], marker = "python_full_version >= '3.11'" }, { name = "types-psutil" }, ] build = [ @@ -6631,7 +6776,7 @@ dev = [ { name = "sphinx-autobuild", version = "2025.8.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sphinx-autodoc-typehints", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx-autodoc-typehints", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "sphinx-autodoc-typehints", version = "3.13.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinx-autodoc-typehints", version = "3.13.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "sphinx-click" }, { name = "sphinx-copybutton" }, { name = "sphinx-design", version = "0.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -6640,7 +6785,8 @@ dev = [ { name = "sphinx-paramlinks" }, { name = "sphinxcontrib-jquery" }, { name = "sphinxcontrib-mermaid" }, - { name = "sqlalchemy", extra = ["asyncio"] }, + { name = "sqlalchemy", version = "2.0.54", source = { registry = "https://pypi.org/simple" }, extra = ["asyncio"], marker = "python_full_version < '3.11'" }, + { name = "sqlalchemy", version = "2.1.1", source = { registry = "https://pypi.org/simple" }, extra = ["asyncio"], marker = "python_full_version >= '3.11'" }, { name = "tomli" }, { name = "types-cffi" }, { name = "types-colorama" }, @@ -6665,7 +6811,7 @@ doc = [ { name = "sphinx-autobuild", version = "2025.8.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sphinx-autodoc-typehints", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx-autodoc-typehints", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "sphinx-autodoc-typehints", version = "3.13.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinx-autodoc-typehints", version = "3.13.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "sphinx-click" }, { name = "sphinx-copybutton" }, { name = "sphinx-design", version = "0.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -6743,7 +6889,7 @@ requires-dist = [ { name = "google-cloud-alloydb-connector", marker = "extra == 'alloydb'" }, { name = "google-cloud-bigquery", marker = "extra == 'bigquery'" }, { name = "google-cloud-bigquery-storage", marker = "extra == 'bigquery'" }, - { name = "google-cloud-spanner", marker = "extra == 'spanner'" }, + { name = "google-cloud-spanner", marker = "extra == 'spanner'", specifier = ">=3.64.0" }, { name = "google-cloud-storage", marker = "extra == 'bigquery'" }, { name = "ibm-db", marker = "extra == 'db2'", specifier = ">=3.3.0" }, { name = "librt", marker = "extra == 'performance'", specifier = ">=0.10.0" }, @@ -6928,15 +7074,15 @@ test = [ [[package]] name = "starlette" -version = "1.6.0" +version = "1.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/2b/3850dc6bf7ef71b088962eba31dafc6cffd2f96e577ebb0bb316df96da3e/starlette-1.7.0.tar.gz", hash = "sha256:c79f74ea63cff761804fbbfb182f1e0b440c2d07b164d24700c5a1bab5d6ff5d", size = 2736246, upload-time = "2026-09-23T07:30:26.35Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d6/1ec1b290f9e0fb067899b61e1d37a30c923068bad260b216dbe37a7d2967/starlette-1.7.0-py3-none-any.whl", hash = "sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e", size = 78980, upload-time = "2026-09-23T07:30:24.567Z" }, ] [[package]] @@ -7087,15 +7233,15 @@ wheels = [ [[package]] name = "types-docker" -version = "7.2.0.20260827" +version = "7.2.0.20260923" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "types-requests" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/92/ebed321b5e0d5f6b2efc2fee34e99961d1425600d3b1629f0684176bc204/types_docker-7.2.0.20260827.tar.gz", hash = "sha256:6e1674ec12dd4d09d6437f735dcf6b3c61e730c330623aacf92b492e1444094f", size = 36929, upload-time = "2026-08-27T12:07:36.723Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/23/f0b34fc4c81fd172811763e191b19295a3fc9500bb424d97028332414b7b/types_docker-7.2.0.20260923.tar.gz", hash = "sha256:b585cff7ff88720ed63dea8388469a2fd1785ed51d6f8043606513c52a94d121", size = 37322, upload-time = "2026-09-23T06:52:15.995Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/62/40a2b11c2be94d7288d7c0c5ca673d2b9d11f2930f58ec9d5d98380092ba/types_docker-7.2.0.20260827-py3-none-any.whl", hash = "sha256:ecc2b403ec086552a298232b92b662fb195d0e749f155c0b707e59fdedb5bfdf", size = 51269, upload-time = "2026-08-27T12:07:35.671Z" }, + { url = "https://files.pythonhosted.org/packages/81/34/6469781657881c4e1b388f5e9df3cb5f5a4a34eefc9ca7525afad7c4fe2e/types_docker-7.2.0.20260923-py3-none-any.whl", hash = "sha256:d966f9e65708d9242995fc95515e2fb070623f14c3d7df9109266e3d50af41ae", size = 51416, upload-time = "2026-09-23T06:52:14.908Z" }, ] [[package]] @@ -7127,20 +7273,20 @@ wheels = [ [[package]] name = "types-pymysql" -version = "1.2.0.20260807" +version = "1.2.0.20260923" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cf/71/f2d9cf554a59396c9dcf7cd902d1c187b6d4762dc7650e25a02c4ca2cf9c/types_pymysql-1.2.0.20260807.tar.gz", hash = "sha256:7fd4a7767925dbb0fd3e73fd488184fcd98b7612f8066cda280453b17864264f", size = 22768, upload-time = "2026-08-07T04:17:29.272Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/cb/2c4017b0da85cbed15296b0569141f2c56790752aa4facc60ea199db24df/types_pymysql-1.2.0.20260923.tar.gz", hash = "sha256:2d02420956deeed7366c3b2b7dfaac3b33ad800d754327857f7b38f59751a59c", size = 22943, upload-time = "2026-09-23T06:52:06.076Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/83/37e485e7ad3404aa018b6740d759b74665669c61c9c154f81d890b2ccd3a/types_pymysql-1.2.0.20260807-py3-none-any.whl", hash = "sha256:2a722284451649e1c2564655cba4963c0661851d04cc10195b7c002fab8e55f6", size = 23327, upload-time = "2026-08-07T04:17:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/af/f3/75928f071b26151423e7a681defd6eee6fa8181de70cb22ea2211c295d39/types_pymysql-1.2.0.20260923-py3-none-any.whl", hash = "sha256:2042fc2d0745eba181c3070221fdc9ac12baaa4908f79c1cb0f953c8c44efcc5", size = 23383, upload-time = "2026-09-23T06:52:04.912Z" }, ] [[package]] name = "types-pytz" -version = "2026.3.1.20260727" +version = "2026.4.0.20260926" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b1/cf/eae96172a036b942e0ad0ec49512108b4ef4b97cd6c2aed5677540a597e7/types_pytz-2026.3.1.20260727.tar.gz", hash = "sha256:4364075b6867dd15b210bb8c1d29727d609917129b45600defe1d4b3eda5ecb9", size = 10914, upload-time = "2026-07-27T05:36:32.389Z" } +sdist = { url = "https://files.pythonhosted.org/packages/69/42/7edb292ada68a506f85adfc22f1e16a8efa6ef736abb11b557a47c8bc149/types_pytz-2026.4.0.20260926.tar.gz", hash = "sha256:2ad5cde113cd6a58dd59c34f3c93f1bce2d126c769bfac15207ca7d7efd65390", size = 11064, upload-time = "2026-09-26T06:45:48.674Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/24/a654176875944981c75516857cd8750600eeb9ff7fc07a2b82573619a57c/types_pytz-2026.3.1.20260727-py3-none-any.whl", hash = "sha256:42ac44e83645bfceb46597342c8fb8ec028a1407e2feae9c5c539986eab6b57a", size = 10132, upload-time = "2026-07-27T05:36:31.52Z" }, + { url = "https://files.pythonhosted.org/packages/44/db/e65769c124c55958b337069a02a0684259e7e0408cfd18d7ed2e2ea4092a/types_pytz-2026.4.0.20260926-py3-none-any.whl", hash = "sha256:0d0357ffa7680a4416811fb0e56e7a5c0d6efec3f299390d146ea2675b66c774", size = 10173, upload-time = "2026-09-26T06:45:47.615Z" }, ] [[package]] @@ -7406,16 +7552,16 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.53.0" +version = "0.54.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5d/ad/04bbb797c84fc1f26cb171f7394716f4865ffb8d8c5e1eef42565c2dfa6b/uvicorn-0.53.0.tar.gz", hash = "sha256:a9356f0cb89b3b8621529c5d5eebd69bfe154f4c3f68b4cf2de47e45fa855c2e", size = 110881, upload-time = "2026-09-14T07:44:23.815Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/18/0eea75741ee812e9f598b687619ce2454f6c3a1c5cd21ea990ec6bd26f45/uvicorn-0.53.0-py3-none-any.whl", hash = "sha256:e8dca71ec86dce5f04e333f0d56cdedf942446e6643b9cea1af0d6d3a02cb03e", size = 87081, upload-time = "2026-09-14T07:44:22.179Z" }, + { url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" }, ] [[package]] @@ -7551,11 +7697,30 @@ wheels = [ [[package]] name = "wcwidth" -version = "0.8.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3d/7a/f98d4ada7c499565ab0c0fcef28a4e54fafa72b8228a6309803c80493c92/wcwidth-0.8.4.tar.gz", hash = "sha256:2dae09efa25253ae2874188e86d6861af3b1652aef4118cdf3f0bda288a957fb", size = 414609, upload-time = "2026-09-17T23:31:45.287Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/39/cc/9c0adc5861020de426ec71716b6c1bef8d90694b7d6d0a16202cdb1647f5/wcwidth-0.8.4-py3-none-any.whl", hash = "sha256:2097bb1d28a0ba8fe177c2eb607317f9b1627b03f33ebebfd3da670b337a65ba", size = 299474, upload-time = "2026-09-17T23:31:43.816Z" }, +version = "0.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/ac/3a943d2792c9bb368aaa8b50121c0f778460ba2d7fbdc0a0366201d9e761/wcwidth-0.9.1.tar.gz", hash = "sha256:5823209b0d43af322ce698c689380d7c15ca31fa8e6e3be8459f27031bef0af5", size = 952410, upload-time = "2026-09-23T20:23:49.127Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/7c/130de33c0a7f6efecee28fef5711fc1f8d55ac33885a6b85f4f6689004ba/wcwidth-0.9.1-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:10b00ba23482e352f874d2e8135e7ace9da838646c7dd800566246bbd46125ff", size = 604219, upload-time = "2026-09-23T20:23:22.071Z" }, + { url = "https://files.pythonhosted.org/packages/33/7c/c7bb03da54de7ac5a08753e186b963f91847b04ed8ae5be136a4d5f52b75/wcwidth-0.9.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:40d936d72c9bdc10df43f93a8be502bc5024b487259139f66a328722c07f34a9", size = 609038, upload-time = "2026-09-23T20:23:24.941Z" }, + { url = "https://files.pythonhosted.org/packages/0b/71/f57effdf895c2d6d49333f1d6833a9f5438725c55a04ed3124a1c95bb3aa/wcwidth-0.9.1-cp310-abi3-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:69bb970cf5652b88cfdb9d3fdd1764fc15e5f8ad531643e7bb3e894cd969740e", size = 761464, upload-time = "2026-09-23T20:23:32.425Z" }, + { url = "https://files.pythonhosted.org/packages/4f/16/15b03d8fb7a747d0d2cf4a33da7b1b3e092493c58780f6589d5b1e9e3b49/wcwidth-0.9.1-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b5da43d6967668982e44a52fb551967d293f86d26cd86036ac95bbdd34394ed9", size = 770483, upload-time = "2026-09-23T20:23:28.128Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a0/834886b30e3b885a5a96e8358f1b3917bc0bac43ae4178d77f0c210d079b/wcwidth-0.9.1-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:747fb724223f417a17541a95a17c1dac3a8ef9a0cf41684950f0eab191a35f65", size = 768443, upload-time = "2026-09-23T20:23:33.875Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ae/1b7597e0132ebc2c920632f39d8c1c61881345c9b480c0a95eb91e7460fa/wcwidth-0.9.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6e1272b7986cefe79783737e38bdb9eaae0682b333c7bd132024441193dd5ce7", size = 781171, upload-time = "2026-09-23T20:23:29.46Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/32149e3b481953f4ef35f38f166e2532886d33ae54ccdd71393f821dfad9/wcwidth-0.9.1-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:fe021c4d8de9d36c31a0cb41d0d2546dadd3b0708a301f1a0c66ce200851831f", size = 781576, upload-time = "2026-09-23T20:23:35.504Z" }, + { url = "https://files.pythonhosted.org/packages/d6/72/1ee3dff67697bda93bc56ec9f7b3b89609cd64b9abb328512a2f22eb9e47/wcwidth-0.9.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:708158c082364af442f9983de7b6ec9ac0d2e1b825ada25f0911b1f138d55405", size = 783526, upload-time = "2026-09-23T20:23:36.937Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0a/e94f19a60f6127bcf3dfcf223ca05a37650d57b37b5ed623597c842ce7ca/wcwidth-0.9.1-cp310-abi3-win32.whl", hash = "sha256:356376852357b8fca71fe5415808ec421679e04b4a98eb7c9cb6a7984b911a05", size = 592136, upload-time = "2026-09-23T20:23:43.774Z" }, + { url = "https://files.pythonhosted.org/packages/3c/ff/d884d2ec7dcdc86cda742a86c7160bc726dbccae76d7ae1f9ac833c26680/wcwidth-0.9.1-cp310-abi3-win_amd64.whl", hash = "sha256:991d1c8834f548e9c1f16432075ee84638e122312556bbf1ed595ea8fffc4673", size = 596738, upload-time = "2026-09-23T20:23:44.989Z" }, + { url = "https://files.pythonhosted.org/packages/fa/93/16e30d617b937272a2310a6e3c1f9d8f94984e19ad4a96eb935628e3e6b6/wcwidth-0.9.1-cp310-abi3-win_arm64.whl", hash = "sha256:61bd7aef9cafb6cb77a37a169998d7928ce82a51522146d11f60db9e7d1cb43a", size = 594700, upload-time = "2026-09-23T20:23:41.013Z" }, + { url = "https://files.pythonhosted.org/packages/0b/fb/bf93868205814f03d63e5b91757cf5bee99e024ce951c2e91fdeaab49c59/wcwidth-0.9.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:dc10e262c3ac0abbfd0a2a51e45a848b1b7f500b21ff512630277973ba25674d", size = 605391, upload-time = "2026-09-23T20:23:23.602Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/dd3ae682f8ab13b1bffd8d52320d2f7becc16e3200580015f52124636e3a/wcwidth-0.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bcb9ed4a367cc025bf1092ac679a156759605182d60b7ed3c28f7221a42ddf55", size = 609851, upload-time = "2026-09-23T20:23:26.583Z" }, + { url = "https://files.pythonhosted.org/packages/56/e7/4f3a86953ab1f59eac15585795f7753da99370798f75b134d3cbf252b243/wcwidth-0.9.1-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:0d68a30d504c68cfdff2a5f804675c1e7ab4c0bbe878024c8b680ec5579cde67", size = 774751, upload-time = "2026-09-23T20:23:38.473Z" }, + { url = "https://files.pythonhosted.org/packages/48/0c/a456332bb581ebf3eb1a7dedb1492747b15772ce82c0717cc05387a64a17/wcwidth-0.9.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9f1636c5075ffd5c2e835b4561874f6e5dd2bbeba3c6c2c99f067d0d16883af8", size = 785798, upload-time = "2026-09-23T20:23:30.998Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6e/a35de089370d6d872c5424d234e00a8580e62c1bff518ff583750e7b15f7/wcwidth-0.9.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c4cead196551112cb8f43cdd1f80c235ef2456e34b1a9955c424537ec99961b2", size = 782933, upload-time = "2026-09-23T20:23:39.672Z" }, + { url = "https://files.pythonhosted.org/packages/ed/25/e39b6140d05f3aa8e2a6cbb2d679cba5767de7ac0a4860d1da246b6c36b3/wcwidth-0.9.1-cp314-cp314t-win32.whl", hash = "sha256:0665ee822ea04e25801e6e82e5407528be0863e88a17b0e7ca038843a4a3ac0c", size = 599829, upload-time = "2026-09-23T20:23:46.221Z" }, + { url = "https://files.pythonhosted.org/packages/12/ad/307b55d8e0a9484c48e8f78f40e9b9a4a9af0c9e2e1c5209ad6d5a09c77c/wcwidth-0.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eab587e18e7cadf1a750b0098fc8bebfb62c125eb9306f2268f0443a282a3d78", size = 604398, upload-time = "2026-09-23T20:23:47.672Z" }, + { url = "https://files.pythonhosted.org/packages/36/f1/69566660f633e2a93df7dad7353761562e9f71bf8a5a7b1eb7f34793b45d/wcwidth-0.9.1-cp314-cp314t-win_arm64.whl", hash = "sha256:03cfca3dcbffa86564290fe3c9978a6191ba003e8ced7f7dbda315fcb3fbe725", size = 602291, upload-time = "2026-09-23T20:23:42.345Z" }, + { url = "https://files.pythonhosted.org/packages/96/a2/f06f2e0be4895e5943d358755b51968ace85774cf0a4cc42b4dafb793832/wcwidth-0.9.1-py3-none-any.whl", hash = "sha256:e0c3a1c45c5b9550c6919a4449e95f5b177f6e165786376981db8f1addae9b21", size = 300656, upload-time = "2026-09-23T20:23:20.674Z" }, ] [[package]] @@ -7619,111 +7784,111 @@ wheels = [ [[package]] name = "werkzeug" -version = "3.1.8" +version = "3.1.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/34/4dd12fc8bb7d61c91467ec3efe415ffa7d5456f799954b40c5bbaeae470e/werkzeug-3.1.9.tar.gz", hash = "sha256:55ca7c70a75689be937aa27f8ff4b018f06ff4838fc73045560bf0f5a1291060", size = 940188, upload-time = "2026-09-27T18:33:41.637Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, + { url = "https://files.pythonhosted.org/packages/a1/38/df03f564f43cec2684823f3cccae1a652ee7face1cbaa76fb223096e64d7/werkzeug-3.1.9-py3-none-any.whl", hash = "sha256:6392e50c78460ba618e5b21f08a71f59c99ce99cdc6cf6e3dd7e6ccca8754fab", size = 228700, upload-time = "2026-09-27T18:33:39.685Z" }, ] [[package]] name = "wrapt" -version = "2.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/42/a6/6375d56c44d590ef24acf0f8f5bf7ed768ff7a510b959306ec412611e90f/wrapt-2.4.1.tar.gz", hash = "sha256:fd6390aab9e8aa40c52eff3c180f098e8d9f5894b1fd4c4fd2c207067b33ed16", size = 164597, upload-time = "2026-09-10T23:12:16.811Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/45/fe4524dea5a15a6f64522745a96618fb2f874c11c4060848abacc4f85232/wrapt-2.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7aaff952dd6930fc87b52d44bf72ac8426e45e90058fe0f2da446889738eff44", size = 97739, upload-time = "2026-09-10T23:09:36.872Z" }, - { url = "https://files.pythonhosted.org/packages/e2/cb/08025a4402bd6f34ac7c07e4d5c75e4f32e595cd7424d697c6fce1663bc3/wrapt-2.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8d67b916f2f777a6d31067e842598e059f1a0e29022c86454564fc9efb9a0c19", size = 98237, upload-time = "2026-09-10T23:09:38.82Z" }, - { url = "https://files.pythonhosted.org/packages/3c/e9/2c1a482052cfabd6906887096993099afa2db738e7597c30e4f2b9922718/wrapt-2.4.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2945f4bce1518e09eb25458dbd09800f7aa3fee81a5a5cefe66c4e82fe8cb69c", size = 217028, upload-time = "2026-09-10T23:09:40.259Z" }, - { url = "https://files.pythonhosted.org/packages/fb/0d/867f5a1955de8e04edfbdf32891376443629d94c3e31ae2ea2ba101d6d31/wrapt-2.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a7ce62ee24eddb76c317ec790c44f4fbe9f34ee0a5dae37b30c65a2b8f3e3604", size = 219323, upload-time = "2026-09-10T23:09:41.701Z" }, - { url = "https://files.pythonhosted.org/packages/42/21/af01e7d55ce47df290e60cc4c0acccd439ccb95f30be63e46ba013e4f212/wrapt-2.4.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cbee8d00840ac89846f6567c75506260a15fbb4dc74de180678fafe09458d47", size = 208024, upload-time = "2026-09-10T23:09:43.318Z" }, - { url = "https://files.pythonhosted.org/packages/89/24/9ae249d4922408cb28bc31e8dec86e918350261cf61e38fd8bbfff8bfd66/wrapt-2.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b78dd8d058468156003ee8c212dab334b6eea18cd9c5db3b81f6c90a9408afcc", size = 217712, upload-time = "2026-09-10T23:09:44.739Z" }, - { url = "https://files.pythonhosted.org/packages/8c/a7/34b1e65e490ae886b057ac813e86e16399b7dae91166b5bde5fc9f86c2ee/wrapt-2.4.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4d48f1720569b4f6e2df97783fd270554abc4c6ebd74b2e5f15e5110283aaf78", size = 206245, upload-time = "2026-09-10T23:09:46.07Z" }, - { url = "https://files.pythonhosted.org/packages/bb/e5/8b5af45e8e7dc102b78a40b077f071466a0f584898fe57ce181bbca2572b/wrapt-2.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:de5d2d7f12557c9e994b4037330efa5700fccb9f2b9df0bd4ccf384eaf73c254", size = 206937, upload-time = "2026-09-10T23:09:47.328Z" }, - { url = "https://files.pythonhosted.org/packages/b7/47/06e483462e90a50250f51da3ffa1f1c67e457910078b36aad55805202886/wrapt-2.4.1-cp310-cp310-win32.whl", hash = "sha256:5c0217b8c12952bf4d137a19ff2c61cc9b626f52c09c43f0289769c9f5f808aa", size = 93405, upload-time = "2026-09-10T23:09:48.64Z" }, - { url = "https://files.pythonhosted.org/packages/2a/01/c200bbabb0c46a431c9d965fff78e6bf08c1598fa970bdf105362aeba969/wrapt-2.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:0aacc1af512e040fb0b3c4593a8bded9aaa04235025f08a54eda9d8c1103d3c6", size = 98454, upload-time = "2026-09-10T23:09:49.771Z" }, - { url = "https://files.pythonhosted.org/packages/e3/04/c7eace579b4c397aa3651c550c216b05d3561cff56bcbeb726c8ab3faf4b/wrapt-2.4.1-cp310-cp310-win_arm64.whl", hash = "sha256:6f32a45d0e883918387aabf5384952e5a93ae40be0a6fb5f1eddc8281af4c714", size = 95247, upload-time = "2026-09-10T23:09:51.119Z" }, - { url = "https://files.pythonhosted.org/packages/c6/c6/5e08ef1e8ad5ff518354db77b7a303666c7e0652e2a569116b5ccb72b690/wrapt-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b6ea396edcfb305698f44ef0a47b9adaca15d30386ce093ca606844f776d7236", size = 97984, upload-time = "2026-09-10T23:09:52.569Z" }, - { url = "https://files.pythonhosted.org/packages/4f/ec/e77ba77fdce732f39522382a6a9b968e5abf3471f352d51670120534e271/wrapt-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b86bfe741840cec6d8d5858b1eb53a325f564f08d7e6fb5694981961edcec230", size = 98241, upload-time = "2026-09-10T23:09:53.72Z" }, - { url = "https://files.pythonhosted.org/packages/7b/af/c54bd0bf32ef3c5f6d2d50af6bd9d88c040ef53d9f8e113e065337d76788/wrapt-2.4.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ec7aeb92e956810eedc268fe75f8c11f05d6c3d864d0995a53eb6623b3b6a51", size = 226167, upload-time = "2026-09-10T23:09:54.946Z" }, - { url = "https://files.pythonhosted.org/packages/00/3c/934e13c7e27680760fa74e25e4b291b213fd607aa42333d883f732de6d73/wrapt-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba66eccadd4b857a845e309c08457bb71fa8300000e508e3a86cd695bf9c7515", size = 228439, upload-time = "2026-09-10T23:09:56.272Z" }, - { url = "https://files.pythonhosted.org/packages/cf/5e/7593f3fe527260ff3e8eb09ed26ff8f0bf51cda5702a75fb5969b689b4a2/wrapt-2.4.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3d5c8f3b4eae814213d4097f134be03aeb23816e6358213454ec5e62e8449fb6", size = 213495, upload-time = "2026-09-10T23:09:57.622Z" }, - { url = "https://files.pythonhosted.org/packages/42/a8/d4b4d6ac44cb2c7d4a882499786fd96758c97feb6b1539ba0ed5c823849a/wrapt-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3bc7ab495e564449f39db2f54c1033d7190c944736d525f3bd06cefaf0134df0", size = 226108, upload-time = "2026-09-10T23:09:59.132Z" }, - { url = "https://files.pythonhosted.org/packages/05/b3/aedc046bbd8ff2333c2ce9d5267db42117221d4192ac13c66e871b528d9f/wrapt-2.4.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2e2e694057bcfd46f43e28f7b50fad8f5fec7113cc6445fdf251219539a08f28", size = 211144, upload-time = "2026-09-10T23:10:01.141Z" }, - { url = "https://files.pythonhosted.org/packages/96/60/c01efb0e8049a9df379b04ebc589eeef995d6cf184b1d8be9f1b84b4d5f7/wrapt-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6f5577fdc702427698c81c8c9c3b8479cbecb3dfd0f697be8c1d09c89f5838cc", size = 215496, upload-time = "2026-09-10T23:10:02.778Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b5/26214472915eb447725982e2735c593131c5bae0c3fd582ad61e7b965907/wrapt-2.4.1-cp311-cp311-win32.whl", hash = "sha256:e78c0f6d66a564bab245faf170d55595df6fc237117b99d97fd838678f0906f0", size = 93493, upload-time = "2026-09-10T23:10:04.196Z" }, - { url = "https://files.pythonhosted.org/packages/d9/10/5f7d2dd40c97c5e572dce771b8d4c1c78d46a90cfc24c7b25f158253215a/wrapt-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:1f1851be0e593d65e68d7c1bc9d3b971e19fefa219f617e490d75e80e467ee14", size = 98679, upload-time = "2026-09-10T23:10:05.413Z" }, - { url = "https://files.pythonhosted.org/packages/a5/6a/f7565da9ab1323b2aa5fb4a83b919b6519cc0f4ea4b91ec8f2d25f5bcbab/wrapt-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:7ff549316d94c404999f96bb0a6993222566231ff4bede1b6f68908d1958a08f", size = 95071, upload-time = "2026-09-10T23:10:06.679Z" }, - { url = "https://files.pythonhosted.org/packages/9f/1f/a2f3225c5ecf522684c1d051aea8ce8253f240e55be75826b787777afd6c/wrapt-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7e86fbc2ac8a363ea04abf631fad82720e16b17a25020f32dbe9b24a2ed2b0e3", size = 98890, upload-time = "2026-09-10T23:10:07.876Z" }, - { url = "https://files.pythonhosted.org/packages/68/6c/eb45660fd4d92cce11ec923f55bb2e647a6c18d30e53734eb07a3c530e31/wrapt-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:24389748f0b9d5b67e478fad4fc8b3f1108422ef80716e48eead6cebcebbff08", size = 98742, upload-time = "2026-09-10T23:10:09.236Z" }, - { url = "https://files.pythonhosted.org/packages/a3/4f/17a89a580cb0082e61b8375074d5c9e5d38e4aa83ee19b6174ee472d17c2/wrapt-2.4.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:30d11c289b013bf384ff1a1a6553f150d0b855901708a9bef667a5680f8247c9", size = 236301, upload-time = "2026-09-10T23:10:11.039Z" }, - { url = "https://files.pythonhosted.org/packages/01/ca/4700eb008a34bf02de328806ddde15fc84c8d1e65d3dcafb92a935a50319/wrapt-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9356dbb59199a0e4709de35fa4a1ac1a88ef6da99711a397f5b009233faff326", size = 237805, upload-time = "2026-09-10T23:10:12.594Z" }, - { url = "https://files.pythonhosted.org/packages/75/5d/26c1740299b29e190d5f4b4a99eb001401042a9d9ab338e3f8e1ce140ecb/wrapt-2.4.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8342f332dada211f64b74609e332d727b13315e9a83177f7918bf68c59f815f2", size = 217037, upload-time = "2026-09-10T23:10:14.028Z" }, - { url = "https://files.pythonhosted.org/packages/3a/55/ec72991153a2ae8b40238bc44cec7c3ddf7706ef6e2d314b0c6f5c7febce/wrapt-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2f86e328c482bc5383b4eda5094be0bed3617fc3076aa9225ff1a9eb6372de9b", size = 234659, upload-time = "2026-09-10T23:10:15.384Z" }, - { url = "https://files.pythonhosted.org/packages/51/32/7cfa1e070dcda76ea56a3e252341cba1cd1e9412baf23cd7adfebf1114e2/wrapt-2.4.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4dc92697444ee380544fbb43c86612d8486529aadf917c22b5524141d4af074c", size = 214590, upload-time = "2026-09-10T23:10:16.744Z" }, - { url = "https://files.pythonhosted.org/packages/79/10/248841cb30107f6f32c53a02662e1c3e0c7c06bea0b8ebfaaee94885dcee/wrapt-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:edd03758a7578526642508b8833d43496fdfba0f64e0025dfca153a7c1777735", size = 225103, upload-time = "2026-09-10T23:10:18.346Z" }, - { url = "https://files.pythonhosted.org/packages/90/02/5b2bf7b35b008a39939a2908e85dba3b867596e535fc9f12ddf3ba1fcaf6/wrapt-2.4.1-cp312-cp312-win32.whl", hash = "sha256:5d83e412665aeb1e854eefbf1564d0d67872d9994b502a0bce96e6ff7f4970b7", size = 93441, upload-time = "2026-09-10T23:10:19.81Z" }, - { url = "https://files.pythonhosted.org/packages/d4/2a/47be56772bfb07ef242d6e924049688e38384af2b2fc99b0f31180988448/wrapt-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:b4e7efdd476ac631a0181551fd9aace844765ea3ce2b5133b194fae4421e8ad0", size = 98808, upload-time = "2026-09-10T23:10:21.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ec/40e4c9735626afd1dc0eeb310310278361f192800503cda0f5b3d8d24db4/wrapt-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:38819761401baa2d11916d7265b82f23265f8fe5a31c431dd7c24a8863c65f88", size = 95240, upload-time = "2026-09-10T23:10:22.39Z" }, - { url = "https://files.pythonhosted.org/packages/5c/1a/9b5aa3c2391aa6d00fffa085c2219e8fc91cd4d2b9b080d2b4e4b6b93f42/wrapt-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:55f36bb1461f93beaf18d818e568b5de343dd8fade7456773d201a38ee723bd3", size = 98597, upload-time = "2026-09-10T23:10:23.723Z" }, - { url = "https://files.pythonhosted.org/packages/fe/07/dc98150c2f9ee5b5fcbd841765e178ea6cc6c43733ab8d1f5181a4fb9f3d/wrapt-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:53e15cd74bd6b84d7fa90b93dda7334d85f4641fae97632de8aa61d268dfd145", size = 98842, upload-time = "2026-09-10T23:10:25.109Z" }, - { url = "https://files.pythonhosted.org/packages/08/c2/0e772a570e8d75c1b3ec45930a3023492fa68223f8f5e3485af4844c10c3/wrapt-2.4.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:be6cdd7121adc89a6f52e3c2f4e26a2d4dcdc1c0fde47e3156234db8939e4cdc", size = 234642, upload-time = "2026-09-10T23:10:26.644Z" }, - { url = "https://files.pythonhosted.org/packages/76/25/4ce4d02dd95ff9ed972a2fc396d04fec636daa5a4ac18cc73a3dc20aa6c3/wrapt-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:03b5598edd435373278731d0d53449ce7a9626bc48d5548e4a71124ee3e526a1", size = 235484, upload-time = "2026-09-10T23:10:28.098Z" }, - { url = "https://files.pythonhosted.org/packages/fd/80/436ef1df620ef8edd9ef057b4d55a0cd704435ff66852a0ef26cbaa02a58/wrapt-2.4.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fdc997819a6df4c65bdb0a1c5601c98b479ee34cc2f94ffa98a767034ad6366d", size = 214371, upload-time = "2026-09-10T23:10:29.841Z" }, - { url = "https://files.pythonhosted.org/packages/6a/2c/cc5b7503843399087db3106a7cf60d60d0900f69539e96148b9fff116291/wrapt-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3bfc6907ebed560d2d3f677c3b17bf6199679163b6c6e475035c9dca497d1697", size = 232347, upload-time = "2026-09-10T23:10:31.511Z" }, - { url = "https://files.pythonhosted.org/packages/86/fb/17668b1ca572c44b458c09d76064d35f5f57d5ac7629248871604bef816c/wrapt-2.4.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a0c3b217332cf0c4df085fec41c126bf7507d6c1ca0efb3ba8d2b3fd234d4e73", size = 212792, upload-time = "2026-09-10T23:10:32.968Z" }, - { url = "https://files.pythonhosted.org/packages/b8/2b/0c5de10d7259e07c478c44bf2fbe179c6878727d09edf0632b97884801db/wrapt-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a23b89621cfeb3329b1a290596bf402e61d7d5a647a65ca8ea735e48771b71d4", size = 223585, upload-time = "2026-09-10T23:10:34.43Z" }, - { url = "https://files.pythonhosted.org/packages/aa/36/013dce1c687f8f1c87b1e78f403d04c80ae9b2ae77de4ddba16069a3e7d1/wrapt-2.4.1-cp313-cp313-win32.whl", hash = "sha256:bc67d4872af5ab2dc1b88904097b92ac00e7658fdf010dee36807807fe882ac4", size = 93425, upload-time = "2026-09-10T23:10:35.901Z" }, - { url = "https://files.pythonhosted.org/packages/b7/1d/d53bcf5910209a45191c8bc173ff0e00830416547d8038772dc444932ee0/wrapt-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:1fe758b9c2d49138231ec3efabd106fec665f86fec50b3d02d7edd75f08a69ca", size = 98569, upload-time = "2026-09-10T23:10:37.316Z" }, - { url = "https://files.pythonhosted.org/packages/e8/1f/759d0c522918f9dfb620136622d8e1ce619570adda0de8fa2cfbb55cbb86/wrapt-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:0c974c36e8205255a3947dad9c2fe000431b57dd522946e56c192174a7f92a0f", size = 95272, upload-time = "2026-09-10T23:10:38.657Z" }, - { url = "https://files.pythonhosted.org/packages/08/05/ed5aa8991c5e9969e2ca17f0e44eb324a9757f44fa982bfc13844cfe9e1d/wrapt-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c6c35541cc729964c65c2b9b1f9cf317811039abc2da13b4557f20f21cdc292b", size = 98876, upload-time = "2026-09-10T23:10:40.078Z" }, - { url = "https://files.pythonhosted.org/packages/f1/5f/f8bf07c3b9a2ad01d28d5ca419e28819bb16937d129167d97c446c05875d/wrapt-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6b9df84f0a96763159cccb8e3b0ed83cc950c7c8bf82d6e45428372a805b3224", size = 99054, upload-time = "2026-09-10T23:10:41.484Z" }, - { url = "https://files.pythonhosted.org/packages/f5/7a/518e5f3ebd18472652e5332bf37728cd6634ff81a0aa568969d05cc66099/wrapt-2.4.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78af62413095d0a57077606654ce85e273deda8e2bfa28fdb04257b962d94ef2", size = 237430, upload-time = "2026-09-10T23:10:43.007Z" }, - { url = "https://files.pythonhosted.org/packages/8a/2b/503835d183c1ca99268e0b5a98af8f487d528ebabb124f4003cd96e73b1f/wrapt-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d60702ebc914d0bb01aa48f5c1785ceaaaa505e0a841b444a69d6ceb5de4097e", size = 237988, upload-time = "2026-09-10T23:10:44.466Z" }, - { url = "https://files.pythonhosted.org/packages/fa/9d/a11a3e1eb18ae1d9adef1c39f8e8e36fd5b86db33938bebf8e6bc5fd9b06/wrapt-2.4.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8c4b44e4be7680fc496816e824b6ed134d781c4da296e44b75399196e5c248f1", size = 218554, upload-time = "2026-09-10T23:10:45.969Z" }, - { url = "https://files.pythonhosted.org/packages/e1/61/61a6f983737ba81008561ab634373bba25b986761d3a0f0aded8352ae3e8/wrapt-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fb5b3f94258bcf71db902795f4a71151c7a74d9fe21fffa00752d2bd286866f8", size = 235476, upload-time = "2026-09-10T23:10:47.528Z" }, - { url = "https://files.pythonhosted.org/packages/42/68/acb7cba46e0f662eaae421487807d2fed28ba52df9a00a3f04db16675e38/wrapt-2.4.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f1556a96b20d5bfdc9cb5dcd0a3ec65cbbac8a4eebcd3cd34efdc91c9519f660", size = 216454, upload-time = "2026-09-10T23:10:49.126Z" }, - { url = "https://files.pythonhosted.org/packages/8b/52/3a4dab8d4803df595de82f19775535d28c036746340c1b498fc8ecba39aa/wrapt-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:094a606d0bf1c4b847b3a743d22fc69cb164015b8c70cf6f20a534d30889ed47", size = 225346, upload-time = "2026-09-10T23:10:51.052Z" }, - { url = "https://files.pythonhosted.org/packages/5a/48/d72d051757fb7955021d8174014679f951fd5067187d2faed95520e1f8d6/wrapt-2.4.1-cp314-cp314-win32.whl", hash = "sha256:b0ee076be124406a7f97ca663c4a3ba32b6bcdd9102ca82497feaca79e9cb33c", size = 93870, upload-time = "2026-09-10T23:10:52.495Z" }, - { url = "https://files.pythonhosted.org/packages/a1/00/1a1bf4d8b60b9e7ac009969595438549ae6e33b2e3e174b78d26a833aad3/wrapt-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:d2d6f9abaa52de05090a2b4a4c1d0e858a268c4de69eec277506af9fbcccff91", size = 98910, upload-time = "2026-09-10T23:10:53.878Z" }, - { url = "https://files.pythonhosted.org/packages/1d/59/b3169c3bcdcff70a6d02ed2b6366097b083d31391d616c407efe9a943a82/wrapt-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:3152b2e94d733a9bd70dbd1c95f148266f079a70e9ffca2a9c5dc421ade1b4e6", size = 96013, upload-time = "2026-09-10T23:10:55.607Z" }, - { url = "https://files.pythonhosted.org/packages/b1/91/d29a063c2ca6e779305cd444dd5acaac60833d73b2d94c7b1d0820c27e3e/wrapt-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:37ff91b390400463ecd4d080ea823314510b6843ac53e6e35cc09bba1805d466", size = 102069, upload-time = "2026-09-10T23:10:57.179Z" }, - { url = "https://files.pythonhosted.org/packages/72/26/7435de516099b528e2232770b459d6706002736f41d8f092bbb11ea01575/wrapt-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e0a518cac3e789443af54fc77f23e66f17d80192c281b160b42058f11fabccc5", size = 102647, upload-time = "2026-09-10T23:10:58.852Z" }, - { url = "https://files.pythonhosted.org/packages/7b/a7/cca7fe0f26aa11cbbf09b2e6f4f774db167feeca43ad22fa1772afdccbad/wrapt-2.4.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ecdeb8a1ec13397421e6f925412186bd87ab86d63517e6825b6d7bccf781f26", size = 275614, upload-time = "2026-09-10T23:11:00.464Z" }, - { url = "https://files.pythonhosted.org/packages/77/22/f3b5f4428ae679b1e2c0d4833519045d8983a9df0ad24df96b195d44dd1b/wrapt-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c15c4ede3fde08723cabab0a892d4b75d35b5a6f0a51c84e6542ac0bdc0507aa", size = 287064, upload-time = "2026-09-10T23:11:02.111Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b8/24274ec01b6ab6d0672f4312bfa29b5f1ffd9d56f16c8da67d5683883956/wrapt-2.4.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e32c5951c36fed88b6c603dc0bab209a62dc3217bd8762413634725fe28f2f3c", size = 255440, upload-time = "2026-09-10T23:11:03.746Z" }, - { url = "https://files.pythonhosted.org/packages/8b/df/c63a36f0f03d70c7b92f60ec1ad3757088167914e1a262f5dcc7df233d51/wrapt-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:898513db90d55a4ed3009312d41c12932b8163edbae3535280046d47aaff774f", size = 281681, upload-time = "2026-09-10T23:11:05.41Z" }, - { url = "https://files.pythonhosted.org/packages/bc/c7/4930b6a369d9b3da59f5ada4ae9e659e2d4a68fe72c5d1ce15e5bead2d24/wrapt-2.4.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:42d01574bd4bcafc3476e77a95c6c0dd6167101991401325fc5591e2db21a91d", size = 252409, upload-time = "2026-09-10T23:11:06.867Z" }, - { url = "https://files.pythonhosted.org/packages/68/d7/f6bc5703061598c4a2ad4f7a2efce696782be6e9d5afed714a59f5902ead/wrapt-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:68a403adbeb4dd2654d6d108e43e69f0f90e6d34cb7588e0e6589109f6985e67", size = 270559, upload-time = "2026-09-10T23:11:08.385Z" }, - { url = "https://files.pythonhosted.org/packages/68/a3/952337a153e634f530079334c47dcc935b20f8ea22f364729fc2a8cf1821/wrapt-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:7cb3035b332bc9d21478600ba82c7c71e2d074ed9b4e76364297f54598792227", size = 96241, upload-time = "2026-09-10T23:11:09.862Z" }, - { url = "https://files.pythonhosted.org/packages/09/28/48f0651547a125dd84e312b86268d4150c565a29f81ff9ca6fd81eba4be4/wrapt-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:707d2bef68deddd0fc74a81103d91b286a69ac5a9ff0f0a6dad66f8787e86697", size = 102655, upload-time = "2026-09-10T23:11:11.388Z" }, - { url = "https://files.pythonhosted.org/packages/21/a6/977441bed5e5afbffe7a789067ac2f5fd2e9f848884122a23db9ccae6a70/wrapt-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:28cb1c2713b4377bf03ddcb3e76d8216e4bb334199066c6122be7eed2f72de8c", size = 98495, upload-time = "2026-09-10T23:11:12.952Z" }, - { url = "https://files.pythonhosted.org/packages/7c/1c/03bcbc3dbf6ac11231f0434f2494aa7b80657b130be1a1a639192a3e44c9/wrapt-2.4.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:96a5023fa63ca2f095f7776c8bfaa1694547577f762646c375abafd8f8ae649b", size = 98878, upload-time = "2026-09-10T23:11:14.506Z" }, - { url = "https://files.pythonhosted.org/packages/87/26/5ad9bd421b8cdce0b2227a61f09586423765da3c284f8bbf0f69fc149fdf/wrapt-2.4.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b21924949dedc3ac63725e09b9b0a130e771975f03432789344ed3422c46008", size = 99090, upload-time = "2026-09-10T23:11:16.101Z" }, - { url = "https://files.pythonhosted.org/packages/a1/c7/fa768261966d587650004ac37bef541540014b1fe23fbf4919c497ebc98c/wrapt-2.4.1-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2f725af353bb3319c528ee69dbff838599426974288b281a3258d5397b18cb63", size = 237803, upload-time = "2026-09-10T23:11:17.738Z" }, - { url = "https://files.pythonhosted.org/packages/0f/37/9c1c876bd88fef8d065b3e8bf125cad9291d70ed3695cedb41b36b2453ec/wrapt-2.4.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20327e162ef7953fae56b31a43ca457f94ed1fc7a206d01e3d5c8412d1b91572", size = 238338, upload-time = "2026-09-10T23:11:19.742Z" }, - { url = "https://files.pythonhosted.org/packages/91/5b/47b2f2f08b90d9d3b4e1790ccae0d4e4de40e73f6614c9a52465a04f02a5/wrapt-2.4.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2cedb743dbdfb9b6d4f11acd8cb6329460264429777e81ea2e86b1b72ea503af", size = 220668, upload-time = "2026-09-10T23:11:21.422Z" }, - { url = "https://files.pythonhosted.org/packages/43/1f/da7844c7a3f7c01f3496e690e15ce9d01c0949278c64d4287ec472c90f72/wrapt-2.4.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:5a54744b1193505f19016194979b773ee3754a199e9ad90db18f2e9a18fffa16", size = 235812, upload-time = "2026-09-10T23:11:23.211Z" }, - { url = "https://files.pythonhosted.org/packages/69/af/ac4aa9f795721a54a21bac329201f7df1b199b71ef064187a266d4d29836/wrapt-2.4.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:b0d38d9cc23e9781e6584f303e5c5a9f0d45b85de92ad45678c4dde8d49d9535", size = 218195, upload-time = "2026-09-10T23:11:24.983Z" }, - { url = "https://files.pythonhosted.org/packages/78/aa/cff7670ec1cfa75b951171cfb41d45c7bdb94e928620a1599ec2b00cd2dd/wrapt-2.4.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:a524ca32f0bcdde2b728d9f81f9527d4dd24b13f15d180f05d08cdc01d1cebe0", size = 225713, upload-time = "2026-09-10T23:11:26.576Z" }, - { url = "https://files.pythonhosted.org/packages/77/c6/64f138aef50b5ea1dfacca2a023b32ba3e41ef087bbd92e8997a95f4a0b8/wrapt-2.4.1-cp315-cp315-win32.whl", hash = "sha256:47c267617551e906de72f6e7265aa3bce84c63d44513d2d2e735e943422aa0a2", size = 93880, upload-time = "2026-09-10T23:11:28.253Z" }, - { url = "https://files.pythonhosted.org/packages/8d/aa/afe7484a0f7232e87f2ebb65286c1025d43cbe1de4e4051b127b024a92f7/wrapt-2.4.1-cp315-cp315-win_amd64.whl", hash = "sha256:ee437bd7fd050823ef731aad20e968ca8fe670b95e1841f5d201bfdbad4a4e96", size = 98919, upload-time = "2026-09-10T23:11:29.832Z" }, - { url = "https://files.pythonhosted.org/packages/83/21/6865f976c6a1082ca61e2792bc6a2d6a0ed03cd750a46f837753127191f8/wrapt-2.4.1-cp315-cp315-win_arm64.whl", hash = "sha256:ebf3b703752b53366fd02b7fbd8c447428e0349c9af3fc17c7a05076dbdd749a", size = 96021, upload-time = "2026-09-10T23:11:31.448Z" }, - { url = "https://files.pythonhosted.org/packages/8d/48/baf784cc9f1fe554f96daf15af06c7c466d76469d5540e357cf564755606/wrapt-2.4.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:0f5990f5db090f069fcd577cc61cc8d463db73cde132abba9033b0a264fc06d0", size = 102028, upload-time = "2026-09-10T23:11:33.102Z" }, - { url = "https://files.pythonhosted.org/packages/82/35/fb809c95bf5512196aeda4c640aabf4e92f607340b8b2556555690a52b7a/wrapt-2.4.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef19a2590b195ac294deff8ee350a027a479afdd2ef2170ce3900af92406e110", size = 102640, upload-time = "2026-09-10T23:11:35.145Z" }, - { url = "https://files.pythonhosted.org/packages/a1/3f/bd462010ccbbe298479f320bf2bb02996706003e9c20a15790b65a186f8b/wrapt-2.4.1-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:432f402f9b6014403cacf9fd18a6bf089c77964330eae951a155131ab0414f5d", size = 275999, upload-time = "2026-09-10T23:11:36.863Z" }, - { url = "https://files.pythonhosted.org/packages/56/1e/bd043b2bad2943336e090583ff6cea2f3e8776bde02f073ccf760b16eee1/wrapt-2.4.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb7c0f8bdd21e954bad89d554cfb7431c2f8179842853f199841ab90cbebe914", size = 287576, upload-time = "2026-09-10T23:11:38.79Z" }, - { url = "https://files.pythonhosted.org/packages/1c/d9/9937bbd61ca4e7f58a7e8a38f5ff3562334f741de51b87f0e031df441254/wrapt-2.4.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e82a1669d63b79a2b53041bbb6ea096b5763cab3ca698ed7ac244b3acb7cd51", size = 256763, upload-time = "2026-09-10T23:11:40.668Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a8/0f78cc7e7fc6313cdba32df06f4b29a45a4a2aed3499040d6c1628d3f339/wrapt-2.4.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b19e71c914c435d2c5652caea386c9bf2807979e957183f5bb06d5ed5fa8b55e", size = 282244, upload-time = "2026-09-10T23:11:42.483Z" }, - { url = "https://files.pythonhosted.org/packages/be/0d/30317d738b9898a7fbedc193657f151a468b0fa7235f4abd58a646a99cd8/wrapt-2.4.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:a1e4870d3368c6c918f38e308d5dcea970ea5b908ce889c21412f3a517ebf0c3", size = 254153, upload-time = "2026-09-10T23:11:44.357Z" }, - { url = "https://files.pythonhosted.org/packages/12/53/fe1f251a89f471ca7c5e17f2edbd8efd48fea6f1c08430dcf60986ea4183/wrapt-2.4.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2286e8e4a937966706463d1bcea30dfefee529e6e73e097a18e9e066a07ae6c2", size = 271234, upload-time = "2026-09-10T23:11:46.132Z" }, - { url = "https://files.pythonhosted.org/packages/bd/7c/d63758cd7fa0d18c415a87312a07cdd9f0102eb824aaab5c7450b3ec08e5/wrapt-2.4.1-cp315-cp315t-win32.whl", hash = "sha256:80afa3b7010e82899044a2a189c468a0cd8c89980398a59ba3b96b451a6dc5e9", size = 96236, upload-time = "2026-09-10T23:11:47.942Z" }, - { url = "https://files.pythonhosted.org/packages/f7/2e/1785e6d2696db08b07c053b1f4ccb2b5e8f9d12e9bf2c7497693be6b4504/wrapt-2.4.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3593b43fabab6b59fe77e38f7e40974aae120c30cea3fd1ceaea6621ee96be00", size = 102662, upload-time = "2026-09-10T23:11:49.831Z" }, - { url = "https://files.pythonhosted.org/packages/2f/fd/3f4ac5c4754948355e050f952ae7a64cdca94891e49fe9927c3f90a73334/wrapt-2.4.1-cp315-cp315t-win_arm64.whl", hash = "sha256:ac1939ccf3e1c33f463706fbf52db5075f5eefb6042bcc1f9cf48c2c20ef478c", size = 98500, upload-time = "2026-09-10T23:11:51.501Z" }, - { url = "https://files.pythonhosted.org/packages/4f/a2/edcfc8d9a30375791b775715f8501364588f65b494ec4f6930568a19e765/wrapt-2.4.1-py3-none-any.whl", hash = "sha256:1e84ec5d89a0a07a0ef6bcd343f5c8ecdc95601d71de3058cdc63274e86c193c", size = 75317, upload-time = "2026-09-10T23:12:14.82Z" }, +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/d2/a254a26d8ceaea87e0eee2e89fcfe53ddc1858418647493bb2937549ab6f/wrapt-2.5.0.tar.gz", hash = "sha256:c48cdb6c904dca76d9915a579e4a5fab6b0c25f650c1019ce78a78effaf7a345", size = 183288, upload-time = "2026-09-27T01:41:56.874Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/23/37e2276e520ae569af851ebffaf03476e6984cd3ff298234ecd6920f7c4e/wrapt-2.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0345d4c1f7aa5a27075d28a7f0e9ed386729198045f1f08b47f8320d6bbda23", size = 104967, upload-time = "2026-09-27T01:39:31.258Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d3/a75a67fb1c9e3164eec4d31c2da3c606895d1ae71f74045eb70ab591fe53/wrapt-2.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:aae2f5f4c77335a39ebe5a1c77d5519f6cefefc7dbff50bd551e3771d927e3fc", size = 105434, upload-time = "2026-09-27T01:39:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e1/6f46c822f6657a69c8fef5d0824e0b5287d27b76d3769b501770b917fbb8/wrapt-2.5.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1b35ef7379323a149a6398f6261248bf48b61666210bd69e2ab24a9a9afdedc0", size = 231084, upload-time = "2026-09-27T01:39:34.363Z" }, + { url = "https://files.pythonhosted.org/packages/81/49/ee77b33964f83ee5624dbccfa939356ca84092ad30a211681095985a5b7e/wrapt-2.5.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e2af7ff9553c492f684a41d903ec41e37bf6267ee3206c17518a349201cae46", size = 232732, upload-time = "2026-09-27T01:39:35.604Z" }, + { url = "https://files.pythonhosted.org/packages/55/a7/e0c0ca927a03d164927001677530bba68e6fe6b267584dda26cbeba5a2dc/wrapt-2.5.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:734ea79e4707751fb7cc4f716123b2115cd16ed0c4926e803540aece22e3ba67", size = 221110, upload-time = "2026-09-27T01:39:36.877Z" }, + { url = "https://files.pythonhosted.org/packages/78/b0/0fe1d3cf811b0135593c8f91d0bb57ff2576807e37eff2a7aa4e3a24d1cc/wrapt-2.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1ccd22ef8690ca302425f26d4c3e8b24f1284d4351d20e49ce21f9c0dd58d64d", size = 230627, upload-time = "2026-09-27T01:39:38.225Z" }, + { url = "https://files.pythonhosted.org/packages/fd/fc/312410d55792dccebe489771fa36a35a71df17580568d6c9685e74102ca6/wrapt-2.5.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:439523506fd0d9af4f76d75f2e46aa4979e164fecba6890859326ff06edbaf9c", size = 218491, upload-time = "2026-09-27T01:39:39.716Z" }, + { url = "https://files.pythonhosted.org/packages/f8/90/a647f92c48c981ce198fe59bfd62c2113ca58e8557bcb8f05149cac93f5d/wrapt-2.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94ebe745d1b0ebd3af91e22c38961616d32a228bdf9c326e7d4a6007a623d48e", size = 220505, upload-time = "2026-09-27T01:39:41.215Z" }, + { url = "https://files.pythonhosted.org/packages/23/34/2f5bf610f5629f68ca71188f8c33db3422da469e05d9d719b84c4304ddf0/wrapt-2.5.0-cp310-cp310-win32.whl", hash = "sha256:5375ff1d2159e2ef847449e3dd2329441423d0a3d895d123273a67095fe7ca0c", size = 100329, upload-time = "2026-09-27T01:39:42.619Z" }, + { url = "https://files.pythonhosted.org/packages/87/c1/c9d72d5f648798c680d68ecaa691b55f7a54b4b9c1f0cb373502b975a01f/wrapt-2.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:513dd1f4a1f91030d5656d9f4b3af8aa490d4db1eb53481e6846b8a5ee7acfcd", size = 105398, upload-time = "2026-09-27T01:39:43.851Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8f/99de0a11ed51906b3a81a5e7ba7ab7d2d39ca4882a31afcb97c70ab9de60/wrapt-2.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:c3dfb16e047c912e1a06bfbc45da752219f474897be85f55b8069f7936b08cc0", size = 102622, upload-time = "2026-09-27T01:39:45.038Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d0/7c23187af053bfa54c99964f87b94b96cea76b78f019b06e42fa86752e5d/wrapt-2.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:57fa1a3fd1279b3ca7655b943ad61d298f2a2464a4cdca7ff298058e408322f9", size = 105327, upload-time = "2026-09-27T01:39:46.242Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fa/6f7f880207b2d74162a44b35e41217d999fd4af48338afb356c650f89a07/wrapt-2.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:63e58f96849f622ce769dcd705f83c7445cd9829bce1dae00e78bb031aec8096", size = 105501, upload-time = "2026-09-27T01:39:47.592Z" }, + { url = "https://files.pythonhosted.org/packages/6f/19/b0b7e7cf1a5499a25bd5260b38deb3b17aa9ca7b268ef29e546dcb683aa7/wrapt-2.5.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fd91203e156d610ecb28b9ccd7b764af7a7b38662d7c163090babab0d10def0c", size = 239849, upload-time = "2026-09-27T01:39:48.838Z" }, + { url = "https://files.pythonhosted.org/packages/7d/cd/b6cce206889497626f9b8b9d2ecb74324287d853bcca13c8bfab9b99a981/wrapt-2.5.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff909b934b1958e31784d412abab5cbb0709fdbc01c86f22965e1d15331371ba", size = 241355, upload-time = "2026-09-27T01:39:50.368Z" }, + { url = "https://files.pythonhosted.org/packages/70/6b/11b3b25915bfed1b47b7486a306602f3c3698b5a6b73b05bbd59bd891650/wrapt-2.5.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:148052fc55013930217f531c6978e918ab210a12bd73cc9bd6de661a7adaf620", size = 226349, upload-time = "2026-09-27T01:39:51.619Z" }, + { url = "https://files.pythonhosted.org/packages/f6/97/9901b407cacfd7b3efca60f808713294e5758dda023586fe201fe203d134/wrapt-2.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e5d4885c5625c9d2dcb49458700851574e9d0ea046c7a265526e990282f8ae8e", size = 239003, upload-time = "2026-09-27T01:39:53.01Z" }, + { url = "https://files.pythonhosted.org/packages/5c/68/b7883fe1c6b445df44e32a2a3a19a544a3a73749c5064a539c10f9f730a4/wrapt-2.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:672dd1bab4256311db1520b1b50e7a10cbaeaae2b0ac6bc5d858cc387ee605a2", size = 223603, upload-time = "2026-09-27T01:39:54.606Z" }, + { url = "https://files.pythonhosted.org/packages/f7/05/f9d527f0da33b901684ad326b6b9fc07593013f9594b9db4ebb0ddc1e127/wrapt-2.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c3a78a3161b3a9bf07725822fd24d379c1f3f161b6db49166c4131096ea73b4c", size = 229164, upload-time = "2026-09-27T01:39:55.904Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e0/4a05ad93a003272619cd98dbfbfb7b45a9c7d1041215473d789d9c6ce8ed/wrapt-2.5.0-cp311-cp311-win32.whl", hash = "sha256:0810e060e58f7960405172ad21080df8e7335841c9fe97417bd7d3f05af24f90", size = 100422, upload-time = "2026-09-27T01:39:57.216Z" }, + { url = "https://files.pythonhosted.org/packages/1e/96/5dcc944f39724e5df8a51f9095e856463df231764d503a7a935503eebeb9/wrapt-2.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:99f8ea48f14a71c5e2df8763e9a490e8af63096dcd67755b7bab0a4b74fc7cd7", size = 105616, upload-time = "2026-09-27T01:39:58.43Z" }, + { url = "https://files.pythonhosted.org/packages/a3/91/c927dac776c8939616ce865efa4bf3e62c933393528487e8fde981aad356/wrapt-2.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:2ac82ef59ee05e259902bc7cf73dee5e6397845e8ccdc376d9d25536b59a877c", size = 102562, upload-time = "2026-09-27T01:39:59.672Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a6/44589f9b34160280a1fbccbcf206b18df034a1b3e9584336d3a7f039a33c/wrapt-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b898caea081303006decc562c7fca5126f7c96507e78dd8f1ae3285dfa50ddc7", size = 106192, upload-time = "2026-09-27T01:40:00.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2b/94db3ba2e9528400e4173cbb67a56822a4644d4c2b319feb0cc638094b36/wrapt-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8837fbe708cb9d8a2d32a37dee836d24a531f02560db26418e2b181986fa21cb", size = 105828, upload-time = "2026-09-27T01:40:02.494Z" }, + { url = "https://files.pythonhosted.org/packages/11/0e/3ce67af67525c0068680637c0eba823ce2f0fc6eff2d1779cab656fb8c3d/wrapt-2.5.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0cabb9c17ab79b2549d1f23b36f436473ad9253ef53995a817feba26fae69d5b", size = 250177, upload-time = "2026-09-27T01:40:03.819Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3b/262b2c3c38aca6fa32cfea6407dbb346ce26ff788179b98137b3b3f3d8ca/wrapt-2.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6761765cc520ff9616fb035c02a85d1d744f7f70edd4649718fd0d09c589eacf", size = 250332, upload-time = "2026-09-27T01:40:05.15Z" }, + { url = "https://files.pythonhosted.org/packages/2e/ec/d54d273a2223d1964ecd5b5954aca18e8fbf2c5d8471e60ef3cbaaed9325/wrapt-2.5.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a145a7826eddea3eb5814903f98f93756042b919bb5305544cb1331daa2705b1", size = 229600, upload-time = "2026-09-27T01:40:06.47Z" }, + { url = "https://files.pythonhosted.org/packages/8e/88/33c75ac47b13b0bf77b44d96d02b1d0a2e6db66180909bacce86ef8fcd8e/wrapt-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6a9ee62a970075738909909bdbef3a7da9f7ae03dfca584db283547a29503b56", size = 246890, upload-time = "2026-09-27T01:40:07.838Z" }, + { url = "https://files.pythonhosted.org/packages/d2/0e/c3a3a158801e5bee7a0d9aa3561a127bd80f166b02b7b520a39e45d8f9b9/wrapt-2.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:691671ea05684f921ffc2e935fd3f9311c1795a10fbbfa006b46269733668f66", size = 226463, upload-time = "2026-09-27T01:40:09.206Z" }, + { url = "https://files.pythonhosted.org/packages/7c/2c/4c48ba51698a87e2e8299fc04856bbef391cb725f195bc93f0bc6196e1f2/wrapt-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e716f47c7f61e11709d3c0904213c94fc22999abf0c41461276cef886c1e8b4d", size = 238583, upload-time = "2026-09-27T01:40:10.631Z" }, + { url = "https://files.pythonhosted.org/packages/33/d2/077835618ed96b131730f74301a7236efd841ec2f341afe36303389b56ef/wrapt-2.5.0-cp312-cp312-win32.whl", hash = "sha256:5421acb5c363a9bc959122a8645e3f1f42010c932dc53885b11a5ff5b5a6d730", size = 100221, upload-time = "2026-09-27T01:40:11.91Z" }, + { url = "https://files.pythonhosted.org/packages/96/9f/9e56db2a3492275be809082c6f63a7a063f52b3e1812aae1eb03ec33f64c/wrapt-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:ab45839c912777e2738fed369636589c8b2a6d9c44ca56de0fd0814581d467c2", size = 105786, upload-time = "2026-09-27T01:40:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e4/e37ff75e5254564aa50e13bc4313383d02d5ec78f5bd3b9b1994dd7052d5/wrapt-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:ce4cab32c37ef71e69cf88f909b7febd0dd79543e5ae3650e2b874e0f3d3b975", size = 102937, upload-time = "2026-09-27T01:40:14.534Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4b/cc7bb5668f7ddc0e73e236e96a0c06cab8fddfca9c53538c9dffac62db6f/wrapt-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b312b3cc87951faaed3cfef984d768ee8bee7f935d9cc929aaa9946b0b96a98c", size = 105808, upload-time = "2026-09-27T01:40:15.826Z" }, + { url = "https://files.pythonhosted.org/packages/4a/13/5d15ef0e2f42d5f084930dc4863e6e52c160c28301c8780aae170c58421c/wrapt-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c57ddae24cf72eb6bd18112638a987cafe6109d90f2df111e6934362cc03ac1a", size = 105931, upload-time = "2026-09-27T01:40:17.136Z" }, + { url = "https://files.pythonhosted.org/packages/1e/02/c7174e78b0c38bb279b2d3c25a6bd7fb9d3b0200c3e5a8fad084e7cc3e85/wrapt-2.5.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b95a6eca3b927853529eea958310563c83140ae8451dd5dc4399c7da385dc4f3", size = 248259, upload-time = "2026-09-27T01:40:18.446Z" }, + { url = "https://files.pythonhosted.org/packages/a4/f9/47ae1d7ef325c3f6c81ae3c1fb4a3fef9d98c8025ed676c0bfc1550903ce/wrapt-2.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6058e12e9caa33468f9a36fb88c15a4bb30a479f997b37834b83abdbf062f264", size = 248224, upload-time = "2026-09-27T01:40:19.713Z" }, + { url = "https://files.pythonhosted.org/packages/38/7b/a394448bcbbaf8e5a3f856520edbbb1b92fc42061def56284c9083f3ac87/wrapt-2.5.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d245ac03f5ae77f1eea6eb19edd9e778c2f772490c20496c2f1cd3a102ee1b6", size = 227137, upload-time = "2026-09-27T01:40:21.359Z" }, + { url = "https://files.pythonhosted.org/packages/41/45/fc252bda5aa1ca01bc838d3b108778e786a2a13d0c52fd17c5f6179aa246/wrapt-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f4ef4935962f7029b2058a99f1a47ccbffc3be919dddb3becb6c2c48eac3d9f0", size = 244710, upload-time = "2026-09-27T01:40:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1c/527d1bde7371dcc2c378d88c97de03b121b486fb4cd3dbb399bc332c0676/wrapt-2.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f12e80c3089ebc03727d368f8205b811b5af2cd4a72b5e4cac75e901dd316e39", size = 225053, upload-time = "2026-09-27T01:40:24.345Z" }, + { url = "https://files.pythonhosted.org/packages/81/8e/2b823fded8c3b815408c58633929812eacd29d824fe57548b7868c4ee422/wrapt-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a346408f19b6d589bf029f25f65c0b4cdeed6302ef8f40da4e5d1552d22dc037", size = 236884, upload-time = "2026-09-27T01:40:25.654Z" }, + { url = "https://files.pythonhosted.org/packages/00/d7/5d185c1193b073a0bf4cbe862b5d31f81067eddc39eff30ae632f346563d/wrapt-2.5.0-cp313-cp313-win32.whl", hash = "sha256:79e68f0fd7d381b9bbd71776f602a2d5440d4d2077459128e02fd6607465422c", size = 100198, upload-time = "2026-09-27T01:40:27.111Z" }, + { url = "https://files.pythonhosted.org/packages/ce/9a/51d95640e01d0ebdd04a7223755f076e4936b0c124ce99bb01a12b53e66c/wrapt-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:77f0a74ff6f6cf89f5b673a732d5afe1911a6e6b1c017260836fdfdf85518dc1", size = 105610, upload-time = "2026-09-27T01:40:28.465Z" }, + { url = "https://files.pythonhosted.org/packages/67/52/183d5ce7c2a9391774e6a623be6ae564351545713ec9a3693528f89c8e85/wrapt-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:b620d7559b6b2197c5730332fab0867ecf1c8cb74d45533ebbcbcad1eacf4616", size = 102641, upload-time = "2026-09-27T01:40:30Z" }, + { url = "https://files.pythonhosted.org/packages/f3/4b/0009086ab8f2d5fb32405ef49fdd11104ce40f69ae9f4cdfba8326462816/wrapt-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:65f2ee406dc592a5b22a7dc6abac13e8a3e8de4b2ecf5dc3c22937865496e4b6", size = 106182, upload-time = "2026-09-27T01:40:31.503Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/8f38339a4c55a42df00296dcf6ad50598d2280049f7a6ffa525e9a1f66d1/wrapt-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d75d6366203c8d025c1a74bae0b565952187ddae79bd5c7bf10687652a56f020", size = 106123, upload-time = "2026-09-27T01:40:32.927Z" }, + { url = "https://files.pythonhosted.org/packages/23/38/285b433121d73c7a447b5b82d93c91dc3330f33ab5853975f34551e0c773/wrapt-2.5.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b640460f0ffb346b192686bd6fac5589e35a6c6640501c59a9fb6e82b0dd6bd8", size = 251245, upload-time = "2026-09-27T01:40:34.309Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a6/3f63f4637e89484c1839a9ba3aedda5b2912e7ce12617034c6bd49752cfa/wrapt-2.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f17c5a3836397bf59fd57b0e5b0dc42969b1daa70d31aa361c6e13cbf138b5a", size = 250483, upload-time = "2026-09-27T01:40:35.841Z" }, + { url = "https://files.pythonhosted.org/packages/e9/cd/f24ee96016da222dbb921cfb22e2beb5ca189a7b730ef49e8bb106b49449/wrapt-2.5.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4343880acd72e74233baf092285aaaf4306244e31d7601828bd2600316027df0", size = 231341, upload-time = "2026-09-27T01:40:37.295Z" }, + { url = "https://files.pythonhosted.org/packages/23/eb/c9b180124271e494f615a130f966be56143e3e26e87706bc28582f94bc09/wrapt-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ea4fdc79c0045d6bb1603c109127145245cafe888588888444e1e37fbeadbac3", size = 247736, upload-time = "2026-09-27T01:40:38.653Z" }, + { url = "https://files.pythonhosted.org/packages/fd/60/345b8c213389809435d1950136b09991a1af2a66b988d0cd930ecd1b9f19/wrapt-2.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f42239c89430eee2d8a6dec39e34677abdbb67fff63caf2467dd6124ea4d4d58", size = 228533, upload-time = "2026-09-27T01:40:40.12Z" }, + { url = "https://files.pythonhosted.org/packages/ea/15/c79f0f5827a9062c6be4fc25dc73e92fe1c014c7bfde2e61c8c0b56a91af/wrapt-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bad63bb4dea3c58e8078a3a173259ac2df5442a437e49c632b4099c0250e803b", size = 238830, upload-time = "2026-09-27T01:40:41.505Z" }, + { url = "https://files.pythonhosted.org/packages/5f/de/79a95ac238c9cae7ae7eb3a18501afc646e3ed61d8d108c725b17bbee301/wrapt-2.5.0-cp314-cp314-win32.whl", hash = "sha256:b58138d19f34e32833e62de5e910bc2a8baae43310b921d783bd39b15227c2dd", size = 100664, upload-time = "2026-09-27T01:40:42.884Z" }, + { url = "https://files.pythonhosted.org/packages/f0/15/32de0f1e6a46a82c773430672562d53203406df14a6d73c93abb59b679e9/wrapt-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:1a3c4035d2026b87ef23dd8d165f1f8d3853ee2bd02791cfd22bd8c6226c41ce", size = 105986, upload-time = "2026-09-27T01:40:44.652Z" }, + { url = "https://files.pythonhosted.org/packages/d9/2a/10a7ff69097385de15b3db7d91587a54c26f8025fcbf36a1d9e83a1e0ad1/wrapt-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:def66258d97ebf1e4e97def12c5daa542d1cc728a3da83ed3a43933f56df6dab", size = 103718, upload-time = "2026-09-27T01:40:45.956Z" }, + { url = "https://files.pythonhosted.org/packages/f7/01/963f893b1906ac6c2aecb777c36e9ab2156a4cf89fabf6125e953ec4ad52/wrapt-2.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:23a9d6cb6413359b76f030d6bbb75340b7669c69da245dde2919a4c93708993b", size = 109290, upload-time = "2026-09-27T01:40:47.22Z" }, + { url = "https://files.pythonhosted.org/packages/cd/6c/30e04d2b1284de2eea5411850008e0411d1876bf4552dc0990c904a0a783/wrapt-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:21cfe343ef9c2deb865ad0d5c57822266447c88dcf6d8805dd8c363fe367f30c", size = 109597, upload-time = "2026-09-27T01:40:49Z" }, + { url = "https://files.pythonhosted.org/packages/6a/34/3980fe5a899b69454f66db2991c144ecc828dbbd355ce6cd7b881056ebbc/wrapt-2.5.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dfc38cb672af51fc29696ba9c6f05d2315f5e62c4af2564e50f07f81198a163", size = 283840, upload-time = "2026-09-27T01:40:50.406Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b4/b37001235fd5871b3f31941229f8fef608279353b772dab3ccb248fd8726/wrapt-2.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:181a45000506a6382eb337354ca7e8525690702f1ccf2eae4f23a210ff339543", size = 293622, upload-time = "2026-09-27T01:40:51.868Z" }, + { url = "https://files.pythonhosted.org/packages/09/b3/9b751c6268fa2111efc7e43895105bc0f60a83896b08581009e77563f8c7/wrapt-2.5.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58b2a87c65cbfb20917ec48ace47f71b1962c1f81dbf18a4052e3037abf72028", size = 262840, upload-time = "2026-09-27T01:40:53.297Z" }, + { url = "https://files.pythonhosted.org/packages/47/7d/b7b51d601981ccc1f7b9e6023991548dec43dc9d40317597fbe0085bc876/wrapt-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0ea62bc142f4fa8b2e0ab058f50699ccd679ef6199c8fa3cc1c2396c7a659000", size = 288781, upload-time = "2026-09-27T01:40:54.756Z" }, + { url = "https://files.pythonhosted.org/packages/d1/82/1a84f288246905d0a71d44aa1f470ff8c75df2b96ef791d2938124449cb0/wrapt-2.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:425349a99b8c9540399d36620c376dc26e6aca93071cd6fafa239c2f1b5d53a4", size = 260116, upload-time = "2026-09-27T01:40:56.667Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0e/0572224d1c4a3f0846f82614702ec3110d45c843dcc7781c5f33779e1fdc/wrapt-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fe09aac4837ec720af606a493e814dcc3f65631984e1b7231b589efcf9917024", size = 278485, upload-time = "2026-09-27T01:40:58.443Z" }, + { url = "https://files.pythonhosted.org/packages/76/44/5a5c111f8ac6dd15f54437c2161588431d3924718a7e4de59c471cd794e9/wrapt-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:bc5607c1911c92530cb402ea90d818933cffb28bd8de9b453a2542279816d8c7", size = 103157, upload-time = "2026-09-27T01:40:59.995Z" }, + { url = "https://files.pythonhosted.org/packages/e6/80/96cc2da58cbc0893f5165f6a0f4f9cb75d7574f409022ad792aa80a0ff3f/wrapt-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7138b0e7990e5555a905c519e8dad17c1da1f20e08b283e202c414229065740f", size = 109683, upload-time = "2026-09-27T01:41:01.43Z" }, + { url = "https://files.pythonhosted.org/packages/c7/70/10dab499970e66c926ba6d404ff456318b68092b8ad0c4608a52160e43a2/wrapt-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a5bb346a34499e091e4fa23df58251ad192173c088e5413d893ca1c730c133c7", size = 105953, upload-time = "2026-09-27T01:41:03.041Z" }, + { url = "https://files.pythonhosted.org/packages/ab/18/5154954f69afdbf5bdeddc07ed60f30bf6e83ed1e9fb6f96c66cc20e4223/wrapt-2.5.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a45a5249a6965d91aac9f991fda7c17e6b8b41fe91592a6099f182bf53c82724", size = 106186, upload-time = "2026-09-27T01:41:04.472Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/7db9952d26b1a89afcf22da8ec7948e6ca55c48721488d53f84754eed89d/wrapt-2.5.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cbd45dfba6b5c1bfbabe1feb3c0f117fbd62416e98268d9a7cd9ad8802875356", size = 106155, upload-time = "2026-09-27T01:41:05.795Z" }, + { url = "https://files.pythonhosted.org/packages/57/b6/41a0d7f9cf1f8e6aaecbf4b5b4eaacf4036fa3396c7d814364e07728a041/wrapt-2.5.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc6491d3008ecabf685b0746f03ad8241a0950336939b14addb03af39b51a316", size = 251754, upload-time = "2026-09-27T01:41:07.26Z" }, + { url = "https://files.pythonhosted.org/packages/55/d4/dd2de1260a490cd55d083b3c1bc47a36aff0e8363249d108d3b34c091c0e/wrapt-2.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47abb2bb7f15b416e72fbe5e68e49a6f09331dae6af1ca6055f5aa2251d2bd2f", size = 250768, upload-time = "2026-09-27T01:41:08.681Z" }, + { url = "https://files.pythonhosted.org/packages/4c/40/d08297feb5728cd6d3c1133633cad0249c2b7a82eb0213062ebab9cc1266/wrapt-2.5.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7e25e9697f60af41fb86b08697e470b1e7eb6cd6ac0eb25e4b1f519839adc271", size = 232618, upload-time = "2026-09-27T01:41:10.293Z" }, + { url = "https://files.pythonhosted.org/packages/54/52/d8ca61b26c2a34927cc999fc250f1018f415741691581280e6a76cced736/wrapt-2.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:bcd42e7b69c8c1e33a29b79b28de03bdc08876a49745830f9162a3af860e06d0", size = 248043, upload-time = "2026-09-27T01:41:12.132Z" }, + { url = "https://files.pythonhosted.org/packages/8c/5e/ba02904736e2d3b05afd7447b4ddff677ce61762265939555541adb8c668/wrapt-2.5.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:36703cafc2ec059e118c2175e6cb7ad7299c2924aecdcb1b7a7ebbf7a3e20c19", size = 229577, upload-time = "2026-09-27T01:41:13.746Z" }, + { url = "https://files.pythonhosted.org/packages/a0/94/23968c18a6e37a8a130706dc52ccf4344a71f1fe53c99965eb0a7715a459/wrapt-2.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1ebc0d09906057ada57a32158a657364ca40b8f86e604da3e7d979069b601502", size = 239372, upload-time = "2026-09-27T01:41:15.459Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ee/8437e73fffa57c96a5f0f6721f942ccf7b1461b64cd965f83e2181e25252/wrapt-2.5.0-cp315-cp315-win32.whl", hash = "sha256:76fb341d5a707a4f211631b8c77259b2df149147e9d9c245ae6ba3dd936bfdfb", size = 100673, upload-time = "2026-09-27T01:41:16.925Z" }, + { url = "https://files.pythonhosted.org/packages/37/6d/6d640f98197d68e61fbeaded20478e4aa840f9c88d11e7a49c8d6d14ae15/wrapt-2.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:6269637d9a54990430b4a769df15833935a46c4d004d9fe8a153bbadf0b9a097", size = 105999, upload-time = "2026-09-27T01:41:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/0d/3e/8b8a0c94f2698c99afb499510824b107c81e9c4a34b3db7e877233a634b7/wrapt-2.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:1122f4f9e363da804ccba05a9f0f39baa3716eb82452c929258bc3f1420c899b", size = 103728, upload-time = "2026-09-27T01:41:19.729Z" }, + { url = "https://files.pythonhosted.org/packages/37/96/88f08f58759ee3739544cc51941853e946df1f400ba60c6efbeccfb589d7/wrapt-2.5.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e3c6fb1c1a516881353186bed9cfcb8899f968c03b3509720c79db0d967acf3b", size = 109275, upload-time = "2026-09-27T01:41:21.213Z" }, + { url = "https://files.pythonhosted.org/packages/29/cc/68846aa92814d0704d4b128a30d7707368be6951e8a8f42f1254ce4ab31c/wrapt-2.5.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0a7a369e7fca9fc8c2c50df634382009b09af416853da3d4515e4bb048a5b9ee", size = 109610, upload-time = "2026-09-27T01:41:22.647Z" }, + { url = "https://files.pythonhosted.org/packages/fe/87/bbaa188dace348b6a403bbf3cc483f3f419ac97700274340e17f2dbc700e/wrapt-2.5.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:88bb24b9fdccb1d805258d5648533206eb58c58b6554985c47db53a89c11be85", size = 284602, upload-time = "2026-09-27T01:41:24.094Z" }, + { url = "https://files.pythonhosted.org/packages/be/2e/8a3309b0cbd3ab809ee6b76812c3be211f5a08732f321f131d26bb4f078a/wrapt-2.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e3d110d7f99644946f249927c346d9dba507a78815d1bcebdbf0d94c14c5649", size = 294893, upload-time = "2026-09-27T01:41:25.654Z" }, + { url = "https://files.pythonhosted.org/packages/15/b7/eda8bbdb6a3b7343d2c71e23fb0ebfc15c12fd470e3cce7ea42f7a57aaac/wrapt-2.5.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ffb2823c95dbeb8a47fedfba9636b2afeb0a8ef94b66df97bd081bdfe5a263f", size = 264658, upload-time = "2026-09-27T01:41:27.375Z" }, + { url = "https://files.pythonhosted.org/packages/41/f0/589bad71ca3ce5444a626ee10d657fd6aa5080ada76b3bcd4550468aa16e/wrapt-2.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:557ebf4ce5568588368675014a2540405db687c2e4c7ad1eb83aa7e857be1864", size = 289974, upload-time = "2026-09-27T01:41:29.032Z" }, + { url = "https://files.pythonhosted.org/packages/dc/97/c48f3c820ae6687e87b537041cd49ad4caa41e05a8f0d8ec08e449ca303c/wrapt-2.5.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:05246a100da68259af521b88788f131ba005465f1c95d358cc3c03ec5e351b52", size = 261945, upload-time = "2026-09-27T01:41:30.941Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ad/d96898f500cb1e4185474bac6cb14bb7ea670a32f37e8c354a0c647e3a92/wrapt-2.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c932273bc43b068538f3874fa5e6c2a60f33fa0b11c1ebc7768652f6a0608943", size = 279744, upload-time = "2026-09-27T01:41:32.617Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8b/7981d2ac838d0dc07e81145cb1c9911812e060b98e8c5a47c84fb92f8f81/wrapt-2.5.0-cp315-cp315t-win32.whl", hash = "sha256:2fd8a61c31220840c7f52621cf51c961af5058bd009a55bcdf4a6732bdb13b35", size = 103124, upload-time = "2026-09-27T01:41:34.117Z" }, + { url = "https://files.pythonhosted.org/packages/2a/1d/374cec175b6087e1067a780374d81d74ca966e1e5e39e666402eaee19a65/wrapt-2.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:1d4da5f0e9a719471502b0db80d5c97503aeca796b7aeb9ab8f47403b2be76e6", size = 109671, upload-time = "2026-09-27T01:41:35.553Z" }, + { url = "https://files.pythonhosted.org/packages/c7/93/fc9e477a1771bec52d7677eee5e8404afe662a47efe1859405a18fff206c/wrapt-2.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:78b7bdaa8b27b7f7607c66bdb6ab15c1dcbd9e9a1556a253a347dad511f615d1", size = 105969, upload-time = "2026-09-27T01:41:36.973Z" }, + { url = "https://files.pythonhosted.org/packages/87/7d/5ed859fad4b5eddd598a846150aaab2703730ed4886c5c5e03b0df0cfdd5/wrapt-2.5.0-py3-none-any.whl", hash = "sha256:107eea1a511e98a3a5033b0c2cb403fbb37f05dee6ac1fb85c0460d311ec278c", size = 81586, upload-time = "2026-09-27T01:41:55.479Z" }, ] [[package]]