Skip to content

Commit b6442b4

Browse files
committed
create sync client
1 parent 3e6336f commit b6442b4

File tree

1 file changed

+237
-0
lines changed

1 file changed

+237
-0
lines changed
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from typing import Any, Callable, Mapping, Optional, Union, Awaitable, TypeVar
16+
import asyncio
17+
from aiohttp import ClientSession
18+
from threading import Thread
19+
20+
from .sync_tool import ToolboxSyncTool
21+
from .client import ToolboxClient
22+
23+
T = TypeVar("T")
24+
25+
26+
class ToolboxSyncClient:
27+
__session: Optional[ClientSession] = None
28+
__loop: Optional[asyncio.AbstractEventLoop] = None
29+
__thread: Optional[Thread] = None
30+
"""
31+
A synchronous client for interacting with a Toolbox service.
32+
33+
Provides methods to discover and load tools defined by a remote Toolbox
34+
service endpoint, returning synchronous tool wrappers (`SyncToolboxTool`).
35+
It manages an underlying asynchronous `ToolboxClient`.
36+
"""
37+
38+
def __init__(
39+
self,
40+
url: str,
41+
):
42+
"""
43+
Initializes the SyncToolboxClient.
44+
45+
Args:
46+
url: The base URL for the Toolbox service API (e.g., "http://localhost:8000").
47+
"""
48+
# Running a loop in a background thread allows us to support async
49+
# methods from non-async environments.
50+
if ToolboxClient.__loop is None:
51+
loop = asyncio.new_event_loop()
52+
thread = Thread(target=loop.run_forever, daemon=True)
53+
thread.start()
54+
ToolboxClient.__thread = thread
55+
ToolboxClient.__loop = loop
56+
57+
async def __start_session() -> None:
58+
59+
# Use a default session if none is provided. This leverages connection
60+
# pooling for better performance by reusing a single session throughout
61+
# the application's lifetime.
62+
if ToolboxClient.__session is None:
63+
ToolboxClient.__session = ClientSession()
64+
65+
coro = __start_session()
66+
67+
asyncio.run_coroutine_threadsafe(coro, ToolboxClient.__loop).result()
68+
69+
if not ToolboxClient.__session:
70+
raise ValueError("Session cannot be None.")
71+
self.__async_client = ToolboxClient(url, ToolboxClient.__session)
72+
73+
def __run_as_sync(self, coro: Awaitable[T]) -> T:
74+
"""Run an async coroutine synchronously"""
75+
if not self.__loop:
76+
raise Exception(
77+
"Cannot call synchronous methods before the background loop is initialized."
78+
)
79+
return asyncio.run_coroutine_threadsafe(coro, self.__loop).result()
80+
81+
async def __run_as_async(self, coro: Awaitable[T]) -> T:
82+
"""Run an async coroutine asynchronously"""
83+
84+
# If a loop has not been provided, attempt to run in current thread.
85+
if not self.__loop:
86+
return await coro
87+
88+
# Otherwise, run in the background thread.
89+
return await asyncio.wrap_future(
90+
asyncio.run_coroutine_threadsafe(coro, self.__loop)
91+
)
92+
93+
def load_tool(
94+
self,
95+
tool_name: str,
96+
auth_token_getters: dict[str, Callable[[], str]] = {},
97+
bound_params: Mapping[str, Union[Callable[[], Any], Any]] = {},
98+
) -> ToolboxSyncTool:
99+
"""
100+
Synchronously loads a tool from the server.
101+
102+
Retrieves the schema for the specified tool and returns a callable,
103+
synchronous object (`SyncToolboxTool`) that can be used to invoke the
104+
tool remotely.
105+
106+
Args:
107+
tool_name: The unique name or identifier of the tool to load.
108+
auth_token_getters: A mapping of authentication service names to
109+
callables that return the corresponding authentication token.
110+
bound_params: A mapping of parameter names to bind to specific values or
111+
callables that are called to produce values as needed.
112+
113+
Returns:
114+
ToolboxSyncTool: A synchronous callable object representing the loaded tool.
115+
"""
116+
async_tool = self.__run_as_sync(
117+
self.__async_client.load_tool(tool_name, auth_token_getters, bound_params)
118+
)
119+
120+
if not self.__loop or not self.__thread:
121+
raise ValueError("Background loop or thread cannot be None.")
122+
return ToolboxSyncTool(async_tool, self.__loop, self.__thread)
123+
124+
def load_toolset(
125+
self,
126+
toolset_name: str,
127+
auth_token_getters: dict[str, Callable[[], str]] = {},
128+
bound_params: Mapping[str, Union[Callable[[], Any], Any]] = {},
129+
) -> list[ToolboxSyncTool]:
130+
"""
131+
Synchronously fetches a toolset and loads all tools defined within it.
132+
133+
Args:
134+
toolset_name: Name of the toolset to load tools.
135+
auth_token_getters: A mapping of authentication service names to
136+
callables that return the corresponding authentication token.
137+
bound_params: A mapping of parameter names to bind to specific values or
138+
callables that are called to produce values as needed.
139+
140+
Returns:
141+
list[ToolboxSyncTool]: A list of synchronous callables, one for each
142+
tool defined in the toolset.
143+
"""
144+
async_tools = self.__run_as_sync(
145+
self.__async_client.load_toolset(
146+
toolset_name, auth_token_getters, bound_params
147+
)
148+
)
149+
150+
if not self.__loop or not self.__thread:
151+
raise ValueError("Background loop or thread cannot be None.")
152+
tools: list[ToolboxSyncTool] = []
153+
for async_tool in async_tools:
154+
tools.append(ToolboxSyncTool(async_tool, self.__loop, self.__thread))
155+
return tools
156+
157+
async def aload_tool(
158+
self,
159+
tool_name: str,
160+
auth_token_getters: dict[str, Callable[[], str]] = {},
161+
bound_params: Mapping[str, Union[Callable[[], Any], Any]] = {},
162+
) -> ToolboxSyncTool:
163+
"""
164+
Synchronously loads a tool from the server.
165+
166+
Retrieves the schema for the specified tool and returns a callable,
167+
synchronous object (`SyncToolboxTool`) that can be used to invoke the
168+
tool remotely.
169+
170+
Args:
171+
tool_name: The unique name or identifier of the tool to load.
172+
auth_token_getters: A mapping of authentication service names to
173+
callables that return the corresponding authentication token.
174+
bound_params: A mapping of parameter names to bind to specific values or
175+
callables that are called to produce values as needed.
176+
177+
Returns:
178+
ToolboxSyncTool: A synchronous callable object representing the loaded tool.
179+
"""
180+
async_tool = await self.__run_as_async(
181+
self.__async_client.load_tool(tool_name, auth_token_getters, bound_params)
182+
)
183+
184+
if not self.__loop or not self.__thread:
185+
raise ValueError("Background loop or thread cannot be None.")
186+
return ToolboxSyncTool(async_tool, self.__loop, self.__thread)
187+
188+
async def aload_toolset(
189+
self,
190+
toolset_name: str,
191+
auth_token_getters: dict[str, Callable[[], str]] = {},
192+
bound_params: Mapping[str, Union[Callable[[], Any], Any]] = {},
193+
) -> list[ToolboxSyncTool]:
194+
"""
195+
Synchronously fetches a toolset and loads all tools defined within it.
196+
197+
Args:
198+
toolset_name: Name of the toolset to load tools.
199+
auth_token_getters: A mapping of authentication service names to
200+
callables that return the corresponding authentication token.
201+
bound_params: A mapping of parameter names to bind to specific values or
202+
callables that are called to produce values as needed.
203+
204+
Returns:
205+
list[ToolboxSyncTool]: A list of synchronous callables, one for each
206+
tool defined in the toolset.
207+
"""
208+
async_tools = await self.__run_as_async(
209+
self.__async_client.load_toolset(
210+
toolset_name, auth_token_getters, bound_params
211+
)
212+
)
213+
214+
if not self.__loop or not self.__thread:
215+
raise ValueError("Background loop or thread cannot be None.")
216+
tools: list[ToolboxSyncTool] = []
217+
for async_tool in async_tools:
218+
tools.append(ToolboxSyncTool(async_tool, self.__loop, self.__thread))
219+
return tools
220+
221+
def close(self):
222+
"""
223+
Synchronously closes the underlying asynchronous client session if it
224+
was created internally by the client.
225+
"""
226+
# Create the coroutine for closing the async client
227+
coro = self.__async_client.close()
228+
# Run it synchronously
229+
self.__run_as_sync(coro)
230+
231+
def __enter__(self):
232+
"""Enter the runtime context related to this client instance."""
233+
return self
234+
235+
def __exit__(self, exc_type, exc_val, exc_tb):
236+
"""Exit the runtime context and close the client session."""
237+
self.close()

0 commit comments

Comments
 (0)