From 6bd5da801b641e763f4276d6857d27c65205de45 Mon Sep 17 00:00:00 2001 From: Lukas Bindreiter Date: Thu, 24 Sep 2026 12:20:32 +0200 Subject: [PATCH] Retire syncifyable --- CHANGELOG.md | 5 +- tilebox-grpc/_tilebox/grpc/aio/syncify.py | 117 ---------- tilebox-grpc/pyproject.toml | 1 - tilebox-grpc/tests/aio/test_syncify.py | 77 ------ .../tests/test_sync_storage_client.py | 63 +++++ tilebox-storage/tilebox/storage/_sync.py | 220 ++++++++++++++++-- tilebox-storage/tilebox/storage/aio.py | 5 +- uv.lock | 11 - 8 files changed, 273 insertions(+), 226 deletions(-) delete mode 100644 tilebox-grpc/_tilebox/grpc/aio/syncify.py delete mode 100644 tilebox-grpc/tests/aio/test_syncify.py create mode 100644 tilebox-storage/tests/test_sync_storage_client.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6add176..97b06fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Require Python 3.11 or newer across all packages, removing Python 3.10 compatibility code and typing backfills. -- `tilebox-grpc`: Use `nest-asyncio2` for nested event-loop support on Python 3.14. Restart notebook kernels already - patched by `nest-asyncio` and apply `nest-asyncio2` before the old library to use the updated patch. +- `tilebox-storage`: Replace legacy synchronous client patching with explicit wrappers using `asyncio.run()`. + When called inside a running event loop (including notebooks), run the operation in a worker thread instead. + Remove the internal `syncify` helper and the `nest-asyncio2` dependency from `tilebox-grpc`. - Raise dependency minimums to remove obsolete compatibility workarounds: boto3 1.40.2, OpenTelemetry 1.43.0 (logging instrumentation 0.64b0), grpcio 1.84.0, and pyqwest 0.7.0. - `tilebox-datasets`: Require NumPy 1.25, pandas 2.2.2, xarray 2024.6, and Shapely 2.0.6 or newer. diff --git a/tilebox-grpc/_tilebox/grpc/aio/syncify.py b/tilebox-grpc/_tilebox/grpc/aio/syncify.py deleted file mode 100644 index 029d9ad..0000000 --- a/tilebox-grpc/_tilebox/grpc/aio/syncify.py +++ /dev/null @@ -1,117 +0,0 @@ -""" -This module provides a mixin class Syncifiable that can be used to add a _syncify() method to a class which when called -patches an object and replaces all async functions in the object to be blocking sync functions instead. - -For all async operations in the codebase we use the anyio library, which allows the library to be used with both -the asyncio and the trio (a third party async library) event loop. However, here we must decide on one event loop -since we need to run it directly for syncifying async functions. We use asyncio, since it is part of the standard -library -""" - -import asyncio -import functools -import inspect -from collections.abc import Callable, Coroutine -from typing import Any, TypeVar - -import nest_asyncio2 - -# this is a patch to enable syncify functionality also inside a running event loop, which is e.g. the case when -# running in a Jupyter notebook or in a pytest session. In that case we need to use nest_asyncio2 to allow -# running nested event loops. - - -class Syncifiable: - """ - A mixin that provides a _syncify method which can be used to wrap all async functions in a blocking sync function. - """ - - def _syncify(self) -> None: - return syncify(self) - - -T_Syncifiable = TypeVar("T_Syncifiable", bound=Syncifiable) - - -def syncify(instance: Any) -> None: - """ - Patch all public async functions and generators in the given instance to be blocking sync functions instead. - - One known limitation of this approach is that it breaks return type inference for the patched functions. - A possible way to get around this could be to use metaprogramming to create a new class with the patched functions - on the fly and hope the type checker can infer that. - - Args: - instance: The instance to patch - """ - for name in dir(instance): - if name.startswith("_"): - continue - attr = getattr(instance, name) - if inspect.iscoroutinefunction(attr): # standard async function / coroutine - setattr(instance, name, _syncify_coroutine(attr)) - elif inspect.isasyncgenfunction(attr): # async generator - setattr(instance, name, _syncify_async_generator(attr)) - - -def _syncify_coroutine(coroutine: Callable[..., Any]) -> Callable[..., Any]: - """Wrap a coroutine function to be a blocking sync function. - - Args: - coroutine: The coroutine to wrap - - Returns: - Callable: The wrapped coroutine as a blocking sync function. - """ - - @functools.wraps(coroutine) # preserve name, docstring, signature etc. of the original function - def wrapper(*args: Any, **kwargs: Any) -> Any: - return _run_blocking(coroutine(*args, **kwargs)) - - return wrapper - - -def _syncify_async_generator(async_generator: Callable[..., Any]) -> Callable[..., Any]: - """Wrap an async generator to be a blocking sync generator. - - This requires a bit more work than wrapping a coroutine function, because we need a coroutine helper function that - waits for the next value in the async generator and returns it. This helper function is then wrapped in a blocking - sync generator. This is necessary because even in the sync generator we want to yield the values as they come in - and not wait for the entire async generator to complete before yielding the values. - - Args: - async_generator: The async generator to wrap - - Yields: - Iterator: Generator over the yielded values of the async generator. - """ - - # inspired by https://stackoverflow.com/a/63595496 - @functools.wraps(async_generator) - def wrapper(*args: Any, **kwargs: Any) -> Any: - async_iter = async_generator(*args, **kwargs).__aiter__() - - async def _next() -> tuple[bool, Any]: - try: - obj = await async_iter.__anext__() - except StopAsyncIteration: - return True, None - return False, obj - - while True: - done, obj = _run_blocking(_next()) - if done: - break - yield obj - - return wrapper - - -def _run_blocking(awaitable: Coroutine[Any, Any, Any]) -> Any: - try: - running_loop = asyncio.get_running_loop() - except RuntimeError: - return asyncio.run(awaitable) - - nest_asyncio2.apply(running_loop) - return running_loop.run_until_complete(awaitable) diff --git a/tilebox-grpc/pyproject.toml b/tilebox-grpc/pyproject.toml index 199d7da..674703c 100644 --- a/tilebox-grpc/pyproject.toml +++ b/tilebox-grpc/pyproject.toml @@ -30,7 +30,6 @@ dependencies = [ # for the libraries below we specify a minimum, tested to be working version "lz4>=4", "anyio>=4", - "nest-asyncio2>=1.7.2", ] [dependency-groups] diff --git a/tilebox-grpc/tests/aio/test_syncify.py b/tilebox-grpc/tests/aio/test_syncify.py deleted file mode 100644 index ac9d27c..0000000 --- a/tilebox-grpc/tests/aio/test_syncify.py +++ /dev/null @@ -1,77 +0,0 @@ -import asyncio -import time -from collections.abc import AsyncIterator, Iterator -from typing import cast - -import anyio -import pytest - -from _tilebox.grpc.aio.syncify import Syncifiable, _run_blocking - - -class AsyncService(Syncifiable): - async def async_method(self) -> int: - return 42 - - async def async_generator(self) -> AsyncIterator[int]: - yield 42 - - async def sleepy_async_generator(self, n: int = 5, sleep: float = 0.01) -> AsyncIterator[int]: - for _ in range(n): - await anyio.sleep(sleep) - yield 42 - - -def test_syncify() -> None: - """Test that syncify works as expected.""" - service = AsyncService() - service._syncify() - assert cast(int, service.async_method()) == 42 - assert list(cast(Iterator[int], service.async_generator())) == [42] - - -@pytest.mark.asyncio -async def test_syncify_in_running_event_loop() -> None: - """Test that syncify works as expected when called from a running event loop.""" - service = AsyncService() - service._syncify() - assert cast(int, service.async_method()) == 42 - assert list(cast(Iterator[int], service.async_generator())) == [42] - - -@pytest.mark.asyncio -async def test_syncify_preserves_current_task_in_nested_loop() -> None: - outer_task = asyncio.current_task() - loop = asyncio.get_running_loop() - - async def nested() -> int: - inner_task = asyncio.current_task() - assert inner_task is not None - assert inner_task is not outer_task - assert asyncio.get_running_loop() is loop - assert inner_task in asyncio.all_tasks() - await asyncio.sleep(0) - assert asyncio.current_task() is inner_task - return 73 - - assert _run_blocking(nested()) == 73 - assert asyncio.current_task() is outer_task - - -@pytest.mark.asyncio -async def test_syncify_generator_items_yielded_as_they_come_in() -> None: - """ - Test that syncifing an async generator yields each item directly when it is available instead of a whole - list of items at the end once the entire generator has completed. - """ - service = AsyncService() - service._syncify() - - sleep, eps = 0.01, 0.005 - - before = time.time() - for item in cast(Iterator[int], service.sleepy_async_generator(sleep=sleep)): - delta = time.time() - before - assert item == 42 - assert sleep - eps <= delta <= sleep + eps - before = time.time() diff --git a/tilebox-storage/tests/test_sync_storage_client.py b/tilebox-storage/tests/test_sync_storage_client.py new file mode 100644 index 0000000..755d7c7 --- /dev/null +++ b/tilebox-storage/tests/test_sync_storage_client.py @@ -0,0 +1,63 @@ +import asyncio +from contextvars import ContextVar +from pathlib import Path +from threading import get_ident +from unittest.mock import patch + +import pytest + +from tilebox import storage +from tilebox.storage._sync import _run +from tilebox.storage.granule import LocationStorageGranule + + +def test_local_sync_client(tmp_path: Path) -> None: + folder = tmp_path / "scene" + folder.mkdir() + (folder / "data.tif").write_bytes(b"raster") + (tmp_path / "preview.jpg").write_bytes(b"preview") + granule = LocationStorageGranule("scene", "preview.jpg") + client = storage.LocalFileSystemStorageClient(tmp_path) + + # Reuse the same client across separate asyncio.run() event loops. + assert client.list_objects(granule) == ["data.tif"] + assert client.download(granule) == folder + assert client.download_quicklook(granule) == tmp_path / "preview.jpg" + with patch("tilebox.storage.aio._display_quicklook") as display: + client.quicklook(granule, width=321, height=123) + display.assert_called_once_with(tmp_path / "preview.jpg", 321, 123, None) + + with pytest.raises(ValueError, match="Data not found"): + client.download(LocationStorageGranule("missing")) + + +@pytest.mark.asyncio +async def test_local_sync_client_in_running_loop(tmp_path: Path) -> None: + loop = asyncio.get_running_loop() + task = asyncio.current_task() + test_local_sync_client(tmp_path) + assert asyncio.get_running_loop() is loop + assert asyncio.current_task() is task + await asyncio.sleep(0) + + +@pytest.mark.asyncio +async def test_run_preserves_context_in_worker_thread() -> None: + context = ContextVar("storage_test_context", default="unset") + token = context.set("caller") + caller_thread = get_ident() + caller_loop = asyncio.get_running_loop() + + async def operation() -> str: + assert get_ident() != caller_thread + assert asyncio.get_running_loop() is not caller_loop + await asyncio.sleep(0) + value = context.get() + context.set("worker") + return value + + try: + assert _run(operation()) == "caller" + assert context.get() == "caller" + finally: + context.reset(token) diff --git a/tilebox-storage/tilebox/storage/_sync.py b/tilebox-storage/tilebox/storage/_sync.py index 5172cfe..210ea33 100644 --- a/tilebox-storage/tilebox/storage/_sync.py +++ b/tilebox-storage/tilebox/storage/_sync.py @@ -1,4 +1,11 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Coroutine +from concurrent.futures import ThreadPoolExecutor +from contextvars import copy_context from pathlib import Path +from typing import TYPE_CHECKING, Any, TypeVar, cast from tilebox.storage.aio import ASFStorageClient as _ASFStorageClient from tilebox.storage.aio import CopernicusStorageClient as _CopernicusStorageClient @@ -6,11 +13,35 @@ from tilebox.storage.aio import UmbraStorageClient as _UmbraStorageClient from tilebox.storage.aio import USGSLandsatStorageClient as _USGSLandsatStorageClient +if TYPE_CHECKING: + import xarray as xr + + from tilebox.storage.granule import ( + ASFStorageGranule, + CopernicusStorageGranule, + LocationStorageGranule, + UmbraStorageGranule, + USGSLandsatStorageGranule, + ) + # These classes live here to keep the package import light, but remain public tilebox.storage classes. Keeping their # module identity stable preserves repr, introspection, and pickle lookup compatibility. +_T = TypeVar("_T") + + +def _run(coroutine: Coroutine[Any, Any, _T]) -> _T: + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(coroutine) + + # A synchronous caller blocks its own loop. Use a separate thread, preserving tracing and logging context. + with ThreadPoolExecutor(max_workers=1) as executor: + return cast(_T, executor.submit(copy_context().run, asyncio.run, coroutine).result()) -class ASFStorageClient(_ASFStorageClient): + +class ASFStorageClient: __module__ = "tilebox.storage" def __init__(self, user: str, password: str, cache_directory: Path = Path.home() / ".cache" / "tilebox") -> None: @@ -22,11 +53,37 @@ def __init__(self, user: str, password: str, cache_directory: Path = Path.home() cache_directory: The directory to store downloaded data in. Defaults to ~/.cache/tilebox. If set to None no cache is used and the `output_dir` parameter will need be set when downloading data. """ - super().__init__(user, password, cache_directory) - self._syncify() + self._client = _ASFStorageClient(user, password, cache_directory) + + def download( + self, + datapoint: xr.Dataset | ASFStorageGranule, + output_dir: Path | None = None, + verify: bool = True, + extract: bool = True, + show_progress: bool = True, + ) -> Path: + """Download the data for a datapoint, and optionally verify and extract it.""" + return _run(self._client.download(datapoint, output_dir, verify, extract, show_progress)) + + def download_quicklook(self, datapoint: xr.Dataset | ASFStorageGranule) -> Path: + """Download the quicklook image for a datapoint.""" + return _run(self._client.download_quicklook(datapoint)) + + def quicklook(self, datapoint: xr.Dataset | ASFStorageGranule, width: int = 600, height: int = 600) -> None: + """Display the quicklook image in IPython.""" + _run(self._client.quicklook(datapoint, width, height)) + def delete(self, file_or_directory: Path) -> None: + """Delete a product from the download cache.""" + _run(self._client.delete(file_or_directory)) -class UmbraStorageClient(_UmbraStorageClient): + def destroy_cache(self) -> None: + """Clear the download cache, deleting all entries.""" + _run(self._client.destroy_cache()) + + +class UmbraStorageClient: __module__ = "tilebox.storage" def __init__(self, cache_directory: Path | None = Path.home() / ".cache" / "tilebox") -> None: @@ -36,11 +93,45 @@ def __init__(self, cache_directory: Path | None = Path.home() / ".cache" / "tile cache_directory: The directory to store downloaded data in. Defaults to ~/.cache/tilebox. If set to None no cache is used and the `output_dir` parameter will need be set when downloading data. """ - super().__init__(cache_directory) - self._syncify() + self._client = _UmbraStorageClient(cache_directory) + + def list_objects(self, datapoint: xr.Dataset | UmbraStorageGranule) -> list[str]: + """List object keys relative to the granule location.""" + return _run(self._client.list_objects(datapoint)) + + def download( + self, + datapoint: xr.Dataset | UmbraStorageGranule, + output_dir: Path | None = None, + show_progress: bool = True, + max_concurrent_downloads: int = 4, + ) -> Path: + """Download the data for a datapoint to the output directory or cache.""" + return _run(self._client.download(datapoint, output_dir, show_progress, max_concurrent_downloads)) + def download_objects( + self, + datapoint: xr.Dataset | UmbraStorageGranule, + objects: list[str], + output_dir: Path | None = None, + show_progress: bool = True, + max_concurrent_downloads: int = 4, + ) -> Path: + """Download selected objects, with names relative to the granule location.""" + return _run( + self._client.download_objects(datapoint, objects, output_dir, show_progress, max_concurrent_downloads) + ) + + def delete(self, file_or_directory: Path) -> None: + """Delete a product from the download cache.""" + _run(self._client.delete(file_or_directory)) + + def destroy_cache(self) -> None: + """Clear the download cache, deleting all entries.""" + _run(self._client.destroy_cache()) -class CopernicusStorageClient(_CopernicusStorageClient): + +class CopernicusStorageClient: __module__ = "tilebox.storage" def __init__( @@ -59,11 +150,53 @@ def __init__( cache_directory: The directory to store downloaded data in. Defaults to ~/.cache/tilebox. If set to None no cache is used and the `output_dir` parameter will need be set when downloading data. """ - super().__init__(access_key, secret_access_key, cache_directory) - self._syncify() + self._client = _CopernicusStorageClient(access_key, secret_access_key, cache_directory) + + def list_objects(self, datapoint: xr.Dataset | CopernicusStorageGranule) -> list[str]: + """List object keys relative to the granule location.""" + return _run(self._client.list_objects(datapoint)) + def download( + self, + datapoint: xr.Dataset | CopernicusStorageGranule, + output_dir: Path | None = None, + show_progress: bool = True, + max_concurrent_downloads: int = 4, + ) -> Path: + """Download the data for a datapoint to the output directory or cache.""" + return _run(self._client.download(datapoint, output_dir, show_progress, max_concurrent_downloads)) + + def download_objects( + self, + datapoint: xr.Dataset | CopernicusStorageGranule, + objects: list[str], + output_dir: Path | None = None, + show_progress: bool = True, + max_concurrent_downloads: int = 4, + ) -> Path: + """Download selected objects, with names relative to the granule location.""" + return _run( + self._client.download_objects(datapoint, objects, output_dir, show_progress, max_concurrent_downloads) + ) + + def download_quicklook(self, datapoint: xr.Dataset | CopernicusStorageGranule) -> Path: + """Download the quicklook image for a datapoint.""" + return _run(self._client.download_quicklook(datapoint)) + + def quicklook(self, datapoint: xr.Dataset | CopernicusStorageGranule, width: int = 600, height: int = 600) -> None: + """Display the quicklook image in IPython.""" + _run(self._client.quicklook(datapoint, width, height)) + + def delete(self, file_or_directory: Path) -> None: + """Delete a product from the download cache.""" + _run(self._client.delete(file_or_directory)) -class USGSLandsatStorageClient(_USGSLandsatStorageClient): + def destroy_cache(self) -> None: + """Clear the download cache, deleting all entries.""" + _run(self._client.destroy_cache()) + + +class USGSLandsatStorageClient: __module__ = "tilebox.storage" def __init__(self, cache_directory: Path | None = Path.home() / ".cache" / "tilebox") -> None: @@ -76,11 +209,53 @@ def __init__(self, cache_directory: Path | None = Path.home() / ".cache" / "tile cache_directory: The directory to store downloaded data in. Defaults to ~/.cache/tilebox. If set to None no cache is used and the `output_dir` parameter will need be set when downloading data. """ - super().__init__(cache_directory) - self._syncify() + self._client = _USGSLandsatStorageClient(cache_directory) + def list_objects(self, datapoint: xr.Dataset | USGSLandsatStorageGranule) -> list[str]: + """List object keys relative to the granule location.""" + return _run(self._client.list_objects(datapoint)) -class LocalFileSystemStorageClient(_LocalFileSystemStorageClient): + def download( + self, + datapoint: xr.Dataset | USGSLandsatStorageGranule, + output_dir: Path | None = None, + show_progress: bool = True, + max_concurrent_downloads: int = 4, + ) -> Path: + """Download the data for a datapoint to the output directory or cache.""" + return _run(self._client.download(datapoint, output_dir, show_progress, max_concurrent_downloads)) + + def download_objects( + self, + datapoint: xr.Dataset | USGSLandsatStorageGranule, + objects: list[str], + output_dir: Path | None = None, + show_progress: bool = True, + max_concurrent_downloads: int = 4, + ) -> Path: + """Download selected objects, with names relative to the granule location.""" + return _run( + self._client.download_objects(datapoint, objects, output_dir, show_progress, max_concurrent_downloads) + ) + + def download_quicklook(self, datapoint: xr.Dataset | USGSLandsatStorageGranule) -> Path: + """Download the quicklook image for a datapoint.""" + return _run(self._client.download_quicklook(datapoint)) + + def quicklook(self, datapoint: xr.Dataset | USGSLandsatStorageGranule, width: int = 600, height: int = 600) -> None: + """Display the quicklook image in IPython.""" + _run(self._client.quicklook(datapoint, width, height)) + + def delete(self, file_or_directory: Path) -> None: + """Delete a product from the download cache.""" + _run(self._client.delete(file_or_directory)) + + def destroy_cache(self) -> None: + """Clear the download cache, deleting all entries.""" + _run(self._client.destroy_cache()) + + +class LocalFileSystemStorageClient: __module__ = "tilebox.storage" def __init__(self, root: Path) -> None: @@ -89,5 +264,20 @@ def __init__(self, root: Path) -> None: Args: root: The root directory of the file system to access. """ - super().__init__(root) - self._syncify() + self._client = _LocalFileSystemStorageClient(root) + + def list_objects(self, datapoint: xr.Dataset | LocationStorageGranule) -> list[str]: + """List object paths relative to the granule location.""" + return _run(self._client.list_objects(datapoint)) + + def download(self, datapoint: xr.Dataset | LocationStorageGranule) -> Path: + """Locate the data already on the local file system.""" + return _run(self._client.download(datapoint)) + + def download_quicklook(self, datapoint: xr.Dataset | LocationStorageGranule) -> Path: + """Locate the quicklook image already on the local file system.""" + return _run(self._client.download_quicklook(datapoint)) + + def quicklook(self, datapoint: xr.Dataset | LocationStorageGranule, width: int = 600, height: int = 600) -> None: + """Display the quicklook image in IPython.""" + _run(self._client.quicklook(datapoint, width, height)) diff --git a/tilebox-storage/tilebox/storage/aio.py b/tilebox-storage/tilebox/storage/aio.py index 265c0f8..0c87593 100644 --- a/tilebox-storage/tilebox/storage/aio.py +++ b/tilebox-storage/tilebox/storage/aio.py @@ -17,7 +17,6 @@ from aiofile import async_open from _tilebox.grpc.aio.producer_consumer import async_producer_consumer -from _tilebox.grpc.aio.syncify import Syncifiable from tilebox.storage.client import ( AssetAccessPolicy, Client, @@ -102,7 +101,7 @@ def _boto3_credential_provider_class() -> type[Any]: return Boto3CredentialProvider -class _HttpClient(Syncifiable): +class _HttpClient: def __init__(self, auth: dict[str, tuple[str, str]]) -> None: """A tilebox storage client that directly downloads files from the storage provider to a given directory.""" self._clients: dict[str, niquests.AsyncSession] = {} @@ -320,7 +319,7 @@ def _display_quicklook(image_data: bytes | Path, width: int, height: int, image_ display(HTML(image_caption)) -class StorageClient(Syncifiable): +class StorageClient: """Base class for all storage clients.""" diff --git a/uv.lock b/uv.lock index 33f129e..9f1d507 100644 --- a/uv.lock +++ b/uv.lock @@ -1423,15 +1423,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/f1/7ebc5d419bda7ab80dba0e59d5484a70229e617cf346665882f70fa499c3/mypy_boto3_s3-1.43.93-py3-none-any.whl", hash = "sha256:52be1672a8b65ff3a1f779b46f7ed4fab85e78f15b353ce7eb5e20a9f15e7b9b", size = 86721, upload-time = "2026-09-11T19:44:35.263Z" }, ] -[[package]] -name = "nest-asyncio2" -version = "1.7.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/a6/a2775b388a14b5ea0c894bbe513c0d3e04e65e8ae583e60f5357722b8e03/nest_asyncio2-1.7.3.tar.gz", hash = "sha256:2e9a84d5d1efe6d020c72988d21aec569bac42d98af2ff6b9de24640c5d22a34", size = 15642, upload-time = "2026-09-22T17:15:06.709Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/29/dd/a8bebc78975a7466932077a0973cbab4361a32368b1d28d40683ec19333c/nest_asyncio2-1.7.3-py3-none-any.whl", hash = "sha256:2bc87bdca654e719425145f5e84eaeb0080b013fdd47d65729a7d66243f4987c", size = 8364, upload-time = "2026-09-22T17:15:05.492Z" }, -] - [[package]] name = "niquests" version = "3.21.2" @@ -2765,7 +2756,6 @@ dependencies = [ { name = "connectrpc" }, { name = "grpcio" }, { name = "lz4" }, - { name = "nest-asyncio2" }, { name = "protobuf" }, { name = "pyqwest" }, ] @@ -2783,7 +2773,6 @@ requires-dist = [ { name = "connectrpc", specifier = ">=0.10.1,<0.11.0" }, { name = "grpcio", specifier = ">=1.84.0" }, { name = "lz4", specifier = ">=4" }, - { name = "nest-asyncio2", specifier = ">=1.7.2" }, { name = "protobuf", specifier = ">=6.31.0" }, { name = "pyqwest", specifier = ">=0.7.0" }, ]