Skip to content

Commit ed82832

Browse files
twishabansalkurtisvganubhav756
authored
feat: Added a sync toolbox client (#131)
* create sync tool * create sync client * add sync client and its tests * lint * small fix * docs fix * lint * Export sync as well as async client * remove mixed implementations * fix toolbox sync client and tool name * fix default port * PR comments resolve * small fix * Apply suggestions from code review Co-authored-by: Kurtis Van Gent <[email protected]> Co-authored-by: Anubhav Dhawan <[email protected]> * fix docstrings * lint * small fix * resolve comment * fix * remove run_as_sync and run_as_async methods * lint * lint * change from function attributes to properties * fix mypy issue * fix mypy issue * lint * remove session variable * lint * added qualname * fix qualname * nit * fix comment * fix error message --------- Co-authored-by: Kurtis Van Gent <[email protected]> Co-authored-by: Anubhav Dhawan <[email protected]>
1 parent 5c2dff7 commit ed82832

File tree

4 files changed

+476
-1
lines changed

4 files changed

+476
-1
lines changed

packages/toolbox-core/src/toolbox_core/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,6 @@
1313
# limitations under the License.
1414

1515
from .client import ToolboxClient
16+
from .sync_client import ToolboxSyncClient
1617

17-
__all__ = ["ToolboxClient"]
18+
__all__ = ["ToolboxClient", "ToolboxSyncClient"]
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
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+
import asyncio
16+
from threading import Thread
17+
from typing import Any, Awaitable, Callable, Mapping, Optional, TypeVar, Union
18+
19+
from aiohttp import ClientSession
20+
21+
from .client import ToolboxClient
22+
from .sync_tool import ToolboxSyncTool
23+
24+
T = TypeVar("T")
25+
26+
27+
class ToolboxSyncClient:
28+
"""
29+
An synchronous client for interacting with a Toolbox service.
30+
31+
Provides methods to discover and load tools defined by a remote Toolbox
32+
service endpoint.
33+
"""
34+
35+
__loop: Optional[asyncio.AbstractEventLoop] = None
36+
__thread: Optional[Thread] = None
37+
38+
def __init__(
39+
self,
40+
url: str,
41+
):
42+
"""
43+
Initializes the ToolboxSyncClient.
44+
45+
Args:
46+
url: The base URL for the Toolbox service API (e.g., "http://localhost:5000").
47+
"""
48+
# Running a loop in a background thread allows us to support async
49+
# methods from non-async environments.
50+
if self.__class__.__loop is None:
51+
loop = asyncio.new_event_loop()
52+
thread = Thread(target=loop.run_forever, daemon=True)
53+
thread.start()
54+
self.__class__.__thread = thread
55+
self.__class__.__loop = loop
56+
57+
async def create_client():
58+
return ToolboxClient(url)
59+
60+
# Ignoring type since we're already checking the existence of a loop above.
61+
self.__async_client = asyncio.run_coroutine_threadsafe(
62+
create_client(), self.__class__.__loop # type: ignore
63+
).result()
64+
65+
def close(self):
66+
"""
67+
Synchronously closes the underlying client session. Doing so will cause
68+
any tools created by this Client to cease to function.
69+
70+
If the session was provided externally during initialization, the caller
71+
is responsible for its lifecycle, but calling close here will still
72+
attempt to close it.
73+
"""
74+
coro = self.__async_client.close()
75+
asyncio.run_coroutine_threadsafe(coro, self.__loop).result()
76+
77+
def load_tool(
78+
self,
79+
name: str,
80+
auth_token_getters: dict[str, Callable[[], str]] = {},
81+
bound_params: Mapping[str, Union[Callable[[], Any], Any]] = {},
82+
) -> ToolboxSyncTool:
83+
"""
84+
Synchronously loads a tool from the server.
85+
86+
Retrieves the schema for the specified tool from the Toolbox server and
87+
returns a callable object (`ToolboxSyncTool`) that can be used to invoke the
88+
tool remotely.
89+
90+
Args:
91+
name: The unique name or identifier of the tool to load.
92+
auth_token_getters: A mapping of authentication service names to
93+
callables that return the corresponding authentication token.
94+
bound_params: A mapping of parameter names to bind to specific values or
95+
callables that are called to produce values as needed.
96+
97+
Returns:
98+
ToolboxSyncTool: A callable object representing the loaded tool, ready
99+
for execution. The specific arguments and behavior of the callable
100+
depend on the tool itself.
101+
"""
102+
coro = self.__async_client.load_tool(name, auth_token_getters, bound_params)
103+
104+
# We have already created a new loop in the init method in case it does not already exist
105+
async_tool = asyncio.run_coroutine_threadsafe(coro, self.__loop).result() # type: ignore
106+
107+
if not self.__loop or not self.__thread:
108+
raise ValueError("Background loop or thread cannot be None.")
109+
return ToolboxSyncTool(async_tool, self.__loop, self.__thread)
110+
111+
def load_toolset(
112+
self,
113+
name: str,
114+
auth_token_getters: dict[str, Callable[[], str]] = {},
115+
bound_params: Mapping[str, Union[Callable[[], Any], Any]] = {},
116+
) -> list[ToolboxSyncTool]:
117+
"""
118+
Synchronously fetches a toolset and loads all tools defined within it.
119+
120+
Args:
121+
name: Name of the toolset to load tools.
122+
auth_token_getters: A mapping of authentication service names to
123+
callables that return the corresponding authentication token.
124+
bound_params: A mapping of parameter names to bind to specific values or
125+
callables that are called to produce values as needed.
126+
127+
Returns:
128+
list[ToolboxSyncTool]: A list of callables, one for each tool defined
129+
in the toolset.
130+
"""
131+
coro = self.__async_client.load_toolset(name, auth_token_getters, bound_params)
132+
133+
# We have already created a new loop in the init method in case it does not already exist
134+
async_tools = asyncio.run_coroutine_threadsafe(coro, self.__loop).result() # type: ignore
135+
136+
if not self.__loop or not self.__thread:
137+
raise ValueError("Background loop or thread cannot be None.")
138+
return [
139+
ToolboxSyncTool(async_tool, self.__loop, self.__thread)
140+
for async_tool in async_tools
141+
]
142+
143+
def __enter__(self):
144+
"""Enter the runtime context related to this client instance."""
145+
return self
146+
147+
def __exit__(self, exc_type, exc_val, exc_tb):
148+
"""Exit the runtime context and close the client session."""
149+
self.close()
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
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+
import asyncio
16+
from asyncio import AbstractEventLoop
17+
from inspect import Signature
18+
from threading import Thread
19+
from typing import Any, Callable, Mapping, TypeVar, Union
20+
21+
from .tool import ToolboxTool
22+
23+
T = TypeVar("T")
24+
25+
26+
class ToolboxSyncTool:
27+
"""
28+
A callable proxy object representing a specific tool on a remote Toolbox server.
29+
30+
Instances of this class behave like synchronous functions. When called, they
31+
send a request to the corresponding tool's endpoint on the Toolbox server with
32+
the provided arguments.
33+
34+
It utilizes Python's introspection features (`__name__`, `__doc__`,
35+
`__signature__`, `__annotations__`) so that standard tools like `help()`
36+
and `inspect` work as expected.
37+
"""
38+
39+
def __init__(
40+
self, async_tool: ToolboxTool, loop: AbstractEventLoop, thread: Thread
41+
):
42+
"""
43+
Initializes a callable that will trigger the tool invocation through the
44+
Toolbox server.
45+
46+
Args:
47+
async_tool: An instance of the asynchronous ToolboxTool.
48+
loop: The event loop used to run asynchronous tasks.
49+
thread: The thread to run blocking operations in.
50+
"""
51+
52+
if not isinstance(async_tool, ToolboxTool):
53+
raise TypeError("async_tool must be an instance of ToolboxTool")
54+
55+
self.__async_tool = async_tool
56+
self.__loop = loop
57+
self.__thread = thread
58+
59+
# NOTE: We cannot define __qualname__ as a @property here.
60+
# Properties are designed to compute values dynamically when accessed on an *instance* (using 'self').
61+
# However, Python needs the class's __qualname__ attribute to be a plain string
62+
# *before* any instances exist, specifically when the 'class ToolboxSyncTool:' statement
63+
# itself is being processed during module import or class definition.
64+
# Defining __qualname__ as a property leads to a TypeError because the class object needs
65+
# a string value immediately, not a descriptor that evaluates later.
66+
self.__qualname__ = f"{self.__class__.__qualname__}.{self.__name__}"
67+
68+
@property
69+
def __name__(self) -> str:
70+
return self.__async_tool.__name__
71+
72+
@property
73+
def __doc__(self) -> Union[str, None]: # type: ignore[override]
74+
# Standard Python object attributes like __doc__ are technically "writable".
75+
# But not defining a setter function makes this a read-only property.
76+
# Mypy flags this issue in the type checks.
77+
return self.__async_tool.__doc__
78+
79+
@property
80+
def __signature__(self) -> Signature:
81+
return self.__async_tool.__signature__
82+
83+
@property
84+
def __annotations__(self) -> dict[str, Any]: # type: ignore[override]
85+
# Standard Python object attributes like __doc__ are technically "writable".
86+
# But not defining a setter function makes this a read-only property.
87+
# Mypy flags this issue in the type checks.
88+
return self.__async_tool.__annotations__
89+
90+
def __call__(self, *args: Any, **kwargs: Any) -> str:
91+
"""
92+
Synchronously calls the remote tool with the provided arguments.
93+
94+
Validates arguments against the tool's signature, then sends them
95+
as a JSON payload in a POST request to the tool's invoke URL.
96+
97+
Args:
98+
*args: Positional arguments for the tool.
99+
**kwargs: Keyword arguments for the tool.
100+
101+
Returns:
102+
The string result returned by the remote tool execution.
103+
"""
104+
coro = self.__async_tool(*args, **kwargs)
105+
return asyncio.run_coroutine_threadsafe(coro, self.__loop).result()
106+
107+
def add_auth_token_getters(
108+
self,
109+
auth_token_getters: Mapping[str, Callable[[], str]],
110+
) -> "ToolboxSyncTool":
111+
"""
112+
Registers an auth token getter function that is used for AuthServices when tools
113+
are invoked.
114+
115+
Args:
116+
auth_token_getters: A mapping of authentication service names to
117+
callables that return the corresponding authentication token.
118+
119+
Returns:
120+
A new ToolboxSyncTool instance with the specified authentication token
121+
getters registered.
122+
"""
123+
124+
new_async_tool = self.__async_tool.add_auth_token_getters(auth_token_getters)
125+
return ToolboxSyncTool(new_async_tool, self.__loop, self.__thread)
126+
127+
def bind_parameters(
128+
self, bound_params: Mapping[str, Union[Callable[[], Any], Any]]
129+
) -> "ToolboxSyncTool":
130+
"""
131+
Binds parameters to values or callables that produce values.
132+
133+
Args:
134+
bound_params: A mapping of parameter names to values or callables that
135+
produce values.
136+
137+
Returns:
138+
A new ToolboxSyncTool instance with the specified parameters bound.
139+
"""
140+
141+
new_async_tool = self.__async_tool.bind_parameters(bound_params)
142+
return ToolboxSyncTool(new_async_tool, self.__loop, self.__thread)

0 commit comments

Comments
 (0)