2626from __future__ import annotations
2727
2828import asyncio
29- from collections .abc import AsyncIterator , Iterator , Sequence
29+ import threading
30+ from collections .abc import AsyncIterator , Coroutine , Iterator , Sequence
3031from dataclasses import dataclass , field
3132from datetime import datetime , timezone
32- from typing import TYPE_CHECKING
33+ from typing import TYPE_CHECKING , TypeVar
3334
3435from obspec import GetResult , GetResultAsync
3536
3839if TYPE_CHECKING :
3940 from obspec import Attributes , GetOptions , ObjectMeta
4041
42+ T = TypeVar ("T" )
43+
4144try :
4245 import aiohttp
4346except ImportError as e :
@@ -196,6 +199,10 @@ def __init__(
196199 self .headers = headers or {}
197200 self .timeout = aiohttp .ClientTimeout (total = timeout )
198201 self ._session : aiohttp .ClientSession | None = None
202+ # Event loop for sync methods when called from async context (e.g., Jupyter)
203+ self ._sync_loop : asyncio .AbstractEventLoop | None = None
204+ self ._sync_thread : threading .Thread | None = None
205+ self ._sync_lock = threading .Lock ()
199206
200207 async def __aenter__ (self ) -> "AiohttpStore" :
201208 """Enter the async context manager, creating a reusable session."""
@@ -211,6 +218,52 @@ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
211218 await self ._session .close ()
212219 self ._session = None
213220
221+ def _get_sync_loop (self ) -> asyncio .AbstractEventLoop :
222+ """Get or create event loop for sync operations when inside a running loop."""
223+ if self ._sync_loop is None :
224+ with self ._sync_lock :
225+ if self ._sync_loop is None :
226+ loop = asyncio .new_event_loop ()
227+ thread = threading .Thread (
228+ target = loop .run_forever ,
229+ name = f"aiohttp_store_{ id (self )} " ,
230+ daemon = True ,
231+ )
232+ thread .start ()
233+ self ._sync_loop = loop
234+ self ._sync_thread = thread
235+ return self ._sync_loop
236+
237+ def _run_sync (self , coro : Coroutine [None , None , T ]) -> T :
238+ """Run coroutine synchronously, handling nested event loops (e.g., Jupyter)."""
239+ try :
240+ asyncio .get_running_loop ()
241+ except RuntimeError :
242+ # No running loop - use asyncio.run() directly
243+ return asyncio .run (coro )
244+
245+ # Inside running loop - use store's dedicated loop
246+ loop = self ._get_sync_loop ()
247+ future = asyncio .run_coroutine_threadsafe (coro , loop )
248+ return future .result ()
249+
250+ def _cleanup_sync_loop (self ) -> None :
251+ """Stop the sync loop and thread."""
252+ if self ._sync_loop is not None :
253+ self ._sync_loop .call_soon_threadsafe (self ._sync_loop .stop )
254+ if self ._sync_thread is not None :
255+ self ._sync_thread .join (timeout = 1.0 )
256+ self ._sync_loop = None
257+ self ._sync_thread = None
258+
259+ def close (self ) -> None :
260+ """Close the store and release resources."""
261+ self ._cleanup_sync_loop ()
262+
263+ def __del__ (self ) -> None :
264+ """Clean up on garbage collection."""
265+ self ._cleanup_sync_loop ()
266+
214267 def _build_url (self , path : str ) -> str :
215268 """Build the full URL from base URL and path."""
216269 path = path .removeprefix ("/" )
@@ -497,7 +550,7 @@ def get(
497550 AiohttpGetResult
498551 Result object with buffer() method and metadata.
499552 """
500- result = asyncio . run (self .get_async (path , options = options ))
553+ result = self . _run_sync (self .get_async (path , options = options ))
501554 return AiohttpGetResult (
502555 _data = result ._data ,
503556 _meta = result ._meta ,
@@ -534,7 +587,7 @@ def get_range(
534587 bytes
535588 The requested byte range.
536589 """
537- return asyncio . run (
590+ return self . _run_sync (
538591 self .get_range_async (path , start = start , end = end , length = length )
539592 )
540593
@@ -567,7 +620,7 @@ def get_ranges(
567620 Sequence[bytes]
568621 The requested byte ranges.
569622 """
570- return asyncio . run (
623+ return self . _run_sync (
571624 self .get_ranges_async (path , starts = starts , ends = ends , lengths = lengths )
572625 )
573626
@@ -627,7 +680,7 @@ def head(self, path: str) -> ObjectMeta:
627680 ObjectMeta
628681 File metadata including size, last_modified, e_tag, etc.
629682 """
630- return asyncio . run (self .head_async (path ))
683+ return self . _run_sync (self .head_async (path ))
631684
632685
633686__all__ = ["AiohttpStore" , "AiohttpGetResult" , "AiohttpGetResultAsync" ]
0 commit comments