Skip to content

Commit 2095970

Browse files
committed
add sync client and its tests
1 parent b6442b4 commit 2095970

File tree

2 files changed

+190
-9
lines changed

2 files changed

+190
-9
lines changed

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

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,28 +47,28 @@ def __init__(
4747
"""
4848
# Running a loop in a background thread allows us to support async
4949
# methods from non-async environments.
50-
if ToolboxClient.__loop is None:
50+
if ToolboxSyncClient.__loop is None:
5151
loop = asyncio.new_event_loop()
5252
thread = Thread(target=loop.run_forever, daemon=True)
5353
thread.start()
54-
ToolboxClient.__thread = thread
55-
ToolboxClient.__loop = loop
54+
ToolboxSyncClient.__thread = thread
55+
ToolboxSyncClient.__loop = loop
5656

5757
async def __start_session() -> None:
5858

5959
# Use a default session if none is provided. This leverages connection
6060
# pooling for better performance by reusing a single session throughout
6161
# the application's lifetime.
62-
if ToolboxClient.__session is None:
63-
ToolboxClient.__session = ClientSession()
62+
if ToolboxSyncClient.__session is None:
63+
ToolboxSyncClient.__session = ClientSession()
6464

6565
coro = __start_session()
6666

67-
asyncio.run_coroutine_threadsafe(coro, ToolboxClient.__loop).result()
67+
asyncio.run_coroutine_threadsafe(coro, ToolboxSyncClient.__loop).result()
6868

69-
if not ToolboxClient.__session:
69+
if not ToolboxSyncClient.__session:
7070
raise ValueError("Session cannot be None.")
71-
self.__async_client = ToolboxClient(url, ToolboxClient.__session)
71+
self.__async_client = ToolboxClient(url, ToolboxSyncClient.__session)
7272

7373
def __run_as_sync(self, coro: Awaitable[T]) -> T:
7474
"""Run an async coroutine synchronously"""
@@ -224,7 +224,7 @@ def close(self):
224224
was created internally by the client.
225225
"""
226226
# Create the coroutine for closing the async client
227-
coro = self.__async_client.close()
227+
coro = self.__session.close()
228228
# Run it synchronously
229229
self.__run_as_sync(coro)
230230

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
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+
import pytest
15+
from toolbox_core.sync_client import ToolboxSyncClient
16+
from toolbox_core.sync_tool import ToolboxSyncTool
17+
18+
19+
# --- Shared Fixtures Defined at Module Level ---
20+
@pytest.fixture(scope="module")
21+
def toolbox():
22+
"""Creates a ToolboxSyncClient instance shared by all tests in this module."""
23+
toolbox = ToolboxSyncClient("http://localhost:5000")
24+
try:
25+
yield toolbox
26+
finally:
27+
toolbox.close()
28+
29+
30+
@pytest.fixture(scope="function")
31+
def get_n_rows_tool(toolbox: ToolboxSyncClient) -> ToolboxSyncTool:
32+
"""Load the 'get-n-rows' tool using the shared toolbox client."""
33+
tool = toolbox.load_tool("get-n-rows")
34+
assert tool.__name__ == "get-n-rows"
35+
return tool
36+
37+
38+
# @pytest.mark.usefixtures("toolbox_server")
39+
class TestBasicE2E:
40+
@pytest.mark.parametrize(
41+
"toolset_name, expected_length, expected_tools",
42+
[
43+
("my-toolset", 1, ["get-row-by-id"]),
44+
("my-toolset-2", 2, ["get-n-rows", "get-row-by-id"]),
45+
],
46+
)
47+
def test_load_toolset_specific(
48+
self,
49+
toolbox: ToolboxSyncClient,
50+
toolset_name: str,
51+
expected_length: int,
52+
expected_tools: list[str],
53+
):
54+
"""Load a specific toolset"""
55+
toolset = toolbox.load_toolset(toolset_name)
56+
assert len(toolset) == expected_length
57+
tool_names = {tool.__name__ for tool in toolset}
58+
assert tool_names == set(expected_tools)
59+
60+
def test_run_tool(self, get_n_rows_tool: ToolboxSyncTool):
61+
"""Invoke a tool."""
62+
response = get_n_rows_tool(num_rows="2")
63+
64+
assert isinstance(response, str)
65+
assert "row1" in response
66+
assert "row2" in response
67+
assert "row3" not in response
68+
69+
def test_run_tool_missing_params(self, get_n_rows_tool: ToolboxSyncTool):
70+
"""Invoke a tool with missing params."""
71+
with pytest.raises(TypeError, match="missing a required argument: 'num_rows'"):
72+
get_n_rows_tool()
73+
74+
def test_run_tool_wrong_param_type(self, get_n_rows_tool: ToolboxSyncTool):
75+
"""Invoke a tool with wrong param type."""
76+
with pytest.raises(
77+
Exception,
78+
match='provided parameters were invalid: unable to parse value for "num_rows": .* not type "string"',
79+
):
80+
get_n_rows_tool(num_rows=2)
81+
82+
83+
@pytest.mark.usefixtures("toolbox_server")
84+
class TestBindParams:
85+
def test_bind_params(
86+
self, toolbox: ToolboxSyncClient, get_n_rows_tool: ToolboxSyncTool
87+
):
88+
"""Bind a param to an existing tool."""
89+
new_tool = get_n_rows_tool.bind_parameters({"num_rows": "3"})
90+
response = new_tool()
91+
assert isinstance(response, str)
92+
assert "row1" in response
93+
assert "row2" in response
94+
assert "row3" in response
95+
assert "row4" not in response
96+
97+
def test_bind_params_callable(
98+
self, toolbox: ToolboxSyncClient, get_n_rows_tool: ToolboxSyncTool
99+
):
100+
"""Bind a callable param to an existing tool."""
101+
new_tool = get_n_rows_tool.bind_parameters({"num_rows": lambda: "3"})
102+
response = new_tool()
103+
assert isinstance(response, str)
104+
assert "row1" in response
105+
assert "row2" in response
106+
assert "row3" in response
107+
assert "row4" not in response
108+
109+
110+
@pytest.mark.usefixtures("toolbox_server")
111+
class TestAuth:
112+
def test_run_tool_unauth_with_auth(
113+
self, toolbox: ToolboxSyncClient, auth_token2: str
114+
):
115+
"""Tests running a tool that doesn't require auth, with auth provided."""
116+
tool = toolbox.load_tool(
117+
"get-row-by-id", auth_token_getters={"my-test-auth": lambda: auth_token2}
118+
)
119+
response = tool(id="2")
120+
assert "row2" in response
121+
122+
def test_run_tool_no_auth(self, toolbox: ToolboxSyncClient):
123+
"""Tests running a tool requiring auth without providing auth."""
124+
tool = toolbox.load_tool("get-row-by-id-auth")
125+
with pytest.raises(
126+
Exception,
127+
match="tool invocation not authorized. Please make sure your specify correct auth headers",
128+
):
129+
tool(id="2")
130+
131+
def test_run_tool_wrong_auth(self, toolbox: ToolboxSyncClient, auth_token2: str):
132+
"""Tests running a tool with incorrect auth. The tool
133+
requires a different authentication than the one provided."""
134+
tool = toolbox.load_tool("get-row-by-id-auth")
135+
auth_tool = tool.add_auth_token_getters({"my-test-auth": lambda: auth_token2})
136+
with pytest.raises(
137+
Exception,
138+
match="tool invocation not authorized",
139+
):
140+
auth_tool(id="2")
141+
142+
def test_run_tool_auth(self, toolbox: ToolboxSyncClient, auth_token1: str):
143+
"""Tests running a tool with correct auth."""
144+
tool = toolbox.load_tool("get-row-by-id-auth")
145+
auth_tool = tool.add_auth_token_getters({"my-test-auth": lambda: auth_token1})
146+
response = auth_tool(id="2")
147+
assert "row2" in response
148+
149+
def test_run_tool_param_auth_no_auth(self, toolbox: ToolboxSyncClient):
150+
"""Tests running a tool with a param requiring auth, without auth."""
151+
tool = toolbox.load_tool("get-row-by-email-auth")
152+
with pytest.raises(
153+
Exception,
154+
match="One or more of the following authn services are required to invoke this tool: my-test-auth",
155+
):
156+
tool()
157+
158+
def test_run_tool_param_auth(self, toolbox: ToolboxSyncClient, auth_token1: str):
159+
"""Tests running a tool with a param requiring auth, with correct auth."""
160+
tool = toolbox.load_tool(
161+
"get-row-by-email-auth",
162+
auth_token_getters={"my-test-auth": lambda: auth_token1},
163+
)
164+
response = tool()
165+
assert "row4" in response
166+
assert "row5" in response
167+
assert "row6" in response
168+
169+
def test_run_tool_param_auth_no_field(
170+
self, toolbox: ToolboxSyncClient, auth_token1: str
171+
):
172+
"""Tests running a tool with a param requiring auth, with insufficient auth."""
173+
tool = toolbox.load_tool(
174+
"get-row-by-content-auth",
175+
auth_token_getters={"my-test-auth": lambda: auth_token1},
176+
)
177+
with pytest.raises(
178+
Exception,
179+
match="no field named row_data in claims",
180+
):
181+
tool()

0 commit comments

Comments
 (0)