4040import time
4141from abc import ABC , abstractmethod
4242from contextlib import suppress
43- from typing import Any , Dict , Generic , Optional , Type , TYPE_CHECKING , TypeVar
43+ from typing import Any , Callable , Dict , Generic , Optional , Type , TYPE_CHECKING , TypeVar
4444from urllib .parse import urlsplit
4545
4646from .client_types import StateT , StepResult
5858ActT = TypeVar ("ActT" )
5959ObsT = TypeVar ("ObsT" )
6060EnvClientT = TypeVar ("EnvClientT" , bound = "EnvClient" )
61+ ResultT = TypeVar ("ResultT" )
6162
6263_VALID_CLIENT_MODES = ("simulation" , "production" )
6364
6465
66+ class _AutoAsyncResult (Generic [ResultT ]):
67+ """Awaitable result that can also be resolved by synchronous access."""
68+
69+ def __init__ (
70+ self ,
71+ client : "EnvClient[Any, Any, Any]" ,
72+ coro_factory : Callable [[], Any ],
73+ ):
74+ self ._client = client
75+ self ._coro_factory = coro_factory
76+ self ._resolved = False
77+ self ._result : ResultT | None = None
78+
79+ def __await__ (self ):
80+ async def _await_result ():
81+ self ._client ._claim_execution_mode ("async" )
82+ return await self ._coro_factory ()
83+
84+ return _await_result ().__await__ ()
85+
86+ def _sync_result (self ) -> ResultT :
87+ if not self ._resolved :
88+ self ._result = self ._client ._run_sync (self ._coro_factory )
89+ self ._resolved = True
90+ return self ._result
91+
92+ def __getattr__ (self , name : str ) -> Any :
93+ return getattr (self ._sync_result (), name )
94+
95+ def __bool__ (self ) -> bool :
96+ return bool (self ._sync_result ())
97+
98+ def __repr__ (self ) -> str :
99+ if self ._resolved :
100+ return repr (self ._result )
101+ return f"<{ type (self ).__name__ } pending>"
102+
103+
65104def _normalize_mode (mode : Optional [str ]) -> str :
66105 """Resolve and validate the client communication mode."""
67106 raw_mode = (
@@ -212,6 +251,8 @@ def __init__(
212251 self ._start_provider_on_connect = base_url is None
213252 self ._child_clients : list [EnvClient [Any , Any , Any ]] = []
214253 self ._ws : Optional [ClientConnection ] = None
254+ self ._execution_mode : Optional [str ] = None
255+ self ._sync_client : Optional ["SyncEnvClient[ActT, ObsT, StateT]" ] = None
215256 self ._ws_loop : Optional [asyncio .AbstractEventLoop ] = None
216257 if base_url is not None :
217258 self ._set_base_url (base_url )
@@ -295,7 +336,56 @@ def __setattr__(self, name: str, value: Any) -> None:
295336 raise AttributeError ("Cannot modify mode after initialization" )
296337 super ().__setattr__ (name , value )
297338
298- async def connect (self ) -> "EnvClient" :
339+ def _claim_execution_mode (self , mode : str ) -> None :
340+ """Lock the client to sync or async execution on first use."""
341+ if self ._execution_mode is None :
342+ self ._execution_mode = mode
343+ elif self ._execution_mode != mode :
344+ raise RuntimeError (
345+ f"EnvClient is already being used in { self ._execution_mode } mode. "
346+ "Create a separate client instance when mixing sync and async code."
347+ )
348+
349+ def _run_sync (
350+ self ,
351+ coro_factory : Callable [[], Any ],
352+ * ,
353+ allow_async_handoff : bool = False ,
354+ ) -> Any :
355+ """Run an async operation through the sync wrapper."""
356+ if allow_async_handoff and self ._execution_mode == "async" :
357+ self ._execution_mode = "sync"
358+ else :
359+ self ._claim_execution_mode ("sync" )
360+ if self ._sync_client is None :
361+ self ._sync_client = self .sync ()
362+ return self ._sync_client ._run (coro_factory ())
363+
364+ def _dispatch (self , coro_factory : Callable [[], Any ]) -> Any :
365+ """Return an awaitable in async code and a concrete result in sync code."""
366+ try :
367+ running_loop = asyncio .get_running_loop ()
368+ except RuntimeError :
369+ running_loop = None
370+
371+ if self ._execution_mode == "sync" :
372+ sync_loop = (
373+ self ._sync_client ._loop if self ._sync_client is not None else None
374+ )
375+ if running_loop is sync_loop :
376+ return coro_factory ()
377+ return self ._run_sync (coro_factory )
378+ if running_loop is not None :
379+ if self ._execution_mode == "async" :
380+ return coro_factory ()
381+ return _AutoAsyncResult (self , coro_factory )
382+
383+ return self ._run_sync (coro_factory , allow_async_handoff = True )
384+
385+ def connect (self ) -> Any :
386+ return self ._dispatch (self ._connect_async )
387+
388+ async def _connect_async (self ) -> "EnvClient" :
299389 """
300390 Establish WebSocket connection to the server.
301391
@@ -352,7 +442,10 @@ async def connect(self) -> "EnvClient":
352442
353443 return self
354444
355- async def disconnect (self ) -> None :
445+ def disconnect (self ) -> Any :
446+ return self ._dispatch (self ._disconnect_async )
447+
448+ async def _disconnect_async (self ) -> None :
356449 """Close the WebSocket connection."""
357450 if self ._ws is not None :
358451 ws = self ._ws
@@ -374,17 +467,13 @@ async def disconnect(self) -> None:
374467 async def _ensure_connected (self ) -> None :
375468 """Ensure WebSocket connection is established on the current loop.
376469
377- Always delegates to `connect()` rather than pre-checking `self._ws is
378- None`: `connect()` itself is the one that knows whether an existing
379- `_ws` is reusable (same event loop) or stale (a different one, e.g.
380- from a prior `from_env()` call now being driven through `.sync()`'s
381- own loop). A pre-check here that only looked at `_ws is None` would
382- skip `connect()` entirely whenever `_ws` is already set -- including
383- the stale-loop case -- so the reconnect logic would never run for
384- callers that never explicitly call `.connect()` themselves (e.g.
385- `client.sync().reset()` right after `from_env()`).
470+ Always delegates to `_connect_async()` rather than pre-checking
471+ `self._ws is None`: `_connect_async()` itself is the one that knows
472+ whether an existing `_ws` is reusable (same event loop) or stale (a
473+ different one, e.g. from a prior `from_env()` call now being driven
474+ through `.sync()`'s own loop).
386475 """
387- await self .connect ()
476+ await self ._connect_async ()
388477
389478 async def _send (self , message : Dict [str , Any ]) -> None :
390479 """Send a message over the WebSocket."""
@@ -570,7 +659,10 @@ def _parse_state(self, payload: Dict[str, Any]) -> StateT:
570659 """Convert a JSON response from the state endpoint to a State object."""
571660 raise NotImplementedError
572661
573- async def reset (self , ** kwargs : Any ) -> StepResult [ObsT ]:
662+ def reset (self , ** kwargs : Any ) -> Any :
663+ return self ._dispatch (lambda : self ._reset_async (** kwargs ))
664+
665+ async def _reset_async (self , ** kwargs : Any ) -> StepResult [ObsT ]:
574666 """
575667 Reset the environment with optional parameters.
576668
@@ -588,7 +680,10 @@ async def reset(self, **kwargs: Any) -> StepResult[ObsT]:
588680 response = await self ._send_and_receive (message )
589681 return self ._parse_result (response .get ("data" , {}))
590682
591- async def step (self , action : ActT , ** kwargs : Any ) -> StepResult [ObsT ]:
683+ def step (self , action : ActT , ** kwargs : Any ) -> Any :
684+ return self ._dispatch (lambda : self ._step_async (action , ** kwargs ))
685+
686+ async def _step_async (self , action : ActT , ** kwargs : Any ) -> StepResult [ObsT ]:
592687 """
593688 Execute an action in the environment.
594689
@@ -608,7 +703,10 @@ async def step(self, action: ActT, **kwargs: Any) -> StepResult[ObsT]:
608703 response = await self ._send_and_receive (message )
609704 return self ._parse_result (response .get ("data" , {}))
610705
611- async def state (self ) -> StateT :
706+ def state (self ) -> Any :
707+ return self ._dispatch (self ._state_async )
708+
709+ async def _state_async (self ) -> StateT :
612710 """
613711 Get the current environment state from the server.
614712
@@ -619,7 +717,10 @@ async def state(self) -> StateT:
619717 response = await self ._send_and_receive (message )
620718 return self ._parse_state (response .get ("data" , {}))
621719
622- async def close (self ) -> None :
720+ def close (self ) -> Any :
721+ return self ._dispatch (self ._close_async )
722+
723+ async def _close_async (self ) -> None :
623724 """
624725 Close the WebSocket connection and clean up resources.
625726
@@ -632,7 +733,7 @@ async def close(self) -> None:
632733 self ._child_clients .clear ()
633734
634735 try :
635- await self .disconnect ()
736+ await self ._disconnect_async ()
636737 finally :
637738 try :
638739 if self ._provider is not None :
@@ -648,6 +749,7 @@ async def close(self) -> None:
648749
649750 async def __aenter__ (self ) -> "EnvClient" :
650751 """Enter async context manager, ensuring connection is established."""
752+ self ._claim_execution_mode ("async" )
651753 await self .connect ()
652754 return self
653755
@@ -656,17 +758,13 @@ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
656758 await self .close ()
657759
658760 def __enter__ (self ) -> "EnvClient" :
659- """Sync context manager entry - raises error suggesting async usage."""
660- raise TypeError (
661- "EnvClient is async by default. Use 'async with' instead of 'with', "
662- "or call .sync() to get a synchronous wrapper:\n "
663- " async with client: # async usage\n "
664- " with client.sync(): # sync wrapper"
665- )
761+ """Enter sync context manager, ensuring connection is established."""
762+ self ._run_sync (self ._connect_async )
763+ return self
666764
667765 def __exit__ (self , exc_type , exc_val , exc_tb ) -> None :
668- """Sync context manager exit - should not be reached ."""
669- pass # pragma: no cover
766+ """Exit sync context manager, closing connection ."""
767+ self . _run_sync ( self . _close_async )
670768
671769 def sync (self ) -> "SyncEnvClient" :
672770 """
@@ -694,4 +792,6 @@ def sync(self) -> "SyncEnvClient":
694792 """
695793 from .sync_client import SyncEnvClient
696794
697- return SyncEnvClient (self )
795+ if self ._sync_client is None :
796+ self ._sync_client = SyncEnvClient (self )
797+ return self ._sync_client
0 commit comments