From 85c77837db6be0df1189c5460e9c8e8b1faace8d 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 b57624b7..3fc371b5 100644 --- a/packages/toolbox-core/tests/test_client.py +++ b/packages/toolbox-core/tests/test_client.py @@ -398,6 +398,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 c4fcc9073d012776a9bf8084c839a6e5aa16b9f5 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 3fc371b5..b57624b7 100644 --- a/packages/toolbox-core/tests/test_client.py +++ b/packages/toolbox-core/tests/test_client.py @@ -398,35 +398,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 92ca91b3ad539664e280335defbf3365ecf6dce2 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 ad2dca02a1dbbcf69fdd55b80f72bcf2368d7364 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 60f86d873984a1d0fe1392f1b9e3ac76401763a0 Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Tue, 13 May 2025 16:24:16 +0530 Subject: [PATCH 5/8] chore: Add unit tests for ToolboxSyncClient --- .../toolbox-core/tests/test_sync_client.py | 621 ++++++++++++++++++ 1 file changed, 621 insertions(+) create mode 100644 packages/toolbox-core/tests/test_sync_client.py diff --git a/packages/toolbox-core/tests/test_sync_client.py b/packages/toolbox-core/tests/test_sync_client.py new file mode 100644 index 00000000..deefc2bf --- /dev/null +++ b/packages/toolbox-core/tests/test_sync_client.py @@ -0,0 +1,621 @@ +# 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 inspect +from typing import Any, Callable, Mapping, Optional +from unittest.mock import AsyncMock, patch + +import pytest +from aioresponses import CallbackResult, aioresponses + +from toolbox_core.client import ToolboxClient +from toolbox_core.protocol import ManifestSchema, ParameterSchema, ToolSchema +from toolbox_core.sync_client import ToolboxSyncClient +from toolbox_core.sync_tool import ToolboxSyncTool + +TEST_BASE_URL = "http://toolbox.example.com" + + +@pytest.fixture +def sync_client_environment(): + """ + Ensures a clean environment for ToolboxSyncClient class-level resources. + It resets the class-level event loop and thread before the test + and stops them after the test. This is crucial for test isolation + due to ToolboxSyncClient's use of class-level loop/thread. + """ + # Save current state if any (more of a defensive measure) + original_loop = getattr(ToolboxSyncClient, "_ToolboxSyncClient__loop", None) + original_thread = getattr(ToolboxSyncClient, "_ToolboxSyncClient__thread", None) + + # Force reset class state before the test. + # This ensures any client created will start a new loop/thread. + + assert ( + not original_loop or not original_loop.is_running() + ), "Loop should not be running" + assert ( + not original_thread or not original_thread.is_alive() + ), "Thread should not be running" + + ToolboxSyncClient._ToolboxSyncClient__loop = None + ToolboxSyncClient._ToolboxSyncClient__thread = None + + yield + + # Teardown: stop the loop and join the thread created *during* the test. + test_loop = getattr(ToolboxSyncClient, "_ToolboxSyncClient__loop", None) + test_thread = getattr(ToolboxSyncClient, "_ToolboxSyncClient__thread", None) + + if test_loop and test_loop.is_running(): + test_loop.call_soon_threadsafe(test_loop.stop) + if test_thread and test_thread.is_alive(): + test_thread.join(timeout=2) + + # Explicitly set to None to ensure a clean state for the next fixture use/test. + ToolboxSyncClient._ToolboxSyncClient__loop = None + ToolboxSyncClient._ToolboxSyncClient__thread = None + + # Restoring original_loop/thread could be risky if they were from a + # non-test setup or a previous misbehaving test. For robust test-to-test + # isolation, ensuring these are None after cleanup is generally preferred. + + +@pytest.fixture +def sync_client(sync_client_environment, request): + """ + Provides a ToolboxSyncClient instance within an isolated environment. + The client's underlying async session is automatically closed after the test. + The class-level loop/thread are managed by sync_client_environment. + """ + # `sync_client_environment` has prepared the class state. + client = ToolboxSyncClient(TEST_BASE_URL) + + def finalizer(): + client.close() # Closes the async_client's session. + # Loop/thread shutdown is handled by sync_client_environment's teardown. + + request.addfinalizer(finalizer) + return client + + +@pytest.fixture() +def test_tool_str_schema(): + return ToolSchema( + description="Test Tool with String input", + parameters=[ + ParameterSchema( + name="param1", type="string", description="Description of Param1" + ) + ], + ) + + +@pytest.fixture() +def test_tool_int_bool_schema(): + return ToolSchema( + description="Test Tool with Int, Bool", + parameters=[ + ParameterSchema(name="argA", type="integer", description="Argument A"), + ParameterSchema(name="argB", type="boolean", description="Argument B"), + ], + ) + + +@pytest.fixture() +def test_tool_auth_schema(): + return ToolSchema( + description="Test Tool with Int,Bool+Auth", + parameters=[ + ParameterSchema(name="argA", type="integer", description="Argument A"), + ParameterSchema( + name="argB", + type="boolean", + description="Argument B", + authSources=["my-auth-service"], + ), + ], + ) + + +@pytest.fixture +def tool_schema_minimal(): + return ToolSchema(description="Minimal Test Tool", parameters=[]) + + +# --- Helper Functions for Mocking --- +def mock_tool_load( + aio_resp: aioresponses, + tool_name: str, + tool_schema: ToolSchema, + base_url: str = TEST_BASE_URL, + server_version: str = "0.0.0", + status: int = 200, + callback: Optional[Callable] = None, + payload_override: Optional[Any] = None, +): + url = f"{base_url}/api/tool/{tool_name}" + payload_data = {} + if payload_override is not None: + payload_data = payload_override + else: + manifest = ManifestSchema( + serverVersion=server_version, tools={tool_name: tool_schema} + ) + payload_data = manifest.model_dump() + aio_resp.get(url, payload=payload_data, status=status, callback=callback) + + +def mock_toolset_load( + aio_resp: aioresponses, + toolset_name: str, + tools_dict: Mapping[str, ToolSchema], + base_url: str = TEST_BASE_URL, + server_version: str = "0.0.0", + status: int = 200, + callback: Optional[Callable] = None, +): + url_path = f"toolset/{toolset_name}" if toolset_name else "toolset/" + url = f"{base_url}/api/{url_path}" + manifest = ManifestSchema(serverVersion=server_version, tools=tools_dict) + aio_resp.get(url, payload=manifest.model_dump(), status=status, callback=callback) + + +def mock_tool_invoke( + aio_resp: aioresponses, + tool_name: str, + base_url: str = TEST_BASE_URL, + response_payload: Any = {"result": "ok"}, + status: int = 200, + callback: Optional[Callable] = None, +): + url = f"{base_url}/api/tool/{tool_name}/invoke" + aio_resp.post(url, payload=response_payload, status=status, callback=callback) + + +# --- Tests for ToolboxSyncClient --- + + +def test_sync_client_creation_in_isolated_env(sync_client): + """Tests that a client is initialized correctly by the sync_client fixture.""" + assert sync_client._ToolboxSyncClient__loop is not None, "Loop should be created" + assert ( + sync_client._ToolboxSyncClient__thread is not None + ), "Thread should be created" + assert sync_client._ToolboxSyncClient__thread.is_alive(), "Thread should be running" + assert isinstance( + sync_client._ToolboxSyncClient__async_client, ToolboxClient + ), "Async client should be ToolboxClient instance" + + +@pytest.mark.usefixtures("sync_client_environment") +def test_sync_client_close_method(): + """ + Tests the close() method of ToolboxSyncClient when manually created. + The sync_client_environment ensures loop/thread cleanup. + """ + mock_async_client_instance = AsyncMock(spec=ToolboxClient) + mock_async_client_instance.close = AsyncMock(return_value=None) + + with patch( + "toolbox_core.sync_client.ToolboxClient", + return_value=mock_async_client_instance, + ) as MockedAsyncClientConst: + # Manually create client; sync_client_environment handles loop setup/teardown. + client = ToolboxSyncClient(TEST_BASE_URL) + MockedAsyncClientConst.assert_called_once_with( + TEST_BASE_URL, client_headers=None + ) + + client.close() # This call closes the async_client's session. + mock_async_client_instance.close.assert_awaited_once() + # The sync_client_environment fixture handles stopping the loop/thread. + + +@pytest.mark.usefixtures("sync_client_environment") +def test_sync_client_context_manager(aioresponses, tool_schema_minimal): + """ + Tests the context manager (__enter__ and __exit__) functionality. + The sync_client_environment ensures loop/thread cleanup. + """ + with patch.object( + ToolboxSyncClient, "close", wraps=ToolboxSyncClient.close, autospec=True + ) as mock_close_method: + with ToolboxSyncClient(TEST_BASE_URL) as client: # Manually creating client + assert isinstance(client, ToolboxSyncClient) + mock_tool_load(aioresponses, "dummy_tool_ctx", tool_schema_minimal) + client.load_tool("dummy_tool_ctx") + mock_close_method.assert_called_once() + + with patch.object( + ToolboxSyncClient, "close", wraps=ToolboxSyncClient.close, autospec=True + ) as mock_close_method_exc: + with pytest.raises(ValueError, match="Test exception"): + with ToolboxSyncClient( + TEST_BASE_URL + ) as client_exc: # Manually creating client + raise ValueError("Test exception") + mock_close_method_exc.assert_called_once() + + +def test_sync_load_tool_success(aioresponses, test_tool_str_schema, sync_client): + TOOL_NAME = "test_tool_sync_1" + mock_tool_load(aioresponses, TOOL_NAME, test_tool_str_schema) + mock_tool_invoke( + aioresponses, TOOL_NAME, response_payload={"result": "sync_tool_ok"} + ) + + loaded_tool = sync_client.load_tool(TOOL_NAME) + + assert callable(loaded_tool) + assert isinstance(loaded_tool, ToolboxSyncTool) + assert loaded_tool.__name__ == TOOL_NAME + assert test_tool_str_schema.description in loaded_tool.__doc__ + sig = inspect.signature(loaded_tool) + assert list(sig.parameters.keys()) == [ + p.name for p in test_tool_str_schema.parameters + ] + result = loaded_tool(param1="some value") + assert result == "sync_tool_ok" + + +def test_sync_load_toolset_success( + aioresponses, test_tool_str_schema, test_tool_int_bool_schema, sync_client +): + TOOLSET_NAME = "my_sync_toolset" + TOOL1_NAME = "sync_tool1" + TOOL2_NAME = "sync_tool2" + tools_definition = { + TOOL1_NAME: test_tool_str_schema, + TOOL2_NAME: test_tool_int_bool_schema, + } + mock_toolset_load(aioresponses, TOOLSET_NAME, tools_definition) + mock_tool_invoke( + aioresponses, TOOL1_NAME, response_payload={"result": f"{TOOL1_NAME}_ok"} + ) + mock_tool_invoke( + aioresponses, TOOL2_NAME, response_payload={"result": f"{TOOL2_NAME}_ok"} + ) + + tools = sync_client.load_toolset(TOOLSET_NAME) + + assert isinstance(tools, list) + assert len(tools) == len(tools_definition) + assert all(isinstance(t, ToolboxSyncTool) for t in tools) + assert {t.__name__ for t in tools} == tools_definition.keys() + tool1 = next(t for t in tools if t.__name__ == TOOL1_NAME) + result1 = tool1(param1="hello") + assert result1 == f"{TOOL1_NAME}_ok" + + +def test_sync_invoke_tool_server_error(aioresponses, test_tool_str_schema, sync_client): + TOOL_NAME = "sync_server_error_tool" + ERROR_MESSAGE = "Simulated Server Error for Sync Client" + mock_tool_load(aioresponses, TOOL_NAME, test_tool_str_schema) + mock_tool_invoke( + aioresponses, TOOL_NAME, response_payload={"error": ERROR_MESSAGE}, status=500 + ) + + loaded_tool = sync_client.load_tool(TOOL_NAME) + with pytest.raises(Exception, match=ERROR_MESSAGE): + loaded_tool(param1="some input") + + +def test_sync_load_tool_not_found_in_manifest( + aioresponses, test_tool_str_schema, sync_client +): + ACTUAL_TOOL_IN_MANIFEST = "actual_tool_sync_abc" + REQUESTED_TOOL_NAME = "non_existent_tool_sync_xyz" + mismatched_manifest_payload = ManifestSchema( + serverVersion="0.0.0", tools={ACTUAL_TOOL_IN_MANIFEST: test_tool_str_schema} + ).model_dump() + mock_tool_load( + aio_resp=aioresponses, + tool_name=REQUESTED_TOOL_NAME, + tool_schema=test_tool_str_schema, + payload_override=mismatched_manifest_payload, + ) + + with pytest.raises( + ValueError, + match=f"Tool '{REQUESTED_TOOL_NAME}' not found!", + ): + sync_client.load_tool(REQUESTED_TOOL_NAME) + aioresponses.assert_called_once_with( + f"{TEST_BASE_URL}/api/tool/{REQUESTED_TOOL_NAME}", method="GET", headers={} + ) + + +def test_sync_add_headers_success(aioresponses, test_tool_str_schema, sync_client): + tool_name = "tool_after_add_headers_sync" + manifest = ManifestSchema( + serverVersion="0.0.0", tools={tool_name: test_tool_str_schema} + ) + expected_payload = {"result": "added_sync_ok"} + headers_to_add = {"X-Custom-SyncHeader": "sync_value"} + + def get_callback(url, **kwargs): + # The sync_client might have default headers. Check ours are present. + assert kwargs.get("headers") is not None + for key, value in headers_to_add.items(): + assert kwargs["headers"].get(key) == value + return CallbackResult(status=200, payload=manifest.model_dump()) + + aioresponses.get(f"{TEST_BASE_URL}/api/tool/{tool_name}", callback=get_callback) + + def post_callback(url, **kwargs): + assert kwargs.get("headers") is not None + for key, value in headers_to_add.items(): + assert kwargs["headers"].get(key) == value + return CallbackResult(status=200, payload=expected_payload) + + aioresponses.post( + f"{TEST_BASE_URL}/api/tool/{tool_name}/invoke", callback=post_callback + ) + + sync_client.add_headers(headers_to_add) + tool = sync_client.load_tool(tool_name) + result = tool(param1="test") + assert result == expected_payload["result"] + + +@pytest.mark.usefixtures("sync_client_environment") +def test_sync_add_headers_duplicate_fail(): + """ + Tests that adding a duplicate header via add_headers raises ValueError. + Manually create client to control initial headers. + """ + initial_headers = {"X-Initial-Header": "initial_value"} + mock_async_client = AsyncMock(spec=ToolboxClient) + + # This mock simulates the behavior of the underlying async client's add_headers + async def mock_add_headers_async_error(headers_to_add): + # Simulate error if header already exists in the "async client's current headers" + if ( + "X-Initial-Header" in headers_to_add + and hasattr(mock_async_client, "_current_headers") + and "X-Initial-Header" in mock_async_client._current_headers + ): + raise ValueError("Client header(s) `X-Initial-Header` already registered") + + mock_async_client.add_headers = ( + mock_add_headers_async_error # Assign as a coroutine + ) + + # Patch ToolboxClient constructor to inject initial_headers into the mock async_client state + def side_effect_constructor(base_url, client_headers=None): + # Store the initial headers on the mock_async_client instance for the test + mock_async_client._current_headers = ( + client_headers.copy() if client_headers else {} + ) + return mock_async_client + + with patch( + "toolbox_core.sync_client.ToolboxClient", side_effect=side_effect_constructor + ) as MockedAsyncClientConst: + # Client is created with initial_headers, which are passed to the (mocked) ToolboxClient + client = ToolboxSyncClient(TEST_BASE_URL, client_headers=initial_headers) + MockedAsyncClientConst.assert_called_with( + TEST_BASE_URL, client_headers=initial_headers + ) + + with pytest.raises( + ValueError, + match="Client header\\(s\\) `X-Initial-Header` already registered", + ): + # This call to client.add_headers will internally call mock_async_client.add_headers + client.add_headers({"X-Initial-Header": "another_value"}) + + +@pytest.mark.usefixtures("sync_client_environment") +def test_load_tool_raises_if_loop_or_thread_none(): + """ + Tests that load_tool and load_toolset raise ValueError if the class-level + event loop or thread is None. sync_client_environment ensures a clean + slate before this test, and client creation will set up the loop/thread. + """ + client = ToolboxSyncClient(TEST_BASE_URL) # Loop/thread are started here. + + original_class_loop = ToolboxSyncClient._ToolboxSyncClient__loop + original_class_thread = ToolboxSyncClient._ToolboxSyncClient__thread + assert ( + original_class_loop is not None + ), "Loop should have been created by client init" + assert ( + original_class_thread is not None + ), "Thread should have been created by client init" + + # Manually break the class's loop to trigger the error condition in load_tool + ToolboxSyncClient._ToolboxSyncClient__loop = None + with pytest.raises(ValueError, match="Background loop or thread cannot be None."): + client.load_tool("any_tool_should_fail") + ToolboxSyncClient._ToolboxSyncClient__loop = ( + original_class_loop # Restore for next check + ) + + ToolboxSyncClient._ToolboxSyncClient__thread = None + with pytest.raises(ValueError, match="Background loop or thread cannot be None."): + client.load_toolset("any_toolset_should_fail") + ToolboxSyncClient._ToolboxSyncClient__thread = original_class_thread # Restore + + client.close() + # sync_client_environment will handle the final cleanup of original_class_loop/thread. + + +class TestSyncAuth: + @pytest.fixture + def expected_header_token(self): + return "sync_auth_token_for_testing" + + @pytest.fixture + def tool_name_auth(self): + return "sync_auth_tool1" + + def test_auth_with_load_tool_success( + self, + tool_name_auth, + expected_header_token, + test_tool_auth_schema, + aioresponses, + sync_client, + ): + manifest = ManifestSchema( + serverVersion="0.0.0", tools={tool_name_auth: test_tool_auth_schema} + ) + aioresponses.get( + f"{TEST_BASE_URL}/api/tool/{tool_name_auth}", + payload=manifest.model_dump(), + status=200, + ) + + def require_headers_callback(url, **kwargs): + assert ( + kwargs["headers"].get("my-auth-service_token") == expected_header_token + ) + return CallbackResult(status=200, payload={"result": "auth_ok"}) + + aioresponses.post( + f"{TEST_BASE_URL}/api/tool/{tool_name_auth}/invoke", + callback=require_headers_callback, + ) + + def token_handler(): + return expected_header_token + + tool = sync_client.load_tool( + tool_name_auth, auth_token_getters={"my-auth-service": token_handler} + ) + result = tool(argA=5) + assert result == "auth_ok" + + def test_auth_with_add_token_success( + self, + tool_name_auth, + expected_header_token, + test_tool_auth_schema, + aioresponses, + sync_client, + ): + manifest = ManifestSchema( + serverVersion="0.0.0", tools={tool_name_auth: test_tool_auth_schema} + ) + aioresponses.get( + f"{TEST_BASE_URL}/api/tool/{tool_name_auth}", + payload=manifest.model_dump(), + status=200, + ) + + def require_headers_callback(url, **kwargs): + assert ( + kwargs["headers"].get("my-auth-service_token") == expected_header_token + ) + return CallbackResult(status=200, payload={"result": "auth_ok"}) + + aioresponses.post( + f"{TEST_BASE_URL}/api/tool/{tool_name_auth}/invoke", + callback=require_headers_callback, + ) + + def token_handler(): + return expected_header_token + + tool = sync_client.load_tool(tool_name_auth) + authed_tool = tool.add_auth_token_getters({"my-auth-service": token_handler}) + result = authed_tool(argA=10) + assert result == "auth_ok" + + def test_auth_with_load_tool_fail_no_token( + self, tool_name_auth, test_tool_auth_schema, aioresponses, sync_client + ): + manifest = ManifestSchema( + serverVersion="0.0.0", tools={tool_name_auth: test_tool_auth_schema} + ) + aioresponses.get( + f"{TEST_BASE_URL}/api/tool/{tool_name_auth}", + payload=manifest.model_dump(), + status=200, + ) + aioresponses.post( + f"{TEST_BASE_URL}/api/tool/{tool_name_auth}/invoke", + payload={"error": "Missing token"}, + status=400, + ) + + tool = sync_client.load_tool(tool_name_auth) + with pytest.raises( + ValueError, + match="One or more of the following authn services are required to invoke this tool: my-auth-service", + ): + tool(argA=15, argB=True) + + def test_add_auth_token_getters_duplicate_fail( + self, tool_name_auth, test_tool_auth_schema, aioresponses, sync_client + ): + manifest = ManifestSchema( + serverVersion="0.0.0", tools={tool_name_auth: test_tool_auth_schema} + ) + aioresponses.get( + f"{TEST_BASE_URL}/api/tool/{tool_name_auth}", + payload=manifest.model_dump(), + status=200, + ) + AUTH_SERVICE = "my-auth-service" + tool = sync_client.load_tool(tool_name_auth) + authed_tool = tool.add_auth_token_getters({AUTH_SERVICE: lambda: "token1"}) + with pytest.raises( + ValueError, + match=f"Authentication source\\(s\\) `{AUTH_SERVICE}` already registered in tool `{tool_name_auth}`.", + ): + authed_tool.add_auth_token_getters({AUTH_SERVICE: lambda: "token2"}) + + def test_add_auth_token_getters_missing_fail( + self, tool_name_auth, test_tool_auth_schema, aioresponses, sync_client + ): + manifest = ManifestSchema( + serverVersion="0.0.0", tools={tool_name_auth: test_tool_auth_schema} + ) + aioresponses.get( + f"{TEST_BASE_URL}/api/tool/{tool_name_auth}", + payload=manifest.model_dump(), + status=200, + ) + UNUSED_AUTH_SERVICE = "xmy-auth-service" + tool = sync_client.load_tool(tool_name_auth) + with pytest.raises( + ValueError, + match=f"Authentication source\\(s\\) `{UNUSED_AUTH_SERVICE}` unused by tool `{tool_name_auth}`.", + ): + tool.add_auth_token_getters({UNUSED_AUTH_SERVICE: lambda: "token"}) + + def test_constructor_getters_missing_fail( + self, tool_name_auth, test_tool_auth_schema, aioresponses, sync_client + ): + manifest = ManifestSchema( + serverVersion="0.0.0", tools={tool_name_auth: test_tool_auth_schema} + ) + aioresponses.get( + f"{TEST_BASE_URL}/api/tool/{tool_name_auth}", + payload=manifest.model_dump(), + status=200, + ) + UNUSED_AUTH_SERVICE = "xmy-auth-service-constructor" + with pytest.raises( + ValueError, + match=f"Validation failed for tool '{tool_name_auth}': unused auth tokens: {UNUSED_AUTH_SERVICE}.", + ): + sync_client.load_tool( + tool_name_auth, + auth_token_getters={UNUSED_AUTH_SERVICE: lambda: "token"}, + ) From f57cbc17129776b979193dd09935c742801d43b6 Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Tue, 13 May 2025 16:44:55 +0530 Subject: [PATCH 6/8] chore: Delint --- packages/toolbox-core/src/toolbox_core/client.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/toolbox-core/src/toolbox_core/client.py b/packages/toolbox-core/src/toolbox_core/client.py index 93ed7b33..8e5ca278 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, Coroutine, Mapping, Optional, Union From a0ed43710b3f462c7b389487a8239a44e49a238d Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Tue, 13 May 2025 16:54:05 +0530 Subject: [PATCH 7/8] chore: Refactor sync client tests to be grouped together in classes These also help identify differences with the existing async client tests. --- .../toolbox-core/tests/test_sync_client.py | 322 ++++++++---------- 1 file changed, 143 insertions(+), 179 deletions(-) diff --git a/packages/toolbox-core/tests/test_sync_client.py b/packages/toolbox-core/tests/test_sync_client.py index deefc2bf..5e5cca1e 100644 --- a/packages/toolbox-core/tests/test_sync_client.py +++ b/packages/toolbox-core/tests/test_sync_client.py @@ -43,12 +43,9 @@ def sync_client_environment(): # Force reset class state before the test. # This ensures any client created will start a new loop/thread. - assert ( - not original_loop or not original_loop.is_running() - ), "Loop should not be running" - assert ( - not original_thread or not original_thread.is_alive() - ), "Thread should not be running" + # Ensure no loop/thread is running from a previous misbehaving test or setup + assert original_loop is None or not original_loop.is_running() + assert original_thread is None or not original_thread.is_alive() ToolboxSyncClient._ToolboxSyncClient__loop = None ToolboxSyncClient._ToolboxSyncClient__thread = None @@ -62,16 +59,12 @@ def sync_client_environment(): if test_loop and test_loop.is_running(): test_loop.call_soon_threadsafe(test_loop.stop) if test_thread and test_thread.is_alive(): - test_thread.join(timeout=2) + test_thread.join(timeout=5) # Explicitly set to None to ensure a clean state for the next fixture use/test. ToolboxSyncClient._ToolboxSyncClient__loop = None ToolboxSyncClient._ToolboxSyncClient__thread = None - # Restoring original_loop/thread could be risky if they were from a - # non-test setup or a previous misbehaving test. For robust test-to-test - # isolation, ensuring these are None after cleanup is generally preferred. - @pytest.fixture def sync_client(sync_client_environment, request): @@ -185,70 +178,7 @@ def mock_tool_invoke( aio_resp.post(url, payload=response_payload, status=status, callback=callback) -# --- Tests for ToolboxSyncClient --- - - -def test_sync_client_creation_in_isolated_env(sync_client): - """Tests that a client is initialized correctly by the sync_client fixture.""" - assert sync_client._ToolboxSyncClient__loop is not None, "Loop should be created" - assert ( - sync_client._ToolboxSyncClient__thread is not None - ), "Thread should be created" - assert sync_client._ToolboxSyncClient__thread.is_alive(), "Thread should be running" - assert isinstance( - sync_client._ToolboxSyncClient__async_client, ToolboxClient - ), "Async client should be ToolboxClient instance" - - -@pytest.mark.usefixtures("sync_client_environment") -def test_sync_client_close_method(): - """ - Tests the close() method of ToolboxSyncClient when manually created. - The sync_client_environment ensures loop/thread cleanup. - """ - mock_async_client_instance = AsyncMock(spec=ToolboxClient) - mock_async_client_instance.close = AsyncMock(return_value=None) - - with patch( - "toolbox_core.sync_client.ToolboxClient", - return_value=mock_async_client_instance, - ) as MockedAsyncClientConst: - # Manually create client; sync_client_environment handles loop setup/teardown. - client = ToolboxSyncClient(TEST_BASE_URL) - MockedAsyncClientConst.assert_called_once_with( - TEST_BASE_URL, client_headers=None - ) - - client.close() # This call closes the async_client's session. - mock_async_client_instance.close.assert_awaited_once() - # The sync_client_environment fixture handles stopping the loop/thread. - - -@pytest.mark.usefixtures("sync_client_environment") -def test_sync_client_context_manager(aioresponses, tool_schema_minimal): - """ - Tests the context manager (__enter__ and __exit__) functionality. - The sync_client_environment ensures loop/thread cleanup. - """ - with patch.object( - ToolboxSyncClient, "close", wraps=ToolboxSyncClient.close, autospec=True - ) as mock_close_method: - with ToolboxSyncClient(TEST_BASE_URL) as client: # Manually creating client - assert isinstance(client, ToolboxSyncClient) - mock_tool_load(aioresponses, "dummy_tool_ctx", tool_schema_minimal) - client.load_tool("dummy_tool_ctx") - mock_close_method.assert_called_once() - - with patch.object( - ToolboxSyncClient, "close", wraps=ToolboxSyncClient.close, autospec=True - ) as mock_close_method_exc: - with pytest.raises(ValueError, match="Test exception"): - with ToolboxSyncClient( - TEST_BASE_URL - ) as client_exc: # Manually creating client - raise ValueError("Test exception") - mock_close_method_exc.assert_called_once() - +# --- Tests for General ToolboxSyncClient Functionality --- def test_sync_load_tool_success(aioresponses, test_tool_str_schema, sync_client): TOOL_NAME = "test_tool_sync_1" @@ -338,120 +268,154 @@ def test_sync_load_tool_not_found_in_manifest( ) -def test_sync_add_headers_success(aioresponses, test_tool_str_schema, sync_client): - tool_name = "tool_after_add_headers_sync" - manifest = ManifestSchema( - serverVersion="0.0.0", tools={tool_name: test_tool_str_schema} - ) - expected_payload = {"result": "added_sync_ok"} - headers_to_add = {"X-Custom-SyncHeader": "sync_value"} - - def get_callback(url, **kwargs): - # The sync_client might have default headers. Check ours are present. - assert kwargs.get("headers") is not None - for key, value in headers_to_add.items(): - assert kwargs["headers"].get(key) == value - return CallbackResult(status=200, payload=manifest.model_dump()) - - aioresponses.get(f"{TEST_BASE_URL}/api/tool/{tool_name}", callback=get_callback) - - def post_callback(url, **kwargs): - assert kwargs.get("headers") is not None - for key, value in headers_to_add.items(): - assert kwargs["headers"].get(key) == value - return CallbackResult(status=200, payload=expected_payload) - - aioresponses.post( - f"{TEST_BASE_URL}/api/tool/{tool_name}/invoke", callback=post_callback - ) +class TestSyncClientLifecycle: + """Tests for ToolboxSyncClient's specific lifecycle and internal management.""" + + def test_sync_client_creation_in_isolated_env(self, sync_client): + """Tests that a client is initialized correctly by the sync_client fixture.""" + assert sync_client._ToolboxSyncClient__loop is not None, "Loop should be created" + assert ( + sync_client._ToolboxSyncClient__thread is not None + ), "Thread should be created" + assert sync_client._ToolboxSyncClient__thread.is_alive(), "Thread should be running" + assert isinstance( + sync_client._ToolboxSyncClient__async_client, ToolboxClient + ), "Async client should be ToolboxClient instance" + + @pytest.mark.usefixtures("sync_client_environment") + def test_sync_client_close_method(self): + """ + Tests the close() method of ToolboxSyncClient when manually created. + The sync_client_environment ensures loop/thread cleanup. + """ + mock_async_client_instance = AsyncMock(spec=ToolboxClient) + # AsyncMock methods are already AsyncMocks + # mock_async_client_instance.close = AsyncMock(return_value=None) + + with patch( + "toolbox_core.sync_client.ToolboxClient", + return_value=mock_async_client_instance, + ) as MockedAsyncClientConst: + client = ToolboxSyncClient(TEST_BASE_URL) + # The sync client passes its internal loop to the async client. + MockedAsyncClientConst.assert_called_once_with( + TEST_BASE_URL, client_headers=None + ) - sync_client.add_headers(headers_to_add) - tool = sync_client.load_tool(tool_name) - result = tool(param1="test") - assert result == expected_payload["result"] + client.close() # This call closes the async_client's session. + mock_async_client_instance.close.assert_awaited_once() + # The sync_client_environment fixture handles stopping the loop/thread. + + @pytest.mark.usefixtures("sync_client_environment") + def test_sync_client_context_manager(self, aioresponses, tool_schema_minimal): + """ + Tests the context manager (__enter__ and __exit__) functionality. + The sync_client_environment ensures loop/thread cleanup. + """ + with patch.object( + ToolboxSyncClient, "close", wraps=ToolboxSyncClient.close, autospec=True + ) as mock_close_method: + with ToolboxSyncClient(TEST_BASE_URL) as client: + assert isinstance(client, ToolboxSyncClient) + mock_tool_load(aioresponses, "dummy_tool_ctx", tool_schema_minimal) + client.load_tool("dummy_tool_ctx") + mock_close_method.assert_called_once() + + with patch.object( + ToolboxSyncClient, "close", wraps=ToolboxSyncClient.close, autospec=True + ) as mock_close_method_exc: + with pytest.raises(ValueError, match="Test exception"): + with ToolboxSyncClient( + TEST_BASE_URL + ) as client_exc: + raise ValueError("Test exception") + mock_close_method_exc.assert_called_once() + + @pytest.mark.usefixtures("sync_client_environment") + def test_load_tool_raises_if_loop_or_thread_none(self): + """ + Tests that load_tool and load_toolset raise ValueError if the class-level + event loop or thread is None. sync_client_environment ensures a clean + slate before this test, and client creation will set up the loop/thread. + """ + client = ToolboxSyncClient(TEST_BASE_URL) # Loop/thread are started here. + + original_class_loop = ToolboxSyncClient._ToolboxSyncClient__loop + original_class_thread = ToolboxSyncClient._ToolboxSyncClient__thread + assert ( + original_class_loop is not None + ), "Loop should have been created by client init" + assert ( + original_class_thread is not None + ), "Thread should have been created by client init" + + # Manually break the class's loop to trigger the error condition in load_tool + ToolboxSyncClient._ToolboxSyncClient__loop = None + with pytest.raises(ValueError, match="Background loop or thread cannot be None."): + client.load_tool("any_tool_should_fail") + ToolboxSyncClient._ToolboxSyncClient__loop = ( + original_class_loop # Restore for next check + ) + ToolboxSyncClient._ToolboxSyncClient__thread = None + with pytest.raises(ValueError, match="Background loop or thread cannot be None."): + client.load_toolset("any_toolset_should_fail") + ToolboxSyncClient._ToolboxSyncClient__thread = original_class_thread # Restore -@pytest.mark.usefixtures("sync_client_environment") -def test_sync_add_headers_duplicate_fail(): - """ - Tests that adding a duplicate header via add_headers raises ValueError. - Manually create client to control initial headers. - """ - initial_headers = {"X-Initial-Header": "initial_value"} - mock_async_client = AsyncMock(spec=ToolboxClient) - - # This mock simulates the behavior of the underlying async client's add_headers - async def mock_add_headers_async_error(headers_to_add): - # Simulate error if header already exists in the "async client's current headers" - if ( - "X-Initial-Header" in headers_to_add - and hasattr(mock_async_client, "_current_headers") - and "X-Initial-Header" in mock_async_client._current_headers - ): - raise ValueError("Client header(s) `X-Initial-Header` already registered") + client.close() # Clean up manually created client + # sync_client_environment will handle the final cleanup of original_class_loop/thread. - mock_async_client.add_headers = ( - mock_add_headers_async_error # Assign as a coroutine - ) - # Patch ToolboxClient constructor to inject initial_headers into the mock async_client state - def side_effect_constructor(base_url, client_headers=None): - # Store the initial headers on the mock_async_client instance for the test - mock_async_client._current_headers = ( - client_headers.copy() if client_headers else {} - ) - return mock_async_client - - with patch( - "toolbox_core.sync_client.ToolboxClient", side_effect=side_effect_constructor - ) as MockedAsyncClientConst: - # Client is created with initial_headers, which are passed to the (mocked) ToolboxClient - client = ToolboxSyncClient(TEST_BASE_URL, client_headers=initial_headers) - MockedAsyncClientConst.assert_called_with( - TEST_BASE_URL, client_headers=initial_headers +class TestSyncClientHeaders: + """Additive tests for client header functionality specific to ToolboxSyncClient if any, + or counterparts to async client header tests.""" + + def test_sync_add_headers_success(self, aioresponses, test_tool_str_schema, sync_client): + tool_name = "tool_after_add_headers_sync" + manifest = ManifestSchema( + serverVersion="0.0.0", tools={tool_name: test_tool_str_schema} ) + expected_payload = {"result": "added_sync_ok"} + headers_to_add = {"X-Custom-SyncHeader": "sync_value"} - with pytest.raises( - ValueError, - match="Client header\\(s\\) `X-Initial-Header` already registered", - ): - # This call to client.add_headers will internally call mock_async_client.add_headers - client.add_headers({"X-Initial-Header": "another_value"}) + def get_callback(url, **kwargs): + # The sync_client might have default headers. Check ours are present. + assert kwargs.get("headers") is not None + for key, value in headers_to_add.items(): + assert kwargs["headers"].get(key) == value + return CallbackResult(status=200, payload=manifest.model_dump()) + aioresponses.get(f"{TEST_BASE_URL}/api/tool/{tool_name}", callback=get_callback) -@pytest.mark.usefixtures("sync_client_environment") -def test_load_tool_raises_if_loop_or_thread_none(): - """ - Tests that load_tool and load_toolset raise ValueError if the class-level - event loop or thread is None. sync_client_environment ensures a clean - slate before this test, and client creation will set up the loop/thread. - """ - client = ToolboxSyncClient(TEST_BASE_URL) # Loop/thread are started here. - - original_class_loop = ToolboxSyncClient._ToolboxSyncClient__loop - original_class_thread = ToolboxSyncClient._ToolboxSyncClient__thread - assert ( - original_class_loop is not None - ), "Loop should have been created by client init" - assert ( - original_class_thread is not None - ), "Thread should have been created by client init" - - # Manually break the class's loop to trigger the error condition in load_tool - ToolboxSyncClient._ToolboxSyncClient__loop = None - with pytest.raises(ValueError, match="Background loop or thread cannot be None."): - client.load_tool("any_tool_should_fail") - ToolboxSyncClient._ToolboxSyncClient__loop = ( - original_class_loop # Restore for next check - ) + def post_callback(url, **kwargs): + assert kwargs.get("headers") is not None + for key, value in headers_to_add.items(): + assert kwargs["headers"].get(key) == value + return CallbackResult(status=200, payload=expected_payload) - ToolboxSyncClient._ToolboxSyncClient__thread = None - with pytest.raises(ValueError, match="Background loop or thread cannot be None."): - client.load_toolset("any_toolset_should_fail") - ToolboxSyncClient._ToolboxSyncClient__thread = original_class_thread # Restore + aioresponses.post( + f"{TEST_BASE_URL}/api/tool/{tool_name}/invoke", callback=post_callback + ) + + sync_client.add_headers(headers_to_add) + tool = sync_client.load_tool(tool_name) + result = tool(param1="test") + assert result == expected_payload["result"] + + @pytest.mark.usefixtures("sync_client_environment") + def test_sync_add_headers_duplicate_fail(self): + """ + Tests that adding a duplicate header via add_headers raises ValueError. + Manually create client to control initial headers. + """ + initial_headers = {"X-Initial-Header": "initial_value"} - client.close() - # sync_client_environment will handle the final cleanup of original_class_loop/thread. + with ToolboxSyncClient(TEST_BASE_URL, client_headers=initial_headers) as client: + with pytest.raises( + ValueError, + match="Client header\\(s\\) `X-Initial-Header` already registered", + ): + client.add_headers({"X-Initial-Header": "another_value"}) class TestSyncAuth: @@ -550,7 +514,7 @@ def test_auth_with_load_tool_fail_no_token( aioresponses.post( f"{TEST_BASE_URL}/api/tool/{tool_name_auth}/invoke", payload={"error": "Missing token"}, - status=400, + status=401, ) tool = sync_client.load_tool(tool_name_auth) From b70dfa72754dcee8fddbe1652be0e1f8c797890f Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Tue, 13 May 2025 16:54:21 +0530 Subject: [PATCH 8/8] chore: Delint --- .../toolbox-core/tests/test_sync_client.py | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/packages/toolbox-core/tests/test_sync_client.py b/packages/toolbox-core/tests/test_sync_client.py index 5e5cca1e..51a4a288 100644 --- a/packages/toolbox-core/tests/test_sync_client.py +++ b/packages/toolbox-core/tests/test_sync_client.py @@ -180,6 +180,7 @@ def mock_tool_invoke( # --- Tests for General ToolboxSyncClient Functionality --- + def test_sync_load_tool_success(aioresponses, test_tool_str_schema, sync_client): TOOL_NAME = "test_tool_sync_1" mock_tool_load(aioresponses, TOOL_NAME, test_tool_str_schema) @@ -273,11 +274,15 @@ class TestSyncClientLifecycle: def test_sync_client_creation_in_isolated_env(self, sync_client): """Tests that a client is initialized correctly by the sync_client fixture.""" - assert sync_client._ToolboxSyncClient__loop is not None, "Loop should be created" + assert ( + sync_client._ToolboxSyncClient__loop is not None + ), "Loop should be created" assert ( sync_client._ToolboxSyncClient__thread is not None ), "Thread should be created" - assert sync_client._ToolboxSyncClient__thread.is_alive(), "Thread should be running" + assert ( + sync_client._ToolboxSyncClient__thread.is_alive() + ), "Thread should be running" assert isinstance( sync_client._ToolboxSyncClient__async_client, ToolboxClient ), "Async client should be ToolboxClient instance" @@ -325,9 +330,7 @@ def test_sync_client_context_manager(self, aioresponses, tool_schema_minimal): ToolboxSyncClient, "close", wraps=ToolboxSyncClient.close, autospec=True ) as mock_close_method_exc: with pytest.raises(ValueError, match="Test exception"): - with ToolboxSyncClient( - TEST_BASE_URL - ) as client_exc: + with ToolboxSyncClient(TEST_BASE_URL) as client_exc: raise ValueError("Test exception") mock_close_method_exc.assert_called_once() @@ -351,18 +354,22 @@ def test_load_tool_raises_if_loop_or_thread_none(self): # Manually break the class's loop to trigger the error condition in load_tool ToolboxSyncClient._ToolboxSyncClient__loop = None - with pytest.raises(ValueError, match="Background loop or thread cannot be None."): + with pytest.raises( + ValueError, match="Background loop or thread cannot be None." + ): client.load_tool("any_tool_should_fail") ToolboxSyncClient._ToolboxSyncClient__loop = ( original_class_loop # Restore for next check ) ToolboxSyncClient._ToolboxSyncClient__thread = None - with pytest.raises(ValueError, match="Background loop or thread cannot be None."): + with pytest.raises( + ValueError, match="Background loop or thread cannot be None." + ): client.load_toolset("any_toolset_should_fail") ToolboxSyncClient._ToolboxSyncClient__thread = original_class_thread # Restore - client.close() # Clean up manually created client + client.close() # Clean up manually created client # sync_client_environment will handle the final cleanup of original_class_loop/thread. @@ -370,7 +377,9 @@ class TestSyncClientHeaders: """Additive tests for client header functionality specific to ToolboxSyncClient if any, or counterparts to async client header tests.""" - def test_sync_add_headers_success(self, aioresponses, test_tool_str_schema, sync_client): + def test_sync_add_headers_success( + self, aioresponses, test_tool_str_schema, sync_client + ): tool_name = "tool_after_add_headers_sync" manifest = ManifestSchema( serverVersion="0.0.0", tools={tool_name: test_tool_str_schema}