From c00b4ad2baf2fc357794dd8e67ce90053a0a71a1 Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Fri, 4 Apr 2025 02:23:34 +0530 Subject: [PATCH 01/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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 a945a4b57f9def60ce77cdc8f49f48302a86b60e Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Fri, 4 Apr 2025 18:33:50 +0530 Subject: [PATCH 11/20] feat: Add support for async token getters to ToolboxTool --- packages/toolbox-core/src/toolbox_core/tool.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/toolbox-core/src/toolbox_core/tool.py b/packages/toolbox-core/src/toolbox_core/tool.py index 04e803bc..0ac454ca 100644 --- a/packages/toolbox-core/src/toolbox_core/tool.py +++ b/packages/toolbox-core/src/toolbox_core/tool.py @@ -181,16 +181,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 get_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 get_value(token_getter) async with self.__session.post( self.__url, @@ -330,3 +326,12 @@ def params_to_pydantic_model( ), ) return create_model(tool_name, **field_definitions) + + +async def get_value(func: Callable[[], Any]) -> Any: + """Asynchronously or synchronously gets the value from a callable.""" + if asyncio.iscoroutinefunction(func): + return await func() + elif callable(func): + return func() + return func From 1fc2a850c05b07f225afcf23581731fdcd3f2e7f Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Sat, 5 Apr 2025 13:05:23 +0530 Subject: [PATCH 12/20] chore: Improve variable names and docstring for more clarity --- .../toolbox-core/src/toolbox_core/tool.py | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/toolbox-core/src/toolbox_core/tool.py b/packages/toolbox-core/src/toolbox_core/tool.py index 0ac454ca..1abeb75b 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,12 +182,12 @@ async def __call__(self, *args: Any, **kwargs: Any) -> str: # apply bounded parameters for param, value in self.__bound_parameters.items(): - payload[param] = await get_value(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"] = await get_value(token_getter) + headers[f"{auth_service}_token"] = await resolve_value(token_getter) async with self.__session.post( self.__url, @@ -328,10 +329,21 @@ def params_to_pydantic_model( return create_model(tool_name, **field_definitions) -async def get_value(func: Callable[[], Any]) -> Any: - """Asynchronously or synchronously gets the value from a callable.""" - if asyncio.iscoroutinefunction(func): - return await func() - elif callable(func): - return func() - return func +async def resolve_value( + source: Union[Callable[[], Awaitable[Any]], Callable[[], Any], Any], +) -> Any: + """ + Asynchronously or synchronously resolves a given source to its value. + + 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 From e3fcadeddee88252d2fde0d636901feccb3c4d6c Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Sat, 5 Apr 2025 14:42:29 +0530 Subject: [PATCH 13/20] chore: Improve docstring --- packages/toolbox-core/src/toolbox_core/tool.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/toolbox-core/src/toolbox_core/tool.py b/packages/toolbox-core/src/toolbox_core/tool.py index 1abeb75b..3436580a 100644 --- a/packages/toolbox-core/src/toolbox_core/tool.py +++ b/packages/toolbox-core/src/toolbox_core/tool.py @@ -335,6 +335,10 @@ async def resolve_value( """ 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. @@ -342,6 +346,7 @@ async def resolve_value( Returns: The resolved value. """ + if asyncio.iscoroutinefunction(source): return await source() elif callable(source): From 17d7f85369d4ac5a70407d4fd49f71ed42a9d41e Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Wed, 9 Apr 2025 19:15:13 +0530 Subject: [PATCH 14/20] chore: Add unit test cases --- packages/toolbox-core/tests/test_tools.py | 64 ++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) 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 From 538b384fa1bbffa19e7e9d691ada7b91c7d63407 Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Fri, 11 Apr 2025 01:39:13 +0530 Subject: [PATCH 15/20] chore: Add e2e test case --- packages/toolbox-core/tests/test_e2e.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/toolbox-core/tests/test_e2e.py b/packages/toolbox-core/tests/test_e2e.py index 68fffa75..2284dc59 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(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") From c80fadcf66219f0dd29a24459686c2a1791acefc Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Fri, 11 Apr 2025 11:05:51 +0530 Subject: [PATCH 16/20] chore: Fix e2e test case --- packages/toolbox-core/tests/test_e2e.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/toolbox-core/tests/test_e2e.py b/packages/toolbox-core/tests/test_e2e.py index 2284dc59..cf7b21d1 100644 --- a/packages/toolbox-core/tests/test_e2e.py +++ b/packages/toolbox-core/tests/test_e2e.py @@ -167,7 +167,7 @@ async def test_run_tool_auth(self, toolbox: ToolboxClient, auth_token1: str): assert "row2" in response @pytest.mark.asyncio - async def test_run_tool_async_auth(toolbox: ToolboxClient, auth_token1: str): + 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") From e005dabd58b2554e0a1e2113307b6796afb1a212 Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Tue, 15 Apr 2025 16:45:46 +0530 Subject: [PATCH 17/20] chore(toolbox-core): Move util functions to a separate file in toolbox core Also includes unit test cases for the new file. --- .../toolbox-core/src/toolbox_core/tool.py | 94 +------ .../toolbox-core/src/toolbox_core/utils.py | 112 ++++++++ packages/toolbox-core/tests/test_utils.py | 243 ++++++++++++++++++ 3 files changed, 362 insertions(+), 87 deletions(-) create mode 100644 packages/toolbox-core/src/toolbox_core/utils.py create mode 100644 packages/toolbox-core/tests/test_utils.py diff --git a/packages/toolbox-core/src/toolbox_core/tool.py b/packages/toolbox-core/src/toolbox_core/tool.py index 3436580a..f1895a02 100644 --- a/packages/toolbox-core/src/toolbox_core/tool.py +++ b/packages/toolbox-core/src/toolbox_core/tool.py @@ -13,27 +13,28 @@ # limitations under the License. -import asyncio import types from inspect import Signature from typing import ( Any, - Awaitable, Callable, - Iterable, Mapping, Optional, Sequence, - Type, Union, - cast, ) from aiohttp import ClientSession -from pydantic import BaseModel, Field, create_model from toolbox_core.protocol import ParameterSchema +from .utils import ( + create_docstring, + identify_required_authn_params, + params_to_pydantic_model, + resolve_value, +) + class ToolboxTool: """ @@ -271,84 +272,3 @@ def bind_parameters( params=new_params, bound_params=types.MappingProxyType(all_bound_params), ) - - -def create_docstring(description: str, params: Sequence[ParameterSchema]) -> str: - """Convert tool description and params into its function docstring""" - docstring = description - if not params: - return docstring - docstring += "\n\nArgs:" - for p in params: - docstring += ( - f"\n {p.name} ({p.to_param().annotation.__name__}): {p.description}" - ) - return docstring - - -def identify_required_authn_params( - req_authn_params: Mapping[str, list[str]], auth_service_names: Iterable[str] -) -> dict[str, list[str]]: - """ - Identifies authentication parameters that are still required; because they - not covered by the provided `auth_service_names`. - - Args: - req_authn_params: A mapping of parameter names to sets of required - authentication services. - auth_service_names: An iterable of authentication service names for which - token getters are available. - - Returns: - A new dictionary representing the subset of required authentication parameters - that are not covered by the provided `auth_services`. - """ - required_params = {} # params that are still required with provided auth_services - for param, services in req_authn_params.items(): - # if we don't have a token_getter for any of the services required by the param, - # the param is still required - required = not any(s in services for s in auth_service_names) - if required: - required_params[param] = services - return required_params - - -def params_to_pydantic_model( - tool_name: str, params: Sequence[ParameterSchema] -) -> Type[BaseModel]: - """Converts the given parameters to a Pydantic BaseModel class.""" - field_definitions = {} - for field in params: - field_definitions[field.name] = cast( - Any, - ( - field.to_param().annotation, - Field(description=field.description), - ), - ) - 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/src/toolbox_core/utils.py b/packages/toolbox-core/src/toolbox_core/utils.py new file mode 100644 index 00000000..ea1f6ea6 --- /dev/null +++ b/packages/toolbox-core/src/toolbox_core/utils.py @@ -0,0 +1,112 @@ +# 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. + + +import asyncio +from typing import ( + Any, + Awaitable, + Callable, + Iterable, + Mapping, + Sequence, + Type, + Union, + cast, +) + +from pydantic import BaseModel, Field, create_model + +from toolbox_core.protocol import ParameterSchema + + +def create_docstring(description: str, params: Sequence[ParameterSchema]) -> str: + """Convert tool description and params into its function docstring""" + docstring = description + if not params: + return docstring + docstring += "\n\nArgs:" + for p in params: + docstring += ( + f"\n {p.name} ({p.to_param().annotation.__name__}): {p.description}" + ) + return docstring + + +def identify_required_authn_params( + req_authn_params: Mapping[str, list[str]], auth_service_names: Iterable[str] +) -> dict[str, list[str]]: + """ + Identifies authentication parameters that are still required; because they + not covered by the provided `auth_service_names`. + + Args: + req_authn_params: A mapping of parameter names to sets of required + authentication services. + auth_service_names: An iterable of authentication service names for which + token getters are available. + + Returns: + A new dictionary representing the subset of required authentication parameters + that are not covered by the provided `auth_services`. + """ + required_params = {} # params that are still required with provided auth_services + for param, services in req_authn_params.items(): + # if we don't have a token_getter for any of the services required by the param, + # the param is still required + required = not any(s in services for s in auth_service_names) + if required: + required_params[param] = services + return required_params + + +def params_to_pydantic_model( + tool_name: str, params: Sequence[ParameterSchema] +) -> Type[BaseModel]: + """Converts the given parameters to a Pydantic BaseModel class.""" + field_definitions = {} + for field in params: + field_definitions[field.name] = cast( + Any, + ( + field.to_param().annotation, + Field(description=field.description), + ), + ) + 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_utils.py b/packages/toolbox-core/tests/test_utils.py new file mode 100644 index 00000000..829511f3 --- /dev/null +++ b/packages/toolbox-core/tests/test_utils.py @@ -0,0 +1,243 @@ +import asyncio +from typing import Type +from unittest.mock import Mock + +import pytest +from pydantic import BaseModel, ValidationError + +from toolbox_core.protocol import ParameterSchema +from toolbox_core.utils import ( + create_docstring, + identify_required_authn_params, + params_to_pydantic_model, + resolve_value, +) + + +def create_param_mock(name: str, description: str, annotation: Type) -> Mock: + """Creates a mock for ParameterSchema.""" + param_mock = Mock(spec=ParameterSchema) + param_mock.name = name + param_mock.description = description + + mock_param_info = Mock() + mock_param_info.annotation = annotation + + param_mock.to_param.return_value = mock_param_info + return param_mock + + +def test_create_docstring_no_params(): + """Test create_docstring with no parameters.""" + description = "This is a tool description." + params = [] + expected_docstring = "This is a tool description." + assert create_docstring(description, params) == expected_docstring + + +def test_create_docstring_with_params(): + """Test create_docstring with multiple parameters using mocks.""" + description = "Tool description." + params = [ + create_param_mock( + name="param1", description="First parameter.", annotation=str + ), + create_param_mock(name="count", description="A number.", annotation=int), + ] + expected_docstring = """Tool description. + +Args: + param1 (str): First parameter. + count (int): A number.""" + assert create_docstring(description, params) == expected_docstring + + +def test_create_docstring_empty_description(): + """Test create_docstring with an empty description using mocks.""" + description = "" + params = [ + create_param_mock( + name="param1", description="First parameter.", annotation=str + ), + ] + expected_docstring = """ + +Args: + param1 (str): First parameter.""" + assert create_docstring(description, params) == expected_docstring + + +def test_identify_required_authn_params_none_required(): + """Test when no authentication parameters are required initially.""" + req_authn_params = {} + auth_service_names = ["service_a", "service_b"] + expected = {} + assert ( + identify_required_authn_params(req_authn_params, auth_service_names) == expected + ) + + +def test_identify_required_authn_params_all_covered(): + """Test when all required parameters are covered by available services.""" + req_authn_params = { + "token_a": ["service_a"], + "token_b": ["service_b", "service_c"], + } + auth_service_names = ["service_a", "service_b"] + expected = {} + assert ( + identify_required_authn_params(req_authn_params, auth_service_names) == expected + ) + + +def test_identify_required_authn_params_some_covered(): + """Test when some parameters are covered, and some are not.""" + req_authn_params = { + "token_a": ["service_a"], + "token_b": ["service_b", "service_c"], + "token_d": ["service_d"], + "token_e": ["service_e", "service_f"], + } + auth_service_names = ["service_a", "service_b"] + expected = { + "token_d": ["service_d"], + "token_e": ["service_e", "service_f"], + } + assert ( + identify_required_authn_params(req_authn_params, auth_service_names) == expected + ) + + +def test_identify_required_authn_params_none_covered(): + """Test when none of the required parameters are covered.""" + req_authn_params = { + "token_d": ["service_d"], + "token_e": ["service_e", "service_f"], + } + auth_service_names = ["service_a", "service_b"] + expected = { + "token_d": ["service_d"], + "token_e": ["service_e", "service_f"], + } + assert ( + identify_required_authn_params(req_authn_params, auth_service_names) == expected + ) + + +def test_identify_required_authn_params_no_available_services(): + """Test when no authentication services are available.""" + req_authn_params = { + "token_a": ["service_a"], + "token_b": ["service_b", "service_c"], + } + auth_service_names = [] + expected = { + "token_a": ["service_a"], + "token_b": ["service_b", "service_c"], + } + assert ( + identify_required_authn_params(req_authn_params, auth_service_names) == expected + ) + + +def test_identify_required_authn_params_empty_services_for_param(): + """Test edge case where a param requires an empty list of services.""" + req_authn_params = { + "token_x": [], + } + auth_service_names = ["service_a"] + expected = { + "token_x": [], + } + assert ( + identify_required_authn_params(req_authn_params, auth_service_names) == expected + ) + + +def test_params_to_pydantic_model_no_params(): + """Test creating a Pydantic model with no parameters.""" + tool_name = "NoParamTool" + params = [] + Model = params_to_pydantic_model(tool_name, params) + + assert issubclass(Model, BaseModel) + assert Model.__name__ == tool_name + assert not Model.model_fields + + instance = Model() + assert isinstance(instance, BaseModel) + + +def test_params_to_pydantic_model_with_params(): + """Test creating a Pydantic model with various parameter types using mocks.""" + tool_name = "MyTool" + params = [ + create_param_mock(name="name", description="User name", annotation=str), + create_param_mock(name="age", description="User age", annotation=int), + create_param_mock( + name="is_active", description="Activity status", annotation=bool + ), + ] + Model = params_to_pydantic_model(tool_name, params) + + assert issubclass(Model, BaseModel) + assert Model.__name__ == tool_name + assert len(Model.model_fields) == 3 + + assert "name" in Model.model_fields + assert Model.model_fields["name"].annotation == str + assert Model.model_fields["name"].description == "User name" + + assert "age" in Model.model_fields + assert Model.model_fields["age"].annotation == int + assert Model.model_fields["age"].description == "User age" + + assert "is_active" in Model.model_fields + assert Model.model_fields["is_active"].annotation == bool + assert Model.model_fields["is_active"].description == "Activity status" + + instance = Model(name="Alice", age=30, is_active=True) + assert instance.name == "Alice" + assert instance.age == 30 + assert instance.is_active is True + + with pytest.raises(ValidationError): + Model(name="Bob", age="thirty", is_active=True) + + +@pytest.mark.asyncio +async def test_resolve_value_plain_value(): + """Test resolving a plain, non-callable value.""" + value = 123 + assert await resolve_value(value) == 123 + + value = "hello" + assert await resolve_value(value) == "hello" + + value = None + assert await resolve_value(value) is None + + +@pytest.mark.asyncio +async def test_resolve_value_sync_callable(): + """Test resolving a synchronous callable using Mock.""" + mock_sync_func = Mock(return_value="sync result") + assert await resolve_value(mock_sync_func) == "sync result" + mock_sync_func.assert_called_once() + assert await resolve_value(lambda: [1, 2, 3]) == [1, 2, 3] + + +@pytest.mark.asyncio +async def test_resolve_value_async_callable(): + """Test resolving an asynchronous callable (coroutine function).""" + + async def async_func(): + await asyncio.sleep(0.01) + return "async result" + + assert await resolve_value(async_func) == "async result" + + async def another_async_func(): + return {"key": "value"} + + assert await resolve_value(another_async_func) == {"key": "value"} From 613ed14c78e81444dd27aa6d8df2721bf968a15d Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Tue, 15 Apr 2025 16:48:59 +0530 Subject: [PATCH 18/20] chore: Add licence header to tests file --- packages/toolbox-core/tests/test_utils.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/toolbox-core/tests/test_utils.py b/packages/toolbox-core/tests/test_utils.py index 829511f3..ceeb0a4a 100644 --- a/packages/toolbox-core/tests/test_utils.py +++ b/packages/toolbox-core/tests/test_utils.py @@ -1,3 +1,18 @@ +# 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. + + import asyncio from typing import Type from unittest.mock import Mock From a0eb9d198921af14d81042a929e98c8cca7cb5e9 Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Wed, 16 Apr 2025 13:25:22 +0530 Subject: [PATCH 19/20] doc: Fix a typo --- packages/toolbox-core/src/toolbox_core/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/toolbox-core/src/toolbox_core/utils.py b/packages/toolbox-core/src/toolbox_core/utils.py index ea1f6ea6..9ab39240 100644 --- a/packages/toolbox-core/src/toolbox_core/utils.py +++ b/packages/toolbox-core/src/toolbox_core/utils.py @@ -49,7 +49,7 @@ def identify_required_authn_params( ) -> dict[str, list[str]]: """ Identifies authentication parameters that are still required; because they - not covered by the provided `auth_service_names`. + are not covered by the provided `auth_service_names`. Args: req_authn_params: A mapping of parameter names to sets of required From 3a5821323010ec6d16a292c4e6fe5e5a6aa35831 Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Wed, 16 Apr 2025 14:48:08 +0530 Subject: [PATCH 20/20] chore: Rename helper function to better reflect the functionality. --- .../toolbox-core/src/toolbox_core/tool.py | 4 +-- .../toolbox-core/src/toolbox_core/utils.py | 2 +- packages/toolbox-core/tests/test_tools.py | 26 +++++++++---------- packages/toolbox-core/tests/test_utils.py | 20 +++++++------- 4 files changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/toolbox-core/src/toolbox_core/tool.py b/packages/toolbox-core/src/toolbox_core/tool.py index f1895a02..3150be94 100644 --- a/packages/toolbox-core/src/toolbox_core/tool.py +++ b/packages/toolbox-core/src/toolbox_core/tool.py @@ -29,7 +29,7 @@ from toolbox_core.protocol import ParameterSchema from .utils import ( - create_docstring, + create_func_docstring, identify_required_authn_params, params_to_pydantic_model, resolve_value, @@ -89,7 +89,7 @@ def __init__( # the following properties are set to help anyone that might inspect it determine usage self.__name__ = name - self.__doc__ = create_docstring(self.__description, self.__params) + self.__doc__ = create_func_docstring(self.__description, self.__params) self.__signature__ = Signature( parameters=inspect_type_params, return_annotation=str ) diff --git a/packages/toolbox-core/src/toolbox_core/utils.py b/packages/toolbox-core/src/toolbox_core/utils.py index 9ab39240..4c4ec5a7 100644 --- a/packages/toolbox-core/src/toolbox_core/utils.py +++ b/packages/toolbox-core/src/toolbox_core/utils.py @@ -31,7 +31,7 @@ from toolbox_core.protocol import ParameterSchema -def create_docstring(description: str, params: Sequence[ParameterSchema]) -> str: +def create_func_docstring(description: str, params: Sequence[ParameterSchema]) -> str: """Convert tool description and params into its function docstring""" docstring = description if not params: diff --git a/packages/toolbox-core/tests/test_tools.py b/packages/toolbox-core/tests/test_tools.py index 505aa7f7..7cb4f305 100644 --- a/packages/toolbox-core/tests/test_tools.py +++ b/packages/toolbox-core/tests/test_tools.py @@ -23,7 +23,7 @@ from pydantic import ValidationError from toolbox_core.protocol import ParameterSchema -from toolbox_core.tool import ToolboxTool, create_docstring, resolve_value +from toolbox_core.tool import ToolboxTool, create_func_docstring, resolve_value TEST_BASE_URL = "http://toolbox.example.com" TEST_TOOL_NAME = "sample_tool" @@ -53,9 +53,9 @@ async def http_session() -> AsyncGenerator[ClientSession, None]: yield session -def test_create_docstring_one_param_real_schema(): +def test_create_func_docstring_one_param_real_schema(): """ - Tests create_docstring with one real ParameterSchema instance. + Tests create_func_docstring with one real ParameterSchema instance. """ description = "This tool does one thing." params = [ @@ -64,7 +64,7 @@ def test_create_docstring_one_param_real_schema(): ) ] - result_docstring = create_docstring(description, params) + result_docstring = create_func_docstring(description, params) expected_docstring = ( "This tool does one thing.\n\n" @@ -75,9 +75,9 @@ def test_create_docstring_one_param_real_schema(): assert result_docstring == expected_docstring -def test_create_docstring_multiple_params_real_schema(): +def test_create_func_docstring_multiple_params_real_schema(): """ - Tests create_docstring with multiple real ParameterSchema instances. + Tests create_func_docstring with multiple real ParameterSchema instances. """ description = "This tool does multiple things." params = [ @@ -90,7 +90,7 @@ def test_create_docstring_multiple_params_real_schema(): ), ] - result_docstring = create_docstring(description, params) + result_docstring = create_func_docstring(description, params) expected_docstring = ( "This tool does multiple things.\n\n" @@ -103,9 +103,9 @@ def test_create_docstring_multiple_params_real_schema(): assert result_docstring == expected_docstring -def test_create_docstring_no_description_real_schema(): +def test_create_func_docstring_no_description_real_schema(): """ - Tests create_docstring with empty description and one real ParameterSchema. + Tests create_func_docstring with empty description and one real ParameterSchema. """ description = "" params = [ @@ -114,7 +114,7 @@ def test_create_docstring_no_description_real_schema(): ) ] - result_docstring = create_docstring(description, params) + result_docstring = create_func_docstring(description, params) expected_docstring = ( "\n\nArgs:\n" " config_id (str): The ID of the configuration." @@ -125,14 +125,14 @@ def test_create_docstring_no_description_real_schema(): assert "config_id (str): The ID of the configuration." in result_docstring -def test_create_docstring_no_params(): +def test_create_func_docstring_no_params(): """ - Tests create_docstring when the params list is empty. + Tests create_func_docstring when the params list is empty. """ description = "This is a tool description." params = [] - result_docstring = create_docstring(description, params) + result_docstring = create_func_docstring(description, params) assert result_docstring == description assert "\n\nArgs:" not in result_docstring diff --git a/packages/toolbox-core/tests/test_utils.py b/packages/toolbox-core/tests/test_utils.py index ceeb0a4a..b71284b6 100644 --- a/packages/toolbox-core/tests/test_utils.py +++ b/packages/toolbox-core/tests/test_utils.py @@ -22,7 +22,7 @@ from toolbox_core.protocol import ParameterSchema from toolbox_core.utils import ( - create_docstring, + create_func_docstring, identify_required_authn_params, params_to_pydantic_model, resolve_value, @@ -42,16 +42,16 @@ def create_param_mock(name: str, description: str, annotation: Type) -> Mock: return param_mock -def test_create_docstring_no_params(): - """Test create_docstring with no parameters.""" +def test_create_func_docstring_no_params(): + """Test create_func_docstring with no parameters.""" description = "This is a tool description." params = [] expected_docstring = "This is a tool description." - assert create_docstring(description, params) == expected_docstring + assert create_func_docstring(description, params) == expected_docstring -def test_create_docstring_with_params(): - """Test create_docstring with multiple parameters using mocks.""" +def test_create_func_docstring_with_params(): + """Test create_func_docstring with multiple parameters using mocks.""" description = "Tool description." params = [ create_param_mock( @@ -64,11 +64,11 @@ def test_create_docstring_with_params(): Args: param1 (str): First parameter. count (int): A number.""" - assert create_docstring(description, params) == expected_docstring + assert create_func_docstring(description, params) == expected_docstring -def test_create_docstring_empty_description(): - """Test create_docstring with an empty description using mocks.""" +def test_create_func_docstring_empty_description(): + """Test create_func_docstring with an empty description using mocks.""" description = "" params = [ create_param_mock( @@ -79,7 +79,7 @@ def test_create_docstring_empty_description(): Args: param1 (str): First parameter.""" - assert create_docstring(description, params) == expected_docstring + assert create_func_docstring(description, params) == expected_docstring def test_identify_required_authn_params_none_required():