From d1163f147b6d10504fa0428a009591272839a3be Mon Sep 17 00:00:00 2001 From: Alejandro Gonzalez Date: Thu, 17 Jul 2025 11:51:26 +0200 Subject: [PATCH 1/3] Added tests to sdk --- README.md | 6 + tests/__init__.py | 1 + tests/test_dynamic_caller.py | 206 ++++++++++++++++++++++++ tests/test_exceptions.py | 105 +++++++++++++ tests/test_function_builder.py | 278 +++++++++++++++++++++++++++++++++ tests/test_mcpd_client.py | 162 +++++++++++++++++++ tests/test_type_converter.py | 205 ++++++++++++++++++++++++ 7 files changed, 963 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/test_dynamic_caller.py create mode 100644 tests/test_exceptions.py create mode 100644 tests/test_function_builder.py create mode 100644 tests/test_mcpd_client.py create mode 100644 tests/test_type_converter.py diff --git a/README.md b/README.md index fa235e5..0d15638 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,12 @@ Then run: uv sync ``` +## Testing + +```bash +uv run pytest +``` + ## Quick Start ```python diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..bcd9801 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Tests for mcpd-sdk-python \ No newline at end of file diff --git a/tests/test_dynamic_caller.py b/tests/test_dynamic_caller.py new file mode 100644 index 0000000..d5a3da0 --- /dev/null +++ b/tests/test_dynamic_caller.py @@ -0,0 +1,206 @@ +from unittest.mock import Mock, patch + +import pytest + +from mcpd_sdk.dynamic_caller import DynamicCaller, ServerProxy +from mcpd_sdk.exceptions import McpdError + + +class TestDynamicCaller: + @pytest.fixture + def mock_client(self): + client = Mock() + client.has_tool = Mock(return_value=True) + client._perform_call = Mock(return_value={"result": "success"}) + return client + + @pytest.fixture + def dynamic_caller(self, mock_client): + return DynamicCaller(mock_client) + + def test_init(self, mock_client): + caller = DynamicCaller(mock_client) + assert caller._client is mock_client + + def test_getattr_returns_server_proxy(self, dynamic_caller, mock_client): + server_proxy = dynamic_caller.test_server + + assert isinstance(server_proxy, ServerProxy) + assert server_proxy._client is mock_client + assert server_proxy._server_name == "test_server" + + def test_multiple_server_access(self, dynamic_caller, mock_client): + server1 = dynamic_caller.server1 + server2 = dynamic_caller.server2 + + assert server1._server_name == "server1" + assert server2._server_name == "server2" + assert server1._client is mock_client + assert server2._client is mock_client + + +class TestServerProxy: + @pytest.fixture + def mock_client(self): + client = Mock() + client.has_tool = Mock(return_value=True) + client._perform_call = Mock(return_value={"result": "success"}) + return client + + @pytest.fixture + def server_proxy(self, mock_client): + return ServerProxy(mock_client, "test_server") + + def test_init(self, mock_client): + proxy = ServerProxy(mock_client, "test_server") + assert proxy._client is mock_client + assert proxy._server_name == "test_server" + + def test_getattr_tool_exists(self, server_proxy, mock_client): + mock_client.has_tool.return_value = True + + tool_callable = server_proxy.test_tool + + assert callable(tool_callable) + mock_client.has_tool.assert_called_once_with("test_server", "test_tool") + + def test_getattr_tool_not_exists(self, server_proxy, mock_client): + mock_client.has_tool.return_value = False + + with pytest.raises(McpdError, match="Tool 'nonexistent_tool' not found on server 'test_server'"): + server_proxy.nonexistent_tool + + def test_tool_callable_execution(self, server_proxy, mock_client): + mock_client.has_tool.return_value = True + + tool_callable = server_proxy.test_tool + result = tool_callable(param1="value1", param2="value2") + + assert result == {"result": "success"} + mock_client._perform_call.assert_called_once_with( + "test_server", "test_tool", {"param1": "value1", "param2": "value2"} + ) + + def test_tool_callable_no_params(self, server_proxy, mock_client): + mock_client.has_tool.return_value = True + + tool_callable = server_proxy.test_tool + result = tool_callable() + + assert result == {"result": "success"} + mock_client._perform_call.assert_called_once_with("test_server", "test_tool", {}) + + def test_tool_callable_with_kwargs(self, server_proxy, mock_client): + mock_client.has_tool.return_value = True + + tool_callable = server_proxy.test_tool + result = tool_callable( + string_param="test", + int_param=42, + bool_param=True, + list_param=["a", "b", "c"], + dict_param={"key": "value"} + ) + + expected_params = { + "string_param": "test", + "int_param": 42, + "bool_param": True, + "list_param": ["a", "b", "c"], + "dict_param": {"key": "value"} + } + + assert result == {"result": "success"} + mock_client._perform_call.assert_called_once_with("test_server", "test_tool", expected_params) + + def test_multiple_tool_calls(self, server_proxy, mock_client): + mock_client.has_tool.return_value = True + + tool1 = server_proxy.tool1 + tool2 = server_proxy.tool2 + + result1 = tool1(param="value1") + result2 = tool2(param="value2") + + assert result1 == {"result": "success"} + assert result2 == {"result": "success"} + + assert mock_client._perform_call.call_count == 2 + mock_client._perform_call.assert_any_call("test_server", "tool1", {"param": "value1"}) + mock_client._perform_call.assert_any_call("test_server", "tool2", {"param": "value2"}) + + def test_has_tool_called_for_each_access(self, server_proxy, mock_client): + mock_client.has_tool.return_value = True + + # Access the same tool multiple times + tool1 = server_proxy.test_tool + tool2 = server_proxy.test_tool + + # has_tool should be called each time + assert mock_client.has_tool.call_count == 2 + mock_client.has_tool.assert_any_call("test_server", "test_tool") + + def test_error_propagation(self, server_proxy, mock_client): + mock_client.has_tool.return_value = True + mock_client._perform_call.side_effect = Exception("API Error") + + tool_callable = server_proxy.test_tool + + with pytest.raises(Exception, match="API Error"): + tool_callable(param="value") + + +class TestIntegration: + @pytest.fixture + def mock_client(self): + client = Mock() + client.has_tool = Mock(return_value=True) + client._perform_call = Mock(return_value={"result": "success"}) + return client + + def test_full_call_chain(self, mock_client): + # Test the complete client.call.server.tool() chain + caller = DynamicCaller(mock_client) + + result = caller.test_server.test_tool(param1="value1", param2="value2") + + assert result == {"result": "success"} + mock_client.has_tool.assert_called_once_with("test_server", "test_tool") + mock_client._perform_call.assert_called_once_with( + "test_server", "test_tool", {"param1": "value1", "param2": "value2"} + ) + + def test_multiple_servers_and_tools(self, mock_client): + caller = DynamicCaller(mock_client) + + # Call different tools on different servers + result1 = caller.server1.tool1(param="value1") + result2 = caller.server2.tool2(param="value2") + result3 = caller.server1.tool3(param="value3") + + assert result1 == {"result": "success"} + assert result2 == {"result": "success"} + assert result3 == {"result": "success"} + + # Verify all calls were made correctly + expected_calls = [ + (("server1", "tool1"), {}), + (("server2", "tool2"), {}), + (("server1", "tool3"), {}) + ] + + has_tool_calls = mock_client.has_tool.call_args_list + perform_call_calls = mock_client._perform_call.call_args_list + + assert len(has_tool_calls) == 3 + assert len(perform_call_calls) == 3 + + # Check that has_tool was called with correct arguments + assert has_tool_calls[0][0] == ("server1", "tool1") + assert has_tool_calls[1][0] == ("server2", "tool2") + assert has_tool_calls[2][0] == ("server1", "tool3") + + # Check that _perform_call was called with correct arguments + assert perform_call_calls[0][0] == ("server1", "tool1", {"param": "value1"}) + assert perform_call_calls[1][0] == ("server2", "tool2", {"param": "value2"}) + assert perform_call_calls[2][0] == ("server1", "tool3", {"param": "value3"}) \ No newline at end of file diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 0000000..992576b --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,105 @@ +import pytest + +from mcpd_sdk.exceptions import McpdError + + +class TestMcpdError: + def test_mcpd_error_inheritance(self): + """Test that McpdError inherits from Exception.""" + assert issubclass(McpdError, Exception) + + def test_mcpd_error_basic_creation(self): + """Test basic McpdError creation.""" + error = McpdError("Test error message") + assert str(error) == "Test error message" + + def test_mcpd_error_empty_message(self): + """Test McpdError with empty message.""" + error = McpdError("") + assert str(error) == "" + + def test_mcpd_error_none_message(self): + """Test McpdError with None message.""" + error = McpdError(None) + assert str(error) == "None" + + def test_mcpd_error_with_args(self): + """Test McpdError with multiple arguments.""" + error = McpdError("Error", "with", "multiple", "args") + assert "Error" in str(error) + + def test_mcpd_error_raising(self): + """Test that McpdError can be raised and caught.""" + with pytest.raises(McpdError): + raise McpdError("Test error") + + def test_mcpd_error_catching_as_exception(self): + """Test that McpdError can be caught as Exception.""" + with pytest.raises(Exception): + raise McpdError("Test error") + + def test_mcpd_error_chaining(self): + """Test error chaining with McpdError.""" + original_error = ValueError("Original error") + + try: + raise original_error + except ValueError as e: + chained_error = McpdError("Chained error") + chained_error.__cause__ = e + + assert chained_error.__cause__ is original_error + assert str(chained_error) == "Chained error" + + def test_mcpd_error_with_format_string(self): + """Test McpdError with format string.""" + server_name = "test_server" + tool_name = "test_tool" + error = McpdError(f"Error calling tool '{tool_name}' on server '{server_name}'") + + expected_message = "Error calling tool 'test_tool' on server 'test_server'" + assert str(error) == expected_message + + def test_mcpd_error_attributes(self): + """Test that McpdError has expected attributes.""" + error = McpdError("Test error") + + # Should have standard Exception attributes + assert hasattr(error, 'args') + assert error.args == ("Test error",) + + def test_mcpd_error_repr(self): + """Test string representation of McpdError.""" + error = McpdError("Test error") + repr_str = repr(error) + + assert "McpdError" in repr_str + assert "Test error" in repr_str + + def test_mcpd_error_instance_check(self): + """Test isinstance checks with McpdError.""" + error = McpdError("Test error") + + assert isinstance(error, McpdError) + assert isinstance(error, Exception) + assert isinstance(error, BaseException) + + def test_mcpd_error_equality(self): + """Test equality comparison of McpdError instances.""" + error1 = McpdError("Same message") + error2 = McpdError("Same message") + error3 = McpdError("Different message") + + # Note: Exception instances are not equal even with same message + # This is standard Python behavior + assert error1 is not error2 + assert error1 is not error3 + + def test_mcpd_error_with_complex_message(self): + """Test McpdError with complex message containing various data types.""" + data = {"server": "test", "tool": "example", "params": [1, 2, 3]} + error = McpdError(f"Complex error with data: {data}") + + assert "Complex error with data:" in str(error) + assert "test" in str(error) + assert "example" in str(error) \ No newline at end of file diff --git a/tests/test_function_builder.py b/tests/test_function_builder.py new file mode 100644 index 0000000..45a1ba4 --- /dev/null +++ b/tests/test_function_builder.py @@ -0,0 +1,278 @@ +from types import FunctionType +from unittest.mock import Mock, patch + +import pytest + +from mcpd_sdk.exceptions import McpdError +from mcpd_sdk.function_builder import FunctionBuilder + + +class TestFunctionBuilder: + @pytest.fixture + def mock_client(self): + client = Mock() + client._perform_call = Mock(return_value={"result": "success"}) + return client + + @pytest.fixture + def function_builder(self, mock_client): + return FunctionBuilder(mock_client) + + def test_safe_name_alphanumeric(self, function_builder): + assert function_builder._safe_name("test_func") == "test_func" + assert function_builder._safe_name("TestFunc123") == "TestFunc123" + + def test_safe_name_special_chars(self, function_builder): + assert function_builder._safe_name("test-func") == "test_func" + assert function_builder._safe_name("test.func") == "test_func" + assert function_builder._safe_name("test func") == "test_func" + assert function_builder._safe_name("test@func#") == "test_func_" + + def test_safe_name_leading_digit(self, function_builder): + assert function_builder._safe_name("123test") == "_123test" + assert function_builder._safe_name("9abc") == "_9abc" + + def test_function_name(self, function_builder): + result = function_builder._function_name("test_server", "test_tool") + assert result == "test_server__test_tool" + + def test_function_name_with_special_chars(self, function_builder): + result = function_builder._function_name("test-server", "test.tool") + assert result == "test_server__test_tool" + + def test_create_function_from_schema_basic(self, function_builder): + schema = { + "name": "test_tool", + "description": "A test tool", + "inputSchema": { + "type": "object", + "properties": { + "param1": {"type": "string", "description": "First parameter"}, + "param2": {"type": "integer", "description": "Second parameter"} + }, + "required": ["param1"] + } + } + + func = function_builder.create_function_from_schema(schema, "test_server") + + assert isinstance(func, FunctionType) + assert func.__name__ == "test_server__test_tool" + assert "A test tool" in func.__doc__ + assert "param1" in func.__doc__ + assert "param2" in func.__doc__ + + def test_create_function_from_schema_execution(self, function_builder): + schema = { + "name": "test_tool", + "description": "A test tool", + "inputSchema": { + "type": "object", + "properties": { + "param1": {"type": "string", "description": "First parameter"} + }, + "required": ["param1"] + } + } + + func = function_builder.create_function_from_schema(schema, "test_server") + result = func(param1="test_value") + + assert result == {"result": "success"} + function_builder.client._perform_call.assert_called_once_with( + "test_server", "test_tool", {"param1": "test_value"} + ) + + def test_create_function_from_schema_missing_required(self, function_builder): + schema = { + "name": "test_tool", + "description": "A test tool", + "inputSchema": { + "type": "object", + "properties": { + "param1": {"type": "string", "description": "First parameter"} + }, + "required": ["param1"] + } + } + + func = function_builder.create_function_from_schema(schema, "test_server") + + with pytest.raises(McpdError, match="Missing required parameters"): + func(param1=None) + + def test_create_function_from_schema_optional_params(self, function_builder): + schema = { + "name": "test_tool", + "description": "A test tool", + "inputSchema": { + "type": "object", + "properties": { + "param1": {"type": "string", "description": "Required parameter"}, + "param2": {"type": "string", "description": "Optional parameter"} + }, + "required": ["param1"] + } + } + + func = function_builder.create_function_from_schema(schema, "test_server") + + # Test with only required param + result = func(param1="test") + function_builder.client._perform_call.assert_called_with( + "test_server", "test_tool", {"param1": "test"} + ) + + # Test with optional param + result = func(param1="test", param2="optional") + function_builder.client._perform_call.assert_called_with( + "test_server", "test_tool", {"param1": "test", "param2": "optional"} + ) + + def test_create_function_from_schema_no_params(self, function_builder): + schema = { + "name": "test_tool", + "description": "A test tool with no parameters", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + } + + func = function_builder.create_function_from_schema(schema, "test_server") + result = func() + + function_builder.client._perform_call.assert_called_once_with( + "test_server", "test_tool", {} + ) + + def test_create_function_from_schema_caching(self, function_builder): + schema = { + "name": "test_tool", + "description": "A test tool", + "inputSchema": { + "type": "object", + "properties": {"param1": {"type": "string"}}, + "required": ["param1"] + } + } + + func1 = function_builder.create_function_from_schema(schema, "test_server") + func2 = function_builder.create_function_from_schema(schema, "test_server") + + # Should be different instances but same functionality + assert func1 is not func2 + assert func1.__name__ == func2.__name__ + + def test_create_annotations_basic_types(self, function_builder): + schema = { + "inputSchema": { + "type": "object", + "properties": { + "str_param": {"type": "string"}, + "int_param": {"type": "integer"}, + "bool_param": {"type": "boolean"} + }, + "required": ["str_param"] + } + } + + annotations = function_builder._create_annotations(schema) + + assert annotations["str_param"] == str + assert annotations["int_param"] == (int | None) + assert annotations["bool_param"] == (bool | None) + + def test_create_docstring_with_params(self, function_builder): + schema = { + "description": "Test function description", + "inputSchema": { + "type": "object", + "properties": { + "param1": {"type": "string", "description": "First parameter"}, + "param2": {"type": "integer", "description": "Second parameter"} + }, + "required": ["param1"] + } + } + + docstring = function_builder._create_docstring(schema) + + assert "Test function description" in docstring + assert "Args:" in docstring + assert "param1: First parameter" in docstring + assert "param2: Second parameter (optional)" in docstring + assert "Returns:" in docstring + assert "Raises:" in docstring + + def test_create_docstring_no_params(self, function_builder): + schema = { + "description": "Test function description", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + } + + docstring = function_builder._create_docstring(schema) + + assert "Test function description" in docstring + assert "Args:" not in docstring + assert "Returns:" in docstring + + def test_create_namespace(self, function_builder): + namespace = function_builder._create_namespace() + + assert "McpdError" in namespace + assert "client" in namespace + assert namespace["client"] is function_builder.client + assert "Any" in namespace + assert "str" in namespace + assert "int" in namespace + + def test_clear_cache(self, function_builder): + # Add something to cache + function_builder._function_cache["test"] = {"data": "test"} + + function_builder.clear_cache() + + assert len(function_builder._function_cache) == 0 + + def test_build_function_code_structure(self, function_builder): + schema = { + "name": "test_tool", + "description": "Test tool", + "inputSchema": { + "type": "object", + "properties": { + "param1": {"type": "string", "description": "Required parameter"}, + "param2": {"type": "string", "description": "Optional parameter"} + }, + "required": ["param1"] + } + } + + code = function_builder._build_function_code(schema, "test_server") + + assert "def test_server__test_tool(param1, param2=None):" in code + assert "required_params = ['param1']" in code + assert 'client._perform_call("test_server", "test_tool", params)' in code + assert "Test tool" in code + + def test_create_function_compilation_error(self, function_builder): + # Create a schema that would cause compilation issues + schema = { + "name": "test-tool", # This should be sanitized + "description": "Test tool", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + } + + # Should not raise an error due to name sanitization + func = function_builder.create_function_from_schema(schema, "test-server") + assert func.__name__ == "test_server__test_tool" \ No newline at end of file diff --git a/tests/test_mcpd_client.py b/tests/test_mcpd_client.py new file mode 100644 index 0000000..29b6c2b --- /dev/null +++ b/tests/test_mcpd_client.py @@ -0,0 +1,162 @@ +from unittest.mock import MagicMock, Mock, patch + +import pytest +import requests + +from mcpd_sdk import McpdClient, McpdError + + +class TestMcpdClient: + @pytest.fixture + def client(self): + return McpdClient(api_endpoint="http://localhost:8090") + + @pytest.fixture + def client_with_auth(self): + return McpdClient(api_endpoint="http://localhost:8090", api_key="test-key") + + def test_init_basic(self, client): + assert client.endpoint == "http://localhost:8090" + assert client.api_key is None + assert hasattr(client, 'session') + assert hasattr(client, 'call') + + def test_init_with_auth(self, client_with_auth): + assert client_with_auth.endpoint == "http://localhost:8090" + assert client_with_auth.api_key == "test-key" + assert "Authorization" in client_with_auth.session.headers + assert client_with_auth.session.headers["Authorization"] == "Bearer test-key" + + def test_init_strips_trailing_slash(self): + client = McpdClient("http://localhost:8090/") + assert client.endpoint == "http://localhost:8090" + + @patch('requests.Session.get') + def test_servers_success(self, mock_get, client): + mock_response = Mock() + mock_response.json.return_value = ["server1", "server2"] + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + result = client.servers() + + assert result == ["server1", "server2"] + mock_get.assert_called_once_with("http://localhost:8090/api/v1/servers", timeout=5) + + @patch('requests.Session.get') + def test_servers_request_error(self, mock_get, client): + mock_get.side_effect = requests.exceptions.RequestException("Connection failed") + + with pytest.raises(McpdError, match="Error listing servers"): + client.servers() + + @patch('requests.Session.get') + def test_tools_single_server(self, mock_get, client): + mock_response = Mock() + mock_response.json.return_value = {"tools": [{"name": "tool1"}, {"name": "tool2"}]} + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + result = client.tools("test_server") + + assert result == [{"name": "tool1"}, {"name": "tool2"}] + mock_get.assert_called_once_with("http://localhost:8090/api/v1/servers/test_server/tools", timeout=5) + + @patch('requests.Session.get') + def test_tools_all_servers(self, mock_get, client): + # Mock servers() call + servers_response = Mock() + servers_response.json.return_value = ["server1", "server2"] + servers_response.raise_for_status.return_value = None + + # Mock tools calls for each server + tools_response = Mock() + tools_response.json.return_value = {"tools": [{"name": "tool1"}]} + tools_response.raise_for_status.return_value = None + + mock_get.side_effect = [servers_response, tools_response, tools_response] + + result = client.tools() + + assert result == { + "server1": [{"name": "tool1"}], + "server2": [{"name": "tool1"}] + } + assert mock_get.call_count == 3 + + @patch('requests.Session.get') + def test_tools_request_error(self, mock_get, client): + mock_get.side_effect = requests.exceptions.RequestException("Connection failed") + + with pytest.raises(McpdError, match="Error listing tool definitions"): + client.tools("test_server") + + @patch('requests.Session.post') + def test_perform_call_success(self, mock_post, client): + mock_response = Mock() + mock_response.json.return_value = {"result": "success"} + mock_response.raise_for_status.return_value = None + mock_post.return_value = mock_response + + result = client._perform_call("test_server", "test_tool", {"param": "value"}) + + assert result == {"result": "success"} + mock_post.assert_called_once_with( + "http://localhost:8090/api/v1/servers/test_server/tools/test_tool", + json={"param": "value"}, + timeout=30 + ) + + @patch('requests.Session.post') + def test_perform_call_request_error(self, mock_post, client): + mock_post.side_effect = requests.exceptions.RequestException("Connection failed") + + with pytest.raises(McpdError, match="Error calling tool 'test_tool' on server 'test_server'"): + client._perform_call("test_server", "test_tool", {"param": "value"}) + + @patch.object(McpdClient, 'tools') + def test_agent_tools(self, mock_tools, client): + mock_tools.return_value = { + "server1": [{"name": "tool1", "description": "Test tool"}], + "server2": [{"name": "tool2", "description": "Another tool"}] + } + + with patch.object(client._function_builder, 'create_function_from_schema') as mock_create: + mock_func1 = Mock() + mock_func2 = Mock() + mock_create.side_effect = [mock_func1, mock_func2] + + result = client.agent_tools() + + assert result == [mock_func1, mock_func2] + assert mock_create.call_count == 2 + + @patch.object(McpdClient, 'tools') + def test_has_tool_exists(self, mock_tools, client): + mock_tools.return_value = [{"name": "existing_tool"}, {"name": "another_tool"}] + + result = client.has_tool("test_server", "existing_tool") + + assert result is True + mock_tools.assert_called_once_with(server_name="test_server") + + @patch.object(McpdClient, 'tools') + def test_has_tool_not_exists(self, mock_tools, client): + mock_tools.return_value = [{"name": "existing_tool"}, {"name": "another_tool"}] + + result = client.has_tool("test_server", "nonexistent_tool") + + assert result is False + + @patch.object(McpdClient, 'tools') + def test_has_tool_server_error(self, mock_tools, client): + mock_tools.side_effect = McpdError("Server error") + + result = client.has_tool("test_server", "any_tool") + + assert result is False + + def test_clear_agent_tools_cache(self, client): + with patch.object(client._function_builder, 'clear_cache') as mock_clear: + client.clear_agent_tools_cache() + mock_clear.assert_called_once() \ No newline at end of file diff --git a/tests/test_type_converter.py b/tests/test_type_converter.py new file mode 100644 index 0000000..0944ff3 --- /dev/null +++ b/tests/test_type_converter.py @@ -0,0 +1,205 @@ +from typing import Any, Literal, Union + +import pytest + +from mcpd_sdk.type_converter import TypeConverter + + +class TestTypeConverter: + + def test_json_type_to_python_type_string(self): + result = TypeConverter.json_type_to_python_type("string", {}) + assert result == str + + def test_json_type_to_python_type_string_with_enum(self): + schema = {"enum": ["option1", "option2", "option3"]} + result = TypeConverter.json_type_to_python_type("string", schema) + + # Should be a Literal type + assert hasattr(result, '__origin__') + assert result.__origin__ is Literal + + def test_json_type_to_python_type_string_with_single_enum(self): + schema = {"enum": ["single_option"]} + result = TypeConverter.json_type_to_python_type("string", schema) + + # Should handle single enum value + assert hasattr(result, '__origin__') or result == Literal["single_option"] + + def test_json_type_to_python_type_number(self): + result = TypeConverter.json_type_to_python_type("number", {}) + assert result == (int | float) + + def test_json_type_to_python_type_integer(self): + result = TypeConverter.json_type_to_python_type("integer", {}) + assert result == int + + def test_json_type_to_python_type_boolean(self): + result = TypeConverter.json_type_to_python_type("boolean", {}) + assert result == bool + + def test_json_type_to_python_type_array_with_items(self): + schema = {"items": {"type": "string"}} + result = TypeConverter.json_type_to_python_type("array", schema) + assert result == list[str] + + def test_json_type_to_python_type_array_without_items(self): + result = TypeConverter.json_type_to_python_type("array", {}) + assert result == list[Any] + + def test_json_type_to_python_type_object(self): + result = TypeConverter.json_type_to_python_type("object", {}) + assert result == dict[str, Any] + + def test_json_type_to_python_type_unknown(self): + result = TypeConverter.json_type_to_python_type("unknown_type", {}) + assert result == Any + + def test_parse_schema_type_simple_type(self): + schema = {"type": "string"} + result = TypeConverter.parse_schema_type(schema) + assert result == str + + def test_parse_schema_type_anyof_simple(self): + schema = { + "anyOf": [ + {"type": "string"}, + {"type": "integer"} + ] + } + result = TypeConverter.parse_schema_type(schema) + assert result == (str | int) + + def test_parse_schema_type_anyof_complex(self): + schema = { + "anyOf": [ + {"type": "string"}, + {"type": "integer"}, + {"type": "boolean"} + ] + } + result = TypeConverter.parse_schema_type(schema) + assert result == (str | int | bool) + + def test_parse_schema_type_anyof_with_arrays(self): + schema = { + "anyOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "integer"}} + ] + } + result = TypeConverter.parse_schema_type(schema) + assert result == (str | list[int]) + + def test_parse_schema_type_nested_anyof(self): + schema = { + "anyOf": [ + {"type": "string"}, + { + "anyOf": [ + {"type": "integer"}, + {"type": "boolean"} + ] + } + ] + } + result = TypeConverter.parse_schema_type(schema) + # Should handle nested anyOf + assert result == (str | (int | bool)) + + def test_parse_schema_type_no_type_field(self): + schema = {"description": "Some schema without type"} + result = TypeConverter.parse_schema_type(schema) + assert result == Any + + def test_parse_schema_type_empty_schema(self): + schema = {} + result = TypeConverter.parse_schema_type(schema) + assert result == Any + + def test_parse_schema_type_array_with_complex_items(self): + schema = { + "type": "array", + "items": { + "type": "object" + } + } + result = TypeConverter.parse_schema_type(schema) + assert result == list[dict[str, Any]] + + def test_parse_schema_type_enum_with_mixed_types(self): + # Test enum with different value types + schema = { + "type": "string", + "enum": ["string_val", "another_string"] + } + result = TypeConverter.parse_schema_type(schema) + + # Should be a Literal type + assert hasattr(result, '__origin__') + assert result.__origin__ is Literal + + def test_complex_nested_schema(self): + schema = { + "type": "object", + "properties": { + "nested_array": { + "type": "array", + "items": { + "anyOf": [ + {"type": "string"}, + {"type": "integer"} + ] + } + } + } + } + # This tests the overall parsing, not specific property parsing + result = TypeConverter.parse_schema_type(schema) + assert result == dict[str, Any] + + def test_array_with_anyof_items(self): + schema = { + "type": "array", + "items": { + "anyOf": [ + {"type": "string"}, + {"type": "integer"} + ] + } + } + result = TypeConverter.parse_schema_type(schema) + assert result == list[str | int] + + def test_enum_fallback_handling(self): + # Test the fallback mechanism for complex enum handling + schema = { + "type": "string", + "enum": ["val1", "val2", "val3", "val4"] + } + result = TypeConverter.parse_schema_type(schema) + + # Should handle the enum properly + assert hasattr(result, '__origin__') + assert result.__origin__ is Literal + + def test_json_type_to_python_type_array_recursive(self): + schema = { + "items": { + "type": "array", + "items": {"type": "string"} + } + } + result = TypeConverter.json_type_to_python_type("array", schema) + assert result == list[list[str]] + + def test_parse_schema_type_with_null_in_anyof(self): + schema = { + "anyOf": [ + {"type": "string"}, + {"type": "null"} + ] + } + result = TypeConverter.parse_schema_type(schema) + # Should handle null type properly + assert result == (str | Any) # null maps to Any in this implementation \ No newline at end of file From da2c2383fc93dc012e642a591f800aee692248c6 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Thu, 17 Jul 2025 17:34:40 +0100 Subject: [PATCH 2/3] Updates/fixes * uv run pre-commit run --all-files * fixed pyproject.toml build system settings for running tests * added py.typed file for PEP 561 * added 'make setup' to Makefile * added 'make test' to Makefile * updated README * added scripts/ to handle install/upgrade of uv * tests: added conftest, matched test folder structure of projects like AnyAgent --- Makefile | 12 ++ README.md | 26 ++++- examples/readme_maker/readme_maker.py | 10 +- pyproject.toml | 16 ++- scripts/setup_uv.sh | 50 ++++++++ src/mcpd_sdk/__init__.py | 15 +++ src/mcpd_sdk/py.typed | 0 tests/__init__.py | 1 - tests/conftest.py | 57 +++++++++ tests/unit/__init__.py | 0 tests/{ => unit}/test_dynamic_caller.py | 99 ++++++---------- tests/{ => unit}/test_exceptions.py | 20 ++-- tests/{ => unit}/test_function_builder.py | 135 +++++++++------------- tests/{ => unit}/test_mcpd_client.py | 109 ++++++++--------- tests/{ => unit}/test_type_converter.py | 110 ++++-------------- uv.lock | 41 ++++++- 16 files changed, 385 insertions(+), 316 deletions(-) create mode 100644 Makefile create mode 100755 scripts/setup_uv.sh create mode 100644 src/mcpd_sdk/py.typed create mode 100644 tests/conftest.py create mode 100644 tests/unit/__init__.py rename tests/{ => unit}/test_dynamic_caller.py (82%) rename tests/{ => unit}/test_exceptions.py (96%) rename tests/{ => unit}/test_function_builder.py (80%) rename tests/{ => unit}/test_mcpd_client.py (64%) rename tests/{ => unit}/test_type_converter.py (67%) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1fe1d61 --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +.PHONY: setup test + +ensure-scripts-exec: + +ensure-scripts-exec: + @chmod +x scripts/* || true + +setup: ensure-scripts-exec + @scripts/setup_uv.sh + +test: + @uv run $(DEBUGPY_ARGS) -m pytest tests diff --git a/README.md b/README.md index 0d15638..b4b90d8 100644 --- a/README.md +++ b/README.md @@ -31,13 +31,37 @@ dependencies = [ Then run: ```bash +uv venv +source .venv/bin/activate uv sync ``` +## Dev Setup + +Use the `Makefile` target to ensure `uv` is installed, and your virtual environment is active and sync'd. + +```bash +make setup +``` + ## Testing +Ensure you have the correct dependencies installed for testing: + +```bash +uv sync --group tests +``` + +Then to run all tests: + +```bash +uv run pytest tests +``` + +... or via `Makefile`: + ```bash -uv run pytest +make test ``` ## Quick Start diff --git a/examples/readme_maker/readme_maker.py b/examples/readme_maker/readme_maker.py index 540ab0d..db2b5e1 100644 --- a/examples/readme_maker/readme_maker.py +++ b/examples/readme_maker/readme_maker.py @@ -30,8 +30,8 @@ If a template README file from an original repository ({input_repo_readme}) is provided: - Template Utilization: Use the structure and style of the original README file as a guide. -- Content Adaptation: Modify the content to accurately describe the target repository. -This step requires analyzing the entire codebase of the target repository ({target_repo}) +- Content Adaptation: Modify the content to accurately describe the target repository. +This step requires analyzing the entire codebase of the target repository ({target_repo}) to understand its functionality, features, and any other relevant details that should be included in the README. If no template README file is provided: @@ -45,7 +45,7 @@ -License information -Acknowledgements (if applicable) -To create the README, analyze the entire codebase of the target repository ({target_repo}) +To create the README, analyze the entire codebase of the target repository ({target_repo}) to understand its functionality, features, and any other relevant details. After creating the new README file: @@ -55,8 +55,8 @@ commit message that clearly indicates the addition of the new README. 3. Pull Request (PR) Creation: Open a Pull Request against the main branch of the target repository, including the new README file. -4. PR Description: In the Pull Request description, mention that the README was generated -by an AI agent of the model {agent_model}. If a template was used, include a link to +4. PR Description: In the Pull Request description, mention that the README was generated +by an AI agent of the model {agent_model}. If a template was used, include a link to the original README template ({input_repo_readme}) used for generating the new README. When providing code snippets for this task, follow the guidelines for code modifications: diff --git a/pyproject.toml b/pyproject.toml index f9bc507..d119fe6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcpd-sdk" -version = "0.0.1" +dynamic = ["version"] description = "mcpd Python SDK" readme = "README.md" license = {text = "Apache-2.0"} @@ -23,6 +23,8 @@ lint = [ tests = [ "pytest>=8.3.5", + "setuptools>=80.9.0", + "setuptools-scm[toml]>=8.3.1", ] all = [ @@ -31,6 +33,18 @@ all = [ { include-group = "tests" }, ] +[build-system] +requires = ["setuptools>=48", "setuptools_scm[toml]>=6.3.1"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +exclude = ["tests", "tests.*"] +where = ["src"] +namespaces = false + +[tool.setuptools.package-data] +"*" = ["py.typed"] + [tool.ruff] fix = true line-length=120 diff --git a/scripts/setup_uv.sh b/scripts/setup_uv.sh new file mode 100755 index 0000000..05b7c5e --- /dev/null +++ b/scripts/setup_uv.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' + +# ########## +# Install UV +# ########## + +# Expected installation path for uv +# See: https://docs.astral.sh/uv/configuration/installer/#changing-the-install-path +LOCAL_BIN="${LOCAL_BIN:-$HOME/.local/bin}" + +# Ensure LOCAL_BIN exists and is on PATH +mkdir -p "$LOCAL_BIN" +if [[ ":$PATH:" != *":$LOCAL_BIN:"* ]]; then + export PATH="$LOCAL_BIN:$PATH" +fi + +VENV_DIR=".venv" + +# Install or update uv +if ! command -v uv &>/dev/null; then + echo "uv not found – installing to $LOCAL_BIN" + curl -fsSL https://astral.sh/uv/install.sh | UV_INSTALL_DIR="$LOCAL_BIN" sh +else + current=$(uv --version | awk '{print $2}') + echo "Found uv v$current" + if command -v jq &>/dev/null; then + latest=$(curl -fsS https://api.github.com/repos/astral-sh/uv/releases/latest \ + | jq -r .tag_name) + if [[ "$current" != "$latest" ]]; then + echo "Updating uv: $current → $latest" + uv self update + fi + fi +fi + +# Bootstrap root .venv +echo "Bootstrapping root .venv in folder $VENV_DIR" +uv venv "$VENV_DIR" +uv sync --group all --active + +echo "Done! Root environment is ready in: $VENV_DIR" + +# After detecting PATH lacked LOCAL_BIN… +if [[ ":$PATH:" != *":$LOCAL_BIN:"* ]]; then + echo "Note: added $LOCAL_BIN to PATH for this session." + echo "To make it permanent, add to your shell profile:" + echo " export PATH=\"$LOCAL_BIN:\$PATH\"" +fi diff --git a/src/mcpd_sdk/__init__.py b/src/mcpd_sdk/__init__.py index ace6348..c94f8c9 100644 --- a/src/mcpd_sdk/__init__.py +++ b/src/mcpd_sdk/__init__.py @@ -1 +1,16 @@ +from importlib.metadata import PackageNotFoundError, version + from .mcpd_client import McpdClient, McpdError + +try: + __version__ = version("mcpd_sdk") +except PackageNotFoundError: + # In the case of local development + # i.e., running directly from the source directory without package being installed + __version__ = "0.0.0-dev" + +__all__ = [ + "McpdClient", + "McpdError", + "__version__", +] diff --git a/src/mcpd_sdk/py.typed b/src/mcpd_sdk/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/__init__.py b/tests/__init__.py index bcd9801..e69de29 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +0,0 @@ -# Tests for mcpd-sdk-python \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..40992d3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,57 @@ +from unittest.mock import Mock + +import pytest + +from mcpd_sdk import McpdClient, McpdError + + +@pytest.fixture(scope="function") +def mock_client(): + client = Mock() + client._perform_call = Mock(return_value={"result": "success"}) + client.has_tool = Mock(return_value=True) + return client + + +@pytest.fixture(scope="function") +def mock_response(): + response = Mock() + response.json.return_value = {"result": "success"} + response.raise_for_status.return_value = None + return response + + +@pytest.fixture(scope="session") +def basic_schema(): + return { + "name": "test_tool", + "description": "A test tool", + "inputSchema": { + "type": "object", + "properties": { + "param1": {"type": "string", "description": "First parameter"}, + "param2": {"type": "integer", "description": "Second parameter"}, + }, + "required": ["param1"], + }, + } + + +@pytest.fixture(scope="session") +def fqdn() -> str: + return "http://localhost:8090" + + +@pytest.fixture(scope="session") +def api_url(fqdn) -> str: + return fqdn + "/api/v1" + + +@pytest.fixture(scope="function") +def client(fqdn): + return McpdClient(api_endpoint=fqdn) + + +@pytest.fixture(scope="function") +def client_with_auth(fqdn): + return McpdClient(api_endpoint=fqdn, api_key="test-key") # pragma: allowlist secret diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_dynamic_caller.py b/tests/unit/test_dynamic_caller.py similarity index 82% rename from tests/test_dynamic_caller.py rename to tests/unit/test_dynamic_caller.py index d5a3da0..d40714d 100644 --- a/tests/test_dynamic_caller.py +++ b/tests/unit/test_dynamic_caller.py @@ -1,19 +1,10 @@ -from unittest.mock import Mock, patch - import pytest +from mcpd_sdk import McpdError from mcpd_sdk.dynamic_caller import DynamicCaller, ServerProxy -from mcpd_sdk.exceptions import McpdError class TestDynamicCaller: - @pytest.fixture - def mock_client(self): - client = Mock() - client.has_tool = Mock(return_value=True) - client._perform_call = Mock(return_value={"result": "success"}) - return client - @pytest.fixture def dynamic_caller(self, mock_client): return DynamicCaller(mock_client) @@ -24,7 +15,7 @@ def test_init(self, mock_client): def test_getattr_returns_server_proxy(self, dynamic_caller, mock_client): server_proxy = dynamic_caller.test_server - + assert isinstance(server_proxy, ServerProxy) assert server_proxy._client is mock_client assert server_proxy._server_name == "test_server" @@ -32,7 +23,7 @@ def test_getattr_returns_server_proxy(self, dynamic_caller, mock_client): def test_multiple_server_access(self, dynamic_caller, mock_client): server1 = dynamic_caller.server1 server2 = dynamic_caller.server2 - + assert server1._server_name == "server1" assert server2._server_name == "server2" assert server1._client is mock_client @@ -40,13 +31,6 @@ def test_multiple_server_access(self, dynamic_caller, mock_client): class TestServerProxy: - @pytest.fixture - def mock_client(self): - client = Mock() - client.has_tool = Mock(return_value=True) - client._perform_call = Mock(return_value={"result": "success"}) - return client - @pytest.fixture def server_proxy(self, mock_client): return ServerProxy(mock_client, "test_server") @@ -58,24 +42,24 @@ def test_init(self, mock_client): def test_getattr_tool_exists(self, server_proxy, mock_client): mock_client.has_tool.return_value = True - + tool_callable = server_proxy.test_tool - + assert callable(tool_callable) mock_client.has_tool.assert_called_once_with("test_server", "test_tool") def test_getattr_tool_not_exists(self, server_proxy, mock_client): mock_client.has_tool.return_value = False - + with pytest.raises(McpdError, match="Tool 'nonexistent_tool' not found on server 'test_server'"): server_proxy.nonexistent_tool def test_tool_callable_execution(self, server_proxy, mock_client): mock_client.has_tool.return_value = True - + tool_callable = server_proxy.test_tool result = tool_callable(param1="value1", param2="value2") - + assert result == {"result": "success"} mock_client._perform_call.assert_called_once_with( "test_server", "test_tool", {"param1": "value1", "param2": "value2"} @@ -83,59 +67,55 @@ def test_tool_callable_execution(self, server_proxy, mock_client): def test_tool_callable_no_params(self, server_proxy, mock_client): mock_client.has_tool.return_value = True - + tool_callable = server_proxy.test_tool result = tool_callable() - + assert result == {"result": "success"} mock_client._perform_call.assert_called_once_with("test_server", "test_tool", {}) def test_tool_callable_with_kwargs(self, server_proxy, mock_client): mock_client.has_tool.return_value = True - + tool_callable = server_proxy.test_tool result = tool_callable( - string_param="test", - int_param=42, - bool_param=True, - list_param=["a", "b", "c"], - dict_param={"key": "value"} + string_param="test", int_param=42, bool_param=True, list_param=["a", "b", "c"], dict_param={"key": "value"} ) - + expected_params = { "string_param": "test", "int_param": 42, "bool_param": True, "list_param": ["a", "b", "c"], - "dict_param": {"key": "value"} + "dict_param": {"key": "value"}, } - + assert result == {"result": "success"} mock_client._perform_call.assert_called_once_with("test_server", "test_tool", expected_params) def test_multiple_tool_calls(self, server_proxy, mock_client): mock_client.has_tool.return_value = True - + tool1 = server_proxy.tool1 tool2 = server_proxy.tool2 - + result1 = tool1(param="value1") result2 = tool2(param="value2") - + assert result1 == {"result": "success"} assert result2 == {"result": "success"} - + assert mock_client._perform_call.call_count == 2 mock_client._perform_call.assert_any_call("test_server", "tool1", {"param": "value1"}) mock_client._perform_call.assert_any_call("test_server", "tool2", {"param": "value2"}) def test_has_tool_called_for_each_access(self, server_proxy, mock_client): mock_client.has_tool.return_value = True - + # Access the same tool multiple times tool1 = server_proxy.test_tool tool2 = server_proxy.test_tool - + # has_tool should be called each time assert mock_client.has_tool.call_count == 2 mock_client.has_tool.assert_any_call("test_server", "test_tool") @@ -143,27 +123,20 @@ def test_has_tool_called_for_each_access(self, server_proxy, mock_client): def test_error_propagation(self, server_proxy, mock_client): mock_client.has_tool.return_value = True mock_client._perform_call.side_effect = Exception("API Error") - + tool_callable = server_proxy.test_tool - + with pytest.raises(Exception, match="API Error"): tool_callable(param="value") class TestIntegration: - @pytest.fixture - def mock_client(self): - client = Mock() - client.has_tool = Mock(return_value=True) - client._perform_call = Mock(return_value={"result": "success"}) - return client - def test_full_call_chain(self, mock_client): # Test the complete client.call.server.tool() chain caller = DynamicCaller(mock_client) - + result = caller.test_server.test_tool(param1="value1", param2="value2") - + assert result == {"result": "success"} mock_client.has_tool.assert_called_once_with("test_server", "test_tool") mock_client._perform_call.assert_called_once_with( @@ -172,35 +145,31 @@ def test_full_call_chain(self, mock_client): def test_multiple_servers_and_tools(self, mock_client): caller = DynamicCaller(mock_client) - + # Call different tools on different servers result1 = caller.server1.tool1(param="value1") result2 = caller.server2.tool2(param="value2") result3 = caller.server1.tool3(param="value3") - + assert result1 == {"result": "success"} assert result2 == {"result": "success"} assert result3 == {"result": "success"} - + # Verify all calls were made correctly - expected_calls = [ - (("server1", "tool1"), {}), - (("server2", "tool2"), {}), - (("server1", "tool3"), {}) - ] - + expected_calls = [(("server1", "tool1"), {}), (("server2", "tool2"), {}), (("server1", "tool3"), {})] + has_tool_calls = mock_client.has_tool.call_args_list perform_call_calls = mock_client._perform_call.call_args_list - + assert len(has_tool_calls) == 3 assert len(perform_call_calls) == 3 - + # Check that has_tool was called with correct arguments assert has_tool_calls[0][0] == ("server1", "tool1") assert has_tool_calls[1][0] == ("server2", "tool2") assert has_tool_calls[2][0] == ("server1", "tool3") - + # Check that _perform_call was called with correct arguments assert perform_call_calls[0][0] == ("server1", "tool1", {"param": "value1"}) assert perform_call_calls[1][0] == ("server2", "tool2", {"param": "value2"}) - assert perform_call_calls[2][0] == ("server1", "tool3", {"param": "value3"}) \ No newline at end of file + assert perform_call_calls[2][0] == ("server1", "tool3", {"param": "value3"}) diff --git a/tests/test_exceptions.py b/tests/unit/test_exceptions.py similarity index 96% rename from tests/test_exceptions.py rename to tests/unit/test_exceptions.py index 992576b..14caade 100644 --- a/tests/test_exceptions.py +++ b/tests/unit/test_exceptions.py @@ -41,13 +41,13 @@ def test_mcpd_error_catching_as_exception(self): def test_mcpd_error_chaining(self): """Test error chaining with McpdError.""" original_error = ValueError("Original error") - + try: raise original_error except ValueError as e: chained_error = McpdError("Chained error") chained_error.__cause__ = e - + assert chained_error.__cause__ is original_error assert str(chained_error) == "Chained error" @@ -56,30 +56,30 @@ def test_mcpd_error_with_format_string(self): server_name = "test_server" tool_name = "test_tool" error = McpdError(f"Error calling tool '{tool_name}' on server '{server_name}'") - + expected_message = "Error calling tool 'test_tool' on server 'test_server'" assert str(error) == expected_message def test_mcpd_error_attributes(self): """Test that McpdError has expected attributes.""" error = McpdError("Test error") - + # Should have standard Exception attributes - assert hasattr(error, 'args') + assert hasattr(error, "args") assert error.args == ("Test error",) def test_mcpd_error_repr(self): """Test string representation of McpdError.""" error = McpdError("Test error") repr_str = repr(error) - + assert "McpdError" in repr_str assert "Test error" in repr_str def test_mcpd_error_instance_check(self): """Test isinstance checks with McpdError.""" error = McpdError("Test error") - + assert isinstance(error, McpdError) assert isinstance(error, Exception) assert isinstance(error, BaseException) @@ -89,7 +89,7 @@ def test_mcpd_error_equality(self): error1 = McpdError("Same message") error2 = McpdError("Same message") error3 = McpdError("Different message") - + # Note: Exception instances are not equal even with same message # This is standard Python behavior assert error1 is not error2 @@ -99,7 +99,7 @@ def test_mcpd_error_with_complex_message(self): """Test McpdError with complex message containing various data types.""" data = {"server": "test", "tool": "example", "params": [1, 2, 3]} error = McpdError(f"Complex error with data: {data}") - + assert "Complex error with data:" in str(error) assert "test" in str(error) - assert "example" in str(error) \ No newline at end of file + assert "example" in str(error) diff --git a/tests/test_function_builder.py b/tests/unit/test_function_builder.py similarity index 80% rename from tests/test_function_builder.py rename to tests/unit/test_function_builder.py index 45a1ba4..d4157a9 100644 --- a/tests/test_function_builder.py +++ b/tests/unit/test_function_builder.py @@ -1,5 +1,4 @@ from types import FunctionType -from unittest.mock import Mock, patch import pytest @@ -8,12 +7,6 @@ class TestFunctionBuilder: - @pytest.fixture - def mock_client(self): - client = Mock() - client._perform_call = Mock(return_value={"result": "success"}) - return client - @pytest.fixture def function_builder(self, mock_client): return FunctionBuilder(mock_client) @@ -48,14 +41,14 @@ def test_create_function_from_schema_basic(self, function_builder): "type": "object", "properties": { "param1": {"type": "string", "description": "First parameter"}, - "param2": {"type": "integer", "description": "Second parameter"} + "param2": {"type": "integer", "description": "Second parameter"}, }, - "required": ["param1"] - } + "required": ["param1"], + }, } - + func = function_builder.create_function_from_schema(schema, "test_server") - + assert isinstance(func, FunctionType) assert func.__name__ == "test_server__test_tool" assert "A test tool" in func.__doc__ @@ -68,16 +61,14 @@ def test_create_function_from_schema_execution(self, function_builder): "description": "A test tool", "inputSchema": { "type": "object", - "properties": { - "param1": {"type": "string", "description": "First parameter"} - }, - "required": ["param1"] - } + "properties": {"param1": {"type": "string", "description": "First parameter"}}, + "required": ["param1"], + }, } - + func = function_builder.create_function_from_schema(schema, "test_server") result = func(param1="test_value") - + assert result == {"result": "success"} function_builder.client._perform_call.assert_called_once_with( "test_server", "test_tool", {"param1": "test_value"} @@ -89,15 +80,13 @@ def test_create_function_from_schema_missing_required(self, function_builder): "description": "A test tool", "inputSchema": { "type": "object", - "properties": { - "param1": {"type": "string", "description": "First parameter"} - }, - "required": ["param1"] - } + "properties": {"param1": {"type": "string", "description": "First parameter"}}, + "required": ["param1"], + }, } - + func = function_builder.create_function_from_schema(schema, "test_server") - + with pytest.raises(McpdError, match="Missing required parameters"): func(param1=None) @@ -109,20 +98,18 @@ def test_create_function_from_schema_optional_params(self, function_builder): "type": "object", "properties": { "param1": {"type": "string", "description": "Required parameter"}, - "param2": {"type": "string", "description": "Optional parameter"} + "param2": {"type": "string", "description": "Optional parameter"}, }, - "required": ["param1"] - } + "required": ["param1"], + }, } - + func = function_builder.create_function_from_schema(schema, "test_server") - + # Test with only required param result = func(param1="test") - function_builder.client._perform_call.assert_called_with( - "test_server", "test_tool", {"param1": "test"} - ) - + function_builder.client._perform_call.assert_called_with("test_server", "test_tool", {"param1": "test"}) + # Test with optional param result = func(param1="test", param2="optional") function_builder.client._perform_call.assert_called_with( @@ -133,34 +120,24 @@ def test_create_function_from_schema_no_params(self, function_builder): schema = { "name": "test_tool", "description": "A test tool with no parameters", - "inputSchema": { - "type": "object", - "properties": {}, - "required": [] - } + "inputSchema": {"type": "object", "properties": {}, "required": []}, } - + func = function_builder.create_function_from_schema(schema, "test_server") result = func() - - function_builder.client._perform_call.assert_called_once_with( - "test_server", "test_tool", {} - ) + + function_builder.client._perform_call.assert_called_once_with("test_server", "test_tool", {}) def test_create_function_from_schema_caching(self, function_builder): schema = { "name": "test_tool", "description": "A test tool", - "inputSchema": { - "type": "object", - "properties": {"param1": {"type": "string"}}, - "required": ["param1"] - } + "inputSchema": {"type": "object", "properties": {"param1": {"type": "string"}}, "required": ["param1"]}, } - + func1 = function_builder.create_function_from_schema(schema, "test_server") func2 = function_builder.create_function_from_schema(schema, "test_server") - + # Should be different instances but same functionality assert func1 is not func2 assert func1.__name__ == func2.__name__ @@ -172,14 +149,14 @@ def test_create_annotations_basic_types(self, function_builder): "properties": { "str_param": {"type": "string"}, "int_param": {"type": "integer"}, - "bool_param": {"type": "boolean"} + "bool_param": {"type": "boolean"}, }, - "required": ["str_param"] + "required": ["str_param"], } } - + annotations = function_builder._create_annotations(schema) - + assert annotations["str_param"] == str assert annotations["int_param"] == (int | None) assert annotations["bool_param"] == (bool | None) @@ -191,14 +168,14 @@ def test_create_docstring_with_params(self, function_builder): "type": "object", "properties": { "param1": {"type": "string", "description": "First parameter"}, - "param2": {"type": "integer", "description": "Second parameter"} + "param2": {"type": "integer", "description": "Second parameter"}, }, - "required": ["param1"] - } + "required": ["param1"], + }, } - + docstring = function_builder._create_docstring(schema) - + assert "Test function description" in docstring assert "Args:" in docstring assert "param1: First parameter" in docstring @@ -209,22 +186,18 @@ def test_create_docstring_with_params(self, function_builder): def test_create_docstring_no_params(self, function_builder): schema = { "description": "Test function description", - "inputSchema": { - "type": "object", - "properties": {}, - "required": [] - } + "inputSchema": {"type": "object", "properties": {}, "required": []}, } - + docstring = function_builder._create_docstring(schema) - + assert "Test function description" in docstring assert "Args:" not in docstring assert "Returns:" in docstring def test_create_namespace(self, function_builder): namespace = function_builder._create_namespace() - + assert "McpdError" in namespace assert "client" in namespace assert namespace["client"] is function_builder.client @@ -235,9 +208,9 @@ def test_create_namespace(self, function_builder): def test_clear_cache(self, function_builder): # Add something to cache function_builder._function_cache["test"] = {"data": "test"} - + function_builder.clear_cache() - + assert len(function_builder._function_cache) == 0 def test_build_function_code_structure(self, function_builder): @@ -248,14 +221,14 @@ def test_build_function_code_structure(self, function_builder): "type": "object", "properties": { "param1": {"type": "string", "description": "Required parameter"}, - "param2": {"type": "string", "description": "Optional parameter"} + "param2": {"type": "string", "description": "Optional parameter"}, }, - "required": ["param1"] - } + "required": ["param1"], + }, } - + code = function_builder._build_function_code(schema, "test_server") - + assert "def test_server__test_tool(param1, param2=None):" in code assert "required_params = ['param1']" in code assert 'client._perform_call("test_server", "test_tool", params)' in code @@ -266,13 +239,9 @@ def test_create_function_compilation_error(self, function_builder): schema = { "name": "test-tool", # This should be sanitized "description": "Test tool", - "inputSchema": { - "type": "object", - "properties": {}, - "required": [] - } + "inputSchema": {"type": "object", "properties": {}, "required": []}, } - + # Should not raise an error due to name sanitization func = function_builder.create_function_from_schema(schema, "test-server") - assert func.__name__ == "test_server__test_tool" \ No newline at end of file + assert func.__name__ == "test_server__test_tool" diff --git a/tests/test_mcpd_client.py b/tests/unit/test_mcpd_client.py similarity index 64% rename from tests/test_mcpd_client.py rename to tests/unit/test_mcpd_client.py index 29b6c2b..d16b00d 100644 --- a/tests/test_mcpd_client.py +++ b/tests/unit/test_mcpd_client.py @@ -1,56 +1,52 @@ -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import Mock, patch import pytest -import requests +from requests import Session +from requests.exceptions import RequestException from mcpd_sdk import McpdClient, McpdError class TestMcpdClient: - @pytest.fixture - def client(self): - return McpdClient(api_endpoint="http://localhost:8090") - - @pytest.fixture - def client_with_auth(self): - return McpdClient(api_endpoint="http://localhost:8090", api_key="test-key") - - def test_init_basic(self, client): - assert client.endpoint == "http://localhost:8090" + def test_init_basic(self): + client = McpdClient(api_endpoint="http://localhost:9999") + assert client.endpoint == "http://localhost:9999" assert client.api_key is None - assert hasattr(client, 'session') - assert hasattr(client, 'call') + assert hasattr(client, "session") + assert hasattr(client, "call") - def test_init_with_auth(self, client_with_auth): - assert client_with_auth.endpoint == "http://localhost:8090" - assert client_with_auth.api_key == "test-key" - assert "Authorization" in client_with_auth.session.headers - assert client_with_auth.session.headers["Authorization"] == "Bearer test-key" + def test_init_with_auth(self): + client = McpdClient(api_endpoint="http://localhost:9090", api_key="test-key123") + assert client.endpoint == "http://localhost:9090" + assert client.api_key == "test-key123" # pragma: allowlist secret + assert "Authorization" in client.session.headers + assert client.session.headers["Authorization"] == "Bearer test-key123" def test_init_strips_trailing_slash(self): client = McpdClient("http://localhost:8090/") assert client.endpoint == "http://localhost:8090" - @patch('requests.Session.get') - def test_servers_success(self, mock_get, client): + @patch.object(Session, "get") + def test_servers_success(self, mock_get, client, api_url): + servers = ["server1", "server2"] mock_response = Mock() - mock_response.json.return_value = ["server1", "server2"] + mock_response.json.return_value = servers mock_response.raise_for_status.return_value = None mock_get.return_value = mock_response result = client.servers() - assert result == ["server1", "server2"] - mock_get.assert_called_once_with("http://localhost:8090/api/v1/servers", timeout=5) + assert result == servers + mock_get.assert_called_once_with(f"{api_url}/servers", timeout=5) - @patch('requests.Session.get') + @patch.object(Session, "get") def test_servers_request_error(self, mock_get, client): - mock_get.side_effect = requests.exceptions.RequestException("Connection failed") + mock_get.side_effect = RequestException("Connection failed") with pytest.raises(McpdError, match="Error listing servers"): client.servers() - @patch('requests.Session.get') + @patch.object(Session, "get") def test_tools_single_server(self, mock_get, client): mock_response = Mock() mock_response.json.return_value = {"tools": [{"name": "tool1"}, {"name": "tool2"}]} @@ -62,36 +58,33 @@ def test_tools_single_server(self, mock_get, client): assert result == [{"name": "tool1"}, {"name": "tool2"}] mock_get.assert_called_once_with("http://localhost:8090/api/v1/servers/test_server/tools", timeout=5) - @patch('requests.Session.get') + @patch.object(Session, "get") def test_tools_all_servers(self, mock_get, client): # Mock servers() call servers_response = Mock() servers_response.json.return_value = ["server1", "server2"] servers_response.raise_for_status.return_value = None - + # Mock tools calls for each server tools_response = Mock() tools_response.json.return_value = {"tools": [{"name": "tool1"}]} tools_response.raise_for_status.return_value = None - + mock_get.side_effect = [servers_response, tools_response, tools_response] result = client.tools() - assert result == { - "server1": [{"name": "tool1"}], - "server2": [{"name": "tool1"}] - } + assert result == {"server1": [{"name": "tool1"}], "server2": [{"name": "tool1"}]} assert mock_get.call_count == 3 - @patch('requests.Session.get') + @patch.object(Session, "get") def test_tools_request_error(self, mock_get, client): - mock_get.side_effect = requests.exceptions.RequestException("Connection failed") + mock_get.side_effect = RequestException("Connection failed") with pytest.raises(McpdError, match="Error listing tool definitions"): client.tools("test_server") - @patch('requests.Session.post') + @patch.object(Session, "post") def test_perform_call_success(self, mock_post, client): mock_response = Mock() mock_response.json.return_value = {"result": "success"} @@ -102,61 +95,59 @@ def test_perform_call_success(self, mock_post, client): assert result == {"result": "success"} mock_post.assert_called_once_with( - "http://localhost:8090/api/v1/servers/test_server/tools/test_tool", - json={"param": "value"}, - timeout=30 + "http://localhost:8090/api/v1/servers/test_server/tools/test_tool", json={"param": "value"}, timeout=30 ) - @patch('requests.Session.post') + @patch.object(Session, "post") def test_perform_call_request_error(self, mock_post, client): - mock_post.side_effect = requests.exceptions.RequestException("Connection failed") + mock_post.side_effect = RequestException("Connection failed") with pytest.raises(McpdError, match="Error calling tool 'test_tool' on server 'test_server'"): client._perform_call("test_server", "test_tool", {"param": "value"}) - @patch.object(McpdClient, 'tools') + @patch.object(McpdClient, "tools") def test_agent_tools(self, mock_tools, client): mock_tools.return_value = { "server1": [{"name": "tool1", "description": "Test tool"}], - "server2": [{"name": "tool2", "description": "Another tool"}] + "server2": [{"name": "tool2", "description": "Another tool"}], } - - with patch.object(client._function_builder, 'create_function_from_schema') as mock_create: + + with patch.object(client._function_builder, "create_function_from_schema") as mock_create: mock_func1 = Mock() mock_func2 = Mock() mock_create.side_effect = [mock_func1, mock_func2] - + result = client.agent_tools() - + assert result == [mock_func1, mock_func2] assert mock_create.call_count == 2 - @patch.object(McpdClient, 'tools') + @patch.object(McpdClient, "tools") def test_has_tool_exists(self, mock_tools, client): mock_tools.return_value = [{"name": "existing_tool"}, {"name": "another_tool"}] - + result = client.has_tool("test_server", "existing_tool") - + assert result is True mock_tools.assert_called_once_with(server_name="test_server") - @patch.object(McpdClient, 'tools') + @patch.object(McpdClient, "tools") def test_has_tool_not_exists(self, mock_tools, client): mock_tools.return_value = [{"name": "existing_tool"}, {"name": "another_tool"}] - + result = client.has_tool("test_server", "nonexistent_tool") - + assert result is False - @patch.object(McpdClient, 'tools') + @patch.object(McpdClient, "tools") def test_has_tool_server_error(self, mock_tools, client): mock_tools.side_effect = McpdError("Server error") - + result = client.has_tool("test_server", "any_tool") - + assert result is False def test_clear_agent_tools_cache(self, client): - with patch.object(client._function_builder, 'clear_cache') as mock_clear: + with patch.object(client._function_builder, "clear_cache") as mock_clear: client.clear_agent_tools_cache() - mock_clear.assert_called_once() \ No newline at end of file + mock_clear.assert_called_once() diff --git a/tests/test_type_converter.py b/tests/unit/test_type_converter.py similarity index 67% rename from tests/test_type_converter.py rename to tests/unit/test_type_converter.py index 0944ff3..5b05950 100644 --- a/tests/test_type_converter.py +++ b/tests/unit/test_type_converter.py @@ -1,12 +1,9 @@ -from typing import Any, Literal, Union - -import pytest +from typing import Any, Literal from mcpd_sdk.type_converter import TypeConverter class TestTypeConverter: - def test_json_type_to_python_type_string(self): result = TypeConverter.json_type_to_python_type("string", {}) assert result == str @@ -14,17 +11,17 @@ def test_json_type_to_python_type_string(self): def test_json_type_to_python_type_string_with_enum(self): schema = {"enum": ["option1", "option2", "option3"]} result = TypeConverter.json_type_to_python_type("string", schema) - + # Should be a Literal type - assert hasattr(result, '__origin__') + assert hasattr(result, "__origin__") assert result.__origin__ is Literal def test_json_type_to_python_type_string_with_single_enum(self): schema = {"enum": ["single_option"]} result = TypeConverter.json_type_to_python_type("string", schema) - + # Should handle single enum value - assert hasattr(result, '__origin__') or result == Literal["single_option"] + assert hasattr(result, "__origin__") or result == Literal["single_option"] def test_json_type_to_python_type_number(self): result = TypeConverter.json_type_to_python_type("number", {}) @@ -61,48 +58,22 @@ def test_parse_schema_type_simple_type(self): assert result == str def test_parse_schema_type_anyof_simple(self): - schema = { - "anyOf": [ - {"type": "string"}, - {"type": "integer"} - ] - } + schema = {"anyOf": [{"type": "string"}, {"type": "integer"}]} result = TypeConverter.parse_schema_type(schema) assert result == (str | int) def test_parse_schema_type_anyof_complex(self): - schema = { - "anyOf": [ - {"type": "string"}, - {"type": "integer"}, - {"type": "boolean"} - ] - } + schema = {"anyOf": [{"type": "string"}, {"type": "integer"}, {"type": "boolean"}]} result = TypeConverter.parse_schema_type(schema) assert result == (str | int | bool) def test_parse_schema_type_anyof_with_arrays(self): - schema = { - "anyOf": [ - {"type": "string"}, - {"type": "array", "items": {"type": "integer"}} - ] - } + schema = {"anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "integer"}}]} result = TypeConverter.parse_schema_type(schema) assert result == (str | list[int]) def test_parse_schema_type_nested_anyof(self): - schema = { - "anyOf": [ - {"type": "string"}, - { - "anyOf": [ - {"type": "integer"}, - {"type": "boolean"} - ] - } - ] - } + schema = {"anyOf": [{"type": "string"}, {"anyOf": [{"type": "integer"}, {"type": "boolean"}]}]} result = TypeConverter.parse_schema_type(schema) # Should handle nested anyOf assert result == (str | (int | bool)) @@ -118,88 +89,51 @@ def test_parse_schema_type_empty_schema(self): assert result == Any def test_parse_schema_type_array_with_complex_items(self): - schema = { - "type": "array", - "items": { - "type": "object" - } - } + schema = {"type": "array", "items": {"type": "object"}} result = TypeConverter.parse_schema_type(schema) assert result == list[dict[str, Any]] def test_parse_schema_type_enum_with_mixed_types(self): # Test enum with different value types - schema = { - "type": "string", - "enum": ["string_val", "another_string"] - } + schema = {"type": "string", "enum": ["string_val", "another_string"]} result = TypeConverter.parse_schema_type(schema) - + # Should be a Literal type - assert hasattr(result, '__origin__') + assert hasattr(result, "__origin__") assert result.__origin__ is Literal def test_complex_nested_schema(self): schema = { "type": "object", "properties": { - "nested_array": { - "type": "array", - "items": { - "anyOf": [ - {"type": "string"}, - {"type": "integer"} - ] - } - } - } + "nested_array": {"type": "array", "items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}} + }, } # This tests the overall parsing, not specific property parsing result = TypeConverter.parse_schema_type(schema) assert result == dict[str, Any] def test_array_with_anyof_items(self): - schema = { - "type": "array", - "items": { - "anyOf": [ - {"type": "string"}, - {"type": "integer"} - ] - } - } + schema = {"type": "array", "items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}} result = TypeConverter.parse_schema_type(schema) assert result == list[str | int] def test_enum_fallback_handling(self): # Test the fallback mechanism for complex enum handling - schema = { - "type": "string", - "enum": ["val1", "val2", "val3", "val4"] - } + schema = {"type": "string", "enum": ["val1", "val2", "val3", "val4"]} result = TypeConverter.parse_schema_type(schema) - + # Should handle the enum properly - assert hasattr(result, '__origin__') + assert hasattr(result, "__origin__") assert result.__origin__ is Literal def test_json_type_to_python_type_array_recursive(self): - schema = { - "items": { - "type": "array", - "items": {"type": "string"} - } - } + schema = {"items": {"type": "array", "items": {"type": "string"}}} result = TypeConverter.json_type_to_python_type("array", schema) assert result == list[list[str]] def test_parse_schema_type_with_null_in_anyof(self): - schema = { - "anyOf": [ - {"type": "string"}, - {"type": "null"} - ] - } + schema = {"anyOf": [{"type": "string"}, {"type": "null"}]} result = TypeConverter.parse_schema_type(schema) # Should handle null type properly - assert result == (str | Any) # null maps to Any in this implementation \ No newline at end of file + assert result == (str | Any) # null maps to Any in this implementation diff --git a/uv.lock b/uv.lock index f6dc1fb..2e1fbec 100644 --- a/uv.lock +++ b/uv.lock @@ -145,8 +145,7 @@ wheels = [ [[package]] name = "mcpd-sdk" -version = "0.0.1" -source = { virtual = "." } +source = { editable = "." } dependencies = [ { name = "requests" }, ] @@ -157,12 +156,16 @@ all = [ { name = "pre-commit" }, { name = "pytest" }, { name = "ruff" }, + { name = "setuptools" }, + { name = "setuptools-scm" }, ] dev = [ { name = "debugpy" }, { name = "pre-commit" }, { name = "pytest" }, { name = "ruff" }, + { name = "setuptools" }, + { name = "setuptools-scm" }, ] lint = [ { name = "pre-commit" }, @@ -170,6 +173,8 @@ lint = [ ] tests = [ { name = "pytest" }, + { name = "setuptools" }, + { name = "setuptools-scm" }, ] [package.metadata] @@ -181,18 +186,26 @@ all = [ { name = "pre-commit", specifier = ">=4.2.0" }, { name = "pytest", specifier = ">=8.3.5" }, { name = "ruff", specifier = ">=0.11.13" }, + { name = "setuptools", specifier = ">=80.9.0" }, + { name = "setuptools-scm", extras = ["toml"], specifier = ">=8.3.1" }, ] dev = [ { name = "debugpy", specifier = ">=1.8.14" }, { name = "pre-commit", specifier = ">=4.2.0" }, { name = "pytest", specifier = ">=8.3.5" }, { name = "ruff", specifier = ">=0.11.13" }, + { name = "setuptools", specifier = ">=80.9.0" }, + { name = "setuptools-scm", extras = ["toml"], specifier = ">=8.3.1" }, ] lint = [ { name = "pre-commit", specifier = ">=4.2.0" }, { name = "ruff", specifier = ">=0.11.13" }, ] -tests = [{ name = "pytest", specifier = ">=8.3.5" }] +tests = [ + { name = "pytest", specifier = ">=8.3.5" }, + { name = "setuptools", specifier = ">=80.9.0" }, + { name = "setuptools-scm", extras = ["toml"], specifier = ">=8.3.1" }, +] [[package]] name = "nodeenv" @@ -346,6 +359,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/bf/b273dd11673fed8a6bd46032c0ea2a04b2ac9bfa9c628756a5856ba113b0/ruff-0.11.13-py3-none-win_arm64.whl", hash = "sha256:b4385285e9179d608ff1d2fb9922062663c658605819a6876d8beef0c30b7f3b", size = 10683928, upload-time = "2025-06-05T21:00:13.758Z" }, ] +[[package]] +name = "setuptools" +version = "80.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, +] + +[[package]] +name = "setuptools-scm" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/19/7ae64b70b2429c48c3a7a4ed36f50f94687d3bfcd0ae2f152367b6410dff/setuptools_scm-8.3.1.tar.gz", hash = "sha256:3d555e92b75dacd037d32bafdf94f97af51ea29ae8c7b234cf94b7a5bd242a63", size = 78088, upload-time = "2025-04-23T11:53:19.739Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/ac/8f96ba9b4cfe3e4ea201f23f4f97165862395e9331a424ed325ae37024a8/setuptools_scm-8.3.1-py3-none-any.whl", hash = "sha256:332ca0d43791b818b841213e76b1971b7711a960761c5bea5fc5cdb5196fbce3", size = 43935, upload-time = "2025-04-23T11:53:17.922Z" }, +] + [[package]] name = "urllib3" version = "2.4.0" From 87437b10c6e529786ba8d30e5216060d8fbe3b75 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Thu, 17 Jul 2025 17:39:23 +0100 Subject: [PATCH 3/3] fix: makefile typo --- Makefile | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 1fe1d61..1fd5cf3 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,4 @@ -.PHONY: setup test - -ensure-scripts-exec: +.PHONY: ensure-scripts-exec setup test ensure-scripts-exec: @chmod +x scripts/* || true