From c00b4ad2baf2fc357794dd8e67ce90053a0a71a1 Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Fri, 4 Apr 2025 02:23:34 +0530 Subject: [PATCH 01/11] dep: Add pytest-cov package as a test dependency. --- packages/toolbox-core/pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/toolbox-core/pyproject.toml b/packages/toolbox-core/pyproject.toml index 602b6d1e..f9df761c 100644 --- a/packages/toolbox-core/pyproject.toml +++ b/packages/toolbox-core/pyproject.toml @@ -46,6 +46,7 @@ test = [ "pytest==8.3.5", "pytest-aioresponses==0.3.0", "pytest-asyncio==0.26.0", + "pytest-cov==6.1.0", "google-cloud-secret-manager==2.23.2", "google-cloud-storage==3.1.0", ] From 915a4fd52ccbb05c82881d42684466ddccb8296f Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Fri, 4 Apr 2025 02:23:55 +0530 Subject: [PATCH 02/11] chore: Remove unused imports from sync_client.py --- packages/toolbox-core/src/toolbox_core/sync_client.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/toolbox-core/src/toolbox_core/sync_client.py b/packages/toolbox-core/src/toolbox_core/sync_client.py index 36877223..37ca6437 100644 --- a/packages/toolbox-core/src/toolbox_core/sync_client.py +++ b/packages/toolbox-core/src/toolbox_core/sync_client.py @@ -14,9 +14,7 @@ import asyncio from threading import Thread -from typing import Any, Awaitable, Callable, Mapping, Optional, TypeVar, Union - -from aiohttp import ClientSession +from typing import Any, Callable, Mapping, Optional, TypeVar, Union from .client import ToolboxClient from .sync_tool import ToolboxSyncTool From 8f7fe72189a2efdc9645f206e53dbe5c2b1177ec Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Wed, 9 Apr 2025 18:05:05 +0530 Subject: [PATCH 03/11] chore: Add unit tests for the tool and client classes --- .../toolbox-core/src/toolbox_core/client.py | 2 + packages/toolbox-core/tests/test_client.py | 97 +++++++++++++++ packages/toolbox-core/tests/test_protocol.py | 110 ++++++++++++++++++ 3 files changed, 209 insertions(+) create mode 100644 packages/toolbox-core/tests/test_protocol.py diff --git a/packages/toolbox-core/src/toolbox_core/client.py b/packages/toolbox-core/src/toolbox_core/client.py index bc8ca23c..a534e706 100644 --- a/packages/toolbox-core/src/toolbox_core/client.py +++ b/packages/toolbox-core/src/toolbox_core/client.py @@ -11,6 +11,8 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. + + import types from typing import Any, Callable, Mapping, Optional, Union diff --git a/packages/toolbox-core/tests/test_client.py b/packages/toolbox-core/tests/test_client.py index 2ce600c3..d2875d08 100644 --- a/packages/toolbox-core/tests/test_client.py +++ b/packages/toolbox-core/tests/test_client.py @@ -19,6 +19,7 @@ import pytest import pytest_asyncio from aioresponses import CallbackResult +from unittest.mock import AsyncMock from toolbox_core import ToolboxClient from toolbox_core.protocol import ManifestSchema, ParameterSchema, ToolSchema @@ -301,3 +302,99 @@ async def test_bind_param_fail(self, tool_name, client): with pytest.raises(Exception): tool = tool.bind_parameters({"argC": lambda: 5}) + + +@pytest.mark.asyncio +async def test_new_invoke_tool_server_error(aioresponses, test_tool_str): + """Tests that invoking a tool raises an Exception when the server returns an + error status.""" + TOOL_NAME = "server_error_tool" + ERROR_MESSAGE = "Simulated Server Error" + manifest = ManifestSchema(serverVersion="0.0.0", tools={TOOL_NAME: test_tool_str}) + + aioresponses.get( + f"{TEST_BASE_URL}/api/tool/{TOOL_NAME}", + payload=manifest.model_dump(), + status=200, + ) + aioresponses.post( + f"{TEST_BASE_URL}/api/tool/{TOOL_NAME}/invoke", + payload={"error": ERROR_MESSAGE}, + status=500, + ) + + async with ToolboxClient(TEST_BASE_URL) as client: + loaded_tool = await client.load_tool(TOOL_NAME) + + with pytest.raises(Exception, match=ERROR_MESSAGE): + await loaded_tool(param1="some input") + + +@pytest.mark.asyncio +async def test_bind_param_async_callable_value_success(aioresponses, test_tool_int_bool): + """ + Tests bind_parameters method with an async callable value. + """ + TOOL_NAME = "async_bind_tool" + manifest = ManifestSchema(serverVersion="0.0.0", tools={TOOL_NAME: test_tool_int_bool}) + + aioresponses.get( + f"{TEST_BASE_URL}/api/tool/{TOOL_NAME}", + payload=manifest.model_dump(), status=200 + ) + + def reflect_parameters(url, **kwargs): + received_params = kwargs.get("json", {}) + return CallbackResult(status=200, payload={"result": received_params}) + + aioresponses.post( + f"{TEST_BASE_URL}/api/tool/{TOOL_NAME}/invoke", + callback=reflect_parameters, + ) + + bound_value_result = True + bound_async_callable = AsyncMock(return_value=bound_value_result) + + async with ToolboxClient(TEST_BASE_URL) as client: + tool = await client.load_tool(TOOL_NAME) + bound_tool = tool.bind_parameters({"argB": bound_async_callable}) + + assert bound_tool is not tool + assert "argB" not in bound_tool.__signature__.parameters + assert "argA" in bound_tool.__signature__.parameters + + passed_value_a = 42 + res_payload = await bound_tool(argA=passed_value_a) + + assert res_payload == {"argA": passed_value_a, "argB": bound_value_result} + bound_async_callable.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_new_add_auth_token_getters_duplicate_fail(aioresponses, test_tool_auth): + """ + Tests that adding a duplicate auth token getter raises ValueError. + """ + TOOL_NAME = "duplicate_auth_tool" + AUTH_SERVICE = "my-auth-service" + manifest = ManifestSchema(serverVersion="0.0.0", tools={TOOL_NAME: test_tool_auth}) + + aioresponses.get( + f"{TEST_BASE_URL}/api/tool/{TOOL_NAME}", + payload=manifest.model_dump(), status=200 + ) + + def token_handler_1(): + return "token1" + + def token_handler_2(): + return "token2" + + async with ToolboxClient(TEST_BASE_URL) as client: + tool = await client.load_tool(TOOL_NAME) + + authed_tool = tool.add_auth_token_getters({AUTH_SERVICE: token_handler_1}) + assert AUTH_SERVICE in authed_tool._ToolboxTool__auth_service_token_getters + + with pytest.raises(ValueError, match=f"Authentication source\\(s\\) `{AUTH_SERVICE}` already registered in tool `{TOOL_NAME}`."): + authed_tool.add_auth_token_getters({AUTH_SERVICE: token_handler_2}) diff --git a/packages/toolbox-core/tests/test_protocol.py b/packages/toolbox-core/tests/test_protocol.py new file mode 100644 index 00000000..762c64a0 --- /dev/null +++ b/packages/toolbox-core/tests/test_protocol.py @@ -0,0 +1,110 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from inspect import Parameter + +import pytest + +from toolbox_core.protocol import ParameterSchema + + +def test_parameter_schema_float(): + """Tests ParameterSchema with type 'float'.""" + schema = ParameterSchema(name="price", type="float", description="The item price") + expected_type = float + assert schema._ParameterSchema__get_type() == expected_type + + param = schema.to_param() + assert isinstance(param, Parameter) + assert param.name == "price" + assert param.annotation == expected_type + assert param.kind == Parameter.POSITIONAL_OR_KEYWORD + assert param.default == Parameter.empty + + +def test_parameter_schema_boolean(): + """Tests ParameterSchema with type 'boolean'.""" + schema = ParameterSchema( + name="is_active", type="boolean", description="Activity status" + ) + expected_type = bool + assert schema._ParameterSchema__get_type() == expected_type + + param = schema.to_param() + assert isinstance(param, Parameter) + assert param.name == "is_active" + assert param.annotation == expected_type + assert param.kind == Parameter.POSITIONAL_OR_KEYWORD + + +def test_parameter_schema_array_string(): + """Tests ParameterSchema with type 'array' containing strings.""" + item_schema = ParameterSchema( + name="", type="string", description="" + ) + schema = ParameterSchema( + name="tags", type="array", description="List of tags", items=item_schema + ) + + assert schema._ParameterSchema__get_type() == list[str] + + param = schema.to_param() + assert isinstance(param, Parameter) + assert param.name == "tags" + assert param.annotation == list[str] + assert param.kind == Parameter.POSITIONAL_OR_KEYWORD + + +def test_parameter_schema_array_integer(): + """Tests ParameterSchema with type 'array' containing integers.""" + item_schema = ParameterSchema(name="", type="integer", description="") + schema = ParameterSchema( + name="scores", type="array", description="List of scores", items=item_schema + ) + + param = schema.to_param() + assert isinstance(param, Parameter) + assert param.name == "scores" + assert param.annotation == list[int] + assert param.kind == Parameter.POSITIONAL_OR_KEYWORD + + +def test_parameter_schema_array_no_items_error(): + """Tests that 'array' type raises error if 'items' is None.""" + schema = ParameterSchema( + name="bad_list", type="array", description="List without item type" + ) + + expected_error_msg = "Unexpected value: type is 'list' but items is None" + with pytest.raises(Exception, match=expected_error_msg): + schema._ParameterSchema__get_type() + + with pytest.raises(Exception, match=expected_error_msg): + schema.to_param() + + +def test_parameter_schema_unsupported_type_error(): + """Tests that an unsupported type raises ValueError.""" + unsupported_type = "datetime" + schema = ParameterSchema( + name="event_time", type=unsupported_type, description="When it happened" + ) + + expected_error_msg = f"Unsupported schema type: {unsupported_type}" + with pytest.raises(ValueError, match=expected_error_msg): + schema._ParameterSchema__get_type() + + with pytest.raises(ValueError, match=expected_error_msg): + schema.to_param() From 952aed2e36cf01728b2603508e2d5891b1f3d6bc Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Wed, 9 Apr 2025 18:09:14 +0530 Subject: [PATCH 04/11] chore: Delint --- packages/toolbox-core/tests/test_client.py | 24 +++++++++++++------- packages/toolbox-core/tests/test_protocol.py | 5 +--- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/packages/toolbox-core/tests/test_client.py b/packages/toolbox-core/tests/test_client.py index d2875d08..3350c62d 100644 --- a/packages/toolbox-core/tests/test_client.py +++ b/packages/toolbox-core/tests/test_client.py @@ -15,12 +15,11 @@ import inspect import json +from unittest.mock import AsyncMock import pytest import pytest_asyncio from aioresponses import CallbackResult -from unittest.mock import AsyncMock - from toolbox_core import ToolboxClient from toolbox_core.protocol import ManifestSchema, ParameterSchema, ToolSchema @@ -331,16 +330,21 @@ async def test_new_invoke_tool_server_error(aioresponses, test_tool_str): @pytest.mark.asyncio -async def test_bind_param_async_callable_value_success(aioresponses, test_tool_int_bool): +async def test_bind_param_async_callable_value_success( + aioresponses, test_tool_int_bool +): """ Tests bind_parameters method with an async callable value. """ TOOL_NAME = "async_bind_tool" - manifest = ManifestSchema(serverVersion="0.0.0", tools={TOOL_NAME: test_tool_int_bool}) + manifest = ManifestSchema( + serverVersion="0.0.0", tools={TOOL_NAME: test_tool_int_bool} + ) aioresponses.get( f"{TEST_BASE_URL}/api/tool/{TOOL_NAME}", - payload=manifest.model_dump(), status=200 + payload=manifest.model_dump(), + status=200, ) def reflect_parameters(url, **kwargs): @@ -381,7 +385,8 @@ async def test_new_add_auth_token_getters_duplicate_fail(aioresponses, test_tool aioresponses.get( f"{TEST_BASE_URL}/api/tool/{TOOL_NAME}", - payload=manifest.model_dump(), status=200 + payload=manifest.model_dump(), + status=200, ) def token_handler_1(): @@ -396,5 +401,8 @@ def token_handler_2(): authed_tool = tool.add_auth_token_getters({AUTH_SERVICE: token_handler_1}) assert AUTH_SERVICE in authed_tool._ToolboxTool__auth_service_token_getters - with pytest.raises(ValueError, match=f"Authentication source\\(s\\) `{AUTH_SERVICE}` already registered in tool `{TOOL_NAME}`."): - authed_tool.add_auth_token_getters({AUTH_SERVICE: token_handler_2}) + with pytest.raises( + ValueError, + match=f"Authentication source\\(s\\) `{AUTH_SERVICE}` already registered in tool `{TOOL_NAME}`.", + ): + authed_tool.add_auth_token_getters({AUTH_SERVICE: token_handler_2}) diff --git a/packages/toolbox-core/tests/test_protocol.py b/packages/toolbox-core/tests/test_protocol.py index 762c64a0..73ad8193 100644 --- a/packages/toolbox-core/tests/test_protocol.py +++ b/packages/toolbox-core/tests/test_protocol.py @@ -16,7 +16,6 @@ from inspect import Parameter import pytest - from toolbox_core.protocol import ParameterSchema @@ -51,9 +50,7 @@ def test_parameter_schema_boolean(): def test_parameter_schema_array_string(): """Tests ParameterSchema with type 'array' containing strings.""" - item_schema = ParameterSchema( - name="", type="string", description="" - ) + item_schema = ParameterSchema(name="", type="string", description="") schema = ParameterSchema( name="tags", type="array", description="List of tags", items=item_schema ) From da4efffb297aec457be46c570e92fc08749e8be0 Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Wed, 9 Apr 2025 18:15:26 +0530 Subject: [PATCH 05/11] chore: Delint --- packages/toolbox-core/tests/test_client.py | 1 + packages/toolbox-core/tests/test_protocol.py | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/toolbox-core/tests/test_client.py b/packages/toolbox-core/tests/test_client.py index 3350c62d..c3fb5f38 100644 --- a/packages/toolbox-core/tests/test_client.py +++ b/packages/toolbox-core/tests/test_client.py @@ -20,6 +20,7 @@ import pytest import pytest_asyncio from aioresponses import CallbackResult + from toolbox_core import ToolboxClient from toolbox_core.protocol import ManifestSchema, ParameterSchema, ToolSchema diff --git a/packages/toolbox-core/tests/test_protocol.py b/packages/toolbox-core/tests/test_protocol.py index 73ad8193..a70fa3fe 100644 --- a/packages/toolbox-core/tests/test_protocol.py +++ b/packages/toolbox-core/tests/test_protocol.py @@ -16,6 +16,7 @@ from inspect import Parameter import pytest + from toolbox_core.protocol import ParameterSchema From 51f2f047e391c6167ccc76c4c0efec75ff7677ac Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Wed, 9 Apr 2025 18:20:23 +0530 Subject: [PATCH 06/11] chore: Cover tool not found case --- packages/toolbox-core/tests/test_client.py | 39 +++++++++++++++++----- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/packages/toolbox-core/tests/test_client.py b/packages/toolbox-core/tests/test_client.py index c3fb5f38..12f84c06 100644 --- a/packages/toolbox-core/tests/test_client.py +++ b/packages/toolbox-core/tests/test_client.py @@ -390,20 +390,43 @@ async def test_new_add_auth_token_getters_duplicate_fail(aioresponses, test_tool status=200, ) - def token_handler_1(): - return "token1" - - def token_handler_2(): - return "token2" - async with ToolboxClient(TEST_BASE_URL) as client: tool = await client.load_tool(TOOL_NAME) - authed_tool = tool.add_auth_token_getters({AUTH_SERVICE: token_handler_1}) + authed_tool = tool.add_auth_token_getters({AUTH_SERVICE: {}}) assert AUTH_SERVICE in authed_tool._ToolboxTool__auth_service_token_getters with pytest.raises( ValueError, match=f"Authentication source\\(s\\) `{AUTH_SERVICE}` already registered in tool `{TOOL_NAME}`.", ): - authed_tool.add_auth_token_getters({AUTH_SERVICE: token_handler_2}) + authed_tool.add_auth_token_getters({AUTH_SERVICE: {}}) + + +@pytest.mark.asyncio +async def test_load_tool_not_found_in_manifest(aioresponses, test_tool_str): + """ + Tests that load_tool raises an Exception when the requested tool name + is not found in the manifest returned by the server, using existing fixtures. + """ + ACTUAL_TOOL_IN_MANIFEST = "actual_tool_abc" + REQUESTED_TOOL_NAME = "non_existent_tool_xyz" + + manifest = ManifestSchema( + serverVersion="0.0.0", + tools={ACTUAL_TOOL_IN_MANIFEST: test_tool_str} + ) + + aioresponses.get( + f"{TEST_BASE_URL}/api/tool/{REQUESTED_TOOL_NAME}", + payload=manifest.model_dump(), + status=200, + ) + + async with ToolboxClient(TEST_BASE_URL) as client: + with pytest.raises(Exception, match=f"Tool '{REQUESTED_TOOL_NAME}' not found!"): + await client.load_tool(REQUESTED_TOOL_NAME) + + aioresponses.assert_called_once_with( + f"{TEST_BASE_URL}/api/tool/{REQUESTED_TOOL_NAME}", method='GET' + ) \ No newline at end of file From 56dea104e28f3ea9204c079be93f17f13cf0617c Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Wed, 9 Apr 2025 18:33:48 +0530 Subject: [PATCH 07/11] chore: Add toolbox tool unit test cases --- packages/toolbox-core/tests/test_client.py | 7 +++--- packages/toolbox-core/tests/test_tools.py | 29 ++++++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) create mode 100644 packages/toolbox-core/tests/test_tools.py diff --git a/packages/toolbox-core/tests/test_client.py b/packages/toolbox-core/tests/test_client.py index 12f84c06..cff335da 100644 --- a/packages/toolbox-core/tests/test_client.py +++ b/packages/toolbox-core/tests/test_client.py @@ -413,8 +413,7 @@ async def test_load_tool_not_found_in_manifest(aioresponses, test_tool_str): REQUESTED_TOOL_NAME = "non_existent_tool_xyz" manifest = ManifestSchema( - serverVersion="0.0.0", - tools={ACTUAL_TOOL_IN_MANIFEST: test_tool_str} + serverVersion="0.0.0", tools={ACTUAL_TOOL_IN_MANIFEST: test_tool_str} ) aioresponses.get( @@ -428,5 +427,5 @@ async def test_load_tool_not_found_in_manifest(aioresponses, test_tool_str): await client.load_tool(REQUESTED_TOOL_NAME) aioresponses.assert_called_once_with( - f"{TEST_BASE_URL}/api/tool/{REQUESTED_TOOL_NAME}", method='GET' - ) \ No newline at end of file + f"{TEST_BASE_URL}/api/tool/{REQUESTED_TOOL_NAME}", method="GET" + ) diff --git a/packages/toolbox-core/tests/test_tools.py b/packages/toolbox-core/tests/test_tools.py new file mode 100644 index 00000000..e8fce916 --- /dev/null +++ b/packages/toolbox-core/tests/test_tools.py @@ -0,0 +1,29 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from toolbox_core.tool import create_docstring + + +def test_create_docstring_no_params(): + """ + Tests create_docstring when the params list is empty. + """ + description = "This is a tool description." + params = [] + + result_docstring = create_docstring(description, params) + + assert result_docstring == description + assert "\n\nArgs:" not in result_docstring From 2991ecad9f6de5aee1acb3d5d4bcf48f4ec1c1f9 Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Fri, 11 Apr 2025 01:04:36 +0530 Subject: [PATCH 08/11] chore: Add additional test cases to cover tool invocation and better docstring validation. --- packages/toolbox-core/tests/test_tools.py | 198 +++++++++++++++++++++- 1 file changed, 197 insertions(+), 1 deletion(-) diff --git a/packages/toolbox-core/tests/test_tools.py b/packages/toolbox-core/tests/test_tools.py index e8fce916..e8fc7e99 100644 --- a/packages/toolbox-core/tests/test_tools.py +++ b/packages/toolbox-core/tests/test_tools.py @@ -13,7 +13,115 @@ # limitations under the License. -from toolbox_core.tool import create_docstring +from typing import AsyncGenerator + +import pytest +import pytest_asyncio +from aiohttp import ClientSession +from aioresponses import aioresponses +from pydantic import ValidationError + +from toolbox_core.protocol import ParameterSchema +from toolbox_core.tool import ToolboxTool, create_docstring + +TEST_BASE_URL = "http://toolbox.example.com" +TEST_TOOL_NAME = "sample_tool" + + +@pytest.fixture +def sample_tool_params() -> list[ParameterSchema]: + """Parameters for the sample tool.""" + return [ + ParameterSchema( + name="message", type="string", description="A message to process" + ), + ParameterSchema(name="count", type="integer", description="A number"), + ] + + +@pytest.fixture +def sample_tool_description() -> str: + """Description for the sample tool.""" + return "A sample tool that processes a message and a count." + + +@pytest_asyncio.fixture +async def http_session() -> AsyncGenerator[ClientSession, None]: + """Provides an aiohttp ClientSession that is closed after the test.""" + async with ClientSession() as session: + yield session + + +def test_create_docstring_one_param_real_schema(): + """ + Tests create_docstring with one real ParameterSchema instance. + """ + description = "This tool does one thing." + params = [ + ParameterSchema( + name="input_file", type="string", description="Path to the input file." + ) + ] + + result_docstring = create_docstring(description, params) + + expected_docstring = ( + "This tool does one thing.\n\n" + "Args:\n" + " input_file (str): Path to the input file." + ) + + assert result_docstring == expected_docstring + + +def test_create_docstring_multiple_params_real_schema(): + """ + Tests create_docstring with multiple real ParameterSchema instances. + """ + description = "This tool does multiple things." + params = [ + ParameterSchema(name="query", type="string", description="The search query."), + ParameterSchema( + name="max_results", type="integer", description="Maximum results to return." + ), + ParameterSchema( + name="verbose", type="boolean", description="Enable verbose output." + ), + ] + + result_docstring = create_docstring(description, params) + + expected_docstring = ( + "This tool does multiple things.\n\n" + "Args:\n" + " query (str): The search query.\n" + " max_results (int): Maximum results to return.\n" + " verbose (bool): Enable verbose output." + ) + + assert result_docstring == expected_docstring + + +def test_create_docstring_no_description_real_schema(): + """ + Tests create_docstring with empty description and one real ParameterSchema. + """ + description = "" + params = [ + ParameterSchema( + name="config_id", type="string", description="The ID of the configuration." + ) + ] + + result_docstring = create_docstring(description, params) + + expected_docstring = ( + "\n\nArgs:\n" " config_id (str): The ID of the configuration." + ) + + assert result_docstring == expected_docstring + assert result_docstring.startswith("\n\nArgs:") + assert "config_id (str): The ID of the configuration." in result_docstring def test_create_docstring_no_params(): @@ -27,3 +135,91 @@ def test_create_docstring_no_params(): assert result_docstring == description assert "\n\nArgs:" not in result_docstring + + +@pytest.mark.asyncio +async def test_tool_creation_callable_and_run( + http_session: ClientSession, + sample_tool_params: list[ParameterSchema], + sample_tool_description: str, +): + """ + Tests creating a ToolboxTool, checks callability, and simulates a run. + """ + tool_name = TEST_TOOL_NAME + base_url = TEST_BASE_URL + invoke_url = f"{base_url}/api/tool/{tool_name}/invoke" + + input_args = {"message": "hello world", "count": 5} + expected_payload = input_args.copy() + mock_server_response_body = {"result": "Processed: hello world (5 times)"} + expected_tool_result = mock_server_response_body["result"] + + with aioresponses() as m: + m.post(invoke_url, status=200, payload=mock_server_response_body) + + tool_instance = ToolboxTool( + session=http_session, + base_url=base_url, + name=tool_name, + description=sample_tool_description, + params=sample_tool_params, + required_authn_params={}, + auth_service_token_getters={}, + bound_params={}, + ) + + assert callable(tool_instance), "ToolboxTool instance should be callable" + + assert "message" in tool_instance.__signature__.parameters + assert "count" in tool_instance.__signature__.parameters + assert tool_instance.__signature__.parameters["message"].annotation == str + assert tool_instance.__signature__.parameters["count"].annotation == int + + actual_result = await tool_instance("hello world", 5) + + assert actual_result == expected_tool_result + + m.assert_called_once_with( + invoke_url, method="POST", json=expected_payload, headers={} + ) + + +@pytest.mark.asyncio +async def test_tool_run_with_pydantic_validation_error( + http_session: ClientSession, + sample_tool_params: list[ParameterSchema], + sample_tool_description: str, +): + """ + Tests that calling the tool with incorrect argument types raises an error + due to Pydantic validation *before* making an HTTP request. + """ + tool_name = TEST_TOOL_NAME + base_url = TEST_BASE_URL + invoke_url = f"{base_url}/api/tool/{tool_name}/invoke" + + with aioresponses() as m: + m.post(invoke_url, status=200, payload={"result": "Should not be called"}) + + tool_instance = ToolboxTool( + session=http_session, + base_url=base_url, + name=tool_name, + description=sample_tool_description, + params=sample_tool_params, + required_authn_params={}, + auth_service_token_getters={}, + bound_params={}, + ) + + assert callable(tool_instance) + + with pytest.raises(ValidationError) as exc_info: + await tool_instance(message="hello", count="not-a-number") + + assert ( + "1 validation error for sample_tool\ncount\n Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='not-a-number', input_type=str]\n For further information visit https://errors.pydantic.dev/2.11/v/int_parsing" + in str(exc_info.value) + ) + m.assert_not_called() From d1d3aaa2da19375157166fb26d85fe7a06e27741 Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Fri, 11 Apr 2025 01:15:37 +0530 Subject: [PATCH 09/11] chore: Add test cases for sync and static bound parameter. --- packages/toolbox-core/tests/test_client.py | 106 ++++++++++++++++++++- 1 file changed, 103 insertions(+), 3 deletions(-) diff --git a/packages/toolbox-core/tests/test_client.py b/packages/toolbox-core/tests/test_client.py index cff335da..6fc0b826 100644 --- a/packages/toolbox-core/tests/test_client.py +++ b/packages/toolbox-core/tests/test_client.py @@ -15,7 +15,7 @@ import inspect import json -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock import pytest import pytest_asyncio @@ -284,6 +284,22 @@ async def test_bind_param_success(self, tool_name, client): assert len(tool.__signature__.parameters) == 2 assert "argA" in tool.__signature__.parameters + tool = tool.bind_parameters({"argA": 5}) + + assert len(tool.__signature__.parameters) == 1 + assert "argA" not in tool.__signature__.parameters + + res = await tool(True) + assert "argA" in res + + @pytest.mark.asyncio + async def test_bind_callable_param_success(self, tool_name, client): + """Tests 'bind_param' with a bound parameter specified.""" + tool = await client.load_tool(tool_name) + + assert len(tool.__signature__.parameters) == 2 + assert "argA" in tool.__signature__.parameters + tool = tool.bind_parameters({"argA": lambda: 5}) assert len(tool.__signature__.parameters) == 1 @@ -305,7 +321,7 @@ async def test_bind_param_fail(self, tool_name, client): @pytest.mark.asyncio -async def test_new_invoke_tool_server_error(aioresponses, test_tool_str): +async def test_invoke_tool_server_error(aioresponses, test_tool_str): """Tests that invoking a tool raises an Exception when the server returns an error status.""" TOOL_NAME = "server_error_tool" @@ -330,6 +346,90 @@ async def test_new_invoke_tool_server_error(aioresponses, test_tool_str): await loaded_tool(param1="some input") +@pytest.mark.asyncio +async def test_bind_param_static_value_success(aioresponses, test_tool_int_bool): + """ + Tests bind_parameters method with a static value. + """ + TOOL_NAME = "async_bind_tool" + manifest = ManifestSchema( + serverVersion="0.0.0", tools={TOOL_NAME: test_tool_int_bool} + ) + + aioresponses.get( + f"{TEST_BASE_URL}/api/tool/{TOOL_NAME}", + payload=manifest.model_dump(), + status=200, + ) + + def reflect_parameters(url, **kwargs): + received_params = kwargs.get("json", {}) + return CallbackResult(status=200, payload={"result": received_params}) + + aioresponses.post( + f"{TEST_BASE_URL}/api/tool/{TOOL_NAME}/invoke", + callback=reflect_parameters, + ) + + bound_value = "Test value" + + async with ToolboxClient(TEST_BASE_URL) as client: + tool = await client.load_tool(TOOL_NAME) + bound_tool = tool.bind_parameters({"argB": bound_value}) + + assert bound_tool is not tool + assert "argB" not in bound_tool.__signature__.parameters + assert "argA" in bound_tool.__signature__.parameters + + passed_value_a = 42 + res_payload = await bound_tool(argA=passed_value_a) + + assert res_payload == {"argA": passed_value_a, "argB": bound_value} + + +@pytest.mark.asyncio +async def test_bind_param_sync_callable_value_success(aioresponses, test_tool_int_bool): + """ + Tests bind_parameters method with a sync callable value. + """ + TOOL_NAME = "async_bind_tool" + manifest = ManifestSchema( + serverVersion="0.0.0", tools={TOOL_NAME: test_tool_int_bool} + ) + + aioresponses.get( + f"{TEST_BASE_URL}/api/tool/{TOOL_NAME}", + payload=manifest.model_dump(), + status=200, + ) + + def reflect_parameters(url, **kwargs): + received_params = kwargs.get("json", {}) + return CallbackResult(status=200, payload={"result": received_params}) + + aioresponses.post( + f"{TEST_BASE_URL}/api/tool/{TOOL_NAME}/invoke", + callback=reflect_parameters, + ) + + bound_value_result = True + bound_sync_callable = Mock(return_value=bound_value_result) + + async with ToolboxClient(TEST_BASE_URL) as client: + tool = await client.load_tool(TOOL_NAME) + bound_tool = tool.bind_parameters({"argB": bound_sync_callable}) + + assert bound_tool is not tool + assert "argB" not in bound_tool.__signature__.parameters + assert "argA" in bound_tool.__signature__.parameters + + passed_value_a = 42 + res_payload = await bound_tool(argA=passed_value_a) + + assert res_payload == {"argA": passed_value_a, "argB": bound_value_result} + bound_sync_callable.assert_called_once() + + @pytest.mark.asyncio async def test_bind_param_async_callable_value_success( aioresponses, test_tool_int_bool @@ -376,7 +476,7 @@ def reflect_parameters(url, **kwargs): @pytest.mark.asyncio -async def test_new_add_auth_token_getters_duplicate_fail(aioresponses, test_tool_auth): +async def test_add_auth_token_getters_duplicate_fail(aioresponses, test_tool_auth): """ Tests that adding a duplicate auth token getter raises ValueError. """ From d3e20e5c8a2beb0e91ece664dab44dde47fe1d51 Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Fri, 11 Apr 2025 01:28:43 +0530 Subject: [PATCH 10/11] chore: Reorder tests in matching classes. This will improve maintainability. --- packages/toolbox-core/tests/test_client.py | 274 ++++++++------------- 1 file changed, 98 insertions(+), 176 deletions(-) diff --git a/packages/toolbox-core/tests/test_client.py b/packages/toolbox-core/tests/test_client.py index 6fc0b826..a9cb091a 100644 --- a/packages/toolbox-core/tests/test_client.py +++ b/packages/toolbox-core/tests/test_client.py @@ -131,6 +131,60 @@ async def test_load_toolset_success(aioresponses, test_tool_str, test_tool_int_b assert {t.__name__ for t in tools} == manifest.tools.keys() +@pytest.mark.asyncio +async def test_invoke_tool_server_error(aioresponses, test_tool_str): + """Tests that invoking a tool raises an Exception when the server returns an + error status.""" + TOOL_NAME = "server_error_tool" + ERROR_MESSAGE = "Simulated Server Error" + manifest = ManifestSchema(serverVersion="0.0.0", tools={TOOL_NAME: test_tool_str}) + + aioresponses.get( + f"{TEST_BASE_URL}/api/tool/{TOOL_NAME}", + payload=manifest.model_dump(), + status=200, + ) + aioresponses.post( + f"{TEST_BASE_URL}/api/tool/{TOOL_NAME}/invoke", + payload={"error": ERROR_MESSAGE}, + status=500, + ) + + async with ToolboxClient(TEST_BASE_URL) as client: + loaded_tool = await client.load_tool(TOOL_NAME) + + with pytest.raises(Exception, match=ERROR_MESSAGE): + await loaded_tool(param1="some input") + + +@pytest.mark.asyncio +async def test_load_tool_not_found_in_manifest(aioresponses, test_tool_str): + """ + Tests that load_tool raises an Exception when the requested tool name + is not found in the manifest returned by the server, using existing fixtures. + """ + ACTUAL_TOOL_IN_MANIFEST = "actual_tool_abc" + REQUESTED_TOOL_NAME = "non_existent_tool_xyz" + + manifest = ManifestSchema( + serverVersion="0.0.0", tools={ACTUAL_TOOL_IN_MANIFEST: test_tool_str} + ) + + aioresponses.get( + f"{TEST_BASE_URL}/api/tool/{REQUESTED_TOOL_NAME}", + payload=manifest.model_dump(), + status=200, + ) + + async with ToolboxClient(TEST_BASE_URL) as client: + with pytest.raises(Exception, match=f"Tool '{REQUESTED_TOOL_NAME}' not found!"): + await client.load_tool(REQUESTED_TOOL_NAME) + + aioresponses.assert_called_once_with( + f"{TEST_BASE_URL}/api/tool/{REQUESTED_TOOL_NAME}", method="GET" + ) + + class TestAuth: @pytest.fixture @@ -183,7 +237,7 @@ def token_handler(): tool = await client.load_tool( tool_name, auth_token_getters={"my-auth-service": token_handler} ) - res = await tool(5) + await tool(5) @pytest.mark.asyncio async def test_auth_with_add_token_success( @@ -196,7 +250,7 @@ def token_handler(): tool = await client.load_tool(tool_name) tool = tool.add_auth_token_getters({"my-auth-service": token_handler}) - res = await tool(5) + await tool(5) @pytest.mark.asyncio async def test_auth_with_load_tool_fail_no_token( @@ -204,12 +258,27 @@ async def test_auth_with_load_tool_fail_no_token( ): """Tests 'load_tool' with auth token is specified.""" - def token_handler(): - return expected_header - tool = await client.load_tool(tool_name) with pytest.raises(Exception): - res = await tool(5) + await tool(5) + + @pytest.mark.asyncio + async def test_add_auth_token_getters_duplicate_fail(self, tool_name, client): + """ + Tests that adding a duplicate auth token getter raises ValueError. + """ + AUTH_SERVICE = "my-auth-service" + + tool = await client.load_tool(tool_name) + + authed_tool = tool.add_auth_token_getters({AUTH_SERVICE: {}}) + assert AUTH_SERVICE in authed_tool._ToolboxTool__auth_service_token_getters + + with pytest.raises( + ValueError, + match=f"Authentication source\\(s\\) `{AUTH_SERVICE}` already registered in tool `{tool_name}`.", + ): + authed_tool.add_auth_token_getters({AUTH_SERVICE: {}}) class TestBoundParameter: @@ -319,62 +388,15 @@ async def test_bind_param_fail(self, tool_name, client): with pytest.raises(Exception): tool = tool.bind_parameters({"argC": lambda: 5}) + @pytest.mark.asyncio + async def test_bind_param_static_value_success(self, tool_name, client): + """ + Tests bind_parameters method with a static value. + """ -@pytest.mark.asyncio -async def test_invoke_tool_server_error(aioresponses, test_tool_str): - """Tests that invoking a tool raises an Exception when the server returns an - error status.""" - TOOL_NAME = "server_error_tool" - ERROR_MESSAGE = "Simulated Server Error" - manifest = ManifestSchema(serverVersion="0.0.0", tools={TOOL_NAME: test_tool_str}) - - aioresponses.get( - f"{TEST_BASE_URL}/api/tool/{TOOL_NAME}", - payload=manifest.model_dump(), - status=200, - ) - aioresponses.post( - f"{TEST_BASE_URL}/api/tool/{TOOL_NAME}/invoke", - payload={"error": ERROR_MESSAGE}, - status=500, - ) - - async with ToolboxClient(TEST_BASE_URL) as client: - loaded_tool = await client.load_tool(TOOL_NAME) - - with pytest.raises(Exception, match=ERROR_MESSAGE): - await loaded_tool(param1="some input") - - -@pytest.mark.asyncio -async def test_bind_param_static_value_success(aioresponses, test_tool_int_bool): - """ - Tests bind_parameters method with a static value. - """ - TOOL_NAME = "async_bind_tool" - manifest = ManifestSchema( - serverVersion="0.0.0", tools={TOOL_NAME: test_tool_int_bool} - ) - - aioresponses.get( - f"{TEST_BASE_URL}/api/tool/{TOOL_NAME}", - payload=manifest.model_dump(), - status=200, - ) - - def reflect_parameters(url, **kwargs): - received_params = kwargs.get("json", {}) - return CallbackResult(status=200, payload={"result": received_params}) - - aioresponses.post( - f"{TEST_BASE_URL}/api/tool/{TOOL_NAME}/invoke", - callback=reflect_parameters, - ) - - bound_value = "Test value" + bound_value = "Test value" - async with ToolboxClient(TEST_BASE_URL) as client: - tool = await client.load_tool(TOOL_NAME) + tool = await client.load_tool(tool_name) bound_tool = tool.bind_parameters({"argB": bound_value}) assert bound_tool is not tool @@ -386,37 +408,16 @@ def reflect_parameters(url, **kwargs): assert res_payload == {"argA": passed_value_a, "argB": bound_value} + @pytest.mark.asyncio + async def test_bind_param_sync_callable_value_success(self, tool_name, client): + """ + Tests bind_parameters method with a sync callable value. + """ -@pytest.mark.asyncio -async def test_bind_param_sync_callable_value_success(aioresponses, test_tool_int_bool): - """ - Tests bind_parameters method with a sync callable value. - """ - TOOL_NAME = "async_bind_tool" - manifest = ManifestSchema( - serverVersion="0.0.0", tools={TOOL_NAME: test_tool_int_bool} - ) - - aioresponses.get( - f"{TEST_BASE_URL}/api/tool/{TOOL_NAME}", - payload=manifest.model_dump(), - status=200, - ) - - def reflect_parameters(url, **kwargs): - received_params = kwargs.get("json", {}) - return CallbackResult(status=200, payload={"result": received_params}) - - aioresponses.post( - f"{TEST_BASE_URL}/api/tool/{TOOL_NAME}/invoke", - callback=reflect_parameters, - ) - - bound_value_result = True - bound_sync_callable = Mock(return_value=bound_value_result) + bound_value_result = True + bound_sync_callable = Mock(return_value=bound_value_result) - async with ToolboxClient(TEST_BASE_URL) as client: - tool = await client.load_tool(TOOL_NAME) + tool = await client.load_tool(tool_name) bound_tool = tool.bind_parameters({"argB": bound_sync_callable}) assert bound_tool is not tool @@ -429,39 +430,16 @@ def reflect_parameters(url, **kwargs): assert res_payload == {"argA": passed_value_a, "argB": bound_value_result} bound_sync_callable.assert_called_once() + @pytest.mark.asyncio + async def test_bind_param_async_callable_value_success(self, tool_name, client): + """ + Tests bind_parameters method with an async callable value. + """ -@pytest.mark.asyncio -async def test_bind_param_async_callable_value_success( - aioresponses, test_tool_int_bool -): - """ - Tests bind_parameters method with an async callable value. - """ - TOOL_NAME = "async_bind_tool" - manifest = ManifestSchema( - serverVersion="0.0.0", tools={TOOL_NAME: test_tool_int_bool} - ) - - aioresponses.get( - f"{TEST_BASE_URL}/api/tool/{TOOL_NAME}", - payload=manifest.model_dump(), - status=200, - ) - - def reflect_parameters(url, **kwargs): - received_params = kwargs.get("json", {}) - return CallbackResult(status=200, payload={"result": received_params}) - - aioresponses.post( - f"{TEST_BASE_URL}/api/tool/{TOOL_NAME}/invoke", - callback=reflect_parameters, - ) - - bound_value_result = True - bound_async_callable = AsyncMock(return_value=bound_value_result) + bound_value_result = True + bound_async_callable = AsyncMock(return_value=bound_value_result) - async with ToolboxClient(TEST_BASE_URL) as client: - tool = await client.load_tool(TOOL_NAME) + tool = await client.load_tool(tool_name) bound_tool = tool.bind_parameters({"argB": bound_async_callable}) assert bound_tool is not tool @@ -473,59 +451,3 @@ def reflect_parameters(url, **kwargs): assert res_payload == {"argA": passed_value_a, "argB": bound_value_result} bound_async_callable.assert_awaited_once() - - -@pytest.mark.asyncio -async def test_add_auth_token_getters_duplicate_fail(aioresponses, test_tool_auth): - """ - Tests that adding a duplicate auth token getter raises ValueError. - """ - TOOL_NAME = "duplicate_auth_tool" - AUTH_SERVICE = "my-auth-service" - manifest = ManifestSchema(serverVersion="0.0.0", tools={TOOL_NAME: test_tool_auth}) - - aioresponses.get( - f"{TEST_BASE_URL}/api/tool/{TOOL_NAME}", - payload=manifest.model_dump(), - status=200, - ) - - async with ToolboxClient(TEST_BASE_URL) as client: - tool = await client.load_tool(TOOL_NAME) - - authed_tool = tool.add_auth_token_getters({AUTH_SERVICE: {}}) - assert AUTH_SERVICE in authed_tool._ToolboxTool__auth_service_token_getters - - with pytest.raises( - ValueError, - match=f"Authentication source\\(s\\) `{AUTH_SERVICE}` already registered in tool `{TOOL_NAME}`.", - ): - authed_tool.add_auth_token_getters({AUTH_SERVICE: {}}) - - -@pytest.mark.asyncio -async def test_load_tool_not_found_in_manifest(aioresponses, test_tool_str): - """ - Tests that load_tool raises an Exception when the requested tool name - is not found in the manifest returned by the server, using existing fixtures. - """ - ACTUAL_TOOL_IN_MANIFEST = "actual_tool_abc" - REQUESTED_TOOL_NAME = "non_existent_tool_xyz" - - manifest = ManifestSchema( - serverVersion="0.0.0", tools={ACTUAL_TOOL_IN_MANIFEST: test_tool_str} - ) - - aioresponses.get( - f"{TEST_BASE_URL}/api/tool/{REQUESTED_TOOL_NAME}", - payload=manifest.model_dump(), - status=200, - ) - - async with ToolboxClient(TEST_BASE_URL) as client: - with pytest.raises(Exception, match=f"Tool '{REQUESTED_TOOL_NAME}' not found!"): - await client.load_tool(REQUESTED_TOOL_NAME) - - aioresponses.assert_called_once_with( - f"{TEST_BASE_URL}/api/tool/{REQUESTED_TOOL_NAME}", method="GET" - ) From 13fe2a8df70556106cc7dd24539372429d7e2ab3 Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Wed, 16 Apr 2025 12:48:56 +0530 Subject: [PATCH 11/11] feat: Add support for async token getters to ToolboxTool (#147) * feat: Add support for async token getters to ToolboxTool * chore: Improve variable names and docstring for more clarity * chore: Improve docstring * chore: Add unit test cases * chore: Add e2e test case * chore: Fix e2e test case --- .../toolbox-core/src/toolbox_core/tool.py | 34 ++++++++-- packages/toolbox-core/tests/test_e2e.py | 14 ++++ packages/toolbox-core/tests/test_tools.py | 64 ++++++++++++++++++- 3 files changed, 105 insertions(+), 7 deletions(-) diff --git a/packages/toolbox-core/src/toolbox_core/tool.py b/packages/toolbox-core/src/toolbox_core/tool.py index 04e803bc..3436580a 100644 --- a/packages/toolbox-core/src/toolbox_core/tool.py +++ b/packages/toolbox-core/src/toolbox_core/tool.py @@ -18,6 +18,7 @@ from inspect import Signature from typing import ( Any, + Awaitable, Callable, Iterable, Mapping, @@ -181,16 +182,12 @@ async def __call__(self, *args: Any, **kwargs: Any) -> str: # apply bounded parameters for param, value in self.__bound_parameters.items(): - if asyncio.iscoroutinefunction(value): - value = await value() - elif callable(value): - value = value() - payload[param] = value + payload[param] = await resolve_value(value) # create headers for auth services headers = {} for auth_service, token_getter in self.__auth_service_token_getters.items(): - headers[f"{auth_service}_token"] = token_getter() + headers[f"{auth_service}_token"] = await resolve_value(token_getter) async with self.__session.post( self.__url, @@ -330,3 +327,28 @@ def params_to_pydantic_model( ), ) return create_model(tool_name, **field_definitions) + + +async def resolve_value( + source: Union[Callable[[], Awaitable[Any]], Callable[[], Any], Any], +) -> Any: + """ + Asynchronously or synchronously resolves a given source to its value. + + If the `source` is a coroutine function, it will be awaited. + If the `source` is a regular callable, it will be called. + Otherwise (if it's not a callable), the `source` itself is returned directly. + + Args: + source: The value, a callable returning a value, or a callable + returning an awaitable value. + + Returns: + The resolved value. + """ + + if asyncio.iscoroutinefunction(source): + return await source() + elif callable(source): + return source() + return source diff --git a/packages/toolbox-core/tests/test_e2e.py b/packages/toolbox-core/tests/test_e2e.py index 68fffa75..cf7b21d1 100644 --- a/packages/toolbox-core/tests/test_e2e.py +++ b/packages/toolbox-core/tests/test_e2e.py @@ -166,6 +166,20 @@ async def test_run_tool_auth(self, toolbox: ToolboxClient, auth_token1: str): response = await auth_tool(id="2") assert "row2" in response + @pytest.mark.asyncio + async def test_run_tool_async_auth(self, toolbox: ToolboxClient, auth_token1: str): + """Tests running a tool with correct auth using an async token getter.""" + tool = await toolbox.load_tool("get-row-by-id-auth") + + async def get_token_asynchronously(): + return auth_token1 + + auth_tool = tool.add_auth_token_getters( + {"my-test-auth": get_token_asynchronously} + ) + response = await auth_tool(id="2") + assert "row2" in response + async def test_run_tool_param_auth_no_auth(self, toolbox: ToolboxClient): """Tests running a tool with a param requiring auth, without auth.""" tool = await toolbox.load_tool("get-row-by-email-auth") diff --git a/packages/toolbox-core/tests/test_tools.py b/packages/toolbox-core/tests/test_tools.py index e8fc7e99..505aa7f7 100644 --- a/packages/toolbox-core/tests/test_tools.py +++ b/packages/toolbox-core/tests/test_tools.py @@ -14,6 +14,7 @@ from typing import AsyncGenerator +from unittest.mock import AsyncMock, Mock import pytest import pytest_asyncio @@ -22,7 +23,7 @@ from pydantic import ValidationError from toolbox_core.protocol import ParameterSchema -from toolbox_core.tool import ToolboxTool, create_docstring +from toolbox_core.tool import ToolboxTool, create_docstring, resolve_value TEST_BASE_URL = "http://toolbox.example.com" TEST_TOOL_NAME = "sample_tool" @@ -223,3 +224,64 @@ async def test_tool_run_with_pydantic_validation_error( in str(exc_info.value) ) m.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "non_callable_source", + [ + "a simple string", + 12345, + True, + False, + None, + [1, "two", 3.0], + {"key": "value", "number": 100}, + object(), + ], + ids=[ + "string", + "integer", + "bool_true", + "bool_false", + "none", + "list", + "dict", + "object", + ], +) +async def test_resolve_value_non_callable(non_callable_source): + """ + Tests resolve_value when the source is not callable. + """ + resolved = await resolve_value(non_callable_source) + + assert resolved is non_callable_source + + +@pytest.mark.asyncio +async def test_resolve_value_sync_callable(): + """ + Tests resolve_value with a synchronous callable. + """ + expected_value = "sync result" + sync_callable = Mock(return_value=expected_value) + + resolved = await resolve_value(sync_callable) + + sync_callable.assert_called_once() + assert resolved == expected_value + + +@pytest.mark.asyncio +async def test_resolve_value_async_callable(): + """ + Tests resolve_value with an asynchronous callable (coroutine function). + """ + expected_value = "async result" + async_callable = AsyncMock(return_value=expected_value) + + resolved = await resolve_value(async_callable) + + async_callable.assert_awaited_once() + assert resolved == expected_value