From e72241393233eea4da785e37e3ce27c65cc78a7d Mon Sep 17 00:00:00 2001 From: Razvan Radulescu <43811028+h3xxit@users.noreply.github.com> Date: Sun, 30 Nov 2025 23:43:13 +0100 Subject: [PATCH] Update all plugins to 1.1 --- core/pyproject.toml | 2 +- .../default_variable_substitutor.py | 35 +- .../cli/pyproject.toml | 4 +- .../communication_protocols/file/README.md | 129 +++++++ .../file/pyproject.toml | 45 +++ .../file/src/utcp_file/__init__.py | 17 + .../file/src/utcp_file/file_call_template.py | 66 ++++ .../utcp_file/file_communication_protocol.py | 140 ++++++++ .../tests/test_file_communication_protocol.py | 286 ++++++++++++++++ .../gql/pyproject.toml | 4 +- .../http/pyproject.toml | 4 +- .../mcp/pyproject.toml | 4 +- .../socket/pyproject.toml | 4 +- .../communication_protocols/text/README.md | 148 ++++---- .../text/pyproject.toml | 9 +- .../text/src/utcp_text/text_call_template.py | 27 +- .../utcp_text/text_communication_protocol.py | 79 ++--- .../tests/test_text_communication_protocol.py | 317 ++++++------------ .../websocket/pyproject.toml | 4 +- 19 files changed, 948 insertions(+), 376 deletions(-) create mode 100644 plugins/communication_protocols/file/README.md create mode 100644 plugins/communication_protocols/file/pyproject.toml create mode 100644 plugins/communication_protocols/file/src/utcp_file/__init__.py create mode 100644 plugins/communication_protocols/file/src/utcp_file/file_call_template.py create mode 100644 plugins/communication_protocols/file/src/utcp_file/file_communication_protocol.py create mode 100644 plugins/communication_protocols/file/tests/test_file_communication_protocol.py diff --git a/core/pyproject.toml b/core/pyproject.toml index 2844db0..e9214da 100644 --- a/core/pyproject.toml +++ b/core/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "utcp" -version = "1.1.0" +version = "1.1.1" authors = [ { name = "UTCP Contributors" }, ] diff --git a/core/src/utcp/implementations/default_variable_substitutor.py b/core/src/utcp/implementations/default_variable_substitutor.py index cb33fe4..ccc6dfb 100644 --- a/core/src/utcp/implementations/default_variable_substitutor.py +++ b/core/src/utcp/implementations/default_variable_substitutor.py @@ -67,6 +67,10 @@ def substitute(self, obj: dict | list | str, config: UtcpClientConfig, variable_ Non-string types are returned unchanged. String values are scanned for variable references using ${VAR} and $VAR syntax. + Note: + Strings containing '$ref' are skipped to support OpenAPI specs + stored as string content, where $ref is a JSON reference keyword. + Args: obj: Object to perform substitution on. Can be any type. config: UTCP client configuration containing variable sources. @@ -95,18 +99,22 @@ def substitute(self, obj: dict | list | str, config: UtcpClientConfig, variable_ if variable_namespace and not all(c.isalnum() or c == '_' for c in variable_namespace): raise ValueError(f"Variable namespace '{variable_namespace}' contains invalid characters. Only alphanumeric characters and underscores are allowed.") - if isinstance(obj, dict): - return {k: self.substitute(v, config, variable_namespace) for k, v in obj.items()} - elif isinstance(obj, list): - return [self.substitute(elem, config, variable_namespace) for elem in obj] - elif isinstance(obj, str): + if isinstance(obj, str): + # Skip substitution for JSON $ref strings + if '$ref' in obj: + return obj + # Use a regular expression to find all variables in the string, supporting ${VAR} and $VAR formats def replacer(match): # The first group that is not None is the one that matched var_name = next((g for g in match.groups() if g is not None), "") return self._get_variable(var_name, config, variable_namespace) - return re.sub(r'\${(\w+)}|\$(\w+)', replacer, obj) + return re.sub(r'\${([a-zA-Z0-9_]+)}|\$([a-zA-Z0-9_]+)', replacer, obj) + elif isinstance(obj, dict): + return {k: self.substitute(v, config, variable_namespace) for k, v in obj.items()} + elif isinstance(obj, list): + return [self.substitute(elem, config, variable_namespace) for elem in obj] else: return obj @@ -118,6 +126,10 @@ def find_required_variables(self, obj: dict | list | str, variable_namespace: Op returning fully-qualified variable names with variable namespacing. Useful for validation and dependency analysis. + Note: + Strings containing '$ref' are skipped to support OpenAPI specs + stored as string content, where $ref is a JSON reference keyword. + Args: obj: Object to scan for variable references. variable_namespace: Variable namespace used for variable namespacing. @@ -127,7 +139,7 @@ def find_required_variables(self, obj: dict | list | str, variable_namespace: Op ValueError: If variable_namespace contains invalid characters. Returns: - List of fully-qualified variable names found in the object. + List of unique fully-qualified variable names found in the object. Example: ```python @@ -156,19 +168,22 @@ def find_required_variables(self, obj: dict | list | str, variable_namespace: Op result.extend(vars) return result elif isinstance(obj, str): + # Skip substitution for JSON $ref strings + if '$ref' in obj: + return [] # Find all variables in the string, supporting ${VAR} and $VAR formats variables = [] - pattern = r'\${(\w+)}|\$(\w+)' + pattern = r'\${([a-zA-Z0-9_]+)}|\$([a-zA-Z0-9_]+)' for match in re.finditer(pattern, obj): # The first group that is not None is the one that matched var_name = next(g for g in match.groups() if g is not None) if variable_namespace: - full_var_name = variable_namespace.replace("_", "!").replace("!", "__") + "_" + var_name + full_var_name = variable_namespace.replace("_", "__") + "_" + var_name else: full_var_name = var_name variables.append(full_var_name) - return variables + return list(set(variables)) else: return [] diff --git a/plugins/communication_protocols/cli/pyproject.toml b/plugins/communication_protocols/cli/pyproject.toml index a13fd96..70d2808 100644 --- a/plugins/communication_protocols/cli/pyproject.toml +++ b/plugins/communication_protocols/cli/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "utcp-cli" -version = "1.1.0" +version = "1.1.1" authors = [ { name = "UTCP Contributors" }, ] @@ -14,7 +14,7 @@ requires-python = ">=3.10" dependencies = [ "pydantic>=2.0", "pyyaml>=6.0", - "utcp>=1.0" + "utcp>=1.1" ] classifiers = [ "Development Status :: 4 - Beta", diff --git a/plugins/communication_protocols/file/README.md b/plugins/communication_protocols/file/README.md new file mode 100644 index 0000000..4e85f5a --- /dev/null +++ b/plugins/communication_protocols/file/README.md @@ -0,0 +1,129 @@ +# UTCP File Plugin + +[![PyPI Downloads](https://static.pepy.tech/badge/utcp-file)](https://pepy.tech/projects/utcp-file) + +A file-based resource plugin for UTCP. This plugin allows you to define tools that return the content of a specified local file. + +## Features + +- **Local File Content**: Define tools that read and return the content of local files. +- **UTCP Manual Discovery**: Load tool definitions from local UTCP manual files in JSON or YAML format. +- **OpenAPI Support**: Automatically converts local OpenAPI specs to UTCP tools with optional authentication. +- **Static & Simple**: Ideal for returning mock data, configuration, or any static text content from a file. +- **Version Control**: Tool definitions and their corresponding content files can be versioned with your code. +- **No File Authentication**: Designed for simple, local file access without authentication for file reading. +- **Tool Authentication**: Supports authentication for generated tools from OpenAPI specs via `auth_tools`. + +## Installation + +```bash +pip install utcp-file +``` + +## How It Works + +The File plugin operates in two main ways: + +1. **Tool Discovery (`register_manual`)**: It can read a standard UTCP manual file (e.g., `my-tools.json`) to learn about available tools. This is how the `UtcpClient` discovers what tools can be called. +2. **Tool Execution (`call_tool`)**: When you call a tool, the plugin looks at the `tool_call_template` associated with that tool. It expects a `file` template, and it will read and return the entire content of the `file_path` specified in that template. + +**Important**: The `call_tool` function **does not** use the arguments you pass to it. It simply returns the full content of the file defined in the tool's template. + +## Quick Start + +Here is a complete example demonstrating how to define and use a tool that returns the content of a file. + +### 1. Create a Content File + +First, create a file with some content that you want your tool to return. + +`./mock_data/user.json`: +```json +{ + "id": 123, + "name": "John Doe", + "email": "john.doe@example.com" +} +``` + +### 2. Create a UTCP Manual + +Next, define a UTCP manual that describes your tool. The `tool_call_template` must be of type `file` and point to the content file you just created. + +`./manuals/local_tools.json`: +```json +{ + "manual_version": "1.0.0", + "utcp_version": "1.0.2", + "tools": [ + { + "name": "get_mock_user", + "description": "Returns a mock user profile from a local file.", + "tool_call_template": { + "call_template_type": "file", + "file_path": "./mock_data/user.json" + } + } + ] +} +``` + +### 3. Use the Tool in Python + +Finally, use the `UtcpClient` to load the manual and call the tool. + +```python +import asyncio +from utcp.utcp_client import UtcpClient + +async def main(): + # Create a client, providing the path to the manual. + # The file plugin is used automatically for the "file" call_template_type. + client = await UtcpClient.create(config={ + "manual_call_templates": [{ + "name": "local_file_tools", + "call_template_type": "file", + "file_path": "./manuals/local_tools.json" + }] + }) + + # List the tools to confirm it was loaded + tools = await client.list_tools() + print("Available tools:", [tool.name for tool in tools]) + + # Call the tool. The result will be the content of './mock_data/user.json' + result = await client.call_tool("local_file_tools.get_mock_user", {}) + + print("\nTool Result:") + print(result) + +if __name__ == "__main__": + asyncio.run(main()) +``` + +### Expected Output: + +``` +Available tools: ['local_file_tools.get_mock_user'] + +Tool Result: +{ + "id": 123, + "name": "John Doe", + "email": "john.doe@example.com" +} +``` + +## Use Cases + +- **Mocking**: Return mock data for tests or local development without needing a live server. +- **Configuration**: Load static configuration files as tool outputs. +- **Templates**: Retrieve text templates (e.g., for emails or reports). + +## Related Documentation + +- [Main UTCP Documentation](../../../README.md) +- [Core Package Documentation](../../../core/README.md) +- [HTTP Plugin](../http/README.md) - For calling real web APIs. +- [Text Plugin](../text/README.md) - For direct text content (browser-compatible). +- [CLI Plugin](../cli/README.md) - For executing command-line tools. diff --git a/plugins/communication_protocols/file/pyproject.toml b/plugins/communication_protocols/file/pyproject.toml new file mode 100644 index 0000000..3551f74 --- /dev/null +++ b/plugins/communication_protocols/file/pyproject.toml @@ -0,0 +1,45 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "utcp-file" +version = "1.1.0" +authors = [ + { name = "UTCP Contributors" }, +] +description = "UTCP communication protocol plugin for reading local files." +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "pydantic>=2.0", + "pyyaml>=6.0", + "utcp>=1.1", + "utcp-http>=1.1", + "aiofiles>=23.2.1" +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Operating System :: OS Independent", +] +license = "MPL-2.0" + +[project.optional-dependencies] +dev = [ + "build", + "pytest", + "pytest-asyncio", + "pytest-cov", + "coverage", + "twine", +] + +[project.urls] +Homepage = "https://utcp.io" +Source = "https://github.com/universal-tool-calling-protocol/python-utcp" +Issues = "https://github.com/universal-tool-calling-protocol/python-utcp/issues" + +[project.entry-points."utcp.plugins"] +file = "utcp_file:register" diff --git a/plugins/communication_protocols/file/src/utcp_file/__init__.py b/plugins/communication_protocols/file/src/utcp_file/__init__.py new file mode 100644 index 0000000..f1ac313 --- /dev/null +++ b/plugins/communication_protocols/file/src/utcp_file/__init__.py @@ -0,0 +1,17 @@ +"""File Communication Protocol plugin for UTCP.""" + +from utcp.plugins.discovery import register_communication_protocol, register_call_template +from utcp_file.file_communication_protocol import FileCommunicationProtocol +from utcp_file.file_call_template import FileCallTemplate, FileCallTemplateSerializer + + +def register(): + register_communication_protocol("file", FileCommunicationProtocol()) + register_call_template("file", FileCallTemplateSerializer()) + + +__all__ = [ + "FileCommunicationProtocol", + "FileCallTemplate", + "FileCallTemplateSerializer", +] diff --git a/plugins/communication_protocols/file/src/utcp_file/file_call_template.py b/plugins/communication_protocols/file/src/utcp_file/file_call_template.py new file mode 100644 index 0000000..e343862 --- /dev/null +++ b/plugins/communication_protocols/file/src/utcp_file/file_call_template.py @@ -0,0 +1,66 @@ +from typing import Literal, Optional, Any +from pydantic import Field, field_serializer, field_validator + +from utcp.data.call_template import CallTemplate +from utcp.data.auth import Auth, AuthSerializer +from utcp.interfaces.serializer import Serializer +from utcp.exceptions import UtcpSerializerValidationError +import traceback + + +class FileCallTemplate(CallTemplate): + """REQUIRED + Call template for file-based manuals and tools. + + Reads UTCP manuals or tool definitions from local JSON/YAML files. Useful for + static tool configurations or environments where manuals are distributed as files. + For direct text content, use the text protocol instead. + + Attributes: + call_template_type: Always "file" for file call templates. + file_path: Path to the file containing the UTCP manual or tool definitions. + auth: Always None - file call templates don't support authentication for file access. + auth_tools: Optional authentication to apply to generated tools from OpenAPI specs. + """ + + call_template_type: Literal["file"] = "file" + file_path: str = Field(..., description="The path to the file containing the UTCP manual or tool definitions.") + auth: None = None + auth_tools: Optional[Auth] = Field(None, description="Authentication to apply to generated tools from OpenAPI specs.") + + @field_serializer('auth_tools') + def serialize_auth_tools(self, auth_tools: Optional[Auth]) -> Optional[dict]: + """Serialize auth_tools to dictionary.""" + if auth_tools is None: + return None + return AuthSerializer().to_dict(auth_tools) + + @field_validator('auth_tools', mode='before') + @classmethod + def validate_auth_tools(cls, v: Any) -> Optional[Auth]: + """Validate and deserialize auth_tools from dictionary.""" + if v is None: + return None + if isinstance(v, Auth): + return v + if isinstance(v, dict): + return AuthSerializer().validate_dict(v) + raise ValueError(f"auth_tools must be None, Auth instance, or dict, got {type(v)}") + + +class FileCallTemplateSerializer(Serializer[FileCallTemplate]): + """REQUIRED + Serializer for FileCallTemplate.""" + + def to_dict(self, obj: FileCallTemplate) -> dict: + """REQUIRED + Convert a FileCallTemplate to a dictionary.""" + return obj.model_dump() + + def validate_dict(self, obj: dict) -> FileCallTemplate: + """REQUIRED + Validate and convert a dictionary to a FileCallTemplate.""" + try: + return FileCallTemplate.model_validate(obj) + except Exception as e: + raise UtcpSerializerValidationError("Invalid FileCallTemplate: " + traceback.format_exc()) diff --git a/plugins/communication_protocols/file/src/utcp_file/file_communication_protocol.py b/plugins/communication_protocols/file/src/utcp_file/file_communication_protocol.py new file mode 100644 index 0000000..f6ae27a --- /dev/null +++ b/plugins/communication_protocols/file/src/utcp_file/file_communication_protocol.py @@ -0,0 +1,140 @@ +""" +File communication protocol for UTCP client. + +This protocol reads UTCP manuals (or OpenAPI specs) from local files to register +tools. It does not maintain any persistent connections. +For direct text content, use the text protocol instead. +""" +import json +import yaml +import aiofiles +from pathlib import Path +from typing import Dict, Any, AsyncGenerator, TYPE_CHECKING + +from utcp.interfaces.communication_protocol import CommunicationProtocol +from utcp.data.call_template import CallTemplate +from utcp.data.utcp_manual import UtcpManual, UtcpManualSerializer +from utcp.data.register_manual_response import RegisterManualResult +from utcp_http.openapi_converter import OpenApiConverter +from utcp_file.file_call_template import FileCallTemplate +import traceback + +if TYPE_CHECKING: + from utcp.utcp_client import UtcpClient + +import logging + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s" +) + +logger = logging.getLogger(__name__) + + +class FileCommunicationProtocol(CommunicationProtocol): + """REQUIRED + Communication protocol for file-based UTCP manuals and tools.""" + + def _log_info(self, message: str) -> None: + logger.info(f"[FileCommunicationProtocol] {message}") + + def _log_error(self, message: str) -> None: + logger.error(f"[FileCommunicationProtocol Error] {message}") + + async def register_manual(self, caller: 'UtcpClient', manual_call_template: CallTemplate) -> RegisterManualResult: + """REQUIRED + Register a file manual and return its tools as a UtcpManual.""" + if not isinstance(manual_call_template, FileCallTemplate): + raise ValueError("FileCommunicationProtocol requires a FileCallTemplate") + + file_path = Path(manual_call_template.file_path) + if not file_path.is_absolute() and caller.root_dir: + file_path = Path(caller.root_dir) / file_path + + self._log_info(f"Reading manual from '{file_path}'") + + try: + if not file_path.exists(): + raise FileNotFoundError(f"Manual file not found: {file_path}") + + async with aiofiles.open(file_path, "r", encoding="utf-8") as f: + file_content = await f.read() + + # Parse based on extension + data: Any + if file_path.suffix.lower() in [".yaml", ".yml"]: + data = yaml.safe_load(file_content) + else: + data = json.loads(file_content) + + utcp_manual: UtcpManual + if isinstance(data, dict) and ("openapi" in data or "swagger" in data or "paths" in data): + self._log_info("Detected OpenAPI specification. Converting to UTCP manual.") + converter = OpenApiConverter( + data, + spec_url=file_path.as_uri(), + call_template_name=manual_call_template.name, + auth_tools=manual_call_template.auth_tools + ) + utcp_manual = converter.convert() + else: + # Try to validate as UTCP manual directly + utcp_manual = UtcpManualSerializer().validate_dict(data) + + self._log_info(f"Loaded {len(utcp_manual.tools)} tools from '{file_path}'") + return RegisterManualResult( + manual_call_template=manual_call_template, + manual=utcp_manual, + success=True, + errors=[], + ) + + except (json.JSONDecodeError, yaml.YAMLError) as e: + self._log_error(f"Failed to parse manual '{file_path}': {traceback.format_exc()}") + return RegisterManualResult( + manual_call_template=manual_call_template, + manual=UtcpManual(tools=[]), + success=False, + errors=[traceback.format_exc()], + ) + except Exception as e: + self._log_error(f"Unexpected error reading manual '{file_path}': {traceback.format_exc()}") + return RegisterManualResult( + manual_call_template=manual_call_template, + manual=UtcpManual(tools=[]), + success=False, + errors=[traceback.format_exc()], + ) + + async def deregister_manual(self, caller: 'UtcpClient', manual_call_template: CallTemplate) -> None: + """REQUIRED + Deregister a file manual (no-op).""" + if isinstance(manual_call_template, FileCallTemplate): + self._log_info(f"Deregistering file manual '{manual_call_template.name}' (no-op)") + + async def call_tool(self, caller: 'UtcpClient', tool_name: str, tool_args: Dict[str, Any], tool_call_template: CallTemplate) -> Any: + """REQUIRED + Call a tool: for file templates, return file content from the configured path.""" + if not isinstance(tool_call_template, FileCallTemplate): + raise ValueError("FileCommunicationProtocol requires a FileCallTemplate for tool calls") + + file_path = Path(tool_call_template.file_path) + if not file_path.is_absolute() and caller.root_dir: + file_path = Path(caller.root_dir) / file_path + + self._log_info(f"Reading content from '{file_path}' for tool '{tool_name}'") + + try: + async with aiofiles.open(file_path, "r", encoding="utf-8") as f: + content = await f.read() + return content + except FileNotFoundError: + self._log_error(f"File not found for tool '{tool_name}': {file_path}") + raise + + async def call_tool_streaming(self, caller: 'UtcpClient', tool_name: str, tool_args: Dict[str, Any], tool_call_template: CallTemplate) -> AsyncGenerator[Any, None]: + """REQUIRED + Streaming variant: yields the full content as a single chunk.""" + result = await self.call_tool(caller, tool_name, tool_args, tool_call_template) + yield result diff --git a/plugins/communication_protocols/file/tests/test_file_communication_protocol.py b/plugins/communication_protocols/file/tests/test_file_communication_protocol.py new file mode 100644 index 0000000..54ac213 --- /dev/null +++ b/plugins/communication_protocols/file/tests/test_file_communication_protocol.py @@ -0,0 +1,286 @@ +""" +Tests for the File communication protocol (file-based) implementation. +""" +import json +import tempfile +from pathlib import Path +import pytest +import pytest_asyncio +from unittest.mock import Mock + +from utcp_file.file_communication_protocol import FileCommunicationProtocol +from utcp_file.file_call_template import FileCallTemplate +from utcp.data.call_template import CallTemplate +from utcp.data.register_manual_response import RegisterManualResult +from utcp.data.auth_implementations.api_key_auth import ApiKeyAuth +from utcp.utcp_client import UtcpClient + + +@pytest_asyncio.fixture +async def file_protocol() -> FileCommunicationProtocol: + """Provides a FileCommunicationProtocol instance.""" + yield FileCommunicationProtocol() + + +@pytest_asyncio.fixture +def mock_utcp_client(tmp_path: Path) -> Mock: + """Provides a mock UtcpClient with a root_dir.""" + client = Mock(spec=UtcpClient) + client.root_dir = tmp_path + return client + + +@pytest_asyncio.fixture +def sample_utcp_manual(): + """Sample UTCP manual with multiple tools.""" + return { + "utcp_version": "1.0.0", + "manual_version": "1.0.0", + "tools": [ + { + "name": "calculator", + "description": "Performs basic arithmetic operations", + "inputs": { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["add", "subtract", "multiply", "divide"] + }, + "a": {"type": "number"}, + "b": {"type": "number"} + }, + "required": ["operation", "a", "b"] + }, + "outputs": { + "type": "object", + "properties": { + "result": {"type": "number"} + } + }, + "tags": ["math", "arithmetic"], + "tool_call_template": { + "call_template_type": "file", + "name": "test-file-call-template", + "file_path": "dummy.json" + } + }, + { + "name": "string_utils", + "description": "String manipulation utilities", + "inputs": { + "type": "object", + "properties": { + "text": {"type": "string"}, + "operation": { + "type": "string", + "enum": ["uppercase", "lowercase", "reverse"] + } + }, + "required": ["text", "operation"] + }, + "outputs": { + "type": "object", + "properties": { + "result": {"type": "string"} + } + }, + "tags": ["text", "utilities"], + "tool_call_template": { + "call_template_type": "file", + "name": "test-file-call-template", + "file_path": "dummy.json" + } + } + ] + } + + +@pytest.mark.asyncio +async def test_register_manual_with_utcp_manual( + file_protocol: FileCommunicationProtocol, sample_utcp_manual, mock_utcp_client: Mock +): + """Register a manual from a local file and validate returned tools.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(sample_utcp_manual, f) + temp_file = f.name + + try: + manual_template = FileCallTemplate(name="test_manual", file_path=temp_file) + result = await file_protocol.register_manual(mock_utcp_client, manual_template) + + assert isinstance(result, RegisterManualResult) + assert result.success is True + assert result.errors == [] + assert result.manual is not None + assert len(result.manual.tools) == 2 + + tool0 = result.manual.tools[0] + assert tool0.name == "calculator" + assert tool0.description == "Performs basic arithmetic operations" + assert tool0.tags == ["math", "arithmetic"] + assert tool0.tool_call_template.call_template_type == "file" + + tool1 = result.manual.tools[1] + assert tool1.name == "string_utils" + assert tool1.description == "String manipulation utilities" + assert tool1.tags == ["text", "utilities"] + assert tool1.tool_call_template.call_template_type == "file" + finally: + Path(temp_file).unlink() + + +@pytest.mark.asyncio +async def test_register_manual_file_not_found( + file_protocol: FileCommunicationProtocol, mock_utcp_client: Mock +): + """Registering a manual with a non-existent file should return errors.""" + manual_template = FileCallTemplate(name="missing", file_path="/path/that/does/not/exist.json") + result = await file_protocol.register_manual(mock_utcp_client, manual_template) + assert isinstance(result, RegisterManualResult) + assert result.success is False + assert result.errors + + +@pytest.mark.asyncio +async def test_register_manual_invalid_json( + file_protocol: FileCommunicationProtocol, mock_utcp_client: Mock +): + """Registering a manual with invalid JSON should return errors (no exception).""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + f.write("{ invalid json content }") + temp_file = f.name + + try: + manual_template = FileCallTemplate(name="invalid_json", file_path=temp_file) + result = await file_protocol.register_manual(mock_utcp_client, manual_template) + assert isinstance(result, RegisterManualResult) + assert result.success is False + assert result.errors + finally: + Path(temp_file).unlink() + + +@pytest.mark.asyncio +async def test_register_manual_wrong_call_template_type(file_protocol: FileCommunicationProtocol, mock_utcp_client: Mock): + """Registering with a non-File call template should raise ValueError.""" + wrong_template = CallTemplate(call_template_type="invalid", name="wrong") + with pytest.raises(ValueError, match="requires a FileCallTemplate"): + await file_protocol.register_manual(mock_utcp_client, wrong_template) + + +@pytest.mark.asyncio +async def test_call_tool_returns_file_content( + file_protocol: FileCommunicationProtocol, sample_utcp_manual, mock_utcp_client: Mock +): + """Calling a tool returns the file content from the call template path.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(sample_utcp_manual, f) + temp_file = f.name + + try: + tool_template = FileCallTemplate(name="tool_call", file_path=temp_file) + + # Call a tool should return the file content + content = await file_protocol.call_tool( + mock_utcp_client, "calculator", {"operation": "add", "a": 1, "b": 2}, tool_template + ) + + # Verify we get the JSON content back as a string + assert isinstance(content, str) + # Parse it back to verify it's the same content + parsed_content = json.loads(content) + assert parsed_content == sample_utcp_manual + finally: + Path(temp_file).unlink() + + +@pytest.mark.asyncio +async def test_call_tool_wrong_call_template_type(file_protocol: FileCommunicationProtocol, mock_utcp_client: Mock): + """Calling a tool with wrong call template type should raise ValueError.""" + wrong_template = CallTemplate(call_template_type="invalid", name="wrong") + with pytest.raises(ValueError, match="requires a FileCallTemplate"): + await file_protocol.call_tool(mock_utcp_client, "some_tool", {}, wrong_template) + + +@pytest.mark.asyncio +async def test_call_tool_file_not_found(file_protocol: FileCommunicationProtocol, mock_utcp_client: Mock): + """Calling a tool when the file doesn't exist should raise FileNotFoundError.""" + tool_template = FileCallTemplate(name="missing", file_path="/path/that/does/not/exist.json") + with pytest.raises(FileNotFoundError): + await file_protocol.call_tool(mock_utcp_client, "some_tool", {}, tool_template) + + +@pytest.mark.asyncio +async def test_deregister_manual(file_protocol: FileCommunicationProtocol, sample_utcp_manual, mock_utcp_client: Mock): + """Deregistering a manual should be a no-op (no errors).""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(sample_utcp_manual, f) + temp_file = f.name + + try: + manual_template = FileCallTemplate(name="test_manual", file_path=temp_file) + await file_protocol.deregister_manual(mock_utcp_client, manual_template) + finally: + Path(temp_file).unlink() + + +@pytest.mark.asyncio +async def test_call_tool_streaming(file_protocol: FileCommunicationProtocol, sample_utcp_manual, mock_utcp_client: Mock): + """Streaming call should yield a single chunk equal to non-streaming content.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(sample_utcp_manual, f) + temp_file = f.name + + try: + tool_template = FileCallTemplate(name="tool_call", file_path=temp_file) + # Non-streaming + content = await file_protocol.call_tool(mock_utcp_client, "calculator", {}, tool_template) + # Streaming + stream = file_protocol.call_tool_streaming(mock_utcp_client, "calculator", {}, tool_template) + chunks = [c async for c in stream] + assert chunks == [content] + finally: + Path(temp_file).unlink() + + +@pytest.mark.asyncio +async def test_file_call_template_with_auth_tools(): + """Test that FileCallTemplate can be created with auth_tools.""" + auth_tools = ApiKeyAuth(api_key="test-key", var_name="Authorization", location="header") + + template = FileCallTemplate( + name="test-template", + file_path="test.json", + auth_tools=auth_tools + ) + + assert template.auth_tools == auth_tools + assert template.auth is None # auth should still be None for file access + + +@pytest.mark.asyncio +async def test_file_call_template_auth_tools_serialization(): + """Test that auth_tools field properly serializes and validates from dict.""" + # Test creation from dict + template_dict = { + "name": "test-template", + "call_template_type": "file", + "file_path": "test.json", + "auth_tools": { + "auth_type": "api_key", + "api_key": "test-key", + "var_name": "Authorization", + "location": "header" + } + } + + template = FileCallTemplate(**template_dict) + assert template.auth_tools is not None + assert template.auth_tools.api_key == "test-key" + assert template.auth_tools.var_name == "Authorization" + + # Test serialization to dict + serialized = template.model_dump() + assert serialized["auth_tools"]["auth_type"] == "api_key" + assert serialized["auth_tools"]["api_key"] == "test-key" diff --git a/plugins/communication_protocols/gql/pyproject.toml b/plugins/communication_protocols/gql/pyproject.toml index d5b558d..4377268 100644 --- a/plugins/communication_protocols/gql/pyproject.toml +++ b/plugins/communication_protocols/gql/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "utcp-gql" -version = "1.0.2" +version = "1.1.0" authors = [ { name = "UTCP Contributors" }, ] @@ -14,7 +14,7 @@ requires-python = ">=3.10" dependencies = [ "pydantic>=2.0", "gql>=3.0", - "utcp>=1.0" + "utcp>=1.1" ] classifiers = [ "Development Status :: 4 - Beta", diff --git a/plugins/communication_protocols/http/pyproject.toml b/plugins/communication_protocols/http/pyproject.toml index 42f0951..ae759ed 100644 --- a/plugins/communication_protocols/http/pyproject.toml +++ b/plugins/communication_protocols/http/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "utcp-http" -version = "1.0.5" +version = "1.1.0" authors = [ { name = "UTCP Contributors" }, ] @@ -16,7 +16,7 @@ dependencies = [ "authlib>=1.0", "aiohttp>=3.8", "pyyaml>=6.0", - "utcp>=1.0" + "utcp>=1.1" ] classifiers = [ "Development Status :: 4 - Beta", diff --git a/plugins/communication_protocols/mcp/pyproject.toml b/plugins/communication_protocols/mcp/pyproject.toml index 2efd4c3..2cfd9a2 100644 --- a/plugins/communication_protocols/mcp/pyproject.toml +++ b/plugins/communication_protocols/mcp/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "utcp-mcp" -version = "1.0.2" +version = "1.1.0" authors = [ { name = "UTCP Contributors" }, ] @@ -14,7 +14,7 @@ requires-python = ">=3.11" dependencies = [ "pydantic>=2.0", "mcp>=1.12", - "utcp>=1.0", + "utcp>=1.1", "mcp-use>=1.3", "langchain>=0.3.27,<0.4.0", ] diff --git a/plugins/communication_protocols/socket/pyproject.toml b/plugins/communication_protocols/socket/pyproject.toml index a544648..dbbc1b0 100644 --- a/plugins/communication_protocols/socket/pyproject.toml +++ b/plugins/communication_protocols/socket/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "utcp-socket" -version = "1.0.2" +version = "1.1.0" authors = [ { name = "UTCP Contributors" }, ] @@ -13,7 +13,7 @@ readme = "README.md" requires-python = ">=3.10" dependencies = [ "pydantic>=2.0", - "utcp>=1.0" + "utcp>=1.1" ] classifiers = [ "Development Status :: 4 - Beta", diff --git a/plugins/communication_protocols/text/README.md b/plugins/communication_protocols/text/README.md index 27f8525..ea875d0 100644 --- a/plugins/communication_protocols/text/README.md +++ b/plugins/communication_protocols/text/README.md @@ -2,16 +2,15 @@ [![PyPI Downloads](https://static.pepy.tech/badge/utcp-text)](https://pepy.tech/projects/utcp-text) -A simple, file-based resource plugin for UTCP. This plugin allows you to define tools that return the content of a specified local file. +A text content plugin for UTCP. This plugin allows you to pass UTCP manuals or tool definitions directly as text content, without requiring file system access. It's browser-compatible and ideal for embedded configurations. ## Features -- **Local File Content**: Define tools that read and return the content of local files. -- **UTCP Manual Discovery**: Load tool definitions from local UTCP manual files in JSON or YAML format. -- **OpenAPI Support**: Automatically converts local OpenAPI specs to UTCP tools with optional authentication. -- **Static & Simple**: Ideal for returning mock data, configuration, or any static text content from a file. -- **Version Control**: Tool definitions and their corresponding content files can be versioned with your code. -- **No File Authentication**: Designed for simple, local file access without authentication for file reading. +- **Direct Text Content**: Pass UTCP manuals or tool definitions directly as strings. +- **Browser Compatible**: No file system access required, works in browser environments. +- **JSON & YAML Support**: Parses both JSON and YAML formatted content. +- **OpenAPI Support**: Automatically converts OpenAPI specs to UTCP tools with optional authentication. +- **Base URL Override**: Override API base URLs when converting OpenAPI specs. - **Tool Authentication**: Supports authentication for generated tools from OpenAPI specs via `auth_tools`. ## Installation @@ -24,66 +23,49 @@ pip install utcp-text The Text plugin operates in two main ways: -1. **Tool Discovery (`register_manual`)**: It can read a standard UTCP manual file (e.g., `my-tools.json`) to learn about available tools. This is how the `UtcpClient` discovers what tools can be called. -2. **Tool Execution (`call_tool`)**: When you call a tool, the plugin looks at the `tool_call_template` associated with that tool. It expects a `text` template, and it will read and return the entire content of the `file_path` specified in that template. +1. **Tool Discovery (`register_manual`)**: It parses the `content` field directly as a UTCP manual or OpenAPI spec. This is how the `UtcpClient` discovers what tools can be called. +2. **Tool Execution (`call_tool`)**: When you call a tool, the plugin returns the `content` field directly. -**Important**: The `call_tool` function **does not** use the arguments you pass to it. It simply returns the full content of the file defined in the tool's template. +**Note**: For file-based tool definitions, use the `utcp-file` plugin instead. ## Quick Start -Here is a complete example demonstrating how to define and use a tool that returns the content of a file. +Here is a complete example demonstrating how to define and use tools with direct text content. -### 1. Create a Content File - -First, create a file with some content that you want your tool to return. - -`./mock_data/user.json`: -```json -{ - "id": 123, - "name": "John Doe", - "email": "john.doe@example.com" -} -``` - -### 2. Create a UTCP Manual - -Next, define a UTCP manual that describes your tool. The `tool_call_template` must be of type `text` and point to the content file you just created. - -`./manuals/local_tools.json`: -```json -{ - "manual_version": "1.0.0", - "utcp_version": "1.0.2", - "tools": [ - { - "name": "get_mock_user", - "description": "Returns a mock user profile from a local file.", - "tool_call_template": { - "call_template_type": "text", - "file_path": "./mock_data/user.json" - } - } - ] -} -``` - -### 3. Use the Tool in Python - -Finally, use the `UtcpClient` to load the manual and call the tool. +### 1. Define Tools with Inline Content ```python import asyncio +import json from utcp.utcp_client import UtcpClient +# Define a UTCP manual as a Python dict, then convert to JSON string +manual_content = json.dumps({ + "manual_version": "1.0.0", + "utcp_version": "1.0.2", + "tools": [ + { + "name": "get_mock_user", + "description": "Returns a mock user profile.", + "tool_call_template": { + "call_template_type": "text", + "content": json.dumps({ + "id": 123, + "name": "John Doe", + "email": "john.doe@example.com" + }) + } + } + ] +}) + async def main(): - # Create a client, providing the path to the manual. - # The text plugin is used automatically for the "text" call_template_type. + # Create a client with direct text content client = await UtcpClient.create(config={ "manual_call_templates": [{ - "name": "local_file_tools", + "name": "inline_tools", "call_template_type": "text", - "file_path": "./manuals/local_tools.json" + "content": manual_content }] }) @@ -91,8 +73,8 @@ async def main(): tools = await client.list_tools() print("Available tools:", [tool.name for tool in tools]) - # Call the tool. The result will be the content of './mock_data/user.json' - result = await client.call_tool("local_file_tools.get_mock_user", {}) + # Call the tool + result = await client.call_tool("inline_tools.get_mock_user", {}) print("\nTool Result:") print(result) @@ -101,28 +83,58 @@ if __name__ == "__main__": asyncio.run(main()) ``` -### Expected Output: +### 2. Using with OpenAPI Specs -``` -Available tools: ['local_file_tools.get_mock_user'] - -Tool Result: -{ - "id": 123, - "name": "John Doe", - "email": "john.doe@example.com" -} +You can also pass OpenAPI specs directly as text content: + +```python +import asyncio +import json +from utcp.utcp_client import UtcpClient + +openapi_spec = json.dumps({ + "openapi": "3.0.0", + "info": {"title": "Pet Store", "version": "1.0.0"}, + "servers": [{"url": "https://api.example.com"}], + "paths": { + "/pets": { + "get": { + "operationId": "listPets", + "summary": "List all pets", + "responses": {"200": {"description": "Success"}} + } + } + } +}) + +async def main(): + client = await UtcpClient.create(config={ + "manual_call_templates": [{ + "name": "pet_api", + "call_template_type": "text", + "content": openapi_spec, + "base_url": "https://api.petstore.io/v1" # Optional: override base URL + }] + }) + + tools = await client.list_tools() + print("Available tools:", [tool.name for tool in tools]) + +if __name__ == "__main__": + asyncio.run(main()) ``` ## Use Cases -- **Mocking**: Return mock data for tests or local development without needing a live server. -- **Configuration**: Load static configuration files as tool outputs. -- **Templates**: Retrieve text templates (e.g., for emails or reports). +- **Embedded Configurations**: Embed tool definitions directly in your application code. +- **Browser Applications**: Use UTCP in browser environments without file system access. +- **Dynamic Tool Generation**: Generate tool definitions programmatically at runtime. +- **Testing**: Define mock tools inline for unit tests. ## Related Documentation - [Main UTCP Documentation](../../../README.md) - [Core Package Documentation](../../../core/README.md) +- [File Plugin](../file/README.md) - For file-based tool definitions. - [HTTP Plugin](../http/README.md) - For calling real web APIs. - [CLI Plugin](../cli/README.md) - For executing command-line tools. diff --git a/plugins/communication_protocols/text/pyproject.toml b/plugins/communication_protocols/text/pyproject.toml index c624e8c..b6bcfce 100644 --- a/plugins/communication_protocols/text/pyproject.toml +++ b/plugins/communication_protocols/text/pyproject.toml @@ -4,19 +4,18 @@ build-backend = "setuptools.build_meta" [project] name = "utcp-text" -version = "1.0.3" +version = "1.1.0" authors = [ { name = "UTCP Contributors" }, ] -description = "UTCP communication protocol plugin for reading text files." +description = "UTCP communication protocol plugin for direct text content (browser-compatible)." readme = "README.md" requires-python = ">=3.10" dependencies = [ "pydantic>=2.0", "pyyaml>=6.0", - "utcp>=1.0", - "utcp-http>=1.0", - "aiofiles>=23.2.1" + "utcp>=1.1", + "utcp-http>=1.1" ] classifiers = [ "Development Status :: 4 - Beta", diff --git a/plugins/communication_protocols/text/src/utcp_text/text_call_template.py b/plugins/communication_protocols/text/src/utcp_text/text_call_template.py index a090817..f5ca2c4 100644 --- a/plugins/communication_protocols/text/src/utcp_text/text_call_template.py +++ b/plugins/communication_protocols/text/src/utcp_text/text_call_template.py @@ -7,22 +7,27 @@ from utcp.exceptions import UtcpSerializerValidationError import traceback + class TextCallTemplate(CallTemplate): """REQUIRED - Call template for text file-based manuals and tools. + Text call template for UTCP client. - Reads UTCP manuals or tool definitions from local JSON/YAML files. Useful for - static tool configurations or environments where manuals are distributed as files. + This template allows passing UTCP manuals or tool definitions directly as text content. + It supports both JSON and YAML formats and can convert OpenAPI specifications to UTCP manuals. + It's browser-compatible and requires no file system access. + For file-based manuals, use the file protocol instead. Attributes: - call_template_type: Always "text" for text file call templates. - file_path: Path to the file containing the UTCP manual or tool definitions. - auth: Always None - text call templates don't support authentication for file access. + call_template_type: Always "text" for text call templates. + content: Direct text content of the UTCP manual or tool definitions (required). + base_url: Optional base URL for API endpoints when converting OpenAPI specs. + auth: Always None - text call templates don't support authentication. auth_tools: Optional authentication to apply to generated tools from OpenAPI specs. """ call_template_type: Literal["text"] = "text" - file_path: str = Field(..., description="The path to the file containing the UTCP manual or tool definitions.") + content: str = Field(..., description="Direct text content of the UTCP manual or tool definitions.") + base_url: Optional[str] = Field(None, description="Optional base URL for API endpoints when converting OpenAPI specs.") auth: None = None auth_tools: Optional[Auth] = Field(None, description="Authentication to apply to generated tools from OpenAPI specs.") @@ -58,6 +63,14 @@ def to_dict(self, obj: TextCallTemplate) -> dict: def validate_dict(self, obj: dict) -> TextCallTemplate: """REQUIRED Validate and convert a dictionary to a TextCallTemplate.""" + # Check for old file_path field and provide helpful migration message + if "file_path" in obj: + raise UtcpSerializerValidationError( + "TextCallTemplate no longer supports 'file_path'. " + "The text protocol now accepts direct content via the 'content' field. " + "For file-based manuals, use the 'file' protocol instead (call_template_type: 'file'). " + "Install with: pip install utcp-file" + ) try: return TextCallTemplate.model_validate(obj) except Exception as e: diff --git a/plugins/communication_protocols/text/src/utcp_text/text_communication_protocol.py b/plugins/communication_protocols/text/src/utcp_text/text_communication_protocol.py index cdd49ae..c979191 100644 --- a/plugins/communication_protocols/text/src/utcp_text/text_communication_protocol.py +++ b/plugins/communication_protocols/text/src/utcp_text/text_communication_protocol.py @@ -1,15 +1,13 @@ """ Text communication protocol for UTCP client. -This protocol reads UTCP manuals (or OpenAPI specs) from local files to register -tools. It does not maintain any persistent connections. +This protocol parses UTCP manuals (or OpenAPI specs) from direct text content. +It's browser-compatible and requires no file system access. +For file-based manuals, use the file protocol instead. """ import json -import sys import yaml -import aiofiles -from pathlib import Path -from typing import Dict, Any, Optional, AsyncGenerator, TYPE_CHECKING +from typing import Dict, Any, AsyncGenerator, TYPE_CHECKING from utcp.interfaces.communication_protocol import CommunicationProtocol from utcp.data.call_template import CallTemplate @@ -31,9 +29,10 @@ logger = logging.getLogger(__name__) + class TextCommunicationProtocol(CommunicationProtocol): """REQUIRED - Communication protocol for file-based UTCP manuals and tools.""" + Communication protocol for text-based UTCP manuals and tools.""" def _log_info(self, message: str) -> None: logger.info(f"[TextCommunicationProtocol] {message}") @@ -47,41 +46,37 @@ async def register_manual(self, caller: 'UtcpClient', manual_call_template: Call if not isinstance(manual_call_template, TextCallTemplate): raise ValueError("TextCommunicationProtocol requires a TextCallTemplate") - file_path = Path(manual_call_template.file_path) - if not file_path.is_absolute() and caller.root_dir: - file_path = Path(caller.root_dir) / file_path - - self._log_info(f"Reading manual from '{file_path}'") - try: - if not file_path.exists(): - raise FileNotFoundError(f"Manual file not found: {file_path}") - - async with aiofiles.open(file_path, "r", encoding="utf-8") as f: - file_content = await f.read() + self._log_info("Parsing direct content for manual") + content = manual_call_template.content - # Parse based on extension + # Try JSON first, then YAML data: Any - if file_path.suffix.lower() in [".yaml", ".yml"]: - data = yaml.safe_load(file_content) - else: - data = json.loads(file_content) + try: + data = json.loads(content) + except json.JSONDecodeError as json_error: + try: + data = yaml.safe_load(content) + except yaml.YAMLError: + raise ValueError(f"Failed to parse content as JSON or YAML: {json_error}") utcp_manual: UtcpManual if isinstance(data, dict) and ("openapi" in data or "swagger" in data or "paths" in data): self._log_info("Detected OpenAPI specification. Converting to UTCP manual.") converter = OpenApiConverter( - data, - spec_url=file_path.as_uri(), + data, + spec_url="text://content", call_template_name=manual_call_template.name, - auth_tools=manual_call_template.auth_tools + auth_tools=manual_call_template.auth_tools, + base_url=manual_call_template.base_url ) utcp_manual = converter.convert() else: # Try to validate as UTCP manual directly + self._log_info("Validating content as UTCP manual.") utcp_manual = UtcpManualSerializer().validate_dict(data) - self._log_info(f"Loaded {len(utcp_manual.tools)} tools from '{file_path}'") + self._log_info(f"Successfully registered manual with {len(utcp_manual.tools)} tools.") return RegisterManualResult( manual_call_template=manual_call_template, manual=utcp_manual, @@ -89,21 +84,14 @@ async def register_manual(self, caller: 'UtcpClient', manual_call_template: Call errors=[], ) - except (json.JSONDecodeError, yaml.YAMLError) as e: - self._log_error(f"Failed to parse manual '{file_path}': {traceback.format_exc()}") - return RegisterManualResult( - manual_call_template=manual_call_template, - manual=UtcpManual(tools=[]), - success=False, - errors=[traceback.format_exc()], - ) except Exception as e: - self._log_error(f"Unexpected error reading manual '{file_path}': {traceback.format_exc()}") + err_msg = f"Failed to register text manual: {str(e)}" + self._log_error(err_msg) return RegisterManualResult( manual_call_template=manual_call_template, manual=UtcpManual(tools=[]), success=False, - errors=[traceback.format_exc()], + errors=[err_msg], ) async def deregister_manual(self, caller: 'UtcpClient', manual_call_template: CallTemplate) -> None: @@ -114,23 +102,12 @@ async def deregister_manual(self, caller: 'UtcpClient', manual_call_template: Ca async def call_tool(self, caller: 'UtcpClient', tool_name: str, tool_args: Dict[str, Any], tool_call_template: CallTemplate) -> Any: """REQUIRED - Call a tool: for text templates, return file content from the configured path.""" + Execute a tool call. Text protocol returns the content directly.""" if not isinstance(tool_call_template, TextCallTemplate): raise ValueError("TextCommunicationProtocol requires a TextCallTemplate for tool calls") - file_path = Path(tool_call_template.file_path) - if not file_path.is_absolute() and caller.root_dir: - file_path = Path(caller.root_dir) / file_path - - self._log_info(f"Reading content from '{file_path}' for tool '{tool_name}'") - - try: - async with aiofiles.open(file_path, "r", encoding="utf-8") as f: - content = await f.read() - return content - except FileNotFoundError: - self._log_error(f"File not found for tool '{tool_name}': {file_path}") - raise + self._log_info(f"Returning direct content for tool '{tool_name}'") + return tool_call_template.content async def call_tool_streaming(self, caller: 'UtcpClient', tool_name: str, tool_args: Dict[str, Any], tool_call_template: CallTemplate) -> AsyncGenerator[Any, None]: """REQUIRED diff --git a/plugins/communication_protocols/text/tests/test_text_communication_protocol.py b/plugins/communication_protocols/text/tests/test_text_communication_protocol.py index 179b34c..f2829ef 100644 --- a/plugins/communication_protocols/text/tests/test_text_communication_protocol.py +++ b/plugins/communication_protocols/text/tests/test_text_communication_protocol.py @@ -1,9 +1,7 @@ """ -Tests for the Text communication protocol (file-based) implementation. +Tests for the Text communication protocol (direct content) implementation. """ import json -import tempfile -from pathlib import Path import pytest import pytest_asyncio from unittest.mock import Mock @@ -15,6 +13,7 @@ from utcp.data.auth_implementations.api_key_auth import ApiKeyAuth from utcp.utcp_client import UtcpClient + @pytest_asyncio.fixture async def text_protocol() -> TextCommunicationProtocol: """Provides a TextCommunicationProtocol instance.""" @@ -22,16 +21,16 @@ async def text_protocol() -> TextCommunicationProtocol: @pytest_asyncio.fixture -def mock_utcp_client(tmp_path: Path) -> Mock: - """Provides a mock UtcpClient with a root_dir.""" +def mock_utcp_client() -> Mock: + """Provides a mock UtcpClient.""" client = Mock(spec=UtcpClient) - client.root_dir = tmp_path + client.root_dir = None return client @pytest_asyncio.fixture def sample_utcp_manual(): - """Sample UTCP manual with multiple tools (new UTCP format).""" + """Sample UTCP manual with multiple tools.""" return { "utcp_version": "1.0.0", "manual_version": "1.0.0", @@ -61,7 +60,7 @@ def sample_utcp_manual(): "tool_call_template": { "call_template_type": "text", "name": "test-text-call-template", - "file_path": "dummy.json" + "content": "dummy content" } }, { @@ -88,226 +87,107 @@ def sample_utcp_manual(): "tool_call_template": { "call_template_type": "text", "name": "test-text-call-template", - "file_path": "dummy.json" + "content": "dummy content" } } ] } -@pytest_asyncio.fixture -def single_tool_definition(): - """Sample single tool definition (new UTCP format).""" - return { - "name": "echo", - "description": "Echoes back the input text", - "inputs": { - "type": "object", - "properties": { - "message": {"type": "string"} - }, - "required": ["message"] - }, - "outputs": { - "type": "object", - "properties": { - "echo": {"type": "string"} - } - }, - "tags": ["utility"], - "tool_call_template": { - "call_template_type": "text", - "name": "test-text-call-template", - "file_path": "dummy.json" - } - } - - -@pytest_asyncio.fixture -def tool_array(): - """Sample array of tool definitions (new UTCP format).""" - return [ - { - "name": "tool1", - "description": "First tool", - "inputs": {"type": "object", "properties": {}, "required": []}, - "outputs": {"type": "object", "properties": {}, "required": []}, - "tags": [], - "tool_call_template": { - "call_template_type": "text", - "name": "test-text-call-template", - "file_path": "dummy.json" - } - }, - { - "name": "tool2", - "description": "Second tool", - "inputs": {"type": "object", "properties": {}, "required": []}, - "outputs": {"type": "object", "properties": {}, "required": []}, - "tags": [], - "tool_call_template": { - "call_template_type": "text", - "name": "test-text-call-template", - "file_path": "dummy.json" - } - } - ] - - @pytest.mark.asyncio async def test_register_manual_with_utcp_manual( text_protocol: TextCommunicationProtocol, sample_utcp_manual, mock_utcp_client: Mock ): - """Register a manual from a local file and validate returned tools.""" - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - json.dump(sample_utcp_manual, f) - temp_file = f.name - - try: - manual_template = TextCallTemplate(name="test_manual", file_path=temp_file) - result = await text_protocol.register_manual(mock_utcp_client, manual_template) - - assert isinstance(result, RegisterManualResult) - assert result.success is True - assert result.errors == [] - assert result.manual is not None - assert len(result.manual.tools) == 2 - - tool0 = result.manual.tools[0] - assert tool0.name == "calculator" - assert tool0.description == "Performs basic arithmetic operations" - assert tool0.tags == ["math", "arithmetic"] - assert tool0.tool_call_template.call_template_type == "text" - - tool1 = result.manual.tools[1] - assert tool1.name == "string_utils" - assert tool1.description == "String manipulation utilities" - assert tool1.tags == ["text", "utilities"] - assert tool1.tool_call_template.call_template_type == "text" - finally: - Path(temp_file).unlink() - + """Register a manual from direct content and validate returned tools.""" + content = json.dumps(sample_utcp_manual) + manual_template = TextCallTemplate(name="test_manual", content=content) + result = await text_protocol.register_manual(mock_utcp_client, manual_template) -@pytest.mark.asyncio -async def test_register_manual_with_single_tool( - text_protocol: TextCommunicationProtocol, single_tool_definition, mock_utcp_client: Mock -): - """Register a manual with a single tool definition.""" - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - manual = { - "utcp_version": "1.0.0", - "manual_version": "1.0.0", - "tools": [single_tool_definition], - } - json.dump(manual, f) - temp_file = f.name + assert isinstance(result, RegisterManualResult) + assert result.success is True + assert result.errors == [] + assert result.manual is not None + assert len(result.manual.tools) == 2 - try: - manual_template = TextCallTemplate(name="single_tool_manual", file_path=temp_file) - result = await text_protocol.register_manual(mock_utcp_client, manual_template) + tool0 = result.manual.tools[0] + assert tool0.name == "calculator" + assert tool0.description == "Performs basic arithmetic operations" + assert tool0.tags == ["math", "arithmetic"] + assert tool0.tool_call_template.call_template_type == "text" - assert result.success is True - assert len(result.manual.tools) == 1 - tool = result.manual.tools[0] - assert tool.name == "echo" - assert tool.description == "Echoes back the input text" - assert tool.tags == ["utility"] - assert tool.tool_call_template.call_template_type == "text" - finally: - Path(temp_file).unlink() + tool1 = result.manual.tools[1] + assert tool1.name == "string_utils" + assert tool1.description == "String manipulation utilities" + assert tool1.tags == ["text", "utilities"] + assert tool1.tool_call_template.call_template_type == "text" @pytest.mark.asyncio -async def test_register_manual_with_tool_array( - text_protocol: TextCommunicationProtocol, tool_array, mock_utcp_client: Mock +async def test_register_manual_with_yaml_content( + text_protocol: TextCommunicationProtocol, mock_utcp_client: Mock ): - """Register a manual with an array of tool definitions.""" - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - manual = { - "utcp_version": "1.0.0", - "manual_version": "1.0.0", - "tools": tool_array, - } - json.dump(manual, f) - temp_file = f.name - - try: - manual_template = TextCallTemplate(name="tool_array_manual", file_path=temp_file) - result = await text_protocol.register_manual(mock_utcp_client, manual_template) + """Register a manual from YAML content.""" + yaml_content = """ +utcp_version: "1.0.0" +manual_version: "1.0.0" +tools: + - name: yaml_tool + description: A tool defined in YAML + inputs: + type: object + properties: {} + outputs: + type: object + properties: {} + tags: [] + tool_call_template: + call_template_type: text + content: "test" +""" + manual_template = TextCallTemplate(name="yaml_manual", content=yaml_content) + result = await text_protocol.register_manual(mock_utcp_client, manual_template) - assert result.success is True - assert len(result.manual.tools) == 2 - assert result.manual.tools[0].name == "tool1" - assert result.manual.tools[1].name == "tool2" - assert result.manual.tools[0].tool_call_template.call_template_type == "text" - assert result.manual.tools[1].tool_call_template.call_template_type == "text" - finally: - Path(temp_file).unlink() + assert result.success is True + assert len(result.manual.tools) == 1 + assert result.manual.tools[0].name == "yaml_tool" @pytest.mark.asyncio -async def test_register_manual_file_not_found( +async def test_register_manual_invalid_json( text_protocol: TextCommunicationProtocol, mock_utcp_client: Mock ): - """Registering a manual with a non-existent file should return errors.""" - manual_template = TextCallTemplate(name="missing", file_path="/path/that/does/not/exist.json") + """Registering a manual with invalid content should return errors.""" + manual_template = TextCallTemplate(name="invalid", content="{ invalid json content }") result = await text_protocol.register_manual(mock_utcp_client, manual_template) assert isinstance(result, RegisterManualResult) assert result.success is False assert result.errors -@pytest.mark.asyncio -async def test_register_manual_invalid_json( - text_protocol: TextCommunicationProtocol, mock_utcp_client: Mock -): - """Registering a manual with invalid JSON should return errors (no exception).""" - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - f.write("{ invalid json content }") - temp_file = f.name - - try: - manual_template = TextCallTemplate(name="invalid_json", file_path=temp_file) - result = await text_protocol.register_manual(mock_utcp_client, manual_template) - assert isinstance(result, RegisterManualResult) - assert result.success is False - assert result.errors - finally: - Path(temp_file).unlink() - - @pytest.mark.asyncio async def test_register_manual_wrong_call_template_type(text_protocol: TextCommunicationProtocol, mock_utcp_client: Mock): """Registering with a non-Text call template should raise ValueError.""" wrong_template = CallTemplate(call_template_type="invalid", name="wrong") with pytest.raises(ValueError, match="requires a TextCallTemplate"): - await text_protocol.register_manual(mock_utcp_client, wrong_template) # type: ignore[arg-type] + await text_protocol.register_manual(mock_utcp_client, wrong_template) @pytest.mark.asyncio -async def test_call_tool_returns_file_content( +async def test_call_tool_returns_content( text_protocol: TextCommunicationProtocol, sample_utcp_manual, mock_utcp_client: Mock ): - """Calling a tool returns the file content from the call template path.""" - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - json.dump(sample_utcp_manual, f) - temp_file = f.name - - try: - tool_template = TextCallTemplate(name="tool_call", file_path=temp_file) + """Calling a tool returns the content directly.""" + content = json.dumps(sample_utcp_manual) + tool_template = TextCallTemplate(name="tool_call", content=content) - # Call a tool should return the file content - content = await text_protocol.call_tool( - mock_utcp_client, "calculator", {"operation": "add", "a": 1, "b": 2}, tool_template - ) + # Call a tool should return the content directly + result = await text_protocol.call_tool( + mock_utcp_client, "calculator", {"operation": "add", "a": 1, "b": 2}, tool_template + ) - # Verify we get the JSON content back as a string - assert isinstance(content, str) - # Parse it back to verify it's the same content - parsed_content = json.loads(content) - assert parsed_content == sample_utcp_manual - finally: - Path(temp_file).unlink() + # Verify we get the content back as-is + assert isinstance(result, str) + assert result == content @pytest.mark.asyncio @@ -315,48 +195,29 @@ async def test_call_tool_wrong_call_template_type(text_protocol: TextCommunicati """Calling a tool with wrong call template type should raise ValueError.""" wrong_template = CallTemplate(call_template_type="invalid", name="wrong") with pytest.raises(ValueError, match="requires a TextCallTemplate"): - await text_protocol.call_tool(mock_utcp_client, "some_tool", {}, wrong_template) # type: ignore[arg-type] - - -@pytest.mark.asyncio -async def test_call_tool_file_not_found(text_protocol: TextCommunicationProtocol, mock_utcp_client: Mock): - """Calling a tool when the file doesn't exist should raise FileNotFoundError.""" - tool_template = TextCallTemplate(name="missing", file_path="/path/that/does/not/exist.json") - with pytest.raises(FileNotFoundError): - await text_protocol.call_tool(mock_utcp_client, "some_tool", {}, tool_template) + await text_protocol.call_tool(mock_utcp_client, "some_tool", {}, wrong_template) @pytest.mark.asyncio async def test_deregister_manual(text_protocol: TextCommunicationProtocol, sample_utcp_manual, mock_utcp_client: Mock): """Deregistering a manual should be a no-op (no errors).""" - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - json.dump(sample_utcp_manual, f) - temp_file = f.name - - try: - manual_template = TextCallTemplate(name="test_manual", file_path=temp_file) - await text_protocol.deregister_manual(mock_utcp_client, manual_template) - finally: - Path(temp_file).unlink() + content = json.dumps(sample_utcp_manual) + manual_template = TextCallTemplate(name="test_manual", content=content) + await text_protocol.deregister_manual(mock_utcp_client, manual_template) @pytest.mark.asyncio async def test_call_tool_streaming(text_protocol: TextCommunicationProtocol, sample_utcp_manual, mock_utcp_client: Mock): """Streaming call should yield a single chunk equal to non-streaming content.""" - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - json.dump(sample_utcp_manual, f) - temp_file = f.name - - try: - tool_template = TextCallTemplate(name="tool_call", file_path=temp_file) - # Non-streaming - content = await text_protocol.call_tool(mock_utcp_client, "calculator", {}, tool_template) - # Streaming - stream = text_protocol.call_tool_streaming(mock_utcp_client, "calculator", {}, tool_template) - chunks = [c async for c in stream] - assert chunks == [content] - finally: - Path(temp_file).unlink() + content = json.dumps(sample_utcp_manual) + tool_template = TextCallTemplate(name="tool_call", content=content) + + # Non-streaming + result = await text_protocol.call_tool(mock_utcp_client, "calculator", {}, tool_template) + # Streaming + stream = text_protocol.call_tool_streaming(mock_utcp_client, "calculator", {}, tool_template) + chunks = [c async for c in stream] + assert chunks == [result] @pytest.mark.asyncio @@ -366,12 +227,24 @@ async def test_text_call_template_with_auth_tools(): template = TextCallTemplate( name="test-template", - file_path="test.json", + content='{"test": true}', auth_tools=auth_tools ) assert template.auth_tools == auth_tools - assert template.auth is None # auth should still be None for file access + assert template.auth is None + + +@pytest.mark.asyncio +async def test_text_call_template_with_base_url(): + """Test that TextCallTemplate can be created with base_url.""" + template = TextCallTemplate( + name="test-template", + content='{"openapi": "3.0.0"}', + base_url="https://api.example.com/v1" + ) + + assert template.base_url == "https://api.example.com/v1" @pytest.mark.asyncio @@ -381,7 +254,7 @@ async def test_text_call_template_auth_tools_serialization(): template_dict = { "name": "test-template", "call_template_type": "text", - "file_path": "test.json", + "content": '{"test": true}', "auth_tools": { "auth_type": "api_key", "api_key": "test-key", diff --git a/plugins/communication_protocols/websocket/pyproject.toml b/plugins/communication_protocols/websocket/pyproject.toml index 5391418..09ce85c 100644 --- a/plugins/communication_protocols/websocket/pyproject.toml +++ b/plugins/communication_protocols/websocket/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "utcp-websocket" -version = "1.0.0" +version = "1.1.0" authors = [ { name = "UTCP Contributors" }, ] @@ -14,7 +14,7 @@ requires-python = ">=3.10" dependencies = [ "pydantic>=2.0", "aiohttp>=3.8", - "utcp>=1.0" + "utcp>=1.1" ] classifiers = [ "Development Status :: 4 - Beta",