From c5e07276ae86e0bd6c1c397b6ead006bb70efe17 Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Tue, 6 May 2025 17:11:47 +0530 Subject: [PATCH 1/8] chore: Add unit test cases --- packages/toolbox-core/tests/test_client.py | 29 ++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/toolbox-core/tests/test_client.py b/packages/toolbox-core/tests/test_client.py index 1d7aedbd..608f8530 100644 --- a/packages/toolbox-core/tests/test_client.py +++ b/packages/toolbox-core/tests/test_client.py @@ -400,6 +400,35 @@ async def test_constructor_getters_missing_fail(self, tool_name, client): await client.load_tool(tool_name, auth_token_getters={AUTH_SERVICE: {}}) + @pytest.mark.asyncio + async def test_add_auth_token_getters_missing_fail(self, tool_name, client): + """ + Tests that adding a missing auth token getter raises ValueError. + """ + AUTH_SERVICE = "xmy-auth-service" + + tool = await client.load_tool(tool_name) + + with pytest.raises( + ValueError, + match=f"Authentication source\(s\) \`{AUTH_SERVICE}\` unused by tool \`{tool_name}\`.", + ): + tool.add_auth_token_getters({AUTH_SERVICE: {}}) + + @pytest.mark.asyncio + async def test_constructor_getters_missing_fail(self, tool_name, client): + """ + Tests that adding a missing auth token getter raises ValueError. + """ + AUTH_SERVICE = "xmy-auth-service" + + with pytest.raises( + ValueError, + match=f"Validation failed for tool '{tool_name}': unused auth tokens: {AUTH_SERVICE}.", + ): + await client.load_tool(tool_name, auth_token_getters={AUTH_SERVICE: {}}) + + class TestBoundParameter: @pytest.fixture From 5039ff9a3025c39de3a8e53748ec186bcb11673b Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Tue, 6 May 2025 17:12:37 +0530 Subject: [PATCH 2/8] chore: Delint --- packages/toolbox-core/tests/test_client.py | 29 ---------------------- 1 file changed, 29 deletions(-) diff --git a/packages/toolbox-core/tests/test_client.py b/packages/toolbox-core/tests/test_client.py index 608f8530..1d7aedbd 100644 --- a/packages/toolbox-core/tests/test_client.py +++ b/packages/toolbox-core/tests/test_client.py @@ -400,35 +400,6 @@ async def test_constructor_getters_missing_fail(self, tool_name, client): await client.load_tool(tool_name, auth_token_getters={AUTH_SERVICE: {}}) - @pytest.mark.asyncio - async def test_add_auth_token_getters_missing_fail(self, tool_name, client): - """ - Tests that adding a missing auth token getter raises ValueError. - """ - AUTH_SERVICE = "xmy-auth-service" - - tool = await client.load_tool(tool_name) - - with pytest.raises( - ValueError, - match=f"Authentication source\(s\) \`{AUTH_SERVICE}\` unused by tool \`{tool_name}\`.", - ): - tool.add_auth_token_getters({AUTH_SERVICE: {}}) - - @pytest.mark.asyncio - async def test_constructor_getters_missing_fail(self, tool_name, client): - """ - Tests that adding a missing auth token getter raises ValueError. - """ - AUTH_SERVICE = "xmy-auth-service" - - with pytest.raises( - ValueError, - match=f"Validation failed for tool '{tool_name}': unused auth tokens: {AUTH_SERVICE}.", - ): - await client.load_tool(tool_name, auth_token_getters={AUTH_SERVICE: {}}) - - class TestBoundParameter: @pytest.fixture From 359f0b648dd687a175f119a73ca806e666c0ed44 Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Tue, 6 May 2025 17:40:02 +0530 Subject: [PATCH 3/8] feat: Warn on insecure tool invocation with authentication This change introduces a warning that is displayed immediately before a tool invocation if: 1. The invocation includes an authentication header. 2. The connection is being made over non-secure HTTP. > [!IMPORTANT] The purpose of this warning is to alert the user to the security risk of sending credentials over an unencrypted channel and to encourage the use of HTTPS. --- packages/toolbox-core/src/toolbox_core/tool.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/toolbox-core/src/toolbox_core/tool.py b/packages/toolbox-core/src/toolbox_core/tool.py index 98d04e17..79da97a8 100644 --- a/packages/toolbox-core/src/toolbox_core/tool.py +++ b/packages/toolbox-core/src/toolbox_core/tool.py @@ -257,18 +257,27 @@ async def __call__(self, *args: Any, **kwargs: Any) -> str: payload[param] = await resolve_value(value) # create headers for auth services - headers = {} + auth_headers = {} for auth_service, token_getter in self.__auth_service_token_getters.items(): - headers[self.__get_auth_header(auth_service)] = await resolve_value( + auth_headers[self.__get_auth_header(auth_service)] = await resolve_value( token_getter ) for client_header_name, client_header_val in self.__client_headers.items(): - headers[client_header_name] = await resolve_value(client_header_val) + auth_headers[client_header_name] = await resolve_value(client_header_val) + + # ID tokens contain sensitive user information (claims). Transmitting + # these over HTTP exposes the data to interception and unauthorized + # access. Always use HTTPS to ensure secure communication and protect + # user privacy. + if auth_headers and not self.__url.startswith("https://"): + warn( + "Sending ID token over HTTP. User data may be exposed. Use HTTPS for secure communication." + ) async with self.__session.post( self.__url, json=payload, - headers=headers, + headers=auth_headers, ) as resp: body = await resp.json() if resp.status < 200 or resp.status >= 300: From 8b147833b4ae2aabec75c1741f02f5b1e4b194d6 Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Tue, 13 May 2025 15:57:46 +0530 Subject: [PATCH 4/8] fix!: Warn about https only during tool initialization --- packages/toolbox-core/src/toolbox_core/tool.py | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/packages/toolbox-core/src/toolbox_core/tool.py b/packages/toolbox-core/src/toolbox_core/tool.py index 79da97a8..98d04e17 100644 --- a/packages/toolbox-core/src/toolbox_core/tool.py +++ b/packages/toolbox-core/src/toolbox_core/tool.py @@ -257,27 +257,18 @@ async def __call__(self, *args: Any, **kwargs: Any) -> str: payload[param] = await resolve_value(value) # create headers for auth services - auth_headers = {} + headers = {} for auth_service, token_getter in self.__auth_service_token_getters.items(): - auth_headers[self.__get_auth_header(auth_service)] = await resolve_value( + headers[self.__get_auth_header(auth_service)] = await resolve_value( token_getter ) for client_header_name, client_header_val in self.__client_headers.items(): - auth_headers[client_header_name] = await resolve_value(client_header_val) - - # ID tokens contain sensitive user information (claims). Transmitting - # these over HTTP exposes the data to interception and unauthorized - # access. Always use HTTPS to ensure secure communication and protect - # user privacy. - if auth_headers and not self.__url.startswith("https://"): - warn( - "Sending ID token over HTTP. User data may be exposed. Use HTTPS for secure communication." - ) + headers[client_header_name] = await resolve_value(client_header_val) async with self.__session.post( self.__url, json=payload, - headers=auth_headers, + headers=headers, ) as resp: body = await resp.json() if resp.status < 200 or resp.status >= 300: From 31810023cd28c3ebc6a8e572adacd67ce2afafc2 Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Tue, 6 May 2025 18:08:30 +0530 Subject: [PATCH 5/8] chore: Add unit tests for ToolboxSyncTool --- .../src/toolbox_core/sync_tool.py | 1 + packages/toolbox-core/tests/test_sync_tool.py | 248 ++++++++++++++++++ 2 files changed, 249 insertions(+) create mode 100644 packages/toolbox-core/tests/test_sync_tool.py diff --git a/packages/toolbox-core/src/toolbox_core/sync_tool.py b/packages/toolbox-core/src/toolbox_core/sync_tool.py index 27302251..0e6d6166 100644 --- a/packages/toolbox-core/src/toolbox_core/sync_tool.py +++ b/packages/toolbox-core/src/toolbox_core/sync_tool.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. + import asyncio from asyncio import AbstractEventLoop from inspect import Signature diff --git a/packages/toolbox-core/tests/test_sync_tool.py b/packages/toolbox-core/tests/test_sync_tool.py new file mode 100644 index 00000000..14075896 --- /dev/null +++ b/packages/toolbox-core/tests/test_sync_tool.py @@ -0,0 +1,248 @@ +# 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 inspect import Parameter, Signature +from threading import Thread +from typing import Any, Callable, Mapping, Union +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from toolbox_core.sync_tool import ToolboxSyncTool +from toolbox_core.tool import ToolboxTool + + +@pytest.fixture +def mock_async_tool() -> MagicMock: + """Fixture for a MagicMock simulating a ToolboxTool instance.""" + tool = MagicMock(spec=ToolboxTool) + tool.__name__ = "mock_async_tool_name" + tool.__doc__ = "Mock async tool documentation." + + # Create a simple signature for the mock tool + param_a = Parameter("a", Parameter.POSITIONAL_OR_KEYWORD, annotation=str) + param_b = Parameter( + "b", Parameter.POSITIONAL_OR_KEYWORD, annotation=int, default=10 + ) + tool.__signature__ = Signature(parameters=[param_a, param_b]) + + tool.__annotations__ = {"a": str, "b": int, "return": str} + + # Mock the __call__ method to return a coroutine (MagicMock that can be awaited) + # We'll make the internal call return a simple value for testing the sync wrapper + async def mock_async_call(*args, **kwargs): + return f"async_called_with_{args}_{kwargs}" + + tool.__call__ = MagicMock(side_effect=lambda *a, **k: mock_async_call(*a, **k)) + + # Mock methods that return a new async_tool + tool.add_auth_token_getters = MagicMock(return_value=MagicMock(spec=ToolboxTool)) + tool.bind_params = MagicMock(return_value=MagicMock(spec=ToolboxTool)) + + return tool + + +@pytest.fixture +def event_loop() -> asyncio.AbstractEventLoop: + """Fixture for an event loop.""" + # Using asyncio.get_event_loop() might be problematic if no loop is set. + # For this test setup, we'll mock `run_coroutine_threadsafe` directly. + return Mock(spec=asyncio.AbstractEventLoop) + + +@pytest.fixture +def mock_thread() -> MagicMock: + """Fixture for a mock Thread.""" + return MagicMock(spec=Thread) + + +@pytest.fixture +def toolbox_sync_tool( + mock_async_tool: MagicMock, + event_loop: asyncio.AbstractEventLoop, + mock_thread: MagicMock, +) -> ToolboxSyncTool: + """Fixture for a ToolboxSyncTool instance.""" + return ToolboxSyncTool(mock_async_tool, event_loop, mock_thread) + + +def test_toolbox_sync_tool_init_success( + mock_async_tool: MagicMock, + event_loop: asyncio.AbstractEventLoop, + mock_thread: MagicMock, +): + """Tests successful initialization of ToolboxSyncTool.""" + tool = ToolboxSyncTool(mock_async_tool, event_loop, mock_thread) + assert tool._ToolboxSyncTool__async_tool is mock_async_tool + assert tool._ToolboxSyncTool__loop is event_loop + assert tool._ToolboxSyncTool__thread is mock_thread + assert tool.__qualname__ == f"ToolboxSyncTool.{mock_async_tool.__name__}" + + +def test_toolbox_sync_tool_init_type_error(): + """Tests TypeError if async_tool is not a ToolboxTool instance.""" + with pytest.raises( + TypeError, match="async_tool must be an instance of ToolboxTool" + ): + ToolboxSyncTool("not_a_toolbox_tool", Mock(), Mock()) + + +def test_toolbox_sync_tool_name_property( + toolbox_sync_tool: ToolboxSyncTool, mock_async_tool: MagicMock +): + """Tests the __name__ property.""" + assert toolbox_sync_tool.__name__ == mock_async_tool.__name__ + + +def test_toolbox_sync_tool_doc_property( + toolbox_sync_tool: ToolboxSyncTool, mock_async_tool: MagicMock +): + """Tests the __doc__ property.""" + assert toolbox_sync_tool.__doc__ == mock_async_tool.__doc__ + + # Test with __doc__ = None + mock_async_tool.__doc__ = None + sync_tool_no_doc = ToolboxSyncTool(mock_async_tool, Mock(), Mock()) + assert sync_tool_no_doc.__doc__ is None + + +def test_toolbox_sync_tool_signature_property( + toolbox_sync_tool: ToolboxSyncTool, mock_async_tool: MagicMock +): + """Tests the __signature__ property.""" + assert toolbox_sync_tool.__signature__ is mock_async_tool.__signature__ + + +def test_toolbox_sync_tool_annotations_property( + toolbox_sync_tool: ToolboxSyncTool, mock_async_tool: MagicMock +): + """Tests the __annotations__ property.""" + assert toolbox_sync_tool.__annotations__ is mock_async_tool.__annotations__ + + +@patch("asyncio.run_coroutine_threadsafe") +def test_toolbox_sync_tool_call( + mock_run_coroutine_threadsafe: MagicMock, + toolbox_sync_tool: ToolboxSyncTool, + mock_async_tool: MagicMock, + event_loop: asyncio.AbstractEventLoop, +): + """Tests the __call__ method.""" + mock_future = MagicMock() + expected_result = "call_result" + mock_future.result.return_value = expected_result + mock_run_coroutine_threadsafe.return_value = mock_future + + args_tuple = ("test_arg",) + kwargs_dict = {"kwarg1": "value1"} + + # Create a mock coroutine to be returned by async_tool.__call__ + mock_coro = MagicMock() # Represents the coroutine object + mock_async_tool.return_value = mock_coro # async_tool() returns mock_coro + + result = toolbox_sync_tool(*args_tuple, **kwargs_dict) + + mock_async_tool.assert_called_once_with(*args_tuple, **kwargs_dict) + mock_run_coroutine_threadsafe.assert_called_once_with(mock_coro, event_loop) + mock_future.result.assert_called_once_with() + assert result == expected_result + + +def test_toolbox_sync_tool_add_auth_token_getters( + toolbox_sync_tool: ToolboxSyncTool, + mock_async_tool: MagicMock, + event_loop: asyncio.AbstractEventLoop, + mock_thread: MagicMock, +): + """Tests the add_auth_token_getters method.""" + auth_getters: Mapping[str, Callable[[], str]] = {"service1": lambda: "token1"} + + # The mock_async_tool.add_auth_token_getters is already set up to return a new MagicMock + new_mock_async_tool = mock_async_tool.add_auth_token_getters.return_value + new_mock_async_tool.__name__ = "new_async_tool_with_auth" # for __qualname__ + + new_sync_tool = toolbox_sync_tool.add_auth_token_getters(auth_getters) + + mock_async_tool.add_auth_token_getters.assert_called_once_with(auth_getters) + + assert isinstance(new_sync_tool, ToolboxSyncTool) + assert new_sync_tool is not toolbox_sync_tool + assert new_sync_tool._ToolboxSyncTool__async_tool is new_mock_async_tool + assert new_sync_tool._ToolboxSyncTool__loop is event_loop # Should be the same loop + assert ( + new_sync_tool._ToolboxSyncTool__thread is mock_thread + ) # Should be the same thread + assert ( + new_sync_tool.__qualname__ == f"ToolboxSyncTool.{new_mock_async_tool.__name__}" + ) + + +def test_toolbox_sync_tool_bind_params( + toolbox_sync_tool: ToolboxSyncTool, + mock_async_tool: MagicMock, + event_loop: asyncio.AbstractEventLoop, + mock_thread: MagicMock, +): + """Tests the bind_params method.""" + bound_params: Mapping[str, Union[Callable[[], Any], Any]] = { + "param1": "value1", + "param2": lambda: "value2", + } + + new_mock_async_tool = mock_async_tool.bind_params.return_value + new_mock_async_tool.__name__ = "new_async_tool_with_bound_params" + + new_sync_tool = toolbox_sync_tool.bind_params(bound_params) + + mock_async_tool.bind_params.assert_called_once_with(bound_params) + + assert isinstance(new_sync_tool, ToolboxSyncTool) + assert new_sync_tool is not toolbox_sync_tool + assert new_sync_tool._ToolboxSyncTool__async_tool is new_mock_async_tool + assert new_sync_tool._ToolboxSyncTool__loop is event_loop + assert new_sync_tool._ToolboxSyncTool__thread is mock_thread + assert ( + new_sync_tool.__qualname__ == f"ToolboxSyncTool.{new_mock_async_tool.__name__}" + ) + + +def test_toolbox_sync_tool_bind_param( + toolbox_sync_tool: ToolboxSyncTool, + mock_async_tool: MagicMock, + event_loop: asyncio.AbstractEventLoop, + mock_thread: MagicMock, +): + """Tests the bind_param method.""" + param_name = "my_param" + param_value = "my_value" + + new_mock_async_tool = mock_async_tool.bind_params.return_value + new_mock_async_tool.__name__ = "new_async_tool_with_single_bound_param" + + # Since bind_param calls self.bind_params, which in turn calls async_tool.bind_params, + # we check that async_tool.bind_params is called correctly. + new_sync_tool = toolbox_sync_tool.bind_param(param_name, param_value) + + mock_async_tool.bind_params.assert_called_once_with({param_name: param_value}) + + assert isinstance(new_sync_tool, ToolboxSyncTool) + assert new_sync_tool is not toolbox_sync_tool + assert new_sync_tool._ToolboxSyncTool__async_tool is new_mock_async_tool + assert new_sync_tool._ToolboxSyncTool__loop is event_loop + assert new_sync_tool._ToolboxSyncTool__thread is mock_thread + assert ( + new_sync_tool.__qualname__ == f"ToolboxSyncTool.{new_mock_async_tool.__name__}" + ) From 8358c2ac6a2ceef7e91234d277d56209213be42b Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Wed, 7 May 2025 13:53:51 +0530 Subject: [PATCH 6/8] fix: Fix unittest failing due to mock attaching wrong spec signature while asserting --- packages/toolbox-core/tests/test_sync_tool.py | 29 +++++++------------ 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/packages/toolbox-core/tests/test_sync_tool.py b/packages/toolbox-core/tests/test_sync_tool.py index 14075896..de6737ac 100644 --- a/packages/toolbox-core/tests/test_sync_tool.py +++ b/packages/toolbox-core/tests/test_sync_tool.py @@ -17,7 +17,7 @@ from inspect import Parameter, Signature from threading import Thread from typing import Any, Callable, Mapping, Union -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import MagicMock, Mock, create_autospec, patch import pytest @@ -27,8 +27,8 @@ @pytest.fixture def mock_async_tool() -> MagicMock: - """Fixture for a MagicMock simulating a ToolboxTool instance.""" - tool = MagicMock(spec=ToolboxTool) + """Fixture for an auto-specced MagicMock simulating a ToolboxTool instance.""" + tool = create_autospec(ToolboxTool, instance=True) tool.__name__ = "mock_async_tool_name" tool.__doc__ = "Mock async tool documentation." @@ -41,16 +41,10 @@ def mock_async_tool() -> MagicMock: tool.__annotations__ = {"a": str, "b": int, "return": str} - # Mock the __call__ method to return a coroutine (MagicMock that can be awaited) - # We'll make the internal call return a simple value for testing the sync wrapper - async def mock_async_call(*args, **kwargs): - return f"async_called_with_{args}_{kwargs}" - - tool.__call__ = MagicMock(side_effect=lambda *a, **k: mock_async_call(*a, **k)) - - # Mock methods that return a new async_tool - tool.add_auth_token_getters = MagicMock(return_value=MagicMock(spec=ToolboxTool)) - tool.bind_params = MagicMock(return_value=MagicMock(spec=ToolboxTool)) + tool.add_auth_token_getters.return_value = create_autospec( + ToolboxTool, instance=True + ) + tool.bind_params.return_value = create_autospec(ToolboxTool, instance=True) return tool @@ -150,8 +144,8 @@ def test_toolbox_sync_tool_call( kwargs_dict = {"kwarg1": "value1"} # Create a mock coroutine to be returned by async_tool.__call__ - mock_coro = MagicMock() # Represents the coroutine object - mock_async_tool.return_value = mock_coro # async_tool() returns mock_coro + mock_coro = MagicMock(name="mock_coro_returned_by_async_tool") + mock_async_tool.return_value = mock_coro result = toolbox_sync_tool(*args_tuple, **kwargs_dict) @@ -170,9 +164,8 @@ def test_toolbox_sync_tool_add_auth_token_getters( """Tests the add_auth_token_getters method.""" auth_getters: Mapping[str, Callable[[], str]] = {"service1": lambda: "token1"} - # The mock_async_tool.add_auth_token_getters is already set up to return a new MagicMock new_mock_async_tool = mock_async_tool.add_auth_token_getters.return_value - new_mock_async_tool.__name__ = "new_async_tool_with_auth" # for __qualname__ + new_mock_async_tool.__name__ = "new_async_tool_with_auth" new_sync_tool = toolbox_sync_tool.add_auth_token_getters(auth_getters) @@ -232,8 +225,6 @@ def test_toolbox_sync_tool_bind_param( new_mock_async_tool = mock_async_tool.bind_params.return_value new_mock_async_tool.__name__ = "new_async_tool_with_single_bound_param" - # Since bind_param calls self.bind_params, which in turn calls async_tool.bind_params, - # we check that async_tool.bind_params is called correctly. new_sync_tool = toolbox_sync_tool.bind_param(param_name, param_value) mock_async_tool.bind_params.assert_called_once_with({param_name: param_value}) From bf88ca7759194fb341df28fe2e4a86e957f0f21b Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Tue, 13 May 2025 02:21:59 +0530 Subject: [PATCH 7/8] fix: Use correct name property while creating sync tool qualname --- packages/toolbox-core/src/toolbox_core/sync_tool.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/toolbox-core/src/toolbox_core/sync_tool.py b/packages/toolbox-core/src/toolbox_core/sync_tool.py index 0e6d6166..74f6f0bf 100644 --- a/packages/toolbox-core/src/toolbox_core/sync_tool.py +++ b/packages/toolbox-core/src/toolbox_core/sync_tool.py @@ -65,7 +65,9 @@ def __init__( # itself is being processed during module import or class definition. # Defining __qualname__ as a property leads to a TypeError because the class object needs # a string value immediately, not a descriptor that evaluates later. - self.__qualname__ = f"{self.__class__.__qualname__}.{self.__async_tool._name}" + self.__qualname__ = ( + f"{self.__class__.__qualname__}.{self.__async_tool.__name__}" + ) @property def __name__(self) -> str: From d7d171dad2e562e81725cbe8687a89d00da9d2d7 Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Tue, 13 May 2025 02:28:41 +0530 Subject: [PATCH 8/8] chore: Add unit tests for @property methods --- packages/toolbox-core/tests/test_sync_tool.py | 54 ++++++++++ packages/toolbox-core/tests/test_tool.py | 98 +++++++++++++++++++ 2 files changed, 152 insertions(+) diff --git a/packages/toolbox-core/tests/test_sync_tool.py b/packages/toolbox-core/tests/test_sync_tool.py index de6737ac..84d00ea4 100644 --- a/packages/toolbox-core/tests/test_sync_tool.py +++ b/packages/toolbox-core/tests/test_sync_tool.py @@ -127,6 +127,60 @@ def test_toolbox_sync_tool_annotations_property( assert toolbox_sync_tool.__annotations__ is mock_async_tool.__annotations__ +def test_toolbox_sync_tool_underscore_name_property( + toolbox_sync_tool: ToolboxSyncTool, mock_async_tool: MagicMock +): + """Tests the _name property.""" + assert toolbox_sync_tool._name == mock_async_tool._name + + +def test_toolbox_sync_tool_underscore_description_property( + toolbox_sync_tool: ToolboxSyncTool, mock_async_tool: MagicMock +): + """Tests the _description property.""" + assert toolbox_sync_tool._description == mock_async_tool._description + + +def test_toolbox_sync_tool_underscore_params_property( + toolbox_sync_tool: ToolboxSyncTool, mock_async_tool: MagicMock +): + """Tests the _params property.""" + assert toolbox_sync_tool._params == mock_async_tool._params + + +def test_toolbox_sync_tool_underscore_bound_params_property( + toolbox_sync_tool: ToolboxSyncTool, mock_async_tool: MagicMock +): + """Tests the _bound_params property.""" + assert toolbox_sync_tool._bound_params == mock_async_tool._bound_params + + +def test_toolbox_sync_tool_underscore_required_auth_params_property( + toolbox_sync_tool: ToolboxSyncTool, mock_async_tool: MagicMock +): + """Tests the _required_auth_params property.""" + assert ( + toolbox_sync_tool._required_auth_params == mock_async_tool._required_auth_params + ) + + +def test_toolbox_sync_tool_underscore_auth_service_token_getters_property( + toolbox_sync_tool: ToolboxSyncTool, mock_async_tool: MagicMock +): + """Tests the _auth_service_token_getters property.""" + assert ( + toolbox_sync_tool._auth_service_token_getters + is mock_async_tool._auth_service_token_getters + ) + + +def test_toolbox_sync_tool_underscore_client_headers_property( + toolbox_sync_tool: ToolboxSyncTool, mock_async_tool: MagicMock +): + """Tests the _client_headers property.""" + assert toolbox_sync_tool._client_headers is mock_async_tool._client_headers + + @patch("asyncio.run_coroutine_threadsafe") def test_toolbox_sync_tool_call( mock_run_coroutine_threadsafe: MagicMock, diff --git a/packages/toolbox-core/tests/test_tool.py b/packages/toolbox-core/tests/test_tool.py index 2324b0c5..c64149f5 100644 --- a/packages/toolbox-core/tests/test_tool.py +++ b/packages/toolbox-core/tests/test_tool.py @@ -14,6 +14,7 @@ import inspect +from types import MappingProxyType from typing import AsyncGenerator, Callable, Mapping from unittest.mock import AsyncMock, Mock from warnings import catch_warnings, simplefilter @@ -102,6 +103,27 @@ def unused_auth_getters() -> dict[str, Callable[[], str]]: return {"unused-auth-service": lambda: "unused-token-value"} +@pytest.fixture +def toolbox_tool( + http_session: ClientSession, + sample_tool_params: list[ParameterSchema], + sample_tool_description: str, +) -> ToolboxTool: + """Fixture for a ToolboxTool instance with common test setup.""" + return ToolboxTool( + session=http_session, + base_url=TEST_BASE_URL, + name=TEST_TOOL_NAME, + description=sample_tool_description, + params=sample_tool_params, + required_authn_params={"message": ["service_a"]}, + required_authz_tokens=["service_b"], + auth_service_token_getters={"service_x": lambda: "token_x"}, + bound_params={"fixed_param": "fixed_value"}, + client_headers={"X-Test-Client": "client_header_value"}, + ) + + def test_create_func_docstring_one_param_real_schema(): """ Tests create_func_docstring with one real ParameterSchema instance. @@ -480,6 +502,82 @@ def test_add_auth_token_getters_unused_token( tool_instance.add_auth_token_getters(unused_auth_getters) +def test_toolbox_tool_underscore_name_property(toolbox_tool: ToolboxTool): + """Tests the _name property.""" + assert toolbox_tool._name == TEST_TOOL_NAME + + +def test_toolbox_tool_underscore_description_property(toolbox_tool: ToolboxTool): + """Tests the _description property.""" + assert ( + toolbox_tool._description + == "A sample tool that processes a message and a count." + ) + + +def test_toolbox_tool_underscore_params_property( + toolbox_tool: ToolboxTool, sample_tool_params: list[ParameterSchema] +): + """Tests the _params property returns a deep copy.""" + params_copy = toolbox_tool._params + assert params_copy == sample_tool_params + assert ( + params_copy is not toolbox_tool._ToolboxTool__params + ) # Ensure it's a deepcopy + # Verify modifying the copy does not affect the original + params_copy.append( + ParameterSchema(name="new_param", type="integer", description="A new parameter") + ) + assert ( + len(toolbox_tool._ToolboxTool__params) == 2 + ) # Original should remain unchanged + + +def test_toolbox_tool_underscore_bound_params_property(toolbox_tool: ToolboxTool): + """Tests the _bound_params property returns an immutable MappingProxyType.""" + bound_params = toolbox_tool._bound_params + assert bound_params == {"fixed_param": "fixed_value"} + assert isinstance(bound_params, MappingProxyType) + # Verify immutability + with pytest.raises(TypeError): + bound_params["new_param"] = "new_value" + + +def test_toolbox_tool_underscore_required_auth_params_property( + toolbox_tool: ToolboxTool, +): + """Tests the _required_auth_params property returns an immutable MappingProxyType.""" + required_auth_params = toolbox_tool._required_auth_params + assert required_auth_params == {"message": ["service_a"]} + assert isinstance(required_auth_params, MappingProxyType) + # Verify immutability + with pytest.raises(TypeError): + required_auth_params["new_param"] = ["new_service"] + + +def test_toolbox_tool_underscore_auth_service_token_getters_property( + toolbox_tool: ToolboxTool, +): + """Tests the _auth_service_token_getters property returns an immutable MappingProxyType.""" + auth_getters = toolbox_tool._auth_service_token_getters + assert "service_x" in auth_getters + assert auth_getters["service_x"]() == "token_x" + assert isinstance(auth_getters, MappingProxyType) + # Verify immutability + with pytest.raises(TypeError): + auth_getters["new_service"] = lambda: "new_token" + + +def test_toolbox_tool_underscore_client_headers_property(toolbox_tool: ToolboxTool): + """Tests the _client_headers property returns an immutable MappingProxyType.""" + client_headers = toolbox_tool._client_headers + assert client_headers == {"X-Test-Client": "client_header_value"} + assert isinstance(client_headers, MappingProxyType) + # Verify immutability + with pytest.raises(TypeError): + client_headers["new_header"] = "new_value" + + # --- Test for the HTTP Warning --- @pytest.mark.parametrize( "trigger_condition_params",